fix(process): integrate main and confirm native exit transitions
This commit is contained in:
commit
8ea965261e
71 files changed
+6651
-475
No files matched your search
@@ -1,8 +1,8 @@
|
||||
graph TD
|
||||
subgraph "Entry Points"
|
||||
CLI["src/cli.ts + src/cli/<br/>One-shot CLI adapter<br/>setup, diagnostics, analysis, decompilation"]
|
||||
MAIN["src/main.ts + src/main/<br/>MCP stdio server adapter<br/>config, session, process lifetime"]
|
||||
DISPATCH["scripts/rea.mjs<br/>Executable dispatcher<br/>routes mcp / one-shot"]
|
||||
MAIN["src/main.ts + src/main/<br/>MCP server adapter: stdio default, streamable HTTP opt-in<br/>config, session, process lifetime"]
|
||||
DISPATCH["scripts/rea.mjs<br/>Executable dispatcher<br/>routes mcp / mcp --http / one-shot"]
|
||||
end
|
||||
|
||||
subgraph "Production Composition"
|
||||
@@ -10,7 +10,7 @@ graph TD
|
||||
end
|
||||
|
||||
subgraph "Adapters"
|
||||
SERVER["src/server/<br/>MCP request translation<br/>createServer, tool registration, toolResult"]
|
||||
SERVER["src/server/<br/>MCP request translation<br/>createServer, tool registration, toolResult<br/>http/: streamable HTTP listener and node:http bridge"]
|
||||
HOPPER_BRIDGE["bridge/hopper_bridge.py<br/>Runs inside Hopper<br/>adapts operations to Hopper Python API"]
|
||||
GHIDRA_BRIDGE["bridge/ghidra/ReaGhidraBridge.java<br/>Packaged HeadlessScript<br/>inventory, decompiler, references, CFG"]
|
||||
INSTALL["src/cli/ + src/application/Setup*<br/>Host installation adapters<br/>macOS: verified vendor DMG to ~/Applications<br/>Linux: verified vendor package + apt/dnf/pacman"]
|
||||
|
||||
+26
@@ -316,6 +316,28 @@ Choose the guide for the target and intended interaction:
|
||||
Runtime requests name the target and actions. Launched targets run with your
|
||||
user permissions; consult the chosen guide for host requirements and effects.
|
||||
|
||||
## Serve MCP over streamable HTTP
|
||||
|
||||
`rea mcp` speaks stdio to the client that launched it. To serve the same MCP
|
||||
catalog over streamable HTTP for one local client, such as an agent environment
|
||||
runtime, start the dispatcher entry with `--http`:
|
||||
|
||||
```sh
|
||||
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`
|
||||
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
|
||||
exited without ending its session needs no restart. The listener runs until
|
||||
SIGINT or SIGTERM and does not stop when stdin closes. See
|
||||
[transports](mcp-contracts.md#transports) for the session, `Host` header and
|
||||
shutdown contract. Client registrations written by setup and the MCP registry
|
||||
entry keep stdio.
|
||||
|
||||
## Output and exit status
|
||||
|
||||
`rea mcp doctor --json` checks production MCP startup and reports child logs in
|
||||
@@ -350,6 +372,10 @@ output formatting.
|
||||
| `1` | The operation could not complete. Structured output identifies invalid input, permissions, cancellation, timeouts or another failure when available. |
|
||||
| `128 + N` | Signal-derived status. An unhandled signal may have ended the process, with the shell or runtime preserving the conventional status; a command may instead have handled the signal, waited for its operation and cleanup to settle, and assigned the same status (`130` for SIGINT, `143` for SIGTERM). In the handled case, it may also have printed a structured `cancelled` result. |
|
||||
|
||||
`rea mcp --http` is long-running and prints no structured result. It exits
|
||||
`130` on SIGINT or `143` on SIGTERM after closing the listener, the session and
|
||||
its providers, and `1` when that cleanup fails.
|
||||
|
||||
A `cancelled` result does not by itself determine the exit status; it depends on
|
||||
the command's signal handling. Commands routed through the shared
|
||||
`withCommandCancellation` handler in the CLI set status `130` for SIGINT or
|
||||
|
||||
+92
-1
@@ -2,7 +2,8 @@
|
||||
|
||||
Use this reference to call REA tools and interpret their replies on one MCP
|
||||
connection. Start with [tool results](#tool-results), [retained Evidence](#retained-application-evidence-inputs),
|
||||
and [session discovery](#identity-and-discovery). See [cancellation](#progress-and-cancellation)
|
||||
and [session discovery](#identity-and-discovery). See [transports](#transports)
|
||||
for the opt-in HTTP listener, [cancellation](#progress-and-cancellation)
|
||||
for partial-result recovery and [first-query deadlines](#ghidra-first-query-deadlines-and-recovery)
|
||||
when a cold provider exceeds the client's wait.
|
||||
|
||||
@@ -443,6 +444,96 @@ inspection fails or is cancelled before a provider client exists. If no client
|
||||
remains, no provider run or health observation is available; follow the cleanup
|
||||
error's reported resources.
|
||||
|
||||
## Transports
|
||||
|
||||
stdio is the default transport and the only one that client registrations,
|
||||
`rea mcp doctor` and the MCP registry entry describe. `rea mcp --http [--host H]
|
||||
[--port N]` is an opt-in alternative that serves the same catalog over the
|
||||
streamable HTTP transport at `/mcp`; it replaces stdio for that process rather
|
||||
than running beside it. Host and port come from the flags, then `MCP_HOST` and
|
||||
`MCP_PORT`, then `127.0.0.1` and an ephemeral port. The bound URL is announced
|
||||
as one JSON log line on stderr (`"msg":"MCP transport listening"` with `url`)
|
||||
at every `REA_LOG_LEVEL`, because an ephemeral port is otherwise unreachable;
|
||||
stdout stays unused.
|
||||
|
||||
This endpoint uses plain HTTP without TLS or client authentication. Use it only
|
||||
within a trusted local or isolated environment; an explicit non-loopback bind
|
||||
makes the same tools and active analysis state reachable at that address. It
|
||||
does not provide separate users or independent target sessions. Standard client
|
||||
registrations continue to use a dedicated stdio process.
|
||||
|
||||
The HTTP server is stateful and serves one client at a time on one server
|
||||
instance. `initialize` returns an `Mcp-Session-Id`; `notifications/initialized`
|
||||
is acknowledged with `202`; responses arrive as JSON or SSE frames; `DELETE
|
||||
/mcp` with the session id ends the session with `200`. Ending a session is not
|
||||
a server shutdown: the process keeps listening, the instance keeps its providers
|
||||
and the shared active target, and the next client opens a fresh session against
|
||||
them. A client ending or replacing a session cancels its running tool calls and
|
||||
prompt completions, stops request-body reads and fences further message dispatch,
|
||||
then waits up to ten seconds for request dispatch, handlers and cleanup to finish before reusing the
|
||||
server. A timeout returns `503`; the old calls remain owned, and later requests
|
||||
retry the drain rather than starting another session against those resources.
|
||||
Requests that finish parsing or SDK dispatch after their session closed are
|
||||
refused, and their response streams are cancelled. Tool callbacks whose
|
||||
validation finishes after cancellation never start.
|
||||
Body forwarding stops when SDK request processing ends, including early header
|
||||
and size refusals, rather than remaining active until the session closes.
|
||||
HTTP input/route dispatch is limited to eight concurrent requests by default,
|
||||
shared by MCP and plain routes. Excess requests receive `503` with a closing
|
||||
connection before their bodies are converted, read or cloned. An embedder can
|
||||
select `maxConcurrentDispatches`; the SDK's per-body size limit still applies.
|
||||
MCP responses with fully read or bodyless requests do not hold admission slots,
|
||||
so an idle MCP SSE stream does not prevent later calls. Plain routes retain their
|
||||
slots through response delivery and response-source cancellation cleanup. An
|
||||
unread upload retains its slot until input finishes or either side closes,
|
||||
even with a long response. Sequential keep-alive is supported; a peer pipelining
|
||||
behind an unfinished response is disconnected before queued requests dispatch.
|
||||
Concurrent MCP requests use separate connections.
|
||||
This bounds concurrent input work, not total provider or process memory.
|
||||
Provider-backed prompt completions use the same request owner and drain, so a
|
||||
cancelled completion cannot keep using providers after handover or shutdown.
|
||||
HTTP factories enable `RequestTrackingMcpServer` tracking so completion is tracked
|
||||
even when cancellation suppresses the SDK's reply; stdio retains the SDK's
|
||||
original callback behavior. Full server shutdown stops the listener and waits
|
||||
for MCP work, plain-route handlers and response-source cancellation to settle
|
||||
before running provider shutdown hooks or closing providers. Response producers
|
||||
must finish provider work before closing their stream and expose asynchronous
|
||||
disconnect cleanup through the stream's `cancel()` result. Cancellation does not
|
||||
delay delivery of a HEAD reply; a caller retaining a sibling of a teed response
|
||||
must consume or cancel that sibling before shutdown can finish. Failed response
|
||||
cleanup remains owned, blocks further admission and prevents provider teardown.
|
||||
The server continues cleanup
|
||||
automatically when they finish, including after a client handover timed out.
|
||||
All routes return `503` until the server factory succeeds; startup failure closes
|
||||
the listener and joins its pending replies before rejecting the factory.
|
||||
The newest client replaces an open session: an `initialize` that arrives
|
||||
while a session is open ends that session instead of being refused, because a
|
||||
client that exits without `DELETE` must not wedge the listener; the replaced
|
||||
client's open streams are closed and its later requests are refused. Only an
|
||||
`initialize` that carries the streamable headers (`Content-Type:
|
||||
application/json` and an `Accept` naming both `application/json` and
|
||||
`text/event-stream`) and passes the Host and Origin checks can replace a
|
||||
session; any other sessionless POST goes to the live transport, which refuses
|
||||
it without disturbing the session. Requests that carry no valid session id are
|
||||
refused with `400` or `404` as the SDK transport defines. A bind is loopback by
|
||||
address class, not spelling, and is classified from the address the listener
|
||||
actually bound: any `127.0.0.0/8` address, `::1`, or an IPv4-mapped loopback.
|
||||
Configured names, including `localhost` and its aliases, do not establish that
|
||||
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 a lone `initialize` can replace an open session; a batch that carries one is left to the transport's refusal. 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
|
||||
4 MiB limit, and the response budget keeps its stdio-derived floor. The
|
||||
listener ends on SIGINT or SIGTERM only, not on stdin EOF, and exits `130` or
|
||||
`143` after cleanup settles. A signal that arrives while startup is still
|
||||
running is recorded and acted on as soon as the listener and its providers are
|
||||
owned, so startup never ends without cleanup; a second signal during startup
|
||||
exits immediately.
|
||||
|
||||
## Progress and cancellation
|
||||
|
||||
REA accepts ordinary `tools/call` progress tokens. The numeric `progress` counts
|
||||
|
||||
@@ -105,11 +105,13 @@ GDB/Python/pwndbg/strace. Fixture generation explicitly launches its owned test
|
||||
program; subsequent inspection only reads the resulting core. Real acceptance
|
||||
checks recorded threads, high registers, missing/malformed notes, opaque owners,
|
||||
CLI/MCP parity, optional debugger context and historical PID collision with an
|
||||
owned live sentinel. Observed Node processes and their traced threads may read
|
||||
that live process's `stat` for the shared ownership inventory; debugger and
|
||||
decoder processes cannot use this exception. A second recording uses a valid
|
||||
owned live sentinel. An observed Node thread group must launch the exact ownership
|
||||
`ps` inventory before it can read that live process's `stat` or `status` for native
|
||||
identity/UID facts. Only traced `CLONE_THREAD` members share this allowance;
|
||||
exec-replaced threads, ordinary children, debugger and decoder processes cannot
|
||||
use it. A second recording uses a valid
|
||||
historical PID above the host's assignable PID range and rejects resolved lookups,
|
||||
including Node `stat` reads, to detect historical-PID-driven access separately
|
||||
including Node `stat`/`status` reads, to detect historical-PID-driven access separately
|
||||
from live inventory. Descriptor paths come from strace's `-yy` decoding; exact
|
||||
PID-directory opens and relative accesses through those handles are rejected.
|
||||
Failed FD-relative pathname attempts are checked across syscall variants.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Implementation plan
|
||||
|
||||
1. Widen the runtime `serve` dependency to a transport-neutral factory that may
|
||||
bind asynchronously. `startMcpTransport` awaits it, hands it the server
|
||||
logger, and keeps stdio as the default; the shutdown handle type loses its
|
||||
stdio name.
|
||||
2. Add `src/server/http/nodeHttpBridge.ts` to turn an IncomingMessage into a web
|
||||
Request (headers, streamed body, abort on client close) and to pipe a web
|
||||
Response back with flushed headers, and `src/server/http/serveStreamableHttp.ts`
|
||||
to own one `McpServer` instance and one stateful transport per session,
|
||||
ended by `DELETE` or replaced by the next client's `initialize` without
|
||||
running the instance's shutdown hooks, plus plain routes, loopback Host
|
||||
validation, the listening log line and idempotent close. The typed listen
|
||||
error lives in its own module so the stdio entry never loads the listener.
|
||||
3. Parse `--http`, `--host` and `--port` in `src/main/mcpHttpArguments.ts` from
|
||||
one schema that also renders help; resolve `MCP_HOST`/`MCP_PORT` in
|
||||
`src/config/mcpHttpEndpoint.ts` as a `Result` that names rejected settings
|
||||
without their values.
|
||||
4. Route the dispatcher to `runMcpHttpEntrypoint` when the `mcp` entry carries
|
||||
`--http`; compose HTTP dependencies in `src/main.ts` with the shared
|
||||
`run()` path so configuration, session, providers and shutdown stay common.
|
||||
5. Register signals only for HTTP mode through `registerSignalShutdown`, a
|
||||
projection of `registerProcessShutdown` without stdin that sets the signal
|
||||
exit status before the idempotent shutdown runs; stdio keeps its stdin
|
||||
end/close triggers.
|
||||
6. Document the transport in docs/mcp-contracts.md and docs/cli.md, relabel the
|
||||
architecture diagram, and cover each boundary with adapters, dispatcher,
|
||||
mcp-boundary and process-boundary tests plus a Python SDK client exchange.
|
||||
|
||||
No new dependency is introduced: the transport, Host validation and body bound
|
||||
come from the already pinned `@modelcontextprotocol/server` root export. Tool
|
||||
contracts, catalog digests, response budgets and stdio behaviour are unchanged.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Streamable HTTP transport for the MCP server
|
||||
|
||||
Issue: https://github.com/morluto/rea/issues/4933
|
||||
|
||||
## Outcome
|
||||
|
||||
`rea mcp --http [--host H] [--port N]` serves the existing MCP tool catalog over
|
||||
the pinned SDK's streamable HTTP transport instead of stdio, so an agent
|
||||
environment runtime or any HTTP MCP client can reach REA on a local URL. The
|
||||
listener is opt-in: bare `rea mcp`, `rea mcp doctor`, `rea mcp add` and every
|
||||
client registration keep stdio.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Route `mcp --http ...` and `--mcp --http ...` to the MCP runtime in the
|
||||
dispatcher without changing bare `mcp`, `mcp doctor` or `mcp add`.
|
||||
- Resolve host and port from the flags, then `MCP_HOST`/`MCP_PORT` from the
|
||||
environment snapshot, then `127.0.0.1` and port `0`; announce the bound URL as
|
||||
one pino JSON line on stderr at every log level and leave stdout unused.
|
||||
- Use `WebStandardStreamableHTTPServerTransport` in stateful mode around one
|
||||
server instance built once, after the listener has bound. `DELETE` ends the session on its transport without
|
||||
running the instance's shutdown hooks, so the same instance keeps its
|
||||
providers and serves the next client on a fresh transport; the hooks run when
|
||||
the listener closes.
|
||||
- Let a new client's `initialize` replace a session that is still open, closing
|
||||
the earlier transport the same way, so a client that left without `DELETE`
|
||||
cannot block the listener; the replaced client's later requests are refused
|
||||
(`404` while the replacing session is open).
|
||||
- Bridge node:http to web Request/Response in `src/server/http/`, streaming
|
||||
response chunks as they are produced and turning a client disconnect into a
|
||||
request abort and body cancellation.
|
||||
- Classify a loopback bind by the actual bound IP (`127.0.0.0/8`, `::1`, or
|
||||
IPv4-mapped loopback), without using the configured DNS name as evidence; validate
|
||||
the `Host` and `Origin` headers with the SDK loopback allowlist plus the
|
||||
configured name and the bound address on such binds, and relax both for
|
||||
other binds and log that relaxation. Refuse scoped IPv6 literals before
|
||||
binding, close the listener if its URL cannot be formed, and forward
|
||||
listener errors to `onerror` only after a successful bind.
|
||||
- Let the caller own instance cleanup (`closeInstance: false`) when its factory
|
||||
owner already closes every instance, as the MCP runtime does.
|
||||
- Let only an `initialize` that carries the streamable headers and passes those
|
||||
checks replace an open session; refuse requests once the slot is closed, join
|
||||
an arming session during shutdown, and keep close stages retryable.
|
||||
- End the HTTP listener on SIGINT/SIGTERM only, never on stdin EOF, and exit with
|
||||
the signal status (`130`/`143`) after cleanup settles; latch a signal that
|
||||
arrives during startup so it is acted on as soon as the lifecycle owner exists,
|
||||
and exit at once on a second one.
|
||||
- Export `serveStreamableHttp({ host, port, createMcpServer, routes, logger })`
|
||||
returning `{ url, close }` so an agent environment server can add plain routes
|
||||
beside `/mcp` without a second bridge.
|
||||
- Keep listener failure causes out of stderr behind a fixed message while the
|
||||
typed listen error carries the system cause (such as `EADDRINUSE`) into the
|
||||
error-level log line.
|
||||
|
||||
## Acceptance and limits
|
||||
|
||||
Adapters tests cover the bridge (method, URL, headers, body, GET without body,
|
||||
incremental streaming, abort and cancel) and the listener (listening line, one
|
||||
factory call across three sessions, replacement of an open session by the next
|
||||
`initialize`, two `initialize` requests racing to replace one session while the
|
||||
instance's shutdown hook stays installed, a working tool call after `DELETE`,
|
||||
the instance's shutdown hook firing only when the listener closes, plain
|
||||
routes, 404, Host refusal, port release, typed bind failure naming
|
||||
`EADDRINUSE`). The dispatcher test covers the new routes and keeps `mcp add
|
||||
--http` with the CLI. An mcp-boundary test spawns the built CLI and drives
|
||||
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
|
||||
budgets keep their stdio-derived floor; registrations, `rea mcp doctor` and
|
||||
`server.json` stay stdio.
|
||||
|
||||
## Deferred issue criteria
|
||||
|
||||
Serving an environment manifest with replayed records, its environment card and
|
||||
the exported bundle are later increments of #4933. This PR must reference rather
|
||||
than close the issue.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Define single-feature requirements and implementation plan before code.
|
||||
- [x] Widen the runtime serve seam and shutdown handle to transport-neutral types.
|
||||
- [x] Implement the node:http bridge and the streamable HTTP listener module.
|
||||
- [x] Parse flags and `MCP_HOST`/`MCP_PORT`, route the dispatcher, add signals-only shutdown.
|
||||
- [x] Document the transport, CLI entry, exit status and architecture label.
|
||||
- [x] Verify bridge, listener, dispatcher, SDK client, Python client and shutdown behavior.
|
||||
- [x] Keep providers alive across sessions, replace a session left open, name the bind cause in the log.
|
||||
- [x] Resolve the re-review: capture the shutdown hook once, one shutdown registration primitive, flags-only HTTP routing.
|
||||
- [x] Run relevant repository checks and prepare a separately scoped PR.
|
||||
@@ -0,0 +1,297 @@
|
||||
# Verification
|
||||
|
||||
Verified base: 18aa9af2b2c26fd114113dd39528a91224801840 (fetched main).
|
||||
|
||||
The worktree was moved onto that base with the slice's changes kept
|
||||
uncommitted, then reinstalled with `npm ci` (exit 0; 540 packages added, 541
|
||||
audited; 6.56 s elapsed, 3.85 s user, 8.07 s sys, 881459200 bytes maximum
|
||||
resident set size). After the edits of this pass the tree was rebuilt with
|
||||
`npm run build` (exit 0, three turbo tasks; 5.23 s elapsed, 13.94 s user,
|
||||
2.93 s sys, 2964652032 bytes) and `npm run test:prepare` (exit 0, six tasks
|
||||
with three cache hits; 1.37 s elapsed, 3.07 s user, 0.76 s sys, 422625280
|
||||
bytes); `dist/mcp/` carries the rebuilt `serveStreamableHttp-*.js` chunk beside
|
||||
`main.js`.
|
||||
|
||||
Static gates on the final tree: `npm run check:fast` passed (typecheck and
|
||||
lint, two tasks; 4.69 s elapsed, 24.25 s user, 3.00 s sys, 4480139264 bytes).
|
||||
`npx oxlint` over the files touched in this pass reported no findings. After
|
||||
the last edit, including this record, `npm run format` ran over 4032 files
|
||||
(0.68 s elapsed, 4.11 s user, 0.74 s sys, 227393536 bytes) and `npm run
|
||||
format:check` passed (0.69 s elapsed, 4.09 s user, 0.62 s sys, 216989696
|
||||
bytes). `npx knip` exited 0 (3.80 s elapsed, 5.10 s user, 0.72 s sys,
|
||||
868679680 bytes). `npm run check:ci:tasks` passed all eight tasks, seven of
|
||||
them turbo cache hits from the run on the same sources (0.88 s elapsed, 4.52 s
|
||||
user, 1.04 s sys, 215171072 bytes): jscpd reported 2576 clones and 3.35%
|
||||
duplicated lines across 3143 files under the 3.5% threshold, lint:built 0
|
||||
errors over 3734 files, and format:check ran fresh. `npm run docs:check`
|
||||
passed, including the README consistency test (5 cases; 3.27 s elapsed,
|
||||
3.43 s user, 0.80 s sys, 288866304 bytes). `git diff --stat` lists 13 tracked
|
||||
files, none under `src/domain/`.
|
||||
|
||||
Source-group tests: `/usr/bin/time -l npx vitest run --project adapters
|
||||
src/server/http src/server/shutdownRegistration.test.ts` passed 3 files, 17
|
||||
tests in 2.74 s elapsed, 3.12 s user, 0.93 s sys, 289767424 bytes.
|
||||
`serveStreamableHttp.test.ts` gained the concurrent case: a slot with one open
|
||||
session receives two `initialize` requests through `Promise.all`, exactly one
|
||||
answers `200` with a session id and the other `400`, the instance's `onclose`
|
||||
hook has fired zero times, and `slot.close()` fires it once. Run before the fix
|
||||
against the same file, that case failed with the hook count `0` instead of `1`
|
||||
(1 failed, 5 passed; 1.07 s elapsed, 0.84 s user, 0.21 s sys, 158187520
|
||||
bytes), which is the pinned no-op hook the review described.
|
||||
`shutdownRegistration.test.ts` asserts that `registerProcessShutdown` hands
|
||||
the handler `SIGINT`/`SIGTERM` by name and `undefined` for stdin end, that
|
||||
omitting stdin registers no input listener, and that `registerSignalShutdown`
|
||||
reports `143`/`130`.
|
||||
|
||||
Built-group tests ran after `npm run test:prepare`. The targeted dispatcher
|
||||
run, `/usr/bin/time -l npx vitest run --project cli-boundary
|
||||
tests/boundary/cli/dispatcher.test.ts`, passed 3 tests in 1.08 s elapsed,
|
||||
0.89 s user, 0.26 s sys, 153026560 bytes, now including `mcp add --http`
|
||||
routed to the CLI beside `mcp --http --port 0`, `--mcp --http` and `mcp --host
|
||||
127.0.0.1 --http` routed to the HTTP entry and `mcp doctor --http`, `mcp add`
|
||||
and `--mcp extra` keeping their routes.
|
||||
|
||||
The four full projects ran in sequence while a sibling worktree ran its own
|
||||
`cli-boundary`, `mcp-boundary` and `acceptance` chain on the same host.
|
||||
`/usr/bin/time -l npx vitest run --project adapters` passed 247 files, 3945
|
||||
tests with 8 skipped, in 93.77 s elapsed, 80.21 s user, 30.69 s sys, 344260608
|
||||
bytes. `/usr/bin/time -l npx vitest run --project mcp-boundary` passed 155
|
||||
files with 2 skipped, 910 tests with 7 skipped, in 167.51 s elapsed, 321.24 s
|
||||
user, 51.30 s sys, 4923490304 bytes (the Vitest parent, its workers and the
|
||||
spawned CLI children). `/usr/bin/time -l npx vitest run --project
|
||||
process-boundary` exited 1: 56 files passed, 2 skipped and 2 failed, 460 tests
|
||||
passed, 19 skipped and 7 failed, in 544.81 s elapsed, 160.89 s user, 133.49 s
|
||||
sys, 1028931584 bytes. `/usr/bin/time -l npx vitest run --project
|
||||
cli-boundary` exited 1: 108 files passed and 3 failed, 658 tests passed, 2
|
||||
skipped and 7 failed, in 452.34 s elapsed, 734.29 s user, 234.02 s sys,
|
||||
648495104 bytes. No failing file belongs to this slice.
|
||||
|
||||
The failures split into two kinds. Load: six `jadxIntegration.test.ts` cases
|
||||
hit the 30 s test or 10 s hook timeout, and six `javascriptApplication*.test.ts`
|
||||
cases failed with the worker's result-capacity constraint; re-run alone in this
|
||||
worktree after the other chains ended, `jadxIntegration.test.ts` passed all 48
|
||||
tests and both JavaScript files passed all 20 tests (`cli-boundary` three files:
|
||||
20 passed, 1 failed, 23.38 s elapsed, 32.99 s user, 10.97 s sys, 343244800
|
||||
bytes; `process-boundary` two files: 48 passed, 1 failed, 62.48 s elapsed,
|
||||
21.89 s user, 17.55 s sys, 686620672 bytes). Pre-existing on main in this
|
||||
environment: `closedStdout.test.ts` "stops quietly when the reader closes
|
||||
during streamed JSON" exits `1` instead of `141` with 1009 bytes received and
|
||||
empty stderr, and `releaseArtifactCanary.test.ts` fails parsing `npm pack
|
||||
--json`, which npm 12.0.2 emits as an object keyed by package name rather than
|
||||
the tuple the test expects. Both reproduce identically on the clean sibling
|
||||
clone at `52b73ee857029a3ab8f5c6eeef233b295951a46c` (no tracked or untracked
|
||||
changes; `closedStdout` 1 failed, 3 passed, 8.86 s elapsed, 6.98 s user, 2.29 s
|
||||
sys, 348012544 bytes; the canary 1 failed, 7.91 s elapsed, 7.76 s user, 1.84 s
|
||||
sys, 668729344 bytes). That clone predates the verified base by 15 commits,
|
||||
none touching either test or `scripts/rea.mjs`. Because the dispatcher is on
|
||||
the `closedStdout` path, its scenario was also replayed against this
|
||||
worktree's built `dist/` with the `18aa9af2` `scripts/rea.mjs` and with the
|
||||
edited one, three runs each: all six exited `1` with 1009 bytes received and
|
||||
empty stderr. Nothing in this pass was changed for these failures.
|
||||
|
||||
Cross-implementation check with the Python `mcp` 1.30.0 client (httpx 0.28.1,
|
||||
Python 3.12.12) against the built `node scripts/rea.mjs mcp --http --port 0 <
|
||||
/dev/null`: stderr announced
|
||||
`{"transport":"streamable-http","url":"http://127.0.0.1:57093/mcp","msg":"MCP transport listening"}`.
|
||||
Session A: `initialize` POST returned `200 text/event-stream` with
|
||||
`mcp-session-id`; `notifications/initialized` returned `202` with no body; the
|
||||
standalone GET and the `tools/list` and `tools/call` POSTs returned `200
|
||||
text/event-stream`; `tools/list` reported 207 tools including
|
||||
`binary_session`; `binary_session` with `{}` returned `isError=False`; `DELETE
|
||||
/mcp` returned `200`. Session B, opened after that `DELETE`, initialized with a
|
||||
new session id, listed the same 207 tools, and `binary_session` again returned
|
||||
`isError=False` before its own `DELETE` returned `200`. The server logged two
|
||||
`MCP HTTP session opened` and two `MCP HTTP session closed by the client`
|
||||
lines, SIGTERM then exited it with status `143`, and stdout stayed empty. The
|
||||
methods, statuses and session-id handling of session A match the recorded
|
||||
reference exchange step for step; the reference's echo server labelled its
|
||||
`202` and `DELETE` replies `application/json`, where this listener sends no
|
||||
content type for those bodiless replies.
|
||||
|
||||
## Independent review
|
||||
|
||||
An adversarial review of the first implementation reproduced that, after a
|
||||
`DELETE`, the next session could initialize and list tools but every tool call
|
||||
failed with a `cancelled` error because the SDK's `DELETE` handling closed the
|
||||
transport and the server's `onclose` hooks ran provider teardown. It also found
|
||||
that a session ended without `DELETE` blocked the listener until restart, that
|
||||
the bind-failure log line omitted the system cause, a duplicated foreign-`Host`
|
||||
probe, a missing trailing newline on `rea mcp --http --help`, and a static
|
||||
import of the HTTP listener on the stdio path. Each finding is addressed and
|
||||
covered by the adapters, mcp-boundary and Python checks in this document.
|
||||
|
||||
The re-review of the rebased tree raised four points, all resolved here. Two
|
||||
concurrent `initialize` POSTs could leave the no-op `onclose` hook installed,
|
||||
because each session end captured the hook current at that moment and the
|
||||
later `finally` restored the other call's no-op; `McpSessionSlot` now captures
|
||||
the instance's hook once, after the factory returns the fully wrapped server,
|
||||
and every session end restores exactly that, with the adapters case above
|
||||
failing before and passing after the change. The optional stdin parameter of
|
||||
`registerProcessShutdown` was dead in production; `registerSignalShutdown` is
|
||||
now a projection of `registerProcessShutdown(handler, { signals })`, so one
|
||||
primitive owns registration while the handler learns the signal and keeps
|
||||
`130`/`143`. The dispatcher routed any `mcp` invocation containing `--http` to
|
||||
the HTTP entry; it now does so only when the first token after `mcp` or
|
||||
`--mcp` is a flag, so `rea mcp add --http` keeps the CLI's own diagnostic, and
|
||||
the dispatcher test covers that case. The formatter hunk in
|
||||
`src/domain/propertyListXml.test.ts` noted in the earlier record is gone after
|
||||
the rebase, as `git diff --stat` confirms. The replacement rule stays as
|
||||
documented, and `docs/mcp-contracts.md` now states it in one sentence with its
|
||||
rationale so maintainers can decide on it.
|
||||
|
||||
## Scope and measurement limits
|
||||
|
||||
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;
|
||||
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
|
||||
re-runs, not by repeating the full projects on an idle host. The error-level
|
||||
log line that names the bind cause was observed in earlier runs, not asserted
|
||||
by `mainErrors.test.ts`. Response budgets keep the stdio-derived floor and were
|
||||
not re-measured over HTTP. Windows was not run.
|
||||
|
||||
## Automated review findings on the PR (2026-10-18)
|
||||
|
||||
Codex and Greptile left eight inline findings on morluto/rea#4955 at
|
||||
46dd8f0e; each was checked against the code and addressed in one follow-up
|
||||
commit, with a regression test where the behaviour is observable:
|
||||
|
||||
- A late `initialize` could arm a fresh transport after shutdown (Codex, P1):
|
||||
the slot now records that it is closed, `close()` joins an arming session
|
||||
before ending it, and every request after that is refused with `503`. Test:
|
||||
an initialize racing `close()` gets `503`, the shutdown hook fires once, and
|
||||
the next request gets `503`.
|
||||
- A failed `close()` was memoized forever (both reviewers, P1): the close runs
|
||||
three stages once each and a failed stage is retried by the next call. Test:
|
||||
the instance's first `close()` rejects, the second call resolves and the port
|
||||
is released.
|
||||
- Loopback aliases skipped Host validation (both reviewers, P2 and P1): a bind
|
||||
is now loopback by address class (`127.0.0.0/8`, `::1`, IPv4-mapped loopback,
|
||||
`localhost` names) after the same normalization the SDK applies to the Host
|
||||
header, and the allowlist adds the bind's own hostname so requests to
|
||||
`127.0.0.2` or `localhost.` are still served. Test: twelve spellings.
|
||||
- A sessionless POST could close the live session before any header check
|
||||
(Greptile, P1): on loopback binds the route validates `Origin` with the same
|
||||
allowlist as `Host`, and only a POST carrying `Content-Type:
|
||||
application/json` and an `Accept` naming both `application/json` and
|
||||
`text/event-stream` may replace a session; others reach the live transport,
|
||||
which refuses them. Test: a `text/plain` initialize and one with a foreign
|
||||
`Origin` are refused (4xx and 403) while the connected client keeps echoing.
|
||||
- The bound URL disappeared at quiet log levels (Greptile, P2): the listening
|
||||
line is written through a child logger pinned to `info`. Test: a `warn`
|
||||
logger still receives the line with the URL.
|
||||
- `rea mcp -h --http` reached the ordinary CLI (Greptile, P2): the dispatcher
|
||||
predicate accepts any flag after the entry. Test: the dispatcher case.
|
||||
|
||||
Checks after the change: `oxfmt --check .` clean; `tsc --noEmit` clean;
|
||||
`oxlint` on the touched sources clean after extracting the staged close into
|
||||
its own function; `npx knip` no findings; `npm run jscpd` 3.35 % duplicated
|
||||
lines; `scan:todos` none; `verify:test-discovery` 1,583 files once; `vitest
|
||||
run` over the listener and bridge adapters tests 14 passed in 2 files; after
|
||||
`npm run build:cached`, `vitest run` over the dispatcher, option, streamable
|
||||
HTTP, shutdown and main-error boundary tests 22 passed in 5 files.
|
||||
|
||||
A second automated round on the fix (Codex, two P2 findings) was addressed in
|
||||
the next commit: the replacement guard compares the media type proper, so
|
||||
`application/json-patch+json` no longer counts as JSON (test: such an
|
||||
initialize is refused while the connected client keeps echoing), and the
|
||||
server instance is created only after the listener has bound, so a bind
|
||||
failure has nothing to clean up and a factory failure releases the listener
|
||||
and rethrows (test: a throwing factory rejects without announcing a URL).
|
||||
|
||||
A third round (Greptile and Codex, five findings) was addressed next: the
|
||||
listener's `error` forwarding to `onerror` is registered after the bind, so
|
||||
`EADDRINUSE` prints only the listen-failure message (test: an occupied port
|
||||
rejects without calling `onerror`); scoped IPv6 literals are refused before
|
||||
binding and a URL that cannot be formed from the bound address closes the
|
||||
listener (test: `fe80::1%lo0` rejects before the factory runs); loopback is
|
||||
classified from the bound address with the configured name added to the
|
||||
allowlist (test: `ip6-localhost` bound to `::1`, `dev.local` bound to
|
||||
`127.0.0.1`); and the MCP runtime passes `closeInstance: false` so the factory
|
||||
owner in `startMcpTransport` remains the single owner of instance cleanup
|
||||
(test: `close` leaves the instance open until its owner closes it).
|
||||
|
||||
A fourth round (Codex, two findings) was addressed next: the replacement
|
||||
guard parses `Accept` into media ranges and requires exact `application/json`
|
||||
and `text/event-stream` entries (test: `application/json-patch+json,
|
||||
text/event-streaming` is refused while the connected client keeps echoing),
|
||||
and a session whose transport shutdown rejected stays owned so the next close
|
||||
retries it (test: a failing transport close rejects `close()` once, the second
|
||||
call completes, the shutdown hook fires once and later requests get `503`).
|
||||
|
||||
A fifth round (Codex, three findings) was addressed next: the instance's
|
||||
shutdown hook now runs exactly once even when the listener closes without a
|
||||
live session (test: two closes of an idle slot fire it once; an idle listener
|
||||
close fires it once); the initialize probe no longer awaits cancelling its
|
||||
cloned body, which is one branch of a tee and would wait for the original
|
||||
request to drain (test: a 4.5 MB sessionless initialize is refused promptly
|
||||
while the connected client keeps echoing); and the node:http bridge passes
|
||||
repeated `Set-Cookie` values as an array instead of collapsing them (test: two
|
||||
cookies from a composed route both arrive).
|
||||
|
||||
A sixth round (Codex, one P1) was addressed next: signal handlers were only
|
||||
installed after the asynchronous startup returned, so a SIGINT or SIGTERM
|
||||
during target opening, provider loading or the HTTP bind ended the process
|
||||
without cleanup. The HTTP entry now latches signals before startup, the
|
||||
lifecycle owner picks the recorded signal up as soon as it registers, and a
|
||||
second signal during startup exits at once (test: the latch records `143`,
|
||||
exits on the second signal, hands the status over on release and leaves no
|
||||
listeners behind).
|
||||
|
||||
Both reviewers then noted that the latched signal's status was overwritten by
|
||||
the entry's normal `0` return: the HTTP entry now owns its exit status through
|
||||
`createExitStatus`, where a `0` after a recorded signal keeps the signal's
|
||||
status and a real failure still wins (unit test), and a process test sends the
|
||||
signal the moment the listening line appears and asserts the signal status.
|
||||
|
||||
An eighth finding (Codex): a reply sent before the MCP handler read the body
|
||||
left that body unread on a paused bridged stream, so a slow uploader could
|
||||
hold the socket. The bridge now closes the connection after any reply whose
|
||||
request body was never read (test: an unknown-route POST and a foreign-Host
|
||||
POST are answered with `Connection: close`, while a keep-alive GET without a
|
||||
body keeps its connection).
|
||||
|
||||
A ninth round (Greptile and Codex): request conversion ran outside the guard,
|
||||
so a method Fetch refuses (`TRACE`) could crash the process; the conversion is
|
||||
now guarded and answered with `405` and a closed connection (test: `TRACE`
|
||||
gets `405` and the next client still echoes). The unread-body decision now
|
||||
comes from the incoming message's own body declaration, so a `GET` or `HEAD`
|
||||
that carries a body also closes, and the regression uses a keep-alive agent
|
||||
and checks that the socket actually closes after the early reply while a
|
||||
bodiless `GET` keeps its socket.
|
||||
|
||||
A tenth round (Codex, three findings): the takeover guard now requires a lone
|
||||
`initialize`, mirroring the transport (test: a batch of `initialize` plus
|
||||
`notifications/initialized` is refused while the connected client keeps
|
||||
echoing); the shutdown hook records completion only after it returns, so a
|
||||
hook that threw stays eligible for the next close (test: a hook that throws
|
||||
once runs twice across two closes and then no more); and the bridge joins the
|
||||
request target onto the listener's origin verbatim, refusing absolute-form
|
||||
targets (test: `//attacker.example/card` is a 404 for that path, a plain route
|
||||
sees the bound host only, and `http://attacker.example/card` gets `400`).
|
||||
|
||||
An eleventh finding (Codex): URL construction canonicalized dot segments, so
|
||||
`/public/../mcp` reached `/mcp`. The bridge now refuses any target whose path
|
||||
differs after URL parsing, so routing always sees the path the client sent
|
||||
(test: `/public/../mcp`, `/public/%2e%2e/mcp` and `/./mcp` get `400`; `/card?x=1`
|
||||
still routes).
|
||||
|
||||
A twelfth finding (Codex): `bodyUsed` means disturbed, not consumed, so a
|
||||
composed route that read one chunk of an upload left the rest paused on a
|
||||
keep-alive socket. The close decision now uses the incoming message's end of
|
||||
stream (test: a route that reads one chunk of a 256 KiB upload is answered
|
||||
with `Connection: close` and a closed socket; a route that reads it all keeps
|
||||
the connection).
|
||||
|
||||
A thirteenth round (Codex): an `Accept` range with `q=0` no longer counts toward
|
||||
the takeover guard (test: `application/json;q=0, text/event-stream` is refused
|
||||
while the connected client keeps echoing). The same round noted that a failed
|
||||
startup leaves cleanup of the already opened session and providers to a single
|
||||
attempt; that path is the pre-existing stdio startup-failure cleanup, which the
|
||||
HTTP mode now reaches on `EADDRINUSE`, and is left for the maintainers to decide.
|
||||
+23
-12
@@ -5,15 +5,22 @@ import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Route production MCP before importing Incur. Incur owns registration helpers
|
||||
// such as `mcp add`, while the MCP runtime serves the stdio tool catalog.
|
||||
// such as `mcp add`, while the MCP runtime serves the tool catalog over stdio
|
||||
// or, with `--http`, over streamable HTTP.
|
||||
const args = process.argv.slice(2);
|
||||
const { default: packageJson } = await import("../package.json", {
|
||||
with: { type: "json" },
|
||||
});
|
||||
process.env.REA_PACKAGE_VERSION = packageJson.version;
|
||||
const isMcpMode =
|
||||
args.length === 1 && (args[0] === "--mcp" || args[0] === "mcp");
|
||||
const isMcpEntry = args[0] === "--mcp" || args[0] === "mcp";
|
||||
const isMcpMode = isMcpEntry && args.length === 1;
|
||||
const isMcpDoctorMode = args[0] === "mcp" && args[1] === "doctor";
|
||||
// Only flags may follow the entry: `mcp add --http` stays Incur's `mcp add`,
|
||||
// while `mcp -h --http` reaches the HTTP entry's own help.
|
||||
const isMcpHttpMode =
|
||||
isMcpEntry &&
|
||||
(args[1]?.startsWith("-") ?? false) &&
|
||||
args.slice(1).includes("--http");
|
||||
/** Exit status of a writer terminated by SIGPIPE (128 + 13). */
|
||||
const SIGPIPE_EXIT_STATUS = 141;
|
||||
// Set once the reader of CLI output has closed the pipe.
|
||||
@@ -40,15 +47,16 @@ if (
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = join(cacheRoot, "ms-playwright");
|
||||
}
|
||||
|
||||
const runtimeFiles = isMcpMode
|
||||
? ["dist/mcp/main.js"]
|
||||
: isMcpDoctorMode
|
||||
? ["dist/mcp/main.js", "dist/cli-runtime/mcpDoctor.js"]
|
||||
: [
|
||||
"dist/cli-runtime/cli.js",
|
||||
"dist/cli-runtime/cliOutput.js",
|
||||
"dist/cli-runtime/cliResultOutput.js",
|
||||
];
|
||||
const runtimeFiles =
|
||||
isMcpMode || isMcpHttpMode
|
||||
? ["dist/mcp/main.js"]
|
||||
: isMcpDoctorMode
|
||||
? ["dist/mcp/main.js", "dist/cli-runtime/mcpDoctor.js"]
|
||||
: [
|
||||
"dist/cli-runtime/cli.js",
|
||||
"dist/cli-runtime/cliOutput.js",
|
||||
"dist/cli-runtime/cliResultOutput.js",
|
||||
];
|
||||
|
||||
if (!(await compiledRuntimeExists(runtimeFiles))) {
|
||||
process.stderr.write(
|
||||
@@ -58,6 +66,9 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
|
||||
} else if (isMcpMode) {
|
||||
const { runEntrypoint } = await import("../dist/mcp/main.js");
|
||||
await runEntrypoint();
|
||||
} else if (isMcpHttpMode) {
|
||||
const { runMcpHttpEntrypoint } = await import("../dist/mcp/main.js");
|
||||
await runMcpHttpEntrypoint(args.slice(1));
|
||||
} else if (isMcpDoctorMode) {
|
||||
guardClosedStdout();
|
||||
const { runProductionMcpDoctorCli } =
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Preserve an unexpected JSON result while describing the failed plan check. */
|
||||
export function setupPlanFailureMessage(options: {
|
||||
readonly planned: unknown;
|
||||
readonly plannedExecution: { readonly status: number | null };
|
||||
readonly plannedClaudeConfig: string;
|
||||
readonly plannedCodexConfig: string;
|
||||
readonly plannedCursorConfig: string;
|
||||
}): string;
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Describe a failed package setup-plan check without masking its result shape. */
|
||||
export const setupPlanFailureMessage = ({
|
||||
planned,
|
||||
plannedExecution,
|
||||
plannedClaudeConfig,
|
||||
plannedCodexConfig,
|
||||
plannedCursorConfig,
|
||||
}) => {
|
||||
const actionKinds = (actions) =>
|
||||
Array.isArray(actions)
|
||||
? actions.map((action) => action?.kind ?? action)
|
||||
: actions;
|
||||
return `packaged setup plan was not read-only: ${JSON.stringify({
|
||||
status: planned?.status,
|
||||
exitCode: plannedExecution.status,
|
||||
plannedKinds: actionKinds(planned?.plannedActions),
|
||||
appliedKinds: actionKinds(planned?.appliedActions),
|
||||
result: planned,
|
||||
claudeConfig: plannedClaudeConfig,
|
||||
codexConfig: plannedCodexConfig,
|
||||
cursorConfig: plannedCursorConfig,
|
||||
})}`;
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "./lib/verify-package-core.mjs";
|
||||
import { parse as parseJsonc } from "jsonc-parser";
|
||||
import { skillReferenceIssues } from "./lib/docs-facts.mjs";
|
||||
import { setupPlanFailureMessage } from "./verify-package-setup-plan.mjs";
|
||||
|
||||
export const terminalModesRestored = (termios) => {
|
||||
if (typeof termios !== "string") return false;
|
||||
@@ -250,23 +251,37 @@ const verifySetupPlan = async ({
|
||||
);
|
||||
const planned = json(plannedExecution.stdout);
|
||||
if (supportedSetupHost) {
|
||||
const plannedActions = Array.isArray(planned?.plannedActions)
|
||||
? planned.plannedActions
|
||||
: undefined;
|
||||
const appliedActions = Array.isArray(planned?.appliedActions)
|
||||
? planned.appliedActions
|
||||
: undefined;
|
||||
const plannedClaudeConfig = await readFile(claudeConfig, "utf8");
|
||||
const plannedCodexConfig = await readFile(codexTarget, "utf8");
|
||||
const plannedCursorConfig = await readFile(cursorConfig, "utf8");
|
||||
const plannedOpenCodeConfig = await readFile(opencodeConfig, "utf8");
|
||||
if (
|
||||
planned.status !== "planned" ||
|
||||
planned?.status !== "planned" ||
|
||||
plannedExecution.status !== 0 ||
|
||||
planned.appliedActions.length !== 0 ||
|
||||
!planned.plannedActions.some(({ kind }) => kind === "configure_client") ||
|
||||
!planned.plannedActions.some(({ kind }) => kind === "install_skill") ||
|
||||
plannedActions === undefined ||
|
||||
appliedActions === undefined ||
|
||||
appliedActions.length !== 0 ||
|
||||
!plannedActions.some((action) => action?.kind === "configure_client") ||
|
||||
!plannedActions.some((action) => action?.kind === "install_skill") ||
|
||||
plannedClaudeConfig !== '{"existing":true}\n' ||
|
||||
plannedCodexConfig !== CODEX_ORIGINAL ||
|
||||
plannedCursorConfig !== '{"existing":true}\n' ||
|
||||
plannedOpenCodeConfig !== opencodeOriginal
|
||||
)
|
||||
throw new Error(
|
||||
`packaged setup plan was not read-only: ${JSON.stringify({ status: planned.status, exitCode: plannedExecution.status, plannedKinds: planned.plannedActions.map(({ kind }) => kind), appliedKinds: planned.appliedActions.map(({ kind }) => kind), claudeConfig: plannedClaudeConfig, codexConfig: plannedCodexConfig, cursorConfig: plannedCursorConfig })}`,
|
||||
setupPlanFailureMessage({
|
||||
planned,
|
||||
plannedExecution,
|
||||
plannedClaudeConfig,
|
||||
plannedCodexConfig,
|
||||
plannedCursorConfig,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return { planned };
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface OwnershipTraceExecution {
|
||||
readonly pid: number;
|
||||
readonly path: string;
|
||||
readonly line: string;
|
||||
readonly succeeded: boolean;
|
||||
readonly ownershipInventory: boolean;
|
||||
}
|
||||
/** Parsed topology and execution facts for one complete strace observation. */
|
||||
export function collectOwnershipTraceFacts(options: {
|
||||
readonly traces: readonly { readonly pid: number; readonly text: string }[];
|
||||
readonly nodeExecutable: string;
|
||||
readonly psExecutables: ReadonlySet<string>;
|
||||
}): {
|
||||
readonly ownershipThreads: ReadonlySet<number>;
|
||||
readonly psProcesses: ReadonlySet<number>;
|
||||
readonly executions: readonly OwnershipTraceExecution[];
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const ownershipPsArguments =
|
||||
'["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]';
|
||||
|
||||
/** Parse execution/thread facts once; only an observed Node ps caller owns inventory reads. */
|
||||
export const collectOwnershipTraceFacts = ({
|
||||
traces,
|
||||
nodeExecutable,
|
||||
psExecutables,
|
||||
}) => {
|
||||
const processGroups = new Map();
|
||||
const findGroup = (pid) => {
|
||||
const parent = processGroups.get(pid);
|
||||
if (parent === undefined) {
|
||||
processGroups.set(pid, pid);
|
||||
return pid;
|
||||
}
|
||||
if (parent === pid) return pid;
|
||||
const root = findGroup(parent);
|
||||
processGroups.set(pid, root);
|
||||
return root;
|
||||
};
|
||||
const joinThreadGroup = (left, right) => {
|
||||
const leftRoot = findGroup(left);
|
||||
const rightRoot = findGroup(right);
|
||||
if (leftRoot !== rightRoot) processGroups.set(rightRoot, leftRoot);
|
||||
};
|
||||
|
||||
const cloneEvents = [];
|
||||
for (const trace of traces) {
|
||||
let unfinished;
|
||||
for (const rawLine of trace.text.split("\n")) {
|
||||
const line = rawLine.trimStart();
|
||||
const start = /^(clone3?|fork|vfork)\((.*)<unfinished \.\.\.>$/u.exec(
|
||||
line,
|
||||
);
|
||||
if (start !== null) {
|
||||
unfinished = { name: start[1], text: start[2] };
|
||||
continue;
|
||||
}
|
||||
const resumed = /^<\.\.\. (clone3?|fork|vfork) resumed>(.*)$/u.exec(line);
|
||||
if (resumed !== null && resumed[1] === unfinished?.name) {
|
||||
cloneEvents.push({
|
||||
parentPid: trace.pid,
|
||||
text: `${unfinished.text}${resumed[2]}`,
|
||||
});
|
||||
unfinished = undefined;
|
||||
continue;
|
||||
}
|
||||
const complete =
|
||||
/^(clone3?|fork|vfork)\((.*\)) = (\d+)(?:<[^>]*>)?$/u.exec(line);
|
||||
if (complete !== null)
|
||||
cloneEvents.push({
|
||||
parentPid: trace.pid,
|
||||
childPid: Number(complete[3]),
|
||||
text: complete[2],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const event of cloneEvents) {
|
||||
const childPid =
|
||||
event.childPid ?? /\) = (\d+)(?:<[^>]*>)?$/u.exec(event.text)?.[1];
|
||||
if (childPid === undefined) continue;
|
||||
const observedChild = Number(childPid);
|
||||
// A child-side clone return of zero is not a parent-to-child edge.
|
||||
if (!Number.isSafeInteger(observedChild) || observedChild <= 0) continue;
|
||||
event.childPid = observedChild;
|
||||
if (/\bCLONE_THREAD\b/u.test(event.text))
|
||||
joinThreadGroup(event.parentPid, event.childPid);
|
||||
}
|
||||
|
||||
const executions = [];
|
||||
for (const trace of traces) {
|
||||
for (const line of trace.text.split("\n")) {
|
||||
if (!/execve(?:at)?\(/u.test(line)) continue;
|
||||
const match = /^execve\("([^"]+)"/u.exec(line);
|
||||
assert.notEqual(match, null, `Unresolved executable identity: ${line}`);
|
||||
assert.ok(
|
||||
/ = (?:0|-1 [A-Z]+)/u.test(line),
|
||||
`Incomplete execution observation: ${line}`,
|
||||
);
|
||||
executions.push({
|
||||
pid: trace.pid,
|
||||
path: match[1],
|
||||
line,
|
||||
succeeded: line.endsWith(" = 0"),
|
||||
ownershipInventory:
|
||||
psExecutables.has(match[1]) && line.includes(ownershipPsArguments),
|
||||
});
|
||||
}
|
||||
}
|
||||
const psProcesses = new Set(
|
||||
executions
|
||||
.filter(
|
||||
({ succeeded, ownershipInventory }) => succeeded && ownershipInventory,
|
||||
)
|
||||
.map(({ pid }) => pid),
|
||||
);
|
||||
const psParentGroups = new Set(
|
||||
cloneEvents
|
||||
.filter(({ childPid }) => psProcesses.has(childPid))
|
||||
.map(({ parentPid }) => findGroup(parentPid)),
|
||||
);
|
||||
const nodeGroups = new Set(
|
||||
executions
|
||||
.filter(({ path, succeeded }) => succeeded && path === nodeExecutable)
|
||||
.map(({ pid }) => findGroup(pid)),
|
||||
);
|
||||
const replaced = new Set(
|
||||
executions
|
||||
.filter(({ path, succeeded }) => succeeded && path !== nodeExecutable)
|
||||
.map(({ pid }) => pid),
|
||||
);
|
||||
const ownershipThreads = new Set(
|
||||
traces
|
||||
.filter(
|
||||
({ pid }) =>
|
||||
nodeGroups.has(findGroup(pid)) &&
|
||||
psParentGroups.has(findGroup(pid)) &&
|
||||
!replaced.has(pid),
|
||||
)
|
||||
.map(({ pid }) => pid),
|
||||
);
|
||||
return { ownershipThreads, psProcesses, executions };
|
||||
};
|
||||
@@ -7,14 +7,10 @@ export const historicalPidTraceContract: {
|
||||
readonly pid_syscalls: readonly string[];
|
||||
readonly unverified: readonly string[];
|
||||
};
|
||||
export function nodeInventoryThreads(
|
||||
traces: readonly SyscallTrace[],
|
||||
executable: string,
|
||||
): ReadonlySet<number>;
|
||||
export function assertHistoricalPidAccess(input: {
|
||||
readonly trace: SyscallTrace;
|
||||
readonly historicalPid: number;
|
||||
readonly nodeThreads: ReadonlySet<number>;
|
||||
readonly ownershipThreads: ReadonlySet<number>;
|
||||
readonly liveCollision: boolean;
|
||||
readonly psInventory: boolean;
|
||||
}): readonly string[];
|
||||
@@ -1,52 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { posix } from "node:path";
|
||||
|
||||
/** Identify REA's Node processes and their observed CLONE_THREAD children. */
|
||||
export function nodeInventoryThreads(traces, executable) {
|
||||
const executesForeign = (text) =>
|
||||
text
|
||||
.split("\n")
|
||||
.some(
|
||||
(line) =>
|
||||
/^execve\(/u.test(line) &&
|
||||
line.endsWith(" = 0") &&
|
||||
!line.startsWith(`execve(${JSON.stringify(executable)},`),
|
||||
);
|
||||
const byPid = new Map(traces.map((trace) => [trace.pid, trace]));
|
||||
const nodes = new Set(
|
||||
traces
|
||||
.filter(
|
||||
({ text }) =>
|
||||
!executesForeign(text) &&
|
||||
text
|
||||
.split("\n")
|
||||
.some(
|
||||
(line) =>
|
||||
line.startsWith(`execve(${JSON.stringify(executable)},`) &&
|
||||
line.endsWith(" = 0"),
|
||||
),
|
||||
)
|
||||
.map(({ pid }) => pid),
|
||||
);
|
||||
for (const pid of nodes) {
|
||||
let pendingThreadClone = false;
|
||||
for (const line of byPid.get(pid)?.text.split("\n") ?? []) {
|
||||
if (!/^(?:clone3?\(|<\.\.\. clone3? resumed>)/u.test(line)) continue;
|
||||
if (/^clone3?\(/u.test(line))
|
||||
pendingThreadClone = /\bCLONE_THREAD\b/u.test(line);
|
||||
const child = / = (\d+)$/u.exec(line);
|
||||
if (pendingThreadClone && child !== null) {
|
||||
const childPid = Number(child[1]);
|
||||
const trace = byPid.get(childPid);
|
||||
if (trace !== undefined && !executesForeign(trace.text))
|
||||
nodes.add(childPid);
|
||||
}
|
||||
if (!line.endsWith("<unfinished ...>")) pendingThreadClone = false;
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// This owner supplies both tracer selection and target interpretation. Kinds
|
||||
// describe syscall argument semantics, never catalog positions or output PIDs.
|
||||
const pidTargetKinds = new Map([
|
||||
@@ -176,7 +130,7 @@ const readsMetadata = (line, root, names) => {
|
||||
export function assertHistoricalPidAccess({
|
||||
trace,
|
||||
historicalPid,
|
||||
nodeThreads,
|
||||
ownershipThreads,
|
||||
liveCollision,
|
||||
psInventory,
|
||||
}) {
|
||||
@@ -216,8 +170,9 @@ export function assertHistoricalPidAccess({
|
||||
!referencesHistoricalPath(line)
|
||||
)
|
||||
continue;
|
||||
const nodeStat =
|
||||
nodeThreads.has(trace.pid) && readsMetadata(line, root, ["stat"]);
|
||||
const ownershipMetadata =
|
||||
ownershipThreads.has(trace.pid) &&
|
||||
readsMetadata(line, root, ["stat", "status"]);
|
||||
const psMetadata =
|
||||
psInventory &&
|
||||
(readsMetadata(line, root, [
|
||||
@@ -236,7 +191,7 @@ export function assertHistoricalPidAccess({
|
||||
liveCollision &&
|
||||
target !== "historical" &&
|
||||
target !== "unresolved" &&
|
||||
(nodeStat || psMetadata),
|
||||
(ownershipMetadata || psMetadata),
|
||||
`${target === "unresolved" ? "Unresolved process target in" : "Historical PID caused a"} non-inventory lookup in trace ${trace.pid}: ${line}`,
|
||||
);
|
||||
accesses.push(line);
|
||||
|
||||
@@ -7,8 +7,8 @@ import { parseEvidence } from "../../../dist/domain/evidence.js";
|
||||
import {
|
||||
assertHistoricalPidAccess,
|
||||
historicalPidTraceContract,
|
||||
nodeInventoryThreads,
|
||||
} from "./trace-policy.mjs";
|
||||
import { collectOwnershipTraceFacts } from "./trace-ownership.mjs";
|
||||
|
||||
/** Check attempted syscalls, including failed attempts, on a recorded PID that collides with an owned live sentinel. */
|
||||
export async function verifyCoreInspectionTrace({
|
||||
@@ -100,11 +100,19 @@ export async function verifyCoreInspectionTrace({
|
||||
// Retain only execution/thread evidence across files, not all raw traces.
|
||||
const inventory = text
|
||||
.split("\n")
|
||||
.filter((line) => /^(?:execve|clone|<\.\.\. clone)/u.test(line))
|
||||
.filter((line) =>
|
||||
/^(?:execve|clone|fork|vfork|<\.\.\. (?:clone|fork|vfork))/u.test(
|
||||
line,
|
||||
),
|
||||
)
|
||||
.join("\n");
|
||||
traces.push({ pid, text: inventory, file });
|
||||
}
|
||||
const nodeThreads = nodeInventoryThreads(traces, process.execPath);
|
||||
const ownershipFacts = collectOwnershipTraceFacts({
|
||||
traces,
|
||||
nodeExecutable: process.execPath,
|
||||
psExecutables: psPaths,
|
||||
});
|
||||
for (const trace of traces) {
|
||||
const { file } = trace;
|
||||
const text = await readFile(join(root, file), "utf8");
|
||||
@@ -115,23 +123,13 @@ export async function verifyCoreInspectionTrace({
|
||||
!text.split("\n").some((line) => line.includes(recordedExecutable)),
|
||||
`Inspection attempted access to the recorded executable: ${file}`,
|
||||
);
|
||||
const ownershipTrace = text
|
||||
.split("\n")
|
||||
.some(
|
||||
(line) =>
|
||||
/^execve\("\/(?:usr\/)?bin\/ps",/u.test(line) &&
|
||||
line.includes(
|
||||
'["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]',
|
||||
) &&
|
||||
line.endsWith(" = 0"),
|
||||
);
|
||||
ownershipReads.push(
|
||||
...assertHistoricalPidAccess({
|
||||
trace: { pid: trace.pid, text },
|
||||
historicalPid,
|
||||
nodeThreads,
|
||||
ownershipThreads: ownershipFacts.ownershipThreads,
|
||||
liveCollision: historicalPid === sentinel.pid,
|
||||
psInventory: ownershipTrace,
|
||||
psInventory: ownershipFacts.psProcesses.has(trace.pid),
|
||||
}),
|
||||
);
|
||||
assert.ok(
|
||||
@@ -148,48 +146,40 @@ export async function verifyCoreInspectionTrace({
|
||||
),
|
||||
`Inspection attempted an Internet socket: ${file}`,
|
||||
);
|
||||
for (const line of text.split("\n")) {
|
||||
if (!/execve(?:at)?\(/u.test(line)) continue;
|
||||
const match = /^execve\("([^"]+)"/u.exec(line);
|
||||
assert.notEqual(match, null, `Unresolved executable identity: ${line}`);
|
||||
assert.ok(
|
||||
/ = (?:0|-1 [A-Z]+)/u.test(line),
|
||||
`Incomplete execution observation: ${line}`,
|
||||
}
|
||||
for (const execution of ownershipFacts.executions) {
|
||||
const { line } = execution;
|
||||
const executable = await realpath(execution.path).catch(
|
||||
() => execution.path,
|
||||
);
|
||||
const ownership = execution.ownershipInventory;
|
||||
const iconv =
|
||||
iconvPaths.has(execution.path) && line.includes('["iconv", "-l"]');
|
||||
const version =
|
||||
gitPaths.has(execution.path) &&
|
||||
line.includes(
|
||||
"[" +
|
||||
[
|
||||
"git",
|
||||
"--git-dir",
|
||||
join(plugin, ".git"),
|
||||
"rev-parse",
|
||||
"--short",
|
||||
"HEAD",
|
||||
]
|
||||
.map((argument) => JSON.stringify(argument))
|
||||
.join(", ") +
|
||||
"]",
|
||||
);
|
||||
const executable = await realpath(match[1]).catch(() => match[1]);
|
||||
const ownership =
|
||||
psPaths.has(match[1]) &&
|
||||
line.includes(
|
||||
'["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]',
|
||||
);
|
||||
const iconv =
|
||||
iconvPaths.has(match[1]) && line.includes('["iconv", "-l"]');
|
||||
const version =
|
||||
gitPaths.has(match[1]) &&
|
||||
line.includes(
|
||||
"[" +
|
||||
[
|
||||
"git",
|
||||
"--git-dir",
|
||||
join(plugin, ".git"),
|
||||
"rev-parse",
|
||||
"--short",
|
||||
"HEAD",
|
||||
]
|
||||
.map((argument) => JSON.stringify(argument))
|
||||
.join(", ") +
|
||||
"]",
|
||||
);
|
||||
assert.ok(
|
||||
[process.execPath, python, gdb].includes(executable) ||
|
||||
ownership ||
|
||||
iconv ||
|
||||
version,
|
||||
`Unexpected attempted host execution: ${line}`,
|
||||
);
|
||||
if (executable === gdb) gdbObserved = true;
|
||||
executions.push(executable);
|
||||
}
|
||||
assert.ok(
|
||||
[process.execPath, python, gdb].includes(executable) ||
|
||||
ownership ||
|
||||
iconv ||
|
||||
version,
|
||||
`Unexpected attempted host execution: ${line}`,
|
||||
);
|
||||
if (executable === gdb) gdbObserved = true;
|
||||
executions.push(executable);
|
||||
}
|
||||
assert.ok(gdbObserved, "Trace must include the actual core-only GDB stage");
|
||||
assert.ok(executions.includes(python));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
||||
import { LosslessNumber } from "lossless-json";
|
||||
import { parseCaptureJson } from "./CaptureJson.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { CANONICAL_BASE64_PATTERN } from "../../domain/stringPatterns.js";
|
||||
import { isCanonicalBase64 } from "../../domain/stringPatterns.js";
|
||||
import {
|
||||
WEB_NETWORK_CAPTURE_LIMITS,
|
||||
type WebNetworkCapture,
|
||||
@@ -265,7 +265,7 @@ const integerLexeme = (literal: string): boolean => {
|
||||
|
||||
const decodeHarBase64 = (value: string, pointer: string): Buffer => {
|
||||
const compact = value.replace(/[\t\n\r ]/g, "");
|
||||
if (compact.length % 4 !== 0 || !CANONICAL_BASE64_PATTERN.test(compact))
|
||||
if (!isCanonicalBase64(compact))
|
||||
throw new CaptureFormatError(
|
||||
"format",
|
||||
"HAR content declares base64 but its text is not valid canonical base64 (ASCII whitespace is allowed).",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
ConfigurationError,
|
||||
type ConfigurationSettingIssue,
|
||||
} from "../domain/configurationErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
/** The loopback default keeps the listener local-only until an operator opts out. */
|
||||
export const DEFAULT_MCP_HTTP_HOST = "127.0.0.1";
|
||||
/** Port 0 asks the host for an ephemeral port, reported by the listening log line. */
|
||||
export const DEFAULT_MCP_HTTP_PORT = 0;
|
||||
|
||||
/** Decimal TCP port text, shared by the `--port` flag and `MCP_PORT`. */
|
||||
export const mcpHttpPortSchema = z
|
||||
.string()
|
||||
.regex(/^(?:0|[1-9]\d*)$/u)
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().min(0).max(65535));
|
||||
|
||||
const mcpHttpHostSchema = z.string().trim().min(1);
|
||||
|
||||
export const MCP_HTTP_PORT_CONSTRAINT =
|
||||
"MCP_PORT must be a decimal integer between 0 and 65535.";
|
||||
export const MCP_HTTP_HOST_CONSTRAINT =
|
||||
"MCP_HOST must be a non-empty host name or IP address.";
|
||||
|
||||
/** Where the streamable HTTP listener binds. */
|
||||
export interface McpHttpEndpoint {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
}
|
||||
|
||||
/** Resolve the endpoint: explicit values, then MCP_HOST/MCP_PORT, then loopback defaults. */
|
||||
export const parseMcpHttpEndpoint = (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
overrides: Partial<McpHttpEndpoint> = {},
|
||||
): Result<McpHttpEndpoint, ConfigurationError> => {
|
||||
const issues: ConfigurationSettingIssue[] = [];
|
||||
let host = overrides.host ?? DEFAULT_MCP_HTTP_HOST;
|
||||
let port = overrides.port ?? DEFAULT_MCP_HTTP_PORT;
|
||||
if (overrides.host === undefined && environment.MCP_HOST !== undefined) {
|
||||
const parsed = mcpHttpHostSchema.safeParse(environment.MCP_HOST);
|
||||
if (parsed.success) host = parsed.data;
|
||||
else
|
||||
issues.push({
|
||||
setting: "MCP_HOST",
|
||||
constraint: MCP_HTTP_HOST_CONSTRAINT,
|
||||
});
|
||||
}
|
||||
if (overrides.port === undefined && environment.MCP_PORT !== undefined) {
|
||||
const parsed = mcpHttpPortSchema.safeParse(environment.MCP_PORT);
|
||||
if (parsed.success) port = parsed.data;
|
||||
else
|
||||
issues.push({
|
||||
setting: "MCP_PORT",
|
||||
constraint: MCP_HTTP_PORT_CONSTRAINT,
|
||||
});
|
||||
}
|
||||
if (issues.length > 0)
|
||||
return err(
|
||||
new ConfigurationError(
|
||||
issues.map(({ constraint }) => constraint).join(" "),
|
||||
{ settings: issues },
|
||||
),
|
||||
);
|
||||
return ok({ host, port });
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { canonicalBase64Schema } from "./base64Schema.js";
|
||||
import { CANONICAL_BASE64_PATTERN } from "./stringPatterns.js";
|
||||
|
||||
it("keeps runtime and projected canonical base64 contracts equivalent", () => {
|
||||
const samples = [
|
||||
"",
|
||||
"AA==",
|
||||
"AAA=",
|
||||
"////",
|
||||
"AB==",
|
||||
"AAB=",
|
||||
"A",
|
||||
"AA",
|
||||
"AAA",
|
||||
"====",
|
||||
"AA==\n",
|
||||
"AA==\r\n",
|
||||
"AA==\u2028",
|
||||
"AA==\u2029",
|
||||
"AA\nAA",
|
||||
"AA=\n=",
|
||||
"AA-_",
|
||||
"a b c",
|
||||
42,
|
||||
null,
|
||||
];
|
||||
for (const io of ["input", "output"] as const) {
|
||||
const projected = z.toJSONSchema(canonicalBase64Schema, { io });
|
||||
const validate = new Ajv2020({ strict: true }).compile(projected);
|
||||
expect(JSON.stringify(projected)).not.toMatch(/\(\?<?[=!]/u);
|
||||
for (const sample of samples) {
|
||||
const expected =
|
||||
typeof sample === "string" && CANONICAL_BASE64_PATTERN.test(sample);
|
||||
expect(
|
||||
canonicalBase64Schema.safeParse(sample).success,
|
||||
String(sample),
|
||||
).toBe(expected);
|
||||
expect(validate(sample), JSON.stringify({ io, sample })).toBe(expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("validates large projected bodies without Unicode-set regexp stack exhaustion", () => {
|
||||
const encoded = Buffer.alloc(24 * 1024 * 1024 - 3, 0x81).toString("base64");
|
||||
const projected = z.toJSONSchema(canonicalBase64Schema);
|
||||
const validate = new Ajv2020({ strict: true }).compile(projected);
|
||||
const samples = [
|
||||
{ value: encoded, expected: true },
|
||||
{ value: `${encoded.slice(0, -1)}!`, expected: false },
|
||||
{ value: `${encoded.slice(0, -3)}R==`, expected: false },
|
||||
{ value: `${encoded}A`, expected: false },
|
||||
];
|
||||
for (const { value, expected } of samples) {
|
||||
expect(canonicalBase64Schema.safeParse(value).success).toBe(expected);
|
||||
expect(validate(value)).toBe(expected);
|
||||
for (const flag of ["", "u", "v"]) {
|
||||
const positive = new RegExp(projected.pattern!, flag);
|
||||
const negative = new RegExp(
|
||||
(projected.not as { pattern: string }).pattern,
|
||||
flag,
|
||||
);
|
||||
expect(positive.test(value) && !negative.test(value), flag).toBe(
|
||||
expected,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
BASE64_QUARTETS_PATTERN,
|
||||
CANONICAL_BASE64_INVALID_PATTERN,
|
||||
} from "./stringPatterns.js";
|
||||
|
||||
/** Exact canonical base64 using stack-bounded, portable JSON Schema patterns. */
|
||||
export const canonicalBase64Schema = z
|
||||
.string()
|
||||
.regex(BASE64_QUARTETS_PATTERN)
|
||||
.refine((value) => !CANONICAL_BASE64_INVALID_PATTERN.test(value), {
|
||||
message: "Expected canonical padded base64",
|
||||
})
|
||||
// JSON Schema negation expresses the same rejection without look-around.
|
||||
.meta({ not: { pattern: CANONICAL_BASE64_INVALID_PATTERN.source } });
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { canonicalBase64Schema } from "../base64Schema.js";
|
||||
import { canonicalBase64Schema } from "../stringPatterns.js";
|
||||
|
||||
const sourceRangeSchema = z.strictObject({
|
||||
offset: z.number().int().nonnegative(),
|
||||
|
||||
@@ -2,15 +2,16 @@ import { z } from "zod";
|
||||
import { canBorrowParsedJsonSnapshot } from "../immutableJson.js";
|
||||
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { canonicalBase64Schema } from "../base64Schema.js";
|
||||
import { canonicalBase64Schema } from "../stringPatterns.js";
|
||||
|
||||
const unsignedHex = z.string().regex(/^0x(?:0|[1-9a-f][0-9a-f]{0,15})$/);
|
||||
const index = z.number().int().nonnegative();
|
||||
const scalar = z.union([z.string(), z.number().int()]);
|
||||
const range = z.strictObject({ offset: unsignedHex, bytes: unsignedHex });
|
||||
const canonicalBase64 = canonicalBase64Schema;
|
||||
const name = z.strictObject({
|
||||
display: z.string(),
|
||||
bytes_base64: canonicalBase64Schema.nullable(),
|
||||
bytes_base64: canonicalBase64.nullable(),
|
||||
location: range.nullable(),
|
||||
unknown_reason: z.string().nullable(),
|
||||
});
|
||||
@@ -150,7 +151,7 @@ const binaryLayoutObjectSchema = z.strictObject({
|
||||
z.strictObject({
|
||||
section_index: index,
|
||||
location: range,
|
||||
encoded_bytes_base64: canonicalBase64Schema,
|
||||
encoded_bytes_base64: canonicalBase64,
|
||||
entries: z.array(
|
||||
z.strictObject({ decoded_index: index, reported_offset: unsignedHex }),
|
||||
),
|
||||
|
||||
@@ -2,12 +2,13 @@ import { z } from "zod";
|
||||
|
||||
import type { BinaryArchitecture } from "../binaryTargetTypes.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { canonicalBase64Schema } from "../base64Schema.js";
|
||||
import { canonicalBase64Schema } from "../stringPatterns.js";
|
||||
import { recordedNoteCoverageIssues } from "./recordedCrashNoteCoverage.js";
|
||||
|
||||
const hex = z.string().regex(/^0x(?:0|[1-9a-f][0-9a-f]{0,15})$/);
|
||||
const index = z.number().int().nonnegative();
|
||||
const signed32 = z.number().int().min(-2147483648).max(2147483647);
|
||||
const base64 = canonicalBase64Schema;
|
||||
const range = z.strictObject({ offset: hex, bytes: hex });
|
||||
const diagnostics = z.strictObject({
|
||||
stdout: z.string(),
|
||||
@@ -221,17 +222,17 @@ const recordedCrashObjectSchema = z.strictObject({
|
||||
type: z.union([z.string(), index]),
|
||||
location: range,
|
||||
owner_location: range,
|
||||
owner_bytes_base64: canonicalBase64Schema,
|
||||
owner_bytes_base64: base64,
|
||||
owner_display: z.string().nullable(),
|
||||
descriptor_location: range,
|
||||
descriptor_bytes_base64: canonicalBase64Schema,
|
||||
descriptor_bytes_base64: base64,
|
||||
}),
|
||||
),
|
||||
note_padding: z.array(
|
||||
z.strictObject({
|
||||
segment_index: index,
|
||||
location: range,
|
||||
bytes_base64: canonicalBase64Schema,
|
||||
bytes_base64: base64,
|
||||
}),
|
||||
),
|
||||
threads: z.array(
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { PADDED_BASE64_PATTERN } from "./stringPatterns.js";
|
||||
import { isPaddedBase64 } from "./stringPatterns.js";
|
||||
|
||||
/** Validate padded syntax without imposing canonical zero pad bits. */
|
||||
export const paddedBase64ByteLength = (value: string): number | undefined => {
|
||||
if (value.length % 4 !== 0 || !PADDED_BASE64_PATTERN.test(value))
|
||||
return undefined;
|
||||
if (!isPaddedBase64(value)) return undefined;
|
||||
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
|
||||
return (value.length / 4) * 3 - padding;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
CANONICAL_BASE64_PATTERN,
|
||||
BASE64_QUARTET_LENGTH_PATTERN,
|
||||
INVALID_BASE64_CHARACTER_PATTERN,
|
||||
INVALID_BASE64_PADDING_PATTERN,
|
||||
INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN,
|
||||
IDENTIFIER_PATTERN,
|
||||
PADDED_BASE64_PATTERN,
|
||||
STABLE_IDENTIFIER_PATTERN,
|
||||
canonicalBase64Schema,
|
||||
isCanonicalBase64,
|
||||
isPaddedBase64,
|
||||
} from "./stringPatterns.js";
|
||||
|
||||
it("preserves identifier grammars under Unicode and Unicode-set compilation", () => {
|
||||
@@ -43,7 +50,7 @@ it("preserves identifier grammars under Unicode and Unicode-set compilation", ()
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves canonical base64 padding and pad-bit validation", () => {
|
||||
it("preserves canonical and permissive padded base64 validation", () => {
|
||||
const previous =
|
||||
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/][AQgw]==|[A-Za-z0-9+/]{2}[AEIMQUYcgkosw048]=)?$/;
|
||||
const samples = [
|
||||
@@ -71,28 +78,79 @@ it("preserves canonical base64 padding and pad-bit validation", () => {
|
||||
for (const first of alphabet)
|
||||
for (const second of alphabet)
|
||||
samples.push(`${first}${second}==`, `A${first}${second}=`);
|
||||
for (const flag of ["u", "v"]) {
|
||||
const projected = new RegExp(CANONICAL_BASE64_PATTERN.source, flag);
|
||||
for (const sample of samples)
|
||||
expect(projected.test(sample), JSON.stringify({ flag, sample })).toBe(
|
||||
previous.test(sample),
|
||||
);
|
||||
for (const sample of samples) {
|
||||
expect(isCanonicalBase64(sample), JSON.stringify({ sample })).toBe(
|
||||
previous.test(sample),
|
||||
);
|
||||
expect(isPaddedBase64(sample), JSON.stringify({ sample })).toBe(
|
||||
sample.length % 4 === 0 && /^[A-Za-z0-9+/]*(?:==|=)?$/.test(sample),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("validates large canonical base64 under JSON Schema Unicode modes", () => {
|
||||
// Near the 32 MiB capture input budget: a quartet expansion alone still
|
||||
// overflows at this size, especially in Unicode-set client validators.
|
||||
it("advertises every portable base64 constraint and keeps large inputs stack-safe", () => {
|
||||
const jsonSchema = z.toJSONSchema(canonicalBase64Schema, {
|
||||
target: "draft-2020-12",
|
||||
});
|
||||
expect(jsonSchema).toMatchObject({
|
||||
allOf: expect.arrayContaining([
|
||||
{ pattern: BASE64_QUARTET_LENGTH_PATTERN.source },
|
||||
{ not: { pattern: INVALID_BASE64_CHARACTER_PATTERN.source } },
|
||||
{ not: { pattern: INVALID_BASE64_PADDING_PATTERN.source } },
|
||||
{ not: { pattern: INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN.source } },
|
||||
]),
|
||||
});
|
||||
|
||||
const body = Buffer.alloc(24 * 1024 * 1024 - 3, 0x81);
|
||||
const encoded = body.toString("base64");
|
||||
for (const flag of ["", "u", "v"]) {
|
||||
const pattern = new RegExp(CANONICAL_BASE64_PATTERN.source, flag);
|
||||
expect(pattern.test(encoded), flag).toBe(true);
|
||||
expect(pattern.test(`${encoded.slice(0, -1)}!`), flag).toBe(false);
|
||||
expect(pattern.test(`${encoded.slice(0, -3)}R==`), flag).toBe(false);
|
||||
expect(pattern.test(`${encoded}A`), flag).toBe(false);
|
||||
const padded = new RegExp(PADDED_BASE64_PATTERN.source, flag);
|
||||
expect(padded.test(encoded), flag).toBe(true);
|
||||
expect(padded.test(`${encoded.slice(0, -1)}!`), flag).toBe(false);
|
||||
expect(
|
||||
new RegExp(BASE64_QUARTET_LENGTH_PATTERN.source, flag).test(encoded),
|
||||
flag,
|
||||
).toBe(true);
|
||||
expect(
|
||||
new RegExp(INVALID_BASE64_CHARACTER_PATTERN.source, flag).test(encoded),
|
||||
flag,
|
||||
).toBe(false);
|
||||
expect(
|
||||
new RegExp(INVALID_BASE64_PADDING_PATTERN.source, flag).test(encoded),
|
||||
flag,
|
||||
).toBe(false);
|
||||
expect(
|
||||
new RegExp(INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN.source, flag).test(
|
||||
encoded,
|
||||
),
|
||||
flag,
|
||||
).toBe(false);
|
||||
expect(
|
||||
new RegExp(INVALID_BASE64_CHARACTER_PATTERN.source, flag).test(
|
||||
`${encoded.slice(0, -1)}!`,
|
||||
),
|
||||
flag,
|
||||
).toBe(true);
|
||||
expect(
|
||||
new RegExp(INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN.source, flag).test(
|
||||
`${encoded.slice(0, -3)}R==`,
|
||||
),
|
||||
flag,
|
||||
).toBe(true);
|
||||
}
|
||||
expect(isCanonicalBase64(encoded)).toBe(true);
|
||||
});
|
||||
|
||||
it("validates complete projected canonical schemas at the encoded-input budget", () => {
|
||||
const projected = z.toJSONSchema(canonicalBase64Schema, {
|
||||
target: "draft-2020-12",
|
||||
});
|
||||
const validate = new Ajv2020({ strict: true }).compile(projected);
|
||||
const encoded = Buffer.alloc(24 * 1024 * 1024 - 3, 0x81).toString("base64");
|
||||
for (const { value, expected } of [
|
||||
{ value: encoded, expected: true },
|
||||
{ value: `${encoded.slice(0, -1)}!`, expected: false },
|
||||
{ value: `${encoded.slice(0, -3)}R==`, expected: false },
|
||||
{ value: `${encoded}A`, expected: false },
|
||||
]) {
|
||||
expect(canonicalBase64Schema.safeParse(value).success).toBe(expected);
|
||||
expect(validate(value)).toBe(expected);
|
||||
}
|
||||
});
|
||||
@@ -1,29 +1,44 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Portable ASCII identifier starting with a letter. */
|
||||
export const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9._\x2d]*$/u;
|
||||
|
||||
/** Stable evidence identifier, also admitting namespace and path separators. */
|
||||
export const STABLE_IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9._:\x2f\x2d]*$/u;
|
||||
|
||||
// Repeated alphabet quartets exhaust V8's regexp stack on large bodies,
|
||||
// especially under JSON Schema's Unicode-set mode. Check invalid content
|
||||
// separately from an unconstrained fixed-width loop for length.
|
||||
export const BASE64_QUARTETS_PATTERN = /^(?:....)*$/;
|
||||
const PADDED_BASE64_INVALID_PATTERN = /[^A-Za-z0-9+\x2f=]|=[^=]|===/;
|
||||
export const CANONICAL_BASE64_INVALID_PATTERN = new RegExp(
|
||||
`${PADDED_BASE64_INVALID_PATTERN.source}|[^AQgw]==$|[^AEIMQUYcgkosw048=]=$`,
|
||||
);
|
||||
// These rules are separate JSON Schema constraints. A single RE2-compatible
|
||||
// regex for alphabet and quartet validity requires a repeated alphabet group;
|
||||
// V8's Unicode-set engine overflows its regexp stack on that group near the
|
||||
// provider's 32 MiB encoded-input budget. Wildcard quartets and literal bad
|
||||
// token probes stay stack-safe at that size and compose exactly with `allOf`.
|
||||
export const BASE64_QUARTET_LENGTH_PATTERN = /^(?:....)*$/;
|
||||
export const INVALID_BASE64_CHARACTER_PATTERN = /[^A-Za-z0-9+\x2f=]/;
|
||||
export const INVALID_BASE64_PADDING_PATTERN = /(?:=[^=]|===)/;
|
||||
export const INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN =
|
||||
/(?:[^AQgw]==$|[BCDFGHJKLNOPRSTVWXZabdefhijlmnpqrtuvxyz1235679+\x2f]=$)/;
|
||||
|
||||
const excludingInvalidBase64 = (invalid: RegExp): RegExp =>
|
||||
new RegExp(
|
||||
`^(?!.*(?:${invalid.source}))${BASE64_QUARTETS_PATTERN.source.slice(1)}`,
|
||||
);
|
||||
export const isPaddedBase64 = (value: string): boolean =>
|
||||
value.length % 4 === 0 &&
|
||||
!INVALID_BASE64_CHARACTER_PATTERN.test(value) &&
|
||||
!INVALID_BASE64_PADDING_PATTERN.test(value);
|
||||
|
||||
/** Padded base64 syntax, allowing nonzero pad bits and the empty encoding. */
|
||||
export const PADDED_BASE64_PATTERN = excludingInvalidBase64(
|
||||
PADDED_BASE64_INVALID_PATTERN,
|
||||
);
|
||||
export const isCanonicalBase64 = (value: string): boolean =>
|
||||
isPaddedBase64(value) &&
|
||||
!INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN.test(value);
|
||||
|
||||
/** Canonical padded base64, including zero pad bits and the empty encoding. */
|
||||
export const CANONICAL_BASE64_PATTERN = excludingInvalidBase64(
|
||||
CANONICAL_BASE64_INVALID_PATTERN,
|
||||
);
|
||||
const paddedBase64SchemaRules = [
|
||||
{ pattern: BASE64_QUARTET_LENGTH_PATTERN.source },
|
||||
{ not: { pattern: INVALID_BASE64_CHARACTER_PATTERN.source } },
|
||||
{ not: { pattern: INVALID_BASE64_PADDING_PATTERN.source } },
|
||||
];
|
||||
|
||||
/** Canonical padded base64 with zero pad bits, including the empty encoding. */
|
||||
export const canonicalBase64Schema = z
|
||||
.string()
|
||||
.refine(isCanonicalBase64, "Expected canonical padded base64")
|
||||
.meta({
|
||||
allOf: [
|
||||
...paddedBase64SchemaRules,
|
||||
{ not: { pattern: INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN.source } },
|
||||
],
|
||||
});
|
||||
+117
-3
@@ -21,7 +21,18 @@ import {
|
||||
} from "./composition/optionalObservationProviders.js";
|
||||
import { createToolResultDelivery } from "./server/toolResult.js";
|
||||
import { createShutdown } from "./main/shutdown.js";
|
||||
import { registerProcessShutdown } from "./main/processShutdown.js";
|
||||
import {
|
||||
createExitStatus,
|
||||
latchSignalShutdown,
|
||||
registerProcessShutdown,
|
||||
registerSignalShutdown,
|
||||
type ExitStatus,
|
||||
type SignalLatch,
|
||||
} from "./main/processShutdown.js";
|
||||
import {
|
||||
parseMcpHttpEndpoint,
|
||||
type McpHttpEndpoint,
|
||||
} from "./config/mcpHttpEndpoint.js";
|
||||
|
||||
const runtimeDependencies = (): RuntimeDependencies => ({
|
||||
env: process.env,
|
||||
@@ -33,10 +44,57 @@ const runtimeDependencies = (): RuntimeDependencies => ({
|
||||
registerShutdown: registerProcessShutdown,
|
||||
});
|
||||
|
||||
/** Replace stdio with the streamable HTTP listener; signals alone end it, never stdin. */
|
||||
const httpRuntimeDependencies = (
|
||||
endpoint: McpHttpEndpoint,
|
||||
latch: SignalLatch,
|
||||
exit: ExitStatus,
|
||||
): RuntimeDependencies => {
|
||||
const base = runtimeDependencies();
|
||||
return {
|
||||
...base,
|
||||
setExitCode: exit.set,
|
||||
serve: async (factory, options) => {
|
||||
// Loaded here so the default stdio entry never loads the HTTP listener.
|
||||
const { serveStreamableHttp } =
|
||||
await import("./server/http/serveStreamableHttp.js");
|
||||
const { RequestTrackingMcpServer } =
|
||||
await import("./server/RequestTrackingMcpServer.js");
|
||||
// The transport's factory owner closes every instance it created; the listener must not race it.
|
||||
return serveStreamableHttp({
|
||||
...endpoint,
|
||||
createMcpServer: async (context) => {
|
||||
const server = await factory(context);
|
||||
if (!(server instanceof RequestTrackingMcpServer))
|
||||
throw new TypeError("HTTP MCP servers must track tool completion");
|
||||
return server;
|
||||
},
|
||||
closeInstance: false,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
registerShutdown: (request) => {
|
||||
const unregister = registerSignalShutdown((exitStatus) => {
|
||||
exit.signal(exitStatus);
|
||||
request();
|
||||
});
|
||||
// A signal that arrived during startup is honoured now that the lifecycle owner exists;
|
||||
// its status outlives the entry's own `0`, which startup returns right after this.
|
||||
const pending = latch.release();
|
||||
if (pending !== undefined) {
|
||||
exit.signal(pending);
|
||||
request();
|
||||
}
|
||||
return unregister;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the long-lived MCP adapter and install idempotent shutdown handlers.
|
||||
* The adapter owns its BinarySession for the process lifetime; EOF and process
|
||||
* signals close bridge resources without assuming ownership of the Hopper app.
|
||||
* The adapter owns its BinarySession for the process lifetime; stdin EOF on
|
||||
* the stdio transport and process signals on either transport close bridge
|
||||
* resources without assuming ownership of the Hopper app.
|
||||
*/
|
||||
export const run = async (
|
||||
dependencies: RuntimeDependencies = runtimeDependencies(),
|
||||
@@ -100,6 +158,62 @@ export const runEntrypoint = async (
|
||||
}
|
||||
};
|
||||
|
||||
/** Process streams the dispatcher entries write to. */
|
||||
export interface EntrypointIo {
|
||||
readonly writeStdout: (text: string) => void;
|
||||
readonly writeStderr: (text: string) => void;
|
||||
readonly setExitCode: (code: number) => void;
|
||||
}
|
||||
|
||||
const processIo: EntrypointIo = {
|
||||
writeStdout: (text) => void process.stdout.write(text),
|
||||
writeStderr: (text) => void process.stderr.write(text),
|
||||
setExitCode: (code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
};
|
||||
|
||||
/** Run `rea mcp --http`: parse its flags, resolve MCP_HOST/MCP_PORT, then serve over HTTP. */
|
||||
export const runMcpHttpEntrypoint = async (
|
||||
arguments_: readonly string[],
|
||||
io: EntrypointIo = processIo,
|
||||
): Promise<void> => {
|
||||
const { parseMcpHttpArguments, renderMcpHttpHelp } =
|
||||
await import("./main/mcpHttpArguments.js");
|
||||
const parsed = parseMcpHttpArguments(arguments_);
|
||||
if (!parsed.ok) {
|
||||
if (parsed.help) {
|
||||
io.writeStdout(`${renderMcpHttpHelp()}\n`);
|
||||
io.setExitCode(0);
|
||||
return;
|
||||
}
|
||||
io.writeStderr(`${parsed.message}\n`);
|
||||
io.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
const endpoint = parseMcpHttpEndpoint(snapshotEnvironment(process.env), {
|
||||
...(parsed.host === undefined ? {} : { host: parsed.host }),
|
||||
...(parsed.port === undefined ? {} : { port: parsed.port }),
|
||||
});
|
||||
if (!endpoint.ok) {
|
||||
io.writeStderr(`${projectAnalysisError(endpoint.error).message}\n`);
|
||||
io.setExitCode(1);
|
||||
return;
|
||||
}
|
||||
// Latched before any asynchronous startup so a signal cannot end the process without cleanup.
|
||||
const latch = latchSignalShutdown();
|
||||
const exit = createExitStatus(io.setExitCode);
|
||||
try {
|
||||
await runEntrypoint(
|
||||
() => run(httpRuntimeDependencies(endpoint.value, latch, exit)),
|
||||
io.writeStderr,
|
||||
exit.set,
|
||||
);
|
||||
} finally {
|
||||
latch.release();
|
||||
}
|
||||
};
|
||||
|
||||
/** Return false when the runtime's argv path is synthetic or no longer exists. */
|
||||
const isDirectEntrypoint = (
|
||||
entryPath: string | undefined,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Help, z } from "incur";
|
||||
|
||||
import {
|
||||
MCP_HTTP_PORT_CONSTRAINT,
|
||||
mcpHttpPortSchema,
|
||||
} from "../config/mcpHttpEndpoint.js";
|
||||
|
||||
/**
|
||||
* Canonical `rea mcp --http` options. The help renderer lists exactly this
|
||||
* schema and the parser accepts exactly these flags plus the `-h` alias.
|
||||
*/
|
||||
const MCP_HTTP_OPTIONS = z.object({
|
||||
http: z
|
||||
.boolean()
|
||||
.describe("Serve the MCP catalog over streamable HTTP at /mcp, not stdio"),
|
||||
host: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Listener address; default MCP_HOST, else 127.0.0.1"),
|
||||
port: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(65535)
|
||||
.optional()
|
||||
.describe("Listener port; default MCP_PORT, else 0 for an ephemeral port"),
|
||||
help: z.boolean().optional().describe("Show help"),
|
||||
});
|
||||
|
||||
const MCP_HTTP_ALIAS = { help: "h" } as const;
|
||||
const MCP_HTTP_COMMAND = "rea mcp --http";
|
||||
const MCP_HTTP_DESCRIPTION =
|
||||
"Serve the MCP server over streamable HTTP for one local client";
|
||||
const HELP_FLAGS: ReadonlySet<string> = new Set(["--help", "-h"]);
|
||||
const VALUED_FLAGS: ReadonlySet<string> = new Set(["--host", "--port"]);
|
||||
|
||||
/** Render help for the dispatcher entry, which accepts no Incur global options. */
|
||||
export const renderMcpHttpHelp = (): string =>
|
||||
Help.formatCommand(MCP_HTTP_COMMAND, {
|
||||
description: MCP_HTTP_DESCRIPTION,
|
||||
hideGlobalOptions: true,
|
||||
options: MCP_HTTP_OPTIONS,
|
||||
alias: MCP_HTTP_ALIAS,
|
||||
hint: "This entry accepts only the options above. The bound URL is logged as one JSON line on stderr.",
|
||||
});
|
||||
|
||||
/** Parsed `rea mcp --http` arguments; omitted values defer to the environment. */
|
||||
export type McpHttpArguments =
|
||||
| { readonly ok: true; readonly host?: string; readonly port?: number }
|
||||
| { readonly ok: false; readonly help: true }
|
||||
| { readonly ok: false; readonly help: false; readonly message: string };
|
||||
|
||||
const invalid = (message: string): McpHttpArguments => ({
|
||||
ok: false,
|
||||
help: false,
|
||||
message,
|
||||
});
|
||||
|
||||
/** Split `--flag=value` into its flag and inline value. */
|
||||
const splitFlag = (argument: string): readonly [string, string | undefined] => {
|
||||
const separator = argument.indexOf("=");
|
||||
return separator === -1
|
||||
? [argument, undefined]
|
||||
: [argument.slice(0, separator), argument.slice(separator + 1)];
|
||||
};
|
||||
|
||||
/** Parse the dispatcher arguments after `mcp`; help wins only in flag position. */
|
||||
export const parseMcpHttpArguments = (
|
||||
arguments_: readonly string[],
|
||||
): McpHttpArguments => {
|
||||
const values = new Map<string, string>();
|
||||
let http = false;
|
||||
for (let index = 0; index < arguments_.length; index += 1) {
|
||||
const argument = arguments_[index] ?? "";
|
||||
if (HELP_FLAGS.has(argument)) return { ok: false, help: true };
|
||||
if (argument === "--http") {
|
||||
http = true;
|
||||
continue;
|
||||
}
|
||||
const [flag, inline] = splitFlag(argument);
|
||||
if (!VALUED_FLAGS.has(flag))
|
||||
return invalid(`Unknown mcp --http argument: ${argument}`);
|
||||
const value = inline ?? arguments_[index + 1];
|
||||
if (inline === undefined) index += 1;
|
||||
if (value === undefined || (inline === undefined && value.startsWith("-")))
|
||||
return invalid(`Missing value for ${flag}`);
|
||||
values.set(flag, value);
|
||||
}
|
||||
if (!http) return invalid("--host and --port require --http");
|
||||
const host = values.get("--host")?.trim();
|
||||
if (host !== undefined && host.length === 0)
|
||||
return invalid("--host must be a non-empty host name or IP address");
|
||||
const portText = values.get("--port");
|
||||
const port =
|
||||
portText === undefined ? undefined : mcpHttpPortSchema.safeParse(portText);
|
||||
if (port !== undefined && !port.success)
|
||||
return invalid(`Invalid --port: ${MCP_HTTP_PORT_CONSTRAINT}`);
|
||||
return {
|
||||
ok: true,
|
||||
...(host === undefined ? {} : { host }),
|
||||
...(port === undefined ? {} : { port: port.data }),
|
||||
};
|
||||
};
|
||||
@@ -6,3 +6,5 @@ export const MCP_SHUTDOWN_FAILED =
|
||||
"REA could not close cleanly. End the REA process before starting it again.";
|
||||
export const SERVER_START_FAILED =
|
||||
"REA could not start. Run `rea doctor`, then restart REA from your MCP client.";
|
||||
export const MCP_HTTP_LISTEN_FAILED =
|
||||
"REA could not listen for MCP HTTP connections on the selected host and port. Choose another --port or stop the process using it.";
|
||||
+89
-10
@@ -1,5 +1,11 @@
|
||||
type ShutdownSignal = "SIGINT" | "SIGTERM";
|
||||
|
||||
/** Exit status a signal-terminated process reports (128 + signal number). */
|
||||
const SIGNAL_EXIT_STATUS: Readonly<Record<ShutdownSignal, number>> = {
|
||||
SIGINT: 130,
|
||||
SIGTERM: 143,
|
||||
};
|
||||
|
||||
interface ShutdownSignalSource {
|
||||
on(event: ShutdownSignal, listener: () => void): unknown;
|
||||
off(event: ShutdownSignal, listener: () => void): unknown;
|
||||
@@ -12,7 +18,8 @@ interface ShutdownInputSource {
|
||||
|
||||
interface ProcessShutdownEvents {
|
||||
readonly signals: ShutdownSignalSource;
|
||||
readonly stdin: ShutdownInputSource;
|
||||
/** Absent when the transport does not own stdin, as the HTTP listener does not. */
|
||||
readonly stdin?: ShutdownInputSource;
|
||||
}
|
||||
|
||||
const processShutdownEvents: ProcessShutdownEvents = {
|
||||
@@ -20,19 +27,91 @@ const processShutdownEvents: ProcessShutdownEvents = {
|
||||
stdin: process.stdin,
|
||||
};
|
||||
|
||||
/** Receives the signal that requested shutdown; stdin end and close carry none. */
|
||||
type ShutdownHandler = (signal?: ShutdownSignal) => void;
|
||||
|
||||
/** Register the process events that request shutdown and return their owner. */
|
||||
export const registerProcessShutdown = (
|
||||
handler: () => void,
|
||||
handler: ShutdownHandler,
|
||||
events: ProcessShutdownEvents = processShutdownEvents,
|
||||
): (() => void) => {
|
||||
events.signals.on("SIGINT", handler);
|
||||
events.signals.on("SIGTERM", handler);
|
||||
events.stdin.on("end", handler);
|
||||
events.stdin.on("close", handler);
|
||||
const onInterrupt = (): void => handler("SIGINT");
|
||||
const onTerminate = (): void => handler("SIGTERM");
|
||||
const onInputEnd = (): void => handler();
|
||||
events.signals.on("SIGINT", onInterrupt);
|
||||
events.signals.on("SIGTERM", onTerminate);
|
||||
events.stdin?.on("end", onInputEnd);
|
||||
events.stdin?.on("close", onInputEnd);
|
||||
return () => {
|
||||
events.signals.off("SIGINT", handler);
|
||||
events.signals.off("SIGTERM", handler);
|
||||
events.stdin.off("end", handler);
|
||||
events.stdin.off("close", handler);
|
||||
events.signals.off("SIGINT", onInterrupt);
|
||||
events.signals.off("SIGTERM", onTerminate);
|
||||
events.stdin?.off("end", onInputEnd);
|
||||
events.stdin?.off("close", onInputEnd);
|
||||
};
|
||||
};
|
||||
|
||||
/** Register signals only, for a transport that does not own stdin, and report the signal's exit status. */
|
||||
export const registerSignalShutdown = (
|
||||
handler: (exitStatus: number) => void,
|
||||
signals: ShutdownSignalSource = process,
|
||||
): (() => void) =>
|
||||
registerProcessShutdown(
|
||||
(signal) => {
|
||||
if (signal !== undefined) handler(SIGNAL_EXIT_STATUS[signal]);
|
||||
},
|
||||
{ signals },
|
||||
);
|
||||
|
||||
/** A signal recorded while startup is still running, before the lifecycle owner can act on it. */
|
||||
export interface SignalLatch {
|
||||
/** Stop recording; returns the exit status of the first signal seen, if any. */
|
||||
readonly release: () => number | undefined;
|
||||
}
|
||||
|
||||
/** Record the first SIGINT or SIGTERM during startup and exit at once on a second one, so startup never dies without its cleanup. */
|
||||
export const latchSignalShutdown = (
|
||||
signals: ShutdownSignalSource = process,
|
||||
exit: (status: number) => void = (status) => process.exit(status),
|
||||
): SignalLatch => {
|
||||
let pending: number | undefined;
|
||||
const record = (signal: ShutdownSignal) => (): void => {
|
||||
if (pending === undefined) pending = SIGNAL_EXIT_STATUS[signal];
|
||||
else exit(pending);
|
||||
};
|
||||
const onInterrupt = record("SIGINT");
|
||||
const onTerminate = record("SIGTERM");
|
||||
signals.on("SIGINT", onInterrupt);
|
||||
signals.on("SIGTERM", onTerminate);
|
||||
return {
|
||||
release: () => {
|
||||
signals.off("SIGINT", onInterrupt);
|
||||
signals.off("SIGTERM", onTerminate);
|
||||
return pending;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** The exit status an entry reports: a signal's status survives the entry's normal `0` return. */
|
||||
export interface ExitStatus {
|
||||
/** The entry's own result; a `0` after a recorded signal keeps the signal's status. */
|
||||
readonly set: (code: number) => void;
|
||||
/** Record the exit status of a signal-requested shutdown. */
|
||||
readonly signal: (status: number) => void;
|
||||
}
|
||||
|
||||
export const createExitStatus = (
|
||||
setExitCode: (code: number) => void,
|
||||
): ExitStatus => {
|
||||
let signalStatus: number | undefined;
|
||||
return {
|
||||
set: (code) => {
|
||||
setExitCode(
|
||||
code === 0 && signalStatus !== undefined ? signalStatus : code,
|
||||
);
|
||||
},
|
||||
signal: (status) => {
|
||||
signalStatus = status;
|
||||
setExitCode(status);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { StdioServerHandle } from "@modelcontextprotocol/server/stdio";
|
||||
|
||||
import type { BinarySession } from "../application/binary/BinarySession.js";
|
||||
import type { Logger } from "pino";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { RuntimeDependencies } from "./types.js";
|
||||
import type { McpServerHandle, RuntimeDependencies } from "./types.js";
|
||||
import { MCP_SHUTDOWN_FAILED } from "./messages.js";
|
||||
|
||||
interface ShutdownCleanupOwner {
|
||||
@@ -13,8 +11,9 @@ interface ShutdownCleanupOwner {
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
/** Own idempotent teardown of the transport handle, providers and session for any shutdown trigger. */
|
||||
export const createShutdown = (input: {
|
||||
readonly handle: StdioServerHandle;
|
||||
readonly handle: McpServerHandle;
|
||||
readonly closeAndroid?: () => Promise<void>;
|
||||
readonly closeOptionalProviders?: () => Promise<void>;
|
||||
readonly session: BinarySession;
|
||||
@@ -48,6 +47,9 @@ export const createShutdown = (input: {
|
||||
if (shutdownAttempt !== undefined) return shutdownAttempt;
|
||||
const pending = cleanupOwners.filter(({ closed }) => !closed);
|
||||
const attempt = (async () => {
|
||||
// HTTP handlers can still own provider resources after cancellation.
|
||||
// A failed drain keeps every shared owner available for a later retry.
|
||||
await handle.prepareShutdown?.();
|
||||
const results = await Promise.allSettled(
|
||||
pending.map(async (owner) => {
|
||||
await owner.close();
|
||||
|
||||
+18
-7
@@ -1,17 +1,18 @@
|
||||
import { createAndroidAnalysisProvider } from "../composition/android.js";
|
||||
import type { AndroidAnalysisPort } from "../application/android/AndroidAnalysisPort.js";
|
||||
import type { StdioServerHandle } from "@modelcontextprotocol/server/stdio";
|
||||
|
||||
import type { BinarySession } from "../application/binary/BinarySession.js";
|
||||
import type { Logger } from "pino";
|
||||
import { createServer } from "../server/createServer.js";
|
||||
import { StreamableHttpListenError } from "../server/http/streamableHttpListenError.js";
|
||||
import type { ToolResultDelivery } from "../server/toolResult.js";
|
||||
import type { RuntimeDependencies } from "./types.js";
|
||||
import type { McpServerHandle, RuntimeDependencies } from "./types.js";
|
||||
import type { OptionalProviderLoadResult } from "../application/OptionalObservationProviders.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
MCP_CONNECTION_LOST,
|
||||
MCP_CONNECTION_START_FAILED,
|
||||
MCP_HTTP_LISTEN_FAILED,
|
||||
} from "./messages.js";
|
||||
|
||||
interface ServerContext {
|
||||
@@ -26,6 +27,7 @@ type ClosableOptionalProvider = {
|
||||
readonly close?: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Start the injected transport (stdio or streamable HTTP) around one shared session. */
|
||||
export const startMcpTransport = async (
|
||||
dependencies: RuntimeDependencies,
|
||||
session: BinarySession,
|
||||
@@ -33,7 +35,7 @@ export const startMcpTransport = async (
|
||||
): Promise<
|
||||
| {
|
||||
readonly ok: true;
|
||||
readonly handle: StdioServerHandle;
|
||||
readonly handle: McpServerHandle;
|
||||
readonly closeAndroid: () => Promise<void>;
|
||||
readonly closeOptionalProviders: () => Promise<void>;
|
||||
}
|
||||
@@ -105,9 +107,9 @@ export const startMcpTransport = async (
|
||||
});
|
||||
await optionalProviderClose;
|
||||
};
|
||||
let handle: StdioServerHandle;
|
||||
let handle: McpServerHandle;
|
||||
try {
|
||||
handle = dependencies.serve(
|
||||
handle = await dependencies.serve(
|
||||
() => {
|
||||
// The SDK can discard a discovery probe and construct a replacement server.
|
||||
const android = createAndroidAnalysisProvider(
|
||||
@@ -139,6 +141,7 @@ export const startMcpTransport = async (
|
||||
serverLogger.error(MCP_CONNECTION_LOST);
|
||||
dependencies.writeStderr(`${MCP_CONNECTION_LOST}\n`);
|
||||
},
|
||||
logger: serverLogger,
|
||||
},
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
@@ -153,13 +156,17 @@ export const startMcpTransport = async (
|
||||
closeServers(),
|
||||
closeOptionalProviders(),
|
||||
]);
|
||||
const startFailure =
|
||||
cause instanceof StreamableHttpListenError
|
||||
? MCP_HTTP_LISTEN_FAILED
|
||||
: MCP_CONNECTION_START_FAILED;
|
||||
serverLogger.error(
|
||||
{
|
||||
error: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
MCP_CONNECTION_START_FAILED,
|
||||
startFailure,
|
||||
);
|
||||
dependencies.writeStderr(`${MCP_CONNECTION_START_FAILED}\n`);
|
||||
dependencies.writeStderr(`${startFailure}\n`);
|
||||
if (binaryCleanup.status === "fulfilled" && !binaryCleanup.value.ok) {
|
||||
const cleanupError = projectAnalysisError(binaryCleanup.value.error);
|
||||
serverLogger.error(
|
||||
@@ -191,7 +198,11 @@ export const startMcpTransport = async (
|
||||
return {
|
||||
ok: true,
|
||||
handle: {
|
||||
...(handle.prepareShutdown === undefined
|
||||
? {}
|
||||
: { prepareShutdown: handle.prepareShutdown }),
|
||||
close: async () => {
|
||||
await handle.prepareShutdown?.();
|
||||
const results = await Promise.allSettled([
|
||||
handle.close(),
|
||||
closeServers(),
|
||||
|
||||
+23
-1
@@ -1,8 +1,30 @@
|
||||
import type { McpServerFactory } from "@modelcontextprotocol/server";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { OptionalProviderLoadResult } from "../application/OptionalObservationProviders.js";
|
||||
|
||||
/** Teardown handle every MCP transport returns to the runtime. */
|
||||
export interface McpServerHandle {
|
||||
/** Optional transport barrier before shared provider teardown. */
|
||||
readonly prepareShutdown?: () => Promise<void>;
|
||||
readonly close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Runtime inputs shared by the stdio and streamable HTTP transports. */
|
||||
export interface McpServeOptions {
|
||||
readonly onerror?: (error: Error) => void;
|
||||
readonly logger?: Logger;
|
||||
}
|
||||
|
||||
/** Start one transport around the server factory; a listener binds asynchronously. */
|
||||
export type McpServe = (
|
||||
factory: McpServerFactory,
|
||||
options: McpServeOptions,
|
||||
) => McpServerHandle | Promise<McpServerHandle>;
|
||||
|
||||
export interface RuntimeDependencies {
|
||||
readonly env: NodeJS.ProcessEnv;
|
||||
readonly serve: typeof import("@modelcontextprotocol/server/stdio").serveStdio;
|
||||
readonly serve: McpServe;
|
||||
readonly writeStderr: (text: string) => void;
|
||||
readonly setExitCode: (code: number) => void;
|
||||
readonly registerShutdown: (handler: () => void) => () => void;
|
||||
|
||||
@@ -7,6 +7,7 @@ const statRecord = (
|
||||
readonly pid?: string;
|
||||
readonly command?: string;
|
||||
readonly state?: string;
|
||||
readonly flags?: string;
|
||||
readonly parentPid?: string;
|
||||
readonly processGroupId?: string;
|
||||
readonly sessionId?: string;
|
||||
@@ -20,6 +21,7 @@ const statRecord = (
|
||||
options.sessionId ?? "19",
|
||||
];
|
||||
while (fields.length < 19) fields.push("0");
|
||||
fields[6] = options.flags ?? "0";
|
||||
fields.push(options.startTime ?? "987654");
|
||||
return `${options.pid ?? "42"} (${options.command ?? "worker"}) ${fields.join(" ")}`;
|
||||
};
|
||||
@@ -56,3 +58,38 @@ it("distinguishes zombie lifecycle and rejects malformed or missing identity fie
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["R", "S", "D"])(
|
||||
"retains PF_EXITING separately from terminal state %s",
|
||||
(state) => {
|
||||
expect(
|
||||
parseLinuxProcessStat(statRecord({ state, flags: "4194316" }))?.lifecycle,
|
||||
).toBe("exiting");
|
||||
expect(
|
||||
parseLinuxProcessStat(statRecord({ state, flags: "4194312" }))?.lifecycle,
|
||||
).toBe("non-zombie");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["Z", "X", "x"])(
|
||||
"recognizes terminal native state %s independently of flags",
|
||||
(state) => {
|
||||
expect(
|
||||
parseLinuxProcessStat(statRecord({ state, flags: "invalid" }))?.lifecycle,
|
||||
).toBe(state === "Z" ? "zombie" : "dead");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["invalid", "-1", "4.1", "9007199254740992"])(
|
||||
"preserves identity and relations with unknown flags %s",
|
||||
(flags) => {
|
||||
expect(parseLinuxProcessStat(statRecord({ flags }))).toEqual({
|
||||
pid: 42,
|
||||
startTime: "987654",
|
||||
lifecycle: undefined,
|
||||
parentPid: 17,
|
||||
processGroupId: 18,
|
||||
sessionId: 19,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ProcessLifecycle } from "./ProcessLifecycle.js";
|
||||
|
||||
/** Native process facts parsed from one Linux `/proc/<pid>/stat` record. */
|
||||
export interface LinuxProcessStat {
|
||||
readonly pid: number;
|
||||
readonly startTime: string;
|
||||
readonly lifecycle: "non-zombie" | "zombie";
|
||||
readonly lifecycle: ProcessLifecycle | undefined;
|
||||
readonly parentPid: number;
|
||||
readonly processGroupId: number;
|
||||
readonly sessionId: number;
|
||||
@@ -58,10 +60,28 @@ export const parseLinuxProcessStat = (
|
||||
)
|
||||
return undefined;
|
||||
|
||||
// Field 9 is task flags. PF_EXITING can precede state Z and procfs token
|
||||
// access can already fail during that interval. Neither proves terminal exit.
|
||||
const flagsText = fields[6];
|
||||
const flags =
|
||||
flagsText !== undefined && /^\d+$/u.test(flagsText)
|
||||
? Number(flagsText)
|
||||
: Number.NaN;
|
||||
const lifecycle: ProcessLifecycle | undefined =
|
||||
state === "Z"
|
||||
? "zombie"
|
||||
: state === "X" || state === "x"
|
||||
? "dead"
|
||||
: !Number.isSafeInteger(flags)
|
||||
? undefined
|
||||
: (flags & 0x00000004) !== 0
|
||||
? "exiting"
|
||||
: "non-zombie";
|
||||
|
||||
return {
|
||||
pid,
|
||||
startTime,
|
||||
lifecycle: state === "Z" ? "zombie" : "non-zombie",
|
||||
lifecycle,
|
||||
parentPid,
|
||||
processGroupId,
|
||||
sessionId,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { parseLinuxProcessEffectiveUid } from "./LinuxProcessStatus.js";
|
||||
|
||||
it("parses the effective UID from the single native status Uid row", () => {
|
||||
expect(
|
||||
parseLinuxProcessEffectiveUid(
|
||||
"Name:\tworker\nUid:\t1000\t1001\t1002\t1003\nGid:\t20\t20\t20\t20\n",
|
||||
),
|
||||
).toBe(1001);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Name:\tworker\n",
|
||||
"Uid:\t1000\t1001\t1002\n",
|
||||
"Uid:\t1000\t1001\t1002\t1003\nUid:\t1000\t1001\t1002\t1003\n",
|
||||
"Uid:\t1000\t-1\t1002\t1003\n",
|
||||
"Uid:\t1000\t9007199254740992\t1002\t1003\n",
|
||||
])("does not infer an effective UID from malformed status: %s", (status) => {
|
||||
expect(parseLinuxProcessEffectiveUid(status)).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Effective UID parsed from one Linux `/proc/<pid>/status` record. */
|
||||
export const parseLinuxProcessEffectiveUid = (
|
||||
value: string,
|
||||
): number | undefined => {
|
||||
let effectiveUid: number | undefined;
|
||||
for (const line of value.split("\n")) {
|
||||
if (!line.startsWith("Uid:")) continue;
|
||||
if (effectiveUid !== undefined) return undefined;
|
||||
const match = /^Uid:\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s*$/u.exec(
|
||||
line,
|
||||
);
|
||||
if (match === null) return undefined;
|
||||
const values = match.slice(1).map(Number);
|
||||
if (values.some((uid) => !Number.isSafeInteger(uid) || uid < 0))
|
||||
return undefined;
|
||||
effectiveUid = values[1];
|
||||
}
|
||||
return effectiveUid;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ProcessIdentityObservation } from "./ProcessOwnership.js";
|
||||
|
||||
/** Native lifecycle is independent of ownership, token readability and ps text. */
|
||||
export type ProcessLifecycle = "non-zombie" | "exiting" | "zombie" | "dead";
|
||||
|
||||
export type ProcessLifecycleObservation =
|
||||
| { readonly state: "observed"; readonly lifecycle: ProcessLifecycle }
|
||||
| { readonly state: "unavailable"; readonly reason: string };
|
||||
|
||||
export const PROCESS_CLEANUP_VERIFICATION_GRACE_MS = 1_000;
|
||||
export const PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS = 25;
|
||||
|
||||
export const processIdentityHasExited = (
|
||||
identity: ProcessIdentityObservation | undefined,
|
||||
): boolean =>
|
||||
identity?.state === "absent" ||
|
||||
(identity?.state === "readable" &&
|
||||
identity.lifecycle?.state === "observed" &&
|
||||
(identity.lifecycle.lifecycle === "zombie" ||
|
||||
identity.lifecycle.lifecycle === "dead"));
|
||||
|
||||
export const processIdentityIsExiting = (
|
||||
identity: ProcessIdentityObservation | undefined,
|
||||
): identity is Extract<
|
||||
ProcessIdentityObservation,
|
||||
{ readonly state: "readable" }
|
||||
> =>
|
||||
identity?.state === "readable" &&
|
||||
identity.lifecycle?.state === "observed" &&
|
||||
identity.lifecycle.lifecycle === "exiting";
|
||||
@@ -0,0 +1,178 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanupOwnedProcessGroup,
|
||||
type ProcessIdentityObservation,
|
||||
type ProcessOwnershipHost,
|
||||
type ProcessTableEntry,
|
||||
} from "./ProcessOwnership.js";
|
||||
import {
|
||||
readProcessOwnershipFacts,
|
||||
settleExitingProcessIdentities,
|
||||
} from "./ProcessOwnershipObservation.js";
|
||||
import { processIdentityHasExited } from "./ProcessLifecycle.js";
|
||||
|
||||
const row: ProcessTableEntry = {
|
||||
pid: 910,
|
||||
parentPid: 17,
|
||||
processGroupId: 910,
|
||||
state: "R",
|
||||
command: "[MainThread]",
|
||||
};
|
||||
const identity = (
|
||||
lifecycle: "non-zombie" | "exiting" | "zombie" | "dead",
|
||||
generation = "start:1",
|
||||
): ProcessIdentityObservation => ({
|
||||
state: "readable",
|
||||
identity: generation,
|
||||
lifecycle: { state: "observed", lifecycle },
|
||||
relation: {
|
||||
state: "observed",
|
||||
parentPid: row.parentPid,
|
||||
processGroupId: row.processGroupId,
|
||||
},
|
||||
});
|
||||
const hostWith = (
|
||||
observe: () => ProcessIdentityObservation,
|
||||
): ProcessOwnershipHost => ({
|
||||
listProcesses: vi.fn(async () => [row]),
|
||||
environment: async () => ({}),
|
||||
processIdentities: vi.fn(
|
||||
async (rows) => new Map(rows.map(({ pid }) => [pid, observe()])),
|
||||
),
|
||||
runTokens: vi.fn(
|
||||
async (rows) =>
|
||||
new Map(
|
||||
rows.map(({ pid }) => [
|
||||
pid,
|
||||
{ state: "unavailable" as const, reason: "EACCES" },
|
||||
]),
|
||||
),
|
||||
),
|
||||
signalGroup: vi.fn(),
|
||||
});
|
||||
|
||||
it.each(["zombie", "dead", "absent"] as const)(
|
||||
"settles exiting token reads only after a fresh native %s observation",
|
||||
async (terminal) => {
|
||||
let reads = 0;
|
||||
const host = hostWith(() =>
|
||||
++reads < 3
|
||||
? identity("exiting")
|
||||
: terminal === "absent"
|
||||
? { state: "absent", reason: "not-in-snapshot" }
|
||||
: identity(terminal),
|
||||
);
|
||||
const facts = await readProcessOwnershipFacts([row], host);
|
||||
expect(processIdentityHasExited(facts[0]?.identityAfter)).toBe(true);
|
||||
expect(facts[0]?.token).toEqual({ state: "unavailable", reason: "EACCES" });
|
||||
expect(host.processIdentities).toHaveBeenCalledTimes(3);
|
||||
expect(host.runTokens).toHaveBeenCalledTimes(1);
|
||||
expect(host.listProcesses).not.toHaveBeenCalled();
|
||||
expect(host.signalGroup).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps unconfirmed exit explicit at the bounded deadline", async () => {
|
||||
const host = hostWith(() => identity("exiting"));
|
||||
const result = await cleanupOwnedProcessGroup(
|
||||
{
|
||||
runId: "run",
|
||||
leaderPid: row.pid,
|
||||
processGroupId: row.processGroupId,
|
||||
expectedCommand: "/exact/node",
|
||||
},
|
||||
host,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
cleaned: false,
|
||||
reason: "owned launcher exit could not be confirmed",
|
||||
});
|
||||
expect(host.signalGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("settles launcher exit before comparing a stale command display", async () => {
|
||||
let reads = 0;
|
||||
const host = hostWith(() =>
|
||||
++reads === 1 ? identity("exiting") : identity("zombie"),
|
||||
);
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(
|
||||
{
|
||||
runId: "run",
|
||||
leaderPid: row.pid,
|
||||
processGroupId: row.processGroupId,
|
||||
expectedCommand: "/exact/node",
|
||||
},
|
||||
host,
|
||||
),
|
||||
).resolves.toMatchObject({ cleaned: true, signaled: false });
|
||||
expect(host.signalGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops at PID reuse and preserves the changed generation", async () => {
|
||||
let reads = 0;
|
||||
const host = hostWith(() =>
|
||||
++reads < 3 ? identity("exiting") : identity("exiting", "start:2"),
|
||||
);
|
||||
const facts = await readProcessOwnershipFacts([row], host);
|
||||
expect(facts[0]).toMatchObject({
|
||||
state: "replaced",
|
||||
identityBefore: { identity: "start:1" },
|
||||
identityAfter: { identity: "start:2" },
|
||||
});
|
||||
expect(host.processIdentities).toHaveBeenCalledTimes(3);
|
||||
expect(host.signalGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves unknown exit evidence without inferring terminal absence", async () => {
|
||||
const host = hostWith(() => ({
|
||||
state: "unavailable",
|
||||
reason: "native identity denied",
|
||||
}));
|
||||
const result = await settleExitingProcessIdentities(
|
||||
[row],
|
||||
host,
|
||||
new Map([[row.pid, identity("exiting")]]),
|
||||
);
|
||||
expect(result.get(row.pid)).toEqual({
|
||||
state: "unavailable",
|
||||
reason: "native identity denied",
|
||||
});
|
||||
expect(processIdentityHasExited(result.get(row.pid))).toBe(false);
|
||||
});
|
||||
|
||||
it("refreshes only selected exiting PIDs and preserves other native observations", async () => {
|
||||
const other = { ...row, pid: 911 };
|
||||
const host = hostWith(() => identity("zombie"));
|
||||
const initial = new Map([
|
||||
[row.pid, identity("exiting")],
|
||||
[other.pid, identity("non-zombie")],
|
||||
]);
|
||||
const result = await settleExitingProcessIdentities(
|
||||
[row, other],
|
||||
host,
|
||||
initial,
|
||||
);
|
||||
expect(host.processIdentities).toHaveBeenCalledExactlyOnceWith(
|
||||
[row],
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(result.get(other.pid)).toBe(initial.get(other.pid));
|
||||
expect(host.listProcesses).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves caller cancellation during native exit settlement", async () => {
|
||||
const abort = new AbortController();
|
||||
const reason = new Error("caller cancelled exit observation");
|
||||
const host = hostWith(() => identity("exiting"));
|
||||
const pending = settleExitingProcessIdentities(
|
||||
[row],
|
||||
host,
|
||||
new Map([[row.pid, identity("exiting")]]),
|
||||
abort.signal,
|
||||
);
|
||||
abort.abort(reason);
|
||||
await expect(pending).rejects.toBe(reason);
|
||||
expect(host.processIdentities).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -113,7 +113,11 @@ describe("Linux process start identities", () => {
|
||||
state: "S",
|
||||
command: process.execPath,
|
||||
} satisfies ProcessTableEntry;
|
||||
const identities = await host.processIdentities?.([observedProcess]);
|
||||
const identities = await host.processIdentities?.(
|
||||
[observedProcess],
|
||||
undefined,
|
||||
new Set([observedProcess.pid]),
|
||||
);
|
||||
expect(identities?.get(observedProcess.pid)).toMatchObject({
|
||||
state: "readable",
|
||||
identity: expect.stringMatching(/^linux-starttime:\d+$/u),
|
||||
@@ -124,8 +128,9 @@ describe("Linux process start identities", () => {
|
||||
processGroupId: expect.any(Number),
|
||||
},
|
||||
});
|
||||
expect(identities?.get(observedProcess.pid)).not.toHaveProperty(
|
||||
expect(identities?.get(observedProcess.pid)).toHaveProperty(
|
||||
"effectiveUid",
|
||||
process.geteuid!(),
|
||||
);
|
||||
const baseline = await host.captureBaseline?.();
|
||||
expect(baseline).toContainEqual({
|
||||
@@ -191,9 +196,11 @@ it("validates and cleans an exact-token owner without a capture baseline", async
|
||||
expect(signalGroup).toHaveBeenCalledWith(process.processGroupId, "SIGKILL");
|
||||
});
|
||||
|
||||
it.skipIf(typeof process.getuid !== "function")(
|
||||
"uses a same-generation native UID instead of the process-table UID for unreadable tokens",
|
||||
async () => {
|
||||
it
|
||||
.skipIf(typeof process.getuid !== "function")
|
||||
.each(["EINVAL", "EACCES: permission denied"])(
|
||||
"uses a same-generation native UID instead of the process-table UID for unreadable tokens: %s",
|
||||
async (diagnostic) => {
|
||||
const callerUid = process.getuid!();
|
||||
const foreignUid = callerUid === 0 ? 1 : 0;
|
||||
const verify = async (rowUid: number, effectiveUid: number) => {
|
||||
@@ -213,7 +220,7 @@ it.skipIf(typeof process.getuid !== "function")(
|
||||
runTokens: () =>
|
||||
Promise.resolve(
|
||||
new Map([
|
||||
[906, { state: "unavailable", reason: "EINVAL" } as const],
|
||||
[906, { state: "unavailable", reason: diagnostic } as const],
|
||||
]),
|
||||
),
|
||||
processIdentities: (entries) =>
|
||||
@@ -242,7 +249,7 @@ it.skipIf(typeof process.getuid !== "function")(
|
||||
});
|
||||
await expect(verify(callerUid, foreignUid)).resolves.toMatchObject({
|
||||
cleaned: true,
|
||||
unverified: [{ pid: 906, diagnostic: "EINVAL" }],
|
||||
unverified: [{ pid: 906, diagnostic }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,8 +9,14 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
return { ...original, readFile: filesystem.readFile };
|
||||
});
|
||||
|
||||
import { createSystemProcessOwnershipHost } from "./ProcessOwnershipObservation.js";
|
||||
import type { ProcessTableEntry } from "./ProcessOwnership.js";
|
||||
import {
|
||||
createSystemProcessOwnershipHost,
|
||||
readProcessOwnershipFacts,
|
||||
} from "./ProcessOwnershipObservation.js";
|
||||
import type {
|
||||
ProcessOwnershipHost,
|
||||
ProcessTableEntry,
|
||||
} from "./ProcessOwnership.js";
|
||||
|
||||
const entry: ProcessTableEntry = {
|
||||
pid: 42,
|
||||
@@ -23,6 +29,144 @@ const entry: ProcessTableEntry = {
|
||||
const filesystemError = (code: string): NodeJS.ErrnoException =>
|
||||
Object.assign(new Error(code), { code });
|
||||
|
||||
const procStat = (
|
||||
startTime: number,
|
||||
parentPid = 1,
|
||||
processGroupId = 42,
|
||||
pid = 42,
|
||||
) => {
|
||||
const fields = ["S", String(parentPid), String(processGroupId), "42"];
|
||||
while (fields.length < 19) fields.push("0");
|
||||
fields.push(String(startTime));
|
||||
return `${String(pid)} (fixture) ${fields.join(" ")}`;
|
||||
};
|
||||
|
||||
it("binds Linux effective UID to the same native process generation", async () => {
|
||||
const host = createSystemProcessOwnershipHost("linux", {});
|
||||
filesystem.readFile
|
||||
.mockResolvedValueOnce(procStat(100, 10, 42))
|
||||
.mockResolvedValueOnce("Name:\tfixture\nUid:\t1000\t1001\t1002\t1003\n")
|
||||
.mockResolvedValueOnce(procStat(100, 11, 43));
|
||||
|
||||
await expect(
|
||||
host.processIdentities?.([entry], undefined, new Set([entry.pid])),
|
||||
).resolves.toEqual(
|
||||
new Map([
|
||||
[
|
||||
42,
|
||||
{
|
||||
state: "readable",
|
||||
identity: "linux-starttime:100",
|
||||
effectiveUid: 1001,
|
||||
lifecycle: { state: "observed", lifecycle: "non-zombie" },
|
||||
relation: {
|
||||
state: "observed",
|
||||
parentPid: 11,
|
||||
processGroupId: 43,
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
expect(filesystem.readFile.mock.calls.map(([path]) => path)).toEqual([
|
||||
"/proc/42/stat",
|
||||
"/proc/42/status",
|
||||
"/proc/42/stat",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps native identity when status UID is unavailable", async () => {
|
||||
const host = createSystemProcessOwnershipHost("linux", {});
|
||||
filesystem.readFile
|
||||
.mockResolvedValueOnce(procStat(100))
|
||||
.mockRejectedValueOnce(filesystemError("EACCES"));
|
||||
|
||||
await expect(
|
||||
host.processIdentities?.([entry], undefined, new Set([entry.pid])),
|
||||
).resolves.toEqual(
|
||||
new Map([
|
||||
[
|
||||
42,
|
||||
{
|
||||
state: "readable",
|
||||
identity: "linux-starttime:100",
|
||||
lifecycle: { state: "observed", lifecycle: "non-zombie" },
|
||||
relation: {
|
||||
state: "observed",
|
||||
parentPid: 1,
|
||||
processGroupId: 42,
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
expect(filesystem.readFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not attach a UID when the process generation changes around status", async () => {
|
||||
const host = createSystemProcessOwnershipHost("linux", {});
|
||||
filesystem.readFile
|
||||
.mockResolvedValueOnce(procStat(100))
|
||||
.mockResolvedValueOnce("Uid:\t1000\t1001\t1002\t1003\n")
|
||||
.mockResolvedValueOnce(procStat(101));
|
||||
|
||||
await expect(
|
||||
host.processIdentities?.([entry], undefined, new Set([entry.pid])),
|
||||
).resolves.toEqual(
|
||||
new Map([
|
||||
[
|
||||
42,
|
||||
{
|
||||
state: "unavailable",
|
||||
reason: "process_identity_changed_during_uid_observation",
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads Linux effective UID only for token-unavailable rows", async () => {
|
||||
const rows = [entry, { ...entry, pid: 43, processGroupId: 43 }];
|
||||
const statReads = new Map<number, number>();
|
||||
const uidStatusPids: number[] = [];
|
||||
filesystem.readFile.mockImplementation(async (path) => {
|
||||
const match = /^\/proc\/(\d+)\/(stat|status)$/u.exec(String(path));
|
||||
if (match === null) throw new Error(`unexpected path: ${String(path)}`);
|
||||
const pid = Number(match[1]);
|
||||
if (match[2] === "status") {
|
||||
uidStatusPids.push(pid);
|
||||
return "Uid:\t1000\t1001\t1002\t1003\n";
|
||||
}
|
||||
const reads = (statReads.get(pid) ?? 0) + 1;
|
||||
statReads.set(pid, reads);
|
||||
return procStat(100, 1, pid, pid);
|
||||
});
|
||||
const systemHost = createSystemProcessOwnershipHost("linux", {});
|
||||
const host: ProcessOwnershipHost = {
|
||||
...systemHost,
|
||||
runTokens: async () =>
|
||||
new Map([
|
||||
[entry.pid, { state: "unavailable", reason: "EACCES" }],
|
||||
[43, { state: "readable", runId: undefined }],
|
||||
]),
|
||||
};
|
||||
|
||||
const facts = await readProcessOwnershipFacts(rows, host);
|
||||
|
||||
expect(uidStatusPids).toEqual([entry.pid]);
|
||||
expect(facts.map(({ identityAfter }) => identityAfter)).toMatchObject([
|
||||
{ state: "readable", effectiveUid: 1001 },
|
||||
{ state: "readable" },
|
||||
]);
|
||||
expect(facts[1]?.identityAfter).not.toHaveProperty("effectiveUid");
|
||||
expect(statReads).toEqual(
|
||||
new Map([
|
||||
[entry.pid, 3],
|
||||
[43, 2],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports absence only when /proc lookup and signal-zero both report ESRCH", async () => {
|
||||
const host = createSystemProcessOwnershipHost("linux", {});
|
||||
const signalProbe = vi.spyOn(process, "kill").mockImplementation(() => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import {
|
||||
verifyNoTokenOwnedProcesses,
|
||||
type ProcessIdentityObservation,
|
||||
type ProcessOwnershipHost,
|
||||
type ProcessTableEntry,
|
||||
} from "./ProcessOwnership.js";
|
||||
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
|
||||
|
||||
it.each(["absent", "zombie", "live", "unavailable"] as const)(
|
||||
"settles a baseline candidate against its final native lifetime: %s",
|
||||
async (lifetime) => {
|
||||
const row: ProcessTableEntry = {
|
||||
pid: 910,
|
||||
parentPid: 1,
|
||||
processGroupId: 910,
|
||||
state: "S",
|
||||
command: "baseline-neighbor",
|
||||
};
|
||||
let tokenRead = false;
|
||||
const identity = (): ProcessIdentityObservation => {
|
||||
if (lifetime === "absent")
|
||||
return { state: "absent", reason: "not-in-snapshot" };
|
||||
if (lifetime === "unavailable")
|
||||
return { state: "unavailable", reason: "native identity denied" };
|
||||
return {
|
||||
state: "readable",
|
||||
identity: "new-generation",
|
||||
lifecycle: {
|
||||
state: "observed",
|
||||
lifecycle:
|
||||
tokenRead && lifetime === "zombie" ? "zombie" : "non-zombie",
|
||||
},
|
||||
relation: { state: "observed", parentPid: 1, processGroupId: 910 },
|
||||
};
|
||||
};
|
||||
const signalGroup = vi.fn();
|
||||
const host: ProcessOwnershipHost = {
|
||||
platform: "darwin",
|
||||
listProcesses: async () => [row],
|
||||
processIdentities: async (members) =>
|
||||
new Map(members.map(({ pid }) => [pid, identity()])),
|
||||
runTokens: async (members) => {
|
||||
tokenRead = true;
|
||||
return new Map(
|
||||
members.map(({ pid }) => [
|
||||
pid,
|
||||
{ state: "unavailable" as const, reason: "EACCES" },
|
||||
]),
|
||||
);
|
||||
},
|
||||
environment: async () => ({}),
|
||||
signalGroup,
|
||||
};
|
||||
const result = await verifyNoTokenOwnedProcesses(
|
||||
"run",
|
||||
host,
|
||||
[{ pid: row.pid, identity: "baseline-generation" }],
|
||||
{
|
||||
leaderPid: 100,
|
||||
processGroupId: 100,
|
||||
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
|
||||
},
|
||||
);
|
||||
if (lifetime === "absent" || lifetime === "zombie")
|
||||
expect(result).toEqual({ cleaned: true, signaled: false });
|
||||
else
|
||||
expect(result).toMatchObject({
|
||||
cleaned: false,
|
||||
failures: [{ pid: row.pid }],
|
||||
});
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
errorMessage,
|
||||
readNativeProcessIdentityMap,
|
||||
readProcessOwnershipFacts,
|
||||
settleExitingProcessIdentities,
|
||||
systemProcessOwnershipHost as systemHost,
|
||||
} from "./ProcessOwnershipObservation.js";
|
||||
import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
|
||||
@@ -15,10 +16,15 @@ import { readProcessRunTokens } from "./ProcessRunTokenObservations.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
import {
|
||||
PROCESS_CLEANUP_VERIFICATION_GRACE_MS,
|
||||
PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS,
|
||||
processIdentityHasExited,
|
||||
processIdentityIsExiting,
|
||||
type ProcessLifecycleObservation,
|
||||
} from "./ProcessLifecycle.js";
|
||||
|
||||
const PROCESS_CLEANUP_VERIFICATION_GRACE_MS = 1_000;
|
||||
const PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS = 25;
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Shared fields for a process group acquired by a resource owner. */
|
||||
interface OwnedProcessGroupFacts {
|
||||
@@ -99,13 +105,7 @@ export type ProcessIdentityObservation =
|
||||
}
|
||||
| { readonly state: "unavailable"; readonly reason: string };
|
||||
|
||||
/** Native lifecycle fact observed alongside (but independent of) start identity. */
|
||||
export type ProcessLifecycleObservation =
|
||||
| {
|
||||
readonly state: "observed";
|
||||
readonly lifecycle: "non-zombie" | "zombie";
|
||||
}
|
||||
| { readonly state: "unavailable"; readonly reason: string };
|
||||
export type { ProcessLifecycleObservation } from "./ProcessLifecycle.js";
|
||||
|
||||
/** Parent and process-group membership from the same native identity read. */
|
||||
export type ProcessNativeRelationObservation =
|
||||
@@ -180,6 +180,8 @@ export interface ProcessOwnershipHost {
|
||||
processIdentities?(
|
||||
processes: readonly ProcessTableEntry[],
|
||||
signal?: AbortSignal,
|
||||
/** Linux UID enrichment is selective; omitted means UID remains unknown. */
|
||||
effectiveUidPids?: ReadonlySet<number>,
|
||||
): Promise<ReadonlyMap<number, ProcessIdentityObservation>>;
|
||||
/** Snapshot identities before launch, honoring startup cancellation. */
|
||||
captureBaseline?(signal?: AbortSignal): Promise<ProcessOwnershipBaseline>;
|
||||
@@ -524,12 +526,7 @@ const readProcessRelationSnapshot = async (
|
||||
const failures: ProcessOwnershipValidationFailure[] = [];
|
||||
for (const process of processes) {
|
||||
const identity = identities.get(process.pid);
|
||||
if (
|
||||
identity?.state === "readable" &&
|
||||
identity.lifecycle?.state === "observed" &&
|
||||
identity.lifecycle.lifecycle === "zombie"
|
||||
)
|
||||
continue;
|
||||
if (processIdentityHasExited(identity)) continue;
|
||||
if (
|
||||
identity?.state === "readable" &&
|
||||
identity.relation?.state === "observed"
|
||||
@@ -552,8 +549,8 @@ const readProcessRelationSnapshot = async (
|
||||
};
|
||||
|
||||
// A process-table read can race exit and retain the old state with an altered
|
||||
// command. Only a fresh table proving that PID is no longer live can settle
|
||||
// that mismatch; surviving group members still require normal token checks.
|
||||
// command. Native terminal facts can settle that mismatch; exit-in-progress
|
||||
// must be confirmed first. Surviving members still require normal token checks.
|
||||
const settleLauncherExit = async (
|
||||
ownership: OwnedProcessGroup,
|
||||
processes: readonly ProcessTableEntry[],
|
||||
@@ -566,15 +563,32 @@ const settleLauncherExit = async (
|
||||
if (launcher === undefined) return processes;
|
||||
let identityCheckedLauncher = launcher;
|
||||
if (host.processIdentities !== undefined) {
|
||||
const identities = await readNativeProcessIdentityMap([launcher], host);
|
||||
const initialIdentities = await readNativeProcessIdentityMap(
|
||||
[launcher],
|
||||
host,
|
||||
);
|
||||
const identities = await settleExitingProcessIdentities(
|
||||
[launcher],
|
||||
host,
|
||||
initialIdentities,
|
||||
);
|
||||
const identity = identities.get(launcher.pid);
|
||||
if (identity?.state === "absent")
|
||||
return processes.filter(({ pid }) => pid !== launcher.pid);
|
||||
const initialIdentity = initialIdentities.get(launcher.pid);
|
||||
if (
|
||||
initialIdentity?.state === "readable" &&
|
||||
identity?.state === "readable" &&
|
||||
identity.lifecycle?.state === "observed" &&
|
||||
identity.lifecycle.lifecycle === "zombie"
|
||||
initialIdentity.identity !== identity.identity
|
||||
)
|
||||
return {
|
||||
cleaned: false,
|
||||
reason: "owned launcher identity changed while verifying exit",
|
||||
};
|
||||
if (processIdentityIsExiting(identity))
|
||||
return {
|
||||
cleaned: false,
|
||||
reason: "owned launcher exit could not be confirmed",
|
||||
};
|
||||
if (processIdentityHasExited(identity))
|
||||
return processes.filter(({ pid }) => pid !== launcher.pid);
|
||||
if (
|
||||
identity?.state !== "readable" ||
|
||||
@@ -973,13 +987,16 @@ const processOwnershipFailures = async (
|
||||
const facts = await readProcessOwnershipFacts(members, host);
|
||||
const failures: ProcessOwnershipValidationFailure[] = [];
|
||||
for (const fact of facts) {
|
||||
if (fact.identityAfter.state === "absent") continue;
|
||||
if (
|
||||
fact.identityAfter.state === "readable" &&
|
||||
fact.identityAfter.lifecycle?.state === "observed" &&
|
||||
fact.identityAfter.lifecycle.lifecycle === "zombie"
|
||||
)
|
||||
if (processIdentityHasExited(fact.identityAfter)) continue;
|
||||
if (processIdentityIsExiting(fact.identityAfter)) {
|
||||
failures.push({
|
||||
pid: fact.process.pid,
|
||||
reason: "process-identity-unavailable",
|
||||
diagnostic:
|
||||
"native process exit could not be confirmed before signal",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const relation =
|
||||
fact.identityAfter.state === "readable" &&
|
||||
fact.identityAfter.relation?.state === "observed"
|
||||
@@ -1291,9 +1308,8 @@ const scanWithOwnershipEvidence = async (
|
||||
const before = baseline.get(process.pid);
|
||||
const now = firstIdentities.get(process.pid);
|
||||
if (before === null || now?.state !== "readable") {
|
||||
// Baseline identity only selects rows for current validation. Let the
|
||||
// owning fact pass distinguish confirmed absence, a current foreign
|
||||
// generation and unresolved identity; a baseline miss is not a failure.
|
||||
// The baseline selects candidates; current native facts and retained
|
||||
// ownership evidence decide identity, liveness and applicability.
|
||||
return true;
|
||||
}
|
||||
return now.identity !== before;
|
||||
@@ -1310,19 +1326,17 @@ const scanWithOwnershipEvidence = async (
|
||||
},
|
||||
);
|
||||
let facts = initialFacts;
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < TOKEN_READ_RETRY_ATTEMPTS &&
|
||||
facts.some(({ token }) => token.state === "unavailable");
|
||||
attempt += 1
|
||||
) {
|
||||
for (let attempt = 0; attempt < TOKEN_READ_RETRY_ATTEMPTS; attempt += 1) {
|
||||
const retryRows = facts
|
||||
.filter(
|
||||
({ token, identityAfter }) =>
|
||||
token.state === "unavailable" &&
|
||||
!processIdentityHasExited(identityAfter) &&
|
||||
!processIdentityIsExiting(identityAfter),
|
||||
)
|
||||
.map(({ process }) => process);
|
||||
if (retryRows.length === 0) break;
|
||||
await delay(TOKEN_READ_RETRY_DELAY_MS);
|
||||
const pendingPids = new Set(
|
||||
facts
|
||||
.filter(({ token }) => token.state === "unavailable")
|
||||
.map(({ process }) => process.pid),
|
||||
);
|
||||
const retryRows = candidates.filter(({ pid }) => pendingPids.has(pid));
|
||||
const retries = await readProcessOwnershipFacts(retryRows, host);
|
||||
const retryByPid = new Map(retries.map((fact) => [fact.process.pid, fact]));
|
||||
facts = facts.map((fact) => retryByPid.get(fact.process.pid) ?? fact);
|
||||
@@ -1350,16 +1364,18 @@ const scanWithOwnershipEvidence = async (
|
||||
const identities = new Map<number, string | null>();
|
||||
|
||||
for (const fact of facts) {
|
||||
// The table row and baseline predate the final native observation. An
|
||||
// exited candidate needs no ownership proof or signal, even when its
|
||||
// token became unreadable while exiting. Unknown native state stays a
|
||||
// cleanup failure; table omission alone is never proof of exit.
|
||||
if (processIdentityHasExited(fact.identityAfter)) continue;
|
||||
if (fact.state === "replaced")
|
||||
failures.push({
|
||||
pid: fact.process.pid,
|
||||
reason: "process-identity-unavailable",
|
||||
diagnostic: "process identity changed during token validation",
|
||||
});
|
||||
else if (
|
||||
fact.state === "unavailable" &&
|
||||
fact.identityAfter.state !== "absent"
|
||||
)
|
||||
else if (fact.state === "unavailable")
|
||||
failures.push({
|
||||
pid: fact.process.pid,
|
||||
reason: "process-identity-unavailable",
|
||||
@@ -1378,7 +1394,6 @@ const scanWithOwnershipEvidence = async (
|
||||
reason: "run-token-mismatch",
|
||||
});
|
||||
if (fact.token.state !== "unavailable") continue;
|
||||
if (fact.identityAfter.state === "absent") continue;
|
||||
const relationBefore =
|
||||
fact.identityBefore.state === "readable"
|
||||
? fact.identityBefore.relation
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { processIdentityHasExited } from "./ProcessLifecycle.js";
|
||||
import type {
|
||||
ProcessIdentityObservation,
|
||||
ProcessNativeRelationObservation,
|
||||
@@ -208,9 +209,7 @@ export class ProcessOwnershipEvidenceOwner {
|
||||
fact.identityAfter.state === "readable" &&
|
||||
fact.identityAfter.identity !== previous.identity) ||
|
||||
(fact.state === "same-generation" &&
|
||||
fact.identityAfter.state === "readable" &&
|
||||
fact.identityAfter.lifecycle?.state === "observed" &&
|
||||
fact.identityAfter.lifecycle.lifecycle === "zombie")
|
||||
processIdentityHasExited(fact.identityAfter))
|
||||
) {
|
||||
this.#candidates.delete(pid);
|
||||
retiredPids.add(pid);
|
||||
@@ -274,9 +273,7 @@ export class ProcessOwnershipEvidenceOwner {
|
||||
(retiredPids.has(pid) && fact.state !== "same-generation") ||
|
||||
fact.state === "absent" ||
|
||||
(fact.state === "same-generation" &&
|
||||
fact.identityAfter.state === "readable" &&
|
||||
fact.identityAfter.lifecycle?.state === "observed" &&
|
||||
fact.identityAfter.lifecycle.lifecycle === "zombie")
|
||||
processIdentityHasExited(fact.identityAfter))
|
||||
)
|
||||
continue;
|
||||
const identity = fact.identityAfter;
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import {
|
||||
processIdentityHasExited,
|
||||
processIdentityIsExiting,
|
||||
PROCESS_CLEANUP_VERIFICATION_GRACE_MS,
|
||||
PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS,
|
||||
} from "./ProcessLifecycle.js";
|
||||
import { parseLinuxProcessStat } from "./LinuxProcessStat.js";
|
||||
import { parseLinuxProcessEffectiveUid } from "./LinuxProcessStatus.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
||||
|
||||
import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
|
||||
@@ -62,11 +71,7 @@ export const observeOwnedProcessGroups = async (
|
||||
if (retainedPids.has(process.pid)) return true;
|
||||
const identity = firstIdentities.get(process.pid);
|
||||
if (identity?.state === "readable") {
|
||||
if (
|
||||
identity.lifecycle?.state === "observed" &&
|
||||
identity.lifecycle.lifecycle === "zombie"
|
||||
)
|
||||
return false;
|
||||
if (processIdentityHasExited(identity)) return false;
|
||||
if (
|
||||
identity.relation?.state === "observed" &&
|
||||
groupIds.includes(identity.relation.processGroupId)
|
||||
@@ -115,12 +120,7 @@ export const observeOwnedProcessGroups = async (
|
||||
fact.identityAfter.relation?.state === "observed"
|
||||
? fact.identityAfter.relation.processGroupId
|
||||
: undefined;
|
||||
if (
|
||||
fact.identityAfter.state === "readable" &&
|
||||
fact.identityAfter.lifecycle?.state === "observed" &&
|
||||
fact.identityAfter.lifecycle.lifecycle === "zombie"
|
||||
)
|
||||
continue;
|
||||
if (processIdentityHasExited(fact.identityAfter)) continue;
|
||||
const candidateGroups = groupIds.filter(
|
||||
(groupId) =>
|
||||
nativeGroupId === groupId ||
|
||||
@@ -300,10 +300,7 @@ const observeNativeOwnedProcessLineage = async (
|
||||
ownership,
|
||||
`owned launcher identity could not be read: ${launcherIdentity?.reason ?? "identity observation was omitted"}`,
|
||||
);
|
||||
if (
|
||||
launcherIdentity.lifecycle?.state === "observed" &&
|
||||
launcherIdentity.lifecycle.lifecycle === "zombie"
|
||||
)
|
||||
if (processIdentityHasExited(launcherIdentity))
|
||||
return unavailableLineage(ownership, "owned launcher is not live");
|
||||
const nativeLauncher = processWithNativeRelation(launcher, launcherIdentity);
|
||||
if (nativeLauncher === undefined)
|
||||
@@ -317,12 +314,7 @@ const observeNativeOwnedProcessLineage = async (
|
||||
|
||||
const nativeProcesses = processes.flatMap((process) => {
|
||||
const identity = initialIdentities.get(process.pid);
|
||||
if (
|
||||
identity?.state === "readable" &&
|
||||
identity.lifecycle?.state === "observed" &&
|
||||
identity.lifecycle.lifecycle === "zombie"
|
||||
)
|
||||
return [];
|
||||
if (processIdentityHasExited(identity)) return [];
|
||||
const nativeProcess =
|
||||
identity?.state === "readable"
|
||||
? processWithNativeRelation(process, identity)
|
||||
@@ -370,11 +362,7 @@ const observeNativeOwnedProcessLineage = async (
|
||||
ownership,
|
||||
`process identity changed or became unavailable during lineage validation for PID ${String(member.pid)}`,
|
||||
);
|
||||
if (
|
||||
fact.identityAfter.state === "readable" &&
|
||||
fact.identityAfter.lifecycle?.state === "observed" &&
|
||||
fact.identityAfter.lifecycle.lifecycle === "zombie"
|
||||
) {
|
||||
if (processIdentityHasExited(fact.identityAfter)) {
|
||||
if (member.pid === ownership.leaderPid)
|
||||
return unavailableLineage(
|
||||
ownership,
|
||||
@@ -498,6 +486,7 @@ const readIdentityFacts = async (
|
||||
host: ProcessOwnershipHost,
|
||||
phase: "before" | "after",
|
||||
signal?: AbortSignal,
|
||||
effectiveUidPids?: ReadonlySet<number>,
|
||||
): Promise<ReadonlyMap<number, ProcessIdentityObservation>> => {
|
||||
signal?.throwIfAborted();
|
||||
if (host.processIdentities === undefined)
|
||||
@@ -511,10 +500,11 @@ const readIdentityFacts = async (
|
||||
]),
|
||||
);
|
||||
try {
|
||||
const observations =
|
||||
signal === undefined
|
||||
? await host.processIdentities(processes)
|
||||
: await host.processIdentities(processes, signal);
|
||||
const observations = await host.processIdentities(
|
||||
processes,
|
||||
signal,
|
||||
effectiveUidPids,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return new Map<number, ProcessIdentityObservation>(
|
||||
processes.map(({ pid }) => [pid, identityFact(observations, pid, phase)]),
|
||||
@@ -539,6 +529,55 @@ export const readNativeProcessIdentityMap = async (
|
||||
): Promise<ReadonlyMap<number, ProcessIdentityObservation>> =>
|
||||
readIdentityFacts(processes, host, "before", signal);
|
||||
|
||||
/**
|
||||
* Wait only for positively observed native exit-in-progress. Never infer exit
|
||||
* from EACCES or ps text, enumerate the process table, or follow a reused PID.
|
||||
* A deadline, unavailable read or changed generation remains explicit evidence.
|
||||
*/
|
||||
export const settleExitingProcessIdentities = async (
|
||||
processes: readonly ProcessTableEntry[],
|
||||
host: ProcessOwnershipHost,
|
||||
initial: ReadonlyMap<number, ProcessIdentityObservation>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReadonlyMap<number, ProcessIdentityObservation>> => {
|
||||
signal?.throwIfAborted();
|
||||
let pending = processes.filter(({ pid }) =>
|
||||
processIdentityIsExiting(initial.get(pid)),
|
||||
);
|
||||
if (pending.length === 0) return initial;
|
||||
const identities = new Map(initial);
|
||||
const deadline = performance.now() + PROCESS_CLEANUP_VERIFICATION_GRACE_MS;
|
||||
while (pending.length > 0) {
|
||||
const remaining = deadline - performance.now();
|
||||
if (remaining <= 0) break;
|
||||
try {
|
||||
await delay(
|
||||
Math.min(PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS, remaining),
|
||||
undefined,
|
||||
signal === undefined ? undefined : { signal },
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
throw cause;
|
||||
}
|
||||
const refreshed = await readNativeProcessIdentityMap(pending, host, signal);
|
||||
for (const { pid } of pending)
|
||||
identities.set(pid, identityFact(refreshed, pid, "after"));
|
||||
pending = pending.filter(({ pid }) => {
|
||||
const first = initial.get(pid);
|
||||
const current = identities.get(pid);
|
||||
return (
|
||||
first?.state === "readable" &&
|
||||
current?.state === "readable" &&
|
||||
processIdentityIsExiting(current) &&
|
||||
current.identity === first.identity
|
||||
);
|
||||
});
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return identities;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read identity, token, then identity for caller-provided rows. This owner
|
||||
* never enumerates the process table; a token is tied to a generation only
|
||||
@@ -583,10 +622,15 @@ export const readProcessOwnershipFacts = async (
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const identityAfter = await readIdentityFacts(
|
||||
const effectiveUidPids = new Set(
|
||||
processes
|
||||
.filter(({ pid }) => tokens.get(pid)?.state !== "readable")
|
||||
.map(({ pid }) => pid),
|
||||
);
|
||||
const identityAfter = await settleExitingProcessIdentities(
|
||||
processes,
|
||||
host,
|
||||
"after",
|
||||
await readIdentityFacts(processes, host, "after", signal, effectiveUidPids),
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
@@ -699,7 +743,7 @@ export const createSystemProcessOwnershipHost = (
|
||||
};
|
||||
const processIdentities: NonNullable<
|
||||
ProcessOwnershipHost["processIdentities"]
|
||||
> = async (processes, signal) => {
|
||||
> = async (processes, signal, effectiveUidPids) => {
|
||||
if (processes.length === 0) return new Map();
|
||||
if (platform === "linux")
|
||||
return new Map<number, ProcessIdentityObservation>(
|
||||
@@ -716,27 +760,84 @@ export const createSystemProcessOwnershipHost = (
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
const parsed = parseLinuxProcessStat(stat);
|
||||
return [
|
||||
pid,
|
||||
parsed === undefined || parsed.pid !== pid
|
||||
? ({
|
||||
state: "unavailable",
|
||||
reason: "malformed_proc_stat",
|
||||
} as const)
|
||||
: ({
|
||||
state: "readable",
|
||||
identity: `linux-starttime:${parsed.startTime}`,
|
||||
lifecycle: {
|
||||
state: "observed",
|
||||
lifecycle: parsed.lifecycle,
|
||||
if (parsed === undefined || parsed.pid !== pid)
|
||||
return [
|
||||
pid,
|
||||
{
|
||||
state: "unavailable",
|
||||
reason: "malformed_proc_stat",
|
||||
} as const,
|
||||
] as const;
|
||||
|
||||
const readableIdentity = (
|
||||
facts: typeof parsed,
|
||||
effectiveUid?: number,
|
||||
) => ({
|
||||
state: "readable" as const,
|
||||
identity: `linux-starttime:${facts.startTime}`,
|
||||
...(effectiveUid === undefined ? {} : { effectiveUid }),
|
||||
lifecycle:
|
||||
facts.lifecycle === undefined
|
||||
? {
|
||||
state: "unavailable" as const,
|
||||
reason: "malformed_proc_flags",
|
||||
}
|
||||
: {
|
||||
state: "observed" as const,
|
||||
lifecycle: facts.lifecycle,
|
||||
},
|
||||
relation: {
|
||||
state: "observed",
|
||||
parentPid: parsed.parentPid,
|
||||
processGroupId: parsed.processGroupId,
|
||||
},
|
||||
} as const),
|
||||
] as const;
|
||||
relation: {
|
||||
state: "observed" as const,
|
||||
parentPid: facts.parentPid,
|
||||
processGroupId: facts.processGroupId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!effectiveUidPids?.has(pid))
|
||||
return [pid, readableIdentity(parsed)] as const;
|
||||
|
||||
let status: string;
|
||||
try {
|
||||
status = await readFile(`/proc/${String(pid)}/status`, {
|
||||
encoding: "utf8",
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (isExpectedAbort(cause, signal)) throw cause;
|
||||
return [pid, readableIdentity(parsed)] as const;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const effectiveUid = parseLinuxProcessEffectiveUid(status);
|
||||
if (effectiveUid === undefined)
|
||||
return [pid, readableIdentity(parsed)] as const;
|
||||
|
||||
let afterStatus: string;
|
||||
try {
|
||||
afterStatus = await readFile(`/proc/${String(pid)}/stat`, {
|
||||
encoding: "utf8",
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (isExpectedAbort(cause, signal)) throw cause;
|
||||
return [pid, readableIdentity(parsed)] as const;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const verified = parseLinuxProcessStat(afterStatus);
|
||||
if (
|
||||
verified !== undefined &&
|
||||
verified.pid === pid &&
|
||||
verified.startTime !== parsed.startTime
|
||||
)
|
||||
return [
|
||||
pid,
|
||||
{
|
||||
state: "unavailable",
|
||||
reason: "process_identity_changed_during_uid_observation",
|
||||
} as const,
|
||||
] as const;
|
||||
if (verified === undefined || verified.pid !== pid)
|
||||
return [pid, readableIdentity(parsed)] as const;
|
||||
return [pid, readableIdentity(verified, effectiveUid)] as const;
|
||||
} catch (cause: unknown) {
|
||||
if (isExpectedAbort(cause, signal)) throw cause;
|
||||
if (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
import {
|
||||
McpServer,
|
||||
isCallToolResult,
|
||||
isJSONRPCRequest,
|
||||
isJSONRPCNotification,
|
||||
@@ -40,6 +39,7 @@ import {
|
||||
measureMcpResultWithinBudget,
|
||||
TARGET_CONTENT_RESULT_META,
|
||||
} from "./toolResultEncoding.js";
|
||||
import { RequestTrackingMcpServer } from "./RequestTrackingMcpServer.js";
|
||||
|
||||
export type { CompactInputSchemaPresentation } from "../contracts/compactInputSchema.js";
|
||||
|
||||
@@ -104,7 +104,7 @@ export const forgetPendingMcpRequest = (
|
||||
};
|
||||
|
||||
/** Bind oversized MCP error recovery to this server's Evidence ledger. */
|
||||
export class EvidenceMcpServer extends McpServer {
|
||||
export class EvidenceMcpServer extends RequestTrackingMcpServer {
|
||||
readonly catalogIdentity: ReturnType<typeof createCatalogIdentity>;
|
||||
private readonly compactPresentation:
|
||||
| CompactInputSchemaPresentation
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/server";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { RequestTrackingMcpServer } from "./RequestTrackingMcpServer.js";
|
||||
|
||||
it.each([false, true])(
|
||||
"preserves context-only callbacks with HTTP tracking enabled=%s",
|
||||
async (tracking) => {
|
||||
const server = new RequestTrackingMcpServer({
|
||||
name: "context-only",
|
||||
version: "1",
|
||||
});
|
||||
if (tracking) server.enableRequestTracking();
|
||||
server.registerTool("context", {}, (context) => ({
|
||||
content: [{ type: "text", text: String(context.mcpReq.signal.aborted) }],
|
||||
}));
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const client = new Client({ name: "context-client", version: "1" });
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
expect((await client.callTool({ name: "context" })).content).toEqual([
|
||||
{ type: "text", text: "false" },
|
||||
]);
|
||||
expect(await server.drainRequests(0)).toBe(true);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("never starts a tool whose asynchronous input validation finishes after transport cancellation", async () => {
|
||||
let release!: () => void;
|
||||
let entered!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const validating = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
let calls = 0;
|
||||
const server = new RequestTrackingMcpServer({
|
||||
name: "validation-race",
|
||||
version: "1",
|
||||
});
|
||||
server.enableRequestTracking();
|
||||
server.registerTool(
|
||||
"slow_validation",
|
||||
{
|
||||
inputSchema: z.object({
|
||||
value: z.string().refine(async () => {
|
||||
entered();
|
||||
await gate;
|
||||
return true;
|
||||
}),
|
||||
}),
|
||||
},
|
||||
() => {
|
||||
calls += 1;
|
||||
return { content: [] };
|
||||
},
|
||||
);
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const client = new Client({ name: "validation-client", version: "1" });
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
void client
|
||||
.callTool({ name: "slow_validation", arguments: { value: "x" } })
|
||||
.catch(() => undefined);
|
||||
await validating;
|
||||
await server.close();
|
||||
expect(await server.drainRequests(0)).toBe(true);
|
||||
release();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(calls).toBe(0);
|
||||
expect(await server.drainRequests(0)).toBe(true);
|
||||
} finally {
|
||||
release();
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
McpServer,
|
||||
type Icon,
|
||||
type RegisteredTool,
|
||||
type ServerContext,
|
||||
type ScopeChallengeHandler,
|
||||
type StandardSchemaWithJSON,
|
||||
type ToolAnnotations,
|
||||
type ToolCallback,
|
||||
} from "@modelcontextprotocol/server";
|
||||
|
||||
/** Tracks tools automatically; other resource-using MCP callbacks use runRequest. */
|
||||
export class RequestTrackingMcpServer extends McpServer {
|
||||
readonly #calls = new Set<Promise<unknown>>();
|
||||
#tracking = false;
|
||||
|
||||
/** HTTP sessions share resources across transports; stdio keeps the SDK's original behavior. */
|
||||
enableRequestTracking(): void {
|
||||
this.#tracking = true;
|
||||
}
|
||||
|
||||
override registerTool<
|
||||
OutputArgs extends StandardSchemaWithJSON,
|
||||
InputArgs extends StandardSchemaWithJSON | undefined = undefined,
|
||||
>(
|
||||
name: string,
|
||||
config: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
inputSchema?: InputArgs;
|
||||
outputSchema?: OutputArgs;
|
||||
annotations?: ToolAnnotations;
|
||||
icons?: Icon[];
|
||||
scopeChallenge?: ScopeChallengeHandler;
|
||||
_meta?: Record<string, unknown>;
|
||||
},
|
||||
callback: ToolCallback<InputArgs>,
|
||||
): RegisteredTool {
|
||||
// The SDK calls a schema-less callback with context alone, and a schema'd
|
||||
// callback with (validated input, context). Narrow its conditional type at
|
||||
// the same inputSchema discriminant used by the SDK.
|
||||
const tracked =
|
||||
config.inputSchema === undefined
|
||||
? (context: ServerContext) =>
|
||||
this.runRequest(context.mcpReq.signal, () =>
|
||||
(callback as ToolCallback<undefined>)(context),
|
||||
)
|
||||
: (args: unknown, context: ServerContext) =>
|
||||
this.runRequest(context.mcpReq.signal, () =>
|
||||
(callback as ToolCallback<StandardSchemaWithJSON>)(args, context),
|
||||
);
|
||||
return super.registerTool<OutputArgs, InputArgs>(
|
||||
name,
|
||||
config,
|
||||
tracked as ToolCallback<InputArgs>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Retain HTTP dispatch, tool and completion work under the HTTP owner's shared drain. */
|
||||
runRequest<T>(
|
||||
signal: AbortSignal | undefined,
|
||||
callback: () => T | Promise<T>,
|
||||
): T | Promise<T> {
|
||||
if (!this.#tracking) return callback();
|
||||
// SDK validation can finish after its transport closed. Such a callback
|
||||
// must never start work against the next session's shared resources.
|
||||
const call = Promise.resolve().then(() => {
|
||||
signal?.throwIfAborted();
|
||||
return callback();
|
||||
});
|
||||
this.#calls.add(call);
|
||||
return call.finally(() => this.#calls.delete(call));
|
||||
}
|
||||
|
||||
/** Wait for running handlers, including their finally blocks; a timeout retains ownership. */
|
||||
async drainRequests(timeoutMs?: number): Promise<boolean> {
|
||||
if (this.#calls.size === 0) return true;
|
||||
const settled = Promise.allSettled(this.#calls).then(() => true);
|
||||
if (timeoutMs === undefined) return settled;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
settled,
|
||||
new Promise<false>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import { writeWebResponse } from "./nodeHttpBridge.js";
|
||||
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
|
||||
|
||||
export type HttpAdmissionLifetime = "dispatch" | "response";
|
||||
|
||||
export interface NodeHttpExchangeContext {
|
||||
readonly onDispatchSettled: (state: {
|
||||
readonly hasUnreadBody: boolean;
|
||||
readonly admissionLifetime: HttpAdmissionLifetime;
|
||||
}) => void;
|
||||
readonly onBodyCleanup: (cleanup: Promise<void>) => void;
|
||||
}
|
||||
|
||||
interface NodeHttpRequestTarget {
|
||||
readonly serve: (
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
context: NodeHttpExchangeContext,
|
||||
) => Promise<void>;
|
||||
readonly refuse: (reason: "capacity" | "cleanup") => Response;
|
||||
}
|
||||
|
||||
/** Own admitted exchanges and separately joinable response-source cleanup. */
|
||||
export class NodeHttpRequestOwner {
|
||||
#active = 0;
|
||||
readonly #exchanges = new Set<Promise<void>>();
|
||||
readonly #bodyCleanups = new Set<Promise<void>>();
|
||||
readonly #cleanupFailures: unknown[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly capacity: number,
|
||||
private readonly target: NodeHttpRequestTarget,
|
||||
) {
|
||||
if (!Number.isSafeInteger(capacity) || capacity < 1)
|
||||
throw new RangeError(
|
||||
"maxConcurrentDispatches must be a positive safe integer",
|
||||
);
|
||||
}
|
||||
|
||||
handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
// Node queues a pipelined response without assigning a socket until its
|
||||
// predecessor finishes. Never start work or queue a refusal behind it.
|
||||
if (request.socket.destroyed || response.socket === null) {
|
||||
request.socket.destroy();
|
||||
return Promise.resolve();
|
||||
}
|
||||
const refusal =
|
||||
this.#cleanupFailures.length > 0
|
||||
? "cleanup"
|
||||
: this.#active >= this.capacity
|
||||
? "capacity"
|
||||
: undefined;
|
||||
if (refusal !== undefined) {
|
||||
return this.#trackExchange(async () => {
|
||||
try {
|
||||
await writeWebResponse(this.target.refuse(refusal), response, {
|
||||
closeConnection: true,
|
||||
onBodyCleanup: this.#trackBodyCleanup,
|
||||
});
|
||||
} catch {
|
||||
response.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
this.#active += 1;
|
||||
let reserved = true;
|
||||
let dispatched = false;
|
||||
let inputDone = false;
|
||||
let responseDone = false;
|
||||
let holdResponse = true;
|
||||
let pendingCleanups = 0;
|
||||
let cleanupFailed = false;
|
||||
const release = (): void => {
|
||||
if (!reserved) return;
|
||||
reserved = false;
|
||||
this.#active -= 1;
|
||||
request.off("end", inputEnded);
|
||||
request.off("close", inputEnded);
|
||||
response.off("close", inputEnded);
|
||||
};
|
||||
const releaseIfReady = (): void => {
|
||||
if (
|
||||
dispatched &&
|
||||
inputDone &&
|
||||
(!holdResponse ||
|
||||
(responseDone && pendingCleanups === 0 && !cleanupFailed))
|
||||
)
|
||||
release();
|
||||
};
|
||||
const inputEnded = (): void => {
|
||||
inputDone = true;
|
||||
releaseIfReady();
|
||||
};
|
||||
return this.#trackExchange(async () => {
|
||||
if (request.socket.destroyed || response.destroyed) {
|
||||
release();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.target.serve(request, response, {
|
||||
onBodyCleanup: (cleanup) => {
|
||||
pendingCleanups += 1;
|
||||
this.#trackBodyCleanup(cleanup, (succeeded) => {
|
||||
pendingCleanups -= 1;
|
||||
cleanupFailed ||= !succeeded;
|
||||
releaseIfReady();
|
||||
});
|
||||
},
|
||||
onDispatchSettled: ({ hasUnreadBody, admissionLifetime }) => {
|
||||
dispatched = true;
|
||||
holdResponse = admissionLifetime === "response";
|
||||
if (!hasUnreadBody || request.destroyed || response.destroyed) {
|
||||
inputEnded();
|
||||
return;
|
||||
}
|
||||
request.once("end", inputEnded);
|
||||
request.once("close", inputEnded);
|
||||
response.once("close", inputEnded);
|
||||
},
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
response.destroy();
|
||||
release();
|
||||
throw cause;
|
||||
} finally {
|
||||
responseDone = true;
|
||||
releaseIfReady();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#trackExchange(serve: () => Promise<void>): Promise<void> {
|
||||
const exchange = Promise.resolve().then(serve);
|
||||
this.#exchanges.add(exchange);
|
||||
return exchange.finally(() => this.#exchanges.delete(exchange));
|
||||
}
|
||||
|
||||
readonly #trackBodyCleanup = (
|
||||
cleanup: Promise<void>,
|
||||
settled?: (succeeded: boolean) => void,
|
||||
): void => {
|
||||
this.#bodyCleanups.add(cleanup);
|
||||
void cleanup.then(
|
||||
() => {
|
||||
this.#bodyCleanups.delete(cleanup);
|
||||
settled?.(true);
|
||||
},
|
||||
(cause: unknown) => {
|
||||
this.#cleanupFailures.push(cause);
|
||||
this.#bodyCleanups.delete(cleanup);
|
||||
settled?.(false);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
async drain(): Promise<void> {
|
||||
// An exchange can register cancellation after its route has returned,
|
||||
// including when a late Response finds that its client already left.
|
||||
while (this.#exchanges.size > 0)
|
||||
await Promise.allSettled([...this.#exchanges]);
|
||||
while (this.#bodyCleanups.size > 0)
|
||||
await Promise.allSettled([...this.#bodyCleanups]);
|
||||
if (this.#cleanupFailures.length > 0)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
"mcp_http_response_cleanup",
|
||||
"transport",
|
||||
"HTTP response cleanup could not be confirmed; provider shutdown remains blocked",
|
||||
{ failed_cleanup_count: this.#cleanupFailures.length },
|
||||
{
|
||||
cause: new AggregateError(
|
||||
this.#cleanupFailures,
|
||||
"Response cleanup failed",
|
||||
),
|
||||
retryable: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { once } from "node:events";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { connect } from "node:net";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
import { toWebRequest, writeWebResponse } from "./nodeHttpBridge.js";
|
||||
|
||||
const servers: Server[] = [];
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
afterEach(async () => {
|
||||
for (const server of servers.splice(0)) {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(() => resolve(undefined)));
|
||||
}
|
||||
});
|
||||
|
||||
/** Serve a web handler through the bridge on an ephemeral loopback port. */
|
||||
const serve = async (
|
||||
handle: (request: Request) => Response | Promise<Response>,
|
||||
): Promise<string> => {
|
||||
const server = createServer();
|
||||
servers.push(server);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const origin = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}`;
|
||||
server.on("request", (request, response) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const webRequest = toWebRequest(request, response, origin);
|
||||
await writeWebResponse(await handle(webRequest), response);
|
||||
} catch {
|
||||
response.destroy();
|
||||
}
|
||||
})();
|
||||
});
|
||||
return origin;
|
||||
};
|
||||
|
||||
it("ends a HEAD reply after the headers and cancels a body the handler attached", async () => {
|
||||
let pulls = 0;
|
||||
let cancelled = false;
|
||||
const origin = await serve(
|
||||
() =>
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull: async () => {
|
||||
pulls += 1;
|
||||
// Never produces a chunk, like an idle event stream.
|
||||
await new Promise(() => undefined);
|
||||
},
|
||||
cancel: () => {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
);
|
||||
const response = await fetch(`${origin}/events`, { method: "HEAD" });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("text/event-stream");
|
||||
await vi.waitFor(() => {
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
expect(pulls).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("finishes a HEAD reply without waiting on a teed body whose sibling stays live", async () => {
|
||||
const cached = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull: () => new Promise(() => undefined),
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
);
|
||||
let finished = false;
|
||||
const server = createServer((_request, response) => {
|
||||
void writeWebResponse(cached.clone(), response).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
});
|
||||
servers.push(server);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const { port } = server.address() as AddressInfo;
|
||||
const response = await fetch(`http://127.0.0.1:${String(port)}/events`, {
|
||||
method: "HEAD",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
await vi.waitFor(() => {
|
||||
expect(finished).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels the body when an asynchronous route resolves after its client left", async () => {
|
||||
let cancelled = false;
|
||||
let settled = false;
|
||||
let received = false;
|
||||
const server = createServer((_request, response) => {
|
||||
received = true;
|
||||
response.once("close", () => {
|
||||
const idle = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull: () => new Promise(() => undefined),
|
||||
cancel: () => {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const settle = (): void => {
|
||||
settled = true;
|
||||
};
|
||||
void writeWebResponse(idle, response).then(settle, settle);
|
||||
});
|
||||
});
|
||||
servers.push(server);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const { port } = server.address() as AddressInfo;
|
||||
const socket = connect(port, "127.0.0.1");
|
||||
await once(socket, "connect");
|
||||
socket.write("GET /late HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n");
|
||||
await vi.waitFor(() => {
|
||||
expect(received).toBe(true);
|
||||
});
|
||||
socket.destroy();
|
||||
await vi.waitFor(() => {
|
||||
expect(settled).toBe(true);
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns when a client leaves a teed streaming body whose sibling stays live", async () => {
|
||||
const cached = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start: (controller) => {
|
||||
controller.enqueue(encoder.encode("data: first\n\n"));
|
||||
},
|
||||
pull: () => new Promise(() => undefined),
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
);
|
||||
let settled = false;
|
||||
const server = createServer((_request, response) => {
|
||||
const settle = (): void => {
|
||||
settled = true;
|
||||
};
|
||||
void writeWebResponse(cached.clone(), response).then(settle, settle);
|
||||
});
|
||||
servers.push(server);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const { port } = server.address() as AddressInfo;
|
||||
const leave = new AbortController();
|
||||
const response = await fetch(`http://127.0.0.1:${String(port)}/events`, {
|
||||
signal: leave.signal,
|
||||
});
|
||||
if (response.body === null) throw new Error("Expected a streamed body");
|
||||
const first = await response.body.getReader().read();
|
||||
expect(decoder.decode(first.value)).toBe("data: first\n\n");
|
||||
leave.abort();
|
||||
await vi.waitFor(() => {
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards method, URL, headers and the request body as a web Request", async () => {
|
||||
const seen: Request[] = [];
|
||||
const origin = await serve(async (request) => {
|
||||
seen.push(request);
|
||||
return Response.json(
|
||||
{ body: await request.text() },
|
||||
{ headers: { "x-reply": "bridged" } },
|
||||
);
|
||||
});
|
||||
const response = await fetch(`${origin}/mcp?page=2`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-probe": "yes" },
|
||||
body: '{"hello":1}',
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("x-reply")).toBe("bridged");
|
||||
await expect(response.json()).resolves.toEqual({ body: '{"hello":1}' });
|
||||
const request = seen[0];
|
||||
if (request === undefined) throw new Error("Request not bridged");
|
||||
const url = new URL(request.url);
|
||||
expect(request.method).toBe("POST");
|
||||
expect(`${url.pathname}${url.search}`).toBe("/mcp?page=2");
|
||||
expect(request.headers.get("x-probe")).toBe("yes");
|
||||
expect(request.headers.get("host")).toBe(new URL(origin).host);
|
||||
});
|
||||
|
||||
it("forwards GET requests without a body", async () => {
|
||||
const origin = await serve((request) =>
|
||||
Response.json({ bodyless: request.body === null }),
|
||||
);
|
||||
const response = await fetch(`${origin}/mcp`);
|
||||
await expect(response.json()).resolves.toEqual({ bodyless: true });
|
||||
});
|
||||
|
||||
it("streams response chunks as the handler produces them", async () => {
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const origin = await serve(
|
||||
() =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
async start(controller) {
|
||||
controller.enqueue(encoder.encode("event: message\ndata: 1\n\n"));
|
||||
await gate;
|
||||
controller.enqueue(encoder.encode("event: message\ndata: 2\n\n"));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
);
|
||||
const response = await fetch(`${origin}/stream`);
|
||||
expect(response.headers.get("content-type")).toBe("text/event-stream");
|
||||
const reader = response.body?.getReader();
|
||||
if (reader === undefined) throw new Error("Response body missing");
|
||||
const first = await reader.read();
|
||||
expect(decoder.decode(first.value)).toContain("data: 1");
|
||||
release();
|
||||
let rest = "";
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
rest += decoder.decode(value);
|
||||
}
|
||||
expect(rest).toContain("data: 2");
|
||||
});
|
||||
|
||||
it("aborts the web Request and cancels its response body when the client leaves", async () => {
|
||||
let aborted = false;
|
||||
let cancelled = false;
|
||||
const origin = await serve((request) => {
|
||||
request.signal.addEventListener("abort", () => {
|
||||
aborted = true;
|
||||
});
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(": open\n\n"));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
);
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const response = await fetch(`${origin}/stream`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const reader = response.body?.getReader();
|
||||
if (reader === undefined) throw new Error("Response body missing");
|
||||
await reader.read();
|
||||
controller.abort();
|
||||
await vi.waitFor(() => {
|
||||
expect(aborted).toBe(true);
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves every Set-Cookie header of a composed route", async () => {
|
||||
const origin = await serve(() => {
|
||||
const headers = new Headers({ "x-one": "1" });
|
||||
headers.append("set-cookie", "session=a; Path=/; HttpOnly");
|
||||
headers.append("set-cookie", "csrf=b; Path=/");
|
||||
return new Response("ok", { headers });
|
||||
});
|
||||
const response = await fetch(`${origin}/cookies`);
|
||||
expect(response.headers.get("x-one")).toBe("1");
|
||||
expect(response.headers.getSetCookie()).toEqual([
|
||||
"session=a; Path=/; HttpOnly",
|
||||
"csrf=b; Path=/",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { finished } from "node:stream";
|
||||
|
||||
/** Fetch rejects a body on these methods, so their request streams are not forwarded. */
|
||||
const BODYLESS_METHODS: ReadonlySet<string> = new Set(["GET", "HEAD"]);
|
||||
|
||||
/** The request target was not an origin-form path; the bridge serves an origin server only. */
|
||||
export class UnsupportedRequestTargetError extends Error {
|
||||
override readonly name = "UnsupportedRequestTargetError";
|
||||
constructor(target: string) {
|
||||
super(`Unsupported request target ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Join an origin-form target onto the listener's origin verbatim, so `//host/path` stays a path and a path that URL parsing would rewrite (dot segments) is refused rather than routed elsewhere. */
|
||||
const requestUrl = (target: string, origin: string): URL => {
|
||||
if (!target.startsWith("/") || target.includes("#"))
|
||||
throw new UnsupportedRequestTargetError(target);
|
||||
const url = new URL(`${origin}${target}`);
|
||||
const rawPath = target.split("?")[0] ?? "";
|
||||
if (url.pathname !== rawPath) throw new UnsupportedRequestTargetError(target);
|
||||
return url;
|
||||
};
|
||||
|
||||
const toHeaders = (incoming: IncomingMessage["headers"]): Headers => {
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of Object.entries(incoming)) {
|
||||
if (value === undefined) continue;
|
||||
if (Array.isArray(value))
|
||||
for (const entry of value) headers.append(name, entry);
|
||||
else headers.set(name, value);
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
/** Cancelling a body stops reads; the response owner closes an unread upload after replying. */
|
||||
const requestBody = (request: IncomingMessage): ReadableStream<Uint8Array> => {
|
||||
let ended = false;
|
||||
let controller: ReadableStreamDefaultController<Uint8Array>;
|
||||
let cleanup = (): void => undefined;
|
||||
const onData = (chunk: unknown): void => {
|
||||
if (ended) return;
|
||||
if (!Buffer.isBuffer(chunk)) {
|
||||
ended = true;
|
||||
request.pause();
|
||||
request.off("data", onData);
|
||||
controller.error(new TypeError("HTTP request body must be bytes"));
|
||||
return;
|
||||
}
|
||||
controller.enqueue(new Uint8Array(chunk));
|
||||
if ((controller.desiredSize ?? 0) <= 0) request.pause();
|
||||
};
|
||||
return new ReadableStream<Uint8Array>(
|
||||
{
|
||||
start(target) {
|
||||
controller = target;
|
||||
request.pause();
|
||||
cleanup = finished(
|
||||
request,
|
||||
{ readable: true, writable: false },
|
||||
(cause) => {
|
||||
request.off("data", onData);
|
||||
cleanup();
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
if (cause) controller.error(cause);
|
||||
else controller.close();
|
||||
},
|
||||
);
|
||||
request.on("data", onData);
|
||||
},
|
||||
pull() {
|
||||
if (!ended) request.resume();
|
||||
},
|
||||
cancel() {
|
||||
ended = true;
|
||||
request.pause();
|
||||
request.off("data", onData);
|
||||
// Keep finished's error/close ownership until the raw request settles.
|
||||
},
|
||||
},
|
||||
new ByteLengthQueuingStrategy({
|
||||
highWaterMark: request.readableHighWaterMark,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/** Translate a node:http request into a web Request whose signal aborts when the client leaves. */
|
||||
export const toWebRequest = (
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
origin: string,
|
||||
): Request => {
|
||||
const controller = new AbortController();
|
||||
response.once("close", () => {
|
||||
if (!response.writableFinished) controller.abort();
|
||||
});
|
||||
const method = request.method ?? "GET";
|
||||
return new Request(requestUrl(request.url ?? "/", origin), {
|
||||
method,
|
||||
headers: toHeaders(request.headers),
|
||||
signal: controller.signal,
|
||||
...(BODYLESS_METHODS.has(method)
|
||||
? {}
|
||||
: { body: requestBody(request), duplex: "half" }),
|
||||
});
|
||||
};
|
||||
|
||||
/** Header fields for writeHead; repeated Set-Cookie values survive as one array. */
|
||||
const responseHeaders = (
|
||||
headers: Headers,
|
||||
closeConnection: boolean,
|
||||
): Record<string, string | string[]> => {
|
||||
const fields: Record<string, string | string[]> = {};
|
||||
for (const [name, value] of headers)
|
||||
if (name !== "set-cookie") fields[name] = value;
|
||||
const cookies = headers.getSetCookie();
|
||||
if (cookies.length > 0) fields["set-cookie"] = cookies;
|
||||
if (closeConnection) fields.connection = "close";
|
||||
return fields;
|
||||
};
|
||||
|
||||
/** Resolve on the next `drain` or `close`, removing both listeners, so a long stream does not accumulate waiters. */
|
||||
const drainOrClose = (target: ServerResponse): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
if (target.destroyed) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const settle = (): void => {
|
||||
target.off("drain", settle);
|
||||
target.off("close", settle);
|
||||
resolve();
|
||||
};
|
||||
target.once("drain", settle);
|
||||
target.once("close", settle);
|
||||
});
|
||||
|
||||
export interface WebResponseOptions {
|
||||
readonly closeConnection?: boolean;
|
||||
/** Retain cleanup independently of request completion, including pending tee cancellation. */
|
||||
readonly onBodyCleanup?: (cleanup: Promise<void>) => void;
|
||||
}
|
||||
|
||||
const retainCleanup = (
|
||||
cleanup: Promise<void>,
|
||||
options: WebResponseOptions,
|
||||
): void => {
|
||||
options.onBodyCleanup?.(cleanup);
|
||||
// The owner receives the original outcome; standalone writers stay nonblocking.
|
||||
void cleanup.catch(() => undefined);
|
||||
};
|
||||
|
||||
/** Copy with backpressure; request completion does not wait on source cancellation. */
|
||||
const pumpBody = async (
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
target: ServerResponse,
|
||||
cancel: () => void,
|
||||
): Promise<void> => {
|
||||
let left = false;
|
||||
const leave = (): void => {
|
||||
if (left) return;
|
||||
left = true;
|
||||
cancel();
|
||||
};
|
||||
target.once("close", leave);
|
||||
try {
|
||||
if (target.destroyed) {
|
||||
leave();
|
||||
return;
|
||||
}
|
||||
for (
|
||||
let next = await reader.read();
|
||||
!next.done && !left;
|
||||
next = await reader.read()
|
||||
)
|
||||
if (!target.write(next.value) && !left) await drainOrClose(target);
|
||||
} finally {
|
||||
target.off("close", leave);
|
||||
}
|
||||
if (!left) target.end();
|
||||
};
|
||||
|
||||
/** Stream a web Response into node:http, flushing headers and each chunk as it arrives; `closeConnection` ends the socket after this reply. A HEAD reply ends after the headers and cancels any body the route attached. */
|
||||
export const writeWebResponse = async (
|
||||
response: Response,
|
||||
target: ServerResponse,
|
||||
options: WebResponseOptions = {},
|
||||
): Promise<void> => {
|
||||
// One owner spans headers, pumping and completion. The pump borrows its reader,
|
||||
// so every output failure cancels the same source exactly once before release.
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
try {
|
||||
reader = response.body?.getReader();
|
||||
} catch (cause: unknown) {
|
||||
retainCleanup(Promise.reject(cause), options);
|
||||
throw cause;
|
||||
}
|
||||
let cancelled = false;
|
||||
const cancel = (): void => {
|
||||
if (cancelled || reader === undefined) return;
|
||||
cancelled = true;
|
||||
retainCleanup(reader.cancel(), options);
|
||||
};
|
||||
try {
|
||||
if (target.destroyed) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
target.writeHead(
|
||||
response.status,
|
||||
responseHeaders(response.headers, options.closeConnection ?? false),
|
||||
);
|
||||
target.flushHeaders();
|
||||
if (reader === undefined || target.req.method === "HEAD") {
|
||||
target.end();
|
||||
// A tee's sibling may remain caller-owned; delivery does not wait for its cleanup.
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
await pumpBody(reader, target, cancel);
|
||||
} catch (cause: unknown) {
|
||||
cancel();
|
||||
throw cause;
|
||||
} finally {
|
||||
reader?.releaseLock();
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,697 @@
|
||||
import {
|
||||
hostHeaderValidationResponse,
|
||||
isInitializeRequest,
|
||||
isJsonContentType,
|
||||
localhostAllowedHostnames,
|
||||
originValidationResponse,
|
||||
readRequestBody,
|
||||
WebStandardStreamableHTTPServerTransport,
|
||||
type McpServer,
|
||||
type McpRequestContext,
|
||||
type Server,
|
||||
} from "@modelcontextprotocol/server";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import {
|
||||
createServer as createHttpServer,
|
||||
type IncomingMessage,
|
||||
type ServerResponse,
|
||||
} from "node:http";
|
||||
import { isIPv4, isIPv6, type AddressInfo } from "node:net";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import {
|
||||
toWebRequest,
|
||||
UnsupportedRequestTargetError,
|
||||
writeWebResponse,
|
||||
} from "./nodeHttpBridge.js";
|
||||
import { StreamableHttpListenError } from "./streamableHttpListenError.js";
|
||||
import {
|
||||
NodeHttpRequestOwner,
|
||||
type HttpAdmissionLifetime,
|
||||
type NodeHttpExchangeContext,
|
||||
} from "./NodeHttpRequestOwner.js";
|
||||
import { RequestTrackingMcpServer } from "../RequestTrackingMcpServer.js";
|
||||
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
|
||||
|
||||
/** MCP endpoint path shared by `rea mcp --http` and agent environment servers. */
|
||||
export const MCP_HTTP_PATH = "/mcp";
|
||||
|
||||
/** One plain HTTP route served beside the MCP endpoint as web Request to Response. */
|
||||
export interface StreamableHttpRoute {
|
||||
readonly method: string;
|
||||
readonly path: string;
|
||||
readonly handle: (request: Request) => Response | Promise<Response>;
|
||||
}
|
||||
|
||||
export interface ServeStreamableHttpOptions {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
/** Called once; the instance serves every session in turn on a fresh transport. */
|
||||
readonly createMcpServer: (
|
||||
context: McpRequestContext,
|
||||
) => RequestTrackingMcpServer | Promise<RequestTrackingMcpServer>;
|
||||
/** Maximum time a client session handover waits for cancelled tool/completion handlers. */
|
||||
readonly sessionDrainTimeoutMs?: number;
|
||||
/** Concurrent HTTP input/route work (default 8); unread uploads retain their permit. */
|
||||
readonly maxConcurrentDispatches?: number;
|
||||
readonly routes?: readonly StreamableHttpRoute[];
|
||||
readonly logger?: Logger;
|
||||
/** Reports listener failures after binding; request failures are logged instead. */
|
||||
readonly onerror?: (error: Error) => void;
|
||||
/** Close the server instance as the last stage of `close`; false when the factory's owner closes it. */
|
||||
readonly closeInstance?: boolean;
|
||||
}
|
||||
|
||||
export interface StreamableHttpServer {
|
||||
/** The bound MCP endpoint URL. */
|
||||
readonly url: string;
|
||||
/** Stop incoming dispatch and drain handlers before shared provider owners close. */
|
||||
readonly prepareShutdown: () => Promise<void>;
|
||||
readonly close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** The bind host as the SDK compares Host headers: lowercase, IPv6 bracketed and canonical. */
|
||||
const normalizedHostname = (host: string): string | undefined => {
|
||||
try {
|
||||
return new URL(`http://${isIPv6(host) ? `[${host}]` : host}`).hostname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const isLoopbackIpv4 = (address: string): boolean =>
|
||||
isIPv4(address) && address.startsWith("127.");
|
||||
/** Classify the native bound IP; configured DNS names are not binding evidence. */
|
||||
const isLoopbackAddress = (hostname: string): boolean => {
|
||||
if (isLoopbackIpv4(hostname)) return true;
|
||||
if (!hostname.startsWith("[") || !hostname.endsWith("]")) return false;
|
||||
const inner = hostname.slice(1, -1);
|
||||
if (inner === "::1") return true;
|
||||
if (!inner.startsWith("::ffff:")) return false;
|
||||
const mapped = inner.slice("::ffff:".length);
|
||||
if (isLoopbackIpv4(mapped)) return true;
|
||||
const hex = /^([0-9a-f]{1,4}):[0-9a-f]{1,4}$/u.exec(mapped);
|
||||
return hex !== null && Number.parseInt(hex[1] ?? "0", 16) >> 8 === 0x7f;
|
||||
};
|
||||
/** Classify the bound address first, then allow its address and configured name. */
|
||||
export const hostHeaderAllowlist = (
|
||||
host: string,
|
||||
boundAddress: string,
|
||||
): string[] | undefined => {
|
||||
const bound = normalizedHostname(boundAddress);
|
||||
if (bound === undefined || !isLoopbackAddress(bound)) return undefined;
|
||||
const names = [normalizedHostname(host), bound];
|
||||
const known = names.filter((name): name is string => name !== undefined);
|
||||
return [...new Set([...localhostAllowedHostnames(), ...known])];
|
||||
};
|
||||
|
||||
const jsonError = (status: number, code: number, message: string): Response =>
|
||||
Response.json(
|
||||
{ jsonrpc: "2.0", error: { code, message }, id: null },
|
||||
{ status },
|
||||
);
|
||||
|
||||
const protocolOf = (instance: McpServer | Server): Server =>
|
||||
"server" in instance ? instance.server : instance;
|
||||
|
||||
const describe = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
const NO_SHUTDOWN_HOOK = (): void => undefined;
|
||||
const SHUTTING_DOWN = (): Response =>
|
||||
jsonError(503, -32000, "Server shutting down");
|
||||
const SESSION_NOT_FOUND = (): Response =>
|
||||
jsonError(404, -32001, "Session not found");
|
||||
const DEFAULT_SESSION_DRAIN_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_MAX_CONCURRENT_DISPATCHES = 8;
|
||||
|
||||
class SessionDrainTimeoutError extends AnalysisResourceConstraintError {
|
||||
constructor(timeoutMs: number) {
|
||||
super(
|
||||
"mcp_http_session",
|
||||
"transport",
|
||||
"Previous MCP session still has running requests; retry after they finish",
|
||||
{ drain_timeout_ms: timeoutMs },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Split a header field on `separator` outside quoted strings, honouring backslash escapes (RFC 9110). */
|
||||
const splitUnquoted = (value: string, separator: string): string[] => {
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
let quoted = false;
|
||||
let escaped = false;
|
||||
for (const char of value) {
|
||||
if (escaped) escaped = false;
|
||||
else if (quoted && char === "\\") escaped = true;
|
||||
else if (char === '"') quoted = !quoted;
|
||||
else if (char === separator && !quoted) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
parts.push(current);
|
||||
return parts;
|
||||
};
|
||||
/** The media type proper of one header value or Accept range: no parameters, lowercase. */
|
||||
const mediaType = (value: string): string =>
|
||||
(splitUnquoted(value, ";")[0] ?? "").trim().toLowerCase();
|
||||
/** An Accept range's quality weight; a missing or unreadable `q` counts as 1, as in RFC 9110. */
|
||||
const quality = (range: string): number => {
|
||||
const q = splitUnquoted(range, ";")
|
||||
.slice(1)
|
||||
.map((parameter) => parameter.split("="))
|
||||
.find(([name]) => name?.trim().toLowerCase() === "q")?.[1];
|
||||
const value = q === undefined ? 1 : Number(q.trim());
|
||||
return Number.isFinite(value) ? value : 1;
|
||||
};
|
||||
/** The headers the SDK requires of a streamable POST; a request without them is left to the transport's refusal. */
|
||||
const hasStreamablePostHeaders = (request: Request): boolean => {
|
||||
const accept = request.headers.get("accept") ?? "";
|
||||
// Exact media ranges with a positive weight: `application/json-patch+json`, `text/event-streaming`
|
||||
// and `application/json;q=0` do not count.
|
||||
const accepted = new Set(
|
||||
splitUnquoted(accept, ",")
|
||||
.filter((range) => quality(range) > 0)
|
||||
.map(mediaType),
|
||||
);
|
||||
// The SDK's own checks too: its Accept test is a case-sensitive substring match, so a header it
|
||||
// would refuse with 406 (such as `Application/JSON`) must not close the live session first.
|
||||
return (
|
||||
accepted.has("application/json") &&
|
||||
accepted.has("text/event-stream") &&
|
||||
accept.includes("application/json") &&
|
||||
accept.includes("text/event-stream") &&
|
||||
isJsonContentType(request.headers.get("content-type"))
|
||||
);
|
||||
};
|
||||
|
||||
/** Only a new client's initialize travels without a session id; peek within the SDK body bound. */
|
||||
const isInitializePost = async (request: Request): Promise<boolean> => {
|
||||
if (
|
||||
request.method !== "POST" ||
|
||||
request.headers.has("mcp-session-id") ||
|
||||
!hasStreamablePostHeaders(request)
|
||||
)
|
||||
return false;
|
||||
const probe = request.clone();
|
||||
try {
|
||||
const read = await readRequestBody(probe);
|
||||
if (read.tooLarge) return false;
|
||||
const body: unknown = JSON.parse(read.text);
|
||||
// The transport accepts only a lone initialize, so only that may evict a session.
|
||||
return Array.isArray(body)
|
||||
? body.length === 1 && isInitializeRequest(body[0])
|
||||
: isInitializeRequest(body);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
// The clone is one branch of a tee; awaiting its cancel would wait for the original to drain.
|
||||
void probe.body?.cancel().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
/** Owns parsing and dispatch as well as the connected transport of one session. */
|
||||
class LiveSession {
|
||||
#closed = false;
|
||||
readonly #bodyAbort = new AbortController();
|
||||
readonly #shutdown: () => Promise<void>;
|
||||
|
||||
constructor(
|
||||
readonly transport: WebStandardStreamableHTTPServerTransport,
|
||||
private readonly instance: RequestTrackingMcpServer,
|
||||
) {
|
||||
this.#shutdown = transport.close.bind(transport);
|
||||
}
|
||||
|
||||
/** Install after connect, so the SDK and Evidence adapter have bound their callbacks. */
|
||||
fenceDispatch(): void {
|
||||
const dispatch = this.transport.onmessage;
|
||||
this.transport.onmessage = (...args) => {
|
||||
if (!this.#closed) dispatch?.(...args);
|
||||
};
|
||||
}
|
||||
|
||||
handle(request: Request): Promise<Response> {
|
||||
if (this.#closed) return Promise.resolve(SESSION_NOT_FOUND());
|
||||
// DELETE awaits this session's close; including it in its own drain would deadlock.
|
||||
if (request.method === "DELETE")
|
||||
return this.transport.handleRequest(request);
|
||||
return Promise.resolve(
|
||||
this.instance.runRequest(undefined, async () => {
|
||||
if (this.#closed) return SESSION_NOT_FOUND();
|
||||
const bodyAbort = new AbortController();
|
||||
try {
|
||||
const guarded =
|
||||
request.body === null
|
||||
? request
|
||||
: new Request(request, {
|
||||
body: request.body.pipeThrough(
|
||||
new TransformStream<Uint8Array, Uint8Array>(),
|
||||
{
|
||||
signal: AbortSignal.any([
|
||||
this.#bodyAbort.signal,
|
||||
bodyAbort.signal,
|
||||
]),
|
||||
},
|
||||
),
|
||||
duplex: "half",
|
||||
} as RequestInit);
|
||||
const response = await this.transport.handleRequest(guarded);
|
||||
if (!this.#closed) return response;
|
||||
// The SDK may have crossed an await before close and created its SSE
|
||||
// response afterwards. Cancel that stream before releasing its owner.
|
||||
await response.body?.cancel();
|
||||
return SESSION_NOT_FOUND();
|
||||
} finally {
|
||||
// Header/size refusals can leave the SDK body unread. Its forwarding
|
||||
// stream belongs to this dispatch, not the remaining session lifetime.
|
||||
bodyAbort.abort();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
shutdown(): Promise<void> {
|
||||
this.#closed = true;
|
||||
this.#bodyAbort.abort();
|
||||
return this.#shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/** Owns the single connected server and the transport of its current session. */
|
||||
export class McpSessionSlot {
|
||||
#live: LiveSession | undefined;
|
||||
#arming: Promise<LiveSession> | undefined;
|
||||
#closed = false;
|
||||
#hookRan = false;
|
||||
#draining = false;
|
||||
#ending: Promise<void> | undefined;
|
||||
/** The instance's own close hook, captured once; it runs exactly once, when the listener closes. */
|
||||
readonly #shutdownHook: () => void;
|
||||
|
||||
constructor(
|
||||
private readonly instance: RequestTrackingMcpServer,
|
||||
private readonly logger: Logger | undefined,
|
||||
private readonly drainTimeoutMs = DEFAULT_SESSION_DRAIN_TIMEOUT_MS,
|
||||
) {
|
||||
instance.enableRequestTracking();
|
||||
const protocol = protocolOf(instance);
|
||||
const captured = protocol.onclose ?? NO_SHUTDOWN_HOOK;
|
||||
let running = false;
|
||||
// Completion is recorded only after the hook returns, so a hook that threw stays eligible for a retry.
|
||||
this.#shutdownHook = () => {
|
||||
if (this.#hookRan || running) return;
|
||||
running = true;
|
||||
try {
|
||||
captured();
|
||||
this.#hookRan = true;
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
protocol.onclose = this.#shutdownHook;
|
||||
}
|
||||
|
||||
/** Route one MCP request; a new client's initialize replaces a session its predecessor left open. */
|
||||
async handle(request: Request): Promise<Response> {
|
||||
try {
|
||||
return await this.#handle(request);
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof SessionDrainTimeoutError)) throw cause;
|
||||
return jsonError(503, -32000, cause.message);
|
||||
}
|
||||
}
|
||||
|
||||
async #handle(request: Request): Promise<Response> {
|
||||
if (this.#closed) return SHUTTING_DOWN();
|
||||
if (this.#draining) await this.#live?.transport.close();
|
||||
if (this.#closed) return SHUTTING_DOWN();
|
||||
const open = this.#live;
|
||||
if (
|
||||
open?.transport.sessionId !== undefined &&
|
||||
(await isInitializePost(request)) &&
|
||||
// A client that left while its initialize was probed would evict the session for nobody.
|
||||
!request.signal.aborted
|
||||
) {
|
||||
if (this.#closed) return SHUTTING_DOWN();
|
||||
this.logger?.info("MCP HTTP session replaced by a new client");
|
||||
await open.transport.close();
|
||||
}
|
||||
if (this.#closed) return SHUTTING_DOWN();
|
||||
const session = await this.#current();
|
||||
// Shutdown may have closed the slot while this request was arming it.
|
||||
if (this.#closed) return SHUTTING_DOWN();
|
||||
return session.handle(request);
|
||||
}
|
||||
|
||||
/** Cancel and drain MCP work before any shared provider shutdown hook can run. */
|
||||
async prepareShutdown(): Promise<void> {
|
||||
this.#closed = true;
|
||||
await this.#arming?.catch(() => undefined);
|
||||
const open = this.#live;
|
||||
// The session stays owned until its shutdown succeeds, so a retry can finish it.
|
||||
try {
|
||||
await open?.transport.close();
|
||||
} catch (cause: unknown) {
|
||||
// Shutdown may have joined a handover that already started its bounded
|
||||
// drain. Retry that timeout with shutdown's completion-based wait.
|
||||
if (!(cause instanceof SessionDrainTimeoutError)) throw cause;
|
||||
await open?.transport.close();
|
||||
}
|
||||
if (this.#live === open) this.#live = undefined;
|
||||
}
|
||||
|
||||
/** Finish shutdown once every consumer of the shared providers has settled. */
|
||||
async close(): Promise<void> {
|
||||
await this.prepareShutdown();
|
||||
// Without a live transport nothing fires the instance's hook; run it here, once.
|
||||
this.#shutdownHook();
|
||||
}
|
||||
|
||||
#current(): Promise<LiveSession> {
|
||||
if (this.#closed)
|
||||
return Promise.reject(new Error("MCP HTTP session slot is closed"));
|
||||
if (this.#live !== undefined) return Promise.resolve(this.#live);
|
||||
this.#arming ??= this.#arm().finally(() => {
|
||||
this.#arming = undefined;
|
||||
});
|
||||
return this.#arming;
|
||||
}
|
||||
|
||||
async #arm(): Promise<LiveSession> {
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: () => this.logger?.info("MCP HTTP session opened"),
|
||||
onsessionclosed: () =>
|
||||
this.logger?.info("MCP HTTP session closed by the client"),
|
||||
});
|
||||
const session = new LiveSession(transport, this.instance);
|
||||
// The SDK closes the transport on DELETE; that ends the session, not the server.
|
||||
transport.close = () => this.#endSession(session);
|
||||
await this.instance.connect(transport);
|
||||
session.fenceDispatch();
|
||||
this.#live = session;
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Close one session's transport while the instance's onclose hooks wait for close(). */
|
||||
async #endSession(session: LiveSession): Promise<void> {
|
||||
if (this.#live !== session) return;
|
||||
if (this.#ending !== undefined) return this.#ending;
|
||||
this.#draining = true;
|
||||
const ending = this.#finishSession(session).finally(() => {
|
||||
if (this.#ending === ending) this.#ending = undefined;
|
||||
});
|
||||
this.#ending = ending;
|
||||
return ending;
|
||||
}
|
||||
|
||||
async #finishSession(session: LiveSession): Promise<void> {
|
||||
const protocol = protocolOf(this.instance);
|
||||
protocol.onclose = NO_SHUTDOWN_HOOK;
|
||||
try {
|
||||
await session.shutdown();
|
||||
if (
|
||||
!(await this.instance.drainRequests(
|
||||
this.#closed ? undefined : this.drainTimeoutMs,
|
||||
))
|
||||
)
|
||||
throw new SessionDrainTimeoutError(this.drainTimeoutMs);
|
||||
// A timed-out handler remains owned and blocks reuse until a later drain succeeds.
|
||||
if (this.#live === session) this.#live = undefined;
|
||||
this.#draining = false;
|
||||
} finally {
|
||||
protocol.onclose = this.#shutdownHook;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const endpointUrl = (address: AddressInfo): string => {
|
||||
const host =
|
||||
address.family === "IPv6" ? `[${address.address}]` : address.address;
|
||||
return `http://${host}:${String(address.port)}${MCP_HTTP_PATH}`;
|
||||
};
|
||||
|
||||
interface NodeRequestTarget {
|
||||
readonly route: (request: Request) => Promise<{
|
||||
readonly response: Response;
|
||||
readonly admissionLifetime: HttpAdmissionLifetime;
|
||||
}>;
|
||||
readonly origin: () => string;
|
||||
readonly logger: Logger | undefined;
|
||||
}
|
||||
|
||||
/** Serve one MCP server, plus plain routes, over streamable HTTP until closed. */
|
||||
export const serveStreamableHttp = async (
|
||||
options: ServeStreamableHttpOptions,
|
||||
): Promise<StreamableHttpServer> => {
|
||||
const { host, port, logger, routes = [], closeInstance = true } = options;
|
||||
// Scoped IPv6 literals bind but cannot be named in a URL; refuse them before anything exists.
|
||||
if (host.includes("%"))
|
||||
throw new StreamableHttpListenError(
|
||||
new Error(`scoped IPv6 bind address ${host} is not supported`),
|
||||
);
|
||||
// Loopback is classified from the address the listener binds, after it binds; the instance
|
||||
// is created after that, so a bind failure leaves nothing to clean up.
|
||||
let allowedHosts: string[] | undefined;
|
||||
let slot: McpSessionSlot | undefined;
|
||||
let origin = "";
|
||||
let stopped = false;
|
||||
const route: NodeRequestTarget["route"] = async (request) => {
|
||||
const local = (response: Response) => ({
|
||||
response,
|
||||
admissionLifetime: "response" as const,
|
||||
});
|
||||
if (stopped) return local(jsonError(503, -32000, "Server shutting down"));
|
||||
// One loopback allowlist gates Host (DNS rebinding) and Origin (browser pages); a missing Origin passes.
|
||||
const refused =
|
||||
allowedHosts === undefined
|
||||
? undefined
|
||||
: (hostHeaderValidationResponse(request, allowedHosts) ??
|
||||
originValidationResponse(request, allowedHosts));
|
||||
if (refused !== undefined) return local(refused);
|
||||
if (slot === undefined)
|
||||
return local(jsonError(503, -32000, "Server starting"));
|
||||
const { pathname } = new URL(request.url);
|
||||
if (pathname === MCP_HTTP_PATH)
|
||||
return {
|
||||
response: await slot.handle(request),
|
||||
admissionLifetime: "dispatch",
|
||||
};
|
||||
const match = routes.find(
|
||||
(candidate) =>
|
||||
candidate.path === pathname && candidate.method === request.method,
|
||||
);
|
||||
return local(
|
||||
match === undefined
|
||||
? jsonError(404, -32601, `No route for ${request.method} ${pathname}`)
|
||||
: await match.handle(request),
|
||||
);
|
||||
};
|
||||
const httpServer = createHttpServer();
|
||||
const target: NodeRequestTarget = { route, origin: () => origin, logger };
|
||||
const requests = new NodeHttpRequestOwner(
|
||||
options.maxConcurrentDispatches ?? DEFAULT_MAX_CONCURRENT_DISPATCHES,
|
||||
{
|
||||
serve: (request, response, context) =>
|
||||
serveNodeRequest(target, request, response, context),
|
||||
refuse: (reason) =>
|
||||
jsonError(
|
||||
503,
|
||||
-32000,
|
||||
reason === "capacity"
|
||||
? "HTTP request capacity is full; retry after pending dispatches finish"
|
||||
: "HTTP response cleanup failed; this server is not accepting new requests",
|
||||
),
|
||||
},
|
||||
);
|
||||
const prepareTransportShutdown = stagedClose([
|
||||
async () => {
|
||||
stopped = true;
|
||||
await closeHttpServer(httpServer);
|
||||
},
|
||||
() => slot?.prepareShutdown() ?? Promise.resolve(),
|
||||
() => requests.drain(),
|
||||
]);
|
||||
try {
|
||||
httpServer.listen(port, host);
|
||||
await once(httpServer, "listening");
|
||||
} catch (cause: unknown) {
|
||||
throw new StreamableHttpListenError(cause);
|
||||
}
|
||||
const bound = httpServer.address();
|
||||
let url: string;
|
||||
try {
|
||||
if (bound === null || typeof bound === "string")
|
||||
throw new TypeError("MCP HTTP listener has no TCP address");
|
||||
url = endpointUrl(bound);
|
||||
origin = new URL(url).origin;
|
||||
allowedHosts = hostHeaderAllowlist(host, bound.address);
|
||||
} catch (cause: unknown) {
|
||||
await prepareTransportShutdown().catch(() => undefined);
|
||||
throw new StreamableHttpListenError(cause);
|
||||
}
|
||||
httpServer.on("request", (request, response) => {
|
||||
void requests.handle(request, response).catch((cause: unknown) => {
|
||||
logger?.warn(
|
||||
{ error: describe(cause) },
|
||||
"MCP HTTP request did not complete",
|
||||
);
|
||||
response.destroy();
|
||||
});
|
||||
});
|
||||
// Bind failures already rejected the listening wait above; only later listener errors are reported.
|
||||
httpServer.on("error", (error) => options.onerror?.(error));
|
||||
let instance: RequestTrackingMcpServer;
|
||||
try {
|
||||
instance = await options.createMcpServer({ era: "legacy" });
|
||||
} catch (cause: unknown) {
|
||||
// Starting responses may still be in flight; application routes stay unavailable.
|
||||
await prepareTransportShutdown().catch(() => undefined);
|
||||
throw cause;
|
||||
}
|
||||
protocolOf(instance).onerror = (error) =>
|
||||
logger?.warn({ error: error.message }, "MCP HTTP request rejected");
|
||||
const live = new McpSessionSlot(
|
||||
instance,
|
||||
logger,
|
||||
options.sessionDrainTimeoutMs,
|
||||
);
|
||||
slot = live;
|
||||
if (allowedHosts === undefined)
|
||||
logger?.warn(
|
||||
{ host },
|
||||
"MCP HTTP Host header validation relaxed for a non-loopback bind",
|
||||
);
|
||||
// The URL is the only way to reach an ephemeral port, so the announcement ignores the log threshold.
|
||||
logger
|
||||
?.child({}, { level: "info" })
|
||||
.info({ transport: "streamable-http", url }, "MCP transport listening");
|
||||
const prepareShutdown = stagedClose([
|
||||
prepareTransportShutdown,
|
||||
() => live.close(),
|
||||
]);
|
||||
const close = stagedClose([
|
||||
prepareShutdown,
|
||||
...(closeInstance ? [() => instance.close()] : []),
|
||||
]);
|
||||
return { url, prepareShutdown, close };
|
||||
};
|
||||
|
||||
/** Stop accepting, drop open connections and release the port. */
|
||||
const closeHttpServer = (
|
||||
httpServer: ReturnType<typeof createHttpServer>,
|
||||
): Promise<void> => {
|
||||
// close() first, as Node documents: a connection accepted before it would otherwise miss the forced-close pass.
|
||||
const closed = new Promise<void>((resolve, reject) => {
|
||||
httpServer.close((error) =>
|
||||
error === undefined ? resolve() : reject(error),
|
||||
);
|
||||
});
|
||||
httpServer.closeAllConnections();
|
||||
return closed;
|
||||
};
|
||||
|
||||
/** Translate one node:http exchange through `route`, logging a failure and answering it when headers are still open. */
|
||||
const serveNodeRequest = async (
|
||||
target: NodeRequestTarget,
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
context: NodeHttpExchangeContext,
|
||||
): Promise<void> => {
|
||||
const { route, origin, logger } = target;
|
||||
// A reply sent before an incoming body was fully consumed leaves the rest on the socket, which
|
||||
// the bridged stream keeps paused; closing the connection after the reply releases it. The
|
||||
// incoming message decides (end of stream, not "body disturbed"), so a partially read upload
|
||||
// and a GET or HEAD that carried a body close too.
|
||||
const declaresBody =
|
||||
request.headers["content-length"] !== undefined ||
|
||||
request.headers["transfer-encoding"] !== undefined;
|
||||
let webRequest: Request | undefined;
|
||||
const unreadBody = (): boolean => declaresBody && !request.readableEnded;
|
||||
let reported = false;
|
||||
let admissionLifetime: HttpAdmissionLifetime = "response";
|
||||
const settled = (): void => {
|
||||
if (reported) return;
|
||||
reported = true;
|
||||
context.onDispatchSettled({
|
||||
hasUnreadBody: unreadBody(),
|
||||
admissionLifetime,
|
||||
});
|
||||
};
|
||||
try {
|
||||
try {
|
||||
webRequest = toWebRequest(request, response, origin());
|
||||
} catch (cause: unknown) {
|
||||
// Fetch refuses methods such as TRACE that node:http accepts, and the bridge refuses
|
||||
// targets that are not origin-form paths; both are answered instead of thrown.
|
||||
const target = cause instanceof UnsupportedRequestTargetError;
|
||||
await writeWebResponse(
|
||||
jsonError(
|
||||
target ? 400 : 405,
|
||||
-32600,
|
||||
target
|
||||
? "Unsupported request target"
|
||||
: `Unsupported method ${request.method ?? ""}`,
|
||||
),
|
||||
response,
|
||||
{ closeConnection: true, onBodyCleanup: context.onBodyCleanup },
|
||||
);
|
||||
return;
|
||||
}
|
||||
let webResponse: Response;
|
||||
try {
|
||||
const routed = await route(webRequest);
|
||||
webResponse = routed.response;
|
||||
admissionLifetime = routed.admissionLifetime;
|
||||
} finally {
|
||||
settled();
|
||||
}
|
||||
await writeWebResponse(webResponse, response, {
|
||||
closeConnection: unreadBody(),
|
||||
onBodyCleanup: context.onBodyCleanup,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
// A client that left mid-stream is routine; a failure before headers is not.
|
||||
logger?.[response.headersSent ? "debug" : "warn"](
|
||||
{ error: describe(cause) },
|
||||
"MCP HTTP request did not complete",
|
||||
);
|
||||
if (response.headersSent) response.destroy();
|
||||
else
|
||||
await writeWebResponse(
|
||||
jsonError(500, -32603, "Internal server error"),
|
||||
response,
|
||||
{ closeConnection: unreadBody(), onBodyCleanup: context.onBodyCleanup },
|
||||
).catch(() => undefined);
|
||||
} finally {
|
||||
settled();
|
||||
}
|
||||
};
|
||||
|
||||
/** Run close stages in order once each; a failed stage is retried by the next call instead of replaying its rejection. */
|
||||
const stagedClose = (
|
||||
stages: readonly (() => Promise<void>)[],
|
||||
): (() => Promise<void>) => {
|
||||
const done = stages.map(() => false);
|
||||
let closing: Promise<void> | undefined;
|
||||
return () => {
|
||||
if (closing !== undefined) return closing;
|
||||
const attempt = (async () => {
|
||||
for (const [index, stage] of stages.entries()) {
|
||||
if (done[index]) continue;
|
||||
await stage();
|
||||
done[index] = true;
|
||||
}
|
||||
})();
|
||||
closing = attempt;
|
||||
void attempt.catch(() => {
|
||||
if (closing === attempt) closing = undefined;
|
||||
});
|
||||
return attempt;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/** The listener could not bind before any client connected; the message names the system cause. */
|
||||
export class StreamableHttpListenError extends Error {
|
||||
override readonly name = "StreamableHttpListenError";
|
||||
constructor(cause: unknown) {
|
||||
super(
|
||||
`MCP HTTP listener could not bind: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import {
|
||||
currentMcpRequestSignal,
|
||||
type EvidenceMcpServer,
|
||||
} from "./EvidenceMcpServer.js";
|
||||
import {
|
||||
completable,
|
||||
type RegisteredPrompt,
|
||||
@@ -31,7 +34,16 @@ export const registerGuidedPrompts = (
|
||||
analysis: AnalysisOperationPort,
|
||||
session?: BinarySessionPort,
|
||||
): GuidedPromptRegistry => {
|
||||
const completion = createPromptCompletionSource(analysis, session);
|
||||
const source = createPromptCompletionSource(analysis, session);
|
||||
const completion: PromptCompletionSource = {
|
||||
async complete(kind, value, context) {
|
||||
// Completions can query the same providers as tools. Cancellation alone
|
||||
// does not mean their provider cleanup has finished.
|
||||
return server.runRequest(currentMcpRequestSignal(), () =>
|
||||
source.complete(kind, value, context),
|
||||
);
|
||||
},
|
||||
};
|
||||
const prompts = new Map<string, RegisteredPrompt>();
|
||||
for (const contract of PROMPT_CONTRACTS)
|
||||
prompts.set(contract.name, registerPrompt(server, contract, completion));
|
||||
|
||||
@@ -3,11 +3,57 @@ import { expect, it } from "vitest";
|
||||
import pino from "pino";
|
||||
|
||||
import { createShutdown } from "../main/shutdown.js";
|
||||
import { registerProcessShutdown } from "../main/processShutdown.js";
|
||||
import type { RuntimeDependencies } from "../main/types.js";
|
||||
import type { StdioServerHandle } from "@modelcontextprotocol/server/stdio";
|
||||
import {
|
||||
createExitStatus,
|
||||
latchSignalShutdown,
|
||||
registerProcessShutdown,
|
||||
registerSignalShutdown,
|
||||
} from "../main/processShutdown.js";
|
||||
import type { McpServerHandle, RuntimeDependencies } from "../main/types.js";
|
||||
import { createManagedBinarySession } from "../composition/binary.js";
|
||||
|
||||
it("registers only signals for a listener and reports the signal exit status", () => {
|
||||
const signals = new EventEmitter();
|
||||
const statuses: number[] = [];
|
||||
const unregister = registerSignalShutdown(
|
||||
(status) => statuses.push(status),
|
||||
signals,
|
||||
);
|
||||
signals.emit("SIGTERM");
|
||||
signals.emit("SIGINT");
|
||||
expect(statuses).toEqual([143, 130]);
|
||||
unregister();
|
||||
signals.emit("SIGTERM");
|
||||
expect(statuses).toEqual([143, 130]);
|
||||
expect(signals.listenerCount("SIGTERM")).toBe(0);
|
||||
});
|
||||
|
||||
it("names the signal that fired, passes none for stdin, and ignores stdin when the events omit it", () => {
|
||||
const signals = new EventEmitter();
|
||||
const input = new EventEmitter();
|
||||
const triggers: (string | undefined)[] = [];
|
||||
const withInput = registerProcessShutdown((signal) => triggers.push(signal), {
|
||||
signals,
|
||||
stdin: input,
|
||||
});
|
||||
signals.emit("SIGINT");
|
||||
input.emit("end");
|
||||
expect(triggers).toEqual(["SIGINT", undefined]);
|
||||
withInput();
|
||||
const signalsOnly = registerProcessShutdown(
|
||||
(signal) => triggers.push(signal),
|
||||
{ signals },
|
||||
);
|
||||
signals.emit("SIGTERM");
|
||||
input.emit("end");
|
||||
input.emit("close");
|
||||
expect(triggers).toEqual(["SIGINT", undefined, "SIGTERM"]);
|
||||
signalsOnly();
|
||||
signals.emit("SIGINT");
|
||||
expect(triggers).toEqual(["SIGINT", undefined, "SIGTERM"]);
|
||||
expect(input.listenerCount("end")).toBe(0);
|
||||
});
|
||||
|
||||
const shutdownEvents = ["SIGINT", "SIGTERM", "end", "close"] as const;
|
||||
|
||||
const flush = async (): Promise<void> => {
|
||||
@@ -51,7 +97,7 @@ it.each(shutdownEvents)(
|
||||
};
|
||||
},
|
||||
};
|
||||
const handle: StdioServerHandle = {
|
||||
const handle: McpServerHandle = {
|
||||
close: async () => {
|
||||
transportCloseCalls += 1;
|
||||
if (transportCloseCalls === 1)
|
||||
@@ -110,6 +156,55 @@ it.each(shutdownEvents)(
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps shared provider owners open when the transport preparation barrier fails", async () => {
|
||||
const events: string[] = [];
|
||||
let attempts = 0;
|
||||
const session = createManagedBinarySession();
|
||||
const closeSession = session.close.bind(session);
|
||||
session.close = async (...args) => {
|
||||
events.push("session");
|
||||
return closeSession(...args);
|
||||
};
|
||||
const lifecycle = createShutdown({
|
||||
handle: {
|
||||
prepareShutdown: async () => {
|
||||
events.push("prepare");
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error("tool cleanup is still running");
|
||||
},
|
||||
close: async () => {
|
||||
events.push("transport");
|
||||
},
|
||||
},
|
||||
closeOptionalProviders: async () => {
|
||||
events.push("provider");
|
||||
},
|
||||
session,
|
||||
dependencies: {
|
||||
env: {},
|
||||
serve: () => {
|
||||
throw new Error("unused transport");
|
||||
},
|
||||
writeStderr: () => undefined,
|
||||
setExitCode: () => undefined,
|
||||
registerShutdown: () => () => undefined,
|
||||
},
|
||||
serverLogger: pino({ enabled: false }),
|
||||
});
|
||||
await expect(lifecycle.shutdown()).rejects.toThrow(
|
||||
"tool cleanup is still running",
|
||||
);
|
||||
expect(events).toEqual(["prepare"]);
|
||||
await lifecycle.shutdown();
|
||||
expect(events).toEqual([
|
||||
"prepare",
|
||||
"prepare",
|
||||
"transport",
|
||||
"provider",
|
||||
"session",
|
||||
]);
|
||||
});
|
||||
|
||||
it("joins concurrent shutdown events and retries only failed owners", async () => {
|
||||
const signals = new EventEmitter();
|
||||
const input = new EventEmitter();
|
||||
@@ -134,7 +229,7 @@ it("joins concurrent shutdown events and retries only failed owners", async () =
|
||||
registerShutdown: (handler) =>
|
||||
registerProcessShutdown(handler, { signals, stdin: input }),
|
||||
};
|
||||
const handle: StdioServerHandle = {
|
||||
const handle: McpServerHandle = {
|
||||
close: async () => {
|
||||
transportCloseCalls += 1;
|
||||
if (transportCloseCalls === 1)
|
||||
@@ -176,3 +271,33 @@ it("joins concurrent shutdown events and retries only failed owners", async () =
|
||||
expect(input.listenerCount("end")).toBe(0);
|
||||
expect(input.listenerCount("close")).toBe(0);
|
||||
});
|
||||
|
||||
it("latches the first startup signal, exits at once on a second, and hands the status over on release", () => {
|
||||
const signals = new EventEmitter();
|
||||
const exits: number[] = [];
|
||||
const latch = latchSignalShutdown(signals, (status) => exits.push(status));
|
||||
expect(signals.listenerCount("SIGINT")).toBe(1);
|
||||
signals.emit("SIGTERM");
|
||||
expect(exits).toEqual([]);
|
||||
signals.emit("SIGINT");
|
||||
expect(exits).toEqual([143]);
|
||||
expect(latch.release()).toBe(143);
|
||||
expect(signals.listenerCount("SIGINT")).toBe(0);
|
||||
expect(signals.listenerCount("SIGTERM")).toBe(0);
|
||||
const quiet = latchSignalShutdown(signals, (status) => exits.push(status));
|
||||
expect(quiet.release()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a signal's exit status through the entry's normal return and yields to a real failure", () => {
|
||||
const codes: number[] = [];
|
||||
const exit = createExitStatus((code) => codes.push(code));
|
||||
exit.set(0);
|
||||
exit.signal(143);
|
||||
exit.set(0);
|
||||
expect(codes).toEqual([0, 143, 143]);
|
||||
exit.set(1);
|
||||
expect(codes.at(-1)).toBe(1);
|
||||
const plain = createExitStatus((code) => codes.push(code));
|
||||
plain.set(0);
|
||||
expect(codes.at(-1)).toBe(0);
|
||||
});
|
||||
@@ -235,12 +235,16 @@ describe("CLI startup imports", () => {
|
||||
});
|
||||
|
||||
describe("MCP startup imports", () => {
|
||||
it("does not load command-specific dependencies to initialize and list tools", async () => {
|
||||
it("initializes and lists tools over stdio with HTTP-only environment settings present", async () => {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [`--import=${RESOLUTION_HOOK}`, "scripts/rea.mjs", "mcp"],
|
||||
cwd: process.cwd(),
|
||||
env: { PATH: process.env.PATH ?? "" },
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
MCP_HOST: " ",
|
||||
MCP_PORT: "not-a-port",
|
||||
},
|
||||
stderr: "pipe",
|
||||
});
|
||||
let stderr = "";
|
||||
@@ -258,6 +262,7 @@ describe("MCP startup imports", () => {
|
||||
await client.close();
|
||||
}
|
||||
const resolved = resolvedMarkers(stderr);
|
||||
expect(stderr).not.toContain("MCP transport listening");
|
||||
expect(resolved).not.toContain("playwright-core");
|
||||
expect(resolved).not.toContain("isomorphic-git");
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("executable dispatcher", () => {
|
||||
),
|
||||
workspace.write(
|
||||
"dist/mcp/main.js",
|
||||
'export const runEntrypoint = () => process.stdout.write(JSON.stringify({ adapter: "mcp" }));\n',
|
||||
'export const runEntrypoint = () => process.stdout.write(JSON.stringify({ adapter: "mcp" })); export const runMcpHttpEntrypoint = (args) => process.stdout.write(JSON.stringify({ adapter: "mcp-http", args }));\n',
|
||||
),
|
||||
workspace.write(
|
||||
"dist/cli-runtime/cli.js",
|
||||
@@ -100,6 +100,24 @@ describe("executable dispatcher", () => {
|
||||
adapter: "cli",
|
||||
args: ["--mcp", "extra"],
|
||||
});
|
||||
for (const args of [
|
||||
["mcp", "--http", "--port", "0"],
|
||||
["--mcp", "--http"],
|
||||
["mcp", "-h", "--http"],
|
||||
["mcp", "--host", "127.0.0.1", "--http"],
|
||||
])
|
||||
await expect(invoke(args)).resolves.toEqual({
|
||||
adapter: "mcp-http",
|
||||
args: args.slice(1),
|
||||
});
|
||||
await expect(invoke(["mcp", "doctor", "--http"])).resolves.toEqual({
|
||||
adapter: "mcp-doctor",
|
||||
args: ["--http"],
|
||||
});
|
||||
await expect(invoke(["mcp", "add", "--http"])).resolves.toEqual({
|
||||
adapter: "cli",
|
||||
args: ["mcp", "add", "--http"],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { setupPlanFailureMessage } from "../../../scripts/verify-package-setup-plan.mjs";
|
||||
|
||||
it("preserves setup failure details when an error result has no action arrays", () => {
|
||||
const result = {
|
||||
status: "needs_human",
|
||||
remediation: "Review the host setup requirements.",
|
||||
};
|
||||
const message = setupPlanFailureMessage({
|
||||
planned: result,
|
||||
plannedExecution: { status: 1 },
|
||||
plannedClaudeConfig: "unchanged",
|
||||
plannedCodexConfig: "unchanged",
|
||||
plannedCursorConfig: "unchanged",
|
||||
});
|
||||
|
||||
expect(message).toContain("packaged setup plan was not read-only");
|
||||
expect(message).toContain('"status":"needs_human"');
|
||||
expect(message).toContain("Review the host setup requirements.");
|
||||
expect(message).not.toContain("TypeError");
|
||||
});
|
||||
|
||||
it("still formats action kinds for a structured setup result", () => {
|
||||
const message = setupPlanFailureMessage({
|
||||
planned: {
|
||||
status: "planned",
|
||||
plannedActions: [{ kind: "configure_client" }],
|
||||
appliedActions: [],
|
||||
},
|
||||
plannedExecution: { status: 0 },
|
||||
plannedClaudeConfig: "unchanged",
|
||||
plannedCodexConfig: "unchanged",
|
||||
plannedCursorConfig: "unchanged",
|
||||
});
|
||||
|
||||
expect(message).toContain('"plannedKinds":["configure_client"]');
|
||||
expect(message).toContain('"appliedKinds":[]');
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { collectOwnershipTraceFacts } from "../../../scripts/verify/recorded-crash/trace-ownership.mjs";
|
||||
import { assertHistoricalPidAccess } from "../../../scripts/verify/recorded-crash/trace-policy.mjs";
|
||||
|
||||
const node = "/fixture/node";
|
||||
const nodeExec = 'execve("/fixture/node", ["node", "rea.mjs"], 0x7fff) = 0';
|
||||
const psExec =
|
||||
'execve("/usr/bin/ps", ["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="], 0x7fff) = 0';
|
||||
const parent = (...events: readonly string[]) => ({
|
||||
pid: 1200,
|
||||
text: [nodeExec, ...events].join("\n"),
|
||||
});
|
||||
const check = (
|
||||
traces: readonly { readonly pid: number; readonly text: string }[],
|
||||
) => {
|
||||
const facts = collectOwnershipTraceFacts({
|
||||
traces,
|
||||
nodeExecutable: node,
|
||||
psExecutables: new Set(["/usr/bin/ps"]),
|
||||
});
|
||||
return traces.flatMap((trace) =>
|
||||
assertHistoricalPidAccess({
|
||||
trace,
|
||||
historicalPid: 4008,
|
||||
ownershipThreads: facts.ownershipThreads,
|
||||
liveCollision: true,
|
||||
psInventory: facts.psProcesses.has(trace.pid),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
it("correlates the ps child trace with its parent identity read", () => {
|
||||
expect(
|
||||
check([
|
||||
parent(
|
||||
"vfork() = 1201",
|
||||
"clone3({flags=CLONE_VM|CLONE_SIGHAND|CLONE_THREAD}, 88) = 1202",
|
||||
'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY|O_CLOEXEC) = 21',
|
||||
),
|
||||
{
|
||||
pid: 1202,
|
||||
text: [
|
||||
'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY|O_CLOEXEC) = 21',
|
||||
'openat(AT_FDCWD, "/proc/4008/status", O_RDONLY|O_CLOEXEC) = 22',
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
pid: 1201,
|
||||
text: [
|
||||
psExec,
|
||||
'openat(AT_FDCWD, "/proc/4008/status", O_RDONLY) = 3',
|
||||
'openat(AT_FDCWD, "/proc/4008/cmdline", O_RDONLY) = 4',
|
||||
].join("\n"),
|
||||
},
|
||||
]),
|
||||
).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("joins unfinished and resumed CLONE_THREAD records", () => {
|
||||
expect(
|
||||
check([
|
||||
parent(
|
||||
"vfork() = 1201",
|
||||
"clone3({flags=CLONE_VM|CLONE_SIGHAND|CLONE_THREAD, child_tid=0x3 <unfinished ...>",
|
||||
"<... clone3 resumed>) = 1202",
|
||||
),
|
||||
{ pid: 1202, text: 'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3' },
|
||||
{ pid: 1201, text: psExec },
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects provider-style reads outside the ps child and identity-only caller", () => {
|
||||
expect(() =>
|
||||
check([
|
||||
parent(
|
||||
"vfork() = 1201",
|
||||
'openat(AT_FDCWD, "/proc/4008/cmdline", O_RDONLY) = 21',
|
||||
),
|
||||
{ pid: 1201, text: psExec },
|
||||
]),
|
||||
).toThrow("non-inventory lookup");
|
||||
});
|
||||
|
||||
it("does not trust a ps command with different arguments or failed exec", () => {
|
||||
for (const text of [
|
||||
'execve("/usr/bin/ps", ["ps", "-ef"], 0x7fff) = 0',
|
||||
psExec.replace(" = 0", " = -1 EACCES"),
|
||||
])
|
||||
expect(() =>
|
||||
check([
|
||||
parent(
|
||||
"vfork() = 1201",
|
||||
'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3',
|
||||
),
|
||||
{ pid: 1201, text },
|
||||
]),
|
||||
).toThrow("non-inventory lookup");
|
||||
});
|
||||
|
||||
it("allows only known procfs fields inside the exact ps child", () => {
|
||||
expect(() =>
|
||||
check([
|
||||
parent("vfork() = 1201"),
|
||||
{
|
||||
pid: 1201,
|
||||
text: `${psExec}\nopenat(AT_FDCWD, "/proc/4008/exe", O_RDONLY) = 3`,
|
||||
},
|
||||
]),
|
||||
).toThrow("non-inventory lookup");
|
||||
});
|
||||
|
||||
it("does not grant the ownership process thread permit to a forked child", () => {
|
||||
for (const field of ["stat", "status"])
|
||||
expect(() =>
|
||||
check([
|
||||
parent("vfork() = 1201", "clone3({flags=SIGCHLD}, 88) = 1203"),
|
||||
{ pid: 1201, text: psExec },
|
||||
{
|
||||
pid: 1203,
|
||||
text: `openat(AT_FDCWD, "/proc/4008/${field}", O_RDONLY) = 3`,
|
||||
},
|
||||
]),
|
||||
).toThrow("non-inventory lookup");
|
||||
});
|
||||
|
||||
it("requires an observed Node execution as well as the inventory ps child", () => {
|
||||
expect(() =>
|
||||
check([
|
||||
{
|
||||
pid: 1200,
|
||||
text: 'vfork() = 1201\nopenat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3',
|
||||
},
|
||||
{ pid: 1201, text: psExec },
|
||||
]),
|
||||
).toThrow("non-inventory lookup");
|
||||
});
|
||||
|
||||
it("refuses identity reads in an exec-replaced ownership thread", () => {
|
||||
expect(() =>
|
||||
check([
|
||||
parent(
|
||||
"vfork() = 1201",
|
||||
"clone3({flags=CLONE_VM|CLONE_THREAD}, 88) = 1202",
|
||||
),
|
||||
{ pid: 1201, text: psExec },
|
||||
{
|
||||
pid: 1202,
|
||||
text: 'execve("/fixture/gdb", ["gdb"], 0x7fff) = 0\nopenat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3',
|
||||
},
|
||||
]),
|
||||
).toThrow("non-inventory lookup");
|
||||
});
|
||||
|
||||
it("does not join unrelated groups through child-side clone returns", () => {
|
||||
expect(() =>
|
||||
check([
|
||||
parent("vfork() = 1201", "clone3({flags=CLONE_VM|CLONE_THREAD}, 88) = 0"),
|
||||
{ pid: 1201, text: psExec },
|
||||
{
|
||||
pid: 1300,
|
||||
text: [
|
||||
nodeExec,
|
||||
"clone3({flags=CLONE_VM|CLONE_THREAD}, 88) = 0",
|
||||
'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3',
|
||||
].join("\n"),
|
||||
},
|
||||
]),
|
||||
).toThrow("non-inventory lookup");
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import {
|
||||
BASE64_QUARTET_LENGTH_PATTERN,
|
||||
INVALID_BASE64_CHARACTER_PATTERN,
|
||||
INVALID_BASE64_PADDING_PATTERN,
|
||||
INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN,
|
||||
} from "../../../src/domain/stringPatterns.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
|
||||
interface AdvertisedPattern {
|
||||
@@ -10,6 +17,33 @@ interface AdvertisedPattern {
|
||||
readonly pattern: string;
|
||||
}
|
||||
|
||||
const collectCanonicalBase64Leaves = (
|
||||
value: unknown,
|
||||
leaves: Record<string, unknown>[],
|
||||
): void => {
|
||||
if (value === null || typeof value !== "object") return;
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => collectCanonicalBase64Leaves(entry, leaves));
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const allOf = record.allOf;
|
||||
if (
|
||||
Array.isArray(allOf) &&
|
||||
allOf.some(
|
||||
(entry) =>
|
||||
entry !== null &&
|
||||
typeof entry === "object" &&
|
||||
"pattern" in entry &&
|
||||
entry.pattern === BASE64_QUARTET_LENGTH_PATTERN.source,
|
||||
)
|
||||
)
|
||||
leaves.push(record);
|
||||
Object.values(record).forEach((entry) =>
|
||||
collectCanonicalBase64Leaves(entry, leaves),
|
||||
);
|
||||
};
|
||||
|
||||
const collectPatterns = (
|
||||
value: unknown,
|
||||
where: string,
|
||||
@@ -48,12 +82,29 @@ it("advertises portable NUL escapes and patterns that compile in all JS modes",
|
||||
|
||||
const advertised = (await client.listTools()).tools;
|
||||
const patterns: AdvertisedPattern[] = [];
|
||||
const canonicalBase64Leaves: Record<string, unknown>[] = [];
|
||||
for (const tool of advertised) {
|
||||
collectPatterns(tool.inputSchema, "inputSchema", tool.name, patterns);
|
||||
collectCanonicalBase64Leaves(tool.inputSchema, canonicalBase64Leaves);
|
||||
if (tool.outputSchema !== undefined)
|
||||
collectPatterns(tool.outputSchema, "outputSchema", tool.name, patterns);
|
||||
if (tool.outputSchema !== undefined)
|
||||
collectCanonicalBase64Leaves(tool.outputSchema, canonicalBase64Leaves);
|
||||
}
|
||||
|
||||
for (const pattern of [
|
||||
BASE64_QUARTET_LENGTH_PATTERN,
|
||||
INVALID_BASE64_CHARACTER_PATTERN,
|
||||
INVALID_BASE64_PADDING_PATTERN,
|
||||
INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN,
|
||||
])
|
||||
expect(
|
||||
patterns.some(({ pattern: advertisedPattern }) =>
|
||||
advertisedPattern.includes(pattern.source),
|
||||
),
|
||||
pattern.source,
|
||||
).toBe(true);
|
||||
|
||||
// Annex B and `u` mode accept an unescaped `-`, `/` or `[` inside a character
|
||||
// class, so REA's own schema validation cannot observe the defect. `v` mode
|
||||
// rejects them, and a client that compiles advertised patterns rejects the
|
||||
@@ -87,4 +138,19 @@ it("advertises portable NUL escapes and patterns that compile in all JS modes",
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(patterns.some(({ pattern }) => pattern.includes("\\x00"))).toBe(true);
|
||||
|
||||
expect(canonicalBase64Leaves.length).toBeGreaterThan(0);
|
||||
const sdkValidator = new AjvJsonSchemaValidator();
|
||||
const validators = canonicalBase64Leaves.map((schema) =>
|
||||
sdkValidator.getValidator(schema),
|
||||
);
|
||||
for (const value of ["", "AA==", "AQID", "AB==", "AAA", "AA$=", "AA\n=="])
|
||||
expect(
|
||||
validators.map((validate) => validate(value).valid),
|
||||
JSON.stringify(value),
|
||||
).toEqual(
|
||||
canonicalBase64Leaves.map(
|
||||
() => value === "" || value === "AA==" || value === "AQID",
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { ServeStdioOptions } from "@modelcontextprotocol/server/stdio";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { run, runEntrypoint } from "../../../src/main.js";
|
||||
import { MCP_HTTP_LISTEN_FAILED } from "../../../src/main/messages.js";
|
||||
import type { McpServeOptions } from "../../../src/main/types.js";
|
||||
import { StreamableHttpListenError } from "../../../src/server/http/streamableHttpListenError.js";
|
||||
|
||||
type RuntimeDependencies = NonNullable<Parameters<typeof run>[0]>;
|
||||
|
||||
@@ -61,7 +63,7 @@ describe("MCP runtime errors", () => {
|
||||
const output: string[] = [];
|
||||
const shutdown: Array<() => void> = [];
|
||||
const shutdownUnregistrations: string[] = [];
|
||||
let options: ServeStdioOptions | undefined;
|
||||
let options: McpServeOptions | undefined;
|
||||
let closeCalls = 0;
|
||||
const close = (): Promise<void> => {
|
||||
closeCalls += 1;
|
||||
@@ -176,3 +178,48 @@ describe("MCP runtime errors", () => {
|
||||
expect(exitCodes).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP runtime HTTP transport errors", () => {
|
||||
it("awaits an asynchronous transport start before installing shutdown", async () => {
|
||||
const shutdown: Array<() => void> = [];
|
||||
let closeCalls = 0;
|
||||
expect(
|
||||
await run(
|
||||
dependencies({
|
||||
serve: async () => ({
|
||||
close: async () => {
|
||||
closeCalls += 1;
|
||||
},
|
||||
}),
|
||||
output: [],
|
||||
shutdown,
|
||||
exitCodes: [],
|
||||
}),
|
||||
),
|
||||
).toBe(0);
|
||||
expect(shutdown).toHaveLength(1);
|
||||
shutdown[0]?.();
|
||||
await nextTurn();
|
||||
expect(closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("reports an HTTP listener that cannot bind without its cause", async () => {
|
||||
const output: string[] = [];
|
||||
expect(
|
||||
await run(
|
||||
dependencies({
|
||||
serve: () =>
|
||||
Promise.reject(
|
||||
new StreamableHttpListenError(
|
||||
new Error("SECRET EADDRINUSE 127.0.0.1:1"),
|
||||
),
|
||||
),
|
||||
output,
|
||||
shutdown: [],
|
||||
exitCodes: [],
|
||||
}),
|
||||
),
|
||||
).toBe(1);
|
||||
expect(output).toEqual([`${MCP_HTTP_LISTEN_FAILED}\n`]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseMcpHttpEndpoint } from "../../../src/config/mcpHttpEndpoint.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { runMcpHttpEntrypoint } from "../../../src/main.js";
|
||||
import {
|
||||
parseMcpHttpArguments,
|
||||
renderMcpHttpHelp,
|
||||
} from "../../../src/main/mcpHttpArguments.js";
|
||||
|
||||
describe("rea mcp --http arguments", () => {
|
||||
it("accepts --http with optional host and port in either flag spelling", () => {
|
||||
expect(parseMcpHttpArguments(["--http"])).toEqual({ ok: true });
|
||||
expect(
|
||||
parseMcpHttpArguments(["--http", "--host", "::1", "--port=8080"]),
|
||||
).toEqual({ ok: true, host: "::1", port: 8080 });
|
||||
expect(parseMcpHttpArguments(["--port", "0", "--http"])).toEqual({
|
||||
ok: true,
|
||||
port: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers help only in flag position and names the rejected argument", () => {
|
||||
expect(parseMcpHttpArguments(["--http", "-h"])).toEqual({
|
||||
ok: false,
|
||||
help: true,
|
||||
});
|
||||
expect(parseMcpHttpArguments(["--http", "--host", "--help"])).toEqual({
|
||||
ok: false,
|
||||
help: false,
|
||||
message: "Missing value for --host",
|
||||
});
|
||||
expect(parseMcpHttpArguments(["--http", "add"])).toMatchObject({
|
||||
ok: false,
|
||||
message: "Unknown mcp --http argument: add",
|
||||
});
|
||||
expect(parseMcpHttpArguments(["--port", "1"])).toMatchObject({
|
||||
ok: false,
|
||||
message: "--host and --port require --http",
|
||||
});
|
||||
for (const port of ["70000", "08", "-1", "http"])
|
||||
expect(parseMcpHttpArguments(["--http", `--port=${port}`])).toMatchObject(
|
||||
{ ok: false, message: expect.stringContaining("Invalid --port") },
|
||||
);
|
||||
expect(parseMcpHttpArguments(["--http", "--host", " "])).toMatchObject({
|
||||
ok: false,
|
||||
message: expect.stringContaining("--host must be"),
|
||||
});
|
||||
});
|
||||
|
||||
it("renders help from the option schema without Incur global options", () => {
|
||||
const help = renderMcpHttpHelp();
|
||||
expect(help).toContain("rea mcp --http");
|
||||
expect(help).toContain("--host");
|
||||
expect(help).toContain("--port");
|
||||
expect(help).toContain("MCP_PORT");
|
||||
expect(help).not.toContain("--filter-output");
|
||||
});
|
||||
|
||||
it("writes help as one newline-terminated stdout block and rejections to stderr", async () => {
|
||||
const io = () => {
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
const exitCodes: number[] = [];
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
exitCodes,
|
||||
writeStdout: (text: string) => void stdout.push(text),
|
||||
writeStderr: (text: string) => void stderr.push(text),
|
||||
setExitCode: (code: number) => void exitCodes.push(code),
|
||||
};
|
||||
};
|
||||
const help = io();
|
||||
await runMcpHttpEntrypoint(["--http", "--help"], help);
|
||||
expect(help.stdout).toEqual([`${renderMcpHttpHelp()}\n`]);
|
||||
expect(help.stderr).toEqual([]);
|
||||
expect(help.exitCodes).toEqual([0]);
|
||||
const rejected = io();
|
||||
await runMcpHttpEntrypoint(["--http", "add"], rejected);
|
||||
expect(rejected.stdout).toEqual([]);
|
||||
expect(rejected.stderr).toEqual(["Unknown mcp --http argument: add\n"]);
|
||||
expect(rejected.exitCodes).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP_HOST and MCP_PORT", () => {
|
||||
it("defaults to loopback and an ephemeral port", () => {
|
||||
expect(parseMcpHttpEndpoint({})).toEqual({
|
||||
ok: true,
|
||||
value: { host: "127.0.0.1", port: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the environment snapshot and lets explicit values win", () => {
|
||||
expect(
|
||||
parseMcpHttpEndpoint({ MCP_HOST: "192.0.2.10", MCP_PORT: "8080" }),
|
||||
).toEqual({ ok: true, value: { host: "192.0.2.10", port: 8080 } });
|
||||
expect(
|
||||
parseMcpHttpEndpoint(
|
||||
{ MCP_HOST: " ", MCP_PORT: "nope" },
|
||||
{ host: "::1", port: 1 },
|
||||
),
|
||||
).toEqual({ ok: true, value: { host: "::1", port: 1 } });
|
||||
});
|
||||
|
||||
it("rejects malformed values by setting name without echoing them", () => {
|
||||
const result = parseMcpHttpEndpoint({ MCP_HOST: " ", MCP_PORT: "65536" });
|
||||
if (result.ok) throw new Error("Expected a configuration error");
|
||||
expect(result.error.settings.map(({ setting }) => setting)).toEqual([
|
||||
"MCP_HOST",
|
||||
"MCP_PORT",
|
||||
]);
|
||||
const projected = JSON.stringify(projectAnalysisError(result.error));
|
||||
expect(projected).toContain("MCP_PORT must be a decimal integer");
|
||||
expect(projected).not.toContain("65536");
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,16 @@ import { ok as resultOk } from "../../../src/domain/result.js";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
Client,
|
||||
InMemoryTransport,
|
||||
StreamableHTTPClientTransport,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import {
|
||||
STDIO_DEFAULT_MAX_BUFFER_SIZE,
|
||||
type McpServer,
|
||||
} from "@modelcontextprotocol/server";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
@@ -39,6 +43,7 @@ import { registerGuidedPrompts } from "../../../src/server/registerPrompts.js";
|
||||
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
|
||||
import { ToolResultDelivery } from "../../../src/server/toolResult.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
import { serveStreamableHttp } from "../../../src/server/http/serveStreamableHttp.js";
|
||||
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
const temporaryDirectories: string[] = [];
|
||||
@@ -380,6 +385,97 @@ it("guided prompt completion avoids cold startup and cancels warm lookup on clos
|
||||
expect((await pending).completion.values).toEqual([]);
|
||||
}, 10_000);
|
||||
|
||||
it.each(["replacement", "shutdown"])(
|
||||
"waits for cancelled completion cleanup before HTTP %s",
|
||||
async (ending) => {
|
||||
let release!: () => void;
|
||||
let entered!: () => void;
|
||||
let cancelled!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const started = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const aborted = new Promise<void>((resolve) => {
|
||||
cancelled = resolve;
|
||||
});
|
||||
const server = new EvidenceMcpServer(
|
||||
{ name: "http-completion-drain", version: "1" },
|
||||
{},
|
||||
undefined,
|
||||
new ToolResultDelivery(STDIO_DEFAULT_MAX_BUFFER_SIZE),
|
||||
);
|
||||
const connections = vi.spyOn(server, "connect");
|
||||
let calls = 0;
|
||||
registerGuidedPrompts(server, {
|
||||
async execute(_operation, _parameters, options) {
|
||||
if (++calls > 1) return observed(["fresh-document"]);
|
||||
entered();
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
options?.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
cancelled();
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
await gate;
|
||||
}
|
||||
return observed(["old-document"]);
|
||||
},
|
||||
});
|
||||
const served = await serveStreamableHttp({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
createMcpServer: () => server,
|
||||
});
|
||||
resources.push(served);
|
||||
const connectHttp = async () => {
|
||||
const client = new Client({ name: "completion-http", version: "1" });
|
||||
resources.push(client);
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(new URL(served.url)),
|
||||
);
|
||||
return client;
|
||||
};
|
||||
const first = await connectHttp();
|
||||
let next: Client | undefined;
|
||||
let finishing: Promise<void> | undefined;
|
||||
try {
|
||||
void complete(first, "document", "").catch(() => undefined);
|
||||
await started;
|
||||
let finished = false;
|
||||
finishing = (
|
||||
ending === "shutdown"
|
||||
? served.close()
|
||||
: connectHttp().then((value) => {
|
||||
next = value;
|
||||
})
|
||||
).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await aborted;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(connections).toHaveBeenCalledTimes(1);
|
||||
expect(finished).toBe(false);
|
||||
release();
|
||||
await finishing;
|
||||
if (next !== undefined)
|
||||
expect(await complete(next, "document", "fresh")).toEqual([
|
||||
"fresh-document",
|
||||
]);
|
||||
} finally {
|
||||
release();
|
||||
await finishing?.catch(() => undefined);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
describe("guided prompt completion lifecycle", () => {
|
||||
it("survives a cancelled completion and ignores its late result", async () => {
|
||||
let calls = 0;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Client,
|
||||
StreamableHTTPClientTransport,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { expectForeignHostRefused } from "../../support/http/rawRequest.js";
|
||||
import { startMcpHttpProcess } from "../../support/mcp/streamableHttpProcess.js";
|
||||
import { processTest } from "../../support/process/processFixture.js";
|
||||
import { assertAdvertisedMcpContracts } from "./mcpContractHarness.js";
|
||||
|
||||
const HTTP_BOUNDARY_TIMEOUT_MS = 90_000;
|
||||
|
||||
const openSession = async (url: URL, name: string) => {
|
||||
const transport = new StreamableHTTPClientTransport(url);
|
||||
const client = new Client({ name, version: "1" });
|
||||
await client.connect(transport);
|
||||
expect(transport.sessionId).toEqual(expect.any(String));
|
||||
return { transport, client };
|
||||
};
|
||||
|
||||
/** The shared target answers with its identity only while its providers are still open. */
|
||||
const expectIdentity = async (client: Client): Promise<void> => {
|
||||
const identity = await client.callTool({
|
||||
name: "binary_session",
|
||||
arguments: {},
|
||||
});
|
||||
expect(identity.isError).toBeFalsy();
|
||||
expect(identity.structuredContent).toMatchObject({
|
||||
result: {
|
||||
server_identity: { package: { version: expect.any(String) } },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe("rea mcp --http", () => {
|
||||
processTest(
|
||||
"serves initialize, tools/list, a tool call and DELETE, then keeps serving later clients",
|
||||
async ({ processes }) => {
|
||||
const started = await startMcpHttpProcess(processes);
|
||||
expect(started.url.hostname).toBe("127.0.0.1");
|
||||
expect(started.url.pathname).toBe("/mcp");
|
||||
let stdout = "";
|
||||
started.child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString("utf8");
|
||||
});
|
||||
const first = await openSession(started.url, "streamable-http-boundary");
|
||||
const { tools } = await first.client.listTools();
|
||||
assertAdvertisedMcpContracts(["binary_session"], tools);
|
||||
await expectIdentity(first.client);
|
||||
await first.transport.terminateSession();
|
||||
expect(first.transport.sessionId).toBeUndefined();
|
||||
await first.client.close();
|
||||
// DELETE ended the session, not the server: the next client's tools still work.
|
||||
const second = await openSession(started.url, "next-session");
|
||||
await expectIdentity(second.client);
|
||||
// A client that leaves without DELETE does not block the listener: the next initialize replaces it.
|
||||
const third = await openSession(started.url, "replacing-session");
|
||||
expect(third.transport.sessionId).not.toBe(second.transport.sessionId);
|
||||
await expectIdentity(third.client);
|
||||
await expect(
|
||||
second.client.callTool({ name: "binary_session", arguments: {} }),
|
||||
).rejects.toThrow();
|
||||
await second.client.close();
|
||||
await third.transport.terminateSession();
|
||||
await third.client.close();
|
||||
expect(stdout).toBe("");
|
||||
expect(started.stderr()).toContain('"transport":"streamable-http"');
|
||||
expect(started.stderr()).toContain(
|
||||
"MCP HTTP session replaced by a new client",
|
||||
);
|
||||
},
|
||||
HTTP_BOUNDARY_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
processTest(
|
||||
"refuses a non-loopback Host header on the loopback bind",
|
||||
async ({ processes }) => {
|
||||
const { url } = await startMcpHttpProcess(processes);
|
||||
const refused = await expectForeignHostRefused(url);
|
||||
expect(refused.body).not.toContain("127.0.0.1");
|
||||
},
|
||||
HTTP_BOUNDARY_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
@@ -917,9 +917,21 @@ it("retains typed memory failure through failed cleanup and restores it after re
|
||||
"heap-failure",
|
||||
() => fails,
|
||||
);
|
||||
const originalRootClose = PrivateRuntimeRoot.prototype.close;
|
||||
const workspaceCloseCause = new Error("fixture workspace close failure");
|
||||
let workspaceCloseFails = true;
|
||||
let workspaceCloseAttempts = 0;
|
||||
const rootClose = vi
|
||||
.spyOn(PrivateRuntimeRoot.prototype, "close")
|
||||
.mockRejectedValueOnce(new Error("fixture workspace close failure"));
|
||||
.mockImplementation(function (this: PrivateRuntimeRoot) {
|
||||
// Independent native helpers own their own roots. Keep the acquired
|
||||
// Android workspace's failure active until the test explicitly clears it.
|
||||
if (this.path === launches[0]?.cwd) {
|
||||
workspaceCloseAttempts += 1;
|
||||
if (workspaceCloseFails) return Promise.reject(workspaceCloseCause);
|
||||
}
|
||||
return originalRootClose.call(this);
|
||||
});
|
||||
try {
|
||||
const result = await service.execute("inspect_android_package", {
|
||||
path: apk,
|
||||
@@ -935,11 +947,14 @@ it("retains typed memory failure through failed cleanup and restores it after re
|
||||
});
|
||||
const workspace = launches[0]?.cwd;
|
||||
expect(failure.cleanupResources).toContain(workspace);
|
||||
expect(workspaceCloseAttempts).toBe(0);
|
||||
fails = false;
|
||||
await expect(provider.close()).rejects.toMatchObject({
|
||||
cleanupIncomplete: true,
|
||||
});
|
||||
expect(failure.cleanupResources).toEqual([workspace]);
|
||||
expect(workspaceCloseAttempts).toBeGreaterThan(0);
|
||||
workspaceCloseFails = false;
|
||||
await provider.close();
|
||||
expect(failure.cleanupIncomplete).toBe(false);
|
||||
expect(failure.cleanupResources).toEqual([]);
|
||||
@@ -955,6 +970,7 @@ it("retains typed memory failure through failed cleanup and restores it after re
|
||||
await verifyCleanup(launches);
|
||||
} finally {
|
||||
rootClose.mockRestore();
|
||||
workspaceCloseFails = false;
|
||||
fails = false;
|
||||
await provider.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { once } from "node:events";
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { MCP_SHUTDOWN_FAILED } from "../../../src/main/messages.js";
|
||||
import { rawHttpRequest } from "../../support/http/rawRequest.js";
|
||||
import { startMcpHttpProcess } from "../../support/mcp/streamableHttpProcess.js";
|
||||
import { processTest } from "../../support/process/processFixture.js";
|
||||
|
||||
const SHUTDOWN_TIMEOUT_MS = 90_000;
|
||||
|
||||
const shutdownSignals: readonly (readonly [NodeJS.Signals, number])[] =
|
||||
process.platform === "win32"
|
||||
? [["SIGINT", 130]]
|
||||
: [
|
||||
["SIGTERM", 143],
|
||||
["SIGINT", 130],
|
||||
];
|
||||
|
||||
describe("rea mcp --http shutdown", () => {
|
||||
for (const [signal, status] of shutdownSignals)
|
||||
processTest(
|
||||
`releases the listener and exits ${String(status)} on ${signal}`,
|
||||
async ({ processes }) => {
|
||||
const started = await startMcpHttpProcess(processes);
|
||||
// The live transport refuses a GET that does not accept an event stream.
|
||||
const before = await rawHttpRequest(started.url);
|
||||
expect(before.status).toBe(406);
|
||||
started.child.kill(signal);
|
||||
const [code, signalCode] = await once(started.child, "close");
|
||||
expect(code).toBe(status);
|
||||
expect(signalCode).toBeNull();
|
||||
expect(started.stderr()).not.toContain(MCP_SHUTDOWN_FAILED);
|
||||
await expect(rawHttpRequest(started.url)).rejects.toMatchObject({
|
||||
code: "ECONNREFUSED",
|
||||
});
|
||||
},
|
||||
SHUTDOWN_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
processTest(
|
||||
"exits with the signal status when the signal lands as startup completes",
|
||||
async ({ processes }) => {
|
||||
const started = await startMcpHttpProcess(processes);
|
||||
// No request first: the signal arrives at the edge between startup and the lifecycle owner.
|
||||
const [signal, status] = shutdownSignals[0]!;
|
||||
started.child.kill(signal);
|
||||
const [code, signalCode] = await once(started.child, "close");
|
||||
expect(code).toBe(status);
|
||||
expect(signalCode).toBeNull();
|
||||
expect(started.stderr()).not.toContain(MCP_SHUTDOWN_FAILED);
|
||||
},
|
||||
SHUTDOWN_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
@@ -1,35 +1,18 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
assertHistoricalPidAccess,
|
||||
nodeInventoryThreads,
|
||||
} from "../../../scripts/verify/recorded-crash/trace-policy.mjs";
|
||||
import { assertHistoricalPidAccess } from "../../../scripts/verify/recorded-crash/trace-policy.mjs";
|
||||
|
||||
const node = "/fixture/node";
|
||||
const stat = 'openat(AT_FDCWD, "/proc/900/stat", O_RDONLY|O_CLOEXEC) = 22';
|
||||
const traces = [
|
||||
{
|
||||
pid: 100,
|
||||
text: [
|
||||
'execve("/fixture/node", ["node", "rea.mjs"], 0x1 /* 3 vars */) = 0',
|
||||
"clone3({flags=CLONE_VM|CLONE_THREAD|CLONE_SIGHAND}, 88) = 101",
|
||||
"clone(child_stack=NULL, flags=SIGCHLD) = 102",
|
||||
].join("\n"),
|
||||
},
|
||||
{ pid: 101, text: stat },
|
||||
{ pid: 102, text: stat },
|
||||
];
|
||||
|
||||
const check = (pid: number, text: string, liveCollision = true) =>
|
||||
assertHistoricalPidAccess({
|
||||
trace: { pid, text },
|
||||
historicalPid: 900,
|
||||
nodeThreads: nodeInventoryThreads(traces, node),
|
||||
ownershipThreads: new Set([100, 101]),
|
||||
liveCollision,
|
||||
psInventory: false,
|
||||
});
|
||||
|
||||
it("allows a live collision's stat read only in an observed Node process or thread", () => {
|
||||
expect([...nodeInventoryThreads(traces, node)]).toEqual([100, 101]);
|
||||
it("allows stat reads only in the supplied ownership thread set", () => {
|
||||
expect(check(100, stat)).toEqual([stat]);
|
||||
expect(check(101, stat)).toEqual([stat]);
|
||||
expect(() => check(102, stat)).toThrow("non-inventory lookup");
|
||||
@@ -51,7 +34,7 @@ it("permits procps UID metadata without granting a PID directory handle", () =>
|
||||
const input = {
|
||||
trace: { pid: 102, text: metadata },
|
||||
historicalPid: 900,
|
||||
nodeThreads: new Set<number>(),
|
||||
ownershipThreads: new Set<number>(),
|
||||
liveCollision: true,
|
||||
psInventory: true,
|
||||
};
|
||||
@@ -119,7 +102,7 @@ it("keeps descriptor reuse and distinct PIDs separate and refuses ps writes", ()
|
||||
assertHistoricalPidAccess({
|
||||
trace: { pid: 102, text: stat.replace("O_RDONLY", "O_WRONLY") },
|
||||
historicalPid: 900,
|
||||
nodeThreads: new Set(),
|
||||
ownershipThreads: new Set(),
|
||||
liveCollision: true,
|
||||
psInventory: true,
|
||||
}),
|
||||
@@ -152,22 +135,6 @@ it("keeps native priority UID selectors and distinct PIDs separate", () => {
|
||||
expect(check(101, "getpriority(PRIO_PROCESS, 9000) = 20")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps exec-replaced threads foreign and follows acknowledged unfinished thread clones", () => {
|
||||
const observed = [
|
||||
{
|
||||
pid: 100,
|
||||
text: [
|
||||
traces[0]!.text,
|
||||
"clone3({flags=CLONE_VM|CLONE_THREAD|CLONE_SIGHAND}, 88 <unfinished ...>",
|
||||
"<... clone3 resumed>) = 103",
|
||||
].join("\n"),
|
||||
},
|
||||
{ pid: 101, text: 'execve("/fixture/gdb", ["gdb"], 0x1) = 0\n' + stat },
|
||||
{ pid: 103, text: stat },
|
||||
];
|
||||
expect([...nodeInventoryThreads(observed, node)]).toEqual([100, 103]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
String.raw`openat(AT_FDCWD</fixture>, "/fixture/\345\267\245\345\205\267", O_RDONLY) = -1 ENOENT (No such file or directory)`,
|
||||
String.raw`openat(5</fixture>, "\345\267\245\345\205\267", O_RDONLY) = -1 ENOENT (No such file or directory)`,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { resolve as resolvePath } from "node:path";
|
||||
|
||||
import type { TestProcesses } from "../process/processFixture.js";
|
||||
|
||||
const LISTENING_MESSAGE = "MCP transport listening";
|
||||
const STARTUP_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** A compiled `rea mcp --http` child and the URL it announced. */
|
||||
export interface McpHttpProcess {
|
||||
readonly child: ChildProcessWithoutNullStreams;
|
||||
readonly url: URL;
|
||||
/** Everything the child has written to stderr so far. */
|
||||
readonly stderr: () => string;
|
||||
}
|
||||
|
||||
/** Find the bound URL in the pino JSON lines the listener writes to stderr. */
|
||||
const listeningUrl = (stderr: string): URL | undefined => {
|
||||
for (const line of stderr.split("\n")) {
|
||||
let entry: unknown;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
typeof entry === "object" &&
|
||||
entry !== null &&
|
||||
"msg" in entry &&
|
||||
entry.msg === LISTENING_MESSAGE &&
|
||||
"url" in entry &&
|
||||
typeof entry.url === "string"
|
||||
)
|
||||
return new URL(entry.url);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Spawn `rea mcp --http --port 0` with stdin closed and resolve once it names its URL. */
|
||||
export const startMcpHttpProcess = async (
|
||||
processes: TestProcesses,
|
||||
arguments_: readonly string[] = [],
|
||||
): Promise<McpHttpProcess> => {
|
||||
const child = processes.spawn(
|
||||
process.execPath,
|
||||
[
|
||||
resolvePath("scripts/rea.mjs"),
|
||||
"mcp",
|
||||
"--http",
|
||||
"--port",
|
||||
"0",
|
||||
...arguments_,
|
||||
],
|
||||
{
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
|
||||
},
|
||||
},
|
||||
);
|
||||
// A closed stdin must not end the HTTP listener; only signals do.
|
||||
child.stdin.end();
|
||||
let stderr = "";
|
||||
const url = await new Promise<URL>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`No listening line within ${String(STARTUP_TIMEOUT_MS)} ms:\n${stderr}`,
|
||||
),
|
||||
),
|
||||
STARTUP_TIMEOUT_MS,
|
||||
);
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
const found = listeningUrl(stderr);
|
||||
if (found === undefined) return;
|
||||
clearTimeout(timer);
|
||||
resolve(found);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
reject(
|
||||
new Error(
|
||||
`Listener exited early (${String(code)}, ${String(signal)}):\n${stderr}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
return { child, url, stderr: () => stderr };
|
||||
};
|
||||
Reference in new issue
Block a user