feat(plugins): move game resources behind marketplace
This commit is contained in:
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]!;
|
||||
|
||||
129
tests/unit/game-resource-client.test.ts
Normal file
129
tests/unit/game-resource-client.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
184
tests/unit/game-resource-plugin-adapter.test.ts
Normal file
184
tests/unit/game-resource-plugin-adapter.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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 免费获取 我的插件',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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']))
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user