From a8642d00d3ee1bfceb1dc79981515732cdcb94ba Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Fri, 21 Aug 2026 09:28:19 +0800 Subject: [PATCH 01/15] fix(desktop): isolate invalid optional MCP tools Keep the Desktop Runtime Host candidate usable when one optional MCP tool publishes an invalid schema, while retaining fail-closed behavior for required native capabilities. Cover AI SDK JSON Schema invocation and rate-limit terminal admission recovery. Generated-by: Codex --- .../__tests__/runtime-host-client-uds.test.ts | 114 ++++++++++++++++++ .../runtime-host-native-capabilities.test.ts | 69 +++++++++++ apps/desktop/src/main/runtime-host-boot.ts | 4 +- .../main/runtime-host-desktop-candidate.ts | 1 + .../src/__tests__/execution-host.test.ts | 39 ++++++ 5 files changed, 225 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index da520348ce..eb054fc3cb 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -128,6 +128,120 @@ test('drives Desktop Session operations through a real Runtime Host connection', } }); +test('keeps the Desktop candidate usable when an optional MCP tool has an invalid schema', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-desktop-invalid-mcp-')); + let host: RuntimeHostKernel | undefined; + try { + const capability = await resolveStorageRoot({ path: base, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + const projected = session('session-invalid-mcp'); + host = await RuntimeHostKernel.start({ + owner, + idleGraceMs: 10_000, + composition: defineInteractiveRuntimeHostComposition(async () => ({ + handlers: handlers({ + 'client.capability.replace': async (input) => { + const mcp = input.offers.find((offer) => offer.offerId === 'desktop_mcp'); + assert.deepEqual(mcp?.tools.map(({ name }) => name), ['mcp_valid']); + return { + ok: true, + result: { registrationId: input.registrationId, revision: 1 }, + }; + }, + 'client.capability.unregister': async (input) => ({ + ok: true, + result: { registrationId: input.registrationId, revision: 2 }, + }), + 'session.catalog.query': async (input) => ({ + ok: true, + result: + input.kind === 'get' + ? { kind: 'session', session: input.sessionId === projected.id ? projected : null } + : { + kind: 'page', + revision: catalogRevision('1'), + sessions: [projected], + nextCursor: null, + }, + }), + }), + beginDrain() {}, + async recover() {}, + async close() {}, + })), + }); + const ipc = ipcHarness(); + const diagnostics: unknown[] = []; + const invalidTool = { + ...nativeTool(), + name: 'mcp_invalid', + parameters: { jsonSchema: { type: 'string' } }, + } as unknown as MakaTool; + const validTool = { + ...nativeTool(), + name: 'mcp_valid', + parameters: { jsonSchema: { type: 'object', properties: {} } }, + } as unknown as MakaTool; + const started = await startDesktopRuntimeHostCandidate({ + rootPath: base, + candidateEntrypoint: new URL('file:///unused-runtime-host-candidate.js'), + ipcMain: ipc, + workspaceRoot: base, + attachmentApprovals: createAttachmentApprovalRegistry(), + stat: async () => ({ size: 0 }), + resizeImage: async (bytes) => bytes, + nativeCapabilities: { + browserTools: [], + releaseBrowserSession() {}, + computerUseTools: Object.assign([], { + clearSession() {}, + }) as unknown as ComputerUseToolSet, + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + invalidToolPolicy: 'omit', + tools: [invalidTool, validTool], + }, + ], + }, + botRegistry: {} as BotRegistry, + resolveBotCreateTarget: async () => ({ + workspace: { kind: 'host_path', path: base }, + }), + resolveSessionCreateProject: async () => ({ kind: 'host_path', path: base }), + emitSessionsChanged() {}, + completeComputerUseTurn() {}, + onError: (error) => diagnostics.push(error), + createSessionCopyCleanup: () => ({ + ownCreation: (_creation, operation) => operation(), + cleanup: async () => undefined, + schedule: async () => undefined, + abandonOwner: async () => undefined, + recover: async () => ({ removed: [], failed: [] }), + }), + }); + assert.equal(started.kind, 'ready'); + if (started.kind !== 'ready') throw new Error('Desktop candidate did not start'); + const { candidate } = started; + ipc.setHost(candidate.client.hostId, ipc.epoch); + + assert.deepEqual( + ((await ipc.invoke('sessions:list')) as SessionCatalogProjection[]).map(({ id }) => id), + [projected.id], + ); + assert.equal(diagnostics.length, 1); + assert.match(String(diagnostics[0]), /desktop_mcp\/mcp_invalid/); + await candidate.close(); + } finally { + await host?.close().catch(() => undefined); + await rm(base, { recursive: true, force: true }); + } +}); + test('drives the renderer Session catalog facade through real UDS framing', async () => { const base = await mkdtemp(join(tmpdir(), 'maka-desktop-host-ipc-')); let host: RuntimeHostKernel | undefined; 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 1b73728b91..4ef06345a1 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 @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { buildComputerUseTools, type ComputerUseToolSet } from '@maka/runtime/computer-use-tools'; import { type CuDispatchBackend } from '@maka/runtime/computer-use-types'; +import { buildMcpTools, type McpToolProvider } from '@maka/runtime/mcp-tools'; import { type MakaTool, type MakaToolContext } from '@maka/runtime/tool-runtime'; import type { ClientCapabilityProvider } from '@maka/runtime-host/client'; import { @@ -180,6 +181,74 @@ test('publishes every production Desktop-owned tool schema through the protocol' ); }); +test('publishes and invokes an MCP tool backed by an AI SDK JSON Schema', async () => { + let invocation: { args: Record; cwd: string } | undefined; + const mcpProvider: McpToolProvider = { + toolSnapshot: () => ({ + revision: 1, + tools: [ + { + binding: 'fixture-binding' as never, + descriptor: { + serverId: 'fixture', + name: 'lookup', + inputSchema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + additionalProperties: false, + }, + }, + }, + ], + }), + async callTool(_binding, args, options) { + invocation = { args, cwd: options.context.cwd }; + return { content: [{ type: 'text', text: 'found' }] }; + }, + }; + const [mcpTool] = buildMcpTools(mcpProvider, { executionLocation: 'remote' }); + assert.ok(mcpTool); + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + invalidToolPolicy: 'omit', + tools: [mcpTool], + }, + ], + }); + + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); + assert.deepEqual( + await call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: mcpTool.name, + arguments: { query: 'maka' }, + }), + ), + { content: [{ type: 'text', text: 'found' }] }, + ); + assert.deepEqual(invocation, { + args: { query: 'maka' }, + cwd: '/workspace', + }); +}); + test('publishes and admits additional Desktop native-effect services', async () => { let admitted = false; const provider = createDesktopNativeCapabilityProvider( diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 7caee1cf13..27ab9a2d89 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1038,8 +1038,8 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( serverId: identified.serverId, toolName: identified.toolName, })), - dynamic: true as const, - })), + dynamic: true as const, + })), ]; }, additionalServices: (scope) => [ diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 81dd3bffbc..87300d60c9 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -745,6 +745,7 @@ export async function createDesktopRuntimeHostCandidate( desktopSessionResourceKey({ ...scope, sessionId }), onSessionUsed: (sessionId) => nativeSessionIds.add(sessionId), onComputerUseTurnUsed: watchComputerUseTurn, + onInvalidTool: reportError, isTargetValid: deps.isTargetValid, onClosed: () => providers.delete(provider), onDiagnostic: logLocalRuntimeHostProcessDiagnostic, diff --git a/packages/runtime-host/src/__tests__/execution-host.test.ts b/packages/runtime-host/src/__tests__/execution-host.test.ts index 096479011b..b4b4998cb9 100644 --- a/packages/runtime-host/src/__tests__/execution-host.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host.test.ts @@ -59,6 +59,7 @@ import { import { FAKE_ASK_SANDBOX_BOUNDARY_PROMPT, FAKE_ASK_USER_QUESTION_PROMPT, + FAKE_ERROR_PROMPT_PREFIX, FAKE_WAIT_FOR_STEERING_PROMPT, } from '@maka/runtime/test-only/fake-backend'; import { type MakaTool, type MakaToolContext } from '@maka/runtime/tool-runtime'; @@ -931,6 +932,44 @@ test('regenerate replays the durable source content with one recoverable root id }); }); +test('a rate-limited root Turn releases admission before regenerate', async () => { + await withExecutionRoot(async (fixture) => { + const host = await fixture.startHost(); + const client = await connectClient(fixture.root); + const sourceTurnId = randomUUID(); + const regeneratedTurnId = randomUUID(); + try { + await client.startTurn( + { + sessionId: fixture.sessionId, + turnId: sourceTurnId, + content: { text: `${FAKE_ERROR_PROMPT_PREFIX}rate_limit` }, + }, + PROCESS_TIMEOUT_MS, + ); + const failed = await waitForTerminalTurn(client, fixture.sessionId, sourceTurnId); + assert.equal(failed.status, 'failed'); + + const regenerated = await client.regenerateTurn( + { + sessionId: fixture.sessionId, + sourceTurnId, + turnId: regeneratedTurnId, + }, + PROCESS_TIMEOUT_MS, + ); + assert.equal(regenerated.turnId, regeneratedTurnId); + assert.equal( + (await waitForTerminalTurn(client, fixture.sessionId, regeneratedTurnId)).status, + 'failed', + ); + } finally { + await client.close(); + await fixture.stopHost(host); + } + }); +}); + test('regenerate rejects self-source and legacy target collisions without draining Host', async () => { await withExecutionRoot(async (fixture) => { const firstHost = await fixture.startHost(); From 46bf43d7c3e88aa073c402251427da4dc23bbc6f Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Fri, 21 Aug 2026 13:50:17 +0800 Subject: [PATCH 02/15] fix(desktop): validate optional MCP capability manifests Validate optional MCP tools against the complete capability manifest, reuse Runtime JSON Schema validation before admission, and remove unrelated Runtime Host coverage. Generated-by: Codex --- .../runtime-host-native-capabilities.test.ts | 106 ++++++++++++++++++ .../main/runtime-host-native-capabilities.ts | 22 +++- .../src/__tests__/execution-host.test.ts | 39 ------- packages/runtime/package.json | 1 + packages/runtime/src/ai-sdk-backend.ts | 54 ++------- .../runtime/src/json-schema-validation.ts | 52 +++++++++ 6 files changed, 184 insertions(+), 90 deletions(-) create mode 100644 packages/runtime/src/json-schema-validation.ts 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 4ef06345a1..1ffd1586af 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 @@ -183,6 +183,7 @@ test('publishes every production Desktop-owned tool schema through the protocol' test('publishes and invokes an MCP tool backed by an AI SDK JSON Schema', async () => { let invocation: { args: Record; cwd: string } | undefined; + let accepted = false; const mcpProvider: McpToolProvider = { toolSnapshot: () => ({ revision: 1, @@ -231,6 +232,24 @@ test('publishes and invokes an MCP tool backed by an AI SDK JSON Schema', async offers: provider.offers(), }), ); + await assert.rejects( + () => + call( + provider, + capabilityFrame({ + offerId: 'desktop_mcp', + serverId: 'desktop_mcp', + toolName: mcpTool.name, + arguments: {}, + }), + () => { + accepted = true; + }, + ), + /Invalid arguments for tool/u, + ); + assert.equal(accepted, false); + assert.equal(invocation, undefined); assert.deepEqual( await call( provider, @@ -240,6 +259,9 @@ test('publishes and invokes an MCP tool backed by an AI SDK JSON Schema', async toolName: mcpTool.name, arguments: { query: 'maka' }, }), + () => { + accepted = true; + }, ), { content: [{ type: 'text', text: 'found' }] }, ); @@ -247,6 +269,65 @@ test('publishes and invokes an MCP tool backed by an AI SDK JSON Schema', async args: { query: 'maka' }, cwd: '/workspace', }); + assert.equal(accepted, true); +}); + +test('omits optional MCP tools that would exceed one offer\'s tool limit', () => { + const diagnostics: Error[] = []; + const provider = createDesktopNativeCapabilityProvider( + { + browserTools: [], + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + { + offerId: 'desktop_mcp', + label: 'MCP', + description: 'MCP tools', + invalidToolPolicy: 'omit', + tools: Array.from({ length: 65 }, (_, index) => + tool(`mcp_tool_${index + 1}`, z.object({}), async () => 'ok'), + ), + }, + ], + }, + { onInvalidTool: (error) => diagnostics.push(error) }, + ); + + assert.deepEqual( + provider.offers()[0]?.tools.map(({ name }) => name), + Array.from({ length: 64 }, (_, index) => `mcp_tool_${index + 1}`), + ); + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0]?.message ?? '', /desktop_mcp\/mcp_tool_65/u); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ registrationId: 'registration-1', offers: provider.offers() }), + ); +}); + +test('omits optional MCP tools that would exceed the complete manifest byte limit', () => { + const diagnostics: Error[] = []; + const provider = createDesktopNativeCapabilityProvider( + { + browserTools: [], + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + optionalMcpGroup('desktop_mcp_first', 'mcp_first', 28 * 1024), + optionalMcpGroup('desktop_mcp_second', 'mcp_second', 28 * 1024), + ], + }, + { onInvalidTool: (error) => diagnostics.push(error) }, + ); + + assert.deepEqual(provider.offers().map(({ offerId }) => offerId), ['desktop_mcp_first']); + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0]?.message ?? '', /desktop_mcp_second\/mcp_second/u); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ registrationId: 'registration-1', offers: provider.offers() }), + ); }); test('publishes and admits additional Desktop native-effect services', async () => { @@ -976,6 +1057,31 @@ function tool( }; } +function optionalMcpGroup(offerId: string, name: string, schemaDescriptionLength: number) { + return { + offerId, + label: 'MCP', + description: 'MCP tools', + invalidToolPolicy: 'omit' as const, + tools: [ + { + name, + displayName: name, + description: `${name} description`, + parameters: { + jsonSchema: { + type: 'object', + description: 'x'.repeat(schemaDescriptionLength), + }, + }, + async impl() { + return 'ok'; + }, + } as MakaTool, + ], + }; +} + function serviceFrame(): ClientCapabilityServiceCallFrame { return { kind: 'client.capability.service_call', diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts index a4df30f0f1..135f3c0abd 100644 --- a/apps/desktop/src/main/runtime-host-native-capabilities.ts +++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts @@ -19,6 +19,10 @@ import { Buffer } from "node:buffer"; import type { ComputerUseToolSet } from '@maka/runtime/computer-use-tools'; +import { + jsonSchemaErrorSummary, + validateJsonSchemaInput, +} from '@maka/runtime/json-schema-validation'; import type { MakaTool } from '@maka/runtime/tool-runtime'; import { createOAuthPresentationClientProvider, @@ -676,10 +680,20 @@ async function parseToolArguments(tool: MakaTool, args: unknown): Promise { diff --git a/packages/runtime-host/src/__tests__/execution-host.test.ts b/packages/runtime-host/src/__tests__/execution-host.test.ts index b4b4998cb9..096479011b 100644 --- a/packages/runtime-host/src/__tests__/execution-host.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host.test.ts @@ -59,7 +59,6 @@ import { import { FAKE_ASK_SANDBOX_BOUNDARY_PROMPT, FAKE_ASK_USER_QUESTION_PROMPT, - FAKE_ERROR_PROMPT_PREFIX, FAKE_WAIT_FOR_STEERING_PROMPT, } from '@maka/runtime/test-only/fake-backend'; import { type MakaTool, type MakaToolContext } from '@maka/runtime/tool-runtime'; @@ -932,44 +931,6 @@ test('regenerate replays the durable source content with one recoverable root id }); }); -test('a rate-limited root Turn releases admission before regenerate', async () => { - await withExecutionRoot(async (fixture) => { - const host = await fixture.startHost(); - const client = await connectClient(fixture.root); - const sourceTurnId = randomUUID(); - const regeneratedTurnId = randomUUID(); - try { - await client.startTurn( - { - sessionId: fixture.sessionId, - turnId: sourceTurnId, - content: { text: `${FAKE_ERROR_PROMPT_PREFIX}rate_limit` }, - }, - PROCESS_TIMEOUT_MS, - ); - const failed = await waitForTerminalTurn(client, fixture.sessionId, sourceTurnId); - assert.equal(failed.status, 'failed'); - - const regenerated = await client.regenerateTurn( - { - sessionId: fixture.sessionId, - sourceTurnId, - turnId: regeneratedTurnId, - }, - PROCESS_TIMEOUT_MS, - ); - assert.equal(regenerated.turnId, regeneratedTurnId); - assert.equal( - (await waitForTerminalTurn(client, fixture.sessionId, regeneratedTurnId)).status, - 'failed', - ); - } finally { - await client.close(); - await fixture.stopHost(host); - } - }); -}); - test('regenerate rejects self-source and legacy target collisions without draining Host', async () => { await withExecutionRoot(async (fixture) => { const firstHost = await fixture.startHost(); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 6d44e393bc..55cf254bb9 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -113,6 +113,7 @@ "./tool-result-archive-capability": "./dist/tool-result-archive-capability.js", "./tool-result-archive-resource": "./dist/tool-result-archive-resource.js", "./tool-runtime": "./dist/tool-runtime.js", + "./json-schema-validation": "./dist/json-schema-validation.js", "./web-fetch-tool": "./dist/web-fetch-tool.js", "./web-search-tool": "./dist/web-search-tool.js", "./xai-oauth-enrollment": "./dist/xai-oauth-enrollment.js" diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index f314471fef..5d0db70548 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -123,13 +123,11 @@ import type { UserContent, } from './model-protocol.js'; import type { ModelCallCommit } from '@maka/core/agent-run'; -import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv'; -import Ajv2019 from 'ajv/dist/2019.js'; -import Ajv2020 from 'ajv/dist/2020.js'; import { z } from 'zod'; import { AsyncEventQueue } from './async-queue.js'; import { AdmissionLimiter } from './admission-limiter.js'; +import { jsonSchemaErrorSummary, validateJsonSchemaInput } from './json-schema-validation.js'; import { type CodeModeExecutionResult, DEFAULT_CODE_MODE_EXECUTION_POLICY, @@ -571,16 +569,6 @@ function nestableToolSnapshot( ); } -const codeModeJsonSchemaOptions = { - allErrors: true, - strict: false, - validateFormats: false, -} as const; -const codeModeDraft7Validator = new Ajv(codeModeJsonSchemaOptions); -const codeModeDraft2019Validator = new Ajv2019(codeModeJsonSchemaOptions); -const codeModeDraft2020Validator = new Ajv2020(codeModeJsonSchemaOptions); -const codeModeCompiledSchemas = new WeakMap(); - async function validateCodeModeToolInput(tool: MakaTool, input: unknown): Promise { const parameters = tool.parameters as { safeParseAsync?: ( @@ -612,29 +600,11 @@ async function validateCodeModeToolInput(tool: MakaTool, input: unknown): Promis } const schema = await parameters.jsonSchema; - const validator = compileCodeModeJsonSchema(schema ?? tool.parameters); - if (!validator || validator(input)) return input; - throw invalidCodeModeToolArguments(tool.name, validator.errors); -} - -function compileCodeModeJsonSchema(schema: unknown): ValidateFunction | undefined { - if (typeof schema === 'boolean') return codeModeDraft2020Validator.compile(schema); - if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return undefined; - const cached = codeModeCompiledSchemas.get(schema); - if (cached) return cached; - const declaredDialect = (schema as { readonly $schema?: unknown }).$schema; - const dialect = typeof declaredDialect === 'string' ? declaredDialect : ''; - const validator = dialect.includes('draft-07') - ? codeModeDraft7Validator - : dialect.includes('2019-09') - ? codeModeDraft2019Validator - : codeModeDraft2020Validator; - const schemaForCompile = dialect.startsWith('https://json-schema.org/draft-07/schema') - ? { ...schema, $schema: dialect.replace('https://', 'http://') } - : schema; - const compiled = validator.compile(schemaForCompile as AnySchema); - codeModeCompiledSchemas.set(schema, compiled); - return compiled; + try { + return validateJsonSchemaInput(schema ?? tool.parameters, input); + } catch (error) { + throw invalidCodeModeToolArguments(tool.name, error); + } } function invalidCodeModeToolArguments(toolName: string, error: unknown): Error { @@ -654,17 +624,7 @@ function schemaErrorSummary(error: unknown): string { .join('; ') .slice(0, 1000); } - if (Array.isArray(error)) { - return (error as ErrorObject[]) - .slice(0, 5) - .map((issue) => { - const path = issue.instancePath || issue.schemaPath; - return `${path || 'input'} ${issue.message ?? 'is invalid'}`; - }) - .join('; ') - .slice(0, 1000); - } - return 'input does not match the declared schema'; + return jsonSchemaErrorSummary(error); } function joinPromptFragments(fragments: readonly (string | undefined)[]): string | undefined { diff --git a/packages/runtime/src/json-schema-validation.ts b/packages/runtime/src/json-schema-validation.ts new file mode 100644 index 0000000000..ef8ad67a14 --- /dev/null +++ b/packages/runtime/src/json-schema-validation.ts @@ -0,0 +1,52 @@ +import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv'; +import Ajv2019 from 'ajv/dist/2019.js'; +import Ajv2020 from 'ajv/dist/2020.js'; + +const jsonSchemaOptions = { + allErrors: true, + strict: false, + validateFormats: false, +} as const; +const draft7Validator = new Ajv(jsonSchemaOptions); +const draft2019Validator = new Ajv2019(jsonSchemaOptions); +const draft2020Validator = new Ajv2020(jsonSchemaOptions); +const compiledSchemas = new WeakMap(); + +/** Validate an input against a provider JSON Schema when the schema is compilable. */ +export function validateJsonSchemaInput(schema: unknown, input: unknown): unknown { + const validator = compileJsonSchema(schema); + if (!validator || validator(input)) return input; + throw validator.errors; +} + +function compileJsonSchema(schema: unknown): ValidateFunction | undefined { + if (typeof schema === 'boolean') return draft2020Validator.compile(schema); + if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return undefined; + const cached = compiledSchemas.get(schema); + if (cached) return cached; + const declaredDialect = (schema as { readonly $schema?: unknown }).$schema; + const dialect = typeof declaredDialect === 'string' ? declaredDialect : ''; + const validator = dialect.includes('draft-07') + ? draft7Validator + : dialect.includes('2019-09') + ? draft2019Validator + : draft2020Validator; + const schemaForCompile = dialect.startsWith('https://json-schema.org/draft-07/schema') + ? { ...schema, $schema: dialect.replace('https://', 'http://') } + : schema; + const compiled = validator.compile(schemaForCompile as AnySchema); + compiledSchemas.set(schema, compiled); + return compiled; +} + +export function jsonSchemaErrorSummary(error: unknown): string { + if (!Array.isArray(error)) return 'input does not match the declared schema'; + return (error as ErrorObject[]) + .slice(0, 5) + .map((issue) => { + const path = issue.instancePath || issue.schemaPath; + return `${path || 'input'} ${issue.message ?? 'is invalid'}`; + }) + .join('; ') + .slice(0, 1000); +} From 572a24ac9102c213c7534a3db89add068bbfabaa Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Sat, 22 Aug 2026 17:40:48 +0800 Subject: [PATCH 03/15] fix(ci): bump Windows auto-update product version Use a temporary Desktop manifest version while building the Windows auto-update installer so the PE product version matches the update feed, then restore the source manifest. Generated-by: Codex --- scripts/package-windows-autoupdate-next.mjs | 51 ++++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/scripts/package-windows-autoupdate-next.mjs b/scripts/package-windows-autoupdate-next.mjs index a33fd230f7..c430431140 100644 --- a/scripts/package-windows-autoupdate-next.mjs +++ b/scripts/package-windows-autoupdate-next.mjs @@ -17,7 +17,8 @@ * under the License. */ -import { access, readFile, rm } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { access, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { runCommand } from './package-windows-x64.mjs'; @@ -75,22 +76,38 @@ export async function packageWindowsAutoupdateNext({ await access(join(desktopRoot, 'release', 'win-unpacked')); await rm(outputDirectory, { recursive: true, force: true }); - await run('npm', [ - '--workspace', - '@maka/desktop', - 'exec', - '--', - 'electron-builder', - '--config', - 'electron-builder.config.mjs', - '--win', - 'nsis', - '--x64', - '--publish', - 'never', - `-c.extraMetadata.version=${nextVersion}`, - '-c.directories.output=release-autoupdate-next', - ]); + // electron-builder reads the root Desktop manifest for the executable's + // Windows version resource. `extraMetadata` only changes the packaged app + // manifest, so it cannot by itself produce a genuinely version-bumped + // installer. Temporarily update the source manifest for the build and + // restore it even when electron-builder fails. + const originalManifest = await readFile(join(desktopRoot, 'package.json')); + const bumpedManifest = JSON.parse(originalManifest); + bumpedManifest.version = nextVersion; + await writeFile( + join(desktopRoot, 'package.json'), + `${JSON.stringify(bumpedManifest, null, 2)}\n`, + ); + try { + await run('npm', [ + '--workspace', + '@maka/desktop', + 'exec', + '--', + 'electron-builder', + '--config', + 'electron-builder.config.mjs', + '--win', + 'nsis', + '--x64', + '--publish', + 'never', + `-c.extraMetadata.version=${nextVersion}`, + '-c.directories.output=release-autoupdate-next', + ]); + } finally { + await writeFile(join(desktopRoot, 'package.json'), originalManifest); + } // Assert the properties the harness depends on, not just process exit 0. await access(exePath); From faeb048166367ee63a6f64e1ed1cafa31c56ae6d Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Sat, 22 Aug 2026 21:09:24 +0800 Subject: [PATCH 04/15] fix(ci): sync root version for Windows autoupdate build --- scripts/package-windows-autoupdate-next.mjs | 37 +++++++++++++-------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/scripts/package-windows-autoupdate-next.mjs b/scripts/package-windows-autoupdate-next.mjs index c430431140..cac128106f 100644 --- a/scripts/package-windows-autoupdate-next.mjs +++ b/scripts/package-windows-autoupdate-next.mjs @@ -30,6 +30,8 @@ import { const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const desktopRoot = join(repoRoot, 'apps', 'desktop'); +const rootManifestPath = join(repoRoot, 'package.json'); +const desktopManifestPath = join(desktopRoot, 'package.json'); /** * The version the auto-update harness serves as "newer than the candidate". @@ -58,7 +60,7 @@ export async function packageWindowsAutoupdateNext({ throw new Error('The auto-update installer build requires a Windows x64 host.'); } - const manifest = JSON.parse(await readFile(join(desktopRoot, 'package.json'), 'utf8')); + const manifest = JSON.parse(await readFile(desktopManifestPath, 'utf8')); const nextVersion = bumpedAutoupdateVersion(manifest.version); const outputDirectory = join(desktopRoot, 'release-autoupdate-next'); // The bumped build is the same target under a different version, so its @@ -76,19 +78,23 @@ export async function packageWindowsAutoupdateNext({ await access(join(desktopRoot, 'release', 'win-unpacked')); await rm(outputDirectory, { recursive: true, force: true }); - // electron-builder reads the root Desktop manifest for the executable's - // Windows version resource. `extraMetadata` only changes the packaged app - // manifest, so it cannot by itself produce a genuinely version-bumped - // installer. Temporarily update the source manifest for the build and - // restore it even when electron-builder fails. - const originalManifest = await readFile(join(desktopRoot, 'package.json')); - const bumpedManifest = JSON.parse(originalManifest); - bumpedManifest.version = nextVersion; - await writeFile( - join(desktopRoot, 'package.json'), - `${JSON.stringify(bumpedManifest, null, 2)}\n`, - ); + // electron-builder requires the root and Desktop manifests to have the same + // version. `extraMetadata` only changes the packaged app manifest, so + // temporarily update both source manifests for the build and restore them + // even when electron-builder fails. + const [originalRootManifest, originalDesktopManifest] = await Promise.all([ + readFile(rootManifestPath), + readFile(desktopManifestPath), + ]); + const bumpedRootManifest = JSON.parse(originalRootManifest); + const bumpedDesktopManifest = JSON.parse(originalDesktopManifest); + bumpedRootManifest.version = nextVersion; + bumpedDesktopManifest.version = nextVersion; try { + await Promise.all([ + writeFile(rootManifestPath, `${JSON.stringify(bumpedRootManifest, null, 2)}\n`), + writeFile(desktopManifestPath, `${JSON.stringify(bumpedDesktopManifest, null, 2)}\n`), + ]); await run('npm', [ '--workspace', '@maka/desktop', @@ -106,7 +112,10 @@ export async function packageWindowsAutoupdateNext({ '-c.directories.output=release-autoupdate-next', ]); } finally { - await writeFile(join(desktopRoot, 'package.json'), originalManifest); + await Promise.all([ + writeFile(rootManifestPath, originalRootManifest), + writeFile(desktopManifestPath, originalDesktopManifest), + ]); } // Assert the properties the harness depends on, not just process exit 0. From 638831790a9f64d0c4be3f2cd6ddffd517364a7e Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Sun, 23 Aug 2026 13:13:10 +0800 Subject: [PATCH 05/15] fix(ci): sync CLI version for autoupdate build --- scripts/package-windows-autoupdate-next.mjs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/scripts/package-windows-autoupdate-next.mjs b/scripts/package-windows-autoupdate-next.mjs index cac128106f..97373c43b6 100644 --- a/scripts/package-windows-autoupdate-next.mjs +++ b/scripts/package-windows-autoupdate-next.mjs @@ -32,6 +32,7 @@ const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const desktopRoot = join(repoRoot, 'apps', 'desktop'); const rootManifestPath = join(repoRoot, 'package.json'); const desktopManifestPath = join(desktopRoot, 'package.json'); +const cliManifestPath = join(repoRoot, 'packages', 'cli', 'package.json'); /** * The version the auto-update harness serves as "newer than the candidate". @@ -78,22 +79,27 @@ export async function packageWindowsAutoupdateNext({ await access(join(desktopRoot, 'release', 'win-unpacked')); await rm(outputDirectory, { recursive: true, force: true }); - // electron-builder requires the root and Desktop manifests to have the same - // version. `extraMetadata` only changes the packaged app manifest, so - // temporarily update both source manifests for the build and restore them - // even when electron-builder fails. - const [originalRootManifest, originalDesktopManifest] = await Promise.all([ + // electron-builder requires all product manifests to have the same version. + // `extraMetadata` only changes the packaged app manifest, so temporarily + // update every source manifest for the build and restore them even when + // electron-builder fails. + const [originalRootManifest, originalDesktopManifest, originalCliManifest] = + await Promise.all([ readFile(rootManifestPath), readFile(desktopManifestPath), + readFile(cliManifestPath), ]); const bumpedRootManifest = JSON.parse(originalRootManifest); const bumpedDesktopManifest = JSON.parse(originalDesktopManifest); + const bumpedCliManifest = JSON.parse(originalCliManifest); bumpedRootManifest.version = nextVersion; bumpedDesktopManifest.version = nextVersion; + bumpedCliManifest.version = nextVersion; try { await Promise.all([ writeFile(rootManifestPath, `${JSON.stringify(bumpedRootManifest, null, 2)}\n`), writeFile(desktopManifestPath, `${JSON.stringify(bumpedDesktopManifest, null, 2)}\n`), + writeFile(cliManifestPath, `${JSON.stringify(bumpedCliManifest, null, 2)}\n`), ]); await run('npm', [ '--workspace', @@ -115,6 +121,7 @@ export async function packageWindowsAutoupdateNext({ await Promise.all([ writeFile(rootManifestPath, originalRootManifest), writeFile(desktopManifestPath, originalDesktopManifest), + writeFile(cliManifestPath, originalCliManifest), ]); } From 81eabec41887c2da2f75f3a3c1e47fd214364b21 Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Sun, 23 Aug 2026 20:42:05 +0800 Subject: [PATCH 06/15] fix(ci): verify upgraded Runtime Host package --- .../runtime/src/json-schema-validation.ts | 19 ++++++++++++ scripts/verify-windows-autoupdate.mjs | 15 ++++++++++ scripts/verify-windows-harness.test.mjs | 29 ++++++++++++++++++- scripts/verify-windows-x64.mjs | 20 ++++++++++--- 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/json-schema-validation.ts b/packages/runtime/src/json-schema-validation.ts index ef8ad67a14..10c3278619 100644 --- a/packages/runtime/src/json-schema-validation.ts +++ b/packages/runtime/src/json-schema-validation.ts @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv'; import Ajv2019 from 'ajv/dist/2019.js'; import Ajv2020 from 'ajv/dist/2020.js'; diff --git a/scripts/verify-windows-autoupdate.mjs b/scripts/verify-windows-autoupdate.mjs index eccd7eda81..666ea6e151 100644 --- a/scripts/verify-windows-autoupdate.mjs +++ b/scripts/verify-windows-autoupdate.mjs @@ -52,6 +52,7 @@ import { verifyPackagedWindowsApp, } from './verify-windows-x64.mjs'; import { compareProductReleaseVersions } from './release-version.mjs'; +import { readProductManifestIdentity } from './product-release-identity.mjs'; const uninstallExecutableName = 'Uninstall Maka.exe'; const executableName = 'Maka.exe'; @@ -64,6 +65,15 @@ function step(label) { console.log(`[verify-windows-autoupdate] ${label}`); } +export function runtimeHostSetupPackageForVersion(runtimeHostSetupPackage, version) { + const separator = runtimeHostSetupPackage.lastIndexOf('@'); + if (separator <= 0) { + throw new Error( + `Cannot derive the Runtime Host setup package for ${JSON.stringify(runtimeHostSetupPackage)}.`, + ); + } + return `${runtimeHostSetupPackage.slice(0, separator + 1)}${version}`; +} export async function waitForInstalledProductVersion( executablePath, { @@ -419,9 +429,14 @@ export async function verifyWindowsAutoupdate( const smokeDirectory = join(temporaryDirectory, 'smoke'); await mkdir(smokeDirectory, { recursive: true }); const upgradedStatusExpression = 'window.maka.app.updateStatus()'; + const product = await readProductManifestIdentity(); await verifyPackagedWindowsApp(installDirectory, { workingDirectory: smokeDirectory, expectedVersion: nextVersion, + expectedRuntimeHostSetupPackage: runtimeHostSetupPackageForVersion( + product.runtimeHostSetupPackage, + nextVersion, + ), smokeRenderer: async (executable, { workingDirectory }) => { const smokeHome = join(workingDirectory, 'home'); const smokeUserData = join(workingDirectory, 'user-data'); diff --git a/scripts/verify-windows-harness.test.mjs b/scripts/verify-windows-harness.test.mjs index 8aaae12534..76c5dd96b6 100644 --- a/scripts/verify-windows-harness.test.mjs +++ b/scripts/verify-windows-harness.test.mjs @@ -36,7 +36,10 @@ import { waitForDevToolsPort, waitForUsableRenderer, } from './verify-packaged-app.mjs'; -import { waitForInstalledProductVersion } from './verify-windows-autoupdate.mjs'; +import { + runtimeHostSetupPackageForVersion, + waitForInstalledProductVersion, +} from './verify-windows-autoupdate.mjs'; import { verifyPackagedWindowsSandboxLifecycle } from './verify-windows-x64.mjs'; import { WINDOWS_SANDBOX_DEFERRED_HARDENING, @@ -56,6 +59,7 @@ import { waitForInstalledProcessesToExit, waitForUninstallRegistrationToClear, } from './verify-windows-installer-lifecycle.mjs'; +import { resolveRuntimeHostSetupPackage } from './verify-windows-x64.mjs'; // Tests for the shared release-verification helpers. Everything here is // platform-neutral on purpose: the Windows lanes execute these helpers for @@ -325,6 +329,29 @@ it('reuses the packaged renderer smoke without widening rollback verification', ]); }); +it('expects the version-bumped Runtime Host setup package after an automatic update', () => { + const candidatePackage = 'maka-agent@0.1.11'; + const upgradedPackage = runtimeHostSetupPackageForVersion(candidatePackage, '0.1.12'); + + assert.equal(upgradedPackage, 'maka-agent@0.1.12'); + assert.equal( + resolveRuntimeHostSetupPackage({ + artifactContract: 'current', + productRuntimeHostSetupPackage: candidatePackage, + expectedRuntimeHostSetupPackage: upgradedPackage, + }), + upgradedPackage, + ); + assert.equal( + resolveRuntimeHostSetupPackage({ + artifactContract: 'upgrade-baseline', + productRuntimeHostSetupPackage: candidatePackage, + expectedRuntimeHostSetupPackage: upgradedPackage, + }), + undefined, + ); +}); + async function makeTree(shape) { const root = await mkdtemp(join(tmpdir(), 'maka-harness-test-')); temporaryRoots.push(root); diff --git a/scripts/verify-windows-x64.mjs b/scripts/verify-windows-x64.mjs index 9f14c0b333..02eb9e418c 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -27,7 +27,7 @@ import { assertPackagedUpdateConfiguration } from './desktop-update-contract.mjs import { resolveDesktopBuildVersion, resolveDesktopReleaseTarget, - resolveRuntimeHostSetupPackage, + resolveRuntimeHostSetupPackage as resolveBuildRuntimeHostSetupPackage, } from './desktop-nightly.mjs'; import { assertMissing, @@ -97,6 +97,16 @@ export async function verifyPackagedWindowsSandboxLifecycle( } } +export function resolveRuntimeHostSetupPackage({ + artifactContract, + productRuntimeHostSetupPackage, + expectedRuntimeHostSetupPackage, +}) { + return artifactContract === 'current' + ? (expectedRuntimeHostSetupPackage ?? productRuntimeHostSetupPackage) + : undefined; +} + // The Windows build is unsigned, so the only architecture evidence in the // artifact is the PE header of the executable itself. export async function readPeMachine(path) { @@ -129,6 +139,7 @@ export async function verifyPackagedWindowsApp( smokeRenderer = smokePackagedRenderer, workingDirectory = appDirectory, expectedVersion, + expectedRuntimeHostSetupPackage, artifactContract = 'current', environment = process.env, // Which channel the packaged client points at is the descriptor's to decide, @@ -143,6 +154,9 @@ export async function verifyPackagedWindowsApp( } const requiresCurrentContract = artifactContract === 'current'; const product = await readProductManifestIdentity(); + const runtimeHostSetupPackage = requiresCurrentContract + ? (expectedRuntimeHostSetupPackage ?? resolveBuildRuntimeHostSetupPackage(product.version, environment)) + : undefined; const resources = join(appDirectory, 'resources'); const executable = join(appDirectory, executableName); const appAsar = join(resources, 'app.asar'); @@ -262,9 +276,7 @@ export async function verifyPackagedWindowsApp( const ptyProbe = makePtyProbe( process.env.ComSpec || 'cmd.exe', ['/c', 'echo', 'maka-node-pty-ok'], - requiresCurrentContract - ? resolveRuntimeHostSetupPackage(product.version, environment) - : undefined, + runtimeHostSetupPackage, ); await run(executable, ['-e', ptyProbe, join(appAsar, 'package.json')], { env: { From 5368654d32ca7d1cbd2d69aaf90e204bc042d4c8 Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Mon, 31 Aug 2026 10:43:02 +0800 Subject: [PATCH 07/15] fix(desktop): include services in capability budget preflight --- .../runtime-host-native-capabilities.test.ts | 75 +++++++++++++++---- 1 file changed, 60 insertions(+), 15 deletions(-) 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 1ffd1586af..b168195a1f 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 @@ -330,6 +330,44 @@ test('omits optional MCP tools that would exceed the complete manifest byte limi ); }); +test('accounts for services when omitting optional MCP tools for the manifest budget', () => { + const diagnostics: Error[] = []; + const provider = createDesktopNativeCapabilityProvider( + { + browserTools: [], + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + optionalMcpGroupWithTools('desktop_mcp', 'mcp_tool', 25 * 1024, 2), + ], + additionalServices: () => + Array.from({ length: 32 }, (_, index) => ({ + serviceId: `service_${index}_${'x'.repeat(112)}`, + version: 'v'.repeat(64), + async call() { + return {}; + }, + })), + }, + { + targetScope: { hostId: 'host-1', targetEpoch: 'epoch-1' }, + onInvalidTool: (error) => diagnostics.push(error), + }, + ); + + assert.deepEqual(provider.offers()[0]?.tools.map(({ name }) => name), ['mcp_tool_1']); + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0]?.message ?? '', /desktop_mcp\/mcp_tool_2/u); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + services: provider.services?.(), + }), + ); +}); + test('publishes and admits additional Desktop native-effect services', async () => { let admitted = false; const provider = createDesktopNativeCapabilityProvider( @@ -1058,27 +1096,34 @@ function tool( } function optionalMcpGroup(offerId: string, name: string, schemaDescriptionLength: number) { + return optionalMcpGroupWithTools(offerId, name, schemaDescriptionLength, 1); +} + +function optionalMcpGroupWithTools( + offerId: string, + name: string, + schemaDescriptionLength: number, + toolCount: number, +) { return { offerId, label: 'MCP', description: 'MCP tools', invalidToolPolicy: 'omit' as const, - tools: [ - { - name, - displayName: name, - description: `${name} description`, - parameters: { - jsonSchema: { - type: 'object', - description: 'x'.repeat(schemaDescriptionLength), - }, - }, - async impl() { - return 'ok'; + tools: Array.from({ length: toolCount }, (_, index) => ({ + name: toolCount === 1 ? name : `${name}_${index + 1}`, + displayName: toolCount === 1 ? name : `${name}_${index + 1}`, + description: `${toolCount === 1 ? name : `${name}_${index + 1}`} description`, + parameters: { + jsonSchema: { + type: 'object', + description: 'x'.repeat(schemaDescriptionLength), }, - } as MakaTool, - ], + }, + async impl() { + return 'ok'; + }, + }) as MakaTool), }; } From 442fc1b4d7034777119a287e80472a7242012f2d Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Mon, 31 Aug 2026 11:26:08 +0800 Subject: [PATCH 08/15] fix(runtime-host): accept draft-07 tuple schemas --- .../__tests__/runtime-host-client-uds.test.ts | 1 + .../client-capability-protocol.test.ts | 27 +++++++++++++++++++ .../src/protocol/client-capability.ts | 4 +++ 3 files changed, 32 insertions(+) diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index eb054fc3cb..23ee002214 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -218,6 +218,7 @@ test('keeps the Desktop candidate usable when an optional MCP tool has an invali onError: (error) => diagnostics.push(error), createSessionCopyCleanup: () => ({ ownCreation: (_creation, operation) => operation(), + rejectCreation: async () => undefined, cleanup: async () => undefined, schedule: async () => undefined, abandonOwner: async () => undefined, 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 b5cfcdf54a..8836dd30aa 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -405,6 +405,7 @@ describe('Client Capability protocol', () => { coordinate: { type: 'array', items: [{ type: 'integer' }, { type: 'integer' }], + additionalItems: false, }, }, }, @@ -414,6 +415,32 @@ describe('Client Capability protocol', () => { ]), ), ); + assert.throws( + () => + decodeClientFrame( + replaceFrame([ + { + ...offer('invalid_additional_items', 'move'), + tools: [ + { + ...offer('invalid_additional_items', 'move').tools[0], + inputSchema: { + type: 'object', + properties: { + coordinate: { + type: 'array', + items: [{ type: 'integer' }], + additionalItems: { unsupportedKeyword: true }, + }, + }, + }, + }, + ], + }, + ]), + ), + (error: unknown) => error instanceof RuntimeHostProtocolError, + ); for (const items of [[], [{ type: 'integer' }, 'not-a-schema']]) { assert.throws( () => diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index 8c7aef6717..1591258b42 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -815,6 +815,7 @@ const CLIENT_CAPABILITY_SCHEMA_KEYWORDS = new Set([ '$ref', 'additionalItems', 'additionalProperties', + 'additionalItems', 'allOf', 'anyOf', 'const', @@ -924,6 +925,9 @@ function validateToolInputSchema(root: Record): void { visit(schema[key]); } } + if (schema.additionalItems !== undefined && typeof schema.additionalItems !== 'boolean') { + visit(schema.additionalItems); + } if (schema.propertyNames !== undefined) { visit(schema.propertyNames); } From b6b65f26aedd17c04a1adb421a1c3631f63520ff Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Mon, 31 Aug 2026 11:26:35 +0800 Subject: [PATCH 09/15] test(mcp): observe stdio reaping on Windows --- .../src/__fixtures__/stdio-fixture-events.ts | 32 +++++++++++++++- .../src/__tests__/stdio-negotiation.test.ts | 38 ++++++++++++------- 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/packages/mcp/src/__fixtures__/stdio-fixture-events.ts b/packages/mcp/src/__fixtures__/stdio-fixture-events.ts index de46e80d6a..7687a74786 100644 --- a/packages/mcp/src/__fixtures__/stdio-fixture-events.ts +++ b/packages/mcp/src/__fixtures__/stdio-fixture-events.ts @@ -17,7 +17,7 @@ * under the License. */ -import { appendFileSync } from 'node:fs'; +import { appendFileSync, readFileSync } from 'node:fs'; export function installStdioFixtureEvents( fixture: string, @@ -36,11 +36,14 @@ export function installStdioFixtureEvents( 'utf8', ); }; + const predecessorPid = latestStartedPid(path); record('start', { execPath: process.execPath, argv: process.argv.slice(1), cwd: process.cwd(), fixtureEnv: process.env.MAKA_MCP_STDIO_FIXTURE_VALUE ?? null, + predecessorPid: predecessorPid ?? null, + predecessorAlive: predecessorPid === undefined ? null : isPidAlive(predecessorPid), }); process.stderr.write(`stdio fixture ${fixture} pid=${process.pid}\n`); process.once('SIGTERM', () => { @@ -50,3 +53,30 @@ export function installStdioFixtureEvents( process.once('exit', (code) => record('exit', { code })); return record; } + +function latestStartedPid(path: string): number | undefined { + let source: string; + try { + source = readFileSync(path, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } + const lines = source.split('\n'); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]; + if (!line) continue; + const event = JSON.parse(line) as { event?: unknown; pid?: unknown }; + if (event.event === 'start' && Number.isSafeInteger(event.pid)) return event.pid as number; + } + return undefined; +} + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} diff --git a/packages/mcp/src/__tests__/stdio-negotiation.test.ts b/packages/mcp/src/__tests__/stdio-negotiation.test.ts index ad143bf13d..1f7d24eaad 100644 --- a/packages/mcp/src/__tests__/stdio-negotiation.test.ts +++ b/packages/mcp/src/__tests__/stdio-negotiation.test.ts @@ -112,10 +112,7 @@ describe('McpClientManager stdio protocol negotiation', { concurrency: false }, ]); await manager.close(); - await waitForEvents(fixture.log, (current) => - current.some((event) => event.event === 'exit' && event.pid === actualPid), - ); - assert.equal(isPidAlive(actualPid), false); + await waitForProcessExit(actualPid); }); test('an exact modern pin connects to a modern-only server without downgrade', async () => { @@ -143,11 +140,9 @@ describe('McpClientManager stdio protocol negotiation', { concurrency: false }, assert.equal(manager.status('fixture')?.state, 'error'); assert.equal(manager.status('fixture')?.negotiatedProtocol, undefined); assert.deepEqual(manager.toolSnapshot().tools, []); - const events = await waitForEvents(fixture.log, (current) => - current.some((event) => event.event === 'exit'), - ); + const events = await waitForEvents(fixture.log, (current) => uniquePids(current).length === 1); assert.equal(uniquePids(events).length, 1); - assert.equal(isPidAlive(uniquePids(events)[0]!), false); + await waitForProcessExit(uniquePids(events)[0]!); }); test('a pre-aborted auto connect starts no child', async () => { @@ -180,10 +175,9 @@ describe('McpClientManager stdio protocol negotiation', { concurrency: false }, assert.equal(manager.cancelConnect('fixture'), true); await sync; - const events = await waitForEvents(fixture.log, (current) => - current.some((event) => event.event === 'exit'), - ); + const events = await waitForEvents(fixture.log, (current) => uniquePids(current).length === 1); assert.equal(uniquePids(events).length, 1); + await waitForProcessExit(uniquePids(events)[0]!); assert.equal(manager.status('fixture')?.state, 'disconnected'); assert.equal(manager.status('fixture')?.negotiatedProtocol, undefined); assert.deepEqual(manager.toolSnapshot().tools, []); @@ -198,6 +192,8 @@ type StdioFixtureEvent = { argv?: string[]; cwd?: string; fixtureEnv?: string | null; + predecessorPid?: number | null; + predecessorAlive?: boolean | null; era?: 'legacy' | 'modern'; }; @@ -274,15 +270,29 @@ function assertProbeBeforeActual(events: StdioFixtureEvent[]): [number, number] const pids = uniquePids(events); assert.equal(pids.length, 2, JSON.stringify(events)); const [probePid, actualPid] = pids as [number, number]; + const starts = events.filter((event) => event.event === 'start'); + const actualStart = starts[1]; + assert.equal(actualStart?.pid, actualPid, JSON.stringify(events)); + assert.equal(actualStart?.predecessorPid, probePid, JSON.stringify(events)); + assert.equal(actualStart?.predecessorAlive, false, JSON.stringify(events)); const probeExit = events.findIndex((event) => event.event === 'exit' && event.pid === probePid); - const actualStart = events.findIndex( + const actualStartIndex = events.findIndex( (event) => event.event === 'start' && event.pid === actualPid, ); - assert.ok(probeExit >= 0, JSON.stringify(events)); - assert.ok(probeExit < actualStart, JSON.stringify(events)); + // POSIX fixtures also record their exit hook; Windows forced termination may not. + if (probeExit >= 0) assert.ok(probeExit < actualStartIndex, JSON.stringify(events)); return [probePid, actualPid]; } +async function waitForProcessExit(pid: number, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!isPidAlive(pid)) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.fail(`timed out waiting for process ${pid} to exit`); +} + function isPidAlive(pid: number): boolean { try { process.kill(pid, 0); From 7bd4009b44a9cc10cca3ddc699abee255842fd1b Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Mon, 31 Aug 2026 11:30:57 +0800 Subject: [PATCH 10/15] fix(ci): satisfy repository format check --- scripts/package-windows-autoupdate-next.mjs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/package-windows-autoupdate-next.mjs b/scripts/package-windows-autoupdate-next.mjs index 97373c43b6..3d4bc8021c 100644 --- a/scripts/package-windows-autoupdate-next.mjs +++ b/scripts/package-windows-autoupdate-next.mjs @@ -83,8 +83,7 @@ export async function packageWindowsAutoupdateNext({ // `extraMetadata` only changes the packaged app manifest, so temporarily // update every source manifest for the build and restore them even when // electron-builder fails. - const [originalRootManifest, originalDesktopManifest, originalCliManifest] = - await Promise.all([ + const [originalRootManifest, originalDesktopManifest, originalCliManifest] = await Promise.all([ readFile(rootManifestPath), readFile(desktopManifestPath), readFile(cliManifestPath), From 7ccf36b68a432bc12d6d5be2f92919224f4adda3 Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Mon, 31 Aug 2026 11:45:26 +0800 Subject: [PATCH 11/15] chore(desktop): remove unrelated test and release changes --- .../src/__fixtures__/stdio-fixture-events.ts | 32 +-------- .../src/__tests__/stdio-negotiation.test.ts | 38 ++++------- scripts/package-windows-autoupdate-next.mjs | 66 +++++-------------- scripts/verify-windows-autoupdate.mjs | 15 ----- scripts/verify-windows-harness.test.mjs | 29 +------- scripts/verify-windows-x64.mjs | 20 ++---- 6 files changed, 37 insertions(+), 163 deletions(-) diff --git a/packages/mcp/src/__fixtures__/stdio-fixture-events.ts b/packages/mcp/src/__fixtures__/stdio-fixture-events.ts index 7687a74786..de46e80d6a 100644 --- a/packages/mcp/src/__fixtures__/stdio-fixture-events.ts +++ b/packages/mcp/src/__fixtures__/stdio-fixture-events.ts @@ -17,7 +17,7 @@ * under the License. */ -import { appendFileSync, readFileSync } from 'node:fs'; +import { appendFileSync } from 'node:fs'; export function installStdioFixtureEvents( fixture: string, @@ -36,14 +36,11 @@ export function installStdioFixtureEvents( 'utf8', ); }; - const predecessorPid = latestStartedPid(path); record('start', { execPath: process.execPath, argv: process.argv.slice(1), cwd: process.cwd(), fixtureEnv: process.env.MAKA_MCP_STDIO_FIXTURE_VALUE ?? null, - predecessorPid: predecessorPid ?? null, - predecessorAlive: predecessorPid === undefined ? null : isPidAlive(predecessorPid), }); process.stderr.write(`stdio fixture ${fixture} pid=${process.pid}\n`); process.once('SIGTERM', () => { @@ -53,30 +50,3 @@ export function installStdioFixtureEvents( process.once('exit', (code) => record('exit', { code })); return record; } - -function latestStartedPid(path: string): number | undefined { - let source: string; - try { - source = readFileSync(path, 'utf8'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; - throw error; - } - const lines = source.split('\n'); - for (let index = lines.length - 1; index >= 0; index -= 1) { - const line = lines[index]; - if (!line) continue; - const event = JSON.parse(line) as { event?: unknown; pid?: unknown }; - if (event.event === 'start' && Number.isSafeInteger(event.pid)) return event.pid as number; - } - return undefined; -} - -function isPidAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException).code === 'EPERM'; - } -} diff --git a/packages/mcp/src/__tests__/stdio-negotiation.test.ts b/packages/mcp/src/__tests__/stdio-negotiation.test.ts index 1f7d24eaad..ad143bf13d 100644 --- a/packages/mcp/src/__tests__/stdio-negotiation.test.ts +++ b/packages/mcp/src/__tests__/stdio-negotiation.test.ts @@ -112,7 +112,10 @@ describe('McpClientManager stdio protocol negotiation', { concurrency: false }, ]); await manager.close(); - await waitForProcessExit(actualPid); + await waitForEvents(fixture.log, (current) => + current.some((event) => event.event === 'exit' && event.pid === actualPid), + ); + assert.equal(isPidAlive(actualPid), false); }); test('an exact modern pin connects to a modern-only server without downgrade', async () => { @@ -140,9 +143,11 @@ describe('McpClientManager stdio protocol negotiation', { concurrency: false }, assert.equal(manager.status('fixture')?.state, 'error'); assert.equal(manager.status('fixture')?.negotiatedProtocol, undefined); assert.deepEqual(manager.toolSnapshot().tools, []); - const events = await waitForEvents(fixture.log, (current) => uniquePids(current).length === 1); + const events = await waitForEvents(fixture.log, (current) => + current.some((event) => event.event === 'exit'), + ); assert.equal(uniquePids(events).length, 1); - await waitForProcessExit(uniquePids(events)[0]!); + assert.equal(isPidAlive(uniquePids(events)[0]!), false); }); test('a pre-aborted auto connect starts no child', async () => { @@ -175,9 +180,10 @@ describe('McpClientManager stdio protocol negotiation', { concurrency: false }, assert.equal(manager.cancelConnect('fixture'), true); await sync; - const events = await waitForEvents(fixture.log, (current) => uniquePids(current).length === 1); + const events = await waitForEvents(fixture.log, (current) => + current.some((event) => event.event === 'exit'), + ); assert.equal(uniquePids(events).length, 1); - await waitForProcessExit(uniquePids(events)[0]!); assert.equal(manager.status('fixture')?.state, 'disconnected'); assert.equal(manager.status('fixture')?.negotiatedProtocol, undefined); assert.deepEqual(manager.toolSnapshot().tools, []); @@ -192,8 +198,6 @@ type StdioFixtureEvent = { argv?: string[]; cwd?: string; fixtureEnv?: string | null; - predecessorPid?: number | null; - predecessorAlive?: boolean | null; era?: 'legacy' | 'modern'; }; @@ -270,29 +274,15 @@ function assertProbeBeforeActual(events: StdioFixtureEvent[]): [number, number] const pids = uniquePids(events); assert.equal(pids.length, 2, JSON.stringify(events)); const [probePid, actualPid] = pids as [number, number]; - const starts = events.filter((event) => event.event === 'start'); - const actualStart = starts[1]; - assert.equal(actualStart?.pid, actualPid, JSON.stringify(events)); - assert.equal(actualStart?.predecessorPid, probePid, JSON.stringify(events)); - assert.equal(actualStart?.predecessorAlive, false, JSON.stringify(events)); const probeExit = events.findIndex((event) => event.event === 'exit' && event.pid === probePid); - const actualStartIndex = events.findIndex( + const actualStart = events.findIndex( (event) => event.event === 'start' && event.pid === actualPid, ); - // POSIX fixtures also record their exit hook; Windows forced termination may not. - if (probeExit >= 0) assert.ok(probeExit < actualStartIndex, JSON.stringify(events)); + assert.ok(probeExit >= 0, JSON.stringify(events)); + assert.ok(probeExit < actualStart, JSON.stringify(events)); return [probePid, actualPid]; } -async function waitForProcessExit(pid: number, timeoutMs = 5_000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!isPidAlive(pid)) return; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - assert.fail(`timed out waiting for process ${pid} to exit`); -} - function isPidAlive(pid: number): boolean { try { process.kill(pid, 0); diff --git a/scripts/package-windows-autoupdate-next.mjs b/scripts/package-windows-autoupdate-next.mjs index 3d4bc8021c..a33fd230f7 100644 --- a/scripts/package-windows-autoupdate-next.mjs +++ b/scripts/package-windows-autoupdate-next.mjs @@ -17,8 +17,7 @@ * under the License. */ -import { createHash } from 'node:crypto'; -import { access, readFile, rm, writeFile } from 'node:fs/promises'; +import { access, readFile, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { runCommand } from './package-windows-x64.mjs'; @@ -30,9 +29,6 @@ import { const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const desktopRoot = join(repoRoot, 'apps', 'desktop'); -const rootManifestPath = join(repoRoot, 'package.json'); -const desktopManifestPath = join(desktopRoot, 'package.json'); -const cliManifestPath = join(repoRoot, 'packages', 'cli', 'package.json'); /** * The version the auto-update harness serves as "newer than the candidate". @@ -61,7 +57,7 @@ export async function packageWindowsAutoupdateNext({ throw new Error('The auto-update installer build requires a Windows x64 host.'); } - const manifest = JSON.parse(await readFile(desktopManifestPath, 'utf8')); + const manifest = JSON.parse(await readFile(join(desktopRoot, 'package.json'), 'utf8')); const nextVersion = bumpedAutoupdateVersion(manifest.version); const outputDirectory = join(desktopRoot, 'release-autoupdate-next'); // The bumped build is the same target under a different version, so its @@ -79,50 +75,22 @@ export async function packageWindowsAutoupdateNext({ await access(join(desktopRoot, 'release', 'win-unpacked')); await rm(outputDirectory, { recursive: true, force: true }); - // electron-builder requires all product manifests to have the same version. - // `extraMetadata` only changes the packaged app manifest, so temporarily - // update every source manifest for the build and restore them even when - // electron-builder fails. - const [originalRootManifest, originalDesktopManifest, originalCliManifest] = await Promise.all([ - readFile(rootManifestPath), - readFile(desktopManifestPath), - readFile(cliManifestPath), + await run('npm', [ + '--workspace', + '@maka/desktop', + 'exec', + '--', + 'electron-builder', + '--config', + 'electron-builder.config.mjs', + '--win', + 'nsis', + '--x64', + '--publish', + 'never', + `-c.extraMetadata.version=${nextVersion}`, + '-c.directories.output=release-autoupdate-next', ]); - const bumpedRootManifest = JSON.parse(originalRootManifest); - const bumpedDesktopManifest = JSON.parse(originalDesktopManifest); - const bumpedCliManifest = JSON.parse(originalCliManifest); - bumpedRootManifest.version = nextVersion; - bumpedDesktopManifest.version = nextVersion; - bumpedCliManifest.version = nextVersion; - try { - await Promise.all([ - writeFile(rootManifestPath, `${JSON.stringify(bumpedRootManifest, null, 2)}\n`), - writeFile(desktopManifestPath, `${JSON.stringify(bumpedDesktopManifest, null, 2)}\n`), - writeFile(cliManifestPath, `${JSON.stringify(bumpedCliManifest, null, 2)}\n`), - ]); - await run('npm', [ - '--workspace', - '@maka/desktop', - 'exec', - '--', - 'electron-builder', - '--config', - 'electron-builder.config.mjs', - '--win', - 'nsis', - '--x64', - '--publish', - 'never', - `-c.extraMetadata.version=${nextVersion}`, - '-c.directories.output=release-autoupdate-next', - ]); - } finally { - await Promise.all([ - writeFile(rootManifestPath, originalRootManifest), - writeFile(desktopManifestPath, originalDesktopManifest), - writeFile(cliManifestPath, originalCliManifest), - ]); - } // Assert the properties the harness depends on, not just process exit 0. await access(exePath); diff --git a/scripts/verify-windows-autoupdate.mjs b/scripts/verify-windows-autoupdate.mjs index 666ea6e151..eccd7eda81 100644 --- a/scripts/verify-windows-autoupdate.mjs +++ b/scripts/verify-windows-autoupdate.mjs @@ -52,7 +52,6 @@ import { verifyPackagedWindowsApp, } from './verify-windows-x64.mjs'; import { compareProductReleaseVersions } from './release-version.mjs'; -import { readProductManifestIdentity } from './product-release-identity.mjs'; const uninstallExecutableName = 'Uninstall Maka.exe'; const executableName = 'Maka.exe'; @@ -65,15 +64,6 @@ function step(label) { console.log(`[verify-windows-autoupdate] ${label}`); } -export function runtimeHostSetupPackageForVersion(runtimeHostSetupPackage, version) { - const separator = runtimeHostSetupPackage.lastIndexOf('@'); - if (separator <= 0) { - throw new Error( - `Cannot derive the Runtime Host setup package for ${JSON.stringify(runtimeHostSetupPackage)}.`, - ); - } - return `${runtimeHostSetupPackage.slice(0, separator + 1)}${version}`; -} export async function waitForInstalledProductVersion( executablePath, { @@ -429,14 +419,9 @@ export async function verifyWindowsAutoupdate( const smokeDirectory = join(temporaryDirectory, 'smoke'); await mkdir(smokeDirectory, { recursive: true }); const upgradedStatusExpression = 'window.maka.app.updateStatus()'; - const product = await readProductManifestIdentity(); await verifyPackagedWindowsApp(installDirectory, { workingDirectory: smokeDirectory, expectedVersion: nextVersion, - expectedRuntimeHostSetupPackage: runtimeHostSetupPackageForVersion( - product.runtimeHostSetupPackage, - nextVersion, - ), smokeRenderer: async (executable, { workingDirectory }) => { const smokeHome = join(workingDirectory, 'home'); const smokeUserData = join(workingDirectory, 'user-data'); diff --git a/scripts/verify-windows-harness.test.mjs b/scripts/verify-windows-harness.test.mjs index 76c5dd96b6..8aaae12534 100644 --- a/scripts/verify-windows-harness.test.mjs +++ b/scripts/verify-windows-harness.test.mjs @@ -36,10 +36,7 @@ import { waitForDevToolsPort, waitForUsableRenderer, } from './verify-packaged-app.mjs'; -import { - runtimeHostSetupPackageForVersion, - waitForInstalledProductVersion, -} from './verify-windows-autoupdate.mjs'; +import { waitForInstalledProductVersion } from './verify-windows-autoupdate.mjs'; import { verifyPackagedWindowsSandboxLifecycle } from './verify-windows-x64.mjs'; import { WINDOWS_SANDBOX_DEFERRED_HARDENING, @@ -59,7 +56,6 @@ import { waitForInstalledProcessesToExit, waitForUninstallRegistrationToClear, } from './verify-windows-installer-lifecycle.mjs'; -import { resolveRuntimeHostSetupPackage } from './verify-windows-x64.mjs'; // Tests for the shared release-verification helpers. Everything here is // platform-neutral on purpose: the Windows lanes execute these helpers for @@ -329,29 +325,6 @@ it('reuses the packaged renderer smoke without widening rollback verification', ]); }); -it('expects the version-bumped Runtime Host setup package after an automatic update', () => { - const candidatePackage = 'maka-agent@0.1.11'; - const upgradedPackage = runtimeHostSetupPackageForVersion(candidatePackage, '0.1.12'); - - assert.equal(upgradedPackage, 'maka-agent@0.1.12'); - assert.equal( - resolveRuntimeHostSetupPackage({ - artifactContract: 'current', - productRuntimeHostSetupPackage: candidatePackage, - expectedRuntimeHostSetupPackage: upgradedPackage, - }), - upgradedPackage, - ); - assert.equal( - resolveRuntimeHostSetupPackage({ - artifactContract: 'upgrade-baseline', - productRuntimeHostSetupPackage: candidatePackage, - expectedRuntimeHostSetupPackage: upgradedPackage, - }), - undefined, - ); -}); - async function makeTree(shape) { const root = await mkdtemp(join(tmpdir(), 'maka-harness-test-')); temporaryRoots.push(root); diff --git a/scripts/verify-windows-x64.mjs b/scripts/verify-windows-x64.mjs index 02eb9e418c..9f14c0b333 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -27,7 +27,7 @@ import { assertPackagedUpdateConfiguration } from './desktop-update-contract.mjs import { resolveDesktopBuildVersion, resolveDesktopReleaseTarget, - resolveRuntimeHostSetupPackage as resolveBuildRuntimeHostSetupPackage, + resolveRuntimeHostSetupPackage, } from './desktop-nightly.mjs'; import { assertMissing, @@ -97,16 +97,6 @@ export async function verifyPackagedWindowsSandboxLifecycle( } } -export function resolveRuntimeHostSetupPackage({ - artifactContract, - productRuntimeHostSetupPackage, - expectedRuntimeHostSetupPackage, -}) { - return artifactContract === 'current' - ? (expectedRuntimeHostSetupPackage ?? productRuntimeHostSetupPackage) - : undefined; -} - // The Windows build is unsigned, so the only architecture evidence in the // artifact is the PE header of the executable itself. export async function readPeMachine(path) { @@ -139,7 +129,6 @@ export async function verifyPackagedWindowsApp( smokeRenderer = smokePackagedRenderer, workingDirectory = appDirectory, expectedVersion, - expectedRuntimeHostSetupPackage, artifactContract = 'current', environment = process.env, // Which channel the packaged client points at is the descriptor's to decide, @@ -154,9 +143,6 @@ export async function verifyPackagedWindowsApp( } const requiresCurrentContract = artifactContract === 'current'; const product = await readProductManifestIdentity(); - const runtimeHostSetupPackage = requiresCurrentContract - ? (expectedRuntimeHostSetupPackage ?? resolveBuildRuntimeHostSetupPackage(product.version, environment)) - : undefined; const resources = join(appDirectory, 'resources'); const executable = join(appDirectory, executableName); const appAsar = join(resources, 'app.asar'); @@ -276,7 +262,9 @@ export async function verifyPackagedWindowsApp( const ptyProbe = makePtyProbe( process.env.ComSpec || 'cmd.exe', ['/c', 'echo', 'maka-node-pty-ok'], - runtimeHostSetupPackage, + requiresCurrentContract + ? resolveRuntimeHostSetupPackage(product.version, environment) + : undefined, ); await run(executable, ['-e', ptyProbe, join(appAsar, 'package.json')], { env: { From 287a2a4d33ed0f781e825f6668980483934358e4 Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Mon, 31 Aug 2026 11:55:10 +0800 Subject: [PATCH 12/15] test(desktop): cover service manifest budget boundary --- .../runtime-host-native-capabilities.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 b168195a1f..29a8576b18 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 @@ -332,6 +332,15 @@ test('omits optional MCP tools that would exceed the complete manifest byte limi test('accounts for services when omitting optional MCP tools for the manifest budget', () => { const diagnostics: Error[] = []; + const offersOnlyProvider = createDesktopNativeCapabilityProvider({ + browserTools: [], + releaseBrowserSession() {}, + computerUseTools: computerTools(), + releaseComputerUseSession() {}, + additionalGroups: () => [ + optionalMcpGroupWithTools('desktop_mcp', 'mcp_tool', 25 * 1024, 2), + ], + }); const provider = createDesktopNativeCapabilityProvider( { browserTools: [], @@ -356,6 +365,19 @@ test('accounts for services when omitting optional MCP tools for the manifest bu }, ); + assert.equal(offersOnlyProvider.offers()[0]?.tools.length, 2); + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: offersOnlyProvider.offers(), + }), + ); + assert.throws(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: offersOnlyProvider.offers(), + services: provider.services?.(), + }), /manifest is too large/u); assert.deepEqual(provider.offers()[0]?.tools.map(({ name }) => name), ['mcp_tool_1']); assert.equal(diagnostics.length, 1); assert.match(diagnostics[0]?.message ?? '', /desktop_mcp\/mcp_tool_2/u); From 1c432abcc79c5d06cf1dcef9a340b28a352a1401 Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Mon, 31 Aug 2026 12:15:11 +0800 Subject: [PATCH 13/15] fix(runtime-host): advance protocol compatibility epoch --- packages/runtime-host/src/protocol/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index b58467c6ee..8553f8864f 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -212,6 +212,8 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 112 as const; // 79: Every `turn.message.submit` disposition carries the exact Skill // invocation outcome. Durable queued replays may omit the previous Host // Epoch's transient queue revision; older strict peers reject either shape. +// 79: Client Capability manifests accept draft-07 tuple schemas through the +// `additionalItems` keyword. Older peers reject the expanded keyword allowlist. // 78: OAuth login targets explicit create/existing Connection entities and // returns their canonical identity. Older peers reject both closed wire shapes. // 77: LLM and tool usage-log projections carry an optional `sessionTitle` (the From f088ba65aec3b841038e3716320a3a0bc3cd5083 Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Fri, 4 Sep 2026 16:05:12 +0800 Subject: [PATCH 14/15] fix(runtime-host): address PR review feedback --- CHANGELOG.md | 4 ++ .../__tests__/runtime-host-client-uds.test.ts | 7 +--- .../runtime-host-native-capabilities.test.ts | 39 +++++++++++-------- .../main/runtime-host-desktop-candidate.ts | 1 - packages/runtime-host/src/protocol/index.ts | 2 - 5 files changed, 29 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d6af22878..42d47524c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,10 @@ ### Changed +- Isolated invalid optional MCP tools while publishing Desktop Client Capability + manifests: malformed tools are omitted with diagnostics, complete manifest + budgets include services, and valid JSON-Schema MCP arguments are validated + before admission. - Made typed `request()` the sole direct Runtime Host operation API; removed the 17 forwarding aliases from direct and reconnecting connections while preserving status validation, subscriptions, capabilities, listeners, lifecycle, and close behavior. diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index 23ee002214..c08c04690c 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -172,7 +172,6 @@ test('keeps the Desktop candidate usable when an optional MCP tool has an invali })), }); const ipc = ipcHarness(); - const diagnostics: unknown[] = []; const invalidTool = { ...nativeTool(), name: 'mcp_invalid', @@ -193,6 +192,7 @@ test('keeps the Desktop candidate usable when an optional MCP tool has an invali resizeImage: async (bytes) => bytes, nativeCapabilities: { browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: Object.assign([], { clearSession() {}, @@ -203,7 +203,7 @@ test('keeps the Desktop candidate usable when an optional MCP tool has an invali offerId: 'desktop_mcp', label: 'MCP', description: 'MCP tools', - invalidToolPolicy: 'omit', + dynamic: true, tools: [invalidTool, validTool], }, ], @@ -215,7 +215,6 @@ test('keeps the Desktop candidate usable when an optional MCP tool has an invali resolveSessionCreateProject: async () => ({ kind: 'host_path', path: base }), emitSessionsChanged() {}, completeComputerUseTurn() {}, - onError: (error) => diagnostics.push(error), createSessionCopyCleanup: () => ({ ownCreation: (_creation, operation) => operation(), rejectCreation: async () => undefined, @@ -234,8 +233,6 @@ test('keeps the Desktop candidate usable when an optional MCP tool has an invali ((await ipc.invoke('sessions:list')) as SessionCatalogProjection[]).map(({ id }) => id), [projected.id], ); - assert.equal(diagnostics.length, 1); - assert.match(String(diagnostics[0]), /desktop_mcp\/mcp_invalid/); await candidate.close(); } finally { await host?.close().catch(() => undefined); 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 29a8576b18..eb1ce6c736 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 @@ -212,6 +212,7 @@ test('publishes and invokes an MCP tool backed by an AI SDK JSON Schema', async assert.ok(mcpTool); const provider = createDesktopNativeCapabilityProvider({ browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), releaseComputerUseSession() {}, @@ -220,7 +221,7 @@ test('publishes and invokes an MCP tool backed by an AI SDK JSON Schema', async offerId: 'desktop_mcp', label: 'MCP', description: 'MCP tools', - invalidToolPolicy: 'omit', + dynamic: true, tools: [mcpTool], }, ], @@ -272,11 +273,12 @@ test('publishes and invokes an MCP tool backed by an AI SDK JSON Schema', async assert.equal(accepted, true); }); -test('omits optional MCP tools that would exceed one offer\'s tool limit', () => { - const diagnostics: Error[] = []; +test('chunks optional MCP tools that exceed one offer\'s tool limit', () => { + const diagnostics: string[] = []; const provider = createDesktopNativeCapabilityProvider( { browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), releaseComputerUseSession() {}, @@ -285,32 +287,35 @@ test('omits optional MCP tools that would exceed one offer\'s tool limit', () => offerId: 'desktop_mcp', label: 'MCP', description: 'MCP tools', - invalidToolPolicy: 'omit', + dynamic: true, tools: Array.from({ length: 65 }, (_, index) => tool(`mcp_tool_${index + 1}`, z.object({}), async () => 'ok'), ), }, ], }, - { onInvalidTool: (error) => diagnostics.push(error) }, + { onDiagnostic: (diagnostic) => diagnostics.push(diagnostic) }, ); assert.deepEqual( - provider.offers()[0]?.tools.map(({ name }) => name), - Array.from({ length: 64 }, (_, index) => `mcp_tool_${index + 1}`), + provider.offers().map((offer) => [offer.offerId, offer.tools.length] as const), + [ + ['desktop_mcp', 64], + ['desktop_mcp_2', 1], + ], ); - assert.equal(diagnostics.length, 1); - assert.match(diagnostics[0]?.message ?? '', /desktop_mcp\/mcp_tool_65/u); + assert.equal(diagnostics.length, 0); assert.doesNotThrow(() => decodeClientCapabilityReplaceInput({ registrationId: 'registration-1', offers: provider.offers() }), ); }); test('omits optional MCP tools that would exceed the complete manifest byte limit', () => { - const diagnostics: Error[] = []; + const diagnostics: string[] = []; const provider = createDesktopNativeCapabilityProvider( { browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), releaseComputerUseSession() {}, @@ -319,21 +324,22 @@ test('omits optional MCP tools that would exceed the complete manifest byte limi optionalMcpGroup('desktop_mcp_second', 'mcp_second', 28 * 1024), ], }, - { onInvalidTool: (error) => diagnostics.push(error) }, + { onDiagnostic: (diagnostic) => diagnostics.push(diagnostic) }, ); assert.deepEqual(provider.offers().map(({ offerId }) => offerId), ['desktop_mcp_first']); assert.equal(diagnostics.length, 1); - assert.match(diagnostics[0]?.message ?? '', /desktop_mcp_second\/mcp_second/u); + assert.match(diagnostics[0] ?? '', /mcp_second/u); assert.doesNotThrow(() => decodeClientCapabilityReplaceInput({ registrationId: 'registration-1', offers: provider.offers() }), ); }); test('accounts for services when omitting optional MCP tools for the manifest budget', () => { - const diagnostics: Error[] = []; + const diagnostics: string[] = []; const offersOnlyProvider = createDesktopNativeCapabilityProvider({ browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), releaseComputerUseSession() {}, @@ -344,6 +350,7 @@ test('accounts for services when omitting optional MCP tools for the manifest bu const provider = createDesktopNativeCapabilityProvider( { browserTools: [], + resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), releaseComputerUseSession() {}, @@ -361,7 +368,7 @@ test('accounts for services when omitting optional MCP tools for the manifest bu }, { targetScope: { hostId: 'host-1', targetEpoch: 'epoch-1' }, - onInvalidTool: (error) => diagnostics.push(error), + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), }, ); @@ -380,7 +387,7 @@ test('accounts for services when omitting optional MCP tools for the manifest bu }), /manifest is too large/u); assert.deepEqual(provider.offers()[0]?.tools.map(({ name }) => name), ['mcp_tool_1']); assert.equal(diagnostics.length, 1); - assert.match(diagnostics[0]?.message ?? '', /desktop_mcp\/mcp_tool_2/u); + assert.match(diagnostics[0] ?? '', /mcp_tool_2/u); assert.doesNotThrow(() => decodeClientCapabilityReplaceInput({ registrationId: 'registration-1', @@ -1131,7 +1138,7 @@ function optionalMcpGroupWithTools( offerId, label: 'MCP', description: 'MCP tools', - invalidToolPolicy: 'omit' as const, + dynamic: true as const, tools: Array.from({ length: toolCount }, (_, index) => ({ name: toolCount === 1 ? name : `${name}_${index + 1}`, displayName: toolCount === 1 ? name : `${name}_${index + 1}`, diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 87300d60c9..81dd3bffbc 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -745,7 +745,6 @@ export async function createDesktopRuntimeHostCandidate( desktopSessionResourceKey({ ...scope, sessionId }), onSessionUsed: (sessionId) => nativeSessionIds.add(sessionId), onComputerUseTurnUsed: watchComputerUseTurn, - onInvalidTool: reportError, isTargetValid: deps.isTargetValid, onClosed: () => providers.delete(provider), onDiagnostic: logLocalRuntimeHostProcessDiagnostic, diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 8553f8864f..b58467c6ee 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -212,8 +212,6 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 112 as const; // 79: Every `turn.message.submit` disposition carries the exact Skill // invocation outcome. Durable queued replays may omit the previous Host // Epoch's transient queue revision; older strict peers reject either shape. -// 79: Client Capability manifests accept draft-07 tuple schemas through the -// `additionalItems` keyword. Older peers reject the expanded keyword allowlist. // 78: OAuth login targets explicit create/existing Connection entities and // returns their canonical identity. Older peers reject both closed wire shapes. // 77: LLM and tool usage-log projections carry an optional `sessionTitle` (the From 2a3e1f02d483004b33431d7abee12caa4b9141c6 Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Fri, 4 Sep 2026 23:02:04 +0800 Subject: [PATCH 15/15] fix(runtime-host): advance compatibility epoch after rebase --- packages/runtime-host/src/protocol/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index b58467c6ee..e3e621c4a8 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,10 @@ 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 = 112 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 113 as const; +// 113: Client Capability schemas recursively validate draft-07 tuple +// `additionalItems` schemas before admission. Older peers may apply a +// different validation boundary to the same manifest. // 112: Owners can query the Host execution environment through an extensible, // bounded resource-envelope contract. Older Hosts do not implement the query. // 111: Client Capability tool schemas may use draft-07 tuple additionalItems.