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 terminal token endpoint and WebSocket auth

Add lexicon, pkl and generated types plus an xrpc handler to issue
short-lived terminal JWTs
Cloudflare sandbox WS now accepts token query param "t", validates the
JWT and checks ownership (added leftJoin to users)
Web client adds a hook and API call to fetch the token and append it to
the terminal WebSocket URL
Minor cleanups: replace several let with const and fix a createSandbox
variable assignment

+231 -13
+26
apps/api/lexicons/actor/getTerminalToken.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.pocketenv.actor.getTerminalToken", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get a terminal token", 8 + "parameters": { 9 + "type": "params", 10 + "properties": {} 11 + }, 12 + "output": { 13 + "encoding": "application/json", 14 + "schema": { 15 + "type": "object", 16 + "properties": { 17 + "token": { 18 + "type": "string", 19 + "description": "An access token that can be used to authenticate with the terminal service. This token is typically short-lived and should be used immediately to establish a connection with the terminal." 20 + } 21 + } 22 + } 23 + } 24 + } 25 + } 26 + }
+22
apps/api/pkl/defs/actor/getTerminalToken.pkl
··· 1 + amends "../../schema/lexicon.pkl" 2 + 3 + lexicon = 1 4 + id = "io.pocketenv.actor.getTerminalToken" 5 + defs = new Mapping<String, Query> { 6 + ["main"] { 7 + type = "query" 8 + description = "Get a terminal token" 9 + output { 10 + encoding = "application/json" 11 + schema = new ObjectType { 12 + type = "object" 13 + properties { 14 + ["token"] = new StringType { 15 + type = "string" 16 + description = "An access token that can be used to authenticate with the terminal service. This token is typically short-lived and should be used immediately to establish a connection with the terminal." 17 + } 18 + } 19 + } 20 + } 21 + } 22 + }
+12
apps/api/src/lexicon/index.ts
··· 11 11 import { schemas } from "./lexicons"; 12 12 import type * as IoPocketenvActorGetActorSandboxes from "./types/io/pocketenv/actor/getActorSandboxes"; 13 13 import type * as IoPocketenvActorGetProfile from "./types/io/pocketenv/actor/getProfile"; 14 + import type * as IoPocketenvActorGetTerminalToken from "./types/io/pocketenv/actor/getTerminalToken"; 14 15 import type * as IoPocketenvSandboxClaimSandbox from "./types/io/pocketenv/sandbox/claimSandbox"; 15 16 import type * as IoPocketenvSandboxCreateSandbox from "./types/io/pocketenv/sandbox/createSandbox"; 16 17 import type * as IoPocketenvSandboxDeleteSandbox from "./types/io/pocketenv/sandbox/deleteSandbox"; ··· 85 86 >, 86 87 ) { 87 88 const nsid = "io.pocketenv.actor.getProfile"; // @ts-ignore 89 + return this._server.xrpc.method(nsid, cfg); 90 + } 91 + 92 + getTerminalToken<AV extends AuthVerifier>( 93 + cfg: ConfigOf< 94 + AV, 95 + IoPocketenvActorGetTerminalToken.Handler<ExtractAuth<AV>>, 96 + IoPocketenvActorGetTerminalToken.HandlerReqCtx<ExtractAuth<AV>> 97 + >, 98 + ) { 99 + const nsid = "io.pocketenv.actor.getTerminalToken"; // @ts-ignore 88 100 return this._server.xrpc.method(nsid, cfg); 89 101 } 90 102 }
+28
apps/api/src/lexicon/lexicons.ts
··· 125 125 }, 126 126 }, 127 127 }, 128 + IoPocketenvActorGetTerminalToken: { 129 + lexicon: 1, 130 + id: "io.pocketenv.actor.getTerminalToken", 131 + defs: { 132 + main: { 133 + type: "query", 134 + description: "Get a terminal token", 135 + parameters: { 136 + type: "params", 137 + properties: {}, 138 + }, 139 + output: { 140 + encoding: "application/json", 141 + schema: { 142 + type: "object", 143 + properties: { 144 + token: { 145 + type: "string", 146 + description: 147 + "An access token that can be used to authenticate with the terminal service. This token is typically short-lived and should be used immediately to establish a connection with the terminal.", 148 + }, 149 + }, 150 + }, 151 + }, 152 + }, 153 + }, 154 + }, 128 155 AppBskyActorProfile: { 129 156 lexicon: 1, 130 157 id: "app.bsky.actor.profile", ··· 839 866 IoPocketenvActorDefs: "io.pocketenv.actor.defs", 840 867 IoPocketenvActorGetActorSandboxes: "io.pocketenv.actor.getActorSandboxes", 841 868 IoPocketenvActorGetProfile: "io.pocketenv.actor.getProfile", 869 + IoPocketenvActorGetTerminalToken: "io.pocketenv.actor.getTerminalToken", 842 870 AppBskyActorProfile: "app.bsky.actor.profile", 843 871 IoPocketenvSandboxClaimSandbox: "io.pocketenv.sandbox.claimSandbox", 844 872 IoPocketenvSandboxCreateSandbox: "io.pocketenv.sandbox.createSandbox",
+45
apps/api/src/lexicon/types/io/pocketenv/actor/getTerminalToken.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import type express from "express"; 5 + import { ValidationResult, BlobRef } from "@atproto/lexicon"; 6 + import { lexicons } from "../../../../lexicons"; 7 + import { isObj, hasProp } from "../../../../util"; 8 + import { CID } from "multiformats/cid"; 9 + import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 + 11 + export type QueryParams = {}; 12 + 13 + export type InputSchema = undefined; 14 + 15 + export interface OutputSchema { 16 + /** An access token that can be used to authenticate with the terminal service. This token is typically short-lived and should be used immediately to establish a connection with the terminal. */ 17 + token?: string; 18 + [k: string]: unknown; 19 + } 20 + 21 + export type HandlerInput = undefined; 22 + 23 + export interface HandlerSuccess { 24 + encoding: "application/json"; 25 + body: OutputSchema; 26 + headers?: { [key: string]: string }; 27 + } 28 + 29 + export interface HandlerError { 30 + status: number; 31 + message?: string; 32 + } 33 + 34 + export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; 35 + export type HandlerReqCtx<HA extends HandlerAuth = never> = { 36 + auth: HA; 37 + params: QueryParams; 38 + input: HandlerInput; 39 + req: express.Request; 40 + res: express.Response; 41 + resetRouteRateLimits: () => Promise<void>; 42 + }; 43 + export type Handler<HA extends HandlerAuth = never> = ( 44 + ctx: HandlerReqCtx<HA>, 45 + ) => Promise<HandlerOutput> | HandlerOutput;
+2
apps/api/src/xrpc/index.ts
··· 9 9 import claimSandbox from "./io/pocketenv/sandbox/claimSandbox"; 10 10 import getProfile from "./io/pocketenv/actor/getProfile"; 11 11 import getActorSandboxes from "./io/pocketenv/actor/getActorSandboxes"; 12 + import getTerminalToken from "./io/pocketenv/actor/getTerminalToken"; 12 13 13 14 export default function (server: Server, ctx: Context) { 14 15 // io.pocketenv ··· 21 22 stopSandbox(server, ctx); 22 23 claimSandbox(server, ctx); 23 24 getProfile(server, ctx); 25 + getTerminalToken(server, ctx); 24 26 25 27 return server; 26 28 }
+27
apps/api/src/xrpc/io/pocketenv/actor/getTerminalToken.ts
··· 1 + import { XRPCError, type HandlerAuth } from "@atproto/xrpc-server"; 2 + import type { Context } from "context"; 3 + import type { Server } from "lexicon"; 4 + import generateJwt from "lib/generateJwt"; 5 + 6 + export default function (server: Server, ctx: Context) { 7 + const getTerminalToken = async (auth: HandlerAuth) => { 8 + if (!auth?.credentials?.did) { 9 + throw new XRPCError(401, "Unauthorized"); 10 + } 11 + const token = await generateJwt(auth.credentials.did as string); 12 + 13 + return { 14 + token, 15 + }; 16 + }; 17 + server.io.pocketenv.actor.getTerminalToken({ 18 + auth: ctx.authVerifier, 19 + handler: async ({ auth }) => { 20 + const result = await getTerminalToken(auth); 21 + return { 22 + encoding: "application/json", 23 + body: result, 24 + }; 25 + }, 26 + }); 27 + }
+1 -1
apps/api/src/xrpc/io/pocketenv/sandbox/createSandbox.ts
··· 22 22 let res; 23 23 try { 24 24 const provider = input.body.provider || Providers.CLOUDFLARE; 25 - let sandbox = 25 + const sandbox = 26 26 provider === Providers.CLOUDFLARE ? ctx.cfsandbox : ctx.sandbox; 27 27 res = await sandbox.post( 28 28 "/v1/sandboxes",
+1 -1
apps/api/src/xrpc/io/pocketenv/sandbox/deleteSandbox.ts
··· 20 20 throw new XRPCError(404, "Sandbox not found", "SandboxNotFound"); 21 21 } 22 22 23 - let sandbox = 23 + const sandbox = 24 24 record.provider === Providers.CLOUDFLARE ? ctx.cfsandbox : ctx.sandbox; 25 25 26 26 try {
+1 -1
apps/api/src/xrpc/io/pocketenv/sandbox/startSandbox.ts
··· 20 20 throw new XRPCError(404, "Sandbox not found", "SandboxNotFound"); 21 21 } 22 22 23 - let sandbox = 23 + const sandbox = 24 24 record.provider === Providers.CLOUDFLARE ? ctx.cfsandbox : ctx.sandbox; 25 25 26 26 await sandbox.post(`/v1/sandboxes/${params.id}/start`, undefined, {
+1 -1
apps/api/src/xrpc/io/pocketenv/sandbox/stopSandbox.ts
··· 20 20 throw new XRPCError(404, "Sandbox not found", "SandboxNotFound"); 21 21 } 22 22 23 - let sandbox = 23 + const sandbox = 24 24 record.provider === Providers.CLOUDFLARE ? ctx.cfsandbox : ctx.sandbox; 25 25 26 26 await sandbox.post(`/v1/sandboxes/${params.id}/stop`, undefined, {
+37 -5
apps/cf-sandbox/src/index.ts
··· 166 166 }); 167 167 168 168 app.get("/v1/sandboxes/:sandboxId", async (c) => { 169 - const record = await getSandboxById(c.var.db, c.req.param("sandboxId")); 169 + const { sandboxes: record } = await getSandboxById( 170 + c.var.db, 171 + c.req.param("sandboxId"), 172 + ); 170 173 return c.json(record); 171 174 }); 172 175 ··· 180 183 }); 181 184 182 185 app.post("/v1/sandboxes/:sandboxId/start", async (c) => { 183 - const record = await getSandboxById(c.var.db, c.req.param("sandboxId")); 186 + const { sandboxes: record } = await getSandboxById( 187 + c.var.db, 188 + c.req.param("sandboxId"), 189 + ); 184 190 185 191 if (!record) { 186 192 return c.json({ error: "Sandbox not found" }, 404); ··· 217 223 }); 218 224 219 225 app.post("/v1/sandboxes/:sandboxId/stop", async (c) => { 220 - const record = await getSandboxById(c.var.db, c.req.param("sandboxId")); 226 + const { sandboxes: record } = await getSandboxById( 227 + c.var.db, 228 + c.req.param("sandboxId"), 229 + ); 221 230 222 231 if (!record) { 223 232 return c.json({ error: "Sandbox not found" }, 404); ··· 253 262 }); 254 263 255 264 app.post("/v1/sandboxes/:sandboxId/runs", async (c) => { 256 - const record = await getSandboxById(c.var.db, c.req.param("sandboxId")); 265 + const { sandboxes: record } = await getSandboxById( 266 + c.var.db, 267 + c.req.param("sandboxId"), 268 + ); 257 269 258 270 if (!record) { 259 271 return c.json({ error: "Sandbox not found" }, 404); ··· 281 293 }); 282 294 283 295 app.delete("/v1/sandboxes/:sandboxId", async (c) => { 284 - const record = await getSandboxById(c.var.db, c.req.param("sandboxId")); 296 + const { sandboxes: record } = await getSandboxById( 297 + c.var.db, 298 + c.req.param("sandboxId"), 299 + ); 285 300 286 301 if (!record) { 287 302 return c.json({ error: "Sandbox not found" }, 404); ··· 317 332 if (c.req.header("upgrade")?.toLowerCase() !== "websocket") { 318 333 return c.text("Expected WebSocket connection", 426); 319 334 } 335 + const token = c.req.query("t"); 336 + if (token) { 337 + const decoded = await jwt.verify(token, process.env.JWT_SECRET!); 338 + 339 + const { sandboxes: record, users: user } = await getSandboxById( 340 + c.var.db, 341 + c.req.param("sandboxId"), 342 + ); 343 + if (!record) { 344 + return c.text("Sandbox not found", 404); 345 + } 346 + 347 + if (record.userId && user && user?.did !== decoded?.payload?.sub) { 348 + return c.text("Unauthorized", 403); 349 + } 350 + } 320 351 321 352 const sandbox = getSandbox(c.env.Sandbox, c.req.param("sandboxId")); 322 353 const sessionId = c.req.query("session"); ··· 337 368 const [record] = await db 338 369 .select() 339 370 .from(sandboxes) 371 + .leftJoin(users, eq(sandboxes.userId, users.id)) 340 372 .where(or(eq(sandboxes.id, sandboxId), eq(sandboxes.sandbox_id, sandboxId))) 341 373 .execute(); 342 374
+8
apps/web/src/api/terminal.ts
··· 1 + import { client } from "."; 2 + 3 + export const getTerminalToken = () => 4 + client.get<{ token: string }>(`/xrpc/io.pocketenv.actor.getTerminalToken`, { 5 + headers: { 6 + Authorization: `Bearer ${localStorage.getItem("token")}`, 7 + }, 8 + });
+11 -4
apps/web/src/components/terminal/CloudflareTerminal.tsx
··· 3 3 import { useXTerm } from "react-xtermjs"; 4 4 import { FitAddon } from "@xterm/addon-fit"; 5 5 import { SandboxAddon } from "@cloudflare/sandbox/xterm"; 6 - import { API_URL, CF_URL } from "../../consts"; 6 + import { CF_URL } from "../../consts"; 7 + import { useTerminalTokenQuery } from "../../hooks/useTerminal"; 7 8 8 9 const darkTheme = { 9 10 background: "#06051d", ··· 72 73 }: TerminalContentProps) { 73 74 const fitAddonRef = useRef<FitAddon | null>(null); 74 75 const sandboxAddonRef = useRef<SandboxAddon | null>(null); 76 + const { data: terminalToken, isLoading } = useTerminalTokenQuery(); 75 77 76 78 const theme = isDarkMode ? darkTheme : lightTheme; 77 79 ··· 94 96 const { ref, instance } = useXTerm({ options }); 95 97 96 98 useEffect(() => { 97 - if (!instance) return; 99 + if (!instance || isLoading) return; 98 100 99 101 const fitAddon = new FitAddon(); 100 102 fitAddonRef.current = fitAddon; ··· 104 106 getWebSocketUrl: ({ sandboxId: addonSandboxId, sessionId }) => { 105 107 const params = new URLSearchParams({}); 106 108 if (sessionId) params.set("session", sessionId); 107 - return `${CF_URL}/v1/sandboxes/${addonSandboxId}/ws/terminal`; 109 + if (terminalToken) params.set("t", terminalToken.token); 110 + const url = new URL( 111 + `${CF_URL}/v1/sandboxes/${addonSandboxId}/ws/terminal`, 112 + ); 113 + url.search = params.toString(); 114 + return url.toString(); 108 115 }, 109 116 onStateChange: (state, error) => { 110 117 if (error) { ··· 153 160 disposable?.dispose?.(); 154 161 sandboxAddonRef.current = null; 155 162 }; 156 - }, [instance, sandboxId, onClose]); 163 + }, [instance, sandboxId, onClose, isLoading]); 157 164 158 165 return ( 159 166 <div
+9
apps/web/src/hooks/useTerminal.ts
··· 1 + import { useQuery } from "@tanstack/react-query"; 2 + import { getTerminalToken } from "../api/terminal"; 3 + 4 + export const useTerminalTokenQuery = () => 5 + useQuery({ 6 + queryKey: ["terminalToken"], 7 + queryFn: () => getTerminalToken(), 8 + select: (response) => response.data, 9 + });