# Phone access for CTM, coding agents, and Wall-E — design + plan

Status: **Draft for implementation review (2026-05-05)** · Author: Claude
Branch: `phone` · Doc path: `claude-task-manager/docs/phone-access-design.md`

This document is the implementation brief for letting the owner drive CTM, all
coding agents (Claude Code / Codex / Gemini), and Wall-E from an iPhone (and a
browser on any other device) — securely, without exposing the Mac to the
internet, and without rewriting the existing UI.

It is meant to be hand-off-grade: another engineer should be able to build
from it without re-doing the research.

2026-05-05 review upgrade: this version closes the design-review gaps around
default-deny authorization, one-time device claims, secure-context rollout,
notification event sources, Cloudflare Access validation, and phone-side UX.

2026-05-09 Walle Relay update: the recommended long-term product path was
initially framed as the hosted Walle Relay remote-control model in
`claude-task-manager/docs/walle-relay-phone-access-design.md`. That remains
useful for typed mobile relay workflows, but it is no longer the primary direct
browser-access architecture.

2026-05-10 Microsoft Dev Tunnel update: Microsoft Dev Tunnel is a quick
browser fallback that avoids VPN, DNS, and Cloudflare setup, but it is still a
tunnel transport rather than the Walle Relay product path. See
`claude-task-manager/docs/microsoft-dev-tunnel-phone-access-design.md`.

2026-05-30 correction (supersedes a brief 2026-05-19 "private by default"
experiment): Microsoft Dev Tunnel defaults to **app-gated** (CTM-authenticated)
access — the tunnel is reachable by URL and CTM's own device token + passkey +
step-up are the gate, behind an unguessable tunnel URL. Forcing the tunnel
"private" (interactive Microsoft/GitHub edge sign-in) bricks the phone, because
the PWA's `fetch()` + `WebSocket` cannot follow the cross-origin login redirect —
the symptom is "can't connect / times out". Private is now an explicit opt-in
only (UI selection, `CTM_MS_TUNNEL_ACCESS_MODE=private`, or
`options.allowPrivateAccess`); a private value coming only from persisted state is
migrated back to app-gated on the next start so the phone keeps working. For a
real edge sign-in that is PWA/WebSocket-compatible, use Cloudflare Access or
Tailscale instead. See
`claude-task-manager/docs/microsoft-dev-tunnel-phone-access-design.md`.

2026-05-27 unified remote-access update: direct browser access for both phone
and desktop uses the same CTM client-device architecture. Microsoft Dev Tunnel
private access is the default direct transport, Tailscale is the private-network
alternative, and Cloudflare Tunnel is out of scope. See
`claude-task-manager/docs/remote-desktop-access-design.md` and
`claude-task-manager/docs/microsoft-dev-tunnel-phone-access-design.md`.

---

## 1. Goals, non-goals, success criteria

### 1.1 Goals (must-have)

1. From an iPhone, the user can:
   - **G1. Review status** of every CTM tab and every agent (Claude Code, Codex, Gemini, Wall-E loops + tasks).
   - **G2. Manage agents**: create new, resume existing, search across, close/kill.
   - **G3. Respond to agents**: send messages, approve/deny permission prompts, answer questions, abort.
   - **G4. Talk to Wall-E**: chat, ask memory questions, run skills.
   - **G5. Get notified** when an agent is waiting on a decision or finishes a long task.
2. The Mac never opens an inbound port to the public internet.
3. Per-device credential issuance + revocation (lose phone → revoke that device only).
4. State-changing operations require a fresh biometric confirmation (Face ID
   on iPhone). Read-only operations do not.
5. The authorization contract is explicit: every HTTP route and every WS
   message type is classified before phone access ships. Unknown remote
   mutations fail closed.
6. The phone UX is optimized for triage and steering: "what needs me?",
   "what changed?", "what should I send?", then biometric confirmation before
   the command leaves the phone.

### 1.2 Non-goals

- Multi-user / team access. Single-user, owner-only.
- Editing source files from the phone (Claude Code already does this; phone is
  for **review + steering**, not text editing).
- Replacing the desktop UI. The desktop dashboard remains the primary
  workbench.
- Replicating the full xterm.js terminal pixel-perfectly. Mobile gets a
  message-stream view; raw terminal can be a stretch goal.

### 1.3 Success criteria

- Time-to-respond from notification → agent receives reply ≤ 10 seconds.
- Phone can ride through Wi-Fi → cellular handoff without re-auth.
- All inbound connectivity is gated by **two** independent factors (network
  identity + app token), one of which can be revoked without disrupting the
  other.
- No regressions to localhost-only desktop usage.
- Pen-test checklist (§12.2) passes.

---

## 2. What already exists (don't reinvent)

Before designing anything new, here's the relevant surface CTM/Wall-E already
have. Phone access is mostly a transport + identity + UI layer over this.

### 2.1 HTTP API gateway (`claude-task-manager/server.js`)

- Single `http.createServer` at `lib/server-config.js` — `HOST=127.0.0.1`,
  `PORT=3456`, both env-overridable (`CTM_HOST`, `CTM_PORT`).
- ~101 routes under `/api/*` (sessions, prompts, reviews, queue, presets,
  worktrees, hooks, otel, services).
- Existing auth gate at `server.js:1276`:
  ```js
  if (url.pathname.startsWith('/api/')) {
    if (!isLocalhost(req)) {
      const authToken = getAuthToken(req, url);
      if (authToken !== config.token) return 401;
    }
    ...
  }
  ```
- `getAuthToken` accepts `?token=`, `Authorization: Bearer …`, or cookie
  `ctm_token` (`server.js:1181`).
- Existing **query → cookie redirect** at `server.js:1196` — paste a
  `?token=…` URL once, server sets `HttpOnly; SameSite=Strict` cookie, redirects
  to clean URL. Keep this as legacy desktop behavior; phone pairing must use
  the one-time claim flow in §5.2, not raw token URLs.
- Token is generated on first run at `lib/server-config.js:38` —
  `crypto.randomBytes(32).toString('hex')`. Stored in `~/.walle/data/config.json`.
- Hook + OTLP routes at `server.js:1215`–`1271` are localhost-only by design
  (telemetry/hook scripts can't carry a token). These must stay localhost-only
  even after we open the rest of the API.

### 2.2 WebSocket protocol (`server.js:7789`+)

The WS already implements every state-changing operation we need from the
phone:

| WS message    | Purpose                              |
|---------------|--------------------------------------|
| `create`      | Spawn a new agent (preset → PTY)     |
| `attach`      | Attach to an existing tab            |
| `input`       | Send keystrokes / message            |
| `resize` / `reflow` | Terminal sizing                |
| `kill`        | Terminate session                    |
| `walle-message` / `walle-cancel` / `walle-permission-response` | Wall-E chat |
| `rename` / `update-session-cwd` | metadata                |
| `subscribe` / `subscribe-stream` | per-tab output filter |

WS auth = same `getAuthToken` + `isLocalhost` check (`server.js:7841`).
Per-connection `clientId` already exists (`server.js:7851`).

### 2.3 Wall-E (`wall-e/server.js`, `wall-e/api-walle.js`)

- Independent HTTP server on port 3457, same auth model: localhost OR Bearer
  token (`WALL_E_API_TOKEN` env). `wall-e/http/auth.js`.
- Routed through CTM via `walleClient.proxyHttp` so the phone only needs to
  reach CTM:3456 (`server.js:1436`).
- Public health/telemetry routes are unauthenticated **on purpose** for Fly.io
  deployment. The personal-Mac instance won't expose those.

### 2.4 PWA primitives

- `public/manifest.json` exists (name, theme, icons 192/512). `display: standalone`.
- `public/index.html` has `viewport: width=device-width, initial-scale=1.0`.
- **No service worker.** No push subscription. No mobile media queries (the UI
  is sidebar + multi-pane, ~13K LOC of HTML). A retrofitted mobile view would
  fight the existing CSS — better to ship a sibling mobile UI under `/m/`.

### 2.5 Telemetry + hooks → notification triggers

- `lib/telemetry-receiver.js` already classifies hook events
  (`hook/claude/{start|stop|idle|menu}`). `idle` is a useful low-confidence
  signal, but it is **not** precise enough by itself for high-severity push.
- `sessionEvents` emits `screenshot-captured`, `session:created`,
  `session:exited`, `session:crashed`. We hook the same bus for push.
- `lib/session-state-bus.js` already models `idle | busy | waiting_input`.
  `onApprovalDetected`, `onApprovalResolved`, and the server's
  `waiting-for-input` broadcast are the primary high-confidence notification
  sources.

### 2.6 What we do NOT have

- Per-device tokens (current model: one shared `config.token`).
- Token revocation table.
- Rate limiting on `/api/*`.
- Central route/message authorization registry.
- WebAuthn / passkey infrastructure.
- Service worker.
- Web Push subscription store + sender.
- A mobile-first UI.
- HTTPS (CTM speaks HTTP because it's loopback-only today).

These are the new components.

---

## 3. Threat model

### 3.1 Assets, ranked

| ID  | Asset                                                                | Severity if compromised |
|-----|----------------------------------------------------------------------|--------------------------|
| A1  | Ability to spawn arbitrary shell commands via Claude Code agents     | **Critical** (full RCE) |
| A2  | Anthropic OAuth token (`~/.claude/.credentials.json`), other API keys (`~/.walle/data/config.json`) | Critical (steal cloud cost, post as user) |
| A3  | Source trees, including `.env` files in worktrees                    | High (leak prod creds)  |
| A4  | Wall-E memory DB (private memory, knowledge graph)                    | High (privacy)          |
| A5  | GitHub identity (CTM can `gh pr create`, push, etc.)                  | High                    |
| A6  | OTel logs / hook stream (sensitive prompt content)                    | Medium                  |
| A7  | CTM admin operations (restart services, delete worktrees)             | Medium (DoS to self)    |

### 3.2 Adversaries

| ID  | Adversary                                                            | Likelihood |
|-----|----------------------------------------------------------------------|------------|
| T1  | Internet-wide port scanners hitting the Mac's public IP              | High if exposed |
| T2  | Hostile Wi-Fi (coffee shop, hotel, airport)                          | High       |
| T3  | Stolen / lost iPhone, still logged in                                | Moderate   |
| T4  | Phishing — token leaks via accidental URL paste in Slack/iMessage     | Moderate   |
| T5  | CSRF from a malicious tab in the same browser as CTM                 | Moderate   |
| T6  | Compromise of a third-party relay (Cloudflare, ngrok, Tailscale CP)   | Low–Med    |
| T7  | Prompt-injection causing the agent to exfiltrate                     | Out of scope (separate guardrail) |
| T8  | Local malware on the Mac                                             | Out of scope (host security) |

### 3.3 Attack surfaces (after phone access ships)

| ID   | Surface                                  | Default posture today      | After this work       |
|------|------------------------------------------|----------------------------|------------------------|
| AS1  | `/api/*` HTTP routes                     | localhost-only             | Tailscale-only + token + (passkey for mutations) |
| AS2  | WebSocket terminal protocol              | same                       | Tailscale-only + token + per-message registry + passkey for mutations |
| AS3  | `/hook/*`, `/v1/logs`                    | localhost-only             | **stays localhost-only** (never exposed) |
| AS4  | Wall-E `/api/wall-e/*` (proxied)         | localhost-only             | Tailscale-only + token + passkey for side effects |
| AS5  | oauth-proxy on :3458                     | localhost-only             | **stays localhost-only** |
| AS6  | Static `public/` files                   | localhost-only             | Tailscale-only (auth optional for assets) |
| AS7  | Push subscription endpoint (new)         | n/a                        | Tailscale-only + token |
| AS8  | WebAuthn registration (new)              | n/a                        | one-time claim for pairing; token+step-up or loopback for replacement |

### 3.4 Out of scope

- Hardening Claude Code / Codex / Gemini themselves against prompt injection.
  That's an upstream agent-permission problem, not a phone-access problem.
- Anti-malware on the Mac itself.

---

## 4. Auth + transport: options analyzed

Three viable stacks. I recommend a primary and a backup; all three avoid the
"bind 0.0.0.0 with no overlay" pattern (e.g., 247-claude-code-remote) which
violates G2 of the threat model.

### 4.1 Option A — Tailscale overlay (RECOMMENDED PRIMARY)

```
[iPhone w/ Tailscale]──WireGuard tunnel──[Mac, tailnet IP 100.x.y.z]
                                            └─ CTM listens on tailscale0:3456
```

**How it works**
- Install Tailscale on the Mac and the iPhone (one owner-controlled Tailscale
  account). Both join the personal *tailnet*.
- CTM binds to **the Tailscale IP/interface only**. Do not use `0.0.0.0`.
  Tailscale ACLs protect the tailnet path, but they do not protect ordinary
  LAN interfaces if CTM is bound to every interface.
- Tailscale gives the Mac a stable name like `ctm-mac.tailNNNN.ts.net`
  resolvable from any device on the tailnet (MagicDNS).
- `tailscale cert ctm-mac.tailNNNN.ts.net` → real Let's Encrypt TLS cert
  inside the tailnet. CTM serves HTTPS using that cert.
- Setup must warn that Tailscale HTTPS certificate names are published in
  Certificate Transparency logs. Rename the Mac to a non-sensitive tailnet
  hostname before requesting a cert.

**Why it's the strongest default**
- No public IP, no port forward, no reverse proxy.
- Connection identity = device cryptographic identity (WireGuard key); zero
  reliance on app-level credentials at the network layer.
- Survives Wi-Fi → cellular handoff because Tailscale roams.
- Works behind double-NAT (CGNAT, etc.).
- Free for personal use up to 100 devices.
- ACLs let us restrict who reaches port 3456 even within the tailnet (not
  needed for single-user, but available).

**Cost**
- Setup: ~15 min (install on both devices, enable MagicDNS, run
  `tailscale cert`).
- Ongoing: zero.

**Tradeoffs**
- Need to install Tailscale on every device that wants access. For phones the
  user controls, this is fine; for "use a friend's laptop in a pinch" it's not.
  → Option B addresses that.
- Tailscale's control plane is a SaaS dependency (Cloudflare-style). If
  Tailscale is breached, the data plane is still WireGuard E2E (control plane
  doesn't see content), but device identity could be issued maliciously.
  Mitigation: tailnet lock (signed device joins) is available if paranoid.

### 4.2 Legacy Option B — Cloudflare Tunnel + Cloudflare Access (DO NOT IMPLEMENT)

2026-05-27 decision: Cloudflare Tunnel is no longer part of the CTM remote
access plan. Keep this section only as historical threat-model context for old
deployments. New work should use private Microsoft Dev Tunnel by default or
Tailscale as the private-network alternative.

```
[Internet client]──HTTPS──[Cloudflare edge]──QUIC tunnel──[cloudflared on Mac]──[CTM:3456]
                          │
                          └── Cloudflare Access policy: passkey + email OTP
```

**How it works**
- Run `cloudflared tunnel` on the Mac (outbound-only QUIC to CF edge).
- Buy / point a domain (or use a `pages.dev` hostname) — `ctm.example.com`.
- In CF Zero Trust, configure an **Access application** for that hostname:
  - Identity provider: owner Google SSO **and** WebAuthn
    passkey **and** email OTP fallback.
  - Policy: only that one email can authenticate; passkey strongly preferred,
    OTP last resort.
- Once Access lets you in, CF passes the request to CTM along with a JWT in
  `Cf-Access-Jwt-Assertion`. CTM verifies this header, not just the
  `CF_Authorization` cookie, **in addition to** the CTM device-token check.
- Verification is fail-closed: expected issuer
  `https://<team-name>.cloudflareaccess.com`, expected Access application AUD
  tag, RS256 signature from the Access JWKS endpoint, clock-skew bounds, email
  claim allowlist, and JWKS cache refresh that tolerates Cloudflare key
  rotation without accepting unsigned or wrong-audience tokens.

**Why it's a strong backup**
- Public URL, accessible from any device with a browser — no client install.
- Access provides defense-in-depth identity (SSO + passkey + OTP) before the
  request reaches your machine.
- DDoS / WAF / rate-limit by CF edge for free.
- Compatible with PWA install on any browser.

**Cost**
- Setup: ~30 min (CF account + domain + Access app + tunnel install).
- Ongoing: zero (Cloudflare Free Zero Trust covers up to 50 users).

**Tradeoffs**
- Trust dependency on Cloudflare. If CF is compromised, attacker still hits
  the app token check, but JWT verification could be forged.
- One more service to maintain.
- Slightly higher latency (one extra hop through CF edge).

### 4.3 Option C — Tailscale Funnel (NOT recommended for default)

Funnel publishes a Tailscale node to the public internet via Tailscale's
relay. Useful when you must accept a webhook from a non-tailnet caller.

We don't need this for the phone use case (Option A covers it) and a public
URL increases attack surface without offsetting benefit. **Skip.**

### 4.4 Comparison

| Property                     | A: Tailscale | B: CF Tunnel + Access | C: Funnel |
|------------------------------|:------------:|:--------------------:|:---------:|
| Inbound port on Mac          | None         | None                 | None      |
| TLS                           | LE via TS    | CF edge              | CF edge   |
| Identity at network layer    | WG device key| CF SSO + passkey     | None (must do app auth) |
| Public URL                    | No           | Yes                  | Yes       |
| Phone-only-with-Tailscale OK  | Required     | Optional             | Optional  |
| Latency                       | Lowest       | +1 hop               | +1 hop    |
| 3rd-party dependency          | Tailscale CP | Cloudflare           | Tailscale |
| Recommended for our case      | **Primary**  | **Fallback / when away from TS phone** | No |

### 4.5 Recommendation

**Ship A as the default. Add B as an optional fallback in v2.** Skip C.

The user already has both Cloudflare and Fly.io accounts (per CLAUDE.md
memory), so onboarding either is light. But A is dramatically simpler and
covers 100% of the iPhone use case.

---

## 5. App-layer auth (independent of transport)

The transport layer (Tailscale/CF) verifies the *device or identity*. The app
layer verifies *that user, on that device, with consent*. Both are required.

### 5.1 Auth context + storage

Replace single shared `config.token` with an `AuthContext` created once per
HTTP request / WS connection:

```ts
type AuthContext = {
  isLoopback: boolean;
  deviceId: string | null;
  scopes: Set<'read' | 'respond' | 'create' | 'admin'>;
  stepUpFresh: boolean;
  tokenLabel: string | null;
  transport: 'loopback' | 'tailscale' | 'cloudflare';
};
```

Loopback requests get all scopes but still go through the same route registry
so tests exercise one path. Remote requests must resolve to a non-revoked
device token before any `/api/*` route or WS message handler runs.

**Loopback-trust security invariant (`lib/transport-security.js` `isLoopbackRequest`).**
A managed tunnel (devtunnel) forwards remote clients to CTM over `127.0.0.1`, so a
loopback socket peer alone does NOT prove a request is on-box. Loopback full-admin
trust requires ALL of: (1) a loopback socket peer, (2) **no** proxy/tunnel
forwarding markers (`X-Forwarded-For/Host/Proto`, `Forwarded`, `X-Real-IP`,
`X-MS-Original-Host`, `X-Original-Host`, `CF-Connecting-IP`), (3) no non-loopback
browser `Origin`/`Referer`, and (4) a loopback or absent `Host`. A genuinely local
browser or CLI never sends forwarding markers; Azure Dev Tunnels (and every
standard proxy) always stamp them, so any tunnel-forwarded request fails closed
and must present a device token like any other remote client. This invariant is
the linchpin of the whole model — never relax it to "loopback socket ⇒ trusted".

**New table**: `ctm_device_tokens`
```sql
CREATE TABLE ctm_device_tokens (
  id                  TEXT PRIMARY KEY,        -- ulid
  label               TEXT NOT NULL,           -- "Owner iPhone"
  token_hash          TEXT NOT NULL UNIQUE,    -- sha256(token); raw token never stored
  scopes              TEXT NOT NULL,           -- JSON array: ['read','respond','create','admin']
  webauthn_required   INTEGER NOT NULL DEFAULT 1,
  created_at          INTEGER NOT NULL,
  claimed_at          INTEGER,
  passkey_bound_at    INTEGER,
  last_used_at        INTEGER,
  last_used_ip        TEXT,
  last_used_ua        TEXT,
  revoked_at          INTEGER,
  expires_at          INTEGER                  -- optional auto-expiry
);
```

**New table**: `ctm_device_claims`
```sql
CREATE TABLE ctm_device_claims (
  id                  TEXT PRIMARY KEY,        -- ulid in the QR URL
  secret_hash         TEXT NOT NULL UNIQUE,    -- sha256(secret); raw secret never stored
  label               TEXT NOT NULL,
  scopes              TEXT NOT NULL,
  created_at          INTEGER NOT NULL,
  expires_at          INTEGER NOT NULL,        -- default: now + 10 min
  claimed_at          INTEGER,
  device_token_id     TEXT REFERENCES ctm_device_tokens(id),
  created_by          TEXT NOT NULL DEFAULT 'loopback'
);
```

**New table**: `ctm_webauthn_credentials`
```sql
CREATE TABLE ctm_webauthn_credentials (
  id                  TEXT PRIMARY KEY,
  device_token_id     TEXT NOT NULL REFERENCES ctm_device_tokens(id) ON DELETE CASCADE,
  rp_id               TEXT NOT NULL,           -- e.g. ctm-mac.tailNNNN.ts.net
  origin              TEXT NOT NULL,           -- exact HTTPS origin used at enrollment
  credential_id       TEXT NOT NULL UNIQUE,
  public_key          BLOB NOT NULL,
  counter             INTEGER NOT NULL DEFAULT 0,
  transports          TEXT,
  created_at          INTEGER NOT NULL,
  last_used_at        INTEGER
);
```

**New table**: `ctm_step_up_sessions`
```sql
CREATE TABLE ctm_step_up_sessions (
  id_hash             TEXT PRIMARY KEY,
  device_token_id     TEXT NOT NULL REFERENCES ctm_device_tokens(id) ON DELETE CASCADE,
  created_at          INTEGER NOT NULL,
  expires_at          INTEGER NOT NULL,
  used_for_audit_id   TEXT
);
```

The `ctm_step_up` cookie contains a random session handle whose hash is stored
server-side. A signed client-only cookie is not enough because token revocation
must invalidate outstanding step-up windows immediately.

### 5.2 One-time phone pairing flow

The QR flow must never put the raw long-lived device token in the URL.

1. On the Mac, user opens `http://localhost:3456/setup.html#devices`, enters a
   label ("iPhone 15 Pro"), picks scopes, and clicks "Pair phone".
2. Loopback-only `POST /api/auth/device-claims` creates `ctm_device_claims`
   with a 10-minute TTL and returns:
   `claim_url = https://<tailnet-host>:3456/m/claim?claim=<id>&secret=<secret>`.
3. The setup page renders a QR code and a short pairing code. The QR modal
   shows countdown, selected scopes, and a "cancel claim" button.
4. Phone scans QR. `/m/claim` verifies secure context first. If HTTPS is not
   active, it stops with setup instructions rather than attempting passkey
   registration.
5. Phone POSTs the claim id + secret to `/api/auth/claim/begin-passkey`.
   Server verifies one-time claim, returns WebAuthn registration options, and
   marks the claim as "registration started" only in memory.
6. Phone calls `navigator.credentials.create(...)`; Face ID verifies local
   user presence. Phone POSTs the attestation to `/api/auth/claim/finish`.
7. Server verifies attestation, creates `ctm_device_tokens`, stores the
   passkey credential, sets `ctm_token` as an HttpOnly cookie, marks claim
   `claimed_at`, and refuses all future use of the same claim.
8. Phone lands on `/m/` with a "This device is paired" interstitial that
   offers "Enable notifications" and "Open dashboard".

WebAuthn RP ID rule:
- A passkey registered on `https://<tailnet-host>:3456` is scoped to that
  tailnet hostname. It will not automatically work on `https://ctm.example.com`.
- A passkey registered on `https://<tunnel>.devtunnels.ms` is scoped to that
  Dev Tunnel hostname. It verifies a CTM-registered credential for this origin;
  it does not prove which Microsoft/GitHub account passed the Dev Tunnel gate.
- If Cloudflare fallback is enabled, the phone must enroll a second credential
  for the Cloudflare origin, tied to the same `device_token_id`.
- Store `rp_id` and `origin` per credential and pick the verification options
  from the request origin. Never accept an assertion for a different origin.

Backwards compatibility:
- Keep `config.token` as a loopback-only "legacy admin" token for one release.
- Migration on first start inserts `config.token` into `ctm_device_tokens` with
  `label='legacy-localhost', scopes=['admin']`, but remote requests using this
  legacy token are rejected.

Cookie policy:
- HTTPS/tailnet: `ctm_token=...; HttpOnly; Secure; SameSite=Strict; Path=/`.
- Localhost HTTP dev mode: omit `Secure` because browsers special-case
  localhost secure context differently from arbitrary HTTP origins.
- Query-param token redirect remains for legacy desktop only; it must not
  accept device-claim URLs and must not be used by phone onboarding.

### 5.3 Scopes

- `read` — all GET routes (status, search, messages).
- `respond` — `walle-message`, prompt replies, agent input via WS, approval
  agent decisions.
- `create` — spawn new sessions, create worktrees.
- `admin` — restart services, kill sessions, delete worktrees, mint new
  tokens, modify settings.

A phone token defaults to `read + respond`. `create` and `admin` require
explicit upgrade. The desktop loopback is always all-scope.

### 5.4 WebAuthn passkey for mutations (G3)

Even with a valid token, **high-risk** routes require a fresh WebAuthn
assertion (a "step-up" challenge). The phone's iOS Face ID / Secure Enclave
makes this near-frictionless. In private Microsoft Tunnel mode, passkey is not
the primary login proof; Microsoft/GitHub gates reachability before CTM sees the
request. Passkey is an app-layer confirmation for sensitive CTM actions and a
backup if a CTM device token/cookie is copied out of the browser.

**Library**: `@simplewebauthn/server` + `@simplewebauthn/browser` (mature,
Node-native, used by Auth.js).

**Flow**
1. Phone POSTs `/api/auth/begin-step-up` with token cookie.
2. Server returns WebAuthn challenge.
3. Phone calls `navigator.credentials.get(...)`. iOS prompts Face ID.
4. Phone POSTs assertion to `/api/auth/finish-step-up`.
5. Server verifies, sets short-lived `ctm_step_up` cookie (HttpOnly,
   Secure, SameSite=Strict, 10 min).
6. Mutating routes require both `ctm_token` AND `ctm_step_up`.

**Operations that require step-up**
- Remote approval / denial / permission response.
- `walle-message` only when Wall-E will execute tools, skills, or external
  actions rather than plain chat.
- Spawn, kill, restart, cancel.
- Worktree create / delete / merge / create-pr.
- Settings, token, notification, and Cloudflare Access configuration changes.

Read-only routes (status, search, messages, watch streams) and low-risk replies
do **not** require step-up when the transport is private and the CTM device
token is valid. The friction budget belongs to actions that can change machine
state or approve privileged agent behavior.

Step-up UX:
- The phone presents an inline "Confirm with Face ID" sheet only after the user
  taps Send/Approve/Spawn, with a one-line summary of what will happen.
- A successful step-up opens a 10-minute window for the same device token, but
  the UI still shows a lock icon on every mutating button. The lock changes to
  "verified" while the window is fresh.
- Revoking a device token deletes all its `ctm_step_up_sessions` rows and
  closes its WS clients.

### 5.5 Anti-CSRF

- Cookies are already `SameSite=Strict; HttpOnly`.
- Add an explicit `Origin` header check on all state-changing routes:
  reject if `Origin` is not in an allowlist (`https://ctm-mac.tailNNNN.ts.net:3456`,
  `https://ctm.example.com`, `http://localhost:3456`, `http://127.0.0.1:3456`).
- For browser HTTP mutations, require `Origin`. For WS handshakes, require
  `Origin` before accepting remote connections.
- If `Sec-Fetch-Site` is present, require `same-origin` or `none`. Do not make
  it the only defense because browser/PWA coverage varies.
- Non-browser API clients must use Bearer token auth and are remote-disabled by
  default unless an explicit `CTM_REMOTE_BEARER_CLIENTS=1` dev flag is set.

### 5.6 Rate limiting + lockout

- Default token bucket per token: 120 RPM, 20 RPS burst.
- Mutation bucket per token: 30/min, 5 burst.
- Spawn/kill/admin bucket: 10/min, 2 burst.
- Failed-token-attempt counter per `(remote_ip, hour)`. After 20 failures,
  IP cooldown for 1 h. (Tailscale IPs are stable and sparse, so this is fine.)
- `/api/auth/begin-step-up` rate-limited to 10/min/token to prevent passkey
  prompt-spam attacks.

### 5.7 Audit log

New table `ctm_auth_audit` — every successful auth, every failure, every
mutation that passed step-up. Stored 30 days. Visible at
`/setup.html#audit` for the user.

Audit rows include `device_token_id`, route or WS message type, scope required,
step-up session id hash prefix, remote IP, user agent, and decision
(`allow|deny|rate_limited|revoked|expired|csrf`).

### 5.8 Default-deny authorization registry

Implementation must add a single registry used by both HTTP and WS handlers.
Routes/messages not listed are denied for remote clients until classified.

```ts
type AuthRule = {
  kind: 'http' | 'ws';
  method?: string;
  path?: RegExp | string;
  messageType?: string;
  scope: 'read' | 'respond' | 'create' | 'admin';
  mutation: boolean;
  stepUp: 'never' | 'remote' | 'always';
};
```

Minimum WS rules:

| WS message | Scope | Mutation | Step-up |
|------------|-------|----------|---------|
| `list`, `attach`, `detach`, `subscribe`, `subscribe-stream`, `unsubscribe-stream` | `read` | no | never |
| `resize`, `reflow` | `read` | no session-state mutation | never |
| `input` | `respond` | yes | remote |
| `walle-message` | `respond` | yes | remote |
| `walle-permission-response` | `respond` | yes | remote |
| `walle-cancel` | `respond` | yes | remote |
| `create` | `create` | yes | remote |
| `kill` | `admin` | yes | remote |
| `rename`, `update-session-cwd` | `admin` | yes | remote |

Minimum HTTP route families:

| Route family | Scope | Mutation | Step-up |
|--------------|-------|----------|---------|
| `GET /api/sessions/*`, `GET /api/reviews/*`, `GET /api/wall-e/memories`, `GET /api/wall-e/tasks`, `GET /api/wall-e/chat/history` | `read` | no | never |
| `POST /api/sessions/ai-search` | `read` | no persistent mutation | never |
| `POST /api/wall-e/chat` | `respond` | yes if tools/skills can run | remote |
| Session input / approve / close APIs | `respond` | yes | remote |
| Session create / attach-to-new-worktree APIs | `create` | yes | remote |
| Worktree delete/merge/create-pr, service restart, token/device/settings APIs | `admin` | yes | remote |
| `/hook/*`, `/v1/logs`, `/api/otlp/*`, OAuth proxy | loopback only | yes | not remote-accessible |

Acceptance gate for Batch 1: an automated test enumerates all HTTP route
definitions and all WS `case` labels in `server.js`; the test fails if any
remote-reachable handler lacks an `AuthRule`.

### 5.9 Kill switch

Single button in `setup.html#devices`: "Revoke all phone tokens." Sets
`revoked_at` on every non-loopback device token, deletes associated
`ctm_step_up_sessions`, and closes WS clients whose `ws.auth.deviceId` matches
those revoked ids. Do not rely on "non-loopback origin" as identity; WS
connections store `AuthContext` at handshake time.

---

## 6. Mobile UI

### 6.1 Architecture choice — sibling app, not retrofit

The existing `public/index.html` is desktop-tuned (sidebar + multi-pane,
13K LOC of HTML, 8.7K LOC of `walle.js`). Retrofitting media queries means
months of CSS pain.

Ship a **sibling PWA** at `/m/` (mobile) that:
- Reuses the existing `/api/*` and WebSocket endpoints (zero backend churn).
- Reuses the message renderer (`public/js/message-renderer.js` — the
  "review/conversation parity" rule from memory means we MUST go through this
  module for any rendering).
- Has its own minimal CSS, single-column layout, large tap targets.

Path: `claude-task-manager/public/m/{index.html, app.js, app.css, sw.js}`
plus shared imports from `../js/`.

The desktop UI doesn't change; users on phones get auto-redirected from `/`
to `/m/` based on viewport (or `/m/` is fully separate — let user choose).
Do **not** auto-redirect these paths: `/setup.html`, `/m/claim`, URLs with
`claim=`, URLs with legacy `token=`, or any URL with `desktop=1`.

### 6.2 Phone-side UX model

The mobile app is for short, high-confidence interventions. It is not a
general IDE and should not feel like a shrunken desktop terminal.

Primary contexts:
- Locked phone receives a notification while the user is away from the Mac.
- User opens the app one-handed to answer "what needs me right now?"
- User reads enough recent context to safely respond, approve, deny, or stop.
- User starts a small new agent from a known project/preset.
- User searches prior sessions or Wall-E memory to regain context.

Design rules:
- First screen is action-oriented: waiting items, running items, failures,
  recent completions. No marketing, no explanatory hero.
- Every mutating control has a lock state. If step-up is stale, tap opens a
  Face ID sheet; if fresh, tap still shows the action summary and sends.
- Lock-screen notifications contain no prompt text by default. They say
  "Agent waiting" or "Task finished" and deep-link into the exact item.
- Message stream is the default view. Raw terminal is hidden behind "Open
  terminal snapshot" and is read-only unless explicitly enabled later.
- Terminal control is typed, not raw. The phone can expose small named actions
  such as "Send Esc" only when they map to reviewed remote-message types.
- Use bottom navigation, sticky bottom action bars, 44 px minimum touch
  targets, and no nested cards. Dense operational UI beats decorative layout.
- Network/offline states are explicit: "Live", "Reconnecting", "Stale 3m",
  "Mac unreachable", "Tailscale disconnected".

Primary journeys:

1. Pair phone:
   Mac setup page shows QR → phone opens `/m/claim` → secure-context check →
   Face ID passkey registration → optional notification permission → dashboard.
2. Triage:
   Status tab shows "Needs you" first, then "Running", then "Done recently".
   Each row shows agent, project, state, elapsed time, and one primary action.
3. Respond:
   Agent detail opens at the latest waiting point, with recent context above
   and a sticky composer below. Send requires step-up.
4. Approve/deny:
   Approval sheet shows command, cwd, agent, risk label, and choices. Approve
   and deny both require step-up; "Open context" expands recent output.
5. Spawn:
   New-agent bottom sheet defaults to last-used project and preset. Creating a
   session requires `create` scope plus step-up.
6. Recover:
   Settings shows current device, scopes, last used, "sign out this device",
   and a Mac-only route for revoking all phone tokens.

### 6.3 Information architecture (5 tabs at the bottom)

```
┌─────────────────────────────────────┐
│  CTM                  🔔 [badge=3]  │ ← header: title + notification button
├─────────────────────────────────────┤
│                                     │
│  [tab content fills here]           │
│                                     │
├─────────────────────────────────────┤
│  Status  Agents  Wall-E  Search  ⋯  │ ← bottom nav
└─────────────────────────────────────┘
```

| Tab     | Purpose                                    | Maps to G   |
|---------|--------------------------------------------|-------------|
| Status  | Aggregated dashboard: who's idle / waiting / running | G1   |
| Agents  | List of CTM tabs + Wall-E loops; tap → detail | G1, G2  |
| Wall-E  | Chat with Wall-E (memory + skills)         | G4          |
| Search  | Full-text search across sessions + memories | G2 (search) |
| ⋯ More  | Settings, devices, sign out, debug         | G2 (admin)  |

### 6.4 Status tab (default)

```
┌─────────────────────────────────────┐
│ ⚠ 2 agents waiting on you           │ ← red banner if any awaiting
├─────────────────────────────────────┤
│ ● phone (Claude Code)               │
│   waiting for permission            │
│   "Run pnpm install in apps/web?"   │
│   [Approve] [Deny] [Open]           │
├─────────────────────────────────────┤
│ ● fix-bug-42 (Codex)                │
│   asked: "Should I rebase or merge?"│
│   [Open]                            │
├─────────────────────────────────────┤
│ ◯ refactor-auth (Claude Code)       │
│   thinking… (3m)                    │
├─────────────────────────────────────┤
│ ◯ Wall-E loops (5 running)          │
│   tap to expand                     │
└─────────────────────────────────────┘
```

Colors map to existing status precedence (`session-status-precedence.js`):
red = awaiting input, orange = error, blue = thinking, gray = idle/done.

Backed by `GET /api/sessions/standup` (already exists) and
`GET /api/wall-e/tasks?status=running`.

States and row actions:

| State | Row treatment | Primary action |
|-------|---------------|----------------|
| Waiting for input | Top "Needs you" section, high-contrast left rail | Open |
| Approval requested | Top section, command/risk summary | Review |
| Running | Normal row with elapsed timer | Watch |
| Recently finished | Collapsed "Done recently" row | Review |
| Crashed / disconnected | Error row | Diagnose |

### 6.5 Agent detail view

Tap an agent → full-screen detail:

```
┌─────────────────────────────────────┐
│  ← phone                       ⋯    │
├─────────────────────────────────────┤
│  [conversation messages, like the   │
│   #review= page — reuses MR module] │
│                                     │
│   user: ...                         │
│   assistant: ...                    │
│   [tool use: bash]                  │
│                                     │
├─────────────────────────────────────┤
│  ⚠ Permission requested             │
│  Run "rm -rf node_modules"?         │
│  [Approve] [Deny]            🔒Face │ ← Face ID prompt before send
├─────────────────────────────────────┤
│ [Type a reply...]            [Send] │
└─────────────────────────────────────┘
```

- Messages: stream-first via existing WebSocket `subscribe-stream` channel.
  Subscribe before loading HTTP history so large JSONL files do not block the
  phone from seeing fresh output.
- Send: WebSocket `input` (or, for Wall-E, `walle-message`).
- Approve/deny: WebSocket `walle-permission-response` for Wall-E; for Claude
  Code, write the prompt's selected option via existing approval API.
- Reply input is a textarea + Send. Tap Send → step-up modal if not fresh →
  Face ID → send.
- Long-press a message → copy / quote / open-on-desktop.

Detail view layout:
- Header: back, agent label, provider, project/worktree, state chip, overflow.
- Context stream: latest messages/output using `message-renderer.js`; tool
  calls collapse by default but show command, cwd, exit state, and duration.
- Waiting panel: sticky above composer when present. Shows why CTM thinks the
  agent is waiting and when that signal was detected.
- Remote controls: a compact strip appears for live terminal-backed sessions.
  It exposes named controls such as "Send Esc" to cancel the current turn or
  close a prompt. It must never expose arbitrary key injection from the phone.
- Approval panel: command text is selectable, cwd is visible, risk label is
  visible, and "Approve"/"Deny" are separated enough to avoid mis-taps.
- Composer: textarea grows to 4 lines, supports dictation, has Send icon,
  optional "quote last output", and disables while offline/reconnecting.
- Desktop handoff: "Open on desktop" copies/deep-links the CTM URL and
  highlights that the desktop remains the full workbench.

Step-up sheet copy:
```
Confirm action
Send this reply to "phone" in /ws/tools/phone.
[Cancel] [Face ID & Send]
```

For approvals:
```
Confirm approval
Allow Claude Code to run:
npm install
cwd: /path/to/worktree
[Deny] [Face ID & Approve]
```

### 6.6 New-agent flow

`+` FAB on Agents tab → bottom sheet:

```
New session
  Project: [▼ ws/tools/phone]
  Preset:  [● claude-code  ○ codex  ○ gemini]
  Worktree: [▼ existing  ○ new from main]
  Initial prompt (optional): [______________]
  [Cancel]              [Spawn 🔒Face ID]
```

POSTs to existing session-creation API (the same one `/api/sessions/attach`
or the WS `create` already does). Step-up required (it spawns a shell).

Phone-specific defaults:
- Project defaults to the active/recent worktree from desktop CTM.
- Preset defaults to last used on phone, then desktop default.
- Worktree selector shows "existing" first; "new worktree" requires `create`
  scope and a clear branch-name field.
- If token lacks `create`, the `+` button opens a read-only explanation with
  "upgrade on Mac" rather than hiding the feature.

### 6.7 Search tab

Single search bar. As you type, debounced calls to:
- `GET /api/sessions/search?q=…` (CTM)
- `POST /api/sessions/ai-search` (semantic, slower; optional)
- `GET /api/wall-e/memories?limit=…` for recent memories.
- Free-text Wall-E memory search needs a new endpoint
  `GET /api/wall-e/memories/search?q=…` or a CTM proxy to Wall-E MCP
  `search_memories`; do not call the nonexistent
  `POST /api/wall-e/memories/search`.

Results grouped by source. Tap → open the relevant detail.

Search result UX:
- Results are grouped as Sessions, Messages, Wall-E memory, Wall-E tasks.
- Each result shows source, time, project/session, and a two-line preview.
- Tapping a session result opens the agent detail at that point if the stream
  can locate it; otherwise it opens the closest available session context.

### 6.8 Wall-E chat

Reuses existing `/api/wall-e/chat/*` endpoints (already proxied). The desktop
walle.js is too heavy; the mobile version uses the same MR module + a thin
chat wrapper (≤ 400 LOC). Includes:
- "Send" with Face ID step-up if Wall-E will run a side-effecting skill.
- "Run skill" picker (lists available skills from
  `GET /api/wall-e/skills`).

Wall-E-specific UX:
- Default mode is chat. Task loops appear as rows with state, duration, and
  stop/review actions.
- Running a side-effecting skill presents the same step-up sheet with the skill
  name, target, and expected side effect.
- Memory answers are readable without step-up. Memory writes, task stops, and
  skill runs require step-up.

### 6.9 Notifications UX

Notification categories:

| Category | Lock-screen body | Tap target | Action buttons |
|----------|------------------|------------|----------------|
| Agent waiting | "Agent waiting on you" | Agent detail waiting panel | None |
| Approval needed | "Approval needed" | Approval panel | None |
| Long task done | "Task finished" | Completion summary | None |
| Crash/disconnect | "Agent crashed" | Diagnostics row | None |

Do not support lock-screen Approve/Deny. It bypasses the Face ID step-up model
and creates a dangerous muscle-memory path. The notification may deep-link to
the approval panel, but the in-app step-up sheet is mandatory.

Badge count = number of unresolved "Needs you" items. Clear a badge when CTM
sees the corresponding waiting/approval item resolved, not merely when the app
is opened.

### 6.10 Settings / devices / sign out

- This device's label, scopes, last-used.
- "Sign out" = clear cookie locally + POST `/api/auth/revoke-self`.
- "Manage all devices" → redirects to `setup.html#devices` (loopback only;
  shows a "this page is only available on the Mac" message if accessed remotely).
- "Notification settings" → waiting always, long-finished threshold, quiet
  hours, include preview toggle default-off.
- "Connection" → tailnet hostname, current network state, last successful
  websocket heartbeat, copy diagnostics.

### 6.11 Offline + caching (service worker)

- `sw.js` caches the shell (HTML/CSS/JS), not API responses.
- API calls always hit network. If offline, show cached "last known status"
  with a "stale" banner.
- Service worker handles `push` events for notifications (§7).
- Queue no mutations offline. A phone must never replay a stale approval after
  reconnect; the user must re-open the action and step up again.

### 6.12 Look + feel

- Dark theme from existing `--bg`, `--fg-*`, `--accent` CSS vars.
- iOS-style large tap targets (≥ 44×44 pt).
- `prefers-color-scheme` respected.
- Use system font stack — no custom font load.
- Animations limited to 120 ms; no parallax, no blur on scroll (battery).

### 6.13 V1 screen inventory

| Screen / sheet | Purpose | Must contain |
|----------------|---------|--------------|
| `/m/claim` | Pair this phone | Secure-context check, claim expiry, scope summary, Face ID passkey registration |
| Status | First screen after launch | Needs-you count, waiting/approval rows, running rows, recent completions |
| Agent detail | Review and steer one agent | Header, live/stale state, message stream, waiting panel, composer |
| Approval sheet | Decide a permission prompt | Command, cwd, provider, risk label, Deny, Face ID & Approve |
| Spawn sheet | Start a new agent | Project, preset, worktree, prompt, scope warning, Face ID & Spawn |
| Search | Recover context | Sessions, messages, Wall-E memories/tasks grouped by source |
| Wall-E chat | Ask Wall-E / inspect loops | Chat stream, task rows, side-effecting skill step-up |
| Settings | Device and notifications | Device label/scopes, sign out, notification toggles, connection diagnostics |
| Mac-only device admin | Manage all devices | Device list, revoke, audit log, pair QR, recovery/reset passkey |
| Error / reconnect | Handle network breakage | Last live time, Tailscale hint, retry, no queued mutations |

---

## 7. Push notifications

### 7.1 Triggers

| Event                                        | Primary source                                      | Severity |
|----------------------------------------------|-----------------------------------------------------|----------|
| Agent waiting on prompt/input                 | `SessionStateBus statechange -> waiting_input` and server `waiting-for-input` broadcast | High |
| Approval prompt detected                      | `statusHooks.onApprovalDetected` / approval-agent audit | High |
| Session crashed                               | `session:crashed` event                            | High |
| Long task finished                            | `session:exited` with duration > user threshold     | Medium |
| Wall-E task/loop completed                    | new `walle:task_completed` event; until then, poll `GET /api/wall-e/tasks` for state transitions | Low |
| Low-confidence idle                           | `telemetry-receiver` idle hook                      | Low / debounce only |
| Custom explicit notify                        | future `mobile_notify` tool/event                   | Variable |

Do not send high-severity push directly from generic `idle`. Idle is a useful
secondary signal, but terminal prompt detection and approval detection are the
sources that mean "the user should act."

### 7.2 Mechanism — Web Push (VAPID)

iOS 16.4+ supports Web Push for installed PWAs.
- Generate a VAPID key pair on first server start. Store in
  `~/.walle/data/vapid.json`.
- Phone's service worker subscribes via `pushManager.subscribe({applicationServerKey:VAPID_PUB})`.
- Subscription endpoint stored in `ctm_push_subs` table, joined to the device
  token.
- Server uses [`web-push`](https://github.com/web-push-libs/web-push) npm
  library to deliver — speaks to APNS / FCM as appropriate.

### 7.3 Subscription store

```sql
CREATE TABLE ctm_push_subs (
  id              TEXT PRIMARY KEY,
  device_token_id TEXT NOT NULL REFERENCES ctm_device_tokens(id) ON DELETE CASCADE,
  endpoint        TEXT NOT NULL,           -- APNS / FCM URL
  p256dh          TEXT NOT NULL,
  auth            TEXT NOT NULL,
  user_agent      TEXT,
  created_at      INTEGER NOT NULL,
  last_delivery   INTEGER,
  failure_count   INTEGER NOT NULL DEFAULT 0
);
```

### 7.4 Flow

```
SessionStateBus / approval-agent / sessionEvents emits a concrete event
   → notification-dispatcher normalizes to MobileNotification
   → dispatcher assigns stable dedupe key: <source>:<sessionId/taskId>:<state>:<since>
   → dispatcher checks user notification settings + quiet hours + unresolved state
   → dispatcher reads ctm_push_subs joined on device tokens
       (filters: scope includes 'respond', token not revoked)
   → web-push.sendNotification(sub, JSON.stringify(payload))
   → APNS delivers to phone
   → service worker `push` event fires
   → showNotification with deep-link to /m/#agent/<id>
```

`MobileNotification` contract:

```ts
type MobileNotification = {
  id: string;              // stable for dedupe/retry
  category: 'waiting' | 'approval' | 'finished' | 'crashed' | 'walle';
  source: 'session-state-bus' | 'approval-agent' | 'session-events' | 'walle';
  sessionId?: string;
  taskId?: string;
  severity: 'low' | 'medium' | 'high';
  createdAt: number;
  deepLink: string;        // /m/#agent/<id> or /m/#walle/task/<id>
  privatePreview?: string; // only when user opts in
};
```

The dispatcher also updates app badge count from unresolved waiting/approval
items. It clears notifications only after the underlying state resolves, not
when a push is delivered.

### 7.5 Privacy

- Notification body **never includes prompt content** by default — just
  "Agent X is waiting on you." Tap the notification to open the app and
  fetch the actual content over the authenticated channel.
- Optional setting: include a 64-char preview if user opts in.
- Apple/Google push relays see only an opaque encrypted blob (Web Push has
  built-in payload encryption with the subscription's `p256dh` key).

---

## 8. API contract (delta from existing)

Most CTM/Wall-E data routes already exist. The deltas are the pairing/auth
surface, the authorization registry, notification endpoints, and one Wall-E
memory-search endpoint if we want free-text memory search in mobile.

### 8.1 New

| Method | Path                              | Auth          | Purpose |
|--------|-----------------------------------|---------------|---------|
| POST   | `/api/auth/device-claims`          | loopback only | Create one-time phone pairing claim |
| DELETE | `/api/auth/device-claims/:id`      | loopback only | Cancel unclaimed QR |
| POST   | `/api/auth/claim/begin-passkey`    | claim secret  | Begin WebAuthn registration or same-phone recovery for a claim |
| POST   | `/api/auth/claim/finish`           | claim secret  | Finish registration/recovery and mint device token cookie |
| GET    | `/api/auth/me`                     | token         | Current device label, scopes, step-up freshness |
| GET    | `/api/auth/devices`                | loopback only | List devices |
| DELETE | `/api/auth/devices/:id`            | loopback or admin+step-up | Revoke |
| POST   | `/api/auth/device-duplicates/revoke` | loopback only | Keep one duplicate phone pairing and revoke the others |
| POST   | `/api/auth/begin-step-up`          | token         | Begin WebAuthn assertion |
| POST   | `/api/auth/finish-step-up`         | token         | Complete WebAuthn assertion, create step-up session |
| POST   | `/api/auth/register-passkey`       | token+step-up or loopback | Add replacement passkey |
| GET    | `/api/auth/audit?days=…`           | admin         | Audit log |
| POST   | `/api/auth/revoke-self`            | token+step-up | Sign out and revoke this device |
| GET    | `/api/auth/rules`                  | loopback only | Debug view of route/message auth registry |
| POST   | `/api/push/subscribe`              | token+respond+step-up | Register a Web Push endpoint |
| DELETE | `/api/push/subscribe/:id`          | token+step-up | Unregister |
| POST   | `/api/push/test`                   | token+step-up | Send a test push |
| GET/PUT | `/api/push/settings`              | token / token+step-up | Read/update quiet hours and thresholds |
| GET    | `/api/wall-e/memories/search?q=…`  | read          | New free-text memory search, if not implemented through MCP proxy |

### 8.2 Modified

- `getAuthToken` (server.js:1181) becomes `resolveAuthContext(req, url)` and
  returns `AuthContext` by looking up the token hash and step-up session hash.
  Fail closed.
- `/api/*` gate (server.js:1276) — additionally checks scope per route
  through the default-deny registry; 403 on insufficient scope or stale step-up.
- WS `connection` (server.js:7837) resolves and stores `ws.auth = AuthContext`;
  every message is checked against the same registry before dispatch.
- All mutating routes — additionally check `ctm_step_up` cookie freshness if
  the request is non-loopback.
- Token query-param redirect remains legacy desktop only and must not mint or
  accept phone device tokens.

### 8.3 Localhost-only routes (unchanged)

- `/hook/*`, `/v1/logs`, `/api/otlp/*` — still localhost-only via
  `isLocalhost(req)` (already enforced).
- `/api/auth/device-claims`, `/api/auth/devices`, `/api/auth/rules` —
  loopback-only unless explicitly marked admin+step-up.
- oauth-proxy on :3458 — not exposed.

### 8.4 Wall-E integration

No change to Wall-E. CTM continues to proxy `/api/wall-e/*` via
`walleClient.proxyHttp`. The phone never talks to :3457 directly — it goes
through CTM:3456, inheriting CTM auth.

Route inventory note: current Wall-E has `GET /api/wall-e/memories` for recent
memories and `GET /api/wall-e/tasks` for task lists. It does not currently have
`POST /api/wall-e/memories/search`; add the exact endpoint above or wire mobile
search to an existing Wall-E MCP/search API before implementing the Search tab.

---

## 9. Phased rollout

Ship in **7 small batches**, each independently shippable and testable. The
first two batches do not expose phone control; they make the security boundary
and secure context real before onboarding.

### Batch 1 — Auth registry + route inventory (≈ 1 day)

- Add `AuthRule` registry for HTTP routes and WS message types.
- Add route/message inventory tests that fail on unclassified handlers.
- Keep current `config.token` behavior, but route it through `AuthContext`.
- Confirm `/hook/*`, `/v1/logs`, `/api/otlp/*`, and oauth-proxy remain
  loopback-only.

**Acceptance**: no behavior change for localhost desktop; a synthetic remote
`read` token can subscribe/read but cannot call any mutation or WS `input`.

### Batch 2 — Tailscale binding + HTTPS plumbing (≈ 1 day)

- Add `CTM_HOST` to `setup.html` page so user can switch to
  the specific Tailscale IP/interface. Do not offer `0.0.0.0`.
- Document `tailscale cert` flow in `claude-task-manager/docs/phone-setup.md`.
- Warn about Certificate Transparency and sensitive machine names.
- Add HTTPS option to CTM HTTP server: if `CTM_TLS_CERT` + `CTM_TLS_KEY`
  envs are present, serve TLS; else HTTP (loopback today).
- Add Origin allowlist + reject mismatched Origin on mutating routes.
- Manual-test: visit `https://ctm-mac.tailNNNN.ts.net:3456/setup.html#devices`
  from desktop, verify Let's Encrypt cert chain.

**Acceptance**: no regression in localhost mode; tailnet mode works from
another tailnet device; LAN IP access fails.

### Batch 3 — Device claims + passkey enrollment (≈ 2 days)

- Migrations: `ctm_device_tokens`, `ctm_device_claims`,
  `ctm_webauthn_credentials`, `ctm_step_up_sessions`, `ctm_auth_audit`.
- Loopback-only device claim endpoints and setup-page QR UI.
- `/m/claim` pairing page with secure-context guard.
- WebAuthn registration via `@simplewebauthn/server` +
  `@simplewebauthn/browser`.
- Cookie attribute handling for HTTPS tailnet vs localhost dev.
- Migrate existing `config.token` into a loopback-only legacy device row.

**Acceptance**: phone scans QR over HTTPS, registers passkey, receives a
device cookie, and cannot reuse the same QR claim.

### Batch 4 — Step-up mutations + revocation (≈ 2 days)

- New `/api/auth/begin-step-up`, `/api/auth/finish-step-up`,
  `/api/auth/register-passkey`, `/api/auth/revoke-self`.
- Decorator on mutating routes and WS dispatch:
  `requireScopeAndStepUp(auth, rule)`.
- Revocation closes WS clients by `deviceId` and deletes step-up sessions.
- Rate limiting + IP lockout.
- Test on iOS Safari + Chrome desktop with USB security key.

**Acceptance**: from the phone, every remote mutation prompts Face ID when
step-up is stale. Read-only ops do not. Revoking the phone disconnects it
within 1 s.

### Batch 5 — Mobile PWA shell + read/respond UX (≈ 4 days)

- Build out `/m/` shell.
- Implement Status, Agents, Wall-E chat, Search tabs.
- Implement detail view + WS subscribe-stream + send/approve/deny.
- Treat WebSocket lifecycle messages as the foreground status source and keep
  `/api/sessions/standup` polling as a recovery fallback.
- Hydrate detail history in the background and replay the live tail after
  history renders.
- New-agent flow.
- Service worker with shell caching.
- Reuse `message-renderer.js` strictly (per the parity rule in user memory).
- Test on iOS Safari (Add to Home Screen → standalone) and Chrome on Android.

**Acceptance**: full flow works — sit on couch, see agent waiting, approve
with Face ID, agent resumes within 2 s.

### Batch 6 — Web Push notifications (≈ 2 days)

- VAPID keys generation on first start.
- `web-push` lib integration.
- Subscription store table.
- Notification dispatcher hooked into `SessionStateBus`, approval detection,
  `sessionEvents`, and Wall-E task transitions.
- Quiet hours config (`/m/` settings).

**Acceptance**: phone is locked, agent reaches `waiting_input`, lock-screen
banner appears within 5 s; tap → unlocks → opens to detail.

### Batch 7 (Removed) — Cloudflare Tunnel + Access fallback

2026-05-27 decision: do not implement this batch for CTM remote access. It is
kept here only to explain old references in the risk register.

- Documentation + optional settings.
- Configure expected team domain, Access application AUD, allowed email, and
  JWKS URL.
- Verify `Cf-Access-Jwt-Assertion` fail-closed before CTM device-token auth.
- Add per-origin passkey enrollment for `ctm.example.com` because the tailnet
  passkey credential cannot be reused across RP IDs.
- Add JWKS cache/refresh tests for Cloudflare key rotation.

---

## 10. Risk register + decisions

### 10.1 Risks

| ID  | Risk                                                                | Mitigation |
|-----|---------------------------------------------------------------------|------------|
| R1  | Tailscale control plane outage → can't connect                     | Cache last cert; doc the CF fallback; offline mode reads cached status |
| R2  | Raw token leaks via QR, logs, screenshots, or browser history       | QR contains only short-lived claim id + secret; long-lived token is minted only after passkey registration |
| R3  | iOS demotes PWA push (iOS upgrades sometimes break PWAs)            | Ship a Telegram / iMessage notification fallback (existing Claude-Code-Remote pattern) |
| R4  | User loses the only passkey → locked out of mutations              | Loopback-only "reset passkey" admin path; recovery codes printed at setup |
| R5  | A malicious browser tab CSRF-hits CTM                               | SameSite=Strict, mandatory Origin allowlist for mutations/WS, Sec-Fetch-Site when present |
| R6  | An idle WS connection from the phone holds resources               | Heartbeat ping every 30 s; close after 90 s silence |
| R7  | Any remote agent input becomes shell execution indirectly           | Every remote `input`, approval, create, kill, and side-effecting Wall-E message requires fresh step-up |
| R8  | Push subscription contains private endpoint URLs (Apple bug 2023)  | Store hashed endpoint, original encrypted at rest |
| R9  | Mobile UI drifts from desktop conversation rendering               | Hard rule: both use `message-renderer.js`; CI lint fails if mobile imports its own renderer (matches existing parity rule) |
| R10 | "I revoke all tokens" panic button locks user out of phone in field | Loopback-only "regenerate device token" flow lets user re-pair quickly |
| R11 | CTM accidentally binds to LAN via `0.0.0.0`                         | Settings only allow loopback or detected Tailscale IP; tests assert LAN access is refused |
| R12 | A new HTTP route or WS message bypasses scope/step-up               | Default-deny auth registry; inventory test fails on unclassified routes/messages |
| R13 | Cloudflare fallback accepts forged/wrong-audience JWT               | Verify `Cf-Access-Jwt-Assertion` issuer, AUD, signature, email, expiry, and JWKS rotation fail-closed |
| R14 | Idle hook creates noisy or false high-priority notifications        | High-priority push only from `waiting_input`, approval detection, crash, or explicit task transition |
| R15 | Offline phone replays stale mutation after reconnect                | Do not queue mutations offline; user must re-open action and step up again |
| R16 | Passkey registered on tailnet origin fails on Cloudflare origin     | Store `rp_id`/origin per credential; require separate credential enrollment per origin |
| R17 | App-gated Dev Tunnel is reachable by URL (no edge sign-in) | The unguessable tunnel URL is not the gate — CTM's device token + per-origin passkey + step-up + default-deny route registry + rate-limit/lockout + audit are. For a stronger perimeter, opt into private (desktop browser only — breaks the phone PWA) or use Cloudflare Access / Tailscale |
| R18 | User mistakes passkey for Microsoft account verification            | UI and docs say Microsoft/GitHub identity is checked by Dev Tunnels; CTM passkey only proves a registered credential for this origin |

### 10.2 Decisions from review

1. ~~**Fly.io question.**~~ **Resolved (2026-05-05): no Fly dependency.** Mac
   reaches APNS/FCM, Cloudflare, and Tailscale directly over outbound HTTPS —
   none of the load-bearing components benefit from a Fly relay, and adding
   one would create a cloud-controlled revoke channel that itself becomes an
   attack surface. Recovery from a lost phone uses Tailscale device revoke
   plus loopback CTM revoke when back at the Mac, rather than a Fly panic page.
   Existing Fly deployments (`walle.sh`, `create-walle.fly.dev`) are unrelated
   and stay as-is.

2. ~~**Per-tab scope per device, or global?**~~ **Resolved (2026-05-05):
   global scopes.** Scope is `read | respond | create | admin`, applied
   globally to a device token. No per-worktree gating. Simpler schema, simpler
   UI; users who want to "share read-only" make a `read` token.

3. ~~**Approve-via-notification-action?**~~ **Resolved: no lock-screen
   approve/deny.** Notifications deep-link to the in-app approval panel. The
   app still requires Face ID step-up before approving, denying, or sending.

4. ~~**xterm.js terminal on phone, or message stream only?**~~ **Resolved:
   message stream only for v1.** The desktop has
   full terminal via xterm + node-pty. On a phone, that's a bad UX (no
   keyboard for full control keys, poor layout). Raw terminal is read-only
   snapshot at most. Small reviewed controls such as `session.cancel_stream`
   can be exposed as typed remote actions without becoming a generic terminal.

5. ~~**Coding-agent-spawn UX.**~~ **Resolved: simple spawn.** Phone supports
   "preset + project/worktree + optional prompt." Full desktop preset editing
   remains desktop-only.

6. ~~**Wall-E direct vs proxied.**~~ **Resolved: proxied only.** Phone → CTM →
   Wall-E keeps one auth surface. Direct phone → Wall-E would be a second
   remote-control perimeter and is deferred.

7. ~~**Recovery secret.**~~ **Resolved: no cloud revoke in v1.** If phone is
   lost, revoke the phone in Tailscale admin first, then revoke CTM token from
   the Mac when available. CTM also supports loopback-only recovery codes for
   passkey reset/re-pairing, but not remote cloud token revocation.

8. ~~**Notification noise budget.**~~ **Resolved default:** waiting/approval
   always pushes; crash always pushes; finished pushes only if task ran longer
   than 60 s; Wall-E completion is low priority unless the task explicitly
   requests review.

---

## 11. Implementation sequence (concrete)

```
[Day 1]     Batch 1: auth registry + route/message inventory
[Day 2]     Batch 2: Tailscale binding + HTTPS secure context
[Day 3-4]   Batch 3: one-time device claims + passkey enrollment
[Day 5-6]   Batch 4: step-up enforcement + revocation + rate limits
[Day 7-10]  Batch 5: mobile PWA shell + phone UX flows
[Day 11-12] Batch 6: Web Push notification dispatcher
[Day 13]    Batch 7 (optional): CF Access JWT verifier
```

Each batch is a separate PR with green tests; each leaves the system
shippable.

---

## 12. Testing + verification

### 12.1 Automated

- **Auth unit tests** (`tests/auth.test.js`): token issue/lookup/revoke,
  scope enforcement, IP lockout, audit insert.
- **Auth inventory test**: parse/collect every `/api/*` route and WS message
  handler; fail if a remote-reachable handler lacks an `AuthRule`.
- **Device claim tests**: claim expiry, replay rejection, wrong secret,
  canceled claim, and "raw long-lived token never appears in claim URL."
- **Step-up tests** (`tests/webauthn.test.js`): challenge replay, signature
  verification, expiry, RP ID/origin mismatch. Use `@simplewebauthn/types`
  test fixtures.
- **WS scope tests**: a `read`-only token can subscribe but can't `input`; a
  `respond` token still cannot `input` without fresh step-up.
- **Origin tests**: mutation from foreign Origin → 403.
- **Binding tests**: tailnet bind accepts tailnet IP; LAN IP and `0.0.0.0`
  mode are refused unless explicitly in local dev.
- **Push tests**: subscription registration, dispatch via mock APNS, dedupe
  by stable notification id, idle hook does not emit high-priority push.
- **Integration test**: spawn dev CTM on a random port (per CLAUDE.md safety
  rule), curl through Tailscale interface.

### 12.2 Manual checklist (pre-ship)

- [ ] Pen-test from outside tailnet: nmap `your-mac-public-ip` → no port open.
- [ ] Pen-test inside tailnet without token: `curl https://…:3456/api/sessions` → 401.
- [ ] Pen-test with revoked token → 401, audit row written.
- [ ] Pairing QR works once; second scan shows expired/claimed.
- [ ] Step-up bypass attempt: replay an old assertion → 401.
- [ ] WS bypass attempt: connect with `read` token and send `input` → 403/close.
- [ ] CSRF: open a malicious page, try to fetch `/api/sessions/kill` → 403 (Origin).
- [ ] Phone offline → app shell still loads, "stale" banner appears.
- [ ] Phone offline mutation attempt is blocked, not queued.
- [ ] Push delivery during phone-locked / Focus-Mode / Low-Power.
- [ ] Push tap opens exact waiting/approval panel, then Face ID gate appears.
- [ ] Tailscale Mac IP changes (rejoin tailnet) → cookie still valid.
- [ ] LAN client cannot reach CTM on the Mac's Wi-Fi IP.
- [ ] Optional CF path rejects missing JWT, wrong AUD, wrong issuer, expired JWT.
- [ ] Loopback flows on desktop unchanged.

### 12.3 Friend-of-the-family pen test

After Batch 4 ships, give a friend a Tailscale invite + a `read` token and
ask them to break out of read-only. Their job is to escalate. Their failure
is the design's success.

---

## 13. Anti-patterns explicitly avoided

| Pattern                                   | Why it's wrong                                      | What we do instead |
|-------------------------------------------|-----------------------------------------------------|---------------------|
| Bind 0.0.0.0 + token-only (247 project)   | Token in URL is brittle; full RCE if leaked         | Tailscale-interface bind + one-time claim + per-device + step-up |
| ngrok with public URL                     | URL discoverable; logs in third-party               | CF Tunnel + Access if public is required |
| One shared `config.token` for everyone    | Lose phone → rotate → boot all devices              | Per-device tokens, surgical revoke |
| Raw device token in QR URL                 | Screenshots/logs/history can leak a durable secret  | Short-lived one-time claim that mints token after passkey |
| Best-effort route auth                     | One missed route/message is a remote-control bypass | Default-deny HTTP/WS auth registry |
| Email/Discord/Telegram bridge as primary  | Async, no real-time agent steering                  | WS direct; bridges = optional notify channel |
| Cloud relay of session content            | Trust dependency, exfil risk                        | Mac is source of truth; relay is transport only |
| Retrofit desktop UI for mobile            | 13 KLOC of HTML, multi-pane, dead-end               | Sibling `/m/` PWA |
| Skip step-up because "I trust my phone"   | Stolen phone == agent RCE                           | Face ID for every mutation |
| Notification body contains prompt content | Apple/Google relay sees it; lockscreen leak         | Body says "agent waiting"; tap to fetch |

---

## 14. References

- Anthropic Remote Control (official): outbound poll to Anthropic relay.
  https://code.claude.com/docs/en/remote-control
- Tailscale docs — Funnel, MagicDNS, certs.
  https://tailscale.com/docs/features/tailscale-funnel
  https://tailscale.com/docs/how-to/set-up-https-certificates
- Legacy Cloudflare Tunnel + Access references for old threat-model context,
  not for new remote-access implementation.
  https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app/
  https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/
- SimpleWebAuthn (Node passkey lib). https://github.com/MasterKale/SimpleWebAuthn
- Web Push (web-push npm + iOS 16.4+ Home Screen web app push).
  https://github.com/web-push-libs/web-push
  https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/
- WebAuthn secure-context requirement.
  https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential
- `harper.blog` "Claude Code is better on your phone" (community pattern).
- QuivrHQ/247-claude-code-remote (negative example — no app auth).
  https://github.com/QuivrHQ/247-claude-code-remote
- MatthewJamisonJS/claude-on-the-go (LAN-only WS bridge).
  https://github.com/MatthewJamisonJS/claude-on-the-go
- JessyTsui/Claude-Code-Remote (bridge via email/Discord/Telegram).
  https://github.com/JessyTsui/Claude-Code-Remote
- happy.engineering — E2E-encrypted relay model.
- CTM existing code paths cited inline above.

---

## 15. Glossary

- **Tailnet**: your private encrypted network of devices via Tailscale.
- **MagicDNS**: tailnet-internal DNS — resolves `ctm-mac` to `100.x.y.z`.
- **VAPID**: Voluntary Application Server Identification — keys that prove
  your push backend is authorized to send to a subscription.
- **Step-up auth**: requiring a stronger auth (passkey/Face ID) for sensitive
  ops on top of an existing session.
- **WebAuthn / passkey**: browser-mediated public-key auth using device
  Secure Enclave. iOS Face ID is the user-verification step.
- **CF Access**: Cloudflare Zero Trust Access — identity gate at the edge.
- **JWKS**: JSON Web Key Set — public keys used to verify JWTs.
- **Origin (CSRF defense)**: browser-set header naming the page that issued
  the request; can't be spoofed by JS.
