From 0f7c8fbb54b4ac798330f85e21a57f40f3e89daf Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Sat, 9 May 2026 22:57:33 -0700 Subject: [PATCH 01/14] Add self-contained dashboard terminal --- bun.lock | 3 + package.json | 1 + src/app/dashboard/terminal/embed/page.tsx | 207 ++++++++ src/app/layout.tsx | 1 + src/core/server/http/proxy.ts | 10 +- src/features/dashboard/layouts/header.tsx | 4 +- src/features/dashboard/terminal/constants.ts | 8 + .../dashboard-terminal-command-dialog.tsx | 68 +++ .../terminal/dashboard-terminal-panel.tsx | 147 ++++++ .../dashboard/terminal/dashboard-terminal.tsx | 459 ++++++++++++++++++ src/features/dashboard/terminal/events.ts | 29 ++ .../dashboard/terminal/sandbox-session.ts | 97 ++++ src/features/dashboard/terminal/storage.ts | 39 ++ src/features/dashboard/terminal/template.ts | 16 + .../dashboard/terminal/terminal-size.ts | 86 ++++ src/features/dashboard/terminal/types.ts | 22 + 16 files changed, 1195 insertions(+), 2 deletions(-) create mode 100644 src/app/dashboard/terminal/embed/page.tsx create mode 100644 src/features/dashboard/terminal/constants.ts create mode 100644 src/features/dashboard/terminal/dashboard-terminal-command-dialog.tsx create mode 100644 src/features/dashboard/terminal/dashboard-terminal-panel.tsx create mode 100644 src/features/dashboard/terminal/dashboard-terminal.tsx create mode 100644 src/features/dashboard/terminal/events.ts create mode 100644 src/features/dashboard/terminal/sandbox-session.ts create mode 100644 src/features/dashboard/terminal/storage.ts create mode 100644 src/features/dashboard/terminal/template.ts create mode 100644 src/features/dashboard/terminal/terminal-size.ts create mode 100644 src/features/dashboard/terminal/types.ts 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/embed/page.tsx b/src/app/dashboard/terminal/embed/page.tsx new file mode 100644 index 000000000..05194e1f9 --- /dev/null +++ b/src/app/dashboard/terminal/embed/page.tsx @@ -0,0 +1,207 @@ +import Link from 'next/link' +import type { Metadata } from 'next/types' +import { AUTH_URLS } from '@/configs/urls' +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 { DashboardContextProvider } from '@/features/dashboard/context' +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 TerminalEmbedPageProps { + searchParams: Promise<{ + command?: string + template?: string + }> +} + +export default async function TerminalEmbedPage({ + searchParams, +}: TerminalEmbedPageProps) { + const { command = '', template } = await searchParams + const terminalTemplate = normalizeTerminalTemplate(template) + + if (!terminalTemplate) { + 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() + const resolvedTeam = await resolveUserTeam(data.user.id, session.access_token) + + if (!teamsResult.ok || !resolvedTeam) { + return + } + + const team = teamsResult.data.find( + (candidate) => candidate.id === resolvedTeam.id + ) + + if (!team) { + return + } + + const templateAvailable = await isTerminalTemplateAvailable({ + accessToken: session.access_token, + teamId: team.id, + template: terminalTemplate, + }) + + if (!templateAvailable.ok) { + return ( + + ) + } + + if (!templateAvailable.available) { + return ( + + ) + } + + return ( + +
+ +
+
+ ) +} + +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 TerminalEmbedSignIn({ + command, + template, +}: { + command: string + template: string +}) { + const returnToParams = new URLSearchParams() + + if (template) { + returnToParams.set('template', template) + } + + if (command) { + returnToParams.set('command', command) + } + + const returnToQuery = returnToParams.toString() + const returnTo = `/dashboard/terminal/embed${ + 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. +

+
+ +
+
+ ) +} + +function TerminalEmbedUnavailable({ + message = 'We could not resolve a dashboard team for this account.', +}: { + message?: string +}) { + return ( +
+
+

Terminal unavailable

+

{message}

+
+
+ ) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 412708168..3f2e3509b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import '@/app/fonts' import '@/styles/globals.css' +import '@xterm/xterm/css/xterm.css' import { Analytics } from '@vercel/analytics/next' import { SpeedInsights } from '@vercel/speed-insights/next' diff --git a/src/core/server/http/proxy.ts b/src/core/server/http/proxy.ts index 18fd217ad..2b0f704db 100644 --- a/src/core/server/http/proxy.ts +++ b/src/core/server/http/proxy.ts @@ -15,6 +15,10 @@ export function isDashboardRoute(pathname: string): boolean { return pathname.startsWith(PROTECTED_URLS.DASHBOARD) } +function isDashboardTerminalEmbedRoute(pathname: string): boolean { + return pathname === '/dashboard/terminal/embed' +} + export function buildRedirectUrl(path: string, request: NextRequest): URL { return new URL(path, request.url) } @@ -23,7 +27,11 @@ export function getAuthRedirect( request: NextRequest, isAuthenticated: boolean ): NextResponse | null { - if (isDashboardRoute(request.nextUrl.pathname) && !isAuthenticated) { + if ( + isDashboardRoute(request.nextUrl.pathname) && + !isDashboardTerminalEmbedRoute(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..116071fe7 100644 --- a/src/features/dashboard/layouts/header.tsx +++ b/src/features/dashboard/layouts/header.tsx @@ -9,6 +9,7 @@ import ClientOnly from '@/ui/client-only' import CopyButton from '@/ui/copy-button' import { SidebarTrigger } from '@/ui/primitives/sidebar' import { ThemeSwitcher } from '@/ui/theme-switcher' +import DashboardTerminal from '../terminal/dashboard-terminal' interface DashboardLayoutHeaderProps { className?: string @@ -54,7 +55,8 @@ 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..ed57d3579 --- /dev/null +++ b/src/features/dashboard/terminal/constants.ts @@ -0,0 +1,8 @@ +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 MIN_PANEL_HEIGHT = 160 +export const MAX_PANEL_HEIGHT_RATIO = 0.72 +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..ac4850e07 --- /dev/null +++ b/src/features/dashboard/terminal/dashboard-terminal-command-dialog.tsx @@ -0,0 +1,68 @@ +'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 ? ( +
+
+

Template

+ + {launch.template} + +
+
+

Command

+
+                {launch.command}
+              
+
+
+ ) : null} + + + + + +
+
+ ) +} diff --git a/src/features/dashboard/terminal/dashboard-terminal-panel.tsx b/src/features/dashboard/terminal/dashboard-terminal-panel.tsx new file mode 100644 index 000000000..65d4ab7e0 --- /dev/null +++ b/src/features/dashboard/terminal/dashboard-terminal-panel.tsx @@ -0,0 +1,147 @@ +import type { PointerEvent, RefObject } from 'react' +import { createPortal } from 'react-dom' +import { Button } from '@/ui/primitives/button' +import { + CloseIcon, + CopyIcon, + RefreshIcon, + TerminalCustomIcon, +} from '@/ui/primitives/icons' +import type { StartTerminalOptions, TerminalStatus } from './types' + +interface DashboardTerminalPanelProps { + isOpen: boolean + portalRoot: HTMLElement | null + variant?: 'fixed' | 'embedded' + panelHeight: number + sandboxId?: string + template: string + status: TerminalStatus + terminalContainerRef: RefObject + onResizeStart: (event: PointerEvent) => void + onResizeMove: (pointerY: number) => void + onResizeStop: (event: PointerEvent) => void + onFocusTerminal: () => void + onCopyTerminalText: () => void + onStartTerminal: (options?: StartTerminalOptions) => void + onClose: () => void +} + +export default function DashboardTerminalPanel({ + isOpen, + portalRoot, + variant = 'fixed', + panelHeight, + sandboxId, + template, + status, + terminalContainerRef, + onResizeStart, + onResizeMove, + onResizeStop, + onFocusTerminal, + onCopyTerminalText, + onStartTerminal, + onClose, +}: DashboardTerminalPanelProps) { + if (!isOpen) return null + + const panel = ( +
+ {variant === 'fixed' ? ( + + ) : null} + +
+
+ + + Terminal + + + {template} + + {sandboxId ? ( + + {sandboxId} + + ) : null} +
+ +
+ + + {variant === 'fixed' ? ( + + ) : null} +
+
+ +
+
+ ) + + if (variant === 'embedded') { + return panel + } + + if (!portalRoot) return null + + return createPortal(panel, portalRoot) +} diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx new file mode 100644 index 000000000..e3af993c4 --- /dev/null +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -0,0 +1,459 @@ +'use client' + +import { Terminal as XTerm } from '@xterm/xterm' +import type Sandbox from 'e2b' +import type { CommandHandle } from 'e2b' +import { + type PointerEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react' +import { Button } from '@/ui/primitives/button' +import { SpinnerIcon, TerminalCustomIcon } from '@/ui/primitives/icons' +import { useDashboard } from '../context' +import { + DEFAULT_COLS, + DEFAULT_CWD, + DEFAULT_PANEL_HEIGHT, + DEFAULT_ROWS, + MAX_PANEL_HEIGHT_RATIO, + MIN_PANEL_HEIGHT, +} from './constants' +import DashboardTerminalCommandDialog from './dashboard-terminal-command-dialog' +import DashboardTerminalPanel from './dashboard-terminal-panel' +import { DASHBOARD_TERMINAL_COMMAND_EVENT } from './events' +import { openTerminalSandbox } from './sandbox-session' +import { normalizeTerminalTemplate } from './template' +import { calculateTerminalSize } from './terminal-size' +import type { + DashboardTerminalCommandDetail, + 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 + initialTemplate?: string + variant?: 'button' | 'embedded' +} + +export default function DashboardTerminal({ + autoStart = false, + initialCommand = '', + initialTemplate, + variant = 'button', +}: DashboardTerminalProps) { + const { team } = useDashboard() + const isEmbedded = variant === 'embedded' + const [isOpen, setIsOpen] = useState(isEmbedded) + const [status, setStatus] = useState('idle') + const [sandboxId, setSandboxId] = useState() + const [template, setTemplate] = useState( + normalizeTerminalTemplate(initialTemplate) ?? 'base' + ) + const [panelHeight, setPanelHeight] = useState(DEFAULT_PANEL_HEIGHT) + const [portalRoot, setPortalRoot] = useState(null) + 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 resizeStartRef = useRef<{ + pointerY: number + panelHeight: number + } | null>(null) + + 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 += text + xtermRef.current?.write(chunk, () => { + xtermRef.current?.scrollToBottom() + }) + }, []) + + const disconnectTerminal = 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 startTerminal = async (options: StartTerminalOptions = {}) => { + if (status === 'starting') return + + const nextTemplate = normalizeTerminalTemplate(options.template) ?? template + await disconnectTerminal() + sandboxRef.current = null + pidRef.current = undefined + decoderRef.current = new TextDecoder() + inputQueueRef.current = Promise.resolve() + terminalTranscriptRef.current = '' + xtermRef.current?.reset() + setStatus('starting') + setSandboxId(undefined) + setTemplate(nextTemplate) + appendOutput('Opening terminal...\r\n') + + try { + const { sandbox } = await openTerminalSandbox({ + forceNewSandbox: options.forceNewSandbox, + onStatus: appendOutput, + teamId: team.id, + template: nextTemplate, + }) + + sandboxRef.current = sandbox + setSandboxId(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) + }, + }) + + 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) { + setStatus('error') + appendOutput( + `\r\nFailed to start terminal: ${ + error instanceof Error ? error.message : 'Unknown error' + }\r\n` + ) + } + } + + const openTerminal = () => { + setIsOpen(true) + if (status === 'idle' || status === 'error') { + void startTerminal() + } + } + + const queueTerminalCommand = ( + command: string, + options: StartTerminalOptions = {} + ) => { + const nextTemplate = normalizeTerminalTemplate(options.template) + + if (!nextTemplate) { + setIsOpen(true) + setStatus('error') + appendOutput('Invalid terminal template.\r\n') + return + } + + setIsOpen(true) + if (command.trim()) { + setPendingLaunch({ + command: command.trim(), + template: nextTemplate, + }) + return + } + + if (status === 'idle' || status === 'error' || options.forceNewSandbox) { + void startTerminal({ + ...options, + template: nextTemplate, + }) + } + } + + const confirmPendingLaunch = () => { + if (!pendingLaunch) return + + const { command, template: launchTemplate } = pendingLaunch + setPendingLaunch(null) + + if (status === 'ready' && template === launchTemplate) { + runCommand(command) + return + } + + pendingCommandsRef.current = [command] + void startTerminal({ + forceNewSandbox: template !== launchTemplate, + template: launchTemplate, + }) + } + + const copyTerminalText = async () => { + const value = + xtermRef.current?.getSelection() || terminalTranscriptRef.current + if (!value) return + + await navigator.clipboard.writeText(value) + xtermRef.current?.focus() + } + + const resizePanel = (pointerY: number) => { + const resizeStart = resizeStartRef.current + if (!resizeStart) return + + const maxHeight = Math.floor(window.innerHeight * MAX_PANEL_HEIGHT_RATIO) + const delta = resizeStart.pointerY - pointerY + const nextHeight = Math.min( + Math.max(resizeStart.panelHeight + delta, MIN_PANEL_HEIGHT), + maxHeight + ) + + setPanelHeight(nextHeight) + } + + const startResize = (event: PointerEvent) => { + event.currentTarget.setPointerCapture(event.pointerId) + resizeStartRef.current = { + pointerY: event.clientY, + panelHeight, + } + } + + const stopResize = (event: PointerEvent) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + resizeStartRef.current = null + resizeTerminal() + } + + useEffect(() => { + setPortalRoot(document.body) + }, []) + + useEffect(() => { + if (!autoStart || didAutoStartRef.current) return + + didAutoStartRef.current = true + queueTerminalCommand(initialCommand, { + template: initialTemplate, + }) + }) + + useEffect(() => { + const container = terminalContainerRef.current + if (!isOpen || !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 + } + } + }, [isOpen, resizeTerminal, sendInputToPty]) + + useEffect(() => { + if (!isOpen) return + + 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) + } + }, [isOpen, resizeTerminal]) + + useEffect(() => { + const handleTerminalCommand = (event: Event) => { + const detail = (event as CustomEvent) + .detail + + queueTerminalCommand(detail?.command ?? '', { + forceNewSandbox: detail?.forceNewSandbox, + template: detail?.template, + }) + } + + window.addEventListener( + DASHBOARD_TERMINAL_COMMAND_EVENT, + handleTerminalCommand + ) + + return () => { + window.removeEventListener( + DASHBOARD_TERMINAL_COMMAND_EVENT, + handleTerminalCommand + ) + } + }) + + return ( + <> + {isEmbedded ? null : ( + + )} + + xtermRef.current?.focus()} + onCopyTerminalText={() => void copyTerminalText()} + onStartTerminal={(options) => void startTerminal(options)} + onClose={() => setIsOpen(false)} + /> + + setPendingLaunch(null)} + onConfirm={confirmPendingLaunch} + /> + + ) +} diff --git a/src/features/dashboard/terminal/events.ts b/src/features/dashboard/terminal/events.ts new file mode 100644 index 000000000..e9c1b0e0f --- /dev/null +++ b/src/features/dashboard/terminal/events.ts @@ -0,0 +1,29 @@ +'use client' + +import type { DashboardTerminalCommandDetail } from './types' + +export const DASHBOARD_TERMINAL_COMMAND_EVENT = 'dashboard-terminal:command' + +export function openDashboardTerminal( + launch?: string | DashboardTerminalCommandDetail +) { + const detail = + typeof launch === 'string' + ? { + command: launch, + } + : { + command: launch?.command ?? '', + forceNewSandbox: launch?.forceNewSandbox, + template: launch?.template, + } + + window.dispatchEvent( + new CustomEvent( + DASHBOARD_TERMINAL_COMMAND_EVENT, + { + detail, + } + ) + ) +} diff --git a/src/features/dashboard/terminal/sandbox-session.ts b/src/features/dashboard/terminal/sandbox-session.ts new file mode 100644 index 000000000..4d23d35c6 --- /dev/null +++ b/src/features/dashboard/terminal/sandbox-session.ts @@ -0,0 +1,97 @@ +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 + teamId: string + template: string +} + +export async function openTerminalSandbox({ + forceNewSandbox = false, + onStatus, + 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) + 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 Sandbox.connect(storedTerminalSession.sandboxId, { + domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, + timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS, + headers: { + ...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 createTerminalSandbox({ + headers, + template, + userId, +}: { + headers: Record + template: string + userId: string +}) { + 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..17fa73cb7 --- /dev/null +++ b/src/features/dashboard/terminal/storage.ts @@ -0,0 +1,39 @@ +import { TERMINAL_SESSION_STORAGE_PREFIX } from './constants' +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 StoredTerminalSession + if (!session.sandboxId) return null + + return { + sandboxId: session.sandboxId, + template: session.template ?? 'base', + } + } catch { + return null + } +} + +export function writeStoredTerminalSession( + userId: string, + session: StoredTerminalSession +) { + window.localStorage.setItem( + getTerminalSessionStorageKey(userId), + JSON.stringify(session) + ) +} + +export function clearStoredTerminalSession(userId: string) { + window.localStorage.removeItem(getTerminalSessionStorageKey(userId)) +} diff --git a/src/features/dashboard/terminal/template.ts b/src/features/dashboard/terminal/template.ts new file mode 100644 index 000000000..7defdfc7d --- /dev/null +++ b/src/features/dashboard/terminal/template.ts @@ -0,0 +1,16 @@ +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 +} 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..201b94d7e --- /dev/null +++ b/src/features/dashboard/terminal/types.ts @@ -0,0 +1,22 @@ +export type TerminalStatus = 'idle' | 'starting' | 'ready' | 'error' + +export type StoredTerminalSession = { + sandboxId: string + template: string +} + +export type StartTerminalOptions = { + forceNewSandbox?: boolean + template?: string +} + +export type DashboardTerminalCommandDetail = { + command: string + forceNewSandbox?: boolean + template?: string +} + +export type PendingTerminalLaunch = { + command: string + template: string +} From 78069b48136606ed7679778fd29f7bb7cf9caec5 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Sat, 9 May 2026 23:10:48 -0700 Subject: [PATCH 02/14] Type Supabase auth cookie updates --- src/core/server/api/middlewares/auth.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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', From be3d80e6b0c0d640ea006522bd3423e428ac53d9 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Sun, 10 May 2026 00:11:22 -0700 Subject: [PATCH 03/14] Preserve terminal return targets across auth links --- src/app/(auth)/sign-in/page.tsx | 5 ++++- src/app/(auth)/sign-up/page.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx index 7faa4bf36..ff4e99724 100644 --- a/src/app/(auth)/sign-in/page.tsx +++ b/src/app/(auth)/sign-in/page.tsx @@ -83,6 +83,9 @@ export default function Login() { if (returnTo) params.set('returnTo', returnTo) window.location.href = `${AUTH_URLS.FORGOT_PASSWORD}?${params.toString()}` } + const signUpHref = returnTo + ? `${AUTH_URLS.SIGN_UP}?${new URLSearchParams({ returnTo }).toString()}` + : AUTH_URLS.SIGN_UP return (
@@ -160,7 +163,7 @@ export default function Login() {

Don't have an account?{' '} - + Sign up . diff --git a/src/app/(auth)/sign-up/page.tsx b/src/app/(auth)/sign-up/page.tsx index 3604c688a..00d7b1060 100644 --- a/src/app/(auth)/sign-up/page.tsx +++ b/src/app/(auth)/sign-up/page.tsx @@ -72,6 +72,9 @@ export default function SignUp() { useEffect(() => { form.setValue('returnTo', returnTo) }, [returnTo, form]) + const signInHref = returnTo + ? `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({ returnTo }).toString()}` + : AUTH_URLS.SIGN_IN // Handle email prefill useEffect(() => { @@ -191,7 +194,7 @@ export default function SignUp() {

Already have an account?{' '} - + Sign in . From 36db46909242f182c7ec97c7a497dc15faf7a07b Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Sun, 10 May 2026 00:11:27 -0700 Subject: [PATCH 04/14] Simplify terminal embed panel surface --- src/features/dashboard/layouts/header.tsx | 2 - src/features/dashboard/terminal/constants.ts | 2 - src/features/dashboard/terminal/events.ts | 29 ------- ...-terminal-panel.tsx => terminal-panel.tsx} | 75 ++----------------- 4 files changed, 6 insertions(+), 102 deletions(-) delete mode 100644 src/features/dashboard/terminal/events.ts rename src/features/dashboard/terminal/{dashboard-terminal-panel.tsx => terminal-panel.tsx} (52%) diff --git a/src/features/dashboard/layouts/header.tsx b/src/features/dashboard/layouts/header.tsx index 116071fe7..e1a780ee7 100644 --- a/src/features/dashboard/layouts/header.tsx +++ b/src/features/dashboard/layouts/header.tsx @@ -9,7 +9,6 @@ import ClientOnly from '@/ui/client-only' import CopyButton from '@/ui/copy-button' import { SidebarTrigger } from '@/ui/primitives/sidebar' import { ThemeSwitcher } from '@/ui/theme-switcher' -import DashboardTerminal from '../terminal/dashboard-terminal' interface DashboardLayoutHeaderProps { className?: string @@ -56,7 +55,6 @@ export default function DashboardLayoutHeader({

- diff --git a/src/features/dashboard/terminal/constants.ts b/src/features/dashboard/terminal/constants.ts index ed57d3579..5a0835058 100644 --- a/src/features/dashboard/terminal/constants.ts +++ b/src/features/dashboard/terminal/constants.ts @@ -2,7 +2,5 @@ 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 MIN_PANEL_HEIGHT = 160 -export const MAX_PANEL_HEIGHT_RATIO = 0.72 export const TERMINAL_SESSION_STORAGE_PREFIX = 'dashboard-terminal-session' export const DEFAULT_CWD = '/home/user' diff --git a/src/features/dashboard/terminal/events.ts b/src/features/dashboard/terminal/events.ts deleted file mode 100644 index e9c1b0e0f..000000000 --- a/src/features/dashboard/terminal/events.ts +++ /dev/null @@ -1,29 +0,0 @@ -'use client' - -import type { DashboardTerminalCommandDetail } from './types' - -export const DASHBOARD_TERMINAL_COMMAND_EVENT = 'dashboard-terminal:command' - -export function openDashboardTerminal( - launch?: string | DashboardTerminalCommandDetail -) { - const detail = - typeof launch === 'string' - ? { - command: launch, - } - : { - command: launch?.command ?? '', - forceNewSandbox: launch?.forceNewSandbox, - template: launch?.template, - } - - window.dispatchEvent( - new CustomEvent( - DASHBOARD_TERMINAL_COMMAND_EVENT, - { - detail, - } - ) - ) -} diff --git a/src/features/dashboard/terminal/dashboard-terminal-panel.tsx b/src/features/dashboard/terminal/terminal-panel.tsx similarity index 52% rename from src/features/dashboard/terminal/dashboard-terminal-panel.tsx rename to src/features/dashboard/terminal/terminal-panel.tsx index 65d4ab7e0..592e4cf3a 100644 --- a/src/features/dashboard/terminal/dashboard-terminal-panel.tsx +++ b/src/features/dashboard/terminal/terminal-panel.tsx @@ -1,75 +1,33 @@ -import type { PointerEvent, RefObject } from 'react' -import { createPortal } from 'react-dom' +import type { RefObject } from 'react' import { Button } from '@/ui/primitives/button' import { - CloseIcon, CopyIcon, RefreshIcon, TerminalCustomIcon, } from '@/ui/primitives/icons' import type { StartTerminalOptions, TerminalStatus } from './types' -interface DashboardTerminalPanelProps { - isOpen: boolean - portalRoot: HTMLElement | null - variant?: 'fixed' | 'embedded' - panelHeight: number +interface TerminalPanelProps { sandboxId?: string template: string status: TerminalStatus terminalContainerRef: RefObject - onResizeStart: (event: PointerEvent) => void - onResizeMove: (pointerY: number) => void - onResizeStop: (event: PointerEvent) => void onFocusTerminal: () => void onCopyTerminalText: () => void onStartTerminal: (options?: StartTerminalOptions) => void - onClose: () => void } -export default function DashboardTerminalPanel({ - isOpen, - portalRoot, - variant = 'fixed', - panelHeight, +export default function TerminalPanel({ sandboxId, template, status, terminalContainerRef, - onResizeStart, - onResizeMove, - onResizeStop, onFocusTerminal, onCopyTerminalText, onStartTerminal, - onClose, -}: DashboardTerminalPanelProps) { - if (!isOpen) return null - - const panel = ( -
- {variant === 'fixed' ? ( - - ) : null} - +}: TerminalPanelProps) { + return ( +
@@ -111,19 +69,6 @@ export default function DashboardTerminalPanel({ > - {variant === 'fixed' ? ( - - ) : null}
@@ -136,12 +81,4 @@ export default function DashboardTerminalPanel({ />
) - - if (variant === 'embedded') { - return panel - } - - if (!portalRoot) return null - - return createPortal(panel, portalRoot) } From b454adc2b6f6ad88b4e04b30e63f941e5540b234 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Sun, 10 May 2026 00:11:40 -0700 Subject: [PATCH 05/14] Support terminal reconnect URLs --- src/app/dashboard/terminal/embed/page.tsx | 155 ++++-- .../dashboard-terminal-command-dialog.tsx | 8 + .../dashboard/terminal/dashboard-terminal.tsx | 444 +++++++++--------- .../dashboard/terminal/sandbox-session.ts | 38 +- src/features/dashboard/terminal/types.ts | 8 +- 5 files changed, 388 insertions(+), 265 deletions(-) diff --git a/src/app/dashboard/terminal/embed/page.tsx b/src/app/dashboard/terminal/embed/page.tsx index 05194e1f9..114250472 100644 --- a/src/app/dashboard/terminal/embed/page.tsx +++ b/src/app/dashboard/terminal/embed/page.tsx @@ -1,6 +1,8 @@ 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, @@ -9,7 +11,8 @@ import { 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 { DashboardContextProvider } from '@/features/dashboard/context' +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' @@ -22,6 +25,7 @@ export const metadata: Metadata = { interface TerminalEmbedPageProps { searchParams: Promise<{ command?: string + sandboxId?: string template?: string }> } @@ -29,8 +33,9 @@ interface TerminalEmbedPageProps { export default async function TerminalEmbedPage({ searchParams, }: TerminalEmbedPageProps) { - const { command = '', template } = await searchParams + const { command = '', sandboxId, template } = await searchParams const terminalTemplate = normalizeTerminalTemplate(template) + const terminalSandboxId = normalizeTerminalSandboxId(sandboxId) if (!terminalTemplate) { return ( @@ -38,36 +43,55 @@ export default async function TerminalEmbedPage({ ) } + if (terminalSandboxId === null) { + return ( + + ) + } + const session = await getSessionInsecure() const { data, error } = await getUserByToken(session?.access_token) if (error || !data.user || !session) { - return + return ( + + ) } const teamsRepository = createUserTeamsRepository({ accessToken: session.access_token, }) const teamsResult = await teamsRepository.listUserTeams() - const resolvedTeam = await resolveUserTeam(data.user.id, session.access_token) - if (!teamsResult.ok || !resolvedTeam) { + if (!teamsResult.ok) { return } - const team = teamsResult.data.find( - (candidate) => candidate.id === resolvedTeam.id - ) + 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 = await isTerminalTemplateAvailable({ - accessToken: session.access_token, - teamId: team.id, - template: terminalTemplate, - }) + const templateAvailable = terminalSandboxId + ? { ok: true as const, available: true } + : await isTerminalTemplateAvailable({ + accessToken: session.access_token, + teamId: team.id, + template: terminalTemplate, + }) if (!templateAvailable.ok) { return ( @@ -84,23 +108,94 @@ export default async function TerminalEmbedPage({ } 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, @@ -149,9 +244,11 @@ async function isTerminalTemplateAvailable({ function TerminalEmbedSignIn({ command, + sandboxId, template, }: { command: string + sandboxId?: string template: string }) { const returnToParams = new URLSearchParams() @@ -164,6 +261,10 @@ function TerminalEmbedSignIn({ returnToParams.set('command', command) } + if (sandboxId) { + returnToParams.set('sandboxId', sandboxId) + } + const returnToQuery = returnToParams.toString() const returnTo = `/dashboard/terminal/embed${ returnToQuery ? `?${returnToQuery}` : '' diff --git a/src/features/dashboard/terminal/dashboard-terminal-command-dialog.tsx b/src/features/dashboard/terminal/dashboard-terminal-command-dialog.tsx index ac4850e07..248c63c1c 100644 --- a/src/features/dashboard/terminal/dashboard-terminal-command-dialog.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal-command-dialog.tsx @@ -39,6 +39,14 @@ export default function DashboardTerminalCommandDialog({ {launch ? (
+ {launch.sandboxId ? ( +
+

Sandbox

+ + {launch.sandboxId} + +
+ ) : null}

Template

diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index e3af993c4..b438f5e40 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -3,32 +3,14 @@ import { Terminal as XTerm } from '@xterm/xterm' import type Sandbox from 'e2b' import type { CommandHandle } from 'e2b' -import { - type PointerEvent, - useCallback, - useEffect, - useRef, - useState, -} from 'react' -import { Button } from '@/ui/primitives/button' -import { SpinnerIcon, TerminalCustomIcon } from '@/ui/primitives/icons' -import { useDashboard } from '../context' -import { - DEFAULT_COLS, - DEFAULT_CWD, - DEFAULT_PANEL_HEIGHT, - DEFAULT_ROWS, - MAX_PANEL_HEIGHT_RATIO, - MIN_PANEL_HEIGHT, -} from './constants' +import { useCallback, useEffect, useRef, useState } from 'react' +import { DEFAULT_COLS, DEFAULT_CWD, DEFAULT_ROWS } from './constants' import DashboardTerminalCommandDialog from './dashboard-terminal-command-dialog' -import DashboardTerminalPanel from './dashboard-terminal-panel' -import { DASHBOARD_TERMINAL_COMMAND_EVENT } from './events' import { openTerminalSandbox } from './sandbox-session' import { normalizeTerminalTemplate } from './template' +import TerminalPanel from './terminal-panel' import { calculateTerminalSize } from './terminal-size' import type { - DashboardTerminalCommandDetail, PendingTerminalLaunch, StartTerminalOptions, TerminalStatus, @@ -46,26 +28,23 @@ const TERMINAL_THEME = { interface DashboardTerminalProps { autoStart?: boolean initialCommand?: string + initialSandboxId?: string initialTemplate?: string - variant?: 'button' | 'embedded' + teamId: string } export default function DashboardTerminal({ autoStart = false, initialCommand = '', + initialSandboxId, initialTemplate, - variant = 'button', + teamId, }: DashboardTerminalProps) { - const { team } = useDashboard() - const isEmbedded = variant === 'embedded' - const [isOpen, setIsOpen] = useState(isEmbedded) const [status, setStatus] = useState('idle') - const [sandboxId, setSandboxId] = useState() + const [activeSandboxId, setActiveSandboxId] = useState() const [template, setTemplate] = useState( normalizeTerminalTemplate(initialTemplate) ?? 'base' ) - const [panelHeight, setPanelHeight] = useState(DEFAULT_PANEL_HEIGHT) - const [portalRoot, setPortalRoot] = useState(null) const [pendingLaunch, setPendingLaunch] = useState(null) @@ -80,10 +59,8 @@ export default function DashboardTerminal({ const pendingCommandsRef = useRef([]) const inputQueueRef = useRef(Promise.resolve()) const didAutoStartRef = useRef(false) - const resizeStartRef = useRef<{ - pointerY: number - panelHeight: number - } | null>(null) + const isStartingRef = useRef(false) + const startGenerationRef = useRef(0) const resizeTerminal = useCallback(() => { const nextSize = calculateTerminalSize( @@ -112,7 +89,7 @@ export default function DashboardTerminal({ }) }, []) - const disconnectTerminal = async () => { + const disconnectTerminal = useCallback(async () => { const pty = ptyRef.current ptyRef.current = null if (!pty) return @@ -122,7 +99,7 @@ export default function DashboardTerminal({ } catch { // Best-effort cleanup. The sandbox is intentionally left alive to pause. } - } + }, []) const sendInputToPty = useCallback( (value: string | Uint8Array, terminalPid = pidRef.current) => { @@ -149,122 +126,211 @@ export default function DashboardTerminal({ [sendInputToPty] ) - const startTerminal = async (options: StartTerminalOptions = {}) => { - if (status === 'starting') return - - const nextTemplate = normalizeTerminalTemplate(options.template) ?? template - await disconnectTerminal() - sandboxRef.current = null - pidRef.current = undefined - decoderRef.current = new TextDecoder() - inputQueueRef.current = Promise.resolve() - terminalTranscriptRef.current = '' - xtermRef.current?.reset() - setStatus('starting') - setSandboxId(undefined) - setTemplate(nextTemplate) - appendOutput('Opening terminal...\r\n') + 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 + } - try { - const { sandbox } = await openTerminalSandbox({ - forceNewSandbox: options.forceNewSandbox, - onStatus: appendOutput, - teamId: team.id, - template: nextTemplate, - }) - - sandboxRef.current = sandbox - setSandboxId(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) - }, - }) - - 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) + if (clearCommand && url.searchParams.has('command')) { + url.searchParams.delete('command') + changed = true } - } catch (error) { - setStatus('error') - appendOutput( - `\r\nFailed to start terminal: ${ - error instanceof Error ? error.message : 'Unknown error' - }\r\n` - ) - } - } - const openTerminal = () => { - setIsOpen(true) - if (status === 'idle' || status === 'error') { - void startTerminal() - } - } + if (changed) { + window.history.replaceState(window.history.state, '', url) + } + }, + [] + ) + + const startTerminal = useCallback( + async (options: StartTerminalOptions = {}) => { + if (isStartingRef.current) return + isStartingRef.current = true + const startGeneration = startGenerationRef.current + 1 + startGenerationRef.current = startGeneration + const isCurrentStart = () => + startGenerationRef.current === startGeneration + + const nextTemplate = + normalizeTerminalTemplate(options.template) ?? template + 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 = ( - command: string, - options: StartTerminalOptions = {} - ) => { - const nextTemplate = normalizeTerminalTemplate(options.template) + const queueTerminalCommand = useCallback( + (command: string, options: StartTerminalOptions = {}) => { + const nextTemplate = + normalizeTerminalTemplate(options.template) ?? template - if (!nextTemplate) { - setIsOpen(true) - setStatus('error') - appendOutput('Invalid terminal template.\r\n') - return - } + if (!nextTemplate) { + setStatus('error') + appendOutput('Invalid terminal template.\r\n') + return + } - setIsOpen(true) - if (command.trim()) { - setPendingLaunch({ - command: command.trim(), - template: nextTemplate, - }) - 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, - }) - } - } + if (status === 'idle' || status === 'error' || options.forceNewSandbox) { + void startTerminal({ + ...options, + template: nextTemplate, + }) + } + }, + [appendOutput, startTerminal, status, template] + ) - const confirmPendingLaunch = () => { + const confirmPendingLaunch = useCallback(() => { if (!pendingLaunch) return - const { command, template: launchTemplate } = pendingLaunch - setPendingLaunch(null) - - if (status === 'ready' && template === launchTemplate) { + 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: template !== launchTemplate, + forceNewSandbox: !launchSandboxId && template !== launchTemplate, + sandboxId: launchSandboxId, template: launchTemplate, }) - } + }, [ + activeSandboxId, + pendingLaunch, + runCommand, + startTerminal, + status, + template, + updateTerminalUrl, + ]) const copyTerminalText = async () => { const value = @@ -275,52 +341,32 @@ export default function DashboardTerminal({ xtermRef.current?.focus() } - const resizePanel = (pointerY: number) => { - const resizeStart = resizeStartRef.current - if (!resizeStart) return - - const maxHeight = Math.floor(window.innerHeight * MAX_PANEL_HEIGHT_RATIO) - const delta = resizeStart.pointerY - pointerY - const nextHeight = Math.min( - Math.max(resizeStart.panelHeight + delta, MIN_PANEL_HEIGHT), - maxHeight - ) - - setPanelHeight(nextHeight) - } - - const startResize = (event: PointerEvent) => { - event.currentTarget.setPointerCapture(event.pointerId) - resizeStartRef.current = { - pointerY: event.clientY, - panelHeight, - } - } - - const stopResize = (event: PointerEvent) => { - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId) - } - resizeStartRef.current = null - resizeTerminal() - } - - useEffect(() => { - setPortalRoot(document.body) - }, []) - 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 (!isOpen || !container) return + if (!container) return const terminal = new XTerm({ cols: terminalSizeRef.current.cols, @@ -358,11 +404,9 @@ export default function DashboardTerminal({ xtermRef.current = null } } - }, [isOpen, resizeTerminal, sendInputToPty]) + }, [resizeTerminal, sendInputToPty]) useEffect(() => { - if (!isOpen) return - const container = terminalContainerRef.current const resizeObserver = container && typeof ResizeObserver !== 'undefined' @@ -385,68 +429,18 @@ export default function DashboardTerminal({ resizeObserver?.disconnect() window.removeEventListener('resize', handleWindowResize) } - }, [isOpen, resizeTerminal]) - - useEffect(() => { - const handleTerminalCommand = (event: Event) => { - const detail = (event as CustomEvent) - .detail - - queueTerminalCommand(detail?.command ?? '', { - forceNewSandbox: detail?.forceNewSandbox, - template: detail?.template, - }) - } - - window.addEventListener( - DASHBOARD_TERMINAL_COMMAND_EVENT, - handleTerminalCommand - ) - - return () => { - window.removeEventListener( - DASHBOARD_TERMINAL_COMMAND_EVENT, - handleTerminalCommand - ) - } - }) + }, [resizeTerminal]) return ( <> - {isEmbedded ? null : ( - - )} - - xtermRef.current?.focus()} onCopyTerminalText={() => void copyTerminalText()} onStartTerminal={(options) => void startTerminal(options)} - onClose={() => setIsOpen(false)} /> void + sandboxId?: string teamId: string template: string } @@ -18,6 +19,7 @@ interface OpenTerminalSandboxOptions { export async function openTerminalSandbox({ forceNewSandbox = false, onStatus, + sandboxId, teamId, template, }: OpenTerminalSandboxOptions) { @@ -29,6 +31,16 @@ export async function openTerminalSandbox({ 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) @@ -41,13 +53,10 @@ export async function openTerminalSandbox({ ) try { - sandbox = await Sandbox.connect(storedTerminalSession.sandboxId, { - domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, - timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS, - headers: { - ...headers, - }, - }) + sandbox = await connectTerminalSandbox( + storedTerminalSession.sandboxId, + headers + ) } catch { clearStoredTerminalSession(userId) onStatus('Stored terminal sandbox is unavailable.\r\n') @@ -69,6 +78,19 @@ export async function openTerminalSandbox({ } } +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, @@ -78,6 +100,8 @@ function createTerminalSandbox({ 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, diff --git a/src/features/dashboard/terminal/types.ts b/src/features/dashboard/terminal/types.ts index 201b94d7e..4dd6b42df 100644 --- a/src/features/dashboard/terminal/types.ts +++ b/src/features/dashboard/terminal/types.ts @@ -7,16 +7,12 @@ export type StoredTerminalSession = { export type StartTerminalOptions = { forceNewSandbox?: boolean - template?: string -} - -export type DashboardTerminalCommandDetail = { - command: string - forceNewSandbox?: boolean + sandboxId?: string template?: string } export type PendingTerminalLaunch = { command: string + sandboxId?: string template: string } From 7b78ce49ad515c87c6fab5dd720504b20e809b54 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Sun, 10 May 2026 01:09:56 -0700 Subject: [PATCH 06/14] Revert "Preserve terminal return targets across auth links" This reverts commit be3d80e6b0c0d640ea006522bd3423e428ac53d9. --- src/app/(auth)/sign-in/page.tsx | 5 +---- src/app/(auth)/sign-up/page.tsx | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx index ff4e99724..7faa4bf36 100644 --- a/src/app/(auth)/sign-in/page.tsx +++ b/src/app/(auth)/sign-in/page.tsx @@ -83,9 +83,6 @@ export default function Login() { if (returnTo) params.set('returnTo', returnTo) window.location.href = `${AUTH_URLS.FORGOT_PASSWORD}?${params.toString()}` } - const signUpHref = returnTo - ? `${AUTH_URLS.SIGN_UP}?${new URLSearchParams({ returnTo }).toString()}` - : AUTH_URLS.SIGN_UP return (
@@ -163,7 +160,7 @@ export default function Login() {

Don't have an account?{' '} - + Sign up . diff --git a/src/app/(auth)/sign-up/page.tsx b/src/app/(auth)/sign-up/page.tsx index 00d7b1060..3604c688a 100644 --- a/src/app/(auth)/sign-up/page.tsx +++ b/src/app/(auth)/sign-up/page.tsx @@ -72,9 +72,6 @@ export default function SignUp() { useEffect(() => { form.setValue('returnTo', returnTo) }, [returnTo, form]) - const signInHref = returnTo - ? `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({ returnTo }).toString()}` - : AUTH_URLS.SIGN_IN // Handle email prefill useEffect(() => { @@ -194,7 +191,7 @@ export default function SignUp() {

Already have an account?{' '} - + Sign in . From 2c079a0d53f632e49d1ea6c38160fb1a98a60b20 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Sun, 10 May 2026 01:13:46 -0700 Subject: [PATCH 07/14] Rename terminal embed route to session --- .../terminal/{embed => session}/page.tsx | 26 +++++++++---------- src/core/server/http/proxy.ts | 6 ++--- 2 files changed, 16 insertions(+), 16 deletions(-) rename src/app/dashboard/terminal/{embed => session}/page.tsx (91%) diff --git a/src/app/dashboard/terminal/embed/page.tsx b/src/app/dashboard/terminal/session/page.tsx similarity index 91% rename from src/app/dashboard/terminal/embed/page.tsx rename to src/app/dashboard/terminal/session/page.tsx index 114250472..ac32553ae 100644 --- a/src/app/dashboard/terminal/embed/page.tsx +++ b/src/app/dashboard/terminal/session/page.tsx @@ -22,7 +22,7 @@ export const metadata: Metadata = { robots: 'noindex, nofollow', } -interface TerminalEmbedPageProps { +interface TerminalSessionPageProps { searchParams: Promise<{ command?: string sandboxId?: string @@ -30,22 +30,22 @@ interface TerminalEmbedPageProps { }> } -export default async function TerminalEmbedPage({ +export default async function TerminalSessionPage({ searchParams, -}: TerminalEmbedPageProps) { +}: TerminalSessionPageProps) { const { command = '', sandboxId, template } = await searchParams const terminalTemplate = normalizeTerminalTemplate(template) const terminalSandboxId = normalizeTerminalSandboxId(sandboxId) if (!terminalTemplate) { return ( - + ) } if (terminalSandboxId === null) { return ( - + ) } @@ -54,7 +54,7 @@ export default async function TerminalEmbedPage({ if (error || !data.user || !session) { return ( - + return } const resolvedTeam = await resolveUserTeam(data.user.id, session.access_token) @@ -82,7 +82,7 @@ export default async function TerminalEmbedPage({ : teamsResult.data.find((candidate) => candidate.id === resolvedTeam?.id) if (!team) { - return + return } const templateAvailable = terminalSandboxId @@ -95,13 +95,13 @@ export default async function TerminalEmbedPage({ if (!templateAvailable.ok) { return ( - + ) } if (!templateAvailable.available) { return ( - ) @@ -242,7 +242,7 @@ async function isTerminalTemplateAvailable({ } } -function TerminalEmbedSignIn({ +function TerminalSessionSignIn({ command, sandboxId, template, @@ -266,7 +266,7 @@ function TerminalEmbedSignIn({ } const returnToQuery = returnToParams.toString() - const returnTo = `/dashboard/terminal/embed${ + const returnTo = `/dashboard/terminal/session${ returnToQuery ? `?${returnToQuery}` : '' }` const signInHref = `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({ @@ -292,7 +292,7 @@ function TerminalEmbedSignIn({ ) } -function TerminalEmbedUnavailable({ +function TerminalSessionUnavailable({ message = 'We could not resolve a dashboard team for this account.', }: { message?: string diff --git a/src/core/server/http/proxy.ts b/src/core/server/http/proxy.ts index 2b0f704db..7722df106 100644 --- a/src/core/server/http/proxy.ts +++ b/src/core/server/http/proxy.ts @@ -15,8 +15,8 @@ export function isDashboardRoute(pathname: string): boolean { return pathname.startsWith(PROTECTED_URLS.DASHBOARD) } -function isDashboardTerminalEmbedRoute(pathname: string): boolean { - return pathname === '/dashboard/terminal/embed' +function isDashboardTerminalSessionRoute(pathname: string): boolean { + return pathname === '/dashboard/terminal/session' } export function buildRedirectUrl(path: string, request: NextRequest): URL { @@ -29,7 +29,7 @@ export function getAuthRedirect( ): NextResponse | null { if ( isDashboardRoute(request.nextUrl.pathname) && - !isDashboardTerminalEmbedRoute(request.nextUrl.pathname) && + !isDashboardTerminalSessionRoute(request.nextUrl.pathname) && !isAuthenticated ) { return NextResponse.redirect(buildRedirectUrl(AUTH_URLS.SIGN_IN, request)) From 3dd4ecba574947fc14865f9f52aeefaf513af59a Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Sun, 10 May 2026 01:18:46 -0700 Subject: [PATCH 08/14] Move terminal route to dashboard terminal --- .../dashboard/terminal/{session => }/page.tsx | 30 ++++++++----------- src/core/server/http/proxy.ts | 6 ++-- tests/integration/proxy.test.ts | 23 ++++++++++++++ 3 files changed, 39 insertions(+), 20 deletions(-) rename src/app/dashboard/terminal/{session => }/page.tsx (91%) diff --git a/src/app/dashboard/terminal/session/page.tsx b/src/app/dashboard/terminal/page.tsx similarity index 91% rename from src/app/dashboard/terminal/session/page.tsx rename to src/app/dashboard/terminal/page.tsx index ac32553ae..50da465b4 100644 --- a/src/app/dashboard/terminal/session/page.tsx +++ b/src/app/dashboard/terminal/page.tsx @@ -22,7 +22,7 @@ export const metadata: Metadata = { robots: 'noindex, nofollow', } -interface TerminalSessionPageProps { +interface TerminalPageProps { searchParams: Promise<{ command?: string sandboxId?: string @@ -30,23 +30,19 @@ interface TerminalSessionPageProps { }> } -export default async function TerminalSessionPage({ +export default async function TerminalPage({ searchParams, -}: TerminalSessionPageProps) { +}: TerminalPageProps) { const { command = '', sandboxId, template } = await searchParams const terminalTemplate = normalizeTerminalTemplate(template) const terminalSandboxId = normalizeTerminalSandboxId(sandboxId) if (!terminalTemplate) { - return ( - - ) + return } if (terminalSandboxId === null) { - return ( - - ) + return } const session = await getSessionInsecure() @@ -54,7 +50,7 @@ export default async function TerminalSessionPage({ if (error || !data.user || !session) { return ( - + return } const resolvedTeam = await resolveUserTeam(data.user.id, session.access_token) @@ -82,7 +78,7 @@ export default async function TerminalSessionPage({ : teamsResult.data.find((candidate) => candidate.id === resolvedTeam?.id) if (!team) { - return + return } const templateAvailable = terminalSandboxId @@ -95,13 +91,13 @@ export default async function TerminalSessionPage({ if (!templateAvailable.ok) { return ( - + ) } if (!templateAvailable.available) { return ( - ) @@ -242,7 +238,7 @@ async function isTerminalTemplateAvailable({ } } -function TerminalSessionSignIn({ +function TerminalSignIn({ command, sandboxId, template, @@ -266,7 +262,7 @@ function TerminalSessionSignIn({ } const returnToQuery = returnToParams.toString() - const returnTo = `/dashboard/terminal/session${ + const returnTo = `/dashboard/terminal${ returnToQuery ? `?${returnToQuery}` : '' }` const signInHref = `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({ @@ -292,7 +288,7 @@ function TerminalSessionSignIn({ ) } -function TerminalSessionUnavailable({ +function TerminalUnavailable({ message = 'We could not resolve a dashboard team for this account.', }: { message?: string diff --git a/src/core/server/http/proxy.ts b/src/core/server/http/proxy.ts index 7722df106..204af839b 100644 --- a/src/core/server/http/proxy.ts +++ b/src/core/server/http/proxy.ts @@ -15,8 +15,8 @@ export function isDashboardRoute(pathname: string): boolean { return pathname.startsWith(PROTECTED_URLS.DASHBOARD) } -function isDashboardTerminalSessionRoute(pathname: string): boolean { - return pathname === '/dashboard/terminal/session' +function isDashboardTerminalRoute(pathname: string): boolean { + return pathname === '/dashboard/terminal' } export function buildRedirectUrl(path: string, request: NextRequest): URL { @@ -29,7 +29,7 @@ export function getAuthRedirect( ): NextResponse | null { if ( isDashboardRoute(request.nextUrl.pathname) && - !isDashboardTerminalSessionRoute(request.nextUrl.pathname) && + !isDashboardTerminalRoute(request.nextUrl.pathname) && !isAuthenticated ) { return NextResponse.redirect(buildRedirectUrl(AUTH_URLS.SIGN_IN, request)) diff --git a/tests/integration/proxy.test.ts b/tests/integration/proxy.test.ts index 555185331..2d90956be 100644 --- a/tests/integration/proxy.test.ts +++ b/tests/integration/proxy.test.ts @@ -176,6 +176,29 @@ 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 public pages', async () => { vi.mocked(createServerClient).mockImplementation( () => From 2d007e78c511e03bf712d6bd393618d4438a414e Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Sun, 10 May 2026 01:24:31 -0700 Subject: [PATCH 09/14] Add dashboard terminal helper tests --- tests/unit/dashboard-terminal.test.ts | 235 ++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 tests/unit/dashboard-terminal.test.ts diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts new file mode 100644 index 000000000..b31ae3aa9 --- /dev/null +++ b/tests/unit/dashboard-terminal.test.ts @@ -0,0 +1,235 @@ +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 } 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('../base')).toBeNull() + expect(normalizeTerminalTemplate('base; echo nope')).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() + + clearStoredTerminalSession('user-123') + expect(readStoredTerminalSession('user-123')).toBeNull() + }) + }) + + 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() + }) + }) +}) From daa6f01f263d6327e0f86b817f1f6c3bf713cbd7 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Sun, 10 May 2026 01:26:29 -0700 Subject: [PATCH 10/14] Address terminal route review cleanup --- src/app/dashboard/terminal/layout.tsx | 9 +++++++++ src/app/layout.tsx | 1 - .../dashboard/terminal/terminal-panel.tsx | 16 +++++++--------- 3 files changed, 16 insertions(+), 10 deletions(-) create mode 100644 src/app/dashboard/terminal/layout.tsx 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/layout.tsx b/src/app/layout.tsx index 3f2e3509b..412708168 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,5 @@ import '@/app/fonts' import '@/styles/globals.css' -import '@xterm/xterm/css/xterm.css' import { Analytics } from '@vercel/analytics/next' import { SpeedInsights } from '@vercel/speed-insights/next' diff --git a/src/features/dashboard/terminal/terminal-panel.tsx b/src/features/dashboard/terminal/terminal-panel.tsx index 592e4cf3a..d35d112ad 100644 --- a/src/features/dashboard/terminal/terminal-panel.tsx +++ b/src/features/dashboard/terminal/terminal-panel.tsx @@ -1,5 +1,5 @@ import type { RefObject } from 'react' -import { Button } from '@/ui/primitives/button' +import { IconButton } from '@/ui/primitives/icon-button' import { CopyIcon, RefreshIcon, @@ -45,10 +45,9 @@ export default function TerminalPanel({

- - +
From bf1da8b29b3d573ef7a86a57e3eee4462e38d5fb Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Sun, 10 May 2026 01:43:21 -0700 Subject: [PATCH 11/14] Remove redundant terminal icon sizing --- src/features/dashboard/terminal/terminal-panel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/dashboard/terminal/terminal-panel.tsx b/src/features/dashboard/terminal/terminal-panel.tsx index d35d112ad..0236076dc 100644 --- a/src/features/dashboard/terminal/terminal-panel.tsx +++ b/src/features/dashboard/terminal/terminal-panel.tsx @@ -54,7 +54,7 @@ export default function TerminalPanel({ onMouseDown={(event) => event.preventDefault()} onClick={onCopyTerminalText} > - + onStartTerminal({ forceNewSandbox: true })} > - +
From fe70042bf88aa644578416cb92ad5e3e15c7fe40 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Sun, 10 May 2026 01:58:03 -0700 Subject: [PATCH 12/14] Preserve terminal template on restart --- .../dashboard/terminal/dashboard-terminal.tsx | 24 +++++++++++++++---- src/features/dashboard/terminal/template.ts | 9 +++++++ tests/unit/dashboard-terminal.test.ts | 18 +++++++++++++- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index b438f5e40..bdad0f80f 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -7,7 +7,10 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { DEFAULT_COLS, DEFAULT_CWD, DEFAULT_ROWS } from './constants' import DashboardTerminalCommandDialog from './dashboard-terminal-command-dialog' import { openTerminalSandbox } from './sandbox-session' -import { normalizeTerminalTemplate } from './template' +import { + normalizeTerminalTemplate, + resolveTerminalTemplateOverride, +} from './template' import TerminalPanel from './terminal-panel' import { calculateTerminalSize } from './terminal-size' import type { @@ -157,14 +160,23 @@ export default function DashboardTerminal({ 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 - const nextTemplate = - normalizeTerminalTemplate(options.template) ?? template await disconnectTerminal() sandboxRef.current = null pidRef.current = undefined @@ -259,8 +271,10 @@ export default function DashboardTerminal({ const queueTerminalCommand = useCallback( (command: string, options: StartTerminalOptions = {}) => { - const nextTemplate = - normalizeTerminalTemplate(options.template) ?? template + const nextTemplate = resolveTerminalTemplateOverride( + options.template, + template + ) if (!nextTemplate) { setStatus('error') diff --git a/src/features/dashboard/terminal/template.ts b/src/features/dashboard/terminal/template.ts index 7defdfc7d..0652958d6 100644 --- a/src/features/dashboard/terminal/template.ts +++ b/src/features/dashboard/terminal/template.ts @@ -14,3 +14,12 @@ export function normalizeTerminalTemplate(template?: string) { return value } + +export function resolveTerminalTemplateOverride( + template: string | undefined, + fallback: string +) { + if (template === undefined) return fallback + + return normalizeTerminalTemplate(template) +} diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts index b31ae3aa9..823952c0b 100644 --- a/tests/unit/dashboard-terminal.test.ts +++ b/tests/unit/dashboard-terminal.test.ts @@ -7,7 +7,10 @@ import { readStoredTerminalSession, writeStoredTerminalSession, } from '@/features/dashboard/terminal/storage' -import { normalizeTerminalTemplate } from '@/features/dashboard/terminal/template' +import { + normalizeTerminalTemplate, + resolveTerminalTemplateOverride, +} from '@/features/dashboard/terminal/template' import { calculateTerminalSize } from '@/features/dashboard/terminal/terminal-size' const { mockCreateSandbox, mockConnectSandbox, mockGetSession } = vi.hoisted( @@ -88,6 +91,19 @@ describe('dashboard terminal helpers', () => { }) }) + 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', { From 2b3245af74ac172159577e2331d556c7b5dcde42 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Sun, 10 May 2026 15:05:07 -0700 Subject: [PATCH 13/14] Harden dashboard terminal edge cases --- src/app/dashboard/terminal/page.tsx | 7 ------ src/core/server/http/proxy.ts | 4 +++- src/features/dashboard/terminal/constants.ts | 1 + .../dashboard/terminal/dashboard-terminal.tsx | 20 ++++++++++++---- src/features/dashboard/terminal/storage.ts | 18 ++++++++++++--- tests/integration/proxy.test.ts | 23 +++++++++++++++++++ tests/unit/dashboard-terminal.test.ts | 18 +++++++++++++++ 7 files changed, 76 insertions(+), 15 deletions(-) diff --git a/src/app/dashboard/terminal/page.tsx b/src/app/dashboard/terminal/page.tsx index 50da465b4..431bc1a7b 100644 --- a/src/app/dashboard/terminal/page.tsx +++ b/src/app/dashboard/terminal/page.tsx @@ -51,7 +51,6 @@ export default async function TerminalPage({ if (error || !data.user || !session) { return ( @@ -239,11 +238,9 @@ async function isTerminalTemplateAvailable({ } function TerminalSignIn({ - command, sandboxId, template, }: { - command: string sandboxId?: string template: string }) { @@ -253,10 +250,6 @@ function TerminalSignIn({ returnToParams.set('template', template) } - if (command) { - returnToParams.set('command', command) - } - if (sandboxId) { returnToParams.set('sandboxId', sandboxId) } diff --git a/src/core/server/http/proxy.ts b/src/core/server/http/proxy.ts index 204af839b..e49242d32 100644 --- a/src/core/server/http/proxy.ts +++ b/src/core/server/http/proxy.ts @@ -16,7 +16,9 @@ export function isDashboardRoute(pathname: string): boolean { } function isDashboardTerminalRoute(pathname: string): boolean { - return pathname === '/dashboard/terminal' + return ( + pathname === '/dashboard/terminal' || pathname === '/dashboard/terminal/' + ) } export function buildRedirectUrl(path: string, request: NextRequest): URL { diff --git a/src/features/dashboard/terminal/constants.ts b/src/features/dashboard/terminal/constants.ts index 5a0835058..514d3ac8c 100644 --- a/src/features/dashboard/terminal/constants.ts +++ b/src/features/dashboard/terminal/constants.ts @@ -2,5 +2,6 @@ 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.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index bdad0f80f..3269dc675 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -4,7 +4,12 @@ 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 } from './constants' +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 { @@ -86,7 +91,9 @@ export default function DashboardTerminal({ ? chunk : decoderRef.current.decode(chunk, { stream: true }) - terminalTranscriptRef.current += text + terminalTranscriptRef.current = ( + terminalTranscriptRef.current + text + ).slice(-MAX_TERMINAL_TRANSCRIPT_CHARS) xtermRef.current?.write(chunk, () => { xtermRef.current?.scrollToBottom() }) @@ -351,8 +358,13 @@ export default function DashboardTerminal({ xtermRef.current?.getSelection() || terminalTranscriptRef.current if (!value) return - await navigator.clipboard.writeText(value) - xtermRef.current?.focus() + try { + await navigator.clipboard.writeText(value) + } catch { + appendOutput('\r\nCould not copy terminal output to clipboard.\r\n') + } finally { + xtermRef.current?.focus() + } } useEffect(() => { diff --git a/src/features/dashboard/terminal/storage.ts b/src/features/dashboard/terminal/storage.ts index 17fa73cb7..091675a27 100644 --- a/src/features/dashboard/terminal/storage.ts +++ b/src/features/dashboard/terminal/storage.ts @@ -1,4 +1,5 @@ import { TERMINAL_SESSION_STORAGE_PREFIX } from './constants' +import { normalizeTerminalTemplate } from './template' import type { StoredTerminalSession } from './types' function getTerminalSessionStorageKey(userId: string) { @@ -12,12 +13,23 @@ export function readStoredTerminalSession(userId: string) { ) if (!value) return null - const session = JSON.parse(value) as StoredTerminalSession - if (!session.sandboxId) 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: session.template ?? 'base', + template, } } catch { return null diff --git a/tests/integration/proxy.test.ts b/tests/integration/proxy.test.ts index 2d90956be..bfdad2771 100644 --- a/tests/integration/proxy.test.ts +++ b/tests/integration/proxy.test.ts @@ -199,6 +199,29 @@ describe('Proxy Integration Tests', () => { 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 index 823952c0b..64bf4b28d 100644 --- a/tests/unit/dashboard-terminal.test.ts +++ b/tests/unit/dashboard-terminal.test.ts @@ -123,6 +123,24 @@ describe('dashboard terminal helpers', () => { ) 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() }) From 8cf74cab32d72a00c76c78a1fe2aedea9dd9322b Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Sun, 10 May 2026 15:06:31 -0700 Subject: [PATCH 14/14] Harden terminal session storage --- src/features/dashboard/terminal/storage.ts | 18 ++++++++++++----- src/features/dashboard/terminal/template.ts | 2 +- tests/unit/dashboard-terminal.test.ts | 22 ++++++++++++++++++++- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/features/dashboard/terminal/storage.ts b/src/features/dashboard/terminal/storage.ts index 091675a27..11106ddef 100644 --- a/src/features/dashboard/terminal/storage.ts +++ b/src/features/dashboard/terminal/storage.ts @@ -40,12 +40,20 @@ export function writeStoredTerminalSession( userId: string, session: StoredTerminalSession ) { - window.localStorage.setItem( - getTerminalSessionStorageKey(userId), - JSON.stringify(session) - ) + 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) { - window.localStorage.removeItem(getTerminalSessionStorageKey(userId)) + 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 index 0652958d6..d320bc870 100644 --- a/src/features/dashboard/terminal/template.ts +++ b/src/features/dashboard/terminal/template.ts @@ -1,5 +1,5 @@ const DEFAULT_TEMPLATE = 'base' -const TEMPLATE_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/ +const TEMPLATE_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]{0,127}$/ export function normalizeTerminalTemplate(template?: string) { const value = template?.trim() diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts index 64bf4b28d..54675e595 100644 --- a/tests/unit/dashboard-terminal.test.ts +++ b/tests/unit/dashboard-terminal.test.ts @@ -86,8 +86,11 @@ describe('dashboard terminal helpers', () => { expect(normalizeTerminalTemplate(' python_3.12-dev ')).toBe( 'python_3.12-dev' ) - expect(normalizeTerminalTemplate('../base')).toBeNull() + expect(normalizeTerminalTemplate('team-slug/python:default')).toBe( + 'team-slug/python:default' + ) expect(normalizeTerminalTemplate('base; echo nope')).toBeNull() + expect(normalizeTerminalTemplate('../base')).toBeNull() }) }) @@ -144,6 +147,23 @@ describe('dashboard terminal helpers', () => { 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', () => {