feat: add Tailscale transport mode for Android/iOS access

- Add transport mode config (surge/tailscale) to remote-control.json
- Add detectTailscaleIp() with CLI and local API fallbacks
- Add startServerTailscale() binding to 0.0.0.0 (token-protected)
- Add Transport toggle in /remote-control menu
- Update README with Tailscale setup + Android connection guide
- Update ARCHITECTURE.md with dual transport documentation
This commit is contained in:
Marc 2026-04-11 13:38:04 -06:00
parent 8cffeb9e27
commit a0713e8a02
7 changed files with 424 additions and 52 deletions

View File

@ -16,9 +16,10 @@ Run `/remote-control` to open the menu:
- **Turn on / Turn off** — start or stop the server - **Turn on / Turn off** — start or stop the server
- **Configure URL** — set the base URL exposed by your local tunnel or proxy, saved to `~/.pi/agent/remote-control.json` - **Configure URL** — set the base URL exposed by your local tunnel or proxy, saved to `~/.pi/agent/remote-control.json`
- **Transport** — switch between Surge Ponte and Tailscale
- **Status** — show the QR code and connection URL (only when server is running) - **Status** — show the QR code and connection URL (only when server is running)
> **Note:** On first use, you must configure the URL before the server can start. > **Note:** On first use, you must configure the URL (Surge Ponte) or have Tailscale running before the server can start.
To start the server automatically on launch: To start the server automatically on launch:
@ -26,11 +27,11 @@ To start the server automatically on launch:
pi --remote-control pi --remote-control
``` ```
## Use case ## Transport modes
The remote-control server binds to `127.0.0.1` on the host running `pi` and is reached through a local tunnel or proxy. This example uses [Surge Ponte](https://kb.nssurge.com/surge-knowledge-base/guidelines/ponte), which provides an end-to-end encrypted device-to-device tunnel without exposing the server to the LAN. ### Surge Ponte (default)
The setup is: The server binds to `127.0.0.1` and is reached through Surge Ponte, which provides an end-to-end encrypted device-to-device tunnel without exposing the server to the LAN.
1. Install this extension on the Mac that runs `pi`. 1. Install this extension on the Mac that runs `pi`.
2. Enable Surge Ponte on that Mac and give it a device name such as `pi`. 2. Enable Surge Ponte on that Mac and give it a device name such as `pi`.
@ -42,12 +43,43 @@ The setup is:
In this setup, the browser URL is `http://pi.sgponte:<port>`, where the port is assigned when the server starts. Use `Status` to get the current URL or scan the QR code — it changes each time the server restarts. In this setup, the browser URL is `http://pi.sgponte:<port>`, where the port is assigned when the server starts. Use `Status` to get the current URL or scan the QR code — it changes each time the server restarts.
### Tailscale
The server binds to `0.0.0.0` (protected by token auth) and is reachable via your Tailscale tailnet IP. This works on any OS with Tailscale installed — Linux, macOS, Windows — and any device on your tailnet, including Android and iOS.
#### Setup (host machine running pi)
1. Install and log in to Tailscale:
```bash
# Linux
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
# macOS
brew install --cask tailscale
# Then open Tailscale from Applications and sign in
```
2. In `pi`, run `/remote-control`.
3. Choose **Transport: Surge Ponte** to switch it to **Transport: Tailscale ✓**.
4. Choose **Turn on**.
5. The connection URL will be shown as `http://100.x.y.z:<port>/?token=...` with a QR code.
#### Connect from Android
1. Install [Tailscale](https://play.google.com/store/apps/details?id=com.tailscale.ipn) from the Play Store and sign in to the same tailnet.
2. Open `pi` on the host, run `/remote-control`, choose **Turn on**.
3. Choose **Status** to see the QR code and URL.
4. On Android, open the Tailscale app to confirm the host device is online, then open the URL shown in `pi`'s status in Chrome (or scan the QR code with your phone's camera).
5. The web UI loads — you can now send messages and stop the agent from your phone.
> **Tip:** Bookmark the URL in Chrome on your phone so you can reconnect quickly. The session cookie lasts 24 hours. When the server restarts, you'll need the new token URL.
Here's what it looks like on iPhone — this is an actual session asking `pi` about its hardware environment: Here's what it looks like on iPhone — this is an actual session asking `pi` about its hardware environment:
<img src="assets/screenshot-mobile.png" width="300" alt="pi remote control on iPhone via pi.sgponte"> <img src="assets/screenshot-mobile.png" width="300" alt="pi remote control on iPhone via pi.sgponte">
## Security notes ## Security notes
- The server only listens on localhost. Remote access depends on whatever local tunnel or proxy you configure. - The server only listens on localhost in Surge Ponte mode. In Tailscale mode, it binds to `0.0.0.0` but is only reachable via the Tailscale virtual interface (which enforces its own ACLs).
- There is no multi-user authentication. Treat the connection URL as a secret for the lifetime of the session. - There is no multi-user authentication. Treat the connection URL as a secret for the lifetime of the session.
- If you use a reverse proxy instead of Surge Ponte, configure it to terminate TLS at a fixed `https://` endpoint and forward to the server's dynamic backend port. Do not expose the dynamic port directly over a public network, as the server does not support HTTPS and any token or session cookie would be transmitted in cleartext. - If you use a reverse proxy instead of Surge Ponte, configure it to terminate TLS at a fixed `https://` endpoint and forward to the server's dynamic backend port. Do not expose the dynamic port directly over a public network, as the server does not support HTTPS and any token or session cookie would be transmitted in cleartext.

View File

@ -6,42 +6,55 @@
HTTP/WebSocket. The machine running pi is the **host**; any browser that HTTP/WebSocket. The machine running pi is the **host**; any browser that
connects is a **guest**. connects is a **guest**.
The server binds to `127.0.0.1` only (never the LAN). A local proxy or tunnel The server supports two transport modes. In **Surge Ponte** mode it binds to
(e.g. Surge Ponte) forwards external traffic to it. The guest interacts `127.0.0.1` and is reached through a localhost proxy. In **Tailscale** mode it
through a self-contained single-page app served inline — no CDN, no external binds to `0.0.0.0` and is reachable via the Tailscale tailnet IP, making it
assets. 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 ```mermaid
graph LR graph LR
subgraph Host["Host machine"] subgraph Host["Host machine"]
PI["pi process"] PI["pi process"]
EXT["remote-control extension"] EXT["remote-control extension"]
SRV["HTTP + WS server — 127.0.0.1:port"] SRV["HTTP + WS server"]
PI -->|events| EXT PI -->|events| EXT
EXT -->|broadcast| SRV EXT -->|broadcast| SRV
SRV -->|sendUserMessage / abort| PI SRV -->|sendUserMessage / abort| PI
end end
PROXY["Surge Ponte"] TUNNEL["Surge Ponte or Tailscale"]
SRV <-->|localhost| PROXY SRV <-->|localhost or tailnet| TUNNEL
subgraph Guest["Guest browser"] subgraph Guest["Guest browser"]
UI["Single-page app"] UI["Single-page app"]
end end
PROXY <-->|HTTPS / WSS| UI 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 ## Files
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `extensions/pi-remote-control/index.ts` | Extension entry point — registers flag, command, and event bridge | | `extensions/pi-remote-control/index.ts` | Extension entry point — registers flag, command, and event bridge |
| `extensions/pi-remote-control/server.ts` | HTTP + WebSocket server, auth enforcement, client management | | `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/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/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/auth.ts` | One-time token generation/validation, session cookie helpers |
| `extensions/pi-remote-control/config.ts` | Read/write `remote-control.json`, public URL normalization and config UI | | `extensions/pi-remote-control/config.ts` | Read/write `remote-control.json`, transport mode config, Tailscale IP detection (`detectTailscaleIp`), URL normalization |
## Authentication Flow ## Authentication Flow
@ -122,7 +135,9 @@ interface RenderMsg {
```mermaid ```mermaid
flowchart TD flowchart TD
SS["session_start"] -->|--remote-control flag| START["startServer()"] SS["session_start"] -->|--remote-control flag| MODE{"Transport?"}
MODE -->|Surge Ponte| START["startServer()"]
MODE -->|Tailscale| START_TS["startServerTailscale()"]
START --> SYNC0["sync all clients"] START --> SYNC0["sync all clients"]
SW["session_switch"] --> SYNC1["scheduleSync()"] SW["session_switch"] --> SYNC1["scheduleSync()"]

View File

@ -12,8 +12,11 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
const REMOTE_CONTROL_CONFIG_FILE = "remote-control.json"; const REMOTE_CONTROL_CONFIG_FILE = "remote-control.json";
export type TransportMode = "surge" | "tailscale";
export interface RemoteControlConfig { export interface RemoteControlConfig {
publicBaseUrl?: string; publicBaseUrl?: string;
transport?: TransportMode;
} }
function getAgentDir(): string { function getAgentDir(): string {
@ -54,7 +57,7 @@ export async function readRemoteControlConfig(): Promise<RemoteControlConfig> {
} }
} }
async function writeRemoteControlConfig(config: RemoteControlConfig): Promise<void> { export async function writeRemoteControlConfig(config: RemoteControlConfig): Promise<void> {
const configPath = getRemoteControlConfigPath(); const configPath = getRemoteControlConfigPath();
await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8"); await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
@ -104,3 +107,50 @@ export async function configureRemoteControlUI(ctx: ExtensionContext): Promise<v
await writeRemoteControlConfig({ publicBaseUrl: value }); await writeRemoteControlConfig({ publicBaseUrl: value });
ctx.ui.notify(`Saved remote-control URL to ${getRemoteControlConfigPath()}`, "info"); ctx.ui.notify(`Saved remote-control URL to ${getRemoteControlConfigPath()}`, "info");
} }
// ── Tailscale helpers ────────────────────────────────────────────────────────
/**
* Detect whether tailscale is installed and running on this machine.
* Returns the Tailscale IPv4 address, or null if tailscale is not available.
*/
export async function detectTailscaleIp(): Promise<string | null> {
// Method 1: tailscale CLI
const { execSync } = await import("node:child_process");
try {
const ip = execSync("tailscale ip -4", { encoding: "utf8", timeout: 5000 }).trim();
if (ip && /^100\.\d+\.\d+\.\d+$/.test(ip)) return ip;
} catch {
// tailscale CLI not available or not logged in
}
// Method 2: Tailscale local API (HTTP on 100.100.100.100)
const http = await import("node:http");
return new Promise((resolve) => {
const req = http.get(
"http://100.100.100.100:9090/localapi/v0/status",
{ timeout: 3000 },
(res) => {
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
try {
const status = JSON.parse(body);
const self = status.Self;
if (self?.TailscaleIPs?.length > 0) {
const ip4 = self.TailscaleIPs.find((ip: string) => ip.startsWith("100."));
if (ip4) { resolve(ip4); return; }
}
} catch { /* parse error */ }
resolve(null);
});
},
);
req.on("error", () => resolve(null));
req.on("timeout", () => { req.destroy(); resolve(null); });
});
}
export async function isTailscaleRunning(): Promise<boolean> {
return (await detectTailscaleIp()) !== null;
}

View File

@ -1,9 +1,8 @@
/** /**
* remote-control Expose the running pi session over HTTP/WebSocket. * remote-control Expose the running pi session over HTTP/WebSocket.
* *
* Starts an HTTP + WebSocket server on a free port, bound to 127.0.0.1 (localhost only). * Starts an HTTP + WebSocket server on a free port, bound to 127.0.0.1 (localhost only)
* This is intended to sit behind a local port-forwarding proxy/tunnel that terminates on * for Surge Ponte mode, or 0.0.0.0 for Tailscale mode.
* the same host (for example Tailscale/Surge), rather than accepting direct LAN traffic.
* Access requires a one-time token (?token=...) which sets a session cookie for * Access requires a one-time token (?token=...) which sets a session cookie for
* subsequent requests. Run /remote-control to start the server and display the URL. * subsequent requests. Run /remote-control to start the server and display the URL.
* The browser is expected to use http(s):// and ws(s):// through that proxy. * The browser is expected to use http(s):// and ws(s):// through that proxy.
@ -16,11 +15,15 @@ import { DynamicBorder, keyHint } from "@mariozechner/pi-coding-agent";
import { Container, Text } from "@mariozechner/pi-tui"; import { Container, Text } from "@mariozechner/pi-tui";
import { import {
readRemoteControlConfig, readRemoteControlConfig,
writeRemoteControlConfig,
buildRemoteControlUrl, buildRemoteControlUrl,
configureRemoteControlUI, configureRemoteControlUI,
detectTailscaleIp,
isTailscaleRunning,
type TransportMode,
} from "./config.js"; } from "./config.js";
import { serializeMessage } from "./messages.js"; import { serializeMessage } from "./messages.js";
import { type RemoteServer, startServer } from "./server.js"; import { type RemoteServer, startServer, startServerTailscale } from "./server.js";
// ── Extension entry point ──────────────────────────────────────────────────── // ── Extension entry point ────────────────────────────────────────────────────
@ -30,6 +33,7 @@ const QRCode = _require("qrcode") as { toString: (text: string, opts: any) => Pr
export default function remoteControl(pi: ExtensionAPI) { export default function remoteControl(pi: ExtensionAPI) {
let server: RemoteServer | undefined; let server: RemoteServer | undefined;
let pendingSyncTimer: ReturnType<typeof setTimeout> | undefined; let pendingSyncTimer: ReturnType<typeof setTimeout> | undefined;
let tailscaleIp: string | null = null;
function scheduleSync(ctx: ExtensionContext): void { function scheduleSync(ctx: ExtensionContext): void {
if (pendingSyncTimer) clearTimeout(pendingSyncTimer); if (pendingSyncTimer) clearTimeout(pendingSyncTimer);
@ -66,23 +70,42 @@ export default function remoteControl(pi: ExtensionAPI) {
if (pi.getFlag("remote-control") !== true) return; if (pi.getFlag("remote-control") !== true) return;
const config = await readRemoteControlConfig(); const config = await readRemoteControlConfig();
const publicBaseUrl = config.publicBaseUrl?.trim(); const transport = config.transport ?? "surge";
if (!publicBaseUrl) {
if (ctx.hasUI) { if (transport === "tailscale") {
ctx.ui.notify( tailscaleIp = await detectTailscaleIp();
"--remote-control: no publicBaseUrl configured. Run /remote-control config first.", if (!tailscaleIp) {
"warning", if (ctx.hasUI) {
); ctx.ui.notify(
"--remote-control: Tailscale is not running. Run `tailscale up` first.",
"warning",
);
}
return;
}
server = await startServerTailscale(pi, ctx);
server.onClientChange(() => updateStatus(ctx));
const url = `http://${tailscaleIp}:${server.port}/?token=${server.token}`;
if (ctx.hasUI) {
ctx.ui.notify(`Remote-control started (Tailscale): ${url}`, "info");
}
} else {
const publicBaseUrl = config.publicBaseUrl?.trim();
if (!publicBaseUrl) {
if (ctx.hasUI) {
ctx.ui.notify(
"--remote-control: no publicBaseUrl configured. Run /remote-control config first.",
"warning",
);
}
return;
}
server = await startServer(pi, ctx);
server.onClientChange(() => updateStatus(ctx));
const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token);
if (ctx.hasUI) {
ctx.ui.notify(`Remote-control started: ${url}`, "info");
} }
return;
}
server = await startServer(pi, ctx);
server.onClientChange(() => updateStatus(ctx));
const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token);
if (ctx.hasUI) {
ctx.ui.notify(`Remote-control started: ${url}`, "info");
} }
updateStatus(ctx); updateStatus(ctx);
}); });
@ -166,10 +189,16 @@ export default function remoteControl(pi: ExtensionAPI) {
if (!server) return; if (!server) return;
const config = await readRemoteControlConfig(); const config = await readRemoteControlConfig();
const publicBaseUrl = config.publicBaseUrl?.trim(); const transport = config.transport ?? "surge";
if (!publicBaseUrl) return; let url: string;
const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token); if (transport === "tailscale" && tailscaleIp) {
url = `http://${tailscaleIp}:${server.port}/?token=${server.token}`;
} else {
const publicBaseUrl = config.publicBaseUrl?.trim();
if (!publicBaseUrl) return;
url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token);
}
// Generate QR code // Generate QR code
let qrLines: string[] = []; let qrLines: string[] = [];
@ -208,17 +237,27 @@ export default function remoteControl(pi: ExtensionAPI) {
pi.registerCommand("remote-control", { pi.registerCommand("remote-control", {
description: "Remote control — start/stop server, configure, show connection info", description: "Remote control — start/stop server, configure, show connection info",
handler: async (args, ctx) => { handler: async (_args, ctx) => {
if (!ctx.hasUI) return; if (!ctx.hasUI) return;
const isRunning = !!server; const isRunning = !!server;
const config = await readRemoteControlConfig(); const config = await readRemoteControlConfig();
const transport = config.transport ?? "surge";
const currentUrl = config.publicBaseUrl?.trim(); const currentUrl = config.publicBaseUrl?.trim();
// Detect Tailscale status
const tsRunning = await isTailscaleRunning();
if (tsRunning) tailscaleIp = await detectTailscaleIp();
const configLabel = currentUrl ? `Configure URL (${currentUrl})` : "Configure URL (not set)"; const configLabel = currentUrl ? `Configure URL (${currentUrl})` : "Configure URL (not set)";
const transportLabel = transport === "tailscale"
? `Transport: Tailscale${tsRunning ? " ✓" : " (not running)"}`
: "Transport: Surge Ponte";
const menuItems = [ const menuItems = [
isRunning ? "Turn off" : "Turn on", isRunning ? "Turn off" : "Turn on",
configLabel, configLabel,
transportLabel,
...(isRunning ? ["Status"] : []), ...(isRunning ? ["Status"] : []),
]; ];
@ -226,16 +265,24 @@ export default function remoteControl(pi: ExtensionAPI) {
if (choice === undefined) return; if (choice === undefined) return;
if (choice === "Turn on") { if (choice === "Turn on") {
const publicBaseUrl = currentUrl; if (transport === "tailscale") {
if (!publicBaseUrl) { if (!tsRunning || !tailscaleIp) {
ctx.ui.notify("Set the public URL first — opening config…", "warning"); ctx.ui.notify("Tailscale is not running. Run `tailscale up` first.", "warning");
await configureRemoteControlUI(ctx); return;
// Re-check after config }
const updated = await readRemoteControlConfig(); server = await startServerTailscale(pi, ctx);
if (!updated.publicBaseUrl?.trim()) return; server.onClientChange(() => updateStatus(ctx));
} else {
const publicBaseUrl = currentUrl;
if (!publicBaseUrl) {
ctx.ui.notify("Set the public URL first — opening config…", "warning");
await configureRemoteControlUI(ctx);
const updated = await readRemoteControlConfig();
if (!updated.publicBaseUrl?.trim()) return;
}
server = await startServer(pi, ctx);
server.onClientChange(() => updateStatus(ctx));
} }
server = await startServer(pi, ctx);
server.onClientChange(() => updateStatus(ctx));
updateStatus(ctx); updateStatus(ctx);
ctx.ui.notify("Remote-control server started", "info"); ctx.ui.notify("Remote-control server started", "info");
await showConnectionInfo(ctx); await showConnectionInfo(ctx);
@ -248,6 +295,12 @@ export default function remoteControl(pi: ExtensionAPI) {
} }
} else if (choice === configLabel) { } else if (choice === configLabel) {
await configureRemoteControlUI(ctx); await configureRemoteControlUI(ctx);
} else if (choice === transportLabel) {
// Toggle transport mode
const modes: TransportMode[] = ["surge", "tailscale"];
const next = modes.find(m => m !== transport) ?? "surge";
await writeRemoteControlConfig({ ...config, transport: next });
ctx.ui.notify(`Transport switched to ${next === "tailscale" ? "Tailscale" : "Surge Ponte"}`, "info");
} else if (choice === "Status") { } else if (choice === "Status") {
await showConnectionInfo(ctx); await showConnectionInfo(ctx);
} }

View File

@ -35,6 +35,7 @@ export interface RemoteServer {
onClientChange: (cb: () => void) => void; onClientChange: (cb: () => void) => void;
port: number; port: number;
token: string; token: string;
bindAddress: string;
} }
export function startServer(pi: ExtensionAPI, ctx: ExtensionContext): Promise<RemoteServer> { export function startServer(pi: ExtensionAPI, ctx: ExtensionContext): Promise<RemoteServer> {
@ -258,6 +259,211 @@ export function startServer(pi: ExtensionAPI, ctx: ExtensionContext): Promise<Re
get token() { get token() {
return token; return token;
}, },
get bindAddress() {
return "127.0.0.1";
},
});
});
});
}
/**
* Start a Tailscale-mode server that listens on 0.0.0.0 so the Tailscale
* interface can reach it. The token auth still protects the endpoint.
*/
export function startServerTailscale(pi: ExtensionAPI, ctx: ExtensionContext): Promise<RemoteServer> {
const clientChangeListeners: Array<() => void> = [];
const clients = new Set<any>();
const token = generateToken();
const SESSION_TTL_MS = 86_400_000;
const validSessions = new Map<string, number>();
const pruneExpiredSessions = (): void => {
const now = Date.now();
for (const [id, expiresAt] of validSessions) {
if (expiresAt <= now) validSessions.delete(id);
}
};
function isAuthenticated(req: any): boolean {
const cookies = parseCookies(req.headers.cookie);
const sessionId = cookies[SESSION_COOKIE];
const sessionExpiry = sessionId ? validSessions.get(sessionId) : undefined;
if (sessionExpiry !== undefined && sessionExpiry > Date.now()) return true;
const url = new URL(req.url ?? "/", "http://localhost");
const providedToken = url.searchParams.get("token");
if (providedToken && validateToken(providedToken, token)) return true;
return false;
}
function broadcast(msg: object): void {
const data = JSON.stringify(msg);
for (const client of clients) {
if (client.readyState === OPEN) {
try {
client.send(data);
} catch { /* ignore */ }
}
}
}
function sync(currentCtx: ExtensionContext): void {
broadcast(buildSyncMessage(currentCtx));
}
const httpServer = createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
const pathname = url.pathname;
if (pathname === "/" || pathname === "/index.html") {
const cookies = parseCookies(req.headers.cookie);
const sc = cookies[SESSION_COOKIE];
const hasValidSession = sc !== undefined && (validSessions.get(sc) ?? 0) > Date.now();
const providedToken = url.searchParams.get("token");
const hasValidToken = providedToken && validateToken(providedToken, token);
if (!hasValidSession && !hasValidToken) {
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Forbidden — valid token required. Use the URL shown in the pi terminal.");
return;
}
if (!hasValidSession && hasValidToken) {
pruneExpiredSessions();
const sessionId = generateSessionId();
validSessions.set(sessionId, Date.now() + SESSION_TTL_MS);
res.writeHead(302, {
"Set-Cookie": `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
Location: "/",
});
res.end();
return;
}
const nonce = randomBytes(16).toString("base64");
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"Content-Security-Policy":
`default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'`,
});
res.end(buildHTML(nonce));
} else {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not found");
}
});
const wss = new WebSocketServer({ noServer: true });
httpServer.on("error", (err: Error) => {
console.error("[remote-control] httpServer error:", err.message);
});
wss.on("error", (err: Error) => {
console.error("[remote-control] wss error:", err.message);
});
httpServer.on("upgrade", (request: any, socket: any, head: any) => {
const url = new URL(request.url, "http://localhost");
if (url.pathname === "/ws") {
if (!isAuthenticated(request)) {
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws: any) => {
wss.emit("connection", ws, request);
});
} else {
socket.destroy();
}
});
wss.on("connection", (ws: any) => {
clients.add(ws);
for (const cb of clientChangeListeners) cb();
try {
ws.send(JSON.stringify(buildSyncMessage(ctx)));
} catch { /* client disconnected before first send */ }
const RATE_WINDOW_MS = 60_000;
const RATE_MAX = 30;
const MAX_MSG_BYTES = 64 * 1024;
const recentPrompts: number[] = [];
ws.on("message", (data: any) => {
if (data.length > MAX_MSG_BYTES) return;
let msg: any;
try { msg = JSON.parse(data.toString()); } catch { return; }
if (msg.type === "stop") {
if (!ctx.isIdle()) ctx.abort();
return;
}
if (msg.type === "prompt" && typeof msg.text === "string" && msg.text.trim()) {
const text = msg.text.trim();
const now = Date.now();
const cutoff = now - RATE_WINDOW_MS;
while (recentPrompts.length > 0 && recentPrompts[0] < cutoff) recentPrompts.shift();
if (recentPrompts.length >= RATE_MAX) return;
recentPrompts.push(now);
if (ctx.isIdle()) {
pi.sendUserMessage(text);
} else {
pi.sendUserMessage(text, { deliverAs: "followUp" });
}
}
});
const onClose = () => {
clients.delete(ws);
broadcast({ type: "status", clientCount: clients.size });
for (const cb of clientChangeListeners) cb();
};
ws.on("close", onClose);
ws.on("error", onClose);
});
return new Promise((resolve) => {
// Bind to 0.0.0.0 so the Tailscale virtual interface can reach the port.
// Auth is enforced via token + session cookie.
httpServer.listen(0, "0.0.0.0", () => {
resolve({
broadcast,
sync,
stop: () =>
new Promise<void>((res) => {
for (const client of clients) {
try { client.terminate(); } catch { /* ignore */ }
}
clients.clear();
const timeout = setTimeout(() => {
httpServer.close(() => {});
httpServer.closeAllConnections?.();
res();
}, 2000);
wss.close(() =>
httpServer.close(() => {
clearTimeout(timeout);
res();
}),
);
}),
clientCount: () => clients.size,
onClientChange: (cb: () => void) => { clientChangeListeners.push(cb); },
get port() {
return (httpServer.address() as any)?.port ?? 0;
},
get token() {
return token;
},
get bindAddress() {
return "0.0.0.0";
},
}); });
}); });
}); });

17
package-lock.json generated
View File

@ -13,7 +13,8 @@
"ws": "^8.0.0" "ws": "^8.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/qrcode": "^1.5.6" "@types/qrcode": "^1.5.6",
"typescript": "^6.0.2"
}, },
"peerDependencies": { "peerDependencies": {
"@mariozechner/pi-coding-agent": "*", "@mariozechner/pi-coding-agent": "*",
@ -3795,6 +3796,20 @@
"license": "0BSD", "license": "0BSD",
"peer": true "peer": true
}, },
"node_modules/typescript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/uint8array-extras": { "node_modules/uint8array-extras": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",

View File

@ -15,6 +15,7 @@
"@mariozechner/pi-tui": "*" "@mariozechner/pi-tui": "*"
}, },
"devDependencies": { "devDependencies": {
"@types/qrcode": "^1.5.6" "@types/qrcode": "^1.5.6",
"typescript": "^6.0.2"
} }
} }