Skip to content

Make nvx-rs the backend of nvx.py sandbox, egress, and artifact lookup - #597

Merged
Pedro Henrique Penna (ppenna) merged 14 commits into
devfrom
agents/fix-issue-359-delegate
Oct 19, 2026
Merged

Pedro Henrique Penna (ppenna) merged 14 commits into
devfrom
agents/fix-issue-359-delegate

Conversation

@ppenna

@ppenna Pedro Henrique Penna (ppenna) commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Part of #359 (PR 4 of 5).

Summary

nvx.py now runs the nvx-rs CLI of #596 for its whole sandbox command and for the egress-policy and artifact steps of run, and the Python implementations that nvx-rs replaced are gone: the control-protocol client, the egress compiler, the managed lifecycle, and the sandbox launch profile. test-microvm keeps its orchestration in Python but no longer speaks the control protocol: the egress scenarios compile with nvx-rs egress compile, the public sandbox scenarios run nvx.py sandbox, and managed-lifecycle runs a new ignored nvx-rs hypervisor test. CI builds nvx-rs once per OS in the NVX CLI jobs and hands it to the microVM jobs, as it hands over OpenVMM. The PR also ports the open PRs #576 and #579 into the Rust launch profile.

How nvx.py finds nvx-rs (scripts/nvx_tools/nvx_rs.py; documented in the nvx-rs section of doc/usage.md):

  1. the executable that NVX_RS names;
  2. nvx-rs/target/release/nvx-rs[.exe], where cargo build --release puts it, and where PR E's nvx.py download should install a release's bin/nvx-rs[.exe] (nvx_rs.binary_path() is the hook);
  3. else a build with cargo build --release --locked --features cli --manifest-path nvx-rs/Cargo.toml --target-dir nvx-rs/target, from the repository root, with Cargo's output on standard error so that it never mixes with the command's output. Without Cargo, a ScriptError names NVX_RS, the build command, nvx.py download, and rustup.

nvx.py sandbox keeps argparse as its front end, for --help (byte-identical at 80 and 120 columns), abbreviations, and the usage errors of unknown options, invalid choices, and missing values. It drops every converter and check that nvx-rs performs, rebuilds the canonical argv from the options the user gave (in full, --name=value, in command order), and runs nvx-rs --root REPO --prog "nvx.py sandbox" sandbox OPERATION ... with inherited stdio, passing its exit status through. The new global --prog option of nvx-rs makes its usage and errors name nvx.py sandbox. Ctrl-C reaches nvx-rs, which prints the only Interrupted and exits with 130, which nvx.py returns unchanged.

What moved where

Deleted Python Replacement
scripts/nvx_tools/control_session.py nvx::host::session and nvx::host::cli::session (#596), which nvx-rs sandbox and nvx-rs/tests/managed_lifecycle.rs use
scripts/nvx_tools/egress_policy.py nvx::host::network::policy and nvx-rs egress compile, which nvx.py run and the egress scenarios run
scripts/nvx_tools/sandbox_lifecycle.py (all of it) nvx-rs sandbox (nvx::host::cli::sandbox); sandbox_lifecycle_tests.py uses local state-file names and a process scan instead
The launch profile of scripts/nvx_tools/sandbox.py: SandboxLaunch, SandboxLayer, SandboxMount, the workload identity, the reserved targets, the share checks nvx::host::sandbox (#595). sandbox.py keeps only the share helpers with which nvx.py run builds its own OpenVMM command
command_sandbox of scripts/nvx.py and its helpers the argparse front end and scripts/nvx_tools/nvx_rs.py
nvx.py run's artifact lookups and egress compilation nvx-rs --root REPO artifacts and nvx-rs egress compile PATH (JSON); run's OpenVMM command assembly stays in Python
scripts/test_control_session.py, scripts/test_egress_policy.py their Rust ports, nvx-rs/src/host/cli/session/tests.rs and nvx-rs/src/host/network/policy/tests.rs
scripts/nvx_rs_fixtures.py (the fixture generator) the frozen records under nvx-rs/tests/fixtures, which a behavior change now updates by hand (see below)
The tests of the deleted code in test_nvx_tools.py and test_microvm_tests.py removed; the run share tests remain as RunShareTests

Where each managed-lifecycle assertion went

The scenario still boots a guest whose init hands the boot to the managed agent; the ignored test nvx-rs/tests/managed_lifecycle.rs now drives it. CI runs the scenario for Alpine (default suite, every backend), Azure Linux (every backend), and Ubuntu (KVM extended).

Assertion of the former run_managed_lifecycle Direct-mode home Layered-root home (through nvx.py sandbox)
Ping; a workload in /tmp with exactly EMPTY= and an entry with spaces, =, and a snowman Rust test managed-exec-config (--cwd, --environment)
A second authenticated session reaches the same guest Rust test every nvx.py sandbox exec is a new session (sandbox-lifecycle)
An empty environment holds nothing; an exact one holds exactly its entries Rust test managed-exec-config (empty and exact environment files)
Timeouts 0, 3600001, 86400000, and 4294967295 ms accepted Rust test managed-exec-config
State persists across requests, working directory and environment do not, no /run/nvx/workload-machine-id Rust test (the machine-ID absence is direct-only) sandbox-lifecycle warm state
A 100 ms timeout ends sleep 5 with category timeout and status 124, guest still usable Rust test managed-exec-config timeout outcome
Refused working directories: cwd-failed, errors 2, 20, 13, no output, a diagnostic naming the directory, nothing of the workload runs Rust test (the protocol-level category and status) managed-exec-config refused directories
Stop acknowledged, OpenVMM exits 0 Rust test sandbox-lifecycle stop
Workload identity 65534:65534 (new: was only implied by the /root refusal) Rust test managed-exec-config identity probe
Capability through a prefilled pipe (#440); adversarial PID journal Rust test nvx-rs sandbox start
Outcome report: backend, managed/success/0, no network policy, complete teardown; kept as managed-outcome.json Python scenario sandbox-lifecycle stop now checks backend, network policy, and teardown too (new)
Time ABI monitor over the guest console Python scenario, over the console log that the Rust test copies from the TCP console —
The managed lifecycle without a control endpoint is refused before boot Python scenario —

Stack

#596, #595, #594, and #561, on which this PR was stacked, have merged, and it is rebased on dev (4f5e255b4), so it holds only its own commits:

  1. nvx-rs: name the calling program in usage messages (the --prog hook)
  2. scripts: find, build, and run nvx-rs for nvx.py (with review fixes)
  3. nvx.py: run the sandbox command with nvx-rs (with a review fix)
  4. nvx.py: look up run's artifacts and egress rules with nvx-rs
  5. test: compile the microVM egress policies with nvx-rs
  6. nvx-rs: test the direct-mode managed lifecycle on a hypervisor (with review fixes)
  7. test: run the managed lifecycle through nvx-rs (with review fixes)
  8. scripts: delete the Python duplicates of nvx-rs
  9. nvx-rs: reserve /run, /run/nvx, and /run/nvx/time as share targets (fix(sandbox): reserve /run, /run/nvx, and the /run/nvx/time tree as share targets #579)
  10. nvx-rs: accept only the distro layer and the scratch disk (sandbox: accept only the distro layer and scratch #576)
  11. ci: hand the nvx-rs executable to the microVM jobs
  12. doc: describe how nvx.py finds, builds, and runs nvx-rs
  13. ci: name the nvx-rs build for the CLI tests that run it (review)
  14. adversarial: run nvx-rs scenarios with Cargo and NVX_RS: the adversarial executor's environment allowlist passes NVX_RS, CARGO_HOME, and RUSTUP_HOME (not RUSTUP_TOOLCHAIN), managed-lifecycle hands the nvx-rs test an absolute PID journal, and doc/adversarial-testing.md lists what a target needs

Commits 1, 9, and 10 change code that #596 and #595 brought: the CLI's usage plumbing, the launch profile, and the golden records. Each commit body explains why.

Contract notes

  • nvx.py sandbox options, stdout and stderr, exit codes (1, 2, 130, and the workload's or OpenVMM's status), the outcome reports, and the state-directory layout are unchanged; the golden scenarios of nvx-rs/tests/cli.rs replay them.
  • Value errors that nvx-rs reports, such as an invalid --layer or --workload-user, print nvx-rs's usage, which names nvx.py sandbox but wraps at 80 columns whatever the terminal's width. The usage errors of unknown options, invalid choices, and missing values still come from argparse, and so now come before the value errors of other options.
  • Abbreviated options still work; nvx.py passes them to nvx-rs in full.
  • nvx.py run and nvx.py sandbox now need nvx-rs. Until PR E ships it in releases, a downloaded checkout builds it with Cargo on first use; README.md and doc/setup.md say so.
  • NVX_OPENVMM, the OpenVMM override of nvx-rs, now also affects nvx.py run's artifact lookup.
  • The managed-lifecycle scenario needs Cargo, as test-aci-edge-sandboxes does.
  • sandbox: accept only the distro layer and scratch #576: --layer runtime,... or custom,... fails with unsupported layer role 'runtime'; choose distro, and a sandbox whose saved configuration names a retired role fails start with a sandbox requires exactly one read-only distro layer.
  • fix(sandbox): reserve /run, /run/nvx, and the /run/nvx/time tree as share targets #579: /run, /run/nvx, and the /run/nvx/time tree are reserved share targets.
  • A signal that nvx.py ignores, such as SIGINT for a background command of a shell without job control or SIGHUP under nohup, stays ignored, and nvx-rs inherits it ignored (review).
  • nvx.py run passes the egress policy's path to nvx-rs egress compile after --, so a path that starts with - is still a path, as it was for the Python compiler (review).
  • An interrupted managed-lifecycle scenario ends and reaps Cargo, the nvx-rs test, and its OpenVMM before it removes its temporary directory, as it ended the OpenVMM that it started before (review).
  • The managed-lifecycle test now fails if a second request loses the workload's state, sees the first request's environment, or finds a workload machine ID: the Python scenario chained those checks with ;, so none could fail, and the port chains them with && (review).
  • sandbox-lifecycle requires all seven teardown steps in a stopped sandbox's report, not only that the steps it names completed (review).
  • The managed-lifecycle test fails if it cannot copy the whole guest console, which the scenario scans for time ABI violations, rather than leave an incomplete log; a reset still ends the console (review).
  • Golden records: sandbox.json's affected cases were re-evaluated with the retired Python launch profile from the commit before this PR retires it, which holds the merged sandbox: accept only the distro layer and scratch #576, patched with fix(sandbox): reserve /run, /run/nvx, and the /run/nvx/time tree as share targets #579's change to sandbox.py (no other record changed; after the rebase, regenerating them that way reproduces sandbox.json byte for byte); cli.json changes three scenarios by hand and adds validation: retired layer role, on both platforms.

#576 and #579

Both were still open when this PR was opened, so this PR implemented their behavior, as the maintainer asked: in the Rust launch profile with their messages and check order, in guest/common/nvx-init-agent verbatim, and, for #576, in sandbox-blocks.sh, its scenario and unit test, and the docs. #576 has since merged into dev with exactly those guest, scenario, test, and doc changes, so after the rebase commit 10 holds only what #576 left out: the Rust launch profile, which on dev still accepts the runtime and custom roles that nvx.py sandbox would otherwise accept again once it runs nvx-rs, with its golden records and tests. #579 is still open; commit 9 implements it as before, and documents its reserved targets in doc/usage.md and doc/run.md. I commented on both PRs: #576 (comment) and #579 (comment).

Validation

Environment Evidence
Windows 11 (local), after the rebase onto dev 4f5e255b4 and the review fixes nvx.py verify; the commands of check-nvx-rs (the bundled-artifacts test included; its macOS and guest-binary steps run on Linux only) and of check-aci-edge-sandboxes, with test-agent-bundling; Ruff, Ruff format, Pyright (Linux and Windows), compileall, the Markdown link check; the validate-nvx unittest list with NVX_RS naming the static-CRT release build (984 tests, the three real-binary tests included), test_adversarial.py, test_hosts.py, and the --help checks; and sandbox.json, regenerated from dev's Python launch profile with #579's change: identical
Ubuntu on WSL (local), after the review fixes NvxRsTests and SandboxDelegationTests, with every POSIX signal test, and the managed-lifecycle unit tests; the two new signal tests fail without their fix; a SIGINT sent only to the Python process of run_managed_lifecycle, with a fake Cargo whose child sleeps, leaves neither running and then removes the temporary directory
CI on earlier heads of this rebase 6f31aa4a4, before the review fixes, run 39286746353, and 9273885a1, with the first three, run 39291857952: every job passed, the microVM suites and OpenVMM VMM tests on KVM, MSHV, and WHP and the performance gate included. The rows below ran before the rebase, which changed none of this PR's own changes; the review fixes change only nvx_rs.py's signal hand-over and egress arguments, the cleanup of the managed-lifecycle scenario, the checks and console copy of the managed-lifecycle test, the teardown check of sandbox-lifecycle, and a .gitattributes comment
Windows 11 (local) nvx.py verify; nvx-rs: fmt, clippy (all features, and lib without defaults), tests (all features and default), rustdoc with -D warnings, 1.89 check, the bundled-artifacts test, aarch64-apple-darwin check; aci_edge_sandboxes: the commands of check-aci-edge-sandboxes and test-agent-bundling; Ruff, Ruff format, Pyright (Linux and Windows), the Markdown link check, compileall, the validate-nvx unittest list, test_adversarial.py, test_hosts.py
Ubuntu on WSL (local) the same Rust and Python gates, check-guest-binaries, and the validate-nvx list on Python 3.14, 3.12, and 3.10. test_adversarial.py fails one subtest on 3.10 (test_contained_native_process_preserves_child_signal_status, sigkill), as it does on origin/dev; it passes on 3.12, which CI uses
Workflow and shell lint (WSL) actionlint 1.7.7, shellcheck 0.10.0, shfmt 3.10.0: no new findings (only pre-existing SC2015 and SC2012 notes and the nvx-adversarial-controller label)
prometheus32, Linux/KVM, bare metal test-microvm scenarios sandbox-lifecycle, managed-exec-config, managed-lifecycle, l3-l4-egress-policy, l3-l4-egress-port-ranges, l3-l4-egress-protocols, ipv6-egress-policy, directional-network-policy, sandbox-blocks, filesystem-comma-paths: pass; managed-lifecycle for Azure Linux and Ubuntu: pass; test-aci-edge-sandboxes: 18 passed
prometheus30, Linux/MSHV, bare metal the same: all pass
prometheus28, Windows/WHP, bare metal the same: all pass
prometheus30, adversarial executor the local executor protocol, driven without Copilot through workload-isolation's managed-control-lifecycle and l3-l4-network-boundary: initialize (verify, provenance, baseline boot), both cases, and finalize (post-campaign boot) pass, once with NVX_RS naming an nvx-rs outside the checkout and once building nvx-rs with Cargo under the executor's environment; every PID journal and outcome inventory complete
Left to CI the full microVM suites and their workflow steps, the guest artifact rebuild, the OpenVMM tests, and the benchmarks

Hardware artifacts: OpenVMM at the pinned 5ab8a84 and the kernel from #594's validation, the Ubuntu and Azure Linux images and distro layer from #561's, and an Alpine initramfs rebuilt natively from this branch, so the hosts ran the new guest agent; nvx-rs was the static musl build that CI hands to Linux runners, and on Windows the static-CRT build that CI makes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The cross-platform runtime and guest-build migration needs human review and complete CI validation, including the currently skipped real-binary integration checks.

1 open issue
What changed in this PR

Moves NVX runtime responsibilities into nvx-rs as part of #359, reducing duplicated Python logic while preserving the command-line contract.

Changes:

  • Adds Rust CLI support, compatibility helpers, and cross-platform process handling.
  • Integrates Rust guest binaries into builds, provenance, and packaging.
  • Updates sandbox restrictions, CI executable handoff, tests, and documentation.
File Description
THIRD_PARTY_NOTICES.md Documents Rust guest dependencies and licenses.
SOURCE-MANIFEST.json Records Rust toolchain and guest sources.
scripts/​test_tool_versions.py Checks musl target installation.
scripts/​test_guest_binaries.py Tests source identity and staged-build validation.
scripts/​setup/​setup-linux-runner.sh Explicitly selects the target toolchain.
scripts/​setup/​setup-linux-mshv.sh Installs and verifies the musl target.
scripts/​nvx_tools/​ubuntu.py Includes Rust sources in provenance.
scripts/​nvx_tools/​sandbox_lifecycle_tests.py Replaces lifecycle imports and strengthens report checks.
scripts/​nvx_tools/​release.py Validates Rust source metadata.
scripts/​nvx_tools/​microvm_test_scripts/​sandbox-blocks.sh Checks distro and scratch devices only.
scripts/​nvx_tools/​guest_binaries.py Adds source-root support and staged-build inspection.
scripts/​nvx_tools/​ci.py Tracks Rust guest inputs for CI selection.
scripts/​nvx_tools/​build.py Installs Rust guest binaries.
scripts/​nvx_tools/​build_constants.py Defines Rust build and packaging inputs.
scripts/​nvx_tools/​benchmark.py Records Rust guest provenance.
scripts/​nvx_tools/​azurelinux.py Includes Rust build inputs in provenance.
scripts/​nvx_tools/​archive.py Excludes generated Rust build outputs.
README.md Explains Rust binaries and first-use builds.
nvx-rs/​src/​protocol/​tests.rs Pins wire values and layered feature requirements.
nvx-rs/​src/​protocol/​mod.rs Documents shared Rust protocol definitions.
nvx-rs/​src/​protocol/​features.rs Defines layered sandbox feature requirements.
nvx-rs/​src/​host/​sys/​mod.rs Exposes Unix interrupt handling.
nvx-rs/​src/​host/​sys/​interrupt.rs Records Unix interrupts for CLI polling.
nvx-rs/​src/​host/​session.rs Exposes request and response primitives.
nvx-rs/​src/​host/​sandbox/​layer.rs Implements distro-only layer parsing.
nvx-rs/​src/​host/​python/​uuid.rs Preserves Python UUID parsing behavior.
nvx-rs/​src/​host/​python/​shell.rs Implements Python-compatible command quoting.
nvx-rs/​src/​host/​python/​mod.rs Organizes Python compatibility helpers.
nvx-rs/​src/​host/​python/​dumps.rs Implements Python-compatible JSON output.
nvx-rs/​src/​host/​process.rs Adds CLI process waiting and termination.
nvx-rs/​src/​host/​platform/​windows.rs Adds Windows process, interrupt, and lock helpers.
nvx-rs/​src/​host/​platform/​other.rs Supplies unsupported-platform interrupt behavior.
nvx-rs/​src/​host/​platform/​linux.rs Exposes Linux interrupt helpers.
nvx-rs/​src/​host/​mod.rs Exposes CLI and sandbox modules.
nvx-rs/​src/​host/​cli/​sandbox/​identity.rs Checks recorded OpenVMM process identities.
nvx-rs/​src/​host/​cli/​output.rs Preserves CLI stream formatting.
nvx-rs/​src/​host/​cli/​egress.rs Adds egress compilation commands.
nvx-rs/​src/​host/​cli/​deadline.rs Adds interruptible deadline handling.
nvx-rs/​src/​host/​cli/​artifacts.rs Locates artifacts and emits JSON paths.
nvx-rs/​src/​guest/​time/​mod.rs Updates historical implementation comments.
nvx-rs/​src/​guest/​time_probe/​mod.rs Updates historical implementation comments.
nvx-rs/​src/​guest/​reseed.rs Clarifies existing behavior in comments.
nvx-rs/​src/​bin/​nvx-rs.rs Adds the CLI entry point.
nvx-rs/​Cargo.toml Enables CLI features and dependencies.
guest/​common/​nvx-reseed.c Removes the replaced C implementation.
guest/​common/​nvx-mmio-write.c Removes the replaced C implementation.
guest/​common/​nvx-init-agent Restricts layers and reserves runtime share targets.
guest/​common/​nvx-exit.c Removes the replaced C implementation.
guest/​common/​nvx-console-pending.c Removes the replaced C implementation.
docker/​Dockerfile Builds and installs shared Rust guest binaries.
doc/​usage.md Documents delegation and sandbox restrictions.
doc/​setup.md Documents Rust build prerequisites.
doc/​run.md Updates sandbox layers and reserved targets.
doc/​project-structure.md Describes the Rust-backed repository structure.
doc/​guests/​ubuntu-guest.md Documents Rust guest installation and provenance.
doc/​guests/​azurelinux-guest.md Documents Rust guest installation and provenance.
doc/​contribute.md Explains runtime ownership and test placement.
doc/​ci.md Documents guest caches and executable handoff.
doc/​build.md Documents Rust builds and packaging.
doc/​benchmarks.md Updates guest-binary provenance documentation.
aci_edge_sandboxes/​src/​openvmm/​mod.rs Documents shared lowering and tests its errors.
aci_edge_sandboxes/​README.md Explains egress lowering compatibility changes.
.github/​workflows/​run-nvx-microvm-tests.yml Downloads the platform’s Rust CLI executable.
.github/​workflows/​copilot-setup-steps.yml Tracks all Rust source changes.
.github/​workflows/​ci.yml Builds and distributes platform CLI executables.
.github/​copilot-instructions.md Documents Rust prerequisites and rebuild guidance.
.github/​actions/​validate-nvx/​action.yml Removes retired Python test suites.
.github/​actions/​build-guest-artifacts/​action.yml Keys guest caches on Rust source identity.
.gitattributes Marks frozen compatibility fixtures as generated.

Review effort: Balanced

Comment thread .github/workflows/ci.yml
Pedro Henrique Penna (ppenna) added a commit that referenced this pull request Oct 17, 2026
Three CLI tests run the real nvx-rs only when NVX_RS names it: the
egress compilation of nvx.py run, the JSON that nvx.py reads from
nvx-rs, and the options that nvx.py sandbox passes. The NVX CLI jobs
built nvx-rs where nvx.py finds it, but set no NVX_RS, so those tests
skipped and the executables that the jobs hand to the microVM jobs left
unexercised by them (review of #597).

Each Build nvx-rs step now exports NVX_RS for the rest of its job,
naming the executable that it built: with `>> "${GITHUB_ENV}"` on Linux,
and on Windows with `Out-File -Encoding utf8 -Append`, as setup-curl
writes GITHUB_ENV under Windows PowerShell. The workflow test checks
both, and doc/ci.md says so.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 17, 2026 08:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The cross-platform migration requires human review and remaining CI confirmation, alongside resolution of the retained functional issues.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 17, 2026 09:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The cross-language lifecycle and guest-build migration warrants human review, with unresolved correctness issues remaining.

No open issues 🎉

Review effort: Balanced

Pedro Henrique Penna (ppenna) added a commit that referenced this pull request Oct 17, 2026
The executor validates and hashes the nvx-rs that NVX_RS names before
the baseline boot, and nvx.py resolves a relative NVX_RS against its
working directory: the executor's, at that point. The scenarios inherit
NVX_RS unchanged but run in the repository root, so with a relative
NVX_RS they ran another executable than the one validated, or found
none.

sanitized_environment() now makes NVX_RS absolute against the
executor's working directory, so that the scenarios run the executable
that the initialization checked. An empty NVX_RS, which nvx.py
ignores, passes unchanged.

A new test validates a relative NVX_RS in one working directory and
resolves it in a scenario process in another; it fails without the
fix. The environment test now expects the absolute path.

Copilot's review of #598 flagged this. #597 added NVX_RS to the
scenarios' environment, and the earlier commit of this series that
validates the executor's nvx-rs made the mismatch matter.

Closes #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 19, 2026 16:35
Pedro Henrique Penna (ppenna) added a commit that referenced this pull request Oct 19, 2026
Three CLI tests run the real nvx-rs only when NVX_RS names it: the
egress compilation of nvx.py run, the JSON that nvx.py reads from
nvx-rs, and the options that nvx.py sandbox passes. The NVX CLI jobs
built nvx-rs where nvx.py finds it, but set no NVX_RS, so those tests
skipped and the executables that the jobs hand to the microVM jobs left
unexercised by them (review of #597).

Each Build nvx-rs step now exports NVX_RS for the rest of its job,
naming the executable that it built: with `>> "${GITHUB_ENV}"` on Linux,
and on Windows with `Out-File -Encoding utf8 -Append`, as setup-curl
writes GITHUB_ENV under Windows PowerShell. The workflow test checks
both, and doc/ci.md says so.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Signal inheritance, cancellation cleanup, and dash-prefixed policy filenames have unresolved regressions.

3 open issues

Review effort: Balanced

Comment thread scripts/nvx_tools/microvm_tests.py Outdated
Comment thread scripts/nvx_tools/nvx_rs.py
Comment thread scripts/nvx_tools/nvx_rs.py Outdated
Copilot AI balanced review requested due to automatic review settings October 19, 2026 17:06
Pedro Henrique Penna (ppenna) added a commit that referenced this pull request Oct 19, 2026
Three CLI tests run the real nvx-rs only when NVX_RS names it: the
egress compilation of nvx.py run, the JSON that nvx.py reads from
nvx-rs, and the options that nvx.py sandbox passes. The NVX CLI jobs
built nvx-rs where nvx.py finds it, but set no NVX_RS, so those tests
skipped and the executables that the jobs hand to the microVM jobs left
unexercised by them (review of #597).

Each Build nvx-rs step now exports NVX_RS for the rest of its job,
naming the executable that it built: with `>> "${GITHUB_ENV}"` on Linux,
and on Windows with `Out-File -Encoding utf8 -Append`, as setup-curl
writes GITHUB_ENV under Windows PowerShell. The workflow test checks
both, and doc/ci.md says so.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx.py sandbox will run nvx-rs sandbox with the options that its own
argparse front end read, and its type conversions, such as --layer,
--workload-user, and the integer options, move to nvx-rs. A value that
nvx-rs then refuses would read "usage: nvx-rs sandbox ..." and
"nvx-rs sandbox: error: ...", where nvx.py printed the usage and name
of its own parser.

A new global option, --prog PROG, names the program in usage, help,
and error messages, nvx-rs by default; nvx.py will pass its own name.
Each parser now describes its syntax as a Syntax value, its command
after the program's name, the usage parts of its options and
positionals, and the help after its usage, instead of static usage
and help text, and Exit::Usage and Exit::Help carry the text that the
syntax formats for the program. format_usage ports argparse's
HelpFormatter._format_usage for 80 columns: a usage that fits stays on
one line; otherwise a name of at most three quarters of the width is
followed by the options, and the positionals start a new line, while a
longer name takes a line of its own. For the default name, every usage
and help is byte for byte what the static text printed, which the
golden scenarios check, and for nvx.py, whose name has the length of
nvx-rs, the usage wraps as Python's does.

The top-level usage lists the new option, so the eight golden steps
whose unrecognized arguments the top-level parser reports, and the
generator's copy of that usage, change accordingly. The top-level help
describes --prog and wraps the help of --root as argparse wraps it; it
had one line of 79 columns.

Unit tests compare the wrapping of short and long names, and of
usages without options, with CPython 3.14's argparse, and a CLI test
checks the usage error of nvx.py sandbox for a root --workload-user,
recorded from CPython, a long name, the top-level parser, and help.
The README documents the option.

This changes the CLI of #596; it adds the option and keeps every
default output.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx.py will delegate its sandbox command, and the egress policy and
artifact steps of its run command, to the nvx-rs CLI (#359). The new
module scripts/nvx_tools/nvx_rs.py finds that executable, builds it
when it is missing, and runs it, so that the commands that follow only
choose what to delegate.

Discovery runs, in order:

- the executable that the NVX_RS environment variable names, which
  must exist;
- nvx-rs/target/release/nvx-rs[.exe] in the checkout, binary_path(),
  where a release build puts it; and
- that executable after `cargo build --release --locked --features
  cli --manifest-path nvx-rs/Cargo.toml --target-dir nvx-rs/target`,
  which runs from the repository root, so that rust-toolchain.toml
  selects the pinned Rust release, with its target directory pinned
  against CARGO_TARGET_DIR. The command line goes to standard error,
  and so does Cargo's standard output, so that a build never mixes
  with the output of the command that needs the executable.

Without Cargo, nvx.py fails with a ScriptError that names NVX_RS, the
build command, rustup, and `nvx.py download`, which PR E makes install
a release's bin/nvx-rs[.exe] at binary_path(), as it installs OpenVMM
where the OpenVMM build puts it.

run() runs `nvx-rs --prog PROGRAM --root ROOT ...` on nvx.py's own
standard streams, with nvx.py's repository root, and returns the
status that nvx.py then exits with: nvx-rs's own, 128 + N for a signal
N that ends nvx-rs, as a shell reports it, and a Windows status above
2^31 as the signed value that SystemExit passes on with every Python.

Ctrl-C belongs to nvx-rs, which prints Interrupted and exits with 130.
While nvx-rs runs, nvx.py replaces its SIGINT handler, so that it
neither raises KeyboardInterrupt, prints Interrupted again, nor kills
nvx-rs before it finishes, as subprocess.run would after 0.25 s. On
POSIX hosts, it hands nvx-rs the SIGINT, SIGTERM, and SIGHUP that
reach nvx.py alone, which a terminal's Ctrl-C to the whole group
makes a harmless repeat, including a signal that arrives before
nvx-rs has started; if SIGINT ends nvx-rs before it can report it,
nvx.py prints Interrupted for it. On Windows, Ctrl-C reaches every
process of the console, so nvx.py only ignores it. The handlers are
installed before nvx-rs starts and restored when it ends. A signal
that nvx.py ignores keeps no handler, so that nvx-rs inherits it
ignored, rather than at the default that exec leaves in place of a
handler: a shell without job control starts a background command
with SIGINT ignored, and nohup starts one with SIGHUP ignored.

query() runs nvx-rs for a JSON result, with standard input closed and
its output captured, and raises a ScriptError with the message that
nvx-rs printed after "error: ", so that nvx.py prints what it printed
for the Python code that nvx-rs replaces. compile_egress_policy() and
artifacts() read `nvx-rs egress compile` and `nvx-rs artifacts` into
typed results and refuse JSON of another shape. The policy's path
follows `--`, so that a path that starts with `-` stays a path.

NvxRsTests covers discovery and its order, the build command, its
working directory, and its output, the missing-Cargo and failed-build
errors, the command line, the exit statuses, and the JSON readers.
Process tests run nvx_rs.run in a fresh Python with a stand-in for
nvx-rs: standard streams and statuses pass through, and on POSIX
hosts, SIGINT to the process group or to nvx.py alone ends the
command with one Interrupted and status 130, and signals that end
nvx-rs give a shell's statuses, and nvx-rs starts with the SIGINT and
SIGHUP that nvx.py ignores still ignored. Mocks cover an interrupted
wait, the restoration of the handlers, an ignored SIGINT that stays
ignored, and the `--` before a policy's path. With NVX_RS set, a test
checks the JSON of the real nvx-rs against what nvx.py reads, also
for a relative policy path that starts with `-`.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx.py sandbox now runs `nvx-rs --prog nvx.py --root REPO sandbox`
for every operation, with nvx.py's standard streams, and exits with
the status of nvx-rs, which ports command_sandbox, the lifecycle of
sandbox_lifecycle.py, and the control session of control_session.py
(#596). Python no longer checks, converts, or runs anything for the
command; the modules that nvx-rs replaced stay for the microVM tests
until a later commit moves those onto nvx-rs.

argparse stays the front end, so that the help, abbreviations, and
the usage errors that it reports (exit 2) work as before, and the help
is unchanged byte for byte at 80 and 120 columns. Every option keeps
its name, metavar, choices, and help, but none has a type converter or
a default: --layer, --workload-user, the integer options, --timeout,
and the paths are plain strings that nvx-rs converts and checks, and
nvx-rs applies the defaults, so Python duplicates neither. A
_SandboxOption action records each option as nvx-rs receives it:

- under its canonical name, so that `--dry` reaches nvx-rs as
  --dry-run, since nvx-rs does not expand abbreviations;
- in command-line order, so that each --mount-deny, --mount-allow,
  and --mount-write follows the --mount whose path it names, which
  nvx-rs attributes as nvx.py did; and
- with its value attached, --option=VALUE, so that a value that starts
  with "-" stays a value.

The operation, run by default, comes first. Only options that the
user gave are passed, so that the defaults live in nvx-rs alone.
--prog makes the usage errors that nvx-rs reports for the values that
it converts read "usage: nvx.py sandbox ..." and "nvx.py sandbox:
error: ...", as argparse printed them; the name has the length of
nvx-rs, so the usage wraps the same.

Usage errors keep their status and form, but which of two errors in
one command line is reported changes: argparse reports an invalid
choice or an unrecognized argument before nvx-rs sees a value that it
refuses, where it used to report whichever came first. Ctrl-C ends
the command with nvx-rs's Interrupted and status 130, once.

scripts/nvx_rs_fixtures.py recorded the golden fixtures of nvx-rs
from the Python implementation of nvx.py sandbox, the egress compiler,
and the Python semantics that the ports keep, and imported the
helpers that this commit removes from nvx.py. With nvx.py running
nvx-rs, the recordings are final, so the generator goes, and the
fixtures stay as frozen references that a deliberate change updates
by hand. Their metadata, .gitattributes, the README, the module
comments that named the generator, and doc/project-structure.md say
so.

The CLI tests of the Python implementation go, since the golden
scenarios of nvx-rs cover each of their cases, as do the systemd,
environment, network-option, and policy checks, and the tests of
`run` that also ran sandbox keep their `run` half. New tests check the
recorded arguments for abbreviations, attached values, order, and the
default operation, the call of nvx-rs and its status through main(),
the usage errors that argparse keeps, a missing nvx-rs, and, through a
fresh nvx.py with a stand-in for nvx-rs, shared standard streams and,
on POSIX hosts, one Interrupted and status 130 after Ctrl-C to the
process group. With NVX_RS set, the real nvx-rs runs a dry run of
options that need an attached value, an abbreviation, and a share
policy, a usage error with nvx.py's name, and a lifecycle error.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx.py run keeps assembling OpenVMM's command line, but the two steps
of it that Python duplicated now go through nvx-rs (#359):

- --network-egress-policy-file compiles with `nvx-rs egress compile`,
  whose JSON lists the native rules of each action in the order in
  which OpenVMM receives them, in place of compile_policy_file of
  egress_policy.py. The option checks before it stay in nvx.py, as
  they belong to run's own options, and a refused policy fails with
  the compiler's message, as before.
- The OpenVMM executable, the kernel, and the initramfs of the guest
  come from `nvx-rs --root REPO artifacts`, in place of
  openvmm_binary_path and artifact_path of common.py. nvx-rs prints
  absolute, unchecked paths of the same files; nvx.py still checks
  each with require_file, in the same order and with the same
  messages, and names the guest's distribution as nvx-rs prints it.

The lookup runs after the egress policy compiles and before the first
check, so a run that fails its checks never needs nvx-rs, and the
command lines, outputs, and messages of `run` are unchanged. One
visible difference: NVX_OPENVMM, which names another OpenVMM for
nvx-rs, now also selects the OpenVMM of nvx.py run, as it does for
nvx.py sandbox.

The microVM scenarios and the build, release, and benchmark code keep
using artifact_path and openvmm_binary_path: they build, package, and
measure the checkout's artifacts.

Tests of run that pass the option checks look up the checkout's
artifacts through a mixin that answers nvx_rs.artifacts without
nvx-rs. The egress test checks that run passes the compiler's rules,
and with NVX_RS set, that the real nvx-rs lowers the policy as the
Python compiler did and refuses a scoped IPv6 network with its
message before any artifact lookup. New tests check that a refused
policy fails before the lookup, and that run takes the paths that
nvx-rs prints, checks them in order with nvx.py's messages, and
reports a missing executable as before.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two microVM scenarios still used Python code that nvx-rs replaced
(#359), which a later commit deletes:

- The L3/L4 policy, port-range, and IPv6 egress scenarios compiled
  their policy files with compile_policy_file of egress_policy.py to
  record and check the native rules that `nvx.py run` passes to
  OpenVMM. They now compile them with `nvx-rs egress compile`, through
  compile_egress_policy of scripts/nvx_tools/nvx_rs.py, which nvx.py
  run itself uses, so the scenarios check the rules of the compiler
  that runs.
- The public sandbox lifecycle took the names of the state files and
  runtime_process_running from sandbox_lifecycle.py. The names are now
  its own constants, as nvx.py sandbox documents them, and
  openvmm_running replaces the process check with the scan that the
  scenario already makes: an OpenVMM process with the recorded ID
  whose arguments name the scenario's fixture, which no other process
  names. kill_openvmm takes that name too.

The tests of the scenarios compile their cases with nvx-rs, through
discovery, as the scenarios do: the check that each port range lowers
to one native rule, the IPv6 case table, and the bounded L3/L4 policy.
The port-range test, which replaces subprocess.run for the public dry
runs, lets nvx-rs's own runs through, and counts only the dry runs.
The L3/L4 policy tests, which replace threading.Thread, which
subprocess uses on Windows, take their policy's rules from a stub, and
a reservation that fails before any policy compiles. The process
check of the lifecycle needs no patch of its own, since it follows the
scan that the tests already replace, and the kill test ends a real
process that a replaced scan reports.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pedro Henrique Penna (ppenna) added a commit that referenced this pull request Oct 19, 2026
Three CLI tests run the real nvx-rs only when NVX_RS names it: the
egress compilation of nvx.py run, the JSON that nvx.py reads from
nvx-rs, and the options that nvx.py sandbox passes. The NVX CLI jobs
built nvx-rs where nvx.py finds it, but set no NVX_RS, so those tests
skipped and the executables that the jobs hand to the microVM jobs left
unexercised by them (review of #597).

Each Build nvx-rs step now exports NVX_RS for the rest of its job,
naming the executable that it built: with `>> "${GITHUB_ENV}"` on Linux,
and on Windows with `Out-File -Encoding utf8 -Append`, as setup-curl
writes GITHUB_ENV under Windows PowerShell. The workflow test checks
both, and doc/ci.md says so.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The cross-platform runtime migration, signal handling, and process cleanup warrant final human review.

No open issues 🎉

2 resolved since last review

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 19, 2026 17:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Cross-platform process handling needs final human review, and console-capture error handling remains unresolved.

1 open issue

Review effort: Balanced

Comment thread nvx-rs/tests/managed_lifecycle.rs Outdated
The managed-lifecycle scenario of `nvx.py test-microvm` boots a guest
whose init hands the boot to the managed agent, and drove its control
session with control_session.py, the Python client of the protocol
that #359 removes. Its checks of the protocol move to an ignored
integration test, tests/managed_lifecycle.rs, which drives the session
with nvx::host::session, as nvx::host::instance does for nvx-rs
sandbox:

- A first session pings the agent and runs a workload in /tmp with
  exactly EMPTY= and an entry with spaces, an equals sign, and a
  snowman, and the workload prints them back.
- A second session reaches the same guest: an explicitly empty
  environment holds no variable, and an exact one holds exactly its
  entries.
- The timeouts 0, 3600001, 86400000, and 4294967295 ms are accepted.
- Workloads run as the configured identity, 65534:65534, which the
  scenario checked only through the refused /root before.
- State persists across requests, while working directories and
  environments do not, and a direct-mode guest has no workload machine
  ID. The workload chains these checks with &&, so that a failed one
  withholds its marker; the Python scenario chained them with `;`,
  which let only the status of the last command count.
- A workload that outlives its 100 ms timeout ends with the timeout
  category and status 124, and the guest stays usable.
- The guest refuses /does-not-exist, /etc/passwd, and /root as working
  directories with the cwd-failed category, the errors 2, 20, and 13,
  no output, and a diagnostic that names the directory, and nothing of
  the refused workload runs.
- Stop is acknowledged, and OpenVMM exits with status 0.

The test starts OpenVMM itself, with the command line, endpoint,
timeout, and output directory that the scenario passes in
NVX_MANAGED_LIFECYCLE_* variables, which the next commit sets. OpenVMM
reads its capability from a pipe that already holds it and its end, as
nvx::host::process::spawn prepares it (#440), writes its output to
managed-lifecycle.log, and is killed if the test fails. The guest
console of a microVM listens on TCP, and the test copies it, from the
address in NVX_MANAGED_LIFECYCLE_CONSOLE, to managed-lifecycle-guest.log
until OpenVMM closes or resets it, so that the scenario can scan it;
any other error of the copy, such as a full disk, fails the test, so
that the scenario never scans an incomplete log. The test records
OpenVMM's process ID in NVX_ADVERSARIAL_OPENVMM_PID_JOURNAL, as the
scenario did, for the adversarial campaigns. Like the Python client,
it bounds each exchange with the scenario's timeout.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The managed-lifecycle scenario was the last user of control_session.py,
the Python client of the control protocol, which #359 leaves to nvx-rs
alone. Its orchestration stays in Python, and its protocol checks run
in nvx-rs's ignored test tests/managed_lifecycle.rs:

- run_managed_lifecycle builds OpenVMM's command line as before, with
  absolute paths, since Cargo runs the test in the nvx-rs directory,
  and runs `cargo test --manifest-path nvx-rs/Cargo.toml --locked
  --test managed_lifecycle -- --ignored --nocapture` from the
  repository root, so that rust-toolchain.toml selects the Rust
  release, as test-aci-edge-sandboxes runs the aci_edge_sandboxes test.
  Without Cargo, it fails and says why. If its wait for Cargo ends
  with an exception, such as the KeyboardInterrupt of a SIGINT that
  reached nvx.py alone, it ends and reaps Cargo's whole tree, the test
  and the OpenVMM that it started, before the temporary directory that
  they use goes, as it ended the OpenVMM that it started before. It
  leaves Cargo in its process group, which a terminal's Ctrl-C and an
  adversarial campaign's cleanup still reach.
- The guest console still listens on TCP, since a microVM console
  takes no file, and the test, which starts OpenVMM, copies it to
  managed-lifecycle-guest.log. The time ABI monitor scans that log once
  the test ends, so that a violation still fails the run, before the
  test's failure, which it may explain.
- The scenario still checks and keeps managed-outcome.json, which the
  adversarial campaigns collect, and that OpenVMM refuses the managed
  lifecycle without a control endpoint.

The checks of OpenVMM's report that also hold for the layered root
now run through nvx.py sandbox too: the sandbox-lifecycle scenario
checks that the report of a stopped sandbox names the backend, that no
network policy was requested, and that all seven teardown steps
completed, besides the managed success that it checked.
managed-exec-config already covers the working directories,
environments, timeouts, and refused directories for the layered root.

The unit tests replace the capability-pipe test of the Python spawn
with tests of the Cargo command, its environment, the absolute paths,
the missing Cargo, an interrupted wait, which ends the tree while the
temporary directory remains, a failed test, a violation on the
console, an invalid report, and an unrefused transition. The sandbox
stand-in writes a full report, and four new regressions show that the
lifecycle acceptance catches another backend, a requested network
policy, an incomplete teardown, and a teardown that names only some of
its steps. The structural test of the TCP consoles counts five, and
checks that the managed lifecycle monitors its file console.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx.py now runs nvx-rs for the sandbox command and for the egress and
artifact steps of run, and the microVM tests drive the control protocol
through nvx-rs, so the Python implementations that nvx-rs ported have
no caller left. #359 asks that Python duplicate none of the
control-protocol, lifecycle, or egress logic, so they go:

- scripts/nvx_tools/control_session.py, the control-session client,
  which nvx::host::session and the CLI's session replace, and its
  tests, scripts/test_control_session.py, which nvx-rs ported to
  src/host/cli/session/tests.rs;
- scripts/nvx_tools/egress_policy.py, the egress policy compiler, which
  nvx::host::network::policy and `nvx-rs egress compile` replace, and
  its tests, scripts/test_egress_policy.py, which nvx-rs ported to
  src/host/network/policy/tests.rs;
- scripts/nvx_tools/sandbox_lifecycle.py, the managed lifecycle, which
  the nvx-rs sandbox command replaces, all of it;
- the launch profile of scripts/nvx_tools/sandbox.py: SandboxLaunch,
  SandboxLayer, SandboxMount, the workload identity, the reserved
  targets, and the checks of shares, which nvx::host::sandbox replaces.

sandbox.py keeps what `nvx.py run` builds its own OpenVMM command line
with, as #359 leaves that assembly in Python: the --mount values, the
names, arguments, and kernel command-line tokens of the aggregate of
several shares, the mount owners and access-policy options, and the
kernel command-line budget. Its docstring says so.

The unit tests of the deleted code go with it: the managed lifecycle,
lock, state, and process-identity tests and the launch-profile tests of
SandboxTests, the helpers that only they used, and ControlSessionTests
of test_microvm_tests.py. What remains of SandboxTests tests the share
helpers of run, as RunShareTests, with the --mount parser and mount
kind tested directly rather than through SandboxMount. The Validate NVX
action no longer runs the two deleted test modules, and the check that
it runs every scripts/test_*.py module looks for one that still exists.

nvx-rs comments and README sections that named the Python files now
call them former, and keep why the ports match them: the state
directories, the lifecycle lock, and the rule output that an older
nvx.py shares with nvx-rs. doc/project-structure.md drops the deleted
modules.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Open PR #579 (jay23606, fixes #486) reserves the guest paths that the
container runtime of a layered sandbox creates or binds over: it
creates /run and /run/nvx below the container root, and binds its time
ABI state over /run/nvx/time. A live share at one of them
would be shadowed, or would expose host data to the runtime's writes.
#579 changes scripts/nvx_tools/sandbox.py, which #359 retires, so this
ports its behavior to the launch profile that replaced it, as the
maintainer asked while #579 is open:

- nvx::host::sandbox::mount: RESERVED_MOUNT_TARGETS gains the
  /run/nvx/time tree, and RESERVED_EXACT_MOUNT_TARGETS /run and
  /run/nvx, with #579's reasons. validate_mount_target refuses them
  with the messages that the Python check gave: `sandbox mount target
  /run is reserved`, and `... overlaps the reserved /run/nvx/time tree`
  for the tree. Other paths below /run, such as /run/secrets,
  /run/nvx/other, or /run/nvx/timer, stay valid.
- guest/common/nvx-init-agent refuses the same targets, as #579 does,
  so that a guest refuses them even from a host that would not.
- The guest-agent test of aggregate children gains #579's rows for
  /run, /run/nvx, and /run/nvx/time/state.
- doc/usage.md and doc/run.md list the new reserved targets, which
  #579 left undocumented.

tests/fixtures/sandbox.json, which the retired Python launch profile
recorded, held nine targets and four shares that #579 now refuses. Its
records of those cases are re-evaluated with that Python code, taken
from the commit before #359 retired it, with #579's change to
sandbox.py applied: 7 targets at /run, one at /run/nvx, and one at
/run/nvx/time become refusals, as do 4 shares at /run, and no other
record changes. The Rust unit tests check the new refusals, and paths
beside them that stay valid.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PR #576 (jay23606, refs #556) narrowed the sandbox to one EROFS distro
layer and the scratch disk: the runtime and custom layers went, and
their MMIO windows and interrupts stay unassigned. It merged after #595
had ported the launch profile to nvx-rs, and it changed
scripts/nvx_tools/sandbox.py, which #359 retires, the guest agent, the
sandbox-blocks scenario, and the docs, but not the launch profile of
nvx-rs, which still accepted the three roles. Now that nvx.py sandbox
runs nvx-rs, this ports #576's change to that launch profile, with the
messages and checks of #576:

- nvx::host::sandbox::layer: LAYER_ROLES, the roles that --layer
  accepts and a launch holds, is distro alone, so `--layer runtime,...`
  fails with `unsupported layer role 'runtime'; choose distro`, as
  does any other role. LayerRole keeps the retired Runtime and Custom
  roles, which from_name still reads, because the configuration of a
  sandbox that an older nvx.py provisioned may name them.
- SandboxLaunch::new checks the duplicate roles first, over every role,
  and then requires exactly one layer, the distro layer: `a sandbox
  requires exactly one read-only distro layer`, which replaces `one to
  three read-only layers`. As in #576, the check holds for a saved
  launch as much as for parsed options, so `nvx.py sandbox start` of
  such a sandbox fails before OpenVMM starts.

The golden records follow. In tests/fixtures/sandbox.json, the layer
and launch cases are re-evaluated with the retired Python launch
profile, from the commit before #359 retired it, which holds #576,
with #579's change to sandbox.py applied. 168 layer values of the
retired roles, 146 of which parsed and 22 of which had an invalid UUID,
become refusals of their role, which is checked first; the refusals of
28 values of unknown roles list only distro; and 136 launches with
retired roles now fail with the duplicate roles or the distro-layer
requirement, which come before their other checks. No other record
changes. The launch harness builds a retired role's layer as a saved
configuration names it, since --layer no longer parses it. In
tests/fixtures/cli.json, by hand, for POSIX and Windows alike:

- `validation: invalid layer role` lists only distro, and a new
  `validation: retired layer role` shows `--layer runtime,...` refused
  with the same usage error;
- `dry run: every layer and option` takes the distro layer alone, so
  its OpenVMM command loses the runtime and custom blocks and their
  nvx_layer tokens;
- `lifecycle: network and launch options` provisions the distro layer
  alone, so its config.json and its OpenVMM arguments lose the runtime
  layer.

A new CLI test edits a provisioned sandbox's configuration to add a
runtime layer, as an older nvx.py wrote it, and checks that start
refuses it with status 1 and the message above, leaving no runtime
state, and that deprovision still removes it.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx.py now runs nvx-rs for its sandbox command and for the egress and
artifact steps of its run command, and the microVM jobs drive both,
in their scenarios and in the steps that call nvx.py sandbox directly.
Without an executable, nvx.py would build one with Cargo in every
microVM job, so CI builds it once per operating system and hands it
over, as it hands over OpenVMM:

- The NVX CLI jobs, which every microVM job of their operating system
  already waits for, build it with `--release --locked --features cli`
  before the CLI tests, at nvx-rs/target/release/, where nvx.py looks
  for it, so the tests run with it rather than building it. Linux
  builds a static x86_64-unknown-linux-musl executable, which runs on
  every self-hosted Linux runner whatever its C library, and Windows an
  executable with a static C runtime, which needs no Visual C++
  redistributable on the runners. The Windows job already installs the
  pinned Rust release with the minimal profile.
- After the tests pass, each job uploads its executable as a one-day
  artifact: nvx-rs-linux-musl-executable or
  nvx-rs-windows-msvc-executable.
- run-nvx-microvm-tests.yml takes the artifact's name as a new required
  input, nvx-rs-artifact, which every microVM job, the debug-kernel
  ones included, sets for its operating system. It downloads the
  executable into nvx-rs/target/release/ and restores its executable
  permission on Linux, before any step runs nvx.py.

The platform jobs run neither nvx.py sandbox nor nvx.py run, so they
take nothing. The change classification needs no change: nvx-rs/,
nvx.py, scripts/nvx_tools/, and these workflows are already test
inputs.

A new workflow test checks the builds, their targets and their order
around the CLI tests, the artifact names and paths, that each microVM
job takes the executable of its operating system and the platform jobs
none, that the reusable workflow downloads it where
nvx_rs.binary_path() looks, and that it does so before it runs nvx.py.
The handoff test counts the microVM workflow's fifth download.
doc/ci.md describes the handoff, and notes that the Copilot setup does
not build nvx-rs, which nvx.py builds on first use.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx.py now runs nvx-rs for its sandbox command and for the egress and
artifact steps of its run command, and test-microvm drives the managed
lifecycle through an nvx-rs test, so the documentation says where the
executable comes from, what needs Cargo, and what the delegation keeps
or changes:

- doc/usage.md gains an nvx-rs section: NVX_RS, then
  nvx-rs/target/release/nvx-rs[.exe], then a Cargo build with the
  pinned release and its output on standard error, the failure that
  names these choices without Cargo, and that nvx.py does not rebuild
  an existing executable after a change. The sandbox section gives the
  exit statuses and says which usage errors argparse still reports,
  that abbreviations still work, and that nvx-rs's usage wraps at 80
  columns; run says which steps nvx-rs performs; test-microvm describes
  the managed-lifecycle scenario's nvx-rs test and its need for Cargo.
- README.md and doc/setup.md list rustup as a prerequisite of
  nvx.py run and nvx.py sandbox, whose nvx-rs nvx.py builds on first
  use until releases include it, and doc/run.md points the sandbox
  section at the nvx-rs section.
- doc/contribute.md says that the control protocol, lifecycle, and
  egress logic belong to nvx-rs, which the microVM scenarios drive as
  commands, and that no Python client of them may return.
- doc/project-structure.md lists scripts/nvx_tools/nvx_rs.py and the
  hypervisor test among nvx-rs's tests.
- nvx-rs/README.md says how nvx.py finds and invokes the CLI, and
  describes tests/managed_lifecycle.rs.
- .github/copilot-instructions.md tells cloud sessions that nvx.py
  builds nvx-rs on first use, how to rebuild it after a change, and
  that the managed-lifecycle scenario needs Cargo.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Three CLI tests run the real nvx-rs only when NVX_RS names it: the
egress compilation of nvx.py run, the JSON that nvx.py reads from
nvx-rs, and the options that nvx.py sandbox passes. The NVX CLI jobs
built nvx-rs where nvx.py finds it, but set no NVX_RS, so those tests
skipped and the executables that the jobs hand to the microVM jobs left
unexercised by them (review of #597).

Each Build nvx-rs step now exports NVX_RS for the rest of its job,
naming the executable that it built: with `>> "${GITHUB_ENV}"` on Linux,
and on Windows with `Out-File -Encoding utf8 -Append`, as setup-curl
writes GITHUB_ENV under Windows PowerShell. The workflow test checks
both, and doc/ci.md says so.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The adversarial executor runs each scenario with an allowlist of
environment variables, which left out everything that nvx-rs needs now
that the control protocol and the egress compilation live there. Of
the catalogued scenarios, managed-lifecycle runs the nvx-rs test
managed_lifecycle with Cargo, and l3-l4-egress-policy compiles its
policy with nvx-rs and runs nvx.py run, which runs nvx-rs too. Under
the executor, they ignored a prebuilt nvx-rs that NVX_RS named, and
they could not find a Rust toolchain installed outside the default
rustup and Cargo homes: without RUSTUP_HOME, rustup starts installing
the pinned release into the home directory, within the scenario's
timeout.

The allowlist now passes NVX_RS, CARGO_HOME, and RUSTUP_HOME, which are
paths, not secrets. It still drops RUSTUP_TOOLCHAIN, so that
rust-toolchain.toml selects the release, and Cargo's other settings and
credentials, such as CARGO_TARGET_DIR, RUSTFLAGS, and registry tokens.
Tests check both, and that nvx.py finds NVX_RS in a process that has
only the executor's environment.

The rest of the executor's contract holds, and is now tested:

- managed-lifecycle still preserves OpenVMM's report as
  managed-outcome.json, with its teardown fields, in the scenario's
  output directory, where the executor's outcome inventory requires
  it. A test checks the preserved report against that inventory.
- The nvx-rs test records OpenVMM in the journal that
  NVX_ADVERSARIAL_OPENVMM_PID_JOURNAL names, as the Python scenarios
  do, but it runs in nvx-rs/, so the scenario now hands it an absolute
  path: a relative journal resolves where nvx.py runs, as for the
  Python scenarios. The test reads the variable as an OsString, so a
  path that is not UTF-8 is no longer skipped, and a new test that
  needs no hypervisor checks that its entries are exactly what the
  executor reads.

doc/adversarial-testing.md describes what a target needs: the Rust
toolchain, with Cargo on the executor's PATH, and nvx-rs and the
managed-lifecycle test built at the staged commit before exposure,
since a scenario's timeout includes any build, nvx.py never rebuilds an
existing nvx-rs, and the target may have no network. doc/ci.md and
doc/setup.md point there. The nvx-adversary agent is the strategist's
prompt, which names no host prerequisites, so it is unchanged.

Verified by driving the local executor on a Linux/MSHV host through the
managed-control-lifecycle and l3-l4-network-boundary cases of
workload-isolation, with source verification, provenance, and the
baseline and post-campaign boots passing: once with NVX_RS naming an
nvx-rs outside the checkout, which the scenario ran without building,
and once without it, when the scenario built nvx-rs with Cargo. Every
journal held the nvx-rs test's OpenVMM, and the inventory found
managed-outcome.json with complete teardown in each repetition. On
Windows, with HOME and USERPROFILE pointing at an empty directory, the
executor's environment built nvx-rs and the managed_lifecycle test
through CARGO_HOME and RUSTUP_HOME, with the release that
rust-toolchain.toml pins despite a RUSTUP_TOOLCHAIN of 1.89; without
those two variables, rustup began installing the release into the
empty home.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 19, 2026 18:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Cross-platform runtime delegation, signal handling, lifecycle cleanup, and CI executable handoffs warrant final human review.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@ppenna
Pedro Henrique Penna (ppenna) merged commit d8d9ade into dev Oct 19, 2026
34 checks passed
@ppenna
Pedro Henrique Penna (ppenna) deleted the agents/fix-issue-359-delegate branch October 19, 2026 19:01
Pedro Henrique Penna (ppenna) added a commit that referenced this pull request Oct 19, 2026
The executor validates and hashes the nvx-rs that NVX_RS names before
the baseline boot, and nvx.py resolves a relative NVX_RS against its
working directory: the executor's, at that point. The scenarios inherit
NVX_RS unchanged but run in the repository root, so with a relative
NVX_RS they ran another executable than the one validated, or found
none.

sanitized_environment() now makes NVX_RS absolute against the
executor's working directory, so that the scenarios run the executable
that the initialization checked. An empty NVX_RS, which nvx.py
ignores, passes unchanged.

A new test validates a relative NVX_RS in one working directory and
resolves it in a scenario process in another; it fails without the
fix. The environment test now expects the absolute path.

Copilot's review of #598 flagged this. #597 added NVX_RS to the
scenarios' environment, and the earlier commit of this series that
validates the executor's nvx-rs made the mismatch matter.

Closes #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pedro Henrique Penna (ppenna) added a commit that referenced this pull request Oct 19, 2026
The executor validates and hashes the nvx-rs that NVX_RS names before
the baseline boot, and nvx.py resolves a relative NVX_RS against its
working directory: the executor's, at that point. The scenarios inherit
NVX_RS unchanged but run in the repository root, so with a relative
NVX_RS they ran another executable than the one validated, or found
none.

sanitized_environment() now makes NVX_RS absolute against the
executor's working directory, so that the scenarios run the executable
that the initialization checked. An empty NVX_RS, which nvx.py
ignores, passes unchanged.

A new test validates a relative NVX_RS in one working directory and
resolves it in a scenario process in another; it fails without the
fix. The environment test now expects the absolute path.

Copilot's review of #598 flagged this. #597 added NVX_RS to the
scenarios' environment, and the earlier commit of this series that
validates the executor's nvx-rs made the mismatch matter.

Closes #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants