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 458 lines 13 kB view raw
1import { SpritesClient, type ExecResult } from "@fly/sprites"; 2import { consola } from "consola"; 3import type { Context } from "context"; 4import * as context from "context"; 5import { eq } from "drizzle-orm"; 6import express, { Router } from "express"; 7import { env } from "lib/env"; 8import jwt from "jsonwebtoken"; 9import schema from "schema"; 10import decrypt from "lib/decrypt"; 11import path from "node:path"; 12import { WebSocketServer, type WebSocket } from "ws"; 13import type { IncomingMessage } from "http"; 14 15const router = Router(); 16router.use((req, res, next) => { 17 req.ctx = context.ctx; 18 next(); 19}); 20router.use(express.json()); 21 22router.use((req, res, next) => { 23 req.sandboxId = req.headers["x-sandbox-id"] as string | undefined; 24 const authHeader = req.headers.authorization; 25 const bearer = authHeader?.split("Bearer ")[1]?.trim(); 26 if (bearer && bearer !== "null") { 27 try { 28 const credentials = jwt.verify(bearer, env.JWT_SECRET, { 29 ignoreExpiration: true, 30 }) as { did: string }; 31 32 req.did = credentials.did; 33 } catch (err) { 34 consola.error("Invalid JWT token:", err); 35 } 36 } 37 38 next(); 39}); 40 41type Session = { 42 cmd: any; 43 clients: Set<express.Response>; 44 wsClients: Set<WebSocket>; 45}; 46 47const sessions = new Map<string, Session>(); 48 49async function createTerminalSession(ctx: Context, id: string, key = id) { 50 const [sandbox] = await ctx.db 51 .select() 52 .from(schema.sandboxes) 53 .where(eq(schema.sandboxes.id, id)) 54 .execute(); 55 56 if (!sandbox) { 57 consola.error(`Sandbox not found: ${id}`); 58 throw new Error(`Sandbox not found: ${id}`); 59 } 60 61 const [ 62 variables, 63 secrets, 64 files, 65 sshKeys, 66 [tailscale], 67 volumes, 68 [spriteAuth], 69 ] = await Promise.all([ 70 ctx.db 71 .select() 72 .from(schema.sandboxVariables) 73 .leftJoin( 74 schema.variables, 75 eq(schema.variables.id, schema.sandboxVariables.variableId), 76 ) 77 .where(eq(schema.sandboxVariables.sandboxId, id)) 78 .execute(), 79 ctx.db 80 .select() 81 .from(schema.sandboxSecrets) 82 .leftJoin( 83 schema.secrets, 84 eq(schema.secrets.id, schema.sandboxSecrets.secretId), 85 ) 86 .where(eq(schema.sandboxSecrets.sandboxId, id)) 87 .execute(), 88 ctx.db 89 .select() 90 .from(schema.sandboxFiles) 91 .leftJoin(schema.files, eq(schema.files.id, schema.sandboxFiles.fileId)) 92 .where(eq(schema.sandboxFiles.sandboxId, id)) 93 .execute(), 94 ctx.db 95 .select() 96 .from(schema.sshKeys) 97 .where(eq(schema.sshKeys.sandboxId, id)) 98 .execute(), 99 ctx.db 100 .select() 101 .from(schema.tailscaleAuthKeys) 102 .where(eq(schema.tailscaleAuthKeys.sandboxId, id)) 103 .execute(), 104 ctx.db 105 .select() 106 .from(schema.sandboxVolumes) 107 .leftJoin( 108 schema.sandboxes, 109 eq(schema.sandboxes.id, schema.sandboxVolumes.sandboxId), 110 ) 111 .leftJoin(schema.users, eq(schema.users.id, schema.sandboxes.userId)) 112 .where(eq(schema.sandboxVolumes.sandboxId, id)) 113 .execute(), 114 ctx.db 115 .select() 116 .from(schema.spriteAuth) 117 .where(eq(schema.spriteAuth.sandboxId, sandbox.id)) 118 .execute(), 119 ]); 120 121 const spriteToken = decrypt(spriteAuth!.spriteToken); 122 const client = new SpritesClient(spriteToken); 123 const sprite = client.sprite(sandbox.sandboxId!); 124 const cmd = sprite.spawn("bash", ["-i"], { 125 tty: true, 126 rows: 24, 127 cols: 80, 128 env: { 129 TERM: "xterm-256color", 130 ...variables 131 .map(({ variables }) => variables) 132 .filter((v) => v !== null) 133 .reduce( 134 (acc, v) => { 135 acc[v.name] = v.value; 136 return acc; 137 }, 138 {} as Record<string, string>, 139 ), 140 ...secrets 141 .map(({ secrets }) => secrets) 142 .filter((s) => s !== null) 143 .reduce( 144 (acc, s) => { 145 acc[s.name] = decrypt(s.value); 146 return acc; 147 }, 148 {} as Record<string, string>, 149 ), 150 }, 151 }); 152 153 const mkdir = async (absolutePath: string): Promise<ExecResult> => 154 sprite.execFile("mkdir", ["-p", absolutePath]); 155 156 const writeFile = async ( 157 absolutePath: string, 158 content: string, 159 ): Promise<void> => { 160 const basePath = path.dirname(absolutePath); 161 if (basePath !== "/" && basePath != ".") { 162 await mkdir(basePath); 163 } 164 await sprite.execFile("sh", ["-c", `echo '${content}' > ${absolutePath}`]); 165 }; 166 167 const setupDefaultSshKeys = async (): Promise<void> => { 168 await sprite.execFile("bash", [ 169 "-c", 170 '[ -f /home/sprite/.ssh/id_ed25519 ] || ssh-keygen -t ed25519 -f /home/sprite/.ssh/id_ed25519 -q -N ""', 171 ]); 172 }; 173 174 const setupSshKeys = async ( 175 privateKey: string, 176 publicKey: string, 177 ): Promise<void> => { 178 await writeFile("/home/sprite/.ssh/id_ed25519", privateKey); 179 await writeFile("/home/sprite/.ssh/id_ed25519.pub", publicKey); 180 await sprite.execFile("chmod", ["600", "/home/sprite/.ssh/id_ed25519"]); 181 await sprite.execFile("chmod", ["644", "/home/sprite/.ssh/id_ed25519.pub"]); 182 await sprite.exec("rm -f /home/sprite/.ssh/known_hosts"); 183 await sprite.execFile("bash", [ 184 "-c", 185 "ssh-keyscan -t rsa tangled.org >> /home/sprite/.ssh/known_hosts", 186 ]); 187 await sprite.execFile("bash", [ 188 "-c", 189 "ssh-keyscan -t rsa github.com >> /home/sprite/.ssh/known_hosts", 190 ]); 191 }; 192 193 const setupTailscale = async (authKey: string): Promise<void> => { 194 try { 195 await sprite.execFile("bash", [ 196 "-c", 197 "PATH=$(cat /etc/profile.d/languages_paths):$PATH type pm2 || npm install -g pm2", 198 ]); 199 await sprite.execFile("bash", [ 200 "-c", 201 "type tailscaled || curl -fsSL https://tailscale.com/install.sh | sh || true", 202 ]); 203 await sprite.exec( 204 "PATH=$(cat /etc/profile.d/languages_paths):$PATH pm2 start tailscaled", 205 ); 206 await sprite.execFile("bash", [ 207 "-c", 208 `tailscale up --auth-key=${authKey}`, 209 ]); 210 } catch (e) { 211 consola.error("failed to setup tailscale", e); 212 } 213 }; 214 215 const mount = async (path: string, prefix?: string): Promise<void> => { 216 try { 217 await sprite.execFile("bash", [ 218 "-c", 219 `type s3fs || apt-get update && apt-get install -y s3fs || sudo apt-get update && sudo apt-get install -y s3fs`, 220 ]); 221 await sprite.execFile("bash", [ 222 "-c", 223 `mkdir -p ${path} || sudo mkdir -p ${path}`, 224 ]); 225 226 const passwdFile = `/tmp/.passwd-s3fs-${crypto.randomUUID()}`; 227 228 await writeFile( 229 passwdFile, 230 `${env.R2_ACCESS_KEY_ID}:${env.R2_SECRET_ACCESS_KEY}`, 231 ); 232 233 await sprite.execFile("bash", ["-c", `chmod 0600 '${passwdFile}'`]); 234 235 const bucketPath = prefix 236 ? `${env.VOLUME_BUCKET}:${prefix}` 237 : env.VOLUME_BUCKET; 238 239 await sprite.execFile("bash", [ 240 "-c", 241 `s3fs '${bucketPath}' '${path}' -o 'passwd_file=${passwdFile},nomixupload,compat_dir,url=https://${env.ACCOUNT_ID}.r2.cloudflarestorage.com'`, 242 ]); 243 } catch (error) { 244 consola.error("Error mounting S3 bucket:", error); 245 } 246 }; 247 248 const unmount = async (path: string): Promise<void> => { 249 try { 250 await sprite.execFile("bash", [ 251 "-c", 252 `fusermount -u ${path} || sudo fusermount -u ${path} || umount ${path}`, 253 ]); 254 } catch (error) { 255 consola.error("Error unmounting S3 bucket:", error); 256 } 257 }; 258 259 await setupDefaultSshKeys(); 260 261 Promise.all([ 262 ...files 263 .filter((x) => x.files !== null) 264 .map(async (record) => 265 writeFile(record.sandbox_files.path, decrypt(record.files!.content)), 266 ), 267 ...sshKeys.map(async (record) => 268 setupSshKeys(decrypt(record.privateKey), record.publicKey), 269 ), 270 tailscale && setupTailscale(decrypt(tailscale.authKey)), 271 ...volumes.map((volume) => 272 mount( 273 volume.sandbox_volumes.path, 274 `/${volume.users?.did || ""}${volume.users?.did ? "/" : ""}${volume.sandbox_volumes.id}/`, 275 ), 276 ), 277 ]) 278 .then(() => consola.success(`Sandbox ${id} is ready`)) 279 .catch((err) => consola.error(`Error setting up sandbox ${id}:`, err)); 280 281 const session: Session = { 282 cmd, 283 clients: new Set(), 284 wsClients: new Set(), 285 }; 286 287 cmd.stdout.on("data", (chunk: Buffer | string) => { 288 const data = chunk.toString("utf8"); 289 290 for (const res of session.clients) { 291 res.write(`event: output\n`); 292 res.write(`data: ${JSON.stringify({ data })}\n\n`); 293 } 294 for (const ws of session.wsClients) { 295 if (ws.readyState === ws.OPEN) ws.send(data); 296 } 297 }); 298 299 cmd.on?.("exit", (code: number) => { 300 for (const res of session.clients) { 301 res.write(`event: exit\n`); 302 res.write(`data: ${JSON.stringify({ code })}\n\n`); 303 } 304 for (const ws of session.wsClients) { 305 if (ws.readyState === ws.OPEN) ws.close(1000, "exit"); 306 } 307 session.clients.clear(); 308 session.wsClients.clear(); 309 sessions.delete(key); 310 }); 311 312 cmd.on?.("error", (err: Error) => { 313 for (const res of session.clients) { 314 res.write(`event: error\n`); 315 res.write(`data: ${JSON.stringify({ message: err.message })}\n\n`); 316 } 317 for (const ws of session.wsClients) { 318 if (ws.readyState === ws.OPEN) ws.close(1011, err.message); 319 } 320 session.clients.clear(); 321 session.wsClients.clear(); 322 sessions.delete(key); 323 }); 324 325 sessions.set(key, session); 326 return session; 327} 328 329async function getSession(ctx: Context, id: string, key = id) { 330 return sessions.get(key) ?? (await createTerminalSession(ctx, id, key)); 331} 332 333router.get("/:id/stream", async (req, res) => { 334 const { id } = req.params; 335 const session = await getSession(req.ctx, id); 336 337 res.setHeader("Content-Type", "text/event-stream"); 338 res.setHeader("Cache-Control", "no-cache, no-transform"); 339 res.setHeader("Connection", "keep-alive"); 340 res.flushHeaders?.(); 341 342 session.clients.add(res); 343 344 const keepAlive = setInterval(() => { 345 res.write(`: ping\n\n`); 346 }, 15000); 347 348 req.on("close", () => { 349 clearInterval(keepAlive); 350 session.clients.delete(res); 351 }); 352}); 353 354router.post("/:id/input", express.text({ type: "*/*" }), async (req, res) => { 355 const { id } = req.params; 356 const session = await getSession(req.ctx, id); 357 358 const input = typeof req.body === "string" ? req.body : ""; 359 session.cmd.stdin.write(input); 360 361 res.status(204).end(); 362}); 363 364router.post("/:id/resize", async (req, res) => { 365 const { id } = req.params; 366 const session = await getSession(req.ctx, id); 367 368 const cols = Number(req.body?.cols); 369 const rows = Number(req.body?.rows); 370 371 if (!Number.isInteger(cols) || !Number.isInteger(rows)) { 372 res.status(400).json({ error: "Invalid cols/rows" }); 373 return; 374 } 375 376 session.cmd.resize(cols, rows); 377 res.status(204).end(); 378}); 379 380export default router; 381 382export function attachWebSocket(base: string) { 383 const pathRegex = new RegExp(`^${base}/([^/]+)/ws$`); 384 const wss = new WebSocketServer({ noServer: true }); 385 386 wss.on( 387 "connection", 388 async (ws: WebSocket, req: IncomingMessage, id: string) => { 389 const url = new URL(req.url ?? "", "http://localhost"); 390 const tokenParam = url.searchParams.get("token"); 391 const authHeader = req.headers.authorization; 392 const bearer = tokenParam ?? authHeader?.split("Bearer ")[1]?.trim(); 393 if (bearer && bearer !== "null") { 394 try { 395 jwt.verify(bearer, env.JWT_SECRET, { ignoreExpiration: true }); 396 } catch (err) { 397 consola.error("WS: Invalid JWT token:", err); 398 ws.close(1008, "Invalid token"); 399 return; 400 } 401 } 402 403 const shareId = url.searchParams.get("sessionId") ?? undefined; 404 const key = shareId ?? id; 405 406 // Buffer messages that arrive before the session is ready. 407 const pendingMessages: Buffer[] = []; 408 const bufferMessage = (data: Buffer) => pendingMessages.push(data); 409 ws.on("message", bufferMessage); 410 411 let session: Session; 412 try { 413 session = await getSession(context.ctx, id, key); 414 } catch (err) { 415 consola.error("WS: Failed to get session:", err); 416 ws.close(1011, "Session error"); 417 return; 418 } 419 420 session.wsClients.add(ws); 421 ws.off("message", bufferMessage); 422 423 const handleMessage = (data: Buffer) => { 424 const text = data.toString("utf-8"); 425 try { 426 const msg = JSON.parse(text); 427 if ( 428 msg?.type === "resize" && 429 Number.isInteger(msg.cols) && 430 Number.isInteger(msg.rows) 431 ) { 432 session.cmd.resize(msg.cols, msg.rows); 433 return; 434 } 435 } catch { 436 // not JSON — treat as raw input 437 } 438 session.cmd.stdin.write(text); 439 }; 440 441 // Replay messages buffered during session setup (e.g. the initial resize). 442 for (const data of pendingMessages) { 443 handleMessage(data); 444 } 445 446 ws.on("message", (data) => handleMessage(data as Buffer)); 447 448 ws.on("close", () => { 449 session.wsClients.delete(ws); 450 }); 451 452 // Trigger a fresh prompt redraw (initial output was lost while wsClients was empty). 453 session.cmd.stdin.write("\n"); 454 }, 455 ); 456 457 return { wss, pathRegex }; 458}