`vm reset` killed the VM and immediately recreated the overlay, without
waiting for the process to actually exit. The next boot then read a
half-detached file and failed with "sandbox did not become reachable" -- a
symptom that appears one step AFTER the cause and says nothing about it.
Reset itself was always correct (verified by hand: after
`rm -rf --no-preserve-root /etc /usr`, a reset guest reports
NAME="Arch Linux" and has /usr/bin/bash back). This only bit when a reset
followed a still-shutting-down VM closely enough to race it, which is exactly
what the destructive demo does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
Measured between two sandboxes: 3 packets transmitted, 3 received, 0% packet
loss, 0.23 ms average.
The previous commit called this broken. It was not: the dgram transport worked
all along, but the odd/even pairing computed the wrong peer port for even
indexes, so a VM pointed `remote.port` at a port nothing had bound. Both ends
came up, addresses were right, and zero frames crossed -- indistinguishable
from a dead transport, and nothing in any log said otherwise. The tell was
`ss -lunp`: the bound ports were 12349 and 12350 while the guest was sending
to 12348. The test then compounded it by using indexes 4 and 5, which belong
to DIFFERENT pairs (4 pairs with 3, 5 pairs with 6).
Pairing is (1,2), (3,4), (5,6)... Two sandboxes created back to back are wired
together, which is the common case.
mcast= remains genuinely unusable here and is documented as such so nobody
re-tries it: lo has no MULTICAST flag and there is no multicast route, so qemu
accepts it and drops every frame.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
The peer-address service read a `vmbox.peer=` kernel argument that the
launcher never passed -- the reader was written and the writer forgotten -- so
eth1 came up DOWN with no address on every guest. It now derives the address
from the NIC's own MAC (the host allocates 52:54:00:be:ef:<index>), which
removes the coupling entirely: nothing has to be passed in, so the launcher
and the guest cannot disagree about which index a VM has.
That fixed the addressing but NOT the connectivity. `mcast=` cannot work on
this host at all -- `lo` carries no MULTICAST flag and there is no multicast
route, so qemu accepts the option and silently drops every frame, which is the
same "flags accepted, nothing happens" trap as POWERDOWN. A listen=/connect=
socket hub over loopback TCP was tried next: the hub port listens, both
guests bring eth1 up with the right addresses, and ARP still fails.
Documented as a known limitation rather than papered over. Sandboxes work
fully on their own; two guests cannot talk to each other yet.
Also corrects the destructive demo: a running Linux SURVIVES `rm -rf /usr`
because processes hold their binaries through open file handles, so sshd keeps
answering. The honest proof of destruction is that the machine cannot boot
again, so the demo now reboots it and asserts it does not come back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
Ran kuhy's real setup_midnight_shutdown.sh inside a sandbox -- the installer
whose own output says "There is NO disable option - this is protected by a
monitor service". It created /etc/shutdown-schedule.conf under guard-lib's
immutable protection and enabled shutdown-timer-monitor.service,
day-specific-shutdown.timer and shutdown-timer-monitor-watchdog.timer.
The host was provably untouched afterwards: base image checksum OK, /etc/hosts
still carrying its +i flag, and the host's own shutdown-schedule.conf still
stamped 11:21 that morning rather than the 21:19 when the guest wrote its copy.
Two properties of that installer are now documented, both found by running it:
it is interactive (`read -r -p 'Do you want to proceed?'`, so pipe `echo y |`
since vm run closes stdin deliberately), and it is subcommand-driven, so a
bare invocation prints usage and changes nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
9p exports a directory TREE and does not follow a symlink pointing out of it.
The share directory held `testsAndMisc -> /home/kuhy/testsAndMisc`, so the
guest mounted /mnt/hostrepo successfully, listed the symlink, and then got
"No such file or directory" following it -- the target path does not exist
inside the guest. Measured in a live sandbox, not assumed.
`vm share <path>` now bind-mounts the directory into the share and remounts it
read-only, so the guest sees real contents while still being unable to write
to the host copy. `vm unshare <name>` reverses it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
The destructive demo asked the guest to confirm its own destruction, one step
after deleting /usr -- which removes bash, sshd and agetty. Nothing would have
been left to answer, so the check would have FAILED on a step that actually
succeeded. It now proves breakage from the host (the overlay grew, and the
guest can no longer answer at all), which is the same evidence the shutdown
verdict relies on and for the same reason: a corpse cannot report its own
death.
Step 3 also no longer recreates the sandbox. `vm reset` in step 4 is the claim
under test; recreating first would have quietly tested something easier.
README's verdict table now carries the MEASURED qmp events per guest command
rather than the inferred ones, and states the finding that motivates the whole
design: ssh returned an identical "Connection closed by remote host" for the
poweroff, the reboot AND the panic.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
`_run_exec "$name" "$@"` sat on its own line under `set -euo pipefail`, so a
non-zero return aborted the script before the verdict was ever computed. ssh
returns 255 when its connection dies -- which is exactly what a SUCCESSFUL
guest poweroff causes. The single case this tool exists to report on was the
one case that silently printed nothing.
Caught by the acceptance demo, not by the unit tests: verdict_report was
correct in isolation the whole time and passed 8/8 there. Only running the
real CLI against a real poweroff exposed the wiring.
Verified after the fix, end to end:
==> Running in 'fixtest': sudo systemctl poweroff
Connection to 127.0.0.1 closed by remote host.
ok VERDICT: clean poweroff -- guest powered itself off and reached
'reboot: Power down'
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
shutdown_verdict_demo.sh exercises every branch of the verdict table against a
real guest -- clean poweroff, reboot-instead-of-poweroff, kernel panic, and a
command that leaves the machine running. The failure branches matter as much
as the success one: a verifier that has only ever returned "pass" is untested.
destructive_demo.sh is the acceptance test for the tool as a whole: destroy
/etc and /usr in the guest, assert the guest really is broken, reset with one
command, assert it is pristine again, then assert the HOST is untouched
(/etc/hosts attributes unchanged, base image hash unchanged, /etc and /usr
still present).
Measured on the first real run, which confirms the design's central claim:
QMP reported SHUTDOWN {"guest": true, "reason": "guest-shutdown"} and the
serial log reached "reboot: Power down", so the verdict was "clean poweroff"
with exit 0 -- while ssh saw only "Connection closed by remote host", which
would have been indistinguishable from a crash or a dropped link.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
Root cause, confirmed causally rather than inferred: masking
systemd-timesyncd (which vmbox does so `-rtc base=` stays pinned, since
timesyncd otherwise resets the guest clock to real time) left
systemd-time-wait-sync waiting for a sync that can never arrive. That unit sat
in "start running" forever and every other job queued behind it as "start
waiting" -- sshd included -- so the guest never reached multi-user.target. It
booted to a login prompt and looked perfectly healthy while being permanently
unreachable. The fix is one line: mask both units together.
Four earlier hypotheses were each disproven by measurement. What finally
worked was asking the guest instead of reading absence-of-log-lines:
`systemctl is-system-running` returned "starting", and `systemctl list-jobs`
put systemd-time-wait-sync at the head of the queue. Stopping it made sshd
active and bound :22 within seconds, and ssh from the host then succeeded.
Rule for next time: a unit that is enabled, has satisfied dependencies, and
neither starts nor fails is QUEUED -- check list-jobs first.
The smoke gate added earlier did its job here: it caught the unreachable image
and refused to seal it, keeping the sandbox so its logs survived.
Also fixes serial_exec markers to be plain alphanumerics -- shell
metacharacters get echoed and line-wrapped by the tty, which made them
unreliable to match and produced spurious timeouts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
Committed by accident: running systemd-ssh-generator --help during
investigation made it treat '--help' as its output path and write generator
units there. Not part of vmbox.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
The Arch cloud image leaves sshd enabled but never started -- no start line,
no failure line, dependencies satisfied. Four hypotheses (NTP, cloud-init
clean, a socket-disable, a blocking start) were each disproven by measurement,
so rather than keep paying a ~10 min rebuild per guess this adds the transport
the tool should arguably have used all along.
For the case vmbox exists for, serial beats ssh on the merits: it keeps
printing straight through the poweroff that kills an ssh connection, and it
works when sshd does not. `vm run` now picks ssh when it answers and the
serial console otherwise, and recovers the guest exit code either way. Guests
get a second serial port (ttyS1) on a unix socket with a root autologin, kept
separate from ttyS0 so driving a shell never interleaves with the append-only
log the verdict is read from.
serial_exec.py waits for an explicit per-command end marker rather than a
fixed sleep. That is not a detail: an earlier probe used a 4s window, the next
command was typed while the previous one still ran, and the tty echoed that
input -- which reads exactly like output in a transcript and produced a
confident but wrong conclusion. It also strips ANSI CSI and systemd's OSC 3008
shell-integration blocks, verified against a real captured boot log.
Also: a vmbox-sshd-kick unit that forces sshd up after multi-user.target as
belt and braces, and the smoke-test sandbox renamed to a name that actually
passes validate_vm_name -- "__smoke" was rejected, so the gate silently never
ran.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
Much of testsAndMisc/linux_configuration cannot be tested on the host: 15
files use `chattr +i` (so a bad test is not simply revertable), the shutdown
installers write units that power the machine off, and boot_recovery installs
pacman hooks. The repo's own CI says as much -- its Ubuntu job runs only the
side-effect-free tests, noting the rest "drive pacman, /etc, systemd or adb".
vmbox runs them in a throwaway Arch guest and verifies the result from the
HOST, which is the part that is not obvious: a successful shutdown kills the
connection carrying the report, and ssh returns 255 for a clean poweroff, a
crash and a dropped link alike. So the verdict comes from two artefacts that
outlive the VM -- a QMP event log written by a recorder attached at launch
(attach later and qemu has already exited), and the serial console log.
Five outcomes are distinguished: clean poweroff (SHUTDOWN guest:true plus a
serial tail reaching "reboot: Power down"), dirty stop, guest-reset (rebooted
instead of powering off), GUEST_PANICKED, and hung. A POWERDOWN event proves
nothing -- it means the request was delivered, not that anything acted on it.
Reset is a qcow2 backing-file trick: the golden image is built once and sealed
(chmod 444 plus a sha256 sidecar verified on every launch, since booting the
base would silently invalidate every overlay), and each sandbox is a thin
overlay, so reset is rm + recreate. No root anywhere -- user-mode networking
with per-VM forwarded ssh ports, and a socket multicast segment for VM-to-VM.
The guest clock is pinned and re-applied on every launch, because the shutdown
installers gate on a 21:00-05:00 window; NTP is masked in the image or it
silently undoes the pin. Repos are cloned from a read-only share scoped to an
explicit directory, never $HOME, and credentials are sentinel values.
`vm build` ends by booting a throwaway sandbox off the sealed image and
refusing to ship if ssh does not answer. Several distinct faults produced an
identical "boots fine, connection reset" symptom; checks inside the build VM
only prove state at seal time, so the gate has to be an end-to-end boot.
Tests: 8 bats cases over the verdict table and name/meta/serial handling;
shellcheck and ruff clean; every file within the 250-line cap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3