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.

Add SSH access support for sandboxes

Expose a /v1/sandboxes/:sandboxId/ssh endpoint and add BaseSandbox.ssh
so
providers can return SSH credentials. Implement ssh for daytona and deno
(and stub vercel). Update API SSH router to parse JWT/x-sandbox-id,
fetch provider SSH info, and connect using returned hostname/username.
Update types and wire Terminal UI to pass sandboxId and Authorization
headers when creating and managing SSH sessions

+134 -35
-1
apps/api/src/lib/authVerfifier.ts
··· 2 2 import type express from "express"; 3 3 import jwt from "jsonwebtoken"; 4 4 import { env } from "./env"; 5 - import consola from "consola"; 6 5 7 6 type ReqCtx = { 8 7 req: express.Request;
+34 -11
apps/api/src/ssh/index.ts
··· 2 2 import { Client } from "ssh2"; 3 3 import { randomUUID } from "node:crypto"; 4 4 import { consola } from "consola"; 5 + import jwt from "jsonwebtoken"; 6 + import { env } from "lib/env"; 7 + import generateJwt from "lib/generateJwt"; 5 8 6 9 interface SSHSession { 7 10 client: Client; ··· 11 14 12 15 const sessions = new Map<string, SSHSession>(); 13 16 14 - const SSH_HOST = process.env.SSH_HOST || "example.com"; 15 - const SSH_PORT = Number(process.env.SSH_PORT || 22); 16 - const SSH_USERNAME = process.env.SSH_USERNAME || "user"; 17 - const SSH_PASSWORD = process.env.SSH_PASSWORD || "123"; 18 - 19 17 const router = Router(); 20 18 21 19 router.use(express.json()); 22 20 21 + router.use((req, res, next) => { 22 + const authHeader = req.headers.authorization; 23 + const bearer = authHeader?.split("Bearer ")[1]?.trim(); 24 + if (bearer && bearer !== "null") { 25 + const credentials = jwt.verify(bearer, env.JWT_SECRET, { 26 + ignoreExpiration: true, 27 + }) as { did: string }; 28 + 29 + req.did = credentials.did; 30 + req.sandboxId = req.headers["x-sandbox-id"] as string | undefined; 31 + } 32 + 33 + next(); 34 + }); 35 + 23 36 /** 24 37 * POST /ssh/connect 25 38 * Creates a new SSH session and returns the sessionId. 26 39 * Optionally accepts { cols, rows } in the body. 27 40 */ 28 - router.post("/ssh/connect", (req, res) => { 41 + router.post("/ssh/connect", async (req, res) => { 29 42 const sessionId = randomUUID(); 30 - consola.log(req.body); 31 43 const cols = req.body?.cols || 80; 32 44 const rows = req.body?.rows || 24; 45 + consola.log(req.did); 46 + consola.log(req.sandboxId); 47 + 48 + const ssh = await req.ctx.sandbox.get(`/v1/sandboxes/${req.sandboxId}/ssh`, { 49 + headers: { 50 + ...(req.did && { 51 + Authorization: `Bearer ${await generateJwt(req.did)}`, 52 + }), 53 + }, 54 + }); 55 + 56 + console.log(ssh); 33 57 34 58 const client = new Client(); 35 59 ··· 100 124 }); 101 125 102 126 client.connect({ 103 - host: SSH_HOST, 104 - port: SSH_PORT, 105 - username: SSH_USERNAME, 106 - password: SSH_PASSWORD, 127 + host: ssh.data?.hostname, 128 + port: 22, 129 + username: ssh.data?.username, 107 130 }); 108 131 }); 109 132
+2
apps/api/types/express.d.ts
··· 5 5 namespace Express { 6 6 interface Request { 7 7 ctx: Context; 8 + did?: string; 9 + sandboxId?: string; 8 10 } 9 11 } 10 12 }
+25
apps/sandbox/src/index.ts
··· 303 303 return c.json({ success: true }, 200); 304 304 }); 305 305 306 + app.get("/v1/sandboxes/:sandboxId/ssh", async (c) => { 307 + const record = await getSandbox(c.var.db, c.req.param("sandboxId")); 308 + 309 + if (!record) { 310 + return c.json({ error: "Sandbox not found" }, 404); 311 + } 312 + 313 + let sandbox: BaseSandbox | null = null; 314 + 315 + if (!["daytona", "deno"].includes(record.provider)) { 316 + return c.json({ error: "Sandbox provider not supported" }, 400); 317 + } 318 + 319 + sandbox = await getSandboxById( 320 + record.provider as Provider, 321 + record.sandbox_id!, 322 + ); 323 + 324 + if (!sandbox) { 325 + return c.json({ error: "Sandbox provider not supported" }, 400); 326 + } 327 + 328 + return c.json(await sandbox.ssh()); 329 + }); 330 + 306 331 export const getSandbox = async (db: Context["db"], sandboxId: string) => { 307 332 const [record] = await db 308 333 .select()
+16 -4
apps/sandbox/src/providers/daytona/mod.ts
··· 23 23 await this.sandbox.delete(); 24 24 } 25 25 26 - async sh(strings: TemplateStringsArray, ...values: any[]): Promise<any> { 26 + // deno-lint-ignore no-explicit-any 27 + sh(strings: TemplateStringsArray, ...values: any[]): Promise<any> { 27 28 const command = strings.reduce((acc, str, i) => { 28 29 return acc + str + (values[i] || ""); 29 30 }, ""); 30 - return this.sandbox.process.executeCommand(command); 31 + return Promise.resolve(this.sandbox.process.executeCommand(command)); 31 32 } 32 33 33 - async id(): Promise<string | null> { 34 - return this.sandbox.id; 34 + id(): Promise<string | null> { 35 + return Promise.resolve(this.sandbox.id); 36 + } 37 + 38 + async ssh(): Promise<{ 39 + username: string; 40 + hostname: string; 41 + }> { 42 + const sshAccess = await this.sandbox.createSshAccess(); 43 + return { 44 + username: sshAccess.token, 45 + hostname: "ssh.app.daytona.io", 46 + }; 35 47 } 36 48 } 37 49
+9 -2
apps/sandbox/src/providers/deno/mod.ts
··· 37 37 return output; 38 38 } 39 39 40 - async id(): Promise<string | null> { 41 - return this.sandbox.id; 40 + id(): Promise<string | null> { 41 + return Promise.resolve(this.sandbox.id); 42 + } 43 + 44 + ssh(): Promise<{ 45 + username: string; 46 + hostname: string; 47 + }> { 48 + return this.sandbox.exposeSsh(); 42 49 } 43 50 } 44 51
+1
apps/sandbox/src/providers/mod.ts
··· 6 6 abstract delete(): Promise<void>; 7 7 abstract sh(strings: TemplateStringsArray, ...values: any[]): Promise<any>; 8 8 abstract id(): Promise<string | null>; 9 + abstract ssh(): Promise<any>; 9 10 } 10 11 11 12 abstract class BaseProvider {
+2
apps/sandbox/src/providers/vercel/mod.ts
··· 40 40 async id(): Promise<string | null> { 41 41 return this.sandbox.sandboxId; 42 42 } 43 + 44 + async ssh(): Promise<any> {} 43 45 } 44 46 45 47 class VercelProvider implements BaseProvider {
+34 -12
apps/web/src/components/terminal/Terminal.tsx
··· 1 + /** eslint-disable @typescript-eslint/no-explicit-any */ 1 2 import { useEffect, useRef, useMemo, useCallback, useState } from "react"; 2 3 import { useXTerm } from "react-xtermjs"; 3 4 import { FitAddon } from "@xterm/addon-fit"; ··· 59 60 60 61 interface TerminalContentProps { 61 62 isDarkMode: boolean; 63 + sandboxId: string; 62 64 } 63 65 64 - function TerminalContent({ isDarkMode }: TerminalContentProps) { 66 + function TerminalContent({ isDarkMode, sandboxId }: TerminalContentProps) { 65 67 const sessionIdRef = useRef<string | null>(null); 66 68 const eventSourceRef = useRef<EventSource | null>(null); 67 69 const fitAddonRef = useRef<FitAddon | null>(null); ··· 92 94 try { 93 95 await fetch(`${API_URL}/ssh/input/${sid}`, { 94 96 method: "POST", 95 - headers: { "Content-Type": "application/json" }, 97 + headers: { 98 + "Content-Type": "application/json", 99 + ...(localStorage.getItem("token") && { 100 + Authorization: `Bearer ${localStorage.getItem("token")}`, 101 + }), 102 + }, 96 103 body: JSON.stringify({ data }), 97 104 }); 98 105 } catch { ··· 106 113 try { 107 114 await fetch(`${API_URL}/ssh/resize/${sid}`, { 108 115 method: "POST", 109 - headers: { "Content-Type": "application/json" }, 116 + headers: { 117 + "Content-Type": "application/json", 118 + ...(localStorage.getItem("token") && { 119 + Authorization: `Bearer ${localStorage.getItem("token")}`, 120 + }), 121 + }, 122 + 110 123 body: JSON.stringify({ cols, rows }), 111 124 }); 112 125 } catch { ··· 121 134 fitAddonRef.current = fitAddon; 122 135 instance.loadAddon(fitAddon); 123 136 124 - // Fit after a small delay to ensure container is sized 125 137 const fitTimer = setTimeout(() => { 126 138 try { 127 139 fitAddon.fit(); ··· 130 142 } 131 143 }, 100); 132 144 133 - // Handle window resize 134 145 const handleResize = () => { 135 146 try { 136 147 fitAddon.fit(); ··· 140 151 }; 141 152 window.addEventListener("resize", handleResize); 142 153 143 - // Send terminal resize to SSH when xterm resizes 144 154 const resizeDisposable = instance.onResize(({ cols, rows }) => { 145 155 sendResize(cols, rows); 146 156 }); ··· 152 162 153 163 const response = await fetch(`${API_URL}/ssh/connect`, { 154 164 method: "POST", 155 - headers: { "Content-Type": "application/json" }, 165 + headers: { 166 + "Content-Type": "application/json", 167 + "X-Sandbox-Id": sandboxId, 168 + ...(localStorage.getItem("token") && { 169 + Authorization: `Bearer ${localStorage.getItem("token")}`, 170 + }), 171 + }, 156 172 body: JSON.stringify({ cols, rows }), 157 173 }); 158 174 ··· 167 183 const { sessionId } = await response.json(); 168 184 sessionIdRef.current = sessionId; 169 185 170 - // Open SSE stream 171 186 const es = new EventSource(`${API_URL}/ssh/stream/${sessionId}`); 172 187 eventSourceRef.current = es; 173 188 174 189 es.addEventListener("connected", () => { 175 - // SSE connected, terminal is ready 176 190 instance.focus(); 177 191 }); 178 192 ··· 194 208 sessionIdRef.current = null; 195 209 }); 196 210 197 - es.addEventListener("error", (e) => { 211 + es.addEventListener("error", (e: any) => { 198 212 // EventSource error can be a reconnect or a real error 199 213 if (es.readyState === EventSource.CLOSED) { 200 214 instance.write("\r\n\x1b[38;5;203mSSH connection lost.\x1b[0m\r\n"); ··· 231 245 // Fire-and-forget disconnect 232 246 fetch(`${API_URL}/ssh/disconnect/${sessionIdRef.current}`, { 233 247 method: "DELETE", 248 + headers: { 249 + Authorization: `Bearer ${localStorage.getItem("token")}`, 250 + }, 234 251 }).catch(() => {}); 235 252 sessionIdRef.current = null; 236 253 } 237 254 }; 238 - }, [instance, sendInput, sendResize]); 255 + }, [instance, sendInput, sendResize, sandboxId]); 239 256 240 257 return ( 241 258 <div ··· 249 266 ); 250 267 } 251 268 252 - function Terminal() { 269 + export interface TerminalProps { 270 + sandboxId: string; 271 + } 272 + 273 + function Terminal({ sandboxId }: TerminalProps) { 253 274 const [isDarkMode, setIsDarkMode] = useState( 254 275 document.documentElement.classList.contains("dark"), 255 276 ); ··· 275 296 <TerminalContent 276 297 key={isDarkMode ? "dark" : "light"} 277 298 isDarkMode={isDarkMode} 299 + sandboxId={sandboxId} 278 300 /> 279 301 ); 280 302 }
+2 -1
apps/web/src/pages/projects/Project/Project.tsx
··· 47 47 48 48 const onOpenTerminal = (e: React.MouseEvent) => { 49 49 e.stopPropagation(); 50 - // if (sandbox.status !== "RUNNING") return; 50 + if (sandbox.status !== "RUNNING") return; 51 51 setModalOpen(true); 52 52 }; 53 53 ··· 121 121 onClose={() => { 122 122 setModalOpen(false); 123 123 }} 124 + sandboxId={sandbox.id} 124 125 /> 125 126 </td> 126 127 </tr>
+8 -2
apps/web/src/pages/projects/Project/TerminalModal/TerminalModal.tsx
··· 5 5 export type TerminalModalProps = { 6 6 isOpen: boolean; 7 7 onClose: () => void; 8 + sandboxId: string; 8 9 title?: string; 9 10 }; 10 11 11 - function TerminalModal({ isOpen, onClose, title }: TerminalModalProps) { 12 + function TerminalModal({ 13 + isOpen, 14 + onClose, 15 + title, 16 + sandboxId, 17 + }: TerminalModalProps) { 12 18 const [isFullscreen, setIsFullscreen] = useState(false); 13 19 14 20 useEffect(() => { ··· 118 124 : { height: "60vh" } 119 125 } 120 126 > 121 - <Terminal /> 127 + <Terminal sandboxId={sandboxId} /> 122 128 </div> 123 129 </div> 124 130 </div>
-1
apps/web/src/pages/projects/Projects.tsx
··· 2 2 import { profileAtom } from "../../atoms/profile"; 3 3 import { useActorSandboxesQuery } from "../../hooks/useSandbox"; 4 4 import Main from "../../layouts/Main"; 5 - import _ from "lodash"; 6 5 import Project from "./Project"; 7 6 8 7 function Projects() {
+1 -1
apps/web/src/pages/sandbox/Sandbox.tsx
··· 260 260 : { height: "400px" } 261 261 } 262 262 > 263 - <Terminal /> 263 + {data && <Terminal sandboxId={data.sandbox.id} />} 264 264 </div> 265 265 </div> 266 266 )}