the universal sandbox runtime for agents and humans. pocketenv.io
sandbox openclaw agent claude-code vercel-sandbox deno-sandbox cloudflare-sandbox atproto sprites daytona
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add TTY SSH support and CLI create command

Add a TTY-based SSH client using eventsource and wire it into the
CLI ssh flow. Implement the CLI create command to call the
io.pocketenv.sandbox.createSandbox endpoint with an optional
--provider flag. Add POCKETENV_TTY_URL env var and the eventsource
dependency and types. Update server sandbox creation to accept either
credentials or artifacts for authentication.

+290 -4
+2 -2
apps/api/src/xrpc/io/pocketenv/sandbox/createSandbox.ts
··· 21 21 const createSandbox = async (input: HandlerInput, auth: HandlerAuth) => { 22 22 let res; 23 23 try { 24 - const { artifacts } = auth; 25 - if (!artifacts) { 24 + const { artifacts, credentials } = auth; 25 + if (!credentials && !artifacts) { 26 26 throw new XRPCError( 27 27 401, 28 28 "Authentication failed, invalid challenge",
+30
apps/cli/package-lock.json
··· 19 19 "dayjs": "^1.11.20", 20 20 "effect": "^3.19.19", 21 21 "envalid": "^8.1.1", 22 + "eventsource": "^4.1.0", 22 23 "express": "^5.2.1", 23 24 "node-pty": "^1.1.0", 24 25 "open": "^11.0.0", ··· 30 31 }, 31 32 "devDependencies": { 32 33 "@types/cors": "^2.8.19", 34 + "@types/eventsource": "^1.1.15", 33 35 "@types/express": "^5.0.6", 34 36 "@types/ws": "^8.18.1", 35 37 "pkgroll": "^2.27.0", ··· 1329 1331 "dev": true, 1330 1332 "license": "MIT" 1331 1333 }, 1334 + "node_modules/@types/eventsource": { 1335 + "version": "1.1.15", 1336 + "resolved": "https://registry.npmjs.org/@types/eventsource/-/eventsource-1.1.15.tgz", 1337 + "integrity": "sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA==", 1338 + "dev": true, 1339 + "license": "MIT" 1340 + }, 1332 1341 "node_modules/@types/express": { 1333 1342 "version": "5.0.6", 1334 1343 "dev": true, ··· 1849 1858 "license": "MIT", 1850 1859 "engines": { 1851 1860 "node": ">= 0.6" 1861 + } 1862 + }, 1863 + "node_modules/eventsource": { 1864 + "version": "4.1.0", 1865 + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.0.tgz", 1866 + "integrity": "sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ==", 1867 + "license": "MIT", 1868 + "dependencies": { 1869 + "eventsource-parser": "^3.0.1" 1870 + }, 1871 + "engines": { 1872 + "node": ">=20.0.0" 1873 + } 1874 + }, 1875 + "node_modules/eventsource-parser": { 1876 + "version": "3.0.6", 1877 + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", 1878 + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", 1879 + "license": "MIT", 1880 + "engines": { 1881 + "node": ">=18.0.0" 1852 1882 } 1853 1883 }, 1854 1884 "node_modules/express": {
+2
apps/cli/package.json
··· 25 25 }, 26 26 "devDependencies": { 27 27 "@types/cors": "^2.8.19", 28 + "@types/eventsource": "^1.1.15", 28 29 "@types/express": "^5.0.6", 29 30 "@types/ws": "^8.18.1", 30 31 "pkgroll": "^2.27.0", ··· 41 42 "dayjs": "^1.11.20", 42 43 "effect": "^3.19.19", 43 44 "envalid": "^8.1.1", 45 + "eventsource": "^4.1.0", 44 46 "express": "^5.2.1", 45 47 "node-pty": "^1.1.0", 46 48 "open": "^11.0.0",
+31 -2
apps/cli/src/cmd/create.ts
··· 1 - async function createSandbox(name?: string) { 2 - console.log(name); 1 + import consola from "consola"; 2 + import { client } from "../client"; 3 + import getAccessToken from "../lib/getAccessToken"; 4 + import type { Sandbox } from "../types/sandbox"; 5 + import chalk from "chalk"; 6 + 7 + async function createSandbox( 8 + name: string, 9 + { provider }: { provider: string | undefined }, 10 + ) { 11 + const token = await getAccessToken(); 12 + try { 13 + const sandbox = await client.post<Sandbox>( 14 + "/xrpc/io.pocketenv.sandbox.createSandbox", 15 + { 16 + headers: { 17 + Authorization: `Bearer ${token}`, 18 + }, 19 + params: { 20 + name, 21 + base: "at://did:plc:aturpi2ls3yvsmhc6wybomun/io.pocketenv.sandbox/openclaw", 22 + provider: provider ?? "cloudflare", 23 + }, 24 + }, 25 + ); 26 + consola.success( 27 + `Sandbox created successfully: ${chalk.greenBright(sandbox.data.name)}`, 28 + ); 29 + } catch (error) { 30 + consola.error(`Failed to create sandbox: ${error}`); 31 + } 3 32 } 4 33 5 34 export default createSandbox;
+2
apps/cli/src/cmd/ssh/index.ts
··· 6 6 import type { Sandbox } from "../../types/sandbox"; 7 7 import type { Profile } from "../../types/profile"; 8 8 import cloudflare from "./cloudflare"; 9 + import tty from "./tty"; 9 10 10 11 async function ssh(sandboxName: string | undefined) { 11 12 const token = await getAccessToken(); ··· 80 81 case "vercel": 81 82 break; 82 83 case "sprites": 84 + await tty(sandbox); 83 85 break; 84 86 default: 85 87 consola.error(
+221
apps/cli/src/cmd/ssh/tty.ts
··· 1 + import chalk from "chalk"; 2 + import consola from "consola"; 3 + import getAccessToken from "../../lib/getAccessToken"; 4 + import { env } from "../../lib/env"; 5 + import type { Sandbox } from "../../types/sandbox"; 6 + import { EventSource } from "eventsource"; 7 + import type { ErrorEvent } from "eventsource"; 8 + import axios from "axios"; 9 + 10 + // ── Protocol (mirrors TtyTerminal web component) ────────────────────────────── 11 + // 12 + // Server → Client (SSE stream at GET /tty/:id/stream): 13 + // event: output data: { "data": "<string>" } raw PTY output chunk 14 + // event: exit data: { "code": <number> } remote shell exited 15 + // 16 + // Client → Server: 17 + // POST /tty/:id/input Content-Type: text/plain raw keystroke bytes 18 + // POST /tty/:id/resize Content-Type: application/json { cols, rows } 19 + 20 + async function sendInput( 21 + ttyUrl: string, 22 + sandboxId: string, 23 + data: string | Buffer, 24 + token: string, 25 + ): Promise<void> { 26 + try { 27 + await axios.post( 28 + `${ttyUrl}/tty/${sandboxId}/input`, 29 + data instanceof Buffer ? data.toString("utf-8") : data, 30 + { 31 + headers: { 32 + "Content-Type": "text/plain", 33 + Authorization: `Bearer ${token}`, 34 + }, 35 + }, 36 + ); 37 + } catch { 38 + // session may have closed — swallow the error silently 39 + } 40 + } 41 + 42 + async function sendResize( 43 + ttyUrl: string, 44 + sandboxId: string, 45 + cols: number, 46 + rows: number, 47 + token: string, 48 + ): Promise<void> { 49 + try { 50 + await axios.post( 51 + `${ttyUrl}/tty/${sandboxId}/resize`, 52 + { cols, rows }, 53 + { 54 + headers: { 55 + "Content-Type": "application/json", 56 + Authorization: `Bearer ${token}`, 57 + }, 58 + }, 59 + ); 60 + } catch { 61 + // ignore transient resize errors 62 + } 63 + } 64 + 65 + /** 66 + * Build a custom fetch function that injects the Authorization header into 67 + * every SSE request. The eventsource v3 package uses a fetch-based 68 + * implementation and exposes this hook via `EventSourceInit.fetch`. 69 + */ 70 + function makeAuthFetch( 71 + token: string, 72 + ): (url: string | URL, init: RequestInit) => Promise<Response> { 73 + return (url: string | URL, init: RequestInit): Promise<Response> => { 74 + const headers = new Headers((init.headers as Record<string, string>) ?? {}); 75 + headers.set("Authorization", `Bearer ${token}`); 76 + return fetch(url, { ...init, headers }); 77 + }; 78 + } 79 + 80 + async function ssh(sandbox: Sandbox): Promise<void> { 81 + const token = await getAccessToken(); 82 + const authToken = env.POCKETENV_TOKEN || token; 83 + 84 + const ttyUrl = env.POCKETENV_TTY_URL; 85 + 86 + let cols = process.stdout.columns ?? 220; 87 + let rows = process.stdout.rows ?? 50; 88 + 89 + consola.info( 90 + `Connecting to ${chalk.cyanBright(sandbox.name)} via TTY stream…`, 91 + ); 92 + 93 + let exiting = false; 94 + let es: EventSource | null = null; 95 + let stdinAttached = false; 96 + 97 + function teardown(code = 0): void { 98 + if (exiting) return; 99 + exiting = true; 100 + 101 + if (process.stdin.isTTY) { 102 + try { 103 + process.stdin.setRawMode(false); 104 + } catch { 105 + // ignore – may already be restored by the time teardown runs 106 + } 107 + } 108 + process.stdin.pause(); 109 + 110 + if (es) { 111 + es.close(); 112 + es = null; 113 + } 114 + 115 + process.exit(code); 116 + } 117 + 118 + function attachStdin(): void { 119 + if (stdinAttached) return; 120 + stdinAttached = true; 121 + 122 + // Switch stdin to raw mode — every keystroke is forwarded immediately, 123 + // with no local echo or line-buffering. 124 + if (process.stdin.isTTY) { 125 + process.stdin.setRawMode(true); 126 + } 127 + 128 + // Keep stdin flowing as a raw binary stream. 129 + process.stdin.resume(); 130 + 131 + // stdin → POST /tty/:id/input 132 + process.stdin.on("data", (chunk: Buffer) => { 133 + sendInput(ttyUrl, sandbox.id, chunk, authToken); 134 + }); 135 + 136 + // Terminal resize → notify the remote PTY. 137 + process.stdout.on("resize", () => { 138 + cols = process.stdout.columns ?? cols; 139 + rows = process.stdout.rows ?? rows; 140 + sendResize(ttyUrl, sandbox.id, cols, rows, authToken); 141 + }); 142 + } 143 + 144 + // Mirror TtyTerminal: print a magenta "Connecting…" hint before the stream 145 + // opens, then erase it once the `open` event fires. 146 + process.stdout.write(`\x1b[35mConnecting to terminal...\x1b[0m\r\n`); 147 + 148 + // Open the SSE stream. 149 + // eventsource v3 is fetch-based, so we inject the Authorization header via 150 + // a custom fetch implementation instead of an `headers` init option. 151 + es = new EventSource(`${ttyUrl}/tty/${sandbox.id}/stream`, { 152 + fetch: makeAuthFetch(authToken), 153 + }); 154 + 155 + es.addEventListener("open", () => { 156 + // Erase the "Connecting…" line (carriage-return + erase-to-end-of-line), 157 + // exactly as TtyTerminal does with `instance.write("\r\x1b[K")`. 158 + process.stdout.write("\r\x1b[K"); 159 + 160 + // Sync terminal dimensions immediately after connecting, then attach stdin. 161 + sendResize(ttyUrl, sandbox.id, cols, rows, authToken).then(() => { 162 + attachStdin(); 163 + }); 164 + }); 165 + 166 + // `event: output` data: { "data": "..." } 167 + es.addEventListener("output", (event: MessageEvent) => { 168 + try { 169 + const { data } = JSON.parse(event.data as string) as { data: string }; 170 + process.stdout.write(data); 171 + } catch { 172 + // Fall back to writing the raw SSE data if the JSON wrapper is absent. 173 + process.stdout.write(event.data as string); 174 + } 175 + }); 176 + 177 + // `event: exit` data: { "code": <number> } 178 + es.addEventListener("exit", (event: MessageEvent) => { 179 + let code = 0; 180 + try { 181 + const parsed = JSON.parse(event.data as string) as { code: number }; 182 + code = parsed.code ?? 0; 183 + process.stderr.write( 184 + `\r\n${chalk.dim(`Process exited with code ${code}`)}\r\n`, 185 + ); 186 + } catch { 187 + process.stderr.write(`\r\n${chalk.dim("Process exited.")}\r\n`); 188 + } 189 + teardown(code); 190 + }); 191 + 192 + // `onerror` receives an `ErrorEvent` (eventsource v3 type). 193 + // readyState === 2 (CLOSED) means the stream is gone and the client will 194 + // NOT auto-retry. readyState === 0 (CONNECTING) is an auto-retry — let it. 195 + es.onerror = (err: ErrorEvent) => { 196 + // The eventsource package exposes readyState on the EventSource instance. 197 + if (es && es.readyState === EventSource.CLOSED) { 198 + const detail = err.message ? ` (${err.message})` : ""; 199 + process.stderr.write( 200 + `\r\n${chalk.red(`Terminal connection lost${detail}`)}\r\n`, 201 + ); 202 + teardown(1); 203 + } 204 + }; 205 + 206 + process.on("SIGINT", () => teardown(0)); 207 + process.on("SIGTERM", () => teardown(0)); 208 + 209 + // Block until teardown() fires (which calls process.exit, but the Promise 210 + // is here as a safety net for future refactors that remove process.exit). 211 + await new Promise<void>((resolve) => { 212 + const poll = setInterval(() => { 213 + if (exiting) { 214 + clearInterval(poll); 215 + resolve(); 216 + } 217 + }, 200); 218 + }); 219 + } 220 + 221 + export default ssh;
+1
apps/cli/src/index.ts
··· 84 84 program 85 85 .command("create") 86 86 .aliases(["new"]) 87 + .option("--provider, -p <provider>", "the provider to use for the sandbox") 87 88 .argument("[name]", "the name of the sandbox to create") 88 89 .description("create a new sandbox") 89 90 .action(createSandbox);
+1
apps/cli/src/lib/env.ts
··· 4 4 POCKETENV_TOKEN: str({ default: "" }), 5 5 POCKETENV_API_URL: str({ default: "https://api.pocketenv.io" }), 6 6 POCKETENV_CF_URL: str({ default: "https://sbx.pocketenv.io" }), 7 + POCKETENV_TTY_URL: str({ default: "https://api.pocketenv.io" }), 7 8 });