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