Release 1.9.8 (aicodeman) and 0.1.8 (xterm-zerolag-input). Fixes macOS session start (`posix_spawnp failed.`, issues #6 and #204): node-pty ships its macOS spawn-helper as mode 0644 and macOS launches every PTY through it. `scripts/fix-node-pty.mjs` (npm run fix:node-pty) chmods every helper, prebuilds/ included, then verifies by really opening a PTY; the blind Node-22+ rebuild is gone. `spawnPtyWithHelperRepair()` self-heals an already broken install on the first failed spawn. Adds the phone home screen (session overview under 430px, per-device `mobileOverviewEnabled`, default ON) and a guided Tailscale path in install.sh, plus `install.sh tailscale` to retrofit it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
14 KiB
Tailscale Setup in the Installer (Plan)
Goal: make "Codeman over Tailscale, with real HTTPS" a first-class, guided path in
install.sh, instead of a one-line hint pointing at the docs. Today the safest
recommended deployment (loopback bind + tailscale serve) is exactly what the
maintainer's own prod runs, but a new user has to discover and wire it by hand.
The installer should do it for them.
Status: IMPLEMENTED (2026-08-04). install.sh carries the 3-way network
prompt, the guided Tailscale flow, and the tailscale subcommand; README,
docs/security-architecture.md section A, and CLAUDE.md are updated. Verified
live on the maintainer's prod host: install.sh tailscale took the idempotent
kept-as-is path against the existing serve mapping (recognizing the legacy
https+insecure:// target), verified https://<node>.ts.net/api/status
end-to-end, and left tailscale serve status byte-identical. Items 1-4, 7,
and 10-12 of the manual matrix below still need a fresh machine to exercise.
Why this is low-hanging fruit
Everything on the app side already works; this is almost purely installer UX:
.ts.netis already inDEFAULT_TRUSTED_HOST_SUFFIXES(src/web/network-auth-policy.ts), so the always-on Host/Origin guard acceptstailscale servetraffic with zero configuration. NoCODEMAN_ALLOWED_HOSTSneeded.- The loopback bind is the server default and prints no warning; nothing to
acknowledge, no
CODEMAN_PASSWORDstrictly required (the tailnet is the auth boundary; Tailscale authenticates the device before a packet ever reaches us). tailscale serveterminates TLS with a real Let's Encrypt certificate for<node>.<tailnet>.ts.net. That gives users valid HTTPS with no self-signed cert warnings, and (because it is a proper secure context) working service worker, PWA install, and web push on phones. This is strictly better thancodeman web --httpsfor remote access.- SSE and WebSockets work through serve (proven by prod:
https://tnode.tailf80371.ts.netfronting127.0.0.1:3000daily). docs/security-architecture.mdsection "A. Tailscale serve (recommended)" already documents this as the preferred setup; the installer just does not implement it.
UX design
1. The network-access prompt grows a Tailscale option
choose_network_binding() (install.sh:1051) currently offers two choices. New
menu, with Tailscale first when it can be recommended:
Network access
How should the Codeman dashboard be reachable?
1) Tailscale (recommended)
Private VPN access from your phone/laptop, real HTTPS,
no password needed. Works from anywhere, not just your Wi-Fi.
2) Any device on your network (0.0.0.0)
Open it straight from your phone or laptop on the same Wi-Fi.
Less safe: set a password so only you control your agents.
3) This machine only (127.0.0.1)
Safest. Reach it remotely via Tailscale or a tunnel later.
Choice mapping:
- Option 1 = bind
127.0.0.1(unchanged server posture) + configuretailscale serve. Internally it is option 3 plus the serve setup, so all existing binding plumbing (BIND_HOST, service files,read_existing_binding) is untouched. - Options 2 and 3 behave exactly as today (renumbered).
- Default choice: 1 when tailscale is installed and logged in, or when an existing serve mapping for our port is detected; otherwise keep today's defaults (1 -> 2, 2 -> 3 renumbering, preserving the "existing setup wins" rule). If tailscale is not installed, option 1 is still shown (the installer offers to install it), but the default stays on the current behavior so a bare Enter never pulls in new software.
- Password: after choosing Tailscale, offer the password prompt as optional
defense in depth with default skip ("the tailnet already authenticates your
devices; add one anyway?"). No
BIND_ACKneeded since the bind is loopback.
2. The Tailscale flow (state machine)
New setup_tailscale_access() runs after the binding choice, before service
setup, handling each state in order:
- Not installed.
- Linux: offer to run the official installer
(
curl -fsSL https://tailscale.com/install.sh | sh), which handles all distros and enablestailscaledat boot. This mirrors our own curl-pipe-bash story and avoids maintaining per-distro logic like the sixinstall_cloudflared_*functions. - macOS: do not auto-install (the GUI app needs an interactive login).
Offer
brew install --cask tailscalewhen brew exists, else print the download link, then wait-and-retry or let the user skip. - Declined install => fall back to plain loopback (option 3 behavior) and
print how to redo this later (
install.sh tailscale, see below).
- Linux: offer to run the official installer
(
- Installed but logged out (
tailscale status --json->.BackendState == "NeedsLogin"or"Stopped").- Run
tailscale up(viarun_as_rootif needed). It prints an auth URL that works headless (user opens it on any device). Poll.BackendState == "Running"with a friendly spinner + timeout; on timeout, skip gracefully with re-run instructions.
- Run
- Running: grant operator (Linux).
sudo tailscale set --operator=$USERso serve configuration (now and in the future) does not need root. Skip silently if we are already operator (probe:tailscale serve statusexits 0) or sudo is declined; fall back torun_as_root tailscale serve .... - HTTPS availability check.
.CertDomainsempty or.CurrentTailnet.MagicDNSEnabled == falsemeans the tailnet has not enabled MagicDNS / HTTPS certificates. Print the exact two toggles with the admin URL (https://login.tailscale.com/admin/dns: enable MagicDNS, then enable HTTPS Certificates), then offer "I enabled it, re-check" / "skip for now". No silent HTTP fallback: the pitch is real HTTPS, and a plain-HTTP serve would break the PWA/push story. Skipping falls back to loopback + re-run instructions. - Existing serve config check (
tailscale serve status --json).- Already proxying to our port (443 ->
127.0.0.1:$PORT): keep it, report it, done. Re-running the installer must be idempotent. - Port 443 occupied by a DIFFERENT target: never clobber it. Ask whether to
replace it or skip. (Prod itself has a second serve on :5000; blind
tailscale serve resetwould destroy user config. NEVER usereset.)
- Already proxying to our port (443 ->
- Configure.
tailscale serve --bg $PORTwhere$PORTis the install's Codeman port (default 3000; honor a presetCODEMAN_PORT). Serve targets plain HTTP on loopback; TLS terminates at tailscaled with the real cert. The--bgconfig persists in tailscaled state across reboots, so no extra service unit is needed. (Note: do NOT combine this withcodeman web --https; that is what forces the awkwardhttps+insecure://proxy target prod historically used. New installs should keep Codeman on plain HTTP behind serve.) - Verify end-to-end. Derive the URL from
.Self.DNSName(strip the trailing dot) and curlhttps://<dnsname>/api/statusafter the service is up, retrying for ~30s: the first request can be slow while the Let's Encrypt cert is issued. Print success with the URL, or the observed error withtailscale serve statusoutput on failure. This follows the "always test before claiming it works" rule; a blind "done!" is not acceptable.
3. Closing summary and security notice
-
The final summary gains a "Remote Access (Tailscale)" block, printed above the cloudflared block, showing the actual URL:
Remote Access (Tailscale): https://tnode.tailf80371.ts.net (any device on your tailnet, HTTPS) tailscale serve status # inspect -
print_security_notice()third branch (loopback) gets a variant: when a serve mapping for our port is detected, lead with "reachable on your tailnet at https://... (HTTPS, tailnet-only)" instead of the generic "do ONE of" list. Detection is dynamic (querytailscale serve status --jsonat print time), no marker persisted anywhere: tailscaled's own state is the single source of truth, so external changes never drift against a stale flag.
4. Standalone entry point: install.sh tailscale
Add a tailscale subcommand next to update / uninstall in the existing
dispatch. It runs setup_tailscale_access() against the already-installed
service (reads the port from the service file, requires an existing install).
This serves:
- existing installs that predate the feature,
- users who picked "this machine only" and changed their mind,
- every "skip for now" branch above, all of which print this exact command.
One implementation, two entry points. No separate scripts/tailscale-setup.sh
(unlike cloudflared, there is no long-running process for a tunnel.sh-style
start/stop wrapper to manage; tailscaled owns the lifecycle).
5. Non-interactive / automation
CODEMAN_TAILSCALE=1presets choice 1 (analogous to presettingCODEMAN_HOST). In non-interactive runs it only proceeds through states that need no human (already installed + logged in + HTTPS-enabled tailnet); anything requiring interaction (login URL, admin-console toggle, replacing a foreign serve mapping) warns and falls back to loopback. It never installs tailscale non-interactively.CODEMAN_NONINTERACTIVE=1with an existing serve mapping: preserve it, same "never silently loosen/change" policy asread_existing_binding.- Document both in the header comment block of install.sh (the env-var reference at the top) and in the README.
Edge cases and decisions
| Case | Decision |
|---|---|
macOS GUI app without tailscale on PATH |
get_tailscale_path() helper mirroring get_cloudflared_path(): check PATH, then /Applications/Tailscale.app/Contents/MacOS/Tailscale. All calls go through it. |
| Tailnet HTTPS certs disabled | Guided admin-console instructions + re-check loop; skip falls back to loopback. Never configure plain-HTTP serve. |
| Port 443 serve exists for another app | Prompt replace/skip; never tailscale serve reset (destroys unrelated mappings). |
| First cert issuance latency | Verify step retries ~30s and says why the first load may be slow. |
tailscale up needs auth |
Print the auth URL prominently, poll with timeout, skip gracefully. Works headless. |
Custom CODEMAN_PORT |
Serve target uses the actual port; install.sh tailscale re-reads it from the service file. |
| Funnel (public internet) | OUT OF SCOPE for v1. If ever added it must mirror the tunnel guard: refuse without CODEMAN_PASSWORD (isUnauthenticatedNetworkAcknowledged). Funnel exposes to the whole internet and is a different risk class than tailnet-only serve. Mention tailscale funnel in docs only, with the password warning. |
| Uninstall | Best effort: if serve status --json shows 443 proxying to our port, run the targeted tailscale serve --https=443 off (still accepted by current CLIs); if the CLI rejects it, print manual instructions. Never touch other mappings, never uninstall tailscale itself. |
| User already fronting Codeman some other way (reverse proxy etc.) | The serve check only looks at tailscale state; other proxies are invisible and unaffected (same stance as the loopback-exemption note in security-architecture). |
What does NOT change
- Server code: no changes required. Host guard already trusts
.ts.net, loopback bind is already the default, SSE/WS already work through serve. - The two existing binding options and their semantics,
read_existing_bindingpreservation, and the LAN+password flow. scripts/tunnel.sh/ cloudflared support (stays as the "no Tailscale account" alternative).- The security model: this feature only ever narrows exposure (loopback + authenticated overlay), never widens it.
Files touched (implementation inventory)
| File | Change |
|---|---|
install.sh |
New: check_tailscale, get_tailscale_path, tailscale_status_field (jq-free JSON field extraction; the installer cannot assume jq: use sed/grep like existing helpers or tailscale status --json piped to node -e since node is guaranteed post-install), offer_install_tailscale, ensure_tailscale_login, ensure_tailscale_operator, ensure_tailnet_https, setup_tailscale_serve, verify_tailscale_access, setup_tailscale_access (orchestrator). Modified: choose_network_binding (3-way menu), summary block, print_security_notice, subcommand dispatch (tailscale), uninstall (targeted serve removal), header env-var docs (CODEMAN_TAILSCALE). |
README.md |
Remote-access section: promote the Tailscale path with the one-liner and install.sh tailscale; keep the tailscale-IP HTTP note for non-serve users but recommend serve + HTTPS. |
docs/security-architecture.md |
Section A gains "the installer can set this up for you" + install.sh tailscale pointer. |
CLAUDE.md |
One line in Scripts & Tunnel: installer offers Tailscale setup (install.sh tailscale to redo). |
test/ |
No unit tests possible for interactive bash + a live tailnet; guard with shellcheck install.sh (already the norm) and the manual matrix below. |
Manual test matrix (before release)
- Linux + tailscale absent: install offered, declined => loopback fallback + hint.
- Linux + tailscale absent: install accepted => full flow => URL verified.
- Logged out => auth URL flow => Running => serve configured.
- Tailnet with HTTPS certs disabled => guided instructions => re-check => success; and the skip branch.
- Re-run installer with serve already configured => idempotent, preserved, reported.
- Second serve mapping on another port present => untouched (prod-like state).
- Port 443 already proxying another target => replace/skip prompt honored.
install.sh tailscaleon an existing loopback install (the retrofit path).CODEMAN_NONINTERACTIVE=1re-run => preserves everything, no prompts.- macOS (Mac mini
arbbotbox): GUI-app CLI path detection + full flow. - Uninstall removes only our 443 mapping, leaves others.
- Phone check: PWA install + push from the
https://*.ts.netorigin.
Release
Changeset: minor (new documented installer capability + new CODEMAN_TAILSCALE
env var). The feature is installer-only, so it ships with zero risk to running
servers; install.sh update does not invoke the new flow (updates never rewrite
access config), only fresh installs and the explicit install.sh tailscale
subcommand do.