Offline captive-portal diagnostic for macOS — names the cause, walks the fix.
You're at an airport, a hotel, a coffee shop. You join the Wi-Fi, the sign-in page never loads, and you have no internet — which is the exact moment you can't Google why. portaldoc runs fully offline on the stuck Mac: it probes the real network state, names the most-likely cause, extracts the portal URL, and walks you through the exact fix. An optional local LLM narrates the diagnosis, grounded strictly on the probe facts — never the public internet.
Run it behind a broken portal and the deterministic core renders instantly — before any model loads:
══════════════════════════════════════════════════════════════
✗ PORTAL NOT CLEARED — HTTPS/HSTS no-pop
══════════════════════════════════════════════════════════════
SSID AirportFreeWiFi · 5GHz · -58dBm IP 10.4.21.88/22 GW 10.4.20.1 ✓
DHCP lease ✓ DNS 10.4.20.1 captive portal-present https TLS-error
Cause: the OS tried HTTPS first; the portal can't intercept it (HSTS),
so the sign-in page never popped. The portal URL was found in the
redirect — open it over plain HTTP to trigger the login page.
[Enter] OPEN PORTAL → http://login.airportfreewifi.net/auth
[2] copy: open http://login.airportfreewifi.net/auth
[r] re-probe [q] quit
model thinking…
──────────────────────────────────────────────────────────────
The verdict, the fact-strip, and the OPEN-PORTAL button render with no model and no internet. If a local model is present, narration streams in underneath to explain the "why" — it never gates the action.
(Illustrative output; exact rendering varies with your network state.)
- Offline-first. The whole tool runs on the machine that's stuck. No internet is required, because the absence of internet is the problem it exists to solve.
- The deterministic core stands alone. Six probes (link, DHCP, gateway, DNS, captive-detection, HTTPS) parse real macOS network output into a typed factsheet, and a pure router maps that to a named cause and verified fix steps. This works with the model off and Docker down — it is the product, not a fallback.
- The LLM is a grounded reasoner, not a free one. When present, the model only narrates the factsheet plus the matched playbook. It reasons over injected facts — never from its own weights — and if the facts don't support an answer, it says so. The diagnosis is already decided deterministically; the model just explains it in plain language.
- It never goes dark. Model, retrieval, and Docker are enrichment layered on top. Lose any of them and
portaldocdegrades cleanly to the verified deterministic steps. There is always a diagnosis.
Requirements: macOS and Bun. Optional: Ollama with a local model for narration; Docker/Chroma for semantic retrieval (both degrade gracefully when absent).
git clone https://github.com/abblake/portaldoc.git
cd portaldoc
bun installRun the interactive cockpit (launches when stdout is a TTY):
bun run src/cli.tsFlags:
bun run src/cli.ts --once # single probe pass, no interactive loop
bun run src/cli.ts --json # machine-readable factsheet to stdout
bun run src/cli.ts --no-llm # deterministic core only, skip narration
bun run src/cli.ts --audit-net # log every outbound packet (proves the offline claim)Optional one-time setup for narration — pull the model portaldoc narrates with:
ollama pull qwen3:14b # narrator (configurable via env/config)
ollama pull nomic-embed-text # local embeddings, only used if Chroma is upThe narrator model is configurable; a smaller model can drop in on modest hardware. With no model present, the tool simply shows the verified fix steps.
- Probe suite. Six probes run real, non-sudo-preferring macOS commands (
system_profiler SPAirPortDataType,scutil --dns,ipconfig getpacket,route, plus the captive and HTTPS reachability checks). Each probe is total — it never throws; it returns a status envelope so "couldn't measure" never masquerades as "measured false." - Factsheet. Probe outputs assemble into one typed
FactSheet— the single source of truth for everything above it. - Cause matching. A pure, deterministic router computes a signature from the factsheet and matches it against a corpus of symptom → cause → fix playbooks. It detects the top public-Wi-Fi failure modes: HTTPS/HSTS no-pop, custom DNS / DoH, MAC randomization, no/stale DHCP lease, gateway unreachable, and already-authed. It extracts the portal URL from the redirect when one is present.
- Grounded narration (optional). The factsheet plus the matched playbook(s) are handed to a local Ollama model that explains the fix and quotes your actual values (IP, SSID, gateway) so you can trust the read. It streams on top of the already-rendered deterministic output.
- Re-probe loop. Apply a fix, press
r, and re-probe. The cockpit shows afield old→new ✓delta so you can watch the network recover — the delta is the information, never a silent full re-render. The banner flips to✓ ONLINEwhen the portal clears.
portaldoc makes zero public-internet calls for knowledge or help lookup. Playbooks are baked in; the model reasons only over local facts.
The only packets it emits are the measurement itself: the captive-detection and HTTPS-reachability probes hit fixed, well-known endpoints (captive.apple.com and an Apple HTTPS host) — the same endpoints macOS already uses to detect captive portals. Those are the diagnostic, not a knowledge lookup. When narration is enabled, the model and embeddings talk to Ollama/Chroma over loopback only (127.0.0.0/8, ::1, localhost); an off-box URL is refused and the deterministic output stands.
Prove it yourself:
bun run src/cli.ts --audit-net--audit-net cross-checks every runtime emitter against a static manifest and asserts that only the two captive/HTTPS endpoints (plus loopback for Ollama/Chroma) ever see a byte. Any off-box config is reported as a violation.
Public Wi-Fi means a hostile access point can control the SSID and portal URL — portaldoc treats those as untrusted input throughout.
- Fixes are surfaced, never auto-run. Any command that needs
sudois shown as copy-paste only and tagged(sudo). The single confirmed action is opening the extracted portal URL, and only after a scheme allowlist (http/httpsonly —file://,javascript:,data:, and similar are blocked). - The model is held inside an allow-set. The narrator may only surface commands present in the matched playbook's fix steps. Anything the model emits that isn't in the verified allow-set is redacted inline (
[redacted: ungrounded command]) before it can render as runnable — the TUI is never more permissive than the headless CLI. - Attacker-controlled fields are defanged. SSID and portal URL are wrapped, control-char-stripped, and length-capped before they reach the prompt, and the model is instructed to distrust them. This grounding defense was hardened across two rounds of adversarial cross-vendor review against the hostile-AP / prompt-injection threat model.
Early and experimental. macOS-only. The deterministic core, grounded narration, and interactive TUI are feature-complete and test-covered, but this is a young tool — expect rough edges and please file what you find.
Contributions welcome. See CONTRIBUTING.md.
MIT — see LICENSE.