feat(pi): materialize effective plugin worker tools

This commit is contained in:
2026-08-27 17:24:16 +08:00
parent c4dd8923a0
commit fd891ff3bb
12 changed files with 617 additions and 212 deletions

View File

@@ -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.

View File

@@ -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 } : {}),
});
}

View File

@@ -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;

View File

@@ -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 },
},
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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',

View File

@@ -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),
});

View File

@@ -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, {

View File

@@ -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');

View File

@@ -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']))

View File

@@ -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 {