fix(marketplace): close client contract gaps
This commit is contained in:
1 parent
e4676977b9
commit
c4120dd847
9 files changed
+236
-29
No files matched your search
@@ -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))) {
|
if (permissions.some((permission) => !hostedPermissionMatchesPlugin(permission, pluginId))) {
|
||||||
fail(filePath, `tools[${index}].permissions`, 'permission is outside the plugin hosted namespace');
|
fail(filePath, `tools[${index}].permissions`, 'permission is outside the plugin hosted namespace');
|
||||||
}
|
}
|
||||||
if (tool.executionMode !== 'synchronous' && tool.executionMode !== 'accepted') {
|
if (tool.executionMode !== 'synchronous' && tool.executionMode !== 'job') {
|
||||||
fail(filePath, `tools[${index}].executionMode`, 'must be synchronous or accepted');
|
fail(filePath, `tools[${index}].executionMode`, 'must be synchronous or job');
|
||||||
}
|
}
|
||||||
validateV2SchemaNode(tool.inputSchema, filePath, `tools[${index}].inputSchema`);
|
validateV2SchemaNode(tool.inputSchema, filePath, `tools[${index}].inputSchema`);
|
||||||
validateV2SchemaNode(tool.outputSchema, filePath, `tools[${index}].outputSchema`);
|
validateV2SchemaNode(tool.outputSchema, filePath, `tools[${index}].outputSchema`);
|
||||||
|
|||||||
@@ -1040,43 +1040,50 @@ export class PluginPackageStore {
|
|||||||
const selected = currentSelection.current[validated]
|
const selected = currentSelection.current[validated]
|
||||||
? records.find((record) => record.releaseId === currentSelection.current[validated])
|
? records.find((record) => record.releaseId === currentSelection.current[validated])
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!selected) {
|
|
||||||
return {
|
|
||||||
status: 'kept',
|
|
||||||
pluginId: validated,
|
|
||||||
reason: 'current_selection_missing',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const protectedIds = new Set([
|
const protectedIds = new Set([
|
||||||
...this.accountCache.referencedReleaseIds(),
|
...this.accountCache.referencedReleaseIds(),
|
||||||
...(this.activeWorkerReleaseIds() ?? []),
|
...(this.activeWorkerReleaseIds() ?? []),
|
||||||
...this.activeWorkers,
|
...this.activeWorkers,
|
||||||
]);
|
]);
|
||||||
const removable = mode === 'explicit'
|
let removable: InstalledReleaseRecord[];
|
||||||
? records.filter((record) => !protectedIds.has(record.releaseId))
|
if (mode === 'explicit') {
|
||||||
: records.length === 1 && !protectedIds.has(selected.releaseId)
|
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
|
||||||
: records.filter((record) => (
|
: records.filter((record) => (
|
||||||
record.releaseId !== selected.releaseId && !protectedIds.has(record.releaseId)
|
record.releaseId !== selected.releaseId && !protectedIds.has(record.releaseId)
|
||||||
));
|
));
|
||||||
if (removable.length === 0) {
|
if (removable.length === 0) {
|
||||||
return {
|
return {
|
||||||
status: 'kept',
|
status: 'kept',
|
||||||
pluginId: validated,
|
pluginId: validated,
|
||||||
releaseId: selected.releaseId,
|
releaseId: selected.releaseId,
|
||||||
version: selected.version,
|
version: selected.version,
|
||||||
...(selected.channel === undefined ? {} : { channel: selected.channel }),
|
...(selected.channel === undefined ? {} : { channel: selected.channel }),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const remaining = index.releases.filter((record) => !removable.includes(record));
|
const remaining = index.releases.filter((record) => !removable.includes(record));
|
||||||
if (binding) this.assertBinding(binding);
|
if (binding) this.assertBinding(binding);
|
||||||
try {
|
if (removable.length > 0) {
|
||||||
await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: remaining }));
|
try {
|
||||||
} catch {
|
await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: remaining }));
|
||||||
throw new PluginPackageStoreError('plugin_install_failed', 'package index cleanup failed');
|
} catch {
|
||||||
|
throw new PluginPackageStoreError('plugin_install_failed', 'package index cleanup failed');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const nextCurrent = { ...currentSelection.current };
|
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]
|
record.pluginId === validated && record.releaseId === nextCurrent[validated]
|
||||||
))) delete nextCurrent[validated];
|
))) delete nextCurrent[validated];
|
||||||
await this.writeCurrentSelection({
|
await this.writeCurrentSelection({
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export type CodingPluginAcquisitionMode =
|
|||||||
| 'system_included'
|
| 'system_included'
|
||||||
| 'user_acquired';
|
| 'user_acquired';
|
||||||
|
|
||||||
export type CodingPluginExecutionMode = 'synchronous' | 'accepted';
|
export type CodingPluginExecutionMode = 'synchronous' | 'job';
|
||||||
|
|
||||||
export interface CodingPluginPackageProvenance {
|
export interface CodingPluginPackageProvenance {
|
||||||
readonly source: 'bundled' | 'marketplace';
|
readonly source: 'bundled' | 'marketplace';
|
||||||
|
|||||||
@@ -229,6 +229,9 @@ export function createPluginMarketplaceStore(
|
|||||||
...(state.installations[pluginId]?.version
|
...(state.installations[pluginId]?.version
|
||||||
? { version: state.installations[pluginId].version }
|
? { version: state.installations[pluginId].version }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(state.installations[pluginId]?.channel
|
||||||
|
? { channel: state.installations[pluginId].channel }
|
||||||
|
: {}),
|
||||||
reason,
|
reason,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -740,11 +740,11 @@ describe('PluginPackageStore', () => {
|
|||||||
expect(index.releases.map(({ release_id }) => release_id)).toEqual(['release-2', 'release-1']);
|
expect(index.releases.map(({ release_id }) => release_id)).toEqual(['release-2', 'release-1']);
|
||||||
|
|
||||||
store.registerActiveWorker('release-1');
|
store.registerActiveWorker('release-1');
|
||||||
await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({
|
await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({
|
||||||
status: 'kept', pluginId: PLUGIN_ID, releaseId: 'release-1', version: '1.0.0',
|
status: 'kept', pluginId: PLUGIN_ID, reason: 'active_worker_reference',
|
||||||
});
|
});
|
||||||
await expect(store.readInstalledIndex()).resolves.toHaveLength(1);
|
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 () => {
|
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' }),
|
expect.objectContaining({ releaseId: 'release-worker' }),
|
||||||
]);
|
]);
|
||||||
await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull();
|
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 () => {
|
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);
|
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', () => {
|
it('requires the exact capability manifest path even for direct parsing', () => {
|
||||||
const root = structuredClone(ROOT) as Record<string, unknown>;
|
const root = structuredClone(ROOT) as Record<string, unknown>;
|
||||||
const extensions = root.extensions as Record<string, unknown>;
|
const extensions = root.extensions as Record<string, unknown>;
|
||||||
|
|||||||
@@ -130,6 +130,31 @@ describe('My Plugins', () => {
|
|||||||
expect(screen.getByText('当前频道:Beta')).toBeVisible();
|
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', () => {
|
it('does not silently fall back to stable when the installed Beta channel has no release', () => {
|
||||||
render(<MemoryRouter><MyPluginsView
|
render(<MemoryRouter><MyPluginsView
|
||||||
library={{ ...library, items: [{ ...library.items[0], betaVersion: null }] }}
|
library={{ ...library, items: [{ ...library.items[0], betaVersion: null }] }}
|
||||||
|
|||||||
@@ -199,4 +199,32 @@ describe('plugin Marketplace store', () => {
|
|||||||
reason: 'plugin_signature_invalid: Release signature is invalid',
|
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