fix: close marketplace client review findings
This commit is contained in:
@@ -175,6 +175,21 @@ export class AccountPluginCache {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget device-resolution snapshots for one plugin in one account binding.
|
||||
* The Library projection is intentionally retained so uninstalling a device
|
||||
* package never becomes an account Library mutation.
|
||||
*/
|
||||
invalidatePlugin(binding: AccountBinding, pluginId: string): void {
|
||||
const record = this.records.get(bindingId(binding));
|
||||
if (!record) return;
|
||||
for (const [key, snapshot] of record.resolves.entries()) {
|
||||
const items = snapshot.items.filter((item) => item.pluginId !== pluginId);
|
||||
if (items.length === 0) record.resolves.delete(key);
|
||||
else if (items.length !== snapshot.items.length) record.resolves.set(key, { ...snapshot, items });
|
||||
}
|
||||
}
|
||||
|
||||
clearAccount(binding: AccountBinding): void {
|
||||
assertBinding(binding);
|
||||
for (const [key, record] of this.records.entries()) {
|
||||
|
||||
@@ -24,6 +24,8 @@ import type {
|
||||
export interface SkillEntry {
|
||||
readonly id: CodingSkillId;
|
||||
readonly entryPath: string;
|
||||
/** Main-verified package root for an installed Marketplace Skill. */
|
||||
readonly packageRoot?: string;
|
||||
}
|
||||
|
||||
/** A policy row copied from the last verified server catalog. */
|
||||
@@ -326,7 +328,13 @@ export class EffectivePluginResolver {
|
||||
if (definition.releaseId) pluginReleaseIds.push(definition.releaseId);
|
||||
for (const skill of selectedSkills) {
|
||||
effectiveSkillIds.push(skill.id);
|
||||
skillEntries.push({ id: skill.id, entryPath: skill.entryPath });
|
||||
skillEntries.push({
|
||||
id: skill.id,
|
||||
entryPath: skill.entryPath,
|
||||
...(definition.provenance.source === 'marketplace'
|
||||
? { packageRoot: definition.provenance.packageRoot }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
if (!definition.requiresBackend) continue;
|
||||
for (const operation of definition.operations) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getWorksSquareAccountBinding,
|
||||
subscribeWorksSquareSession,
|
||||
} from '../services/works-square-session';
|
||||
import { proxyAwareFetch, fetchWithDeadline } from '../utils/proxy-fetch';
|
||||
import { proxyAwareFetch, runWithDeadline } from '../utils/proxy-fetch';
|
||||
import {
|
||||
AccountPluginCache,
|
||||
type AccountBinding,
|
||||
@@ -202,7 +202,17 @@ export type MarketplaceErrorCode =
|
||||
| 'marketplace_response_invalid'
|
||||
| 'marketplace_response_too_large'
|
||||
| 'marketplace_download_invalid'
|
||||
| 'marketplace_beta_selection_required';
|
||||
| 'marketplace_beta_selection_required'
|
||||
| 'plugin_auth_required'
|
||||
| 'plugin_account_changed'
|
||||
| 'plugin_library_required'
|
||||
| 'plugin_release_not_ready'
|
||||
| 'plugin_release_yanked'
|
||||
| 'plugin_incompatible_client'
|
||||
| 'plugin_signature_invalid'
|
||||
| 'plugin_artifact_invalid'
|
||||
| 'plugin_runtime_suspended'
|
||||
| 'plugin_backend_unavailable';
|
||||
|
||||
export class MarketplaceClientError extends Error {
|
||||
constructor(
|
||||
@@ -236,6 +246,19 @@ function fail(code: MarketplaceErrorCode, message: string = code, status = 0): n
|
||||
throw new MarketplaceClientError(code, status, message);
|
||||
}
|
||||
|
||||
const SERVER_ERROR_CODES = new Set<MarketplaceErrorCode>([
|
||||
'plugin_auth_required',
|
||||
'plugin_account_changed',
|
||||
'plugin_library_required',
|
||||
'plugin_release_not_ready',
|
||||
'plugin_release_yanked',
|
||||
'plugin_incompatible_client',
|
||||
'plugin_signature_invalid',
|
||||
'plugin_artifact_invalid',
|
||||
'plugin_runtime_suspended',
|
||||
'plugin_backend_unavailable',
|
||||
]);
|
||||
|
||||
function exactKeys(
|
||||
value: UnknownRecord,
|
||||
required: readonly string[],
|
||||
@@ -586,8 +609,7 @@ async function readBoundedBytes(response: Response, maximum: number): Promise<Ui
|
||||
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
||||
}
|
||||
|
||||
async function readJson(response: Response, maximum: number): Promise<unknown> {
|
||||
const bytes = await readBoundedBytes(response, maximum);
|
||||
function parseJsonBytes(bytes: Uint8Array): unknown {
|
||||
let source: string;
|
||||
try {
|
||||
source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
@@ -602,6 +624,48 @@ async function readJson(response: Response, maximum: number): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
function failForResponse(
|
||||
response: Response,
|
||||
bytes: Uint8Array | null,
|
||||
fallbackMessage: string,
|
||||
): never {
|
||||
let code: MarketplaceErrorCode | null = null;
|
||||
let responseMessage = fallbackMessage;
|
||||
if (bytes && bytes.byteLength > 0) {
|
||||
try {
|
||||
const payload = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown;
|
||||
if (isRecord(payload)) {
|
||||
if (typeof payload.code === 'string' && SERVER_ERROR_CODES.has(payload.code as MarketplaceErrorCode)) {
|
||||
code = payload.code as MarketplaceErrorCode;
|
||||
}
|
||||
if (typeof payload.error === 'string' && payload.error.length > 0 && payload.error.length <= 256) {
|
||||
responseMessage = payload.error;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A malformed error body must stay a bounded generic client failure.
|
||||
}
|
||||
}
|
||||
fail(code ?? 'marketplace_request_failed', responseMessage, response.status);
|
||||
}
|
||||
|
||||
async function fetchResponseWithBodyDeadline(
|
||||
fetchImpl: FetchImplementation,
|
||||
input: string | URL,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
maximum: number,
|
||||
): Promise<{ response: Response; bytes: Uint8Array | null }> {
|
||||
return runWithDeadline(async (signal) => {
|
||||
const response = await fetchImpl(input, { ...init, signal });
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
return { response, bytes: null };
|
||||
}
|
||||
return { response, bytes: await readBoundedBytes(response, maximum) };
|
||||
}, timeoutMs, init.signal);
|
||||
}
|
||||
|
||||
function normalizedBase(value: string): string {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
@@ -955,7 +1019,7 @@ class MarketplaceClientImpl implements MarketplaceClient {
|
||||
let url = localUrl.toString();
|
||||
let sendAuthorization = true;
|
||||
for (let redirect = 0; redirect <= 3; redirect += 1) {
|
||||
const response = await fetchWithDeadline(
|
||||
const { response, bytes } = await fetchResponseWithBodyDeadline(
|
||||
this.fetchImpl as typeof fetch,
|
||||
url,
|
||||
{
|
||||
@@ -967,6 +1031,7 @@ class MarketplaceClientImpl implements MarketplaceClient {
|
||||
redirect: 'manual',
|
||||
},
|
||||
this.requestTimeoutMs,
|
||||
Math.min(this.maxArtifactBytes, grant.sizeBytes),
|
||||
);
|
||||
if (response.status === 401 && sendAuthorization && !refreshed) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
@@ -995,10 +1060,9 @@ class MarketplaceClientImpl implements MarketplaceClient {
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
fail(response.status === 401 ? 'marketplace_auth_required' : 'marketplace_request_failed', 'Marketplace download failed', response.status);
|
||||
failForResponse(response, bytes, 'Marketplace download failed');
|
||||
}
|
||||
const bytes = await readBoundedBytes(response, Math.min(this.maxArtifactBytes, grant.sizeBytes));
|
||||
if (!bytes) fail('marketplace_download_invalid', 'Marketplace response body is unavailable');
|
||||
if (bytes.byteLength !== grant.sizeBytes) fail('marketplace_download_invalid', 'Marketplace artifact size does not match its grant');
|
||||
this.assertBinding(binding);
|
||||
return bytes;
|
||||
@@ -1083,11 +1147,12 @@ class MarketplaceClientImpl implements MarketplaceClient {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.etag ? { 'If-None-Match': options.etag } : {}),
|
||||
};
|
||||
const response = await fetchWithDeadline(
|
||||
const { response, bytes } = await fetchResponseWithBodyDeadline(
|
||||
this.fetchImpl as typeof fetch,
|
||||
url,
|
||||
{ method, headers, body, redirect: 'manual' },
|
||||
this.requestTimeoutMs,
|
||||
this.maxResponseBytes,
|
||||
);
|
||||
if (response.status === 401 && !refreshed && token) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
@@ -1102,10 +1167,10 @@ class MarketplaceClientImpl implements MarketplaceClient {
|
||||
return { status: response.status, headers: response.headers, value: null, notModified: true };
|
||||
}
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
fail(response.status === 401 ? 'marketplace_auth_required' : 'marketplace_request_failed', 'Marketplace request failed', response.status);
|
||||
failForResponse(response, bytes, 'Marketplace request failed');
|
||||
}
|
||||
const value = parser(await readJson(response, this.maxResponseBytes));
|
||||
if (!bytes) fail('marketplace_response_invalid', 'Marketplace response body is unavailable');
|
||||
const value = parser(parseJsonBytes(bytes));
|
||||
if (binding) this.assertBinding(binding);
|
||||
return { status: response.status, headers: response.headers, value, notModified: false };
|
||||
}
|
||||
|
||||
@@ -46,10 +46,12 @@ import type {
|
||||
import type { PluginSigningKeyStore } from './trusted-keys';
|
||||
|
||||
const INDEX_SCHEMA_VERSION = 1;
|
||||
const CURRENT_SELECTION_SCHEMA_VERSION = 1;
|
||||
const DEFAULT_MAX_ARCHIVE_BYTES = 16 * 1024 * 1024;
|
||||
const DEFAULT_MAX_EXTRACTED_BYTES = 32 * 1024 * 1024;
|
||||
const DEFAULT_MAX_FILES = 256;
|
||||
const DEFAULT_MAX_FILE_BYTES = 4 * 1024 * 1024;
|
||||
const ORPHAN_ARCHIVE_FILE = '.makelore-release.zip';
|
||||
const MAX_PLUGIN_ID = 128;
|
||||
const MAX_RELEASE_ID = 128;
|
||||
const MAX_VERSION = 128;
|
||||
@@ -69,6 +71,7 @@ const INDEX_RELEASE_KEYS = new Set([
|
||||
'size_bytes',
|
||||
'installed_at',
|
||||
]);
|
||||
const CURRENT_SELECTION_ROOT_KEYS = new Set(['schema_version', 'current']);
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
@@ -154,6 +157,11 @@ export type PluginPackageStoreErrorCode =
|
||||
| 'plugin_manifest_invalid'
|
||||
| 'plugin_runtime_not_supported'
|
||||
| 'plugin_release_conflict'
|
||||
| 'plugin_release_not_ready'
|
||||
| 'plugin_release_yanked'
|
||||
| 'plugin_runtime_suspended'
|
||||
| 'plugin_library_required'
|
||||
| 'plugin_backend_unavailable'
|
||||
| 'plugin_install_failed';
|
||||
|
||||
export class PluginPackageStoreError extends Error {
|
||||
@@ -171,6 +179,11 @@ interface IndexDocument {
|
||||
readonly releases: readonly InstalledReleaseRecord[];
|
||||
}
|
||||
|
||||
interface CurrentSelectionDocument {
|
||||
readonly schema_version: typeof CURRENT_SELECTION_SCHEMA_VERSION;
|
||||
readonly current: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -325,6 +338,33 @@ function serializeIndex(document: IndexDocument): Uint8Array {
|
||||
})}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function parseCurrentSelectionDocument(value: unknown): CurrentSelectionDocument {
|
||||
const root = isRecord(value) ? value : fail('plugin_store_index_invalid', 'current selection root must be an object');
|
||||
assertExactKeys(root, CURRENT_SELECTION_ROOT_KEYS, 'current selection');
|
||||
if (root.schema_version !== CURRENT_SELECTION_SCHEMA_VERSION) {
|
||||
fail('plugin_store_index_invalid', 'unsupported current selection schema');
|
||||
}
|
||||
if (!isRecord(root.current) || Object.keys(root.current).length > DEFAULT_MAX_FILES) {
|
||||
fail('plugin_store_index_invalid', 'invalid current selection');
|
||||
}
|
||||
const current: Record<string, string> = {};
|
||||
for (const [pluginId, releaseId] of Object.entries(root.current)) {
|
||||
const validId = validPluginId(pluginId, 'current plugin_id', 'plugin_store_index_invalid');
|
||||
current[validId] = validReleaseId(releaseId, `current ${validId}.release_id`, 'plugin_store_index_invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schema_version: CURRENT_SELECTION_SCHEMA_VERSION,
|
||||
current: Object.freeze(current),
|
||||
});
|
||||
}
|
||||
|
||||
function serializeCurrentSelection(document: CurrentSelectionDocument): Uint8Array {
|
||||
return Buffer.from(`${JSON.stringify({
|
||||
schema_version: document.schema_version,
|
||||
current: document.current,
|
||||
})}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function parseJsonFile(filePath: string): Promise<unknown> {
|
||||
const bytes = await readFile(filePath);
|
||||
let source: string;
|
||||
@@ -482,6 +522,7 @@ export class PluginPackageStore {
|
||||
private readonly rootDir: string;
|
||||
private readonly packagesDir: string;
|
||||
private readonly indexPath: string;
|
||||
private readonly currentSelectionPath: string;
|
||||
private readonly marketplace: MarketplacePackageClientPort;
|
||||
private readonly accountCache: AccountPluginCache;
|
||||
private readonly getAccountBindingImpl: () => AccountBinding | null;
|
||||
@@ -502,6 +543,7 @@ export class PluginPackageStore {
|
||||
this.rootDir = path.resolve(options.rootDir ?? path.join(getDataDir(), 'coding-plugins'));
|
||||
this.packagesDir = path.join(this.rootDir, 'packages');
|
||||
this.indexPath = path.join(this.rootDir, 'index.json');
|
||||
this.currentSelectionPath = path.join(this.rootDir, 'current.json');
|
||||
this.marketplace = options.marketplace;
|
||||
this.accountCache = options.accountCache ?? new AccountPluginCache();
|
||||
this.getAccountBindingImpl = options.getAccountBinding
|
||||
@@ -554,37 +596,23 @@ export class PluginPackageStore {
|
||||
async getInstalled(pluginId: string): Promise<InstalledRelease | null> {
|
||||
const validated = validPluginId(pluginId);
|
||||
const index = await this.readIndex();
|
||||
return this.getInstalledFromIndex(index, validated);
|
||||
const current = await this.readCurrentSelection();
|
||||
return this.getInstalledFromIndex(index, validated, undefined, current);
|
||||
}
|
||||
|
||||
async removeUnused(pluginId: string): Promise<InstallationSnapshot> {
|
||||
const validated = validPluginId(pluginId);
|
||||
return this.withOperation(() => this.removeUnusedLocked(validated));
|
||||
}
|
||||
|
||||
/** Remove a device package while leaving the account Library projection intact. */
|
||||
async uninstall(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,
|
||||
]);
|
||||
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 };
|
||||
const binding = this.requireBinding();
|
||||
this.accountCache.invalidatePlugin(binding, validated);
|
||||
this.assertBinding(binding);
|
||||
return this.removeUnusedLocked(validated);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -610,7 +638,12 @@ export class PluginPackageStore {
|
||||
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 current = await this.getInstalledFromIndex(
|
||||
index,
|
||||
pluginId,
|
||||
undefined,
|
||||
await this.readCurrentSelection(),
|
||||
);
|
||||
const installed = input.installed === undefined
|
||||
? (current ? [toInstalledInput(current)] : [])
|
||||
: input.installed === null ? [] : [input.installed];
|
||||
@@ -633,7 +666,20 @@ export class PluginPackageStore {
|
||||
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 === 'unavailable') {
|
||||
const reason = item.reason;
|
||||
if (reason === 'plugin_release_not_ready'
|
||||
|| reason === 'plugin_release_yanked'
|
||||
|| reason === 'plugin_runtime_suspended'
|
||||
|| reason === 'plugin_library_required'
|
||||
|| reason === 'plugin_incompatible_client'
|
||||
|| reason === 'plugin_signature_invalid'
|
||||
|| reason === 'plugin_artifact_invalid'
|
||||
|| reason === 'plugin_backend_unavailable') {
|
||||
fail(reason, reason);
|
||||
}
|
||||
fail('plugin_release_unavailable', reason ?? 'Plugin Release is unavailable');
|
||||
}
|
||||
if (item.action === 'keep') {
|
||||
if (!current) fail('plugin_release_unavailable', 'resolve requested keep without an installed Release');
|
||||
return {
|
||||
@@ -660,6 +706,11 @@ export class PluginPackageStore {
|
||||
if (existingRecord && existingRecord.sha256 === item.sha256 && await isDirectoryPath(this.releaseDirectory(existingRecord))) {
|
||||
const existing = await this.getInstalledFromIndex(index, pluginId, item.releaseId);
|
||||
if (existing) {
|
||||
await this.setCurrentSelection(
|
||||
await this.readCurrentSelection(),
|
||||
pluginId,
|
||||
existing.releaseId,
|
||||
);
|
||||
return {
|
||||
status: 'kept',
|
||||
pluginId,
|
||||
@@ -679,6 +730,17 @@ export class PluginPackageStore {
|
||||
if (error instanceof PluginPackageStoreError) throw error;
|
||||
throw this.mapMarketplaceError(error);
|
||||
}
|
||||
const packageRoot = this.releaseDirectory({ pluginId, releaseId: grant.releaseId } as InstalledReleaseRecord);
|
||||
if (await pathExists(packageRoot)) {
|
||||
return this.recoverOrphanedRelease({
|
||||
index,
|
||||
currentSelection: await this.readCurrentSelection(),
|
||||
packageRoot,
|
||||
pluginId,
|
||||
grant,
|
||||
binding,
|
||||
});
|
||||
}
|
||||
let artifact: Uint8Array;
|
||||
try {
|
||||
artifact = await this.marketplace.downloadContent(grant);
|
||||
@@ -687,29 +749,7 @@ export class PluginPackageStore {
|
||||
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);
|
||||
const descriptor = this.verifyArtifact(artifact, grant);
|
||||
try {
|
||||
await mkdir(this.rootDir, { recursive: true });
|
||||
} catch {
|
||||
@@ -734,32 +774,21 @@ export class PluginPackageStore {
|
||||
});
|
||||
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 writeFile(path.join(extractedPath, ORPHAN_ARCHIVE_FILE), toBuffer(artifact), { flag: 'wx' });
|
||||
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 record = this.installedRecord(grant);
|
||||
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');
|
||||
}
|
||||
await this.setCurrentSelection(await this.readCurrentSelection(), pluginId, record.releaseId);
|
||||
return {
|
||||
status: 'installed',
|
||||
pluginId,
|
||||
@@ -782,6 +811,151 @@ export class PluginPackageStore {
|
||||
}
|
||||
}
|
||||
|
||||
private async recoverOrphanedRelease(input: {
|
||||
readonly index: IndexDocument;
|
||||
readonly currentSelection: CurrentSelectionDocument;
|
||||
readonly packageRoot: string;
|
||||
readonly pluginId: string;
|
||||
readonly grant: DownloadGrant;
|
||||
readonly binding: AccountBinding;
|
||||
}): Promise<InstallationSnapshot> {
|
||||
const existingRecord = input.index.releases.find((record) => (
|
||||
record.pluginId === input.pluginId && record.releaseId === input.grant.releaseId
|
||||
));
|
||||
if (existingRecord) fail('plugin_release_conflict', 'immutable Release directory already exists');
|
||||
let artifact: Uint8Array;
|
||||
try {
|
||||
artifact = await readFile(path.join(input.packageRoot, ORPHAN_ARCHIVE_FILE));
|
||||
} catch {
|
||||
fail('plugin_release_conflict', 'immutable Release directory cannot be recovered');
|
||||
}
|
||||
const descriptor = this.verifyArtifact(artifact, input.grant);
|
||||
const definition = await this.loadDefinition(input.packageRoot, input.grant, descriptor);
|
||||
this.assertBinding(input.binding);
|
||||
const record = this.installedRecord(input.grant);
|
||||
try {
|
||||
await this.writeIndex(this.indexPath, serializeIndex({
|
||||
schema_version: INDEX_SCHEMA_VERSION,
|
||||
releases: [...input.index.releases, record],
|
||||
}));
|
||||
} catch {
|
||||
throw new PluginPackageStoreError('plugin_install_failed', 'package index replacement failed');
|
||||
}
|
||||
await this.setCurrentSelection(input.currentSelection, input.pluginId, record.releaseId);
|
||||
return {
|
||||
status: 'installed',
|
||||
pluginId: input.pluginId,
|
||||
releaseId: record.releaseId,
|
||||
version: record.version,
|
||||
packageRoot: input.packageRoot,
|
||||
definition,
|
||||
};
|
||||
}
|
||||
|
||||
private verifyArtifact(artifact: Uint8Array, grant: DownloadGrant): PluginReleaseDescriptor {
|
||||
const expiresAt = typeof grant.expiresAt === 'string' ? Date.parse(grant.expiresAt) : Number.NaN;
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= this.now()) {
|
||||
fail('plugin_release_unavailable', 'download admission has expired');
|
||||
}
|
||||
if (!(artifact instanceof Uint8Array)) fail('plugin_artifact_invalid', 'downloaded artifact is not binary data');
|
||||
if (artifact.byteLength !== grant.sizeBytes || artifact.byteLength > this.maxArchiveBytes || digest(artifact) !== grant.sha256) {
|
||||
fail('plugin_artifact_invalid', 'downloaded artifact does not match its grant');
|
||||
}
|
||||
const descriptor = this.buildDescriptor(grant);
|
||||
if (!isMakeLoreVersionCompatible(
|
||||
this.clientVersion,
|
||||
descriptor.minMakeloreVersion,
|
||||
descriptor.maxMakeloreVersion,
|
||||
)) {
|
||||
fail('plugin_incompatible_client', 'Release is incompatible with this MakeLore client');
|
||||
}
|
||||
const verification = this.signatureVerifier.verify({
|
||||
keyId: grant.signingKeyId,
|
||||
signature: grant.descriptorSignature,
|
||||
descriptor,
|
||||
artifact,
|
||||
});
|
||||
if (!verification.ok) fail(mapVerificationFailure(verification.code), verification.message);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
private installedRecord(grant: DownloadGrant): InstalledReleaseRecord {
|
||||
return Object.freeze({
|
||||
pluginId: grant.pluginId,
|
||||
releaseId: grant.releaseId,
|
||||
version: grant.version,
|
||||
packageSchemaVersion: grant.packageSchemaVersion,
|
||||
contractVersion: grant.contractVersion,
|
||||
runtimeKind: 'skill_only',
|
||||
sha256: grant.sha256,
|
||||
sizeBytes: grant.sizeBytes,
|
||||
installedAt: new Date(this.now()).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private async removeUnusedLocked(validated: string): Promise<InstallationSnapshot> {
|
||||
const index = await this.readIndex();
|
||||
const currentSelection = await this.readCurrentSelection();
|
||||
const records = index.releases.filter((record) => record.pluginId === validated);
|
||||
if (records.length === 0) {
|
||||
if (currentSelection.current[validated] !== undefined) {
|
||||
const current = { ...currentSelection.current };
|
||||
delete current[validated];
|
||||
await this.writeCurrentSelection({
|
||||
schema_version: CURRENT_SELECTION_SCHEMA_VERSION,
|
||||
current: Object.freeze(current),
|
||||
});
|
||||
}
|
||||
return { status: 'removed', pluginId: validated, reason: 'none' };
|
||||
}
|
||||
const 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 selected = currentSelection.current[validated]
|
||||
? records.find((record) => record.releaseId === currentSelection.current[validated])
|
||||
: undefined;
|
||||
const reported = selected ?? latest;
|
||||
const protectedIds = new Set([
|
||||
...this.accountCache.referencedReleaseIds(),
|
||||
...(this.activeWorkerReleaseIds() ?? []),
|
||||
...this.activeWorkers,
|
||||
]);
|
||||
const removable = records.filter((record) => !protectedIds.has(record.releaseId));
|
||||
if (removable.length === 0) return { status: 'kept', pluginId: validated, releaseId: reported.releaseId, version: reported.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');
|
||||
}
|
||||
const nextCurrent = { ...currentSelection.current };
|
||||
if (nextCurrent[validated] && !remaining.some((record) => (
|
||||
record.pluginId === validated && record.releaseId === nextCurrent[validated]
|
||||
))) {
|
||||
const fallback = remaining
|
||||
.filter((record) => record.pluginId === validated)
|
||||
.map((record, index) => ({ record, index }))
|
||||
.sort((left, right) => right.record.installedAt.localeCompare(left.record.installedAt) || right.index - left.index)[0]
|
||||
?.record;
|
||||
if (fallback) nextCurrent[validated] = fallback.releaseId;
|
||||
else delete nextCurrent[validated];
|
||||
}
|
||||
await this.writeCurrentSelection({
|
||||
schema_version: CURRENT_SELECTION_SCHEMA_VERSION,
|
||||
current: Object.freeze(nextCurrent),
|
||||
});
|
||||
await Promise.all(removable.map(async (record) => {
|
||||
await rm(this.releaseDirectory(record), { recursive: true, force: true });
|
||||
}));
|
||||
const currentAfter = nextCurrent[validated]
|
||||
? remaining.find((record) => record.pluginId === validated && record.releaseId === nextCurrent[validated])
|
||||
: undefined;
|
||||
const reportedAfter = currentAfter ?? latest;
|
||||
return { status: 'removed', pluginId: validated, releaseId: reportedAfter.releaseId, version: reportedAfter.version };
|
||||
}
|
||||
|
||||
private buildDescriptor(grant: DownloadGrant): PluginReleaseDescriptor {
|
||||
try {
|
||||
return buildPluginReleaseDescriptor({
|
||||
@@ -850,6 +1024,39 @@ export class PluginPackageStore {
|
||||
}
|
||||
}
|
||||
|
||||
private async readCurrentSelection(): Promise<CurrentSelectionDocument> {
|
||||
try {
|
||||
return parseCurrentSelectionDocument(await parseJsonFile(this.currentSelectionPath));
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) {
|
||||
return { schema_version: CURRENT_SELECTION_SCHEMA_VERSION, current: Object.freeze({}) };
|
||||
}
|
||||
if (error instanceof PluginPackageStoreError) throw error;
|
||||
throw new PluginPackageStoreError('plugin_store_index_invalid', 'current package selection is unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
private async writeCurrentSelection(document: CurrentSelectionDocument): Promise<void> {
|
||||
try {
|
||||
await atomicWriteIndex(this.currentSelectionPath, serializeCurrentSelection(document));
|
||||
} catch {
|
||||
throw new PluginPackageStoreError('plugin_install_failed', 'package current selection replacement failed');
|
||||
}
|
||||
}
|
||||
|
||||
private async setCurrentSelection(
|
||||
currentSelection: CurrentSelectionDocument,
|
||||
pluginId: string,
|
||||
releaseId: string,
|
||||
): Promise<void> {
|
||||
if (currentSelection.current[pluginId] === releaseId) return;
|
||||
const current = { ...currentSelection.current, [pluginId]: releaseId };
|
||||
await this.writeCurrentSelection({
|
||||
schema_version: CURRENT_SELECTION_SCHEMA_VERSION,
|
||||
current: Object.freeze(current),
|
||||
});
|
||||
}
|
||||
|
||||
private releaseDirectory(record: Pick<InstalledReleaseRecord, 'pluginId' | 'releaseId'>): string {
|
||||
return path.join(this.packagesDir, record.pluginId, record.releaseId);
|
||||
}
|
||||
@@ -858,13 +1065,18 @@ export class PluginPackageStore {
|
||||
index: IndexDocument,
|
||||
pluginId: string,
|
||||
releaseId?: string,
|
||||
currentSelection?: CurrentSelectionDocument,
|
||||
): Promise<InstalledRelease | null> {
|
||||
const pointedReleaseId = releaseId === undefined ? currentSelection?.current[pluginId] : undefined;
|
||||
const records = index.releases
|
||||
.map((record, index) => ({ record, index }))
|
||||
.filter(({ record }) => record.pluginId === pluginId && (releaseId === undefined || record.releaseId === releaseId))
|
||||
.sort((left, right) => right.record.installedAt.localeCompare(left.record.installedAt) || right.index - left.index)
|
||||
.map(({ record }) => record);
|
||||
for (const record of records) {
|
||||
const selected = pointedReleaseId === undefined
|
||||
? records
|
||||
: records.filter((record) => record.releaseId === pointedReleaseId);
|
||||
for (const record of selected) {
|
||||
const packageRoot = this.releaseDirectory(record);
|
||||
if (!await isDirectoryPath(packageRoot)) continue;
|
||||
try {
|
||||
@@ -895,6 +1107,24 @@ export class PluginPackageStore {
|
||||
if (isRecord(error) && error.code === 'marketplace_beta_selection_required') {
|
||||
return new PluginPackageStoreError('plugin_beta_selection_required');
|
||||
}
|
||||
if (isRecord(error) && (
|
||||
error.code === 'plugin_release_yanked'
|
||||
|| error.code === 'plugin_release_not_ready'
|
||||
|| error.code === 'plugin_runtime_suspended'
|
||||
|| error.code === 'plugin_library_required'
|
||||
|| error.code === 'plugin_backend_unavailable'
|
||||
|| error.code === 'plugin_incompatible_client'
|
||||
|| error.code === 'plugin_signature_invalid'
|
||||
|| error.code === 'plugin_artifact_invalid'
|
||||
)) {
|
||||
return new PluginPackageStoreError(
|
||||
error.code,
|
||||
typeof error.message === 'string' ? error.message : error.code,
|
||||
);
|
||||
}
|
||||
if (isRecord(error) && error.code === 'plugin_auth_required') {
|
||||
return new PluginPackageStoreError('plugin_account_changed', 'Marketplace authentication is required');
|
||||
}
|
||||
return new PluginPackageStoreError('plugin_install_failed', 'Marketplace package operation failed');
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ export interface ResolvedWorkerResources {
|
||||
catalogRevision: number;
|
||||
pluginIds: readonly string[];
|
||||
effectiveSkillIds: readonly string[];
|
||||
skillEntries: readonly { id: string; entryPath: string }[];
|
||||
skillEntries: readonly { id: string; entryPath: string; packageRoot?: string }[];
|
||||
tools: readonly CodingPluginToolDefinition[];
|
||||
/** The exact Main-owned snapshot used to produce these legacy fields. */
|
||||
effectiveSnapshot?: EffectivePluginSnapshot;
|
||||
@@ -401,7 +401,11 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor
|
||||
catalogRevision: this.options.effectiveResolver.getPolicyState().revision,
|
||||
pluginIds: [...pluginIds],
|
||||
effectiveSkillIds: [...snapshot.effectiveSkillIds],
|
||||
skillEntries: snapshot.skillEntries.map(({ id, entryPath }) => ({ id, entryPath })),
|
||||
skillEntries: snapshot.skillEntries.map(({ id, entryPath, packageRoot }) => ({
|
||||
id,
|
||||
entryPath,
|
||||
...(packageRoot ? { packageRoot } : {}),
|
||||
})),
|
||||
tools: snapshot.toolDefinitions.map((tool) => structuredClone(tool)),
|
||||
effectiveSnapshot: snapshot,
|
||||
skillRoots: [...skillRoots],
|
||||
@@ -457,7 +461,13 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor
|
||||
if (selectedSkills.length === 0) continue;
|
||||
for (const skill of selectedSkills) {
|
||||
effectiveSkillIds.push(skill.id);
|
||||
skillEntries.push({ id: skill.id, entryPath: skill.entryPath });
|
||||
skillEntries.push({
|
||||
id: skill.id,
|
||||
entryPath: skill.entryPath,
|
||||
...(definition.provenance.source === 'marketplace'
|
||||
? { packageRoot: definition.provenance.packageRoot }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
for (const tool of definition.tools) {
|
||||
const granted = selectedSkills.some(({ grants }) => grants.includes(tool.capabilityId));
|
||||
|
||||
@@ -13,6 +13,7 @@ export const PLUGIN_SIGNING_KEY_ACTIVATION_HOLD = true as const;
|
||||
|
||||
export interface PluginSigningKeyStore {
|
||||
get(keyId: string): Uint8Array | string | null;
|
||||
readonly sourceMarker?: string;
|
||||
}
|
||||
|
||||
function decodeKey(value: string): Uint8Array | null {
|
||||
@@ -36,6 +37,7 @@ export function loadCodeOwnedPluginSigningKey(keyId: string): Uint8Array | null
|
||||
export function createCodeOwnedPluginTrustStore(): PluginSigningKeyStore {
|
||||
return Object.freeze({
|
||||
get: loadCodeOwnedPluginSigningKey,
|
||||
sourceMarker: 'makelore.plugin-trust.code-owned.v1',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user