feat(plugins): move game resources behind marketplace

This commit is contained in:
2026-08-31 10:45:20 +08:00
parent 62304dc85b
commit fe656dd865
33 changed files with 1413 additions and 1018 deletions

View File

@@ -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' });
});
});

View File

@@ -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',

View File

@@ -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();

View File

@@ -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<string, unknown>;
(source.tools as Array<Record<string, unknown>>)[0]!.projectWriteLease = true;
expect(parse(source).tools[0]?.projectWriteLease).toBe(true);
});
it.each([
['unknown capability field', (value: Record<string, unknown>) => { value.unknown = true; }],
['unknown runtime field', (value: Record<string, unknown>) => {
@@ -162,8 +169,10 @@ describe('Marketplace Release A package contract', () => {
['hosted foreign permission namespace', (value: Record<string, unknown>) => {
(value.tools as Array<Record<string, unknown>>)[0]!.permissions = ['hosted.makelore.example.generate'];
}],
['hosted project lease', (value: Record<string, unknown>) => {
(value.tools as Array<Record<string, unknown>>)[0]!.projectWriteLease = true;
['hosted read-only project lease', (value: Record<string, unknown>) => {
const tool = (value.tools as Array<Record<string, unknown>>)[0]!;
tool.mutation = 'read';
tool.projectWriteLease = true;
}],
['unbounded schema', (value: Record<string, unknown>) => {
const tool = (value.tools as Array<Record<string, unknown>>)[0]!;

View File

@@ -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<string, unknown> = {}) {
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<typeof fetch>()
.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<typeof fetch>().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<GameResourceClientError>({
code: 'plugin_backend_invalid', status: 502,
});
});
it('downloads bounded output bytes without accepting provider redirects', async () => {
const fetchImpl = vi.fn<typeof fetch>().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);
});
});

View File

@@ -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<PluginPackageStore, 'getInstalled' | 'getInstalledRelease'>,
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',
});
});
});

View File

@@ -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();
});
});

View File

@@ -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<string, unknown>,
};
}
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();
});
});

View File

@@ -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',

View File

@@ -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<Record<string, string>>,
@@ -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 免费获取 我的插件',

View File

@@ -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({

View File

@@ -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']))

View File

@@ -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',