the universal sandbox runtime for agents and humans.
pocketenv.io
sandbox
openclaw
agent
claude-code
vercel-sandbox
deno-sandbox
cloudflare-sandbox
atproto
sprites
daytona
1import WebSocket from "ws";
2import chalk from "chalk";
3import consola from "consola";
4import getAccessToken from "../../lib/getAccessToken";
5import { env } from "../../lib/env";
6import type { Sandbox } from "../../types/sandbox";
7import axios from "axios";
8
9// ── Protocol ──────────────────────────────────────────────────────────────────
10//
11// Step 1: POST /ssh/connect → { sessionId }
12//
13// Step 2: WS at /ssh/:sessionId/ws
14// Server → Client:
15// - Text frame base64-encoded PTY output
16// - Close SSH session ended
17//
18// Client → Server:
19// - Text frame raw keystroke bytes (UTF-8)
20// - Text frame JSON { type: "resize", cols: number, rows: number }
21
22function toWsUrl(httpUrl: string, path: string, token: string): string {
23 const base = httpUrl.replace(/^http(s?)/, (_, s) => `ws${s}`);
24 const url = new URL(`${base}${path}`);
25 url.searchParams.set("token", token);
26 return url.toString();
27}
28
29async function terminal(sandbox: Sandbox): Promise<void> {
30 const token = await getAccessToken();
31 const authToken = env.POCKETENV_TOKEN || token;
32 const apiUrl = env.POCKETENV_API_URL;
33
34 let cols = process.stdout.columns ?? 220;
35 let rows = process.stdout.rows ?? 50;
36
37 consola.info(`Connecting to ${chalk.cyanBright(sandbox.name)} via SSH…`);
38
39 process.stdout.write(`\x1b[35mConnecting to SSH session...\x1b[0m\r\n`);
40
41 // Step 1: create the SSH session
42 let sessionId: string;
43 try {
44 const res = await axios.post<{ sessionId: string }>(
45 `${apiUrl}/ssh/connect`,
46 { cols, rows },
47 {
48 headers: {
49 "Content-Type": "application/json",
50 "X-Sandbox-Id": sandbox.id,
51 Authorization: `Bearer ${authToken}`,
52 },
53 },
54 );
55 sessionId = res.data.sessionId;
56 } catch (err: unknown) {
57 process.stdout.write("\r\x1b[K");
58 const message =
59 axios.isAxiosError(err) && err.response?.data
60 ? (err.response.data as { message?: string; error?: string }).message ??
61 (err.response.data as { message?: string; error?: string }).error ??
62 String(err)
63 : String(err);
64 process.stderr.write(
65 `\x1b[38;5;203mSSH connection failed: ${message}\x1b[0m\r\n`,
66 );
67 process.exit(1);
68 }
69
70 // Step 2: open WebSocket to /ssh/:sessionId/ws
71 const wsUrl = toWsUrl(apiUrl, `/ssh/${sessionId}/ws`, authToken);
72 const ws = new WebSocket(wsUrl, {
73 headers: { "User-Agent": "pocketenv-cli" },
74 });
75
76 let exiting = false;
77 let stdinAttached = false;
78
79 function sendResize(c: number, r: number): void {
80 if (ws.readyState === WebSocket.OPEN) {
81 ws.send(JSON.stringify({ type: "resize", cols: c, rows: r }));
82 }
83 }
84
85 function teardown(code = 0): void {
86 if (exiting) return;
87 exiting = true;
88
89 if (process.stdin.isTTY) {
90 try {
91 process.stdin.setRawMode(false);
92 } catch {
93 // already restored
94 }
95 }
96 process.stdin.pause();
97
98 if (
99 ws.readyState === WebSocket.OPEN ||
100 ws.readyState === WebSocket.CONNECTING
101 ) {
102 ws.close(1000, "client disconnect");
103 }
104
105 axios
106 .delete(`${apiUrl}/ssh/disconnect/${sessionId}`, {
107 headers: { Authorization: `Bearer ${authToken}` },
108 })
109 .catch(() => {});
110
111 process.exit(code);
112 }
113
114 function attachStdin(): void {
115 if (stdinAttached) return;
116 stdinAttached = true;
117
118 if (process.stdin.isTTY) {
119 process.stdin.setRawMode(true);
120 }
121 process.stdin.resume();
122
123 process.stdin.on("data", (chunk: Buffer) => {
124 if (chunk.includes(0x0b)) {
125 // Ctrl+K — local escape hatch
126 teardown(0);
127 return;
128 }
129 if (ws.readyState === WebSocket.OPEN) {
130 ws.send(chunk.toString("utf-8"));
131 }
132 });
133
134 process.stdout.on("resize", () => {
135 cols = process.stdout.columns ?? cols;
136 rows = process.stdout.rows ?? rows;
137 sendResize(cols, rows);
138 });
139 }
140
141 ws.on("open", () => {
142 process.stdout.write("\r\x1b[K");
143 sendResize(cols, rows);
144 attachStdin();
145 });
146
147 ws.on("message", (raw: WebSocket.RawData, isBinary: boolean) => {
148 if (isBinary) {
149 process.stdout.write(raw as Buffer);
150 return;
151 }
152 // base64-encoded SSH output
153 try {
154 const bytes = Buffer.from(raw.toString(), "base64");
155 process.stdout.write(bytes);
156 } catch {
157 process.stdout.write(raw.toString());
158 }
159 });
160
161 ws.on("close", (code, reason) => {
162 if (!exiting) {
163 process.stderr.write(`\r\n${chalk.dim("SSH session closed.")}\r\n`);
164 if (reason.length) {
165 process.stderr.write(
166 `${chalk.yellow("Connection closed")} (${code} – ${reason})\r\n`,
167 );
168 }
169 teardown(0);
170 }
171 });
172
173 ws.on("error", (err: Error) => {
174 consola.error("WebSocket error:", err.message);
175 teardown(1);
176 });
177
178 process.on("SIGINT", () => teardown(0));
179 process.on("SIGTERM", () => teardown(0));
180
181 await new Promise<void>((resolve) => {
182 ws.on("close", resolve);
183 ws.on("error", () => resolve());
184 });
185}
186
187export default terminal;