Repository navigation
[LXC] State-aware sandbox lifecycle - #849
Darren Hoehna (dhoehna) wants to merge 161 commits into
Conversation
…) (AB#62953349) Provision/start/exec/stop/deprovision for the LXC backend, modeled on IsolationSessionRunner; reuses lxc CLI wrappers and one-shot lxc-attach PTY streaming. Registers the lxc wire key in Rust dispatch/parser and SDK state-aware routing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3b78bec0-e139-4cfd-9c10-092ef986d4f4
… + narrow LXC start network type Restrict is_valid_container_name to the same character set and length bound (<=20 chars, alphanumeric/-/_) that NetworkIptablesManager::new uses to derive the per-container iptables chain name. This makes the container-name -> chain-name mapping an identity on valid names, so distinct names (e.g. 'a.b' vs 'ab', or names differing only past the 20th char) can no longer collide onto the same firewall chain and cross-tear-down each other's rules. Narrow LxcStartConfig.network to Omit<NetworkConfig, 'proxy'> so the SDK rejects network.proxy at compile time, matching the Rust runner which rejects it at start (apply_network_policy). Adds Rust + TypeScript tests for both. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…aware_provision.json) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e5d2aa5b-7f04-4e4d-83d3-a02efe7020ab
Resolved conflicts: - src/core/lxc/src/main.rs: kept state-aware imports; dropped now-unused ScriptRunner - src/Cargo.lock: took main's lock, reconciled via cargo metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e5d2aa5b-7f04-4e4d-83d3-a02efe7020ab
Re-run GitHub Actions after a transient Hyperlight E2E network flake (hyperlight_networking live-HTTP cases timed out after 30s). No source changes; this empty commit only re-fires the pull_request workflows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e5d2aa5b-7f04-4e4d-83d3-a02efe7020ab
- Route Lxc state-aware dispatch through mxc_engine::run_state_aware so the lxc binary stays a thin CLI shim instead of hand-rolling the backend match. - Stop/destroy the container before tearing down its iptables rules in stop(), deprovision(), and the start() rollback, discovering the veth first so the FORWARD hook rule can still be deleted after the device is gone. Closes an unrestricted-egress window during teardown. - Clear lxc.mount.entry before reapplying filesystem mounts so a restart with a tightened policy no longer inherits the previous run's bind mounts (new LxcContainer::clear_config_item). - Kill the timed-out child's whole process group and bound the output drain in mxc_pty::run_with_pty so a leaked in-container process holding the pty open can no longer hang exec forever (new join_with_timeout helper). Adds unit/regression tests for each fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Resolve conflict in wxc_common/src/state_aware_dispatch.rs: register both the `lxc` (this PR) and `wsb` (upstream microsoft#578) state-aware backend prefixes in backend_from_prefix, and keep both resolve_backend unit tests. Added `correlation_vector: None` to the lxc test to match the ParsedStateAwareRequest field introduced upstream (microsoft#624). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tate-aware-lifecycle # Conflicts: # sdk/node/src/state-aware-helper.ts # sdk/node/src/state-aware-types.ts # sdk/node/tests/unit/state-aware-types.test.ts # src/backends/lxc/common/src/filesystem_mounts.rs # src/core/wxc_common/src/state_aware_backend.rs
Resolve conflict in src/backends/lxc/common/src/filesystem_mounts.rs as a union of both changes: - microsoft#633 mount-accumulation fix: clear_config_item("lxc.mount.entry") before re-deriving the policy's mounts (so a restart replaces, not unions, mounts). - upstream microsoft#630 denied-dir masking: rebound_container_paths / has_rebound_descendant, iterating &mounts. Both new unit tests (configure_filesystem_mounts_replaces_not_accumulates and has_rebound_descendant_detects_nested_rebind_only) are kept. Validated with `cargo check -p lxc_common --tests` (native linux/liblxc, WSL). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b6b3323b-7297-4b07-9e6e-ab4b220124e6
…art, SDK surface Five review findings, all cases where the state-aware LXC path reported success while enforcing less than the caller asked for. - Hook FORWARD with -i, not -o. Container-originated packets arrive at the host on the host-side veth, so egress matches by input interface. `-o` matched traffic flowing toward the container, so container egress -- the thing the policy exists to restrict -- was never filtered for the whole runtime. The teardown `-D` uses `-i` for the same reason, or the hook leaks; `force_cleanup` shares that path so signal and stop/deprovision cleanup stay consistent. - Stop start() from failing open. `apply_firewall_rules` treats a non-firewall enforcement mode as a successful no-op, and only warns when no veth was discovered. Since `enforcementMode` defaults to `capabilities` and LXC has no capability-based network enforcement, a policy with allowedHosts/blockedHosts/defaultPolicy=block was silently unenforced. Start now rejects that combination, and fails when the veth cannot be discovered in firewall mode. - Narrow LxcNetworkConfig to what LXC actually honors. It was `Omit<NetworkConfig,'proxy'>`, which still exposed `removeRulesOnExit` (SDK-only, and `wire::Network` is `deny_unknown_fields`, so sending it fails the whole request) and `allowLocalNetwork` (deserializes, but the LXC backend never turns it into a rule). `enforcementMode` is restricted to the firewall modes to match the runtime check above. The existing type test asserted the old shape and is updated. - Export the LXC state-aware types from the package entry point. They were missing from sdk/node/src/index.ts, unlike the IsolationSession and WindowsSandbox equivalents, so consumers could not import them. - Document the containerId contract. The API doc said state-aware shapes never carry containerId; LXC provision does. Documents the adopt-or-create behavior and, importantly, that deprovision destroys an adopted container too -- MXC keeps no state between phases, so it cannot tell the two apart. Adds the missing LXC row to the policy-honor matrix. Also applies `cargo fmt`, which fixes the failing format check. Tests: 478 Rust (cargo test -p lxc_common -p wxc_common -p lxc) and 210 SDK (npm test) pass; clippy on the Linux crates is clean; fmt is clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
…xc executors The lxc executor's state-aware entry point called mxc_engine::run_state_aware directly, while wxc's wrapped the same call in telemetry init, backend/phase process attribution, the MS-CV seed/spin plan, the crash panic hook, and the terminal emit_state_aware event. So a Linux lifecycle produced no lifecycle telemetry, carried no correlation vector -- provision returned no cV for the client to relay into later phases -- and installed no crash hook. Every one of those is invisible at the call site, which is how it stayed unnoticed. Moves that orchestration into mxc_engine::run_state_aware_with_telemetry and calls it from both entry points, so the two cannot drift again. Executors keep their own terminal behavior (buffer flush, stdout envelope, exit code); only the observability wrapper is shared. The correlation-vector helpers move with it, along with their six tests -- ported verbatim rather than rewritten, so coverage is unchanged. Also fixes a misleading error from exec_state_aware. LXC has no streaming SandboxProcess, but the fallback arm reported "backend Lxc does not implement the state-aware lifecycle" -- untrue, since run_state_aware dispatches every phase for it, and it points at a provision path that works fine. The message now separates "no lifecycle at all" from "lifecycle but no streaming exec" and names the API that does work. Streaming exec for LXC is still unimplemented; this only makes the gap legible. Tests: 498 Rust on Linux (mxc_engine, lxc, lxc_common, wxc_common), 40 on Windows (wxc, mxc_engine); clippy clean on both; fmt clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
…suite tests/configs/lxc_state_aware_provision.json was checked in but no script ever executed it, and run_lxc_all_tests.sh had no state-aware entry at all -- only one-shot cases. The unit tests stub the container, so nothing exercised provision -> start -> exec -> stop -> deprovision against a real host: a phase that was broken on Linux would still ship green. Both other backends already have such a script (run_isolation_session_state_aware_tests.ps1, run_windows_sandbox_state_aware_tests.ps1); LXC is the odd one out. Adds run_lxc_state_aware_test.sh, which relays the provisioned sandboxId through every later phase the way a real client does, asserts the lxc:mxc- prefix, and checks that exec relays a nonzero script exit code rather than swallowing it. Later phases are generated with the sandboxId injected, matching how the PowerShell suites build requests inline; only provision reads a static config, so the distribution/release stay in one place. sandboxId is extracted with sed rather than jq or python, neither of which is guaranteed on an LXC test host. An EXIT/INT/TERM trap deprovisions on any early failure or signal, since a leaked container outlives the run and breaks the next one; the normal path clears the id first so the container is not deprovisioned twice. Verified against a stub lxc-exec (no LXC host needed): the happy path passes 8/8 with the sandboxId relayed into start/stop/deprovision, a failing phase is counted and exits nonzero without double-deprovisioning, and a SIGTERM mid-run triggers exactly one cleanup deprovision of the right sandbox. bash -n clean, LF endings so the suite's CRLF guard passes, mode 100755. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
The enforceability gate added in the previous commit used default_network_policy == Block as evidence that the caller asked for a restriction. NetworkPolicy::default() *is* Block, so a start with no `network` block at all produces exactly that value alongside the default enforcementMode of `capabilities` -- and was rejected. That breaks every plain start, including the basic lifecycle in run_lxc_state_aware_test.sh, so the backend advertised a lifecycle its own E2E test could not complete. Once the wire `network` block is flattened into ContainerPolicy, an explicitly requested `defaultPolicy: "block"` is indistinguishable from no block at all, so it cannot be the trigger. Gates on the host lists instead, which are empty unless the caller populated them -- the same reasoning has_network_policy already uses to ignore default_network_policy. allowedHosts/blockedHosts under a non-firewall mode are still rejected, which was the actual fail-open. Documents the residual gap rather than hiding it: `defaultPolicy: "block"` alone is not enforced under `capabilities`, and callers who want a default-deny container must set enforcementMode explicitly. Adds a regression test built from ContainerPolicy::default() -- the exact policy a plain start produces -- so this cannot silently come back. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
Two resolutions needed: - src/core/wxc/src/main.rs: upstream kept the correlation-vector helpers and added log_state_aware_dispatch_error next to them; this branch had moved the helpers into mxc_engine so the lxc executor could share them. Kept the move and kept upstream's new helper and its call site. Mirrored the same diagnostic-error routing into the lxc executor, which is the whole point of sharing the orchestration -- upstream improved one entry point and the other would otherwise have drifted again immediately. - src/core/wxc_common/src/state_aware_dispatch.rs: upstream added a source_text field to ParsedStateAwareRequest and updated every struct literal it could see. resolve_backend_for_lxc_prefix_returns_lxc is added by this branch, so it was invisible to that sweep and broke the build after a clean textual merge. Added the field. Tests: 558 Rust on Linux, 43 on Windows, 210 SDK; clippy and fmt clean on both platforms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
The merge commit c9001a3 swept 1,691 untracked files into the branch: sdk/node_modules (1,615), sdk/dist (52), and sdk/dist-tests (24). They were produced by running npm ci / the SDK build locally to collect test numbers, and none of them are tracked at the merge base (33f3033) or on main. Untracked with 'git rm -r --cached'; the files stay on disk locally. No source change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
The same bad 'git add' in merge commit c9001a3 rewrote this file with CRLF endings. The repo stores it as LF, core.autocrlf is false, and .gitattributes only pins *.sh to LF, so git recorded the flip verbatim and the whole file showed as rewritten: 1707 insertions / 1949 deletions for what is really a 5 insertion / 247 deletion refactor. Converted back to LF. The diff for this file is now identical with and without --ignore-all-space. No source change; cargo check and cargo fmt pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea24fae3-d643-4f19-a701-9e31b9f950fd
…tate-aware-lifecycle
configure_filesystem_mounts cleared every lxc.mount.entry line from the container config on each start, which deleted non-MXC baseline mounts the distribution template or the operator had placed there. Tag each MXC-added mount with a marker comment (set_mxc_mount_entry) and reclaim only marker-tagged entries on restart (clear_mxc_mount_entries), leaving foreign lxc.mount.entry lines intact. The generic clear_config_item is retained for other keys and its tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
NetworkIptablesManager::new sanitized and truncated the container name to MXC-<name>, so two containers whose names shared a prefix, or differed only in characters the sanitizer strips, collapsed onto one chain -- tearing down one then flushed and deleted the other's rules. This held even though the name-validation layer bounded lengths, because new() is also reached from the signal-time force_cleanup path with the raw name. Fold a deterministic FNV-1a hash of the full, unsanitized name into the chain name (MXC-<=15 sanitized>-<8 hex>, <=28 chars, within the netfilter limit). Distinct names now always produce distinct chains, independent of caller-side validation. Update the container-name rationale comment accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
remove_firewall_rules deleted the FORWARD jump only when the manager still remembered the veth interface it hooked. A teardown that never learned the veth (signal-time force_cleanup, or a veth that was never discovered) left the jump installed; the chain then stayed referenced and the following -X failed, leaking the whole chain across container lifetimes. Enumerate the live FORWARD chain (iptables -S FORWARD) and delete every rule that jumps to this chain by its -j target, so the hook is removed whatever interface it was scoped to. Parsing is factored into forward_hook_deletions for testability. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The network policy was applied *after* container.start(), leaving a window (roughly the container's boot time) in which a container with a deny policy had unrestricted network. The reviewer flagged this as the most serious finding. Move the firewall install ahead of start. iptables accepts an interface name that does not exist yet, so pin a deterministic host-side veth name (lxc.net.0.veth.pair = mxcv<hash>, reusing the chain-name hash and fitting the 15-char IFNAMSIZ limit) in the container config, build the chain and its FORWARD hook against that name, and only then start the container -- the veth comes up already filtered. A firewall-install failure now aborts the start instead of proceeding fail-open, and a failed start tears the rules back down. This removes the post-start veth discovery and wait_for_network from the start path (discovery is still used by stop/deprovision teardown). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A non-dry-run exec streams the container's raw PTY output directly to the executor's stdout during backend.exec(). If the dispatch then returns Err, the JSON error envelope was printed to that same stdout, so a consumer parsing stdout as JSON saw the envelope glued onto the tail of the raw output. Capture whether this run is a streaming exec (Phase::Exec && !dry_run) before parsed is moved into the telemetry-wrapped dispatch, and in the error branch send the envelope to stderr for that case while every other phase keeps stdout as its single client-facing channel. Factor the serialisation into error_envelope_string so the stdout and stderr paths share one builder and the last-resort fallback, mirroring the wxc sibling executor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
trap cleanup EXIT INT TERM ran cleanup twice on a signal: once for the signal handler and once for the EXIT that the shell then fires. The deprovision phase was issued twice for the same sandbox. Guard cleanup with a CLEANED_UP flag so the teardown body executes at most once regardless of how many trapped events fire. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ec gap The state-aware section listed only isolation_session and windows_sandbox and omitted lxc, which the engine dispatches on Linux (non-experimental). Add lxc to both the prose backend-support note and the API-at-a-glance comment, and disclose the one real limitation: streaming exec (execInSandbox / IPty) returns unsupported_phase for lxc, so callers must use the non-streaming execInSandboxAsync. No network policy field shapes are touched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Recover the presence signal the wire carries but the parser discarded, then fix the three coupled default-policy defects a reviewer raised on PR microsoft#633. The wire type `Network::default_policy` is `Option<NetworkPolicy>`, so the config distinguishes an explicit `defaultPolicy: "block"` (`Some(Block)`) from no network block at all (`None`). `config_parser` flattened that `Option` into the non-`Option` `ContainerPolicy::default_network_policy`, whose struct default is already `Block`, erasing the distinction. `ContainerPolicy` is the internal lowered representation, not the wire schema: it is not reachable from `schema_for!(MxcConfig)`, carries no `JsonSchema` derive, appears in no generated SDK binding, and is never serialized across a process boundary, so recovering the bit needs no schema change. Add an additive internal `default_network_policy_present: bool` to `ContainerPolicy`, set by the parser when the wire value was present. The struct's existing `#[serde(default)]` keeps the field backward-compatible. With the bit restored: - `has_network_policy` now honors an explicit default policy, so a config whose only network setting is `defaultPolicy` is recognized as having a policy. - `requires_firewall_enforcement` now returns true for an explicit `defaultPolicy: "block"`, so under a capabilities (non-firewall) mode the start is rejected fail-closed instead of running the default-deny unenforced. - The already-running ("adopted") container path in `start()`, which keys off `has_network_policy`, now returns `already_started` instead of silently reporting success and bypassing the default-deny. The absent case (default-constructed policy, presence bit false) is unchanged: a plain start with no network block is still not rejected. Updates the one test whose premise this change invalidates (`default_policy_alone_does_not_require_firewall_enforcement`, renamed to `explicit_default_block_requires_firewall_but_absent_or_allow_does_not`) to assert the new distinction while keeping the absent-block invariant. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Black-box spec tests derived from the chain_name_for / name_hash contract. The implementation file was never read; every assertion traces to a quoted contract clause. Properties covered: * Injectivity (collision-freedom) over a 200+ name adversarial corpus including the two named families from the contract: shared prefix past the truncation point, and names differing only in sanitizer-stripped characters. * Length bound ≤ 28 characters, asserted over the same corpus. * Shape: MXC- prefix + ≤ 15 sanitized chars + - + 8 hex digits. * Determinism: repeated calls return identical results; FNV-1a regression pins lock the hash values across builds. * name_hash covers the full unsanitized name (hashes differ for inputs that sanitize identically). * NetworkIptablesManager::new stores chain_name consistent with chain_name_for. Mutation test results (all caught): 1. Hash zeroed via AND 0 at truncation point — 4 failures. 2. Hash computed over sanitized name (strip non-alnum/dash) — 3 failures. 3. Hash truncated to 4 hex digits — 1 failure. 4. Sanitized segment widened past 15 chars (.take(20)) — 4 failures. 5. FNV prime bumped by XOR 1 — 2 failures. lxc_common test count: 70 → 81 (+11). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Finding 1 (Medium) -- documentation stated the opposite of the code. The paragraph at mxc-state-aware-sandbox-api.md:1616 said defaultPolicy: "block" was indistinguishable from an absent policy and would NOT be enforced under capabilities. e3e657a inverted that: the parser now records default_network_policy_present so an explicit block IS distinguishable, and requires_firewall_enforcement returns true for it. Rewrote the paragraph to describe actual behavior verified from state_aware.rs:158-163 and :227-233. Finding 2 (Low) -- rejection message named only allowedHosts/blockedHosts. A caller rejected solely for defaultPolicy: "block" received a message that mentioned only allowedHosts/blockedHosts. Extended the message to name the explicit default policy as an additional trigger alongside the host lists while keeping the existing voice and error type. Finding 3 (Low) -- parser assignment for the presence bit was untested. Existing parser tests asserted only the flattened NetworkPolicy value, not default_network_policy_present. Added three end-to-end parser tests: absent defaultPolicy -> presence false; explicit "block" -> presence true + value Block; explicit "allow" -> presence true + value Allow. Mutation proof: deleting the single assignment policy.default_network_policy_present = true; from config_parser.rs with anchor count=1 produced 562 passed / 2 failed (block_sets_presence_true and allow_sets_presence_true). Restore confirmed byte-identical. wxc_common test count: 561 -> 564 (+3). lxc_common test count: 81 (unchanged). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The iptables chain/veth name derivation truncated the FNV-1a hash to its low 32 bits, so two attacker-chosen container names could collide onto a byte-identical chain (proven: "web-frontend-017m3b" and "web-frontend-01kgar" both -> "MXC-web-frontend-01-3d4a49a5", found in 793,379 candidates). A teardown then flushes and deletes the incumbent container's chain and FORWARD hook, leaving it running with no firewall -- fail-open. Retain the full 64-bit FNV-1a hash and encode it as a fixed 11-char base36 token (hash mod 36^11, ~2^56.9), shared by both names: chain = "MXC-" + <=12 sanitized + "-" + 11 base36 = 28 chars (netfilter) veth = "mxcv" + 11 base36 = 15 chars (IFNAMSIZ) The sanitized allowance shrinks 15 -> 12 to fit the wider token. Determinism is preserved (no RandomState/DefaultHasher) so force_cleanup reconstructs the same names cross-process. Adversarial collision search moves from ~2^32 (sub-second) to ~2^56.9 (infeasible); a 2,000,000-name shared-prefix sweep now yields zero collisions. This is collision-resistant, not injective -- corrected the five places that claimed "always distinct" / "collision-free" / "injectivity" to say so. Tests: converted the collision proof into a regression test, added length-bound, shape, exact-string cross-process determinism, and 64-bit hash pins; renamed the former "injectivity" test to state it checks a near-miss corpus only. All 5 mutants of the derivation are caught. lxc_common 81 -> 88 passed / 0 failed / 0 ignored; wxc_common 564 passed unchanged. No schema files touched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The hash_token comment stated 36^11 = 131_601_804_755_189_760. The correct value is 131_621_703_842_267_136. The code is unaffected -- MODULUS is computed as 36u64.pow(11), so only the comment was wrong -- but a reviewer checking the width argument against the stated number would have found it did not add up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52fb9e8c-4a48-435c-9964-8ec0fee653c6
The doc comment on chain_name_for stated that finding a collision requires ~2^56.9 work and was infeasible to search adversarially. That conflated second-preimage with collision resistance. 36^11 is ~56.87 bits, so the generic birthday work to find some colliding pair is ~2^28.4, and FNV-1a is non-cryptographic - every step is a bijection, so it inverts rather than needing a search. A caller that picks its own containerId can therefore construct a colliding pair and make teardown of one name remove the other's chain. Defending against that needs persisted ownership verification, not a wider hash. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52fb9e8c-4a48-435c-9964-8ec0fee653c6
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 76 out of 78 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
tests/scripts/run_lxc_experimental_provision_fields_test.sh:205
- This assertion contradicts the script's stated ability to run without an LXC runtime. On such a host the well-formed request stops at the availability check with
backend_unavailable, notbackend_error, so the test fails even though field validation behaved correctly. Either require/skip when LXC is absent if backend-error mapping is part of this test, or acceptbackend_unavailablehere while still rejecting the field-validation diagnostics.
sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:24
- Adding this enum value exposes
ProvisionSandbox(StateAwareContainment.Lxc), but the managed API has no LXC provision options andBuildProvisionEnvelopehas no LXC case. Consequently it can never serialize the requiredexperimental.lxc.provision.distributionandrelease, so every .NET LXC provision call is rejected. Add the LXC-specific provision/start option types and envelope serialization before advertising this backend, or leave LXC out of the enum.
/// <summary>Linux container managed by LXC.</summary>
Lxc,
| /// The lowest schema version LXC's state-aware lifecycle accepts. | ||
| pub const STATE_AWARE_MIN_SCHEMA_VERSION: &str = "0.8.0"; |
| // LXC's network surface is the directional field set, which a pre-0.8 schema | ||
| // does not enforce. On the shared default a policy would be accepted and no | ||
| // firewall installed. | ||
| export const LXC_STATE_AWARE_VERSION = '0.8.0-alpha'; |
A configuration file that names something the backend cannot use produced a running container and a log line. Two entries behaved that way. An egress destination that does not parse -- 10.0.0.0/33, 10.0.0.0/+24, 10.0.0.0/20/8, /24, a blank string -- resolved to nothing, warned, and the remaining entries were programmed. The container then ran under a policy the author never wrote, and the one entry they got wrong was the one silently missing. In proxy mode the allow list is never resolved at all, so the warning could not fire there even in principle. An environment entry that is not KEY=VALUE was dropped where it was translated into lxc-attach arguments, and the script ran with an environment differing from the configured one in no observable way. Both are now refused ahead of container creation and firewall programming, so a rejected request leaves nothing to roll back. The cut is malformed against unresolved, not warning against error. A typo matches nothing on any host at any moment and is decidable without touching the network. A well-formed hostname whose lookup failed is a fact about this moment's DNS, and it keeps the existing warning: erroring there would make sandbox startup depend on live DNS, and an allow that goes unwritten can never widen what the container reaches. The two existing hard errors for an unresolvable deny entry are untouched. Schema 0.8 is unaffected. Its peers arrive as an already-parsed NetworkCidr, so a malformed destination cannot reach the backend. The legacy allowedHosts and blockedHosts lists are raw strings that nothing validates before this point. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52a2970c-c3e6-49c8-8879-6e8354341216
A configuration could name a network posture LXC never applied, and the run
reported success. The caller was told their policy was in effect while the
container held whatever network the host gave it. Four cases behaved this
way, each warning to the log and continuing.
LXC now refuses the run and names the setting:
- An allow entry that resolves to no address. No rule can carry it, and
under a blocking default the destination the configuration names stays
unreachable.
- An allow list supplied beside a proxy. Proxy mode permits the proxy
endpoint and nothing else; the listed destinations were never programmed.
- An enforcement mode of 'capabilities' on a configuration that states a
network posture. This backend filters with iptables and has no capability
mechanism for the network, and installed no rule in either direction.
- A host whose IPv6 stack is loaded but not yet addressed. This was read as
a kernel without IPv6 and produced IPv4-only rules. An address arriving
during the run left IPv6 egress unfiltered. It is now treated as unknown,
which the surrounding code already fails closed on.
The refusals stay narrow. A configuration that states no network posture is
not asking for enforcement and still runs, which is what keeps configurations
written before the enforcement mode existed working.
This changes behavior for existing 0.7 callers. A configuration carrying
'defaultPolicy' or a host list without an explicit enforcement mode used to
run with an open network and now fails. That is the defect, not a
regression: those callers never had the policy they asked for.
Two E2E tests asserted the old silence and were rewritten. A new refusal
suite covers the remaining cases with a control proving a posture-free
configuration still runs.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 52a2970c-c3e6-49c8-8879-6e8354341216
Six passages described behavior this branch replaced. A reader following them would write a configuration expecting a bad entry to be dropped and the run to carry on, and would instead get a refusal. The network contract states that a backend rejects configurations it cannot enforce, outside the exceptions a backend's own document records. The three fail-open passages here were exactly such exceptions. Removing them is what returns this backend to the contract's default rather than merely tidying prose. Corrected: - a malformed process.env entry fails the run instead of being dropped - an allowedHosts entry that resolves to nothing fails firewall setup - a destination that could never match anything is refused before any chain is created, separately from a well-formed name that resolves to nothing today - host IPv6 that is loaded but not yet addressed reads as unknown, not as off - the capabilities refusal covers any stated network posture, not only a proxy - egress and inbound both key on the existence of if_inet6, so the asymmetry this document called deliberate and one-directional no longer exists Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52a2970c-c3e6-49c8-8879-6e8354341216
lxc_runner.rs is stored LF on this branch and CRLF on main. An editor running on Windows rewrites it with CRLF on any edit, which re-commits all 1,890 lines as a change and buries the real diff inside a whole-file rewrite. That is what happened when this branch's own history normalized the file. The rule pins the stored ending for this backend's sources. Adding it renormalizes nothing: no .rs file under the LXC backend is stored CRLF today, so the only file in this commit is .gitattributes itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52a2970c-c3e6-49c8-8879-6e8354341216
There was a problem hiding this comment.
🟡 Changes recommended
The .NET API is incomplete, exact-version contracts diverge, and LXC exec timeout isolation remains bypassable.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:24
- Adding
Lxchere exposes it as a usable .NET lifecycle backend, but the .NET surface cannot build a valid LXC request. There is noLxcProvisionOptions,BuildProvisionEnvelopehas no LXC arm to emit the requiredexperimental.lxc.provision.{distribution,release}, andValidateProvisionOptionsacceptsnull; consequentlyProvisionSandbox(StateAwareContainment.Lxc)always reaches Rust without the required fields and fails asmalformed_request. The start API also only acceptsStateAwarePhaseOptions, so .NET callers cannot supply the filesystem/network policy that LXC applies at start. Add backend-specific provision/start option types and envelope serialization (plus typed metadata) before registering this enum value.
Lxc,
sdk/node/src/state-aware.ts:169
- This channel assumption is already false for the new LXC path.
LxcStateAwareRunner::execrelays workload output to stdout before returning, and a late failure such as timeout then appends the error envelope to that same stream; the added test atstate-aware.test.ts:464-489confirmstryParseErrorEnvelope(stdout)fails and the SDK resolves anExecResultinstead of throwing the documented typedMxcError. Please use a framing/separate-channel mechanism for dispatch failures (or otherwise make late failures unambiguous) rather than documenting stdout as envelope-only.
// The cross-backend contract (§7.3) reserves stdout for the response
// envelope in every phase, so a dispatch failure is always found there and
// no backend needs its own channel rule.
src/core/wxc_common/src/wire.rs:590
- The new rolling wire field is not represented in the per-version
0.9.0-alphacontract.mxc_config_contract/src/dev/state_aware/provision/mod.rsstill has only IsolationSession/WindowsSandbox/WSLC provision variants, and the dev adapter explicitly setslxc: None; the exact schema and generated exact SDK oracle therefore also omit state-aware LXC. This leaves rolling and exact parsing inconsistent and will reject LXC when exact dispatch becomes authoritative. Add the closed LXC provision contract, adapter, exact-contract tests, and regenerate the0.9.0-alphaschema/types alongside this wire change.
- Files reviewed: 89/91 changed files
- Comments generated: 1
- Review effort level: Balanced
| let marker = mint_exec_marker(); | ||
| signal_cleanup::set_active_exec(container_name, &marker); | ||
| // Cleared unconditionally: an empty env would otherwise leave | ||
| // `lxc-attach` in keep-env mode, inheriting this process's environment | ||
| // and the credentials in it. | ||
| let outcome = container.attach_run( | ||
| &request.script_code, | ||
| &request.working_directory, | ||
| &request.env, | ||
| true, | ||
| timeout, | ||
| Some(&marker), |
A dry run answers "would this configuration be accepted?", and a caller acts on that answer without ever starting a container. A configuration naming a proxy under 'capabilities' enforcement got two different answers: the dry run reported success, and the real run refused it. The person reading the dry run was told their proxy configuration was fine. The refusal lived in the execution path, which a dry run skips by design. It now lives in the validation hook, which runs before the dry-run answer is produced. Both paths give the same refusal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52a2970c-c3e6-49c8-8879-6e8354341216
There was a problem hiding this comment.
🟡 Changes recommended
Network enforcement remains bypassable, while managed SDK and schema contracts are incomplete.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (6)
Previously missed (1) — in code that hasn't changed since the last review.
sdk/node/src/state-aware-types.ts:129
- This statement conflicts with the new lifecycle-wide schema floor: an explicit pre-0.8 LXC version is rejected before the start policy is evaluated, so it does not merely record these fields and skip the firewall. Document the rejection instead.
src/backends/lxc/common/src/network_ingress.rs:509
- This rule only blocks the bridge gateway address, but
hostLoopback: denycovers every container-to-host path. A container can address another IP assigned to the host (for example its LAN address); Linux locally delivers that packet to the host even though the destination is not the bridge gateway, so this OUTPUT rule does not match it. Enforce the deny in the host namespace using the veth plus a local-destination match (or enumerate all host addresses), otherwise the default 0.8 posture still exposes host services.
src/backends/lxc/common/src/state_aware.rs:1043 - The ingress policy is not an enforcement boundary against the sandboxed workload. LXC exec runs as container root with
CAP_NET_ADMIN, so contained code can flush the INPUT/OUTPUT rules installed in its own network namespace and restore inbound/host reachability. Move these controls to host-owned rules scoped to the host-side veth, or removeCAP_NET_ADMINbefore advertising ingress enforcement.
sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:24 - This public enum value cannot actually provision an LXC sandbox.
BuildProvisionEnvelopehas no LXC options arm,ValidateProvisionOptionsaccepts(Lxc, null), and the resulting request omits the requiredexperimental.lxc.provision.distribution/release, so Rust always returnsmalformed_request; there is also no start-options type for LXC filesystem/network policy. Add the LXC-specific option types and serialization, or do not expose/accept this backend in the managed lifecycle API.
/// <summary>Linux container managed by LXC.</summary>
Lxc,
src/core/wxc_common/src/wire.rs:590
- LXC was added only to the rolling wire model. The closed
0.9.0-alphacontract still omits LXC from its state-aware containment/provision unions, and the dev adapter hard-codeslxc: None; consequently the generated exact schema/types reject an LXC lifecycle request even though the backend accepts 0.9. Per the repository's dual-contract workflow, add the matching exact contract and adapter support and regenerate the exact artifacts.
sdk/node/src/state-aware-helper.ts:23 - This introduces a backend-specific schema default outside the canonical version source.
schemas/schema-version.jsonhas no LXC state-aware entry, andcheck-schema-versions.jsvalidates only the shared and WSLC defaults, so the new TypeScript and C# constants can drift independently. Add a canonicalstateAwareLxcvalue and extend the drift gate to check both SDKs.
// LXC's network surface is the directional field set, which a pre-0.8 schema
// does not enforce. On the shared default a policy would be accepted and no
// firewall installed.
export const LXC_STATE_AWARE_VERSION = '0.8.0-alpha';
- Files reviewed: 89/91 changed files
- Comments generated: 2
- Review effort level: Balanced
| if policy.network_mode_specified { | ||
| return Err( | ||
| "network.enforcementMode='capabilities' names a posture LXC cannot \ | ||
| enforce. This backend filters with iptables and has no capability \ | ||
| mechanism for the network, so under 'capabilities' no rule is \ |
LXC's state-aware lifecycle is not stable yet, and a caller could reach it without opting in. It now requires --experimental like the other experimental backends, and reads as experimental in the docs. The wire contract declares LXC's state-aware provision shape, so the published schema and both SDKs can describe an LXC config. The Node SDK stamps 0.8 on an unversioned LXC request, the lowest version LXC accepts, and carries a caller-supplied container name through instead of generating one. LXC's rule about proxying without a firewall now lives in the LXC backend rather than the shared parser, and covers a stated capabilities mode as well. LXC has no capability mechanism for the network, so a legacy config naming that mode is refused rather than run with nothing enforced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c3c1dc0e-d369-4375-b472-5e7b832ee8a9
There was a problem hiding this comment.
🔵 Needs a closer look
LXC is incomplete in the exact contract and .NET SDK, while timeout handling and legacy mount migration can produce incorrect or unsafe behavior.
Review details
Suppressed comments (4)
sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:24
- Adding
Lxcto the public state-aware enum exposes a backend that the managed SDK cannot actually provision: there is noLxcProvisionOptions,BuildProvisionEnvelopehas no LXC arm, andValidateProvisionOptionsacceptsnullfor LXC. The resulting envelope omits the requiredexperimental.lxc.provision.distributionandrelease, so everyProvisionSandbox(StateAwareContainment.Lxc)call is rejected. The start API also cannot carry LXC's filesystem/network policy. Please add the LXC-specific provision/start option types and serialization, or do not expose this enum value yet.
/// <summary>Linux container managed by LXC.</summary>
Lxc,
src/core/wxc_common/src/wire.rs:590
- The rolling wire model now accepts
experimental.lxc, but the exact 0.9 contract still has only IsolationSession, Windows Sandbox, and WSLC provision variants, and this PR's exact adapter explicitly setslxc: None. Thus a valid 0.9 LXC lifecycle request is accepted by today's parser but rejected by the future-authoritative exact parser. Updatemxc_config_contract::dev, its adapter, and regenerated exact schema/TypeScript oracle together as required by the parser transition.
src/backends/lxc/common/src/state_aware.rs:1146 - A timeout is converted here into
backend_errorafterattach_runhas already streamed workload output.lxc-execthen appends an error envelope to that same stdout, soexecInSandboxAsynccannot parse the envelope and returns an ordinary nonzeroExecResult; the new SDK test explicitly characterizes this gap. This also contradicts the lifecycle contract, which says runtime timeouts surface as sentinel exec exit codes rather than typed wire errors. Preserve the timed-out process's sentinel exit status on the executor path instead of returningMxcErrorafter output has begun.
src/backends/lxc/common/src/filesystem_mounts.rs:215 - The replacement only recognizes mount entries carrying the new marker, but MXC versions before this change appended their own
lxc.mount.entrylines without a marker. A preserved/adopted container first configured by an older binary therefore keeps those legacy MXC mounts forever; restarting it with a narrower policy still exposes the old host paths. Please provide a migration/ownership mechanism for pre-marker MXC entries rather than treating every unmarked entry as operator-owned.
// Derive the whole mount set first, then commit it in one config rewrite.
// liblxc accumulates `lxc.mount.entry` lines across restarts, so the
// previous run's MXC mounts have to go; committing the replacement one
// entry at a time meant a crash or a rejected path partway through left a
// durable config matching no policy anyone wrote. Only MXC's own entries
// are replaced -- baseline mounts the distribution template or the operator
// placed in the config carry no marker and survive.
- Files reviewed: 93/95 changed files
- Comments generated: 0 new
- Review effort level: Balanced
The shared state-aware surface now describes an exec by how its stdio is carried rather than by who is calling, and LXC still referred to the caller. LXC refuses the same request as before, on the same terms, and the Linux build compiles again. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c3c1dc0e-d369-4375-b472-5e7b832ee8a9
There was a problem hiding this comment.
🟡 Changes recommended
Contract-version, .NET provisioning, timeout signaling, compatibility, and failed-provision cleanup issues remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 93/95 changed files
- Comments generated: 2
- Review effort level: Balanced
| if created { | ||
| container | ||
| .create(&config.distribution, &config.release) | ||
| .map_err(|e| MxcError::backend_error(format!("Failed to create container: {e}")))?; |
There was a problem hiding this comment.
This seems to be a legit issue
There was a problem hiding this comment.
🔵 Needs a closer look
Legacy mounts can retain removed filesystem access, and the exact-contract and C# integrations are incomplete.
Review details
Suppressed comments (4)
src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs:57
- The new LXC state-aware shape exists only in the rolling wire model; this adapter still hard-codes
lxc: None, and the closed dev contract has no LXC provision variant. Consequently the generated exact 0.9 schema rejects a request that the current parser accepts, so switching exact dispatch to authority would break this feature. Add LXC tomxc_config_contract::devand this adapter, then regenerate both exact artifacts as required bydocs/versioning.md:204-214.
sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs:395 - This makes LXC publicly routable, but
ValidateProvisionOptionsaccepts onlynullfor LXC andBuildProvisionEnvelopehas no LXC options arm. The resulting envelope therefore omits requiredexperimental.lxc.provision.distributionandrelease, so every C# LXC provision call fails asmalformed_request, with no API capable of supplying the fields. Add an LXC provision-options type, validate it, serialize its fields, and cover the envelope in the C# tests before exposing this enum case.
StateAwareContainment.Lxc => LxcContainment,
sdk/node/src/state-aware-helper.ts:21
- This new backend-specific schema default is not represented in
schemas/schema-version.json, andscripts/versioning/check-schema-versions.jsvalidates only the shared and WSLC defaults. The new Node and C# LXC constants can therefore drift silently, contrary to the repository's canonical-version rule. Add a canonical LXC state-aware field and extend the sync gate (or derive this from an existing canonical field).
// LXC refuses any state-aware request below 0.8.
export const LXC_STATE_AWARE_VERSION = '0.8.0-alpha';
src/backends/lxc/common/src/filesystem_mounts.rs:317
- This replacement only removes entries preceded by the new
# mxc-managed-mountmarker. Containers preserved by earlier releases have MXC mounts appended without that marker (the removed code calledset_config_itemdirectly), so adopting/restarting one with a tighter policy leaves the old broad mount active alongside the new set. That can preserve filesystem access the caller explicitly removed. Add a safe legacy migration/refusal strategy before treating the marked rewrite as authoritative.
container.replace_mxc_mount_entries(&entries)
- Files reviewed: 93/95 changed files
- Comments generated: 0 new
- Review effort level: Balanced
| _ => StateAwareVersion, | ||
| }; | ||
|
|
||
| private static void ValidateProvisionOptions( |
There was a problem hiding this comment.
This validator has no LXC arm, and no LxcProvisionOptions type exists, so every typed options object falls through to _ => false?
| if !uses_directional_schema { | ||
| return false; | ||
| } | ||
| Self::stated_ingress(policy, uses_directional_schema) |
There was a problem hiding this comment.
is_some_and returns false when there's no ingress section, so this returns false? This is opposite of the comment above: "An absent 0.8 ingress section still denies."
| let veth = enforced_veth_name(container_name); | ||
| let stopped = container.kill(); | ||
| signal_cleanup::clear_active(); | ||
| if let Err(stop_err) = stopped { |
There was a problem hiding this comment.
Can the container exit on its own between start and ingress install? If so, kill() will fail because it's already gone, and this branch reads every failure as "still up", returning early and leaving the egress chain installed, which then blocks every later start of that name.
| if created { | ||
| container | ||
| .create(&config.distribution, &config.release) | ||
| .map_err(|e| MxcError::backend_error(format!("Failed to create container: {e}")))?; |
There was a problem hiding this comment.
This seems to be a legit issue
| loop { | ||
| // sigwait isn't normally interruptible; on the unlikely failure, retry. | ||
| let Ok(sig) = mask.wait() else { continue }; | ||
| let active = std::mem::take(&mut *lock_slot()); |
There was a problem hiding this comment.
stop takes LifecycleLock for exactly this race (state_aware.rs:1168-1172); the watchdog doesn't. A signal between :982 and :995 removes the firewall, the main thread starts the container anyway, and exit leaves it running unfiltered. NetworkOnly stops rather than destroys, so nothing reclaims it, the base's destroy() did.
Shall we quiesce the transition first?
| } | ||
| }); | ||
| } | ||
| std::process::exit(128 + sig as i32); |
There was a problem hiding this comment.
Exiting here drops the process-held flock immediately, but the children this process spawned outlive it?
| // that wait is a running container with no inbound filtering. | ||
| let veth = enforced_veth_name(container_name); | ||
| let stopped = container.kill(); | ||
| signal_cleanup::clear_active(); |
There was a problem hiding this comment.
This runs before cleanup_network_owned, so a signal arriving during that iptables sequence finds slot.name == None, skips rollback entirely, and strands whatever chain or FORWARD hook hadn't been removed yet with no owner left to reclaim it?
main and this branch had each grown their own answer to the same question: how a lifecycle phase gets its correlation vector. This branch had the caller supply one on the wire. main derives it internally from the sandbox id and rejects `correlationVector` as an input, and already carries parser tests refusing the field. main's design wins here -- a caller-supplied vector is a caller-controlled field on a shared contract. Folding that in left `run_state_aware_with_telemetry` still written against the relay design. The shared helper is rewritten onto main's: it derives the phase vector, anchors the effective policy hash, publishes the driver's diagnostic sinks to the dispatch thread, and records the sandbox identity. Both executors now run that one body. Previously only `wxc` did this work, and a Linux lifecycle emitted no telemetry and no correlation vector at all. `lxc` now initializes telemetry for the state-aware path and gets all of it. Telemetry is stable rather than experimental now. The helper takes `telemetry_active` in place of `experimental`. Config-rejection reporting moves out of `wxc`'s `main.rs` into `wxc_common::config_rejection`, where the shared helper can report a refusal without a second copy of the reason mapping. `lxc` covers main's new `ParseError::OneShotMalformed` variant on its existing parse-error arm. It does no rejection logging on any other parse arm, and this one does none either. Verified: the workspace compiles clean, 1337 Rust tests pass, the generated schema and TypeScript regenerate with no drift, and 313 node SDK unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a671670c-ad14-4302-8077-56bca655b58b
main landed verbose Learning Mode diagnostics while this branch was being merged, and it declares a new module and a new re-export in the two files this branch had just rewritten. Both conflicts are additions on either side with nothing in common, so both sides are kept: `mxc_engine` exposes the state-aware telemetry helper and the verbose-telemetry emitter side by side, and `wxc`'s one-shot path emits the verbose artifact before the completion event. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a671670c-ad14-4302-8077-56bca655b58b
There was a problem hiding this comment.
🟡 Changes recommended
The C# lifecycle surface cannot provision LXC, legacy mounts can survive narrowed policies, and SDK/network and rejection-reporting gaps remain.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:23
- Exposing
Lxchere makes it selectable through the C# lifecycle API, but the managed surface cannot construct a valid LXC provision request: there is no LXC provision-options type,BuildProvisionEnvelopenever emitsexperimental.lxc.provision, and the native parser requiresdistributionandrelease. As a result, every C# LXC provision call fails before a container can be created. Add typed LXC provision options and serialize that block before advertising this backend.
/// <summary>Linux container managed by LXC.</summary>
src/backends/lxc/common/src/filesystem_mounts.rs:317
- This replacement only removes entries carrying the new marker. Containers preserved by earlier MXC versions have the same MXC-generated
lxc.mount.entrylines without that marker, so adopting/reusing one after upgrade leaves the old grants active alongside the new policy. Narrowing or removing a mount can therefore still expose the previously granted host path. Please add a migration/ownership strategy for legacy MXC entries (or reject such reuse) before treating the new policy as authoritative.
container.replace_mxc_mount_entries(&entries)
- Files reviewed: 95/97 changed files
- Comments generated: 2
- Review effort level: Balanced
| export interface LxcNetworkConfig { | ||
| defaultPolicy?: NetworkConfig['defaultPolicy']; | ||
| allowedHosts?: NetworkConfig['allowedHosts']; | ||
| blockedHosts?: NetworkConfig['blockedHosts']; | ||
| } | ||
|
|
||
| /** The `FilesystemConfig` fields LXC honors at start. */ | ||
| export interface LxcFilesystemConfig { | ||
| readwritePaths?: string[]; |
| Err(ParseError::OneShot(_)) | ||
| | Err(ParseError::OneShotMalformed(_)) | ||
| | Err(ParseError::Decode(_)) => { | ||
| eprint!("Request error\n{}", logger.get_buffer()); | ||
| process::exit(1); | ||
| } | ||
| Err(ParseError::StateAware(e)) => { | ||
| println!("{}", error_envelope_string(&e)); |
There was a problem hiding this comment.
🟡 Changes recommended
C# cannot construct a valid LXC provision request, and repeated in-process attached execs can leak stdin readers and consume later input.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs:404
- Adding LXC here makes
ProvisionSandbox(StateAwareContainment.Lxc, ...)publicly selectable, but the managed API cannot construct a valid LXC provision request. Native validation requiresexperimental.lxc.provision.distributionandrelease;nulloptions emit neither field, andValidateProvisionOptionsrejects every existing non-null option type. Please add anLxcProvisionOptionstype, accept it in validation, serialize both required fields into the backend provision block, and cover the resulting envelope in the C# tests.
StateAwareContainment.Lxc => LxcStateAwareVersion,
- Files reviewed: 95/97 changed files
- Comments generated: 1
- Review effort level: Balanced
| let outcome = container.attach_run( | ||
| &request.script_code, | ||
| &request.working_directory, | ||
| &request.env, | ||
| true, | ||
| timeout, | ||
| Some(&marker), | ||
| ); |
The merge that folded telemetry into the shared engine helper left three call sites over the line limit and joined two use statements onto one line, which cargo fmt --check rejects. All three lint lanes stopped there, so clippy never ran on the merged tree. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a671670c-ad14-4302-8077-56bca655b58b
Every other Rust source under src/core is stored with LF. This file went in with CRLF, which .gitattributes already warns makes a Windows editor re-commit the whole file as a rewrite. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a671670c-ad14-4302-8077-56bca655b58b
There was a problem hiding this comment.
🔵 Needs a closer look
LXC is unusable through the newly exposed C# provision API, exact-contract support is incomplete, and IPv6 host-loopback enforcement can fail open.
Review details
Suppressed comments (4)
sdk/dotnet/Microsoft.Mxc.Sdk/MxcLifecycle.cs:395
Lxcis now advertised as a provisionable C# backend, butBuildProvisionEnvelopehas no LXC branch andValidateProvisionOptionsaccepts no LXC-specific options. Passingnulltherefore emits no requiredexperimental.lxc.provision.distribution/releaseand native validation rejects it; passing any options is rejected in managed code. Add anLxcProvisionOptionsmodel, validation case, serialization branch, and API tests before exposing this enum value.
StateAwareContainment.Lxc => LxcContainment,
src/core/wxc_common/src/wire.rs:581
- This adds LXC only to the rolling wire model. The exact
0.9.0-alphacontract still has no LXC provision discriminator/variant (mxc_config_contract/src/dev/state_aware/provision), so its generated exact schema and TypeScript oracle reject this new lifecycle surface while the rolling schema accepts it. Mirror the new provision contract and adapter in the exact development model and regenerate both exact artifacts.
src/backends/lxc/common/src/network_ingress.rs:718 - This enforces
hostLoopback: denyonly for IPv4. Provision can adopt an existing single-veth container, so the assumption that every supported bridge has no IPv6 route is not guaranteed; an IPv6-enabled bridge leaves container-to-host connectivity open even though the GA contract requireshostLoopbackin both directions. Resolve and block the host's IPv6 bridge addresses as well, or reject the start when IPv6 is active and that path cannot be closed.
// IPv4 only, matching the one route LXC's default bridge gives the
// container to the host. `lxc-net` ships an IPv4 address and leaves
// `LXC_IPV6_ADDR` unset, leaving no IPv6 path to close.
if Self::denies_host_loopback(policy, uses_directional_schema) {
self.install_host_loopback_drop(&mut runner, logger)?;
src/core/lxc/src/main.rs:332
- This new state-aware parse-failure path writes the envelope but never calls the shared configuration-rejection reporter.
config_rejection.rs:4-9,15-21requires every executor rejection—including pre-dispatch malformed JSON—to emit the common audit/telemetry record, andwxc/src/main.rs:1077-1088does so for the equivalent branch. As written, malformed LXC lifecycle requests are absent from the audit stream.
- Files reviewed: 95/97 changed files
- Comments generated: 0 new
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Ingress remains workload-tamperable, timeout output violates the exec protocol, and both SDK surfaces expose incomplete LXC lifecycle APIs.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
src/backends/lxc/common/src/state_aware.rs:1146
- A timed-out exec reaches this branch as
Err, after its PTY output may already have been streamed to stdout.lxc-execthen appends an error envelope to that same stdout, violating the documented pure-output-or-pure-envelope contract;execInSandboxAsyncconsequently returns an ordinary nonzeroExecResultand loses the timeout reason. Preserve timeout as an execution outcome/sentinel instead of converting it toMxcErrorafter output has begun.
sdk/node/src/state-aware-types.ts:119 - This public start type omits the schema-0.8 directional fields that LXC now enforces. Since the helper sends
0.8.0-alpha, callers cannot expressnetwork.egressrules ornetwork.ingress/hostLoopbackthrough the typed state-aware SDK even though the backend and added fixtures support them. Includeegressandingressin this honored subset.
/** The `NetworkConfig` fields LXC honors at start. */
export interface LxcNetworkConfig {
defaultPolicy?: NetworkConfig['defaultPolicy'];
allowedHosts?: NetworkConfig['allowedHosts'];
blockedHosts?: NetworkConfig['blockedHosts'];
}
sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:24
- Adding
Lxchere exposes a backend that the .NET lifecycle API cannot provision.ValidateProvisionOptionsaccepts onlynullfor LXC, whileBuildProvisionEnvelopehas no LXC case, so every accepted call omits the backend-requireddistributionandreleasefields and fails natively. Add an LXC-specific provision options type and serialize it underexperimental.lxc.provisionbefore advertising this enum value.
/// <summary>Linux container managed by LXC.</summary>
Lxc,
- Files reviewed: 95/97 changed files
- Comments generated: 1
- Review effort level: Balanced
| // IPv4 only, matching the one route LXC's default bridge gives the | ||
| // container to the host. `lxc-net` ships an IPv4 address and leaves | ||
| // `LXC_IPV6_ADDR` unset, leaving no IPv6 path to close. | ||
| if Self::denies_host_loopback(policy, uses_directional_schema) { | ||
| self.install_host_loopback_drop(&mut runner, logger)?; |
Gudge (MGudgin)
left a comment
There was a problem hiding this comment.
Summary
Requesting changes based on the containment and lifecycle blockers below. I re-fetched d3c94a0, confirmed its merge base is the PR base d130c369, and rechecked every finding against that range. Nineteen findings are attached to added lines; the three claim/scope findings are recorded here because they do not have valid code anchors.
The highest-priority fixes are: make the pre-start veth/firewall selector collision-safe, register provision for signal rollback, and make repeated attached exec reclaim its PTY threads/descriptors without adding a five-second delay to silent commands. The lifecycle lock and timeout-reap paths also need bounded failure behavior.
Findings outside the diff / claim locations
Medium (correctness, proportionality) - The statement “One-shot LXC is untouched” is inaccurate. claim_mismatch: one-shot still requires no experimental flag, but this PR adds malformed-environment, proxy/enforcement-mode, multi-interface, and host-resolution refusals and changes firewall setup/teardown behavior. Please narrow the claim and explicitly document the compatibility-impacting one-shot changes, or split them from this PR.
Medium (proportionality) - “Policy is enforced in both directions rather than outbound only” overstates the delta. claim_mismatch: inbound enforcement existed at the base revision; this PR adds host-loopback denial and return-path ownership behavior. Please describe that narrower change so reviewers and users understand the actual new guarantee.
Low (proportionality) - The shared one-shot firewall path grows beyond the stated lifecycle scope. introduced_by_change: deterministic pinning is lifecycle-related, but multi-interface refusal and hook-survival hardening also alter one-shot behavior. Please call out that blast radius in the PR description and release notes.
Verified clean, with receipts
- Supply chain:
Cargo.lockadds no package or version; the manifest changes only add edges to already lockedsemverandserde_jsoncrates. - Experimental gating: state-aware LXC passes through
require_experimental_optinbefore backend dispatch; one-shot remains ungated. - Telemetry/config rejection payloads do not include caller sandbox IDs or raw configuration values.
- The parser explicitly rejects top-level
lxcon state-aware requests and rejectsexperimental.lxcon one-shot requests, so the duplicated configuration locations do not currently have precedence ambiguity.
Verified pre-existing - not attributed to this PR
None retained as findings.
| /// The name must fit the kernel `IFNAMSIZ` limit of 15 characters and be | ||
| /// unique per container, so a `mxcv` prefix (4) is followed by an | ||
| /// 11-character base36 hash token (15 chars total). | ||
| pub fn deterministic_veth_name(container_name: &str) -> String { |
There was a problem hiding this comment.
High (security) - Make the deterministic veth selector collision-safe.
Attribution: introduced_by_change — this PR derives the pre-start packet selector from caller-controlled containerId using an explicitly non-cryptographic FNV token.
If two accepted IDs map to the same veth name, the second start can install top-inserted FORWARD hooks against the first container's interface; a terminal ACCEPT chain can then preempt the victim's deny chain. Teardown also identifies return rules by that interface name.
Fix: derive the token from the existing SHA-256/base32 machinery and fail closed before installing hooks if the target interface already exists. Add collision/pre-claimed-interface tests.
| }; | ||
| if created { | ||
| container | ||
| .create(&config.distribution, &config.release) |
There was a problem hiding this comment.
High (reliability) - Register provision for signal rollback.
Attribution: introduced_by_change — provision performs the long-running lxc-create operation without registering an active sandbox with the new signal-cleanup watchdog.
A termination during image download can leave a partially created container. For a minted name, the caller never received the ID and cannot clean it up.
Fix: register a provisioning rollback before create(), destroy newly created state on signal, and clear the registration after completion.
| // A one-shot executor exits immediately afterward and the OS reaps | ||
| // the thread, but an in-process caller does not, so the thread and | ||
| // the pty fd survive until its host exits. | ||
| let _ = join_with_timeout(output_thread, DRAIN_GRACE); |
There was a problem hiding this comment.
High (performance/reliability) - Repeated attached exec leaks per-call PTY resources.
Attribution: newly_exposed_by_change — state-aware LXC makes this PTY path repeatable from long-lived in-process callers, while this branch explicitly abandons the reader thread and descriptor after timeout. The SIGWINCH and stdin forwarders are also not invocation-scoped.
Fix: retain cancellation handles, close duplicated PTY/self-pipe descriptors, stop and join all forwarding threads, and make the reader cancellable instead of abandoning it.
| // Cleared unconditionally: an empty env would otherwise leave | ||
| // `lxc-attach` in keep-env mode, inheriting this process's environment | ||
| // and the credentials in it. | ||
| let outcome = container.attach_run( |
There was a problem hiding this comment.
High (performance) - Silent state-aware execs wait five seconds before proceeding.
Attribution: newly_exposed_by_change — this new repeated-exec call uses run_with_pty, whose readiness channel is signaled only after the first output byte. A successful silent command reaches EOF without signaling and waits the full default readiness timeout.
Fix: treat EOF/child exit as readiness completion, or skip output-readiness waiting for non-interactive execution. Add a timing regression test using true.
| ))) | ||
| } | ||
| }; | ||
| let guard = nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive) |
There was a problem hiding this comment.
Medium (reliability/test coverage) - Bound and integration-test the cross-process lifecycle lock.
Attribution: introduced_by_change — the new lock blocks indefinitely and is held across external create/start/firewall commands. The current exclusion test runs in one process and does not prove the advertised cross-process serialization.
Fix: use nonblocking flock acquisition against an overall deadline with an actionable busy error, and add a live test launching two concurrent lxc-exec start processes against the same ID.
| /// well-formed hostname currently resolves is a question about this | ||
| /// moment's DNS, and it stays with `resolve_host` and the unresolved-host | ||
| /// warning. | ||
| fn malformed_destination(policy: &ContainerPolicy) -> Option<(&'static str, String)> { |
There was a problem hiding this comment.
Medium (proportionality) - Separate the warn-to-refuse policy change from lifecycle support.
Attribution: introduced_by_change — malformed/unresolvable network and environment cases are converted into hard refusals with a substantial independent test suite. This changes existing one-shot compatibility but is not required for container reuse.
Fix: split the refusal hardening into its own PR, or clearly justify and document the coordinated breaking change here.
| // get a chain built against a name that never exists while its traffic | ||
| // ran unfiltered and MXC reported the policy as enforced. | ||
| let sole_kind = net.sole_kind.as_deref().unwrap_or_default(); | ||
| if sole_kind != "veth" { |
There was a problem hiding this comment.
Low (maintainability) - Share the network-interface eligibility predicate.
Attribution: introduced_by_change — state-aware now independently encodes interface count/type eligibility while one-shot implements the same decision through a differently shaped fallback path.
Fix: extract one shared predicate and keep only the surface-specific fallback behavior separate.
| let mut slot = lock_slot(); | ||
| slot.name = Some(name.to_owned()); | ||
| slot.veth = None; | ||
| slot.rollback = SignalRollback::DestroyContainer; |
There was a problem hiding this comment.
Low (maintainability) - Consolidate the active-cleanup setters.
Attribution: introduced_by_change — the three setters repeat the same slot initialization and differ only in rollback mode and marker.
Fix: add a private set_active_with(name, rollback, marker) helper and delegate the public variants to it.
| runner: &mut dyn CommandRunner, | ||
| logger: &mut Logger, | ||
| ) -> Result<(), String> { | ||
| if !self.host_loopback_dropped { |
There was a problem hiding this comment.
Low (test coverage) - Test host-loopback rule teardown semantics.
Attribution: introduced_by_change — this new teardown distinguishes “already absent” from retryable failure, but has no direct test comparable to the chain teardown suite.
Fix: verify absent-rule success clears the flag and real errors retain it for retry.
| #[serde(rename_all = "camelCase")] | ||
| pub struct LxcExperimental { | ||
| /// State-aware provision-phase configuration. | ||
| pub provision: Option<LxcProvisionPhase>, |
There was a problem hiding this comment.
Medium (API surface/maintainability) - Avoid two LXC JSON shapes for identical provisioning fields.
Attribution: introduced_by_change — experimental.lxc.provision duplicates the existing top-level lxc section's distribution and release using a second wire struct/schema definition.
The parser rejects mixing the two, so there is no precedence bug, but raw-config users now need lifecycle-dependent addresses and the definitions can drift.
Fix: preferably accept the canonical top-level lxc section for state-aware provision, using phase plus the experimental opt-in for disambiguation. If nested phase config is required by convention, reuse the same wire/schema type and add parity tests.
Brings in d636524, six commits behind. One conflicted file, src/core/wxc/src/main.rs, from 2ecaa5f "(microsoft#969) Resolve the command before the request is parsed". This branch relocated three helpers out of main.rs into modules while main still defines them inline, so upstream's side of two hunks was a duplicate of config_rejection_reason_for (now wxc_common/src/config_rejection.rs), state_aware_policy_identity, and sandbox_id_for_identity_record (now mxc_engine/src/state_aware_telemetry.rs). Those hunks resolve to this branch. The conflicted test block resolves as a split rather than a side. Four incoming tests cover sandbox_id_for_identity_record, which no longer lives in main.rs, and already exist by the same names in state_aware_telemetry.rs; they are dropped here with no loss of coverage. microsoft#969's own request_error_route_matches_the_output_conventions test and its resolve_with_cli helper are kept, since microsoft#969's production path auto-merged intact. backend_name_for_state_aware and emit_state_aware_early_rejection are removed. microsoft#969 resolves the command before parsing, so a malformed CLI command override now surfaces as a ParseError on the pre-parse path, and the post-parse error path these two served no longer exists. This drops early-rejection telemetry for that case: telemetry is initialized after parsing, so there is no active session to emit against at the point the override is now rejected. Verified with cargo check --workspace --all-targets on Windows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d276e7c7-103f-4a4f-ac6c-ad4790f3801b
There was a problem hiding this comment.
🟡 Changes recommended
The C# surface cannot provision LXC, legacy mounts may survive tightened policies, and several lifecycle error and diagnostic paths are incorrect.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (8)
sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:24
- Adding
Lxchere exposes a public lifecycle option that cannot produce a valid provision request.ValidateProvisionOptionsaccepts onlynullfor LXC, andBuildProvisionEnvelopehas no LXC branch, so the emitted envelope omits requiredexperimental.lxc.provision.distributionandreleasefields and everyProvisionSandbox(StateAwareContainment.Lxc)call is rejected by the native parser. Please add an LXC-specific provision-options type and serialize those required fields before exposing this enum member.
/// <summary>Linux container managed by LXC.</summary>
Lxc,
src/backends/lxc/common/src/lxc_bindings.rs:609
- Marker-only replacement leaves filesystem grants written by older MXC builds in place. The previous implementation appended unmarked
lxc.mount.entrylines, and preserved containers keep that startup config; adopting or reusing one now treats those entries as user baseline and adds the new policy alongside them. A caller tightening/removing a prior mount can therefore retain host access that the current policy did not grant. This needs an explicit migration/rejection strategy for legacy MXC entries before marker-based replacement is safe.
/// Marker comment written immediately above every `lxc.mount.entry` line
/// that MXC itself adds, so
/// [`replace_mxc_mount_entries`](Self::replace_mxc_mount_entries) can
/// rewrite only MXC's own mounts and leave baseline entries the distro
/// template or the user placed in the config untouched. It is a real LXC
/// comment (`#`), so liblxc ignores it when parsing the file.
const MXC_MOUNT_MARKER: &'static str = "# mxc-managed-mount";
src/core/lxc/src/main.rs:332
- This state-aware parse-error branch bypasses
log_config_rejected, unlike the correspondingwxc-execbranch. Malformed lifecycle requests handled here therefore produce neither the audit event nor the telemetry rejection event introduced by this PR, despite the shared rejection module's executor-wide contract. RouteParseError::StateAwarethrough the rejection classifier/logger before emitting the envelope.
src/backends/lxc/common/src/state_aware.rs:1183 - This stop-phase logger drops the diagnostic sink installed by the shared state-aware wrapper, so firewall-cleanup diagnostics never reach
--log-fileor the audit pipe. Backend-local loggers on this path must inherit the thread sink.
src/backends/lxc/common/src/state_aware.rs:1221 - This deprovision-phase logger drops the diagnostic sink installed by the shared state-aware wrapper, so authoritative firewall-cleanup diagnostics never reach
--log-fileor the audit pipe. Backend-local loggers on this path must inherit the thread sink.
src/backends/lxc/common/src/state_aware.rs:519 - This branch reports any firewall setup failure after partial resource creation as invalid caller policy, although it is an operational iptables failure and policy validation has already completed. Typed clients receive a non-retryable
policy_validationcode and telemetry recordsUnsupportedField; returnbackend_errorinstead.
src/backends/lxc/common/src/state_aware.rs:531 - An existing/stale firewall chain is lifecycle state, not a malformed policy. Classifying this collision as
policy_validationcauses typed clients and rejection telemetry to blame the request even though the remediation in the message is to stop/deprovision and retry. Return a backend/state error code instead.
src/backends/lxc/common/src/state_aware.rs:950 - This provision-phase logger drops the diagnostic sink installed by the shared state-aware wrapper, so container-creation diagnostics never reach
--log-fileor the audit pipe. Backend-local loggers on this path must inherit the thread sink.
- Files reviewed: 95/97 changed files
- Comments generated: 5
- Review effort level: Balanced
| /// order means a dry run reports the same error the real start would, not merely | ||
| /// some error. | ||
| fn validate_start_policy(request: &ExecutionRequest) -> Result<(), MxcError> { | ||
| let mut logger = Logger::new(Mode::Buffer); |
| Ok(false) => Err(MxcError::policy_validation( | ||
| "Failed to apply network firewall rules", | ||
| )), |
| } | ||
|
|
||
| let buffered = logger.get_buffer().to_string(); | ||
| if !buffered.is_empty() { |
| if ! lxc-create -n "$FOREIGN" -t download -- \ | ||
| -d "$DISTRIBUTION" -r "$RELEASE" -a amd64 >/dev/null 2>&1; then | ||
| skip "could not create a container to adopt; no image cache and no network?" | ||
| fi |
| TIMEOUT_STARTED=$(date +%s) | ||
| run_phase exec "$SANDBOX_ID" '"process": { "commandLine": "sleep 30", "timeout": 1000 }' >/dev/null 2>&1 | ||
| TIMEOUT_RC=$? | ||
| TIMEOUT_ELAPSED=$(( $(date +%s) - TIMEOUT_STARTED )) | ||
| if [ "$TIMEOUT_RC" -ne 0 ] && [ "$TIMEOUT_ELAPSED" -lt 25 ]; then |
A caller who asks a sandbox for no network gets a legacy network block carrying `defaultPolicy: "block"` and no enforcement mode. LXC refuses a posture it cannot enforce. That request is rejected at start, though the caller never stated a mode to disagree with. The SDK now names `firewall` for a legacy block that does not already name one. A mode the caller stated is left alone, and a 0.8 directional block carries no enforcement mode by design and is untouched. The constraints note in the state-aware API document said `enforcementMode` was accepted but ignored by LXC. That stopped being true when the backend began refusing `capabilities` rather than running unenforced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d276e7c7-103f-4a4f-ac6c-ad4790f3801b
Handoff — Darren is on vacationEverything on his workstation is pushed. This comment is the map, written for Where the work isEvery branch below is on a remote as of 2026-09-11. Nothing is stranded on the
Every branch above is on microsoft/mxc except this PR's own branch, which is The four Two worktrees, Two things that need a decision, not a keystroke1. An egress regression in PR #1041, which nobody has raised on that PR. Defending the first review finding on this PR, the argument was that egress is This is the highest-value open item and it belongs on #1041, not here. 2. Which of #849 and #1041 lands first. Nothing in either branch records the intended order, and three review findings The review threads — do not bulk-resolve33 human review comments are unresolved. A pass was run to find which were made The reason is worth stating, because it will come up again: authorship decides Two findings have partial fixes already on the branch ( Still open, and unstarted
|
There was a problem hiding this comment.
🔵 Needs a closer look
The exact contract and SDK surfaces remain incomplete, and timeout and parse-rejection paths violate advertised lifecycle behavior.
Review details
Suppressed comments (5)
Previously missed (3) — in code that hasn't changed since the last review.
sdk/node/src/state-aware-types.ts:111
- This type omits the schema-0.8
egress/ingressfields even though LXC defaults lifecycle requests to 0.8 and the backend implements those policies. It also omitsenforcementMode, so callers overriding the request to 0.7 cannot request the firewall behavior described by this PR. The public typed API therefore cannot express supported LXC network policies.
sdk/node/src/state-aware.ts:145 - This typed-error guarantee does not hold for the new LXC timeout path. LXC may stream workload bytes to stdout before returning its timeout error; the executor appends the error envelope to that same stream, and
tryParseErrorEnvelope(stdout)then fails whole-string parsing and resolves an ordinaryExecResult. The newly added unit test explicitly demonstrates this loss of theMxcError. Preserve a separately framed/channelled dispatch result (or otherwise parse it unambiguously) so timeout remains a typed dispatch failure.
src/core/wxc_common/src/config_contract_adapters/dev/state_aware.rs:54 - The exact 0.9 contract still has no LXC containment or provision variant:
mxc_config_contract::dev::state_aware::provision::Containment::parse_exactrejects"lxc", while these adapter additions only initialize the new rolling-wire field toNone. This leaves the new backend absent from exact parsing/codegen and will break when exact dispatch becomes authoritative. Add the LXC phase contracts and conversions alongside the rolling model.
sdk/dotnet/Microsoft.Mxc.Sdk/StateAwareTypes.cs:24
- Exposing
Lxchere makes the C# API advertise a backend that it cannot provision.ValidateProvisionOptionsaccepts onlynullfor LXC, butBuildProvisionEnvelopethen emits noexperimental.lxc.provision.distributionorrelease, so native validation always rejects the request; every non-null options subtype is rejected too. Add an LXC-specific provision options type and serialize its required fields (and start-time policy options), or do not expose this enum member yet.
/// <summary>Linux container managed by LXC.</summary>
Lxc,
src/core/lxc/src/main.rs:332
- State-aware parse failures exit here before
run_state_aware_with_telemetry, so they never calllog_config_rejected. The Windows executor records the correspondingParseError::StateAwarerefusal, and the shared telemetry path only covers errors after successful parsing. This makes malformed LXC lifecycle requests disappear from the configuration-rejection audit stream; classify and log the rejection before emitting the envelope.
- Files reviewed: 96/98 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
We have decided to postpone this to >1.0, the work-item is being tracked in ADO |
Problem
LXC only runs one-shot. A caller hands it a config, it builds a container, runs a single workload, and destroys the container. Driving several commands against the same environment costs a full container build and teardown every time, and nothing the first command leaves behind is there for the second.
Fix
LXC implements the state-aware lifecycle: provision, start, exec, stop, and deprovision. A caller builds a container once, execs against it as many times as needed, and tears it down when finished.
--experimentalthe request is refused and the message names the flag. One-shot LXC is untouched and still needs no flag.enforcementModeasked for it, which leaves a config written before that field behaving as it always did.lxcas a state-aware backend with per-phase config types, and sends0.8.0-alpha.🔍 Validation
Measured on
ec0ba3a3:run_lxc_all_tests.shagainst a live LXC host — 31 passed, 0 failed, 0 skipped, 1 disabled.cargo test -p lxc_common -p bwrap_commonon Linux — 829 passed, 0 failed.cargo test -p wxc_common -p mxc_engineon Windows — 1,150 passed, 0 failed.npm testinsdk/node— 317 passed, 0 failed;tsc --noEmitclean.wire.tsregenerate byte-identical to what is committed.✅ Checklist
Cargo.lock, thedependency-feed-checkcheck passes📋 Issue Type
Microsoft Reviewers: Open in CodeFlow