feat(plugins): move game resources behind marketplace
This commit is contained in:
@@ -40,6 +40,8 @@ import {
|
||||
createCodingCapabilityRegistry,
|
||||
} from '../coding-plugins/registry';
|
||||
import { createDataServicePluginAdapter } from '../coding-plugins/adapters/data-service';
|
||||
import { createGameResourcePluginAdapter } from '../coding-plugins/adapters/game-resource';
|
||||
import { GameResourceClient } from '../services/game-resource-client';
|
||||
import { AccountPluginCache } from '../coding-plugins/account-plugin-cache';
|
||||
import {
|
||||
createMarketplaceClient,
|
||||
@@ -235,6 +237,12 @@ export function createCodingComposition(
|
||||
});
|
||||
const dataService = createDataServiceOperations({ projects });
|
||||
const dataServiceAdapter = createDataServicePluginAdapter(dataService);
|
||||
const gameResourceAdapter = createGameResourcePluginAdapter({
|
||||
client: new GameResourceClient(),
|
||||
marketplace: marketplaceClient,
|
||||
packageStore,
|
||||
makeloreVersion: options.clientVersion ?? '2.0.0',
|
||||
});
|
||||
const policyClient = options.policyClient ?? new PluginPolicyClient();
|
||||
const knownPluginIds = new Set(pluginDefinitions.map(({ id }) => id));
|
||||
// Existing user Releases are discovered from the device index at startup;
|
||||
@@ -270,7 +278,7 @@ export function createCodingComposition(
|
||||
const capabilityRegistry = createCodingCapabilityRegistry({
|
||||
policyClient,
|
||||
projectPlugins,
|
||||
adapters: [dataServiceAdapter],
|
||||
adapters: [dataServiceAdapter, gameResourceAdapter],
|
||||
definitions: pluginDefinitions,
|
||||
effectiveResolver,
|
||||
getDurableProjectId: async (projectPath, localProjectId) => {
|
||||
@@ -286,7 +294,7 @@ export function createCodingComposition(
|
||||
projects,
|
||||
projectPlugins,
|
||||
policyClient,
|
||||
adapters: [dataServiceAdapter],
|
||||
adapters: [dataServiceAdapter, gameResourceAdapter],
|
||||
definitions: pluginDefinitions,
|
||||
effectiveResolver,
|
||||
getDefinitions: async () => {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { handleProviderRoutes } from './routes/providers';
|
||||
import { handleLogRoutes } from './routes/logs';
|
||||
import { handleUsageRoutes } from './routes/usage';
|
||||
import { handleFileRoutes } from './routes/files';
|
||||
import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets';
|
||||
import { handleAgentBrowserRoutes } from './routes/agent-browser';
|
||||
import { handleCodingFileRoutes } from './routes/coding-files';
|
||||
import { handleCodingAttachmentRoutes } from './routes/coding-attachments';
|
||||
@@ -58,7 +57,6 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [
|
||||
handleSettingsRoutes,
|
||||
handleProviderRoutes,
|
||||
handleFileRoutes,
|
||||
handleMeowaGameAssetsRoutes,
|
||||
handleLogRoutes,
|
||||
handleUsageRoutes,
|
||||
];
|
||||
|
||||
@@ -1,439 +0,0 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
import { deleteApiKey, getApiKey, storeApiKey } from '../../utils/secure-storage';
|
||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||
import {
|
||||
readBundledMeowaApiKey,
|
||||
readEmbeddedMeowaApiKey,
|
||||
} from '../../services/meowa-game-assets-release-credential';
|
||||
|
||||
export const MEOWA_GAME_ASSETS_ACCOUNT_ID = 'meowa-game-assets';
|
||||
export const MEOWA_GAME_ASSETS_API_BASE_URL = 'https://api.meowa.ai';
|
||||
export const MEOWA_GAME_ASSETS_SKILL_VERSION = '2026.06.19.1';
|
||||
|
||||
const MAX_REFERENCE_FILE_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_REFERENCE_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_DOWNLOAD_BYTES = 32 * 1024 * 1024;
|
||||
const SUPPORTED_KINDS = ['pixel', 'hd'] as const;
|
||||
|
||||
type MeowaGameAssetKind = typeof SUPPORTED_KINDS[number];
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as JsonRecord
|
||||
: null;
|
||||
}
|
||||
|
||||
function readRequiredString(value: unknown, field: string, maxLength = 2_000): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new Error(`Missing ${field}`);
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (normalized.length > maxLength) {
|
||||
throw new Error(`${field} is too long`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown, maxLength = 2_000): string | undefined {
|
||||
if (typeof value !== 'string' || !value.trim()) return undefined;
|
||||
const normalized = value.trim();
|
||||
if (normalized.length > maxLength) throw new Error('Optional field is too long');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readKind(value: unknown): MeowaGameAssetKind {
|
||||
if (value === 'pixel' || value === 'hd') return value;
|
||||
throw new Error('kind must be pixel or hd');
|
||||
}
|
||||
|
||||
function endpoint(kind: MeowaGameAssetKind, suffix: string): string {
|
||||
return `${MEOWA_GAME_ASSETS_API_BASE_URL}/api/${kind}-gen${suffix}`;
|
||||
}
|
||||
|
||||
function upstreamHeaders(apiKey?: string): Record<string, string> {
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function readResponsePayload(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return text.slice(0, 500);
|
||||
}
|
||||
}
|
||||
|
||||
function messageFromPayload(payload: unknown, fallback: string): string {
|
||||
const record = asRecord(payload);
|
||||
if (record) {
|
||||
for (const field of ['message', 'error', 'detail', 'msg']) {
|
||||
const value = record[field];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 500);
|
||||
}
|
||||
}
|
||||
if (typeof payload === 'string' && payload.trim()) return payload.trim().slice(0, 500);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function sendUpstreamError(
|
||||
res: ServerResponse,
|
||||
response: Response,
|
||||
operation: string,
|
||||
): Promise<void> {
|
||||
const payload = await readResponsePayload(response);
|
||||
const status = response.status >= 400 && response.status < 500 ? response.status : 502;
|
||||
sendJson(res, status, {
|
||||
success: false,
|
||||
error: `${operation}: ${messageFromPayload(payload, `upstream status ${response.status}`)}`,
|
||||
});
|
||||
}
|
||||
|
||||
type MeowaCredentialSource = 'secure-store' | 'environment' | 'release-bundle' | 'embedded' | 'none';
|
||||
|
||||
async function resolveCredential(): Promise<{ apiKey: string | null; source: MeowaCredentialSource }> {
|
||||
const stored = await getApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID);
|
||||
if (stored?.trim()) return { apiKey: stored.trim(), source: 'secure-store' };
|
||||
|
||||
const environment = process.env.MEOWART_API_KEY?.trim();
|
||||
if (environment) {
|
||||
await storeApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID, environment);
|
||||
return { apiKey: environment, source: 'environment' };
|
||||
}
|
||||
|
||||
const bundled = await readBundledMeowaApiKey();
|
||||
if (bundled) {
|
||||
await storeApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID, bundled);
|
||||
return { apiKey: bundled, source: 'release-bundle' };
|
||||
}
|
||||
|
||||
const embedded = readEmbeddedMeowaApiKey();
|
||||
if (embedded) {
|
||||
await storeApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID, embedded);
|
||||
return { apiKey: embedded, source: 'embedded' };
|
||||
}
|
||||
|
||||
return { apiKey: null, source: 'none' };
|
||||
}
|
||||
|
||||
export async function initializeMeowaGameAssetsCredential(): Promise<Awaited<ReturnType<typeof resolveCredential>>> {
|
||||
return resolveCredential();
|
||||
}
|
||||
|
||||
async function requireCredential(res: ServerResponse): Promise<string | null> {
|
||||
const credential = await resolveCredential();
|
||||
if (credential.apiKey) return credential.apiKey;
|
||||
sendJson(res, 409, {
|
||||
success: false,
|
||||
error: 'Meowa 素材服务尚未配置,请联系管理员。',
|
||||
code: 'MEOWA_API_KEY_MISSING',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function readTemplateConfig(value: unknown): string {
|
||||
if (value === undefined || value === null || value === '') return '{}';
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('templateConfig must be an object');
|
||||
return JSON.stringify(parsed);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid templateConfig: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
||||
}
|
||||
}
|
||||
if (!asRecord(value)) throw new Error('templateConfig must be an object');
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function readReferenceFiles(value: unknown): Array<{ name: string; mimeType: string; bytes: Buffer }> {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value)) throw new Error('referenceFiles must be an array');
|
||||
|
||||
let totalBytes = 0;
|
||||
return value.map((item, index) => {
|
||||
const record = asRecord(item);
|
||||
if (!record) throw new Error(`referenceFiles[${index}] must be an object`);
|
||||
const encoded = readRequiredString(record.dataBase64, `referenceFiles[${index}].dataBase64`, MAX_REFERENCE_FILE_BYTES * 2);
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) throw new Error(`referenceFiles[${index}] has invalid base64`);
|
||||
const bytes = Buffer.from(encoded, 'base64');
|
||||
if (bytes.length > MAX_REFERENCE_FILE_BYTES) throw new Error(`referenceFiles[${index}] is too large`);
|
||||
totalBytes += bytes.length;
|
||||
if (totalBytes > MAX_REFERENCE_BYTES) throw new Error('referenceFiles are too large');
|
||||
return {
|
||||
name: readOptionalString(record.name, 160) ?? `reference-${index + 1}.png`,
|
||||
mimeType: readOptionalString(record.mimeType, 120) ?? 'image/png',
|
||||
bytes,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function buildGenerationForm(kind: MeowaGameAssetKind, body: JsonRecord): Promise<FormData> {
|
||||
const form = new FormData();
|
||||
form.set('template_name', readRequiredString(body.templateName, 'templateName', 200));
|
||||
form.set('template_config', readTemplateConfig(body.templateConfig));
|
||||
form.set('requirement', readRequiredString(body.requirement, 'requirement', 12_000));
|
||||
form.set('aspect_ratio', readOptionalString(body.aspectRatio, 40) ?? '1:1');
|
||||
form.set('temperature', String(readNumber(body.temperature, 0)));
|
||||
form.set('include_base64', readBoolean(body.includeBase64, false) ? 'true' : 'false');
|
||||
|
||||
const optionalFields: Array<[string, unknown, number]> = [
|
||||
['job_name', body.jobName, 200],
|
||||
['model_name', body.modelName, 200],
|
||||
['resolution', body.resolution, 40],
|
||||
['hd_remove_bg_mode', body.hdRemoveBgMode, 80],
|
||||
['project_id', body.projectId, 200],
|
||||
['thread_id', body.threadId, 200],
|
||||
];
|
||||
for (const [field, value, maxLength] of optionalFields) {
|
||||
const normalized = readOptionalString(value, maxLength);
|
||||
if (normalized) form.set(field, normalized);
|
||||
}
|
||||
|
||||
const references = readReferenceFiles(body.referenceFiles);
|
||||
references.forEach((reference, index) => {
|
||||
const blob = new Blob([reference.bytes], { type: reference.mimeType });
|
||||
form.append(index === 0 ? 'reference_file' : 'reference_files', blob, reference.name);
|
||||
});
|
||||
|
||||
// Keep this explicit so adding a third generation kind cannot silently send
|
||||
// HD-only fields to a different Meowa endpoint.
|
||||
if (kind === 'pixel') {
|
||||
form.delete('hd_remove_bg_mode');
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
async function forwardJson(
|
||||
res: ServerResponse,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
operation: string,
|
||||
): Promise<void> {
|
||||
const response = await proxyAwareFetch(url, init);
|
||||
if (!response.ok) {
|
||||
await sendUpstreamError(res, response, operation);
|
||||
return;
|
||||
}
|
||||
const payload = await readResponsePayload(response);
|
||||
sendJson(res, response.status, payload);
|
||||
}
|
||||
|
||||
function parseOutputIndex(value: string | null): number | null {
|
||||
if (value === null || value.trim() === '') return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 99) throw new Error('outputIndex must be an integer from 0 to 99');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function responseFileName(response: Response, jobId: string): string {
|
||||
const disposition = response.headers.get('content-disposition') ?? '';
|
||||
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]).replace(/[\\/\0]/g, '_');
|
||||
const match = disposition.match(/filename="?([^";]+)"?/i);
|
||||
if (match?.[1]) return match[1].replace(/[\\/\0]/g, '_');
|
||||
const mime = response.headers.get('content-type')?.toLowerCase() ?? '';
|
||||
const suffix = mime.includes('zip') ? '.zip' : mime.includes('jpeg') ? '.jpg' : mime.includes('webp') ? '.webp' : '.png';
|
||||
return `${jobId}${suffix}`;
|
||||
}
|
||||
|
||||
async function handleDownload(
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
kind: MeowaGameAssetKind,
|
||||
apiKey: string,
|
||||
): Promise<void> {
|
||||
const jobId = readRequiredString(url.searchParams.get('id'), 'id', 200);
|
||||
const outputIndex = parseOutputIndex(url.searchParams.get('outputIndex'));
|
||||
const suffix = outputIndex === null
|
||||
? `/jobs/${encodeURIComponent(jobId)}/download`
|
||||
: `/jobs/${encodeURIComponent(jobId)}/outputs/${outputIndex}/download`;
|
||||
const response = await proxyAwareFetch(endpoint(kind, suffix), {
|
||||
method: 'GET',
|
||||
headers: upstreamHeaders(apiKey),
|
||||
});
|
||||
if (!response.ok) {
|
||||
await sendUpstreamError(res, response, 'Meowa 素材下载失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
if (bytes.length > MAX_DOWNLOAD_BYTES) {
|
||||
sendJson(res, 413, { success: false, error: 'Meowa 输出文件超过本地代理大小限制' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
fileName: responseFileName(response, jobId),
|
||||
mimeType: response.headers.get('content-type') || 'application/octet-stream',
|
||||
bytes: bytes.length,
|
||||
dataBase64: bytes.toString('base64'),
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleMeowaGameAssetsRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
_ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/meowa/game-assets/config' && req.method === 'GET') {
|
||||
const credential = await resolveCredential();
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
configured: Boolean(credential.apiKey),
|
||||
credentialSource: credential.source,
|
||||
provider: 'meowa',
|
||||
apiBaseUrl: MEOWA_GAME_ASSETS_API_BASE_URL,
|
||||
skillVersion: MEOWA_GAME_ASSETS_SKILL_VERSION,
|
||||
supportedKinds: [...SUPPORTED_KINDS],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/meowa/game-assets/config' && req.method === 'PUT') {
|
||||
try {
|
||||
const body = await parseJsonBody<JsonRecord>(req);
|
||||
const apiKey = readRequiredString(body.apiKey, 'apiKey', 512);
|
||||
const stored = await storeApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID, apiKey);
|
||||
if (!stored) throw new Error('安全存储写入失败');
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
configured: true,
|
||||
credentialSource: 'secure-store',
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/meowa/game-assets/config' && req.method === 'DELETE') {
|
||||
const deleted = await deleteApiKey(MEOWA_GAME_ASSETS_ACCOUNT_ID);
|
||||
const credential = deleted ? await resolveCredential() : null;
|
||||
sendJson(res, deleted ? 200 : 500, {
|
||||
success: deleted,
|
||||
configured: Boolean(credential?.apiKey),
|
||||
credentialSource: credential?.source ?? 'none',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/meowa/game-assets/skill-doc' && req.method === 'GET') {
|
||||
try {
|
||||
const remoteUrl = new URL(`${MEOWA_GAME_ASSETS_API_BASE_URL}/api/agent-skills/game-assets/doc`);
|
||||
const task = url.searchParams.get('task');
|
||||
const topic = url.searchParams.get('topic');
|
||||
if (task) remoteUrl.searchParams.set('task', task.slice(0, 2_000));
|
||||
if (topic) remoteUrl.searchParams.set('topic', topic.slice(0, 200));
|
||||
await forwardJson(res, remoteUrl.toString(), { method: 'GET', headers: upstreamHeaders() }, 'Meowa Skill 文档获取失败');
|
||||
} catch (error) {
|
||||
sendJson(res, 502, { success: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/meowa/game-assets/template-info' && req.method === 'GET') {
|
||||
try {
|
||||
const kind = readKind(url.searchParams.get('kind'));
|
||||
const apiKey = await requireCredential(res);
|
||||
if (!apiKey) return true;
|
||||
await forwardJson(
|
||||
res,
|
||||
endpoint(kind, '/template-info'),
|
||||
{ method: 'GET', headers: upstreamHeaders(apiKey) },
|
||||
`Meowa ${kind} 模版信息获取失败`,
|
||||
);
|
||||
} catch (error) {
|
||||
sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/meowa/game-assets/generate' && req.method === 'POST') {
|
||||
try {
|
||||
const apiKey = await requireCredential(res);
|
||||
if (!apiKey) return true;
|
||||
const body = await parseJsonBody<JsonRecord>(req);
|
||||
const kind = readKind(body.kind);
|
||||
const form = await buildGenerationForm(kind, body);
|
||||
|
||||
const response = await proxyAwareFetch(endpoint(kind, ''), {
|
||||
method: 'POST',
|
||||
headers: upstreamHeaders(apiKey),
|
||||
body: form,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await sendUpstreamError(res, response, `Meowa ${kind} 素材生成提交失败`);
|
||||
return true;
|
||||
}
|
||||
sendJson(res, response.status, await readResponsePayload(response));
|
||||
} catch (error) {
|
||||
sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/meowa/game-assets/jobs' && req.method === 'GET') {
|
||||
try {
|
||||
const kind = readKind(url.searchParams.get('kind'));
|
||||
const apiKey = await requireCredential(res);
|
||||
if (!apiKey) return true;
|
||||
const jobId = readRequiredString(url.searchParams.get('id'), 'id', 200);
|
||||
await forwardJson(
|
||||
res,
|
||||
endpoint(kind, `/jobs?id=${encodeURIComponent(jobId)}`),
|
||||
{ method: 'GET', headers: upstreamHeaders(apiKey) },
|
||||
`Meowa ${kind} 任务查询失败`,
|
||||
);
|
||||
} catch (error) {
|
||||
sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/meowa/game-assets/download' && req.method === 'GET') {
|
||||
try {
|
||||
const kind = readKind(url.searchParams.get('kind'));
|
||||
const apiKey = await requireCredential(res);
|
||||
if (!apiKey) return true;
|
||||
await handleDownload(res, url, kind, apiKey);
|
||||
} catch (error) {
|
||||
sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/meowa/game-assets/credits' && req.method === 'GET') {
|
||||
try {
|
||||
const apiKey = await requireCredential(res);
|
||||
if (!apiKey) return true;
|
||||
await forwardJson(
|
||||
res,
|
||||
`${MEOWA_GAME_ASSETS_API_BASE_URL}/api/credits/balance`,
|
||||
{ method: 'GET', headers: upstreamHeaders(apiKey) },
|
||||
'Meowa 额度查询失败',
|
||||
);
|
||||
} catch (error) {
|
||||
sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
319
electron/coding-plugins/adapters/game-resource.ts
Normal file
319
electron/coding-plugins/adapters/game-resource.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins';
|
||||
import { PiGameAssetTools } from '../../coding-runtime/pi/extensions/game-assets';
|
||||
import type { MarketplacePackageClientPort, PluginPackageStore } from '../package-store';
|
||||
import {
|
||||
GameResourceClient,
|
||||
GameResourceClientError,
|
||||
type GameResourceGeneration,
|
||||
} from '../../services/game-resource-client';
|
||||
import type {
|
||||
AdapterInvocationResult,
|
||||
CodingPluginAdapter,
|
||||
PluginBackendProjection,
|
||||
TrustedCodingCapabilityContext,
|
||||
} from '../registry';
|
||||
|
||||
const PLUGIN_ID = 'makelore.game-resource';
|
||||
const MAX_REFERENCE_FILE_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_REFERENCE_BYTES = 16 * 1024 * 1024;
|
||||
const EXECUTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
||||
|
||||
type Input = Record<string, unknown>;
|
||||
|
||||
export interface GameResourcePluginAdapterOptions {
|
||||
readonly client: GameResourceClient;
|
||||
readonly marketplace: MarketplacePackageClientPort;
|
||||
readonly packageStore: Pick<PluginPackageStore, 'getInstalled' | 'getInstalledRelease'>;
|
||||
readonly makeloreVersion: string;
|
||||
readonly gameAssets?: PiGameAssetTools;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Input {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function success<T>(
|
||||
data: T,
|
||||
status = 200,
|
||||
billing?: Extract<NonNullable<AdapterInvocationResult['billing']>, { mode: 'platform_metered' }>,
|
||||
): AdapterInvocationResult<T> {
|
||||
return {
|
||||
success: true,
|
||||
status,
|
||||
code: null,
|
||||
error: null,
|
||||
retryable: false,
|
||||
payload_schema: 'game-resource.v1',
|
||||
data,
|
||||
...(billing ? { billing } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function failure(
|
||||
code: string,
|
||||
error: string,
|
||||
status: number,
|
||||
retryable: boolean,
|
||||
billing?: Extract<NonNullable<AdapterInvocationResult['billing']>, { mode: 'platform_metered' }>,
|
||||
): AdapterInvocationResult {
|
||||
return {
|
||||
success: false,
|
||||
status,
|
||||
code,
|
||||
error,
|
||||
retryable,
|
||||
payload_schema: 'game-resource.v1',
|
||||
data: null,
|
||||
...(billing ? { billing } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function generationData(value: GameResourceGeneration, includeBilling: boolean) {
|
||||
return {
|
||||
executionId: value.executionId,
|
||||
status: value.status,
|
||||
outputCount: value.outputCount,
|
||||
...(value.pollIntervalSeconds === undefined ? {} : { pollIntervalSeconds: value.pollIntervalSeconds }),
|
||||
...(value.errorCode === undefined ? {} : { errorCode: value.errorCode }),
|
||||
...(includeBilling ? { generationBilling: value.billing } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function clientFailure(error: unknown): AdapterInvocationResult {
|
||||
if (error instanceof GameResourceClientError) {
|
||||
return failure(error.code, error.message, error.status, error.retryable);
|
||||
}
|
||||
return failure(
|
||||
'plugin_backend_unavailable',
|
||||
'Hosted game-resource service is temporarily unavailable',
|
||||
503,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function relativeProjectPath(projectPath: string, candidate: unknown): { absolute: string; relative: string } {
|
||||
if (typeof candidate !== 'string' || !candidate.trim() || candidate.length > 1_024) {
|
||||
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Project-relative path is invalid');
|
||||
}
|
||||
const root = path.resolve(projectPath);
|
||||
const absolute = path.resolve(root, candidate);
|
||||
const relative = path.relative(root, absolute);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Project-relative path is invalid');
|
||||
}
|
||||
return { absolute, relative: relative.split(path.sep).join('/') };
|
||||
}
|
||||
|
||||
function mimeType(filePath: string): string {
|
||||
switch (path.extname(filePath).toLowerCase()) {
|
||||
case '.jpg':
|
||||
case '.jpeg': return 'image/jpeg';
|
||||
case '.webp': return 'image/webp';
|
||||
case '.gif': return 'image/gif';
|
||||
default: return 'image/png';
|
||||
}
|
||||
}
|
||||
|
||||
async function references(projectPath: string, value: unknown) {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length > 8) {
|
||||
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Reference paths are invalid');
|
||||
}
|
||||
let total = 0;
|
||||
return await Promise.all(value.map(async (candidate) => {
|
||||
const target = relativeProjectPath(projectPath, candidate);
|
||||
const metadata = await stat(target.absolute).catch(() => null);
|
||||
if (!metadata?.isFile() || metadata.size <= 0 || metadata.size > MAX_REFERENCE_FILE_BYTES) {
|
||||
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Reference file is unavailable or too large');
|
||||
}
|
||||
total += metadata.size;
|
||||
if (total > MAX_REFERENCE_BYTES) {
|
||||
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Reference files exceed their total bound');
|
||||
}
|
||||
return {
|
||||
name: path.basename(target.absolute).slice(0, 160),
|
||||
mimeType: mimeType(target.absolute),
|
||||
dataBase64: (await readFile(target.absolute)).toString('base64'),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
function templateConfig(value: unknown): Readonly<Record<string, unknown>> {
|
||||
if (value === undefined || value === '') return {};
|
||||
if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > 16_384) {
|
||||
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Template config JSON is invalid');
|
||||
}
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(value) as unknown; } catch {
|
||||
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Template config JSON is invalid');
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Template config JSON must be an object');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export class GameResourcePluginAdapter implements CodingPluginAdapter {
|
||||
readonly pluginId = PLUGIN_ID;
|
||||
private readonly gameAssets: PiGameAssetTools;
|
||||
|
||||
constructor(private readonly options: GameResourcePluginAdapterOptions) {
|
||||
this.gameAssets = options.gameAssets ?? new PiGameAssetTools();
|
||||
}
|
||||
|
||||
async inspect(): Promise<PluginBackendProjection> {
|
||||
const installed = await this.options.packageStore.getInstalled(PLUGIN_ID).catch(() => null);
|
||||
return installed ? { status: 'ready' } : { status: 'unconfigured' };
|
||||
}
|
||||
|
||||
async invoke(
|
||||
context: TrustedCodingCapabilityContext,
|
||||
tool: CodingPluginToolDefinition,
|
||||
input: unknown,
|
||||
): Promise<AdapterInvocationResult> {
|
||||
if (!isRecord(input)) return failure('plugin_input_invalid', 'Game-resource tool input is invalid', 422, false);
|
||||
try {
|
||||
switch (tool.name) {
|
||||
case 'game_resource_templates': {
|
||||
const kind = input.kind === 'pixel' || input.kind === 'hd' ? input.kind : null;
|
||||
if (!kind) return failure('plugin_input_invalid', 'Game-resource kind is invalid', 422, false);
|
||||
const admission = await this.admission(context);
|
||||
return success({ kind, templates: await this.options.client.templates({ ...admission, kind }) });
|
||||
}
|
||||
case 'game_resource_generate': {
|
||||
if (input.confirmed !== true) {
|
||||
return failure(
|
||||
'confirmation_required',
|
||||
'Explicit Token Point generation confirmation is required',
|
||||
400,
|
||||
false,
|
||||
);
|
||||
}
|
||||
const kind = input.kind === 'pixel' || input.kind === 'hd' ? input.kind : null;
|
||||
if (!kind || typeof input.templateName !== 'string' || typeof input.requirement !== 'string') {
|
||||
return failure('plugin_input_invalid', 'Game-resource generation input is invalid', 422, false);
|
||||
}
|
||||
const admission = await this.admission(context);
|
||||
const generated = await this.options.client.generate({
|
||||
...admission,
|
||||
projectId: context.durableProjectId,
|
||||
logicalOperationId: context.requestId,
|
||||
kind,
|
||||
templateName: input.templateName,
|
||||
templateConfig: templateConfig(input.templateConfigJson),
|
||||
requirement: input.requirement,
|
||||
...(typeof input.aspectRatio === 'string' ? { aspectRatio: input.aspectRatio } : {}),
|
||||
...(typeof input.temperature === 'number' ? { temperature: input.temperature } : {}),
|
||||
...(typeof input.jobName === 'string' ? { jobName: input.jobName } : {}),
|
||||
...(typeof input.modelName === 'string' ? { modelName: input.modelName } : {}),
|
||||
...(typeof input.resolution === 'string' ? { resolution: input.resolution } : {}),
|
||||
...(typeof input.hdRemoveBgMode === 'string' ? { hdRemoveBgMode: input.hdRemoveBgMode } : {}),
|
||||
...(typeof input.threadId === 'string' ? { threadId: input.threadId } : {}),
|
||||
referenceFiles: await references(context.projectPath, input.referencePaths),
|
||||
});
|
||||
if (generated.status === 'failed' || generated.status === 'cancelled') {
|
||||
return failure(
|
||||
generated.errorCode ?? 'game_resource_generation_failed',
|
||||
'Hosted game-resource generation was rejected',
|
||||
422,
|
||||
false,
|
||||
generated.billing,
|
||||
);
|
||||
}
|
||||
return success(
|
||||
generationData(generated, false),
|
||||
generated.status === 'succeeded' ? 200 : 202,
|
||||
generated.billing,
|
||||
);
|
||||
}
|
||||
case 'game_resource_status': {
|
||||
const executionId = this.executionId(input.executionId);
|
||||
const generated = await this.options.client.get(executionId);
|
||||
return success(generationData(generated, true));
|
||||
}
|
||||
case 'game_resource_cancel': {
|
||||
const executionId = this.executionId(input.executionId);
|
||||
const generated = await this.options.client.cancel(executionId);
|
||||
return success(generationData(generated, true));
|
||||
}
|
||||
case 'game_resource_save_output': {
|
||||
if (input.confirmed !== true) return failure('confirmation_required', 'Explicit save confirmation is required', 400, false);
|
||||
const executionId = this.executionId(input.executionId);
|
||||
const outputIndex = input.outputIndex === undefined ? undefined : input.outputIndex;
|
||||
if (outputIndex !== undefined && (!Number.isSafeInteger(outputIndex) || (outputIndex as number) < 0 || (outputIndex as number) > 99)) {
|
||||
return failure('plugin_input_invalid', 'Game-resource output index is invalid', 422, false);
|
||||
}
|
||||
const target = relativeProjectPath(context.projectPath, input.relativePath);
|
||||
const content = await this.options.client.download(executionId, outputIndex as number | undefined);
|
||||
await mkdir(path.dirname(target.absolute), { recursive: true });
|
||||
try {
|
||||
await writeFile(target.absolute, content.bytes, { flag: 'wx' });
|
||||
} catch (error) {
|
||||
if (isRecord(error) && error.code === 'EEXIST') {
|
||||
return failure('game_resource_destination_exists', 'Destination file already exists', 409, false);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return success({ savedPath: target.relative, bytes: content.bytes.byteLength });
|
||||
}
|
||||
case 'game_asset_browser': {
|
||||
const result = await this.gameAssets.browse(context.projectPath, input, context.requestId);
|
||||
const parsed = JSON.parse(result.content[0]?.text ?? '{}') as unknown;
|
||||
return success(parsed);
|
||||
}
|
||||
case 'game_asset_review': {
|
||||
const result = await this.gameAssets.review(context.projectPath, input, context.requestId);
|
||||
return success(result.details);
|
||||
}
|
||||
default:
|
||||
return failure('plugin_contract_unsupported', 'Game-resource operation is unavailable', 503, true);
|
||||
}
|
||||
} catch (error) {
|
||||
return clientFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
private executionId(value: unknown): string {
|
||||
if (typeof value !== 'string' || !EXECUTION_ID.test(value)) {
|
||||
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Execution ID is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private async admission(context: TrustedCodingCapabilityContext): Promise<{
|
||||
releaseId: string;
|
||||
releaseAdmissionId: string;
|
||||
}> {
|
||||
const installed = context.pluginReleaseId
|
||||
? await this.options.packageStore.getInstalledRelease(PLUGIN_ID, context.pluginReleaseId)
|
||||
: await this.options.packageStore.getInstalled(PLUGIN_ID);
|
||||
if (!installed || !installed.channel) {
|
||||
throw new GameResourceClientError('plugin_release_unavailable', 409, false, 'Installed game-resource Release is unavailable');
|
||||
}
|
||||
const resolved = await this.options.marketplace.resolve({
|
||||
resolveRequestId: context.requestId,
|
||||
makeloreVersion: this.options.makeloreVersion,
|
||||
channel: installed.channel,
|
||||
installed: [{
|
||||
pluginId: installed.pluginId,
|
||||
releaseId: installed.releaseId,
|
||||
sha256: installed.sha256,
|
||||
}],
|
||||
});
|
||||
const item = resolved.items.find(({ pluginId }) => pluginId === PLUGIN_ID);
|
||||
if (!item?.releaseId || !item.releaseAdmissionId || item.releaseId !== installed.releaseId
|
||||
|| (item.action !== 'keep' && item.action !== 'install')) {
|
||||
throw new GameResourceClientError('plugin_runtime_stale', 409, false, 'Game-resource worker Release is stale');
|
||||
}
|
||||
return { releaseId: item.releaseId, releaseAdmissionId: item.releaseAdmissionId };
|
||||
}
|
||||
}
|
||||
|
||||
export function createGameResourcePluginAdapter(
|
||||
options: GameResourcePluginAdapterOptions,
|
||||
): GameResourcePluginAdapter {
|
||||
return new GameResourcePluginAdapter(options);
|
||||
}
|
||||
@@ -31,6 +31,8 @@ export interface SkillEntry {
|
||||
/** A policy row copied from the last verified server catalog. */
|
||||
export interface RuntimePolicy {
|
||||
readonly pluginId: string;
|
||||
readonly pluginVersion: string;
|
||||
readonly releaseId: string | null;
|
||||
readonly contractVersion: number;
|
||||
readonly capabilityId: string;
|
||||
readonly operation: string;
|
||||
@@ -90,6 +92,7 @@ export interface EffectivePluginResolverOptions {
|
||||
readonly packageStore?: {
|
||||
readInstalledIndex(): Promise<readonly { pluginId: string }[]>;
|
||||
getInstalled(pluginId: string): Promise<InstalledRelease | null>;
|
||||
getInstalledRelease(pluginId: string, releaseId: string): Promise<InstalledRelease | null>;
|
||||
};
|
||||
readonly getLibrary?: (binding: AccountBinding) => Promise<MarketplaceLibrarySnapshot | null> | MarketplaceLibrarySnapshot | null;
|
||||
readonly marketplace?: {
|
||||
@@ -393,6 +396,8 @@ export class EffectivePluginResolver {
|
||||
if (!policy) continue;
|
||||
runtimePolicies.push({
|
||||
pluginId: definition.id,
|
||||
pluginVersion: definition.version,
|
||||
releaseId: definition.releaseId,
|
||||
contractVersion: definition.contractVersion,
|
||||
capabilityId: operation.capabilityId,
|
||||
operation: operation.operation,
|
||||
@@ -455,6 +460,18 @@ export class EffectivePluginResolver {
|
||||
return this.options.policyClient?.getState() ?? EMPTY_POLICY_STATE;
|
||||
}
|
||||
|
||||
async getInstalledDefinition(
|
||||
pluginId: string,
|
||||
releaseId?: string | null,
|
||||
): Promise<CodingPluginDefinition | null> {
|
||||
if (releaseId && this.options.packageStore) {
|
||||
const installed = await this.options.packageStore.getInstalledRelease(pluginId, releaseId);
|
||||
return installed && !installed.unavailableReason ? installed.definition : null;
|
||||
}
|
||||
const record = (await this.definitionRecords()).find(({ definition }) => definition.id === pluginId);
|
||||
return record?.installed && !record.unavailableReason ? record.definition : null;
|
||||
}
|
||||
|
||||
private async definitionRecords(): Promise<DefinitionRecord[]> {
|
||||
const base = [
|
||||
...(this.options.definitions ?? []),
|
||||
|
||||
@@ -772,7 +772,9 @@ function parseV2Tool(
|
||||
fail(filePath, `tools[${index}].mutation`, 'unknown tool mutation');
|
||||
}
|
||||
const projectWriteLease = bool(tool.projectWriteLease, filePath, `tools[${index}].projectWriteLease`);
|
||||
if (projectWriteLease) fail(filePath, `tools[${index}].projectWriteLease`, 'distributed tools cannot request a project write lease');
|
||||
if (projectWriteLease && tool.mutation === 'read') {
|
||||
fail(filePath, `tools[${index}].projectWriteLease`, 'read-only tools cannot request a project write lease');
|
||||
}
|
||||
const permissions = uniqueStrings(
|
||||
tool.permissions,
|
||||
filePath,
|
||||
@@ -804,7 +806,7 @@ function parseV2Tool(
|
||||
operation,
|
||||
roles: ['parent'],
|
||||
mutation: mutation as PluginToolMutation,
|
||||
projectWriteLease: false,
|
||||
projectWriteLease,
|
||||
permissions,
|
||||
executionMode: tool.executionMode as CodingPluginExecutionMode,
|
||||
inputSchema: freezeDeep(structuredClone(inputSchema)),
|
||||
|
||||
@@ -320,7 +320,7 @@ function parseIndexDocument(value: unknown): IndexDocument {
|
||||
if (seen.has(key)) fail('plugin_store_index_invalid', `duplicate release ${releaseId}`);
|
||||
seen.add(key);
|
||||
const runtimeKind = record.runtime_kind;
|
||||
if (runtimeKind !== 'skill_only') {
|
||||
if (runtimeKind !== 'skill_only' && runtimeKind !== 'platform_hosted') {
|
||||
fail('plugin_store_index_invalid', `invalid release ${index}.runtime_kind`);
|
||||
}
|
||||
const installedAt = boundedText(record.installed_at, `release ${index}.installed_at`, 80);
|
||||
@@ -706,6 +706,16 @@ export class PluginPackageStore {
|
||||
return this.getInstalledFromIndex(index, validated, undefined, current);
|
||||
}
|
||||
|
||||
async getInstalledRelease(pluginId: string, releaseId: string): Promise<InstalledRelease | null> {
|
||||
const validatedPluginId = validPluginId(pluginId);
|
||||
const validatedReleaseId = validReleaseId(releaseId);
|
||||
return this.getInstalledFromIndex(
|
||||
await this.readIndex(),
|
||||
validatedPluginId,
|
||||
validatedReleaseId,
|
||||
);
|
||||
}
|
||||
|
||||
async removeUnused(pluginId: string): Promise<InstallationSnapshot> {
|
||||
const validated = validPluginId(pluginId);
|
||||
return this.withOperation(() => this.removeUnusedLocked(validated, undefined, 'background'));
|
||||
@@ -912,7 +922,7 @@ export class PluginPackageStore {
|
||||
await writeFile(path.join(extractedPath, ORPHAN_ARCHIVE_FILE), toBuffer(artifact), { flag: 'wx' });
|
||||
await rename(extractedPath, packageRoot);
|
||||
moved = true;
|
||||
const record = this.installedRecord(grant, channel);
|
||||
const record = this.installedRecord(grant, channel, definition.runtimeKind);
|
||||
const records = index.releases.filter((candidate) => !(candidate.pluginId === pluginId && candidate.releaseId === grant.releaseId));
|
||||
this.assertBinding(binding);
|
||||
try {
|
||||
@@ -968,7 +978,7 @@ export class PluginPackageStore {
|
||||
const descriptor = this.verifyArtifact(artifact, input.grant);
|
||||
const definition = await this.loadDefinition(input.packageRoot, input.grant, descriptor);
|
||||
this.assertBinding(input.binding);
|
||||
const record = this.installedRecord(input.grant, input.channel);
|
||||
const record = this.installedRecord(input.grant, input.channel, definition.runtimeKind);
|
||||
this.assertBinding(input.binding);
|
||||
try {
|
||||
await this.writeIndex(this.indexPath, serializeIndex({
|
||||
@@ -1019,14 +1029,18 @@ export class PluginPackageStore {
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
private installedRecord(grant: DownloadGrant, channel: 'stable' | 'beta'): InstalledReleaseRecord {
|
||||
private installedRecord(
|
||||
grant: DownloadGrant,
|
||||
channel: 'stable' | 'beta',
|
||||
runtimeKind: 'skill_only' | 'platform_hosted',
|
||||
): InstalledReleaseRecord {
|
||||
return Object.freeze({
|
||||
pluginId: grant.pluginId,
|
||||
releaseId: grant.releaseId,
|
||||
version: grant.version,
|
||||
packageSchemaVersion: grant.packageSchemaVersion,
|
||||
contractVersion: grant.contractVersion,
|
||||
runtimeKind: 'skill_only',
|
||||
runtimeKind,
|
||||
sha256: grant.sha256,
|
||||
sizeBytes: grant.sizeBytes,
|
||||
installedAt: new Date(this.now()).toISOString(),
|
||||
@@ -1193,7 +1207,6 @@ export class PluginPackageStore {
|
||||
let definition: CodingPluginDefinition;
|
||||
try {
|
||||
definition = await loadCodingPluginDefinition(packageRoot, {
|
||||
runtimeKind: 'skill_only',
|
||||
acquisitionMode: 'user_acquired',
|
||||
releaseId: grant.releaseId,
|
||||
provenance: { source: 'marketplace', packageRoot },
|
||||
@@ -1204,7 +1217,8 @@ export class PluginPackageStore {
|
||||
}
|
||||
if (definition.id !== descriptor.pluginId || definition.version !== descriptor.version
|
||||
|| definition.contractVersion !== descriptor.contractVersion || definition.releaseId !== grant.releaseId
|
||||
|| definition.runtimeKind !== 'skill_only' || definition.acquisitionMode !== 'user_acquired') {
|
||||
|| (definition.runtimeKind !== 'skill_only' && definition.runtimeKind !== 'platform_hosted')
|
||||
|| definition.acquisitionMode !== 'user_acquired') {
|
||||
fail('plugin_manifest_invalid', 'package definition does not match the signed Release');
|
||||
}
|
||||
return definition;
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface TrustedCodingCapabilityContext {
|
||||
durableProjectId: string;
|
||||
workerRole: 'parent' | 'child';
|
||||
effectiveSkillIds: readonly string[];
|
||||
pluginReleaseId?: string;
|
||||
}
|
||||
|
||||
export type PluginBackendProjection =
|
||||
@@ -527,19 +528,50 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor
|
||||
effectiveSkillIds: readonly string[];
|
||||
value: unknown;
|
||||
}): Promise<PiProductToolResult> {
|
||||
const indexed = this.toolsByName.get(input.toolName);
|
||||
let indexed = this.toolsByName.get(input.toolName);
|
||||
const validId = requestId(input.context) !== 'invalid-request-id';
|
||||
if (!indexed && this.options.effectiveResolver && input.context.effectiveSnapshot) {
|
||||
const snapshotTool = input.context.effectiveSnapshot.toolDefinitions
|
||||
.find(({ name }) => name === input.toolName);
|
||||
const policy = snapshotTool
|
||||
? input.context.effectiveSnapshot.runtimePolicies.find((candidate) => (
|
||||
candidate.capabilityId === snapshotTool.capabilityId
|
||||
&& candidate.operation === snapshotTool.operation
|
||||
))
|
||||
: undefined;
|
||||
const definition = policy
|
||||
? await this.options.effectiveResolver.getInstalledDefinition(policy.pluginId, policy.releaseId)
|
||||
: null;
|
||||
const tool = definition?.tools.find((candidate) => (
|
||||
candidate.name === input.toolName
|
||||
&& candidate.capabilityId === snapshotTool?.capabilityId
|
||||
&& candidate.operation === snapshotTool.operation
|
||||
));
|
||||
if (definition && tool) indexed = { definition, tool };
|
||||
}
|
||||
if (!indexed) return this.unknownResult(input.context, 'plugin_backend_unavailable', 'Plugin capability is unavailable');
|
||||
const { definition, tool } = indexed;
|
||||
if (this.options.effectiveResolver && input.context.effectiveSnapshot) {
|
||||
const frozenPolicy = input.context.effectiveSnapshot.runtimePolicies.find((candidate) => (
|
||||
candidate.pluginId === definition.id
|
||||
&& candidate.capabilityId === tool.capabilityId
|
||||
&& candidate.operation === tool.operation
|
||||
));
|
||||
const currentSnapshot = await this.options.effectiveResolver.resolve({
|
||||
projectId: input.context.projectId,
|
||||
projectPath: input.context.projectPath,
|
||||
assignedSkillIds: input.context.effectiveSnapshot.effectiveSkillIds,
|
||||
role: input.workerRole,
|
||||
});
|
||||
const currentPolicy = currentSnapshot.runtimePolicies.find((candidate) => (
|
||||
candidate.pluginId === definition.id
|
||||
&& candidate.capabilityId === tool.capabilityId
|
||||
&& candidate.operation === tool.operation
|
||||
));
|
||||
if (currentSnapshot.accountSessionId !== input.context.effectiveSnapshot.accountSessionId
|
||||
|| !currentSnapshot.toolDefinitions.some(({ name }) => name === input.toolName)) {
|
||||
|| !currentSnapshot.toolDefinitions.some(({ name }) => name === input.toolName)
|
||||
|| frozenPolicy?.releaseId !== currentPolicy?.releaseId
|
||||
|| frozenPolicy?.pluginVersion !== currentPolicy?.pluginVersion) {
|
||||
const disabled = currentSnapshot.unavailableReasons.some((reason) => (
|
||||
reason.pluginId === definition.id && reason.code === 'project_disabled'
|
||||
));
|
||||
@@ -599,6 +631,7 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor
|
||||
durableProjectId,
|
||||
workerRole: input.workerRole,
|
||||
effectiveSkillIds: [...input.effectiveSkillIds],
|
||||
...(definition.releaseId ? { pluginReleaseId: definition.releaseId } : {}),
|
||||
};
|
||||
let result: AdapterInvocationResult;
|
||||
try {
|
||||
@@ -640,7 +673,7 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor
|
||||
code,
|
||||
error,
|
||||
retryable,
|
||||
payload_schema: 'data-service.v1',
|
||||
payload_schema: 'unknown',
|
||||
data: null,
|
||||
}, billing);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@ const MAX_REQUEST_BYTES = 64 * 1024;
|
||||
const PRODUCT_TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9._:-]{0,63}$/u;
|
||||
const CORE_PRODUCT_TOOL_NAMES = new Set([
|
||||
'agent_browser',
|
||||
'game_asset_browser',
|
||||
'game_asset_review',
|
||||
'task_state',
|
||||
'changed_file',
|
||||
'runtime_context',
|
||||
|
||||
@@ -12,8 +12,6 @@ const MUTATION_TOOLS = new Set([
|
||||
'bash',
|
||||
'edit',
|
||||
'write',
|
||||
'game_asset_browser',
|
||||
'game_asset_review',
|
||||
]);
|
||||
const WORKER_ROLE = process.env.MAKELORE_PI_WORKER_ROLE || 'parent';
|
||||
const leases = new Map();
|
||||
@@ -272,26 +270,6 @@ export default async function makeloreRuntime(pi) {
|
||||
},
|
||||
},
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'game_asset_browser',
|
||||
'Game assets',
|
||||
'Load product-owned game asset candidates and their current review state.',
|
||||
{ type: 'object', additionalProperties: false, properties: { invocationId: { type: 'string' } } },
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'game_asset_review',
|
||||
'Game asset review',
|
||||
'Load one versioned game asset review interaction without encoding decisions in message text.',
|
||||
{
|
||||
type: 'object', additionalProperties: false,
|
||||
properties: {
|
||||
invocationId: { type: 'string' },
|
||||
candidateIds: { type: 'array', maxItems: 200, items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'task_state',
|
||||
|
||||
@@ -24,21 +24,16 @@ import type { KnownToolDetails, RuntimeContextDetailsV1 } from '../contracts';
|
||||
import { BUNDLED_CODING_SKILL_IDS } from '../../../shared/coding-skills';
|
||||
import { PiAgentBrowserTool } from './extensions/agent-browser';
|
||||
import { reportChangedFiles } from './extensions/changed-file';
|
||||
import { PiGameAssetTools } from './extensions/game-assets';
|
||||
import { projectTaskState } from './extensions/task-state';
|
||||
|
||||
export type PiProductToolName =
|
||||
| 'agent_browser'
|
||||
| 'game_asset_browser'
|
||||
| 'game_asset_review'
|
||||
| 'task_state'
|
||||
| 'changed_file'
|
||||
| 'runtime_context';
|
||||
|
||||
const PI_PRODUCT_TOOL_NAMES = new Set<string>([
|
||||
'agent_browser',
|
||||
'game_asset_browser',
|
||||
'game_asset_review',
|
||||
'task_state',
|
||||
'changed_file',
|
||||
'runtime_context',
|
||||
@@ -77,7 +72,6 @@ export interface PiProductToolsOptions {
|
||||
export class PiProductTools {
|
||||
readonly changeTracker: ConversationChangeTracker;
|
||||
private readonly browser: PiAgentBrowserTool;
|
||||
private readonly gameAssets = new PiGameAssetTools();
|
||||
private capabilityRegistry: CodingCapabilityRegistry | undefined;
|
||||
|
||||
constructor(private readonly options: PiProductToolsOptions) {
|
||||
@@ -152,12 +146,6 @@ export class PiProductTools {
|
||||
if (toolName === 'agent_browser') {
|
||||
return await this.browser.execute(context, input);
|
||||
}
|
||||
if (toolName === 'game_asset_browser') {
|
||||
return await this.gameAssets.browse(context.projectPath, input, context.resourceId);
|
||||
}
|
||||
if (toolName === 'game_asset_review') {
|
||||
return await this.gameAssets.review(context.projectPath, input, context.resourceId);
|
||||
}
|
||||
if (toolName === 'task_state') return projectTaskState(input);
|
||||
if (toolName === 'changed_file') {
|
||||
return await reportChangedFiles(this.changeTracker, context, input);
|
||||
|
||||
@@ -124,7 +124,7 @@ export type PiWorkerProcessOptions = {
|
||||
/** Tools available to every managed parent worker before plugin materialization. */
|
||||
export const PI_CORE_TOOL_NAMES = Object.freeze([
|
||||
'read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'ask_user', 'subagent',
|
||||
'agent_browser', 'game_asset_browser', 'game_asset_review',
|
||||
'agent_browser',
|
||||
'task_state', 'changed_file', 'runtime_context',
|
||||
] as const);
|
||||
|
||||
|
||||
@@ -70,7 +70,6 @@ import {
|
||||
import { shouldUseSecureWorksSquareSessionPersistence } from '../services/works-square-session-persistence-policy';
|
||||
import { initializeRememberedPassword } from '../services/remembered-password';
|
||||
import { clearManagedWorksSquareRuntimeBestEffort } from '../services/works-square-runtime';
|
||||
import { initializeMeowaGameAssetsCredential } from '../api/routes/meowa-game-assets';
|
||||
import { WorksSquareDesignWorkspace } from '../image-workspace/works-square-workspace';
|
||||
import type { DesignWorkspaceModule } from '../image-workspace/module';
|
||||
import {
|
||||
@@ -603,12 +602,6 @@ async function initialize(): Promise<void> {
|
||||
// services, but never before the first local paint.
|
||||
await runDeferred('proxy', applyProxySettings);
|
||||
await runDeferred('telemetry', initTelemetry);
|
||||
await runDeferred('game-assets credential', async () => {
|
||||
const meowaCredential = await initializeMeowaGameAssetsCredential();
|
||||
if (meowaCredential.source !== 'none') {
|
||||
logger.info(`Meowa game-assets credential initialized via ${meowaCredential.source}`);
|
||||
}
|
||||
});
|
||||
await runDeferred('launch-at-startup setting', syncLaunchAtStartupSettingFromStore);
|
||||
}
|
||||
|
||||
|
||||
334
electron/services/game-resource-client.ts
Normal file
334
electron/services/game-resource-client.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import type { CapabilityBillingReceiptV1 } from '../../shared/data-service';
|
||||
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import { getValidWorksSquareAccessToken } from './works-square-session';
|
||||
|
||||
const MAX_JSON_BYTES = 1_048_576;
|
||||
const MAX_REQUEST_BYTES = 24 * 1024 * 1024;
|
||||
const MAX_CONTENT_BYTES = 32 * 1024 * 1024;
|
||||
const EXECUTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
||||
const DECIMAL = /^(?:0|[1-9]\d*)\.\d{2}$/u;
|
||||
const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'cancelled', 'pending_review']);
|
||||
|
||||
type FetchImplementation = typeof fetch;
|
||||
type AccessTokenGetter = typeof getValidWorksSquareAccessToken;
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export interface GameResourceGeneration {
|
||||
readonly executionId: string;
|
||||
readonly releaseId: string;
|
||||
readonly projectId: string;
|
||||
readonly logicalOperationId: string;
|
||||
readonly kind: 'pixel' | 'hd';
|
||||
readonly templateName: string;
|
||||
readonly status: 'reserved' | 'accepted' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'submission_unknown' | 'pending_review';
|
||||
readonly outputCount: number;
|
||||
readonly pollIntervalSeconds?: number;
|
||||
readonly errorCode?: string;
|
||||
readonly billing: Extract<CapabilityBillingReceiptV1, { mode: 'platform_metered' }>;
|
||||
}
|
||||
|
||||
export interface GameResourceContent {
|
||||
readonly bytes: Uint8Array;
|
||||
readonly fileName: string | null;
|
||||
readonly contentType: string;
|
||||
}
|
||||
|
||||
export interface GameResourceGenerateInput {
|
||||
readonly releaseAdmissionId: string;
|
||||
readonly releaseId: string;
|
||||
readonly projectId: string;
|
||||
readonly logicalOperationId: string;
|
||||
readonly kind: 'pixel' | 'hd';
|
||||
readonly templateName: string;
|
||||
readonly templateConfig: Readonly<Record<string, unknown>>;
|
||||
readonly requirement: string;
|
||||
readonly aspectRatio?: string;
|
||||
readonly temperature?: number;
|
||||
readonly jobName?: string;
|
||||
readonly modelName?: string;
|
||||
readonly resolution?: string;
|
||||
readonly hdRemoveBgMode?: string;
|
||||
readonly threadId?: string;
|
||||
readonly referenceFiles?: readonly {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
dataBase64: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export class GameResourceClientError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
readonly status: number,
|
||||
readonly retryable: boolean,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'GameResourceClientError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface GameResourceClientOptions {
|
||||
readonly fetchImpl?: FetchImplementation;
|
||||
readonly getAccessToken?: AccessTokenGetter;
|
||||
readonly apiBaseUrl?: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function text(value: unknown, maximum: number): string | null {
|
||||
return typeof value === 'string' && value.length > 0 && value.length <= maximum ? value : null;
|
||||
}
|
||||
|
||||
function integer(value: unknown, minimum: number, maximum: number): number | null {
|
||||
return Number.isSafeInteger(value) && (value as number) >= minimum && (value as number) <= maximum
|
||||
? value as number
|
||||
: null;
|
||||
}
|
||||
|
||||
function billing(value: unknown): Extract<CapabilityBillingReceiptV1, { mode: 'platform_metered' }> {
|
||||
if (!isRecord(value) || value.mode !== 'platform_metered') throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted billing receipt is invalid');
|
||||
const status = text(value.status, 32);
|
||||
const reservedPoints = text(value.reserved_points, 32);
|
||||
const actualPoints = value.actual_points === null || value.actual_points === undefined
|
||||
? undefined
|
||||
: text(value.actual_points, 32) ?? undefined;
|
||||
const usageAmount = value.usage_amount === null || value.usage_amount === undefined
|
||||
? undefined
|
||||
: integer(value.usage_amount, 0, Number.MAX_SAFE_INTEGER) ?? undefined;
|
||||
if (!status || !['reserved', 'dispatched', 'settled', 'released', 'expired', 'pending_review', 'refunded'].includes(status)
|
||||
|| !reservedPoints || !DECIMAL.test(reservedPoints)
|
||||
|| (actualPoints !== undefined && !DECIMAL.test(actualPoints))
|
||||
|| (['settled', 'refunded'].includes(status) && actualPoints === undefined)
|
||||
|| value.unit !== 'generation') {
|
||||
throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted billing receipt is invalid');
|
||||
}
|
||||
return {
|
||||
mode: 'platform_metered',
|
||||
status: status as Extract<CapabilityBillingReceiptV1, { mode: 'platform_metered' }>['status'],
|
||||
reserved_points: reservedPoints,
|
||||
...(actualPoints === undefined ? {} : { actual_points: actualPoints }),
|
||||
...(usageAmount === undefined ? {} : { usage_amount: usageAmount }),
|
||||
unit: 'generation',
|
||||
} as Extract<CapabilityBillingReceiptV1, { mode: 'platform_metered' }>;
|
||||
}
|
||||
|
||||
function generation(value: unknown): GameResourceGeneration {
|
||||
if (!isRecord(value) || value.schema_version !== 1 || value.plugin_id !== 'makelore.game-resource') {
|
||||
throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted generation response is invalid');
|
||||
}
|
||||
const executionId = text(value.execution_id, 36);
|
||||
const releaseId = text(value.release_id, 36);
|
||||
const projectId = text(value.project_id, 36);
|
||||
const logicalOperationId = text(value.logical_operation_id, 128);
|
||||
const templateName = text(value.template_name, 200);
|
||||
const status = text(value.status, 32);
|
||||
const outputCount = integer(value.output_count, 0, 100);
|
||||
const poll = value.poll_interval_seconds === null || value.poll_interval_seconds === undefined
|
||||
? undefined
|
||||
: integer(value.poll_interval_seconds, 1, 300) ?? undefined;
|
||||
const errorCode = value.error_code === null || value.error_code === undefined
|
||||
? undefined
|
||||
: text(value.error_code, 128) ?? undefined;
|
||||
if (!executionId || !EXECUTION_ID.test(executionId) || !releaseId || !projectId || !logicalOperationId
|
||||
|| (value.kind !== 'pixel' && value.kind !== 'hd') || !templateName
|
||||
|| !status || !['reserved', 'accepted', 'running', 'succeeded', 'failed', 'cancelled', 'submission_unknown', 'pending_review'].includes(status)
|
||||
|| outputCount === null) {
|
||||
throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted generation response is invalid');
|
||||
}
|
||||
return {
|
||||
executionId,
|
||||
releaseId,
|
||||
projectId,
|
||||
logicalOperationId,
|
||||
kind: value.kind,
|
||||
templateName,
|
||||
status: status as GameResourceGeneration['status'],
|
||||
outputCount,
|
||||
...(poll === undefined ? {} : { pollIntervalSeconds: poll }),
|
||||
...(errorCode === undefined ? {} : { errorCode }),
|
||||
billing: billing(value.billing),
|
||||
};
|
||||
}
|
||||
|
||||
async function readBounded(response: Response, maximum: number): Promise<Uint8Array> {
|
||||
const declared = response.headers.get('content-length');
|
||||
if (declared && /^\d+$/u.test(declared) && Number(declared) > maximum) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw new GameResourceClientError('plugin_backend_response_too_large', 502, false, 'Hosted response exceeds its bound');
|
||||
}
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength > maximum) throw new GameResourceClientError('plugin_backend_response_too_large', 502, false, 'Hosted response exceeds its bound');
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function errorFrom(status: number, payload: unknown): GameResourceClientError {
|
||||
const detail = isRecord(payload) && isRecord(payload.detail) ? payload.detail : null;
|
||||
const code = text(detail?.error_code, 128) ?? 'plugin_backend_unavailable';
|
||||
const retryable = status >= 500 || status === 429;
|
||||
const message = status === 401
|
||||
? 'Works Square sign-in is required'
|
||||
: status === 402
|
||||
? 'Token Point balance is insufficient'
|
||||
: status === 409
|
||||
? 'Hosted game-resource operation conflicts with current state'
|
||||
: status === 422
|
||||
? 'Hosted game-resource request is invalid'
|
||||
: 'Hosted game-resource service is unavailable';
|
||||
return new GameResourceClientError(code, status, retryable, message);
|
||||
}
|
||||
|
||||
function encodedBody(value: unknown): string {
|
||||
const result = JSON.stringify(value);
|
||||
if (Buffer.byteLength(result, 'utf8') > MAX_REQUEST_BYTES) {
|
||||
throw new GameResourceClientError('plugin_input_invalid', 422, false, 'Hosted game-resource request is too large');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export class GameResourceClient {
|
||||
private readonly fetchImpl: FetchImplementation;
|
||||
private readonly getAccessToken: AccessTokenGetter;
|
||||
private readonly apiBaseUrl: string;
|
||||
|
||||
constructor(options: GameResourceClientOptions = {}) {
|
||||
this.fetchImpl = options.fetchImpl ?? proxyAwareFetch;
|
||||
this.getAccessToken = options.getAccessToken ?? getValidWorksSquareAccessToken;
|
||||
this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
async templates(input: { releaseId: string; releaseAdmissionId: string; kind: 'pixel' | 'hd' }): Promise<readonly string[]> {
|
||||
const query = new URLSearchParams({
|
||||
release_id: input.releaseId,
|
||||
release_admission_id: input.releaseAdmissionId,
|
||||
kind: input.kind,
|
||||
});
|
||||
const payload = await this.json('GET', `/api/plugins/v1/hosted/game-resource/templates?${query}`);
|
||||
if (!isRecord(payload) || payload.schema_version !== 1 || payload.kind !== input.kind
|
||||
|| !Array.isArray(payload.templates) || payload.templates.length > 100
|
||||
|| payload.templates.some((item) => !text(item, 200))) {
|
||||
throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted template response is invalid');
|
||||
}
|
||||
return payload.templates as string[];
|
||||
}
|
||||
|
||||
async generate(input: GameResourceGenerateInput): Promise<GameResourceGeneration> {
|
||||
return generation(await this.json('POST', '/api/plugins/v1/hosted/game-resource/generations', {
|
||||
release_admission_id: input.releaseAdmissionId,
|
||||
release_id: input.releaseId,
|
||||
project_id: input.projectId,
|
||||
logical_operation_id: input.logicalOperationId,
|
||||
kind: input.kind,
|
||||
template_name: input.templateName,
|
||||
template_config: input.templateConfig,
|
||||
requirement: input.requirement,
|
||||
aspect_ratio: input.aspectRatio ?? '1:1',
|
||||
temperature: input.temperature ?? 0,
|
||||
...(input.jobName ? { job_name: input.jobName } : {}),
|
||||
...(input.modelName ? { model_name: input.modelName } : {}),
|
||||
...(input.resolution ? { resolution: input.resolution } : {}),
|
||||
...(input.hdRemoveBgMode ? { hd_remove_bg_mode: input.hdRemoveBgMode } : {}),
|
||||
...(input.threadId ? { thread_id: input.threadId } : {}),
|
||||
reference_files: input.referenceFiles?.map((item) => ({
|
||||
name: item.name,
|
||||
mime_type: item.mimeType,
|
||||
data_base64: item.dataBase64,
|
||||
})) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
async get(executionId: string): Promise<GameResourceGeneration> {
|
||||
return generation(await this.json('GET', `/api/plugins/v1/hosted/game-resource/generations/${encodeURIComponent(executionId)}`));
|
||||
}
|
||||
|
||||
async cancel(executionId: string): Promise<GameResourceGeneration> {
|
||||
return generation(await this.json('POST', `/api/plugins/v1/hosted/game-resource/generations/${encodeURIComponent(executionId)}/cancel`));
|
||||
}
|
||||
|
||||
async download(executionId: string, outputIndex?: number): Promise<GameResourceContent> {
|
||||
const suffix = outputIndex === undefined ? '' : `?output_index=${outputIndex}`;
|
||||
const response = await this.send('GET', `/api/plugins/v1/hosted/game-resource/generations/${encodeURIComponent(executionId)}/content${suffix}`);
|
||||
if (!response.ok) {
|
||||
const bytes = await readBounded(response, MAX_JSON_BYTES).catch(() => new Uint8Array());
|
||||
let payload: unknown = null;
|
||||
try { payload = JSON.parse(Buffer.from(bytes).toString('utf8')) as unknown; } catch { /* bounded generic error */ }
|
||||
throw errorFrom(response.status, payload);
|
||||
}
|
||||
const bytes = await readBounded(response, MAX_CONTENT_BYTES);
|
||||
const disposition = response.headers.get('content-disposition') ?? '';
|
||||
const encoded = /filename\*=UTF-8''([^;]+)/iu.exec(disposition)?.[1];
|
||||
return {
|
||||
bytes,
|
||||
fileName: encoded ? decodeURIComponent(encoded).slice(0, 200) : null,
|
||||
contentType: (response.headers.get('content-type') ?? 'application/octet-stream').slice(0, 128),
|
||||
};
|
||||
}
|
||||
|
||||
private async json(method: 'GET' | 'POST', path: string, body?: unknown): Promise<unknown> {
|
||||
const response = await this.send(method, path, body);
|
||||
const bytes = await readBounded(response, MAX_JSON_BYTES);
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = bytes.byteLength ? JSON.parse(Buffer.from(bytes).toString('utf8')) as unknown : null;
|
||||
} catch {
|
||||
throw new GameResourceClientError('plugin_backend_invalid', 502, false, 'Hosted response is invalid');
|
||||
}
|
||||
if (!response.ok) throw errorFrom(response.status, payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private async send(method: 'GET' | 'POST', path: string, body?: unknown): Promise<Response> {
|
||||
let token: string | null;
|
||||
try {
|
||||
token = await this.getAccessToken({ fetchImpl: this.fetchImpl });
|
||||
} catch {
|
||||
token = null;
|
||||
}
|
||||
if (!token) throw new GameResourceClientError('authentication_required', 401, false, 'Works Square sign-in is required');
|
||||
const encoded = body === undefined ? undefined : encodedBody(body);
|
||||
const request = async (accessToken: string) => await this.fetchImpl(`${this.apiBaseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...(encoded === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
...(encoded === undefined ? {} : { body: encoded }),
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(35_000),
|
||||
});
|
||||
let response: Response;
|
||||
try {
|
||||
response = await request(token);
|
||||
} catch {
|
||||
throw new GameResourceClientError('plugin_backend_unavailable', 503, true, 'Hosted game-resource service is unavailable');
|
||||
}
|
||||
if (response.status !== 401) return response;
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
let refreshed: string | null;
|
||||
try {
|
||||
refreshed = await this.getAccessToken({ fetchImpl: this.fetchImpl, forceRefresh: true });
|
||||
} catch {
|
||||
refreshed = null;
|
||||
}
|
||||
if (!refreshed) throw new GameResourceClientError('authentication_required', 401, false, 'Works Square sign-in is required');
|
||||
try {
|
||||
response = await request(refreshed);
|
||||
} catch {
|
||||
throw new GameResourceClientError('plugin_backend_unavailable', 503, true, 'Hosted game-resource service is unavailable');
|
||||
}
|
||||
if (response.status === 401) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw new GameResourceClientError('authentication_required', 401, false, 'Works Square sign-in is required');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export function isTerminalGameResourceStatus(status: string): boolean {
|
||||
return TERMINAL_STATUSES.has(status);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const MEOWA_RELEASE_CREDENTIAL_FILE_NAME = 'meowa-game-assets-credential.json';
|
||||
|
||||
const EMBEDDED_MEOWA_API_KEY = 'ma_live_mY19lufmkTJNULToKGUH2yKJX0YmKHhg';
|
||||
|
||||
export function readEmbeddedMeowaApiKey(): string | null {
|
||||
const apiKey = EMBEDDED_MEOWA_API_KEY.trim();
|
||||
return apiKey && apiKey.length <= 512 ? apiKey : null;
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as JsonRecord
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getMeowaReleaseCredentialPath(resourcesPath: string): string {
|
||||
return join(resourcesPath, 'resources', MEOWA_RELEASE_CREDENTIAL_FILE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the release-only credential staged by electron-builder's afterPack hook.
|
||||
* This file is intentionally not part of the source tree; it exists only in a
|
||||
* packaged artifact built with MEOWART_API_KEY present in the release env.
|
||||
*/
|
||||
export async function readBundledMeowaApiKey(resourcesPath = process.resourcesPath): Promise<string | null> {
|
||||
if (typeof resourcesPath !== 'string' || !resourcesPath.trim()) return null;
|
||||
|
||||
try {
|
||||
const raw = await readFile(getMeowaReleaseCredentialPath(resourcesPath), 'utf8');
|
||||
const record = asRecord(JSON.parse(raw) as unknown);
|
||||
if (!record) return null;
|
||||
if (record.schemaVersion !== 1) return null;
|
||||
|
||||
const apiKey = typeof record.apiKey === 'string' ? record.apiKey.trim() : '';
|
||||
return apiKey && apiKey.length <= 512 ? apiKey : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user