Skip to content

Run and automate apps in persistent Windows execution targets - #779

Open
Nikola Metulev (nmetulev) wants to merge 123 commits into
mainfrom
nmetulev-sandbox-execution-target
Open

Nikola Metulev (nmetulev) wants to merge 123 commits into
mainfrom
nmetulev-sandbox-execution-target

Conversation

@nmetulev

@nmetulev Nikola Metulev (nmetulev) commented Aug 21, 2026

Copy link
Copy Markdown
Member

Summary

Implements Windows Sandbox execution for winapp per #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 sandbox
  • winapp unregister --on sandbox
  • winapp ui ... --on sandbox
  • winapp target exec sandbox -- <command> [args...]
  • winapp target push sandbox <host-source> <target-destination>
  • winapp target pull sandbox <target-source> <host-destination>

Omitting --on runs the command here, exactly as before. Sandbox is the only non-local target this
build 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.

Removed Replacement
--sandbox on run, ui, unregister --on sandbox
-a sandbox:<app>, -a sandbox:<pid> --on sandbox -a <app or pid>
winapp sandbox exec winapp target exec sandbox
winapp 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 need hyperv: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 --on named. For the same reason the run result no longer emits a bare, target-implying
process ID — it emits uiTargetArgs, the two arguments a caller needs together, alongside the
structured 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 MyApp parsed cleanly, drove the host desktop, and exited 0 —
the user asked for another machine and was told it worked. So --on is registered recursively on
the 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 -- is
refused. TargetSelectionParserTests pins each shape, including the original fail-open case.

The verb is the direction. push and pull replace a prefix that had to appear on exactly one
of 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, ExecutionTargetRef is now a provider kind plus that provider's own ID, and its state
key 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. ITargetOperationExecutor is the boundary shared orchestration
sees; GuestCommandChannel implements it, and nothing above the provider boundary reaches a
transport. winapp target list/show/status, a background-desktop provider, and a capability
enumeration are deliberately not here.

Stage 1 validation

Rebased onto main at 27048203 — 63 commits replayed, three conflicts resolved: the DI
registration main reshaped when it moved UI Automation into its own package, a test file where
both sides added methods, and NativeMethods.txt, where main's removal of the UI Automation
P/Invokes took window APIs the Sandbox window controller still needs.

Check Result
Solution build, Debug and Release 0 errors, 0 warnings
WinApp.Cli.Tests 5000 tests, 4917 passed, 70 failed, 13 skipped
Regressions against the same suite on rebased main none — every failure is in the pre-change baseline set
Those 70 failures api.nuget.org is unreachable from a corp machine, so anything downloading Microsoft.Windows.SDK.BuildTools fails; documented in AGENTS.md
WinApp.UIAutomation.Tests 380 tests, 378 passed, 0 failed
Scripts Pester suite 72 passed, 0 failed
npm tsc clean, 230 tests passed
scripts/build-cli.ps1 completed; NativeAOT published for x64 and arm64; only MSIX packaging fails, on the same NuGet reachability
validate-llm-docs.ps1, validate-plugin-package.ps1 pass — docs/cli-schema.json regenerates unchanged
Removed-surface audit no --sandbox, sandbox: resource prefix, or winapp sandbox remains in code, docs, skills, samples, schema, or npm bindings

Live Windows Sandbox E2E on ARM64, gated, all nine tests in one invocation:

Check Result
Cold start on an empty machine passed; host foreground window unchanged; no firewall consent dialog
Caller-assigned instance ID honoured passed
Warm reuse from a separate process passed, same instance and epoch
Manually started Sandbox adopted without losing its contents passed
target exec with piped stdin, and the EOF close passed
target push refusing a host directory junction passed
Packaged framework-dependent WinUI: run, inspect, set-value, invoke, wait-for, screenshot, record passed
Concurrent channels — a long operation does not delay a separate command passed
Cleanup wsb list empty afterwards

The 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 what
makes 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.

The sections below were written during earlier review and live-testing rounds and keep the
Sandbox-specific spelling those rounds used. They are the record of how each defect was found
rather than instructions — translate any command through the table above.

Implementation

  • Provider-neutral target/epoch, backend and guest-transport contracts, stable Sandbox error envelope, atomic revisioned state, mutation lock, and one-channel connection lock.
  • Typed wsb.exe lifecycle wrapper with singleton ownership: never adopts or stops an unowned instance.
  • Read-only bootstrap share, bounded guest-writable result share, architecture-matched AOT guest binary plus native companion, hash-bound heartbeat, foreground-preserving/off-screen non-minimized client placement, and authenticated AES-GCM TCP transport.
  • Persistent guest agent with dynamic readiness checks, job-object process-tree containment, staged version/update primitives, argument fidelity, stdin/stdout/stderr ordering, cancellation, and exact guest exit codes.
  • Exact deployment reconciliation, dirty repair, guest-managed roots, package ownership, and exact-match unregister protection.
  • One shared no-follow host traversal for every folder that is hashed, deployed, or copied into the guest, with ancestors re-proven immediately before each read.
  • One shared standard-input pump used by every command documented as streaming stdin, started from the published operation ID so eagerly piped input is not dropped.
  • Host-cache-first runtime provisioning:
    • package manifest, unpackaged *.deps.json, and *.runtimeconfig.json requirement discovery;
    • complete Windows App Runtime inventory (Framework, DDLM, Main, Singleton);
    • .NET Runtime and .NET Desktop Runtime from compatible host installs or official NuGet runtime packs, cached as portable layouts and installed side-by-side in the guest;
    • allowlisted official VCLibs fallback, Authenticode Microsoft-signature verification followed by identity/version/architecture/publisher validation, authenticated staging, full-graph verification before every launch, phase timing, and dirty journal repair;
    • never removes/downgrades shared runtimes or forces shutdown of live apps.
  • 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.
  • UI routing intercepts once before local handlers, preserves owner context, requires explicit targets, routes every verb, and atomically copies screenshots/recordings to requested host paths with JSON path rewriting.
  • target exec, push, and pull use the same authenticated process/file channel; pulled paths are contained under the requested host destination.

Stage checklist

  • S0 Foundations — target/epoch, backend + transport contracts, stable errors, atomic state, mutation lock
  • S1 Guest protocol — AES-GCM framing, HKDF keys, PSK handshake, replay/order rejection, fuzz target
  • S2 Windows Sandbox backend — lifecycle, ownership, foreground-safe client, bootstrap, firewall, TCP transport, epoch recovery
  • Draft PR opened
  • S3 Guest agent — hidden mode, session/readiness verification, heartbeat, job containment, command server
  • S4 Agent versioning — version/hash planning, staged self-test, activation/rollback primitives, never downgrade
  • S5 Deployment/package ownership — exact reconciliation, dirty repair, ownership records, exact unregister
  • S6 Runtime provisioning — .NET Runtime, .NET Desktop Runtime, full Windows App Runtime, VCLibs, cache-first acquisition, guest staging/install/verify, journal repair, no downgrade/removal
  • S7 run --on <target> and unregister --on <target>
  • S8 UI routing — recursive selector routing and owner forwarding
  • S9 Artifacts — bounded staging, size/hash verification, atomic host publish, path rewriting
  • S10 target exec, target push, and target pull
  • S11 Telemetry exclusions, schema, npm, docs, shipped skill
  • S12 Gated live Windows Sandbox E2E
  • Final review blockers addressed — see below
  • Follow-up re-review blocker addressed — source-root and file-leaf holes closed (finding 5)
  • Live-testing regressions fixed — findings 6–9 below
  • Execution targets, stage 1--on and winapp target replace the Sandbox-specific surface

Live-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.

# Symptom Cause Fix
6 Terminal frozen for seconds after the host build, with no output DescribeProgress was called once, from run, after PrepareAsync had already returned. The UI router printed nothing at all. Every slow phase reports before it starts, via ITargetProgress on standard error so --json stdout stays a single document.
7 "Windows Firewall has blocked some features of winapp" on first run The agent bound port 0 and the host created the inbound rule afterwards, from the port the heartbeat reported. Windows raises consent when a program binds a listening socket with no matching rule, so the rule was always too late. The host assigns a dynamic-range port, writes it into the bootstrap material, and creates the rule — scoped to that program and port — before launching the agent. Stale rules are removed rather than accumulating.
8 ui inspect -a <pid> --sandbox hung, then reported OperationCanceled The router requested Interactive and set RequiresRealInput for 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. Verbs are classified from the parsed command: read-only UI Automation verbs prepare ReadOnly and assert no input. An unrecognized verb keeps the stricter treatment. Internal cancellation is reported as a target error with an action.
9 Every command started a new Sandbox; the previous client showed "the connection was lost, reconnect?" _guestAddress and _activeMaterial were instance fields on a backend that every CLI invocation constructs fresh, so the reuse fast path could never execute across processes. Connection material is read back from the bootstrap file and the guest address is cached in target state, so a later process reconnects to the agent already serving. A failed reconnect repairs the agent inside the same instance, never the instance.

The user's own state made 7 and 9 unambiguous: connection.json held "port": 0 while the heartbeat held 49726, and target-state.json had reached revision: 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 and wsb stop guidance); material carrying "port": 0 was accepted by a naive range check because it equals IPEndPoint.MinPort; and TargetStateStore.Commit rebuilds the record field by field, so the new GuestAddress was 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

Check Result
Cold start 19s; progress ordered on stderr; no firewall consent dialog
Assigned port connection.json "port": 64266 — host-assigned, was 0
Warm reuse, separate process 1s, same instance and epoch, Reusing the running Windows Sandbox agent...
run --sandbox --detach --json Exit 0; stdout pure JSON, all progress on stderr
ui inspect -a 6500 --sandbox Exit 0 in 1s, full UI tree — previously hung then OperationCanceled
ui inspect -a sandbox:6500 Exit 0 in 0.8s, identical tree
Read-only verbs Never reconnect the client — no Connecting the Windows Sandbox window...
Real input FeatureToggle ToggleState Off → On via ui invoke
Screen capture 110 KB PNG copied to the host path
Address cache target-state.json guestAddress: 172.29.11.192
Instance count Exactly one, shared by 8+ independent CLI processes
Cleanup Only the instance these tests created was stopped; wsb list empty

Thirty-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.

# Finding Severity Resolution
1 Host source folders were enumerated with 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. High Deployment snapshots and sandbox cp now 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 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 was removed so the rule lives in exactly one place.
2 sandbox exec and run --sandbox --with-alias both document stdin/stdout/stderr forwarding, but their callbacks carried output handlers only, so standard input silently went nowhere. High 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 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.
3 The shipped agent, plugins/winapp/agents/winapp.agent.md, covered none of the Sandbox surface. High 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, an end-to-end workflow, and pointers to the winapp-sandbox skill.
4 VcLibsPayloadAcquirer published 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. Recommended, treated as required 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. Uses the same verifier seam as WinDbgJsProviderAcquirer.
5 Follow-up re-review: the new no-follow walk never checked the source root itself, and the pre-read re-check excluded the file leaf (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. High EnumerateFiles now 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, via File.GetAttributes, which reports the link's own attributes for files and directories alike. sandbox cp additionally had the re-check behind a Directory.Exists guard, 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. VerifyUnchanged stats with FileInfo, which does not follow a symbolic link — it reports the link's own length and timestamp. Hashing opens with FileStream, which does follow. For a zero-byte file (ordinary in build output) replaced by a link that keeps its timestamp, VerifyUnchanged sees 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 mismatch VerifyUnchanged happened 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 exclude predicate, which runs inside the loop after the enumeration has been materialised; the cp swap 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.txt for a junction pointing outside the root, and the new walk does not.

Specification compliance

# Acceptance criterion Classification Evidence
1 Cold start/reuse without changing host foreground Implemented + tested Live foreground-handle assertion, cold start, same-epoch warm reconnect
2 Differential rerun/delete stale state without affecting another app Implemented + tested Exact reconciliation/dirty-repair tests; live warm reuse. Cross-app live concurrency remains in row 7
3 UI commands execute entirely in guest with existing output/exit semantics Implemented + tested Fake transport plus live inspect, invoke, set-value, wait, screenshot, record
4 Explicit UI targets and dynamic input readiness Implemented + tested Routing/readiness tests and live input workflow
5 Verified atomic screenshots/recordings; no partial success Implemented + tested Transfer interruption/size/hash tests and live PNG + playable H.264 artifact generation
6 PID/HWND scoped to epoch; stale values rejected Implemented + tested Epoch/stale-handle tests and external-stop generation change
7 Two distinct applications run concurrently Implemented but live-untested Independent deployment/process ownership coverage; not repeated in the final live lane
8 External termination detected and next command recovers Implemented + tested Live external wsb stop, new instance ID and boot nonce, successful next command
9 Unmanaged Sandbox/packages/provisioned packages never modified Implemented + tested Ownership/refusal tests and prior live unmanaged-instance refusal
10 Agent update staged/self-tested/rollback-safe/compatible/never downgraded Implemented but live-untested Version/update/rollback unit coverage; no destructive live upgrade injection
11 Generic exec/cp preserve semantics and exclude sensitive telemetry Implemented + tested Exact exit/stdout and 64 KiB byte-identical live round trip, live piped-stdin round trip, live junction containment, plus telemetry tests
12 Unsupported forms fail structurally with no local fallback Implemented + tested Orchestrator/error tests
13 Deployment/UI/artifact tests use fake guest transport without WSB Implemented + tested Real host channel + real guest server/file service over in-memory transport
14 Cooperative UI Turns ownership/scheduling across forwarding Deferred by spec for scheduling Owner resolution/scoping/forwarding is implemented and tested. Actual Observe/TurnShared/DesktopExclusive scheduling remains dependent on draft #767; no Sandbox-specific scheduler was added

Delivery phases

Phase Classification
1. Lifecycle, transport, safe bootstrap/update foundations, state/epoch, deployment, WinUI run Implemented + tested
2. UI routing, owner forwarding, readiness, artifacts, recording Implemented + tested, except Cooperative UI Turns scheduling deferred by spec to #767
3. target exec, target push, and target pull Implemented + tested
4. Additional application-framework provisioning Deferred by spec
5. Second execution target Deferred by spec

Validation

  • Live ARM64 Windows Sandbox — full gated suite green, 6/6, nothing skipped:
    • cold start, foreground preservation, same-epoch warm reuse;
    • authenticated guest agent/TCP, exact guest exit code, stdout ordering;
    • 64 KiB file round trip;
    • piped standard input reaching a guest process through winapp target exec, proving both the forwarding and the EOF close;
    • a real host directory junction not followed by target push: only the file genuinely inside the folder is transferred, and the outside file never reaches the guest;
    • packaged framework-dependent WinUI build/provision/register/launch;
    • UI inspect, invoke, set-value, toggle, wait postcondition;
    • screenshot copied to host with rewritten JSON path;
    • H.264 recording copied to host;
    • strict owned-instance cleanup; only the instance the test created is ever stopped, and wsb list is empty afterwards.
  • Deterministic changed-area suites pass — 522 tests, the only failures being the 4 known corporate-network WinAppSDK cases — 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.
  • Full native suite: 4,991 total; 4,912 passed; 68 failed; 11 skipped. Every failure is the existing corporate-network 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.
  • npm: format, lint, compile, package, and generated APIs/docs all pass.
  • scripts/build-cli.ps1 -SkipTests -SkipMsix completes and publishes NativeAOT x64 + ARM64, npm and NuGet packages, schema, and docs. docs/cli-schema.json regenerates with no drift, since these fixes change behavior rather than CLI surface. Local MSIX creation remains unable to acquire BuildTools because this machine has neither azureauth/az nor access to api.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.

Series What it delivers
Rerun lifecycle (SBX-006/008) A rerun stops the previous run's instance before it mutates that instance's layout, verified by package identity plus the layout it is actually registered from, or by PID and process start time for an unpackaged launch. A deployment stays dirty through both payload and registration-layout cleanup, so an interrupted --clean can no longer masquerade as healthy.
Mutation lock and launch-only phase The mutation lease now spans the caller's whole mutating window — runtime provisioning, deployment reconciliation, package registration — instead of ending when PrepareAsync returns. Packaged registration is globally locked, --no-launch included. The launch that follows is a hidden guest-launch verb with no code path that registers or unregisters anything, so phase two is structurally unable to mutate. --unregister-on-exit takes a fresh lease after the application exits.
Channel concurrency (SBX-009) The guest agent serves concurrent authenticated channels, bounded at eight with an immediate sandbox_agent_busy refusal 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 PreparedTarget carries no connection lease and a foreground application never keeps another winapp process from connecting; the mutation lease still outlives PrepareAsync and 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.

# What was wrong Why it only appeared here Fix
1 All nine PackagedSandboxMutationLockTests hung indefinitely. The lifecycle series taught the agent to refuse an exec whose working directory does not exist, so 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. Harness only. It now carries real layout content, creates the registration layout when the registration request starts (after any --clean wipe, 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.
2 Three doc passages still said a PreparedTarget holds its connection lease "for the whole run", and justified AcquireMutationLease by a self-deadlock that can no longer happen. Each statement was true on its own branch and false only after the connection lock was narrowed. Rewritten to the surviving rationale: the channel is still live because PrepareAsync released only the lock, so a second mutating window reuses that channel under the same epoch and needs nothing from the connection lock.
3 scripts/build-cli.ps1 failed before generating anything. It publishes Release and builds tests with -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. RegisterPackageAsync becomes 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

Check Result
Build, Debug and Release, -p:TreatWarningsAsErrors=true 0 warnings, 0 errors
Targeted execution-target suites 333 passed, 0 failed (orchestrator, packaged mutation lock, guest-launch, channel concurrency, handshake sockets, guest command server, guest files, run/sandbox/UI-router, unregister, mutation and connection locks, runtime service)
Full native suite 5,148 total; 5,094 passed; 42 failed; 12 skipped
Failure classification The same suite was run against the pristine base commit (4c7fe807) and the failure sets compared. Every difference is the known offline-environment class: this machine cannot reach api.nuget.org, and the internal-feed token expires during a 30-minute run, producing 401 (Unauthorized) on Microsoft.Windows.SDK.BuildTools. Re-run with a fresh token and no competing load, all eight differing tests pass, and PackageCommandTests is 111/112 with the remainder a cppwinrt.exe not found package-availability error. No production MSIX, packaging, signing, or NuGet file is touched by this integration.
scripts/build-cli.ps1 Completes end to end: NativeAOT x64 + ARM64, npm, NuGet, MSIX, schema, docs
docs/cli-schema.json Regenerates byte-identical — no public CLI surface added or removed. guest-launch, guest-agent, and guest-runtime each appear 0 times; the hidden verbs stay hidden
Live gated E2E, fresh Sandbox, real AOT ARM64 binary 7/7 passed — support probe with no side effects, cold start with host foreground preserved, warm reuse, 64 KiB file round trip, junction not followed by target 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 command
Live concurrency, measured directly With a 30-second sandbox exec in flight, a concurrent short sandbox exec returned in 0.8s instead of queueing behind it
Firewall No consent dialog, and no firewall rule exists for this worktree's test binary — the socket tests now bind loopback, which is exempt. The only winapp rules on the machine belong to earlier worktrees
Cleanup Only instances these tests created were stopped; wsb list --raw is empty

An 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

  • The PR remains draft while Cooperative UI Turns implementation Cooperative UI turns for concurrent winapp ui agents #767 is draft. Owner forwarding is complete; actual guest scheduling becomes authoritative when Cooperative UI turns for concurrent winapp ui agents #767 lands.
  • SBX-014 — a detached unpackaged Sandbox app lives only for the current guest agent's lifetime. winapp starts it as a child of the agent, and the agent contains everything it starts so a cancelled command cannot strand guest processes holding files the next deployment must replace. The same containment ends the app when the agent does, including during the automatic agent repair a later command performs without asking. Nothing reports an error at that moment, because nothing was waiting on the app; rerunning the command brings it back. Independent A/B testing showed a packaged app surviving the same repair, because Windows activates it rather than the agent starting it — stated in the docs as observed rather than guaranteed. Closing or restarting the Sandbox still ends everything either way. Documented rather than changed: the containment is the half worth keeping, and removing it to lengthen a detached app's life would reintroduce exactly the orphaned-process problem the rerun-lifecycle work exists to prevent.
  • Managed path validation rejects traversal and existing reparse points, and host source folders are now walked without following them — root included — so a link cannot widen what is deployed or copied. As documented in 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.
  • ARM64 received the full live E2E. x64 is AOT-built/package-validated but not live-run on this ARM64 host.

Independent review findings, final disposition

The independent reviewer's remaining items, and where each landed.

# Finding Severity Disposition
SBX-014 Docs promised --detach keeps the app and the Sandbox running, without qualification. An unpackaged detached app does not survive guest-agent repair. Medium — documentation accuracy Fixed by documentation. docs/sandbox-execution.md gains 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.
The documented sandbox cpsandbox exec PowerShell sequence fails on a fresh Sandbox. Medium — the documented steps do not work Fixed by documentation. A fresh Sandbox starts with the execution policy at Restricted, so the copied-in script is refused with UnauthorizedAccess. Every copy of the example now passes -ExecutionPolicy Bypass. The agent reference's copy was additionally using a rooted sandbox:C:\... guest path, which sandbox cp refuses outright; it now matches the canonical relative form.
SBX-002 target exec --json relays the target process's raw stdout rather than a JSON envelope, which the docs now state explicitly. Low Not changed. This is parity with the local exec path rather than a Sandbox-specific defect, and it is pre-existing. Confirmed still present and unchanged by the integration. Dispositioned separately.
SBX-010, SBX-012 Low Deferred. Neither blocks this PR.
SBX-013 Firewall-rule growth after the rule-lifecycle fix. No growth observed. Re-checked after the combined work: the only winapp firewall 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.json regenerates unchanged, as it must for a docs-only change. validate-llm-docs.ps1 (which also runs validate-plugin-package.ps1) and validate-mslearn-docs.ps1 both pass, with the MS Learn warning count unchanged — the change adds no blockquote callouts. No docs/toc file exists in this repo, so none needed porting.


Integration with main (merge 369a737)

Product decision

--on sandbox sets up and reuses Windows Sandbox by itself. There is no separate init, adopt, or
enable 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. dism is always invoked
with /NoRestart, and ERROR_SUCCESS_REBOOT_REQUIRED (3010) is reported for the user to act on.
IWindowsSandboxCli.StopAsync has zero production callers; only winapp sandbox-external
tooling 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 this
branch was adding automatic setup and adoption. Exactly one file conflicted —
MsixServiceIdentityTests.cs — and only because both sides appended a test at the same point. Both
tests 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.exe and DeploymentAgent.exe; this branch split AddLooseLayoutIdentityAsync
so a --on sandbox run materializes a layout without installing a runtime or registering a package on
the 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 it
is 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.

  • Merge correctness — no issues. The conflicted file is an exact union (1223 base + 128 series +
    26 main = 1377 lines; 0 deletions against either parent). For all 7 files main touched and all
    175 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
    Materialized early-returns still precede both EnsureWindowsAppRuntimeInstalledAsync and
    RegisterLooseLayoutPackageAsync, so materialization still mutates nothing on the host.
  • Security of setup/adoption — no vulnerabilities. Elevation, control-plane binding, adoption,
    path safety, secrets, and the state store were each verified sound. The elevated target is a fixed
    Environment.SystemDirectory\dism.exe with per-argument ArgumentList; wsb.exe is resolved from
    the 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

Suite Result
Sandbox + execution-target, Debug 940 passed / 0 failed / 10 gated-live
Sandbox + execution-target, Release 940 passed / 0 failed / 10 gated-live
Timing/socket subset, Release x5 129/129 every run — no flakes
Full solution, Debug 5267 tests, 5177 passed, 75 failed
Full solution on unmodified origin/main 4621 tests, 4541 passed, 75 failed

The 75 failures are identical on both sides — the same 59 test methods, zero new and zero fixed —
and every one is api.nuget.org being 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.ps1 completes: NativeAOT x64 and arm64, NuGet, and npm all build;
docs/cli-schema.json regenerates byte-identical; validate-plugin-package.ps1 passes. The
hidden guest-agent, guest-launch, and guest-runtime verbs are absent from both the schema and
--help. MSIX packaging is the one step that cannot run here — it shells out to winapp 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.

  • Adoption preserves the user's work. A Sandbox was started by hand as
    08c14300-a406-42a4-8cd1-7ce4e4bf270d, given a marker file and a long-running ping -t. With
    winapp holding no ownership record, winapp sandbox exec reported "Using the Windows Sandbox
    that is already running"
    and recorded instanceOrigin: "Adopted" against that exact ID, with
    an explicit bootstrappedEpoch of <instance>:<bootNonce>. Both the marker and the running
    process survived.
  • Created path and assigned IDs. With state cleared, winapp started its own instance in 18.1 s
    as 1b43e443-eb2e-4f34-aae7-dd5a1d493849, instanceOrigin: "Created", and wsb list reported
    exactly that ID. A separate check confirmed wsb start --id <guid> returns the caller's GUID
    verbatim and the instance stayed listed past 120 s.
  • Warm reuse never duplicates a client. Four further independent winapp processes each reported
    "Reusing the running Windows Sandbox agent" in 0.8–0.9 s, with the epoch unchanged and
    zero new WindowsSandboxRemoteSession processes.
  • Guest exit codes are the guest's. sandbox exec -- cmd /c exit 7 returned 7. Separately,
    wsb exec --raw returns {"ExitCode": 0} on success, while a dispatch failure returns no
    ExitCode at all and is classified as infrastructure — the two are never conflated.
  • UI routing really runs in the guest. winapp ui list-windows --sandbox enumerated the guest's
    own shell windows (Search, Start, Shell_TrayWnd, Program Manager).
  • Host is left alone. The host foreground window was byte-identical before and after every
    command. This worktree created no firewall rules, and winapp.exe has none at all.
  • JSON is pure. ui list-windows --sandbox --json stdout parses as JSON with no progress text
    mixed in; progress is visible on the normal path.
  • Cleanup. Only the two instances this run created were stopped, plus only the client processes
    it created. A client that predated the run was left untouched, and wsb list ended empty.

Gaps — honest list

  • First-run setup could not be exercised. This host already had the feature enabled and the
    client initialized, so the uninitialized, UAC-prompt, and 3010 restart-required paths were never
    reached. 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: run
    winapp run <app> --sandbox as a standard user on such a machine, accept the UAC prompt, and
    capture full --verbose output plus the exit code for the enable, restart-required, and
    post-restart first-run cases.
  • The full gated live suite now passes 9/9. Two of its tests were failing on a real host, and
    both were fixture defects rather than product ones; they are fixed in 598bbe98, not disabled.
    ManuallyStartedSandbox_… seeded the guest with wsb exec --run-as ExistingLogin immediately
    after wsb start, which attaches no client — so there was no logon session and wsb refused with
    0x80070520. It now connects a client first, as a user-started Sandbox actually has, and waits
    until an ExistingLogin command really succeeds. ColdStartThenWarmReuse_… asserted warm reuse
    against a backend constructed without the optional state store; since RememberConnection is the
    only writer of BootstrappedEpoch, and IsWarm and Reused both derive from it, warm reuse was
    unobservable 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.
  • The NoInputDesktop single-reconnect path could not be triggered live. Killing the
    WindowsSandboxRemoteSession client did not end the guest's logon session, so the agent kept
    reporting a usable input desktop and — correctly — no reconnect was attempted and no client was
    duplicated. The one-reconnect bound stays covered by ClosedClient_IsReconnectedOnceAndThenWorks
    and its siblings.
  • x64 is built and package-validated but not live-run, this host being ARM64.

Comment thread src/winapp-CLI/WinApp.Cli.Tests/TargetMutationLockTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/TargetStateStoreTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/TargetStateStoreTests.cs Fixed
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Build Metrics Report

Binary Sizes

Artifact Baseline Current Delta
CLI (ARM64) 46.12 MB 48.44 MB 📈 +2.32 MB (+5.04%)
CLI (x64) 46.03 MB 48.28 MB 📈 +2.26 MB (+4.90%)
MSIX (ARM64) 19.04 MB 19.96 MB 📈 +941.7 KB (+4.83%)
MSIX (x64) 20.18 MB 21.16 MB 📈 +993.4 KB (+4.81%)
NPM Package 39.61 MB 41.53 MB 📈 +1.92 MB (+4.84%)
NuGet Package 39.75 MB 41.66 MB 📈 +1.91 MB (+4.81%)

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 · ⚠️ -1.7% vs. baseline

CLI Startup Time

50ms median (x64, winapp --version) · 📉 -11ms vs. baseline

Try This Build

Installs 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))) 779
Switching between builds often?

Put the tool on your PATH once:

& ([scriptblock]::Create((irm https://raw.githubusercontent.com/microsoft/winappCli/main/scripts/winapp-pr.ps1))) -AddToPath

Then this build is just:

winapp-pr 779

Run winapp-pr with no arguments to pick from a list of open PRs.


Updated 2026-09-11 23:34:51 UTC · commit c033de0 · workflow run

Nikola Metulev (nmetulev) added a commit that referenced this pull request Aug 21, 2026
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>
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestProcessHostTests.cs Fixed
Nikola Metulev (nmetulev) added a commit that referenced this pull request Aug 21, 2026
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>
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestProcessHostTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/DeploymentPlannerTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/DeploymentPlannerTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestProcessHostTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestProcessHostTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestProcessHostTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestProcessHostTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/TargetMutationLockTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/TargetStateStoreTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestCommandServerTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestAgentVersioningTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestAgentVersioningTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestAgentVersioningTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/WindowsSandboxCliTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/WindowsSandboxCliTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/WindowsSandboxLifecycleTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli/Commands/RunCommand.Sandbox.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli/Commands/RunCommand.Sandbox.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli/Commands/RunCommand.Sandbox.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/MsixServiceIdentityTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/MsixServiceIdentityTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/MsixServiceIdentityTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/MsixServiceIdentityTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/MsixServiceIdentityTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli/Commands/SandboxUiRouter.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli/Commands/RunCommand.Target.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/PackageRegistrationServiceTests.cs Fixed
Nikola Metulev (nmetulev) added a commit that referenced this pull request Aug 24, 2026
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>
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestStandardInputPumpTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestStandardInputPumpTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestStandardInputPumpTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestStandardInputPumpTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/HostSourceWalkerTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/HostSourceWalkerTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/HostSourceWalkerTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
Nikola Metulev (nmetulev) added a commit that referenced this pull request Aug 26, 2026
…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>
Nikola Metulev (nmetulev) added a commit that referenced this pull request Aug 26, 2026
…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>
Nikola Metulev (nmetulev) added a commit that referenced this pull request Aug 26, 2026
…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>
Nikola Metulev (nmetulev) added a commit that referenced this pull request Aug 26, 2026
…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>
Nikola Metulev (nmetulev) added a commit that referenced this pull request Aug 26, 2026
…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>
Comment thread src/winapp-CLI/WinApp.Cli.Tests/PackagedSandboxMutationLockTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/PackagedSandboxMutationLockTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli/Commands/RunCommand.GuestLaunch.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestHandshakeSocketTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestHandshakeSocketTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestHandshakeSocketTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestHandshakeSocketTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestHandshakeSocketTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/ConcurrentGuestAgentHarness.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestHandshakeSocketTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestHandshakeSocketTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestHandshakeSocketTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestHandshakeSocketTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/AppLauncherServiceTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestCommandServerTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestLaunchCommandTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestLaunchCommandTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestLaunchCommandTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/GuestLaunchCommandTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/SandboxHandleInheritanceTests.cs Fixed
@azchohfi

Copy link
Copy Markdown
Collaborator

🤖 AI-generated review (winappcli pr-review skill) — verify before acting.

Decision

Changes 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 fix

Both are commented inline:

1. A typo in a --sandbox UI command boots a Sandbox VM and never mentions the typoProgram.cs:224

winapp ui inspect --depth notanumber fails instantly with a parse error. Add --sandbox and it spends ~3.5 minutes starting a Sandbox, exits 70 with Windows Sandbox could not allow the authenticated guest-agent connection, never reports --depth, and leaves a VM running. Reproduced end to end. One-line fix.

2. --unregister-on-exit never clears the record it just cleaned upRunCommand.Sandbox.cs:429

A stale deployment state reaches ClearPackage, the revision check rejects the write, and the failure is swallowed at Debug. The guest unregisters fine, but the host still records the package. This fires on every run where a process actually started, not just under contention.

Non-blocking

sandbox cp recommends an example it rejects

winapp sandbox cp .\a .\b --json returns "example": "winapp sandbox cp .\\setup.ps1 sandbox:C:\\Setup\\setup.ps1", but drive-absolute guest paths are refused by design in NormalizeGuestRelative. Because that check runs after PrepareAsync, following the suggestion also starts a Sandbox before failing. Suggest sandbox:Setup\setup.ps1 instead.
SandboxCopyParser.cs:60

Exit code 70 does not distinguish what the docs promise

docs/usage.md says infrastructure failures use 70 so "winapp could not run your command" is always distinguishable from "your command failed". But sandbox exec returns the guest exit code verbatim, so a guest exiting 70 is indistinguishable — and 70 is also returned for plain argument mistakes (winapp sandbox cp .\a .\b exits 70). Suggest softening the claim and pointing scripts at error.code in the JSON envelope, which is unambiguous.
SandboxCommand.cs:146, docs/usage.md:1227

The --debug-output option table omits the exception stated 95 lines later

The options table says --debug-output "Debugs inside the guest and streams its output back" with no caveat; it is refused outright for unpackaged apps (GuestRunPlanner.cs:151). The build-output-folder case is the common unpackaged workflow, and the table is what people read first. Adding "packaged apps only" to that row would cover it.
docs/sandbox-execution.md:104

The Sandbox page argues for the implementation instead of teaching the feature

docs/sandbox-execution.md opens with "This page documents the behaviour and its failure modes so they are reviewable alongside the code", then spends multi-paragraph passages defending design choices — reparse-point handling, firewall ordering, an accepted residual race. A reader looking for "how do I run my app in the Sandbox" hits ~38 lines on why a TOCTOU race is acceptable before reaching "Running an app". AGENTS.md's audience contract asks user docs to stay on observable behaviour and recovery, so this rationale probably belongs in design notes outside docs/.
docs/sandbox-execution.md:9, 46-83

What was exercised

  • dotnet build src/winapp-CLI/winapp.sln -c Release — succeeded, 0 warnings, 0 errors.
  • Full unit suite via the built test executable. The 70 failures are all in pre-existing NugetService / EndToEndTests / PackageCommandTests / MsixService coverage and trace to api.nuget.org being unreachable from a corp machine — the limitation AGENTS.md documents. No failure in any new execution-target or Sandbox suite. I did not point the internal feed at those tests, so that coverage is unverified here.
  • Ran the published winapp.exe directly rather than dotnet run: reproduced the --sandbox parse-error defect end to end, including the delay and the leftover Sandbox VM (stopped afterwards with wsb stop --id). Also exercised sandbox cp direction errors in text and --json mode.
  • Not exercised: a successful guest session — agent bootstrap failed at the firewall step on my machine, so sandbox exec, sandbox cp transfers, run --sandbox, and the --unregister-on-exit defect were not confirmed at runtime. That second finding is code-traced rather than reproduced, though the full chain is visible in the diff.

Comment thread src/winapp-CLI/WinApp.Cli/Program.cs Outdated
Comment thread src/winapp-CLI/WinApp.Cli/Commands/RunCommand.Target.cs Outdated
@nmetulev
Nikola Metulev (nmetulev) force-pushed the nmetulev-sandbox-execution-target branch from 598bbe9 to 5e4d3e9 Compare September 4, 2026 04:31
Nikola Metulev (nmetulev) added a commit that referenced this pull request Sep 4, 2026
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>
Comment thread src/winapp-CLI/WinApp.Cli.Tests/DesktopCaptureTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/DesktopCaptureTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/DesktopCaptureTests.cs Fixed
Comment thread src/winapp-CLI/WinApp.Cli.Tests/DesktopCaptureTests.cs Fixed
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

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

Comment thread src/winapp-CLI/WinApp.Cli/Commands/GuestDesktopCaptureCommand.cs Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

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

Comment thread src/winapp-CLI/WinApp.Cli/ExecutionTargets/GuestAgent/GuestFileService.cs Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

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

Comment thread src/winapp-CLI/WinApp.Cli/ExecutionTargets/Orchestration/HostSourceWalker.cs Outdated
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Run and automate apps in persistent Windows execution targets

6 participants