From a06f3a5b0d2e3809f61b528e19403fae8bea938c Mon Sep 17 00:00:00 2001 From: Tony Deng Date: Sun, 26 Apr 2026 15:34:34 -0700 Subject: [PATCH 01/15] pass 1 --- CONTRIBUTING.md | 3 +- README.md | 5 +- package.json | 2 + pnpm-lock.yaml | 13 +++ src/commands/devbox/pty.ts | 163 +++++++++++++++++++++++++++ src/components/DevboxActionsMenu.tsx | 23 +++- src/components/InteractivePty.tsx | 156 +++++++++++++++++++++++++ src/lib/pty-client.ts | 79 +++++++++++++ src/router/Router.tsx | 5 + src/screens/PtySessionScreen.tsx | 62 ++++++++++ src/store/navigationStore.tsx | 4 + src/utils/commands.ts | 22 ++++ 12 files changed, 534 insertions(+), 3 deletions(-) create mode 100644 src/commands/devbox/pty.ts create mode 100644 src/components/InteractivePty.tsx create mode 100644 src/lib/pty-client.ts create mode 100644 src/screens/PtySessionScreen.tsx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 50ca29c3..66586e81 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -152,12 +152,13 @@ PR titles should follow the conventional commit format as they are used for rele src/ ├── cli.ts # Main CLI entry point ├── commands/ # Command implementations -│ ├── devbox/ # Devbox commands +│ ├── devbox/ # Devbox commands (ssh, pty, exec, etc.) │ ├── snapshot/ # Snapshot commands │ ├── blueprint/ # Blueprint commands │ └── object/ # Object storage commands ├── components/ # React/Ink UI components ├── hooks/ # Custom React hooks +├── lib/ # Reusable protocol clients (PTY, etc.) ├── mcp/ # MCP server implementation ├── router/ # Navigation router ├── screens/ # Full-screen views diff --git a/README.md b/README.md index 518ed882..ea8a80fa 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ rli devbox delete - 🎯 **CLI mode** — Traditional commands with text, JSON, and YAML output for scripting - ⚡ Fast and responsive with pagination - 📦 Manage devboxes, snapshots, and blueprints -- 🚀 Execute commands, SSH, view logs in devboxes +- 🚀 Execute commands, SSH, PTY shell, view logs in devboxes - 🤖 **Model Context Protocol (MCP) server for AI integration** ## Installation @@ -69,7 +69,9 @@ The URL must be of the form `https://api.`. The CLI derives other servic | API | `https://api.` (the value of `RUNLOOP_BASE_URL`) | | Platform | `https://platform.` | | SSH | `ssh.:443` | +| PTY | `https://pty.` | | Tunnels | `tunnel.` | +``` ## Usage @@ -109,6 +111,7 @@ rli devbox suspend # Suspend a devbox rli devbox resume # Resume a suspended devbox rli devbox shutdown # Shutdown a devbox rli devbox ssh # SSH into a devbox +rli devbox pty # Connect to a devbox PTY session via W... rli devbox scp # Copy files to/from a devbox using scp... rli devbox rsync # Sync files to/from a devbox using rsy... rli devbox tunnel # Create a port-forwarding tunnel to a ... diff --git a/package.json b/package.json index 78d0cc83..92d746f5 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "ink-spinner": "5.0.0", "ink-text-input": "6.0.0", "react": "19.2.0", + "ws": "^8.18.0", "yaml": "2.8.3", "zustand": "5.0.10" }, @@ -140,6 +141,7 @@ "prettier": "3.8.1", "ts-jest": "29.4.6", "ts-node": "10.9.2", + "@types/ws": "^8.5.0", "typescript": "5.9.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ada46644..a7de95af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: react: specifier: 19.2.0 version: 19.2.0 + ws: + specifier: ^8.18.0 + version: 8.19.0 yaml: specifier: 2.8.3 version: 2.8.3 @@ -111,6 +114,9 @@ importers: '@types/react': specifier: 19.2.10 version: 19.2.10 + '@types/ws': + specifier: ^8.5.0 + version: 8.18.1 '@typescript-eslint/eslint-plugin': specifier: 8.54.0 version: 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) @@ -830,6 +836,9 @@ packages: '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -4004,6 +4013,10 @@ snapshots: '@types/wrap-ansi@3.0.0': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.7 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': diff --git a/src/commands/devbox/pty.ts b/src/commands/devbox/pty.ts new file mode 100644 index 00000000..d10a6bab --- /dev/null +++ b/src/commands/devbox/pty.ts @@ -0,0 +1,163 @@ +import WebSocket from "ws"; +import { cliStatus } from "../../utils/cliStatus.js"; +import { outputError } from "../../utils/output.js"; +import { processUtils } from "../../utils/processUtils.js"; +import { waitForReady } from "../../utils/ssh.js"; +import { + getPtyBaseUrl, + ptyConnect, + ptyControl, + buildWsUrl, +} from "../../lib/pty-client.js"; + +interface PtyOptions { + session?: string; + command?: string; + wait?: boolean; + timeout?: string; + pollInterval?: string; + output?: string; +} + +export async function ptyDevbox(devboxId: string, options: PtyOptions = {}) { + try { + if (options.wait !== false) { + cliStatus(`Waiting for devbox ${devboxId} to be ready...`); + const isReady = await waitForReady( + devboxId, + parseInt(options.timeout || "180"), + parseInt(options.pollInterval || "3"), + ); + if (!isReady) { + outputError(`Devbox ${devboxId} is not ready. Please try again later.`); + } + } + + const baseUrl = getPtyBaseUrl(); + const sessionName = options.session || devboxId; + + if (options.command) { + await execCommand(baseUrl, sessionName, options.command); + } else { + await interactiveSession(baseUrl, sessionName); + } + } catch (error) { + outputError("Failed to start PTY session", error); + } +} + +async function execCommand( + baseUrl: string, + sessionName: string, + command: string, +): Promise { + const connectResponse = await ptyConnect(baseUrl, sessionName, { + cols: 80, + rows: 24, + }); + + const wsUrl = buildWsUrl(baseUrl, connectResponse.connect_url); + + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + + ws.on("open", () => { + ws.send(command + "\n"); + }); + + ws.on("message", (data: WebSocket.Data) => { + if (Buffer.isBuffer(data)) { + process.stdout.write(data); + } else if (data instanceof ArrayBuffer) { + process.stdout.write(Buffer.from(data)); + } else if (Array.isArray(data)) { + process.stdout.write(Buffer.concat(data)); + } else { + process.stdout.write(String(data)); + } + }); + + ws.on("close", () => { + resolve(); + }); + + ws.on("error", (err: Error) => { + reject(err); + }); + }); +} + +async function interactiveSession( + baseUrl: string, + sessionName: string, +): Promise { + const cols = process.stdout.columns || 80; + const rows = process.stdout.rows || 24; + + const connectResponse = await ptyConnect(baseUrl, sessionName, { + cols, + rows, + }); + + const wsUrl = buildWsUrl(baseUrl, connectResponse.connect_url); + + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + + const cleanup = () => { + process.stdin.removeListener("data", onStdinData); + process.removeListener("SIGWINCH", onResize); + if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { + processUtils.stdin.setRawMode(false); + } + process.stdin.pause(); + }; + + const onStdinData = (data: Buffer) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data); + } + }; + + const onResize = () => { + const newCols = process.stdout.columns || 80; + const newRows = process.stdout.rows || 24; + ptyControl(baseUrl, sessionName, { + action: "resize", + cols: newCols, + rows: newRows, + }).catch(() => {}); + }; + + ws.on("open", () => { + if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { + processUtils.stdin.setRawMode(true); + } + process.stdin.resume(); + process.stdin.on("data", onStdinData); + process.on("SIGWINCH", onResize); + }); + + ws.on("message", (data: WebSocket.Data) => { + if (Buffer.isBuffer(data)) { + process.stdout.write(data); + } else if (data instanceof ArrayBuffer) { + process.stdout.write(Buffer.from(data)); + } else if (Array.isArray(data)) { + process.stdout.write(Buffer.concat(data)); + } else { + process.stdout.write(String(data)); + } + }); + + ws.on("close", () => { + cleanup(); + resolve(); + }); + + ws.on("error", (err: Error) => { + cleanup(); + reject(err); + }); + }); +} diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index e135d669..bb0584d7 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -33,6 +33,7 @@ type Operation = | "upload" | "snapshot" | "ssh" + | "pty" | "logs" | "tunnel" | "suspend" @@ -167,6 +168,13 @@ export const DevboxActionsMenu = ({ icon: figures.arrowRight, shortcut: "s", }, + { + key: "pty", + label: "PTY Shell", + color: colors.primary, + icon: figures.arrowRight, + shortcut: "y", + }, { key: "tunnel", label: "Open Tunnel", @@ -244,7 +252,7 @@ export const DevboxActionsMenu = ({ // Auto-execute operations that don't need input (except delete which needs confirmation) React.useEffect(() => { - const autoExecuteOps = ["ssh", "logs", "suspend", "resume"]; + const autoExecuteOps = ["ssh", "pty", "logs", "suspend", "resume"]; if ( executingOperation && autoExecuteOps.includes(executingOperation) && @@ -681,6 +689,19 @@ export const DevboxActionsMenu = ({ }); break; + case "pty": { + const { getPtyBaseUrl } = await import("../lib/pty-client.js"); + navigate("pty-session", { + ptyBaseUrl: getPtyBaseUrl(), + ptySessionName: devbox.id, + devboxId: devbox.id, + devboxName: devbox.name || devbox.id, + returnScreen: currentScreen, + returnParams: params, + }); + break; + } + case "logs": // Set flag to show streaming logs viewer const logsResult: any = { diff --git a/src/components/InteractivePty.tsx b/src/components/InteractivePty.tsx new file mode 100644 index 00000000..8fe22bcb --- /dev/null +++ b/src/components/InteractivePty.tsx @@ -0,0 +1,156 @@ +import React from "react"; +import WebSocket from "ws"; +import { + ptyConnect, + ptyControl, + buildWsUrl, +} from "../lib/pty-client.js"; +import { + showCursor, + clearScreen, + enterAlternateScreenBuffer, +} from "../utils/screen.js"; +import { processUtils } from "../utils/processUtils.js"; + +interface InteractivePtyProps { + baseUrl: string; + sessionName: string; + onExit?: (code: number | null) => void; + onError?: (error: Error) => void; +} + +function releaseTerminal(): void { + process.stdin.pause(); + if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { + processUtils.stdin.setRawMode(false); + } + if (processUtils.stdout.isTTY) { + processUtils.stdout.write("\x1b[0m"); + } + showCursor(); +} + +function restoreTerminal(): void { + clearScreen(); + enterAlternateScreenBuffer(); + if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { + processUtils.stdin.setRawMode(true); + } + process.stdin.resume(); +} + +export const InteractivePty: React.FC = ({ + baseUrl, + sessionName, + onExit, + onError, +}) => { + const wsRef = React.useRef(null); + const hasStartedRef = React.useRef(false); + + React.useEffect(() => { + if (hasStartedRef.current) return; + hasStartedRef.current = true; + + releaseTerminal(); + + let stdinListener: ((data: Buffer) => void) | null = null; + let sigwinchListener: (() => void) | null = null; + + setImmediate(async () => { + try { + const cols = process.stdout.columns || 80; + const rows = process.stdout.rows || 24; + + const connectResponse = await ptyConnect(baseUrl, sessionName, { + cols, + rows, + }); + + const wsUrl = buildWsUrl(baseUrl, connectResponse.connect_url); + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.binaryType = "arraybuffer"; + + ws.on("open", () => { + if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { + processUtils.stdin.setRawMode(true); + } + process.stdin.resume(); + + stdinListener = (data: Buffer) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data); + } + }; + process.stdin.on("data", stdinListener); + }); + + ws.on("message", (data: WebSocket.Data) => { + if (data instanceof ArrayBuffer) { + process.stdout.write(Buffer.from(data)); + } else if (Buffer.isBuffer(data)) { + process.stdout.write(data); + } else if (Array.isArray(data)) { + process.stdout.write(Buffer.concat(data)); + } else { + process.stdout.write(String(data)); + } + }); + + sigwinchListener = () => { + const newCols = process.stdout.columns || 80; + const newRows = process.stdout.rows || 24; + ptyControl(baseUrl, sessionName, { + action: "resize", + cols: newCols, + rows: newRows, + }).catch(() => {}); + }; + process.on("SIGWINCH", sigwinchListener); + + ws.on("close", (code: number) => { + cleanup(); + restoreTerminal(); + hasStartedRef.current = false; + onExit?.(code === 4000 ? 0 : code); + }); + + ws.on("error", (err: Error) => { + cleanup(); + restoreTerminal(); + hasStartedRef.current = false; + onError?.(err); + }); + } catch (err) { + restoreTerminal(); + hasStartedRef.current = false; + onError?.(err instanceof Error ? err : new Error(String(err))); + } + }); + + function cleanup() { + if (stdinListener) { + process.stdin.removeListener("data", stdinListener); + stdinListener = null; + } + if (sigwinchListener) { + process.removeListener("SIGWINCH", sigwinchListener); + sigwinchListener = null; + } + } + + return () => { + cleanup(); + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.close(); + } + wsRef.current = null; + restoreTerminal(); + hasStartedRef.current = false; + }; + }, [baseUrl, sessionName, onExit, onError]); + + return null; +}; diff --git a/src/lib/pty-client.ts b/src/lib/pty-client.ts new file mode 100644 index 00000000..85286b71 --- /dev/null +++ b/src/lib/pty-client.ts @@ -0,0 +1,79 @@ +import { runloopBaseDomain } from "../utils/config.js"; + +// TODO: Update when PTY server URLs are finalized for production +export const PTY_BASE_URL_PROD = "https://pty.runloop.ai"; +export const PTY_BASE_URL_DEV = "https://pty.runloop.pro"; +export const PTY_BASE_URL_LOCAL = "http://localhost:8080"; + +export interface PtyConnectResponse { + session_name: string; + status: string; + protocol_version: string; + connect_url: string; + created: boolean; + attached: boolean; + cols: number; + rows: number; + idle_ttl_seconds: number; +} + +export interface PtyControlResult { + session_name: string; + status: string; +} + +export type ControlAction = + | { action: "resize"; cols: number; rows: number } + | { action: "signal"; signal: string } + | { action: "close" }; + +export function getPtyBaseUrl(): string { + const override = process.env.RUNLOOP_PTY_URL?.trim(); + if (override) return override; + + // TODO: Derive from base domain once PTY server hostnames are finalized + const domain = runloopBaseDomain(); + return `https://pty.${domain}`; +} + +export async function ptyConnect( + baseUrl: string, + sessionName: string, + opts?: { cols?: number; rows?: number }, +): Promise { + const params = new URLSearchParams(); + if (opts?.cols) params.set("cols", String(opts.cols)); + if (opts?.rows) params.set("rows", String(opts.rows)); + + const qs = params.toString(); + const url = `${baseUrl}/pty/${sessionName}${qs ? `?${qs}` : ""}`; + + const res = await fetch(url); + if (!res.ok) { + throw new Error(`PTY connect failed: ${res.status} ${res.statusText}`); + } + return res.json() as Promise; +} + +export async function ptyControl( + baseUrl: string, + sessionName: string, + action: ControlAction, +): Promise { + const url = `${baseUrl}/pty/${sessionName}/control`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(action), + }); + if (!res.ok) { + throw new Error(`PTY control failed: ${res.status} ${res.statusText}`); + } + return res.json() as Promise; +} + +export function buildWsUrl(baseUrl: string, connectUrl: string): string { + const parsed = new URL(baseUrl); + const protocol = parsed.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${parsed.host}${connectUrl}`; +} diff --git a/src/router/Router.tsx b/src/router/Router.tsx index 9d2feb13..c38a4c68 100644 --- a/src/router/Router.tsx +++ b/src/router/Router.tsx @@ -54,6 +54,7 @@ const KNOWN_SCREENS: Set = new Set([ "object-list", "object-detail", "ssh-session", + "pty-session", "benchmark-menu", "benchmark-list", "benchmark-detail", @@ -136,6 +137,7 @@ import { AxonDetailScreen } from "../screens/AxonDetailScreen.js"; import { ObjectListScreen } from "../screens/ObjectListScreen.js"; import { ObjectDetailScreen } from "../screens/ObjectDetailScreen.js"; import { SSHSessionScreen } from "../screens/SSHSessionScreen.js"; +import { PtySessionScreen } from "../screens/PtySessionScreen.js"; import { BenchmarkMenuScreen } from "../screens/BenchmarkMenuScreen.js"; import { BenchmarkListScreen } from "../screens/BenchmarkListScreen.js"; import { BenchmarkDetailScreen } from "../screens/BenchmarkDetailScreen.js"; @@ -355,6 +357,9 @@ export function Router() { {currentScreen === "ssh-session" && ( )} + {currentScreen === "pty-session" && ( + + )} {currentScreen === "benchmark-menu" && ( )} diff --git a/src/screens/PtySessionScreen.tsx b/src/screens/PtySessionScreen.tsx new file mode 100644 index 00000000..f9ad4572 --- /dev/null +++ b/src/screens/PtySessionScreen.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { InteractivePty } from "../components/InteractivePty.js"; +import { + useNavigation, + type ScreenName, + type RouteParams, +} from "../store/navigationStore.js"; +import { Breadcrumb } from "../components/Breadcrumb.js"; +import { colors } from "../utils/theme.js"; +import figures from "figures"; + +export function PtySessionScreen() { + const { params, replace } = useNavigation(); + + const baseUrl = params.ptyBaseUrl; + const sessionName = params.ptySessionName || params.devboxId; + const devboxName = params.devboxName || params.devboxId || "devbox"; + const returnScreen = (params.returnScreen as ScreenName) || "devbox-list"; + const returnParams = (params.returnParams as RouteParams) || {}; + + if (!baseUrl || !sessionName) { + return ( + <> + + + + {figures.cross} Missing PTY configuration. Returning... + + + + ); + } + + return ( + <> + + + + {figures.play} Connecting to {devboxName}... + + + Press Ctrl+D or type exit to disconnect + + + { + setTimeout(() => { + replace(returnScreen, returnParams || {}); + }, 100); + }} + onError={(_error) => { + setTimeout(() => { + replace(returnScreen, returnParams || {}); + }, 100); + }} + /> + + ); +} diff --git a/src/store/navigationStore.tsx b/src/store/navigationStore.tsx index a6cc54f9..96cb92df 100644 --- a/src/store/navigationStore.tsx +++ b/src/store/navigationStore.tsx @@ -45,6 +45,7 @@ export type ScreenName = | "object-list" | "object-detail" | "ssh-session" + | "pty-session" | "benchmark-list" | "benchmark-detail" | "benchmark-run-list" @@ -78,6 +79,9 @@ export interface RouteParams { devboxName?: string; returnScreen?: ScreenName; returnParams?: RouteParams; + // PTY session params + ptyBaseUrl?: string; + ptySessionName?: string; // Exec session params executionId?: string; execCommand?: string; diff --git a/src/utils/commands.ts b/src/utils/commands.ts index 70f920d8..083b45c9 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -221,6 +221,28 @@ export function createProgram(): Command { await sshDevbox(id, options); }); + devbox + .command("pty ") + .alias("shell") + .description("Connect to a devbox PTY session via WebSocket") + .option("--session ", "Session name (defaults to devbox ID)") + .option("--command ", "Execute command non-interactively") + .option("--no-wait", "Do not wait for devbox to be ready") + .option( + "--timeout ", + "Timeout in seconds to wait for readiness", + "180", + ) + .option( + "--poll-interval ", + "Polling interval in seconds while waiting", + "3", + ) + .action(async (id, options) => { + const { ptyDevbox } = await import("../commands/devbox/pty.js"); + await ptyDevbox(id, options); + }); + devbox .command("scp ") .description( From 513b28fdd838a174408aa65045661889235028cf Mon Sep 17 00:00:00 2001 From: Tony Deng Date: Sun, 26 Apr 2026 15:51:38 -0700 Subject: [PATCH 02/15] chore: format InteractivePty.tsx Made-with: Cursor --- src/components/InteractivePty.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/InteractivePty.tsx b/src/components/InteractivePty.tsx index 8fe22bcb..6bf3faa6 100644 --- a/src/components/InteractivePty.tsx +++ b/src/components/InteractivePty.tsx @@ -1,10 +1,6 @@ import React from "react"; import WebSocket from "ws"; -import { - ptyConnect, - ptyControl, - buildWsUrl, -} from "../lib/pty-client.js"; +import { ptyConnect, ptyControl, buildWsUrl } from "../lib/pty-client.js"; import { showCursor, clearScreen, From aedad66227cad317022133735c36d0baa76c2d2b Mon Sep 17 00:00:00 2001 From: Tony Deng Date: Fri, 1 May 2026 15:24:35 -0700 Subject: [PATCH 03/15] cli connect to latest pty shape --- src/commands/devbox/pty.ts | 44 ++++++++++++++----- src/components/DevboxActionsMenu.tsx | 21 +++++++++- src/components/InteractivePty.tsx | 31 ++++++++++---- src/lib/pty-client.ts | 63 ++++++++++++++++++++++++++-- src/screens/PtySessionScreen.tsx | 2 + src/store/navigationStore.tsx | 1 + 6 files changed, 138 insertions(+), 24 deletions(-) diff --git a/src/commands/devbox/pty.ts b/src/commands/devbox/pty.ts index d10a6bab..22566346 100644 --- a/src/commands/devbox/pty.ts +++ b/src/commands/devbox/pty.ts @@ -8,6 +8,10 @@ import { ptyConnect, ptyControl, buildWsUrl, + createPtyTunnel, + getPtyTunnelBaseUrl, + isLocalPtyOverride, + buildWsHeaders, } from "../../lib/pty-client.js"; interface PtyOptions { @@ -33,13 +37,24 @@ export async function ptyDevbox(devboxId: string, options: PtyOptions = {}) { } } - const baseUrl = getPtyBaseUrl(); + let baseUrl: string; + let authToken: string | undefined; + + if (isLocalPtyOverride()) { + baseUrl = getPtyBaseUrl(); + } else { + cliStatus(`Creating PTY tunnel for ${devboxId}...`); + const tunnel = await createPtyTunnel(devboxId); + baseUrl = getPtyTunnelBaseUrl(tunnel.tunnel_key); + authToken = tunnel.auth_token; + } + const sessionName = options.session || devboxId; if (options.command) { - await execCommand(baseUrl, sessionName, options.command); + await execCommand(baseUrl, sessionName, options.command, authToken); } else { - await interactiveSession(baseUrl, sessionName); + await interactiveSession(baseUrl, sessionName, authToken); } } catch (error) { outputError("Failed to start PTY session", error); @@ -50,16 +65,18 @@ async function execCommand( baseUrl: string, sessionName: string, command: string, + authToken?: string, ): Promise { const connectResponse = await ptyConnect(baseUrl, sessionName, { cols: 80, rows: 24, + authToken, }); const wsUrl = buildWsUrl(baseUrl, connectResponse.connect_url); return new Promise((resolve, reject) => { - const ws = new WebSocket(wsUrl); + const ws = new WebSocket(wsUrl, { headers: buildWsHeaders(authToken) }); ws.on("open", () => { ws.send(command + "\n"); @@ -90,6 +107,7 @@ async function execCommand( async function interactiveSession( baseUrl: string, sessionName: string, + authToken?: string, ): Promise { const cols = process.stdout.columns || 80; const rows = process.stdout.rows || 24; @@ -97,12 +115,13 @@ async function interactiveSession( const connectResponse = await ptyConnect(baseUrl, sessionName, { cols, rows, + authToken, }); const wsUrl = buildWsUrl(baseUrl, connectResponse.connect_url); return new Promise((resolve, reject) => { - const ws = new WebSocket(wsUrl); + const ws = new WebSocket(wsUrl, { headers: buildWsHeaders(authToken) }); const cleanup = () => { process.stdin.removeListener("data", onStdinData); @@ -122,11 +141,16 @@ async function interactiveSession( const onResize = () => { const newCols = process.stdout.columns || 80; const newRows = process.stdout.rows || 24; - ptyControl(baseUrl, sessionName, { - action: "resize", - cols: newCols, - rows: newRows, - }).catch(() => {}); + ptyControl( + baseUrl, + sessionName, + { + action: "resize", + cols: newCols, + rows: newRows, + }, + authToken, + ).catch(() => {}); }; ws.on("open", () => { diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index bb0584d7..12789819 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -27,6 +27,12 @@ import { } from "../services/devboxService.js"; import { StreamingLogsViewer } from "./StreamingLogsViewer.js"; import { DevboxView } from "@runloop/api-client/resources/devboxes.mjs"; +import { + getPtyBaseUrl, + isLocalPtyOverride, + createPtyTunnel, + getPtyTunnelBaseUrl, +} from "../lib/pty-client.js"; type Operation = | "exec" @@ -690,10 +696,21 @@ export const DevboxActionsMenu = ({ break; case "pty": { - const { getPtyBaseUrl } = await import("../lib/pty-client.js"); + let ptyBaseUrl: string; + let ptyAuthToken: string | undefined; + + if (isLocalPtyOverride()) { + ptyBaseUrl = getPtyBaseUrl(); + } else { + const tunnel = await createPtyTunnel(devbox.id); + ptyBaseUrl = getPtyTunnelBaseUrl(tunnel.tunnel_key); + ptyAuthToken = tunnel.auth_token; + } + navigate("pty-session", { - ptyBaseUrl: getPtyBaseUrl(), + ptyBaseUrl, ptySessionName: devbox.id, + ptyAuthToken, devboxId: devbox.id, devboxName: devbox.name || devbox.id, returnScreen: currentScreen, diff --git a/src/components/InteractivePty.tsx b/src/components/InteractivePty.tsx index 6bf3faa6..c89b399b 100644 --- a/src/components/InteractivePty.tsx +++ b/src/components/InteractivePty.tsx @@ -1,6 +1,11 @@ import React from "react"; import WebSocket from "ws"; -import { ptyConnect, ptyControl, buildWsUrl } from "../lib/pty-client.js"; +import { + ptyConnect, + ptyControl, + buildWsUrl, + buildWsHeaders, +} from "../lib/pty-client.js"; import { showCursor, clearScreen, @@ -11,6 +16,7 @@ import { processUtils } from "../utils/processUtils.js"; interface InteractivePtyProps { baseUrl: string; sessionName: string; + authToken?: string; onExit?: (code: number | null) => void; onError?: (error: Error) => void; } @@ -38,6 +44,7 @@ function restoreTerminal(): void { export const InteractivePty: React.FC = ({ baseUrl, sessionName, + authToken, onExit, onError, }) => { @@ -61,10 +68,13 @@ export const InteractivePty: React.FC = ({ const connectResponse = await ptyConnect(baseUrl, sessionName, { cols, rows, + authToken, }); const wsUrl = buildWsUrl(baseUrl, connectResponse.connect_url); - const ws = new WebSocket(wsUrl); + const ws = new WebSocket(wsUrl, { + headers: buildWsHeaders(authToken), + }); wsRef.current = ws; ws.binaryType = "arraybuffer"; @@ -98,11 +108,16 @@ export const InteractivePty: React.FC = ({ sigwinchListener = () => { const newCols = process.stdout.columns || 80; const newRows = process.stdout.rows || 24; - ptyControl(baseUrl, sessionName, { - action: "resize", - cols: newCols, - rows: newRows, - }).catch(() => {}); + ptyControl( + baseUrl, + sessionName, + { + action: "resize", + cols: newCols, + rows: newRows, + }, + authToken, + ).catch(() => {}); }; process.on("SIGWINCH", sigwinchListener); @@ -146,7 +161,7 @@ export const InteractivePty: React.FC = ({ restoreTerminal(); hasStartedRef.current = false; }; - }, [baseUrl, sessionName, onExit, onError]); + }, [baseUrl, sessionName, authToken, onExit, onError]); return null; }; diff --git a/src/lib/pty-client.ts b/src/lib/pty-client.ts index 85286b71..21963d05 100644 --- a/src/lib/pty-client.ts +++ b/src/lib/pty-client.ts @@ -1,4 +1,4 @@ -import { runloopBaseDomain } from "../utils/config.js"; +import { runloopBaseDomain, baseUrl, getConfig } from "../utils/config.js"; // TODO: Update when PTY server URLs are finalized for production export const PTY_BASE_URL_PROD = "https://pty.runloop.ai"; @@ -22,11 +22,54 @@ export interface PtyControlResult { status: string; } +export interface PtyTunnelView { + tunnel_key: string; + auth_token: string; +} + export type ControlAction = | { action: "resize"; cols: number; rows: number } | { action: "signal"; signal: string } | { action: "close" }; +export async function createPtyTunnel( + devboxId: string, +): Promise { + const apiKey = getConfig().apiKey; + if (!apiKey) throw new Error("API key not configured"); + + const url = `${baseUrl()}/v1/devboxes/${encodeURIComponent(devboxId)}/create_pty_tunnel`; + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + }); + if (!res.ok) { + throw new Error( + `Create PTY tunnel failed: ${res.status} ${res.statusText}`, + ); + } + return res.json() as Promise; +} + +export function getPtyTunnelBaseUrl(tunnelKey: string): string { + const domain = runloopBaseDomain(); + return `https://${tunnelKey}.tunnel.${domain}`; +} + +export function isLocalPtyOverride(): boolean { + return !!process.env.RUNLOOP_PTY_URL?.trim(); +} + +export function buildWsHeaders( + authToken?: string, +): Record | undefined { + if (!authToken) return undefined; + return { Authorization: `Bearer ${authToken}` }; +} + export function getPtyBaseUrl(): string { const override = process.env.RUNLOOP_PTY_URL?.trim(); if (override) return override; @@ -39,7 +82,7 @@ export function getPtyBaseUrl(): string { export async function ptyConnect( baseUrl: string, sessionName: string, - opts?: { cols?: number; rows?: number }, + opts?: { cols?: number; rows?: number; authToken?: string }, ): Promise { const params = new URLSearchParams(); if (opts?.cols) params.set("cols", String(opts.cols)); @@ -48,7 +91,12 @@ export async function ptyConnect( const qs = params.toString(); const url = `${baseUrl}/pty/${sessionName}${qs ? `?${qs}` : ""}`; - const res = await fetch(url); + const headers: Record = {}; + if (opts?.authToken) { + headers["Authorization"] = `Bearer ${opts.authToken}`; + } + + const res = await fetch(url, { headers }); if (!res.ok) { throw new Error(`PTY connect failed: ${res.status} ${res.statusText}`); } @@ -59,11 +107,18 @@ export async function ptyControl( baseUrl: string, sessionName: string, action: ControlAction, + authToken?: string, ): Promise { const url = `${baseUrl}/pty/${sessionName}/control`; + const headers: Record = { + "Content-Type": "application/json", + }; + if (authToken) { + headers["Authorization"] = `Bearer ${authToken}`; + } const res = await fetch(url, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify(action), }); if (!res.ok) { diff --git a/src/screens/PtySessionScreen.tsx b/src/screens/PtySessionScreen.tsx index f9ad4572..bbe6b014 100644 --- a/src/screens/PtySessionScreen.tsx +++ b/src/screens/PtySessionScreen.tsx @@ -15,6 +15,7 @@ export function PtySessionScreen() { const baseUrl = params.ptyBaseUrl; const sessionName = params.ptySessionName || params.devboxId; + const authToken = params.ptyAuthToken; const devboxName = params.devboxName || params.devboxId || "devbox"; const returnScreen = (params.returnScreen as ScreenName) || "devbox-list"; const returnParams = (params.returnParams as RouteParams) || {}; @@ -46,6 +47,7 @@ export function PtySessionScreen() { { setTimeout(() => { replace(returnScreen, returnParams || {}); diff --git a/src/store/navigationStore.tsx b/src/store/navigationStore.tsx index 96cb92df..ef229259 100644 --- a/src/store/navigationStore.tsx +++ b/src/store/navigationStore.tsx @@ -82,6 +82,7 @@ export interface RouteParams { // PTY session params ptyBaseUrl?: string; ptySessionName?: string; + ptyAuthToken?: string; // Exec session params executionId?: string; execCommand?: string; From dc05765d0d8789d4273952aa6433f6c3ee839c7b Mon Sep 17 00:00:00 2001 From: Tony Deng Date: Tue, 5 May 2026 14:22:20 -0700 Subject: [PATCH 04/15] update connect --- src/lib/pty-client.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/lib/pty-client.ts b/src/lib/pty-client.ts index 21963d05..bc841c0a 100644 --- a/src/lib/pty-client.ts +++ b/src/lib/pty-client.ts @@ -1,9 +1,11 @@ -import { runloopBaseDomain, baseUrl, getConfig } from "../utils/config.js"; +import { baseUrl, getConfig } from "../utils/config.js"; +import { getTunnelUrl } from "../utils/url.js"; -// TODO: Update when PTY server URLs are finalized for production -export const PTY_BASE_URL_PROD = "https://pty.runloop.ai"; -export const PTY_BASE_URL_DEV = "https://pty.runloop.pro"; -export const PTY_BASE_URL_LOCAL = "http://localhost:8080"; +/** Tunnel port for the PTY / rage REST plane (see Runloop PTY usage guide). */ +export const PTY_TUNNEL_REST_PORT = 13; + +/** Default local PTY dev server when `RUNLOOP_PTY_URL` is unset (bazel `//src/test/pty:test_bin`). */ +export const PTY_BASE_URL_LOCAL = "http://localhost:5000"; export interface PtyConnectResponse { session_name: string; @@ -55,8 +57,7 @@ export async function createPtyTunnel( } export function getPtyTunnelBaseUrl(tunnelKey: string): string { - const domain = runloopBaseDomain(); - return `https://${tunnelKey}.tunnel.${domain}`; + return getTunnelUrl(PTY_TUNNEL_REST_PORT, tunnelKey); } export function isLocalPtyOverride(): boolean { @@ -74,9 +75,8 @@ export function getPtyBaseUrl(): string { const override = process.env.RUNLOOP_PTY_URL?.trim(); if (override) return override; - // TODO: Derive from base domain once PTY server hostnames are finalized - const domain = runloopBaseDomain(); - return `https://pty.${domain}`; + // Production uses `create_pty_tunnel` + `getPtyTunnelBaseUrl`; this matches the local test server default. + return PTY_BASE_URL_LOCAL; } export async function ptyConnect( From 8f4ce3a3b5bf699d60e44a070d475608771120a4 Mon Sep 17 00:00:00 2001 From: Tony Deng Date: Tue, 5 May 2026 15:44:52 -0700 Subject: [PATCH 05/15] WORKING --- src/cli.ts | 8 +- src/commands/devbox/pty.ts | 88 +++++-- src/components/DevboxActionsMenu.tsx | 2 + src/components/InteractivePty.tsx | 46 ++-- src/lib/pty-client.ts | 242 ++++++++++++++++-- src/lib/pty-ws.ts | 94 +++++++ src/mcp/server.ts | 6 +- src/utils/config.ts | 90 ++++++- src/utils/url.ts | 6 +- tests/__tests__/utils/config-zprofile.test.ts | 53 ++++ 10 files changed, 572 insertions(+), 63 deletions(-) create mode 100644 src/lib/pty-ws.ts create mode 100644 tests/__tests__/utils/config-zprofile.test.ts diff --git a/src/cli.ts b/src/cli.ts index cc2fe9fe..ca9b1432 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,7 +3,11 @@ import { exitAlternateScreenBuffer } from "./utils/screen.js"; import { processUtils } from "./utils/processUtils.js"; import { createProgram } from "./utils/commands.js"; -import { getApiKeyErrorMessage, checkBaseDomain } from "./utils/config.js"; +import { + getApiKeyErrorMessage, + checkBaseDomain, + loadRunloopApiKeyFromZprofileIfNeeded, +} from "./utils/config.js"; // Global Ctrl+C handler to ensure it always exits processUtils.on("SIGINT", () => { @@ -23,6 +27,8 @@ const program = createProgram(); checkBaseDomain(); + loadRunloopApiKeyFromZprofileIfNeeded(); + // Check if API key is configured (except for mcp commands) const args = process.argv.slice(2); if (!process.env.RUNLOOP_API_KEY) { diff --git a/src/commands/devbox/pty.ts b/src/commands/devbox/pty.ts index 22566346..9b0ad7a0 100644 --- a/src/commands/devbox/pty.ts +++ b/src/commands/devbox/pty.ts @@ -5,14 +5,16 @@ import { processUtils } from "../../utils/processUtils.js"; import { waitForReady } from "../../utils/ssh.js"; import { getPtyBaseUrl, - ptyConnect, ptyControl, - buildWsUrl, + ptyNotifyClosed, + resolvePtyWebSocketUrl, createPtyTunnel, getPtyTunnelBaseUrl, isLocalPtyOverride, buildWsHeaders, + settleAfterPtyTunnel, } from "../../lib/pty-client.js"; +import { openPtyWebSocket } from "../../lib/pty-ws.js"; interface PtyOptions { session?: string; @@ -45,6 +47,7 @@ export async function ptyDevbox(devboxId: string, options: PtyOptions = {}) { } else { cliStatus(`Creating PTY tunnel for ${devboxId}...`); const tunnel = await createPtyTunnel(devboxId); + await settleAfterPtyTunnel(); baseUrl = getPtyTunnelBaseUrl(tunnel.tunnel_key); authToken = tunnel.auth_token; } @@ -67,20 +70,36 @@ async function execCommand( command: string, authToken?: string, ): Promise { - const connectResponse = await ptyConnect(baseUrl, sessionName, { + const wsUrl = await resolvePtyWebSocketUrl(baseUrl, sessionName, { cols: 80, rows: 24, authToken, }); - - const wsUrl = buildWsUrl(baseUrl, connectResponse.connect_url); + const ws = await openPtyWebSocket(wsUrl, buildWsHeaders(authToken)); + ws.send(command + "\n"); return new Promise((resolve, reject) => { - const ws = new WebSocket(wsUrl, { headers: buildWsHeaders(authToken) }); + let released = false; + const notifyClosedOnce = () => { + if (released) return; + released = true; + ptyNotifyClosed(baseUrl, sessionName, authToken); + }; - ws.on("open", () => { - ws.send(command + "\n"); - }); + const onSigint = () => { + notifyClosedOnce(); + process.removeListener("SIGINT", onSigint); + process.removeListener("SIGTERM", onSigterm); + ws.close(); + }; + const onSigterm = () => { + notifyClosedOnce(); + process.removeListener("SIGINT", onSigint); + process.removeListener("SIGTERM", onSigterm); + ws.close(); + }; + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); ws.on("message", (data: WebSocket.Data) => { if (Buffer.isBuffer(data)) { @@ -95,10 +114,16 @@ async function execCommand( }); ws.on("close", () => { + notifyClosedOnce(); + process.removeListener("SIGINT", onSigint); + process.removeListener("SIGTERM", onSigterm); resolve(); }); ws.on("error", (err: Error) => { + notifyClosedOnce(); + process.removeListener("SIGINT", onSigint); + process.removeListener("SIGTERM", onSigterm); reject(err); }); }); @@ -112,18 +137,39 @@ async function interactiveSession( const cols = process.stdout.columns || 80; const rows = process.stdout.rows || 24; - const connectResponse = await ptyConnect(baseUrl, sessionName, { + const wsUrl = await resolvePtyWebSocketUrl(baseUrl, sessionName, { cols, rows, authToken, }); - - const wsUrl = buildWsUrl(baseUrl, connectResponse.connect_url); + const ws = await openPtyWebSocket(wsUrl, buildWsHeaders(authToken)); return new Promise((resolve, reject) => { - const ws = new WebSocket(wsUrl, { headers: buildWsHeaders(authToken) }); + let released = false; + const notifyClosedOnce = () => { + if (released) return; + released = true; + ptyNotifyClosed(baseUrl, sessionName, authToken); + }; + + const onSigint = () => { + notifyClosedOnce(); + process.removeListener("SIGINT", onSigint); + process.removeListener("SIGTERM", onSigterm); + ws.close(); + }; + const onSigterm = () => { + notifyClosedOnce(); + process.removeListener("SIGINT", onSigint); + process.removeListener("SIGTERM", onSigterm); + ws.close(); + }; + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); const cleanup = () => { + process.removeListener("SIGINT", onSigint); + process.removeListener("SIGTERM", onSigterm); process.stdin.removeListener("data", onStdinData); process.removeListener("SIGWINCH", onResize); if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { @@ -153,14 +199,12 @@ async function interactiveSession( ).catch(() => {}); }; - ws.on("open", () => { - if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { - processUtils.stdin.setRawMode(true); - } - process.stdin.resume(); - process.stdin.on("data", onStdinData); - process.on("SIGWINCH", onResize); - }); + if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { + processUtils.stdin.setRawMode(true); + } + process.stdin.resume(); + process.stdin.on("data", onStdinData); + process.on("SIGWINCH", onResize); ws.on("message", (data: WebSocket.Data) => { if (Buffer.isBuffer(data)) { @@ -175,11 +219,13 @@ async function interactiveSession( }); ws.on("close", () => { + notifyClosedOnce(); cleanup(); resolve(); }); ws.on("error", (err: Error) => { + notifyClosedOnce(); cleanup(); reject(err); }); diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index 12789819..23c92c6d 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -32,6 +32,7 @@ import { isLocalPtyOverride, createPtyTunnel, getPtyTunnelBaseUrl, + settleAfterPtyTunnel, } from "../lib/pty-client.js"; type Operation = @@ -703,6 +704,7 @@ export const DevboxActionsMenu = ({ ptyBaseUrl = getPtyBaseUrl(); } else { const tunnel = await createPtyTunnel(devbox.id); + await settleAfterPtyTunnel(); ptyBaseUrl = getPtyTunnelBaseUrl(tunnel.tunnel_key); ptyAuthToken = tunnel.auth_token; } diff --git a/src/components/InteractivePty.tsx b/src/components/InteractivePty.tsx index c89b399b..48c1fb6c 100644 --- a/src/components/InteractivePty.tsx +++ b/src/components/InteractivePty.tsx @@ -1,11 +1,12 @@ import React from "react"; import WebSocket from "ws"; import { - ptyConnect, ptyControl, - buildWsUrl, + ptyNotifyClosed, + resolvePtyWebSocketUrl, buildWsHeaders, } from "../lib/pty-client.js"; +import { openPtyWebSocket } from "../lib/pty-ws.js"; import { showCursor, clearScreen, @@ -59,39 +60,41 @@ export const InteractivePty: React.FC = ({ let stdinListener: ((data: Buffer) => void) | null = null; let sigwinchListener: (() => void) | null = null; + let releaseServerSession: (() => void) | null = null; setImmediate(async () => { try { const cols = process.stdout.columns || 80; const rows = process.stdout.rows || 24; - const connectResponse = await ptyConnect(baseUrl, sessionName, { + const wsUrl = await resolvePtyWebSocketUrl(baseUrl, sessionName, { cols, rows, authToken, }); - - const wsUrl = buildWsUrl(baseUrl, connectResponse.connect_url); - const ws = new WebSocket(wsUrl, { - headers: buildWsHeaders(authToken), - }); + const ws = await openPtyWebSocket(wsUrl, buildWsHeaders(authToken)); wsRef.current = ws; + let sessionNotified = false; + releaseServerSession = () => { + if (sessionNotified) return; + sessionNotified = true; + ptyNotifyClosed(baseUrl, sessionName, authToken); + }; + ws.binaryType = "arraybuffer"; - ws.on("open", () => { - if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { - processUtils.stdin.setRawMode(true); + if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { + processUtils.stdin.setRawMode(true); + } + process.stdin.resume(); + + stdinListener = (data: Buffer) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data); } - process.stdin.resume(); - - stdinListener = (data: Buffer) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(data); - } - }; - process.stdin.on("data", stdinListener); - }); + }; + process.stdin.on("data", stdinListener); ws.on("message", (data: WebSocket.Data) => { if (data instanceof ArrayBuffer) { @@ -122,6 +125,7 @@ export const InteractivePty: React.FC = ({ process.on("SIGWINCH", sigwinchListener); ws.on("close", (code: number) => { + releaseServerSession?.(); cleanup(); restoreTerminal(); hasStartedRef.current = false; @@ -129,6 +133,7 @@ export const InteractivePty: React.FC = ({ }); ws.on("error", (err: Error) => { + releaseServerSession?.(); cleanup(); restoreTerminal(); hasStartedRef.current = false; @@ -154,6 +159,7 @@ export const InteractivePty: React.FC = ({ return () => { cleanup(); + releaseServerSession?.(); if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.close(); } diff --git a/src/lib/pty-client.ts b/src/lib/pty-client.ts index bc841c0a..5ccd961a 100644 --- a/src/lib/pty-client.ts +++ b/src/lib/pty-client.ts @@ -1,8 +1,71 @@ -import { baseUrl, getConfig } from "../utils/config.js"; +import { baseUrl, getConfig, isRunloopDebug } from "../utils/config.js"; import { getTunnelUrl } from "../utils/url.js"; -/** Tunnel port for the PTY / rage REST plane (see Runloop PTY usage guide). */ -export const PTY_TUNNEL_REST_PORT = 13; +const PTY_CONNECT_MAX_ATTEMPTS = Math.max( + 1, + parseInt(process.env.RUNLOOP_PTY_CONNECT_RETRIES || "3", 10) || 3, +); + +/** Optional pause after `create_pty_tunnel` so the mux can route (ms). `0` disables. */ +export async function settleAfterPtyTunnel(): Promise { + const ms = Math.max( + 0, + parseInt(process.env.RUNLOOP_PTY_POST_TUNNEL_MS || "1500", 10) || 0, + ); + if (ms > 0) await new Promise((r) => setTimeout(r, ms)); +} + +function ptyBootstrapTokenInQuery(): boolean { + const v = process.env.RUNLOOP_PTY_BOOTSTRAP_TOKEN_IN_QUERY?.toLowerCase().trim(); + return v === "1" || v === "true" || v === "yes"; +} + +function ptyBootstrapConnectionClose(): boolean { + const v = process.env.RUNLOOP_PTY_BOOTSTRAP_CONNECTION_CLOSE?.toLowerCase().trim(); + return v === "1" || v === "true" || v === "yes"; +} + +function urlForDebugLog(url: string): string { + try { + const u = new URL(url); + if (u.searchParams.has("token")) { + u.searchParams.set("token", "REDACTED"); + } + return u.toString(); + } catch { + return url; + } +} + +async function readErrorSnippet(res: Response): Promise { + try { + const t = (await res.text()).trim(); + if (!t) return ""; + return t.length > 500 ? `${t.slice(0, 500)}…` : t; + } catch { + return ""; + } +} + +function formatHttpError( + prefix: string, + res: Response, + detail: string, +): string { + return detail + ? `${prefix}: ${res.status} ${res.statusText} — ${detail}` + : `${prefix}: ${res.status} ${res.statusText}`; +} + +/** + * Rage REST port on real devboxes (PTY HTTP + WebSocket). Matches platform + * `RAGE_REST_PORT` in Java/K8s; not configurable in normal flows. + * + * MUX PTY origins use {@link getPtyTunnelBaseUrl}: `https://13-{tunnel_key}.tunnel.` + * (via {@link getTunnelUrl}). Must include the `tunnel.` label — not `13-{key}.`. + * Local PTY tests use an ephemeral port on 127.0.0.1 instead (`RUNLOOP_PTY_URL`). + */ +export const RAGE_REST_PORT = 13 as const; /** Default local PTY dev server when `RUNLOOP_PTY_URL` is unset (bazel `//src/test/pty:test_bin`). */ export const PTY_BASE_URL_LOCAL = "http://localhost:5000"; @@ -41,23 +104,31 @@ export async function createPtyTunnel( if (!apiKey) throw new Error("API key not configured"); const url = `${baseUrl()}/v1/devboxes/${encodeURIComponent(devboxId)}/create_pty_tunnel`; + if (isRunloopDebug()) { + console.error(`[RUNLOOP_DEBUG] POST ${url}`); + } const res = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, + body: "{}", }); if (!res.ok) { - throw new Error( - `Create PTY tunnel failed: ${res.status} ${res.statusText}`, - ); + const detail = await readErrorSnippet(res); + throw new Error(formatHttpError("Create PTY tunnel failed", res, detail)); } return res.json() as Promise; } +/** + * Public origin for PTY after `create_pty_tunnel` (MUX): `https://13-{tunnel_key}.tunnel.` + * (same `{port}-{key}.tunnel…` shape as port-forward tunnels, with port fixed at + * {@link RAGE_REST_PORT}). Paths are still `GET /pty/{session_name}`, WebSocket `…/attach`, etc. + */ export function getPtyTunnelBaseUrl(tunnelKey: string): string { - return getTunnelUrl(PTY_TUNNEL_REST_PORT, tunnelKey); + return getTunnelUrl(RAGE_REST_PORT, tunnelKey); } export function isLocalPtyOverride(): boolean { @@ -79,6 +150,7 @@ export function getPtyBaseUrl(): string { return PTY_BASE_URL_LOCAL; } +/** `GET /pty/{session}` bootstrap (JSON + `connect_url`). Required before WebSocket attach unless `RUNLOOP_PTY_ATTACH_ONLY=1`. */ export async function ptyConnect( baseUrl: string, sessionName: string, @@ -87,20 +159,66 @@ export async function ptyConnect( const params = new URLSearchParams(); if (opts?.cols) params.set("cols", String(opts.cols)); if (opts?.rows) params.set("rows", String(opts.rows)); + if (opts?.authToken && ptyBootstrapTokenInQuery()) { + params.set("token", opts.authToken); + } const qs = params.toString(); - const url = `${baseUrl}/pty/${sessionName}${qs ? `?${qs}` : ""}`; + const pathSession = encodeURIComponent(sessionName); + const url = `${baseUrl}/pty/${pathSession}${qs ? `?${qs}` : ""}`; - const headers: Record = {}; + const headers: Record = { + Accept: "application/json", + "User-Agent": "Runloop-rli/pty-bootstrap", + }; if (opts?.authToken) { headers["Authorization"] = `Bearer ${opts.authToken}`; } + if (opts?.authToken && ptyBootstrapConnectionClose()) { + headers["Connection"] = "close"; + } - const res = await fetch(url, { headers }); - if (!res.ok) { - throw new Error(`PTY connect failed: ${res.status} ${res.statusText}`); + for (let attempt = 1; attempt <= PTY_CONNECT_MAX_ATTEMPTS; attempt++) { + if (isRunloopDebug()) { + console.error( + `[RUNLOOP_DEBUG] PTY bootstrap GET ${urlForDebugLog(url)} (attempt ${attempt})`, + ); + if (opts?.authToken) { + console.error(`[RUNLOOP_DEBUG] PTY bootstrap Authorization: Bearer `); + console.error( + `[RUNLOOP_DEBUG] PTY bootstrap token-in-query=${ptyBootstrapTokenInQuery()} connection-close=${ptyBootstrapConnectionClose()}`, + ); + } + } + const res = await fetch(url, { headers }); + if (res.ok) { + return res.json() as Promise; + } + + let detail = await readErrorSnippet(res); + if (res.status === 502) { + const hint = + "Tunnel 502 usually means Rage REST is unreachable from the mux (same URL with curl typically matches)."; + detail = detail ? `${detail} — ${hint}` : hint; + } + const msg = formatHttpError("PTY connect failed", res, detail); + const retryable = res.status === 502 || res.status === 503; + + if (retryable && attempt < PTY_CONNECT_MAX_ATTEMPTS) { + const delayMs = Math.min(10_000, 400 * 2 ** (attempt - 1)); + if (isRunloopDebug()) { + console.error( + `[RUNLOOP_DEBUG] ${msg}; retrying in ${delayMs}ms (${attempt}/${PTY_CONNECT_MAX_ATTEMPTS})`, + ); + } + await new Promise((r) => setTimeout(r, delayMs)); + continue; + } + + throw new Error(msg); } - return res.json() as Promise; + + throw new Error("PTY connect failed: exhausted retries"); } export async function ptyControl( @@ -109,7 +227,7 @@ export async function ptyControl( action: ControlAction, authToken?: string, ): Promise { - const url = `${baseUrl}/pty/${sessionName}/control`; + const url = `${baseUrl}/pty/${encodeURIComponent(sessionName)}/control`; const headers: Record = { "Content-Type": "application/json", }; @@ -122,13 +240,103 @@ export async function ptyControl( body: JSON.stringify(action), }); if (!res.ok) { - throw new Error(`PTY control failed: ${res.status} ${res.statusText}`); + const detail = await readErrorSnippet(res); + throw new Error(formatHttpError("PTY control failed", res, detail)); } return res.json() as Promise; } -export function buildWsUrl(baseUrl: string, connectUrl: string): string { +/** Best-effort: release the PTY session on the server so the same session name can attach again. */ +export function ptyNotifyClosed( + baseUrl: string, + sessionName: string, + authToken?: string, +): void { + void ptyControl(baseUrl, sessionName, { action: "close" }, authToken).catch( + () => {}, + ); +} + +/** WebSocket attach URL; adds `?token=` when `authToken` is set (tunnel WS upgrade). */ +export function buildWsUrl( + baseUrl: string, + connectUrl: string, + authToken?: string, +): string { const parsed = new URL(baseUrl); const protocol = parsed.protocol === "https:" ? "wss:" : "ws:"; - return `${protocol}//${parsed.host}${connectUrl}`; + let path = connectUrl; + if (authToken) { + const sep = connectUrl.includes("?") ? "&" : "?"; + path = `${connectUrl}${sep}token=${encodeURIComponent(authToken)}`; + } + return `${protocol}//${parsed.host}${path}`; +} + +/** + * Path for WebSocket attach only (no prior `GET /pty/{session}` bootstrap). + * Optional `cols` / `rows` match query params supported on upgrade. + */ +export function buildPtyAttachPath( + sessionName: string, + cols?: number, + rows?: number, +): string { + const params = new URLSearchParams(); + if (cols) params.set("cols", String(cols)); + if (rows) params.set("rows", String(rows)); + const qs = params.toString(); + return `/pty/${encodeURIComponent(sessionName)}/attach${qs ? `?${qs}` : ""}`; +} + +/** Full `wss:` URL for PTY streaming with no HTTP bootstrap (experimental). */ +export function buildPtyAttachWsUrl( + baseUrl: string, + sessionName: string, + opts?: { cols?: number; rows?: number; authToken?: string }, +): string { + const path = buildPtyAttachPath(sessionName, opts?.cols, opts?.rows); + const wsUrl = buildWsUrl(baseUrl, path, opts?.authToken); + if (isRunloopDebug()) { + console.error( + `[RUNLOOP_DEBUG] PTY attach-only (no bootstrap) WebSocket ${urlForDebugLog(wsUrl)}`, + ); + } + return wsUrl; +} + +export function isPtyAttachOnlyMode(): boolean { + const v = process.env.RUNLOOP_PTY_ATTACH_ONLY?.toLowerCase().trim(); + return v === "1" || v === "true" || v === "yes"; +} + +/** + * Resolves the WebSocket URL: by default `ptyConnect` bootstrap then `connect_url`; + * if `RUNLOOP_PTY_ATTACH_ONLY=1`, opens `/pty/.../attach` directly (often 502 if server requires bootstrap). + */ +export async function resolvePtyWebSocketUrl( + baseUrl: string, + sessionName: string, + opts: { cols: number; rows: number; authToken?: string }, +): Promise { + if (isPtyAttachOnlyMode()) { + return buildPtyAttachWsUrl(baseUrl, sessionName, opts); + } + const connectResponse = await ptyConnect(baseUrl, sessionName, { + cols: opts.cols, + rows: opts.rows, + authToken: opts.authToken, + }); + const wsUrl = buildWsUrl( + baseUrl, + connectResponse.connect_url, + opts.authToken, + ); + if (isRunloopDebug()) { + console.error( + `[RUNLOOP_DEBUG] PTY bootstrap connect_url=${connectResponse.connect_url}`, + ); + console.error(`[RUNLOOP_DEBUG] PTY WebSocket ${urlForDebugLog(wsUrl)}`); + } + return wsUrl; } diff --git a/src/lib/pty-ws.ts b/src/lib/pty-ws.ts new file mode 100644 index 00000000..c762d3cc --- /dev/null +++ b/src/lib/pty-ws.ts @@ -0,0 +1,94 @@ +import type { ClientRequest, IncomingMessage } from "http"; +import WebSocket from "ws"; +import { isRunloopDebug } from "../utils/config.js"; + +const PTY_WS_MAX_ATTEMPTS = Math.max( + 1, + parseInt(process.env.RUNLOOP_PTY_WS_RETRIES || "3", 10) || 3, +); + +function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function connectWebSocketOnce( + wsUrl: string, + headers: Record | undefined, +): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl, { headers }); + let settled = false; + + const connectTimer = setTimeout(() => { + if (settled) return; + settled = true; + ws.terminate(); + reject(new Error("WebSocket connection timed out")); + }, 45_000); + + function finish(ok: boolean, result: WebSocket | Error) { + if (settled) return; + settled = true; + clearTimeout(connectTimer); + if (ok) resolve(result as WebSocket); + else reject(result as Error); + } + + ws.once("open", () => finish(true, ws)); + + ws.once( + "unexpected-response", + (_req: ClientRequest, res: IncomingMessage) => { + const code = res.statusCode ?? 0; + finish( + false, + new Error( + `WebSocket upgrade failed: HTTP ${code} ${res.statusMessage ?? ""}`.trim(), + ), + ); + }, + ); + + ws.once("error", (err: Error) => finish(false, err)); + }); +} + +/** Connect to PTY attach URL; retries HTTP 502/503 from tunnel edge during warm-up. */ +export async function openPtyWebSocket( + wsUrl: string, + headers: Record | undefined, +): Promise { + let lastErr: Error | undefined; + + for (let attempt = 1; attempt <= PTY_WS_MAX_ATTEMPTS; attempt++) { + try { + if (isRunloopDebug() && attempt > 1) { + console.error( + `[RUNLOOP_DEBUG] WebSocket connect attempt ${attempt}/${PTY_WS_MAX_ATTEMPTS}`, + ); + } + return await connectWebSocketOnce(wsUrl, headers); + } catch (err) { + lastErr = err instanceof Error ? err : new Error(String(err)); + const msg = lastErr.message; + const retryable = + /HTTP\s+(502|503)\b/.test(msg) || + msg.includes("502") || + msg.includes("503"); + + if (!retryable || attempt === PTY_WS_MAX_ATTEMPTS) { + throw lastErr; + } + + const delayMs = Math.min(10_000, 400 * 2 ** (attempt - 1)); + if (isRunloopDebug()) { + console.error( + `[RUNLOOP_DEBUG] ${msg}; retry in ${delayMs}ms (${attempt}/${PTY_WS_MAX_ATTEMPTS})`, + ); + } + await delay(delayMs); + } + } + + throw lastErr ?? new Error("WebSocket connect failed"); +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 4d4839f1..12552284 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -10,7 +10,10 @@ import Runloop from "@runloop/api-client"; import { VERSION } from "@runloop/api-client/version.js"; import Conf from "conf"; import { processUtils } from "../utils/processUtils.js"; -import { checkBaseDomain } from "../utils/config.js"; +import { + checkBaseDomain, + loadRunloopApiKeyFromZprofileIfNeeded, +} from "../utils/config.js"; // Client configuration interface Config { @@ -475,6 +478,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // Start the server async function main() { try { + loadRunloopApiKeyFromZprofileIfNeeded(); checkBaseDomain(); console.error("[MCP] Starting Runloop MCP server..."); const transport = new StdioServerTransport(); diff --git a/src/utils/config.ts b/src/utils/config.ts index e9e8801c..06cddb43 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,7 +1,13 @@ import Conf from "conf"; import { homedir } from "os"; import { join } from "path"; -import { existsSync, statSync, mkdirSync, writeFileSync } from "fs"; +import { + existsSync, + statSync, + mkdirSync, + writeFileSync, + readFileSync, +} from "fs"; interface Config { apiKey?: string; @@ -23,6 +29,78 @@ export function getConfig(): Config { }; } +/** + * If `RUNLOOP_API_KEY` is unset, parse it from `~/.zprofile` (common zsh login + * exports) and set `process.env.RUNLOOP_API_KEY`. Safe to call multiple times. + */ +export function loadRunloopApiKeyFromZprofileIfNeeded(): void { + if (process.env.RUNLOOP_API_KEY?.trim()) return; + + const zprofilePath = join(homedir(), ".zprofile"); + if (!existsSync(zprofilePath)) return; + + try { + const content = readFileSync(zprofilePath, "utf8"); + const key = extractRunloopApiKeyFromShellFile(content); + if (key) { + process.env.RUNLOOP_API_KEY = key; + } + } catch { + // ignore read / permission errors + } +} + +/** @internal — exported for tests */ +export function extractRunloopApiKeyFromShellFile( + content: string, +): string | undefined { + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const m = trimmed.match(/^(?:export\s+)?RUNLOOP_API_KEY=(.*)$/); + if (!m) continue; + + const parsed = parseShellAssignmentValue(m[1].trim()); + if (parsed) return parsed; + } + return undefined; +} + +function parseShellAssignmentValue(raw: string): string | undefined { + if (!raw) return undefined; + + if (raw.startsWith('"')) { + let i = 1; + let out = ""; + while (i < raw.length) { + const c = raw[i]; + if (c === "\\" && i + 1 < raw.length) { + out += raw[i + 1]; + i += 2; + continue; + } + if (c === '"') { + return out || undefined; + } + out += c; + i++; + } + return out || undefined; + } + + if (raw.startsWith("'")) { + const end = raw.indexOf("'", 1); + if (end === -1) return raw.slice(1) || undefined; + return raw.slice(1, end) || undefined; + } + + const hash = raw.indexOf("#"); + const segment = hash === -1 ? raw : raw.slice(0, hash); + const val = segment.trim(); + return val || undefined; +} + export function setApiKey(apiKey: string): void { config.set("apiKey", apiKey); } @@ -206,6 +284,12 @@ export function isBetaEnabled(): boolean { return betaValue === "1" || betaValue === "true"; } +/** Verbose PTY / API diagnostics on stderr (e.g. retry reasons, request URLs). */ +export function isRunloopDebug(): boolean { + const v = process.env.RUNLOOP_DEBUG?.toLowerCase(); + return v === "1" || v === "true" || v === "yes"; +} + /** * Returns the detailed error message for when the API key is not configured. * This message provides instructions on how to set up the API key. @@ -224,6 +308,8 @@ To make it permanent, add this line to your shell config: • For zsh: echo 'export RUNLOOP_API_KEY=your_api_key_here' >> ~/.zshrc • For bash: echo 'export RUNLOOP_API_KEY=your_api_key_here' >> ~/.bashrc -Then restart your terminal or run: source ~/.zshrc (or ~/.bashrc) +If the key is only in ~/.zprofile, this CLI reads it automatically when the +environment variable is not set. Otherwise restart your terminal or run: +source ~/.zprofile (or ~/.zshrc / ~/.bashrc) `; } diff --git a/src/utils/url.ts b/src/utils/url.ts index 2ac450e1..d898305b 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -26,7 +26,11 @@ export function getSettingsUrl(): string { } /** - * Generate a tunnel URL for the given port and tunnel key. + * Public URL for a devbox tunnel: `https://{remotePort}-{tunnelKey}.tunnel.`. + * User port-forwards pass the chosen remote port; PTY (Rage REST) uses port 13 via + * `getPtyTunnelBaseUrl` → `getTunnelUrl(13, tunnelKey)`. The `tunnel.` hostname + * segment is required (see `tunnelBaseHostname()`), e.g. not `13-{key}.runloop.pro`. + * * Pass a number for a real URL, or a string like "{port}" for a display pattern. */ export function getTunnelUrl(port: number | string, tunnelKey: string): string { diff --git a/tests/__tests__/utils/config-zprofile.test.ts b/tests/__tests__/utils/config-zprofile.test.ts new file mode 100644 index 00000000..9e1e36db --- /dev/null +++ b/tests/__tests__/utils/config-zprofile.test.ts @@ -0,0 +1,53 @@ +import { extractRunloopApiKeyFromShellFile } from "../../../src/utils/config.js"; + +describe("extractRunloopApiKeyFromShellFile", () => { + it("parses export with unquoted value", () => { + expect( + extractRunloopApiKeyFromShellFile( + "export RUNLOOP_API_KEY=ak_test_123\n", + ), + ).toBe("ak_test_123"); + }); + + it("parses assignment without export keyword", () => { + expect( + extractRunloopApiKeyFromShellFile("RUNLOOP_API_KEY=ak_plain\n"), + ).toBe("ak_plain"); + }); + + it("strips inline comment on unquoted line", () => { + expect( + extractRunloopApiKeyFromShellFile( + "export RUNLOOP_API_KEY=ak_x # my key\n", + ), + ).toBe("ak_x"); + }); + + it("parses double-quoted value", () => { + expect( + extractRunloopApiKeyFromShellFile( + 'export RUNLOOP_API_KEY="ak_quoted"\n', + ), + ).toBe("ak_quoted"); + }); + + it("parses single-quoted value", () => { + expect( + extractRunloopApiKeyFromShellFile( + "export RUNLOOP_API_KEY='ak_single'\n", + ), + ).toBe("ak_single"); + }); + + it("ignores comments and blank lines", () => { + expect( + extractRunloopApiKeyFromShellFile( + "# comment\n\nexport OTHER=x\nexport RUNLOOP_API_KEY=ak_last\n", + ), + ).toBe("ak_last"); + }); + + it("returns undefined when missing", () => { + expect(extractRunloopApiKeyFromShellFile("export FOO=1\n")).toBeUndefined(); + }); +}); From 12a6cc252c63973cdaea7bd6f1ae9b7e362f778e Mon Sep 17 00:00:00 2001 From: Tony Deng Date: Tue, 5 May 2026 15:56:42 -0700 Subject: [PATCH 06/15] pty review --- README.md | 3 +- src/commands/devbox/pty.ts | 121 ++++++++++++------------------ src/components/InteractivePty.tsx | 13 ++-- src/lib/pty-client.ts | 14 ++++ 4 files changed, 71 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 1271f87a..e0c66f73 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,6 @@ The URL must be of the form `https://api.`. The CLI derives other servic | SSH | `ssh.:443` | | PTY | `https://pty.` | | Tunnels | `tunnel.` | -``` ## Usage @@ -111,7 +110,7 @@ rli devbox suspend # Suspend a devbox rli devbox resume # Resume a suspended devbox rli devbox shutdown # Shutdown a devbox rli devbox ssh # SSH into a devbox -rli devbox pty # Connect to a devbox PTY session via W... +rli devbox pty # Interactive shell (PTY over WebSocket tunnel) rli devbox scp # Copy files to/from a devbox using scp... rli devbox rsync # Sync files to/from a devbox using rsy... rli devbox tunnel # Create a port-forwarding tunnel to a ... diff --git a/src/commands/devbox/pty.ts b/src/commands/devbox/pty.ts index 9b0ad7a0..94bfeb1e 100644 --- a/src/commands/devbox/pty.ts +++ b/src/commands/devbox/pty.ts @@ -6,7 +6,7 @@ import { waitForReady } from "../../utils/ssh.js"; import { getPtyBaseUrl, ptyControl, - ptyNotifyClosed, + createPtySessionReleaser, resolvePtyWebSocketUrl, createPtyTunnel, getPtyTunnelBaseUrl, @@ -16,6 +16,42 @@ import { } from "../../lib/pty-client.js"; import { openPtyWebSocket } from "../../lib/pty-ws.js"; +/** SIGINT/SIGTERM → optional notify then close socket; returns disposer for ws close/error cleanup. */ +function registerPtyInterruptHandlers( + ws: WebSocket, + beforeClose: () => void, +): () => void { + function dispose() { + process.removeListener("SIGINT", onSigint); + process.removeListener("SIGTERM", onSigterm); + } + function onSigint() { + beforeClose(); + dispose(); + ws.close(); + } + function onSigterm() { + beforeClose(); + dispose(); + ws.close(); + } + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + return dispose; +} + +function writePtyStreamToStdout(data: WebSocket.Data): void { + if (Buffer.isBuffer(data)) { + process.stdout.write(data); + } else if (data instanceof ArrayBuffer) { + process.stdout.write(Buffer.from(data)); + } else if (Array.isArray(data)) { + process.stdout.write(Buffer.concat(data)); + } else { + process.stdout.write(String(data)); + } +} + interface PtyOptions { session?: string; command?: string; @@ -79,51 +115,22 @@ async function execCommand( ws.send(command + "\n"); return new Promise((resolve, reject) => { - let released = false; - const notifyClosedOnce = () => { - if (released) return; - released = true; - ptyNotifyClosed(baseUrl, sessionName, authToken); - }; - - const onSigint = () => { - notifyClosedOnce(); - process.removeListener("SIGINT", onSigint); - process.removeListener("SIGTERM", onSigterm); - ws.close(); - }; - const onSigterm = () => { - notifyClosedOnce(); - process.removeListener("SIGINT", onSigint); - process.removeListener("SIGTERM", onSigterm); - ws.close(); - }; - process.on("SIGINT", onSigint); - process.on("SIGTERM", onSigterm); + const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken); + const disposeInterruptSignals = registerPtyInterruptHandlers(ws, releaseOnce); ws.on("message", (data: WebSocket.Data) => { - if (Buffer.isBuffer(data)) { - process.stdout.write(data); - } else if (data instanceof ArrayBuffer) { - process.stdout.write(Buffer.from(data)); - } else if (Array.isArray(data)) { - process.stdout.write(Buffer.concat(data)); - } else { - process.stdout.write(String(data)); - } + writePtyStreamToStdout(data); }); ws.on("close", () => { - notifyClosedOnce(); - process.removeListener("SIGINT", onSigint); - process.removeListener("SIGTERM", onSigterm); + releaseOnce(); + disposeInterruptSignals(); resolve(); }); ws.on("error", (err: Error) => { - notifyClosedOnce(); - process.removeListener("SIGINT", onSigint); - process.removeListener("SIGTERM", onSigterm); + releaseOnce(); + disposeInterruptSignals(); reject(err); }); }); @@ -145,31 +152,11 @@ async function interactiveSession( const ws = await openPtyWebSocket(wsUrl, buildWsHeaders(authToken)); return new Promise((resolve, reject) => { - let released = false; - const notifyClosedOnce = () => { - if (released) return; - released = true; - ptyNotifyClosed(baseUrl, sessionName, authToken); - }; - - const onSigint = () => { - notifyClosedOnce(); - process.removeListener("SIGINT", onSigint); - process.removeListener("SIGTERM", onSigterm); - ws.close(); - }; - const onSigterm = () => { - notifyClosedOnce(); - process.removeListener("SIGINT", onSigint); - process.removeListener("SIGTERM", onSigterm); - ws.close(); - }; - process.on("SIGINT", onSigint); - process.on("SIGTERM", onSigterm); + const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken); + const disposeInterruptSignals = registerPtyInterruptHandlers(ws, releaseOnce); const cleanup = () => { - process.removeListener("SIGINT", onSigint); - process.removeListener("SIGTERM", onSigterm); + disposeInterruptSignals(); process.stdin.removeListener("data", onStdinData); process.removeListener("SIGWINCH", onResize); if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { @@ -207,25 +194,17 @@ async function interactiveSession( process.on("SIGWINCH", onResize); ws.on("message", (data: WebSocket.Data) => { - if (Buffer.isBuffer(data)) { - process.stdout.write(data); - } else if (data instanceof ArrayBuffer) { - process.stdout.write(Buffer.from(data)); - } else if (Array.isArray(data)) { - process.stdout.write(Buffer.concat(data)); - } else { - process.stdout.write(String(data)); - } + writePtyStreamToStdout(data); }); ws.on("close", () => { - notifyClosedOnce(); + releaseOnce(); cleanup(); resolve(); }); ws.on("error", (err: Error) => { - notifyClosedOnce(); + releaseOnce(); cleanup(); reject(err); }); diff --git a/src/components/InteractivePty.tsx b/src/components/InteractivePty.tsx index 48c1fb6c..d130b7da 100644 --- a/src/components/InteractivePty.tsx +++ b/src/components/InteractivePty.tsx @@ -2,7 +2,7 @@ import React from "react"; import WebSocket from "ws"; import { ptyControl, - ptyNotifyClosed, + createPtySessionReleaser, resolvePtyWebSocketUrl, buildWsHeaders, } from "../lib/pty-client.js"; @@ -75,12 +75,11 @@ export const InteractivePty: React.FC = ({ const ws = await openPtyWebSocket(wsUrl, buildWsHeaders(authToken)); wsRef.current = ws; - let sessionNotified = false; - releaseServerSession = () => { - if (sessionNotified) return; - sessionNotified = true; - ptyNotifyClosed(baseUrl, sessionName, authToken); - }; + releaseServerSession = createPtySessionReleaser( + baseUrl, + sessionName, + authToken, + ); ws.binaryType = "arraybuffer"; diff --git a/src/lib/pty-client.ts b/src/lib/pty-client.ts index 5ccd961a..1b1f1bc8 100644 --- a/src/lib/pty-client.ts +++ b/src/lib/pty-client.ts @@ -257,6 +257,20 @@ export function ptyNotifyClosed( ); } +/** Calls {@link ptyNotifyClosed} at most once (ws close, signals, Ink unmount, etc.). */ +export function createPtySessionReleaser( + baseUrl: string, + sessionName: string, + authToken?: string, +): () => void { + let released = false; + return () => { + if (released) return; + released = true; + ptyNotifyClosed(baseUrl, sessionName, authToken); + }; +} + /** WebSocket attach URL; adds `?token=` when `authToken` is set (tunnel WS upgrade). */ export function buildWsUrl( baseUrl: string, From 7880d43e642a35f5e284601b000bef079d0f6b7c Mon Sep 17 00:00:00 2001 From: Tony Deng Date: Tue, 5 May 2026 16:00:03 -0700 Subject: [PATCH 07/15] fmt --- README.md | 2 +- src/commands/devbox/pty.ts | 22 ++++++++++++++++++---- src/lib/pty-client.ts | 10 +++++++--- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e0c66f73..6cf617bd 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ rli devbox suspend # Suspend a devbox rli devbox resume # Resume a suspended devbox rli devbox shutdown # Shutdown a devbox rli devbox ssh # SSH into a devbox -rli devbox pty # Interactive shell (PTY over WebSocket tunnel) +rli devbox pty # Connect to a devbox PTY session via Websocket tunnel rli devbox scp # Copy files to/from a devbox using scp... rli devbox rsync # Sync files to/from a devbox using rsy... rli devbox tunnel # Create a port-forwarding tunnel to a ... diff --git a/src/commands/devbox/pty.ts b/src/commands/devbox/pty.ts index 94bfeb1e..ced1ac60 100644 --- a/src/commands/devbox/pty.ts +++ b/src/commands/devbox/pty.ts @@ -115,8 +115,15 @@ async function execCommand( ws.send(command + "\n"); return new Promise((resolve, reject) => { - const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken); - const disposeInterruptSignals = registerPtyInterruptHandlers(ws, releaseOnce); + const releaseOnce = createPtySessionReleaser( + baseUrl, + sessionName, + authToken, + ); + const disposeInterruptSignals = registerPtyInterruptHandlers( + ws, + releaseOnce, + ); ws.on("message", (data: WebSocket.Data) => { writePtyStreamToStdout(data); @@ -152,8 +159,15 @@ async function interactiveSession( const ws = await openPtyWebSocket(wsUrl, buildWsHeaders(authToken)); return new Promise((resolve, reject) => { - const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken); - const disposeInterruptSignals = registerPtyInterruptHandlers(ws, releaseOnce); + const releaseOnce = createPtySessionReleaser( + baseUrl, + sessionName, + authToken, + ); + const disposeInterruptSignals = registerPtyInterruptHandlers( + ws, + releaseOnce, + ); const cleanup = () => { disposeInterruptSignals(); diff --git a/src/lib/pty-client.ts b/src/lib/pty-client.ts index 1b1f1bc8..13f0cdf2 100644 --- a/src/lib/pty-client.ts +++ b/src/lib/pty-client.ts @@ -16,12 +16,14 @@ export async function settleAfterPtyTunnel(): Promise { } function ptyBootstrapTokenInQuery(): boolean { - const v = process.env.RUNLOOP_PTY_BOOTSTRAP_TOKEN_IN_QUERY?.toLowerCase().trim(); + const v = + process.env.RUNLOOP_PTY_BOOTSTRAP_TOKEN_IN_QUERY?.toLowerCase().trim(); return v === "1" || v === "true" || v === "yes"; } function ptyBootstrapConnectionClose(): boolean { - const v = process.env.RUNLOOP_PTY_BOOTSTRAP_CONNECTION_CLOSE?.toLowerCase().trim(); + const v = + process.env.RUNLOOP_PTY_BOOTSTRAP_CONNECTION_CLOSE?.toLowerCase().trim(); return v === "1" || v === "true" || v === "yes"; } @@ -184,7 +186,9 @@ export async function ptyConnect( `[RUNLOOP_DEBUG] PTY bootstrap GET ${urlForDebugLog(url)} (attempt ${attempt})`, ); if (opts?.authToken) { - console.error(`[RUNLOOP_DEBUG] PTY bootstrap Authorization: Bearer `); + console.error( + `[RUNLOOP_DEBUG] PTY bootstrap Authorization: Bearer `, + ); console.error( `[RUNLOOP_DEBUG] PTY bootstrap token-in-query=${ptyBootstrapTokenInQuery()} connection-close=${ptyBootstrapConnectionClose()}`, ); From 043fd03ae3a2734373125d30fe13b5b2f14a02c8 Mon Sep 17 00:00:00 2001 From: Tony Deng Date: Tue, 5 May 2026 16:02:24 -0700 Subject: [PATCH 08/15] fmt --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6cf617bd..18ac3b9d 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ rli devbox suspend # Suspend a devbox rli devbox resume # Resume a suspended devbox rli devbox shutdown # Shutdown a devbox rli devbox ssh # SSH into a devbox -rli devbox pty # Connect to a devbox PTY session via Websocket tunnel +rli devbox pty # Connect to a devbox PTY session via W... rli devbox scp # Copy files to/from a devbox using scp... rli devbox rsync # Sync files to/from a devbox using rsy... rli devbox tunnel # Create a port-forwarding tunnel to a ... From 8df9f89cc378ba838447ce7e8d8acdae9ba0476d Mon Sep 17 00:00:00 2001 From: Tony Deng Date: Wed, 6 May 2026 10:42:03 -0700 Subject: [PATCH 09/15] cleanup --- src/cli.ts | 8 +- src/lib/pty-client.ts | 46 +--------- src/lib/pty-ws.ts | 11 --- src/mcp/server.ts | 6 +- src/utils/config.ts | 90 +------------------ tests/__tests__/utils/config-zprofile.test.ts | 53 ----------- 6 files changed, 5 insertions(+), 209 deletions(-) delete mode 100644 tests/__tests__/utils/config-zprofile.test.ts diff --git a/src/cli.ts b/src/cli.ts index ca9b1432..cc2fe9fe 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,11 +3,7 @@ import { exitAlternateScreenBuffer } from "./utils/screen.js"; import { processUtils } from "./utils/processUtils.js"; import { createProgram } from "./utils/commands.js"; -import { - getApiKeyErrorMessage, - checkBaseDomain, - loadRunloopApiKeyFromZprofileIfNeeded, -} from "./utils/config.js"; +import { getApiKeyErrorMessage, checkBaseDomain } from "./utils/config.js"; // Global Ctrl+C handler to ensure it always exits processUtils.on("SIGINT", () => { @@ -27,8 +23,6 @@ const program = createProgram(); checkBaseDomain(); - loadRunloopApiKeyFromZprofileIfNeeded(); - // Check if API key is configured (except for mcp commands) const args = process.argv.slice(2); if (!process.env.RUNLOOP_API_KEY) { diff --git a/src/lib/pty-client.ts b/src/lib/pty-client.ts index 13f0cdf2..2bc4752d 100644 --- a/src/lib/pty-client.ts +++ b/src/lib/pty-client.ts @@ -1,4 +1,4 @@ -import { baseUrl, getConfig, isRunloopDebug } from "../utils/config.js"; +import { baseUrl, getConfig } from "../utils/config.js"; import { getTunnelUrl } from "../utils/url.js"; const PTY_CONNECT_MAX_ATTEMPTS = Math.max( @@ -27,18 +27,6 @@ function ptyBootstrapConnectionClose(): boolean { return v === "1" || v === "true" || v === "yes"; } -function urlForDebugLog(url: string): string { - try { - const u = new URL(url); - if (u.searchParams.has("token")) { - u.searchParams.set("token", "REDACTED"); - } - return u.toString(); - } catch { - return url; - } -} - async function readErrorSnippet(res: Response): Promise { try { const t = (await res.text()).trim(); @@ -106,9 +94,6 @@ export async function createPtyTunnel( if (!apiKey) throw new Error("API key not configured"); const url = `${baseUrl()}/v1/devboxes/${encodeURIComponent(devboxId)}/create_pty_tunnel`; - if (isRunloopDebug()) { - console.error(`[RUNLOOP_DEBUG] POST ${url}`); - } const res = await fetch(url, { method: "POST", headers: { @@ -181,19 +166,6 @@ export async function ptyConnect( } for (let attempt = 1; attempt <= PTY_CONNECT_MAX_ATTEMPTS; attempt++) { - if (isRunloopDebug()) { - console.error( - `[RUNLOOP_DEBUG] PTY bootstrap GET ${urlForDebugLog(url)} (attempt ${attempt})`, - ); - if (opts?.authToken) { - console.error( - `[RUNLOOP_DEBUG] PTY bootstrap Authorization: Bearer `, - ); - console.error( - `[RUNLOOP_DEBUG] PTY bootstrap token-in-query=${ptyBootstrapTokenInQuery()} connection-close=${ptyBootstrapConnectionClose()}`, - ); - } - } const res = await fetch(url, { headers }); if (res.ok) { return res.json() as Promise; @@ -210,11 +182,6 @@ export async function ptyConnect( if (retryable && attempt < PTY_CONNECT_MAX_ATTEMPTS) { const delayMs = Math.min(10_000, 400 * 2 ** (attempt - 1)); - if (isRunloopDebug()) { - console.error( - `[RUNLOOP_DEBUG] ${msg}; retrying in ${delayMs}ms (${attempt}/${PTY_CONNECT_MAX_ATTEMPTS})`, - ); - } await new Promise((r) => setTimeout(r, delayMs)); continue; } @@ -315,11 +282,6 @@ export function buildPtyAttachWsUrl( ): string { const path = buildPtyAttachPath(sessionName, opts?.cols, opts?.rows); const wsUrl = buildWsUrl(baseUrl, path, opts?.authToken); - if (isRunloopDebug()) { - console.error( - `[RUNLOOP_DEBUG] PTY attach-only (no bootstrap) WebSocket ${urlForDebugLog(wsUrl)}`, - ); - } return wsUrl; } @@ -350,11 +312,5 @@ export async function resolvePtyWebSocketUrl( connectResponse.connect_url, opts.authToken, ); - if (isRunloopDebug()) { - console.error( - `[RUNLOOP_DEBUG] PTY bootstrap connect_url=${connectResponse.connect_url}`, - ); - console.error(`[RUNLOOP_DEBUG] PTY WebSocket ${urlForDebugLog(wsUrl)}`); - } return wsUrl; } diff --git a/src/lib/pty-ws.ts b/src/lib/pty-ws.ts index c762d3cc..16e0cd38 100644 --- a/src/lib/pty-ws.ts +++ b/src/lib/pty-ws.ts @@ -1,6 +1,5 @@ import type { ClientRequest, IncomingMessage } from "http"; import WebSocket from "ws"; -import { isRunloopDebug } from "../utils/config.js"; const PTY_WS_MAX_ATTEMPTS = Math.max( 1, @@ -62,11 +61,6 @@ export async function openPtyWebSocket( for (let attempt = 1; attempt <= PTY_WS_MAX_ATTEMPTS; attempt++) { try { - if (isRunloopDebug() && attempt > 1) { - console.error( - `[RUNLOOP_DEBUG] WebSocket connect attempt ${attempt}/${PTY_WS_MAX_ATTEMPTS}`, - ); - } return await connectWebSocketOnce(wsUrl, headers); } catch (err) { lastErr = err instanceof Error ? err : new Error(String(err)); @@ -81,11 +75,6 @@ export async function openPtyWebSocket( } const delayMs = Math.min(10_000, 400 * 2 ** (attempt - 1)); - if (isRunloopDebug()) { - console.error( - `[RUNLOOP_DEBUG] ${msg}; retry in ${delayMs}ms (${attempt}/${PTY_WS_MAX_ATTEMPTS})`, - ); - } await delay(delayMs); } } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 12552284..4d4839f1 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -10,10 +10,7 @@ import Runloop from "@runloop/api-client"; import { VERSION } from "@runloop/api-client/version.js"; import Conf from "conf"; import { processUtils } from "../utils/processUtils.js"; -import { - checkBaseDomain, - loadRunloopApiKeyFromZprofileIfNeeded, -} from "../utils/config.js"; +import { checkBaseDomain } from "../utils/config.js"; // Client configuration interface Config { @@ -478,7 +475,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // Start the server async function main() { try { - loadRunloopApiKeyFromZprofileIfNeeded(); checkBaseDomain(); console.error("[MCP] Starting Runloop MCP server..."); const transport = new StdioServerTransport(); diff --git a/src/utils/config.ts b/src/utils/config.ts index 06cddb43..3c29af1a 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,13 +1,7 @@ import Conf from "conf"; import { homedir } from "os"; import { join } from "path"; -import { - existsSync, - statSync, - mkdirSync, - writeFileSync, - readFileSync, -} from "fs"; +import { existsSync, statSync, mkdirSync, writeFileSync } from "fs"; interface Config { apiKey?: string; @@ -29,78 +23,6 @@ export function getConfig(): Config { }; } -/** - * If `RUNLOOP_API_KEY` is unset, parse it from `~/.zprofile` (common zsh login - * exports) and set `process.env.RUNLOOP_API_KEY`. Safe to call multiple times. - */ -export function loadRunloopApiKeyFromZprofileIfNeeded(): void { - if (process.env.RUNLOOP_API_KEY?.trim()) return; - - const zprofilePath = join(homedir(), ".zprofile"); - if (!existsSync(zprofilePath)) return; - - try { - const content = readFileSync(zprofilePath, "utf8"); - const key = extractRunloopApiKeyFromShellFile(content); - if (key) { - process.env.RUNLOOP_API_KEY = key; - } - } catch { - // ignore read / permission errors - } -} - -/** @internal — exported for tests */ -export function extractRunloopApiKeyFromShellFile( - content: string, -): string | undefined { - for (const line of content.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - - const m = trimmed.match(/^(?:export\s+)?RUNLOOP_API_KEY=(.*)$/); - if (!m) continue; - - const parsed = parseShellAssignmentValue(m[1].trim()); - if (parsed) return parsed; - } - return undefined; -} - -function parseShellAssignmentValue(raw: string): string | undefined { - if (!raw) return undefined; - - if (raw.startsWith('"')) { - let i = 1; - let out = ""; - while (i < raw.length) { - const c = raw[i]; - if (c === "\\" && i + 1 < raw.length) { - out += raw[i + 1]; - i += 2; - continue; - } - if (c === '"') { - return out || undefined; - } - out += c; - i++; - } - return out || undefined; - } - - if (raw.startsWith("'")) { - const end = raw.indexOf("'", 1); - if (end === -1) return raw.slice(1) || undefined; - return raw.slice(1, end) || undefined; - } - - const hash = raw.indexOf("#"); - const segment = hash === -1 ? raw : raw.slice(0, hash); - const val = segment.trim(); - return val || undefined; -} - export function setApiKey(apiKey: string): void { config.set("apiKey", apiKey); } @@ -284,12 +206,6 @@ export function isBetaEnabled(): boolean { return betaValue === "1" || betaValue === "true"; } -/** Verbose PTY / API diagnostics on stderr (e.g. retry reasons, request URLs). */ -export function isRunloopDebug(): boolean { - const v = process.env.RUNLOOP_DEBUG?.toLowerCase(); - return v === "1" || v === "true" || v === "yes"; -} - /** * Returns the detailed error message for when the API key is not configured. * This message provides instructions on how to set up the API key. @@ -308,8 +224,6 @@ To make it permanent, add this line to your shell config: • For zsh: echo 'export RUNLOOP_API_KEY=your_api_key_here' >> ~/.zshrc • For bash: echo 'export RUNLOOP_API_KEY=your_api_key_here' >> ~/.bashrc -If the key is only in ~/.zprofile, this CLI reads it automatically when the -environment variable is not set. Otherwise restart your terminal or run: -source ~/.zprofile (or ~/.zshrc / ~/.bashrc) +Restart your terminal or run \`source ~/.zshrc\` / \`source ~/.bashrc\` so the variable is picked up. `; } diff --git a/tests/__tests__/utils/config-zprofile.test.ts b/tests/__tests__/utils/config-zprofile.test.ts deleted file mode 100644 index 9e1e36db..00000000 --- a/tests/__tests__/utils/config-zprofile.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { extractRunloopApiKeyFromShellFile } from "../../../src/utils/config.js"; - -describe("extractRunloopApiKeyFromShellFile", () => { - it("parses export with unquoted value", () => { - expect( - extractRunloopApiKeyFromShellFile( - "export RUNLOOP_API_KEY=ak_test_123\n", - ), - ).toBe("ak_test_123"); - }); - - it("parses assignment without export keyword", () => { - expect( - extractRunloopApiKeyFromShellFile("RUNLOOP_API_KEY=ak_plain\n"), - ).toBe("ak_plain"); - }); - - it("strips inline comment on unquoted line", () => { - expect( - extractRunloopApiKeyFromShellFile( - "export RUNLOOP_API_KEY=ak_x # my key\n", - ), - ).toBe("ak_x"); - }); - - it("parses double-quoted value", () => { - expect( - extractRunloopApiKeyFromShellFile( - 'export RUNLOOP_API_KEY="ak_quoted"\n', - ), - ).toBe("ak_quoted"); - }); - - it("parses single-quoted value", () => { - expect( - extractRunloopApiKeyFromShellFile( - "export RUNLOOP_API_KEY='ak_single'\n", - ), - ).toBe("ak_single"); - }); - - it("ignores comments and blank lines", () => { - expect( - extractRunloopApiKeyFromShellFile( - "# comment\n\nexport OTHER=x\nexport RUNLOOP_API_KEY=ak_last\n", - ), - ).toBe("ak_last"); - }); - - it("returns undefined when missing", () => { - expect(extractRunloopApiKeyFromShellFile("export FOO=1\n")).toBeUndefined(); - }); -}); From 033f78ef40f23783817d7cfa2006638086f40031 Mon Sep 17 00:00:00 2001 From: Tony Deng Date: Wed, 6 May 2026 11:45:46 -0700 Subject: [PATCH 10/15] re-attach logic --- src/commands/devbox/pty.ts | 19 ++++++++++++++++++- src/components/InteractivePty.tsx | 10 ++++++++++ src/lib/pty-client.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/commands/devbox/pty.ts b/src/commands/devbox/pty.ts index ced1ac60..6786708f 100644 --- a/src/commands/devbox/pty.ts +++ b/src/commands/devbox/pty.ts @@ -13,6 +13,7 @@ import { isLocalPtyOverride, buildWsHeaders, settleAfterPtyTunnel, + refreshPtySessionAfterAttach, } from "../../lib/pty-client.js"; import { openPtyWebSocket } from "../../lib/pty-ws.js"; @@ -88,7 +89,7 @@ export async function ptyDevbox(devboxId: string, options: PtyOptions = {}) { authToken = tunnel.auth_token; } - const sessionName = options.session || devboxId; + const sessionName = options.session?.trim() || devboxId; if (options.command) { await execCommand(baseUrl, sessionName, options.command, authToken); @@ -112,6 +113,14 @@ async function execCommand( authToken, }); const ws = await openPtyWebSocket(wsUrl, buildWsHeaders(authToken)); + await refreshPtySessionAfterAttach( + ws, + baseUrl, + sessionName, + 80, + 24, + authToken, + ); ws.send(command + "\n"); return new Promise((resolve, reject) => { @@ -157,6 +166,14 @@ async function interactiveSession( authToken, }); const ws = await openPtyWebSocket(wsUrl, buildWsHeaders(authToken)); + await refreshPtySessionAfterAttach( + ws, + baseUrl, + sessionName, + cols, + rows, + authToken, + ); return new Promise((resolve, reject) => { const releaseOnce = createPtySessionReleaser( diff --git a/src/components/InteractivePty.tsx b/src/components/InteractivePty.tsx index d130b7da..0f1d14b4 100644 --- a/src/components/InteractivePty.tsx +++ b/src/components/InteractivePty.tsx @@ -5,6 +5,7 @@ import { createPtySessionReleaser, resolvePtyWebSocketUrl, buildWsHeaders, + refreshPtySessionAfterAttach, } from "../lib/pty-client.js"; import { openPtyWebSocket } from "../lib/pty-ws.js"; import { @@ -75,6 +76,15 @@ export const InteractivePty: React.FC = ({ const ws = await openPtyWebSocket(wsUrl, buildWsHeaders(authToken)); wsRef.current = ws; + await refreshPtySessionAfterAttach( + ws, + baseUrl, + sessionName, + cols, + rows, + authToken, + ); + releaseServerSession = createPtySessionReleaser( baseUrl, sessionName, diff --git a/src/lib/pty-client.ts b/src/lib/pty-client.ts index 2bc4752d..aef37619 100644 --- a/src/lib/pty-client.ts +++ b/src/lib/pty-client.ts @@ -242,6 +242,32 @@ export function createPtySessionReleaser( }; } +/** Same numeric value as `WebSocket.OPEN` from the `ws` package. */ +const WS_READY_STATE_OPEN = 1; + +/** + * After the attach WebSocket is open: re-send terminal size (refreshes session geometry) + * and send CR so the shell redraws the prompt (avoids a blank display until the user hits Enter). + */ +export async function refreshPtySessionAfterAttach( + ws: { readyState: number; send(data: string | Buffer): void }, + baseUrl: string, + sessionName: string, + cols: number, + rows: number, + authToken?: string, +): Promise { + await ptyControl( + baseUrl, + sessionName, + { action: "resize", cols, rows }, + authToken, + ).catch(() => {}); + if (ws.readyState === WS_READY_STATE_OPEN) { + ws.send("\r"); + } +} + /** WebSocket attach URL; adds `?token=` when `authToken` is set (tunnel WS upgrade). */ export function buildWsUrl( baseUrl: string, From 9f0a0e8acec896f10bd4e34cb80e7155df859285 Mon Sep 17 00:00:00 2001 From: Tony Deng Date: Wed, 6 May 2026 12:27:34 -0700 Subject: [PATCH 11/15] review comments --- README.md | 2 +- src/components/InteractivePty.tsx | 16 ++++++++++++---- src/screens/PtySessionScreen.tsx | 9 +++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 18ac3b9d..18deb5d6 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ rli devbox suspend # Suspend a devbox rli devbox resume # Resume a suspended devbox rli devbox shutdown # Shutdown a devbox rli devbox ssh # SSH into a devbox -rli devbox pty # Connect to a devbox PTY session via W... +rli devbox pty # Interactive PTY session via WebSocket... rli devbox scp # Copy files to/from a devbox using scp... rli devbox rsync # Sync files to/from a devbox using rsy... rli devbox tunnel # Create a port-forwarding tunnel to a ... diff --git a/src/components/InteractivePty.tsx b/src/components/InteractivePty.tsx index 0f1d14b4..496a637e 100644 --- a/src/components/InteractivePty.tsx +++ b/src/components/InteractivePty.tsx @@ -52,6 +52,12 @@ export const InteractivePty: React.FC = ({ }) => { const wsRef = React.useRef(null); const hasStartedRef = React.useRef(false); + const onExitRef = React.useRef(onExit); + const onErrorRef = React.useRef(onError); + React.useEffect(() => { + onExitRef.current = onExit; + onErrorRef.current = onError; + }); React.useEffect(() => { if (hasStartedRef.current) return; @@ -138,7 +144,7 @@ export const InteractivePty: React.FC = ({ cleanup(); restoreTerminal(); hasStartedRef.current = false; - onExit?.(code === 4000 ? 0 : code); + onExitRef.current?.(code === 4000 ? 0 : code); }); ws.on("error", (err: Error) => { @@ -146,12 +152,14 @@ export const InteractivePty: React.FC = ({ cleanup(); restoreTerminal(); hasStartedRef.current = false; - onError?.(err); + onErrorRef.current?.(err); }); } catch (err) { restoreTerminal(); hasStartedRef.current = false; - onError?.(err instanceof Error ? err : new Error(String(err))); + onErrorRef.current?.( + err instanceof Error ? err : new Error(String(err)), + ); } }); @@ -176,7 +184,7 @@ export const InteractivePty: React.FC = ({ restoreTerminal(); hasStartedRef.current = false; }; - }, [baseUrl, sessionName, authToken, onExit, onError]); + }, [baseUrl, sessionName, authToken]); return null; }; diff --git a/src/screens/PtySessionScreen.tsx b/src/screens/PtySessionScreen.tsx index bbe6b014..71637096 100644 --- a/src/screens/PtySessionScreen.tsx +++ b/src/screens/PtySessionScreen.tsx @@ -20,6 +20,15 @@ export function PtySessionScreen() { const returnScreen = (params.returnScreen as ScreenName) || "devbox-list"; const returnParams = (params.returnParams as RouteParams) || {}; + const configOk = !!(baseUrl && sessionName); + React.useEffect(() => { + if (configOk) return; + const id = setTimeout(() => { + replace(returnScreen, returnParams || {}); + }, 100); + return () => clearTimeout(id); + }, [configOk, replace, returnScreen, returnParams]); + if (!baseUrl || !sessionName) { return ( <> From 3cd7f25262c0ec700c0dafa79c91e2a8e049acbc Mon Sep 17 00:00:00 2001 From: Tony Deng Date: Wed, 6 May 2026 12:27:55 -0700 Subject: [PATCH 12/15] readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 18deb5d6..18ac3b9d 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ rli devbox suspend # Suspend a devbox rli devbox resume # Resume a suspended devbox rli devbox shutdown # Shutdown a devbox rli devbox ssh # SSH into a devbox -rli devbox pty # Interactive PTY session via WebSocket... +rli devbox pty # Connect to a devbox PTY session via W... rli devbox scp # Copy files to/from a devbox using scp... rli devbox rsync # Sync files to/from a devbox using rsy... rli devbox tunnel # Create a port-forwarding tunnel to a ... From f6d9cf405692434b6c77161ce596b9e92ee7bfc8 Mon Sep 17 00:00:00 2001 From: Tony Deng Date: Wed, 6 May 2026 13:52:47 -0700 Subject: [PATCH 13/15] review comments --- src/commands/devbox/pty.ts | 145 ++++++++------------------ src/commands/object/upload.ts | 3 +- src/components/DevboxActionsMenu.tsx | 3 + src/components/InteractivePty.tsx | 146 +++++++++++---------------- src/lib/pty-client.ts | 128 ++++++++++++++++++----- src/lib/pty-ws.ts | 15 ++- 6 files changed, 220 insertions(+), 220 deletions(-) diff --git a/src/commands/devbox/pty.ts b/src/commands/devbox/pty.ts index 6786708f..942ae00f 100644 --- a/src/commands/devbox/pty.ts +++ b/src/commands/devbox/pty.ts @@ -1,19 +1,17 @@ import WebSocket from "ws"; import { cliStatus } from "../../utils/cliStatus.js"; import { outputError } from "../../utils/output.js"; -import { processUtils } from "../../utils/processUtils.js"; import { waitForReady } from "../../utils/ssh.js"; import { getPtyBaseUrl, - ptyControl, createPtySessionReleaser, resolvePtyWebSocketUrl, createPtyTunnel, getPtyTunnelBaseUrl, isLocalPtyOverride, - buildWsHeaders, settleAfterPtyTunnel, refreshPtySessionAfterAttach, + startPtyIoSession, } from "../../lib/pty-client.js"; import { openPtyWebSocket } from "../../lib/pty-ws.js"; @@ -41,7 +39,7 @@ function registerPtyInterruptHandlers( return dispose; } -function writePtyStreamToStdout(data: WebSocket.Data): void { +function writePtyStreamToStdout(data: WebSocket.RawData): void { if (Buffer.isBuffer(data)) { process.stdout.write(data); } else if (data instanceof ArrayBuffer) { @@ -101,6 +99,11 @@ export async function ptyDevbox(devboxId: string, options: PtyOptions = {}) { } } +const PTY_EXEC_TIMEOUT_MS = (() => { + const v = parseInt(process.env.RUNLOOP_PTY_EXEC_TIMEOUT_MS || "0", 10); + return isNaN(v) || v < 0 ? 0 : v; +})(); + async function execCommand( baseUrl: string, sessionName: string, @@ -112,41 +115,39 @@ async function execCommand( rows: 24, authToken, }); - const ws = await openPtyWebSocket(wsUrl, buildWsHeaders(authToken)); - await refreshPtySessionAfterAttach( - ws, - baseUrl, - sessionName, - 80, - 24, - authToken, - ); + const ws = await openPtyWebSocket(wsUrl, authToken); + await refreshPtySessionAfterAttach(ws, baseUrl, sessionName, 80, 24, authToken); ws.send(command + "\n"); return new Promise((resolve, reject) => { - const releaseOnce = createPtySessionReleaser( - baseUrl, - sessionName, - authToken, - ); - const disposeInterruptSignals = registerPtyInterruptHandlers( - ws, - releaseOnce, - ); - - ws.on("message", (data: WebSocket.Data) => { + const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken); + const disposeInterruptSignals = registerPtyInterruptHandlers(ws, releaseOnce); + + let timeoutId: ReturnType | undefined; + if (PTY_EXEC_TIMEOUT_MS > 0) { + timeoutId = setTimeout(() => { + releaseOnce(); + ws.close(); + }, PTY_EXEC_TIMEOUT_MS); + } + + const finish = () => { + if (timeoutId !== undefined) clearTimeout(timeoutId); + releaseOnce(); + disposeInterruptSignals(); + }; + + ws.on("message", (data: WebSocket.RawData) => { writePtyStreamToStdout(data); }); ws.on("close", () => { - releaseOnce(); - disposeInterruptSignals(); + finish(); resolve(); }); ws.on("error", (err: Error) => { - releaseOnce(); - disposeInterruptSignals(); + finish(); reject(err); }); }); @@ -165,79 +166,21 @@ async function interactiveSession( rows, authToken, }); - const ws = await openPtyWebSocket(wsUrl, buildWsHeaders(authToken)); - await refreshPtySessionAfterAttach( - ws, - baseUrl, - sessionName, - cols, - rows, - authToken, - ); - - return new Promise((resolve, reject) => { - const releaseOnce = createPtySessionReleaser( - baseUrl, - sessionName, - authToken, - ); - const disposeInterruptSignals = registerPtyInterruptHandlers( - ws, - releaseOnce, - ); - - const cleanup = () => { - disposeInterruptSignals(); - process.stdin.removeListener("data", onStdinData); - process.removeListener("SIGWINCH", onResize); - if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { - processUtils.stdin.setRawMode(false); - } - process.stdin.pause(); - }; - - const onStdinData = (data: Buffer) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(data); - } - }; - - const onResize = () => { - const newCols = process.stdout.columns || 80; - const newRows = process.stdout.rows || 24; - ptyControl( - baseUrl, - sessionName, - { - action: "resize", - cols: newCols, - rows: newRows, - }, - authToken, - ).catch(() => {}); - }; + const ws = await openPtyWebSocket(wsUrl, authToken); + await refreshPtySessionAfterAttach(ws, baseUrl, sessionName, cols, rows, authToken); - if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { - processUtils.stdin.setRawMode(true); - } - process.stdin.resume(); - process.stdin.on("data", onStdinData); - process.on("SIGWINCH", onResize); + const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken); + const { dispose, done } = startPtyIoSession(ws, baseUrl, sessionName, authToken); + const disposeSignals = registerPtyInterruptHandlers(ws, releaseOnce); - ws.on("message", (data: WebSocket.Data) => { - writePtyStreamToStdout(data); - }); - - ws.on("close", () => { - releaseOnce(); - cleanup(); - resolve(); - }); - - ws.on("error", (err: Error) => { - releaseOnce(); - cleanup(); - reject(err); - }); - }); + try { + await done; + releaseOnce(); + } catch (err) { + releaseOnce(); + throw err; + } finally { + dispose(); + disposeSignals(); + } } diff --git a/src/commands/object/upload.ts b/src/commands/object/upload.ts index 9e8d648d..ef59c950 100644 --- a/src/commands/object/upload.ts +++ b/src/commands/object/upload.ts @@ -173,7 +173,8 @@ async function readStdinBuffer(): Promise { export async function uploadObject(options: UploadObjectOptions) { try { const client = getClient(); - const { paths, name, contentType, output: outputFormat } = options; + const { paths: rawPaths, name, contentType, output: outputFormat } = options; + const paths = [...rawPaths]; if (paths.length === 0) { if (!processUtils.stdin.isTTY) { diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index 23c92c6d..d359dfd8 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -34,6 +34,7 @@ import { getPtyTunnelBaseUrl, settleAfterPtyTunnel, } from "../lib/pty-client.js"; +import { waitForReady } from "../utils/ssh.js"; type Operation = | "exec" @@ -697,6 +698,8 @@ export const DevboxActionsMenu = ({ break; case "pty": { + await waitForReady(devbox.id, 180, 3); + let ptyBaseUrl: string; let ptyAuthToken: string | undefined; diff --git a/src/components/InteractivePty.tsx b/src/components/InteractivePty.tsx index 496a637e..438a84a5 100644 --- a/src/components/InteractivePty.tsx +++ b/src/components/InteractivePty.tsx @@ -1,18 +1,13 @@ import React from "react"; import WebSocket from "ws"; import { - ptyControl, createPtySessionReleaser, resolvePtyWebSocketUrl, - buildWsHeaders, refreshPtySessionAfterAttach, + startPtyIoSession, } from "../lib/pty-client.js"; import { openPtyWebSocket } from "../lib/pty-ws.js"; -import { - showCursor, - clearScreen, - enterAlternateScreenBuffer, -} from "../utils/screen.js"; +import { clearScreen } from "../utils/screen.js"; import { processUtils } from "../utils/processUtils.js"; interface InteractivePtyProps { @@ -23,6 +18,10 @@ interface InteractivePtyProps { onError?: (error: Error) => void; } +/** + * Hand control of the terminal back to the PTY by pausing Ink's raw-mode input. + * Called before opening the WebSocket so Ink doesn't race on stdin. + */ function releaseTerminal(): void { process.stdin.pause(); if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { @@ -31,12 +30,15 @@ function releaseTerminal(): void { if (processUtils.stdout.isTTY) { processUtils.stdout.write("\x1b[0m"); } - showCursor(); } +/** + * Return the terminal to Ink after a PTY session ends. + * Clears any residual PTY output and re-enables raw mode so Ink can + * receive keyboard events again. + */ function restoreTerminal(): void { clearScreen(); - enterAlternateScreenBuffer(); if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { processUtils.stdin.setRawMode(true); } @@ -65,9 +67,8 @@ export const InteractivePty: React.FC = ({ releaseTerminal(); - let stdinListener: ((data: Buffer) => void) | null = null; - let sigwinchListener: (() => void) | null = null; - let releaseServerSession: (() => void) | null = null; + let cancelled = false; + let ioDispose: (() => void) | null = null; setImmediate(async () => { try { @@ -79,7 +80,16 @@ export const InteractivePty: React.FC = ({ rows, authToken, }); - const ws = await openPtyWebSocket(wsUrl, buildWsHeaders(authToken)); + + if (cancelled) return; + + const ws = await openPtyWebSocket(wsUrl, authToken); + + if (cancelled) { + ws.close(); + return; + } + wsRef.current = ws; await refreshPtySessionAfterAttach( @@ -91,92 +101,58 @@ export const InteractivePty: React.FC = ({ authToken, ); - releaseServerSession = createPtySessionReleaser( + const releaseServerSession = createPtySessionReleaser( + baseUrl, + sessionName, + authToken, + ); + const { dispose, done } = startPtyIoSession( + ws, baseUrl, sessionName, authToken, ); - ws.binaryType = "arraybuffer"; - - if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { - processUtils.stdin.setRawMode(true); - } - process.stdin.resume(); - - stdinListener = (data: Buffer) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(data); - } - }; - process.stdin.on("data", stdinListener); - - ws.on("message", (data: WebSocket.Data) => { - if (data instanceof ArrayBuffer) { - process.stdout.write(Buffer.from(data)); - } else if (Buffer.isBuffer(data)) { - process.stdout.write(data); - } else if (Array.isArray(data)) { - process.stdout.write(Buffer.concat(data)); - } else { - process.stdout.write(String(data)); - } - }); - - sigwinchListener = () => { - const newCols = process.stdout.columns || 80; - const newRows = process.stdout.rows || 24; - ptyControl( - baseUrl, - sessionName, - { - action: "resize", - cols: newCols, - rows: newRows, - }, - authToken, - ).catch(() => {}); + const ioCleanup = () => { + dispose(); + releaseServerSession(); }; - process.on("SIGWINCH", sigwinchListener); + ioDispose = ioCleanup; - ws.on("close", (code: number) => { - releaseServerSession?.(); - cleanup(); - restoreTerminal(); - hasStartedRef.current = false; - onExitRef.current?.(code === 4000 ? 0 : code); - }); + if (cancelled) { + ioCleanup(); + return; + } - ws.on("error", (err: Error) => { - releaseServerSession?.(); - cleanup(); + done + .then((code) => { + wsRef.current = null; + ioCleanup(); + restoreTerminal(); + hasStartedRef.current = false; + onExitRef.current?.(code === 4000 ? 0 : code); + }) + .catch((err: Error) => { + wsRef.current = null; + ioCleanup(); + restoreTerminal(); + hasStartedRef.current = false; + onErrorRef.current?.(err); + }); + } catch (err) { + if (!cancelled) { restoreTerminal(); hasStartedRef.current = false; - onErrorRef.current?.(err); - }); - } catch (err) { - restoreTerminal(); - hasStartedRef.current = false; - onErrorRef.current?.( - err instanceof Error ? err : new Error(String(err)), - ); + onErrorRef.current?.( + err instanceof Error ? err : new Error(String(err)), + ); + } } }); - function cleanup() { - if (stdinListener) { - process.stdin.removeListener("data", stdinListener); - stdinListener = null; - } - if (sigwinchListener) { - process.removeListener("SIGWINCH", sigwinchListener); - sigwinchListener = null; - } - } - return () => { - cleanup(); - releaseServerSession?.(); + cancelled = true; + ioDispose?.(); if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.close(); } diff --git a/src/lib/pty-client.ts b/src/lib/pty-client.ts index aef37619..25d78836 100644 --- a/src/lib/pty-client.ts +++ b/src/lib/pty-client.ts @@ -1,5 +1,7 @@ +import WebSocket from "ws"; import { baseUrl, getConfig } from "../utils/config.js"; import { getTunnelUrl } from "../utils/url.js"; +import { processUtils } from "../utils/processUtils.js"; const PTY_CONNECT_MAX_ATTEMPTS = Math.max( 1, @@ -122,13 +124,6 @@ export function isLocalPtyOverride(): boolean { return !!process.env.RUNLOOP_PTY_URL?.trim(); } -export function buildWsHeaders( - authToken?: string, -): Record | undefined { - if (!authToken) return undefined; - return { Authorization: `Bearer ${authToken}` }; -} - export function getPtyBaseUrl(): string { const override = process.env.RUNLOOP_PTY_URL?.trim(); if (override) return override; @@ -268,20 +263,11 @@ export async function refreshPtySessionAfterAttach( } } -/** WebSocket attach URL; adds `?token=` when `authToken` is set (tunnel WS upgrade). */ -export function buildWsUrl( - baseUrl: string, - connectUrl: string, - authToken?: string, -): string { +/** WebSocket attach URL built from `baseUrl` + `connectUrl` path. No auth token in the URL. */ +export function buildWsUrl(baseUrl: string, connectUrl: string): string { const parsed = new URL(baseUrl); const protocol = parsed.protocol === "https:" ? "wss:" : "ws:"; - let path = connectUrl; - if (authToken) { - const sep = connectUrl.includes("?") ? "&" : "?"; - path = `${connectUrl}${sep}token=${encodeURIComponent(authToken)}`; - } - return `${protocol}//${parsed.host}${path}`; + return `${protocol}//${parsed.host}${connectUrl}`; } /** @@ -304,11 +290,10 @@ export function buildPtyAttachPath( export function buildPtyAttachWsUrl( baseUrl: string, sessionName: string, - opts?: { cols?: number; rows?: number; authToken?: string }, + opts?: { cols?: number; rows?: number }, ): string { const path = buildPtyAttachPath(sessionName, opts?.cols, opts?.rows); - const wsUrl = buildWsUrl(baseUrl, path, opts?.authToken); - return wsUrl; + return buildWsUrl(baseUrl, path); } export function isPtyAttachOnlyMode(): boolean { @@ -319,6 +304,8 @@ export function isPtyAttachOnlyMode(): boolean { /** * Resolves the WebSocket URL: by default `ptyConnect` bootstrap then `connect_url`; * if `RUNLOOP_PTY_ATTACH_ONLY=1`, opens `/pty/.../attach` directly (often 502 if server requires bootstrap). + * Auth token is passed via HTTP Authorization header (bootstrap) and as WebSocket subprotocol + * (Sec-WebSocket-Protocol), never in the URL. */ export async function resolvePtyWebSocketUrl( baseUrl: string, @@ -333,10 +320,95 @@ export async function resolvePtyWebSocketUrl( rows: opts.rows, authToken: opts.authToken, }); - const wsUrl = buildWsUrl( - baseUrl, - connectResponse.connect_url, - opts.authToken, - ); - return wsUrl; + return buildWsUrl(baseUrl, connectResponse.connect_url); +} + +export interface PtyIoHandle { + /** + * Stop the session: removes all stdin/SIGWINCH/WS listeners, disables raw mode, + * pauses stdin. Does not close the WebSocket or release the server session. + */ + dispose: () => void; + /** + * Resolves with the WS close code when the server closes the session. + * Rejects if a WS error fires before close. Never settles after dispose(). + */ + done: Promise; +} + +/** + * Wire an open WebSocket into an interactive PTY I/O session: + * - Sets stdin to raw mode and pumps keystrokes to the WS + * - Forwards WS messages (binary or text) to stdout + * - Sends resize commands on SIGWINCH + * + * Returns a handle with `dispose()` to tear down listeners and `done` that + * resolves with the WS close code when the session ends naturally. + */ +export function startPtyIoSession( + ws: WebSocket, + baseUrl: string, + sessionName: string, + authToken?: string, +): PtyIoHandle { + let doneResolve!: (code: number) => void; + let doneReject!: (err: Error) => void; + const done = new Promise((resolve, reject) => { + doneResolve = resolve; + doneReject = reject; + }); + + if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { + processUtils.stdin.setRawMode(true); + } + process.stdin.resume(); + + const onStdinData = (data: Buffer) => { + if (ws.readyState === WS_READY_STATE_OPEN) { + ws.send(data); + } + }; + + const onResize = () => { + const cols = process.stdout.columns || 80; + const rows = process.stdout.rows || 24; + ptyControl(baseUrl, sessionName, { action: "resize", cols, rows }, authToken).catch( + () => {}, + ); + }; + + const onMessage = (data: WebSocket.RawData) => { + if (data instanceof ArrayBuffer) { + process.stdout.write(Buffer.from(data)); + } else if (Buffer.isBuffer(data)) { + process.stdout.write(data); + } else if (Array.isArray(data)) { + process.stdout.write(Buffer.concat(data)); + } else { + process.stdout.write(String(data)); + } + }; + + const onClose = (code: number) => doneResolve(code); + const onError = (err: Error) => doneReject(err); + + process.stdin.on("data", onStdinData); + process.on("SIGWINCH", onResize); + ws.on("message", onMessage); + ws.on("close", onClose); + ws.on("error", onError); + + const dispose = () => { + process.stdin.removeListener("data", onStdinData); + process.removeListener("SIGWINCH", onResize); + ws.removeListener("message", onMessage); + ws.removeListener("close", onClose); + ws.removeListener("error", onError); + if (processUtils.stdin.isTTY && processUtils.stdin.setRawMode) { + processUtils.stdin.setRawMode(false); + } + process.stdin.pause(); + }; + + return { dispose, done }; } diff --git a/src/lib/pty-ws.ts b/src/lib/pty-ws.ts index 16e0cd38..7f10dd31 100644 --- a/src/lib/pty-ws.ts +++ b/src/lib/pty-ws.ts @@ -12,10 +12,10 @@ function delay(ms: number): Promise { function connectWebSocketOnce( wsUrl: string, - headers: Record | undefined, + protocols: string[] | undefined, ): Promise { return new Promise((resolve, reject) => { - const ws = new WebSocket(wsUrl, { headers }); + const ws = new WebSocket(wsUrl, protocols ?? []); let settled = false; const connectTimer = setTimeout(() => { @@ -52,16 +52,21 @@ function connectWebSocketOnce( }); } -/** Connect to PTY attach URL; retries HTTP 502/503 from tunnel edge during warm-up. */ +/** + * Connect to PTY attach URL; retries HTTP 502/503 from tunnel edge during warm-up. + * authToken is passed as a WebSocket subprotocol (Sec-WebSocket-Protocol) so it + * is not visible in server access logs as a URL query parameter. + */ export async function openPtyWebSocket( wsUrl: string, - headers: Record | undefined, + authToken: string | undefined, ): Promise { + const protocols = authToken ? [authToken] : undefined; let lastErr: Error | undefined; for (let attempt = 1; attempt <= PTY_WS_MAX_ATTEMPTS; attempt++) { try { - return await connectWebSocketOnce(wsUrl, headers); + return await connectWebSocketOnce(wsUrl, protocols); } catch (err) { lastErr = err instanceof Error ? err : new Error(String(err)); const msg = lastErr.message; From 014e53ebe5aba218ab6d6db128c6cac28a9c96a0 Mon Sep 17 00:00:00 2001 From: Tony Deng Date: Wed, 6 May 2026 13:59:26 -0700 Subject: [PATCH 14/15] fmt --- src/commands/devbox/pty.ts | 36 ++++++++++++++++++++++++++++++----- src/commands/object/upload.ts | 7 ++++++- src/lib/pty-client.ts | 9 ++++++--- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/commands/devbox/pty.ts b/src/commands/devbox/pty.ts index 942ae00f..e00305ef 100644 --- a/src/commands/devbox/pty.ts +++ b/src/commands/devbox/pty.ts @@ -116,12 +116,26 @@ async function execCommand( authToken, }); const ws = await openPtyWebSocket(wsUrl, authToken); - await refreshPtySessionAfterAttach(ws, baseUrl, sessionName, 80, 24, authToken); + await refreshPtySessionAfterAttach( + ws, + baseUrl, + sessionName, + 80, + 24, + authToken, + ); ws.send(command + "\n"); return new Promise((resolve, reject) => { - const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken); - const disposeInterruptSignals = registerPtyInterruptHandlers(ws, releaseOnce); + const releaseOnce = createPtySessionReleaser( + baseUrl, + sessionName, + authToken, + ); + const disposeInterruptSignals = registerPtyInterruptHandlers( + ws, + releaseOnce, + ); let timeoutId: ReturnType | undefined; if (PTY_EXEC_TIMEOUT_MS > 0) { @@ -167,10 +181,22 @@ async function interactiveSession( authToken, }); const ws = await openPtyWebSocket(wsUrl, authToken); - await refreshPtySessionAfterAttach(ws, baseUrl, sessionName, cols, rows, authToken); + await refreshPtySessionAfterAttach( + ws, + baseUrl, + sessionName, + cols, + rows, + authToken, + ); const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken); - const { dispose, done } = startPtyIoSession(ws, baseUrl, sessionName, authToken); + const { dispose, done } = startPtyIoSession( + ws, + baseUrl, + sessionName, + authToken, + ); const disposeSignals = registerPtyInterruptHandlers(ws, releaseOnce); try { diff --git a/src/commands/object/upload.ts b/src/commands/object/upload.ts index ef59c950..98ec16aa 100644 --- a/src/commands/object/upload.ts +++ b/src/commands/object/upload.ts @@ -173,7 +173,12 @@ async function readStdinBuffer(): Promise { export async function uploadObject(options: UploadObjectOptions) { try { const client = getClient(); - const { paths: rawPaths, name, contentType, output: outputFormat } = options; + const { + paths: rawPaths, + name, + contentType, + output: outputFormat, + } = options; const paths = [...rawPaths]; if (paths.length === 0) { diff --git a/src/lib/pty-client.ts b/src/lib/pty-client.ts index 25d78836..9d892385 100644 --- a/src/lib/pty-client.ts +++ b/src/lib/pty-client.ts @@ -372,9 +372,12 @@ export function startPtyIoSession( const onResize = () => { const cols = process.stdout.columns || 80; const rows = process.stdout.rows || 24; - ptyControl(baseUrl, sessionName, { action: "resize", cols, rows }, authToken).catch( - () => {}, - ); + ptyControl( + baseUrl, + sessionName, + { action: "resize", cols, rows }, + authToken, + ).catch(() => {}); }; const onMessage = (data: WebSocket.RawData) => { From 9d447091bff82c574adab9aba253cc5a3ba9328a Mon Sep 17 00:00:00 2001 From: Tony Deng Date: Mon, 11 May 2026 10:28:39 -0700 Subject: [PATCH 15/15] PR Review --- README.md | 3 +- src/commands/devbox/pty.ts | 73 +++---- src/components/InteractivePty.tsx | 23 ++- src/lib/pty-client.ts | 7 + src/lib/pty-ws.ts | 27 ++- src/screens/PtySessionScreen.tsx | 36 ++-- tests/__tests__/lib/pty-client.test.ts | 265 +++++++++++++++++++++++++ tests/__tests__/lib/pty-ws.test.ts | 156 +++++++++++++++ 8 files changed, 520 insertions(+), 70 deletions(-) create mode 100644 tests/__tests__/lib/pty-client.test.ts create mode 100644 tests/__tests__/lib/pty-ws.test.ts diff --git a/README.md b/README.md index 18ac3b9d..aac8f043 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,7 @@ The URL must be of the form `https://api.`. The CLI derives other servic | API | `https://api.` (the value of `RUNLOOP_BASE_URL`) | | Platform | `https://platform.` | | SSH | `ssh.:443` | -| PTY | `https://pty.` | -| Tunnels | `tunnel.` | +| Tunnels | `tunnel.` (PTY sessions reach the devbox over a tunnel created via the API) | ## Usage diff --git a/src/commands/devbox/pty.ts b/src/commands/devbox/pty.ts index e00305ef..13e493b1 100644 --- a/src/commands/devbox/pty.ts +++ b/src/commands/devbox/pty.ts @@ -116,41 +116,21 @@ async function execCommand( authToken, }); const ws = await openPtyWebSocket(wsUrl, authToken); - await refreshPtySessionAfterAttach( - ws, - baseUrl, - sessionName, - 80, - 24, - authToken, - ); - ws.send(command + "\n"); - - return new Promise((resolve, reject) => { - const releaseOnce = createPtySessionReleaser( - baseUrl, - sessionName, - authToken, - ); - const disposeInterruptSignals = registerPtyInterruptHandlers( - ws, - releaseOnce, - ); - - let timeoutId: ReturnType | undefined; - if (PTY_EXEC_TIMEOUT_MS > 0) { - timeoutId = setTimeout(() => { - releaseOnce(); - ws.close(); - }, PTY_EXEC_TIMEOUT_MS); - } + const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken); + const disposeInterruptSignals = registerPtyInterruptHandlers(ws, releaseOnce); + + let timeoutId: ReturnType | undefined; + + const completion = new Promise((resolve, reject) => { const finish = () => { if (timeoutId !== undefined) clearTimeout(timeoutId); releaseOnce(); disposeInterruptSignals(); }; + // Attach listeners synchronously *before* any await — otherwise messages + // emitted between ws open and listener registration are dropped. ws.on("message", (data: WebSocket.RawData) => { writePtyStreamToStdout(data); }); @@ -164,7 +144,29 @@ async function execCommand( finish(); reject(err); }); + + if (PTY_EXEC_TIMEOUT_MS > 0) { + timeoutId = setTimeout(() => { + releaseOnce(); + ws.close(); + }, PTY_EXEC_TIMEOUT_MS); + } }); + + await refreshPtySessionAfterAttach( + ws, + baseUrl, + sessionName, + 80, + 24, + authToken, + ); + // Send the command followed by `exit` so the shell terminates and the + // server closes the WebSocket. Without this, the session would stay open + // after the command finished and the CLI would hang. + ws.send(command + "\nexit\n"); + + return completion; } async function interactiveSession( @@ -181,23 +183,26 @@ async function interactiveSession( authToken, }); const ws = await openPtyWebSocket(wsUrl, authToken); - await refreshPtySessionAfterAttach( + + // Attach IO listeners before the refresh round-trip so server output + // emitted during the ptyControl HTTP call is not dropped. + const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken); + const { dispose, done } = startPtyIoSession( ws, baseUrl, sessionName, - cols, - rows, authToken, ); + const disposeSignals = registerPtyInterruptHandlers(ws, releaseOnce); - const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken); - const { dispose, done } = startPtyIoSession( + await refreshPtySessionAfterAttach( ws, baseUrl, sessionName, + cols, + rows, authToken, ); - const disposeSignals = registerPtyInterruptHandlers(ws, releaseOnce); try { await done; diff --git a/src/components/InteractivePty.tsx b/src/components/InteractivePty.tsx index 438a84a5..9a2f9236 100644 --- a/src/components/InteractivePty.tsx +++ b/src/components/InteractivePty.tsx @@ -5,6 +5,7 @@ import { resolvePtyWebSocketUrl, refreshPtySessionAfterAttach, startPtyIoSession, + PTY_NORMAL_CLOSE_CODE, } from "../lib/pty-client.js"; import { openPtyWebSocket } from "../lib/pty-ws.js"; import { clearScreen } from "../utils/screen.js"; @@ -92,15 +93,8 @@ export const InteractivePty: React.FC = ({ wsRef.current = ws; - await refreshPtySessionAfterAttach( - ws, - baseUrl, - sessionName, - cols, - rows, - authToken, - ); - + // Attach IO listeners before the refresh round-trip so server output + // emitted during the ptyControl HTTP call is not dropped. const releaseServerSession = createPtySessionReleaser( baseUrl, sessionName, @@ -124,13 +118,22 @@ export const InteractivePty: React.FC = ({ return; } + await refreshPtySessionAfterAttach( + ws, + baseUrl, + sessionName, + cols, + rows, + authToken, + ); + done .then((code) => { wsRef.current = null; ioCleanup(); restoreTerminal(); hasStartedRef.current = false; - onExitRef.current?.(code === 4000 ? 0 : code); + onExitRef.current?.(code === PTY_NORMAL_CLOSE_CODE ? 0 : code); }) .catch((err: Error) => { wsRef.current = null; diff --git a/src/lib/pty-client.ts b/src/lib/pty-client.ts index 9d892385..dd0380ad 100644 --- a/src/lib/pty-client.ts +++ b/src/lib/pty-client.ts @@ -240,6 +240,13 @@ export function createPtySessionReleaser( /** Same numeric value as `WebSocket.OPEN` from the `ws` package. */ const WS_READY_STATE_OPEN = 1; +/** + * Application-defined WebSocket close code (range 4000-4999 is reserved for + * applications) the PTY server uses to signal a clean session end — e.g. the + * user typed `exit` and the shell terminated normally. Treated as exit 0. + */ +export const PTY_NORMAL_CLOSE_CODE = 4000; + /** * After the attach WebSocket is open: re-send terminal size (refreshes session geometry) * and send CR so the shell redraws the prompt (avoids a blank display until the user hits Enter). diff --git a/src/lib/pty-ws.ts b/src/lib/pty-ws.ts index 7f10dd31..24d8f0db 100644 --- a/src/lib/pty-ws.ts +++ b/src/lib/pty-ws.ts @@ -6,6 +6,25 @@ const PTY_WS_MAX_ATTEMPTS = Math.max( parseInt(process.env.RUNLOOP_PTY_WS_RETRIES || "3", 10) || 3, ); +/** + * Per-attempt connect timeout. Kept short so the worst-case wall-clock spent + * stacking ptyConnect (up to 3 × 10s back-off) and WS attach retries stays + * bounded; override via env if a slow tunnel ever needs it. + */ +const PTY_WS_CONNECT_TIMEOUT_MS = (() => { + const raw = parseInt( + process.env.RUNLOOP_PTY_WS_CONNECT_TIMEOUT_MS || "15000", + 10, + ); + return Number.isFinite(raw) && raw > 0 ? raw : 15_000; +})(); + +/** + * Tunnel-edge HTTP statuses worth retrying on WebSocket upgrade. 502/503 are + * emitted by the mux while the upstream Rage REST listener is still warming up. + */ +const RETRYABLE_UPGRADE_STATUS = /HTTP\s+(502|503)\b/; + function delay(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } @@ -23,7 +42,7 @@ function connectWebSocketOnce( settled = true; ws.terminate(); reject(new Error("WebSocket connection timed out")); - }, 45_000); + }, PTY_WS_CONNECT_TIMEOUT_MS); function finish(ok: boolean, result: WebSocket | Error) { if (settled) return; @@ -69,11 +88,7 @@ export async function openPtyWebSocket( return await connectWebSocketOnce(wsUrl, protocols); } catch (err) { lastErr = err instanceof Error ? err : new Error(String(err)); - const msg = lastErr.message; - const retryable = - /HTTP\s+(502|503)\b/.test(msg) || - msg.includes("502") || - msg.includes("503"); + const retryable = RETRYABLE_UPGRADE_STATUS.test(lastErr.message); if (!retryable || attempt === PTY_WS_MAX_ATTEMPTS) { throw lastErr; diff --git a/src/screens/PtySessionScreen.tsx b/src/screens/PtySessionScreen.tsx index 71637096..d3c14136 100644 --- a/src/screens/PtySessionScreen.tsx +++ b/src/screens/PtySessionScreen.tsx @@ -17,17 +17,25 @@ export function PtySessionScreen() { const sessionName = params.ptySessionName || params.devboxId; const authToken = params.ptyAuthToken; const devboxName = params.devboxName || params.devboxId || "devbox"; - const returnScreen = (params.returnScreen as ScreenName) || "devbox-list"; - const returnParams = (params.returnParams as RouteParams) || {}; + const returnScreen: ScreenName = + (params.returnScreen as ScreenName) || "devbox-list"; + + // Stabilize returnParams across renders — the inline `|| {}` would otherwise + // produce a fresh object every render and re-fire any effect depending on it. + const returnParamsRaw = params.returnParams as RouteParams | undefined; + const returnParams = React.useMemo( + () => returnParamsRaw ?? {}, + [returnParamsRaw], + ); + + const goBack = React.useCallback(() => { + replace(returnScreen, returnParams); + }, [replace, returnScreen, returnParams]); const configOk = !!(baseUrl && sessionName); React.useEffect(() => { - if (configOk) return; - const id = setTimeout(() => { - replace(returnScreen, returnParams || {}); - }, 100); - return () => clearTimeout(id); - }, [configOk, replace, returnScreen, returnParams]); + if (!configOk) goBack(); + }, [configOk, goBack]); if (!baseUrl || !sessionName) { return ( @@ -57,16 +65,8 @@ export function PtySessionScreen() { baseUrl={baseUrl} sessionName={sessionName} authToken={authToken} - onExit={(_code) => { - setTimeout(() => { - replace(returnScreen, returnParams || {}); - }, 100); - }} - onError={(_error) => { - setTimeout(() => { - replace(returnScreen, returnParams || {}); - }, 100); - }} + onExit={goBack} + onError={goBack} /> ); diff --git a/tests/__tests__/lib/pty-client.test.ts b/tests/__tests__/lib/pty-client.test.ts new file mode 100644 index 00000000..90c2b4b3 --- /dev/null +++ b/tests/__tests__/lib/pty-client.test.ts @@ -0,0 +1,265 @@ +/** + * Tests for PTY client helpers. + * + * Focused on the pure helpers (URL builders, attach-mode toggle, releaser + * once-only semantics) and the bootstrap retry classifier — i.e. the things + * a future regression is most likely to silently break. + */ + +import { + jest, + describe, + it, + expect, + beforeEach, + afterEach, +} from "@jest/globals"; +import { + buildWsUrl, + buildPtyAttachPath, + isPtyAttachOnlyMode, + resolvePtyWebSocketUrl, + createPtySessionReleaser, + ptyConnect, +} from "@/lib/pty-client.js"; + +describe("buildWsUrl", () => { + it("rewrites https:// to wss:// and keeps the host", () => { + expect(buildWsUrl("https://13-abc.tunnel.example.com", "/pty/foo")).toBe( + "wss://13-abc.tunnel.example.com/pty/foo", + ); + }); + + it("rewrites http:// to ws:// for local dev", () => { + expect(buildWsUrl("http://localhost:5000", "/pty/foo/attach")).toBe( + "ws://localhost:5000/pty/foo/attach", + ); + }); + + it("preserves port and ignores baseUrl path", () => { + expect(buildWsUrl("https://api.example.com:8443/v1", "/pty/x")).toBe( + "wss://api.example.com:8443/pty/x", + ); + }); +}); + +describe("buildPtyAttachPath", () => { + it("URL-encodes session names with special characters", () => { + expect(buildPtyAttachPath("session/with spaces & symbols")).toBe( + "/pty/session%2Fwith%20spaces%20%26%20symbols/attach", + ); + }); + + it("omits the query string when no cols/rows provided", () => { + expect(buildPtyAttachPath("s")).toBe("/pty/s/attach"); + }); + + it("includes cols/rows when set", () => { + expect(buildPtyAttachPath("s", 120, 40)).toBe( + "/pty/s/attach?cols=120&rows=40", + ); + }); + + it("drops zero cols/rows (treated as 'unset')", () => { + // Mirrors the URLSearchParams `if (cols)` filtering — zero cols isn't a + // real terminal size, so emitting `cols=0` would be misleading. + expect(buildPtyAttachPath("s", 0, 0)).toBe("/pty/s/attach"); + }); +}); + +describe("isPtyAttachOnlyMode", () => { + const originalEnv = process.env.RUNLOOP_PTY_ATTACH_ONLY; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.RUNLOOP_PTY_ATTACH_ONLY; + } else { + process.env.RUNLOOP_PTY_ATTACH_ONLY = originalEnv; + } + }); + + it.each([ + ["1", true], + ["true", true], + ["TRUE", true], + ["yes", true], + ["0", false], + ["false", false], + ["", false], + ])("env=%s → %s", (value, expected) => { + process.env.RUNLOOP_PTY_ATTACH_ONLY = value; + expect(isPtyAttachOnlyMode()).toBe(expected); + }); +}); + +describe("resolvePtyWebSocketUrl", () => { + const originalFetch = globalThis.fetch; + const originalAttachOnly = process.env.RUNLOOP_PTY_ATTACH_ONLY; + const mockFetch = jest.fn(); + + beforeEach(() => { + globalThis.fetch = mockFetch; + mockFetch.mockReset(); + delete process.env.RUNLOOP_PTY_ATTACH_ONLY; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (originalAttachOnly === undefined) { + delete process.env.RUNLOOP_PTY_ATTACH_ONLY; + } else { + process.env.RUNLOOP_PTY_ATTACH_ONLY = originalAttachOnly; + } + }); + + it("returns the bootstrap connect_url converted to wss when attach-only is off", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + session_name: "s", + status: "ready", + protocol_version: "1", + connect_url: "/pty/s/attach?token=ephemeral", + created: true, + attached: false, + cols: 80, + rows: 24, + idle_ttl_seconds: 300, + }), + } as Response); + + const url = await resolvePtyWebSocketUrl( + "https://13-abc.tunnel.example.com", + "s", + { cols: 80, rows: 24 }, + ); + expect(url).toBe( + "wss://13-abc.tunnel.example.com/pty/s/attach?token=ephemeral", + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("skips bootstrap and builds attach URL directly when attach-only is set", async () => { + process.env.RUNLOOP_PTY_ATTACH_ONLY = "1"; + const url = await resolvePtyWebSocketUrl( + "https://13-abc.tunnel.example.com", + "s", + { cols: 100, rows: 30 }, + ); + expect(url).toBe( + "wss://13-abc.tunnel.example.com/pty/s/attach?cols=100&rows=30", + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + +describe("createPtySessionReleaser", () => { + const originalFetch = globalThis.fetch; + const mockFetch = jest.fn(); + + beforeEach(() => { + globalThis.fetch = mockFetch; + mockFetch.mockReset(); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ session_name: "s", status: "closed" }), + } as Response); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("only triggers the underlying close call once across many releases", async () => { + const release = createPtySessionReleaser( + "https://h.example.com", + "session-a", + "tok", + ); + release(); + release(); + release(); + // Releaser fires `ptyNotifyClosed` asynchronously (void-thened); give it a tick. + await new Promise((r) => setImmediate(r)); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe("https://h.example.com/pty/session-a/control"); + expect(init?.method).toBe("POST"); + expect(JSON.parse(init?.body as string)).toEqual({ action: "close" }); + expect( + (init?.headers as Record)["Authorization"], + ).toBe("Bearer tok"); + }); + + it("does not propagate ptyControl failures to the caller", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: "boom", + text: async () => "internal", + } as Response); + + const release = createPtySessionReleaser("https://h.example.com", "s"); + expect(() => release()).not.toThrow(); + await new Promise((r) => setImmediate(r)); + }); +}); + +describe("ptyConnect retry classifier", () => { + const originalFetch = globalThis.fetch; + const mockFetch = jest.fn(); + + beforeEach(() => { + globalThis.fetch = mockFetch; + mockFetch.mockReset(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("retries 502 then succeeds", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 502, + statusText: "Bad Gateway", + text: async () => "edge", + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + session_name: "s", + status: "ready", + protocol_version: "1", + connect_url: "/pty/s/attach", + created: true, + attached: false, + cols: 80, + rows: 24, + idle_ttl_seconds: 300, + }), + } as Response); + + const res = await ptyConnect("https://h.example.com", "s", { + cols: 80, + rows: 24, + }); + expect(res.connect_url).toBe("/pty/s/attach"); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("does not retry 401 / 404 etc.", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: async () => "missing token", + } as Response); + + await expect( + ptyConnect("https://h.example.com", "s", { cols: 80, rows: 24 }), + ).rejects.toThrow(/401/); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/__tests__/lib/pty-ws.test.ts b/tests/__tests__/lib/pty-ws.test.ts new file mode 100644 index 00000000..4e36f84c --- /dev/null +++ b/tests/__tests__/lib/pty-ws.test.ts @@ -0,0 +1,156 @@ +/** + * Tests for the PTY WebSocket connection helper. + * + * Focused on the retry classifier — the bit most likely to silently break + * once people start poking at error message formatting. + * + * `ws` is mocked via `jest.unstable_mockModule`, which requires the module + * under test to be loaded dynamically *after* the mock is registered. We do + * this once in `beforeAll` and store the export at the top of the file so + * individual tests can rely on plain references rather than scattered + * `await import(...)` calls. + */ + +import { + jest, + describe, + it, + expect, + beforeAll, + beforeEach, + afterEach, +} from "@jest/globals"; +import { EventEmitter } from "events"; + +/** + * Minimal fake `ws` that lets a test drive the connect lifecycle (open, + * unexpected-response, error) without touching the network or real timers. + */ +class FakeWs extends EventEmitter { + terminated = false; + terminate() { + this.terminated = true; + } + // Match the `ws` API surface the code under test touches. + close() {} +} + +const fakeWsInstances: FakeWs[] = []; +const wsConstructorMock = jest.fn(() => { + const ws = new FakeWs(); + fakeWsInstances.push(ws); + return ws; +}); + +jest.unstable_mockModule("ws", () => ({ + default: wsConstructorMock, + __esModule: true, +})); + +let openPtyWebSocket: typeof import("@/lib/pty-ws.js").openPtyWebSocket; + +beforeAll(async () => { + ({ openPtyWebSocket } = await import("@/lib/pty-ws.js")); +}); + +describe("openPtyWebSocket", () => { + const originalRetries = process.env.RUNLOOP_PTY_WS_RETRIES; + + beforeEach(() => { + fakeWsInstances.length = 0; + wsConstructorMock.mockClear(); + process.env.RUNLOOP_PTY_WS_RETRIES = "3"; + }); + + afterEach(() => { + if (originalRetries === undefined) { + delete process.env.RUNLOOP_PTY_WS_RETRIES; + } else { + process.env.RUNLOOP_PTY_WS_RETRIES = originalRetries; + } + }); + + it("resolves on open", async () => { + const promise = openPtyWebSocket("wss://h/p", undefined); + + // The constructor is called synchronously inside connectWebSocketOnce; emit + // `open` on the next tick so the listener is in place. + await new Promise((r) => setImmediate(r)); + fakeWsInstances[0].emit("open"); + + const ws = await promise; + expect(ws).toBe(fakeWsInstances[0]); + expect(wsConstructorMock).toHaveBeenCalledTimes(1); + }); + + it("retries on 502 and resolves on the second attempt", async () => { + const promise = openPtyWebSocket("wss://h/p", "token"); + + await new Promise((r) => setImmediate(r)); + fakeWsInstances[0].emit("unexpected-response", null, { + statusCode: 502, + statusMessage: "Bad Gateway", + }); + + // Back-off uses a real timer; allow up to ~1s for the first retry. + for (let i = 0; i < 50 && fakeWsInstances.length < 2; i++) { + await new Promise((r) => setTimeout(r, 20)); + } + expect(fakeWsInstances).toHaveLength(2); + + fakeWsInstances[1].emit("open"); + const ws = await promise; + expect(ws).toBe(fakeWsInstances[1]); + }); + + it("does not retry on 401", async () => { + const promise = openPtyWebSocket("wss://h/p", "token"); + + await new Promise((r) => setImmediate(r)); + fakeWsInstances[0].emit("unexpected-response", null, { + statusCode: 401, + statusMessage: "Unauthorized", + }); + + await expect(promise).rejects.toThrow(/401/); + expect(fakeWsInstances).toHaveLength(1); + }); + + it("does not match 502 / 503 buried in unrelated error text", async () => { + // Regression guard for the old loose `msg.includes("502")` fallback — + // an unrelated message must not trigger a retry just because it contains + // those digits. + const promise = openPtyWebSocket("wss://h/p", undefined); + + await new Promise((r) => setImmediate(r)); + fakeWsInstances[0].emit( + "error", + new Error("ECONNRESET while reading 50234 bytes"), + ); + + await expect(promise).rejects.toThrow(/ECONNRESET/); + expect(fakeWsInstances).toHaveLength(1); + }); + + it("passes the auth token via Sec-WebSocket-Protocol (not the URL)", async () => { + const promise = openPtyWebSocket("wss://h/p", "secret-token"); + + await new Promise((r) => setImmediate(r)); + expect(wsConstructorMock).toHaveBeenCalledWith("wss://h/p", [ + "secret-token", + ]); + + fakeWsInstances[0].emit("open"); + await promise; + }); + + it("passes an empty protocols list when no token is provided", async () => { + const promise = openPtyWebSocket("wss://h/p", undefined); + + await new Promise((r) => setImmediate(r)); + expect(wsConstructorMock).toHaveBeenCalledWith("wss://h/p", []); + + fakeWsInstances[0].emit("open"); + await promise; + }); +});