Skip to main content

Runtime: Windows Sandbox

Experimental

The Windows Sandbox runtime may change in minor versions until its prerequisites are validated against a live Windows Sandbox.

Desktop.create({ runtime: "sandbox" }) runs applications inside an isolated Windows Sandbox guest. Locators, actions, screenshots and errors work as they do with runtime: "local"; only the runtime differs. This page is for anyone who runs tests or an agent against applications in the sandbox: what the machine needs, how the runtime reaches the guest, its options, where it keeps its files, and what it does for security.

import { Desktop } from "@spartaficial/spart-desk";

await using desktop = await Desktop.create({ runtime: "sandbox" });
const app = await desktop.launch({ executable: "./build/MyApp.exe" });
const main = app.window();
await main.getByRole("button", { name: "Save" }).click();

Prerequisites​

  • Windows 11 24H2 or later, and a supported edition. Windows 10 and older Windows 11 releases have Windows Sandbox but not its command line (wsb.exe), so the sandbox runtime cannot run there; doctor and the wsb-missing error name the build. Windows Server has no Windows Sandbox at any build, and doctor and the error say so.
    • Pro, Enterprise or Education, with hardware virtualization enabled.
    • The Windows Sandbox feature: Enable-WindowsOptionalFeature -Online -FeatureName Containers-DisposableClientVM -All in an elevated shell, then reboot.
  • Windows Sandbox opened once from the Start menu.
    • That installs the Windows Sandbox app, which brings wsb.exe (an app execution alias under %LOCALAPPDATA%\Microsoft\WindowsApps), the command line that lists, starts, shares into and stops a sandbox. npx spart-desk doctor reports whether it is found.
  • The helper, spart-desk-host.exe. It is what runs inside the guest; the package ships it signed (backend-windows.md).
  • guestAppControl: "off" for an unsigned helper. A fresh guest enforces Smart App Control even where the host has it off, and it starts only programs that are signed or known to Microsoft. An unsigned helper is neither, so createSandboxRuntime({ guestAppControl: "off" }) turns the policy off in the guest for that boot. With the default ("keep") the guest refuses to start the helper without a word: once the agent's wait (180 s) runs out, the runtime asks the guest whether the helper runs and whether Smart App Control is on, and the preparation fails saying so. The host's own setting is never touched.
  • Real input and screen capture need the client window.
    • Real input means clicks with the mouse, press with modifier chords that cannot be posted, and right or double clicks; captures are the guest's pictures.
    • The window must be connected and not minimized, on an unlocked host desktop. The runtime connects it for a sandbox it started, and for a reused one that has none; a closed or minimized window is reported as not-ready, with wsb connect --id <id> to run.
    • A failed input command carries this requirement as BackendError.hint.
    • Operations through UI Automation and posted keys (invoke, set-value, reading a value, inspection) do not need it.
  • One sandbox at a time.
    • Windows allows only one. A sandbox that is already running is reused and left running; SPART-Desk stops only a sandbox it started.

How it works​

Two things talk to the guest: wsb.exe, which manages the sandbox itself, and SPART-Desk's helper, which runs inside the guest as an agent the engine reaches over TCP and which runs the helper's other jobs there as children of its own. Nothing else crosses the boundary.

ConcernMechanism
Sandbox lifecyclewsb list, wsb start [--config], wsb stop; wsb ip for the guest's address; wsb connect for the client window.
Getting the helper inThe helper and the agent's connection material (agent.json: the port, a token for this boot, the helper's root) are staged on the host and shared into the guest read-only as C:\spart-desk-share (wsb share). One wsb exec as SYSTEM copies the helper to C:\spart-desk, adds a firewall rule for it, and turns Smart App Control off when asked.
The agentspart-desk-host.exe agent --config C:\spart-desk-share\agent.json, started in the guest's signed-in session (wsb exec --run-as ExistingLogin), left running for the sandbox's life. Each connection names the token and a job; the agent runs that job as a child, carries its standard streams over the connection, and ends it with the connection.
UI automation in the guestThe same WindowsDriver as on the host, bound to the sandbox for its whole life, so host and guest state are never mixed. Its helper is the guest's: the long-lived helper (the window census, every window's tree, patterns) and the per-request one (posted keys, real input, pictures) run as the agent's children, one connection each, kept running. A helper that stops in the guest, or whose connection is reset, is started again through the agent, three times in any five minutes.
Plain executablesThe executable's folder is staged under the share (apps/<name>-<hash>; unchanged files are skipped) and copied on with robocopy through the agent into C:\spart-desk\apps\<name>-<hash>. A program of the Windows folder (C:\Windows\System32\charmap.exe) is not copied: the guest runs the same Windows, and the program finds its language resources only beside the system's copy. The helper runs the program and reports its process id, and the application is addressed by it.
Packaged foldersA folder with Package.appxmanifest beside the executable is copied and its executable started as a program, as the local runtime starts it: nothing is registered in either place. A loose layout that needs its identity is registered by your test, not by the engine: launch its executable once to copy it in (windowTimeout: 0, then close it), register the copy under C:\spart-desk\apps\<name>-<hash> with desktop.run (Developer Mode in the guest's registry, reg add HKLM\...\AppModelUnlock /v AllowDevelopmentWithoutDevLicense, then Add-AppxPackage -Register; the guest's user is its administrator), and start it with launch({ appId }).
Java applicationsOne that brings its runtime beside its launcher (jpackage's runtime\) is copied whole, started with the Java Access Bridge turned on for that runtime, addressed by its launcher's name (its JVM runs in a second process of that name), and read through the helper in the guest. A .jar, a JDK's own javaw.exe and java: true without a runtime in the folder are refused with LaunchError. A 32-bit JVM is read by the helper's 32-bit build, staged in the guest beside the 64-bit one and run by the agent.
Shortcuts, batch files and ClickOnce referencesRefused with LaunchError: a .lnk names a program of the host, a .bat or .cmd runs commands that name the host's programs, which the guest does not have, and a .appref-ms names a ClickOnce deployment installed for this machine's user. Launch the program itself, or run commands in the guest with desktop.run.
Touch and penwindow.touchscreen, window.pen and locator.tap() send their gestures through the helper in the guest, from a synthetic device the guest's Windows makes. Real input, so the sandbox's client window must be connected.
A picture of the desktopdesktop.screenshot() pictures the guest's desktop, through the helper in the guest.
Store apps and packaged applicationslaunch({ appId, args? }) starts the application through the helper in the guest, as the local runtime does on the host: any package registered in the guest, with its arguments; the image has no Store apps, and asking for one reports Windows' reason ("0x80073CF1 Package was not found"). launch({ uri, appId? }) opens a URI in the guest as on the host: with appId, that packaged application is activated for it; without, the scheme's handler is; a target is stopped as an attached application is.
args, env, cwdargs and env are passed. cwd must lie inside the executable's folder (it is mapped to the copy), and is refused otherwise. java is decided against the application's own folder (see Java applications above).
Screenshots and reading textThe guest helper takes the window's or the screen's picture; the PNG comes back over the agent's connection to the host, where the text reader runs.
Studiospart-desk studio --sandbox runs the session on this runtime; its picker and recorder run inside the guest as another child of the agent (runtime.guestHelper is the launcher for it), reporting the control under the cursor ten times a second (studio.md). createSandboxRuntime({ transcript: { onTraffic } }) keeps the conversation with the guest helper for a support bundle.
Cleanupdispose() terminates every launched application (taskkill /PID <pid> /T /F through the agent, as SYSTEM through wsb exec when the agent cannot be reached), then stops the sandbox if SPART-Desk started it. The agent stays with a sandbox that stays.

The runtime never falls back to the host. Any missing prerequisite raises SandboxUnavailableError with a reason:

reasonMeaning
wsb-missingwsb.exe was not found or failed. The Windows Sandbox feature is not enabled, or Windows Sandbox has never been opened (see Prerequisites).
start-failedwsb start failed, or the guest could not be prepared: the share, the SYSTEM step (Smart App Control, the helper refused by the guest's policy, the firewall rule), the agent not answering within 3 minutes, or an agent of another build. A sandbox that was started is stopped again.
not-readyThe agent answers, but the guest's session takes no real input (the sandbox's client window is closed or minimized, so nobody is signed in or the desktop is not drawn). Connect it with wsb connect --id <id> and try again.
not-runningThe sandboxId you asked for is not running, or the runtime was used before prepare.
disposedThe runtime was already disposed.

Lifecycle​

Desktop.create({ runtime: "sandbox" })
└─ runtime.prepare()
├─ wsb list --raw → must succeed else SandboxUnavailableError(wsb-missing)
├─ sandboxId given → must be running
│ else running sandbox found → reuse it (not owned; never stopped by SPART-Desk)
│ else wsb start [--config] → owned else SandboxUnavailableError(start-failed)
├─ agent.json of an earlier runtime on this sandbox → ping it → reached: done (a sandbox kept running keeps its agent)
├─ wsb ip the guest's address; the host's own address on that network
├─ stage <cache>\sandbox\<id>\ spart-desk-host.exe, agent.json (port, token, root)
├─ wsb share … C:\spart-desk-share read-only
├─ wsb exec --run-as System [Smart App Control off]; copy the helper to C:\spart-desk; netsh firewall rule
├─ wsb connect for a sandbox the runtime started (the client window signs the user in)
├─ wsb exec --run-as ExistingLogin "exit 0" again and again while the sign-in goes on; the window connected once for a reused sandbox without one
├─ wsb exec --run-as ExistingLogin spart-desk-host.exe agent left running; launched once more if silent for 30 s
└─ ping the agent over TCP until it answers with this engine's protocol and the session takes input
else SandboxUnavailableError(start-failed | not-ready)
desktop.launch({ executable })
├─ a .jar or a JDK's javaw.exe? LaunchError (the guest has no Java of its own)
├─ otherwise stage the folder under the share; robocopy it on, through the agent
│ the helper starts the program, through the agent → { pid }
└─ waits for the first window through the guest helper's census
desktop.launch({ appId, args? }) the helper activates <appId> [--args <line>], through the agent → { pid }
desktop.launch({ uri, appId? }) the helper activates <appId> --uri <uri>, or opens <uri>, through the agent → { pid }
(pid 0: the target given is waited for, and stopped as an attached application)
app.close() taskkill /PID <pid> /T /F through the agent (as SYSTEM through wsb exec when the agent is gone)
desktop.dispose() closes apps, then wsb stop --id <id> when owned (unless keepRunning)

A program started directly by a child of the agent would end with that child, because the children run in a job object that ends them with the agent. The helper therefore asks WMI to start a second copy of itself, which starts the program outside that job.

Desktop and Application implement Symbol.asyncDispose, so await using gives deterministic cleanup even when a test throws. dispose() is idempotent, runs every cleanup step even if an earlier one fails, and reports all failures together (error.cause is an AggregateError).

Options​

import { Desktop, createSandboxRuntime } from "@spartaficial/spart-desk";

const desktop = await Desktop.create({
runtime: createSandboxRuntime({
config: "<Configuration><Networking>Disabled</Networking></Configuration>", // wsb start --config
keepRunning: true, // leave an owned sandbox running after dispose
sandboxId: "1234...", // reuse a specific running sandbox
guestAppControl: "off", // an unsigned helper
driver: { inspectDepth: 12 },
}),
});
OptionDefaultMeaning
sandboxIdthe running sandbox, or a new oneReuse this running sandbox instead of starting one. A sandbox SPART-Desk did not start is never stopped by SPART-Desk.
confignoneWindows Sandbox configuration XML passed to wsb start --config.
keepRunningfalseLeave a sandbox SPART-Desk started running on dispose. Its agent stays with it, and the next runtime reaches it without preparing the guest again.
guestAppControl"keep"What to do about Smart App Control in the guest: "keep" leaves it, and a refused helper fails the preparation with what the policy means and this option's name; "off" turns it off in the guest for this boot, as SYSTEM, before the helper starts.
portone of the dynamic ports (49152–65535), chosen at randomThe TCP port the agent listens on in the guest.
wsbExecutablePathwsb on PATHPath to wsb.exe.
drivernoneBackend tuning: inspectDepth, keyboard, captureMode of WindowsDriverOptions; the execution target is always the sandbox.
transcript.onTrafficnoneReceives the conversation with the helper kept running in the guest (HostTraffic), for a support bundle.

desktop.runtimeName is "sandbox" and desktop.backendName is "windows@sandbox". The runtime object exposes sandboxId, ownsSandbox and, once prepared, guestHelper (a HelperLauncher: how Studio runs the helper's other jobs in the guest) for diagnostics.

Where the guest's files are staged​

The runtime keeps one folder per sandbox under %LOCALAPPDATA%\spart-desk\sandbox\<id> (SPART_DESK_CACHE_DIR\sandbox\<id> when that variable is set; npx spart-desk doctor prints the folder): spart-desk-host.exe, agent.json and apps\<name>-<hash>, one folder per launched application's folder. It is shared into the guest read-only as C:\spart-desk-share; the guest's own copies live under C:\spart-desk. The folder stays after the sandbox stops, so that the next launch of the same application copies only what changed and a sandbox kept running is reached again through its agent.json; delete it when you want a clean start.

Security​

  • The token. 32 random bytes, made for this boot and written into agent.json. A connection's first line must name it; a wrong token closes the connection after a pause, and nothing else is said.
  • The firewall rule. Added as SYSTEM before the agent listens (Windows would otherwise ask the guest's user to allow a listener, and nobody is there to answer): inbound TCP on the agent's port, for the helper's program only, from the host's address on the sandbox's network alone. When no interface of the host is on that network, the rule names no address and the token alone gates the agent.
  • No encryption. The sandbox's network is a virtual switch between this host and the guest; nothing else reaches it.
  • The share is read-only. The guest can neither rewrite the agent's material nor the helper. A guest that can read the share can read the token, as it can read anything else shared in.
  • Nothing comes back by file. Pictures, trees and exit codes travel over the agent's connection.
  • The guest's processes are the guest's. taskkill runs through the agent as the guest's user, and as SYSTEM through wsb exec only when the agent cannot be reached.
  • desktop.run runs in the guest through the agent, as the guest's user (WDAGUtilityAccount, an administrator of the guest), never as SYSTEM, and its output comes back over the connection. A program stopped at its timeout goes with what it started: the agent runs each in a job of its own. A program that exits while one it started keeps its output open is answered half a second later.
  • A helper that stops in the guest is said at once. The agent waits on each of its children, so the engine learns of the end while it still holds the connection, and starts the helper again.

Performance​

A question in the guest costs a round trip over TCP to a helper that is already running there, not a process start; it costs more than the same question on the host, and a launch more still, since the application's folder is copied first (unchanged files are skipped on later launches into the same sandbox). A new sandbox costs the time from wsb start until its user is signed in and the agent answers; the agent is waited for up to 3 minutes, the session for 1 minute more. Keep one sandbox running between runs (keepRunning) to pay that once.

Notes​

  • Applications must be copied into the guest, so a launch of a large folder takes as long as the copy. Unchanged files are skipped on later launches into the same sandbox.
  • Builds happen on the host. Running them in a sandbox does not make untrusted projects safe to build.
  • Stopping the sandbox discards all guest work. SPART-Desk only stops sandboxes it started.
  • An unsigned helper needs guestAppControl: "off". With "keep", a guest that enforces Smart App Control refuses the helper, and the preparation fails once the agent's wait runs out, saying the helper is not running there because of Smart App Control, and naming the option.
  • Java applications need their own runtime in their folder; a 32-bit JVM is read by the helper's 32-bit build, staged in the guest beside the 64-bit one: see the table above.
  • The image has no Store apps, and the engine installs none: launch({ appId }) of one reports Windows' reason ("0x80073CF1 Package was not found"). A packaged application registered in the guest (see the table above) starts by its app ID.