# CARAPACE — Claude runtime host setup (single-runtime, v3.0 · 2026-08-26) > **FASTEST PATH — use the installer script instead of this document.** This same install, > consolidated and self-verifying, is one command: > `curl -fsSL https://carapace.info/install.sh -o /tmp/carapace-install.sh` — skim it from disk, > then `bash /tmp/carapace-install.sh claude`. It ends by printing the pairing QR only after > it has verified the exact URL the QR encodes. This document remains the by-hand reference > for machines where the script cannot run — and for understanding what the script does. > > **HARD RULE, either path: never hand-assemble a pairing QR or `carapace://` link.** The only > QR an owner may ever be shown is one produced by this document's Phase F after E.4.4's > 200/401/200 triad passed, or by `carapace-pair` (which re-verifies before it renders). A QR > for an unverified URL pairs the phone to a dead machine and reads like success. This document is pasted by a **non-technical host owner into their own Claude Code (or equivalent local agent)** so that agent installs the CARAPACE **Claude** runtime on this machine and pairs it to the CARAPACE iPhone app. It installs exactly **one** loopback gateway (the Claude adapter on 127.0.0.1:18800), exposes only that one over Tailscale, and produces one pairing QR. **Both macOS and Linux (Ubuntu/Debian, with dnf notes) are first-class.** Every activation, supervision and serve step below has an **(a) macOS** and **(b) Linux** variant. Never improvise a substitute for a step you cannot find — the variant exists. This document is **self-contained**: it needs nothing but itself. The release bundle it installs is downloaded and hash-verified by Phase A0 from `https://carapace.info/install/bundle/`. If any instruction here conflicts with a README, a code comment, another document in the bundle, or an older prompt, **this document wins.** The bundle may also contain files for other runtimes (e.g. a Codex or OpenClaw gateway) and other setup documents — for the Claude runtime this file is authoritative; install `claude-gateway.mjs` + `tmux-drive.mjs` and **ignore every other runtime artifact in the bundle.** Work in phases **A0 → A0.5 → A → B → C → D → E → F → G, in order.** Every phase ends with a copy-pasteable **VERIFICATION** whose expected output is stated. **Do not advance past a phase whose verification did not produce the expected result** — stop and report instead. **Phase G is the verdict**: it re-measures the install live (including one real turn) and prints the one `INSTALL_STATUS:` line the Final Report must copy. An install is not finished because earlier phases printed pass markers; it is finished when G says so. **Re-running this whole document after a successful install is SAFE and idempotent** — A0 simply re-downloads and re-verifies the bundle. It keeps the existing token (so a paired phone keeps working), keeps a `claude` binary the owner deliberately pinned, keeps a supervisor definition that carries any of the three keys this document tells owners to add by hand (`GW_MODELS`, `GW_DISALLOWED`, `GW_MODE`), re-installs the same gateway files, and reloads the supervisor correctly. **The exact scope of that promise:** those three keys are the only hand edits C.4 *detects*. Any **other** edit to the plist/unit (a raised `ThrottleInterval`/`RestartSec`, an extra `Environment=` / `EnvironmentVariables` entry, a hand-fixed `PATH`, a changed `After=`/`WantedBy=`) **is replaced** on a re-run — but never silently: C.4 prints the generated-vs-installed **diff** on every path where it writes over a file that differed, so the owner can see exactly what was discarded and put it back. Every write that could destroy an owner decision is guarded, and each guard says how to override it deliberately (an EDIT-THIS-LINE switch, never a silent overwrite). If the bundle is **gone**, do not re-run the document — A0 cannot re-verify what is not there; to reload a working install, use the Section-3 reload pair on its own. Portability rules: always use `$(id -un)`, `$(id -u)`, and `$HOME`; never hardcode another machine's home directory; never assign to `UID` (read-only in bash and zsh) or to `LINES` (a reserved integer in zsh — assigning text to it aborts the block). Every block here is written to run **as-is under both bash and zsh** (zsh is the macOS default): that is why arguments are never passed through an unquoted variable (zsh does not word-split), why file lists come from `find` rather than a shell glob (an unmatched glob aborts a zsh block), and why loops read from a file rather than from `$VAR`. Do not "simplify" those. Tools this document actually uses: `node` (prerequisite), **`shasum` *or* `sha256sum`** (A0's integrity gate STOPs when neither exists — it is a hard prerequisite, not a nicety), `curl`, `qrencode`, `tailscale` **(option 3 only — never installed on the other three routes)**, `openssl` *or* node's crypto, plus `launchctl`/`plutil` on macOS or `systemctl`/`systemd-analyze` on Linux, and `lsof` **or** `ss` for listener checks. The blocks also use these POSIX basics, which every supported host already has and which are called out only so a minimal container image is diagnosed at A0 instead of mid-install: `find`, `install`, `cmp`, `diff`, `grep`, `awk`, `sed`, `cut`, `tr`, `date`, `id`, `uname`, `chmod`, `wc`. It does **not** rely on `python3` (a stub on a fresh Mac), on GNU `timeout` (absent on macOS), or on Homebrew being present. --- ## BEGIN PROMPT ### Document gate — did you actually download THIS document? (30 seconds; changes nothing) A mistyped path under `/install/prompt/` does **not** 404. This static host answers **200 with the carapace.info web page**, and `curl -fsSL` accepts that happily — so one wrong character (`claude-code.md`) hands you ~28KB of marketing HTML as your "setup document" and you start improvising an install from it. The release bundle is hash-gated in A0; this is the gate for the document itself, and it is one line. ```bash # Read-only. Nothing is written, nothing is changed. SELF="${CARAPACE_SETUP_DOC:-/tmp/carapace-setup.md}" if [ -f "$SELF" ]; then H1="$(head -n 1 "$SELF")" case "$H1" in '# CARAPACE'*'Claude runtime host setup'*) echo "SETUP_DOC_OK — this is the Claude document ($SELF)" ;; '# CARAPACE'*) echo "STOP: $SELF is a CARAPACE setup document for a DIFFERENT runtime — its first line reads '$H1'. Re-fetch https://carapace.info/install/prompt/claude.md (exact filename) and start again." ;; *) echo "STOP: $SELF does not begin with a CARAPACE heading. Its first line reads: '$H1'" echo " If that looks like HTML, you downloaded the carapace.info WEB PAGE, not this document." echo " Re-fetch exactly, then re-run this gate — do NOT follow a file that fails it:" echo " curl -fsSL https://carapace.info/install/prompt/claude.md -o /tmp/carapace-setup.md" ;; esac else echo "SETUP_DOC_NOTE: no file at $SELF — you are reading this from somewhere else, which is fine." echo " Then verify BY EYE that the first line of what you are reading is a '# CARAPACE' heading" echo " naming the Claude runtime. If it is HTML, or names another runtime, stop and re-fetch." fi ``` --- ### Runtime gate — are YOU the runtime this file installs? (do this first; change nothing yet) **This file installs the CARAPACE _Claude_ runtime.** Before anything else, settle *who you are* — and settle it from your own process, not from what happens to be lying on this disk. **Your RUNTIME is the harness you are executing inside, not the model that powers you.** A GPT-powered OpenClaw agent is **OpenClaw**; a Claude-powered OpenClaw agent is still **OpenClaw**. "Codex" means specifically the **OpenAI Codex CLI** app. Your model vendor is irrelevant here. **The presence of another runtime's files on this machine — `~/.claude`, `~/.codex`, `~/.openclaw`, or a `claude`/`codex`/`openclaw` binary on `PATH` — does NOT mean you are that runtime.** Multi-runtime machines are normal, and that assumption is exactly how the wrong runtime gets installed. Identify yourself from **your own process environment and ancestry**. These read-only commands print it and change nothing: ```bash printf 'OPENCLAW_SHELL=%s · CLAUDECODE=%s · CODEX_HOME=%s\n' "${OPENCLAW_SHELL:-unset}" "${CLAUDECODE:-unset}" "${CODEX_HOME:-unset}" env | grep -E '^(CLAUDECODE|CLAUDE_CODE_|CODEX_|OPENCLAW_|HERMES_)' | cut -d= -f1 | sort | tr '\n' ' '; echo '<- runtime markers in MY environment' pid=$$; hops=0 while [ "$hops" -lt 8 ] && [ -n "$pid" ] && [ "$pid" != 1 ]; do ps -o args= -p "$pid" 2>/dev/null | cut -c1-90 pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' '); hops=$((hops + 1)) done ``` **Take the first rule that matches — the order is deliberate:** 1. **Two runtimes' own markers at once** (e.g. `CLAUDECODE=1` *and* `OPENCLAW_SHELL`) → you are one agent nested inside another. That is ambiguity, not a tie-break: **STOP and ask the owner** which runtime they want installed. (`CODEX_*` sitting next to `OPENCLAW_*` is not this case — see 3.) 2. **`CLAUDECODE=1`**, with `CLAUDE_CODE_*` variables alongside and no `OPENCLAW_*` → you are **Claude Code** → **this file. Continue.** 3. **Any `OPENCLAW_*` variable** — `OPENCLAW_SHELL` (`exec` from its exec tool, `tui-local` from the TUI, `acp-client`), `OPENCLAW_SERVICE_MARKER`, `OPENCLAW_GATEWAY_SERVICE_PID` — → you are **OpenClaw** → `openclaw.md`. Any `CODEX_*` or `ANTHROPIC_*` beside it belongs to **OpenClaw's model backend, not to you**: OpenClaw runs Codex and Claude underneath itself and sets their variables for them. A `CODEX_HOME` pointing inside `~/.openclaw/…` is the giveaway. 4. **`CODEX_*` alone** (`CODEX_SANDBOX`, `CODEX_SANDBOX_NETWORK_DISABLED`, or a `CODEX_HOME` outside `~/.openclaw`), with no `OPENCLAW_*` and no `CLAUDECODE` → you are the **Codex CLI** → `codex.md`. 5. **`HERMES_*` alone** (`HERMES_HOME`, `HERMES_BIN`, `HERMES_PYTHON`, `HERMES_YOLO_MODE`), with no `CLAUDECODE`, no `OPENCLAW_*` and no `CODEX_*` → treat it as **Hermes** → `hermes.md`. The grep above has always *printed* these and no rule *consumed* them, so a Hermes-only environment matched nothing at all; the Hermes supervisor exports `HERMES_HOME`, so this is reachable in practice. Two cautions: `HERMES_*` **beside** another runtime's markers is rule 1 (ambiguity — ask), and because these are ordinary configuration variables an owner could export by hand, **confirm with the owner before installing on this evidence alone**. 6. **No runtime markers at all** → the environment cannot prove it. **Hermes exposes no reliable in-process marker** beyond the `HERMES_*` case above, and neither does a bare terminal, so do **not** guess: show the owner the ancestry lines you just printed and **ask which runtime this is** before continuing. Note that **any env-stripping shell transition makes this rule the inevitable outcome**: under `sudo -i`, `su -`, a systemd-spawned unit or some devcontainers, the markers are gone by construction, so "no markers" means **unknown**, never "probably Hermes" and never "probably the runtime whose file I happen to be holding". Ask. Ancestry corroborates but never overrides, and it matches on the **program being run, not the path it sits in**: `node …/openclaw/dist/index.js` (or an `openclaw` command) means OpenClaw launched you, a `claude` parent means Claude Code, a `codex` parent means the Codex CLI. A home directory or install path that merely *contains* the word `openclaw`, `claude` or `codex` proves nothing. **Where your environment and your ancestry disagree, your own environment wins** — an agent's parent is often just the supervisor that started it. **`STOP:` if this is not you.** If your markers name a different runtime than this file, or leave you unsure, change nothing here: say plainly which runtime you appear to be and **quote the marker that says so**, then fetch that file from `https://carapace.info/install/prompt/` (`codex.md`, `openclaw.md`, or `hermes.md`) and follow that one instead. Installing the wrong runtime is the one mistake this gate exists to prevent. **One deliberate exception:** if the owner explicitly asked *you* to install the **Claude** runtime from some other agent, that is supported — this document only needs a real terminal. Name the agent you actually are, confirm the owner meant the Claude runtime, then continue. --- ### Mission You are setting up the CARAPACE Claude runtime on THIS machine (the one you are running on) for its owner. You will: verify the release bundle, detect the platform, confirm prerequisites, install the Claude gateway under `~/.carapace/claude-gateway/`, ensure a bearer token exists, supervise the gateway with launchd (macOS) or a systemd `--user` unit (Linux), prove it can actually answer, expose only its loopback port through Tailscale Serve, prove the model catalog is populated, and generate one pairing QR. Then the owner scans it once on their iPhone and gets a working Claude lane. You have a real terminal. Run the commands. Read their output. A phase's job is not done until its VERIFICATION prints what this document says it should. --- ### 0. What you are building | Piece | Value | |---|---| | Runtime | Claude (Claude Code, driven by this gateway) | | Gateway process | `~/.carapace/claude-gateway/claude-gateway.mjs` (Node) | | Bind | **127.0.0.1:18800** by default. Phase E.4 may move it to a **LAN** address (option 1), or to a public address **only** under option 4's explicit `REACH_INSECURE_HTTP=yes` testing opt-out (`GW_BIND`) — option 4's default HTTPS front keeps it on loopback. Never a wildcard | | Supervisor (macOS) | launchd user agent `com.carapace.claude-gateway` (RunAtLoad + KeepAlive) | | Supervisor (Linux) | systemd `--user` unit `carapace-claude-gateway.service` (Restart=always + linger) | | Bearer token | `~/.carapace/claude-gateway/token` (0600, generated once, reused on re-runs) | | Remote boundary | **Decided by Phase E.4, one of five**: (1) LAN bind `http://:18800`; (2) the owner's existing HTTPS domain/tunnel/proxy → `/carapace-claude`; (3) Tailscale Serve HTTPS path `/carapace-claude` → `http://127.0.0.1:18800`; (4) **direct public IP with automatic HTTPS** — caddy + the free hostname `.sslip.io` + a real ACME certificate in front of the still-loopback gateway → `https://.sslip.io` + `/carapace-claude`, built by E.4.3b–E.4.3d. Plain `http://:18800` survives only behind the explicit `REACH_INSECURE_HTTP=yes` **testing** opt-out, which puts the bearer token on the wire in cleartext. Plus **(4b)** that same automatic HTTPS for a box **behind a router**: the owner forwards external **TCP 443 and nothing else**, caddy answers it here on a certificate issued over **TLS-ALPN-01** (so an ISP that blocks inbound 80 is irrelevant), the CA's own validation is the proof the forward works, and the gateway still never leaves loopback. The bearer token is the authorization in **all** of them | | Root health alias | Tailscale Serve `/health` → `http://127.0.0.1:18800/health` (the phone's reachability probe) — added **only if nothing else already owns root `/health`**; E.5b probes first | | Pairing | one QR encoding a `carapace://config` deep link with the token inline | | Re-pairing | `~/.carapace/bin/carapace-pair` — reprints that QR on demand from the token + reach record already on disk, so a lost/rotated token never costs a re-run of this document | The gateway listens on 127.0.0.1 until Phase E.4 says otherwise, and **E.4 decides how the phone reaches it — by detection, not by interrogation.** Tailscale is the *recommended* route for "from anywhere, without building anything", not a requirement: a box on the owner's Wi-Fi, behind their existing HTTPS, or holding its own public IP pairs without it. In every option the **bearer token is the authorization**, the gateway 401s without it, and a 20-per-minute bad-auth limiter backs it. What never changes: never a wildcard bind (`*` / `0.0.0.0` / `[::]`), and never Tailscale **Funnel** — that publishes to the public internet with no decision recorded anywhere. --- ### 1. Prerequisites — confirm these BEFORE touching anything 1. **Claude Code installed and logged in.** The gateway drives the owner's own `claude` binary — it does not ship or configure Claude. If `claude` is not installed, STOP: tell the owner to install it (https://claude.com/claude-code) and run `claude` once to log in, then re-run this prompt. - **"And logged in" is a real requirement, and it is now checked in Phase A** (`A.2b`), not discovered at E.3 after everything is installed and published (2026-07-30 audit D-7). `--version` answers perfectly on a logged-out CLI, so it proves nothing about credentials. A machine with no credentials **can** still be set up deliberately — A.2b's *no-auth path* says exactly which acceptance items go BLOCKED/N-A when it is. What is not acceptable is finding out at the end and reporting it as a pass. - **Tool policy — the owner's Claude configuration governs.** The gateway imposes NO tool restrictions of its own and does NOT run `--dangerously-skip-permissions`. Lanes run the owner's `claude` with the owner's own permission configuration (`~/.claude/settings.json` allowlists/modes): whatever their Claude may do, their lanes may do; anything their configuration would prompt for is declined headlessly (there is no terminal to approve on). Two optional dials, both set in the supervisor's environment (plist `EnvironmentVariables` / unit `Environment=`): `GW_DISALLOWED` (a space-separated tool blocklist, e.g. `Bash WebFetch`) to restrict lanes below the owner's config, and `GW_MODE=full` (adds `--dangerously-skip-permissions`) to bypass permission prompts entirely — only for an owner who explicitly wants that. - **Privacy — disclose this to the owner (Phase B):** a paired phone can drive a lane with the owner's full Claude capabilities, and a lane's working directory defaults to the home folder — including **reading files anywhere in the home directory (secrets like `~/.ssh/`, `~/.aws/`) and, if the owner's Claude config allows it, running commands and modifying files.** The protections are: only a device the owner pairs (holding the token, on their tailnet) can reach it, and the owner's own Claude permission config is the capability ceiling. 2. **A way for the phone to reach this box — NOT necessarily Tailscale.** Phase E.4 detects the options and picks one; on a box with a LAN address or its own public address it needs **nothing installed and nothing asked**. Tailscale is the *recommendation* when the owner wants access from anywhere without building it themselves, and it stays fully scripted: if E.4 selects it (or the box already runs it), the client is installed in **E.4a0** — *after* the decision, never before it (`wo_20260825ae`) — the version floor is **≥ 1.98** asserted programmatically, and the iPhone must be on the **same tailnet, signed in as the same identity**. On options 1, 2 and 4 none of that applies, and nothing in this document installs Tailscale on those paths: C.3 only **looks** for a client, and E.4a0 is behind the same `REACH_MODE=tailscale` gate the serve blocks use. Do not install Tailscale on a box that already has a way in. 3. **Node.js ≥ 18** (≥ 20 recommended). Checked and asserted in A.1. 4. The **CARAPACE release bundle** — **nothing to find and nothing to ask for.** Phase A0 downloads it from `https://carapace.info/install/bundle/` into `$HOME/.carapace/bundle` (that folder is `$BUNDLE`) and verifies every file's SHA-256 against the published `MANIFEST.sha256` before a single line of it is executed. All you need is `curl` and outbound HTTPS to `carapace.info`. If this host is air-gapped, copy the same files from a machine that can reach the site into any folder and re-run A0 with `CARAPACE_BUNDLE_DIR=/that/folder` — the integrity gate is identical either way. 5. **Administrative rights and a human who can approve prompts.** Installing `qrencode` needs an admin password, and **if — and only if — E.4 lands on option 3**, so do installing Tailscale (macOS `.pkg` into `/Applications` + the menu-bar "Install CLI…"; Linux apt/dnf repo) and, on Linux, `sudo tailscale set --operator=…`. Those three are E.4a0's business, and a box that ends up on option 1, 2 or 4 never needs any of them. (`loginctl enable-linger` is **not** in that list — see Section 3 rule (f): a user can normally enable linger for themselves with no sudo at all, so never report a non-admin box as un-installable on linger's account.) Managed/MDM machines may block the Tailscale package outright. Check early: - macOS: `id -Gn | tr " " "\n" | grep -qx admin && echo "admin: yes" || echo "admin: NO"` - Linux: `sudo -n true 2>/dev/null && echo "sudo: yes" || echo "sudo: password needed or absent"` If the owner is not an administrator and `qrencode` is not already installed, STOP and report — do not start an install you cannot finish. A missing **Tailscale** is not that case: it is only a blocker on option 3, so a non-admin box is steered to option 1, 2 or 4 at E.4.2 rather than halted here. 6. **A GUI login session (macOS only) or linger (Linux only)** for supervision to survive. See Phase E.1 — this is the single most common headless-machine failure. You do NOT need OpenClaw, Codex, `tmux`, or `python3`. --- ### 2. House rules 1. **Never print, log, or paste a token or the pairing deep link to the screen, or into any file that is not mode 0600.** That includes the **process command line**: a token passed as `curl -H "Authorization: Bearer $TOK"` is visible to `ps` for the life of the request, and on Linux `/proc//cmdline` is world-readable by default (`hidepid` is not the Debian/Ubuntu default), so on a multi-user host any local user can read it. Every authed probe in this document therefore writes the header into a **0600 file inside the 0700 gateway directory** and passes it as `curl -H @"$HF"` (supported since curl 7.55; macOS and every current distro ship newer), then deletes it. Keep that shape when you copy a block; never put a token back on a command line. Never `echo`/`cat` a token to the terminal. **There is exactly ONE sanctioned exception, and it is the entire point of the install:** the terminal QR in F.3 — which is **shown by DEFAULT** (`wo_20260825ad`), on every platform, including its last-resort branch that prints the typable link when no renderer exists. The owner cannot pair with a code they never see, so rendering it is the expected path and not a breach of this rule; F.3's closing note says exactly what that costs and how to rotate. **Phase F's decode is NOT an exception — it prints no token bytes at all.** It prints `tokenLength` plus a `tokenFingerprint` = the first 8 characters of the **SHA-256 of the token**, never of the token itself. That distinction is load-bearing under the agent-driven mode Phase B sanctions: an installer's stdout is a transcript another agent reads and stores, so a "redacted" 8-character token *prefix* is 8 live token bytes copied into a log forever. A hash prefix compares two things (the link vs the token file) with equal certainty and leaks nothing. If you are copying an older block that printed `tokenPrefix8`, replace it with the fingerprint form shown in F.1. 2. **`STOP:` is binding, and it is the ONLY blocking prefix.** Any line a block prints that begins with `STOP:` means **halt and resolve it before continuing** — do not run later commands, do not "keep going and see". There are **no variants**: this document never prints `STOP-CLASS`, `WARNING`, or any other pseudo-halt. Anything that is not a `STOP:` line is a `NOTE:` — informational, to be recorded in the Final Report, and it never blocks. So a phase VERIFICATION that says "no `STOP:` line anywhere" is a complete gate. Blocks in this document deliberately do **not** call `exit`, because a bare `exit` in a pasted interactive shell closes the owner's terminal. The guard prints; **you** stop. 3. **Never restart or reload a running service without the Section-3 verification snippet** and, for anything already serving the owner, telling them. 4. **Supervision is required. A foreground process you started by hand is NOT an installation.** The only sanctioned "keep it running" mechanisms are launchd `KeepAlive` (macOS) and a systemd `--user` unit with `Restart=always` + `loginctl enable-linger` (Linux). No cron, timers, `while true`, or any other recurring scheduler. 5. **One 32-byte random token, generated on THIS machine, and never rotated by accident.** Never reuse a token from another machine, the bundle, or any example. On a re-run the existing token is KEPT (see C.2 "Rotating the token"). 6. **Loopback by default; a non-loopback bind ONLY through Phase E.4's recorded decision. Funnel OFF, always.** The listener is `127.0.0.1:18800` unless E.4 recorded `REACH_OPTION=1`, or `REACH_OPTION=4` **with `REACH_MODE=public`** (the `REACH_INSECURE_HTTP=yes` testing opt-out) — in which case it is exactly `the address in `~/.carapace/reach-*.env`` — the LAN or public address E.4 detected and wrote down. **Option 4's default (`REACH_MODE=public-tls`) keeps the gateway on loopback** and puts caddy on 443 in front of it, so the listener never moves at all. It is never `*:18800` / `0.0.0.0` / `[::]`: a wildcard bind is still forbidden, because it publishes every interface including ones nobody inspected. Verified in E.1c and on the acceptance checklist, which both read E.4's record rather than assuming loopback. Tailscale **Funnel** stays off in every option — that is the public internet, and option 4 reaches the internet deliberately, through its own HTTPS front door, not by accident. 7. **Logs and scratch files live inside the 0700 gateway directory, never `/tmp`, and the log itself must be 0600.** `gw.log` records a 7-character token prefix and message previews by design; `/tmp` is world-readable and a symlink target. The 0700 directory is a *second* lock, not the only one: the supervisor creates `gw.log` with the **daemon's** umask, and a stock Ubuntu user umask of `0002` produced `-rw-rw-r-- gw.log` (world-readable, token prefix and all) on a host where every directory check passed. So the supervisor definition pins the umask — `UMask=0077` in the systemd unit, `Umask63` in the plist (**63 decimal = 0o77**; plist integers are decimal, and writing `77` there silently sets `0o115`) — and C.4 repairs the mode of any log an earlier run already created too wide. The acceptance checklist verifies `gw.log`'s own mode, not just its parent's. 8. **Do not invent components.** A file this document names that is missing from the bundle is a **bundle defect** — stop and report. Never substitute your own gateway script. 9. **Every block re-derives what it consumes.** Blocks may run in **fresh shells** — nothing is inherited between them. That is why `OS`, `NODE`, `GW_DIR`, `HOSTN`, `TOK`, `BUNDLE` and `CLAUDE_BIN` are recomputed (or read back from `~/.carapace/.bundle-path-claude` / `~/.carapace/.claude-bin`) at the top of every block that uses them, and why each one is guarded. If you see an empty-variable symptom (`install /claude-gateway.mjs`, `https:///…`, ``), you skipped a re-derivation — do not patch around it. 10. **Never widen another service's blast radius.** Tailscale Serve config is machine-global and shared. Never run `tailscale serve reset` or `tailscale serve --https=443 off` (the CLI suggests both; both wipe other services' routes). Only ever touch a route this document proves is ours. --- ### 3. Supervision rules — the ones that bite **macOS / launchd** - **(a)** `launchctl kickstart -k` restarts the PROCESS ONLY. It silently **ignores new EnvironmentVariables** while handing you a fresh pid that looks like success. Use it only to reload unchanged code, never to apply a plist edit. - **(b)** To apply ANY plist change: `launchctl bootout` then `launchctl bootstrap` — the pair, in that order. A bare second `bootstrap` on an already-loaded label **fails** (`Bootstrap failed: 5`) and leaves the OLD process running, so run `bootout` on **every** re-run, unconditionally. - **(c)** `bootout` takes the **LABEL with no `.plist` suffix and no path**: `launchctl bootout gui/$(id -u)/com.carapace.claude-gateway`. - **(d)** A failed/partial `bootout` leaves the job UNLOADED and the port DEAD. Always re-probe (`launchctl print …` / `curl …/health`) after — never assume it came back. Also: `gui/$(id -u)` requires an **active GUI (Aqua) login session** for that uid. Over SSH with nobody at the console it does not exist — E.1a pre-checks this. **Linux / systemd `--user`** - **(e)** `systemctl --user daemon-reload` after **every** unit-file edit, then `restart` (an `Environment=` change is not picked up by a bare `restart` without the reload). - **(f)** `loginctl enable-linger "$(id -un)"` is **required**: without it the user manager is torn down at logout and the unit never comes back after a reboot. This is the Linux equivalent of `RunAtLoad`. **It usually does NOT need sudo** — on stock Ubuntu polkit lets a user enable linger for themselves (verified on a fresh non-admin account: `rc=0`, `Linger=yes`, `/run/user/` created). Try it unprivileged, then `sudo -n`, and only ask the owner for a password if both fail. And enable it **before** the first `systemctl --user` command, not after: linger is what creates the user manager those commands talk to. - **(g)** `systemctl --user` needs a user D-Bus session. Every Linux block in this document that **reads or writes** supervisor state therefore begins with `export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"` — keep that line when you copy a block, and add it to any command of your own. Without it you get `Failed to connect to bus`, an **empty** `MainPID`, and a false "not running" verdict on a healthy install (then enable linger per (f)). - **(h)** `Restart=always` + `RestartSec=5` are the `KeepAlive` + `ThrottleInterval` equivalents. Log to `append:` files inside the 0700 gateway dir — never `/tmp`. **Verification snippet (use after every load/reload/restart, both OSes):** ```bash OS="$(uname -s)"; LBL=com.carapace.claude-gateway; UNIT=carapace-claude-gateway.service if [ "$OS" = Darwin ]; then launchctl print "gui/$(id -u)/$LBL" 2>/dev/null | grep -E "state = |pid = " | head -2 SPID="$(launchctl print "gui/$(id -u)/$LBL" 2>/dev/null | grep -E "^[[:space:]]*pid = " | head -1 | tr -dc "0-9")" [ -n "$SPID" ] && ps eww "$SPID" | tr " " "\n" | grep -E "^(GW_|CLAUDE_BIN=|PORT=|PATH=)" | sed "s/=.*/=/" else # REQUIRED on every Linux block that reads supervisor state: without it `systemctl --user` # fails with "Failed to connect to bus" on any shell pam_systemd did not set up (devcontainer, # `su -`, `sudo -u`, an agent-spawned non-login shell) and MainPID comes back EMPTY — which # reads exactly like a dead service on a perfectly healthy install. export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user show -p ActiveState -p SubState -p MainPID "$UNIT" SPID="$(systemctl --user show -p MainPID --value "$UNIT" 2>/dev/null)" [ -n "$SPID" ] && [ "$SPID" != 0 ] && tr "\0" "\n" < "/proc/$SPID/environ" | grep -E "^(GW_|CLAUDE_BIN=|PORT=|PATH=)" | sed "s/=.*/=/" fi echo "supervised pid: ${SPID:-none}" sleep 2 curl -s -m 5 -o /dev/null -w "health -> %{http_code}\n" http://127.0.0.1:18800/health ``` Expected: macOS `state = running` + a numeric pid, or Linux `ActiveState=active` / `SubState=running` + a non-zero `MainPID`; the env keys present as ``; `health -> 200`. (If health is not 200 yet, this is the only place a retry is allowed without a diagnosis — see E.1c's readiness loop; supervisors return before the socket listens.) --- ### Phase A0 — platform gate + bundle integrity (run FIRST; abort on any failure) ```bash OS="$(uname -s)"; echo "platform: $OS" case "$OS" in Darwin) echo "macOS: use every (a) variant below" ;; Linux) echo "Linux: use every (b) variant below" ;; *) echo "STOP: unsupported platform '$OS' — this document covers macOS and Linux only" ;; esac mkdir -p "$HOME/.carapace"; chmod 700 "$HOME/.carapace" # ---- acquire the release bundle: DOWNLOAD it. Nothing has to be on this machine first. ---- # The bundle is published at https://carapace.info/install/bundle/ . Fetch only the three files # this runtime installs plus the manifest that proves them. NOTHING here is executed: every # downloaded byte is hash-checked against MANIFEST.sha256 immediately below, and the install # cannot advance unless that check passes. if ! command -v curl >/dev/null 2>&1; then echo "STOP: curl is not installed — it is required to download the release bundle." echo " macOS ships it; Debian/Ubuntu: sudo apt-get install -y curl ; Fedora: sudo dnf install -y curl" fi BUNDLE="${CARAPACE_BUNDLE_DIR:-$HOME/.carapace/bundle}" mkdir -p "$BUNDLE"; chmod 700 "$BUNDLE" DL_FAIL=""; DL_CODES=""; DL_REFUSED=""; DL_NOANSWER="" for f in MANIFEST.sha256 claude-gateway.mjs tmux-drive.mjs carapace-pair; do # Download to .part and rename only on success, so a half-written file can never be mistaken # for a good one by the hash gate (it would fail anyway — this just keeps the error honest). # CAPTURE THE STATUS. `-w '%{http_code}'` is written even when `-f` aborts the transfer, and the # whole diagnosis below turns on it: "the server refused me (403)" and "nothing answered (000)" # are opposite problems with opposite remedies, and a bare exit code cannot tell them apart. # `|| true` — NEVER `|| echo 000`, which would append a second code and make $HC the meaningless # token `000000`. curl's own error line still reaches stderr (`-S`), so nothing is hidden. HC="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 2 --max-time 120 \ -w '%{http_code}' -o "$BUNDLE/$f.part" "https://carapace.info/install/bundle/$f")" || true case "$HC" in ''|*[!0-9]*) HC=000 ;; esac if [ "$HC" = 200 ] && [ -s "$BUNDLE/$f.part" ]; then mv -f "$BUNDLE/$f.part" "$BUNDLE/$f" # A path a static host does not have can still answer 200 with an HTML error page, which # `curl -f` cannot see. Reject an HTML body HERE so the failure reads as "not published" # instead of resurfacing below as a bogus "corrupt bundle". (The hash gate catches it either # way — this only keeps the diagnosis honest.) if head -c 64 "$BUNDLE/$f" | grep -qi ' claude-gateway.mjs' (or './claude-gateway.mjs')." echo " If MANIFEST.sha256 uses the BSD form 'SHA256 (file) = hash', lists these files only under a" echo " subdirectory (e.g. 'dist/claude-gateway.mjs' — the copies C.1 installs would then be" echo " unverified), or omits them, that is a BUNDLE DEFECT — ask for a fresh bundle." elif command -v shasum >/dev/null 2>&1; then if ( cd "$BUNDLE" && printf '%s\n' "$MLINES" | shasum -a 256 -c - ); then INTEGRITY=yes; echo "INTEGRITY_OK" else echo "STOP: checksum mismatch or unreadable file — STALE OR CORRUPT BUNDLE, do not install"; fi elif command -v sha256sum >/dev/null 2>&1; then if ( cd "$BUNDLE" && printf '%s\n' "$MLINES" | sha256sum -c - ); then INTEGRITY=yes; echo "INTEGRITY_OK" else echo "STOP: checksum mismatch or unreadable file — STALE OR CORRUPT BUNDLE, do not install"; fi else echo "STOP: neither shasum nor sha256sum available — install one (Debian/Ubuntu: coreutils/libdigest-sha-perl) and re-run A0" fi for f in claude-gateway.mjs tmux-drive.mjs carapace-pair; do if [ -f "$BUNDLE/$f" ]; then echo "present: $f" elif [ -n "$DL_REFUSED" ]; then FILES=missing echo "STOP: $f is absent because the download was REFUSED ($DL_CODES) — that is a WAF/network" echo " refusal, NOT a bundle defect. Do not ask for a 'fresh bundle': sideload per steps 1-3." else FILES=missing echo "STOP: MISSING $f (download status:$DL_CODES) — if nothing refused the fetch and the network" echo " is healthy, this is a bundle defect: report it, and never substitute your own script." fi done # Persist the path ONLY when this bundle fully passed. C.1 reads this file and treats it as # "the checksum-verified bundle"; writing it earlier would make that guarantee a lie for # exactly the case that matters (a stale/corrupt bundle). if [ "$INTEGRITY" = yes ] && [ "$FILES" = ok ]; then printf '%s' "$BUNDLE" > "$HOME/.carapace/.bundle-path-claude"; chmod 600 "$HOME/.carapace/.bundle-path-claude" echo "BUNDLE_VERIFIED — path persisted for later phases: $HOME/.carapace/.bundle-path-claude" else # Also REVOKE any path remembered from an earlier run: if this bundle just failed, a stale # marker would let C.1 install from it anyway — the exact false guarantee we are closing. rm -f "$HOME/.carapace/.bundle-path-claude" echo "STOP: bundle NOT verified — the remembered path has been revoked, so C.1 will refuse to" echo " install. Re-run A0: it re-downloads the bundle from carapace.info and re-verifies it." echo " If it fails again, report it — do NOT install an unverified gateway." fi fi ``` `claude-gateway.mjs` statically imports `tmux-drive.mjs` — both are required, in the same directory, even though this setup runs `GW_TMUX=0`. `carapace-pair` is the standing re-pair command (C.1 installs it); it is not imported by anything and nothing here executes it. **VERIFICATION:** `platform:` printed and supported; `manifest lines matched: 3`; `INTEGRITY_OK`; all three files `present`; `BUNDLE_VERIFIED` (this is the marker C.1 depends on — the path is persisted **only** on this path); **no `STOP:` line anywhere in the output.** --- ### Phase A0.5 — existing install branch (fresh install vs upgrade) ```bash OS="$(uname -s)"; GW_DIR="$HOME/.carapace/claude-gateway" LBL=com.carapace.claude-gateway; UNIT=carapace-claude-gateway.service EXISTING=no [ -s "$GW_DIR/token" ] && EXISTING=yes if [ "$OS" = Darwin ]; then launchctl print "gui/$(id -u)/$LBL" >/dev/null 2>&1 && EXISTING=yes [ -f "$HOME/Library/LaunchAgents/$LBL.plist" ] && EXISTING=yes else export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" # Section 3(g): required to read state systemctl --user cat "$UNIT" >/dev/null 2>&1 && EXISTING=yes [ -f "$HOME/.config/systemd/user/$UNIT" ] && EXISTING=yes # true even if the bus is unreachable fi echo "existing CARAPACE Claude install detected: $EXISTING" [ -s "$GW_DIR/token" ] && echo "existing token: present (kept — a paired phone still holds it)" # `find` (not a shell glob): an unmatched glob aborts the block under zsh. find "$HOME/.carapace" -maxdepth 1 -name 'carapace-claude-pairing*.png' -print 2>/dev/null ``` **If `EXISTING=no`:** this is a fresh install. Continue normally. **If `EXISTING=yes`:** this is an **UPGRADE**, and it is fully supported: - The token is **kept** (C.2 only mints when the file is missing/empty). Re-minting would orphan the owner's already-paired phone. - Activation re-runs cleanly: macOS `bootout` then `bootstrap` (Section 3(b)); Linux `daemon-reload` then `restart` (Section 3(e)). - Port 18800 will be busy with our own supervised gateway — that is expected here, see A.3. - Only if the owner explicitly wants to **re-pair from scratch** do you rotate the token — see "Rotating the token" in C.2, and say plainly that every already-paired device stops working until it scans the new QR. **VERIFICATION:** the line `existing CARAPACE Claude install detected: yes|no` printed, and you have stated in your running report which path (fresh / upgrade) you are on. --- ### Phase A — inventory only (no changes yet) **A.1 Node ≥ 18 — asserted, and not version-manager scoped.** ```bash ACCEPT_VM_NODE=0 # <-- EDIT to 1 ONLY to knowingly accept a version-manager-scoped node path NODE="$(command -v node || true)" if [ ! -x "$NODE" ]; then echo "STOP: Node not installed. macOS: 'brew install node' or the pkg from nodejs.org." echo " Debian/Ubuntu: 'sudo apt-get install -y nodejs npm' (or NodeSource for >= 20). Fedora: 'sudo dnf install -y nodejs'." else echo "node: $("$NODE" -v) at $NODE" if "$NODE" -e 'process.exit(+process.versions.node.split(".")[0]>=18?0:1)'; then echo "NODE_VERSION_OK" else echo "STOP: Node major version < 18 — upgrade before continuing" fi case "$NODE" in */.nvm/*|*/.asdf/*|*/.volta/*|*/.fnm/*|*/n/versions/*|*/nodenv/*) if [ "$ACCEPT_VM_NODE" = 1 ]; then echo "NOTE: version-manager-scoped node path ACCEPTED on purpose (ACCEPT_VM_NODE=1): $NODE" echo " Record it in the Final Report: when this path disappears (a Node upgrade or prune)" echo " the supervisor will respawn a missing binary forever — gw.log fills with ENOENT and" echo " the phone dies silently months later. That is the accepted risk." echo "NODE_PATH_ACCEPTED" else echo "STOP: this node path is version-manager scoped ($NODE), and the supervisor pins node by" echo " absolute path. When the owner upgrades or prunes Node the path disappears and the" echo " supervisor respawns a missing binary forever (gw.log fills with ENOENT and the phone" echo " silently dies months later). This is a binding STOP — resolve it one of three ways:" echo " 1. Install a system Node (macOS brew/nodejs.org pkg; Linux distro or NodeSource) so that" echo " 'command -v node' resolves outside the version manager, then re-run A.1." echo " 2. Create a stable symlink the owner controls, e.g." echo " sudo ln -sfn \"$NODE\" /usr/local/bin/node-carapace" echo " then put THAT path on the NODE_OVERRIDE EDIT line inside C.4(a)/C.4(b) (each fence has" echo " one) so the supervisor pins the stable path. Re-run A.1 to confirm nothing else changed." echo " 3. Accept the risk knowingly: set ACCEPT_VM_NODE=1 on the EDIT line at the top of A.1," echo " re-run A.1, and record NODE_PATH_ACCEPTED in the Final Report." fi ;; *) echo "node path is stable (not version-manager scoped)"; echo "NODE_PATH_OK" ;; esac fi ``` Every later block re-derives `NODE` with `command -v node`, which is safe for the parsing helpers (they use node for a few milliseconds and are re-derived every time). The one place the path is **pinned into a file that outlives the shell** is C.4 — so if you took the stable-symlink route, put that absolute path on the `NODE_OVERRIDE=""` EDIT line inside C.4(a) or C.4(b). Do not try to `export NODE=…` for a later block: each block re-derives it on its first line, which would overwrite your export. **A.2 Find the owner's `claude` binary — dueling installs are NORMAL.** ```bash mkdir -p "$HOME/.carapace"; chmod 700 "$HOME/.carapace" REPIN=0 # <-- EDIT to 1 ONLY to deliberately discard an existing pin and re-choose ACCEPT_SHIM=0 # <-- EDIT to 1 ONLY to knowingly pin a tiny wrapper this block refuses (see D-1 gate) CF="$HOME/.carapace/.claude-candidates" { command -v claude 2>/dev/null || true echo "$HOME/.claude/local/claude" echo "$HOME/.local/bin/claude" echo "$HOME/.npm-global/bin/claude" echo "/opt/homebrew/bin/claude" echo "/usr/local/bin/claude" echo "/usr/bin/claude" # `claude` is often a shell alias/function (Claude Code's local installer), which `command -v` # reports as a name, not a path. Resolve it. Profile noise here is harmless. for sh in zsh bash; do if command -v "$sh" >/dev/null 2>&1; then "$sh" -ic 'type -f claude' 2>/dev/null | grep -oE '/[^ "]*/claude' | head -1 fi done } > "$CF" CNT=0; FIRST=""; SEEN=""; FSZ=""; FOWN=""; BIG=""; BIGSZ=0; ME="$(id -un)" # read from a file, not `for c in $CANDS`: zsh does not word-split unquoted variables, # and a `while read` pipeline would lose CNT/FIRST to a subshell. while read -r c; do [ -n "$c" ] && [ -x "$c" ] || continue case " $SEEN " in *" $c "*) continue ;; esac SEEN="$SEEN $c" CV="$("$c" --version 2>/dev/null | head -1)" if [ -z "$CV" ]; then echo "candidate IGNORED (does not answer --version): $c"; continue; fi # 2026-07-30 audit D-1: IDENTITY, not just "it answered". Anything on PATH can be named # `claude` and print something; only output that carries "Claude Code" or a bare semver is # this CLI. A non-matching binary is never counted and never pinned. case "$CV" in *"Claude Code"*) ;; [0-9]*.[0-9]*.[0-9]*) ;; *) echo "candidate IGNORED (--version output is not Claude Code: '$CV'): $c"; continue ;; esac # 2026-07-30 audit D-1: size + owner are the evidence that separates the real CLI from a # wrapper someone else installed. Print both for every candidate, always. CSZ="$(wc -c < "$c" | tr -d ' ')"; COWN="$(ls -ld "$c" 2>/dev/null | awk '{print $3}')" echo "candidate: $c -> $CV ($CSZ bytes, owner ${COWN:-unknown})" CNT=$((CNT+1)) if [ -z "$FIRST" ]; then FIRST="$c"; FSZ="$CSZ"; FOWN="$COWN"; fi # remember the largest candidate: on a dueling-installs machine that is the real CLI # (npm-global / native install), and it is what the D-1 gate recommends pinning instead. if [ "$CSZ" -gt "$BIGSZ" ]; then BIGSZ="$CSZ"; BIG="$c"; fi done < "$CF" rm -f "$CF" echo "runnable claude candidates: $CNT" PINNED="$(cat "$HOME/.carapace/.claude-bin" 2>/dev/null || true)" # ORDER MATTERS: test the existing pin BEFORE the "nothing found" branch. The candidate list above # only scans seven well-known locations plus PATH, and A.2 explicitly invites the owner to pin a # binary that lives somewhere else. On a second run of a WORKING install, that pin must not be # diagnosed as "Claude Code is not installed". if [ "$REPIN" != 1 ] && [ -n "$PINNED" ] && [ -x "$PINNED" ] && "$PINNED" --version >/dev/null 2>&1; then # SECOND RUN: an existing, still-working pin is the owner's decision. Never silently revert it # to PATH-first — on a dueling-installs machine that switches which Claude the lanes drive. echo "CLAUDE_BIN=$PINNED (EXISTING PIN KEPT — not overwritten)" echo "CLAUDE_BIN_PINNED" if [ "$CNT" -eq 0 ]; then echo "NOTE: the pin is outside every location A.2 scans and 'claude' is not on this shell's PATH." echo " That is NOT a problem: the pin is what C.4 writes into the supervisor, and it just" echo " answered --version above. (It was verified directly, not via the candidate scan.)" elif [ -n "$FIRST" ] && [ "$PINNED" != "$FIRST" ]; then echo "NOTE: the kept pin differs from the first PATH candidate ($FIRST). That is fine and deliberate." fi echo " To re-choose deliberately: set REPIN=1 on the EDIT line at the top of A.2 and re-run it," echo " or pin directly: printf '%s' ${BIG:-/full/path/to/claude} > ~/.carapace/.claude-bin && chmod 600 ~/.carapace/.claude-bin" [ -n "$BIG" ] && echo " (that path is filled in for you — paste the line above as-is)" elif [ "$CNT" -eq 0 ]; then if [ -n "$PINNED" ]; then echo "NOTE: a pin exists ($PINNED) but it is not executable or no longer answers --version." fi echo "STOP: no runnable 'claude' found. The owner must install Claude Code" echo " (https://claude.com/claude-code) and run 'claude' once to log in, then re-run A.2." echo " If their claude lives somewhere unusual, pin it directly instead and re-run A.2:" echo " printf '%s' ${BIG:-/full/path/to/claude} > ~/.carapace/.claude-bin && chmod 600 ~/.carapace/.claude-bin" else [ -n "$PINNED" ] && [ "$REPIN" = 1 ] && echo "NOTE: REPIN=1 — replacing the previous pin ($PINNED) on purpose." [ -n "$PINNED" ] && [ "$REPIN" != 1 ] && echo "NOTE: the previous pin ($PINNED) no longer runs — replacing it." # 2026-07-30 audit D-1: STRAY-BINARY GATE — runs BEFORE the pin file is written, because a # pin written first is a pin already adopted. On the audited host `command -v claude` resolved # a 59-byte ROOT-OWNED /usr/local/bin/claude that exec'd the owner's real ~/.local/bin/claude, # and this block adopted it silently: it answered --version, so nothing warned. A wrapper # someone else owns can be re-pointed at a different Claude at any time, and every lane on # this machine would follow it. ADOPT=1 if [ "${FSZ:-0}" -lt 4096 ]; then # Only ever read a SMALL file to find its target — never grep a 200MB native binary. SHIMTGT="$(readlink "$FIRST" 2>/dev/null || true)" [ -n "$SHIMTGT" ] || SHIMTGT="$(grep -oE '/[^ "]*/claude' "$FIRST" 2>/dev/null | head -1 || true)" echo "EVIDENCE: chosen candidate is a wrapper, not the CLI itself" echo " path: $FIRST" echo " size: ${FSZ:-unknown} bytes owner: ${FOWN:-unknown} this user: $ME" echo " execs: ${SHIMTGT:-could not resolve — read it yourself: cat '$FIRST'}" [ -n "$BIG" ] && [ "$BIG" != "$FIRST" ] && echo " larger candidate seen: $BIG ($BIGSZ bytes) — that is the shape of a real install" if [ "${FOWN:-}" != "$ME" ] && [ "$ACCEPT_SHIM" != 1 ]; then ADOPT=0; fi fi if [ "$ADOPT" != 1 ]; then echo "STOP: refusing to pin '$FIRST' — a ${FSZ}-byte wrapper owned by '${FOWN:-unknown}', not by" echo " $ME, that PATH happens to resolve first. It answers --version, so the old" echo " check passed it; that is exactly the silent-adoption this gate exists to stop." echo " Nothing was pinned and nothing was installed. Resolve it one of two ways:" echo " 1. Pin the owner's real binary (recommended) — from the candidate lines above," echo " normally the largest one${BIG:+, here $BIG}:" echo " printf '%s' ${BIG:-/full/path/to/claude} > \"\$HOME/.carapace/.claude-bin\" && chmod 600 \"\$HOME/.carapace/.claude-bin\"" [ -n "$BIG" ] && echo " (that path is filled in for you — paste the line above as-is)" echo " then re-run A.2 (it keeps a working pin)." echo " 2. Adopt the wrapper knowingly: confirm with the owner that ${SHIMTGT:-its target} is" echo " the Claude they use, set ACCEPT_SHIM=1 on the EDIT line at the top of A.2," echo " re-run A.2, and record the decision in the Final Report." else [ "${FSZ:-0}" -lt 4096 ] && [ "$ACCEPT_SHIM" = 1 ] && echo "NOTE: ACCEPT_SHIM=1 — the wrapper above is being pinned on purpose. Record it in the Final Report." printf '%s' "$FIRST" > "$HOME/.carapace/.claude-bin"; chmod 600 "$HOME/.carapace/.claude-bin" echo "CLAUDE_BIN=$FIRST (persisted to ~/.carapace/.claude-bin for C.4)" echo "CLAUDE_BIN_PINNED" if [ "$CNT" -gt 1 ]; then echo "NOTE: $CNT runnable claude binaries exist (npm-global + native + shims is a normal machine)." echo " Ask the owner which one they actually use. If it is not the one above, pin it:" echo " printf '%s' ${BIG:-/full/path/to/claude} > ~/.carapace/.claude-bin && chmod 600 ~/.carapace/.claude-bin" echo " A later re-run of A.2 KEEPS that pin (it only re-chooses if the pin stops running)." fi fi fi ``` Notes: a very small candidate (a few dozen bytes) is a shell shim. **It is no longer adopted on the strength of `--version` alone** (2026-07-30 audit D-1): the block prints the wrapper's size, its owner and the path it `exec`s, and if that wrapper is owned by **someone other than the person running this document** it `STOP:`s instead of pinning it — a root-owned shim in `/usr/local/bin` can be re-pointed at a different Claude at any time, and every lane would follow it. A wrapper the owner owns themselves (Claude Code's own local installer writes one) is a NOTE, not a STOP. To adopt a foreign wrapper deliberately, set `ACCEPT_SHIM=1` and say so in the Final Report. Paths containing spaces are not supported; move or symlink such a binary. Identity here is "it runs, reports a version that looks like Claude Code, and is not an unexplained foreign wrapper"; the functional proof that it can actually answer a turn is **E.3**, and the cheap early read on whether it is even logged in is **A.2b**. **A.2b Is that `claude` actually LOGGED IN? — cheap probe, non-fatal, run BEFORE anything installs.** Prerequisite 1 says "installed **and** logged in", but `--version` answers happily on a logged-out CLI, so until 2026-07-30 nothing tested the second half until **E.3** — after Phase C had written files, Phase E had activated a supervisor and Tailscale Serve had published the route. The audited host finished that whole sequence and was left live, supervised, exposed and pairable while every turn returned `Not logged in · Please run /login`. This block asks the question first. It is a **NOTE**, never a `STOP:` — a credential-less box is a legitimate thing to set up, as long as it is set up *knowingly* (see "the no-auth path" below). ```bash # 2026-07-30 audit D-7: login is checked HERE, not first discovered at E.3. One tiny turn. umask 077 CB="$(cat "$HOME/.carapace/.claude-bin" 2>/dev/null || true)" if [ -z "$CB" ] || [ ! -x "$CB" ]; then echo "STOP: no usable pin in ~/.carapace/.claude-bin — A.2 has to pass before A.2b can run" else D="$HOME/.carapace"; OUTF="$D/.login-probe"; PIDF="$D/.login-probe.pid"; DONEF="$D/.login-probe.done" rm -f "$OUTF" "$PIDF" "$DONEF" # No GNU `timeout` on macOS (and none is assumed anywhere in this document), so the probe is # backgrounded and polled against a sentinel FILE. Do NOT switch this to `kill -0 $!`: a # finished-but-unreaped child is still a zombie that `kill -0` reports as alive, which would # burn the full wait on every healthy host. The inner pid is recorded so a hung probe can be # killed precisely instead of by pattern. # `< /dev/null` is REQUIRED, not tidiness: `claude -p` reads stdin, and with no redirect it # stalls ~3s and prints "Warning: no stdin data received in 3s, proceeding without it" as its # FIRST line — which would then be reported as the answer instead of the real message. ( "$CB" -p 'Reply with exactly: ping' < /dev/null > "$OUTF" 2>&1 & echo $! > "$PIDF"; wait; echo done > "$DONEF" ) & N=0; while [ "$N" -lt 60 ] && [ ! -f "$DONEF" ]; do sleep 1; N=$((N+1)); done if [ ! -f "$DONEF" ]; then CPID="$(cat "$PIDF" 2>/dev/null || true)" if [ -n "$CPID" ]; then kill "$CPID" 2>/dev/null; sleep 1; kill -0 "$CPID" 2>/dev/null && kill -9 "$CPID" 2>/dev/null fi echo "NOTE: CLAUDE_LOGIN_UNPROVEN — the probe did not finish in ${N}s; probe pid ${CPID:-unknown} was killed." echo " Not a verdict either way. E.3 remains the authoritative test. If that pid had" echo " children of its own they are not chased here — check with 'ps' if the box feels busy." else # Report the first line that actually says something: any stdin warning is noise, not a verdict. L1="$(grep -v 'no stdin data received' "$OUTF" 2>/dev/null | sed '/^[[:space:]]*$/d' | head -1 | tr -d '\r')" ALL="$(head -5 "$OUTF" 2>/dev/null | tr -d '\r' | tr '\n' ' ')" case "$ALL" in *"Not logged in"*|*"Please run /login"*|*"Invalid API key"*|*"authentication_error"*|*"OAuth token"*|*"credit balance"*) echo "NOTE: CLAUDE_NOT_LOGGED_IN — the pinned claude answered, but not with a turn:" echo " ${L1:-}" echo " This is the SAME message E.3 would surface hours from now, after this machine" echo " is installed, supervised and published on the tailnet. Fix it now if you can:" echo " run '$CB' once in a terminal and complete the login, then re-run A.2b." echo " If you cannot (headless, no browser, no owner present), read 'the no-auth path'" echo " below BEFORE running Phase C, and carry that decision into the Final Report." ;; *) if [ -z "$L1" ]; then echo "NOTE: CLAUDE_LOGIN_UNPROVEN — the probe produced no usable output. E.3 is the real gate." else echo "CLAUDE_LOGIN_OK (a real turn came back — first line: $L1)" fi ;; esac fi rm -f "$OUTF" "$PIDF" "$DONEF" fi ``` Read it: - `CLAUDE_LOGIN_OK` → credentials work **on this machine, as this user**. Continue. - `NOTE: CLAUDE_NOT_LOGGED_IN` → do not expect `SMOKE_OK` at E.3. Resolve it now, or take the no-auth path deliberately. - `NOTE: CLAUDE_LOGIN_UNPROVEN` → nothing was proven; neither pass nor fail. Continue and let E.3 decide. **The no-auth path — installing on a credential-less box, honestly.** There are three ways to give the gateway working credentials, and the first two are the only ones that need no browser on this machine: 1. **`claude setup-token`** — run it **as the same user, on this machine** (`"$(cat ~/.carapace/.claude-bin)" setup-token`). It sets up a long-lived authentication token (it requires a Claude subscription) and stores it in that user's own credential store, which is exactly where the lanes will look. The flow still needs a browser once, but only once, and what it leaves behind outlives any interactive session — which is the whole difference between this and "run `claude` and log in". This is the headless-friendly route; prefer it. 2. **`ANTHROPIC_API_KEY`** in the supervisor's environment — plist `EnvironmentVariables` / unit `Environment=`, set exactly the way C.4's `GW_MODELS` note shows, then applied with the Section-3 reload pair. **Read this before you do it:** a supervisor definition is *not* a 0600 file (`~/.config/systemd/user/*.service` is world-readable by default), so an API key put there is readable by every local user — House rule 1's reasoning applied to a file instead of a command line. If the owner accepts that, `chmod 600` the unit/plist and say so in the Final Report; if they do not, use route 1 or 3. 3. **A human logs in once** — `claude` interactively on this machine — which is what Prerequisite 1 has always meant. If none of those happens, the install can still be completed and left **BLOCKED at E.3** on purpose. In that case, and only in that case, these acceptance items are recorded as **BLOCKED / N-A** rather than PASS — never as PASS: - **BLOCKED:** "E.3 printed `SMOKE_OK`" — it will print `STOP:` instead (see E.3's second-failure gate). The E.3 cleanup reload still runs and still must print `health after reap -> 200`. - **N-A:** every Phase F item (paired / runtime shows Claude / picker populated / one real reply / pearl / QR deleted). **Do not pair a gateway that cannot answer a turn** — the owner would scan a QR, hand a live token to a phone, and get an error on every message. - Everything else on the checklist (integrity, permissions, supervision, respawn, the Phase D and E.6 auth triads, `FUNNEL_OFF`, `CATALOG_OK`) is unaffected and must still PASS: none of it depends on credentials. The catalog is served from the gateway's own table, so `CATALOG_OK` on a logged-out box is real — and is *not* evidence that a turn would work. The Final Report must say `smoke turn (E.3): BLOCKED — claude not logged in` and list the login as the outstanding owner action. An install reported any other way is a false pass. **A.3 Ports 18800 (gateway) and 18899 (Phase D scratch).** ```bash show_port() { SEEN=""; TOOL=yes # SEEN non-empty ⇒ something IS listening, whoever owns it # macOS keeps lsof in /usr/sbin, which some non-login shells drop from PATH. Look there before # concluding the tool is absent — otherwise a perfectly equipped Mac takes the "cannot prove" path. case ":$PATH:" in *:/usr/sbin:*) : ;; *) PATH="$PATH:/usr/sbin:/sbin" ;; esac # 2026-08-22 fresh-install audit (wo_20260821w): `ss` FIRST on Linux, matching codex.md A.4 and # hermes.md A.3. This used to be lsof-first, and the consequences were measured on a live box: # an UNPRIVILEGED `lsof` cannot see another user's socket at all, so `lsof … || echo "$1 free"` # printed "18800 free" while a foreign gateway was answering /health 200 on that very port — # a flat falsehood with no STOP: behind it. Unprivileged `ss` still SEES the socket (it only # blanks the process column), so it can at least prove occupancy. The lsof branch, with its # argv printing, remains the macOS path and the Linux fallback. if [ "$(uname -s)" != Darwin ] && command -v ss >/dev/null 2>&1; then SEEN="$(ss -lnt "sport = :$1" 2>/dev/null | grep ":$1" || true)" if [ -n "$SEEN" ]; then ss -lntp "sport = :$1" 2>/dev/null echo "NOTE: this is the 'ss' branch. A row whose process column is EMPTY belongs to another" echo " user and cannot be attributed from here. Before acting on it, re-run elevated:" echo " sudo ss -lntp \"sport = :$1\" (or: sudo lsof -nP -iTCP:$1 -sTCP:LISTEN)" echo " A wildcard local address (0.0.0.0:$1 / [::]:$1 / *:$1) in that table is somebody" echo " else's exposed service — House rule 6 forbids us from ever creating one." fi elif command -v lsof >/dev/null 2>&1; then SEEN="$(lsof -nP -iTCP:"$1" -sTCP:LISTEN 2>/dev/null || true)" if [ -n "$SEEN" ]; then printf '%s\n' "$SEEN" # wo_20260811: lsof truncates COMMAND to 9 chars and prints no argv, so the A.3 branch # ("the command line shows node .../claude-gateway.mjs") cannot be answered from the # table above. Print the real command line for every listener it found. for P in $(lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null); do echo " pid $P cmdline: $(ps -o command= -p "$P" 2>/dev/null | head -1)" done fi else TOOL=no echo "NOTE: neither lsof nor ss present — install one (Debian/Ubuntu: sudo apt-get install -y iproute2 lsof) before E.1c" fi # CROSS-CHECK, and it is the part that makes "free" mean something. A listener table is a # PRIVILEGE-LIMITED view; an HTTP answer is not. If nothing was visible but something answers, # the port is occupied by a process this account cannot see — and that must be a STOP:, never # the cheerful "free" that a fresh install would happily bind on top of and crash-loop against. PH="$(curl -s -m 3 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$1/health")" if [ -n "$SEEN" ]; then echo "$1 OCCUPIED (listener above; http://127.0.0.1:$1/health -> $PH)" elif [ "$TOOL" = no ] && [ "$PH" = 000 ]; then echo "STOP: port occupancy on $1 CANNOT be proven — no lsof and no ss on this host, and 'nothing" echo " answered /health' is not the same as 'nothing is bound'. 'I could not look' is never" echo " 'free'. Install lsof or iproute2 and re-run A.3 before anything binds this port." elif [ "$PH" != 000 ]; then echo "STOP: $1 reports FREE in the listener table but http://127.0.0.1:$1/health ANSWERED $PH." echo " The table is wrong, not the answer: an unprivileged lsof/ss cannot see a socket" echo " owned by another user or by root. Treat $1 as HELD BY A FOREIGN PROCESS. Identify" echo " it with 'sudo ss -lntp \"sport = :$1\"' and resolve it with the owner — do not" echo " install on top of it, and never read that 200 as ours." else echo "$1 free (nothing listening AND nothing answers on /health)" fi } show_port 18800 show_port 18899 ``` Branch on what you see for **18800**: - **free** → good, fresh install path. - **occupied by our own gateway** (the command line shows `node …/.carapace/claude-gateway/claude-gateway.mjs`, and A0.5 said `EXISTING=yes`) → expected on an upgrade. Continue; E.1 replaces it. - **occupied by anything else** (another CARAPACE install, an old hand-started copy, an unrelated service) → `STOP: 18800 is held by a foreign process`. Do not proceed: every later `curl http://127.0.0.1:18800/health` would be answered by the squatter while our supervised job crash-loops on `EADDRINUSE`. Resolve it **in this order** (2026-07-30 audit D-2 — the first step used to be missing, and without it the second one cannot be carried out at all): 1. **Get a name you can act on.** If the output above came from the `ss` branch and its process column is empty, you do not yet know whose socket it is — re-run elevated (`sudo ss -lntp "sport = :18800"` / `sudo lsof -nP -iTCP:18800 -sTCP:LISTEN`). An unattributed socket is never a licence to kill something. 2. **Get the owner's decision**, then evict it with the guarded fence below — or leave it and stop. Never install "alongside" it. **"free" is weaker evidence than it looks — read this before trusting it.** Unprivileged `lsof` and `ss -lntp` do **not** list sockets owned by *other users*, so on a multi-user box a squatter belonging to another account prints as `18800 free`. Two cheap cross-checks, and you should do both on any host with more than one human on it: - Re-run the check with elevated rights: `sudo lsof -nP -iTCP:18800 -sTCP:LISTEN` (or `sudo ss -lntp "sport = :18800"`). Only that output can honestly say "nobody is listening". - Treat **`18800 free` together with a live answer** as proof of a foreign squatter: `curl -s -m 5 -o /dev/null -w "health -> %{http_code}\n" http://127.0.0.1:18800/health` — anything other than `000` means something IS on that port that you cannot see. That is the same reasoning E.2 step 2 uses, and it is a `STOP:` here for the same reason. Also note this for E.1c: assertion 3 there compares the listening pid with the supervised pid, and an **empty** listener table (unprivileged `lsof` against another user's socket, or neither tool installed) gives it nothing to compare — an empty table is "unproven", never "passed". **Evicting a foreign holder of 18800 — the guarded fence (2026-07-30 audit D-6).** "Remove/stop it" was, until this revision, the only destructive action this document recommended without a fence — every other one (stale route, root route, uninstall) has an EDIT-line confirmation and a checked exit status. This is that fence. It refuses by default, refuses on anything it cannot attribute, and refuses on our own supervised gateway (that is a reload, not a kill). ```bash EVICT_18800=0 # <-- EDIT to 1 ONLY after the owner has decided this process should be stopped PID_18800="" # <-- EDIT to the pid from A.3's output (elevated if the process column was empty) if [ "$EVICT_18800" != 1 ]; then echo "NOTE: eviction fence is OFF (EVICT_18800=0). Nothing was stopped. This is the default." elif [ -z "$PID_18800" ]; then echo "STOP: EVICT_18800=1 but PID_18800 is empty — an unattributed socket is never a licence to" echo " kill something. Re-run A.3's check elevated, fill in the pid, then re-run this fence." elif ! kill -0 "$PID_18800" 2>/dev/null; then echo "STOP: pid $PID_18800 does not exist or is not yours to signal (try elevated). Nothing was stopped." else CMD="$(ps -o command= -p "$PID_18800" 2>/dev/null | head -1)" echo "EVICTION TARGET: pid $PID_18800" # No apostrophe inside this ${VAR:-default}: a single quote in a default value parses fine in # zsh and is a hard syntax error in bash ("unexpected EOF while looking for matching quote"). echo " command: ${CMD:-}" case "$CMD" in *"/.carapace/claude-gateway/claude-gateway.mjs"*|*"claude-gateway.mjs"*) echo "STOP: that is OUR OWN gateway, not a foreign squatter. Killing it is never the answer —" echo " use the Section-3 reload pair (E.1a/E.1b) instead. Nothing was stopped." ;; *) kill "$PID_18800" 2>/dev/null; RC=$? sleep 3 if [ "$RC" != 0 ]; then echo "STOP: kill exited $RC — the process is not yours to signal. Re-run elevated. Nothing changed." elif kill -0 "$PID_18800" 2>/dev/null; then echo "STOP: pid $PID_18800 ignored SIGTERM and is still alive. Do NOT escalate to -9 blindly:" echo " a supervised service (launchd/systemd) will simply respawn, and you will fight it" echo " forever. Find its supervisor and disable that instead, then re-run A.3." else echo "EVICTED (pid $PID_18800 gone, SIGTERM, exit status checked)" curl -s -m 5 -o /dev/null -w "18800 answers -> %{http_code} (000 = nothing there, which is the goal)\n" http://127.0.0.1:18800/health echo "Re-run A.3 to confirm the port reads free, then continue." fi ;; esac fi ``` **There is no port escape hatch, and that is a documented limitation, not an oversight (2026-07-30 audit D-6).** `18800` is written literally in ~66 places in this document. The supervisor *does* pass the port as an environment variable (plist `PORT` / unit `Environment=PORT=18800`, and Phase D proves the gateway honours it via `SCRATCH_PORT`), so the gateway itself is not the obstacle — every probe, failure row and serve target here is. The only EDIT-able ports are Phase D's `SCRATCH_PORT` and E.5's read-only `ROOT_PORT`. So an owner who legitimately runs something else on 18800 has exactly two supported options: move **that** service, or evict it with the fence above. Moving CARAPACE instead is an unsupported deviation — if the owner insists, it means changing `PORT` in the supervisor, re-pointing the `/carapace-claude` serve route, and replacing every `18800` in this document with the new port before running any of it, and the result must be recorded in the Final Report as a deviation. Do not do it halfway: a document that probes 18800 while the gateway listens elsewhere passes nothing and diagnoses everything wrongly. If **18899** is occupied, Phase D must use another free port. You cannot prefix an assignment onto a multi-line block, and (House rule 9) an `export` from this shell does not survive into Phase D's shell — so **edit the default inside Phase D's own fence**: change the line `SCRATCH_PORT="${SCRATCH_PORT:-18899}"` to `SCRATCH_PORT="${SCRATCH_PORT:-18898}"` (or any port `show_port` reports free), then run the fence. Phase D's diagnostics use that same variable, so nothing else needs changing. **A.4 Tailscale — liveness, not mere presence. PROBE ONLY: nothing in Phase A or Phase C installs it** (`wo_20260825ae` — E.4a0 does, and only on option 3). ```bash NODE="$(command -v node || true)" if [ ! -x "$NODE" ]; then echo "STOP: node not found — re-run A.1 first; every probe below parses JSON with node" elif command -v tailscale >/dev/null 2>&1; then echo "tailscale CLI: $(command -v tailscale) $(tailscale version 2>/dev/null | head -1) (version is GATED in C.3(d), not here)" tailscale status --json 2>/dev/null | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);console.log("BackendState="+j.BackendState);console.log("host="+String((j.Self&&j.Self.DNSName)||"").replace(/\.$/,""));console.log("tailnet="+((j.CurrentTailnet&&j.CurrentTailnet.Name)||j.MagicDNSSuffix||"unknown"));const u=(j.User&&j.Self&&j.User[j.Self.UserID])||{};console.log("login="+(u.LoginName||"unknown"));}catch(e){console.log("BackendState=UNKNOWN (tailscaled not answering)")}})' else echo "TS_ABSENT — tailscale is not installed on this box, and that is a normal, passing outcome." echo " NOTHING here installs it. The client is installed ONLY if E.4.2 records" echo " REACH_MODE=tailscale, and only by E.4a0, which runs after that decision exists." echo " On options 1, 2 and 4 this line is the end of the matter — do not install anything." fi ``` Read it as follows: - `TS_ABSENT` (no CLI at all) → **do nothing**. This is the majority case on a fresh box and it is not a defect: E.4.2 may well choose LAN, the owner's own proxy, or a public address, and then this machine finishes the install with no Tailscale on it that it did not already have. Only `REACH_MODE=tailscale` sends you to E.4a0. - `BackendState=Running` → signed in and up. E.4 will **skip** `tailscale up`. - `BackendState=NeedsLogin` / `Stopped` → installed but not usable yet; E.4a handles it. - `BackendState=UNKNOWN (tailscaled not answering)` → the CLI exists but no daemon. On **macOS** this is the classic Homebrew trap: `brew install tailscale` ships only the CLI and `tailscale serve` cannot work. If `command -v tailscale` resolves under `/opt/homebrew/…` or `/usr/local/Cellar/…` and `/Applications/Tailscale.app` does not exist, that is a real problem **only if E.4.2 later chooses option 3** — in which case E.4a0(a) installs the standalone app. Do not install it here to make this line look tidy. When you do, be aware two `tailscale` binaries then exist and PATH order decides which you get; prefer `/usr/local/bin/tailscale` from the app. On **Linux** it means `tailscaled` is not running: `sudo systemctl enable --now tailscaled`. **VERIFICATION:** `NODE_VERSION_OK` plus either `NODE_PATH_OK` (stable path) or `NODE_PATH_ACCEPTED` (version-manager path accepted on purpose via `ACCEPT_VM_NODE=1`, recorded in the Final Report); `CLAUDE_BIN_PINNED` (a pin written, or an existing owner pin explicitly kept) — and if A.2 printed an `EVIDENCE:` block, the pin was either owner-owned or knowingly accepted with `ACCEPT_SHIM=1`, never adopted silently (2026-07-30 audit D-1); **one of A.2b's three markers recorded** — `CLAUDE_LOGIN_OK`, `NOTE: CLAUDE_NOT_LOGGED_IN` or `NOTE: CLAUDE_LOGIN_UNPROVEN` (audit D-7: none of them blocks, but a `CLAUDE_NOT_LOGGED_IN` that is not carried into the no-auth decision is how a machine ends up installed, exposed and unable to answer); 18800 free **or** identified as our own; 18899 free (or a substitute port chosen and edited into Phase D's fence); Tailscale either `TS_ABSENT` (nothing to classify, nothing to do) **or** its `BackendState` known and classified. `TS_ABSENT` ticks this item exactly as well as `Running` does. The printed Tailscale **version** is informational here — the single binding version rule is the gate in **C.3(d)**, and it only ever binds option 3; do not compare versions by eye. --- ### Phase B — the disclosures the owner must receive Tell the owner plainly and get a yes to all four: 1. "I will set up **one way for your phone to reach this box**. Phase E.4 reads what this machine already has and picks from four: **[1] same Wi-Fi / LAN** (no extra setup), **[2] a domain, tunnel or reverse proxy you already run**, **[3] Tailscale** — recommended for reach-it-from-anywhere — or **[4] a direct public IP**, where I set up **free HTTPS for you automatically**: caddy, the free hostname `.sslip.io`, and a real certificate that renews itself, so your phone talks to `https://…` and never cleartext. Tailscale and caddy are the only things I install, and only for the option that needs one. I record which one you got and tell you." 2. "I will expose **only** the Claude gateway's local port over that one route — no other service on this machine, and never Tailscale Funnel / the open public internet. On option 1 the listener moves off loopback onto your LAN address, so anything on that Wi-Fi can *reach* the port. On option 4 the gateway stays on loopback and a caddy HTTPS front answers on the public interface, so anything on the internet can reach *that* — over TLS, with a real certificate I get for you. Plain `http` on a public address happens only if `REACH_INSECURE_HTTP=yes` is set deliberately, and then the token crosses the internet in cleartext. The bearer token is required on every request, in every option. If this machine runs a firewall (ufw / firewalld / nftables), I will open **exactly the port(s) that one route needs — nothing else** — using its own tool, and the Final Report names every rule I added so you can remove them with one command each." 3. "I will store a secret token locally for the phone-to-Mac link, and I will register the gateway as a **user service** (systemd --user on Linux, launchd on macOS) so it restarts on reboot — the unit file path is printed and removing it is one command" (and, on a fresh install, "I will generate it now"). 4. "A paired device drives a lane with YOUR Claude's own permissions — it can read files in your home folder (including secrets like `~/.ssh/` and `~/.aws/`), and do whatever else your Claude configuration allows, including running commands and editing files if you have allowed that. OK?" **Tailscale is a recommendation, not a prerequisite.** If the owner declines it, use one of the other three routes. Stop only if they decline **every** route — then the phone genuinely has no way in and there is nothing to pair. **Unattended / agent-driven installs:** if the owner is not interactively present, **pasting this prompt into their agent constitutes acceptance of the stated defaults.** Do not block — instead print all four disclosures **verbatim** in the Final Report, so the record exists where the owner will read it. Never silently drop them. **VERIFICATION:** disclosures **delivered** — either acknowledged in conversation, or queued verbatim for the Final Report. State which. --- ### Phase C — install (nothing is activated yet) **C.1 Install the gateway files.** ```bash GW_DIR="$HOME/.carapace/claude-gateway" BUNDLE="$(cat "$HOME/.carapace/.bundle-path-claude" 2>/dev/null || true)" if [ -z "$BUNDLE" ] || [ ! -f "$BUNDLE/claude-gateway.mjs" ] || [ ! -f "$BUNDLE/tmux-drive.mjs" ] || [ ! -f "$BUNDLE/carapace-pair" ]; then echo "STOP: verified bundle path unknown or wrong (BUNDLE='$BUNDLE') — re-run Phase A0, which persists it" else mkdir -p "$HOME/.carapace" "$GW_DIR" chmod 700 "$HOME/.carapace" "$GW_DIR" # gw.log holds a token prefix + message previews install -m 0700 "$BUNDLE/claude-gateway.mjs" "$GW_DIR/claude-gateway.mjs" install -m 0700 "$BUNDLE/tmux-drive.mjs" "$GW_DIR/tmux-drive.mjs" # The standing re-pair command. It is a tool the OWNER runs later, not part of the gateway, # so it goes in a bin dir and is 0755 (readable/executable, carries no secret of its own). mkdir -p "$HOME/.carapace/bin" install -m 0755 "$BUNDLE/carapace-pair" "$HOME/.carapace/bin/carapace-pair" echo "--- parent directory (must be drwx------ = 0700) ---" ls -ld "$HOME/.carapace" echo "--- gateway directory + installed files ---" ls -ld "$GW_DIR" ls -l "$GW_DIR" echo "--- re-pair command ---" ls -l "$HOME/.carapace/bin/carapace-pair" fi ``` Reading the path back from `~/.carapace/.bundle-path-claude` is what guarantees the installed files are the **checksum-verified** ones: A0 writes that file **only** on its `BUNDLE_VERIFIED` path (after `INTEGRITY_OK` **and** both files present), so its existence is the proof — never re-type the path here. The two `ls -ld` lines above are the evidence for the acceptance-checklist item about `~/.carapace` and `~/.carapace/claude-gateway` both being 0700; read them, do not assume them. `~/.carapace/bin/carapace-pair` is the ten-second re-pair command: run it on this machine any time the phone stops being able to reach this gateway, and it reprints the pairing QR from the token and the reachability record that are already on disk — no part of this document has to be re-run. **Invoke it by its full path** (`~/.carapace/bin/carapace-pair`); nothing here puts `~/.carapace/bin` on `PATH`, and telling the owner to type `carapace-pair` on a machine where that resolves to nothing is a worse failure than the one they started with. Tell them the path in the Final Report. **C.2 Ensure a bearer token exists (never printed, never clobbered).** ```bash umask 077 GW_DIR="$HOME/.carapace/claude-gateway"; NODE="$(command -v node || true)" mkdir -p "$GW_DIR"; chmod 700 "$HOME/.carapace" "$GW_DIR" if [ ! -s "$GW_DIR/token" ]; then if command -v openssl >/dev/null 2>&1; then openssl rand -hex 32 > "$GW_DIR/token" elif [ -x "$NODE" ]; then "$NODE" -e 'process.stdout.write(require("crypto").randomBytes(32).toString("hex"))' > "$GW_DIR/token" else echo "STOP: neither openssl nor node available to mint a token — re-run A.1" fi # Say "minted" only if a non-empty file actually exists now — a claim of a write is not a write. if [ -s "$GW_DIR/token" ]; then echo "token: newly minted" else echo "token: NOT minted — see the STOP above; nothing was written"; fi else echo "token: already present — KEPT (re-minting would orphan any paired phone)" fi # Guarded: on the STOP path above (neither openssl nor node) there is no token file, and a bare # chmod would print a confusing "No such file or directory" ahead of the real STOP line below. [ -s "$GW_DIR/token" ] && chmod 600 "$GW_DIR/token" if [ -s "$GW_DIR/token" ]; then echo "token present, mode $(ls -l "$GW_DIR/token" | cut -c1-10), $(wc -c < "$GW_DIR/token" | tr -d ' ') bytes (value NOT shown)" else echo "STOP: token missing or empty" fi ``` A token this document mints is 64 hex characters (`64` or `65` bytes depending on a trailing newline — both fine; the gateway trims). A token **kept** from an earlier install may be a different length: that is fine and is **not** a reason to rotate it — rotating would orphan the paired phone. **Rotating the token (deliberate re-pair only).** This invalidates every paired device until it scans a new QR. Only with the owner's explicit yes: ```bash umask 077 GW_DIR="$HOME/.carapace/claude-gateway"; NODE="$(command -v node || true)" mkdir -p "$GW_DIR"; chmod 700 "$HOME/.carapace" "$GW_DIR" # Mint into a NEW file first. `openssl rand > token` would truncate the live token to 0 bytes # BEFORE failing on a host without openssl — destroying the paired phone's credential with # nothing to restore. The rotate path must be exactly as defensive as C.2's mint path. NEWTOK="$GW_DIR/token.new"; rm -f "$NEWTOK" if command -v openssl >/dev/null 2>&1; then openssl rand -hex 32 > "$NEWTOK" elif [ -x "$NODE" ]; then "$NODE" -e 'process.stdout.write(require("crypto").randomBytes(32).toString("hex"))' > "$NEWTOK" else echo "STOP: neither openssl nor node available to mint a token — the existing token is UNCHANGED; re-run A.1" fi if [ -s "$NEWTOK" ]; then mv "$NEWTOK" "$GW_DIR/token"; chmod 600 "$GW_DIR/token" echo "token ROTATED ($(wc -c < "$GW_DIR/token" | tr -d ' ') bytes, mode 0600, value NOT shown)" echo "every already-paired device is now dead until it scans the new QR" else rm -f "$NEWTOK" echo "STOP: rotation aborted — the existing token is UNCHANGED and still valid" fi # then re-run E.1 (bootout+bootstrap / daemon-reload+restart), E.3, E.6 and all of Phase F ``` **C.3 A QR tool, and a Tailscale PRESENCE PROBE — nothing here installs Tailscale.** **(a)/(b) The Tailscale client install has MOVED to E.4a0** (`wo_20260825ae`). It used to sit right here, ~1200 lines before E.4.2 asks the owner how their phone should reach this box — so *every* install put Tailscale on the machine, including the ones that then chose LAN, the owner's own proxy, or a public address and never used it. The only thing gating it was A.4's presence probe, which is not a choice. Nor can the gate live here: the decision record (`~/.carapace/reach-claude.env`) is not written until E.4.2, so at this point in the document there is literally nothing to condition on. The install therefore belongs after the question, behind the same `REACH_MODE=tailscale` gate the serve blocks already use — that is **E.4a0**, immediately after the OPTION 3 gate in Phase E. C.3 only **looks**: (d) below records what is already on this box, installs nothing, and never halts because the answer is "nothing". **(c) QR tool — required for Phase F, both OSes.** ```bash if command -v qrencode >/dev/null 2>&1; then echo "qrencode: $(command -v qrencode) $(qrencode --version 2>&1 | head -1)" else echo "qrencode missing — install it:" echo " macOS (Homebrew): brew install qrencode # needs admin + Xcode CLT on first brew install" echo " macOS (MacPorts): sudo port install qrencode" echo " Debian/Ubuntu: sudo apt-get install -y qrencode" echo " Fedora/RHEL: sudo dnf install -y qrencode" echo "STOP: install qrencode, then re-run C.3(c) — Phase F cannot render the QR without it" fi ``` **(d) The Tailscale version rule — stated once, here, and gated. Both OSes. PROBE ONLY — it never installs, and "absent" is a PASS.** The required floor is **1.98** (`serve --set-path` semantics changed; 1.88/1.92 from snap are the versions we actually got burned by). Every other mention of a Tailscale version in this document defers to this block — nothing else is a version decision. What changed in `wo_20260825ae` is *when it binds*: on options 1, 2 and 4 no Tailscale client is ever installed, so an absent or ancient one cannot block anything, and E.4a0 re-runs this same comparison as a hard gate on the one option that needs it. ```bash NODE="$(command -v node || true)" if ! command -v tailscale >/dev/null 2>&1; then echo "TAILSCALE_ABSENT — no tailscale CLI on this box. That is a PASSING outcome for Phase C:" echo " nothing here installs it, and it is only needed if E.4.2 records REACH_MODE=tailscale," echo " in which case E.4a0 installs the client and re-runs this comparison as a binding gate." elif [ ! -x "$NODE" ]; then echo "STOP: node not found — re-run A.1 (the comparison is done in node, not by eye)" else TSV="$(tailscale version 2>/dev/null | head -1 | tr -d ' ')" echo "tailscale version: ${TSV:-}" if [ -z "$TSV" ]; then echo "TAILSCALE_UNREADABLE — 'tailscale version' printed nothing: the CLI is broken or the daemon is unreachable (see A.4)." echo " NOT a STOP here — it matters only on option 3, and E.4a0 is where it becomes binding." elif "$NODE" -e 'const m=String(process.argv[1]).match(/(\d+)\.(\d+)/);if(!m)process.exit(2);const a=+m[1],b=+m[2];process.exit((a>1||(a===1&&b>=98))?0:1)' "$TSV"; then echo "TAILSCALE_VERSION_OK (>= 1.98)" else echo "TAILSCALE_TOO_OLD ($TSV) — below the required 1.98 floor (or unparseable). NOT a STOP here:" echo " it blocks OPTION 3 ONLY, and E.4a0 is where that becomes binding. Remediation, if you get there:" echo " Linux: if this came from snap, remove it and reinstall from the official script/repo:" echo " snap list tailscale 2>/dev/null; sudo snap remove tailscale; curl -fsSL https://tailscale.com/install.sh | sudo sh" echo " macOS: install the standalone app from https://pkgs.tailscale.com/stable/ and re-do 'Install CLI…' (E.4a0);" echo " if 'command -v tailscale' resolves under /opt/homebrew or /usr/local/Cellar, that is the CLI-only" echo " Homebrew build — prefer /usr/local/bin/tailscale from the app." fi fi ``` Expected: **`TAILSCALE_VERSION_OK (>= 1.98)`, `TAILSCALE_ABSENT`, `TAILSCALE_UNREADABLE` or `TAILSCALE_TOO_OLD`** — all four let Phase C continue, because on three of the four reachability options this box will never run `tailscale` at all. Only option 3 turns the last three into a blocker, and **E.4a0** is the block that does it. **C.4 Write the supervisor definition (loaded in Phase E).** Both variants re-derive `NODE` and `CLAUDE_BIN` from scratch — **never** rely on a variable set in an earlier phase, because each block may run in a fresh shell. Both variants are **idempotent and non-destructive**: they write a *candidate* definition, then refuse to overwrite an installed one that carries keys this document did not generate (`GW_MODELS`, `GW_DISALLOWED`, `GW_MODE` — exactly the keys the notes below tell the owner to add by hand). To regenerate deliberately, set `FORCE_SUPERVISOR=1` on the EDIT line inside the fence. Those three keys are the only hand edits either variant can **detect**; when the installed file differs in any other way it **is** replaced, and the block prints the before/after **diff** so the edit is visible rather than lost in silence. Each fence also carries a `NODE_OVERRIDE=""` EDIT line: that is where a stable symlink goes if A.1 flagged the resolved node as version-manager scoped (`command -v node` is otherwise used). **(a) macOS — launchd user agent.** ```bash umask 077 OS="$(uname -s)"; GW_DIR="$HOME/.carapace/claude-gateway"; LBL=com.carapace.claude-gateway NODE_OVERRIDE="" # <-- EDIT to an absolute node path to PIN instead of `command -v node` # (use this if A.1 said the resolved node is version-manager scoped) NODE="$(command -v node || true)"; [ -n "$NODE_OVERRIDE" ] && NODE="$NODE_OVERRIDE" CLAUDE_BIN="$(cat "$HOME/.carapace/.claude-bin" 2>/dev/null || true)" PLIST="$HOME/Library/LaunchAgents/$LBL.plist" FORCE_SUPERVISOR=0 # <-- EDIT to 1 ONLY to deliberately discard owner-set keys and regenerate if [ "$OS" != Darwin ]; then echo "SKIP: not macOS — use C.4(b)" elif [ ! -x "$NODE" ]; then echo "STOP: node not found (or NODE_OVERRIDE '$NODE_OVERRIDE' is not executable) — re-run A.1" elif [ ! -x "$CLAUDE_BIN" ]; then echo "STOP: CLAUDE_BIN unset or not executable ('$CLAUDE_BIN') — re-run A.2" elif ! "$CLAUDE_BIN" --version >/dev/null 2>&1; then echo "STOP: '$CLAUDE_BIN' does not answer --version — re-run A.2 and pin a working binary" elif [ "${NODE#/}" = "$NODE" ] || [ "${CLAUDE_BIN#/}" = "$CLAUDE_BIN" ]; then echo "STOP: node ('$NODE') and CLAUDE_BIN ('$CLAUDE_BIN') must BOTH be absolute paths — the" echo " supervisor has no PATH context of its own. Re-run A.1/A.2 from a shell with a normal PATH." elif [ ! -f "$GW_DIR/claude-gateway.mjs" ]; then echo "STOP: gateway not installed — re-run C.1" else echo "pinning node=$NODE claude=$CLAUDE_BIN ($("$CLAUDE_BIN" --version 2>/dev/null | head -1))" mkdir -p "$HOME/Library/LaunchAgents" # does not exist on a clean macOS install CAND="$GW_DIR/.plist.candidate" cat > "$CAND" < Label$LBL ProgramArguments $NODE $GW_DIR/claude-gateway.mjs WorkingDirectory$GW_DIR EnvironmentVariables PORT18800 GW_TMUX0 CLAUDE_BIN$CLAUDE_BIN PATH$(dirname "$NODE"):$(dirname "$CLAUDE_BIN"):$HOME/.local/bin:$HOME/.npm-global/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin RunAtLoad KeepAlive ThrottleInterval5 Umask63 StandardOutPath$GW_DIR/gw.log StandardErrorPath$GW_DIR/gw.log PLIST # 63 decimal = 0o77 (House rule 7). plist integers are DECIMAL: 77 would set # umask 0o115 and leave the log group/world-readable in a different, harder-to-spot way. Without # this key launchd creates gw.log with the session umask, and gw.log carries a 7-character token # prefix plus message previews. # Decide whether the candidate may replace what is installed. OWNERKEYS="" [ -f "$PLIST" ] && OWNERKEYS="$(grep -oE 'GW_MODELS|GW_DISALLOWED|GW_MODE' "$PLIST" | sort -u | tr '\n' ' ')" if [ ! -f "$PLIST" ]; then cp "$CAND" "$PLIST"; chmod 644 "$PLIST"; echo "plist: WRITTEN (first install)" elif cmp -s "$CAND" "$PLIST"; then echo "plist: UNCHANGED — installed definition is already byte-identical (idempotent re-run)" elif [ -n "$OWNERKEYS" ] && [ "$FORCE_SUPERVISOR" != 1 ]; then echo "plist: PRESERVED — the installed plist carries owner-set keys this document did not generate: $OWNERKEYS" echo " Not overwriting. Regenerating would silently delete them (a deleted GW_MODELS = empty model picker)." echo " If node/CLAUDE_BIN changed, edit those values in place; or set FORCE_SUPERVISOR=1 on" echo " the EDIT line above, re-run C.4(a), and re-add the keys with the plutil -replace recipe below." echo " generated-vs-installed differences (first 20 lines):" diff "$PLIST" "$CAND" 2>/dev/null | head -20 else # Keep the outgoing file just long enough to SHOW what changed, then delete it (no .bak litter). cp "$PLIST" "$PLIST.prev" 2>/dev/null || true cp "$CAND" "$PLIST"; chmod 644 "$PLIST" if [ -n "$OWNERKEYS" ]; then echo "plist: REGENERATED (FORCE_SUPERVISOR=1) — owner-set keys were deliberately DISCARDED: $OWNERKEYS" echo " Re-add them now with the plutil -replace recipe in the GW_MODELS note below, then reload" echo " with the Section-3 pair and re-check E.6's catalog gate." else echo "plist: UPDATED (none of GW_MODELS/GW_DISALLOWED/GW_MODE were present)" echo " Those three keys are the only hand edits this block can DETECT, so any OTHER edit" echo " (ThrottleInterval, an extra EnvironmentVariables entry, a hand-fixed PATH) has just been" echo " replaced. It is not discarded silently — it is in the diff below. Read it and re-apply" echo " anything the owner meant to keep." fi echo " installed-before(<) vs written-now(>) (first 20 lines):" diff "$PLIST.prev" "$PLIST" 2>/dev/null | head -20 fi rm -f "$CAND" "$PLIST.prev" LINT=fail if plutil -lint "$PLIST"; then LINT=ok; echo "PLIST_LINT_OK" else echo "STOP: the installed plist is not valid XML — do not activate. Fix it, or set FORCE_SUPERVISOR=1 and re-run C.4(a)." fi # A marker must never outrank the gate above it: if the lint failed, the pin check does not run and # SUPERVISOR_WRITTEN is deliberately NOT printed (Phase C's VERIFICATION demands both). if [ "$LINT" != ok ]; then echo " Pin check skipped — an unparseable plist cannot be honestly reported as 'pinned'." else # The pin check compares against the CURRENT resolved values ($NODE, $CLAUDE_BIN) and against the # script path we install — all three, by exact fixed string. A plist PRESERVED from an earlier run # that still names a node path which no longer exists must FAIL here; that stale pin is precisely # the "gw.log repeats ENOENT months later" failure in the table below. -F = literal, -q = quiet. # Evidence for the acceptance checklist's tool-policy row: print what the INSTALLED file actually # says about the two dials, so that row is ticked from output rather than from memory. # Anchor each dial to its delimiter (plist "", unit/env "=") — a bare GW_MODE pattern # also matches inside GW_MODELS and reports a permission bypass that is not there. DIALS="" grep -qE 'GW_MODE(|=)' "$PLIST" && DIALS="GW_MODE " grep -qE 'GW_DISALLOWED(|=)' "$PLIST" && DIALS="${DIALS}GW_DISALLOWED" DIALS="${DIALS% }" if [ -z "$DIALS" ]; then echo "DIALS_NONE (no GW_MODE, no GW_DISALLOWED — the owner's own Claude config is the only policy)" else echo "DIALS_SET: $DIALS — record the exact value in the Final Report and confirm the owner asked for it"; fi if grep -qF "$CLAUDE_BIN" "$PLIST" \ && grep -qF "$NODE" "$PLIST" \ && grep -qF "$GW_DIR/claude-gateway.mjs" "$PLIST"; then echo "SUPERVISOR_WRITTEN (node + CLAUDE_BIN + gateway script pinned by absolute path)" else echo "STOP: the installed plist does not pin the current node ($NODE), CLAUDE_BIN ($CLAUDE_BIN)" echo " and gateway script ($GW_DIR/claude-gateway.mjs)." echo " If it was PRESERVED above, edit those two values in it now (then re-run C.4(a) to re-check);" echo " otherwise re-run C.4(a). Do not activate a supervisor pinned to a path that no longer exists." fi fi fi ``` `SUPERVISOR_WRITTEN` is emitted on every **passing** path — first write, unchanged re-run, update, and a preserved owner-edited plist that **still pins the current paths** — because Phase C's VERIFICATION and the acceptance checklist require that marker. It is emitted on **no** failing path: not when `plutil -lint` failed (the pin check is skipped entirely), and not when the installed plist names a node/CLAUDE_BIN/script path that is not the one resolved right now. A preserved plist pointing at a vanished node path therefore cannot tick the checklist box — that is the whole point of comparing against `$NODE`/`$CLAUDE_BIN` instead of merely "starts with a slash". **(b) Linux — systemd `--user` unit.** ```bash umask 077 OS="$(uname -s)"; GW_DIR="$HOME/.carapace/claude-gateway"; UNIT=carapace-claude-gateway.service [ "$OS" = Darwin ] || export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" # Section 3(g) NODE_OVERRIDE="" # <-- EDIT to an absolute node path to PIN instead of `command -v node` # (use this if A.1 said the resolved node is version-manager scoped) NODE="$(command -v node || true)"; [ -n "$NODE_OVERRIDE" ] && NODE="$NODE_OVERRIDE" CLAUDE_BIN="$(cat "$HOME/.carapace/.claude-bin" 2>/dev/null || true)" UF="$HOME/.config/systemd/user/$UNIT" FORCE_SUPERVISOR=0 # <-- EDIT to 1 ONLY to deliberately discard owner-set Environment= lines if [ "$OS" = Darwin ]; then echo "SKIP: macOS — use C.4(a)" elif [ ! -x "$NODE" ]; then echo "STOP: node not found (or NODE_OVERRIDE '$NODE_OVERRIDE' is not executable) — re-run A.1" elif [ ! -x "$CLAUDE_BIN" ]; then echo "STOP: CLAUDE_BIN unset or not executable ('$CLAUDE_BIN') — re-run A.2" elif ! "$CLAUDE_BIN" --version >/dev/null 2>&1; then echo "STOP: '$CLAUDE_BIN' does not answer --version — re-run A.2 and pin a working binary" elif [ "${NODE#/}" = "$NODE" ] || [ "${CLAUDE_BIN#/}" = "$CLAUDE_BIN" ]; then echo "STOP: node ('$NODE') and CLAUDE_BIN ('$CLAUDE_BIN') must BOTH be absolute paths — a systemd" echo " unit has no PATH context of its own. Re-run A.1/A.2 from a shell with a normal PATH." elif [ ! -f "$GW_DIR/claude-gateway.mjs" ]; then echo "STOP: gateway not installed — re-run C.1" else echo "pinning node=$NODE claude=$CLAUDE_BIN ($("$CLAUDE_BIN" --version 2>/dev/null | head -1))" mkdir -p "$HOME/.config/systemd/user" CAND="$GW_DIR/.unit.candidate" cat > "$CAND" </dev/null | head -20 else # Keep the outgoing file just long enough to SHOW what changed, then delete it (no .bak litter). cp "$UF" "$UF.prev" 2>/dev/null || true cp "$CAND" "$UF"; chmod 644 "$UF" if [ -n "$OWNERKEYS" ]; then echo "unit: REGENERATED (FORCE_SUPERVISOR=1) — owner-set keys were deliberately DISCARDED: $OWNERKEYS" echo " Re-add the Environment= lines from the GW_MODELS note below, then daemon-reload + restart" echo " and re-check E.6's catalog gate." else echo "unit: UPDATED (none of GW_MODELS/GW_DISALLOWED/GW_MODE were present)" echo " Those three keys are the only hand edits this block can DETECT, so any OTHER edit" echo " (RestartSec, an extra Environment= line, a hand-fixed PATH, After=/WantedBy= changes) has" echo " just been replaced. It is not discarded silently — it is in the diff below. Read it and" echo " re-apply anything the owner meant to keep." fi echo " installed-before(<) vs written-now(>) (first 20 lines):" diff "$UF.prev" "$UF" 2>/dev/null | head -20 fi rm -f "$CAND" "$UF.prev" # `--user` matters: verifying a user unit in system mode is what produces the bogus # "unit file outside the search path" style warning. And warnings must NOT withhold the # marker that Phase C's VERIFICATION demands — only real errors may. VERIFY=fail if command -v systemd-analyze >/dev/null 2>&1; then VOUT="$(systemd-analyze --user verify "$UF" 2>&1 || true)" [ -n "$VOUT" ] && printf '%s\n' "$VOUT" | sed 's/^/ verify: /' if printf '%s\n' "$VOUT" | grep -qiE 'error|invalid|failed to (parse|load)|bad '; then echo "STOP: systemd-analyze reported an ERROR in $UF — fix it before E.1b (warnings alone are fine)" else VERIFY=ok; echo "UNIT_VERIFY_OK" fi else VERIFY=ok echo "UNIT_VERIFY_OK (lint SKIPPED: systemd-analyze not installed — Debian/Ubuntu: it ships with systemd)" fi # As in C.4(a): a marker never outranks the gate above it. If verify found an ERROR, the pin check # does not run and SUPERVISOR_WRITTEN is deliberately NOT printed. if [ "$VERIFY" != ok ]; then echo " Pin check skipped — a unit systemd rejects cannot be honestly reported as 'pinned'." else # SYMMETRY WITH C.4(a), and the point of the whole check: compare against the CURRENT resolved # values, not merely "starts with a slash". A unit PRESERVED from an earlier run whose # ExecStart names a node path that no longer exists (the classic version-manager upgrade) must # FAIL here — that stale pin is exactly the "gw.log repeats ENOENT months later" row in the # failure table. -x = whole line, -F = literal string. # Evidence for the acceptance checklist's tool-policy row (same as C.4(a)). # Same delimiter anchoring as the macOS block: bare GW_MODE would match inside GW_MODELS. DIALS="" grep -qE 'GW_MODE(|=)' "$UF" && DIALS="GW_MODE " grep -qE 'GW_DISALLOWED(|=)' "$UF" && DIALS="${DIALS}GW_DISALLOWED" DIALS="${DIALS% }" if [ -z "$DIALS" ]; then echo "DIALS_NONE (no GW_MODE, no GW_DISALLOWED — the owner's own Claude config is the only policy)" else echo "DIALS_SET: $DIALS — record the exact value in the Final Report and confirm the owner asked for it"; fi if grep -qxF "Environment=CLAUDE_BIN=$CLAUDE_BIN" "$UF" \ && grep -qxF "ExecStart=$NODE $GW_DIR/claude-gateway.mjs" "$UF"; then echo "SUPERVISOR_WRITTEN (node + CLAUDE_BIN + gateway script pinned by absolute path)" else echo "STOP: the installed unit does not pin the current node ($NODE) and CLAUDE_BIN ($CLAUDE_BIN)." echo " Required, verbatim: 'ExecStart=$NODE $GW_DIR/claude-gateway.mjs' and" echo " 'Environment=CLAUDE_BIN=$CLAUDE_BIN'." echo " If it was PRESERVED above, edit ExecStart= / Environment=CLAUDE_BIN= in it now and re-run C.4(b);" echo " otherwise re-run C.4(b). Do not activate a supervisor pinned to a path that no longer exists." fi fi fi ``` `systemd-analyze --user verify` is the `plutil -lint` equivalent. Informational **warnings** are fine and do not withhold `UNIT_VERIFY_OK`; a line containing an **error** is a `STOP:`. `UNIT_VERIFY_OK` is emitted on every passing path (including a preserved owner-edited unit), because Phase C's VERIFICATION and the acceptance checklist require that marker — and `SUPERVISOR_WRITTEN` is emitted on none of the failing ones: an ExecStart naming a node path that is not the one `command -v node` (or `NODE_OVERRIDE`) resolves to right now fails the grep, exactly as the macOS twin does. The two OSes check the same three things. **C.4c Pin the log mode (both OSes) — the umask line above only governs files created from now on.** A `gw.log` an earlier run created under a `0002` umask stays `-rw-rw-r--` forever, so repair it here and prove the mode instead of assuming it. This is the acceptance checklist's `gw.log` row; run it now and again after E.1 (the first supervised boot re-creates the log if it is absent). ```bash GW_DIR="$HOME/.carapace/claude-gateway" chmod 700 "$HOME/.carapace" "$GW_DIR" 2>/dev/null # find, not a glob: an unmatched glob aborts the whole command under zsh, and on a fresh install # there is legitimately no log yet. Covers gw.log plus the gateway's own state files. find "$GW_DIR" -maxdepth 1 -type f \( -name '*.log' -o -name '*.json' \) -exec chmod 600 {} + 2>/dev/null [ -f "$GW_DIR/token" ] && chmod 600 "$GW_DIR/token" echo "--- modes inside the gateway directory (every file must be -rw------- or -rwx------) ---" ls -l "$GW_DIR" WIDE="$(find "$GW_DIR" -maxdepth 1 -type f ! -perm 600 ! -perm 700 -print 2>/dev/null)" if [ -z "$WIDE" ]; then echo "LOG_MODE_OK (nothing inside $GW_DIR is group- or world-readable)" else echo "STOP: these files are wider than 0600/0700 and gw.log carries a token prefix:" printf '%s\n' "$WIDE" echo " chmod 600 them, then confirm the supervisor definition carries the umask pin" echo " (systemd 'UMask=0077' / plist 'Umask63') — without it" echo " the next respawn recreates the log exactly as wide as it was." fi ``` Expected: `LOG_MODE_OK`. If the supervisor definition was **PRESERVED** by C.4 (owner-set keys), it may predate the umask pin — add the one line by hand (`UMask=0077` inside `[Service]`, or the `Umask`/`63` key pair in the plist), then reload with the Section-3 pair. **Notes on both variants.** `GW_TMUX=0` = programmatic mode (drives `claude -p`; no `tmux`, and no long-turn cutoff). The gateway reads its token from the default path `~/.carapace/claude-gateway/token` (where C.2 put it), so no token env var is needed. We deliberately do **not** set `GW_MODE=full` (that adds `--dangerously-skip-permissions`) and do not set `GW_DISALLOWED`: the owner's own Claude permission configuration is the sole capability policy. **`GW_MODELS` — the model catalog override.** Unset, the gateway serves its built-in default catalog (which is what the phone's picker shows, and what E.6's catalog gate counts). Set it only when the owner's plan does not include a default id. It is a JSON array of `{id,name,efforts,default}`. The quoting is easy to get wrong (a JSON array inside plist XML or inside a `systemd` `Environment=` line), so use these exact forms. **macOS** — set it with `plutil -replace` **after** C.4(a) wrote the plist, so the shell never expands the JSON and the XML stays valid: ```bash PLIST="$HOME/Library/LaunchAgents/com.carapace.claude-gateway.plist" plutil -replace EnvironmentVariables.GW_MODELS -string '[{"id":"claude-sonnet-4-5","name":"Sonnet 4.5","efforts":["low","high"],"default":true}]' "$PLIST" plutil -lint "$PLIST" && echo "GW_MODELS set; now apply it with bootout+bootstrap (E.1a)" ``` **Linux** — add one line inside the unit's `[Service]` block, quoting the whole assignment, then `daemon-reload` + `restart`: ``` Environment="GW_MODELS=[{\"id\":\"claude-sonnet-4-5\",\"name\":\"Sonnet 4.5\",\"efforts\":[\"low\",\"high\"],\"default\":true}]" ``` Either way, apply it with the Section-3 reload pair (bootout+bootstrap / daemon-reload+restart) — `kickstart -k` will not pick it up (Section 3, rule **(a)**) — then re-check E.6's catalog gate. To go back to the built-in catalog: `plutil -remove EnvironmentVariables.GW_MODELS "$PLIST"` (macOS) or delete the `Environment="GW_MODELS=…"` line (Linux), then reload. **Bad `GW_MODELS` JSON fails SILENTLY — verify by id, never by count (2026-07-30 audit D-8).** The gateway does not reject a malformed override loudly: it discards it and serves the built-in catalog, so `CATALOG_OK` / `LOOPBACK_CATALOG_OK` still print, the count still looks right, and the owner just quietly gets the wrong picker. (Verified with the accidental `GW_MODELS=[{id:claude-sonnet-4-5,name:Sonnet` — every gate in this document passed.) So do two things whenever you set it. First, check the JSON **before** it goes anywhere near a supervisor definition — this reads the value from a 0600 file rather than the command line, so nothing lands in `ps`: ```bash umask 077 MJ="$HOME/.carapace/.gw-models.json" # write your intended GW_MODELS value into this file first NODE="$(command -v node || true)" if [ ! -x "$NODE" ]; then echo "STOP: node not found — re-run A.1" elif [ ! -s "$MJ" ]; then echo "STOP: $MJ is missing or empty — write the JSON array into it first" else "$NODE" -e 'const fs=require("fs");let j;try{j=JSON.parse(fs.readFileSync(process.argv[1],"utf8"))}catch(e){console.log("STOP: GW_MODELS is not valid JSON — "+e.message);process.exit(0)} if(!Array.isArray(j)){console.log("STOP: GW_MODELS must be a JSON ARRAY of {id,name,efforts,default}");process.exit(0)} if(!j.length){console.log("STOP: GW_MODELS is an empty array — that is an empty picker; remove the key instead");process.exit(0)} const bad=j.filter(m=>!m||typeof m.id!=="string"||!m.id); if(bad.length){console.log("STOP: "+bad.length+" entr"+(bad.length===1?"y has":"ies have")+" no string id");process.exit(0)} console.log("GW_MODELS_JSON_OK ids="+j.map(m=>m.id).join(","));' "$MJ" fi ``` Second, **after** reloading, run **E.6b** and read its `ids=` line — not its count. If your ids are not in it, the value was rejected and you are looking at the built-ins. Delete `$HOME/.carapace/.gw-models.json` when you are done with it. **A later re-run of C.4 will NOT delete this.** Once `GW_MODELS` (or `GW_DISALLOWED`/`GW_MODE`) appears in the plist/unit, C.4 detects it and prints `PRESERVED` instead of overwriting, showing you a diff of what it *would* have written. Only `FORCE_SUPERVISOR=1` discards it — and if you use that, re-apply this recipe afterwards and re-check E.6. If the phone's picker is empty after a re-run, this is the first thing to check: `grep -c GW_MODELS` the plist/unit before blaming the catalog. **VERIFICATION:** gateway files 0700 under `~/.carapace/claude-gateway/`; parent `~/.carapace` 0700 **as printed by C.1's `ls -ld` line** (not assumed); token 0600 non-empty; `PLIST_LINT_OK` (macOS) or `UNIT_VERIFY_OK` (Linux); `SUPERVISOR_WRITTEN` (after a `WRITTEN`/`UNCHANGED`/`UPDATED`/`PRESERVED` decision line); **one** of C.3(d)'s four verdicts recorded — `TAILSCALE_VERSION_OK`, `TAILSCALE_ABSENT`, `TAILSCALE_UNREADABLE` or `TAILSCALE_TOO_OLD` (they all pass Phase C; only option 3 needs the first, and E.4a0 gates that); `qrencode` resolves; no `STOP:` line. --- ### Phase D — isolated verification, before activation Prove the gateway boots and **enforces auth**, on a scratch port, using the **same token file path production uses** (no token env), without touching launchd/systemd or Tailscale. ```bash umask 077 # scratch log must not be world-readable GW_DIR="$HOME/.carapace/claude-gateway"; NODE="$(command -v node || true)" SCRATCH_PORT="${SCRATCH_PORT:-18899}"; SLOG="$GW_DIR/gw-scratch.log" if [ ! -x "$NODE" ]; then echo "STOP: node not found — re-run A.1" elif [ ! -f "$GW_DIR/claude-gateway.mjs" ]; then echo "STOP: gateway not installed — re-run C.1" elif [ ! -s "$GW_DIR/token" ]; then echo "STOP: token missing — re-run C.2" else # GW_VERSION_FILE: the scratch boot must never write the REAL version stamp — on a host whose # default claude differs from the pinned one, a scratch stamp would fake a "version CHANGED" # on the first supervised boot. The scratch stamp is deleted with the scratch log below. PORT="$SCRATCH_PORT" GW_TMUX=0 GW_VERSION_FILE="$GW_DIR/gw-scratch-version.json" "$NODE" "$GW_DIR/claude-gateway.mjs" > "$SLOG" 2>&1 & SCRATCH=$! i=0; while [ $i -lt 15 ]; do curl -s -m 3 -o /dev/null "http://127.0.0.1:$SCRATCH_PORT/health" && break i=$((i+1)); sleep 1 done # House rule 1: the token goes into a 0600 header file inside the 0700 dir, never onto a command # line (argv is world-readable via /proc//cmdline on a default Linux). Deleted below. HF="$GW_DIR/.authhdr"; rm -f "$HF" printf 'Authorization: Bearer %s\n' "$(cat "$GW_DIR/token")" > "$HF"; chmod 600 "$HF" curl -s -m 10 -o /dev/null -w "health -> %{http_code}\n" "http://127.0.0.1:$SCRATCH_PORT/health" curl -s -m 10 -o /dev/null -w "no-auth /status -> %{http_code}\n" "http://127.0.0.1:$SCRATCH_PORT/api/app/v1/status" curl -s -m 10 -o /dev/null -w "good-auth /status -> %{http_code}\n" -H @"$HF" "http://127.0.0.1:$SCRATCH_PORT/api/app/v1/status" rm -f "$HF" kill "$SCRATCH" 2>/dev/null; sleep 1; kill -9 "$SCRATCH" 2>/dev/null rm -f "$GW_DIR/gw-scratch-version.json" echo "scratch process stopped (pid $SCRATCH); scratch log at $SLOG" fi ``` **Expected exactly:** `health -> 200`, `no-auth /status -> 401`, `good-auth /status -> 200`. A `000` on any line means curl could not connect at all (curl itself writes `000` into `%{http_code}` on a connection failure — that is the same code every retry loop in Phase E treats as "not up yet"), so the scratch process never listened: read the scratch log. (bash may also print a job notice like `Terminated: 15` when the scratch process is killed — that is the shell reporting our own `kill`, not an error. If 18899 was busy in A.3, edit the `SCRATCH_PORT="${SCRATCH_PORT:-18899}"` line **inside this fence** to a free port before running it — do not try to prefix an assignment onto the block.) - If `health` is not 200: read the scratch log — `tail -40 "$HOME/.carapace/claude-gateway/gw-scratch.log"`. The usual cause is a wrong Node version or a missing `tmux-drive.mjs`. **The log contains a 7-character token prefix and message previews — read it, never paste it anywhere.** - If `no-auth` is not 401: **STOP — the auth gate is not enforcing.** Do not activate, do not expose, do not pair. This is the fail-closed check, and it happens here (isolated) on purpose, before anything is reachable. On success, remove the scratch log: ```bash rm -f "$HOME/.carapace/claude-gateway/gw-scratch.log" ``` **VERIFICATION:** the triad printed `200 / 401 / 200`; scratch process stopped; scratch log deleted. --- ### Phase E — activate + expose (in this order) **E.1a (macOS) Load into the GUI (Aqua) domain — bootout first, always.** ```bash OS="$(uname -s)"; LBL=com.carapace.claude-gateway; PLIST="$HOME/Library/LaunchAgents/$LBL.plist" if [ "$OS" != Darwin ]; then echo "SKIP: Linux — use E.1b" elif [ ! -f "$PLIST" ]; then echo "STOP: plist missing — re-run C.4(a)" elif ! launchctl print "gui/$(id -u)" >/dev/null 2>&1; then echo "STOP: there is no Aqua (GUI) login session for uid $(id -u), so 'launchctl bootstrap gui/...'" echo " will fail with 'Bootstrap failed: 5: Input/output error'." echo " Fix: have the owner log in at the console (Screen Sharing counts), or enable" echo " auto-login (System Settings > Users & Groups > Automatic login) for a headless" echo " Mac, then re-run E.1a." else launchctl bootout "gui/$(id -u)/$LBL" 2>/dev/null || true # required on EVERY re-run if launchctl bootstrap "gui/$(id -u)" "$PLIST"; then echo "BOOTSTRAP_OK" else echo "STOP: launchctl bootstrap failed — see the failure table row for 'Bootstrap failed'" fi fi ``` Be plain with the owner: a launchd **user agent in the `gui/` domain runs while they are logged in.** On a machine that reboots unattended, auto-login is what keeps CARAPACE available. **E.1b (Linux) Enable the user unit + linger.** ```bash OS="$(uname -s)"; UNIT=carapace-claude-gateway.service if [ "$OS" = Darwin ]; then echo "SKIP: macOS — use E.1a" else export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" # needed over SSH # ---- LINGER FIRST. The ORDER is the fix (fresh-install audit wo_20260821w). Without linger there # is no user manager and no /run/user//bus, so EVERY `systemctl --user` below dies with # "Failed to connect to user scope bus" — and the one command that repairs it used to sit BELOW # the failures it causes, where a literal single pass can never reach it. # And it is NOT a sudo-only operation: on stock Ubuntu polkit lets a user enable linger for # THEMSELVES. Proven on a fresh non-admin account: rc=0, Linger=yes, /run/user/ created. # Try unprivileged first. Telling a non-admin owner "this needs sudo" when it does not is how a # completable install gets reported as impossible. # `sudo -n` (never a bare interactive sudo here) so an unattended run LEARNS the answer instead # of hanging on a password prompt — the same standard the reachability phase already applies. loginctl enable-linger "$(id -un)" 2>/dev/null \ || sudo -n loginctl enable-linger "$(id -un)" 2>/dev/null || true LG="$(loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null)" echo "Linger=${LG:-unknown}" if [ "$LG" != yes ]; then echo "STOP: linger is OFF (Linger=${LG:-unknown}) — the unit would die at logout and never return" echo " after reboot, which is not an installation (House rule 4). Both unprivileged and" echo " non-interactive-sudo attempts above failed, so this needs the owner once:" echo " sudo loginctl enable-linger \"\$(id -un)\" # then re-run E.1b" echo " Nothing further in E.1b can work until it holds. Do not 'work around' it by starting the" echo " gateway by hand — an unsupervised copy is exactly what A.3 calls a squatter." else echo "LINGER_OK" fi # The user manager and its bus appear a moment after linger is enabled. Bounded wait, then judge — # so that a missing bus is diagnosed HERE, by name, instead of surfacing below as a mystery. i=0; while [ $i -lt 10 ] && [ ! -S "$XDG_RUNTIME_DIR/bus" ]; do i=$((i+1)); sleep 1; done if [ -S "$XDG_RUNTIME_DIR/bus" ]; then echo "USER_BUS_OK ($XDG_RUNTIME_DIR/bus, after ${i}s)" else echo "STOP: no user D-Bus socket at $XDG_RUNTIME_DIR/bus, so no 'systemctl --user' command can" echo " work at all. The cause is LINGER OFF / NO USER MANAGER for this uid (Linger=${LG:-unknown})." echo " It is NOT a wrong XDG_RUNTIME_DIR — exporting it while linger is off is a pure no-op —" echo " and it is NOT a busy port. Fix linger above, then re-run E.1b." fi systemctl --user daemon-reload systemctl --user enable --now "$UNIT" && echo "UNIT_ENABLED" systemctl --user restart "$UNIT" && echo "UNIT_RESTARTED" # wo_20260811 (fresh-VPS stress test): `restart` exits 0 as soon as the fork succeeds, so it # returns 0 on a unit that dies instantly on EADDRINUSE. Let it settle, then ask the # manager what actually happened — three green markers above are NOT proof of a live unit. sleep 6 AS="$(systemctl --user show -p ActiveState --value "$UNIT" 2>/dev/null)" MP="$(systemctl --user show -p MainPID --value "$UNIT" 2>/dev/null)" NR="$(systemctl --user show -p NRestarts --value "$UNIT" 2>/dev/null)" echo "ActiveState=$AS MainPID=${MP:-0} NRestarts=${NR:-0}" # An EMPTY ActiveState is a DIFFERENT failure from an inactive one, and conflating them is what # sent an earlier audit hunting a squatter on a machine whose only problem was a missing user # manager (wo_20260821w). systemd answered nothing ⇒ there is no manager to answer. if [ -z "$AS" ]; then echo "STOP: systemd answered NOTHING (ActiveState came back empty) — there is no user manager to" echo " ask. Read the USER_BUS_OK / linger lines above: this is linger off or no session bus," echo " NOT a busy port. Nothing has even tried to bind 18800 yet, so do NOT go looking for a" echo " squatter and do NOT evict anything. Fix linger, then re-run E.1b." elif [ "$AS" != active ] || [ -z "$MP" ] || [ "$MP" = 0 ]; then echo "STOP: the unit is not running (ActiveState=$AS, MainPID=${MP:-0}, NRestarts=${NR:-0})." echo " A restart that 'succeeded' plus a dead MainPID is almost always EADDRINUSE:" echo " something ELSE already holds 127.0.0.1:18800 — including an UNSUPERVISED copy of" echo " our own gateway from an earlier install, which E.1 does NOT replace." echo " Confirm FIRST, before believing that: journalctl --user -u $UNIT -n 20 --no-pager" echo " (if the log says 'Failed to connect to bus' rather than 'address already in use'," echo " this is the linger/user-manager problem above, not a port collision — treat it as such)." echo " Otherwise go back to A.3, identify the holder, and use the eviction fence. Do not" echo " read a 200 from http://127.0.0.1:18800/health as ours — that is the squatter." else echo "UNIT_ACTIVE_OK" fi fi ``` **E.1c (both) Readiness + listener identity + bind address.** ```bash OS="$(uname -s)"; LBL=com.carapace.claude-gateway; UNIT=carapace-claude-gateway.service [ "$OS" = Darwin ] || export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" # Section 3(g) # Supervisors return BEFORE the socket listens — retry up to ~15s, then judge. # NO `|| echo 000` here (or anywhere else in this document): curl ALREADY writes 000 into # %{http_code} when it cannot connect, and THEN exits non-zero — so a `|| echo 000` appends a # second 000 and the variable becomes the single token `000000`, which equals nothing in any # decision table and makes `[ "$CODE" != 000 ]` true on the very first attempt (retry loop dead). i=0; CODE=000 while [ $i -lt 15 ]; do CODE="$(curl -s -m 3 -o /dev/null -w "%{http_code}" http://127.0.0.1:18800/health)" [ "$CODE" = 200 ] && break i=$((i+1)); sleep 1 done echo "health -> $CODE (expect 200 within ~15s; 000 = nothing listening on 18800 yet)" if [ "$OS" = Darwin ]; then launchctl print "gui/$(id -u)/$LBL" 2>/dev/null | grep -E "state = |pid = " | head -2 SPID="$(launchctl print "gui/$(id -u)/$LBL" 2>/dev/null | grep -E "^[[:space:]]*pid = " | head -1 | tr -dc "0-9")" else systemctl --user show -p ActiveState -p SubState -p MainPID "$UNIT" SPID="$(systemctl --user show -p MainPID --value "$UNIT" 2>/dev/null)" fi echo "supervised pid: ${SPID:-none}" # ss FIRST on Linux (parity with A.3's show_port, codex.md A.4 and hermes.md A.3). Measured on a # live multi-user box: the lsof branch returned pid=[] addr=[] — "unknown" — and routed the agent # to a sudo-only remedy it could not perform, while ss on the same instant returned # addr=[0.0.0.0:18800], instantly diagnostic under House rule 6. Never let lsof pre-empt ss here. if [ "$OS" != Darwin ] && command -v ss >/dev/null 2>&1; then ss -lntp "sport = :18800" elif command -v lsof >/dev/null 2>&1; then lsof -nP -iTCP:18800 -sTCP:LISTEN else echo "NOTE: install lsof or ss (iproute2) — the bind/identity check cannot be completed without one"; fi # wo_20260811 (fresh-VPS stress test): the three assertions below are now COMPUTED — this was # the only major gate with no grep-able marker, and an agent pointed at a squatter (which # can answer /health with 200 or 404) could sail past prose. LPID=""; LADDR="" # ss FIRST on Linux — same reason as the table above. lsof-first here returned "unknown" for BOTH # fields on a host where ss simultaneously read 0.0.0.0:18800; "unknown" then sent the agent to a # sudo-only remedy instead of to House rule 6. macOS has no ss, so lsof remains its branch. if [ "$OS" != Darwin ] && command -v ss >/dev/null 2>&1; then LPID="$(ss -lntp "sport = :18800" 2>/dev/null | grep -o 'pid=[0-9]*' | head -1 | tr -dc 0-9)" LADDR="$(ss -lnt "sport = :18800" 2>/dev/null | awk 'NR==2{print $4}')" elif command -v lsof >/dev/null 2>&1; then LPID="$(lsof -nP -iTCP:18800 -sTCP:LISTEN -t 2>/dev/null | head -1)" LADDR="$(lsof -nP -iTCP:18800 -sTCP:LISTEN 2>/dev/null | awk 'NR==2{print $9}')" fi echo "listener pid: ${LPID:-unknown} addr: ${LADDR:-unknown}" # An address we can see but no pid we can attribute is a FOREIGN socket, not a mystery: name it # here so the STOP below is read as "someone else holds this port", not "the tool is broken". if [ -z "$LPID" ] && [ -n "$LADDR" ]; then echo "NOTE: the socket is visible but its owning process is not — that means it belongs to another" echo " user or to root. Attribute it with: sudo ss -lntp \"sport = :18800\"" fi # The EXPECTED address is loopback until Phase E.4 records a different one. Reading the record # (rather than hard-coding 127.0.0.1) is what lets a legitimate option-1/4 install re-run this # block, and Phase G re-measure it, without a false STOP. A bind that matches NEITHER loopback # NOR the recorded address is still a hard failure, and a wildcard bind always is. WANT="127.0.0.1:18800" REACHF="$HOME/.carapace/reach-claude.env" if [ -s "$REACHF" ]; then . "$REACHF" 2>/dev/null || true; fi case "${BIND_ADDR:-127.0.0.1}" in ""|127.0.0.1) : ;; *) WANT="$BIND_ADDR:18800" ;; esac echo "expected listen address: $WANT (from ${REACHF##*/} when present; loopback otherwise)" if [ "$CODE" = 200 ] && [ -n "$LPID" ] && [ -n "$SPID" ] && [ "$SPID" != 0 ] \ && [ "$LPID" = "$SPID" ] && [ "$LADDR" = "$WANT" ]; then echo "LISTENER_IDENTITY_OK" else echo "STOP: E.1c did not prove the supervised gateway owns $WANT" echo " (health=$CODE listener=${LPID:-unknown} supervised=${SPID:-none} addr=${LADDR:-unknown})." echo " A 200/404 answered by a pid that is not the supervised pid is the SQUATTER." echo " An unknown listener means this check is UNPROVEN (unprivileged lsof/ss cannot" echo " see another user's socket) — re-run the listener lines with sudo, then judge (A.3)." fi ``` Assert all three, and do not continue unless all three hold: 1. `health -> 200`. A `000` after the loop means nothing is listening at all (read `gw.log`); a `5xx` means the process is up but broken (also `gw.log`); a `404` (or any other non-200) usually means a SQUATTER is answering on 18800, not our gateway. None of these are a pass. 2. The listening address is exactly the **expected** one the block prints: `127.0.0.1:18800` before Phase E.4, or the address E.4 recorded for option 1 (and for option 4 only under the `REACH_INSECURE_HTTP=yes` opt-out; option 4's default leaves this loopback) — and **never** `*:18800`, `0.0.0.0:18800` or `[::]:18800`, in any option (House rule 6). 3. The **listening pid equals the supervised pid** printed above. If it does not, something else owns the port (see A.3) and every later gate would be testing the wrong process. An **empty** listener table is not assertion 2 or 3 passing — it is them being unproven: either no tool is installed, or an unprivileged `lsof`/`ss` cannot see another user's socket (A.3's note). Re-run the listener line with `sudo` and only then judge. Then run the **Section-3 verification snippet** to confirm the env keys came through. **E.2 Prove the supervisor really respawns it.** ```bash OS="$(uname -s)"; LBL=com.carapace.claude-gateway; UNIT=carapace-claude-gateway.service # Section 3(g): without this, `systemctl --user` cannot reach the bus, MainPID comes back EMPTY, # and the STOP branch below fires on a perfectly healthy install (then you loop E.1b -> E.2 # forever). Export it BEFORE the first read, in this same shell as the reads. [ "$OS" = Darwin ] || export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" getpid() { if [ "$(uname -s)" = Darwin ]; then launchctl print "gui/$(id -u)/$LBL" 2>/dev/null | grep -E "^[[:space:]]*pid = " | head -1 | tr -dc "0-9" else systemctl --user show -p MainPID --value "$UNIT" 2>/dev/null fi } SPID="$(getpid)"; echo "pid before: ${SPID:-none}" if [ -z "$SPID" ] || [ "$SPID" = 0 ]; then echo "STOP: no supervised pid. Check in this order before re-running E.1:" echo " 1. Linux only — is the bus reachable? Run: systemctl --user is-active $UNIT" echo " If that says 'Failed to connect to bus', XDG_RUNTIME_DIR is the problem, not the service." echo " 2. Is health answering anyway? curl -s -m 5 -o /dev/null -w '%{http_code}\\n' http://127.0.0.1:18800/health" echo " A 200 with no supervised pid means something UNSUPERVISED holds the port (see A.3) —" echo " that is not an installation. Do not re-run E.1 on top of it; resolve the squatter first." echo " 3. Only if both of those are clean is this really 'E.1 did not activate' — re-run E.1 and read its output." else kill -9 "$SPID" i=0; NEW=""; CODE=000 while [ $i -lt 30 ]; do sleep 1; i=$((i+1)) NEW="$(getpid)" if [ -n "$NEW" ] && [ "$NEW" != 0 ] && [ "$NEW" != "$SPID" ]; then # No `|| echo 000`: curl already writes 000 on a connection failure (see E.1c's comment). CODE="$(curl -s -m 3 -o /dev/null -w "%{http_code}" http://127.0.0.1:18800/health)" [ "$CODE" = 200 ] && break fi done echo "pid after: ${NEW:-none} health -> $CODE (waited ${i}s)" fi ``` Expect a **different, non-zero pid** and `health -> 200` within 30s (the throttle/RestartSec is 5s, but a slow box needs more than one interval — that is why this retries instead of a single `sleep`). **E.3 Prove it can actually ANSWER — assembled SSE, never a raw grep.** The reply arrives as **many** SSE frames: the word PONG has been observed streaming as `"P"` then `"ONG"`, so `grep -c PONG` on the raw stream returns 0 **on a perfectly healthy install** — and some frames echo the request text, so a raw grep can also return ≥1 on a broken one. Both failure directions are eliminated by assembling the deltas first. `x-openclaw-session-key` is the gateway's opaque **lane identifier** (a historical name — nothing OpenClaw is installed or required). Format `agent::`; any value creates-or-resumes a lane. Reuse the **same** key on retries, as below: a fresh key per attempt spawns an additional resident `claude` process each time. ```bash umask 077 GW_DIR="$HOME/.carapace/claude-gateway"; NODE="$(command -v node || true)" if [ ! -x "$NODE" ]; then echo "STOP: node not found — re-run A.1" elif [ ! -s "$GW_DIR/token" ]; then echo "STOP: token missing — re-run C.2" else # House rule 1: token via a 0600 header file, never on the command line. Removed at the end. HF="$GW_DIR/.authhdr"; rm -f "$HF" printf 'Authorization: Bearer %s\n' "$(cat "$GW_DIR/token")" > "$HF"; chmod 600 "$HF" OK=0 for T in 120 300; do echo "smoke attempt, timeout ${T}s (the FIRST cold turn can exceed 90s: model spawn + auth handshake)" # 2026-07-30 audit D-3: MEASURE fast-vs-slow. The failure ladder below branches on it and # "it felt fast" is not evidence — on the audited host the failing turn took 0 seconds. T0="$(date +%s)" RES="$(curl -sN -m "$T" -X POST http://127.0.0.1:18800/api/app/v1/chat \ -H @"$HF" -H "Content-Type: application/json" \ -H "x-openclaw-session-key: agent:setuptest:main" \ -d '{"stream":true,"messages":[{"role":"user","content":"Reply with exactly: PONG"}]}' \ | "$NODE" -e ' let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{ let out="",err="",flag=false; for (const ln of s.split(/\r?\n/)) { // 2026-07-30 audit D-3: an SSE `event: error` frame carries the ONLY explanation of // a fast empty reply. Measured on the audited host, the whole stream was: // event: openclaw.status / data: {"status":"thinking"} // event: error / data: {"error":"Not logged in - Please run /login"} // data: [DONE] // The old assembler consumed nothing but choices[] deltas, so it DROPPED that frame, // printed `assembled reply: ""`, and sent the reader to the wrong failure row while // the answer sat in the discarded line. Capture it; print it verbatim. if (/^event:\s*error/.test(ln)) { flag=true; if(!err) err="(error frame carried no message)"; continue; } if (ln.indexOf("data:")!==0) { if(ln.trim()==="") flag=false; continue; } const p=ln.slice(5).trim(); if (!p||p==="[DONE]") { flag=false; continue; } let j; try { j=JSON.parse(p) } catch(e) { if(flag){ err=p.slice(0,300); flag=false } continue } if (flag||(j&&(j.error||j.type==="error"))) { const e0=j&&j.error; const m=(typeof e0==="string")?e0:((e0&&(e0.message||e0.error))||(j&&j.message)||p); err=String(m).slice(0,300); flag=false; continue; } const ch=(j.choices&&j.choices[0])||{}; const d=ch.delta||ch.message||{}; if (d.role==="user") continue; if (typeof d.content==="string") out+=d.content; else if (Array.isArray(d.content)) for (const b of d.content) if (b&&typeof b.text==="string") out+=b.text; } const cleaned=out.split("Reply with exactly: PONG").join(""); console.log("assembled reply: "+JSON.stringify(cleaned.slice(0,160))); if (err) console.log("gateway error: "+err); console.log(cleaned.includes("PONG")?"SMOKE_OK":("SMOKE_FAIL"+(err?" — gateway error: "+err:""))); })')" echo "$RES" echo "elapsed: $(( $(date +%s) - T0 ))s" case "$RES" in *SMOKE_OK*) OK=1; break ;; esac done rm -f "$HF" # 2026-07-30 audit D-9: E.3 is a hard gate on the acceptance checklist, but it printed only # `SMOKE_FAIL` — no `STOP:`. House rule 2 says a VERIFICATION of "no STOP: line anywhere" is a # complete gate, so an agent leaning on that rule could read a failed smoke as a passed phase. # Two failures now print the blocking prefix. if [ "$OK" != 1 ]; then echo "STOP: E.3 failed twice — this gateway cannot answer a turn." echo " If a 'gateway error:' line appeared above, that text IS the diagnosis — read it" echo " before touching the supervisor, and match it against the failure table." echo " Do NOT pair a phone with a runtime that cannot answer: Phase F stays N-A until" echo " this prints SMOKE_OK. On a deliberately credential-less box this STOP is the" echo " documented outcome — see 'the no-auth path' in A.2b and record E.3 as BLOCKED." fi fi ``` Expect **`SMOKE_OK`** and an assembled reply containing PONG. The assembler ignores frames whose role is `user` and subtracts the literal prompt sentence, so an echoed request cannot fake a pass. If the second attempt also prints `SMOKE_FAIL`, work the causes in this order. **Rule zero (2026-07-30 audit D-3): if a `gateway error:` line was printed, it outranks every branch below.** That text came from the runtime itself; do not reload, re-pin or re-probe anything until you have read it. `Not logged in` / `Please run /login` / `Invalid API key` means the `claude` child refused — go to A.2b's no-auth path, not to the supervisor. 1. `assembled reply: ""`, **no** `gateway error:` line, `elapsed: 0s`–a few seconds, **and** the loopback triad still passes (Phase D `200/401/200`, E.6's origin triad, E.1c's pid match) → the request never reached a model. Two different faults look identical here, so separate them: - triads pass ⇒ the gateway answered but its `claude` child refused instantly. That is an **auth or binary** problem: re-run **A.2b**, and `"$(cat ~/.carapace/.claude-bin)" --version`. - triads fail ⇒ you are not talking to our supervised process. Re-check E.1c, then reload with the Section-3 pair. This is the only branch a reload fixes. The audited host sat in the first sub-branch and the old wording sent it to the second, so it reloaded a perfectly healthy supervisor in a loop while the cause was a discarded error frame. 2. `assembled reply: ""` and `elapsed:` at or near the full timeout → the `claude` child was spawned but never answered: run `"$(cat ~/.carapace/.claude-bin)" --version` and then `claude` once interactively to confirm it is logged in; re-pin in A.2 if it is the wrong binary; apply with the Section-3 reload pair. 3. A non-empty reply that simply is not PONG → the runtime is working; the model was chatty. Treat as a pass **only** if the assembled text plainly answers the prompt, and say so in the report. **Cleanup.** Each lane holds a resident `claude` child. One reload reaps them: ```bash OS="$(uname -s)"; LBL=com.carapace.claude-gateway; UNIT=carapace-claude-gateway.service if [ "$OS" = Darwin ]; then launchctl bootout "gui/$(id -u)/$LBL" 2>/dev/null || true launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/$LBL.plist" else export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"; systemctl --user restart "$UNIT" fi sleep 3; curl -s -m 5 -o /dev/null -w "health after reap -> %{http_code}\n" http://127.0.0.1:18800/health ``` The `agent:setuptest:main` lane may still appear in the phone's lane list after pairing. It is harmless installer debris — tell the owner they can delete that lane in the app. **On "mark-delivered" / closing the smoke lane — deliberate omission, not an oversight.** The Claude gateway in this bundle exposes **no** lane-close, lane-delete or mark-delivered call over the app API; there is nothing to invoke, so this document invokes nothing. The only two sanctioned cleanups are the supervisor reload above (which reaps the resident `claude` child) and the owner deleting the lane in the app. Do **not** improvise a `DELETE` or an undocumented endpoint against the gateway to tidy it up — House rule 8. Record the lane's state in the Final Report so the debris is disclosed rather than hidden. ### Phase E.4 — REACHABILITY: how the phone reaches this box (detect → at most ONE question → record) **The north star.** "Download the app, copy and paste the prompt. Boom you're in." Every input this phase asks the owner for is a **defect that has to justify itself**. So this phase *detects* first, asks *at most one* question, and — when nobody is answering — **decides for itself and records the decision** instead of stalling. `INPUTS REQUIRED FROM OWNER THIS RUN: 0` is the target on any box whose network can be read, and Phase F prints that count as a hard number. **Tailscale is a RECOMMENDATION here, never a requirement.** It is option 3, it is genuinely the best answer for "reach my box from anywhere without building anything", and it stays fully scripted below — but a box that already has a LAN, a domain, a tunnel or a public IP pairs *without ever installing it*. Do not turn a preference into a prerequisite. **The ways in.** One of these becomes `BASE_URL`, and everything downstream (verification, QR, link) is built from that one value: | # | Route in | Gateway bind | Extra setup | `BASE_URL` shape | |---|---|---|---|---| | 1 | **Same Wi-Fi / LAN** | `GW_BIND=` | none | `http://:18800` | | 2 | **I already have a way in** (domain / tunnel / reverse proxy) | loopback (unchanged) | owner's, already built | `https://` + `/carapace-claude` | | 3 | **Tailscale** (recommended for anywhere-access) | loopback (unchanged) | install + `serve`, scripted below | `https://.ts.net` + `/carapace-claude` | | 4 | **Direct public IP** (VPS) — *free HTTPS, automatic* | loopback (**unchanged**) | caddy + `.sslip.io` + a real ACME cert, scripted in E.4.3b–d | `https://.sslip.io` + `/carapace-claude` | | 4b | **Home box behind a router** — *the same free HTTPS, ONE rule on the router* | loopback (**unchanged**) | owner forwards **external TCP 443 → ``:443** and nothing else (**no port-80 rule** — the cert is issued over TLS-ALPN-01 on 443); guided in E.4.2b–c, built in E.4.3b–d | `https://.sslip.io` + `/carapace-claude` | | 4-insecure | **Direct public IP, plain http** — testing opt-out only (`REACH_INSECURE_HTTP=yes`; it is **not** a `REACH_CHOICE` value — you select it as option 4 plus that flag. The menu's `[4b]` is a different thing entirely: the guided port-forward) | `GW_BIND=` | none — **the bearer token then crosses the internet in cleartext** | `http://:18800` | **OPTION 4 REALITY CHECK — what is actually true as of app build 435.** The CARAPACE app **does** permit plain `http` to a public address: the App Transport Security opt-out ships in the app, the request leaves the phone, and the gateway answers it. So the old claim on this line — "an iPhone refuses plain http over the internet" — is **no longer correct for this app** and has been removed. What plain `http` still costs is confidentiality: **the bearer token is sent on every single request, in the clear**, so anyone who can watch the path (café Wi-Fi, hotel router, any hop in between) can copy it and drive the owner's runtime. That is why **https is the product default, and option 4 now builds it for you instead of sending you elsewhere**: - **Option 4's default IS automatic HTTPS.** E.4.3b–E.4.3d install caddy, take the hostname `.sslip.io` (sslip.io is a public wildcard DNS service that answers `203-0-113-10.sslip.io` → `203.0.113.10`: no account, no records, no cost), let ACME issue a **real certificate** over HTTP-01, and reverse-proxy `/carapace-claude/*` to the gateway. The gateway **stays on 127.0.0.1** in this mode — the TLS front is the boundary, not a second bind. - **A machine at home is [4b], not [4].** A house box never holds the public address — the router does — so option 4 has nothing to certify there. What it *can* do is answer **one** forwarded port. The owner adds a single rule (external **TCP 443** → ``:443), caddy runs **here**, and the certificate names `.sslip.io`. Two consequences fall straight out of the mechanism and neither is optional: the external port **must be 443** (the ACME TLS-ALPN-01 check only ever dials 443, and the app drops a non-standard port from an `https` base), and the certificate is issued over **TLS-ALPN-01, never HTTP-01** — which is why **no port-80 rule is needed** and an ISP that blocks inbound 80 changes nothing. Proven end to end on a real NAT: the CA validated this box from several public vantage points and the phone's whole triad answered through the forward. **The box cannot check its own forward** — so it does not pretend to; the certificate is the proof, and E.4.3d/E.4.4 probe the box's own public name over the LAN address with `curl --resolve` so a non-hairpinning router cannot false-fail the install. - **Pick [2] instead if you already have a domain, tunnel or reverse proxy** — that is your TLS, it exists already, and it needs no new listener on 80/443. - **Pick [3] Tailscale** if you would rather expose nothing to the public internet at all: `serve` gives HTTPS inside the tailnet, with Funnel off. - **Plain `http` is an explicit opt-out, never a default and never an auto-pick.** `REACH_INSECURE_HTTP=yes` on E.4.2's EDIT line binds the gateway straight onto the public address with no TLS. The caveat is printed to the owner verbatim and `INSECURE_HTTP_CHOSEN` is recorded in the reach env **and** the Final Report. > *Footnote — phones on builds older than 435.* Those builds shipped ATS **without** the opt-out and > refused plain `http` to any non-local address before the request ever left the phone > (`NSURLErrorAppTransportSecurityRequiresSecureConnection`, error **-1022** — measured as 250 > consecutive failures against a public-IP gateway; the app also ships `NSAllowsLocalNetworking`, > which is why LAN `http` always worked). On such a phone the `REACH_INSECURE_HTTP=yes` path pairs > nothing at all, and option 4's HTTPS default is the only option 4 that works. **How the app composes the URL — this is why the shapes above are exact, not stylistic.** The consumer of the QR is `SettingsManager.openClawStatusBaseURL(forHost:runtimeKind:runtimeBasePath:)`, and it branches on the scheme: - `https://` → the app **appends `basePath`** and talks to `https:///carapace-claude/api/app/v1/…`. That is why options 2 and 3 need something in front that **mounts a path and strips the prefix** (`tailscale serve --set-path`, or the owner's proxy). This gateway strips only `/api/app/v1` — it will never strip `/carapace-claude` for you. - `http://:` → the app uses the base **verbatim** and `basePath` is **ignored**: requests go to `http://:/api/app/v1/…`, which is exactly what a directly-bound gateway answers. That is why option 1 — and option 4 under the `REACH_INSECURE_HTTP=yes` opt-out — must always carry an **explicit port**. Option 4's default is an `https://` base, so it takes the branch above and the basePath is appended, which is exactly what caddy's `handle_path` mount expects. - `http://` with **no port** is the trap: the app appends its own built-in default port for this runtime, not necessarily 18800. Never emit that shape. - `https://:` is the other trap: the https branch appends `basePath` regardless of the port, so it only works if a path mount really exists there. Simulate the consumer's parser, do not guess at it: E.4.4 below builds the URL the same way the app does and proves it with a real request **before** any QR exists. **E.4.1 Detect — no questions, nothing written outside `~/.carapace`, nothing changed.** ```bash umask 077 mkdir -p "$HOME/.carapace"; chmod 700 "$HOME/.carapace" DET="$HOME/.carapace/reach-claude.detect"; : > "$DET"; chmod 600 "$DET" ADDRF="$HOME/.carapace/.reach-addrs"; : > "$ADDRF"; chmod 600 "$ADDRF" OS="$(uname -s)"; NODE="$(command -v node || true)" # ---- 0. Resolve the tools BY ABSOLUTE PATH as well as by PATH. `ip` lives in /sbin or /usr/sbin # on many distros, `ifconfig` in /sbin, macOS's `ipconfig` in /usr/sbin, and the Tailscale CLI in # /usr/local/bin, /opt/homebrew/bin or inside the .app — none of which are guaranteed on a minimal # non-interactive PATH. Verified on the reference Mac: `command -v ifconfig` and # `command -v tailscale` BOTH came back empty on a box that has both. A tool that cannot be found # must read as UNPROVEN, never as "this box has no LAN" — the second one silently converts a # zero-input install into one that asks the owner a question it could have answered itself. IPBIN=""; for c in "$(command -v ip 2>/dev/null)" /sbin/ip /usr/sbin/ip /bin/ip /usr/bin/ip; do [ -n "$c" ] && [ -x "$c" ] && { IPBIN="$c"; break; }; done IFBIN=""; for c in "$(command -v ifconfig 2>/dev/null)" /sbin/ifconfig /usr/sbin/ifconfig; do [ -n "$c" ] && [ -x "$c" ] && { IFBIN="$c"; break; }; done IPCFG=""; for c in "$(command -v ipconfig 2>/dev/null)" /usr/sbin/ipconfig; do [ -n "$c" ] && [ -x "$c" ] && { IPCFG="$c"; break; }; done TSBIN=""; for c in "$(command -v tailscale 2>/dev/null)" /usr/local/bin/tailscale \ /opt/homebrew/bin/tailscale /usr/bin/tailscale \ /Applications/Tailscale.app/Contents/MacOS/Tailscale; do [ -n "$c" ] && [ -x "$c" ] && { TSBIN="$c"; break; }; done # ---- 1. Addresses that are actually ON this machine's interfaces, one per line into a FILE. # A file, not a variable: zsh (the macOS default shell) does NOT word-split an unquoted parameter, # so `for A in $ADDRS` would iterate ONCE with every address glued together — and a `while read` # after a PIPE runs in a subshell that loses the results. `while read < file` is the only form # that is correct in sh, bash and zsh at once. ADDR_SRC=none if [ -n "$IPBIN" ]; then "$IPBIN" -4 -o addr show scope global 2>/dev/null \ | awk '$2 !~ /^(lo|docker|br-|veth|virbr|tailscale|utun|zt|wg)/ {split($4,a,"/"); print a[1]}' > "$ADDRF" [ -s "$ADDRF" ] && ADDR_SRC=ip fi if [ ! -s "$ADDRF" ] && [ -n "$IFBIN" ] && [ -n "$IPCFG" ]; then for IF in $("$IFBIN" -l 2>/dev/null); do case "$IF" in lo*|utun*|awdl*|llw*|bridge*|gif*|stf*|anpi*|ap[0-9]*) continue ;; esac A="$("$IPCFG" getifaddr "$IF" 2>/dev/null || true)" [ -n "$A" ] && echo "$A" >> "$ADDRF" done [ -s "$ADDRF" ] && ADDR_SRC=ipconfig fi if [ ! -s "$ADDRF" ] && [ -n "$IFBIN" ]; then "$IFBIN" 2>/dev/null \ | awk '/^[a-zA-Z0-9]+:/{ifc=$1} /^[[:space:]]*inet /{if (ifc !~ /^(lo|utun|awdl|llw|bridge|gif|stf|anpi)/) {sub("addr:","",$2); print $2}}' > "$ADDRF" [ -s "$ADDRF" ] && ADDR_SRC=ifconfig fi # ---- 2. Classify. 100.64/10 is TAILSCALE's CGNAT range: it is neither LAN nor public, and a # phone that is not on the tailnet cannot reach it — classifying it as "LAN" was the obvious bug # to avoid here. RFC1918 = LAN. Anything else that sits on a real interface = a PUBLIC IP the # box owns (the VPS case), which is what makes option 4 possible WITHOUT port forwarding. LAN_IP=""; OWN_PUBLIC_IP=""; TS_CGNAT="" while read -r A; do [ -n "$A" ] || continue case "$A" in 10.*|192.168.*) [ -n "$LAN_IP" ] || LAN_IP="$A" ;; 172.1[6-9].*|172.2[0-9].*|172.3[01].*) [ -n "$LAN_IP" ] || LAN_IP="$A" ;; 100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*) [ -n "$TS_CGNAT" ] || TS_CGNAT="$A" ;; 127.*|169.254.*) : ;; *) [ -n "$OWN_PUBLIC_IP" ] || OWN_PUBLIC_IP="$A" ;; esac done < "$ADDRF" rm -f "$ADDRF" # ---- 2b. The hostname option 4 will get a certificate for: 203.0.113.10 -> 203-0-113-10.sslip.io. # sslip.io is a public wildcard DNS service that answers with the address embedded in the name, so # it needs no account, no records and no money — and because it genuinely RESOLVES, a public CA can # issue a real certificate for it over HTTP-01. Derived here so the decision record carries it; # nothing is contacted and nothing is installed by this line. SSLIP_HOST="" [ -n "$OWN_PUBLIC_IP" ] && SSLIP_HOST="$(printf '%s' "$OWN_PUBLIC_IP" | tr '.' '-').sslip.io" # ---- 3. Public IP as the internet sees it. BEST-EFFORT ONLY: short timeout, failure is normal # (air-gapped box, no DNS, blocked egress) and must never stall the install. It is used for ONE # decision — whether the public address is the SAME address the box is bound to (VPS) or a NAT # router in front of it (home), which is the difference between "option 4 works" and "option 4 # needs port forwarding the owner has not done". # `-4` forces IPv4. Without it these services answer with the box's IPv6 when it has one, and the # digit filter turns `2604:2020:2300:9::…` into `2604202023009` — a meaningless number that # BEHIND_NAT then compares against an IPv4 address. (Observed on the reference VPS: it recorded # BEHIND_NAT=yes on a box that is not behind NAT at all.) SEEN_PUBLIC_IP="$(curl -4 -s -m 3 https://ifconfig.me 2>/dev/null | tr -dc '0-9.' | head -c 45)" [ -n "$SEEN_PUBLIC_IP" ] || SEEN_PUBLIC_IP="$(curl -4 -s -m 3 https://api.ipify.org 2>/dev/null | tr -dc '0-9.' | head -c 45)" # Shape gate: four dotted decimal groups or nothing. An unusable answer must be recorded as # absent, never as a number that reads like an address in the decision record. case "$SEEN_PUBLIC_IP" in [0-9]*.[0-9]*.[0-9]*.[0-9]*) : ;; *) SEEN_PUBLIC_IP="" ;; esac # ---- 4. Existing tailscale — READ ONLY. Never install, never `up`, never sign in from here. TS_STATE=NOT_INSTALLED; TS_HOST="" if [ -n "$TSBIN" ] && [ -x "$NODE" ]; then TS_STATE="$("$TSBIN" status --json 2>/dev/null | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(JSON.parse(s).BackendState||"UNKNOWN")}catch(e){process.stdout.write("UNKNOWN")}})')" TS_HOST="$("$TSBIN" status --json 2>/dev/null | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(String((JSON.parse(s).Self||{}).DNSName||"").replace(/\.$/,"").toLowerCase())}catch(e){}})')" case "$TS_HOST" in *.ts.net) : ;; *) TS_HOST="" ;; esac elif [ -n "$TSBIN" ]; then TS_STATE=NO_NODE_PARSER fi [ -n "$TS_STATE" ] || TS_STATE=UNKNOWN # ---- 5. Reverse-proxy HINTS. A hint is not a configuration: it only tells you option 2 is # plausible on this box. It never auto-selects anything, because option 2 needs a URL only the # owner knows — and a URL is an INPUT, which is the thing this phase exists to avoid. PROXY_HINT="" for B in caddy nginx traefik haproxy cloudflared frpc ngrok; do command -v "$B" >/dev/null 2>&1 && PROXY_HINT="$PROXY_HINT $B" done if command -v systemctl >/dev/null 2>&1; then U="$(systemctl list-units --type=service --state=running --no-legend --no-pager 2>/dev/null \ | awk '{print $1}' | grep -Ei 'caddy|nginx|traefik|haproxy|cloudflared|frpc' | tr '\n' ' ')" [ -n "$U" ] && PROXY_HINT="$PROXY_HINT [running: $U]" fi # ---- 5b. WHO holds 80/443, not merely whether something does. Option 4's HTTPS front needs 443 # (and prefers 80 for the ACME HTTP-01 challenge), and every holder has a DIFFERENT right answer: # tailscaled means option 3 is already built here; another web server means "add one route to it", # never "take the port". A name that cannot be read as this user is recorded as `unknown` — not as # "free", which is the same UNPROVEN-vs-absent distinction the address tools above insist on. who_holds() { _P="$1"; _L=""; _N="" if command -v ss >/dev/null 2>&1; then _L="$(ss -ltnp 2>/dev/null | awk -v p="$_P" '$4 ~ "[:.]"p"$" {print; exit}')" if [ -n "$_L" ]; then _N="$(printf '%s' "$_L" | sed -n 's/.*users:(("\([^"]*\)".*/\1/p')" [ -n "$_N" ] || _N=unknown printf '%s' "$_N"; return 0 fi fi if command -v lsof >/dev/null 2>&1; then _N="$(lsof -nP -iTCP:"$_P" -sTCP:LISTEN 2>/dev/null | awk 'NR==2{print $1}')" [ -n "$_N" ] && { printf '%s' "$_N"; return 0; } fi printf '' } WEB443_OWNER="$(who_holds 443)"; WEB80_OWNER="$(who_holds 80)" WEBPORT="" [ -n "$WEB443_OWNER" ] && WEBPORT="443" [ -n "$WEB80_OWNER" ] && WEBPORT="${WEBPORT:+$WEBPORT,}80" # ---- 6. The recommendation. ONE ordered decision, BEST match first — never a run of separate # `if`s. The old form was a sequential-if chain, so the LAST test that matched won rather than the # best one: on a tailnet-connected VPS the public-IP line overwrote the tailscale line. Priority # order, highest first, and it is NOT reorderable: # 1st tailscale Running (+ a real *.ts.net host) -> 3 (HTTPS with no cert work, one command) # 2nd reverse-proxy hints on this box -> 2 (it already terminates TLS; the URL is # still the owner's to give) # 3rd private LAN IP -> 1 (home box: bind the LAN address) # 4th bare public IP on an interface -> 4 (VPS: E.4.3b-d put free HTTPS in front of # the loopback gateway — caddy + sslip.io + # a real cert — so the auto branch RUNS it) # else -> 2 (only the owner knows the way in) # `elif` is the whole point: the FIRST branch that matches wins and nothing below it may overwrite # it. Do not flatten this back into independent `if` statements, and do not reorder the branches. if [ "$ADDR_SRC" = none ]; then RECOMMEND=0 WHY="UNPROVEN: no usable address tool (ip / ifconfig+ipconfig) could be found or run, so the addresses on this box were never read. This is a MISSING TOOL, not a missing network." elif [ "$TS_STATE" = Running ] && [ -n "$TS_HOST" ]; then RECOMMEND=3; WHY="tailscale is already up on this box ($TS_HOST) — keep loopback and publish with serve, exactly as before" elif [ -n "$PROXY_HINT" ]; then RECOMMEND=2; WHY="this box already runs something that terminates TLS ($PROXY_HINT) — their own HTTPS front door beats a plain-http bind, but only they have its URL" elif [ -n "$LAN_IP" ]; then RECOMMEND=1; WHY="a private LAN address exists ($LAN_IP) and needs no extra setup" elif [ -n "$OWN_PUBLIC_IP" ]; then RECOMMEND=4; WHY="this box holds a PUBLIC address on its own interface ($OWN_PUBLIC_IP) — the VPS case. Option 4 now builds its own free HTTPS front (caddy + $SSLIP_HOST + a real ACME certificate), so it is a COMPLETE path and the unattended branch runs it instead of stopping" else RECOMMEND=2; WHY="no LAN address, no public interface address and no tailnet — only the owner knows the way in" fi NAT=unknown if [ -n "$SEEN_PUBLIC_IP" ]; then if [ "$SEEN_PUBLIC_IP" = "$OWN_PUBLIC_IP" ]; then NAT=no; else NAT=yes; fi fi # EVERY value is written single-quoted. This file is `.`-sourced by the blocks below, and an # unquoted `WHY=a private LAN address exists (192.168.1.5)` is a shell SYNTAX ERROR — parentheses, # brackets and spaces all appear in these values. Quote at the write, not at the read. { printf "OS='%s'\n" "$OS" printf "ADDR_SRC='%s'\n" "$ADDR_SRC" printf "LAN_IP='%s'\n" "$LAN_IP" printf "OWN_PUBLIC_IP='%s'\n" "$OWN_PUBLIC_IP" printf "SEEN_PUBLIC_IP='%s'\n" "$SEEN_PUBLIC_IP" printf "BEHIND_NAT='%s'\n" "$NAT" printf "TS_STATE='%s'\n" "$TS_STATE" printf "TS_HOST='%s'\n" "$TS_HOST" printf "TS_CGNAT='%s'\n" "$TS_CGNAT" printf "PROXY_HINT='%s'\n" "$PROXY_HINT" printf "WEB_PORTS_LISTENING='%s'\n" "$WEBPORT" printf "WEB_443_OWNER='%s'\n" "$WEB443_OWNER" printf "WEB_80_OWNER='%s'\n" "$WEB80_OWNER" printf "SSLIP_HOST='%s'\n" "$SSLIP_HOST" printf "RECOMMEND='%s'\n" "$RECOMMEND" printf "WHY='%s'\n" "$WHY" } > "$DET" chmod 600 "$DET" cat "$DET" if [ "$RECOMMEND" = 0 ]; then echo "STOP: REACH_DETECT_UNPROVEN — no address tool ran, so 'no LAN' here means 'not looked at'." echo " Install one (Debian/Ubuntu: sudo apt-get install -y iproute2; Fedora/RHEL: sudo dnf install -y iproute)" echo " or put /sbin and /usr/sbin on PATH, then re-run E.4.1. Do NOT ask the owner a question" echo " that a missing binary created." else echo "REACH_DETECT_OK (nothing was changed; recommendation = option $RECOMMEND)" fi ``` Read the output as: `LAN_IP` present ⇒ option 1 is available with **zero** extra setup. `OWN_PUBLIC_IP` present ⇒ this is a VPS, and option 4 is a **complete** path on its own: `SSLIP_HOST` is the hostname E.4.3b–d will get a real certificate for, so the phone gets `https://…` without the owner owning a domain. `WEB_443_OWNER` / `WEB_80_OWNER` name whoever already holds those ports — the one thing that can block that front door, and E.4.3b turns each holder into a specific answer (`tailscaled` ⇒ option 3 is already built here, prefer it; another web server ⇒ one reverse-proxy snippet for *that* server, recorded as a `REQUIRED_ACTION`) instead of a fight for the port. `BEHIND_NAT=yes` ⇒ the public address belongs to a **router**, not to this box — the home case, and the whole reason **option 4b** exists: ONE forwarded port (external TCP 443 → this box:443) and E.4.2b–c / E.4.3b–d build exactly the same automatic HTTPS. It is **never** auto-selected, because the one thing it needs is a rule on a router this script cannot see, reach or change, and `auto` may not claim an owner made a change they were never asked about. `TS_STATE=Running` ⇒ option 3 is available and is the recommendation, because it is already paid for. `PROXY_HINT` non-empty ⇒ this box already runs something that terminates TLS, so option 2 **outranks a LAN bind** in the recommendation — but only the owner has the URL, so it surfaces the ONE question instead of selecting anything for them. **The recommendation is a strict PRIORITY ORDER, not a list of equals.** Highest match wins, the code says `elif` for exactly that reason, and the order is: **tailscale Running → 3**, else **reverse-proxy hints → 2**, else **private LAN IP → 1**, else **bare public IP → 4** (which the unattended branch now *runs*, HTTPS front and all — see the reality check above), else **2**. Reordering these branches, or flattening them back into separate `if` statements where the LAST match silently wins, re-introduces the exact bug this form exists to kill: a tailnet-connected VPS that recommends a plain-http public bind. **E.4.2 Decide — the ONE question, and the rule for when nobody is there to answer it.** When the owner **is** in the conversation, ask exactly this, once, and nothing else: > **How will your phone reach this box?** > **[1] Same Wi-Fi / LAN** — I'm on the same network as this machine. *(no extra setup)* > **[2] I already have a way in** — a domain, tunnel or reverse proxy. *(paste the public HTTPS base URL)* > **[3] Tailscale** — recommended if you want it to work from anywhere and don't want to build that yourself. *(I'll install and configure it)* > **[4] Direct public IP — I'll set up free HTTPS automatically** — this is a VPS with its own public address. *(I install caddy, take the free hostname `.sslip.io`, get a real certificate that renews itself, and put it in front of the gateway — which stays on loopback. No domain, no account, no cost, nothing for you to do. Already have a domain/tunnel/proxy? [2] is less work. Want nothing on the public internet at all? [3].)* > **[4b] It's a machine at home — I'll add ONE rule to my router** — the public address belongs to your router, so [4] cannot work here until one port arrives. *(You forward **external TCP 443 → this machine, port 443**, and nothing else: **no port-80 rule**, because the certificate is issued over TLS-ALPN-01 on 443, so an ISP that blocks inbound 80 changes nothing. I check for carrier-grade NAT first — on that kind of line no forward can ever work — then walk you through the one rule, prove this gateway already rejects every request that does not carry your CARAPACE key **before** anything is exposed, and then let the certificate authority be the witness: a real certificate for your address cannot be issued unless it reached this box from the internet. This exposes port 443 on this machine only. [3] Tailscale is the alternative that needs no forward at all.)* **`[4b]` here means the GUIDED PORT FORWARD, and nothing else.** (`REACH_CHOICE=4b` selects only this. The reachability table above lists the plain-http testing opt-out as `4-insecure` precisely so the two can never be confused — that one is option **4** with `REACH_INSECURE_HTTP=yes`, and it is not a `REACH_CHOICE` value at all.) **`[4b]` is a NAT-only line: offer it only when E.4.1 recorded `BEHIND_NAT=yes`** *and* a `LAN_IP` for the rule to point at. On a VPS it is noise (that box already holds its own public address — that is plain option 4), and on a box whose addresses were never read it is a guess. Everything it does lives in **E.4.2b** (the CGNAT gate and the one-rule walkthrough) and **E.4.2c** (the lock check and the only write). **`auto` never selects it**: the rule lives on a router this prompt cannot see, so an unattended run may not assume one was made. The forward itself is proven later and elsewhere — by the certificate authority, in **E.4.3d** (`PORTFORWARD_PROVEN_BY_ACME`). Then set the one variable below. **If no human is answering, leave it at `auto`** — that is not a placeholder, it is the sanctioned unattended path: it takes E.4.1's recommendation, records that nobody was asked, and **never stalls**. Blocking an unattended install on a question with no offline substitute is the one failure this phase is designed to make impossible. ```bash # EDIT AT MOST THREE LINES. `auto` is correct whenever no human is answering questions in this session. REACH_CHOICE=auto # <-- auto | 1 | 2 | 3 | 4 | 4b # 4b = option 4 on a line where a router owns the public address: # E.4.2b guides the ONE rule (external tcp/443 -> this box:443, no # port-80 rule), E.4.2c proves the LOCK before anything is exposed, # and E.4.3d's certificate is the third-party proof the forward # works. Never auto-picked — a router rule is a human action. REACH_PUBLIC_BASE="" # <-- OPTION 2 ONLY: the full base URL, e.g. https://box.example.com # (or http://host:port). Leave empty for every other option. REACH_INSECURE_HTTP=no # <-- OPTION 4 ONLY, TESTING ONLY: `yes` opts OUT of the automatic HTTPS # front and binds plain http on the public address instead. Your # bearer token then rides CLEARTEXT over the internet on every # request. Never set by `auto`; leave it `no`. umask 077 DET="$HOME/.carapace/reach-claude.detect"; ENVF="$HOME/.carapace/reach-claude.env" if [ ! -s "$DET" ]; then echo "STOP: no detection record — run E.4.1 first (it writes $DET)"; else . "$DET" 2>/dev/null || true DECIDED_BY=owner; INPUTS=1; INPUTS_LIST="reachability choice" if [ "$REACH_CHOICE" = auto ]; then REACH_CHOICE="$RECOMMEND"; DECIDED_BY=auto; INPUTS=0; INPUTS_LIST=none case "$RECOMMEND" in 2) # The one auto-pick that CANNOT be honoured: option 2 needs a URL only the owner has. This is # the sole case where the question is unavoidable — say why, in the owner's terms. echo "STOP: this box cannot be reached by a phone without the owner's help. Ask the ONE" echo " question now: option 2 (paste an existing HTTPS base URL) or option 3 (install" echo " Tailscale, scripted, one command)." echo " Detection said: $WHY" [ -n "$PROXY_HINT" ] && echo " Reverse-proxy hints on this box:$PROXY_HINT — ask for THAT front door's HTTPS base first." [ -n "$LAN_IP" ] && echo " No HTTPS front door and the phone is on this network? option 1 (LAN $LAN_IP) is the zero-setup fallback." REACH_CHOICE="" ;; 4) # Option 4 is a COMPLETE path now, so the unattended branch RUNS it instead of stalling: # E.4.3b-E.4.3d put caddy + $SSLIP_HOST + a real certificate in front of the loopback # gateway. What `auto` still may never do is pick PLAIN http — that needs the deliberate # REACH_INSECURE_HTTP=yes edit below, because it puts the bearer token on the wire in clear. echo "AUTO_OPTION4_HTTPS: bare public IP -> option 4 with the automatic HTTPS front" echo " (caddy + ${SSLIP_HOST:-.sslip.io} + a real ACME certificate, built in E.4.3b-E.4.3d)." echo " Detection said: $WHY" if [ "$REACH_INSECURE_HTTP" = yes ]; then echo "NOTE: REACH_INSECURE_HTTP=yes is set, so the plain-http bind is used instead. That is an" echo " EDITED line, not an auto-pick — it is recorded as an owner input below." fi ;; esac fi if [ "$REACH_CHOICE" = 0 ]; then echo "STOP: detection was UNPROVEN (E.4.1 printed REACH_DETECT_UNPROVEN) — fix the missing" echo " address tool and re-run E.4.1 before deciding anything. Nothing was recorded." REACH_CHOICE="" fi BIND_ADDR=127.0.0.1; BASE_URL=""; MODE=""; FAIL=""; TLS_HOST=""; INSECURE_CHOSEN=no case "$REACH_CHOICE" in 1) MODE=lan if [ -z "$LAN_IP" ]; then FAIL="option 1 was chosen but E.4.1 found no private LAN address on any real interface. Re-run E.4.1 with the machine on the network the phone uses, or choose another option." else BIND_ADDR="$LAN_IP"; BASE_URL="http://$LAN_IP:18800"; fi ;; 2) MODE=proxy # Shape gate, enforced BEFORE anything is written: the app's parser (see the table above) # only accepts https://host or http://host:port. Everything else silently misroutes. case "$REACH_PUBLIC_BASE" in "") FAIL="option 2 was chosen but REACH_PUBLIC_BASE is empty — paste the owner's base URL on the EDIT line." ;; https://*/*) FAIL="option 2 base URL must be a bare origin with NO path: give https://host (the app appends /carapace-claude itself)." ;; https://*:*) FAIL="option 2 with an explicit port on https is the trap in the table above — the app still appends /carapace-claude. Use https://host (443) or an http://host:port origin." ;; https://*) BASE_URL="${REACH_PUBLIC_BASE%/}" ;; http://*:*) BASE_URL="${REACH_PUBLIC_BASE%/}" ;; http://*) FAIL="option 2 over http needs an EXPLICIT port (http://host:port) — without one the app substitutes its own default port for this runtime." ;; *) FAIL="option 2 base URL must start with https:// or http:// — got '$REACH_PUBLIC_BASE'." ;; esac [ -n "$BASE_URL" ] && [ "$DECIDED_BY" = owner ] && { INPUTS=2; INPUTS_LIST="reachability choice, public base URL"; } ;; 3) MODE=tailscale # The client is installed by E.4a0 below (that is the ONLY place), and BASE_URL is filled in # by E.4a-E.5c once the tailnet host is known. Neither is guessed here. BASE_URL="$( [ -n "$TS_HOST" ] && echo "https://$TS_HOST" )" ;; 4) if [ -n "$OWN_PUBLIC_IP" ]; then if [ "$REACH_INSECURE_HTTP" = yes ]; then # The deliberate testing opt-out. It is a real EDIT by a human, so it is recorded as an # input even when the OPTION itself was auto-selected — `auto` never lands here on its own. MODE=public; BIND_ADDR="$OWN_PUBLIC_IP"; BASE_URL="http://$OWN_PUBLIC_IP:18800"; INSECURE_CHOSEN=yes echo "INSECURE_HTTP_CHOSEN: REACH_INSECURE_HTTP=yes — plain http bound on a PUBLIC address." echo " Your bearer token rides CLEARTEXT over the internet on every request, and anyone" echo " who can watch the path can copy it and drive this runtime. TESTING ONLY." echo " https is ONE RE-RUN AWAY: set REACH_INSECURE_HTTP=no and re-run E.4.2 -> E.4.3d," echo " which builds the certificate for you in about a minute." if [ "$DECIDED_BY" = auto ]; then INPUTS=1; INPUTS_LIST="insecure-http opt-out (REACH_INSECURE_HTTP=yes)"; fi else # THE DEFAULT. The gateway does not move: BIND_ADDR stays 127.0.0.1 and caddy is the # boundary. BASE_URL is https + the sslip.io host, which is exactly the shape the app # composes basePath onto (the https branch of the table above). MODE=public-tls TLS_HOST="${SSLIP_HOST:-$(printf '%s' "$OWN_PUBLIC_IP" | tr '.' '-').sslip.io}" BASE_URL="https://$TLS_HOST" echo "OPTION4_HTTPS_PLANNED: $BASE_URL" echo " caddy + a real ACME certificate go in front; the gateway STAYS on 127.0.0.1." echo " E.4.3b checks 80/443 first and refuses to fight anyone already holding them." fi elif [ "$BEHIND_NAT" = yes ]; then FAIL="option 4 was chosen but the public address ($SEEN_PUBLIC_IP) is NOT on this box's interfaces — it belongs to the router in front of it, and nothing here can answer the internet until that router forwards ONE port to this box. That is OPTION 4b, not option 4: external TCP 443 -> ${LAN_IP:-the LAN address of this box}:443, ONE rule, port 443 on both sides, no port-80 rule (the certificate is issued over TLS-ALPN-01). THREE ways forward, no dead end: option 1 (same network), option 3 (tailscale — nothing to forward at all), or option 4b — E.4.2b walks the owner through that one rule, E.4.2c proves the lock before anything is exposed, and E.4.3d's certificate is the proof the forward works. The insecure-http opt-out has the same problem and is worse: port 18800 would need forwarding too, in cleartext." else FAIL="option 4 was chosen but no public address was found on any interface. Re-run E.4.1; if this box really is public, set BIND_ADDR by hand and say so in the Final Report."; fi ;; 4b) # The guided port-forward path. This branch decides NOTHING and records NOTHING: it only # proves the question is worth asking — there really is a router in front of this box, this # box has a LAN address the rule can point at, and the line is not carrier-grade NAT — then # hands off to E.4.2b/E.4.2c, which write the decision only after the LOCK is proven. The # FORWARD is not provable from in here at all (see E.4.2c): the certificate in E.4.3d is what # proves it, because a CA has to reach this box from the internet to issue one. if [ "$BEHIND_NAT" != yes ]; then FAIL="option 4b is the port-forward path for a box behind a router, and BEHIND_NAT='$BEHIND_NAT' here. If this box holds its own public address, plain option 4 needs no forward at all; if detection never learned the public address, re-run E.4.1 with egress available." elif [ -z "$LAN_IP" ]; then FAIL="option 4b forwards a router port to THIS box's LAN address and E.4.1 found none. That address is also what every later probe dials to reach caddy here (curl --resolve) instead of asking the router to hairpin its own public name, so the path cannot run without it. Re-run E.4.1 with this machine on the network the router serves." else case "$SEEN_PUBLIC_IP" in 100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*|10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*) FAIL="carrier-grade NAT: the address the internet answered with ($SEEN_PUBLIC_IP) is itself inside a shared/private range, so no router page anywhere can forward it to this box. Say it plainly — 'Your ISP uses carrier-grade NAT — port forwarding cannot work on this connection. Choose Tailscale instead.' — and re-run E.4.2 with REACH_CHOICE=3." ;; *) FAIL=__handled__ echo "FORWARD_GUIDE_REQUIRED: option 4b accepted as a PLAN, not as a decision." echo " Public address seen: $SEEN_PUBLIC_IP (router) this box on the LAN: $LAN_IP" echo " ONE rule is needed on that router: external TCP 443 -> $LAN_IP port 443" echo " No port-80 rule: the certificate is issued over TLS-ALPN-01 on 443." echo " Run E.4.2b now (CGNAT gate + the router walkthrough), then E.4.2c when the owner" echo " says the rule is saved. E.4.2c is the ONLY writer of the record on this path." ;; esac fi ;; "") FAIL=__handled__ ;; *) FAIL="REACH_CHOICE must be auto, 1, 2, 3, 4 or 4b — got '$REACH_CHOICE'." ;; esac if [ -n "$FAIL" ]; then [ "$FAIL" = __handled__ ] || echo "STOP: $FAIL" echo " Nothing was recorded and nothing was changed." else # Single-quoted for the same reason as the detect file: INPUTS_LIST contains spaces and commas, # and every block downstream `.`-sources this record rather than re-deriving the decision. { printf "REACH_OPTION='%s'\n" "$REACH_CHOICE" printf "REACH_MODE='%s'\n" "$MODE" printf "BIND_ADDR='%s'\n" "$BIND_ADDR" printf "BASE_URL='%s'\n" "$BASE_URL" printf "BASE_PATH='%s'\n" "/carapace-claude" printf "TLS_HOST='%s'\n" "$TLS_HOST" printf "INSECURE_HTTP_CHOSEN='%s'\n" "$INSECURE_CHOSEN" printf "DECIDED_BY='%s'\n" "$DECIDED_BY" printf "INPUTS='%s'\n" "$INPUTS" printf "INPUTS_LIST='%s'\n" "$INPUTS_LIST" } > "$ENVF" chmod 600 "$ENVF" cat "$ENVF" echo "REACH_DECIDED: option $REACH_CHOICE ($MODE), decided by $DECIDED_BY, bind $BIND_ADDR" [ "$DECIDED_BY" = auto ] && echo "REACH_UNATTENDED: nobody was asked. Final Report must say 'reachability: option $REACH_CHOICE (auto-selected, owner not asked)' — never 'owner chose'." fi fi ``` Expected: one `REACH_DECIDED:` line, plus `REACH_UNATTENDED:` when nobody was asked. A `STOP:` here means the *choice* contradicts the *machine* — that is a real answer needing a real fix, not a detection failure. **Never write `owner chose` for an `auto` decision**: the whole point of recording `DECIDED_BY` is that a later reviewer can tell the difference. **E.4.2b — Option 4b, step 1 of 2: the carrier-grade NAT gate, then the router walkthrough.** This block changes nothing on this box and nothing on the router. It prints the exact walk, with **this machine's real LAN address in it**, and it refuses to start a walk that cannot end: on a carrier-grade NAT line no port forward can ever work, and walking an owner through one would be a lie. It asks for **one rule and one port** — external TCP **443** → this machine:443 — and that rule points at **this machine only**: nothing else on that network becomes reachable because of it. There is deliberately **no port-80 rule**; the certificate is issued over TLS-ALPN-01 on 443. ```bash umask 077 DET="$HOME/.carapace/reach-claude.detect" if [ ! -s "$DET" ]; then echo "STOP: no detection record — run E.4.1 first (it writes $DET)"; else . "$DET" 2>/dev/null || true if [ "$BEHIND_NAT" != yes ]; then echo "FORWARD_GUIDE_NA (BEHIND_NAT=$BEHIND_NAT) — this walkthrough exists for a box behind a router." echo " A box holding its own public address needs no forward: that is plain option 4." else # ---- 1. CGNAT has TWO tells and only ONE of them can be read from here. The machine-readable one # is checked now; the definitive one lives on the router's own status page and is step 1 of the # walkthrough, before the owner touches a single rule. CG=no case "$SEEN_PUBLIC_IP" in 100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*) CG=yes ;; 10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*) CG=private ;; "") CG=unknown ;; esac # ---- 2. The router's address, so the walkthrough names a real page instead of a guess. Absence is # recorded as absence: a generic "usually ..." is printed, never a fabricated address. GWBIN=""; for c in "$(command -v ip 2>/dev/null)" /sbin/ip /usr/sbin/ip; do [ -n "$c" ] && [ -x "$c" ] && { GWBIN="$c"; break; }; done ROUTER="" [ -n "$GWBIN" ] && ROUTER="$("$GWBIN" route show default 2>/dev/null | awk '{for(i=1;i/dev/null 2>&1; then ROUTER="$(netstat -rn 2>/dev/null | awk '$1=="default" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ {print $2; exit}')" fi FWD_TLS_HOST="$(printf '%s' "$SEEN_PUBLIC_IP" | tr '.' '-').sslip.io" if [ "$CG" = yes ] || [ "$CG" = private ]; then echo "STOP: CARRIER-GRADE NAT. Say this to the owner in these words:" echo " \"Your ISP uses carrier-grade NAT — port forwarding cannot work on this connection." echo " Choose Tailscale instead.\"" echo " Evidence: the address the internet answered with ($SEEN_PUBLIC_IP) is itself inside a" echo " shared or private range, so there is no router page anywhere that can forward it here." echo " Re-run E.4.2 with REACH_CHOICE=3. Do NOT run E.4.2c — it has nothing it could prove." elif [ "$CG" = unknown ]; then echo "STOP: E.4.1 never learned this line's public address (no egress to ifconfig.me / api.ipify.org)," echo " so neither the CGNAT test nor the re-check in E.4.2c has an address to work with." echo " Re-run E.4.1 with egress available, or choose option 1 / option 3." else echo "FORWARD_GUIDE (nothing has been changed anywhere — read this to the owner as written):" echo echo " 1. CHECK FOR CARRIER-GRADE NAT FIRST — this box cannot see it, only your router can." echo " Open your router's admin page (${ROUTER:+http://$ROUTER}${ROUTER:-usually http://192.168.1.1 or http://192.168.0.1}) and find WAN / Internet status." echo " If the WAN address shown there starts with 100.64 through 100.127, or looks like 10.x /" echo " 172.16-31.x / 192.168.x, your ISP uses carrier-grade NAT: port forwarding cannot work on" echo " this connection. Choose Tailscale instead and stop here." echo " If it shows $SEEN_PUBLIC_IP — the address the internet already answered with — forwarding" echo " is possible. Continue." echo " 2. On the same admin page find PORT FORWARDING (some routers call it Virtual Server, NAT" echo " Forwarding, or Applications & Gaming)." echo " 3. Add ONE rule — TCP, pointing at THIS machine — ${LAN_IP:-}:" echo " external TCP 443 -> ${LAN_IP:-} port 443" echo " Port 443 on BOTH sides. Same number in and out, no port translation, and it MUST be 443:" echo " the certificate check only ever dials 443, and the app drops a non-standard port from an" echo " https base, so any other external port cannot work." echo " There is NO second rule. You do NOT need to forward port 80 — the certificate is issued" echo " over TLS-ALPN-01 on 443, so an ISP that blocks inbound 80 changes nothing here." echo " Only this machine is exposed by that one rule — nothing else on your network becomes" echo " reachable from the internet because of it." echo " 4. In the same router, give this machine a DHCP RESERVATION (also called static lease or" echo " address reservation) for ${LAN_IP:-}, so the forward cannot start pointing at a" echo " different computer after a reboot." echo " 5. Save / Apply — some routers need a second Apply, a few need a reboot — then say you are" echo " done and I continue (E.4.2c)." echo echo " FACTS, said once each, no editorial:" echo " - This exposes port 443 on this machine only; every request requires your CARAPACE key." echo " - The gateway itself stays on 127.0.0.1. caddy is the only thing that answers the forwarded" echo " port, and E.4.2c refuses to record anything unless a tokenless request to a real API" echo " route is ALREADY being rejected by this gateway." echo " - Your router probably cannot answer its own public address from inside your house (NAT" echo " hairpin), so this machine mostly CANNOT test your rule itself. It does not pretend to." echo " The certificate authority is the witness: it has to reach this box on 443 from the public" echo " internet to issue the certificate, so a certificate that appears in E.4.3d IS the proof" echo " your rule works. If it never appears, E.4.3d names the reason." echo " - Home IP addresses change. The hostname your phone will use is $FWD_TLS_HOST, which" echo " contains today's address: if your ISP rotates it, that name no longer points here and" echo " pairing breaks until this phase is re-run. A Tailscale route (option 3) carries no" echo " address in its name and needs no forward." fi fi fi ``` Expected: either `FORWARD_GUIDE` and a five-step walk with **this box's LAN address printed in it**, or one honest `STOP:` that names Tailscale as the route that works on that line. `FORWARD_GUIDE_NA` means 4b was chosen on a box that never needed it. Run **E.4.2c** when the owner says the rule is saved — not before, and not instead. **E.4.2c — Option 4b, step 2 of 2: prove it is LOCKED, record, and let the certificate prove the forward.** Two gates, in this order, and only the first one may stop the install: 1. **LOCKED** — a tokenless request to a real API route (`/api/app/v1/status`) must be refused (`401`/`403`) while `/health` still answers `200`. This runs on loopback, *before* anything is exposed: the point of the order is that a gateway which answers strangers is never the thing you forward a port to. Reachable-but-open is a **failure**, not a partial success. 2. **REACHABLE** — and here is the honesty this path was missing: **a box behind a router usually cannot measure its own forward.** Most home routers refuse to loop a LAN client back through their own public address (NAT hairpin), so a silent self-probe is evidence of nothing. The authoritative proof is issued by a **third party** a few blocks later: a public CA cannot sign a certificate for `.sslip.io` unless it reached caddy **on this box, on 443, through the router's rule**, from several public vantage points. E.4.3d reads that back as `PORTFORWARD_PROVEN_BY_ACME`. The **cellular nonce read-back** below is the alternative external check — available immediately, and stronger than any self-probe. The self-probe survives only because a router that *does* hairpin hands you a free answer. Exactly one reachability outcome stops this block: something answered through the public address that **is not this machine** (an unsaved rule pointing at the router's own admin page, or at the wrong computer). Silence continues on purpose. `BASE_URL` comes out as `https://.sslip.io` — the same shape option 4 produces — and the record also carries `NAT_FORWARD=yes` + `LAN_IP`, which is what makes E.4.3a open **443 only**, E.4.3b force **TLS-ALPN-01**, and E.4.3d/E.4.4 probe this box's own public name **through the LAN address** (`curl --resolve :443:`) instead of asking the router to hairpin. ```bash umask 077 PHONE_SAW="" # <-- OPTIONAL, for the cellular re-check described at the bottom of this # block: paste EXACTLY what the phone showed. Empty on the first run. DET="$HOME/.carapace/reach-claude.detect"; ENVF="$HOME/.carapace/reach-claude.env"; GWPORT=18800 NONCEF="$HOME/.carapace/.reach-fwd-claude.nonce" LOGF="$HOME/.carapace/.reach-fwd-claude.log" if [ ! -s "$DET" ]; then echo "STOP: no detection record — run E.4.1 first (it writes $DET)"; else . "$DET" 2>/dev/null || true VERIFIED=no; PROOF="" # ---- GATE 1 (ALWAYS FIRST): is the thing we are about to put behind a public port actually locked? # The same triad Phase D proved, re-measured now rather than trusted: a run that installed cleanly # hours ago is not evidence about the process answering right now. LH="$(curl -s -m 5 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$GWPORT/health" 2>/dev/null)" LN="$(curl -s -m 5 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$GWPORT/api/app/v1/status" 2>/dev/null)" echo "LOCK_CHECK: /health -> $LH (expect 200) tokenless /api/app/v1/status -> $LN (expect 401 or 403)" LOCKED=no case "$LH:$LN" in 200:401|200:403) LOCKED=yes ;; esac if [ "$LOCKED" != yes ]; then echo "STOP: this gateway is NOT key-locked, so nothing here gets a public port. Reachable-but-open" echo " is a failure, not a partial success." case "$LH" in 200) : ;; 000) echo " /health answered $LH — the gateway is not running at all. Re-run E.1c." ;; *) echo " /health answered $LH — resolve that first; the lock verdict is meaningless until it is 200." ;; esac case "$LN" in 200) echo " A tokenless /api/app/v1/status returned 200: strangers are being served TODAY, on loopback." ;; esac echo " This gateway has no strict-auth switch to turn on: it refuses tokenless requests on" echo " every route except /health by default, which is exactly what Phase D's 200/401/200" echo " triad proved. A 200 here means the install is wrong, not that an option is missing —" echo " fix Phase D and re-run this block." echo " Nothing was recorded and nothing was started." else echo "LOCK_OK (every route needs the CARAPACE key; /health is the only exempt surface)" # ---- GATE 2: the forward. THE HONEST PART — this box very often CANNOT measure its own forward. # Most home routers refuse to hairpin: a client inside the LAN asking for the router's own public # address gets nothing back (measured on the reference NAT — refused in 3ms, on a forward that was # answering the public internet perfectly at that moment). A silent self-probe is therefore NOT a # verdict about the router, and this block no longer blocks on one. Three proofs exist, weakest last: # 1. THE CERTIFICATE (E.4.3d). A public CA cannot issue a certificate for this name without # reaching caddy ON THIS BOX, on 443, through the router's rule, from several public vantage # points seconds earlier. That is a third party measuring the one thing a box behind NAT can # never measure about itself, and on option 4b it is THE proof: PORTFORWARD_PROVEN_BY_ACME. # 2. A phone on CELLULAR reading back the nonce below — genuinely outside this network. # 3. This box's own probe through the public address: real when it answers, meaningless when it # does not, because a non-hairpinning router looks exactly like a missing rule from in here. # The previous version required (3) and stopped after three failures, which turned a PERFECT forward # on a non-hairpinning router into three "failures" and a dead end. That bound is gone on purpose — # the thing allowed to fail this path is the certificate, and it names its own reason when it does. PREV="$(cat "$NONCEF" 2>/dev/null)" if [ -n "$PHONE_SAW" ] && [ -n "$PREV" ]; then case "$PHONE_SAW" in *"$PREV"*) VERIFIED=yes; PROOF="a phone on cellular read back the nonce ($PREV) served here, through $SEEN_PUBLIC_IP:443" ;; *) echo "PHONE_MISMATCH: the phone showed \"$PHONE_SAW\", which does not contain the nonce this box" echo " served ($PREV). Something answered that is NOT this machine — usually the router's own" echo " admin page (the rule was never saved) or another computer on the LAN (wrong internal" echo " address: the rule must point at ${LAN_IP:-the LAN address of this box})." ;; esac elif [ -n "$PHONE_SAW" ]; then echo "PHONE_NONCE_MISSING: no nonce is on disk to compare against — run this block once without" echo " PHONE_SAW first, so there is something for the phone to read." fi FWD=blocked if [ "$VERIFIED" = yes ]; then FWD=proven-external; else NODE="$(command -v node || true)" SUDO=no if [ "$(id -u)" = 0 ]; then SUDO=root elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then SUDO=ok; fi asroot() { if [ "$SUDO" = root ]; then "$@"; else sudo -n "$@"; fi; } BLOCK="" [ -n "$SEEN_PUBLIC_IP" ] || BLOCK="E.4.1 never learned this line's public address, so there is no hostname to certify and nothing to test. Re-run E.4.1 with egress available." [ -z "$BLOCK" ] && [ -z "$LAN_IP" ] && BLOCK="E.4.1 found no LAN address on this box. The router rule has to point at one, and every later probe reaches caddy here THROUGH it (curl --resolve) instead of asking the router to hairpin its own public name. Re-run E.4.1 with this machine on the network the router serves." [ -z "$BLOCK" ] && [ -z "$NODE" ] && BLOCK="node is not on PATH here, and the check needs it to answer on 443 for a few seconds. Fix Phase A first." [ -z "$BLOCK" ] && [ -n "$WEB_443_OWNER" ] && BLOCK="something already holds 443 on this box ($WEB_443_OWNER) and this check does not fight for a port. E.4.3b turns each holder into a specific answer — read it before forwarding anything. Port 80 is irrelevant on this path: the certificate is issued over TLS-ALPN-01 on 443." [ -z "$BLOCK" ] && [ "$SUDO" = no ] && BLOCK="binding 443 needs root and 'sudo -n' failed here. caddy needs the same root in E.4.3b, so this is not an extra cost: run this phase where sudo works, or choose option 3." if [ -n "$BLOCK" ]; then echo "STOP: $BLOCK" echo " Nothing was recorded and nothing was started." else asroot pkill -f 'carapace-fwd-' 2>/dev/null; sleep 1 NONCE="carapace-fwd-$(od -An -N8 -tx1 /dev/urandom 2>/dev/null | tr -dc '0-9a-f')" [ "$NONCE" = "carapace-fwd-" ] && NONCE="carapace-fwd-$$-$(date +%s)" printf '%s' "$NONCE" > "$NONCEF"; chmod 600 "$NONCEF" : > "$LOGF"; chmod 600 "$LOGF" # ONE node process on ONE port — 443, the only port option 4b ever asks anyone to forward — with # a self-timeout: 180 seconds from now it exits on its own, so a check nobody answers cannot # leave a listener on 443 behind. It reads nothing, writes nothing and holds no state; its whole # job is to say one unguessable word back, which is what makes the cellular proof impossible to # fake. Started inside a SUBSHELL so this shell never owns the job (an owned job prints its own # "Terminated" notice into the transcript), and the nonce lives in the process arguments, which # is the handle that stops it below. ( asroot "$NODE" -e 'const n=process.argv[1],h=require("http");h.createServer((q,s)=>{s.writeHead(200,{"content-type":"text/plain"});s.end(n+"\n")}).listen(443,"0.0.0.0").on("error",e=>console.log("listen 443: "+e.code));setTimeout(()=>process.exit(0),180000);' "$NONCE" >>"$LOGF" 2>&1 & ) sleep 2 grep -q 'listen ' "$LOGF" 2>/dev/null && { echo "LISTENER_ERROR (the check itself could not bind — this is NOT a verdict about the router):"; sed 's/^/ /' "$LOGF"; } P443=""; i=0 while [ $i -lt 3 ]; do P443="$(curl -s -m 6 "http://$SEEN_PUBLIC_IP:443/" 2>/dev/null | tr -d '\r\n')" [ -n "$P443" ] && break i=$((i+1)); sleep 3 done case "$P443" in *"$NONCE"*) FWD=proven-hairpin; VERIFIED=yes PROOF="the forwarded port answered the nonce served here, through $SEEN_PUBLIC_IP:443 (this router hairpins)" ;; "") FWD=silent ;; *) FWD=someone-else ;; esac echo "FORWARD_CHECK (via $SEEN_PUBLIC_IP:443): $FWD" if [ "$FWD" = proven-hairpin ]; then asroot pkill -f "$NONCE" 2>/dev/null echo "CHECK_LISTENER_STOPPED (443 is free again for caddy)" fi fi fi # ---- THE VERDICT. Exactly ONE outcome is a failure that must not be recorded: something answered # through the public address and it was not this machine. Silence is not that, and never was. RECORD=no case "$FWD" in proven-external|proven-hairpin) RECORD=yes ;; silent) RECORD=yes PROOF="not measurable from this box — the router does not hairpin its own public address, which says nothing about the forward. The certificate authority is the witness: see PORTFORWARD_PROVEN_BY_ACME in E.4.3d" echo "FORWARD_UNMEASURED_FROM_HERE — nothing came back through $SEEN_PUBLIC_IP:443, and that is the" echo " EXPECTED result on most home routers: they do not loop a LAN client back through their" echo " own public address (NAT hairpin). It is NOT evidence that the rule is missing." echo " Continuing on purpose. The proof that matters is issued by a THIRD PARTY in E.4.3d: a" echo " public CA cannot sign a certificate for this address without reaching caddy on this box," echo " on 443, through your rule. If the rule is missing, if the ISP blocks inbound 443, or if" echo " this line is carrier-grade NAT, the certificate simply never issues and E.4.3d says which." echo " OPTIONAL and stronger than anything this box can do — the listener above stays up ~180s:" echo " On the phone: turn Wi-Fi OFF (cellular only) and open http://$SEEN_PUBLIC_IP:443/" echo " It must print exactly: ${NONCE:-the nonce this block printed}" echo " Then re-run this block with PHONE_SAW=\"\" pasted in." ;; someone-else) echo "STOP: something answered through $SEEN_PUBLIC_IP:443 and it was NOT this machine. That is a" echo " real misconfiguration, not a hairpin: the rule reaches the router's own admin page (it" echo " was never saved), or a different computer on the LAN (wrong internal address — it must" echo " point at ${LAN_IP:-the LAN address of this box}, port 443 on BOTH sides)." echo " Fix the rule and re-run this block. Nothing was recorded." ;; esac if [ "$RECORD" = yes ]; then TLS_HOST="$(printf '%s' "$SEEN_PUBLIC_IP" | tr '.' '-').sslip.io" # Every value below is written single-quoted because this record is `.`-sourced by E.4.3a and by # every block after it, and PROOF is free-form prose: one apostrophe in it would make the whole # file a syntax error and silently drop everything under it. Strip them at the write, the same way # E.4.3d strips them out of the certificate issuer string. PROOF="$(printf '%s' "$PROOF" | tr -d "'")" { printf "REACH_OPTION='%s'\n" "4b" printf "REACH_MODE='%s'\n" "public-tls" printf "NAT_FORWARD='%s'\n" "yes" printf "LAN_IP='%s'\n" "$LAN_IP" printf "BIND_ADDR='%s'\n" "127.0.0.1" printf "BASE_URL='%s'\n" "https://$TLS_HOST" printf "BASE_PATH='%s'\n" "/carapace-claude" printf "TLS_HOST='%s'\n" "$TLS_HOST" printf "INSECURE_HTTP_CHOSEN='%s'\n" "no" printf "FORWARD_PROOF='%s'\n" "$FWD" printf "FORWARD_VERIFIED='%s'\n" "$PROOF" printf "LOCK_VERIFIED='%s'\n" "loopback tokenless /api/app/v1/status -> $LN, /health -> $LH" printf "DECIDED_BY='%s'\n" "owner" printf "INPUTS='%s'\n" "2" printf "INPUTS_LIST='%s'\n" "reachability choice, router port-forward (external tcp/443 -> $LAN_IP:443)" } > "$ENVF" chmod 600 "$ENVF" cat "$ENVF" [ "$FWD" = silent ] || rm -f "$NONCEF" echo "FORWARD_PROOF: $FWD — $PROOF" echo "REACH_DECIDED: option 4b (public-tls behind a router that forwards 443), decided by owner, bind 127.0.0.1" echo " Continue at E.4.3a exactly as option 4 does. NAT_FORWARD=yes changes three things and all" echo " three happen automatically: the host firewall opens 443 and ONLY 443, the certificate is" echo " forced onto TLS-ALPN-01 (no port-80 rule, no HTTP-01, so a blocked inbound 80 is" echo " irrelevant), and every probe of this box's own public name is aimed at $LAN_IP:443 with" echo " curl --resolve, so a router that will not hairpin cannot false-fail the install." echo " E.4.3d is the gate that matters here: PORTFORWARD_PROVEN_BY_ACME + CERT_ISSUED." echo " If E.4.3b reports node holding 443, a check listener is still inside its 180s window —" echo " wait it out, or: sudo pkill -f carapace-fwd-" fi fi fi ``` **Reading E.4.2c.** `LOCK_OK` + `REACH_DECIDED: option 4b` is the pass. `FORWARD_CHECK … proven-hairpin` is a bonus, not a requirement, and `FORWARD_UNMEASURED_FROM_HERE` is the **normal** result on a home router — not a failure. Three honesties to carry forward rather than paper over: - A hairpin answer proves your **router** forwards; it does not prove your **ISP** allows inbound 443. The certificate is the independent proof — the CA's TLS-ALPN-01 challenge arrives from the internet on that same port 443, so a certificate that issues in E.4.3d could not have issued without a forward that works all the way through. - **No port-80 rule is ever asked for on this path, and none is needed.** E.4.3b forces the certificate onto TLS-ALPN-01 (port 443), so an ISP that blocks inbound 80 — which many residential ISPs do — changes nothing here. One rule, one port: external TCP 443 → this box:443. - E.4.3d and E.4.4 probe `https://` **via `curl --resolve :443:`** on this path: same hostname, same SNI, same certificate validation, only the dialled address changes. A `000` measured from inside the LAN was the old false failure and it is gone. If you still want an outside opinion, open `https:///health` on the phone **on cellular** — a `{"ok":true}` there outranks anything measured from in here. **E.4.3 Wire it up.** Exactly one of these four applies. Read `REACH_MODE` and do that one. **Option 1 (and option 4 *only* under the `REACH_INSECURE_HTTP=yes` opt-out) — `GW_BIND` into the supervisor definition.** This is the only change to the service, it is one environment variable, and it is reversible in one line. `GW_BIND` moves the listener off loopback; the **bearer token remains the entire authorization**, and the gateway's bad-auth limiter (20 failures per minute per source IP → `429`) backs it. Both are verified in E.4.4 — not assumed. **Option 4's default (`REACH_MODE=public-tls`) does not come through here at all**: the gateway stays on 127.0.0.1 and E.4.3b–E.4.3d build the TLS front instead. ```bash umask 077 ENVF="$HOME/.carapace/reach-claude.env" if [ ! -s "$ENVF" ]; then echo "STOP: no decision record — run E.4.2 first"; else . "$ENVF" 2>/dev/null || true case "$REACH_MODE" in lan|public) : ;; public-tls) if [ "${NAT_FORWARD:-no}" = yes ]; then echo "SKIP: option 4b keeps the gateway on loopback too — the router forwards 443 to caddy ON THIS BOX and caddy proxies to 127.0.0.1. E.4.3b-E.4.3d wire that front." else echo "SKIP: option 4 with the automatic HTTPS front keeps the gateway on loopback — E.4.3b-E.4.3d wire the TLS front instead of a bind."; fi; BIND_ADDR="" ;; *) echo "SKIP: REACH_MODE=$REACH_MODE does not bind beyond loopback — nothing to wire here."; BIND_ADDR="" ;; esac if [ -n "$BIND_ADDR" ] && [ "$BIND_ADDR" != 127.0.0.1 ]; then OS="$(uname -s)"; LBL=com.carapace.claude-gateway; UNIT=carapace-claude-gateway.service if [ "$OS" = Darwin ]; then PL="$HOME/Library/LaunchAgents/$LBL.plist" if [ ! -f "$PL" ]; then echo "STOP: $PL is missing — C.4 never wrote the supervisor definition. Nothing was changed." else # PlistBuddy, never a rewritten heredoc: the plist already carries PATH, UMask, log paths and # possibly owner-added keys, and re-emitting it from scratch is how those get silently dropped. [ -f "$PL.bak-pre-gwbind" ] || cp -p "$PL" "$PL.bak-pre-gwbind" /usr/libexec/PlistBuddy -c "Add :EnvironmentVariables dict" "$PL" >/dev/null 2>&1 /usr/libexec/PlistBuddy -c "Add :EnvironmentVariables:GW_BIND string $BIND_ADDR" "$PL" >/dev/null 2>&1 \ || /usr/libexec/PlistBuddy -c "Set :EnvironmentVariables:GW_BIND $BIND_ADDR" "$PL" >/dev/null 2>&1 if plutil -lint "$PL" >/dev/null 2>&1 \ && [ "$(/usr/libexec/PlistBuddy -c 'Print :EnvironmentVariables:GW_BIND' "$PL" 2>/dev/null)" = "$BIND_ADDR" ]; then echo "GW_BIND_WRITTEN $BIND_ADDR (plist; one backup at $PL.bak-pre-gwbind)" launchctl bootout "gui/$(id -u)/$LBL" 2>/dev/null launchctl bootstrap "gui/$(id -u)" "$PL" && echo "SUPERVISOR_RELOADED" \ || echo "STOP: launchctl bootstrap failed — the gateway may be down. Re-run E.1a." else echo "STOP: the plist did not take the GW_BIND key (or failed plutil -lint). Restore with:" echo " cp -p $PL.bak-pre-gwbind $PL then re-run E.1a. Nothing else was changed." fi fi else export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" # systemctl --user needs the bus DROPD="$HOME/.config/systemd/user/$UNIT.d" mkdir -p "$DROPD" # A DROP-IN, not an edit of the unit file. systemd merges it, `daemon-reload` picks it up, and # teardown is `rm` of one file — the unit C.4 wrote stays byte-for-byte as installed. printf '[Service]\nEnvironment=GW_BIND=%s\n' "$BIND_ADDR" > "$DROPD/10-carapace-bind.conf" chmod 600 "$DROPD/10-carapace-bind.conf" systemctl --user daemon-reload if systemctl --user restart "$UNIT"; then echo "GW_BIND_WRITTEN $BIND_ADDR (drop-in $DROPD/10-carapace-bind.conf)" echo "SUPERVISOR_RELOADED" else echo "STOP: 'systemctl --user restart $UNIT' failed — read 'journalctl --user -u $UNIT -n 50'." echo " Remove $DROPD/10-carapace-bind.conf and daemon-reload to get back to loopback." fi systemctl --user show -p Environment "$UNIT" | grep -c GW_BIND >/dev/null 2>&1 systemctl --user show -p Environment --value "$UNIT" | tr ' ' '\n' | grep '^GW_BIND=' || \ echo "NOTE: GW_BIND is not in the unit's merged environment — the drop-in did not take. Check the path above." fi fi fi ``` **E.4.3a Firewall — a bound port the host firewall drops looks exactly like a broken install.** Option 1 (and option 4's insecure opt-out) move the listener onto a real interface; option 4's **default** puts caddy on 80/443 instead. Either way a stock VPS image (plus plenty of desktop Linux) ships `ufw`/`firewalld` **enabled with a default-deny INPUT**, so every check on this box passes while the phone sees a silent timeout. Detect the firewall, then open **exactly the ports this mode needs** — the gateway port for a direct bind, or **80 + 443** for the HTTPS front, and never the raw gateway port when caddy is the front door. Never disable the firewall, never open a range, never touch an existing rule. ```bash umask 077 ENVF="$HOME/.carapace/reach-claude.env"; GWPORT=18800 if [ ! -s "$ENVF" ]; then echo "STOP: no decision record — run E.4.2 first"; else . "$ENVF" 2>/dev/null || true # WHICH ports have to be reachable is a function of the mode, and the list is carried in "$@": # `for P in $PORTS` would iterate ONCE under zsh, gluing "80 443" into a single word (the same # no-word-split trap E.4.1 documents for addresses). `for P in "$@"` is correct in sh, bash and zsh. case "$REACH_MODE" in lan|public) set -- "$GWPORT"; PORTLBL="$GWPORT/tcp"; REACHLBL="$BIND_ADDR:$GWPORT" SCOPENOTE="the gateway is bound to $BIND_ADDR, so this IS the port the phone talks to" ;; public-tls) if [ "${NAT_FORWARD:-no}" = yes ]; then # OPTION 4b forwards ONE port, so the host firewall opens ONE port. No port-80 rule: # the certificate is issued over TLS-ALPN-01 on 443, which is also why an ISP that # blocks inbound 80 is irrelevant on this path. set -- 443; PORTLBL="443/tcp"; REACHLBL="${BASE_URL:-the HTTPS front}" SCOPENOTE="option 4b: the router forwards exactly one port here, so 443 is the only port that has to be open — the gateway port itself stays CLOSED to the world on purpose" else set -- 80 443; PORTLBL="80/tcp + 443/tcp"; REACHLBL="${BASE_URL:-the HTTPS front}" SCOPENOTE="caddy is the front door; the gateway port itself stays CLOSED to the world on purpose" fi ;; *) echo "FIREWALL_NA (REACH_MODE=$REACH_MODE keeps the listener on loopback — nothing to open)"; REACH_MODE=""; set -- ;; esac if [ -n "$REACH_MODE" ]; then echo "FIREWALL_SCOPE: $PORTLBL (option $REACH_OPTION / $REACH_MODE — $SCOPENOTE)" FW=none; FWSTATE=""; FIX=""; FIXALT=""; ALLOPEN=yes if command -v ufw >/dev/null 2>&1; then FWALL="$(ufw status 2>/dev/null || true)"; FWSTATE="$(printf '%s' "$FWALL" | head -1)" case "$FWSTATE" in *inactive*) FW=ufw-inactive ;; *[Aa]ctive*) FW=ufw ;; *) FW=ufw-unproven ;; # `ufw status` needs root: unreadable is NOT "open" esac for P in "$@"; do printf '%s' "$FWALL" | grep -qE "(^|[^0-9])$P/tcp[[:space:]]+ALLOW" || ALLOPEN=no FIX="${FIX:+$FIX && }sudo ufw allow $P/tcp comment 'carapace Claude'" done [ "$ALLOPEN" = yes ] && FW=ufw-portopen elif command -v firewall-cmd >/dev/null 2>&1; then FWSTATE="$(firewall-cmd --state 2>/dev/null || true)" if [ "$FWSTATE" = running ]; then FW=firewalld; else FW=firewalld-inactive; fi for P in "$@"; do OPENP=no firewall-cmd --list-ports 2>/dev/null | grep -q "$P/tcp" && OPENP=yes # firewalld usually expresses 80/443 as the SERVICES http/https, not as ports. Reading only # --list-ports there reports "closed" on a box that is wide open, and adds a duplicate rule. case "$P" in 80) firewall-cmd --list-services 2>/dev/null | grep -qw http && OPENP=yes ;; 443) firewall-cmd --list-services 2>/dev/null | grep -qw https && OPENP=yes ;; esac [ "$OPENP" = yes ] || ALLOPEN=no FIX="${FIX:+$FIX && }sudo firewall-cmd --permanent --add-port=$P/tcp" done FIX="$FIX && sudo firewall-cmd --reload" [ "$ALLOPEN" = yes ] && FW=firewalld-portopen elif command -v nft >/dev/null 2>&1 || command -v iptables >/dev/null 2>&1; then NFTOUT="$(nft list ruleset 2>/dev/null || true)"; IPTOUT="$(iptables -S INPUT 2>/dev/null || true)" if [ -n "$NFTOUT" ] || [ -n "$IPTOUT" ]; then FWSTATE="$(printf '%s\n%s' "$NFTOUT" "$IPTOUT" | grep -E 'policy (drop|reject)|^-P INPUT|^[[:space:]]*chain ' | head -6 | tr '\n' ';')" if printf '%s\n%s' "$NFTOUT" "$IPTOUT" | grep -qE 'policy drop|policy reject|-P INPUT (DROP|REJECT)'; then FW=netfilter; else FW=netfilter-open; fi for P in "$@"; do printf '%s\n%s' "$NFTOUT" "$IPTOUT" | grep -q "dport $P" || ALLOPEN=no; done [ "$ALLOPEN" = yes ] && FW=netfilter-portopen else FW=netfilter-unproven; FWSTATE="the ruleset could not be read as this user (nft/iptables need root)" fi for P in "$@"; do FIX="${FIX:+$FIX && }sudo iptables -I INPUT -p tcp --dport $P -j ACCEPT"; done FIXALT="nft equivalent: one 'sudo nft add rule inet filter input tcp dport accept' per port in $PORTLBL — either way, persist it the way this distro does (netfilter-persistent / iptables-save / nftables.conf)" fi echo "FIREWALL_DETECTED: $FW" [ -n "$FWSTATE" ] && echo " state: $FWSTATE" case "$FW" in none) echo "FIREWALL_NONE (no ufw / firewalld / nft / iptables on this box — nothing of ours is blocking $PORTLBL)" ;; *-inactive|*-open) echo "FIREWALL_OK ($FW — the host firewall is not filtering $PORTLBL)" ;; *-portopen) echo "FIREWALL_OK ($FW — $PORTLBL ALREADY allowed; no rule was added)" ;; *-unproven) echo "FIREWALL_UNPROVEN ($FW) — the rules could not be READ as this user, which is not the same" echo " as 'no firewall'. Read them, then open the ports if they are closed:" echo " sudo ufw status verbose (or: sudo firewall-cmd --list-all / sudo nft list ruleset)" echo " $FIX" [ -n "$FIXALT" ] && echo " $FIXALT" echo " UNATTENDED: record it verbatim in the Final Report:" echo " REQUIRED_ACTION (firewall): verify the host firewall and, if it filters, run \`$FIX\` — otherwise the phone times out on $REACHLBL." ;; *) echo "FIREWALL_ACTIVE — $PORTLBL is NOT open yet. The exact fix, these ports and nothing else:" echo " $FIX" [ -n "$FIXALT" ] && echo " $FIXALT" echo " ATTENDED: get the owner's OK for that command (it needs sudo), run it, re-run this block." echo " UNATTENDED: do NOT run it. Record it verbatim in the Final Report:" echo " REQUIRED_ACTION (firewall): run \`$FIX\` on this box, or the phone will time out on $REACHLBL." echo " A provider firewall (AWS security group, OVH/Hetzner panel) is a SECOND gate this box" echo " cannot see or fix — $PORTLBL has to be open there too." ;; esac if [ "$(uname -s)" = Darwin ]; then ALF="$(/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2>/dev/null | tr -d '\n')" case "$ALF" in *enabled*) echo "NOTE (macOS): the Application Firewall is ON ($ALF). It filters per-APPLICATION, not per-port: the first non-loopback connection can raise a 'do you want node to accept incoming network connections?' dialog, and until somebody answers Allow the phone just times out. Answer it at the console, or: sudo /usr/libexec/ApplicationFirewall/socketfilterfw --add \"$(command -v node)\" --unblockapp \"$(command -v node)\"" ;; *) echo "NOTE (macOS): Application Firewall state: ${ALF:-unreadable}" ;; esac fi # Verify from the box, at the address the QR is about to name. This proves the LISTENER answers on # that address; it does NOT prove the firewall (traffic from this box to its own address is routed # over loopback and skips the external path), which is why the rule state above is printed as # evidence in its own right. The phone is the only true test of an external firewall. if [ -n "$BIND_ADDR" ] && [ "$BIND_ADDR" != 127.0.0.1 ]; then RC="$(curl -s -m 5 -o /dev/null -w "%{http_code}" "http://$BIND_ADDR:$GWPORT/health")" case "$RC" in 200) echo "SELF_LAN_HEALTH_OK (http://$BIND_ADDR:$GWPORT/health -> 200 from this box)" ;; 000) echo "STOP: nothing answers http://$BIND_ADDR:$GWPORT/health from this box itself — the bind never took. Re-read E.4.3's GW_BIND output; this is not a firewall problem." ;; *) echo "STOP: http://$BIND_ADDR:$GWPORT/health returned $RC from this box — resolve that before pairing." ;; esac fi fi fi ``` **E.4.3b — the HTTPS front of options 4 and 4b, step 1 of 3: preconditions, and honest STOPs.** This block changes nothing on the box except undoing a public `GW_BIND` a *previous run of this same prompt* wrote — in `public-tls` mode the TLS front is the boundary, so that bind is redundant exposure. It then answers the only question that can block the front door: **who already holds 80 and 443.** Every holder has a different correct response and none of them is "take the port". ```bash umask 077 ENVF="$HOME/.carapace/reach-claude.env"; TLSF="$HOME/.carapace/reach-claude.tls"; GWPORT=18800 if [ ! -s "$ENVF" ]; then echo "STOP: no decision record — run E.4.2 first"; else . "$ENVF" 2>/dev/null || true if [ "$REACH_MODE" != public-tls ]; then echo "TLS_FRONT_NA (REACH_MODE=$REACH_MODE — E.4.3b/c/d build the automatic-HTTPS front of options 4 and 4b only)" else rm -f "$TLSF"; PRE=ok # ---- 1. Put the gateway BACK on loopback if an earlier run bound it to the public address. # Only the key/drop-in E.4.3 writes is touched; anything the owner set by hand is left alone. OS="$(uname -s)"; LBL=com.carapace.claude-gateway; UNIT=carapace-claude-gateway.service; UNDONE=no if [ "$OS" = Darwin ]; then PL="$HOME/Library/LaunchAgents/$LBL.plist" if [ -f "$PL" ] && /usr/libexec/PlistBuddy -c "Print :EnvironmentVariables:GW_BIND" "$PL" >/dev/null 2>&1; then /usr/libexec/PlistBuddy -c "Delete :EnvironmentVariables:GW_BIND" "$PL" >/dev/null 2>&1 && UNDONE=yes launchctl bootout "gui/$(id -u)/$LBL" 2>/dev/null launchctl bootstrap "gui/$(id -u)" "$PL" >/dev/null 2>&1 || echo "NOTE: launchctl bootstrap returned non-zero after removing GW_BIND — the loopback probe below is the real test." fi else export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" DROPIN="$HOME/.config/systemd/user/$UNIT.d/10-carapace-bind.conf" if [ -f "$DROPIN" ]; then rm -f "$DROPIN" && UNDONE=yes systemctl --user daemon-reload systemctl --user restart "$UNIT" >/dev/null 2>&1 || echo "NOTE: restart returned non-zero after removing the GW_BIND drop-in — the loopback probe below is the real test." fi fi if [ "$UNDONE" = yes ]; then echo "GW_BIND_REMOVED (an earlier run had bound this gateway to a public address; the TLS front replaces that bind)" else echo "GW_BIND_ABSENT (the gateway was already loopback-only — nothing to undo)"; fi # ---- 2. caddy proxies to LOOPBACK, so loopback has to answer before anything else is worth doing. i=0; LOOP=000 while [ $i -lt 12 ]; do LOOP="$(curl -s -m 5 -o /dev/null -w "%{http_code}" "http://127.0.0.1:$GWPORT/health")" [ "$LOOP" != 000 ] && break i=$((i+1)); sleep 5 done if [ "$LOOP" = 200 ]; then echo "LOOPBACK_HEALTH_OK (127.0.0.1:$GWPORT/health -> 200 after $i retries — this is what caddy will proxy to)" else PRE=stop; echo "STOP: http://127.0.0.1:$GWPORT/health returned $LOOP — the gateway is not answering on loopback, so there is nothing to put TLS in front of. Fix E.1c first. Nothing was installed."; fi # ---- 3. WHO holds 443 and 80. `unknown` (a socket this user cannot attribute) is UNPROVEN, and # taking 443 from a service you cannot name is never acceptable. who_holds() { _P="$1"; _L=""; _N="" if command -v ss >/dev/null 2>&1; then _L="$(ss -ltnp 2>/dev/null | awk -v p="$_P" '$4 ~ "[:.]"p"$" {print; exit}')" if [ -n "$_L" ]; then _N="$(printf '%s' "$_L" | sed -n 's/.*users:(("\([^"]*\)".*/\1/p')" [ -n "$_N" ] || _N=unknown printf '%s' "$_N"; return 0 fi fi if command -v lsof >/dev/null 2>&1; then _N="$(lsof -nP -iTCP:"$_P" -sTCP:LISTEN 2>/dev/null | awk 'NR==2{print $1}')" [ -n "$_N" ] && { printf '%s' "$_N"; return 0; } fi printf '' } HOLD443="$(who_holds 443)"; HOLD80="$(who_holds 80)"; NEED_ALPN=no # ---- 3b. OPTION 4b ONLY. The certificate MUST be issued over TLS-ALPN-01 on 443 and never over # HTTP-01: this path asks the owner for ONE forwarded port, residential ISPs commonly block inbound # 80, and letting caddy try HTTP-01 first burns failed orders against the CA's rate limit for # nothing. Forcing it here is what makes "no port-80 rule needed" true rather than hopeful. if [ "${NAT_FORWARD:-no}" = yes ]; then NEED_ALPN=yes echo "OPTION4B_MODE: certificate over TLS-ALPN-01 on 443 only (no port-80 rule, no HTTP-01)." echo " The router must ALREADY forward external TCP 443 -> ${LAN_IP:-this box}:443. E.4.3d does" echo " not take that on trust: it reads the CA's own validation of this box as the proof." if [ -z "$LAN_IP" ]; then PRE=stop echo "STOP: option 4b needs this box's LAN address on record — the router forwards to it, and" echo " E.4.3d/E.4.4 probe through it because a home router usually cannot hairpin its own" echo " public name. The decision record carries none. Re-run E.4.1 -> E.4.2 -> E.4.2c with" echo " this machine on the network the router serves. Nothing was installed." fi fi echo "PORT_443_HOLDER: ${HOLD443:-none} PORT_80_HOLDER: ${HOLD80:-none}" case "$HOLD443" in "") : ;; caddy) echo "NOTE: caddy already holds 443 — E.4.3c ADDS our site to its config and refuses to overwrite an owner-written Caddyfile." ;; tailscaled|tailscale) PRE=stop echo "STOP: tailscaled already holds 443 on this box, which means OPTION 3 IS ALREADY BUILT HERE." echo " Tailscale Serve is terminating TLS on this machine already, and it is the better answer" echo " than a second front door: no public port, no certificate work, no firewall change, and" echo " the phone reaches it over the tailnet instead of the open internet." echo " Do this instead: re-run E.4.2 with REACH_CHOICE=3, then E.4a -> E.5c." echo " Nothing was installed and nothing was changed." ;; unknown) PRE=stop echo "STOP: something is listening on 443 but this user cannot read WHICH process (ss/lsof need" echo " root to attribute another user's socket). That is UNPROVEN, not free. Run one of:" echo " sudo ss -ltnp 'sport = :443' / sudo lsof -nP -iTCP:443 -sTCP:LISTEN" echo " then re-run this block." echo " REQUIRED_ACTION (reachability): identify the holder of 443 and re-run E.4.3b." ;; *) PRE=stop echo "STOP: $HOLD443 already holds 443. That is the owner's web server and this phase does not take" echo " a port from it. Add ONE route to THAT server instead, then re-run E.4.2 with" echo " REACH_CHOICE=2 and REACH_PUBLIC_BASE=https://." echo " The route must: mount /carapace-claude/*, STRIP that prefix, forward to 127.0.0.1:$GWPORT," echo " pass the Authorization header through untouched, and NOT buffer (the app streams SSE)." echo " Exact snippet for $HOLD443:" case "$HOLD443" in nginx|openresty) echo " location /carapace-claude/ {" echo " proxy_pass http://127.0.0.1:$GWPORT/; # the trailing slash is what strips the prefix" echo " proxy_http_version 1.1;" echo " proxy_set_header Connection \"\";" echo " proxy_buffering off; # SSE" echo " proxy_read_timeout 3600s;" echo " }" ;; apache2|httpd) echo " ProxyPass /carapace-claude/ http://127.0.0.1:$GWPORT/ flushpackets=on" echo " ProxyPassReverse /carapace-claude/ http://127.0.0.1:$GWPORT/" ;; *) echo " caddy: handle_path /carapace-claude/* { reverse_proxy 127.0.0.1:$GWPORT { flush_interval -1 } }" echo " (any other server: apply the four rules above by hand)" ;; esac echo " REQUIRED_ACTION (reachability): add that one route to $HOLD443, then re-run E.4.2 as option 2." ;; esac if [ -n "$HOLD80" ] && [ "$HOLD80" != caddy ]; then NEED_ALPN=yes echo "NOTE: $HOLD80 holds port 80. That does NOT block HTTPS — E.4.3c disables the port-80 redirect" echo " and the HTTP-01 challenge, and the certificate is issued over TLS-ALPN-01 on 443" echo " instead. Nothing on port 80 is touched." fi # ---- 4. Root. Installing caddy and listening on 443 both need it. `sudo -n` never prompts, so an # unattended run LEARNS the answer instead of hanging on a password prompt nobody will ever type. SUDO=no if [ "$(id -u)" = 0 ]; then SUDO=root elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then SUDO=ok; fi echo "SUDO_STATE: $SUDO" if [ "$SUDO" = no ] && [ "$PRE" = ok ]; then PRE=stop echo "STOP: no non-interactive root on this box (sudo -n failed), and both installing caddy and" echo " binding 443 require it. Two ways forward — pick one, then re-run E.4.3b:" echo " (a) ATTENDED: have the owner run this phase where sudo works." echo " (b) Tailscale instead — no root, no public port, one line:" echo " tailscale serve --bg --https=443 --set-path /carapace-claude http://127.0.0.1:$GWPORT" echo " then re-run E.4.2 with REACH_CHOICE=3." echo " REQUIRED_ACTION (reachability): option 4's HTTPS front needs root on this box." fi if [ "$PRE" = ok ]; then { printf "TLS_HOST='%s'\n" "$TLS_HOST" printf "GWPORT='%s'\n" "$GWPORT" printf "NEED_ALPN='%s'\n" "$NEED_ALPN" printf "NAT_FORWARD='%s'\n" "${NAT_FORWARD:-no}" printf "LAN_IP='%s'\n" "$LAN_IP" printf "SUDO='%s'\n" "$SUDO" printf "CADDY_PREEXISTING='%s'\n" "$HOLD443" } > "$TLSF" chmod 600 "$TLSF" echo "TLS_PRECHECK_OK (host $TLS_HOST; 443 ${HOLD443:-free}; 80 ${HOLD80:-free}; sudo $SUDO) — continue to E.4.3c" else echo "TLS_PRECHECK_BLOCKED — nothing was installed. The STOP above names the fix; no record was written." fi fi fi ``` Expected on the happy path: `GW_BIND_ABSENT` (or `GW_BIND_REMOVED`), `LOOPBACK_HEALTH_OK`, `PORT_443_HOLDER: none`, `SUDO_STATE: root|ok`, `TLS_PRECHECK_OK`. Any `STOP:` here is a real answer about *this* box, and each one names the alternative route rather than leaving a dead end. **E.4.3c — step 2 of 3: install caddy, write ONE site, reload.** The Caddyfile mounts **every** CARAPACE gateway that actually answers on this box, each under its own `handle_path` — which strips the prefix, exactly like `tailscale serve --set-path` does, because that is the contract the app's URL composition assumes. A gateway that does not answer is not mounted; nothing is guessed. ```bash umask 077 ENVF="$HOME/.carapace/reach-claude.env"; TLSF="$HOME/.carapace/reach-claude.tls" if [ ! -s "$TLSF" ]; then echo "STOP: no TLS precheck record — run E.4.3b first (it deliberately writes none when a precondition fails)"; else . "$ENVF" 2>/dev/null || true . "$TLSF" 2>/dev/null || true OS="$(uname -s)" # One helper instead of a "$SUDO_RUN command" prefix variable: an unquoted prefix does NOT word-split # under zsh, so `$SUDO_RUN apt-get ...` would try to execute a command literally named "sudo -n". asroot() { if [ "$SUDO" = root ]; then "$@"; else sudo -n "$@"; fi; } # ---- 1. caddy: use what is here, install only if it is not. CADDY="$(command -v caddy || true)" for C in /usr/bin/caddy /usr/local/bin/caddy /opt/homebrew/bin/caddy; do [ -n "$CADDY" ] || { [ -x "$C" ] && CADDY="$C"; } done if [ -z "$CADDY" ]; then echo "installing caddy (this is the only package this phase installs)..." if [ "$OS" = Darwin ]; then command -v brew >/dev/null 2>&1 && brew install caddy # never as root: brew refuses elif command -v apt-get >/dev/null 2>&1; then asroot apt-get update -qq 2>/dev/null asroot apt-get install -y caddy 2>/dev/null if ! command -v caddy >/dev/null 2>&1; then # Debian/Ubuntu do not carry caddy in the default repos. This is the project's own signed # apt source: ONE keyring + ONE list file, both named in the teardown below. asroot apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg 2>/dev/null curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' 2>/dev/null \ | asroot gpg --batch --yes --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg 2>/dev/null curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' 2>/dev/null \ | asroot tee /etc/apt/sources.list.d/caddy-stable.list >/dev/null 2>&1 asroot apt-get update -qq 2>/dev/null asroot apt-get install -y caddy 2>/dev/null fi elif command -v dnf >/dev/null 2>&1; then asroot dnf install -y caddy 2>/dev/null if ! command -v caddy >/dev/null 2>&1; then asroot dnf install -y 'dnf-command(copr)' 2>/dev/null asroot dnf copr enable -y '@caddy/caddy' 2>/dev/null asroot dnf install -y caddy 2>/dev/null fi fi CADDY="$(command -v caddy || true)" fi if [ -z "$CADDY" ]; then echo "STOP: caddy could not be installed on this box, so there is no HTTPS front to build." echo " Read the package manager's own error above — it is the cause, and it is usually one of:" echo " no network egress, no sudo, or an unsupported distro." echo " FALLBACK, one line, no packages and no certificates:" echo " tailscale serve --bg --https=443 --set-path /carapace-claude http://127.0.0.1:${GWPORT:-18800}" echo " then re-run E.4.2 with REACH_CHOICE=3." echo " REQUIRED_ACTION (reachability): install caddy (or switch to option 3) and re-run E.4.3c." else echo "CADDY_INSTALLED $CADDY ($("$CADDY" version 2>/dev/null | head -1))" # ---- 2. Build the site. handle_path STRIPS the matched prefix, which is the same contract as # `tailscale serve --set-path`: this gateway strips only /api/app/v1 and will never strip its own # basePath. Probe all four known CARAPACE ports and mount the ones that actually answer. MARK="# --- carapace (managed by the CARAPACE setup prompt — re-written on every run) ---" SITE="$HOME/.carapace/reach-claude.caddy" add_route() { # $1 = port, $2 = basePath RC="$(curl -s -m 3 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$1/health" 2>/dev/null)" [ "$RC" = 200 ] || return 0 printf '\thandle_path %s/* {\n\t\treverse_proxy 127.0.0.1:%s {\n\t\t\tflush_interval -1\n\t\t}\n\t}\n' "$2" "$1" } { printf '%s\n' "$MARK" [ "$NEED_ALPN" = yes ] && printf '{\n\tauto_https disable_redirects\n}\n\n' printf '%s {\n' "$TLS_HOST" [ "$NEED_ALPN" = yes ] && printf '\ttls {\n\t\tissuer acme {\n\t\t\tdisable_http_challenge\n\t\t}\n\t}\n' add_route 18800 /carapace-claude add_route 18802 /carapace-codex add_route 18795 /carapace-hermes-api add_route 18794 /carapace # Root /health is the phone's connection-pearl probe. On THIS front door we own the whole # hostname, so unlike the machine-global tailscale serve config (E.5b) there is nobody to # displace. Any CARAPACE gateway answers it with {"ok":true}. printf '\thandle /health {\n\t\treverse_proxy 127.0.0.1:%s\n\t}\n' "${GWPORT:-18800}" printf '}\n' } > "$SITE" chmod 600 "$SITE" echo "site file $SITE:"; sed 's/^/ /' "$SITE" if ! grep -q 'handle_path' "$SITE"; then echo "STOP: not one CARAPACE gateway answered on loopback, so the site has no routes. Re-run E.1c." else # ---- 3. Install it, refusing to clobber an owner-written config. if [ "$OS" = Darwin ]; then CF="$(brew --prefix 2>/dev/null)/etc/Caddyfile"; else CF=/etc/caddy/Caddyfile; fi WRITEMODE=fresh if [ -f "$CF" ]; then if grep -qF "$MARK" "$CF" 2>/dev/null; then WRITEMODE=replace elif grep -q 'The Caddyfile is a configuration file' "$CF" 2>/dev/null; then WRITEMODE=stock elif grep -qE '^[[:space:]]*[^#[:space:]]' "$CF" 2>/dev/null; then WRITEMODE=refuse fi fi echo "CADDYFILE_TARGET $CF (mode: $WRITEMODE)" if [ "$WRITEMODE" = refuse ]; then echo "STOP: $CF already holds an owner-written caddy configuration, and this phase does not" echo " overwrite one. Paste the site block printed above into it by hand (or add our" echo " handle_path lines to the site that already serves this box), reload caddy, then" echo " re-run E.4.3d — which verifies the result rather than assuming it." echo " REQUIRED_ACTION (reachability): merge $SITE into $CF and reload caddy." else asroot mkdir -p "$(dirname "$CF")" # The FIRST backup is the true pre-carapace state: never overwrite it on a re-run. [ -f "$CF" ] && [ ! -f "$CF.bak-pre-carapace" ] && asroot cp -p "$CF" "$CF.bak-pre-carapace" asroot cp "$SITE" "$CF" && asroot chmod 644 "$CF" if asroot "$CADDY" validate --adapter caddyfile --config "$CF" >/dev/null 2>&1; then echo "CADDYFILE_WRITTEN $CF (caddy validate: OK)" if [ "$OS" = Darwin ]; then asroot brew services restart caddy >/dev/null 2>&1 || brew services restart caddy >/dev/null 2>&1 else asroot systemctl enable --now caddy >/dev/null 2>&1 asroot systemctl reload caddy >/dev/null 2>&1 || asroot systemctl restart caddy >/dev/null 2>&1 fi printf "CADDYFILE='%s'\n" "$CF" >> "$TLSF" printf "CADDY_BIN='%s'\n" "$CADDY" >> "$TLSF" echo "CADDY_SERVING — who holds 443 now (evidence, not a claim):" if command -v ss >/dev/null 2>&1; then ss -ltnp 2>/dev/null | awk '$4 ~ /[:.]443$/ {print " " $0}' | head -3 elif command -v lsof >/dev/null 2>&1; then lsof -nP -iTCP:443 -sTCP:LISTEN 2>/dev/null | sed -n '2,4p' | sed 's/^/ /'; fi echo " (a certificate is being requested right now if this is the first run — E.4.3d waits for it)" else echo "STOP: caddy validate REJECTED the config that was just written. Restoring and changing nothing:" asroot "$CADDY" validate --adapter caddyfile --config "$CF" 2>&1 | sed 's/^/ /' | head -20 if [ -f "$CF.bak-pre-carapace" ]; then asroot cp -p "$CF.bak-pre-carapace" "$CF"; echo " restored $CF from $CF.bak-pre-carapace" else asroot rm -f "$CF"; echo " removed $CF (there was no file here before this run)"; fi echo " REQUIRED_ACTION (reachability): the generated Caddyfile is invalid on this caddy version — report the validate output verbatim." fi fi fi fi fi ``` **E.4.3d — step 3 of 3: prove the front door, record the certificate.** A brand-new certificate is issued in the seconds after caddy first serves the hostname, so this block **retries** rather than reading a cold `000` as a failure. Both probes here are token-free on purpose (`health` → 200, unauthenticated status → 401); E.4.4 then completes the same triad with a good token through the same `BASE_URL`. ```bash umask 077 ENVF="$HOME/.carapace/reach-claude.env"; TLSF="$HOME/.carapace/reach-claude.tls" if [ ! -s "$TLSF" ]; then echo "STOP: no TLS record — run E.4.3b and E.4.3c first"; else . "$ENVF" 2>/dev/null || true . "$TLSF" 2>/dev/null || true if [ -z "$TLS_HOST" ]; then echo "STOP: TLS_HOST is empty — re-run E.4.2 (options 4 and 4b are what record it)"; else # ---- OPTION 4b: THE HAIRPIN. A box behind a router usually cannot reach its own public name from # inside the LAN — the router does not loop the packet back (measured on the reference NAT: refused # in 3ms, on a forward that was answering the public internet perfectly). So every probe below is # aimed at the SAME origin over the LAN address with --resolve: identical hostname, identical SNI, # identical certificate validation, no hairpin required. Whether the FORWARD works is a different # fact, it cannot be measured from in here, and the CA's validation below is what proves it. # The helper exists because `curl $FLAGS` does NOT word-split under zsh (E.4.1 documents the same # trap for addresses), so the flag has to be passed as a real argument or not at all. LANRES="" if [ "${NAT_FORWARD:-no}" = yes ]; then if [ -n "$LAN_IP" ]; then LANRES="$TLS_HOST:443:$LAN_IP" echo "NAT_HAIRPIN_BYPASS: probing https://$TLS_HOST via $LAN_IP:443 (--resolve) — same origin, same certificate, no hairpin." else echo "NOTE: option 4b with no LAN_IP on record — probing the public name from inside the LAN. A 000 here is the hairpin, not a fault." fi fi cget() { if [ -n "$LANRES" ]; then curl -s -m 10 --resolve "$LANRES" "$@"; else curl -s -m 10 "$@"; fi; } i=0; H=000 while [ $i -lt 18 ]; do H="$(cget -o /dev/null -w "%{http_code}" "https://$TLS_HOST/carapace-claude/health")" [ "$H" != 000 ] && break i=$((i+1)); sleep 5 done N="$(cget -o /dev/null -w "%{http_code}" "https://$TLS_HOST/carapace-claude/api/app/v1/status")" echo "tls health -> $H (after $i retries, ~5s apart) https://$TLS_HOST/carapace-claude/health" echo "tls no-auth -> $N (expect 401 — no token was sent)" if [ "$H" = 200 ] && [ "$N" = 401 ]; then echo "TLS_HEALTH_OK / TLS_NOAUTH_401 — the front door answers and the bearer gate is CLOSED through it." elif [ "$H" = 200 ] && [ "$N" = 200 ]; then echo "STOP: the front door answers, but the status endpoint returned 200 with NO token — the bearer" echo " gate is OPEN through caddy. Do not pair, do not publish. Fix the gateway auth (Phase D)." elif [ "$H" = 000 ]; then echo "STOP: nothing answered https://$TLS_HOST/carapace-claude/health after $i retries. In order of likelihood:" echo " 1. the certificate was never issued — read the reason, it names itself:" echo " journalctl -u caddy -n 80 --no-pager (macOS: brew services log caddy)" echo " 2. 80/443 are not reachable FROM THE INTERNET — E.4.3a's verdict covers the host" echo " firewall, but a provider firewall (AWS security group, OVH/Hetzner panel) is a" echo " second gate this box cannot see." echo " 3. $TLS_HOST does not resolve here: check with 'getent hosts $TLS_HOST' or" echo " 'dig +short $TLS_HOST' — it must answer with this box's public address." if [ "${NAT_FORWARD:-no}" = yes ] && [ -n "$LANRES" ]; then echo " 4. OPTION 4b: this probe already goes to $LAN_IP:443, NOT through the router, so a 000" echo " here means caddy is not answering ON THIS BOX — a local problem, not the forward." echo " If caddy IS up and only the CERTIFICATE is missing, read the reason in" echo " 'sudo journalctl -u caddy -n 200': the router rule is missing, or points at a" echo " different machine (DHCP moved this box — give it a reservation), or the ISP blocks" echo " inbound 443, or this line is carrier-grade NAT. In the last two cases no rule can" echo " ever work and option 3 is the answer, not a longer retry." fi echo " FALLBACK, one line, no ports and no certificates:" echo " tailscale serve --bg --https=443 --set-path /carapace-claude http://127.0.0.1:${GWPORT:-18800}" echo " then re-run E.4.2 with REACH_CHOICE=3." else echo "STOP: https://$TLS_HOST/carapace-claude/health returned $H, expected 200. A 502/504 means caddy is up but" echo " the gateway behind it is not answering on 127.0.0.1:${GWPORT:-18800}; a 404 means the" echo " handle_path mount does not match /carapace-claude. Neither is a certificate problem." fi # ---- OPTION 4b: the one fact that proves the FORWARD works from the internet. A certificate for # this name cannot exist unless the CA connected to caddy ON THIS BOX, on 443, through the router's # rule — from several public vantage points, seconds ago. That is a third party measuring the thing # a box behind NAT cannot measure about itself, and it is why 4b's gate is not a self-probe. if [ "${NAT_FORWARD:-no}" = yes ]; then EV="" if command -v journalctl >/dev/null 2>&1; then EV="$(journalctl -u caddy -n 400 --no-pager 2>/dev/null | grep -a -e tls-alpn-01 -e 'certificate obtained successfully' | tail -3)" fi if [ -z "$EV" ]; then for L in /var/log/caddy/caddy.log "$HOME/Library/Logs/caddy.log" /usr/local/var/log/caddy.log; do [ -f "$L" ] && [ -z "$EV" ] && EV="$(grep -a -e tls-alpn-01 -e 'certificate obtained successfully' "$L" 2>/dev/null | tail -3)" done fi if [ -n "$EV" ]; then echo "PORTFORWARD_PROVEN_BY_ACME — the certificate authority reached caddy on this box THROUGH the" echo " router's 443 forward and validated it. Evidence, verbatim:" printf '%s\n' "$EV" | sed 's/^/ /' else echo "PORTFORWARD_ACME_EVIDENCE_UNREAD — caddy's log could not be read as this user (try" echo " 'sudo journalctl -u caddy -n 200'). The certificate recorded below is still the proof: a" echo " public CA issued it, and it can only do that by reaching this box on 443 from the public" echo " internet. Absent evidence here is UNREAD, never 'not forwarded'." fi fi # ---- the certificate itself, as a fact with a date on it. Single quotes are stripped from both # values before they are written: the issuer string contains an apostrophe, and this record is # `.`-sourced by every block downstream. CERT_NOTAFTER=""; CERT_ISSUER="" if command -v openssl >/dev/null 2>&1; then # OPTION 4b: dial the LAN address, keep the PUBLIC name as SNI. Dialling "$TLS_HOST:443" from # inside the LAN is the hairpin again — it returns nothing, and this block then reported # CERT_ISSUED=unproven on a box holding a perfectly good certificate (measured on the reference # NAT). The certificate served is the same one either way; only the address dialled changes. CERT_DIAL="$TLS_HOST:443" [ -n "$LANRES" ] && [ -n "$LAN_IP" ] && CERT_DIAL="$LAN_IP:443" CERTOUT="$(echo | openssl s_client -servername "$TLS_HOST" -connect "$CERT_DIAL" 2>/dev/null | openssl x509 -noout -enddate -issuer 2>/dev/null)" CERT_NOTAFTER="$(printf '%s\n' "$CERTOUT" | sed -n 's/^notAfter=//p' | tr -d "'")" CERT_ISSUER="$(printf '%s\n' "$CERTOUT" | sed -n 's/^issuer=//p' | tr -d "'")" fi if [ -n "$CERT_NOTAFTER" ]; then echo "CERT_ISSUED notAfter=$CERT_NOTAFTER issuer=$CERT_ISSUER" echo " RENEWAL IS AUTOMATIC: caddy renews about 30 days before that date and reloads itself." echo " There is no cron to add and nothing for the owner to remember." else CERT_NOTAFTER=unproven echo "CERT_ISSUED unproven (no openssl here) — the https 200 above is still real evidence of a valid" echo " chain, because curl verifies the certificate by default and would have failed otherwise." fi TMP="$ENVF.new" grep -v -e '^TLS_FRONT=' -e '^CERT_ISSUED=' -e '^CERT_ISSUER=' "$ENVF" > "$TMP" { printf "TLS_FRONT='caddy'\n" printf "CERT_ISSUED='%s'\n" "$CERT_NOTAFTER" printf "CERT_ISSUER='%s'\n" "$CERT_ISSUER" } >> "$TMP" mv "$TMP" "$ENVF"; chmod 600 "$ENVF" echo "REACH_BASE_RECORDED $BASE_URL (Phase F builds the QR from exactly this value)" fi fi ``` **Reading E.4.3d:** `tls health -> 200` + `tls no-auth -> 401` + a `CERT_ISSUED` date is the whole gate. `BASE_URL` is already `https://.sslip.io`, Phase F appends `/carapace-claude` to it exactly as the app does, and E.4.4 re-proves all three codes through that base with a real token before any QR exists. **Option 2 — nothing is wired.** The gateway stays on `127.0.0.1:18800` and the owner's existing proxy/tunnel is the boundary, exactly as it is today for their other services. Two requirements are theirs to satisfy, and E.4.4 proves both with real requests rather than trusting them: 1. the proxy forwards `https:///carapace-claude/…` to `http://127.0.0.1:18800/…` and **strips the `/carapace-claude` prefix** (this gateway strips only `/api/app/v1`); 2. it forwards the `Authorization` header untouched — the bearer token is the whole authorization, and a proxy that eats the header turns every request into a `401`. **Option 3 — Tailscale.** Run **E.4a0 → E.5c below exactly as written**. E.4a0 installs the Tailscale client — this is the one option that needs one, and the only place it is installed (`wo_20260825ae`); nothing else new enters this design, and E.4a onwards are unchanged. When they finish, come back and record the base: ```bash umask 077 ENVF="$HOME/.carapace/reach-claude.env"; NODE="$(command -v node || true)" if [ ! -s "$ENVF" ]; then echo "STOP: no decision record — run E.4.2 first" elif [ ! -x "$NODE" ]; then echo "STOP: node not found — re-run A.1" else HOSTN="$(tailscale status --json 2>/dev/null | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(String((JSON.parse(s).Self||{}).DNSName||"").replace(/\.$/,"").toLowerCase())}catch(e){}})')" case "$HOSTN" in *.ts.net) TMP="$ENVF.new"; grep -v '^BASE_URL=' "$ENVF" > "$TMP"; printf "BASE_URL='https://%s'\n" "$HOSTN" >> "$TMP" mv "$TMP" "$ENVF"; chmod 600 "$ENVF" echo "REACH_BASE_RECORDED https://$HOSTN" ;; *) echo "STOP: no tailnet host yet — finish E.4c/E.5b before recording the base." ;; esac fi ``` **E.4.4 Verify at the chosen base — compose the URL the way the APP does, then prove all of it.** This is the gate that replaces "it looked right": the triad, the non-loopback warning, and the limiter, measured through the exact origin the QR is about to encode. ```bash umask 077 ENVF="$HOME/.carapace/reach-claude.env"; GW_DIR="$HOME/.carapace/claude-gateway"; NODE="$(command -v node || true)" if [ ! -s "$ENVF" ]; then echo "STOP: no decision record — run E.4.2 first"; else . "$ENVF" 2>/dev/null || true TOK="$(cat "$GW_DIR/token" 2>/dev/null | tr -d "\n")" if [ -z "$BASE_URL" ]; then echo "STOP: BASE_URL is empty — option 3 must record it (E.4.3) and option 2 must supply it." elif [ -z "$TOK" ]; then echo "STOP: no usable bearer token (the 0600 token file at $HOME/.carapace/claude-gateway/token) — resolve that first; nothing here can be proven without it." else # Compose EXACTLY as SettingsManager does: https -> append basePath; http://host:port -> verbatim. case "$BASE_URL" in https://*) APPBASE="$BASE_URL/carapace-claude" ;; http://*) APPBASE="$BASE_URL" ;; *) APPBASE="" ;; esac if [ -z "$APPBASE" ]; then echo "STOP: BASE_URL '$BASE_URL' has no usable scheme."; else echo "the phone will talk to: $APPBASE (base $BASE_URL, option $REACH_OPTION/$REACH_MODE)" # OPTION 4b: the phone reaches this origin from the internet; THIS BOX reaches the same origin over # the LAN, because a router that will not hairpin answers 000 for its own public name. --resolve # keeps hostname, SNI and certificate validation identical and changes only which address is dialled. # (Passed as a real argument, never as an unquoted flag variable — zsh does not word-split those.) LANRES="" if [ "${NAT_FORWARD:-no}" = yes ] && [ -n "$LAN_IP" ] && [ -n "$TLS_HOST" ]; then LANRES="$TLS_HOST:443:$LAN_IP" echo "NAT_HAIRPIN_BYPASS: probing $APPBASE via $LAN_IP:443 (--resolve) — same origin, no hairpin." echo " The internet-side proof of the forward is E.4.3d's PORTFORWARD_PROVEN_BY_ACME line." fi HF="$GW_DIR/.authhdr"; mkdir -p "$GW_DIR"; chmod 700 "$GW_DIR"; rm -f "$HF" printf 'Authorization: Bearer %s\n' "$TOK" > "$HF"; chmod 600 "$HF" # House rule 1: never in argv # No `|| echo 000` anywhere in this block: curl already writes 000 into %{http_code} on a # connection failure and then exits non-zero, so a `||` doubles it to `000000` and every # comparison below breaks. A fresh HTTPS origin may also need ~15-20s for a cert, so: retry. tri() { i=0; c=000 while [ $i -lt 12 ]; do if [ -n "$3" ] && [ -n "$LANRES" ]; then c="$(curl -s -m 10 --resolve "$LANRES" -o /dev/null -w "%{http_code}" -H @"$3" "$2")" elif [ -n "$3" ]; then c="$(curl -s -m 10 -o /dev/null -w "%{http_code}" -H @"$3" "$2")" elif [ -n "$LANRES" ]; then c="$(curl -s -m 10 --resolve "$LANRES" -o /dev/null -w "%{http_code}" "$2")" else c="$(curl -s -m 10 -o /dev/null -w "%{http_code}" "$2")"; fi [ "$c" != 000 ] && break i=$((i+1)); sleep 5 done echo "$1 -> $c (after ${i} retries, ~5s apart)" } tri "base health" "$APPBASE/health" tri "base no-auth" "$APPBASE/api/app/v1/status" tri "base good-auth" "$APPBASE/api/app/v1/status" "$HF" echo "expect 200 / 401 / 200. A 404 on good-auth with 401 on health means a proxy did NOT strip /carapace-claude." echo "a 200 on no-auth means the bearer gate is OPEN — that is a STOP, not a curiosity: do not pair." # ---- Everything below is for a NON-LOOPBACK bind (option 1, and option 4 ONLY under the # REACH_INSECURE_HTTP=yes opt-out) only. Option 4's default runs through caddy, so it lands in the # loopback branch below with every other proxied mode. # On options 2 and 3 every request arrives from the proxy/serve hop as 127.0.0.1, so a bad-auth # drill would throttle THAT source — i.e. the phone's traffic too, for a minute. The limiter is # still there and still working; this is a deliberate skip, not an untested path. if [ "$BIND_ADDR" = 127.0.0.1 ]; then echo "LIMITER_DRILL_SKIPPED (loopback bind: the drill would rate-limit the proxy hop, which is every client)" else # ---- 1. the WARNING the gateway logs at boot when it is not on loopback W="" [ -f "$HOME/.carapace/claude-gateway/gw.log" ] && W="$(grep -a "WARNING: listening on" "$HOME/.carapace/claude-gateway/gw.log" 2>/dev/null | tail -1)" if [ -z "$W" ] && command -v journalctl >/dev/null 2>&1; then W="$(journalctl --user -u carapace-claude-gateway.service -n 300 --no-pager 2>/dev/null | grep -a "WARNING: listening on" | tail -1)" fi if [ -n "$W" ]; then echo "BIND_WARNING_SEEN: $W" else echo "STOP: bound to $BIND_ADDR but the gateway never logged '[gw] WARNING: listening on …'." echo " Either the process answering is not the one you just restarted (squatter — re-check E.1c)," echo " or GW_BIND never reached it (re-read the supervisor definition). Do not pair a listener" echo " you cannot identify." fi # ---- 2. the bad-auth limiter: 21 wrong tokens from this source IP must produce at least one 429. # The limiter keys on the SOURCE IP and is checked BEFORE the token is validated, so for ~60s # after this drill even a CORRECT token from THIS box reads 429. The phone is a different IP and # is unaffected. The settle step below waits the window out so Phase G cannot misread it. i=1; SAW429=no; LAST="" while [ $i -le 21 ]; do LAST="$(curl -s -m 5 -o /dev/null -w "%{http_code}" -H "Authorization: Bearer carapace-badauth-drill" "$APPBASE/api/app/v1/status")" if [ "$LAST" = 429 ]; then SAW429=yes; break; fi i=$((i+1)) done if [ "$SAW429" = yes ]; then echo "LIMITER_429_OK (429 on attempt $i; the limit is 20 bad auths per minute per source IP)" else echo "NOTE: 21 bad-auth attempts produced no 429 (last=$LAST). This build's limiter is not" echo " answering. Record it: on a non-loopback bind the bearer token is then the ONLY" echo " gate, with no brute-force brake. Prefer option 2 or 3 until that is fixed." fi # Settle: wait the 60s window out and prove a GOOD token is served again, so Phase G (and any # re-run of the triad) cannot read this drill's own throttling as a broken install. sleep 62 RC="$(curl -s -m 10 -o /dev/null -w "%{http_code}" -H @"$HF" "$APPBASE/api/app/v1/status")" if [ "$RC" = 200 ]; then echo "LIMITER_RECOVERED (good-auth -> 200 after the window)" else echo "STOP: after the limiter window a GOOD token returned $RC, not 200 — re-check E.4.4's triad before pairing."; fi fi rm -f "$HF" fi fi fi ``` **Reading E.4.4:** | Line | Pass | What a failure means | |---|---|---| | `base health` | `200` | `000` = nothing reachable at that origin (firewall, wrong IP, proxy down). `404` = the path mount is wrong | | `base no-auth` | `401` | `200` means **the gateway is answering unauthenticated requests** — do not pair, do not publish; fix the token gate first | | `base good-auth` | `200` | `404` with `health` = `401` is the classic un-stripped `/carapace-claude` prefix | | `BIND_WARNING_SEEN` | present when bound off loopback | absent ⇒ either the running process is not the one you just restarted, or it never took `GW_BIND` | | `LIMITER_429_OK` | at least one `429` in 21 tries | all-`401` means the limiter is not in this build — record it and keep the bind loopback-only if you cannot accept that | **Reaching this box from outside is a real change in exposure. Say it out loud, in the Final Report:** with option 1 anything on that Wi-Fi can *reach* the port (and must still have the token); with option 4 **anything on the internet** can reach the front door — over TLS in the default mode, in cleartext under the opt-out. The bearer token + the limiter are the gate in every option; what TLS changes is whether that token is **readable in transit**. Print the caveat that matches the mode that was actually recorded — they are not interchangeable, and neither is optional boilerplate. > **Option 4, default HTTPS mode (`REACH_MODE=public-tls`) — print this to the owner, verbatim.** > This box now answers the public internet on 443 as `https://.sslip.io`, with > a real certificate that **caddy renews for you** — nothing expires in 90 days and waits for you to > notice. Two things to know. **(1) The hostname is public and guessable** (it is your IP with > dashes), so the bearer token is the whole lock: 32 random bytes, `401` without it, and a > bad-auth limiter that throttles guessing. **(2) The gateway itself never moved** — it is still on > `127.0.0.1`, and caddy is the only thing listening on the public interface, forwarding one path > to one local port. Re-running this phase later is free and does not change the token. > **Option 4b (`REACH_MODE=public-tls` with `NAT_FORWARD=yes`) — print this to the owner, verbatim.** > Your router now sends one port — **443, and only 443** — to this machine, and this machine answers > it as `https://.sslip.io` with a real certificate that **renews > itself**. Four things to know. **(1) The gateway never moved**: it is still on `127.0.0.1`, and > caddy is the only thing the forwarded port can reach. **(2) The hostname is public and guessable** > — it is your address with dashes — so the bearer token is the whole lock: 32 random bytes, `401` > without it, and a limiter that throttles guessing. **(3) Your home IP can change.** If your ISP > moves it, the hostname changes with it: nothing breaks dangerously, the phone simply stops > connecting, and the fix is to re-run E.4.2 → Phase F and re-scan the new QR. **(4) Give this > machine a fixed address on your router** (a DHCP reservation), or the forward will one day point > at a different device on your network. When you are done with all this, **delete that one router > rule** — nothing on this box depends on it. > **Option 4, `REACH_INSECURE_HTTP=yes` (`REACH_MODE=public`) — print this to the owner, verbatim.** > You opted out of TLS. The app **will** make the request (build 435 permits plain `http` to a > public address), so this does work — but **every request, including the bearer token, crosses the > internet unencrypted**, and anyone who can watch the path can copy the token and drive your > runtime. This is a testing path, not a deployment. **HTTPS is one re-run away:** set > `REACH_INSECURE_HTTP=no` and re-run E.4.2 → E.4.3d, which gets the certificate for you in about a > minute. Re-generate the QR afterwards — Phase F reads `BASE_URL` fresh, and the old QR encodes the > `http://` base. **Option 4b — what a failure actually means** (read this before retrying anything): | Symptom | Cause | Fix | |---|---|---| | `https://.sslip.io` gives **000 from the box**, but caddy is up | **NAT hairpin** — most home routers will not loop a LAN client back through their own public address. Not a failure and not a certificate problem | Nothing: E.4.3d and E.4.4 already probe the same origin over the LAN with `--resolve :443:`. The internet-side proof is `PORTFORWARD_PROVEN_BY_ACME` | | No certificate; caddy logs an ACME failure | The router rule is missing or points elsewhere, the ISP blocks inbound 443, or the line is behind **carrier-grade NAT** | `sudo journalctl -u caddy -n 200`; re-check the rule is **443 → ``:443**; reserve this box's LAN address. CGNAT and a blocked 443 cannot be fixed from here — use option 3 | | The owner forwarded **8443** (or any non-443 external port) | The ACME TLS-ALPN-01 check only ever dials **443**, and the app drops a non-standard port from an `https` base | The external port must be 443. If 443 is genuinely unavailable on that line, option 3 (Tailscale) is the answer | | It worked, then stopped weeks later | The home **public IP changed**, so `.sslip.io` names somebody else now — or DHCP moved this box and the rule points at another device | Re-run E.4.2 → Phase F for the new hostname and re-scan the QR; give the box a DHCP reservation | | `FORWARD_CHECK … someone-else` | The rule reaches the router's own admin page (never saved) or another computer on the LAN | Point the rule at this box's LAN address, 443 on both sides, save, re-run E.4.2c | **TEARDOWN of everything this phase adds** (three files and one key — nothing else): ```bash # macOS: /usr/libexec/PlistBuddy -c "Delete :EnvironmentVariables:GW_BIND" ~/Library/LaunchAgents/com.carapace.claude-gateway.plist # launchctl bootout "gui/$(id -u)/com.carapace.claude-gateway"; launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.carapace.claude-gateway.plist # Linux: rm -f ~/.config/systemd/user/carapace-claude-gateway.service.d/10-carapace-bind.conf # systemctl --user daemon-reload && systemctl --user restart carapace-claude-gateway.service # option 4 HTTPS front (caddy), only if this phase built it: # sudo mv /etc/caddy/Caddyfile.bak-pre-carapace /etc/caddy/Caddyfile (macOS: the same two # files under "$(brew --prefix)/etc/"), or just `sudo rm -f /etc/caddy/Caddyfile` when this # phase created it; then `sudo systemctl reload caddy` (macOS: sudo brew services restart caddy). # If caddy was installed BY this phase and nothing else uses it: # sudo systemctl disable --now caddy; sudo apt-get remove -y caddy (or dnf remove / brew uninstall) # and, if the apt source was added here: sudo rm -f /etc/apt/sources.list.d/caddy-stable.list # /usr/share/keyrings/caddy-stable-archive-keyring.gpg # The certificate needs no revocation — it simply expires. caddy's data lives in # /var/lib/caddy (Linux) or ~/Library/Application Support/Caddy (macOS). # option 4b's ROUTER RULE is not ours to remove and this prompt never touched it: tell the owner to # delete the "external TCP 443 -> :443" forward on their router when they are done. # Nothing on this box depends on it, and leaving it points a public port at a caddy that is # no longer serving anything. # both: rm -f ~/.carapace/reach-claude.env ~/.carapace/reach-claude.detect ~/.carapace/reach-claude.tls ~/.carapace/reach-claude.caddy # firewall: E.4.3a never adds a rule itself — if the owner ran the command it printed, the # reverse is `sudo ufw delete allow /tcp` (or `sudo firewall-cmd --permanent # --remove-port=/tcp && sudo firewall-cmd --reload`). Nothing else was touched. ``` That returns the listener to `127.0.0.1:18800`. The Tailscale route teardown (option 3) is unchanged and lives in the uninstall section. **VERIFICATION (Phase E.4):** `REACH_DETECT_OK`; one `REACH_DECIDED:` line with a recorded `DECIDED_BY`; for **option 4b** (`REACH_MODE=public-tls` + `NAT_FORWARD=yes`) everything option 4 requires **plus** `OPTION4B_MODE` (TLS-ALPN-01 forced), `NAT_HAIRPIN_BYPASS` in E.4.3d and E.4.4, and `PORTFORWARD_PROVEN_BY_ACME` (or the recorded `PORTFORWARD_ACME_EVIDENCE_UNREAD` marker with the `CERT_ISSUED` date standing in for it) — a 4b install whose only evidence is a self-probe is **not** proven, because the box cannot see its own forward; for option 4's default HTTPS path (`REACH_MODE=public-tls`) `LOOPBACK_HEALTH_OK` + `TLS_PRECHECK_OK` + `CADDY_INSTALLED` + `CADDYFILE_WRITTEN` + `CADDY_SERVING` + `TLS_HEALTH_OK` + `TLS_NOAUTH_401` + a `CERT_ISSUED` line carrying a real `notAfter` date; for option 1 and option 4-under-the-opt-out `GW_BIND_WRITTEN` + `SUPERVISOR_RELOADED` + `BIND_WARNING_SEEN` (or the recorded absence marker) + the limiter marker + E.4.3a's firewall verdict (`FIREWALL_NONE` / `FIREWALL_OK` / `FIREWALL_UNPROVEN` / `FIREWALL_ACTIVE`, the last two with their `REQUIRED_ACTION` line recorded) + `SELF_LAN_HEALTH_OK`; E.4.4's triad reading `200 / 401 / 200` through the chosen base. No `STOP:` line anywhere. --- **OPTION 3 (Tailscale) — the blocks from here to the end of the tailnet audit run ONLY when `REACH_MODE=tailscale`.** They are unchanged except for **E.4a0**, the client install that moved here from C.3 so that it happens only when the owner actually chose this route (`wo_20260825ae`). They are still the recommended route for "reach it from anywhere without building anything myself", and they are still the only route that gives you HTTPS with no certificate work. On options 1, 2 and 4 **skip straight past them** to E.4.4 — nothing below is a prerequisite for pairing, and installing Tailscale on a box that already has a way in is exactly the extra step the north star forbids. ```bash ENVF="$HOME/.carapace/reach-claude.env" if [ -s "$ENVF" ]; then . "$ENVF" 2>/dev/null || true; fi # NORMALISE BEFORE COMPARING (wo_20260826b). A hand-edited or CRLF-saved env file yields # REACH_MODE="tailscale\r" (or with a trailing space), which matches NOTHING below and prints # "OPTION3_SKIPPED (REACH_MODE=tailscale )" — a skip that reads like a match. Strip it once, here. REACH_MODE="$(printf '%s' "${REACH_MODE:-}" | tr -d '[:space:]')" case "${REACH_MODE:-unset}" in tailscale) echo "OPTION3_ACTIVE — run the Tailscale blocks below, then return to E.4.3's recorder and E.4.4" ;; unset) echo "STOP: no reachability decision on disk — run E.4.1/E.4.2 first (they may say you never need this section)" ;; *) echo "OPTION3_SKIPPED (REACH_MODE=$REACH_MODE) — do NOT run the Tailscale blocks below; go to E.4.4" ;; esac ``` **E.4a0 Install the Tailscale client — the ONE place in this document that installs it, and it runs only under `OPTION3_ACTIVE`** (`wo_20260825ae`; it used to be C.3(a)/(b), ~1200 lines before the owner was asked anything). On options 1, 2 and 4 you never reach this line, and this machine ends the install with exactly as much Tailscale on it as it started with. A re-run on a box that already has a good client installs nothing (`TS_INSTALL_NOT_NEEDED`), and a box that chose option 3 *later* (a second pass with a different answer) installs correctly then. **(a) macOS — Tailscale must be the standalone build, NOT Homebrew** (brew ships only the CLI; the full app is required for `tailscale serve`): 1. Download the macOS standalone package from https://pkgs.tailscale.com/stable/ and install `/Applications/Tailscale.app`. This needs an **admin password** — hand off explicitly: "please run this installer and click through it, then tell me 'done'". Do not assume you can drive the GUI. 2. Menu-bar icon → **"Install CLI…"** (writes `/usr/local/bin/tailscale`; also admin). 3. Then run the version gate **(c)** below — that block, and only that block, decides whether the installed version is acceptable. Do not judge the version by reading it. **(b) Linux — use the official repo or install script. NEVER snap** (the snap stable channel ships ancient versions — 1.88/1.92 have been observed, and `serve --set-path` behaviour differs). The fence re-reads the decision record itself, so pasting it on a non-Tailscale run is a no-op rather than an install — it can never be the source of an unwanted client: ```bash ENVF="$HOME/.carapace/reach-claude.env" if [ -s "$ENVF" ]; then . "$ENVF" 2>/dev/null || true; fi REACH_MODE="$(printf '%s' "${REACH_MODE:-}" | tr -d '[:space:]')" # whitespace/CR must not fake a skip (wo_20260826b) NODE="$(command -v node || true)" TSV=""; command -v tailscale >/dev/null 2>&1 && TSV="$(tailscale version 2>/dev/null | head -1 | tr -d ' ')" TSOK=no [ -n "$TSV" ] && [ -x "$NODE" ] && "$NODE" -e 'const m=String(process.argv[1]).match(/(\d+)\.(\d+)/);if(!m)process.exit(2);const a=+m[1],b=+m[2];process.exit((a>1||(a===1&&b>=98))?0:1)' "$TSV" && TSOK=yes if [ "${REACH_MODE:-unset}" != tailscale ]; then echo "TS_INSTALL_SKIPPED (REACH_MODE=${REACH_MODE:-unset}) — the owner's route does not use Tailscale, so NOTHING is installed here. Go to E.4.4." elif [ "$TSOK" = yes ]; then echo "TS_INSTALL_NOT_NEEDED — tailscale $TSV is already installed and at or above the 1.98 floor (idempotent re-run)" elif [ "$(uname -s)" = Darwin ]; then echo "macOS: do (a) above BY HAND — the standalone .pkg needs an admin password and a human click," echo " and this document never drives that GUI: https://pkgs.tailscale.com/stable/ , then the" echo " menu-bar 'Install CLI…' item, then re-run this block and (c)." else curl -fsSL https://tailscale.com/install.sh | sudo sh # official installer; picks apt/dnf for you # Fedora/RHEL alternative: sudo dnf install -y tailscale sudo systemctl enable --now tailscaled # One-time privilege grant so `tailscale serve` works without sudo (E.5): sudo tailscale set --operator="$(id -un)" \ || echo "NOTE: operator grant failed — every 'tailscale serve' WRITE in E.5 must then be run with sudo, and you must check the exit status of each one" fi ``` If you cannot get the operator grant, E.5 falls back to `sudo tailscale serve …` — but you must then check exit status on every write (read-only `serve status` succeeds unprivileged and will happily show you a **stale** route that makes a failed write look like a success). **(c) The gate, re-run after installing — the same 1.98 comparison C.3(d) printed, and HERE it binds.** C.3(d) is a probe (`TAILSCALE_ABSENT`/`TAILSCALE_TOO_OLD` pass there); this is option 3, where a client that cannot `serve --set-path` correctly is a broken route, not a note: ```bash NODE="$(command -v node || true)" if ! command -v tailscale >/dev/null 2>&1; then echo "STOP: tailscale still not on PATH after E.4a0 — the install did not complete. macOS: use the menu-bar 'Install CLI…' item, or symlink it yourself (sudo ln -sf /Applications/Tailscale.app/Contents/MacOS/Tailscale /usr/local/bin/tailscale)." elif [ ! -x "$NODE" ]; then echo "STOP: node not found — re-run A.1 (the comparison is done in node, not by eye)" else TSV="$(tailscale version 2>/dev/null | head -1 | tr -d ' ')" echo "tailscale version: ${TSV:-}" if [ -n "$TSV" ] && "$NODE" -e 'const m=String(process.argv[1]).match(/(\d+)\.(\d+)/);if(!m)process.exit(2);const a=+m[1],b=+m[2];process.exit((a>1||(a===1&&b>=98))?0:1)' "$TSV"; then echo "TAILSCALE_VERSION_OK (>= 1.98)" else echo "STOP: Tailscale ${TSV:-} is below the required 1.98 floor (or unparseable), and option 3 needs it." echo " Linux: if this came from snap, remove it and reinstall from the official script/repo:" echo " snap list tailscale 2>/dev/null; sudo snap remove tailscale; curl -fsSL https://tailscale.com/install.sh | sudo sh" echo " macOS: install the standalone app from https://pkgs.tailscale.com/stable/ and re-do 'Install CLI…' (a above);" echo " if 'command -v tailscale' resolves under /opt/homebrew or /usr/local/Cellar, that is the CLI-only" echo " Homebrew build — prefer /usr/local/bin/tailscale from the app." echo " Then re-run E.4a0. Do not continue to E.4a/E.5 on an older version." fi fi ``` Expected: `TAILSCALE_VERSION_OK (>= 1.98)` before E.4a. A `STOP:` here blocks **option 3 only** — the owner is not stuck, because re-running E.4.2 with `REACH_CHOICE=1|2|4` gives them a route that needs no Tailscale at all. **E.4a Tailscale up (skip if already Running) — the sign-in is the OWNER's action.** ```bash NODE="$(command -v node || true)" # Guarded like every other block that parses JSON (House rule 9). Without this, an absent node made # this block print `BackendState=` plus `command not found`, and then tell the owner to run # `tailscale up` on a host that was already Running — a wrong remedy from a missing tool. if [ ! -x "$NODE" ]; then echo "STOP: node not found — re-run A.1 (this block parses JSON with node)" elif ! command -v tailscale >/dev/null 2>&1; then echo "STOP: tailscale CLI not installed — run E.4a0 (this section's install step; it is the only place this document installs it), then re-run E.4a" else STATE="$(tailscale status --json 2>/dev/null | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(JSON.parse(s).BackendState||"UNKNOWN")}catch(e){process.stdout.write("UNKNOWN")}})')" echo "BackendState=${STATE:-UNKNOWN}" if [ "$STATE" = Running ]; then echo "already up and signed in — do NOT run 'tailscale up' (it can trigger reauth or reset flags)" elif [ "$STATE" = UNKNOWN ] || [ -z "$STATE" ]; then echo "STOP: tailscaled is not answering (see A.4 — macOS Homebrew CLI-only trap, or on Linux" echo " 'sudo systemctl enable --now tailscaled'). Fix that first; do NOT run 'tailscale up' blind." else echo "Run 'tailscale up' now and HAND THE SIGN-IN TO THE OWNER:" echo " - it prints a URL and blocks until the browser flow completes" echo " - on a headless host there is no browser: give the owner the URL and wait for them to say done" echo " - they must sign in as the SAME identity/tailnet the iPhone uses" echo " then re-run E.4a and require BackendState=Running before E.4b." fi fi ``` (When you do run it, `tailscale up --timeout=120s` keeps it from blocking forever. On Linux it may need `sudo`.) **E.4b Confirm WHICH tailnet — tailnet membership is the entire authorization.** ```bash NODE="$(command -v node || true)" if [ ! -x "$NODE" ]; then echo "STOP: node not found — re-run A.1"; else tailscale status --json 2>/dev/null | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let j;try{j=JSON.parse(s)}catch(e){console.log("STOP: tailscale status --json returned nothing parseable — tailscaled is not answering (see A.4); re-run E.4b once it is");return}j=j||{};const u=(j.User&&j.Self&&j.User[j.Self.UserID])||{};console.log("tailnet="+((j.CurrentTailnet&&j.CurrentTailnet.Name)||j.MagicDNSSuffix||"unknown"));console.log("magicDNSSuffix="+(j.MagicDNSSuffix||"unknown"));console.log("login="+(u.LoginName||"unknown"));console.log("host="+String((j.Self&&j.Self.DNSName)||"").replace(/\.$/,""));})' fi ``` Ask the owner, in these words: **"Is `` the tailnet your iPhone is signed into, and do you control every device on it?"** A work/corporate or shared tailnet means devices the owner does not control can reach `/carapace-claude`, which grants a lane with their full Claude capabilities. **When the owner is interactively present, do not run E.5 without an explicit yes.** If it is the wrong tailnet, the owner must switch this machine (`tailscale logout` then sign in to the right one) first. **Unattended / agent-driven installs — the same carve-out Phase B carries, for the same reason.** If the owner is not interactively present, **pasting this prompt into their agent constitutes acceptance of the stated defaults, and that includes this question.** A literal "do not proceed without a yes" read would block *every* agent-driven install at the one step that has no offline substitute, forever. So: do **not** block. Instead do all three of these, and record the exact outcome — the record is what makes the decision reviewable later: 1. Print the block's own output (`tailnet=`, `login=`, `host=`) into the Final Report verbatim. That login name **is** the evidence of whose tailnet this is. 2. Emit the marker below so the answer is grep-able rather than remembered. 3. Fill the Final Report's `owner confirmed control:` field with **`not asked (unattended)`** — never `yes`. `yes` means a human said yes. ```bash # EDIT ONE LINE. Leave it `unattended` when no human is answering questions in this session. TAILNET_CONFIRM=unattended # <-- yes | no | unattended NODE="$(command -v node || true)" TN="$(tailscale status --json 2>/dev/null | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let j;try{j=JSON.parse(s)}catch(e){return}j=j||{};const u=(j.User&&j.Self&&j.User[j.Self.UserID])||{};process.stdout.write(((j.CurrentTailnet&&j.CurrentTailnet.Name)||j.MagicDNSSuffix||"unknown")+" login="+(u.LoginName||"unknown"))})' 2>/dev/null)" case "$TAILNET_CONFIRM" in yes) echo "TAILNET_CONFIRMED_BY_OWNER: $TN" ;; no) echo "STOP: the owner said this is NOT their tailnet, or they do not control its devices." echo " Do not run E.5. Have them 'tailscale logout' and sign in to the right tailnet, then re-run E.4." ;; *) echo "TAILNET_UNATTENDED: $TN — no human confirmed this; proceeding under the Phase B unattended rule." echo " Final Report: tailnet=, owner confirmed control=not asked (unattended)." echo " This is a NOTE, not a STOP (House rule 2): it never blocks, and it must never be" echo " written up as a confirmation that did not happen." ;; esac ``` Expected: `TAILNET_CONFIRMED_BY_OWNER` (a human answered) **or** `TAILNET_UNATTENDED` (nobody was asked). Only `TAILNET_CONFIRM=no` is a `STOP:` — that is a real answer, and it is a refusal. **E.4c Derive the tailnet host.** ```bash NODE="$(command -v node || true)" # Guard first: with node absent this printed "STOP: no usable tailnet host" and two WRONG # diagnoses (sign-in / MagicDNS) on a perfectly signed-in host. A missing tool is its own cause. if [ ! -x "$NODE" ]; then echo "STOP: node not found — re-run A.1 (this block parses JSON with node)" elif ! command -v tailscale >/dev/null 2>&1; then echo "STOP: tailscale CLI not installed — run E.4a0 (this section installs the client; nothing earlier does), then re-run E.4c" else HOSTN="$(tailscale status --json 2>/dev/null | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(String((JSON.parse(s).Self||{}).DNSName||"").replace(/\.$/,""))}catch(e){}})')" echo "tailnet host: ${HOSTN:-}" case "$HOSTN" in *.ts.net) echo "HOSTN_OK" ;; *) echo "STOP: no usable tailnet host. Either sign-in is incomplete (E.4a: BackendState must be Running)" echo " or MagicDNS is OFF for this tailnet (turn it on in the Tailscale admin console)." echo " Confirm which by re-running E.4a — it now separates 'tailscaled not answering' from" echo " 'signed out'. Do not guess between the two." ;; esac fi ``` Two **Tailscale** settings (not CARAPACE) must be ON in the admin console: **MagicDNS** and **HTTPS Certificates** — the latter is required for `tailscale serve --https=443` to obtain a cert, and a brand-new tailnet has it off. There is no CLI toggle. The **observable proof** that both are ON, and the only evidence a headless installer can honestly tick, is E.6: a `200` from `https://.ts.net/carapace-claude/health`. Until E.6 passes, treat these as unverified. **E.5a Snapshot the serve config, then READ it.** ```bash mkdir -p "$HOME/.carapace"; chmod 700 "$HOME/.carapace" if ! command -v tailscale >/dev/null 2>&1; then echo "STOP: tailscale CLI not installed — run E.4a0 (this section installs the client; nothing earlier does), then re-run E.5a" else # Write-only-if-changed: every re-run of this document would otherwise leave another # serve-snapshot-.txt behind forever. A snapshot is only useful when it differs from the # newest one we already have. (`find`, not a glob — an unmatched glob aborts a zsh block.) NEWSNAP="$HOME/.carapace/.serve-snapshot.new" tailscale serve status > "$NEWSNAP" 2>&1 PREVSNAP="$(find "$HOME/.carapace" -maxdepth 1 -name 'serve-snapshot-*.txt' -print 2>/dev/null | sort | tail -1)" if [ -n "$PREVSNAP" ] && cmp -s "$NEWSNAP" "$PREVSNAP"; then rm -f "$NEWSNAP"; SNAP="$PREVSNAP" echo "serve config is byte-identical to the newest existing snapshot — no new file written" else SNAP="$HOME/.carapace/serve-snapshot-$(date +%Y%m%d-%H%M%S).txt" mv "$NEWSNAP" "$SNAP"; chmod 600 "$SNAP" echo "new snapshot written (serve config changed since the last one, or this is the first)" fi echo "snapshot: $SNAP"; cat "$SNAP" fi ``` Serve config is **machine-global and shared with everything else the owner runs.** Decide before you mutate: - If port 443 already has a **whole-port or root proxy** that is not an obviously dead CARAPACE route, `STOP:` and get the owner's explicit OK before touching 443 — adding `--set-path` alongside it can displace their service. - The snapshot is your restore recipe: any route you remove can be put back by re-issuing `tailscale serve --bg --https=443 --set-path ` with the values from that file. - **Never** `tailscale serve reset` and **never** `tailscale serve --https=443 off` (the CLI suggests both; both delete every other service's routes on this host). **E.5b Add OUR route (and check the exit status — this is where Linux bites).** ```bash if tailscale serve --bg --https=443 --set-path /carapace-claude http://127.0.0.1:18800; then echo "SERVE_SET_OK (unprivileged)" elif [ "$(uname -s)" != Darwin ] && sudo tailscale serve --bg --https=443 --set-path /carapace-claude http://127.0.0.1:18800; then echo "SERVE_SET_OK (via sudo)" echo "cleaner one-time fix: sudo tailscale set --operator=\"$(id -un)\"" else echo "STOP: serve write failed. On Linux this is usually 'Access denied: serve config denied'" echo " (grant the operator bit or use sudo, E.4a0(b)); it can also be tailscaled not running." echo " Do NOT trust 'tailscale serve status' here: it succeeds read-only and may show a" echo " STALE /carapace-claude line from a previous install, faking success." fi ``` The arguments are spelled out in full in both branches on purpose — never move them into a variable, because `tailscale serve $ARGS` does not split into arguments under zsh. **Root `/health` alias.** The iPhone's connection pearl probes the **machine root** `/health` for reachability, so this alias is what makes the pearl read green instead of grey. `--set-path` **REPLACES** whatever is at that path, and serve config is machine-global (House rule 10), so this block **probes who owns `/health` first** and refuses to displace anyone. It then proves the alias actually answers `{"ok":true}` — setting a route is not evidence that it works. **First, the question this document used to skip: is this the ONLY CARAPACE runtime on this host?** Root `/health` is a **machine-level** route, but the only ownership test below is "who owns it right now". On a host that also runs the Codex and/or OpenClaw runtimes, that makes the answer depend on **install order**: whichever runtime is installed first finds `/health` unclaimed and takes the machine's pearl probe; every runtime installed afterwards runs this same block, reads the first one's origin, correctly classifies it as foreign, and leaves it. Nothing warned you that the outcome was decided by a coin toss. It is worth being deliberate about, for one reason: the teardown of the runtime that owns `/health` **removes the pearl probe for every other runtime on the box**. Run this before the ownership probe: ```bash NODE="$(command -v node || true)" if [ ! -x "$NODE" ]; then echo "STOP: node not found — re-run A.1"; else OTHERS="$(tailscale serve status --json 2>/dev/null | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let j=null;try{j=JSON.parse(s.trim()||"null")}catch(e){process.stdout.write("UNKNOWN");return}if(j===null){process.stdout.write("");return}const web=(j&&j.Web)||{};const out=[];for(const k of Object.keys(web)){const hs=(web[k]||{}).Handlers||{};for(const p of Object.keys(hs)){if(/^\/carapace/.test(p)&&p!=="/carapace-claude"){const t=hs[p]||{};out.push(p+" -> "+(t.Proxy||t.Text||"?"))}}}process.stdout.write(out.join("; "))})' 2>/dev/null)" case "$OTHERS" in UNKNOWN) echo "NOTE: serve config unreadable here — the ownership probe below has its own STOP for that." ;; "") echo "SINGLE_RUNTIME_HOST: no other /carapace* route is served here. Claiming root /health is uncontested." ;; *) echo "MULTI_RUNTIME_HOST: this machine already serves other CARAPACE runtimes: $OTHERS" echo " Root /health is ONE route for the whole machine, and any CARAPACE gateway answers it" echo " with {\"ok\":true}, so the pearl is green whichever of them owns it — there is nothing" echo " to fix and nothing to fight over. Two consequences you must carry forward:" echo " 1. Record in the Final Report WHICH runtime owns root /health (the probe below prints" echo " the origin port: 18800 = Claude, 18802 = Codex, 18794 = OpenClaw)." echo " 2. If this runtime ends up owning it, the uninstall section's /health removal takes the" echo " pearl probe away from the OTHER runtimes too. Re-point it at a surviving gateway in" echo " the same teardown, or the owner's phone goes grey for runtimes that still work." ;; esac fi ``` This block only *classifies* — it writes nothing. The default stays "claim `/health` if it is unclaimed", because a machine with no `/health` at all shows a grey pearl for every runtime on it. ```bash NODE="$(command -v node || true)" if [ ! -x "$NODE" ]; then echo "STOP: node not found — re-run A.1" elif ! command -v tailscale >/dev/null 2>&1; then echo "STOP: tailscale CLI not installed — run E.4a0 (this section installs the client; nothing earlier does), then re-run this block" else # 1. Who owns root /health right now — and is a root proxy hiding one we cannot see? # The script prints EXACTLY "HEALTH=" + "ROOT=", or a PARSE_FAIL # sentinel. A sentinel matters: the empty string used to mean BOTH "nobody owns /health" and # "I could not find out", and the second one must never authorise a write to a machine-global, # shared config (House rule 10). OWN="$(tailscale serve status --json 2>/dev/null | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const raw=s.trim();if(!raw){process.stdout.write("PARSE_FAIL:empty-output");return}let j;try{j=JSON.parse(raw)}catch(e){process.stdout.write("PARSE_FAIL:unparseable");return}if(j===null){process.stdout.write("HEALTH=NONE\nROOT=NONE");return}const web=(j&&j.Web)||{};let h="NONE",r="NONE";for(const k of Object.keys(web)){const hs=(web[k]||{}).Handlers||{};const pick=(p)=>{const t=hs[p];if(!t)return null;return t.Proxy||t.Text||JSON.stringify(t)};const a=pick("/health");if(a)h=a;const b=pick("/");if(b)r=b}process.stdout.write("HEALTH="+h+"\nROOT="+r)})' 2>/dev/null)" [ -n "$OWN" ] || OWN="PARSE_FAIL:no-output" HOWN="$(printf '%s\n' "$OWN" | grep '^HEALTH=' | cut -d= -f2-)" ROWN="$(printf '%s\n' "$OWN" | grep '^ROOT=' | cut -d= -f2-)" SETIT=no case "$OWN" in PARSE_FAIL*) SETIT=unknown ;; esac { [ -n "$HOWN" ] && [ -n "$ROWN" ]; } || SETIT=unknown if [ "$SETIT" = unknown ]; then echo "STOP: could not determine who owns root /health (probe said: $OWN)." echo " 'tailscale serve status --json' returned nothing parseable — usually tailscaled not" echo " answering (A.4), or a tailscale CLI too old for --json (C.3(d) requires >= 1.98)." echo " Serve config is machine-global and shared (House rule 10): a DETECTION failure is not" echo " a licence to write. Nothing was changed. Fix the CLI/daemon and re-run this block." echo " (Skipping the alias entirely is also acceptable — see the Final Report's 'not set' option.)" else echo "current owner of root /health: $HOWN" echo "root '/' handler: $ROWN" case "$HOWN" in NONE) case "$ROWN" in NONE|*127.0.0.1:18800*) SETIT=yes; echo "root /health is unclaimed — safe to add ours" ;; *) SETIT=no echo "NOTE: there is no explicit /health handler, BUT root '/' is proxied to $ROWN. That" echo " service most likely serves its own /health UNDER that proxy, and --set-path" echo " /health would SHADOW it. This is E.5a's whole-path/root-proxy rule, enforced" echo " here instead of left to prose: LEFT UNTOUCHED, nothing was written." echo " Only with the owner's explicit OK, run the --set-path /health command from this" echo " block by hand, then re-run E.5c and E.6. Otherwise record it and continue —" echo " pairing works without the alias; only the phone's pearl is affected." ;; esac ;; *127.0.0.1:18800*) SETIT=yes; echo "root /health already points at OUR port — re-issuing is a no-op" ;; *) SETIT=no echo "NOTE: root /health is owned by another service ($HOWN) — LEFT UNTOUCHED (House rule 10)." echo " Do not 'fix' the pearl by taking it over. Record it in the Final Report and tell the" echo " owner: their existing /health answers the phone's probe, or they choose to re-point it." ;; esac # 2. Only write if it is unclaimed or already ours. if [ "$SETIT" = yes ]; then if tailscale serve --bg --https=443 --set-path /health http://127.0.0.1:18800/health; then echo "HEALTH_ALIAS_SET" elif [ "$(uname -s)" != Darwin ] && sudo tailscale serve --bg --https=443 --set-path /health http://127.0.0.1:18800/health; then echo "HEALTH_ALIAS_SET (via sudo)" else SETIT=failed echo "STOP: the /health alias write FAILED (same causes as E.5b: operator grant / sudo / tailscaled)." echo " Do not hand-wave this as cosmetic — resolve it as you would E.5b, then re-run this block." echo " Not probing /health below: after a failed write, anything that answers is not evidence" echo " that OUR alias exists, and no marker may claim it does." fi fi # 3. Prove it answers. A fresh serve config mints a cert on demand: 000 = not ready YET. # No `|| echo ' HTTP=000'`: curl already writes HTTP=000 itself on a connection failure, so the # `||` would print the marker twice and make the retry condition read a doubled string. HOSTN="$(tailscale status --json 2>/dev/null | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(String((JSON.parse(s).Self||{}).DNSName||"").replace(/\.$/,""))}catch(e){}})')" case "$HOSTN" in *.ts.net) : ;; *) HOSTN=""; echo "NOTE: no tailnet host yet (E.4c) — cannot probe /health here; E.6 re-checks it" ;; esac if [ -n "$HOSTN" ] && [ "$SETIT" != failed ]; then i=0; R="" while [ $i -lt 12 ]; do R="$(curl -s -m 10 -w ' HTTP=%{http_code}' "https://$HOSTN/health" 2>/dev/null)" case "$R" in *HTTP=000*) i=$((i+1)); sleep 5 ;; *) break ;; esac done echo "root /health probe (after ${i} retries): $R" # The verdict depends on WHOSE route this is, because that decides whether anything here is # ours to fix. When we wrote nothing, no outcome can be a STOP: there is no action to take, # and a STOP the reader cannot resolve is an infinite loop (House rule 2). if [ "$SETIT" = yes ]; then case "$R" in *'"ok":true'*) echo "HEALTH_ALIAS_VERIFIED (ours: /health -> 127.0.0.1:18800)" ;; *HTTP=200*) echo "NOTE: root /health answered 200 but NOT {\"ok\":true} — something other than our gateway answered. Record it verbatim; do not overwrite anything." ;; *HTTP=404*) echo "STOP: root /health is 404 and the alias above is OURS — the write did not take effect. Re-check the write's output and E.5c's route listing." ;; *HTTP=50*) echo "STOP: root /health is 5xx and the route is OURS — our origin is dead. Fix the gateway at E.1c (health 200, listener = supervised pid); do NOT delete the route." ;; *HTTP=000*) echo "NOTE: still 000 after ~60s — cert mint/MagicDNS not ready. Not a failure yet: E.6 probes it again." ;; *) echo "NOTE: unexpected /health response — record it verbatim in the Final Report" ;; esac else case "$R" in *'"ok":true'*) echo "HEALTH_ALIAS_FOREIGN_OK (another service answers root /health with {\"ok\":true} — pearl fine, route NOT ours, do not remove it)" ;; *HTTP=200*) echo "HEALTH_ALIAS_FOREIGN_OTHER (200 without {\"ok\":true}, and the route is not ours — the pearl may read grey. Record it; the owner decides whether to re-point their own /health. NOT this install's to fix.)" ;; *) echo "HEALTH_ALIAS_FOREIGN_UNHEALTHY (root /health is not answering usefully: $R — and it is NOT ours. We wrote nothing and must change nothing here. Record it; the pearl may read grey until the owner fixes their endpoint. This does NOT block the install.)" ;; esac fi fi fi fi ``` This alias is **unauthenticated by design** and returns only `{"ok":true}` — no token, no data. Tell the owner. Decision table for the probe — note that **the same HTTP code means different things depending on whose route it is**, which is why the block branches on that first: | Probe | Whose route | Marker / verdict | Action | |---|---|---|---| | `{"ok":true}` | ours (we set it) | `HEALTH_ALIAS_VERIFIED` | done — pearl will read green | | `{"ok":true}` | another service's | `HEALTH_ALIAS_FOREIGN_OK` | leave it; record it. Pearl is fine, route is not ours | | `200` without `"ok":true` | ours | NOTE | something else answered — record verbatim, overwrite nothing | | `200` without `"ok":true` | another service's | `HEALTH_ALIAS_FOREIGN_OTHER` | record; owner's endpoint, owner's call | | `404` | ours | **STOP** | our write did not take effect — re-check it and E.5c's listing | | `502`/`504` | ours | **STOP** | our origin is dead — fix the gateway (E.1c); never delete the route | | `404`/`5xx`/`000`/anything odd | another service's | `HEALTH_ALIAS_FOREIGN_UNHEALTHY` (NOTE) | record it and **continue** — not ours to fix, and not a blocker | | `000` after ~60s | ours | NOTE | cert mint / MagicDNS not ready; **not** a failure — E.6 re-probes | | ownership undeterminable | unknown | **STOP** | `serve status --json` unreadable: nothing written, fix the CLI/daemon (A.4) first | Only an alias **we** set is ours to remove; the uninstall section removes exactly that one and leaves a foreign `/health` alone. **E.5c Audit the whole serve tree: our route, the `/health` alias, dead CARAPACE routes, Funnel.** ```bash NODE="$(command -v node || true)" if [ ! -x "$NODE" ]; then echo "STOP: node not found — re-run A.1" elif ! command -v tailscale >/dev/null 2>&1; then echo "STOP: tailscale CLI not installed — run E.4a0 (this section installs the client; nothing earlier does), then re-run E.5c" else # `tailscale serve status --json` marshals to literal `null` when there is NO serve config at # all, so j.Web on an unguarded parse throws "Cannot read properties of null". Guarded here. # The Funnel verdict is EMITTED AS A MARKER (FUNNEL_OFF / STOP:), not left for the reader to # eyeball, because House rule 6 ("never enable Funnel") is on the acceptance checklist and every # other checklist item has a grep-able marker. tailscale serve status --json 2>/dev/null | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let j;try{j=JSON.parse(s)}catch(e){console.log("STOP: serve status --json returned nothing parseable — tailscaled not answering (A.4), or the CLI is older than the 1.98 floor C.3(d) requires; re-run E.5c");return}if(j===null){console.log("STOP: no serve config at all on this host (serve status --json is literal null) — E.5b did not take effect. Re-run E.5b and read its exit status, then re-run E.5c.");return}const web=j.Web||{};const ks=Object.keys(web);if(!ks.length)console.log("(no Web handlers)");for(const k of ks){const h=(web[k]||{}).Handlers||{};for(const p of Object.keys(h)){const t=h[p]||{};console.log("route "+k+p+" -> "+(t.Proxy||t.Text||JSON.stringify(t)))}}const af=j.AllowFunnel;console.log("AllowFunnel="+JSON.stringify(af===undefined?null:af));const on=af&&typeof af==="object"&&Object.keys(af).some(k=>af[k]===true);console.log(on?"STOP: FUNNEL IS ON for "+Object.keys(af).filter(k=>af[k]===true).join(",")+" — that publishes this host to the PUBLIC INTERNET (House rule 6). Turn it off (tailscale funnel --https=443 off), then re-run E.5c. Do not pair with Funnel on.":"FUNNEL_OFF");})' fi ``` Read it as: - **Our route** must be present: `…/carapace-claude -> http://127.0.0.1:18800`. - **Our `/health` alias** (if this host set one): `…/health -> http://127.0.0.1:18800/health`. If the listing shows `/health` pointing somewhere else, that is **another service's** and we did not touch it — record it. - **Funnel**: the block prints `FUNNEL_OFF` (required — `AllowFunnel` null/empty or with no `true` entry) or a `STOP:` naming the funnelled host:port. That marker is what the acceptance checklist ticks; do not substitute your own reading of the raw value. Do **not** use `tailscale funnel status` for this — on 1.98 it prints the entire serve tree, byte-identical to `serve status`, and is uninterpretable as a pass/fail signal. - **Other `carapace*` routes**: probe each one before touching anything (next block). ```bash NODE="$(command -v node || true)"; GW_DIR="$HOME/.carapace/claude-gateway" if [ ! -x "$NODE" ]; then echo "STOP: node not found — re-run A.1" elif ! command -v tailscale >/dev/null 2>&1; then echo "STOP: tailscale CLI not installed — run E.4a0 (this section installs the client; nothing earlier does), then re-run E.5c" else HOSTN="$(tailscale status --json 2>/dev/null | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(String((JSON.parse(s).Self||{}).DNSName||"").replace(/\.$/,""))}catch(e){}})')" case "$HOSTN" in *.ts.net) : ;; *) HOSTN=""; echo "STOP: no tailnet host — re-run E.4c" ;; esac if [ -n "$HOSTN" ]; then # List every carapace* path AND root /health (the alias E.5b may have added), so nothing this # install created goes unverified. The list goes to a FILE inside the 0700 dir first (never # /tmp, House rule 7) for two reasons: a `while read` after a pipe runs in a subshell, and an # unreadable serve config must FAIL LOUD instead of silently producing an empty route list that # looks exactly like "audited, nothing wrong". RLIST="$GW_DIR/.serve-paths"; rm -f "$RLIST" tailscale serve status --json 2>/dev/null | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const raw=s.trim();if(!raw){console.log("PARSE_FAIL:empty-output");return}let j;try{j=JSON.parse(raw)}catch(e){console.log("PARSE_FAIL:unparseable");return}if(j===null){console.log("PARSE_FAIL:no-serve-config");return}const web=j.Web||{};const out=[];for(const k of Object.keys(web)){const h=(web[k]||{}).Handlers||{};for(const p of Object.keys(h))if(/carapace/i.test(p)||p==="/health")out.push(p+" "+((h[p]||{}).Proxy||"-"))}console.log(out.join("\n"))})' > "$RLIST" if grep -q '^PARSE_FAIL' "$RLIST"; then echo "STOP: could not read the serve config ($(cat "$RLIST")) — tailscaled not answering (A.4)," echo " a CLI below the 1.98 floor (C.3(d)), or no serve config at all (then E.5b did not take" echo " effect — re-run it and read its exit status). NOTHING was audited: do not read the" echo " absence of route lines below as 'no problems found'." else # Our route must actually BE there. An empty list, or a list holding only other runtimes' # paths, means E.5b's write did not take effect — and that has to be said out loud rather than # inferred from a missing line. Exact path match: /carapace-claude-old is not our live route. if ! grep -q '^/carapace-claude ' "$RLIST"; then echo "STOP: the serve config parsed, but it contains NO /carapace-claude route — E.5b's write did" echo " not take effect. Re-run E.5b and READ ITS EXIT STATUS. Anything probed below belongs" echo " to another service and is not a substitute for ours." fi while read -r P ORIGIN; do [ -n "$P" ] || continue # Probe ONLY our own surface. Foreign /carapace* routes (other runtimes, or things like # /carapace-hermes/*) are listed above for awareness but never probed: probing 20+ dead # foreign routes costs up to 30s each and buries the two lines that matter, and the # ownership policy forbids touching them anyway. case "$P" in /carapace-claude|/carapace-claude/*|/health) : ;; *) echo "route $P (origin $ORIGIN) -> not probed (foreign route — listed for awareness, left untouched)"; continue ;; esac # /health IS the health endpoint; every other path exposes ours under /health. case "$P" in /health) U="https://$HOSTN/health" ;; *) U="https://$HOSTN$P/health" ;; esac # Retry: a route created seconds ago in E.5b is still inside the on-demand cert window, and a # first-probe 000 there means "not ready yet", NOT "dead". Never judge on a single 000. # NO `|| echo 000` — curl already writes 000 into %{http_code} on a connection failure and # then exits non-zero, so the `||` would make C the token `000000`: not equal to 000, so the # loop would break on attempt 1 and print a code that matches no row of the table below. i=0; C=000 while [ $i -lt 6 ]; do C="$(curl -s -m 10 -o /dev/null -w "%{http_code}" "$U" 2>/dev/null)" [ "$C" != 000 ] && break i=$((i+1)); sleep 5 done echo "route $P (origin $ORIGIN) -> $C (after ${i} retries, ~5s apart)" done < "$RLIST" fi rm -f "$RLIST" i=0; C=000 while [ $i -lt 6 ]; do C="$(curl -s -m 10 -o /dev/null -w "%{http_code}" "https://$HOSTN/" 2>/dev/null)" [ "$C" != 000 ] && break i=$((i+1)); sleep 5 done echo "serve root / -> $C (after ${i} retries, ~5s apart) (anything except 502/504 is fine)" fi fi ``` Every `-> ` line above is a **real** code from a real request, and `(after N retries)` is the truth about how long it took: `N=0` means it answered first try, `N=6` with `000` means it never answered in ~30s. There is no path where a code is invented or doubled. **Reading a probe:** | Probe result | Means | What you may do | |---|---|---| | `200` | alive | nothing — correct | | `401` | alive (endpoint requires auth) | nothing — correct | | `404` | route absent, or path not mounted | re-check E.5b's write; never delete anything on a 404 | | `502` / `504` | a route exists, its origin is dead | see the policy below — **only** ours may be removed | | `000` after the retries above | not reachable/not ready (cert mint, MagicDNS, tailscaled) | **never** delete a route on a `000` — especially one created seconds ago in E.5b. Re-probe in E.6; if it is still `000` there, diagnose reachability, do not mutate routes | **Route policy — the only rules you may act on.** One ownership test decides everything: a route is **ours** if its path is `/carapace-claude*`, **or** it is the `/health` alias whose origin is **exactly** `127.0.0.1:18800`. Nothing else is ours, whatever it is named and however broken it is. A `/health` whose origin is anything else is **another service's** — E.5b and the uninstall both treat it that way, and so must you: it is not "our stale alias", because from the outside those two are indistinguishable. - **Ours, alive** (`/carapace-claude` → 200/401, `/health` → `{"ok":true}`): correct, leave it. - **Ours, but the origin is a STALE port** — a `/carapace-claude*` route whose origin is **not** `127.0.0.1:18800`: leftover from a previous install of **this** runtime, and the one route you may remove. Use this fence (it is a hard gate, not a suggestion: it refuses any path that is not `/carapace-claude*`, and it checks the write's exit status — a removal that silently failed leaves a route E.5c will later re-classify as ours-with-a-stale-origin and try to remove again): ```bash STALE_PATH="" # <-- EDIT to the exact path E.5c printed, e.g. /carapace-claude-old case "$STALE_PATH" in "") echo "STOP: STALE_PATH is empty — nothing was removed. Put the exact path E.5c printed on the EDIT line above." ;; /carapace-claude*) if tailscale serve --https=443 --set-path "$STALE_PATH" off; then echo "REMOVED $STALE_PATH (exit status checked) — now re-run E.5b and E.5c" elif [ "$(uname -s)" != Darwin ] && sudo tailscale serve --https=443 --set-path "$STALE_PATH" off; then echo "REMOVED $STALE_PATH via sudo (exit status checked) — now re-run E.5b and E.5c" else echo "STOP: removal FAILED — the route is STILL THERE. Cause is the same as E.5b's write failure" echo " (operator grant / sudo / tailscaled — E.4a0(b)). Do not believe 'tailscale serve status'" echo " here: it succeeds read-only and will keep showing the route either way." fi ;; *) echo "STOP: '$STALE_PATH' is not a /carapace-claude* path — by the ownership test above it is NOT ours." echo " Nothing was removed. Record it as pre-existing debris instead (House rule 10)." ;; esac ``` - **Ours, origin IS `127.0.0.1:18800`, but it answers 502/504:** the route is fine — **our gateway is down.** Do **not** delete it. Go back to E.1c (health 200, listener = supervised pid) and fix the process; then re-probe. Deleting the route here would turn a five-second restart into a re-pair. - **Any `000`:** not a verdict. Never remove a route on the strength of a `000` — see the table above. Re-probe in E.6. - **Not ours — NEVER touched:** any other runtime's path (`/carapace`, `/carapace-codex`, `/carapace-hermes*`, …), a `/health` owned by another origin, and any unrelated service, **even when it answers 502.** Record it in the Final Report as pre-existing debris outside this install's scope, and tell the owner it may make the app's pearl flicker so they can decide. Deleting another service's routes is a worse outcome than a flickering pearl. - **Root `/`:** the app's reachability probe hits the machine root. `404` (no root handler), `000`/refused, and `200` (the owner legitimately serves something there) are **all healthy** — only `502`/`504` is broken, and that is what makes the connection pearl blink forever. If the root's origin is **our** 18800 and it 502s, that is the gateway-down case above: fix E.1c instead. If the root serves something the owner recognises, leave it, report it, and do not remove it even when it is broken — that is theirs to fix. Removing the root route is a **last-resort, three-condition** action, so it too gets a hard gate rather than a prose invitation — every condition is re-checked in the block, and the destructive command is unreachable unless all of them hold: ```bash ROOT_PORT="" # <-- EDIT to the port in the root '/' handler's origin, as E.5c printed it CONFIRM_ROOT_REMOVAL=0 # <-- EDIT to 1 only when ALL are true: root / is 502/504, the owner does # not recognise the service, and its origin is NOT our 18800 LISTENING=unprovable if [ -n "$ROOT_PORT" ]; then if command -v lsof >/dev/null 2>&1; then if lsof -nP -iTCP:"$ROOT_PORT" -sTCP:LISTEN >/dev/null 2>&1; then LISTENING=yes; else LISTENING=no; fi elif command -v ss >/dev/null 2>&1; then if ss -lntp "sport = :$ROOT_PORT" 2>/dev/null | grep -q ":$ROOT_PORT"; then LISTENING=yes; else LISTENING=no; fi fi fi echo "root origin port: ${ROOT_PORT:-} something listening there: $LISTENING" if [ "$CONFIRM_ROOT_REMOVAL" != 1 ]; then echo "NOTE: nothing done (CONFIRM_ROOT_REMOVAL=0). That is the safe default — a healthy root can be 404, 000 or 200." elif [ -z "$ROOT_PORT" ]; then echo "STOP: ROOT_PORT is empty — fill it in from E.5c's listing. Nothing was removed." elif [ "$ROOT_PORT" = 18800 ]; then echo "STOP: the root origin IS our gateway port — the route is fine and OUR GATEWAY IS DOWN." echo " Do not remove it. Fix E.1c, then re-probe. Nothing was removed." elif [ "$LISTENING" != no ]; then echo "STOP: cannot prove the origin is dead (listening=$LISTENING — 'yes' means something IS there," echo " 'unprovable' means neither lsof nor ss is installed, or an unprivileged check cannot see" echo " another user's socket: see A.3). Install lsof/ss or re-check with sudo. Nothing was removed." elif tailscale serve --https=443 --set-path / off; then echo "root route REMOVED (exit status checked) — re-probe the root now" elif [ "$(uname -s)" != Darwin ] && sudo tailscale serve --https=443 --set-path / off; then echo "root route REMOVED via sudo (exit status checked) — re-probe the root now" else echo "STOP: root route removal FAILED — it is STILL THERE (operator grant / sudo / tailscaled, E.4a0(b))." fi ``` Note this removes **only** the `/` path handler. Never `tailscale serve --https=443 off` and never `serve reset` (House rule 10): both delete every other service's routes on this host. **Assert for E.5:** our `/carapace-claude` route is present and points at `127.0.0.1:18800`; the root `/health` alias reached **one** of these recorded outcomes — ours and verified (`HEALTH_ALIAS_VERIFIED`), another service's and healthy (`HEALTH_ALIAS_FOREIGN_OK`), another service's and not healthy (`HEALTH_ALIAS_FOREIGN_OTHER` / `HEALTH_ALIAS_FOREIGN_UNHEALTHY`), or not set at all because a root proxy would have been shadowed — and in every one of those it was **never taken over**; `FUNNEL_OFF` was printed by E.5c (**Funnel OFF, tailnet only**); no stale-origin CARAPACE route **that we own** remains; the root is not 502/504. (Not "only `/carapace-claude` exists" — on a machine with history that is neither true nor desirable.) Note the deliberate asymmetry: only the two **ours** outcomes can produce a `STOP:`; a foreign `/health` is a recorded NOTE and never blocks, because there is nothing this install may do about it. **E.6 Verify through the tailnet origin, then gate the model catalog.** ```bash umask 077 NODE="$(command -v node || true)"; GW_DIR="$HOME/.carapace/claude-gateway" HOSTN="" if [ -x "$NODE" ] && command -v tailscale >/dev/null 2>&1; then HOSTN="$(tailscale status --json 2>/dev/null | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(String((JSON.parse(s).Self||{}).DNSName||"").replace(/\.$/,""))}catch(e){}})' 2>/dev/null || true)" fi case "$HOSTN" in *.ts.net) : ;; *) HOSTN=""; echo "STOP: no tailnet host — re-run E.4c" ;; esac # First HTTPS request after a fresh serve config mints a cert on demand: 15-20s is normal, and 000 # means "not ready / unreachable", not "broken" — so retry (12 x 5s) before diagnosing. # There is NO `|| echo 000` in this loop, and there must never be one: curl writes 000 into # %{http_code} itself on a connection failure and THEN exits non-zero, so `|| echo 000` appends a # second 000 (no trailing newline) and c becomes `000000` — which is != 000, so the loop breaks on # the FIRST attempt and prints a code that matches no row of any table in this document. That # turned the normal 15-20s cert wait into a hard failure one step before pairing. # $3, when given, is the PATH TO A 0600 HEADER FILE (House rule 1) — never a token. tri() { i=0; c=000 while [ $i -lt 12 ]; do if [ -n "$3" ]; then c="$(curl -s -m 10 -o /dev/null -w "%{http_code}" -H @"$3" "$2")" else c="$(curl -s -m 10 -o /dev/null -w "%{http_code}" "$2")"; fi [ "$c" != 000 ] && break i=$((i+1)); sleep 5 done echo "$1 -> $c (after ${i} retries, ~5s apart)" } if [ ! -x "$NODE" ]; then echo "STOP: node not found — re-run A.1" elif [ -n "$HOSTN" ] && [ -s "$GW_DIR/token" ]; then HF="$GW_DIR/.authhdr"; rm -f "$HF" printf 'Authorization: Bearer %s\n' "$(cat "$GW_DIR/token")" > "$HF"; chmod 600 "$HF" tri "serve health" "https://$HOSTN/carapace-claude/health" tri "serve no-auth" "https://$HOSTN/carapace-claude/api/app/v1/status" tri "serve good-auth" "https://$HOSTN/carapace-claude/api/app/v1/status" "$HF" tri "serve root" "https://$HOSTN/" # The pearl gate: the phone probes MACHINE-ROOT /health and needs {"ok":true} in the BODY. # A code alone is not evidence. WHOSE route it is decides whether a bad answer is actionable, so # re-derive ownership here (this block runs in a fresh shell — E.5b's SETIT is long gone). HOWN="$(tailscale serve status --json 2>/dev/null | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const raw=s.trim();if(!raw){process.stdout.write("PARSE_FAIL");return}let j;try{j=JSON.parse(raw)}catch(e){process.stdout.write("PARSE_FAIL");return}if(j===null){process.stdout.write("NONE");return}const web=(j&&j.Web)||{};let o="";for(const k of Object.keys(web)){const h=(web[k]||{}).Handlers||{};if(h["/health"]){const t=h["/health"]||{};o=t.Proxy||t.Text||JSON.stringify(t)}}process.stdout.write(o||"NONE")})' 2>/dev/null)" [ -n "$HOWN" ] || HOWN=PARSE_FAIL case "$HOWN" in *127.0.0.1:18800*) HMINE=yes ;; NONE) HMINE=none ;; PARSE_FAIL) HMINE=unknown ;; *) HMINE=no ;; esac echo "root /health handler: $HOWN (ours: $HMINE)" i=0; HR="" while [ $i -lt 12 ]; do HR="$(curl -s -m 10 -w ' HTTP=%{http_code}' "https://$HOSTN/health" 2>/dev/null)" case "$HR" in *HTTP=000*) i=$((i+1)); sleep 5 ;; *) break ;; esac done echo "root /health -> $HR (after ${i} retries, ~5s apart)" case "$HR" in *'"ok":true'*) if [ "$HMINE" = yes ]; then echo "ROOT_HEALTH_OK (our alias answers {\"ok\":true} — body read, not just the code)" else echo "ROOT_HEALTH_OK (root /health answers {\"ok\":true} from a compatible endpoint that is NOT ours — pearl will be green; recorded as foreign per E.5b, never taken over)"; fi ;; *) if [ "$HMINE" = yes ]; then echo "STOP: root /health is OURS but did not return {\"ok\":true} ($HR) — the phone's pearl will read" echo " grey/blinking. This one IS actionable: our origin is the gateway, so fix E.1c (health 200," echo " listener = supervised pid), then re-run this block. Do not delete the route." else echo "NOTE: root /health did not return {\"ok\":true} ($HR) and it is NOT ours (handler: $HOWN)." echo " This is NOT a STOP and NOT a blocker: E.5b either left a foreign /health alone or never" echo " set one, and re-running E.5b refuses again by policy in the foreign case — so a STOP" echo " here would be a loop the reader cannot resolve (House rule 2)." echo " The pearl may read grey until the owner fixes or re-points their own endpoint. Record this" echo " verbatim in the Final Report (that outcome is an accepted, recordable result) and continue" echo " to pairing: /carapace-claude is what the app actually talks to." if [ "$HMINE" = none ]; then echo " (Handler is NONE — no /health route exists. You may re-run E.5b's alias block ONCE: it" echo " writes only when /health is unclaimed and no root proxy would be shadowed. If it" echo " refuses or its write fails, record 'not set — pearl may read grey' and move on.)" elif [ "$HMINE" = unknown ]; then echo " (Ownership was undeterminable — 'serve status --json' unreadable. Fix tailscaled/the CLI" echo " per A.4 if you want a verdict; changing routes on an unreadable config is forbidden.)" fi fi ;; esac curl -s -m 30 -H @"$HF" "https://$HOSTN/carapace-claude/api/app/v1/models" \ | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);const a=Array.isArray(j)?j:(j.models||j.data||[]);console.log("models: "+a.length);console.log(a.length>=1?"CATALOG_OK":"CATALOG_EMPTY")}catch(e){console.log("CATALOG_UNPARSEABLE")}})' rm -f "$HF" else echo "STOP: missing tailnet host or token — resolve E.4c / C.2 first" fi ``` Expect: `serve health -> 200`, `serve no-auth -> 401`, `serve good-auth -> 200`, `serve root` = **anything except 502/504** (`404`, `000`/refused and `200` are all healthy — see E.5c), `ROOT_HEALTH_OK` (or the recorded foreign-`/health` NOTE), and `CATALOG_OK` with `models: >= 1`. About the `(after N retries)` suffix on those four lines: `N=0` means it answered immediately; `N` between 1 and 11 with a real code is **normal and healthy** right after E.5b (the on-demand cert mint takes ~15–20s), not a warning. Only `000 (after 11 retries)` — roughly a minute of nothing — is a reachability problem, and then you diagnose MagicDNS/HTTPS-Certificates (E.4c) and tailscaled; you do **not** mutate routes on a `000` (E.5c's table). If you ever see a code that is not exactly three digits (e.g. `000000`), a retry loop has been edited to add `|| echo 000` — undo that, it is the one thing these loops must not contain. This catalog gate is what proves the phone's **model picker will populate** — server-side, before pairing. `CATALOG_EMPTY` / `CATALOG_UNPARSEABLE`: see the failure table, then run E.6b. **E.6b Loopback catalog probe — the same question asked at the origin, bypassing Tailscale.** Run this whenever E.6 printed `CATALOG_EMPTY` or `CATALOG_UNPARSEABLE`: it separates "the gateway has no catalog" from "the tailnet path mangled the response". **It is also the catalog gate for options 1, 2 and 4** (`wo_20260826b`). Despite sitting inside the OPTION 3 section it touches no Tailscale at all — it dials `127.0.0.1:18800` — so on a route that skipped E.4a0–E.6 this is the block that proves the phone's model picker will populate. Run it there; `LOOPBACK_CATALOG_OK` is what the acceptance checklist reads on those routes. ```bash umask 077 NODE="$(command -v node || true)"; GW_DIR="$HOME/.carapace/claude-gateway" if [ ! -x "$NODE" ]; then echo "STOP: node not found — re-run A.1" elif [ ! -s "$GW_DIR/token" ]; then echo "STOP: token missing — re-run C.2" else HF="$GW_DIR/.authhdr"; rm -f "$HF" printf 'Authorization: Bearer %s\n' "$(cat "$GW_DIR/token")" > "$HF"; chmod 600 "$HF" # 2026-07-30 audit D-8: print the IDS, not just the count. A malformed GW_MODELS is discarded # silently and the gateway serves its built-in catalog instead, so the count stays healthy and # this probe printed LOOPBACK_CATALOG_OK on a host whose override had been rejected. The ids # are the only observable that tells the owner whose catalog they are actually looking at. curl -s -m 30 -H @"$HF" http://127.0.0.1:18800/api/app/v1/models \ | "$NODE" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);const a=Array.isArray(j)?j:(j.models||j.data||[]);console.log("loopback models: "+a.length);console.log("ids="+a.map(m=>(m&&(m.id||m.name))||"?").join(","));console.log(a.length>=1?"LOOPBACK_CATALOG_OK":"LOOPBACK_CATALOG_EMPTY")}catch(e){console.log("LOOPBACK_CATALOG_UNPARSEABLE")}})' rm -f "$HF" fi ``` Read it: - `LOOPBACK_CATALOG_OK` but E.6 said `CATALOG_EMPTY`/`UNPARSEABLE` → the gateway is fine; the problem is the serve path or auth through the tailnet. Re-run E.5b/E.5c and E.6. - `LOOPBACK_CATALOG_EMPTY` → the gateway itself has no catalog. Check whether a re-run wiped a hand-added `GW_MODELS`: `grep -c GW_MODELS ~/Library/LaunchAgents/com.carapace.claude-gateway.plist` (macOS) or `grep -c GW_MODELS ~/.config/systemd/user/carapace-claude-gateway.service` (Linux). If it is not set, the built-in catalog failed to build: read `gw.log`. - **`LOOPBACK_CATALOG_OK` is not proof that a `GW_MODELS` override took (2026-07-30 audit D-8).** Bad JSON in `GW_MODELS` is **discarded silently** — the gateway falls back to its built-in catalog, so the count is healthy and this probe says OK while the owner's list is nowhere. Read the `ids=` line: if `GW_MODELS` is set and your ids are not in it, the value was rejected. Fix the quoting per C.4's recipe, reload with the Section-3 pair, and re-run this probe. - `LOOPBACK_CATALOG_UNPARSEABLE` → you got HTML or an error body from the origin itself; re-check E.1c (are you talking to our supervised pid?) and Phase D's triad. Diagnostic: if instead you see `serve health` = **401** and `serve good-auth` = **404**, Tailscale Serve did not strip the `/carapace-claude` mount prefix (the gateway strips only `/api/app/v1`). Re-run E.5b and re-check. **VERIFICATION (Phase E):** on Linux `LINGER_OK`; E.1c three assertions hold; E.2 shows a different pid + 200; E.3 prints `SMOKE_OK`. **Then, on option 3 only** (`OPTION3_ACTIVE`): `TAILSCALE_VERSION_OK` from E.4a0(c) — C.3(d)'s probe verdict is not a substitute, and on options 1, 2 and 4 there is no Tailscale to verify and E.4a0 through E.6 are all `N/A`; E.4b tailnet confirmed by the owner; E.5 assert holds (including `FUNNEL_OFF` and the `/health` alias outcome — set-and-verified, or foreign-and-untouched, or deliberately not set because a root proxy would have been shadowed); E.6 prints `200 / 401 / 200`, root not 502/504, `ROOT_HEALTH_OK` (or the recorded foreign-`/health` NOTE, which is an accepted outcome and not a `STOP:`), and `CATALOG_OK`. No `STOP:` line anywhere — and remember rule 2: `STOP:` is the only blocking prefix, so a `NOTE:` in this phase is something to record, not something to resolve. --- ### Phase F — pairing (node builds the link, qrencode renders it; token never printed) **Everything in Phase F is built from ONE value: `BASE_URL`, as recorded by Phase E.4.** There is no second pairing path per option — the LAN case, the owner's-domain case, the tailnet case and the public-IP case differ only in the string. Nothing here re-derives a tailnet host, and nothing here asks the owner a question. **THE OWNER MUST SEE THE CODE. That is what this phase is FOR** (`wo_20260825ad`). A real install ended with every gate green, a 0600 PNG on a headless VPS that nobody there could open, a NOTE pointing at F.3 — and F.3 hardcoded off: the owner finished setup having never seen a code, and had to argue for one. So Phase F has **two** mandatory halves. **F.1 writes the PNG; F.3 SHOWS the code; neither is optional, on any platform.** On macOS the PNG opens on the owner's screen *and* F.3 draws the code in the terminal; on a Linux/SSH box F.3 is the only place a human can see it at all. **Finishing setup without the owner having seen a scannable code — or, when nothing can render, the typable link — is a FAILED install**, however many pass markers the earlier phases printed. A pairing code is a secret whose whole job is to be shown to exactly one person: show it. > ⚠️ THIS QR/LINK IS A KEY, NOT A PICTURE. Anyone who scans or opens it controls this gateway — every lane, every file it can reach. Never post, screenshot, or share it. If it leaks: delete the token file and re-run pairing to mint a new one. > > **Re-key in one line:** `rm -f ~/.carapace/claude-gateway/token, re-run C.2 to mint a new one, restart the service (E.1a/E.1b), re-run Phase F` **QR FRESHNESS — mandatory, and it is a rule about correctness, not tidiness.** F.1 **always REGENERATES** the QR from the **CURRENT** reach env (`~/.carapace/reach-claude.env`). A QR from a previous run is **stale the moment reachability changes**: it encodes the old `BASE_URL`, so it pairs the phone to an address that no longer answers — and nothing about the picture says so. **Delete `~/.carapace/*pair*.png` for this runtime before writing the new one.** F.1 does exactly that below (`find -delete`, only *after* its own STOP gates pass, so a still-unscanned code is never destroyed by a failed run). Never hand the owner a PNG you did not just render in this run, and never skip F.1 because "there is already a QR on disk". **F.1 Build the link and render the QR — from `BASE_URL`, token never printed.** ```bash umask 077 # the QR carries the token — never world-readable ENVF="$HOME/.carapace/reach-claude.env"; GW_DIR="$HOME/.carapace/claude-gateway"; NODE="$(command -v node || true)" mkdir -p "$HOME/.carapace"; chmod 700 "$HOME/.carapace" if [ ! -s "$ENVF" ]; then echo "STOP: no reachability record — run Phase E.4 first (it decides BASE_URL)" elif [ ! -x "$NODE" ]; then echo "STOP: node not found — re-run A.1" else . "$ENVF" 2>/dev/null || true TOK="$(cat "$GW_DIR/token" 2>/dev/null | tr -d "\n")" if [ -z "$BASE_URL" ]; then echo "STOP: BASE_URL is empty — re-run E.4.2/E.4.3. Nothing was written." elif [ -z "$TOK" ]; then echo "STOP: no usable bearer token (the 0600 token file at $HOME/.carapace/claude-gateway/token) — the QR would carry a dead credential. NOTHING WILL BE RENDERED." elif ! command -v qrencode >/dev/null 2>&1; then echo "STOP: qrencode missing — install it (C.3) and re-run F.1" else # Only NOW delete stale QRs: a STOP above must never destroy a still-unscanned code from a # previous run. `find -delete`, never a glob — an unmatched glob aborts the block under zsh. find "$HOME/.carapace" -maxdepth 1 -name 'carapace-claude-pairing*.png' -delete 2>/dev/null OUT="$HOME/.carapace/carapace-claude-pairing-$(date +%Y%m%d-%H%M%S).png" # machineName: the host part of the base, minus scheme and port — it is only a display label. URL="$(BASE="$BASE_URL" TOK="$TOK" "$NODE" -e ' const base=process.env.BASE, tok=process.env.TOK; // via env: never in argv if(!base||!tok){process.exit(3)} // An IP address must NOT be split on "." — that produced machineName "192" from // http://192.168.4.25:PORT. Names keep their first DNS label; IPs stay whole. const h=base.replace(/^https?:\/\//,"").replace(/:\d+$/,""); let label=/^[0-9.]+$/.test(h) ? h : (h.split(".")[0]||"claude"); const runtimes=[{kind:"claude",name:"Claude",basePath:"/carapace-claude",token:tok}]; const q=new URLSearchParams(); q.set("gatewayBaseURL", base); q.set("runtimeKind","claude"); q.set("runtimeName","Claude"); q.set("machineName", label); q.set("runtimes", JSON.stringify(runtimes)); // URLSearchParams percent-encodes everything the consumer parser needs and encodes a literal // "+" as %2B. Never hand-build this: a bare "+" decodes to a space and the token dies. process.stdout.write("carapace://config?"+q.toString()); ')" case "$URL" in "carapace://config?"*) : ;; *) URL=""; echo "STOP: deep link not built — BASE_URL or the token was unreadable. NO QR WAS RENDERED." ;; esac if [ -n "$URL" ]; then if printf '%s' "$URL" | qrencode -s 8 -o "$OUT" && [ -s "$OUT" ]; then chmod 600 "$OUT" echo "QR written (no secrets shown): $OUT ($(wc -c < "$OUT" | tr -d ' ') bytes, mode 600)" else echo "STOP: qrencode failed to write $OUT — check qrencode (C.3) and free disk, then re-run F.1" fi # Redacted decode. `tokenFingerprint` is the first 8 chars of the SHA-256 OF the token: it # proves the link carries the same secret as the source (recomputed underneath) while leaking # nothing into a transcript. Never print token bytes, not even a prefix. printf '%s' "$URL" | "$NODE" -e 'const c=require("crypto");let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const u=new URL(s.trim().replace("carapace://","https://carapace.invalid/"));const q=u.searchParams;const rt=(JSON.parse(q.get("runtimes")||"[]")[0])||{};console.log(JSON.stringify({gatewayBaseURL:q.get("gatewayBaseURL"),runtimeKind:q.get("runtimeKind"),machineName:q.get("machineName"),kind:rt.kind,basePath:rt.basePath,tokenLength:(rt.token||"").length,tokenFingerprint:c.createHash("sha256").update(rt.token||"").digest("hex").slice(0,8),noRawPlusInPayload:!s.trim().slice(s.trim().indexOf("?")+1).includes("+")},null,1))})' printf 'fingerprint recomputed from the source: %s <- must equal tokenFingerprint above\n' \ "$(TOK="$TOK" "$NODE" -e 'const c=require("crypto");process.stdout.write(c.createHash("sha256").update(process.env.TOK).digest("hex").slice(0,8))')" cat <<'WARN' ⚠️ THIS QR/LINK IS A KEY, NOT A PICTURE. Anyone who scans or opens it controls this gateway — every lane, every file it can reach. Never post, screenshot, or share it. If it leaks: delete the token file and re-run pairing to mint a new one. WARN echo "re-key in one line: rm -f ~/.carapace/claude-gateway/token, re-run C.2 to mint a new one, restart the service (E.1a/E.1b), re-run Phase F" if [ "$(uname -s)" = Darwin ]; then open "$OUT" || echo "NOTE: could not open the PNG viewer — F.3 draws the same code in this terminal" else echo "NOTE: Linux/headless — no 'open' here, so this PNG is a 0600 file nobody on this box can look at." fi echo "NEXT: run F.3 NOW — it SHOWS the code. This install is not finished until the owner has seen one." fi fi fi unset TOK URL ``` Expected decode: `gatewayBaseURL` **equal to the recorded `BASE_URL`**, `runtimeKind` = `claude`, `kind` = `claude`, `basePath` = `/carapace-claude`, a **non-zero** `tokenLength`, `noRawPlusInPayload: true`, and `tokenFingerprint` **equal to** the recomputed line printed under it. `noRawPlusInPayload: false` means a literal `+` reached the payload — it decodes to a space and kills the token; rebuild the link with `URLSearchParams` and never with a hand-rolled encoder. A fingerprint mismatch means the QR encodes a different secret than the gateway reads — rebuild it, do not pair. `basePath` is emitted for every option on purpose, so there is exactly one payload shape: the app **appends** it on an `https://` base and **ignores** it on an `http://host:port` base (E.4's table). Do not "clean it up" for the direct-bind options — a second payload shape is a second bug surface. **Expected link shape** (so a bad scan is debuggable without ever printing the real link): ``` carapace://config?gatewayBaseURL=&runtimeKind=claude&runtimeName=Claude&machineName=