Run and automate apps in persistent Windows execution targets - #779
Nikola Metulev (nmetulev) wants to merge 123 commits into
Conversation
Build Metrics ReportBinary Sizes
Test Results✅ 6810 passed, 37 skipped out of 6847 tests in 941.6s (+1020 tests, +97.2s vs. baseline) Test Coverage✅ 85.8% line coverage, 79.8% branch coverage · CLI Startup Time50ms median (x64, Try This BuildInstalls the MSIX for your architecture, replacing any previously installed build. Needs the GitHub CLI — the command offers to install it and sign you in if it is missing. & ([scriptblock]::Create((irm https://raw.githubusercontent.com/microsoft/winappCli/main/scripts/winapp-pr.ps1))) 779Switching between builds often?Put the tool on your PATH once: & ([scriptblock]::Create((irm https://raw.githubusercontent.com/microsoft/winappCli/main/scripts/winapp-pr.ps1))) -AddToPathThen this build is just: winapp-pr 779Run Updated 2026-09-11 23:34:51 UTC · commit |
Addresses all four findings from the independent review of #779, each with a regression test, plus the guest-side process host they gate. HIGH -- wsb.exe could be hijacked from the current directory. Availability resolved PATH but execution passed a bare name, and with UseShellExecute=false CreateProcess searches the application and current directories before PATH. A wsb.exe dropped into a repository a developer happens to be sitting in would win and take over Sandbox control. The resolved absolute path is now cached and always executed, and relative PATH entries are skipped because they resolve against the current directory and would reintroduce the same hole. HIGH -- the mutation lock was thread-affine. A Windows mutex must be released by the thread that acquired it, but this lock is held across awaits, so the continuation that releases it usually runs on a different thread-pool thread. ReleaseMutex threw there, the exception was swallowed, and the mutex stayed held until the original thread exited -- blocking every other winapp process and later surfacing as a false abandonment. Replaced with an exclusively opened file, which has no thread affinity and is closed by the kernel on process death, so crash recovery still works. Abandonment is now detected from an owner record that a clean release clears. Regression tests cover cross-thread release, release after many awaits, and serialized concurrent acquirers. HIGH -- a failed ownership commit stranded a running Sandbox. The instance existed but was never recorded, so every later command refused it as unmanaged, permanently wedging the target through no fault of the user. Commit failure now best-effort stops the exact instance the call created, using its own bounded timeout because the caller's token may already be cancelled, and rethrows the original failure. Compensation failure is covered too. MEDIUM -- wsb exec reported infrastructure failures as guest exit codes. wsb exec never relays guest stdout or stderr, so anything on stderr is wsb's own diagnostic and means the command was never dispatched; returning that code let an infrastructure failure impersonate an application result. It now throws, and only a clean dispatch returns the child's exit code. Also adds GuestProcessHost and GuestJobObject. Killing a process ID alone leaves orphaned grandchildren that keep holding files the next deployment must replace, so children run in a Job Object with KILL_ON_JOB_CLOSE and the tree dies together -- including if the agent crashes. Graceful stop closes standard input first and only terminates the job after a timeout, so a child flushing output or finalizing a recording can still exit cleanly. Streams are forwarded as raw bytes because decoding per chunk would corrupt binary output and split UTF-8 sequences. Assignment tolerates a child that exited before it could be assigned: Windows refuses to assign a terminated process, and a process that has exited has no tree left to contain. This surfaced only under parallel test load. Part of #769. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Addresses the inline code-quality findings on #779 by fixing the underlying pattern rather than each warning site. Every managed path here is a trusted root plus derived segments -- a target slug, a state or lock file name, a guest-relative path -- and each is a place where a value meant to name something inside a managed folder could instead name something outside it. TargetPathSafety is now the single rule: segments must be plain names (non-empty, not rooted, no separators, no relative specifiers, no invalid filename characters), and the joined result is canonicalized and proven to stay inside its root. Both halves are load-bearing. Path.Combine silently discards everything before a rooted segment, so Combine(root, @"C:\Windows") returns C:\Windows. Path.Join avoids that specific surprise but validates nothing, so it is not a substitute: a segment containing traversal still escapes. Validation and containment are kept as independent defences, and rejection is preferred over sanitizing because silently rewriting a value that tried to escape hides the attempt. Routed through it: TargetStateDirectoryProvider (targets root and slug), TargetStateStore (state file and atomic temp file), TargetMutationLock (lock file), and DeploymentPlanner, whose duplicated containment check now delegates to the shared one. One real bug fixed along the way: VerifyUnchanged combined snapshot-relative paths directly, so a tampered snapshot could have made it stat files outside the deployment root. It now goes through the containment check like every other guest-relative path. The "condition is always not null" finding was not correct as written -- the compiler cannot infer that a non-null InstanceId implies a non-null state, and applying it verbatim fails to build. Restructured as a single pattern match over both members, which removes the redundancy the finding was pointing at while keeping null analysis satisfied. Also adopts using statements where a manual finally-dispose was flagged, and switches test helpers to safe path construction rather than duplicating security logic in tests. Part of #769. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Four validated findings from the final review of #779, plus the tests and docs each one requires. Host directory traversal (HIGH). Deployment snapshots and `sandbox cp` enumerated host sources with SearchOption.AllDirectories, which follows directory junctions and symbolic links. The per-file reparse checks did not compensate: a file reached *through* a junction is an ordinary file with no reparse attribute, so every one of them passed while content from outside the named folder was hashed, deployed, or copied into the guest. Both call sites now share one manual no-follow walk that tests every directory before descending, which also ends a self-referencing junction at the loop edge instead of recursing until the path length gives out. Deployment refuses such a folder; `sandbox cp` treats the link as absent, matching the guest-side rule. Ancestors are re-proven immediately before each file is opened, narrowing the unguarded window to the open itself. The now-duplicated per-file check in DeploymentPlanner is removed so the rule lives in exactly one place. Standard input forwarding (HIGH). `sandbox exec` and `run --sandbox --with-alias` both document stdin/stdout/stderr forwarding, but their callbacks carried output handlers only, so stdin silently went nowhere. The pump that already existed in the UI router is extracted into a shared GuestStandardInputPump and attached by all three call sites. It starts from the published operation ID, so input a caller piped in before winapp began is not sent for an operation the guest has not heard of; forwards raw bytes; closes guest stdin on host EOF; and stays silent under cancellation rather than announcing EOF into a teardown. Shipped agent guidance (HIGH). winapp.agent.md covered none of the Sandbox surface. Adds the decision-tree branch, command reference for `run/unregister/ui --sandbox` and `sandbox exec`/`cp`, WSB lifecycle and single-owner rules, prerequisites, the trust boundary, runtime limits, error-code triage, and pointers to the winapp-sandbox skill. VCLibs acquisition hardening. The one payload winapp downloads was published to the shared host cache on the strength of identity strings read from inside the downloaded zip, which are forgeable by anyone able to serve the bytes. The staged file must now pass AuthenticodeVerifier.IsTrustedMicrosoftSigned before publication, with the identity/version/architecture/publisher check retained as a second gate. Either failure discards the staged file and publishes nothing, so a rejected payload never becomes a cache entry a later run would trust. Tests use real junctions (which need no elevation) and report inconclusive rather than passing vacuously when no link can be created. Live Windows Sandbox coverage is added for piped stdin and for junction containment in `sandbox cp`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…unches HIGH-severity follow-up finding from independent review of 1157c10, confirmed by code tracing: a launching packaged `run --sandbox` (anything without --no-launch) sends the guest a single `winapp run` request that both registers the package (Add-AppxPackage-equivalent, a real guest package mutation) and then launches/waits on the application. RunCommand.Sandbox released the mutation lease (via ReleaseMutationLease, right after CommitPackage) *before* that single guest request was even sent -- so registration itself, and any --clean guest-side app-data reset, ran completely unlocked. Two concurrent `run --sandbox --clean` invocations against the same deployment could still interleave their registrations despite the mutation-window fix in 1157c10, because that commit only covered host-side deployment reconciliation, not the guest-side registration folded into the single "register + launch" call. The review also flagged that PreparedTarget.RequireMutationLease() checked only "is the lease reference non-null", which stays true forever because disposing a lease does not null out the field -- so it would have kept passing even after ReleaseMutationLease() ran, silently no-op'ing the very guard meant to catch a mutation running unprotected. Fix, in the smallest form that keeps existing stdout/exit/lifecycle semantics unchanged: - ExecutionTargets/Orchestration/TargetMutationLock.cs: TargetMutationLease gains an internal IsReleased flag backed by the same field Dispose() already clears atomically (Interlocked.Exchange + a paired Volatile.Read), so release state can never disagree with the lock's real, physical state. - ExecutionTargets/Orchestration/ExecutionTargetOrchestrator.cs: PreparedTarget.RequireMutationLease() now rejects a released lease (MutationLease is not { IsReleased: false }), not just a null one. - Commands/RunCommand.Sandbox.cs: a launching packaged run (identity != null, !noLaunch) now splits into two guest calls instead of one. Phase 1, still under the lease, is guest `winapp run --no-launch` -- the same production register-only path `--no-launch` already uses locally, never a bespoke reimplementation -- built via a new private RegisterPackageAsync. Phase 2, after ReleaseMutationLease(), is the caller's real run (with --clean forced off, since phase 1 already applied it): the guest's own TrySkipRegistration/IsExistingRegistrationUpToDate makes this a no-op query rather than a mutation when nothing changed since phase 1, so it needs no lock of its own. A `--no-launch` request is unaffected: there is no launch phase to split off, so it stays the single, already-fully-locked call it was. Output handling for phase 1 mirrors the existing single-call behavior exactly (captured/relayed as the caller's own result only on failure; discarded on success, since phase 2's result is what the caller actually sees). TargetRuntimeService.EnsureAsync and GuestApplicationRunner.DeployAsync were already asserting the caller's held lease (from 1157c10) rather than reacquiring their own, so no nested-lock/self-deadlock risk was introduced by extending the locked window to also cover registration. Tests (all new, all passing): - ExecutionTargetOrchestratorTests: RequireMutationLease before/after release, after DisposeAsync's fail-safe release, and against a read-only (non-mutating) target -- covering the exact "checks only non-null" gap the review flagged. - TargetRuntimeServiceTests: EnsureAsync with a non-empty requirement set fails fast with InvalidOperationException when the caller already released its lease, rather than deadlocking (the old per-call TryAcquire would have reacquired against its own now-outer caller) or mutating unprotected. - New file PackagedSandboxMutationLockTests.cs: drives the real RunCommand.Handler.ExecuteRunPipelineAsync production entry point (the same internal method `run --sandbox` reaches after CLI parsing) through a real ExecutionTargetOrchestrator, real file-backed TargetMutationLock, and the real GuestCommandChannel/GuestCommandServer wire protocol -- only the guest OS process each request would start is scripted, the same boundary SandboxRunTests already treats as production-equivalent given no live Sandbox is available here. Covers: two concurrent `--clean` runs against the same deployment serialize their registrations but not their launches (the second's registration proceeds while the first's "app" is still running); `--no-launch` stays a single locked call; a registration failure never reaches the launch phase and still releases the lease (unblocking a second waiting run); `--with-alias` also splits, with alias/debug/detach/ unregister-on-exit flags confined to the unlocked launch call only. ClassInitialize raises the process-wide thread-pool floor once, because this suite's real Thread.Sleep-polling lock plus two concurrent guest servers per test triggered severe thread-pool-starvation slowdowns under the full suite's own parallel load (isolated runs were sub-second; full-suite runs without the floor bump could run for 30+ minutes). Confirmed by running the full suite both without and with this file: identical 73 pre-existing/ environmental failures either way, ~6m duration both times. Verified: - Reproduced the bug conceptually by tracing the exact call path (registration embedded in the single "winapp run" guest request, sent after the lease was already released) before implementing the fix. - Targeted suite (Orchestrator/RuntimeService/DeploymentService/MutationLock/ SandboxRun/RunCommand/new PackagedSandboxMutationLockTests): 224 passed, 0 failed, 1 unrelated inconclusive (long-path support enabled on this machine). - Full suite: 5065 total, 4987 passed, 73 failed (all pre-existing NuGet/MSIX build-tools and one crash-dump test, unrelated to this change and to 1157c10 -- this corp machine cannot reach api.nuget.org), 5 inconclusive. No live Windows Sandbox was used. New commit (not amending 1157c10) on the isolated branch nmetulev-mutation-lock-coverage-gap, based on 4c7fe80 (nmetulev-sandbox-execution-target). Not pushed to PR #779's branch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…to mutate HIGH-severity follow-up finding from independent review of 7cca0e3, confirmed by code tracing: after phase 1 (locked) registers the package and releases the mutation lease, phase 2 sent the caller's ordinary guest `winapp run` request. That command registers and launches inseparably. If a different deployment sharing the same package identity but a different layout registered in the gap between phase 1 and phase 2 -- exactly the race the split exists to protect against -- phase 2's `run` would see the install location no longer matches this deployment's layout, and its own IsExistingRegistrationUpToDate check would (correctly, for what it is) fall through to an unlocked unregister+register: an unlocked package mutation that also destroys the other deployment's registration. The 7cca0e3 split closed the "two same-deployment `--clean` runs interleave" case; it did not, and structurally could not, close this one, because the general `run` verb always retains a registration code path. Fix: phase 2 no longer runs the general `run` verb at all for a launching packaged sandbox run. It runs a new, hidden guest verb, `guest-launch`, that has no code path capable of registering or unregistering anything: - ExecutionTargets/Orchestration/GuestLaunchPlanner.cs: builds the guest-launch argument vector (package-name/publisher/application-id/expected-layout/ payload plus the launch-only option subset -- with-alias/debug-output/ unregister-on-exit/detach/json/args). No --clean, no --no-launch: those options describe registration behavior this verb does not have. - Commands/GuestLaunchCommand.cs: the hidden `guest-launch` command and its option surface (Hidden = true, so it carries no public schema/docs surface, matching guest-agent/guest-runtime). - Commands/RunCommand.GuestLaunch.cs: the verb's handler, added to RunCommand.Handler (dispatched from InvokeAsync by command type, since GuestLaunchCommand shares that handler rather than a second instance). It looks up the currently registered dev-mode package by name via the same IPackageRegistrationService.FindDevPackages already used for the equivalent safety check in MsixService.SkipRegistration, requires exactly one match whose InstallLocation equals the expected layout exactly, and only then launches. A zero/ambiguous/mismatched result is refused outright -- there is no fallback branch that calls install/register/unregister to "fix" it, because no such branch exists in this method at all. - Commands/RunCommand.cs: extracted the post-registration launch/wait/detach/ debug/unregister-on-exit tail of ExecuteRunPipelineAsync into LaunchRegisteredApplicationAsync (behavior-preserving refactor, verified by the full pre-existing RunCommandTests suite passing unchanged), so the ordinary local run and the new guest-launch verb share one implementation of "what happens after launch" instead of two that could drift. - Commands/RunCommand.Sandbox.cs: phase 2's request now comes from GuestLaunchPlanner instead of GuestRunPlanner when the run also launches; --no-launch is unaffected (there is no launch phase to split off, so the single, already-fully-locked general `run --no-launch` call is unchanged). - Commands/WinAppRootCommand.cs, Helpers/HostBuilderExtensions.cs: wire the new hidden verb into the command tree and DI, reusing the existing RunCommand.Handler singleton rather than registering a second instance. Tests: - New GuestLaunchCommandTests.cs drives RunCommand.Handler.InvokeAsync for a parsed GuestLaunchCommand directly, with FakePackageRegistrationService standing in for the guest's package state. Every test asserts all five of that fake's mutation call lists (install/unregister/unregister-by-full-name/ register-loose-layout/register-sparse) stay empty -- proving there is no code path to any of them, not just that a given flag combination happens not to exercise one. Covers: exact-match launches cleanly; a different layout registered under the same identity (the exact SBX-009 follow-up scenario) refuses and leaves that other registration completely undisturbed; zero matches; ambiguous (multiple) matches; a non-dev-mode registration is never treated as satisfying the expectation; --with-alias never calls LaunchByAumid and never mutates even when the alias launch itself then fails for unrelated reasons; --unregister-on-exit never fires on a mismatch, because that point is never reached. - PackagedSandboxMutationLockTests.cs: added IsGuestLaunchVerb and asserted phase 2's guest exec request in every scenario (plain launch, --with-alias) is the guest-launch verb, never the general run -- a structural, wiring-level check that complements GuestLaunchCommandTests' behavioral proof. Verified: - Extraction of LaunchRegisteredApplicationAsync is behavior-preserving: full pre-existing RunCommandTests suite (138 tests) passes unchanged before and after. - `winapp guest-launch ...` invoked directly: absent from `--help` output (confirms Hidden = true is honored), and correctly refuses with a descriptive message and exit code 1 against a package that is not registered on this machine. - Targeted suite (PackagedSandboxMutationLockTests/GuestLaunchCommandTests/ RunCommandTests/ExecutionTargetOrchestratorTests/TargetRuntimeServiceTests/ SandboxRunTests): 209 total, 208 passed, 0 failed, 1 unrelated inconclusive. - Full suite: 5072 total, 4994 passed, 73 failed (same pre-existing NuGet/ MSIX-build-tools and one crash-dump failure as the 1157c10/7cca0e3f baselines, unrelated to this change), 5 inconclusive, ~6m11s (no new hangs/regressions). No live Windows Sandbox was used. Also audited, per explicit request, whether this session's new tests could be the source of a Windows Firewall consent prompt seen during today's test runs: PackagedSandboxMutationLockTests.cs, GuestLaunchCommandTests.cs, and every production file touched across all three follow-up commits contain no socket, listener, or IPAddress usage of any kind -- the only transport involved is the existing in-process LoopbackTransportPair (System.Threading.Channels, no OS network resource). The one real network listener in this codebase (GuestTcpTransport's `new TcpListener(IPAddress.Any, ...)`) is pre-existing, unrelated to this fix, and is reachable only through SandboxLiveE2ETests, which requires a live Windows Sandbox and was not run in this session. No fix was needed in this commit because no network-binding code was introduced. New commit (not amending 7cca0e3 or 1157c10) on the isolated branch nmetulev-mutation-lock-coverage-gap, based on 4c7fe80 (nmetulev-sandbox-execution-target). Not pushed to PR #779's branch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ust PrepareAsync SBX-009 finding confirmed: ExecutionTargetOrchestrator.PrepareAsync acquired the target mutation lock but released it (via a using-scoped lease local) as soon as PrepareAsync itself returned -- before the caller performed any of the guest mutation the lock exists to protect. RunCommand.Sandbox's RunInGuestAsync, SandboxCommand's cp, and UnregisterCommand's --sandbox path all call PrepareAsync(Mutating) and then run runtime provisioning, deployment reconciliation, package registration/unregistration, or a guest file copy -- entirely unlocked. TargetDeploymentService.ReconcileAsync's own docstring already asserted "callers must already hold the target mutation lock", which was false in practice. TargetRuntimeService.EnsureAsync separately re-acquired its own lease from the same file-backed lock, which would now self-deadlock once the outer lease is held for the whole window. A deterministic test proves the gap: two concurrent PrepareAsync(Mutating) calls against the real, file-backed TargetMutationLock ran their simulated mutation work concurrently (maxConcurrency == 2) before this fix, and are serialized (maxConcurrency == 1) after it. See ExecutionTargetOrchestratorTests.ConcurrentMutatingCommands_NeverOverlapGuestMutationWork. Fix: PreparedTarget now carries its MutationLease (mirroring ConnectionLease) instead of PrepareAsync disposing it internally. Callers must call PreparedTarget.ReleaseMutationLease() once every mutation is done and before anything long-running (RunCommand.Sandbox does this right before launching the app); DisposeAsync releases it too, as a fail-safe, never as the primary path. TargetRuntimeService.EnsureAsync and GuestApplicationRunner.DeployAsync no longer scatter their own lock acquisition -- they assert the caller's lease via PreparedTarget.RequireMutationLease(), which also removes the self-deadlock risk. The connection-establishment lock (ITargetConnectionLock) is untouched. Tests updated: ExecutionTargetOrchestratorTests (release timing + new concurrency proof), TargetRuntimeServiceTests.Harness and SandboxRunTests harnesses (hold a real lease, matching the new caller contract). Targeted suite (Orchestrator/RuntimeService/DeploymentService/MutationLock/ SandboxRun/RunCommand tests): 215 passed, 0 failed, 1 unrelated inconclusive. Full suite: 4978 passed, 73 failed -- all pre-existing NuGet/MSIX build-tools and crash-dump failures unrelated to this change (this corp machine cannot reach api.nuget.org; see AGENTS.md), 5 inconclusive (interactive/hardware-gated). No live Windows Sandbox was used; SandboxLiveE2ETests was excluded. Isolated investigation branch per SBX-009 coordination; PR #779's branch was not touched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…unches HIGH-severity follow-up finding from independent review of 1157c10, confirmed by code tracing: a launching packaged `run --sandbox` (anything without --no-launch) sends the guest a single `winapp run` request that both registers the package (Add-AppxPackage-equivalent, a real guest package mutation) and then launches/waits on the application. RunCommand.Sandbox released the mutation lease (via ReleaseMutationLease, right after CommitPackage) *before* that single guest request was even sent -- so registration itself, and any --clean guest-side app-data reset, ran completely unlocked. Two concurrent `run --sandbox --clean` invocations against the same deployment could still interleave their registrations despite the mutation-window fix in 1157c10, because that commit only covered host-side deployment reconciliation, not the guest-side registration folded into the single "register + launch" call. The review also flagged that PreparedTarget.RequireMutationLease() checked only "is the lease reference non-null", which stays true forever because disposing a lease does not null out the field -- so it would have kept passing even after ReleaseMutationLease() ran, silently no-op'ing the very guard meant to catch a mutation running unprotected. Fix, in the smallest form that keeps existing stdout/exit/lifecycle semantics unchanged: - ExecutionTargets/Orchestration/TargetMutationLock.cs: TargetMutationLease gains an internal IsReleased flag backed by the same field Dispose() already clears atomically (Interlocked.Exchange + a paired Volatile.Read), so release state can never disagree with the lock's real, physical state. - ExecutionTargets/Orchestration/ExecutionTargetOrchestrator.cs: PreparedTarget.RequireMutationLease() now rejects a released lease (MutationLease is not { IsReleased: false }), not just a null one. - Commands/RunCommand.Sandbox.cs: a launching packaged run (identity != null, !noLaunch) now splits into two guest calls instead of one. Phase 1, still under the lease, is guest `winapp run --no-launch` -- the same production register-only path `--no-launch` already uses locally, never a bespoke reimplementation -- built via a new private RegisterPackageAsync. Phase 2, after ReleaseMutationLease(), is the caller's real run (with --clean forced off, since phase 1 already applied it): the guest's own TrySkipRegistration/IsExistingRegistrationUpToDate makes this a no-op query rather than a mutation when nothing changed since phase 1, so it needs no lock of its own. A `--no-launch` request is unaffected: there is no launch phase to split off, so it stays the single, already-fully-locked call it was. Output handling for phase 1 mirrors the existing single-call behavior exactly (captured/relayed as the caller's own result only on failure; discarded on success, since phase 2's result is what the caller actually sees). TargetRuntimeService.EnsureAsync and GuestApplicationRunner.DeployAsync were already asserting the caller's held lease (from 1157c10) rather than reacquiring their own, so no nested-lock/self-deadlock risk was introduced by extending the locked window to also cover registration. Tests (all new, all passing): - ExecutionTargetOrchestratorTests: RequireMutationLease before/after release, after DisposeAsync's fail-safe release, and against a read-only (non-mutating) target -- covering the exact "checks only non-null" gap the review flagged. - TargetRuntimeServiceTests: EnsureAsync with a non-empty requirement set fails fast with InvalidOperationException when the caller already released its lease, rather than deadlocking (the old per-call TryAcquire would have reacquired against its own now-outer caller) or mutating unprotected. - New file PackagedSandboxMutationLockTests.cs: drives the real RunCommand.Handler.ExecuteRunPipelineAsync production entry point (the same internal method `run --sandbox` reaches after CLI parsing) through a real ExecutionTargetOrchestrator, real file-backed TargetMutationLock, and the real GuestCommandChannel/GuestCommandServer wire protocol -- only the guest OS process each request would start is scripted, the same boundary SandboxRunTests already treats as production-equivalent given no live Sandbox is available here. Covers: two concurrent `--clean` runs against the same deployment serialize their registrations but not their launches (the second's registration proceeds while the first's "app" is still running); `--no-launch` stays a single locked call; a registration failure never reaches the launch phase and still releases the lease (unblocking a second waiting run); `--with-alias` also splits, with alias/debug/detach/ unregister-on-exit flags confined to the unlocked launch call only. ClassInitialize raises the process-wide thread-pool floor once, because this suite's real Thread.Sleep-polling lock plus two concurrent guest servers per test triggered severe thread-pool-starvation slowdowns under the full suite's own parallel load (isolated runs were sub-second; full-suite runs without the floor bump could run for 30+ minutes). Confirmed by running the full suite both without and with this file: identical 73 pre-existing/ environmental failures either way, ~6m duration both times. Verified: - Reproduced the bug conceptually by tracing the exact call path (registration embedded in the single "winapp run" guest request, sent after the lease was already released) before implementing the fix. - Targeted suite (Orchestrator/RuntimeService/DeploymentService/MutationLock/ SandboxRun/RunCommand/new PackagedSandboxMutationLockTests): 224 passed, 0 failed, 1 unrelated inconclusive (long-path support enabled on this machine). - Full suite: 5065 total, 4987 passed, 73 failed (all pre-existing NuGet/MSIX build-tools and one crash-dump test, unrelated to this change and to 1157c10 -- this corp machine cannot reach api.nuget.org), 5 inconclusive. No live Windows Sandbox was used. New commit (not amending 1157c10) on the isolated branch nmetulev-mutation-lock-coverage-gap, based on 4c7fe80 (nmetulev-sandbox-execution-target). Not pushed to PR #779's branch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…to mutate HIGH-severity follow-up finding from independent review of 7cca0e3, confirmed by code tracing: after phase 1 (locked) registers the package and releases the mutation lease, phase 2 sent the caller's ordinary guest `winapp run` request. That command registers and launches inseparably. If a different deployment sharing the same package identity but a different layout registered in the gap between phase 1 and phase 2 -- exactly the race the split exists to protect against -- phase 2's `run` would see the install location no longer matches this deployment's layout, and its own IsExistingRegistrationUpToDate check would (correctly, for what it is) fall through to an unlocked unregister+register: an unlocked package mutation that also destroys the other deployment's registration. The 7cca0e3 split closed the "two same-deployment `--clean` runs interleave" case; it did not, and structurally could not, close this one, because the general `run` verb always retains a registration code path. Fix: phase 2 no longer runs the general `run` verb at all for a launching packaged sandbox run. It runs a new, hidden guest verb, `guest-launch`, that has no code path capable of registering or unregistering anything: - ExecutionTargets/Orchestration/GuestLaunchPlanner.cs: builds the guest-launch argument vector (package-name/publisher/application-id/expected-layout/ payload plus the launch-only option subset -- with-alias/debug-output/ unregister-on-exit/detach/json/args). No --clean, no --no-launch: those options describe registration behavior this verb does not have. - Commands/GuestLaunchCommand.cs: the hidden `guest-launch` command and its option surface (Hidden = true, so it carries no public schema/docs surface, matching guest-agent/guest-runtime). - Commands/RunCommand.GuestLaunch.cs: the verb's handler, added to RunCommand.Handler (dispatched from InvokeAsync by command type, since GuestLaunchCommand shares that handler rather than a second instance). It looks up the currently registered dev-mode package by name via the same IPackageRegistrationService.FindDevPackages already used for the equivalent safety check in MsixService.SkipRegistration, requires exactly one match whose InstallLocation equals the expected layout exactly, and only then launches. A zero/ambiguous/mismatched result is refused outright -- there is no fallback branch that calls install/register/unregister to "fix" it, because no such branch exists in this method at all. - Commands/RunCommand.cs: extracted the post-registration launch/wait/detach/ debug/unregister-on-exit tail of ExecuteRunPipelineAsync into LaunchRegisteredApplicationAsync (behavior-preserving refactor, verified by the full pre-existing RunCommandTests suite passing unchanged), so the ordinary local run and the new guest-launch verb share one implementation of "what happens after launch" instead of two that could drift. - Commands/RunCommand.Sandbox.cs: phase 2's request now comes from GuestLaunchPlanner instead of GuestRunPlanner when the run also launches; --no-launch is unaffected (there is no launch phase to split off, so the single, already-fully-locked general `run --no-launch` call is unchanged). - Commands/WinAppRootCommand.cs, Helpers/HostBuilderExtensions.cs: wire the new hidden verb into the command tree and DI, reusing the existing RunCommand.Handler singleton rather than registering a second instance. Tests: - New GuestLaunchCommandTests.cs drives RunCommand.Handler.InvokeAsync for a parsed GuestLaunchCommand directly, with FakePackageRegistrationService standing in for the guest's package state. Every test asserts all five of that fake's mutation call lists (install/unregister/unregister-by-full-name/ register-loose-layout/register-sparse) stay empty -- proving there is no code path to any of them, not just that a given flag combination happens not to exercise one. Covers: exact-match launches cleanly; a different layout registered under the same identity (the exact SBX-009 follow-up scenario) refuses and leaves that other registration completely undisturbed; zero matches; ambiguous (multiple) matches; a non-dev-mode registration is never treated as satisfying the expectation; --with-alias never calls LaunchByAumid and never mutates even when the alias launch itself then fails for unrelated reasons; --unregister-on-exit never fires on a mismatch, because that point is never reached. - PackagedSandboxMutationLockTests.cs: added IsGuestLaunchVerb and asserted phase 2's guest exec request in every scenario (plain launch, --with-alias) is the guest-launch verb, never the general run -- a structural, wiring-level check that complements GuestLaunchCommandTests' behavioral proof. Verified: - Extraction of LaunchRegisteredApplicationAsync is behavior-preserving: full pre-existing RunCommandTests suite (138 tests) passes unchanged before and after. - `winapp guest-launch ...` invoked directly: absent from `--help` output (confirms Hidden = true is honored), and correctly refuses with a descriptive message and exit code 1 against a package that is not registered on this machine. - Targeted suite (PackagedSandboxMutationLockTests/GuestLaunchCommandTests/ RunCommandTests/ExecutionTargetOrchestratorTests/TargetRuntimeServiceTests/ SandboxRunTests): 209 total, 208 passed, 0 failed, 1 unrelated inconclusive. - Full suite: 5072 total, 4994 passed, 73 failed (same pre-existing NuGet/ MSIX-build-tools and one crash-dump failure as the 1157c10/7cca0e3f baselines, unrelated to this change), 5 inconclusive, ~6m11s (no new hangs/regressions). No live Windows Sandbox was used. Also audited, per explicit request, whether this session's new tests could be the source of a Windows Firewall consent prompt seen during today's test runs: PackagedSandboxMutationLockTests.cs, GuestLaunchCommandTests.cs, and every production file touched across all three follow-up commits contain no socket, listener, or IPAddress usage of any kind -- the only transport involved is the existing in-process LoopbackTransportPair (System.Threading.Channels, no OS network resource). The one real network listener in this codebase (GuestTcpTransport's `new TcpListener(IPAddress.Any, ...)`) is pre-existing, unrelated to this fix, and is reachable only through SandboxLiveE2ETests, which requires a live Windows Sandbox and was not run in this session. No fix was needed in this commit because no network-binding code was introduced. New commit (not amending 7cca0e3 or 1157c10) on the isolated branch nmetulev-mutation-lock-coverage-gap, based on 4c7fe80 (nmetulev-sandbox-execution-target). Not pushed to PR #779's branch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
DecisionChanges required — two defects worth fixing before this ships. The feature itself is in good shape: it builds clean with zero warnings, nothing in the new test suites failed, and the abstraction, locking, and path-containment design largely earn their size. Both defects are small, local fixes. Must fixBoth are commented inline: 1. A typo in a
2. A stale deployment state reaches Non-blocking
|
598bbe9 to
5e4d3e9
Compare
Addresses all four findings from the independent review of #779, each with a regression test, plus the guest-side process host they gate. HIGH -- wsb.exe could be hijacked from the current directory. Availability resolved PATH but execution passed a bare name, and with UseShellExecute=false CreateProcess searches the application and current directories before PATH. A wsb.exe dropped into a repository a developer happens to be sitting in would win and take over Sandbox control. The resolved absolute path is now cached and always executed, and relative PATH entries are skipped because they resolve against the current directory and would reintroduce the same hole. HIGH -- the mutation lock was thread-affine. A Windows mutex must be released by the thread that acquired it, but this lock is held across awaits, so the continuation that releases it usually runs on a different thread-pool thread. ReleaseMutex threw there, the exception was swallowed, and the mutex stayed held until the original thread exited -- blocking every other winapp process and later surfacing as a false abandonment. Replaced with an exclusively opened file, which has no thread affinity and is closed by the kernel on process death, so crash recovery still works. Abandonment is now detected from an owner record that a clean release clears. Regression tests cover cross-thread release, release after many awaits, and serialized concurrent acquirers. HIGH -- a failed ownership commit stranded a running Sandbox. The instance existed but was never recorded, so every later command refused it as unmanaged, permanently wedging the target through no fault of the user. Commit failure now best-effort stops the exact instance the call created, using its own bounded timeout because the caller's token may already be cancelled, and rethrows the original failure. Compensation failure is covered too. MEDIUM -- wsb exec reported infrastructure failures as guest exit codes. wsb exec never relays guest stdout or stderr, so anything on stderr is wsb's own diagnostic and means the command was never dispatched; returning that code let an infrastructure failure impersonate an application result. It now throws, and only a clean dispatch returns the child's exit code. Also adds GuestProcessHost and GuestJobObject. Killing a process ID alone leaves orphaned grandchildren that keep holding files the next deployment must replace, so children run in a Job Object with KILL_ON_JOB_CLOSE and the tree dies together -- including if the agent crashes. Graceful stop closes standard input first and only terminates the job after a timeout, so a child flushing output or finalizing a recording can still exit cleanly. Streams are forwarded as raw bytes because decoding per chunk would corrupt binary output and split UTF-8 sequences. Assignment tolerates a child that exited before it could be assigned: Windows refuses to assign a terminated process, and a process that has exited has no tree left to contain. This surfaced only under parallel test load. Part of #769. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Cover stopped guest recordings, full frame copy-back and whole-desktop recording with the published CLI. Refresh generated surfaces from the canonical NativeAOT build. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Compile the same internal artifact publisher into host and recorder, reusing each assembly's source-generated JSON context. Preserve a single atomic video publication implementation without exposing recording interop internals. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep architecture policy in runtime discovery rather than substituting the guest architecture for an unresolved app. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Correct overwrite timing and npm duration requirements. Explain that artifacts must be copied into guest work storage before pulling by a work-relative path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Capture desktop screenshots and recordings in the guest, with physical coordinate bounds and scaled-frame mapping. Reuse authenticated warm connections without repeated setup output, preserve generic target context in recovery advice, and cover lifecycle fallback, geometry and recording behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Retain the PR's newer main integration for streamed restore progress. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Combine the new API discovery command tree with execution targets and preserve recursive option inheritance in generated wrappers. Regenerate shipping command surfaces. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Address all four review comments, including screenshot cancellation and display-change paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Remove redeployment rationale from the shared run option description and regenerate schema and npm documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Document the shipping feature rather than the PR's development status. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Remove automatic feature enablement and client installation. Report missing prerequisites and observed pending restart state with advisory setup guidance, and keep agent-assisted setup and reboot decisions under user control. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep the default-versus-custom cleanup behavior and practical guidance in the usage reference; remove the lengthy implementation and path-safety rationale. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Opt managed guest infrastructure and forwarded winapp commands out of uploads without changing application telemetry settings. Add the normalized execution target kind to command completion events and document host-only reporting. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep generic whole-desktop recording and local window behavior while removing the unused host-client capture branch, APIs, and policy-specific tests. Retain ordinary recording regressions and keep the shared blank-buffer helper internal to the automation package. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Remove the XML comment left behind by the deleted NoActivation hook. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Whole-desktop screenshots currently bypass foreign UI workflows by using the non-serializing Observe coordination mode.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 69/262 changed files
- Comments generated: 1
- Review effort level: Balanced
Queue native desktop screenshots behind foreign workflows and hold the desktop section only while measuring and capturing pixels. Preserve non-activating capture and release the section before encoding or publication. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Concurrent lifecycle-state commits can lose ownership data, and guest cleanup follows junctions into unrelated directories.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 69/262 changed files
- Comments generated: 1
- Review effort level: Balanced
Remove linked directory entries without recursing through them when pruning empty managed directories. Cover real junctions with and without stale-file deletions, preserving outside empty directories and files. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Automatic prerequisite setup is missing and the host source-link defense can fail open on attribute-query errors.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 69/262 changed files
- Comments generated: 1
- Review effort level: Balanced
Treat only genuinely missing components as non-links. Surface access and I/O errors rather than continuing a deployment or transfer without checking containment. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Summary
Implements Windows Sandbox execution for
winappper #769: build on the host, then run, debug, copy files, and UI-automate inside the one persistent Windows Sandbox managed by winapp. Windows Sandbox remains behind the internal execution-target boundary so deployment, runtime, UI, and artifact orchestration stay provider-neutral.Public surface:
winapp run <input> --on sandboxwinapp unregister --on sandboxwinapp ui ... --on sandboxwinapp target exec sandbox -- <command> [args...]winapp target push sandbox <host-source> <target-destination>winapp target pull sandbox <target-source> <host-destination>Omitting
--onruns the command here, exactly as before. Sandbox is the only non-local target thisbuild implements, and nothing above names it except the selector value.
Closes #769.
Scope
Delivered: specification phases 1–3, host-cache-first WinUI/.NET runtime provisioning, docs, shipped skills, CLI schema, npm APIs, telemetry exclusions, deterministic fake-transport coverage, and gated live Windows Sandbox E2E.
Still intentionally out of scope: package identity rewriting, another execution target, automatic Sandbox shutdown, a public provider/plugin model, image/snapshot/port/package-manager commands, arbitrary SYSTEM execution, and additional-framework provisioning beyond the guaranteed WinUI/.NET profile.
Target-first public surface
The Sandbox-specific surface this PR originally proposed was replaced before shipping, so no alias
or migration path exists — none is owed, because none of it was ever released.
--sandboxonrun,ui,unregister--on sandbox-a sandbox:<app>,-a sandbox:<pid>--on sandbox -a <app or pid>winapp sandbox execwinapp target exec sandboxwinapp sandbox cp <src> sandbox:<dst>winapp target push sandbox <src> <dst>winapp sandbox cp sandbox:<src> <dst>winapp target pull sandbox <src> <dst>Three things drove the shape.
A resource is not a target. Encoding the target into an application value produced
-a sandbox:6624, which does not extend: a second provider would needhyperv:WinAppTest:MyApp,and a PID copied out of that string silently means something different from the PID next to it. The
selector is now one option, and an app name, PID, or window handle is always read on whichever
target
--onnamed. For the same reason the run result no longer emits a bare, target-implyingprocess ID — it emits
uiTargetArgs, the two arguments a caller needs together, alongside thestructured
executionTarget.A misspelt option must not become a positional argument. System.CommandLine binds an
unrecognised token to a nearby optional positional rather than failing, and it did:
winapp ui inspect --onn=sandbox -a MyAppparsed cleanly, drove the host desktop, and exited 0 —the user asked for another machine and was told it worked. So
--onis registered recursively onthe root, and every command parses the token; a command that cannot honour it rejects it by
name instead of ignoring it; and any dash-leading value bound to a positional before
--isrefused.
TargetSelectionParserTestspins each shape, including the original fail-open case.The verb is the direction.
pushandpullreplace a prefix that had to appear on exactly oneof two paths. Neither path carries a marker, neither side can be mistaken for the other, and a
target path that is rooted, UNC, or escaping the managed work area now fails while the command line
is being read — before a Sandbox is booted and before the mutation lock is taken.
Internally,
ExecutionTargetRefis now a provider kind plus that provider's own ID, and its statekey is a readable slug plus a hash over the exact pair, so two targets can never share a state root
or a lock however they are named.
ITargetOperationExecutoris the boundary shared orchestrationsees;
GuestCommandChannelimplements it, and nothing above the provider boundary reaches atransport.
winapp target list/show/status, a background-desktop provider, and a capabilityenumeration are deliberately not here.
Stage 1 validation
Rebased onto
mainat27048203— 63 commits replayed, three conflicts resolved: the DIregistration
mainreshaped when it moved UI Automation into its own package, a test file whereboth sides added methods, and
NativeMethods.txt, wheremain's removal of the UI AutomationP/Invokes took window APIs the Sandbox window controller still needs.
WinApp.Cli.Testsmainapi.nuget.orgis unreachable from a corp machine, so anything downloadingMicrosoft.Windows.SDK.BuildToolsfails; documented inAGENTS.mdWinApp.UIAutomation.Teststscclean, 230 tests passedscripts/build-cli.ps1validate-llm-docs.ps1,validate-plugin-package.ps1docs/cli-schema.jsonregenerates unchanged--sandbox,sandbox:resource prefix, orwinapp sandboxremains in code, docs, skills, samples, schema, or npm bindingsLive Windows Sandbox E2E on ARM64, gated, all nine tests in one invocation:
target execwith piped stdin, and the EOF closetarget pushrefusing a host directory junctionwsb listempty afterwardsThe two live classes previously raced each other for the single Sandbox Windows allows, because
[DoNotParallelize]only serializes within a class; they now share an exclusion, which is whatmakes the nine-test run above reproducible.
An independent review of the combined diff raised two defects, both fixed and re-verified here: a
mistyped transfer path was rejected only after the target had been prepared, so a typo booted a
Sandbox, took the mutation lock, and reported the infrastructure exit code instead of the
bad-command-line one; and the new positional guard left the generated npm wrappers unable to pass a
value beginning with a dash, because they emit positionals with no
--separator.Implementation
wsb.exelifecycle wrapper with singleton ownership: never adopts or stops an unowned instance.*.deps.json, and*.runtimeconfig.jsonrequirement discovery;run --on <target>preserves packaged and unpackaged, framework-dependent and self-contained, detach, JSON, no-launch, clean, unregister-on-exit, alias, and debug behavior. Direct unpackaged detach is owned by the guest agent and emits a clean host-scoped JSON envelope.target exec,push, andpulluse the same authenticated process/file channel; pulled paths are contained under the requested host destination.Stage checklist
run --on <target>andunregister --on <target>target exec,target push, andtarget pull--onandwinapp targetreplace the Sandbox-specific surfaceLive-testing regressions
Running the feature against a real application surfaced four release-blocking usability faults. Each produced a correct result or a correct refusal eventually, which is why the deterministic suite stayed green: those tests drive orchestration over an in-memory transport, and that is precisely the layer that hides a firewall prompt, a client reconnect, a silent terminal, and a backend field that cannot survive process exit.
DescribeProgresswas called once, fromrun, afterPrepareAsynchad already returned. The UI router printed nothing at all.ITargetProgresson standard error so--jsonstdout stays a single document.ui inspect -a <pid> --sandboxhung, then reportedOperationCanceledInteractiveand setRequiresRealInputfor every verb, contradicting its own comment. That reconnects the Sandbox client, ending the session a previous command left running, and races the command that triggered it.ReadOnlyand assert no input. An unrecognized verb keeps the stricter treatment. Internal cancellation is reported as a target error with an action._guestAddressand_activeMaterialwere instance fields on a backend that every CLI invocation constructs fresh, so the reuse fast path could never execute across processes.The user's own state made 7 and 9 unambiguous:
connection.jsonheld"port": 0while the heartbeat held49726, andtarget-state.jsonhad reachedrevision: 63.Three further defects were found by this work and fixed with it: repairing across winapp versions threw
An unexpected error occurred: IO_SharingViolation_NoFileName(the running agent holds the staged binary — now a specific message andwsb stopguidance); material carrying"port": 0was accepted by a naive range check because it equalsIPEndPoint.MinPort; andTargetStateStore.Commitrebuilds the record field by field, so the newGuestAddresswas silently dropped on every write — the caller's own object still held the value, so nothing failed and the field simply never reached disk. Only live state showed that one.Live verification, fresh Sandbox, real AOT ARM64 binary
connection.json"port": 64266— host-assigned, was0Reusing the running Windows Sandbox agent...run --sandbox --detach --jsonui inspect -a 6500 --sandboxOperationCanceledui inspect -a sandbox:6500Connecting the Windows Sandbox window...FeatureToggleToggleState Off → On viaui invoketarget-state.jsonguestAddress: 172.29.11.192wsb listemptyThirty-two deterministic tests cover what a transport-level test cannot see — that the firewall rule precedes the agent launch, that it names the assigned port and program, that a reused instance is not reconnected for a read-only command, that material survives for a later process, that every persisted state field survives a commit, and how each verb is classified. The router's use of that classification is asserted against the source, because the defect was the wiring rather than the logic.
Final review follow-ups
Findings 1–4 came from the final review; finding 5 came from a focused re-review of those fixes. Each is listed with the tests and docs it required.
SearchOption.AllDirectories, which follows directory junctions and symbolic links. The per-file reparse checks did not compensate, because a file reached through a junction is an ordinary file carrying no reparse attribute — so every one of them passed while content from outside the named folder was hashed, deployed, or copied into the guest.sandbox cpnow share one manual no-follow walk (HostSourceWalker) that tests every directory before descending, which also ends a self-referencing junction at the loop edge instead of recursing until the path length gives out. Deployment refuses such a folder;sandbox cptreats the link as absent, matching the guest-side rule. Ancestors are re-proven immediately before each file is opened, narrowing the unguarded window to the open itself. The now-duplicated per-file check inDeploymentPlannerwas removed so the rule lives in exactly one place.sandbox execandrun --sandbox --with-aliasboth document stdin/stdout/stderr forwarding, but their callbacks carried output handlers only, so standard input silently went nowhere.GuestStandardInputPumpand attached by all three call sites. It starts from the published operation ID (so input piped in before winapp began is not sent for an operation the guest has not heard of), forwards raw bytes, closes guest stdin on host EOF, and stays silent under cancellation rather than announcing EOF into a teardown. Documented no-TTY semantics are unchanged.plugins/winapp/agents/winapp.agent.md, covered none of the Sandbox surface.run/unregister/ui --sandboxandsandbox exec/cp, WSB lifecycle and single-owner rules, prerequisites, the trust boundary, runtime limits, error-code triage, an end-to-end workflow, and pointers to thewinapp-sandboxskill.VcLibsPayloadAcquirerpublished the one downloaded payload into the shared host cache on the strength of identity strings read from inside the downloaded zip — forgeable by anyone able to serve the bytes.AuthenticodeVerifier.IsTrustedMicrosoftSignedbefore publication, with the identity/version/architecture/publisher check retained as a second gate. Either failure discards the staged file and publishes nothing, so a rejected payload never becomes a cache entry a later run would trust. Uses the same verifier seam asWinDbgJsProviderAcquirer.SkipLast(1)). So a root that was a junction was followed wholesale, and a file swapped for a link after enumeration was hashed and copied out of the tree.EnumerateFilesnow rejects a linked root before walking — under both policies, since "treat the link as absent" applied to the root would mean copying nothing while reporting success.EnsureNoLinkOnPath(renamed, because it is no longer ancestors-only) re-checks every component including the root and the leaf, viaFile.GetAttributes, which reports the link's own attributes for files and directories alike.sandbox cpadditionally had the re-check behind aDirectory.Existsguard, and for a single named file the leaf was the only segment — so it was checking nothing at all.Why finding 5 was a real leak, not a theoretical one
The leaf gap was not covered by the existing "changed while preparing to deploy" guard.
VerifyUnchangedstats withFileInfo, which does not follow a symbolic link — it reports the link's own length and timestamp. Hashing opens withFileStream, which does follow. For a zero-byte file (ordinary in build output) replaced by a link that keeps its timestamp,VerifyUnchangedsees a matching length of zero and a matching time and raises nothing, while the hash is computed over the link target's contents.The regression test is built to that exact shape, so only the leaf check can save it. Verified by removing the fix: the test fails with
Content from outside the deployment root was hashed into the snapshot. Six tests fail without these fixes and pass with them. An earlier draft of that test passed for the wrong reason — masked by a size mismatchVerifyUnchangedhappened to catch — which is why the decoy is now size- and timestamp-identical.Swap timing is deterministic rather than raced. The snapshot swap is driven from the
excludepredicate, which runs inside the loop after the enumeration has been materialised; thecpswap is driven from a transport decorator that fires on the first frame the copy sends — the guest list request, issued after enumeration and before any file is read.Tests use real junctions — which need no elevation, unlike symbolic links — and report inconclusive rather than passing vacuously when no link can be created. The junction defect was confirmed directly: the previous enumeration returns
<root>\linked\secret.txtfor a junction pointing outside the root, and the new walk does not.Specification compliance
wsb stop, new instance ID and boot nonce, successful next commandDelivery phases
target exec,target push, andtarget pullValidation
winapp target exec, proving both the forwarding and the EOF close;target push: only the file genuinely inside the folder is transferred, and the outside file never reaches the guest;wsb listis empty afterwards.WinAppSDKcases — including the new host traversal and stdin-pump coverage, runtime discovery/resolution/install/repair, copy containment, connection locking, process/stream ordering, UI routing, artifacts, and run semantics.api.nuget.org/BuildTools class (WinAppSDK Stable/Experimental,E2E_*,GetLatestVersionAsync_*,GetPackageDependenciesAsync_*,CreateMsixPackageAsync_*,PackageCommand_ToolDiscovery_*) plus the known ARM64-emulation dump test; no changed-area failure. Confirmed by running the same suite against the pristine base commit, where those tests already fail. The count is at or below the 73-failure baseline recorded before these changes.scripts/build-cli.ps1 -SkipTests -SkipMsixcompletes and publishes NativeAOT x64 + ARM64, npm and NuGet packages, schema, and docs.docs/cli-schema.jsonregenerates with no drift, since these fixes change behavior rather than CLI surface. Local MSIX creation remains unable to acquire BuildTools because this machine has neitherazureauth/aznor access toapi.nuget.org; CI supplies the authenticated internal feed.Three fix series integrated
Three follow-up series were developed and reviewed independently against this branch's previous head (
4c7fe807) and are now merged onto it. Each was review-clean on its own branch; what follows is what changed when they met.--cleancan no longer masquerade as healthy.PrepareAsyncreturns. Packaged registration is globally locked,--no-launchincluded. The launch that follows is a hiddenguest-launchverb with no code path that registers or unregisters anything, so phase two is structurally unable to mutate.--unregister-on-exittakes a fresh lease after the application exits.sandbox_agent_busyrefusal rather than a queue, with per-connection operation identity, stdin, and cancellation. The host connection lock is narrowed to connection establishment. A handshake the peer closes is classified by asking whether anything is still listening, so a busy agent is no longer repaired as if it were dead.What the merge itself had to decide
The two lock changes overlap and were reconciled rather than picked. They are orthogonal: the connection lock now covers establishment only, so a
PreparedTargetcarries no connection lease and a foreground application never keeps another winapp process from connecting; the mutation lease still outlivesPrepareAsyncand is released by its caller. The concurrency series' ordering was kept, so capabilities are negotiated before the mutation lock is taken — a busy agent answers immediately instead of after the ten-minute lock timeout — and the connection lease is now released before the mutation lock is acquired, removing a nested acquisition that previously existed.Three integration-only defects surfaced, none of which either parent branch could have shown on its own.
PackagedSandboxMutationLockTestshung indefinitely.sandbox exec --cwd <missing>is diagnosed as the missing directory it is. That refusal precedes the process factory. The mutation series' harness used an empty layout, so reconciliation transferred no files, never created the guest deployment directory, and nothing created the registration layout a real guest winapp creates while registering — every exec was refused before reaching the factory the tests wait on.--cleanwipe, as the real guest does), and reports a guest package inventory, which the stop-before-mutate step requires. The production precondition is correct and unchanged: registration cannot succeed without materialising the layout, so no real run reaches unregister-on-exit with it absent.PreparedTargetholds its connection lease "for the whole run", and justifiedAcquireMutationLeaseby a self-deadlock that can no longer happen.PrepareAsyncreleased only the lock, so a second mutating window reuses that channel under the same epoch and needs nothing from the connection lock.scripts/build-cli.ps1failed before generating anything.-p:TreatWarningsAsErrors=true, so four analyzer warnings that are merely noisy in a plain Debug build are hard errors. All four are byte-identical to their parent branches; this is simply the first branch to carry all three and run the script over the result.RegisterPackageAsyncbecomes static (it reads no instance state), three identical expected-result arrays become one shared field, and the runtime harness disposes its mutation lease explicitly. Debug and Release now build with zero warnings under that flag.Combined validation
-p:TreatWarningsAsErrors=true4c7fe807) and the failure sets compared. Every difference is the known offline-environment class: this machine cannot reachapi.nuget.org, and the internal-feed token expires during a 30-minute run, producing401 (Unauthorized)onMicrosoft.Windows.SDK.BuildTools. Re-run with a fresh token and no competing load, all eight differing tests pass, andPackageCommandTestsis 111/112 with the remainder acppwinrt.exe not foundpackage-availability error. No production MSIX, packaging, signing, or NuGet file is touched by this integration.scripts/build-cli.ps1docs/cli-schema.jsonguest-launch,guest-agent, andguest-runtimeeach appear 0 times; the hidden verbs stay hiddentarget push, piped stdin reaching a guest process, packaged framework-dependent WinUI build/register/launch with UI automation, and a long operation not delaying a separate commandsandbox execin flight, a concurrent shortsandbox execreturned in 0.8s instead of queueing behind itwinapprules on the machine belong to earlier worktreeswsb list --rawis emptyAn independent review of the merged diff, scoped to integration mistakes, found no functional defects; item 2 above is the one issue it raised.
Items left open by this integration, including SBX-002, are dispositioned in Independent review findings below.
Dependency and known limits
winapp uiagents #767 is draft. Owner forwarding is complete; actual guest scheduling becomes authoritative when Cooperative UI turns for concurrentwinapp uiagents #767 lands.docs/sandbox-execution.md, every path component including the file itself is re-checked immediately before each read, but this is still not a handle-relative TOCTOU proof against a mutually trusted co-resident guest process racing a link into place in the window between that final check and the open. Closing that fully requires handle-relative no-follow opens on every path component, which v1 does not implement.Independent review findings, final disposition
The independent reviewer's remaining items, and where each landed.
--detachkeeps the app and the Sandbox running, without qualification. An unpackaged detached app does not survive guest-agent repair.docs/sandbox-execution.mdgains Detached apps and the agent's lifetime, cross-referenced from the Job Object paragraph that is its mechanism;docs/usage.md,plugins/winapp/skills/winapp-sandbox/SKILL.md, and the Copilot agent reference all point at it, and both surfaces' troubleshooting tables gain a no-error-code row. Containment is preserved.sandbox cp→sandbox execPowerShell sequence fails on a fresh Sandbox.Restricted, so the copied-in script is refused withUnauthorizedAccess. Every copy of the example now passes-ExecutionPolicy Bypass. The agent reference's copy was additionally using a rootedsandbox:C:\...guest path, whichsandbox cprefuses outright; it now matches the canonical relative form.target exec --jsonrelays the target process's raw stdout rather than a JSON envelope, which the docs now state explicitly.execpath rather than a Sandbox-specific defect, and it is pre-existing. Confirmed still present and unchanged by the integration. Dispositioned separately.winappfirewall rules on the test machine belong to earlier worktrees, and this branch's test binary has none, because the socket tests now bind loopback, which is firewall-exempt.Recommendation: proceed on the merits of the code; the remaining blocker is the #767 dependency, not any finding above.
Documentation-only validation for this change
docs/cli-schema.jsonregenerates unchanged, as it must for a docs-only change.validate-llm-docs.ps1(which also runsvalidate-plugin-package.ps1) andvalidate-mslearn-docs.ps1both pass, with the MS Learn warning count unchanged — the change adds no blockquote callouts. Nodocs/tocfile exists in this repo, so none needed porting.Integration with main (merge 369a737)
Product decision
--on sandboxsets up and reuses Windows Sandbox by itself. There is no separate init, adopt, orenable verb and no prompt: winapp enables the optional feature where it can, waits for the
Store-delivered client, starts an instance under a caller-assigned GUID it persists, recovers a
half-finished start, and takes over the one instance already running rather than refusing or
duplicating it.
Two things it deliberately never does: reboot the machine and stop a Sandbox — least of all
one it adopted, which belongs to the user and may hold hours of their work.
dismis always invokedwith
/NoRestart, andERROR_SUCCESS_REBOOT_REQUIRED(3010) is reported for the user to act on.IWindowsSandboxCli.StopAsynchas zero production callers; onlywinapp sandbox-externaltooling and tests call it. UAC refusal, a pending restart, policy, and timeout each surface as their
own stable error rather than a generic failure.
What the merge changed
Main landed the
$targetnametoken$fix for Windows App SDK self-contained apps (#793) while thisbranch was adding automatic setup and adoption. Exactly one file conflicted —
MsixServiceIdentityTests.cs— and only because both sides appended a test at the same point. Bothtests are kept verbatim; the resolution adds the one brace that closes the helper this branch
introduced.
The part git merged silently is the part worth stating. Main taught executable auto-detection to
skip
RestartAgent.exeandDeploymentAgent.exe; this branch splitAddLooseLayoutIdentityAsyncso a
--on sandboxrun materializes a layout without installing a runtime or registering a package onthe host. They meet in the manifest-processing step both outcomes share, which is the outcome worth
having: a Windows App SDK self-contained app now resolves
$targetnametoken$identically whether itis registered here or shipped to the guest, so a placeholder that resolves locally cannot fail in the
Sandbox.
Independent review of the integration
Two independent reviews were run against the merge itself.
26 main = 1377 lines;
0deletions against either parent). For all 7 files main touched and all175 the series touched, the diff the merge applied is byte-identical to that parent's own diff from
the merge base — no evil merge. The partial-class halves define no member twice, and the
Materializedearly-returns still precede bothEnsureWindowsAppRuntimeInstalledAsyncandRegisterLooseLayoutPackageAsync, so materialization still mutates nothing on the host.path safety, secrets, and the state store were each verified sound. The elevated target is a fixed
Environment.SystemDirectory\dism.exewith per-argumentArgumentList;wsb.exeis resolved fromthe known-folder API and gated on being a reparse point with the package registered, never from
PATH or CWD; and no persisted field is ever used as a path segment — bootstrap folders derive from
a SHA-256 epoch token, so a planted instance ID cannot steer a write.
Test results
origin/mainThe 75 failures are identical on both sides — the same 59 test methods, zero new and zero fixed —
and every one is
api.nuget.orgbeing unreachable from a corporate machine(
Failed to install Microsoft.Windows.SDK.BuildTools: The SSL connection could not be established).This branch adds 646 tests and introduces no new failure.
scripts/build-cli.ps1completes: NativeAOT x64 and arm64, NuGet, and npm all build;docs/cli-schema.jsonregenerates byte-identical;validate-plugin-package.ps1passes. Thehidden
guest-agent,guest-launch, andguest-runtimeverbs are absent from both the schema and--help. MSIX packaging is the one step that cannot run here — it shells out towinapp update,which needs the same blocked feed.
Live evidence
Run on this ARM64 host against the real NativeAOT
winapp.exe(
0.6.3-nmetulev-sandbox-auto-setup-integration.75), touching only instances this run created.08c14300-a406-42a4-8cd1-7ce4e4bf270d, given a marker file and a long-runningping -t. Withwinapp holding no ownership record,
winapp sandbox execreported "Using the Windows Sandboxthat is already running" and recorded
instanceOrigin: "Adopted"against that exact ID, withan explicit
bootstrappedEpochof<instance>:<bootNonce>. Both the marker and the runningprocess survived.
as
1b43e443-eb2e-4f34-aae7-dd5a1d493849,instanceOrigin: "Created", andwsb listreportedexactly that ID. A separate check confirmed
wsb start --id <guid>returns the caller's GUIDverbatim and the instance stayed listed past 120 s.
"Reusing the running Windows Sandbox agent" in 0.8–0.9 s, with the epoch unchanged and
zero new
WindowsSandboxRemoteSessionprocesses.sandbox exec -- cmd /c exit 7returned 7. Separately,wsb exec --rawreturns{"ExitCode": 0}on success, while a dispatch failure returns noExitCodeat all and is classified as infrastructure — the two are never conflated.winapp ui list-windows --sandboxenumerated the guest'sown shell windows (
Search,Start,Shell_TrayWnd,Program Manager).command. This worktree created no firewall rules, and
winapp.exehas none at all.ui list-windows --sandbox --jsonstdout parses as JSON with no progress textmixed in; progress is visible on the normal path.
it created. A client that predated the run was left untouched, and
wsb listended empty.Gaps — honest list
client initialized, so the uninitialized, UAC-prompt, and
3010restart-required paths were neverreached. They remain covered only by deterministic tests. The same applies to a real
0x80070002. These need a disposable device with Windows Sandbox not yet set up: runwinapp run <app> --sandboxas a standard user on such a machine, accept the UAC prompt, andcapture full
--verboseoutput plus the exit code for the enable, restart-required, andpost-restart first-run cases.
both were fixture defects rather than product ones; they are fixed in
598bbe98, not disabled.ManuallyStartedSandbox_…seeded the guest withwsb exec --run-as ExistingLoginimmediatelyafter
wsb start, which attaches no client — so there was no logon session and wsb refused with0x80070520. It now connects a client first, as a user-started Sandbox actually has, and waitsuntil an
ExistingLogincommand really succeeds.ColdStartThenWarmReuse_…asserted warm reuseagainst a backend constructed without the optional state store; since
RememberConnectionis theonly writer of
BootstrappedEpoch, andIsWarmandReusedboth derive from it, warm reuse wasunobservable rather than broken. The fixture is now wired like the CLI's own container, and the
warm prepare uses its own backend and orchestrator so it models a genuinely separate process.
NoInputDesktopsingle-reconnect path could not be triggered live. Killing theWindowsSandboxRemoteSessionclient did not end the guest's logon session, so the agent keptreporting a usable input desktop and — correctly — no reconnect was attempted and no client was
duplicated. The one-reconnect bound stays covered by
ClosedClient_IsReconnectedOnceAndThenWorksand its siblings.