From fe656dd865f1941f1dc2d369ce3bb09efb955fd8 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Mon, 31 Aug 2026 10:45:20 +0800 Subject: [PATCH] feat(plugins): move game resources behind marketplace --- .env.example | 8 - ...831-meowa-hosted-plugin-client-8d3a5b72.md | 112 +++++ electron/api/coding-composition.ts | 12 +- electron/api/route-handlers.ts | 2 - electron/api/routes/meowa-game-assets.ts | 439 ------------------ .../coding-plugins/adapters/game-resource.ts | 319 +++++++++++++ electron/coding-plugins/effective-resolver.ts | 17 + electron/coding-plugins/manifest.ts | 6 +- electron/coding-plugins/package-store.ts | 28 +- electron/coding-plugins/registry.ts | 39 +- electron/coding-runtime/pi/extension-host.ts | 2 - .../pi/extensions/makelore-runtime.ts | 22 - electron/coding-runtime/pi/product-tools.ts | 12 - electron/coding-runtime/pi/worker-process.ts | 2 +- electron/main/index.ts | 7 - electron/services/game-resource-client.ts | 334 +++++++++++++ .../meowa-game-assets-release-credential.ts | 44 -- scripts/after-pack.cjs | 25 +- scripts/lib/pi-product-artifact.mjs | 20 + scripts/run-electron-builder.mjs | 8 - tests/unit/after-pack-hygiene.test.ts | 20 +- tests/unit/coding-capability-registry.test.ts | 130 +++++- .../coding-plugin-marketplace-client.test.ts | 58 +++ ...coding-plugin-marketplace-contract.test.ts | 13 +- tests/unit/game-resource-client.test.ts | 129 +++++ .../unit/game-resource-plugin-adapter.test.ts | 184 ++++++++ ...owa-game-assets-release-credential.test.ts | 51 -- tests/unit/meowa-game-assets-route.test.ts | 292 ------------ tests/unit/pi-extension-bundle.test.ts | 16 +- tests/unit/pi-product-artifact.test.ts | 50 +- tests/unit/pi-product-tools.test.ts | 24 - tests/unit/pi-rpc-foundation.test.ts | 2 +- tests/unit/pi-worker-process-real.test.ts | 4 - 33 files changed, 1413 insertions(+), 1018 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260831-meowa-hosted-plugin-client-8d3a5b72.md delete mode 100644 electron/api/routes/meowa-game-assets.ts create mode 100644 electron/coding-plugins/adapters/game-resource.ts create mode 100644 electron/services/game-resource-client.ts delete mode 100644 electron/services/meowa-game-assets-release-credential.ts create mode 100644 tests/unit/game-resource-client.test.ts create mode 100644 tests/unit/game-resource-plugin-adapter.test.ts delete mode 100644 tests/unit/meowa-game-assets-release-credential.test.ts delete mode 100644 tests/unit/meowa-game-assets-route.test.ts diff --git a/.env.example b/.env.example index 8795d48..41cbbda 100644 --- a/.env.example +++ b/.env.example @@ -18,11 +18,3 @@ CSC_KEY_PASSWORD=certificate_password # GitHub Token for releases GH_TOKEN=github_personal_access_token - -# Meowa release credential -# Keep the real value in the local release shell or CI/CD secret store. -# The electron-builder afterPack hook stages it only in the packaged app; -# never commit the real value to this file or the repository. -# MEOWART_API_KEY=your_meowa_api_key -# Set this to 1 when a package build must fail if the release credential is absent. -# MEOWA_RELEASE_KEY_REQUIRED=1 diff --git a/.project-docs/30-worklog/tasks/20260831-meowa-hosted-plugin-client-8d3a5b72.md b/.project-docs/30-worklog/tasks/20260831-meowa-hosted-plugin-client-8d3a5b72.md new file mode 100644 index 0000000..0483b52 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260831-meowa-hosted-plugin-client-8d3a5b72.md @@ -0,0 +1,112 @@ +# Task: Meowa hosted plugin client package and Marketplace integration + +## Identity + +- Task ID: 20260831-meowa-hosted-plugin-client-8d3a5b72 +- Mode: Feature +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: 62304dc85b3c1069cd656dfacb61ee820e216fa2 +- Owner: codex-root-meowa-client +- Status: Ready for Integration + +## Scope + +- Add the signed Marketplace package definition/Skill and hosted adapter for the + provider-neutral `makelore.game-resource` Plugin. +- Move Meowa-backed generation and existing game-asset browser/review tools behind Plugin + selection, install, Agent assignment, runtime policy, Release Admission, lifecycle, and + `makelore-capability.v1` receipts. +- Remove the direct always-mounted Meowa proxy, local credential configuration, embedded + credential source, and packaging-time plaintext key path. +- Preserve existing local game-asset review UI behavior where it is reached by Plugin tools. + +## Intent And Constraints + +- Plugin/package/UI use `makelore.game-resource`; they never expose `meowa`, a Provider URL, + Provider credit balance, or credentials as public authority. +- Acquisition is free; hosted operations are `platform_metered` and display the server + Token Point policy/receipts. The client cannot quote or choose price/payer/meter. +- Stable logical operation identity survives response loss/Main restart. Accepted jobs map + to dispatched; ambiguous submission maps to pending review with no automatic new request. +- Existing project/Agent config keeps unknown or disabled Plugin assignments inert. Child + workers receive no unassigned game-resource Skill/tools. +- Preserve the user-authorized unrelated untracked record + `.project-docs/30-worklog/tasks/20260827-plugin-ml07-spec-7c5a2e91.md` byte-for-byte and + never stage, edit, delete, stash, or clean it. +- Existing live-shaped credential is never printed or invoked; remove its source and all + release-bundle/env-to-secure-store credential paths. User/operator must revoke it. +- Production trust stays fail closed until the official Marketplace Ed25519 public key is + supplied; ephemeral integration keys do not authorize production activation. +- Concurrent Task Gate: PASS via authorized `--adopt-existing`; no other writer owns the + client root or this semantic slice. +- Planning Gate: PASS after reading required client project memory/AGENTS, Marketplace + Release B contract, current package store/effective resolver/registry/Pi composition, + prior Meowa audit, and official Meowa public contracts. + +## Outcome + +- Added generic schema-2 `platform_hosted` package parsing, immutable Package Store + support, Marketplace/Library resolve and Admission projection, effective Skill/tool + materialization, and capability-registry dispatch for user-acquired hosted Plugins. +- Added the provider-neutral `GameResourceClient` and `makelore.game-resource` adapter. + The seven Marketplace tools are mounted only for an installed, enabled, assigned, + admitted parent worker. Child or unassigned workers receive neither its Skill nor tools. +- Generation requires an explicit Main-side `confirmed: true` before resolve/admission or + charging. Stable logical operation identity survives replay; `submission_unknown` is + distinct from a billing `pending_review` receipt and cannot silently create a new job. + Saving output uses the existing project write lease and bounded project-relative paths. +- Removed the always-mounted Meowa Pi tools, direct Main proxy route, local Provider URL, + env/secure-store release credential source, package-time key injection, and their tests. + The public client now knows only Works Square hosted game-resource routes and never sees + Meowa/provider URLs, credentials, raw responses, credit balance, or provider job IDs. +- Extended the final `app.asar` verifier to prove both `skill_only` and + `platform_hosted`, the reachable game-resource hosted route, and absence of five legacy + Meowa client-authority markers. Production Marketplace trust remains the empty + code-owned fail-closed store; no temporary key was added to the shipped client. +- Preserved the unrelated untracked + `.project-docs/30-worklog/tasks/20260827-plugin-ml07-spec-7c5a2e91.md` without editing, + staging, deleting, stashing, or cleaning it. + +## Verification + +- Hosted/Marketplace/Pi/package focused suite: `13 files, 153 passed, 2 skipped`. +- Full unit suite: `207 files / 1687 passed / 2 skipped`; its only failure is the unchanged + REN-008 pressure threshold when all files run concurrently. The same pressure test passes + independently (`1 passed`) and is byte-identical to the task base. +- `pnpm typecheck` passed. `pnpm lint:check` passed with 0 errors and the same 5 existing + Home/Makelore warnings. Renderer/Main/Preload/utility Vite/Electron build and Windows + x64/NSIS packaging completed successfully before final documentation. +- `pnpm verify:artifact:pi` passed against the packaged application and reports + `schema2PlatformHosted=true`, `legacyMeowaClientAuthorityAbsent=true`, Data Service's + exact ten bundled tools, and the existing Pi 0.84.2 closure. +- `git diff --check` passed for the task changes. No real Provider request, production + Marketplace signature, production install, push, deploy, or publication was performed. + +## Follow-ups + +- Revoke the previously client-exposed Meowa credential externally; removing its source + and package path cannot invalidate a credential already leaked into history/artifacts. +- After the server has a rotated Meowa credential, positive Token Point pricing, and the + official signing key pair, run signed package install/update/rollback, Agent/Pi, + response-loss/pending-review, and real Provider acceptance using the packaged client. +- Keep production installation/activation fail closed until the official Ed25519 public + key is built into `trusted-keys.ts` through the normal reviewed release process. + +## Promotion Candidates + +- Target: `.project-docs/30-worklog/current-state.md`. + Proposal: record that MakeLore implements generic `platform_hosted` Marketplace packages + and the `makelore.game-resource` adapter, while production activation remains held. + Evidence: focused tests, typecheck/lint/build, and final `app.asar` proof. Future impact: + future hosted Plugins reuse the same package/Admission/worker lifecycle. No semantic + conflict; human confirmation is required only to lift production HOLDs. +- Target: `.project-docs/20-architecture/{module-map,data-flow}.md` and + `.project-docs/40-domain/business-rules.md`. + Proposal: document Main -> Works Square as the only hosted Plugin transport, dynamic + parent-worker Skill/tool projection, explicit paid confirmation, stable logical operation + identity, project write leases, and the prohibition on provider credentials/URLs/IDs in + packages or Renderer/Pi results. Evidence: client/service/adapter/registry/package-store + code plus tests and packaged artifact verification. Future impact: prevents reintroducing + provider-specific always-mounted client proxies. No semantic conflict; no + product-direction confirmation is required. diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index 762c9b3..35100a8 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -40,6 +40,8 @@ import { createCodingCapabilityRegistry, } from '../coding-plugins/registry'; import { createDataServicePluginAdapter } from '../coding-plugins/adapters/data-service'; +import { createGameResourcePluginAdapter } from '../coding-plugins/adapters/game-resource'; +import { GameResourceClient } from '../services/game-resource-client'; import { AccountPluginCache } from '../coding-plugins/account-plugin-cache'; import { createMarketplaceClient, @@ -235,6 +237,12 @@ export function createCodingComposition( }); const dataService = createDataServiceOperations({ projects }); const dataServiceAdapter = createDataServicePluginAdapter(dataService); + const gameResourceAdapter = createGameResourcePluginAdapter({ + client: new GameResourceClient(), + marketplace: marketplaceClient, + packageStore, + makeloreVersion: options.clientVersion ?? '2.0.0', + }); const policyClient = options.policyClient ?? new PluginPolicyClient(); const knownPluginIds = new Set(pluginDefinitions.map(({ id }) => id)); // Existing user Releases are discovered from the device index at startup; @@ -270,7 +278,7 @@ export function createCodingComposition( const capabilityRegistry = createCodingCapabilityRegistry({ policyClient, projectPlugins, - adapters: [dataServiceAdapter], + adapters: [dataServiceAdapter, gameResourceAdapter], definitions: pluginDefinitions, effectiveResolver, getDurableProjectId: async (projectPath, localProjectId) => { @@ -286,7 +294,7 @@ export function createCodingComposition( projects, projectPlugins, policyClient, - adapters: [dataServiceAdapter], + adapters: [dataServiceAdapter, gameResourceAdapter], definitions: pluginDefinitions, effectiveResolver, getDefinitions: async () => { diff --git a/electron/api/route-handlers.ts b/electron/api/route-handlers.ts index 27cee5a..2e03b70 100644 --- a/electron/api/route-handlers.ts +++ b/electron/api/route-handlers.ts @@ -15,7 +15,6 @@ import { handleProviderRoutes } from './routes/providers'; import { handleLogRoutes } from './routes/logs'; import { handleUsageRoutes } from './routes/usage'; import { handleFileRoutes } from './routes/files'; -import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets'; import { handleAgentBrowserRoutes } from './routes/agent-browser'; import { handleCodingFileRoutes } from './routes/coding-files'; import { handleCodingAttachmentRoutes } from './routes/coding-attachments'; @@ -58,7 +57,6 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [ handleSettingsRoutes, handleProviderRoutes, handleFileRoutes, - handleMeowaGameAssetsRoutes, handleLogRoutes, handleUsageRoutes, ]; diff --git a/electron/api/routes/meowa-game-assets.ts b/electron/api/routes/meowa-game-assets.ts deleted file mode 100644 index a8f5213..0000000 --- a/electron/api/routes/meowa-game-assets.ts +++ /dev/null @@ -1,439 +0,0 @@ -import { Buffer } from 'node:buffer'; -import type { IncomingMessage, ServerResponse } from 'node:http'; -import type { HostApiContext } from '../context'; -import { parseJsonBody, sendJson } from '../route-utils'; -import { deleteApiKey, getApiKey, storeApiKey } from '../../utils/secure-storage'; -import { proxyAwareFetch } from '../../utils/proxy-fetch'; -import { - readBundledMeowaApiKey, - readEmbeddedMeowaApiKey, -} from '../../services/meowa-game-assets-release-credential'; - -export const MEOWA_GAME_ASSETS_ACCOUNT_ID = 'meowa-game-assets'; -export const MEOWA_GAME_ASSETS_API_BASE_URL = 'https://api.meowa.ai'; -export const MEOWA_GAME_ASSETS_SKILL_VERSION = '2026.06.19.1'; - -const MAX_REFERENCE_FILE_BYTES = 8 * 1024 * 1024; -const MAX_REFERENCE_BYTES = 16 * 1024 * 1024; -const MAX_DOWNLOAD_BYTES = 32 * 1024 * 1024; -const SUPPORTED_KINDS = ['pixel', 'hd'] as const; - -type MeowaGameAssetKind = typeof SUPPORTED_KINDS[number]; -type JsonRecord = Record; - -function asRecord(value: unknown): JsonRecord | null { - return value && typeof value === 'object' && !Array.isArray(value) - ? value as JsonRecord - : null; -} - -function readRequiredString(value: unknown, field: string, maxLength = 2_000): string { - if (typeof value !== 'string' || !value.trim()) { - throw new Error(`Missing ${field}`); - } - const normalized = value.trim(); - if (normalized.length > maxLength) { - throw new Error(`${field} is too long`); - } - return normalized; -} - -function readOptionalString(value: unknown, maxLength = 2_000): string | undefined { - if (typeof value !== 'string' || !value.trim()) return undefined; - const normalized = value.trim(); - if (normalized.length > maxLength) throw new Error('Optional field is too long'); - return normalized; -} - -function readKind(value: unknown): MeowaGameAssetKind { - if (value === 'pixel' || value === 'hd') return value; - throw new Error('kind must be pixel or hd'); -} - -function endpoint(kind: MeowaGameAssetKind, suffix: string): string { - return `${MEOWA_GAME_ASSETS_API_BASE_URL}/api/${kind}-gen${suffix}`; -} - -function upstreamHeaders(apiKey?: string): Record { - return { - Accept: 'application/json', - ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), - }; -} - -async function readResponsePayload(response: Response): Promise { - const text = await response.text(); - if (!text.trim()) return null; - try { - return JSON.parse(text) as unknown; - } catch { - return text.slice(0, 500); - } -} - -function messageFromPayload(payload: unknown, fallback: string): string { - const record = asRecord(payload); - if (record) { - for (const field of ['message', 'error', 'detail', 'msg']) { - const value = record[field]; - if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 500); - } - } - if (typeof payload === 'string' && payload.trim()) return payload.trim().slice(0, 500); - return fallback; -} - -async function sendUpstreamError( - res: ServerResponse, - response: Response, - operation: string, -): Promise { - const payload = await readResponsePayload(response); - const status = response.status >= 400 && response.status < 500 ? response.status : 502; - sendJson(res, status, { - success: false, - error: `${operation}: ${messageFromPayload(payload, `upstream status ${response.status}`)}`, - }); -} - -type MeowaCredentialSource = 'secure-store' | 'environment' | 'release-bundle' | 'embedded' | 'none'; - -async function resolveCredential(): Promise<{ apiKey: string | null; source: MeowaCredentialSource }> { - const stored = await getApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID); - if (stored?.trim()) return { apiKey: stored.trim(), source: 'secure-store' }; - - const environment = process.env.MEOWART_API_KEY?.trim(); - if (environment) { - await storeApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID, environment); - return { apiKey: environment, source: 'environment' }; - } - - const bundled = await readBundledMeowaApiKey(); - if (bundled) { - await storeApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID, bundled); - return { apiKey: bundled, source: 'release-bundle' }; - } - - const embedded = readEmbeddedMeowaApiKey(); - if (embedded) { - await storeApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID, embedded); - return { apiKey: embedded, source: 'embedded' }; - } - - return { apiKey: null, source: 'none' }; -} - -export async function initializeMeowaGameAssetsCredential(): Promise>> { - return resolveCredential(); -} - -async function requireCredential(res: ServerResponse): Promise { - const credential = await resolveCredential(); - if (credential.apiKey) return credential.apiKey; - sendJson(res, 409, { - success: false, - error: 'Meowa 素材服务尚未配置,请联系管理员。', - code: 'MEOWA_API_KEY_MISSING', - }); - return null; -} - -function readTemplateConfig(value: unknown): string { - if (value === undefined || value === null || value === '') return '{}'; - if (typeof value === 'string') { - try { - const parsed = JSON.parse(value) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('templateConfig must be an object'); - return JSON.stringify(parsed); - } catch (error) { - throw new Error(`Invalid templateConfig: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); - } - } - if (!asRecord(value)) throw new Error('templateConfig must be an object'); - return JSON.stringify(value); -} - -function readBoolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - -function readNumber(value: unknown, fallback: number): number { - return typeof value === 'number' && Number.isFinite(value) ? value : fallback; -} - -function readReferenceFiles(value: unknown): Array<{ name: string; mimeType: string; bytes: Buffer }> { - if (value === undefined) return []; - if (!Array.isArray(value)) throw new Error('referenceFiles must be an array'); - - let totalBytes = 0; - return value.map((item, index) => { - const record = asRecord(item); - if (!record) throw new Error(`referenceFiles[${index}] must be an object`); - const encoded = readRequiredString(record.dataBase64, `referenceFiles[${index}].dataBase64`, MAX_REFERENCE_FILE_BYTES * 2); - if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) throw new Error(`referenceFiles[${index}] has invalid base64`); - const bytes = Buffer.from(encoded, 'base64'); - if (bytes.length > MAX_REFERENCE_FILE_BYTES) throw new Error(`referenceFiles[${index}] is too large`); - totalBytes += bytes.length; - if (totalBytes > MAX_REFERENCE_BYTES) throw new Error('referenceFiles are too large'); - return { - name: readOptionalString(record.name, 160) ?? `reference-${index + 1}.png`, - mimeType: readOptionalString(record.mimeType, 120) ?? 'image/png', - bytes, - }; - }); -} - -async function buildGenerationForm(kind: MeowaGameAssetKind, body: JsonRecord): Promise { - const form = new FormData(); - form.set('template_name', readRequiredString(body.templateName, 'templateName', 200)); - form.set('template_config', readTemplateConfig(body.templateConfig)); - form.set('requirement', readRequiredString(body.requirement, 'requirement', 12_000)); - form.set('aspect_ratio', readOptionalString(body.aspectRatio, 40) ?? '1:1'); - form.set('temperature', String(readNumber(body.temperature, 0))); - form.set('include_base64', readBoolean(body.includeBase64, false) ? 'true' : 'false'); - - const optionalFields: Array<[string, unknown, number]> = [ - ['job_name', body.jobName, 200], - ['model_name', body.modelName, 200], - ['resolution', body.resolution, 40], - ['hd_remove_bg_mode', body.hdRemoveBgMode, 80], - ['project_id', body.projectId, 200], - ['thread_id', body.threadId, 200], - ]; - for (const [field, value, maxLength] of optionalFields) { - const normalized = readOptionalString(value, maxLength); - if (normalized) form.set(field, normalized); - } - - const references = readReferenceFiles(body.referenceFiles); - references.forEach((reference, index) => { - const blob = new Blob([reference.bytes], { type: reference.mimeType }); - form.append(index === 0 ? 'reference_file' : 'reference_files', blob, reference.name); - }); - - // Keep this explicit so adding a third generation kind cannot silently send - // HD-only fields to a different Meowa endpoint. - if (kind === 'pixel') { - form.delete('hd_remove_bg_mode'); - } - return form; -} - -async function forwardJson( - res: ServerResponse, - url: string, - init: RequestInit, - operation: string, -): Promise { - const response = await proxyAwareFetch(url, init); - if (!response.ok) { - await sendUpstreamError(res, response, operation); - return; - } - const payload = await readResponsePayload(response); - sendJson(res, response.status, payload); -} - -function parseOutputIndex(value: string | null): number | null { - if (value === null || value.trim() === '') return null; - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 0 || parsed > 99) throw new Error('outputIndex must be an integer from 0 to 99'); - return parsed; -} - -function responseFileName(response: Response, jobId: string): string { - const disposition = response.headers.get('content-disposition') ?? ''; - const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); - if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]).replace(/[\\/\0]/g, '_'); - const match = disposition.match(/filename="?([^";]+)"?/i); - if (match?.[1]) return match[1].replace(/[\\/\0]/g, '_'); - const mime = response.headers.get('content-type')?.toLowerCase() ?? ''; - const suffix = mime.includes('zip') ? '.zip' : mime.includes('jpeg') ? '.jpg' : mime.includes('webp') ? '.webp' : '.png'; - return `${jobId}${suffix}`; -} - -async function handleDownload( - res: ServerResponse, - url: URL, - kind: MeowaGameAssetKind, - apiKey: string, -): Promise { - const jobId = readRequiredString(url.searchParams.get('id'), 'id', 200); - const outputIndex = parseOutputIndex(url.searchParams.get('outputIndex')); - const suffix = outputIndex === null - ? `/jobs/${encodeURIComponent(jobId)}/download` - : `/jobs/${encodeURIComponent(jobId)}/outputs/${outputIndex}/download`; - const response = await proxyAwareFetch(endpoint(kind, suffix), { - method: 'GET', - headers: upstreamHeaders(apiKey), - }); - if (!response.ok) { - await sendUpstreamError(res, response, 'Meowa 素材下载失败'); - return; - } - - const bytes = Buffer.from(await response.arrayBuffer()); - if (bytes.length > MAX_DOWNLOAD_BYTES) { - sendJson(res, 413, { success: false, error: 'Meowa 输出文件超过本地代理大小限制' }); - return; - } - sendJson(res, 200, { - success: true, - fileName: responseFileName(response, jobId), - mimeType: response.headers.get('content-type') || 'application/octet-stream', - bytes: bytes.length, - dataBase64: bytes.toString('base64'), - }); -} - -export async function handleMeowaGameAssetsRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - _ctx: HostApiContext, -): Promise { - if (url.pathname === '/api/meowa/game-assets/config' && req.method === 'GET') { - const credential = await resolveCredential(); - sendJson(res, 200, { - success: true, - configured: Boolean(credential.apiKey), - credentialSource: credential.source, - provider: 'meowa', - apiBaseUrl: MEOWA_GAME_ASSETS_API_BASE_URL, - skillVersion: MEOWA_GAME_ASSETS_SKILL_VERSION, - supportedKinds: [...SUPPORTED_KINDS], - }); - return true; - } - - if (url.pathname === '/api/meowa/game-assets/config' && req.method === 'PUT') { - try { - const body = await parseJsonBody(req); - const apiKey = readRequiredString(body.apiKey, 'apiKey', 512); - const stored = await storeApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID, apiKey); - if (!stored) throw new Error('安全存储写入失败'); - sendJson(res, 200, { - success: true, - configured: true, - credentialSource: 'secure-store', - }); - } catch (error) { - sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/meowa/game-assets/config' && req.method === 'DELETE') { - const deleted = await deleteApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID); - const credential = deleted ? await resolveCredential() : null; - sendJson(res, deleted ? 200 : 500, { - success: deleted, - configured: Boolean(credential?.apiKey), - credentialSource: credential?.source ?? 'none', - }); - return true; - } - - if (url.pathname === '/api/meowa/game-assets/skill-doc' && req.method === 'GET') { - try { - const remoteUrl = new URL(`${MEOWA_GAME_ASSETS_API_BASE_URL}/api/agent-skills/game-assets/doc`); - const task = url.searchParams.get('task'); - const topic = url.searchParams.get('topic'); - if (task) remoteUrl.searchParams.set('task', task.slice(0, 2_000)); - if (topic) remoteUrl.searchParams.set('topic', topic.slice(0, 200)); - await forwardJson(res, remoteUrl.toString(), { method: 'GET', headers: upstreamHeaders() }, 'Meowa Skill 文档获取失败'); - } catch (error) { - sendJson(res, 502, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/meowa/game-assets/template-info' && req.method === 'GET') { - try { - const kind = readKind(url.searchParams.get('kind')); - const apiKey = await requireCredential(res); - if (!apiKey) return true; - await forwardJson( - res, - endpoint(kind, '/template-info'), - { method: 'GET', headers: upstreamHeaders(apiKey) }, - `Meowa ${kind} 模版信息获取失败`, - ); - } catch (error) { - sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/meowa/game-assets/generate' && req.method === 'POST') { - try { - const apiKey = await requireCredential(res); - if (!apiKey) return true; - const body = await parseJsonBody(req); - const kind = readKind(body.kind); - const form = await buildGenerationForm(kind, body); - - const response = await proxyAwareFetch(endpoint(kind, ''), { - method: 'POST', - headers: upstreamHeaders(apiKey), - body: form, - }); - if (!response.ok) { - await sendUpstreamError(res, response, `Meowa ${kind} 素材生成提交失败`); - return true; - } - sendJson(res, response.status, await readResponsePayload(response)); - } catch (error) { - sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/meowa/game-assets/jobs' && req.method === 'GET') { - try { - const kind = readKind(url.searchParams.get('kind')); - const apiKey = await requireCredential(res); - if (!apiKey) return true; - const jobId = readRequiredString(url.searchParams.get('id'), 'id', 200); - await forwardJson( - res, - endpoint(kind, `/jobs?id=${encodeURIComponent(jobId)}`), - { method: 'GET', headers: upstreamHeaders(apiKey) }, - `Meowa ${kind} 任务查询失败`, - ); - } catch (error) { - sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/meowa/game-assets/download' && req.method === 'GET') { - try { - const kind = readKind(url.searchParams.get('kind')); - const apiKey = await requireCredential(res); - if (!apiKey) return true; - await handleDownload(res, url, kind, apiKey); - } catch (error) { - sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - if (url.pathname === '/api/meowa/game-assets/credits' && req.method === 'GET') { - try { - const apiKey = await requireCredential(res); - if (!apiKey) return true; - await forwardJson( - res, - `${MEOWA_GAME_ASSETS_API_BASE_URL}/api/credits/balance`, - { method: 'GET', headers: upstreamHeaders(apiKey) }, - 'Meowa 额度查询失败', - ); - } catch (error) { - sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) }); - } - return true; - } - - return false; -} diff --git a/electron/coding-plugins/adapters/game-resource.ts b/electron/coding-plugins/adapters/game-resource.ts new file mode 100644 index 0000000..983cabf --- /dev/null +++ b/electron/coding-plugins/adapters/game-resource.ts @@ -0,0 +1,319 @@ +import { Buffer } from 'node:buffer'; +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +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 { + GameResourceClient, + GameResourceClientError, + type GameResourceGeneration, +} from '../../services/game-resource-client'; +import type { + AdapterInvocationResult, + CodingPluginAdapter, + PluginBackendProjection, + TrustedCodingCapabilityContext, +} from '../registry'; + +const PLUGIN_ID = 'makelore.game-resource'; +const MAX_REFERENCE_FILE_BYTES = 8 * 1024 * 1024; +const MAX_REFERENCE_BYTES = 16 * 1024 * 1024; +const EXECUTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; + +type Input = Record; + +export interface GameResourcePluginAdapterOptions { + readonly client: GameResourceClient; + readonly marketplace: MarketplacePackageClientPort; + readonly packageStore: Pick; + readonly makeloreVersion: string; + readonly gameAssets?: PiGameAssetTools; +} + +function isRecord(value: unknown): value is Input { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function success( + data: T, + status = 200, + billing?: Extract, { mode: 'platform_metered' }>, +): AdapterInvocationResult { + return { + success: true, + status, + code: null, + error: null, + retryable: false, + payload_schema: 'game-resource.v1', + data, + ...(billing ? { billing } : {}), + }; +} + +function failure( + code: string, + error: string, + status: number, + retryable: boolean, + billing?: Extract, { mode: 'platform_metered' }>, +): AdapterInvocationResult { + return { + success: false, + status, + code, + error, + retryable, + payload_schema: 'game-resource.v1', + data: null, + ...(billing ? { billing } : {}), + }; +} + +function generationData(value: GameResourceGeneration, includeBilling: boolean) { + return { + executionId: value.executionId, + status: value.status, + outputCount: value.outputCount, + ...(value.pollIntervalSeconds === undefined ? {} : { pollIntervalSeconds: value.pollIntervalSeconds }), + ...(value.errorCode === undefined ? {} : { errorCode: value.errorCode }), + ...(includeBilling ? { generationBilling: value.billing } : {}), + }; +} + +function clientFailure(error: unknown): AdapterInvocationResult { + if (error instanceof GameResourceClientError) { + return failure(error.code, error.message, error.status, error.retryable); + } + return failure( + 'plugin_backend_unavailable', + 'Hosted game-resource service is temporarily unavailable', + 503, + true, + ); +} + +function relativeProjectPath(projectPath: string, candidate: unknown): { absolute: string; relative: string } { + if (typeof candidate !== 'string' || !candidate.trim() || candidate.length > 1_024) { + throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Project-relative path is invalid'); + } + const root = path.resolve(projectPath); + const absolute = path.resolve(root, candidate); + const relative = path.relative(root, absolute); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Project-relative path is invalid'); + } + return { absolute, relative: relative.split(path.sep).join('/') }; +} + +function mimeType(filePath: string): string { + switch (path.extname(filePath).toLowerCase()) { + case '.jpg': + case '.jpeg': return 'image/jpeg'; + case '.webp': return 'image/webp'; + case '.gif': return 'image/gif'; + default: return 'image/png'; + } +} + +async function references(projectPath: string, value: unknown) { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > 8) { + throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Reference paths are invalid'); + } + let total = 0; + return await Promise.all(value.map(async (candidate) => { + const target = relativeProjectPath(projectPath, candidate); + const metadata = await stat(target.absolute).catch(() => null); + if (!metadata?.isFile() || metadata.size <= 0 || metadata.size > MAX_REFERENCE_FILE_BYTES) { + throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Reference file is unavailable or too large'); + } + total += metadata.size; + if (total > MAX_REFERENCE_BYTES) { + throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Reference files exceed their total bound'); + } + return { + name: path.basename(target.absolute).slice(0, 160), + mimeType: mimeType(target.absolute), + dataBase64: (await readFile(target.absolute)).toString('base64'), + }; + })); +} + +function templateConfig(value: unknown): Readonly> { + if (value === undefined || value === '') return {}; + if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > 16_384) { + throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Template config JSON is invalid'); + } + let parsed: unknown; + try { parsed = JSON.parse(value) as unknown; } catch { + throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Template config JSON is invalid'); + } + if (!isRecord(parsed)) { + throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Template config JSON must be an object'); + } + return parsed; +} + +export class GameResourcePluginAdapter implements CodingPluginAdapter { + readonly pluginId = PLUGIN_ID; + private readonly gameAssets: PiGameAssetTools; + + constructor(private readonly options: GameResourcePluginAdapterOptions) { + this.gameAssets = options.gameAssets ?? new PiGameAssetTools(); + } + + async inspect(): Promise { + const installed = await this.options.packageStore.getInstalled(PLUGIN_ID).catch(() => null); + return installed ? { status: 'ready' } : { status: 'unconfigured' }; + } + + async invoke( + context: TrustedCodingCapabilityContext, + tool: CodingPluginToolDefinition, + input: unknown, + ): Promise { + if (!isRecord(input)) return failure('plugin_input_invalid', 'Game-resource tool input is invalid', 422, false); + try { + switch (tool.name) { + case 'game_resource_templates': { + const kind = input.kind === 'pixel' || input.kind === 'hd' ? input.kind : null; + if (!kind) return failure('plugin_input_invalid', 'Game-resource kind is invalid', 422, false); + const admission = await this.admission(context); + return success({ kind, templates: await this.options.client.templates({ ...admission, kind }) }); + } + case 'game_resource_generate': { + if (input.confirmed !== true) { + return failure( + 'confirmation_required', + 'Explicit Token Point generation confirmation is required', + 400, + false, + ); + } + const kind = input.kind === 'pixel' || input.kind === 'hd' ? input.kind : null; + if (!kind || typeof input.templateName !== 'string' || typeof input.requirement !== 'string') { + return failure('plugin_input_invalid', 'Game-resource generation input is invalid', 422, false); + } + const admission = await this.admission(context); + const generated = await this.options.client.generate({ + ...admission, + projectId: context.durableProjectId, + logicalOperationId: context.requestId, + kind, + templateName: input.templateName, + templateConfig: templateConfig(input.templateConfigJson), + requirement: input.requirement, + ...(typeof input.aspectRatio === 'string' ? { aspectRatio: input.aspectRatio } : {}), + ...(typeof input.temperature === 'number' ? { temperature: input.temperature } : {}), + ...(typeof input.jobName === 'string' ? { jobName: input.jobName } : {}), + ...(typeof input.modelName === 'string' ? { modelName: input.modelName } : {}), + ...(typeof input.resolution === 'string' ? { resolution: input.resolution } : {}), + ...(typeof input.hdRemoveBgMode === 'string' ? { hdRemoveBgMode: input.hdRemoveBgMode } : {}), + ...(typeof input.threadId === 'string' ? { threadId: input.threadId } : {}), + referenceFiles: await references(context.projectPath, input.referencePaths), + }); + if (generated.status === 'failed' || generated.status === 'cancelled') { + return failure( + generated.errorCode ?? 'game_resource_generation_failed', + 'Hosted game-resource generation was rejected', + 422, + false, + generated.billing, + ); + } + return success( + generationData(generated, false), + generated.status === 'succeeded' ? 200 : 202, + generated.billing, + ); + } + case 'game_resource_status': { + const executionId = this.executionId(input.executionId); + const generated = await this.options.client.get(executionId); + return success(generationData(generated, true)); + } + case 'game_resource_cancel': { + const executionId = this.executionId(input.executionId); + const generated = await this.options.client.cancel(executionId); + return success(generationData(generated, true)); + } + case 'game_resource_save_output': { + if (input.confirmed !== true) return failure('confirmation_required', 'Explicit save confirmation is required', 400, false); + const executionId = this.executionId(input.executionId); + const outputIndex = input.outputIndex === undefined ? undefined : input.outputIndex; + if (outputIndex !== undefined && (!Number.isSafeInteger(outputIndex) || (outputIndex as number) < 0 || (outputIndex as number) > 99)) { + return failure('plugin_input_invalid', 'Game-resource output index is invalid', 422, false); + } + const target = relativeProjectPath(context.projectPath, input.relativePath); + const content = await this.options.client.download(executionId, outputIndex as number | undefined); + await mkdir(path.dirname(target.absolute), { recursive: true }); + try { + await writeFile(target.absolute, content.bytes, { flag: 'wx' }); + } catch (error) { + if (isRecord(error) && error.code === 'EEXIST') { + return failure('game_resource_destination_exists', 'Destination file already exists', 409, false); + } + throw error; + } + return success({ savedPath: target.relative, bytes: content.bytes.byteLength }); + } + case 'game_asset_browser': { + const result = await this.gameAssets.browse(context.projectPath, input, context.requestId); + const parsed = JSON.parse(result.content[0]?.text ?? '{}') as unknown; + return success(parsed); + } + case 'game_asset_review': { + const result = await this.gameAssets.review(context.projectPath, input, context.requestId); + return success(result.details); + } + default: + return failure('plugin_contract_unsupported', 'Game-resource operation is unavailable', 503, true); + } + } catch (error) { + return clientFailure(error); + } + } + + private executionId(value: unknown): string { + if (typeof value !== 'string' || !EXECUTION_ID.test(value)) { + throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Execution ID is invalid'); + } + return value; + } + + private async admission(context: TrustedCodingCapabilityContext): Promise<{ + 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, + }], + }); + 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 }; + } +} + +export function createGameResourcePluginAdapter( + options: GameResourcePluginAdapterOptions, +): GameResourcePluginAdapter { + return new GameResourcePluginAdapter(options); +} diff --git a/electron/coding-plugins/effective-resolver.ts b/electron/coding-plugins/effective-resolver.ts index fda4670..571a177 100644 --- a/electron/coding-plugins/effective-resolver.ts +++ b/electron/coding-plugins/effective-resolver.ts @@ -31,6 +31,8 @@ export interface SkillEntry { /** A policy row copied from the last verified server catalog. */ export interface RuntimePolicy { readonly pluginId: string; + readonly pluginVersion: string; + readonly releaseId: string | null; readonly contractVersion: number; readonly capabilityId: string; readonly operation: string; @@ -90,6 +92,7 @@ export interface EffectivePluginResolverOptions { readonly packageStore?: { readInstalledIndex(): Promise; getInstalled(pluginId: string): Promise; + getInstalledRelease(pluginId: string, releaseId: string): Promise; }; readonly getLibrary?: (binding: AccountBinding) => Promise | MarketplaceLibrarySnapshot | null; readonly marketplace?: { @@ -393,6 +396,8 @@ export class EffectivePluginResolver { if (!policy) continue; runtimePolicies.push({ pluginId: definition.id, + pluginVersion: definition.version, + releaseId: definition.releaseId, contractVersion: definition.contractVersion, capabilityId: operation.capabilityId, operation: operation.operation, @@ -455,6 +460,18 @@ export class EffectivePluginResolver { return this.options.policyClient?.getState() ?? EMPTY_POLICY_STATE; } + async getInstalledDefinition( + pluginId: string, + releaseId?: string | null, + ): Promise { + if (releaseId && this.options.packageStore) { + const installed = await this.options.packageStore.getInstalledRelease(pluginId, releaseId); + return installed && !installed.unavailableReason ? installed.definition : null; + } + const record = (await this.definitionRecords()).find(({ definition }) => definition.id === pluginId); + return record?.installed && !record.unavailableReason ? record.definition : null; + } + private async definitionRecords(): Promise { const base = [ ...(this.options.definitions ?? []), diff --git a/electron/coding-plugins/manifest.ts b/electron/coding-plugins/manifest.ts index d0b4d5f..34faef1 100644 --- a/electron/coding-plugins/manifest.ts +++ b/electron/coding-plugins/manifest.ts @@ -772,7 +772,9 @@ function parseV2Tool( fail(filePath, `tools[${index}].mutation`, 'unknown tool mutation'); } const projectWriteLease = bool(tool.projectWriteLease, filePath, `tools[${index}].projectWriteLease`); - if (projectWriteLease) fail(filePath, `tools[${index}].projectWriteLease`, 'distributed tools cannot request a project write lease'); + if (projectWriteLease && tool.mutation === 'read') { + fail(filePath, `tools[${index}].projectWriteLease`, 'read-only tools cannot request a project write lease'); + } const permissions = uniqueStrings( tool.permissions, filePath, @@ -804,7 +806,7 @@ function parseV2Tool( operation, roles: ['parent'], mutation: mutation as PluginToolMutation, - projectWriteLease: false, + projectWriteLease, permissions, executionMode: tool.executionMode as CodingPluginExecutionMode, inputSchema: freezeDeep(structuredClone(inputSchema)), diff --git a/electron/coding-plugins/package-store.ts b/electron/coding-plugins/package-store.ts index c00e2c9..8b46cb7 100644 --- a/electron/coding-plugins/package-store.ts +++ b/electron/coding-plugins/package-store.ts @@ -320,7 +320,7 @@ function parseIndexDocument(value: unknown): IndexDocument { if (seen.has(key)) fail('plugin_store_index_invalid', `duplicate release ${releaseId}`); seen.add(key); const runtimeKind = record.runtime_kind; - if (runtimeKind !== 'skill_only') { + if (runtimeKind !== 'skill_only' && runtimeKind !== 'platform_hosted') { fail('plugin_store_index_invalid', `invalid release ${index}.runtime_kind`); } const installedAt = boundedText(record.installed_at, `release ${index}.installed_at`, 80); @@ -706,6 +706,16 @@ export class PluginPackageStore { return this.getInstalledFromIndex(index, validated, undefined, current); } + async getInstalledRelease(pluginId: string, releaseId: string): Promise { + const validatedPluginId = validPluginId(pluginId); + const validatedReleaseId = validReleaseId(releaseId); + return this.getInstalledFromIndex( + await this.readIndex(), + validatedPluginId, + validatedReleaseId, + ); + } + async removeUnused(pluginId: string): Promise { const validated = validPluginId(pluginId); return this.withOperation(() => this.removeUnusedLocked(validated, undefined, 'background')); @@ -912,7 +922,7 @@ export class PluginPackageStore { await writeFile(path.join(extractedPath, ORPHAN_ARCHIVE_FILE), toBuffer(artifact), { flag: 'wx' }); await rename(extractedPath, packageRoot); moved = true; - const record = this.installedRecord(grant, channel); + const record = this.installedRecord(grant, channel, definition.runtimeKind); const records = index.releases.filter((candidate) => !(candidate.pluginId === pluginId && candidate.releaseId === grant.releaseId)); this.assertBinding(binding); try { @@ -968,7 +978,7 @@ export class PluginPackageStore { const descriptor = this.verifyArtifact(artifact, input.grant); const definition = await this.loadDefinition(input.packageRoot, input.grant, descriptor); this.assertBinding(input.binding); - const record = this.installedRecord(input.grant, input.channel); + const record = this.installedRecord(input.grant, input.channel, definition.runtimeKind); this.assertBinding(input.binding); try { await this.writeIndex(this.indexPath, serializeIndex({ @@ -1019,14 +1029,18 @@ export class PluginPackageStore { return descriptor; } - private installedRecord(grant: DownloadGrant, channel: 'stable' | 'beta'): InstalledReleaseRecord { + private installedRecord( + grant: DownloadGrant, + channel: 'stable' | 'beta', + runtimeKind: 'skill_only' | 'platform_hosted', + ): InstalledReleaseRecord { return Object.freeze({ pluginId: grant.pluginId, releaseId: grant.releaseId, version: grant.version, packageSchemaVersion: grant.packageSchemaVersion, contractVersion: grant.contractVersion, - runtimeKind: 'skill_only', + runtimeKind, sha256: grant.sha256, sizeBytes: grant.sizeBytes, installedAt: new Date(this.now()).toISOString(), @@ -1193,7 +1207,6 @@ export class PluginPackageStore { let definition: CodingPluginDefinition; try { definition = await loadCodingPluginDefinition(packageRoot, { - runtimeKind: 'skill_only', acquisitionMode: 'user_acquired', releaseId: grant.releaseId, provenance: { source: 'marketplace', packageRoot }, @@ -1204,7 +1217,8 @@ export class PluginPackageStore { } if (definition.id !== descriptor.pluginId || definition.version !== descriptor.version || definition.contractVersion !== descriptor.contractVersion || definition.releaseId !== grant.releaseId - || definition.runtimeKind !== 'skill_only' || definition.acquisitionMode !== 'user_acquired') { + || (definition.runtimeKind !== 'skill_only' && definition.runtimeKind !== 'platform_hosted') + || definition.acquisitionMode !== 'user_acquired') { fail('plugin_manifest_invalid', 'package definition does not match the signed Release'); } return definition; diff --git a/electron/coding-plugins/registry.ts b/electron/coding-plugins/registry.ts index 7e482ea..ab28b47 100644 --- a/electron/coding-plugins/registry.ts +++ b/electron/coding-plugins/registry.ts @@ -41,6 +41,7 @@ export interface TrustedCodingCapabilityContext { durableProjectId: string; workerRole: 'parent' | 'child'; effectiveSkillIds: readonly string[]; + pluginReleaseId?: string; } export type PluginBackendProjection = @@ -527,19 +528,50 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor effectiveSkillIds: readonly string[]; value: unknown; }): Promise { - const indexed = this.toolsByName.get(input.toolName); + let indexed = this.toolsByName.get(input.toolName); const validId = requestId(input.context) !== 'invalid-request-id'; + if (!indexed && this.options.effectiveResolver && input.context.effectiveSnapshot) { + const snapshotTool = input.context.effectiveSnapshot.toolDefinitions + .find(({ name }) => name === input.toolName); + const policy = snapshotTool + ? input.context.effectiveSnapshot.runtimePolicies.find((candidate) => ( + candidate.capabilityId === snapshotTool.capabilityId + && candidate.operation === snapshotTool.operation + )) + : undefined; + const definition = policy + ? await this.options.effectiveResolver.getInstalledDefinition(policy.pluginId, policy.releaseId) + : null; + const tool = definition?.tools.find((candidate) => ( + candidate.name === input.toolName + && candidate.capabilityId === snapshotTool?.capabilityId + && candidate.operation === snapshotTool.operation + )); + if (definition && tool) indexed = { definition, tool }; + } if (!indexed) return this.unknownResult(input.context, 'plugin_backend_unavailable', 'Plugin capability is unavailable'); const { definition, tool } = indexed; if (this.options.effectiveResolver && input.context.effectiveSnapshot) { + const frozenPolicy = input.context.effectiveSnapshot.runtimePolicies.find((candidate) => ( + candidate.pluginId === definition.id + && candidate.capabilityId === tool.capabilityId + && candidate.operation === tool.operation + )); const currentSnapshot = await this.options.effectiveResolver.resolve({ projectId: input.context.projectId, projectPath: input.context.projectPath, assignedSkillIds: input.context.effectiveSnapshot.effectiveSkillIds, role: input.workerRole, }); + const currentPolicy = currentSnapshot.runtimePolicies.find((candidate) => ( + candidate.pluginId === definition.id + && candidate.capabilityId === tool.capabilityId + && candidate.operation === tool.operation + )); if (currentSnapshot.accountSessionId !== input.context.effectiveSnapshot.accountSessionId - || !currentSnapshot.toolDefinitions.some(({ name }) => name === input.toolName)) { + || !currentSnapshot.toolDefinitions.some(({ name }) => name === input.toolName) + || frozenPolicy?.releaseId !== currentPolicy?.releaseId + || frozenPolicy?.pluginVersion !== currentPolicy?.pluginVersion) { const disabled = currentSnapshot.unavailableReasons.some((reason) => ( reason.pluginId === definition.id && reason.code === 'project_disabled' )); @@ -599,6 +631,7 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor durableProjectId, workerRole: input.workerRole, effectiveSkillIds: [...input.effectiveSkillIds], + ...(definition.releaseId ? { pluginReleaseId: definition.releaseId } : {}), }; let result: AdapterInvocationResult; try { @@ -640,7 +673,7 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor code, error, retryable, - payload_schema: 'data-service.v1', + payload_schema: 'unknown', data: null, }, billing); } diff --git a/electron/coding-runtime/pi/extension-host.ts b/electron/coding-runtime/pi/extension-host.ts index 90bea7c..39a1e92 100644 --- a/electron/coding-runtime/pi/extension-host.ts +++ b/electron/coding-runtime/pi/extension-host.ts @@ -22,8 +22,6 @@ const MAX_REQUEST_BYTES = 64 * 1024; const PRODUCT_TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9._:-]{0,63}$/u; const CORE_PRODUCT_TOOL_NAMES = new Set([ 'agent_browser', - 'game_asset_browser', - 'game_asset_review', 'task_state', 'changed_file', 'runtime_context', diff --git a/electron/coding-runtime/pi/extensions/makelore-runtime.ts b/electron/coding-runtime/pi/extensions/makelore-runtime.ts index 5e56e1b..1a14ace 100644 --- a/electron/coding-runtime/pi/extensions/makelore-runtime.ts +++ b/electron/coding-runtime/pi/extensions/makelore-runtime.ts @@ -12,8 +12,6 @@ const MUTATION_TOOLS = new Set([ 'bash', 'edit', 'write', - 'game_asset_browser', - 'game_asset_review', ]); const WORKER_ROLE = process.env.MAKELORE_PI_WORKER_ROLE || 'parent'; const leases = new Map(); @@ -272,26 +270,6 @@ export default async function makeloreRuntime(pi) { }, }, ); - if (WORKER_ROLE === 'parent') registerProductTool( - pi, - 'game_asset_browser', - 'Game assets', - 'Load product-owned game asset candidates and their current review state.', - { type: 'object', additionalProperties: false, properties: { invocationId: { type: 'string' } } }, - ); - if (WORKER_ROLE === 'parent') registerProductTool( - pi, - 'game_asset_review', - 'Game asset review', - 'Load one versioned game asset review interaction without encoding decisions in message text.', - { - type: 'object', additionalProperties: false, - properties: { - invocationId: { type: 'string' }, - candidateIds: { type: 'array', maxItems: 200, items: { type: 'string' } }, - }, - }, - ); if (WORKER_ROLE === 'parent') registerProductTool( pi, 'task_state', diff --git a/electron/coding-runtime/pi/product-tools.ts b/electron/coding-runtime/pi/product-tools.ts index ecbff90..8ae250d 100644 --- a/electron/coding-runtime/pi/product-tools.ts +++ b/electron/coding-runtime/pi/product-tools.ts @@ -24,21 +24,16 @@ import type { KnownToolDetails, RuntimeContextDetailsV1 } from '../contracts'; import { BUNDLED_CODING_SKILL_IDS } from '../../../shared/coding-skills'; import { PiAgentBrowserTool } from './extensions/agent-browser'; import { reportChangedFiles } from './extensions/changed-file'; -import { PiGameAssetTools } from './extensions/game-assets'; import { projectTaskState } from './extensions/task-state'; export type PiProductToolName = | 'agent_browser' - | 'game_asset_browser' - | 'game_asset_review' | 'task_state' | 'changed_file' | 'runtime_context'; const PI_PRODUCT_TOOL_NAMES = new Set([ 'agent_browser', - 'game_asset_browser', - 'game_asset_review', 'task_state', 'changed_file', 'runtime_context', @@ -77,7 +72,6 @@ export interface PiProductToolsOptions { export class PiProductTools { readonly changeTracker: ConversationChangeTracker; private readonly browser: PiAgentBrowserTool; - private readonly gameAssets = new PiGameAssetTools(); private capabilityRegistry: CodingCapabilityRegistry | undefined; constructor(private readonly options: PiProductToolsOptions) { @@ -152,12 +146,6 @@ export class PiProductTools { if (toolName === 'agent_browser') { return await this.browser.execute(context, input); } - if (toolName === 'game_asset_browser') { - return await this.gameAssets.browse(context.projectPath, input, context.resourceId); - } - if (toolName === 'game_asset_review') { - return await this.gameAssets.review(context.projectPath, input, context.resourceId); - } if (toolName === 'task_state') return projectTaskState(input); if (toolName === 'changed_file') { return await reportChangedFiles(this.changeTracker, context, input); diff --git a/electron/coding-runtime/pi/worker-process.ts b/electron/coding-runtime/pi/worker-process.ts index 2991add..94a1677 100644 --- a/electron/coding-runtime/pi/worker-process.ts +++ b/electron/coding-runtime/pi/worker-process.ts @@ -124,7 +124,7 @@ export type PiWorkerProcessOptions = { /** Tools available to every managed parent worker before plugin materialization. */ export const PI_CORE_TOOL_NAMES = Object.freeze([ 'read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'ask_user', 'subagent', - 'agent_browser', 'game_asset_browser', 'game_asset_review', + 'agent_browser', 'task_state', 'changed_file', 'runtime_context', ] as const); diff --git a/electron/main/index.ts b/electron/main/index.ts index 5612236..2100f5c 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -70,7 +70,6 @@ import { import { shouldUseSecureWorksSquareSessionPersistence } from '../services/works-square-session-persistence-policy'; import { initializeRememberedPassword } from '../services/remembered-password'; import { clearManagedWorksSquareRuntimeBestEffort } from '../services/works-square-runtime'; -import { initializeMeowaGameAssetsCredential } from '../api/routes/meowa-game-assets'; import { WorksSquareDesignWorkspace } from '../image-workspace/works-square-workspace'; import type { DesignWorkspaceModule } from '../image-workspace/module'; import { @@ -603,12 +602,6 @@ async function initialize(): Promise { // services, but never before the first local paint. await runDeferred('proxy', applyProxySettings); await runDeferred('telemetry', initTelemetry); - await runDeferred('game-assets credential', async () => { - const meowaCredential = await initializeMeowaGameAssetsCredential(); - if (meowaCredential.source !== 'none') { - logger.info(`Meowa game-assets credential initialized via ${meowaCredential.source}`); - } - }); await runDeferred('launch-at-startup setting', syncLaunchAtStartupSettingFromStore); } diff --git a/electron/services/game-resource-client.ts b/electron/services/game-resource-client.ts new file mode 100644 index 0000000..93b57d8 --- /dev/null +++ b/electron/services/game-resource-client.ts @@ -0,0 +1,334 @@ +import { Buffer } from 'node:buffer'; +import type { CapabilityBillingReceiptV1 } from '../../shared/data-service'; +import { WORKS_SQUARE_CONFIG } from '../api/works-config'; +import { proxyAwareFetch } from '../utils/proxy-fetch'; +import { getValidWorksSquareAccessToken } from './works-square-session'; + +const MAX_JSON_BYTES = 1_048_576; +const MAX_REQUEST_BYTES = 24 * 1024 * 1024; +const MAX_CONTENT_BYTES = 32 * 1024 * 1024; +const EXECUTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; +const DECIMAL = /^(?:0|[1-9]\d*)\.\d{2}$/u; +const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'cancelled', 'pending_review']); + +type FetchImplementation = typeof fetch; +type AccessTokenGetter = typeof getValidWorksSquareAccessToken; +type JsonRecord = Record; + +export interface GameResourceGeneration { + readonly executionId: string; + readonly releaseId: string; + readonly projectId: string; + readonly logicalOperationId: string; + readonly kind: 'pixel' | 'hd'; + readonly templateName: string; + readonly status: 'reserved' | 'accepted' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'submission_unknown' | 'pending_review'; + readonly outputCount: number; + readonly pollIntervalSeconds?: number; + readonly errorCode?: string; + readonly billing: Extract; +} + +export interface GameResourceContent { + readonly bytes: Uint8Array; + readonly fileName: string | null; + readonly contentType: string; +} + +export interface GameResourceGenerateInput { + readonly releaseAdmissionId: string; + readonly releaseId: string; + readonly projectId: string; + readonly logicalOperationId: string; + readonly kind: 'pixel' | 'hd'; + readonly templateName: string; + readonly templateConfig: Readonly>; + readonly requirement: string; + readonly aspectRatio?: string; + readonly temperature?: number; + readonly jobName?: string; + readonly modelName?: string; + readonly resolution?: string; + readonly hdRemoveBgMode?: string; + readonly threadId?: string; + readonly referenceFiles?: readonly { + name: string; + mimeType: string; + dataBase64: string; + }[]; +} + +export class GameResourceClientError extends Error { + constructor( + readonly code: string, + readonly status: number, + readonly retryable: boolean, + message: string, + ) { + super(message); + this.name = 'GameResourceClientError'; + } +} + +export interface GameResourceClientOptions { + readonly fetchImpl?: FetchImplementation; + readonly getAccessToken?: AccessTokenGetter; + readonly apiBaseUrl?: string; +} + +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function text(value: unknown, maximum: number): string | null { + return typeof value === 'string' && value.length > 0 && value.length <= maximum ? value : null; +} + +function integer(value: unknown, minimum: number, maximum: number): number | null { + return Number.isSafeInteger(value) && (value as number) >= minimum && (value as number) <= maximum + ? value as number + : null; +} + +function billing(value: unknown): Extract { + if (!isRecord(value) || value.mode !== 'platform_metered') throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted billing receipt is invalid'); + const status = text(value.status, 32); + const reservedPoints = text(value.reserved_points, 32); + const actualPoints = value.actual_points === null || value.actual_points === undefined + ? undefined + : text(value.actual_points, 32) ?? undefined; + const usageAmount = value.usage_amount === null || value.usage_amount === undefined + ? undefined + : integer(value.usage_amount, 0, Number.MAX_SAFE_INTEGER) ?? undefined; + if (!status || !['reserved', 'dispatched', 'settled', 'released', 'expired', 'pending_review', 'refunded'].includes(status) + || !reservedPoints || !DECIMAL.test(reservedPoints) + || (actualPoints !== undefined && !DECIMAL.test(actualPoints)) + || (['settled', 'refunded'].includes(status) && actualPoints === undefined) + || value.unit !== 'generation') { + throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted billing receipt is invalid'); + } + return { + mode: 'platform_metered', + status: status as Extract['status'], + reserved_points: reservedPoints, + ...(actualPoints === undefined ? {} : { actual_points: actualPoints }), + ...(usageAmount === undefined ? {} : { usage_amount: usageAmount }), + unit: 'generation', + } as Extract; +} + +function generation(value: unknown): GameResourceGeneration { + if (!isRecord(value) || value.schema_version !== 1 || value.plugin_id !== 'makelore.game-resource') { + throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted generation response is invalid'); + } + const executionId = text(value.execution_id, 36); + const releaseId = text(value.release_id, 36); + const projectId = text(value.project_id, 36); + const logicalOperationId = text(value.logical_operation_id, 128); + const templateName = text(value.template_name, 200); + const status = text(value.status, 32); + const outputCount = integer(value.output_count, 0, 100); + const poll = value.poll_interval_seconds === null || value.poll_interval_seconds === undefined + ? undefined + : integer(value.poll_interval_seconds, 1, 300) ?? undefined; + const errorCode = value.error_code === null || value.error_code === undefined + ? undefined + : text(value.error_code, 128) ?? undefined; + if (!executionId || !EXECUTION_ID.test(executionId) || !releaseId || !projectId || !logicalOperationId + || (value.kind !== 'pixel' && value.kind !== 'hd') || !templateName + || !status || !['reserved', 'accepted', 'running', 'succeeded', 'failed', 'cancelled', 'submission_unknown', 'pending_review'].includes(status) + || outputCount === null) { + throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted generation response is invalid'); + } + return { + executionId, + releaseId, + projectId, + logicalOperationId, + kind: value.kind, + templateName, + status: status as GameResourceGeneration['status'], + outputCount, + ...(poll === undefined ? {} : { pollIntervalSeconds: poll }), + ...(errorCode === undefined ? {} : { errorCode }), + billing: billing(value.billing), + }; +} + +async function readBounded(response: Response, maximum: number): Promise { + const declared = response.headers.get('content-length'); + if (declared && /^\d+$/u.test(declared) && Number(declared) > maximum) { + await response.body?.cancel().catch(() => undefined); + throw new GameResourceClientError('plugin_backend_response_too_large', 502, false, 'Hosted response exceeds its bound'); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > maximum) throw new GameResourceClientError('plugin_backend_response_too_large', 502, false, 'Hosted response exceeds its bound'); + return bytes; +} + +function errorFrom(status: number, payload: unknown): GameResourceClientError { + const detail = isRecord(payload) && isRecord(payload.detail) ? payload.detail : null; + const code = text(detail?.error_code, 128) ?? 'plugin_backend_unavailable'; + const retryable = status >= 500 || status === 429; + const message = status === 401 + ? 'Works Square sign-in is required' + : status === 402 + ? 'Token Point balance is insufficient' + : status === 409 + ? 'Hosted game-resource operation conflicts with current state' + : status === 422 + ? 'Hosted game-resource request is invalid' + : 'Hosted game-resource service is unavailable'; + return new GameResourceClientError(code, status, retryable, message); +} + +function encodedBody(value: unknown): string { + const result = JSON.stringify(value); + if (Buffer.byteLength(result, 'utf8') > MAX_REQUEST_BYTES) { + throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Hosted game-resource request is too large'); + } + return result; +} + +export class GameResourceClient { + private readonly fetchImpl: FetchImplementation; + private readonly getAccessToken: AccessTokenGetter; + private readonly apiBaseUrl: string; + + constructor(options: GameResourceClientOptions = {}) { + this.fetchImpl = options.fetchImpl ?? proxyAwareFetch; + this.getAccessToken = options.getAccessToken ?? getValidWorksSquareAccessToken; + this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/u, ''); + } + + async templates(input: { releaseId: string; releaseAdmissionId: string; kind: 'pixel' | 'hd' }): Promise { + const query = new URLSearchParams({ + release_id: input.releaseId, + release_admission_id: input.releaseAdmissionId, + kind: input.kind, + }); + const payload = await this.json('GET', `/api/plugins/v1/hosted/game-resource/templates?${query}`); + if (!isRecord(payload) || payload.schema_version !== 1 || payload.kind !== input.kind + || !Array.isArray(payload.templates) || payload.templates.length > 100 + || payload.templates.some((item) => !text(item, 200))) { + throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted template response is invalid'); + } + return payload.templates as string[]; + } + + async generate(input: GameResourceGenerateInput): Promise { + return generation(await this.json('POST', '/api/plugins/v1/hosted/game-resource/generations', { + release_admission_id: input.releaseAdmissionId, + release_id: input.releaseId, + project_id: input.projectId, + logical_operation_id: input.logicalOperationId, + kind: input.kind, + template_name: input.templateName, + template_config: input.templateConfig, + requirement: input.requirement, + aspect_ratio: input.aspectRatio ?? '1:1', + temperature: input.temperature ?? 0, + ...(input.jobName ? { job_name: input.jobName } : {}), + ...(input.modelName ? { model_name: input.modelName } : {}), + ...(input.resolution ? { resolution: input.resolution } : {}), + ...(input.hdRemoveBgMode ? { hd_remove_bg_mode: input.hdRemoveBgMode } : {}), + ...(input.threadId ? { thread_id: input.threadId } : {}), + reference_files: input.referenceFiles?.map((item) => ({ + name: item.name, + mime_type: item.mimeType, + data_base64: item.dataBase64, + })) ?? [], + })); + } + + async get(executionId: string): Promise { + return generation(await this.json('GET', `/api/plugins/v1/hosted/game-resource/generations/${encodeURIComponent(executionId)}`)); + } + + async cancel(executionId: string): Promise { + return generation(await this.json('POST', `/api/plugins/v1/hosted/game-resource/generations/${encodeURIComponent(executionId)}/cancel`)); + } + + async download(executionId: string, outputIndex?: number): Promise { + const suffix = outputIndex === undefined ? '' : `?output_index=${outputIndex}`; + const response = await this.send('GET', `/api/plugins/v1/hosted/game-resource/generations/${encodeURIComponent(executionId)}/content${suffix}`); + if (!response.ok) { + const bytes = await readBounded(response, MAX_JSON_BYTES).catch(() => new Uint8Array()); + let payload: unknown = null; + try { payload = JSON.parse(Buffer.from(bytes).toString('utf8')) as unknown; } catch { /* bounded generic error */ } + throw errorFrom(response.status, payload); + } + const bytes = await readBounded(response, MAX_CONTENT_BYTES); + const disposition = response.headers.get('content-disposition') ?? ''; + const encoded = /filename\*=UTF-8''([^;]+)/iu.exec(disposition)?.[1]; + return { + bytes, + fileName: encoded ? decodeURIComponent(encoded).slice(0, 200) : null, + contentType: (response.headers.get('content-type') ?? 'application/octet-stream').slice(0, 128), + }; + } + + private async json(method: 'GET' | 'POST', path: string, body?: unknown): Promise { + const response = await this.send(method, path, body); + const bytes = await readBounded(response, MAX_JSON_BYTES); + let payload: unknown; + try { + payload = bytes.byteLength ? JSON.parse(Buffer.from(bytes).toString('utf8')) as unknown : null; + } catch { + throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted response is invalid'); + } + if (!response.ok) throw errorFrom(response.status, payload); + return payload; + } + + private async send(method: 'GET' | 'POST', path: string, body?: unknown): Promise { + let token: string | null; + try { + token = await this.getAccessToken({ fetchImpl: this.fetchImpl }); + } catch { + token = null; + } + if (!token) throw new GameResourceClientError('authentication_required', 401, false, 'Works Square sign-in is required'); + const encoded = body === undefined ? undefined : encodedBody(body); + const request = async (accessToken: string) => await this.fetchImpl(`${this.apiBaseUrl}${path}`, { + method, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + ...(encoded === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(encoded === undefined ? {} : { body: encoded }), + redirect: 'manual', + signal: AbortSignal.timeout(35_000), + }); + let response: Response; + try { + response = await request(token); + } catch { + throw new GameResourceClientError('plugin_backend_unavailable', 503, true, 'Hosted game-resource service is unavailable'); + } + if (response.status !== 401) return response; + await response.body?.cancel().catch(() => undefined); + let refreshed: string | null; + try { + refreshed = await this.getAccessToken({ fetchImpl: this.fetchImpl, forceRefresh: true }); + } catch { + refreshed = null; + } + if (!refreshed) throw new GameResourceClientError('authentication_required', 401, false, 'Works Square sign-in is required'); + try { + response = await request(refreshed); + } catch { + throw new GameResourceClientError('plugin_backend_unavailable', 503, true, 'Hosted game-resource service is unavailable'); + } + if (response.status === 401) { + await response.body?.cancel().catch(() => undefined); + throw new GameResourceClientError('authentication_required', 401, false, 'Works Square sign-in is required'); + } + return response; + } +} + +export function isTerminalGameResourceStatus(status: string): boolean { + return TERMINAL_STATUSES.has(status); +} diff --git a/electron/services/meowa-game-assets-release-credential.ts b/electron/services/meowa-game-assets-release-credential.ts deleted file mode 100644 index 55de162..0000000 --- a/electron/services/meowa-game-assets-release-credential.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; - -export const MEOWA_RELEASE_CREDENTIAL_FILE_NAME = 'meowa-game-assets-credential.json'; - -const EMBEDDED_MEOWA_API_KEY = 'ma_live_mY19lufmkTJNULToKGUH2yKJX0YmKHhg'; - -export function readEmbeddedMeowaApiKey(): string | null { - const apiKey = EMBEDDED_MEOWA_API_KEY.trim(); - return apiKey && apiKey.length <= 512 ? apiKey : null; -} - -type JsonRecord = Record; - -function asRecord(value: unknown): JsonRecord | null { - return value && typeof value === 'object' && !Array.isArray(value) - ? value as JsonRecord - : null; -} - -export function getMeowaReleaseCredentialPath(resourcesPath: string): string { - return join(resourcesPath, 'resources', MEOWA_RELEASE_CREDENTIAL_FILE_NAME); -} - -/** - * Read the release-only credential staged by electron-builder's afterPack hook. - * This file is intentionally not part of the source tree; it exists only in a - * packaged artifact built with MEOWART_API_KEY present in the release env. - */ -export async function readBundledMeowaApiKey(resourcesPath = process.resourcesPath): Promise { - if (typeof resourcesPath !== 'string' || !resourcesPath.trim()) return null; - - try { - const raw = await readFile(getMeowaReleaseCredentialPath(resourcesPath), 'utf8'); - const record = asRecord(JSON.parse(raw) as unknown); - if (!record) return null; - if (record.schemaVersion !== 1) return null; - - const apiKey = typeof record.apiKey === 'string' ? record.apiKey.trim() : ''; - return apiKey && apiKey.length <= 512 ? apiKey : null; - } catch { - return null; - } -} diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 60d6863..dd3ebe0 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -1,14 +1,11 @@ const { execFileSync } = require('child_process'); -const { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } = require('fs'); +const { cpSync, existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } = require('fs'); const { join } = require('path'); -const MEOWA_RELEASE_CREDENTIAL_FILE_NAME = 'meowa-game-assets-credential.json'; - exports.default = async function afterPack(context) { const platform = context.electronPlatformName; console.log(`[after-pack] Target: ${platform}/${context.arch}`); - writeMeowaReleaseCredential(context); copyPublishRuntime(context); assertNoPersistedUserData(context.appOutDir); @@ -32,25 +29,6 @@ function copyPublishRuntime(context) { console.log('[after-pack] Copied self-contained npm publish runtime.'); } -function writeMeowaReleaseCredential(context) { - const apiKey = process.env.MEOWART_API_KEY?.trim(); - if (!apiKey) { - console.log('[after-pack] Meowa release credential not included; MEOWART_API_KEY is unset.'); - return false; - } - - const resourcesDir = context.packager.getResourcesDir(context.appOutDir); - const target = join(resourcesDir, 'resources', MEOWA_RELEASE_CREDENTIAL_FILE_NAME); - mkdirSync(join(resourcesDir, 'resources'), { recursive: true }); - writeFileSync( - target, - `${JSON.stringify({ schemaVersion: 1, apiKey })}\n`, - { encoding: 'utf8', mode: 0o600 }, - ); - console.log('[after-pack] Meowa release credential included in packaged Main resources.'); - return true; -} - const PERSISTED_USER_DATA_FILES = new Set([ 'gateway-prelaunch-maintenance-cache.json', 'niancode-device-identity.json', @@ -227,4 +205,3 @@ function patchNsisExtractionMacro() { } exports.assertNoPersistedUserData = assertNoPersistedUserData; -exports.writeMeowaReleaseCredential = writeMeowaReleaseCredential; diff --git a/scripts/lib/pi-product-artifact.mjs b/scripts/lib/pi-product-artifact.mjs index 7b1e6d7..43ba082 100644 --- a/scripts/lib/pi-product-artifact.mjs +++ b/scripts/lib/pi-product-artifact.mjs @@ -37,9 +37,14 @@ const MARKETPLACE_ARTIFACT_MARKERS = Object.freeze({ packageTrust: Object.freeze([ 'makelore-plugin-release.v1', 'skill_only', + 'platform_hosted', 'plugin_signature_invalid', 'signing key is not trusted', ]), + hostedRuntime: Object.freeze([ + 'makelore.game-resource', + '/api/plugins/v1/hosted/game-resource/generations', + ]), mainRoutes: Object.freeze([ '/api/coding/plugin-marketplace', 'plugin-marketplace\\/install\\/', @@ -56,6 +61,13 @@ const MARKETPLACE_ARTIFACT_MARKERS = Object.freeze({ '我的插件', ]), }); +const FORBIDDEN_MEOWA_ARTIFACT_MARKERS = Object.freeze([ + 'MEOWA_API_KEY', + 'MEOWA_API_URL', + 'MEOWA_GAME_ASSETS_SHARED_SECRET', + '/api/coding/meowa-game-assets', + 'https://api.meowa.ai', +]); const CODE_OWNED_PLUGIN_SIGNING_KEYS_SOURCE_MARKER = 'makelore.plugin-trust.code-owned.v1'; const PI_AI_PROVIDER_PREFIX = 'pi-runtime/node_modules/@earendil-works/pi-ai/dist/providers/'; const PI_AI_PROVIDER_ASAR_PREFIX = 'app.asar/node_modules/@earendil-works/pi-ai/dist/providers/'; @@ -297,6 +309,12 @@ export function verifyMarketplaceClientArtifact(appAsarContents, productionTrust if (missing.length > 0) { throw new Error(`Packaged app.asar does not contain Marketplace contract markers: ${missing.join(', ')}`); } + const forbidden = FORBIDDEN_MEOWA_ARTIFACT_MARKERS.filter((marker) => ( + appAsarContents.includes(Buffer.from(marker)) + )); + if (forbidden.length > 0) { + throw new Error(`Packaged app.asar still contains legacy Meowa client authority: ${forbidden.join(', ')}`); + } const hasEmptyCodeOwnedTrust = /(?:CODE_OWNED_PLUGIN_SIGNING_KEYS\s*=\s*)?Object\.freeze\(\s*\{\}\s*(?:as\s+[^)]*)?\)/u.test(productionTrustSource); if (!hasEmptyCodeOwnedTrust || !productionTrustSource.includes(CODE_OWNED_PLUGIN_SIGNING_KEYS_SOURCE_MARKER) @@ -307,6 +325,8 @@ export function verifyMarketplaceClientArtifact(appAsarContents, productionTrust } return { schema2SkillOnly: true, + schema2PlatformHosted: true, + legacyMeowaClientAuthorityAbsent: true, productionTrust: 'official-key-absent-fail-closed', libraryInstallAndEffectiveRoutes: true, rendererAssets: true, diff --git a/scripts/run-electron-builder.mjs b/scripts/run-electron-builder.mjs index d28d5e1..5d7c2d6 100644 --- a/scripts/run-electron-builder.mjs +++ b/scripts/run-electron-builder.mjs @@ -11,14 +11,6 @@ const ELECTRON_BUILDER_BIN = process.platform === 'win32' : path.join(ROOT, 'node_modules', '.bin', 'electron-builder'); const args = process.argv.slice(2); -const publishingRelease = args.some((arg, index) => arg === '--publish' && args[index + 1] === 'always') - || args.includes('--publish=always'); -const releaseCredentialRequired = publishingRelease || process.env.MEOWA_RELEASE_KEY_REQUIRED === '1'; -if (releaseCredentialRequired && !process.env.MEOWART_API_KEY?.trim()) { - console.error('[release] MEOWART_API_KEY is required for a Meowa-enabled release build.'); - process.exit(1); -} - function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; } diff --git a/tests/unit/after-pack-hygiene.test.ts b/tests/unit/after-pack-hygiene.test.ts index 00cee48..194f3f5 100644 --- a/tests/unit/after-pack-hygiene.test.ts +++ b/tests/unit/after-pack-hygiene.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createRequire } from 'node:module'; @@ -7,7 +7,6 @@ import { afterEach, describe, expect, it } from 'vitest'; const require = createRequire(import.meta.url); const afterPack = require('../../scripts/after-pack.cjs') as { assertNoPersistedUserData?: (appOutDir: string) => void; - writeMeowaReleaseCredential?: (context: { appOutDir: string; packager: { getResourcesDir: (appOutDir: string) => string } }) => boolean; }; const tempDirs: string[] = []; @@ -19,7 +18,6 @@ function makeTempDir(): string { } afterEach(() => { - delete process.env.MEOWART_API_KEY; for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } @@ -55,20 +53,4 @@ describe('after-pack package hygiene', () => { expect(() => afterPack.assertNoPersistedUserData?.(appOutDir)).not.toThrow(); }); - - it('stages the release credential inside packaged resources when the build env provides it', () => { - const appOutDir = makeTempDir(); - process.env.MEOWART_API_KEY = 'release-secret'; - const resourcesDir = join(appOutDir, 'resources'); - - expect(afterPack.writeMeowaReleaseCredential?.({ - appOutDir, - packager: { getResourcesDir: () => resourcesDir }, - })).toBe(true); - - expect(JSON.parse(readFileSync( - join(resourcesDir, 'resources', 'meowa-game-assets-credential.json'), - 'utf8', - ))).toEqual({ schemaVersion: 1, apiKey: 'release-secret' }); - }); }); diff --git a/tests/unit/coding-capability-registry.test.ts b/tests/unit/coding-capability-registry.test.ts index 7f5b552..9b2274e 100644 --- a/tests/unit/coding-capability-registry.test.ts +++ b/tests/unit/coding-capability-registry.test.ts @@ -12,7 +12,10 @@ import { DataServiceCloudClient, type DataServiceOperations, } from '../../electron/services/data-service-client'; -import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins'; +import { + DATA_SERVICE_PLUGIN_DEFINITION, + type CodingPluginDefinition, +} from '../../shared/coding-plugins'; import { productToolDetailsOfResult } from '../../shared/coding-conversation-product-tool-protocol'; import type { DataServiceErrorContext, DataServiceHostResult } from '../../shared/data-service'; import type { @@ -203,6 +206,131 @@ describe('CodingCapabilityRegistry', () => { expect(JSON.stringify(invalidIdentity)).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}/i); }); + it('dispatches a Marketplace-hosted tool from the frozen effective snapshot without a static tool list', async () => { + const hostedDefinition: CodingPluginDefinition = { + id: 'makelore.game-resource', version: '1.0.0', contractVersion: 1, + displayName: 'Game Resource', description: 'Hosted game resources', + runtimeKind: 'platform_hosted', acquisitionMode: 'user_acquired', + releaseId: 'release-game-1', + provenance: { source: 'marketplace', packageRoot: 'C:/packages/game-resource' }, + scope: 'project', adapterId: '', requiresBackend: true, + skills: [{ + id: 'game-resource', entryPath: 'skills/game-resource/SKILL.md', + grants: ['game-resource.generate'], + }], + tools: [{ + name: 'game_resource_generate', label: 'Generate', description: 'Generate an asset', + capabilityId: 'game-resource.generate', operation: 'generate', roles: ['parent'], + mutation: 'write', projectWriteLease: false, + permissions: ['hosted.game-resource.generate'], executionMode: 'job', + inputSchema: { + type: 'object', additionalProperties: false, required: ['kind'], + properties: { kind: { type: 'string' } }, + }, + }], + operations: [{ + capabilityId: 'game-resource.generate', operation: 'generate', toolName: 'game_resource_generate', + }], + surfaces: {}, + }; + const hostedPolicy: PluginPolicyClientState = { + status: 'current', revision: 9, lastVerifiedAt: 1, + catalog: { + schema_version: 1, catalog_version: 'hosted-1', pricing_version: 'pricing-1', + plugins: [{ + plugin_id: hostedDefinition.id, supported_contract_versions: [1], status: 'active', + capabilities: [{ + capability_id: 'game-resource.generate', + operations: [{ + operation: 'generate', + billing: { + mode: 'platform_metered', entitlement_scope: 'plugin_usage', notice: 'Metered', + unit_name: 'generation', unit_size: 1, rate_points: '1.00', + minimum_charge_points: '1.00', rounding_mode: 'ceil', + }, + }], + }], + }], + }, + }; + const frozenSnapshot: EffectivePluginSnapshot = { + accountSessionId: 'account-a\u00001', projectId: context.projectId, + pluginReleaseIds: ['release-game-1'], effectiveSkillIds: ['game-resource'], + skillEntries: [{ + id: 'game-resource', entryPath: 'skills/game-resource/SKILL.md', + packageRoot: 'C:/packages/game-resource', + }], + toolDefinitions: hostedDefinition.tools, + runtimePolicies: [{ + pluginId: hostedDefinition.id, pluginVersion: hostedDefinition.version, + releaseId: hostedDefinition.releaseId, contractVersion: 1, + capabilityId: 'game-resource.generate', operation: 'generate', + billing: hostedPolicy.catalog!.plugins[0]!.capabilities[0]!.operations[0]!.billing, + }], + unavailableReasons: [], + }; + const resolve = vi.fn(async () => frozenSnapshot); + const effectiveResolver = { + resolve, + getSkillSources: vi.fn(async () => []), + getPolicyState: vi.fn(() => hostedPolicy), + getInstalledDefinition: vi.fn(async () => hostedDefinition), + } as unknown as EffectivePluginResolver; + const invoke = vi.fn(async () => ({ + success: true as const, status: 202, code: null, error: null, retryable: false as const, + payload_schema: 'game-resource.v1', data: { executionId: 'execution-a', status: 'accepted' }, + billing: { + mode: 'platform_metered' as const, status: 'dispatched' as const, + reserved_points: '2.00', usage_amount: 1, unit: 'generation', + }, + })); + const capabilityRegistry = registry({ + definitions: [], effectiveResolver, + adapters: [{ pluginId: hostedDefinition.id, inspect: async () => ({ status: 'ready' }), invoke }], + policyClient: { getState: () => hostedPolicy, refresh: vi.fn() }, + getEnabledPluginIds: async () => [hostedDefinition.id], + }); + + const result = await capabilityRegistry.invoke({ + toolName: 'game_resource_generate', + context: { ...context, skillIds: ['game-resource'], effectiveSnapshot: frozenSnapshot }, + workerRole: 'parent', effectiveSkillIds: ['game-resource'], value: { kind: 'pixel' }, + }); + + expect(effectiveResolver.getInstalledDefinition).toHaveBeenCalledWith( + hostedDefinition.id, + hostedDefinition.releaseId, + ); + expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ + requestId: 'pi:run-a:resource-a', workerRole: 'parent', effectiveSkillIds: ['game-resource'], + pluginReleaseId: hostedDefinition.releaseId, + }), hostedDefinition.tools[0], { kind: 'pixel' }); + expect(result.details).toMatchObject({ + schema: 'makelore-capability.v1', plugin_id: hostedDefinition.id, + capability_id: 'game-resource.generate', operation: 'generate', + request_id: 'pi:run-a:resource-a', success: true, status: 202, + billing: { mode: 'platform_metered', status: 'dispatched', reserved_points: '2.00' }, + payload_schema: 'game-resource.v1', + }); + + resolve.mockResolvedValue({ + ...frozenSnapshot, + pluginReleaseIds: ['release-game-2'], + runtimePolicies: [{ + ...frozenSnapshot.runtimePolicies[0]!, + pluginVersion: '2.0.0', + releaseId: 'release-game-2', + }], + }); + const stale = await capabilityRegistry.invoke({ + toolName: 'game_resource_generate', + context: { ...context, skillIds: ['game-resource'], effectiveSnapshot: frozenSnapshot }, + workerRole: 'parent', effectiveSkillIds: ['game-resource'], value: { kind: 'pixel' }, + }); + expect(stale.details).toMatchObject({ success: false, code: 'plugin_runtime_stale' }); + expect(invoke).toHaveBeenCalledTimes(1); + }); + it('refuses a new plugin action from an old worker after lifecycle invalidation', async () => { const frozenSnapshot: EffectivePluginSnapshot = { accountSessionId: 'account-a\u00001', diff --git a/tests/unit/coding-plugin-marketplace-client.test.ts b/tests/unit/coding-plugin-marketplace-client.test.ts index e506290..39d21e9 100644 --- a/tests/unit/coding-plugin-marketplace-client.test.ts +++ b/tests/unit/coding-plugin-marketplace-client.test.ts @@ -128,6 +128,33 @@ function buildSkillOnlyArchive( return zip.toBuffer(); } +function buildHostedArchive(): Buffer { + return buildSkillOnlyArchive({ + 'com.makelore/capability.json': JSON.stringify({ + schemaVersion: 2, + pluginId: PLUGIN_ID, + contractVersion: 1, + scope: 'project', + runtime: { kind: 'platform_hosted', protocol: 'makelore-hosted.v1' }, + skills: [{ id: 'example-skill', entry: '../skills/example-skill/SKILL.md', grants: ['example.write'] }], + tools: [{ + name: 'example_write', label: 'Write', description: 'Write an example output', + capabilityId: 'example.write', operation: 'write', roles: ['parent'], + mutation: 'write', projectWriteLease: true, + permissions: ['hosted.example.write'], executionMode: 'synchronous', + inputSchema: { + type: 'object', additionalProperties: false, required: ['value'], + properties: { value: { type: 'string', minLength: 1, maxLength: 100 } }, + }, + outputSchema: { + type: 'object', additionalProperties: false, required: ['saved'], + properties: { saved: { type: 'boolean' } }, + }, + }], + }), + }); +} + function signedGrant( archive: Buffer, options: { @@ -536,6 +563,37 @@ describe('PluginPackageStore', () => { expect(issueDownload).toHaveBeenCalledWith({ releaseId: RELEASE_ID, releaseAdmissionId: ADMISSION_ID }); }); + it('persists a signed platform-hosted package with its write-lease tool contract', async () => { + temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); + const archive = buildHostedArchive(); + const { grant, publicKey } = signedGrant(archive); + const marketplace = { + resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, { + sha256: grant.sha256, sizeBytes: grant.sizeBytes, + })), + issueDownload: vi.fn(async () => grant), + downloadContent: vi.fn(async () => archive), + getCurrentAccountBinding: () => ACCOUNT_A, + } as unknown as MarketplaceClient; + const store = new PluginPackageStore({ + rootDir: temporaryRoot, + marketplace, + clientVersion: '1.0.0', + keyStore: new Map([['test-key', publicKey]]), + getAccountBinding: () => ACCOUNT_A, + }); + + await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' })) + .resolves.toMatchObject({ status: 'installed', pluginId: PLUGIN_ID }); + await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ + runtimeKind: 'platform_hosted', + definition: { + runtimeKind: 'platform_hosted', requiresBackend: true, + tools: [{ name: 'example_write', projectWriteLease: true, executionMode: 'synchronous' }], + }, + }); + }); + 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(); diff --git a/tests/unit/coding-plugin-marketplace-contract.test.ts b/tests/unit/coding-plugin-marketplace-contract.test.ts index c3b6be2..ef0f754 100644 --- a/tests/unit/coding-plugin-marketplace-contract.test.ts +++ b/tests/unit/coding-plugin-marketplace-contract.test.ts @@ -143,6 +143,13 @@ describe('Marketplace Release A package contract', () => { })).toThrow(CodingPluginManifestError); }); + it('allows a hosted write tool to request the Main-owned project write lease', () => { + const source = structuredClone(HOSTED_CAPABILITY) as Record; + (source.tools as Array>)[0]!.projectWriteLease = true; + + expect(parse(source).tools[0]?.projectWriteLease).toBe(true); + }); + it.each([ ['unknown capability field', (value: Record) => { value.unknown = true; }], ['unknown runtime field', (value: Record) => { @@ -162,8 +169,10 @@ describe('Marketplace Release A package contract', () => { ['hosted foreign permission namespace', (value: Record) => { (value.tools as Array>)[0]!.permissions = ['hosted.makelore.example.generate']; }], - ['hosted project lease', (value: Record) => { - (value.tools as Array>)[0]!.projectWriteLease = true; + ['hosted read-only project lease', (value: Record) => { + const tool = (value.tools as Array>)[0]!; + tool.mutation = 'read'; + tool.projectWriteLease = true; }], ['unbounded schema', (value: Record) => { const tool = (value.tools as Array>)[0]!; diff --git a/tests/unit/game-resource-client.test.ts b/tests/unit/game-resource-client.test.ts new file mode 100644 index 0000000..c24c909 --- /dev/null +++ b/tests/unit/game-resource-client.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from 'vitest'; +import { + GameResourceClient, + GameResourceClientError, + isTerminalGameResourceStatus, +} from '../../electron/services/game-resource-client'; + +const EXECUTION_ID = '11111111-1111-4111-8111-111111111111'; +const RELEASE_ID = '22222222-2222-4222-8222-222222222222'; +const PROJECT_ID = '33333333-3333-4333-8333-333333333333'; + +function generation(overrides: Record = {}) { + return { + schema_version: 1, + plugin_id: 'makelore.game-resource', + execution_id: EXECUTION_ID, + release_id: RELEASE_ID, + project_id: PROJECT_ID, + logical_operation_id: 'pi:run-a:resource-a', + kind: 'pixel', + template_name: 'character', + status: 'accepted', + output_count: 0, + poll_interval_seconds: 3, + billing: { + mode: 'platform_metered', + status: 'dispatched', + reserved_points: '2.00', + usage_amount: 1, + unit: 'generation', + }, + ...overrides, + }; +} + +function jsonResponse(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('GameResourceClient', () => { + it('refreshes one 401 and parses the provider-neutral metered contract', async () => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(new Response(null, { status: 401 })) + .mockResolvedValueOnce(jsonResponse(generation({ provider_job_id: 'must-not-project' }), 202)); + const getAccessToken = vi.fn(async (options?: { forceRefresh?: boolean }) => ( + options?.forceRefresh ? 'fresh-token' : 'stale-token' + )); + const client = new GameResourceClient({ + apiBaseUrl: 'https://works.example/', + fetchImpl, + getAccessToken: getAccessToken as never, + }); + + const result = await client.generate({ + releaseAdmissionId: 'admission-a', + releaseId: RELEASE_ID, + projectId: PROJECT_ID, + logicalOperationId: 'pi:run-a:resource-a', + kind: 'pixel', + templateName: 'character', + templateConfig: {}, + requirement: 'A blue-armored hero', + }); + + expect(result).toEqual({ + executionId: EXECUTION_ID, + releaseId: RELEASE_ID, + projectId: PROJECT_ID, + logicalOperationId: 'pi:run-a:resource-a', + kind: 'pixel', + templateName: 'character', + status: 'accepted', + outputCount: 0, + pollIntervalSeconds: 3, + billing: { + mode: 'platform_metered', status: 'dispatched', reserved_points: '2.00', + usage_amount: 1, unit: 'generation', + }, + }); + expect(JSON.stringify(result)).not.toContain('provider_job_id'); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(fetchImpl.mock.calls[0]?.[1]?.headers).toMatchObject({ Authorization: 'Bearer stale-token' }); + expect(fetchImpl.mock.calls[1]?.[1]?.headers).toMatchObject({ Authorization: 'Bearer fresh-token' }); + expect(getAccessToken).toHaveBeenNthCalledWith(2, { fetchImpl, forceRefresh: true }); + }); + + it('fails closed on a malformed billing receipt', async () => { + const client = new GameResourceClient({ + fetchImpl: vi.fn().mockResolvedValue(jsonResponse(generation({ + billing: { mode: 'platform_metered', status: 'settled', reserved_points: '2.00', unit: 'credit' }, + }))), + getAccessToken: vi.fn(async () => 'token') as never, + }); + + await expect(client.get(EXECUTION_ID)).rejects.toMatchObject({ + code: 'plugin_backend_invalid', status: 502, + }); + }); + + it('downloads bounded output bytes without accepting provider redirects', async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { + 'content-type': 'image/png', + 'content-disposition': "attachment; filename*=UTF-8''hero.png", + }, + })); + const client = new GameResourceClient({ + apiBaseUrl: 'https://works.example', + fetchImpl, + getAccessToken: vi.fn(async () => 'token') as never, + }); + + await expect(client.download(EXECUTION_ID, 1)).resolves.toMatchObject({ + fileName: 'hero.png', contentType: 'image/png', bytes: new Uint8Array([1, 2, 3]), + }); + expect(fetchImpl.mock.calls[0]?.[0]).toBe( + `https://works.example/api/plugins/v1/hosted/game-resource/generations/${EXECUTION_ID}/content?output_index=1`, + ); + expect(fetchImpl.mock.calls[0]?.[1]).toMatchObject({ redirect: 'manual' }); + expect(isTerminalGameResourceStatus('succeeded')).toBe(true); + expect(isTerminalGameResourceStatus('running')).toBe(false); + }); +}); diff --git a/tests/unit/game-resource-plugin-adapter.test.ts b/tests/unit/game-resource-plugin-adapter.test.ts new file mode 100644 index 0000000..fe0a7cf --- /dev/null +++ b/tests/unit/game-resource-plugin-adapter.test.ts @@ -0,0 +1,184 @@ +// @vitest-environment node + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CodingPluginToolDefinition } from '../../shared/coding-plugins'; +import { + GameResourcePluginAdapter, +} from '../../electron/coding-plugins/adapters/game-resource'; +import type { GameResourceClient, GameResourceGeneration } from '../../electron/services/game-resource-client'; +import type { MarketplacePackageClientPort, PluginPackageStore } from '../../electron/coding-plugins/package-store'; +import type { TrustedCodingCapabilityContext } from '../../electron/coding-plugins/registry'; + +const EXECUTION_ID = '11111111-1111-4111-8111-111111111111'; +const RELEASE_ID = '22222222-2222-4222-8222-222222222222'; +const PROJECT_ID = '33333333-3333-4333-8333-333333333333'; +const roots: string[] = []; + +const billing = { + mode: 'platform_metered' as const, + status: 'dispatched' as const, + reserved_points: '2.00', + usage_amount: 1, + unit: 'generation', +}; + +function generation(status: GameResourceGeneration['status'] = 'accepted'): GameResourceGeneration { + return { + executionId: EXECUTION_ID, + releaseId: RELEASE_ID, + projectId: PROJECT_ID, + logicalOperationId: 'pi:run-a:resource-a', + kind: 'pixel', + templateName: 'character', + status, + outputCount: status === 'succeeded' ? 1 : 0, + pollIntervalSeconds: 3, + billing, + }; +} + +function tool( + name: string, + capabilityId = 'game-resource.generate', + operation = name.replace('game_resource_', ''), +): CodingPluginToolDefinition { + return { + name, + label: name, + description: name, + capabilityId, + operation, + roles: ['parent'], + mutation: name === 'game_resource_status' ? 'read' : 'write', + projectWriteLease: name === 'game_resource_save_output', + permissions: [`hosted.game-resource.${operation.replaceAll('_', '-')}`], + inputSchema: { type: 'object' }, + }; +} + +async function fixture() { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-game-resource-')); + roots.push(projectPath); + const resolve = vi.fn(async () => ({ + resolveRequestId: 'pi:run-a:resource-a', + resolveRequestDigest: 'a'.repeat(64), + items: [{ + pluginId: 'makelore.game-resource', action: 'keep' as const, + releaseId: RELEASE_ID, version: '1.0.0', sha256: 'a'.repeat(64), sizeBytes: 1, + releaseAdmissionId: 'admission-a', expiresAt: '2100-01-01T00:00:00Z', + channel: 'stable' as const, reason: null, + }], + catalogGeneration: 1, + etag: '"plugins-1"', + stale: false, + })); + const client = { + templates: vi.fn(async () => ['character']), + generate: vi.fn(async () => generation()), + get: vi.fn(async () => generation('succeeded')), + cancel: vi.fn(async () => generation('cancelled')), + download: vi.fn(async () => ({ + bytes: new Uint8Array([1, 2, 3]), fileName: 'hero.png', contentType: 'image/png', + })), + }; + const adapter = new GameResourcePluginAdapter({ + client: client as unknown as GameResourceClient, + marketplace: { resolve } as unknown as MarketplacePackageClientPort, + packageStore: { + getInstalled: vi.fn(async () => ({ + pluginId: 'makelore.game-resource', releaseId: RELEASE_ID, version: '1.0.0', + sha256: 'a'.repeat(64), channel: 'stable', + })), + getInstalledRelease: vi.fn(async () => ({ + pluginId: 'makelore.game-resource', releaseId: RELEASE_ID, version: '1.0.0', + sha256: 'a'.repeat(64), channel: 'stable', + })), + } as unknown as Pick, + makeloreVersion: '2.0.0', + }); + const context: TrustedCodingCapabilityContext = { + conversationId: 'conversation-a', runId: 'run-a', resourceId: 'resource-a', + requestId: 'pi:run-a:resource-a', localProjectId: 'local-a', + projectPath, durableProjectId: PROJECT_ID, workerRole: 'parent', + effectiveSkillIds: ['game-resource'], pluginReleaseId: RELEASE_ID, + }; + return { adapter, client, context, projectPath, resolve }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(async (root) => await rm(root, { recursive: true, force: true }))); +}); + +describe('GameResourcePluginAdapter', () => { + it('resolves a fresh admission and keeps project reference bytes behind the hosted boundary', async () => { + const { adapter, client, context, projectPath, resolve } = await fixture(); + await writeFile(path.join(projectPath, 'reference.png'), new Uint8Array([4, 5, 6])); + + const result = await adapter.invoke(context, tool('game_resource_generate'), { + kind: 'pixel', templateName: 'character', requirement: 'Blue-armored hero', + confirmed: true, + referencePaths: ['reference.png'], + }); + + expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ + resolveRequestId: 'pi:run-a:resource-a', channel: 'stable', + installed: [{ pluginId: 'makelore.game-resource', releaseId: RELEASE_ID, sha256: 'a'.repeat(64) }], + })); + expect(client.generate).toHaveBeenCalledWith(expect.objectContaining({ + releaseAdmissionId: 'admission-a', releaseId: RELEASE_ID, + projectId: PROJECT_ID, logicalOperationId: 'pi:run-a:resource-a', + referenceFiles: [{ name: 'reference.png', mimeType: 'image/png', dataBase64: 'BAUG' }], + })); + expect(result).toMatchObject({ + success: true, status: 202, billing, + data: { executionId: EXECUTION_ID, status: 'accepted', outputCount: 0 }, + }); + expect(JSON.stringify(result)).not.toContain(projectPath); + }); + + it('does not resolve admission or reserve usage before explicit generation confirmation', async () => { + const { adapter, client, context, resolve } = await fixture(); + + await expect(adapter.invoke(context, tool('game_resource_generate'), { + kind: 'pixel', templateName: 'character', requirement: 'Blue-armored hero', + confirmed: false, + })).resolves.toMatchObject({ success: false, code: 'confirmation_required' }); + + expect(resolve).not.toHaveBeenCalled(); + expect(client.generate).not.toHaveBeenCalled(); + }); + + it('requires explicit confirmation and never overwrites a project file', async () => { + const { adapter, client, context, projectPath } = await fixture(); + const saveTool = tool('game_resource_save_output', 'game-resource.library', 'save_output'); + + await expect(adapter.invoke(context, saveTool, { + executionId: EXECUTION_ID, relativePath: 'assets/hero.png', confirmed: false, + })).resolves.toMatchObject({ success: false, code: 'confirmation_required' }); + expect(client.download).not.toHaveBeenCalled(); + + await expect(adapter.invoke(context, saveTool, { + executionId: EXECUTION_ID, relativePath: 'assets/hero.png', confirmed: true, + })).resolves.toMatchObject({ + success: true, data: { savedPath: 'assets/hero.png', bytes: 3 }, + }); + await expect(readFile(path.join(projectPath, 'assets/hero.png'))).resolves.toEqual(Buffer.from([1, 2, 3])); + + await expect(adapter.invoke(context, saveTool, { + executionId: EXECUTION_ID, relativePath: 'assets/hero.png', confirmed: true, + })).resolves.toMatchObject({ success: false, code: 'game_resource_destination_exists' }); + await expect(readFile(path.join(projectPath, 'assets/hero.png'))).resolves.toEqual(Buffer.from([1, 2, 3])); + }); + + it('lists server-owned templates through the same release admission', async () => { + const { adapter, context, client } = await fixture(); + await expect(adapter.invoke(context, tool('game_resource_templates'), { kind: 'pixel' })) + .resolves.toMatchObject({ success: true, data: { kind: 'pixel', templates: ['character'] } }); + expect(client.templates).toHaveBeenCalledWith({ + releaseId: RELEASE_ID, releaseAdmissionId: 'admission-a', kind: 'pixel', + }); + }); +}); diff --git a/tests/unit/meowa-game-assets-release-credential.test.ts b/tests/unit/meowa-game-assets-release-credential.test.ts deleted file mode 100644 index 7008595..0000000 --- a/tests/unit/meowa-game-assets-release-credential.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { - getMeowaReleaseCredentialPath, - MEOWA_RELEASE_CREDENTIAL_FILE_NAME, - readBundledMeowaApiKey, - readEmbeddedMeowaApiKey, -} from '@electron/services/meowa-game-assets-release-credential'; - -const tempDirs: string[] = []; - -afterEach(async () => { - await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); -}); - -describe('Meowa release credential resource', () => { - it('provides the approved Main-only embedded credential', () => { - const apiKey = readEmbeddedMeowaApiKey(); - expect(apiKey).toMatch(/^ma_live_/); - expect(apiKey?.length).toBeLessThanOrEqual(512); - }); - - it('reads a versioned packaged credential from the resources tree', async () => { - const resourcesPath = await mkdtemp(join(tmpdir(), 'niancode-meowa-release-')); - tempDirs.push(resourcesPath); - const file = getMeowaReleaseCredentialPath(resourcesPath); - await mkdir(join(resourcesPath, 'resources'), { recursive: true }); - await writeFile(file, JSON.stringify({ schemaVersion: 1, apiKey: 'release-secret' }), 'utf8'); - - await expect(readBundledMeowaApiKey(resourcesPath)).resolves.toBe('release-secret'); - expect(file.endsWith(MEOWA_RELEASE_CREDENTIAL_FILE_NAME)).toBe(true); - }); - - it('rejects malformed, unversioned, and oversized packaged credentials', async () => { - const resourcesPath = await mkdtemp(join(tmpdir(), 'niancode-meowa-release-')); - tempDirs.push(resourcesPath); - const file = getMeowaReleaseCredentialPath(resourcesPath); - await mkdir(join(resourcesPath, 'resources'), { recursive: true }); - - await writeFile(file, JSON.stringify({ apiKey: 'missing-version' }), 'utf8'); - await expect(readBundledMeowaApiKey(resourcesPath)).resolves.toBeNull(); - - await writeFile(file, '{not-json', 'utf8'); - await expect(readBundledMeowaApiKey(resourcesPath)).resolves.toBeNull(); - - await writeFile(file, JSON.stringify({ schemaVersion: 1, apiKey: 'x'.repeat(513) }), 'utf8'); - await expect(readBundledMeowaApiKey(resourcesPath)).resolves.toBeNull(); - }); -}); diff --git a/tests/unit/meowa-game-assets-route.test.ts b/tests/unit/meowa-game-assets-route.test.ts deleted file mode 100644 index 5368331..0000000 --- a/tests/unit/meowa-game-assets-route.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { EventEmitter } from 'node:events'; -import type { IncomingMessage, ServerResponse } from 'node:http'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { handleMeowaGameAssetsRoutes } from '@electron/api/routes/meowa-game-assets'; - -const getApiKeyMock = vi.hoisted(() => vi.fn()); -const storeApiKeyMock = vi.hoisted(() => vi.fn()); -const deleteApiKeyMock = vi.hoisted(() => vi.fn()); -const readBundledMeowaApiKeyMock = vi.hoisted(() => vi.fn()); -const readEmbeddedMeowaApiKeyMock = vi.hoisted(() => vi.fn()); -const proxyAwareFetchMock = vi.hoisted(() => vi.fn()); - -vi.mock('@electron/utils/secure-storage', () => ({ - getApiKey: (...args: unknown[]) => getApiKeyMock(...args), - storeApiKey: (...args: unknown[]) => storeApiKeyMock(...args), - deleteApiKey: (...args: unknown[]) => deleteApiKeyMock(...args), -})); - -vi.mock('@electron/services/meowa-game-assets-release-credential', () => ({ - readBundledMeowaApiKey: (...args: unknown[]) => readBundledMeowaApiKeyMock(...args), - readEmbeddedMeowaApiKey: (...args: unknown[]) => readEmbeddedMeowaApiKeyMock(...args), -})); - -vi.mock('@electron/utils/proxy-fetch', () => ({ - proxyAwareFetch: (...args: unknown[]) => proxyAwareFetchMock(...args), -})); - -function createResponse() { - const chunks: string[] = []; - const response = { - statusCode: 0, - setHeader: vi.fn(), - end: vi.fn((chunk?: string) => { - if (chunk) chunks.push(chunk); - }), - } as unknown as ServerResponse; - return { - response, - json: () => JSON.parse(chunks.join('')) as Record, - }; -} - -function createRequest(method: string, body?: unknown): IncomingMessage { - const request = new EventEmitter(); - Object.assign(request, { - method, - headers: body === undefined ? {} : { 'content-type': 'application/json' }, - [Symbol.asyncIterator]: async function* () { - if (body !== undefined) yield Buffer.from(JSON.stringify(body)); - }, - }); - return request as IncomingMessage; -} - -describe('Meowa game-assets Host API routes', () => { - beforeEach(() => { - vi.clearAllMocks(); - delete process.env.MEOWART_API_KEY; - getApiKeyMock.mockResolvedValue(null); - readBundledMeowaApiKeyMock.mockResolvedValue(null); - readEmbeddedMeowaApiKeyMock.mockReturnValue(null); - storeApiKeyMock.mockResolvedValue(true); - deleteApiKeyMock.mockResolvedValue(true); - }); - - it('reports configuration without returning the stored credential', async () => { - getApiKeyMock.mockResolvedValue('meowa-secret'); - const { response, json } = createResponse(); - - await handleMeowaGameAssetsRoutes( - createRequest('GET'), - response, - new URL('http://127.0.0.1/api/meowa/game-assets/config'), - {} as never, - ); - - expect(response.statusCode).toBe(200); - expect(json()).toMatchObject({ - configured: true, - credentialSource: 'secure-store', - provider: 'meowa', - }); - expect(json()).not.toHaveProperty('quota'); - expect(JSON.stringify(json())).not.toContain('meowa-secret'); - }); - - it('hydrates and reports a packaged release credential without returning it', async () => { - readBundledMeowaApiKeyMock.mockResolvedValue('meowa-release-secret'); - const { response, json } = createResponse(); - - await handleMeowaGameAssetsRoutes( - createRequest('GET'), - response, - new URL('http://127.0.0.1/api/meowa/game-assets/config'), - {} as never, - ); - - expect(response.statusCode).toBe(200); - expect(json()).toMatchObject({ - configured: true, - credentialSource: 'release-bundle', - }); - expect(JSON.stringify(json())).not.toContain('meowa-release-secret'); - expect(storeApiKeyMock).toHaveBeenCalledWith('meowa-game-assets', 'meowa-release-secret'); - }); - - it('persists and reports the embedded credential without returning it', async () => { - readEmbeddedMeowaApiKeyMock.mockReturnValue('embedded-meowa-secret'); - const { response, json } = createResponse(); - - await handleMeowaGameAssetsRoutes( - createRequest('GET'), - response, - new URL('http://127.0.0.1/api/meowa/game-assets/config'), - {} as never, - ); - - expect(response.statusCode).toBe(200); - expect(json()).toMatchObject({ configured: true, credentialSource: 'embedded' }); - expect(JSON.stringify(json())).not.toContain('embedded-meowa-secret'); - expect(storeApiKeyMock).toHaveBeenCalledWith('meowa-game-assets', 'embedded-meowa-secret'); - }); - - it('prefers the packaged release credential over the embedded fallback', async () => { - readBundledMeowaApiKeyMock.mockResolvedValue('release-secret'); - readEmbeddedMeowaApiKeyMock.mockReturnValue('embedded-secret'); - const { response, json } = createResponse(); - - await handleMeowaGameAssetsRoutes( - createRequest('GET'), - response, - new URL('http://127.0.0.1/api/meowa/game-assets/config'), - {} as never, - ); - - expect(json()).toMatchObject({ credentialSource: 'release-bundle' }); - expect(storeApiKeyMock).toHaveBeenCalledWith('meowa-game-assets', 'release-secret'); - expect(readEmbeddedMeowaApiKeyMock).not.toHaveBeenCalled(); - }); - - it('does not expose the removed local quota route', async () => { - getApiKeyMock.mockResolvedValue('meowa-secret'); - const { response } = createResponse(); - - const handled = await handleMeowaGameAssetsRoutes( - createRequest('GET'), - response, - new URL('http://127.0.0.1/api/meowa/game-assets/quota'), - {} as never, - ); - - expect(handled).toBe(false); - expect(response.end).not.toHaveBeenCalled(); - }); - - it('proxies template info with the stored credential kept in Main', async () => { - getApiKeyMock.mockResolvedValue('meowa-secret'); - proxyAwareFetchMock.mockResolvedValue(new Response(JSON.stringify({ templates: [{ name: 'pixel-character' }] }), { - status: 200, - headers: { 'content-type': 'application/json' }, - })); - const { response, json } = createResponse(); - - await handleMeowaGameAssetsRoutes( - createRequest('GET'), - response, - new URL('http://127.0.0.1/api/meowa/game-assets/template-info?kind=pixel'), - {} as never, - ); - - expect(response.statusCode).toBe(200); - expect(json()).toEqual({ templates: [{ name: 'pixel-character' }] }); - expect(proxyAwareFetchMock).toHaveBeenCalledWith( - 'https://api.meowa.ai/api/pixel-gen/template-info', - expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer meowa-secret' }) }), - ); - }); - - it('converts generation JSON into Meowa multipart form data', async () => { - getApiKeyMock.mockResolvedValue('meowa-secret'); - proxyAwareFetchMock.mockResolvedValue(new Response(JSON.stringify({ api_job_id: 'job-1' }), { - status: 202, - headers: { 'content-type': 'application/json' }, - })); - const { response, json } = createResponse(); - - await handleMeowaGameAssetsRoutes( - createRequest('POST', { - kind: 'pixel', - templateName: 'pixel-character', - requirement: '透明背景的像素角色', - templateConfig: { size: 64 }, - }), - response, - new URL('http://127.0.0.1/api/meowa/game-assets/generate'), - {} as never, - ); - - expect(response.statusCode).toBe(202); - expect(json()).toEqual({ api_job_id: 'job-1' }); - const [, init] = proxyAwareFetchMock.mock.calls[0] as [string, RequestInit]; - const form = init.body as FormData; - expect(form.get('template_name')).toBe('pixel-character'); - expect(form.get('requirement')).toBe('透明背景的像素角色'); - expect(form.get('template_config')).toBe('{"size":64}'); - expect(JSON.stringify([...form.entries()])).not.toContain('meowa-secret'); - }); - - it('forwards generation without a local daily quota check', async () => { - getApiKeyMock.mockResolvedValue('meowa-secret'); - proxyAwareFetchMock.mockResolvedValue(new Response(JSON.stringify({ api_job_id: 'job-after-ten' }), { - status: 202, - headers: { 'content-type': 'application/json' }, - })); - const { response, json } = createResponse(); - - await handleMeowaGameAssetsRoutes( - createRequest('POST', { - kind: 'pixel', - templateName: 'pixel-character', - requirement: '透明背景的像素角色', - }), - response, - new URL('http://127.0.0.1/api/meowa/game-assets/generate'), - {} as never, - ); - - expect(response.statusCode).toBe(202); - expect(json()).toEqual({ api_job_id: 'job-after-ten' }); - expect(proxyAwareFetchMock).toHaveBeenCalledOnce(); - }); - - it('forwards Meowa rejection when a generation submission fails', async () => { - getApiKeyMock.mockResolvedValue('meowa-secret'); - proxyAwareFetchMock.mockResolvedValue(new Response(JSON.stringify({ error: 'invalid template' }), { - status: 400, - headers: { 'content-type': 'application/json' }, - })); - const { response } = createResponse(); - - await handleMeowaGameAssetsRoutes( - createRequest('POST', { - kind: 'pixel', - templateName: 'pixel-character', - requirement: '透明背景的像素角色', - }), - response, - new URL('http://127.0.0.1/api/meowa/game-assets/generate'), - {} as never, - ); - - expect(response.statusCode).toBe(400); - }); - - it('downloads bytes through Main without exposing a signed upstream URL', async () => { - getApiKeyMock.mockResolvedValue('meowa-secret'); - proxyAwareFetchMock.mockResolvedValue(new Response(Buffer.from('png-bytes'), { - status: 200, - headers: { 'content-type': 'image/png' }, - })); - const { response, json } = createResponse(); - - await handleMeowaGameAssetsRoutes( - createRequest('GET'), - response, - new URL('http://127.0.0.1/api/meowa/game-assets/download?kind=pixel&id=job-1'), - {} as never, - ); - - expect(response.statusCode).toBe(200); - expect(json()).toMatchObject({ success: true, mimeType: 'image/png', dataBase64: Buffer.from('png-bytes').toString('base64') }); - expect(JSON.stringify(json())).not.toContain('meowa-secret'); - }); - - it('blocks generation before network access when no credential is configured', async () => { - const { response, json } = createResponse(); - - await handleMeowaGameAssetsRoutes( - createRequest('POST', { kind: 'pixel', templateName: 'pixel-character', requirement: 'test' }), - response, - new URL('http://127.0.0.1/api/meowa/game-assets/generate'), - {} as never, - ); - - expect(response.statusCode).toBe(409); - expect(json()).toMatchObject({ - code: 'MEOWA_API_KEY_MISSING', - error: 'Meowa 素材服务尚未配置,请联系管理员。', - }); - expect(proxyAwareFetchMock).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/unit/pi-extension-bundle.test.ts b/tests/unit/pi-extension-bundle.test.ts index 2798cfd..8a8655d 100644 --- a/tests/unit/pi-extension-bundle.test.ts +++ b/tests/unit/pi-extension-bundle.test.ts @@ -338,19 +338,6 @@ describe('Makelore Pi extension bundle', () => { Buffer.from('packaged-png').toString('base64'), ); expect((await attachments.read('packaged-attachment-a')).data.toString()).toBe('packaged-png'); - const gameBrowse = await tools.get('game_asset_browser')?.execute?.( - 'game-browse-a', {}, new AbortController().signal, - ); - expect(gameBrowse).toMatchObject({ - details: { schema: 'game-assets.v1', candidateIds: ['hero'], status: 'pending' }, - }); - const gameReview = await tools.get('game_asset_review')?.execute?.( - 'game-review-a', { candidateIds: ['hero'] }, new AbortController().signal, - ); - expect(gameReview).toMatchObject({ - details: { schema: 'game-assets.v1', candidateIds: ['hero'], status: 'pending' }, - }); - expect(JSON.stringify({ gameBrowse, gameReview })).not.toContain(root); await writeFile(path.join(root, 'notes.txt'), 'changed by write tool\n', 'utf8'); await handlers.get('tool_call')?.({ toolName: 'write', toolCallId: 'write-a', input: { path: path.join(root, 'notes.txt') }, @@ -422,8 +409,7 @@ describe('Makelore Pi extension bundle', () => { on: (event, handler) => handlers.set(event, handler), }); expect([...tools.keys()]).toEqual([ - 'ask_user', 'subagent', 'agent_browser', 'game_asset_browser', - 'game_asset_review', 'task_state', 'changed_file', 'runtime_context', + 'ask_user', 'subagent', 'agent_browser', 'task_state', 'changed_file', 'runtime_context', 'data_service_configure', 'data_service_inspect', 'data_service_list_projects', 'data_service_get_document', 'data_service_list_documents', 'data_service_put_document', 'data_service_delete_document', 'data_service_remove_collection', 'data_service_reset', diff --git a/tests/unit/pi-product-artifact.test.ts b/tests/unit/pi-product-artifact.test.ts index 4bd2f7e..c1a16ed 100644 --- a/tests/unit/pi-product-artifact.test.ts +++ b/tests/unit/pi-product-artifact.test.ts @@ -25,6 +25,16 @@ import { parsePiArtifactVerifierArgs } from '../../scripts/verify-pi-product-art const roots: string[] = []; const PI_PACKAGE = '@earendil-works/pi-coding-agent'; const { createPackage } = createRequire(import.meta.url)('@electron/asar'); +const MARKETPLACE_ARTIFACT_TEXT = [ + 'makelore-plugin-release.v1', 'skill_only', 'platform_hosted', + 'plugin_signature_invalid', 'signing key is not trusted', + 'makelore.game-resource', '/api/plugins/v1/hosted/game-resource/generations', + '/api/coding/plugin-marketplace', + 'plugin-marketplace\\/install\\/', 'plugin-marketplace\\/update\\/', + 'effectiveSkillIds', 'pluginReleaseIds', + '/api/coding/plugin-marketplace/catalog', '/api/coding/plugin-marketplace/library', + '免费获取', '我的插件', +].join('\n'); async function createAsarFixture(source: string, archive: string) { const output = await createPackage(source, archive); @@ -195,14 +205,7 @@ describe('final Pi product artifact verification', () => { }); it('proves the packaged Marketplace trust, routes, effective snapshot, and Renderer assets', () => { - const artifact = Buffer.from([ - 'makelore-plugin-release.v1', 'skill_only', 'plugin_signature_invalid', - 'signing key is not trusted', '/api/coding/plugin-marketplace', - 'plugin-marketplace\\/install\\/', 'plugin-marketplace\\/update\\/', - 'effectiveSkillIds', 'pluginReleaseIds', - '/api/coding/plugin-marketplace/catalog', '/api/coding/plugin-marketplace/library', - '免费获取', '我的插件', - ].join('\n')); + const artifact = Buffer.from(MARKETPLACE_ARTIFACT_TEXT); const trustSource = `export const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze( {} as Readonly>, @@ -210,6 +213,8 @@ describe('final Pi product artifact verification', () => { export const sourceMarker = 'makelore.plugin-trust.code-owned.v1';`; expect(verifyMarketplaceClientArtifact(artifact, trustSource)).toMatchObject({ schema2SkillOnly: true, + schema2PlatformHosted: true, + legacyMeowaClientAuthorityAbsent: true, productionTrust: 'official-key-absent-fail-closed', libraryInstallAndEffectiveRoutes: true, rendererAssets: true, @@ -224,18 +229,16 @@ describe('final Pi product artifact verification', () => { expect(() => verifyMarketplaceClientArtifact(Buffer.from('makelore-plugin-release.v1'), emptyTrust)) .toThrow('Marketplace contract markers'); - const complete = Buffer.from([ - 'makelore-plugin-release.v1', 'skill_only', 'plugin_signature_invalid', - 'signing key is not trusted', '/api/coding/plugin-marketplace', - 'plugin-marketplace\\/install\\/', 'plugin-marketplace\\/update\\/', - 'effectiveSkillIds', 'pluginReleaseIds', - '/api/coding/plugin-marketplace/catalog', '/api/coding/plugin-marketplace/library', - '免费获取', '我的插件', - ].join('\n')); + const complete = Buffer.from(MARKETPLACE_ARTIFACT_TEXT); expect(() => verifyMarketplaceClientArtifact( complete, `${emptyTrust}\nprocess.env.PLUGIN_KEY`, )).toThrow('empty code-owned fail-closed store'); + + expect(() => verifyMarketplaceClientArtifact( + Buffer.from(`${MARKETPLACE_ARTIFACT_TEXT}\nMEOWA_API_KEY`), + `${emptyTrust}\nmakelore.plugin-trust.code-owned.v1`, + )).toThrow('legacy Meowa client authority'); }); it('proves Marketplace trust from the packaged app.asar rather than checkout source', async () => { @@ -273,14 +276,7 @@ describe('final Pi product artifact verification', () => { 'makelore.plugin-trust.code-owned.v1', ); expect(verifyMarketplaceClientArtifact( - Buffer.from([ - 'makelore-plugin-release.v1', 'skill_only', 'plugin_signature_invalid', - 'signing key is not trusted', '/api/coding/plugin-marketplace', - 'plugin-marketplace\\/install\\/', 'plugin-marketplace\\/update\\/', - 'effectiveSkillIds', 'pluginReleaseIds', - '/api/coding/plugin-marketplace/catalog', '/api/coding/plugin-marketplace/library', - '免费获取', '我的插件', - ].join('\n')), + Buffer.from(MARKETPLACE_ARTIFACT_TEXT), 'const dC = Object.freeze({}); sourceMarker: makelore.plugin-trust.code-owned.v1', )).toMatchObject({ productionTrust: 'official-key-absent-fail-closed' }); }); @@ -322,7 +318,8 @@ describe('final Pi product artifact verification', () => { 'import{marketplace}from"./plugin-marketplace.js";export{marketplace};', ); await writeFile(path.join(source, 'dist', 'assets', 'plugin-marketplace.js'), [ - 'makelore-plugin-release.v1 skill_only plugin_signature_invalid signing key is not trusted', + 'makelore-plugin-release.v1 skill_only platform_hosted plugin_signature_invalid signing key is not trusted', + 'makelore.game-resource /api/plugins/v1/hosted/game-resource/generations', '/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/', 'effectiveSkillIds pluginReleaseIds', '/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 我的插件', @@ -339,7 +336,8 @@ describe('final Pi product artifact verification', () => { 'export const sourceMarker = "makelore.plugin-trust.code-owned.v1";', ].join('\n')); await writeFile(path.join(staleSource, 'dist-electron', 'stale-marketplace.js'), [ - 'makelore-plugin-release.v1 skill_only plugin_signature_invalid signing key is not trusted', + 'makelore-plugin-release.v1 skill_only platform_hosted plugin_signature_invalid signing key is not trusted', + 'makelore.game-resource /api/plugins/v1/hosted/game-resource/generations', '/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/', 'effectiveSkillIds pluginReleaseIds', '/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 我的插件', diff --git a/tests/unit/pi-product-tools.test.ts b/tests/unit/pi-product-tools.test.ts index 3556cf3..6d6b1e9 100644 --- a/tests/unit/pi-product-tools.test.ts +++ b/tests/unit/pi-product-tools.test.ts @@ -392,30 +392,6 @@ describe('PI-090 product tools', () => { })); }); - it('loads game asset review state through the vendor-neutral product module', async () => { - const root = await temporaryRoot('makelore-pi-game-tool-'); - await writeFile(path.join(root, 'ASSET_PLAN.md'), [ - '```json', - JSON.stringify({ assets: [{ id: 'hero', name: 'Hero', category: 'visual', status: 'candidate' }] }), - '```', - ].join('\n'), 'utf8'); - const tools = new PiProductTools({ - browser: {} as AgentBrowserModule, - attachments: new CodingAttachmentStore(path.join(root, 'attachments')), - bundledSkillsDir: path.resolve('resources/coding-skills'), - }); - const result = await tools.execute('game_asset_browser', { - conversationId: 'conversation-a', runId: 'run-a', resourceId: 'review-a', - projectId: 'project-a', projectPath: root, skillIds: [], - }, {}); - expect(result.details).toEqual({ - schema: 'game-assets.v1', invocationId: 'review-a', candidateIds: ['hero'], - status: 'pending', pendingAssetIds: ['hero'], approvedAssetIds: [], discardedAssetIds: [], - }); - expect(JSON.stringify(result)).not.toContain(root); - expect(JSON.stringify(result)).not.toContain('data:'); - }); - it('dispatches all Data Service tools only through the capability registry', async () => { const root = await temporaryRoot('makelore-pi-data-tools-'); const invoke = vi.fn().mockImplementation(({ toolName, context }) => Promise.resolve({ diff --git a/tests/unit/pi-rpc-foundation.test.ts b/tests/unit/pi-rpc-foundation.test.ts index d6837da..69bb5c7 100644 --- a/tests/unit/pi-rpc-foundation.test.ts +++ b/tests/unit/pi-rpc-foundation.test.ts @@ -244,7 +244,7 @@ describe('Pi worker process', () => { '--no-context-files', '--no-approve', '--tools', - 'read,bash,edit,write,grep,find,ls,ask_user,subagent,agent_browser,game_asset_browser,game_asset_review,task_state,changed_file,runtime_context', + 'read,bash,edit,write,grep,find,ls,ask_user,subagent,agent_browser,task_state,changed_file,runtime_context', '--model', 'model-a', ]); expect(buildPiRpcArgs('sessions', ['--no-session'], ['read', 'grep', 'find', 'ls'])) diff --git a/tests/unit/pi-worker-process-real.test.ts b/tests/unit/pi-worker-process-real.test.ts index 15e668e..1155b39 100644 --- a/tests/unit/pi-worker-process-real.test.ts +++ b/tests/unit/pi-worker-process-real.test.ts @@ -123,8 +123,6 @@ describe('locked Pi worker process smoke', () => { 'ask_user', 'subagent', 'agent_browser', - 'game_asset_browser', - 'game_asset_review', 'task_state', 'changed_file', 'runtime_context', @@ -298,8 +296,6 @@ describe('locked Pi worker process smoke', () => { }); expect(await readActiveTools(probe.resultPath)).toEqual(expect.arrayContaining([ 'agent_browser', - 'game_asset_browser', - 'game_asset_review', 'task_state', 'changed_file', 'runtime_context',