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 Turnstile CAPTCHA for sandbox creation

Server: add validateTurnstile and make authVerifier async to verify
x-challenge header using CF_SECRET_KEY; auth output now includes an
artifacts flag and createSandbox rejects requests without a valid
challenge (401).

Client: add react-turnstile and CF_SITE_KEY, render Turnstile in
NewProject, send X-Challenge header when creating sandboxes, and show
placeholders until a challenge token is obtained. Update deps and env.

+145 -13
+21 -3
apps/api/src/lib/authVerfifier.ts
··· 2 2 import type express from "express"; 3 3 import jwt from "jsonwebtoken"; 4 4 import { env } from "./env"; 5 + import validateTurnstile from "./turnstile"; 5 6 6 7 type ReqCtx = { 7 8 req: express.Request; 8 9 }; 9 10 10 - export default function authVerifier(ctx: ReqCtx): AuthOutput { 11 + export default async function authVerifier(ctx: ReqCtx): Promise<AuthOutput> { 12 + const challenge = ctx.req.headers["x-challenge"]?.toString(); 13 + let artifacts = false; 14 + 15 + if (challenge) { 16 + const ip: string = 17 + ctx.req.headers["cf-connecting-ip"]?.toString() || 18 + ctx.req.headers["x-forwarded-for"]?.toString() || 19 + "unknown"; 20 + const validation = await validateTurnstile(challenge, ip); 21 + artifacts = (validation as { success: boolean }).success; 22 + } 23 + 11 24 if (!ctx.req.headers.authorization) { 12 - return {}; 25 + return { 26 + artifacts, 27 + }; 13 28 } 14 29 15 30 const bearer = (ctx.req.headers.authorization || "").split(" ")[1]?.trim(); ··· 21 36 22 37 return { 23 38 credentials, 39 + artifacts, 24 40 }; 25 41 } 26 42 27 - return {}; 43 + return { 44 + artifacts, 45 + }; 28 46 }
+1
apps/api/src/lib/env.ts
··· 27 27 PRIVATE_KEY: str({}), 28 28 SANDBOX_API_URL: str({ default: "http://localhost:8788" }), 29 29 CF_SANDBOX_API_URL: str({ default: "http://localhost:8787" }), 30 + CF_SECRET_KEY: str({}), 30 31 });
+29
apps/api/src/lib/turnstile.ts
··· 1 + import { env } from "./env"; 2 + 3 + export default async function validateTurnstile( 4 + token: string, 5 + remoteip: string, 6 + ) { 7 + try { 8 + const response = await fetch( 9 + "https://challenges.cloudflare.com/turnstile/v0/siteverify", 10 + { 11 + method: "POST", 12 + headers: { 13 + "Content-Type": "application/json", 14 + }, 15 + body: JSON.stringify({ 16 + secret: env.CF_SECRET_KEY, 17 + response: token, 18 + remoteip: remoteip, 19 + }), 20 + }, 21 + ); 22 + 23 + const result = await response.json(); 24 + return result; 25 + } catch (error) { 26 + console.error("Turnstile validation error:", error); 27 + return { success: false, "error-codes": ["internal-error"] }; 28 + } 29 + }
+9
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) { 26 + throw new XRPCError( 27 + 401, 28 + "Authentication failed, invalid challenge", 29 + "AuthenticationError", 30 + ); 31 + } 32 + 24 33 const provider = input.body.provider || Providers.CLOUDFLARE; 25 34 const sandbox = 26 35 provider === Providers.CLOUDFLARE ? ctx.cfsandbox : ctx.sandbox;
+3
apps/web/bun.lock
··· 30 30 "react-content-loader": "^7.1.2", 31 31 "react-dom": "^19.2.0", 32 32 "react-hook-form": "^7.71.1", 33 + "react-turnstile": "^1.1.5", 33 34 "react-xtermjs": "^1.0.10", 34 35 "zod": "^4.3.6", 35 36 }, ··· 746 747 "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], 747 748 748 749 "react-hook-form": ["react-hook-form@7.71.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="], 750 + 751 + "react-turnstile": ["react-turnstile@1.1.5", "", { "peerDependencies": { "react": ">= 16.13.1", "react-dom": ">= 16.13.1" } }, "sha512-VTL5OeHAatzCEVQxAZox70/TPmhKxEbNgtr++dg+8zm9QrWKuoU9E0+7gqmycOSCDZuJFzvMMLKQb5PVUPLV6w=="], 749 752 750 753 "react-xtermjs": ["react-xtermjs@1.0.10", "", { "peerDependencies": { "@xterm/xterm": "^5.5.0" } }, "sha512-+xpKEKbmsypWzRKE0FR1LNIGcI8gx+R6VMHe8IQW7iTbgeqp3Qg7SbiVNOzR+Ovb1QK4DPA3KqsIQV+XP0iRUA=="], 751 754
+1
apps/web/package.json
··· 35 35 "react-content-loader": "^7.1.2", 36 36 "react-dom": "^19.2.0", 37 37 "react-hook-form": "^7.71.1", 38 + "react-turnstile": "^1.1.5", 38 39 "react-xtermjs": "^1.0.10", 39 40 "zod": "^4.3.6" 40 41 },
+3
apps/web/src/api/sandbox.ts
··· 5 5 export const createSandbox = ({ 6 6 base, 7 7 provider, 8 + challenge, 8 9 }: { 9 10 base: string; 10 11 provider: Provider; 12 + challenge: string | null; 11 13 }) => 12 14 client.post<Sandbox | undefined>( 13 15 "/xrpc/io.pocketenv.sandbox.createSandbox", ··· 18 20 { 19 21 headers: { 20 22 Authorization: `Bearer ${localStorage.getItem("token")}`, 23 + "X-Challenge": challenge ?? "", 21 24 }, 22 25 }, 23 26 );
+3
apps/web/src/components/contextmenu/ContextMenu.tsx
··· 73 73 setIsDeleteSandboxModalOpen(true); 74 74 }; 75 75 76 + // disable context menu temporarily 77 + return <></>; 78 + 76 79 return ( 77 80 <> 78 81 <div
+62 -4
apps/web/src/components/newproject/NewProject.tsx
··· 1 1 import { useEffect, useRef, useState } from "react"; 2 + import ContentLoader from "react-content-loader"; 2 3 import { 3 4 useCreateSandboxMutation, 4 5 useSandboxesQuery, 5 6 } from "../../hooks/useSandbox"; 6 7 import { useNavigate } from "@tanstack/react-router"; 8 + import { Turnstile, useTurnstile } from "react-turnstile"; 9 + import { CF_SITE_KEY } from "../../consts"; 7 10 8 11 export type NewProjectProps = { 9 12 isOpen: boolean; ··· 17 20 const { data, isLoading } = useSandboxesQuery(); 18 21 const navigate = useNavigate(); 19 22 const { mutateAsync } = useCreateSandboxMutation(); 23 + const turnstile = useTurnstile(); 24 + const [challenge, setChallenge] = useState<string | null>(null); 20 25 21 26 const sandboxes = data?.sandboxes.filter((sandbox) => 22 27 filter ··· 35 40 if (event.key === "Escape" && isOpen) { 36 41 onClose(); 37 42 setFilter(""); 43 + turnstile.reset(); 44 + setChallenge(null); 38 45 } 39 46 }; 40 47 ··· 42 49 return () => { 43 50 document.removeEventListener("keydown", handleEscapeKey); 44 51 }; 45 - }, [isOpen, onClose]); 52 + }, [isOpen, onClose, turnstile]); 46 53 47 54 const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => { 48 55 if (e.target === e.currentTarget) { 49 56 onClose(); 50 57 setFilter(""); 58 + turnstile.reset(); 59 + setChallenge(null); 51 60 } 52 61 }; 53 62 ··· 57 66 58 67 const onSelect = async (id: string) => { 59 68 setSelected(id); 60 - const res = await mutateAsync({ base: id, provider: "cloudflare" }); 69 + const res = await mutateAsync({ 70 + base: id, 71 + provider: "cloudflare", 72 + challenge, 73 + }); 61 74 await navigate({ 62 75 to: res.data?.uri 63 76 ? `/${res.data?.uri.split("at://")[1].replace("io.pocketenv.", "")}` ··· 66 79 setSelected(null); 67 80 onClose(); 68 81 setFilter(""); 82 + turnstile.reset(); 83 + setChallenge(null); 69 84 }; 70 85 71 86 return ( ··· 98 113 </div> 99 114 </div> 100 115 <div className="modal-body"> 116 + {isOpen && ( 117 + <Turnstile 118 + sitekey={CF_SITE_KEY} 119 + onVerify={(token) => { 120 + setChallenge(token); 121 + }} 122 + /> 123 + )} 101 124 {!isLoading && 125 + challenge && 102 126 sandboxes?.map((item) => ( 103 127 <div 104 128 key={item.id} ··· 109 133 {item.displayName} 110 134 </div> 111 135 {selected === item.uri && ( 112 - <span className="loading loading-spinner loadiing-md text-pink-500"></span> 136 + <span className="loading loading-spinner loading-md text-pink-500"></span> 113 137 )} 114 138 </div> 115 139 ))} 116 - {!isLoading && sandboxes?.length === 0 && ( 140 + {isLoading || 141 + (!challenge && ( 142 + <div className="flex flex-col gap-2 p-3"> 143 + {Array.from({ length: 6 }).map((_, i) => ( 144 + <ContentLoader 145 + key={i} 146 + speed={1.5} 147 + width="100%" 148 + height={44} 149 + backgroundColor="#ffffff10" 150 + foregroundColor="#ffffff20" 151 + > 152 + <rect 153 + x="0" 154 + y="10" 155 + rx="6" 156 + ry="6" 157 + width="40%" 158 + height="14" 159 + /> 160 + <rect 161 + x="0" 162 + y="30" 163 + rx="4" 164 + ry="4" 165 + width="25%" 166 + height="8" 167 + /> 168 + </ContentLoader> 169 + ))} 170 + </div> 171 + ))} 172 + {!isLoading && !challenge && sandboxes?.length === 0 && ( 117 173 <div className="p-3 text-center font-semibold opacity-70"> 118 174 No results found for <br /> 119 175 <b>{filter}</b> ··· 133 189 onClick={() => { 134 190 onClose(); 135 191 setFilter(""); 192 + turnstile.reset(); 193 + setChallenge(null); 136 194 }} 137 195 ></div> 138 196 )}
+2
apps/web/src/consts.ts
··· 2 2 export const WS_URL = import.meta.env.VITE_WS_URL || "ws://localhost:8790"; 3 3 export const POCKETENV_DID = "did:plc:aturpi2ls3yvsmhc6wybomun"; 4 4 export const CF_URL = import.meta.env.VITE_CF_URL || "http://localhost:8787"; 5 + export const CF_SITE_KEY = 6 + import.meta.env.VITE_CF_SITE_KEY || "1x00000000000000000000BB";
+5 -2
apps/web/src/hooks/useSandbox.ts
··· 40 40 export const useCreateSandboxMutation = () => 41 41 useMutation({ 42 42 mutationKey: ["createSandbox"], 43 - mutationFn: async (params: { base: string; provider: Provider }) => 44 - createSandbox(params), 43 + mutationFn: async (params: { 44 + base: string; 45 + provider: Provider; 46 + challenge: string | null; 47 + }) => createSandbox(params), 45 48 }); 46 49 47 50 export const useClaimSandboxMutation = () =>
+3 -3
apps/web/src/pages/projects/Project/TerminalModal/TerminalModal.tsx
··· 81 81 <div 82 82 className={`overlay-animation-target modal-dialog overlay-open:duration-300 transition-all ease-out ${ 83 83 isFullscreen 84 - ? "fixed inset-0 !m-0 !max-w-none !w-screen !h-screen !rounded-none" 84 + ? "fixed inset-0 m-0! max-w-none! w-screen! h-screen! rounded-none!" 85 85 : "modal-dialog-xl overlay-open:mt-4 mt-12" 86 86 }`} 87 87 onClick={handleContentClick} ··· 89 89 style={isFullscreen ? { maxHeight: "100vh" } : undefined} 90 90 > 91 91 <div 92 - className={`modal-content ${isFullscreen ? "!rounded-none h-full" : ""}`} 92 + className={`modal-content ${isFullscreen ? "rounded-none! h-full!" : ""}`} 93 93 > 94 94 <div className="modal-header"> 95 95 <div className="flex-1 text-center">{title}</div> ··· 122 122 className="modal-body p-0 pl-2 overflow-y-hidden" 123 123 style={ 124 124 isFullscreen 125 - ? { height: "calc(100vh - 56px)" } 125 + ? { height: "100vh", paddingBottom: 20 } 126 126 : { height: "60vh" } 127 127 } 128 128 >
+3 -1
apps/web/src/pages/sandbox/Sandbox.tsx
··· 118 118 Sandbox 119 119 </span> 120 120 {((profile && data?.sandbox?.owner?.did === profile.did) || 121 - !data?.sandbox?.owner) && <ContextMenu />} 121 + !data?.sandbox?.owner) && ( 122 + <ContextMenu sandboxId={data?.sandbox?.id} /> 123 + )} 122 124 </div> 123 125 124 126 <div className="w-[50%] overflow-x-auto mt-5 ml-[-18px]">