Commit Graph

28 Commits

Author SHA1 Message Date
00bb6706fd vmbox: vm lightdm and vm shim — the host's login topology and fake hardware
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
The golden image logs in with agetty+startx and has no RGB controllers,
no NVIDIA card and no sound card, so the night-lockdown scripts — which
mask/stop lightdm.service and getty@.service and shell out to openrgb,
nvidia-smi and amixer — had nothing real to act on in a sandbox and
were being "verified" on the host instead.

- `vm lightdm <name>`: installs lightdm + lightdm-gtk-greeter into the
  live overlay (pacman -Syu, not -S: the overlay's package database is
  the image's and mirrors have rotated those versions out), configures
  autologin -> i3 exactly like the host, turns tty1 back into a plain
  getty, reboots the guest and proves lightdm.service is active with an
  Xorg it owns. Never rebuilds the base: `vm build --force` re-seals a
  new checksum while every existing sandbox still backs onto the old
  file's name, which is silent qcow2 corruption.
- `vm shim <name> <tool> [exit[:N]|hang|sleep:S]`: a fake tool ahead of
  the real one on PATH, every call logged with an epoch to
  /var/log/vmbox-shim/<tool>.log so the host can assert what ran and in
  what order. `hang` is the openrgb 1.0-2 that never returned on
  2026-09-12 and defeated the curfew.
- The DISPLAY/XAUTHORITY resolver `vm run` sources becomes its own file,
  guest/vmbox-x11.sh, installed by the image build and re-installed by
  the lightdm switch; it now tests readability, not existence, and tries
  ~/.Xauthority before the startx serverauth glob, because lightdm's
  Xorg -auth file is root-only.
- README: the worked example uses MIDNIGHT_SHUTDOWN_CONFIRM=y (the
  installer refuses to run unattended now; `echo y |` no longer works),
  and the "i2c RGB stays on the host" line is gone.
- bats: four host-side tests for shim.sh (exit, exit:N, hang, refusals).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mLnUeoJ3mUwGRTowTUH6d
2026-09-13 10:51:10 +02:00
7096c102cd Re-point paths for the ~ reorganisation (home-tidy)
Some checks are pending
crdt_sync_dart tests / test (push) Waiting to run
crdt_sync_flutter tests / test (push) Waiting to run
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
design_system tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
freedays pre-commit / pre-commit (push) Waiting to run
freedays tests / test (push) Waiting to run
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (push) Waiting to run
github_device_auth tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
music_theory tests / test (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
web_ui tests / test (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
2026-09-11 18:45:55 +02:00
a10d190b65 Add the shared markdown-naming gate and migrate utils' own docs
Some checks failed
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
design_system tests / test (push) Has been cancelled
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled
web_ui tests / test (push) Has been cancelled
Markdown across every repo had accumulated a dozen ad-hoc names
(refactor_claude_todo.md x23, DESIGN_AUDIT_TODO.md, NEXT_SESSION,
HANDOFF, PLAN, SPEC) with nothing distinguishing a live task from a
finished record. An audit found docs asserting work was pending that
shipped months earlier, and one asserting completion while the work
was open.

Four namespaces now: README.md, CLAUDE*.md, DOCS*.md, TODO*.md. Task
files carry the literal "REMOVE ME AFTER FINISH" and are deleted when
the work lands.

Enforced bidirectionally -- a TODO without the marker fails, AND any
file carrying the marker that is not named TODO*.md fails. Without the
reverse rule a task can be renamed into the permanent namespace and
quietly live forever, which is the failure being fixed.

md_naming/ mirrors the file_length topology and imports its tables
(EXCLUDED_DIRS, THIRD_PARTY_REPOS, VENDORED_ANYWHERE, GENERATED_PATTERN)
rather than restating them, so the two gates cannot disagree about the
same file. 71 tests, 100% branch coverage.

stale.py warns on TODOs past 90 days but never fails: no machine can
prove a task is finished, so the honest mechanism is a nudge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
2026-08-24 15:53:12 +02:00
6fd3b44e8c vmbox: close the last open item -- reset clears chattr +i
Some checks are pending
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
Item 5 was recorded as "PASS (partial, reset check pending)". Now verified:
`chattr +i /etc/hosts` in a sandbox gives `----i-----------------`; after
`vm reset` the same file reads `----------------------` and accepts a write.

Worth having measured rather than assumed. Immutability is exactly what makes
the hosts guard hard to undo on a real machine -- `rm` fails EPERM -- so
"reset discards it wholesale" is the strongest form of the reset guarantee and
the one most worth being sure of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wfxvsYxKM5xDXoDbkKi5u
2026-08-23 15:29:14 +02:00
a597104cf2 vmbox: correct the .claude remote note and record a contamination path
Some checks are pending
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
Two fixes to DISCOVERY_TEST.md, both found by re-checking claims instead
of trusting them.

The .claude remote: `git branch -vv` + `ls-remote` confirm main DOES track
origin/main on kuhyx/claude-config-local, and `gh repo view` says that repo
is PRIVATE. The correction was right but stated the wrong load-bearing
fact -- what matters when telling a future session to push there is the
visibility, not merely that a remote exists. Now says so, and says to
re-check rather than trust the line.

The contamination: NEXT_SESSION_INSTALLER_FIX.md, added to the root of
~/testsAndMisc the same day, is about hosts/install.sh -- the exact file
the discovery prompt names -- and contains "Sandbox first, always ...
~/utils/vmbox". One `ls` at that repo root leaks the answer, which is a
likelier path than the ~/utils grep already noted, and this session created
it. Records it and offers the pacman wrapper as an uncontaminated
substitute prompt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 23:54:11 +02:00
5ec35952d1 vmbox: add DISCOVERY_TEST.md -- does a fresh Claude find the sandbox?
Some checks are pending
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
The sandbox-first rule and the vmbox-testing skill only pay off if a
session with no memory of building them reaches for the sandbox on its
own. This is the test for that: a pasteable prompt (install the hosts
guard and confirm it enforces) that never mentions vmbox, sandboxes, VMs
or the skill, plus a Pass/Partial/Fail rubric and the follow-up.

Deliberately NOT executed by the session that wrote it -- that session
knew all about vmbox, so its behaviour would prove nothing, and a subagent
either inherits that context or gets the task framed by it.

Chose the hosts guard over the shutdown timer because a Fail should be
cheap to lose: it leaves an immutable /etc/hosts rather than a surprise
poweroff. The rubric carries the undo commands next to the Fail
definition so a failed run is not discovered weeks later.

Records that ~/.claude DOES have a remote (kuhyx/claude-config-local),
correcting an earlier note that claimed main there is remote-less; the
fix on a Fail is the skill's description: frontmatter first, then the
CLAUDE.md block, and both need committing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 23:52:19 +02:00
30bf1ebb43 vmbox: hand off the boot-repair guard, the installer fix, and a discovery test
Replaces the handoff prompt with the next three pieces of work.

Job 1 lands the firmware guard for the bug the sandbox found: boot-repair
deletes the only bootable kernel on a BIOS/GRUB box because its "orphaned
kernel shadowing the ESP" heuristic never checks /sys/firmware/efi. The vmbox
guest IS such a box, so the fix is verifiable in the same place the bug was.

Job 2 writes the install_core_system.sh findings into a prompt in the repo
that owns that code. The two dead module paths resolve to ~/screen-locker and
~/steam-backlog-enforcer (checked on disk, not assumed), which makes the
installer cross-repo and is itself a question for kuhy rather than a guess.

Job 3 is a test of whether a fresh Claude discovers the sandbox rule on its
own. It is written, deliberately NOT executed: running it in the session that
built the tooling, or as a subagent briefed by that session, measures nothing.
The prompt says so up front, since it constrains the whole session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 23:33:43 +02:00
ab8227e23f vmbox: fix the settle gate breaking shutdown verdicts on the serial transport
Some checks are pending
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
The previous commit skipped the poweroff settle when `ssh_rc != 255`, on the
reasoning that a successful poweroff kills the ssh connection and ssh reports
255. That holds for the ssh transport and for nothing else. `_run_transport`
falls back to the serial console whenever ssh does not answer -- and the
README calls serial "the transport of record for shutdown tests" -- where the
exit code comes from serial_exec.py and is 0 even while the machine is already
on its way down.

Result, reproduced: `VMBOX_TRANSPORT=serial vm run <vm> 'sudo systemctl
poweroff'` reported "still running" for a guest that had cleanly powered off,
with SHUTDOWN{guest:true} in events.jsonl and "reboot: Power down" in the
serial log. The one thing this tool exists to get right, silently wrong. My
five verdict re-tests all went through ssh, so none of them caught it.

Grepping the serial log for shutdown markers instead does not work either:
they land a second or two after the command returns, so the check loses the
race. The fix waits a short grace period (default 3s) for any sign of a stop
before committing to the 30s wait. Warm runs go from 0s to 3s; that is the
honest price of a correct verdict.

Re-tested: clean poweroff, reboot and panic on BOTH transports, plus
tests/destructive_demo.sh 7/7.

Also, per "a fix that needs a human to remember a flag is not a fix": the X
session wait is now automatic on the branch that actually boots the guest --
the only one that can race tty1's autologin -- instead of requiring
VMBOX_X_WAIT. Warm runs never reach that branch. The env var remains an
override. README numbers corrected to the measured 14s/3s and ~100s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 23:28:57 +02:00
b9e2abad2d vmbox: record the confirming build number (99.99s from committed source)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 23:20:09 +02:00
cb3c985ee6 vmbox: 3-4x faster runs, a read-only host package cache, and vm scp
`vm run` took 41s cold / 30s warm to run `true`. Both were vmbox waiting on
itself. Now 12s / 0s, with all five shutdown verdicts re-tested unchanged.

- _run_settle waited up to 30s for the kernel's "reboot: Power down" marker
  UNCONDITIONALLY, including on the majority of runs that leave the guest up,
  where that marker is never going to appear. It now settles only when the
  guest has actually stopped, or when ssh returned 255 (what a successful
  poweroff looks like from the host).

- The ssh readiness polls used a 5s ConnectTimeout. QEMU's user-mode
  networking ACCEPTS the forwarded connection while the guest still has
  nothing on :22, so each probe blocked for the full timeout instead of
  failing fast: a guest reachable at ~10s was reported up at ~45s. Fixed in
  both the sandbox and build paths, via an env override -- ssh honours the
  FIRST value for an option, so a second -o could not have worked.

Making runs fast exposed a race: sshd now answers BEFORE tty1's autologin has
started Xorg, so the X environment could be missing on the first run after a
boot. `VMBOX_X_WAIT=<seconds>` waits for the session; it defaults to 0 so an
ordinary run never pays for a graphical session it is not using.

Build: the host's /var/cache/pacman/pkg is offered to the build guest
read-only (`readonly=on` AND an `ro` mount, because it is the real host cache)
as an extra CacheDir after the guest's own writable one, so a package the host
lacks still downloads rather than failing the build. This removes the 396 MiB
the build used to fetch. Host cache verified byte-for-byte identical across
four builds: same file count, same size, same mtime.

Build time itself moved only 103.8s -> 100.3s. Per-phase timings (also added
here) show why: 47s of it is the build guest's cloud-init first boot, and that
is NOT datasource probing -- `cloud-init analyze blame` totals 2.27s. Left
unexplained rather than guessed at; SESSION_RESULTS.md records the evidence.

Also: `vm scp`, which lib/ssh.sh could always do but the CLI never exposed,
and provision.sh split at the 250-line cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 23:17:21 +02:00
8bb4d3a60d vmbox: bound every wait, so an unbootable guest fails instead of hanging
Testing boot_recovery produced a guest that cannot boot: boot-repair read
/boot/vmlinuz-linux and initramfs-linux.img on this BIOS/GRUB guest as
"orphaned kernel files shadowing the ESP" and deleted them, leaving the
sandbox at a GRUB prompt. That is a legitimate sandbox outcome -- destroying
the guest is what the tool is for -- but vmbox handled it by hanging.

Two independent unbounded waits, both fixed:

- vm_wait_ssh counted only its `sleep 2` calls, ignoring the up-to-5s
  ConnectTimeout each failed probe spends inside ssh. A nominal 150s wait ran
  past 400s (measured, then cut short by the harness). It now works to a wall
  clock deadline: the same guest is now reported unreachable in 156s.

- _run_recover_rc reboots the stopped guest to read the recorded exit code,
  and fell through to an unbounded serial console read: 180s for a prompt
  that never comes plus 300s for a command that never runs, all AFTER the
  verdict had already been printed. Recovery is best-effort by definition, so
  both bounds are now short (60s/30s) and overridable.

serial_exec's timeouts become env-overridable to make that possible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 22:31:39 +02:00
b4a5b4f899 vmbox: add the host-side test suite the README already promised
README documented `bats tests/test_vmbox.bats` covering "name validation,
meta handling, serial rotation and the full verdict table", and the handoff
made it a done-condition, but the file did not exist. Written to that spec:
39 tests, no VM boot, well under a second.

VMBOX_HOME is redirected to a per-test mktemp dir, so the suite can create
and delete VM state without any chance of clobbering a live sandbox's overlay
or pidfile; one test asserts every path helper stays inside it.

The verdict cases encode the distinctions the design turns on rather than just
exercising the function: guest:true with a truncated serial log is DIRTY (3)
and not a clean poweroff, POWERDOWN alone proves nothing and must read as
"none", an unrecognised shutdown reason fails closed at 6 rather than passing
silently, and events.jsonl is read last-event-first so a previous boot's
poweroff is never mistaken for this run's outcome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 22:06:13 +02:00
cf5982c9fa vmbox: give guest commands the sandbox's own X session
The image ships Xorg + i3 on tty1 precisely so locker and i3 tests can run,
but `vm run` executes through `sh -c`, which reads no profile. Every X tool
therefore landed with no DISPLAY and no XAUTHORITY and failed with
"Could not determine i3 socket path" -- indistinguishable from "this sandbox
has no X server". testsAndMisc's i3-configuration installer failed that way;
with the environment resolved it exits 0 and i3-msg reports {"success":true}.

XAUTHORITY is the awkward half: startx generates /tmp/serverauth.XXXXXXXX
with a fresh random suffix every boot, so it cannot be hardcoded. Resolve it
from the running Xorg's own -auth argument, falling back to a newest-first
glob.

The `[ -f ]` guard on the source line is load-bearing, not defensive
dressing. In POSIX sh, `.` on a missing file is a SPECIAL BUILTIN error that
exits the shell outright, and `|| true` does NOT rescue it -- written that
way, every `vm run` against a base image predating this snippet silently
produced no output at all. Found by doing exactly that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:51:54 +02:00
25ba142f94 vmbox: handoff gains the fix-it mandate, sandbox reuse, and a build-speed job
Some checks are pending
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
Three changes from kuhy's follow-up:

- An explicit standing instruction that when vmbox breaks the next session
  FIXES it -- not works around it, not reports it as a limitation, not quietly
  falls back to testing on the host. Bugs found by real use are the return on
  having built this.
- Guidance to attach to an existing sandbox or create one as appropriate,
  rather than tearing down and rebuilding per command (`vm reset` is ~2s).
- A build-speed job with measurements rather than guesses: the last build
  pulled 197 packages / 396 MiB over the internet and spent 45s waiting on
  cloud-init, while the host's 114 GB pacman cache already holds every one of
  the packages checked. Exposing that cache read-only is the obvious first
  move; trimming base-devel and shortening the cloud-init wait follow.

The smoke gate stays regardless -- a faster build that fails open would be a
bad trade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:34:26 +02:00
4a39e2c0e1 vmbox: hand off with a prompt for the next session
Two jobs, deliberately ordered. First: run real linux_configuration work
through the sandbox (the 8 Arch-only CI tests, night-lockdown, boot_recovery,
the hosts guard) and fix vmbox wherever it breaks -- it has only ever been
driven by one session against a handful of scripts, so the ~1090-file surface
will find things.

Second, and only if the first says vmbox is trustworthy: the CLAUDE.md rule
that routes script testing here by default. A rule pointing at a flaky tool is
worse than no rule, so that ordering is the point. Four questions have to be
settled before writing it -- what triggers it, whether it replaces or precedes
the host run (see feedback-verify-real-deployment-path), the hardware escape
hatch, and keeping it short since CLAUDE.md is always-on context.

Carries forward the traps that cost real time this session: diagnose an
unreachable guest with `systemctl list-jobs`, never grep a bash -x trace for
the failure, and fix the image live over the serial console rather than
rebuilding once per hypothesis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:33:27 +02:00
c6688a21f7 vmbox: note that vm screenshot is verified
Some checks are pending
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
Captured a real 1280x800 PNG of a sandbox's i3 session straight from qemu's
framebuffer -- no VNC or SPICE viewer installed on the host. This is the path
locker and fullscreen-gate tests use, and it is safer than testing those on
the host, where a fullscreen locker can hijack input and steal focus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 21:29:15 +02:00
45b923dfac vmbox: wait for qemu to release the overlay before recreating it
`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
2026-08-22 21:28:02 +02:00
89399526f5 vmbox: VM-to-VM networking works (the transport was fine, my pairing was not)
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
2026-08-22 21:26:47 +02:00
6c49f42c2f vmbox: derive the peer IP from the NIC's MAC; document that VM-to-VM is broken
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
2026-08-22 21:23:37 +02:00
25698cd763 vmbox: document the worked example, verified end to end
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
2026-08-22 21:20:05 +02:00
a18e0da3a9 vmbox: share host repos by bind mount, not symlink
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
2026-08-22 21:15:57 +02:00
60571adb40 vmbox: assert destruction host-side; README gets the measured constants
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
2026-08-22 21:13:41 +02:00
efe77d4454 vmbox: print the verdict when the guest powers off (set -e aborted first)
`_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
2026-08-22 21:10:40 +02:00
36234e980a vmbox: add the end-to-end acceptance demos
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
2026-08-22 21:08:53 +02:00
4cb7abc3ac vmbox: fix the boot deadlock that made every sealed image unreachable
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
2026-08-22 21:06:06 +02:00
8465bec805 vmbox: drop a stray --help/ directory
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
2026-08-22 20:58:32 +02:00
0b1f86ae2c vmbox: add a serial transport so shutdown tests do not depend on sshd
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
2026-08-22 20:58:22 +02:00
a2d672f7fd vmbox: disposable Arch VM sandbox for destructive system tests
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
2026-08-22 20:47:56 +02:00