diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b0e0e85..92aa592 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,6 +26,16 @@ jobs:
globs: |
docs/**/*.md
+ pw-mcp-loopback:
+ name: Playwright MCP stays on loopback (#115)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - name: ShellCheck
+ run: shellcheck -x --source-path=SCRIPTDIR poc/pw-mcp/install.sh poc/pw-mcp/check-legacy-mapping.sh scripts/test-pw-mcp-check.sh
+ - name: No tailscale serve for 8794/8443, and the read-only check against a stub CLI
+ run: bash scripts/test-pw-mcp-check.sh
+
swift:
name: Swift build & test
runs-on: macos-15
diff --git a/README.md b/README.md
index fb5d5c9..7f958db 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,6 @@ flowchart LR
subgraph mac["The instance — a Mac with someone logged in"]
subgraph ts["tailscale serve (TLS + identity)"]
s8444[":8444 → 127.0.0.1:8795
injects Tailscale-User-Login"]
- s8443[":8443 → 127.0.0.1:8794"]
end
subgraph gui["Aqua session · LaunchAgents in gui/501"]
mcp["oab-instance-mcp (Swift)
auth = allow-login AND bearer token
sys_info · exec · exec_start/poll/list/cancel
screenshot · ui_snapshot · ui_action · mouse · key · osascript"]
@@ -54,8 +53,7 @@ flowchart LR
sb -- "tool calls over that socket" --> dial
dial --> mcp
s8444 --> mcp
- cli -- "HTTPS · MCP" --> s8443
- s8443 --> pw
+ mcp -- "--upstream, loopback only" --> pw
mcp --> hands
tcc -. "gates screen / input / apps" .-> hands
```
@@ -263,13 +261,22 @@ read-only.
|---|---|---|---|---|---|
| `127.0.0.1:8795` (`--host`, `--port`; `--path` for the `/mcp` route) | TCP, plain HTTP/1.1 | Processes on this Mac only | `/mcp` (POST, DELETE), `/attach` (reverse attach; off with `--no-attach`), `/healthz`. Anything else is 404 | Always, on IPv4 loopback by default. No-flag launch from Finder, the installer's LaunchAgent, or a manual launch. A no-flag launch also briefly binds the port to test it is free, without listening | `/mcp` and `/attach`: [Auth](#auth). `/healthz`: none |
| `https://..ts.net:8444` → `127.0.0.1:8795` | HTTPS, terminated by Tailscale | Your tailnet only | The way other machines reach the agent | `scripts/install-prebuilt.sh` (and the `.pkg`) run `tailscale serve --bg --https=8444`. The app runs the same command only after you turn tailnet access on, from the wizard's tailnet page or the menu's "Allow tailnet access…", and removes it with "Turn Off Tailnet Access". A launch with no stored opt-in does not create it. A launch with a stored opt-in re-applies it when it is missing. A mapping that already exists is shown as "On (existing mapping, not managed by this app)" and left alone until you choose "Manage with this app" | Every configured check must pass. The installer configures both: a Tailscale login on the allow list **and** the bearer token |
-| `127.0.0.1:8794` | TCP, HTTP | Processes on this Mac. Your tailnet too, if the `:8443` mapping below exists | Playwright MCP, a **separate process** the app only connects to (`--upstream`) | Its own LaunchAgent (`poc/pw-mcp/install.sh`). The app never starts it | None in the app |
-| `https://..ts.net:8443` → `127.0.0.1:8794` | HTTPS, terminated by Tailscale | Your whole tailnet | Direct access to Playwright MCP (a proof-of-concept path) | Only `poc/pw-mcp/install.sh` runs `tailscale serve --bg --https=8443`. Not the app, not `install-prebuilt.sh` | **None.** No bearer token, login check or tool profile. It bypasses the agent's auth |
+| `127.0.0.1:8794` | TCP, HTTP | Processes on this Mac only. Other machines reach the browser tools only through the app's `--upstream` | Playwright MCP, a **separate process** the app only connects to (`--upstream`). It has no `:8443` or other tailnet access path | Its own LaunchAgent (`poc/pw-mcp/install.sh`), which binds `--host 127.0.0.1` and creates no `tailscale serve` mapping. The app never starts it | None in Playwright. The app's [Auth](#auth) applies to callers before it relays browser tools |
The macOS app opens one listening socket. It uses no UDP and no Unix domain sockets. The `:8444`
mapping is made by the installer, by the app after you turn tailnet access on, or by the app's launch
-re-apply of that choice. The `:8443` mapping belongs to the
-Playwright installer, not to the app.
+re-apply of that choice. Nothing in this repo maps `:8443` any more. Older versions of
+`poc/pw-mcp/install.sh` did (`tailscale serve --bg --https=8443 http://127.0.0.1:8794`), which put
+the unauthenticated Playwright MCP on your whole tailnet. Re-running the new installer does not
+remove it. Check for it and remove it on any Mac that ran the old installer:
+
+```sh
+tailscale serve status # look for :8443 pointing at http://127.0.0.1:8794
+tailscale serve --https=8443 off # remove only that mapping
+```
+
+Browser tools keep working afterwards, because the app reaches Playwright over loopback. The new
+installer runs the read-only `serve status` at the end and prints a warning if it finds the mapping.
### Outbound connections
@@ -311,9 +318,8 @@ the menu) closes the tailnet path and leaves the loopback listener.
A caller must pass every configured check. The installer always sets both, so a tailnet caller needs
an allow-listed Tailscale login **and** the bearer token. A no-flag launch falls back to the token
-alone if it cannot find your login, and so does a manual launch without `--allow-login`. The `:8443`
-Playwright mapping has no auth: remove it with `tailscale serve --https=8443 off`. Browser tools
-still work, because the agent reaches Playwright over loopback. The app does not
+alone if it cannot find your login, and so does a manual launch without `--allow-login`. Playwright MCP
+has no auth of its own, so it stays on loopback and is reached only through the app. The app does not
guard `--host`: a non-loopback value binds in plain HTTP. The menu's Local MCP row then says
"reachable from other devices", but the app does not block it. The app
trusts a `Tailscale-User-Login` header from any peer, so on a non-loopback bind only the bearer
@@ -341,8 +347,7 @@ The Tailnet row comes only from Tailscale state (the read-only `serve status` re
choice), never from `--public-url`. A `--public-url` that is not the local URL is shown only on the top URL line. When the app has no
Tailscale state of its own (an installer owns another port), the Tailnet row reads "unknown (not
checked by this app)", whatever the public URL is. The Browser tools row says "this Mac only" about
-the app's own `--upstream` address; a separate `:8443` Playwright mapping, if you made one, is
-described in the table above.
+the app's own `--upstream` address. Playwright has no tailnet mapping; the note under the table above covers older installs.
Clicking the Local MCP row or the Tailnet URL copies it. Tailnet can also read "Off", "On (existing
mapping, not managed by this app), " or "opted in, not applied (see the log)". Browser tools
@@ -492,7 +497,7 @@ oab-instance-mcp --token-file ~/.config/oab-computer/token \
### Browser tools for lent sessions (`--upstream`)
The sandbox has no path to any browser, so browser control is served **from this Mac**: with
-`--upstream browser=http://127.0.0.1:8794/mcp` (deploy.sh adds it when the Playwright MCP
+`--upstream browser=http://127.0.0.1:8794/mcp` (the installer adds it when the Playwright MCP
LaunchAgent from [`poc/pw-mcp`](poc/pw-mcp/README.md) is installed) the daemon re-serves
Playwright's tools under its own `tools/list`, filtered by the connection's profile:
diff --git a/poc/pw-mcp/README.md b/poc/pw-mcp/README.md
index 7dcc714..6321af0 100644
--- a/poc/pw-mcp/README.md
+++ b/poc/pw-mcp/README.md
@@ -1,9 +1,15 @@
# Step 1 PoC — Playwright MCP on macmini, driven from the tailnet
> Done 2026-09-22. Design context: openabdev/openab#1544.
-> Proves the transport half of the relay-agent design before any Swift is written:
-> a headed browser living in macmini's Aqua session, exposed as a remote MCP server
-> through `tailscale serve`, consumed by kiro-cli on the laptop.
+> Proves the browser half of the relay-agent design before any Swift is written:
+> a headed browser living in macmini's Aqua session, served as an MCP server on loopback.
+>
+> **Playwright MCP is loopback only (`127.0.0.1:8794`) and has no authentication.** It is not
+> mapped onto the tailnet and has no `:8443` access path (#115). Browser tools are reached only
+> through `oab-instance-mcp` (`--upstream browser=http://127.0.0.1:8794/mcp`), which adds the
+> Tailscale login check, the bearer token and the owner-only tool profile. The first version of
+> this PoC mapped `:8443` with `tailscale serve`; see
+> [Older installs](#older-installs-may-still-have-a-8443-mapping).
## What is deployed on macmini
@@ -15,14 +21,16 @@
| Persistent browser profile | `~/.local/oab-instance-mcp/pw-profile/` |
| Screenshot / snapshot output | `~/.local/oab-instance-mcp/pw-output/` |
| Logs | `~/Library/Logs/oab-instance-mcp/pw-mcp.{log,err}` |
-| Listener | `127.0.0.1:8794` (loopback only) |
-| Tailnet URL | `https://..ts.net:8443/mcp` via `tailscale serve --bg --https=8443 http://127.0.0.1:8794` |
+| Listener | `127.0.0.1:8794` (loopback only; the wrapper passes `--host 127.0.0.1`) |
+| Tailnet URL | None. `install.sh` creates no `tailscale serve` mapping |
Browser: Playwright's bundled Chromium (`~/Library/Caches/ms-playwright/chromium-1246`),
**headed**, spawned as a child of the LaunchAgent pid, so it is a real window in the
logged-in desktop session. No Google Chrome is installed on macmini.
-Client side (laptop): `kiro-cli mcp add --name macmini-browser --url https://..ts.net:8443/mcp --scope global --timeout 20000`.
+Client side: do not point a client at Playwright directly. Connect to `oab-instance-mcp`, which
+re-serves the `browser_*` tools under its `owner` profile (top-level README, "Browser tools for
+lent sessions").
## Install / operate
@@ -34,17 +42,27 @@ ssh macmini 'cd ~/src/oab-instance-mcp && bash poc/pw-mcp/install.sh' # idempo
ssh macmini launchctl print gui/501/dev.openab.instance-mcp.pw-mcp | grep -E 'state|pid'
ssh macmini launchctl kickstart -k gui/501/dev.openab.instance-mcp.pw-mcp # restart
ssh macmini tail -20 ~/Library/Logs/oab-instance-mcp/pw-mcp.err
-ssh macmini /Applications/Tailscale.app/Contents/MacOS/Tailscale serve status
+ssh macmini lsof -nP -iTCP:8794 -sTCP:LISTEN # one listener, on 127.0.0.1
```
-Smoke test from the laptop (no LLM in the loop):
+At the end, `install.sh` runs a read-only check (`tailscale serve status`) and prints a warning
+if a `:8443` mapping to `127.0.0.1:8794` exists. It never changes a mapping.
+
+### Older installs may still have a :8443 mapping
+
+Earlier versions of `install.sh` ran `tailscale serve --bg --https=8443 http://127.0.0.1:8794`.
+Re-running the new installer does not remove it. That mapping lets any device on the tailnet call
+every browser tool without a login check or token, including `browser_run_code_unsafe`. Check and
+remove it on each Mac that ran the old installer:
```sh
-kiro-cli chat --no-interactive --trust-tools=@macmini-browser --agent kiro_default \
- "Use macmini-browser only: navigate to https://example.com, browser_snapshot, report the title."
+tailscale serve status # look for :8443 pointing at http://127.0.0.1:8794
+tailscale serve --https=8443 off # remove only that mapping
```
-## Measurements (laptop → macmini over tailnet, direct connection)
+Browser tools keep working through `oab-instance-mcp`, which reaches Playwright over loopback.
+
+## Measurements (first PoC, laptop → macmini through the since-removed :8443 mapping)
| Call | Latency |
|---|---|
@@ -61,9 +79,8 @@ Latency is not the bottleneck; the LLM round trip is. Good enough for the thin-e
## Gotchas (each cost a restart)
1. **`--allowed-hosts` is an exact string match on the `Host` header, port included.**
- `..ts.net` does *not* cover `..ts.net:8443`, and
- `127.0.0.1` does not cover `127.0.0.1:8794`. List every form the proxy will forward.
- (`tailscale serve` forwards the original Host, with port.)
+ `127.0.0.1` does not cover `127.0.0.1:8794`. List every form a client will send.
+ (oab-instance-mcp sends the bare `127.0.0.1`, which the wrapper allows.)
2. **Default browser channel is `chrome`, not bundled Chromium.** Without
`--browser chromium` the first tool call fails with
`Chromium distribution 'chrome' is not found at /Applications/Google Chrome.app`. The
@@ -73,9 +90,8 @@ Latency is not the bottleneck; the LLM round trip is. Good enough for the thin-e
Each MCP client session (every kiro-cli run is a new one) tries to launch its own browser
on the shared `--user-data-dir`. Fix: `--shared-browser-context`. Alternative `--isolated`
loses login state between sessions, which defeats the point of a resident browser.
-4. **Dedicated HTTPS port rather than a path on :443.** The existing `:443` mappings are
- path-prefixed (`/wdyt`, `/lossic`, …); the MCP server expects `/mcp` at root and the SSE
- endpoint at `/sse`, so `--https=8443` avoids any path rewriting question.
+4. **History: the first PoC used a dedicated HTTPS port** (`--https=8443`) because the MCP
+ server expects `/mcp` at root. That mapping was removed from the design in #115.
5. **RAID/TCC reminder held.** Everything lives on the internal disk (`~/.local/...`), per the
home.md rule that LaunchAgents cannot read the external RAID. The `.zshenv: .cargo/env:
operation not permitted` line in `pw-mcp.err` is that same restriction firing on the
@@ -83,27 +99,25 @@ Latency is not the bottleneck; the LLM round trip is. Good enough for the thin-e
6. `launchctl print` shows `state = running` even when the wrapper is about to die; check
the `.err` log, and check `lsof -nP -iTCP:8794 -sTCP:LISTEN`.
-## Security posture of this PoC
+## Security posture
-- Reachable only inside the tailnet (`tailscale serve`, not `funnel`). Anyone on the tailnet
- can drive the browser; there is no token check yet. Add a Tailscale ACL restricting
- `macmini:8443` to the laptop before leaving this running unattended, or front it with the
- Swift daemon that checks `Tailscale-User-Login`.
-- `browser_run_code_unsafe` is in the tool list — arbitrary Playwright JS on the box. Fine
- for "my CLI on my Mac". A lent session reaches the browser only through the daemon's
- `--upstream`, and only under the `owner` profile: `observe` gets no browser tool (#59).
+- Playwright MCP has no authentication. It listens on `127.0.0.1:8794` only, so only processes
+ on this Mac reach it. There is no `tailscale serve` mapping for it and no `:8443` access path.
+- Browser tools reach other machines only through `oab-instance-mcp`'s `--upstream`, behind its
+ login check and bearer token, and only under the `owner` profile: `observe` gets no browser
+ tool (#59). A switchboard's per-caller allowlist applies on top.
+- `browser_run_code_unsafe` is in the tool list: arbitrary Playwright JS in the logged-in
+ browser profile. That is why Playwright must not be reachable without the daemon's auth.
## What this proves for the design
-- The Aqua-session LaunchAgent + loopback listener + `tailscale serve` shape works and
- needs no custom transport. The Swift `oab-instance-mcp` (exec / screenshot / mouse / key /
- osascript) will sit behind the same pattern on the next port.
+- The Aqua-session LaunchAgent + loopback listener shape works and needs no custom transport.
+ The Swift `oab-instance-mcp` re-serves the browser tools to remote callers.
- Playwright MCP covers the browser half completely; the Swift daemon does not need to
touch CDP. `connectOverCDP` from the sandbox was never needed.
## Next
-Step 2 shipped 2026-09-22: the Swift `oab-instance-mcp` daemon (see top-level README) runs beside
-this on `127.0.0.1:8795` → `tailscale serve --https=8444`, same LaunchAgent pattern, with
-`exec` / `screenshot` / `sys_info` and Tailscale-identity auth. Remaining for the browser
-half: a Tailscale ACL on :8443. Lent callers get the browser only under `owner` (#59).
+The Swift `oab-instance-mcp` daemon (see top-level README) runs beside this on `127.0.0.1:8795`,
+same LaunchAgent pattern, with Tailscale-identity auth, and re-serves these tools through
+`--upstream`. Lent callers get the browser only under `owner` (#59).
diff --git a/poc/pw-mcp/check-legacy-mapping.sh b/poc/pw-mcp/check-legacy-mapping.sh
new file mode 100755
index 0000000..1b91a5e
--- /dev/null
+++ b/poc/pw-mcp/check-legacy-mapping.sh
@@ -0,0 +1,34 @@
+#!/bin/bash
+# Read-only check, sourced by install.sh and by scripts/test-pw-mcp-check.sh (#115).
+# Older installs ran `tailscale serve --https=8443` to 127.0.0.1:8794, which hands the
+# unauthenticated Playwright MCP to the whole tailnet. This function only reads
+# `tailscale serve status`. It never creates, changes or removes a mapping, and it never
+# fails the caller. It prints no login, tailnet name or token.
+# Expects $TS (the Tailscale.app binary); falls back to `tailscale` on PATH.
+warn_legacy_8443_mapping() {
+ local cli="" out
+ if [ -n "${TS:-}" ] && [ -x "$TS" ]; then cli=$TS; else cli=$(command -v tailscale 2>/dev/null || true); fi
+ if [ -z "$cli" ]; then
+ echo "Tailscale :8443 check: could not check (Tailscale CLI not found)"; return 0
+ fi
+ # TERM_PROGRAM makes the Tailscale.app binary act as a CLI.
+ if ! out=$(TERM_PROGRAM=oab-pw-mcp-installer "$cli" serve status 2>/dev/null) || [ -z "$out" ]; then
+ echo "Tailscale :8443 check: could not check (serve status unreadable)"; return 0
+ fi
+ # A mapping is a non-indented "https://...:8443" header followed by a handler line to 8794.
+ if printf '%s\n' "$out" | awk '
+ /^[^ |]/ { cur = ($0 ~ /:8443([ \t]|$)/) }
+ cur && /(127\.0\.0\.1|localhost):8794/ { found = 1 }
+ END { exit !found }'; then
+ cat <<'MSG'
+WARNING: this Mac still maps tailnet port :8443 to 127.0.0.1:8794 (Playwright MCP).
+ Playwright MCP has no authentication, so any device on your tailnet can drive the browser,
+ including browser_run_code_unsafe. Browser tools do not need it: oab-instance-mcp reaches
+ Playwright over loopback. This installer does not remove it. To remove it, run:
+ tailscale serve --https=8443 off
+MSG
+ else
+ echo "Tailscale :8443 check: no mapping to 127.0.0.1:8794 found"
+ fi
+ return 0
+}
diff --git a/poc/pw-mcp/install.sh b/poc/pw-mcp/install.sh
index ce3e7ee..b0cd935 100755
--- a/poc/pw-mcp/install.sh
+++ b/poc/pw-mcp/install.sh
@@ -1,13 +1,16 @@
#!/bin/bash
# Install the Playwright MCP LaunchAgent beside oab-instance-mcp (run ON the target Mac).
# Renders the plist with this user's $HOME (launchd does not expand ~), installs the
-# wrapper, pins @playwright/mcp, and exposes it via `tailscale serve --https=8443`.
+# wrapper and pins @playwright/mcp. Playwright MCP listens on 127.0.0.1:8794 only and is
+# NOT exposed to the tailnet; browser tools are reached through oab-instance-mcp (#115).
set -euo pipefail
LABEL=dev.openab.instance-mcp.pw-mcp
BASE="$HOME/.local/oab-instance-mcp"
LOGS="$HOME/Library/Logs/oab-instance-mcp"
HERE="$(cd "$(dirname "$0")" && pwd)"
-TS=/Applications/Tailscale.app/Contents/MacOS/Tailscale
+TS=/Applications/Tailscale.app/Contents/MacOS/Tailscale # read by warn_legacy_8443_mapping
+# shellcheck source=check-legacy-mapping.sh disable=SC2034
+. "$HERE/check-legacy-mapping.sh"
mkdir -p "$BASE/pw-mcp" "$LOGS"
cp "$HERE/pw-mcp.sh" "$BASE/pw-mcp.sh"; chmod +x "$BASE/pw-mcp.sh"
@@ -33,6 +36,6 @@ UID_=$(id -u)
launchctl bootout "gui/$UID_/$LABEL" 2>/dev/null || true
for _ in $(seq 1 40); do launchctl print "gui/$UID_/$LABEL" >/dev/null 2>&1 || break; sleep 0.25; done
launchctl bootstrap "gui/$UID_" "$PLIST"
-"$TS" serve --bg --https=8443 http://127.0.0.1:8794 >/dev/null
launchctl print "gui/$UID_/$LABEL" | grep -E 'state|pid ='
-echo "MCP URL: https://$("$TS" status --self --peers=false --json | python3 -c 'import json,sys;print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))'):8443/mcp"
+warn_legacy_8443_mapping
+echo "Browser tools: reach them through oab-instance-mcp with --upstream browser=http://127.0.0.1:8794/mcp (the main installer adds it when this agent is loaded)."
diff --git a/poc/pw-mcp/pw-mcp.sh b/poc/pw-mcp/pw-mcp.sh
index 5142993..756f01c 100755
--- a/poc/pw-mcp/pw-mcp.sh
+++ b/poc/pw-mcp/pw-mcp.sh
@@ -1,12 +1,12 @@
#!/bin/zsh
-# Playwright MCP for oab-instance-mcp step-1 PoC. Loopback only; exposed via tailscale serve.
+# Playwright MCP for oab-instance-mcp step-1 PoC. Loopback only (127.0.0.1:8794); never exposed with tailscale serve (#115).
export PATH=/opt/homebrew/bin:/usr/bin:/bin
BASE="$HOME/.local/oab-instance-mcp"
DNSNAME="${TS_DNSNAME:-$(/Applications/Tailscale.app/Contents/MacOS/Tailscale status --self --peers=false --json | python3 -c 'import json,sys;print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))')}"
cd "$BASE/pw-mcp"
exec ./node_modules/.bin/playwright-mcp \
--host 127.0.0.1 --port 8794 \
- --allowed-hosts "$DNSNAME,$DNSNAME:8443,127.0.0.1,localhost" \
+ --allowed-hosts "$DNSNAME,127.0.0.1,localhost" \
--user-data-dir "$BASE/pw-profile" \
--output-dir "$BASE/pw-output" \
--idle-timeout 1800000 \
diff --git a/scripts/test-pw-mcp-check.sh b/scripts/test-pw-mcp-check.sh
new file mode 100755
index 0000000..841d4e1
--- /dev/null
+++ b/scripts/test-pw-mcp-check.sh
@@ -0,0 +1,59 @@
+#!/bin/bash
+# #115: Playwright MCP must stay on loopback. (1) Guard: no `tailscale serve` / `$TS serve` call
+# that targets 8794 or 8443 under poc/pw-mcp/. (2) The read-only :8443 check, run against a stub
+# CLI (no real tailscale is called). Usage: scripts/test-pw-mcp-check.sh [dir-to-guard]
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+DIR="${1:-$ROOT/poc/pw-mcp}"
+
+# Any `tailscale|$TS|$cli serve|funnel ...` call under the dir fails, whatever port or variable it
+# names, except the read-only `serve status`. The removal command `serve --https=8443 off` is
+# allowed only as text inside the `cat <<'MSG' ... MSG` warning heredoc, never as a call.
+# Backslash continuations are joined first, the heredoc body and `serve status` are cut out
+# (so they cannot hide a second call on the same line), and comment lines are skipped.
+# Scanned: every file except *.md (the README may describe the old mapping).
+hits=""
+while IFS= read -r f; do
+ out=$(awk 'skip { if ($0 == "MSG") skip = 0; next }
+ /^[[:space:]]*cat <<.?MSG.?[[:space:]]*$/ { skip = 1; next }
+ /\\$/ { sub(/\\$/, ""); buf = buf $0; next } { print FNR ": " buf $0; buf = "" }' "$f" \
+ | grep -vE '^[0-9]+:[[:space:]]*#' \
+ | sed -e 's/serve status//g' \
+ | grep -E '(tailscale|Tailscale|\$[{]?[A-Za-z_]+[}]?"?)[[:space:]]+"?(serve|funnel)([[:space:]"]|$)' || true)
+ [ -n "$out" ] && hits="$hits$f: $out"$'\n'
+done < <(find "$DIR" -type f ! -name '*.md')
+if [ -n "$hits" ]; then
+ echo "FAIL: tailscale serve call under $DIR (#115):" >&2; printf '%s' "$hits" >&2; exit 1
+fi
+echo "guard ok: no tailscale serve call under $DIR"
+[ -n "${1:-}" ] && exit 0
+
+T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
+mkdir "$T/bin"
+stub() { # $1 = mode
+ cat >"$T/bin/tailscale" <&2; exit 8; }
+case "$1" in
+ present) printf 'https://host.example.ts.net:8443 (tailnet only)\n|-- / proxy http://127.0.0.1:8794\n\nhttps://host.example.ts.net:8444 (tailnet only)\n|-- / proxy http://127.0.0.1:8795\n' ;;
+ absent) printf 'https://host.example.ts.net:8444 (tailnet only)\n|-- / proxy http://127.0.0.1:8795\n\nhttps://host.example.ts.net (tailnet only)\n|-- /x proxy http://127.0.0.1:8794\n' ;;
+ unreadable) exit 1 ;;
+esac
+STUB
+ chmod +x "$T/bin/tailscale"
+}
+# shellcheck source=../poc/pw-mcp/check-legacy-mapping.sh disable=SC2034
+. "$ROOT/poc/pw-mcp/check-legacy-mapping.sh"
+run() { # $1 = mode, $2 = expected substring
+ local out; TS=/nonexistent; rm -f "$T/bin/tailscale"; [ "$1" = missing ] || stub "$1"
+ out=$(PATH="$T/bin:$([ "$1" = missing ] && echo /usr/bin:/bin || echo "$PATH")" warn_legacy_8443_mapping)
+ echo "--- $1"; echo "$out"
+ case "$out" in *"$2"*) ;; *) echo "FAIL: $1: expected '$2'" >&2; exit 1 ;; esac
+ case "$out" in *host.example*) echo "FAIL: $1: printed a host name" >&2; exit 1 ;; esac
+}
+run present "tailscale serve --https=8443 off"
+run absent "no mapping"
+run unreadable "could not check"
+run missing "could not check"
+echo "stub tests ok"