Skip to content

Commit 3cfd720

Browse files
authored
feat: pty support (#234)
## Description <!-- Provide a brief description of your changes --> **Note:** PR titles should follow [Conventional Commits](https://www.conventionalcommits.org/) format (e.g., `feat(devbox): add support for custom env vars` or `fix(snapshot): resolve pagination issue`) as they are used for automatic release notes generation. ## Type of Change <!-- Mark the relevant option with an 'x' --> - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Code refactoring - [ ] Performance improvement - [ ] Test updates ## Related Issues <!-- Link to related issues using #issue-number --> Closes # ## Changes Made <!-- Describe the changes in detail --> ## Testing <!-- Describe how you tested your changes --> - [ ] I have tested locally - [ ] I have added/updated tests - [ ] All existing tests pass ## Checklist - [ ] My code follows the code style of this project - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have updated the documentation accordingly - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published ## Screenshots (if applicable) <!-- Add screenshots to help explain your changes --> ## Additional Notes <!-- Any additional information that reviewers should know -->
1 parent 83874ca commit 3cfd720

18 files changed

Lines changed: 1516 additions & 7 deletions

File tree

CONTRIBUTING.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,13 @@ PR titles should follow the conventional commit format as they are used for rele
152152
src/
153153
├── cli.ts # Main CLI entry point
154154
├── commands/ # Command implementations
155-
│ ├── devbox/ # Devbox commands
155+
│ ├── devbox/ # Devbox commands (ssh, pty, exec, etc.)
156156
│ ├── snapshot/ # Snapshot commands
157157
│ ├── blueprint/ # Blueprint commands
158158
│ └── object/ # Object storage commands
159159
├── components/ # React/Ink UI components
160160
├── hooks/ # Custom React hooks
161+
├── lib/ # Reusable protocol clients (PTY, etc.)
161162
├── mcp/ # MCP server implementation
162163
├── router/ # Navigation router
163164
├── screens/ # Full-screen views

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ rli devbox delete <devbox-id>
3131
- 🎯 **CLI mode** — Traditional commands with text, JSON, and YAML output for scripting
3232
- ⚡ Fast and responsive with pagination
3333
- 📦 Manage devboxes, snapshots, and blueprints
34-
- 🚀 Execute commands, SSH, view logs in devboxes
34+
- 🚀 Execute commands, SSH, PTY shell, view logs in devboxes
3535
- 🤖 **Model Context Protocol (MCP) server for AI integration**
3636

3737
## Installation
@@ -69,7 +69,7 @@ The URL must be of the form `https://api.<domain>`. The CLI derives other servic
6969
| API | `https://api.<domain>` (the value of `RUNLOOP_BASE_URL`) |
7070
| Platform | `https://platform.<domain>` |
7171
| SSH | `ssh.<domain>:443` |
72-
| Tunnels | `tunnel.<domain>` |
72+
| Tunnels | `tunnel.<domain>` (PTY sessions reach the devbox over a tunnel created via the API) |
7373

7474
## Usage
7575

@@ -109,6 +109,7 @@ rli devbox suspend <id> # Suspend a devbox
109109
rli devbox resume <id> # Resume a suspended devbox
110110
rli devbox shutdown <id> # Shutdown a devbox
111111
rli devbox ssh <id> # SSH into a devbox
112+
rli devbox pty <id> # Connect to a devbox PTY session via W...
112113
rli devbox scp <src> <dst> # Copy files to/from a devbox using scp...
113114
rli devbox rsync <src> <dst> # Sync files to/from a devbox using rsy...
114115
rli devbox tunnel <id> <ports> # Create a port-forwarding tunnel to a ...

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"ink-spinner": "5.0.0",
9090
"ink-text-input": "6.0.0",
9191
"react": "19.2.0",
92+
"ws": "^8.18.0",
9293
"tar-stream": "3.1.7",
9394
"yaml": "2.8.3",
9495
"zustand": "5.0.10"
@@ -142,6 +143,7 @@
142143
"prettier": "3.8.1",
143144
"ts-jest": "29.4.6",
144145
"ts-node": "10.9.2",
146+
"@types/ws": "^8.5.0",
145147
"typescript": "5.9.3"
146148
}
147149
}

pnpm-lock.yaml

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commands/devbox/pty.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import WebSocket from "ws";
2+
import { cliStatus } from "../../utils/cliStatus.js";
3+
import { outputError } from "../../utils/output.js";
4+
import { waitForReady } from "../../utils/ssh.js";
5+
import {
6+
getPtyBaseUrl,
7+
createPtySessionReleaser,
8+
resolvePtyWebSocketUrl,
9+
createPtyTunnel,
10+
getPtyTunnelBaseUrl,
11+
isLocalPtyOverride,
12+
settleAfterPtyTunnel,
13+
refreshPtySessionAfterAttach,
14+
startPtyIoSession,
15+
} from "../../lib/pty-client.js";
16+
import { openPtyWebSocket } from "../../lib/pty-ws.js";
17+
18+
/** SIGINT/SIGTERM → optional notify then close socket; returns disposer for ws close/error cleanup. */
19+
function registerPtyInterruptHandlers(
20+
ws: WebSocket,
21+
beforeClose: () => void,
22+
): () => void {
23+
function dispose() {
24+
process.removeListener("SIGINT", onSigint);
25+
process.removeListener("SIGTERM", onSigterm);
26+
}
27+
function onSigint() {
28+
beforeClose();
29+
dispose();
30+
ws.close();
31+
}
32+
function onSigterm() {
33+
beforeClose();
34+
dispose();
35+
ws.close();
36+
}
37+
process.on("SIGINT", onSigint);
38+
process.on("SIGTERM", onSigterm);
39+
return dispose;
40+
}
41+
42+
function writePtyStreamToStdout(data: WebSocket.RawData): void {
43+
if (Buffer.isBuffer(data)) {
44+
process.stdout.write(data);
45+
} else if (data instanceof ArrayBuffer) {
46+
process.stdout.write(Buffer.from(data));
47+
} else if (Array.isArray(data)) {
48+
process.stdout.write(Buffer.concat(data));
49+
} else {
50+
process.stdout.write(String(data));
51+
}
52+
}
53+
54+
interface PtyOptions {
55+
session?: string;
56+
command?: string;
57+
wait?: boolean;
58+
timeout?: string;
59+
pollInterval?: string;
60+
output?: string;
61+
}
62+
63+
export async function ptyDevbox(devboxId: string, options: PtyOptions = {}) {
64+
try {
65+
if (options.wait !== false) {
66+
cliStatus(`Waiting for devbox ${devboxId} to be ready...`);
67+
const isReady = await waitForReady(
68+
devboxId,
69+
parseInt(options.timeout || "180"),
70+
parseInt(options.pollInterval || "3"),
71+
);
72+
if (!isReady) {
73+
outputError(`Devbox ${devboxId} is not ready. Please try again later.`);
74+
}
75+
}
76+
77+
let baseUrl: string;
78+
let authToken: string | undefined;
79+
80+
if (isLocalPtyOverride()) {
81+
baseUrl = getPtyBaseUrl();
82+
} else {
83+
cliStatus(`Creating PTY tunnel for ${devboxId}...`);
84+
const tunnel = await createPtyTunnel(devboxId);
85+
await settleAfterPtyTunnel();
86+
baseUrl = getPtyTunnelBaseUrl(tunnel.tunnel_key);
87+
authToken = tunnel.auth_token;
88+
}
89+
90+
const sessionName = options.session?.trim() || devboxId;
91+
92+
if (options.command) {
93+
await execCommand(baseUrl, sessionName, options.command, authToken);
94+
} else {
95+
await interactiveSession(baseUrl, sessionName, authToken);
96+
}
97+
} catch (error) {
98+
outputError("Failed to start PTY session", error);
99+
}
100+
}
101+
102+
const PTY_EXEC_TIMEOUT_MS = (() => {
103+
const v = parseInt(process.env.RUNLOOP_PTY_EXEC_TIMEOUT_MS || "0", 10);
104+
return isNaN(v) || v < 0 ? 0 : v;
105+
})();
106+
107+
async function execCommand(
108+
baseUrl: string,
109+
sessionName: string,
110+
command: string,
111+
authToken?: string,
112+
): Promise<void> {
113+
const wsUrl = await resolvePtyWebSocketUrl(baseUrl, sessionName, {
114+
cols: 80,
115+
rows: 24,
116+
authToken,
117+
});
118+
const ws = await openPtyWebSocket(wsUrl, authToken);
119+
120+
const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken);
121+
const disposeInterruptSignals = registerPtyInterruptHandlers(ws, releaseOnce);
122+
123+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
124+
125+
const completion = new Promise<void>((resolve, reject) => {
126+
const finish = () => {
127+
if (timeoutId !== undefined) clearTimeout(timeoutId);
128+
releaseOnce();
129+
disposeInterruptSignals();
130+
};
131+
132+
// Attach listeners synchronously *before* any await — otherwise messages
133+
// emitted between ws open and listener registration are dropped.
134+
ws.on("message", (data: WebSocket.RawData) => {
135+
writePtyStreamToStdout(data);
136+
});
137+
138+
ws.on("close", () => {
139+
finish();
140+
resolve();
141+
});
142+
143+
ws.on("error", (err: Error) => {
144+
finish();
145+
reject(err);
146+
});
147+
148+
if (PTY_EXEC_TIMEOUT_MS > 0) {
149+
timeoutId = setTimeout(() => {
150+
releaseOnce();
151+
ws.close();
152+
}, PTY_EXEC_TIMEOUT_MS);
153+
}
154+
});
155+
156+
await refreshPtySessionAfterAttach(
157+
ws,
158+
baseUrl,
159+
sessionName,
160+
80,
161+
24,
162+
authToken,
163+
);
164+
// Send the command followed by `exit` so the shell terminates and the
165+
// server closes the WebSocket. Without this, the session would stay open
166+
// after the command finished and the CLI would hang.
167+
ws.send(command + "\nexit\n");
168+
169+
return completion;
170+
}
171+
172+
async function interactiveSession(
173+
baseUrl: string,
174+
sessionName: string,
175+
authToken?: string,
176+
): Promise<void> {
177+
const cols = process.stdout.columns || 80;
178+
const rows = process.stdout.rows || 24;
179+
180+
const wsUrl = await resolvePtyWebSocketUrl(baseUrl, sessionName, {
181+
cols,
182+
rows,
183+
authToken,
184+
});
185+
const ws = await openPtyWebSocket(wsUrl, authToken);
186+
187+
// Attach IO listeners before the refresh round-trip so server output
188+
// emitted during the ptyControl HTTP call is not dropped.
189+
const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken);
190+
const { dispose, done } = startPtyIoSession(
191+
ws,
192+
baseUrl,
193+
sessionName,
194+
authToken,
195+
);
196+
const disposeSignals = registerPtyInterruptHandlers(ws, releaseOnce);
197+
198+
await refreshPtySessionAfterAttach(
199+
ws,
200+
baseUrl,
201+
sessionName,
202+
cols,
203+
rows,
204+
authToken,
205+
);
206+
207+
try {
208+
await done;
209+
releaseOnce();
210+
} catch (err) {
211+
releaseOnce();
212+
throw err;
213+
} finally {
214+
dispose();
215+
disposeSignals();
216+
}
217+
}

src/commands/object/upload.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,13 @@ async function readStdinBuffer(): Promise<Buffer> {
214214
export async function uploadObject(options: UploadObjectOptions) {
215215
try {
216216
const client = getClient();
217-
const { paths, name, contentType, output: outputFormat } = options;
217+
const {
218+
paths: rawPaths,
219+
name,
220+
contentType,
221+
output: outputFormat,
222+
} = options;
223+
const paths = [...rawPaths];
218224

219225
if (paths.length === 0) {
220226
if (!processUtils.stdin.isTTY) {

0 commit comments

Comments
 (0)