// @vitest-environment node import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host'; import { PiSubagentScheduler } from '../../electron/coding-runtime/pi/subagent'; import { PiProcessBudget } from '../../electron/coding-runtime/pi/worker-pool'; import type { AgentBrowserModule } from '../../electron/agent-browser'; import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store'; import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools'; import { DATA_SERVICE_PLUGIN_DEFINITION, type CodingPluginToolDefinition, } from '../../shared/coding-plugins'; type ExtensionHandler = (...arguments_: unknown[]) => Promise | unknown; type ExtensionTool = { name: string; parameters?: Record; execute?: (...arguments_: unknown[]) => Promise; }; async function post( registration: Awaited>, body: Record, ): Promise { return await fetch(registration.env.MAKELORE_PI_BRIDGE_URL as string, { method: 'POST', headers: { authorization: `Bearer ${registration.env.MAKELORE_PI_WORKER_TOKEN}`, 'content-type': 'application/json', }, body: JSON.stringify(body), }); } const roots: string[] = []; const hosts: PiManagedExtensionHost[] = []; afterEach(async () => { await Promise.all(hosts.splice(0).map((host) => host.close())); await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); describe('Makelore Pi extension bundle', () => { it('materializes only the frozen plugin declarations and lease metadata', async () => { const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-dynamic-bundle-')); roots.push(root); const host = new PiManagedExtensionHost(); hosts.push(host); const pluginTool: CodingPluginToolDefinition = { name: 'data_service_inspect', label: 'Data Service inspect', description: 'Inspect the active project Data Service instance.', capabilityId: 'data-service.control', operation: 'inspect', roles: ['parent'], mutation: 'read', projectWriteLease: true, permissions: ['project.data.read'], inputSchema: { type: 'object', additionalProperties: false, properties: {} }, }; const registration = await host.registerWorker({ conversationId: 'dynamic-conversation', generation: 1, projectId: 'dynamic-project', projectPath: root, extensionsDir: root, skillEntries: [{ id: 'data-service', entryPath: 'skills/data-service/SKILL.md' }], catalogRevision: 19, tools: [pluginTool], }); await host.bindRun('dynamic-conversation', 1, 'dynamic-run'); const previous = { bridge: process.env.MAKELORE_PI_BRIDGE_URL, token: process.env.MAKELORE_PI_WORKER_TOKEN, context: process.env.MAKELORE_PI_CONTEXT_FILE, role: process.env.MAKELORE_PI_WORKER_ROLE, }; Object.assign(process.env, registration.env); try { const module = await import( /* @vite-ignore */ `${pathToFileURL(registration.extensionPath).href}?dynamic=${Date.now()}` ) as { default(factory: { registerTool(tool: ExtensionTool): void; on(event: string, handler: ExtensionHandler): void; }): void | Promise; }; const tools = new Map(); await module.default({ registerTool: (tool) => tools.set(tool.name, tool), on: () => undefined, }); await new Promise((resolve) => setImmediate(resolve)); expect(tools.has('data_service_inspect')).toBe(true); expect(tools.has('data_service_put_document')).toBe(false); expect(tools.get('data_service_inspect')?.parameters).toEqual(pluginTool.inputSchema); const context = JSON.parse(await readFile(registration.env.MAKELORE_PI_CONTEXT_FILE as string, 'utf8')); expect(context).toMatchObject({ catalogRevision: 19, allowedToolNames: ['data_service_inspect'], projectWriteLeaseToolNames: ['data_service_inspect'], }); const denied = await post(registration, { action: 'product.invoke', conversationId: 'dynamic-conversation', workerGeneration: 1, runId: 'dynamic-run', resourceId: 'denied-tool', toolName: 'data_service_put_document', input: {}, }); expect(denied.status).toBe(403); } finally { for (const [key, value] of Object.entries(previous)) { const environmentKey = key === 'bridge' ? 'MAKELORE_PI_BRIDGE_URL' : key === 'token' ? 'MAKELORE_PI_WORKER_TOKEN' : key === 'context' ? 'MAKELORE_PI_CONTEXT_FILE' : 'MAKELORE_PI_WORKER_ROLE'; if (value === undefined) delete process.env[environmentKey]; else process.env[environmentKey] = value; } } }); it('executes versioned product tools through the authenticated real bundle', async () => { const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-product-bundle-')); roots.push(root); await writeFile(path.join(root, 'notes.txt'), 'changed\n', 'utf8'); await writeFile(path.join(root, 'ASSET_PLAN.md'), [ '```json', JSON.stringify({ assets: [{ id: 'hero', name: 'Hero', category: 'visual', status: 'candidate' }] }), '```', ].join('\n'), 'utf8'); const browser = { async getSnapshot() { return { browserId: 'browser-a', projectId: 'project-a', projectPath: root, state: 'attached', generation: 1, url: 'http://127.0.0.1:5173/', title: 'App', visible: false, bounds: null, canGoBack: false, canGoForward: false, eventCursor: 0, }; }, async sendCdp() { return { kind: 'inline', value: { data: Buffer.from('packaged-png').toString('base64') } }; }, } as unknown as AgentBrowserModule; const attachments = new CodingAttachmentStore(path.join(root, 'attachments'), { createId: () => 'packaged-attachment-a', }); const host = new PiManagedExtensionHost(); const productTools = new PiProductTools({ browser, attachments, bundledSkillsDir: path.resolve('resources/coding-skills'), }); host.configureProductTools(productTools); hosts.push(host); const worker = await host.registerWorker({ conversationId: 'conversation-tools', generation: 1, projectId: 'project-a', projectPath: root, skillEntries: [{ id: 'agent-browser', entryPath: 'agent-browser/SKILL.md' }], catalogRevision: 3, extensionsDir: root, }); await host.bindRun('conversation-tools', 1, 'run-tools'); const previous = { bridge: process.env.MAKELORE_PI_BRIDGE_URL, token: process.env.MAKELORE_PI_WORKER_TOKEN, context: process.env.MAKELORE_PI_CONTEXT_FILE, role: process.env.MAKELORE_PI_WORKER_ROLE, }; Object.assign(process.env, worker.env); try { const module = await import( /* @vite-ignore */ `${pathToFileURL(worker.extensionPath).href}?tools=${Date.now()}` ) as { default(factory: { registerTool(tool: ExtensionTool): void; on(event: string, handler: ExtensionHandler): void; }): void; }; const tools = new Map(); const handlers = new Map(); await module.default({ registerTool: (tool) => tools.set(tool.name, tool), on: (event, handler) => handlers.set(event, handler), }); await expect(tools.get('task_state')?.execute?.( 'task-state-a', { tasks: [{ id: 'one', title: 'Inspect', status: 'complete' }] }, new AbortController().signal, )).resolves.toMatchObject({ details: { schema: 'task-state.v1' } }); await expect(tools.get('changed_file')?.execute?.( 'changed-a', { paths: ['notes.txt'] }, new AbortController().signal, )).resolves.toMatchObject({ content: [{ type: 'text', text: '1 changed path(s) recorded' }], details: { schema: 'changed-file.v1', paths: ['notes.txt'] }, }); await expect(tools.get('runtime_context')?.execute?.( 'context-a', {}, new AbortController().signal, )).resolves.toMatchObject({ details: { schema: 'runtime-context.v1', skills: expect.arrayContaining([expect.objectContaining({ id: 'agent-browser', selected: true })]), }, }); const browserResult = await tools.get('agent_browser')?.execute?.( 'browser-a', { action: 'status' }, new AbortController().signal, ); expect(browserResult).toMatchObject({ details: { schema: 'agent-browser.v1', action: 'status' } }); expect(JSON.stringify(browserResult)).not.toContain(root); const screenshotResult = await tools.get('agent_browser')?.execute?.( 'browser-screenshot-a', { action: 'send_cdp', method: 'Page.captureScreenshot', params: { format: 'png' } }, new AbortController().signal, ); expect(screenshotResult).toMatchObject({ details: { schema: 'agent-browser.v1', action: 'send_cdp', attachmentId: 'packaged-attachment-a', mime: 'image/png', }, }); expect(JSON.stringify(screenshotResult)).not.toContain( 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') }, }, { signal: new AbortController().signal, ui: { setStatus: () => undefined }, }); await handlers.get('tool_result')?.({ toolName: 'write', toolCallId: 'write-a' }); expect(productTools.getChanges('conversation-tools')?.files).toEqual([ expect.objectContaining({ path: 'notes.txt' }), ]); } finally { for (const [key, value] of Object.entries(previous)) { const environmentKey = key === 'bridge' ? 'MAKELORE_PI_BRIDGE_URL' : key === 'token' ? 'MAKELORE_PI_WORKER_TOKEN' : key === 'context' ? 'MAKELORE_PI_CONTEXT_FILE' : 'MAKELORE_PI_WORKER_ROLE'; if (value === undefined) delete process.env[environmentKey]; else process.env[environmentKey] = value; } } }); it('loads the real bundle and releases its project lease on tool_result', async () => { const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-extension-bundle-')); roots.push(root); const host = new PiManagedExtensionHost(); const scheduler = new PiSubagentScheduler({ processBudget: new PiProcessBudget(8), openChild: async (input) => ({ id: input.taskId, async run() { return { summary: `done ${input.agentId}` }; }, async stop() {}, }), }); host.configureSubagents({ scheduler }); hosts.push(host); const extensionWorker = await host.registerWorker({ conversationId: 'conversation-a1', generation: 1, projectId: 'project-a', extensionsDir: root, tools: [...DATA_SERVICE_PLUGIN_DEFINITION.tools], catalogRevision: 4, }); const waitingWorker = await host.registerWorker({ conversationId: 'conversation-a2', generation: 1, projectId: 'project-a', extensionsDir: root, }); await Promise.all([ host.bindRun('conversation-a1', 1, 'run-a1'), host.bindRun('conversation-a2', 1, 'run-a2'), ]); const previousEnvironment = { bridge: process.env.MAKELORE_PI_BRIDGE_URL, token: process.env.MAKELORE_PI_WORKER_TOKEN, context: process.env.MAKELORE_PI_CONTEXT_FILE, role: process.env.MAKELORE_PI_WORKER_ROLE, }; Object.assign(process.env, extensionWorker.env); try { const module = await import(/* @vite-ignore */ pathToFileURL(extensionWorker.extensionPath).href) as { default(factory: { registerTool(tool: ExtensionTool): void; on(event: string, handler: ExtensionHandler): void; }): void; }; const handlers = new Map(); const tools = new Map(); await module.default({ registerTool: (tool) => tools.set(tool.name, tool), 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', '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', 'data_service_remove_project', ]); const dataServiceTools = [ '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', 'data_service_remove_project', ]; for (const name of dataServiceTools) { const tool = tools.get(name); expect(tool?.parameters).toEqual( DATA_SERVICE_PLUGIN_DEFINITION.tools.find(({ name: candidate }) => candidate === name)?.inputSchema, ); } const updates: unknown[] = []; const subagentResult = await tools.get('subagent')?.execute?.( 'subagent-1', { mode: 'single', tasks: [{ agentId: 'agent-a', task: 'Inspect', toolProfile: 'read-only' }], }, new AbortController().signal, (update: unknown) => updates.push(update), ); expect(subagentResult).toMatchObject({ details: { schema: 'subagent.v1', mode: 'single', tasks: [{ agentId: 'agent-a', status: 'complete', summary: 'done agent-a' }], }, }); expect(updates.length).toBeGreaterThan(0); expect(updates.every((update) => ( (update as { details?: { schema?: string } }).details?.schema === 'subagent.v1' ))).toBe(true); const statuses: Array = []; const context = { signal: new AbortController().signal, ui: { setStatus: (_key: string, text: string | undefined) => statuses.push(text) }, }; await handlers.get('tool_call')?.({ toolName: 'read', toolCallId: 'read-1' }, context); await handlers.get('tool_call')?.({ toolName: 'write', toolCallId: 'write-1' }, context); expect(statuses).toEqual(['等待项目写入', undefined]); let waiterSettled = false; const waiting = fetch(waitingWorker.env.MAKELORE_PI_BRIDGE_URL as string, { method: 'POST', headers: { authorization: `Bearer ${waitingWorker.env.MAKELORE_PI_WORKER_TOKEN}`, 'content-type': 'application/json', }, body: JSON.stringify({ action: 'lease.acquire', conversationId: 'conversation-a2', workerGeneration: 1, runId: 'run-a2', resourceId: 'write-2', }), }).then((response) => { waiterSettled = true; return response; }); await Promise.resolve(); expect(waiterSettled).toBe(false); await handlers.get('tool_result')?.({ toolCallId: 'write-1' }); expect((await waiting).status).toBe(200); } finally { await scheduler.close(); if (previousEnvironment.bridge === undefined) delete process.env.MAKELORE_PI_BRIDGE_URL; else process.env.MAKELORE_PI_BRIDGE_URL = previousEnvironment.bridge; if (previousEnvironment.token === undefined) delete process.env.MAKELORE_PI_WORKER_TOKEN; else process.env.MAKELORE_PI_WORKER_TOKEN = previousEnvironment.token; if (previousEnvironment.context === undefined) delete process.env.MAKELORE_PI_CONTEXT_FILE; else process.env.MAKELORE_PI_CONTEXT_FILE = previousEnvironment.context; if (previousEnvironment.role === undefined) delete process.env.MAKELORE_PI_WORKER_ROLE; else process.env.MAKELORE_PI_WORKER_ROLE = previousEnvironment.role; } }); it('does not expose parent-only tools from a child process', async () => { const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-extension-child-bundle-')); roots.push(root); await writeFile(path.join(root, 'child.txt'), 'before\n', 'utf8'); const host = new PiManagedExtensionHost(); const productTools = new PiProductTools({ browser: {} as AgentBrowserModule, attachments: new CodingAttachmentStore(path.join(root, 'attachments')), bundledSkillsDir: path.resolve('resources/coding-skills'), }); host.configureProductTools(productTools); hosts.push(host); await host.registerWorker({ conversationId: 'conversation-child', generation: 1, projectId: 'project-a', projectPath: root, extensionsDir: root, }); await host.bindRun('conversation-child', 1, 'run-parent'); const child = await host.registerWorker({ conversationId: 'conversation-child', generation: 1, projectId: 'project-a', projectPath: root, extensionsDir: root, role: 'child', runId: 'run-parent', }); const previous = { bridge: process.env.MAKELORE_PI_BRIDGE_URL, token: process.env.MAKELORE_PI_WORKER_TOKEN, context: process.env.MAKELORE_PI_CONTEXT_FILE, role: process.env.MAKELORE_PI_WORKER_ROLE, }; Object.assign(process.env, child.env); try { const module = await import( /* @vite-ignore */ `${pathToFileURL(child.extensionPath).href}?child=${Date.now()}` ) as { default(factory: { registerTool(tool: ExtensionTool): void; on(event: string, handler: ExtensionHandler): void; }): void; }; const tools: string[] = []; const handlers = new Map(); await module.default({ registerTool: (tool) => tools.push(tool.name), on: (event, handler) => handlers.set(event, handler), }); expect(tools).toEqual([]); const forgedParentTool = await post(child, { action: 'product.invoke', conversationId: 'conversation-child', workerGeneration: 1, runId: 'run-parent', resourceId: 'forged-data-tool', toolName: 'data_service_inspect', input: {}, }); expect(forgedParentTool.status).toBe(403); await writeFile(path.join(root, 'child.txt'), 'after\n', 'utf8'); await handlers.get('tool_call')?.({ toolName: 'write', toolCallId: 'child-write', input: { path: 'child.txt' }, }, { signal: new AbortController().signal, ui: { setStatus: () => undefined }, }); await handlers.get('tool_result')?.({ toolName: 'write', toolCallId: 'child-write' }); expect(productTools.getChanges('conversation-child')?.files).toEqual([ expect.objectContaining({ path: 'child.txt', preview: 'after\n' }), ]); } finally { for (const [key, value] of Object.entries(previous)) { const environmentKey = key === 'bridge' ? 'MAKELORE_PI_BRIDGE_URL' : key === 'token' ? 'MAKELORE_PI_WORKER_TOKEN' : key === 'context' ? 'MAKELORE_PI_CONTEXT_FILE' : 'MAKELORE_PI_WORKER_ROLE'; if (value === undefined) delete process.env[environmentKey]; else process.env[environmentKey] = value; } } }); });