diff --git a/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/filesystem/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/filesystem/page.tsx index 3392d88ab..c27872f66 100644 --- a/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/filesystem/page.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/filesystem/page.tsx @@ -4,7 +4,6 @@ import { COOKIE_KEYS } from '@/configs/cookies' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { getAuthContext } from '@/core/server/auth' import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' -import { createSandboxManagementAuth } from '@/core/shared/sandbox-management-auth.server' import SandboxInspectView from '@/features/dashboard/sandbox/inspect/view' const DEFAULT_ROOT_PATH = '/home/user' @@ -41,13 +40,5 @@ export default async function SandboxInspectPage({ cookieStore.get(COOKIE_KEYS.SANDBOX_INSPECT_ROOT_PATH)?.value || DEFAULT_ROOT_PATH - return ( - - ) + return } diff --git a/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx index b88105a77..4d7f78f1a 100644 --- a/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx @@ -2,7 +2,6 @@ import { redirect } from 'next/navigation' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { getAuthContext } from '@/core/server/auth' import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' -import { createSandboxManagementAuth } from '@/core/shared/sandbox-management-auth.server' import SandboxTerminalView from '@/features/dashboard/sandbox/terminal/view' interface SandboxTerminalPageProps { @@ -36,13 +35,5 @@ export default async function SandboxTerminalPage({ redirect(PROTECTED_URLS.DASHBOARD) } - return ( - - ) + return } diff --git a/src/app/dashboard/[teamSlug]/terminal/page.tsx b/src/app/dashboard/[teamSlug]/terminal/page.tsx index 9aa4103ae..9d522da36 100644 --- a/src/app/dashboard/[teamSlug]/terminal/page.tsx +++ b/src/app/dashboard/[teamSlug]/terminal/page.tsx @@ -10,7 +10,6 @@ import { import { getAuthContext } from '@/core/server/auth' import { infra } from '@/core/shared/clients/api' import type { components as InfraComponents } from '@/core/shared/contracts/infra-api.types' -import { createSandboxManagementAuth } from '@/core/shared/sandbox-management-auth.server' import { SandboxIdSchema } from '@/core/shared/schemas/api' import DashboardTerminal from '@/features/dashboard/terminal/dashboard-terminal' import { normalizeTerminalTemplate } from '@/features/dashboard/terminal/template' @@ -132,11 +131,8 @@ export default async function TeamTerminalPage({ sandboxId: terminalSandboxId, template: terminalTemplate, }} - sandboxManagementAuth={createSandboxManagementAuth( - authContext, - team.id - )} teamSlug={team.slug} + userId={authContext.user.id} /> ) diff --git a/src/core/server/api/routers/sandbox.ts b/src/core/server/api/routers/sandbox.ts index b85b46e67..8e672b928 100644 --- a/src/core/server/api/routers/sandbox.ts +++ b/src/core/server/api/routers/sandbox.ts @@ -1,5 +1,6 @@ +import { TRPCError } from '@trpc/server' import { millisecondsInDay } from 'date-fns/constants' -import { Sandbox } from 'e2b' +import { Sandbox, TimeoutError } from 'e2b' import { z } from 'zod' import { authHeaders } from '@/configs/api' import { @@ -17,8 +18,10 @@ import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/r import { createTRPCRouter } from '@/core/server/trpc/init' import { protectedTeamProcedure } from '@/core/server/trpc/procedures' import { SandboxIdSchema } from '@/core/shared/schemas/api' +import { SANDBOX_RESUME_TIMEOUT_MS } from '@/features/dashboard/sandbox/inspect/constants' import { SANDBOX_MONITORING_METRICS_RETENTION_MS } from '@/features/dashboard/sandbox/monitoring/utils/constants' import { TERMINAL_SANDBOX_TIMEOUT_MS } from '@/features/dashboard/terminal/constants' +import { normalizeTerminalTemplate } from '@/features/dashboard/terminal/template' const sandboxRepositoryProcedure = protectedTeamProcedure.use( withTeamAuthedRequestRepository( @@ -209,6 +212,205 @@ export const sandboxRouter = createTRPCRouter({ // MUTATIONS + // Runs the control-plane create/connect server-side so the user's + // account-level access token never reaches the browser. Returns only the + // sandbox-scoped envd credentials the client needs for PTY access. + openTerminal: protectedTeamProcedure + .input( + z.object({ + template: z.string().min(1, 'Template is required'), + sandboxId: SandboxIdSchema.optional(), + requestTimeoutMs: z.number().int().positive().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { sandboxId, template, requestTimeoutMs } = input + const { session, teamId } = ctx + + const normalizedTemplate = normalizeTerminalTemplate(template) + if (!normalizedTemplate) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Invalid terminal template', + }) + } + + const connectionOpts = { + apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, + domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + headers: authHeaders(session.access_token, teamId), + } + + try { + let resolvedSandboxId: string + if (sandboxId) { + const sandbox = await Sandbox.connect(sandboxId, { + ...connectionOpts, + timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS, + requestTimeoutMs, + }) + resolvedSandboxId = sandbox.sandboxId + } else { + const sandbox = await Sandbox.create(normalizedTemplate, { + ...connectionOpts, + timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS, + requestTimeoutMs, + lifecycle: { + onTimeout: 'pause', + autoResume: true, + }, + metadata: { + source: 'dashboard-terminal', + template: normalizedTemplate, + userId: session.user.id, + }, + }) + resolvedSandboxId = sandbox.sandboxId + } + + // `Sandbox.create`/`connect` build a full SDK instance but only expose + // the sandbox id/domain publicly; fetch the envd credentials via the + // public info endpoint rather than reading the SDK's internal fields. + // Kept inside this try (with the same requestTimeoutMs) so a stalled + // GET times out promptly and is normalized like the connect timeout. + const info = await Sandbox.getFullInfo(resolvedSandboxId, { + ...connectionOpts, + requestTimeoutMs, + }) + + // `envdAccessToken` is absent for `secure: false` sandboxes, whose envd + // is reachable without the `X-Access-Token` header — pass it through + // as-is rather than treating its absence as a failure. + return { + sandboxId: resolvedSandboxId, + sandboxDomain: info.sandboxDomain, + envdVersion: info.envdVersion, + envdAccessToken: info.envdAccessToken, + } + } catch (error) { + if (error instanceof TimeoutError) { + throw new TRPCError({ + code: 'TIMEOUT', + message: sandboxId + ? 'Timed out connecting to terminal sandbox' + : 'Timed out creating terminal sandbox', + cause: error, + }) + } + + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: sandboxId + ? 'Failed to connect to terminal sandbox' + : 'Failed to create terminal sandbox', + cause: error, + }) + } + }), + + // Explicit, user-triggered resume of a paused sandbox for the inspect view. + // The control-plane connect (which resumes + sets TTL) runs server-side so + // the account token never reaches the browser; returns the sandbox-scoped + // envd credentials the client uses to rebuild its envd-only client. + resume: protectedTeamProcedure + .input( + z.object({ + sandboxId: SandboxIdSchema, + requestTimeoutMs: z.number().int().positive().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { sandboxId, requestTimeoutMs } = input + const { session, teamId } = ctx + + const connectionOpts = { + apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, + domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + headers: authHeaders(session.access_token, teamId), + } + + try { + await Sandbox.connect(sandboxId, { + ...connectionOpts, + timeoutMs: SANDBOX_RESUME_TIMEOUT_MS, + requestTimeoutMs: requestTimeoutMs ?? SANDBOX_RESUME_TIMEOUT_MS, + }) + + const info = await Sandbox.getFullInfo(sandboxId, { + ...connectionOpts, + requestTimeoutMs: requestTimeoutMs ?? SANDBOX_RESUME_TIMEOUT_MS, + }) + + return { + sandboxId, + sandboxDomain: info.sandboxDomain, + envdVersion: info.envdVersion, + envdAccessToken: info.envdAccessToken, + } + } catch (error) { + if (error instanceof TimeoutError) { + throw new TRPCError({ + code: 'TIMEOUT', + message: 'Timed out resuming sandbox', + cause: error, + }) + } + + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to resume sandbox', + cause: error, + }) + } + }), + + // Explicit, user-triggered pause of a running sandbox. Uses the SDK's + // control-plane pause (which snapshots and pauses) server-side so the + // account token never reaches the browser. + pause: protectedTeamProcedure + .input( + z.object({ + sandboxId: SandboxIdSchema, + requestTimeoutMs: z.number().int().positive().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { sandboxId, requestTimeoutMs } = input + const { session, teamId } = ctx + + const connectionOpts = { + apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, + domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + headers: authHeaders(session.access_token, teamId), + } + + try { + // Returns false when the sandbox was already paused, which we treat + // as success since the desired end state is reached. + await Sandbox.pause(sandboxId, { + ...connectionOpts, + ...(requestTimeoutMs ? { requestTimeoutMs } : {}), + }) + } catch (error) { + if (error instanceof TimeoutError) { + throw new TRPCError({ + code: 'TIMEOUT', + message: 'Timed out pausing sandbox', + cause: error, + }) + } + + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to pause sandbox', + cause: error, + }) + } + }), + killTerminalPty: protectedTeamProcedure .input( z.object({ diff --git a/src/core/shared/create-envd-sandbox.ts b/src/core/shared/create-envd-sandbox.ts new file mode 100644 index 000000000..7699b5870 --- /dev/null +++ b/src/core/shared/create-envd-sandbox.ts @@ -0,0 +1,54 @@ +import Sandbox, { type SandboxConnectOpts } from 'e2b' + +/** + * Sandbox-scoped credentials needed to talk to a sandbox's envd daemon + * (filesystem + PTY). These are safe to expose to the browser: they grant + * access to a single sandbox only, unlike the account-level access token. + */ +export interface EnvdSandboxParams { + sandboxId: string + sandboxDomain?: string | null + envdVersion: string + // Optional: `secure: false` sandboxes expose envd without an access token. + envdAccessToken?: string + domain?: string + sandboxUrl?: string + trafficAccessToken?: string +} + +/** + * The {@link Sandbox} constructor is marked `@internal` in the SDK type defs + * (the public entry points are `Sandbox.create`/`Sandbox.connect`, both of + * which make a control-plane call that requires the account-level access + * token). We deliberately bypass those and build an envd-only instance + * directly from sandbox-scoped credentials: the constructor performs NO + * network call and authenticates every `files.*`/`pty.*` request with the + * `envdAccessToken` alone. + * + * The cast is isolated here so the rest of the codebase never touches the + * internal API. The option object is typed against the exported + * `SandboxConnectOpts`, so an SDK field rename surfaces as a compile error. + */ +type EnvdSandboxConstructor = new ( + opts: SandboxConnectOpts & { + sandboxId: string + sandboxDomain?: string + envdVersion: string + envdAccessToken?: string + trafficAccessToken?: string + } +) => Sandbox + +export function createEnvdSandbox(params: EnvdSandboxParams): Sandbox { + const SandboxCtor = Sandbox as unknown as EnvdSandboxConstructor + + return new SandboxCtor({ + sandboxId: params.sandboxId, + sandboxDomain: params.sandboxDomain ?? undefined, + envdVersion: params.envdVersion, + envdAccessToken: params.envdAccessToken, + trafficAccessToken: params.trafficAccessToken, + domain: params.domain, + sandboxUrl: params.sandboxUrl, + }) +} diff --git a/src/core/shared/sandbox-management-auth.server.ts b/src/core/shared/sandbox-management-auth.server.ts deleted file mode 100644 index b958e0a34..000000000 --- a/src/core/shared/sandbox-management-auth.server.ts +++ /dev/null @@ -1,15 +0,0 @@ -import 'server-only' - -import { authHeaders } from '@/configs/api' -import type { AuthContext } from '@/core/server/auth/types' -import type { SandboxManagementAuth } from './sandbox-management-auth' - -export function createSandboxManagementAuth( - authContext: AuthContext, - teamId: string -): SandboxManagementAuth { - return { - headers: authHeaders(authContext.accessToken, teamId), - userId: authContext.user.id, - } -} diff --git a/src/core/shared/sandbox-management-auth.ts b/src/core/shared/sandbox-management-auth.ts deleted file mode 100644 index b60dba9dd..000000000 --- a/src/core/shared/sandbox-management-auth.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface SandboxManagementAuth { - userId: string - headers: Record -} diff --git a/src/features/dashboard/sandbox/header/controls.tsx b/src/features/dashboard/sandbox/header/controls.tsx index 3f7471487..0c90efae6 100644 --- a/src/features/dashboard/sandbox/header/controls.tsx +++ b/src/features/dashboard/sandbox/header/controls.tsx @@ -1,8 +1,10 @@ import KillButton from './kill-button' +import PauseButton from './pause-button' export default function SandboxDetailsControls() { return (
+
) diff --git a/src/features/dashboard/sandbox/header/pause-button.tsx b/src/features/dashboard/sandbox/header/pause-button.tsx new file mode 100644 index 000000000..0e67da223 --- /dev/null +++ b/src/features/dashboard/sandbox/header/pause-button.tsx @@ -0,0 +1,84 @@ +'use client' + +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { toast } from 'sonner' +import { useRouteParams } from '@/lib/hooks/use-route-params' +import { useTRPC } from '@/trpc/client' +import { AlertPopover } from '@/ui/alert-popover' +import { Button } from '@/ui/primitives/button' +import { PausedIcon } from '@/ui/primitives/icons' +import { useSandboxContext } from '../context' + +interface PauseButtonProps { + className?: string +} + +export default function PauseButton({ className }: PauseButtonProps) { + const [open, setOpen] = useState(false) + const { sandboxInfo, refetchSandboxInfo } = useSandboxContext() + const { teamSlug, sandboxId } = + useRouteParams<'/dashboard/[teamSlug]/sandboxes/[sandboxId]'>() + const trpc = useTRPC() + const queryClient = useQueryClient() + + const canPause = sandboxInfo?.state === 'running' + const detailsKey = trpc.sandbox.details.queryKey({ teamSlug, sandboxId }) + + const { mutate: pause, isPending } = useMutation( + trpc.sandbox.pause.mutationOptions({ + // Optimistically mark the sandbox as paused so the live terminal/filesystem + // connections tear down immediately. Otherwise their envd traffic + // auto-resumes the sandbox while the pause snapshot is being created. + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: detailsKey }) + const previous = queryClient.getQueryData(detailsKey) + queryClient.setQueryData(detailsKey, (old) => + old?.state === 'running' ? { ...old, state: 'paused' as const } : old + ) + return { previous } + }, + onError: (_error, _variables, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData(detailsKey, context.previous) + } + toast.error('Failed to pause sandbox. Please try again.') + }, + onSuccess: async () => { + toast.success('Sandbox paused successfully') + setOpen(false) + refetchSandboxInfo() + }, + }) + ) + + const handlePause = () => { + if (!canPause || !sandboxInfo?.sandboxID) return + + pause({ teamSlug, sandboxId: sandboxInfo.sandboxID }) + } + + if (!canPause) return null + + return ( + + + Pause + + } + confirmProps={{ + disabled: isPending, + loading: isPending ? 'Pausing...' : undefined, + }} + onConfirm={handlePause} + onCancel={() => setOpen(false)} + /> + ) +} diff --git a/src/features/dashboard/sandbox/inspect/constants.ts b/src/features/dashboard/sandbox/inspect/constants.ts new file mode 100644 index 000000000..6d635932f --- /dev/null +++ b/src/features/dashboard/sandbox/inspect/constants.ts @@ -0,0 +1,4 @@ +// Sandbox TTL (and connect-request wait) granted on an explicit inspect resume. +// Kept short on purpose: resuming for inspect should give a brief debug window, +// not silently extend a customer sandbox's lifetime. +export const SANDBOX_RESUME_TIMEOUT_MS = 70_000 diff --git a/src/features/dashboard/sandbox/inspect/context.tsx b/src/features/dashboard/sandbox/inspect/context.tsx index b37121016..84a463c7e 100644 --- a/src/features/dashboard/sandbox/inspect/context.tsx +++ b/src/features/dashboard/sandbox/inspect/context.tsx @@ -1,7 +1,6 @@ 'use client' import { useQuery } from '@tanstack/react-query' -import Sandbox from 'e2b' import type { ReactNode } from 'react' import { createContext, @@ -11,17 +10,17 @@ import { useRef, useState, } from 'react' -import type { SandboxManagementAuth } from '@/core/shared/sandbox-management-auth' +import { createEnvdSandbox } from '@/core/shared/create-envd-sandbox' import { useSandboxInspectAnalytics } from '@/lib/hooks/use-analytics' import { getParentPath, normalizePath } from '@/lib/utils/filesystem' +import { useTRPCClient } from '@/trpc/client' import { useDashboard } from '../../context' import { useSandboxContext } from '../context' +import { SANDBOX_RESUME_TIMEOUT_MS } from './constants' import { createFilesystemStore, type FilesystemStore } from './filesystem/store' import type { FilesystemOperations } from './filesystem/types' import { SandboxManager } from './sandbox-manager' -const SANDBOX_RESUME_TIMEOUT_MS = 70_000 - interface SandboxInspectContextValue { store: FilesystemStore operations: FilesystemOperations @@ -37,7 +36,6 @@ const SandboxInspectContext = createContext( interface SandboxInspectProviderProps { children: ReactNode rootPath: string - sandboxManagementAuth: SandboxManagementAuth } function createStoreWithRoot(rootPath: string) { @@ -64,10 +62,10 @@ function createStoreWithRoot(rootPath: string) { export default function SandboxInspectProvider({ children, rootPath, - sandboxManagementAuth, }: SandboxInspectProviderProps) { const { team } = useDashboard() const teamId = team.id + const trpcClient = useTRPCClient() const { sandboxInfo, isRunning, refetchSandboxInfo } = useSandboxContext() const sandboxId = sandboxInfo?.sandboxID @@ -75,7 +73,6 @@ export default function SandboxInspectProvider({ const [store] = useState(() => createStoreWithRoot(rootPath)) const sandboxManagerRef = useRef(null) const connectGenerationRef = useRef(0) - const connectAbortControllerRef = useRef(null) const [isSandboxResumePending, setIsSandboxResumePending] = useState(false) const [sandboxResumeError, setSandboxResumeError] = useState() const [connectionKey, setConnectionKey] = useState() @@ -169,44 +166,53 @@ export default function SandboxInspectProvider({ [isRunning, store, trackInteraction] ) - const connectSandbox = async (options?: { - connectionKey?: string | null - requestTimeoutMs?: number - timeoutMs?: number + // Build an envd-only client from sandbox-scoped credentials and start the + // file watcher. No control-plane call, so this never resumes a paused + // sandbox or extends its TTL — the account token never reaches the browser. + const buildManagerFromCreds = async (creds: { + sandboxId: string + sandboxDomain?: string | null + envdVersion: string + envdAccessToken?: string }) => { - if (!sandboxId || !teamId) return false const generation = connectGenerationRef.current + 1 connectGenerationRef.current = generation - connectAbortControllerRef.current?.abort() - const abortController = new AbortController() - connectAbortControllerRef.current = abortController - // (re)create the sandbox-manager when sandbox / team / root changes if (sandboxManagerRef.current) { sandboxManagerRef.current.stopWatching() } - const sandbox = await Sandbox.connect(sandboxId, { + const sandbox = createEnvdSandbox({ + ...creds, domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - // Keep inspect connections from extending sandbox TTL via SDK default connect timeout. - timeoutMs: options?.timeoutMs ?? 1_000, - requestTimeoutMs: options?.requestTimeoutMs, - signal: abortController.signal, - apiHeaders: { - ...sandboxManagementAuth.headers, - }, + sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, }) + const manager = new SandboxManager(store, sandbox, rootPath) + sandboxManagerRef.current = manager + await manager.loadDirectory(rootPath) + // Superseded by a newer connect/resume while loading — discard. if (connectGenerationRef.current !== generation) { - if (connectAbortControllerRef.current === abortController) { - connectAbortControllerRef.current = null - } + manager.stopWatching() return false } + return true + } + + const connectSandbox = async (options?: { + connectionKey?: string | null + }) => { + if (!sandboxInfo || !sandboxId || !teamId) return false + if (sandboxInfo.state === 'killed') return false + + const didConnect = await buildManagerFromCreds({ + sandboxId, + sandboxDomain: sandboxInfo.domain, + envdVersion: sandboxInfo.envdVersion, + envdAccessToken: sandboxInfo.envdAccessToken, + }) + if (!didConnect) return false - connectAbortControllerRef.current = null - sandboxManagerRef.current = new SandboxManager(store, sandbox, rootPath) - await sandboxManagerRef.current.loadDirectory(rootPath) if (options?.connectionKey !== null) { setConnectionKey(options?.connectionKey ?? expectedConnectionKey) } @@ -224,8 +230,6 @@ export default function SandboxInspectProvider({ queryFn: () => { if (!isRunning) { connectGenerationRef.current += 1 - connectAbortControllerRef.current?.abort() - connectAbortControllerRef.current = null sandboxManagerRef.current?.stopWatching() sandboxManagerRef.current = null setConnectionKey(expectedConnectionKey) @@ -246,20 +250,27 @@ export default function SandboxInspectProvider({ staleTime: Number.POSITIVE_INFINITY, }) + // Explicit, user-triggered resume. The control-plane connect (resume + TTL) + // happens server-side via the `sandbox.resume` mutation; we then rebuild the + // envd-only client from the returned sandbox-scoped credentials. const resumeSandbox = async () => { + if (!sandboxId || !teamId) return setSandboxResumeError(undefined) setIsSandboxResumePending(true) try { - const didConnect = await connectSandbox({ - connectionKey: null, + const creds = await trpcClient.sandbox.resume.mutate({ + teamSlug: team.slug, + sandboxId, requestTimeoutMs: SANDBOX_RESUME_TIMEOUT_MS, - timeoutMs: SANDBOX_RESUME_TIMEOUT_MS, }) + + const didConnect = await buildManagerFromCreds(creds) if (!didConnect) { setSandboxResumeError('Failed to resume sandbox. Please try again.') setIsSandboxResumePending(false) return } + const nextSandboxInfo = await refetchSandboxInfo() if (nextSandboxInfo?.state === 'running') { setConnectionKey( @@ -280,8 +291,6 @@ export default function SandboxInspectProvider({ useEffect(() => { return () => { connectGenerationRef.current += 1 - connectAbortControllerRef.current?.abort() - connectAbortControllerRef.current = null sandboxManagerRef.current?.stopWatching() sandboxManagerRef.current = null } diff --git a/src/features/dashboard/sandbox/inspect/filesystem.tsx b/src/features/dashboard/sandbox/inspect/filesystem.tsx index 2ee4d8132..19014fd3f 100644 --- a/src/features/dashboard/sandbox/inspect/filesystem.tsx +++ b/src/features/dashboard/sandbox/inspect/filesystem.tsx @@ -30,7 +30,7 @@ export default function SandboxInspectFilesystem({ return (
- + {isRunning && } {showRootLoading ? ( - ) : !children.length ? ( + ) : !isRunning || !children.length ? ( void resumeSandbox()} diff --git a/src/features/dashboard/sandbox/inspect/not-found.tsx b/src/features/dashboard/sandbox/inspect/not-found.tsx index ee5bdc89f..1b5805619 100644 --- a/src/features/dashboard/sandbox/inspect/not-found.tsx +++ b/src/features/dashboard/sandbox/inspect/not-found.tsx @@ -16,8 +16,11 @@ import { RunningIcon, } from '@/ui/primitives/icons' import { useSandboxContext } from '../context' +import { SANDBOX_RESUME_TIMEOUT_MS } from './constants' import SandboxInspectEmptyFrame from './empty' +const RESUME_TIMEOUT_SECONDS = Math.round(SANDBOX_RESUME_TIMEOUT_MS / 1000) + interface SandboxInspectNotFoundProps { isResumePending?: boolean onResumeSandbox?: () => void @@ -75,15 +78,23 @@ export default function SandboxInspectNotFound({ const isPaused = sandboxInfo?.state === 'paused' const resourceName = isFilesystem ? 'filesystem' : 'terminal' const description = - isRunning && isFilesystem - ? 'This directory appears to be empty or does not exist. You can reset to the default state, navigate to root, or refresh to try again.' - : isRunning - ? 'The terminal is unavailable right now. Refresh to try again.' - : resumeError - ? resumeError - : isPaused - ? `Resume this sandbox to access the ${resourceName}.` - : `It seems like the sandbox is not connected anymore. We cannot access the ${resourceName} at this time.` + isRunning && isFilesystem ? ( + 'This directory appears to be empty or does not exist. You can reset to the default state, navigate to root, or refresh to try again.' + ) : isRunning ? ( + 'The terminal is unavailable right now. Refresh to try again.' + ) : resumeError ? ( + resumeError + ) : isPaused ? ( + <> + Resume this sandbox to access the {resourceName}. + + Runs for {RESUME_TIMEOUT_SECONDS}s, then stops unless the timeout is + extended. You can pause it again anytime. + + + ) : ( + `It seems like the sandbox is not connected anymore. We cannot access the ${resourceName} at this time.` + ) const actions = isRunning && isFilesystem ? ( diff --git a/src/features/dashboard/sandbox/inspect/view.tsx b/src/features/dashboard/sandbox/inspect/view.tsx index 1b11ba377..366e16a4b 100644 --- a/src/features/dashboard/sandbox/inspect/view.tsx +++ b/src/features/dashboard/sandbox/inspect/view.tsx @@ -1,7 +1,6 @@ 'use client' import { SANDBOX_INSPECT_MINIMUM_ENVD_VERSION } from '@/configs/versioning' -import type { SandboxManagementAuth } from '@/core/shared/sandbox-management-auth' import SandboxInspectProvider from '@/features/dashboard/sandbox/inspect/context' import SandboxInspectFilesystem from '@/features/dashboard/sandbox/inspect/filesystem' import SandboxInspectViewer from '@/features/dashboard/sandbox/inspect/viewer' @@ -12,12 +11,10 @@ import SandboxInspectIncompatible from './incompatible' interface SandboxInspectViewProps { rootPath: string - sandboxManagementAuth: SandboxManagementAuth } export default function SandboxInspectView({ rootPath, - sandboxManagementAuth, }: SandboxInspectViewProps) { const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/sandboxes/[sandboxId]'>() @@ -45,7 +42,6 @@ export default function SandboxInspectView({
diff --git a/src/features/dashboard/sandbox/terminal/view.tsx b/src/features/dashboard/sandbox/terminal/view.tsx index 107c51a75..4b7292377 100644 --- a/src/features/dashboard/sandbox/terminal/view.tsx +++ b/src/features/dashboard/sandbox/terminal/view.tsx @@ -1,7 +1,6 @@ 'use client' import { type ReactNode, useMemo, useState } from 'react' -import type { SandboxManagementAuth } from '@/core/shared/sandbox-management-auth' import LoadingLayout from '@/features/dashboard/loading-layout' import DashboardTerminal from '@/features/dashboard/terminal/dashboard-terminal' import { useRouteParams } from '@/lib/hooks/use-route-params' @@ -11,14 +10,14 @@ import SandboxInspectNotFound from '../inspect/not-found' interface SandboxTerminalViewProps { command?: string - sandboxManagementAuth: SandboxManagementAuth + userId: string } const SANDBOX_TERMINAL_RESUME_TIMEOUT_MS = 70_000 export default function SandboxTerminalView({ command, - sandboxManagementAuth, + userId, }: SandboxTerminalViewProps) { const [shouldResumeSandbox, setShouldResumeSandbox] = useState(false) const [terminalResumeError, setTerminalResumeError] = useState() @@ -96,9 +95,9 @@ export default function SandboxTerminalView({ sandboxConnectRequestTimeoutMs={ shouldResumeSandbox ? SANDBOX_TERMINAL_RESUME_TIMEOUT_MS : undefined } - sandboxManagementAuth={sandboxManagementAuth} sandboxScoped teamSlug={team.slug} + userId={userId} />
) diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index c15872ebd..c3c443193 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -2,7 +2,7 @@ import type { CommandHandle, Sandbox } from 'e2b' import { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react' -import type { SandboxManagementAuth } from '@/core/shared/sandbox-management-auth' +import { useTRPCClient } from '@/trpc/client' import { DEFAULT_CWD, TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS, @@ -35,9 +35,9 @@ interface DashboardTerminalProps { onSandboxAttached?: (sandboxId: string) => void onSandboxAttachFailed?: (target: TerminalLaunchTarget | undefined) => void sandboxConnectRequestTimeoutMs?: number - sandboxManagementAuth: SandboxManagementAuth sandboxScoped?: boolean teamSlug: string + userId: string } export default function DashboardTerminal({ @@ -47,10 +47,12 @@ export default function DashboardTerminal({ onSandboxAttached, onSandboxAttachFailed, sandboxConnectRequestTimeoutMs, - sandboxManagementAuth, sandboxScoped = false, teamSlug, + userId, }: DashboardTerminalProps) { + const trpcClient = useTRPCClient() + const [status, setStatus] = useState('idle') const [activeSandboxId, setActiveSandboxId] = useState() const [template, setTemplate] = useState( @@ -307,12 +309,15 @@ export default function DashboardTerminal({ const terminalSandbox = await openTerminalSandbox({ forceNewSandbox: options.forceNewSandbox, onStatus: appendOutput, + openTerminal: (mutationInput) => + trpcClient.sandbox.openTerminal.mutate(mutationInput), requestTimeoutMs: requestedSandboxId ? (sandboxConnectRequestTimeoutMs ?? TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS) : undefined, - sandboxManagementAuth, shouldStoreSession: !sandboxScoped, + teamSlug, + userId, sandboxId: requestedSandboxId, template: nextTemplate, }) @@ -413,7 +418,9 @@ export default function DashboardTerminal({ focusTerminal, getSandbox, runCommand, - sandboxManagementAuth, + trpcClient, + teamSlug, + userId, sandboxScoped, sandboxConnectRequestTimeoutMs, template, diff --git a/src/features/dashboard/terminal/sandbox-session.ts b/src/features/dashboard/terminal/sandbox-session.ts index 37dc43813..8a610a6bd 100644 --- a/src/features/dashboard/terminal/sandbox-session.ts +++ b/src/features/dashboard/terminal/sandbox-session.ts @@ -1,18 +1,38 @@ -import Sandbox from 'e2b' -import type { SandboxManagementAuth } from '@/core/shared/sandbox-management-auth' -import { TERMINAL_SANDBOX_TIMEOUT_MS } from './constants' +import type { Sandbox } from 'e2b' +import { createEnvdSandbox } from '@/core/shared/create-envd-sandbox' +import type { TRPCRouterOutputs } from '@/trpc/client' import { clearStoredTerminalSession, readStoredTerminalSession, writeStoredTerminalSession, } from './storage' +type TerminalSandboxConnection = TRPCRouterOutputs['sandbox']['openTerminal'] + +interface OpenTerminalMutationInput { + teamSlug: string + template: string + sandboxId?: string + requestTimeoutMs?: number +} + +/** + * Performs the `sandbox.openTerminal` tRPC mutation. Injected from the + * component (via the vanilla tRPC client) so this orchestration helper stays + * usable outside of a React hook. + */ +export type OpenTerminalMutation = ( + input: OpenTerminalMutationInput +) => Promise + interface OpenTerminalSandboxOptions { forceNewSandbox?: boolean onStatus: (message: string) => void + openTerminal: OpenTerminalMutation requestTimeoutMs?: number - sandboxManagementAuth: SandboxManagementAuth shouldStoreSession?: boolean + teamSlug: string + userId: string sandboxId?: string template: string } @@ -20,19 +40,21 @@ interface OpenTerminalSandboxOptions { export async function openTerminalSandbox({ forceNewSandbox = false, onStatus, + openTerminal, requestTimeoutMs, - sandboxManagementAuth, shouldStoreSession, + teamSlug, + userId, sandboxId, template, }: OpenTerminalSandboxOptions) { - const { headers, userId } = sandboxManagementAuth - if (sandboxId) { onStatus(`Connecting to terminal sandbox ${sandboxId}...\r\n`) - const sandbox = await connectTerminalSandbox(sandboxId, headers, { - requestTimeoutMs, - }) + const sandbox = await acquireTerminalSandbox( + openTerminal, + { teamSlug, template, sandboxId, requestTimeoutMs }, + 'Failed to connect to terminal sandbox' + ) return { sandbox, @@ -51,20 +73,33 @@ export async function openTerminalSandbox({ ) try { - sandbox = await connectTerminalSandbox( - storedTerminalSession.sandboxId, - headers, - { requestTimeoutMs } + sandbox = await acquireTerminalSandbox( + openTerminal, + { + teamSlug, + template, + sandboxId: storedTerminalSession.sandboxId, + requestTimeoutMs, + }, + 'Failed to connect to terminal sandbox' ) } catch { clearStoredTerminalSession(userId) onStatus('Stored terminal sandbox is unavailable.\r\n') onStatus(`Starting ${template} terminal sandbox...\r\n`) - sandbox = await createTerminalSandbox({ headers, template, userId }) + sandbox = await acquireTerminalSandbox( + openTerminal, + { teamSlug, template }, + 'Failed to create terminal sandbox' + ) } } else { onStatus(`Starting ${template} terminal sandbox...\r\n`) - sandbox = await createTerminalSandbox({ headers, template, userId }) + sandbox = await acquireTerminalSandbox( + openTerminal, + { teamSlug, template }, + 'Failed to create terminal sandbox' + ) } if (shouldStoreSession ?? true) { @@ -79,48 +114,22 @@ export async function openTerminalSandbox({ } } -function connectTerminalSandbox( - sandboxId: string, - headers: Record, - options: { requestTimeoutMs?: number } = {} -) { - return Sandbox.connect(sandboxId, { - apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, - timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS, - requestTimeoutMs: options.requestTimeoutMs, - headers: { - ...headers, - }, - }) -} +async function acquireTerminalSandbox( + openTerminal: OpenTerminalMutation, + input: OpenTerminalMutationInput, + fallbackMessage: string +): Promise { + let connection: TerminalSandboxConnection -function createTerminalSandbox({ - headers, - template, - userId, -}: { - headers: Record - template: string - userId: string -}) { - return Sandbox.create(template, { - apiUrl: process.env.NEXT_PUBLIC_INFRA_API_URL, + try { + connection = await openTerminal(input) + } catch (error) { + throw error instanceof Error ? error : new Error(fallbackMessage) + } + + return createEnvdSandbox({ + ...connection, domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, - timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS, - lifecycle: { - onTimeout: 'pause', - autoResume: true, - }, - metadata: { - source: 'dashboard-terminal', - template, - userId, - }, - headers: { - ...headers, - }, }) } diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts index e8ab4b345..3558cb2c2 100644 --- a/tests/unit/dashboard-terminal.test.ts +++ b/tests/unit/dashboard-terminal.test.ts @@ -1,9 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - AUTHORIZATION_HEADER, - BEARER_TOKEN_PREFIX, - TEAM_ID_HEADER, -} from '@/configs/api' import { TERMINAL_SESSION_STORAGE_PREFIX } from '@/features/dashboard/terminal/constants' import { openTerminalSandbox } from '@/features/dashboard/terminal/sandbox-session' import { @@ -17,18 +12,19 @@ import { } from '@/features/dashboard/terminal/template' import { calculateTerminalSize } from '@/features/dashboard/terminal/terminal-size' -const { mockCreateSandbox, mockConnectSandbox } = vi.hoisted(() => ({ - mockCreateSandbox: vi.fn(), - mockConnectSandbox: vi.fn(), +const { mockCreateEnvdSandbox } = vi.hoisted(() => ({ + mockCreateEnvdSandbox: vi.fn(), })) -vi.mock('e2b', () => ({ - default: { - connect: mockConnectSandbox, - create: mockCreateSandbox, - }, +vi.mock('@/core/shared/create-envd-sandbox', () => ({ + createEnvdSandbox: mockCreateEnvdSandbox, })) +// The `sandbox.openTerminal` tRPC mutation is injected into +// openTerminalSandbox, so the test passes this mock directly instead of +// mocking a module. +const mockOpenTerminal = vi.fn() + function installLocalStorage() { const values = new Map() @@ -52,19 +48,23 @@ function installLocalStorage() { } describe('dashboard terminal helpers', () => { - const sandboxManagementAuth = { - headers: { - [AUTHORIZATION_HEADER]: `${BEARER_TOKEN_PREFIX}auth-provider-token`, - [TEAM_ID_HEADER]: 'team-123', - }, - userId: 'user-123', - } - beforeEach(() => { vi.clearAllMocks() installLocalStorage() - mockCreateSandbox.mockResolvedValue({ sandboxId: 'created-sandbox' }) - mockConnectSandbox.mockResolvedValue({ sandboxId: 'connected-sandbox' }) + // The tRPC mutation returns sandbox-scoped envd credentials; for an + // explicit/stored sandbox id it echoes that id back, otherwise it reports + // the freshly created sandbox id. + mockOpenTerminal.mockImplementation( + async (input: { sandboxId?: string }) => ({ + sandboxId: input.sandboxId ?? 'created-sandbox', + sandboxDomain: 'sandbox.example.com', + envdVersion: '0.2.0', + envdAccessToken: 'envd-token', + }) + ) + mockCreateEnvdSandbox.mockImplementation( + (params: { sandboxId: string }) => ({ sandboxId: params.sandboxId }) + ) }) describe('normalizeTerminalTemplate', () => { @@ -267,50 +267,72 @@ describe('dashboard terminal helpers', () => { await openTerminalSandbox({ onStatus: (message) => statuses.push(message), - sandboxManagementAuth, + openTerminal: mockOpenTerminal, + teamSlug: 'team-slug', + userId: 'user-123', sandboxId: 'sandbox-from-url', template: 'base', }) - expect(mockConnectSandbox).toHaveBeenCalledWith('sandbox-from-url', { - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - timeoutMs: 30 * 60 * 1000, + expect(mockOpenTerminal).toHaveBeenCalledWith({ + teamSlug: 'team-slug', + template: 'base', + sandboxId: 'sandbox-from-url', requestTimeoutMs: undefined, - headers: { - [AUTHORIZATION_HEADER]: `${BEARER_TOKEN_PREFIX}auth-provider-token`, - [TEAM_ID_HEADER]: 'team-123', - }, }) - expect(mockCreateSandbox).not.toHaveBeenCalled() + expect(mockCreateEnvdSandbox).toHaveBeenCalledWith({ + sandboxId: 'sandbox-from-url', + sandboxDomain: 'sandbox.example.com', + envdVersion: '0.2.0', + envdAccessToken: 'envd-token', + domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + }) expect(readStoredTerminalSession('user-123')).toBeNull() expect(statuses).toEqual([ 'Connecting to terminal sandbox sandbox-from-url...\r\n', ]) }) - it('creates and stores a terminal sandbox when no reusable session exists', async () => { + it('connects to a tokenless (secure: false) sandbox without an envd access token', async () => { + mockOpenTerminal.mockResolvedValueOnce({ + sandboxId: 'insecure-sandbox', + sandboxDomain: 'sandbox.example.com', + envdVersion: '0.2.0', + envdAccessToken: undefined, + }) + await openTerminalSandbox({ onStatus: vi.fn(), - sandboxManagementAuth, + openTerminal: mockOpenTerminal, + teamSlug: 'team-slug', + userId: 'user-123', + sandboxId: 'insecure-sandbox', template: 'base', }) - expect(mockCreateSandbox).toHaveBeenCalledWith('base', { + expect(mockCreateEnvdSandbox).toHaveBeenCalledWith({ + sandboxId: 'insecure-sandbox', + sandboxDomain: 'sandbox.example.com', + envdVersion: '0.2.0', + envdAccessToken: undefined, domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - timeoutMs: 30 * 60 * 1000, - lifecycle: { - onTimeout: 'pause', - autoResume: true, - }, - metadata: { - source: 'dashboard-terminal', - template: 'base', - userId: 'user-123', - }, - headers: { - [AUTHORIZATION_HEADER]: `${BEARER_TOKEN_PREFIX}auth-provider-token`, - [TEAM_ID_HEADER]: 'team-123', - }, + sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, + }) + }) + + it('creates and stores a terminal sandbox when no reusable session exists', async () => { + await openTerminalSandbox({ + onStatus: vi.fn(), + openTerminal: mockOpenTerminal, + teamSlug: 'team-slug', + userId: 'user-123', + template: 'base', + }) + + expect(mockOpenTerminal).toHaveBeenCalledWith({ + teamSlug: 'team-slug', + template: 'base', }) expect(readStoredTerminalSession('user-123')).toEqual({ sandboxId: 'created-sandbox', @@ -326,15 +348,53 @@ describe('dashboard terminal helpers', () => { await openTerminalSandbox({ onStatus: vi.fn(), - sandboxManagementAuth, + openTerminal: mockOpenTerminal, + teamSlug: 'team-slug', + userId: 'user-123', template: 'base', }) - expect(mockConnectSandbox).toHaveBeenCalledWith( - 'stored-sandbox', - expect.anything() - ) - expect(mockCreateSandbox).not.toHaveBeenCalled() + expect(mockOpenTerminal).toHaveBeenCalledWith({ + teamSlug: 'team-slug', + template: 'base', + sandboxId: 'stored-sandbox', + requestTimeoutMs: undefined, + }) + }) + + it('falls back to creating a new sandbox when reconnecting fails', async () => { + writeStoredTerminalSession('user-123', { + sandboxId: 'stored-sandbox', + template: 'base', + }) + + mockOpenTerminal.mockImplementationOnce(async () => { + throw new Error('Failed to connect to terminal sandbox') + }) + + await openTerminalSandbox({ + onStatus: vi.fn(), + openTerminal: mockOpenTerminal, + teamSlug: 'team-slug', + userId: 'user-123', + template: 'base', + }) + + // First call attempts the stored sandbox, second call creates a new one. + expect(mockOpenTerminal).toHaveBeenNthCalledWith(1, { + teamSlug: 'team-slug', + template: 'base', + sandboxId: 'stored-sandbox', + requestTimeoutMs: undefined, + }) + expect(mockOpenTerminal).toHaveBeenNthCalledWith(2, { + teamSlug: 'team-slug', + template: 'base', + }) + expect(readStoredTerminalSession('user-123')).toEqual({ + sandboxId: 'created-sandbox', + template: 'base', + }) }) }) }) diff --git a/tests/unit/sandbox-side-effects.test.ts b/tests/unit/sandbox-side-effects.test.ts new file mode 100644 index 000000000..c455d4c61 --- /dev/null +++ b/tests/unit/sandbox-side-effects.test.ts @@ -0,0 +1,148 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTRPCContext } from '@/core/server/trpc/init' +import { SANDBOX_RESUME_TIMEOUT_MS } from '@/features/dashboard/sandbox/inspect/constants' +import { TERMINAL_SANDBOX_TIMEOUT_MS } from '@/features/dashboard/terminal/constants' + +/** + * Guards the lifecycle invariant for the sandbox debug/inspect views: + * + * - Connecting to a sandbox from the inspect/terminal CLIENT must never touch + * the control plane (`Sandbox.connect`/`Sandbox.create`), because those + * resume paused sandboxes and can extend a running sandbox's TTL. + * - The only control-plane calls live in the tRPC router, and they happen on + * EXPLICIT user actions (open terminal / resume) with a deliberate, + * bounded `timeoutMs`. + */ + +const sdkMock = vi.hoisted(() => ({ + connect: vi.fn(), + create: vi.fn(), + getFullInfo: vi.fn(), +})) + +vi.mock('e2b', () => ({ + Sandbox: { + connect: sdkMock.connect, + create: sdkMock.create, + getFullInfo: sdkMock.getFullInfo, + }, + TimeoutError: class TimeoutError extends Error {}, +})) + +const authMock = vi.hoisted(() => ({ getAuthContext: vi.fn() })) +vi.mock('@/core/server/auth', () => ({ + getAuthContext: authMock.getAuthContext, +})) + +const teamMock = vi.hoisted(() => ({ getTeamIdFromSlug: vi.fn() })) +vi.mock('@/core/server/functions/team/get-team-id-from-slug', () => ({ + getTeamIdFromSlug: teamMock.getTeamIdFromSlug, +})) + +const { createCallerFactory } = await import('@/core/server/trpc/init') +const { sandboxRouter } = await import('@/core/server/api/routers/sandbox') + +const createCaller = createCallerFactory(sandboxRouter) + +async function caller() { + const ctx = await createTRPCContext({ headers: new Headers() }) + return createCaller(ctx) +} + +describe('sandbox lifecycle side effects', () => { + beforeEach(() => { + vi.clearAllMocks() + authMock.getAuthContext.mockResolvedValue({ + user: { id: 'user-1' }, + accessToken: 'access-token', + }) + teamMock.getTeamIdFromSlug.mockResolvedValue({ ok: true, data: 'team-1' }) + sdkMock.connect.mockResolvedValue({ sandboxId: 'sbx_existing' }) + sdkMock.create.mockResolvedValue({ sandboxId: 'sbx_new' }) + sdkMock.getFullInfo.mockResolvedValue({ + sandboxDomain: 'sandbox.example.com', + envdVersion: '0.2.0', + envdAccessToken: 'envd-token', + }) + }) + + describe('sandbox.resume (explicit user action)', () => { + it('resumes via the control plane with the bounded resume TTL only', async () => { + const c = await caller() + const result = await c.resume({ + teamSlug: 'team-slug', + sandboxId: 'sbxexisting', + }) + + // Resume is the only inspect-side control-plane connect, and it sets an + // explicit, bounded TTL — never the SDK default. + expect(sdkMock.connect).toHaveBeenCalledTimes(1) + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + expect.objectContaining({ timeoutMs: SANDBOX_RESUME_TIMEOUT_MS }) + ) + expect(sdkMock.create).not.toHaveBeenCalled() + expect(result.envdAccessToken).toBe('envd-token') + }) + }) + + describe('sandbox.openTerminal (explicit user action)', () => { + it('connects to an existing sandbox with the explicit terminal TTL', async () => { + const c = await caller() + await c.openTerminal({ + teamSlug: 'team-slug', + template: 'base', + sandboxId: 'sbxexisting', + }) + + expect(sdkMock.connect).toHaveBeenCalledTimes(1) + expect(sdkMock.connect).toHaveBeenCalledWith( + 'sbxexisting', + expect.objectContaining({ timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS }) + ) + expect(sdkMock.create).not.toHaveBeenCalled() + }) + + it('creates a new sandbox (never connect) when no sandbox id is given', async () => { + const c = await caller() + await c.openTerminal({ teamSlug: 'team-slug', template: 'base' }) + + expect(sdkMock.create).toHaveBeenCalledTimes(1) + expect(sdkMock.connect).not.toHaveBeenCalled() + }) + }) +}) + +describe('sandbox inspect/terminal client never calls the control plane', () => { + const read = (relativePath: string) => + readFileSync(join(process.cwd(), relativePath), 'utf8') + + // Client modules that connect to sandboxes for the debug/inspect views. + const clientModules = [ + 'src/features/dashboard/sandbox/inspect/context.tsx', + 'src/features/dashboard/terminal/sandbox-session.ts', + 'src/features/dashboard/terminal/dashboard-terminal.tsx', + 'src/features/dashboard/sandbox/terminal/view.tsx', + ] + + it.each( + clientModules + )('%s does not call Sandbox.connect / Sandbox.create', (modulePath) => { + const source = read(modulePath) + expect(source).not.toMatch(/Sandbox\.connect\s*\(/) + expect(source).not.toMatch(/Sandbox\.create\s*\(/) + }) + + it('the inspect context builds an envd-only client via createEnvdSandbox', () => { + const source = read('src/features/dashboard/sandbox/inspect/context.tsx') + expect(source).toMatch(/createEnvdSandbox\(/) + }) + + it('createEnvdSandbox constructs envd-only and never calls the control plane', () => { + const source = read('src/core/shared/create-envd-sandbox.ts') + expect(source).not.toMatch(/Sandbox\.connect\s*\(/) + expect(source).not.toMatch(/Sandbox\.create\s*\(/) + }) +})