From 1edd75e2465ae8bfbba87d8e077365daee144920 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Tue, 1 Sep 2026 01:11:53 +0800 Subject: [PATCH] feat(plugins): share hosted admission resolution --- ...901-web-search-mlw01-admission-b7d5f3a2.md | 113 +++++++++++ .../coding-plugins/adapters/game-resource.ts | 41 ++-- electron/coding-plugins/hosted-admission.ts | 184 ++++++++++++++++++ .../unit/game-resource-plugin-adapter.test.ts | 20 +- tests/unit/hosted-admission.test.ts | 138 +++++++++++++ 5 files changed, 474 insertions(+), 22 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260901-web-search-mlw01-admission-b7d5f3a2.md create mode 100644 electron/coding-plugins/hosted-admission.ts create mode 100644 tests/unit/hosted-admission.test.ts diff --git a/.project-docs/30-worklog/tasks/20260901-web-search-mlw01-admission-b7d5f3a2.md b/.project-docs/30-worklog/tasks/20260901-web-search-mlw01-admission-b7d5f3a2.md new file mode 100644 index 0000000..cf1f226 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-web-search-mlw01-admission-b7d5f3a2.md @@ -0,0 +1,113 @@ +# Task: Implement MLW-01 shared hosted admission resolver + +## Identity + +- Task ID: 20260901-web-search-mlw01-admission-b7d5f3a2 +- Mode: Feature +- Branch: codex/20260901-web-search-mlw01-admission-b7d5f3a2-web-search-mlw01-admission +- Worktree: D:\Datas\OthersProjects\makelore-web-search-mlw01-admission-b7d5f3a2 +- Base commit: c4db68767fcd1916e4441ab0e2c2504210f75e83 +- Owner: web-search-mlw01-implementer +- Status: Ready for Integration + +## Scope + +- Implement MLW-01 shared hosted admission resolution from the exact client + frontier `c4db68767fcd1916e4441ab0e2c2504210f75e83`. +- Add `electron/coding-plugins/hosted-admission.ts` as the single shared, + provider-neutral resolver. It validates a trusted worker snapshot against + the Package Store, MakeLore version, Marketplace channel resolution and the + current account admission, returning only exact `releaseId` and + `releaseAdmissionId` or a typed failure. +- Refactor only the admission portion of + `electron/coding-plugins/adapters/game-resource.ts` to consume the helper; + preserve its existing provider, payload, confirmation, replay, and result + behavior. +- Add seam tests in `tests/unit/hosted-admission.test.ts` and the relevant + admission regression coverage in + `tests/unit/game-resource-plugin-adapter.test.ts`. +- Do not implement Web Search client/adapter, composition, Renderer, Pi + worker, Package Store/effective resolver, Server, or MLW-02/MLW-03 work. + +## Intent And Constraints + +- Project Context Loaded: client AGENTS.md, the complete + `maintain-project-docs` skill, TDD and implement-spec instructions, required + project-memory entry points, the MLW-01 implementation ticket, and the + reviewed Web Search design/implementation sections were read before coding. +- Concurrent Task Gate: Passed. Task-context owner, isolated worktree, branch, + owner, mode, and exact base match; the client coordinator and user root + worktrees remain untouched. +- Planning Gate: Passed. The prior broad Web Search feature/coordinator + records were inspected read-only. Their old `0a86ec8...` base and broad + coordination scope overlap by topic, but the current coordinator explicitly + owns this isolated MLW-01 ticket from the frozen DTO frontier; no unresolved + semantic conflict remains. +- The helper accepts only code-owned plugin identity, trusted worker snapshot, + Package Store, Marketplace client, and MakeLore version. It does not accept + provider/config, payload, billing, or HTTP-route inputs and sends no business + request of its own. +- Use TDD red -> green vertical slices and public seams. Every source change + stays within the five product/test paths above plus this task record. One + source commit with this task record included, clean worktree, and + `READY_FOR_INTEGRATION` handoff are required. No push, PR, deploy, publish, + or real Provider call. +- Project Context Loaded: + - Task ID: `20260901-web-search-mlw01-admission-b7d5f3a2` + - Mode: Feature + - Branch: `codex/20260901-web-search-mlw01-admission-b7d5f3a2-web-search-mlw01-admission` + - Worktree: `D:\Datas\OthersProjects\makelore-web-search-mlw01-admission-b7d5f3a2` + - Base commit: `c4db68767fcd1916e4441ab0e2c2504210f75e83` + - Other active local tasks: old Web Search feature/coordinator records and + unrelated historical review records; no other task owns the files in this + ticket. + - Overlap assessment: topic overlap with the old Web Search coordinator is + resolved by current ticket ownership and exact-base isolation; no code-path + or semantic overlap is left within this ticket's owned files. +- Planned slices: (1) resolver contract and mismatch tests; (2) minimal + resolver implementation; (3) Game Resource seam migration and regression; + (4) focused/adjacent/typecheck/lint/doc gates; (5) one source commit and + clean handoff. + +## Outcome + +- Implemented the provider-neutral `MarketplaceHostedAdmissionResolver` and + migrated Game Resource's admission seam to use it. A worker must carry a + frozen release; the resolver reads that exact installed release, resolves the + current account admission through the selected channel, and returns only the + exact release/admission IDs. Release, channel, action, digest, size, request, + and account mismatches fail with typed bounded errors. No current-release + fallback, provider/config, payload, billing, HTTP, Web Search, or renderer + behavior was added. +- The Game Resource adapter preserves its existing client and tool behavior, + while missing or stale hosted admission now fails closed through the shared + resolver. The only product/test paths changed are the five paths listed in + Scope; this task record is the sole project-doc change. + +## Verification + +- TDD red: the new resolver suite initially collected zero tests because the + new module was absent (expected module-not-found); no product module existed + before the red checkpoint. +- TDD green: hosted-admission and Game Resource admission suites passed 12/12. +- `pnpm run typecheck` passed. +- Adjacent coding-plugin/Game Resource suite passed 74/74 across 6 files. +- Full `pnpm test` passed: 212 files, 1,728 tests, 2 skipped; pressure suite + passed 1/1. `pnpm run lint:check` passed with 0 errors and 5 pre-existing + warnings outside this task (`Home` hook dependency and `Makelore` fast-refresh + exports). Owned-file ESLint and compile checks passed. +- Worktree diff/doc gates are being finalized before the single source commit; + no real provider call, publish, deployment, or production activation was + attempted. + +## Follow-ups + +- Coordinator must cherry-pick the single source commit from this exact base, + then run its repository-local integration and fixed-range review gates. +- Real PostgreSQL, live OpenAI, official signing-key, provider-secret, and + production-activation gates remain outside this client ticket and HOLD as + specified by the parent Web Search rollout. + +## Promotion Candidates + +- None recorded. diff --git a/electron/coding-plugins/adapters/game-resource.ts b/electron/coding-plugins/adapters/game-resource.ts index 983cabf..fcc8f17 100644 --- a/electron/coding-plugins/adapters/game-resource.ts +++ b/electron/coding-plugins/adapters/game-resource.ts @@ -4,6 +4,10 @@ import path from 'node:path'; import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins'; import { PiGameAssetTools } from '../../coding-runtime/pi/extensions/game-assets'; import type { MarketplacePackageClientPort, PluginPackageStore } from '../package-store'; +import { + MarketplaceHostedAdmissionError, + MarketplaceHostedAdmissionResolver, +} from '../hosted-admission'; import { GameResourceClient, GameResourceClientError, @@ -28,6 +32,7 @@ export interface GameResourcePluginAdapterOptions { readonly marketplace: MarketplacePackageClientPort; readonly packageStore: Pick; readonly makeloreVersion: string; + readonly admissionResolver?: MarketplaceHostedAdmissionResolver; readonly gameAssets?: PiGameAssetTools; } @@ -86,6 +91,9 @@ function clientFailure(error: unknown): AdapterInvocationResult { if (error instanceof GameResourceClientError) { return failure(error.code, error.message, error.status, error.retryable); } + if (error instanceof MarketplaceHostedAdmissionError) { + return failure(error.code, error.message, error.status, error.retryable); + } return failure( 'plugin_backend_unavailable', 'Hosted game-resource service is temporarily unavailable', @@ -159,9 +167,15 @@ function templateConfig(value: unknown): Readonly> { export class GameResourcePluginAdapter implements CodingPluginAdapter { readonly pluginId = PLUGIN_ID; private readonly gameAssets: PiGameAssetTools; + private readonly admissionResolver: MarketplaceHostedAdmissionResolver; constructor(private readonly options: GameResourcePluginAdapterOptions) { this.gameAssets = options.gameAssets ?? new PiGameAssetTools(); + this.admissionResolver = options.admissionResolver ?? new MarketplaceHostedAdmissionResolver({ + marketplace: options.marketplace, + packageStore: options.packageStore, + makeloreVersion: options.makeloreVersion, + }); } async inspect(): Promise { @@ -287,28 +301,13 @@ export class GameResourcePluginAdapter implements CodingPluginAdapter { releaseId: string; releaseAdmissionId: string; }> { - const installed = context.pluginReleaseId - ? await this.options.packageStore.getInstalledRelease(PLUGIN_ID, context.pluginReleaseId) - : await this.options.packageStore.getInstalled(PLUGIN_ID); - if (!installed || !installed.channel) { - throw new GameResourceClientError('plugin_release_unavailable', 409, false, 'Installed game-resource Release is unavailable'); - } - const resolved = await this.options.marketplace.resolve({ - resolveRequestId: context.requestId, - makeloreVersion: this.options.makeloreVersion, - channel: installed.channel, - installed: [{ - pluginId: installed.pluginId, - releaseId: installed.releaseId, - sha256: installed.sha256, - }], + return this.admissionResolver.resolve({ + pluginId: PLUGIN_ID, + workerSnapshot: { + requestId: context.requestId, + ...(context.pluginReleaseId === undefined ? {} : { pluginReleaseId: context.pluginReleaseId }), + }, }); - const item = resolved.items.find(({ pluginId }) => pluginId === PLUGIN_ID); - if (!item?.releaseId || !item.releaseAdmissionId || item.releaseId !== installed.releaseId - || (item.action !== 'keep' && item.action !== 'install')) { - throw new GameResourceClientError('plugin_runtime_stale', 409, false, 'Game-resource worker Release is stale'); - } - return { releaseId: item.releaseId, releaseAdmissionId: item.releaseAdmissionId }; } } diff --git a/electron/coding-plugins/hosted-admission.ts b/electron/coding-plugins/hosted-admission.ts new file mode 100644 index 0000000..83c2845 --- /dev/null +++ b/electron/coding-plugins/hosted-admission.ts @@ -0,0 +1,184 @@ +import type { MarketplaceResolveItem } from './account-plugin-cache'; +import type { MarketplacePackageClientPort, PluginPackageStore } from './package-store'; + +const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u; +const RELEASE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; +const REQUEST_ID_PATTERN = /^[\x21-\x7e]{1,128}$/u; +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; + +export interface HostedAdmissionWorkerSnapshot { + /** Main-issued identity for the logical worker operation. */ + readonly requestId: string; + /** Release frozen into this parent worker's effective snapshot. */ + readonly pluginReleaseId?: string; +} + +export interface MarketplaceHostedAdmissionInput { + readonly pluginId: string; + readonly workerSnapshot: HostedAdmissionWorkerSnapshot; +} + +export interface MarketplaceHostedAdmission { + readonly releaseId: string; + readonly releaseAdmissionId: string; +} + +export type MarketplaceHostedAdmissionErrorCode = + | 'plugin_release_unavailable' + | 'plugin_runtime_stale' + | 'plugin_account_changed' + | 'plugin_backend_unavailable'; + +export class MarketplaceHostedAdmissionError extends Error { + constructor( + readonly code: MarketplaceHostedAdmissionErrorCode, + readonly status: number, + readonly retryable: boolean, + message: string, + ) { + super(message); + this.name = 'MarketplaceHostedAdmissionError'; + } +} + +export interface MarketplaceHostedAdmissionResolverOptions { + readonly packageStore: Pick; + readonly marketplace: Pick; + readonly makeloreVersion: string; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function errorCode(value: unknown): unknown { + return isRecord(value) ? value.code : undefined; +} + +function releaseId(value: unknown): string | null { + return typeof value === 'string' && RELEASE_ID_PATTERN.test(value) ? value : null; +} + +function pluginId(value: unknown): string | null { + return typeof value === 'string' && PLUGIN_ID_PATTERN.test(value) ? value : null; +} + +function requestId(value: unknown): string | null { + return typeof value === 'string' && REQUEST_ID_PATTERN.test(value) ? value : null; +} + +function stale(message: string): MarketplaceHostedAdmissionError { + return new MarketplaceHostedAdmissionError('plugin_runtime_stale', 409, false, message); +} + +function unavailable(message: string): MarketplaceHostedAdmissionError { + return new MarketplaceHostedAdmissionError('plugin_release_unavailable', 409, false, message); +} + +function backendUnavailable(): MarketplaceHostedAdmissionError { + return new MarketplaceHostedAdmissionError( + 'plugin_backend_unavailable', + 503, + true, + 'Hosted Plugin admission service is temporarily unavailable', + ); +} + +function accountChanged(): MarketplaceHostedAdmissionError { + return new MarketplaceHostedAdmissionError( + 'plugin_account_changed', + 409, + false, + 'Marketplace account changed while resolving hosted Plugin admission', + ); +} + +function matchesFrozenRelease( + item: MarketplaceResolveItem, + installed: Awaited>, + plugin: string, +): item is MarketplaceResolveItem & { + readonly releaseId: string; + readonly releaseAdmissionId: string; +} { + if (!installed) return false; + if (item.pluginId !== plugin || (item.action !== 'keep' && item.action !== 'install')) return false; + if (releaseId(item.releaseId) !== installed.releaseId + || releaseId(item.releaseAdmissionId) === null + || item.releaseAdmissionId === undefined + || item.releaseAdmissionId === null) return false; + if (item.version !== installed.version || item.sha256 !== installed.sha256) return false; + if (item.sizeBytes !== installed.sizeBytes || item.channel !== installed.channel) return false; + return item.sha256 !== null && item.sha256 !== undefined && SHA256_PATTERN.test(item.sha256); +} + +/** + * Resolves the current account admission for the exact Release frozen into a + * parent worker. Marketplace identity and Release freshness are the only + * concerns here; provider, payload, billing, and transport routes remain + * outside this boundary. + */ +export class MarketplaceHostedAdmissionResolver { + constructor(private readonly options: MarketplaceHostedAdmissionResolverOptions) {} + + async resolve(input: MarketplaceHostedAdmissionInput): Promise { + const plugin = pluginId(input?.pluginId); + const worker = input?.workerSnapshot; + const request = requestId(worker?.requestId); + const frozenRelease = releaseId(worker?.pluginReleaseId); + if (!plugin || !request) throw unavailable('Hosted Plugin worker identity is unavailable'); + if (!frozenRelease) throw unavailable('Hosted Plugin worker Release is unavailable'); + + let installed: Awaited>; + try { + installed = await this.options.packageStore.getInstalledRelease(plugin, frozenRelease); + } catch (error) { + throw this.mapError(error); + } + if (!installed || installed.pluginId !== plugin || installed.releaseId !== frozenRelease + || installed.unavailableReason || !installed.channel) { + throw unavailable('Installed hosted Plugin Release is unavailable'); + } + + let resolved; + try { + resolved = await this.options.marketplace.resolve({ + resolveRequestId: request, + makeloreVersion: this.options.makeloreVersion, + channel: installed.channel, + installed: [{ + pluginId: installed.pluginId, + releaseId: installed.releaseId, + sha256: installed.sha256, + }], + }); + } catch (error) { + throw this.mapError(error); + } + + if (resolved.stale || resolved.resolveRequestId !== request) { + throw stale('Hosted Plugin worker admission is stale'); + } + const item = resolved.items.find(({ pluginId: candidate }) => candidate === plugin); + if (!item || !matchesFrozenRelease(item, installed, plugin)) { + throw stale('Hosted Plugin worker Release admission is stale'); + } + return Object.freeze({ + releaseId: item.releaseId, + releaseAdmissionId: item.releaseAdmissionId, + }); + } + + private mapError(error: unknown): MarketplaceHostedAdmissionError { + if (error instanceof MarketplaceHostedAdmissionError) return error; + const code = errorCode(error); + if (code === 'marketplace_account_changed' || code === 'plugin_account_changed') return accountChanged(); + return backendUnavailable(); + } +} + +export function createMarketplaceHostedAdmissionResolver( + options: MarketplaceHostedAdmissionResolverOptions, +): MarketplaceHostedAdmissionResolver { + return new MarketplaceHostedAdmissionResolver(options); +} diff --git a/tests/unit/game-resource-plugin-adapter.test.ts b/tests/unit/game-resource-plugin-adapter.test.ts index fe0a7cf..eeca4e6 100644 --- a/tests/unit/game-resource-plugin-adapter.test.ts +++ b/tests/unit/game-resource-plugin-adapter.test.ts @@ -94,7 +94,7 @@ async function fixture() { })), getInstalledRelease: vi.fn(async () => ({ pluginId: 'makelore.game-resource', releaseId: RELEASE_ID, version: '1.0.0', - sha256: 'a'.repeat(64), channel: 'stable', + sha256: 'a'.repeat(64), sizeBytes: 1, channel: 'stable', })), } as unknown as Pick, makeloreVersion: '2.0.0', @@ -181,4 +181,22 @@ describe('GameResourcePluginAdapter', () => { releaseId: RELEASE_ID, releaseAdmissionId: 'admission-a', kind: 'pixel', }); }); + + it('fails closed when a worker has no frozen release instead of using the current package', async () => { + const { adapter, context, client, resolve } = await fixture(); + + await expect(adapter.invoke( + { ...context, pluginReleaseId: undefined }, + tool('game_resource_templates'), + { kind: 'pixel' }, + )).resolves.toMatchObject({ + success: false, + status: 409, + code: 'plugin_release_unavailable', + retryable: false, + }); + + expect(resolve).not.toHaveBeenCalled(); + expect(client.templates).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/hosted-admission.test.ts b/tests/unit/hosted-admission.test.ts new file mode 100644 index 0000000..6154def --- /dev/null +++ b/tests/unit/hosted-admission.test.ts @@ -0,0 +1,138 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from 'vitest'; +import { + MarketplaceHostedAdmissionError, + MarketplaceHostedAdmissionResolver, +} from '../../electron/coding-plugins/hosted-admission'; +import type { MarketplacePackageClientPort, PluginPackageStore } from '../../electron/coding-plugins/package-store'; + +const PLUGIN_ID = 'makelore.game-resource'; +const RELEASE_ID = 'release-game-1'; +const REQUEST_ID = 'pi:run-a:resource-a'; +const DIGEST = 'a'.repeat(64); + +function installed() { + return { + pluginId: PLUGIN_ID, + releaseId: RELEASE_ID, + version: '1.0.0', + packageSchemaVersion: 2, + contractVersion: 1, + runtimeKind: 'platform_hosted' as const, + sha256: DIGEST, + sizeBytes: 1, + installedAt: '2026-09-01T00:00:00.000Z', + channel: 'stable' as const, + }; +} + +function resolved(overrides: Record = {}) { + return { + resolveRequestId: REQUEST_ID, + resolveRequestDigest: DIGEST, + items: [{ + pluginId: PLUGIN_ID, + action: 'keep' as const, + releaseId: RELEASE_ID, + version: '1.0.0', + sha256: DIGEST, + sizeBytes: 1, + releaseAdmissionId: 'admission-game-1', + expiresAt: '2100-01-01T00:00:00Z', + channel: 'stable' as const, + reason: null, + ...overrides, + }], + catalogGeneration: 1, + etag: '"plugins-1-tp-free"', + stale: false, + }; +} + +function resolverFixture() { + const getInstalledRelease = vi.fn(async () => installed()); + const resolve = vi.fn(async () => resolved()); + const resolver = new MarketplaceHostedAdmissionResolver({ + packageStore: { getInstalledRelease } as unknown as Pick, + marketplace: { resolve } as unknown as Pick, + makeloreVersion: '2.0.0', + }); + return { resolver, getInstalledRelease, resolve }; +} + +describe('MarketplaceHostedAdmissionResolver', () => { + it('resolves the frozen package release through the selected channel and current account admission', async () => { + const { resolver, getInstalledRelease, resolve } = resolverFixture(); + + await expect(resolver.resolve({ + pluginId: PLUGIN_ID, + workerSnapshot: { requestId: REQUEST_ID, pluginReleaseId: RELEASE_ID }, + })).resolves.toEqual({ + releaseId: RELEASE_ID, + releaseAdmissionId: 'admission-game-1', + }); + + expect(getInstalledRelease).toHaveBeenCalledWith(PLUGIN_ID, RELEASE_ID); + expect(resolve).toHaveBeenCalledWith({ + resolveRequestId: REQUEST_ID, + makeloreVersion: '2.0.0', + channel: 'stable', + installed: [{ pluginId: PLUGIN_ID, releaseId: RELEASE_ID, sha256: DIGEST }], + }); + }); + + it('does not substitute the current package when the worker has no frozen release', async () => { + const { resolver, getInstalledRelease, resolve } = resolverFixture(); + + await expect(resolver.resolve({ + pluginId: PLUGIN_ID, + workerSnapshot: { requestId: REQUEST_ID }, + })).rejects.toMatchObject({ + code: 'plugin_release_unavailable', + status: 409, + retryable: false, + }); + + expect(getInstalledRelease).not.toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); + }); + + it.each([ + ['release mismatch', { releaseId: 'release-other' }], + ['channel mismatch', { channel: 'beta' }], + ['action mismatch', { action: 'update' }], + ['digest mismatch', { sha256: 'b'.repeat(64) }], + ])('returns one typed stale failure for %s', async (_label, item) => { + const marketplace = { + resolve: vi.fn(async () => resolved(item)), + } as unknown as Pick; + const staleResolver = new MarketplaceHostedAdmissionResolver({ + packageStore: { getInstalledRelease: vi.fn(async () => installed()) } as unknown as Pick, + marketplace, + makeloreVersion: '2.0.0', + }); + + const error = await staleResolver.resolve({ + pluginId: PLUGIN_ID, + workerSnapshot: { requestId: REQUEST_ID, pluginReleaseId: RELEASE_ID }, + }).catch((value: unknown) => value); + + expect(error).toBeInstanceOf(MarketplaceHostedAdmissionError); + expect(error).toMatchObject({ code: 'plugin_runtime_stale', status: 409, retryable: false }); + }); + + it('projects an account change as a typed non-retryable worker failure', async () => { + const { resolver, resolve } = resolverFixture(); + resolve.mockRejectedValueOnce({ code: 'marketplace_account_changed' }); + + await expect(resolver.resolve({ + pluginId: PLUGIN_ID, + workerSnapshot: { requestId: REQUEST_ID, pluginReleaseId: RELEASE_ID }, + })).rejects.toMatchObject({ + code: 'plugin_account_changed', + status: 409, + retryable: false, + }); + }); +});