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.

Handle sandbox provider preferences and fix DB queries

Use canonical sandboxes.id for lookups and deletions, selecting the
record before removing provider auth entries. Wrap provider save
transaction with try/catch, log failures and throw an XRPCError on
failure. Return provider prefs with explicit $type in the API.

Add preferences API and React hooks; load/save provider preferences in
the settings UI, seal API keys client-side (and store a redacted copy),
and show success/error notifications on save.

+172 -50
+22 -7
apps/api/src/xrpc/io/pocketenv/sandbox/getPreferences.ts
··· 31 31 } 32 32 33 33 const sandboxFilter = or( 34 - eq(sandboxes.sandboxId, params.id), 34 + eq(sandboxes.id, params.id), 35 35 eq(sandboxes.uri, params.id), 36 36 eq(sandboxes.name, params.id), 37 37 ); ··· 66 66 .execute() 67 67 .then(([row]) => row?.vercel_auth), 68 68 ]); 69 + console.log(daytona, deno, sprite, vercel); 69 70 70 71 if (!daytona && !deno && !sprite && !vercel) { 71 72 return []; 72 73 } 73 74 74 - const provider = ( 75 - (daytona && { name: "daytona" as const, redactedApiKey: daytona.redactedApiKey }) || 76 - (deno && { name: "deno" as const, redactedApiKey: deno.redactedDenoToken }) || 77 - (sprite && { name: "sprites" as const, redactedApiKey: sprite.redactedSpriteToken }) || 78 - (vercel && { name: "vercel" as const, redactedApiKey: vercel.redactedVercelToken }) 79 - )!; 75 + const provider = ((daytona && { 76 + $type: "io.pocketenv.sandbox.defs#sandboxProviderPref" as const, 77 + name: "daytona" as const, 78 + redactedApiKey: daytona.redactedApiKey, 79 + }) || 80 + (deno && { 81 + $type: "io.pocketenv.sandbox.defs#sandboxProviderPref" as const, 82 + name: "deno" as const, 83 + redactedApiKey: deno.redactedDenoToken, 84 + }) || 85 + (sprite && { 86 + $type: "io.pocketenv.sandbox.defs#sandboxProviderPref" as const, 87 + name: "sprites" as const, 88 + redactedApiKey: sprite.redactedSpriteToken, 89 + }) || 90 + (vercel && { 91 + $type: "io.pocketenv.sandbox.defs#sandboxProviderPref" as const, 92 + name: "vercel" as const, 93 + redactedApiKey: vercel.redactedVercelToken, 94 + }))!; 80 95 81 96 return [provider satisfies SandboxProviderPref]; 82 97 };
+60 -22
apps/api/src/xrpc/io/pocketenv/sandbox/putPreferences.ts
··· 80 80 ); 81 81 } 82 82 if (isSandboxProviderPref(pref)) { 83 - await ctx.db.transaction(async (tx) => { 84 - await tx 85 - .update(sandboxes) 86 - .set({ provider: pref.name }) 87 - .where( 88 - and( 89 - eq(sandboxes.id, input.body.sandboxId), 90 - eq(sandboxes.userId, user.id), 91 - ), 92 - ) 93 - .execute(); 94 - await saveSandboxProvider(tx, user, input, pref); 95 - }); 83 + try { 84 + await ctx.db.transaction(async (tx) => { 85 + await tx 86 + .update(sandboxes) 87 + .set({ provider: pref.name }) 88 + .where( 89 + and( 90 + eq(sandboxes.id, input.body.sandboxId), 91 + eq(sandboxes.userId, user.id), 92 + ), 93 + ) 94 + .execute(); 95 + await saveSandboxProvider(tx, user, input, pref); 96 + }); 97 + } catch (err) { 98 + consola.error("Failed to save sandbox provider preferences:", err); 99 + throw new XRPCError( 100 + 500, 101 + "Failed to save sandbox provider preferences", 102 + "SavePreferencesError", 103 + ); 104 + } 96 105 } 97 106 } 98 107 return {}; ··· 189 198 .execute(); 190 199 break; 191 200 case "cloudflare": 192 - const sandboxFilter = or( 193 - eq(sandboxes.sandboxId, input.body.sandboxId), 194 - eq(sandboxes.uri, input.body.sandboxId), 195 - eq(sandboxes.name, input.body.sandboxId), 196 - ); 201 + const [record] = await tx 202 + .select() 203 + .from(sandboxes) 204 + .where( 205 + and( 206 + or( 207 + eq(sandboxes.id, input.body.sandboxId), 208 + eq(sandboxes.name, input.body.sandboxId), 209 + eq(sandboxes.uri, input.body.sandboxId), 210 + ), 211 + eq(sandboxes.userId, user.id), 212 + ), 213 + ) 214 + .execute(); 197 215 await Promise.all([ 198 216 tx 199 217 .delete(daytonaAuth) 200 - .where(and(eq(daytonaAuth.userId, user.id), sandboxFilter)) 218 + .where( 219 + and( 220 + eq(daytonaAuth.userId, user.id), 221 + eq(daytonaAuth.sandboxId, record!.id), 222 + ), 223 + ) 201 224 .execute(), 202 225 tx 203 226 .delete(denoAuth) 204 - .where(and(eq(denoAuth.userId, user.id), sandboxFilter)) 227 + .where( 228 + and( 229 + eq(denoAuth.userId, user.id), 230 + eq(denoAuth.sandboxId, record!.id), 231 + ), 232 + ) 205 233 .execute(), 206 234 tx 207 235 .delete(vercelAuth) 208 - .where(and(eq(vercelAuth.userId, user.id), sandboxFilter)) 236 + .where( 237 + and( 238 + eq(vercelAuth.userId, user.id), 239 + eq(vercelAuth.sandboxId, record!.id), 240 + ), 241 + ) 209 242 .execute(), 210 243 tx 211 244 .delete(spriteAuth) 212 - .where(and(eq(spriteAuth.userId, user.id), sandboxFilter)) 245 + .where( 246 + and( 247 + eq(spriteAuth.userId, user.id), 248 + eq(spriteAuth.sandboxId, record!.id), 249 + ), 250 + ) 213 251 .execute(), 214 252 ]); 215 253 break;
+5 -8
apps/web/src/api/preferences.ts
··· 13 13 ); 14 14 15 15 export const getPreferences = (sandboxId: string) => 16 - client.get<{ preferences: Preference[] }>( 17 - "/xrpc/io.pocketenv.sandbox.getPreferences", 18 - { 19 - params: { id: sandboxId }, 20 - headers: { 21 - Authorization: `Bearer ${localStorage.getItem("token")}`, 22 - }, 16 + client.get<Preference[]>("/xrpc/io.pocketenv.sandbox.getPreferences", { 17 + params: { id: sandboxId }, 18 + headers: { 19 + Authorization: `Bearer ${localStorage.getItem("token")}`, 23 20 }, 24 - ); 21 + });
-7
apps/web/src/api/sandbox.ts
··· 115 115 }, 116 116 ); 117 117 118 - export const getPreferences = () => 119 - client.get(`/xrpc/io.pocketenv.sandbox.getPreferences`, { 120 - headers: { 121 - Authorization: `Bearer ${localStorage.getItem("token")}`, 122 - }, 123 - }); 124 - 125 118 export const exposePort = ( 126 119 id: string, 127 120 {
+19 -4
apps/web/src/hooks/usePreferences.ts
··· 1 - import { useMutation } from "@tanstack/react-query"; 2 - import { putPreferences } from "../api/preferences"; 1 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 2 + import { getPreferences, putPreferences } from "../api/preferences"; 3 3 import type { Preference } from "../types/preferences"; 4 4 5 - export const useUpdatePreferencesMutation = () => 6 - useMutation({ 5 + export const useUpdatePreferencesMutation = () => { 6 + const queryClient = useQueryClient(); 7 + return useMutation({ 7 8 mutationKey: ["updatePreferences"], 8 9 mutationFn: async (params: { 9 10 sandboxId: string; 10 11 preferences: Preference[]; 11 12 }) => putPreferences(params.sandboxId, params.preferences), 13 + onSuccess: (_, variables) => { 14 + queryClient.invalidateQueries({ 15 + queryKey: ["preferences", variables.sandboxId], 16 + }); 17 + }, 18 + }); 19 + }; 20 + 21 + export const usePreferences = (sandboxId: string) => 22 + useQuery({ 23 + queryKey: ["preferences", sandboxId], 24 + queryFn: async () => getPreferences(sandboxId), 25 + select: (response) => response.data, 26 + enabled: !!sandboxId, 12 27 });
+66 -2
apps/web/src/pages/settings/provider/Provider.tsx
··· 1 1 import { useRouterState } from "@tanstack/react-router"; 2 2 import { zodResolver } from "@hookform/resolvers/zod"; 3 + import { useEffect } from "react"; 3 4 import { useForm, useWatch } from "react-hook-form"; 4 5 import { z } from "zod"; 6 + import { useQueryClient } from "@tanstack/react-query"; 5 7 import { useSandboxQuery } from "../../../hooks/useSandbox"; 8 + import { 9 + usePreferences, 10 + useUpdatePreferencesMutation, 11 + } from "../../../hooks/usePreferences"; 12 + import type { SandboxProvider } from "../../../types/preferences"; 13 + import { useSodium } from "../../../hooks/useSodium"; 14 + import { PUBLIC_KEY } from "../../../consts"; 15 + import { useNotyf } from "../../../hooks/useNotyf"; 6 16 import Main from "../../../layouts/Main"; 7 17 import Sidebar from "../sidebar/Sidebar"; 8 18 ··· 38 48 const { data } = useSandboxQuery( 39 49 `at:/${pathname.replace("/provider", "").replace("sandbox", "io.pocketenv.sandbox")}`, 40 50 ); 51 + 52 + const notyf = useNotyf(); 53 + const queryClient = useQueryClient(); 54 + const sodium = useSodium(); 55 + const sandboxId = data?.sandbox?.id ?? ""; 56 + const { data: preferences } = usePreferences(sandboxId); 57 + const { mutateAsync: updatePreferences } = useUpdatePreferencesMutation(); 41 58 42 59 const { 43 60 register, 44 61 handleSubmit, 45 62 control, 63 + setValue, 46 64 formState: { errors }, 47 65 } = useForm<FormValues>({ 48 66 resolver: zodResolver(schema), 49 67 defaultValues: { provider: "cloudflare", apiKey: "" }, 50 68 }); 51 69 70 + useEffect(() => { 71 + if (!preferences) return; 72 + const providerPref = preferences.find( 73 + (p): p is SandboxProvider => 74 + p.$type === "io.pocketenv.sandbox.defs#sandboxProviderPref", 75 + ); 76 + if (providerPref) { 77 + setValue("provider", providerPref.name as FormValues["provider"]); 78 + setValue("apiKey", providerPref.redactedApiKey ?? ""); 79 + } 80 + }, [preferences, setValue]); 81 + 52 82 const provider = useWatch({ control, name: "provider" }) as Provider; 53 83 54 - const onSubmit = (values: FormValues) => { 55 - console.log(values); 84 + const onSubmit = async (values: FormValues) => { 85 + const pref: SandboxProvider = { 86 + $type: "io.pocketenv.sandbox.defs#sandboxProviderPref", 87 + name: values.provider, 88 + }; 89 + 90 + if (values.apiKey?.includes("**") && values.provider !== "cloudflare") { 91 + return; 92 + } 93 + 94 + if (values.apiKey && !values.apiKey.includes("**")) { 95 + const sealed = sodium.cryptoBoxSeal( 96 + sodium.fromString(values.apiKey), 97 + sodium.fromHex(PUBLIC_KEY), 98 + ); 99 + pref.apiKey = sodium.toBase64( 100 + sealed, 101 + sodium.base64Variants.URLSAFE_NO_PADDING, 102 + ); 103 + pref.redactedApiKey = 104 + values.apiKey.length > 14 105 + ? values.apiKey.slice(0, 11) + 106 + "*".repeat(24) + 107 + values.apiKey.slice(-3) 108 + : values.apiKey; 109 + } 110 + 111 + try { 112 + await updatePreferences({ sandboxId, preferences: [pref] }); 113 + await queryClient.invalidateQueries({ 114 + queryKey: ["preferences", sandboxId], 115 + }); 116 + notyf.open("primary", "Provider saved successfully!"); 117 + } catch { 118 + notyf.open("error", "Failed to save provider."); 119 + } 56 120 }; 57 121 58 122 return (