feat: add marketplace client package store
This commit is contained in:
@@ -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`
|
||||
§§3–6, 7.2–7.3, 8.1, 10.1–10.3, 13.2–13.3 and 16; ticket graph MLM-02;
|
||||
accepted design sections 6, 8.2–8.3, 9.1, 11.2–11.5, 14–15, 18.3, and
|
||||
19.2–20; 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.
|
||||
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');
|
||||
}
|
||||
}
|
||||
619
tests/unit/coding-plugin-marketplace-client.test.ts
Normal file
619
tests/unit/coding-plugin-marketplace-client.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user