Skip to content

fix(sandbox): make Image.expose() usable on persistent local sandboxes - #3133

Merged
r33drichards merged 3 commits into
mainfrom
exposed-state
Aug 13, 2026
Merged

fix(sandbox): make Image.expose() usable on persistent local sandboxes#3133
r33drichards merged 3 commits into
mainfrom
exposed-state

Conversation

@r33drichards

@r33drichards r33drichards commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Three related defects on the Image.expose() surface I added in #3130, plus the misleading error message beside it. The first commit's message overstates its own bug — corrected below.

1. runtime.start() was never told whether the sandbox is ephemeral

Sandbox.create (persistent) and Sandbox.ephemeral both reached:

rt_info = await runtime.start(image, sb_name)      # no ephemeral=

and the runtime defaults it: ephemeral = opts.pop("ephemeral", True). So a persistent local sandbox is ephemeral as far as the runtime is concerned:

  • its if not ephemeral: branch never runs, so the runtime never records state
  • _session_disk is set, so the overlay is deleted on stop — for a sandbox that is meant to persist

The value was already in scope, passed to _record_sandbox_create two lines below.

2. sandbox_state.save() rejected the argument the runtime passes

#3130 added exposed_ports=exposed_ports or None to that save() call. save() accepts no such parameter and no **kwargs, so the call would raise TypeError.

Correction to the first commit message: I described this as "every non-ephemeral local sandbox raises TypeError after the guest boots." It does not. Defect 1 makes that branch unreachable, so the call is dead code and nothing raises today — verified on 0.3.1: Sandbox.create(..., local=True) returns fine, exposed_ports is {3000: 38119}, a state file is written by a different call site, and it contains no exposed_ports key. I inferred the crash from reading the call site rather than executing it. The bug is real in the source and would surface the moment defect 1 was fixed — which is precisely why both belong in one change.

save() now accepts and persists the mapping.

3. The forwarded port was unrecoverable after a reconnect

It lived only on the object create() returned. tunnel.forward() raises NotImplementedError on the local HTTPTransport, and nothing persisted the mapping, so a reconnecting caller could not find the port. Documentation had to read sb._runtime_info.exposed_ports[3000] — a private attribute in a published how-to.

Sandbox.exposed_ports is now public: it prefers live runtime info and falls back to saved state, restoring the int keys JSON turns into strings.

4. The tunnel error omitted a transport that works

Supported transports: ADBTransport, GRPCEmulatorTransport, SSHTransport.

FleetCloudTransport implements forward_tunnel and is regression-tested. A local caller hitting this was told Fleet could not tunnel either — false, and directly contradicted by the Fleet examples in the docs. The test recomputes the set of transports overriding forward_tunnel and asserts each is named, so this hardcoded list cannot drift again.

Verification

Seven tests. Each new one was confirmed failing against the source it fixes and passing after, by stashing only the source so the tests were present to fail rather than silently deselected.

7 passed

isort, black and ruff clean.

Behaviour change worth a reviewer's nod: with defect 1 fixed, persistent local sandboxes now keep their session disk on stop instead of deleting it, and the runtime records their state. That is the documented intent of Sandbox.create versus Sandbox.ephemeral, but it is a real change to disk retention.

…boxes crashing

#3130 passed exposed_ports= to sandbox_state.save(), which accepts no such
parameter and no **kwargs, so every non-ephemeral local sandbox raised
TypeError after the guest had booted. Ephemeral sandboxes skip that branch,
which is why the released path looked healthy.

Persist the mapping, and add a public Sandbox.exposed_ports that prefers live
runtime info and falls back to saved state — the forwarded port was otherwise
unrecoverable after a reconnect, since tunnel.forward() raises on the local
HTTPTransport, and documentation had to read sb._runtime_info.
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

The NotImplementedError listed ADBTransport, GRPCEmulatorTransport and
SSHTransport, omitting FleetCloudTransport — which implements forward_tunnel
and is regression-tested. A local caller hitting the error was told Fleet
could not tunnel either, contradicting the documented Fleet path.

The test recomputes the set of transports overriding forward_tunnel and
asserts each is named, so the list cannot silently drift again.
Sandbox.create and Sandbox.ephemeral both called runtime.start without
ephemeral=, and the runtime defaults it to True. A persistent local sandbox was
therefore ephemeral to the runtime: it never recorded state, and _session_disk
was set so the overlay is deleted on stop.

It also made the save(..., exposed_ports=) call in that branch unreachable,
which is why the TypeError it would raise never surfaced in practice.
@r33drichards r33drichards changed the title fix(sandbox): persist exposed_ports and stop non-ephemeral local sandboxes crashing fix(sandbox): make Image.expose() usable on persistent local sandboxes Aug 13, 2026
@r33drichards
r33drichards merged commit da522b1 into main Aug 13, 2026
21 checks passed
@r33drichards
r33drichards deleted the exposed-state branch August 13, 2026 17:50
r33drichards added a commit that referenced this pull request Aug 13, 2026
Two changes merged to main after this page was written and left parts of
it describing behaviour that no longer exists.

#3133 added a public Sandbox.exposed_ports property that falls back to the
saved sandbox state, so the page no longer needs to reach into
_runtime_info, and the callout warning that a reconnecting caller cannot
recover the port is simply wrong now. Rewrite it around the property,
keeping the local/Fleet split the property's own docstring draws:
exposed_ports locally, tunnel.forward() on Fleet, where it is empty.

#3132 filters the MCP tools/list roster through the capability policy, so
denied tools are no longer advertised. The page presented "list_tools()
advertises everything, the policy refuses at call time" as the rule; that
holds only for driver builds predating the fix. Attribute the 55-tool
observation to the image used here, say the listing may already be
filtered, and tell readers not to assume the listing and the callable set
match in either direction. The client-side trim is unaffected and still
worth doing, so its rationale is restated without leaning on the count.
r33drichards added a commit that referenced this pull request Aug 13, 2026
I moved the page onto the public Sandbox.exposed_ports property in
b2bebc1 without revisiting the prerequisite, which still said 0.3.1. The
property arrived with #3133, merged 2026-08-13T17:49Z — after cua-sandbox
0.3.1 went to PyPI at 04:07 that morning and before 0.3.2 at 17:54. So a
reader installing the version the page asked for gets AttributeError on
the page's own first example.

Floor is 0.3.2. The bullet now names what each version is needed for, so
the next person to use a newer API has the mapping in front of them.
r33drichards added a commit that referenced this pull request Aug 13, 2026
Two changes merged to main after this page was written and left parts of
it describing behaviour that no longer exists.

#3133 added a public Sandbox.exposed_ports property that falls back to the
saved sandbox state, so the page no longer needs to reach into
_runtime_info, and the callout warning that a reconnecting caller cannot
recover the port is simply wrong now. Rewrite it around the property,
keeping the local/Fleet split the property's own docstring draws:
exposed_ports locally, tunnel.forward() on Fleet, where it is empty.

#3132 filters the MCP tools/list roster through the capability policy, so
denied tools are no longer advertised. The page presented "list_tools()
advertises everything, the policy refuses at call time" as the rule; that
holds only for driver builds predating the fix. Attribute the 55-tool
observation to the image used here, say the listing may already be
filtered, and tell readers not to assume the listing and the callable set
match in either direction. The client-side trim is unaffected and still
worth doing, so its rationale is restated without leaning on the count.
r33drichards added a commit that referenced this pull request Aug 13, 2026
I moved the page onto the public Sandbox.exposed_ports property in
b2bebc1 without revisiting the prerequisite, which still said 0.3.1. The
property arrived with #3133, merged 2026-08-13T17:49Z — after cua-sandbox
0.3.1 went to PyPI at 04:07 that morning and before 0.3.2 at 17:54. So a
reader installing the version the page asked for gets AttributeError on
the page's own first example.

Floor is 0.3.2. The bullet now names what each version is needed for, so
the next person to use a newer API has the mapping in front of them.
r33drichards added a commit that referenced this pull request Aug 13, 2026
#3131)

* docs(sandbox): add a how-to for running Minecraft in a Windows sandbox

Walks through booting Image.windows() on the local bare-metal QEMU
runtime, installing Minecraft Java Edition inside it, and handing the
launcher to a computer-use agent.

The page documents the four traps that make this fail otherwise:

- the bare-metal netdev is restrict=on, so the guest has no internet
  until a second NIC is added, and that NIC needs its own subnet or
  both user-mode networks offer the guest the same address
- the default qemu64 CPU model is too thin for a software OpenGL
  driver: the game creates its window and then dies during resource
  loading with no Java exception, which -cpu host fixes
- MSVC builds of both Prism Launcher and Mesa3D need the VC++
  redistributable that Windows Server 2022 does not ship, so the
  MinGW builds are the ones that work
- the launcher downloads its own Java, so the Mesa DLLs have to land
  next to that javaw.exe rather than a separately installed JRE

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(sandbox): drop the obsolete shared-efivars troubleshooting row

EFI variables stopped being shared in #3128: the bare-metal QEMU runtime
now derives one pflash file per VM from its disk name
(Path(disk_path).with_suffix(".efivars.fd"), runtime/qemu.py:345), and
tests/test_docs_regressions.py asserts the old shared path is gone. There
is no ~/.cua/cua-sandbox/images/sessions/efivars.fd to delete anymore, so
the row pointed readers at a file that does not exist.

* docs(sandbox): drive Minecraft through cua-driver's MCP server

Rewrites the agent half of the guide around the MCP server the sandbox
already runs, rather than ComputerAgent: Image.windows().expose(3000)
publishes cua-driver's endpoint on guest :3000, a FastMCP client lists
its tools, and a small loop hands them to a model as ordinary function
tools. The same loop runs against a local sandbox and against Fleet;
only the transport differs.

Presenting MCP tools as ordinary function tools also sidesteps endpoints
that refuse provider-native computer-use tool types.

Documents three things about cua-driver that are otherwise a surprise:
a YAML policy refuses tools that list_tools() still advertises, clicks
are addressed to a pid rather than the screen, and there is no wait tool
so the loop waits by re-screenshotting.

Also adds prerequisites (cua-sandbox 0.3.1+, and that -cpu host needs
KVM or HVF, so an x86_64 guest on Apple Silicon cannot use the local
path), a delivery snippet for the Mesa script, and replaces the stale
Fleet callout now that Windows boots there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(sandbox): use the public tunnel API and make the sample match the run

Replaces the hand-assembled Fleet service URL with sb.tunnel.forward(3000),
which returns exactly that URL and is already documented in tunneling.mdx.
The local path keeps reading the port off the object Sandbox.create returns,
because tunnel.forward() is not implemented for that transport and the port
is not recoverable after a reconnect — now stated outright rather than left
for a reader to discover.

The agent sample now does what the prose said it did: streams and rebuilds
with stream_chunk_builder, folds the system prompt into the first user turn,
prunes old screenshots, and trims the 55-tool listing to the handful the task
needs.

Also explains why -cpu host is absent on Fleet, attributes the token lifetime
to what was observed rather than asserting it, and drops an unused import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(sandbox): drop the /docs prefix from the tunneling cross-link

Internal doc links in docs/content are written relative to the docs root
without a /docs segment — every other absolute link in the tree does it
this way (for example ](/how-to-guides/sandbox/images)), and there were
zero occurrences of ](/docs/ before this one. The prefixed form resolves
to /docs/how-to-guides/... at render time and 404s.

* docs(sandbox): record that Minecraft does not yet run on Fleet

The Fleet path is verified as far as the sandbox and the agent: it boots,
.expose(3000) publishes cua-driver, the MCP client lists the same tools,
the setup script installs Prism and Mesa, the Microsoft sign-in completes,
and the agent loop drives the desktop. The game itself exits during
resource loading every time, with no Java exception, no hs_err and nothing
in the event log.

Neither known cause explains it: the Fleet CPU reports full AVX-512, so
the qemu64 instruction theory does not apply, and forcing the heap from
Prism's auto-sized -Xmx2717m down to -Xmx1024m on the 4 GB sandbox changed
nothing. Unlike the local path there are no QEMU arguments to adjust.

Says so plainly rather than implying the game runs there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(sandbox): pin down the Fleet crash with an exit code and a second version

Adds the evidence that narrows it: the game dies with exitcode -2147024809,
and it is not version-specific — 1.20.1 on Java 17 with LWJGL 3 and 1.12.2
on Java 8 with LWJGL 2 stop at the same point. That exit code is the same
one the local qemu64 CPU model produced, but the Fleet CPU reports AVX,
AVX2 and AVX-512 present, so the local explanation does not carry over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(sandbox): rule out machine size for the Fleet crash

Records the last elimination so nobody repeats the experiment: the same
local session disk, rebooted with -m 4096 -smp 4 to match the Fleet
sandbox exactly, runs Minecraft fine. Prism warns about low free memory
and the game launches anyway.

So the Fleet failure is not resources. Five things are now ruled out --
CPU feature flags, heap size, Minecraft version, Java/LWJGL generation,
and machine size -- with the same stopping point and the same exitcode
-2147024809 throughout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(sandbox): restore the tunneling cross-link path

The /docs prefix came back in 2a4c385 when the surrounding Fleet
paragraph was rewritten, undoing 5f19e66. Internal links in docs/content
omit the /docs segment — every other absolute link in the tree is written
](/how-to-guides/...) and this is the only ](/docs/ in it — so the
prefixed form 404s at render time.

* docs(sandbox): stop sending Apple Silicon readers to Fleet for the game

The prerequisites and the -cpu host troubleshooting row both told readers
without KVM or HVF to use the Fleet path instead. Since 93c1cdd the guide
also records that Minecraft does not run on Fleet, so those two lines sent
the one reader who cannot use the local path to the one path where the
game is known not to work. Both now say what Fleet does get you — the
sandbox and the agent loop — and point at the Fleet section for the rest.

* docs(sandbox): Minecraft runs on Fleet with GALLIUM_DRIVER=softpipe

The Fleet crash was Mesa's llvmpipe renderer. Switching to softpipe fixes
it, and an agent drove the same loop from launcher to standing in a new
world on Fleet in 51 steps. The section documents the workaround instead
of the failure, and the Apple Silicon note now sends readers to Fleet for
the game rather than away from it.

Keeps the eliminations as the reason softpipe is needed rather than a
guess: not the Minecraft version, not the Java/LWJGL generation, not the
heap, not the machine size, and not simply wide-vector codegen -- narrowing
llvmpipe with LP_NATIVE_VECTOR_WIDTH=128 did not help. Notes that softpipe
has no JIT and is markedly slower, and that the variable must be set in the
process that launches the launcher, since a machine-level variable does not
reach an already-running process.

Also records that Fleet sizing is account-dependent: cpu/memory_mb route
through a gated path that returned 403 until a card requirement was lifted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(sandbox): make the resource-loading crash findable for Fleet readers

The troubleshooting table had one row for "game exits during resource
loading", attributing it to the qemu64 CPU model and prescribing -cpu
host. Since b8ab92f the same symptom has a second, unrelated cause on
Fleet — Mesa's llvmpipe renderer — and -cpu host is not available there
at all, because Fleet takes no runtime= and no QEMU arguments. A Fleet
reader looking the symptom up was sent to a fix they cannot apply, while
the one that works had no row.

Scope the existing row to local, add a Fleet row pointing at
GALLIUM_DRIVER=softpipe, and note in the Fleet callout that setup.ps1
has already started Prism without the variable, so the launcher has to
be restarted for it to take effect.

* docs(sandbox): catch up with the exposed-ports and tool-listing fixes

Two changes merged to main after this page was written and left parts of
it describing behaviour that no longer exists.

#3133 added a public Sandbox.exposed_ports property that falls back to the
saved sandbox state, so the page no longer needs to reach into
_runtime_info, and the callout warning that a reconnecting caller cannot
recover the port is simply wrong now. Rewrite it around the property,
keeping the local/Fleet split the property's own docstring draws:
exposed_ports locally, tunnel.forward() on Fleet, where it is empty.

#3132 filters the MCP tools/list roster through the capability policy, so
denied tools are no longer advertised. The page presented "list_tools()
advertises everything, the policy refuses at call time" as the rule; that
holds only for driver builds predating the fix. Attribute the 55-tool
observation to the image used here, say the listing may already be
filtered, and tell readers not to assume the listing and the callable set
match in either direction. The client-side trim is unaffected and still
worth doing, so its rationale is restated without leaning on the count.

* docs(sandbox): state the tool-listing behaviour as fact, not a hedge

Every published cua-driver release advertises the full tool surface and
refuses out-of-policy calls only at call time: tags 0.19.2 (2026-08-07),
0.19.3 (2026-08-10) and the 0.19.4 nightly (2026-08-12) all predate the
change that filters the listing, which merged on 2026-08-13. So for any
reader today this is simply how it behaves, and the previous
"depends on your image / do not assume either direction" wording cost
them a concrete expectation for no gain.

State it plainly, keep the probe-your-own-image advice for the allow list
itself, and note that later drivers make the listing and the callable set
agree. The troubleshooting row is scoped the same way.

* docs(sandbox): raise the version floor to 0.3.2 for sb.exposed_ports

I moved the page onto the public Sandbox.exposed_ports property in
b2bebc1 without revisiting the prerequisite, which still said 0.3.1. The
property arrived with #3133, merged 2026-08-13T17:49Z — after cua-sandbox
0.3.1 went to PyPI at 04:07 that morning and before 0.3.2 at 17:54. So a
reader installing the version the page asked for gets AttributeError on
the page's own first example.

Floor is 0.3.2. The bullet now names what each version is needed for, so
the next person to use a newer API has the mapping in front of them.

---------

Co-authored-by: Robert Wendt <robert@trycua.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
r33drichards added a commit that referenced this pull request Aug 13, 2026
from_registry hardcoded os_type="linux" with no way to override it, while
from_file — the adjacent constructor building the same object from a different
source — takes os_type and kind as keyword arguments.

os_type is what selects UEFI, on the local QEMU runtime and in the Fleet
transport alike. A Windows containerDisk pulled from a registry was therefore
handed BIOS and could not boot, and the only escape was reaching past the
constructor with dataclasses.replace(). That is the firmware failure fixed for
Fleet in #3125 arriving through a different door.

The default stays "linux", so existing callers are unaffected.

Also repairs a test double in test_cloud.py: #3133 made Sandbox._create pass
ephemeral= to runtime.start(), and the fake Runtime there does not accept it, so
that test has been failing on main since it merged. It went unnoticed because
cua-sandbox is not in the ci-test-python package matrix and its suite never runs
on pull requests.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.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.

3 participants