Skip to content

Commit 6fb46b0

Browse files
authored
feat(cli): add Remote Control for the web UI (#222)
## Related Issue None — direct feature request. ## Problem The web UI only reaches devices that can already talk to this machine: localhost, or the LAN with `--host`. Off-network there is no way to pick up a running session from a phone, and setting up a tunnel by hand is not something most users will do. ## What changed Remote Control: the CLI opens an outbound tunnel to a relay, and the relay serves the local web UI to a remote device. Sessions keep running on this machine — only the screen moves. This also gives a mobile client a stable endpoint to talk to. Entry points: - `pythinker rc` (alias `remote`) — starts the server with Remote Control on - `pythinker web --rc` / `--remote-control` — same, on the existing command - `/remote-control` or `/rc` in the terminal UI — hands the current session over The terminal prints a QR code, the link, and the path of a PNG copy of the QR for terminals that cannot render it inline. **Transport.** Two WebSockets to the relay — a management channel and an HTTP tunnel — plus one stream socket per browser WebSocket. HTTP requests arrive base64-framed and capped at 10 MiB, are replayed against the local server with hop-by-hop and credential headers stripped, and HTML/JS/CSS responses get asset paths rewritten under the device prefix. A liveness watchdog pings every 30s and reconnects after 300s of silence, so a relay that dies without a close frame does not strand the client. **Auth.** No accounts. The device authenticates to the relay with the existing persistent `server.token`. The link itself carries no credential: the CLI adds the bearer to every request it replays against the local server, so the token never leaves this machine — not into the QR image on disk, not into the remote browser's URL bar. **Guards.** Loopback bind only, `--dangerous-bypass-auth` refused, and one instance per machine via a pid+nonce lock at `<data-dir>/server/rc.json` — a second start reports the link the first one is using. Experimental and hidden from `--help` unless `PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL=1` (or `PYTHINKER_CODE_EXPERIMENTAL_FLAG=1`). **Relay.** No relay server is included in this change. Point Remote Control at your own with `--relay-origin <url>` or `PYTHINKER_CODE_REMOTE_CONTROL_RELAY`; the default is `https://code-rc.pythinker.com`. Supporting changes: `StartForegroundHooks` gained an `onShutdown` hook and `onReady` became awaitable, so the tunnel closes with the server; `supportsHyperlinks` was added next to `toTerminalHyperlink`. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [ ] I have linked a related issue (external PRs: the issue must have a maintainer's `/approve`). - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added experimental Remote Control for accessing the local web interface from phones or other computers. * Start it with `pythinker rc` or `/remote-control` (also `/rc`); connection links and QR codes appear in the terminal. * Added configurable relay support, secure single-session handling, token-free URLs, and improved connection reliability. * Remote Control requires explicit experimental feature activation. * **Documentation** * Added a guide covering setup, authentication, relay configuration, and shutdown. * **Tests** * Added comprehensive coverage for startup, connections, QR codes, relays, authentication, and error handling. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 882835e commit 6fb46b0

26 files changed

Lines changed: 3175 additions & 27 deletions

.changeset/remote-control.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": minor
3+
---
4+
5+
Add Remote Control, which makes the local web UI reachable from a phone or another computer. Run `pythinker rc`, or use `/rc` in the terminal UI, and scan the printed QR code. Enable it with `PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL=1`.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"sourceHash": "c05b9ce73324f5a446966d9f931dc804f0f1e73e78daf14e1479d86e58669c1a",
2+
"sourceHash": "d84e17f04092f5fb9afa9f4d323b614d3945ec8e33abbbc92f5793ad2c30959e",
33
"sourceFileCount": 404
44
}

apps/pythinker-code/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,17 +94,21 @@
9494
"@pymodel/pythinker-telemetry": "workspace:^",
9595
"@pymodel/vis-server": "workspace:^",
9696
"@pymodel/vis-web": "workspace:*",
97+
"@types/qrcode": "^1.5.6",
9798
"@types/semver": "^7.7.0",
99+
"@types/ws": "^8.18.0",
98100
"@types/yazl": "^2.4.6",
99101
"chalk": "^5.4.1",
100102
"cli-highlight": "^2.1.11",
101103
"commander": "^13.1.0",
102104
"jimp": "^1.6.1",
103105
"pathe": "^2.0.3",
104106
"postject": "1.0.0-alpha.6",
107+
"qrcode": "^1.5.4",
105108
"semver": "^7.7.4",
106109
"smol-toml": "^1.6.1",
107110
"tsx": "^4.23.5",
111+
"ws": "^8.21.3",
108112
"yazl": "^3.3.1",
109113
"zod": "^4.3.6"
110114
},

apps/pythinker-code/src/cli/sub/web/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { Command } from 'commander';
1515
import { registerDeprecatedServerCommand } from './deprecated-server';
1616
import { registerRotateTokenCommand } from './rotate-token';
1717
import { buildWebCommand } from './run';
18+
import { isRemoteControlEnabled } from './remote-control';
1819

1920
export function registerWebCommand(program: Command): void {
2021
const web = buildWebCommand(
@@ -23,5 +24,14 @@ export function registerWebCommand(program: Command): void {
2324
.description('Run the local Pythinker server and open the web UI.'),
2425
);
2526
registerRotateTokenCommand(web);
27+
buildWebCommand(
28+
program
29+
.command('rc', { hidden: !isRemoteControlEnabled() })
30+
.alias('remote')
31+
.description(
32+
'Run the local Pythinker server and open the web UI through Remote Control (experimental).',
33+
),
34+
{ forceRemoteControl: true },
35+
);
2636
registerDeprecatedServerCommand(program);
2737
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { randomBytes } from 'node:crypto';
2+
import { setTimeout as sleep } from 'node:timers/promises';
3+
import { mkdir, open, readFile, unlink } from 'node:fs/promises';
4+
import { dirname, join } from 'node:path';
5+
6+
export interface RemoteControlLockInfo {
7+
readonly pid: number;
8+
readonly nonce: string;
9+
readonly localOrigin: string;
10+
readonly deviceId: string;
11+
readonly url: string;
12+
readonly startedAt: number;
13+
}
14+
15+
interface RemoteControlLockDisk {
16+
readonly pid: number;
17+
readonly nonce: string;
18+
readonly local_origin: string;
19+
readonly device_id: string;
20+
readonly url: string;
21+
readonly started_at: number;
22+
}
23+
24+
export class RemoteControlAlreadyRunningError extends Error {
25+
readonly holder: RemoteControlLockInfo;
26+
27+
constructor(holder: RemoteControlLockInfo) {
28+
super(formatRemoteControlAlreadyRunning(holder));
29+
this.name = 'RemoteControlAlreadyRunningError';
30+
this.holder = holder;
31+
}
32+
}
33+
34+
export function formatRemoteControlAlreadyRunning(holder: RemoteControlLockInfo): string {
35+
return [
36+
`Remote Control is already running on this machine (pid ${holder.pid}, ${holder.localOrigin}, since ${new Date(holder.startedAt).toLocaleString()}).`,
37+
`Use the existing link: ${holder.url}`,
38+
'To start a new one here, stop the other `pythinker web --remote-control` process first.',
39+
].join('\n');
40+
}
41+
42+
export function remoteControlLockPath(homeDir: string): string {
43+
return join(homeDir, 'server', 'rc.json');
44+
}
45+
46+
export interface RemoteControlLock {
47+
release(): Promise<void>;
48+
}
49+
50+
const MAX_ACQUIRE_ATTEMPTS = 3;
51+
const ACQUIRE_RETRY_DELAY_MS = 25;
52+
53+
export async function acquireRemoteControlLock(
54+
homeDir: string,
55+
details: { localOrigin: string; deviceId: string; url: string },
56+
): Promise<RemoteControlLock> {
57+
const lockPath = remoteControlLockPath(homeDir);
58+
// The lock sits beside `server.token`; keep the same owner-only permissions.
59+
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
60+
const info: RemoteControlLockInfo = {
61+
pid: process.pid,
62+
nonce: randomBytes(8).toString('hex'),
63+
localOrigin: details.localOrigin,
64+
deviceId: details.deviceId,
65+
url: details.url,
66+
startedAt: Date.now(),
67+
};
68+
for (let attempt = 0; ; attempt += 1) {
69+
try {
70+
const handle = await open(lockPath, 'wx', 0o600);
71+
try {
72+
await handle.writeFile(encodeLock(info));
73+
} finally {
74+
await handle.close();
75+
}
76+
return { release: () => releaseRemoteControlLock(lockPath, info.nonce) };
77+
} catch (error) {
78+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
79+
throw error;
80+
}
81+
// Read the holder even on the last attempt: a second process can recreate
82+
// the lock between our unlink and our open, and a raw EEXIST tells the
83+
// user nothing about who holds it or how to stop them.
84+
const holder = await readRemoteControlLock(lockPath);
85+
if (holder !== undefined && pidAlive(holder.pid)) {
86+
throw new RemoteControlAlreadyRunningError(holder);
87+
}
88+
// `open(…, 'wx')` publishes an empty file before its JSON is written, so
89+
// an unreadable lock may simply be a rival mid-write. Deleting it there
90+
// would let both processes believe they hold the lock. Give the writer a
91+
// moment and re-read; only sweep it once it is still unreadable at the
92+
// end, which is the genuinely corrupt case.
93+
if (holder === undefined && attempt < MAX_ACQUIRE_ATTEMPTS) {
94+
await sleep(ACQUIRE_RETRY_DELAY_MS);
95+
continue;
96+
}
97+
if (attempt >= MAX_ACQUIRE_ATTEMPTS) {
98+
throw new Error(
99+
`Unable to acquire the Remote Control lock at ${lockPath}. Another process keeps recreating it.`, { cause: error },
100+
);
101+
}
102+
await removeFile(lockPath);
103+
}
104+
}
105+
}
106+
107+
export async function inspectRemoteControlLock(
108+
homeDir: string,
109+
): Promise<RemoteControlLockInfo | undefined> {
110+
const lockPath = remoteControlLockPath(homeDir);
111+
const info = await readRemoteControlLock(lockPath);
112+
if (info === undefined) return undefined;
113+
if (!pidAlive(info.pid)) {
114+
await removeFile(lockPath);
115+
return undefined;
116+
}
117+
return info;
118+
}
119+
120+
async function releaseRemoteControlLock(lockPath: string, nonce: string): Promise<void> {
121+
const info = await readRemoteControlLock(lockPath);
122+
if (info === undefined || info.nonce !== nonce) return;
123+
await removeFile(lockPath);
124+
}
125+
126+
async function readRemoteControlLock(lockPath: string): Promise<RemoteControlLockInfo | undefined> {
127+
let raw: string;
128+
try {
129+
raw = await readFile(lockPath, 'utf8');
130+
} catch {
131+
return undefined;
132+
}
133+
return decodeLock(raw);
134+
}
135+
136+
function encodeLock(info: RemoteControlLockInfo): string {
137+
const disk: RemoteControlLockDisk = {
138+
pid: info.pid,
139+
nonce: info.nonce,
140+
local_origin: info.localOrigin,
141+
device_id: info.deviceId,
142+
url: info.url,
143+
started_at: info.startedAt,
144+
};
145+
return JSON.stringify(disk);
146+
}
147+
148+
function decodeLock(raw: string): RemoteControlLockInfo | undefined {
149+
try {
150+
const parsed = JSON.parse(raw) as Partial<RemoteControlLockDisk>;
151+
if (
152+
typeof parsed.pid === 'number' &&
153+
// `process.kill(0, 0)` signals our own process group and reports "alive",
154+
// so a corrupt `"pid": 0` would pin the lock forever.
155+
Number.isInteger(parsed.pid) &&
156+
parsed.pid > 0 &&
157+
typeof parsed.nonce === 'string' &&
158+
typeof parsed.local_origin === 'string' &&
159+
typeof parsed.device_id === 'string' &&
160+
typeof parsed.url === 'string' &&
161+
typeof parsed.started_at === 'number'
162+
) {
163+
return {
164+
pid: parsed.pid,
165+
nonce: parsed.nonce,
166+
localOrigin: parsed.local_origin,
167+
deviceId: parsed.device_id,
168+
url: parsed.url,
169+
startedAt: parsed.started_at,
170+
};
171+
}
172+
return undefined;
173+
} catch {
174+
return undefined;
175+
}
176+
}
177+
178+
async function removeFile(lockPath: string): Promise<void> {
179+
try {
180+
await unlink(lockPath);
181+
} catch (error) {
182+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
183+
}
184+
}
185+
186+
function pidAlive(pid: number): boolean {
187+
try {
188+
process.kill(pid, 0);
189+
return true;
190+
} catch (error) {
191+
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false;
192+
return true;
193+
}
194+
}

0 commit comments

Comments
 (0)