From b4189828b62d0db69b17d33f0b480aeff7a43add Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 27 Aug 2026 06:02:32 -0400 Subject: [PATCH 1/6] feat(cli): add Remote Control for the web UI Tunnel the local web server through a relay so the session is reachable from a phone or another computer. The session keeps running on this machine; only the UI moves. - `pythinker rc` (alias `remote`) and `pythinker web --remote-control` - `/remote-control` (alias `/rc`) hands the current session over - QR code in the terminal plus a PNG copy under the data dir - loopback-only bind, bearer auth required, one instance per machine - experimental: PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL=1 --- .changeset/remote-control.md | 5 + apps/pythinker-code/package.json | 4 + apps/pythinker-code/src/cli/sub/web/index.ts | 10 + .../src/cli/sub/web/remote-control-lock.ts | 170 +++ .../src/cli/sub/web/remote-control.ts | 1033 +++++++++++++++++ apps/pythinker-code/src/cli/sub/web/run.ts | 121 +- .../src/tui/commands/dispatch.ts | 7 +- apps/pythinker-code/src/tui/commands/index.ts | 2 +- .../src/tui/commands/registry.ts | 8 + apps/pythinker-code/src/tui/commands/web.ts | 78 ++ .../src/utils/remote-control-qr.ts | 62 + .../src/utils/terminal-hyperlink.ts | 21 + apps/pythinker-code/test/cli/options.test.ts | 13 +- .../test/cli/web/remote-control.test.ts | 782 +++++++++++++ apps/pythinker-code/test/cli/web/web.test.ts | 106 +- .../test/tui/commands/registry.test.ts | 7 + .../test/tui/commands/resolve.test.ts | 8 + .../test/tui/commands/web.test.ts | 195 +++- .../test/utils/remote-control-qr.test.ts | 104 ++ docs/.vitepress/config.ts | 1 + docs/guides/remote-control.md | 35 + flake.nix | 2 +- .../src/app/remoteControl/flag.ts | 16 + packages/agent-core-v2/src/index.ts | 1 + pnpm-lock.yaml | 206 +++- 25 files changed, 2971 insertions(+), 26 deletions(-) create mode 100644 .changeset/remote-control.md create mode 100644 apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts create mode 100644 apps/pythinker-code/src/cli/sub/web/remote-control.ts create mode 100644 apps/pythinker-code/src/utils/remote-control-qr.ts create mode 100644 apps/pythinker-code/test/cli/web/remote-control.test.ts create mode 100644 apps/pythinker-code/test/utils/remote-control-qr.test.ts create mode 100644 docs/guides/remote-control.md create mode 100644 packages/agent-core-v2/src/app/remoteControl/flag.ts diff --git a/.changeset/remote-control.md b/.changeset/remote-control.md new file mode 100644 index 000000000..d36af77a5 --- /dev/null +++ b/.changeset/remote-control.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +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`. diff --git a/apps/pythinker-code/package.json b/apps/pythinker-code/package.json index 8bdfc2bfe..c042eee6f 100644 --- a/apps/pythinker-code/package.json +++ b/apps/pythinker-code/package.json @@ -94,7 +94,9 @@ "@pymodel/pythinker-telemetry": "workspace:^", "@pymodel/vis-server": "workspace:^", "@pymodel/vis-web": "workspace:*", + "@types/qrcode": "^1.5.6", "@types/semver": "^7.7.0", + "@types/ws": "^8.18.0", "@types/yazl": "^2.4.6", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", @@ -102,9 +104,11 @@ "jimp": "^1.6.1", "pathe": "^2.0.3", "postject": "1.0.0-alpha.6", + "qrcode": "^1.5.4", "semver": "^7.7.4", "smol-toml": "^1.6.1", "tsx": "^4.23.5", + "ws": "^8.18.0", "yazl": "^3.3.1", "zod": "^4.3.6" }, diff --git a/apps/pythinker-code/src/cli/sub/web/index.ts b/apps/pythinker-code/src/cli/sub/web/index.ts index 2fa13ceab..4eec0fd08 100644 --- a/apps/pythinker-code/src/cli/sub/web/index.ts +++ b/apps/pythinker-code/src/cli/sub/web/index.ts @@ -15,6 +15,7 @@ import type { Command } from 'commander'; import { registerDeprecatedServerCommand } from './deprecated-server'; import { registerRotateTokenCommand } from './rotate-token'; import { buildWebCommand } from './run'; +import { isRemoteControlEnabled } from './remote-control'; export function registerWebCommand(program: Command): void { const web = buildWebCommand( @@ -23,5 +24,14 @@ export function registerWebCommand(program: Command): void { .description('Run the local Pythinker server and open the web UI.'), ); registerRotateTokenCommand(web); + buildWebCommand( + program + .command('rc', { hidden: !isRemoteControlEnabled() }) + .alias('remote') + .description( + 'Run the local Pythinker server and open the web UI through Remote Control (experimental).', + ), + { forceRemoteControl: true }, + ); registerDeprecatedServerCommand(program); } diff --git a/apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts b/apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts new file mode 100644 index 000000000..ac8372cbc --- /dev/null +++ b/apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts @@ -0,0 +1,170 @@ +import { randomBytes } from 'node:crypto'; +import { mkdir, open, readFile, unlink } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +export interface RemoteControlLockInfo { + readonly pid: number; + readonly nonce: string; + readonly localOrigin: string; + readonly deviceId: string; + readonly url: string; + readonly startedAt: number; +} + +interface RemoteControlLockDisk { + readonly pid: number; + readonly nonce: string; + readonly local_origin: string; + readonly device_id: string; + readonly url: string; + readonly started_at: number; +} + +export class RemoteControlAlreadyRunningError extends Error { + readonly holder: RemoteControlLockInfo; + + constructor(holder: RemoteControlLockInfo) { + super(formatRemoteControlAlreadyRunning(holder)); + this.name = 'RemoteControlAlreadyRunningError'; + this.holder = holder; + } +} + +export function formatRemoteControlAlreadyRunning(holder: RemoteControlLockInfo): string { + return [ + `Remote Control is already running on this machine (pid ${holder.pid}, ${holder.localOrigin}, since ${new Date(holder.startedAt).toLocaleString()}).`, + `Use the existing link: ${holder.url}`, + 'To start a new one here, stop the other `pythinker web --remote-control` process first.', + ].join('\n'); +} + +export function remoteControlLockPath(homeDir: string): string { + return join(homeDir, 'server', 'rc.json'); +} + +export interface RemoteControlLock { + release(): Promise; +} + +const MAX_ACQUIRE_ATTEMPTS = 3; + +export async function acquireRemoteControlLock( + homeDir: string, + details: { localOrigin: string; deviceId: string; url: string }, +): Promise { + const lockPath = remoteControlLockPath(homeDir); + await mkdir(dirname(lockPath), { recursive: true }); + const info: RemoteControlLockInfo = { + pid: process.pid, + nonce: randomBytes(8).toString('hex'), + localOrigin: details.localOrigin, + deviceId: details.deviceId, + url: details.url, + startedAt: Date.now(), + }; + for (let attempt = 0; ; attempt += 1) { + try { + const handle = await open(lockPath, 'wx'); + try { + await handle.writeFile(encodeLock(info)); + } finally { + await handle.close(); + } + return { release: () => releaseRemoteControlLock(lockPath, info.nonce) }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST' || attempt >= MAX_ACQUIRE_ATTEMPTS) { + throw error; + } + const holder = await readRemoteControlLock(lockPath); + if (holder !== undefined && pidAlive(holder.pid)) { + throw new RemoteControlAlreadyRunningError(holder); + } + await removeFile(lockPath); + } + } +} + +export async function inspectRemoteControlLock( + homeDir: string, +): Promise { + const lockPath = remoteControlLockPath(homeDir); + const info = await readRemoteControlLock(lockPath); + if (info === undefined) return undefined; + if (!pidAlive(info.pid)) { + await removeFile(lockPath); + return undefined; + } + return info; +} + +async function releaseRemoteControlLock(lockPath: string, nonce: string): Promise { + const info = await readRemoteControlLock(lockPath); + if (info === undefined || info.nonce !== nonce) return; + await removeFile(lockPath); +} + +async function readRemoteControlLock(lockPath: string): Promise { + let raw: string; + try { + raw = await readFile(lockPath, 'utf8'); + } catch { + return undefined; + } + return decodeLock(raw); +} + +function encodeLock(info: RemoteControlLockInfo): string { + const disk: RemoteControlLockDisk = { + pid: info.pid, + nonce: info.nonce, + local_origin: info.localOrigin, + device_id: info.deviceId, + url: info.url, + started_at: info.startedAt, + }; + return JSON.stringify(disk); +} + +function decodeLock(raw: string): RemoteControlLockInfo | undefined { + try { + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.pid === 'number' && + typeof parsed.nonce === 'string' && + typeof parsed.local_origin === 'string' && + typeof parsed.device_id === 'string' && + typeof parsed.url === 'string' && + typeof parsed.started_at === 'number' + ) { + return { + pid: parsed.pid, + nonce: parsed.nonce, + localOrigin: parsed.local_origin, + deviceId: parsed.device_id, + url: parsed.url, + startedAt: parsed.started_at, + }; + } + return undefined; + } catch { + return undefined; + } +} + +async function removeFile(lockPath: string): Promise { + try { + await unlink(lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } +} + +function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false; + return true; + } +} diff --git a/apps/pythinker-code/src/cli/sub/web/remote-control.ts b/apps/pythinker-code/src/cli/sub/web/remote-control.ts new file mode 100644 index 000000000..3e0e2d1c3 --- /dev/null +++ b/apps/pythinker-code/src/cli/sub/web/remote-control.ts @@ -0,0 +1,1033 @@ +import { hostname, platform } from 'node:os'; +import { request as httpRequest, validateHeaderName, validateHeaderValue } from 'node:http'; +import { setTimeout as sleep } from 'node:timers/promises'; + +import { createPythinkerDeviceId } from '@pymodel/pythinker-code-oauth'; +import { WebSocket, type RawData } from 'ws'; +import chalk from 'chalk'; + +import { getVersion } from '../../version'; +import { darkColors } from '../../../tui/theme/colors'; +import { supportsHyperlinks, toTerminalHyperlink } from '../../../utils/terminal-hyperlink'; +import { acquireRemoteControlLock } from './remote-control-lock'; + +export const REMOTE_CONTROL_RELAY_ORIGIN = 'https://code-rc.pythinker.com'; + +export const REMOTE_CONTROL_FLAG_ENV = 'PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL'; + +const TRUTHY_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']); + +export function isRemoteControlEnabled( + env: Readonly> = process.env, +): boolean { + const truthy = (key: string): boolean => + TRUTHY_ENV_VALUES.has((env[key] ?? '').trim().toLowerCase()); + return truthy('PYTHINKER_CODE_EXPERIMENTAL_FLAG') || truthy(REMOTE_CONTROL_FLAG_ENV); +} + +const MAX_HTTP_HEADER_BYTES = 64 * 1024; +const MAX_HTTP_REQUEST_BYTES = 10 * 1024 * 1024; +const HTTP_REQUEST_TIMEOUT_MS = 30_000; +const REGISTER_TIMEOUT_MS = 10_000; +const MAX_RECONNECT_DELAY_MS = 30_000; +const RELAY_PING_INTERVAL_MS = 30_000; +const RELAY_SILENCE_TIMEOUT_MS = 300_000; +const BLOCKED_REQUEST_HEADERS = new Set([ + 'authorization', + 'cookie', + 'host', + 'origin', + 'proxy-authorization', + 'proxy-authenticate', + 'accept-encoding', + 'connection', + 'keep-alive', + 'proxy-connection', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); +const BLOCKED_RESPONSE_HEADERS = new Set([ + 'connection', + 'content-length', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'proxy-connection', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +interface RelayMessage { + readonly type: string; + readonly payload?: Record; +} + +interface PendingHttpRequest { + readonly chunks: Buffer[]; + size: number; +} + +export interface ParsedRawHttpRequest { + readonly method: string; + readonly path: string; + readonly headers: readonly [string, string][]; + readonly body: Buffer; +} + +export type RemoteControlStatus = + | 'relay_connected' + | 'relay_disconnected' + | 'device_connected' + | 'device_disconnected'; + +export interface RemoteControlOptions { + readonly homeDir: string; + readonly localOrigin: string; + readonly localServerToken: string; + readonly relayOrigin?: string; + readonly stderr?: Pick; + readonly onStatus?: (status: RemoteControlStatus) => void; + readonly pingIntervalMs?: number; + readonly silenceTimeoutMs?: number; +} + +export interface RemoteControlHandle { + readonly deviceId: string; + readonly deviceName: string; + readonly url: string; + close(): Promise; +} + +interface ActiveStream { + readonly local: WebSocket; + readonly tunnel: WebSocket; +} + +class RegistrationError extends Error {} + +export interface RemoteControlOutputOptions { + readonly url: string; + readonly localOrigin: string; + readonly deviceName: string; + readonly qrCode: string; + readonly pngPath: string; +} + +export function formatRemoteControlOutput(options: RemoteControlOutputOptions): string { + const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text); + const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); + const accent = (text: string): string => chalk.hex(darkColors.accent)(text); + const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text); + const status = (text: string): string => chalk.hex(darkColors.success)(text); + const link = (url: string): string => + supportsHyperlinks() + ? toTerminalHyperlink(accent(shortRemoteControlUrl(url)), url) + : accent(url); + const docs = toTerminalHyperlink('docs', 'https://code.pythinker.com/guides/remote-control.html'); + const feedback = toTerminalHyperlink('feedback', 'https://github.com/PyModel/pythinker-code/issues'); + return [ + '', + ` ${title('Pythinker Remote Control ready')} ${muted(`${getVersion()} (experimental)`)}`, + ` ${muted('Use Pythinker Code on this machine from your phone or another computer.')}`, + '', + ` ${label('1.')} Scan the QR code, or open ${link(options.url)}`, + ` ${label('2.')} Start chatting — sessions run on this machine`, + '', + ` ${status('✓')} ${muted(`Connected to ${new URL(options.url).host}, waiting for remote devices…`)}`, + ` ${label('This device: ')}${muted(options.deviceName)}`, + ` ${status('⚠')} ${muted('This link grants control of this machine. Do not share it.')}`, + '', + options.qrCode.trimEnd().replaceAll(/^/gm, ' '), + ` ${label('QR code PNG: ')}${options.pngPath} ${muted('(open this if the QR above does not scan)')}`, + ` ${label('Local UI: ')}${muted(options.localOrigin)} ${muted('(LAN: --host)')}`, + '', + ` ${muted('Experimental —')} ${docs} ${muted('·')} ${feedback}`, + ` ${label('Logs: ')}${muted('off (--log-level info)')} ${muted('·')} ${label('Stop: ')}${muted('Ctrl+C')}`, + '', + ].join('\n'); +} + +export function formatRemoteControlStatus(status: RemoteControlStatus): string { + const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); + const value = (text: string): string => chalk.hex(darkColors.success)(text); + switch (status) { + case 'relay_connected': + return ` ${value('✓')} ${label('Connected to relay, waiting for remote devices…')}\n`; + case 'relay_disconnected': + return ` ${value('!')} ${label('Relay disconnected; reconnecting…')}\n`; + case 'device_connected': + return ` ${value('✓')} ${label('Remote device connected (1 active session)')}\n`; + case 'device_disconnected': + return ` ${value('→')} ${label('Remote device disconnected')}\n`; + } +} + +function shortRemoteControlUrl(url: string): string { + const parsed = new URL(url); + const parts = parsed.pathname.split('/'); + const deviceIndex = parts.indexOf('devices'); + const deviceId = deviceIndex >= 0 ? parts[deviceIndex + 1] : undefined; + if (deviceId !== undefined && deviceId.length > 12) { + parts[deviceIndex + 1] = `${deviceId.slice(0, 6)}…${deviceId.slice(-4)}`; + } + return `${parsed.host}${parts.join('/')}`; +} + +export function buildRemoteControlUrl( + deviceId: string, + sessionId?: string, + relayOrigin = REMOTE_CONTROL_RELAY_ORIGIN, + serverToken?: string, +): string { + const url = new URL(relayOrigin); + const relayPath = url.pathname.replace(/\/+$/, ''); + const devicePath = `${relayPath}/devices/${encodeURIComponent(deviceId)}`; + url.pathname = + sessionId === undefined + ? `${devicePath}/` + : `${devicePath}/sessions/${encodeURIComponent(sessionId)}`; + url.search = new URLSearchParams({ rc: '1', from: 'pythinker_code_cli' }).toString(); + url.hash = serverToken === undefined ? '' : `token=${serverToken}`; + return url.toString(); +} + +export function parseRawHttpRequest(raw: Buffer): ParsedRawHttpRequest { + const separator = raw.indexOf('\r\n\r\n'); + if (separator < 0 || separator > MAX_HTTP_HEADER_BYTES) { + throw new SyntaxError('invalid HTTP request headers'); + } + const head = raw.subarray(0, separator).toString('latin1'); + const lines = head.split('\r\n'); + const requestLine = lines.shift(); + const match = requestLine?.match( + /^([!#$%&'*+.^_`|~0-9A-Za-z-]+) (\/[^\u0000-\u0020]*) HTTP\/1\.[01]$/, + ); + if (match === null || match === undefined || match[2]!.startsWith('//')) { + throw new SyntaxError('invalid HTTP request line'); + } + const headers: [string, string][] = []; + for (const line of lines) { + const colon = line.indexOf(':'); + if (colon <= 0) throw new SyntaxError('invalid HTTP request header'); + const name = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim(); + try { + validateHeaderName(name); + validateHeaderValue(name, value); + } catch { + throw new SyntaxError('invalid HTTP request header'); + } + headers.push([name, value]); + } + return { + method: match[1]!, + path: match[2]!, + headers, + body: raw.subarray(separator + 4), + }; +} + +export function filterForwardRequestHeaders( + headers: readonly [string, string][], + serverToken: string, +): string[] { + const connectionHeaders = new Set(); + for (const [name, value] of headers) { + if (name.toLowerCase() === 'connection') { + for (const token of value.split(',')) connectionHeaders.add(token.trim().toLowerCase()); + } + } + const result: string[] = []; + for (const [name, value] of headers) { + const lower = name.toLowerCase(); + if (BLOCKED_REQUEST_HEADERS.has(lower) || connectionHeaders.has(lower)) continue; + result.push(name, value); + } + result.push('Authorization', `Bearer ${serverToken}`); + return result; +} + +export function rewriteRemoteControlResponse( + contentType: string, + body: Buffer, + publicPrefix: string, +): Buffer { + const normalizedPrefix = publicPrefix.replace(/\/+$/, ''); + if (contentType.toLowerCase().includes('text/html')) { + const prefixLiteral = JSON.stringify(normalizedPrefix); + const injected = ``; + let text = body.toString('utf8'); + const headMatch = /]*)?>/i.exec(text); + text = + headMatch === null + ? injected + text + : text.slice(0, headMatch.index + headMatch[0].length) + + injected + + text.slice(headMatch.index + headMatch[0].length); + text = text.replaceAll(/\bsrc="\//g, `src="${normalizedPrefix}/`); + text = text.replaceAll(/\bhref="\//g, `href="${normalizedPrefix}/`); + return Buffer.from(text); + } + const lower = contentType.toLowerCase(); + if (lower.includes('javascript') || lower.includes('text/css')) { + let text = body.toString('utf8'); + text = text.replaceAll('"/assets/', `"${normalizedPrefix}/assets/`); + text = text.replaceAll("'/assets/", `'${normalizedPrefix}/assets/`); + text = text.replaceAll('(/assets/', `(${normalizedPrefix}/assets/`); + text = text.replaceAll('"/sessions/"', `"${normalizedPrefix}/sessions/"`); + text = text.replaceAll('return"/"+', `return"${normalizedPrefix}/"+`); + return Buffer.from(text); + } + return body; +} + +export async function startRemoteControl( + options: RemoteControlOptions, +): Promise { + if (options.localServerToken.length === 0) { + throw new Error('Remote Control requires local server authentication.'); + } + const relayOrigin = options.relayOrigin ?? REMOTE_CONTROL_RELAY_ORIGIN; + const deviceId = createPythinkerDeviceId(options.homeDir); + const deviceName = hostname(); + const url = buildRemoteControlUrl(deviceId, undefined, relayOrigin, options.localServerToken); + const lock = await acquireRemoteControlLock(options.homeDir, { + localOrigin: options.localOrigin.replace(/\/+$/, ''), + deviceId, + url, + }); + const client = new RemoteControlClient({ + ...options, + relayOrigin, + deviceId, + relayToken: options.localServerToken, + }); + try { + await client.start(); + } catch (error) { + await lock.release(); + throw error; + } + return { + deviceId, + deviceName, + url, + close: async () => { + await client.close(); + await lock.release(); + }, + }; +} + +class RemoteControlClient { + private readonly localOrigin: string; + private readonly localServerToken: string; + private readonly relayOrigin: string; + private readonly deviceId: string; + private readonly relayToken: string; + private readonly stderr: Pick; + private readonly onStatus: (status: RemoteControlStatus) => void; + private readonly streams = new Map(); + private readonly pendingHttpRequests = new Map(); + private management: WebSocket | undefined; + private reconnectAbort: AbortController | undefined; + private http: WebSocket | undefined; + private pendingHttpBytes = 0; + private reconnectAttempt = 0; + private reconnectImmediately = false; + private readonly pingIntervalMs: number; + private readonly silenceTimeoutMs: number; + private stopped = false; + private connected = false; + private relayOnline = false; + private runPromise: Promise | undefined; + private initialResolve: (() => void) | undefined; + private initialReject: ((error: unknown) => void) | undefined; + + constructor( + options: RemoteControlOptions & { + readonly relayOrigin: string; + readonly deviceId: string; + readonly relayToken: string; + }, + ) { + this.localOrigin = options.localOrigin.replace(/\/+$/, ''); + this.localServerToken = options.localServerToken; + this.relayOrigin = options.relayOrigin; + this.deviceId = options.deviceId; + this.relayToken = options.relayToken; + this.stderr = options.stderr ?? process.stderr; + this.onStatus = options.onStatus ?? (() => {}); + this.pingIntervalMs = options.pingIntervalMs ?? RELAY_PING_INTERVAL_MS; + this.silenceTimeoutMs = options.silenceTimeoutMs ?? RELAY_SILENCE_TIMEOUT_MS; + } + + async start(): Promise { + const initial = new Promise((resolve, reject) => { + this.initialResolve = resolve; + this.initialReject = reject; + }); + this.runPromise = this.run(); + await initial; + } + + async close(): Promise { + if (this.stopped) { + await this.runPromise; + return; + } + this.stopped = true; + if (!this.connected) this.rejectInitial(new Error('Remote Control closed before ready.')); + if (this.management?.readyState === WebSocket.OPEN) { + this.management.send( + JSON.stringify({ type: 'disconnect', payload: { reason: 'local_server_stopped' } }), + ); + } + this.closeCycle(); + this.reconnectAbort?.abort(); + await this.runPromise; + } + + private async run(): Promise { + while (!this.stopped) { + try { + await this.serveCycle(); + } catch (error) { + if (error instanceof RegistrationError) { + if (!this.connected) { + this.rejectInitial(error); + this.stopped = true; + return; + } + this.stderr.write(`${error.message}\n`); + } else if (!this.stopped && !this.reconnectImmediately) { + this.stderr.write(`Remote Control disconnected: ${errorMessage(error)}\n`); + } + } finally { + this.closeCycle(); + } + if (this.stopped) { + if (!this.connected) this.rejectInitial(new Error('Remote Control stopped before ready.')); + return; + } + if (this.reconnectImmediately) { + this.reconnectImmediately = false; + continue; + } + this.reconnectAttempt += 1; + const delay = Math.min( + MAX_RECONNECT_DELAY_MS, + 1000 * 2 ** Math.min(this.reconnectAttempt - 1, 5), + ); + await this.waitForReconnect(delay); + } + } + + private async serveCycle(): Promise { + const management = await this.connectRelay('/v1/remote/create'); + this.management = management; + this.watchSocket(management, 'management'); + management.send( + JSON.stringify({ + type: 'register', + payload: { + device_id: this.deviceId, + alias: hostname(), + platform: platform(), + client_version: `pythinker-code/${getVersion()}`, + local_base_url: this.localOrigin, + }, + }), + ); + const registration = await waitForRelayMessage(management, REGISTER_TIMEOUT_MS); + if (registration.type === 'register_nak') { + const code = stringField(registration.payload, 'error_code') ?? 'REGISTRATION_REJECTED'; + const message = stringField(registration.payload, 'error_message') ?? 'registration rejected'; + throw new RegistrationError(`Remote Control registration failed (${code}): ${message}`); + } + if (registration.type !== 'register_ack') { + throw new Error(`Remote Control expected register_ack, received ${registration.type}`); + } + + const managementEnd = waitForSocketEnd(management); + const http = await this.connectRelay( + `/v1/remote/http?device_id=${encodeURIComponent(this.deviceId)}`, + ); + this.http = http; + this.watchSocket(http, 'http'); + if (management.readyState !== WebSocket.OPEN) { + throw new Error('management connection closed'); + } + management.on('message', (data) => this.handleManagementMessage(data)); + http.on('message', (data) => this.handleHttpMessage(data)); + this.reconnectAttempt = 0; + this.relayOnline = true; + this.onStatus('relay_connected'); + + if (!this.connected) { + this.connected = true; + this.initialResolve?.(); + this.initialResolve = undefined; + this.initialReject = undefined; + } + + await Promise.race([managementEnd, waitForSocketEnd(http)]); + if (!this.stopped) throw new Error('relay connection closed'); + } + + private connectRelay(path: string): Promise { + return connectWebSocket(relayWebSocketUrl(this.relayOrigin, path), this.relayToken); + } + + private watchSocket(socket: WebSocket, label: string): void { + const pingTimer = setInterval(() => { + if (socket.readyState === WebSocket.OPEN) socket.ping(); + }, this.pingIntervalMs); + pingTimer.unref(); + let silenceTimer: NodeJS.Timeout | undefined; + const armSilenceTimer = (): void => { + if (silenceTimer !== undefined) clearTimeout(silenceTimer); + silenceTimer = setTimeout(() => { + this.stderr.write( + `Remote Control ${label} connection silent for ${Math.round(this.silenceTimeoutMs / 1000)}s; reconnecting…\n`, + ); + socket.terminate(); + }, this.silenceTimeoutMs); + silenceTimer.unref(); + }; + armSilenceTimer(); + socket.on('message', armSilenceTimer); + socket.on('ping', armSilenceTimer); + socket.on('pong', armSilenceTimer); + socket.once('close', () => { + clearInterval(pingTimer); + if (silenceTimer !== undefined) clearTimeout(silenceTimer); + }); + } + + private rejectInitial(error: Error): void { + this.initialReject?.(error); + this.initialReject = undefined; + this.initialResolve = undefined; + } + + private handleManagementMessage(data: RawData): void { + let message: RelayMessage; + try { + message = parseRelayMessage(data); + } catch (error) { + this.stderr.write(`Remote Control message error: ${errorMessage(error)}\n`); + return; + } + if (message.type === 'open_ws') { + void this.openStream(message.payload ?? {}); + return; + } + if (message.type === 'close_ws') { + const streamId = stringField(message.payload, 'stream_id'); + if (streamId !== undefined) this.closeStream(streamId); + return; + } + if (message.type === 'disconnect') { + const reason = stringField(message.payload, 'reason'); + if (reason === 'user_requested') this.stopped = true; + if (reason === 'server_shutting_down') this.reconnectImmediately = true; + this.closeCycle(); + } + } + + private handleHttpMessage(data: RawData): void { + const text = rawDataText(data).trim(); + if (text.length === 0) return; + let requestId: string | undefined; + try { + const parsed = JSON.parse(text) as Record; + if (parsed['type'] !== 'request') return; + requestId = typeof parsed['request_id'] === 'string' ? parsed['request_id'] : undefined; + if ( + requestId === undefined || + typeof parsed['body_base64'] !== 'string' || + typeof parsed['is_last'] !== 'boolean' + ) { + throw new SyntaxError('invalid HTTP tunnel request message'); + } + const chunk = decodeBase64(parsed['body_base64']); + const pending = this.pendingHttpRequests.get(requestId) ?? { chunks: [], size: 0 }; + if (this.pendingHttpBytes + chunk.length > MAX_HTTP_REQUEST_BYTES) { + throw new SyntaxError('HTTP tunnel request exceeds 10 MiB'); + } + pending.chunks.push(chunk); + pending.size += chunk.length; + this.pendingHttpBytes += chunk.length; + this.pendingHttpRequests.set(requestId, pending); + if (!parsed['is_last']) return; + const rawRequest = Buffer.concat(pending.chunks, pending.size); + this.clearPendingHttpRequest(requestId); + void this.forwardHttpRequest(requestId, rawRequest); + } catch (error) { + if (requestId !== undefined) { + this.clearPendingHttpRequest(requestId); + this.sendHttpResponse(requestId, buildErrorResponse(400)); + } + this.stderr.write(`Remote Control HTTP message error: ${errorMessage(error)}\n`); + } + } + + private async forwardHttpRequest(requestId: string, rawRequest: Buffer): Promise { + try { + const parsed = parseRawHttpRequest(rawRequest); + const response = await requestLocalHttp( + this.localOrigin, + parsed, + this.localServerToken, + this.publicPrefix(), + ); + this.sendHttpResponse(requestId, response); + } catch (error) { + const status = error instanceof SyntaxError ? 400 : 502; + this.sendHttpResponse(requestId, buildErrorResponse(status)); + this.stderr.write(`Remote Control HTTP forwarding failed: ${errorMessage(error)}\n`); + } + } + + private sendHttpResponse(requestId: string, response: Buffer): void { + if (this.http?.readyState !== WebSocket.OPEN) return; + this.http.send( + JSON.stringify({ + request_id: requestId, + type: 'response', + is_last: true, + body_base64: response.toString('base64'), + }), + ); + } + + private async openStream(payload: Record): Promise { + const streamId = stringField(payload, 'stream_id'); + const path = stringField(payload, 'path'); + if (streamId === undefined || path === undefined || !path.startsWith('/') || path.startsWith('//')) { + if (streamId !== undefined) { + this.sendOpenStreamResult(streamId, false, 'LOCAL_WS_FAILED', 'invalid local WebSocket path'); + } + return; + } + + let local: WebSocket | undefined; + let tunnel: WebSocket | undefined; + const earlyLocalFrames: [RawData, boolean][] = []; + try { + local = await connectWebSocket( + localWebSocketUrl(this.localOrigin, path), + this.localServerToken, + relayHeaders(payload['headers']), + earlyLocalFrames, + ); + tunnel = await this.connectRelay(`/v1/remote/stream/${encodeURIComponent(streamId)}`); + if (this.stopped || this.management?.readyState !== WebSocket.OPEN) { + throw new Error('management connection closed'); + } + this.streams.set(streamId, { local, tunnel }); + this.onStatus('device_connected'); + bridgeSockets( + local, + tunnel, + () => { + if (this.streams.get(streamId)?.local === local) { + this.streams.delete(streamId); + this.onStatus('device_disconnected'); + } + }, + earlyLocalFrames, + ); + this.sendOpenStreamResult(streamId, true); + } catch (error) { + local?.close(); + tunnel?.close(); + this.sendOpenStreamResult( + streamId, + false, + local === undefined ? 'LOCAL_WS_FAILED' : 'TUNNEL_STREAM_FAILED', + errorMessage(error), + ); + } + } + + private sendOpenStreamResult( + streamId: string, + success: boolean, + errorCode?: string, + error?: string, + ): void { + if (this.management?.readyState !== WebSocket.OPEN) return; + this.management.send( + JSON.stringify({ + type: 'open_ws_result', + payload: { + stream_id: streamId, + success, + error_code: errorCode, + error_message: error, + }, + }), + ); + } + + private closeStream(streamId: string): void { + const stream = this.streams.get(streamId); + if (stream === undefined) return; + this.streams.delete(streamId); + this.onStatus('device_disconnected'); + stream.local.close(); + stream.tunnel.close(); + } + + private clearPendingHttpRequest(requestId: string): void { + const pending = this.pendingHttpRequests.get(requestId); + if (pending === undefined) return; + this.pendingHttpRequests.delete(requestId); + this.pendingHttpBytes -= pending.size; + } + + private closeCycle(): void { + for (const streamId of this.streams.keys()) this.closeStream(streamId); + this.pendingHttpRequests.clear(); + this.pendingHttpBytes = 0; + this.management?.close(); + this.http?.close(); + if (this.relayOnline) { + this.relayOnline = false; + this.onStatus('relay_disconnected'); + } + this.management = undefined; + this.http = undefined; + } + + private publicPrefix(): string { + const relayPath = new URL(this.relayOrigin).pathname.replace(/\/+$/, ''); + return `${relayPath}/devices/${encodeURIComponent(this.deviceId)}`; + } + + private async waitForReconnect(ms: number): Promise { + if (this.stopped) return; + const controller = new AbortController(); + this.reconnectAbort = controller; + try { + await sleep(ms, undefined, { signal: controller.signal }); + } catch (error) { + if (!(error instanceof Error) || error.name !== 'AbortError') throw error; + } finally { + if (this.reconnectAbort === controller) this.reconnectAbort = undefined; + } + } +} + +async function connectWebSocket( + url: string, + token: string, + headers: Record = {}, + earlyFrames?: [RawData, boolean][], +): Promise { + const protocol = `pythinker-code.bearer.${token}`; + if (isWebSocketProtocolToken(protocol)) { + try { + return await connectWebSocketAttempt(url, [protocol], headers, earlyFrames); + } catch {} + } + return connectWebSocketAttempt( + url, + undefined, + { + ...headers, + Authorization: `Bearer ${token}`, + }, + earlyFrames, + ); +} + +function connectWebSocketAttempt( + url: string, + protocols: string[] | undefined, + headers: Record, + earlyFrames?: [RawData, boolean][], +): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(url, protocols, { + headers, + handshakeTimeout: REGISTER_TIMEOUT_MS, + }); + if (earlyFrames !== undefined) { + socket.on('message', (data, isBinary) => { + earlyFrames.push([data, isBinary]); + }); + } + let settled = false; + const cleanup = (): void => { + socket.off('open', onOpen); + socket.off('error', onError); + socket.off('close', onClose); + }; + const finish = (error?: Error): void => { + if (settled) return; + settled = true; + cleanup(); + if (error === undefined) resolve(socket); + else reject(error); + }; + const onOpen = (): void => finish(); + const onError = (error: Error): void => finish(error); + const onClose = (code: number, reason: Buffer): void => { + finish(new Error(`WebSocket closed during handshake (${code} ${reason.toString()})`)); + }; + socket.once('open', onOpen); + socket.once('error', onError); + socket.once('close', onClose); + }); +} + +function isWebSocketProtocolToken(value: string): boolean { + return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value); +} + +function waitForRelayMessage(socket: WebSocket, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => finish(new Error('Remote Control registration timed out')), timeoutMs); + const onMessage = (data: RawData): void => { + try { + finish(undefined, parseRelayMessage(data)); + } catch (error) { + finish(error); + } + }; + const onClose = (code: number, reason: Buffer): void => { + finish(new Error(`Remote Control registration closed (${code} ${reason.toString()})`)); + }; + const onError = (error: Error): void => finish(error); + const finish = (error?: unknown, message?: RelayMessage): void => { + clearTimeout(timer); + socket.off('message', onMessage); + socket.off('close', onClose); + socket.off('error', onError); + if (error !== undefined) reject(error); + else resolve(message!); + }; + socket.once('message', onMessage); + socket.once('close', onClose); + socket.once('error', onError); + }); +} + +function waitForSocketEnd(socket: WebSocket): Promise { + return new Promise((resolve) => { + socket.once('close', () => resolve()); + socket.once('error', () => resolve()); + }); +} + +function parseRelayMessage(data: RawData): RelayMessage { + const parsed = JSON.parse(rawDataText(data)) as Record; + if (typeof parsed['type'] !== 'string') throw new Error('relay message has no type'); + const payload = isRecord(parsed['payload']) ? parsed['payload'] : undefined; + return { type: parsed['type'], payload }; +} + +function requestLocalHttp( + localOrigin: string, + parsed: ParsedRawHttpRequest, + serverToken: string, + publicPrefix: string, +): Promise { + const origin = new URL(localOrigin); + return new Promise((resolve, reject) => { + const request = httpRequest( + { + protocol: origin.protocol, + hostname: origin.hostname, + port: origin.port, + method: parsed.method, + path: parsed.path, + headers: [ + ...filterForwardRequestHeaders(parsed.headers, serverToken), + 'Host', + origin.host, + ], + timeout: HTTP_REQUEST_TIMEOUT_MS, + }, + (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer | string) => chunks.push(Buffer.from(chunk))); + response.once('error', reject); + response.once('end', () => { + const contentType = response.headers['content-type'] ?? ''; + const receivedBody = Buffer.concat(chunks); + const body = + response.headers['content-encoding'] === undefined + ? rewriteRemoteControlResponse(contentType, receivedBody, publicPrefix) + : receivedBody; + const rewritten = body !== receivedBody; + const headers = filterResponseHeaders(response.rawHeaders, rewritten); + if (rewritten) headers.push('Cache-Control', 'no-cache'); + headers.push('Content-Length', String(body.length)); + const statusCode = response.statusCode ?? 502; + const statusMessage = response.statusMessage ?? 'Bad Gateway'; + resolve( + Buffer.concat([ + Buffer.from(`HTTP/1.1 ${statusCode} ${statusMessage}\r\n${headerLines(headers)}\r\n\r\n`), + body, + ]), + ); + }); + }, + ); + request.once('timeout', () => request.destroy(new Error('local HTTP request timed out'))); + request.once('error', reject); + request.end(parsed.body); + }); +} + +function filterResponseHeaders(rawHeaders: readonly string[], blockCacheControl = false): string[] { + const connectionHeaders = new Set(); + for (let index = 0; index < rawHeaders.length; index += 2) { + if (rawHeaders[index]!.toLowerCase() === 'connection') { + for (const token of rawHeaders[index + 1]!.split(',')) { + connectionHeaders.add(token.trim().toLowerCase()); + } + } + } + const result: string[] = []; + for (let index = 0; index < rawHeaders.length; index += 2) { + const name = rawHeaders[index]!; + const lower = name.toLowerCase(); + if (BLOCKED_RESPONSE_HEADERS.has(lower) || connectionHeaders.has(lower)) { + continue; + } + if (blockCacheControl && lower === 'cache-control') continue; + result.push(name, rawHeaders[index + 1]!); + } + return result; +} + +function relayHeaders(value: unknown): Record { + if (!isRecord(value)) return {}; + const entries: [string, string][] = []; + for (const [name, raw] of Object.entries(value)) { + if (typeof raw !== 'string') continue; + const lower = name.toLowerCase(); + if (BLOCKED_REQUEST_HEADERS.has(lower)) continue; + try { + validateHeaderName(name); + validateHeaderValue(name, raw); + entries.push([name, raw]); + } catch {} + } + return Object.fromEntries(entries); +} + +function bridgeSockets( + left: WebSocket, + right: WebSocket, + onClose: () => void, + earlyLeftFrames?: [RawData, boolean][], +): void { + let closed = false; + const closeBoth = (code = 1000, reason = Buffer.alloc(0)): void => { + if (closed) return; + closed = true; + onClose(); + const safeCode = isValidCloseCode(code) ? code : 1000; + if (left.readyState === WebSocket.OPEN) left.close(safeCode, reason); + if (right.readyState === WebSocket.OPEN) right.close(safeCode, reason); + }; + if (earlyLeftFrames !== undefined) { + left.removeAllListeners('message'); + for (const [data, isBinary] of earlyLeftFrames) { + if (right.readyState === WebSocket.OPEN) right.send(data, { binary: isBinary }); + } + } + left.on('message', (data, isBinary) => { + if (right.readyState === WebSocket.OPEN) right.send(data, { binary: isBinary }); + }); + right.on('message', (data, isBinary) => { + if (left.readyState === WebSocket.OPEN) left.send(data, { binary: isBinary }); + }); + left.once('close', closeBoth); + right.once('close', closeBoth); + left.once('error', () => closeBoth(1011)); + right.once('error', () => closeBoth(1011)); +} + +function isValidCloseCode(code: number): boolean { + return ( + code === 1000 || + code === 1001 || + code === 1002 || + code === 1003 || + (code >= 1007 && code <= 1014) || + (code >= 3000 && code <= 4999) + ); +} + +function relayWebSocketUrl(origin: string, path: string): string { + const url = new URL(origin); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + const relayPath = url.pathname.replace(/\/+$/, ''); + const [pathname, query] = path.split('?', 2); + url.pathname = `${relayPath}${pathname}`; + url.search = query === undefined ? '' : query; + url.hash = ''; + return url.toString(); +} + +function localWebSocketUrl(origin: string, path: string): string { + const url = new URL(origin); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + url.pathname = path.split('?', 1)[0]!; + const query = path.includes('?') ? path.slice(path.indexOf('?') + 1) : ''; + url.search = query; + url.hash = ''; + return url.toString(); +} + +function headerLines(headers: readonly string[]): string { + let result = ''; + for (let index = 0; index < headers.length; index += 2) { + result += `${headers[index]}: ${headers[index + 1]}\r\n`; + } + return result.replace(/\r\n$/, ''); +} + +function buildErrorResponse(status: number): Buffer { + const reason = status === 400 ? 'Bad Request' : 'Bad Gateway'; + return Buffer.from(`HTTP/1.1 ${status} ${reason}\r\nContent-Length: 0\r\n\r\n`); +} + +function stringField( + value: Record | undefined, + key: string, +): string | undefined { + const field = value?.[key]; + return typeof field === 'string' ? field : undefined; +} + +function decodeBase64(value: string): Buffer { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + throw new SyntaxError('invalid HTTP tunnel request base64'); + } + return Buffer.from(value, 'base64'); +} + +function rawDataText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); + return Buffer.from(data as ArrayBuffer).toString('utf8'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/pythinker-code/src/cli/sub/web/run.ts b/apps/pythinker-code/src/cli/sub/web/run.ts index cb150f5a6..e7366c3e0 100644 --- a/apps/pythinker-code/src/cli/sub/web/run.ts +++ b/apps/pythinker-code/src/cli/sub/web/run.ts @@ -14,7 +14,7 @@ import { join } from 'node:path'; import { createServerLogger, startServer, type ServerLogger } from '@pymodel/agent-gateway'; import { shutdownTelemetry, track } from '@pymodel/pythinker-telemetry'; import chalk from 'chalk'; -import { type Command } from 'commander'; +import { type Command, Option } from 'commander'; import { CLI_SHUTDOWN_TIMEOUT_MS, WEB_USER_AGENT_SUFFIX } from '#/constant/app'; import { getNativeWebAssetsDir } from '#/native/web-assets'; @@ -25,6 +25,7 @@ import { import { darkColors } from '#/tui/theme/colors'; import { openUrl as defaultOpenUrl } from '#/utils/open-url'; import { getDataDir } from '#/utils/paths'; +import { generateRemoteControlQr } from '#/utils/remote-control-qr'; import { initializeServerTelemetry } from '../../telemetry'; import { @@ -39,6 +40,16 @@ import { splitTokenFragment, } from './access-urls'; import { type NetworkAddress } from './networks'; +import { + formatRemoteControlOutput, + formatRemoteControlStatus, + isRemoteControlEnabled, + REMOTE_CONTROL_FLAG_ENV, + startRemoteControl, + type RemoteControlHandle, + type RemoteControlOptions, + type RemoteControlStatus, +} from './remote-control'; import { DEFAULT_FOREGROUND_LOG_LEVEL, DEFAULT_LAN_HOST, @@ -66,11 +77,14 @@ interface RoutedServer { export interface WebCliOptions extends ServerCliOptions { open?: boolean; + remoteControl?: boolean; } export interface StartForegroundHooks { /** Fires once the server is listening, before the foreground runner blocks. */ - onReady?: (origin: string) => void; + onReady?: (origin: string) => void | Promise; + /** Fires once shutdown starts, before the server socket is closed. */ + onShutdown?: (reason: string) => void | Promise; } export interface WebCommandDeps { @@ -87,6 +101,8 @@ export interface WebCommandDeps { * it simply print/open the plain origin. */ resolveToken?: () => string | undefined; + /** Remote Control starter; defaults to the real relay client when omitted. */ + startRemoteControl?: (options: RemoteControlOptions) => Promise; /** * Non-loopback interface addresses to display for a wildcard bind. Defaults * to the machine's own interfaces (`listNetworkAddresses()`); inject a fixed @@ -109,8 +125,12 @@ export function buildWebUrl(origin: string, token: string): string { } /** Build the `web` command, mounting the runner action on `cmd` itself. */ -export function buildWebCommand(cmd: Command): Command { - return cmd +export function buildWebCommand( + cmd: Command, + opts: { forceRemoteControl?: boolean } = {}, +): Command { + const forceRemoteControl = opts.forceRemoteControl === true; + const withServerOptions = cmd .option( '--port ', `Bind port (default ${DEFAULT_SERVER_PORT})`, @@ -151,11 +171,24 @@ export function buildWebCommand(cmd: Command): Command { .option( '--web-title ', 'Set a custom browser tab title for this web UI instance (default: "<workspace dir> | Pythinker Code").', - ) + ); + if (!forceRemoteControl) { + withServerOptions.addOption( + new Option( + '--rc, --remote-control', + 'Expose the web UI through Pythinker Remote Control (experimental).', + ) + .default(false) + .hideHelp(!isRemoteControlEnabled()), + ); + } + return withServerOptions .option('--no-open', 'Do not open the web UI in the default browser.', true) .action(async (opts: WebCliOptions) => { try { - await handleWebCommand(opts); + await handleWebCommand( + forceRemoteControl ? { ...opts, remoteControl: true } : opts, + ); } catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exit(1); @@ -168,9 +201,21 @@ export async function handleWebCommand( deps: WebCommandDeps = DEFAULT_WEB_COMMAND_DEPS, ): Promise<void> { const parsed = parseServerOptions(opts); + if (opts.remoteControl === true && !isRemoteControlEnabled()) { + throw new Error( + `--remote-control is experimental: set ${REMOTE_CONTROL_FLAG_ENV}=1 (or PYTHINKER_CODE_EXPERIMENTAL_FLAG=1) to enable it.`, + ); + } + if (opts.remoteControl === true && parsed.dangerousBypassAuth) { + throw new Error('--remote-control cannot be combined with --dangerous-bypass-auth.'); + } + if (opts.remoteControl === true && !isLoopbackHost(parsed.host)) { + throw new Error('--remote-control requires a loopback host.'); + } const run = deps.startServerForeground ?? startServerForeground; + let remoteControl: RemoteControlHandle | undefined; await run(parsed, { - onReady: (origin) => { + onReady: async (origin) => { // Resolve the persistent token only once the server is up: a fresh // server writes `server.token` on first boot, so reading it beforehand // would miss first-time starts and the browser would hit the auth gate. @@ -179,6 +224,41 @@ export async function handleWebCommand( // token line when unavailable. When auth is bypassed, the token is // meaningless and is intentionally NOT shown or carried in the URL. const token = parsed.dangerousBypassAuth ? undefined : deps.resolveToken?.(); + if (opts.remoteControl === true) { + if (token === undefined) throw new Error('Unable to read the local server token.'); + const dataDir = getDataDir(); + // Status lines can arrive while the relay handshake is still running, + // before the banner is printed. Buffer them so they never interleave + // with the banner they are supposed to follow. + let outputReady = false; + const pendingStatuses: string[] = []; + const onStatus = (status: RemoteControlStatus): void => { + const line = formatRemoteControlStatus(status); + if (outputReady) deps.stdout.write(line); + else pendingStatuses.push(line); + }; + remoteControl = await (deps.startRemoteControl ?? startRemoteControl)({ + homeDir: dataDir, + localOrigin: origin, + localServerToken: token, + stderr: deps.stderr, + onStatus, + }); + const qrCode = await generateRemoteControlQr(remoteControl.url, dataDir); + deps.stdout.write( + formatRemoteControlOutput({ + url: remoteControl.url, + localOrigin: origin, + deviceName: remoteControl.deviceName, + qrCode: qrCode.terminal, + pngPath: qrCode.pngPath, + }), + ); + outputReady = true; + for (const line of pendingStatuses) deps.stdout.write(line); + if (opts.open === true) deps.openUrl(remoteControl.url); + return; + } deps.stdout.write( parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL ? formatReadyBanner(origin, parsed.host, { @@ -192,6 +272,9 @@ export async function handleWebCommand( deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin); } }, + onShutdown: async () => { + await remoteControl?.close(); + }, }); } @@ -229,7 +312,7 @@ export async function startServerForeground( options: ParsedServerOptions, hooks: StartForegroundHooks = {}, ): Promise<never> { - return runServerInProcess(options, hooks.onReady); + return runServerInProcess(options, hooks); } /** @@ -238,7 +321,7 @@ export async function startServerForeground( */ async function runServerInProcess( options: ParsedServerOptions, - onReady?: (origin: string) => void, + hooks: StartForegroundHooks = {}, ): Promise<never> { const version = getVersion(); // Registers the telemetry provider for `track` / `shutdownTelemetry`; the @@ -252,6 +335,14 @@ async function runServerInProcess( if (stopping) return; stopping = true; running?.logger.info({ reason }, 'server shutting down'); + try { + await hooks.onShutdown?.(reason); + } catch (error) { + running?.logger.error( + { err: error instanceof Error ? error : new Error(String(error)) }, + 'foreground shutdown hook error', + ); + } try { await running?.close(); await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); @@ -322,7 +413,17 @@ async function runServerInProcess( running.logger.info({ address: running.address }, 'server ready'); - onReady?.(running.address); + try { + await hooks.onReady?.(running.address); + } catch (error) { + try { + await hooks.onShutdown?.('startup_failed'); + } finally { + await running.close(); + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + } + throw error; + } return new Promise<never>(() => { // Keeps the event loop alive; the process ends via shutdown()/process.exit. diff --git a/apps/pythinker-code/src/tui/commands/dispatch.ts b/apps/pythinker-code/src/tui/commands/dispatch.ts index b78a6aefe..59e0ff6cd 100644 --- a/apps/pythinker-code/src/tui/commands/dispatch.ts +++ b/apps/pythinker-code/src/tui/commands/dispatch.ts @@ -70,7 +70,7 @@ import { import { handleDynamicWorkflowCommand } from './dynamic_workflow'; import { handleTowerCommand } from './tower'; import { handleUndoCommand } from './undo'; -import { handleWebCommand } from './web'; +import { handleRemoteControlCommand, handleWebCommand } from './web'; // --------------------------------------------------------------------------- // Re-exports — keep existing consumers working @@ -109,7 +109,7 @@ export { handleTitleCommand, } from './session'; export { handleUndoCommand } from './undo'; -export { handleWebCommand } from './web'; +export { handleRemoteControlCommand, handleWebCommand } from './web'; // --------------------------------------------------------------------------- // Host interface @@ -605,6 +605,9 @@ async function handleBuiltInSlashCommand( case 'web': await handleWebCommand(host); return; + case 'remote-control': + await handleRemoteControlCommand(host); + return; default: host.showError(`Unknown slash command: /${String(name)}`); return; diff --git a/apps/pythinker-code/src/tui/commands/index.ts b/apps/pythinker-code/src/tui/commands/index.ts index f1e538d54..222a821ce 100644 --- a/apps/pythinker-code/src/tui/commands/index.ts +++ b/apps/pythinker-code/src/tui/commands/index.ts @@ -31,7 +31,7 @@ export { handleGoalCommand, parseGoalCommand, goalObjectiveLengthWarning } from export { goalArgumentCompletions, towerArgumentCompletions } from './registry'; export { handleForkCommand, handleInitCommand, handleTitleCommand } from './session'; export { handleUndoCommand } from './undo'; -export { handleWebCommand } from './web'; +export { handleRemoteControlCommand, handleWebCommand } from './web'; export { promptApiKey, promptCatalogProviderSelection, diff --git a/apps/pythinker-code/src/tui/commands/registry.ts b/apps/pythinker-code/src/tui/commands/registry.ts index 483822797..d98f9887a 100644 --- a/apps/pythinker-code/src/tui/commands/registry.ts +++ b/apps/pythinker-code/src/tui/commands/registry.ts @@ -418,6 +418,14 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 40, availability: 'always', }, + { + name: 'remote-control', + aliases: ['rc'], + description: 'Open the current session through Pythinker Remote Control (experimental)', + priority: 40, + availability: 'always', + experimentalFlag: 'remote-control', + }, { name: 'exit', aliases: ['quit', 'q'], diff --git a/apps/pythinker-code/src/tui/commands/web.ts b/apps/pythinker-code/src/tui/commands/web.ts index 9c12f96ba..94c44bf39 100644 --- a/apps/pythinker-code/src/tui/commands/web.ts +++ b/apps/pythinker-code/src/tui/commands/web.ts @@ -1,10 +1,22 @@ import chalk from 'chalk'; import { splitTokenFragment } from '#/cli/sub/web/access-urls'; +import { + buildRemoteControlUrl, + formatRemoteControlOutput, + formatRemoteControlStatus, + startRemoteControl, + type RemoteControlStatus, +} from '#/cli/sub/web/remote-control'; +import { + formatRemoteControlAlreadyRunning, + inspectRemoteControlLock, +} from '#/cli/sub/web/remote-control-lock'; import { formatReadyBanner, startServerForeground } from '#/cli/sub/web/run'; import { parseServerOptions, tryResolveServerToken } from '#/cli/sub/web/shared'; import { openUrl } from '#/utils/open-url'; import { getDataDir } from '#/utils/paths'; +import { generateRemoteControlQr } from '#/utils/remote-control-qr'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui'; import { darkColors } from '../theme/colors'; @@ -30,6 +42,72 @@ export async function handleWebCommand(host: SlashCommandHost): Promise<void> { await host.stop(); } +/** + * `/remote-control` — hand the current session off to a remote device. + * + * Same exit takeover as `/web`, except the process also opens a relay tunnel + * so the session is reachable from a phone or another machine. The relay link + * and its QR code print once the tunnel is up. + */ +export async function handleRemoteControlCommand(host: SlashCommandHost): Promise<void> { + await host.waitForLazyCreation(); + const session = host.session; + + const holder = await inspectRemoteControlLock(getDataDir()); + if (holder !== undefined) { + host.showError(formatRemoteControlAlreadyRunning(holder)); + return; + } + + host.setExitForegroundTask(async () => { + const options = parseServerOptions({}); + let remoteControl: Awaited<ReturnType<typeof startRemoteControl>> | undefined; + try { + await startServerForeground(options, { + onReady: async (origin) => { + const dataDir = getDataDir(); + const token = tryResolveServerToken(dataDir); + if (token === undefined) throw new Error('Unable to read the local server token.'); + let outputReady = false; + const pendingStatuses: string[] = []; + const onStatus = (status: RemoteControlStatus): void => { + const line = formatRemoteControlStatus(status); + if (outputReady) process.stdout.write(line); + else pendingStatuses.push(line); + }; + remoteControl = await startRemoteControl({ + homeDir: dataDir, + localOrigin: origin, + localServerToken: token, + onStatus, + }); + const url = buildRemoteControlUrl(remoteControl.deviceId, session?.id, undefined, token); + const qrCode = await generateRemoteControlQr(url, dataDir); + process.stdout.write( + formatRemoteControlOutput({ + url, + localOrigin: origin, + deviceName: remoteControl.deviceName, + qrCode: qrCode.terminal, + pngPath: qrCode.pngPath, + }), + ); + outputReady = true; + for (const line of pendingStatuses) process.stdout.write(line); + openUrl(url); + }, + onShutdown: async () => { + await remoteControl?.close(); + }, + }); + } catch (error) { + process.stderr.write(`Failed to start Remote Control: ${formatErrorMessage(error)}\n`); + process.exit(1); + } + }); + await host.stop(); +} + /** * Register the exit takeover that turns this process into the new server once * the TUI has shut down (where `process.exit` would normally happen): the diff --git a/apps/pythinker-code/src/utils/remote-control-qr.ts b/apps/pythinker-code/src/utils/remote-control-qr.ts new file mode 100644 index 000000000..affd659f0 --- /dev/null +++ b/apps/pythinker-code/src/utils/remote-control-qr.ts @@ -0,0 +1,62 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { + getCapabilities, + getCellDimensions, + getPngDimensions, + renderImage, +} from '@pymodel/pi-tui'; +import * as QRCode from 'qrcode'; + +const TERMINAL_QR_MARGIN = 2; +const TERMINAL_QR_DARK = '0;0;0'; +const TERMINAL_QR_LIGHT = '255;255;255'; +const ANSI_RESET = '\u001B[0m'; + +const QR_PNG_MARGIN = 4; +const QR_IMAGE_MIN_PX_PER_MODULE = 4; + +export async function generateRemoteControlQr( + url: string, + dataDir: string, +): Promise<{ terminal: string; pngPath: string }> { + await mkdir(dataDir, { recursive: true }); + const pngPath = resolve(dataDir, 'rc-qrcode.png'); + const png = await QRCode.toBuffer(url, { type: 'png', margin: QR_PNG_MARGIN }); + await writeFile(pngPath, png); + const terminal = renderInlineImageQr(url, png) ?? renderTerminalQr(url); + return { terminal, pngPath }; +} + +function renderInlineImageQr(url: string, png: Buffer): string | null { + if (getCapabilities().images === null) return null; + const base64 = png.toString('base64'); + const dimensions = getPngDimensions(base64); + if (dimensions === null) return null; + const moduleCount = + QRCode.create(url, { errorCorrectionLevel: 'M' }).modules.size + QR_PNG_MARGIN * 2; + const maxWidthCells = Math.ceil( + (moduleCount * QR_IMAGE_MIN_PX_PER_MODULE) / getCellDimensions().widthPx, + ); + const rendered = renderImage(base64, dimensions, { maxWidthCells }); + return rendered === null ? null : `${rendered.sequence}\n`; +} + +export function renderTerminalQr(url: string): string { + const qr = QRCode.create(url, { errorCorrectionLevel: 'M' }); + const size: number = qr.modules.size; + const data: Uint8Array = qr.modules.data; + const isDark = (x: number, y: number): boolean => + x >= 0 && y >= 0 && x < size && y < size && data[y * size + x] === 1; + let output = ''; + for (let y = -TERMINAL_QR_MARGIN; y < size + TERMINAL_QR_MARGIN; y += 2) { + for (let x = -TERMINAL_QR_MARGIN; x < size + TERMINAL_QR_MARGIN; x++) { + const top = isDark(x, y) ? TERMINAL_QR_DARK : TERMINAL_QR_LIGHT; + const bottom = isDark(x, y + 1) ? TERMINAL_QR_DARK : TERMINAL_QR_LIGHT; + output += `\u001B[38;2;${top}m\u001B[48;2;${bottom}m▀`; + } + output += `${ANSI_RESET}\n`; + } + return output + ANSI_RESET; +} diff --git a/apps/pythinker-code/src/utils/terminal-hyperlink.ts b/apps/pythinker-code/src/utils/terminal-hyperlink.ts index 43d27f0a3..f77ea7dec 100644 --- a/apps/pythinker-code/src/utils/terminal-hyperlink.ts +++ b/apps/pythinker-code/src/utils/terminal-hyperlink.ts @@ -1,3 +1,24 @@ +const HYPERLINK_TERM_PROGRAMS = new Set([ + 'iTerm.app', + 'WezTerm', + 'vscode', + 'ghostty', + 'WarpTerminal', + 'Hyper', +]); +const HYPERLINK_TERMS = new Set(['xterm-kitty', 'xterm-ghostty', 'wezterm', 'foot', 'contour']); + +export function supportsHyperlinks(env: NodeJS.ProcessEnv = process.env): boolean { + const force = env['FORCE_HYPERLINK']; + if (force !== undefined) return force !== '0'; + if ((env['WT_SESSION'] ?? '').length > 0) return true; + if (HYPERLINK_TERM_PROGRAMS.has(env['TERM_PROGRAM'] ?? '')) return true; + if (HYPERLINK_TERMS.has(env['TERM'] ?? '')) return true; + if (Number(env['VTE_VERSION'] ?? '0') >= 5000) return true; + if ((env['KONSOLE_VERSION'] ?? '').length > 0) return true; + return false; +} + export function toTerminalHyperlink(text: string, url: string): string { return `\u001B]8;;${url}\u0007${text}\u001B]8;;\u0007`; } diff --git a/apps/pythinker-code/test/cli/options.test.ts b/apps/pythinker-code/test/cli/options.test.ts index 79ffe2dea..45f6a125f 100644 --- a/apps/pythinker-code/test/cli/options.test.ts +++ b/apps/pythinker-code/test/cli/options.test.ts @@ -5,7 +5,7 @@ * Run: pnpm -C apps/pythinker-code exec vitest run test/cli/options.test.ts */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; import { createProgram } from '#/cli/commands'; import type { CLIOptions } from '#/cli/options'; @@ -574,12 +574,21 @@ describe('CLI options parsing', () => { }); it('registers the visible sub-commands', () => { + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', '0'); + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0'); + onTestFinished(() => { + vi.unstubAllEnvs(); + }); const program = createProgram( '0.0.0', () => {}, ); const commandNames: string[] = program.commands - .filter((command) => !command.name().startsWith('__')) + .filter( + (command) => + !command.name().startsWith('__') && + !(command as unknown as { _hidden?: boolean })._hidden, + ) .map((command) => command.name()); expect(commandNames).toEqual([ 'export', diff --git a/apps/pythinker-code/test/cli/web/remote-control.test.ts b/apps/pythinker-code/test/cli/web/remote-control.test.ts new file mode 100644 index 000000000..a5f45581f --- /dev/null +++ b/apps/pythinker-code/test/cli/web/remote-control.test.ts @@ -0,0 +1,782 @@ +import { createServer, type IncomingMessage } from 'node:http'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { WebSocketServer, type RawData, type WebSocket } from 'ws'; + +import { + buildRemoteControlUrl, + filterForwardRequestHeaders, + formatRemoteControlOutput, + formatRemoteControlStatus, + isRemoteControlEnabled, + parseRawHttpRequest, + rewriteRemoteControlResponse, + startRemoteControl, + type RemoteControlHandle, +} from '#/cli/sub/web/remote-control'; +import { remoteControlLockPath } from '#/cli/sub/web/remote-control-lock'; + +const RELAY_TOKEN = 'relay-token'; + +const cleanups: Array<() => Promise<void> | void> = []; + +afterEach(async () => { + vi.unstubAllEnvs(); + while (cleanups.length > 0) await cleanups.pop()!(); +}); + +describe('Remote Control experimental flag', () => { + it('is off unless the per-feature env or the master switch is truthy', () => { + expect(isRemoteControlEnabled({})).toBe(false); + expect(isRemoteControlEnabled({ PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL: '0' })).toBe(false); + expect(isRemoteControlEnabled({ PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL: '1' })).toBe(true); + expect(isRemoteControlEnabled({ PYTHINKER_CODE_EXPERIMENTAL_FLAG: 'true' })).toBe(true); + expect( + isRemoteControlEnabled({ + PYTHINKER_CODE_EXPERIMENTAL_FLAG: '0', + PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL: 'yes', + }), + ).toBe(true); + }); +}); + +describe('Remote Control URLs', () => { + it('builds the public device entry without a local token', () => { + const url = buildRemoteControlUrl('device/one'); + expect(url).toBe( + 'https://code-rc.pythinker.com/devices/device%2Fone/?rc=1&from=pythinker_code_cli', + ); + expect(url).not.toContain('token'); + }); + + it('builds an encoded session deep link before the query', () => { + expect(buildRemoteControlUrl('device-1', 'session/a b')).toBe( + 'https://code-rc.pythinker.com/devices/device-1/sessions/session%2Fa%20b?rc=1&from=pythinker_code_cli', + ); + }); +}); + +describe('Remote Control output', () => { + const outputOptions = { + url: 'https://example.test/devices/example-device/?rc=1&from=pythinker_code_cli', + localOrigin: 'http://127.0.0.1:1234', + deviceName: 'example-device', + qrCode: 'QR\n', + pngPath: '/tmp/example-qr.png', + }; + + it('keeps the full URL clickable while showing a short link and the setup contract', () => { + vi.stubEnv('FORCE_HYPERLINK', '1'); + const output = formatRemoteControlOutput(outputOptions); + const url = outputOptions.url; + expect(output).toContain('Use Pythinker Code on this machine'); + expect(output).toContain('1.'); + expect(output).toContain('2.'); + expect(output).not.toContain('3.'); + expect(output).toContain('example.test/devices/exampl…vice/'); + expect(output).toContain(`\u001B]8;;${url}`); + expect(output).toContain('Connected to example.test'); + expect(output).toContain('This device:'); + expect(output).not.toContain('Manage devices'); + expect(output).toContain('PNG:'); + expect(output).toContain('\n QR'); + expect(output).toContain('grants control of this machine'); + expect(output).toContain('docs'); + expect(output).toContain('feedback'); + expect(output).toContain('Logs: off'); + expect(output).not.toContain('stream-1'); + }); + + it('prints the full URL as plain text when the terminal cannot render hyperlinks', () => { + vi.stubEnv('FORCE_HYPERLINK', '0'); + const output = formatRemoteControlOutput(outputOptions); + expect(output).toContain(`open ${outputOptions.url}`); + expect(output).not.toContain('exampl…vice'); + expect(output).not.toContain('Manage devices'); + }); + + it('formats relay and device lifecycle states', () => { + expect(formatRemoteControlStatus('relay_connected').toLowerCase()).toContain('connected'); + expect(formatRemoteControlStatus('relay_disconnected')).toContain('disconnected'); + expect(formatRemoteControlStatus('device_connected').toLowerCase()).toContain('connected'); + expect(formatRemoteControlStatus('device_disconnected')).toContain('disconnected'); + }); +}); + +describe('Remote Control HTTP forwarding', () => { + it('parses raw requests and replaces relay credentials with local bearer auth', () => { + const parsed = parseRawHttpRequest( + Buffer.from( + 'POST /api/v1/messages?q=1 HTTP/1.1\r\nHost: relay.example\r\nAuthorization: Bearer relay\r\nCookie: sid=1\r\nOrigin: https://relay.example\r\nConnection: keep-alive, X-Hop\r\nX-Hop: remove\r\nX-Keep: yes\r\nContent-Length: 4\r\n\r\ndata', + ), + ); + expect(parsed).toMatchObject({ method: 'POST', path: '/api/v1/messages?q=1' }); + expect(parsed.body.toString()).toBe('data'); + expect(filterForwardRequestHeaders(parsed.headers, 'local-token')).toEqual([ + 'X-Keep', + 'yes', + 'Content-Length', + '4', + 'Authorization', + 'Bearer local-token', + ]); + }); + + it('rejects absolute-form and malformed request targets', () => { + expect(() => + parseRawHttpRequest(Buffer.from('GET https://example.test/ HTTP/1.1\r\n\r\n')), + ).toThrow(/request line/); + expect(() => parseRawHttpRequest(Buffer.from('GET //example.test/ HTTP/1.1\r\n\r\n'))).toThrow( + /request line/, + ); + }); + + it('rewrites HTML, JavaScript, and CSS under the device prefix', () => { + const prefix = '/coding-relay/devices/device-1'; + const html = rewriteRemoteControlResponse( + 'text/html; charset=utf-8', + Buffer.from('<html><head></head><body><script src="/boot.js"></script><a href="/x">x</a></body></html>'), + prefix, + ).toString(); + expect(html).toContain(`src="${prefix}/boot.js"`); + expect(html).toContain(`href="${prefix}/x"`); + expect(html).toContain("sessionStorage.setItem('pythinker-desktop-server-origin',location.origin+p)"); + expect(html).toContain('history.pushState=w(history.pushState)'); + + const js = rewriteRemoteControlResponse( + 'text/javascript', + Buffer.from( + 'const a="/assets/a.js";const s="/sessions/";const p=function(e){return"/"+e};', + ), + prefix, + ).toString(); + expect(js).toBe( + `const a="${prefix}/assets/a.js";const s="${prefix}/sessions/";const p=function(e){return"${prefix}/"+e};`, + ); + + const css = rewriteRemoteControlResponse( + 'text/css', + Buffer.from('.x{background:url(/assets/x.png)}'), + prefix, + ).toString(); + expect(css).toBe(`.x{background:url(${prefix}/assets/x.png)}`); + }); +}); + +describe('Remote Control tunnel', () => { + it('surfaces register_nak details', async () => { + const homeDir = mkdtempSync(join(tmpdir(), 'pythinker-rc-nak-')); + cleanups.push(() => rmSync(homeDir, { recursive: true, force: true })); + const managementServer = new WebSocketServer({ noServer: true }); + const relayServer = createServer(); + managementServer.on('connection', (ws) => { + ws.once('message', () => { + ws.send( + JSON.stringify({ + type: 'register_nak', + payload: { + error_code: 'DEVICE_LIMIT_EXCEEDED', + error_message: 'membership allows 3 devices', + }, + }), + ); + }); + }); + relayServer.on('upgrade', (request, socket, head) => { + managementServer.handleUpgrade(request, socket, head, (ws) => + managementServer.emit('connection', ws, request), + ); + }); + const relayPort = await listen(relayServer); + cleanups.push(() => closeServer(relayServer)); + + await expect( + startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:1', + localServerToken: 'local-server-token', + relayOrigin: `http://127.0.0.1:${relayPort}/coding-relay`, + stderr: { write: () => true }, + }), + ).rejects.toThrow(/DEVICE_LIMIT_EXCEEDED.*membership allows 3 devices/); + }); + + it('uses only Authorization when the relay token is not a valid subprotocol token', async () => { + const homeDir = createRemoteControlHome(); + const relayToken = 'invalid/token='; + const relay = await startAuthRelay(); + let handle: RemoteControlHandle | undefined; + cleanups.push(async () => handle?.close()); + + handle = await startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:1', + localServerToken: relayToken, + relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, + stderr: { write: () => true }, + }); + + expect(relay.requests).toHaveLength(2); + expect(relay.requests.every((request) => request.protocol === undefined)).toBe(true); + expect(relay.requests.every((request) => request.authorization === 'Bearer invalid/token=')).toBe( + true, + ); + }); + + it('retries with only Authorization when the server does not echo the subprotocol', async () => { + const homeDir = createRemoteControlHome(); + const relayToken = RELAY_TOKEN; + const relay = await startAuthRelay({ echoProtocol: false }); + let handle: RemoteControlHandle | undefined; + cleanups.push(async () => handle?.close()); + + handle = await startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:1', + localServerToken: relayToken, + relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, + stderr: { write: () => true }, + }); + + expect(relay.requests.some((request) => request.protocol?.startsWith('pythinker-code.bearer.'))).toBe( + true, + ); + expect( + relay.requests.some( + (request) => + request.protocol === undefined && + request.authorization === `Bearer ${relayToken}`, + ), + ).toBe(true); + }); + + it('keeps the initial start pending through transient failures and recovers', async () => { + const homeDir = createRemoteControlHome(); + const relayToken = RELAY_TOKEN; + const relay = await startAuthRelay({ rejectUpgrades: 2 }); + let handle: RemoteControlHandle | undefined; + cleanups.push(async () => handle?.close()); + + handle = await startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:1', + localServerToken: relayToken, + relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, + stderr: { write: () => true }, + }); + + expect(relay.requests.length).toBeGreaterThanOrEqual(4); + expect(handle.url).toContain('?rc=1&from=pythinker_code_cli'); + }, 6000); + + it('reconnects when management closes during the HTTP tunnel handshake', async () => { + const homeDir = createRemoteControlHome(); + const relayToken = RELAY_TOKEN; + const relay = await startAuthRelay({ closeManagementDuringFirstHttpHandshake: true }); + let handle: RemoteControlHandle | undefined; + cleanups.push(async () => handle?.close()); + + handle = await startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:1', + localServerToken: relayToken, + relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, + stderr: { write: () => true }, + }); + + expect(relay.requests.length).toBeGreaterThanOrEqual(4); + }, 6000); + + it('registers, forwards HTTP and WS with local auth, then reconnects the pair', async () => { + const homeDir = mkdtempSync(join(tmpdir(), 'pythinker-rc-')); + cleanups.push(() => rmSync(homeDir, { recursive: true, force: true })); + + let localHttpRequest: IncomingMessage | undefined; + let localWsRequest: IncomingMessage | undefined; + const localWsServer = new WebSocketServer({ noServer: true }); + const localServer = createServer((request, response) => { + localHttpRequest = request; + response.writeHead(200, { + 'Content-Type': 'text/html', + 'Cache-Control': 'public, max-age=31536000, immutable', + Connection: 'X-Remove', + 'X-Remove': 'gone', + }); + response.end('<html><head></head><script src="/boot.js"></script></html>'); + }); + localServer.on('upgrade', (request, socket, head) => { + localWsRequest = request; + localWsServer.handleUpgrade(request, socket, head, (ws) => localWsServer.emit('connection', ws, request)); + }); + const localPort = await listen(localServer); + cleanups.push(() => closeServer(localServer)); + + const managementServer = new WebSocketServer({ noServer: true }); + const httpTunnelServer = new WebSocketServer({ noServer: true }); + const streamServer = new WebSocketServer({ noServer: true }); + const relayServer = createServer(); + const managementConnections: WebSocket[] = []; + const httpConnections: WebSocket[] = []; + const streamConnections: WebSocket[] = []; + const registrations: unknown[] = []; + const managementMessages: unknown[] = []; + const streamMessages: string[] = []; + let localWs: WebSocket | undefined; + + managementServer.on('connection', (ws) => { + managementConnections.push(ws); + ws.on('message', (data) => { + const message = JSON.parse(rawDataText(data)) as { type: string }; + managementMessages.push(message); + if (message.type === 'register') { + registrations.push(message); + ws.send(JSON.stringify({ type: 'register_ack', payload: { success: true } })); + } + }); + }); + httpTunnelServer.on('connection', (ws) => httpConnections.push(ws)); + streamServer.on('connection', (ws) => { + streamConnections.push(ws); + ws.on('message', (data) => streamMessages.push(rawDataText(data))); + }); + localWsServer.on('connection', (ws) => { + localWs = ws; + ws.send('server-hello-frame'); + }); + relayServer.on('upgrade', (request, socket, head) => { + const pathname = new URL(request.url!, 'http://relay.test').pathname; + const target = pathname.endsWith('/v1/remote/create') + ? managementServer + : pathname.endsWith('/v1/remote/http') + ? httpTunnelServer + : streamServer; + target.handleUpgrade(request, socket, head, (ws) => target.emit('connection', ws, request)); + }); + const relayPort = await listen(relayServer); + cleanups.push(() => closeServer(relayServer)); + + let handle: RemoteControlHandle | undefined; + cleanups.push(async () => handle?.close()); + handle = await startRemoteControl({ + homeDir, + localOrigin: `http://127.0.0.1:${localPort}`, + localServerToken: 'local-server-token', + relayOrigin: `http://127.0.0.1:${relayPort}/coding-relay`, + stderr: { write: () => true }, + }); + + expect(registrations).toHaveLength(1); + expect(handle.url).toContain('/coding-relay/devices/'); + expect(handle.url).toContain('?rc=1&from=pythinker_code_cli'); + + const rawRequest = Buffer.from( + 'GET / HTTP/1.1\r\nHost: relay.test\r\nAuthorization: Bearer relay-token\r\nCookie: sid=1\r\nOrigin: https://relay.test\r\nConnection: X-Hop\r\nX-Hop: remove\r\nX-Keep: yes\r\n\r\n', + ); + const splitAt = Math.floor(rawRequest.length / 2); + httpConnections[0]!.send( + JSON.stringify({ + request_id: 'request-1', + type: 'request', + is_last: false, + body_base64: rawRequest.subarray(0, splitAt).toString('base64'), + }), + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(localHttpRequest).toBeUndefined(); + const responsePromise = nextJsonMessage(httpConnections[0]!); + httpConnections[0]!.send( + JSON.stringify({ + request_id: 'request-1', + type: 'request', + is_last: true, + body_base64: rawRequest.subarray(splitAt).toString('base64'), + }), + ); + const responseMessage = await responsePromise; + const response = Buffer.from(responseMessage['body_base64'] as string, 'base64').toString(); + expect(response).toContain('HTTP/1.1 200 OK'); + expect(localHttpRequest?.headers.authorization).toBe('Bearer local-server-token'); + expect(localHttpRequest?.headers.cookie).toBeUndefined(); + expect(localHttpRequest?.headers.origin).toBeUndefined(); + expect(localHttpRequest?.headers['x-hop']).toBeUndefined(); + expect(localHttpRequest?.headers['x-keep']).toBe('yes'); + expect(response).not.toContain('X-Remove'); + expect(response).not.toContain('immutable'); + expect(response).toContain('Cache-Control: no-cache'); + expect(response).toContain(`/coding-relay/devices/${handle.deviceId}/boot.js`); + + managementConnections[0]!.send( + JSON.stringify({ + type: 'open_ws', + payload: { + stream_id: 'stream-1', + path: '/api/v1/ws', + headers: { Cookie: 'relay-cookie', Origin: 'https://relay.test', 'X-Keep': 'yes' }, + }, + }), + ); + await waitFor(() => streamConnections.length === 1 && localWs !== undefined); + expect(localWsRequest?.headers['sec-websocket-protocol']).toBe( + 'pythinker-code.bearer.local-server-token', + ); + expect(localWsRequest?.headers.authorization).toBeUndefined(); + expect(localWsRequest?.headers.cookie).toBeUndefined(); + expect(localWsRequest?.headers.origin).toBeUndefined(); + expect(localWsRequest?.headers['x-keep']).toBe('yes'); + await waitFor(() => + managementMessages.some( + (value) => + (value as { type?: string }).type === 'open_ws_result' && + (value as { payload?: { success?: boolean } }).payload?.success === true, + ), + ); + + await waitFor(() => streamMessages.includes('server-hello-frame')); + const localMessage = nextTextMessage(localWs!); + streamConnections[0]!.send('from-relay'); + await expect(localMessage).resolves.toBe('from-relay'); + const relayMessage = nextTextMessage(streamConnections[0]!); + localWs!.send('from-local'); + await expect(relayMessage).resolves.toBe('from-local'); + + streamConnections[0]!.terminate(); + await waitFor(() => localWs?.readyState === 3); + + httpConnections[0]!.terminate(); + await waitFor(() => registrations.length === 2 && httpConnections.length === 2, 4000); + + await handle.close(); + await waitFor(() => + managementMessages.some( + (value) => + (value as { type?: string; payload?: { reason?: string } }).type === 'disconnect' && + (value as { payload?: { reason?: string } }).payload?.reason === 'local_server_stopped', + ), + ); + }); + + it('reconnects when the relay goes silent without closing the sockets', async () => { + const homeDir = createRemoteControlHome(); + const relayToken = RELAY_TOKEN; + const relay = await startAuthRelay(); + let handle: RemoteControlHandle | undefined; + cleanups.push(async () => handle?.close()); + let logs = ''; + + handle = await startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:1', + localServerToken: relayToken, + relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, + stderr: { write: (text) => ((logs += String(text)), true) }, + pingIntervalMs: 50, + silenceTimeoutMs: 300, + }); + + expect(relay.registrations).toHaveLength(1); + relay.managementSockets[0]!.pause(); + relay.httpSockets[0]!.pause(); + + await waitFor(() => relay.registrations.length === 2, 10_000); + expect(logs).toContain('silent'); + relay.managementSockets[0]!.terminate(); + relay.httpSockets[0]!.terminate(); + }, 15_000); + + it('retries when registration is rejected after a reconnect', async () => { + const homeDir = createRemoteControlHome(); + const relayToken = RELAY_TOKEN; + const relay = await startAuthRelay({ nakRegistrationsAfterFirst: 1 }); + let handle: RemoteControlHandle | undefined; + cleanups.push(async () => handle?.close()); + let logs = ''; + + handle = await startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:1', + localServerToken: relayToken, + relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, + stderr: { write: (text) => ((logs += String(text)), true) }, + }); + + expect(relay.registrations).toHaveLength(1); + relay.managementSockets[0]!.terminate(); + relay.httpSockets[0]!.terminate(); + + await waitFor(() => relay.registrations.length >= 3, 10_000); + await waitFor( + () => relay.managementSockets.some((socket) => socket.readyState === 1) && + relay.httpSockets.some((socket) => socket.readyState === 1), + ); + expect(logs).toContain('DEPLOYING'); + expect(handle.url).toContain('?rc=1&from=pythinker_code_cli'); + }, 15_000); +}); + +describe('Remote Control single-instance lock', () => { + async function deadPid(): Promise<number> { + const child = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' }); + await new Promise<void>((resolve) => child.on('exit', () => resolve())); + return child.pid!; + } + + it('refuses a second instance on the same home and reports the running link', async () => { + const homeDir = createRemoteControlHome(); + const relayToken = RELAY_TOKEN; + const relay = await startAuthRelay(); + let first: RemoteControlHandle | undefined; + cleanups.push(async () => first?.close()); + first = await startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:58627', + localServerToken: relayToken, + relayOrigin: `http://127.0.0.1:${relay.port}`, + stderr: { write: () => true }, + }); + + await expect( + startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:58628', + localServerToken: relayToken, + relayOrigin: `http://127.0.0.1:${relay.port}`, + stderr: { write: () => true }, + }), + ).rejects.toThrow(/already running[\s\S]*127\.0\.0\.1:58627[\s\S]*\/devices\//); + expect(relay.requests).toHaveLength(2); + }); + + it('reaps a stale lock left by a dead process', async () => { + const homeDir = createRemoteControlHome(); + const relayToken = RELAY_TOKEN; + await mkdir(join(homeDir, 'server'), { recursive: true }); + await writeFile( + remoteControlLockPath(homeDir), + JSON.stringify({ + pid: await deadPid(), + nonce: 'stale', + local_origin: 'http://127.0.0.1:1', + device_id: 'dead-device', + url: 'https://code-rc.pythinker.com/devices/dead-device/', + started_at: 0, + }), + ); + const relay = await startAuthRelay(); + let handle: RemoteControlHandle | undefined; + cleanups.push(async () => handle?.close()); + + handle = await startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:58627', + localServerToken: relayToken, + relayOrigin: `http://127.0.0.1:${relay.port}`, + stderr: { write: () => true }, + }); + + const lock = JSON.parse(await readFile(remoteControlLockPath(homeDir), 'utf8')) as { + pid: number; + }; + expect(lock.pid).toBe(process.pid); + }); + + it('releases the lock on close so a new instance can start', async () => { + const homeDir = createRemoteControlHome(); + const relayToken = RELAY_TOKEN; + const relay = await startAuthRelay(); + const options = { + homeDir, + localOrigin: 'http://127.0.0.1:58627', + localServerToken: relayToken, + relayOrigin: `http://127.0.0.1:${relay.port}`, + stderr: { write: () => true }, + }; + const first = await startRemoteControl(options); + await first.close(); + + let second: RemoteControlHandle | undefined; + cleanups.push(async () => second?.close()); + second = await startRemoteControl(options); + expect(second.url).toContain('/devices/'); + }); + + it('does not remove a successor lock when closing', async () => { + const homeDir = createRemoteControlHome(); + const relayToken = RELAY_TOKEN; + const relay = await startAuthRelay(); + const handle = await startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:58627', + localServerToken: relayToken, + relayOrigin: `http://127.0.0.1:${relay.port}`, + stderr: { write: () => true }, + }); + cleanups.push(async () => handle?.close()); + await writeFile( + remoteControlLockPath(homeDir), + JSON.stringify({ + pid: process.pid, + nonce: 'successor', + local_origin: 'http://127.0.0.1:58628', + device_id: 'device-2', + url: 'https://code-rc.pythinker.com/devices/device-2/', + started_at: Date.now(), + }), + ); + + await handle.close(); + + const lock = JSON.parse(await readFile(remoteControlLockPath(homeDir), 'utf8')) as { + nonce: string; + }; + expect(lock.nonce).toBe('successor'); + }); +}); + +function createRemoteControlHome(): string { + const homeDir = mkdtempSync(join(tmpdir(), 'pythinker-rc-auth-')); + cleanups.push(() => rmSync(homeDir, { recursive: true, force: true })); + return homeDir; +} + +async function startAuthRelay( + options: { + echoProtocol?: boolean; + rejectUpgrades?: number; + closeManagementDuringFirstHttpHandshake?: boolean; + nakRegistrationsAfterFirst?: number; + } = {}, +): Promise<{ + port: number; + requests: Array<{ authorization?: string; protocol?: string }>; + registrations: unknown[]; + managementSockets: WebSocket[]; + httpSockets: WebSocket[]; +}> { + const handleProtocols = options.echoProtocol === false ? (): false => false : undefined; + const managementServer = new WebSocketServer({ noServer: true, handleProtocols }); + const httpTunnelServer = new WebSocketServer({ noServer: true, handleProtocols }); + const relayServer = createServer(); + const requests: Array<{ authorization?: string; protocol?: string }> = []; + const registrations: unknown[] = []; + const managementSockets: WebSocket[] = []; + const httpSockets: WebSocket[] = []; + let remainingRejections = options.rejectUpgrades ?? 0; + let closeManagement = options.closeManagementDuringFirstHttpHandshake === true; + let delayHttpUpgrade = closeManagement; + let pendingNaks = options.nakRegistrationsAfterFirst ?? 0; + + managementServer.on('connection', (ws) => { + managementSockets.push(ws); + ws.on('error', () => {}); + ws.on('message', (data) => { + const message = JSON.parse(rawDataText(data)) as { type?: string }; + if (message.type === 'register') { + const isReconnectRegistration = registrations.length > 0; + registrations.push(message); + if (isReconnectRegistration && pendingNaks > 0) { + pendingNaks -= 1; + ws.send( + JSON.stringify({ + type: 'register_nak', + payload: { + error_code: 'DEPLOYING', + error_message: 'relay is restarting', + }, + }), + ); + return; + } + ws.send(JSON.stringify({ type: 'register_ack', payload: { success: true } })); + if (closeManagement) { + closeManagement = false; + setTimeout(() => ws.close(), 10); + } + } + }); + }); + httpTunnelServer.on('connection', (ws) => { + httpSockets.push(ws); + ws.on('error', () => {}); + }); + relayServer.on('upgrade', (request, socket, head) => { + const authorization = request.headers.authorization; + const protocol = request.headers['sec-websocket-protocol']; + requests.push({ + authorization: Array.isArray(authorization) ? authorization[0] : authorization, + protocol: Array.isArray(protocol) ? protocol[0] : protocol, + }); + if (remainingRejections > 0) { + remainingRejections -= 1; + socket.end( + 'HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n', + ); + return; + } + const pathname = new URL(request.url!, 'http://relay.test').pathname; + const target = pathname.endsWith('/v1/remote/create') + ? managementServer + : httpTunnelServer; + const upgrade = (): void => { + target.handleUpgrade(request, socket, head, (ws) => target.emit('connection', ws, request)); + }; + if (target === httpTunnelServer && delayHttpUpgrade) { + delayHttpUpgrade = false; + setTimeout(upgrade, 50); + return; + } + upgrade(); + }); + const port = await listen(relayServer); + cleanups.push(() => closeServer(relayServer)); + return { port, requests, registrations, managementSockets, httpSockets }; +} + +function listen(server: ReturnType<typeof createServer>): Promise<number> { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address === null || typeof address === 'string') reject(new Error('missing address')); + else resolve(address.port); + }); + }); +} + +function closeServer(server: ReturnType<typeof createServer>): Promise<void> { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) resolve(); + else reject(error); + }); + }); +} + +function rawDataText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); + return Buffer.from(data as ArrayBuffer).toString('utf8'); +} + +function nextJsonMessage(socket: WebSocket): Promise<Record<string, unknown>> { + return new Promise((resolve) => { + socket.once('message', (data) => resolve(JSON.parse(rawDataText(data)) as Record<string, unknown>)); + }); +} + +function nextTextMessage(socket: WebSocket): Promise<string> { + return new Promise((resolve) => { + socket.once('message', (data) => resolve(rawDataText(data))); + }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('condition timed out'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} diff --git a/apps/pythinker-code/test/cli/web/web.test.ts b/apps/pythinker-code/test/cli/web/web.test.ts index 6bfa88212..7ae5564ea 100644 --- a/apps/pythinker-code/test/cli/web/web.test.ts +++ b/apps/pythinker-code/test/cli/web/web.test.ts @@ -50,7 +50,7 @@ function makeRunner(origin = 'http://127.0.0.1:58627'): { const calls: { options: ParsedServerOptions | undefined } = { options: undefined }; const runner: ForegroundRunner = async (options, hooks) => { calls.options = options; - hooks?.onReady?.(origin); + await hooks?.onReady?.(origin); return undefined as never; }; return { runner, calls }; @@ -103,6 +103,8 @@ describe('pythinker web', () => { expect(longs).toContain('--log-level'); expect(longs).toContain('--debug-endpoints'); expect(longs).toContain('--web-title'); + const remoteControl = web!.options.find((option) => option.long === '--remote-control'); + expect(remoteControl?.short).toBe('--rc'); // web opens the browser by default → the option is the negative --no-open. expect(longs).toContain('--no-open'); // The background/daemon era flags are gone: the server always runs in the @@ -392,6 +394,57 @@ describe('`pythinker web` opens the browser', () => { expect(openUrl).not.toHaveBeenCalled(); }); + + it('maps --remote-control and --rc to the same option', () => { + for (const flag of ['--remote-control', '--rc']) { + const program = makeProgram(); + const web = program.commands.find((command) => command.name() === 'web')!; + web.parseOptions([flag]); + expect(web.opts()).toMatchObject({ remoteControl: true }); + } + }); + + it('rejects Remote Control on a non-loopback host', async () => { + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1'); + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr } = makeIo(); + + await expect( + handleWebCommand( + { remoteControl: true, host: '0.0.0.0', open: false }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ), + ).rejects.toThrow('--remote-control requires a loopback host.'); + }); + + it('rejects --remote-control while the experimental flag is off', async () => { + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', '0'); + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0'); + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr } = makeIo(); + + await expect( + handleWebCommand( + { remoteControl: true, open: false }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ), + ).rejects.toThrow('--remote-control is experimental:'); + }); + + it('hides --remote-control from help unless the experimental flag is on', () => { + const remoteControlOption = () => + makeProgram() + .commands.find((command) => command.name() === 'web')! + .options.find((option) => option.long === '--remote-control'); + + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', '0'); + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0'); + expect(remoteControlOption()?.hidden).toBe(true); + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1'); + expect(remoteControlOption()?.hidden).toBe(false); + }); }); describe('`pythinker web` option threading', () => { @@ -1027,3 +1080,54 @@ describe('filterDisplayAddresses', () => { ]); }); }); + +describe('pythinker rc', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('registers `rc` with the `remote` alias and the web server options, without a --remote-control flag', () => { + const program = makeProgram(); + const rc = program.commands.find((c) => c.name() === 'rc'); + expect(rc).toBeDefined(); + expect(rc!.alias()).toBe('remote'); + const longs = rc!.options.map((o) => o.long).filter(Boolean); + expect(longs).toContain('--port'); + expect(longs).toContain('--host'); + expect(longs).toContain('--no-open'); + expect(longs).not.toContain('--remote-control'); + }); + + it('hides `rc` from help unless the experimental flag is on', () => { + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', '0'); + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0'); + expect(makeProgram().helpInformation()).not.toContain('rc|remote'); + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1'); + expect(makeProgram().helpInformation()).toContain('rc|remote'); + }); + + it('forces Remote Control for both `rc` and `remote`', async () => { + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', '0'); + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0'); + for (const name of ['rc', 'remote']) { + const program = makeProgram(); + let stderr = ''; + const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + try { + await program.parseAsync(['node', 'pythinker', name]); + } finally { + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + // The flag-off experimental error proves remoteControl was forced before + // the runner could start. + expect(stderr).toContain('--remote-control is experimental:'); + } + }); +}); diff --git a/apps/pythinker-code/test/tui/commands/registry.test.ts b/apps/pythinker-code/test/tui/commands/registry.test.ts index 2cac0fae7..caaae491e 100644 --- a/apps/pythinker-code/test/tui/commands/registry.test.ts +++ b/apps/pythinker-code/test/tui/commands/registry.test.ts @@ -218,4 +218,11 @@ describe('built-in slash command registry', () => { expect(resolveSlashCommandAvailability(command!, 'teardown')).toBe('always'); expect(resolveSlashCommandAvailability(command!, 'Ship feature X')).toBe('always'); }); + + it('gates remote-control behind the remote-control experiment, always available', () => { + const command = findBuiltInSlashCommand('remote-control'); + expect(command).toBeDefined(); + expect((command as PythinkerSlashCommand).experimentalFlag).toBe('remote-control'); + expect(resolveSlashCommandAvailability(command!, '')).toBe('always'); + }); }); diff --git a/apps/pythinker-code/test/tui/commands/resolve.test.ts b/apps/pythinker-code/test/tui/commands/resolve.test.ts index e1714b78b..21b446a0e 100644 --- a/apps/pythinker-code/test/tui/commands/resolve.test.ts +++ b/apps/pythinker-code/test/tui/commands/resolve.test.ts @@ -64,6 +64,14 @@ describe('resolveSlashCommandInput', () => { }); }); + + it('gates /remote-control behind the remote-control experimental flag', () => { + expect(resolve('/rc')).toEqual({ kind: 'message', input: '/rc' }); + setExperimentalFeatures([{ id: 'remote-control', enabled: true }]); + expect(resolve('/rc')).toMatchObject({ kind: 'builtin', name: 'remote-control' }); + expect(resolve('/remote-control')).toMatchObject({ kind: 'builtin', name: 'remote-control' }); + }); + it('blocks idle-only built-ins while streaming', () => { expect(resolve('/new', { isStreaming: true })).toEqual({ kind: 'blocked', diff --git a/apps/pythinker-code/test/tui/commands/web.test.ts b/apps/pythinker-code/test/tui/commands/web.test.ts index 32f9845ba..b3542bdba 100644 --- a/apps/pythinker-code/test/tui/commands/web.test.ts +++ b/apps/pythinker-code/test/tui/commands/web.test.ts @@ -1,17 +1,29 @@ +import { setCapabilities } from '@pymodel/pi-tui'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { findBuiltInSlashCommand, resolveSlashCommandAvailability } from '#/tui/commands/index'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; import { PYTHINKER_LOGO_LINES } from '#/tui/components/chrome/pythinker-logo'; -import { handleWebCommand, webSessionUrl } from '#/tui/commands/web'; +import { + handleRemoteControlCommand, + handleWebCommand, + webSessionUrl, +} from '#/tui/commands/web'; +import { renderTerminalQr } from '#/utils/remote-control-qr'; const mocks = vi.hoisted(() => ({ startServerForeground: vi.fn(), + startRemoteControl: vi.fn(), tryResolveServerToken: vi.fn(), getDataDir: vi.fn(() => '/tmp/pythinker-home'), openUrl: vi.fn(), })); +vi.mock('#/cli/sub/web/remote-control', async (importOriginal) => { + const actual = await importOriginal<typeof import('#/cli/sub/web/remote-control')>(); + return { ...actual, startRemoteControl: mocks.startRemoteControl }; +}); + vi.mock('#/cli/sub/web/run', async (importOriginal) => { const actual = await importOriginal<typeof import('#/cli/sub/web/run')>(); return { ...actual, startServerForeground: mocks.startServerForeground }; @@ -35,6 +47,9 @@ vi.mock('#/utils/paths', async (importOriginal) => { return { ...actual, getDataDir: mocks.getDataDir }; }); +const indentedQr = (url: string): string => + renderTerminalQr(url).trimEnd().replaceAll(/^/gm, ' '); + function makeHost() { const host = { session: { id: 'ses-1' }, @@ -45,6 +60,7 @@ function makeHost() { setExitOpenUrl: vi.fn(), setExitForegroundTask: vi.fn(), stop: vi.fn(async () => {}), + waitForLazyCreation: vi.fn(async () => {}), } as unknown as SlashCommandHost & { showStatus: ReturnType<typeof vi.fn>; showError: ReturnType<typeof vi.fn>; @@ -53,6 +69,7 @@ function makeHost() { setExitOpenUrl: ReturnType<typeof vi.fn>; setExitForegroundTask: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn>; + waitForLazyCreation: ReturnType<typeof vi.fn>; }; return host; } @@ -65,6 +82,13 @@ describe('web slash command', () => { }); }); + it('registers /remote-control and /rc as the same always-available built-in', () => { + const command = findBuiltInSlashCommand('remote-control'); + expect(command).toBeDefined(); + expect(findBuiltInSlashCommand('rc')).toBe(command); + expect(resolveSlashCommandAvailability(command!, '')).toBe('always'); + }); + describe('handleWebCommand', () => { beforeEach(() => { vi.clearAllMocks(); @@ -122,6 +146,175 @@ describe('handleWebCommand', () => { }); }); +describe('handleRemoteControlCommand', () => { + it('stays in the TUI with a readable error when another instance holds Remote Control', async () => { + vi.clearAllMocks(); + const { mkdtempSync, mkdirSync, rmSync, writeFileSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const tempRoot = mkdtempSync(join(tmpdir(), 'pythinker-rc-lock-')); + const dataDir = join(tempRoot, 'home'); + mkdirSync(join(dataDir, 'server'), { recursive: true }); + writeFileSync( + join(dataDir, 'server', 'rc.json'), + JSON.stringify({ + pid: process.pid, + nonce: 'holder', + local_origin: 'http://127.0.0.1:58627', + device_id: 'device-1', + url: 'https://code-rc.pythinker.com/devices/device-1/?rc=1&from=pythinker_code_cli', + started_at: Date.now(), + }), + ); + mocks.getDataDir.mockReturnValue(dataDir); + const host = makeHost(); + + try { + await handleRemoteControlCommand(host); + + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('already running')); + expect(host.showError).toHaveBeenCalledWith( + expect.stringContaining('/devices/device-1/'), + ); + expect(host.setExitForegroundTask).not.toHaveBeenCalled(); + expect(host.stop).not.toHaveBeenCalled(); + expect(mocks.startServerForeground).not.toHaveBeenCalled(); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('starts the tunnel and saves a session QR code carrying the server token', async () => { + vi.clearAllMocks(); + setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + const { mkdtempSync, readFileSync, rmSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { isAbsolute, join } = await import('node:path'); + const QRCode = await import('qrcode'); + const tempRoot = mkdtempSync(join(tmpdir(), 'pythinker-rc-qrcode-')); + const dataDir = join(tempRoot, 'custom-home'); + const entryUrl = + 'https://code-rc.pythinker.com/devices/device-1/?rc=1&from=pythinker_code_cli'; + const sessionUrl = + 'https://code-rc.pythinker.com/devices/device-1/sessions/ses-1?rc=1&from=pythinker_code_cli#token=local-server-token'; + const pngPath = join(dataDir, 'rc-qrcode.png'); + mocks.getDataDir.mockReturnValue(dataDir); + mocks.tryResolveServerToken.mockReturnValue('local-server-token'); + const close = vi.fn(async () => {}); + mocks.startRemoteControl.mockResolvedValue({ + deviceId: 'device-1', + deviceName: 'example-device', + url: entryUrl, + close, + }); + mocks.startServerForeground.mockImplementation( + async ( + _options: unknown, + hooks: { + onReady?: (origin: string) => void | Promise<void>; + onShutdown?: (reason: string) => void | Promise<void>; + }, + ) => { + await hooks.onReady?.('http://127.0.0.1:58627'); + await hooks.onShutdown?.('SIGINT'); + }, + ); + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const host = makeHost(); + + try { + await handleRemoteControlCommand(host); + const task = host.setExitForegroundTask.mock.calls[0]![0] as () => Promise<void>; + await task(); + + expect(mocks.startRemoteControl).toHaveBeenCalledWith( + expect.objectContaining({ + homeDir: dataDir, + localOrigin: 'http://127.0.0.1:58627', + localServerToken: 'local-server-token', + }), + ); + expect(mocks.openUrl).toHaveBeenCalledWith(sessionUrl); + const written = writeSpy.mock.calls.map((call) => String(call[0])).join(''); + expect(written).toContain('Pythinker Remote Control ready'); + expect(written).toContain(indentedQr(sessionUrl)); + expect(written).not.toContain(indentedQr(entryUrl)); + expect(isAbsolute(pngPath)).toBe(true); + expect(written).toContain(`QR code PNG: ${pngPath}`); + const png = readFileSync(pngPath); + expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + expect(png).toEqual(await QRCode.toBuffer(sessionUrl)); + expect(written).toContain('#token=local-server-token'); + expect(close).toHaveBeenCalledOnce(); + } finally { + writeSpy.mockRestore(); + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('opens the device entry URL without a session instead of creating one', async () => { + vi.clearAllMocks(); + setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + const { mkdtempSync, readFileSync, rmSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const QRCode = await import('qrcode'); + const tempRoot = mkdtempSync(join(tmpdir(), 'pythinker-rc-entry-')); + const dataDir = join(tempRoot, 'custom-home'); + const entryUrl = + 'https://code-rc.pythinker.com/devices/device-1/?rc=1&from=pythinker_code_cli'; + const entryUrlWithToken = `${entryUrl}#token=local-server-token`; + mocks.getDataDir.mockReturnValue(dataDir); + mocks.tryResolveServerToken.mockReturnValue('local-server-token'); + const close = vi.fn(async () => {}); + mocks.startRemoteControl.mockResolvedValue({ + deviceId: 'device-1', + deviceName: 'example-device', + url: entryUrl, + close, + }); + mocks.startServerForeground.mockImplementation( + async ( + _options: unknown, + hooks: { + onReady?: (origin: string) => void | Promise<void>; + onShutdown?: (reason: string) => void | Promise<void>; + }, + ) => { + await hooks.onReady?.('http://127.0.0.1:58627'); + await hooks.onShutdown?.('SIGINT'); + }, + ); + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const host = makeHost(); + host.session = undefined; + + try { + await handleRemoteControlCommand(host); + + expect(host.waitForLazyCreation).toHaveBeenCalledOnce(); + expect(host.showError).not.toHaveBeenCalled(); + expect(host.setExitForegroundTask).toHaveBeenCalledOnce(); + expect(host.stop).toHaveBeenCalledOnce(); + + const task = host.setExitForegroundTask.mock.calls[0]![0] as () => Promise<void>; + await task(); + + expect(mocks.openUrl).toHaveBeenCalledWith(entryUrlWithToken); + const written = writeSpy.mock.calls.map((call) => String(call[0])).join(''); + expect(written).toContain(indentedQr(entryUrlWithToken)); + expect(written).not.toContain('/sessions/'); + expect(readFileSync(join(dataDir, 'rc-qrcode.png'))).toEqual( + await QRCode.toBuffer(entryUrlWithToken), + ); + expect(close).toHaveBeenCalledOnce(); + } finally { + writeSpy.mockRestore(); + rmSync(tempRoot, { recursive: true, force: true }); + } + }); +}); + describe('webSessionUrl', () => { it('deep-links to the session under the origin', () => { expect(webSessionUrl('http://127.0.0.1:58627', 'abc123')).toBe( diff --git a/apps/pythinker-code/test/utils/remote-control-qr.test.ts b/apps/pythinker-code/test/utils/remote-control-qr.test.ts new file mode 100644 index 000000000..bfe74c042 --- /dev/null +++ b/apps/pythinker-code/test/utils/remote-control-qr.test.ts @@ -0,0 +1,104 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { resetCapabilitiesCache, setCapabilities } from '@pymodel/pi-tui'; +import { afterEach, describe, expect, it } from 'vitest'; + +import * as QRCode from 'qrcode'; + +import { generateRemoteControlQr, renderTerminalQr } from '#/utils/remote-control-qr'; + +const RESET = '\u001B[0m'; +const WHITE_CELL = '\u001B[38;2;255;255;255m\u001B[48;2;255;255;255m▀'; + +describe('renderTerminalQr', () => { + it('renders truecolor black-on-white half blocks with a white quiet zone', () => { + const url = 'https://example.test/rc/entry'; + const output = renderTerminalQr(url); + const size = QRCode.create(url, { errorCorrectionLevel: 'M' }).modules.size; + const width = size + 4; + + expect(output).toContain('\u001B[38;2;0;0;0m'); + expect(output).not.toContain('\u001B[40m'); + expect(output).not.toContain('\u001B[47m'); + expect(output).not.toContain('\u001B[30m'); + expect(output).not.toContain('\u001B[37m'); + expect(output.endsWith(RESET)).toBe(true); + + const lines = output.split('\n'); + expect(lines.at(-1)).toBe(RESET); + const rows = lines.slice(0, -1); + expect(rows.length).toBe(Math.ceil((size + 4) / 2)); + for (const row of rows) { + expect(row.startsWith(WHITE_CELL.repeat(2))).toBe(true); + expect(row.endsWith(`${WHITE_CELL.repeat(2)}${RESET}`)).toBe(true); + expect(row.split('▀').length - 1).toBe(width); + } + expect(rows[0]).toBe(`${WHITE_CELL.repeat(width)}${RESET}`); + expect(rows.at(-1)).toBe(`${WHITE_CELL.repeat(width)}${RESET}`); + }); + + it('renders different output for different URLs', () => { + expect(renderTerminalQr('https://example.test/a')).not.toBe( + renderTerminalQr('https://example.test/b'), + ); + }); +}); + +describe('generateRemoteControlQr terminal rendering', () => { + afterEach(() => { + resetCapabilitiesCache(); + }); + + async function generateInTempDir(url: string) { + const dir = mkdtempSync(join(tmpdir(), 'pythinker-rc-qr-')); + try { + const result = await generateRemoteControlQr(url, dir); + return { ...result, dir }; + } catch (error) { + rmSync(dir, { recursive: true, force: true }); + throw error; + } + } + + it('falls back to half-block rendering when the terminal has no image protocol', async () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + const url = 'https://example.test/rc/entry'; + const { terminal, pngPath, dir } = await generateInTempDir(url); + try { + expect(terminal).toBe(renderTerminalQr(url)); + expect(readFileSync(pngPath)).toEqual(await QRCode.toBuffer(url)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('renders the PNG as a kitty image when the kitty protocol is available', async () => { + setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true }); + const url = 'https://example.test/rc/entry'; + const { terminal, pngPath, dir } = await generateInTempDir(url); + try { + const png = readFileSync(pngPath); + expect(terminal).toContain('\u001B_G'); + expect(terminal).toContain(png.toString('base64')); + expect(terminal).not.toBe(renderTerminalQr(url)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('renders the PNG as an iterm2 inline image when the iterm2 protocol is available', async () => { + setCapabilities({ images: 'iterm2', trueColor: true, hyperlinks: true }); + const url = 'https://example.test/rc/entry'; + const { terminal, pngPath, dir } = await generateInTempDir(url); + try { + const png = readFileSync(pngPath); + expect(terminal).toContain('\u001B]1337;File='); + expect(terminal).toContain(png.toString('base64')); + expect(terminal).not.toBe(renderTerminalQr(url)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 1e2aa146b..40e63be98 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -51,6 +51,7 @@ const config = withMermaid(defineConfig({ { text: 'Getting Started', link: '/guides/getting-started' }, { text: 'Desktop App', link: '/guides/desktop' }, { text: 'Use in a Browser', link: '/guides/web' }, + { text: 'Remote Control', link: '/guides/remote-control' }, { text: 'Common Use Cases', link: '/guides/use-cases' }, { text: 'Interaction and Input', link: '/guides/interaction' }, { text: 'Sessions and Context', link: '/guides/sessions' }, diff --git a/docs/guides/remote-control.md b/docs/guides/remote-control.md new file mode 100644 index 000000000..89360bcdf --- /dev/null +++ b/docs/guides/remote-control.md @@ -0,0 +1,35 @@ +# Remote Control + +::: warning Experimental +Remote Control is experimental. Enable it with `PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL=1`, or turn on every experiment with `PYTHINKER_CODE_EXPERIMENTAL_FLAG=1`. +::: + +Remote Control makes the local web UI reachable from your phone or another computer. The session still runs on this machine; only the screen moves. + +## Start it + +```sh +pythinker rc +``` + +`pythinker web --remote-control` does the same thing. Use `/remote-control` (or `/rc`) in the terminal UI to hand the current session over. + +The terminal prints a QR code, a link, and the path of a PNG copy of the QR code. Scan the code with the remote device, or open the link there. + +## Requirements + +- The server must bind a loopback host. Remote Control refuses a `--host` bind. +- Bearer-token auth must stay on. Remote Control refuses `--dangerous-bypass-auth`. +- One Remote Control session per machine. A second start reports the link the first one is using. + +## Security + +The link grants control of this machine. It carries the access token in its URL fragment, which the browser keeps to itself and never sends to the relay. Do not share the link or the QR code. + +## Relay + +Traffic reaches the remote device through a relay. Set `relayOrigin` to point at your own relay; the default is `https://code-rc.pythinker.com`. + +## Stop it + +Press `Ctrl+C`. The tunnel closes with the server. diff --git a/flake.nix b/flake.nix index 4d16d93b4..6cb440325 100644 --- a/flake.nix +++ b/flake.nix @@ -162,7 +162,7 @@ inherit pnpm; fetcherVersion = 3; # Monaco's package patch is part of src, not the fetched dependency closure. - hash = "sha256-oNkKA0G8DE05icitELz9z9/AFX9mofscmc4h4xhhT+s="; + hash = "sha256-K9EGrzed/ApTTSUscowNaCX/Rkz5Bbmq1HYSV3/U9Z8="; }; nativeBuildInputs = [ diff --git a/packages/agent-core-v2/src/app/remoteControl/flag.ts b/packages/agent-core-v2/src/app/remoteControl/flag.ts new file mode 100644 index 000000000..b489b6c45 --- /dev/null +++ b/packages/agent-core-v2/src/app/remoteControl/flag.ts @@ -0,0 +1,16 @@ +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const REMOTE_CONTROL_FLAG_ID = 'remote-control'; +export const REMOTE_CONTROL_FLAG_ENV = 'PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL'; + +export const remoteControlFlag: FlagDefinitionInput = { + id: REMOTE_CONTROL_FLAG_ID, + title: 'Remote Control', + description: + 'Expose the local web UI through Pythinker Remote Control (`pythinker web --remote-control`, `/remote-control`).', + env: REMOTE_CONTROL_FLAG_ENV, + default: false, + surface: 'both', +}; + +registerFlagDefinition(remoteControlFlag); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 7f2d15e51..288529bcd 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -179,6 +179,7 @@ export * from '#/kosong/provider/providerService'; export * from '#/kosong/provider/providerDefinition'; export * from '#/kosong/provider/protocolAdapterRegistry'; import '#/features/skill/catalog/configSection'; +import '#/app/remoteControl/flag'; import '#/app/agentIdentity/configSection'; export * from '#/app/agentIdentity/configSection'; export * from '#/app/agentIdentity/agentIdentity'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32ba89efa..aa755cb9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,7 +101,7 @@ importers: version: 2.13.1 tsdown: specifier: 0.22.3 - version: 0.22.3(@arethetypeswrong/core@0.18.3)(@typescript/native-preview@7.0.0-dev.20260707.2)(publint@0.3.21)(tsx@4.23.12)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1))(vue-tsc@3.3.10(typescript@6.0.3)) + version: 0.22.3(@arethetypeswrong/core@0.18.3)(@typescript/native-preview@7.0.0-dev.20260707.2)(publint@0.3.21)(tsx@4.23.12)(typescript@6.0.3)(unrun@0.2.34)(vue-tsc@3.3.10(typescript@6.0.3)) tsx: specifier: ^4.23.5 version: 4.23.12 @@ -183,9 +183,15 @@ importers: '@pymodel/vis-web': specifier: workspace:* version: link:../vis/web + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/semver': specifier: ^7.7.0 version: 7.7.1 + '@types/ws': + specifier: ^8.18.0 + version: 8.18.1 '@types/yazl': specifier: ^2.4.6 version: 2.4.6 @@ -207,6 +213,9 @@ importers: postject: specifier: 1.0.0-alpha.6 version: 1.0.0-alpha.6 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 semver: specifier: ^7.7.4 version: 7.8.5 @@ -216,6 +225,9 @@ importers: tsx: specifier: ^4.23.5 version: 4.23.12 + ws: + specifier: 8.21.3 + version: 8.21.3 yazl: specifier: ^3.3.1 version: 3.3.1 @@ -596,11 +608,11 @@ importers: version: 1.13.1 vitepress-plugin-mermaid: specifier: ^2.0.17 - version: 2.0.17(mermaid@11.17.0)(vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@26.2.0)(fuse.js@7.5.0)(jiti@2.7.0)(lightningcss@1.32.0)(postcss@8.5.26)(search-insights@2.17.3)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.8.3)) + version: 2.0.17(mermaid@11.17.0)(vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@26.2.0)(fuse.js@7.5.0)(jiti@2.7.0)(lightningcss@1.32.0)(postcss@8.5.26)(qrcode@1.5.4)(search-insights@2.17.3)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.8.3)) devDependencies: vitepress: specifier: ^1.5.0 - version: 1.6.4(@algolia/client-search@5.52.1)(@types/node@26.2.0)(fuse.js@7.5.0)(jiti@2.7.0)(lightningcss@1.32.0)(postcss@8.5.26)(search-insights@2.17.3)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.8.3) + version: 1.6.4(@algolia/client-search@5.52.1)(@types/node@26.2.0)(fuse.js@7.5.0)(jiti@2.7.0)(lightningcss@1.32.0)(postcss@8.5.26)(qrcode@1.5.4)(search-insights@2.17.3)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.8.3) packages/acp-adapter: dependencies: @@ -4465,6 +4477,9 @@ packages: '@types/proper-lockfile@4.1.4': resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.4': resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} peerDependencies: @@ -5242,6 +5257,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -5353,6 +5372,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -5747,6 +5769,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decamelize@4.0.0: resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} engines: {node: '>=10'} @@ -5848,6 +5874,9 @@ packages: resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -8285,6 +8314,10 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + pngjs@6.0.0: resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} engines: {node: '>=12.13.0'} @@ -8411,6 +8444,11 @@ packages: resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} engines: {node: '>=16.0.0'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -8635,6 +8673,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -8873,6 +8914,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -10127,6 +10171,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.20: resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} @@ -10159,6 +10206,10 @@ packages: workerpool@9.3.4: resolution: {integrity: sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -10219,6 +10270,9 @@ packages: xstate@5.32.5: resolution: {integrity: sha512-ULazi1oe6wGrXl0Frb6otSlkm5HLifbbVTkMk5kkSKqz4TkxJaVpnl6jOJwKeid3ORPxYyZQgNLUSYX9q65SIA==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -10242,6 +10296,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -10254,6 +10312,10 @@ packages: resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} engines: {node: '>=10'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} @@ -13107,6 +13169,14 @@ snapshots: '@rolldown/binding-openharmony-arm64@1.1.5': optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + dependencies: + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) @@ -13891,6 +13961,10 @@ snapshots: dependencies: '@types/retry': 0.12.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 26.2.0 + '@types/react-dom@19.2.4(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 @@ -14364,7 +14438,7 @@ snapshots: transitivePeerDependencies: - typescript - '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(fuse.js@7.5.0)(typescript@6.0.3)': + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(fuse.js@7.5.0)(qrcode@1.5.4)(typescript@6.0.3)': dependencies: '@vueuse/core': 12.8.2(typescript@6.0.3) '@vueuse/shared': 12.8.2(typescript@6.0.3) @@ -14372,6 +14446,7 @@ snapshots: optionalDependencies: focus-trap: 7.8.0 fuse.js: 7.5.0 + qrcode: 1.5.4 transitivePeerDependencies: - typescript @@ -14833,6 +14908,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + camelcase@6.3.0: {} caniuse-lite@1.0.30001788: {} @@ -14948,6 +15025,12 @@ snapshots: cli-width@4.1.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -15370,6 +15453,8 @@ snapshots: optionalDependencies: supports-color: 8.1.1 + decamelize@1.2.0: {} + decamelize@4.0.0: {} decimal.js@10.6.0: {} @@ -15445,6 +15530,8 @@ snapshots: diff@9.0.0: {} + dijkstrajs@1.0.3: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.5 @@ -18427,6 +18514,8 @@ snapshots: pluralize@8.0.0: {} + pngjs@5.0.0: {} + pngjs@6.0.0: {} pngjs@7.0.0: {} @@ -18556,6 +18645,12 @@ snapshots: pvutils@1.2.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -18924,6 +19019,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -19002,6 +19099,31 @@ snapshots: transitivePeerDependencies: - oxc-resolver + rolldown@1.0.0-rc.12: + dependencies: + '@oxc-project/types': 0.122.0 + '@rolldown/pluginutils': 1.0.0-rc.12 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-x64': 1.0.0-rc.12 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + rolldown@1.0.0-rc.12(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): dependencies: '@oxc-project/types': 0.122.0 @@ -19246,6 +19368,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} set-cookie-parser@3.1.2: {} @@ -19976,6 +20100,35 @@ snapshots: - oxc-resolver - vue-tsc + tsdown@0.22.3(@arethetypeswrong/core@0.18.3)(@typescript/native-preview@7.0.0-dev.20260707.2)(publint@0.3.21)(tsx@4.23.12)(typescript@6.0.3)(unrun@0.2.34)(vue-tsc@3.3.10(typescript@6.0.3)): + dependencies: + ansis: 4.3.1 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.0.1 + hookable: 6.1.1 + import-without-cache: 0.4.0 + obug: 2.1.4 + picomatch: 4.0.4 + rolldown: 1.1.5 + rolldown-plugin-dts: 0.26.0(@typescript/native-preview@7.0.0-dev.20260707.2)(rolldown@1.1.5)(typescript@6.0.3)(vue-tsc@3.3.10(typescript@6.0.3)) + semver: 7.8.5 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + optionalDependencies: + '@arethetypeswrong/core': 0.18.3 + publint: 0.3.21 + tsx: 4.23.12 + typescript: 6.0.3 + unrun: 0.2.34 + transitivePeerDependencies: + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - vue-tsc + tslib@2.8.1: {} tsx@4.23.12: @@ -20181,6 +20334,14 @@ snapshots: picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 + unrun@0.2.34: + dependencies: + rolldown: 1.0.0-rc.12 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + unrun@0.2.34(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): dependencies: rolldown: 1.0.0-rc.12(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) @@ -20347,14 +20508,14 @@ snapshots: transitivePeerDependencies: - supports-color - vitepress-plugin-mermaid@2.0.17(mermaid@11.17.0)(vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@26.2.0)(fuse.js@7.5.0)(jiti@2.7.0)(lightningcss@1.32.0)(postcss@8.5.26)(search-insights@2.17.3)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.8.3)): + vitepress-plugin-mermaid@2.0.17(mermaid@11.17.0)(vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@26.2.0)(fuse.js@7.5.0)(jiti@2.7.0)(lightningcss@1.32.0)(postcss@8.5.26)(qrcode@1.5.4)(search-insights@2.17.3)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.8.3)): dependencies: mermaid: 11.17.0 - vitepress: 1.6.4(@algolia/client-search@5.52.1)(@types/node@26.2.0)(fuse.js@7.5.0)(jiti@2.7.0)(lightningcss@1.32.0)(postcss@8.5.26)(search-insights@2.17.3)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.8.3) + vitepress: 1.6.4(@algolia/client-search@5.52.1)(@types/node@26.2.0)(fuse.js@7.5.0)(jiti@2.7.0)(lightningcss@1.32.0)(postcss@8.5.26)(qrcode@1.5.4)(search-insights@2.17.3)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.8.3) optionalDependencies: '@mermaid-js/mermaid-mindmap': 9.3.0 - vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@26.2.0)(fuse.js@7.5.0)(jiti@2.7.0)(lightningcss@1.32.0)(postcss@8.5.26)(search-insights@2.17.3)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.8.3): + vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@26.2.0)(fuse.js@7.5.0)(jiti@2.7.0)(lightningcss@1.32.0)(postcss@8.5.26)(qrcode@1.5.4)(search-insights@2.17.3)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.8.3): dependencies: '@docsearch/css': 3.8.2 '@docsearch/js': 3.8.2(@algolia/client-search@5.52.1)(search-insights@2.17.3) @@ -20367,7 +20528,7 @@ snapshots: '@vue/devtools-api': 7.7.9 '@vue/shared': 3.5.35 '@vueuse/core': 12.8.2(typescript@6.0.3) - '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(fuse.js@7.5.0)(typescript@6.0.3) + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(fuse.js@7.5.0)(qrcode@1.5.4)(typescript@6.0.3) focus-trap: 7.8.0 mark.js: 8.11.1 minisearch: 7.2.0 @@ -20594,6 +20755,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 @@ -20627,6 +20790,12 @@ snapshots: workerpool@9.3.4: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -20675,6 +20844,8 @@ snapshots: xstate@5.32.5: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -20688,6 +20859,11 @@ snapshots: yaml@2.8.3: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} @@ -20699,6 +20875,20 @@ snapshots: flat: 5.0.2 is-plain-obj: 2.1.0 + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@16.2.0: dependencies: cliui: 7.0.4 From 3adad9a2dceefe40d024e92fa45a38f0bbedba51 Mon Sep 17 00:00:00 2001 From: elkaix <melkholy@techmatrix.com> Date: Thu, 27 Aug 2026 06:11:20 -0400 Subject: [PATCH 2/6] fix(cli): keep the Remote Control link token-free and make the relay configurable The tunnel client already adds the bearer token to every request it replays against the local server, so the link needs no credential. Drop the token fragment that was leaking the machine-wide server token into the terminal, the QR image on disk, and the remote browser's URL bar. Add --relay-origin and PYTHINKER_CODE_REMOTE_CONTROL_RELAY: this build ships no relay, so the relay had no way to be set outside tests. Restrict the lock file and the QR image to owner-only permissions. --- .../src/cli/sub/web/remote-control-lock.ts | 5 ++-- .../src/cli/sub/web/remote-control.ts | 25 ++++++++++++++-- apps/pythinker-code/src/cli/sub/web/run.ts | 10 +++++++ apps/pythinker-code/src/tui/commands/web.ts | 5 +++- .../src/utils/remote-control-qr.ts | 4 +-- .../test/cli/web/remote-control.test.ts | 26 +++++++++++++++++ apps/pythinker-code/test/cli/web/web.test.ts | 29 +++++++++++++++++++ .../test/tui/commands/web.test.ts | 14 ++++----- docs/guides/remote-control.md | 12 ++++++-- 9 files changed, 113 insertions(+), 17 deletions(-) diff --git a/apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts b/apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts index ac8372cbc..c9098576e 100644 --- a/apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts +++ b/apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts @@ -53,7 +53,8 @@ export async function acquireRemoteControlLock( details: { localOrigin: string; deviceId: string; url: string }, ): Promise<RemoteControlLock> { const lockPath = remoteControlLockPath(homeDir); - await mkdir(dirname(lockPath), { recursive: true }); + // The lock sits beside `server.token`; keep the same owner-only permissions. + await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 }); const info: RemoteControlLockInfo = { pid: process.pid, nonce: randomBytes(8).toString('hex'), @@ -64,7 +65,7 @@ export async function acquireRemoteControlLock( }; for (let attempt = 0; ; attempt += 1) { try { - const handle = await open(lockPath, 'wx'); + const handle = await open(lockPath, 'wx', 0o600); try { await handle.writeFile(encodeLock(info)); } finally { diff --git a/apps/pythinker-code/src/cli/sub/web/remote-control.ts b/apps/pythinker-code/src/cli/sub/web/remote-control.ts index 3e0e2d1c3..dcd20a286 100644 --- a/apps/pythinker-code/src/cli/sub/web/remote-control.ts +++ b/apps/pythinker-code/src/cli/sub/web/remote-control.ts @@ -15,6 +15,26 @@ export const REMOTE_CONTROL_RELAY_ORIGIN = 'https://code-rc.pythinker.com'; export const REMOTE_CONTROL_FLAG_ENV = 'PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL'; +export const REMOTE_CONTROL_RELAY_ENV = 'PYTHINKER_CODE_REMOTE_CONTROL_RELAY'; + +/** + * Resolve the relay to tunnel through. Pythinker ships no relay, so an operator + * running their own points at it with `--relay-origin` or the env var; the + * default constant is the last resort. + */ +export function resolveRelayOrigin( + explicit?: string, + env: Readonly<Record<string, string | undefined>> = process.env, +): string { + const candidate = explicit?.trim() ?? env[REMOTE_CONTROL_RELAY_ENV]?.trim() ?? ''; + if (candidate.length === 0) return REMOTE_CONTROL_RELAY_ORIGIN; + const url = new URL(candidate); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`Remote Control relay must be an http(s) URL: ${candidate}`); + } + return candidate; +} + const TRUTHY_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']); export function isRemoteControlEnabled( @@ -181,7 +201,6 @@ export function buildRemoteControlUrl( deviceId: string, sessionId?: string, relayOrigin = REMOTE_CONTROL_RELAY_ORIGIN, - serverToken?: string, ): string { const url = new URL(relayOrigin); const relayPath = url.pathname.replace(/\/+$/, ''); @@ -191,7 +210,7 @@ export function buildRemoteControlUrl( ? `${devicePath}/` : `${devicePath}/sessions/${encodeURIComponent(sessionId)}`; url.search = new URLSearchParams({ rc: '1', from: 'pythinker_code_cli' }).toString(); - url.hash = serverToken === undefined ? '' : `token=${serverToken}`; + url.hash = ''; return url.toString(); } @@ -294,7 +313,7 @@ export async function startRemoteControl( const relayOrigin = options.relayOrigin ?? REMOTE_CONTROL_RELAY_ORIGIN; const deviceId = createPythinkerDeviceId(options.homeDir); const deviceName = hostname(); - const url = buildRemoteControlUrl(deviceId, undefined, relayOrigin, options.localServerToken); + const url = buildRemoteControlUrl(deviceId, undefined, relayOrigin); const lock = await acquireRemoteControlLock(options.homeDir, { localOrigin: options.localOrigin.replace(/\/+$/, ''), deviceId, diff --git a/apps/pythinker-code/src/cli/sub/web/run.ts b/apps/pythinker-code/src/cli/sub/web/run.ts index e7366c3e0..967e23f06 100644 --- a/apps/pythinker-code/src/cli/sub/web/run.ts +++ b/apps/pythinker-code/src/cli/sub/web/run.ts @@ -45,6 +45,7 @@ import { formatRemoteControlStatus, isRemoteControlEnabled, REMOTE_CONTROL_FLAG_ENV, + resolveRelayOrigin, startRemoteControl, type RemoteControlHandle, type RemoteControlOptions, @@ -78,6 +79,7 @@ interface RoutedServer { export interface WebCliOptions extends ServerCliOptions { open?: boolean; remoteControl?: boolean; + relayOrigin?: string; } export interface StartForegroundHooks { @@ -182,6 +184,12 @@ export function buildWebCommand( .hideHelp(!isRemoteControlEnabled()), ); } + withServerOptions.addOption( + new Option( + '--relay-origin <url>', + 'Remote Control relay to tunnel through. Defaults to $PYTHINKER_CODE_REMOTE_CONTROL_RELAY.', + ).hideHelp(!isRemoteControlEnabled()), + ); return withServerOptions .option('--no-open', 'Do not open the web UI in the default browser.', true) .action(async (opts: WebCliOptions) => { @@ -212,6 +220,7 @@ export async function handleWebCommand( if (opts.remoteControl === true && !isLoopbackHost(parsed.host)) { throw new Error('--remote-control requires a loopback host.'); } + const relayOrigin = opts.remoteControl === true ? resolveRelayOrigin(opts.relayOrigin) : undefined; const run = deps.startServerForeground ?? startServerForeground; let remoteControl: RemoteControlHandle | undefined; await run(parsed, { @@ -241,6 +250,7 @@ export async function handleWebCommand( homeDir: dataDir, localOrigin: origin, localServerToken: token, + relayOrigin, stderr: deps.stderr, onStatus, }); diff --git a/apps/pythinker-code/src/tui/commands/web.ts b/apps/pythinker-code/src/tui/commands/web.ts index 94c44bf39..83dbcafc2 100644 --- a/apps/pythinker-code/src/tui/commands/web.ts +++ b/apps/pythinker-code/src/tui/commands/web.ts @@ -5,6 +5,7 @@ import { buildRemoteControlUrl, formatRemoteControlOutput, formatRemoteControlStatus, + resolveRelayOrigin, startRemoteControl, type RemoteControlStatus, } from '#/cli/sub/web/remote-control'; @@ -61,6 +62,7 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis host.setExitForegroundTask(async () => { const options = parseServerOptions({}); + const relayOrigin = resolveRelayOrigin(); let remoteControl: Awaited<ReturnType<typeof startRemoteControl>> | undefined; try { await startServerForeground(options, { @@ -79,9 +81,10 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis homeDir: dataDir, localOrigin: origin, localServerToken: token, + relayOrigin, onStatus, }); - const url = buildRemoteControlUrl(remoteControl.deviceId, session?.id, undefined, token); + const url = buildRemoteControlUrl(remoteControl.deviceId, session?.id, relayOrigin); const qrCode = await generateRemoteControlQr(url, dataDir); process.stdout.write( formatRemoteControlOutput({ diff --git a/apps/pythinker-code/src/utils/remote-control-qr.ts b/apps/pythinker-code/src/utils/remote-control-qr.ts index affd659f0..3e2a44b07 100644 --- a/apps/pythinker-code/src/utils/remote-control-qr.ts +++ b/apps/pythinker-code/src/utils/remote-control-qr.ts @@ -21,10 +21,10 @@ export async function generateRemoteControlQr( url: string, dataDir: string, ): Promise<{ terminal: string; pngPath: string }> { - await mkdir(dataDir, { recursive: true }); + await mkdir(dataDir, { recursive: true, mode: 0o700 }); const pngPath = resolve(dataDir, 'rc-qrcode.png'); const png = await QRCode.toBuffer(url, { type: 'png', margin: QR_PNG_MARGIN }); - await writeFile(pngPath, png); + await writeFile(pngPath, png, { mode: 0o600 }); const terminal = renderInlineImageQr(url, png) ?? renderTerminalQr(url); return { terminal, pngPath }; } diff --git a/apps/pythinker-code/test/cli/web/remote-control.test.ts b/apps/pythinker-code/test/cli/web/remote-control.test.ts index a5f45581f..8e12b4fa2 100644 --- a/apps/pythinker-code/test/cli/web/remote-control.test.ts +++ b/apps/pythinker-code/test/cli/web/remote-control.test.ts @@ -168,6 +168,32 @@ describe('Remote Control HTTP forwarding', () => { }); }); +describe('resolveRelayOrigin', () => { + it('prefers the explicit value, then the env var, then the built-in default', async () => { + const { resolveRelayOrigin, REMOTE_CONTROL_RELAY_ORIGIN } = await import( + '#/cli/sub/web/remote-control' + ); + expect(resolveRelayOrigin('https://relay.example.test', {})).toBe('https://relay.example.test'); + expect( + resolveRelayOrigin(undefined, { + PYTHINKER_CODE_REMOTE_CONTROL_RELAY: 'https://env.example.test', + }), + ).toBe('https://env.example.test'); + expect(resolveRelayOrigin(undefined, {})).toBe(REMOTE_CONTROL_RELAY_ORIGIN); + expect(resolveRelayOrigin(' ', { PYTHINKER_CODE_REMOTE_CONTROL_RELAY: ' ' })).toBe( + REMOTE_CONTROL_RELAY_ORIGIN, + ); + }); + + it('rejects a relay that is not http(s)', async () => { + const { resolveRelayOrigin } = await import('#/cli/sub/web/remote-control'); + expect(() => resolveRelayOrigin('ws://relay.example.test', {})).toThrow( + 'Remote Control relay must be an http(s) URL', + ); + expect(() => resolveRelayOrigin('not-a-url', {})).toThrow(); + }); +}); + describe('Remote Control tunnel', () => { it('surfaces register_nak details', async () => { const homeDir = mkdtempSync(join(tmpdir(), 'pythinker-rc-nak-')); diff --git a/apps/pythinker-code/test/cli/web/web.test.ts b/apps/pythinker-code/test/cli/web/web.test.ts index 7ae5564ea..c85f85844 100644 --- a/apps/pythinker-code/test/cli/web/web.test.ts +++ b/apps/pythinker-code/test/cli/web/web.test.ts @@ -404,6 +404,35 @@ describe('`pythinker web` opens the browser', () => { } }); + it('passes the resolved relay origin to the tunnel', async () => { + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1'); + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr } = makeIo(); + const startRemoteControl = vi.fn(async () => ({ + deviceId: 'device-1', + deviceName: 'example-device', + url: 'https://relay.example.test/devices/device-1/?rc=1&from=pythinker_code_cli', + close: async () => {}, + })); + + await handleWebCommand( + { remoteControl: true, relayOrigin: 'https://relay.example.test', open: false }, + { + startServerForeground: runner, + openUrl: vi.fn(), + resolveToken: () => 'tok-1', + startRemoteControl, + stdout, + stderr, + }, + ); + + expect(startRemoteControl).toHaveBeenCalledWith( + expect.objectContaining({ relayOrigin: 'https://relay.example.test' }), + ); + }); + it('rejects Remote Control on a non-loopback host', async () => { vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1'); const { handleWebCommand } = await import('#/cli/sub/web/run'); diff --git a/apps/pythinker-code/test/tui/commands/web.test.ts b/apps/pythinker-code/test/tui/commands/web.test.ts index b3542bdba..c05c54f05 100644 --- a/apps/pythinker-code/test/tui/commands/web.test.ts +++ b/apps/pythinker-code/test/tui/commands/web.test.ts @@ -184,7 +184,7 @@ describe('handleRemoteControlCommand', () => { } }); - it('starts the tunnel and saves a session QR code carrying the server token', async () => { + it('starts the tunnel and saves a token-free session QR code', async () => { vi.clearAllMocks(); setCapabilities({ images: null, trueColor: true, hyperlinks: false }); const { mkdtempSync, readFileSync, rmSync } = await import('node:fs'); @@ -196,7 +196,7 @@ describe('handleRemoteControlCommand', () => { const entryUrl = 'https://code-rc.pythinker.com/devices/device-1/?rc=1&from=pythinker_code_cli'; const sessionUrl = - 'https://code-rc.pythinker.com/devices/device-1/sessions/ses-1?rc=1&from=pythinker_code_cli#token=local-server-token'; + 'https://code-rc.pythinker.com/devices/device-1/sessions/ses-1?rc=1&from=pythinker_code_cli'; const pngPath = join(dataDir, 'rc-qrcode.png'); mocks.getDataDir.mockReturnValue(dataDir); mocks.tryResolveServerToken.mockReturnValue('local-server-token'); @@ -244,7 +244,8 @@ describe('handleRemoteControlCommand', () => { const png = readFileSync(pngPath); expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); expect(png).toEqual(await QRCode.toBuffer(sessionUrl)); - expect(written).toContain('#token=local-server-token'); + expect(written).not.toContain('local-server-token'); + expect(written).not.toContain('#token='); expect(close).toHaveBeenCalledOnce(); } finally { writeSpy.mockRestore(); @@ -263,7 +264,6 @@ describe('handleRemoteControlCommand', () => { const dataDir = join(tempRoot, 'custom-home'); const entryUrl = 'https://code-rc.pythinker.com/devices/device-1/?rc=1&from=pythinker_code_cli'; - const entryUrlWithToken = `${entryUrl}#token=local-server-token`; mocks.getDataDir.mockReturnValue(dataDir); mocks.tryResolveServerToken.mockReturnValue('local-server-token'); const close = vi.fn(async () => {}); @@ -300,12 +300,12 @@ describe('handleRemoteControlCommand', () => { const task = host.setExitForegroundTask.mock.calls[0]![0] as () => Promise<void>; await task(); - expect(mocks.openUrl).toHaveBeenCalledWith(entryUrlWithToken); + expect(mocks.openUrl).toHaveBeenCalledWith(entryUrl); const written = writeSpy.mock.calls.map((call) => String(call[0])).join(''); - expect(written).toContain(indentedQr(entryUrlWithToken)); + expect(written).toContain(indentedQr(entryUrl)); expect(written).not.toContain('/sessions/'); expect(readFileSync(join(dataDir, 'rc-qrcode.png'))).toEqual( - await QRCode.toBuffer(entryUrlWithToken), + await QRCode.toBuffer(entryUrl), ); expect(close).toHaveBeenCalledOnce(); } finally { diff --git a/docs/guides/remote-control.md b/docs/guides/remote-control.md index 89360bcdf..55dafe7ab 100644 --- a/docs/guides/remote-control.md +++ b/docs/guides/remote-control.md @@ -24,11 +24,19 @@ The terminal prints a QR code, a link, and the path of a PNG copy of the QR code ## Security -The link grants control of this machine. It carries the access token in its URL fragment, which the browser keeps to itself and never sends to the relay. Do not share the link or the QR code. +The link grants control of this machine. Do not share the link or the QR code. + +The link carries no access token. Requests reach the local server through the tunnel, and the Pythinker Code process on this machine adds the bearer token to each one, so the token never leaves this machine. ## Relay -Traffic reaches the remote device through a relay. Set `relayOrigin` to point at your own relay; the default is `https://code-rc.pythinker.com`. +Traffic reaches the remote device through a relay. Point Remote Control at your own relay with `--relay-origin`: + +```sh +pythinker rc --relay-origin https://relay.example.com +``` + +`PYTHINKER_CODE_REMOTE_CONTROL_RELAY` sets the same thing for `/rc` in the terminal UI and for every run in a shell. ## Stop it From 76e97d522423b7bc9fd42b3945c7c3ddbf7e5bb8 Mon Sep 17 00:00:00 2001 From: elkaix <melkholy@techmatrix.com> Date: Thu, 27 Aug 2026 06:22:12 -0400 Subject: [PATCH 3/6] fix(cli): harden the Remote Control tunnel and lock - align ws with the rest of the workspace and restage the web bundle - report the lock holder when acquisition retries run out, instead of a raw EEXIST - reject a non-positive pid in the lock file; process.kill(0, 0) signals our own process group and would pin the lock forever - settle the startup promise when the relay loop throws outside its retry block - buffer management frames between register_ack and the HTTP tunnel, so an immediate open_ws is not dropped - refuse chunked request bodies and derive Content-Length from the bytes actually forwarded - run every startup-cleanup step and keep the original error Correct the guide: the relay does receive the token in the WebSocket handshake, so it has to be trusted. --- .../dist-web/.web-bundle-manifest.json | 2 +- apps/pythinker-code/package.json | 2 +- .../src/cli/sub/web/remote-control-lock.ts | 14 ++++++++- .../src/cli/sub/web/remote-control.ts | 30 ++++++++++++++++++- apps/pythinker-code/src/cli/sub/web/run.ts | 23 ++++++++++---- .../test/cli/web/remote-control.test.ts | 15 ++++++++-- docs/guides/remote-control.md | 6 ++-- pnpm-lock.yaml | 2 +- 8 files changed, 80 insertions(+), 14 deletions(-) diff --git a/apps/pythinker-code/dist-web/.web-bundle-manifest.json b/apps/pythinker-code/dist-web/.web-bundle-manifest.json index 9e43f08ed..234acb0c3 100644 --- a/apps/pythinker-code/dist-web/.web-bundle-manifest.json +++ b/apps/pythinker-code/dist-web/.web-bundle-manifest.json @@ -1,4 +1,4 @@ { - "sourceHash": "c05b9ce73324f5a446966d9f931dc804f0f1e73e78daf14e1479d86e58669c1a", + "sourceHash": "d84e17f04092f5fb9afa9f4d323b614d3945ec8e33abbbc92f5793ad2c30959e", "sourceFileCount": 404 } diff --git a/apps/pythinker-code/package.json b/apps/pythinker-code/package.json index c042eee6f..f5f94b6af 100644 --- a/apps/pythinker-code/package.json +++ b/apps/pythinker-code/package.json @@ -108,7 +108,7 @@ "semver": "^7.7.4", "smol-toml": "^1.6.1", "tsx": "^4.23.5", - "ws": "^8.18.0", + "ws": "^8.21.3", "yazl": "^3.3.1", "zod": "^4.3.6" }, diff --git a/apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts b/apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts index c9098576e..c23a71dbf 100644 --- a/apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts +++ b/apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts @@ -73,13 +73,21 @@ export async function acquireRemoteControlLock( } return { release: () => releaseRemoteControlLock(lockPath, info.nonce) }; } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST' || attempt >= MAX_ACQUIRE_ATTEMPTS) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { throw error; } + // Read the holder even on the last attempt: a second process can recreate + // the lock between our unlink and our open, and a raw EEXIST tells the + // user nothing about who holds it or how to stop them. const holder = await readRemoteControlLock(lockPath); if (holder !== undefined && pidAlive(holder.pid)) { throw new RemoteControlAlreadyRunningError(holder); } + if (attempt >= MAX_ACQUIRE_ATTEMPTS) { + throw new Error( + `Unable to acquire the Remote Control lock at ${lockPath}. Another process keeps recreating it.`, { cause: error }, + ); + } await removeFile(lockPath); } } @@ -131,6 +139,10 @@ function decodeLock(raw: string): RemoteControlLockInfo | undefined { const parsed = JSON.parse(raw) as Partial<RemoteControlLockDisk>; if ( typeof parsed.pid === 'number' && + // `process.kill(0, 0)` signals our own process group and reports "alive", + // so a corrupt `"pid": 0` would pin the lock forever. + Number.isInteger(parsed.pid) && + parsed.pid > 0 && typeof parsed.nonce === 'string' && typeof parsed.local_origin === 'string' && typeof parsed.device_id === 'string' && diff --git a/apps/pythinker-code/src/cli/sub/web/remote-control.ts b/apps/pythinker-code/src/cli/sub/web/remote-control.ts index dcd20a286..1d943686b 100644 --- a/apps/pythinker-code/src/cli/sub/web/remote-control.ts +++ b/apps/pythinker-code/src/cli/sub/web/remote-control.ts @@ -54,6 +54,7 @@ const RELAY_PING_INTERVAL_MS = 30_000; const RELAY_SILENCE_TIMEOUT_MS = 300_000; const BLOCKED_REQUEST_HEADERS = new Set([ 'authorization', + 'content-length', 'cookie', 'host', 'origin', @@ -242,6 +243,17 @@ export function parseRawHttpRequest(raw: Buffer): ParsedRawHttpRequest { } headers.push([name, value]); } + // `transfer-encoding` is stripped before forwarding, so a chunked body would + // reach the local server with its chunk framing as entity data. The relay + // sends whole requests, so refuse the framing instead of decoding it. + if ( + headers.some( + ([name, value]) => + name.toLowerCase() === 'transfer-encoding' && value.toLowerCase().includes('chunked'), + ) + ) { + throw new SyntaxError('chunked HTTP request bodies are not supported'); + } return { method: match[1]!, path: match[2]!, @@ -390,7 +402,11 @@ class RemoteControlClient { this.initialResolve = resolve; this.initialReject = reject; }); - this.runPromise = this.run(); + // `run()` settles `initial` from inside its loop, but a throw from outside + // that loop's try would leave the caller waiting forever. + this.runPromise = this.run().catch((error: unknown) => { + this.rejectInitial(error instanceof Error ? error : new Error(String(error))); + }); await initial; } @@ -473,6 +489,14 @@ class RemoteControlClient { } const managementEnd = waitForSocketEnd(management); + // The relay may send `open_ws` the moment it acknowledges registration. + // `waitForRelayMessage` has just detached its own listener, so buffer + // everything that lands before the HTTP tunnel is up and replay it. + const earlyManagement: RawData[] = []; + const bufferManagement = (data: RawData): void => { + earlyManagement.push(data); + }; + management.on('message', bufferManagement); const http = await this.connectRelay( `/v1/remote/http?device_id=${encodeURIComponent(this.deviceId)}`, ); @@ -481,8 +505,10 @@ class RemoteControlClient { if (management.readyState !== WebSocket.OPEN) { throw new Error('management connection closed'); } + management.off('message', bufferManagement); management.on('message', (data) => this.handleManagementMessage(data)); http.on('message', (data) => this.handleHttpMessage(data)); + for (const data of earlyManagement) this.handleManagementMessage(data); this.reconnectAttempt = 0; this.relayOnline = true; this.onStatus('relay_connected'); @@ -870,6 +896,8 @@ function requestLocalHttp( path: parsed.path, headers: [ ...filterForwardRequestHeaders(parsed.headers, serverToken), + 'Content-Length', + String(parsed.body.length), 'Host', origin.host, ], diff --git a/apps/pythinker-code/src/cli/sub/web/run.ts b/apps/pythinker-code/src/cli/sub/web/run.ts index 967e23f06..804bfe199 100644 --- a/apps/pythinker-code/src/cli/sub/web/run.ts +++ b/apps/pythinker-code/src/cli/sub/web/run.ts @@ -426,11 +426,24 @@ async function runServerInProcess( try { await hooks.onReady?.(running.address); } catch (error) { - try { - await hooks.onShutdown?.('startup_failed'); - } finally { - await running.close(); - await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + // Every cleanup step runs even when an earlier one fails, and none of them + // may replace the startup error the caller needs to see. + for (const step of [ + async () => hooks.onShutdown?.('startup_failed'), + async () => running.close(), + async () => shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }), + ]) { + try { + await step(); + } catch (cleanupError) { + running.logger.error( + { + err: + cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)), + }, + 'startup cleanup step failed', + ); + } } throw error; } diff --git a/apps/pythinker-code/test/cli/web/remote-control.test.ts b/apps/pythinker-code/test/cli/web/remote-control.test.ts index 8e12b4fa2..5724fd3d2 100644 --- a/apps/pythinker-code/test/cli/web/remote-control.test.ts +++ b/apps/pythinker-code/test/cli/web/remote-control.test.ts @@ -117,16 +117,27 @@ describe('Remote Control HTTP forwarding', () => { ); expect(parsed).toMatchObject({ method: 'POST', path: '/api/v1/messages?q=1' }); expect(parsed.body.toString()).toBe('data'); + // Content-Length is dropped here and re-derived from the body that is + // actually forwarded, so a relay cannot desync the local server with a + // length that disagrees with the bytes. expect(filterForwardRequestHeaders(parsed.headers, 'local-token')).toEqual([ 'X-Keep', 'yes', - 'Content-Length', - '4', 'Authorization', 'Bearer local-token', ]); }); + it('refuses a chunked request body instead of forwarding its framing as data', () => { + expect(() => + parseRawHttpRequest( + Buffer.from( + 'POST /api/v1/messages HTTP/1.1\r\nHost: relay.example\r\nTransfer-Encoding: chunked\r\n\r\n4\r\ndata\r\n0\r\n\r\n', + ), + ), + ).toThrow(SyntaxError); + }); + it('rejects absolute-form and malformed request targets', () => { expect(() => parseRawHttpRequest(Buffer.from('GET https://example.test/ HTTP/1.1\r\n\r\n')), diff --git a/docs/guides/remote-control.md b/docs/guides/remote-control.md index 55dafe7ab..0f4d225fc 100644 --- a/docs/guides/remote-control.md +++ b/docs/guides/remote-control.md @@ -26,7 +26,9 @@ The terminal prints a QR code, a link, and the path of a PNG copy of the QR code The link grants control of this machine. Do not share the link or the QR code. -The link carries no access token. Requests reach the local server through the tunnel, and the Pythinker Code process on this machine adds the bearer token to each one, so the token never leaves this machine. +The link itself carries no access token: requests arrive through the tunnel, and the Pythinker Code process on this machine adds the bearer token to each one before it reaches the local server. The QR code, the printed link, and the PNG on disk hold no credential. + +The relay is a different matter. Pythinker Code authenticates to it with the same token, sent in the WebSocket handshake, and every request and response passes through it in the clear. Use a relay you operate or otherwise trust. Rotate the token with `pythinker web rotate-token` if a relay is ever compromised. ## Relay @@ -40,4 +42,4 @@ pythinker rc --relay-origin https://relay.example.com ## Stop it -Press `Ctrl+C`. The tunnel closes with the server. +Press `Ctrl-C`. The tunnel closes with the server. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa755cb9c..86874a712 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,7 +226,7 @@ importers: specifier: ^4.23.5 version: 4.23.12 ws: - specifier: 8.21.3 + specifier: ^8.21.3 version: 8.21.3 yazl: specifier: ^3.3.1 From 31dc00bb07282e18f134e156b4c328d8af50c22a Mon Sep 17 00:00:00 2001 From: elkaix <melkholy@techmatrix.com> Date: Thu, 27 Aug 2026 06:29:13 -0400 Subject: [PATCH 4/6] fix(cli): escape the prefix embedded in the Remote Control bootstrap script JSON.stringify leaves `</script>` and the U+2028/U+2029 line terminators intact, so a relay path containing either could close the injected script element or break its string literal. --- .../src/cli/sub/web/remote-control.ts | 15 ++++++++++++++- .../test/cli/web/remote-control.test.ts | 11 +++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/pythinker-code/src/cli/sub/web/remote-control.ts b/apps/pythinker-code/src/cli/sub/web/remote-control.ts index 1d943686b..ccbf35638 100644 --- a/apps/pythinker-code/src/cli/sub/web/remote-control.ts +++ b/apps/pythinker-code/src/cli/sub/web/remote-control.ts @@ -289,7 +289,7 @@ export function rewriteRemoteControlResponse( ): Buffer { const normalizedPrefix = publicPrefix.replace(/\/+$/, ''); if (contentType.toLowerCase().includes('text/html')) { - const prefixLiteral = JSON.stringify(normalizedPrefix); + const prefixLiteral = scriptStringLiteral(normalizedPrefix); const injected = `<script>(function(){var p=${prefixLiteral};try{sessionStorage.setItem('pythinker-desktop-server-origin',location.origin+p)}catch(e){}var w=function(f){return function(s,t,u){if(typeof u==='string'&&u.charAt(0)==='/'&&u.indexOf(p)!==0)u=p+u;return f.apply(this,[s,t,u])}};history.pushState=w(history.pushState);history.replaceState=w(history.replaceState)})();</script>`; let text = body.toString('utf8'); const headMatch = /<head(?:\s[^>]*)?>/i.exec(text); @@ -316,6 +316,19 @@ export function rewriteRemoteControlResponse( return body; } +/** + * Embed a value in an inline `<script>`. `JSON.stringify` alone is not enough: + * `</script>` in the value would close the element, and U+2028/U+2029 are line + * terminators inside a JavaScript string literal. + */ +function scriptStringLiteral(value: string): string { + return JSON.stringify(value) + .replaceAll('<', '\\u003c') + .replaceAll('>', '\\u003e') + .replaceAll('\u2028', '\\u2028') + .replaceAll('\u2029', '\\u2029'); +} + export async function startRemoteControl( options: RemoteControlOptions, ): Promise<RemoteControlHandle> { diff --git a/apps/pythinker-code/test/cli/web/remote-control.test.ts b/apps/pythinker-code/test/cli/web/remote-control.test.ts index 5724fd3d2..d7e4c5dae 100644 --- a/apps/pythinker-code/test/cli/web/remote-control.test.ts +++ b/apps/pythinker-code/test/cli/web/remote-control.test.ts @@ -138,6 +138,17 @@ describe('Remote Control HTTP forwarding', () => { ).toThrow(SyntaxError); }); + it('escapes the injected prefix so it cannot close the script element', () => { + const html = rewriteRemoteControlResponse( + 'text/html', + Buffer.from('<html><head></head><body></body></html>'), + '/relay</script><script>alert(1)</script>/devices/d1', + ).toString(); + expect(html).not.toContain('</script><script>alert(1)'); + expect(html).toContain('\\u003c/script\\u003e'); + expect(html.match(/<script>/g)).toHaveLength(1); + }); + it('rejects absolute-form and malformed request targets', () => { expect(() => parseRawHttpRequest(Buffer.from('GET https://example.test/ HTTP/1.1\r\n\r\n')), From 21085301545579c03d710ca9bb3f871d380a158f Mon Sep 17 00:00:00 2001 From: elkaix <melkholy@techmatrix.com> Date: Thu, 27 Aug 2026 06:44:46 -0400 Subject: [PATCH 5/6] fix(cli): close the Remote Control lock write race and tighten artifact modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - an unreadable lock is a rival mid-write, not a stale one: `open(…, 'wx')` publishes an empty file before its JSON lands, so removing it let both processes believe they held the lock. Re-read before sweeping, and sweep only once it is still unreadable at the end - chmod the QR directory and image, since mode only applies to paths the call creates - resolve the relay origin inside the try, so a malformed setting reports through the startup handler --- .../src/cli/sub/web/remote-control-lock.ts | 11 +++++++++++ apps/pythinker-code/src/tui/commands/web.ts | 4 +++- apps/pythinker-code/src/utils/remote-control-qr.ts | 6 +++++- .../test/cli/web/remote-control.test.ts | 2 +- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts b/apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts index c23a71dbf..e507d5a39 100644 --- a/apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts +++ b/apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts @@ -1,4 +1,5 @@ import { randomBytes } from 'node:crypto'; +import { setTimeout as sleep } from 'node:timers/promises'; import { mkdir, open, readFile, unlink } from 'node:fs/promises'; import { dirname, join } from 'node:path'; @@ -47,6 +48,7 @@ export interface RemoteControlLock { } const MAX_ACQUIRE_ATTEMPTS = 3; +const ACQUIRE_RETRY_DELAY_MS = 25; export async function acquireRemoteControlLock( homeDir: string, @@ -83,6 +85,15 @@ export async function acquireRemoteControlLock( if (holder !== undefined && pidAlive(holder.pid)) { throw new RemoteControlAlreadyRunningError(holder); } + // `open(…, 'wx')` publishes an empty file before its JSON is written, so + // an unreadable lock may simply be a rival mid-write. Deleting it there + // would let both processes believe they hold the lock. Give the writer a + // moment and re-read; only sweep it once it is still unreadable at the + // end, which is the genuinely corrupt case. + if (holder === undefined && attempt < MAX_ACQUIRE_ATTEMPTS) { + await sleep(ACQUIRE_RETRY_DELAY_MS); + continue; + } if (attempt >= MAX_ACQUIRE_ATTEMPTS) { throw new Error( `Unable to acquire the Remote Control lock at ${lockPath}. Another process keeps recreating it.`, { cause: error }, diff --git a/apps/pythinker-code/src/tui/commands/web.ts b/apps/pythinker-code/src/tui/commands/web.ts index 83dbcafc2..2ef0e4780 100644 --- a/apps/pythinker-code/src/tui/commands/web.ts +++ b/apps/pythinker-code/src/tui/commands/web.ts @@ -62,9 +62,11 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis host.setExitForegroundTask(async () => { const options = parseServerOptions({}); - const relayOrigin = resolveRelayOrigin(); let remoteControl: Awaited<ReturnType<typeof startRemoteControl>> | undefined; try { + // Inside the try: a malformed relay setting throws here, and the user + // should see it through the same handler as any other startup failure. + const relayOrigin = resolveRelayOrigin(); await startServerForeground(options, { onReady: async (origin) => { const dataDir = getDataDir(); diff --git a/apps/pythinker-code/src/utils/remote-control-qr.ts b/apps/pythinker-code/src/utils/remote-control-qr.ts index 3e2a44b07..25c3b5e45 100644 --- a/apps/pythinker-code/src/utils/remote-control-qr.ts +++ b/apps/pythinker-code/src/utils/remote-control-qr.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { @@ -22,9 +22,13 @@ export async function generateRemoteControlQr( dataDir: string, ): Promise<{ terminal: string; pngPath: string }> { await mkdir(dataDir, { recursive: true, mode: 0o700 }); + // `mode` only applies to paths these calls create; tighten an existing dir + // or an earlier run's image too. + await chmod(dataDir, 0o700); const pngPath = resolve(dataDir, 'rc-qrcode.png'); const png = await QRCode.toBuffer(url, { type: 'png', margin: QR_PNG_MARGIN }); await writeFile(pngPath, png, { mode: 0o600 }); + await chmod(pngPath, 0o600); const terminal = renderInlineImageQr(url, png) ?? renderTerminalQr(url); return { terminal, pngPath }; } diff --git a/apps/pythinker-code/test/cli/web/remote-control.test.ts b/apps/pythinker-code/test/cli/web/remote-control.test.ts index d7e4c5dae..b387ceb3e 100644 --- a/apps/pythinker-code/test/cli/web/remote-control.test.ts +++ b/apps/pythinker-code/test/cli/web/remote-control.test.ts @@ -146,7 +146,7 @@ describe('Remote Control HTTP forwarding', () => { ).toString(); expect(html).not.toContain('</script><script>alert(1)'); expect(html).toContain('\\u003c/script\\u003e'); - expect(html.match(/<script>/g)).toHaveLength(1); + expect(html.split('<script>')).toHaveLength(2); }); it('rejects absolute-form and malformed request targets', () => { From eb227e657118e8567c5b263ba86609ade3318d80 Mon Sep 17 00:00:00 2001 From: elkaix <melkholy@techmatrix.com> Date: Thu, 27 Aug 2026 06:57:00 -0400 Subject: [PATCH 6/6] test(cli): count script elements case-insensitively in the escape guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitting on the exact string `<script>` would let `<script src=…>` slip past the assertion. --- apps/pythinker-code/test/cli/web/remote-control.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/pythinker-code/test/cli/web/remote-control.test.ts b/apps/pythinker-code/test/cli/web/remote-control.test.ts index b387ceb3e..cdb34cc92 100644 --- a/apps/pythinker-code/test/cli/web/remote-control.test.ts +++ b/apps/pythinker-code/test/cli/web/remote-control.test.ts @@ -146,7 +146,7 @@ describe('Remote Control HTTP forwarding', () => { ).toString(); expect(html).not.toContain('</script><script>alert(1)'); expect(html).toContain('\\u003c/script\\u003e'); - expect(html.split('<script>')).toHaveLength(2); + expect(html.match(/<script\b/gi)).toHaveLength(1); }); it('rejects absolute-form and malformed request targets', () => {