mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 06:15:59 +00:00
Require operator-declared hostnames before serving MCP HTTP outside loopback, preserving Host/Origin gates. Parse hostname identities once, share typed startup recovery across CLI/MCP, and announce an admitted client URL. Generated AgentEnv bundles declare their client/service names and validate DNS/HTTP name identity before export. Validated through real packaged HTTP requests, direct SDK connection, compiled export refusals, 184 focused tests, static and documentation checks on the refreshed main integration. Final CI and review evidence is recorded in the PR body. BREAKING CHANGE: non-loopback deployments require MCP_ALLOWED_HOSTS or rea mcp --http --allowed-hosts; bundle names must be unchanged HTTP hostnames within the 63-octet DNS label bound. Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
200 lines
6.1 KiB
TypeScript
200 lines
6.1 KiB
TypeScript
import { createServer } from "node:http";
|
|
import { once } from "node:events";
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
import { expect, it } from "vitest";
|
|
|
|
import { transportError } from "../../../src/cli/environmentCommands.js";
|
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
|
import { StreamableHttpListenError } from "../../../src/server/http/streamableHttpListenError.js";
|
|
import {
|
|
writeEnvironmentServeFiles,
|
|
type EnvironmentServeFiles,
|
|
} from "../../fixtures/environmentServe.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
|
|
|
|
const files = async (): Promise<EnvironmentServeFiles> => {
|
|
const directory = await createTestTempDirectory("rea-serve-remediation-");
|
|
return writeEnvironmentServeFiles(directory);
|
|
};
|
|
|
|
const serve = async (
|
|
cli: TestCli,
|
|
input: EnvironmentServeFiles,
|
|
host: string,
|
|
port: number,
|
|
) => {
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"environment-serve",
|
|
input.manifestPath,
|
|
"--evidence",
|
|
input.capturePath,
|
|
"--host",
|
|
host,
|
|
"--port",
|
|
String(port),
|
|
"--json",
|
|
],
|
|
timeoutMs: 30_000,
|
|
});
|
|
return {
|
|
exitCode: result.exitCode,
|
|
output: JSON.parse(result.stdout) as Record<string, unknown>,
|
|
};
|
|
};
|
|
|
|
cliTest(
|
|
"reports host lookup failure with host-specific recovery and no listener startup",
|
|
async ({ cli }) => {
|
|
const input = await files();
|
|
const host = `rea-${randomUUID()}.invalid`;
|
|
const result = await serve(cli, input, host, 0);
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.output).toMatchObject({
|
|
code: "resource_constraint",
|
|
details: {
|
|
operation: "environment-serve",
|
|
resource: "transport",
|
|
reported_limits: { host, port: 0 },
|
|
diagnostics: {
|
|
listener_failure: {
|
|
kind: "host_lookup",
|
|
system_code: "ENOTFOUND",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(result.output.remediation).toMatchObject({
|
|
action: expect.stringContaining("--host or MCP_HOST"),
|
|
});
|
|
expect(JSON.stringify(result.output)).toContain("ENOTFOUND");
|
|
expect(JSON.stringify(result.output)).not.toContain(
|
|
"Choose another --port",
|
|
);
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"reports the missing host allowlist instead of port advice for a non-loopback bind",
|
|
async ({ cli }) => {
|
|
const result = await serve(cli, await files(), "0.0.0.0", 0);
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.output).toMatchObject({
|
|
code: "resource_constraint",
|
|
details: {
|
|
diagnostics: {
|
|
listener_failure: { kind: "host_allowlist", reason: "missing" },
|
|
},
|
|
},
|
|
remediation: { action: expect.stringContaining("Set MCP_ALLOWED_HOSTS") },
|
|
});
|
|
expect(JSON.stringify(result.output.remediation)).not.toContain(
|
|
"Choose another --port",
|
|
);
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"retains port advice without closing the loopback listener it does not own",
|
|
async ({ cli }) => {
|
|
const input = await files();
|
|
const owner = createServer();
|
|
try {
|
|
owner.listen({ host: "127.0.0.1", port: 0 });
|
|
await once(owner, "listening");
|
|
const address = owner.address();
|
|
if (address === null || typeof address === "string")
|
|
throw new Error("Owned loopback listener has no TCP address");
|
|
|
|
const result = await serve(cli, input, "127.0.0.1", address.port);
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.output).toMatchObject({
|
|
code: "resource_constraint",
|
|
details: {
|
|
reported_limits: { host: "127.0.0.1", port: address.port },
|
|
diagnostics: {
|
|
listener_failure: {
|
|
kind: "port_in_use",
|
|
system_code: "EADDRINUSE",
|
|
},
|
|
},
|
|
},
|
|
remediation: {
|
|
action: expect.stringContaining("Choose another --port or MCP_PORT"),
|
|
},
|
|
});
|
|
expect(owner.listening).toBe(true);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) =>
|
|
owner.close((cause) =>
|
|
cause === undefined ? resolve() : reject(cause),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["EACCES", "permission_denied", "permitted to bind"],
|
|
["EPERM", "permission_denied", "permitted to bind"],
|
|
["EADDRNOTAVAIL", "host_unavailable", "address assigned to this machine"],
|
|
["EIO", "unclassified", "Check the selected listener host, port"],
|
|
] as const)(
|
|
"projects typed listener failure %s without parsing the error display text",
|
|
(systemCode, kind, remediationText) => {
|
|
const cause = Object.assign(
|
|
new Error(
|
|
systemCode === "EIO"
|
|
? "display mentions EADDRINUSE, but errno is EIO"
|
|
: "controlled listener failure",
|
|
),
|
|
{ code: systemCode },
|
|
);
|
|
const listenError = new StreamableHttpListenError(cause);
|
|
const transportFailure = transportError(
|
|
{ host: "127.0.0.1", port: 80 },
|
|
listenError,
|
|
);
|
|
const projected = projectAnalysisError(transportFailure);
|
|
|
|
expect(listenError.cause).toBe(cause);
|
|
expect(transportFailure.cause).toBe(listenError);
|
|
expect(projected.details).toMatchObject({
|
|
diagnostics: {
|
|
listener_failure: {
|
|
kind,
|
|
system_code: systemCode,
|
|
},
|
|
listener_phase: "startup",
|
|
},
|
|
});
|
|
expect(projected.remediation.action).toContain(remediationText);
|
|
expect(projected.remediation.action).not.toContain(
|
|
"stop the process using it",
|
|
);
|
|
},
|
|
);
|
|
|
|
it("retains an after-bind listener failure as a serving-phase error", () => {
|
|
const cause = Object.assign(new Error("socket failed after bind"), {
|
|
code: "EIO",
|
|
});
|
|
const listenError = new StreamableHttpListenError(cause, "serving");
|
|
const projected = projectAnalysisError(
|
|
transportError({ host: "127.0.0.1", port: 80 }, listenError),
|
|
);
|
|
|
|
expect(listenError.cause).toBe(cause);
|
|
expect(listenError.message).toContain("failed after binding");
|
|
expect(listenError.message).not.toContain("could not bind");
|
|
expect(projected.details).toMatchObject({
|
|
diagnostics: {
|
|
listener_failure: { kind: "unclassified", system_code: "EIO" },
|
|
listener_phase: "serving",
|
|
},
|
|
});
|
|
});
|