the universal sandbox runtime for agents and humans.
pocketenv.io
sandbox
openclaw
agent
claude-code
vercel-sandbox
deno-sandbox
cloudflare-sandbox
atproto
sprites
daytona
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}