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
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -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()"]
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue