fix(marketplace): close client review findings
This commit is contained in:
@@ -52,6 +52,8 @@ const DEFAULT_MAX_ARCHIVE_BYTES = 16 * 1024 * 1024;
|
||||
const DEFAULT_MAX_EXTRACTED_BYTES = 32 * 1024 * 1024;
|
||||
const DEFAULT_MAX_FILES = 256;
|
||||
const DEFAULT_MAX_FILE_BYTES = 4 * 1024 * 1024;
|
||||
const PLUGIN_MANIFEST_MAX_BYTES = 256 * 1024;
|
||||
const PLUGIN_SKILL_MAX_BYTES = 256 * 1024;
|
||||
const ORPHAN_ARCHIVE_FILE = '.makelore-release.zip';
|
||||
const MAX_PLUGIN_ID = 128;
|
||||
const MAX_RELEASE_ID = 128;
|
||||
@@ -76,6 +78,12 @@ const INDEX_RELEASE_KEYS = new Set([
|
||||
'max_makelore_version',
|
||||
]);
|
||||
const CURRENT_SELECTION_ROOT_KEYS = new Set(['schema_version', 'current']);
|
||||
const PACKAGE_MANIFEST_PATHS = new Set(['plugin.json', 'com.makelore/capability.json']);
|
||||
const PACKAGE_TEXT_EXTENSIONS = new Set([
|
||||
'.css', '.html', '.json', '.md', '.markdown', '.mustache', '.hbs', '.ini',
|
||||
'.jinja', '.jinja2', '.template', '.txt', '.toml', '.tmpl', '.xml', '.yaml', '.yml',
|
||||
]);
|
||||
const PACKAGE_IMAGE_EXTENSIONS = new Set(['.jpeg', '.jpg', '.png', '.svg', '.webp']);
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
@@ -445,6 +453,55 @@ function entryIsSymlink(entry: AdmZip.IZipEntry): boolean {
|
||||
return (mode & 0xf000) === 0xa000;
|
||||
}
|
||||
|
||||
function assertUtf8(content: Uint8Array, filePath: string): void {
|
||||
try {
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(content);
|
||||
} catch {
|
||||
fail('plugin_artifact_invalid', `${filePath} must be UTF-8`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateArchiveFile(filePath: string, content: Uint8Array): void {
|
||||
if (PACKAGE_MANIFEST_PATHS.has(filePath)) {
|
||||
if (content.byteLength > PLUGIN_MANIFEST_MAX_BYTES) {
|
||||
fail('plugin_artifact_invalid', `${filePath} exceeds its byte limit`);
|
||||
}
|
||||
assertUtf8(content, filePath);
|
||||
return;
|
||||
}
|
||||
if (!filePath.startsWith('skills/')) {
|
||||
fail('plugin_artifact_invalid', 'package files must be under a declared Skill');
|
||||
}
|
||||
const extension = path.posix.extname(filePath).toLowerCase();
|
||||
if (!PACKAGE_TEXT_EXTENSIONS.has(extension) && !PACKAGE_IMAGE_EXTENSIONS.has(extension)) {
|
||||
fail('plugin_artifact_invalid', 'archive asset extension is not supported');
|
||||
}
|
||||
if (filePath.endsWith('/SKILL.md') && content.byteLength > PLUGIN_SKILL_MAX_BYTES) {
|
||||
fail('plugin_artifact_invalid', 'Skill exceeds its byte limit');
|
||||
}
|
||||
if (PACKAGE_TEXT_EXTENSIONS.has(extension)) assertUtf8(content, filePath);
|
||||
}
|
||||
|
||||
function validateArchiveDefinition(
|
||||
files: readonly string[],
|
||||
definition: CodingPluginDefinition,
|
||||
): void {
|
||||
for (const manifestPath of PACKAGE_MANIFEST_PATHS) {
|
||||
if (!files.includes(manifestPath)) fail('plugin_artifact_invalid', `archive is missing ${manifestPath}`);
|
||||
}
|
||||
const skillIds = new Set(definition.skills.map(({ id }) => id));
|
||||
for (const filePath of files) {
|
||||
if (PACKAGE_MANIFEST_PATHS.has(filePath)) continue;
|
||||
const [, skillId] = filePath.split('/');
|
||||
if (!skillId || !skillIds.has(skillId)) {
|
||||
fail('plugin_artifact_invalid', 'archive file is outside a declared Skill');
|
||||
}
|
||||
}
|
||||
for (const { entryPath } of definition.skills) {
|
||||
if (!files.includes(entryPath)) fail('plugin_artifact_invalid', 'archive is missing a declared Skill entry');
|
||||
}
|
||||
}
|
||||
|
||||
async function extractArchive(
|
||||
bytes: Uint8Array,
|
||||
destination: string,
|
||||
@@ -453,7 +510,7 @@ async function extractArchive(
|
||||
readonly maxFiles: number;
|
||||
readonly maxFileBytes: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
): Promise<readonly string[]> {
|
||||
let archive: AdmZip;
|
||||
try {
|
||||
archive = new AdmZip(toBuffer(bytes));
|
||||
@@ -465,6 +522,7 @@ async function extractArchive(
|
||||
fail('plugin_artifact_invalid', 'artifact file count is outside its bound');
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const files: string[] = [];
|
||||
let extractedBytes = 0;
|
||||
for (const entry of entries) {
|
||||
if (entryIsSymlink(entry)) fail('plugin_artifact_invalid', 'archive symlinks are not supported');
|
||||
@@ -496,6 +554,8 @@ async function extractArchive(
|
||||
if (content.byteLength !== declaredSize || content.byteLength > options.maxFileBytes) {
|
||||
fail('plugin_artifact_invalid', 'archive entry size is invalid');
|
||||
}
|
||||
validateArchiveFile(relative, content);
|
||||
files.push(relative);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
try {
|
||||
await writeFile(target, content, { flag: 'wx' });
|
||||
@@ -503,6 +563,7 @@ async function extractArchive(
|
||||
fail('plugin_artifact_invalid', 'archive extraction could not create a file');
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function mapVerificationFailure(code: PluginSignatureFailureCode): PluginPackageStoreErrorCode {
|
||||
@@ -610,8 +671,9 @@ export class PluginPackageStore {
|
||||
}
|
||||
|
||||
async syncLibrary(): Promise<MarketplaceLibrarySnapshot> {
|
||||
const binding = this.requireBinding();
|
||||
return this.withOperation(async () => {
|
||||
const binding = this.requireBinding();
|
||||
this.assertBinding(binding);
|
||||
if (!this.marketplace.readLibrary) fail('plugin_install_failed', 'Marketplace Library is unavailable');
|
||||
const snapshot = await this.marketplace.readLibrary();
|
||||
this.assertBinding(binding);
|
||||
@@ -621,7 +683,15 @@ export class PluginPackageStore {
|
||||
}
|
||||
|
||||
async resolveAndInstall(input: ResolveInstallInput): Promise<InstallationSnapshot> {
|
||||
return this.withOperation(() => this.resolveAndInstallLocked(input));
|
||||
validPluginId(input.pluginId);
|
||||
if (input.channel !== undefined && input.channel !== 'stable' && input.channel !== 'beta') {
|
||||
fail('plugin_release_unavailable', 'invalid Marketplace channel');
|
||||
}
|
||||
if ((input.channel ?? 'stable') === 'beta' && input.explicitBeta !== true) {
|
||||
fail('plugin_beta_selection_required');
|
||||
}
|
||||
const binding = this.requireBinding();
|
||||
return this.withOperation(() => this.resolveAndInstallLocked(input, binding));
|
||||
}
|
||||
|
||||
async getInstalled(pluginId: string): Promise<InstalledRelease | null> {
|
||||
@@ -633,17 +703,18 @@ export class PluginPackageStore {
|
||||
|
||||
async removeUnused(pluginId: string): Promise<InstallationSnapshot> {
|
||||
const validated = validPluginId(pluginId);
|
||||
return this.withOperation(() => this.removeUnusedLocked(validated));
|
||||
return this.withOperation(() => this.removeUnusedLocked(validated, undefined, 'background'));
|
||||
}
|
||||
|
||||
/** Remove a device package while leaving the account Library projection intact. */
|
||||
async uninstall(pluginId: string): Promise<InstallationSnapshot> {
|
||||
const validated = validPluginId(pluginId);
|
||||
const binding = this.requireBinding();
|
||||
return this.withOperation(async () => {
|
||||
const binding = this.requireBinding();
|
||||
this.assertBinding(binding);
|
||||
this.accountCache.invalidatePlugin(binding, validated);
|
||||
this.assertBinding(binding);
|
||||
return this.removeUnusedLocked(validated);
|
||||
return this.removeUnusedLocked(validated, binding, 'explicit');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -660,14 +731,17 @@ export class PluginPackageStore {
|
||||
return clone(index.releases);
|
||||
}
|
||||
|
||||
private async resolveAndInstallLocked(input: ResolveInstallInput): Promise<InstallationSnapshot> {
|
||||
private async resolveAndInstallLocked(
|
||||
input: ResolveInstallInput,
|
||||
binding: AccountBinding,
|
||||
): Promise<InstallationSnapshot> {
|
||||
const pluginId = validPluginId(input.pluginId);
|
||||
if (input.channel !== undefined && input.channel !== 'stable' && input.channel !== 'beta') {
|
||||
fail('plugin_release_unavailable', 'invalid Marketplace channel');
|
||||
}
|
||||
const channel = input.channel ?? 'stable';
|
||||
if (channel === 'beta' && input.explicitBeta !== true) fail('plugin_beta_selection_required');
|
||||
const binding = this.requireBinding();
|
||||
this.assertBinding(binding);
|
||||
const index = await this.readIndex();
|
||||
const current = await this.getInstalledFromIndex(
|
||||
index,
|
||||
@@ -713,6 +787,7 @@ export class PluginPackageStore {
|
||||
}
|
||||
if (item.action === 'keep') {
|
||||
if (!current) fail('plugin_release_unavailable', 'resolve requested keep without an installed Release');
|
||||
this.assertBinding(binding);
|
||||
return {
|
||||
status: 'kept',
|
||||
pluginId,
|
||||
@@ -738,6 +813,7 @@ 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 && !existing.unavailableReason) {
|
||||
this.assertBinding(binding);
|
||||
await this.setCurrentSelection(
|
||||
await this.readCurrentSelection(),
|
||||
pluginId,
|
||||
@@ -801,12 +877,13 @@ export class PluginPackageStore {
|
||||
const extractedPath = path.join(staging, 'package');
|
||||
await writeFile(archivePath, toBuffer(artifact), { flag: 'wx' });
|
||||
await mkdir(extractedPath, { recursive: true });
|
||||
await extractArchive(artifact, extractedPath, {
|
||||
const archiveFiles = await extractArchive(artifact, extractedPath, {
|
||||
maxExtractedBytes: this.maxExtractedBytes,
|
||||
maxFiles: this.maxFiles,
|
||||
maxFileBytes: this.maxFileBytes,
|
||||
});
|
||||
const definition = await this.loadDefinition(extractedPath, grant, descriptor);
|
||||
validateArchiveDefinition(archiveFiles, definition);
|
||||
this.assertBinding(binding);
|
||||
await mkdir(path.dirname(packageRoot), { recursive: true });
|
||||
if (await isDirectoryPath(packageRoot) || await pathExists(packageRoot)) {
|
||||
@@ -817,12 +894,15 @@ export class PluginPackageStore {
|
||||
moved = true;
|
||||
const record = this.installedRecord(grant, channel);
|
||||
const records = index.releases.filter((candidate) => !(candidate.pluginId === pluginId && candidate.releaseId === grant.releaseId));
|
||||
this.assertBinding(binding);
|
||||
try {
|
||||
await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: [...records, record] }));
|
||||
} catch {
|
||||
throw new PluginPackageStoreError('plugin_install_failed', 'package index replacement failed');
|
||||
}
|
||||
this.assertBinding(binding);
|
||||
await this.setCurrentSelection(await this.readCurrentSelection(), pluginId, record.releaseId);
|
||||
this.assertBinding(binding);
|
||||
return {
|
||||
status: 'installed',
|
||||
pluginId,
|
||||
@@ -869,6 +949,7 @@ export class PluginPackageStore {
|
||||
const definition = await this.loadDefinition(input.packageRoot, input.grant, descriptor);
|
||||
this.assertBinding(input.binding);
|
||||
const record = this.installedRecord(input.grant, input.channel);
|
||||
this.assertBinding(input.binding);
|
||||
try {
|
||||
await this.writeIndex(this.indexPath, serializeIndex({
|
||||
schema_version: INDEX_SCHEMA_VERSION,
|
||||
@@ -877,7 +958,9 @@ export class PluginPackageStore {
|
||||
} catch {
|
||||
throw new PluginPackageStoreError('plugin_install_failed', 'package index replacement failed');
|
||||
}
|
||||
this.assertBinding(input.binding);
|
||||
await this.setCurrentSelection(input.currentSelection, input.pluginId, record.releaseId);
|
||||
this.assertBinding(input.binding);
|
||||
return {
|
||||
status: 'installed',
|
||||
pluginId: input.pluginId,
|
||||
@@ -933,12 +1016,18 @@ export class PluginPackageStore {
|
||||
});
|
||||
}
|
||||
|
||||
private async removeUnusedLocked(validated: string): Promise<InstallationSnapshot> {
|
||||
private async removeUnusedLocked(
|
||||
validated: string,
|
||||
binding?: AccountBinding,
|
||||
mode: 'background' | 'explicit' = 'background',
|
||||
): Promise<InstallationSnapshot> {
|
||||
if (binding) this.assertBinding(binding);
|
||||
const index = await this.readIndex();
|
||||
const currentSelection = await this.readCurrentSelection();
|
||||
const records = index.releases.filter((record) => record.pluginId === validated);
|
||||
if (records.length === 0) {
|
||||
if (currentSelection.current[validated] !== undefined) {
|
||||
if (binding) this.assertBinding(binding);
|
||||
const current = { ...currentSelection.current };
|
||||
delete current[validated];
|
||||
await this.writeCurrentSelection({
|
||||
@@ -951,21 +1040,36 @@ export class PluginPackageStore {
|
||||
const selected = currentSelection.current[validated]
|
||||
? records.find((record) => record.releaseId === currentSelection.current[validated])
|
||||
: undefined;
|
||||
if (!selected) {
|
||||
return {
|
||||
status: 'kept',
|
||||
pluginId: validated,
|
||||
reason: 'current_selection_missing',
|
||||
};
|
||||
}
|
||||
const protectedIds = new Set([
|
||||
...this.accountCache.referencedReleaseIds(),
|
||||
...(this.activeWorkerReleaseIds() ?? []),
|
||||
...this.activeWorkers,
|
||||
]);
|
||||
const removable = selected && records.length > 1
|
||||
? records.filter((record) => record.releaseId !== selected.releaseId && !protectedIds.has(record.releaseId))
|
||||
: records.filter((record) => !protectedIds.has(record.releaseId));
|
||||
const removable = mode === 'explicit'
|
||||
? records.filter((record) => !protectedIds.has(record.releaseId))
|
||||
: records.length === 1 && !protectedIds.has(selected.releaseId)
|
||||
? records
|
||||
: records.filter((record) => (
|
||||
record.releaseId !== selected.releaseId && !protectedIds.has(record.releaseId)
|
||||
));
|
||||
if (removable.length === 0) {
|
||||
const reported = selected ?? records.find((record) => protectedIds.has(record.releaseId));
|
||||
return reported
|
||||
? { status: 'kept', pluginId: validated, releaseId: reported.releaseId, version: reported.version }
|
||||
: { status: 'kept', pluginId: validated, reason: 'current_selection_missing' };
|
||||
return {
|
||||
status: 'kept',
|
||||
pluginId: validated,
|
||||
releaseId: selected.releaseId,
|
||||
version: selected.version,
|
||||
...(selected.channel === undefined ? {} : { channel: selected.channel }),
|
||||
};
|
||||
}
|
||||
const remaining = index.releases.filter((record) => !removable.includes(record));
|
||||
if (binding) this.assertBinding(binding);
|
||||
try {
|
||||
await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: remaining }));
|
||||
} catch {
|
||||
@@ -979,15 +1083,21 @@ export class PluginPackageStore {
|
||||
schema_version: CURRENT_SELECTION_SCHEMA_VERSION,
|
||||
current: Object.freeze(nextCurrent),
|
||||
});
|
||||
if (binding) this.assertBinding(binding);
|
||||
await Promise.all(removable.map(async (record) => {
|
||||
await rm(this.releaseDirectory(record), { recursive: true, force: true });
|
||||
}));
|
||||
if (binding) this.assertBinding(binding);
|
||||
const currentAfter = nextCurrent[validated]
|
||||
? remaining.find((record) => record.pluginId === validated && record.releaseId === nextCurrent[validated])
|
||||
: undefined;
|
||||
const selectedAfter = selected && remaining.some((record) => (
|
||||
record.pluginId === validated && record.releaseId === selected.releaseId
|
||||
)) ? selected : undefined;
|
||||
if (mode === 'explicit' && !currentAfter
|
||||
&& remaining.some((record) => record.pluginId === validated && protectedIds.has(record.releaseId))) {
|
||||
return { status: 'kept', pluginId: validated, reason: 'active_worker_reference' };
|
||||
}
|
||||
const reportedAfter = currentAfter ?? selectedAfter ?? remaining.find((record) => record.pluginId === validated);
|
||||
if (reportedAfter) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user