diff --git a/src/core/server/actions/terminal-actions.ts b/src/core/server/actions/terminal-actions.ts deleted file mode 100644 index 7dca407c1..000000000 --- a/src/core/server/actions/terminal-actions.ts +++ /dev/null @@ -1,138 +0,0 @@ -'use server' - -import Sandbox, { TimeoutError } from 'e2b' -import { z } from 'zod' -import { authHeaders } from '@/configs/api' -import { - authActionClient, - withTeamSlugResolution, -} from '@/core/server/actions/client' -import { returnServerError } from '@/core/server/actions/utils' -import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' -import { SandboxIdSchema } from '@/core/shared/schemas/api' -import { TeamSlugSchema } from '@/core/shared/schemas/team' -import { - TERMINAL_SANDBOX_TIMEOUT_ERROR, - TERMINAL_SANDBOX_TIMEOUT_MS, -} from '@/features/dashboard/terminal/constants' -import { normalizeTerminalTemplate } from '@/features/dashboard/terminal/template' - -const OpenTerminalSandboxSchema = z.object({ - teamSlug: TeamSlugSchema, - template: z.string().min(1, 'Template is required'), - sandboxId: SandboxIdSchema.optional(), - requestTimeoutMs: z.number().int().positive().optional(), -}) - -/** - * Sandbox-scoped credentials returned to the client. These authenticate only - * against the sandbox's envd daemon (PTY/filesystem) — the account-level - * access token used for the control-plane create/connect never leaves the - * server. - */ -export interface TerminalSandboxConnection { - sandboxId: string - sandboxDomain?: string - envdVersion: string - envdAccessToken: string -} - -/** - * Create (or connect to) a terminal sandbox server-side. The control-plane - * calls (`Sandbox.create`/`Sandbox.connect`) require the user's account-level - * access token, so they must run here; the client then builds an envd-only - * client from the returned sandbox-scoped credentials. - */ -export const openTerminalSandboxAction = authActionClient - .schema(OpenTerminalSandboxSchema) - .metadata({ actionName: 'openTerminalSandbox' }) - .use(withTeamSlugResolution) - .action(async ({ parsedInput, ctx }): Promise => { - const { sandboxId, template, requestTimeoutMs } = parsedInput - const { session, teamId } = ctx - - const normalizedTemplate = normalizeTerminalTemplate(template) - if (!normalizedTemplate) { - return returnServerError('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, - apiHeaders: { - ...authHeaders(session.access_token, teamId), - }, - } - - let resolvedSandboxId: string - try { - 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, - lifecycle: { - onTimeout: 'pause', - autoResume: true, - }, - metadata: { - source: 'dashboard-terminal', - template: normalizedTemplate, - userId: session.user.id, - }, - }) - resolvedSandboxId = sandbox.sandboxId - } - } catch (error) { - l.warn( - { - key: 'open_terminal_sandbox_action:control_plane_error', - error: serializeErrorForLog(error), - user_id: session.user.id, - team_id: teamId, - sandbox_id: sandboxId, - }, - `Failed to open terminal sandbox: ${ - error instanceof Error ? error.message : 'Unknown error' - }` - ) - - // Surface timeouts with a stable sentinel so the client can rethrow a - // TimeoutError and let the attach-retry logic recognize them. - if (error instanceof TimeoutError) { - return returnServerError(TERMINAL_SANDBOX_TIMEOUT_ERROR, { - cause: error, - }) - } - - return returnServerError( - sandboxId - ? 'Failed to connect to terminal sandbox' - : 'Failed to create terminal sandbox', - { cause: error } - ) - } - - // `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. - const info = await Sandbox.getFullInfo(resolvedSandboxId, connectionOpts) - - if (!info.envdAccessToken) { - return returnServerError('Sandbox is not ready for terminal access') - } - - return { - sandboxId: resolvedSandboxId, - sandboxDomain: info.sandboxDomain, - envdVersion: info.envdVersion, - envdAccessToken: info.envdAccessToken, - } - }) diff --git a/src/core/server/api/routers/sandbox.ts b/src/core/server/api/routers/sandbox.ts index b85b46e67..5a8c4c1ba 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 { @@ -18,7 +19,11 @@ import { createTRPCRouter } from '@/core/server/trpc/init' import { protectedTeamProcedure } from '@/core/server/trpc/procedures' import { SandboxIdSchema } from '@/core/shared/schemas/api' import { SANDBOX_MONITORING_METRICS_RETENTION_MS } from '@/features/dashboard/sandbox/monitoring/utils/constants' -import { TERMINAL_SANDBOX_TIMEOUT_MS } from '@/features/dashboard/terminal/constants' +import { + TERMINAL_SANDBOX_TIMEOUT_ERROR, + TERMINAL_SANDBOX_TIMEOUT_MS, +} from '@/features/dashboard/terminal/constants' +import { normalizeTerminalTemplate } from '@/features/dashboard/terminal/template' const sandboxRepositoryProcedure = protectedTeamProcedure.use( withTeamAuthedRequestRepository( @@ -209,6 +214,101 @@ 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), + } + + let resolvedSandboxId: string + try { + 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, + lifecycle: { + onTimeout: 'pause', + autoResume: true, + }, + metadata: { + source: 'dashboard-terminal', + template: normalizedTemplate, + userId: session.user.id, + }, + }) + resolvedSandboxId = sandbox.sandboxId + } + } catch (error) { + // Surface timeouts with a stable sentinel so the client can rethrow a + // TimeoutError and let the attach-retry logic recognize them. + if (error instanceof TimeoutError) { + throw new TRPCError({ + code: 'TIMEOUT', + message: TERMINAL_SANDBOX_TIMEOUT_ERROR, + cause: error, + }) + } + + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: sandboxId + ? 'Failed to connect to terminal sandbox' + : 'Failed to create terminal sandbox', + cause: error, + }) + } + + // `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. + const info = await Sandbox.getFullInfo(resolvedSandboxId, connectionOpts) + + if (!info.envdAccessToken) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Sandbox is not ready for terminal access', + }) + } + + return { + sandboxId: resolvedSandboxId, + sandboxDomain: info.sandboxDomain, + envdVersion: info.envdVersion, + envdAccessToken: info.envdAccessToken, + } + }), + killTerminalPty: protectedTeamProcedure .input( z.object({ diff --git a/src/features/dashboard/terminal/constants.ts b/src/features/dashboard/terminal/constants.ts index 6d97f660f..b0b20349b 100644 --- a/src/features/dashboard/terminal/constants.ts +++ b/src/features/dashboard/terminal/constants.ts @@ -10,7 +10,7 @@ export const TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS = 15_000 export const TERMINAL_ATTACH_MAX_RETRIES = 3 export const TERMINAL_ATTACH_RETRY_BASE_DELAY_MS = 1500 export const TERMINAL_ATTACH_RETRY_MAX_DELAY_MS = 5000 -// Sentinel server-error returned by openTerminalSandboxAction when the -// server-side control-plane connect times out, so the client can rethrow a -// TimeoutError and let the attach-retry logic recognize it. +// Sentinel error message returned by the sandbox.openTerminal tRPC mutation +// when the server-side control-plane connect times out, so the client can +// rethrow a TimeoutError and let the attach-retry logic recognize it. export const TERMINAL_SANDBOX_TIMEOUT_ERROR = 'terminal-sandbox-timeout' diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index 09033594f..59d261698 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -2,6 +2,7 @@ import { type CommandHandle, type Sandbox, TimeoutError } from 'e2b' import { useCallback, useEffect, useRef, useState } from 'react' +import { useTRPCClient } from '@/trpc/client' import { attachTerminalWithRetry } from './attach-terminal' import { DEFAULT_CWD, @@ -52,6 +53,8 @@ export default function DashboardTerminal({ teamSlug, userId, }: DashboardTerminalProps) { + const trpcClient = useTRPCClient() + const [status, setStatus] = useState('idle') const [activeSandboxId, setActiveSandboxId] = useState() const [template, setTemplate] = useState( @@ -332,6 +335,8 @@ export default function DashboardTerminal({ const terminalSandbox = await openTerminalSandbox({ forceNewSandbox: options.forceNewSandbox, onStatus: appendOutput, + openTerminal: (mutationInput) => + trpcClient.sandbox.openTerminal.mutate(mutationInput), requestTimeoutMs: requestedSandboxId ? TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS : undefined, @@ -436,6 +441,7 @@ export default function DashboardTerminal({ getSandbox, runCommand, teamSlug, + trpcClient, sandboxScoped, template, onSandboxAttached, diff --git a/src/features/dashboard/terminal/sandbox-session.ts b/src/features/dashboard/terminal/sandbox-session.ts index 4ceb91c41..3dba58772 100644 --- a/src/features/dashboard/terminal/sandbox-session.ts +++ b/src/features/dashboard/terminal/sandbox-session.ts @@ -1,6 +1,6 @@ import { type Sandbox, TimeoutError } from 'e2b' -import { openTerminalSandboxAction } from '@/core/server/actions/terminal-actions' import { createEnvdSandbox } from '@/core/shared/create-envd-sandbox' +import type { TRPCRouterOutputs } from '@/trpc/client' import { TERMINAL_SANDBOX_TIMEOUT_ERROR } from './constants' import { clearStoredTerminalSession, @@ -8,9 +8,28 @@ import { 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 shouldStoreSession?: boolean teamSlug: string @@ -22,6 +41,7 @@ interface OpenTerminalSandboxOptions { export async function openTerminalSandbox({ forceNewSandbox = false, onStatus, + openTerminal, requestTimeoutMs, shouldStoreSession, teamSlug, @@ -31,12 +51,11 @@ export async function openTerminalSandbox({ }: OpenTerminalSandboxOptions) { if (sandboxId) { onStatus(`Connecting to terminal sandbox ${sandboxId}...\r\n`) - const sandbox = await connectTerminalSandbox({ - teamSlug, - template, - sandboxId, - requestTimeoutMs, - }) + const sandbox = await acquireTerminalSandbox( + openTerminal, + { teamSlug, template, sandboxId, requestTimeoutMs }, + 'Failed to connect to terminal sandbox' + ) return { sandbox, @@ -55,21 +74,33 @@ export async function openTerminalSandbox({ ) try { - sandbox = await connectTerminalSandbox({ - teamSlug, - template, - sandboxId: storedTerminalSession.sandboxId, - 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({ teamSlug, template }) + sandbox = await acquireTerminalSandbox( + openTerminal, + { teamSlug, template }, + 'Failed to create terminal sandbox' + ) } } else { onStatus(`Starting ${template} terminal sandbox...\r\n`) - sandbox = await createTerminalSandbox({ teamSlug, template }) + sandbox = await acquireTerminalSandbox( + openTerminal, + { teamSlug, template }, + 'Failed to create terminal sandbox' + ) } if (shouldStoreSession ?? true) { @@ -84,55 +115,31 @@ export async function openTerminalSandbox({ } } -async function connectTerminalSandbox({ - teamSlug, - template, - sandboxId, - requestTimeoutMs, -}: { - teamSlug: string - template: string - sandboxId: string - requestTimeoutMs?: number -}): Promise { - const result = await openTerminalSandboxAction({ - teamSlug, - template, - sandboxId, - requestTimeoutMs, - }) - - return toEnvdSandbox(result, 'Failed to connect to terminal sandbox') -} - -async function createTerminalSandbox({ - teamSlug, - template, -}: { - teamSlug: string - template: string -}): Promise { - const result = await openTerminalSandboxAction({ teamSlug, template }) - - return toEnvdSandbox(result, 'Failed to create terminal sandbox') -} - -function toEnvdSandbox( - result: Awaited>, +async function acquireTerminalSandbox( + openTerminal: OpenTerminalMutation, + input: OpenTerminalMutationInput, fallbackMessage: string -): Sandbox { - if (!result?.data) { - // Preserve TimeoutError across the server-action boundary so the - // attach-retry logic can recognize and retry transient connect timeouts. - if (result?.serverError === TERMINAL_SANDBOX_TIMEOUT_ERROR) { +): Promise { + let connection: TerminalSandboxConnection + + try { + connection = await openTerminal(input) + } catch (error) { + // Preserve TimeoutError across the tRPC boundary so the attach-retry + // logic can recognize and retry transient connect timeouts. + if ( + error instanceof TimeoutError || + (error instanceof Error && + error.message === TERMINAL_SANDBOX_TIMEOUT_ERROR) + ) { throw new TimeoutError(fallbackMessage) } - throw new Error(result?.serverError ?? fallbackMessage) + throw error instanceof Error ? error : new Error(fallbackMessage) } return createEnvdSandbox({ - ...result.data, + ...connection, domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, sandboxUrl: process.env.NEXT_PUBLIC_E2B_SANDBOX_URL, }) diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts index 233a9e9d3..6968b1080 100644 --- a/tests/unit/dashboard-terminal.test.ts +++ b/tests/unit/dashboard-terminal.test.ts @@ -13,21 +13,19 @@ import { } from '@/features/dashboard/terminal/template' import { calculateTerminalSize } from '@/features/dashboard/terminal/terminal-size' -const { mockOpenTerminalSandboxAction, mockCreateEnvdSandbox } = vi.hoisted( - () => ({ - mockOpenTerminalSandboxAction: vi.fn(), - mockCreateEnvdSandbox: vi.fn(), - }) -) - -vi.mock('@/core/server/actions/terminal-actions', () => ({ - openTerminalSandboxAction: mockOpenTerminalSandboxAction, +const { mockCreateEnvdSandbox } = vi.hoisted(() => ({ + mockCreateEnvdSandbox: vi.fn(), })) 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() @@ -54,19 +52,15 @@ describe('dashboard terminal helpers', () => { beforeEach(() => { vi.clearAllMocks() installLocalStorage() - // The server action returns sandbox-scoped envd credentials; for an + // 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. - mockOpenTerminalSandboxAction.mockImplementation( - async (input: { sandboxId?: string }) => ({ - data: { - sandboxId: input.sandboxId ?? 'created-sandbox', - sandboxDomain: 'sandbox.example.com', - envdVersion: '0.2.0', - envdAccessToken: 'envd-token', - }, - }) - ) + 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 }) ) @@ -349,16 +343,18 @@ describe('dashboard terminal helpers', () => { await openTerminalSandbox({ onStatus: (message) => statuses.push(message), + openTerminal: mockOpenTerminal, teamSlug: 'team-slug', userId: 'user-123', sandboxId: 'sandbox-from-url', template: 'base', }) - expect(mockOpenTerminalSandboxAction).toHaveBeenCalledWith({ + expect(mockOpenTerminal).toHaveBeenCalledWith({ teamSlug: 'team-slug', template: 'base', sandboxId: 'sandbox-from-url', + requestTimeoutMs: undefined, }) expect(mockCreateEnvdSandbox).toHaveBeenCalledWith({ sandboxId: 'sandbox-from-url', @@ -377,12 +373,13 @@ describe('dashboard terminal helpers', () => { 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(mockOpenTerminalSandboxAction).toHaveBeenCalledWith({ + expect(mockOpenTerminal).toHaveBeenCalledWith({ teamSlug: 'team-slug', template: 'base', }) @@ -400,15 +397,17 @@ describe('dashboard terminal helpers', () => { await openTerminalSandbox({ onStatus: vi.fn(), + openTerminal: mockOpenTerminal, teamSlug: 'team-slug', userId: 'user-123', template: 'base', }) - expect(mockOpenTerminalSandboxAction).toHaveBeenCalledWith({ + expect(mockOpenTerminal).toHaveBeenCalledWith({ teamSlug: 'team-slug', template: 'base', sandboxId: 'stored-sandbox', + requestTimeoutMs: undefined, }) }) @@ -418,24 +417,26 @@ describe('dashboard terminal helpers', () => { template: 'base', }) - mockOpenTerminalSandboxAction.mockImplementationOnce(async () => ({ - serverError: 'Failed to connect to terminal sandbox', - })) + 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(mockOpenTerminalSandboxAction).toHaveBeenNthCalledWith(1, { + expect(mockOpenTerminal).toHaveBeenNthCalledWith(1, { teamSlug: 'team-slug', template: 'base', sandboxId: 'stored-sandbox', + requestTimeoutMs: undefined, }) - expect(mockOpenTerminalSandboxAction).toHaveBeenNthCalledWith(2, { + expect(mockOpenTerminal).toHaveBeenNthCalledWith(2, { teamSlug: 'team-slug', template: 'base', })