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 new sandbox flow, repo expansion, and VSCode

Refactor backend exposeVscode: allow unauthenticated queries for public
sandboxes (isNull userId) and defer AT Protocol agent creation until a
sandbox has an at:// uri.

Add GitLab repo expansion (CLI + web hook), a useExpandRepo hook and a
/new page to create sandboxes from repo URLs. Add client/mutation and UI
button to expose a VS Code preview and open the returned preview URL.

+189 -19
+21 -19
apps/api/src/xrpc/io/pocketenv/sandbox/exposeVscode.ts
··· 3 3 import { consola } from "consola"; 4 4 import { Providers, VSCODE_PORT } from "consts"; 5 5 import type { Context } from "context"; 6 - import { and, eq, or } from "drizzle-orm"; 6 + import { and, eq, isNull, or } from "drizzle-orm"; 7 7 import type { Server } from "lexicon"; 8 8 import type { 9 9 QueryParams, ··· 15 15 16 16 export default function (server: Server, ctx: Context) { 17 17 const exposeVscode = async (params: QueryParams, auth: HandlerAuth) => { 18 - if (!auth.credentials) { 19 - throw new XRPCError(401, "Unauthorized"); 20 - } 21 - 22 - const agent = await createAgent(ctx.oauthClient, auth.credentials.did); 23 - if (!agent) { 24 - consola.error( 25 - "Failed to create AT Protocol agent for DID:", 26 - auth.credentials.did, 27 - ); 28 - throw new XRPCError( 29 - 500, 30 - "Failed to create AT Protocol agent", 31 - "AgentCreationError", 32 - ); 33 - } 34 - 35 18 return ctx.db.transaction(async (tx) => { 36 19 const [record] = await tx 37 20 .select() ··· 44 27 eq(schema.sandboxes.uri, params.id), 45 28 eq(schema.sandboxes.name, params.id), 46 29 ), 47 - eq(schema.users.did, auth.credentials.did), 30 + auth.credentials 31 + ? eq(schema.users.did, auth.credentials.did) 32 + : isNull(schema.sandboxes.userId), 48 33 ), 49 34 ) 50 35 .execute(); ··· 86 71 const ports = records.map((r) => r.exposedPort); 87 72 88 73 if (record.sandboxes.uri) { 74 + if (!auth.credentials) { 75 + throw new XRPCError(401, "Unauthorized"); 76 + } 77 + 78 + const agent = await createAgent(ctx.oauthClient, auth.credentials.did); 79 + if (!agent) { 80 + consola.error( 81 + "Failed to create AT Protocol agent for DID:", 82 + auth.credentials.did, 83 + ); 84 + throw new XRPCError( 85 + 500, 86 + "Failed to create AT Protocol agent", 87 + "AgentCreationError", 88 + ); 89 + } 90 + 89 91 updateSandbox(agent, { 90 92 rkey: record.sandboxes.uri.split("/").pop()!, 91 93 ports,
+3
apps/cli/src/lib/expandRepo.ts
··· 5 5 const tangledMatch = repo.match(/^tangled:([^/]+\/[^/]+)$/); 6 6 if (tangledMatch) return `https://tangled.org/${tangledMatch[1]}`; 7 7 8 + const gitlabMatch = repo.match(/^gitlab:([^/]+\/[^/]+)$/); 9 + if (gitlabMatch) return `https://gitlab.com/${gitlabMatch[1]}`; 10 + 8 11 return repo; 9 12 }
+17
apps/web/src/api/sandbox.ts
··· 7 7 base, 8 8 provider, 9 9 challenge, 10 + repo, 10 11 }: { 11 12 base: string; 12 13 provider: Provider; 13 14 challenge: string | null; 15 + repo?: string; 14 16 }) => 15 17 client.post<Sandbox | undefined>( 16 18 "/xrpc/io.pocketenv.sandbox.createSandbox", 17 19 { 18 20 base, 19 21 provider, 22 + repo, 20 23 }, 21 24 { 22 25 headers: { ··· 165 168 Authorization: `Bearer ${localStorage.getItem("token")}`, 166 169 }, 167 170 }); 171 + 172 + export const exposeVscode = (id: string) => 173 + client.post<{ previewUrl?: string }>( 174 + `/xrpc/io.pocketenv.sandbox.exposeVscode`, 175 + undefined, 176 + { 177 + params: { 178 + id, 179 + }, 180 + headers: { 181 + Authorization: `Bearer ${localStorage.getItem("token")}`, 182 + }, 183 + }, 184 + );
+14
apps/web/src/hooks/useExpandRepo.ts
··· 1 + export const useExpandRepo = (repo?: string): string => { 2 + if (!repo) return ""; 3 + 4 + const githubMatch = repo.match(/^github:([^/]+\/[^/]+)$/); 5 + if (githubMatch) return `https://github.com/${githubMatch[1]}`; 6 + 7 + const tangledMatch = repo.match(/^tangled:([^/]+\/[^/]+)$/); 8 + if (tangledMatch) return `https://tangled.org/${tangledMatch[1]}`; 9 + 10 + const gitlabMatch = repo.match(/^gitlab:([^/]+\/[^/]+)$/); 11 + if (gitlabMatch) return `https://gitlab.com/${gitlabMatch[1]}`; 12 + 13 + return repo; 14 + };
+9
apps/web/src/hooks/useSandbox.ts
··· 11 11 getExposedPorts, 12 12 exposePort, 13 13 unexposePort, 14 + exposeVscode, 14 15 } from "../api/sandbox"; 15 16 import type { Provider } from "../types/providers"; 16 17 ··· 47 48 base: string; 48 49 provider: Provider; 49 50 challenge: string | null; 51 + repo?: string; 50 52 }) => createSandbox(params), 51 53 }); 52 54 ··· 115 117 }, 116 118 }); 117 119 }; 120 + 121 + export const useExposeVscodeMutation = () => { 122 + return useMutation({ 123 + mutationKey: ["exposeVscode"], 124 + mutationFn: async (id: string) => exposeVscode(id), 125 + }); 126 + };
+55
apps/web/src/pages/new/New.tsx
··· 1 + import Turnstile, { useTurnstile } from "react-turnstile"; 2 + import { useExpandRepo } from "../../hooks/useExpandRepo"; 3 + import { useCreateSandboxMutation } from "../../hooks/useSandbox"; 4 + import { Route } from "../../routes/new"; 5 + import { useEffect, useState } from "react"; 6 + import { CF_SITE_KEY } from "../../consts"; 7 + import { useNavigate } from "@tanstack/react-router"; 8 + 9 + function New() { 10 + const navigate = useNavigate(); 11 + const [challenge, setChallenge] = useState<string | null>(null); 12 + const { repo, base } = Route.useSearch(); 13 + const repoUrl = useExpandRepo(repo); 14 + const { mutateAsync } = useCreateSandboxMutation(); 15 + const turnstile = useTurnstile(); 16 + 17 + useEffect(() => { 18 + if (!repoUrl || !challenge) return; 19 + 20 + const createSandbox = async () => { 21 + const res = await mutateAsync({ 22 + base, 23 + provider: "cloudflare", 24 + challenge, 25 + repo: repoUrl, 26 + }); 27 + await navigate({ 28 + to: res.data?.uri 29 + ? `/${res.data?.uri.split("at://")[1].replace("io.pocketenv.", "")}` 30 + : `/sandbox/${res.data?.id}`, 31 + }); 32 + }; 33 + 34 + createSandbox(); 35 + return () => { 36 + turnstile.reset(); 37 + }; 38 + }, [repoUrl, base, mutateAsync, turnstile, challenge, navigate]); 39 + 40 + return ( 41 + <> 42 + <div className="flex flex-col min-h-screen bg-base-100 items-center justify-center"> 43 + <span className="loading loading-spinner loading-xl mr-1.5 text-teal-300"></span> 44 + <Turnstile 45 + sitekey={CF_SITE_KEY} 46 + onVerify={(token) => { 47 + setChallenge(token); 48 + }} 49 + /> 50 + </div> 51 + </> 52 + ); 53 + } 54 + 55 + export default New;
+3
apps/web/src/pages/new/index.tsx
··· 1 + import New from "./New"; 2 + 3 + export default New;
+33
apps/web/src/pages/sandbox/Sandbox.tsx
··· 4 4 import { useLocation, useNavigate } from "@tanstack/react-router"; 5 5 import { 6 6 useClaimSandboxMutation, 7 + useExposeVscodeMutation, 7 8 useSandboxQuery, 8 9 useStartSandboxMutation, 9 10 useStopSandboxMutation, ··· 21 22 22 23 function New() { 23 24 const profile = useAtomValue(profileAtom); 25 + const { mutateAsync: exposeVscode, isPending } = useExposeVscodeMutation(); 24 26 const queryClient = useQueryClient(); 25 27 const [displayLoading, setDisplayLoading] = useState(false); 26 28 const [loadingClaim, setLoadingClaim] = useState(false); ··· 117 119 <span className="badge bg-white/15 rounded-full text-white/80 border-none mr-1"> 118 120 Sandbox 119 121 </span> 122 + <button 123 + className="ml-[5px] flex items-center" 124 + disabled={ 125 + isPending || 126 + !data?.sandbox?.status || 127 + data.sandbox.status !== "RUNNING" 128 + } 129 + onClick={async () => { 130 + if (isPending) return; 131 + 132 + const response = await exposeVscode(data!.sandbox!.id!); 133 + if (response.data?.previewUrl) { 134 + window.open(response.data.previewUrl, "_blank"); 135 + } 136 + }} 137 + > 138 + {!isPending && ( 139 + <span 140 + className={`icon-[proicons--visual-studio-code] size-5.5 btn-text ${ 141 + isPending || 142 + !data?.sandbox?.status || 143 + data.sandbox.status !== "RUNNING" 144 + ? "opacity-50" 145 + : "hover:text-white" 146 + }`} 147 + ></span> 148 + )} 149 + {isPending && ( 150 + <span className="loading loading-spinner loading-sm btn-text" /> 151 + )} 152 + </button> 120 153 {((profile && data?.sandbox?.owner?.did === profile.did) || 121 154 !data?.sandbox?.owner) && ( 122 155 <ContextMenu
+21
apps/web/src/routeTree.gen.ts
··· 15 15 import { Route as SettingsRouteImport } from './routes/settings' 16 16 import { Route as SecretsRouteImport } from './routes/secrets' 17 17 import { Route as ProjectsRouteImport } from './routes/projects' 18 + import { Route as NewRouteImport } from './routes/new' 18 19 import { Route as IndexRouteImport } from './routes/index' 19 20 import { Route as SandboxIdRouteImport } from './routes/sandbox/$id' 20 21 import { Route as DidSandboxRkeyRouteImport } from './routes/$did.sandbox.$rkey' ··· 58 59 const ProjectsRoute = ProjectsRouteImport.update({ 59 60 id: '/projects', 60 61 path: '/projects', 62 + getParentRoute: () => rootRouteImport, 63 + } as any) 64 + const NewRoute = NewRouteImport.update({ 65 + id: '/new', 66 + path: '/new', 61 67 getParentRoute: () => rootRouteImport, 62 68 } as any) 63 69 const IndexRoute = IndexRouteImport.update({ ··· 135 141 136 142 export interface FileRoutesByFullPath { 137 143 '/': typeof IndexRoute 144 + '/new': typeof NewRoute 138 145 '/projects': typeof ProjectsRoute 139 146 '/secrets': typeof SecretsRoute 140 147 '/settings': typeof SettingsRoute ··· 157 164 } 158 165 export interface FileRoutesByTo { 159 166 '/': typeof IndexRoute 167 + '/new': typeof NewRoute 160 168 '/projects': typeof ProjectsRoute 161 169 '/secrets': typeof SecretsRoute 162 170 '/settings': typeof SettingsRoute ··· 179 187 export interface FileRoutesById { 180 188 __root__: typeof rootRouteImport 181 189 '/': typeof IndexRoute 190 + '/new': typeof NewRoute 182 191 '/projects': typeof ProjectsRoute 183 192 '/secrets': typeof SecretsRoute 184 193 '/settings': typeof SettingsRoute ··· 203 212 fileRoutesByFullPath: FileRoutesByFullPath 204 213 fullPaths: 205 214 | '/' 215 + | '/new' 206 216 | '/projects' 207 217 | '/secrets' 208 218 | '/settings' ··· 225 235 fileRoutesByTo: FileRoutesByTo 226 236 to: 227 237 | '/' 238 + | '/new' 228 239 | '/projects' 229 240 | '/secrets' 230 241 | '/settings' ··· 246 257 id: 247 258 | '__root__' 248 259 | '/' 260 + | '/new' 249 261 | '/projects' 250 262 | '/secrets' 251 263 | '/settings' ··· 269 281 } 270 282 export interface RootRouteChildren { 271 283 IndexRoute: typeof IndexRoute 284 + NewRoute: typeof NewRoute 272 285 ProjectsRoute: typeof ProjectsRoute 273 286 SecretsRoute: typeof SecretsRoute 274 287 SettingsRoute: typeof SettingsRoute ··· 321 334 path: '/projects' 322 335 fullPath: '/projects' 323 336 preLoaderRoute: typeof ProjectsRouteImport 337 + parentRoute: typeof rootRouteImport 338 + } 339 + '/new': { 340 + id: '/new' 341 + path: '/new' 342 + fullPath: '/new' 343 + preLoaderRoute: typeof NewRouteImport 324 344 parentRoute: typeof rootRouteImport 325 345 } 326 346 '/': { ··· 458 478 459 479 const rootRouteChildren: RootRouteChildren = { 460 480 IndexRoute: IndexRoute, 481 + NewRoute: NewRoute, 461 482 ProjectsRoute: ProjectsRoute, 462 483 SecretsRoute: SecretsRoute, 463 484 SettingsRoute: SettingsRoute,
+13
apps/web/src/routes/new.tsx
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + import NewPage from "../pages/new"; 3 + import z from "zod"; 4 + 5 + const newProjectSchema = z.object({ 6 + repo: z.string().optional(), 7 + base: z.string().default("docker"), 8 + }); 9 + 10 + export const Route = createFileRoute("/new")({ 11 + validateSearch: (search) => newProjectSchema.parse(search), 12 + component: NewPage, 13 + });