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.

Split SSH command into provider modules

+307 -263
-263
apps/cli/src/cmd/ssh.ts
··· 1 - import WebSocket from "ws"; 2 - import * as pty from "node-pty"; 3 - import chalk from "chalk"; 4 - import consola from "consola"; 5 - import getAccessToken from "../lib/getAccessToken"; 6 - import { client } from "../client"; 7 - import { env } from "../lib/env"; 8 - import type { Sandbox } from "../types/sandbox"; 9 - import type { Profile } from "../types/profile"; 10 - import type { RawData } from "ws"; 11 - 12 - type ServerMessage = 13 - | { type: "connected"; sessionId: string } 14 - | { type: "output"; data: string } 15 - | { type: "error"; message: string }; 16 - 17 - type ClientMessage = 18 - | { type: "input"; data: string } 19 - | { type: "resize"; cols: number; rows: number }; 20 - 21 - function send(ws: WebSocket, msg: ClientMessage) { 22 - if (ws.readyState === WebSocket.OPEN) { 23 - ws.send(JSON.stringify(msg)); 24 - } 25 - } 26 - 27 - /** 28 - * Resolve the Cloudflare worker URL for a given baseSandbox (template) name. 29 - * 30 - * The web app constructs the URL with: 31 - * CF_URL.replace("sbx", worker).replace("claude-code", "claudecode") 32 - * 33 - * The production CF_URL is "https://sbx.pocketenv.io", so for a worker named 34 - * "claude-code" the final URL becomes "https://claudecode.pocketenv.io". 35 - * We replicate that logic here. 36 - */ 37 - function resolveWorkerUrl(baseSandbox: string, cfUrl: string): string { 38 - return cfUrl.replace("sbx", baseSandbox).replace("claude-code", "claudecode"); 39 - } 40 - 41 - async function ssh(sandboxName: string | undefined) { 42 - const token = await getAccessToken(); 43 - const authToken = env.POCKETENV_TOKEN || token; 44 - 45 - let sandbox: Sandbox; 46 - 47 - if (!sandboxName) { 48 - const profile = await client.get<Profile>( 49 - "/xrpc/io.pocketenv.actor.getProfile", 50 - { headers: { Authorization: `Bearer ${authToken}` } }, 51 - ); 52 - 53 - const response = await client.get<{ sandboxes: Sandbox[] }>( 54 - "/xrpc/io.pocketenv.actor.getActorSandboxes", 55 - { 56 - params: { did: profile.data.did, offset: 0, limit: 100 }, 57 - headers: { Authorization: `Bearer ${authToken}` }, 58 - }, 59 - ); 60 - 61 - const runningSandboxes = response.data.sandboxes.filter( 62 - (s) => s.status === "RUNNING" && s.provider === "cloudflare", 63 - ); 64 - 65 - if (runningSandboxes.length === 0) { 66 - consola.error( 67 - `No running Cloudflare sandboxes found. ` + 68 - `Start one with ${chalk.greenBright("pocketenv start <sandbox>")} first.`, 69 - ); 70 - process.exit(1); 71 - } 72 - 73 - sandbox = runningSandboxes[0] as Sandbox; 74 - consola.info(`Connecting to sandbox ${chalk.greenBright(sandbox.name)}…`); 75 - } else { 76 - const response = await client.get<{ sandbox: Sandbox | null }>( 77 - "/xrpc/io.pocketenv.sandbox.getSandbox", 78 - { 79 - params: { id: sandboxName }, 80 - headers: { Authorization: `Bearer ${authToken}` }, 81 - }, 82 - ); 83 - 84 - if (!response.data.sandbox) { 85 - consola.error(`Sandbox ${chalk.yellowBright(sandboxName)} not found.`); 86 - process.exit(1); 87 - } 88 - 89 - sandbox = response.data.sandbox; 90 - } 91 - 92 - if (sandbox.provider !== "cloudflare") { 93 - consola.error( 94 - `Sandbox ${chalk.yellowBright(sandbox.name)} uses provider ` + 95 - `${chalk.cyan(sandbox.provider)}, but this command only supports ` + 96 - `${chalk.cyan("cloudflare")} sandboxes.`, 97 - ); 98 - process.exit(1); 99 - } 100 - 101 - if (sandbox.status !== "RUNNING") { 102 - consola.error( 103 - `Sandbox ${chalk.yellowBright(sandbox.name)} is not running. ` + 104 - `Start it with ${chalk.greenBright(`pocketenv start ${sandbox.name}`)}.`, 105 - ); 106 - process.exit(1); 107 - } 108 - 109 - const tokenResponse = await client.get<{ token?: string }>( 110 - "/xrpc/io.pocketenv.actor.getTerminalToken", 111 - { headers: { Authorization: `Bearer ${authToken}` } }, 112 - ); 113 - 114 - const terminalToken = tokenResponse.data.token; 115 - if (!terminalToken) { 116 - consola.error("Failed to obtain a terminal token."); 117 - process.exit(1); 118 - } 119 - 120 - const cfBaseUrl = env.POCKETENV_CF_URL; 121 - 122 - const workerUrl = resolveWorkerUrl(sandbox.baseSandbox, cfBaseUrl); 123 - 124 - const wsBase = workerUrl.replace(/^http/, "ws"); 125 - 126 - const wsUrl = new URL(`${wsBase}/v1/sandboxes/${sandbox.id}/ws/terminal`); 127 - wsUrl.searchParams.set("t", terminalToken); 128 - 129 - const cols = process.stdout.columns ?? 220; 130 - const rows = process.stdout.rows ?? 50; 131 - 132 - // We spawn a minimal shell just to own the PTY file descriptor for resizing 133 - // purposes. All real I/O is forwarded over the WebSocket – the local PTY is 134 - // only used so we can put stdin into raw mode and track SIGWINCH. 135 - const localPty = pty.spawn( 136 - process.platform === "win32" ? "cmd.exe" : "sh", 137 - [], 138 - { 139 - name: "xterm-256color", 140 - cols, 141 - rows, 142 - cwd: process.env.HOME ?? process.cwd(), 143 - env: process.env as Record<string, string>, 144 - }, 145 - ); 146 - 147 - consola.info( 148 - `Connecting to ${chalk.cyanBright(sandbox.name)} via Cloudflare sandbox…`, 149 - ); 150 - 151 - const ws = new WebSocket(wsUrl.toString(), { 152 - headers: { "User-Agent": "pocketenv-cli" }, 153 - }); 154 - 155 - let exiting = false; 156 - 157 - function teardown(code = 0) { 158 - if (exiting) return; 159 - exiting = true; 160 - 161 - // Restore terminal state. 162 - if (process.stdin.isTTY && process.stdin.setRawMode) { 163 - try { 164 - process.stdin.setRawMode(false); 165 - } catch { 166 - // ignore 167 - } 168 - } 169 - process.stdin.resume(); 170 - 171 - try { 172 - localPty.kill(); 173 - } catch { 174 - // already dead 175 - } 176 - 177 - if ( 178 - ws.readyState === WebSocket.OPEN || 179 - ws.readyState === WebSocket.CONNECTING 180 - ) { 181 - ws.close(1000, "client disconnect"); 182 - } 183 - 184 - process.exit(code); 185 - } 186 - 187 - ws.on("open", () => { 188 - send(ws, { type: "resize", cols, rows }); 189 - }); 190 - 191 - ws.on("message", (raw: RawData) => { 192 - let msg: ServerMessage; 193 - try { 194 - msg = JSON.parse(raw.toString()) as ServerMessage; 195 - } catch { 196 - return; 197 - } 198 - 199 - switch (msg.type) { 200 - case "connected": 201 - if (process.stdin.isTTY && process.stdin.setRawMode) { 202 - process.stdin.setRawMode(true); 203 - } 204 - process.stdin.resume(); 205 - 206 - process.stdin.on("data", (chunk: Buffer) => { 207 - const data = chunk.toString("binary"); 208 - // Ctrl-C / Ctrl-D / Ctrl-Z are forwarded as-is; the remote shell 209 - // handles them. We only intercept nothing here. 210 - send(ws, { type: "input", data }); 211 - }); 212 - 213 - process.on("SIGWINCH", () => { 214 - const newCols = process.stdout.columns ?? cols; 215 - const newRows = process.stdout.rows ?? rows; 216 - try { 217 - localPty.resize(newCols, newRows); 218 - } catch { 219 - // ignore 220 - } 221 - send(ws, { type: "resize", cols: newCols, rows: newRows }); 222 - }); 223 - break; 224 - 225 - case "output": 226 - // Remote PTY output → local stdout 227 - process.stdout.write(msg.data); 228 - break; 229 - 230 - case "error": 231 - process.stderr.write( 232 - `\r\n${chalk.red("Terminal error:")} ${msg.message}\r\n`, 233 - ); 234 - teardown(1); 235 - break; 236 - } 237 - }); 238 - 239 - ws.on("close", (code, reason) => { 240 - if (!exiting) { 241 - process.stderr.write( 242 - `\r\n${chalk.yellow("Connection closed")} (${code}${reason.length ? ` – ${reason}` : ""})\r\n`, 243 - ); 244 - teardown(0); 245 - } 246 - }); 247 - 248 - ws.on("error", (err: Error) => { 249 - consola.error("WebSocket error:", err.message); 250 - teardown(1); 251 - }); 252 - 253 - process.on("SIGINT", () => teardown(0)); 254 - process.on("SIGTERM", () => teardown(0)); 255 - 256 - // Prevent node from exiting while the WebSocket is open. 257 - await new Promise<void>((resolve) => { 258 - ws.on("close", resolve); 259 - ws.on("error", () => resolve()); 260 - }); 261 - } 262 - 263 - export default ssh;
+213
apps/cli/src/cmd/ssh/cloudflare.ts
··· 1 + import WebSocket from "ws"; 2 + import chalk from "chalk"; 3 + import consola from "consola"; 4 + import getAccessToken from "../../lib/getAccessToken"; 5 + import { client } from "../../client"; 6 + import { env } from "../../lib/env"; 7 + import type { Sandbox } from "../../types/sandbox"; 8 + import type { Profile } from "../../types/profile"; 9 + 10 + // ── Protocol (mirrors @cloudflare/sandbox xterm addon) ─────────────────────── 11 + // 12 + // Server → Client: 13 + // - Binary frame raw PTY output (UTF-8 bytes), write directly to stdout 14 + // - Text frame JSON control message: 15 + // { type: "ready" } session is live 16 + // { type: "error", message: string } terminal error 17 + // { type: "exit", code: number } remote shell exited 18 + // 19 + // Client → Server: 20 + // - Binary frame raw keystroke bytes (UTF-8) — same as TextEncoder output 21 + // - Text frame { type: "resize", cols: number, rows: number } 22 + 23 + type ControlMessage = 24 + | { type: "ready" } 25 + | { type: "error"; message: string } 26 + | { type: "exit"; code: number; signal?: string }; 27 + 28 + function sendInput(ws: WebSocket, data: Buffer): void { 29 + if (ws.readyState === WebSocket.OPEN) { 30 + ws.send(data); 31 + } 32 + } 33 + 34 + function sendResize(ws: WebSocket, cols: number, rows: number): void { 35 + if (ws.readyState === WebSocket.OPEN) { 36 + ws.send(JSON.stringify({ type: "resize", cols, rows })); 37 + } 38 + } 39 + 40 + /** 41 + * Resolve the Cloudflare worker URL for a given baseSandbox (template) name. 42 + * 43 + * The web app constructs the URL with: 44 + * CF_URL.replace("sbx", worker).replace("claude-code", "claudecode") 45 + * 46 + * The production CF_URL is "https://sbx.pocketenv.io", so for a worker named 47 + * "claude-code" the final URL becomes "https://claudecode.pocketenv.io". 48 + */ 49 + function resolveWorkerUrl(baseSandbox: string, cfUrl: string): string { 50 + return cfUrl.replace("sbx", baseSandbox).replace("claude-code", "claudecode"); 51 + } 52 + 53 + async function ssh(sandbox: Sandbox) { 54 + const token = await getAccessToken(); 55 + const authToken = env.POCKETENV_TOKEN || token; 56 + 57 + const tokenResponse = await client.get<{ token?: string }>( 58 + "/xrpc/io.pocketenv.actor.getTerminalToken", 59 + { headers: { Authorization: `Bearer ${authToken}` } }, 60 + ); 61 + 62 + const terminalToken = tokenResponse.data.token; 63 + if (!terminalToken) { 64 + consola.error("Failed to obtain a terminal token."); 65 + process.exit(1); 66 + } 67 + 68 + const cfBaseUrl = env.POCKETENV_CF_URL; 69 + const workerUrl = resolveWorkerUrl(sandbox.baseSandbox, cfBaseUrl); 70 + 71 + // Convert http(s) → ws(s) 72 + const wsBase = workerUrl.replace(/^http/, "ws"); 73 + const wsUrl = new URL(`${wsBase}/v1/sandboxes/${sandbox.id}/ws/terminal`); 74 + wsUrl.searchParams.set("t", terminalToken); 75 + wsUrl.searchParams.set("session", crypto.randomUUID()); 76 + 77 + let cols = process.stdout.columns ?? 220; 78 + let rows = process.stdout.rows ?? 50; 79 + 80 + consola.info( 81 + `Connecting to ${chalk.cyanBright(sandbox.name)} via Cloudflare sandbox…`, 82 + ); 83 + 84 + // Use default binaryType ("nodebuffer") so binary frames arrive as Buffer, 85 + // which is what isBinary:true + Buffer.isBuffer() correctly identifies. 86 + const ws = new WebSocket(wsUrl.toString(), { 87 + headers: { "User-Agent": "pocketenv-cli" }, 88 + }); 89 + 90 + let exiting = false; 91 + let stdinAttached = false; 92 + 93 + function teardown(code = 0): void { 94 + if (exiting) return; 95 + exiting = true; 96 + 97 + if (process.stdin.isTTY) { 98 + try { 99 + process.stdin.setRawMode(false); 100 + } catch { 101 + // ignore – may already be restored 102 + } 103 + } 104 + process.stdin.pause(); 105 + 106 + if ( 107 + ws.readyState === WebSocket.OPEN || 108 + ws.readyState === WebSocket.CONNECTING 109 + ) { 110 + ws.close(1000, "client disconnect"); 111 + } 112 + 113 + process.exit(code); 114 + } 115 + 116 + ws.on("open", () => { 117 + // Nothing to do on open — wait for "ready" before sending resize or input. 118 + // (Matches the xterm addon behaviour: onSocketOpen only registers listeners, 119 + // sendResize is called from handleControlMessage("ready").) 120 + }); 121 + 122 + ws.on("message", (raw: WebSocket.RawData, isBinary: boolean) => { 123 + if (isBinary) { 124 + // Raw PTY output — write the bytes directly to stdout unchanged. 125 + // raw is a Buffer (default nodebuffer binaryType). 126 + process.stdout.write(raw as Buffer); 127 + return; 128 + } 129 + 130 + // Text frame → JSON control message. 131 + let msg: ControlMessage; 132 + try { 133 + msg = JSON.parse(raw.toString()) as ControlMessage; 134 + } catch { 135 + return; 136 + } 137 + 138 + switch (msg.type) { 139 + case "ready": { 140 + // ── Session is live ────────────────────────────────────────────── 141 + // 1. Send current terminal dimensions now that the PTY is ready. 142 + sendResize(ws, cols, rows); 143 + 144 + if (stdinAttached) break; 145 + stdinAttached = true; 146 + 147 + // 2. Switch stdin to raw mode — every keystroke is forwarded 148 + // immediately, no local echo or line-buffering. 149 + if (process.stdin.isTTY) { 150 + process.stdin.setRawMode(true); 151 + } 152 + // Keep stdin flowing as a raw binary stream. Using no encoding 153 + // means data events fire with Buffer objects, which we send 154 + // directly as binary WebSocket frames — no encoding round-trip. 155 + process.stdin.resume(); 156 + 157 + // stdin → WebSocket (binary frame, UTF-8 bytes) 158 + process.stdin.on("data", (chunk: Buffer) => { 159 + sendInput(ws, chunk); 160 + }); 161 + 162 + // Terminal resize → notify the remote PTY. 163 + process.stdout.on("resize", () => { 164 + cols = process.stdout.columns ?? cols; 165 + rows = process.stdout.rows ?? rows; 166 + sendResize(ws, cols, rows); 167 + }); 168 + 169 + break; 170 + } 171 + 172 + case "error": 173 + process.stderr.write( 174 + `\r\n${chalk.red("Terminal error:")} ${msg.message}\r\n`, 175 + ); 176 + teardown(1); 177 + break; 178 + 179 + case "exit": 180 + process.stderr.write( 181 + `\r\n${chalk.dim( 182 + `Session exited with code ${msg.code}${msg.signal ? ` (${msg.signal})` : ""}`, 183 + )}\r\n`, 184 + ); 185 + teardown(msg.code ?? 0); 186 + break; 187 + } 188 + }); 189 + 190 + ws.on("close", (code, reason) => { 191 + if (!exiting) { 192 + process.stderr.write( 193 + `\r\n${chalk.yellow("Connection closed")} (${code}${reason.length ? ` – ${reason}` : ""})\r\n`, 194 + ); 195 + teardown(0); 196 + } 197 + }); 198 + 199 + ws.on("error", (err: Error) => { 200 + consola.error("WebSocket error:", err.message); 201 + teardown(1); 202 + }); 203 + 204 + process.on("SIGINT", () => teardown(0)); 205 + process.on("SIGTERM", () => teardown(0)); 206 + 207 + await new Promise<void>((resolve) => { 208 + ws.on("close", resolve); 209 + ws.on("error", () => resolve()); 210 + }); 211 + } 212 + 213 + export default ssh;
+94
apps/cli/src/cmd/ssh/index.ts
··· 1 + import chalk from "chalk"; 2 + import consola from "consola"; 3 + import getAccessToken from "../../lib/getAccessToken"; 4 + import { client } from "../../client"; 5 + import { env } from "../../lib/env"; 6 + import type { Sandbox } from "../../types/sandbox"; 7 + import type { Profile } from "../../types/profile"; 8 + import cloudflare from "./cloudflare"; 9 + 10 + async function ssh(sandboxName: string | undefined) { 11 + const token = await getAccessToken(); 12 + const authToken = env.POCKETENV_TOKEN || token; 13 + 14 + let sandbox: Sandbox; 15 + 16 + if (!sandboxName) { 17 + // No name provided – list the user's sandboxes and pick the first running one. 18 + const profile = await client.get<Profile>( 19 + "/xrpc/io.pocketenv.actor.getProfile", 20 + { headers: { Authorization: `Bearer ${authToken}` } }, 21 + ); 22 + 23 + const response = await client.get<{ sandboxes: Sandbox[] }>( 24 + "/xrpc/io.pocketenv.actor.getActorSandboxes", 25 + { 26 + params: { did: profile.data.did, offset: 0, limit: 100 }, 27 + headers: { Authorization: `Bearer ${authToken}` }, 28 + }, 29 + ); 30 + 31 + const runningSandboxes = response.data.sandboxes.filter( 32 + (s) => s.status === "RUNNING", 33 + ); 34 + 35 + if (runningSandboxes.length === 0) { 36 + consola.error( 37 + `No running sandboxes found. ` + 38 + `Start one with ${chalk.greenBright("pocketenv start <sandbox>")} first.`, 39 + ); 40 + process.exit(1); 41 + } 42 + 43 + sandbox = runningSandboxes[0] as Sandbox; 44 + consola.info(`Connecting to sandbox ${chalk.greenBright(sandbox.name)}…`); 45 + } else { 46 + // Look up the named sandbox. 47 + const response = await client.get<{ sandbox: Sandbox | null }>( 48 + "/xrpc/io.pocketenv.sandbox.getSandbox", 49 + { 50 + params: { id: sandboxName }, 51 + headers: { Authorization: `Bearer ${authToken}` }, 52 + }, 53 + ); 54 + 55 + if (!response.data.sandbox) { 56 + consola.error(`Sandbox ${chalk.yellowBright(sandboxName)} not found.`); 57 + process.exit(1); 58 + } 59 + 60 + sandbox = response.data.sandbox; 61 + } 62 + 63 + if (sandbox.status !== "RUNNING") { 64 + consola.error( 65 + `Sandbox ${chalk.yellowBright(sandbox.name)} is not running. ` + 66 + `Start it with ${chalk.greenBright(`pocketenv start ${sandbox.name}`)}.`, 67 + ); 68 + process.exit(1); 69 + } 70 + 71 + // export type Provider = "daytona" | "deno" | "cloudflare" | "vercel" | "sprites"; 72 + switch (sandbox.provider) { 73 + case "cloudflare": 74 + await cloudflare(sandbox); 75 + break; 76 + case "daytona": 77 + break; 78 + case "deno": 79 + break; 80 + case "vercel": 81 + break; 82 + case "sprites": 83 + break; 84 + default: 85 + consola.error( 86 + `Sandbox ${chalk.yellowBright(sandbox.name)} uses provider ` + 87 + `${chalk.cyan(sandbox.provider)}, but this command only supports ` + 88 + `${chalk.cyan("cloudflare")}, ${chalk.cyan("daytona")}, ${chalk.cyan("deno")}, ${chalk.cyan("vercel")}, or ${chalk.cyan("sprites")} sandboxes.`, 89 + ); 90 + process.exit(1); 91 + } 92 + } 93 + 94 + export default ssh;
apps/cli/src/cmd/ssh/terminal.ts

This is a binary file and will not be displayed.

apps/cli/src/cmd/ssh/tty.ts

This is a binary file and will not be displayed.