AGENTS.md@sdk/wlink · git:20260827.0d4ea09 · 2026-08-27 · sha256 89dcf44a2e25277f

AGENTS.md@sdk/wlink git:20260827.0d4ea09A

Immutable. This exact content is served forever at /api/v1/blob/89dcf44a2e25277f.

# wlink — WSL link (tunnel + resolver) management

> **Up:** [`sdk/`](../AGENTS.md) · user-facing tour [`sdk/README.md`](../README.md#-wlink--why-does-ssh-hang-but-the-ip-work) ·
> objective [`docs/mbo/`](../../docs/mbo/index.md) slug `wlink` (design · spec · plan · trio)

**wlink = WSL link** — the link between this WSL box and the private network its
fleet lives on: the tunnel carrying it, the resolver that makes its names
resolvable, and whether it is currently usable. Pairs with
[`fleet`](../fleet/AGENTS.md): *fleet* answers who my hosts are, *wlink* answers
whether I can reach them from here.

## Commands

| Command | Does | Exit |
| :-- | :-- | :-- |
| `wlink status [--json]` | tunnel state, pinned resolver, fleet reachability, drift | 0 healthy · 1 degraded |
| `wlink pin [--dry-run] [--allow-nonrecursive]` | probe every resolver Windows knows, pin the one that answers for the fleet | 0 pinned **or safely declined** · 1 write failed |
| `wlink unpin` | restore the snapshot exactly, symlink target included | 0 · 1 restore failed |
| `wlink verify [--max-fail-seconds N]` | the tunnel up/down matrix, through the host resolver | 0 met · 1 failed |
| `wlink wait --ready [--timeout D]` | block until the fleet resolves | 0 ready · 1 timed out |
| `wlink doctor [--fix]` | conditions that predict outages (ssh keepalive, snapshot, drift) | 0 clean · 1 findings |

Unknown flags exit **2**. A *safe decline* is exit **0** — not WSL, Windows
unreachable, no fleet names, no winner, guard tripped, or no undo path.
`install.sh` must never fail because a tunnel happened to be down.

## The five things that are easy to get wrong

Each cost real debugging time; each is pinned by a test.

1. **The default route's resolver is usually the wrong one.** On a VPN'd
   machine the gateway resolves nothing local while a secondary interface
   resolves everything. `wlink` probes *every* interface rather than reasoning
   about routes (spec EC-1).
2. **`resolv.conf` is an ordered list, not a routing table.** Nameserver #1
   answers every query and its NXDOMAIN is **final** — glibc falls through only
   on timeout. Pinning a local-only resolver first kills public DNS with no
   fallback, so the recursion guard refuses it (EC-2).
3. **`/etc/resolv.conf` is a symlink into `/mnt/wsl`, which is shared across
   distros.** Writing *through* it leaks the pin to every distro and gets
   regenerated. `pin` replaces the link with a real file (EC-17).
4. **A name in `/etc/hosts` is answered before any resolver is consulted**
   (`nsswitch: files dns`). Probing it caps the score forever and lets `verify`
   count a `/etc/hosts` hit as evidence the *resolver* works (EC-5).
5. **Attached ≠ ready.** Windows publishes a VPN adapter and its DNS server the
   moment you click connect, seconds before the handshake completes, and the old
   network is already unroutable — so every candidate goes silent and it looks
   like "no tunnel" (EC-6).

## Layout

| Path | Owns |
| :-- | :-- |
| `cmd/` | cobra verbs, exit codes, human vs `--json` rendering, the `Runtime` that wires everything |
| `internal/winhost/` | **the only place that talks to Windows.** All interop behind `Runner`, so the rest is testable in CI |
| `internal/probe/` | native DNS (no `dig`), candidate scoring, the recursion guard |
| `internal/linkstate/` | the `State` schema — a **published contract** (`--json`, gsl, CI) |
| `internal/resolvconf/` | render/parse `resolv.conf` + `wsl.conf` INI, snapshot, restore, drift |
| `internal/fleetsrc/` | fleet names via `fleet discover --json`; ssh-config fallback is **read-only** |
| `internal/sshcfg/` | `ssh -G` inspection for the keepalive check |

## Rules for changing it

- **Interop stays behind `winhost.Runner`.** A `powershell` reference anywhere
  else makes the module untestable in CI.
- **No write without an undo path.** `TakeSnapshot` runs before the first byte;
  if it fails, nothing is written and the command still exits 0.
- **Log via `sdk/libs/log` only** — never hand-roll a logger.
- **`State` is a contract.** Renaming a field or wire value breaks `gsl` and any
  CI gate; the shape is pinned by test against the spec's §4.1 example.
- **Behaviour learned here belongs in the spec.** The shell prototype that
  produced this design was deleted; [`docs/mbo/specs/wlink.md`](../../docs/mbo/specs/wlink.md)
  §5.1 (EC-1…EC-22) is now the only record of what it proved. A behaviour that
  never reaches the spec is a behaviour that is lost.

## Testing

`go test ./...` — everything runs against fixtures and temp directories: no
Windows, no privileged write, no network. `WLINK_LIVE=1` additionally runs the
`winhost` parser against this machine's real PowerShell output.

Testing hooks (also useful for evidence capture): `WLINK_RESOLV_CONF`,
`WLINK_WSL_CONF`, `WLINK_BACKUP_DIR`, `WLINK_SSH_CONFIG`, `WLINK_HOSTS_FILE`,
`WLINK_PROBE_HOSTS`, `WLINK_PUBLIC_PROBE`, `WLINK_FALLBACKS`, `WLINK_GIT_HOST`.