open, interoperable sandbox platform for agents and humans 📦 ✨ pocketenv.io
claude-code atproto sandbox openclaw agent
at main 239 lines 6.1 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"; 8 9interface SSHSession { 10 client: Client; 11 stream: NodeJS.ReadWriteStream | null; 12 sseRes: import("express").Response | null; 13} 14 15const sessions = new Map<string, SSHSession>(); 16 17const router = Router(); 18 19router.use(express.json()); 20 21router.use((req, res, next) => { 22 req.sandboxId = req.headers["x-sandbox-id"] as string | undefined; 23 const authHeader = req.headers.authorization; 24 const bearer = authHeader?.split("Bearer ")[1]?.trim(); 25 if (bearer && bearer !== "null") { 26 try { 27 const credentials = jwt.verify(bearer, env.JWT_SECRET, { 28 ignoreExpiration: true, 29 }) as { did: string }; 30 31 req.did = credentials.did; 32 } catch (err) { 33 consola.error("Invalid JWT token:", err); 34 } 35 } 36 37 next(); 38}); 39 40/** 41 * POST /ssh/connect 42 * Creates a new SSH session and returns the sessionId. 43 * Optionally accepts { cols, rows } in the body. 44 */ 45router.post("/connect", async (req, res) => { 46 const sessionId = randomUUID(); 47 const cols = req.body?.cols || 80; 48 const rows = req.body?.rows || 24; 49 consola.log(req.did); 50 consola.log(req.sandboxId); 51 52 const ssh = await req.ctx.sandbox.get(`/v1/sandboxes/${req.sandboxId}/ssh`, { 53 headers: { 54 ...(req.did && { 55 Authorization: `Bearer ${await generateJwt(req.did)}`, 56 }), 57 }, 58 }); 59 60 const client = new Client(); 61 62 const session: SSHSession = { 63 client, 64 stream: null, 65 sseRes: null, 66 }; 67 68 sessions.set(sessionId, session); 69 70 client.on("ready", () => { 71 consola.success(`SSH session ${sessionId} connected`); 72 73 client.shell({ cols, rows, term: "xterm-256color" }, (err, stream) => { 74 if (err) { 75 consola.error(`SSH shell error for session ${sessionId}:`, err); 76 sessions.delete(sessionId); 77 res.status(500).json({ error: "Failed to open shell" }); 78 return; 79 } 80 81 session.stream = stream; 82 83 stream.on("data", (data: Buffer) => { 84 if (session.sseRes && !session.sseRes.writableEnded) { 85 const encoded = Buffer.from(data).toString("base64"); 86 session.sseRes.write(`data: ${encoded}\n\n`); 87 } 88 }); 89 90 stream.on("close", () => { 91 consola.info(`SSH stream closed for session ${sessionId}`); 92 if (session.sseRes && !session.sseRes.writableEnded) { 93 session.sseRes.write(`event: close\ndata: closed\n\n`); 94 session.sseRes.end(); 95 } 96 client.end(); 97 sessions.delete(sessionId); 98 }); 99 100 stream.stderr.on("data", (data: Buffer) => { 101 if (session.sseRes && !session.sseRes.writableEnded) { 102 const encoded = Buffer.from(data).toString("base64"); 103 session.sseRes.write(`data: ${encoded}\n\n`); 104 } 105 }); 106 107 res.json({ sessionId }); 108 }); 109 }); 110 111 client.on("error", (err) => { 112 consola.error(`SSH connection error for session ${sessionId}:`, err); 113 if (session.sseRes && !session.sseRes.writableEnded) { 114 session.sseRes.write( 115 `event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`, 116 ); 117 session.sseRes.end(); 118 } 119 sessions.delete(sessionId); 120 // Only respond if headers haven't been sent 121 if (!res.headersSent) { 122 res 123 .status(500) 124 .json({ error: "SSH connection failed", message: err.message }); 125 } 126 }); 127 128 client.connect({ 129 host: ssh.data?.hostname, 130 port: 22, 131 username: ssh.data?.username, 132 }); 133}); 134 135/** 136 * GET /ssh/stream/:sessionId 137 * SSE endpoint that streams SSH output to the client. 138 */ 139router.get("/stream/:sessionId", (req, res) => { 140 const { sessionId } = req.params; 141 const session = sessions.get(sessionId); 142 143 if (!session) { 144 res.status(404).json({ error: "Session not found" }); 145 return; 146 } 147 148 // Set SSE headers 149 res.setHeader("Content-Type", "text/event-stream"); 150 res.setHeader("Cache-Control", "no-cache"); 151 res.setHeader("Connection", "keep-alive"); 152 res.setHeader("X-Accel-Buffering", "no"); 153 res.flushHeaders(); 154 155 // Send initial connected event 156 res.write(`event: connected\ndata: ${sessionId}\n\n`); 157 158 session.sseRes = res; 159 160 // Handle client disconnect 161 req.on("close", () => { 162 consola.info(`SSE client disconnected for session ${sessionId}`); 163 session.sseRes = null; 164 }); 165}); 166 167/** 168 * POST /ssh/input/:sessionId 169 * Sends keyboard input to the SSH session. 170 * Body: { data: string } 171 */ 172router.post("/input/:sessionId", (req, res) => { 173 const { sessionId } = req.params; 174 const session = sessions.get(sessionId); 175 176 if (!session || !session.stream) { 177 res.status(404).json({ error: "Session not found" }); 178 return; 179 } 180 181 const { data } = req.body; 182 if (data) { 183 session.stream.write(data); 184 } 185 186 res.json({ ok: true }); 187}); 188 189/** 190 * POST /ssh/resize/:sessionId 191 * Resizes the SSH terminal. 192 * Body: { cols: number, rows: number } 193 */ 194router.post("/resize/:sessionId", (req, res) => { 195 const { sessionId } = req.params; 196 const session = sessions.get(sessionId); 197 198 if (!session || !session.stream) { 199 res.status(404).json({ error: "Session not found" }); 200 return; 201 } 202 203 const { cols, rows } = req.body; 204 if (cols && rows) { 205 (session.stream as any).setWindow(rows, cols, 0, 0); 206 } 207 208 res.json({ ok: true }); 209}); 210 211/** 212 * DELETE /ssh/disconnect/:sessionId 213 * Disconnects the SSH session. 214 */ 215router.delete("/disconnect/:sessionId", (req, res) => { 216 const { sessionId } = req.params; 217 const session = sessions.get(sessionId); 218 219 if (!session) { 220 res.status(404).json({ error: "Session not found" }); 221 return; 222 } 223 224 if (session.stream) { 225 session.stream.end(); 226 } 227 session.client.end(); 228 229 if (session.sseRes && !session.sseRes.writableEnded) { 230 session.sseRes.end(); 231 } 232 233 sessions.delete(sessionId); 234 consola.info(`SSH session ${sessionId} disconnected`); 235 236 res.json({ ok: true }); 237}); 238 239export default router;