open, interoperable sandbox platform for agents and humans 📦 ✨
pocketenv.io
claude-code
atproto
sandbox
openclaw
agent
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;