From 7a0c47bfa4259af859779000bf8bc8aaf16b35bd Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 14:29:43 -0700 Subject: [PATCH] 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 Co-authored-by: Claude Opus 5.5 Co-authored-by: Claude Fable 5.1 --- .github/workflows/ci.yml | 12 +- .github/workflows/real-agentenv.yml | 82 ++++ docs/agent-environments.md | 91 ++++- docs/cli.md | 38 ++ .../environment-export/plan.md | 25 ++ .../environment-export/spec.md | 116 ++++++ .../environment-export/tasks.md | 12 + .../environment-export/verification.md | 362 ++++++++++++++++++ docs/testing.md | 68 +++- package.json | 1 + scripts/ci/scopes.mjs | 15 + scripts/verify-agentenv-environment.mjs | 358 +++++++++++++++++ src/application/JsonInputFile.ts | 36 +- src/application/OwnedJsonInput.ts | 54 +++ src/application/RegularFileRead.ts | 3 + .../environment/environmentBundle.test.ts | 274 +++++++++++++ .../environment/environmentBundle.ts | 340 ++++++++++++++++ .../environmentBundleWrite.test.ts | 203 ++++++++++ .../environment/environmentBundleWrite.ts | 194 ++++++++++ .../environment/environmentExport.test.ts | 342 +++++++++++++++++ .../environment/environmentExport.ts | 346 +++++++++++++++++ .../environmentInputCleanup.test.ts | 65 ++++ .../environmentInputSnapshot.test.ts | 69 ++++ .../environment/environmentSource.ts | 54 ++- .../environment/environmentTools.ts | 3 +- src/artifacts/SafeOutputTree.ts | 185 ++++++--- src/artifacts/SafeOutputTreeCleanup.ts | 3 +- src/artifacts/SafeOutputTreeIdentity.ts | 43 ++- src/cli/environmentCommands.ts | 128 ++++++- src/cliCommandNames.ts | 1 + src/cliJsonInput.ts | 26 +- tests/boundary/cli/cliStartupImports.test.ts | 11 + .../boundary/cli/environmentExportCli.test.ts | 284 ++++++++++++++ .../boundary/filesystem/ciChangeScope.test.ts | 37 +- .../filesystem/ciRequiredLanes.test.ts | 2 + .../cliJsonInputCancellation.test.ts | 15 + .../filesystem/safeOutputTree.test.ts | 15 + .../filesystem/safeOutputTreeUnicode.test.ts | 69 ++++ .../process/agentenvVerifierIdentity.test.ts | 124 ++++++ .../environmentBundlePackagePin.test.ts | 40 ++ 40 files changed, 4038 insertions(+), 108 deletions(-) create mode 100644 .github/workflows/real-agentenv.yml create mode 100644 docs/specs/agent-environments/environment-export/plan.md create mode 100644 docs/specs/agent-environments/environment-export/spec.md create mode 100644 docs/specs/agent-environments/environment-export/tasks.md create mode 100644 docs/specs/agent-environments/environment-export/verification.md create mode 100644 scripts/verify-agentenv-environment.mjs create mode 100644 src/application/OwnedJsonInput.ts create mode 100644 src/application/environment/environmentBundle.test.ts create mode 100644 src/application/environment/environmentBundle.ts create mode 100644 src/application/environment/environmentBundleWrite.test.ts create mode 100644 src/application/environment/environmentBundleWrite.ts create mode 100644 src/application/environment/environmentExport.test.ts create mode 100644 src/application/environment/environmentExport.ts create mode 100644 src/application/environment/environmentInputCleanup.test.ts create mode 100644 src/application/environment/environmentInputSnapshot.test.ts create mode 100644 tests/boundary/cli/environmentExportCli.test.ts create mode 100644 tests/boundary/filesystem/safeOutputTreeUnicode.test.ts create mode 100644 tests/boundary/process/agentenvVerifierIdentity.test.ts create mode 100644 tests/conformance/package/environmentBundlePackagePin.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 384e54d303..fffe2de4e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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, diff --git a/.github/workflows/real-agentenv.yml b/.github/workflows/real-agentenv.yml new file mode 100644 index 0000000000..5d9275efd7 --- /dev/null +++ b/.github/workflows/real-agentenv.yml @@ -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 diff --git a/docs/agent-environments.md b/docs/agent-environments.md index 19f162194a..2d816fac23 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -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@ + 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 ` 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 diff --git a/docs/cli.md b/docs/cli.md index e912a2fbf5..f1d30f6bdd 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 --evidence +[--package-tarball ] [--name ]` writes the same manifest and +capture pair as a bundle folder the agentenv framework runs with +`agent-env run `. 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//` 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_/` 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 diff --git a/docs/specs/agent-environments/environment-export/plan.md b/docs/specs/agent-environments/environment-export/plan.md new file mode 100644 index 0000000000..9e927e128d --- /dev/null +++ b/docs/specs/agent-environments/environment-export/plan.md @@ -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. diff --git a/docs/specs/agent-environments/environment-export/spec.md b/docs/specs/agent-environments/environment-export/spec.md new file mode 100644 index 0000000000..6a2d10164d --- /dev/null +++ b/docs/specs/agent-environments/environment-export/spec.md @@ -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 --evidence +[--package-tarball ] [--name ]` writes the manifest and +capture pair that `environment-serve` serves as a bundle folder the agentenv +framework runs with `agent-env run `: 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//Dockerfile`, `envs//env.toml`, + `envs//manifest.json` and `envs//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_/verify.py` and + `tasks/smoke.json`, where `` 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@` 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": , "description": }]`; with no + such tool it checks the count only and says so. +- `tasks/smoke.json` is `deploy_env` of `` on the local sandbox followed + by `env_outcome_verifier` with `file_artifact_id` `check_` 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 --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. diff --git a/docs/specs/agent-environments/environment-export/tasks.md b/docs/specs/agent-environments/environment-export/tasks.md new file mode 100644 index 0000000000..f1a0a4a211 --- /dev/null +++ b/docs/specs/agent-environments/environment-export/tasks.md @@ -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. diff --git a/docs/specs/agent-environments/environment-export/verification.md b/docs/specs/agent-environments/environment-export/verification.md new file mode 100644 index 0000000000..9620ff7b8a --- /dev/null +++ b/docs/specs/agent-environments/environment-export/verification.md @@ -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 `, 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: 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@` +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 /grafana-example --package-tarball --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 /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=/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[-]` 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**. diff --git a/docs/testing.md b/docs/testing.md index 4da56f539d..a38ec04ce4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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-`), +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 diff --git a/package.json b/package.json index 58c354e11e..a4b3dceab7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index d36a181f3f..aeda08504e 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -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", diff --git a/scripts/verify-agentenv-environment.mjs b/scripts/verify-agentenv-environment.mjs new file mode 100644 index 0000000000..4ab0e67f9b --- /dev/null +++ b/scripts/verify-agentenv-environment.mjs @@ -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))]; +} diff --git a/src/application/JsonInputFile.ts b/src/application/JsonInputFile.ts index 25e5fcec02..2e93d0a679 100644 --- a/src/application/JsonInputFile.ts +++ b/src/application/JsonInputFile.ts @@ -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> => 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 | 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); diff --git a/src/application/OwnedJsonInput.ts b/src/application/OwnedJsonInput.ts new file mode 100644 index 0000000000..5157aa27a3 --- /dev/null +++ b/src/application/OwnedJsonInput.ts @@ -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 ( + steps: Generator, + signal?: AbortSignal, +): Promise => { + 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> => { + 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), + ); +}; diff --git a/src/application/RegularFileRead.ts b/src/application/RegularFileRead.ts index 6792a3b272..cdbadedc46 100644 --- a/src/application/RegularFileRead.ts +++ b/src/application/RegularFileRead.ts @@ -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 = ( withRegularFile( path, async (handle, stats) => { + options.admit?.(stats); const bytes = await readBoundedFileBytes( handle, stats.size, diff --git a/src/application/environment/environmentBundle.test.ts b/src/application/environment/environmentBundle.test.ts new file mode 100644 index 0000000000..da80f08ad6 --- /dev/null +++ b/src/application/environment/environmentBundle.test.ts @@ -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, 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, + }, + }); +}); diff --git a/src/application/environment/environmentBundle.ts b/src/application/environment/environmentBundle.ts new file mode 100644 index 0000000000..d5eda7399d --- /dev/null +++ b/src/application/environment/environmentBundle.ts @@ -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 => { + 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, + }, + }); +}; diff --git a/src/application/environment/environmentBundleWrite.test.ts b/src/application/environment/environmentBundleWrite.test.ts new file mode 100644 index 0000000000..4169cb2c44 --- /dev/null +++ b/src/application/environment/environmentBundleWrite.test.ts @@ -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 + ) { + 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 + ) { + 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(); + } + }, +); diff --git a/src/application/environment/environmentBundleWrite.ts b/src/application/environment/environmentBundleWrite.ts new file mode 100644 index 0000000000..06e7296b7d --- /dev/null +++ b/src/application/environment/environmentBundleWrite.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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; + } +}; diff --git a/src/application/environment/environmentExport.test.ts b/src/application/environment/environmentExport.test.ts new file mode 100644 index 0000000000..c7d15a5076 --- /dev/null +++ b/src/application/environment/environmentExport.test.ts @@ -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 => + (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( + "../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), + }, + }); + }); +}); diff --git a/src/application/environment/environmentExport.ts b/src/application/environment/environmentExport.ts new file mode 100644 index 0000000000..0709b0d5c5 --- /dev/null +++ b/src/application/environment/environmentExport.ts @@ -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(); + 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> => { + 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; + 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> => { + 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> => { + 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, + }); +}; diff --git a/src/application/environment/environmentInputCleanup.test.ts b/src/application/environment/environmentInputCleanup.test.ts new file mode 100644 index 0000000000..2e26cc31ce --- /dev/null +++ b/src/application/environment/environmentInputCleanup.test.ts @@ -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); + } + }, +); diff --git a/src/application/environment/environmentInputSnapshot.test.ts b/src/application/environment/environmentInputSnapshot.test.ts new file mode 100644 index 0000000000..cfb3510077 --- /dev/null +++ b/src/application/environment/environmentInputSnapshot.test.ts @@ -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); +}); diff --git a/src/application/environment/environmentSource.ts b/src/application/environment/environmentSource.ts index 1f6260add1..dba47582b7 100644 --- a/src/application/environment/environmentSource.ts +++ b/src/application/environment/environmentSource.ts @@ -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; } 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 => { 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 => { - 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), }); }; diff --git a/src/application/environment/environmentTools.ts b/src/application/environment/environmentTools.ts index efbf083e0c..d21414c278 100644 --- a/src/application/environment/environmentTools.ts +++ b/src/application/environment/environmentTools.ts @@ -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", { diff --git a/src/artifacts/SafeOutputTree.ts b/src/artifacts/SafeOutputTree.ts index 6435100271..072d5ba9a8 100644 --- a/src/artifacts/SafeOutputTree.ts +++ b/src/artifacts/SafeOutputTree.ts @@ -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(); readonly #openHandles = new Set(); #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 { 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 { 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 { + 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 { @@ -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 { 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 { + 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 { + /** Release descriptors without deleting an inspectable partial output tree. */ + async closeHandles(): Promise { const failures: unknown[] = []; for (const owner of this.#openHandles) { try { diff --git a/src/artifacts/SafeOutputTreeCleanup.ts b/src/artifacts/SafeOutputTreeCleanup.ts index 91e6565d19..bf21e4aac1 100644 --- a/src/artifacts/SafeOutputTreeCleanup.ts +++ b/src/artifacts/SafeOutputTreeCleanup.ts @@ -16,6 +16,7 @@ export const removeOwnedTree = async (input: { readonly rootIdentity: DirectoryIdentity; readonly directories: Map; readonly files: Map; + readonly preserveRoot?: boolean; }): Promise => { const { outputRoot, rootIdentity, directories, files } = input; const filesByDirectory = new Map(); @@ -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 ( diff --git a/src/artifacts/SafeOutputTreeIdentity.ts b/src/artifacts/SafeOutputTreeIdentity.ts index 038a95ba22..5c29c2cea4 100644 --- a/src/artifacts/SafeOutputTreeIdentity.ts +++ b/src/artifacts/SafeOutputTreeIdentity.ts @@ -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; export type FileIdentity = DirectoryIdentity; +/** Reject unowned, missing or replaced entries before an output tree is published. */ +export const assertExactTreeEntries = async ( + root: string, + directories: ReadonlyMap, + files: ReadonlyMap, +): Promise => { + const expected = new Map>(); + 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(); + 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 => { diff --git a/src/cli/environmentCommands.ts b/src/cli/environmentCommands.ts index b9f0427c37..5e10269f6c 100644 --- a/src/cli/environmentCommands.ts +++ b/src/cli/environmentCommands.ts @@ -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 => { + 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, ): 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) diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index d46c2acae4..2ac4f27b73 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -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", diff --git a/src/cliJsonInput.ts b/src/cliJsonInput.ts index 7583a1e4fd..1f32d262a5 100644 --- a/src/cliJsonInput.ts +++ b/src/cliJsonInput.ts @@ -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 } diff --git a/tests/boundary/cli/cliStartupImports.test.ts b/tests/boundary/cli/cliStartupImports.test.ts index 50ec459dfe..acfada7517 100644 --- a/tests/boundary/cli/cliStartupImports.test.ts +++ b/tests/boundary/cli/cliStartupImports.test.ts @@ -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", diff --git a/tests/boundary/cli/environmentExportCli.test.ts b/tests/boundary/cli/environmentExportCli.test.ts new file mode 100644 index 0000000000..064a8358dd --- /dev/null +++ b/tests/boundary/cli/environmentExportCli.test.ts @@ -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" }); + }, +); diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index cb8519f705..107eb1449d 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -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"], diff --git a/tests/boundary/filesystem/ciRequiredLanes.test.ts b/tests/boundary/filesystem/ciRequiredLanes.test.ts index 457d532560..81fb54bc54 100644 --- a/tests/boundary/filesystem/ciRequiredLanes.test.ts +++ b/tests/boundary/filesystem/ciRequiredLanes.test.ts @@ -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< diff --git a/tests/boundary/filesystem/cliJsonInputCancellation.test.ts b/tests/boundary/filesystem/cliJsonInputCancellation.test.ts index 6752f7da27..8ecfa23fd7 100644 --- a/tests/boundary/filesystem/cliJsonInputCancellation.test.ts +++ b/tests/boundary/filesystem/cliJsonInputCancellation.test.ts @@ -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" }, + }); +}); diff --git a/tests/boundary/filesystem/safeOutputTree.test.ts b/tests/boundary/filesystem/safeOutputTree.test.ts index 3e82a62ab1..8c5c3a4b5d 100644 --- a/tests/boundary/filesystem/safeOutputTree.test.ts +++ b/tests/boundary/filesystem/safeOutputTree.test.ts @@ -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 () => { diff --git a/tests/boundary/filesystem/safeOutputTreeUnicode.test.ts b/tests/boundary/filesystem/safeOutputTreeUnicode.test.ts new file mode 100644 index 0000000000..f14e152d9b --- /dev/null +++ b/tests/boundary/filesystem/safeOutputTreeUnicode.test.ts @@ -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(); + 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(); + } + }, +); diff --git a/tests/boundary/process/agentenvVerifierIdentity.test.ts b/tests/boundary/process/agentenvVerifierIdentity.test.ts new file mode 100644 index 0000000000..c6cf96541c --- /dev/null +++ b/tests/boundary/process/agentenvVerifierIdentity.test.ts @@ -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" }); + }, +); diff --git a/tests/conformance/package/environmentBundlePackagePin.test.ts b/tests/conformance/package/environmentBundlePackagePin.test.ts new file mode 100644 index 0000000000..35f9a79963 --- /dev/null +++ b/tests/conformance/package/environmentBundlePackagePin.test.ts @@ -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); + }); +});