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 { type ContainerProcess, ModalClient, type Sandbox } from "modal";
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";
12import path from "node:path";
13
14const TERM = "xterm-256color";
15const PTY_SERVER_DOWNLOAD_URL =
16 "https://github.com/tsirysndr/pty-tunnel-server/releases/download/v0.0.2/pty-server-linux-x86_64.tar.gz";
17const SERVER_BIN_NAME = "pty-tunnel-server";
18const PTY_PORT = 26661;
19
20type SandboxEnvironmentOptions = {
21 id: string;
22 tokenId: string;
23 tokenSecret: string;
24};
25
26async function checkIfServerInstalled(sandbox: Sandbox) {
27 const exists = await sandbox.exec(["command", "-v", SERVER_BIN_NAME]);
28 const exitCode = await exists.wait();
29 return exitCode === 0;
30}
31
32async function setupSandboxEnvironment(
33 options: SandboxEnvironmentOptions,
34): Promise<{ sandbox: Sandbox; cmd: ContainerProcess<string> }> {
35 const modal = new ModalClient({
36 tokenId: options.tokenId,
37 tokenSecret: options.tokenSecret,
38 });
39 consola.info("Modal: fetching sandbox", chalk.greenBright(options.id));
40 const sandbox = await modal.sandboxes.fromId(options.id);
41 consola.info("Modal: sandbox fetched", chalk.greenBright(options.id));
42
43 consola.info(
44 "Modal: checking pty-tunnel-server",
45 chalk.greenBright(options.id),
46 );
47 if (!(await checkIfServerInstalled(sandbox))) {
48 await $`bash -c "type /tmp/${SERVER_BIN_NAME} || curl -L ${PTY_SERVER_DOWNLOAD_URL} | tar xz -C /tmp"`;
49
50 consola.info(
51 "Uploading pty-tunnel server binary to sandbox",
52 chalk.greenBright(options.id),
53 );
54
55 const pathname = path.join("/tmp", `pty-server-${crypto.randomUUID()}`);
56 const bin = await sandbox.open(pathname, "w");
57 await bin.write(await fs.readFile(`/tmp/${SERVER_BIN_NAME}`));
58 await bin.close();
59
60 consola.info(
61 "Setting up pty-tunnel server binary in sandbox",
62 chalk.greenBright(options.id),
63 );
64
65 await sandbox.exec([
66 "bash",
67 "-c",
68 `mv "${pathname}" /usr/local/bin/${SERVER_BIN_NAME} || sudo mv "${pathname}" /usr/local/bin/${SERVER_BIN_NAME}; chmod a+x /usr/local/bin/${SERVER_BIN_NAME} || sudo chmod a+x /usr/local/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 const cmd = await sandbox.exec(
83 [
84 SERVER_BIN_NAME,
85 `--port=${PTY_PORT}`,
86 `--mode=client`,
87 `--cols=${process.stdout.columns ?? 80}`,
88 `--rows=${process.stdout.rows ?? 24}`,
89 "bash",
90 ],
91 {
92 env: {
93 TERM,
94 },
95 },
96 );
97
98 consola.info(
99 "Modal: pty-tunnel-server process started",
100 chalk.greenBright(options.id),
101 );
102
103 return { sandbox, cmd };
104}
105
106export async function createTerminalSession(
107 ctx: Context,
108 id: string,
109 key = id,
110) {
111 const [record] = await ctx.db
112 .select()
113 .from(schema.sandboxes)
114 .leftJoin(
115 schema.modalAuth,
116 eq(schema.modalAuth.sandboxId, schema.sandboxes.id),
117 )
118 .where(or(eq(schema.sandboxes.id, id), eq(schema.sandboxes.sandboxId, id)))
119 .execute();
120
121 if (!record?.modal_auth) {
122 consola.error("Modal auth not found for sandbox", { id });
123 throw new Error("Modal auth not found for sandbox " + id);
124 }
125
126 if (!record.sandboxes.sandboxId) {
127 consola.error("Sandbox ID not found for sandbox", { id });
128 throw new Error("Sandbox ID not found for sandbox " + id);
129 }
130
131 // setup the sandbox environment for pty-tunnel server
132 const { sandbox, cmd } = await setupSandboxEnvironment({
133 id: record.sandboxes.sandboxId,
134 tokenId: decrypt(record.modal_auth.tokenId),
135 tokenSecret: decrypt(record.modal_auth.tokenSecret),
136 });
137
138 const listener = createListener();
139
140 // Log stderr without piping to the listener.
141 (async () => {
142 for await (const data of cmd.stderr) {
143 consola.debug(`pty-tunnel-server [stderr]:`, data.trimEnd());
144 }
145 })().catch(() => {});
146
147 // Pipe the pty-tunnel-server's stdout into the listener so
148 // readConnectionInfo() can parse the JSON connection handshake.
149 // We also accept stderr in case the binary writes there instead.
150 (async () => {
151 for await (const data of cmd.stdout) {
152 consola.debug(`pty-tunnel-server [stdout]:`, data.trimEnd());
153 // jsonlines parser requires newline-terminated data
154 const chunk = data.endsWith("\n") ? data : data + "\n";
155 listener.stdoutStream.write(chunk);
156 }
157 listener.stdoutStream.end();
158 })().catch((err) =>
159 consola.error("pty-tunnel-server log stream error:", err),
160 );
161
162 consola.info("Modal: fetching sandbox tunnels", chalk.greenBright(id));
163 const tunnels = await sandbox.tunnels();
164 consola.info("Modal: tunnels fetched", JSON.stringify(Object.keys(tunnels)));
165 const port = tunnels[PTY_PORT];
166 if (!port) {
167 consola.error(`PTY port ${PTY_PORT} not found in sandbox tunnels`, {
168 id,
169 tunnels,
170 });
171 throw new Error(`PTY port ${PTY_PORT} not found in sandbox tunnels`);
172 }
173
174 consola.info(
175 "Modal: awaiting pty-tunnel connection info",
176 chalk.greenBright(id),
177 );
178 const details = await listener.connection;
179 consola.info(
180 "Modal: pty-tunnel connection info received",
181 chalk.greenBright(id),
182 );
183
184 const url = `wss://${port.url.replace(/^https?:\/\//, "")}` as const;
185 consola.info("Connecting to WebSocket URL:", url);
186
187 const socket = details.createClient(url);
188
189 const session: Session = {
190 socket,
191 clients: new Set(),
192 wsClients: new Set(),
193 };
194
195 socket.addEventListener("message", async ({ data }) => {
196 const text = data.toString("utf-8");
197 for (const res of session.clients) {
198 res.write(`event: output\n`);
199 res.write(`data: ${JSON.stringify({ data: text })}\n\n`);
200 }
201 for (const ws of session.wsClients) {
202 if (ws.readyState === ws.OPEN) ws.send(text);
203 }
204 });
205
206 socket.addEventListener("close", () => {
207 ctx.sessions.delete(key);
208 for (const ws of session.wsClients) {
209 if (ws.readyState === ws.OPEN) ws.close(1000, "exit");
210 }
211 session.clients.clear();
212 session.wsClients.clear();
213 });
214
215 consola.info(
216 "Modal: waiting for pty-tunnel socket to open",
217 chalk.greenBright(id),
218 );
219 await socket.waitForOpen();
220 consola.info(
221 "Modal: pty-tunnel socket open, sending ready",
222 chalk.greenBright(id),
223 );
224 socket.sendMessage({ type: "ready" });
225
226 ctx.sessions.set(key, session);
227 return session;
228}