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