diff --git a/bun.lock b/bun.lock index 2c9788506..a97d38482 100644 --- a/bun.lock +++ b/bun.lock @@ -51,6 +51,7 @@ "@vercel/kv": "^3.0.0", "@vercel/otel": "^1.13.0", "@vercel/speed-insights": "^1.2.0", + "@xterm/xterm": "^6.0.0", "cheerio": "^1.0.0", "chrono-node": "^2.8.4", "class-variance-authority": "^0.7.1", @@ -1016,6 +1017,8 @@ "@vercel/speed-insights": ["@vercel/speed-insights@1.2.0", "", { "peerDependencies": { "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-y9GVzrUJ2xmgtQlzFP2KhVRoCglwfRQgjyfY607aU0hh0Un6d0OUyrJkjuAlsV18qR4zfoFPs/BiIj9YDS6Wzw=="], + "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@3.2.4", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", "ast-v8-to-istanbul": "^0.3.3", "debug": "^4.4.1", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, "peerDependencies": { "@vitest/browser": "3.2.4", "vitest": "3.2.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ=="], "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], diff --git a/package.json b/package.json index 5a8b7b36d..3ffa58f60 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "@vercel/kv": "^3.0.0", "@vercel/otel": "^1.13.0", "@vercel/speed-insights": "^1.2.0", + "@xterm/xterm": "^6.0.0", "cheerio": "^1.0.0", "chrono-node": "^2.8.4", "class-variance-authority": "^0.7.1", diff --git a/src/app/dashboard/terminal/layout.tsx b/src/app/dashboard/terminal/layout.tsx new file mode 100644 index 000000000..c6a1e579a --- /dev/null +++ b/src/app/dashboard/terminal/layout.tsx @@ -0,0 +1,9 @@ +import '@xterm/xterm/css/xterm.css' + +export default function TerminalLayout({ + children, +}: { + children: React.ReactNode +}) { + return children +} diff --git a/src/app/dashboard/terminal/page.tsx b/src/app/dashboard/terminal/page.tsx new file mode 100644 index 000000000..431bc1a7b --- /dev/null +++ b/src/app/dashboard/terminal/page.tsx @@ -0,0 +1,297 @@ +import Link from 'next/link' +import type { Metadata } from 'next/types' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { AUTH_URLS } from '@/configs/urls' +import type { TeamModel } from '@/core/modules/teams/models' +import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' +import { + createDefaultTemplatesRepository, + createTemplatesRepository, +} from '@/core/modules/templates/repository.server' +import { getSessionInsecure } from '@/core/server/functions/auth/get-session' +import getUserByToken from '@/core/server/functions/auth/get-user-by-token' +import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' +import { infra } from '@/core/shared/clients/api' +import { SandboxIdSchema } from '@/core/shared/schemas/api' +import DashboardTerminal from '@/features/dashboard/terminal/dashboard-terminal' +import { normalizeTerminalTemplate } from '@/features/dashboard/terminal/template' +import { Button } from '@/ui/primitives/button' + +export const metadata: Metadata = { + title: 'Terminal - E2B', + robots: 'noindex, nofollow', +} + +interface TerminalPageProps { + searchParams: Promise<{ + command?: string + sandboxId?: string + template?: string + }> +} + +export default async function TerminalPage({ + searchParams, +}: TerminalPageProps) { + const { command = '', sandboxId, template } = await searchParams + const terminalTemplate = normalizeTerminalTemplate(template) + const terminalSandboxId = normalizeTerminalSandboxId(sandboxId) + + if (!terminalTemplate) { + return + } + + if (terminalSandboxId === null) { + return + } + + const session = await getSessionInsecure() + const { data, error } = await getUserByToken(session?.access_token) + + if (error || !data.user || !session) { + return ( + + ) + } + + const teamsRepository = createUserTeamsRepository({ + accessToken: session.access_token, + }) + const teamsResult = await teamsRepository.listUserTeams() + + if (!teamsResult.ok) { + return + } + + const resolvedTeam = await resolveUserTeam(data.user.id, session.access_token) + const team = terminalSandboxId + ? await resolveTerminalSandboxTeam({ + accessToken: session.access_token, + preferredTeamId: resolvedTeam?.id, + sandboxId: terminalSandboxId, + teams: teamsResult.data, + }) + : teamsResult.data.find((candidate) => candidate.id === resolvedTeam?.id) + + if (!team) { + return + } + + const templateAvailable = terminalSandboxId + ? { ok: true as const, available: true } + : await isTerminalTemplateAvailable({ + accessToken: session.access_token, + teamId: team.id, + template: terminalTemplate, + }) + + if (!templateAvailable.ok) { + return ( + + ) + } + + if (!templateAvailable.available) { + return ( + + ) + } + + return ( + + + + ) +} + +function normalizeTerminalSandboxId(sandboxId?: string) { + const value = sandboxId?.trim() + if (!value) return undefined + + const parsedSandboxId = SandboxIdSchema.safeParse(value) + return parsedSandboxId.success ? parsedSandboxId.data : null +} + +async function resolveTerminalSandboxTeam({ + accessToken, + preferredTeamId, + sandboxId, + teams, +}: { + accessToken: string + preferredTeamId?: string + sandboxId: string + teams: TeamModel[] +}) { + if (preferredTeamId) { + const preferredTeam = teams.find((team) => team.id === preferredTeamId) + if ( + preferredTeam && + (await hasSandboxInTeam({ + accessToken, + sandboxId, + teamId: preferredTeam.id, + })) + ) { + return preferredTeam + } + } + + const candidateTeams = teams.filter((team) => team.id !== preferredTeamId) + const teamMatches = await Promise.all( + candidateTeams.map(async (team) => ({ + team, + ownsSandbox: await hasSandboxInTeam({ + accessToken, + sandboxId, + teamId: team.id, + }), + })) + ) + + return teamMatches.find((match) => match.ownsSandbox)?.team ?? null +} + +async function hasSandboxInTeam({ + accessToken, + sandboxId, + teamId, +}: { + accessToken: string + sandboxId: string + teamId: string +}) { + try { + const result = await infra.GET('/sandboxes/{sandboxID}', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + headers: { + ...SUPABASE_AUTH_HEADERS(accessToken, teamId), + }, + cache: 'no-store', + }) + + return result.response.ok && Boolean(result.data) + } catch { + return false + } +} + +async function isTerminalTemplateAvailable({ + accessToken, + teamId, + template, +}: { + accessToken: string + teamId: string + template: string +}) { + if (template === 'base') { + return { ok: true as const, available: true } + } + + const defaultTemplatesRepository = createDefaultTemplatesRepository({ + accessToken, + }) + const teamTemplatesRepository = createTemplatesRepository({ + accessToken, + teamId, + }) + const [defaultTemplates, teamTemplates] = await Promise.all([ + defaultTemplatesRepository.getDefaultTemplatesCached(), + teamTemplatesRepository.getTeamTemplates(), + ]) + + if (!defaultTemplates.ok || !teamTemplates.ok) { + return { ok: false as const } + } + + const templates = [ + ...defaultTemplates.data.templates, + ...teamTemplates.data.templates, + ] + + return { + ok: true as const, + available: templates.some((candidate) => + [ + candidate.templateID, + ...(candidate.aliases ?? []), + ...(candidate.names ?? []), + ].includes(template) + ), + } +} + +function TerminalSignIn({ + sandboxId, + template, +}: { + sandboxId?: string + template: string +}) { + const returnToParams = new URLSearchParams() + + if (template) { + returnToParams.set('template', template) + } + + if (sandboxId) { + returnToParams.set('sandboxId', sandboxId) + } + + const returnToQuery = returnToParams.toString() + const returnTo = `/dashboard/terminal${ + returnToQuery ? `?${returnToQuery}` : '' + }` + const signInHref = `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({ + returnTo, + }).toString()}` + + return ( + + + + Sign in to open a terminal + + The terminal runs in your E2B dashboard account. + + + + + Sign in + + + + + ) +} + +function TerminalUnavailable({ + message = 'We could not resolve a dashboard team for this account.', +}: { + message?: string +}) { + return ( + + + Terminal unavailable + {message} + + + ) +} diff --git a/src/core/server/api/middlewares/auth.ts b/src/core/server/api/middlewares/auth.ts index 76b4fb2a2..ddff37115 100644 --- a/src/core/server/api/middlewares/auth.ts +++ b/src/core/server/api/middlewares/auth.ts @@ -2,6 +2,7 @@ import { context, SpanStatusCode, trace } from '@opentelemetry/api' import { createServerClient, parseCookieHeader, + type SetAllCookies, serializeCookieHeader, } from '@supabase/ssr' import { unauthorizedUserError } from '@/core/server/adapters/errors' @@ -19,7 +20,7 @@ const createSupabaseServerClient = (headers: Headers) => { getAll() { return parseCookieHeader(headers.get('cookie') ?? '') }, - setAll(cookiesToSet) { + setAll(cookiesToSet: Parameters[0]) { cookiesToSet.forEach(({ name, value, options }) => { headers.append( 'Set-Cookie', diff --git a/src/core/server/http/proxy.ts b/src/core/server/http/proxy.ts index 18fd217ad..e49242d32 100644 --- a/src/core/server/http/proxy.ts +++ b/src/core/server/http/proxy.ts @@ -15,6 +15,12 @@ export function isDashboardRoute(pathname: string): boolean { return pathname.startsWith(PROTECTED_URLS.DASHBOARD) } +function isDashboardTerminalRoute(pathname: string): boolean { + return ( + pathname === '/dashboard/terminal' || pathname === '/dashboard/terminal/' + ) +} + export function buildRedirectUrl(path: string, request: NextRequest): URL { return new URL(path, request.url) } @@ -23,7 +29,11 @@ export function getAuthRedirect( request: NextRequest, isAuthenticated: boolean ): NextResponse | null { - if (isDashboardRoute(request.nextUrl.pathname) && !isAuthenticated) { + if ( + isDashboardRoute(request.nextUrl.pathname) && + !isDashboardTerminalRoute(request.nextUrl.pathname) && + !isAuthenticated + ) { return NextResponse.redirect(buildRedirectUrl(AUTH_URLS.SIGN_IN, request)) } diff --git a/src/features/dashboard/layouts/header.tsx b/src/features/dashboard/layouts/header.tsx index 25ac0834e..e1a780ee7 100644 --- a/src/features/dashboard/layouts/header.tsx +++ b/src/features/dashboard/layouts/header.tsx @@ -54,7 +54,7 @@ export default function DashboardLayoutHeader({ )} - + diff --git a/src/features/dashboard/terminal/constants.ts b/src/features/dashboard/terminal/constants.ts new file mode 100644 index 000000000..514d3ac8c --- /dev/null +++ b/src/features/dashboard/terminal/constants.ts @@ -0,0 +1,7 @@ +export const TERMINAL_SANDBOX_TIMEOUT_MS = 30 * 60 * 1000 +export const DEFAULT_COLS = 100 +export const DEFAULT_ROWS = 28 +export const DEFAULT_PANEL_HEIGHT = 260 +export const MAX_TERMINAL_TRANSCRIPT_CHARS = 200_000 +export const TERMINAL_SESSION_STORAGE_PREFIX = 'dashboard-terminal-session' +export const DEFAULT_CWD = '/home/user' diff --git a/src/features/dashboard/terminal/dashboard-terminal-command-dialog.tsx b/src/features/dashboard/terminal/dashboard-terminal-command-dialog.tsx new file mode 100644 index 000000000..248c63c1c --- /dev/null +++ b/src/features/dashboard/terminal/dashboard-terminal-command-dialog.tsx @@ -0,0 +1,76 @@ +'use client' + +import { Button } from '@/ui/primitives/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/ui/primitives/dialog' +import { WarningIcon } from '@/ui/primitives/icons' +import type { PendingTerminalLaunch } from './types' + +interface DashboardTerminalCommandDialogProps { + launch: PendingTerminalLaunch | null + onCancel: () => void + onConfirm: () => void +} + +export default function DashboardTerminalCommandDialog({ + launch, + onCancel, + onConfirm, +}: DashboardTerminalCommandDialogProps) { + return ( + !open && onCancel()}> + + + + + + Review terminal command + + This command will run inside a persistent E2B sandbox after the + terminal opens. + + + + {launch ? ( + + {launch.sandboxId ? ( + + Sandbox + + {launch.sandboxId} + + + ) : null} + + Template + + {launch.template} + + + + Command + + {launch.command} + + + + ) : null} + + + + Cancel + + + Run command + + + + + ) +} diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx new file mode 100644 index 000000000..3269dc675 --- /dev/null +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -0,0 +1,479 @@ +'use client' + +import { Terminal as XTerm } from '@xterm/xterm' +import type Sandbox from 'e2b' +import type { CommandHandle } from 'e2b' +import { useCallback, useEffect, useRef, useState } from 'react' +import { + DEFAULT_COLS, + DEFAULT_CWD, + DEFAULT_ROWS, + MAX_TERMINAL_TRANSCRIPT_CHARS, +} from './constants' +import DashboardTerminalCommandDialog from './dashboard-terminal-command-dialog' +import { openTerminalSandbox } from './sandbox-session' +import { + normalizeTerminalTemplate, + resolveTerminalTemplateOverride, +} from './template' +import TerminalPanel from './terminal-panel' +import { calculateTerminalSize } from './terminal-size' +import type { + PendingTerminalLaunch, + StartTerminalOptions, + TerminalStatus, +} from './types' + +const INITIAL_TERMINAL_TEXT = + 'Open a terminal to start a persistent E2B sandbox.\r\n' +const TERMINAL_THEME = { + background: '#000000', + cursor: '#ffffff', + foreground: '#ffffff', + selectionBackground: '#ffffff40', +} + +interface DashboardTerminalProps { + autoStart?: boolean + initialCommand?: string + initialSandboxId?: string + initialTemplate?: string + teamId: string +} + +export default function DashboardTerminal({ + autoStart = false, + initialCommand = '', + initialSandboxId, + initialTemplate, + teamId, +}: DashboardTerminalProps) { + const [status, setStatus] = useState('idle') + const [activeSandboxId, setActiveSandboxId] = useState() + const [template, setTemplate] = useState( + normalizeTerminalTemplate(initialTemplate) ?? 'base' + ) + const [pendingLaunch, setPendingLaunch] = + useState(null) + + const sandboxRef = useRef(null) + const ptyRef = useRef(null) + const pidRef = useRef(undefined) + const xtermRef = useRef(null) + const terminalContainerRef = useRef(null) + const terminalTranscriptRef = useRef(INITIAL_TERMINAL_TEXT) + const terminalSizeRef = useRef({ cols: DEFAULT_COLS, rows: DEFAULT_ROWS }) + const decoderRef = useRef(new TextDecoder()) + const pendingCommandsRef = useRef([]) + const inputQueueRef = useRef(Promise.resolve()) + const didAutoStartRef = useRef(false) + const isStartingRef = useRef(false) + const startGenerationRef = useRef(0) + + const resizeTerminal = useCallback(() => { + const nextSize = calculateTerminalSize( + terminalContainerRef.current, + xtermRef.current + ) + terminalSizeRef.current = nextSize + xtermRef.current?.resize(nextSize.cols, nextSize.rows) + + if (sandboxRef.current && pidRef.current) { + void sandboxRef.current.pty.resize(pidRef.current, nextSize) + } + + return nextSize + }, []) + + const appendOutput = useCallback((chunk: string | Uint8Array) => { + const text = + typeof chunk === 'string' + ? chunk + : decoderRef.current.decode(chunk, { stream: true }) + + terminalTranscriptRef.current = ( + terminalTranscriptRef.current + text + ).slice(-MAX_TERMINAL_TRANSCRIPT_CHARS) + xtermRef.current?.write(chunk, () => { + xtermRef.current?.scrollToBottom() + }) + }, []) + + const disconnectTerminal = useCallback(async () => { + const pty = ptyRef.current + ptyRef.current = null + if (!pty) return + + try { + await pty.disconnect() + } catch { + // Best-effort cleanup. The sandbox is intentionally left alive to pause. + } + }, []) + + const sendInputToPty = useCallback( + (value: string | Uint8Array, terminalPid = pidRef.current) => { + if (!value || !sandboxRef.current || !terminalPid) return + + const sandbox = sandboxRef.current + const data = + typeof value === 'string' ? new TextEncoder().encode(value) : value + + inputQueueRef.current = inputQueueRef.current + .catch(() => undefined) + .then(() => sandbox.pty.sendInput(terminalPid, data)) + }, + [] + ) + + const runCommand = useCallback( + (command: string, terminalPid?: number) => { + const normalizedCommand = command.trim() + if (!normalizedCommand) return + + sendInputToPty(`${normalizedCommand}\r`, terminalPid) + }, + [sendInputToPty] + ) + + const updateTerminalUrl = useCallback( + ({ + clearCommand = false, + sandboxId, + }: { + clearCommand?: boolean + sandboxId: string + }) => { + const url = new URL(window.location.href) + let changed = false + + if (url.searchParams.get('sandboxId') !== sandboxId) { + url.searchParams.set('sandboxId', sandboxId) + changed = true + } + + if (clearCommand && url.searchParams.has('command')) { + url.searchParams.delete('command') + changed = true + } + + if (changed) { + window.history.replaceState(window.history.state, '', url) + } + }, + [] + ) + + const startTerminal = useCallback( + async (options: StartTerminalOptions = {}) => { + if (isStartingRef.current) return + const nextTemplate = resolveTerminalTemplateOverride( + options.template, + template + ) + + if (!nextTemplate) { + setStatus('error') + appendOutput('Invalid terminal template.\r\n') + return + } + + isStartingRef.current = true + const startGeneration = startGenerationRef.current + 1 + startGenerationRef.current = startGeneration + const isCurrentStart = () => + startGenerationRef.current === startGeneration + + await disconnectTerminal() + sandboxRef.current = null + pidRef.current = undefined + decoderRef.current = new TextDecoder() + inputQueueRef.current = Promise.resolve() + terminalTranscriptRef.current = '' + xtermRef.current?.reset() + setStatus('starting') + setActiveSandboxId(options.sandboxId) + setTemplate(nextTemplate) + appendOutput('Opening terminal...\r\n') + + try { + const { sandbox } = await openTerminalSandbox({ + forceNewSandbox: options.forceNewSandbox, + onStatus: appendOutput, + sandboxId: options.sandboxId, + teamId, + template: nextTemplate, + }) + + if (!isCurrentStart()) return + + sandboxRef.current = sandbox + setActiveSandboxId(sandbox.sandboxId) + updateTerminalUrl({ + // Keep ?command= until the confirmed command has an attached sandbox. + clearCommand: pendingCommandsRef.current.length > 0, + sandboxId: sandbox.sandboxId, + }) + appendOutput(`Sandbox ${sandbox.sandboxId} is running.\r\n`) + + appendOutput('Opening PTY...\r\n') + const terminalSize = resizeTerminal() + const pty = await sandbox.pty.create({ + cols: terminalSize.cols, + rows: terminalSize.rows, + timeoutMs: 0, + cwd: DEFAULT_CWD, + onData: (data) => { + appendOutput(data) + }, + }) + + if (!isCurrentStart()) { + try { + await pty.disconnect() + } catch { + // The start was superseded or unmounted; best-effort PTY cleanup. + } + return + } + + ptyRef.current = pty + pidRef.current = pty.pid + resizeTerminal() + setStatus('ready') + appendOutput(`PTY ${pty.pid} attached.\r\n`) + xtermRef.current?.focus() + + const pendingCommands = pendingCommandsRef.current + pendingCommandsRef.current = [] + for (const command of pendingCommands) { + runCommand(command, pty.pid) + } + } catch (error) { + if (!isCurrentStart()) return + + setStatus('error') + appendOutput( + `\r\nFailed to start terminal: ${ + error instanceof Error ? error.message : 'Unknown error' + }\r\n` + ) + } finally { + if (isCurrentStart()) { + // Only the latest start owns the shared starting flag. + isStartingRef.current = false + } + } + }, + [ + appendOutput, + disconnectTerminal, + resizeTerminal, + runCommand, + teamId, + template, + updateTerminalUrl, + ] + ) + + const queueTerminalCommand = useCallback( + (command: string, options: StartTerminalOptions = {}) => { + const nextTemplate = resolveTerminalTemplateOverride( + options.template, + template + ) + + if (!nextTemplate) { + setStatus('error') + appendOutput('Invalid terminal template.\r\n') + return + } + + if (command.trim()) { + // Commands can come from links, so require an explicit click before + // sending anything into the PTY. + setPendingLaunch({ + command: command.trim(), + sandboxId: options.sandboxId, + template: nextTemplate, + }) + return + } + + if (status === 'idle' || status === 'error' || options.forceNewSandbox) { + void startTerminal({ + ...options, + template: nextTemplate, + }) + } + }, + [appendOutput, startTerminal, status, template] + ) + + const confirmPendingLaunch = useCallback(() => { + if (!pendingLaunch) return + + const { + command, + sandboxId: launchSandboxId, + template: launchTemplate, + } = pendingLaunch + + if ( + status === 'ready' && + template === launchTemplate && + (!launchSandboxId || activeSandboxId === launchSandboxId) + ) { + setPendingLaunch(null) + runCommand(command) + if (activeSandboxId) { + updateTerminalUrl({ clearCommand: true, sandboxId: activeSandboxId }) + } + return + } + + if (isStartingRef.current) { + return + } + + setPendingLaunch(null) + pendingCommandsRef.current = [command] + void startTerminal({ + forceNewSandbox: !launchSandboxId && template !== launchTemplate, + sandboxId: launchSandboxId, + template: launchTemplate, + }) + }, [ + activeSandboxId, + pendingLaunch, + runCommand, + startTerminal, + status, + template, + updateTerminalUrl, + ]) + + const copyTerminalText = async () => { + const value = + xtermRef.current?.getSelection() || terminalTranscriptRef.current + if (!value) return + + try { + await navigator.clipboard.writeText(value) + } catch { + appendOutput('\r\nCould not copy terminal output to clipboard.\r\n') + } finally { + xtermRef.current?.focus() + } + } + + useEffect(() => { + if (!autoStart || didAutoStartRef.current) return + + didAutoStartRef.current = true + queueTerminalCommand(initialCommand, { + sandboxId: initialSandboxId, + template: initialTemplate, + }) + }, [ + autoStart, + initialCommand, + initialSandboxId, + initialTemplate, + queueTerminalCommand, + ]) + + useEffect(() => { + return () => { + startGenerationRef.current += 1 + void disconnectTerminal() + } + }, [disconnectTerminal]) + + useEffect(() => { + const container = terminalContainerRef.current + if (!container) return + + const terminal = new XTerm({ + cols: terminalSizeRef.current.cols, + rows: terminalSizeRef.current.rows, + cursorBlink: true, + cursorStyle: 'block', + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontSize: 13, + lineHeight: 1.54, + scrollback: 10_000, + theme: TERMINAL_THEME, + }) + + xtermRef.current = terminal + terminal.open(container) + terminal.write(terminalTranscriptRef.current) + const dataSubscription = terminal.onData((data) => { + sendInputToPty(data) + }) + + requestAnimationFrame(() => { + resizeTerminal() + terminal.focus() + }) + const resizeTimer = window.setTimeout(() => { + resizeTerminal() + }, 100) + + return () => { + window.clearTimeout(resizeTimer) + dataSubscription.dispose() + terminal.dispose() + if (xtermRef.current === terminal) { + xtermRef.current = null + } + } + }, [resizeTerminal, sendInputToPty]) + + useEffect(() => { + const container = terminalContainerRef.current + const resizeObserver = + container && typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(() => { + resizeTerminal() + }) + : null + + if (container) { + resizeObserver?.observe(container) + } + + const handleWindowResize = () => { + resizeTerminal() + } + + window.addEventListener('resize', handleWindowResize) + + return () => { + resizeObserver?.disconnect() + window.removeEventListener('resize', handleWindowResize) + } + }, [resizeTerminal]) + + return ( + <> + xtermRef.current?.focus()} + onCopyTerminalText={() => void copyTerminalText()} + onStartTerminal={(options) => void startTerminal(options)} + /> + + setPendingLaunch(null)} + onConfirm={confirmPendingLaunch} + /> + > + ) +} diff --git a/src/features/dashboard/terminal/sandbox-session.ts b/src/features/dashboard/terminal/sandbox-session.ts new file mode 100644 index 000000000..2e6f86700 --- /dev/null +++ b/src/features/dashboard/terminal/sandbox-session.ts @@ -0,0 +1,121 @@ +import Sandbox from 'e2b' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { supabase } from '@/core/shared/clients/supabase/client' +import { TERMINAL_SANDBOX_TIMEOUT_MS } from './constants' +import { + clearStoredTerminalSession, + readStoredTerminalSession, + writeStoredTerminalSession, +} from './storage' + +interface OpenTerminalSandboxOptions { + forceNewSandbox?: boolean + onStatus: (message: string) => void + sandboxId?: string + teamId: string + template: string +} + +export async function openTerminalSandbox({ + forceNewSandbox = false, + onStatus, + sandboxId, + teamId, + template, +}: OpenTerminalSandboxOptions) { + const { data } = await supabase.auth.getSession() + + if (!data.session) { + throw new Error('You need to sign in before opening a terminal.') + } + + const userId = data.session.user.id + const headers = SUPABASE_AUTH_HEADERS(data.session.access_token, teamId) + + if (sandboxId) { + onStatus(`Connecting to terminal sandbox ${sandboxId}...\r\n`) + const sandbox = await connectTerminalSandbox(sandboxId, headers) + + return { + sandbox, + } + } + + const storedTerminalSession = forceNewSandbox + ? null + : readStoredTerminalSession(userId) + + let sandbox: Sandbox + + if (storedTerminalSession?.template === template) { + onStatus( + `Reconnecting to terminal sandbox ${storedTerminalSession.sandboxId}...\r\n` + ) + + try { + sandbox = await connectTerminalSandbox( + storedTerminalSession.sandboxId, + headers + ) + } catch { + clearStoredTerminalSession(userId) + onStatus('Stored terminal sandbox is unavailable.\r\n') + onStatus(`Starting ${template} terminal sandbox...\r\n`) + sandbox = await createTerminalSandbox({ headers, template, userId }) + } + } else { + onStatus(`Starting ${template} terminal sandbox...\r\n`) + sandbox = await createTerminalSandbox({ headers, template, userId }) + } + + writeStoredTerminalSession(userId, { + sandboxId: sandbox.sandboxId, + template, + }) + + return { + sandbox, + } +} + +function connectTerminalSandbox( + sandboxId: string, + headers: Record +) { + return Sandbox.connect(sandboxId, { + domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS, + headers: { + ...headers, + }, + }) +} + +function createTerminalSandbox({ + headers, + template, + userId, +}: { + headers: Record + template: string + userId: string +}) { + // The browser SDK sends the signed-in user's Supabase token so E2B can + // authorize sandbox ownership without a dashboard proxy endpoint. + return Sandbox.create(template, { + domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS, + lifecycle: { + onTimeout: 'pause', + autoResume: true, + }, + metadata: { + source: 'dashboard-terminal', + template, + userId, + }, + headers: { + ...headers, + }, + }) +} diff --git a/src/features/dashboard/terminal/storage.ts b/src/features/dashboard/terminal/storage.ts new file mode 100644 index 000000000..11106ddef --- /dev/null +++ b/src/features/dashboard/terminal/storage.ts @@ -0,0 +1,59 @@ +import { TERMINAL_SESSION_STORAGE_PREFIX } from './constants' +import { normalizeTerminalTemplate } from './template' +import type { StoredTerminalSession } from './types' + +function getTerminalSessionStorageKey(userId: string) { + return `${TERMINAL_SESSION_STORAGE_PREFIX}:${userId}` +} + +export function readStoredTerminalSession(userId: string) { + try { + const value = window.localStorage.getItem( + getTerminalSessionStorageKey(userId) + ) + if (!value) return null + + const session = JSON.parse(value) as Partial + if (typeof session.sandboxId !== 'string' || !session.sandboxId) { + return null + } + + const template = + session.template === undefined + ? 'base' + : typeof session.template === 'string' + ? normalizeTerminalTemplate(session.template) + : null + + if (!template) return null + + return { + sandboxId: session.sandboxId, + template, + } + } catch { + return null + } +} + +export function writeStoredTerminalSession( + userId: string, + session: StoredTerminalSession +) { + try { + window.localStorage.setItem( + getTerminalSessionStorageKey(userId), + JSON.stringify(session) + ) + } catch { + // Terminal launch should still succeed if browser storage is unavailable. + } +} + +export function clearStoredTerminalSession(userId: string) { + try { + window.localStorage.removeItem(getTerminalSessionStorageKey(userId)) + } catch { + // Best-effort cleanup for unavailable or blocked browser storage. + } +} diff --git a/src/features/dashboard/terminal/template.ts b/src/features/dashboard/terminal/template.ts new file mode 100644 index 000000000..d320bc870 --- /dev/null +++ b/src/features/dashboard/terminal/template.ts @@ -0,0 +1,25 @@ +const DEFAULT_TEMPLATE = 'base' +const TEMPLATE_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]{0,127}$/ + +export function normalizeTerminalTemplate(template?: string) { + const value = template?.trim() + + if (!value) { + return DEFAULT_TEMPLATE + } + + if (!TEMPLATE_PATTERN.test(value)) { + return null + } + + return value +} + +export function resolveTerminalTemplateOverride( + template: string | undefined, + fallback: string +) { + if (template === undefined) return fallback + + return normalizeTerminalTemplate(template) +} diff --git a/src/features/dashboard/terminal/terminal-panel.tsx b/src/features/dashboard/terminal/terminal-panel.tsx new file mode 100644 index 000000000..0236076dc --- /dev/null +++ b/src/features/dashboard/terminal/terminal-panel.tsx @@ -0,0 +1,82 @@ +import type { RefObject } from 'react' +import { IconButton } from '@/ui/primitives/icon-button' +import { + CopyIcon, + RefreshIcon, + TerminalCustomIcon, +} from '@/ui/primitives/icons' +import type { StartTerminalOptions, TerminalStatus } from './types' + +interface TerminalPanelProps { + sandboxId?: string + template: string + status: TerminalStatus + terminalContainerRef: RefObject + onFocusTerminal: () => void + onCopyTerminalText: () => void + onStartTerminal: (options?: StartTerminalOptions) => void +} + +export default function TerminalPanel({ + sandboxId, + template, + status, + terminalContainerRef, + onFocusTerminal, + onCopyTerminalText, + onStartTerminal, +}: TerminalPanelProps) { + return ( + + + + + + Terminal + + + {template} + + {sandboxId ? ( + + {sandboxId} + + ) : null} + + + + event.preventDefault()} + onClick={onCopyTerminalText} + > + + + onStartTerminal({ forceNewSandbox: true })} + > + + + + + + + + ) +} diff --git a/src/features/dashboard/terminal/terminal-size.ts b/src/features/dashboard/terminal/terminal-size.ts new file mode 100644 index 000000000..59669ff60 --- /dev/null +++ b/src/features/dashboard/terminal/terminal-size.ts @@ -0,0 +1,86 @@ +import type { Terminal as XTerm } from '@xterm/xterm' +import { DEFAULT_COLS, DEFAULT_PANEL_HEIGHT, DEFAULT_ROWS } from './constants' + +const MIN_TERMINAL_COLS = 40 +const MIN_TERMINAL_ROWS = 8 +const TERMINAL_PADDING_PX = 24 +const TERMINAL_SCROLLBAR_GUTTER_PX = 44 +const DEFAULT_CELL_WIDTH_PX = 8 +const DEFAULT_CELL_HEIGHT_PX = 20 +const MIN_CELL_WIDTH_PX = 4 +const MAX_CELL_WIDTH_PX = 16 +const MIN_CELL_HEIGHT_PX = 8 +const MAX_CELL_HEIGHT_PX = 40 + +function getElementSize(element: Element | null) { + if (!element) return undefined + + const rect = element.getBoundingClientRect() + if (!rect.width || !rect.height) return undefined + + return rect +} + +function getMeasuredCellSize(terminal: XTerm | null) { + const measureElement = terminal?.element?.querySelector( + '.xterm-char-measure-element' + ) + const rowElement = terminal?.element?.querySelector('.xterm-rows > div') + const measuredCharSize = getElementSize(measureElement ?? null) + const rowSize = getElementSize(rowElement ?? null) + + if (!measuredCharSize && !rowSize) return undefined + + const measuredWidth = measuredCharSize?.width + const measuredHeight = rowSize?.height ?? measuredCharSize?.height + + return { + width: + measuredWidth && + measuredWidth >= MIN_CELL_WIDTH_PX && + measuredWidth <= MAX_CELL_WIDTH_PX + ? measuredWidth + : undefined, + height: + measuredHeight && + measuredHeight >= MIN_CELL_HEIGHT_PX && + measuredHeight <= MAX_CELL_HEIGHT_PX + ? measuredHeight + : undefined, + } +} + +export function calculateTerminalSize( + container: HTMLDivElement | null, + terminal: XTerm | null +) { + if (!container) { + return { cols: DEFAULT_COLS, rows: DEFAULT_ROWS } + } + + const measuredCellSize = getMeasuredCellSize(terminal) + const containerRect = container.getBoundingClientRect() + const containerWidth = + container.clientWidth || containerRect.width || window.innerWidth + const containerHeight = + container.clientHeight || containerRect.height || DEFAULT_PANEL_HEIGHT + const availableWidth = + containerWidth - TERMINAL_PADDING_PX - TERMINAL_SCROLLBAR_GUTTER_PX + const availableHeight = containerHeight - TERMINAL_PADDING_PX + const cellWidth = Math.max( + measuredCellSize?.width ?? DEFAULT_CELL_WIDTH_PX, + 1 + ) + const cellHeight = Math.max( + measuredCellSize?.height ?? DEFAULT_CELL_HEIGHT_PX, + 1 + ) + + return { + cols: Math.max(MIN_TERMINAL_COLS, Math.floor(availableWidth / cellWidth)), + rows: Math.max( + MIN_TERMINAL_ROWS, + Math.floor(availableHeight / cellHeight) - 1 + ), + } +} diff --git a/src/features/dashboard/terminal/types.ts b/src/features/dashboard/terminal/types.ts new file mode 100644 index 000000000..4dd6b42df --- /dev/null +++ b/src/features/dashboard/terminal/types.ts @@ -0,0 +1,18 @@ +export type TerminalStatus = 'idle' | 'starting' | 'ready' | 'error' + +export type StoredTerminalSession = { + sandboxId: string + template: string +} + +export type StartTerminalOptions = { + forceNewSandbox?: boolean + sandboxId?: string + template?: string +} + +export type PendingTerminalLaunch = { + command: string + sandboxId?: string + template: string +} diff --git a/tests/integration/proxy.test.ts b/tests/integration/proxy.test.ts index 555185331..bfdad2771 100644 --- a/tests/integration/proxy.test.ts +++ b/tests/integration/proxy.test.ts @@ -176,6 +176,52 @@ describe('Proxy Integration Tests', () => { expect(NextResponse.next).toHaveBeenCalled() }) + it('allows unauthenticated users to access the terminal launcher', async () => { + vi.mocked(createServerClient).mockImplementation( + () => + ({ + auth: { + getUser: vi.fn().mockResolvedValue({ + data: { user: null }, + error: { message: 'Not authenticated' }, + }), + }, + }) as unknown as ReturnType + ) + + const request = createMockRequest({ + path: '/dashboard/terminal', + }) + + await proxy(request) + + expect(NextResponse.redirect).not.toHaveBeenCalled() + expect(NextResponse.next).toHaveBeenCalled() + }) + + it('allows unauthenticated users to access the terminal launcher with a trailing slash', async () => { + vi.mocked(createServerClient).mockImplementation( + () => + ({ + auth: { + getUser: vi.fn().mockResolvedValue({ + data: { user: null }, + error: { message: 'Not authenticated' }, + }), + }, + }) as unknown as ReturnType + ) + + const request = createMockRequest({ + path: '/dashboard/terminal/', + }) + + await proxy(request) + + expect(NextResponse.redirect).not.toHaveBeenCalled() + expect(NextResponse.next).toHaveBeenCalled() + }) + it('allows unauthenticated users to access public pages', async () => { vi.mocked(createServerClient).mockImplementation( () => diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts new file mode 100644 index 000000000..54675e595 --- /dev/null +++ b/tests/unit/dashboard-terminal.test.ts @@ -0,0 +1,289 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SUPABASE_TEAM_HEADER, SUPABASE_TOKEN_HEADER } from '@/configs/api' +import { TERMINAL_SESSION_STORAGE_PREFIX } from '@/features/dashboard/terminal/constants' +import { openTerminalSandbox } from '@/features/dashboard/terminal/sandbox-session' +import { + clearStoredTerminalSession, + readStoredTerminalSession, + writeStoredTerminalSession, +} from '@/features/dashboard/terminal/storage' +import { + normalizeTerminalTemplate, + resolveTerminalTemplateOverride, +} from '@/features/dashboard/terminal/template' +import { calculateTerminalSize } from '@/features/dashboard/terminal/terminal-size' + +const { mockCreateSandbox, mockConnectSandbox, mockGetSession } = vi.hoisted( + () => ({ + mockCreateSandbox: vi.fn(), + mockConnectSandbox: vi.fn(), + mockGetSession: vi.fn(), + }) +) + +vi.mock('e2b', () => ({ + default: { + connect: mockConnectSandbox, + create: mockCreateSandbox, + }, +})) + +vi.mock('@/core/shared/clients/supabase/client', () => ({ + supabase: { + auth: { + getSession: mockGetSession, + }, + }, +})) + +function installLocalStorage() { + const values = new Map() + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + innerWidth: 1200, + localStorage: { + getItem: vi.fn((key: string) => values.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + values.set(key, value) + }), + removeItem: vi.fn((key: string) => { + values.delete(key) + }), + }, + }, + }) + + return values +} + +describe('dashboard terminal helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + installLocalStorage() + mockGetSession.mockResolvedValue({ + data: { + session: { + access_token: 'supabase-token', + user: { + id: 'user-123', + }, + }, + }, + }) + mockCreateSandbox.mockResolvedValue({ sandboxId: 'created-sandbox' }) + mockConnectSandbox.mockResolvedValue({ sandboxId: 'connected-sandbox' }) + }) + + describe('normalizeTerminalTemplate', () => { + it('defaults blank values to base', () => { + expect(normalizeTerminalTemplate()).toBe('base') + expect(normalizeTerminalTemplate(' ')).toBe('base') + }) + + it('keeps valid template identifiers and rejects unsafe values', () => { + expect(normalizeTerminalTemplate(' python_3.12-dev ')).toBe( + 'python_3.12-dev' + ) + expect(normalizeTerminalTemplate('team-slug/python:default')).toBe( + 'team-slug/python:default' + ) + expect(normalizeTerminalTemplate('base; echo nope')).toBeNull() + expect(normalizeTerminalTemplate('../base')).toBeNull() + }) + }) + + describe('resolveTerminalTemplateOverride', () => { + it('preserves the current template when no override is provided', () => { + expect(resolveTerminalTemplateOverride(undefined, 'python')).toBe( + 'python' + ) + }) + + it('normalizes explicit overrides', () => { + expect(resolveTerminalTemplateOverride(' ', 'python')).toBe('base') + expect(resolveTerminalTemplateOverride('../base', 'python')).toBeNull() + }) + }) + + describe('stored terminal session', () => { + it('round-trips session data by user and ignores invalid stored values', () => { + writeStoredTerminalSession('user-123', { + sandboxId: 'sandbox-123', + template: 'base', + }) + + expect(readStoredTerminalSession('user-123')).toEqual({ + sandboxId: 'sandbox-123', + template: 'base', + }) + expect(readStoredTerminalSession('user-456')).toBeNull() + + window.localStorage.setItem( + `${TERMINAL_SESSION_STORAGE_PREFIX}:user-123`, + JSON.stringify({ template: 'base' }) + ) + expect(readStoredTerminalSession('user-123')).toBeNull() + + window.localStorage.setItem( + `${TERMINAL_SESSION_STORAGE_PREFIX}:user-123`, + JSON.stringify({ sandboxId: 123, template: 'base' }) + ) + expect(readStoredTerminalSession('user-123')).toBeNull() + + window.localStorage.setItem( + `${TERMINAL_SESSION_STORAGE_PREFIX}:user-123`, + JSON.stringify({ sandboxId: 'sandbox-123', template: 123 }) + ) + expect(readStoredTerminalSession('user-123')).toBeNull() + + window.localStorage.setItem( + `${TERMINAL_SESSION_STORAGE_PREFIX}:user-123`, + JSON.stringify({ sandboxId: 'sandbox-123', template: '../base' }) + ) + expect(readStoredTerminalSession('user-123')).toBeNull() + + clearStoredTerminalSession('user-123') + expect(readStoredTerminalSession('user-123')).toBeNull() + }) + + it('treats storage writes and removals as best-effort', () => { + vi.mocked(window.localStorage.setItem).mockImplementationOnce(() => { + throw new Error('Quota exceeded') + }) + vi.mocked(window.localStorage.removeItem).mockImplementationOnce(() => { + throw new Error('Storage blocked') + }) + + expect(() => + writeStoredTerminalSession('user-123', { + sandboxId: 'sandbox-123', + template: 'base', + }) + ).not.toThrow() + expect(() => clearStoredTerminalSession('user-123')).not.toThrow() + }) + }) + + describe('calculateTerminalSize', () => { + it('uses fallback dimensions without a rendered terminal', () => { + const container = { + clientWidth: 900, + clientHeight: 500, + getBoundingClientRect: () => ({ width: 900, height: 500 }), + } as HTMLDivElement + + expect(calculateTerminalSize(container, null)).toEqual({ + cols: 104, + rows: 22, + }) + }) + + it('honors measured xterm cell dimensions when available', () => { + const container = { + clientWidth: 900, + clientHeight: 500, + getBoundingClientRect: () => ({ width: 900, height: 500 }), + } as HTMLDivElement + const terminal = { + element: { + querySelector: (selector: string) => { + if (selector === '.xterm-char-measure-element') { + return { + getBoundingClientRect: () => ({ width: 10, height: 18 }), + } + } + if (selector === '.xterm-rows > div') { + return { + getBoundingClientRect: () => ({ width: 900, height: 22 }), + } + } + return null + }, + }, + } as never + + expect(calculateTerminalSize(container, terminal)).toEqual({ + cols: 83, + rows: 20, + }) + }) + }) + + describe('openTerminalSandbox', () => { + it('connects to an explicit sandbox without writing a stored session', async () => { + const statuses: string[] = [] + + await openTerminalSandbox({ + onStatus: (message) => statuses.push(message), + sandboxId: 'sandbox-from-url', + teamId: 'team-123', + template: 'base', + }) + + expect(mockConnectSandbox).toHaveBeenCalledWith('sandbox-from-url', { + domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + timeoutMs: 30 * 60 * 1000, + headers: { + [SUPABASE_TOKEN_HEADER]: 'supabase-token', + [SUPABASE_TEAM_HEADER]: 'team-123', + }, + }) + expect(mockCreateSandbox).not.toHaveBeenCalled() + 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 () => { + await openTerminalSandbox({ + onStatus: vi.fn(), + teamId: 'team-123', + template: 'base', + }) + + expect(mockCreateSandbox).toHaveBeenCalledWith('base', { + 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: { + [SUPABASE_TOKEN_HEADER]: 'supabase-token', + [SUPABASE_TEAM_HEADER]: 'team-123', + }, + }) + expect(readStoredTerminalSession('user-123')).toEqual({ + sandboxId: 'created-sandbox', + template: 'base', + }) + }) + + it('reuses a stored sandbox only when its template matches', async () => { + writeStoredTerminalSession('user-123', { + sandboxId: 'stored-sandbox', + template: 'base', + }) + + await openTerminalSandbox({ + onStatus: vi.fn(), + teamId: 'team-123', + template: 'base', + }) + + expect(mockConnectSandbox).toHaveBeenCalledWith( + 'stored-sandbox', + expect.anything() + ) + expect(mockCreateSandbox).not.toHaveBeenCalled() + }) + }) +})
+ The terminal runs in your E2B dashboard account. +
{message}
Sandbox
+ {launch.sandboxId} +
Template
+ {launch.template} +
Command
+ {launch.command} +
{launch.command}