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 212 lines 6.7 kB view raw
1import WebSocket from "ws"; 2import chalk from "chalk"; 3import consola from "consola"; 4import getAccessToken from "../../lib/getAccessToken"; 5import { client } from "../../client"; 6import { env } from "../../lib/env"; 7import type { Sandbox } from "../../types/sandbox"; 8 9// ── Protocol (mirrors @cloudflare/sandbox xterm addon) ─────────────────────── 10// 11// Server → Client: 12// - Binary frame raw PTY output (UTF-8 bytes), write directly to stdout 13// - Text frame JSON control message: 14// { type: "ready" } session is live 15// { type: "error", message: string } terminal error 16// { type: "exit", code: number } remote shell exited 17// 18// Client → Server: 19// - Binary frame raw keystroke bytes (UTF-8) — same as TextEncoder output 20// - Text frame { type: "resize", cols: number, rows: number } 21 22type ControlMessage = 23 | { type: "ready" } 24 | { type: "error"; message: string } 25 | { type: "exit"; code: number; signal?: string }; 26 27function sendInput(ws: WebSocket, data: Buffer): void { 28 if (ws.readyState === WebSocket.OPEN) { 29 ws.send(data); 30 } 31} 32 33function sendResize(ws: WebSocket, cols: number, rows: number): void { 34 if (ws.readyState === WebSocket.OPEN) { 35 ws.send(JSON.stringify({ type: "resize", cols, rows })); 36 } 37} 38 39/** 40 * Resolve the Cloudflare worker URL for a given baseSandbox (template) name. 41 * 42 * The web app constructs the URL with: 43 * CF_URL.replace("sbx", worker).replace("claude-code", "claudecode") 44 * 45 * The production CF_URL is "https://sbx.pocketenv.io", so for a worker named 46 * "claude-code" the final URL becomes "https://claudecode.pocketenv.io". 47 */ 48function resolveWorkerUrl(baseSandbox: string, cfUrl: string): string { 49 return cfUrl.replace("sbx", baseSandbox).replace("claude-code", "claudecode"); 50} 51 52async function ssh(sandbox: Sandbox) { 53 const token = await getAccessToken(); 54 const authToken = env.POCKETENV_TOKEN || token; 55 56 const tokenResponse = await client.get<{ token?: string }>( 57 "/xrpc/io.pocketenv.actor.getTerminalToken", 58 { headers: { Authorization: `Bearer ${authToken}` } }, 59 ); 60 61 const terminalToken = tokenResponse.data.token; 62 if (!terminalToken) { 63 consola.error("Failed to obtain a terminal token."); 64 process.exit(1); 65 } 66 67 const cfBaseUrl = env.POCKETENV_CF_URL; 68 const workerUrl = resolveWorkerUrl(sandbox.baseSandbox, cfBaseUrl); 69 70 // Convert http(s) → ws(s) 71 const wsBase = workerUrl.replace(/^http/, "ws"); 72 const wsUrl = new URL(`${wsBase}/v1/sandboxes/${sandbox.id}/ws/terminal`); 73 wsUrl.searchParams.set("t", terminalToken); 74 wsUrl.searchParams.set("session", crypto.randomUUID()); 75 76 let cols = process.stdout.columns ?? 220; 77 let rows = process.stdout.rows ?? 50; 78 79 consola.info( 80 `Connecting to ${chalk.cyanBright(sandbox.name)} via Cloudflare Sandbox…`, 81 ); 82 83 // Use default binaryType ("nodebuffer") so binary frames arrive as Buffer, 84 // which is what isBinary:true + Buffer.isBuffer() correctly identifies. 85 const ws = new WebSocket(wsUrl.toString(), { 86 headers: { "User-Agent": "pocketenv-cli" }, 87 }); 88 89 let exiting = false; 90 let stdinAttached = false; 91 92 function teardown(code = 0): void { 93 if (exiting) return; 94 exiting = true; 95 96 if (process.stdin.isTTY) { 97 try { 98 process.stdin.setRawMode(false); 99 } catch { 100 // ignore – may already be restored 101 } 102 } 103 process.stdin.pause(); 104 105 if ( 106 ws.readyState === WebSocket.OPEN || 107 ws.readyState === WebSocket.CONNECTING 108 ) { 109 ws.close(1000, "client disconnect"); 110 } 111 112 process.exit(code); 113 } 114 115 ws.on("open", () => { 116 // Nothing to do on open — wait for "ready" before sending resize or input. 117 // (Matches the xterm addon behaviour: onSocketOpen only registers listeners, 118 // sendResize is called from handleControlMessage("ready").) 119 }); 120 121 ws.on("message", (raw: WebSocket.RawData, isBinary: boolean) => { 122 if (isBinary) { 123 // Raw PTY output — write the bytes directly to stdout unchanged. 124 // raw is a Buffer (default nodebuffer binaryType). 125 process.stdout.write(raw as Buffer); 126 return; 127 } 128 129 // Text frame → JSON control message. 130 let msg: ControlMessage; 131 try { 132 msg = JSON.parse(raw.toString()) as ControlMessage; 133 } catch { 134 return; 135 } 136 137 switch (msg.type) { 138 case "ready": { 139 // ── Session is live ────────────────────────────────────────────── 140 // 1. Send current terminal dimensions now that the PTY is ready. 141 sendResize(ws, cols, rows); 142 143 if (stdinAttached) break; 144 stdinAttached = true; 145 146 // 2. Switch stdin to raw mode — every keystroke is forwarded 147 // immediately, no local echo or line-buffering. 148 if (process.stdin.isTTY) { 149 process.stdin.setRawMode(true); 150 } 151 // Keep stdin flowing as a raw binary stream. Using no encoding 152 // means data events fire with Buffer objects, which we send 153 // directly as binary WebSocket frames — no encoding round-trip. 154 process.stdin.resume(); 155 156 // stdin → WebSocket (binary frame, UTF-8 bytes) 157 process.stdin.on("data", (chunk: Buffer) => { 158 sendInput(ws, chunk); 159 }); 160 161 // Terminal resize → notify the remote PTY. 162 process.stdout.on("resize", () => { 163 cols = process.stdout.columns ?? cols; 164 rows = process.stdout.rows ?? rows; 165 sendResize(ws, cols, rows); 166 }); 167 168 break; 169 } 170 171 case "error": 172 process.stderr.write( 173 `\r\n${chalk.red("Terminal error:")} ${msg.message}\r\n`, 174 ); 175 teardown(1); 176 break; 177 178 case "exit": 179 process.stderr.write( 180 `\r\n${chalk.dim( 181 `Session exited with code ${msg.code}${msg.signal ? ` (${msg.signal})` : ""}`, 182 )}\r\n`, 183 ); 184 teardown(msg.code ?? 0); 185 break; 186 } 187 }); 188 189 ws.on("close", (code, reason) => { 190 if (!exiting) { 191 process.stderr.write( 192 `\r\n${chalk.yellow("Connection closed")} (${code}${reason.length ? `${reason}` : ""})\r\n`, 193 ); 194 teardown(0); 195 } 196 }); 197 198 ws.on("error", (err: Error) => { 199 consola.error("WebSocket error:", err.message); 200 teardown(1); 201 }); 202 203 process.on("SIGINT", () => teardown(0)); 204 process.on("SIGTERM", () => teardown(0)); 205 206 await new Promise<void>((resolve) => { 207 ws.on("close", resolve); 208 ws.on("error", () => resolve()); 209 }); 210} 211 212export default ssh;