Skip to content

Ship nvx-rs in NVX releases - #598

Open
Pedro Henrique Penna (ppenna) wants to merge 47 commits into
devfrom
agents/fix-issue-359-release
Open

Pedro Henrique Penna (ppenna) wants to merge 47 commits into
devfrom
agents/fix-issue-359-release

Conversation

@ppenna

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

Copy link
Copy Markdown
Contributor

Closes #359 (PR 5 of 5).

Summary

Every NVX release package now ships the nvx-rs CLI to which nvx.py sandbox and the egress and artifact steps of nvx.py run delegate since #597, and nvx.py download installs it where nvx.py finds it, so a downloaded checkout runs both commands without Cargo. The executable travels with a build provenance, as OpenVMM's does, which packaging, download, and the adversarial executor check.

  • Provenance. build/nvx-rs.provenance.json binds the executable's SHA-256 to a line-ending-independent digest of the files that build nvx-rs (Cargo.toml, Cargo.lock, build.rs, src/, and rust-toolchain.toml), the pinned Rust release, the target, and the cli feature. nvx.py records it after its own Cargo builds, and the new nvx.py record-nvx-rs-provenance [--target TARGET] records it for a build made otherwise.
  • Packaging. nvx.py package ships bin/nvx-rs[.exe] and provenance/nvx-rs.provenance.json, lists both in SHA256SUMS, and records an nvx_rs section in SOURCE-MANIFEST.json. It refuses an nvx-rs whose provenance does not describe it as the build of the checkout's sources for the release target of the host's OS, and a Linux nvx-rs that is not static.
  • CI. The NVX CLI jobs record the provenance of the nvx-rs that they build and test, and upload it beside the executable. The platform jobs hand both to package-release. The platform jobs now also package and archive on pull requests, without uploading, so this PR's platform jobs exercise packaging; before, only dev pushes did. The development release job refuses packages without bin/openvmm and bin/nvx-rs.
  • Download. download verifies SHA256SUMS, then installs bin/nvx-rs[.exe] at nvx-rs/target/release/nvx-rs[.exe], executable, with its provenance in build/, when the provenance describes the build of the checkout's nvx-rs sources that nvx.py runs. Otherwise it installs the rest and leaves nvx-rs to Cargo.
  • Adversarial executor. As a follow-up that Make nvx-rs the backend of nvx.py sandbox, egress, and artifact lookup #597 flagged, the executor's runtime validation and artifact metadata now cover the nvx-rs that the scenarios run, so a stale nvx-rs/target/release/nvx-rs fails the initialization instead of running.
  • Notices. THIRD_PARTY_NOTICES.md covers the crates, the musl, LLVM libunwind, and Microsoft C runtime code that the shipped executables link.
  • A fix outside nvx-rs. The Ubuntu converter input digest sorted Path objects, which Windows compares without case. Since Build the guest binaries from nvx-rs #561 added nvx-rs/Cargo.toml beside nvx-rs/build.rs, nvx.py package failed on Windows with "Ubuntu artifact manifest does not match initramfs-ubuntu.cpio.gz", which would have failed the next windows-whp development release. The digest now sorts as Linux always has, so existing artifacts stay valid.

Package changes

Path linux-kvm, linux-mshv windows-whp
bin/nvx-rs[.exe] (new) bin/nvx-rs: static x86_64-unknown-linux-musl executable, mode 0755 bin/nvx-rs.exe: x86_64-pc-windows-msvc, static C runtime
provenance/nvx-rs.provenance.json (new) format, source_sha256, toolchain (1.95.0), target, features (["cli"]), executable_sha256 the same
SOURCE-MANIFEST.json new nvx_rs section with the executable's SHA-256, the source digest, toolchain, target, and features the same
SHA256SUMS lists both new files: 22 entries in a binary-only package instead of 20 the same

Choices:

  • Older releases stay installable. A release without bin/nvx-rs, or whose nvx-rs was built from other nvx-rs sources (a first-parent fallback release), still installs everything else; download then removes any earlier nvx-rs and its provenance, says why, and nvx.py builds nvx-rs with Cargo when a command needs it, as before this PR. A package whose nvx-rs contradicts its own provenance is broken and fails the download before anything is replaced.
  • The bundled pin stays. The bundled build script extracts and checks only OpenVMM, the Alpine guest, and the manifest of a release, so a release with bin/nvx-rs stages as the pinned older one does. The synthetic release of the check-nvx-rs action now has the current layout, and the pinned release still stages and tests.
  • Source-inclusive packages ship nvx-rs too; their project source archive already holds its sources.

Stack

Based on #597 (403c7e83d), #596, #595, #594, and #561; it contains their commits until they merge. The merge order after #561 is #594 (A) → #595 (B) → #596 (C) → #597 (D) → this PR (E). This PR's commits:

  1. ubuntu: order converter inputs alike on every OS: the Windows packaging fix above, in code that Build the guest binaries from nvx-rs #561 exposed
  2. scripts: record the provenance of nvx-rs
  3. release: package nvx-rs with its provenance
  4. notices: cover the nvx-rs binary
  5. ci: package releases on pull requests
  6. release: install nvx-rs with nvx.py download
  7. adversarial: check the nvx-rs that the scenarios run
  8. nvx-rs: count a VM as running until its last thread exits (review): the CLI's Linux identity check counted a zombie OpenVMM whose other threads still run as exited, unlike the library and the CLI's Windows branch
  9. adversarial: hand the scenarios the nvx-rs that the executor checked (review): the executor now hands the scenarios an absolute NVX_RS, so they run the executable that it validated

Commits 3, 7, and 9 touch code that #597 owns: run-nvx-microvm-tests.yml now takes the base name of the nvx-rs artifacts and appends -executable, as the workflows name the OpenVMM artifacts; the adversarial executor's validation and metadata cover nvx-rs, so targets must now record its provenance, which nvx.py does for its own builds; and the executor hands the scenarios an absolute NVX_RS. Commit 8 changes the Linux identity check of the CLI, which #596 added. Each commit body explains why.

#359 acceptance criteria

Criterion Evidence
nvx.py sandbox …, and the egress and artifact steps of nvx.py run, go through nvx-rs #594 (egress compiler), #595 (launch profile and artifact lookup), #596 (the CLI), and #597 (the delegation, with its hardware runs). Here, a released nvx-rs ran sandbox run and run's artifact and egress steps on KVM, MSHV, and WHP without Cargo
Python duplicates no control-protocol, lifecycle, or egress logic #597 deleted control_session.py, egress_policy.py, sandbox_lifecycle.py, and the launch profile of sandbox.py, with their tests ported to Rust
Releases ship nvx-rs, and nvx.py download installs it This PR: the package contents, SHA256SUMS, manifest, and provenance (commit 3), the publication check (3), the installer (6), their tests in test_nvx_tools.py and test_development_release.py, and the hardware runs below. This PR's platform jobs package each platform
The managed exec tests pass on KVM, MSHV, and WHP #597: managed-exec-config, sandbox-lifecycle, and managed-lifecycle on prometheus32, prometheus30, and prometheus28, and in CI's microVM jobs, which run again on this PR

Validation

Environment Evidence
Windows 11 (local, Python 3.14) 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, aarch64-apple-darwin check, and the bundled-artifacts steps with the synthetic current-layout release and the pinned release; 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 (996 tests), test_adversarial.py (73), test_hosts.py, and the CLI help commands
Each commit (local) Ruff, Pyright, the link check, test_nvx_tools.py, test_development_release.py, test_markdown_links.py, and test_adversarial.py where it changes
Ubuntu on WSL the validate-nvx list on Python 3.14, 3.12, and 3.10 (996 tests each), test_hosts.py, the Specula wiring tests, and actionlint 1.7.7 (only the pre-existing nvx-adversarial-controller label note). test_adversarial.py passes on 3.14 and 3.12, and on 3.10 fails only test_contained_native_process_preserves_child_signal_status (sigkill), as on origin/dev
Windows 11 (local), packaging package --binary-only and archive-release with #597's CI guest artifacts and OpenVMM, then an install with download's installer into a scratch clone: sandbox run --dry-run and run --dry-run with an egress policy file, without Cargo and with NVX_RS unset
prometheus32, Linux/KVM, bare metal from this branch: nvx-rs built for x86_64-unknown-linux-musl as CI does (static-pie), its provenance recorded, a linux-kvm binary-only package with the host's OpenVMM at 5ab8a84 and #597's CI guest artifacts, archived (269 MB); sha256sum -c SHA256SUMS passes; the extracted release's bin/nvx-rs artifacts finds the release layout; installed into a scratch checkout with download's installer, which verifies SHA256SUMS and the provenance. Without Cargo and with NVX_RS unset: sandbox run --dry-run and a real sandbox run of /bin/echo (hello-from-a-release, outcome success, complete teardown), the egress policy file through sandbox run and run, whose dry run also finds the kernel and initramfs; nothing rebuilt nvx-rs
prometheus30, Linux/MSHV, bare metal the same with a linux-mshv package (268 MB): all pass
prometheus28, Windows/WHP, bare metal the same with nvx-rs.exe built with a static C runtime as CI does, and a windows-whp package (151 MB, 22 SHA256SUMS entries, no mismatches): all pass
Review fixes (commits 8 and 9) Windows: nvx-rs clippy (all features, and lib without defaults), tests, rustdoc with -D warnings, 1.89 and aarch64-apple-darwin checks; Ruff, Pyright, test_adversarial.py (74). WSL: nvx-rs fmt, clippy, all tests, including the new Linux test a_vm_runs_until_its_last_thread_exits, which fails without the fix, and the 1.89 check; test_adversarial.py on 3.14 and 3.12 (74), and on 3.10 with only the known sigkill failure. The new executor test fails without its fix
Left to CI the packaging of all three platforms in this PR's platform jobs, the microVM suites, the guest artifact rebuild, the OpenVMM tests, and the benchmarks. The release upload and publication run only on dev pushes

Replace native C helper compilation with the shared Rust guest-binary
builder introduced by #528. Build one checked set of all nine
executables per initramfs, install it at the established /sbin/nvx-*
paths, and record the aggregate source and individual binary digests.

Remove the obsolete C compiler flags, helper inventories, and build
functions. Keep C test-only flags local until the C-driven suites are
deleted later in this stack.

Part of #358.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Build one checked set of Rust guest executables in the digest-pinned Rust
1.95.0 image and reuse its exact bytes in Alpine, Ubuntu, and Azure
Linux through a guest-only base, so the kernel and Linux source targets
never compile them. Keep the toolchain available only for Ubuntu's two
fresh determinism attempts, and remove the guest C compilers from the
base.

Bind reusable binary sets to normalized Rust-source, toolchain, target,
profile, and executable digests, and refuse the output of an earlier
build that a rebuild would upgrade in place. Record all nine helpers in
the Azure Linux package manifest.

Part of #358.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Cover the nvx-rs guest inputs in every image identity, package the crate
without its target directory, and declare the exact Rust toolchain and
musl target in SOURCE-MANIFEST.json. Verify those pins and inputs from
nvx.py verify and release packaging.

Make device-I/O benchmark provenance validate the shared Rust source
digest, and bind the Azure Linux cache input to the newly tracked files.

Part of #358.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Hash the complete nvx-rs tree except target, the exact toolchain pin, and
the shared build modules into every guest cache, and advance the guest
cache schemas. Because hashFiles hashes contents only, also append the
path-aware Rust source digest that the guest package manifests record,
which the new guest-binary-source-identity command prints, so a module
move that keeps every file's bytes still rebuilds the images. Treat
nvx-rs changes as OpenVMM VMM-test inputs so they rebuild and boot the
affected images.

Keep Copilot setup and both Linux setup paths on the pinned musl target,
and revalidate Copilot setup for every nvx-rs change.

Part of #358.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove the nine remaining guest C implementations and the C-compiled
Python suites whose behavior the nvx-rs unit, guest-binary, loopback, and
real-VM tests now cover. Drop the deleted modules from both CLI validation legs.

Add a repository regression that rejects C, header, or assembler sources
outside the OpenVMM submodule.

Part of #358.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Document the one pinned build shared by native builds, Docker, and CI,
the nine binaries shipped by every guest, their provenance, cache inputs,
test ownership, and release source. Now that the Rust definitions are
authoritative, replace the obsolete source comparisons with literal wire
values and remove C-era wording.

Extend the third-party notices for the Rust standard libraries,
compiler_builtins and its compiler-rt-derived routines, libc, nix,
getrandom, and their linked transitive crates, and musl's complete
guest-binary scope.

Closes #358.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port scripts/nvx_tools/egress_policy.py, which compiles the files of
nvx.py's --network-egress-policy-file option, to
nvx::host::network::policy, so that the nvx-rs CLI can replace it.
Python stays authoritative until nvx.py delegates to nvx-rs. The port
accepts the same files, compiles them to the same rules, and fails with
the same messages:

- compile_policy_file reads at most 1 MiB and one byte of a file, and
  compile_policy_bytes and compile_policy compile its contents or an
  already parsed serde_json value. The CompiledEgressPolicy result has
  the serde form {"allow": [...], "deny": [...]}.
- The format keeps Python's root fields, the MXC and legacy flat rule
  forms, the tcp, udp, icmp, and any protocols, and TCP and UDP port
  ranges, and runs every check in Python's order with its message.
- The lowering keeps Python's steps: address-only rules, rules for every
  port of ICMP, TCP, and UDP widened into the address-only cover, the
  sweep over TCP and UDP port boundaries, the budget of 256 native rules
  that both families of an action share, and the sort by family,
  address, prefix length, protocol, and ports.

network.rs becomes network/mod.rs, beside address.rs, the networks and
address intervals, and lowering.rs, the rule model and the lowering.
openvmm_arguments still expands NetworkPolicy rules as before.

The format depends on Python semantics, which a crate-private
host::python module provides for this and later ports:

- python::json reads a document as json.loads does, with hooks in the
  place of object_pairs_hook and parse_int: NaN and Infinity are floats,
  numbers beyond a float's range are infinities, integers keep their
  digits, and a \ud800 escape stays an unpaired surrogate. It keeps
  values in an arena and never recurses. The policy reader's hooks
  refuse a repeated field when its object closes and an integer of more
  than 64 digits when it is read, so their errors surface in Python's
  order. serde_json, which nvx-rs already depends on, cannot do this: it
  refuses NaN, Infinity, 1e400, and lone surrogates, which Python reads
  and the compiler then reports with its own messages. No dependency is
  added.
- python::text holds strings that may contain unpaired surrogates,
  ordered by code point as Python orders them, and shows a surrogate as
  \udXXX, which is how Python prints it.
- python::path gives str(pathlib.Path(path)) for POSIX and Windows
  paths, as CPython 3.14 splits them, and the normalized path that
  Python opens, so that messages name files as nvx.py does.

Networks are read as ipaddress.ip_interface reads them: host bits are
cleared, IPv4 netmasks and hostmasks, prefix lengths with leading zeros
up to Python's 4300-digit int limit, and embedded IPv4 addresses are
accepted, and IPv6 scopes are found and refused. They print as Python
3.13 and later print them, with IPv4-mapped addresses in dotted form.

One input differs. Python's decoder raises RecursionError for a
document nested more deeply than its stack allows, at a depth that
depends on the Python version and the platform. This reader reads any
depth, so such a document fails as an invalid policy instead.

The tests port each case of scripts/test_egress_policy.py under its
name. The cases that mock ipaddress.summarize_address_range and
_merge_intervals to bound the work read thread-local counters of the
lowering instead. Unit tests cover the JSON reader, strings, paths, and
networks against values that Python 3.14 produced.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add scripts/generate_egress_policy_fixtures.py, which runs the Python
compiler of scripts/nvx_tools/egress_policy.py over a corpus of policy
files and records each file with its outcome, its native rules or its
error message, in nvx-rs/tests/fixtures/egress-policy.json. The Rust
tests replay every entry through compile_policy_bytes and, from a real
file, compile_policy_file, and need no Python, so the fixture outlives
the Python compiler, which a later change in this stack deletes.

The corpus holds 784 policy files:

- every file that scripts/test_egress_policy.py compiles, which the
  script captures by running that suite, except those of the two tests
  whose files exceed 64 KiB, which pin their complete outcomes in
  Python and in their Rust ports;
- hand-written cases that reach every message and JSON edge: syntax
  that json.loads refuses, NaN, Infinity, floats, Booleans, and 64- and
  65-digit integers as ports, repeated fields and long integers in
  document order, unpaired surrogates in names, networks, scopes, and
  protocols, unknown fields in code-point order, each check of both
  rule forms in order, network spellings as ip_interface reads them,
  the rule limit across protocols and families, and files at and beyond
  the 1 MiB limit; and
- 200 random policies from seed 359 that mix both rule forms and both
  families, dense and sparse networks written with host bits, masks,
  and exceptions, overlapping protocols and port ranges, crowds of
  rules near the limit, broken fields, and varied, truncated, and
  altered JSON text.

The fixture also records the errors for a missing file and a directory,
and how PurePosixPath and PureWindowsPath print 55 paths, which the
tests compare with python::path. Messages name the policy file
{path}, and show unpaired surrogates as Python prints them. Inputs are
text, or hexadecimal for bytes that are not UTF-8, with runs of 256 or
more characters recorded as repetitions, and each entry is a line of
ASCII JSON, about 1.1 MB in all.

The script needs Python 3.13 or later, whose ipaddress module prints
IPv4-mapped IPv6 addresses in dotted form, as the port does. Its output
depends only on the seed and the corpus size, so --check verifies that
the fixture is current. --random-cases and --output write larger
corpora, and NVX_EGRESS_POLICY_FIXTURE points the tests at one. The
script passes ruff and strict pyright on Linux and Windows.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx::host::network::openvmm_arguments, which the OpenVMM backend of
aci_edge_sandboxes calls, expanded each EgressRule into OpenVMM rules on
its own, then sorted and deduplicated the strings. It now lowers each
action's rules with the lowering of egress policy files, so that NVX
has one lowering. An EgressRule is a policy file's MXC rule whose empty
to or ports stands for an absent field, and messages name its fields
after EgressRules::name, where a file's messages say allow or deny, as
in "network.egress.allow[0].to[1].cidr is not a valid IPv4 or IPv6
CIDR: x".

openvmm_arguments keeps the checks that only a NetworkPolicy has, which
the Python compiler lacks: no device for a closed posture, the /29
limit of host-loopback access, and the rules of host-loopback forwards.
The check that no deny rule covers the guest network of forwards now
reads the deny rules as the lowering parsed them, so it follows the same
normalization. Before, it parsed them with the stricter Cidr and missed
a network written with host bits, which the old expansion also refused.

The observable changes, which aci_edge_sandboxes/README.md lists as
breaking changes:

- The OpenVMM rules follow family, address, prefix length, protocol,
  and ports, instead of string order.
- Merging destinations and port ranges, and subsuming narrower rules,
  usually needs fewer rules, so some policies beyond the limit of 256
  now fit: 256 adjacent /24 destinations become one /16 rule. Port
  ranges that overlap only partly across networks can also need a rule
  for their union.
- The backend's rule checks report the compiler's messages. Through
  AciEdgeSandbox, whose request validation runs first, only the limit
  message, now "network.egress.allow emits at most 256 native rules",
  changes. The error codes are unchanged.
- Without that validation, the backend accepts networks with host bits
  and IPv4 netmasks, and refuses port 0, which it passed to OpenVMM.

Tests: the nvx-rs network tests pin the new order, merging, messages,
and coverage check. ipv6_rules_expand_exactly, now
ipv6_rules_lower_exactly, expects ::/0 first.
inexpressible_rules_are_rejected, now
invalid_rules_are_rejected_with_the_policy_compilers_messages, checks
each message, and its 300-destination rule, which now lowers to one
rule, moved to rules_lower_like_egress_policy_files.
exceptions_that_split_a_network_too_finely_are_rejected expects the
limit message. network_policies_convert_field_for_field expects the new
ICMP message and also checks the scope and limit messages. The other
aci_edge_sandboxes tests, including the fake-OpenVMM test that pins
the launch arguments, pass unchanged.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Describe nvx::host::network::policy in nvx-rs/README.md: what it ports
and that Python stays authoritative until nvx.py delegates to nvx-rs,
the policy format, the lowering and the order of its rules, how
openvmm_arguments, and so aci_edge_sandboxes, share that lowering, the
fidelity of its messages and the one input on which it differs from
Python, and the parity fixture with its generator. The module table
gains the policy module, and its network row describes NetworkPolicy
and the shared lowering instead of the expansion.

doc/project-structure.md lists the generator, the parity fixture, and
the compiler in its descriptions of scripts/ and nvx-rs/.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
An MXC rule pairs each family of its destinations with each of its
port selectors, and `combine`, formerly `mxc_rules`, gave every pair
its own copy of the family's address intervals. Python's
`_parse_mxc_rule` and `_parse_legacy_rule` share one tuple among the
rules of a family instead. A policy file well within the 1 MiB limit,
such as 8192 destinations, none adjacent to another, with 8192 port
selectors, made the port copy 2 GiB of 32-byte intervals, and the
unions of `_compile_family` flattened the shared addresses once for
each selector again.

- `Rule.addresses` is an `Rc<[Interval]>` that the rules of one rule's
  selectors share: for MXC rules, legacy rules, and the typed rules of
  `openvmm_arguments` alike, which all build their rules with
  `combine`.
- The union of the rules for every protocol, and of those for every
  port of each protocol, merges each distinct address set once. Python
  merges the same addresses once for each selector; the union is the
  same.
- The port sweep asks once for each address set whether `covered`
  holds it entirely, rather than once for each rule.
- An `AddressSet` compares rule addresses by value, as Python's tuples
  compare, and compares a shared allocation with itself without
  reading it, as CPython's dictionaries do. The sweep counts its
  active rules by it, as `_lower_protocol_rules` counts them in a
  `Counter`.

The output, the errors, and the order of both are unchanged: the
parity fixture and the ported Python tests still pass. New tests check
that the rules of a rule's selectors share one allocation, that
address sets compare by value, that the unions read shared addresses
once (64 intervals rather than 4096), and that three 8192-by-8192
policies compile as Python 3.14 compiles them: to `0.0.0.0/0` with a
covering rule, and to the 256-rule limit errors without one. Python
takes 15 to 27 seconds for each. Before this change, the debug build
of that test took 171 seconds and peaked at 4.05 GiB; it now takes
0.5 seconds and 48 MiB.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port SandboxLaunch, SandboxLayer, SandboxMount, and the helpers of
scripts/nvx_tools/sandbox.py to a new nvx::host::sandbox module, so
that the nvx-rs CLI can launch sandboxes as nvx.py sandbox does.
Python stays authoritative and unchanged until nvx.py delegates to
nvx-rs.

The port keeps the Python contract byte for byte: the limits, option
semantics, OpenVMM arguments, kernel command line, and messages, with
refused requests as Unsupported, missing images as Unavailable, and
failed host queries as Io errors in Python's [Errno N] form.

- Layers: one to three EROFS layers, distro, runtime, and custom,
  ordered by role, with duplicate roles refused in sorted order. A
  --layer value is ROLE,PATH,EROFS_UUID, and the UUID takes every form
  that uuid.UUID reads: braces, urn:uuid:, no hyphens, and uppercase.
  Layer and scratch paths with ',' or ';' are refused.
- Settings: an absolute entrypoint and nonempty arguments without
  whitespace, a lowercase RFC 1123 hostname label, a non-root workload
  identity, 65534:65534 by default, which parse_workload_identity
  reads as int(field, 10) does, and positive limits, with
  nvx_pids_max one above the request.
- Shares: --mount GUEST_TARGET,HOST_PATH[,ro|rw], whose target ends at
  the first comma and whose mode follows the last one; the target
  rules, at most 4096 UTF-8 bytes, and the reserved targets; at most
  128 denied, allowed, and writable paths each; the overlap and
  single-owner checks; caller ownership refused on Windows; file and
  directory shares; the aggregate at /run/nvx/shares with children
  named INDEX-SHA256(TARGET)[:32] and its nvx_share= tokens; and the
  host-path checks, by Path.resolve() and (st_dev, st_ino) lineage.
- Kernel command line: NUL and the nvx_* and virtfs_* tokens refused,
  the tokens in Python's order, and the 1024 bytes that OpenVMM leaves
  of the 2048-byte x86 command line, which also hold OpenVMM's
  bootstrap tokens for the shares and the NUL.

The private python module that the egress policy compiler introduced
gains what these rules rely on, after CPython 3.14: builtins, with
str.isspace, which unlike char::is_whitespace includes U+001C to
U+001F, str.split() and str.strip(), repr of a str, and int, with
signs, underscores, Unicode digits, surrounding whitespace, and the
4300-digit limit; uuid, for uuid.UUID; unicode, the Unicode 16.0.0
tables of str.isprintable and int; and fs, the queries of os.path and
os.stat, with Path.resolve() ported from posixpath.realpath and
ntpath.realpath. On Windows, os.stat identifies a file by the 64-bit
volume serial number and 128-bit file ID of FILE_ID_INFO, as Python
3.12 and later do, through the windows-sys features that the crate
already enables. Operating-system strings that are not valid Unicode,
which Python would carry as surrogates, are refused.

python::path gains PurePath, a Unicode path of either pathlib flavor
with its parts, parents, joins, and case-insensitive comparison on
Windows, and Flavor, with the splitroot, join, isabs, and split of
posixpath and ntpath, and ntpath.normpath. One parser serves both
python_path and PurePath, so the printed and the pure paths cannot
disagree. Windows paths follow CPython on Windows, whose splitroot and
normpath are C functions that index UTF-16 code units: a character
beyond the Basic Multilingual Plane before a colon is no drive there,
although Python's own ntpath, which other hosts use, reads one.

scripts/generate_egress_policy_fixtures.py becomes
scripts/nvx_rs_fixtures.py, one generator for every Python-parity
file of nvx-rs, which now also writes the Unicode tables, with one
--check for all of them. It needs CPython 3.14, whose Unicode database
the tables record, and says so; --output and --random-cases still
write a larger egress policy corpus. The egress policy fixture
regenerates byte for byte, except its comment, which names the
script.

Aggregate child names need SHA-256 on the host, so the host feature
enables sha2 0.10, which the bundled build script already uses and
locks, as does the aci_edge_sandboxes agent, instead of another crate.

The unit tests port the launch-profile cases of
scripts/test_nvx_tools.py, and check require_file, share kinds,
host-path identity, and bind and hard-link identity on temporary
directories on Linux and Windows.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port the pure checks that scripts/nvx.py applies to the options of
nvx.py sandbox and nvx.py run to nvx::host::sandbox::options, so that
the nvx-rs CLI keeps their order, semantics, and messages and stays a
thin layer over the library.

- SandboxOptions::check: the checks of command_sandbox that precede
  the launch, in its order: --dry-run outside run, --environment with
  --environment-file, managed exec options outside exec, the managed
  exec environment, network policy options outside run and provision,
  systemd entrypoints, --outcome-report outside run and exec, share
  options without --mount, --mount outside run and provision, --net
  without --network-profile or the reverse, and a missing --layer or
  --scratch.
- sandbox_mounts, from _sandbox_mounts: with one --mount, every
  --mount-deny, --mount-allow, and --mount-write names a path in it;
  with several, each names a path in the --mount before it. Each
  share takes the kind that mount_kind finds for its host path.
- validate_sandbox_systemd_policy and SYSTEMD_ENTRYPOINTS: a distro
  layer's manifest, next to it, must be a JSON object of format 1 that
  names the layer and its SHA-256 digest and lists packages with
  names, none of them systemd. The format is compared as == compares
  it, so true and 1.0 match, and the manifest is read with universal
  newlines, as read_text reads it.
- validate_run_share_targets, from _validate_run_share_targets: the
  guest targets of several nvx.py run shares, or of a shared file.
- validate_exec_environment and exec_environment_from_file: at most
  256 nonempty KEY=VALUE entries of at most 4096 UTF-8 bytes, without
  NUL or a repeated key, from --environment or from a JSON array in a
  file of at most 1 MiB, as encode_exec_environment checks them.

These checks fail with the messages of Python's JSON decoder and UTF-8
codec, so the private python module gains them:

- python::json keeps its API, its hooks, and its arena, but its reader
  is now a port of the C scanner of CPython 3.14, scan_once_unicode
  and the functions that it calls, with the checks of
  JSONDecoder.decode around them. It accepts what the previous reader
  accepted, as the egress policy fixture and tests confirm, and a
  refusal now carries the message and the position of Python's
  JSONDecodeError, such as Expecting ',' delimiter, instead of the
  offset at which reading stopped, which is not always Python's
  position: an unterminated string fails at its opening quote.
  syntax_error formats it as str() of the error, with its line,
  column, and character. Loads are the hooks of json.loads without
  arguments, whose int refuses more than 4300 digits, and
  Document::get reads a member as the dict that Python builds does.
- python::text gains PyText::encode_utf8 and decode_utf8, with the
  messages of UnicodeEncodeError and UnicodeDecodeError, so PyText is
  the one string type that holds surrogates.
- python::path gains name and with_name, and python::fs exists.

Argument parsing and type conversion stay with the CLI, as do the
lifecycle operations.

The unit tests port the option cases of scripts/test_nvx_tools.py:
the attribution of policy paths to shares, file shares, the systemd
policy, the run share targets, and the environment rules, with the
environment files and distro manifests on temporary files. The JSON
and codec errors have tests of their own.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Record what the Python code does with fixed and seeded random inputs,
and compare the Rust port with those records in golden tests, so that
the port stays faithful byte for byte without Python at test time,
also after nvx.py delegates to nvx-rs and the Python code goes away.

scripts/nvx_rs_fixtures.py now also writes three fixtures to
nvx-rs/tests/fixtures, which its --check covers with the others:

- python.json: the str.isspace, decimal digit, and str.isprintable
  tables; repr, str.split(), str.strip(), int, and uuid.UUID of
  strings; str(), parts, name, parents, and is_absolute of pure paths,
  joins, and Windows path equality; ntpath.normpath; and the values
  and errors of json.loads and of the UTF-8 codec.
- sandbox.json: parse_workload_identity, SandboxLayer.parse,
  parse_mount_value, validate_mount_target, aggregate_child_name,
  SandboxMount with its OpenVMM arguments, command-line fragment, and
  absolute paths, and SandboxLaunch with its OpenVMM arguments and
  kernel command line. The launches include, for each kind of share
  set, command lines that fill the 1024-byte budget exactly and that
  exceed it by one byte.
- sandbox_options.json: _validate_run_share_targets, _sandbox_mounts,
  encode_exec_environment, the --environment-file rules of
  command_sandbox, which runs with exec_workload patched, and
  _validate_sandbox_systemd_policy, on temporary files.

The generator patches sandbox.Path with pure POSIX and Windows paths
whose current directory is fixed, so that the cases of both flavors
come out the same on any host: CPython 3.14 writes identical files on
Linux and Windows. It writes ASCII JSON with one case per line. The
random corpus is mostly valid input, so that most cases reach the
arguments and command lines, and invalid at a fixed rate. Each
fixture has a random generator of its own, so the egress policy
fixture is unchanged.

The Rust golden tests read the fixtures with serde_json, run the same
inputs through the port, and report each case that differs. They run
the cases of both path flavors on every host, through the internal
constructors that take a flavor and a fixed current directory, and
the file cases on temporary files. The JSON cases go through
python::json with the hooks of json.loads, so they check the reader
that the egress policy compiler shares. .gitattributes marks the
fixtures, about 1.2 MB, as generated, so that reviews collapse them.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extend nvx::host::artifacts to every artifact of the guests that
nvx.py runs, so that nvx-rs can stand in for the runtime lookups of
scripts/nvx_tools/common.py: artifact_path, openvmm_binary_path, and
the require_file checks of nvx.py run and nvx.py sandbox.

- Guest describes the Alpine, Ubuntu, and Azure Linux guests of
  scripts/nvx_tools/guests.py: their names, distributions, initramfs
  images and package manifests, and the Ubuntu distro layer,
  ubuntu-distro.erofs, with its manifest. Guest::from_name fails as
  guest_descriptor does.
- ArtifactDir names them in a repository checkout, as build/NAME and
  openvmm/target/release/openvmm[.exe], where local builds write them
  and where nvx.py download installs a release, or in an extracted
  release package, as guest/NAME and bin/openvmm[.exe], where
  package_release stages them. ArtifactDir::from_dir tells the
  layouts apart as Artifacts::from_dir does, and makes the directory
  absolute without resolving links, so callers pass the resolved
  REPO_ROOT of nvx.py as it is.
- require_openvmm, require_kernel, and require_initramfs check the
  files with the messages of nvx.py: "OpenVMM release binary not
  found: PATH", "Linux direct kernel not found: PATH", and
  "DISTRIBUTION initramfs not found: PATH". sandbox_artifacts checks
  the OpenVMM executable, the kernel, and the Alpine initramfs in the
  order of nvx.py sandbox run and start, which call the last
  "initramfs".
- paths lists every artifact as ArtifactPaths, which serializes as
  JSON, with the guests in nvx.py's order, for nvx-rs artifacts.

Like nvx.py, the lookup reads no SOURCE-MANIFEST.json, and checks only
the files that a command needs. Artifacts and its discovery, which
aci_edge_sandboxes uses, are unchanged: Artifacts::from_dir only
shares its layout detection, and the systemd policy of the sandbox
options now takes the distro manifest suffix from here.

The tests cover both layouts, the layout of this repository, the
order and messages of the checks, and the JSON form.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Describe in nvx-rs/README.md the nvx::host::sandbox module, with its
mount and options submodules, and the guest artifact lookup that
ArtifactDir, Guest, and ArtifactPaths add to nvx::host::artifacts,
beside the discovery that aci_edge_sandboxes uses. A new Python parity
section explains which Python semantics the ports keep, including the
egress policy compiler, the private python module that implements
them, and the fixtures that scripts/nvx_rs_fixtures.py records, with
the commands that regenerate and check them, the CPython 3.14 that
they need, and why CI does not check them; the egress policy section
points to it. The features table names sha2, which the host feature
now enables.

Update the trees and the nvx-rs summary of doc/project-structure.md
for the sandbox launch profile and the golden fixtures.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add the cli feature, which implies host, and the nvx-rs binary, with
required-features = ["cli"]: the command-line interface to which
nvx.py will delegate its sandbox, egress-policy, and artifact steps
(#359). This commit adds the command-line framework and three
commands; the managed lifecycle of nvx-rs sandbox follows in the next
commit, and until then provision, start, exec, stop, and deprovision
fail after their option checks.

- nvx-rs [--root PATH] artifacts prints ArtifactDir::paths as JSON,
  as json.dumps(value, indent=2, sort_keys=True) writes it. The root
  is --root, which nvx.py will pass, or else the release whose bin/
  holds the executable, or the checkout whose nvx-rs/target/ holds
  it, either with its SOURCE-MANIFEST.json. The NVX_OPENVMM variable
  names an OpenVMM executable to run instead of the root's, such as a
  test double; a relative path is made absolute.
- nvx-rs egress compile PATH prints the rules of compile_policy_file
  as {"allow": [...], "deny": [...]}, each list in the order in which
  OpenVMM receives them, and fails with the compiler's message.
- nvx-rs sandbox ports the sandbox parser and command_sandbox of
  scripts/nvx.py: every option with its name, default, type, and
  choices, and the checks of SandboxOptions::check, the egress policy
  file, the mounts, the launch, and the systemd policy, in Python's
  order and with its messages. A one-shot run prints ">> COMMAND",
  quoted as subprocess.list2cmdline quotes it on Windows and as
  shlex.join does elsewhere, and unless --dry-run runs OpenVMM with
  the standard streams and exits with its status. The artifact checks
  keep the messages and order of nvx.py, and --hypervisor auto
  resolves to whp on Windows and to kvm elsewhere.

The parser reproduces argparse step by step: --opt value and
--opt=value; values that start with a dash only after "=", unless
they look like negative numbers or hold a space; the alternation of
options and positionals; the optional operation positional, which
defaults to run; repeated options that accumulate; and the messages
and status 2 of usage errors, with unrecognized arguments reported by
the top-level parser after the subcommand parsed. It does not expand
abbreviations, since nvx.py will pass canonical options; that is an
intended deviation. Help and usage are argparse's for 80 columns.

Errors print "error: MESSAGE" and exit with status 1, as main() of
nvx.py does for a ScriptError. Ctrl-C prints "Interrupted" and exits
with status 130. A SIGINT handler on Unix, installed only if SIGINT is
not ignored, and a console control handler on Windows record it, and
a run then gives OpenVMM 0.25 s to exit before it kills it on POSIX,
as subprocess.run does, and waits for it on Windows, where Python
cannot interrupt the wait either. Text ends its lines as Python's
print ends them, with \r\n on Windows.

The private python module of #594 and #595 gains dumps, json.dumps
with indent=2, sort_keys=True, and ensure_ascii; shell,
subprocess.list2cmdline, shlex.quote, and shlex.join; and float() in
builtins for positive_float. The sys and platform modules gain the
interrupt primitives, and windows-sys its console bindings.

Unit tests compare the parser with argparse, the Python helpers with
CPython, and cover the discovery of the artifact root. Golden
scenarios recorded from nvx.py follow in a later commit.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port scripts/nvx_tools/sandbox_lifecycle.py, and the ControlSession of
control_session.py that it uses, so that nvx-rs sandbox provision,
start, exec, stop, and deprovision keep the contract of nvx.py
sandbox: the same state directory, files, formats, locks, messages,
outputs, and exit statuses. Either implementation can operate a
sandbox that the other provisioned or started.

State, in sandbox/state.rs:
- config.json in the lowest of formats 1 to 6 that holds the sandbox,
  and runtime.json, format 1, with the pid, start_time, and
  control_endpoint of OpenVMM. Both are written as json.dumps(value,
  indent=2, sort_keys=True) + "\n" writes them, ASCII-escaped and with
  \r\n line ends on Windows as write_text produces them, through a
  temporary file, chmod 0600, and a rename.
- Reading applies Python's semantics to whatever another writer left:
  strict UTF-8, == comparisons of the format, str() and int() of the
  fields, and the order and messages of _deserialize_launch.

Locks, in sandbox/lock.rs: lifecycle.lock is locked as Python locks
it, so the implementations exclude each other: flock(LOCK_EX |
LOCK_NB) on POSIX, and on Windows CreateFileW with read, write, and
delete sharing and FILE_FLAG_OPEN_REPARSE_POINT, then LockFile of the
first byte, as msvcrt.locking(LK_NBLCK, 1) takes it. The
standalone-regular-file checks, the retries every 25 ms within
--timeout, the hand-off waits, the "deprovisioned" tombstone, and the
removal of the directory are ported, with Windows errors classed as
CPython maps them to errno.

Process identity, in sandbox/identity.rs, is Python's: on Linux the
start time in /proc/PID/stat, with zombies as exited, and on Windows
the creation time, so that a runtime record that either implementation
wrote is valid for the other.

The lifecycle, in sandbox/lifecycle.rs:
- start generates a 32-byte capability, never all zero, writes
  control.capability, passes the capability on OpenVMM's standard
  input through a pipe closed before OpenVMM starts, appends its
  output to openvmm.log, and starts it detached, in its own session
  on POSIX and process group on Windows, with _start's command line:
  the managed lifecycle, its network options in _start's order, and
  the endpoint STATE/control.sock, or //./pipe/openvmm-microvm-HEX
  with a uuid4 on Windows. It records runtime.json, then connects,
  attaches, and pings. A failed start ends OpenVMM and removes the
  runtime files in _start's order, and reports OpenVMM's exit as
  _startup_exit_message does.
- exec buffers the workload's output, writes standard output, then
  standard error, and exits with the workload's status. With
  --outcome-report, it checks the destination before it runs and
  writes {"operation_id", "outcome", "schema_version": 1} as
  _write_new_json does.
- stop asks the guest to shut down, waits for OpenVMM, reads its
  outcome report, removes the runtime files, and prints nothing, as
  nvx.py sandbox stop does. deprovision removes the state directory.

The session, in session.rs, keeps the observable behavior of
control_session.py on nvx::host::session, whose ControlSession gains
send_request and read_response for callers that check the responses
themselves: connection retries every 25 ms until the timeout, then
"managed control endpoint did not become available: PATH"; one
deadline per operation for its request and its responses; "managed
control request timed out" or "managed control response timed out"
by the direction of the transfer; ControlEndpointClosed for a closed
endpoint, but not for a reset; a session closed after a failed
transfer; and exec's checks, in their order and with their messages,
its exit categories, and its refusals with their status and ASCII
category. Waits run in 250 ms slices, so that Ctrl-C ends them.

Intended deviations: an endpoint that a process other than the
launched OpenVMM serves counts as one that has not appeared yet, as
in nvx::host::session; OpenVMM starts without a console window on
Windows, as nvx::host::process starts it; exec refuses more than the
1 MiB of output that the guest agent forwards; and where Python would
end with a traceback, as for a configuration without a field that
_start reads, nvx-rs prints "error: MESSAGE" with the exception's
message and exits with status 1.

The base modules gain what the port needs: process::Launched waits
for, terminates, and kills OpenVMM as Popen does; the Windows platform
module locks the first byte of a file and reads exit codes; and the
python module adds value, str(), int(), and == of JSON values, and
lstat, lexists, and fstat with st_file_attributes on Windows.

Unit tests cover the formats, the JSON writer, identity parsing, the
locks, and the lifecycle helpers. The tests of control_session.py and
golden scenarios recorded from nvx.py follow.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port scripts/test_control_session.py to the session of nvx-rs
sandbox, in host/cli/session/tests.rs, except the cases that the tests
of nvx::host::session, whose framing the session reuses, already
cover. Session::connect now attaches through Session::attach, which
the tests call on their own transports.

The ControlSessionTests run on an in-memory stream pair, like the
socketpair of the Python tests, with a guest thread that plays
OpenVMM's endpoint: exec streams output and returns its status;
refuses relative entrypoints, more than 64 arguments, and invalid
arguments, timeouts, working directories, environments, and response
timeouts before it sends anything; encodes the legacy and extended
requests, with their working directory and omitted, empty, replaced,
or layered environments, exactly; accepts the whole u32 timeout range;
refuses unknown exit categories, including "cancelled", which the CLI
never requests; and keeps the status and category of a refusal. A
reset is not a closed endpoint, every transfer of an operation gets no
more than what is left of its timeout, and a failed transfer closes
the session.

The stream tests run on the endpoints of each host, which this
process serves: on POSIX, Unix-domain sockets whose closure and
partial reads report a closed endpoint; on Windows, named pipes whose
closure reports a closed endpoint after the data that they held, and
whose read timeout closes the session. A stalled endpoint, which
reads nothing, times out the request within its timeout on both
hosts, and an endpoint that never appears times out with its path.
The Windows tests create their pipe servers through windows-sys, so
their module allows unsafe code.

Not ported: the capability-pipe tests, since the pipe is
nvx::host::process::spawn's, which writes the capability and closes
the pipe before OpenVMM starts, and which every start of the golden
lifecycle scenarios exercises, since the fake OpenVMM requires exactly
the capability and the end of the file on its standard input; the
mocked transfers that move nothing, which the closed-endpoint tests
cover; reads that time out without losing data, which
nvx::host::session's timed_out_polls_keep_partial_records covers; and
the cancellation of an interrupted pipe write, since the CLI checks
Ctrl-C between waits and bounds each write by its timeout.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
As the #359 discussion decided, nvx-rs sandbox start now negotiates
the guest's control features, with a required set of its own rather
than GuestFeatures::REQUIRED, which describes the direct agent of
nvx::host::instance. This is the one intended change from nvx.py
sandbox start, which does not ask.

GuestFeatures::LAYERED_SANDBOX holds exactly what the CLI's launches
use: EXEC_ENVIRONMENT (bit 4), because exec sends a working directory
and an environment in an extended EXEC. It holds no CANCEL (bit 0):
the CLI never sends APP_CANCEL, since, as nvx.py did, Ctrl-C ends an
exec by closing the session, and the guest agent ends the workload
when OpenVMM resets the session that it ran for. It holds no
WORKLOAD_ACCOUNT (bit 2), which no sandbox launch requests and the
layered root does not perform (#566). The NVX guest agents, the Rust
agent of #561 and the C agent before it, advertise bits 0, 2, and 4 in
sandbox mode.

After its PING, start sends FEATURES. An agent that predates the
request refuses it as an unsupported operation and so provides none.
If the guest lacks a required feature, start ends OpenVMM and removes
the runtime files, as a failed start does, and fails with

  error: the guest image lacks control features that layered
  sandboxes need (per-execution environments); use an NVX release or
  build that includes them

which names the missing features with GuestFeatures::missing, as the
reject_guest of nvx::host::instance does.

Tests cover the required set, the features responses of current,
older, and misbehaving agents, and, with the golden scenarios that
follow, a start against guests that lack the feature or predate the
request.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extend nvx::testing::fake_openvmm, and so nvx-fake-openvmm, to the
sandbox profile that nvx.py sandbox and nvx-rs sandbox launch, so that
both can run end to end without a VM. A --microvm-sandbox-block option
selects the profile; direct launches, which nvx::host::instance and
aci_edge_sandboxes use, are unchanged.

- The fake accepts the sandbox's arguments and checks them as OpenVMM
  would: read-only distro, runtime, and custom layers and one scratch
  disk, each an existing file; nvx_sandbox=1, one nvx_entrypoint=,
  and one nvx_hostname= on the kernel command line; one --mount of a
  directory, or the --mount-child children of an aggregate at
  /run/nvx/shares; --mount-owner and --network-proxy; and the network
  options, which it summarizes as OpenVMM's report does.
- --microvm-lifecycle managed serves the control console as before,
  with the capability on standard input, and its guest advertises
  GuestFeatures::SANDBOX_AGENT. The guest targets of directory shares,
  from --mount and the nvx_share= tokens of an aggregate, are
  directories that a workload can enter.
- --microvm-lifecycle one-shot reads no capability and serves no
  console: it runs the entrypoint and arguments of the kernel command
  line on its own standard streams, /bin/echo, /bin/sleep, /bin/true,
  or /bin/false, with any other program not found and status 127,
  writes an outcome report of operation "run" if asked, and exits with
  the workload's status, or with fake_exit_status=STATUS.
- The outcome reports of sandboxes take the form of OpenVMM's: the
  schema version, a random instance ID, the backend, the outcome, the
  network policy, and every teardown step, as JSON with a final
  newline.

The existing knobs keep working for sandboxes, among them
fake_exit_on_start, fake_guest_features, and fake_legacy_guest, which
the golden scenarios and the guest-feature tests use. The fake
OpenVMM tests still pass unchanged.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Record what nvx.py sandbox does, as a command, and replay it with
nvx-rs in tests that need no Python, so that the CLI stays faithful to
nvx.py after PR D deletes the Python code.

scripts/nvx_rs_fixtures.py, the generator of #595, now also writes
nvx-rs/tests/fixtures/cli.json, which its single --check covers. It
builds nvx-rs and nvx-fake-openvmm with cargo and plays scenarios in
fresh temporary roots, each with fake guest artifacts and a copy of
nvx-fake-openvmm as the root's OpenVMM executable:

- 24 usage errors and helps of the sandbox parser, 69 option and
  launch checks of command_sandbox, 11 dry runs, 11 one-shot runs,
  the lifecycles of sandboxes in every configuration format, and 8
  scenarios of the refusals and failures of the lifecycle operations,
  215 steps in all on POSIX and 209 on Windows, where a caller-owned
  share, and so format 3, is refused;
- nvx-rs egress compile and nvx-rs artifacts, from the Python
  functions that they port, compile_policy_file and the paths of
  scripts/nvx_tools/common.py and guests.py.

Each step records its exit status, standard output and error, and
the files that it leaves, such as config.json in formats 1 to 6,
runtime.json, the state directory's entries and modes, outcome
reports, and the command line that start passed to OpenVMM. The
generator runs nvx.py in a subprocess, with artifact_path and
openvmm_binary_path patched, unbuffered and with UTF-8 standard
streams, no terminal size, and no colors. It replaces the root, as
printed and as JSON escapes it, process IDs, start times, and random
identifiers with placeholders, and the program names of nvx.py with
those of nvx-rs, whose parser names have the same length, so usage
wraps alike. The posix and windows sections hold the scenarios of each
kind of host, recorded there; the script rewrites or checks the
section of the host that runs it and keeps the other. Recording needs
CPython 3.14.5 or later, whose argparse quotes the choices of an
invalid choice, as nvx-rs does.

The script then plays every scenario with nvx-rs, and each lifecycle
with nvx.py and nvx-rs taking turns both ways, so that each provisions,
starts, executes in, stops, and deprovisions a sandbox of the other,
and fails if any step differs from nvx.py. Both sections pass on
Windows and Linux.

nvx-rs/tests/cli.rs replays the section of its host with nvx-rs,
four scenarios at a time, in canonical temporary roots. It also
checks what nvx-rs adds: a start against guests that lack per-execution
environments or predate the features request, the default root of the
executable in a checkout, a relative NVX_OPENVMM, the top-level usage
errors, and on Linux, Ctrl-C during a run, which ends OpenVMM, and
during an exec, which leaves the sandbox running. .gitattributes marks
the fixture, about 370 KB, as generated.

This changes the generator of #595 only by adding the CLI section.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Describe in nvx-rs/README.md the nvx-rs CLI that the cli feature
builds, which PR D's nvx.py will run:

- its commands and how it finds the artifact root: --root, which
  nvx.py will pass, or else the release or checkout that holds the
  executable, and the NVX_OPENVMM override;
- the JSON forms of artifacts and egress compile, and the contract of
  sandbox: its options, outputs, exit statuses, outcome reports, and
  the state directory that it shares with nvx.py;
- the guest features that start requires, and why bits 0 and 2 are not
  among them;
- the other intended differences from nvx.py;
- the test hooks, --root, NVX_OPENVMM, and the fake OpenVMM's sandbox
  profile, and the golden scenarios with their interoperability
  checks.

The layout, module, and features tables, the Python parity and
testing sections, and the introduction mention the CLI, and
doc/project-structure.md lists it in the nvx-rs tree and summary.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A terminal's Ctrl-C reaches the whole foreground process group, so
nvx-rs sandbox run and the OpenVMM that it runs on the terminal
receive it together. run_openvmm polled OpenVMM before it checked
for the interrupt, so when OpenVMM had already exited from the same
Ctrl-C, the run returned OpenVMM's status instead of ending as
interrupted: on Unix, a SIGINT-ended OpenVMM made nvx-rs exit with
254 and print nothing, where nvx.py prints "Interrupted" and exits
with 130. Python's wait raises KeyboardInterrupt once SIGINT arrives,
whether or not the child has exited meanwhile.

run_openvmm now checks for the interrupt after each poll and before
it reports a status, and only waits for, then kills, an OpenVMM that
is still running. main() also lets a recorded Ctrl-C decide how any
command ends, as KeyboardInterrupt does wherever it finds a Python
command, so that a command that finished while Ctrl-C arrived still
prints "Interrupted" and exits with 130.

A new Linux test sends SIGINT to the CLI's whole process group, as a
terminal does, during a one-shot run whose fake OpenVMM the signal
ends at once. It failed with status 254 before this change, in three
of three runs, and passes now; the test that interrupts the CLI alone
still passes.

Reported in the review of #596.

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.

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.

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. Mocks cover an interrupted wait and
the restoration of the handlers. With NVX_RS set, a test checks the
JSON of the real nvx-rs against what nvx.py reads.

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>
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.
- 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 it, so that the scenario can scan it. 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.
- 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 every teardown step 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, a failed test, a violation on the console, an
invalid report, and an unrefused transition. The sandbox stand-in
writes a full report, and three new regressions show that the
lifecycle acceptance catches another backend, a requested network
policy, and an incomplete teardown. 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>
Open PR #576 (jay23606, refs #556) narrows the sandbox to one EROFS
distro layer and the scratch disk: the runtime and custom layers go,
and their MMIO windows and interrupts stay unassigned. #576 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 #576 is open, 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.
- guest/common/nvx-init-agent mounts and unmounts only the distro
  layer, and refuses a second layer: #576's change, verbatim.
- The sandbox-blocks scenario attaches the distro layer and scratch,
  and its guest script checks for only vda and vdb, for no vdc, and
  that the cmdline assigns neither 0xd0004000 nor 0xd0005000, with
  #576's codes 30, 31, and 32. Its unit test expects two blocks.
- doc/run.md and doc/usage.md describe one distro layer, as in #576.

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, with #576's and #579's
changes 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>
converter_input_sha256() hashes an inventory of the files that
customize the Ubuntu guest, in the order that sorted() gives their Path
objects. That order depends on the OS: PosixPath compares path
components with case, and WindowsPath without it. Since #561 added
nvx-rs/Cargo.toml and nvx-rs/Cargo.lock to those files, beside
nvx-rs/build.rs, Windows sorts build.rs first and Linux last, so
Windows computes another digest than the Linux hosts that build the
Ubuntu artifacts and record it in their manifest. `nvx.py package` on
Windows then fails with "Ubuntu artifact manifest does not match
initramfs-ubuntu.cpio.gz", which would fail the windows-whp package of
the next development release.

The inventory now takes the files in the order of their POSIX paths
relative to the repository, component by component and with case. That
is the order that Linux has always used, so the digests of existing
artifacts and manifests stay valid: on Windows, this tree now gives the
digest that the Linux-built manifest records. The callers' own order no
longer matters. The Alpine and Azure Linux input digests already sort
by POSIX path strings, which compare alike everywhere.

A test feeds the files in both orders and checks the inventory's order,
Cargo.toml before build.rs and src/cli/args.rs before src/cli.rs, as
Linux sorts them; it fails on Windows without the fix.

This fixes an issue in code that #561 exposed, found while packaging
nvx-rs on Windows for this series.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx.py runs the nvx-rs that NVX_RS names or else the one in
nvx-rs/target/release, and never rebuilds one that exists, so nothing
tells whether that executable is a build of the checkout's nvx-rs
sources. OpenVMM has the same problem and solves it with
build/openvmm.provenance.json, which binds the digest of its binary to
its gitlink revision. Releases are about to ship nvx-rs, and packaging
and `nvx.py download` need the same binding for it.

build/nvx-rs.provenance.json now records, for the nvx-rs that nvx.py
runs:

- source_sha256: a digest of the files that build nvx-rs, the crate's
  Cargo.toml, Cargo.lock, build.rs, and src/ tree, and
  rust-toolchain.toml, by path and with line endings normalized, so
  that LF and CRLF checkouts of the same sources agree. The tests,
  README.md, and artifacts.json build nothing, and stay out;
- toolchain: the Rust release that rust-toolchain.toml pins;
- target: the Rust target of the build;
- features: ["cli"], which BUILD_COMMAND builds;
- executable_sha256: the digest of the executable.

nvx.py records it after each Cargo build that it makes, for the host
target that `rustc -vV` reports. The new `nvx.py
record-nvx-rs-provenance [--target TARGET]` command records it for a
build made otherwise, of the executable that NVX_RS names or else of
nvx-rs/target/release/nvx-rs[.exe]. The caller vouches that this
checkout built that executable for that target; the record lets later
steps check the claim against the sources. RELEASE_TARGETS names the
targets that the NVX CLI jobs build for releases,
x86_64-unknown-linux-musl and x86_64-pc-windows-msvc, and a record for
the musl target requires a static ELF executable, since a build for the
host's own Linux target is dynamic.

The new constants live in nvx_tools/nvx_rs.py, which no guest artifact
digests, so the existing guest provenance stays valid.

doc/usage.md documents the command and the record, doc/build.md the
file beside the other provenance sidecars, nvx-rs/README.md who
records it, and doc/ci.md that the Copilot setup's first nvx-rs build
records it too.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
#359 makes nvx-rs the backend of nvx.py sandbox and of the egress and
artifact steps of nvx.py run, so a release that nvx.py download
installs is of little use without it. Every package now ships the
release build of nvx-rs beside OpenVMM:

- bin/nvx-rs in the Linux packages, the static
  x86_64-unknown-linux-musl executable that runs on every x86-64 Linux
  host, and bin/nvx-rs.exe in the Windows package, the
  x86_64-pc-windows-msvc executable with a static C runtime;
- provenance/nvx-rs.provenance.json, its build provenance;
- an nvx_rs section in SOURCE-MANIFEST.json with the executable's
  digest, the source digest, the Rust release, the target, and the
  features;
- both files in SHA256SUMS, as every packaged file is.

`nvx.py package` takes them from nvx-rs/target/release and build/, as
it takes OpenVMM, and refuses a provenance that does not describe the
executable as a build of the checkout's nvx-rs sources, with its Rust
release and features, for the release target of the host's OS, and a
Linux executable that is not static. It checks the staged copies
against the provenance, as it does for OpenVMM. Source-inclusive
packages ship nvx-rs too; their project source archive already holds
its sources.

CI provides the build. Each NVX CLI job now records the provenance of
the nvx-rs that it built and tested, with --target, and uploads it as
nvx-rs-linux-musl-provenance or nvx-rs-windows-msvc-provenance beside
the executable. The workflows now name the pair by its base name, as
they name the OpenVMM artifacts, so run-nvx-microvm-tests.yml appends
-executable itself. run-platform.yml takes the base name and passes it
to the package-release action, which downloads both into place before
it packages. The platform jobs already wait for their CLI job.

Before it publishes, publish_development_release.py checks, from each
archive's member list, that every platform's package ships bin/openvmm
and bin/nvx-rs, or their .exe forms, so that a development release
without them never goes out.

The bundled feature of nvx-rs extracts and checks only OpenVMM, the
Alpine guest, and SOURCE-MANIFEST.json of a release, so a release with
bin/nvx-rs stages as the pinned older release does, and the pin stays.
The synthetic release of the check-nvx-rs action now has the current
layout, with nvx-rs and its provenance in the bundle and in its
SHA256SUMS.

doc/distribution.md, doc/build.md, doc/usage.md, doc/ci.md, and
nvx-rs/README.md describe the package contents and the handoff.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Packages now ship bin/nvx-rs[.exe], which statically links the Rust
standard library, the crates of the nvx-rs cli feature, and a C
runtime, so THIRD_PARTY_NOTICES.md, which every package ships, must
cover them, as it has covered the Rust guest binaries since #561.

The crate rows follow `cargo tree --locked --features cli -e normal`
in nvx-rs for both release targets, x86_64-unknown-linux-musl and
x86_64-pc-windows-msvc: 26 crates, with the licenses that
`cargo metadata` reports.

- The rows of the crates that the guest binaries already link, the
  Rust standard library, compiler_builtins, libc, nix, getrandom,
  bitflags, cfg-if, and memoffset, now name bin/nvx-rs too.
- New rows cover serde, serde_core, serde_json and its itoa, memchr,
  and zmij, sha2 and its RustCrypto dependencies, and windows-sys and
  windows-link on Windows. serde_derive and the proc-macro2, quote,
  syn, and unicode-ident crates that it runs on are marked as build
  time only, not linked.
- The musl row now includes the bin/nvx-rs of the linux-kvm and
  linux-mshv packages, under the musl terms that it already
  documents.
- A new LLVM libunwind row covers the unwinder that the Rust musl
  target links into the guest binaries and the Linux nvx-rs, under
  Apache-2.0 WITH LLVM-exception, which lifts the attribution
  conditions for compiled code.
- A new Microsoft C runtime row covers the static CRT of
  bin/nvx-rs.exe, under the Visual Studio redistribution terms.

doc/distribution.md says the same in its licensing section. A test
parses nvx-rs/Cargo.lock, requires each of its crates to be classified
as shipped or not, and checks that every shipped crate has a row with
its exact version that names bin/nvx-rs, and that the runtime rows name
it too.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The platform jobs packaged a release only on dev pushes, so a pull
request that broke packaging, as one that changes what a package ships
can, passed CI and failed the next development release instead. The
Windows packaging failure that the first commit of this series fixes
had gone unnoticed that way.

The Package release step of run-platform.yml now runs whenever the
platform packages releases, and tells the package-release action
through a new upload input whether to upload the archive: true only on
dev pushes. The action stages the package, which checks every input
against its provenance, and archives it, which checks the staged files
against SHA256SUMS, on every run, and uploads the archive for the
development release only when asked. Pull requests still publish
nothing; the release job runs only on dev pushes. Packaging adds about
a minute and a half to each Linux platform job and twenty seconds to
the Windows one.

doc/distribution.md describes it, and a test checks the conditions.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx.py download installed OpenVMM and the guest artifacts of a release,
so nvx.py sandbox and nvx.py run still needed Cargo to build nvx-rs
afterwards. It now installs the package's bin/nvx-rs[.exe] at
nvx-rs/target/release/nvx-rs[.exe], where nvx.py finds it, and its
provenance at build/nvx-rs.provenance.json, so that both commands run
without Cargo after a download.

nvx.py runs that executable without ever rebuilding it, and download
may fall back to the release of an earlier first-parent commit, so it
installs nvx-rs only when the package's provenance describes the build
of this checkout's nvx-rs sources that nvx.py runs: the same source
digest, Rust release, and features, for the release target of this OS.
Otherwise, as for a release from before packages shipped nvx-rs, it
installs everything else, removes any earlier nvx-rs and its
provenance, which may not match the checkout either, and says that
nvx.py builds nvx-rs with Cargo when a command needs it, unless NVX_RS
names one. Older releases thus stay installable. A package whose nvx-rs
does not match its own provenance is broken, and fails the download
before it replaces anything.

The installed OpenVMM and nvx-rs are now executable on POSIX whatever
mode the archive records.

The message that nvx.py prints without Cargo, the discovery notes in
nvx_rs.py, doc/usage.md, doc/setup.md, doc/distribution.md, README.md,
and nvx-rs/README.md describe the installed nvx-rs.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The adversarial executor validates and hashes OpenVMM, the kernel, the
initramfs, and their provenance before the baseline boot, so that a
campaign runs what the staged commit built. Scenarios such as
l3-l4-egress-policy also run nvx-rs through nvx.py, which never
rebuilds an nvx-rs that exists, so one left from before a change of the
nvx-rs sources ran unchecked.

validate_runtime_artifact_provenance() now also requires the nvx-rs
that nvx.py runs, the one that NVX_RS names or else
nvx-rs/target/release/nvx-rs[.exe], with a build/nvx-rs.provenance.json
that describes it as a build of the checkout's nvx-rs sources, for any
target. The executor's artifact metadata hashes both, beside the other
artifacts. A stale, unrecorded, or missing nvx-rs now fails the
initialization instead of running.

Targets already had to build nvx-rs before exposure; the preparation
in doc/adversarial-testing.md now also records its provenance, which
nvx.py does itself for the builds that it makes, and a target with the
release of the staged commit gets both from nvx.py download. doc/ci.md
and doc/usage.md mention the check.

Closes #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

Executable path consistency and thread-aware process liveness need correction before approval.

2 open issues
What changed in this PR

Ships nvx-rs with NVX releases, completing the release-delivery portion of #359 so compatible downloads can run delegated commands without Cargo.

Changes:

  • Adds executable provenance, packaging, installation, and publication checks.
  • Passes nvx-rs artifacts through CI and validates them in adversarial execution.
  • Includes the stacked Rust CLI, guest-build migration, tests, and documentation updates.
File Description
SOURCE-MANIFEST.json Records Rust guest toolchain and sources.
scripts/​test_tool_versions.py Checks musl target setup.
scripts/​test_guest_binaries.py Tests source identities and staged builds.
scripts/​test_development_release.py Tests required release executables.
scripts/​test_adversarial.py Tests executable discovery and metadata.
scripts/​setup/​setup-linux-runner.sh Explicitly selects the target toolchain.
scripts/​setup/​setup-linux-mshv.sh Installs and verifies musl support.
scripts/​publish_development_release.py Checks packages before publication.
scripts/​nvx_tools/​ubuntu.py Normalizes and orders build inputs.
scripts/​nvx_tools/​sandbox_lifecycle_tests.py Updates lifecycle and teardown checks.
scripts/​nvx_tools/​microvm_test_scripts/​sandbox-blocks.sh Tests distro-only block layout.
scripts/​nvx_tools/​guest_binaries.py Validates staged Rust guest binaries.
scripts/​nvx_tools/​ci.py Tracks Rust guest build dependencies.
scripts/​nvx_tools/​build.py Installs shared Rust guest binaries.
scripts/​nvx_tools/​build_constants.py Defines Rust build and source packaging inputs.
scripts/​nvx_tools/​benchmark.py Records Rust guest provenance.
scripts/​nvx_tools/​azurelinux.py Includes normalized Rust build inputs.
scripts/​nvx_tools/​archive.py Excludes generated Rust build output.
scripts/​nvx_tools/​adversarial_executor.py Validates and records nvx-rs.
README.md Explains prebuilt CLI availability.
nvx-rs/​src/​protocol/​mod.rs Updates shared-protocol documentation.
nvx-rs/​src/​protocol/​features.rs Defines layered sandbox requirements.
nvx-rs/​src/​host/​sys/​mod.rs Exposes interrupt handling.
nvx-rs/​src/​host/​sys/​interrupt.rs Records Unix interrupts.
nvx-rs/​src/​host/​session.rs Exposes CLI request and response methods.
nvx-rs/​src/​host/​sandbox/​layer.rs Defines distro-layer parsing and validation.
nvx-rs/​src/​host/​python/​uuid.rs Implements Python-compatible UUID parsing.
nvx-rs/​src/​host/​python/​shell.rs Implements command display quoting.
nvx-rs/​src/​host/​python/​mod.rs Organizes Python compatibility helpers.
nvx-rs/​src/​host/​python/​dumps.rs Implements compatible JSON formatting.
nvx-rs/​src/​host/​process.rs Adds CLI process-control methods.
nvx-rs/​src/​host/​platform/​windows.rs Adds Windows CLI primitives.
nvx-rs/​src/​host/​platform/​other.rs Provides interrupt compatibility hooks.
nvx-rs/​src/​host/​platform/​linux.rs Exposes Linux interrupt hooks.
nvx-rs/​src/​host/​mod.rs Exposes sandbox and CLI modules.
nvx-rs/​src/​host/​cli/​sandbox/​identity.rs Checks sandbox process identity.
nvx-rs/​src/​host/​cli/​output.rs Implements CLI stream output.
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 and reports runtime artifacts.
nvx-rs/​src/​guest/​time/​mod.rs Updates guest-time documentation.
nvx-rs/​src/​guest/​time_probe/​mod.rs Updates probe documentation.
nvx-rs/​src/​guest/​reseed.rs Removes obsolete C references in comments.
nvx-rs/​src/​bin/​nvx-rs.rs Adds the CLI entry point.
nvx-rs/​Cargo.toml Declares 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 paths.
guest/​common/​nvx-exit.c Removes the replaced C implementation.
guest/​common/​nvx-console-pending.c Removes the replaced C implementation.
docker/​Dockerfile Shares Rust binaries across guest builds.
doc/​setup.md Documents Rust build prerequisites.
doc/​run.md Documents delegated sandbox execution.
doc/​project-structure.md Describes the Rust-backed layout.
doc/​guests/​ubuntu-guest.md Documents Rust guest binaries.
doc/​guests/​azurelinux-guest.md Documents guest binary provenance.
doc/​distribution.md Documents CLI packaging and installation.
doc/​contribute.md Clarifies Rust ownership and testing.
doc/​ci.md Documents artifact handoff and release checks.
doc/​build.md Documents build provenance and package layout.
doc/​benchmarks.md Documents Rust benchmark provenance.
doc/​adversarial-testing.md Documents target CLI requirements.
aci_edge_sandboxes/​src/​openvmm/​mod.rs Updates shared egress documentation and tests.
aci_edge_sandboxes/​README.md Documents egress behavior changes.
.github/​workflows/​run-platform.yml Packages on PRs without publishing.
.github/​workflows/​run-nvx-microvm-tests.yml Downloads the CLI for scenarios.
.github/​workflows/​copilot-setup-steps.yml Tracks all Rust source changes.
.github/​copilot-instructions.md Updates build and runtime guidance.
.github/​actions/​validate-nvx/​action.yml Removes replaced Python test suites.
.github/​actions/​package-release/​action.yml Downloads CLI artifacts and gates uploads.
.github/​actions/​check-nvx-rs/​action.yml Updates the synthetic release layout.
.github/​actions/​build-guest-artifacts/​action.yml Keys caches on Rust source identity.
.gitattributes Marks frozen compatibility fixtures generated.

Review effort: Balanced

Comment thread scripts/nvx_tools/adversarial_executor.py
Comment thread nvx-rs/src/host/cli/sandbox/identity.rs Outdated
nvx-rs sandbox identifies the OpenVMM of a running sandbox by its
process ID and start time, which identity.rs reads on Linux from
/proc/PID/stat. Ported from the former sandbox_lifecycle.py, it counted
every zombie as exited. But /proc reports a process as a zombie as soon
as its main thread exits, while its other threads may still run and
hold its descriptors, so exec and stop could reject a live VM as stale,
and deprovision could remove the state of a VM that still runs. The
library's platform::process_start_time, with which the process helpers
wait for and kill OpenVMM, already counts such a process as running
until its last thread exits, as Windows does, and so does the CLI's own
Windows branch; only its Linux branch disagreed.

linux_start_time now asks, for a zombie only, whether other threads
remain, through the platform's other_threads_remain, which reads
/proc/PID/task, and otherwise keeps its Python-compatible parsing and
messages. other_threads_remain becomes pub(crate) for that.

The stat-line test covers a zombie with and without other threads, and
an error while listing them. A new Linux test, like
process::tests::a_process_runs_until_its_last_thread_exits, makes the
main thread of a Python process exit through pthread_exit while another
thread sleeps: process_running holds until the process is killed. It
fails without the fix.

Copilot's review of #598 flagged this in code that #596 added, the
nvx-rs CLI; the platform helper is already on dev. The change stays
within the CLI's Linux identity check.

Part of #359.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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 17, 2026 11:53

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 release, provenance, and lifecycle changes need final human review and confirmation of platform CI results.

No open issues 🎉

2 resolved since last review

Review effort: Balanced

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.

nvx-rs: make the nvx-rs CLI the backend of nvx.py sandbox, egress, and artifact lookup

2 participants