feat: add marketplace client package store

This commit is contained in:
2026-08-28 16:10:15 +08:00
parent c73fcf1d2d
commit 4052fa85cc
5 changed files with 3071 additions and 0 deletions

View File

@@ -0,0 +1,182 @@
# Task: Marketplace client and Package Store (MLM-02)
## Identity
- Task ID: 20260828-plugin-marketplace-mlm02-9b7c4e1a
- Mode: Feature
- Branch: codex/20260828-plugin-marketplace-mlm02-9b7c4e1a-marketplace-mlm02
- Worktree: D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm02-9b7c4e1a
- Base commit: c73fcf1d2d2e5dccea6f3b403a3b7c00bdc0b25a
- Owner: codex
- Status: Ready for Integration
## Scope
- Implement ticket MLM-02 from the exact client coordinator frontier
`c73fcf1d2d2e5dccea6f3b403a3b7c00bdc0b25a` in this isolated worktree.
- Own only new `electron/coding-plugins/marketplace-client.ts`,
`electron/coding-plugins/package-store.ts`,
`electron/coding-plugins/account-plugin-cache.ts`, and focused Marketplace,
account-cache, download, and Package Store tests plus this task record.
- Consume the integrated MLM-01 parser/signature/trust primitives and frozen
WSM-03/WSM-04 Marketplace DTO contract; do not wire registry, resolver, Pi,
Host routes, Renderer, project state, server, Operations, or Release B.
## Intent And Constraints
- Main derives the current account from the existing session boundary; Renderer
never supplies account IDs, Bearer tokens, admissions, or package paths.
- Parse closed catalog/detail/Library/resolve/download DTOs with bounded request
body/deadline handling, composite ETag and stale metadata, and exactly one
authenticated 401 refresh/retry. Preserve a stable resolve request identity
for one logical sync across retry/restart; changed logical requests receive a
new identity.
- Package installation is a controlled temporary-file/extraction transaction:
verify descriptor, code-owned key, Ed25519 signature, size, SHA-256, exact
manifest, plugin/version/client range, then atomically switch the immutable
release directory and index. Every interruption or failure preserves the old
release/index.
- Account Library and Release Admission projections are session/account-scoped
and never enter shared `index.json`; logout/account switch invalidates them.
Cleanup must respect active workers and every account snapshot, with no token,
project, TP, or arbitrary Renderer path in the shared index.
- Stable automatic update and explicit beta selection are separate; no hot swap
for current workers. Production official-key absence remains an activation hold;
no private key, test key, environment trust override, or Release B runtime is
added.
## Project Context Loaded
Read:
- `C:\Users\7brot\.agents\skills\implement-spec\SKILL.md`
- `C:\Users\7brot\.codex\skills\maintain-project-docs\SKILL.md`
- `AGENTS.md`, `.project-docs/05-agent-entry/read-before-planning.md`,
`planning-gate.md`, and `read-before-coding.md`
- `.project-docs/05-agent-entry/memory-index.md`,
`.project-docs/00-brief/project-positioning.md`,
`.project-docs/30-worklog/current-state.md`, this task record,
`.project-docs/10-decisions/decision-index.md`,
`.project-docs/20-architecture/system-overview.md`, `module-map.md`,
`data-flow.md`, `.project-docs/40-domain/business-rules.md`, `glossary.md`,
`.project-docs/50-evidence/evidence-index.md`,
`.project-docs/60-reflection/reflection-index.md`,
`.project-docs/80-commitments/commitments.md`, and
`.project-docs/90-maintenance/stale-items.md`
- implementation spec `2026-08-28-makelore-plugin-marketplace-implementation-spec.md`
§§36, 7.27.3, 8.1, 10.110.3, 13.213.3 and 16; ticket graph MLM-02;
accepted design sections 6, 8.28.3, 9.1, 11.211.5, 1415, 18.3, and
19.220; and the frozen server DTO/API source at `f9c41bd...`.
- Peer records for MLM-01, the client coordinator, WSM-03, and WSM-04 (read-only).
Relevant understanding:
- The project is an Electron Main/Renderer client; Main owns authentication,
filesystem, network, and package trust while Renderer consumes safe projections.
- Marketplace Release A separates publication, Library acquisition, device
installation, project enablement, Agent assignment, runtime authorization, and
billing. Acquire/download/install/enable/assignment must not write Token Points.
- The exact server contract has anonymous catalog/detail, authenticated Library,
digest-idempotent stable/beta resolve, account-bound expiring admissions, and
admission-authorized download grants/content. `removed_at` tombstones remain in
Library snapshots; `system_included` Data Service bypasses Library/admission.
- MLM-01 is integrated at the dispatch base and provides exact schema-v1/v2
parsing, descriptor bytes, SemVer checks, Ed25519 verification, and a code-owned
fail-closed trust store. The official production public key is not supplied.
- Current canonical memory predates Marketplace; the committed spec/design,
frozen server DTOs, exact frontier, and peer task outcomes are authoritative.
Task context:
- Task ID: `20260828-plugin-marketplace-mlm02-9b7c4e1a`
- Mode: Feature
- Branch: `codex/20260828-plugin-marketplace-mlm02-9b7c4e1a-marketplace-mlm02`
- Worktree: `D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm02-9b7c4e1a`
- Base commit: `c73fcf1d2d2e5dccea6f3b403a3b7c00bdc0b25a`
- Other active local tasks: client coordinator and completed MLM-01 peer;
separate server/Operations WSM-03/WSM-04 peers own disjoint files.
- Overlap/semantic assessment: no unresolved conflict. MLM-01 owns parser/trust;
MLM-02 owns only the three Main modules and focused tests; MLM-03 owns resolver,
routes, Pi/lifecycle. WSM-03/04 DTOs are frozen read-only inputs.
Gate result:
- Concurrent Task Gate: Passed. `check_project_docs.py` passed; `task_context.py
start` created the isolated owner worktree, and `status --json` matches this
task ID, owner, feature mode, absolute worktree, branch, and exact base.
- Planning Gate: Passed. Required memory, spec/ticket/design, frozen server DTO,
and peer scopes were read; no unresolved semantic conflict or ownership overlap
blocks this plan. Coordinator and user root worktrees remain untouched.
## Implementation Plan
1. Inspect the existing Main HTTP/session, safe file/archive, package parser, and
test seams without editing; write red focused contract tests for bounded DTO
parsing, auth refresh/retry, stable request identity, account invalidation, and
atomic Package Store failure preservation.
2. Implement the smallest cohesive authenticated MarketplaceClient with closed
response parsers, cache validators/stale state, session-derived account, one
401 retry, and logical resolve identity management.
3. Implement account cache and Package Store with controlled temp paths, exact
MLM-01 descriptor/signature/manifest verification, atomic immutable index
switching, account/worker-aware cleanup, and stable-vs-beta update policy.
4. Run focused tests and interruption/account/path regression matrices, typecheck,
scoped/full lint, proportionate build, then task-aware diff/doc gates. Complete
the task and make exactly one clean source commit with sole parent the exact
dispatch base.
## Outcome
- Implemented MLM-02 from the exact client frontier. Main now owns closed
Marketplace catalog/detail/Library/resolve/download parsing, bounded requests,
composite cache metadata, stale projections, one-refresh authentication retry,
and stable logical resolve identity.
- Added an in-memory account/epoch cache and a device-only immutable Package Store
with controlled temporary extraction, schema-2 Skill-only manifest checks,
descriptor/client-range/size/SHA/Ed25519 verification, atomic release-directory
and index replacement, old-release preservation, stable-versus-explicit-beta
selection, and account/worker-aware cleanup hooks.
- Scope remains limited to the three new Main modules, focused tests, and this
task record. No registry, effective resolver, Pi, Host route, Renderer,
project-file, server, Operations, Token Point, hosted-runtime, or Release B
code was changed. The official production public key is not present, so
production activation remains an explicit hold; tests use ephemeral injected
keys only and contain no production private key.
## Verification
- `corepack pnpm install --frozen-lockfile`: passed; no dependency or lockfile
change.
- Initial focused red checkpoint: collection failed because the new owned
`account-plugin-cache` module was absent; no pre-existing implementation was
accepted as a fallback.
- `corepack pnpm exec vitest run tests/unit/coding-plugin-marketplace-client.test.ts
--maxWorkers=1`: 14 passed.
- `corepack pnpm exec vitest run tests/unit/coding-plugin-manifest.test.ts
tests/unit/coding-plugin-marketplace-contract.test.ts
tests/unit/coding-plugin-marketplace-client.test.ts --maxWorkers=1`: 43 passed.
- `corepack pnpm run typecheck`: passed.
- Owned ESLint over the three Main modules and focused test: passed.
- `corepack pnpm run lint:check`: 0 errors, 5 pre-existing warnings in
`src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`.
- `corepack pnpm run build:vite`: passed; only existing dynamic-import and chunk
size warnings.
- Full Vitest: 1,727 passed, 2 skipped; the separate coding-chat pressure test
passed 1/1.
- Focused failure matrix covers 401 refresh/retry, bounded/closed DTO parsing,
detail release projection, stable resolve identity, account A/B cache
isolation, download interruption, bad signature, invalid ZIP extraction,
index replacement failure, incompatible client, account switch, and path
containment; old release/index retention is asserted for the covered install
failure points.
- `git diff --check`: passed; exactly the five owned paths are changed.
- Task-aware `check_doc_drift.py`: passed. `task_context.py complete` returned
`READY_FOR_INTEGRATION` for this task and exact base/worktree/branch.
## Follow-ups
- Production activation remains blocked until the official Ed25519 public key is
supplied through the code-owned trust store and the corresponding production
private key is available only from deployment secret management.
- MLM-03 may consume these Main modules for the effective resolver and local
routes; no runtime integration is included here.
## Promotion Candidates
- None. No canonical project-memory change is proposed.

View 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;

File diff suppressed because it is too large Load Diff

View 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');
}
}

View File

@@ -0,0 +1,619 @@
// @vitest-environment node
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import path from 'node:path';
import AdmZip from 'adm-zip';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
AccountPluginCache,
type AccountBinding,
type MarketplaceLibrarySnapshot,
} from '../../electron/coding-plugins/account-plugin-cache';
import {
MarketplaceClientError,
createMarketplaceClient,
type MarketplaceClient,
type ResolveRequest,
} from '../../electron/coding-plugins/marketplace-client';
import {
PluginPackageStore,
type DownloadGrant,
type ResolveSnapshot,
} from '../../electron/coding-plugins/package-store';
import {
buildPluginReleaseDescriptor,
serializePluginReleaseDescriptor,
} from '../../electron/coding-plugins/release-descriptor';
const ACCOUNT_A: AccountBinding = { accountKey: 'a'.repeat(64), epoch: 1 };
const ACCOUNT_B: AccountBinding = { accountKey: 'b'.repeat(64), epoch: 2 };
const PLUGIN_ID = 'makelore.example';
const RELEASE_ID = 'release-1';
const ADMISSION_ID = 'admission-1';
const SHA256 = 'a'.repeat(64);
const catalogPage = {
items: [{
plugin_id: PLUGIN_ID,
title: 'Example',
summary: 'Example Skill',
category: 'tools',
tags: ['example'],
provider_display_name: 'MakeLore',
runtime_kind: 'skill_only',
runtime_status: 'enabled',
acquisition: 'free',
usage_billing: 'included',
included_operation_count: 0,
metered_operation_count: 0,
stable_version: '1.0.0',
beta_version: null,
}],
next_cursor: null,
total: 1,
catalog_generation: 7,
};
function response(body: unknown, init: ResponseInit = {}, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json', ...headers },
...init,
});
}
function requestBody(fetcher: ReturnType<typeof vi.fn>, index: number): Record<string, unknown> {
return JSON.parse(fetcher.mock.calls[index]?.[1]?.body as string) as Record<string, unknown>;
}
function makeResolveResult(input: ResolveRequest, itemOverrides: Record<string, unknown> = {}): ResolveSnapshot {
return {
resolveRequestId: input.resolveRequestId ?? 'generated',
resolveRequestDigest: input.resolveRequestDigest ?? SHA256,
items: [{
pluginId: PLUGIN_ID,
action: 'install',
releaseId: RELEASE_ID,
version: '1.0.0',
sha256: SHA256,
sizeBytes: 1,
releaseAdmissionId: ADMISSION_ID,
expiresAt: '2026-08-29T00:00:00Z',
channel: input.channel,
reason: null,
...itemOverrides,
}],
catalogGeneration: 7,
etag: '"plugins-7-tp-none"',
stale: false,
};
}
function buildSkillOnlyArchive(): Buffer {
const zip = new AdmZip();
zip.addFile('plugin.json', Buffer.from(JSON.stringify({
$schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
name: PLUGIN_ID,
version: '1.0.0',
description: 'Example Skill',
author: { name: 'MakeLore' },
extensions: { 'com.makelore': { capabilityManifest: './com.makelore/capability.json' } },
})));
zip.addFile('com.makelore/capability.json', Buffer.from(JSON.stringify({
schemaVersion: 2,
pluginId: PLUGIN_ID,
contractVersion: 1,
scope: 'project',
runtime: { kind: 'skill_only' },
skills: [{ id: 'example-skill', entry: '../skills/example-skill/SKILL.md', grants: [] }],
tools: [],
})));
zip.addFile('skills/example-skill/SKILL.md', Buffer.from('# Example\n'));
return zip.toBuffer();
}
function signedGrant(
archive: Buffer,
options: {
readonly releaseId?: string;
readonly minMakeloreVersion?: string;
readonly maxMakeloreVersion?: string | null;
} = {},
): { grant: DownloadGrant; publicKey: Buffer; signature: string } {
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const sha256 = createHash('sha256').update(archive).digest('hex');
const releaseId = options.releaseId ?? RELEASE_ID;
const descriptor = buildPluginReleaseDescriptor({
pluginId: PLUGIN_ID,
version: '1.0.0',
packageSchemaVersion: 2,
contractVersion: 1,
minMakeloreVersion: options.minMakeloreVersion ?? '1.0.0',
maxMakeloreVersion: options.maxMakeloreVersion ?? null,
artifact: { sha256, sizeBytes: archive.byteLength },
});
const signature = sign(null, serializePluginReleaseDescriptor(descriptor), privateKey).toString('base64url');
return {
grant: {
releaseAdmissionId: ADMISSION_ID,
releaseId,
pluginId: PLUGIN_ID,
version: '1.0.0',
packageSchemaVersion: 2,
contractVersion: 1,
minMakeloreVersion: options.minMakeloreVersion ?? '1.0.0',
maxMakeloreVersion: options.maxMakeloreVersion ?? null,
sizeBytes: archive.byteLength,
sha256,
signingKeyId: 'test-key',
descriptorSignature: signature,
expiresAt: '2026-08-29T00:00:00Z',
contentUrl: `/api/plugin-marketplace/v1/releases/${releaseId}/content?release_admission_id=${ADMISSION_ID}`,
},
publicKey: publicKey.export({ type: 'spki', format: 'der' }) as Buffer,
signature,
};
}
describe('Marketplace client and account cache', () => {
afterEach(() => vi.restoreAllMocks());
it('keeps Library and admission snapshots isolated by account and invalidates on logout', () => {
const cache = new AccountPluginCache();
const aLibrary: MarketplaceLibrarySnapshot = {
items: [{
pluginId: PLUGIN_ID,
title: 'A',
summary: 'A',
category: 'tools',
acquisition: 'free',
acquisitionMode: 'user_acquired',
catalogStatus: 'active',
runtimeStatus: 'enabled',
acquiredAt: null,
removedAt: null,
stableVersion: '1.0.0',
betaVersion: null,
}],
total: 1,
stale: false,
fetchedAt: 1,
};
cache.setLibrary(ACCOUNT_A, aLibrary);
cache.setResolve(ACCOUNT_A, 'resolve-a', {
resolveRequestId: 'resolve-a',
resolveRequestDigest: SHA256,
items: [{ pluginId: PLUGIN_ID, action: 'install', releaseId: RELEASE_ID }],
catalogGeneration: 1,
etag: null,
stale: false,
});
expect(cache.getLibrary(ACCOUNT_A)).toEqual(aLibrary);
expect(cache.getLibrary(ACCOUNT_B)).toBeNull();
expect(cache.getResolve(ACCOUNT_B, 'resolve-a')).toBeNull();
expect(cache.referencedReleaseIds()).toEqual(new Set([RELEASE_ID]));
cache.invalidateAll();
expect(cache.getLibrary(ACCOUNT_A)).toBeNull();
expect(cache.referencedReleaseIds()).toEqual(new Set());
});
it('parses bounded catalog metadata, refreshes exactly once after a 401, and marks stale data', async () => {
const fetcher = vi.fn<typeof fetch>();
fetcher
.mockResolvedValueOnce(new Response('', { status: 401 }))
.mockResolvedValueOnce(response(catalogPage, {}, {
ETag: '"plugins-7-tp-none"',
'X-Plugin-Catalog-Generation': '7',
'X-Token-Point-Pricing-Version': 'none',
}));
const refresh = vi.fn(async () => 'refreshed-token');
const client = createMarketplaceClient({
fetchImpl: fetcher,
apiBaseUrl: 'https://square.example',
getAccessToken: async (options) => options?.forceRefresh ? refresh() : 'initial-token',
getAccountBinding: () => ACCOUNT_A,
subscribeSession: () => () => undefined,
});
await expect(client.readCatalog({ limit: 10 })).resolves.toMatchObject({
total: 1,
etag: '"plugins-7-tp-none"',
stale: false,
});
expect(fetcher).toHaveBeenCalledTimes(2);
expect(refresh).toHaveBeenCalledTimes(1);
expect(fetcher.mock.calls[1]?.[1]?.headers).toMatchObject({ Authorization: 'Bearer refreshed-token' });
fetcher.mockRejectedValueOnce(new Error('offline'));
await expect(client.readCatalog({ limit: 10 })).resolves.toMatchObject({ stale: true, total: 1 });
});
it('refreshes download authentication at most once before accepting the artifact', async () => {
const archive = Buffer.from('signed-artifact');
const { grant } = signedGrant(archive);
const fetcher = vi.fn<typeof fetch>()
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(new Response(archive, {
status: 200,
headers: { 'content-length': String(archive.byteLength) },
}));
const refresh = vi.fn(async () => 'refreshed-token');
const client = createMarketplaceClient({
fetchImpl: fetcher,
apiBaseUrl: 'https://square.example',
getAccessToken: async (options) => options?.forceRefresh ? refresh() : 'initial-token',
getAccountBinding: () => ACCOUNT_A,
subscribeSession: () => () => undefined,
});
const downloaded = await client.downloadContent(grant);
expect(Buffer.from(downloaded)).toEqual(archive);
expect(fetcher).toHaveBeenCalledTimes(2);
expect(refresh).toHaveBeenCalledTimes(1);
expect(fetcher.mock.calls[1]?.[1]?.headers).toMatchObject({ Authorization: 'Bearer refreshed-token' });
});
it('parses the detail DTO with its nested release projection', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(response({
...catalogPage.items[0],
description_markdown: 'Example details',
permissions: ['plugin.example.read'],
operations: [],
stable_release: null,
beta_release: null,
}, {}, {
ETag: '"plugins-7-tp-none"',
'X-Plugin-Catalog-Generation': '7',
'X-Token-Point-Pricing-Version': 'none',
}));
const client = createMarketplaceClient({
fetchImpl: fetcher,
apiBaseUrl: 'https://square.example',
getAccessToken: async () => null,
subscribeSession: () => () => undefined,
});
await expect(client.readDetail(PLUGIN_ID)).resolves.toMatchObject({
pluginId: PLUGIN_ID,
descriptionMarkdown: 'Example details',
permissions: ['plugin.example.read'],
etag: '"plugins-7-tp-none"',
});
});
it('derives stable resolve identity from one logical request and changes it when installed state changes', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_input, init) => {
const request = JSON.parse(init?.body as string) as Record<string, unknown>;
return response({
resolve_request_id: request.resolve_request_id,
resolve_request_digest: request.resolve_request_digest,
items: [],
catalog_generation: 7,
}, {}, { ETag: '"plugins-7-tp-none"' });
});
const client = createMarketplaceClient({
fetchImpl: fetcher,
apiBaseUrl: 'https://square.example',
getAccessToken: async () => 'token',
getAccountBinding: () => ACCOUNT_A,
subscribeSession: () => () => undefined,
});
const base: ResolveRequest = {
makeloreVersion: '1.0.0',
channel: 'stable',
installed: [],
};
await client.resolve(base);
await client.resolve(base);
const firstId = requestBody(fetcher, 0).resolve_request_id;
const secondId = requestBody(fetcher, 1).resolve_request_id;
expect(firstId).toBe(secondId);
await client.resolve({ ...base, installed: [{ pluginId: PLUGIN_ID, releaseId: RELEASE_ID, sha256: SHA256 }] });
expect(requestBody(fetcher, 2).resolve_request_id).not.toBe(firstId);
});
it('rejects a response body above the bounded DTO limit', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response('x'.repeat(2_100_000), { status: 200 }));
const client = createMarketplaceClient({
fetchImpl: fetcher,
apiBaseUrl: 'https://square.example',
getAccessToken: async () => null,
subscribeSession: () => () => undefined,
});
await expect(client.readCatalog({ limit: 10 })).rejects.toMatchObject({ code: 'marketplace_response_too_large' });
});
it('rejects a malformed authenticated response with a stable client error', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(response({ items: [] }));
const client = createMarketplaceClient({
fetchImpl: fetcher,
apiBaseUrl: 'https://square.example',
getAccessToken: async () => 'token',
getAccountBinding: () => ACCOUNT_A,
subscribeSession: () => () => undefined,
});
await expect(client.readLibrary()).rejects.toBeInstanceOf(MarketplaceClientError);
});
});
describe('PluginPackageStore', () => {
let temporaryRoot: string | null = null;
afterEach(async () => {
if (temporaryRoot) await rm(temporaryRoot, { recursive: true, force: true });
temporaryRoot = null;
});
it('verifies and atomically installs a signed Skill-only package without account data in index', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const { grant, publicKey } = signedGrant(archive);
const resolve = vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
sha256: grant.sha256,
sizeBytes: grant.sizeBytes,
}));
const issueDownload = vi.fn(async () => grant);
const marketplace: MarketplaceClient = {
resolve,
issueDownload,
downloadContent: async () => archive,
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
clientVersion: '1.0.0',
keyStore: new Map([['test-key', publicKey]]),
accountCache: new AccountPluginCache(),
getAccountBinding: () => ACCOUNT_A,
});
await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }))
.resolves.toMatchObject({ pluginId: PLUGIN_ID, releaseId: RELEASE_ID, status: 'installed' });
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({
pluginId: PLUGIN_ID,
releaseId: RELEASE_ID,
version: '1.0.0',
});
const index = JSON.parse(await readFile(path.join(temporaryRoot, 'index.json'), 'utf8')) as Record<string, unknown>;
expect(JSON.stringify(index)).not.toContain('account');
expect(JSON.stringify(index)).not.toContain('admission');
expect(JSON.stringify(index)).not.toContain('token');
expect(resolve).toHaveBeenCalledTimes(1);
expect(issueDownload).toHaveBeenCalledWith({ releaseId: RELEASE_ID, releaseAdmissionId: ADMISSION_ID });
});
it('preserves the old immutable release when index replacement fails', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const { grant, publicKey } = signedGrant(archive);
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
sha256: grant.sha256,
sizeBytes: grant.sizeBytes,
})),
issueDownload: vi.fn(async () => grant),
downloadContent: async () => archive,
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
clientVersion: '1.0.0',
keyStore: new Map([['test-key', publicKey]]),
getAccountBinding: () => ACCOUNT_A,
});
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
const replacement = signedGrant(archive, { releaseId: 'release-2' });
const replacementMarketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
releaseId: replacement.grant.releaseId,
sha256: replacement.grant.sha256,
sizeBytes: replacement.grant.sizeBytes,
})),
issueDownload: vi.fn(async () => replacement.grant),
downloadContent: async () => archive,
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const failingStore = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace: replacementMarketplace,
clientVersion: '1.0.0',
keyStore: new Map([['test-key', replacement.publicKey]]),
getAccountBinding: () => ACCOUNT_A,
writeIndex: async () => { throw new Error('simulated index interruption'); },
});
await expect(failingStore.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }))
.rejects.toMatchObject({ code: 'plugin_install_failed' });
await expect(failingStore.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID });
});
it('preserves the old release across download, signature, and extraction failures', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const oldArchive = buildSkillOnlyArchive();
const old = signedGrant(oldArchive, { releaseId: RELEASE_ID });
const oldMarketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
sha256: old.grant.sha256,
sizeBytes: old.grant.sizeBytes,
})),
issueDownload: vi.fn(async () => old.grant),
downloadContent: async () => oldArchive,
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const oldStore = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace: oldMarketplace,
clientVersion: '1.0.0',
keyStore: new Map([['test-key', old.publicKey]]),
getAccountBinding: () => ACCOUNT_A,
});
await oldStore.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
const invalidArchive = Buffer.from('not a ZIP archive');
const cases = [
{
releaseId: 'release-download-failure',
expectedCode: 'plugin_install_failed',
artifact: oldArchive,
},
{
releaseId: 'release-signature-failure',
expectedCode: 'plugin_signature_invalid',
artifact: oldArchive,
},
{
releaseId: 'release-extraction-failure',
expectedCode: 'plugin_artifact_invalid',
artifact: invalidArchive,
},
] as const;
for (const [index, scenario] of cases.entries()) {
const signed = signedGrant(scenario.artifact, { releaseId: scenario.releaseId });
const grant = scenario.expectedCode === 'plugin_signature_invalid'
? { ...signed.grant, descriptorSignature: signed.grant.descriptorSignature[0] === 'A'
? `B${signed.grant.descriptorSignature.slice(1)}`
: `A${signed.grant.descriptorSignature.slice(1)}` }
: signed.grant;
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
releaseId: grant.releaseId,
sha256: grant.sha256,
sizeBytes: grant.sizeBytes,
})),
issueDownload: vi.fn(async () => grant),
downloadContent: async () => {
if (index === 0) throw new Error('simulated download interruption');
return scenario.artifact;
},
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
clientVersion: '1.0.0',
keyStore: new Map([['test-key', signed.publicKey]]),
getAccountBinding: () => ACCOUNT_A,
});
await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }))
.rejects.toMatchObject({ code: scenario.expectedCode });
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({
releaseId: RELEASE_ID,
version: '1.0.0',
});
await expect(store.readInstalledIndex()).resolves.toHaveLength(1);
}
});
it('rejects a Release outside the MakeLore client range before installation', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const { grant, publicKey } = signedGrant(archive, { maxMakeloreVersion: '1.5.0' });
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
sha256: grant.sha256,
sizeBytes: grant.sizeBytes,
})),
issueDownload: vi.fn(async () => grant),
downloadContent: async () => archive,
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
clientVersion: '2.0.0',
keyStore: new Map([['test-key', publicKey]]),
getAccountBinding: () => ACCOUNT_A,
});
await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '2.0.0' }))
.rejects.toMatchObject({ code: 'plugin_incompatible_client' });
await expect(store.readInstalledIndex()).resolves.toEqual([]);
});
it('aborts an in-flight install when the Main account changes', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const { grant, publicKey } = signedGrant(archive);
let binding: AccountBinding | null = ACCOUNT_A;
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
sha256: grant.sha256,
sizeBytes: grant.sizeBytes,
})),
issueDownload: vi.fn(async () => grant),
downloadContent: vi.fn(async () => {
binding = ACCOUNT_B;
return archive;
}),
getCurrentAccountBinding: () => binding,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
keyStore: new Map([['test-key', publicKey]]),
getAccountBinding: () => binding,
});
await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }))
.rejects.toMatchObject({ code: 'plugin_account_changed' });
await expect(store.readInstalledIndex()).resolves.toEqual([]);
});
it('rejects archive traversal before materializing a package', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const zip = new AdmZip();
zip.addFile('C:/outside.txt', Buffer.from('outside'));
const archive = zip.toBuffer();
const { grant, publicKey } = signedGrant(archive);
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
sha256: grant.sha256,
sizeBytes: grant.sizeBytes,
})),
issueDownload: vi.fn(async () => grant),
downloadContent: async () => archive,
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
keyStore: new Map([['test-key', publicKey]]),
getAccountBinding: () => ACCOUNT_A,
});
await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }))
.rejects.toMatchObject({ code: 'plugin_artifact_invalid' });
await expect(store.readInstalledIndex()).resolves.toEqual([]);
});
it('requires explicit beta selection and rejects content paths outside the server route', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(response({
release_admission_id: ADMISSION_ID,
release_id: RELEASE_ID,
plugin_id: PLUGIN_ID,
version: '1.0.0',
package_schema_version: 2,
contract_version: 1,
min_makelore_version: '1.0.0',
max_makelore_version: null,
size_bytes: 1,
sha256: SHA256,
signing_key_id: 'test-key',
descriptor_signature: 'a'.repeat(86),
expires_at: '2026-08-29T00:00:00Z',
content_url: 'https://evil.example/archive.zip',
}));
const client = createMarketplaceClient({
fetchImpl: fetcher,
apiBaseUrl: 'https://square.example',
getAccessToken: async () => 'token',
getAccountBinding: () => ACCOUNT_A,
subscribeSession: () => () => undefined,
});
await expect(client.issueDownload({ releaseId: RELEASE_ID, releaseAdmissionId: ADMISSION_ID }))
.rejects.toMatchObject({ code: 'marketplace_response_invalid' });
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input)),
issueDownload: vi.fn(),
} as MarketplaceClient;
const store = new PluginPackageStore({ rootDir: await mkdtemp(path.join(process.cwd(), '.marketplace-test-')), marketplace });
await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, channel: 'beta', makeloreVersion: '1.0.0' }))
.rejects.toMatchObject({ code: 'plugin_beta_selection_required' });
});
});