From fd891ff3bb87a29381a0a7006fb4618ec4fe144f Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Thu, 27 Aug 2026 17:24:16 +0800 Subject: [PATCH] feat(pi): materialize effective plugin worker tools --- ...in-ml03-worker-materialization-9b2e6c41.md | 120 +++++++++++ electron/coding-runtime/pi/extension-host.ts | 66 +++++- .../pi/extensions/makelore-runtime.ts | 193 +++++------------- electron/coding-runtime/pi/resource-loader.ts | 122 ++++++++--- electron/coding-runtime/pi/runtime.ts | 32 ++- electron/coding-runtime/pi/subagent-child.ts | 27 ++- electron/coding-runtime/pi/worker-process.ts | 15 +- tests/unit/pi-extension-bundle.test.ts | 112 ++++++++-- tests/unit/pi-extension-host.test.ts | 1 + tests/unit/pi-resource-loader.test.ts | 126 +++++++++++- tests/unit/pi-rpc-foundation.test.ts | 2 +- tests/unit/pi-worker-process-real.test.ts | 13 +- 12 files changed, 617 insertions(+), 212 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260827-plugin-ml03-worker-materialization-9b2e6c41.md diff --git a/.project-docs/30-worklog/tasks/20260827-plugin-ml03-worker-materialization-9b2e6c41.md b/.project-docs/30-worklog/tasks/20260827-plugin-ml03-worker-materialization-9b2e6c41.md new file mode 100644 index 0000000..fcb00c8 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260827-plugin-ml03-worker-materialization-9b2e6c41.md @@ -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. diff --git a/electron/coding-runtime/pi/extension-host.ts b/electron/coding-runtime/pi/extension-host.ts index 3a34274..eeb402f 100644 --- a/electron/coding-runtime/pi/extension-host.ts +++ b/electron/coding-runtime/pi/extension-host.ts @@ -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; } @@ -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 } : {}), }); } diff --git a/electron/coding-runtime/pi/extensions/makelore-runtime.ts b/electron/coding-runtime/pi/extensions/makelore-runtime.ts index f6eaa7b..5e56e1b 100644 --- a/electron/coding-runtime/pi/extensions/makelore-runtime.ts +++ b/electron/coding-runtime/pi/extensions/makelore-runtime.ts @@ -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; diff --git a/electron/coding-runtime/pi/resource-loader.ts b/electron/coding-runtime/pi/resource-loader.ts index 348aa13..c094420 100644 --- a/electron/coding-runtime/pi/resource-loader.ts +++ b/electron/coding-runtime/pi/resource-loader.ts @@ -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(); - 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 { + 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 }, }, }; diff --git a/electron/coding-runtime/pi/runtime.ts b/electron/coding-runtime/pi/runtime.ts index f909990..d1524ba 100644 --- a/electron/coding-runtime/pi/runtime.ts +++ b/electron/coding-runtime/pi/runtime.ts @@ -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, diff --git a/electron/coding-runtime/pi/subagent-child.ts b/electron/coding-runtime/pi/subagent-child.ts index 54472cd..f6777c7 100644 --- a/electron/coding-runtime/pi/subagent-child.ts +++ b/electron/coding-runtime/pi/subagent-child.ts @@ -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; 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 | 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, diff --git a/electron/coding-runtime/pi/worker-process.ts b/electron/coding-runtime/pi/worker-process.ts index ac2b2e2..2991add 100644 --- a/electron/coding-runtime/pi/worker-process.ts +++ b/electron/coding-runtime/pi/worker-process.ts @@ -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', diff --git a/tests/unit/pi-extension-bundle.test.ts b/tests/unit/pi-extension-bundle.test.ts index 8ae2df4..a1293d1 100644 --- a/tests/unit/pi-extension-bundle.test.ts +++ b/tests/unit/pi-extension-bundle.test.ts @@ -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; 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; + }; + 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); @@ -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(); const handlers = new Map(); - 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(); const tools = new Map(); - 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>; - 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(); - module.default({ + await module.default({ registerTool: (tool) => tools.push(tool.name), on: (event, handler) => handlers.set(event, handler), }); diff --git a/tests/unit/pi-extension-host.test.ts b/tests/unit/pi-extension-host.test.ts index 7c3a996..e83ed1a 100644 --- a/tests/unit/pi-extension-host.test.ts +++ b/tests/unit/pi-extension-host.test.ts @@ -107,6 +107,7 @@ describe('managed Pi extension bridge', () => { )) as Record; 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, { diff --git a/tests/unit/pi-resource-loader.test.ts b/tests/unit/pi-resource-loader.test.ts index 02f0653..2176c4e 100644 --- a/tests/unit/pi-resource-loader.test.ts +++ b/tests/unit/pi-resource-loader.test.ts @@ -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; @@ -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'); diff --git a/tests/unit/pi-rpc-foundation.test.ts b/tests/unit/pi-rpc-foundation.test.ts index 6ef8cbb..d6837da 100644 --- a/tests/unit/pi-rpc-foundation.test.ts +++ b/tests/unit/pi-rpc-foundation.test.ts @@ -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'])) diff --git a/tests/unit/pi-worker-process-real.test.ts b/tests/unit/pi-worker-process-real.test.ts index 637db21..15e668e 100644 --- a/tests/unit/pi-worker-process-real.test.ts +++ b/tests/unit/pi-worker-process-real.test.ts @@ -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 {