fix(marketplace): close client review findings
This commit is contained in:
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user