fix(marketplace): preserve release sync semantics

This commit is contained in:
2026-08-29 14:17:39 +08:00
parent d04b031040
commit 227b8214a8
8 changed files with 270 additions and 14 deletions

View File

@@ -186,6 +186,48 @@ describe('coding plugin bounded product service', () => {
});
});
it('projects a Marketplace Skill owner collision as unavailable', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-collision-'));
roots.push(root);
await createCodingProjectMetadata(root, { now: '2026-08-29T00:00:00.000Z' });
const definition = {
...DATA_SERVICE_PLUGIN_DEFINITION,
id: 'makelore.data-service-shadow', displayName: 'Data Service Shadow',
description: 'Conflicting package', requiresBackend: false, runtimeKind: 'skill_only' as const,
acquisitionMode: 'user_acquired' as const, releaseId: 'release-shadow',
provenance: { source: 'marketplace' as const, packageRoot: 'C:/packages/release-shadow' },
adapterId: '', operations: [], tools: [],
skills: [{ id: 'data-service', entryPath: 'skills/data-service/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: 'skill_owner_conflict',
message: 'Plugin Skill ID conflicts with an existing owner',
}],
}),
} as never,
});
await expect(service.list('local-a')).resolves.toMatchObject({
items: [{ id: definition.id, enabled: true, state: 'unavailable' }],
});
});
it('projects the exact three capabilities and fourteen mixed-policy operations', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-policy-join-'));
roots.push(root);

View File

@@ -205,7 +205,10 @@ describe('effective plugin resolver', () => {
{ id: 'shared-skill', entryPath: 'skills/shared/SKILL.md', packageRoot: 'C:/packages/collision-a-1' },
]);
expect(result.pluginReleaseIds).toEqual(['collision-a-1']);
expect(result.unavailableReasons).toEqual([]);
expect(result.unavailableReasons).toEqual(expect.arrayContaining([
expect.objectContaining({ pluginId: coreCollision.id, code: 'skill_owner_conflict' }),
expect.objectContaining({ pluginId: packageB.id, code: 'skill_owner_conflict' }),
]));
});
it('reserves every bundled Skill owner, including Data Service, before accepting Marketplace packages', async () => {
@@ -238,9 +241,10 @@ describe('effective plugin resolver', () => {
});
expect(result.pluginReleaseIds).toEqual([]);
expect(result.unavailableReasons).not.toEqual([
expect.objectContaining({ pluginId: marketplaceDataServiceCollision.id }),
]);
expect(result.unavailableReasons).toContainEqual(expect.objectContaining({
pluginId: marketplaceDataServiceCollision.id,
code: 'skill_owner_conflict',
}));
});
it('requires current Library, installed Release, project selection, and assignment', async () => {
@@ -284,6 +288,40 @@ describe('effective plugin resolver', () => {
});
});
it('keeps installed skill-only local under a trusted stale Library while hosted stays fail-closed', async () => {
const staleNotesLibrary = { ...library(), stale: true };
await expect(resolve(resolver({ getLibrary: vi.fn(async () => staleNotesLibrary) })))
.resolves.toMatchObject({
effectiveSkillIds: ['notes'],
pluginReleaseIds: ['notes-1'],
unavailableReasons: [],
});
const staleHostedLibrary = {
...library(),
stale: true,
items: [{ ...library().items[0], pluginId: serverDefinition.id }],
};
const hosted = createEffectivePluginResolver({
definitions: [serverDefinition],
getAccountBinding: () => binding,
getLibrary: vi.fn(async () => staleHostedLibrary),
getInstalled: vi.fn(async () => installed(serverDefinition)),
getEnabledPluginIds: vi.fn(async () => [serverDefinition.id]),
policyClient: { getState: currentPolicy, refresh: vi.fn() },
});
await expect(hosted.resolve({
projectId: 'project-a', projectPath: 'C:/project-a', assignedSkillIds: ['remote'], role: 'parent',
})).resolves.toMatchObject({
effectiveSkillIds: [],
pluginReleaseIds: [],
unavailableReasons: [expect.objectContaining({
pluginId: serverDefinition.id,
code: 'library_unavailable',
})],
});
});
it('never exposes plugin resources to a child worker', async () => {
const result = await resolver().resolve({
projectId: 'project-a', projectPath: 'C:/project-a', assignedSkillIds: ['notes'], role: 'child',

View File

@@ -374,7 +374,7 @@ describe('Marketplace client and account cache', () => {
});
});
it('derives stable resolve identity from one logical request and changes it when installed state changes', async () => {
it('creates a new resolve identity per logical sync and preserves an explicit replay identity', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_input, init) => {
const request = JSON.parse(init?.body as string) as Record<string, unknown>;
return response({
@@ -400,9 +400,40 @@ describe('Marketplace client and account cache', () => {
await client.resolve(base);
const firstId = requestBody(fetcher, 0).resolve_request_id;
const secondId = requestBody(fetcher, 1).resolve_request_id;
expect(firstId).toBe(secondId);
await client.resolve({ ...base, installed: [{ pluginId: PLUGIN_ID, releaseId: RELEASE_ID, sha256: SHA256 }] });
expect(requestBody(fetcher, 2).resolve_request_id).not.toBe(firstId);
expect(secondId).not.toBe(firstId);
expect(requestBody(fetcher, 1).resolve_request_digest).toBe(requestBody(fetcher, 0).resolve_request_digest);
const replay = { ...base, resolveRequestId: 'makelore-resolve-logical-sync-a' };
await client.resolve(replay);
await client.resolve(replay);
expect(requestBody(fetcher, 2).resolve_request_id).toBe('makelore-resolve-logical-sync-a');
expect(requestBody(fetcher, 3).resolve_request_id).toBe('makelore-resolve-logical-sync-a');
});
it('keeps one generated resolve identity across the authenticated retry of a logical sync', async () => {
const fetcher = vi.fn<typeof fetch>()
.mockResolvedValueOnce(new Response('', { status: 401 }))
.mockImplementationOnce(async (_input, init) => {
const request = JSON.parse(init?.body as string) as Record<string, unknown>;
return response({
resolve_request_id: request.resolve_request_id,
resolve_request_digest: request.resolve_request_digest,
items: [],
catalog_generation: 7,
}, {}, { ETag: '"plugins-7-tp-none"' });
});
const client = createMarketplaceClient({
fetchImpl: fetcher,
apiBaseUrl: 'https://square.example',
getAccessToken: async ({ forceRefresh } = {}) => forceRefresh ? 'refreshed-token' : 'initial-token',
getAccountBinding: () => ACCOUNT_A,
subscribeSession: () => () => undefined,
});
await client.resolve({ makeloreVersion: '1.0.0', channel: 'stable', installed: [] });
expect(requestBody(fetcher, 1).resolve_request_id).toBe(requestBody(fetcher, 0).resolve_request_id);
expect(requestBody(fetcher, 1).resolve_request_digest).toBe(requestBody(fetcher, 0).resolve_request_digest);
});
it('rejects a response body above the bounded DTO limit', async () => {
@@ -505,6 +536,38 @@ describe('PluginPackageStore', () => {
expect(issueDownload).toHaveBeenCalledWith({ releaseId: RELEASE_ID, releaseAdmissionId: ADMISSION_ID });
});
it('assigns a distinct logical resolve identity to each new Package Store sync', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const { grant, publicKey } = signedGrant(archive);
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: () => 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 store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
const first = resolve.mock.calls[0]?.[0].resolveRequestId;
const second = resolve.mock.calls[1]?.[0].resolveRequestId;
expect(first).toMatch(/^makelore-resolve-/u);
expect(second).toMatch(/^makelore-resolve-/u);
expect(second).not.toBe(first);
});
it('removes only releases with no account or active-worker reference', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();