feat(pi): materialize effective plugin worker tools
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
# Task: Implement ML-03 Pi worker resource materialization
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260827-plugin-ml03-worker-materialization-9b2e6c41
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260827-plugin-ml03-worker-materialization-9b2e6c41-plugin-ml03-worker-materialization
|
||||
- Worktree: D:\Datas\OthersProjects\makelore-plugin-ml03-worker-materialization-9b2e6c41
|
||||
- Base commit: c4dd8923a0076920e8a7fd8820fdc01bdfde1760
|
||||
- Owner: plugin_ml03_worker_materialization
|
||||
- Status: Ready for integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Implement ML-03 dynamic Pi worker resource materialization from exact coordinator
|
||||
frontier `c4dd8923a0076920e8a7fd8820fdc01bdfde1760`.
|
||||
- Own only the Pi resource loader, extension host, runtime, worker process,
|
||||
subagent child, `makelore-runtime` extension, the five named Pi-focused tests,
|
||||
and this task record.
|
||||
- Resolve one worker-resource snapshot before Skill materialization and extension
|
||||
registration; carry effective Skills, frozen catalog revision, tool declarations,
|
||||
allowed names, bridge allowlist, write-lease membership, and explicit CLI tools.
|
||||
- Preserve core tools/Skills, keep child plugin catalog empty, remove the obsolete
|
||||
static Data Service CLI allowlist, and prove disabled/re-enabled/old-worker and
|
||||
child-denial behavior.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Follow implementation spec sections 7.2-7.3 and 10.2, ticket ML-03, and detailed
|
||||
design sections 8.5-8.7. The server catalog and ML-02 registry/envelope are the
|
||||
authoritative upstream contracts; do not duplicate or alter them.
|
||||
- Parent plugin exposure is the intersection of valid package, project selection,
|
||||
selected Skill grant, permitted role, and verified policy. Known-disabled Skill
|
||||
assignments remain in config/UI but do not enter the effective snapshot; truly
|
||||
unknown assignments retain existing invalid-configuration behavior.
|
||||
- A worker run freezes its catalog. Main-side invocation remains authoritative and
|
||||
rechecks current selection; declarations are never authority. Child workers get
|
||||
no plugin Skills/tools and no permission broadening.
|
||||
- No Host, Renderer, preview, adapter, registry, P1 billing, generic endpoint,
|
||||
arbitrary code/MCP/hooks, or static Data Service list changes. If an upstream
|
||||
interface is semantically insufficient, stop and report to the coordinator.
|
||||
- Test-first. Use the exact pinned pnpm version. Keep the worktree isolated and
|
||||
clean; return one implementation commit with sole parent the exact base.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
- Task ID/mode/branch/worktree/base match the Git-common owner record exactly.
|
||||
- Concurrent Task Gate passed: `check_project_docs.py` succeeded and
|
||||
`task_context.py start`/`status --json` show this task owns the isolated worktree.
|
||||
- Planning Gate passed on 2026-08-27 after reading the required entry documents,
|
||||
current integrated snapshot, accepted Pi runtime decision, relevant architecture,
|
||||
domain/evidence/reflection/commitment/stale indexes, and peer task records.
|
||||
- Other active local tasks include the client coordinator and historical/unrelated
|
||||
planning tasks; the coordinator owns only its integration record/branch and no
|
||||
ML-03 implementation files. No unresolved semantic conflict or ownership overlap
|
||||
affects this ticket. The integrated memory predates the plugin work; the frozen
|
||||
implementation spec, ticket graph, and exact ML-02 frontier control this task.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Inspect the exact ML-02 frontier's Pi seams and existing tests without changing
|
||||
files; map current resource/extension/worker/child contracts to the frozen ML-03
|
||||
requirements.
|
||||
2. Add failing focused tests for one-snapshot effective resource resolution,
|
||||
declaration/bridge/CLI agreement, disabled and re-enabled assignments, old
|
||||
worker refusal, child denial, and unchanged core behavior.
|
||||
3. Implement the smallest cohesive changes within the owned files, preserving the
|
||||
existing Pi 0.84.2 product contracts and project write-lease semantics.
|
||||
4. Run owned focused tests plus relevant Pi regressions, typecheck, scoped/full
|
||||
lint as appropriate, and diff/doc gates; investigate and fix only failures in
|
||||
owned scope.
|
||||
5. Update this record with exact outcome/evidence/follow-ups, run task-aware doc
|
||||
drift, and complete task_context to `ready_for_integration` on a clean one-commit
|
||||
worktree.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Implemented the ML-03 effective worker snapshot flow. Parent and child openers
|
||||
resolve the capability registry once before materialization/registration and
|
||||
pass the resulting effective Skill entries, catalog revision, and plugin tool
|
||||
definitions through resource-loader and extension-host. Plugin Skill paths now
|
||||
resolve from the fixed bundled package roots; disabled assignments therefore do
|
||||
not reach the worker while the assigned configuration remains untouched.
|
||||
- Pi extension contexts carry the frozen revision, effective Skill IDs, exact
|
||||
declarations, dynamic bridge names, and `projectWriteLease` names. The bundle
|
||||
registers only the declarations in that context and applies leases from the
|
||||
declaration metadata. Child registrations force empty plugin tool exposure.
|
||||
- Pi CLI defaults contain only the fixed core profile. Parent runtime workers
|
||||
explicitly pass the fixed core profile plus the registration's plugin names;
|
||||
the static Data Service CLI list was removed. Bridge requests accept bounded
|
||||
string tool names but reject names outside the worker's frozen plugin set,
|
||||
while retaining the fixed core product bridge tools.
|
||||
- Owned tests cover plugin Skill resource roots, catalog metadata, dynamic
|
||||
declaration/schema materialization, bridge denial for an unregistered tool,
|
||||
dynamic lease metadata, core-only defaults, real Pi declaration/CLI agreement,
|
||||
and enabled-to-disabled-to-re-enabled worker materialization without rewriting
|
||||
the retained Agent assignment.
|
||||
|
||||
## Verification
|
||||
|
||||
- Owned focused (maxWorkers=1):
|
||||
`tests/unit/pi-resource-loader.test.ts`, `pi-extension-host.test.ts`,
|
||||
`pi-extension-bundle.test.ts`, `pi-worker-process-real.test.ts`, and
|
||||
`pi-rpc-foundation.test.ts`: 43 passed, 2 skipped (staged-runtime gated).
|
||||
- All `tests/unit/pi-*.test.ts` regressions with one worker: 30 files passed,
|
||||
169 passed, 2 skipped.
|
||||
- `pnpm typecheck`: passed.
|
||||
- `pnpm lint:check`: passed with five pre-existing warnings in
|
||||
`src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`; no errors.
|
||||
- `git diff --check`: passed.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Coordinator ML-04 must wire the already-optional capability registry into
|
||||
the production composition and keep its own Host/Renderer ownership; this
|
||||
task intentionally did not edit composition or product-tools files.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None.
|
||||
@@ -13,13 +13,20 @@ import {
|
||||
parsePiSubagentDispatchRequest,
|
||||
type PiSubagentScheduler,
|
||||
} from './subagent';
|
||||
import {
|
||||
isPiProductToolName,
|
||||
type PiProductToolName,
|
||||
type PiProductTools,
|
||||
} from './product-tools';
|
||||
import type { PiProductTools } from './product-tools';
|
||||
import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins';
|
||||
import type { PiSkillEntry } from './resource-loader';
|
||||
|
||||
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',
|
||||
]);
|
||||
|
||||
interface WorkerRegistrationRecord {
|
||||
token: string;
|
||||
@@ -28,6 +35,10 @@ interface WorkerRegistrationRecord {
|
||||
projectId: string;
|
||||
projectPath: string | null;
|
||||
skillIds: string[];
|
||||
catalogRevision?: number;
|
||||
allowedToolNames: string[];
|
||||
tools: CodingPluginToolDefinition[];
|
||||
projectWriteLeaseToolNames: string[];
|
||||
role: 'parent' | 'child';
|
||||
contextFile: string;
|
||||
runId: string | null;
|
||||
@@ -39,6 +50,7 @@ export interface PiExtensionWorkerRegistration {
|
||||
extensionPath: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
sensitiveValues: string[];
|
||||
allowedToolNames: readonly string[];
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -47,7 +59,9 @@ export interface RegisterPiExtensionWorkerInput {
|
||||
generation: number;
|
||||
projectId: string;
|
||||
projectPath?: string;
|
||||
skillIds?: readonly string[];
|
||||
skillEntries?: readonly PiSkillEntry[];
|
||||
catalogRevision?: number;
|
||||
tools?: readonly CodingPluginToolDefinition[];
|
||||
extensionsDir: string;
|
||||
role?: 'parent' | 'child';
|
||||
runId?: string;
|
||||
@@ -77,7 +91,7 @@ interface ProductToolBridgeRequest {
|
||||
workerGeneration: number;
|
||||
runId: string;
|
||||
resourceId: string;
|
||||
toolName: PiProductToolName;
|
||||
toolName: string;
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
@@ -122,7 +136,9 @@ function bridgeRequest(value: unknown): value is BridgeRequest {
|
||||
if (!common) return false;
|
||||
if (value.action === 'subagent.dispatch') return 'request' in value;
|
||||
if (value.action === 'product.invoke') {
|
||||
return isPiProductToolName(value.toolName) && 'input' in value;
|
||||
return typeof value.toolName === 'string'
|
||||
&& PRODUCT_TOOL_NAME_PATTERN.test(value.toolName)
|
||||
&& 'input' in value;
|
||||
}
|
||||
if (value.action === 'changes.bash') return true;
|
||||
if (value.action === 'changes.touched') {
|
||||
@@ -201,6 +217,23 @@ export class PiManagedExtensionHost {
|
||||
if (this.productTools && !input.projectPath?.trim()) {
|
||||
throw new Error('Product tools require a worker project path');
|
||||
}
|
||||
const skillEntries = [...new Map(
|
||||
(input.skillEntries ?? [])
|
||||
.filter((entry) => entry.id.trim() && entry.entryPath.trim())
|
||||
.map((entry) => [entry.id.trim(), {
|
||||
id: entry.id.trim(),
|
||||
entryPath: entry.entryPath.trim().replaceAll('\\', '/'),
|
||||
}]),
|
||||
).values()];
|
||||
const tools = role === 'child'
|
||||
? []
|
||||
: [...new Map(
|
||||
(input.tools ?? []).map((tool) => [tool.name, structuredClone(tool)]),
|
||||
).values()];
|
||||
const allowedToolNames = tools.map(({ name }) => name);
|
||||
const projectWriteLeaseToolNames = tools
|
||||
.filter(({ projectWriteLease }) => projectWriteLease)
|
||||
.map(({ name }) => name);
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const contextFile = path.join(input.extensionsDir, `worker-${randomUUID()}.json`);
|
||||
const record: WorkerRegistrationRecord = {
|
||||
@@ -209,7 +242,11 @@ export class PiManagedExtensionHost {
|
||||
generation: input.generation,
|
||||
projectId: input.projectId,
|
||||
projectPath: input.projectPath?.trim() || null,
|
||||
skillIds: [...new Set((input.skillIds ?? []).map((id) => id.trim()).filter(Boolean))],
|
||||
skillIds: skillEntries.map(({ id }) => id),
|
||||
...(input.catalogRevision === undefined ? {} : { catalogRevision: input.catalogRevision }),
|
||||
allowedToolNames,
|
||||
tools,
|
||||
projectWriteLeaseToolNames,
|
||||
role,
|
||||
contextFile,
|
||||
runId: role === 'child'
|
||||
@@ -230,6 +267,7 @@ export class PiManagedExtensionHost {
|
||||
MAKELORE_PI_WORKER_ROLE: role,
|
||||
},
|
||||
sensitiveValues: [token],
|
||||
allowedToolNames: [...allowedToolNames],
|
||||
dispose: async () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
@@ -370,6 +408,11 @@ export class PiManagedExtensionHost {
|
||||
this.respond(response, 403, { error: 'Child workers cannot invoke parent product tools' });
|
||||
return;
|
||||
}
|
||||
if (!CORE_PRODUCT_TOOL_NAMES.has(value.toolName)
|
||||
&& !record.allowedToolNames.includes(value.toolName)) {
|
||||
this.respond(response, 403, { error: 'Product tool is not enabled for this worker' });
|
||||
return;
|
||||
}
|
||||
if (!this.productTools || !record.projectPath) {
|
||||
this.respond(response, 503, { error: 'Product tools are unavailable' });
|
||||
return;
|
||||
@@ -544,6 +587,11 @@ export class PiManagedExtensionHost {
|
||||
conversationId: record.conversationId,
|
||||
workerGeneration: record.generation,
|
||||
role: record.role,
|
||||
skillIds: record.skillIds,
|
||||
...(record.catalogRevision === undefined ? {} : { catalogRevision: record.catalogRevision }),
|
||||
allowedToolNames: record.allowedToolNames,
|
||||
tools: record.tools,
|
||||
projectWriteLeaseToolNames: record.projectWriteLeaseToolNames,
|
||||
...(record.runId ? { runId: record.runId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,19 +14,22 @@ const MUTATION_TOOLS = new Set([
|
||||
'write',
|
||||
'game_asset_browser',
|
||||
'game_asset_review',
|
||||
'data_service_configure',
|
||||
'data_service_put_document',
|
||||
'data_service_delete_document',
|
||||
'data_service_remove_collection',
|
||||
'data_service_reset',
|
||||
'data_service_remove_project',
|
||||
]);
|
||||
const WORKER_ROLE = process.env.MAKELORE_PI_WORKER_ROLE || 'parent';
|
||||
const leases = new Map();
|
||||
const touchedPaths = new Map();
|
||||
let dynamicLeaseTools = new Set();
|
||||
|
||||
async function readWorkerContext() {
|
||||
const value = JSON.parse(await readFile(process.env.MAKELORE_PI_CONTEXT_FILE, 'utf8'));
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Makelore worker context is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function runtimeContext() {
|
||||
const value = JSON.parse(await readFile(process.env.MAKELORE_PI_CONTEXT_FILE, 'utf8'));
|
||||
const value = await readWorkerContext();
|
||||
if (!value.runId) throw new Error('Makelore run context is unavailable');
|
||||
return value;
|
||||
}
|
||||
@@ -131,7 +134,47 @@ function registerProductTool(pi, name, label, description, parameters) {
|
||||
});
|
||||
}
|
||||
|
||||
export default function makeloreRuntime(pi) {
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isToolDeclaration(value) {
|
||||
return isRecord(value)
|
||||
&& typeof value.name === 'string'
|
||||
&& typeof value.label === 'string'
|
||||
&& typeof value.description === 'string'
|
||||
&& isRecord(value.inputSchema);
|
||||
}
|
||||
|
||||
async function registerDynamicProductTools(pi) {
|
||||
if (WORKER_ROLE !== 'parent') return;
|
||||
const context = await readWorkerContext();
|
||||
const allowedToolNames = new Set(
|
||||
Array.isArray(context.allowedToolNames)
|
||||
? context.allowedToolNames.filter((name) => typeof name === 'string')
|
||||
: [],
|
||||
);
|
||||
const declarations = Array.isArray(context.tools) ? context.tools : [];
|
||||
dynamicLeaseTools = new Set(
|
||||
declarations
|
||||
.filter((declaration) => isToolDeclaration(declaration)
|
||||
&& declaration.projectWriteLease === true
|
||||
&& allowedToolNames.has(declaration.name))
|
||||
.map((declaration) => declaration.name),
|
||||
);
|
||||
for (const declaration of declarations) {
|
||||
if (!isToolDeclaration(declaration) || !allowedToolNames.has(declaration.name)) continue;
|
||||
registerProductTool(
|
||||
pi,
|
||||
declaration.name,
|
||||
declaration.label,
|
||||
declaration.description,
|
||||
declaration.inputSchema,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function makeloreRuntime(pi) {
|
||||
if (WORKER_ROLE === 'parent') pi.registerTool({
|
||||
name: 'ask_user',
|
||||
label: 'Ask user',
|
||||
@@ -290,139 +333,11 @@ export default function makeloreRuntime(pi) {
|
||||
'Read the safe selected-skill and command catalog for this managed worker.',
|
||||
{ type: 'object', additionalProperties: false, properties: {} },
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_configure',
|
||||
'Data Service configure',
|
||||
'Configure collections for the active Makelore project Data Service instance.',
|
||||
{
|
||||
type: 'object', additionalProperties: false, required: ['collections'],
|
||||
properties: {
|
||||
collections: {
|
||||
type: 'array', minItems: 0, maxItems: 20,
|
||||
items: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_inspect',
|
||||
'Data Service inspect',
|
||||
'Inspect the active Makelore project Data Service instance.',
|
||||
{ type: 'object', additionalProperties: false, properties: {} },
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_list_projects',
|
||||
'Data Service projects',
|
||||
'List Data Service instances available to the signed-in account.',
|
||||
{ type: 'object', additionalProperties: false, properties: {} },
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_get_document',
|
||||
'Data Service get document',
|
||||
'Read one document from a collection in the active Makelore project.',
|
||||
{
|
||||
type: 'object', additionalProperties: false, required: ['collection', 'document_id'],
|
||||
properties: {
|
||||
collection: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
|
||||
document_id: {
|
||||
type: 'string', minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9._~-]{1,128}$',
|
||||
not: { enum: ['.', '..'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_list_documents',
|
||||
'Data Service list documents',
|
||||
'List documents from a collection in the active Makelore project.',
|
||||
{
|
||||
type: 'object', additionalProperties: false, required: ['collection'],
|
||||
properties: {
|
||||
collection: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100 },
|
||||
cursor: { type: 'string', minLength: 1, maxLength: 1024 },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_put_document',
|
||||
'Data Service put document',
|
||||
'Create or update one document in a collection in the active Makelore project.',
|
||||
{
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['collection', 'document_id', 'data'],
|
||||
properties: {
|
||||
collection: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
|
||||
document_id: {
|
||||
type: 'string', minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9._~-]{1,128}$',
|
||||
not: { enum: ['.', '..'] },
|
||||
},
|
||||
data: { type: 'object' },
|
||||
if_revision: { type: 'integer', minimum: 1, maximum: 9007199254740991 },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_delete_document',
|
||||
'Data Service delete document',
|
||||
'Delete one document from the active Makelore project after explicit confirmation.',
|
||||
{
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['collection', 'document_id', 'confirmed'],
|
||||
properties: {
|
||||
collection: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
|
||||
document_id: {
|
||||
type: 'string', minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9._~-]{1,128}$',
|
||||
not: { enum: ['.', '..'] },
|
||||
},
|
||||
if_revision: { type: 'integer', minimum: 1, maximum: 9007199254740991 },
|
||||
confirmed: { type: 'boolean', const: true },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_remove_collection',
|
||||
'Data Service remove collection',
|
||||
'Remove a collection from the active Makelore project after explicit confirmation.',
|
||||
{
|
||||
type: 'object', additionalProperties: false, required: ['collection', 'confirmed'],
|
||||
properties: {
|
||||
collection: { type: 'string', minLength: 1, maxLength: 48, pattern: '^[a-z][a-z0-9_-]{0,47}$' },
|
||||
confirmed: { type: 'boolean', const: true },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_reset',
|
||||
'Data Service reset',
|
||||
'Reset all collections in the active Makelore project after explicit confirmation.',
|
||||
{
|
||||
type: 'object', additionalProperties: false, required: ['confirmed'],
|
||||
properties: { confirmed: { type: 'boolean', const: true } },
|
||||
},
|
||||
);
|
||||
if (WORKER_ROLE === 'parent') registerProductTool(
|
||||
pi,
|
||||
'data_service_remove_project',
|
||||
'Data Service remove project',
|
||||
'Remove the active Makelore project Data Service instance after explicit confirmation.',
|
||||
{
|
||||
type: 'object', additionalProperties: false, required: ['confirmed'],
|
||||
properties: { confirmed: { type: 'boolean', const: true } },
|
||||
},
|
||||
);
|
||||
|
||||
await registerDynamicProductTools(pi);
|
||||
|
||||
pi.on('tool_call', async (event, ctx) => {
|
||||
if (!MUTATION_TOOLS.has(event.toolName)) return;
|
||||
if (!MUTATION_TOOLS.has(event.toolName) && !dynamicLeaseTools.has(event.toolName)) return;
|
||||
const input = event.input || event.arguments || event.args || {};
|
||||
const touchedPath = (event.toolName === 'write' || event.toolName === 'edit')
|
||||
? projectRelativePath(input.path) : undefined;
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { mkdir, rename, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
BUNDLED_CODING_SKILL_IDS,
|
||||
type BundledCodingSkillId,
|
||||
} from '../../../shared/coding-skills';
|
||||
import { atomicWriteJson, atomicWriteText } from '../../coding-projects/atomic-json';
|
||||
import { validateSessionKey } from '../../coding-projects/conversation-store';
|
||||
import { resolveBundledCodingPluginRootPaths } from '../../coding-plugins/manifest';
|
||||
import type { PiProviderSelection } from './provider-config';
|
||||
import type { PiManagedInputRevision } from './managed-input-revision';
|
||||
|
||||
const MANAGED_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
||||
|
||||
export interface PiSkillEntry {
|
||||
id: string;
|
||||
entryPath: string;
|
||||
}
|
||||
|
||||
export interface BundledCodingSkillsPathInput {
|
||||
isPackaged: boolean;
|
||||
resourcesPath: string;
|
||||
@@ -33,7 +35,8 @@ export interface MaterializePiAgentResourcesOptions {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
prompt: string;
|
||||
skillIds: readonly string[];
|
||||
skillEntries: readonly PiSkillEntry[];
|
||||
catalogRevision: number;
|
||||
bundledSkillsDir: string;
|
||||
revision: PiManagedInputRevision;
|
||||
}
|
||||
@@ -43,7 +46,9 @@ export interface PiAgentResourceManifest {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
promptFile: string;
|
||||
skillIds: BundledCodingSkillId[];
|
||||
skillIds: string[];
|
||||
skillEntries: PiSkillEntry[];
|
||||
catalogRevision: number;
|
||||
revision: PiManagedInputRevision;
|
||||
}
|
||||
|
||||
@@ -52,13 +57,17 @@ export interface PiAgentResourceSnapshot {
|
||||
projectSessionsDir: string;
|
||||
promptPath: string;
|
||||
manifestPath: string;
|
||||
skillIds: BundledCodingSkillId[];
|
||||
skillIds: string[];
|
||||
skillEntries: PiSkillEntry[];
|
||||
skillPaths: string[];
|
||||
catalogRevision: number;
|
||||
revision: PiManagedInputRevision;
|
||||
summary: {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
skillIds: BundledCodingSkillId[];
|
||||
skillIds: string[];
|
||||
skillEntries: PiSkillEntry[];
|
||||
catalogRevision: number;
|
||||
revision: PiManagedInputRevision;
|
||||
};
|
||||
}
|
||||
@@ -130,33 +139,85 @@ export async function archivePiConversationSession(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSkillIds(skillIds: readonly string[]): BundledCodingSkillId[] {
|
||||
const result: BundledCodingSkillId[] = [];
|
||||
function normalizeSkillEntries(skillEntries: readonly PiSkillEntry[]): PiSkillEntry[] {
|
||||
const result: PiSkillEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const rawSkillId of skillIds) {
|
||||
const skillId = rawSkillId.trim();
|
||||
if (!BUNDLED_CODING_SKILL_IDS.includes(skillId as BundledCodingSkillId)) {
|
||||
throw new Error(`Unknown bundled coding skill: ${skillId || '(empty)'}`);
|
||||
for (const rawEntry of skillEntries) {
|
||||
if (!rawEntry || typeof rawEntry.id !== 'string' || typeof rawEntry.entryPath !== 'string') {
|
||||
throw new Error('Effective coding Skill entry is invalid');
|
||||
}
|
||||
const skillId = rawEntry.id.trim();
|
||||
const entryPath = rawEntry.entryPath.trim().replaceAll('\\', '/');
|
||||
if (!skillId || !entryPath) throw new Error('Effective coding Skill entry is invalid');
|
||||
if (entryPath.startsWith('/') || /^[A-Za-z]:\//u.test(entryPath)
|
||||
|| /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(entryPath)) {
|
||||
throw new Error(`Effective coding Skill entry path must be relative: ${entryPath}`);
|
||||
}
|
||||
const relative = path.posix.normalize(entryPath);
|
||||
if (!relative || relative === '.' || relative === '..' || relative.startsWith('../')) {
|
||||
throw new Error(`Effective coding Skill entry path escapes its package: ${entryPath}`);
|
||||
}
|
||||
if (seen.has(skillId)) continue;
|
||||
seen.add(skillId);
|
||||
result.push(skillId as BundledCodingSkillId);
|
||||
result.push({ id: skillId, entryPath: relative });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function catalogRevision(value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error('Catalog revision must be a non-negative safe integer');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function pathWithin(root: string, entryPath: string): string | null {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const resolved = path.resolve(resolvedRoot, ...entryPath.split('/'));
|
||||
const relative = path.relative(resolvedRoot, resolved);
|
||||
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function resolveSkillEntryPath(
|
||||
bundledSkillsDir: string,
|
||||
entry: PiSkillEntry,
|
||||
): Promise<string> {
|
||||
const roots = [
|
||||
path.resolve(bundledSkillsDir),
|
||||
...resolveBundledCodingPluginRootPaths(path.join(
|
||||
path.dirname(path.resolve(bundledSkillsDir)),
|
||||
'coding-plugins',
|
||||
)),
|
||||
];
|
||||
for (const root of roots) {
|
||||
const candidate = pathWithin(root, entry.entryPath);
|
||||
if (!candidate) continue;
|
||||
try {
|
||||
const metadata = await stat(candidate);
|
||||
if (metadata.isFile()) return candidate;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`Bundled coding Skill entry is not a file: ${entry.entryPath}`);
|
||||
}
|
||||
|
||||
export async function resolveExplicitCodingSkillPaths(
|
||||
bundledSkillsDir: string,
|
||||
skillIds: readonly string[],
|
||||
): Promise<{ skillIds: BundledCodingSkillId[]; skillPaths: string[] }> {
|
||||
const normalizedSkillIds = normalizeSkillIds(skillIds);
|
||||
const root = path.resolve(bundledSkillsDir);
|
||||
const skillPaths = normalizedSkillIds.map((skillId) => path.join(root, skillId, 'SKILL.md'));
|
||||
await Promise.all(skillPaths.map(async (skillPath) => {
|
||||
const metadata = await stat(skillPath);
|
||||
if (!metadata.isFile()) throw new Error(`Bundled coding skill entry is not a file: ${skillPath}`);
|
||||
}));
|
||||
return { skillIds: normalizedSkillIds, skillPaths };
|
||||
skillEntries: readonly PiSkillEntry[],
|
||||
): Promise<{ skillIds: string[]; skillEntries: PiSkillEntry[]; skillPaths: string[] }> {
|
||||
const normalizedEntries = normalizeSkillEntries(skillEntries);
|
||||
const skillPaths = await Promise.all(normalizedEntries.map((entry) => (
|
||||
resolveSkillEntryPath(bundledSkillsDir, entry)
|
||||
)));
|
||||
return {
|
||||
skillIds: normalizedEntries.map(({ id }) => id),
|
||||
skillEntries: normalizedEntries,
|
||||
skillPaths,
|
||||
};
|
||||
}
|
||||
|
||||
export async function materializePiAgentResources(
|
||||
@@ -171,10 +232,11 @@ export async function materializePiAgentResources(
|
||||
mkdir(projectSessionsDir, { recursive: true }),
|
||||
mkdir(projectPromptsDir, { recursive: true }),
|
||||
]);
|
||||
const { skillIds, skillPaths } = await resolveExplicitCodingSkillPaths(
|
||||
const { skillIds, skillEntries, skillPaths } = await resolveExplicitCodingSkillPaths(
|
||||
options.bundledSkillsDir,
|
||||
options.skillIds,
|
||||
options.skillEntries,
|
||||
);
|
||||
const resolvedCatalogRevision = catalogRevision(options.catalogRevision);
|
||||
const promptPath = path.join(projectPromptsDir, `${agentId}.md`);
|
||||
const manifestPath = path.join(projectPromptsDir, `${agentId}.manifest.json`);
|
||||
const manifest: PiAgentResourceManifest = {
|
||||
@@ -183,6 +245,8 @@ export async function materializePiAgentResources(
|
||||
agentId,
|
||||
promptFile: path.basename(promptPath),
|
||||
skillIds: [...skillIds],
|
||||
skillEntries: structuredClone(skillEntries),
|
||||
catalogRevision: resolvedCatalogRevision,
|
||||
revision: { ...options.revision },
|
||||
};
|
||||
await atomicWriteText(promptPath, options.prompt);
|
||||
@@ -193,12 +257,16 @@ export async function materializePiAgentResources(
|
||||
promptPath,
|
||||
manifestPath,
|
||||
skillIds: [...skillIds],
|
||||
skillEntries: structuredClone(skillEntries),
|
||||
skillPaths,
|
||||
catalogRevision: resolvedCatalogRevision,
|
||||
revision: { ...options.revision },
|
||||
summary: {
|
||||
projectId,
|
||||
agentId,
|
||||
skillIds: [...skillIds],
|
||||
skillEntries: structuredClone(skillEntries),
|
||||
catalogRevision: resolvedCatalogRevision,
|
||||
revision: { ...options.revision },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -60,6 +60,7 @@ import type {
|
||||
} from './rpc-client';
|
||||
import {
|
||||
PiWorkerProcess,
|
||||
PI_CORE_TOOL_NAMES,
|
||||
type PiWorkerProcessOptions,
|
||||
type PiWorkerProofFailure,
|
||||
type PiWorkerStopReason,
|
||||
@@ -84,6 +85,7 @@ import {
|
||||
} from './session-projector';
|
||||
import { PiManagedExtensionHost } from './extension-host';
|
||||
import type { PiSubagentScheduler } from './subagent';
|
||||
import type { CodingCapabilityRegistry, ResolvedWorkerResources } from '../../coding-plugins/registry';
|
||||
import {
|
||||
PiInteractionStore,
|
||||
} from './interaction';
|
||||
@@ -147,6 +149,7 @@ export interface PiManagedWorkerOpenerOptions {
|
||||
now?: () => number;
|
||||
onTelemetry?: (event: PiRuntimeTelemetryEvent) => void;
|
||||
extensionHost: PiManagedExtensionHost;
|
||||
capabilityRegistry?: CodingCapabilityRegistry;
|
||||
}
|
||||
|
||||
interface PiRpcSessionStateProjection {
|
||||
@@ -154,6 +157,17 @@ interface PiRpcSessionStateProjection {
|
||||
sessionFile?: string;
|
||||
}
|
||||
|
||||
function fallbackWorkerResources(skillIds: readonly string[]): ResolvedWorkerResources {
|
||||
const effectiveSkillIds = [...new Set(skillIds.map((id) => id.trim()).filter(Boolean))];
|
||||
return {
|
||||
catalogRevision: 0,
|
||||
pluginIds: [],
|
||||
effectiveSkillIds,
|
||||
skillEntries: effectiveSkillIds.map((id) => ({ id, entryPath: `${id}/SKILL.md` })),
|
||||
tools: [],
|
||||
};
|
||||
}
|
||||
|
||||
class ManagedPiConversationWorker implements PiConversationWorker {
|
||||
constructor(
|
||||
readonly id: string,
|
||||
@@ -229,12 +243,20 @@ export function createPiManagedWorkerOpener(
|
||||
if (!account || !descriptor) throw new Error('Selected Provider account is unavailable');
|
||||
const managedPaths = await ensurePiManagedPaths(options.userDataDir);
|
||||
await writePiProviderCatalog(managedPaths.modelsFile, catalog, model);
|
||||
const workerResources = options.capabilityRegistry
|
||||
? await options.capabilityRegistry.resolveWorkerResources({
|
||||
projectPath: registered.projectPath,
|
||||
assignedSkillIds: registered.agent.skillIds,
|
||||
role: 'parent',
|
||||
})
|
||||
: fallbackWorkerResources(registered.agent.skillIds);
|
||||
const resources = await materializePiAgentResources({
|
||||
userDataDir: options.userDataDir,
|
||||
projectId: input.conversation.projectId,
|
||||
agentId: registered.agent.id,
|
||||
prompt: registered.agent.prompt,
|
||||
skillIds: registered.agent.skillIds,
|
||||
skillEntries: workerResources.skillEntries,
|
||||
catalogRevision: workerResources.catalogRevision,
|
||||
bundledSkillsDir: options.bundledSkillsDir,
|
||||
revision: input.revision,
|
||||
});
|
||||
@@ -251,7 +273,9 @@ export function createPiManagedWorkerOpener(
|
||||
generation: input.generation,
|
||||
projectId: input.conversation.projectId,
|
||||
projectPath: registered.projectPath,
|
||||
skillIds: registered.agent.skillIds,
|
||||
skillEntries: workerResources.skillEntries,
|
||||
catalogRevision: workerResources.catalogRevision,
|
||||
tools: workerResources.tools,
|
||||
extensionsDir: managedPaths.extensionsDir,
|
||||
});
|
||||
recordManagedMilestone(
|
||||
@@ -274,6 +298,10 @@ export function createPiManagedWorkerOpener(
|
||||
cwd: registered.projectPath,
|
||||
configDir: resources.paths.configDir,
|
||||
sessionDir: resources.projectSessionsDir,
|
||||
tools: [...new Set([
|
||||
...PI_CORE_TOOL_NAMES,
|
||||
...extension.allowedToolNames,
|
||||
])],
|
||||
additionalArgs: [
|
||||
...buildPiManagedInputArgs(selection, resources),
|
||||
'--extension', extension.extensionPath,
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
PiSubagentChildResult,
|
||||
} from './subagent';
|
||||
import { PiSubagentChildError } from './subagent';
|
||||
import type { CodingCapabilityRegistry, ResolvedWorkerResources } from '../../coding-plugins/registry';
|
||||
import {
|
||||
PiWorkerProcess,
|
||||
type PiWorkerProcessOptions,
|
||||
@@ -61,6 +62,18 @@ export interface PiManagedSubagentChildOpenerOptions {
|
||||
getRevision(): PiManagedInputRevision;
|
||||
getLocalProxyCredential?(): Promise<string | undefined>;
|
||||
createProcess?(options: PiWorkerProcessOptions): PiSubagentProcessAdapter;
|
||||
capabilityRegistry?: CodingCapabilityRegistry;
|
||||
}
|
||||
|
||||
function fallbackWorkerResources(skillIds: readonly string[]): ResolvedWorkerResources {
|
||||
const effectiveSkillIds = [...new Set(skillIds.map((id) => id.trim()).filter(Boolean))];
|
||||
return {
|
||||
catalogRevision: 0,
|
||||
pluginIds: [],
|
||||
effectiveSkillIds,
|
||||
skillEntries: effectiveSkillIds.map((id) => ({ id, entryPath: `${id}/SKILL.md` })),
|
||||
tools: [],
|
||||
};
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | null {
|
||||
@@ -178,12 +191,20 @@ export function createPiManagedSubagentChildOpener(
|
||||
if (!account || !descriptor) throw new PiSubagentChildError('SUBAGENT_MODEL_UNAVAILABLE');
|
||||
const managedPaths = await ensurePiManagedPaths(options.userDataDir);
|
||||
await writePiProviderCatalog(managedPaths.modelsFile, catalog, agent.model);
|
||||
const workerResources = options.capabilityRegistry
|
||||
? await options.capabilityRegistry.resolveWorkerResources({
|
||||
projectPath: project.path,
|
||||
assignedSkillIds: agent.skillIds,
|
||||
role: 'child',
|
||||
})
|
||||
: fallbackWorkerResources(agent.skillIds);
|
||||
const resources = await materializePiAgentResources({
|
||||
userDataDir: options.userDataDir,
|
||||
projectId: input.projectId,
|
||||
agentId: agent.id,
|
||||
prompt: agent.prompt,
|
||||
skillIds: agent.skillIds,
|
||||
skillEntries: workerResources.skillEntries,
|
||||
catalogRevision: workerResources.catalogRevision,
|
||||
bundledSkillsDir: options.bundledSkillsDir,
|
||||
revision: options.getRevision(),
|
||||
});
|
||||
@@ -200,7 +221,9 @@ export function createPiManagedSubagentChildOpener(
|
||||
generation: input.workerGeneration,
|
||||
projectId: input.projectId,
|
||||
projectPath: project.path,
|
||||
skillIds: agent.skillIds,
|
||||
skillEntries: workerResources.skillEntries,
|
||||
catalogRevision: workerResources.catalogRevision,
|
||||
tools: [],
|
||||
extensionsDir: managedPaths.extensionsDir,
|
||||
role: 'child',
|
||||
runId: input.runId,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
type ChildProcessWithoutNullStreams,
|
||||
} from 'node:child_process';
|
||||
import { platform } from 'node:os';
|
||||
import { DATA_SERVICE_PI_TOOL_NAMES } from '../../../shared/data-service';
|
||||
import { logger } from '../../utils/logger';
|
||||
import type { CodingRuntimeDisposeReason } from '../contracts';
|
||||
import { PiProcessError, type PiProcessErrorCode } from './process-errors';
|
||||
@@ -122,15 +121,17 @@ export type PiWorkerProcessOptions = {
|
||||
onLifecycleEvent?(event: PiWorkerLifecycleEvent): void;
|
||||
};
|
||||
|
||||
/** 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',
|
||||
'task_state', 'changed_file', 'runtime_context',
|
||||
] as const);
|
||||
|
||||
export function buildPiRpcArgs(
|
||||
sessionDir: string,
|
||||
additionalArgs: readonly string[] = [],
|
||||
tools: readonly string[] = [
|
||||
'read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'ask_user', 'subagent',
|
||||
'agent_browser', 'game_asset_browser', 'game_asset_review',
|
||||
'task_state', 'changed_file', 'runtime_context',
|
||||
...DATA_SERVICE_PI_TOOL_NAMES,
|
||||
],
|
||||
tools: readonly string[] = PI_CORE_TOOL_NAMES,
|
||||
): string[] {
|
||||
return [
|
||||
'--mode', 'rpc',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
@@ -11,6 +11,10 @@ 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> | unknown;
|
||||
type ExtensionTool = {
|
||||
@@ -42,6 +46,83 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
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<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);
|
||||
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);
|
||||
@@ -76,7 +157,10 @@ describe('Makelore Pi extension bundle', () => {
|
||||
hosts.push(host);
|
||||
const worker = await host.registerWorker({
|
||||
conversationId: 'conversation-tools', generation: 1, projectId: 'project-a',
|
||||
projectPath: root, skillIds: ['agent-browser'], extensionsDir: root,
|
||||
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 = {
|
||||
@@ -97,7 +181,7 @@ describe('Makelore Pi extension bundle', () => {
|
||||
};
|
||||
const tools = new Map<string, ExtensionTool>();
|
||||
const handlers = new Map<string, ExtensionHandler>();
|
||||
module.default({
|
||||
await module.default({
|
||||
registerTool: (tool) => tools.set(tool.name, tool),
|
||||
on: (event, handler) => handlers.set(event, handler),
|
||||
});
|
||||
@@ -192,6 +276,8 @@ describe('Makelore Pi extension bundle', () => {
|
||||
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,
|
||||
@@ -217,7 +303,7 @@ describe('Makelore Pi extension bundle', () => {
|
||||
};
|
||||
const handlers = new Map<string, ExtensionHandler>();
|
||||
const tools = new Map<string, ExtensionTool>();
|
||||
module.default({
|
||||
await module.default({
|
||||
registerTool: (tool) => tools.set(tool.name, tool),
|
||||
on: (event, handler) => handlers.set(event, handler),
|
||||
});
|
||||
@@ -237,21 +323,9 @@ describe('Makelore Pi extension bundle', () => {
|
||||
];
|
||||
for (const name of dataServiceTools) {
|
||||
const tool = tools.get(name);
|
||||
expect(tool?.parameters).toMatchObject({
|
||||
type: 'object', additionalProperties: false,
|
||||
});
|
||||
expect(Object.keys(tool?.parameters?.properties ?? {})).not.toEqual(
|
||||
expect.arrayContaining(['owner', 'project', 'path', 'token', 'endpoint', 'url', 'handle']),
|
||||
expect(tool?.parameters).toEqual(
|
||||
DATA_SERVICE_PLUGIN_DEFINITION.tools.find(({ name: candidate }) => candidate === name)?.inputSchema,
|
||||
);
|
||||
const properties = tool?.parameters?.properties as Record<string, Record<string, unknown>>;
|
||||
if (name === 'data_service_get_document'
|
||||
|| name === 'data_service_put_document'
|
||||
|| name === 'data_service_delete_document') {
|
||||
expect(properties.document_id?.not).toEqual({ enum: ['.', '..'] });
|
||||
}
|
||||
if (name === 'data_service_put_document' || name === 'data_service_delete_document') {
|
||||
expect(properties.if_revision?.maximum).toBe(Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
}
|
||||
|
||||
const updates: unknown[] = [];
|
||||
@@ -355,7 +429,7 @@ describe('Makelore Pi extension bundle', () => {
|
||||
};
|
||||
const tools: string[] = [];
|
||||
const handlers = new Map<string, ExtensionHandler>();
|
||||
module.default({
|
||||
await module.default({
|
||||
registerTool: (tool) => tools.push(tool.name),
|
||||
on: (event, handler) => handlers.set(event, handler),
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@ describe('managed Pi extension bridge', () => {
|
||||
)) as Record<string, unknown>;
|
||||
expect(context).toEqual({
|
||||
conversationId: 'conversation-a', workerGeneration: 2, role: 'parent', runId: 'run-a',
|
||||
skillIds: [], allowedToolNames: [], tools: [], projectWriteLeaseToolNames: [],
|
||||
});
|
||||
await first.dispose();
|
||||
const response = await post(replacement, {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import YAML from 'yaml';
|
||||
import { CodingCapabilityRegistryImpl } from '@electron/coding-plugins/registry';
|
||||
import type { PiProviderSelection } from '@electron/coding-runtime/pi/provider-config';
|
||||
import {
|
||||
buildPiManagedInputArgs,
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
resolveBundledCodingSkillsDir,
|
||||
resolveExplicitCodingSkillPaths,
|
||||
} from '@electron/coding-runtime/pi/resource-loader';
|
||||
import type { PluginPolicyClientState } from '@electron/services/plugin-policy-client';
|
||||
import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins';
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
@@ -34,12 +37,18 @@ async function fixtureRoot(): Promise<{
|
||||
mkdir(path.join(projectDir, '.agents', 'skills', 'untrusted-agent-skill'), { recursive: true }),
|
||||
mkdir(path.join(skillsDir, 'grilling'), { recursive: true }),
|
||||
mkdir(path.join(skillsDir, 'agent-browser'), { recursive: true }),
|
||||
mkdir(path.join(path.dirname(skillsDir), 'coding-plugins', 'data-service', 'skills', 'data-service'), { recursive: true }),
|
||||
]);
|
||||
await Promise.all([
|
||||
writeFile(path.join(projectDir, '.pi', 'skills', 'untrusted-project-skill', 'SKILL.md'), 'untrusted', 'utf8'),
|
||||
writeFile(path.join(projectDir, '.agents', 'skills', 'untrusted-agent-skill', 'SKILL.md'), 'untrusted', 'utf8'),
|
||||
writeFile(path.join(skillsDir, 'grilling', 'SKILL.md'), '---\nname: grilling\n---\n', 'utf8'),
|
||||
writeFile(path.join(skillsDir, 'agent-browser', 'SKILL.md'), '---\nname: agent-browser\n---\n', 'utf8'),
|
||||
writeFile(
|
||||
path.join(path.dirname(skillsDir), 'coding-plugins', 'data-service', 'skills', 'data-service', 'SKILL.md'),
|
||||
'---\nname: data-service\n---\n',
|
||||
'utf8',
|
||||
),
|
||||
]);
|
||||
return { root, userDataDir, projectDir, skillsDir };
|
||||
}
|
||||
@@ -53,13 +62,21 @@ describe('Pi managed resource loader', () => {
|
||||
projectId: 'project-1',
|
||||
agentId: 'agent-1',
|
||||
prompt,
|
||||
skillIds: ['grilling', 'grilling'],
|
||||
skillEntries: [
|
||||
{ id: 'grilling', entryPath: 'grilling/SKILL.md' },
|
||||
{ id: 'grilling', entryPath: 'grilling/SKILL.md' },
|
||||
],
|
||||
catalogRevision: 11,
|
||||
bundledSkillsDir: fixture.skillsDir,
|
||||
revision: { provider: 3, resources: 7 },
|
||||
});
|
||||
|
||||
expect(await readFile(resources.promptPath, 'utf8')).toBe(prompt);
|
||||
expect(resources.skillIds).toEqual(['grilling']);
|
||||
expect(resources.skillEntries).toEqual([
|
||||
{ id: 'grilling', entryPath: 'grilling/SKILL.md' },
|
||||
]);
|
||||
expect(resources.catalogRevision).toBe(11);
|
||||
expect(resources.skillPaths).toEqual([path.join(fixture.skillsDir, 'grilling', 'SKILL.md')]);
|
||||
expect(JSON.stringify(resources.summary)).not.toContain(prompt);
|
||||
const manifest = JSON.parse(await readFile(resources.manifestPath, 'utf8')) as Record<string, unknown>;
|
||||
@@ -69,6 +86,8 @@ describe('Pi managed resource loader', () => {
|
||||
agentId: 'agent-1',
|
||||
promptFile: 'agent-1.md',
|
||||
skillIds: ['grilling'],
|
||||
skillEntries: [{ id: 'grilling', entryPath: 'grilling/SKILL.md' }],
|
||||
catalogRevision: 11,
|
||||
revision: { provider: 3, resources: 7 },
|
||||
});
|
||||
expect(JSON.stringify(manifest)).not.toContain(prompt);
|
||||
@@ -84,7 +103,8 @@ describe('Pi managed resource loader', () => {
|
||||
projectId: 'project-1',
|
||||
agentId: 'agent-1',
|
||||
prompt,
|
||||
skillIds: ['agent-browser'],
|
||||
skillEntries: [{ id: 'agent-browser', entryPath: 'agent-browser/SKILL.md' }],
|
||||
catalogRevision: 12,
|
||||
bundledSkillsDir: fixture.skillsDir,
|
||||
revision: { provider: 1, resources: 1 },
|
||||
});
|
||||
@@ -111,16 +131,112 @@ describe('Pi managed resource loader', () => {
|
||||
expect(serialized).not.toContain(path.join(fixture.projectDir, '.agents'));
|
||||
});
|
||||
|
||||
it('materializes effective plugin Skill entries from their bundled package roots', async () => {
|
||||
const fixture = await fixtureRoot();
|
||||
const resources = await materializePiAgentResources({
|
||||
userDataDir: fixture.userDataDir,
|
||||
projectId: 'project-1',
|
||||
agentId: 'agent-1',
|
||||
prompt: 'plugin prompt',
|
||||
skillEntries: [{ id: 'data-service', entryPath: 'skills/data-service/SKILL.md' }],
|
||||
catalogRevision: 13,
|
||||
bundledSkillsDir: fixture.skillsDir,
|
||||
revision: { provider: 1, resources: 1 },
|
||||
});
|
||||
|
||||
expect(resources.skillIds).toEqual(['data-service']);
|
||||
expect(resources.skillPaths).toEqual([
|
||||
path.join(path.dirname(fixture.skillsDir), 'coding-plugins', 'data-service', 'skills', 'data-service', 'SKILL.md'),
|
||||
]);
|
||||
expect(resources.catalogRevision).toBe(13);
|
||||
});
|
||||
|
||||
it('filters a known disabled plugin Skill for the next worker and restores it after re-enable', async () => {
|
||||
const fixture = await fixtureRoot();
|
||||
const assignedSkillIds = ['grilling', 'data-service'];
|
||||
let enabled = true;
|
||||
const policy: PluginPolicyClientState = {
|
||||
status: 'current',
|
||||
revision: 17,
|
||||
lastVerifiedAt: 1,
|
||||
catalog: {
|
||||
schema_version: 1,
|
||||
catalog_version: 'catalog-17',
|
||||
pricing_version: null,
|
||||
plugins: [{
|
||||
plugin_id: DATA_SERVICE_PLUGIN_DEFINITION.id,
|
||||
supported_contract_versions: [DATA_SERVICE_PLUGIN_DEFINITION.contractVersion],
|
||||
status: 'active',
|
||||
capabilities: DATA_SERVICE_PLUGIN_DEFINITION.skills[0].grants.map((capabilityId) => ({
|
||||
capability_id: capabilityId,
|
||||
operations: DATA_SERVICE_PLUGIN_DEFINITION.tools
|
||||
.filter((tool) => tool.capabilityId === capabilityId)
|
||||
.map((tool) => ({
|
||||
operation: tool.operation,
|
||||
billing: { mode: 'included' as const, entitlement_scope: null, notice: 'Included' },
|
||||
})),
|
||||
})),
|
||||
}],
|
||||
},
|
||||
};
|
||||
const registry = new CodingCapabilityRegistryImpl({
|
||||
policyClient: { getState: () => policy },
|
||||
getEnabledPluginIds: async () => enabled ? [DATA_SERVICE_PLUGIN_DEFINITION.id] : [],
|
||||
adapters: [{
|
||||
pluginId: DATA_SERVICE_PLUGIN_DEFINITION.id,
|
||||
async inspect() { return { status: 'ready' }; },
|
||||
async invoke() {
|
||||
return {
|
||||
success: true, status: 200, code: null, error: null, retryable: false,
|
||||
payload_schema: 'data-service.v1', data: null,
|
||||
};
|
||||
},
|
||||
}],
|
||||
});
|
||||
const resolve = async () => await registry.resolveWorkerResources({
|
||||
projectPath: fixture.projectDir,
|
||||
assignedSkillIds,
|
||||
role: 'parent',
|
||||
});
|
||||
|
||||
const first = await resolve();
|
||||
expect(first.effectiveSkillIds).toEqual(assignedSkillIds);
|
||||
expect(first.tools).toHaveLength(10);
|
||||
|
||||
enabled = false;
|
||||
const disabled = await resolve();
|
||||
expect(assignedSkillIds).toEqual(['grilling', 'data-service']);
|
||||
expect(disabled.effectiveSkillIds).toEqual(['grilling']);
|
||||
expect(disabled.tools).toEqual([]);
|
||||
await expect(materializePiAgentResources({
|
||||
userDataDir: fixture.userDataDir,
|
||||
projectId: 'project-1',
|
||||
agentId: 'agent-1',
|
||||
prompt: 'disabled plugin prompt',
|
||||
skillEntries: disabled.skillEntries,
|
||||
catalogRevision: disabled.catalogRevision,
|
||||
bundledSkillsDir: fixture.skillsDir,
|
||||
revision: { provider: 1, resources: 2 },
|
||||
})).resolves.toMatchObject({ skillIds: ['grilling'], catalogRevision: 17 });
|
||||
|
||||
enabled = true;
|
||||
const restored = await resolve();
|
||||
expect(restored.effectiveSkillIds).toEqual(assignedSkillIds);
|
||||
expect(restored.tools).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('rejects unknown skills and unsafe managed path segments', async () => {
|
||||
const fixture = await fixtureRoot();
|
||||
await expect(resolveExplicitCodingSkillPaths(fixture.skillsDir, ['not-bundled']))
|
||||
.rejects.toThrow('Unknown bundled coding skill');
|
||||
await expect(resolveExplicitCodingSkillPaths(fixture.skillsDir, [
|
||||
{ id: 'not-bundled', entryPath: 'not-bundled/SKILL.md' },
|
||||
])).rejects.toThrow('Bundled coding Skill entry is not a file');
|
||||
await expect(materializePiAgentResources({
|
||||
userDataDir: fixture.userDataDir,
|
||||
projectId: '../outside',
|
||||
agentId: 'agent-1',
|
||||
prompt: '',
|
||||
skillIds: [],
|
||||
skillEntries: [],
|
||||
catalogRevision: 1,
|
||||
bundledSkillsDir: fixture.skillsDir,
|
||||
revision: { provider: 1, resources: 1 },
|
||||
})).rejects.toThrow('Project id');
|
||||
|
||||
@@ -244,7 +244,7 @@ describe('Pi worker process', () => {
|
||||
'--no-context-files',
|
||||
'--no-approve',
|
||||
'--tools',
|
||||
'read,bash,edit,write,grep,find,ls,ask_user,subagent,agent_browser,game_asset_browser,game_asset_review,task_state,changed_file,runtime_context,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',
|
||||
'read,bash,edit,write,grep,find,ls,ask_user,subagent,agent_browser,game_asset_browser,game_asset_review,task_state,changed_file,runtime_context',
|
||||
'--model', 'model-a',
|
||||
]);
|
||||
expect(buildPiRpcArgs('sessions', ['--no-session'], ['read', 'grep', 'find', 'ls']))
|
||||
|
||||
@@ -6,8 +6,12 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { PiWorkerProcess } from '../../electron/coding-runtime/pi/worker-process';
|
||||
import {
|
||||
PI_CORE_TOOL_NAMES,
|
||||
PiWorkerProcess,
|
||||
} from '../../electron/coding-runtime/pi/worker-process';
|
||||
import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host';
|
||||
import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins';
|
||||
import {
|
||||
PI_RUNTIME_MANIFEST,
|
||||
PI_RUNTIME_VERSION,
|
||||
@@ -85,6 +89,8 @@ describe('locked Pi worker process smoke', () => {
|
||||
generation: 1,
|
||||
projectId: 'real-project',
|
||||
extensionsDir: join(root, 'extensions'),
|
||||
catalogRevision: 7,
|
||||
tools: [...DATA_SERVICE_PLUGIN_DEFINITION.tools],
|
||||
});
|
||||
await extensionHost.bindRun('real-conversation', 1, 'real-run');
|
||||
const probe = await materializeActiveToolsProbe(root);
|
||||
@@ -94,6 +100,10 @@ describe('locked Pi worker process smoke', () => {
|
||||
cwd,
|
||||
configDir,
|
||||
sessionDir,
|
||||
tools: [
|
||||
...PI_CORE_TOOL_NAMES,
|
||||
...DATA_SERVICE_PLUGIN_DEFINITION.tools.map(({ name }) => name),
|
||||
],
|
||||
additionalArgs: [
|
||||
'--extension', extension.extensionPath,
|
||||
'--extension', probe.extensionPath,
|
||||
@@ -118,6 +128,7 @@ describe('locked Pi worker process smoke', () => {
|
||||
'task_state',
|
||||
'changed_file',
|
||||
'runtime_context',
|
||||
...DATA_SERVICE_PLUGIN_DEFINITION.tools.map(({ name }) => name),
|
||||
]));
|
||||
await expect(worker.stop('test_injection')).resolves.toMatchObject({ mode: 'stdin-close', code: 0 });
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user