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 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}