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 type { Context, Session } from "context";
2import { eq, or } from "drizzle-orm";
3import schema from "schema";
4import { consola } from "consola";
5import { Devbox, RunloopSDK } from "@runloop/api-client";
6import decrypt from "lib/decrypt";
7import { createListener } from "pty/pty-tunnel";
8import chalk from "chalk";
9import { $ } from "zx";
10import crypto from "crypto";
11import fs from "fs/promises";
12
13const TERM = "xterm-256color";
14const PTY_SERVER_DOWNLOAD_URL =
15 "https://github.com/tsirysndr/pty-tunnel-server/releases/download/v0.0.2/pty-server-linux-x86_64.tar.gz";
16const SERVER_BIN_NAME = "pty-tunnel-server";
17const PTY_PORT = 26661;
18
19type SandboxEnvironmentOptions = {
20 id: string;
21 apiKey: string;
22};
23
24async function checkIfServerInstalled(sandbox: Devbox) {
25 const exists = await sandbox.cmd.exec(`command -v ${SERVER_BIN_NAME}`);
26 return exists.exitCode === 0;
27}
28
29async function setupSandboxEnvironment(
30 options: SandboxEnvironmentOptions,
31 stdout: (data: string) => void,
32 stderr: (data: string) => void,
33): Promise<{ sandbox: Devbox }> {
34 const sdk = new RunloopSDK({
35 bearerToken: options.apiKey,
36 });
37 consola.info("Runloop: fetching sandbox", chalk.greenBright(options.id));
38 const sandbox = sdk.devbox.fromId(options.id);
39 consola.info("Runloop: sandbox fetched", chalk.greenBright(options.id));
40
41 consola.info(
42 "Runloop: checking pty-tunnel-server",
43 chalk.greenBright(options.id),
44 );
45 if (!(await checkIfServerInstalled(sandbox))) {
46 await $`bash -c "type /tmp/${SERVER_BIN_NAME} || curl -L ${PTY_SERVER_DOWNLOAD_URL} | tar xz -C /tmp"`;
47
48 consola.info(
49 "Uploading pty-tunnel server binary to sandbox",
50 chalk.greenBright(options.id),
51 );
52
53 const pathname = `pty-server-${crypto.randomUUID()}`;
54 await sandbox.file.upload({
55 path: pathname,
56 file: new File(
57 [await fs.readFile(`/tmp/${SERVER_BIN_NAME}`)],
58 SERVER_BIN_NAME,
59 ),
60 });
61
62 consola.info(
63 "Setting up pty-tunnel server binary in sandbox",
64 chalk.greenBright(options.id),
65 );
66
67 await sandbox.cmd.exec(
68 `bash -c mv "${pathname}" /usr/bin/${SERVER_BIN_NAME} || sudo mv "${pathname}" /usr/bin/${SERVER_BIN_NAME}; chmod a+x /usr/bin/${SERVER_BIN_NAME} || sudo chmod a+x /usr/bin/${SERVER_BIN_NAME}`,
69 );
70
71 consola.info(
72 "Pty-tunnel server binary set up in sandbox",
73 chalk.greenBright(options.id),
74 );
75 }
76
77 consola.info(
78 "Starting pty-tunnel server in sandbox",
79 chalk.greenBright(options.id),
80 );
81
82 await sandbox.cmd.execAsync(
83 `bash -c "TERM==${TERM} ${SERVER_BIN_NAME} --port=${PTY_PORT} --mode=client --cols=${process.stdout.columns ?? 80} --rows=${process.stdout.rows ?? 24} bash"`,
84 {
85 stdout: stdout,
86 stderr: stderr,
87 },
88 );
89
90 consola.info(
91 "Runloop: pty-tunnel-server process started",
92 chalk.greenBright(options.id),
93 );
94
95 return { sandbox };
96}
97
98export async function createTerminalSession(
99 ctx: Context,
100 id: string,
101 key = id,
102) {
103 const [record] = await ctx.db
104 .select()
105 .from(schema.sandboxes)
106 .leftJoin(
107 schema.runloopAuth,
108 eq(schema.runloopAuth.sandboxId, schema.sandboxes.id),
109 )
110 .where(or(eq(schema.sandboxes.id, id), eq(schema.sandboxes.sandboxId, id)))
111 .execute();
112
113 if (!record?.runloop_auth) {
114 consola.error("Runloop auth not found for sandbox", { id });
115 throw new Error("Runloop auth not found for sandbox " + id);
116 }
117
118 if (!record.sandboxes.sandboxId) {
119 consola.error("Sandbox ID not found for sandbox", { id });
120 throw new Error("Sandbox ID not found for sandbox " + id);
121 }
122
123 const listener = createListener();
124
125 // setup the sandbox environment for pty-tunnel server
126 const { sandbox } = await setupSandboxEnvironment(
127 {
128 id: record.sandboxes.sandboxId,
129 apiKey: decrypt(record.runloop_auth.apiKey),
130 },
131 // Pipe the pty-tunnel-server's stdout into the listener so
132 // readConnectionInfo() can parse the JSON connection handshake.
133 // We also accept stderr in case the binary writes there instead.
134 (data) => {
135 consola.debug(`pty-tunnel-server [stdout]:`, data.trimEnd());
136 // jsonlines parser requires newline-terminated data
137 const chunk = data.endsWith("\n") ? data : data + "\n";
138 listener.stdoutStream.write(chunk);
139 },
140 (data) => consola.debug(`pty-tunnel-server [stderr]:`, data.trimEnd()),
141 );
142
143 consola.info("Runloop: fetching sandbox tunnels", chalk.greenBright(id));
144 const tunnel = (await sandbox.getInfo()).tunnel;
145 console.log(
146 `Tunnel URL for port ${PTY_PORT}: https://${PTY_PORT}-${tunnel?.tunnel_key}.tunnel.runloop.ai`,
147 );
148 consola.info(
149 "Runloop: awaiting pty-tunnel connection info",
150 chalk.greenBright(id),
151 );
152 const details = await listener.connection;
153 consola.info(
154 "Runloop: pty-tunnel connection info received",
155 chalk.greenBright(id),
156 );
157
158 const url =
159 `wss://${PTY_PORT}-${tunnel?.tunnel_key}.tunnel.runloop.ai` as const;
160 consola.info("Connecting to WebSocket URL:", url);
161
162 const socket = details.createClient(url);
163
164 const session: Session = {
165 socket,
166 clients: new Set(),
167 wsClients: new Set(),
168 };
169
170 socket.addEventListener("message", async ({ data }) => {
171 const text = data.toString("utf-8");
172 for (const res of session.clients) {
173 res.write(`event: output\n`);
174 res.write(`data: ${JSON.stringify({ data: text })}\n\n`);
175 }
176 for (const ws of session.wsClients) {
177 if (ws.readyState === ws.OPEN) ws.send(text);
178 }
179 });
180
181 socket.addEventListener("close", () => {
182 ctx.sessions.delete(key);
183 for (const ws of session.wsClients) {
184 if (ws.readyState === ws.OPEN) ws.close(1000, "exit");
185 }
186 session.clients.clear();
187 session.wsClients.clear();
188 });
189
190 consola.info(
191 "Runloop: waiting for pty-tunnel socket to open",
192 chalk.greenBright(id),
193 );
194 await socket.waitForOpen();
195 consola.info(
196 "Runloop: pty-tunnel socket open, sending ready",
197 chalk.greenBright(id),
198 );
199 socket.sendMessage({ type: "ready" });
200
201 ctx.sessions.set(key, session);
202 return session;
203}