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/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 8bdfc2bfe..f5f94b6af 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.21.3", "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..e507d5a39 --- /dev/null +++ b/apps/pythinker-code/src/cli/sub/web/remote-control-lock.ts @@ -0,0 +1,194 @@ +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'; + +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; +const ACQUIRE_RETRY_DELAY_MS = 25; + +export async function acquireRemoteControlLock( + homeDir: string, + details: { localOrigin: string; deviceId: string; url: string }, +): Promise { + const lockPath = remoteControlLockPath(homeDir); + // 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'), + 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', 0o600); + 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') { + 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); + } + // `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 }, + ); + } + 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' && + // `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' && + 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..ccbf35638 --- /dev/null +++ b/apps/pythinker-code/src/cli/sub/web/remote-control.ts @@ -0,0 +1,1093 @@ +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'; + +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> = 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( + 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', + 'content-length', + '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, +): 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 = ''; + 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]); + } + // `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]!, + 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 = scriptStringLiteral(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; +} + +/** + * Embed a value in an inline `` 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 { + 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); + 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; + }); + // `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; + } + + 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); + // 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)}`, + ); + this.http = http; + this.watchSocket(http, 'http'); + 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'); + + 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), + 'Content-Length', + String(parsed.body.length), + '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..804bfe199 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,17 @@ import { splitTokenFragment, } from './access-urls'; import { type NetworkAddress } from './networks'; +import { + formatRemoteControlOutput, + formatRemoteControlStatus, + isRemoteControlEnabled, + REMOTE_CONTROL_FLAG_ENV, + resolveRelayOrigin, + startRemoteControl, + type RemoteControlHandle, + type RemoteControlOptions, + type RemoteControlStatus, +} from './remote-control'; import { DEFAULT_FOREGROUND_LOG_LEVEL, DEFAULT_LAN_HOST, @@ -66,11 +78,15 @@ interface RoutedServer { export interface WebCliOptions extends ServerCliOptions { open?: boolean; + remoteControl?: boolean; + relayOrigin?: string; } 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 +103,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 +127,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 +173,30 @@ 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()), + ); + } + 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) => { 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 +209,22 @@ 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 relayOrigin = opts.remoteControl === true ? resolveRelayOrigin(opts.relayOrigin) : undefined; 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 +233,42 @@ 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, + relayOrigin, + 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 +282,9 @@ export async function handleWebCommand( deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin); } }, + onShutdown: async () => { + await remoteControl?.close(); + }, }); } @@ -229,7 +322,7 @@ export async function startServerForeground( options: ParsedServerOptions, hooks: StartForegroundHooks = {}, ): Promise<never> { - return runServerInProcess(options, hooks.onReady); + return runServerInProcess(options, hooks); } /** @@ -238,7 +331,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 +345,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 +423,30 @@ async function runServerInProcess( running.logger.info({ address: running.address }, 'server ready'); - onReady?.(running.address); + try { + await hooks.onReady?.(running.address); + } catch (error) { + // 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; + } 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..2ef0e4780 100644 --- a/apps/pythinker-code/src/tui/commands/web.ts +++ b/apps/pythinker-code/src/tui/commands/web.ts @@ -1,10 +1,23 @@ import chalk from 'chalk'; import { splitTokenFragment } from '#/cli/sub/web/access-urls'; +import { + buildRemoteControlUrl, + formatRemoteControlOutput, + formatRemoteControlStatus, + resolveRelayOrigin, + 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 +43,76 @@ 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 { + // 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(); + 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, + relayOrigin, + onStatus, + }); + const url = buildRemoteControlUrl(remoteControl.deviceId, session?.id, relayOrigin); + 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..25c3b5e45 --- /dev/null +++ b/apps/pythinker-code/src/utils/remote-control-qr.ts @@ -0,0 +1,66 @@ +import { chmod, 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, 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 }; +} + +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..cdb34cc92 --- /dev/null +++ b/apps/pythinker-code/test/cli/web/remote-control.test.ts @@ -0,0 +1,830 @@ +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'); + // 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', + '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('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\b/gi)).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')), + ).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('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-')); + 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..c85f85844 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,86 @@ 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('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'); + 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 +1109,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..c05c54f05 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 token-free session QR code', 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'; + 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).not.toContain('local-server-token'); + expect(written).not.toContain('#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'; + 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(entryUrl); + const written = writeSpy.mock.calls.map((call) => String(call[0])).join(''); + expect(written).toContain(indentedQr(entryUrl)); + expect(written).not.toContain('/sessions/'); + expect(readFileSync(join(dataDir, 'rc-qrcode.png'))).toEqual( + await QRCode.toBuffer(entryUrl), + ); + 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..0f4d225fc --- /dev/null +++ b/docs/guides/remote-control.md @@ -0,0 +1,45 @@ +# 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. Do not share the link or the QR code. + +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 + +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 + +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..86874a712 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