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
15 changes: 11 additions & 4 deletions docs/agent-environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,9 @@ name it `.har`.
manifest's, unless the capture's origin is a loopback name or an IP literal:
the capture file then names it (`grafana.har` and `grafana.capture.json`
become `grafana`) and a `name-from-file` warning says so. A file name that
yields no portable label (kebab-case, at most 64 characters, not a Windows
device name) is refused; pass `--name`.
yields no portable label (kebab-case, at most 63 characters for a DNS service
label, not a Windows device name or a label interpreted as an IP address) is
refused; pass `--name`.
- `--package-tarball` works as for the export, and `--capture-timeout-ms`
as for `inspect-web-network-capture`.
- `--dry-run` runs every stage and prints the report with `status: "planned"`
Expand Down Expand Up @@ -982,7 +983,8 @@ serves replay. The complete tool catalog the bundle serves must also fit the
server's advertised catalog budget before any files are written. Privately parsed JSON is sealed once so authentication
and replay share immutable response trees rather than copying the capture repeatedly.
`--name` replaces the manifest's portable kebab-case name as the env folder and env id
(Windows device names such as `con` and `lpt1` are refused);
(at most 63 characters for its DNS service label; Windows device names and
labels interpreted as IP addresses are refused);
`--package-tarball <path>` copies a packed `rea-agents` tarball into the env
folder and makes the Dockerfile install it instead of the registry package and
remove it in the same layer, which is how a checkout rather than a release is
Expand All @@ -1000,7 +1002,12 @@ Windows inherits ACLs and REA does not establish owner-only access there. Choose
an already private Windows destination when captured content needs it.

The Dockerfile sets `MCP_HOST=0.0.0.0` and `MCP_PORT=18765`, the port the
framework maps for an MCP server env; the framework's own deploy supplies
framework maps for an MCP server env, and `MCP_ALLOWED_HOSTS` naming the loopback
addresses and the environment name, because the container binds outside loopback
and the server refuses to serve on such a bind without an operator-declared
allowlist: the framework's published port is reached over loopback and its
gateway proxies to the env container by the compose service name (the
environment name). The framework's own deploy supplies
`MCP_HOST`, `MCP_PORT` and `ENVIRONMENT_NAME`, so the served card carries the
env's name. It installs Debian's `python3` because the framework's local
sandbox probes a container's readiness with `python3` inside it, which the slim
Expand Down
27 changes: 23 additions & 4 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -738,8 +738,18 @@ rea mcp --http --port 8765
```

`--host` and `--port` default to `MCP_HOST` and `MCP_PORT` from the environment,
then to `127.0.0.1` and an ephemeral port. The bound URL is written to stderr as
one JSON log line (`"msg":"MCP transport listening"`) whatever `REA_LOG_LEVEL`
then to `127.0.0.1` and an ephemeral port. A bind outside loopback requires
`MCP_ALLOWED_HOSTS` or `--allowed-hosts`, a comma-separated list of client host
names or IP addresses without URLs or ports. For example:

```sh
rea mcp --http --host 0.0.0.0 --allowed-hosts 127.0.0.1,rea.lan
```

Requests with a different `Host` or browser `Origin` receive `403`; a missing
`Origin` is accepted. The announced client URL uses the bound hostname when
admitted, otherwise the first declared hostname, with the actual bound port.
It is written to stderr as one JSON log line (`"msg":"MCP transport listening"`) whatever `REA_LOG_LEVEL`
is set to; nothing is written to stdout. `rea mcp --http --help` lists the accepted options. The listener serves
one client at a time: a session ends with the client's `DELETE`, and a client
that connects while another session is open takes it over, so a client that
Expand Down Expand Up @@ -777,9 +787,18 @@ rea environment-serve manifest.json --evidence capture.json --port 8765
```

`--host` and `--port` resolve like `rea mcp --http`: flags, then `MCP_HOST` and
`MCP_PORT`, then `127.0.0.1` and an ephemeral port. `ENVIRONMENT_NAME` renames
`MCP_PORT`, then `127.0.0.1` and an ephemeral port. A non-loopback bind requires
`MCP_ALLOWED_HOSTS` with the same hostname rules and Host/Origin gates as above.
`environment-serve` uses this environment setting; `--allowed-hosts` belongs to
`rea mcp --http`. For example:

```sh
MCP_ALLOWED_HOSTS=127.0.0.1,rea.lan rea environment-serve manifest.json --evidence capture.json --host 0.0.0.0
```

`ENVIRONMENT_NAME` renames
the served environment; set but blank, it is refused as `configuration_invalid`
like a blank `MCP_HOST`. The bound URL is written to stderr as one JSON log line
like a blank `MCP_HOST`. The admitted client URL is written to stderr as one JSON log line
(`"msg":"MCP transport listening"`). See
[agent environments](agent-environments.md) for what the state and replay
return and do not establish.
Expand Down
7 changes: 6 additions & 1 deletion docs/mcp-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -817,7 +817,12 @@ the bound address is local; names are added only after classifying the actual IP
On such a bind, requests whose `Host` header is outside the SDK loopback allowlist plus
the configured name and the bound address are refused with `403`, and a browser
`Origin` outside that allowlist is refused the same way (a missing `Origin`
passes); a non-loopback bind relaxes both checks and logs the relaxation. A reply sent before a request body was read (a refused `Host` or `Origin`, an unknown route, the startup `503`) closes the connection after the reply, so an unread upload cannot keep a socket open. Request targets are taken verbatim as origin-form paths (`//host/path` is a path, not another origin); an absolute-form target, a target containing a raw fragment delimiter (`#`), or one whose path URL parsing would rewrite (dot segments such as `/public/../mcp`), is refused with `400`, and a method Fetch does not support with `405`. Percent-encoded `%23` remains valid query data. Only an `initialize` object can replace an open session. A sessionless array containing initialize, including a one-element array, is refused with HTTP `400` before session creation or handover. Scoped
passes). A non-loopback bind requires `MCP_ALLOWED_HOSTS`, or
`--allowed-hosts` for `rea mcp --http`, listing every client host name or IP
address. Configuration validates and canonicalizes complete tokens once;
URLs, ports and empty entries are refused. Both Host and Origin checks remain
active on that declared list. The announced URL uses the bound hostname if
admitted, otherwise the first declared hostname, with the actual bound port. A reply sent before a request body was read (a refused `Host` or `Origin`, an unknown route, the startup `503`) closes the connection after the reply, so an unread upload cannot keep a socket open. Request targets are taken verbatim as origin-form paths (`//host/path` is a path, not another origin); an absolute-form target, a target containing a raw fragment delimiter (`#`), or one whose path URL parsing would rewrite (dot segments such as `/public/../mcp`), is refused with `400`, and a method Fetch does not support with `405`. Percent-encoded `%23` remains valid query data. Only an `initialize` object can replace an open session. A sessionless array containing initialize, including a one-element array, is refused with HTTP `400` before session creation or handover. Scoped
IPv6 literals (`fe80::1%eth0`) are refused before binding, because no URL can
name them. Listener errors reach the runtime's connection-loss handler only
after a successful bind; a bind failure prints the listen-failure message alone. Request bodies are bounded by the SDK's
Expand Down
8 changes: 5 additions & 3 deletions docs/specs/agent-environments/environment-export/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ previous increment.
copies of the inputs (mode `0600` on POSIX; Windows retains inherited ACLs
and does not provide an owner-only guarantee), `artifacts/check_<name>/verify.py` and
`tasks/smoke.json`, where `<name>` defaults to the manifest's kebab-case name
and `--name` must be a kebab-case label of at most 64 characters that is not a
and `--name` must be a kebab-case label of at most 63 characters for its DNS label that is not a
device name Windows reserves (`con`, `prn`, `aux`, `nul`, `com1`–`com9`,
`lpt1`–`lpt9`), since the env folder could not be created there.
`lpt1`–`lpt9`). Its HTTP hostname identity must remain unchanged, so numeric
labels interpreted as IP addresses are refused before planning.
- The Dockerfile uses the Node version pinned in `.nvmrc` with the
`bookworm-slim` variant, sets
`MCP_HOST=0.0.0.0` and `MCP_PORT=18765`, installs Debian's `python3` for the
`MCP_HOST=0.0.0.0`, `MCP_PORT=18765`, and
`MCP_ALLOWED_HOSTS=127.0.0.1,localhost,<name>`, installs Debian's `python3` for the
framework's readiness probe, installs
`rea-agents@<PRODUCT_IDENTITY.packageVersion>` from the registry or, with
`--package-tarball`, copies the tarball as `rea-agents.tgz`, installs it
Expand Down
6 changes: 4 additions & 2 deletions docs/specs/agent-environments/environment-serve/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,10 @@ unreadable path; the process-boundary test asserts a `143` exit with a
released port. Replay validates no supplied values, performs no network
activity and establishes no authorization, response semantics or state
transitions. One MCP client is served at a time, as the shared listener
defines. Non-loopback binds relax the `Host` check and are not exercised; a
listener `error` after binding is wired but not provoked by a test.
defines. Non-loopback binds require `MCP_ALLOWED_HOSTS` and retain both Host
and Origin checks. Shared listener regressions exercise a wildcard bind,
configuration refusal, port release and admitted/foreign request headers;
the original environment-serving fixture runs below used loopback.

## Resource and cancellation boundaries

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,10 @@ parameters cannot mismatch, and a call without arguments is exact on the
tool's first record. Request bodies on write tools are accepted and ignored.
The post-bind listener `error` path is wired and type-checked but not
provoked by a test, since the shared listener does not expose its
`node:http` server. Non-loopback binds and the relaxed `Host` check were not
exercised; the Python exchange, curl checks and every test used `127.0.0.1`.
`node:http` server. These recorded runs predate the required non-loopback Host/Origin allowlist;
the Python exchange, curl checks and tests recorded here used `127.0.0.1`.
The current contract and later non-loopback coverage are described in the
[transport specification](../http-transport/spec.md).
Replay selection and body parsing were measured on the 28-record fixture
only; no large-capture timing was taken. Resident-set figures are `ps` samples
of the serving process before and after one request each. Token usage and
Expand Down
7 changes: 5 additions & 2 deletions docs/specs/agent-environments/http-transport/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,11 @@ initialize, tools/list, `binary_session` and `DELETE` with the SDK streamable
HTTP client, then proves `binary_session` still answers in the next session and
in a session that replaced one left open; a process-boundary test asserts
`143`/`130` exits and a released port. Concurrent clients are not supported:
the newest `initialize` owns the server. The non-loopback relaxation is covered
only through its pure allowlist helper; no wide bind runs in tests. Response
the newest `initialize` owns the server. Non-loopback binds require the
operator's parsed hostname allowlist, retain Host/Origin gates, and announce a
client URL admitted by that list. Current listener regressions exercise a
wildcard bind, refusal/port release and a direct SDK connection to the
announced endpoint. Response
budgets keep their stdio-derived floor; registrations, `rea mcp doctor` and
`server.json` stay stdio.

Expand Down
6 changes: 3 additions & 3 deletions docs/specs/agent-environments/http-transport/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,9 @@ The concurrent-`initialize` case is exercised at the slot with in-memory
`Request` objects, where both calls advance in lockstep; over TCP two
connections are served in separate event-loop turns, so the race is reachable
only in-process and the listener-level behaviour remains "the newest client
owns the server, the other sees `400` or `404`". The non-loopback relaxation
path is covered only through the pure allowlist helper and the loopback smoke
runs; no wide bind was exercised. Sessions are served one at a time;
owns the server, the other sees `400` or `404`". Those recorded runs predate the mandatory non-loopback allowlist and did not
exercise a wide bind. The current requirement and later listener regression
coverage are described in the [specification](spec.md). Sessions are served one at a time;
concurrent clients are not supported. The two failures that reproduce on the
clean clone are environmental (macOS with Node 22.23.1 and npm 12.0.2) and are
left for their owners; the load-induced timeouts were classified by isolated
Expand Down
3 changes: 2 additions & 1 deletion src/application/environment/environmentBuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { EnvironmentBuildReport } from "../../domain/environmentBundleExpor
import { err, ok, type Result } from "../../domain/result.js";
import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js";
import {
BUNDLE_NAME_CONSTRAINT,
ENVIRONMENT_EXPORT_OPERATION,
isPortableBundleName,
planEnvironmentBundle,
Expand Down Expand Up @@ -191,7 +192,7 @@ const unnamed = (path: string): AnalysisInputError =>
new AnalysisInputError(
ENVIRONMENT_BUILD_OPERATION,
{
userMessage: `The capture's origin is a loopback or IP address, and ${path === "" ? "retained Evidence has no file name to take one from" : `${path} yields no portable environment name (a kebab-case label of at most 64 characters that is not a Windows device name)`}; name the environment.`,
userMessage: `The capture's origin is a loopback or IP address, and ${path === "" ? "retained Evidence has no file name to take one from" : `${path} yields no portable environment name (${BUNDLE_NAME_CONSTRAINT})`}; name the environment.`,
},
[
{
Expand Down
34 changes: 34 additions & 0 deletions src/application/environment/environmentBundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js";
import {
BUNDLE_BASE_IMAGE,
bundleDockerfile,
isPortableBundleName,
ENVIRONMENT_EXPORT_OPERATION,
planEnvironmentBundle,
registryPackageSource,
Expand Down Expand Up @@ -117,6 +118,9 @@ describe("planEnvironmentBundle", () => {
expect(lines[0]).toBe(`FROM ${BUNDLE_BASE_IMAGE}`);
expect(lines).toContain("ENV MCP_HOST=0.0.0.0");
expect(lines).toContain("ENV MCP_PORT=18765");
expect(lines).toContain(
`ENV MCP_ALLOWED_HOSTS=127.0.0.1,localhost,grafana-example`,
);
expect(lines).toContain(
"RUN apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/*",
);
Expand Down Expand Up @@ -235,6 +239,36 @@ describe("planEnvironmentBundle", () => {
});

describe("planEnvironmentBundle portable names", () => {
it.each(["4294967296", "123", "0xffffffff"])(
"refuses bundle name %s when HTTP cannot preserve its host identity",
(name) => {
expect(nameRefusal(name)).toMatchObject({
code: "invalid_request",
details: {
operation: "environment-export",
issues: [{ path: ["name"], reason: "invalid_value" }],
},
});
},
);

it("keeps addressable numeric prefixes and admits the DNS label boundary", () => {
for (const name of ["123-replay", "0xffffffff-replay", "a".repeat(63)]) {
expect(isPortableBundleName(name)).toBe(true);
const planned = plan({ name });
expect(planned.name).toBe(name);
expect(file(planned, `envs/${name}/Dockerfile`)).toContain(
`ENV MCP_ALLOWED_HOSTS=127.0.0.1,localhost,${name}`,
);
}
expect(isPortableBundleName("a".repeat(64))).toBe(false);
expect(nameRefusal("a".repeat(64))).toMatchObject({
code: "invalid_request",
});
for (const name of ["123", "4294967296", "0xffffffff"])
expect(isPortableBundleName(name)).toBe(false);
});

it("accepts a kebab-case name override and refuses any other name as invalid input", () => {
const renamed = plan({ name: "replay-2" });
expect(renamed.files.map((entry) => entry.path)).toContain(
Expand Down
Loading
Loading