diff --git a/.project-docs/30-worklog/tasks/20260826-ml04-pi-data-tools-8d3c91a7.md b/.project-docs/30-worklog/tasks/20260826-ml04-pi-data-tools-8d3c91a7.md new file mode 100644 index 0000000..b505048 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260826-ml04-pi-data-tools-8d3c91a7.md @@ -0,0 +1,132 @@ +# Task: Implement ML-04 purpose-specific Pi product tools + +## Identity + +- Task ID: 20260826-ml04-pi-data-tools-8d3c91a7 +- Mode: Feature +- Branch: codex/20260826-ml04-pi-data-tools-8d3c91a7-ml04-pi-data-tools +- Worktree: D:\Datas\OthersProjects\makelore-ml04-pi-data-tools-8d3c91a7 +- Base commit: e15d8b7f51bea6b56558c981964082a4e76e523c +- Owner: ml04-pi-tools +- Status: Ready for integration + +## Scope + +- Implement ML-04 in the MakeLore Electron Main/Pi boundary: ten purpose-specific + Data Service Pi tools, strict protocol schemas, safe result details, destructive + confirmation handling, and one-to-one in-process dispatch through the existing + ML-03 `DataServiceOperations` seam. +- Primary owned paths are `electron/coding-runtime/pi/product-tools.ts`, + `electron/coding-runtime/pi/extensions/makelore-runtime.ts`, + `electron/api/coding-composition.ts`, and focused Pi product-tool/extension + tests. Only directly required shared contracts/projectors and the Data Service + trusted-project-path seam may change. +- Explicitly out of scope: ML-05 preview, ML-06 browser work, unrelated Renderer + work, any OpenCode compatibility or generic multiplexer, loopback HTTP, + credentials or arbitrary project/owner/path inputs, and root-main/push/PR + operations. The only Renderer touch is the type-required conversation detail + summary for the new safe `data-service.v1` contract. + +## Intent And Constraints + +- Preserve Pi `0.84.2` as the sole runtime and preserve both parent-only controls: + bundled extension registration and the extension-host bridge `403` for child + workers. +- Tool identifiers are the exact ten underscore names from spec 9.4. Every input + is closed (`additionalProperties: false`), validated at the product-tool seam, + and derives authority from `PiProductToolContext.projectPath`; no tool input may + contain owner, project, path, token, endpoint, URL, or local handles. +- Reuse the single Main `DataServiceOperations` instance created by composition. + Pass trusted Pi context projectPath at the deep operation boundary so active + project identity is checked by the existing ML-03 service rather than by a + second adapter or network hop. +- Destructive operations require literal `confirmed: true` and return the safe + Data Service envelope/detail without leaking credentials, local paths, or + arbitrary upstream data. + +## Project Context Loaded + +- Task identity: `20260826-ml04-pi-data-tools-8d3c91a7`; mode `feature`; owner + `ml04-pi-tools`; branch + `codex/20260826-ml04-pi-data-tools-8d3c91a7-ml04-pi-data-tools`; isolated + worktree `D:\Datas\OthersProjects\makelore-ml04-pi-data-tools-8d3c91a7`; base + `e15d8b7f51bea6b56558c981964082a4e76e523c`. +- Concurrent-task findings: the ML-00..ML-09 coordinator owns integration + sequencing but not these implementation paths; existing Pi product-tools, + extension-host, child-worker, and core-host-api work is already in the base. + Other active planning records are placeholders or unrelated E2E/media work; + no semantic ownership conflict was found for this scope. +- Read before planning: repository `AGENTS.md`; `.project-docs` entry, + memory-index, current-state, decision index, ADR-006, system overview, + module-map, data-flow, business-rules, glossary, evidence/reflection/ + commitments/stale-item indexes; all relevant active/ready peer task records; + the server-side ML plan and Data Service integration specification sections + 8.5, 9.1–9.4. +- Relevant understanding: Main composition already creates one + `DataServiceOperations`; `CodingProjectService` exposes + `requireActiveRealProjectWithIdentity`; Pi product tools currently dispatch + existing product operations in-process; the extension bundle forwards parent + invocations to the extension-host bridge; the bridge already rejects child + product invocation with `403`. +- Known documentation uncertainty: project positioning and success-criteria + documents remain mostly placeholders; this task therefore treats the accepted + ML plan/spec and existing code/contracts as authoritative. + +## Plan + +1. Extend shared Data Service result/detail contracts and product-tool projectors + only as needed to represent a safe, versioned result envelope. +2. Add strict ten-tool validation and one-to-one dispatch in Pi `product-tools`, + passing trusted context projectPath to the single Data Service operations + object; wire that object from composition. +3. Add all ten parent-only registrations and closed parameter schemas to the + bundled Makelore Pi extension while retaining existing registration behavior + and bridge child-worker rejection. +4. Add focused contract, Pi product-tool, bundle, and bridge tests proving exact + IDs/schemas, dispatch/authority, safe details, confirmations, and absence of + forbidden input fields. +5. Run focused tests, typecheck, lint, diff/doc-drift checks; update this task + record and complete the task-context gate. + +## Outcome + +- Added the ten exact parent-only Pi Data Service tools with closed, bounded + protocol schemas and matching Main-side input validation. Each operation + dispatches directly to the one composition-owned `DataServiceOperations` + instance; active-project operations receive the trusted Pi projectPath and + owner-wide listing does not. +- Added versioned `data-service.v1` safe result details with bounded envelope + projection, wired them through the existing conversation projector, and kept + destructive operations behind literal `confirmed: true`. +- Preserved the bundled parent-role registration gate and extension-host child + bridge `403`; added tests for all ten registrations, dispatch routing, + trusted path authority, forbidden input fields, and confirmation rejection. +- No canonical project memory was changed. The only non-Main contract touch is + the minimal conversation timeline summary required for the new discriminated + safe-detail type to remain type-safe and visible. + +## Verification + +- `pnpm exec vitest run tests/unit/pi-product-tools.test.ts tests/unit/pi-extension-bundle.test.ts tests/unit/pi-extension-host.test.ts --maxWorkers=1` — 23 passed. +- Data Service/projector plus Pi focused suites — 62 passed. +- Full unit suite excluding the separately serialized pressure test — 186 + files, 1,574 passed, 2 skipped. +- `pnpm typecheck` — passed. +- `pnpm lint:check` — passed with the five pre-existing repository warnings in + `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`. +- `git diff --check` — passed. +- Final task-aware doc-drift check and task-context completion are run before + handoff. + +## Follow-ups + +- The coordinator advanced after this task's required base with ML-03 commit + `0ecceef15d2e43190f835fd71a7a1478222f1ba8` / coordinator frontier + `44bcdf52...`; when integrating, retain its Data Service error-code/status + consistency correction while applying this task's trusted-project-path + method-signature hunk. + +## Promotion Candidates + +- None. The accepted ML-04 plan/spec and existing architecture remain the + authority; no canonical ADR or project-memory update is proposed. diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index 8929b44..f24ad36 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -207,6 +207,7 @@ export function createCodingComposition( listPiCommands: (conversationId) => conversations.listLiveCommands(conversationId), }); const dataService = createDataServiceOperations({ projects }); + productTools.configureDataService(dataService); return { attachments, dataService, diff --git a/electron/coding-runtime/pi/extension-host.ts b/electron/coding-runtime/pi/extension-host.ts index 36767d8..3a34274 100644 --- a/electron/coding-runtime/pi/extension-host.ts +++ b/electron/coding-runtime/pi/extension-host.ts @@ -14,6 +14,7 @@ import { type PiSubagentScheduler, } from './subagent'; import { + isPiProductToolName, type PiProductToolName, type PiProductTools, } from './product-tools'; @@ -121,7 +122,7 @@ 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 typeof value.toolName === 'string' && 'input' in value; + return isPiProductToolName(value.toolName) && 'input' in value; } if (value.action === 'changes.bash') return true; if (value.action === 'changes.touched') { diff --git a/electron/coding-runtime/pi/extensions/makelore-runtime.ts b/electron/coding-runtime/pi/extensions/makelore-runtime.ts index 97ced02..8419372 100644 --- a/electron/coding-runtime/pi/extensions/makelore-runtime.ts +++ b/electron/coding-runtime/pi/extensions/makelore-runtime.ts @@ -14,6 +14,12 @@ 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(); @@ -283,6 +289,127 @@ 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}$' }, + }, + }, + ); + 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}$' }, + data: { type: 'object' }, + if_revision: { type: 'integer', minimum: 1 }, + }, + }, + ); + 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}$' }, + if_revision: { type: 'integer', minimum: 1 }, + 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 } }, + }, + ); pi.on('tool_call', async (event, ctx) => { if (!MUTATION_TOOLS.has(event.toolName)) return; diff --git a/electron/coding-runtime/pi/product-tools.ts b/electron/coding-runtime/pi/product-tools.ts index c3aed81..515471d 100644 --- a/electron/coding-runtime/pi/product-tools.ts +++ b/electron/coding-runtime/pi/product-tools.ts @@ -13,6 +13,14 @@ import type { ProductCodingSkill, ProductPiCommandInput, } from '../../../shared/coding-product-tools'; +import { + DATA_SERVICE_PI_TOOL_NAMES, + type DataServiceHostResult, + type DataServicePiToolName, + type DataServiceToolData, + type DataServiceToolDetailsV1, +} from '../../../shared/data-service'; +import type { DataServiceOperations } from '../../services/data-service-client'; import type { KnownToolDetails, RuntimeContextDetailsV1 } from '../contracts'; import { PiAgentBrowserTool } from './extensions/agent-browser'; import { reportChangedFiles } from './extensions/changed-file'; @@ -25,7 +33,122 @@ export type PiProductToolName = | 'game_asset_review' | 'task_state' | 'changed_file' - | 'runtime_context'; + | 'runtime_context' + | DataServicePiToolName; + +export { DATA_SERVICE_PI_TOOL_NAMES }; + +const PI_PRODUCT_TOOL_NAMES = new Set([ + 'agent_browser', + 'game_asset_browser', + 'game_asset_review', + 'task_state', + 'changed_file', + 'runtime_context', + ...DATA_SERVICE_PI_TOOL_NAMES, +]); + +export function isPiProductToolName(value: unknown): value is PiProductToolName { + return typeof value === 'string' && PI_PRODUCT_TOOL_NAMES.has(value); +} + +const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/; +const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/; +const MAX_REQUEST_BYTES = 98_304; +const MAX_CURSOR_LENGTH = 1_024; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function invalidDataServiceInput(): never { + throw new Error('Data Service tool input is invalid'); +} + +function exactInput( + value: unknown, + required: readonly string[], + optional: readonly string[] = [], +): Record { + if (!isRecord(value)) return invalidDataServiceInput(); + const allowed = new Set([...required, ...optional]); + if (Object.keys(value).some((key) => !allowed.has(key)) + || required.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) { + return invalidDataServiceInput(); + } + return value; +} + +function collection(value: unknown): string { + if (typeof value !== 'string' || !COLLECTION_PATTERN.test(value)) return invalidDataServiceInput(); + return value; +} + +function documentId(value: unknown): string { + if (typeof value !== 'string' || value === '.' || value === '..' + || !DOCUMENT_ID_PATTERN.test(value)) return invalidDataServiceInput(); + return value; +} + +function revision(value: unknown): number | undefined { + if (value === undefined) return undefined; + if (!Number.isSafeInteger(value) || (value as number) < 1) return invalidDataServiceInput(); + return value as number; +} + +function collections(value: unknown): string[] { + if (!Array.isArray(value) || value.length > 20) return invalidDataServiceInput(); + return value.map(collection); +} + +function data(value: unknown): Record { + if (!isRecord(value)) return invalidDataServiceInput(); + try { + const encoded = JSON.stringify(value); + if (typeof encoded !== 'string' || Buffer.byteLength(encoded, 'utf8') > MAX_REQUEST_BYTES) { + return invalidDataServiceInput(); + } + } catch { + return invalidDataServiceInput(); + } + return value; +} + +function limit(value: unknown): number | undefined { + if (value === undefined) return undefined; + if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > 100) { + return invalidDataServiceInput(); + } + return value as number; +} + +function cursor(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || !value || value.length > MAX_CURSOR_LENGTH) { + return invalidDataServiceInput(); + } + return value; +} + +function confirmation(value: unknown): true { + if (value !== true) return invalidDataServiceInput(); + return true; +} + +function dataServiceResult( + operation: DataServicePiToolName, + result: DataServiceHostResult, +): PiProductToolResult { + const details: DataServiceToolDetailsV1 = { + schema: 'data-service.v1', + operation, + ...result, + }; + return { + content: [{ type: 'text', text: JSON.stringify(details) }], + details, + }; +} export interface PiProductToolContext { conversationId: string; @@ -46,16 +169,23 @@ export interface PiProductToolsOptions { attachments: CodingAttachmentStore; bundledSkillsDir: string; changeTracker?: ConversationChangeTracker; + dataService?: DataServiceOperations; } export class PiProductTools { readonly changeTracker: ConversationChangeTracker; private readonly browser: PiAgentBrowserTool; private readonly gameAssets = new PiGameAssetTools(); + private dataService: DataServiceOperations | undefined; constructor(private readonly options: PiProductToolsOptions) { this.changeTracker = options.changeTracker ?? new ConversationChangeTracker(); this.browser = new PiAgentBrowserTool(options.browser, options.attachments); + this.dataService = options.dataService; + } + + configureDataService(dataService: DataServiceOperations): void { + this.dataService = dataService; } beginRun(input: { conversationId: string; runId: string; projectPath: string }) { @@ -107,6 +237,77 @@ export class PiProductTools { if (toolName === 'changed_file') { return await reportChangedFiles(this.changeTracker, context, input); } + if (toolName === 'data_service_configure') { + const body = exactInput(input, ['collections']); + const result = await this.requireDataService().configure( + { collections: collections(body.collections) }, context.projectPath, + ); + return dataServiceResult(toolName, result); + } + if (toolName === 'data_service_inspect') { + exactInput(input, []); + return dataServiceResult(toolName, await this.requireDataService().inspect(context.projectPath)); + } + if (toolName === 'data_service_list_projects') { + exactInput(input, []); + return dataServiceResult(toolName, await this.requireDataService().listProjects()); + } + if (toolName === 'data_service_get_document') { + const body = exactInput(input, ['collection', 'document_id']); + return dataServiceResult(toolName, await this.requireDataService().getDocument({ + collection: collection(body.collection), + document_id: documentId(body.document_id), + }, context.projectPath)); + } + if (toolName === 'data_service_list_documents') { + const body = exactInput(input, ['collection'], ['limit', 'cursor']); + const requestedLimit = limit(body.limit); + const requestedCursor = cursor(body.cursor); + return dataServiceResult(toolName, await this.requireDataService().listDocuments({ + collection: collection(body.collection), + ...(requestedLimit === undefined ? {} : { limit: requestedLimit }), + ...(requestedCursor === undefined ? {} : { cursor: requestedCursor }), + }, context.projectPath)); + } + if (toolName === 'data_service_put_document') { + const body = exactInput(input, ['collection', 'document_id', 'data'], ['if_revision']); + const requestedRevision = revision(body.if_revision); + return dataServiceResult(toolName, await this.requireDataService().putDocument({ + collection: collection(body.collection), + document_id: documentId(body.document_id), + data: data(body.data), + ...(requestedRevision === undefined ? {} : { if_revision: requestedRevision }), + }, context.projectPath)); + } + if (toolName === 'data_service_delete_document') { + const body = exactInput(input, ['collection', 'document_id', 'confirmed'], ['if_revision']); + const requestedRevision = revision(body.if_revision); + return dataServiceResult(toolName, await this.requireDataService().deleteDocument({ + collection: collection(body.collection), + document_id: documentId(body.document_id), + ...(requestedRevision === undefined ? {} : { if_revision: requestedRevision }), + confirmed: confirmation(body.confirmed), + }, context.projectPath)); + } + if (toolName === 'data_service_remove_collection') { + const body = exactInput(input, ['collection', 'confirmed']); + return dataServiceResult(toolName, await this.requireDataService().removeCollection({ + collection: collection(body.collection), + confirmed: confirmation(body.confirmed), + }, context.projectPath)); + } + if (toolName === 'data_service_reset') { + const body = exactInput(input, ['confirmed']); + return dataServiceResult(toolName, await this.requireDataService().reset({ + confirmed: confirmation(body.confirmed), + }, context.projectPath)); + } + if (toolName === 'data_service_remove_project') { + const body = exactInput(input, ['confirmed']); + return dataServiceResult(toolName, await this.requireDataService().removeProject({ + confirmed: confirmation(body.confirmed), + }, context.projectPath)); + } if (toolName !== 'runtime_context') throw new Error('Product tool is unavailable'); const skills = await this.listSkills(context.skillIds); const details: RuntimeContextDetailsV1 = { @@ -119,4 +320,9 @@ export class PiProductTools { details, }; } + + private requireDataService(): DataServiceOperations { + if (!this.dataService) throw new Error('Data Service tools are unavailable'); + return this.dataService; + } } diff --git a/electron/services/data-service-client.ts b/electron/services/data-service-client.ts index 6c8d7cb..c8b8a14 100644 --- a/electron/services/data-service-client.ts +++ b/electron/services/data-service-client.ts @@ -77,16 +77,16 @@ type FetchImplementation = typeof fetch; type AccessTokenGetter = typeof getValidWorksSquareAccessToken; export type DataServiceOperations = { - configure(input: { collections: string[] }): Promise>; - inspect(): Promise>; + configure(input: { collections: string[] }, trustedProjectPath?: string): Promise>; + inspect(trustedProjectPath?: string): Promise>; listProjects(): Promise>; - getDocument(input: { collection: string; document_id: string }): Promise>; - listDocuments(input: { collection: string; limit?: number; cursor?: string }): Promise>; - putDocument(input: DataServicePutDocumentInput): Promise>; - deleteDocument(input: DataServiceDocumentTargetInput & { confirmed: true }): Promise>; - removeCollection(input: DataServiceCollectionTargetInput): Promise>; - reset(input: DataServiceConfirmationInput): Promise>; - removeProject(input: DataServiceConfirmationInput): Promise>; + getDocument(input: { collection: string; document_id: string }, trustedProjectPath?: string): Promise>; + listDocuments(input: { collection: string; limit?: number; cursor?: string }, trustedProjectPath?: string): Promise>; + putDocument(input: DataServicePutDocumentInput, trustedProjectPath?: string): Promise>; + deleteDocument(input: DataServiceDocumentTargetInput & { confirmed: true }, trustedProjectPath?: string): Promise>; + removeCollection(input: DataServiceCollectionTargetInput, trustedProjectPath?: string): Promise>; + reset(input: DataServiceConfirmationInput, trustedProjectPath?: string): Promise>; + removeProject(input: DataServiceConfirmationInput, trustedProjectPath?: string): Promise>; }; export type DataServiceCloudClientDependencies = { @@ -747,10 +747,11 @@ export function createDataServiceOperations( const client = dependencies.client ?? new DataServiceCloudClient(); async function withActive( + trustedProjectPath: string | undefined, operation: (projectId: string) => Promise>, ): Promise> { try { - const active = await dependencies.projects.requireActiveRealProjectWithIdentity(); + const active = await dependencies.projects.requireActiveRealProjectWithIdentity(trustedProjectPath); return await operation(active.projectId); } catch (error) { return projectLocalProjectError(error); @@ -759,26 +760,26 @@ export function createDataServiceOperations( return { listProjects: () => client.listProjects(), - configure: ({ collections }) => withActive((projectId) => client.configure(projectId, collections)), - inspect: () => withActive((projectId) => client.inspect(projectId)), - getDocument: ({ collection, document_id }) => withActive( + configure: ({ collections }, trustedProjectPath) => withActive(trustedProjectPath, (projectId) => client.configure(projectId, collections)), + inspect: (trustedProjectPath) => withActive(trustedProjectPath, (projectId) => client.inspect(projectId)), + getDocument: ({ collection, document_id }, trustedProjectPath) => withActive(trustedProjectPath, (projectId) => client.getDocument(projectId, collection, document_id), ), - listDocuments: ({ collection, limit, cursor }) => withActive( + listDocuments: ({ collection, limit, cursor }, trustedProjectPath) => withActive(trustedProjectPath, (projectId) => client.listDocuments(projectId, collection, limit, cursor), ), - putDocument: (input) => withActive((projectId) => client.putDocument(projectId, input)), - deleteDocument: ({ collection, document_id, if_revision, confirmed }) => confirmed === true - ? withActive((projectId) => client.deleteDocument(projectId, { collection, document_id, if_revision })) + putDocument: (input, trustedProjectPath) => withActive(trustedProjectPath, (projectId) => client.putDocument(projectId, input)), + deleteDocument: ({ collection, document_id, if_revision, confirmed }, trustedProjectPath) => confirmed === true + ? withActive(trustedProjectPath, (projectId) => client.deleteDocument(projectId, { collection, document_id, if_revision })) : Promise.resolve(confirmationRequired()), - removeCollection: ({ collection, confirmed }) => confirmed === true - ? withActive((projectId) => client.removeCollection(projectId, collection)) + removeCollection: ({ collection, confirmed }, trustedProjectPath) => confirmed === true + ? withActive(trustedProjectPath, (projectId) => client.removeCollection(projectId, collection)) : Promise.resolve(confirmationRequired()), - reset: ({ confirmed }) => confirmed === true - ? withActive((projectId) => client.reset(projectId)) + reset: ({ confirmed }, trustedProjectPath) => confirmed === true + ? withActive(trustedProjectPath, (projectId) => client.reset(projectId)) : Promise.resolve(confirmationRequired()), - removeProject: ({ confirmed }) => confirmed === true - ? withActive((projectId) => client.removeProject(projectId)) + removeProject: ({ confirmed }, trustedProjectPath) => confirmed === true + ? withActive(trustedProjectPath, (projectId) => client.removeProject(projectId)) : Promise.resolve(confirmationRequired()), }; } diff --git a/shared/coding-conversation-contracts.ts b/shared/coding-conversation-contracts.ts index 29ac76f..75a531e 100644 --- a/shared/coding-conversation-contracts.ts +++ b/shared/coding-conversation-contracts.ts @@ -1,3 +1,5 @@ +import type { DataServiceToolDetailsV1 } from './data-service'; + export type ConversationThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high'; export interface ProductModelRef { @@ -192,6 +194,8 @@ export interface RuntimeContextDetailsV1 { }>; } +export type { DataServiceToolDetailsV1 } from './data-service'; + export interface SubagentDetailsV1 { schema: 'subagent.v1'; dispatchId: string; @@ -214,6 +218,7 @@ export type KnownToolDetails = | AgentBrowserDetailsV1 | GameAssetsDetailsV1 | RuntimeContextDetailsV1 + | DataServiceToolDetailsV1 | SubagentDetailsV1; export interface ConversationToolNode { diff --git a/shared/coding-conversation-product-tool-protocol.ts b/shared/coding-conversation-product-tool-protocol.ts index 396f5ab..98a5df9 100644 --- a/shared/coding-conversation-product-tool-protocol.ts +++ b/shared/coding-conversation-product-tool-protocol.ts @@ -1,11 +1,18 @@ import type { AgentBrowserDetailsV1, ChangedFileDetailsV1, + DataServiceToolDetailsV1, GameAssetsDetailsV1, KnownToolDetails, RuntimeContextDetailsV1, TaskStateDetailsV1, } from './coding-conversation-contracts'; +import { + DATA_SERVICE_PI_TOOL_NAMES, + type DataServiceErrorContext, + type DataServiceToolData, + type DataServicePiToolName, +} from './data-service'; const PRODUCT_TOOL_NAMES = new Set([ 'agent_browser', @@ -14,8 +21,21 @@ const PRODUCT_TOOL_NAMES = new Set([ 'task_state', 'changed_file', 'runtime_context', + ...DATA_SERVICE_PI_TOOL_NAMES, ]); +const DATA_SERVICE_CONTEXT_KEYS = new Set([ + 'resource', + 'limit', + 'current', + 'attempted', + 'actual', + 'allowed', + 'current_revision', + 'retry_after_seconds', +]); +const DATA_SERVICE_CONTEXT_RESOURCES = new Set(['instances', 'collections', 'documents', 'bytes']); + function record(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record @@ -133,6 +153,63 @@ function runtimeContextDetails(value: Record): RuntimeContextDe return { schema: 'runtime-context.v1', skills, commands }; } +function dataServiceContext(value: unknown): DataServiceErrorContext | undefined | null { + if (value === undefined) return undefined; + const source = record(value); + if (!source || Object.keys(source).length > 8) return null; + const context: DataServiceErrorContext = {}; + for (const [key, item] of Object.entries(source)) { + if (!DATA_SERVICE_CONTEXT_KEYS.has(key)) return null; + if (key === 'resource') { + if (typeof item !== 'string' || !DATA_SERVICE_CONTEXT_RESOURCES.has(item)) return null; + context[key] = item; + } else if (Number.isSafeInteger(item) && (item as number) >= 0) { + context[key] = item as number; + } else { + return null; + } + } + return Object.keys(context).length > 0 ? context : undefined; +} + +function dataServiceDetails(value: Record): DataServiceToolDetailsV1 | null { + if (!DATA_SERVICE_PI_TOOL_NAMES.includes(value.operation as DataServicePiToolName)) return null; + if (typeof value.success !== 'boolean' + || !Number.isSafeInteger(value.status) + || (value.status as number) < 100 || (value.status as number) > 599 + || (value.code !== null && text(value.code, 128) === null) + || (value.error !== null && text(value.error, 2_000) === null) + || typeof value.retryable !== 'boolean' + || !Object.prototype.hasOwnProperty.call(value, 'data')) return null; + if (value.success) { + if ((value.status as number) < 200 || (value.status as number) > 299 + || value.code !== null || value.error !== null || value.retryable !== false) return null; + } else if ((value.status as number) < 400 || value.code === null || value.error === null) { + return null; + } + const retryAfter = value.retry_after_seconds; + if (retryAfter !== undefined + && (!Number.isSafeInteger(retryAfter) || (retryAfter as number) < 0 || (retryAfter as number) > 86_400)) { + return null; + } + const context = dataServiceContext(value.context); + if (context === null) return null; + const data = value.data === null ? null : record(value.data); + if (value.data !== null && !data) return null; + return { + schema: 'data-service.v1', + operation: value.operation as DataServicePiToolName, + success: value.success, + status: value.status as number, + code: value.code as string | null, + error: value.error as string | null, + retryable: value.retryable, + ...(retryAfter === undefined ? {} : { retry_after_seconds: retryAfter as number }), + ...(context === undefined ? {} : { context }), + data: data as DataServiceToolData | null, + }; +} + export function productToolDetails(value: unknown): Exclude | null { const details = record(value); if (!details) return null; @@ -144,6 +221,7 @@ export function productToolDetails(value: unknown): Exclude; +export const DATA_SERVICE_PI_TOOL_NAMES = [ + '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', +] as const; + +export type DataServicePiToolName = (typeof DATA_SERVICE_PI_TOOL_NAMES)[number]; + export type DataServiceHostResult = { success: boolean; status: number; @@ -73,6 +88,19 @@ export type DataServiceHostResult = { data: T | null; }; +export type DataServiceToolData = + | DataServiceInstanceState + | DataServiceInstanceList + | DataServiceDocument + | DataServiceDocumentList + | DataServiceCollectionRemoval + | DataServiceInstanceRemoval; + +export type DataServiceToolDetailsV1 = DataServiceHostResult & { + schema: 'data-service.v1'; + operation: DataServicePiToolName; +}; + export type DataServicePutDocumentInput = { collection: string; document_id: string; diff --git a/src/pages/Chat/CodingConversationTimeline.tsx b/src/pages/Chat/CodingConversationTimeline.tsx index 892ec39..1ba8ee6 100644 --- a/src/pages/Chat/CodingConversationTimeline.tsx +++ b/src/pages/Chat/CodingConversationTimeline.tsx @@ -303,6 +303,16 @@ const ToolDetails = memo(function ToolDetails({ details }: { details: KnownToolD ); } + if (details.schema === 'data-service.v1') { + return ( +
+

数据服务 · {details.operation}

+

+ {details.success ? '成功' : details.error ?? '请求失败'} · HTTP {details.status} +

+
+ ); + } return ; }); diff --git a/tests/unit/pi-extension-bundle.test.ts b/tests/unit/pi-extension-bundle.test.ts index dbc68ac..962d87d 100644 --- a/tests/unit/pi-extension-bundle.test.ts +++ b/tests/unit/pi-extension-bundle.test.ts @@ -15,9 +15,24 @@ import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools'; type ExtensionHandler = (...arguments_: unknown[]) => Promise | unknown; type ExtensionTool = { name: string; + parameters?: Record; execute?: (...arguments_: unknown[]) => Promise; }; +async function post( + registration: Awaited>, + body: Record, +): Promise { + return await fetch(registration.env.MAKELORE_PI_BRIDGE_URL as string, { + method: 'POST', + headers: { + authorization: `Bearer ${registration.env.MAKELORE_PI_WORKER_TOKEN}`, + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }); +} + const roots: string[] = []; const hosts: PiManagedExtensionHost[] = []; @@ -209,7 +224,26 @@ describe('Makelore Pi extension bundle', () => { expect([...tools.keys()]).toEqual([ 'ask_user', 'subagent', 'agent_browser', 'game_asset_browser', 'game_asset_review', 'task_state', 'changed_file', 'runtime_context', + 'data_service_configure', 'data_service_inspect', 'data_service_list_projects', + 'data_service_get_document', 'data_service_list_documents', 'data_service_put_document', + 'data_service_delete_document', 'data_service_remove_collection', 'data_service_reset', + 'data_service_remove_project', ]); + const dataServiceTools = [ + 'data_service_configure', 'data_service_inspect', 'data_service_list_projects', + 'data_service_get_document', 'data_service_list_documents', 'data_service_put_document', + 'data_service_delete_document', 'data_service_remove_collection', 'data_service_reset', + 'data_service_remove_project', + ]; + for (const name of dataServiceTools) { + const tool = tools.get(name); + expect(tool?.parameters).toMatchObject({ + type: 'object', additionalProperties: false, + }); + expect(Object.keys(tool?.parameters?.properties ?? {})).not.toEqual( + expect.arrayContaining(['owner', 'project', 'path', 'token', 'endpoint', 'url', 'handle']), + ); + } const updates: unknown[] = []; const subagentResult = await tools.get('subagent')?.execute?.( @@ -317,6 +351,12 @@ describe('Makelore Pi extension bundle', () => { on: (event, handler) => handlers.set(event, handler), }); expect(tools).toEqual([]); + const forgedParentTool = await post(child, { + action: 'product.invoke', conversationId: 'conversation-child', workerGeneration: 1, + runId: 'run-parent', resourceId: 'forged-data-tool', + toolName: 'data_service_inspect', input: {}, + }); + expect(forgedParentTool.status).toBe(403); await writeFile(path.join(root, 'child.txt'), 'after\n', 'utf8'); await handlers.get('tool_call')?.({ toolName: 'write', toolCallId: 'child-write', input: { path: 'child.txt' }, diff --git a/tests/unit/pi-product-tools.test.ts b/tests/unit/pi-product-tools.test.ts index 229f86b..0c6cbc2 100644 --- a/tests/unit/pi-product-tools.test.ts +++ b/tests/unit/pi-product-tools.test.ts @@ -5,7 +5,7 @@ import { mkdtemp, mkdir, rm, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { AgentBrowserModule } from '../../electron/agent-browser'; import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store'; import { @@ -18,6 +18,7 @@ import { } from '../../electron/coding-projects/skill-registry'; import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools'; import { productToolDetails } from '../../electron/coding-runtime/product-tool-protocol'; +import type { DataServiceOperations } from '../../electron/services/data-service-client'; const exec = promisify(execFile); const roots: string[] = []; @@ -231,6 +232,36 @@ describe('PI-090 product tools', () => { schema: 'agent-browser.v1', action: 'send_cdp', attachmentId: 'attachment-a', })).toBeNull(); expect(productToolDetails({ schema: 'task-state.v1', tasks: [] })).toBeNull(); + expect(productToolDetails({ + schema: 'data-service.v1', + operation: 'data_service_inspect', + success: true, + status: 200, + code: null, + error: null, + retryable: false, + data: { instance_id: 'instance-a' }, + owner: 'must-be-dropped', + })).toEqual({ + schema: 'data-service.v1', + operation: 'data_service_inspect', + success: true, + status: 200, + code: null, + error: null, + retryable: false, + data: { instance_id: 'instance-a' }, + }); + expect(productToolDetails({ + schema: 'data-service.v1', + operation: 'data_service_unknown', + success: true, + status: 200, + code: null, + error: null, + retryable: false, + data: null, + })).toBeNull(); }); it('stores browser screenshots as attachment ids and never returns base64', async () => { @@ -288,4 +319,108 @@ describe('PI-090 product tools', () => { expect(JSON.stringify(result)).not.toContain(root); expect(JSON.stringify(result)).not.toContain('data:'); }); + + it('dispatches all Data Service tools through the shared adapter and trusted project path', async () => { + const root = await temporaryRoot('makelore-pi-data-tools-'); + const response = (data: unknown) => ({ + success: true, status: 200, code: null, error: null, retryable: false, data, + }); + const dataService = { + configure: vi.fn().mockResolvedValue(response({ configured: true })), + inspect: vi.fn().mockResolvedValue(response({ instance_id: 'instance-a' })), + listProjects: vi.fn().mockResolvedValue(response({ items: [], total: 0, instance_limit: 20 })), + getDocument: vi.fn().mockResolvedValue(response({ id: 'one', data: {}, revision: 1 })), + listDocuments: vi.fn().mockResolvedValue(response({ items: [], next_cursor: null, limit: 50 })), + putDocument: vi.fn().mockResolvedValue(response({ id: 'one', data: {}, revision: 1 })), + deleteDocument: vi.fn().mockResolvedValue(response(null)), + removeCollection: vi.fn().mockResolvedValue(response({ removed: true, usage: { document_count: 0, total_bytes: 0 } })), + reset: vi.fn().mockResolvedValue(response({ instance_id: 'instance-a' })), + removeProject: vi.fn().mockResolvedValue(response({ removed: true })), + } as unknown as DataServiceOperations; + const tools = new PiProductTools({ + browser: {} as AgentBrowserModule, + attachments: new CodingAttachmentStore(path.join(root, 'attachments')), + bundledSkillsDir: path.resolve('resources/coding-skills'), + dataService, + }); + const context = { + conversationId: 'conversation-a', runId: 'run-a', resourceId: 'resource-a', + projectId: 'local-project-a', projectPath: root, skillIds: [], + }; + + await tools.execute('data_service_configure', context, { collections: ['todos'] }); + await tools.execute('data_service_inspect', context, {}); + await tools.execute('data_service_list_projects', context, {}); + await tools.execute('data_service_get_document', context, { + collection: 'todos', document_id: 'one', + }); + await tools.execute('data_service_list_documents', context, { + collection: 'todos', limit: 50, cursor: 'cursor-a', + }); + await tools.execute('data_service_put_document', context, { + collection: 'todos', document_id: 'one', data: { done: false }, if_revision: 1, + }); + await tools.execute('data_service_delete_document', context, { + collection: 'todos', document_id: 'one', if_revision: 1, confirmed: true, + }); + await tools.execute('data_service_remove_collection', context, { + collection: 'todos', confirmed: true, + }); + await tools.execute('data_service_reset', context, { confirmed: true }); + const removed = await tools.execute('data_service_remove_project', context, { confirmed: true }); + + expect(dataService.configure).toHaveBeenCalledWith({ collections: ['todos'] }, root); + expect(dataService.inspect).toHaveBeenCalledWith(root); + expect(dataService.listProjects).toHaveBeenCalledWith(); + expect(dataService.getDocument).toHaveBeenCalledWith({ collection: 'todos', document_id: 'one' }, root); + expect(dataService.listDocuments).toHaveBeenCalledWith({ + collection: 'todos', limit: 50, cursor: 'cursor-a', + }, root); + expect(dataService.putDocument).toHaveBeenCalledWith({ + collection: 'todos', document_id: 'one', data: { done: false }, if_revision: 1, + }, root); + expect(dataService.deleteDocument).toHaveBeenCalledWith({ + collection: 'todos', document_id: 'one', if_revision: 1, confirmed: true, + }, root); + expect(dataService.removeCollection).toHaveBeenCalledWith({ collection: 'todos', confirmed: true }, root); + expect(dataService.reset).toHaveBeenCalledWith({ confirmed: true }, root); + expect(dataService.removeProject).toHaveBeenCalledWith({ confirmed: true }, root); + expect(removed.details).toMatchObject({ + schema: 'data-service.v1', operation: 'data_service_remove_project', + success: true, status: 200, data: { removed: true }, + }); + expect(JSON.stringify(removed)).not.toContain(root); + }); + + it('rejects forbidden tool fields and destructive calls without literal confirmation', async () => { + const root = await temporaryRoot('makelore-pi-data-input-'); + const dataService = { + inspect: vi.fn().mockResolvedValue({ + success: true, status: 200, code: null, error: null, retryable: false, data: null, + }), + removeProject: vi.fn(), + } as unknown as DataServiceOperations; + const tools = new PiProductTools({ + browser: {} as AgentBrowserModule, + attachments: new CodingAttachmentStore(path.join(root, 'attachments')), + bundledSkillsDir: path.resolve('resources/coding-skills'), + dataService, + }); + const context = { + conversationId: 'conversation-a', runId: 'run-a', resourceId: 'resource-a', + projectId: 'local-project-a', projectPath: root, skillIds: [], + }; + + await expect(tools.execute('data_service_inspect', context, { owner: 'owner-a' })).rejects.toThrow( + 'Data Service tool input is invalid', + ); + await expect(tools.execute('data_service_put_document', context, { + collection: 'todos', document_id: 'one', data: {}, path: root, + })).rejects.toThrow('Data Service tool input is invalid'); + await expect(tools.execute('data_service_remove_project', context, { confirmed: false })).rejects.toThrow( + 'Data Service tool input is invalid', + ); + expect(dataService.inspect).not.toHaveBeenCalled(); + expect(dataService.removeProject).not.toHaveBeenCalled(); + }); });