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