feat(environment): export an environment manifest as an agent environment bundle (#4999)

Export an authenticated capture and environment manifest as a pinned agentenv bundle with provenance, a replay verifier and smoke task.

Followups preserve immutable input ownership, bounded discovery, caller paths and numeric values; bind bundle writes and package copying to retained filesystem owners; verify POSIX modes; retain cleanup authority; and verify framework/npm executable selection. Default MCP remains stdio.

Validation: pinned Linux toolchain, typecheck/build/lint/format/docs/knip, 452 focused tests, ten product-catalog cases, real GitHub response replay in the generated non-root Docker image, and the real pinned agentenv framework smoke workflow. Platform and resource-measurement limits are recorded in the feature verification document.

Co-authored-by: gensokyo-dev <gensokyo-dev@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Edgar Arakelyan
2026-10-19 14:29:43 -07:00
committed by GitHub
parent b4938aed58
commit 7a0c47bfa4
40 changed files with 4038 additions and 108 deletions
+11 -1
View File
@@ -59,6 +59,7 @@ jobs:
web_network: ${{ steps.plan.outputs.web_network }}
web_runtime: ${{ steps.plan.outputs.web_runtime }}
web_source_map: ${{ steps.plan.outputs.web_source_map }}
agentenv: ${{ steps.plan.outputs.agentenv }}
nativeaot: ${{ steps.plan.outputs.nativeaot }}
ghidra_bridge: ${{ steps.plan.outputs.ghidra_bridge }}
website: ${{ steps.plan.outputs.website }}
@@ -302,7 +303,8 @@ jobs:
needs.changes.outputs.recorded_crash == 'true' ||
needs.changes.outputs.web_network == 'true' ||
needs.changes.outputs.web_runtime == 'true' ||
needs.changes.outputs.web_source_map == 'true'
needs.changes.outputs.web_source_map == 'true' ||
needs.changes.outputs.agentenv == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
@@ -909,6 +911,13 @@ jobs:
with:
build_cache_artifact: rea-build-cache
agentenv:
needs: [changes, static, build]
if: needs.changes.outputs.agentenv == 'true'
uses: ./.github/workflows/real-agentenv.yml
with:
build_cache_artifact: rea-build-cache
nativeaot:
needs: [changes, static]
if: needs.changes.outputs.nativeaot == 'true'
@@ -953,6 +962,7 @@ jobs:
web_network,
web_runtime,
web_source_map,
agentenv,
nativeaot,
ghidra_bridge,
website,
+82
View File
@@ -0,0 +1,82 @@
name: Real agentenv environment bundle
on:
workflow_dispatch:
workflow_call:
inputs:
build_cache_artifact:
description: Portable build cache artifact from the calling CI run.
required: false
type: string
default: ""
permissions:
contents: read
env:
HUSKY: "0"
NODE_OPTIONS: --max-old-space-size=768
PIP_DISABLE_PIP_VERSION_CHECK: "1"
jobs:
real-agentenv:
name: Export and run an environment bundle with the agentenv runner
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- uses: ./.github/actions/portable-build-cache
with:
build_cache_artifact: ${{ inputs.build_cache_artifact }}
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
# Public ECR caps anonymous pulls by data per source IP and hosted runners share IPs, so the
# framework's image builds can fail here for hours. mirror.gcr.io serves the same Docker
# official images under the same digests, so each base image the pinned framework builds from
# is pulled there and named as its Dockerfile names it, as the framework's own CI does.
# Digest-pinned names are set in containerd, whose images Docker reads only on the containerd
# image store; switching stores restarts dockerd, so it happens before anything uses Docker.
- name: Use the containerd image store
run: |
[ -s /etc/docker/daemon.json ] || echo '{}' | sudo tee /etc/docker/daemon.json >/dev/null
jq '.features["containerd-snapshotter"] = true' /etc/docker/daemon.json > "$RUNNER_TEMP/daemon.json"
sudo mv "$RUNNER_TEMP/daemon.json" /etc/docker/daemon.json
sudo systemctl restart docker
docker info --format '{{json .DriverStatus}}' | grep -q io.containerd.snapshotter
- name: Acquire the pinned agentenv runner in isolated runner storage and check host Docker
shell: bash
run: |
runner_root="${RUNNER_TEMP}/rea-agentenv-runner"
python -m venv "${runner_root}"
"${runner_root}/bin/python" -m pip install --require-virtualenv --no-cache-dir 'agentenv-framework==0.9.1334'
"${runner_root}/bin/agent-env" --version
docker version --format '{{.Server.Version}}'
echo "REA_AGENT_ENV_COMMAND=${runner_root}/bin/agent-env" >> "${GITHUB_ENV}"
echo "REA_DOCKER_COMMAND=$(command -v docker)" >> "${GITHUB_ENV}"
- name: Pull the pinned framework's public ECR base images from mirror.gcr.io
shell: bash
run: |
package="$("${REA_AGENT_ENV_COMMAND%/agent-env}/python" -c 'import agent_env, pathlib; print(pathlib.Path(agent_env.__file__).parent)')"
grep -rhoE '^FROM +public\.ecr\.aws/docker/library/[^ ]+' --include='Dockerfile*' "${package}" | awk '{print $2}' | sort -u |
while read -r image; do
name=${image#public.ecr.aws/docker/library/}
source=mirror.gcr.io/library/$name
case $name in *@*) source=mirror.gcr.io/library/${name%%[:@]*}@${name#*@} ;; esac
for attempt in 1 2 3; do
docker pull --quiet "$source" && break
if [ "$attempt" = 3 ]; then echo "::warning::couldn't pull $source; $image's build will pull it"; continue 2; fi
sleep $((attempt * 10))
done
case ${name%@*} in *:*) docker tag "$source" "${image%@*}" ;; esac
case $image in *@*) sudo ctr --namespace moby images tag "$source" "$image" >/dev/null ;; esac
done
- name: Export a bundle from the source-owned capture and run it with the agentenv runner
run: npm run verify:agentenv:environment
+90 -1
View File
@@ -4,7 +4,8 @@ Use `rea environment-serve` to serve an environment manifest as an agent
environment: an MCP server whose tools answer from the records of one retained
web network capture, plus the environment card the agentenv framework reads to
discover it. Nothing is fetched from the recorded application; every answer is
retained Evidence.
retained Evidence. `rea environment-export` writes the same pair as a bundle
folder `agent-env run` builds and runs; see [Export a bundle](#export-a-bundle).
```sh
rea inspect-web-network-capture /absolute/session.har har --format json > capture.json
@@ -225,6 +226,94 @@ schema does not carry.
`[]` and `null`: a replay environment declares no extensions, no data-plane
operations and no children.
## Export a bundle
`rea environment-export` writes the same pair as a bundle folder the agentenv
framework runs directly, shaped like its own echo example:
```sh
rea environment-export manifest.json --evidence capture.json ./grafana-example --json
agent-env run ./grafana-example
```
```text
grafana-example/
README.md provenance, replay limits, run command, rights note
envs/grafana-example/Dockerfile pinned image: node:24.18.0-bookworm-slim + rea-agents@<this version>
envs/grafana-example/env.toml environment_name = "grafana-example"
envs/grafana-example/manifest.json byte-exact copy of manifest.json
envs/grafana-example/evidence.json byte-exact copy of capture.json
artifacts/check_grafana-example/verify.py the replay verifier
tasks/smoke.json deploy_env (local sandbox), then env_outcome_verifier
```
The command reads each file once, after checking that reading and parsing the
whole file, including sealing traversal, fits the process's live heap headroom (a file that does not is
refused as a `resource_constraint` before it is read), then authenticates and cross-checks the
pair as `environment-serve` does, so a manifest projected from another capture
is refused with the same `invalid_request` before anything is written, and the
bytes it copies are the bytes it authenticated. The bundle directory must be
absent or empty; an occupied directory is refused and nothing in it changes.
The complete tool catalog must also fit the server's advertised catalog budget
before any files are written. Privately parsed JSON is sealed once so authentication
and replay share immutable response trees rather than copying the capture repeatedly.
`--name` replaces the manifest's portable kebab-case name as the env folder and env id
(Windows device names such as `con` and `lpt1` are refused);
`--package-tarball <path>` copies a packed `rea-agents` tarball into the env
folder and makes the Dockerfile install it instead of the registry package and
remove it in the same layer, which is how a checkout rather than a release is
run. The command prints the
written files with their sizes and the pinned package source.
Export uses the shared output-tree owner to check directory identities around
exclusive creates, verify written bytes, and reject unowned entries before
reporting success. It does not delete an inspectable partial folder on failure.
Node's portable pathname APIs leave a syscall-boundary race, so the output's
parent directories must be trusted. On POSIX output files use mode `0600`;
Windows inherits ACLs and REA does not establish owner-only access there. Choose
an already private Windows destination when captured content needs it.
The Dockerfile sets `MCP_HOST=0.0.0.0` and `MCP_PORT=18765`, the port the
framework maps for an MCP server env; the framework's own deploy supplies
`MCP_HOST`, `MCP_PORT` and `ENVIRONMENT_NAME`, so the served card carries the
env's name. It installs Debian's `python3` because the framework's local
sandbox probes a container's readiness with `python3` inside it, which the slim
Node image lacks. It copies the two inputs owned by the image's non-root `node`
user, so a private host umask cannot leave them unreadable, runs as that user and starts
`rea environment-serve /env/manifest.json --evidence /env/evidence.json`. The
`env.toml` names the environment because the framework otherwise reads the
name from Python sources the image does not have.
`verify.py` is the framework's `async def verify(mcp_url)` shape over its
Python MCP client: it lists the tools and checks the count equals the
manifest's, calls the first `GET` tool without path parameters with no
arguments and checks the call did not fail and its replayed `status` equals the
recorded status of that tool's first cited record (an unknown recorded status
stays unknown; it is not borrowed from another record), and returns one
`replay` result whose description
reports both checks. A manifest with no such tool yields a verifier that checks
the count only and says so. `tasks/smoke.json` deploys the env on the local
sandbox and runs that verifier against the env's MCP endpoint; the first run
also builds the framework's own infrastructure, and the run tears everything
down when it ends.
The README written into the bundle records the capture artifact's SHA-256, the
capture and manifest Evidence ids, the manifest view digest, the tool count and
the package source, and states that the recorded content belongs to the
captured application and must not be published without the rights to it.
## Compatibility verification
`npm run verify:agentenv:environment` is the hosted `real-agentenv` lane
described in [testing](testing.md#exported-environment-bundles). It projects
the Grafana-shaped source-owned capture through the built CLI, packs the
checkout, exports a bundle with `--package-tarball`, runs it with the pinned
`agentenv-framework` release on the runner's Docker and requires the smoke task
to pass with no container left behind other than the framework's own
image-registry helper, which the receipt reports. It proves that one exported bundle runs
on one framework release; it does not claim any hosted platform or other
framework versions.
## Resource and lifecycle boundaries
Both Evidence files are read once at startup through the bounded JSON input
+38
View File
@@ -361,6 +361,35 @@ like a blank `MCP_HOST`. The bound URL is written to stderr as one JSON log line
[agent environments](agent-environments.md) for what replay returns and does
not establish.
## Export an environment bundle
`rea environment-export <manifest.json> --evidence <capture.json> <dir>
[--package-tarball <path>] [--name <override>]` writes the same manifest and
capture pair as a bundle folder the agentenv framework runs with
`agent-env run <dir>`. It reads each file once, authenticates and cross-checks
the pair with the same checks as `environment-serve`, copies the bytes it
authenticated, refuses to write into a directory that exists and is not empty,
and never overwrites a file.
```sh
rea environment-export manifest.json --evidence capture.json ./grafana-example --json
```
The folder holds a `README.md` with the bundle's provenance, an env folder
`envs/<name>/` with a `Dockerfile` that installs this exact `rea-agents`
version and serves the copied `manifest.json` and `evidence.json` (written
with mode `0600` on POSIX; Windows retains inherited ACLs) with
`environment-serve`, a Python verifier under `artifacts/check_<name>/` and the
smoke task `tasks/smoke.json`. `--package-tarball` copies a packed `rea-agents`
tarball into the env folder and installs it instead of the registry package,
which is how the hosted lane proves a checkout rather than a release. `--name`
replaces the manifest's kebab-case name as the env folder and env id; a device
name Windows reserves (`con`, `nul`, `com1` and the like) is refused. The
command prints one JSON document listing the written files with their sizes
and the pinned package source. See
[agent environments](agent-environments.md#export-a-bundle) for the folder
layout and what the smoke task checks.
## Output and exit status
`rea mcp doctor --json` checks production MCP startup and reports child logs in
@@ -407,6 +436,15 @@ Refusals before binding (unreadable files, non-Evidence JSON, capture Evidence
in a format other than HAR, a manifest whose parent is not the supplied
evidence, an unbindable port) print a structured
error and exit `1`, as does a listener that fails after binding.
`rea environment-export` is one-shot: it prints the written files and exits
`0`, or prints a structured `invalid_request` error and exits `1` for the same
pair refusals, a non-empty or file-occupied directory, a missing package
tarball or a name that is not a kebab-case label or is a Windows device name,
writing nothing in each case.
A write that fails after the first file (a permission or disk-space failure
on a later path) propagates as the host's failure and leaves the partial
folder in place for inspection; remove it before retrying, since the next
attempt refuses the non-empty directory.
A `cancelled` result does not by itself determine the exit status; it depends on
the command's signal handling. Commands routed through the shared
@@ -0,0 +1,25 @@
# Implementation plan
1. Extend `src/application/environment/environmentSource.ts` so
`loadEnvironmentSource` takes the reporting operation and retains the
capture artifact's SHA-256, letting the export reuse the serve command's
authentication and cross-check unchanged.
2. Add `environmentBundle.ts` to plan the bundle from the source alone: the
name rule, the Dockerfile for the registry and tarball package sources, the
verifier, the smoke task, the README and the copy targets.
3. Add `environmentExport.ts` to read each input once through the
regular-file reader, refuse an occupied directory, a missing tarball or an
unreadable input before writing, then write the planned files with
exclusive creation and copy the authenticated bytes and the tarball
byte-exact.
4. Register `environment-export` in `src/cli/environmentCommands.ts` beside
`environment-serve`, sharing the argument descriptions, and in
`CLI_COMMANDS`.
5. Add the `agentenv` scope, `real-agentenv.yml`, the package script and
`scripts/verify-agentenv-environment.mjs`, wire `ci.yml` and extend the
`ciRequiredLanes` and `ciChangeScope` tables.
6. Add services, conformance and cli-boundary tests; document the command in
`docs/cli.md`, the bundle and lane in `docs/agent-environments.md` and
`docs/testing.md`; record the spec.
No new dependency is introduced; the CLI inventory gains one command.
@@ -0,0 +1,116 @@
# Export an environment manifest as an agent environment bundle
Issue: https://github.com/morluto/rea/issues/4933
## Outcome
`rea environment-export <manifest.json> --evidence <capture.json> <dir>
[--package-tarball <path>] [--name <override>]` writes the manifest and
capture pair that `environment-serve` serves as a bundle folder the agentenv
framework runs with `agent-env run <dir>`: a README with provenance, an env
folder whose Dockerfile installs this exact `rea-agents` version and serves the
copied pair, a Python replay verifier and a smoke task. The hosted
`real-agentenv` lane exports such a bundle from the source-owned Grafana-shaped
capture through the built CLI, packs the checkout into the image and runs it
with the pinned framework release on the runner's Docker. This increment
completes the agent-environment work of the issue; serving came in the
previous increment.
## Requirements
- Read each of the two files once through the regular-file reader, parse it
from those bytes and authenticate and cross-check the pair with
`loadEnvironmentSource` as `environment-serve` does, so the bytes copied into
the bundle are the bytes that were authenticated; every refusal reports
operation `environment-export`, and a manifest projected from a different
capture is refused before anything is written.
- Refuse a bundle directory that exists and is not empty, or that names a file,
with an `invalid_request` whose issue path is `directory`; an absent or empty
directory is accepted. Write every file with the exclusive-create flag so
nothing is ever overwritten.
- Write `README.md`, `envs/<name>/Dockerfile`, `envs/<name>/env.toml`,
`envs/<name>/manifest.json` and `envs/<name>/evidence.json` as byte-exact
copies of the inputs (mode `0600` on POSIX; Windows retains inherited ACLs
and does not provide an owner-only guarantee), `artifacts/check_<name>/verify.py` and
`tasks/smoke.json`, where `<name>` defaults to the manifest's kebab-case name
and `--name` must be a kebab-case label of at most 64 characters that is not a
device name Windows reserves (`con`, `prn`, `aux`, `nul`, `com1`–`com9`,
`lpt1`–`lpt9`), since the env folder could not be created there.
- The Dockerfile reads `FROM node:24.18.0-bookworm-slim`, sets
`MCP_HOST=0.0.0.0` and `MCP_PORT=18765`, installs Debian's `python3` for the
framework's readiness probe, installs
`rea-agents@<PRODUCT_IDENTITY.packageVersion>` from the registry or, with
`--package-tarball`, copies the tarball as `rea-agents.tgz`, installs it
from `/tmp/` and removes it in the same layer, copies the pair to `/env/`,
switches to the image's `node` user
and runs `rea environment-serve /env/manifest.json --evidence
/env/evidence.json`.
- `verify.py` mirrors the framework's echo example: `async def verify(mcp_url)`
over `mcp.client.streamable_http`, lists the tools and checks the count
equals the manifest's, calls the first `GET` tool without path parameters
with no arguments and checks the replayed `status` equals the recorded status
of that tool's first cited record, prints the description and returns
`[{"id": "replay", "result": <bool>, "description": <both checks>}]`; with no
such tool it checks the count only and says so.
- `tasks/smoke.json` is `deploy_env` of `<name>` on the local sandbox followed
by `env_outcome_verifier` with `file_artifact_id` `check_<name>` and
`verifier_id` `replay`. The README records the capture artifact SHA-256, both
Evidence ids, the manifest view digest, the tool count and the package
source, states what replay does and does not establish, names the run
command as `agent-env run .` from the bundle root (the folder it sits in,
whatever the caller named it), and carries the rights note.
- Print one JSON document with `status: "exported"`, the directory, name, tool
count, both Evidence ids, the pinned `package_source` and the written files
with sizes. Register the flat `environment-export` command in the CLI
inventory; no MCP tool is added.
- Add the `agentenv` CI scope, the reusable `real-agentenv.yml` workflow
(`workflow_dispatch` and `workflow_call` with `build_cache_artifact`,
`ubuntu-24.04`, SHA-pinned actions, `setup-python` 3.12, a virtual
environment with `agentenv-framework==0.9.1334`, the runner's Docker, 30
minutes), the `verify:agentenv:environment` script and
`scripts/verify-agentenv-environment.mjs`, which requires absolute
`REA_AGENT_ENV_COMMAND` and `REA_DOCKER_COMMAND` like every other lane
verifier, runs `agent-env run <dir> --sandbox local` bounded to 15 minutes
and resolves nothing from `PATH`; wire `ci.yml` at the four spots and extend
the two CI guard tests.
## Acceptance and limits
Services tests cover the planned file set, the Dockerfile for both package
sources, the verifier text, the smoke task, the README, the name override and
its refusals, writing into an absent and an empty directory with byte-exact
copies and correct sizes, the tarball copy, and the refusals of a non-empty
directory, a file path, a missing tarball and an inline manifest. The
conformance test pins the registry install to the `package.json` version. The
cli-boundary test writes the folder from the fixture pair through the built
CLI, refuses a non-empty directory leaving it unchanged, and refuses a
mismatched pair without creating the directory. The CI guard tests enumerate
the new consumer, route and classification cases. The local end-to-end run
recorded in `verification.md` is the proof that one exported bundle runs under
`agent-env run` with the smoke task passed and no container left behind.
The export copies bytes and never inspects the tarball; it does not build or
run the image itself. Its writes are not transactional: a host failure after
the first file leaves the partial folder for inspection, as `docs/cli.md`
states. The verifier checks one replayed status and the tool count, not every
tool. The lane passes `--sandbox local` as designed although
`tasks/smoke.json` already pins `sandbox_type: local` on its deploy step, so
the flag restates what the task fixes. The lane verifier imports the
Grafana-shaped HAR builder from `tests/fixtures/historicalHar.ts` through
Node's type stripping rather than carrying a second copy under `scripts/`; the
`agentenv` scope lists that fixture so a change to it runs the lane. The lane
proves one framework release on one hosted runner and claims nothing about
any hosted platform. Three additions to the
designed Dockerfile and file set were needed by the framework, each found by
the local run: `MCP_PORT=18765` as the listener default for the port the
framework maps, Debian's `python3` because the framework's local sandbox
probes readiness with `python3 -c "import socket; …"` inside the container and
the slim Node image has none, and `env.toml` naming the environment, because
the framework otherwise reads the name from Python sources the Node image does
not have.
## Deferred issue criteria
This increment closes the agent-environment scope of #4933 together with the
previous three. Running the lane on the maintainer's CI and any follow-up on
hosted platforms are outside this PR.
@@ -0,0 +1,12 @@
# Tasks
- [x] Record the export contract, requirements and the lane.
- [x] Thread the operation name and capture digest through the source loader.
- [x] Plan the bundle: name rule, Dockerfile for both package sources, verifier, smoke task, README.
- [x] Write the bundle with directory, tarball and input refusals and exclusive creation.
- [x] Register the flat CLI command with described arguments and options.
- [x] Add the agentenv scope, workflow, package script and verifier; wire ci.yml; extend the guard tests.
- [x] Add services, conformance and cli-boundary coverage.
- [x] Document the command, the bundle and the lane; record the spec.
- [x] Run static, formatting, duplication, documentation and test gates and the local end-to-end run.
- [x] Repair the review findings (README run command, absolute tool paths, single read, tarball cleanup) and re-run the gates and the end-to-end run.
@@ -0,0 +1,362 @@
# Verification
The initial author record below describes the original macOS tree. The Linux
followup record at the end covers the integrated serve changes and subsequent
repairs in this PR. The two records have different toolchains and cache states.
Verified base: 24574576e4fa74ff56eda965ae287555b7552c03, the tip of the environment-serve increment on fetched main b83b9caf (this increment builds on it).
The worktree carries the manifest-view, streamable-HTTP and serve increments
as commits on upstream main b83b9caf; this increment's changes are uncommitted
edits on top. Every check below ran as written on this tree after the review
repairs; nothing is reported that did not run. Timings are macOS
`/usr/bin/time -l` command accounting (elapsed, user, system seconds and
maximum resident set size of the command and the children it waited for) on
macOS 26.5.1 with Node 22.23.1, npm 12.0.2, Docker 29.5.3 and a Python 3.12.12
virtual environment holding `agentenv-framework 0.9.1334`.
## Review findings addressed
An adversarial review of the first pass found two should-fix findings and
seven nits; each was repaired at its owning boundary before the gates below
ran.
- The bundle README told the reader to run `agent-env run <name>`, which only
works from the bundle's parent when the folder happens to carry the env's
name. The README now says `agent-env run .` from the bundle root and names
the folder's path for any other working directory; the services test
asserts the new block and the absence of the old command.
- The lane verifier resolved `agent-env` and `docker` from `PATH` when the
`REA_*` variables were unset. It now requires absolute
`REA_AGENT_ENV_COMMAND` and `REA_DOCKER_COMMAND`, throwing the standard
actionable message naming the npm script and that nothing is installed, and
checks both paths exist; the workflow exports `REA_DOCKER_COMMAND` from
`command -v docker` beside the virtual environment's `agent-env`;
`docs/testing.md` names both variables and drops the `PATH` default. No
Linux preflight was added, so the lane stays runnable on a macOS host.
- The brittle `assert.doesNotMatch(stdout, /failed/)` is gone; exit code `0`
and the `tasks/smoke.json v<n>: passed` summary already decide the case.
- The inner `agent-env run` timeout is 15 minutes inside the 30-minute job,
so a hung run ends with the verifier's failed receipt rather than the
runner's kill; `docs/testing.md` says so.
- The pair was read twice: parsed once for authentication and re-read from
disk for the byte-exact copies, so a file replaced between the reads was
copied unchecked. `readEnvironmentExportInputs` now reads each file once
through the regular-file reader, parses the value from those bytes with the
shared UTF-8 JSON parser, and the command passes the same buffers to the
export, which copies them; the refusals of inline JSON, a directory and a
file that is not JSON are typed by role, and the spec, plan and both guides
state the single read.
- The tarball Dockerfile left the 6 MB `rea-agents.tgz` in `/tmp` of the
image; the install layer now removes it (`&& rm /tmp/rea-agents.tgz`),
confirmed by listing `/tmp` in the built image below.
- The verifier passes `--sandbox local` as the design specifies; the spec
records that the task's `sandbox_type: local` already pins the provider and
the flag restates it.
- The verifier's import of the HAR builder from `tests/fixtures/` through
Node's type stripping is kept and recorded in the spec's limits with the
reason (no second copy under `scripts/`; the scope lists the fixture).
- Writes are not transactional; `docs/cli.md` now states that a host failure
after the first file leaves the partial folder for inspection and that the
next attempt refuses the non-empty directory, and the spec's limits say so.
## Setup
`npm ci` completed (exit 0; 373 top-level entries). `/usr/bin/time -l npm run
build` passed with 3 tasks successful in 7.60 s elapsed (14.81 s user, 2.69 s
sys, 3013410816 bytes); `/usr/bin/time -l npm run test:prepare` passed with 6
tasks successful, 3 cached, in 1.63 s (3.47 s user, 0.81 s sys, 400752640
bytes).
## Static gates
Every gate ran on the final tree after the last source edit, sequentially:
`npm run check:fast` (typecheck and lint with module boundaries; 2 tasks
successful; 5.28 s elapsed, 23.22 s user, 2.93 s sys, 4564680704 bytes);
`npm run format` rewrote nothing outside this increment's files ("Finished in
635ms on 4077 files"), and `npm run format:check` passed ("All matched files
use the correct format"; 0.87 s, 4.30 s user, 0.64 s sys, 224051200 bytes);
`npx knip` reported no findings (3.49 s, 5.04 s user, 0.72 s sys, 805191680
bytes); `npm run verify:test-discovery` reported "Discovered all 1597 test
files exactly once" (0.55 s, 0.38 s user, 0.14 s sys, 141492224 bytes);
`npm run check:ci:tasks` (typecheck, lint:built, format:check, knip,
metadata:check, jscpd, scan:todos) passed with 8 tasks successful, lint "3764
files, 0 errors, 1690 warnings" and jscpd "3171 files, 2587 clones, 3.34%
duplicated lines" under the 3.5% threshold (4.89 s elapsed, 15.19 s user,
3.86 s sys, 874627072 bytes); `npm run docs:check` passed including the README
consistency test (6 tasks, 5 tests; 4.00 s elapsed, 3.86 s user, 0.94 s sys,
290111488 bytes). `actionlint 1.7.7` (`-shellcheck= -pyflakes=`, from the
review's scratch virtual environment) reported no findings on
`.github/workflows/real-agentenv.yml` and `.github/workflows/ci.yml`. A
case-insensitive grep for the forbidden words and marker comments over the 23
new or modified files matched only the `scan:todos` script name in
`package.json` and this file.
## Test projects
Source group: `/usr/bin/time -l npx vitest run --project services --project
conformance` passed 122 files, 1524 tests in 11.08 s elapsed, 23.10 s user,
2.24 s sys, 1631240192 bytes. The increment's files are
`environmentBundle.test.ts` (7 tests: the planned file set and copy targets,
the registry Dockerfile pinned to `PACKAGE_METADATA.version` with the
readiness-probe layer, the tarball Dockerfile installing and removing
`/tmp/rea-agents.tgz`, the verifier text naming `get_annotations` as the
first parameterless `GET` tool with recorded status 200 of record 25, the
smoke task, the README's provenance, `agent-env run .` block and rights note,
the name override and six refused names), `environmentExport.test.ts` (6
tests: the reader returning each file's bytes and parsed value, its refusals
of inline JSON, a directory and malformed JSON by role, writing into an absent
directory with the read bytes copied unchanged and reported sizes, the
tarball copy, the refusals of a non-empty directory, a file path and a missing
tarball with nothing written, and an existing empty directory accepted),
`environmentSource.test.ts` (4 tests; the loader takes the reporting
operation) and the conformance test `environmentBundlePackagePin.test.ts` (1
test: the registry install line ends with `rea-agents@<package.json version>`
and never uses `latest` or a range).
Built group after `npm run test:prepare`: `/usr/bin/time -l npx vitest run
--project cli-boundary --project boundary` over `environmentExportCli` (3),
`ciChangeScope` (228), `ciRequiredLanes` (17), `workflowActionPinning` (5),
`ciCachePolicy` (6), `cliToolCoverage` (1), `cliStartupImports` (5) and
`ciTestProjects` (11) passed 8 files, 276 tests in 432.80 s elapsed, 303.47 s
user, 92.63 s sys, 422412288 bytes; `tests/boundary/filesystem/productCatalog`
passed separately (10 tests, 5.32 s). The cli-boundary file writes the folder
from the fixture pair through the built CLI and checks the seven written
paths, the byte-exact copies and the Dockerfile's first line and `CMD`;
refuses a non-empty directory leaving its one file in place; and refuses a
mismatched pair without creating the directory.
## Local end-to-end run
With the built CLI in this worktree, from a fresh scratch directory:
`inspect-web-network-capture grafana-like.har har --format json --json` wrote
capture Evidence `ev_4ee4759b…` (28 records); `inspect-analysis-view` with the
inline source and all three template rules wrote manifest Evidence
`ev_6553a8e5…` naming that parent, `grafana-example`, 19 tools, view digest
`8d1eb3ee…`. `npm pack --ignore-scripts` of the checkout produced
`rea-agents-6.6.0.tgz` (6039238 bytes). `environment-export manifest.json
--evidence capture.json <tmp>/grafana-example --package-tarball <tgz> --json`
exited 0 and printed `status: exported`, `tool_count: 19`, `package_source:
{kind: tarball}` and eight files: `README.md` (2076 bytes),
`envs/grafana-example/env.toml` (37), `envs/grafana-example/Dockerfile` (441),
`artifacts/check_grafana-example/verify.py` (1534), `tasks/smoke.json` (291),
`envs/grafana-example/manifest.json` (15701),
`envs/grafana-example/evidence.json` (58296) and
`envs/grafana-example/rea-agents.tgz` (6039238); `cmp` confirmed the two
Evidence copies and the tarball byte-identical to the inputs. The Dockerfile's
install line reads `RUN npm install -g --ignore-scripts --no-fund --no-audit
/tmp/rea-agents.tgz && rm /tmp/rea-agents.tgz`, and the README's run section
is the `agent-env run .` block.
`/usr/bin/time -l agent-env run <tmp>/grafana-example` exited 0 in 43.66 s
elapsed (5.19 s user, 3.26 s sys, 129318912 bytes), output
```text
[tasks/smoke.json] step 1/2 env done in 25.1s
[tasks/smoke.json] step 2/2 replay (env_outcome_verifier)
replay: listed 19 tools, expected 19; get_annotations replayed status 200, recorded 200
[tasks/smoke.json] passed in 25.1s
Tasks:
tasks/smoke.json v1: passed (replay: 1), 25.1s, instance @local/…/grafana-example/smoke-nwvvojs7
Tore down 1 sandbox.
```
`docker ps -aq` before and after the run listed the same three pre-existing
containers (a Grafana container, an exited container and the framework's
registry from earlier sessions, unrelated to this run); no container was
added, and the image count rose from 19 to 20 for the built env image
`5a5b3bf62aae`. `docker run --rm --entrypoint sh 5a5b3bf62aae -c 'ls -la
/tmp; rea --version; id -un'` listed only `node-compile-cache` in `/tmp` (no
`rea-agents.tgz`), printed `6.6.0` and ran as `node`.
`REA_AGENT_ENV_COMMAND=<venv>/bin/agent-env REA_DOCKER_COMMAND=$(command -v
docker) /usr/bin/time -l node scripts/verify-agentenv-environment.mjs` exited
0 in 37.24 s elapsed (10.42 s user, 4.45 s sys, 268533760 bytes) and printed
the receipt `status: passed`, `public_cases: 5`, `upstream: agentenv-framework
0.9.1334`, `runner: darwin arm64; host Docker`, `docker_before: {containers:
3, images: 20}`, `agent_env_run: {exit_code: 0, seconds: 32}` with the same
passed summary in its stdout tail, `docker_after: {containers: 3, images: 21,
new_containers: []}` and `process_lineage.status: verified` with no
descendants. The first hosted run of the lane, on a runner with no prior
framework state, created the framework's image-store helper `agentenv-registry`
(`registry:2`, container `b2e047f4c6cd`) during `agent-env run`; the run itself
passed (`exit_code: 0`, 354 s, `tasks/smoke.json v1: passed`, "Tore down 1
sandbox.") but the first form of the leak check counted the helper and failed.
The check now lists containers with their names, excludes
`agentenv-registry[-<port>]` and reports it under `image_store_helpers`; the
local receipt gained `image_store_helpers: []` because the helper already
existed before the run here. Public ECR's anonymous pull quota refused the
framework's `postgres:16-alpine` pull with `429 Too Many Requests, Data limit
exceeded` on two hosted runs out of six (the second and, after the stack was
rebuilt, the sixth); the verifier now retries the run once, after 90 seconds,
when the failure carries that registry's signature, and records
`attempts: 2` with `retried_for` in the receipt. The local receipt reports
`attempts: 1`. Removing that helper (`docker rm -f
agentenv-registry`) and running the verifier again reproduced the fresh-host
path locally: the run recreated it and the receipt passed with
`image_store_helpers: ["agentenv-registry"]`, `new_containers: []`
(`agent_env_run: {exit_code: 0, seconds: 33}`). Without `REA_DOCKER_COMMAND` the verifier exited 1 before
touching anything with "verify:agentenv:environment requires absolute
REA_DOCKER_COMMAND; see docs/testing.md. No dependencies are installed by this
verifier."; with a relative `REA_AGENT_ENV_COMMAND=agent-env` it exited 1 with
the same sentence naming that variable.
Refusals through the built CLI: exporting into the already written bundle
directory exited 1 with `invalid_request`, issue path `["directory"]` and the
message "is not empty (4 entries); choose an absent or empty directory";
exporting the manifest against the capture of one selected record
(`--record 0`, `ev_3249d233…`) exited 1 with `invalid_request`, issue path
`["evidence", "evidence_id"]` and "The manifest was projected from a
different capture than the supplied evidence file.", creating no directory;
a truncated `broken.json` as the evidence exited 1 with `invalid_request`,
issue path `["evidence"]`, reason `invalid_format` and "broken.json is not
valid JSON: Unexpected end of JSON input", creating no directory. An export
without `--package-tarball` wrote a Dockerfile whose install line is
`RUN npm install -g --ignore-scripts --no-fund --no-audit rea-agents@6.6.0`,
the `package.json` version, with no tarball beside it; the tarball variant
copies `rea-agents.tgz` and installs and removes `/tmp/rea-agents.tgz`.
## Independent review
The adversarial review of the first pass is the independent review of this
increment; its findings and their repairs are listed above. One further
observation surfaced while repairing them: the single-read helper routes the
export's input refusals through the export module rather than the CLI's
shared JSON-input parser, so the inline-JSON refusal is now a typed
`invalid_request` on the `manifest` or `evidence` issue path instead of the
parser's `input_path`/`input_reason` envelope; the cli-boundary tests did not
depend on that envelope.
## Scope and measurement limits
The framework readiness probe, the port variable default and the `env.toml`
name were found by running the bundle in the first pass, not by reading a
published contract; they are recorded in the spec as the three additions to
the designed file set. The local run and the verifier used the host's Docker
with the framework's gateway and service-database images already built from
earlier sessions, so the "first run also builds the infrastructure" path was
not timed here. The verifier's container check compares `docker ps -aq`
before and after; images built by the run remain by design and are only
counted. `npm pack --json` has the npm 12 object shape on this host and the
npm 11 array shape on the hosted runner; the verifier accepts both but only
the former was exercised locally. The hosted `real-agentenv` workflow was not
dispatched from this tree. The built projects ran on this host's Node 22.23.1
rather than the pinned 24.18.0. The `/usr/bin/time -l` figures account for
each command and the children it waited for; Docker's own work runs in the
daemon and is outside them. Windows was not run.
## Linux followup verification (2026-10-19)
The original PR was updated from main after #4955 and #4975 merged. The
followups retain the author's commits and portable-name checks, seal private
JSON parser output once for shared immutable authentication/replay, reserve
sealing work before reading, preflight the served catalog before exporting,
translate changed-file failures by input role, defer export imports during CLI
registration, and verify the supplied framework executable before attributing
its results. The default `rea mcp` transport remains stdio.
Executed on Linux x64 with Node 24.18.0, npm 11.16.0, Docker 20.10.24,
Python 3.12.13 and an isolated `agentenv-framework==0.9.1334` environment:
- `turbo run typecheck lint:built format:check docs:check:built knip metadata:check artifacts:skills --concurrency=1`: ten tasks passed, one cached, 289.315 s elapsed, 284.552 s CPU, 4450.18 MiB peak process-family RSS.
- The focused source/compiled CLI/SDK/lifecycle/configuration run passed 399 tests in 24 files. A later run passed the updated 231 CI-selection cases, three executable-identity cases and ten product-catalog cases. The latter needed its generated catalog first; its initial missing-file failure was preparation, not a passing check.
- Four previously recorded, unauthenticated real GitHub API responses were exported with the checkout tarball. The generated Dockerfile built unchanged. A fresh non-root container replayed every response twice through the official MCP SDK, matching body, status, Evidence ordinal/id and untrusted-content provenance. Discovery/card parity and tarball removal passed. The standalone test supplies `ENVIRONMENT_NAME` as the framework does. Runtime verification with the built image took 12.393 s / 6.002 s CPU / 313.11 MiB process-family RSS; image-build cost is excluded. A Docker stats sample was 250.5 MiB, not a measured container peak.
- The production `verify-agentenv-environment.mjs` observed `agent-env 0.9.1334`, exported its 28-record source-owned Grafana-shaped fixture, deployed it with the real framework and passed `tasks/smoke.json`. The receipt reported five public cases, one sandbox torn down, no new task containers and no supervised descendants. This successful run used already prepared infrastructure: 141.697 s / 101.971 s CPU / 501.61 MiB process-family RSS. Earlier attempts failed because this host's proxy environment required optional SOCKS support and its default home sandbox directory was unwritable. Only the test process's proxy variables were cleared; supported `XDG_STATE_HOME` and `AGENT_ENV_LOCAL_SANDBOX_DIR` pointed into temporary storage. The shared Docker daemon was not reconfigured or restarted. The framework's persistent registry helper and images remain as its documented behavior.
A matched public `environment-serve`/SDK task compared the pre-followup serve
build with the final implementation. Both used the same GitHub capture with
1,000 synthetic HAR-extension items (each containing additional containers),
one first call and twenty warm identical replays. Fixture generation was
excluded. This is controlled stress data, not newly observed application data.
Larger 25,000/75,000-item projection attempts returned the existing typed
representation-budget refusal; that budget was not relaxed.
| Measurement | Before | After |
| ----------------------- | ----------: | ----------: |
| Cold listener startup | 2189.064 ms | 2862.860 ms |
| First call | 76.950 ms | 45.265 ms |
| Twenty warm calls | 324.119 ms | 267.132 ms |
| Completed task elapsed | 3.229 s | 3.885 s |
| Completed task CPU | 3.196 s | 3.827 s |
| Peak process-family RSS | 466.43 MiB | 470.21 MiB |
These single completed samples do not establish an overall speed or memory
improvement. Separate identity regressions establish that large reported JSON
trees are shared rather than copied and that caller-owned mutable trees are
not frozen. Token usage was not measured. CPU is child user+system time;
process-family memory sums sampled descendant RSS every 50 ms and may count
shared pages twice. Docker daemon/container CPU and peaks are outside that
process-family measurement and remain unmeasured. Builds used one task at a
time, one CPU and a soft 3 GiB Go limit; Docker builds were serialized and
limited to one CPU/4 GiB, and the standalone replay container to one CPU/2 GiB.
Windows execution and the published registry package were not tested in this
followup; the checkout tarball proves the code before its release.
### Output ownership and final review followups
The output boundary now uses the existing `SafeOutputTree` owner instead of
separate directory admission and pathname writes. It checks the admitted root,
parents, file identities and complete entry inventory around publication, keeps
failed-close authority, and preserves an existing empty caller root on rollback.
The optional package is held as one regular-file descriptor before output
writes, streamed with hash/readback verification, and checked for changes before
release. POSIX file modes are set and verified as `0600` independently of umask;
newly owned directories are `0700`. Existing root permissions remain caller owned.
Windows privacy depends on the selected destination ACL; no owner-only Windows
ACL guarantee or Windows runtime validation is claimed. Portable pathname
operations still require a trusted destination and parents against concurrent
replacement.
A compiled CLI counterexample under umask `0400` failed before the mode fix and
succeeded afterward with every generated file readable at `0600` and exact
Evidence bytes. Public CLI coverage also verifies that `symlink/..` retains its
filesystem meaning for the destination and package path. Real-filesystem tests
exercise destination replacement, foreign entries, parent symlinks, read-only
permissions and admitted-tarball replacement. Controlled close failures exercise
post-publication recovery and persistent retained ownership.
The inventory owner accepts canonically equivalent Unicode directory listings
without collapsing distinct filenames: an NFC multiset comparison is followed
by the original path identity checks. Tests simulate decomposed `readdir`
spelling over a real owned file and create an extra distinct Linux decomposed
foreign file. These establish the boundary behavior, not actual HFS+ execution.
Manifest, Evidence and tarball descriptors use the shared resource scope's close
retry. Persistent failures retain the canonical cleanup lease and produce a
tagged `environment-export` error. The compiled JSON CLI regression injects a
persistent manifest close failure before any destination write.
The framework verifier reuses `execNpm` and honors selected `npm_execpath`.
Four process-boundary attribution cases pass, including a conflicting PATH npm.
Those controlled executables verify attribution only.
After merging main `b4938aed5`, the generated Dockerfile built unchanged with the
checkout package and replayed all four captured public GitHub API responses
twice through the official MCP SDK in a fresh non-root container. Input bytes,
status/body/Evidence/provenance, discovery/card parity and package removal passed.
Warm base-image build plus pack/export/runtime verification: **40.699 s elapsed,
8.274 s process CPU, 421.04 MiB peak process-family RSS**. The real pinned
`agent-env 0.9.1334` verifier then passed all five public cases and the smoke task,
tore down its sandbox, and reported no new task containers or supervised
descendants: **156.895 s / 102.871 s CPU / 500.34 MiB**. Framework infrastructure
was already prepared. The later Unicode, close-failure and npm-selection repairs
have separate boundary regressions; they do not establish new real-platform
coverage.
A matched export comparison before the main merge used the same manifest
(6,416 bytes), capture (98,519 bytes) and checkout tarball (6,221,957 bytes), with
all copied inputs byte-exact. Before/after the output-owner repair: **1.827/2.604
s elapsed, 1.807/2.545 s CPU, 316.43/334.11 MiB** peak process-family RSS. These are
single process samples with uncontrolled host filesystem cache; the additional
ownership checks increased measured cost. They do not establish a speed or
memory improvement.
All followup heavy work uses one CPU and one test/build task at a time. Docker
builds are limited to one CPU/4 GiB, standalone runtime containers one CPU/2 GiB.
Process-family RSS is sampled every 50 ms and may double-count shared pages.
Docker daemon/container CPU and peak memory, agent tokens and fresh-framework
infrastructure cost are unmeasured. The previously documented early catalog
heap refusal is not represented as a passing run.
Final focused verification on the reviewed source passed **452 tests in 33 files** (including 232 CI-selection cases and the compiled CLI regressions): **161.276 s elapsed / 159.900 s CPU / 1242.43 MiB** peak process-family RSS. The final typecheck/build/lint gate passed: **218.427 s / 218.130 s CPU / 4449.45 MiB**.
+65 -3
View File
@@ -1177,6 +1177,16 @@ identifiable; unknown or shared script changes select everything. Android
build-script edits retain the Linux suite and workflow validation; real Android
verification stays manual. Renames account for both paths.
The `agentenv` scope owns the environment application and server modules, the
`environment-serve` and `environment-export` command registration, the shared
streamable HTTP listener, `scripts/verify-agentenv-environment.mjs` and its
workflow `real-agentenv.yml`. The lane exports a bundle from the source-owned
Grafana-shaped capture through the built CLI, packs the checkout into the
bundle's image and runs `agent-env run` on the hosted runner's Docker with the
pinned `agentenv-framework` release; it passes when the smoke task passes and
no container remains. It claims nothing about any hosted platform or another
framework version.
This narrower selection trades PR-time coverage for runner capacity. A change to
unowned shared runtime code that fails only on arm64, macOS, Windows, the Apple
artifact lane, or a provider lane that does not own the file is not caught on
@@ -1325,9 +1335,9 @@ Portable owner-based test selection remains observational. Its reports compare
proposed omissions with the full run; incomplete or skipped observations do not
establish that fewer tests would preserve regression detection.
The eight automatic browser, binary-layout, EVM, JavaScript-recovery,
recorded-crash, and web provider lanes download `rea-build-cache` from their own
run after the build and static jobs succeed. Their existing build commands still
The nine automatic browser, binary-layout, EVM, JavaScript-recovery,
recorded-crash, web and environment-bundle provider lanes download
`rea-build-cache` from their own run after the build and static jobs succeed. Their existing build commands still
validate task hashes and rebuild mismatched inputs; provider observations always
run. Provider-workflow-only edits also admit the build without enabling source
test shards. Direct provider dispatches retain the cross-run restore-only cache.
@@ -1554,6 +1564,58 @@ This is fixture evidence, not a sandbox claim. Inputs remain unchanged and the
sentinel must stay alive; owned cleanup and empty verifier descendants are required. Pass an
installed package entrypoint as the script's first argument for package coverage.
### Exported environment bundles
`npm run verify:agentenv:environment` is the `real-agentenv` lane. It requires
the host's Docker CLI named by absolute `REA_DOCKER_COMMAND`, Python 3.12 with
`agentenv-framework==0.9.1334` installed in a virtual environment whose
`agent-env` binary is named by absolute `REA_AGENT_ENV_COMMAND`, and outbound
network for `pip` on the runner and for `npm` inside the image build. Both
variables are required; neither upstream executable is resolved from `PATH`,
and the verifier installs nothing. Packing uses the shared npm helper, honoring
`npm_execpath` from the caller's selected npm. Direct Node invocations without
that value use the helper's platform fallback, so configure the pinned toolchain
on `PATH` for those runs. It checks the supplied runner's `--version` against the pin before
using Docker and retains the observed version in both passing and failing receipts.
Its disposable CI runner creates the virtual environment in runner
storage, exports both paths and uses the runner's own Docker; no developer host
configuration changes. `agent-env run` attempts share one 22-minute budget
inside the 30-minute job (the first attempt at most 15 minutes), so a hung or
retried run ends with the verifier's failed receipt rather than the runner's
kill. The framework builds its gateway and service-db
images from `public.ecr.aws` on every fresh runner, and hosted runners share
that registry's anonymous pull quota, so the lane first pulls the base images
the pinned framework's Dockerfiles name from `mirror.gcr.io`, which serves the
same Docker official images under the same digests, and names them as those
Dockerfiles do (on Docker's containerd image store, so digest-pinned names
resolve), as the framework's own CI does. The quota handling below covers
anything still pulled from that registry. The receipt's `agent_env_run` records the
last run whether it passed or failed, with `public_ecr_quota` set to
`data_limit` or `rate` when that registry's `429 Too Many Requests` ended it.
The data-transfer cap (`toomanyrequests: Data limit exceeded`) is counted per
source IP, so it fails the lane at once: a pause on the same runner cannot lift
it. When a rate refusal ends the run and at least five minutes of the budget
would remain after the pause, the verifier waits 90 seconds and runs once more,
recording `attempts: 2` and `retried_for` in the receipt. Any other failure
ends the lane at once.
The verifier writes the Grafana-shaped HAR the view tests own, inspects it and
projects the `environment-manifest` view with all three template rules through
the built CLI, packs the checkout with `npm pack`, exports the bundle with
`--package-tarball` so the image installs this checkout rather than the
registry package, and runs `agent-env run` on the bundle. It checks that the
export reports the planned file set with the inputs copied byte-exact, that
the runner exits `0` with `tasks/smoke.json` reported as passed and the
verifier's description naming the tool count, and that no container exists
after the run that did not exist before; the container inventory runs even when
the run fails or times out, so a failed receipt still lists what it left. The one exception is the framework's
own image-store helper, `agentenv-registry` (or `agentenv-registry-<port>`),
a `registry:2` container the local provider starts on first use and keeps
running across runs; a first run on a fresh host creates it, so the receipt
lists it under `image_store_helpers` instead of counting it as a leak. Images
built by the run remain and are counted in the receipt. Pass an installed
package entrypoint as the script's first argument for package coverage.
## Agent evaluation and conformance records
`npm run verify:agent` evaluates investigation scenarios through a real local
+1
View File
@@ -287,6 +287,7 @@
"verify:browser:source-maps": "npm run build:cached && node scripts/verify-browser-source-maps.mjs",
"verify:browser:runtime": "npm run build:cached && node scripts/verify-browser-runtime.mjs",
"verify:web:network-captures": "npm run build:cached && node scripts/verify-web-network-captures.mjs",
"verify:agentenv:environment": "npm run build:cached && node scripts/verify-agentenv-environment.mjs",
"verify:web:http3": "npm run build:cached && node scripts/verify-http3-network-captures.mjs",
"verify:binary:layout": "npm run build:cached && node scripts/verify-binary-layout.mjs",
"verify:go:binary": "npm run build:cached && node scripts/verify-go-binary.mjs",
+15
View File
@@ -438,6 +438,21 @@ export const SCOPES = {
"src/cli.ts",
"src/artifacts/readStableArtifact.ts",
],
agentenv: [
".github/workflows/real-agentenv.yml",
"scripts/verify-agentenv-environment.mjs",
"src/{application,server}/environment/**",
"src/application/{JsonInputFile,OwnedJsonInput,RegularFileRead}.ts",
"src/artifacts/{SafeOutputTree*,ArtifactResourceScope,StreamBytes}.ts",
"src/cliJsonInput.ts",
"src/server/http/**",
"src/{cli,cliCommandNames,cli/environmentCommands}.ts",
"src/config/{environmentName,mcpHttpEndpoint}.ts",
"src/domain/analysisView/{e,E}nvironmentManifest*.ts",
"src/process/{OwnedCommand,ProviderProcess,PrivateRuntimeRoot}.ts",
"src/process/ProcessOwnership*.ts",
"tests/fixtures/historicalHar.ts",
],
nativeaot: [
"bridge/nativeaot/**",
"scripts/generate-nativeaot-fixtures.mjs",
+358
View File
@@ -0,0 +1,358 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { access, readFile, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { setTimeout as pause } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js";
import {
OwnedCommandFailure,
runOwnedCommand,
} from "../dist/process/OwnedCommand.js";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import { execNpm } from "./lib/verify-package-core.mjs";
const AGENTENV_VERSION = "0.9.1334";
const AGENTENV_PIN = `agentenv-framework ${AGENTENV_VERSION}`;
// The framework builds its infra images from public ECR, whose anonymous pull quota hosted
// runners share; a rate refusal earns one retry, anything else fails at once.
const PUBLIC_ECR_QUOTA =
/public\.ecr\.aws[\s\S]{0,400}?(?:429 Too Many Requests|toomanyrequests)/u;
// The anonymous data-transfer cap is counted per source IP, so a pause on the same runner
// cannot lift it; the receipt names it instead of retrying.
const PUBLIC_ECR_DATA_LIMIT = /toomanyrequests: Data limit exceeded/u;
const QUOTA_RETRY_PAUSE_MS = 90_000;
// Both bundle attempts share this budget inside the 30-minute job; a retry needs real time left.
const RUN_BUDGET_MS = 22 * 60_000;
const FIRST_ATTEMPT_MS = 15 * 60_000;
const MIN_RETRY_MS = 5 * 60_000;
const TEMPLATE_RULES = [
"after-uid-literal",
"opaque-token",
"sibling-variance",
];
const execFileAsync = promisify(execFile);
// Both tools are caller-supplied absolute paths, as every real-* lane names its upstream.
for (const key of ["REA_AGENT_ENV_COMMAND", "REA_DOCKER_COMMAND"])
if (!isAbsolute(process.env[key] ?? ""))
throw new Error(
`verify:agentenv:environment requires absolute ${key}; see docs/testing.md. No dependencies are installed by this verifier.`,
);
const agentEnv = process.env.REA_AGENT_ENV_COMMAND;
const docker = process.env.REA_DOCKER_COMMAND;
// The framework's local image store keeps its registry:2 helper running across runs; a first
// run on a fresh host creates it, so it is reported separately rather than counted as a leak.
const IMAGE_STORE_HELPER = /^agentenv-registry(?:-\d+)?$/u;
await Promise.all([access(agentEnv), access(docker)]);
const entrypoint =
process.argv[2] ?? fileURLToPath(new URL("./rea.mjs", import.meta.url));
const checkout = resolve(dirname(entrypoint), "..");
const run = createVerifierRun();
const runtime = await PrivateRuntimeRoot.create({
prefix: "rea-agentenv-verifier-",
});
const environment = Object.fromEntries(
Object.entries(process.env).filter(([, value]) => typeof value === "string"),
);
const failures = [];
let cases = 0;
const receipt = {};
let knownContainers;
try {
const version = await execFileAsync(agentEnv, ["--version"], {
env: environment,
timeout: 10_000,
maxBuffer: 64 * 1024,
});
receipt.agent_env_version = version.stdout.trim();
assert.equal(
receipt.agent_env_version,
`agent-env ${AGENTENV_VERSION}`,
"The verifier requires the pinned framework executable; its observed version is retained in the receipt.",
);
const containersBefore = await containers();
knownContainers = new Set(containersBefore.map((entry) => entry.id));
receipt.docker_before = {
containers: containersBefore.length,
images: (await imageIds()).length,
};
// Source-owned capture: the Grafana-shaped HAR builder the view tests use.
const { environmentManifestHar } = await import(
new URL("../tests/fixtures/historicalHar.ts", import.meta.url).href
);
const harPath = join(runtime.path, "capture.har");
await writeFile(harPath, JSON.stringify(environmentManifestHar()));
const capture = await cli(
"inspect-web-network-capture",
harPath,
"har",
"--format",
"json",
);
assert.equal(capture.operation, "inspect_web_network_capture");
assert.equal(capture.normalized_result.total_records, 28);
cases++;
const viewPath = join(runtime.path, "view.json");
await writeFile(
viewPath,
JSON.stringify({
source: { kind: "inline", evidence: capture },
view: { kind: "environment-manifest", template_rules: TEMPLATE_RULES },
}),
);
const manifest = await cli(
"inspect-analysis-view",
viewPath,
"--format",
"json",
);
assert.equal(manifest.normalized_result.kind, "environment-manifest");
assert.equal(
manifest.normalized_result.parent_evidence_id,
capture.evidence_id,
);
assert.equal(manifest.normalized_result.name, "grafana-example");
const toolCount = manifest.normalized_result.tools.length;
assert.ok(toolCount > 0);
cases++;
const capturePath = join(runtime.path, "capture.json");
const manifestPath = join(runtime.path, "manifest.json");
await writeFile(capturePath, JSON.stringify(capture));
await writeFile(manifestPath, JSON.stringify(manifest));
// The lane proves this checkout, not the registry package: pack it and install the tarball in the image.
const { stdout: packed } = await execNpm(
["pack", "--ignore-scripts", "--json", "--pack-destination", runtime.path],
{ cwd: checkout, env: environment, maxBuffer: 16 * 1024 * 1024 },
);
const tarball = join(runtime.path, packedFilename(JSON.parse(packed)));
await access(tarball);
const bundle = join(runtime.path, "grafana-example");
const exported = await cli(
"environment-export",
manifestPath,
"--evidence",
capturePath,
bundle,
"--package-tarball",
tarball,
);
assert.equal(exported.status, "exported");
assert.equal(exported.name, "grafana-example");
assert.equal(exported.tool_count, toolCount);
assert.equal(exported.package_source.kind, "tarball");
assert.deepEqual(exported.files.map((file) => file.path).sort(), [
"README.md",
"artifacts/check_grafana-example/verify.py",
"envs/grafana-example/Dockerfile",
"envs/grafana-example/env.toml",
"envs/grafana-example/evidence.json",
"envs/grafana-example/manifest.json",
"envs/grafana-example/rea-agents.tgz",
"tasks/smoke.json",
]);
assert.equal(
await readFile(join(bundle, "envs/grafana-example/manifest.json"), "utf8"),
await readFile(manifestPath, "utf8"),
);
const dockerfile = await readFile(
join(bundle, "envs/grafana-example/Dockerfile"),
"utf8",
);
assert.ok(dockerfile.includes("COPY rea-agents.tgz /tmp/"));
assert.ok(dockerfile.includes("/tmp/rea-agents.tgz"));
cases++;
// The agentenv runner builds the image, deploys it on the local sandbox and runs the verifier.
// One deadline for both attempts, well under the 30-minute job, so a hung or retried run ends
// with this receipt rather than the runner's kill.
const started = Date.now();
const deadline = started + RUN_BUDGET_MS;
let attempts = 0;
const runBundle = async (timeoutMs) => {
attempts += 1;
let outcome;
try {
outcome = await runOwnedCommand(
{
command: agentEnv,
arguments: ["run", bundle, "--sandbox", "local"],
cwd: runtime.path,
runId: `rea-agentenv-run-${randomUUID()}`,
hostEnvironment: environment,
},
{ timeoutMs, diagnosticBytes: 4 * 1024 * 1024 },
);
return outcome;
} catch (cause) {
outcome =
cause instanceof OwnedCommandFailure ? cause.snapshot : undefined;
throw cause;
} finally {
// Recorded before the outcome is judged, so a failed receipt still says how the last
// run ended, how many runs there were and whether the registry quota refused it.
receipt.agent_env_run = runRecord(
outcome,
Date.now() - started,
attempts,
);
}
};
let snapshot;
try {
snapshot = await runBundle(
Math.min(FIRST_ATTEMPT_MS, deadline - Date.now()),
);
} catch (cause) {
if (
receipt.agent_env_run?.public_ecr_quota !== "rate" ||
deadline - Date.now() - QUOTA_RETRY_PAUSE_MS < MIN_RETRY_MS
)
throw cause;
await pause(QUOTA_RETRY_PAUSE_MS);
snapshot = await runBundle(deadline - Date.now());
}
assert.equal(snapshot.exitCode, 0);
assert.match(snapshot.stdout.text, /tasks\/smoke\.json v\d+: passed/u);
assert.match(
snapshot.stdout.text,
new RegExp(
`replay: listed ${String(toolCount)} tools, expected ${String(toolCount)};`,
"u",
),
);
cases++;
} catch (cause) {
failures.push(cause);
} finally {
// The container inventory runs whether the bundle passed, failed or timed out, so a failed
// receipt still says what the run left behind.
if (knownContainers !== undefined)
try {
const containersAfter = await containers();
const added = containersAfter.filter(
(entry) => !knownContainers.has(entry.id),
);
const helpers = added.filter((entry) =>
IMAGE_STORE_HELPER.test(entry.name),
);
const leaked = added.filter(
(entry) => !IMAGE_STORE_HELPER.test(entry.name),
);
receipt.docker_after = {
containers: containersAfter.length,
images: (await imageIds()).length,
image_store_helpers: helpers.map((entry) => entry.name),
new_containers: leaked.map((entry) => `${entry.id} ${entry.name}`),
};
assert.deepEqual(leaked, []);
if (failures.length === 0) cases++;
} catch (cause) {
failures.push(cause);
}
try {
await runtime.close();
} catch (cause) {
failures.push(cause);
}
}
const verifier = await completeVerifierRun(run);
try {
assert.equal(verifier.process_lineage.status, "verified");
assert.deepEqual(verifier.process_lineage.descendants, []);
} catch (cause) {
failures.push(cause);
}
if (failures.length > 0) {
console.error(
JSON.stringify(
{ status: "failed", public_cases: cases, ...receipt, verifier },
null,
2,
),
);
throw new AggregateError(
failures,
"Environment bundle verification failed; export, run and cleanup failures are retained.",
);
}
console.log(
JSON.stringify(
{
status: "passed",
public_cases: cases,
upstream: AGENTENV_PIN,
runner: `${process.platform} ${process.arch}; host Docker`,
offline: false,
...receipt,
verifier,
},
null,
2,
),
);
async function cli(...args) {
const { stdout } = await execFileAsync(
process.execPath,
[entrypoint, ...args, "--json"],
{ env: environment, cwd: runtime.path, maxBuffer: 64 * 1024 * 1024 },
);
return JSON.parse(stdout);
}
// The receipt's view of one `agent-env run`, passed or failed; `outcome` is absent when the
// command failed before producing a snapshot.
function runRecord(outcome, elapsedMs, attempts) {
const quota = publicEcrQuota(outcome?.stderr.text ?? "");
return {
exit_code: outcome?.exitCode ?? null,
...(outcome?.signal ? { signal: outcome.signal } : {}),
seconds: Math.round(elapsedMs / 1000),
attempts,
...(attempts > 1
? { retried_for: "public ECR anonymous pull rate limit (429)" }
: {}),
...(quota === undefined ? {} : { public_ecr_quota: quota }),
stdout_tail: (outcome?.stdout.text ?? "").trim().split("\n").slice(-6),
};
}
// Which public ECR refusal ended a run: the per-IP data cap or a request-rate refusal.
function publicEcrQuota(stderr) {
if (!PUBLIC_ECR_QUOTA.test(stderr)) return undefined;
return PUBLIC_ECR_DATA_LIMIT.test(stderr) ? "data_limit" : "rate";
}
// npm 11 prints `npm pack --json` as a one-element array; npm 12 keys the same record by package name.
function packedFilename(report) {
const record = Array.isArray(report) ? report[0] : Object.values(report)[0];
assert.equal(typeof record?.filename, "string");
return record.filename;
}
// Containers are not verifier descendants, so leaks are checked by id against the pre-run set.
async function containers() {
const { stdout } = await execFileAsync(
docker,
["ps", "-a", "--format", "{{.ID}}\t{{.Names}}"],
{ env: environment },
);
return stdout
.split("\n")
.filter((line) => line.length > 0)
.map((line) => {
const [id, name = ""] = line.split("\t");
return { id, name };
})
.sort((left, right) => left.id.localeCompare(right.id));
}
async function imageIds() {
const { stdout } = await execFileAsync(docker, ["images", "-q"], {
env: environment,
});
return [...new Set(stdout.split("\n").filter((line) => line.length > 0))];
}
+27 -9
View File
@@ -12,6 +12,7 @@ import streamArray from "stream-json/streamers/stream-array.js";
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { sealOwnedJsonInput } from "./OwnedJsonInput.js";
import { withRegularFile } from "./RegularFileRead.js";
import {
JSON_BYTE_ORDER_MARK_MESSAGE,
@@ -33,13 +34,20 @@ const PACKED_TOKEN_PART_CODE_UNITS = 64 * 1024;
// This selects an implementation; larger files remain accepted through streaming.
const NATIVE_PARSE_READ_BUDGET = 8 * 1024 * 1024;
/** Keep native parsing only when input copies and value expansion fit live headroom. */
const nativeParseFits = (inputBytes: number): boolean =>
inputBytes <= NATIVE_PARSE_READ_BUDGET &&
/** Heap a whole-file native JSON.parse may need: the assembler's 64x container expansion plus byte and text copies. */
export const nativeJsonParseRequiredBytes = (
inputBytes: number,
snapshot = false,
): number =>
// The assembler estimate reserves up to 128 bytes per two-byte container.
// Allow that 64x expansion plus byte/text copies before native JSON.parse,
// whose individual container allocations cannot be intercepted.
inputBytes * 72 + READ_CHUNK_BYTES <=
(inputBytes * 72 + READ_CHUNK_BYTES) * (snapshot ? 2 : 1);
/** Snapshot mode also reserves one projection for cooperative sealing bookkeeping. */
const nativeParseFits = (inputBytes: number, snapshot = false): boolean =>
inputBytes <= NATIVE_PARSE_READ_BUDGET &&
nativeJsonParseRequiredBytes(inputBytes, snapshot) <=
getHeapStatistics().total_available_size;
/** One selected root member; repeated keys begin again with last-value semantics. */
@@ -246,6 +254,7 @@ export const readJsonInputFile = (
operation: string,
signal?: AbortSignal,
resources?: ArtifactResourceScope,
snapshot = false,
): Promise<Result<unknown, JsonInputFileFailure>> =>
withRegularFile(
path,
@@ -259,8 +268,12 @@ export const readJsonInputFile = (
path,
operation,
signal,
snapshot,
);
if (native !== undefined) return native;
if (native !== undefined)
return native.ok && snapshot
? await sealOwnedJsonInput(native.value, operation, signal)
: native;
await pipeline(
decodedChunks(handle, stats.size, path, signal),
// Fuse synchronous token stages so only file chunks cross the async
@@ -275,7 +288,7 @@ export const readJsonInputFile = (
packNumbers: false,
}),
packJsonScalars(operation, stats.size, path),
admitJsonAssembly(operation, stats.size, path),
admitJsonAssembly(operation, stats.size, path, snapshot ? 2 : 1),
streamValues(),
(row: unknown): typeof none => {
if (
@@ -296,7 +309,9 @@ export const readJsonInputFile = (
signal === undefined ? {} : { signal },
);
return found
? ok(value)
? snapshot
? await sealOwnedJsonInput(value, operation, signal)
: ok(value)
: err(invalidJson(operation, "JSON document is empty"));
} catch (cause: unknown) {
const failure = jsonReadFailure(cause, operation, stats.size, path);
@@ -425,6 +440,7 @@ const admitJsonAssembly = (
operation: string,
inputFileBytes: number,
path: string,
retainedMultiplier = 1,
) => {
let projectedBytes = 0;
let nextCheck = 0;
@@ -451,7 +467,8 @@ const admitJsonAssembly = (
// values. Keep another whole projection available for container growth,
// plus the next chunk of estimates between samples. This is admission
// against live runtime headroom, not a fixed document-size policy.
const requiredBytes = projectedBytes + READ_CHUNK_BYTES;
const requiredBytes =
projectedBytes * retainedMultiplier + READ_CHUNK_BYTES;
if (requiredBytes > heap.total_available_size)
throw new AnalysisResourceConstraintError(
operation,
@@ -573,8 +590,9 @@ const readNativeJson = async (
path: string,
operation: string,
signal?: AbortSignal,
snapshot = false,
): Promise<Result<unknown, JsonInputFileFailure> | undefined> => {
if (!nativeParseFits(size)) return undefined;
if (!nativeParseFits(size, snapshot)) return undefined;
const prefix = await readPrefix(handle, size + 1, signal);
if (!prefix.complete || prefix.bytes.length !== size)
throw new RegularFileChangedError(path);
+54
View File
@@ -0,0 +1,54 @@
import { setImmediate } from "node:timers/promises";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { freezeOwnedJsonSnapshotSteps } from "../domain/immutableJson.js";
import {
jsonValueValidationSteps,
type JsonValue,
} from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
const finish = async <Value>(
steps: Generator<void, Value>,
signal?: AbortSignal,
): Promise<Value> => {
let completed = false;
try {
for (;;) {
signal?.throwIfAborted();
const step = steps.next();
if (step.done) {
completed = true;
return step.value;
}
await setImmediate(undefined, signal === undefined ? {} : { signal });
}
} finally {
// An abandoned validation/sealing traversal must release its provisional state.
if (!completed) steps.return(undefined as never);
}
};
/** Seal exclusively owned parser output so later schemas can share authenticated JSON subtrees. */
export const sealOwnedJsonInput = async (
value: unknown,
operation: string,
signal?: AbortSignal,
): Promise<Result<JsonValue, AnalysisInputError>> => {
const issue = await finish(jsonValueValidationSteps(value), signal);
if (issue !== undefined)
return err(
new AnalysisInputError(operation, { userMessage: issue }, [
{
path: [],
reason: "invalid_value",
expected: "bounded JSON-safe input",
message: issue,
},
]),
);
// The completed validator above established the JsonValue contract without a copy.
return ok(
await finish(freezeOwnedJsonSnapshotSteps(value as JsonValue), signal),
);
};
+3
View File
@@ -22,6 +22,8 @@ export interface RegularFileReadOptions {
readonly signal?: AbortSignal | undefined;
readonly symlinks?: "follow" | "reject";
readonly resources?: ArtifactResourceScope;
/** Runs on the opened file's own state before any byte is read; throw to refuse the read. */
readonly admit?: (state: Stats) => void;
}
export class InvalidUtf8FileError extends TypeError {
@@ -223,6 +225,7 @@ const readRegularFileContent = <Value>(
withRegularFile(
path,
async (handle, stats) => {
options.admit?.(stats);
const bytes = await readBoundedFileBytes(
handle,
stats.size,
@@ -0,0 +1,274 @@
import { describe, expect, it } from "vitest";
import { environmentServeSource } from "../../../tests/fixtures/environmentServe.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js";
import {
BUNDLE_BASE_IMAGE,
bundleDockerfile,
planEnvironmentBundle,
registryPackageSource,
} from "./environmentBundle.js";
const plan = (options: { name?: string; tarball?: string } = {}) => {
const planned = planEnvironmentBundle(environmentServeSource(), {
name: options.name,
packageSource:
options.tarball === undefined
? registryPackageSource()
: { kind: "tarball", path: options.tarball },
});
if (!planned.ok) throw planned.error;
return planned.value;
};
const file = (planned: ReturnType<typeof plan>, path: string): string => {
const found = planned.files.find((entry) => entry.path === path);
if (found === undefined) throw new Error(`Planned file missing: ${path}`);
return found.content;
};
describe("planEnvironmentBundle", () => {
it("plans the echo-shaped file set under the manifest name with byte-exact copies beside the Dockerfile", () => {
const planned = plan();
expect(planned.name).toBe("grafana-example");
expect(planned.toolCount).toBe(19);
expect(planned.files.map((entry) => entry.path)).toEqual([
"README.md",
"envs/grafana-example/env.toml",
"envs/grafana-example/Dockerfile",
"artifacts/check_grafana-example/verify.py",
"tasks/smoke.json",
]);
expect(planned.copies).toEqual({
manifest: "envs/grafana-example/manifest.json",
evidence: "envs/grafana-example/evidence.json",
tarball: null,
});
expect(file(planned, "envs/grafana-example/env.toml")).toBe(
'environment_name = "grafana-example"\n',
);
});
it("pins the registry package to this build's exact version in the Dockerfile", () => {
const dockerfile = file(plan(), "envs/grafana-example/Dockerfile");
const lines = dockerfile.trimEnd().split("\n");
expect(lines[0]).toBe(`FROM ${BUNDLE_BASE_IMAGE}`);
expect(lines).toContain("ENV MCP_HOST=0.0.0.0");
expect(lines).toContain("ENV MCP_PORT=18765");
expect(lines).toContain(
"RUN apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/*",
);
expect(lines).toContain(
`RUN npm install -g --ignore-scripts --no-fund --no-audit rea-agents@${PACKAGE_METADATA.version}`,
);
expect(lines).toContain(
"COPY --chown=node:node manifest.json evidence.json /env/",
);
expect(lines).toContain("USER node");
expect(lines.at(-1)).toBe(
'CMD ["rea","environment-serve","/env/manifest.json","--evidence","/env/evidence.json"]',
);
expect(dockerfile).not.toContain("rea-agents.tgz");
expect(dockerfile.indexOf("RUN npm install")).toBeLessThan(
dockerfile.indexOf("USER node"),
);
});
it("copies and installs the tarball instead of the registry package when one is given", () => {
const planned = plan({ tarball: "/packed/rea-agents-1.2.3.tgz" });
expect(planned.copies.tarball).toBe("envs/grafana-example/rea-agents.tgz");
const dockerfile = file(planned, "envs/grafana-example/Dockerfile");
expect(dockerfile).toContain("COPY rea-agents.tgz /tmp/\n");
expect(dockerfile).toContain(
"RUN npm install -g --ignore-scripts --no-fund --no-audit /tmp/rea-agents.tgz && rm /tmp/rea-agents.tgz\n",
);
expect(dockerfile).not.toContain(`rea-agents@${PACKAGE_METADATA.version}`);
expect(bundleDockerfile(registryPackageSource())).not.toBe(dockerfile);
});
it("writes a verifier that checks the tool count and the first parameterless GET tool's recorded status", () => {
const planned = plan();
// Manifest tools are name-ordered, so the first parameterless GET is the annotations list.
expect(planned.check).toEqual({
tool: "get_annotations",
recordOrdinal: 25,
status: 200,
});
const verifier = file(planned, "artifacts/check_grafana-example/verify.py");
expect(verifier).toContain("from mcp import ClientSession");
expect(verifier).toContain(
"from mcp.client.streamable_http import streamable_http_client",
);
expect(verifier).toContain("async def verify(mcp_url):");
expect(verifier).toContain("TOOL_COUNT = 19");
expect(verifier).toContain('TOOL = "get_annotations"');
expect(verifier).toContain("RECORDED_STATUS = 200");
expect(verifier).toContain(
"passed = passed and not result.isError and status == RECORDED_STATUS",
);
expect(verifier).toContain("await session.list_tools()");
expect(verifier).toContain("await session.call_tool(TOOL, {})");
expect(verifier).toContain(
'return [{"id": "replay", "result": passed, "description": description}]',
);
});
it("writes the smoke task deploying the env locally before the replay verifier", () => {
expect(JSON.parse(file(plan(), "tasks/smoke.json"))).toEqual([
{
id: "env",
type: "deploy_env",
env_id: "grafana-example",
sandbox_type: "local",
},
{
id: "replay",
type: "env_outcome_verifier",
env_id: "grafana-example",
file_artifact_id: "check_grafana-example",
verifier_id: "replay",
},
]);
});
it("records provenance, replay limits, the run command and the rights note in the README", () => {
const source = environmentServeSource();
const readme = file(plan(), "README.md");
expect(readme).toContain(
`- Capture Evidence (the manifest's parent): \`${source.evidenceId}\``,
);
expect(readme).toContain(
`- Manifest Evidence: \`${source.manifestEvidenceId}\``,
);
expect(readme).toContain(
`- Manifest view digest: \`${source.manifest.view_digest}\``,
);
expect(readme).toContain(
`- Capture artifact SHA-256: \`${source.captureSha256 ?? ""}\``,
);
expect(readme).toContain("- Tools: 19");
expect(readme).toContain(`rea-agents@${PACKAGE_METADATA.version}`);
expect(readme).toContain("```sh\nagent-env run .\n```");
expect(readme).not.toContain("agent-env run grafana-example");
expect(readme).toContain("Replay is not emulation");
expect(readme).toContain("belongs to the captured application");
expect(readme).toContain("Do not publish this bundle without the rights");
expect(readme).toContain("calls `get_annotations`");
});
it("accepts a kebab-case name override and refuses any other name as invalid input", () => {
const renamed = plan({ name: "replay-2" });
expect(renamed.files.map((entry) => entry.path)).toContain(
"envs/replay-2/Dockerfile",
);
expect(file(renamed, "envs/replay-2/env.toml")).toBe(
'environment_name = "replay-2"\n',
);
expect(JSON.parse(file(renamed, "tasks/smoke.json"))[1]).toMatchObject({
env_id: "replay-2",
file_artifact_id: "check_replay-2",
});
for (const name of ["Grafana", "a_b", "-a", "a--b", "", "a".repeat(65)]) {
const refused = planEnvironmentBundle(environmentServeSource(), {
name,
packageSource: registryPackageSource(),
});
if (refused.ok) throw new Error(`Expected ${name} to be refused`);
expect(projectAnalysisError(refused.error)).toMatchObject({
code: "invalid_request",
details: {
operation: "environment-export",
issues: [{ path: ["name"], reason: "invalid_format" }],
},
});
}
});
});
describe("planEnvironmentBundle portable names", () => {
it("refuses device names Windows reserves for files and folders, before planning any file", () => {
for (const name of [
"con",
"prn",
"aux",
"nul",
"com1",
"com9",
"lpt1",
"lpt9",
]) {
const refused = planEnvironmentBundle(environmentServeSource(), {
name,
packageSource: registryPackageSource(),
});
if (refused.ok) throw new Error(`Expected ${name} to be refused`);
expect(projectAnalysisError(refused.error)).toMatchObject({
code: "invalid_request",
details: {
operation: "environment-export",
issues: [{ path: ["name"], reason: "invalid_value" }],
},
});
}
for (const name of ["console", "com10", "nul-check"])
expect(
planEnvironmentBundle(environmentServeSource(), {
name,
packageSource: registryPackageSource(),
}).ok,
).toBe(true);
});
});
describe("planEnvironmentBundle replay check", () => {
it("keeps the first record's unknown status instead of borrowing another record's", () => {
const source = environmentServeSource();
const first = source.exchanges.get(25);
if (first === undefined) throw new Error("Fixture lacks record 25");
const exchanges = new Map(source.exchanges);
exchanges.set(25, { ...first, status: null });
const planned = planEnvironmentBundle(
{ ...source, exchanges },
{ packageSource: registryPackageSource() },
);
if (!planned.ok) throw planned.error;
expect(planned.value.check).toMatchObject({
recordOrdinal: 25,
status: null,
});
expect(
file(planned.value, "artifacts/check_grafana-example/verify.py"),
).toContain("RECORDED_STATUS = None");
});
});
it("refuses a bundle that the replay server cannot admit, using export operation context", () => {
const source = environmentServeSource();
const tool = source.manifest.tools[0]!;
const manifest = {
...source.manifest,
tools: [
{
...tool,
query_parameter_names: Array.from(
{ length: 10000 },
(_, index) => `q_${index}`,
),
},
],
};
const result = planEnvironmentBundle(
{ ...source, manifest },
{ packageSource: registryPackageSource() },
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisResourceConstraintError",
operation: "environment-export",
resource: "capacity",
retryable: false,
},
});
});
@@ -0,0 +1,340 @@
import { PRODUCT_IDENTITY } from "../../identity.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../../domain/result.js";
import type {
EnvironmentManifestTool,
EnvironmentSource,
} from "./environmentSource.js";
import { environmentToolDefinitions } from "./environmentTools.js";
/** Operation name every refusal of the export command reports. */
export const ENVIRONMENT_EXPORT_OPERATION = "environment-export";
/** The image the exported Dockerfile builds from; the agentenv runner builds it with Docker. */
export const BUNDLE_BASE_IMAGE = "node:24.18.0-bookworm-slim";
/** The port the agentenv framework maps for an MCP server env; it sets the host but not the port. */
export const BUNDLE_MCP_PORT = 18765;
/** Name of the packed CLI inside the env folder when a tarball is exported. */
export const BUNDLE_TARBALL_NAME = "rea-agents.tgz";
/** Folder and env id rule: a kebab-case label the agentenv framework accepts as a local id. */
const BUNDLE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
const BUNDLE_NAME_MAX_LENGTH = 64;
/** Win32 reserves these device names for files and folders, so the env folder can never take one. */
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/u;
/** Where the Dockerfile installs the CLI from. */
export type EnvironmentPackageSource =
| { readonly kind: "registry"; readonly specifier: string }
| { readonly kind: "tarball"; readonly path: string };
/** One text file of the bundle, relative to the bundle root. */
export interface EnvironmentBundleFile {
readonly path: string;
readonly content: string;
}
/** The replay check the verifier performs against the served environment. */
export interface EnvironmentReplayCheck {
readonly tool: string;
readonly recordOrdinal: number;
readonly status: number | null;
}
/** Everything the export writes, derived from the source alone. */
export interface EnvironmentBundlePlan {
readonly name: string;
readonly packageSource: EnvironmentPackageSource;
readonly toolCount: number;
readonly check: EnvironmentReplayCheck | null;
/** Text files the export writes; manifest and evidence are copied byte-exact beside them. */
readonly files: readonly EnvironmentBundleFile[];
/** Relative paths of the byte-exact copies in write order. */
readonly copies: {
readonly manifest: string;
readonly evidence: string;
readonly tarball: string | null;
};
}
export interface EnvironmentBundleOptions {
/** Folder and env id; defaults to the manifest's kebab-case name. */
readonly name?: string | undefined;
readonly packageSource: EnvironmentPackageSource;
}
/** The registry source pinning the exact version of this build. */
export const registryPackageSource = (): EnvironmentPackageSource => ({
kind: "registry",
specifier: PRODUCT_IDENTITY.registrationPackageSpecifier,
});
const nameError = (name: string): AnalysisInputError =>
new AnalysisInputError(
ENVIRONMENT_EXPORT_OPERATION,
{
userMessage: `The bundle name ${JSON.stringify(name)} is not a portable kebab-case label of at most ${String(BUNDLE_NAME_MAX_LENGTH)} characters.`,
},
[
{
path: ["name"],
reason: "invalid_format",
expected: BUNDLE_NAME.source,
message:
"The bundle name is the env folder and env id; use lowercase letters, digits and single hyphens, excluding Windows device names.",
},
],
);
const reservedNameError = (name: string): AnalysisInputError =>
new AnalysisInputError(
ENVIRONMENT_EXPORT_OPERATION,
{
userMessage: `The bundle name ${JSON.stringify(name)} is a device name Windows reserves, so it cannot name the env folder; pass --name with another label.`,
},
[
{
path: ["name"],
reason: "invalid_value",
message:
"con, prn, aux, nul, com1-com9 and lpt1-lpt9 are reserved file names on Windows.",
},
],
);
const installLine = (source: EnvironmentPackageSource): string =>
source.kind === "registry"
? `RUN npm install -g --ignore-scripts --no-fund --no-audit ${source.specifier}`
: `COPY ${BUNDLE_TARBALL_NAME} /tmp/\nRUN npm install -g --ignore-scripts --no-fund --no-audit /tmp/${BUNDLE_TARBALL_NAME} && rm /tmp/${BUNDLE_TARBALL_NAME}`;
/** The agentenv local sandbox probes container readiness with python3 inside the container; the slim image has none. */
const READINESS_PROBE_LAYER =
"RUN apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/*";
/** The Dockerfile text: pinned base image, readiness probe interpreter, pinned CLI, listener defaults, non-root user. */
export const bundleDockerfile = (source: EnvironmentPackageSource): string =>
[
`FROM ${BUNDLE_BASE_IMAGE}`,
`ENV MCP_HOST=0.0.0.0`,
`ENV MCP_PORT=${String(BUNDLE_MCP_PORT)}`,
READINESS_PROBE_LAYER,
installLine(source),
// Owned by the runtime user, so a private host umask cannot make the inputs unreadable.
"COPY --chown=node:node manifest.json evidence.json /env/",
"USER node",
'CMD ["rea","environment-serve","/env/manifest.json","--evidence","/env/evidence.json"]',
"",
].join("\n");
/** The first GET tool without path parameters, in manifest order, and its first record's status. */
const replayCheck = (
source: EnvironmentSource,
): EnvironmentReplayCheck | null => {
const tool: EnvironmentManifestTool | undefined = source.manifest.tools.find(
(candidate) =>
candidate.method === "GET" &&
candidate.params.length === 0 &&
candidate.record_ordinals.length > 0,
);
if (tool === undefined) return null;
const ordinal = tool.record_ordinals[0] ?? -1;
const exchange = source.exchanges.get(ordinal);
return {
tool: tool.name,
recordOrdinal: ordinal,
// An exchange's own unknown status is what replay returns; fall back only when the record is absent.
status:
exchange === undefined ? (tool.statuses[0] ?? null) : exchange.status,
};
};
const pythonLiteral = (value: string | number | null): string =>
value === null ? "None" : JSON.stringify(value);
/** The verifier script in the shape of the agentenv echo example's `verify(mcp_url)`. */
export const bundleVerifier = (
toolCount: number,
check: EnvironmentReplayCheck | null,
): string => {
const header = [
'"""Checks the replay environment through its MCP endpoint: the tool count and one replayed status."""',
"import json",
"",
"from mcp import ClientSession",
"from mcp.client.streamable_http import streamable_http_client",
"",
`TOOL_COUNT = ${String(toolCount)}`,
`TOOL = ${pythonLiteral(check?.tool ?? null)}`,
`RECORDED_STATUS = ${pythonLiteral(check?.status ?? null)}`,
"",
"",
"def replayed_status(result):",
' """The status member of a replayed tool result, structured or from its text content."""',
" data = result.structuredContent",
" if data is None:",
' text = "".join(getattr(part, "text", "") for part in result.content)',
" data = json.loads(text) if text else {}",
' return data.get("status") if isinstance(data, dict) else None',
"",
"",
"async def verify(mcp_url):",
" async with streamable_http_client(mcp_url) as (read, write, _):",
" async with ClientSession(read, write) as session:",
" await session.initialize()",
" listed = await session.list_tools()",
" result = await session.call_tool(TOOL, {}) if TOOL is not None else None",
" count = len(listed.tools)",
" passed = count == TOOL_COUNT",
' description = f"listed {count} tools, expected {TOOL_COUNT}"',
" if result is not None:",
" status = None if result.isError else replayed_status(result)",
" passed = passed and not result.isError and status == RECORDED_STATUS",
' description += f"; {TOOL} replayed status {status!r}, recorded {RECORDED_STATUS!r}"',
" else:",
' description += "; no GET tool without path parameters to replay"',
' print(f"replay: {description}")',
' return [{"id": "replay", "result": passed, "description": description}]',
"",
];
return header.join("\n");
};
/** The smoke task: deploy the env locally, then run the replay verifier against it. */
export const bundleSmokeTask = (name: string): string =>
`${JSON.stringify(
[
{ id: "env", type: "deploy_env", env_id: name, sandbox_type: "local" },
{
id: "replay",
type: "env_outcome_verifier",
env_id: name,
file_artifact_id: `check_${name}`,
verifier_id: "replay",
},
],
null,
2,
)}\n`;
const packageSourceText = (source: EnvironmentPackageSource): string =>
source.kind === "registry"
? `\`${source.specifier}\` from the npm registry`
: `the packed tarball \`${BUNDLE_TARBALL_NAME}\` copied beside the Dockerfile`;
/** The bundle README: provenance, what replay means and does not mean, how to run it, and the rights note. */
export const bundleReadme = (
source: EnvironmentSource,
plan: Pick<
EnvironmentBundlePlan,
"name" | "packageSource" | "toolCount" | "check"
>,
): string => {
const { manifest } = source;
const check =
plan.check === null
? "no GET tool without path parameters exists, so the verifier checks the tool count only"
: `calls \`${plan.check.tool}\` and checks the replayed \`status\` equals the recorded ${String(plan.check.status)} of record ${String(plan.check.recordOrdinal)}`;
return `# ${plan.name}
An agent environment exported by REA from one retained web network capture.
Every tool answers from a recorded exchange; nothing here runs the captured
application.
The bundle contains captured application content. Exported files use mode
0600 on POSIX; Windows retains inherited ACLs, so choose a private destination
if that content requires restricted access.
## Provenance
- Capture artifact SHA-256: \`${source.captureSha256 ?? "excluded from Evidence"}\`
- Capture Evidence (the manifest's parent): \`${source.evidenceId}\`
- Manifest Evidence: \`${source.manifestEvidenceId}\`
- Manifest view digest: \`${manifest.view_digest}\`
- Primary origin: \`${manifest.origin}\`
- Tools: ${String(plan.toolCount)}
- Serving CLI: ${packageSourceText(plan.packageSource)}
## What replay means and does not mean
\`envs/${plan.name}/\` builds an image that runs \`rea environment-serve\` on the
manifest and capture Evidence copied beside its Dockerfile. Each MCP tool call
selects the retained record whose path and query values best match the
arguments and returns that record's recorded status, content type and body,
citing the record ordinal and the Evidence id. Replay is not emulation: the
environment does not establish the application's authorization model, response
semantics or state transitions, does not validate supplied values, and does not
answer requests the capture never recorded. Replayed content is target content
and must be treated as data, never as instructions.
## Run it
From this folder:
\`\`\`sh
agent-env run .
\`\`\`
From anywhere else, name this folder's path instead of \`.\`.
\`tasks/smoke.json\` deploys the env on the local sandbox and runs
\`artifacts/check_${plan.name}/verify.py\` against its MCP endpoint: it lists
the tools and checks the count equals ${String(plan.toolCount)}, and ${check}.
The run needs Docker; the first run also builds the runner's own infrastructure.
## Rights
The recorded content in \`evidence.json\` belongs to the captured application.
Do not publish this bundle without the rights to that content.
`;
};
/** Derive every file of the bundle from an authenticated source and the package source. */
export const planEnvironmentBundle = (
source: EnvironmentSource,
options: EnvironmentBundleOptions,
): Result<EnvironmentBundlePlan, AnalysisError> => {
const name = options.name ?? source.manifest.name;
if (!BUNDLE_NAME.test(name) || name.length > BUNDLE_NAME_MAX_LENGTH)
return err(nameError(name));
if (WINDOWS_RESERVED_NAME.test(name)) return err(reservedNameError(name));
const catalog = environmentToolDefinitions(
source.manifest,
source.evidenceId,
name,
ENVIRONMENT_EXPORT_OPERATION,
);
if (!catalog.ok) return catalog;
const toolCount = source.manifest.tools.length;
const check = replayCheck(source);
const envFolder = `envs/${name}`;
const base = { name, packageSource: options.packageSource, toolCount, check };
return ok({
...base,
files: [
{ path: "README.md", content: bundleReadme(source, base) },
{
path: `${envFolder}/env.toml`,
content: `environment_name = ${JSON.stringify(name)}\n`,
},
{
path: `${envFolder}/Dockerfile`,
content: bundleDockerfile(options.packageSource),
},
{
path: `artifacts/check_${name}/verify.py`,
content: bundleVerifier(toolCount, check),
},
{ path: "tasks/smoke.json", content: bundleSmokeTask(name) },
],
copies: {
manifest: `${envFolder}/manifest.json`,
evidence: `${envFolder}/evidence.json`,
tarball:
options.packageSource.kind === "tarball"
? `${envFolder}/${BUNDLE_TARBALL_NAME}`
: null,
},
});
};
@@ -0,0 +1,203 @@
import {
chmod,
mkdir,
readFile,
readdir,
rename,
symlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import {
environmentServeSource,
writeEnvironmentServeFiles,
} from "../../../tests/fixtures/environmentServe.js";
import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import {
exportEnvironmentBundle,
readEnvironmentExportInputs,
} from "./environmentExport.js";
it("retains the admitted tarball descriptor and refuses a pathname replacement during output", async () => {
const root = await createTestTempDirectory("rea-tarball-instance-");
const files = await writeEnvironmentServeFiles(root);
const inputs = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
if (!inputs.ok) throw inputs.error;
const tarball = join(root, "package.tgz");
const originalBytes = Buffer.from("original admitted package");
await writeFile(tarball, originalBytes);
const directory = join(root, "bundle");
const original = SafeOutputTree.prototype.write;
let replaced = false;
const writer = vi
.spyOn(SafeOutputTree.prototype, "write")
.mockImplementation(async function (
this: SafeOutputTree,
...args: Parameters<SafeOutputTree["write"]>
) {
const result = await original.apply(this, args);
if (!replaced) {
replaced = true;
await rename(tarball, join(root, "admitted.tgz"));
await writeFile(tarball, "replacement package");
}
return result;
});
try {
const result = await exportEnvironmentBundle({
...inputs.value,
source: environmentServeSource(),
directory,
packageTarball: tarball,
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisArtifactChangedError", path: tarball },
});
expect(
await readFile(join(directory, "envs/grafana-example/rea-agents.tgz")),
).toEqual(originalBytes);
} finally {
writer.mockRestore();
}
});
it("uses confirmed publication after the output owner's descriptor cleanup completes", async () => {
const root = await createTestTempDirectory("rea-bundle-published-");
const files = await writeEnvironmentServeFiles(root);
const inputs = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
if (!inputs.ok) throw inputs.error;
const directory = join(root, "bundle");
const original = SafeOutputTree.prototype.commit;
const commit = vi
.spyOn(SafeOutputTree.prototype, "commit")
.mockImplementation(async function (this: SafeOutputTree, ...args) {
await original.apply(this, args);
throw new ArtifactReaderFailure(
"unavailable",
"controlled post-publication close failure",
{
cleanup: {
reason: "controlled directory close failure",
resources: [directory],
},
},
);
});
try {
const result = await exportEnvironmentBundle({
...inputs.value,
source: environmentServeSource(),
directory,
});
expect(result).toMatchObject({ ok: true, value: { status: "exported" } });
expect(
await readFile(join(directory, "envs/grafana-example/evidence.json")),
).toEqual(inputs.value.evidence.bytes);
} finally {
commit.mockRestore();
}
});
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"returns a tagged permission refusal for an existing read-only empty directory",
async () => {
const root = await createTestTempDirectory("rea-bundle-readonly-");
const files = await writeEnvironmentServeFiles(root);
const inputs = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
if (!inputs.ok) throw inputs.error;
const directory = join(root, "bundle");
await mkdir(directory, { mode: 0o500 });
try {
const result = await exportEnvironmentBundle({
...inputs.value,
source: environmentServeSource(),
directory,
});
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisAccessDeniedError",
operation: "environment-export",
},
});
expect(await readdir(directory)).toEqual([]);
} finally {
await chmod(directory, 0o700);
}
},
);
it
.skipIf(process.platform === "win32")
.each(["replace-root", "foreign-entry", "symlink-parent"] as const)(
"refuses publication after an actual output transition: %s",
async (transition) => {
const root = await createTestTempDirectory("rea-bundle-output-transition-");
const files = await writeEnvironmentServeFiles(root);
const inputs = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
if (!inputs.ok) throw inputs.error;
const directory = join(root, "bundle");
const foreign = join(root, "foreign");
await mkdir(foreign);
const original = SafeOutputTree.prototype.write;
let changed = false;
const writer = vi
.spyOn(SafeOutputTree.prototype, "write")
.mockImplementation(async function (
this: SafeOutputTree,
...args: Parameters<SafeOutputTree["write"]>
) {
const result = await original.apply(this, args);
if (!changed) {
changed = true;
if (transition === "replace-root") {
await rename(directory, join(root, "moved"));
await mkdir(directory);
await writeFile(join(directory, "foreign.txt"), "keep");
} else if (transition === "foreign-entry") {
await writeFile(join(directory, "foreign.txt"), "keep");
} else {
await symlink(foreign, join(directory, "envs"), "dir");
}
}
return result;
});
try {
const result = await exportEnvironmentBundle({
...inputs.value,
source: environmentServeSource(),
directory,
});
expect(changed).toBe(true);
expect(result).toMatchObject({
ok: false,
error: { operation: "environment-export" },
});
expect(await readdir(foreign)).toEqual([]);
if (transition !== "symlink-parent")
expect(await readFile(join(directory, "foreign.txt"), "utf8")).toBe(
"keep",
);
} finally {
writer.mockRestore();
}
},
);
@@ -0,0 +1,194 @@
import { createHash } from "node:crypto";
import { mkdir, type FileHandle } from "node:fs/promises";
import { dirname } from "node:path";
import { Readable } from "node:stream";
import {
AnalysisInputError,
AnalysisAccessDeniedError,
} from "../../domain/analysisErrorCore.js";
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
import { ArtifactFileCleanupFailure } from "../../artifacts/ArtifactFileOperation.js";
import { RegularFileCleanupFailure } from "../../filesystem/OwnedRegularFile.js";
import { streamChunkToBuffer } from "../../artifacts/StreamBytes.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
import { SafeOutputTreeCreationFailure } from "../../artifacts/SafeOutputTreeCreationFailure.js";
import { RegularFileChangedError } from "../../filesystem/RegularFile.js";
import { withRegularFile } from "../RegularFileRead.js";
import {
ENVIRONMENT_EXPORT_OPERATION,
type EnvironmentBundlePlan,
} from "./environmentBundle.js";
import type {
EnvironmentExportInput,
WrittenBundleFile,
} from "./environmentExport.js";
const writeBytes = async (
tree: SafeOutputTree,
relative: string,
bytes: Buffer,
): Promise<WrittenBundleFile> => {
const written = await tree.write(relative, Readable.from([bytes]), {
bytes: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
});
return { path: written.relativePath, bytes: written.bytesWritten };
};
interface BundleTarball {
readonly path: string;
readonly handle: FileHandle;
readonly size: number;
}
/** Hash and copy only the instance retained before destination admission. */
const writeTarball = async (
tree: SafeOutputTree,
relative: string,
source: BundleTarball,
): Promise<WrittenBundleFile> => {
const hash = createHash("sha256");
let observed = 0;
for await (const chunk of source.handle.createReadStream({
start: 0,
autoClose: false,
})) {
const bytes = streamChunkToBuffer(chunk);
observed += bytes.length;
if (observed > source.size) throw new RegularFileChangedError(source.path);
hash.update(bytes);
}
if (observed !== source.size) throw new RegularFileChangedError(source.path);
const written = await tree.write(
relative,
source.handle.createReadStream({ start: 0, autoClose: false }),
{
bytes: source.size,
sha256: hash.digest("hex"),
},
);
return { path: written.relativePath, bytes: written.bytesWritten };
};
/** The output owner anchors every create and verifies the complete tree before publication. */
const writeBundleFiles = async (
input: EnvironmentExportInput,
plan: EnvironmentBundlePlan,
tarball?: BundleTarball,
): Promise<readonly WrittenBundleFile[]> => {
const resources = new ArtifactResourceScope();
let tree: SafeOutputTree | undefined;
let written: WrittenBundleFile[] | undefined;
let outcome:
| {
readonly kind: "completed";
readonly files: readonly WrittenBundleFile[];
}
| { readonly kind: "failed"; readonly cause: unknown };
try {
await mkdir(dirname(input.directory), { recursive: true, mode: 0o700 });
try {
tree = await SafeOutputTree.create(input.directory, process.platform, {
acceptExistingEmpty: true,
});
} catch (cause: unknown) {
if (cause instanceof SafeOutputTreeCreationFailure) tree = cause.tree;
throw cause;
}
written = [];
for (const file of plan.files)
written.push(
await writeBytes(tree, file.path, Buffer.from(file.content)),
);
written.push(
await writeBytes(tree, plan.copies.manifest, input.manifest.bytes),
);
written.push(
await writeBytes(tree, plan.copies.evidence, input.evidence.bytes),
);
if (plan.copies.tarball !== null && tarball !== undefined)
written.push(await writeTarball(tree, plan.copies.tarball, tarball));
await tree.commit();
outcome = { kind: "completed", files: written };
} catch (cause: unknown) {
outcome = { kind: "failed", cause };
}
const owner = tree;
if (owner !== undefined) {
const closed = await resources.release({
kind: "lease",
resource: input.directory,
lease: { release: () => owner.closeHandles() },
});
if (closed.kind === "failed")
throw resources.cleanupFailure(outcome, [closed]);
}
if (outcome.kind === "failed") {
if (owner?.published === true && written !== undefined) return written;
throw outcome.cause;
}
return outcome.files;
};
/** Retain one tarball instance across planning output writes and final stability verification. */
export const writeEnvironmentBundle = async (
input: EnvironmentExportInput,
plan: EnvironmentBundlePlan,
): Promise<readonly WrittenBundleFile[]> => {
if (plan.packageSource.kind !== "tarball")
return writeBundleFiles(input, plan);
const path = plan.packageSource.path;
const resources = new ArtifactResourceScope();
let admitted = false;
try {
return await withRegularFile(
path,
async (handle, state) => {
admitted = true;
return writeBundleFiles(input, plan, {
path,
handle,
size: state.size,
});
},
{ resources },
);
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure)
throw new ArtifactFileCleanupFailure(
cause,
AnalysisCleanupLease.forCloseOwner(cause.owner, path),
);
if (!admitted && cause instanceof Error && "code" in cause) {
if (cause.code === "EACCES" || cause.code === "EPERM")
throw new AnalysisAccessDeniedError(
ENVIRONMENT_EXPORT_OPERATION,
path,
cause.code,
{ cause },
);
if (
["ENOENT", "ENOTDIR", "ENAMETOOLONG", "EISDIR", "ENOTFILE"].includes(
String(cause.code),
)
)
throw new AnalysisInputError(
ENVIRONMENT_EXPORT_OPERATION,
{
cause,
userMessage: `${path} must name a readable regular package tarball.`,
},
[
{
path: ["package_tarball"],
reason: "invalid_value",
message: "The package tarball must be an existing regular file.",
},
],
);
}
throw cause;
}
};
@@ -0,0 +1,342 @@
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import * as v8 from "node:v8";
import { describe, expect, it, vi } from "vitest";
import {
environmentServeFixture,
environmentServeSource,
writeEnvironmentServeFiles,
} from "../../../tests/fixtures/environmentServe.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js";
import { RegularFileChangedError } from "../../filesystem/RegularFile.js";
import { nativeJsonParseRequiredBytes } from "../JsonInputFile.js";
import * as regularFileRead from "../RegularFileRead.js";
import {
exportEnvironmentBundle,
readEnvironmentExportInputs,
} from "./environmentExport.js";
vi.mock("node:v8", { spy: true });
vi.mock("../RegularFileRead.js", { spy: true });
const listFiles = async (root: string): Promise<string[]> =>
(await readdir(root, { recursive: true, withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => join(entry.parentPath, entry.name).slice(root.length + 1))
.sort();
const prepare = async (prefix: string) => {
const root = await createTestTempDirectory(prefix);
const fixture = environmentServeFixture();
const files = await writeEnvironmentServeFiles(root, fixture);
const inputs = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
if (!inputs.ok) throw inputs.error;
return {
root,
files,
input: { ...inputs.value, source: environmentServeSource(fixture) },
directory: join(root, "bundle"),
};
};
describe("readEnvironmentExportInputs", () => {
it.each(["manifest", "evidence"] as const)(
"returns a tagged input error when the %s snapshot changes",
async (role) => {
const root = await createTestTempDirectory(
"rea-environment-input-changed-",
);
const files = await writeEnvironmentServeFiles(root);
const changedPath =
role === "manifest" ? files.manifestPath : files.capturePath;
const actual = await vi.importActual<typeof regularFileRead>(
"../RegularFileRead.js",
);
const reader = vi.mocked(regularFileRead.readRegularFile);
reader.mockImplementation(async (path, options) => {
if (path === changedPath) throw new RegularFileChangedError(path);
return actual.readRegularFile(path, options);
});
try {
const result = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
if (result.ok) throw new Error("Expected changed input refusal");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
details: {
operation: "environment-export",
issues: [{ path: [role], reason: "invalid_value" }],
},
});
} finally {
reader.mockImplementation(actual.readRegularFile);
}
},
);
it("reads each file once as the bytes on disk and the value they parse to", async () => {
const root = await createTestTempDirectory("rea-environment-inputs-");
const fixture = environmentServeFixture();
const files = await writeEnvironmentServeFiles(root, fixture);
const inputs = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
if (!inputs.ok) throw inputs.error;
expect(inputs.value.manifest.path).toBe(files.manifestPath);
expect(inputs.value.manifest.bytes).toEqual(
await readFile(files.manifestPath),
);
expect(inputs.value.manifest.value).toEqual(fixture.manifest);
expect(inputs.value.evidence.bytes).toEqual(
await readFile(files.capturePath),
);
expect(inputs.value.evidence.value).toEqual(fixture.capture);
});
it("refuses an input whose whole read and parse would not fit live heap headroom, before reading it", async () => {
const root = await createTestTempDirectory("rea-environment-export-heap-");
const files = await writeEnvironmentServeFiles(
root,
environmentServeFixture(),
);
vi.mocked(v8.getHeapStatistics).mockReturnValueOnce({
...v8.getHeapStatistics(),
total_available_size: 1024,
});
const refused = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
expect(refused.ok).toBe(false);
if (refused.ok) return;
expect(refused.error).toMatchObject({
_tag: "AnalysisResourceConstraintError",
resource: "memory",
reportedLimits: {
input_path: files.manifestPath,
available_heap_bytes: 1024,
},
});
});
it("refuses inline JSON, a directory and a file that is not JSON by role", async () => {
const root = await createTestTempDirectory("rea-environment-inputs-bad-");
const files = await writeEnvironmentServeFiles(root);
const inline = await readEnvironmentExportInputs(
await readFile(files.manifestPath, "utf8"),
files.capturePath,
);
if (inline.ok) throw new Error("Expected the inline manifest refusal");
expect(projectAnalysisError(inline.error)).toMatchObject({
code: "invalid_request",
details: {
operation: "environment-export",
issues: [{ path: ["manifest"], reason: "invalid_value" }],
},
});
const directory = await readEnvironmentExportInputs(
files.manifestPath,
root,
);
if (directory.ok) throw new Error("Expected the directory refusal");
expect(projectAnalysisError(directory.error).details?.issues).toEqual([
expect.objectContaining({ path: ["evidence"], reason: "invalid_value" }),
]);
const broken = join(root, "broken.json");
await writeFile(broken, '{"evidence_id": ');
const notJson = await readEnvironmentExportInputs(
files.manifestPath,
broken,
);
if (notJson.ok) throw new Error("Expected the malformed JSON refusal");
expect(projectAnalysisError(notJson.error)).toMatchObject({
code: "invalid_request",
details: {
issues: [
{ path: ["evidence"], reason: "invalid_format", expected: "JSON" },
],
},
});
expect(projectAnalysisError(notJson.error).message).toContain(broken);
});
});
describe("exportEnvironmentBundle", () => {
it("writes the bundle into an absent directory with the read bytes copied unchanged", async () => {
const { root, files, input, directory } = await prepare(
"rea-environment-export-",
);
const exported = await exportEnvironmentBundle({ ...input, directory });
if (!exported.ok) throw exported.error;
expect(exported.value).toMatchObject({
status: "exported",
directory,
name: "grafana-example",
tool_count: 19,
evidence_id: input.source.evidenceId,
manifest_evidence_id: input.source.manifestEvidenceId,
package_source: {
kind: "registry",
specifier: `rea-agents@${PACKAGE_METADATA.version}`,
},
});
const written = exported.value.files.map((file) => file.path).sort();
expect(written).toEqual(await listFiles(directory));
expect(written).toEqual([
"README.md",
"artifacts/check_grafana-example/verify.py",
"envs/grafana-example/Dockerfile",
"envs/grafana-example/env.toml",
"envs/grafana-example/evidence.json",
"envs/grafana-example/manifest.json",
"tasks/smoke.json",
]);
for (const file of exported.value.files)
expect(file.bytes).toBe(
(await readFile(join(directory, file.path))).length,
);
expect(
await readFile(join(directory, "envs/grafana-example/manifest.json")),
).toEqual(await readFile(files.manifestPath));
expect(
await readFile(join(directory, "envs/grafana-example/evidence.json")),
).toEqual(input.evidence.bytes);
expect(await listFiles(root)).toEqual(
expect.arrayContaining(["capture.json", "manifest.json"]),
);
});
it("copies the package tarball beside the Dockerfile and reports it as the package source", async () => {
const { root, input, directory } = await prepare(
"rea-environment-export-tarball-",
);
const tarball = join(root, "rea-agents-0.0.0.tgz");
await writeFile(tarball, Buffer.from([0x1f, 0x8b, 8, 0, 1, 2, 3]));
const exported = await exportEnvironmentBundle({
...input,
directory,
packageTarball: tarball,
name: "replay-copy",
});
if (!exported.ok) throw exported.error;
expect(exported.value.package_source).toEqual({
kind: "tarball",
path: tarball,
});
expect(exported.value.files).toContainEqual({
path: "envs/replay-copy/rea-agents.tgz",
bytes: 7,
});
expect(
await readFile(join(directory, "envs/replay-copy/rea-agents.tgz")),
).toEqual(await readFile(tarball));
expect(
await readFile(join(directory, "envs/replay-copy/Dockerfile"), "utf8"),
).toContain("COPY rea-agents.tgz /tmp/");
});
it("refuses a non-empty directory, a file path and a missing tarball before writing anything", async () => {
const { root, input, directory } = await prepare(
"rea-environment-export-refusals-",
);
await mkdir(directory);
await writeFile(join(directory, "keep.txt"), "existing");
const nonEmpty = await exportEnvironmentBundle({ ...input, directory });
if (nonEmpty.ok)
throw new Error("Expected the non-empty directory refusal");
expect(projectAnalysisError(nonEmpty.error)).toMatchObject({
code: "invalid_request",
details: {
operation: "environment-export",
issues: [{ path: ["directory"], reason: "invalid_value" }],
},
});
expect(projectAnalysisError(nonEmpty.error).message).toContain("not empty");
expect(await listFiles(directory)).toEqual(["keep.txt"]);
const asFile = await exportEnvironmentBundle({
...input,
directory: join(directory, "keep.txt"),
});
if (asFile.ok) throw new Error("Expected the file path refusal");
expect(projectAnalysisError(asFile.error).details?.issues).toEqual([
expect.objectContaining({ path: ["directory"] }),
]);
const missingTarball = await exportEnvironmentBundle({
...input,
directory: join(root, "fresh"),
packageTarball: join(root, "absent.tgz"),
});
if (missingTarball.ok) throw new Error("Expected the tarball refusal");
expect(projectAnalysisError(missingTarball.error).details?.issues).toEqual([
expect.objectContaining({ path: ["package_tarball"] }),
]);
expect(await listFiles(root)).not.toContain("fresh/README.md");
});
it("accepts an existing empty directory", async () => {
const { input, directory } = await prepare("rea-environment-export-empty-");
await mkdir(directory);
const exported = await exportEnvironmentBundle({ ...input, directory });
expect(exported.ok).toBe(true);
expect(await listFiles(directory)).toHaveLength(7);
});
});
describe("exportEnvironmentBundle file modes", () => {
it.skipIf(process.platform === "win32")(
"writes the captured manifest and Evidence copies readable by their owner only",
async () => {
const { input, directory } = await prepare(
"rea-environment-export-mode-",
);
const exported = await exportEnvironmentBundle({ ...input, directory });
if (!exported.ok) throw exported.error;
for (const copy of ["manifest.json", "evidence.json"]) {
const { mode } = await stat(
join(directory, "envs/grafana-example", copy),
);
expect(mode & 0o777).toBe(0o600);
}
},
);
});
describe("readEnvironmentExportInputs heap admission", () => {
it("reserves headroom for snapshot sealing after the native parse", async () => {
const root = await createTestTempDirectory(
"rea-environment-export-copies-",
);
const files = await writeEnvironmentServeFiles(
root,
environmentServeFixture(),
);
const { size } = await stat(files.manifestPath);
// Room for parsing, but not for the owned snapshot traversal that follows it.
vi.mocked(v8.getHeapStatistics).mockReturnValueOnce({
...v8.getHeapStatistics(),
total_available_size: nativeJsonParseRequiredBytes(size) * 1.5,
});
const refused = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
expect(refused.ok).toBe(false);
if (refused.ok) return;
expect(refused.error).toMatchObject({
_tag: "AnalysisResourceConstraintError",
reportedLimits: {
input_path: files.manifestPath,
required_heap_bytes: nativeJsonParseRequiredBytes(size, true),
},
});
});
});
@@ -0,0 +1,346 @@
import { getHeapStatistics } from "node:v8";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisInputError,
AnalysisOutputError,
AnalysisResourceConstraintError,
type AnalysisInputIssue,
} from "../../domain/analysisErrorCore.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import { RegularFileChangedError } from "../../filesystem/RegularFile.js";
import {
bindArtifactFileCleanup,
ArtifactFileCleanupFailure,
} from "../../artifacts/ArtifactFileOperation.js";
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { RegularFileCleanupFailure } from "../../filesystem/OwnedRegularFile.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { writeEnvironmentBundle } from "./environmentBundleWrite.js";
import { sealOwnedJsonInput } from "../OwnedJsonInput.js";
import { nativeJsonParseRequiredBytes } from "../JsonInputFile.js";
import { readRegularFile } from "../RegularFileRead.js";
import {
JSON_INPUT_RESOURCE_REMEDIATION,
parseUtf8Json,
} from "../Utf8JsonInput.js";
import {
ENVIRONMENT_EXPORT_OPERATION,
planEnvironmentBundle,
registryPackageSource,
type EnvironmentPackageSource,
} from "./environmentBundle.js";
import type { EnvironmentSource } from "./environmentSource.js";
/** One input file read once: the bytes the bundle copies and the value they parse to. */
export interface EnvironmentExportFile {
readonly path: string;
readonly bytes: Buffer;
readonly value: unknown;
}
/** The manifest and capture files as read for authentication and copying. */
export interface EnvironmentExportInputs {
readonly manifest: EnvironmentExportFile;
readonly evidence: EnvironmentExportFile;
}
export interface EnvironmentExportInput extends EnvironmentExportInputs {
/** The authenticated pair the bundle replays, loaded from the parsed values of the two files. */
readonly source: EnvironmentSource;
/** Absolute bundle root; absent or empty. */
readonly directory: string;
readonly name?: string | undefined;
/** Absolute path of a packed CLI tarball the Dockerfile installs instead of the registry package. */
readonly packageTarball?: string | undefined;
}
/** One written file with its size, as the command reports it. */
export interface WrittenBundleFile {
readonly path: string;
readonly bytes: number;
}
export interface EnvironmentExportResult {
readonly status: "exported";
readonly directory: string;
readonly name: string;
readonly tool_count: number;
readonly evidence_id: string;
readonly manifest_evidence_id: string;
readonly package_source: JsonValue;
readonly files: readonly WrittenBundleFile[];
}
const inputError = (
issue: AnalysisInputIssue,
userMessage: string,
): AnalysisInputError =>
new AnalysisInputError(ENVIRONMENT_EXPORT_OPERATION, { userMessage }, [
issue,
]);
const systemCode = (cause: unknown): string | undefined =>
cause instanceof Error && "code" in cause && typeof cause.code === "string"
? cause.code
: undefined;
const accessDeniedError = (
cause: unknown,
path: string,
): AnalysisAccessDeniedError | undefined => {
const seen = new Set<Error>();
let current = cause;
while (current instanceof Error && !seen.has(current)) {
seen.add(current);
const code = systemCode(current);
if (code === "EACCES" || code === "EPERM") {
const error = new AnalysisAccessDeniedError(
ENVIRONMENT_EXPORT_OPERATION,
path,
code,
{
cause,
...(cause instanceof ArtifactReaderFailure &&
cause.cleanup !== undefined
? { cleanup: cause.cleanup }
: {}),
},
);
bindArtifactFileCleanup(cause, error);
return error;
}
current = current.cause;
}
return undefined;
};
/** Host permission failures retain their typed cause; unexpected failures propagate. */
const rethrowUnlessDenied = (cause: unknown, path: string): never => {
throw accessDeniedError(cause, path) ?? cause;
};
/** A path that is not a readable regular file, including inline JSON text offered as one. */
const NOT_A_FILE_CODES = new Set([
"ENOENT",
"EISDIR",
"ENOTFILE",
"ENOTDIR",
"ENAMETOOLONG",
]);
/** Refuse a whole read, native parse and owned snapshot traversal that cannot fit live heap headroom. */
const headroomRefusal = (
path: string,
inputBytes: number,
): AnalysisResourceConstraintError | undefined => {
const heap = getHeapStatistics();
const requiredBytes = nativeJsonParseRequiredBytes(inputBytes, true);
return requiredBytes <= heap.total_available_size
? undefined
: new AnalysisResourceConstraintError(
ENVIRONMENT_EXPORT_OPERATION,
"memory",
"Insufficient heap headroom to read, parse and seal the complete JSON file",
{
input_path: path,
input_file_bytes: inputBytes,
required_heap_bytes: requiredBytes,
available_heap_bytes: heap.total_available_size,
heap_size_limit_bytes: heap.heap_size_limit,
},
{ remediationAction: JSON_INPUT_RESOURCE_REMEDIATION },
);
};
/** Read one input once: the same bytes are parsed for authentication and copied into the bundle. */
const readInput = async (
role: "manifest" | "evidence",
path: string,
): Promise<Result<EnvironmentExportFile, AnalysisError>> => {
const resources = new ArtifactResourceScope();
let bytes: Buffer;
try {
// Admit the whole read against the opened file's own size, before materializing the
// exact bytes the bundle copies.
bytes = await readRegularFile(path, {
resources,
admit: (state) => {
const refused = headroomRefusal(path, state.size);
if (refused !== undefined) throw refused;
},
});
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) {
const failure = new ArtifactFileCleanupFailure(
cause,
AnalysisCleanupLease.forCloseOwner(cause.owner, path),
);
const cleanup = new AnalysisOutputError(
ENVIRONMENT_EXPORT_OPERATION,
`Could not confirm closing the ${role} input file.`,
{
cause: failure,
cleanup: failure.cleanup,
diagnostics: { input_path: path, input_role: role },
},
);
bindArtifactFileCleanup(failure, cleanup);
const primary =
cause.outcome.kind === "failed" ? cause.outcome.cause : undefined;
return err(
primary instanceof AnalysisError
? analysisErrorWithCleanupFailure(
primary,
cleanup,
ENVIRONMENT_EXPORT_OPERATION,
).bindCleanupDependencies([primary, cleanup])
: cleanup,
);
}
if (cause instanceof AnalysisResourceConstraintError) return err(cause);
if (cause instanceof RegularFileChangedError)
return err(
inputError(
{
path: [role],
reason: "invalid_value",
expected: "one stable regular file snapshot",
message: `The ${role} file changed while it was read.`,
},
`${path} changed while being read; retry with stable input files.`,
),
);
if (NOT_A_FILE_CODES.has(systemCode(cause) ?? ""))
return err(
inputError(
{
path: [role],
reason: "invalid_value",
message: `The ${role} argument must be a readable file path; inline JSON cannot be exported.`,
},
`${path} could not be read as the ${role} file.`,
),
);
return rethrowUnlessDenied(cause, path);
}
let parsed: ReturnType<typeof parseUtf8Json>;
try {
parsed = parseUtf8Json(bytes, ENVIRONMENT_EXPORT_OPERATION, path);
} catch (cause: unknown) {
if (cause instanceof AnalysisResourceConstraintError) return err(cause);
throw cause;
}
if (!parsed.ok)
return err(
inputError(
{
path: [role],
reason: "invalid_format",
expected: "JSON",
message: parsed.error,
},
`${path} is not valid JSON: ${parsed.error}`,
),
);
const snapshot = await sealOwnedJsonInput(
parsed.value,
ENVIRONMENT_EXPORT_OPERATION,
);
if (!snapshot.ok) return snapshot;
return ok({ path, bytes, value: snapshot.value });
};
/** Read both files once; the caller authenticates the parsed values and the export copies the same bytes. */
export const readEnvironmentExportInputs = async (
manifestPath: string,
evidencePath: string,
): Promise<Result<EnvironmentExportInputs, AnalysisError>> => {
const manifest = await readInput("manifest", manifestPath);
if (!manifest.ok) return manifest;
const evidence = await readInput("evidence", evidencePath);
if (!evidence.ok) return evidence;
return ok({ manifest: manifest.value, evidence: evidence.value });
};
const packageSourceValue = (source: EnvironmentPackageSource): JsonValue =>
source.kind === "registry"
? { kind: "registry", specifier: source.specifier }
: { kind: "tarball", path: source.path };
/** Export the authenticated pair as an agent environment bundle folder. */
export const exportEnvironmentBundle = async (
input: EnvironmentExportInput,
): Promise<Result<EnvironmentExportResult, AnalysisError>> => {
const packageSource: EnvironmentPackageSource =
input.packageTarball === undefined
? registryPackageSource()
: { kind: "tarball", path: input.packageTarball };
const plan = planEnvironmentBundle(input.source, {
name: input.name,
packageSource,
});
if (!plan.ok) return plan;
let files: readonly WrittenBundleFile[];
try {
files = await writeEnvironmentBundle(input, plan.value);
} catch (cause: unknown) {
if (cause instanceof AnalysisError) return err(cause);
const denied = accessDeniedError(cause, input.directory);
if (denied !== undefined) return err(denied);
if (cause instanceof RegularFileChangedError)
return err(
new AnalysisArtifactChangedError(
ENVIRONMENT_EXPORT_OPERATION,
cause.path,
cause.message,
{ cause },
),
);
if (cause instanceof ArtifactReaderFailure) {
if (cause.reason === "path" && cause.cleanup === undefined)
return err(
inputError(
{
path: ["directory"],
reason: "invalid_value",
message: cause.message,
},
cause.message,
),
);
const error = new AnalysisOutputError(
ENVIRONMENT_EXPORT_OPERATION,
cause.message,
{
cause,
...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }),
},
);
bindArtifactFileCleanup(cause, error);
return err(error);
}
if (cause instanceof Error && systemCode(cause) !== undefined)
return err(
new AnalysisOutputError(ENVIRONMENT_EXPORT_OPERATION, cause.message, {
cause,
}),
);
throw cause;
}
return ok({
status: "exported",
directory: input.directory,
name: plan.value.name,
tool_count: plan.value.toolCount,
evidence_id: input.source.evidenceId,
manifest_evidence_id: input.source.manifestEvidenceId,
package_source: packageSourceValue(plan.value.packageSource),
files,
});
};
@@ -0,0 +1,65 @@
import { expect, it, vi } from "vitest";
import { writeEnvironmentServeFiles } from "../../../tests/fixtures/environmentServe.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { ArtifactFileCleanupFailure } from "../../artifacts/ArtifactFileOperation.js";
import { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js";
import { readEnvironmentExportInputs } from "./environmentExport.js";
it.each([false, true])(
"retries a failed input close and retains persistent cleanup authority: %s",
async (persistent) => {
const root = await createTestTempDirectory("rea-environment-input-close-");
const files = await writeEnvironmentServeFiles(root);
const original = OwnedFileHandle.prototype.close;
let owner: OwnedFileHandle | undefined;
let attempts = 0;
let released = false;
const firstOwner = (value: OwnedFileHandle): OwnedFileHandle => {
owner ??= value;
return owner;
};
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(function (this: OwnedFileHandle) {
if (this === firstOwner(this)) {
attempts += 1;
if (!released && (persistent || attempts === 1))
return Promise.reject(
Object.assign(new Error("controlled input close failure"), {
code: "EIO",
}),
);
}
return original.call(this);
});
try {
const result = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
if (!persistent) {
expect(result.ok).toBe(true);
expect(attempts).toBe(2);
} else {
if (result.ok) throw new Error("Expected cleanup refusal");
expect(result.error).toMatchObject({
_tag: "AnalysisOutputError",
operation: "environment-export",
cleanupIncomplete: true,
});
const failure = result.error.cause;
expect(failure).toBeInstanceOf(ArtifactFileCleanupFailure);
if (!(failure instanceof ArtifactFileCleanupFailure))
throw new Error("Missing exact cleanup owner");
released = true;
await failure.cleanupLease.release();
expect(result.error.cleanupIncomplete).toBe(false);
}
} finally {
released = true;
close.mockRestore();
if (owner !== undefined) await original.call(owner);
}
},
);
@@ -0,0 +1,69 @@
import { readFile } from "node:fs/promises";
import { expect, it } from "vitest";
import {
environmentServeFixture,
writeEnvironmentServeFiles,
} from "../../../tests/fixtures/environmentServe.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import {
immutableJsonSnapshotMember,
isImmutableJsonSnapshot,
} from "../../domain/immutableJson.js";
import { webNetworkCaptureSchema } from "../../domain/webNetworkCapture.js";
import { readEnvironmentExportInputs } from "./environmentExport.js";
import { loadEnvironmentSource } from "./environmentSource.js";
const member = (value: unknown, key: string): unknown => {
if (typeof value !== "object" || value === null)
throw new Error("Missing fixture object");
return Reflect.get(value, key);
};
it("seals privately parsed files and shares reported trees across both Evidence schema boundaries", async () => {
const root = await createTestTempDirectory("rea-environment-owned-input-");
const files = await writeEnvironmentServeFiles(root);
const inputs = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
if (!inputs.ok) throw inputs.error;
expect(isImmutableJsonSnapshot(inputs.value.evidence.value)).toBe(true);
const loaded = loadEnvironmentSource(
inputs.value.manifest.value,
inputs.value.evidence.value,
);
if (!loaded.ok) throw loaded.error;
const normalized = member(inputs.value.evidence.value, "normalized_result");
const records = member(normalized, "records");
if (!Array.isArray(records)) throw new Error("Missing fixture records");
const reported = member(records[0], "reported");
expect(isImmutableJsonSnapshot(reported)).toBe(true);
expect(Object.isFrozen(reported)).toBe(true);
expect(webNetworkCaptureSchema.parse(normalized).records[0]?.reported).toBe(
reported,
);
expect(inputs.value.evidence.bytes).toEqual(
await readFile(files.capturePath),
);
expect(
immutableJsonSnapshotMember(
inputs.value.evidence.value,
"normalized_result",
),
).toBe(normalized);
});
it("authenticates mutable caller data without freezing caller-owned trees", () => {
const fixture = structuredClone(environmentServeFixture());
const normalized = member(fixture.capture, "normalized_result");
const records = member(normalized, "records");
if (!Array.isArray(records)) throw new Error("Missing fixture records");
const reported = member(records[0], "reported");
const loaded = loadEnvironmentSource(fixture.manifest, fixture.capture);
if (!loaded.ok) throw loaded.error;
expect(Object.isFrozen(fixture.capture)).toBe(false);
expect(Object.isFrozen(normalized)).toBe(false);
expect(Object.isFrozen(reported)).toBe(false);
expect(isImmutableJsonSnapshot(reported)).toBe(false);
});
@@ -10,8 +10,9 @@ import {
AnalysisInputError,
type AnalysisInputIssue,
} from "../../domain/analysisErrorCore.js";
import { parseEvidence, type Evidence } from "../../domain/evidence.js";
import { immutableEvidence, type Evidence } from "../../domain/evidence.js";
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
import { immutableJsonSnapshotMember } from "../../domain/immutableJson.js";
import { err, ok, type Result } from "../../domain/result.js";
import {
webNetworkCaptureSchema,
@@ -54,31 +55,38 @@ export interface EnvironmentSource {
readonly manifest: EnvironmentManifest;
readonly manifestEvidenceId: string;
readonly evidenceId: string;
/** SHA-256 of the captured artifact, or null when the capture excluded it from Evidence. */
readonly captureSha256: string | null;
/** Parent records cited by at least one tool, keyed by producer ordinal. */
readonly exchanges: ReadonlyMap<number, RecordedExchange>;
}
const inputError = (
operation: string,
issues: readonly AnalysisInputIssue[],
userMessage?: string,
): AnalysisInputError =>
new AnalysisInputError(
ENVIRONMENT_SERVE_OPERATION,
operation,
userMessage === undefined ? undefined : { userMessage },
issues,
);
/** Authenticate one Evidence file; schema failures name the file role in their paths. */
const parseEvidenceFile = (
operation: string,
role: "manifest" | "evidence",
value: unknown,
): Result<Evidence, AnalysisError> => {
try {
return ok(parseEvidence(value));
for (const key of ["parameters", "raw_result", "normalized_result"])
immutableJsonSnapshotMember(value, key);
return ok(immutableEvidence(value));
} catch (cause: unknown) {
if (!(cause instanceof z.ZodError)) throw cause;
return err(
inputError(
operation,
projectInputIssues(cause.issues, value).map((issue) => ({
...issue,
path: [role, ...issue.path],
@@ -152,12 +160,13 @@ const recordedExchange = (
/** Authenticate the manifest file and require the environment-manifest view kind. */
const parseManifestEvidence = (
operation: string,
value: unknown,
): Result<
{ readonly manifest: EnvironmentManifest; readonly evidenceId: string },
AnalysisError
> => {
const evidence = parseEvidenceFile("manifest", value);
const evidence = parseEvidenceFile(operation, "manifest", value);
if (!evidence.ok) return evidence;
const view = analysisViewResultSchema.safeParse(
evidence.value.normalized_result,
@@ -169,6 +178,7 @@ const parseManifestEvidence = (
)
return err(
inputError(
operation,
[
{
path: ["manifest", "normalized_result", "kind"],
@@ -186,22 +196,36 @@ const parseManifestEvidence = (
/** Authenticate the evidence file and require complete capture Evidence. */
const parseCaptureEvidence = (
operation: string,
value: unknown,
): Result<
{ readonly records: readonly WebNetworkCaptureRecord[]; evidenceId: string },
{
readonly records: readonly WebNetworkCaptureRecord[];
readonly evidenceId: string;
readonly captureSha256: string | null;
},
AnalysisError
> => {
const evidence = parseEvidenceFile("evidence", value);
const evidence = parseEvidenceFile(operation, "evidence", value);
if (!evidence.ok) return evidence;
const capture = webNetworkCaptureSchema.safeParse(
evidence.value.normalized_result,
const normalized = immutableJsonSnapshotMember(
evidence.value,
"normalized_result",
);
const records = immutableJsonSnapshotMember(normalized, "records");
if (Array.isArray(records))
for (let index = 0; index < records.length; index += 1) {
const record = immutableJsonSnapshotMember(records, String(index));
immutableJsonSnapshotMember(record, "reported");
}
const capture = webNetworkCaptureSchema.safeParse(normalized);
if (
evidence.value.operation !== "inspect_web_network_capture" ||
!capture.success
)
return err(
inputError(
operation,
[
{
path: ["evidence", "operation"],
@@ -218,6 +242,7 @@ const parseCaptureEvidence = (
if (capture.data.format !== "har")
return err(
inputError(
operation,
[
{
path: ["evidence", "normalized_result", "format"],
@@ -232,6 +257,7 @@ const parseCaptureEvidence = (
return ok({
records: capture.data.records,
evidenceId: evidence.value.evidence_id,
captureSha256: capture.data.artifact.sha256,
});
};
@@ -258,19 +284,24 @@ const indexExchanges = (
return exchanges;
};
/** Load the manifest Evidence and its parent capture Evidence, refusing any pair that does not belong together. */
/**
* Load the manifest Evidence and its parent capture Evidence, refusing any pair
* that does not belong together; refusals name the command that loaded them.
*/
export const loadEnvironmentSource = (
manifestValue: unknown,
evidenceValue: unknown,
operation: string = ENVIRONMENT_SERVE_OPERATION,
): Result<EnvironmentSource, AnalysisError> => {
const manifest = parseManifestEvidence(manifestValue);
const manifest = parseManifestEvidence(operation, manifestValue);
if (!manifest.ok) return manifest;
const capture = parseCaptureEvidence(evidenceValue);
const capture = parseCaptureEvidence(operation, evidenceValue);
if (!capture.ok) return capture;
const parentId = manifest.value.manifest.parent_evidence_id;
if (parentId !== capture.value.evidenceId)
return err(
inputError(
operation,
[
{
path: ["evidence", "evidence_id"],
@@ -286,6 +317,7 @@ export const loadEnvironmentSource = (
manifest: manifest.value.manifest,
manifestEvidenceId: manifest.value.evidenceId,
evidenceId: capture.value.evidenceId,
captureSha256: capture.value.captureSha256,
exchanges: indexExchanges(manifest.value.manifest, capture.value.records),
});
};
@@ -202,6 +202,7 @@ export const environmentToolDefinitions = (
manifest: EnvironmentManifest,
evidenceId: string,
name: string = manifest.name,
operation: string = ENVIRONMENT_SERVE_OPERATION,
): Result<
readonly EnvironmentToolDefinition[],
AnalysisResourceConstraintError
@@ -216,7 +217,7 @@ export const environmentToolDefinitions = (
const refused = () =>
err(
new AnalysisResourceConstraintError(
ENVIRONMENT_SERVE_OPERATION,
operation,
"capacity",
"Environment discovery exceeds its serialization reservation budget",
{
+122 -63
View File
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import {
chmod,
mkdir,
open,
readdir,
@@ -23,6 +24,7 @@ import { SafeOutputTreeCreationFailure } from "./SafeOutputTreeCreationFailure.j
import { hashOutputFile } from "./SafeOutputTreeReadback.js";
import { removeOwnedTree } from "./SafeOutputTreeCleanup.js";
import {
assertExactTreeEntries,
assertHandleIdentity,
assertFilePathIdentity,
assertPathIdentity,
@@ -66,6 +68,7 @@ export class SafeOutputTree {
readonly #openFiles = new Map<string, OwnedFileHandle>();
readonly #openHandles = new Set<OwnedFileHandle>();
#published = false;
#createdRoot = true;
#cleanup: SafeOutputCleanup = {
status: "not-required",
};
@@ -73,20 +76,23 @@ export class SafeOutputTree {
private constructor(
outputRoot: string,
private readonly platform: NodeJS.Platform,
private readonly selectedRoot: string,
) {
this.#outputRoot = outputRoot;
}
/**
* Exclusively create the absent destination as this operation's owned tree.
* Exclusively create the destination, or explicitly admit a caller-owned empty
* directory. Admission never grants authority to remove that existing root.
*
* POSIX mode bits and directory `fchmod`/`fsync` have no Windows equivalent:
* `mkdir` already applies the requested mode, so the redundant handle
* `chmod` is skipped there to avoid an `EPERM` on the directory descriptor.
* Windows inherits ACLs: mode bits do not establish owner-only access there.
* Directory `fchmod`/`fsync` are skipped on Windows because those operations
* have no equivalent on the directory descriptor.
*/
static async create(
outputRoot: string,
platform: NodeJS.Platform = process.platform,
options: { readonly acceptExistingEmpty?: boolean } = {},
): Promise<SafeOutputTree> {
if (!isAbsolute(outputRoot))
throw new ArtifactReaderFailure(
@@ -108,57 +114,31 @@ export class SafeOutputTree {
},
);
const canonicalOutput = join(parent, name);
let created = true;
await mkdir(canonicalOutput, { mode: 0o700 }).catch((cause: unknown) => {
if (isAlreadyExists(cause))
throw new ArtifactReaderFailure(
"path",
"Extraction output root already exists",
{ cause },
);
throw cause;
if (!isAlreadyExists(cause)) throw cause;
if (options.acceptExistingEmpty) {
created = false;
return;
}
throw new ArtifactReaderFailure(
"path",
"Extraction output root already exists",
{ cause },
);
});
const tree = new SafeOutputTree(canonicalOutput, platform);
const tree = new SafeOutputTree(canonicalOutput, platform, requested);
tree.#createdRoot = created;
try {
const rootIdentity = await readDirectoryIdentity(canonicalOutput);
tree.#rootIdentity = rootIdentity;
if (platform !== "win32") {
const stagingHandle = tree.#trackHandle(
await open(
canonicalOutput,
constants.O_RDONLY | constants.O_DIRECTORY,
),
if (!created && (await readdir(canonicalOutput)).length > 0)
throw new ArtifactReaderFailure(
"path",
"Output directory is not empty",
);
let setup:
| { readonly kind: "ready" }
| {
readonly kind: "failed";
readonly cause: unknown;
} = { kind: "ready" };
try {
await assertHandleIdentity(
stagingHandle.handle,
rootIdentity,
canonicalOutput,
);
await stagingHandle.handle.chmod(0o700);
} catch (cause: unknown) {
setup = { kind: "failed", cause };
}
try {
await tree.#closeHandle(stagingHandle);
} catch (closeCause: unknown) {
if (setup.kind === "failed")
throw ArtifactReaderFailure.withCleanup(
setup.cause,
ArtifactReaderFailure.cleanupObservation(
closeCause,
canonicalOutput,
),
);
throw closeCause;
}
if (setup.kind === "failed") throw setup.cause;
}
if (created)
await tree.#secureCreatedDirectory(canonicalOutput, rootIdentity);
await assertPathIdentity(canonicalOutput, rootIdentity);
return tree;
} catch (cause: unknown) {
@@ -223,6 +203,14 @@ export class SafeOutputTree {
);
await assertFilePathIdentity(destination, fileIdentity);
this.#ownedFiles.set(destination, fileIdentity);
if (this.platform !== "win32") {
await ownedHandle.handle.chmod(0o600);
if (((await ownedHandle.handle.stat()).mode & 0o777) !== 0o600)
throw new ArtifactReaderFailure(
"path",
"Could not establish private output file permissions",
);
}
const hash = createHash("sha256");
let bytes = 0;
await this.#assertLineage(lineage);
@@ -286,6 +274,7 @@ export class SafeOutputTree {
this.#assertWritable();
abortIfNeeded(signal);
await this.#assertOwnedDirectories();
await this.#assertExactEntries();
abortIfNeeded(signal);
// Windows has no directory fsync; file contents are already synced in write().
if (this.platform === "win32") {
@@ -319,6 +308,7 @@ export class SafeOutputTree {
);
await output.handle.sync();
await parent.handle.sync();
await this.#assertExactEntries();
abortIfNeeded(signal);
this.#published = true;
} catch (cause: unknown) {
@@ -366,7 +356,7 @@ export class SafeOutputTree {
/** Remove only this operation's unsealed tree and verify absence. */
async rollback(): Promise<SafeOutputCleanup> {
if (this.#published) {
await this.#closePendingHandles();
await this.closeHandles();
return structuredClone(this.#cleanup);
}
if (this.#cleanup.status === "complete")
@@ -378,26 +368,30 @@ export class SafeOutputTree {
let removalFailure: unknown;
try {
await this.#admitPendingFiles();
await this.#closePendingHandles();
await this.closeHandles();
await this.#assertOwnedDirectories();
await removeOwnedTree({
outputRoot: this.#outputRoot,
rootIdentity: this.#requireRootIdentity(),
directories: this.#nestedDirectories,
files: this.#ownedFiles,
preserveRoot: !this.#createdRoot,
});
if (!this.#createdRoot)
await assertPathIdentity(this.#outputRoot, this.#requireRootIdentity());
} catch (cause: unknown) {
removalFailure = cause;
}
const absent =
removalFailure === undefined && (await isAbsent(this.#outputRoot));
this.#cleanup = absent
const complete =
removalFailure === undefined &&
(!this.#createdRoot || (await isAbsent(this.#outputRoot)));
this.#cleanup = complete
? { status: "complete", residualPaths: [] }
: {
status: "incomplete",
residualPaths: [basename(this.#outputRoot)],
};
if (!absent)
if (!complete)
throw new ArtifactReaderFailure(
"integrity",
`Extraction output cleanup could not be verified${removalFailure === undefined ? "" : `: ${removalFailure instanceof Error ? removalFailure.message : String(removalFailure)}`}`,
@@ -415,6 +409,59 @@ export class SafeOutputTree {
return structuredClone(this.#cleanup);
}
async #secureCreatedDirectory(
path: string,
identity: DirectoryIdentity,
): Promise<void> {
if (this.platform === "win32") return;
const stagingHandle = this.#trackHandle(
await open(path, constants.O_RDONLY | constants.O_DIRECTORY).catch(
async (cause: unknown) => {
if (
!(cause instanceof Error) ||
!("code" in cause) ||
cause.code !== "EACCES"
)
throw cause;
// Owner-read can be masked at mkdir; repair only the proven created
// directory under the same portable pathname-race boundary.
await assertPathIdentity(path, identity);
await chmod(path, 0o700);
await assertPathIdentity(path, identity);
return open(path, constants.O_RDONLY | constants.O_DIRECTORY);
},
),
);
let setup:
| { readonly kind: "ready" }
| {
readonly kind: "failed";
readonly cause: unknown;
} = { kind: "ready" };
try {
await assertHandleIdentity(stagingHandle.handle, identity, path);
await stagingHandle.handle.chmod(0o700);
if (((await stagingHandle.handle.stat()).mode & 0o777) !== 0o700)
throw new ArtifactReaderFailure(
"path",
"Could not establish owner access to output directory",
);
} catch (cause: unknown) {
setup = { kind: "failed", cause };
}
try {
await this.#closeHandle(stagingHandle);
} catch (closeCause: unknown) {
if (setup.kind === "failed")
throw ArtifactReaderFailure.withCleanup(
setup.cause,
ArtifactReaderFailure.cleanupObservation(closeCause, path),
);
throw closeCause;
}
if (setup.kind === "failed") throw setup.cause;
}
async #prepareParent(
relativePath: string,
): Promise<readonly DirectoryLineageEntry[]> {
@@ -446,13 +493,14 @@ export class SafeOutputTree {
if (identity === undefined) throw replacedDirectory(current);
await assertPathIdentity(current, identity);
} else {
try {
const identity = await readDirectoryIdentity(current);
this.#nestedDirectories.set(current, identity);
} catch (cause: unknown) {
this.#unknownDirectories.add(current);
throw cause;
}
const identity = await readDirectoryIdentity(current).catch(
(cause: unknown) => {
this.#unknownDirectories.add(current);
throw cause;
},
);
this.#nestedDirectories.set(current, identity);
await this.#secureCreatedDirectory(current, identity);
}
await assertPathIdentity(parent.path, parent.identity);
const identity = this.#nestedDirectories.get(current);
@@ -474,6 +522,7 @@ export class SafeOutputTree {
async #assertOwnedDirectories(): Promise<void> {
const rootIdentity = this.#requireRootIdentity();
await assertPathIdentity(this.#outputRoot, rootIdentity);
await assertPathIdentity(this.selectedRoot, rootIdentity);
for (const [path, identity] of this.#nestedDirectories)
await assertPathIdentity(path, identity);
if (this.#unknownDirectories.size > 0)
@@ -483,6 +532,15 @@ export class SafeOutputTree {
);
}
async #assertExactEntries(): Promise<void> {
await assertExactTreeEntries(
this.#outputRoot,
this.#nestedDirectories,
this.#ownedFiles,
);
await this.#assertOwnedDirectories();
}
#requireRootIdentity(): DirectoryIdentity {
if (this.#rootIdentity === undefined)
throw new ArtifactReaderFailure(
@@ -516,7 +574,8 @@ export class SafeOutputTree {
this.#openHandles.delete(owner);
}
async #closePendingHandles(): Promise<void> {
/** Release descriptors without deleting an inspectable partial output tree. */
async closeHandles(): Promise<void> {
const failures: unknown[] = [];
for (const owner of this.#openHandles) {
try {
+2 -1
View File
@@ -16,6 +16,7 @@ export const removeOwnedTree = async (input: {
readonly rootIdentity: DirectoryIdentity;
readonly directories: Map<string, DirectoryIdentity>;
readonly files: Map<string, FileIdentity>;
readonly preserveRoot?: boolean;
}): Promise<void> => {
const { outputRoot, rootIdentity, directories, files } = input;
const filesByDirectory = new Map<string, [string, FileIdentity][]>();
@@ -50,7 +51,7 @@ export const removeOwnedTree = async (input: {
);
await assertPathIdentity(outputRoot, rootIdentity);
await requireEmpty(outputRoot);
await rmdir(outputRoot);
if (!input.preserveRoot) await rmdir(outputRoot);
};
const removeFiles = async (
+42 -1
View File
@@ -1,5 +1,6 @@
import type { BigIntStats } from "node:fs";
import { lstat } from "node:fs/promises";
import { lstat, readdir } from "node:fs/promises";
import { basename, dirname } from "node:path";
import type { FileHandle } from "node:fs/promises";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
@@ -7,6 +8,46 @@ import { ArtifactReaderFailure } from "./ArtifactReader.js";
export type DirectoryIdentity = Pick<BigIntStats, "dev" | "ino">;
export type FileIdentity = DirectoryIdentity;
/** Reject unowned, missing or replaced entries before an output tree is published. */
export const assertExactTreeEntries = async (
root: string,
directories: ReadonlyMap<string, DirectoryIdentity>,
files: ReadonlyMap<string, FileIdentity>,
): Promise<void> => {
const expected = new Map<string, Set<string>>();
expected.set(root, new Set());
for (const directory of directories.keys())
expected.set(directory, new Set());
for (const path of directories.keys())
expected.get(dirname(path))?.add(basename(path));
for (const path of files.keys())
expected.get(dirname(path))?.add(basename(path));
for (const [directory, names] of expected) {
const entries = await readdir(directory);
const remaining = new Map<string, number>();
for (const name of names) {
const key = name.normalize("NFC");
remaining.set(key, (remaining.get(key) ?? 0) + 1);
}
let unexpected = entries.length !== names.size;
for (const entry of entries) {
const key = entry.normalize("NFC");
const count = remaining.get(key) ?? 0;
if (count === 0) unexpected = true;
else remaining.set(key, count - 1);
}
// HFS+ can return an equivalent decomposed spelling. Counts avoid merging
// distinct Linux names; the original paths must still resolve to owned IDs.
if (unexpected)
throw new ArtifactReaderFailure(
"path",
`Unowned or missing output entries: ${directory}`,
);
}
for (const [path, identity] of files)
await assertFilePathIdentity(path, identity);
};
export const readDirectoryIdentity = async (
path: string,
): Promise<DirectoryIdentity> => {
+115 -13
View File
@@ -1,3 +1,5 @@
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import type { EnvironmentExportResult } from "../application/environment/environmentExport.js";
import { z } from "incur";
import type { Logger } from "pino";
@@ -91,12 +93,14 @@ const serveEnvironmentCommand = async (
input.manifest,
CLI_COMMANDS.environmentServe,
signal,
{ snapshot: true },
);
if (!manifest.ok) return manifest.error;
const evidence = await parseCliJsonInput(
input.evidence,
CLI_COMMANDS.environmentServe,
signal,
{ snapshot: true },
);
if (!evidence.ok) return evidence.error;
const { loadEnvironmentSource } =
@@ -153,28 +157,126 @@ const serveEnvironmentCommand = async (
};
};
/** Register the long-running agent environment server command. */
interface ExportArguments {
readonly manifest: string;
readonly evidence: string;
readonly directory: string;
readonly packageTarball?: string | undefined;
readonly name?: string | undefined;
}
/** The written files with sizes and the pinned package source, as the command prints them. */
const exportDocument = (result: EnvironmentExportResult): JsonValue => ({
status: result.status,
directory: result.directory,
name: result.name,
tool_count: result.tool_count,
evidence_id: result.evidence_id,
manifest_evidence_id: result.manifest_evidence_id,
package_source: result.package_source,
files: result.files.map(({ path, bytes }) => ({ path, bytes })),
});
/** Read each file once, authenticate the pair as the serve command does, then write the bundle from the same bytes. */
const exportEnvironmentCommand = async (
input: ExportArguments,
): Promise<JsonValue> => {
const { readEnvironmentExportInputs, exportEnvironmentBundle } =
await import("../application/environment/environmentExport.js");
const inputs = await readEnvironmentExportInputs(
input.manifest,
input.evidence,
);
if (!inputs.ok) return cliError(inputs.error);
// Loaded here, as for environment-serve, so registering the command catalog never loads it.
const { loadEnvironmentSource } =
await import("../application/environment/environmentSource.js");
const source = loadEnvironmentSource(
inputs.value.manifest.value,
inputs.value.evidence.value,
CLI_COMMANDS.environmentExport,
);
if (!source.ok) return cliError(source.error);
const exported = await exportEnvironmentBundle({
...inputs.value,
source: source.value,
directory: absoluteCallerPath(input.directory),
name: input.name,
packageTarball:
input.packageTarball === undefined
? undefined
: absoluteCallerPath(input.packageTarball),
});
return exported.ok
? exportDocument(exported.value)
: cliError(exported.error);
};
const manifestArgument = z
.string()
.describe(
"Environment manifest Evidence JSON path (inspect-analysis-view environment-manifest output)",
);
const evidenceOption = z
.string()
.describe(
"Parent capture Evidence JSON path (inspect-web-network-capture output) the manifest cites",
);
/** Register the one-shot bundle export command. */
const registerExportCommand = (cli: CliInstance, logger: Logger): void => {
cli.command(CLI_COMMANDS.environmentExport, {
description:
"Export an environment manifest and its capture Evidence as an agent environment bundle folder the agentenv framework runs with agent-env run",
args: z.object({
manifest: manifestArgument,
directory: z
.string()
.describe(
"Bundle directory to create; must be absent or empty, nothing is overwritten",
),
}),
options: z.object({
evidence: evidenceOption,
packageTarball: z
.string()
.optional()
.describe(
"Packed rea-agents tarball (npm pack) copied into the env folder and installed by the Dockerfile instead of the registry package pinned to this version",
),
name: z
.string()
.optional()
.describe(
"Env folder and env id (kebab-case); default the manifest's name",
),
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.environmentExport, () =>
exportEnvironmentCommand({
manifest: args.manifest,
evidence: options.evidence,
directory: args.directory,
packageTarball: options.packageTarball,
name: options.name,
}),
),
});
};
/** Register the long-running agent environment server command and the bundle export. */
export const registerEnvironmentCommands = (
cli: CliInstance,
logger: Logger,
environment: Readonly<NodeJS.ProcessEnv>,
): void => {
registerExportCommand(cli, logger);
cli.command(CLI_COMMANDS.environmentServe, {
description:
"Serve an environment manifest as an agent environment that replays retained capture records over streamable HTTP until SIGINT or SIGTERM",
args: z.object({
manifest: z
.string()
.describe(
"Environment manifest Evidence JSON path (inspect-analysis-view environment-manifest output)",
),
}),
args: z.object({ manifest: manifestArgument }),
options: z.object({
evidence: z
.string()
.describe(
"Parent capture Evidence JSON path (inspect-web-network-capture output) the manifest cites",
),
evidence: evidenceOption,
host: z
.string()
.min(1)
+1
View File
@@ -124,6 +124,7 @@ export const CLI_COMMANDS = Object.freeze({
evidenceImport: "evidence-import",
evidenceExport: "evidence-export",
environmentServe: "environment-serve",
environmentExport: "environment-export",
compare: "compare",
captureProcess: "capture-process",
compareProcessCaptures: "compare-process-captures",
+23 -3
View File
@@ -1,5 +1,6 @@
import { resolve } from "node:path";
import { sealOwnedJsonInput } from "./application/OwnedJsonInput.js";
import { readJsonInputFile } from "./application/JsonInputFile.js";
import { NonRegularFileReadError } from "./filesystem/RegularFile.js";
import {
@@ -16,6 +17,7 @@ export const parseCliJsonInput = async (
value: string,
operation: string,
signal?: AbortSignal,
options?: { readonly snapshot?: boolean },
): Promise<
| { readonly ok: true; readonly value: unknown }
| { readonly ok: false; readonly error: JsonValue }
@@ -28,10 +30,28 @@ export const parseCliJsonInput = async (
},
});
if (signal?.aborted) return cancelled();
const inline = safeParseJson(value);
if (inline.ok) return { ok: true, value: inline.value };
try {
const parsed = await readJsonInputFile(value, operation, signal);
const inline = safeParseJson(value);
if (inline.ok) {
if (!options?.snapshot) return { ok: true, value: inline.value };
const sealed = await sealOwnedJsonInput(inline.value, operation, signal);
return sealed.ok
? { ok: true, value: sealed.value }
: {
ok: false,
error: {
error: "Application workflow failed",
...projectAnalysisError(sealed.error),
},
};
}
const parsed = await readJsonInputFile(
value,
operation,
signal,
undefined,
options?.snapshot ?? false,
);
if (parsed.ok && signal?.aborted) return cancelled();
return parsed.ok
? { ok: true, value: parsed.value }
@@ -118,6 +118,10 @@ const DEFERRED_IMPORTS = [
name: "environment-source",
path: "/dist/application/environment/environmentSource.js",
},
{
name: "environment-export",
path: "/dist/application/environment/environmentExport.js",
},
{
name: "environment-server",
path: "/dist/server/environment/serveEnvironment.js",
@@ -176,6 +180,13 @@ describe("CLI startup imports", () => {
),
),
).resolves.toContain("environment-source");
await expect(
resolvedPackages(
evaluate(
'await import("./dist/application/environment/environmentExport.js");',
),
),
).resolves.toContain("environment-export");
const missingBrowser = join(
await createTestTempDirectory("rea-startup-browser-"),
"missing-browser",
@@ -0,0 +1,284 @@
import {
mkdir,
readdir,
readFile,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
import {
analysisViewCaptureEvidence,
analysisViewCaptureFixture,
} from "../../fixtures/analysisView.js";
import {
environmentServeFixture,
writeEnvironmentServeFiles,
} from "../../fixtures/environmentServe.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"returns a tagged JSON error when a read input descriptor cannot be closed",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-export-input-close-cli-");
const files = await writeEnvironmentServeFiles(root);
const preload = join(root, "fail-close.mjs");
await writeFile(
preload,
`import fs from 'node:fs';\nimport { syncBuiltinESMExports } from 'node:module';\nconst original = fs.promises.open;\nfs.promises.open = async (...args) => {\n const handle = await original(...args);\n if (args[0] === ${JSON.stringify(files.manifestPath)}) handle.close = async () => { throw Object.assign(new Error('controlled persistent close failure'), { code: 'EIO' }); };\n return handle;\n};\nsyncBuiltinESMExports();\n`,
);
const directory = join(root, "bundle");
const result = await cli.run({
arguments: [
"environment-export",
files.manifestPath,
"--evidence",
files.capturePath,
directory,
"--json",
],
environment: {
NODE_OPTIONS: `--import=${preload} --max-old-space-size=768`,
NODE_DISABLE_COMPILE_CACHE: "1",
},
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
details: { operation: "environment-export" },
});
expect(JSON.stringify(result.json)).toContain("cleanup");
await expect(readFile(join(directory, "README.md"))).rejects.toMatchObject({
code: "ENOENT",
});
},
);
const BUNDLE_FILES = [
"README.md",
"artifacts/check_grafana-example/verify.py",
"envs/grafana-example/Dockerfile",
"envs/grafana-example/env.toml",
"envs/grafana-example/evidence.json",
"envs/grafana-example/manifest.json",
"tasks/smoke.json",
];
describe("rea environment-export", () => {
cliTest(
"writes the bundle folder from the fixture pair and prints the written files with sizes",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-environment-export-cli-");
const files = await writeEnvironmentServeFiles(root);
const directory = join(root, "bundle");
const result = await cli.run({
arguments: [
"environment-export",
files.manifestPath,
"--evidence",
files.capturePath,
directory,
"--json",
],
timeoutMs: 30_000,
});
expect(result.stderr).toBe("");
expect(result.exitCode).toBe(0);
expect(result.json).toMatchObject({
status: "exported",
directory,
name: "grafana-example",
tool_count: 19,
package_source: { kind: "registry" },
});
const document = result.json as {
files: { path: string; bytes: number }[];
};
expect(document.files.map((file) => file.path).sort()).toEqual(
BUNDLE_FILES,
);
const written = (
await readdir(directory, { recursive: true, withFileTypes: true })
)
.filter((entry) => entry.isFile())
.map((entry) =>
join(entry.parentPath, entry.name).slice(directory.length + 1),
)
.sort();
expect(written).toEqual(BUNDLE_FILES);
expect(
await readFile(join(directory, "envs/grafana-example/manifest.json")),
).toEqual(await readFile(files.manifestPath));
expect(
await readFile(join(directory, "envs/grafana-example/evidence.json")),
).toEqual(await readFile(files.capturePath));
const dockerfile = await readFile(
join(directory, "envs/grafana-example/Dockerfile"),
"utf8",
);
expect(dockerfile).toMatch(/^FROM node:24\.18\.0-bookworm-slim\n/u);
expect(dockerfile).toContain('CMD ["rea","environment-serve"');
},
);
cliTest(
"refuses a non-empty directory with a typed invalid request and leaves it unchanged",
async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-environment-export-busy-",
);
const files = await writeEnvironmentServeFiles(root);
const directory = join(root, "occupied");
await mkdir(directory);
await writeFile(join(directory, "notes.txt"), "keep");
const result = await cli.run({
arguments: [
"environment-export",
files.manifestPath,
"--evidence",
files.capturePath,
directory,
"--json",
],
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
error: "Application workflow failed",
code: "invalid_request",
category: "invalid_input",
details: {
operation: "environment-export",
issues: [{ path: ["directory"], reason: "invalid_value" }],
},
});
expect(result.stdout).toContain("not empty");
expect(await readdir(directory)).toEqual(["notes.txt"]);
},
);
cliTest(
"refuses a manifest whose parent is not the supplied evidence before creating the directory",
async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-environment-export-mismatch-",
);
const files = await writeEnvironmentServeFiles(
root,
environmentServeFixture(),
);
const other = join(root, "other.json");
await writeFile(
other,
JSON.stringify(
analysisViewCaptureEvidence(analysisViewCaptureFixture()),
),
);
const directory = join(root, "bundle");
const result = await cli.run({
arguments: [
"environment-export",
files.manifestPath,
"--evidence",
other,
directory,
"--json",
],
timeoutMs: 30_000,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
error: "Application workflow failed",
code: "invalid_request",
details: {
operation: "environment-export",
issues: [{ path: ["evidence", "evidence_id"] }],
},
});
expect(result.stdout).toContain("different capture");
expect(await readdir(root)).not.toContain("bundle");
},
);
});
cliTest.skipIf(process.platform === "win32")(
"produces readable private files independently of an owner-read-masking umask",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-export-umask-");
const files = await writeEnvironmentServeFiles(root);
const preload = join(root, "umask.mjs");
await writeFile(preload, "process.umask(0o400);\n");
const directory = join(root, "bundle");
const result = await cli.run({
arguments: [
"environment-export",
files.manifestPath,
"--evidence",
files.capturePath,
directory,
"--json",
],
environment: {
NODE_OPTIONS: `--import=${preload} --max-old-space-size=768`,
NODE_DISABLE_COMPILE_CACHE: "1",
},
timeoutMs: 30_000,
});
expect(result.exitCode, result.stderr).toBe(0);
for (const relative of BUNDLE_FILES) {
expect((await stat(join(directory, relative))).mode & 0o777).toBe(0o600);
expect(
(await readFile(join(directory, relative))).length,
).toBeGreaterThan(0);
}
expect(
await readFile(join(directory, "envs/grafana-example/evidence.json")),
).toEqual(await readFile(files.capturePath));
},
);
cliTest.skipIf(process.platform === "win32")(
"preserves the filesystem meaning of symlink/.. for the destination and tarball",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-export-selected-path-");
const physical = join(root, "physical");
const nested = join(physical, "nested");
await mkdir(nested, { recursive: true });
await symlink(nested, join(root, "link"), "dir");
const files = await writeEnvironmentServeFiles(root);
const bytes = Buffer.from("selected packed package");
await writeFile(join(physical, "package.tgz"), bytes);
const directory = `${root}/link/../bundle`;
const packagePath = `${root}/link/../package.tgz`;
const result = await cli.run({
arguments: [
"environment-export",
files.manifestPath,
"--evidence",
files.capturePath,
directory,
"--package-tarball",
packagePath,
"--json",
],
timeoutMs: 30_000,
});
expect(result.exitCode, result.stderr).toBe(0);
expect(result.json).toMatchObject({
directory,
package_source: { kind: "tarball", path: packagePath },
});
expect(
await readFile(
join(physical, "bundle/envs/grafana-example/rea-agents.tgz"),
),
).toEqual(bytes);
await expect(
readFile(join(root, "bundle/README.md")),
).rejects.toMatchObject({ code: "ENOENT" });
},
);
@@ -792,9 +792,43 @@ it.each([
"src/domain/analysisErrorProjection.ts",
{ code: true, sqlite: true, runtime: true },
],
[
"src/application/environment/environmentExport.ts",
{
code: true,
runtime: true,
agentenv: true,
browser: false,
package: false,
},
],
[
"scripts/verify-agentenv-environment.mjs",
{ code: true, agentenv: true, runtime: false, package: false },
],
[
"src/application/OwnedJsonInput.ts",
{ code: true, agentenv: true, runtime: true, package: false },
],
[
"src/artifacts/SafeOutputTree.ts",
{ code: true, agentenv: true, runtime: true },
],
[
"src/cliJsonInput.ts",
{ code: true, agentenv: true, runtime: true, package: false },
],
[
"tests/boundary/process/agentenvVerifierIdentity.test.ts",
{ code: true, agentenv: false, runtime: false, package: false },
],
[
".github/workflows/real-agentenv.yml",
{ code: false, agentenv: true, workflows: true, package: false },
],
[
"src/domain/javascript/example.ts",
{ code: true, sqlite: false, ghidra_bridge: false },
{ code: true, sqlite: false, ghidra_bridge: false, agentenv: false },
],
])("selects the appropriate lanes for %s", async (path, expected) => {
const { directory, base } = await fixture();
@@ -893,6 +927,7 @@ it.each([
["web_network", "real-web-network-captures"],
["web_runtime", "real-web-runtime"],
["web_source_map", "real-web-source-map"],
["agentenv", "real-agentenv"],
["nativeaot", "nativeaot-fixtures"],
["ghidra_bridge", "ghidra-bridge-sources"],
["website", "website-check"],
@@ -219,6 +219,7 @@ it("binds provider build reuse to the current run while keeping standalone dispa
"web_network",
"web_runtime",
"web_source_map",
"agentenv",
];
const buildTerms = z
.string()
@@ -443,6 +444,7 @@ it.each([
["sqlite", "sqlite-runtime"],
["workflows", "static"],
["ghidra_bridge", "ghidra_bridge"],
["agentenv", "agentenv"],
] as const) {
// GitHub exposes only direct dependencies in the required job's needs context.
const needs: Record<
@@ -28,3 +28,18 @@ it("cancels a pending regular-file JSON read through the owning reader", async (
error: { code: "cancelled", details: { operation: "test-input" } },
});
});
it("cancels cooperative sealing of an owned inline Evidence input", async () => {
const controller = new AbortController();
const input = JSON.stringify(
Array.from({ length: 20000 }, () => ({ value: 1 })),
);
const pending = parseCliJsonInput(input, "test-input", controller.signal, {
snapshot: true,
});
controller.abort();
expect(await pending).toMatchObject({
ok: false,
error: { code: "cancelled" },
});
});
@@ -24,6 +24,21 @@ import { SafeOutputTreeCreationFailure } from "../../../src/artifacts/SafeOutput
import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js";
describe("safe artifact output cleanup", () => {
it("adopts an empty caller directory without taking authority to remove it", async () => {
const parent = await createTestTempDirectory("rea-admitted-output-");
const output = join(parent, "output");
await mkdir(output);
const tree = await SafeOutputTree.create(output, process.platform, {
acceptExistingEmpty: true,
});
await tree.write("file", Readable.from([Buffer.from("owned")]), {
bytes: 5,
sha256: createHash("sha256").update("owned").digest("hex"),
});
await tree.rollback();
expect(await readdir(output)).toEqual([]);
await tree.closeHandles();
});
it.skipIf(process.platform !== "linux")(
"closes the parent descriptor when the output disappears before commit",
async () => {
@@ -0,0 +1,69 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Readable } from "node:stream";
import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
const boundary = vi.hoisted(() => ({ decomposed: false }));
vi.mock("node:fs/promises", async (original) => {
const actual = await original<typeof import("node:fs/promises")>();
return {
...actual,
readdir: async (...args: unknown[]) => {
const entries: unknown = await Reflect.apply(
actual.readdir,
undefined,
args,
);
return boundary.decomposed &&
Array.isArray(entries) &&
entries.every((name): name is string => typeof name === "string")
? entries.map((name) => name.normalize("NFD"))
: entries;
},
};
});
const writeAccent = async (tree: SafeOutputTree) => {
const bytes = Buffer.from("owned");
await tree.write("café.txt", Readable.from([bytes]), {
bytes: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
});
};
it("accepts a decomposed directory listing while verifying the original owned file identity", async () => {
const root = await createTestTempDirectory("rea-output-unicode-");
const tree = await SafeOutputTree.create(join(root, "output"));
await writeAccent(tree);
boundary.decomposed = true;
try {
await tree.commit();
expect(tree.published).toBe(true);
} finally {
boundary.decomposed = false;
await tree.closeHandles();
}
});
it.skipIf(process.platform !== "linux")(
"does not collapse an additional canonically equivalent foreign filename",
async () => {
const root = await createTestTempDirectory("rea-output-unicode-foreign-");
const output = join(root, "output");
const tree = await SafeOutputTree.create(output);
await writeAccent(tree);
const foreign = join(output, "cafe\u0301.txt");
await writeFile(foreign, "foreign", { flag: "wx" });
try {
await expect(tree.commit()).rejects.toThrow("Unowned or missing");
expect(await readFile(foreign, "utf8")).toBe("foreign");
} finally {
await tree.closeHandles();
}
},
);
@@ -0,0 +1,124 @@
import { spawnSync } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it.skipIf(process.platform === "win32")(
"packs through the selected npm entrypoint even when PATH contains another npm",
async () => {
const root = await createTestTempDirectory("rea-agentenv-npm-selection-");
const bin = join(root, "bin");
await mkdir(bin);
const selected = join(root, "selected-npm.mjs");
const selectedMarker = join(root, "selected-called");
const pathMarker = join(root, "path-called");
const runner = join(root, "agent-env.mjs");
const docker = join(root, "docker.mjs");
await writeFile(
selected,
`import { writeFileSync } from 'node:fs';\nwriteFileSync(${JSON.stringify(selectedMarker)}, process.argv[2]);\nprocess.exit(23);\n`,
);
await writeFile(
join(bin, "npm"),
`#!/usr/bin/env node\nrequire('node:fs').writeFileSync(${JSON.stringify(pathMarker)}, 'called');\nprocess.exit(66);\n`,
{ mode: 0o755 },
);
await writeFile(
runner,
"#!/usr/bin/env node\nprocess.stdout.write('agent-env 0.9.1334\\n');\n",
{ mode: 0o755 },
);
await writeFile(docker, "#!/usr/bin/env node\nprocess.exit(0);\n", {
mode: 0o755,
});
const result = spawnSync(
process.execPath,
[
fileURLToPath(
new URL(
"../../../scripts/verify-agentenv-environment.mjs",
import.meta.url,
),
),
],
{
env: {
...process.env,
PATH: `${bin}:${process.env.PATH ?? ""}`,
npm_execpath: selected,
REA_AGENT_ENV_COMMAND: runner,
REA_DOCKER_COMMAND: docker,
},
encoding: "utf8",
timeout: 30_000,
maxBuffer: 1024 * 1024,
},
);
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(await readFile(selectedMarker, "utf8")).toBe("pack");
await expect(readFile(pathMarker)).rejects.toMatchObject({
code: "ENOENT",
});
},
);
// The local framework and these executable shebang fixtures target POSIX hosts.
it.skipIf(process.platform === "win32").each([
["agent-env 0.9.9999", false],
["other-runner 0.9.1334", false],
["agent-env 0.9.1334", true],
] as const)(
"admits the observed executable identity %s before Docker: %s",
async (version, accepted) => {
const root = await createTestTempDirectory("rea-agentenv-identity-");
const runner = join(root, "agent-env.mjs");
const docker = join(root, "docker.mjs");
const marker = join(root, "docker-called");
await writeFile(
runner,
`#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(`${version}\n`)});\n`,
{ mode: 0o755 },
);
await writeFile(
docker,
`#!/usr/bin/env node\nimport { writeFileSync } from 'node:fs';\nwriteFileSync(${JSON.stringify(marker)}, 'called');\nprocess.exit(97);\n`,
{ mode: 0o755 },
);
const result = spawnSync(
process.execPath,
[
fileURLToPath(
new URL(
"../../../scripts/verify-agentenv-environment.mjs",
import.meta.url,
),
),
],
{
env: {
...process.env,
REA_AGENT_ENV_COMMAND: runner,
REA_DOCKER_COMMAND: docker,
},
encoding: "utf8",
timeout: 30_000,
maxBuffer: 1024 * 1024,
},
);
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(result.stderr).toContain(
`"agent_env_version": ${JSON.stringify(version)}`,
);
expect(result.stderr).toContain('"public_cases": 0');
expect(result.stderr).not.toContain('"status": "passed"');
if (accepted) expect(await readFile(marker, "utf8")).toBe("called");
else
await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" });
},
);
@@ -0,0 +1,40 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
bundleDockerfile,
registryPackageSource,
} from "../../../src/application/environment/environmentBundle.js";
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
/**
* The exported Dockerfile installs the CLI that produced the bundle, so the
* pin must be the exact package version of this build, read from the package
* manifest rather than restated anywhere in source.
*/
describe("environment bundle package pin", () => {
it("pins the registry install to the package.json version", async () => {
const manifest = z
.object({ name: z.literal("rea-agents"), version: z.string().min(1) })
.parse(
JSON.parse(
await readFile(
new URL("../../../package.json", import.meta.url),
"utf8",
),
),
);
const dockerfile = bundleDockerfile(registryPackageSource());
const install = dockerfile
.split("\n")
.find((line) => line.startsWith("RUN npm install -g"));
expect(install).toBeDefined();
expect(install?.endsWith(` ${manifest.name}@${manifest.version}`)).toBe(
true,
);
expect(PRODUCT_IDENTITY.packageVersion).toBe(manifest.version);
expect(dockerfile).not.toMatch(/rea-agents@(?:latest|\^|~)/u);
});
});