Skip to content

Commit d22b836

Browse files
authored
refactor(sandbox): move sandbox state management server-side (ENG-4287) (#381)
1 parent 374ca7a commit d22b836

19 files changed

Lines changed: 761 additions & 217 deletions

File tree

src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/filesystem/page.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { COOKIE_KEYS } from '@/configs/cookies'
44
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
55
import { getAuthContext } from '@/core/server/auth'
66
import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug'
7-
import { createSandboxManagementAuth } from '@/core/shared/sandbox-management-auth.server'
87
import SandboxInspectView from '@/features/dashboard/sandbox/inspect/view'
98

109
const DEFAULT_ROOT_PATH = '/home/user'
@@ -41,13 +40,5 @@ export default async function SandboxInspectPage({
4140
cookieStore.get(COOKIE_KEYS.SANDBOX_INSPECT_ROOT_PATH)?.value ||
4241
DEFAULT_ROOT_PATH
4342

44-
return (
45-
<SandboxInspectView
46-
rootPath={rootPath}
47-
sandboxManagementAuth={createSandboxManagementAuth(
48-
authContext,
49-
teamId.data
50-
)}
51-
/>
52-
)
43+
return <SandboxInspectView rootPath={rootPath} />
5344
}

src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { redirect } from 'next/navigation'
22
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
33
import { getAuthContext } from '@/core/server/auth'
44
import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug'
5-
import { createSandboxManagementAuth } from '@/core/shared/sandbox-management-auth.server'
65
import SandboxTerminalView from '@/features/dashboard/sandbox/terminal/view'
76

87
interface SandboxTerminalPageProps {
@@ -36,13 +35,5 @@ export default async function SandboxTerminalPage({
3635
redirect(PROTECTED_URLS.DASHBOARD)
3736
}
3837

39-
return (
40-
<SandboxTerminalView
41-
command={command}
42-
sandboxManagementAuth={createSandboxManagementAuth(
43-
authContext,
44-
teamId.data
45-
)}
46-
/>
47-
)
38+
return <SandboxTerminalView command={command} userId={authContext.user.id} />
4839
}

src/app/dashboard/[teamSlug]/terminal/page.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
import { getAuthContext } from '@/core/server/auth'
1111
import { infra } from '@/core/shared/clients/api'
1212
import type { components as InfraComponents } from '@/core/shared/contracts/infra-api.types'
13-
import { createSandboxManagementAuth } from '@/core/shared/sandbox-management-auth.server'
1413
import { SandboxIdSchema } from '@/core/shared/schemas/api'
1514
import DashboardTerminal from '@/features/dashboard/terminal/dashboard-terminal'
1615
import { normalizeTerminalTemplate } from '@/features/dashboard/terminal/template'
@@ -132,11 +131,8 @@ export default async function TeamTerminalPage({
132131
sandboxId: terminalSandboxId,
133132
template: terminalTemplate,
134133
}}
135-
sandboxManagementAuth={createSandboxManagementAuth(
136-
authContext,
137-
team.id
138-
)}
139134
teamSlug={team.slug}
135+
userId={authContext.user.id}
140136
/>
141137
</div>
142138
)

src/core/server/api/routers/sandbox.ts

Lines changed: 203 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { TRPCError } from '@trpc/server'
12
import { millisecondsInDay } from 'date-fns/constants'
2-
import { Sandbox } from 'e2b'
3+
import { Sandbox, TimeoutError } from 'e2b'
34
import { z } from 'zod'
45
import { authHeaders } from '@/configs/api'
56
import {
@@ -17,8 +18,10 @@ import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/r
1718
import { createTRPCRouter } from '@/core/server/trpc/init'
1819
import { protectedTeamProcedure } from '@/core/server/trpc/procedures'
1920
import { SandboxIdSchema } from '@/core/shared/schemas/api'
21+
import { SANDBOX_RESUME_TIMEOUT_MS } from '@/features/dashboard/sandbox/inspect/constants'
2022
import { SANDBOX_MONITORING_METRICS_RETENTION_MS } from '@/features/dashboard/sandbox/monitoring/utils/constants'
2123
import { TERMINAL_SANDBOX_TIMEOUT_MS } from '@/features/dashboard/terminal/constants'
24+
import { normalizeTerminalTemplate } from '@/features/dashboard/terminal/template'
2225

2326
const sandboxRepositoryProcedure = protectedTeamProcedure.use(
2427
withTeamAuthedRequestRepository(
@@ -209,6 +212,205 @@ export const sandboxRouter = createTRPCRouter({
209212

210213
// MUTATIONS
211214

215+
// Runs the control-plane create/connect server-side so the user's
216+
// account-level access token never reaches the browser. Returns only the
217+
// sandbox-scoped envd credentials the client needs for PTY access.
218+
openTerminal: protectedTeamProcedure
219+
.input(
220+
z.object({
221+
template: z.string().min(1, 'Template is required'),
222+
sandboxId: SandboxIdSchema.optional(),
223+
requestTimeoutMs: z.number().int().positive().optional(),
224+
})
225+
)
226+
.mutation(async ({ ctx, input }) => {
227+
const { sandboxId, template, requestTimeoutMs } = input
228+
const { session, teamId } = ctx
229+
230+
const normalizedTemplate = normalizeTerminalTemplate(template)
231+
if (!normalizedTemplate) {
232+
throw new TRPCError({
233+
code: 'BAD_REQUEST',
234+
message: 'Invalid terminal template',
235+
})
236+
}
237+
238+
const connectionOpts = {
239+
apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL,
240+
domain: process.env.NEXT_PUBLIC_E2B_DOMAIN,
241+
sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL,
242+
headers: authHeaders(session.access_token, teamId),
243+
}
244+
245+
try {
246+
let resolvedSandboxId: string
247+
if (sandboxId) {
248+
const sandbox = await Sandbox.connect(sandboxId, {
249+
...connectionOpts,
250+
timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS,
251+
requestTimeoutMs,
252+
})
253+
resolvedSandboxId = sandbox.sandboxId
254+
} else {
255+
const sandbox = await Sandbox.create(normalizedTemplate, {
256+
...connectionOpts,
257+
timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS,
258+
requestTimeoutMs,
259+
lifecycle: {
260+
onTimeout: 'pause',
261+
autoResume: true,
262+
},
263+
metadata: {
264+
source: 'dashboard-terminal',
265+
template: normalizedTemplate,
266+
userId: session.user.id,
267+
},
268+
})
269+
resolvedSandboxId = sandbox.sandboxId
270+
}
271+
272+
// `Sandbox.create`/`connect` build a full SDK instance but only expose
273+
// the sandbox id/domain publicly; fetch the envd credentials via the
274+
// public info endpoint rather than reading the SDK's internal fields.
275+
// Kept inside this try (with the same requestTimeoutMs) so a stalled
276+
// GET times out promptly and is normalized like the connect timeout.
277+
const info = await Sandbox.getFullInfo(resolvedSandboxId, {
278+
...connectionOpts,
279+
requestTimeoutMs,
280+
})
281+
282+
// `envdAccessToken` is absent for `secure: false` sandboxes, whose envd
283+
// is reachable without the `X-Access-Token` header — pass it through
284+
// as-is rather than treating its absence as a failure.
285+
return {
286+
sandboxId: resolvedSandboxId,
287+
sandboxDomain: info.sandboxDomain,
288+
envdVersion: info.envdVersion,
289+
envdAccessToken: info.envdAccessToken,
290+
}
291+
} catch (error) {
292+
if (error instanceof TimeoutError) {
293+
throw new TRPCError({
294+
code: 'TIMEOUT',
295+
message: sandboxId
296+
? 'Timed out connecting to terminal sandbox'
297+
: 'Timed out creating terminal sandbox',
298+
cause: error,
299+
})
300+
}
301+
302+
throw new TRPCError({
303+
code: 'INTERNAL_SERVER_ERROR',
304+
message: sandboxId
305+
? 'Failed to connect to terminal sandbox'
306+
: 'Failed to create terminal sandbox',
307+
cause: error,
308+
})
309+
}
310+
}),
311+
312+
// Explicit, user-triggered resume of a paused sandbox for the inspect view.
313+
// The control-plane connect (which resumes + sets TTL) runs server-side so
314+
// the account token never reaches the browser; returns the sandbox-scoped
315+
// envd credentials the client uses to rebuild its envd-only client.
316+
resume: protectedTeamProcedure
317+
.input(
318+
z.object({
319+
sandboxId: SandboxIdSchema,
320+
requestTimeoutMs: z.number().int().positive().optional(),
321+
})
322+
)
323+
.mutation(async ({ ctx, input }) => {
324+
const { sandboxId, requestTimeoutMs } = input
325+
const { session, teamId } = ctx
326+
327+
const connectionOpts = {
328+
apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL,
329+
domain: process.env.NEXT_PUBLIC_E2B_DOMAIN,
330+
sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL,
331+
headers: authHeaders(session.access_token, teamId),
332+
}
333+
334+
try {
335+
await Sandbox.connect(sandboxId, {
336+
...connectionOpts,
337+
timeoutMs: SANDBOX_RESUME_TIMEOUT_MS,
338+
requestTimeoutMs: requestTimeoutMs ?? SANDBOX_RESUME_TIMEOUT_MS,
339+
})
340+
341+
const info = await Sandbox.getFullInfo(sandboxId, {
342+
...connectionOpts,
343+
requestTimeoutMs: requestTimeoutMs ?? SANDBOX_RESUME_TIMEOUT_MS,
344+
})
345+
346+
return {
347+
sandboxId,
348+
sandboxDomain: info.sandboxDomain,
349+
envdVersion: info.envdVersion,
350+
envdAccessToken: info.envdAccessToken,
351+
}
352+
} catch (error) {
353+
if (error instanceof TimeoutError) {
354+
throw new TRPCError({
355+
code: 'TIMEOUT',
356+
message: 'Timed out resuming sandbox',
357+
cause: error,
358+
})
359+
}
360+
361+
throw new TRPCError({
362+
code: 'INTERNAL_SERVER_ERROR',
363+
message: 'Failed to resume sandbox',
364+
cause: error,
365+
})
366+
}
367+
}),
368+
369+
// Explicit, user-triggered pause of a running sandbox. Uses the SDK's
370+
// control-plane pause (which snapshots and pauses) server-side so the
371+
// account token never reaches the browser.
372+
pause: protectedTeamProcedure
373+
.input(
374+
z.object({
375+
sandboxId: SandboxIdSchema,
376+
requestTimeoutMs: z.number().int().positive().optional(),
377+
})
378+
)
379+
.mutation(async ({ ctx, input }) => {
380+
const { sandboxId, requestTimeoutMs } = input
381+
const { session, teamId } = ctx
382+
383+
const connectionOpts = {
384+
apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL,
385+
domain: process.env.NEXT_PUBLIC_E2B_DOMAIN,
386+
sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL,
387+
headers: authHeaders(session.access_token, teamId),
388+
}
389+
390+
try {
391+
// Returns false when the sandbox was already paused, which we treat
392+
// as success since the desired end state is reached.
393+
await Sandbox.pause(sandboxId, {
394+
...connectionOpts,
395+
...(requestTimeoutMs ? { requestTimeoutMs } : {}),
396+
})
397+
} catch (error) {
398+
if (error instanceof TimeoutError) {
399+
throw new TRPCError({
400+
code: 'TIMEOUT',
401+
message: 'Timed out pausing sandbox',
402+
cause: error,
403+
})
404+
}
405+
406+
throw new TRPCError({
407+
code: 'INTERNAL_SERVER_ERROR',
408+
message: 'Failed to pause sandbox',
409+
cause: error,
410+
})
411+
}
412+
}),
413+
212414
killTerminalPty: protectedTeamProcedure
213415
.input(
214416
z.object({
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import Sandbox, { type SandboxConnectOpts } from 'e2b'
2+
3+
/**
4+
* Sandbox-scoped credentials needed to talk to a sandbox's envd daemon
5+
* (filesystem + PTY). These are safe to expose to the browser: they grant
6+
* access to a single sandbox only, unlike the account-level access token.
7+
*/
8+
export interface EnvdSandboxParams {
9+
sandboxId: string
10+
sandboxDomain?: string | null
11+
envdVersion: string
12+
// Optional: `secure: false` sandboxes expose envd without an access token.
13+
envdAccessToken?: string
14+
domain?: string
15+
sandboxUrl?: string
16+
trafficAccessToken?: string
17+
}
18+
19+
/**
20+
* The {@link Sandbox} constructor is marked `@internal` in the SDK type defs
21+
* (the public entry points are `Sandbox.create`/`Sandbox.connect`, both of
22+
* which make a control-plane call that requires the account-level access
23+
* token). We deliberately bypass those and build an envd-only instance
24+
* directly from sandbox-scoped credentials: the constructor performs NO
25+
* network call and authenticates every `files.*`/`pty.*` request with the
26+
* `envdAccessToken` alone.
27+
*
28+
* The cast is isolated here so the rest of the codebase never touches the
29+
* internal API. The option object is typed against the exported
30+
* `SandboxConnectOpts`, so an SDK field rename surfaces as a compile error.
31+
*/
32+
type EnvdSandboxConstructor = new (
33+
opts: SandboxConnectOpts & {
34+
sandboxId: string
35+
sandboxDomain?: string
36+
envdVersion: string
37+
envdAccessToken?: string
38+
trafficAccessToken?: string
39+
}
40+
) => Sandbox
41+
42+
export function createEnvdSandbox(params: EnvdSandboxParams): Sandbox {
43+
const SandboxCtor = Sandbox as unknown as EnvdSandboxConstructor
44+
45+
return new SandboxCtor({
46+
sandboxId: params.sandboxId,
47+
sandboxDomain: params.sandboxDomain ?? undefined,
48+
envdVersion: params.envdVersion,
49+
envdAccessToken: params.envdAccessToken,
50+
trafficAccessToken: params.trafficAccessToken,
51+
domain: params.domain,
52+
sandboxUrl: params.sandboxUrl,
53+
})
54+
}

src/core/shared/sandbox-management-auth.server.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.

src/core/shared/sandbox-management-auth.ts

Lines changed: 0 additions & 4 deletions
This file was deleted.

src/features/dashboard/sandbox/header/controls.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import KillButton from './kill-button'
2+
import PauseButton from './pause-button'
23

34
export default function SandboxDetailsControls() {
45
return (
56
<div className="flex items-center gap-2 md:pb-2">
7+
<PauseButton />
68
<KillButton />
79
</div>
810
)

0 commit comments

Comments
 (0)