mirror of
https://github.com/morluto/rea.git
synced 2026-10-21 21:32:26 -05:00
Add an explicit Streamable HTTP entry for environment/gateway hosts while keeping stdio registrations and default behavior. Preserve the shared provider/session owner across DELETE and replacement; fence and drain tool/completion work before handover or teardown. Bound HTTP input and plain-response admission, cancel refused bodies, join whole exchanges and asynchronous response cleanup before provider hooks, gate startup readiness, and reject queued HTTP/1.1 pipelining while retaining sequential keep-alive. Validation: 8 static/build tasks, 83 adapter tests and registry metadata pass. A real installed gh binary produces seven equivalent CLI/stdio/HTTP reports with DELETE/replacement and clean signal shutdown. Broader local native-provider/ownership failures reproduce on clean main and are documented with limitations in the PR. Co-authored-by: Edgar Arakelyan <edgar.arakelyan@scale.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
70 lines
1.9 KiB
TypeScript
70 lines
1.9 KiB
TypeScript
import { request as httpRequest, type IncomingHttpHeaders } from "node:http";
|
|
|
|
import { expect } from "vitest";
|
|
|
|
/** One buffered HTTP response. */
|
|
export interface RawHttpResponse {
|
|
readonly status: number;
|
|
readonly headers: IncomingHttpHeaders;
|
|
readonly body: string;
|
|
}
|
|
|
|
/** Send one request with caller-controlled headers, including Host, which fetch overwrites. */
|
|
export const rawHttpRequest = (
|
|
url: URL,
|
|
options: {
|
|
readonly method?: string;
|
|
readonly headers?: Readonly<Record<string, string>>;
|
|
readonly body?: string;
|
|
} = {},
|
|
): Promise<RawHttpResponse> =>
|
|
new Promise((resolve, reject) => {
|
|
// A fresh connection per request keeps a closed listener observable as ECONNREFUSED.
|
|
const request = httpRequest(
|
|
url,
|
|
{
|
|
method: options.method ?? "GET",
|
|
headers: { ...options.headers },
|
|
agent: false,
|
|
},
|
|
(response) => {
|
|
let body = "";
|
|
response.setEncoding("utf8");
|
|
response.on("data", (chunk: string) => {
|
|
body += chunk;
|
|
});
|
|
response.on("end", () =>
|
|
resolve({
|
|
status: response.statusCode ?? 0,
|
|
headers: response.headers,
|
|
body,
|
|
}),
|
|
);
|
|
response.on("error", reject);
|
|
},
|
|
);
|
|
request.on("error", reject);
|
|
request.end(options.body);
|
|
});
|
|
|
|
/** Assert the MCP endpoint refuses a Host header outside the loopback allowlist. */
|
|
export const expectForeignHostRefused = async (
|
|
url: URL,
|
|
): Promise<RawHttpResponse> => {
|
|
const refused = await rawHttpRequest(url, {
|
|
method: "POST",
|
|
headers: {
|
|
host: "rea.invalid",
|
|
"content-type": "application/json",
|
|
accept: "application/json, text/event-stream",
|
|
},
|
|
body: "{}",
|
|
});
|
|
expect(refused.status).toBe(403);
|
|
expect(JSON.parse(refused.body)).toMatchObject({
|
|
jsonrpc: "2.0",
|
|
error: { code: -32000 },
|
|
});
|
|
return refused;
|
|
};
|