Skip to content

v0.4.0: swap, serial forwarding, MCP declarations, the pi runner, host-wide defaults - #15

Merged
celrenheit merged 3 commits into
mainfrom
release/v0.4.0
Aug 13, 2026
Merged

v0.4.0: swap, serial forwarding, MCP declarations, the pi runner, host-wide defaults#15
celrenheit merged 3 commits into
mainfrom
release/v0.4.0

Conversation

@celrenheit

@celrenheit celrenheit commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Five features, plus the fixes that came out of reviewing them. Three commits: the unrelated oci test fix, the feature set, and the changelog as the version bump.

What's in it

Every sandbox boots with swap (2 GiB default, vm ( swap <size|off> )). Motivated by the balloon controller rather than the guest's appetite: under host memory pressure clawk reclaims guest RAM against guest demand, and a guest with nowhere to put cold anonymous pages answers with direct-reclaim stalls and eventually its OOM killer. A multi-second stall in the agent process is not merely slow — a process that stops draining its socket lets the connection go idle, and on a link whose NAT reaps idle mappings in under a minute that ends the streaming response outright. It rides its own sparse virtio-blk device, because swapon(2) rejects a file with holes and a swapfile would cost its full size in real host bytes per sandbox.

Serial devices. clawk serial add <sandbox> /dev/cu.usbmodem1101 puts a board plugged into the Mac inside the sandbox as /dev/<name>. The USB device isn't passed through — nothing clawk runs on can do that — but the byte stream and line settings are, which is all arduino-cli, esptool and a monitor ever wanted. Host port opens only while a guest process holds the device, which is also what makes auto-reset work. vz-only: the guest is the end that dials.

mcp ( … ) declares MCP servers so a sandbox comes up with them configured. Each http/sse host folds into a derived mcp network layer ranking below anything you wrote, so a declared server is reachable without a matching network allow and can't override your own deny. Credential values stay out of clawk entirely — clawk.mod and the rendered guest config hold a ${VAR} reference.

The pi runner, opencode wired up, per-runner state on the host. The fix underneath: only ~/.claude was host-mounted, so codex's sessions and login lived on a rootfs vz re-clones every boot and were discarded by a plain down/up, not just by destroy.

Host-wide defaults in ~/.config/clawk/clawk.mod — one anonymous sandbox ( … ) block outside any repo, as the lowest layer of the precedence chain. --no-global drops it for a reproducible run.

Review fixes worth calling out

  • An env ( … ) declaration now beats clawk's same-named variable unconditionally, including when it fails to resolve. Previously the carve-out was derived from resolved values, so a sandbox pointed at a third-party gateway got clawk's Anthropic token as an Authorization header from any shell where the referenced variable was missing — and the request succeeded, so nothing surfaced.
  • A broken host-wide clawk.mod is now fatal on every create path. Here-mode previously warned and continued, which discarded the repo's own clawk.mod too, and snapshot-at-create meant the misconfigured sandbox persisted.
  • The serial hangup path flushes before letting go, so echo cmd > /dev/ttyACM0 is delivered rather than discarded. POLLIN and POLLHUP are independent on a PTY master, and honouring the hangup first binned the bytes.
  • An mcp URL embedding credentials is refused — the one spelling that would put a secret value on host disk.
  • ROOT_CLAWK_MODCLAWK_GLOBAL_MOD, for prefix consistency. Free now, breaking later.

Testing

build, vet, gofmt and the full suites pass in both modules, and all three commits pass individually so the branch bisects. Serial was exercised end to end against a PTY standing in for a board — device appears, lazy attach, bytes both directions, mode changes, refusal of a second opener, live serial add, detach.

Not covered by CI: swap on a real boot (both providers), serial against actual hardware, and MCP end to end. internal/e2e boots firecracker and needs /dev/kvm, so it structurally can't cover serial.

Before tagging

  • macOS CI is the real gate. pressure_darwin.go changed and can't be cross-compiled, so this PR's macos-14 job is the first thing to type-check it.
  • Republish clawk-dev before the tag. defaultImage is the mutable :v0, and publish-clawk-dev only fires on pushes to main touching images/clawk-dev/**. Tagging first ships a binary advertising clawk run pi/opencode against an image that has neither.
  • Device-ceiling change. Four more agent state mounts plus the swap disk is five more virtio devices per sandbox against the 32 limit. The preflight guard names the count, but a large multi-repo workspace that booted on v0.3.0 may now be refused.

Fixes #14

`require.LessOrEqual(t, d, time.Second)` failed on an emulated arm64 host at
1.19s while the cache was working perfectly. Wall-clock was the wrong
instrument twice over. It is a proxy for "did not redo the work" whose budget
depends entirely on the hardware the test lands on — and it was not even
measuring the network, because resolveSource calls crane.Digest to turn the tag
into a digest BEFORE the cache lookup, so every Build makes a registry
round-trip whose latency the test cannot bound. A slow link fails it as surely
as a slow CPU.

The cache-hit path stats the done marker and returns; a miss re-pulls and
rewrites disk.ext4. So an unchanged mtime, size and os.SameFile prove the
artifact was reused — the property the timing bound stood in for, established
deterministically. Verified sensitive by forcing a real rebuild: both signals
flip.

Unrelated to the release below: the test is unchanged since the initial
release, so it stands on its own.
…r, host-wide defaults

The v0.4.0 feature set. Five threads, each pulling on the same premise — that
a sandbox should arrive configured rather than needing setup once it is up.

**Every sandbox boots with swap** (2 GiB default, `vm ( swap <size|off> )`).
The reason is the balloon controller, not the guest's appetite: under host
memory pressure clawk reclaims guest RAM against guest demand, and a guest with
nowhere to put cold anonymous pages answers with direct-reclaim stalls and
eventually its OOM killer. A multi-second stall in the agent is not merely slow
— a process that stops draining its socket lets the connection go idle, and on a
link whose NAT reaps idle mappings in under a minute that ends the streaming
response. It rides its own sparse virtio-blk device rather than a swapfile,
because swapon(2) rejects a file with holes and a swapfile would cost its full
size in real host bytes per sandbox. clawk-init writes the header itself rather
than shelling to mkswap, since the rootfs is an arbitrary OCI image. The
controller learned about swap at the same time and had to: paging out raises
MemAvailable and lowers PSI, so a squeezed guest reads as roomy on both signals
the controller uses. swapTrend holds reclaim while swap is GROWING and lets the
hold decay after two minutes of quiet, because occupancy latches — a slot is
freed only when its page faults back in — and reading the level as pressure
would retire reclaim for that sandbox permanently.

**Serial devices.** `clawk serial add <sandbox> /dev/cu.usbmodem1101` puts a
board plugged into the Mac inside the sandbox as `/dev/<name>`, so arduino-cli,
esptool and a monitor can run in there against real hardware. The USB device is
not passed through, because nothing clawk runs on can do that; what crosses is
the byte stream and the line settings, which is all any of those tools wanted.
The guest side is a PTY, the host side the real tty, opened only while a process
in the sandbox holds the device — which is also what makes auto-reset work,
since opening a port asserts DTR. A glob is resolved at open time, not at
configure time, so a board that re-enumerates into its bootloader under a
neighbouring name stays reachable. vz-only: the guest is the end that dials.

**`mcp ( … )`** declares MCP servers so a sandbox comes up with them configured,
no interactive login inside the VM. Each http/sse host is folded into a derived
`mcp` network layer ranking below anything you wrote, so a declared server is
reachable without a matching `network allow` and cannot override your own
`deny`. Credential values stay out of clawk: `clawk.mod` and the rendered guest
config hold a `${VAR}` reference, the value travels to the runner's process
environment at attach. A URL embedding credentials is refused, since it is the
one spelling that would defeat that.

**The pi runner, opencode wired up, per-runner state on the host.** `clawk run
pi` joins claude and codex, and opencode goes from registry-only to installed
and persisted. The bigger fix underneath: only `~/.claude` was host-mounted, so
codex's sessions and login lived on a rootfs vz re-clones every boot and were
discarded by a plain `down`/`up` — not just by `destroy`. Each runner's home is
now its own per-sandbox mount, opencode's two XDG dirs included.

**Host-wide defaults in `~/.config/clawk/clawk.mod`.** A kernel path, a token
alias, a personal skill mount and a house rule are properties of the host, not
of the repo they were written in. One anonymous `sandbox ( … )` block outside
any repo now supplies settings for every sandbox on the machine, as the lowest
layer of the chain. Scope is enforced, relative paths resolve against the file's
own directory, and `--no-global` drops the layer for a reproducible run. A
broken file is fatal on every create path rather than degrading to "no defaults"
— that silently discarded the repo's own clawk.mod too.

Also from the pre-release review: `env ( … )` values now reach the runner's own
process (not just its login shells) and a declaration wins over clawk's
same-named variable unconditionally, even when it fails to resolve — otherwise a
sandbox pointed at a third-party gateway got clawk's Anthropic token as an
Authorization header. And the serial hangup path flushes before letting go, so
`echo cmd > /dev/ttyACM0` is delivered rather than discarded.
@celrenheit
celrenheit merged commit a67d04f into main Aug 13, 2026
2 checks passed
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.

Central default config

1 participant