fix(marketplace): close client contract gaps

This commit is contained in:
2026-08-29 23:21:26 +08:00
parent e4676977b9
commit c4120dd847
9 changed files with 236 additions and 29 deletions

View File

@@ -0,0 +1,91 @@
# Task: Marketplace MLM-06 R5 remediation
## Identity
- Task ID: 20260829-marketplace-mlm06-r5-remediation-c8f4a2d1
- Mode: Feature
- Branch: codex/20260829-marketplace-mlm06-r5-remediation-c8f4a2d1-marketplace-mlm06-r5-remediation
- Worktree: D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm06-r5-remediation-c8f4a2d1
- Base commit: e4676977b91be62a87ba8c12d3415f1e8334e3c4
- Owner: marketplace-client-coordinator-r5-remediator
- Status: Ready for Integration
## Scope
- Remediate the fixed-range MLM-06 R5 Release A findings from exact coordinator
frontier `e4676977b91be62a87ba8c12d3415f1e8334e3c4` as the sole product writer.
- Own only the schema-v2 execution-mode vocabulary/parser, explicit Package Store
uninstall cleanup semantics, Marketplace device-failure projection, their focused
tests, and this task record.
- Accept any additional supported R5 Standards finding into this same task before
commit; do not create a second remediation owner.
## Intent And Constraints
- Make the client schema-v2 contract exactly match canonical/server
`synchronous | job`; reject the non-contract `accepted` value.
- Make explicit uninstall prevent a new worker from seeing the Plugin immediately,
while preserving already-running workers until disposal and allowing their
formerly protected Release records to be removed after the last reference ends.
- Preserve the installed Beta channel across failed install/update projection so the
Renderer cannot silently fall back to Stable labeling or routing.
- Keep Account Library, Device Installation, project selection, Agent assignment,
active-worker freezing, runtime authorization, and billing separate. Do not add a
compatibility layer, background daemon, hidden mutation, Release B path, hosted
adapter, Token Point path, or production key material.
- User root worktrees, server, Operations, publication, deployment, XMA-01, push/PR,
and production activation remain outside this task.
- Concurrent and Planning Gates passed: task-context identity matches exact base;
coordinator is clean; R5 Spec/Standards peers are read-only reviews of the same
range and have no ownership conflict. `AGENTS.md`, all required skills, startup
memory, coordinator/peer records, and the full canonical spec/plan/design were read.
## Outcome
- Red/green implementation is in progress. The exact focused red boundary was
`4 failed / 60 passed`: schema-v2 rejected canonical `job`, explicit uninstall
could not retire active/current records across worker release, and a failed Beta
update dropped its channel.
- The minimal product correction now uses `synchronous | job`, removes explicit
current selection before deferred active-worker cleanup, and preserves the prior
installation channel in bounded failure projection. No compatibility layer,
daemon, root-worktree change, server change, or Release B path was added.
- Explicit uninstall now separates logical availability from physical retention:
it clears the current selection immediately, keeps immutable bytes referenced by
a running worker, and allows a later explicit cleanup to remove those bytes after
the worker releases its frozen Release. Background cleanup still refuses to guess
when current selection is missing.
- R5 Standards finalized with the same three Medium findings as R5 Spec and no
independent finding. The complete remediation set is therefore closed in this
single writer task.
## Verification
- Focused green: manifest/Package Store/store `3 files / 64 passed`.
- Adjacent Marketplace/Main/effective/Pi green: `11 files / 126 passed`.
- Renderer Beta failure/action proof: `2 files / 20 passed`.
- TypeScript typecheck passed. Dependency install used the frozen lockfile, reused
all 997 packages from the local store, downloaded nothing, and changed no lockfile.
- Scoped ESLint passed. Full `lint:check` passed with zero errors and the exact five
unchanged out-of-scope warnings in Home/Makelore.
- Vite production build passed for Renderer, Main, Preload, and utility worker. The
first sandboxed invocation failed only because pnpm could not `lstat` the Windows
user directory; the identical escalated command passed, so this is recorded as an
environment gate rather than a product failure.
- Full unit suite passed: 208 files / 1,807 passed / 2 skipped, followed by the
repository's single-worker pressure file 1/1 passed.
- Packaged artifact verification was not repeated in this remediation: no trust
key, package layout, artifact verifier, or packaged resource changed; the Vite
build and exact manifest contract tests exercise this change. XMA-01 remains the
later real packaged acceptance gate.
## Follow-ups
- Fresh fixed-range Standards and Spec review must run from the integrated client
frontier before XMA-01 can open.
- Official Ed25519 production public key activation and Release B Provider work
remain external holds, not this Release A remediation's completion criteria.
## Promotion Candidates
- None recorded.

View File

@@ -782,8 +782,8 @@ function parseV2Tool(
if (permissions.some((permission) => !hostedPermissionMatchesPlugin(permission, pluginId))) {
fail(filePath, `tools[${index}].permissions`, 'permission is outside the plugin hosted namespace');
}
if (tool.executionMode !== 'synchronous' && tool.executionMode !== 'accepted') {
fail(filePath, `tools[${index}].executionMode`, 'must be synchronous or accepted');
if (tool.executionMode !== 'synchronous' && tool.executionMode !== 'job') {
fail(filePath, `tools[${index}].executionMode`, 'must be synchronous or job');
}
validateV2SchemaNode(tool.inputSchema, filePath, `tools[${index}].inputSchema`);
validateV2SchemaNode(tool.outputSchema, filePath, `tools[${index}].outputSchema`);

View File

@@ -1040,43 +1040,50 @@ 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 = mode === 'explicit'
? records.filter((record) => !protectedIds.has(record.releaseId))
: records.length === 1 && !protectedIds.has(selected.releaseId)
let removable: InstalledReleaseRecord[];
if (mode === 'explicit') {
removable = records.filter((record) => !protectedIds.has(record.releaseId));
} else {
if (!selected) {
return {
status: 'kept',
pluginId: validated,
reason: 'current_selection_missing',
};
}
removable = records.length === 1 && !protectedIds.has(selected.releaseId)
? records
: records.filter((record) => (
record.releaseId !== selected.releaseId && !protectedIds.has(record.releaseId)
));
if (removable.length === 0) {
return {
status: 'kept',
pluginId: validated,
releaseId: selected.releaseId,
version: selected.version,
...(selected.channel === undefined ? {} : { channel: selected.channel }),
};
if (removable.length === 0) {
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 {
throw new PluginPackageStoreError('plugin_install_failed', 'package index cleanup failed');
if (removable.length > 0) {
try {
await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: remaining }));
} catch {
throw new PluginPackageStoreError('plugin_install_failed', 'package index cleanup failed');
}
}
const nextCurrent = { ...currentSelection.current };
if (nextCurrent[validated] && !remaining.some((record) => (
if (mode === 'explicit') {
delete nextCurrent[validated];
} else if (nextCurrent[validated] && !remaining.some((record) => (
record.pluginId === validated && record.releaseId === nextCurrent[validated]
))) delete nextCurrent[validated];
await this.writeCurrentSelection({

View File

@@ -21,7 +21,7 @@ export type CodingPluginAcquisitionMode =
| 'system_included'
| 'user_acquired';
export type CodingPluginExecutionMode = 'synchronous' | 'accepted';
export type CodingPluginExecutionMode = 'synchronous' | 'job';
export interface CodingPluginPackageProvenance {
readonly source: 'bundled' | 'marketplace';

View File

@@ -229,6 +229,9 @@ export function createPluginMarketplaceStore(
...(state.installations[pluginId]?.version
? { version: state.installations[pluginId].version }
: {}),
...(state.installations[pluginId]?.channel
? { channel: state.installations[pluginId].channel }
: {}),
reason,
},
},

View File

@@ -740,11 +740,11 @@ describe('PluginPackageStore', () => {
expect(index.releases.map(({ release_id }) => release_id)).toEqual(['release-2', 'release-1']);
store.registerActiveWorker('release-1');
await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({
status: 'kept', pluginId: PLUGIN_ID, releaseId: 'release-1', version: '1.0.0',
await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({
status: 'kept', pluginId: PLUGIN_ID, reason: 'active_worker_reference',
});
await expect(store.readInstalledIndex()).resolves.toHaveLength(1);
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: 'release-1' });
await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull();
});
it('persists the installed channel and client range, and fails closed after a client upgrade', async () => {
@@ -899,6 +899,49 @@ describe('PluginPackageStore', () => {
expect.objectContaining({ releaseId: 'release-worker' }),
]);
await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull();
store.releaseActiveWorker('release-worker');
await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({
status: 'removed', pluginId: PLUGIN_ID, reason: 'none',
});
await expect(store.readInstalledIndex()).resolves.toEqual([]);
});
it('makes an active current Release unavailable to new workers before deferred cleanup', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const active = signedGrant(archive, { releaseId: 'release-active' });
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
releaseId: active.grant.releaseId,
sha256: active.grant.sha256,
sizeBytes: active.grant.sizeBytes,
})),
issueDownload: vi.fn(async () => active.grant),
downloadContent: async () => archive,
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
getAccountBinding: () => ACCOUNT_A,
keyStore: new Map([['test-key', active.publicKey]]),
clientVersion: '1.0.0',
});
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
store.registerActiveWorker('release-active');
await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({
status: 'kept', pluginId: PLUGIN_ID, reason: 'active_worker_reference',
});
await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull();
await expect(store.readInstalledIndex()).resolves.toHaveLength(1);
store.releaseActiveWorker('release-active');
await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({
status: 'removed', pluginId: PLUGIN_ID, reason: 'none',
});
await expect(store.readInstalledIndex()).resolves.toEqual([]);
});
it('preserves the old release across download, signature, and extraction failures', async () => {

View File

@@ -121,6 +121,16 @@ describe('Marketplace Release A package contract', () => {
expect(Object.isFrozen(definition.tools[0]?.outputSchema)).toBe(true);
});
it('accepts only the canonical asynchronous job execution mode', () => {
const job = structuredClone(HOSTED_CAPABILITY) as Record<string, unknown>;
(job.tools as Array<Record<string, unknown>>)[0]!.executionMode = 'job';
expect(parse(job).tools[0]?.executionMode).toBe('job');
const accepted = structuredClone(HOSTED_CAPABILITY) as Record<string, unknown>;
(accepted.tools as Array<Record<string, unknown>>)[0]!.executionMode = 'accepted';
expect(() => parse(accepted)).toThrow(CodingPluginManifestError);
});
it('requires the exact capability manifest path even for direct parsing', () => {
const root = structuredClone(ROOT) as Record<string, unknown>;
const extensions = root.extensions as Record<string, unknown>;

View File

@@ -130,6 +130,31 @@ describe('My Plugins', () => {
expect(screen.getByText('当前频道Beta')).toBeVisible();
});
it('keeps a failed Beta update on the explicit Beta route', () => {
const onUpdate = vi.fn();
const onInstallBeta = vi.fn();
render(<MemoryRouter><MyPluginsView
library={{ ...library, items: [library.items[0]] }}
installations={{
'makelore.notes': {
status: 'unavailable',
pluginId: 'makelore.notes',
channel: 'beta',
version: '2.0.0',
releaseId: 'beta-1',
reason: 'plugin_release_unavailable',
},
}}
state="ready" pending={{}} onRefresh={vi.fn()} onInstall={vi.fn()} onUpdate={onUpdate}
onInstallBeta={onInstallBeta} onUninstall={vi.fn()} onRemove={vi.fn()} onReacquire={vi.fn()}
/></MemoryRouter>);
fireEvent.click(screen.getByRole('button', { name: '更新 Beta灵感笔记' }));
expect(onInstallBeta).toHaveBeenCalledWith('makelore.notes');
expect(onUpdate).not.toHaveBeenCalled();
expect(screen.getByText('当前频道Beta')).toBeVisible();
});
it('does not silently fall back to stable when the installed Beta channel has no release', () => {
render(<MemoryRouter><MyPluginsView
library={{ ...library, items: [{ ...library.items[0], betaVersion: null }] }}

View File

@@ -199,4 +199,32 @@ describe('plugin Marketplace store', () => {
reason: 'plugin_signature_invalid: Release signature is invalid',
});
});
it('preserves the installed Beta channel when a device update fails', async () => {
const error = Object.assign(new Error('Release is temporarily unavailable'), {
code: 'plugin_release_unavailable',
});
const store = createPluginMarketplaceStore({
installBeta: vi.fn().mockResolvedValue({
status: 'installed',
pluginId: 'makelore.notes',
releaseId: 'release-beta-1',
version: '2.0.0-beta.1',
channel: 'beta',
}),
update: vi.fn().mockRejectedValue(error),
});
store.getState().activateAccount('account-a');
await store.getState().installBeta('makelore.notes');
await expect(store.getState().update('makelore.notes')).rejects.toBe(error);
expect(store.getState().installations['makelore.notes']).toMatchObject({
status: 'unavailable',
pluginId: 'makelore.notes',
releaseId: 'release-beta-1',
version: '2.0.0-beta.1',
channel: 'beta',
reason: 'plugin_release_unavailable: Release is temporarily unavailable',
});
});
});