158 lines
5.8 KiB
Markdown
158 lines
5.8 KiB
Markdown
# Architecture
|
|
|
|
## Overview
|
|
|
|
`pi-remote-control` is a pi extension that exposes a running pi session over
|
|
HTTP/WebSocket. The machine running pi is the **host**; any browser that
|
|
connects is a **guest**.
|
|
|
|
The server supports two transport modes. In **Surge Ponte** mode it binds to
|
|
`127.0.0.1` and is reached through a localhost proxy. In **Tailscale** mode it
|
|
binds to `0.0.0.0` and is reachable via the Tailscale tailnet IP, making it
|
|
accessible from any device on your tailnet including Android and iOS. The guest
|
|
interacts through a self-contained single-page app served inline — no CDN, no
|
|
external assets.
|
|
|
|
```mermaid
|
|
graph LR
|
|
subgraph Host["Host machine"]
|
|
PI["pi process"]
|
|
EXT["remote-control extension"]
|
|
SRV["HTTP + WS server"]
|
|
PI -->|events| EXT
|
|
EXT -->|broadcast| SRV
|
|
SRV -->|sendUserMessage / abort| PI
|
|
end
|
|
|
|
TUNNEL["Surge Ponte or Tailscale"]
|
|
SRV <-->|localhost or tailnet| TUNNEL
|
|
|
|
subgraph Guest["Guest browser"]
|
|
UI["Single-page app"]
|
|
end
|
|
|
|
TUNNEL <-->|HTTPS / WSS| UI
|
|
```
|
|
|
|
### Transport modes
|
|
|
|
| Mode | Bind address | Reachable via | Notes |
|
|
|------|-------------|---------------|-------|
|
|
| Surge Ponte | `127.0.0.1` | Surge Ponte hostname | macOS only |
|
|
| Tailscale | `0.0.0.0` | Tailscale IP (100.x.y.z) | Any OS, works on Android/iOS |
|
|
|
|
In Tailscale mode the server binds to `0.0.0.0` so the Tailscale virtual
|
|
interface can reach the port; access is still protected by the one-time token
|
|
and session cookie auth flow.
|
|
|
|
## Files
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `extensions/pi-remote-control/index.ts` | Extension entry point — registers flag, command, and event bridge |
|
|
| `extensions/pi-remote-control/server.ts` | HTTP + WebSocket server: `startServer` (Surge), `startServerTailscale` (Tailscale), auth enforcement, client management |
|
|
| `extensions/pi-remote-control/messages.ts` | Wire protocol: serialize session entries → `RenderMsg`, build `sync` payloads |
|
|
| `extensions/pi-remote-control/html.ts` | Inline single-page web UI (self-contained, no external deps) |
|
|
| `extensions/pi-remote-control/auth.ts` | One-time token generation/validation, session cookie helpers |
|
|
| `extensions/pi-remote-control/config.ts` | Read/write `remote-control.json`, transport mode config, Tailscale IP detection (`detectTailscaleIp`), URL normalization |
|
|
|
|
## Authentication Flow
|
|
|
|
First-visit authentication exchanges the one-time token for a 24-hour session
|
|
cookie, keeping the token out of browser history and subsequent requests.
|
|
Note that the initial `GET /?token=TOKEN` may still be recorded by any proxy
|
|
or tunnel sitting in front of the server before the 302 redirect issues the cookie.
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
actor Guest
|
|
participant Server
|
|
|
|
Note over Guest: Has URL with ?token=TOKEN
|
|
|
|
Guest->>Server: GET /?token=TOKEN
|
|
Server->>Server: validateToken() - timing-safe compare
|
|
Server-->>Guest: 302 to / + Set-Cookie pi_rc_session=ID HttpOnly
|
|
|
|
Guest->>Server: GET / (cookie only)
|
|
Server-->>Guest: 200 HTML with CSP nonce
|
|
|
|
Guest->>Server: GET /ws (cookie in header)
|
|
Server->>Server: isAuthenticated() - checks session map
|
|
Server-->>Guest: 101 Switching Protocols
|
|
|
|
Note over Server,Guest: WebSocket open, full sync sent immediately
|
|
```
|
|
|
|
**Security headers on every HTML response:**
|
|
- `Content-Security-Policy` with a per-request nonce (no `unsafe-inline`)
|
|
- `X-Frame-Options: DENY`
|
|
- `X-Content-Type-Options: nosniff`
|
|
- `Referrer-Policy: no-referrer`
|
|
|
|
## Wire Protocol (WebSocket)
|
|
|
|
### Host → Guest
|
|
|
|
| Message type | Payload fields | When sent |
|
|
|---|---|---|
|
|
| `sync` | `messages[]`, `state` (`isStreaming`, `model`, `cwd`, `sessionName`) | On connect, session switch, model change |
|
|
| `message_update` | `message: RenderMsg` | Streaming assistant turn (partial) |
|
|
| `message_end` | `message: RenderMsg` | Assistant turn finalized |
|
|
| `tool_start` | `toolCallId`, `toolName`, `args` | Tool execution begins |
|
|
| `tool_end` | `toolCallId`, `result`, `isError` | Tool execution completes |
|
|
| `agent_start` | — | Agent turn starts |
|
|
| `agent_end` | — | Agent turn ends |
|
|
| `status` | `clientCount` | Client disconnects |
|
|
|
|
### Guest → Host
|
|
|
|
| Message type | Payload | Effect |
|
|
|---|---|---|
|
|
| `prompt` | `text: string` | `pi.sendUserMessage(text)` — as normal message when idle, as follow-up when busy |
|
|
| `stop` | — | `ctx.abort()` — cancels the current agent turn |
|
|
|
|
**Per-connection server-side rate limit:** max 30 prompt messages per 60-second
|
|
sliding window; messages over 64 KB are silently dropped. The limit applies
|
|
per WebSocket connection — opening multiple connections multiplies it.
|
|
|
|
### RenderMsg shape
|
|
|
|
```typescript
|
|
interface RenderMsg {
|
|
id: string; // SessionEntry id, or "pending" while streaming
|
|
role: "user" | "assistant" | "tool_result";
|
|
text: string;
|
|
toolCalls?: Array<{ id: string; name: string; args: string }>;
|
|
toolName?: string; // tool_result only
|
|
toolCallId?: string; // tool_result only
|
|
isError?: boolean; // tool_result only
|
|
model?: string; // assistant only
|
|
}
|
|
```
|
|
|
|
## Session Lifecycle Events
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
SS["session_start"] -->|--remote-control flag| MODE{"Transport?"}
|
|
MODE -->|Surge Ponte| START["startServer()"]
|
|
MODE -->|Tailscale| START_TS["startServerTailscale()"]
|
|
START --> SYNC0["sync all clients"]
|
|
|
|
SW["session_switch"] --> SYNC1["scheduleSync()"]
|
|
MS["model_select"] -->|when idle| SYNC1
|
|
|
|
AGs["agent_start"] --> B1["broadcast agent_start"]
|
|
AGe["agent_end"] --> B2["broadcast agent_end"]
|
|
MU["message_update"] --> B3["broadcast message_update"]
|
|
ME["message_end"] --> B4["broadcast message_end"]
|
|
TS["tool_execution_start"] --> B5["broadcast tool_start"]
|
|
TE["tool_execution_end"] --> B6["broadcast tool_end"]
|
|
|
|
SD["session_shutdown"] --> STOP["server.stop()"]
|
|
```
|
|
|
|
|
|
|