feat(llm): resolve the gate's selection Llm by tier, not by name #91
@@ -32,6 +32,16 @@ function httpRequest(opts: {
|
|||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
/**
|
||||||
|
* Resolve as soon as response headers arrive, without waiting for the body
|
||||||
|
* to end. Required for endpoints that never end: an SSE stream's socket only
|
||||||
|
* goes idle when nothing else is talking to mcplocal, so waiting for the
|
||||||
|
* inactivity timeout made this depend on whether other smoke files happened
|
||||||
|
* to be generating traffic concurrently. Resolving on headers is also a
|
||||||
|
* STRONGER assertion — the caller sees the real status instead of inferring
|
||||||
|
* "reachable" from a timeout, which would pass just as happily on a slow 401.
|
||||||
|
*/
|
||||||
|
resolveOnHeaders?: boolean;
|
||||||
}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
|
}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const parsed = new URL(opts.url);
|
const parsed = new URL(opts.url);
|
||||||
@@ -46,6 +56,12 @@ function httpRequest(opts: {
|
|||||||
timeout: opts.timeout ?? 10_000,
|
timeout: opts.timeout ?? 10_000,
|
||||||
},
|
},
|
||||||
(res) => {
|
(res) => {
|
||||||
|
if (opts.resolveOnHeaders === true) {
|
||||||
|
resolve({ status: res.statusCode ?? 0, headers: res.headers, body: '' });
|
||||||
|
res.destroy();
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
@@ -92,22 +108,19 @@ describe('Smoke: Security — mcplocal unauthenticated endpoints', () => {
|
|||||||
if (!available) return;
|
if (!available) return;
|
||||||
|
|
||||||
// /inspect streams ALL MCP traffic (tool calls, arguments, responses)
|
// /inspect streams ALL MCP traffic (tool calls, arguments, responses)
|
||||||
// for ALL projects to any unauthenticated local client
|
// for ALL projects to any unauthenticated local client. The stream never
|
||||||
|
// ends, so take the status off the response headers and hang up.
|
||||||
const res = await httpRequest({
|
const res = await httpRequest({
|
||||||
url: `${MCPLOCAL_URL}/inspect`,
|
url: `${MCPLOCAL_URL}/inspect`,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'Accept': 'text/event-stream' },
|
headers: { 'Accept': 'text/event-stream' },
|
||||||
timeout: 3_000,
|
timeout: 5_000,
|
||||||
}).catch((err) => {
|
resolveOnHeaders: true,
|
||||||
// Timeout is expected (SSE keeps connection open) — still means endpoint is accessible
|
|
||||||
if ((err as Error).message.includes('timed out')) {
|
|
||||||
return { status: 200, headers: {} as http.IncomingHttpHeaders, body: '' };
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should be accessible without auth (documenting the vulnerability)
|
// Should be accessible without auth (documenting the vulnerability)
|
||||||
expect(res.status).toBeLessThan(400);
|
expect(res.status).toBeLessThan(400);
|
||||||
|
expect(res.headers['content-type']).toContain('text/event-stream');
|
||||||
console.log(` ⚠ /inspect accessible without auth (status ${res.status})`);
|
console.log(` ⚠ /inspect accessible without auth (status ${res.status})`);
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user