Conversation
The opencode bundle's 1.4 GB uncompressed initramfs could not be unpacked in the fixed 4096 MiB guest: the kernel holds the image and the ramfs it unpacks into at the same time, so unpacking failed and the guest panicked without an init. Strip the npm/pip/uv caches and /boot from the rootfs, compress the image with zstd, and derive the guest memory from the built sizes so a large bundle gets the memory it needs. An undersized bundle is now rejected on the host instead of dying inside the kernel. The compressed image is padded to a 4-byte boundary and the per-run concatenation keeps every segment aligned, because unpack_to_rootfs() only recognizes the appended overlay archive there.
The emulated serial console carries no window size, so the guest console reported 0x0. Terminal UIs that trust it rendered one column wide (codex, opencode) and those with a fallback drew into a fixed 80x24 box regardless of the real terminal (claude, pi). Seed the console from the host terminal in the generated run script. The size is applied once at startup: the console is not a controlling terminal in the guest, so a later resize delivers no SIGWINCH.
opencode keeps its state in opencode.db, a WAL-mode SQLite database inside its
config store, which the qemu backend mounts over 9p. WAL shared-memory files
need mmap semantics there, so every query failed ("Failed query: PRAGMA
wal_checkpoint(PASSIVE)"), the storage layer died before the app logged
anything, and the TUI painted an empty frame. codex already declares
qemuStoreCacheMmap for the same reason.
Also populate /etc/hosts in the bundle. Docker bind-mounts it into the
provisioning container, so the export left an empty placeholder and the guest
resolved "localhost" through musl built-ins, answering ::1 first.
EclipseSourceAI
left a comment
There was a problem hiding this comment.
Note
Autonomous AI review.
This review was done by an AI agent and therefore may contain mistakes. Feel free to ignore any comment you disagree with. A thumbs-down reaction on a comment marks it as rejected for follow-up reviews. Noting why in a reply helps, since replies are read too.
Resolving all AI comments does not lead to an automatic approval. A maintainer still needs to review and sign off on the overall architecture and design.
To get an updated review after pushing changes, a maintainer may re-request a review from this account.
Running in Eclipse Enclave, submitted via review-guard-mcp
Three independent qemu-guest fixes in one PR: bundle sizing (zstd-compress the initramfs, strip build caches and /boot, record packed/unpacked sizes and derive guest memory from them), console sizing (seed stty rows/cols from the host terminal in the generated run script), and opencode's SQLite store (qemuStoreCacheMmap plus a populated /etc/hosts in the bundle).
The approach looks sound. qemuMinMemoryMiB correctly becomes a floor that feeds the bundle hash while the effective size is a build output, and dropping the config-content comparison from qemuBundleCurrent follows from that. The 4-byte alignment reasoning for the appended overlay matches unpack_to_rootfs(), and concatenateFiles now enforces it generically, which also covers the legacy uncompressed bundles. make test and shellcheck pass locally.
Points worth a maintainer's attention:
- Derived guest memory is unbounded and never compared against host RAM. A large bundle now hands QEMU more than the host has and the session dies to the OOM killer mid-run rather than failing up front.
- The undersized-bundle error advises
--rebuild, which is exactly the flag that is ignored for the--image-namebundles most likely to hit it. - The builder counts the unpacked size through an unchecked pipeline, so a zstd failure there silently under-reports the size and reintroduces the boot failure this PR fixes.
- The alignment invariant now lives in both the builder and
concatenateFiles.
Scope is tight, no unrelated refactors, and the new comments explain non-obvious kernel and 9p behavior rather than restating the code.
| if memoryMiB >= required { | ||
| return nil | ||
| } | ||
| return fmt.Errorf("qemu backend: bundle %s needs at least %d MiB to unpack its %d MiB initramfs but %s declares %d MiB; rebuild the bundle with --rebuild", |
There was a problem hiding this comment.
The --rebuild advice is a dead end for the case that actually trips this. A prebuilt bundle passed via --image-name typically has no enclave-vm-bundle.json, so resolveBundleMemoryMiB falls back to DefaultMemoryMiB and fails here, while --rebuild is explicitly ignored for --image-name (link). Falling back to RequiredMemoryMiB when the config is absent but the metadata is present would make that bundle just work.
| return err | ||
| } | ||
| if memoryMiB > minMemoryMiB { | ||
| logx.Infof("Sizing qemu guest memory to %d MiB for the built initramfs.", memoryMiB) |
There was a problem hiding this comment.
The derived size is unbounded and never checked against host RAM. With a big enough bundle QEMU gets -m larger than the host has, starts fine, and the session dies to the OOM killer mid-run instead of failing up front. The old fixed 4096 MiB could not do that, so a maintainer should decide whether a cap or an upfront warning belongs here.
| appendFile(attach.Out) | ||
| appendFile(attach.Err) | ||
| appendFile(attach.In) | ||
| candidates = append(candidates, os.Stdout, os.Stderr, os.Stdin) |
There was a problem hiding this comment.
This re-implements the nil-stdio fallback that readerOrDefault/writerOrDefault/stderrOrDefault already do (link). Running attach through those instead keeps the probed descriptors identical to the ones QEMU is actually handed.
| initramfs=/out/initramfs.cpio.zst | ||
| ( cd "$root" && find . -print | cpio -o -H newc --quiet ) | zstd -q -f -T0 -9 -o "$initramfs" | ||
| zstd -q -t "$initramfs" | ||
| uncompressed=$(zstd -q -d -c "$initramfs" | wc -c) |
There was a problem hiding this comment.
sh has no pipefail under set -eu, so a zstd failure here is swallowed and wc reports a short count that lands in the metadata and under-sizes the guest, which is exactly the boot failure this PR fixes. The -t pass above catches a corrupt file but not a failure in this pass; set -o pipefail (busybox ash supports it) or an explicit status check would.
| # run overlay - when it starts on a 4-byte boundary, so pad the compressed | ||
| # image out to one. The kernel skips the padding bytes before the segment. | ||
| compressed=$(wc -c < "$initramfs") | ||
| pad=$(( (4 - compressed % 4) % 4 )) |
There was a problem hiding this comment.
concatenateFiles now aligns every segment before appending, so this padding is redundant for any bundle the host boots. Two copies of the same non-obvious kernel invariant in two languages will drift; worth picking one as authoritative.
| and its unpacked contents at the same time. Guest memory therefore scales with | ||
| the bundle, and the host derives it from `enclave-vm-initramfs.json` | ||
| (`backendqemu.RequiredMemoryMiB`), never below the profile's | ||
| `qemuMinMemoryMiB`. Two consequences for the builder: |
There was a problem hiding this comment.
Guest memory scaling with the bundle is user-visible (opencode now gets 5 GiB instead of 4), but the QEMU section in docs/cli-reference.md still says nothing about memory. A sentence there would help, this README is a builder asset doc.
| `sandbox.qemuMinMemoryMiB`, what the built initramfs needs). Use it for tools | ||
| that need more memory than the default to start reliably; it never has to | ||
| account for the bundle's own size, because the guest rootfs is the unpacked | ||
| initramfs and the builder already sizes memory to it. Set `sandbox.qemuStoreCacheMmap` when the tool's |
There was a problem hiding this comment.
Line is 102 chars while the rest of the paragraph wraps at ~76. Rewrap.
What it does
Fixes #75.
Three defects kept
--backend qemusessions from working with the bundled tools.into a ramfs that becomes the rootfs, and during unpacking the kernel holds both
the image and its contents. opencode's 1.4 GB image did not fit the fixed
4096 MiB guest and panicked without an init. The builder now strips build-time
caches (npm, pip, uv) and
/boot, compresses the image with zstd, and recordspacked/unpacked sizes so the host derives guest memory from the actual bundle
(never below
qemuMinMemoryMiB). An undersized bundle is rejected on the hostinstead of dying inside the kernel. The compressed image is padded to a 4-byte
boundary so the kernel still recognizes the per-run overlay archive appended to it.
codex and opencode rendered one column wide and claude and pi fell back to a fixed
80x24 box. The generated run script now seeds the console from the host terminal.
opencode.dbruns in WAL mode inside theconfig store, and WAL shared-memory files need mmap semantics there; every query
failed and the TUI painted an empty frame. opencode now declares
qemuStoreCacheMmap, as codex already did. The bundle also gets a populated/etc/hosts— Docker bind-mounts it into the provisioning container, so the exportleft an empty placeholder and the guest answered
::1forlocalhostfirst.How to test
Requires Docker (bundle build) and
qemu-system-x86_64. Resize your terminal tosomething clearly wider than 80 columns first.
enclave --backend qemu --tool opencode— the bundle builds, the guest boots, andthe TUI comes up filling the terminal with no
Failed query: PRAGMA wal_checkpointerrors. Send a prompt, exit, restart: the session history is still there.
--tool codex,--tool claude,--tool pi— each TUI uses the fullterminal width, not one column and not 80x24.
stty sizematches your host terminal, andgetent hosts localhostanswers127.0.0.1.Follow-ups
The console size is applied once at startup. Resizing the host terminal mid-session
does not reach the guest, because the console is not a controlling terminal there and
no
SIGWINCHis delivered.Breaking changes
Review checklist