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 WebSocket from "ws";
2import chalk from "chalk";
3import consola from "consola";
4import getAccessToken from "../../lib/getAccessToken";
5import { client } from "../../client";
6import { env } from "../../lib/env";
7import type { Sandbox } from "../../types/sandbox";
8
9// ── Protocol (mirrors @cloudflare/sandbox xterm addon) ───────────────────────
10//
11// Server → Client:
12// - Binary frame raw PTY output (UTF-8 bytes), write directly to stdout
13// - Text frame JSON control message:
14// { type: "ready" } session is live
15// { type: "error", message: string } terminal error
16// { type: "exit", code: number } remote shell exited
17//
18// Client → Server:
19// - Binary frame raw keystroke bytes (UTF-8) — same as TextEncoder output
20// - Text frame { type: "resize", cols: number, rows: number }
21
22type ControlMessage =
23 | { type: "ready" }
24 | { type: "error"; message: string }
25 | { type: "exit"; code: number; signal?: string };
26
27function sendInput(ws: WebSocket, data: Buffer): void {
28 if (ws.readyState === WebSocket.OPEN) {
29 ws.send(data);
30 }
31}
32
33function sendResize(ws: WebSocket, cols: number, rows: number): void {
34 if (ws.readyState === WebSocket.OPEN) {
35 ws.send(JSON.stringify({ type: "resize", cols, rows }));
36 }
37}
38
39/**
40 * Resolve the Cloudflare worker URL for a given baseSandbox (template) name.
41 *
42 * The web app constructs the URL with:
43 * CF_URL.replace("sbx", worker).replace("claude-code", "claudecode")
44 *
45 * The production CF_URL is "https://sbx.pocketenv.io", so for a worker named
46 * "claude-code" the final URL becomes "https://claudecode.pocketenv.io".
47 */
48function resolveWorkerUrl(baseSandbox: string, cfUrl: string): string {
49 return cfUrl.replace("sbx", baseSandbox).replace("claude-code", "claudecode");
50}
51
52async function ssh(sandbox: Sandbox) {
53 const token = await getAccessToken();
54 const authToken = env.POCKETENV_TOKEN || token;
55
56 const tokenResponse = await client.get<{ token?: string }>(
57 "/xrpc/io.pocketenv.actor.getTerminalToken",
58 { headers: { Authorization: `Bearer ${authToken}` } },
59 );
60
61 const terminalToken = tokenResponse.data.token;
62 if (!terminalToken) {
63 consola.error("Failed to obtain a terminal token.");
64 process.exit(1);
65 }
66
67 const cfBaseUrl = env.POCKETENV_CF_URL;
68 const workerUrl = resolveWorkerUrl(sandbox.baseSandbox, cfBaseUrl);
69
70 // Convert http(s) → ws(s)
71 const wsBase = workerUrl.replace(/^http/, "ws");
72 const wsUrl = new URL(`${wsBase}/v1/sandboxes/${sandbox.id}/ws/terminal`);
73 wsUrl.searchParams.set("t", terminalToken);
74 wsUrl.searchParams.set("session", crypto.randomUUID());
75
76 let cols = process.stdout.columns ?? 220;
77 let rows = process.stdout.rows ?? 50;
78
79 consola.info(
80 `Connecting to ${chalk.cyanBright(sandbox.name)} via Cloudflare Sandbox…`,
81 );
82
83 // Use default binaryType ("nodebuffer") so binary frames arrive as Buffer,
84 // which is what isBinary:true + Buffer.isBuffer() correctly identifies.
85 const ws = new WebSocket(wsUrl.toString(), {
86 headers: { "User-Agent": "pocketenv-cli" },
87 });
88
89 let exiting = false;
90 let stdinAttached = false;
91
92 function teardown(code = 0): void {
93 if (exiting) return;
94 exiting = true;
95
96 if (process.stdin.isTTY) {
97 try {
98 process.stdin.setRawMode(false);
99 } catch {
100 // ignore – may already be restored
101 }
102 }
103 process.stdin.pause();
104
105 if (
106 ws.readyState === WebSocket.OPEN ||
107 ws.readyState === WebSocket.CONNECTING
108 ) {
109 ws.close(1000, "client disconnect");
110 }
111
112 process.exit(code);
113 }
114
115 ws.on("open", () => {
116 // Nothing to do on open — wait for "ready" before sending resize or input.
117 // (Matches the xterm addon behaviour: onSocketOpen only registers listeners,
118 // sendResize is called from handleControlMessage("ready").)
119 });
120
121 ws.on("message", (raw: WebSocket.RawData, isBinary: boolean) => {
122 if (isBinary) {
123 // Raw PTY output — write the bytes directly to stdout unchanged.
124 // raw is a Buffer (default nodebuffer binaryType).
125 process.stdout.write(raw as Buffer);
126 return;
127 }
128
129 // Text frame → JSON control message.
130 let msg: ControlMessage;
131 try {
132 msg = JSON.parse(raw.toString()) as ControlMessage;
133 } catch {
134 return;
135 }
136
137 switch (msg.type) {
138 case "ready": {
139 // ── Session is live ──────────────────────────────────────────────
140 // 1. Send current terminal dimensions now that the PTY is ready.
141 sendResize(ws, cols, rows);
142
143 if (stdinAttached) break;
144 stdinAttached = true;
145
146 // 2. Switch stdin to raw mode — every keystroke is forwarded
147 // immediately, no local echo or line-buffering.
148 if (process.stdin.isTTY) {
149 process.stdin.setRawMode(true);
150 }
151 // Keep stdin flowing as a raw binary stream. Using no encoding
152 // means data events fire with Buffer objects, which we send
153 // directly as binary WebSocket frames — no encoding round-trip.
154 process.stdin.resume();
155
156 // stdin → WebSocket (binary frame, UTF-8 bytes)
157 process.stdin.on("data", (chunk: Buffer) => {
158 sendInput(ws, chunk);
159 });
160
161 // Terminal resize → notify the remote PTY.
162 process.stdout.on("resize", () => {
163 cols = process.stdout.columns ?? cols;
164 rows = process.stdout.rows ?? rows;
165 sendResize(ws, cols, rows);
166 });
167
168 break;
169 }
170
171 case "error":
172 process.stderr.write(
173 `\r\n${chalk.red("Terminal error:")} ${msg.message}\r\n`,
174 );
175 teardown(1);
176 break;
177
178 case "exit":
179 process.stderr.write(
180 `\r\n${chalk.dim(
181 `Session exited with code ${msg.code}${msg.signal ? ` (${msg.signal})` : ""}`,
182 )}\r\n`,
183 );
184 teardown(msg.code ?? 0);
185 break;
186 }
187 });
188
189 ws.on("close", (code, reason) => {
190 if (!exiting) {
191 process.stderr.write(
192 `\r\n${chalk.yellow("Connection closed")} (${code}${reason.length ? ` – ${reason}` : ""})\r\n`,
193 );
194 teardown(0);
195 }
196 });
197
198 ws.on("error", (err: Error) => {
199 consola.error("WebSocket error:", err.message);
200 teardown(1);
201 });
202
203 process.on("SIGINT", () => teardown(0));
204 process.on("SIGTERM", () => teardown(0));
205
206 await new Promise<void>((resolve) => {
207 ws.on("close", resolve);
208 ws.on("error", () => resolve());
209 });
210}
211
212export default ssh;