Files
makelore/tests/unit/pi-extension-bundle.test.ts

758 lines
32 KiB
TypeScript

// @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, vi } 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 {
DevicePackageError,
type DevicePackageManager,
} from '../../electron/coding-packages/device-package-manager';
import {
DEVICE_PACKAGE_TOOL_DEFINITIONS,
DevicePackageTools,
} from '../../electron/coding-packages/device-package-tools';
import { CodingCapabilityRegistryImpl } from '../../electron/coding-plugins/registry';
import type { PluginPolicyClientState } from '../../electron/services/plugin-policy-client';
import {
DATA_SERVICE_PLUGIN_DEFINITION,
type CodingPluginToolDefinition,
} from '../../shared/coding-plugins';
type ExtensionHandler = (...arguments_: unknown[]) => Promise<unknown> | unknown;
type ExtensionTool = {
name: string;
parameters?: Record<string, unknown>;
executionMode?: 'sequential';
execute?: (...arguments_: unknown[]) => Promise<unknown>;
};
async function post(
registration: Awaited<ReturnType<PiManagedExtensionHost['registerWorker']>>,
body: Record<string, unknown>,
): Promise<Response> {
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('preserves a closed Device Package failure through the real worker bridge', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-device-package-error-'));
roots.push(root);
const manager = {
async prepare() {
throw new DevicePackageError(
'local_package_dependency_failed',
'Bundled Pi package manager is unavailable',
);
},
} as unknown as DevicePackageManager;
const host = new PiManagedExtensionHost();
host.configureProductTools(new PiProductTools({
browser: {} as AgentBrowserModule,
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
bundledSkillsDir: path.resolve('resources/coding-skills'),
devicePackageTools: new DevicePackageTools(manager),
}));
hosts.push(host);
const prepareTool = DEVICE_PACKAGE_TOOL_DEFINITIONS.find(
({ name }) => name === 'local_package_prepare',
);
if (!prepareTool) throw new Error('Device Package prepare definition missing');
const worker = await host.registerWorker({
conversationId: 'device-package-conversation',
generation: 1,
projectId: 'project-a',
projectPath: root,
extensionsDir: root,
tools: [prepareTool],
});
await host.bindRun('device-package-conversation', 1, 'device-package-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, worker.env);
try {
const module = await import(
/* @vite-ignore */ `${pathToFileURL(worker.extensionPath).href}?device-package=${Date.now()}`
) as {
default(factory: {
registerTool(tool: ExtensionTool): void;
on(event: string, handler: ExtensionHandler): void;
}): void | Promise<void>;
};
const tools = new Map<string, ExtensionTool>();
await module.default({
registerTool: (tool) => tools.set(tool.name, tool),
on: () => undefined,
});
await expect(tools.get('local_package_prepare')?.execute?.(
'prepare-a',
{ source: 'npm:pi-web-search' },
new AbortController().signal,
)).rejects.toThrow(
'local_package_dependency_failed: Bundled Pi package manager is unavailable',
);
} 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('hydrates persisted Pi identity through reconnect and event replay into the capability registry', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-persisted-identity-'));
roots.push(root);
const policy: PluginPolicyClientState = {
status: 'current', revision: 23, lastVerifiedAt: 1,
catalog: {
schema_version: 1, catalog_version: 'catalog-23', pricing_version: null,
plugins: [{
plugin_id: DATA_SERVICE_PLUGIN_DEFINITION.id,
supported_contract_versions: [DATA_SERVICE_PLUGIN_DEFINITION.contractVersion],
status: 'active',
capabilities: [{
capability_id: 'data-service.control',
operations: [{
operation: 'inspect',
billing: { mode: 'included', entitlement_scope: null, notice: 'Included' },
}],
}],
}],
},
};
const invoke = vi.fn(async () => ({
success: true as const, status: 200, code: null, error: null, retryable: false as const,
payload_schema: 'data-service.v1', data: { instance_id: 'instance-a' },
}));
const capabilityRegistry = new CodingCapabilityRegistryImpl({
policyClient: { getState: () => policy, refresh: vi.fn().mockResolvedValue(undefined) },
getEnabledPluginIds: async () => [DATA_SERVICE_PLUGIN_DEFINITION.id],
definitions: [DATA_SERVICE_PLUGIN_DEFINITION],
adapters: [{
pluginId: DATA_SERVICE_PLUGIN_DEFINITION.id,
async inspect() { return { status: 'ready' }; },
invoke,
}],
});
const host = new PiManagedExtensionHost();
host.configureProductTools(new PiProductTools({
browser: {} as AgentBrowserModule,
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
bundledSkillsDir: path.resolve('resources/coding-skills'),
capabilityRegistry,
}));
hosts.push(host);
const inspectTool = DATA_SERVICE_PLUGIN_DEFINITION.tools.find(
({ name }) => name === 'data_service_inspect',
);
if (!inspectTool) throw new Error('inspect definition missing');
const worker = await host.registerWorker({
conversationId: 'persisted-conversation', generation: 1, projectId: 'project-a',
projectPath: root, extensionsDir: root,
skillEntries: [{ id: 'data-service', entryPath: 'skills/data-service/SKILL.md' }],
catalogRevision: policy.revision,
tools: [inspectTool],
});
await host.bindRun('persisted-conversation', 1, 'persisted-run');
const persistedContext = JSON.parse(await readFile(
worker.env.MAKELORE_PI_CONTEXT_FILE as string,
'utf8',
)) as { runId?: string };
expect(persistedContext.runId).toBe('persisted-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, worker.env);
try {
const executeAfterHydration = async (connection: string) => {
const module = await import(
/* @vite-ignore */ `${pathToFileURL(worker.extensionPath).href}?connection=${connection}`
) as {
default(factory: {
registerTool(tool: ExtensionTool): void;
on(event: string, handler: ExtensionHandler): void;
}): void | Promise<void>;
};
const tools = new Map<string, ExtensionTool>();
await module.default({
registerTool: (tool) => tools.set(tool.name, tool),
on: () => undefined,
});
return await tools.get('data_service_inspect')?.execute?.(
'persisted-resource', {}, new AbortController().signal,
);
};
const first = await executeAfterHydration('initial');
const replay = await executeAfterHydration('reconnect');
expect(first).toMatchObject({
details: {
plugin_id: DATA_SERVICE_PLUGIN_DEFINITION.id,
request_id: 'pi:persisted-run:persisted-resource',
},
});
expect(replay).toMatchObject({
details: { request_id: 'pi:persisted-run:persisted-resource' },
});
expect(invoke).toHaveBeenCalledTimes(2);
} 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('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<void>;
};
const tools = new Map<string, ExtensionTool>();
await module.default({
registerTool: (tool) => tools.set(tool.name, tool),
on: () => undefined,
});
await new Promise<void>((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);
expect(tools.get('data_service_inspect')?.executionMode).toBe('sequential');
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('streams job-tool progress through the authenticated product bridge', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-product-stream-'));
roots.push(root);
const host = new PiManagedExtensionHost();
hosts.push(host);
const details = (phase: string) => ({
schema: 'makelore-capability.v1' as const,
plugin_id: 'makelore.game-resource',
plugin_version: '1.0.0',
capability_id: 'game-resource.generate',
operation: 'generate',
request_id: 'pi:stream-run:stream-tool',
success: true,
status: phase === 'saved' ? 200 : 202,
code: null,
error: null,
retryable: false,
billing: {
mode: 'platform_metered' as const,
status: phase === 'saved' ? 'settled' as const : 'dispatched' as const,
reserved_points: '2.00',
...(phase === 'saved' ? { actual_points: '2.00' } : {}),
usage_amount: 1,
unit: 'generation',
},
payload_schema: 'game-resource.v1',
data: { phase, executionId: 'execution-a' },
});
const invoke = vi.fn(async (input: {
onUpdate?: (result: { content: []; details: ReturnType<typeof details> }) => void;
}) => {
input.onUpdate?.({ content: [], details: details('generating') });
input.onUpdate?.({ content: [], details: details('saving') });
return { content: [], details: details('saved') };
});
host.configureProductTools(new PiProductTools({
browser: {} as AgentBrowserModule,
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
bundledSkillsDir: path.resolve('resources/coding-skills'),
capabilityRegistry: { invoke } as unknown as CodingCapabilityRegistryImpl,
}));
const jobTool: CodingPluginToolDefinition = {
name: 'game_resource_generate',
label: 'Generate game resource',
description: 'Generate and save a game resource.',
capabilityId: 'game-resource.generate',
operation: 'generate',
roles: ['parent'],
mutation: 'write',
projectWriteLease: false,
permissions: ['hosted.game-resource.generate'],
executionMode: 'job',
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
};
const registration = await host.registerWorker({
conversationId: 'stream-conversation', generation: 1, projectId: 'stream-project',
projectPath: root, extensionsDir: root, tools: [jobTool],
});
await host.bindRun('stream-conversation', 1, 'stream-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}?stream=${Date.now()}`
) as {
default(factory: {
registerTool(tool: ExtensionTool): void;
on(event: string, handler: ExtensionHandler): void;
}): void | Promise<void>;
};
const tools = new Map<string, ExtensionTool>();
await module.default({
registerTool: (tool) => tools.set(tool.name, tool),
on: () => undefined,
});
const updates: unknown[] = [];
const result = await tools.get('game_resource_generate')?.execute?.(
'stream-tool',
{},
new AbortController().signal,
(update: unknown) => updates.push(update),
);
expect(updates).toEqual([
{ content: [], details: details('generating') },
{ content: [], details: details('saving') },
]);
expect(result).toEqual({ content: [], details: details('saved') });
expect(invoke).toHaveBeenCalledWith(expect.objectContaining({
toolName: 'game_resource_generate',
onUpdate: expect.any(Function),
}));
} 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: [],
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<string, ExtensionTool>();
const handlers = new Map<string, ExtensionHandler>();
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: [],
},
});
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');
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<string, ExtensionHandler>();
const tools = new Map<string, ExtensionTool>();
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', '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<string | undefined> = [];
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<string, ExtensionHandler>();
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;
}
}
});
});