Skip to content

feat(sandbox): create a sandbox from a custom image, and check the name - #243

Merged
quickbeard merged 3 commits into
mainfrom
feat/sandbox-custom-image
Aug 16, 2026
Merged

quickbeard merged 3 commits into
mainfrom
feat/sandbox-custom-image

Conversation

@quickbeard

Copy link
Copy Markdown
Owner

Creating a sandbox always downloaded its guest image — well over a gigabyte, and impossible on a machine that cannot reach the storage host. Add sandbox now takes an optional image file, and both it and the name field are validated in the dialog instead of by a create that fails minutes in.

Custom image

Empty means download as before. A path means use that file: read where it sits rather than copied into the cache (duplicating gigabytes helps nobody), and persisted on the sandbox so Reset re-provisions from the same file.

The plumbing reuses the existing asset path rather than growing a parallel one — localImageAsset() describes the file as the same {url, sha256, bytes} shape a published asset has, and stageAsset() short-circuits file: URLs. Each provider gains one branch; the marker logic, unpack and reset behaviour are untouched. The digest means a reset still re-provisions when the file behind it has changed.

Validating the image

Three ways a picked file can be wrong, all caught before a create starts:

Problem Message
Not an archive "That is not a sandbox image. A sandbox image is a .tar.gz file."
An archive, but not a sandbox "That .tar.gz is not a sandbox image — it does not contain a sandbox environment."
Wrong OS "That image is for macOS sandboxes. This computer needs the Windows/Linux image."
Wrong architecture "That image is built for Intel/AMD (x86_64) machines, which this computer is not."
Unreadable "That file could not be read. Check it still exists and you have permission to open it."

inspect.ts streams the tarball and reads only the first bytes of each member, so a 1.3 GB image is judged in milliseconds. Measured against the four published images:

vz-arm64     {"kind":"image","format":"vz","arch":"arm64"}       127ms
vz-x64       {"kind":"image","format":"vz","arch":"x64"}          28ms
bwrap-arm64  {"kind":"image","format":"rootfs","arch":"arm64"}     2ms
bwrap-x64    {"kind":"image","format":"rootfs","arch":"x64"}       1ms

Format comes from the members: a kernel plus a disk is the macOS image; a Linux root filesystem is the tarball Windows and Linux share. Those two are therefore indistinguishable — accepting one image for both is correct, not a gap, and there's a test asserting it.

Architecture comes from the kernel's own magic, the build's arch marker in a rootfs, or failing those the first ELF header in the tree. The kernel magic was verified against both built kernels: arm64 stamps ARM\x64 at offset 56, x86 stamps HdrS at 514 — both start MZ, so those offsets are what actually distinguish them.

One deliberate call: an image whose architecture cannot be determined is allowed through. Refusing a valid image because the probe could not prove its arch is worse than the rare mismatch that slips past, and the guest fails loudly on first boot either way.

The dialog only renders a verdict — the main process decides, and the same check runs on sandbox-add, so a stale or hand-crafted IPC call cannot get an unusable image past it. A path typed by hand rather than picked is tracked as unchecked until it is committed, so it can never show as approved without having been looked at.

Name validation

The name is not a label: it becomes the sandbox id (sandbox:<name>), its data directory, and on Windows the WSL distribution name. Previously only the character set was checked, and only in the dialog. Now also:

  • Duplicates, compared case-insensitively — macOS and Windows would give Work and work the same data directory.
  • Length, capped at 32, comfortably under every limit involved.
  • Clearer charset message explaining the leading-character rule.

All of it up front, instead of an already added error thrown after the image had downloaded.

Testing

  • packages/codev-sandbox: 141 → 156 pass. 12 new inspector tests over synthetic archives (both kernel arches, marker file, ELF fallback, non-archive, non-sandbox archive, missing/empty, and the full validate matrix), plus 4 covering local-image staging.
  • packages/app: 670 → 677 pass, 7 of them the new name-model tests.
  • packages/desktop: 96 pass. bun typecheck clean in all three.
  • The inspector was also run against the four real published images, which is where the timings above come from.

DIVERGENCES rows updated for platform.tsx / renderer/index.tsx (a new path-only file picker — the existing attachment picker reads the file's bytes, unusable for a multi-gigabyte image) and for the i18n families.

🤖 Generated with Claude Code

quickbeard and others added 3 commits August 16, 2026 08:35
Creating a sandbox always downloaded its guest image — over a gigabyte, and
impossible on a machine that cannot reach the storage host. The Add sandbox
dialog now takes an optional image file. Empty means download as before; a
path means use that file, read where it sits rather than copied, and persisted
so reset re-provisions from the same file.

An image the user picked has three ways to be wrong that a create would
otherwise discover minutes in, or worse, only when the guest fails to boot:
it is not a sandbox image, it is for another OS, or it is for another
architecture. inspect.ts streams the tarball to answer that, reading only the
first bytes of each member so a 1.3 GB image is judged in milliseconds
(measured: 1-127ms across the four published images).

Format comes from the members: a kernel plus a disk is the macOS image, a
Linux root filesystem is the one Windows and Linux share — which is why those
two cannot be distinguished, and accepting one image for both is correct
rather than a gap. Architecture comes from the kernel's own magic (arm64
stamps "ARM\x64" at offset 56, x86 stamps "HdrS" at 514 — both start "MZ", so
the offsets are what tell them apart), from the build's arch marker in a
rootfs, or failing that from the first ELF header in the tree. An image whose
architecture cannot be determined is allowed through: refusing a valid image
because the probe could not prove its arch is worse than the rare mismatch,
which the guest reports loudly on first boot anyway.

The main process is the authority — the same check runs on add, so a stale or
hand-crafted call cannot get an unusable image past the dialog.

The name field is checked while typing too. It becomes the sandbox id, its
data directory, and the WSL distribution name, so a duplicate (compared
case-insensitively, since macOS and Windows would give both sandboxes one
directory), an over-long name, or an unsafe character used to surface only
after a create had started and downloaded its image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Choosing an image left the field empty. open-file-picker is the attachment
picker: it stats every file, enforces MAX_ATTACHMENT_BYTES (20 MB) and mints a
read token for the renderer. A guest image is over a gigabyte, so the handler
threw, the invoke rejected, and the dialog's un-caught .then never ran — the
picker closed and nothing appeared, with no error to explain it.

Picking a path is not attaching a file: nothing is read, so it gets its own
channel with no budget and no read authorization. Its filters also carry an
"All files" group, so an image with an unusual name stays selectable.

The dialog now reports a failed pick instead of dropping it. That silence is
what made a 20 MB limit look like the app ignoring the click.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The status line under the Add sandbox form read the create job's progress and
treated any value as a download in flight. That progress covers fetching the
image and nothing else, so it was wrong in three situations, and wrong for the
whole slow part of each:

- a custom image reports its full size in one go, so the dialog claimed to be
  downloading a file the user had picked off their own disk;
- a finished download leaves progress pinned at 100% for the entire unpack, so
  the bar sat full while still saying "Downloading";
- an image already in the cache reports nothing, so the idle hint about first
  downloads stayed up for the whole create.

The phase now comes from what the progress is not saying — complete, or absent,
means the fetch is over and the sandbox is being built — and the dialog says
"Creating the sandbox…" for that stretch. The download hint is also dropped
once a custom image is chosen, since nothing will be downloaded.

The rule is in create-phase.ts with tests rather than inline in the JSX; it is
small, pure, and had already been got wrong three ways by eye.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quickbeard
quickbeard merged commit 5da8b27 into main Aug 16, 2026
0 of 5 checks passed
@quickbeard
quickbeard deleted the feat/sandbox-custom-image branch August 16, 2026 05:12
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.

1 participant