diff --git a/docs/INTEGRATION-TESTS.md b/docs/INTEGRATION-TESTS.md index c8ed80eee..185270176 100644 --- a/docs/INTEGRATION-TESTS.md +++ b/docs/INTEGRATION-TESTS.md @@ -104,6 +104,15 @@ A separate false-by-default `run_environment_probe` dispatch runs only the script-only public environment probe through the same release-attested public MCP route, without an agent executor, API proxy, or Copilot secret; see [the probe dispatch](cloud-hypervisor-foundation.md#public-enclave-environment-probe-dispatch). +The public gateway fixture preserves the MCP server's empty HTTP 202 +acknowledgment for JSON-RPC notifications, including `notifications/initialized` +and cancellation. It still requires bounded JSON responses for requests with +an ID and rejects malformed, truncated, or oversized backend responses. +Deterministic fixture tests cover this distinction: release v0.28.70's probe +and live acceptance both passed `initialize` but failed the initialized +notification because the fixture tried to parse its empty acknowledgment as +JSON and returned HTTP 502. This fixture regression does not establish a guest +or provider failure, and its mocked fix does not prove live KVM acceptance. The live job also runs scripts/ci-only startup-failure probes for both roles after real VM creation and boot, using the production host executor service, authenticated broker protocol v2 client, storage provider, and VM manager. diff --git a/scripts/ci/cloud-hypervisor-enclave-gateway.js b/scripts/ci/cloud-hypervisor-enclave-gateway.js index 982841025..689e4656f 100644 --- a/scripts/ci/cloud-hypervisor-enclave-gateway.js +++ b/scripts/ci/cloud-hypervisor-enclave-gateway.js @@ -109,6 +109,15 @@ const server = http.createServer((request, response) => { let forwarded; try { parsed = JSON.parse(payload.toString('utf8')); + // MCP notifications are acknowledged without a JSON-RPC response body. + if (upstreamResponse.statusCode === 202 && responseBytes === 0 + && parsed && typeof parsed === 'object' && !Array.isArray(parsed) + && parsed.jsonrpc === '2.0' && typeof parsed.method === 'string' + && !Object.prototype.hasOwnProperty.call(parsed, 'id')) { + response.writeHead(202, { 'cache-control': 'no-store', 'content-length': '0' }); + response.end(); + return; + } forwarded = transformResponse(parsed, JSON.parse(Buffer.concat(responseChunks).toString('utf8'))); } catch { sendJson(response, 502, { error: 'invalid upstream response' }); diff --git a/scripts/ci/cloud-hypervisor-enclave-gateway.test.ts b/scripts/ci/cloud-hypervisor-enclave-gateway.test.ts index caba314ae..dfd9c6eab 100644 --- a/scripts/ci/cloud-hypervisor-enclave-gateway.test.ts +++ b/scripts/ci/cloud-hypervisor-enclave-gateway.test.ts @@ -31,7 +31,7 @@ async function post(port: number, body: unknown): Promise<{ status: number; body response.on('data', (chunk: Buffer) => chunks.push(chunk)); response.on('end', () => resolve({ status: response.statusCode ?? 0, - body: JSON.parse(Buffer.concat(chunks).toString('utf8')), + body: chunks.length === 0 ? undefined : JSON.parse(Buffer.concat(chunks).toString('utf8')), })); }); request.on('error', reject); @@ -77,12 +77,25 @@ describe('Cloud Hypervisor enclave gateway fixture', () => { }); }); - it('forwards a reachable backend and rewrites the initialize server identity', async () => { + it('completes initialize, the empty initialized acknowledgment, and routed tool discovery', async () => { + const methods: string[] = []; upstream = http.createServer((request, response) => { - request.resume(); + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); request.on('end', () => { + const message = JSON.parse(Buffer.concat(chunks).toString('utf8')); + methods.push(message.method); + if (message.method === 'notifications/initialized') { + response.writeHead(202, { 'content-length': '0' }); + response.end(); + return; + } response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { serverInfo: { name: 'inner' } } })); + response.end(JSON.stringify({ + jsonrpc: '2.0', id: message.id, + result: message.method === 'initialize' ? { serverInfo: { name: 'inner' } } + : { tools: [{ name: 'enclave_run_script', description: 'bounded script', inputSchema: {} }] }, + })); }); }); await new Promise((resolve) => upstream?.listen(0, '127.0.0.1', resolve)); @@ -91,6 +104,16 @@ describe('Cloud Hypervisor enclave gateway fixture', () => { status: 200, body: { jsonrpc: '2.0', id: 1, result: { serverInfo: { name: 'awmg-awf-enclave', version: 'live-test' } } }, }); + await expect(post(port, { jsonrpc: '2.0', method: 'notifications/initialized' })).resolves.toEqual({ + status: 202, body: undefined, + }); + await expect(post(port, { jsonrpc: '2.0', id: 2, method: 'tools/list' })).resolves.toEqual({ + status: 200, + body: { jsonrpc: '2.0', id: 2, result: { tools: [{ + name: 'enclave_run_script', description: '[awf-enclave] bounded script', inputSchema: {}, + }] } }, + }); + expect(methods).toEqual(['initialize', 'notifications/initialized', 'tools/list']); }); it('keeps an invalid backend response permanent', async () => { @@ -105,6 +128,51 @@ describe('Cloud Hypervisor enclave gateway fixture', () => { }); }); + it.each([ + { jsonrpc: '2.0', method: 'notifications/initialized' }, + { jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 3 } }, + ])('preserves empty notification acknowledgments for $method', async (notification) => { + const received: unknown[] = []; + upstream = http.createServer((request, response) => { + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + expect(request.headers.authorization).toBe(`Bearer ${capability}`); + received.push(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + response.writeHead(202, { 'content-length': '0' }); + response.end(); + }); + }); + await new Promise((resolve) => upstream?.listen(0, '127.0.0.1', resolve)); + const port = await startGateway((upstream.address() as AddressInfo).port); + await expect(post(port, notification)).resolves.toEqual({ status: 202, body: undefined }); + expect(received).toEqual([notification]); + expect(gateway?.exitCode).toBeNull(); + }); + + it.each([ + { status: 202, request: { jsonrpc: '2.0', id: 1, method: 'initialize' } }, + { status: 202, request: { jsonrpc: '2.0', id: null, method: 'initialize' } }, + { status: 202, request: { method: 'notifications/initialized' } }, + { status: 202, request: { jsonrpc: '2.0' } }, + { status: 200, request: { jsonrpc: '2.0', method: 'notifications/initialized' } }, + { status: 401, request: { jsonrpc: '2.0', method: 'notifications/initialized' } }, + { status: 503, request: { jsonrpc: '2.0', method: 'notifications/initialized' } }, + ])('does not accept an empty $status response for $request', async ({ status, request }) => { + upstream = http.createServer((incoming, response) => { + incoming.resume(); + incoming.on('end', () => { + response.writeHead(status, { 'content-length': '0' }); + response.end(); + }); + }); + await new Promise((resolve) => upstream?.listen(0, '127.0.0.1', resolve)); + const port = await startGateway((upstream.address() as AddressInfo).port); + await expect(post(port, request)).resolves.toEqual({ + status: 502, body: { error: 'invalid upstream response' }, + }); + }); + it('returns permanent failures for truncated backend responses without terminating the gateway', async () => { upstream = http.createServer((request, response) => { request.resume();