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 { env } from "../../lib/env";
6import type { Sandbox } from "../../types/sandbox";
7
8// ── Protocol ──────────────────────────────────────────────────────────────────
9//
10// Server → Client (WS at /tty/:id/ws or /pty/:id/ws):
11// - Text frame raw PTY output, write directly to stdout
12// - Close shell exited or session error
13//
14// Client → Server:
15// - Text frame raw keystroke bytes (UTF-8)
16// - Text frame JSON { type: "resize", cols: number, rows: number }
17
18function toWsUrl(httpUrl: string, path: string, token: string): string {
19 const base = httpUrl.replace(/^http(s?)/, (_, s) => `ws${s}`);
20 const url = new URL(`${base}${path}`);
21 url.searchParams.set("token", token);
22 return url.toString();
23}
24
25async function ssh(sandbox: Sandbox, tty: boolean = false): Promise<void> {
26 const token = await getAccessToken();
27 const authToken = env.POCKETENV_TOKEN || token;
28
29 const baseUrl = tty ? env.POCKETENV_TTY_URL : env.POCKETENV_PTY_URL;
30 const wsUrl = toWsUrl(baseUrl, `/${sandbox.id}/ws`, authToken);
31
32 let cols = process.stdout.columns ?? 220;
33 let rows = process.stdout.rows ?? 50;
34
35 consola.info(
36 `Connecting to ${chalk.cyanBright(sandbox.name)} via ${tty ? "TTY" : "PTY"} WebSocket…`,
37 );
38
39 const ws = new WebSocket(wsUrl, {
40 headers: { "User-Agent": "pocketenv-cli" },
41 });
42
43 let exiting = false;
44 let stdinAttached = false;
45
46 function sendResize(c: number, r: number): void {
47 if (ws.readyState === WebSocket.OPEN) {
48 ws.send(JSON.stringify({ type: "resize", cols: c, rows: r }));
49 }
50 }
51
52 function teardown(code = 0): void {
53 if (exiting) return;
54 exiting = true;
55
56 if (process.stdin.isTTY) {
57 try {
58 process.stdin.setRawMode(false);
59 } catch {
60 // already restored
61 }
62 }
63 process.stdin.pause();
64
65 if (
66 ws.readyState === WebSocket.OPEN ||
67 ws.readyState === WebSocket.CONNECTING
68 ) {
69 ws.close(1000, "client disconnect");
70 }
71
72 process.exit(code);
73 }
74
75 function attachStdin(): void {
76 if (stdinAttached) return;
77 stdinAttached = true;
78
79 if (process.stdin.isTTY) {
80 process.stdin.setRawMode(true);
81 }
82 process.stdin.resume();
83
84 process.stdin.on("data", (chunk: Buffer) => {
85 if (chunk.includes(0x0b)) {
86 // Ctrl+K — local escape hatch
87 teardown(0);
88 return;
89 }
90 if (ws.readyState === WebSocket.OPEN) {
91 ws.send(chunk.toString("utf-8"));
92 }
93 });
94
95 process.stdout.on("resize", () => {
96 cols = process.stdout.columns ?? cols;
97 rows = process.stdout.rows ?? rows;
98 sendResize(cols, rows);
99 });
100 }
101
102 process.stdout.write(`\x1b[35mConnecting to terminal...\x1b[0m\r\n`);
103
104 ws.on("open", () => {
105 process.stdout.write("\r\x1b[K");
106 sendResize(cols, rows);
107 attachStdin();
108 });
109
110 ws.on("message", (raw: WebSocket.RawData, isBinary: boolean) => {
111 process.stdout.write(isBinary ? (raw as Buffer) : raw.toString("utf-8"));
112 });
113
114 ws.on("close", (code, reason) => {
115 if (!exiting) {
116 const msg = reason.length ? ` (${code} – ${reason})` : "";
117 if (msg) {
118 process.stderr.write(
119 `\r\n${chalk.yellow("Connection closed")}${msg}\r\n`,
120 );
121 }
122 teardown(0);
123 }
124 });
125
126 ws.on("error", (err: Error) => {
127 consola.error("WebSocket error:", err.message);
128 teardown(1);
129 });
130
131 process.on("SIGINT", () => teardown(0));
132 process.on("SIGTERM", () => teardown(0));
133
134 await new Promise<void>((resolve) => {
135 ws.on("close", resolve);
136 ws.on("error", () => resolve());
137 });
138}
139
140export default ssh;