--- id: CUST-WP-0032 type: workplan title: "Haskell Build Machine — VirtualBox Image & State-Hub Integration" domain: railiance repo: the-custodian status: done owner: custodian topic_slug: railiance created: "2026-04-20" updated: "2026-04-20" decisions_resolved: "2026-04-20" state_hub_workstream_id: "f2bfac74-de8a-4c86-9aa9-4d95a92336e2" --- # Haskell Build Machine — VirtualBox Image & State-Hub Integration ## Goal Build a reproducible VirtualBox image that boots as a Haskell build sandbox on any capable laptop, self-registers with the Custodian State Hub via the capability catalog, and integrates with the ops-bridge SSH tunnel system for seamless remote builds. The motivating use case: offload computationally expensive Haskell compilation from the development workstation (WSL2/CoulombCore) to a dedicated, beefy laptop running VirtualBox — without changing the developer workflow. ## Architecture ``` Laptop (VirtualBox host) └── haskell-build VM (Ubuntu 24.04, bridged network) ├── GHC 9.8.4 (default) + GHC 9.6.6 + Cabal via GHCup (no Stack, no HLS) ├── build-agent (systemd): registers with state-hub on boot │ └── POST /capability-catalog/ { capability_type: "haskell-build-agent" } └── autossh: two tunnels in one SSH connection ├── -R 12222:localhost:22 (reverse: workstation → VM SSH) └── -L 18000:localhost:8000 (forward: VM → state-hub, port 18000) Workstation (WSL2) ├── state-hub: sees haskell-build-* capability entries, knows tunnel port ├── ops-bridge: optional — manages named tunnel configs for known VMs └── Developer: ssh haskell-build → build remotely, rsync artifacts back ``` ## Constraints - Image must be fully unattended (no interactive steps after first boot). - Credentials (SSH keys, state-hub URL) are injected via `/etc/build-agent.env`, never baked into the OVA. - Must work with the existing ops-bridge tunnel pattern. - State-hub registration uses the existing `/capability-catalog/` endpoint; no new state-hub endpoints required for MVP. ## Deliverables | Path | Purpose | |------|---------| | `infra/build-machines/haskell/haskell-build.pkr.hcl` | Packer build definition (NAT during build) | | `infra/build-machines/haskell/scripts/install-haskell.sh` | GHCup + GHC 9.8.4 + 9.6.6 install | | `infra/build-machines/haskell/scripts/install-agent.sh` | Agent + systemd install | | `infra/build-machines/haskell/scripts/setup-vm.sh` | Post-import VBoxManage network config | | `infra/build-machines/haskell/scripts/inject-keys.sh` | SSH key + env injection for new VMs | | `infra/build-machines/haskell/files/build-agent.py` | Boot registration + dual tunnel agent | | `infra/build-machines/haskell/files/build-agent.service` | systemd unit | | `infra/build-machines/haskell/files/build-agent.env.template` | Env var template | | `infra/build-machines/haskell/files/cloud-init/user-data` | Ubuntu autoinstall config | | `infra/build-machines/haskell/files/cloud-init/meta-data` | cloud-init meta-data | | `infra/build-machines/port-registry.yml` | Port assignment tracker (12221-12230) | | `infra/build-machines/state-hub-refs.yml` | State-hub entity UUID references | | `infra/build-machines/README.md` | Deployment & usage guide | --- ## Phase 1 — Packer Image Build Infrastructure ### Task: Packer build definition ```task id: CUST-WP-0032-T01 status: done priority: high state_hub_task_id: "1430844c-82f2-4e7b-88b2-6e74a29167c4" ``` Create `infra/build-machines/haskell/haskell-build.pkr.hcl`. The Packer `virtualbox-iso` source must: - Base: Ubuntu 24.04 LTS server ISO (amd64) - Disk: 40 GB, Memory: 8192 MB, CPUs: 4 - **Network during build: NAT** — Packer needs internet for ISO + packages; bridged is set post-import by `setup-vm.sh` (adapter names are laptop-specific) - Unattended install via cloud-init autoinstall (not preseed) - SSH communicator: `build` user, key-based after provisioning - Provisioners: `install-haskell.sh`, then `install-agent.sh` - Post-processor: export as `haskell-build-.ova` Key Packer variables to expose: - `var.vm_name` (default: `haskell-build`) - `var.disk_size` (default: `40960`) - `var.memory` (default: `8192`) - `var.cpus` (default: `4`) - `var.ghc_primary_version` (default: `9.8.4`) - `var.ghc_secondary_version` (default: `9.6.6`) - `var.cabal_version` (default: `3.12.1.0`) Also create `scripts/setup-vm.sh` (run once after OVA import): ```bash #!/bin/bash # setup-vm.sh — switches imported VM from NAT to bridged networking VM_NAME="${1:?Usage: setup-vm.sh [adapter]}" # Auto-detect first available bridge interface if not specified ADAPTER="${2:-$(VBoxManage list bridgedifs | awk '/^Name:/{print $2; exit}')}" VBoxManage modifyvm "$VM_NAME" \ --nic1 bridged \ --bridgeadapter1 "$ADAPTER" \ --memory 8192 --cpus 4 echo "Configured $VM_NAME: bridged on $ADAPTER" echo "Next: inject keys with scripts/inject-keys.sh, then start VM" ``` ### Task: Ubuntu autoinstall (cloud-init) config ```task id: CUST-WP-0032-T02 status: done priority: high state_hub_task_id: "816bd164-ed1a-4d57-bdeb-c9e3d9e4d614" ``` Create `files/cloud-init/user-data` (cloud-init autoinstall format): - Locale: en_US.UTF-8, keyboard: us - Storage: LVM, single volume group, auto-resize - User: `build` (sudo NOPASSWD), password disabled, SSH key placeholder - Packages: `build-essential curl git libgmp-dev libffi-dev zlib1g-dev libncurses-dev libtinfo-dev pkg-config openssh-server autossh jq rsync` - SSH: `PasswordAuthentication no`, `PubkeyAuthentication yes` - Timezone: Europe/Berlin Create `files/cloud-init/meta-data` (empty YAML, required by autoinstall). Packer boot_command sequence: 1. Select "Try or Install Ubuntu Server" 2. Wait for installer, interrupt with autoinstall kernel param 3. Feed `ds=nocloud;s=http://{{ .HTTPIP }}:{{ .HTTPPort }}/` via Packer HTTP server ### Task: Haskell toolchain installation script ```task id: CUST-WP-0032-T03 status: done priority: high state_hub_task_id: "2900ae95-828b-4ced-8821-ded6b4a52e61" ``` Create `scripts/install-haskell.sh`: ```bash #!/bin/bash set -euo pipefail DEBIAN_FRONTEND=noninteractive # System deps (already installed via cloud-init but idempotent) apt-get update -qq apt-get install -y -qq build-essential curl git \ libgmp-dev libffi-dev zlib1g-dev libncurses-dev libtinfo-dev pkg-config # GHCup — non-interactive bootstrap # Primary version (9.8.4) is the default; secondary (9.6.6) covers LTS 22/23. # Skip Stack (cabal covers 95% of projects) and HLS (saves ~2 GB image size). GHC_PRIMARY="${GHC_PRIMARY_VERSION:-9.8.4}" GHC_SECONDARY="${GHC_SECONDARY_VERSION:-9.6.6}" CABAL_VERSION="${CABAL_VERSION:-3.12.1.0}" export BOOTSTRAP_HASKELL_NONINTERACTIVE=1 export BOOTSTRAP_HASKELL_GHC_VERSION="$GHC_PRIMARY" export BOOTSTRAP_HASKELL_CABAL_VERSION="$CABAL_VERSION" export BOOTSTRAP_HASKELL_INSTALL_STACK=0 # not needed; cabal suffices export BOOTSTRAP_HASKELL_INSTALL_HLS=0 # ~2 GB — skip for build-only image curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org \ | runuser -l build -c 'sh -s -- --no-modify-path' # Add ghcup env to build user profile echo '. "$HOME/.ghcup/env"' >> /home/build/.bashrc echo '. "$HOME/.ghcup/env"' >> /home/build/.profile # Install secondary GHC version (~500 MB, shared GHCup base — worth it) runuser -l build -c "source ~/.ghcup/env && ghcup install ghc $GHC_SECONDARY" # Ensure primary is the default runuser -l build -c "source ~/.ghcup/env && ghcup set ghc $GHC_PRIMARY" # Pre-warm cabal package db (saves 2-3 min on first real build) runuser -l build -c 'source ~/.ghcup/env && cabal update' # Verify both versions present runuser -l build -c "source ~/.ghcup/env && ghc --version && cabal --version" runuser -l build -c "source ~/.ghcup/env && ghcup run --ghc $GHC_SECONDARY -- ghc --version" ``` ### Task: Agent installation script ```task id: CUST-WP-0032-T04 status: done priority: high state_hub_task_id: "5267d2f3-f8fb-4072-a9fa-40b18cf888bd" ``` Create `scripts/install-agent.sh`: ```bash #!/bin/bash set -euo pipefail # Copy agent artefacts (provisioned by Packer file provisioner) install -m 0755 /tmp/build-agent.py /usr/local/bin/build-agent install -m 0644 /tmp/build-agent.service /etc/systemd/system/build-agent.service install -m 0600 /tmp/build-agent.env.template /etc/build-agent.env.template # Placeholder env file — operator fills this in before first boot if [ ! -f /etc/build-agent.env ]; then cp /etc/build-agent.env.template /etc/build-agent.env fi # Install autossh apt-get install -y -qq autossh # Enable agent service (starts on boot, after network-online) systemctl daemon-reload systemctl enable build-agent.service # SSH host key generation (deterministic at first boot, not baked in image) dpkg-reconfigure openssh-server ``` --- ## Phase 2 — Boot-time Registration Agent ### Task: Registration + tunnel agent (build-agent.py) ```task id: CUST-WP-0032-T05 status: done priority: high state_hub_task_id: "18ee959d-30b7-4a06-9a84-02c4e5d7ba83" ``` Create `files/build-agent.py`. Full implementation: ```python #!/usr/bin/env python3 """ build-agent — runs at VM boot. 1. Reads /etc/build-agent.env 2. Detects GHC version 3. Registers (or updates) a capability-catalog entry in the state-hub 4. Opens an autossh reverse tunnel to the workstation """ import os, json, socket, subprocess, time, sys import urllib.request, urllib.error def load_env(path="/etc/build-agent.env"): env = {} try: with open(path) as f: for line in f: line = line.strip() if line and not line.startswith('#') and '=' in line: k, _, v = line.partition('=') env[k.strip()] = v.strip().strip('"') except FileNotFoundError: pass return env def get_ghc_version(): for path in [ "/home/build/.ghcup/bin/ghc", "/usr/local/bin/ghc", ]: try: r = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=15) if r.returncode == 0: return r.stdout.strip().split()[-1] except Exception: continue return "unknown" def get_local_ip(): """Get the primary LAN IP (not loopback).""" try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) return s.getsockname()[0] except Exception: return "unknown" def register(cfg): # State-hub is always accessed via the forward tunnel (port 18000), never # via direct LAN. This matches the CoulombCore remote worker pattern and # works regardless of network topology (LAN, VPN, different subnet). state_hub = cfg.get("STATE_HUB_URL", "http://127.0.0.1:18000") hostname = socket.gethostname() domain = cfg.get("STATE_HUB_DOMAIN", "railiance") remote_port = cfg.get("REMOTE_PORT", "12222") ghc_ver = get_ghc_version() local_ip = get_local_ip() payload = { "domain": domain, "capability_type": "haskell-build-agent", "title": f"Haskell Build Agent — {hostname}", "description": ( f"GHC {ghc_ver} build sandbox on {hostname} ({local_ip}). " f"SSH tunnel port: {remote_port} on workstation." ), "keywords": [ "haskell", "ghc", f"ghc-{ghc_ver}", "build-agent", "cabal", "stack", f"host:{hostname}", f"tunnel-port:{remote_port}", ], } data = json.dumps(payload).encode() req = urllib.request.Request( f"{state_hub}/capability-catalog/", data=data, headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=15) as resp: result = json.loads(resp.read()) print(f"[build-agent] Registered capability: {result['id']}", flush=True) return result except urllib.error.HTTPError as e: body = e.read().decode() print(f"[build-agent] Registration HTTP error {e.code}: {body}", flush=True) raise except Exception as e: print(f"[build-agent] Registration failed: {e}", flush=True) raise def open_tunnel(cfg): relay_host = cfg.get("SSH_RELAY_HOST", "") relay_user = cfg.get("SSH_RELAY_USER", "worsch") ssh_key = cfg.get("SSH_KEY_PATH", "/home/build/.ssh/id_build") remote_port = cfg.get("REMOTE_PORT", "12222") if not relay_host: print("[build-agent] SSH_RELAY_HOST not set — tunnel disabled", flush=True) # Sleep forever so systemd considers service active while True: time.sleep(3600) cmd = [ "autossh", "-M", "0", # disable autossh monitoring port "-o", "ServerAliveInterval=30", "-o", "ServerAliveCountMax=3", "-o", "ExitOnForwardFailure=yes", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-N", "-R", f"{remote_port}:localhost:22", # reverse: workstation → VM SSH "-L", "18000:localhost:8000", # forward: VM → state-hub (port 18000) "-i", ssh_key, f"{relay_user}@{relay_host}", ] print( f"[build-agent] Opening tunnels: " f"-R {remote_port}→local:22, -L 18000→state-hub:8000", flush=True, ) subprocess.run(cmd) # autossh manages reconnects internally def main(): cfg = load_env() # Retry registration until state-hub is reachable (network may not be ready) for attempt in range(20): try: register(cfg) break except Exception: wait = min(10 * (attempt + 1), 60) print(f"[build-agent] Retrying in {wait}s ...", flush=True) time.sleep(wait) else: print("[build-agent] Registration permanently failed — continuing to tunnel", flush=True) open_tunnel(cfg) if __name__ == "__main__": main() ``` ### Task: systemd unit and env template ```task id: CUST-WP-0032-T06 status: done priority: high state_hub_task_id: "1a6bf2a2-91e8-46f9-a82c-de08ccfda729" ``` Create `files/build-agent.service`: ```ini [Unit] Description=Haskell Build Agent — State Hub registration + SSH reverse tunnel Documentation=https://github.com/tegwick/the-custodian After=network-online.target Wants=network-online.target [Service] Type=simple User=build EnvironmentFile=/etc/build-agent.env ExecStart=/usr/local/bin/build-agent Restart=on-failure RestartSec=30 StandardOutput=journal StandardError=journal SyslogIdentifier=build-agent [Install] WantedBy=multi-user.target ``` Create `files/build-agent.env.template`: ```bash # Custodian State Hub URL — always access via forward tunnel (port 18000). # The agent opens -L 18000:localhost:8000 alongside the reverse SSH tunnel, # so this works regardless of network topology (LAN, VPN, different subnet). # Matches the CoulombCore remote worker bridge pattern. STATE_HUB_URL=http://127.0.0.1:18000 # Domain to register capability under STATE_HUB_DOMAIN=railiance # Workstation hostname or LAN IP for SSH relay connection # The VM connects OUT to this host to establish both tunnels. SSH_RELAY_HOST=192.168.1.100 # replace with actual workstation LAN IP SSH_RELAY_USER=worsch # Path to private key for SSH tunnel (matching authorized_keys on workstation) SSH_KEY_PATH=/home/build/.ssh/id_build # Port to bind on workstation (ssh -R :localhost:22) # Each VM instance must use a distinct port — see port-registry.yml # Range: 12221-12230 REMOTE_PORT=12222 ``` ### Task: SSH key injection procedure ```task id: CUST-WP-0032-T07 status: done priority: medium state_hub_task_id: "6bb36de9-df03-452d-bb1c-3dfc5a695265" ``` Document and script the key injection procedure for new VM deployments. Keys must never be baked into the OVA. Injection options (in order of preference): **Option A — VirtualBox shared folder (recommended for LAN deployments):** ```bash # On host before first boot: vboxmanage sharedfolder add "haskell-build" \ --name "build-init" \ --hostpath /path/to/vm-keys/$(vm_name) \ --readonly --auto-mount # VM firstboot service reads from /media/sf_build-init/: # id_build (private key), id_build.pub, build-agent.env ``` **Option B — Post-boot SSH + scp:** ```bash # VM starts with password auth enabled for initial setup only ssh build@ "mkdir -p ~/.ssh && chmod 700 ~/.ssh" scp id_build build@:~/.ssh/ ssh build@ "chmod 600 ~/.ssh/id_build" sudo scp build-agent.env build@:/etc/build-agent.env ``` Create `scripts/inject-keys.sh` implementing Option B. --- ## Phase 3 — SSH Bridge Integration ### Task: Workstation-side tunnel configuration ```task id: CUST-WP-0032-T08 status: done priority: high state_hub_task_id: "a42342cb-41ef-4915-8ce5-923a36bd2918" ``` For each deployed VM, add an entry to the ops-bridge tunnel catalog so the workstation knows about the reverse-tunnel port and can health-check it. Ops-bridge config entry (`~/ops-bridge/tunnels/haskell-build-.yml`): ```yaml name: haskell-build-alpha description: Haskell Build VM — primary laptop direction: reverse # VM → workstation remote_port: 12222 local_port: 22 target_host: localhost check_cmd: "ssh -p 12222 -o StrictHostKeyChecking=no build@localhost exit" auto_restart: false # VM controls the tunnel ``` Add a `make bridge-status` target to `infra/build-machines/Makefile` that queries known build-machine ports: ```makefile bridge-status: @for port in 12221 12222 12223 12224 12225; do \ ssh -q -p $$port -o ConnectTimeout=2 -o StrictHostKeyChecking=no \ build@localhost "echo $$port: $$(hostname) OK" 2>/dev/null || \ echo "$$port: no tunnel"; \ done ``` ### Task: SSH client config template ```task id: CUST-WP-0032-T09 status: done priority: medium state_hub_task_id: "5ea059a0-94c3-4b6e-ae99-cf20a6c4af1c" ``` Create `infra/build-machines/ssh-config.template`: ``` # Haskell Build VM — tunnel via workstation (auto-generated) # Source: infra/build-machines/README.md Host haskell-build haskell-build-alpha HostName localhost Port 12222 User build IdentityFile ~/.ssh/id_build StrictHostKeyChecking no UserKnownHostsFile /dev/null ServerAliveInterval 30 ServerAliveCountMax 3 ``` Add `make install-ssh-config` target that appends this to `~/.ssh/config` (idempotent via sentinel comment check). ### Task: Port allocation registry ```task id: CUST-WP-0032-T10 status: done priority: low state_hub_task_id: "6bfd43de-b1e2-4114-b509-d1c78d066756" ``` Create `infra/build-machines/port-registry.yml` to track port assignments and prevent collisions across multiple VM instances: ```yaml # Build machine port registry # Range: 12221-12230 (10 slots) # Each entry: port, vm_name, host_machine, status ports: 12221: vm_name: haskell-build-alpha host_machine: unassigned status: reserved 12222: vm_name: haskell-build-beta host_machine: unassigned status: reserved 12223: vm_name: unassigned status: available ``` --- ## Phase 4 — State-Hub Capability Registration ### Task: Bootstrap capability catalog entry ```task id: CUST-WP-0032-T11 status: done priority: high state_hub_task_id: "f7efd28e-0ae4-41c9-bd76-649bd17bec16" ``` Register the `haskell-build-agent` capability type in the state-hub capability catalog with a canonical/static entry (distinct from the per-instance dynamic entries that VMs create on boot). ```bash curl -s -X POST http://127.0.0.1:8000/capability-catalog/ \ -H "Content-Type: application/json" \ -d '{ "domain": "railiance", "capability_type": "haskell-build-agent", "title": "Haskell Build Agent (type definition)", "description": "Capability type for GHC/Cabal/Stack build sandboxes running in VirtualBox VMs. Instances register themselves at boot via build-agent.py. SSH access via reverse tunnel on workstation port range 12221-12230.", "keywords": ["haskell", "ghc", "cabal", "stack", "build-agent", "virtualbox", "sandbox"] }' ``` Save the returned `id` in `infra/build-machines/state-hub-refs.yml`: ```yaml # State-hub entity references for build-machines infra capability_type_entry_id: "" ``` ### Task: Query interface via state-hub MCP ```task id: CUST-WP-0032-T12 status: done priority: low state_hub_task_id: "55f30877-7fe7-4aaa-b74a-f9ab435f1d9a" ``` Verify that registered build agents are discoverable via existing MCP tools: ```python # Should return all haskell-build-agent entries list_capabilities(capability_type="haskell-build-agent") # or get_capability_profile(domain="railiance") ``` If filtering by `capability_type` is not supported, document the workaround (filter client-side after `list_capabilities()`). No new MCP tools needed for MVP. --- ## Phase 5 — Remote Build Workflow ### Task: Remote build Makefile ```task id: CUST-WP-0032-T13 status: done priority: high state_hub_task_id: "4c27f5db-a0c1-4f43-97a7-87472ce3a1cc" ``` Create `infra/build-machines/Makefile` with targets for using the build VM: ```makefile # infra/build-machines/Makefile # Usage: make remote-build PROJECT=~/projects/my-haskell-app [VM=haskell-build] VM ?= haskell-build PROJECT ?= . RDIR := /build/$(notdir $(realpath $(PROJECT))) # Sync project source to VM (exclude build artefacts) .PHONY: sync sync: rsync -av --delete \ --exclude='.git' \ --exclude='dist-newstyle' \ --exclude='.stack-work' \ --exclude='*.o' --exclude='*.hi' \ $(PROJECT)/ $(VM):$(RDIR)/ # Run cabal build on VM after sync .PHONY: remote-build remote-build: sync ssh $(VM) "cd $(RDIR) && source ~/.ghcup/env && cabal build all 2>&1" # Run tests on VM .PHONY: remote-test remote-test: sync ssh $(VM) "cd $(RDIR) && source ~/.ghcup/env && cabal test all 2>&1" # Open a GHCi session on the VM .PHONY: remote-ghci remote-ghci: sync ssh -t $(VM) "cd $(RDIR) && source ~/.ghcup/env && cabal repl" # Sync build artefacts back (for local IDE inspection) .PHONY: fetch-artifacts fetch-artifacts: rsync -av $(VM):$(RDIR)/dist-newstyle/ $(PROJECT)/dist-newstyle/ # Check which VMs are reachable .PHONY: bridge-status bridge-status: @echo "Scanning build-machine tunnel ports..." @for port in 12221 12222 12223 12224 12225; do \ result=$$(ssh -q -p $$port -o ConnectTimeout=2 \ -o StrictHostKeyChecking=no build@localhost \ "echo $$port OK: $$(hostname) — GHC: $$(~/.ghcup/bin/ghc --numeric-version)" \ 2>/dev/null) ; \ if [ -n "$$result" ]; then echo " $$result"; \ else echo " port $$port: no tunnel"; fi; \ done .PHONY: vm-info vm-info: ssh $(VM) "uname -a; source ~/.ghcup/env && ghc --version && cabal --version" ``` ### Task: End-to-end smoke test ```task id: CUST-WP-0032-T14 status: done priority: high state_hub_task_id: "b3a9613d-0d08-4f08-9361-d7e42c07069a" ``` Write and run a smoke test that validates the full stack: 1. Boot VM (manually or via VBoxManage) 2. Wait for tunnel port to appear: `make bridge-status` 3. Check state-hub capability catalog: `GET /capability-catalog/?capability_type=haskell-build-agent` 4. Verify VM entry appeared with correct hostname and tunnel port in keywords 5. Build a small Haskell project: `make remote-build PROJECT=/path/to/hello-world` 6. Confirm artefacts compile: `make fetch-artifacts` Create `infra/build-machines/smoke-test.sh` that automates steps 2-5. ### Task: Deployment README ```task id: CUST-WP-0032-T15 status: done priority: medium state_hub_task_id: "4d858d77-4b9d-4f75-820a-b8f9d2dd3f19" ``` Create `infra/build-machines/README.md` covering: - Prerequisites (Packer ≥ 1.10, VirtualBox ≥ 7.0, autossh on workstation) - One-time setup: generating the build SSH keypair - Building the OVA: `cd infra/build-machines/haskell && packer build .` - Deploying a VM: import OVA → inject keys → set bridged adapter → start - Adding to ops-bridge tunnel catalog - Using the VM: `make remote-build`, `make bridge-status` - Updating the VM: re-run Packer, re-import (data lives on workstation) - Troubleshooting: tunnel not up, capability not appearing in state-hub --- ## Dependencies None — this workplan is self-contained. Packer and VirtualBox are workstation-level tools that need to be installed once. ## Decisions (Resolved 2026-04-20) 1. **Host network adapter name** → **Post-import via `setup-vm.sh`** (VBoxManage auto-detects first available bridge interface). Packer builds with NAT for internet access; `setup-vm.sh` switches to bridged after OVA import. Avoids hardcoding any laptop-specific adapter name in the image. 2. **GHC version pinning** → **Bake two versions: 9.8.4 (primary) + 9.6.6 (secondary)**. Both installed at image build time via GHCup. 9.8.4 is the default; 9.6.6 covers Stackage LTS 22/23 projects at a cost of ~500 MB extra image size. Additional versions can be added post-deployment with `ghcup install ghc ` — no rebuild. 3. **Stack vs Cabal** → **Cabal only, no Stack, no HLS**. Cabal covers 95%+ of Haskell projects. Stack adds ~400 MB and HLS adds ~2 GB to the image with no benefit for a CI/build sandbox. Both can be added post-deployment if a project specifically requires them. 4. **state-hub reachability from VM** → **Always via forward tunnel (port 18000)**. The agent opens `-L 18000:localhost:8000` alongside `-R :localhost:22` in a single autossh connection. `STATE_HUB_URL` defaults to `http://127.0.0.1:18000`. This matches the CoulombCore remote worker bridge pattern and works on any network topology without exposing state-hub on a non-loopback interface.