---
type: Reference
title: "Workspace remote access over a private tailnet (Tailscale)"
timestamp: 2026-07-26
---

# Reaching the workspace from your phone — over a private tailnet, never the public internet

The workspace server owns **real terminal sessions (PTYs)** running your `claude` on **your own
subscription**. By default it binds `127.0.0.1` only, so it is reachable solely from the machine it
runs on. This runbook enables reaching **the same local server** from another of your own devices —
a phone, a laptop — over a **private Tailscale tailnet**. It never exposes a public endpoint.

## Why a tailnet, and why no public endpoint (D62)

A [Tailscale](https://tailscale.com) tailnet is a private [WireGuard](https://www.wireguard.com/)
mesh between **your own devices**. Every device is authenticated and encrypted end-to-end; addresses
live in the CGNAT range `100.64.0.0/10` and are routable **only** between devices on your tailnet —
never from the public internet.

PMOS's locked decision **D62** rejects, permanently:

- **any public endpoint** for the workspace server, and
- **any hosted PTY bridge** (proxying the terminal through a hosted, browser-reachable surface).

A public server that owns a PTY running your Claude subscription is severe exposure. The tailnet path
sidesteps that entirely: the phone attaches to the *same local server* across your own private
network. Accordingly, this feature will **only ever** bind loopback plus your machine's own tailnet
address. If you ask it to bind anything else (`0.0.0.0`, a LAN or public IP), it **refuses to start**.

## Setup

1. **Install Tailscale** on the machine running the workspace and on your phone:
   - Machine: https://tailscale.com/download — then `tailscale up`.
   - Phone: install the Tailscale app from your app store and sign in to the **same** tailnet.
   - Confirm the machine's tailnet IPv4: `tailscale ip -4` (a `100.x.y.z` address).

2. **Start the server in remote mode**, from `workspace/`:

   ```sh
   PMOS_REMOTE=tailnet npm start
   ```

   On startup it prints (once):

   ```
   pmos workspace server — REMOTE (tailnet) mode
     local:   http://127.0.0.1:7433
     tailnet: http://100.x.y.z:7433   (private WireGuard net, device-authenticated, never public — D62)
     token (generated — shown ONCE, copy it now): <a long token>
     attach with header  Authorization: Bearer <token>   or query  ?token=<token>
     loopback requests are token-free; every tailnet request must present the token.
   ```

   **Copy the token now** — it is shown only once and never logged again.

3. **Open it from your phone.** In the phone's browser (on the same tailnet), open:

   ```
   http://100.x.y.z:7433/?token=<token>
   ```

   The client attaches to the **same live sessions** the desktop sees; the token authorizes both the
   page and its WebSocket. Requests from the machine itself (`127.0.0.1`) never need the token.

## Configuration

| Variable | Effect |
|---|---|
| `PMOS_REMOTE=tailnet` | Opt in. Bind loopback **+** the machine's tailnet IPv4, and require a token beyond loopback. |
| `PMOS_BIND_TAILNET=1` | Equivalent to `PMOS_REMOTE=tailnet`. |
| `PMOS_TOKEN=<t>` | Use your own token instead of a generated one. **Never printed** — supply it yourself on both ends. |
| `PMOS_TAILNET_IP=<100.x.y.z>` | Pin the tailnet address (skips auto-detection). Must be within `100.64.0.0/10`. |
| `PMOS_BIND_ADDR=<addr>` | Force a specific bind. Accepted **only** if loopback or within `100.64.0.0/10`; anything else refuses to start. |
| *(none of the above)* | **Default:** loopback only, no token — unchanged local behavior. |

## Security model (what is guaranteed)

- **Never public.** The only addresses the server will ever bind are loopback and a
  `100.64.0.0/10` tailnet address. `0.0.0.0`, `::`, LAN, and public IPs are refused — the server
  exits with a clear error rather than binding them. There is no code path to a public bind.
- **Token beyond loopback.** With remote mode on, every non-loopback HTTP request and WebSocket
  upgrade must present the bearer token, or it is rejected (`401` / socket closed). Loopback requests
  stay token-free for local development. The comparison is constant-time.
- **Origin/Host allowlist (D64).** Both the HTTP guard and the WebSocket-upgrade handshake reject any
  request whose `Origin` is foreign (not same-origin with the `Host` it arrived on) or whose `Host`
  hostname is not loopback/localhost or your tailnet address. This closes the cross-origin-localhost /
  DNS-rebinding vector: without it, **any website open in your browser** could open a WebSocket to
  `ws://127.0.0.1:<port>` and spawn a shell + type into it, because loopback peers are token-exempt.
  A same-origin page served by the workspace passes; a cross-site page is rejected `403`. Native
  clients / `curl` (which send no `Origin`) still pass this check and are then gated by the token.
- **No header/token logging (D08).** The request-guard path logs nothing about the request. The
  token is printed at most once at startup (only when generated); a token you supply is never echoed.
- **Rate-limited control channel (D64).** The WS messages that spawn shells (`new`/`respawn`) and
  feed a PTY (`input`) are per-connection rate-limited, bounding abuse if the socket is ever reached.
- **Independent layers.** The tailnet (WireGuard device auth), the bearer token, and the origin/host
  allowlist all gate access — a device must be *on your tailnet*, *hold the token*, **and** present a
  same-origin request from an allowed host.

## Exposing to the internet — the required hardening (D64)

The workspace is **tailnet-first**. The tailnet path above is the supported way to reach it from
another of your own devices and needs no internet exposure. If you nonetheless expose an instance to
the internet, D64 permits it **only after all three of the following are in place — never before, and
never as a raw public bind**:

1. **The bearer token for every non-loopback peer** (already enforced in remote mode).
2. **The Origin/Host allowlist** (already enforced — see above).
3. **TLS termination at the exposure boundary.** **The workspace server does not terminate TLS.** It
   speaks plain HTTP/WS and must sit behind something that terminates TLS for it — Tailscale's own
   HTTPS (`tailscale serve` / `tailscale funnel`) or a reverse proxy (Caddy, nginx, a tunnel) that
   holds the certificate and forwards to the loopback/tailnet listener. Do **not** put the raw HTTP
   port on a public interface — the server refuses non-loopback/non-tailnet binds precisely so this
   stays a conscious proxy decision, not an accident.

When a reverse proxy forwards to the workspace, keep the forwarded `Host` set to the loopback/tailnet
host the server actually binds, so the Origin/Host allowlist still recognises same-origin requests.

## Troubleshooting

- **"refusing to bind … only loopback or a Tailscale tailnet address (100.64.0.0/10) is permitted"**
  — you passed `PMOS_BIND_ADDR` (or `PMOS_TAILNET_IP`) that isn't loopback or tailnet. Use your real
  `tailscale ip -4` value.
- **"no Tailscale tailnet address … was found"** — Tailscale isn't up on this machine. Run
  `tailscale up`, or set `PMOS_TAILNET_IP`.
- **"cannot bind 100.x.y.z:PORT — that address is not present on this machine"** — the pinned
  `PMOS_TAILNET_IP` isn't actually assigned to this machine. Check `tailscale ip -4`.
- **Phone gets 401** — the token is missing or wrong. Re-open with `?token=<token>` (or send
  `Authorization: Bearer <token>`). Restarting the server without `PMOS_TOKEN` mints a new token.
- **Phone can't connect at all** — confirm both devices show each other in the Tailscale app, and
  that you used the `100.x.y.z` address, not a LAN IP.
