Extract transport-agnostic egress relay core
## Epic requirement (verbatim)
"Implement microVM on Windows and MacOS" (Task 10021 PRD ask #1, `in_scope`).
Operator decisions that bind every child (verbatim from the PRD/Architecture):
- D1 **Full parity** with Linux. Each session gets its own Linux guest kernel. The CP egress allowlist is enforced on the node. Control calls are vsock-bound (or an equivalent per-VM channel). No NodeLink inside the VM. No root in the guest. The disk is retained on `system_failed`. Console, sizing and placement UX are the same.
- D2 **All three:** fleet runners, advisor chats and human console sessions. D3 **The Linux guest's set** of providers. D4 macOS = Apple Silicon, Windows = x64. D5 cloud Windows too. D7 same plan feature (`billing_plans.microvm_allowed` + `orgs.microvm_allowed_override`).
- AD1 Windows = WSL2 + the existing Cloud Hypervisor/KVM runtime (Windows 10 refused). AD2 Mac guests arm64 only, no Rosetta. AD3 Windows test PC = `win-t8703`. AD5 probe m8i.large exactly; metal (m7i.metal-24xl) is the fallback. AD6 Mac oracles deferred until the Mac is enrolled on dev. AD7 AC2(a) live is dropped; unit tests map every `microvm_prereq` code to its sentence.
**Quality bar (verbatim).** Every refusal shown to an operator is one plain sentence naming what is missing and how to fix it (`docs/standards/ui-copy-standards.md`). No generic 400, no stack trace, no Linux-specific wording on a Mac or Windows node. Boot-to-runner latency on Mac and Win is reported in the build's evidence. It is not gated, but it must not be degraded by more than 2× against a Linux MicroVM node of similar size without the Architect recording why.
## This child: egress relay core seam (Architecture §3.2 refactor half, §9.4)
Requirements this child serves (PRD §4, verbatim):
- R3. Parity of contract. Every bullet of `docs/policies/microvm-nodes.md` holds on these OSes: sizing resolver and source display; placement admission by free RAM/disk and hypervisor usability, with readable wait reasons, and `failing_nodes`; the intent/fact rail; lifecycle: destroy at done, reset, delete or console end; retain on `system_failed` for `microvm_retention_hours`; liveness reclaim; the egress allowlist: default ∪ project additions − removals, plus the CP and forge hosts, with private, link-local and node-own addresses refused and each refusal logged; per-VM control-call binding: a guest can act only on its own task; no root and no `sudo` in the guest, with a rootless engine; no NodeLink mount in a VM; the API-key sign-in served without a key in the guest environment.
- R7. Linux unchanged. Linux MicroVM behaviour, the static pool and the unmarked Mac/Windows behaviour do not regress.
The full settled Architecture (Task 10022) and PRD (Task 10021) are the epic's prerequisite artifacts; read them with `task_read`/`artifact_read` when this body is shorter than you need. Section numbers (§) refer to the Architecture.
### Approach (settled, realize it)
Refactor `apps/jaahd/microvm_egress.py` into a transport-agnostic core; **the Linux behaviour is byte-identical** (same pool, same refusals, same `egress.log`, same `egress_refusals` fact). The Linux tap listener becomes the first thin caller (tap → `vm_id` from the registry, `SO_ORIGINAL_DST` → `orig_*`). Pinned seam (§9.4, verbatim):
- `relay_flow(vm_id, orig_ip, orig_port, client)` is **blocking**. It runs on the relay's existing worker pool and **always closes `client`**. Every refusal is appended to `<vmdir>/egress.log` and the node tail with the existing reasons.
- `answer_dns(vm_id, query) -> bytes | None` always returns a DNS response for a parseable query: an A record for an allowed name, NXDOMAIN for a refused one (as Linux does today), and an empty NOERROR for other types of an allowed name. It returns `None` for an unparseable query, and the caller closes the stream. Never SERVFAIL.
- `host_platform.own_networks() -> tuple[set[str], list[IPv4Network]]` returns the node's own IPv4 addresses (dotted strings) and connected networks. These are **the types `local_ipv4` and `connected_networks` return today** (`microvm_egress.py:334,351`). The Linux bodies move behind this function unchanged, and macOS implements the same types (`getifaddrs` through `ctypes`, stdlib only). On WSL the `host` frame's Windows addresses are unioned in (§2.4) — that union is the Windows-backend child's.
The vsock transport (header parsing, port 15001/15053 listeners) is the macOS-runtime child's second caller, not built here.
Packaging note (Planner): the Architect kept this refactor inside C3; it is split out because §2.4/§9.4 make the Windows backend call `own_networks()` too, so both runtime children build on it — the design is unchanged.
Acceptance criteria this child serves (PRD §5, verbatim):
- AC6 — Egress is enforced (R3). Verify: In a VM session: `curl -sI https://github.com` → succeeds; a host not on the project allowlist → refused; the node's own LAN IP and `169.254.169.254` → refused. Add the host to the project's egress additions (ProjectEditPage) → a new session reaches it. Each refusal is visible in the node's egress refusal log/report.
- AC15 — Linux and static behaviour unchanged (R7). Verify: `./dc check full` is green. On dev, a Linux MicroVM node still passes AC3. An unmarked Mac and Win still run a static task as before.
Ownership note (Planner): this child proves the refactor keeps Linux AC6 behaviour; the Win/Mac AC6 runs belong to their runtime children.
Credentials & reach (Architect §8, verbatim rows):
- AC15 Linux dev half | Dev node `gmind-microvm-1` (Ubuntu, online, 1.2.4277) | **verified**: read in the dev Nodes list from this node. C1 confirms its MicroVM chip before the re-run.
- AC15 gate, AC16, AC17 | Worktree only (`./dc check full`, `tools/check_context.py`, `tools/microvm_pins.py`) | **verified**: nothing beyond the worktree.
- AC1–AC12, AC14 (console flows) | `org/console/dev-login` (operator login), dev console `https://app.dev.jaah.ai` | **verified**: `credential_get` succeeded; the console was opened from this node (`h8agi5dw`), signed in, Nodes list read. The credential's stored `base_url` names the legacy dev host, which does not answer from this node; the oracle uses `app.dev.jaah.ai`.
Out of scope (BA, verbatim — do NOT touch):
- Do **not** change Linux MicroVM behaviour, its pins, its egress default list (`apps/api/app/microvm_egress.py`) or its sizing defaults, beyond what is needed to add the OS dimension.
- Do **not** change the static worktree pool or dynamic worktrees on any OS.
- Do **not** add a new plan term or billing price (D7).
- Do **not** mount NodeLinks inside a VM on any OS.
- Do **not** add Multi-Node Hosts / co-located nodes (`vnode_host`), GitHub Actions runners or SDK-agent support to macOS or Windows hosts.
- Do **not** support Intel Macs, Windows on ARM, or cloud-launched macOS nodes.
- Do **not** relax the host-session (unmarked) capability table of ADR-0145.
- Do **not** describe the boundary as stronger than each hypervisor gives ("strong isolation", "defence in depth" — ADR-0129 anti-pattern).
- No new console page. Existing surfaces only (A4).
- Operate and verify Jaah only through the console. No CLI, SQL or cloud-console workaround for live state (CLAUDE.md hard rule).
Architect boundaries (declined, do NOT build): a Windows native Hyper-V runtime; Rosetta in Mac guests; boot-latency optimisation (pre-booted snapshots).
Also do NOT touch: the egress default list or any refusal reason string; `microvm_net.py` tap/nft rules.
Done means:
- All existing `microvm_egress` tests pass unchanged (no test edited except imports/paths for moved functions).
- New unit tests: `relay_flow` closes `client` on success, on every refusal reason, and on upstream error; `answer_dns` returns A for allowed, NXDOMAIN for refused, empty NOERROR for an AAAA of an allowed name, `None` for garbage bytes, never SERVFAIL; `own_networks()` on Linux returns the same values as the old functions for the same fixture files; the macOS `getifaddrs` implementation returns the same types (tested via ctypes on the CI host's libc, or on the macos-15 job).
- AC15 — `./dc check full` green (cite GATE_RESULT); after deploy, on dev `gmind-microvm-1` a console VM session: `curl -sI https://github.com` succeeds, a non-allowlisted host and `169.254.169.254` are refused and appear in the node's egress refusal report.
Outcome
Egress relay core extracted: answer_dns/relay_flow seam + host_platform.own_networks (Linux moved, macOS getifaddrs); Linux tap path unchanged. Quick gate + 1081 changed-area tests green; 3 review lanes pass. PR #6921 queued. Deferred: tapless vm_by_id, macOS routed nets. Memory: none applied.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)