diff --git a/workplans/CUST-WP-0032-haskell-build-machine.md b/workplans/CUST-WP-0032-haskell-build-machine.md new file mode 100644 index 0000000..f6bd4e4 --- /dev/null +++ b/workplans/CUST-WP-0032-haskell-build-machine.md @@ -0,0 +1,739 @@ +--- +id: CUST-WP-0032 +type: workplan +title: "Haskell Build Machine — VirtualBox Image & State-Hub Integration" +domain: railiance +repo: the-custodian +status: todo +owner: custodian +topic_slug: railiance +created: "2026-04-20" +updated: "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.x + Cabal + Stack via GHCup + ├── build-agent (systemd): registers with state-hub on boot + │ └── POST /capability-catalog/ { capability_type: "haskell-build-agent" } + └── autossh: reverse tunnel → workstation port 12222 + └── ssh -R 12222:localhost:22 worsch@ + +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 | +| `infra/build-machines/haskell/scripts/install-haskell.sh` | GHCup + toolchain install | +| `infra/build-machines/haskell/scripts/install-agent.sh` | Agent + systemd install | +| `infra/build-machines/haskell/files/build-agent.py` | Boot registration + 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/README.md` | Deployment & usage guide | + +--- + +## Phase 1 — Packer Image Build Infrastructure + +### Task: Packer build definition + +```task +id: CUST-WP-0032-T01 +status: todo +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: bridged (host adapter selected at runtime, not baked in) +- 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_version` (default: `9.8.4`) +- `var.cabal_version` (default: `3.12.1.0`) + +### Task: Ubuntu autoinstall (cloud-init) config + +```task +id: CUST-WP-0032-T02 +status: todo +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: todo +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 +export BOOTSTRAP_HASKELL_NONINTERACTIVE=1 +export BOOTSTRAP_HASKELL_GHC_VERSION=${GHC_VERSION:-9.8.4} +export BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL_VERSION:-3.12.1.0} +export BOOTSTRAP_HASKELL_INSTALL_STACK=1 +export BOOTSTRAP_HASKELL_INSTALL_HLS=0 # skip HLS — large, not needed for CI + +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 + +# Pre-warm cabal package db (saves 2-3 min on first real build) +runuser -l build -c 'source ~/.ghcup/env && cabal update' + +# Verify +runuser -l build -c 'source ~/.ghcup/env && ghc --version && cabal --version' +``` + +### Task: Agent installation script + +```task +id: CUST-WP-0032-T04 +status: todo +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: todo +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 = cfg.get("STATE_HUB_URL", "http://192.168.1.100:8000") + 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", + "-i", ssh_key, + f"{relay_user}@{relay_host}", + ] + print(f"[build-agent] Opening tunnel: {relay_host}:{remote_port} -> local:22", + 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: todo +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 (reachable from VM on LAN) +# On workstation: http://:8000 +# If using ops-bridge reverse tunnel from VM: http://127.0.0.1:18000 +STATE_HUB_URL=http://192.168.1.100:8000 + +# Domain to register capability under +STATE_HUB_DOMAIN=railiance + +# Workstation hostname or LAN IP for reverse SSH tunnel +SSH_RELAY_HOST=192.168.1.100 +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 should use a distinct port to avoid conflicts +# 12221 = first instance, 12222 = second, etc. +REMOTE_PORT=12222 +``` + +### Task: SSH key injection procedure + +```task +id: CUST-WP-0032-T07 +status: todo +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: todo +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: todo +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: todo +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: todo +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: todo +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: todo +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: todo +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: todo +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. + +## Open Questions / Decisions Needed + +1. **Host network adapter name**: Bridged mode requires specifying the adapter + (e.g. `eth0`, `enp3s0`). Should this be a Packer variable or set post-import? + → Recommend: set post-import via VBoxManage / GUI (avoids hardcoding laptop-specific adapter). + +2. **GHC version pinning**: Should each OVA bake a specific GHC version, or use + `ghcup` to install on first boot? Baking is faster; first-boot install is flexible. + → Recommend: bake one version (9.8.4), expose `var.ghc_version` for rebuilds. + +3. **Multiple GHC versions**: Projects may need different GHC versions. Should the + VM pre-install multiple versions via `ghcup install ghc `? + → Defer to v2 — for now, one version per image, rebuild for major version bumps. + +4. **state-hub reachability from VM**: Does the laptop's LAN have direct access to + the workstation on port 8000, or does the VM need to tunnel state-hub access + through the SSH relay? If the latter, the agent should use `http://127.0.0.1:18000` + and add a forward tunnel alongside the reverse tunnel. + → Decision needed before implementing T05.