fix(marketplace): close client review findings

This commit is contained in:
2026-08-29 10:31:38 +08:00
parent 57962591de
commit 3df794c2e7
14 changed files with 840 additions and 68 deletions

View File

@@ -0,0 +1,201 @@
# Task: Marketplace MLM-06 R3 fixed-range remediation
## Identity
- Task ID: 20260828-marketplace-mlm06-r3-remediation-9e4c2a71
- Mode: Feature
- Branch: codex/20260828-marketplace-mlm06-r3-remediation-9e4c2a71-marketplace-mlm06-r3-remediation
- Worktree: D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm06-r3-remediation-9e4c2a71
- Base commit: 57962591de71fc0a9f142dbcd130e3308754e423
- Owner: marketplace-client-coordinator-r3-remediator
- Status: Ready for Integration
## Scope
- Sole MLM-06 R3 fixed-range remediation over the eight accepted actionable roots
handed off from exact client frontier
`57962591de71fc0a9f142dbcd130e3308754e423`. Product/test changes stay in this
isolated MakeLore worktree and are limited to the assigned resolver, Package
Store/archive validation, Renderer Marketplace store/My Plugins, project
projection, packaged artifact verifier, focused tests/E2E, and this task record.
- Preserve Marketplace Release A only. Data Service remains bundled,
system-included, typed, and zero Token Point; hosted Provider/Release B,
arbitrary local code/MCP/hooks, generic invoke/config/ledger, XMA-01,
deployment, publication, push, and PR creation are outside this task.
- Deliver one source commit containing this task record, with sole parent
`57962591de71fc0a9f142dbcd130e3308754e423`, a clean worktree, and
`READY_FOR_INTEGRATION` task-context completion.
## Intent And Constraints
- Human-confirmed recovery was required because the original implementation owner
stopped responding while leaving a known dirty worktree. The coordinator
interrupted that writer, ran `release --force`, reversibly backed up its untracked
task record, and successfully reclaimed this exact task/worktree with
`--adopt-existing`. The eight existing product/test paths were never stashed,
reset, cleaned, deleted, or moved. This owner is now the sole writer.
- Concurrent Task Gate passed after recovery: project-doc structure is valid and
task-context status matches this task ID, feature mode, owner, absolute worktree,
branch, and exact base. The coordinator, user root, server repository, and prior
review/source worktrees remain read-only.
- Planning Gate passed after reading the required project-memory startup set,
relevant peer records, implementation spec, ticket graph, accepted design, and
ADR-006. Canonical integrated memory predates Marketplace; the frozen spec/design,
coordinator ledger, and R3 fixed-range findings are the current authority.
- Implement test-first at each public seam. Do not add compatibility layers,
arbitrary prefixes, hidden request chaining, account/Library conflation, broad
archive-security frameworks, fabricated yanked fields, runtime key overrides, or
unrelated UI redesign. Explicit device uninstall remains distinct from Library
removal and background cleanup.
- Production Ed25519 activation remains HOLD. Tests may inject ephemeral keys only;
no production private key or arbitrary runtime trust source may be generated,
read, or committed.
## Project Context Loaded
- Task identity: `20260828-marketplace-mlm06-r3-remediation-9e4c2a71`, feature
mode, branch
`codex/20260828-marketplace-mlm06-r3-remediation-9e4c2a71-marketplace-mlm06-r3-remediation`,
worktree
`D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm06-r3-remediation-9e4c2a71`,
owner `marketplace-client-coordinator-r3-remediator`, exact base
`57962591de71fc0a9f142dbcd130e3308754e423`.
- Other active Marketplace owners are completed source/remediation tasks,
read-only fixed-range reviewers, or the coordinator. No owner has write authority
in this worktree and no unresolved semantic conflict changes the eight-root plan.
- Main owns account, package, trust, filesystem, and effective-runtime authority;
Renderer consumes bounded projections. Account Library, Device Installation,
Project Selection, Agent assignment, authorization, and billing remain separate.
- The accepted eight roots are: bundled Skill collision reservation; pre-queue
account binding; independent same-plugin Library/device intents; explicit
uninstall versus cleanup; reachable packaged markers; canonical archive
validation; `client_incompatible` Project Plugins projection; and no-version My
Plugins presentation.
- Gate result: Passed. Project positioning is still a placeholder and shared
integrated memory predates Marketplace, both explicitly recorded as stale context
rather than silently treated as current authority.
## Fixed-Range Ownership / Red Plan
1. Reserve every non-Marketplace bundled Skill ID, including Data Service, and
reject conflicting Marketplace resources without rewriting configuration.
2. Capture the current account binding before queued Package Store work and verify
it before execution and completion so A-originated work cannot execute as B.
3. Split same-plugin Renderer Library and device intents while retaining latest-only
ordering within each domain and merging independent successful results.
4. Separate explicit uninstall from background cleanup; remove all unprotected
current/old releases and report `kept` only for actual protections.
5. Bind Marketplace artifact proof to the real packaged `package.json.main`
reachability graph and reject disconnected marker strings.
6. Enforce the canonical archive allowlist, extension/UTF-8 rules, and 256 KiB
manifest/Skill limits before atomic install, without adding a generic framework.
7. Project `client_incompatible` as bounded Project Plugins unavailable state.
8. Render no-version unavailable installation projections as not installed and not
deletable; keep versioned incompatible packages present and removable.
## Implementation Plan
1. Review the preserved dirty diff against all eight accepted roots and existing
focused tests; retain correct work and identify still-missing paths without
rewriting completed slices.
2. For each missing root, add an exact red test, apply the smallest green change,
and run the focused public-boundary regression with a stated failure consequence.
3. Run affected Marketplace/Pi/Data Service regressions, full unit/pressure,
typecheck, lint, Vite build, Windows Electron, Marketplace E2E, x64 packaging,
and the real artifact verifier. Record exact baseline/environment deviations.
4. Update this record with the recovery and RED→GREEN ledger, run diff/doc gates,
create the single source commit with the exact parent, complete task_context, and
verify a clean handoff.
## Outcome
- Recovered and completed the preserved implementation without resetting, stashing,
cleaning, or replacing any predecessor work. The only discarded filesystem data
was the trace CLI's own temporary extraction directory after diagnosis.
- Reserved every bundled/non-Marketplace Skill owner, so a signed Marketplace
package can no longer shadow the bundled Data Service Skill or another package's
Skill root. Project and Agent assignments remain unchanged and regain effect if
the original package returns.
- Bound Package Store sync/install/uninstall operations to the account captured
before queueing, with execution- and completion-time rechecks. Explicit uninstall
now removes every unprotected Release, while background cleanup may retain the
selected current Release; account snapshots and active workers remain protected.
- Split Renderer Library and device mutation intent per plugin/domain. Independent
successful operations merge instead of one domain silently suppressing the other.
- Hardened Release A archive admission to the canonical Skill-only tree, server-
aligned text/image extensions, fatal UTF-8 manifests/text, declared Skill roots,
and 256 KiB manifest/Skill limits. Script/native payloads and undeclared assets
fail before the atomic install write.
- Project Plugins maps `client_incompatible` to the bounded unavailable state. My
Plugins treats a no-version unavailable projection as not downloaded and not
deletable, while an incompatible versioned package remains visible/removable.
- Packaged proof now walks only files reachable from `package.json.main`, including
Main imports/requires, minified static ESM imports, dynamic Renderer chunks, and
HTML assets. Disconnected marker strings cannot satisfy trust or Marketplace
coverage. Test ASAR creation waits for the returned WritableStream `finish`
signal, eliminating partial archive reads without adding a production retry.
- Stabilized the Marketplace Electron test at its real bootstrap boundary: the
initial module-selection screen must be ready before replacing Main's Host API
handler and reloading with persisted auth. This removes an old-page/new-page auth
initialization race without changing production authentication.
- No hosted Provider, charging/Token Point path, arbitrary code/MCP/hooks, generic
invoke/config/ledger, publication, deployment, XMA-01, production key material,
or server change was introduced.
## Verification
- Recovery baseline: the inherited focused set produced 43 passes / 15 failures.
Thirteen failures were an expired fixed Admission fixture; the remaining product
reds proved the missing archive rejection, Project `client_incompatible` state,
and no-version My Plugins action. Correcting the fixture and implementing the
owned seams turned those boundaries green.
- Explicit uninstall edge RED: when an old Release was protected but current was
not, the old implementation returned `kept` and retained current. The focused
regression is green after explicit/background cleanup separation.
- Direct final focused: 6 files / 73 tests passed.
- Marketplace/Pi/Project Plugins/Data Service adjacent regression: 28 files / 229
tests passed.
- Full unit run: 208 files / 1,799 passed / 2 skipped; dedicated pressure run 1/1
passed.
- `pnpm typecheck`: passed.
- `pnpm run lint:check`: 0 errors; exactly five pre-existing out-of-scope warnings
remain (`Home` one hook warning, `Makelore` four Fast Refresh warnings).
- `pnpm run build:vite`: passed (Renderer 2,261 modules; Main 193; Preload 1;
utility worker 9). Existing dynamic-import and chunk-size warnings remain.
- The first clean-source `pnpm run package:win` passed through Python/uv staging,
Pi runtime bundling, win-unpacked, and NSIS. The first real
`verify:artifact:pi` correctly went RED because Vite's minified
`import{...}from"..."` edge was not traversed and the reachable graph could not
prove Renderer catalog/Library markers. A matching realistic ASAR unit fixture
failed 1/13; after accepting minified static ESM syntax it passed 13/13 and the
same real app.asar verifier returned overall `result: pass`, Marketplace
`result: pass`, Data Service 10 tools, Pi 0.84.2 closure complete, and only the
inherited real-Provider/cross-platform `partial-pass` waivers.
- `pnpm run test:electron:windows`: 2 files / 6 tests passed.
- Marketplace E2E bootstrap diagnosis: the original focused scenario failed 3/5,
and the direct-localStorage hypothesis failed 4/5 and was reverted. Waiting for
initial Renderer bootstrap before handler replacement passed 5/5; the original
Marketplace + Project Plugins target then passed 3/3.
- Full Electron E2E: 28 passed / 1 known baseline failure. The sole failure remains
`tests/e2e/pi-coding-first-chat.spec.ts:575`, where the current-conversation model
combobox is disabled and `selectOption` times out; both Marketplace tests and the
Project Plugins test pass in the same run.
- `git diff --check`: passed before documentation finalization.
- After the source commit is amended with the minified-ESM regression, the client
coordinator reruns clean-HEAD `package:win`, `verify:artifact:pi`,
`verify:artifact:win`, and `verify:publish-runtime` so embedded Git evidence names
the final source. Exact hashes belong to the repository-local integration
checkpoint and remain a handoff condition.
## Follow-ups
- The client coordinator must integrate only the single source commit, record the
clean-HEAD Windows/package verifier ledger, then start fresh fixed-range R4
Standards and Spec reviews. XMA-01 stays closed until both repositories pass.
- Production Marketplace activation remains on hold until the official Ed25519
public key is supplied. Release B remains deferred until a named Provider contract
exists.
## Promotion Candidates
- None recorded.

View File

@@ -497,7 +497,8 @@ export function createCodingProjectPluginService(
if (effective && effectiveReason?.code === 'project_disabled') state = 'disabled';
else if (effective && !hasEffectiveSkill && effectiveReason
&& ['account_required', 'library_required', 'library_unavailable', 'release_not_installed',
'release_invalid', 'runtime_suspended', 'policy_unavailable', 'policy_unsupported']
'release_invalid', 'client_incompatible', 'runtime_suspended', 'policy_unavailable',
'policy_unsupported']
.includes(effectiveReason.code)) state = 'unavailable';
return {
id: definition.id,

View File

@@ -220,6 +220,10 @@ function validateBinding(value: AccountBinding | null): AccountBinding | null {
function marketplaceSkillConflicts(records: readonly DefinitionRecord[]): ReadonlySet<string> {
const blocked = new Set<string>();
const owned = new Set<string>(CORE_CODING_SKILL_IDS);
for (const { definition } of records) {
if (definition.provenance.source === 'marketplace') continue;
for (const { id } of definition.skills) owned.add(id);
}
for (const { definition } of records) {
if (definition.provenance.source !== 'marketplace') continue;
const conflicts = definition.skills.some(({ id }) => owned.has(id));

View File

@@ -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 {

View File

@@ -318,20 +318,22 @@ export function verifyMarketplaceClientArtifact(appAsarContents, productionTrust
}
/**
* Read the Marketplace trust source from the packaged application itself.
* Checkout sources are not evidence of what an installed app will trust.
* Read the package.main-reachable application graph from the packaged app.
* Checkout sources and unrelated app.asar files are not product evidence.
*/
export async function readPackagedMarketplaceTrustSource(appAsar) {
async function readPackagedApplicationGraph(appAsar) {
const entries = new Set(listPackage(appAsar, { isPack: false })
.map((entry) => entry.replace(/^[/\\]+/u, '').replaceAll('\\', '/')));
const readArchiveText = (entry) => Buffer.from(
extractFile(appAsar, entry.replaceAll('/', sep)),
).toString('utf8');
const readArchiveText = (entry) => {
if (!entries.has(entry)) throw new Error(`Packaged app.asar entry is missing: ${entry}`);
return Buffer.from(extractFile(appAsar, entry.replaceAll('/', sep))).toString('utf8');
};
let packageJson;
try {
packageJson = JSON.parse(readArchiveText('package.json'));
} catch {
throw new Error('Packaged app.asar package.json is unreadable');
} catch (error) {
const reason = error instanceof Error ? error.message : 'unknown error';
throw new Error(`Packaged app.asar package.json is unreadable: ${reason}`);
}
if (typeof packageJson.main !== 'string' || packageJson.main.trim().length === 0) {
throw new Error('Packaged app.asar package.json.main is missing');
@@ -348,9 +350,10 @@ export async function readPackagedMarketplaceTrustSource(appAsar) {
}
return result.join('/');
};
const resolveModule = (from, specifier) => {
const resolveEntry = (from, specifier) => {
if (!specifier.startsWith('.')) return null;
const base = normalizeAsarPath(`${dirname(from).replaceAll('\\', '/')}/${specifier}`);
const withoutSuffix = specifier.split(/[?#]/u, 1)[0];
const base = normalizeAsarPath(`${dirname(from).replaceAll('\\', '/')}/${withoutSuffix}`);
if (!base) return null;
const candidates = [base, `${base}.js`, `${base}.mjs`, `${base}.cjs`, `${base}/index.js`];
return candidates.find((candidate) => entries.has(candidate)) ?? null;
@@ -372,16 +375,28 @@ export async function readPackagedMarketplaceTrustSource(appAsar) {
}
reachable.push({ entry, source });
const specifiers = [];
const importPattern = /(?:import|export)\s+(?:[\s\S]*?\sfrom\s*)?['"]([^'"]+)['"]/gu;
const importPattern = /\b(?:import|export)(?:[^"'`;]*?\bfrom)?\s*['"]([^'"]+)['"]/gu;
const dynamicImportPattern = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/gu;
const requirePattern = /\brequire\(\s*['"]([^'"]+)['"]\s*\)/gu;
const htmlEntryPattern = /['"]([^'"]+\.html(?:[?#][^'"]*)?)['"]/gu;
const htmlAssetPattern = /\b(?:src|href)\s*=\s*['"]([^'"]+)['"]/gu;
for (const match of source.matchAll(importPattern)) specifiers.push(match[1]);
for (const match of source.matchAll(dynamicImportPattern)) specifiers.push(match[1]);
for (const match of source.matchAll(requirePattern)) specifiers.push(match[1]);
for (const match of source.matchAll(htmlEntryPattern)) specifiers.push(match[1]);
if (entry.endsWith('.html')) {
for (const match of source.matchAll(htmlAssetPattern)) specifiers.push(match[1]);
}
for (const specifier of specifiers) {
const resolved = resolveModule(entry, specifier);
const resolved = resolveEntry(entry, specifier);
if (resolved) pending.push(resolved);
}
}
return { packageJson, reachable };
}
function findPackagedMarketplaceTrustSource(reachable) {
for (const { source } of reachable) {
const trustAssignment = source.match(
/CODE_OWNED_PLUGIN_SIGNING_KEYS\s*=\s*Object\.freeze\(\s*\{\}\s*(?:as\s+[^)]*)?\)/u,
@@ -398,6 +413,31 @@ export async function readPackagedMarketplaceTrustSource(appAsar) {
throw new Error('Packaged app.asar does not contain the Marketplace trust source');
}
/**
* Read the Marketplace trust source from the packaged application itself.
* Checkout sources are not evidence of what an installed app will trust.
*/
export async function readPackagedMarketplaceTrustSource(appAsar) {
const { reachable } = await readPackagedApplicationGraph(appAsar);
return findPackagedMarketplaceTrustSource(reachable);
}
/**
* Verify Marketplace contract markers only in the package.main-reachable app
* graph. An arbitrary string elsewhere in app.asar is not evidence that the
* installed Main/Renderer contract is present.
*/
export async function verifyPackagedMarketplaceClientArtifact(appAsar) {
const { reachable } = await readPackagedApplicationGraph(appAsar);
const reachableContents = Buffer.from(reachable.map(({ source }) => source).join('\n'));
const trustSource = findPackagedMarketplaceTrustSource(reachable);
if (!reachableContents.includes(Buffer.from(CODE_OWNED_PLUGIN_SIGNING_KEYS_SOURCE_MARKER))) {
throw new Error('Packaged app.asar reachable graph does not contain the Marketplace trust provenance marker');
}
const verifiedTrustSource = `${trustSource}\n${CODE_OWNED_PLUGIN_SIGNING_KEYS_SOURCE_MARKER}`;
return verifyMarketplaceClientArtifact(reachableContents, verifiedTrustSource);
}
async function filesContainingNeedles(root, needles) {
const matches = [];
const visit = async (path) => {
@@ -648,7 +688,6 @@ export async function verifyPiProductArtifact({ projectRoot, executable }) {
throw new Error(`Pi runtime manifest contains absolute paths: ${JSON.stringify(absoluteManifestValues)}`);
}
const appAsarContents = await readFile(appAsar);
const marketplaceTrustSource = await readPackagedMarketplaceTrustSource(appAsar);
const physicalOpenCodePaths = await collectForbiddenResourcePaths(resourcesDirectory);
const asarOpenCodePaths = collectForbiddenAsarPaths(appAsar);
const openCodeResourcePaths = classifyOpenCodeResourcePaths([
@@ -666,7 +705,7 @@ export async function verifyPiProductArtifact({ projectRoot, executable }) {
resourcesDirectory,
appAsarContents,
});
const marketplace = verifyMarketplaceClientArtifact(appAsarContents, marketplaceTrustSource);
const marketplace = await verifyPackagedMarketplaceClientArtifact(appAsar);
const actualSkills = bundledPluginResources.coreResources.skills;
const missingExtensionMarkers = EXTENSION_CONTRACT_MARKERS.filter(
(marker) => !appAsarContents.includes(Buffer.from(marker)),

View File

@@ -64,26 +64,27 @@ export function MyPluginsView(props: MyPluginsViewProps) {
<section aria-label="账号插件库" className="mt-6 space-y-4">
{props.library?.items.map((plugin) => {
const installed = props.installations[plugin.pluginId];
const hasDevicePackage = Boolean(installed?.version);
const removed = plugin.removedAt !== null;
const suspended = plugin.runtimeStatus === 'suspended';
const retired = plugin.catalogStatus === 'retired';
const installedChannel = installed?.channel ?? 'stable';
const installedChannel = hasDevicePackage ? installed?.channel ?? 'stable' : 'stable';
const channelVersion = installedChannel === 'beta' ? plugin.betaVersion : plugin.stableVersion;
const updateAvailable = Boolean(installed?.version && channelVersion && installed.version !== channelVersion);
const betaChannelUnavailable = Boolean(installed && installedChannel === 'beta' && !plugin.betaVersion);
const updateAvailable = Boolean(hasDevicePackage && channelVersion && installed?.version !== channelVersion);
const betaChannelUnavailable = Boolean(hasDevicePackage && installedChannel === 'beta' && !plugin.betaVersion);
const busy = Object.keys(props.pending).some((key) => key.endsWith(`:${plugin.pluginId}`));
const failure = failureText(installed?.reason);
return <article key={plugin.pluginId} className="rounded-2xl bg-background p-5 shadow-soft ring-1 ring-border/70">
<div className="flex flex-wrap items-start justify-between gap-4"><div><div className="flex flex-wrap items-center gap-2"><h2 className="text-balance text-xl font-semibold">{plugin.title}</h2>{plugin.acquisition === 'system_included' ? <Badge variant="outline"></Badge> : removed ? <Badge variant="outline"></Badge> : <Badge variant="secondary"></Badge>}</div><p className="mt-2 text-pretty text-sm text-muted-foreground">{plugin.summary}</p></div><div className="text-right text-sm"><p className="tabular-nums"> {plugin.stableVersion ?? '不可用'}</p>{plugin.betaVersion ? <p className="mt-1 tabular-nums text-muted-foreground">Beta {plugin.betaVersion}</p> : null}{installed?.version ? <><p className="mt-1 tabular-nums text-muted-foreground"> {installed.version}</p><p className="mt-1 text-muted-foreground">{installedChannel === 'beta' ? 'Beta' : '稳定'}</p></> : <p className="mt-1 text-muted-foreground"></p>}</div></div>
<div className="flex flex-wrap items-start justify-between gap-4"><div><div className="flex flex-wrap items-center gap-2"><h2 className="text-balance text-xl font-semibold">{plugin.title}</h2>{plugin.acquisition === 'system_included' ? <Badge variant="outline"></Badge> : removed ? <Badge variant="outline"></Badge> : <Badge variant="secondary"></Badge>}</div><p className="mt-2 text-pretty text-sm text-muted-foreground">{plugin.summary}</p></div><div className="text-right text-sm"><p className="tabular-nums"> {plugin.stableVersion ?? '不可用'}</p>{plugin.betaVersion ? <p className="mt-1 tabular-nums text-muted-foreground">Beta {plugin.betaVersion}</p> : null}{hasDevicePackage ? <><p className="mt-1 tabular-nums text-muted-foreground"> {installed?.version}</p><p className="mt-1 text-muted-foreground">{installedChannel === 'beta' ? 'Beta' : '稳定'}</p></> : <p className="mt-1 text-muted-foreground"></p>}</div></div>
<div className="mt-4 space-y-1 text-pretty text-sm">{suspended ? <p className="text-destructive"></p> : null}{retired ? <p>{removed ? '已退役,移除后不可重新获取' : '已退役;现有账号插件库仍可继续使用受支持版本'}</p> : null}{betaChannelUnavailable ? <p> Beta </p> : null}{failure ? <p className="text-destructive"><AlertCircle className="mr-1 inline h-4 w-4" />{failure}</p> : null}</div>
<div className="mt-5 flex flex-wrap gap-2">
{removed ? <Button type="button" className="min-h-10" disabled={busy || retired || suspended} aria-label={`重新免费获取${plugin.title}`} onClick={() => void props.onReacquire(plugin.pluginId)}></Button>
: !installed ? <Button type="button" className="min-h-10" disabled={busy || suspended} aria-label={`下载${plugin.title}`} onClick={() => void props.onInstall(plugin.pluginId)}><Download className="mr-2 h-4 w-4" /></Button>
: !hasDevicePackage ? <Button type="button" className="min-h-10" disabled={busy || suspended} aria-label={`下载${plugin.title}`} onClick={() => void props.onInstall(plugin.pluginId)}><Download className="mr-2 h-4 w-4" /></Button>
: updateAvailable ? <Button type="button" className="min-h-10" disabled={busy || suspended} aria-label={`${installedChannel === 'beta' ? '更新 Beta' : '更新'}${plugin.title}`} onClick={() => void (installedChannel === 'beta' ? props.onInstallBeta(plugin.pluginId) : props.onUpdate(plugin.pluginId))}><RefreshCw className="mr-2 h-4 w-4" />{installedChannel === 'beta' ? '更新 Beta' : '更新'}</Button>
: <span className="inline-flex min-h-10 items-center px-2 text-sm font-medium"><PackageCheck className="mr-2 h-4 w-4 text-brand" /></span>}
{!removed && plugin.acquisition !== 'system_included' && plugin.betaVersion && installedChannel !== 'beta' ? <Button type="button" variant="outline" className="min-h-10" disabled={busy || suspended} aria-label={`安装 Beta${plugin.title}`} onClick={() => void props.onInstallBeta(plugin.pluginId)}> Beta</Button> : null}
{!removed && plugin.acquisition !== 'system_included' ? <Button type="button" variant="outline" className="min-h-10" disabled={busy} aria-label={`移除${plugin.title}`} onClick={() => void props.onRemove(plugin.pluginId)}><Trash2 className="mr-2 h-4 w-4" /></Button> : null}
{installed ? <Button type="button" variant="ghost" className="min-h-10" disabled={busy} aria-label={`删除设备上的${plugin.title}`} onClick={() => void props.onUninstall(plugin.pluginId)}></Button> : null}
{hasDevicePackage ? <Button type="button" variant="ghost" className="min-h-10" disabled={busy} aria-label={`删除设备上的${plugin.title}`} onClick={() => void props.onUninstall(plugin.pluginId)}></Button> : null}
<Button asChild variant="outline" className="min-h-10"><Link to="/project-plugins"></Link></Button>
<Button asChild variant="outline" className="min-h-10"><Link to="/project-config"></Link></Button>
</div>

View File

@@ -93,12 +93,13 @@ export function createPluginMarketplaceStore(
let catalogGeneration = 0;
let libraryIntent = 0;
let latestLibraryReadIntent = 0;
const latestPluginMutationIntent = new Map<string, number>();
const latestLibraryMutationIntent = new Map<string, number>();
const latestDeviceMutationIntent = new Map<string, number>();
return createStore<PluginMarketplaceState>((set, get) => {
const beginMutation = (pluginId: string): number => {
const beginMutation = (pluginId: string, domain: 'library' | 'device'): number => {
const intent = ++libraryIntent;
latestPluginMutationIntent.set(pluginId, intent);
(domain === 'library' ? latestLibraryMutationIntent : latestDeviceMutationIntent).set(pluginId, intent);
return intent;
};
@@ -107,12 +108,18 @@ export function createPluginMarketplaceStore(
intent: number,
generation: number,
accountKey: string,
domain: 'library' | 'device',
): boolean => generation === scopeGeneration
&& get().accountKey === accountKey
&& latestPluginMutationIntent.get(pluginId) === intent
// A later explicit read is the newest account projection for every
// plugin, so an older mutation must not replace it.
&& latestLibraryReadIntent <= intent;
&& (domain === 'library' ? latestLibraryMutationIntent : latestDeviceMutationIntent).get(pluginId) === intent
// A later explicit read is the newest Library projection, so an older
// Library mutation must not replace it. Device state is independent.
&& (domain === 'device' || latestLibraryReadIntent <= intent);
const latestPluginMutationIntent = (pluginId: string): number => Math.max(
latestLibraryMutationIntent.get(pluginId) ?? 0,
latestDeviceMutationIntent.get(pluginId) ?? 0,
);
const mergeLibraryForPlugin = (
current: MarketplaceLibrarySnapshot | null,
@@ -125,8 +132,8 @@ export function createPluginMarketplaceStore(
if (incomingItem) byPlugin.set(pluginId, incomingItem);
else byPlugin.delete(pluginId);
const items = [...byPlugin.values()].sort((left, right) => {
const leftIntent = latestPluginMutationIntent.get(left.pluginId) ?? 0;
const rightIntent = latestPluginMutationIntent.get(right.pluginId) ?? 0;
const leftIntent = latestPluginMutationIntent(left.pluginId);
const rightIntent = latestPluginMutationIntent(right.pluginId);
return rightIntent - leftIntent;
});
return {
@@ -170,10 +177,10 @@ export function createPluginMarketplaceStore(
const generation = scopeGeneration;
const accountKey = get().accountKey;
if (!accountKey) throw new Error('请先登录后管理我的插件');
const intent = beginMutation(pluginId);
const intent = beginMutation(pluginId, 'library');
try {
const projection = await action(pluginId);
if (!canCommitMutation(pluginId, intent, generation, accountKey)) return;
if (!canCommitMutation(pluginId, intent, generation, accountKey, 'library')) return;
const merged = mergeLibraryProjection(pluginId, projection);
set({
library: merged.library,
@@ -182,7 +189,7 @@ export function createPluginMarketplaceStore(
libraryError: null,
});
} catch (error) {
if (canCommitMutation(pluginId, intent, generation, accountKey)) {
if (canCommitMutation(pluginId, intent, generation, accountKey, 'library')) {
set({ libraryError: message(error) });
}
throw error;
@@ -197,10 +204,10 @@ export function createPluginMarketplaceStore(
const generation = scopeGeneration;
const accountKey = get().accountKey;
if (!accountKey) throw new Error('请先登录后管理设备插件');
const intent = beginMutation(pluginId);
const intent = beginMutation(pluginId, 'device');
try {
const result = await action(pluginId);
if (!canCommitMutation(pluginId, intent, generation, accountKey)) return;
if (!canCommitMutation(pluginId, intent, generation, accountKey, 'device')) return;
set((state) => {
const installations = { ...state.installations };
if (result.status === 'removed') delete installations[pluginId];
@@ -208,7 +215,7 @@ export function createPluginMarketplaceStore(
return { installations, libraryError: null };
});
} catch (error) {
if (canCommitMutation(pluginId, intent, generation, accountKey)) {
if (canCommitMutation(pluginId, intent, generation, accountKey, 'device')) {
const reason = message(error);
set((state) => ({
installations: {
@@ -250,7 +257,8 @@ export function createPluginMarketplaceStore(
scopeGeneration += 1;
libraryIntent += 1;
latestLibraryReadIntent = libraryIntent;
latestPluginMutationIntent.clear();
latestLibraryMutationIntent.clear();
latestDeviceMutationIntent.clear();
set({
accountKey,
library: null,

View File

@@ -61,6 +61,7 @@ test.describe('Plugin Marketplace', () => {
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await getStableWindow(app);
await expect(page.getByTestId('ai-module-option-programming')).toBeVisible();
await app.evaluate(({ ipcMain }) => {
let installedChannel: 'stable' | 'beta' | null = 'stable';
const installedVersion = '1.0.0';

View File

@@ -139,6 +139,53 @@ describe('coding plugin bounded product service', () => {
expect(result.items).not.toContainEqual(expect.objectContaining({ id: 'makelore.removed' }));
});
it('projects a client-incompatible installed package as unavailable', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-incompatible-'));
roots.push(root);
await createCodingProjectMetadata(root, { now: '2026-08-28T00:00:00.000Z' });
await createCodingProjectAgent(root, {
id: 'builder', avatarId: 'avatar-01', roleName: '实现者', name: 'Builder',
model: null, modelResolution: 'required', skillIds: ['notes'],
responsibility: { mission: 'Build', owns: [], boundaries: [], collaborators: [], principles: [] },
});
const definition = {
...DATA_SERVICE_PLUGIN_DEFINITION,
id: 'makelore.notes', displayName: 'Notes', description: 'Notes',
requiresBackend: false, runtimeKind: 'skill_only' as const,
acquisitionMode: 'user_acquired' as const, releaseId: 'release-notes',
provenance: { source: 'marketplace' as const, packageRoot: 'C:/packages/release-notes' },
adapterId: '', operations: [], tools: [],
skills: [{ id: 'notes', entryPath: 'skills/notes/SKILL.md', grants: [] }],
surfaces: {},
};
const service = createCodingProjectPluginService({
projects: { getProject: vi.fn().mockResolvedValue({ id: 'local-a', path: root }) },
projectPlugins: {
getEnabledPluginIds: vi.fn().mockResolvedValue([definition.id]), setEnabled: vi.fn(),
},
policyClient: {
refresh: vi.fn(),
getState: () => ({ status: 'unavailable' as const, revision: 0, lastVerifiedAt: null, catalog: null }),
},
adapters: [], definitions: [definition],
effectiveResolver: {
resolve: vi.fn().mockResolvedValue({
accountSessionId: 'account-a\u00001', projectId: 'local-a',
pluginReleaseIds: [], effectiveSkillIds: [], skillEntries: [],
toolDefinitions: [], runtimePolicies: [],
unavailableReasons: [{
pluginId: definition.id, code: 'client_incompatible',
message: 'Plugin Release is incompatible with this MakeLore client',
}],
}),
} as never,
});
await expect(service.list('local-a')).resolves.toMatchObject({
items: [{ id: definition.id, enabled: true, state: 'unavailable' }],
});
});
it('projects the exact three capabilities and fourteen mixed-policy operations', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-policy-join-'));
roots.push(root);

View File

@@ -5,7 +5,10 @@ import {
createEffectivePluginResolver,
type EffectivePluginResolver,
} from '../../electron/coding-plugins/effective-resolver';
import type { CodingPluginDefinition } from '../../shared/coding-plugins';
import {
DATA_SERVICE_PLUGIN_DEFINITION,
type CodingPluginDefinition,
} from '../../shared/coding-plugins';
const skillOnlyDefinition: CodingPluginDefinition = {
id: 'makelore.notes',
@@ -205,6 +208,41 @@ describe('effective plugin resolver', () => {
expect(result.unavailableReasons).toEqual([]);
});
it('reserves every bundled Skill owner, including Data Service, before accepting Marketplace packages', async () => {
const marketplaceDataServiceCollision: CodingPluginDefinition = {
...skillOnlyDefinition,
id: 'makelore.data-service-shadow',
releaseId: 'data-service-shadow-1',
provenance: { source: 'marketplace', packageRoot: 'C:/packages/data-service-shadow-1' },
skills: [{ id: 'data-service', entryPath: 'skills/data-service/SKILL.md', grants: [] }],
};
const result = await createEffectivePluginResolver({
definitions: [DATA_SERVICE_PLUGIN_DEFINITION, marketplaceDataServiceCollision],
getAccountBinding: () => binding,
getLibrary: vi.fn(async () => ({
...library(),
items: [{ ...library().items[0], pluginId: marketplaceDataServiceCollision.id }],
})),
getInstalled: vi.fn(async (pluginId: string) => pluginId === marketplaceDataServiceCollision.id
? installed(marketplaceDataServiceCollision)
: null),
getEnabledPluginIds: vi.fn(async () => [
DATA_SERVICE_PLUGIN_DEFINITION.id,
marketplaceDataServiceCollision.id,
]),
}).resolve({
projectId: 'project-a',
projectPath: 'C:/project-a',
assignedSkillIds: ['data-service'],
role: 'parent',
});
expect(result.pluginReleaseIds).toEqual([]);
expect(result.unavailableReasons).not.toEqual([
expect.objectContaining({ pluginId: marketplaceDataServiceCollision.id }),
]);
});
it('requires current Library, installed Release, project selection, and assignment', async () => {
const effective = resolver();
await expect(resolve(effective)).resolves.toMatchObject({

View File

@@ -86,7 +86,7 @@ function makeResolveResult(input: ResolveRequest, itemOverrides: Record<string,
sha256: SHA256,
sizeBytes: 1,
releaseAdmissionId: ADMISSION_ID,
expiresAt: '2026-08-29T00:00:00Z',
expiresAt: '2100-01-01T00:00:00Z',
channel: input.channel,
reason: null,
...itemOverrides,
@@ -97,17 +97,20 @@ function makeResolveResult(input: ResolveRequest, itemOverrides: Record<string,
};
}
function buildSkillOnlyArchive(): Buffer {
function buildSkillOnlyArchive(
overrides: Readonly<Record<string, Buffer | string>> = {},
): Buffer {
const zip = new AdmZip();
zip.addFile('plugin.json', Buffer.from(JSON.stringify({
const files = new Map<string, Buffer | string>([
['plugin.json', 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({
})],
['com.makelore/capability.json', JSON.stringify({
schemaVersion: 2,
pluginId: PLUGIN_ID,
contractVersion: 1,
@@ -115,8 +118,13 @@ function buildSkillOnlyArchive(): Buffer {
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'));
})],
['skills/example-skill/SKILL.md', '# Example\n'],
]);
for (const [filePath, content] of Object.entries(overrides)) files.set(filePath, content);
for (const [filePath, content] of files) {
zip.addFile(filePath, Buffer.isBuffer(content) ? content : Buffer.from(content));
}
return zip.toBuffer();
}
@@ -156,7 +164,7 @@ function signedGrant(
sha256,
signingKeyId: options.signingKeyId ?? 'test-key',
descriptorSignature: signature,
expiresAt: '2026-08-29T00:00:00Z',
expiresAt: '2100-01-01T00:00:00Z',
contentUrl: `/api/plugin-marketplace/v1/releases/${releaseId}/content?release_admission_id=${ADMISSION_ID}`,
},
publicKey: publicKey.export({ type: 'spki', format: 'der' }) as Buffer,
@@ -749,12 +757,87 @@ describe('PluginPackageStore', () => {
current = second;
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({ status: 'kept', releaseId: 'release-current' });
await expect(store.removeUnused(PLUGIN_ID)).resolves.toMatchObject({ status: 'kept', releaseId: 'release-current' });
await expect(store.readInstalledIndex()).resolves.toHaveLength(1);
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: 'release-current' });
await expect(readFile(path.join(temporaryRoot, 'current.json'), 'utf8')).resolves.toContain('release-current');
});
it('explicitly uninstalls every unprotected release without selecting by install time', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const first = signedGrant(archive, { releaseId: 'release-old' });
const second = signedGrant(archive, { releaseId: 'release-current', signingKeyId: 'test-key-2' });
let current = first;
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
releaseId: current.grant.releaseId,
sha256: current.grant.sha256,
sizeBytes: current.grant.sizeBytes,
})),
issueDownload: vi.fn(async () => current.grant),
downloadContent: async () => archive,
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
getAccountBinding: () => ACCOUNT_A,
keyStore: new Map([['test-key', first.publicKey], ['test-key-2', second.publicKey]]),
clientVersion: '1.0.0',
});
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
current = second;
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({
status: 'removed', pluginId: PLUGIN_ID, releaseId: 'release-current',
});
await expect(store.readInstalledIndex()).resolves.toEqual([]);
await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull();
await expect(readFile(path.join(temporaryRoot, 'current.json'), 'utf8')).resolves.toContain('"current":{}');
});
it('removes an unprotected current Release while retaining only an active-worker Release', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const protectedRelease = signedGrant(archive, { releaseId: 'release-worker' });
const selectedRelease = signedGrant(archive, {
releaseId: 'release-current', signingKeyId: 'test-key-current',
});
let current = protectedRelease;
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
releaseId: current.grant.releaseId,
sha256: current.grant.sha256,
sizeBytes: current.grant.sizeBytes,
})),
issueDownload: vi.fn(async () => current.grant),
downloadContent: async () => archive,
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot, marketplace, getAccountBinding: () => ACCOUNT_A,
keyStore: new Map([
['test-key', protectedRelease.publicKey],
['test-key-current', selectedRelease.publicKey],
]),
clientVersion: '1.0.0',
});
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
current = selectedRelease;
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
store.registerActiveWorker('release-worker');
await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({
status: 'kept', pluginId: PLUGIN_ID, reason: 'active_worker_reference',
});
await expect(store.readInstalledIndex()).resolves.toEqual([
expect.objectContaining({ releaseId: 'release-worker' }),
]);
await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull();
});
it('preserves the old release across download, signature, and extraction failures', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const oldArchive = buildSkillOnlyArchive();
@@ -886,6 +969,82 @@ describe('PluginPackageStore', () => {
await expect(store.readInstalledIndex()).resolves.toEqual([]);
});
it('captures the account before queued package mutations execute', 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 firstResolve = deferred<ResolveSnapshot>();
const resolve = vi.fn(async (input: ResolveRequest) => (
resolve.mock.calls.length === 1
? firstResolve.promise
: makeResolveResult(input, { sha256: grant.sha256, sizeBytes: grant.sizeBytes })
));
const marketplace: MarketplaceClient = {
resolve,
issueDownload: vi.fn(async () => grant),
downloadContent: async () => archive,
getCurrentAccountBinding: () => binding,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
clientVersion: '1.0.0',
keyStore: new Map([['test-key', publicKey]]),
getAccountBinding: () => binding,
});
const inFlight = store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
await vi.waitFor(() => expect(resolve).toHaveBeenCalledTimes(1));
const queued = store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
binding = ACCOUNT_B;
firstResolve.resolve(makeResolveResult({ makeloreVersion: '1.0.0', channel: 'stable' }));
await expect(inFlight).rejects.toMatchObject({ code: 'plugin_account_changed' });
await expect(queued).rejects.toMatchObject({ code: 'plugin_account_changed' });
expect(resolve).toHaveBeenCalledTimes(1);
await expect(store.readInstalledIndex()).resolves.toEqual([]);
});
it('does not let a queued uninstall switch accounts while waiting', 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 resolve = vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
sha256: grant.sha256,
sizeBytes: grant.sizeBytes,
}));
const marketplace: MarketplaceClient = {
resolve,
issueDownload: vi.fn(async () => grant),
downloadContent: async () => archive,
getCurrentAccountBinding: () => binding,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
clientVersion: '1.0.0',
keyStore: new Map([['test-key', publicKey]]),
getAccountBinding: () => binding,
});
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
const firstResolve = deferred<ResolveSnapshot>();
resolve.mockImplementationOnce(async () => firstResolve.promise);
const inFlight = store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
await vi.waitFor(() => expect(resolve).toHaveBeenCalledTimes(2));
const queued = store.uninstall(PLUGIN_ID);
binding = ACCOUNT_B;
firstResolve.resolve(makeResolveResult({ makeloreVersion: '1.0.0', channel: 'stable' }, {
action: 'keep',
}));
await expect(inFlight).rejects.toMatchObject({ code: 'plugin_account_changed' });
await expect(queued).rejects.toMatchObject({ code: 'plugin_account_changed' });
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID });
});
it('rejects archive traversal before materializing a package', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const zip = new AdmZip();
@@ -912,6 +1071,68 @@ describe('PluginPackageStore', () => {
await expect(store.readInstalledIndex()).resolves.toEqual([]);
});
it('enforces the canonical archive contract before installing a package', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const oversizedManifest = JSON.stringify({
$schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
name: PLUGIN_ID,
version: '1.0.0',
description: 'x'.repeat(256 * 1024),
author: { name: 'MakeLore' },
extensions: { 'com.makelore': { capabilityManifest: './com.makelore/capability.json' } },
});
const cases = [
{
name: 'unsupported asset extension',
archive: buildSkillOnlyArchive({ 'skills/example-skill/assets/run.exe': Buffer.from('not executable') }),
},
{
name: 'non-UTF-8 Skill text',
archive: buildSkillOnlyArchive({ 'skills/example-skill/SKILL.md': Buffer.from([0xff, 0xfe, 0xfd]) }),
},
{
name: 'non-UTF-8 capability manifest',
archive: buildSkillOnlyArchive({ 'com.makelore/capability.json': Buffer.from([0xff, 0xfe, 0xfd]) }),
},
{
name: 'asset outside a declared Skill',
archive: buildSkillOnlyArchive({ 'skills/other-skill/notes.md': '# Hidden\n' }),
},
{
name: 'oversized manifest',
archive: buildSkillOnlyArchive({ 'plugin.json': oversizedManifest }),
},
{
name: 'oversized Skill',
archive: buildSkillOnlyArchive({ 'skills/example-skill/SKILL.md': Buffer.alloc(256 * 1024 + 1, 0x61) }),
},
];
for (const [index, scenario] of cases.entries()) {
const { grant, publicKey } = signedGrant(scenario.archive, { releaseId: `release-invalid-${index}` });
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 () => scenario.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 expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }), scenario.name)
.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,

View File

@@ -4,6 +4,7 @@ import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { finished } from 'node:stream/promises';
import { afterEach, describe, expect, it } from 'vitest';
import {
@@ -15,6 +16,7 @@ import {
verifyBundledCodingPluginResources,
defaultProductExecutable,
readPackagedMarketplaceTrustSource,
verifyPackagedMarketplaceClientArtifact,
validatePiArtifactMetadata,
verifyMarketplaceClientArtifact,
} from '../../scripts/lib/pi-product-artifact.mjs';
@@ -24,6 +26,11 @@ const roots: string[] = [];
const PI_PACKAGE = '@earendil-works/pi-coding-agent';
const { createPackage } = createRequire(import.meta.url)('@electron/asar');
async function createAsarFixture(source: string, archive: string) {
const output = await createPackage(source, archive);
await finished(output);
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
@@ -149,7 +156,7 @@ describe('final Pi product artifact verification', () => {
);
await writeFile(path.join(source, 'resources', 'opencode-runtime', 'legacy.js'), 'export {};');
const archive = path.join(root, 'app.asar');
await createPackage(source, archive);
await createAsarFixture(source, archive);
const inventory = collectForbiddenAsarPaths(archive);
expect(inventory.entryCount).toBeGreaterThan(2);
@@ -240,10 +247,11 @@ describe('final Pi product artifact verification', () => {
await writeFile(path.join(source, 'dist-electron', 'main.js'), [
'const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({});',
'const unrelatedConfiguration = process.env.NIANCODE_E2E;',
'export const sourceMarker = "makelore.plugin-trust.code-owned.v1";',
'export const marketplace = true;',
].join('\n'));
const appAsar = path.join(root, 'app.asar');
await createPackage(source, appAsar);
await createAsarFixture(source, appAsar);
await expect(readPackagedMarketplaceTrustSource(appAsar)).resolves.toBe(
'CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({})',
);
@@ -260,7 +268,7 @@ describe('final Pi product artifact verification', () => {
'function createTrust() { return Object.freeze({ get: load, sourceMarker: "makelore.plugin-trust.code-owned.v1" }); }',
].join('\n'));
const appAsar = path.join(root, 'app.asar');
await createPackage(source, appAsar);
await createAsarFixture(source, appAsar);
await expect(readPackagedMarketplaceTrustSource(appAsar)).resolves.toContain(
'makelore.plugin-trust.code-owned.v1',
);
@@ -289,10 +297,59 @@ describe('final Pi product artifact verification', () => {
'export const sourceMarker = "makelore.plugin-trust.code-owned.v1";',
].join('\n'));
const appAsar = path.join(root, 'app.asar');
await createPackage(source, appAsar);
await createAsarFixture(source, appAsar);
await expect(readPackagedMarketplaceTrustSource(appAsar)).rejects.toThrow('trust source');
});
it('binds Marketplace route, Renderer, and effective markers to the package main graph', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-marketplace-contract-asar-'));
roots.push(root);
const source = path.join(root, 'source');
await mkdir(path.join(source, 'dist-electron'), { recursive: true });
await mkdir(path.join(source, 'dist', 'assets'), { recursive: true });
await writeFile(path.join(source, 'package.json'), JSON.stringify({ main: 'dist-electron/main.js' }));
await writeFile(path.join(source, 'dist-electron', 'main.js'), [
'const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({});',
'export const sourceMarker = "makelore.plugin-trust.code-owned.v1";',
'loadFile("../dist/index.html");',
].join('\n'));
await writeFile(path.join(source, 'dist', 'index.html'), [
'<script type="module" src="./assets/index.js"></script>',
].join('\n'));
await writeFile(path.join(source, 'dist', 'assets', 'index.js'), 'import("./project-plugins.js");');
await writeFile(
path.join(source, 'dist', 'assets', 'project-plugins.js'),
'import{marketplace}from"./plugin-marketplace.js";export{marketplace};',
);
await writeFile(path.join(source, 'dist', 'assets', 'plugin-marketplace.js'), [
'makelore-plugin-release.v1 skill_only plugin_signature_invalid signing key is not trusted',
'/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/',
'effectiveSkillIds pluginReleaseIds',
'/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 我的插件',
].join('\n'));
const appAsar = path.join(root, 'app.asar');
await createAsarFixture(source, appAsar);
await expect(verifyPackagedMarketplaceClientArtifact(appAsar)).resolves.toMatchObject({ result: 'pass' });
const staleSource = path.join(root, 'stale-source');
await mkdir(path.join(staleSource, 'dist-electron'), { recursive: true });
await writeFile(path.join(staleSource, 'package.json'), JSON.stringify({ main: 'dist-electron/main.js' }));
await writeFile(path.join(staleSource, 'dist-electron', 'main.js'), [
'const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({});',
'export const sourceMarker = "makelore.plugin-trust.code-owned.v1";',
].join('\n'));
await writeFile(path.join(staleSource, 'dist-electron', 'stale-marketplace.js'), [
'makelore-plugin-release.v1 skill_only plugin_signature_invalid signing key is not trusted',
'/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/',
'effectiveSkillIds pluginReleaseIds',
'/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 我的插件',
].join('\n'));
const staleAsar = path.join(root, 'stale.asar');
await createAsarFixture(staleSource, staleAsar);
await expect(verifyPackagedMarketplaceClientArtifact(staleAsar))
.rejects.toThrow('Marketplace contract markers');
});
it('rejects a packaged plugin tree that drops an SDK asset or catalog marker', async () => {
const fixture = await bundledResourceFixture();
await rm(path.join(

View File

@@ -154,6 +154,25 @@ describe('My Plugins', () => {
expect(screen.getByText('设备版本 1.5.0')).toBeVisible();
});
it('does not present a no-version unavailable projection as an installed device package', () => {
render(<MemoryRouter><MyPluginsView
library={{ ...library, items: [library.items[0]] }}
installations={{
'makelore.notes': {
status: 'unavailable', pluginId: 'makelore.notes', reason: 'plugin_incompatible_client',
},
}}
state="ready" pending={{}} onRefresh={vi.fn()} onInstall={vi.fn()} onUpdate={vi.fn()}
onInstallBeta={vi.fn()} onUninstall={vi.fn()} onRemove={vi.fn()} onReacquire={vi.fn()}
/></MemoryRouter>);
expect(screen.getByText('尚未下载到设备')).toBeVisible();
expect(screen.getByRole('button', { name: '下载灵感笔记' })).toBeVisible();
expect(screen.queryByRole('button', { name: '删除设备上的灵感笔记' })).not.toBeInTheDocument();
expect(screen.queryByText('设备版本已是最新')).not.toBeInTheDocument();
expect(screen.getByText(/当前 MakeLore 版本不兼容/)).toBeVisible();
});
it('honestly groups stable Main failure codes without claiming hidden authority', () => {
const { rerender } = render(<MemoryRouter><MyPluginsView
library={{ ...library, items: [library.items[0]] }} installations={{}} state="ready"

View File

@@ -141,6 +141,31 @@ describe('plugin Marketplace store', () => {
expect(store.getState().installations['makelore.data']).toMatchObject({ status: 'installed' });
});
it('merges same-plugin Library and device mutations in either completion order', async () => {
for (const order of ['library-then-device', 'device-then-library'] as const) {
const acquire = deferred<MarketplaceLibraryProjection>();
const install = deferred<MarketplaceInstallation>();
const store = createPluginMarketplaceStore({
acquire: vi.fn(() => acquire.promise),
install: vi.fn(() => install.promise),
});
store.getState().activateAccount('account-a');
const libraryMutation = store.getState().acquire('makelore.notes');
const deviceMutation = store.getState().install('makelore.notes');
const installed = { status: 'installed' as const, pluginId: 'makelore.notes', version: '1.0.0' };
const acquired = { library: library('Notes acquired'), installations: [] };
if (order === 'library-then-device') {
acquire.resolve(acquired); await libraryMutation;
install.resolve(installed); await deviceMutation;
} else {
install.resolve(installed); await deviceMutation;
acquire.resolve(acquired); await libraryMutation;
}
expect(store.getState().library?.items[0]?.title).toBe('Notes acquired');
expect(store.getState().installations['makelore.notes']).toMatchObject(installed);
}
});
it('calls only the selected state mutation', async () => {
const acquire = vi.fn().mockResolvedValue(projection('Acquired'));
const install = vi.fn().mockResolvedValue({ status: 'installed', pluginId: 'makelore.notes', version: '1.0.0' });