diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6240a1c749..e040893dc7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -61,6 +61,7 @@ "@sigstore/bundle": "5.0.0", "@sigstore/tuf": "5.0.0", "@sigstore/verify": "4.1.2", + "ajv": "^8.20.0", "electron-updater": "^6.8.9", "node-pty": "^1.2.0-beta.15", "qrcode": "^1.5.4", @@ -84,6 +85,7 @@ "@types/react-dom": "^19.2.4", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^6.1.0", + "ai": "7.0.70", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "electron": "43.4.1", diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index da5c76d48b..22a1f5a5cd 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -544,7 +544,9 @@ test('rolls back only candidate-owned IPC after a registration collision', async assert.equal(host.closeCalls, 1); }); -test('closes the claimed Host connection when native capability construction fails', async () => { +test('does not drop the Host connection when a native tool schema is invalid', async () => { + // Per-tool isolation: one bad tool is skipped and the provider still + // constructs, so the Host connection stays alive. const ipc = ipcHarness(); const host = connectionHarness('invalid-capability'); const invalidTool = { @@ -552,22 +554,19 @@ test('closes the claimed Host connection when native capability construction fai parameters: z.string(), } as unknown as MakaTool; - await assert.rejects( - () => - createDesktopRuntimeHostCandidate( - host.connection, - deps(ipc, { - browserTools: [invalidTool], - resolveBrowserUrl: () => 'https://example.com/', - releaseBrowserSession() {}, - computerUseTools: emptyComputerUseTools(), - releaseComputerUseSession() {}, - }), - ), - /tool schema must be an object/, + const candidate = await createDesktopRuntimeHostCandidate( + host.connection, + deps(ipc, { + browserTools: [invalidTool], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: emptyComputerUseTools(), + releaseComputerUseSession() {}, + }), ); - assert.equal(ipc.size, 0); + assert.equal(host.closeCalls, 0); + await candidate.close(); assert.equal(host.closeCalls, 1); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts index 3e2f36007d..5f456b0366 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { jsonSchema } from 'ai'; import { buildComputerUseTools, type ComputerUseToolSet } from '@maka/runtime/computer-use-tools'; import { type CuDispatchBackend } from '@maka/runtime/computer-use-types'; import { type MakaTool, type MakaToolContext } from '@maka/runtime/tool-runtime'; @@ -133,6 +134,508 @@ test('publishes the real Computer Use schema through the Client Capability proto assert.equal(Array.isArray(coordinateSchema?.coordinate?.items), true); }); +test('projects and publishes jsonSchema-wrapped MCP proxy tool descriptors', () => { + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ + { + name: 'fixture_tool', + displayName: 'fixture_tool', + description: 'fixture_tool description', + parameters: jsonSchema({ + $id: 'https://example.com/tool.schema.json', + type: 'object', + properties: { + prefix: { + type: 'string', + default: 'ready', + enum: ['ready', 'done'], + examples: ['ready'], + pattern: '^[a-z]+$', + }, + }, + patternProperties: { + '^x-': { type: 'string' }, + }, + }), + impl: async () => 'ok', + }, + ], + }, + ], + }); + + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); + const published = provider.offers()[0]?.tools[0]?.inputSchema; + const properties = published?.properties as + | Record + | undefined; + const prefixSchema = properties?.prefix; + assert.equal(published?.$id, undefined); + assert.equal(prefixSchema?.default, 'ready'); + assert.deepEqual(prefixSchema?.enum, ['ready', 'done']); + assert.deepEqual(prefixSchema?.examples, ['ready']); + assert.deepEqual(published?.patternProperties, { '^x-': { type: 'string' } }); +}); + +test('validates jsonSchema-wrapped tool arguments and rejects invalid input', async () => { + const calls: unknown[] = []; + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ + { + name: 'fixture_tool', + displayName: 'fixture_tool', + description: 'fixture_tool description', + parameters: jsonSchema({ + type: 'object', + properties: { + prefix: { + type: 'string', + enum: ['ready', 'done'], + }, + }, + required: ['prefix'], + additionalProperties: false, + }), + impl: async (args) => { + calls.push(args); + return 'ok'; + }, + }, + ], + }, + ], + }); + + // Reject enum-violating values. + await assert.rejects( + () => + call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'fixture_tool', + arguments: { prefix: 'abc' }, + }), + ), + /Invalid arguments/, + ); + assert.equal(calls.length, 0); + + // Accept a valid enum value. + await call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'fixture_tool', + arguments: { prefix: 'ready' }, + }), + ); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], { prefix: 'ready' }); +}); + +test('skips non-object root jsonSchema tools without dropping the offer', () => { + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ + { + name: 'bad_tool', + displayName: 'bad_tool', + description: 'bad_tool description', + parameters: jsonSchema({ + type: 'string', + }), + impl: async () => 'nope', + }, + { + name: 'good_tool', + displayName: 'good_tool', + description: 'good_tool description', + parameters: jsonSchema({ + type: 'object', + properties: { value: { type: 'string' } }, + }), + impl: async () => 'ok', + }, + ], + }, + ], + }); + + const tools = provider.offers().flatMap((offer) => offer.tools); + assert.deepEqual( + tools.map((descriptor) => descriptor.name), + ['good_tool'], + ); +}); + +test('skips unsupported schema type tools without dropping the offer', () => { + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ + { + name: 'bad_tool', + displayName: 'bad_tool', + description: 'bad_tool description', + parameters: 42, + impl: async () => 'nope', + }, + { + name: 'good_tool', + displayName: 'good_tool', + description: 'good_tool description', + parameters: jsonSchema({ + type: 'object', + properties: { value: { type: 'string' } }, + }), + impl: async () => 'ok', + }, + ], + }, + ], + }); + + const tools = provider.offers().flatMap((offer) => offer.tools); + assert.deepEqual( + tools.map((descriptor) => descriptor.name), + ['good_tool'], + ); +}); + +test('skips a malformed MCP tool without dropping the other offers', async () => { + let healthyCalls = 0; + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [ + tool('browser_snapshot', z.object({}), async () => { + healthyCalls += 1; + return 'snapshot'; + }), + ], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ + { + name: 'bad_tool', + displayName: 'bad_tool', + description: 'bad_tool description', + parameters: jsonSchema({ + type: 'object', + properties: { value: { type: 'string' } }, + patternProperties: { '(': { type: 'string' } }, + }), + impl: async () => 'nope', + }, + { + name: 'good_tool', + displayName: 'good_tool', + description: 'good_tool description', + parameters: jsonSchema({ + type: 'object', + properties: { value: { type: 'string' } }, + }), + impl: async () => 'ok', + }, + ], + }, + ], + }); + + // The malformed tool is skipped; the healthy tool stays published and + // callable, and the empty-offer case never poisons the registration. + const tools = provider.offers().flatMap((offer) => offer.tools); + assert.deepEqual( + tools.map((descriptor) => descriptor.name), + ['browser_snapshot', 'good_tool'], + ); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); + + await call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'good_tool', + arguments: { value: 'hello' }, + }), + ); + await call( + provider, + capabilityFrame({ + offerId: 'desktop_browser', + serverId: 'desktop_browser', + toolName: 'browser_snapshot', + arguments: {}, + }), + ); + assert.equal(healthyCalls, 1); +}); + +test('does not enforce regex constraints locally (MCP endpoint re-validates)', async () => { + const calls: unknown[] = []; + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ + { + name: 'prefix_tool', + displayName: 'prefix_tool', + description: 'prefix_tool description', + parameters: jsonSchema({ + type: 'object', + properties: { + prefix: { type: 'string', pattern: '^[a-z]+$' }, + }, + }), + impl: async (args) => { + calls.push(args); + return 'ok'; + }, + }, + ], + }, + ], + }); + + // Pattern-violating value is accepted locally; the regex is enforced by + // the MCP endpoint (guards against ReDoS in the Electron main process). + await call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'prefix_tool', + arguments: { prefix: '123' }, + }), + ); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], { prefix: '123' }); +}); + +test('validates tuple items against Ajv 2020 semantics', async () => { + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ + { + name: 'tuple_tool', + displayName: 'tuple_tool', + description: 'tuple_tool description', + parameters: jsonSchema({ + type: 'object', + properties: { + coordinate: { + type: 'array', + items: [{ type: 'integer' }, { type: 'integer' }], + }, + }, + }), + impl: async () => 'ok', + }, + ], + }, + ], + }); + + // A valid draft-07 tuple compiles as prefixItems under Ajv 2020 and passes. + await call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'tuple_tool', + arguments: { coordinate: [1, 2] }, + }), + ); + // A tuple violation is still rejected. + await assert.rejects( + () => + call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: 'tuple_tool', + arguments: { coordinate: [1, 'x'] }, + }), + ), + /Invalid arguments/, + ); +}); + +test('an invalid patternProperties regex key is isolated at the provider boundary', () => { + // An unparseable regex key is rejected by the per-tool validation when the + // provider is built, so the offending tool is skipped instead of reaching + // Ajv.compile or the protocol decode. + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ + { + name: 'bad_tool', + displayName: 'bad_tool', + description: 'bad_tool description', + parameters: jsonSchema({ + type: 'object', + patternProperties: { + '(': { type: 'string' }, + }, + }), + impl: async () => 'nope', + }, + { + name: 'good_tool', + displayName: 'good_tool', + description: 'good_tool description', + parameters: jsonSchema({ + type: 'object', + properties: { value: { type: 'string' } }, + }), + impl: async () => 'ok', + }, + ], + }, + ], + }); + + const tools = provider.offers().flatMap((offer) => offer.tools); + assert.deepEqual( + tools.map((descriptor) => descriptor.name), + ['good_tool'], + ); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); +}); + +test('empty allOf/anyOf/oneOf are projected away so the schema still publishes', () => { + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + tools: [ + { + name: 'fixture_tool', + displayName: 'fixture_tool', + description: 'fixture_tool description', + parameters: jsonSchema({ + type: 'object', + properties: { + x: { type: 'string', allOf: [], anyOf: [], oneOf: [] }, + }, + }), + impl: async () => 'ok', + }, + ], + }, + ], + }); + + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); + const published = provider.offers()[0]?.tools[0]?.inputSchema as + | { properties?: { x?: Record } } + | undefined; + const x = published?.properties?.x; + assert.equal(x !== undefined && 'allOf' in x, false); + assert.equal(x !== undefined && 'anyOf' in x, false); + assert.equal(x !== undefined && 'oneOf' in x, false); +}); + test('publishes every production Desktop-owned tool schema through the protocol', () => { const settingsTools = buildClientSettingsTools({ async read() { diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index bebe81db1c..9d5938ec61 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -25,15 +25,19 @@ import { type ClientCapabilityProvider, type OAuthPresentationBackend, } from "@maka/runtime-host/client"; -import type { - ClientCapabilityCallFrame, - ClientCapabilityCallResult, - ClientCapabilityContentBlock, - ClientCapabilityHostPathAccess, - ClientCapabilityOffer, - ClientCapabilityServiceCallFrame, - ClientCapabilityServiceOffer, +import { + projectToolInputSchema, + validateToolInputSchema, + type ClientCapabilityCallFrame, + type ClientCapabilityCallResult, + type ClientCapabilityContentBlock, + type ClientCapabilityHostPathAccess, + type ClientCapabilityOffer, + type ClientCapabilityServiceCallFrame, + type ClientCapabilityServiceOffer, + type ClientCapabilityToolDescriptor, } from "@maka/runtime-host/protocol"; +import Ajv2020, { type AnySchema, type ValidateFunction } from 'ajv/dist/2020.js'; import { toJSONSchema, z } from "zod"; import { withBrowserOriginAdmission } from './browser/browser-origin-admission.js'; import type { DesktopTargetScope } from '../shared/runtime-host-identity.js'; @@ -114,10 +118,7 @@ export function createDesktopNativeCapabilityProvider( ): DesktopNativeCapabilityProvider { const groups = capabilityGroups(input); const hostPathAccess = providerOptions.hostPathAccess ?? "cwd"; - const offers = Object.freeze( - groups.map((group) => capabilityOffer(group, hostPathAccess)), - ); - const bindings = indexBindings(groups); + const { offers, bindings } = buildPublishedCapabilities(groups, hostPathAccess); const oauthPresentation = input.oauthPresentation ? createOAuthPresentationClientProvider(input.oauthPresentation) : undefined; @@ -337,8 +338,7 @@ async function invokeNativeTool( } const signal = AbortSignal.any([options.signal, invocation.signal]); signal.throwIfAborted(); - const parameters = requireZodSchema(binding.tool); - const args = await parameters.parseAsync(frame.arguments); + const args = await parseNativeToolArguments(binding.tool.parameters, frame.arguments); signal.throwIfAborted(); const sessionId = frame.offerId === BROWSER_OFFER_ID || frame.offerId === COMPUTER_USE_OFFER_ID @@ -419,82 +419,260 @@ function abortInvocations( return settling; } -function capabilityOffer( - group: DesktopCapabilityGroup, +function buildPublishedCapabilities( + groups: readonly DesktopCapabilityGroup[], hostPathAccess: ClientCapabilityHostPathAccess, -): ClientCapabilityOffer { - return Object.freeze({ - offerId: group.offerId, - version: CAPABILITY_VERSION, - affinity: "session", - hostPathAccess, - label: group.label, - description: group.description, - tools: Object.freeze( - group.tools.map((tool) => +): { + readonly offers: readonly ClientCapabilityOffer[]; + readonly bindings: Map; +} { + const offers: ClientCapabilityOffer[] = []; + const bindings = new Map(); + for (const group of groups) { + const tools: ClientCapabilityToolDescriptor[] = []; + for (const tool of group.tools) { + const key = bindingKey({ + offerId: group.offerId, + serverId: group.offerId, + toolName: tool.name, + }); + if (bindings.has(key)) { + throw new Error( + `Duplicate Desktop native capability tool: ${group.offerId}/${tool.name}`, + ); + } + let inputSchema: Record; + try { + inputSchema = toolInputSchema(tool); + validateToolInputSchema(inputSchema); + } catch (error) { + // One malformed MCP descriptor must not take down the whole offer set: + // skip and name the offending tool so Browser, Computer Use, settings, + // and other MCP tools keep publishing. + console.warn( + `Skipping Desktop native capability tool ${group.offerId}/${tool.name}: ${error instanceof Error ? error.message : String(error)}`, + ); + continue; + } + bindings.set(key, { tool }); + tools.push( Object.freeze({ serverId: group.offerId, name: tool.name, description: tool.description, - inputSchema: toolInputSchema(tool), + inputSchema, ...(tool.activityKind ? { activityKind: tool.activityKind } : {}), ...(tool.displayName ? { annotations: Object.freeze({ title: tool.displayName }) } : {}), }), - ), - ), - }); + ); + } + if (tools.length === 0) continue; + offers.push( + Object.freeze({ + offerId: group.offerId, + version: CAPABILITY_VERSION, + affinity: "session", + hostPathAccess, + label: group.label, + description: group.description, + tools: Object.freeze(tools), + }), + ); + } + return { offers: Object.freeze(offers), bindings }; } function toolInputSchema(tool: MakaTool): Record { - const schema = toJSONSchema(requireZodSchema(tool), { - io: "input", - target: "draft-07", - unrepresentable: "any", - cycles: "ref", - reused: "inline", - }); - delete schema.$schema; - if (schema.type !== "object") { + if (tool.parameters instanceof z.ZodType) { + const schema = toJSONSchema(tool.parameters, { + io: "input", + target: "draft-07", + unrepresentable: "any", + cycles: "ref", + reused: "inline", + }); + delete schema.$schema; + if (schema.type !== "object") { + throw new Error( + `Desktop native capability tool schema must be an object: ${tool.name}`, + ); + } + return Object.freeze(schema); + } + + const wrapper = tool.parameters as JsonSchemaWrapper | undefined; + if (wrapper && typeof wrapper.jsonSchema === "object" && wrapper.jsonSchema) { + const schema = wrapper.jsonSchema; + if (typeof schema === "object" && schema !== null) { + return Object.freeze( + projectToolInputSchema(schema as Record), + ); + } + } + + throw new Error( + `Desktop native capability tool has an unsupported schema type: ${tool.name}`, + ); +} + +interface JsonSchemaWrapper { + readonly jsonSchema?: Record; +} + +// -- JSON Schema argument validation ------------------------------------------- +// +// Validation runs against the projected (advertised) schema, not the raw +// MCP schema, so any constraint expressed via non-whitelisted keywords +// (if/then/else, contains, dependentRequired, …) is dropped before Ajv +// sees it. The downstream MCP server re-validates against the full schema. +// +// Regex constraints (pattern / patternProperties) are also omitted from the +// local validator: the schema comes from an untrusted MCP server, and Ajv +// executes those expressions synchronously on the call path, so a +// pathological expression could block the Electron main process (ReDoS). +// The MCP endpoint enforces them against the full schema. + +const jsonSchemaValidatorOptions = { + allErrors: true, + strict: false, + validateFormats: false, +} as const; +const schemaValidator = new Ajv2020(jsonSchemaValidatorOptions); +const compiledSchemas = new WeakMap(); + +function adaptSchemaForLocalValidation(value: unknown): unknown { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map((entry) => adaptSchemaForLocalValidation(entry)); + const schema = value as Record; + const result: Record = {}; + for (const [key, val] of Object.entries(schema)) { + if (key === 'pattern' || key === 'patternProperties') continue; + if (key === 'items' && Array.isArray(val)) { + // draft-07 tuple `items` does not compile under Ajv 2020-12 (which + // requires a single schema or `prefixItems`), so translate it before + // compiling. The advertised schema keeps the original tuple shape. + if ((val as unknown[]).length === 0) continue; + result.prefixItems = (val as unknown[]).map((entry) => + adaptSchemaForLocalValidation(entry), + ); + result.items = false; + continue; + } + switch (key) { + case 'properties': + case '$defs': + case 'definitions': { + if (val === null || typeof val !== 'object' || Array.isArray(val)) { + result[key] = val; + continue; + } + const entries: Record = {}; + for (const [nestedKey, nested] of Object.entries(val as Record)) { + entries[nestedKey] = adaptSchemaForLocalValidation(nested); + } + result[key] = entries; + continue; + } + case 'allOf': + case 'anyOf': + case 'oneOf': { + result[key] = Array.isArray(val) + ? val.map((entry) => adaptSchemaForLocalValidation(entry)) + : val; + continue; + } + case 'additionalProperties': + case 'propertyNames': { + result[key] = + val !== null && typeof val === 'object' && !Array.isArray(val) + ? adaptSchemaForLocalValidation(val) + : val; + continue; + } + default: + result[key] = val; + } + } + return result; +} + +function compileJsonSchema(rawSchema: Record): ValidateFunction { + const cached = compiledSchemas.get(rawSchema); + if (cached) return cached; + const projected = projectToolInputSchema(rawSchema); + const adapted = adaptSchemaForLocalValidation(projected); + let compiled: ValidateFunction; + try { + compiled = schemaValidator.compile(adapted as AnySchema); + } catch (error) { throw new Error( - `Desktop native capability tool schema must be an object: ${tool.name}`, + `Desktop native capability tool has an uncompilable schema: ${ + error instanceof Error ? error.message : String(error) + }`, ); } - return Object.freeze(schema); + compiledSchemas.set(rawSchema, compiled); + return compiled; } -function requireZodSchema(tool: MakaTool): z.ZodType { - if (!(tool.parameters instanceof z.ZodType)) { +async function parseNativeToolArguments( + parameters: unknown, + args: unknown, +): Promise { + if ( + !parameters || + (typeof parameters !== 'object' && typeof parameters !== 'function') + ) { + return args; + } + + // Zod schemas: validate and coerce with the schema itself. + if (parameters instanceof z.ZodType) { + return await parameters.parseAsync(args); + } + + // jsonSchema() wrappers: compile the projected schema and validate. + const wrapper = parameters as JsonSchemaWrapper | undefined; + if (wrapper?.jsonSchema) { + const raw = wrapper.jsonSchema as Record; + const validator = compileJsonSchema(raw); + if (validator(args)) return args; throw new Error( - `Desktop native capability tool has an invalid schema: ${tool.name}`, + `Invalid arguments: ${schemaErrorSummary(validator.errors)}`, ); } - return tool.parameters; + + return args; } -function indexBindings( - groups: readonly DesktopCapabilityGroup[], -): Map { - const bindings = new Map(); - for (const group of groups) { - for (const tool of group.tools) { - const key = bindingKey({ - offerId: group.offerId, - serverId: group.offerId, - toolName: tool.name, - }); - if (bindings.has(key)) { - throw new Error( - `Duplicate Desktop native capability tool: ${group.offerId}/${tool.name}`, - ); - } - bindings.set(key, { tool }); - } +function schemaErrorSummary(error: unknown): string { + if (Array.isArray(error)) { + return (error as Array<{ message?: unknown }>) + .slice(0, 5) + .map( + (entry) => + (typeof entry?.message === 'string' ? entry.message : '') + + (entry && typeof entry === 'object' && 'instancePath' in entry + ? ` at ${(entry as { instancePath: unknown }).instancePath}` + : ''), + ) + .filter(Boolean) + .join('; ') + .slice(0, 1000); } - return bindings; + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : 'validation failed'; + return message.slice(0, 1000); } + + function bindingKey( frame: Pick, ): string { diff --git a/package-lock.json b/package-lock.json index 670c94ab33..0b0f4465a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "@sigstore/bundle": "5.0.0", "@sigstore/tuf": "5.0.0", "@sigstore/verify": "4.1.2", + "ajv": "^8.20.0", "electron-updater": "^6.8.9", "node-pty": "^1.2.0-beta.15", "qrcode": "^1.5.4", @@ -78,6 +79,7 @@ "@vitejs/plugin-react": "^6.1.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "ai": "7.0.70", "electron": "43.4.1", "electron-builder": "26.15.3", "esbuild": "^0.28.1", diff --git a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts index 1f79a4b7a3..12d587e433 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -256,6 +256,26 @@ describe('Client Capability protocol', () => { ), (error: unknown) => error instanceof RuntimeHostProtocolError, ); + assert.doesNotThrow(() => + decodeClientFrame( + replaceFrame([ + { + ...offer('pattern_properties', 'tool'), + tools: [ + { + ...offer('pattern_properties', 'tool').tools[0], + inputSchema: { + type: 'object', + patternProperties: { + '^x-': { type: 'string' }, + }, + }, + }, + ], + }, + ]), + ), + ); for (const inputSchema of [ { type: 'string' }, { type: 'object', unsupportedKeyword: true }, @@ -303,6 +323,50 @@ describe('Client Capability protocol', () => { ]), ), ); + assert.doesNotThrow(() => + decodeClientFrame( + replaceFrame([ + { + ...offer('pattern_properties', 'tool'), + tools: [ + { + ...offer('pattern_properties', 'tool').tools[0], + inputSchema: { + type: 'object', + properties: { + prefix: { type: 'string', pattern: '^[a-z]+$' }, + }, + patternProperties: { + '^x-': { type: 'string' }, + }, + }, + }, + ], + }, + ]), + ), + ); + assert.throws( + () => + decodeClientFrame( + replaceFrame([ + { + ...offer('bad_pattern_property', 'tool'), + tools: [ + { + ...offer('bad_pattern_property', 'tool').tools[0], + inputSchema: { + type: 'object', + properties: { value: { type: 'string' } }, + patternProperties: { '(': { type: 'string' } }, + }, + }, + ], + }, + ]), + ), + (error: unknown) => error instanceof RuntimeHostProtocolError, + ); assert.doesNotThrow(() => decodeClientFrame( replaceFrame([ diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index ce51734621..ede73c3cbd 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -716,7 +716,7 @@ const CLIENT_CAPABILITY_SCHEMA_TYPES = new Set([ 'object', 'string', ]); -const CLIENT_CAPABILITY_SCHEMA_KEYWORDS = new Set([ +export const CLIENT_CAPABILITY_SCHEMA_KEYWORDS = new Set([ '$defs', '$ref', 'additionalProperties', @@ -743,6 +743,7 @@ const CLIENT_CAPABILITY_SCHEMA_KEYWORDS = new Set([ 'multipleOf', 'oneOf', 'pattern', + 'patternProperties', 'propertyNames', 'properties', 'required', @@ -751,7 +752,85 @@ const CLIENT_CAPABILITY_SCHEMA_KEYWORDS = new Set([ 'uniqueItems', ]); -function validateToolInputSchema(root: Record): void { +const CLIENT_CAPABILITY_SCHEMA_CONTAINER_SHAPES: Record< + string, + 'record' | 'array' | 'single_or_array' | 'single' +> = { + properties: 'record', + patternProperties: 'record', + $defs: 'record', + definitions: 'record', + allOf: 'array', + anyOf: 'array', + oneOf: 'array', + items: 'single_or_array', + additionalProperties: 'single', + propertyNames: 'single', +}; + +/** + * Project an external JSON Schema (e.g. from an MCP tool) down to exactly the + * keywords the Client Capability protocol admits, recursing into nested schemas + * via the same shape table that {@link validateToolInputSchema} uses. + * + * `$ref` is retained when it resolves locally inside `$defs`/`definitions`; + * otherwise upstream callers should omit it first. + * + * Empty `items`, `allOf`, `anyOf`, and `oneOf` are dropped so the projected + * schema never emits a shape the protocol boundary rejects. + */ +export function projectToolInputSchema(schema: Record): Record { + if (!Object.hasOwn(schema, 'type') || schema.type !== 'object') { + throw new Error('Client Capability tool schema root must be an object'); + } + return projectSchemaNode(schema) as Record; +} + +function projectSchemaNode(value: unknown): unknown { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map((entry) => projectSchemaNode(entry)); + const schema = value as Record; + const result: Record = {}; + for (const [key, val] of Object.entries(schema)) { + if (!CLIENT_CAPABILITY_SCHEMA_KEYWORDS.has(key)) continue; + const projected = projectSchemaKeyword(key, val); + if (projected !== undefined) { + result[key] = projected; + } + } + return result; +} + +function projectSchemaKeyword(key: string, value: unknown): unknown { + const shape = CLIENT_CAPABILITY_SCHEMA_CONTAINER_SHAPES[key]; + if (shape === undefined) return value; + switch (shape) { + case 'record': { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}; + const result: Record = {}; + for (const [nestedKey, nestedValue] of Object.entries(value as Record)) { + result[nestedKey] = projectSchemaNode(nestedValue); + } + return result; + } + case 'array': { + if (!Array.isArray(value) || value.length === 0) return undefined; + return value.map((entry) => projectSchemaNode(entry)); + } + case 'single_or_array': { + if (Array.isArray(value)) { + if (value.length === 0) return undefined; + return value.map((entry) => projectSchemaNode(entry)); + } + return projectSchemaNode(value); + } + case 'single': { + return projectSchemaNode(value); + } + } +} + +export function validateToolInputSchema(root: Record): void { if (!Object.hasOwn(root, 'type') || root.type !== 'object') { throw invalidProtocolFrame('Client Capability tool schema root must be an object'); } @@ -810,11 +889,6 @@ function validateToolInputSchema(root: Record): void { if (schema.uniqueItems !== undefined && typeof schema.uniqueItems !== 'boolean') { throw invalidProtocolFrame('Invalid Client Capability tool schema uniqueItems'); } - for (const key of ['properties', '$defs', 'definitions'] as const) { - if (schema[key] === undefined) continue; - const entries = requireRecord(schema[key], `Client Capability tool schema ${key}`); - for (const nested of Object.values(entries)) visit(nested); - } if (schema.required !== undefined) { if ( !Array.isArray(schema.required) || @@ -824,31 +898,42 @@ function validateToolInputSchema(root: Record): void { throw invalidProtocolFrame('Invalid Client Capability tool schema required'); } } - if ( - schema.additionalProperties !== undefined && - typeof schema.additionalProperties !== 'boolean' - ) { - visit(schema.additionalProperties); - } - if (schema.propertyNames !== undefined) { - visit(schema.propertyNames); - } - if (schema.items !== undefined) { - if (Array.isArray(schema.items)) { - if (schema.items.length === 0) { - throw invalidProtocolFrame('Invalid Client Capability tool schema items'); - } - for (const nested of schema.items) visit(nested); - } else { - visit(schema.items); - } - } - for (const key of ['allOf', 'anyOf', 'oneOf'] as const) { + for (const [key, shape] of Object.entries(CLIENT_CAPABILITY_SCHEMA_CONTAINER_SHAPES)) { if (schema[key] === undefined) continue; - if (!Array.isArray(schema[key]) || schema[key].length === 0) { - throw invalidProtocolFrame(`Invalid Client Capability tool schema ${key}`); + switch (shape) { + case 'record': { + const entries = requireRecord(schema[key], `Client Capability tool schema ${key}`); + if (key === 'patternProperties') { + for (const patternKey of Object.keys(entries)) { + validateSchemaPattern(patternKey); + } + } + for (const nested of Object.values(entries)) visit(nested); + break; + } + case 'array': { + if (!Array.isArray(schema[key]) || (schema[key] as unknown[]).length === 0) { + throw invalidProtocolFrame(`Invalid Client Capability tool schema ${key}`); + } + for (const nested of schema[key] as unknown[]) visit(nested); + break; + } + case 'single_or_array': { + if (Array.isArray(schema[key])) { + if ((schema[key] as unknown[]).length === 0) { + throw invalidProtocolFrame(`Invalid Client Capability tool schema ${key}`); + } + for (const nested of schema[key] as unknown[]) visit(nested); + } else { + visit(schema[key]); + } + break; + } + case 'single': { + visit(schema[key]); + break; + } } - for (const nested of schema[key]) visit(nested); } if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length === 0)) { throw invalidProtocolFrame('Invalid Client Capability tool schema enum'); @@ -874,6 +959,21 @@ function validateToolInputSchema(root: Record): void { } } +function validateSchemaPattern(value: unknown): void { + if (typeof value !== 'string') { + throw invalidProtocolFrame( + 'Client Capability tool schema patternProperties key must be a string', + ); + } + try { + new RegExp(value); + } catch { + throw invalidProtocolFrame( + 'Client Capability tool schema patternProperties key is not a valid pattern', + ); + } +} + function validateSchemaType(value: unknown): void { const values = Array.isArray(value) ? value : [value]; if ( diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 9abf64b3a6..7aa30d36a2 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -100,7 +100,11 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 105 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 106 as const; +// 106: Client Capability tool schema vocabulary adds `patternProperties`; +// `validateToolInputSchema` recursion and `projectToolInputSchema` are now +// driven by a single per-keyword shape table exported alongside the keyword +// set. Older peers reject the unknown keyword and fail the handshake. // 105: Usage summaries may carry the recorded call-time total and per-Session // tool-invocation totals. Older Clients reject the unknown fields, so a newer // Host's usage summary is unreadable to them.