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:
parent
8cffeb9e27
commit
a0713e8a02
42
README.md
42
README.md
|
|
@ -16,9 +16,10 @@ Run `/remote-control` to open the menu:
|
|||
|
||||
- **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`
|
||||
- **Transport** — switch between Surge Ponte and Tailscale
|
||||
- **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:
|
||||
|
||||
|
|
@ -26,11 +27,11 @@ To start the server automatically on launch:
|
|||
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`.
|
||||
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.
|
||||
|
||||
### 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:
|
||||
|
||||
<img src="assets/screenshot-mobile.png" width="300" alt="pi remote control on iPhone via pi.sgponte">
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -6,42 +6,55 @@
|
|||
HTTP/WebSocket. The machine running pi is the **host**; any browser that
|
||||
connects is a **guest**.
|
||||
|
||||
The server binds to `127.0.0.1` only (never the LAN). A local proxy or tunnel
|
||||
(e.g. Surge Ponte) forwards external traffic to it. The guest interacts
|
||||
through a self-contained single-page app served inline — no CDN, no external
|
||||
assets.
|
||||
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 — 127.0.0.1:port"]
|
||||
SRV["HTTP + WS server"]
|
||||
PI -->|events| EXT
|
||||
EXT -->|broadcast| SRV
|
||||
SRV -->|sendUserMessage / abort| PI
|
||||
end
|
||||
|
||||
PROXY["Surge Ponte"]
|
||||
SRV <-->|localhost| PROXY
|
||||
TUNNEL["Surge Ponte or Tailscale"]
|
||||
SRV <-->|localhost or tailnet| TUNNEL
|
||||
|
||||
subgraph Guest["Guest browser"]
|
||||
UI["Single-page app"]
|
||||
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
|
||||
|
||||
| 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, 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/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`, 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
|
||||
|
||||
|
|
@ -122,7 +135,9 @@ interface RenderMsg {
|
|||
|
||||
```mermaid
|
||||
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"]
|
||||
|
||||
SW["session_switch"] --> SYNC1["scheduleSync()"]
|
||||
|
|
|
|||
|
|
@ -12,8 +12,11 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|||
|
||||
const REMOTE_CONTROL_CONFIG_FILE = "remote-control.json";
|
||||
|
||||
export type TransportMode = "surge" | "tailscale";
|
||||
|
||||
export interface RemoteControlConfig {
|
||||
publicBaseUrl?: string;
|
||||
transport?: TransportMode;
|
||||
}
|
||||
|
||||
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();
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
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 });
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
/**
|
||||
* 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).
|
||||
* This is intended to sit behind a local port-forwarding proxy/tunnel that terminates on
|
||||
* the same host (for example Tailscale/Surge), rather than accepting direct LAN traffic.
|
||||
* Starts an HTTP + WebSocket server on a free port, bound to 127.0.0.1 (localhost only)
|
||||
* for Surge Ponte mode, or 0.0.0.0 for Tailscale mode.
|
||||
* 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.
|
||||
* 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 {
|
||||
readRemoteControlConfig,
|
||||
writeRemoteControlConfig,
|
||||
buildRemoteControlUrl,
|
||||
configureRemoteControlUI,
|
||||
detectTailscaleIp,
|
||||
isTailscaleRunning,
|
||||
type TransportMode,
|
||||
} from "./config.js";
|
||||
import { serializeMessage } from "./messages.js";
|
||||
import { type RemoteServer, startServer } from "./server.js";
|
||||
import { type RemoteServer, startServer, startServerTailscale } from "./server.js";
|
||||
|
||||
// ── Extension entry point ────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -30,6 +33,7 @@ const QRCode = _require("qrcode") as { toString: (text: string, opts: any) => Pr
|
|||
export default function remoteControl(pi: ExtensionAPI) {
|
||||
let server: RemoteServer | undefined;
|
||||
let pendingSyncTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let tailscaleIp: string | null = null;
|
||||
|
||||
function scheduleSync(ctx: ExtensionContext): void {
|
||||
if (pendingSyncTimer) clearTimeout(pendingSyncTimer);
|
||||
|
|
@ -66,23 +70,42 @@ export default function remoteControl(pi: ExtensionAPI) {
|
|||
if (pi.getFlag("remote-control") !== true) return;
|
||||
|
||||
const config = await readRemoteControlConfig();
|
||||
const publicBaseUrl = config.publicBaseUrl?.trim();
|
||||
if (!publicBaseUrl) {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(
|
||||
"--remote-control: no publicBaseUrl configured. Run /remote-control config first.",
|
||||
"warning",
|
||||
);
|
||||
const transport = config.transport ?? "surge";
|
||||
|
||||
if (transport === "tailscale") {
|
||||
tailscaleIp = await detectTailscaleIp();
|
||||
if (!tailscaleIp) {
|
||||
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);
|
||||
});
|
||||
|
|
@ -166,10 +189,16 @@ export default function remoteControl(pi: ExtensionAPI) {
|
|||
if (!server) return;
|
||||
|
||||
const config = await readRemoteControlConfig();
|
||||
const publicBaseUrl = config.publicBaseUrl?.trim();
|
||||
if (!publicBaseUrl) return;
|
||||
const transport = config.transport ?? "surge";
|
||||
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
|
||||
let qrLines: string[] = [];
|
||||
|
|
@ -208,17 +237,27 @@ export default function remoteControl(pi: ExtensionAPI) {
|
|||
|
||||
pi.registerCommand("remote-control", {
|
||||
description: "Remote control — start/stop server, configure, show connection info",
|
||||
handler: async (args, ctx) => {
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const isRunning = !!server;
|
||||
const config = await readRemoteControlConfig();
|
||||
const transport = config.transport ?? "surge";
|
||||
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 transportLabel = transport === "tailscale"
|
||||
? `Transport: Tailscale${tsRunning ? " ✓" : " (not running)"}`
|
||||
: "Transport: Surge Ponte";
|
||||
|
||||
const menuItems = [
|
||||
isRunning ? "Turn off" : "Turn on",
|
||||
configLabel,
|
||||
transportLabel,
|
||||
...(isRunning ? ["Status"] : []),
|
||||
];
|
||||
|
||||
|
|
@ -226,16 +265,24 @@ export default function remoteControl(pi: ExtensionAPI) {
|
|||
if (choice === undefined) return;
|
||||
|
||||
if (choice === "Turn on") {
|
||||
const publicBaseUrl = currentUrl;
|
||||
if (!publicBaseUrl) {
|
||||
ctx.ui.notify("Set the public URL first — opening config…", "warning");
|
||||
await configureRemoteControlUI(ctx);
|
||||
// Re-check after config
|
||||
const updated = await readRemoteControlConfig();
|
||||
if (!updated.publicBaseUrl?.trim()) return;
|
||||
if (transport === "tailscale") {
|
||||
if (!tsRunning || !tailscaleIp) {
|
||||
ctx.ui.notify("Tailscale is not running. Run `tailscale up` first.", "warning");
|
||||
return;
|
||||
}
|
||||
server = await startServerTailscale(pi, ctx);
|
||||
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);
|
||||
ctx.ui.notify("Remote-control server started", "info");
|
||||
await showConnectionInfo(ctx);
|
||||
|
|
@ -248,6 +295,12 @@ export default function remoteControl(pi: ExtensionAPI) {
|
|||
}
|
||||
} else if (choice === configLabel) {
|
||||
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") {
|
||||
await showConnectionInfo(ctx);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export interface RemoteServer {
|
|||
onClientChange: (cb: () => void) => void;
|
||||
port: number;
|
||||
token: string;
|
||||
bindAddress: string;
|
||||
}
|
||||
|
||||
export function startServer(pi: ExtensionAPI, ctx: ExtensionContext): Promise<RemoteServer> {
|
||||
|
|
@ -258,6 +259,211 @@ export function startServer(pi: ExtensionAPI, ctx: ExtensionContext): Promise<Re
|
|||
get 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";
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@
|
|||
"ws": "^8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qrcode": "^1.5.6"
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mariozechner/pi-coding-agent": "*",
|
||||
|
|
@ -3795,6 +3796,20 @@
|
|||
"license": "0BSD",
|
||||
"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": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"@mariozechner/pi-tui": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qrcode": "^1.5.6"
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue