diff --git a/.project-docs/30-worklog/tasks/20260827-plugin-ml02-policy-registry-3f7b2c91.md b/.project-docs/30-worklog/tasks/20260827-plugin-ml02-policy-registry-3f7b2c91.md new file mode 100644 index 0000000..b42e42b --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260827-plugin-ml02-policy-registry-3f7b2c91.md @@ -0,0 +1,118 @@ +# Task: Implement ML-02 plugin policy registry + +## Identity + +- Task ID: 20260827-plugin-ml02-policy-registry-3f7b2c91 +- Mode: Feature +- Branch: codex/20260827-plugin-ml02-policy-registry-3f7b2c91-plugin-ml02-policy-registry +- Worktree: D:\Datas\OthersProjects\makelore-plugin-ml02-policy-registry-3f7b2c91 +- Base commit: d9c9a2b0dd8fd495c6aa5a0994598192ad9c8e58 +- Owner: codex-ml02 +- Status: Ready for Integration + +## Scope + +- Implement ML-02 from exact frontier `d9c9a2b0dd8fd495c6aa5a0994598192ad9c8e58`: + - strict policy catalog client with exact parsing, join, coalesced refresh, and + current/stale/unavailable state; + - capability registry and trusted `makelore-capability.v1` envelope; + - in-process Data Service adapter and generic Pi product-tool delegation; + - bounded capability/billing/conversation contracts and ten-tool regression + coverage. +- Owned product/test files are exactly the ML-02 ticket list, plus the one-time + coordinator-approved ownership transfer for the envelope display branch in + `src/pages/Chat/CodingConversationTimeline.tsx`; this task record is the only + project-document file owned by this task. + +## Intent And Constraints + +- Follow implementation spec sections 5.3 and 7.1, 7.3-7.4 and ticket ML-02. +- Consume ML-01 exported definitions/project-service seams and the frozen WS-02 + catalog DTO. Do not modify worker materialization/CLI, Host routes, preview + sessions, other Renderer files, or files owned by other tickets. The + coordinator explicitly transferred only the `ToolDetails` envelope + discriminator/display branch in `CodingConversationTimeline.tsx` to ML-02. +- Preserve typed Data Service routes and Main-owned identity/credentials. No + generic invoke/config/ledger, P1 paid policy, Plugin Credits, marketplace, + arbitrary code/MCP/hooks, or publication coupling. +- Use `runId` + `resourceId` for deterministic Pi request IDs with no UUID fallback; + client code cannot manufacture server billing receipts and included calls make + no Token Point transaction. + +## Project Context Loaded + +- Concurrent Task Gate passed after `check_project_docs.py`; task identity, + isolated worktree, branch, owner, and base match the Git-common owner record. +- Planning Gate passed. Required entry, positioning, integrated state, decisions, + architecture/data-flow, domain, evidence, reflection, commitments, and stale + context were read. The positioning file is still the repository template, and + integrated memory predates this plugin feature; the accepted implementation + spec, ticket graph, ML-01 peer record, and coordinator frontier are authoritative + for this task. +- Other same-repository active scopes are the client coordinator and ML-01 source + task. ML-01 is ready for integration and owns package/selection files only; + client coordinator owns integration and later tickets. The initial Renderer + ownership conflict was explicitly resolved by a one-file, one-branch transfer + from the coordinator; no remaining semantic conflict or file overlap exists. +- Cross-repository WS-02 is consumed as a frozen input: schema version 1, one + `makelore.data-service` plugin, three capabilities/fourteen operations, and + `platform_metered` unavailable projected as `billing_unavailable`. + +## Implementation Plan + +1. Inspect existing Data Service/Pi/conversation seams and ML-01 definitions. +2. Add contract types, strict parser/client state, registry/adaptor, and generic + Pi delegation with tests first where practical. +3. Migrate conversation detail/result unions and the transferred timeline + consumer to the bounded capability envelope while preserving + `data-service.v1` only as the payload schema and retaining whitelisted context. +4. Run owned focused tests, relevant existing regressions, typecheck, scoped/full + lint, diff/doc gates; update this record and produce one implementation commit. + +## Outcome + +- Implemented the strict Plugin Policy catalog client with exact DTO parsing, + coalesced refresh, and current/stale/unavailable state preservation. +- Implemented the bundled capability registry with exact policy joins, + package/selection/Skill/role/policy admission and invoke revalidation, child + plugin-empty behavior, and trusted `makelore-capability.v1` envelopes. +- Added the in-process Data Service adapter for all ten contract operations and + moved Pi product-tool delegation to the registry while preserving core tools + and the compatibility fallback. +- Added bounded billing receipts, result/conversation contracts, deterministic + `pi:${runId}:${resourceId}` request IDs, domain-fault context handling, and + focused regression coverage. +- No P1 paid policy, generic invoke/config/ledger, credits, marketplace, + arbitrary code/MCP/hooks, publication coupling, or out-of-scope file changes + were introduced. The coordinator-approved one-file Renderer ownership + transfer was used only for the envelope discriminator/display adaptation. + +## Verification + +- Owned focused suite: 5 files, 38 passed. +- Transferred Renderer timeline focused suite: 1 file, 4 passed. +- Relevant existing regressions: 8 files, 62 passed, 2 skipped. +- Full test command: 193 files, 1,642 passed, 2 skipped; isolated pressure + test: 1 passed. +- Owned-file ESLint (including the transferred timeline) and full `lint:check`: + 0 errors; full lint reports only + the 5 pre-existing warnings in `src/pages/Home/index.tsx` and + `src/pages/Makelore/index.tsx`. +- `git diff --check` passed. +- Typecheck passed after replacing the transferred Renderer consumer's stale + `data-service.v1` discriminator with the bounded + `makelore-capability.v1` envelope branch. +- Task-aware documentation drift check and `task_context complete` remain + completion gates. + +## Follow-ups + +- No downstream follow-up remains for the transferred timeline branch: it now + consumes `makelore-capability.v1`, while `data-service.v1` remains only the + nested payload schema for the Data Service adapter. + +## Promotion Candidates + +- None. The implementation follows the accepted plugin-platform spec and + frozen WS-02 catalog contract without proposing canonical project-memory + changes. diff --git a/electron/coding-plugins/adapters/data-service.ts b/electron/coding-plugins/adapters/data-service.ts new file mode 100644 index 0000000..21acf5f --- /dev/null +++ b/electron/coding-plugins/adapters/data-service.ts @@ -0,0 +1,306 @@ +import type { + DataServiceCollectionRemoval, + DataServiceDocument, + DataServiceDocumentList, + DataServiceErrorContext, + DataServiceHostResult, + DataServiceInstanceList, + DataServiceInstanceRemoval, + DataServiceInstanceState, +} from '../../../shared/data-service'; +import { + DATA_SERVICE_PLUGIN_ID, + DATA_SERVICE_TOOL_DEFINITIONS, + type CodingPluginToolDefinition, +} from '../../../shared/coding-plugins'; +import type { DataServiceOperations } from '../../services/data-service-client'; +import type { + AdapterInvocationResult, + CodingPluginAdapter, + PluginBackendProjection, + TrustedCodingCapabilityContext, +} from '../registry'; + +const MAX_REQUEST_BYTES = 98_304; +const CONTEXT_KEYS = new Set([ + 'resource', 'limit', 'current', 'attempted', 'actual', 'current_revision', +]); +const CONTEXT_RESOURCES = new Set(['instances', 'collections', 'documents', 'bytes']); + +type InputRecord = Record; + +function isRecord(value: unknown): value is InputRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function boundedContext(value: DataServiceErrorContext | undefined): DataServiceErrorContext | undefined { + if (!value) return undefined; + const projected: DataServiceErrorContext = {}; + for (const [key, item] of Object.entries(value)) { + if (!CONTEXT_KEYS.has(key)) continue; + if (key === 'resource') { + if (typeof item !== 'string' || !CONTEXT_RESOURCES.has(item) || item.length > 32) return undefined; + projected[key] = item; + } else if (Number.isSafeInteger(item) && (item as number) >= 0) { + projected[key] = item as number; + } + } + return Object.keys(projected).length > 0 ? projected : undefined; +} + +function validRetryAfter(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= 86_400; +} + +function boundedError(error: unknown, fallback: string): string { + return typeof error === 'string' && error.trim().length > 0 && error.length <= 2_000 + ? error + : fallback; +} + +function failure( + code: string, + error: string, + status = 422, + retryable = false, +): AdapterInvocationResult { + return { + success: false, + status, + code, + error, + retryable, + payload_schema: 'data-service.v1', + data: null, + }; +} + +function projectResult(result: DataServiceHostResult): AdapterInvocationResult { + if (result.success) { + return { + success: true, + status: result.status, + code: null, + error: null, + retryable: false, + payload_schema: 'data-service.v1', + data: result.data, + }; + } + const context = boundedContext(result.context); + return { + success: false, + status: result.status, + code: result.code ?? 'plugin_backend_unavailable', + error: boundedError(result.error, 'Data Service request was rejected'), + retryable: result.retryable, + ...(validRetryAfter(result.retry_after_seconds) + ? { retry_after_seconds: result.retry_after_seconds } + : {}), + ...(context ? { context } : {}), + payload_schema: 'data-service.v1', + data: null, + }; +} + +function schemaValid(value: unknown, schema: Readonly>): boolean { + if (schema.type === 'object') { + if (!isRecord(value)) return false; + const properties = isRecord(schema.properties) ? schema.properties : {}; + if (schema.additionalProperties === false + && Object.keys(value).some((key) => !Object.prototype.hasOwnProperty.call(properties, key))) { + return false; + } + const required = Array.isArray(schema.required) ? schema.required : []; + if (required.some((key) => typeof key !== 'string' || !Object.prototype.hasOwnProperty.call(value, key))) { + return false; + } + return Object.entries(value).every(([key, item]) => { + const child = properties[key]; + return !child || (isRecord(child) && schemaValid(item, child)); + }); + } + if (schema.type === 'array') { + if (!Array.isArray(value)) return false; + if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) return false; + const items = isRecord(schema.items) ? schema.items : null; + return !items || value.every((item) => schemaValid(item, items)); + } + if (schema.type === 'string') { + if (typeof value !== 'string') return false; + if (typeof schema.minLength === 'number' && value.length < schema.minLength) return false; + if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) return false; + if (typeof schema.pattern === 'string' && !(new RegExp(schema.pattern, 'u')).test(value)) return false; + return true; + } + if (schema.type === 'integer') { + if (!Number.isSafeInteger(value)) return false; + if (typeof schema.minimum === 'number' && (value as number) < schema.minimum) return false; + if (typeof schema.maximum === 'number' && (value as number) > schema.maximum) return false; + return true; + } + if (schema.type === 'boolean') return typeof value === 'boolean' + && (schema.const === undefined || value === schema.const); + return true; +} + +function boundedDocumentData(value: unknown): boolean { + if (!isRecord(value)) return false; + try { + const encoded = JSON.stringify({ data: value }); + return typeof encoded === 'string' && Buffer.byteLength(encoded, 'utf8') <= MAX_REQUEST_BYTES; + } catch { + return false; + } +} + +function validateInput(tool: CodingPluginToolDefinition, input: unknown): boolean { + if (!schemaValid(input, tool.inputSchema)) return false; + if (!isRecord(input)) return false; + if (tool.name === 'data_service_put_document' && !boundedDocumentData(input.data)) return false; + return true; +} + +type DataServiceResult = + | DataServiceInstanceState + | DataServiceInstanceList + | DataServiceDocument + | DataServiceDocumentList + | DataServiceCollectionRemoval + | DataServiceInstanceRemoval + | null; + +function toolByName(toolName: string): CodingPluginToolDefinition | undefined { + return DATA_SERVICE_TOOL_DEFINITIONS.find(({ name }) => name === toolName); +} + +async function callDataService( + operations: DataServiceOperations, + toolName: string, + input: InputRecord, + projectPath: string, +): Promise> { + switch (toolName) { + case 'data_service_configure': + return await operations.configure({ collections: input.collections as string[] }, projectPath); + case 'data_service_inspect': + return await operations.inspect(projectPath); + case 'data_service_list_projects': + return await operations.listProjects(); + case 'data_service_get_document': + return await operations.getDocument({ + collection: input.collection as string, + document_id: input.document_id as string, + }, projectPath); + case 'data_service_list_documents': + return await operations.listDocuments({ + collection: input.collection as string, + ...(input.limit === undefined ? {} : { limit: input.limit as number }), + ...(input.cursor === undefined ? {} : { cursor: input.cursor as string }), + }, projectPath); + case 'data_service_put_document': + return await operations.putDocument({ + collection: input.collection as string, + document_id: input.document_id as string, + data: input.data as Record, + ...(input.if_revision === undefined ? {} : { if_revision: input.if_revision as number }), + }, projectPath); + case 'data_service_delete_document': + return await operations.deleteDocument({ + collection: input.collection as string, + document_id: input.document_id as string, + ...(input.if_revision === undefined ? {} : { if_revision: input.if_revision as number }), + confirmed: true, + }, projectPath); + case 'data_service_remove_collection': + return await operations.removeCollection({ + collection: input.collection as string, + confirmed: true, + }, projectPath); + case 'data_service_reset': + return await operations.reset({ confirmed: true }, projectPath); + case 'data_service_remove_project': + return await operations.removeProject({ confirmed: true }, projectPath); + default: + throw new Error('Data Service tool is unavailable'); + } +} + +function projection(result: DataServiceHostResult): PluginBackendProjection { + if (result.success) return { status: 'ready' }; + switch (result.code) { + case 'authentication_required': return { status: 'authentication_required' }; + case 'project_identity_required': return { status: 'identity_required' }; + case 'active_project_required': + case 'instance_not_found': return { status: 'unconfigured' }; + case 'active_project_path_mismatch': + case 'active_project_invalid': return { status: 'degraded', code: result.code, message: 'Data Service project is unavailable', retryable: false }; + default: + return { + status: 'degraded', + code: result.code ?? 'plugin_backend_unavailable', + message: 'Data Service is temporarily unavailable', + retryable: result.retryable, + ...(validRetryAfter(result.retry_after_seconds) + ? { retry_after_seconds: result.retry_after_seconds } + : {}), + }; + } +} + +export class DataServicePluginAdapter implements CodingPluginAdapter { + readonly pluginId = DATA_SERVICE_PLUGIN_ID; + + constructor(private readonly operations: DataServiceOperations) {} + + async inspect(projectPath: string): Promise { + try { + return projection(await this.operations.inspect(projectPath)); + } catch { + return { + status: 'degraded', + code: 'plugin_backend_unavailable', + message: 'Data Service is temporarily unavailable', + retryable: true, + }; + } + } + + async invoke( + _context: TrustedCodingCapabilityContext, + tool: CodingPluginToolDefinition, + input: unknown, + ): Promise> { + const canonicalTool = toolByName(tool.name); + if (!canonicalTool || !validateInput(canonicalTool, input)) { + return failure('plugin_input_invalid', 'Data Service tool input is invalid'); + } + try { + return projectResult(await callDataService( + this.operations, + canonicalTool.name, + input as InputRecord, + _context.projectPath, + )); + } catch { + return { + success: false, + status: 503, + code: 'plugin_backend_unavailable', + error: 'Data Service is temporarily unavailable', + retryable: true, + payload_schema: 'data-service.v1', + data: null, + }; + } + } + + async deactivate(_projectPath: string): Promise { + // Data Service deactivation invalidates preview sessions in the dedicated + // lifecycle owner; this adapter owns no independent process or token. + } +} + +export const createDataServicePluginAdapter = ( + operations: DataServiceOperations, +): DataServicePluginAdapter => new DataServicePluginAdapter(operations); diff --git a/electron/coding-plugins/registry.ts b/electron/coding-plugins/registry.ts new file mode 100644 index 0000000..a5fbd3e --- /dev/null +++ b/electron/coding-plugins/registry.ts @@ -0,0 +1,549 @@ +import { + BUNDLED_CODING_PLUGIN_DEFINITIONS, + type CodingPluginDefinition, + type CodingPluginToolDefinition, + type PluginBillingMode, +} from '../../shared/coding-plugins'; +import { CORE_CODING_SKILL_IDS } from '../../shared/coding-skills'; +import type { + CapabilityBillingReceiptV1, + CapabilityResultV1, +} from '../../shared/data-service'; +import type { PiProductToolContext, PiProductToolResult } from '../coding-runtime/pi/product-tools'; +import type { + PluginCatalog, + PluginCatalogOperation, + PluginPolicyClientState, +} from '../services/plugin-policy-client'; + +const MAX_REQUEST_ID = 128; +const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,47}$/u; +const CAPABILITY_ID_PATTERN = /^[a-z][a-z0-9.-]{0,63}$/u; +const OPERATION_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u; +const VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.+-]{0,63}$/u; +const DECIMAL_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d+)?$/u; +const CONTEXT_KEYS = new Set([ + 'resource', 'limit', 'current', 'attempted', 'actual', 'current_revision', +]); +const CONTEXT_RESOURCES = new Set(['instances', 'collections', 'documents', 'bytes']); + +export interface TrustedCodingCapabilityContext { + conversationId: string; + runId: string; + resourceId: string; + requestId: string; + localProjectId: string; + projectPath: string; + durableProjectId: string; + workerRole: 'parent' | 'child'; + effectiveSkillIds: readonly string[]; +} + +export type PluginBackendProjection = + | { status: 'not_required' } + | { status: 'identity_required' } + | { status: 'authentication_required' } + | { status: 'unconfigured' } + | { status: 'ready' } + | { + status: 'degraded'; + code: string; + message: string; + retryable: boolean; + retry_after_seconds?: number; + }; + +export type AdapterInvocationResult = + | { + success: true; + status: number; + code: null; + error: null; + retryable: false; + payload_schema: string; + data: T | null; + billing?: CapabilityBillingReceiptV1; + } + | { + success: false; + status: number; + code: string; + error: string; + retryable: boolean; + retry_after_seconds?: number; + context?: Readonly>; + payload_schema: string; + data: null; + billing?: CapabilityBillingReceiptV1; + }; + +export interface CodingPluginAdapter { + readonly pluginId: string; + inspect(projectPath: string): Promise; + invoke( + context: TrustedCodingCapabilityContext, + tool: CodingPluginToolDefinition, + input: unknown, + ): Promise; + deactivate?(projectPath: string): Promise; +} + +export interface ResolvedWorkerResources { + catalogRevision: number; + pluginIds: readonly string[]; + effectiveSkillIds: readonly string[]; + skillEntries: readonly { id: string; entryPath: string }[]; + tools: readonly CodingPluginToolDefinition[]; +} + +export interface CodingCapabilityRegistryPort { + resolveWorkerResources(input: { + projectPath: string; + assignedSkillIds: readonly string[]; + role: 'parent' | 'child'; + }): Promise; + invoke(input: { + toolName: string; + context: PiProductToolContext; + workerRole: 'parent' | 'child'; + effectiveSkillIds: readonly string[]; + value: unknown; + }): Promise; +} + +export type CodingCapabilityRegistry = CodingCapabilityRegistryPort; + +export interface CodingCapabilityRegistryOptions { + policyClient: Pick<{ getState(): PluginPolicyClientState }, 'getState'>; + getEnabledPluginIds?: (projectPath: string) => Promise; + projectPlugins?: { + getEnabledPluginIds(projectPath: string): Promise; + }; + adapters: readonly CodingPluginAdapter[]; + definitions?: readonly CodingPluginDefinition[]; + getDurableProjectId?: (projectPath: string, localProjectId: string) => Promise | string; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function boundedText(value: unknown, maximum: number, fallback: string): string { + return typeof value === 'string' && value.trim().length > 0 && value.length <= maximum + ? value + : fallback; +} + +function validRequestPart(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= MAX_REQUEST_ID; +} + +function requestId(context: PiProductToolContext): string { + const runId = context.runId; + const resourceId = context.resourceId; + const result = `pi:${runId}:${resourceId}`; + return validRequestPart(runId) && validRequestPart(resourceId) && result.length <= MAX_REQUEST_ID + ? result + : 'invalid-request-id'; +} + +function boundedStatus(value: unknown, fallback: number): number { + return Number.isSafeInteger(value) && (value as number) >= 100 && (value as number) <= 599 + ? value as number + : fallback; +} + +function boundedRetry(value: unknown): number | undefined { + return Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= 86_400 + ? value as number + : undefined; +} + +function boundedContext(value: unknown): Readonly> | undefined { + if (!isRecord(value)) return undefined; + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (!CONTEXT_KEYS.has(key)) continue; + if (key === 'resource') { + if (typeof item !== 'string' || item.length === 0 || item.length > 32 + || !CONTEXT_RESOURCES.has(item)) continue; + result[key] = item; + } else if (Number.isSafeInteger(item) && (item as number) >= 0) { + result[key] = item as number; + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + +function validDecimal(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= 32 + && DECIMAL_PATTERN.test(value); +} + +function validBillingReceipt(value: unknown): value is CapabilityBillingReceiptV1 { + if (!isRecord(value) || typeof value.mode !== 'string' || typeof value.status !== 'string') return false; + const keys = new Set(Object.keys(value)); + if (value.status === 'not_started') { + return keys.size === 2 && ['unknown', 'included', 'platform_metered', 'external_account'].includes(value.mode); + } + if (value.mode === 'included' && value.status === 'included') return keys.size === 2; + if (value.mode === 'external_account' && value.status === 'external') return keys.size === 2; + if (value.mode !== 'platform_metered' || !validDecimal(value.reserved_points)) return false; + const common = new Set(['mode', 'status', 'reserved_points', 'actual_points', 'usage_amount', 'unit']); + if (keys.size !== [...keys].filter((key) => common.has(key)).length) return false; + if (value.actual_points !== undefined && !validDecimal(value.actual_points)) return false; + if (value.usage_amount !== undefined + && (typeof value.usage_amount !== 'number' || !Number.isFinite(value.usage_amount) + || value.usage_amount < 0 || value.usage_amount > Number.MAX_SAFE_INTEGER)) return false; + if (value.unit !== undefined + && (typeof value.unit !== 'string' || value.unit.length === 0 || value.unit.length > 80)) return false; + const status = value.status; + if (['settled', 'refunded'].includes(status)) return value.actual_points !== undefined; + return ['reserved', 'dispatched', 'released', 'pending_review', 'expired'].includes(status); +} + +function schemaValid(value: unknown, schema: Readonly>): boolean { + if (schema.type === 'object') { + if (!isRecord(value)) return false; + const properties = isRecord(schema.properties) ? schema.properties : {}; + if (schema.additionalProperties === false + && Object.keys(value).some((key) => !Object.prototype.hasOwnProperty.call(properties, key))) return false; + const required = Array.isArray(schema.required) ? schema.required : []; + if (required.some((key) => typeof key !== 'string' || !Object.prototype.hasOwnProperty.call(value, key))) return false; + return Object.entries(value).every(([key, item]) => { + const child = properties[key]; + return !child || (isRecord(child) && schemaValid(item, child)); + }); + } + if (schema.type === 'array') { + if (!Array.isArray(value)) return false; + if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) return false; + const items = isRecord(schema.items) ? schema.items : undefined; + return !items || value.every((item) => schemaValid(item, items)); + } + if (schema.type === 'string') { + if (typeof value !== 'string') return false; + if (typeof schema.minLength === 'number' && value.length < schema.minLength) return false; + if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) return false; + if (typeof schema.pattern === 'string' && !(new RegExp(schema.pattern, 'u')).test(value)) return false; + return true; + } + if (schema.type === 'integer') { + if (!Number.isSafeInteger(value)) return false; + if (typeof schema.minimum === 'number' && (value as number) < schema.minimum) return false; + if (typeof schema.maximum === 'number' && (value as number) > schema.maximum) return false; + return true; + } + if (schema.type === 'boolean') { + return typeof value === 'boolean' && (schema.const === undefined || value === schema.const); + } + return true; +} + +function policyOperation( + catalog: PluginCatalog, + definition: CodingPluginDefinition, + tool: CodingPluginToolDefinition, +): PluginCatalogOperation | null { + const plugin = catalog.plugins.find(({ plugin_id }) => plugin_id === definition.id); + if (!plugin || plugin.status !== 'active' || !plugin.supported_contract_versions.includes(definition.contractVersion)) return null; + const capability = plugin.capabilities.find(({ capability_id }) => capability_id === tool.capabilityId); + return capability?.operations.find(({ operation }) => operation === tool.operation) ?? null; +} + +function definitionValid(definition: CodingPluginDefinition): boolean { + if (!PLUGIN_ID_PATTERN.test(definition.id) || !VERSION_PATTERN.test(definition.version) + || !Number.isSafeInteger(definition.contractVersion) || definition.contractVersion < 1 + || definition.scope !== 'project' || typeof definition.requiresBackend !== 'boolean') return false; + if (!definition.skills.length || !definition.tools.length) return false; + const skillIds = new Set(); + for (const skill of definition.skills) { + if (!skill.id || skillIds.has(skill.id) || !skill.entryPath || skill.grants.length === 0) return false; + skillIds.add(skill.id); + } + const toolNames = new Set(); + for (const tool of definition.tools) { + if (!tool.name || toolNames.has(tool.name) || tool.roles.length !== 1 || tool.roles[0] !== 'parent' + || !CAPABILITY_ID_PATTERN.test(tool.capabilityId) || !OPERATION_PATTERN.test(tool.operation) + || !isRecord(tool.inputSchema) || tool.inputSchema.type !== 'object' + || tool.inputSchema.additionalProperties !== false) return false; + toolNames.add(tool.name); + } + return true; +} + +function policyNotStarted(mode: PluginBillingMode | 'unknown'): CapabilityBillingReceiptV1 { + return { mode, status: 'not_started' }; +} + +function policyEntered(mode: PluginBillingMode): CapabilityBillingReceiptV1 { + if (mode === 'included') return { mode, status: 'included' }; + if (mode === 'external_account') return { mode, status: 'external' }; + return { mode, status: 'not_started' }; +} + +function resultDetails( + definition: CodingPluginDefinition, + tool: CodingPluginToolDefinition, + context: PiProductToolContext, + result: AdapterInvocationResult, + billing: CapabilityBillingReceiptV1, +): CapabilityResultV1 { + const id = requestId(context); + const rawStatus = boundedStatus(result.status, 502); + const success = result.success && rawStatus >= 200 && rawStatus <= 299; + const status = success ? rawStatus : rawStatus >= 400 ? rawStatus : 502; + const payloadSchema = boundedText(result.payload_schema, 128, 'unknown'); + const code = success ? null : result.success + ? 'plugin_backend_unavailable' + : boundedText(result.code, 128, 'plugin_backend_unavailable'); + const details: CapabilityResultV1 = { + schema: 'makelore-capability.v1', + plugin_id: definition.id, + plugin_version: definition.version, + capability_id: tool.capabilityId, + operation: tool.operation, + request_id: id, + success, + status, + code, + error: success ? null : boundedText(result.success ? null : result.error, 2_000, 'Plugin capability failed'), + retryable: success ? false : result.success ? true : result.retryable, + ...(success || result.success ? {} : (boundedRetry(result.retry_after_seconds) === undefined + ? {} : { retry_after_seconds: boundedRetry(result.retry_after_seconds) })), + ...(success || result.success ? {} : (boundedContext(result.context) === undefined + ? {} : { context: boundedContext(result.context) })), + billing, + payload_schema: payloadSchema, + data: success ? (result.data ?? null) : null, + }; + return details; +} + +export function buildCapabilityToolResult( + definition: CodingPluginDefinition, + tool: CodingPluginToolDefinition, + context: PiProductToolContext, + result: AdapterInvocationResult, + billing: CapabilityBillingReceiptV1 = { mode: 'included', status: 'included' }, +): PiProductToolResult { + const details = resultDetails(definition, tool, context, result, billing); + return { + content: [{ type: 'text', text: JSON.stringify(details) }], + details, + }; +} + +export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPort { + private readonly definitions: readonly CodingPluginDefinition[]; + private readonly toolsByName: ReadonlyMap; + private readonly adaptersByPluginId: ReadonlyMap; + + constructor(private readonly options: CodingCapabilityRegistryOptions) { + const definitions = (options.definitions ?? BUNDLED_CODING_PLUGIN_DEFINITIONS).filter(definitionValid); + this.definitions = Object.freeze([...definitions]); + const tools = new Map(); + for (const definition of this.definitions) { + for (const tool of definition.tools) { + if (!tools.has(tool.name)) tools.set(tool.name, { definition, tool }); + } + } + this.toolsByName = tools; + this.adaptersByPluginId = new Map(options.adapters.map((adapter) => [adapter.pluginId, adapter])); + } + + async resolveWorkerResources(input: { + projectPath: string; + assignedSkillIds: readonly string[]; + role: 'parent' | 'child'; + }): Promise { + const assigned = [...new Set(input.assignedSkillIds)]; + const coreIds = new Set(CORE_CODING_SKILL_IDS); + const pluginSkillOwners = new Map(); + for (const definition of this.definitions) { + for (const skill of definition.skills) pluginSkillOwners.set(skill.id, definition); + } + for (const id of assigned) { + if (!coreIds.has(id) && !pluginSkillOwners.has(id)) throw new Error(`Unknown bundled coding skill: ${id}`); + } + const effectiveCoreSkills = assigned.filter((id) => coreIds.has(id)); + if (input.role === 'child') { + return { + catalogRevision: this.options.policyClient.getState().revision, + pluginIds: [], + effectiveSkillIds: effectiveCoreSkills, + skillEntries: effectiveCoreSkills.map((id) => ({ id, entryPath: `${id}/SKILL.md` })), + tools: [], + }; + } + const enabled = await this.enabledPluginIds(input.projectPath); + const state = this.options.policyClient.getState(); + const pluginIds: string[] = []; + const effectiveSkillIds = [...effectiveCoreSkills]; + const skillEntries = effectiveCoreSkills.map((id) => ({ id, entryPath: `${id}/SKILL.md` })); + const tools: CodingPluginToolDefinition[] = []; + for (const definition of this.definitions) { + if (!definition.requiresBackend || !enabled.includes(definition.id) + || !this.adaptersByPluginId.has(definition.id)) continue; + const pluginPolicy = state.catalog ? state.catalog.plugins.find(({ plugin_id }) => plugin_id === definition.id) : undefined; + if (!pluginPolicy || pluginPolicy.status !== 'active' || !pluginPolicy.supported_contract_versions.includes(definition.contractVersion)) continue; + pluginIds.push(definition.id); + const selectedSkills = definition.skills.filter(({ id }) => assigned.includes(id)); + if (selectedSkills.length === 0) continue; + for (const skill of selectedSkills) { + effectiveSkillIds.push(skill.id); + skillEntries.push({ id: skill.id, entryPath: skill.entryPath }); + } + for (const tool of definition.tools) { + const granted = selectedSkills.some(({ grants }) => grants.includes(tool.capabilityId)); + const policy = state.catalog ? policyOperation(state.catalog, definition, tool) : null; + const available = policy !== null && ( + policy.billing.mode !== 'platform_metered' + || !('status' in policy.billing && policy.billing.status === 'billing_unavailable') + ); + if (granted && available) { + tools.push(tool); + } + } + } + return { + catalogRevision: state.revision, + pluginIds, + effectiveSkillIds: [...new Set(effectiveSkillIds)], + skillEntries, + tools, + }; + } + + async invoke(input: { + toolName: string; + context: PiProductToolContext; + workerRole: 'parent' | 'child'; + effectiveSkillIds: readonly string[]; + value: unknown; + }): Promise { + const indexed = this.toolsByName.get(input.toolName); + const state = this.options.policyClient.getState(); + const validId = requestId(input.context) !== 'invalid-request-id'; + if (!indexed) return this.unknownResult(input.context, 'plugin_backend_unavailable', 'Plugin capability is unavailable'); + const { definition, tool } = indexed; + const currentEnabled = await this.enabledPluginIds(input.context.projectPath); + const catalogPolicy = state.catalog ? policyOperation(state.catalog, definition, tool) : null; + const baseBilling = catalogPolicy ? policyNotStarted(catalogPolicy.billing.mode) : policyNotStarted('unknown'); + if (!validId) return this.resultFailure(definition, tool, input.context, 'plugin_input_invalid', 'Pi request identity is invalid', 422, false, baseBilling); + if (input.workerRole !== 'parent' || !tool.roles.includes(input.workerRole)) { + return this.resultFailure(definition, tool, input.context, 'plugin_permission_denied', 'Plugin capability is not permitted for this worker', 403, false, baseBilling); + } + if (state.status === 'unavailable' || !state.catalog) { + return this.unknownResult(input.context, 'plugin_backend_unavailable', 'Plugin policy is unavailable'); + } + if (!currentEnabled.includes(definition.id)) { + return this.resultFailure(definition, tool, input.context, 'plugin_not_enabled', 'Plugin is not enabled for this project', 403, false, baseBilling); + } + if (!catalogPolicy) { + return this.resultFailure(definition, tool, input.context, 'plugin_contract_unsupported', 'Plugin capability is not supported by the server policy', 503, true, policyNotStarted('unknown')); + } + const selectedSkill = definition.skills.find(({ id }) => input.effectiveSkillIds.includes(id)); + if (!selectedSkill || !selectedSkill.grants.includes(tool.capabilityId)) { + return this.resultFailure(definition, tool, input.context, 'plugin_permission_denied', 'Plugin Skill grant is required', 403, false, baseBilling); + } + if (catalogPolicy.billing.mode === 'platform_metered' + && 'status' in catalogPolicy.billing && catalogPolicy.billing.status === 'billing_unavailable') { + return this.resultFailure(definition, tool, input.context, 'plugin_billing_unavailable', 'Plugin billing is unavailable', 503, true, baseBilling); + } + if (!schemaValid(input.value, tool.inputSchema)) { + return this.resultFailure(definition, tool, input.context, 'plugin_input_invalid', 'Plugin tool input is invalid', 422, false, baseBilling); + } + const adapter = this.adaptersByPluginId.get(definition.id); + if (!adapter) return this.resultFailure(definition, tool, input.context, 'plugin_backend_unavailable', 'Plugin adapter is unavailable', 503, true, baseBilling); + const durableProjectId = await this.options.getDurableProjectId?.(input.context.projectPath, input.context.projectId) + ?? input.context.projectId; + const trustedContext: TrustedCodingCapabilityContext = { + conversationId: input.context.conversationId, + runId: input.context.runId, + resourceId: input.context.resourceId, + requestId: requestId(input.context), + localProjectId: input.context.projectId, + projectPath: input.context.projectPath, + durableProjectId, + workerRole: input.workerRole, + effectiveSkillIds: [...input.effectiveSkillIds], + }; + let result: AdapterInvocationResult; + try { + result = await adapter.invoke(trustedContext, tool, input.value); + } catch { + return this.resultFailure(definition, tool, input.context, 'plugin_backend_unavailable', 'Plugin adapter is temporarily unavailable', 503, true, baseBilling); + } + let billing: CapabilityBillingReceiptV1; + if (catalogPolicy.billing.mode === 'platform_metered') { + if (!result.billing || !validBillingReceipt(result.billing) || result.billing.mode !== 'platform_metered') { + return this.resultFailure(definition, tool, input.context, 'plugin_billing_unavailable', 'Plugin billing receipt is unavailable', 503, true, policyNotStarted('platform_metered')); + } + billing = result.billing; + } else { + billing = policyEntered(catalogPolicy.billing.mode); + } + return buildCapabilityToolResult(definition, tool, input.context, result, billing); + } + + private async enabledPluginIds(projectPath: string): Promise { + if (this.options.getEnabledPluginIds) return await this.options.getEnabledPluginIds(projectPath); + if (this.options.projectPlugins) return await this.options.projectPlugins.getEnabledPluginIds(projectPath); + return []; + } + + private resultFailure( + definition: CodingPluginDefinition, + tool: CodingPluginToolDefinition, + context: PiProductToolContext, + code: string, + error: string, + status: number, + retryable: boolean, + billing: CapabilityBillingReceiptV1, + ): PiProductToolResult { + return buildCapabilityToolResult(definition, tool, context, { + success: false, + status, + code, + error, + retryable, + payload_schema: 'data-service.v1', + data: null, + }, billing); + } + + private unknownResult( + context: PiProductToolContext, + code: string, + error: string, + ): PiProductToolResult { + const details: CapabilityResultV1 = { + schema: 'makelore-capability.v1', + plugin_id: 'unknown', + plugin_version: 'unknown', + capability_id: 'unknown', + operation: 'unknown', + request_id: requestId(context), + success: false, + status: 503, + code, + error, + retryable: true, + billing: { mode: 'unknown', status: 'not_started' }, + payload_schema: 'unknown', + data: null, + }; + return { + content: [{ type: 'text', text: JSON.stringify(details) }], + details, + }; + } +} + +export const CodingCapabilityRegistry = CodingCapabilityRegistryImpl; + +export const createCodingCapabilityRegistry = ( + options: CodingCapabilityRegistryOptions, +): CodingCapabilityRegistry => new CodingCapabilityRegistryImpl(options); diff --git a/electron/coding-runtime/pi/product-tools.ts b/electron/coding-runtime/pi/product-tools.ts index 515471d..a768f10 100644 --- a/electron/coding-runtime/pi/product-tools.ts +++ b/electron/coding-runtime/pi/product-tools.ts @@ -15,11 +15,18 @@ import type { } from '../../../shared/coding-product-tools'; import { DATA_SERVICE_PI_TOOL_NAMES, - type DataServiceHostResult, type DataServicePiToolName, - type DataServiceToolData, - type DataServiceToolDetailsV1, } from '../../../shared/data-service'; +import { + DATA_SERVICE_PLUGIN_DEFINITION, +} from '../../../shared/coding-plugins'; +import { createDataServicePluginAdapter } from '../../coding-plugins/adapters/data-service'; +import { + buildCapabilityToolResult, + type AdapterInvocationResult, + type CodingCapabilityRegistry, + type TrustedCodingCapabilityContext, +} from '../../coding-plugins/registry'; import type { DataServiceOperations } from '../../services/data-service-client'; import type { KnownToolDetails, RuntimeContextDetailsV1 } from '../contracts'; import { PiAgentBrowserTool } from './extensions/agent-browser'; @@ -52,104 +59,6 @@ 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; runId: string; @@ -170,22 +79,31 @@ export interface PiProductToolsOptions { bundledSkillsDir: string; changeTracker?: ConversationChangeTracker; dataService?: DataServiceOperations; + capabilityRegistry?: CodingCapabilityRegistry; } export class PiProductTools { readonly changeTracker: ConversationChangeTracker; private readonly browser: PiAgentBrowserTool; private readonly gameAssets = new PiGameAssetTools(); - private dataService: DataServiceOperations | undefined; + private dataServiceAdapter: ReturnType | undefined; + private capabilityRegistry: CodingCapabilityRegistry | undefined; constructor(private readonly options: PiProductToolsOptions) { this.changeTracker = options.changeTracker ?? new ConversationChangeTracker(); this.browser = new PiAgentBrowserTool(options.browser, options.attachments); - this.dataService = options.dataService; + this.capabilityRegistry = options.capabilityRegistry; + this.dataServiceAdapter = options.dataService + ? createDataServicePluginAdapter(options.dataService) + : undefined; } configureDataService(dataService: DataServiceOperations): void { - this.dataService = dataService; + this.dataServiceAdapter = createDataServicePluginAdapter(dataService); + } + + configureCapabilityRegistry(registry: CodingCapabilityRegistry): void { + this.capabilityRegistry = registry; } beginRun(input: { conversationId: string; runId: string; projectPath: string }) { @@ -220,7 +138,7 @@ export class PiProductTools { } async execute( - toolName: PiProductToolName, + toolName: PiProductToolName | string, context: PiProductToolContext, input: unknown, ): Promise { @@ -237,76 +155,38 @@ 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, + if (this.capabilityRegistry && toolName !== 'runtime_context') { + return await this.capabilityRegistry.invoke({ + toolName, + context, + workerRole: 'parent', + effectiveSkillIds: context.skillIds, + value: input, + }); + } + if (DATA_SERVICE_PI_TOOL_NAMES.includes(toolName as DataServicePiToolName)) { + const adapter = this.dataServiceAdapter; + const definition = DATA_SERVICE_PLUGIN_DEFINITION.tools.find(({ name }) => name === toolName); + if (!adapter || !definition) throw new Error('Data Service tools are unavailable'); + const trustedContext: TrustedCodingCapabilityContext = { + conversationId: context.conversationId, + runId: context.runId, + resourceId: context.resourceId, + requestId: `pi:${context.runId}:${context.resourceId}`, + localProjectId: context.projectId, + projectPath: context.projectPath, + durableProjectId: context.projectId, + workerRole: 'parent', + effectiveSkillIds: [...context.skillIds], + }; + const result: AdapterInvocationResult = await adapter.invoke(trustedContext, definition, input); + return buildCapabilityToolResult( + DATA_SERVICE_PLUGIN_DEFINITION, + definition, + context, + result, + { mode: 'included', status: 'included' }, ); - 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); @@ -321,8 +201,4 @@ export class PiProductTools { }; } - private requireDataService(): DataServiceOperations { - if (!this.dataService) throw new Error('Data Service tools are unavailable'); - return this.dataService; - } } diff --git a/electron/services/plugin-policy-client.ts b/electron/services/plugin-policy-client.ts new file mode 100644 index 0000000..4386788 --- /dev/null +++ b/electron/services/plugin-policy-client.ts @@ -0,0 +1,466 @@ +import { WORKS_SQUARE_CONFIG } from '../api/works-config'; +import { proxyAwareFetch } from '../utils/proxy-fetch'; + +const MAX_CATALOG_BYTES = 1_310_720; +const MAX_CATALOG_VERSION = 64; +const MAX_PLUGIN_ID = 48; +const MAX_CAPABILITY_ID = 64; +const MAX_OPERATION_ID = 64; +const MAX_NOTICE = 160; +const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,47}$/u; +const CAPABILITY_ID_PATTERN = /^[a-z][a-z0-9.-]{0,63}$/u; +const OPERATION_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u; +const TOKEN_POINT_AMOUNT_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/u; + +type UnknownRecord = Record; +type FetchImplementation = (input: string | URL, init?: RequestInit) => Promise; + +export interface PluginPricingVersion { + version: number; + version_id: string; + effective_at: string; +} + +export type PluginBillingPolicy = + | { + mode: 'included'; + entitlement_scope: null; + notice: string; + } + | { + mode: 'external_account'; + notice: string; + } + | { + mode: 'platform_metered'; + entitlement_scope: string; + notice: string; + unit_name: string; + unit_size: number; + rate_points: string; + minimum_charge_points: string; + rounding_mode: 'ceil'; + } + | { + mode: 'platform_metered'; + status: 'billing_unavailable'; + entitlement_scope: string; + notice: string; + }; + +export interface PluginCatalogOperation { + operation: string; + billing: PluginBillingPolicy; +} + +export interface PluginCatalogCapability { + capability_id: string; + operations: PluginCatalogOperation[]; +} + +export interface PluginCatalogPlugin { + plugin_id: string; + supported_contract_versions: number[]; + status: 'active'; + capabilities: PluginCatalogCapability[]; +} + +export interface PluginCatalog { + schema_version: 1; + catalog_version: string; + pricing_version: PluginPricingVersion | null; + plugins: PluginCatalogPlugin[]; +} + +export type PluginPolicyAvailability = 'unavailable' | 'current' | 'stale'; + +export interface PluginPolicyClientState { + status: PluginPolicyAvailability; + catalog: PluginCatalog | null; + revision: number; + lastVerifiedAt: number | null; + errorCode?: 'plugin_backend_unavailable'; +} + +export interface PluginPolicyClientOptions { + fetchImpl?: FetchImplementation; + apiBaseUrl?: string; + now?: () => number; +} + +export class PluginPolicyCatalogError extends Error { + readonly code = 'plugin_policy_invalid' as const; + + constructor(message = 'Plugin policy catalog is invalid') { + super(message); + this.name = 'PluginPolicyCatalogError'; + } +} + +function isRecord(value: unknown): value is UnknownRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function exactKeys( + value: UnknownRecord, + required: readonly string[], + optional: readonly string[] = [], +): void { + const allowed = new Set([...required, ...optional]); + const keys = Object.keys(value); + if (keys.some((key) => !allowed.has(key)) + || required.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) { + throw new PluginPolicyCatalogError('Plugin policy catalog contains unexpected fields'); + } +} + +function text(value: unknown, maximum: number, field: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > maximum) { + throw new PluginPolicyCatalogError(`${field} is invalid`); + } + return value; +} + +function identifier( + value: unknown, + maximum: number, + pattern: RegExp, + field: string, +): string { + const result = text(value, maximum, field); + if (!pattern.test(result)) throw new PluginPolicyCatalogError(`${field} has invalid shape`); + return result; +} + +function positiveInteger(value: unknown, field: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + throw new PluginPolicyCatalogError(`${field} must be a positive integer`); + } + return value as number; +} + +function unique(values: readonly string[], field: string): void { + if (new Set(values).size !== values.length) { + throw new PluginPolicyCatalogError(`${field} contains duplicates`); + } +} + +function timestamp(value: unknown, field: string): string { + const result = text(value, 64, field); + if (!result.endsWith('Z') || Number.isNaN(Date.parse(result))) { + throw new PluginPolicyCatalogError(`${field} is invalid`); + } + return result; +} + +function publicAmount(value: unknown, field: string): string { + const result = text(value, 32, field); + if (!TOKEN_POINT_AMOUNT_PATTERN.test(result)) { + throw new PluginPolicyCatalogError(`${field} is invalid`); + } + return result; +} + +function parsePricingVersion(value: unknown): PluginPricingVersion | null { + if (value === null) return null; + if (!isRecord(value)) throw new PluginPolicyCatalogError('pricing_version is invalid'); + exactKeys(value, ['version', 'version_id', 'effective_at']); + return { + version: positiveInteger(value.version, 'pricing_version.version'), + version_id: text(value.version_id, 36, 'pricing_version.version_id'), + effective_at: timestamp(value.effective_at, 'pricing_version.effective_at'), + }; +} + +function parseBilling(value: unknown): PluginBillingPolicy { + if (!isRecord(value)) throw new PluginPolicyCatalogError('billing is invalid'); + if (value.mode === 'included') { + exactKeys(value, ['mode', 'entitlement_scope', 'notice']); + if (value.entitlement_scope !== null) { + throw new PluginPolicyCatalogError('included entitlement_scope must be null'); + } + return { + mode: 'included', + entitlement_scope: null, + notice: text(value.notice, MAX_NOTICE, 'billing.notice'), + }; + } + if (value.mode === 'external_account') { + exactKeys(value, ['mode', 'notice']); + return { + mode: 'external_account', + notice: text(value.notice, MAX_NOTICE, 'billing.notice'), + }; + } + if (value.mode !== 'platform_metered') { + throw new PluginPolicyCatalogError('billing.mode is invalid'); + } + if (value.status === 'billing_unavailable') { + exactKeys(value, ['mode', 'status', 'entitlement_scope', 'notice']); + return { + mode: 'platform_metered', + status: 'billing_unavailable', + entitlement_scope: identifier( + value.entitlement_scope, + 64, + OPERATION_ID_PATTERN, + 'billing.entitlement_scope', + ), + notice: text(value.notice, MAX_NOTICE, 'billing.notice'), + }; + } + exactKeys(value, [ + 'mode', + 'entitlement_scope', + 'notice', + 'unit_name', + 'unit_size', + 'rate_points', + 'minimum_charge_points', + 'rounding_mode', + ]); + if (value.rounding_mode !== 'ceil') { + throw new PluginPolicyCatalogError('billing.rounding_mode is invalid'); + } + return { + mode: 'platform_metered', + entitlement_scope: identifier( + value.entitlement_scope, + 64, + OPERATION_ID_PATTERN, + 'billing.entitlement_scope', + ), + notice: text(value.notice, MAX_NOTICE, 'billing.notice'), + unit_name: text(value.unit_name, 80, 'billing.unit_name'), + unit_size: positiveInteger(value.unit_size, 'billing.unit_size'), + rate_points: publicAmount(value.rate_points, 'billing.rate_points'), + minimum_charge_points: publicAmount( + value.minimum_charge_points, + 'billing.minimum_charge_points', + ), + rounding_mode: 'ceil', + }; +} + +function parseOperation(value: unknown): PluginCatalogOperation { + if (!isRecord(value)) throw new PluginPolicyCatalogError('operation is invalid'); + exactKeys(value, ['operation', 'billing']); + return { + operation: identifier(value.operation, MAX_OPERATION_ID, OPERATION_ID_PATTERN, 'operation'), + billing: parseBilling(value.billing), + }; +} + +function parseCapability(value: unknown): PluginCatalogCapability { + if (!isRecord(value)) throw new PluginPolicyCatalogError('capability is invalid'); + exactKeys(value, ['capability_id', 'operations']); + if (!Array.isArray(value.operations) || value.operations.length < 1 || value.operations.length > 256) { + throw new PluginPolicyCatalogError('capability.operations is invalid'); + } + const operations = value.operations.map(parseOperation); + unique(operations.map(({ operation }) => operation), 'capability.operations'); + return { + capability_id: identifier( + value.capability_id, + MAX_CAPABILITY_ID, + CAPABILITY_ID_PATTERN, + 'capability_id', + ), + operations, + }; +} + +function parsePlugin(value: unknown): PluginCatalogPlugin { + if (!isRecord(value)) throw new PluginPolicyCatalogError('plugin is invalid'); + exactKeys(value, ['plugin_id', 'supported_contract_versions', 'status', 'capabilities']); + if (value.status !== 'active') throw new PluginPolicyCatalogError('plugin.status is invalid'); + if (!Array.isArray(value.supported_contract_versions) + || value.supported_contract_versions.length < 1 + || value.supported_contract_versions.length > 32) { + throw new PluginPolicyCatalogError('supported_contract_versions is invalid'); + } + const supportedContractVersions = value.supported_contract_versions.map((item) => ( + positiveInteger(item, 'supported_contract_versions') + )); + if (new Set(supportedContractVersions).size !== supportedContractVersions.length) { + throw new PluginPolicyCatalogError('supported_contract_versions contains duplicates'); + } + if (!Array.isArray(value.capabilities) || value.capabilities.length < 1 || value.capabilities.length > 128) { + throw new PluginPolicyCatalogError('plugin.capabilities is invalid'); + } + const capabilities = value.capabilities.map(parseCapability); + unique(capabilities.map(({ capability_id }) => capability_id), 'plugin.capabilities'); + return { + plugin_id: identifier(value.plugin_id, MAX_PLUGIN_ID, PLUGIN_ID_PATTERN, 'plugin_id'), + supported_contract_versions: supportedContractVersions, + status: 'active', + capabilities, + }; +} + +/** Parse the exact public server catalog; unknown fields are rejected. */ +export function parsePluginCatalog(value: unknown): PluginCatalog { + if (!isRecord(value)) throw new PluginPolicyCatalogError(); + exactKeys(value, ['schema_version', 'catalog_version', 'pricing_version', 'plugins']); + if (value.schema_version !== 1) throw new PluginPolicyCatalogError('schema_version must equal 1'); + if (!Array.isArray(value.plugins) || value.plugins.length > 128) { + throw new PluginPolicyCatalogError('plugins is invalid'); + } + const plugins = value.plugins.map(parsePlugin); + unique(plugins.map(({ plugin_id }) => plugin_id), 'plugins'); + return Object.freeze({ + schema_version: 1 as const, + catalog_version: text(value.catalog_version, MAX_CATALOG_VERSION, 'catalog_version'), + pricing_version: parsePricingVersion(value.pricing_version), + plugins, + }); +} + +async function readBoundedText(response: Response): Promise { + const declaredLength = response.headers.get('content-length'); + if (declaredLength && /^\d+$/u.test(declaredLength) + && Number(declaredLength) > MAX_CATALOG_BYTES) { + await response.body?.cancel().catch(() => undefined); + throw new PluginPolicyCatalogError('catalog response is too large'); + } + if (!response.body) { + const textValue = await response.text(); + if (Buffer.byteLength(textValue, 'utf8') > MAX_CATALOG_BYTES) { + throw new PluginPolicyCatalogError('catalog response is too large'); + } + return textValue; + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + size += value.byteLength; + if (size > MAX_CATALOG_BYTES) { + await reader.cancel().catch(() => undefined); + throw new PluginPolicyCatalogError('catalog response is too large'); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8'); +} + +async function readCatalog(response: Response): Promise { + let source: string; + try { + source = await readBoundedText(response); + } catch (error) { + if (error instanceof PluginPolicyCatalogError) throw error; + throw new PluginPolicyCatalogError(); + } + let payload: unknown; + try { + payload = JSON.parse(source) as unknown; + } catch { + throw new PluginPolicyCatalogError(); + } + return parsePluginCatalog(payload); +} + +function cloneState(state: PluginPolicyClientState): PluginPolicyClientState { + return { + ...state, + catalog: state.catalog ? structuredClone(state.catalog) : null, + }; +} + +/** + * Narrow HTTP adapter for the unauthenticated, user-independent policy catalog. + * Only the last successfully parsed catalog is retained after a refresh error; + * it is visibly stale and never becomes a source of billing authority. + */ +export class PluginPolicyClient { + private readonly fetchImpl: FetchImplementation; + private readonly apiBaseUrl: string; + private readonly now: () => number; + private state: PluginPolicyClientState = { + status: 'unavailable', + catalog: null, + revision: 0, + lastVerifiedAt: null, + }; + private refreshFlight: Promise | null = null; + + constructor(options: PluginPolicyClientOptions = {}) { + this.fetchImpl = options.fetchImpl ?? proxyAwareFetch; + this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/u, ''); + this.now = options.now ?? (() => Date.now()); + } + + getState(): PluginPolicyClientState { + return cloneState(this.state); + } + + getStatus(): PluginPolicyAvailability { + return this.state.status; + } + + get catalog(): PluginCatalog | null { + return this.state.catalog ? structuredClone(this.state.catalog) : null; + } + + refresh(): Promise { + if (this.refreshFlight) return this.refreshFlight; + const flight = this.fetchCatalog() + .then((catalog) => { + this.state = { + status: 'current', + catalog, + revision: this.state.revision + 1, + lastVerifiedAt: this.now(), + }; + return this.getState(); + }) + .catch(() => { + this.state = { + ...this.state, + status: this.state.catalog ? 'stale' : 'unavailable', + errorCode: 'plugin_backend_unavailable', + }; + return this.getState(); + }) + .finally(() => { + if (this.refreshFlight === flight) this.refreshFlight = null; + }); + this.refreshFlight = flight; + return flight; + } + + refreshCatalog(): Promise { + return this.refresh(); + } + + private async fetchCatalog(): Promise { + let response: Response; + try { + response = await this.fetchImpl( + `${this.apiBaseUrl}/api/plugins/v1/catalog`, + { + method: 'GET', + headers: { Accept: 'application/json' }, + redirect: 'manual', + }, + ); + } catch { + throw new PluginPolicyCatalogError('catalog request failed'); + } + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + throw new PluginPolicyCatalogError('catalog request failed'); + } + return await readCatalog(response); + } +} + +export const parsePluginPolicyCatalog = parsePluginCatalog; diff --git a/shared/coding-conversation-contracts.ts b/shared/coding-conversation-contracts.ts index 75a531e..111e0a9 100644 --- a/shared/coding-conversation-contracts.ts +++ b/shared/coding-conversation-contracts.ts @@ -1,4 +1,6 @@ -import type { DataServiceToolDetailsV1 } from './data-service'; +import type { CapabilityResultV1 } from './data-service'; + +export type { CapabilityBillingReceiptV1, CapabilityResultV1 } from './data-service'; export type ConversationThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high'; @@ -194,8 +196,6 @@ export interface RuntimeContextDetailsV1 { }>; } -export type { DataServiceToolDetailsV1 } from './data-service'; - export interface SubagentDetailsV1 { schema: 'subagent.v1'; dispatchId: string; @@ -218,7 +218,7 @@ export type KnownToolDetails = | AgentBrowserDetailsV1 | GameAssetsDetailsV1 | RuntimeContextDetailsV1 - | DataServiceToolDetailsV1 + | CapabilityResultV1 | SubagentDetailsV1; export interface ConversationToolNode { diff --git a/shared/coding-conversation-product-tool-protocol.ts b/shared/coding-conversation-product-tool-protocol.ts index 98a5df9..14c6905 100644 --- a/shared/coding-conversation-product-tool-protocol.ts +++ b/shared/coding-conversation-product-tool-protocol.ts @@ -1,18 +1,14 @@ import type { AgentBrowserDetailsV1, + CapabilityBillingReceiptV1, + CapabilityResultV1, 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'; +import { DATA_SERVICE_PI_TOOL_NAMES } from './data-service'; const PRODUCT_TOOL_NAMES = new Set([ 'agent_browser', @@ -24,17 +20,15 @@ const PRODUCT_TOOL_NAMES = new Set([ ...DATA_SERVICE_PI_TOOL_NAMES, ]); -const DATA_SERVICE_CONTEXT_KEYS = new Set([ - 'resource', - 'limit', - 'current', - 'attempted', - 'actual', - 'allowed', - 'current_revision', - 'retry_after_seconds', +const CAPABILITY_CONTEXT_KEYS = new Set([ + 'resource', 'limit', 'current', 'attempted', 'actual', 'current_revision', ]); -const DATA_SERVICE_CONTEXT_RESOURCES = new Set(['instances', 'collections', 'documents', 'bytes']); +const CAPABILITY_CONTEXT_RESOURCES = new Set(['instances', 'collections', 'documents', 'bytes']); +const CAPABILITY_ID_PATTERN = /^[a-z][a-z0-9.-]{0,63}$/u; +const CAPABILITY_OPERATION_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u; +const CAPABILITY_PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,47}$/u; +const CAPABILITY_DECIMAL_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d+)?$/u; +const MAX_CAPABILITY_DATA_BYTES = 1_310_720; function record(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) @@ -153,15 +147,16 @@ function runtimeContextDetails(value: Record): RuntimeContextDe return { schema: 'runtime-context.v1', skills, commands }; } -function dataServiceContext(value: unknown): DataServiceErrorContext | undefined | null { +function capabilityContext(value: unknown): Readonly> | undefined | null { if (value === undefined) return undefined; const source = record(value); - if (!source || Object.keys(source).length > 8) return null; - const context: DataServiceErrorContext = {}; + if (!source || Object.keys(source).length > 6) return null; + const context: Record = {}; for (const [key, item] of Object.entries(source)) { - if (!DATA_SERVICE_CONTEXT_KEYS.has(key)) return null; + if (!CAPABILITY_CONTEXT_KEYS.has(key)) return null; if (key === 'resource') { - if (typeof item !== 'string' || !DATA_SERVICE_CONTEXT_RESOURCES.has(item)) return null; + if (typeof item !== 'string' || item.length === 0 || item.length > 32 + || !CAPABILITY_CONTEXT_RESOURCES.has(item)) return null; context[key] = item; } else if (Number.isSafeInteger(item) && (item as number) >= 0) { context[key] = item as number; @@ -172,8 +167,78 @@ function dataServiceContext(value: unknown): DataServiceErrorContext | undefined 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; +function exactKeys(value: Record, required: readonly string[], optional: readonly string[] = []): boolean { + const allowed = new Set([...required, ...optional]); + return required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) + && Object.keys(value).every((key) => allowed.has(key)); +} + +function capabilityBilling(value: unknown): CapabilityBillingReceiptV1 | null { + const billing = record(value); + if (!billing || typeof billing.mode !== 'string' || typeof billing.status !== 'string') return null; + if (billing.status === 'not_started') { + if (!exactKeys(billing, ['mode', 'status']) + || !['unknown', 'included', 'platform_metered', 'external_account'].includes(billing.mode)) return null; + return { + mode: billing.mode as CapabilityBillingReceiptV1['mode'], + status: 'not_started', + }; + } + if (billing.mode === 'included' && billing.status === 'included') { + return exactKeys(billing, ['mode', 'status']) ? { mode: 'included', status: 'included' } : null; + } + if (billing.mode === 'external_account' && billing.status === 'external') { + return exactKeys(billing, ['mode', 'status']) + ? { mode: 'external_account', status: 'external' } + : null; + } + if (billing.mode !== 'platform_metered' || typeof billing.reserved_points !== 'string' + || !CAPABILITY_DECIMAL_PATTERN.test(billing.reserved_points) + || billing.reserved_points.length > 32) return null; + if (!exactKeys(billing, ['mode', 'status', 'reserved_points'], ['actual_points', 'usage_amount', 'unit'])) return null; + if (billing.actual_points !== undefined + && (typeof billing.actual_points !== 'string' || billing.actual_points.length > 32 + || !CAPABILITY_DECIMAL_PATTERN.test(billing.actual_points))) return null; + if (billing.usage_amount !== undefined + && (typeof billing.usage_amount !== 'number' || !Number.isFinite(billing.usage_amount) + || billing.usage_amount < 0 || billing.usage_amount > Number.MAX_SAFE_INTEGER)) return null; + if (billing.unit !== undefined + && (typeof billing.unit !== 'string' || billing.unit.length === 0 || billing.unit.length > 80)) return null; + if (['settled', 'refunded'].includes(billing.status)) { + return billing.actual_points === undefined ? null : { + mode: 'platform_metered', + status: billing.status as 'settled' | 'refunded', + reserved_points: billing.reserved_points, + actual_points: billing.actual_points, + ...(billing.usage_amount === undefined ? {} : { usage_amount: billing.usage_amount as number }), + ...(billing.unit === undefined ? {} : { unit: billing.unit as string }), + }; + } + if (!['reserved', 'dispatched', 'released', 'pending_review', 'expired'].includes(billing.status)) return null; + return { + mode: 'platform_metered', + status: billing.status as 'reserved' | 'dispatched' | 'released' | 'pending_review' | 'expired', + reserved_points: billing.reserved_points, + ...(billing.actual_points === undefined ? {} : { actual_points: billing.actual_points as string }), + ...(billing.usage_amount === undefined ? {} : { usage_amount: billing.usage_amount as number }), + ...(billing.unit === undefined ? {} : { unit: billing.unit as string }), + }; +} + +function capabilityDetails(value: Record): CapabilityResultV1 | null { + const required = [ + 'schema', 'plugin_id', 'plugin_version', 'capability_id', 'operation', 'request_id', + 'success', 'status', 'code', 'error', 'retryable', 'billing', 'payload_schema', 'data', + ]; + if (!exactKeys(value, required, ['retry_after_seconds', 'context']) + || value.schema !== 'makelore-capability.v1') return null; + if (typeof value.plugin_id !== 'string' || value.plugin_id.length > 48 + || !CAPABILITY_PLUGIN_ID_PATTERN.test(value.plugin_id) + || !text(value.plugin_version, 64) + || typeof value.capability_id !== 'string' || !CAPABILITY_ID_PATTERN.test(value.capability_id) + || typeof value.operation !== 'string' || !CAPABILITY_OPERATION_PATTERN.test(value.operation) + || typeof value.request_id !== 'string' || value.request_id.length === 0 || value.request_id.length > 128 + || typeof value.payload_schema !== 'string' || value.payload_schema.length === 0 || value.payload_schema.length > 128) return null; if (typeof value.success !== 'boolean' || !Number.isSafeInteger(value.status) || (value.status as number) < 100 || (value.status as number) > 599 @@ -192,13 +257,29 @@ function dataServiceDetails(value: Record): DataServiceToolDeta && (!Number.isSafeInteger(retryAfter) || (retryAfter as number) < 0 || (retryAfter as number) > 86_400)) { return null; } - const context = dataServiceContext(value.context); + const context = capabilityContext(value.context); if (context === null) return null; + const billing = capabilityBilling(value.billing); + if (!billing) return null; const data = value.data === null ? null : record(value.data); if (value.data !== null && !data) return null; + if (data !== null) { + try { + const encoded = JSON.stringify(data); + if (typeof encoded !== 'string' || new TextEncoder().encode(encoded).byteLength > MAX_CAPABILITY_DATA_BYTES) { + return null; + } + } catch { + return null; + } + } return { - schema: 'data-service.v1', - operation: value.operation as DataServicePiToolName, + schema: 'makelore-capability.v1', + plugin_id: value.plugin_id as string, + plugin_version: value.plugin_version as string, + capability_id: value.capability_id as string, + operation: value.operation as string, + request_id: value.request_id as string, success: value.success, status: value.status as number, code: value.code as string | null, @@ -206,7 +287,9 @@ function dataServiceDetails(value: Record): DataServiceToolDeta retryable: value.retryable, ...(retryAfter === undefined ? {} : { retry_after_seconds: retryAfter as number }), ...(context === undefined ? {} : { context }), - data: data as DataServiceToolData | null, + billing, + payload_schema: value.payload_schema as string, + data, }; } @@ -221,7 +304,7 @@ export function productToolDetails(value: unknown): Exclude; +export type PluginBillingMode = + | 'included' + | 'platform_metered' + | 'external_account'; + +/** + * A closed billing projection. Token Point amounts stay decimal strings all + * the way through the client boundary; no UI or adapter is allowed to invent + * an amount or a terminal receipt. + */ +export type CapabilityBillingReceiptV1 = + | { + mode: 'unknown' | PluginBillingMode; + status: 'not_started'; + } + | { + mode: 'included'; + status: 'included'; + } + | { + mode: 'external_account'; + status: 'external'; + } + | { + mode: 'platform_metered'; + status: 'reserved' | 'dispatched' | 'released' | 'pending_review' | 'expired'; + reserved_points: string; + actual_points?: string; + usage_amount?: number; + unit?: string; + } + | { + mode: 'platform_metered'; + status: 'settled' | 'refunded'; + reserved_points: string; + actual_points: string; + usage_amount?: number; + unit?: string; + }; + +export interface CapabilityResultV1 { + schema: 'makelore-capability.v1'; + plugin_id: string; + plugin_version: string; + capability_id: string; + operation: string; + request_id: string; + success: boolean; + status: number; + code: string | null; + error: string | null; + retryable: boolean; + retry_after_seconds?: number; + context?: Readonly>; + billing: CapabilityBillingReceiptV1; + payload_schema: string; + data: T | null; +} + export const DATA_SERVICE_PI_TOOL_NAMES = [ 'data_service_configure', 'data_service_inspect', @@ -96,9 +155,9 @@ export type DataServiceToolData = | DataServiceCollectionRemoval | DataServiceInstanceRemoval; -export type DataServiceToolDetailsV1 = DataServiceHostResult & { - schema: 'data-service.v1'; - operation: DataServicePiToolName; +/** @deprecated Use the registry-owned capability envelope with this payload schema. */ +export type DataServiceToolDetailsV1 = CapabilityResultV1 & { + payload_schema: 'data-service.v1'; }; export type DataServicePutDocumentInput = { diff --git a/src/pages/Chat/CodingConversationTimeline.tsx b/src/pages/Chat/CodingConversationTimeline.tsx index 1ba8ee6..c325484 100644 --- a/src/pages/Chat/CodingConversationTimeline.tsx +++ b/src/pages/Chat/CodingConversationTimeline.tsx @@ -303,10 +303,10 @@ const ToolDetails = memo(function ToolDetails({ details }: { details: KnownToolD ); } - if (details.schema === 'data-service.v1') { + if (details.schema === 'makelore-capability.v1') { return (
-

数据服务 · {details.operation}

+

插件能力 · {details.operation}

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

diff --git a/tests/unit/coding-capability-registry.test.ts b/tests/unit/coding-capability-registry.test.ts new file mode 100644 index 0000000..49ba957 --- /dev/null +++ b/tests/unit/coding-capability-registry.test.ts @@ -0,0 +1,153 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest'; +import type { PluginPolicyClientState } from '../../electron/services/plugin-policy-client'; +import { + CodingCapabilityRegistryImpl, +} from '../../electron/coding-plugins/registry'; +import type { CodingPluginAdapter } from '../../electron/coding-plugins/registry'; + +const policy: PluginPolicyClientState = { + status: 'current', + revision: 7, + lastVerifiedAt: 1, + catalog: { + schema_version: 1, + catalog_version: 'catalog-1', + pricing_version: null, + plugins: [{ + plugin_id: 'makelore.data-service', + supported_contract_versions: [1], + status: 'active', + capabilities: [ + { + capability_id: 'data-service.control', + operations: ['configure', 'inspect', 'list_projects', 'remove_collection', 'reset', 'remove_project'] + .map((operation) => ({ + operation, + billing: { mode: 'included' as const, entitlement_scope: null, notice: 'Included' }, + })), + }, + { + capability_id: 'data-service.documents', + operations: ['get_document', 'list_documents', 'put_document', 'delete_document'] + .map((operation) => ({ + operation, + billing: { mode: 'included' as const, entitlement_scope: null, notice: 'Included' }, + })), + }, + { + capability_id: 'data-service.preview', + operations: ['get', 'list', 'put', 'delete'].map((operation) => ({ + operation, + billing: { mode: 'included' as const, entitlement_scope: null, notice: 'Included' }, + })), + }, + ], + }], + }, +}; + +const context = { + conversationId: 'conversation-a', + runId: 'run-a', + resourceId: 'resource-a', + projectId: 'local-project-a', + projectPath: 'C:\\projects\\demo', + skillIds: ['data-service'], +}; + +function adapter(): CodingPluginAdapter { + return { + pluginId: 'makelore.data-service', + async inspect() { return { status: 'ready' }; }, + async invoke() { + return { + success: true, + status: 200, + code: null, + error: null, + retryable: false, + payload_schema: 'data-service.v1', + data: { id: 'one', data: {}, revision: 1 }, + }; + }, + }; +} + +function registry(overrides: Partial[0]> = {}) { + return new CodingCapabilityRegistryImpl({ + policyClient: { getState: () => policy }, + getEnabledPluginIds: async () => ['makelore.data-service'], + adapters: [adapter()], + ...overrides, + }); +} + +describe('CodingCapabilityRegistry', () => { + it('joins package, selection, grant, role, and server policy for parent resources', async () => { + const resources = await registry().resolveWorkerResources({ + projectPath: context.projectPath, + assignedSkillIds: context.skillIds, + role: 'parent', + }); + expect(resources.catalogRevision).toBe(7); + expect(resources.pluginIds).toEqual(['makelore.data-service']); + expect(resources.effectiveSkillIds).toEqual(['data-service']); + expect(resources.tools.map(({ name }) => name)).toHaveLength(10); + }); + + it('never exposes plugin resources to child workers or before the first catalog', async () => { + const child = await registry().resolveWorkerResources({ + projectPath: context.projectPath, assignedSkillIds: context.skillIds, role: 'child', + }); + expect(child.pluginIds).toEqual([]); + expect(child.tools).toEqual([]); + const unavailable = await registry({ + policyClient: { getState: () => ({ ...policy, catalog: null, status: 'unavailable', revision: 0 }) }, + }).resolveWorkerResources({ + projectPath: context.projectPath, assignedSkillIds: context.skillIds, role: 'parent', + }); + expect(unavailable.tools).toEqual([]); + }); + + it('revalidates invocation, derives the stable Pi request id, and preserves domain faults', async () => { + const result = await registry().invoke({ + toolName: 'data_service_get_document', + context, + workerRole: 'parent', + effectiveSkillIds: context.skillIds, + value: { collection: 'todos', document_id: 'one' }, + }); + expect(result.details).toMatchObject({ + schema: 'makelore-capability.v1', + plugin_id: 'makelore.data-service', + capability_id: 'data-service.documents', + operation: 'get_document', + request_id: 'pi:run-a:resource-a', + billing: { mode: 'included', status: 'included' }, + payload_schema: 'data-service.v1', + }); + const invalid = await registry().invoke({ + toolName: 'data_service_get_document', context, workerRole: 'parent', + effectiveSkillIds: context.skillIds, value: { collection: 'todos', projectPath: 'forbidden' }, + }); + expect(invalid.details).toMatchObject({ success: false, code: 'plugin_input_invalid' }); + const disabled = await registry({ getEnabledPluginIds: async () => [] }).invoke({ + toolName: 'data_service_get_document', context, workerRole: 'parent', + effectiveSkillIds: context.skillIds, value: { collection: 'todos', document_id: 'one' }, + }); + expect(disabled.details).toMatchObject({ success: false, code: 'plugin_not_enabled' }); + const invalidIdentity = await registry().invoke({ + toolName: 'data_service_get_document', + context: { ...context, runId: '' }, + workerRole: 'parent', + effectiveSkillIds: context.skillIds, + value: { collection: 'todos', document_id: 'one' }, + }); + expect(invalidIdentity.details).toMatchObject({ + success: false, code: 'plugin_input_invalid', request_id: 'invalid-request-id', + }); + expect(JSON.stringify(invalidIdentity)).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}/i); + }); +}); diff --git a/tests/unit/coding-conversation-contracts.test.ts b/tests/unit/coding-conversation-contracts.test.ts index bd3e15c..5596b5e 100644 --- a/tests/unit/coding-conversation-contracts.test.ts +++ b/tests/unit/coding-conversation-contracts.test.ts @@ -23,6 +23,7 @@ import { PI_PRODUCT_FIXTURE_SOURCE, PI_WIRE_TO_PRODUCT_BOUNDARY, } from '../fixtures/coding-conversation-product-fixtures'; +import { productToolDetails } from '../../shared/coding-conversation-product-tool-protocol'; function reduceAll( snapshot: ConversationSnapshot, @@ -49,6 +50,62 @@ function envelope( } describe('Conversation product contracts', () => { + it('accepts bounded capability envelopes for every Data Service operation', () => { + const control = new Set(['configure', 'inspect', 'list_projects', 'remove_collection', 'reset', 'remove_project']); + const operations = [ + 'configure', 'inspect', 'list_projects', 'get_document', 'list_documents', + 'put_document', 'delete_document', 'remove_collection', 'reset', 'remove_project', + ]; + for (const operation of operations) { + const parsed = productToolDetails({ + schema: 'makelore-capability.v1', + plugin_id: 'makelore.data-service', + plugin_version: '1.0.0', + capability_id: control.has(operation) ? 'data-service.control' : 'data-service.documents', + operation, + request_id: 'pi:run-a:resource-a', + success: false, + status: operation === 'put_document' ? 413 : operation === 'delete_document' ? 409 : 429, + code: operation === 'put_document' ? 'document_too_large' : 'rate_limited', + error: 'Data Service request was rejected', + retryable: true, + retry_after_seconds: 30, + context: operation === 'put_document' + ? { resource: 'bytes', actual: 100_000, limit: 98_304 } + : operation === 'delete_document' + ? { current_revision: 3 } + : { resource: 'documents' }, + billing: { mode: 'included', status: 'included' }, + payload_schema: 'data-service.v1', + data: null, + }); + expect(parsed).toMatchObject({ schema: 'makelore-capability.v1', operation }); + } + expect(productToolDetails({ + schema: 'makelore-capability.v1', + plugin_id: 'makelore.data-service', plugin_version: '1.0.0', + capability_id: 'data-service.documents', operation: 'get_document', + request_id: 'pi:run-a:resource-a', success: true, status: 200, + code: null, error: null, retryable: false, + billing: { mode: 'platform_metered', status: 'refunded', reserved_points: '2.50', actual_points: '1.25' }, + payload_schema: 'data-service.v1', data: { id: 'one' }, + })).toMatchObject({ billing: { mode: 'platform_metered', status: 'refunded', actual_points: '1.25' } }); + expect(productToolDetails({ + schema: 'makelore-capability.v1', plugin_id: 'makelore.data-service', plugin_version: '1.0.0', + capability_id: 'data-service.documents', operation: 'get_document', request_id: 'pi:run-a:resource-a', + success: false, status: 409, code: 'quota_exceeded', error: 'Quota exceeded', retryable: false, + billing: { mode: 'included', status: 'included' }, payload_schema: 'data-service.v1', data: null, + context: { allowed: 1 }, + })).toBeNull(); + expect(productToolDetails({ + schema: 'makelore-capability.v1', plugin_id: 'makelore.data-service', plugin_version: '1.0.0', + capability_id: 'data-service.documents', operation: 'get_document', request_id: 'pi:run-a:resource-a', + success: true, status: 200, code: null, error: null, retryable: false, + billing: { mode: 'included', status: 'included' }, payload_schema: 'data-service.v1', + data: { body: 'raw upstream body must be projected' }, extra: 'reject', + })).toBeNull(); + }); + it('accepts schema v1 snapshots and fail-closes unknown schemas until replacement', () => { const snapshot = createProductSnapshot(); expect(isConversationSnapshot(snapshot)).toBe(true); diff --git a/tests/unit/data-service-plugin-adapter.test.ts b/tests/unit/data-service-plugin-adapter.test.ts new file mode 100644 index 0000000..939ef0d --- /dev/null +++ b/tests/unit/data-service-plugin-adapter.test.ts @@ -0,0 +1,98 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from 'vitest'; +import { DATA_SERVICE_TOOL_DEFINITIONS } from '../../shared/coding-plugins'; +import type { DataServiceOperations } from '../../electron/services/data-service-client'; +import { + createDataServicePluginAdapter, +} from '../../electron/coding-plugins/adapters/data-service'; + +const context = { + conversationId: 'conversation-a', + runId: 'run-a', + resourceId: 'resource-a', + requestId: 'pi:run-a:resource-a', + localProjectId: 'local-project-a', + projectPath: 'C:\\projects\\demo', + durableProjectId: 'durable-project-a', + workerRole: 'parent' as const, + effectiveSkillIds: ['data-service'], +}; + +function success(data: T) { + return { + success: true as const, + status: 200, + code: null, + error: null, + retryable: false as const, + data, + }; +} + +function operations(): DataServiceOperations { + return { + configure: vi.fn().mockResolvedValue(success({ configured: true })), + inspect: vi.fn().mockResolvedValue(success({ instance_id: 'instance-a' })), + listProjects: vi.fn().mockResolvedValue(success({ items: [], total: 0, instance_limit: 20 })), + getDocument: vi.fn().mockResolvedValue(success({ id: 'one', data: {}, revision: 1 })), + listDocuments: vi.fn().mockResolvedValue(success({ items: [], next_cursor: null, limit: 50 })), + putDocument: vi.fn().mockResolvedValue(success({ id: 'one', data: {}, revision: 1 })), + deleteDocument: vi.fn().mockResolvedValue(success(null)), + removeCollection: vi.fn().mockResolvedValue(success({ removed: true, usage: { document_count: 0, total_bytes: 0 } })), + reset: vi.fn().mockResolvedValue(success({ instance_id: 'instance-a' })), + removeProject: vi.fn().mockResolvedValue(success({ removed: true })), + }; +} + +describe('Data Service plugin adapter', () => { + it('maps all ten package operations through DataServiceOperations', async () => { + const dataService = operations(); + const adapter = createDataServicePluginAdapter(dataService); + const inputs: Record = { + data_service_configure: { collections: ['todos'] }, + data_service_inspect: {}, + data_service_list_projects: {}, + data_service_get_document: { collection: 'todos', document_id: 'one' }, + data_service_list_documents: { collection: 'todos', limit: 50, cursor: 'cursor-a' }, + data_service_put_document: { collection: 'todos', document_id: 'one', data: { done: false }, if_revision: 1 }, + data_service_delete_document: { collection: 'todos', document_id: 'one', if_revision: 1, confirmed: true }, + data_service_remove_collection: { collection: 'todos', confirmed: true }, + data_service_reset: { confirmed: true }, + data_service_remove_project: { confirmed: true }, + }; + for (const tool of DATA_SERVICE_TOOL_DEFINITIONS) { + const result = await adapter.invoke(context, tool, inputs[tool.name]); + expect(result).toMatchObject({ success: true, payload_schema: 'data-service.v1' }); + expect(result).not.toHaveProperty('plugin_id'); + expect(result).not.toHaveProperty('request_id'); + } + expect(dataService.configure).toHaveBeenCalledWith({ collections: ['todos'] }, context.projectPath); + expect(dataService.inspect).toHaveBeenCalledWith(context.projectPath); + expect(dataService.listProjects).toHaveBeenCalledWith(); + expect(dataService.getDocument).toHaveBeenCalledWith({ collection: 'todos', document_id: 'one' }, context.projectPath); + expect(dataService.listDocuments).toHaveBeenCalledWith({ collection: 'todos', limit: 50, cursor: 'cursor-a' }, context.projectPath); + expect(dataService.putDocument).toHaveBeenCalledWith({ + collection: 'todos', document_id: 'one', data: { done: false }, if_revision: 1, + }, context.projectPath); + expect(dataService.deleteDocument).toHaveBeenCalledWith({ + collection: 'todos', document_id: 'one', if_revision: 1, confirmed: true, + }, context.projectPath); + }); + + it('returns bounded domain context and rejects forbidden or malformed input', async () => { + const dataService = operations(); + const adapter = createDataServicePluginAdapter(dataService); + const put = DATA_SERVICE_TOOL_DEFINITIONS.find(({ name }) => name === 'data_service_put_document'); + if (!put) throw new Error('put definition missing'); + const invalid = await adapter.invoke(context, put, { + collection: 'todos', document_id: 'one', data: {}, projectPath: context.projectPath, + }); + expect(invalid).toMatchObject({ + success: false, status: 422, code: 'plugin_input_invalid', data: null, + }); + const result = await adapter.invoke(context, put, { collection: 'todos', document_id: 'one', data: {} }); + expect(result).toMatchObject({ success: true, payload_schema: 'data-service.v1' }); + expect(dataService.putDocument).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/pi-product-tools.test.ts b/tests/unit/pi-product-tools.test.ts index b7c1596..df2b88b 100644 --- a/tests/unit/pi-product-tools.test.ts +++ b/tests/unit/pi-product-tools.test.ts @@ -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 { CodingCapabilityRegistry } from '../../electron/coding-plugins/registry'; import type { DataServiceOperations } from '../../electron/services/data-service-client'; const exec = promisify(execFile); @@ -233,35 +234,38 @@ describe('PI-090 product tools', () => { })).toBeNull(); expect(productToolDetails({ schema: 'task-state.v1', tasks: [] })).toBeNull(); expect(productToolDetails({ - schema: 'data-service.v1', - operation: 'data_service_inspect', + schema: 'makelore-capability.v1', + plugin_id: 'makelore.data-service', + plugin_version: '1.0.0', + capability_id: 'data-service.control', + operation: 'inspect', + request_id: 'pi:run-a:resource-a', success: true, status: 200, code: null, error: null, retryable: false, + billing: { mode: 'included', status: 'included' }, + payload_schema: 'data-service.v1', 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(); + expect(productToolDetails({ + schema: 'makelore-capability.v1', + plugin_id: 'makelore.data-service', + plugin_version: '1.0.0', + capability_id: 'data-service.control', + operation: 'inspect', + request_id: 'pi:run-a:resource-a', + success: true, + status: 200, + code: null, + error: null, + retryable: false, + billing: { mode: 'included', status: 'included' }, + payload_schema: 'data-service.v1', + data: null, + })).toMatchObject({ schema: 'makelore-capability.v1', operation: 'inspect' }); }); it('stores browser screenshots as attachment ids and never returns base64', async () => { @@ -413,12 +417,42 @@ describe('PI-090 product tools', () => { 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', + schema: 'makelore-capability.v1', operation: 'remove_project', + plugin_id: 'makelore.data-service', request_id: 'pi:run-a:resource-a', + billing: { mode: 'included', status: 'included' }, payload_schema: 'data-service.v1', success: true, status: 200, data: { removed: true }, }); expect(JSON.stringify(removed)).not.toContain(root); }); + it('delegates any non-core product tool to the capability registry', async () => { + const root = await temporaryRoot('makelore-pi-generic-plugin-'); + const invoke = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'plugin-result' }], + details: { + schema: 'makelore-capability.v1', plugin_id: 'makelore.example', plugin_version: '1.0.0', + capability_id: 'example.capability', operation: 'run', request_id: 'pi:run-a:resource-a', + success: true, status: 200, code: null, error: null, retryable: false, + billing: { mode: 'included', status: 'included' }, payload_schema: 'example.v1', data: {}, + }, + }); + const registry = { invoke } as unknown as CodingCapabilityRegistry; + const tools = new PiProductTools({ + browser: {} as AgentBrowserModule, + attachments: new CodingAttachmentStore(path.join(root, 'attachments')), + bundledSkillsDir: path.resolve('resources/coding-skills'), + capabilityRegistry: registry, + }); + const context = { + conversationId: 'conversation-a', runId: 'run-a', resourceId: 'resource-a', + projectId: 'local-project-a', projectPath: root, skillIds: [], + }; + await tools.execute('example_tool', context, { value: 1 }); + expect(invoke).toHaveBeenCalledWith({ + toolName: 'example_tool', context, workerRole: 'parent', effectiveSkillIds: [], value: { value: 1 }, + }); + }); + it('rejects forbidden tool fields and destructive calls without literal confirmation', async () => { const root = await temporaryRoot('makelore-pi-data-input-'); const dataService = { @@ -438,15 +472,17 @@ describe('PI-090 product tools', () => { 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_inspect', context, { owner: 'owner-a' })).resolves.toMatchObject({ + details: { schema: 'makelore-capability.v1', code: 'plugin_input_invalid', status: 422 }, + }); 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', - ); + })).resolves.toMatchObject({ + details: { schema: 'makelore-capability.v1', code: 'plugin_input_invalid', status: 422 }, + }); + await expect(tools.execute('data_service_remove_project', context, { confirmed: false })).resolves.toMatchObject({ + details: { schema: 'makelore-capability.v1', code: 'plugin_input_invalid', status: 422 }, + }); expect(dataService.inspect).not.toHaveBeenCalled(); expect(dataService.removeProject).not.toHaveBeenCalled(); }); diff --git a/tests/unit/plugin-policy-client.test.ts b/tests/unit/plugin-policy-client.test.ts new file mode 100644 index 0000000..74f7490 --- /dev/null +++ b/tests/unit/plugin-policy-client.test.ts @@ -0,0 +1,123 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from 'vitest'; +import { + parsePluginCatalog, + PluginPolicyClient, +} from '../../electron/services/plugin-policy-client'; + +const catalog = { + schema_version: 1, + catalog_version: 'catalog-1', + pricing_version: null, + plugins: [{ + plugin_id: 'makelore.data-service', + supported_contract_versions: [1], + status: 'active', + capabilities: [ + { + capability_id: 'data-service.documents', + operations: [ + { + operation: 'get_document', + billing: { + mode: 'included', + entitlement_scope: null, + notice: 'Included in development data service', + }, + }, + ], + }, + ], + }], +}; + +describe('PluginPolicyClient', () => { + it('parses the exact catalog and rejects unknown or malformed fields', () => { + expect(parsePluginCatalog(catalog)).toMatchObject({ + schema_version: 1, + catalog_version: 'catalog-1', + plugins: [{ plugin_id: 'makelore.data-service' }], + }); + expect(() => parsePluginCatalog({ ...catalog, extra: true })).toThrow('unexpected fields'); + expect(() => parsePluginCatalog({ + ...catalog, + plugins: [{ + ...catalog.plugins[0], + capabilities: [{ + ...catalog.plugins[0].capabilities[0], + operations: [{ + ...catalog.plugins[0].capabilities[0].operations[0], + billing: { mode: 'included', entitlement_scope: 'wrong', notice: 'nope' }, + }], + }], + }], + })).toThrow(); + }); + + it('accepts an unavailable platform-metered policy without exposing a price', () => { + const parsed = parsePluginCatalog({ + ...catalog, + plugins: [{ + ...catalog.plugins[0], + capabilities: [{ + ...catalog.plugins[0].capabilities[0], + operations: [{ + ...catalog.plugins[0].capabilities[0].operations[0], + billing: { + mode: 'platform_metered', + status: 'billing_unavailable', + entitlement_scope: 'data-service.documents', + notice: 'Pricing is unavailable', + }, + }], + }], + }], + }); + expect(parsed.plugins[0]?.capabilities[0]?.operations[0]?.billing).toEqual({ + mode: 'platform_metered', + status: 'billing_unavailable', + entitlement_scope: 'data-service.documents', + notice: 'Pricing is unavailable', + }); + }); + + it('coalesces concurrent refreshes and marks the verified catalog stale after failure', async () => { + let resolveRequest: ((response: Response) => void) | undefined; + const fetchImpl = vi.fn(() => new Promise((resolve) => { + resolveRequest = resolve; + })); + const client = new PluginPolicyClient({ + fetchImpl, + apiBaseUrl: 'https://works.example', + now: () => 123, + }); + const first = client.refresh(); + const second = client.refresh(); + expect(first).toBe(second); + expect(fetchImpl).toHaveBeenCalledTimes(1); + resolveRequest?.(new Response(JSON.stringify(catalog), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + await expect(first).resolves.toMatchObject({ status: 'current', revision: 1 }); + + fetchImpl.mockRejectedValueOnce(new Error('offline')); + await expect(client.refresh()).resolves.toMatchObject({ + status: 'stale', + revision: 1, + errorCode: 'plugin_backend_unavailable', + }); + expect(client.getState().catalog).toEqual(expect.objectContaining({ catalog_version: 'catalog-1' })); + }); + + it('keeps an unavailable first state when no catalog was verified', async () => { + const client = new PluginPolicyClient({ fetchImpl: vi.fn().mockRejectedValue(new Error('offline')) }); + await expect(client.refresh()).resolves.toMatchObject({ + status: 'unavailable', + catalog: null, + revision: 0, + errorCode: 'plugin_backend_unavailable', + }); + }); +});