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 1241cd4e999bb3f48dca4cab7bcff14fe6f5d6ad 333 lines 8.9 kB view raw
1import express, { Router } from "express"; 2import { Client } from "ssh2"; 3import { randomUUID } from "node:crypto"; 4import { consola } from "consola"; 5import jwt from "jsonwebtoken"; 6import { env } from "lib/env"; 7import generateJwt from "lib/generateJwt"; 8import { WebSocketServer, type WebSocket } from "ws"; 9import type { IncomingMessage } from "http"; 10 11interface SSHSession { 12 client: Client; 13 stream: NodeJS.ReadWriteStream | null; 14 sseRes: import("express").Response | null; 15 buffer: string[]; 16 wsClients: Set<WebSocket>; 17} 18 19const sessions = new Map<string, SSHSession>(); 20 21const router = Router(); 22 23router.use(express.json()); 24 25router.use((req, res, next) => { 26 req.sandboxId = req.headers["x-sandbox-id"] as string | undefined; 27 const authHeader = req.headers.authorization; 28 const bearer = authHeader?.split("Bearer ")[1]?.trim(); 29 if (bearer && bearer !== "null") { 30 try { 31 const credentials = jwt.verify(bearer, env.JWT_SECRET, { 32 ignoreExpiration: true, 33 }) as { did: string }; 34 35 req.did = credentials.did; 36 } catch (err) { 37 consola.error("Invalid JWT token:", err); 38 } 39 } 40 41 next(); 42}); 43 44/** 45 * POST /ssh/connect 46 * Creates a new SSH session and returns the sessionId. 47 * Optionally accepts { cols, rows } in the body. 48 */ 49router.post("/connect", async (req, res) => { 50 const sessionId = randomUUID(); 51 const cols = req.body?.cols || 80; 52 const rows = req.body?.rows || 24; 53 consola.log(req.did); 54 consola.log(req.sandboxId); 55 56 const ssh = await req.ctx 57 .sandbox() 58 .get(`/v1/sandboxes/${req.sandboxId}/ssh`, { 59 headers: { 60 ...(req.did && { 61 Authorization: `Bearer ${await generateJwt(req.did)}`, 62 }), 63 }, 64 }); 65 66 const client = new Client(); 67 68 const session: SSHSession = { 69 client, 70 stream: null, 71 sseRes: null, 72 buffer: [], 73 wsClients: new Set(), 74 }; 75 76 sessions.set(sessionId, session); 77 78 client.on("ready", () => { 79 consola.success(`SSH session ${sessionId} connected`); 80 81 client.shell({ cols, rows, term: "xterm-256color" }, (err, stream) => { 82 if (err) { 83 consola.error(`SSH shell error for session ${sessionId}:`, err); 84 sessions.delete(sessionId); 85 res.status(500).json({ error: "Failed to open shell" }); 86 return; 87 } 88 89 session.stream = stream; 90 91 stream.on("data", (data: Buffer) => { 92 const encoded = Buffer.from(data).toString("base64"); 93 if (session.sseRes && !session.sseRes.writableEnded) { 94 session.sseRes.write(`data: ${encoded}\n\n`); 95 } else { 96 session.buffer.push(encoded); 97 } 98 for (const ws of session.wsClients) { 99 if (ws.readyState === ws.OPEN) ws.send(encoded); 100 } 101 }); 102 103 stream.on("close", () => { 104 consola.info(`SSH stream closed for session ${sessionId}`); 105 if (session.sseRes && !session.sseRes.writableEnded) { 106 session.sseRes.write(`event: close\ndata: closed\n\n`); 107 session.sseRes.end(); 108 } 109 for (const ws of session.wsClients) { 110 if (ws.readyState === ws.OPEN) ws.close(1000, "closed"); 111 } 112 session.wsClients.clear(); 113 client.end(); 114 sessions.delete(sessionId); 115 }); 116 117 stream.stderr.on("data", (data: Buffer) => { 118 const encoded = Buffer.from(data).toString("base64"); 119 if (session.sseRes && !session.sseRes.writableEnded) { 120 session.sseRes.write(`data: ${encoded}\n\n`); 121 } else { 122 session.buffer.push(encoded); 123 } 124 for (const ws of session.wsClients) { 125 if (ws.readyState === ws.OPEN) ws.send(encoded); 126 } 127 }); 128 129 res.json({ sessionId }); 130 }); 131 }); 132 133 client.on("error", (err) => { 134 consola.error(`SSH connection error for session ${sessionId}:`, err); 135 if (session.sseRes && !session.sseRes.writableEnded) { 136 session.sseRes.write( 137 `event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`, 138 ); 139 session.sseRes.end(); 140 } 141 for (const ws of session.wsClients) { 142 if (ws.readyState === ws.OPEN) ws.close(1011, err.message); 143 } 144 session.wsClients.clear(); 145 sessions.delete(sessionId); 146 // Only respond if headers haven't been sent 147 if (!res.headersSent) { 148 res 149 .status(500) 150 .json({ error: "SSH connection failed", message: err.message }); 151 } 152 }); 153 154 client.connect({ 155 host: ssh.data?.hostname, 156 port: 22, 157 username: ssh.data?.username, 158 }); 159}); 160 161/** 162 * GET /ssh/stream/:sessionId 163 * SSE endpoint that streams SSH output to the client. 164 */ 165router.get("/stream/:sessionId", (req, res) => { 166 const { sessionId } = req.params; 167 const session = sessions.get(sessionId); 168 169 if (!session) { 170 res.status(404).json({ error: "Session not found" }); 171 return; 172 } 173 174 // Set SSE headers 175 res.setHeader("Content-Type", "text/event-stream"); 176 res.setHeader("Cache-Control", "no-cache"); 177 res.setHeader("Connection", "keep-alive"); 178 res.setHeader("X-Accel-Buffering", "no"); 179 res.flushHeaders(); 180 181 // Send initial connected event 182 res.write(`event: connected\ndata: ${sessionId}\n\n`); 183 184 session.sseRes = res; 185 186 // Flush buffered output that arrived before the SSE client connected 187 for (const encoded of session.buffer) { 188 res.write(`data: ${encoded}\n\n`); 189 } 190 session.buffer = []; 191 192 // Handle client disconnect 193 req.on("close", () => { 194 consola.info(`SSE client disconnected for session ${sessionId}`); 195 session.sseRes = null; 196 }); 197}); 198 199/** 200 * POST /ssh/input/:sessionId 201 * Sends keyboard input to the SSH session. 202 * Body: { data: string } 203 */ 204router.post("/input/:sessionId", (req, res) => { 205 const { sessionId } = req.params; 206 const session = sessions.get(sessionId); 207 208 if (!session || !session.stream) { 209 res.status(404).json({ error: "Session not found" }); 210 return; 211 } 212 213 const { data } = req.body; 214 if (data) { 215 session.stream.write(data); 216 } 217 218 res.json({ ok: true }); 219}); 220 221/** 222 * POST /ssh/resize/:sessionId 223 * Resizes the SSH terminal. 224 * Body: { cols: number, rows: number } 225 */ 226router.post("/resize/:sessionId", (req, res) => { 227 const { sessionId } = req.params; 228 const session = sessions.get(sessionId); 229 230 if (!session || !session.stream) { 231 res.status(404).json({ error: "Session not found" }); 232 return; 233 } 234 235 const { cols, rows } = req.body; 236 if (cols && rows) { 237 (session.stream as any).setWindow(rows, cols, 0, 0); 238 } 239 240 res.json({ ok: true }); 241}); 242 243/** 244 * DELETE /ssh/disconnect/:sessionId 245 * Disconnects the SSH session. 246 */ 247router.delete("/disconnect/:sessionId", (req, res) => { 248 const { sessionId } = req.params; 249 const session = sessions.get(sessionId); 250 251 if (!session) { 252 res.status(404).json({ error: "Session not found" }); 253 return; 254 } 255 256 if (session.stream) { 257 session.stream.end(); 258 } 259 session.client.end(); 260 261 if (session.sseRes && !session.sseRes.writableEnded) { 262 session.sseRes.end(); 263 } 264 265 sessions.delete(sessionId); 266 consola.info(`SSH session ${sessionId} disconnected`); 267 268 res.json({ ok: true }); 269}); 270 271export default router; 272 273export function attachWebSocket(base: string) { 274 const pathRegex = new RegExp(`^${base}/([^/]+)/ws$`); 275 const wss = new WebSocketServer({ noServer: true }); 276 277 wss.on( 278 "connection", 279 async (ws: WebSocket, req: IncomingMessage, sessionId: string) => { 280 const url = new URL(req.url ?? "", "http://localhost"); 281 const tokenParam = url.searchParams.get("token"); 282 const authHeader = req.headers.authorization; 283 const bearer = tokenParam ?? authHeader?.split("Bearer ")[1]?.trim(); 284 if (bearer && bearer !== "null") { 285 try { 286 jwt.verify(bearer, env.JWT_SECRET, { ignoreExpiration: true }); 287 } catch (err) { 288 consola.error("WS: Invalid JWT token:", err); 289 ws.close(1008, "Invalid token"); 290 return; 291 } 292 } 293 294 const session = sessions.get(sessionId); 295 if (!session) { 296 ws.close(1011, "Session not found"); 297 return; 298 } 299 300 session.wsClients.add(ws); 301 302 // Flush buffered output that arrived before the WS client connected 303 for (const encoded of session.buffer) { 304 ws.send(encoded); 305 } 306 307 ws.on("message", (data) => { 308 if (!session.stream) return; 309 const text = data.toString("utf-8"); 310 try { 311 const msg = JSON.parse(text); 312 if ( 313 msg?.type === "resize" && 314 Number.isInteger(msg.cols) && 315 Number.isInteger(msg.rows) 316 ) { 317 (session.stream as any).setWindow(msg.rows, msg.cols, 0, 0); 318 return; 319 } 320 } catch { 321 // not JSON — treat as raw input 322 } 323 session.stream.write(text); 324 }); 325 326 ws.on("close", () => { 327 session.wsClients.delete(ws); 328 }); 329 }, 330 ); 331 332 return { wss, pathRegex }; 333}