the universal sandbox runtime for agents and humans. pocketenv.io
sandbox openclaw agent claude-code vercel-sandbox deno-sandbox cloudflare-sandbox atproto sprites daytona
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add terminal SSH client and wire into ssh command

+232
+4
apps/cli/src/cmd/ssh/index.ts
··· 7 7 import type { Profile } from "../../types/profile"; 8 8 import cloudflare from "./cloudflare"; 9 9 import tty from "./tty"; 10 + import terminal from "./terminal"; 10 11 11 12 async function ssh(sandboxName: string | undefined) { 12 13 const token = await getAccessToken(); ··· 75 76 await cloudflare(sandbox); 76 77 break; 77 78 case "daytona": 79 + await terminal(sandbox); 78 80 break; 79 81 case "deno": 82 + await terminal(sandbox); 80 83 break; 81 84 case "vercel": 85 + // await terminal(sandbox); 82 86 break; 83 87 case "sprites": 84 88 await tty(sandbox);
+228
apps/cli/src/cmd/ssh/terminal.ts
··· 1 + import chalk from "chalk"; 2 + import consola from "consola"; 3 + import getAccessToken from "../../lib/getAccessToken"; 4 + import { env } from "../../lib/env"; 5 + import type { Sandbox } from "../../types/sandbox"; 6 + import { EventSource } from "eventsource"; 7 + import type { ErrorEvent } from "eventsource"; 8 + import axios from "axios"; 9 + 10 + async function sendInput( 11 + apiUrl: string, 12 + sessionId: string, 13 + data: string | Buffer, 14 + token: string, 15 + ): Promise<void> { 16 + try { 17 + await axios.post( 18 + `${apiUrl}/ssh/input/${sessionId}`, 19 + { data: data instanceof Buffer ? data.toString("utf-8") : data }, 20 + { 21 + headers: { 22 + "Content-Type": "application/json", 23 + Authorization: `Bearer ${token}`, 24 + }, 25 + }, 26 + ); 27 + } catch { 28 + // session may have closed — swallow silently 29 + } 30 + } 31 + 32 + async function sendResize( 33 + apiUrl: string, 34 + sessionId: string, 35 + cols: number, 36 + rows: number, 37 + token: string, 38 + ): Promise<void> { 39 + try { 40 + await axios.post( 41 + `${apiUrl}/ssh/resize/${sessionId}`, 42 + { cols, rows }, 43 + { 44 + headers: { 45 + "Content-Type": "application/json", 46 + Authorization: `Bearer ${token}`, 47 + }, 48 + }, 49 + ); 50 + } catch { 51 + // ignore transient resize errors 52 + } 53 + } 54 + 55 + function makeAuthFetch( 56 + token: string, 57 + ): (url: string | URL, init: RequestInit) => Promise<Response> { 58 + return (url: string | URL, init: RequestInit): Promise<Response> => { 59 + const headers = new Headers((init.headers as Record<string, string>) ?? {}); 60 + headers.set("Authorization", `Bearer ${token}`); 61 + return fetch(url, { ...init, headers }); 62 + }; 63 + } 64 + 65 + async function terminal(sandbox: Sandbox): Promise<void> { 66 + const token = await getAccessToken(); 67 + const authToken = env.POCKETENV_TOKEN || token; 68 + const apiUrl = env.POCKETENV_API_URL; 69 + 70 + let cols = process.stdout.columns ?? 220; 71 + let rows = process.stdout.rows ?? 50; 72 + 73 + consola.info( 74 + `Connecting to ${chalk.cyanBright(sandbox.name)} via SSH…`, 75 + ); 76 + 77 + let exiting = false; 78 + let es: EventSource | null = null; 79 + let sessionId: string | null = null; 80 + let stdinAttached = false; 81 + 82 + function teardown(code = 0): void { 83 + if (exiting) return; 84 + exiting = true; 85 + 86 + if (process.stdin.isTTY) { 87 + try { 88 + process.stdin.setRawMode(false); 89 + } catch { 90 + // ignore 91 + } 92 + } 93 + process.stdin.pause(); 94 + 95 + if (es) { 96 + es.close(); 97 + es = null; 98 + } 99 + 100 + if (sessionId) { 101 + const sid = sessionId; 102 + sessionId = null; 103 + axios 104 + .delete(`${apiUrl}/ssh/disconnect/${sid}`, { 105 + headers: { Authorization: `Bearer ${authToken}` }, 106 + }) 107 + .catch(() => {}); 108 + } 109 + 110 + process.exit(code); 111 + } 112 + 113 + function attachStdin(sid: string): void { 114 + if (stdinAttached) return; 115 + stdinAttached = true; 116 + 117 + if (process.stdin.isTTY) { 118 + process.stdin.setRawMode(true); 119 + } 120 + process.stdin.resume(); 121 + 122 + process.stdin.on("data", (chunk: Buffer) => { 123 + if (chunk.includes(0x0b)) { 124 + // Ctrl+K — local escape hatch 125 + teardown(0); 126 + return; 127 + } 128 + sendInput(apiUrl, sid, chunk, authToken); 129 + }); 130 + 131 + process.stdout.on("resize", () => { 132 + cols = process.stdout.columns ?? cols; 133 + rows = process.stdout.rows ?? rows; 134 + sendResize(apiUrl, sid, cols, rows, authToken); 135 + }); 136 + } 137 + 138 + process.stdout.write(`\x1b[35mConnecting to SSH session...\x1b[0m\r\n`); 139 + 140 + // Step 1: POST /ssh/connect to obtain a sessionId 141 + let connectResponse: { sessionId: string }; 142 + try { 143 + const res = await axios.post<{ sessionId: string }>( 144 + `${apiUrl}/ssh/connect`, 145 + { cols, rows }, 146 + { 147 + headers: { 148 + "Content-Type": "application/json", 149 + "X-Sandbox-Id": sandbox.id, 150 + Authorization: `Bearer ${authToken}`, 151 + }, 152 + }, 153 + ); 154 + connectResponse = res.data; 155 + } catch (err: unknown) { 156 + process.stdout.write("\r\x1b[K"); 157 + const message = 158 + axios.isAxiosError(err) && err.response?.data 159 + ? (err.response.data as { message?: string; error?: string }) 160 + .message ?? 161 + (err.response.data as { message?: string; error?: string }).error ?? 162 + String(err) 163 + : String(err); 164 + process.stderr.write( 165 + `\x1b[38;5;203mSSH connection failed: ${message}\x1b[0m\r\n`, 166 + ); 167 + process.exit(1); 168 + } 169 + 170 + sessionId = connectResponse.sessionId; 171 + 172 + // Erase the "Connecting…" line 173 + process.stdout.write("\r\x1b[K"); 174 + 175 + // Step 2: open the SSE stream 176 + es = new EventSource(`${apiUrl}/ssh/stream/${sessionId}`, { 177 + fetch: makeAuthFetch(authToken), 178 + }); 179 + 180 + es.addEventListener("connected", () => { 181 + const sid = sessionId!; 182 + sendResize(apiUrl, sid, cols, rows, authToken).then(() => { 183 + attachStdin(sid); 184 + }); 185 + }); 186 + 187 + // Default `message` events carry base64-encoded PTY output 188 + es.onmessage = (event: MessageEvent) => { 189 + try { 190 + const bytes = Buffer.from(event.data as string, "base64"); 191 + process.stdout.write(bytes); 192 + } catch { 193 + process.stdout.write(event.data as string); 194 + } 195 + }; 196 + 197 + es.addEventListener("close", () => { 198 + process.stderr.write(`\r\n${chalk.dim("SSH session closed.")}\r\n`); 199 + teardown(0); 200 + }); 201 + 202 + es.onerror = (err: ErrorEvent) => { 203 + if (es && es.readyState === EventSource.CLOSED) { 204 + if (!err.message) { 205 + teardown(0); 206 + } else { 207 + process.stderr.write( 208 + `\r\n${chalk.red(`SSH connection lost (${err.message})`)}\r\n`, 209 + ); 210 + teardown(1); 211 + } 212 + } 213 + }; 214 + 215 + process.on("SIGINT", () => teardown(0)); 216 + process.on("SIGTERM", () => teardown(0)); 217 + 218 + await new Promise<void>((resolve) => { 219 + const poll = setInterval(() => { 220 + if (exiting) { 221 + clearInterval(poll); 222 + resolve(); 223 + } 224 + }, 200); 225 + }); 226 + } 227 + 228 + export default terminal;