A static page that boots a real 32-bit Linux kernel under v86 (an x86 PC emulator compiled to wasm), with the actual fixpoint-linux system running on it:
fx-initas PID 1 — exec'd out of the content-addressed store via the kernel'srdinit=, the M4 initramfs path: no disk image, no bootloader.- the content-addressed store at
/fx/store. dhakematerializing the rootfs —/etcand/binexist in the guest only because dhake's buildfile ran and copied/symlinked them out of the store.- the boot verdict: fx-init's own
fx-init: boot-ok v3on the console. (The interactive terminal is DEFERRED, deliberately — and the verdict does not hang on a missing binary: the store is activated fromm3/config-good.dhall, which declares NO console service, because a declared service whose binary is absent is an EXEC failure and fx-init fails the boot on that BY DESIGN (init.zig: "a console service that fails to SPAWN/EXEC still fails the boot honestly"; the exit it ignores is a user typingexit). The guest ships no fx-core payload and no busybox —harness/check-shell-free.mjsasserts the image stays shell-free — so there is nofx>prompt to type at. fxsh comes back as a PACKAGE installed through the system dhall config, so the store can attribute it withprovides/src_of, instead of this image copying binaries out of a host bindir beside the store's knowledge.)
The kernel, the store, fx-init and dhake are genuine builds of the
fixpoint-linux components — the emulator is emulating a PC, not simulating
fixpoint. Total download: 2,786,539 bytes (2.66 MiB) — including
the guest's 64 MiB disk, which is an empty ext2 and goes over the wire gzipped
at 66,508 bytes.
sd 0:0:0:0: [sda] Attached SCSI disk
Run /fx/store/d41ef87c…-fx-init/fx-init as init process
fx-init: store from kernel command line fx.store=/fx/store
fx-init: boot start store /fx/store
EXT4-fs (sda): mounted filesystem bca8c481-2fd0-41bc-9001-b0a466163a9a r/w without journal. Quota mode: disabled.
… (the ~30 MB seed: the disk was empty, so fx-init
copies the ramfs store onto it — NATIVELY, no
busybox: the image carries no shell at all)
fx-init: disk store mounted (current v3)
fx-init: pivot_root EINVAL (initramfs root is the namespace root) — switch_root fallback (MS_MOVE + chroot) applied
fx-init: pivoted to tmpfs root (magic 0x1021994)
fx-init: fifo control up (/run/fx/control.in + /run/fx/control.out)
fx-init: no UNIX control socket (/run/fx/control.sock: socket failed: Function not implemented — this kernel has no socket layer: CONFIG_NET is not set) — the fifo pair (/run/fx/control.in + control.out) carries the protocol for in-guest fxctl
fx> cat /etc/hostname
fixboxfx> cksum /bin/fxctl
1487296706 211824 /bin/fxctl
fx> cksum /bin/init
1977226950 845268 /bin/init
fx> cat /etc/passwd
root:x:0:0::/home/root:/bin/sh
fx> fx-init: boot-ok v3
seq 1 3 | head -n 2
1
2
fx> ls /bin
{"name":"dhake","size":402376,"mode":493}
{"name":"fakesvc","size":1110484,"mode":493}
{"name":"fx-activate","size":974404,"mode":493}
{"name":"fxctl","size":211824,"mode":493}
{"name":"init","size":845268,"mode":493}
fx> ls /tmp
{"name":"fx-ls-hGoJip","size":140,"mode":448}
{"name":"fx-ls-bKKOjo","size":200,"mode":448}
fx> cat /fx/disk/store/.bootlog
3 in-progress 1791885924 none
3 ok 1791885939 none
fx>
The two sd 0:0:0:0: [sda] / EXT4-fs (sda) / disk store mounted lines are this
image's change: the page now boots the same three artifacts as the native
souche lane — one i386 kernel, one initrd, one ATA disk — and fx-init brings
the disk store up before it pivots. cat /fx/disk/store/.bootlog is fx-init's
own append-only ledger on the disk, read back through the guest's console; the
persistence assertion in harness/assert.mjs reboots the machine in place
(same disk buffer, no page reload) and requires boot 1's exact bytes to still be
a prefix of boot 2's.
git clone https://github.com/fixpoint-linux/fx-demo && cd fx-demo
python3 harness/serve.py 8080 web # open http://127.0.0.1:8080/Everything the page fetches is committed, so this works offline and needs no
build. A served page is required (not file://) because a module script and a
wasm fetch cannot be loaded from the filesystem.
python3 harness/serve.py 8080 web &
npm install playwright
npx playwright install chromium-headless-shell # the browser playwright drives
node harness/assert.mjs http://127.0.0.1:8080/(If you already have Playwright and a browser, skip the two installs and set
PLAYWRIGHT=/path/to/node_modules/playwright/index.mjs instead.)
Exit 0 and every assertion below is the claim; proof.png, proof-serial.txt
(boot 1) and proof-serial-boot2.txt (the in-place reboot) are written to the
current directory. The pre-disk run quoted in evidence/ was made this way,
against a fresh git clone of the pushed repo, not from a working tree —
evidence/assert-output.txt is its verbatim transcript, including the scratch
dir, the sha256sum -c SHA256SUMS result and the port used. (That clone was at
34684ca, the commit that introduced that image's web/ bytes;
evidence/tmp-fix-before-after.txt is the same assertion run against the
image before it, where the two /tmp assertions fail.) evidence/disk-2026-10-13/
is the DISK image's own run: assert-output.txt (the gate), page-boot1-serial.txt
page-boot2-serial.txt(the page's 8250 capture, boot 1 and the in-place reboot),persist-output.txt+persist-boot{1,2}.txt(the headless node twin of the same assertion) andcontrol-ramfs-output.txt(the fail-before: the pre-disk bytes, booted WITH the disk attached, print the pinned ramfs line and mount nothing).
ASSERT fx-init-boot-ok-in-page: true (fx-init: boot-ok v3)
ASSERT pivoted-to-tmpfs: true
ASSERT rdinit-from-store: true
ASSERT dhake-materialized-/etc (rendered 'fixbox'): true
ASSERT dhake-/bin/fxctl-symlink-reads-through (in-guest "1487296706 211824 /bin/fxctl"): true
ASSERT dhake-/bin/init-symlink-reads-through (in-guest "1977226950 845268 /bin/init"): true
ASSERT dhake-materialized-/etc/passwd: true
ASSERT typed-pipeline-in-terminal: true output-1: true output-2: true 3-filtered: true
ASSERT post-pivot-/tmp-writable (ls /bin runs, no Mkdtemp): true
ASSERT post-pivot-/tmp-lists-a-mkdtemp-scratch-dir (ls /tmp): true
ASSERT disk-store-mounted-in-page: true (fx-init: disk store mounted (current v3))
ASSERT no-ramfs-fallback-line: true
ASSERT disk-ledger-read-through-the-guest-console: true 2 record(s)
ASSERT reboot-in-place-available: true
ASSERT b2-disk-store-mounted: true (fx-init: disk store mounted (current v3))
ASSERT b2-not-re-seeded (the discriminator: the store was already on the disk): true
ASSERT b2-ledger-grew-across-the-reboot: true 2 -> 4 record(s)
ASSERT b2-ledger-starts-with-boot-1's-exact-bytes: true
TOTAL FETCHED BY THE BROWSER (bytes): 5525275
The two cksum rows are not typed into the gate: harness/guest-image.mjs
reads the store's fx-init/fxctl entries out of the shipped initrd.xz and
computes their crc32+size, so the assertion is against this image's bytes
(the pre-disk image's rows were 3239201204 826524 and 1217607440 210768).
harness/check-cmdline.mjs runs first and refuses to start the browser at all
if the rdinit= hash in web/guest-cmdline.mjs is not the store the archive
carries.
docs/BOOT.md explains why each of those is evidence rather than decoration.
The last two are the regression test for the /tmp fix — evidence/ tmp-fix-before-after.txt is the same assertion run against the previous
image, where both are false and the guest answers ls /bin with
error: Mkdtemp.
5,525,275 bytes is not a sum of file sizes: it is the sum of the
content-length of every response the browser actually received in the passing
run, with the server applying Content-Encoding: gzip.
| file | raw | sent |
|---|---|---|
initrd.xz — the store + fx-init (NO fx-core, NO busybox) |
831,332 | 831,332 (xz: incompressible) |
bzImage (i386, linux-6.12.19 + ATA/ext4 fragment) |
1,253,888 | 1,253,888 (extension-less: the server does not gzip it) |
v86.wasm |
2,007,347 | 380,456 |
libv86.js |
359,825 | 93,787 |
xterm.mjs |
344,970 | 88,463 |
disk.img — 64 MiB of empty ext2, the guest's ATA disk |
67,108,864 | 66,616 (gzip; serve.py gzips .img for exactly this) |
seabios.bin |
131,072 | 65,607 |
xterm.css |
7,112 | 2,502 |
index.html |
5,221 | 2,477 |
guest-cmdline.mjs |
2,420 | 1,242 |
| 5,525,275 |
Same page served with identity encoding: 74,790,787 bytes — and 67,108,864 of
those bytes are the disk, which is 99.9% zeros because it is an empty ext2
the guest formats nothing on and fills itself (the seeding is the guest's, at
boot). Disk included, the whole page is still ~7.7 MB of assets that are not
the disk. For scale, a qemu-wasm route to the same guest is ~40 MB raw /
~10–13 MB compressed.
Caveat, stated not hidden: 169 bytes of that total is a single response
chromium makes that the page's server does not serve (a UUID path; the
server's own request log lists only the page's assets). Excluding it, the page's
own assets total 5,525,106 bytes. vgabios.bin and v86-fallback.wasm are
never fetched — the console is serial-only, and the fallback wasm is only
requested if the primary fails to load.
The 64 MiB disk is the one number here that a reader should push back on: it is a repo and CI cost (and a 64 MiB commit), paid so that the browser lane boots the same disk image the native souche lane boots rather than a browser-shaped substitute. If that trade is ever judged wrong, the honest alternatives are a smaller store, or shipping the disk gzipped as a named artifact — not a disk the browser lane builds for itself.
Decision: the built artifacts and the third-party runtime assets are all
committed to this repo and pinned by sha256 in SHA256SUMS. v86 and
xterm.js are vendored, not fetched from a CDN.
Why:
- The whole point of the repo is that
git clone+ serve + assert works. A release-asset route puts a network fetch and a hash check in the hot path of the demo, and a CDN route puts a third party in it. Vendored + committed means the clean-clone proof has no external dependency at all. - It costs 74.8 MB — of which 67.1 MB is the guest's disk image. This is
the one place the original argument (6.6 MB, and therefore small) stopped
applying, and it is stated rather than glossed: the disk is a 64 MiB, ~99.9%
zero-filled ext2 that the guest fills in at boot, so it is a repo/CI cost
paid for the property that matters — the browser lane boots the same three
artifacts the native lane boots, byte for byte, from a clone with no fetch.
The disk is also why
serve.pygzips.img: on the wire it is 66,616 B. Thefx-initproject's convention of publishing its kernel as a hash-pinned release asset is still the honest alternative if this repo's size ever matters more than the clone-and-serve property; what is not acceptable is a browser lane that builds its own disk. - The pins are real anyway (
SHA256SUMS, andsha256sum -cis part of the verification below), so anyone who prefers a release-asset mirror can build one from this repo without ambiguity about which bytes.
Component repos are not submodules here — the shipped artifacts are the
anchors. web/bzImage and web/initrd.xz are pinned by sha256, and the tree
below is the exact revision (all clean, committed trees this time — the
earlier guest had to be captured from uncommitted working-tree patches in three
of the six repos) observed at packaging time.
| component | revision | tree at packaging | shipped payload |
|---|---|---|---|
669187010053b8ea37f179756b06dad73043e150 |
clean | REMOVED from the guest. The 63 static i386 binaries (fxsh + the fx-* stages) were the image's only un-provenanced payload — copied from a host bindir with no provides and no src_of, invisible to the store's own audit. The console shell returns as a package through the system dhall config. |
|
| fx-init | 675e801cdb7441cc0abf35a8fcee10a7c6bfd468 |
clean | fx-init (PID 1), fxctl, fx-activate |
| fxstore | f7962b681c6405d539baf6f41e93bf26e5739f55 |
clean | hash input only (never executes) |
| dhake | 12dc4fbc477c089fbade2ad20372e2f71a624db0 |
clean | dhake.com (materializes the rootfs) |
| dhall-c | 565d728d147426e747a6e74f0ed09c53331053b1 |
clean | hash input only |
| datalog-dafsa | 59f7d855bd4d113e59798a0de92b9a2ad386661c |
clean | hash input only |
The store paths are content hashes of the source trees. The rdinit= hash
in web/guest-cmdline.mjs (d41ef87c…-fx-init) is the hash of the fx-init
tree this image was built from — a working tree that carried the disk-probe
change, not the committed 675e801; the dhake store dir names the tree fx-init vendors
(fx-init/vendor/dhake, eb16bcb) even though the shipped dhake.com is built
from the sibling at 12dc4fb — the store names its inputs, it does not vouch
for the binary you put in the directory. Re-running the provisioning against a
different fx-init tree yields a different name, which is the store doing its
job; image/build-store.sh prints the one to use.
web/bzImage, web/initrd.xz and web/disk.img are the shared image — the
same three artifacts the native souche lane boots under its own emulator, so
that a difference between the lanes is a difference in the guest, not in the
bytes. Built from the working trees at packaging time:
bzImage—kbuild/build-v86guest.sh(podmandebian:stable): the pre-disk lean fragment plus ATA/ata_piix/sd/ext4/the crypto chain. The pre-disk kernel's fragment (build-lean.sh) is kept in-tree and still builds: it is whatharness/control-ramfs.shboots as the fail-before.initrd.xz—image/build-store.sh+image/mkinitramfs.sh. NO busybox and NO fx-core: fx-init's disk migration (rm -rf/cp -a/mv/sync) is native in init.zig now (rm_rf/copy_tree/rename/syncfs_path), andharness/check-shell-free.mjsasserts the image stays shell-free — the pinned busybox the image used to carry hadsh,ashANDhush(MEASURED), a live shell in a guest whose containment claim is "no sh, no bash, no busybox".disk.img— PRE-FORMATTED at build time:busybox mkfs.ext2 -Fon 64 MiB of zeros, on the host, with no privileges (MEASURED; a file-backed mkfs needs none — only MOUNT does). The guest FORMATS NOTHING: fx-init refuses a blank disk loudly instead. The first boot then seeds it by copying the ramfs store onto it natively.fx-initis not a committed revision here: the store was activated from a working tree that carried the uncommitted disk-probe change. MEASURED in the shipped bytes:/dev/sda1is inweb/initrd.xz's fx-init — i.e. the binary really is the disk-probe tree (zig/src/init.zig's blob wasb7bf3c07…when slice 3 built it, and still is), and its own control-channel messages are the newer pair quoted in the transcript (fifo control up (…)+no UNIX control socket (…)) where the pre-disk image printed the single older warning. The pins are the artifacts' sha256 inSHA256SUMS, which is the anchor this repo has always used for the store hash either way.
What is verified reproducible (all MEASURED this packaging):
- every binary still in the shipped image is byte-identical to a fresh
clean-tree build —
fx-init,fx-activate,fxctl,dhake.comandfakesvc(each image copy vs the build of the commit in the table above); the 63 fx-core binaries are gone, so their 63 matches are gone with them. image/mkinitramfs.shproduces a byte-identicalinitrd.xzfrom the same store — two consecutive runs give the same sha256 (that took the--reproducibledelta, seedocs/BUILD.md);- the store dirs are stable across repeated
activate_pathsruns against quiescent trees (two runs, identical closure).
web/ the page and everything it fetches (committed, no CDN)
index.html the page: v86 wiring; imports the CMDLINE
guest-cmdline.mjs THE command line (the rdinit hash lives here, once)
bzImage i386 linux-6.12.19 + ATA/ext4 (sha256 f0278a8f…)
initrd.xz the store + userland (sha256 d6eb1351…)
disk.img the guest's ATA disk: empty ext2, 64 MiB (sha256 a2f0f6da…)
libv86.js v86.wasm seabios.bin (v86 0.5.470, Apache/BSD 2-clause)
xterm.mjs xterm.css (@xterm/xterm 6.0.0, MIT)
kbuild/ the kernel build recipe (podman, debian:stable)
build-v86guest.sh config-i386-v86guest.txt what the page ships (i386 + ATA)
build-lean.sh config-i386-lean.txt the pre-disk kernel (the control)
build.sh config-i386.txt the earlier untrimmed variant
image/ the guest image
build-store.sh provision + activate a store from the i386 userland
mkinitramfs.sh assemble initrd.xz (fx-init's M4 builder, adapted)
harness/ boot / drive / assert
serve.py gzip-reporting static server (the download number's instrument)
guest-image.mjs reads facts out of initrd.xz (store dir, entry crc32+size)
check-cmdline.mjs the rdinit hash vs the archive — refuses a stale boot
boot.mjs drive.mjs v86 in node, serial to stdout / scripted sessions
persist.mjs the reboot-in-place persistence proof (node, headless)
control-ramfs.sh the fail-before: pre-disk bytes + the disk -> ramfs line
assert.mjs the Playwright proof
docs/ BUILD.md (rebuild), BOOT.md (the boot chain and its evidence)
evidence/ the clean-clone run: assert-output.txt, proof.png,
proof-serial.txt (the guest's 8250 capture), proof-png-vision-read.txt
(an independent vision-model read of the screenshot), image-contents.txt,
tmp-fix-before-after.txt (the /tmp assertion vs the PREVIOUS image)
licenses/ v86 (BSD-2-Clause) and xterm.js (MIT) license + package metadata
SHA256SUMS every committed artifact, verifiable with `sha256sum -c`
The previous guest shipped components built before these commits, so it
reported all three of these as open. Each is now fixed upstream and in this
image; the fix is quoted in the run above, and evidence/tmp-fix-before-after.txt
is the assertion run against the previous image next to this one's.
- Post-pivot there was no
/tmp— FIXED (fx-init675e801). The oldpivot_root_to_tmpfscreated/proc /sys /dev /run /fx /fx/disk /lib64 /usr /oldrooton the new tmpfs root and/tmpwas not among them, so the tmpfs root — which replaces the initramfs layer that had it — had no/tmpat all. Ninefx-*stagesmkdtempa hardcoded/tmp/<stage>-XXXXXXtemplate, so the guest answeredls /binwitherror: Mkdtempandcksum /tmpwithcannot open '/tmp'+error: OpenFailed.init.zignow creates/newroot/tmpwithmkdir(0o1777)andchmod(0o1777)(the umask masksmkdir's mode) — deliberately not folded into the shared dir list, which is0755; the mode is the point. MEASURED in this guest:ls /binnow returns its five rows andls /tmplists thefx-ls-XXXXXXscratch dirs of the preceding commands (i.e. the directory is not merely present but used). That is exactly the pair of assertions added toharness/assert.mjs— this bug had no fireable regression test in its own repo (fx-init's console lane drives onlyseq | head, which needs no/tmp), so the demo's assertion is where one now lives. - The control-socket warning was uninformative — FIXED (fx-init
675e801). The behaviour it reports is unchanged and is still open (next section): this kernel has no socket layer. What is fixed is the line. It wasfx-init: warning: control socket failed; it is nowfx-init: warning: no control channel: /run/fx/control.sock (socket failed: Function not implemented — this kernel has no socket layer: CONFIG_NET is not set) and no virtio control port — fxctl cannot connect in this guest, naming the path, the failing step, the errno, the structural cause and the consequence.setup_ctrlnow records the failing step and its errno (ctrlFail) instead of returning a bare-1. SUPERSEDED by the disk image: that line is what the pre-disk image's fx-init printed, andevidence/proof-serial.txt:75is where it is pinned. The shipped fx-init is newer and printsfx-init: fifo control up (/run/fx/control.in + /run/fx/control.out)followed byfx-init: no UNIX control socket (…) — the fifo pair (…) carries the protocol for in-guest fxctl. Same defect, same errno, a newer tree that also grew the fifo transport; the disk image did not change it and this line is not asserted byassert.mjsin either form. fxsh's unknown-stage error was unhelpful — FIXED (fx-core6691870). The refusal itself is correct and stays (below); the message now says why:fx-shell: unknown stage '<name>' (not one of the 31 pipeline stages): a stage runs only when its command declares a typed signature, so the line can be typechecked before anything runs — fxsh does NOT execute arbitrary PATH binaries. The stage name has no 'fx-' prefix (the binary fx-ls is the stagels).The error value is unchanged (error.UnknownCommand).
These are known and unfixed as of the shipped image. They are listed because a demo that hides them is not a demo of the real thing.
- The control plane's UNIX socket cannot exist in this guest — the pinned
kernel has no socket layer (still open; a kernel change, not a code one).
fx-init serves the control protocol on a UNIX socket (
/run/fx/control.sock, and thetests/fxinit_boot.shlane drives it), butCONFIG_UNIXdepends onCONFIG_NET, the configs are# CONFIG_NET is not set(kbuild/config-i386-v86guest.txt, as the olderconfig-i386-lean.txtwas), and withNEToff the kernel stubs the whole socket layer, sosocket(2)returnsENOSYS. The--enable UNIXthe build script used to pass was dropped silently byolddefconfig— corrected inkbuild/build*.shand documented indocs/BUILD.md. MEASURED in this guest: the errno is reported verbatim by fx-init (the line quoted above). It does not affect the boot verdict — the boot path never uses the control socket — and the shipped image adds a second transport that needs no sockets: the fifo pair (/run/fx/control.in+/run/fx/control.out), which fx-init's own line says is up. Not asserted: that in-guestfxctl(activate/rollback/shutdown) actually completes a request over that fifo — nothing in this repo drives it, andassert.mjsdoes not claim it. The real fix for the socket route is a kernel rebuild withCONFIG_NET=y(i.e. a different, much larger kernel than the one this demo ships). - fx-init's control plane has no virtio-serial under v86 either. Separate
from the above: with no
virtio-consoledevice in this guest there is no vport for the other control transport fx-init supports (the onetests/qemu_ctrl.shdrives), so neither channel exists here and the demo drives the console over the emulated 8250 instead. fxshis not aPATHshell, by design. It dispatches exactly its 31fx-*pipeline stages and refuses everything else;/bin/fxctlis an unknown stage. 32 of the 63 shipped binaries are therefore unreachable from the console. That is the contract — a line runs only if it typechecks — not a defect: a PATH fallback would run commands whose stage-to-stage shapes were never checked. A module such asrealpathtakes its path operand from the pipeline (text_operand) — feeding it as argv isTooManyArgs— whilecksum/cataccept a literal path. This is why the assertions usecksum /bin/fxctl.- Guest memory is fixed at 128 MiB and the initrd is placed at the 64 MiB
mark by v86's
bzImageloader, so the initrd must stay well under 64 MiB. It is 0.83 MB (no busybox, no fx-core payload). - The kernel build is not bit-reproducible —
makeembeds a timestamp. The kernel is pinned by sha256; seedocs/BUILD.md. - The guest's disk writes live in the emulator's memory, not in the file.
Neither v86 (the page, or
harness/boot.mjs/persist.mjs) nor the native lane flushes the disk back toweb/disk.img— MEASURED: the shippeddisk.imgis byte-identical after a run that seeded ~30 MB into it. So the persistence this repo proves is the strong, checkable one — across a reboot in place with the same buffer (harness/persist.mjs, and the page's__rebootInPlace()): fx-init's append-only ledger on the disk must still start with the exact bytes boot 1 wrote. What is not claimed is that a page reload preserves anything: a reload re-fetchesdisk.imgand the guest boots the shipped bytes again, exactly as if the disk were fresh. Making a reload persist would mean writing the buffer back (a download/upload the demo deliberately does not do) — named here as the open item it is, not implied away.
The fixpoint-linux components (fx-init, fx-core, fxstore, dhake) carry
their own licenses in their own repositories. The vendored third-party assets
keep theirs, reproduced in licenses/: v86 (BSD-2-Clause, 0.5.470) and
xterm.js (@xterm/xterm 6.0.0, MIT). seabios.bin is SeaBIOS (LGPLv3),
distributed by the v86 project as the emulator's firmware image; it is verified
here against v86's published copy.