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.

at main 187 lines 5.2 kB view raw
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;