Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/INTEGRATION-TESTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions scripts/ci/cloud-hypervisor-enclave-gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
76 changes: 72 additions & 4 deletions scripts/ci/cloud-hypervisor-enclave-gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<void>((resolve) => upstream?.listen(0, '127.0.0.1', resolve));
Expand All @@ -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 () => {
Expand All @@ -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<void>((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<void>((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();
Expand Down
Loading