diff --git a/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx index 4d7f78f1a..c4cff1006 100644 --- a/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/terminal/page.tsx @@ -3,6 +3,10 @@ import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { getAuthContext } from '@/core/server/auth' import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' import SandboxTerminalView from '@/features/dashboard/sandbox/terminal/view' +import { + hasPtyOptionsSearchParams, + parsePtyOptionsFromSearchParams, +} from '@/features/dashboard/terminal/pty-options' interface SandboxTerminalPageProps { params: Promise<{ @@ -10,6 +14,9 @@ interface SandboxTerminalPageProps { }> searchParams: Promise<{ command?: string + cwd?: string + env?: string | string[] + user?: string }> } @@ -17,11 +24,12 @@ export default async function SandboxTerminalPage({ params, searchParams, }: SandboxTerminalPageProps) { - const [{ teamSlug }, { command = '' }, authContext] = await Promise.all([ + const [{ teamSlug }, terminalSearchParams, authContext] = await Promise.all([ params, searchParams, getAuthContext(), ]) + const { command = '' } = terminalSearchParams if (!authContext) { redirect(AUTH_URLS.SIGN_IN) @@ -35,5 +43,12 @@ export default async function SandboxTerminalPage({ redirect(PROTECTED_URLS.DASHBOARD) } - return + return ( + + ) } diff --git a/src/app/dashboard/[teamSlug]/terminal/page.tsx b/src/app/dashboard/[teamSlug]/terminal/page.tsx index 3054eddf1..314fc32f8 100644 --- a/src/app/dashboard/[teamSlug]/terminal/page.tsx +++ b/src/app/dashboard/[teamSlug]/terminal/page.tsx @@ -8,6 +8,10 @@ import { infra } from '@/core/shared/clients/api' import type { components as InfraComponents } from '@/core/shared/contracts/infra-api.types' import { SandboxIdSchema } from '@/core/shared/schemas/api' import DashboardTerminal from '@/features/dashboard/terminal/dashboard-terminal' +import { + hasPtyOptionsSearchParams, + parsePtyOptionsFromSearchParams, +} from '@/features/dashboard/terminal/pty-options' import { normalizeTerminalTemplate } from '@/features/dashboard/terminal/template' import { Button } from '@/ui/primitives/button' @@ -22,8 +26,11 @@ interface TeamTerminalPageProps { }> searchParams: Promise<{ command?: string + cwd?: string + env?: string | string[] sandboxId?: string template?: string + user?: string }> } @@ -31,13 +38,18 @@ export default async function TeamTerminalPage({ params, searchParams, }: TeamTerminalPageProps) { - const [{ teamSlug }, { command = '', sandboxId, template }] = - await Promise.all([params, searchParams]) + const [{ teamSlug }, terminalSearchParams] = await Promise.all([ + params, + searchParams, + ]) + const { command = '', sandboxId, template } = terminalSearchParams const hasTemplateOverride = template !== undefined const requestedTemplate = hasTemplateOverride ? normalizeTerminalTemplate(template) : undefined const terminalSandboxId = normalizeTerminalSandboxId(sandboxId) + const ptyOptions = parsePtyOptionsFromSearchParams(terminalSearchParams) + const requiresConfirmation = hasPtyOptionsSearchParams(terminalSearchParams) if (!terminalSandboxId && hasTemplateOverride && !requestedTemplate) { return @@ -53,11 +65,14 @@ export default async function TeamTerminalPage({ return ( ) } @@ -106,6 +121,8 @@ export default async function TeamTerminalPage({ launchTarget={{ command, forceNewSandbox: !terminalSandboxId && hasTemplateOverride, + ptyOptions, + requiresConfirmation, sandboxId: terminalSandboxId, template: terminalSandboxId ? terminalTemplate @@ -157,20 +174,33 @@ async function getSandboxInTeam({ function TerminalSignIn({ command, + cwd, + env, sandboxId, teamSlug, template, + user, }: { command?: string + cwd?: string + env?: string | string[] sandboxId?: string teamSlug: string template?: string + user?: string }) { - const returnToQuery = new URLSearchParams({ + const returnToParams = new URLSearchParams({ ...(command ? { command } : {}), + ...(cwd ? { cwd } : {}), ...(sandboxId ? { sandboxId } : {}), ...(template ? { template } : {}), - }).toString() + ...(user ? { user } : {}), + }) + const envValues = Array.isArray(env) ? env : env ? [env] : [] + for (const value of envValues) { + returnToParams.append('env', value) + } + const returnToQuery = returnToParams.toString() const returnTo = `${PROTECTED_URLS.TERMINAL(teamSlug)}${ returnToQuery ? `?${returnToQuery}` : '' }` diff --git a/src/app/dashboard/route.ts b/src/app/dashboard/route.ts index a450bb833..d437eadfc 100644 --- a/src/app/dashboard/route.ts +++ b/src/app/dashboard/route.ts @@ -69,13 +69,16 @@ export async function GET(request: NextRequest) { // send everything to terminal if it's terminal if (tab === 'terminal') { - const terminalParams = ['template', 'sandboxId', 'command'] + const terminalParams = ['template', 'sandboxId', 'command', 'user', 'cwd'] terminalParams.forEach((param) => { const value = searchParams.get(param) if (value) { redirectUrl.searchParams.set(param, value) } }) + searchParams.getAll('env').forEach((value) => { + redirectUrl.searchParams.append('env', value) + }) } return NextResponse.redirect(redirectUrl) diff --git a/src/core/shared/create-envd-sandbox.ts b/src/core/shared/create-envd-sandbox.ts index 7699b5870..6507e6515 100644 --- a/src/core/shared/create-envd-sandbox.ts +++ b/src/core/shared/create-envd-sandbox.ts @@ -1,5 +1,7 @@ import Sandbox, { type SandboxConnectOpts } from 'e2b' +const SDK_ENVD_PORT = 49983 + /** * Sandbox-scoped credentials needed to talk to a sandbox's envd daemon * (filesystem + PTY). These are safe to expose to the browser: they grant @@ -49,6 +51,26 @@ export function createEnvdSandbox(params: EnvdSandboxParams): Sandbox { envdAccessToken: params.envdAccessToken, trafficAccessToken: params.trafficAccessToken, domain: params.domain, - sandboxUrl: params.sandboxUrl, + sandboxUrl: resolveBrowserSandboxUrl(params.sandboxUrl, params.sandboxId), }) } + +export function resolveBrowserSandboxUrl( + sandboxUrl: string | undefined, + sandboxId: string +) { + if (!sandboxUrl || typeof window === 'undefined') return sandboxUrl + + try { + const url = new URL(sandboxUrl) + const sandboxHostPrefix = `${SDK_ENVD_PORT}-${sandboxId}.` + + if (!url.hostname.startsWith(sandboxHostPrefix)) { + url.hostname = `${sandboxHostPrefix}${url.hostname}` + } + + return url.toString() + } catch { + return sandboxUrl + } +} diff --git a/src/features/dashboard/sandbox/terminal/view.tsx b/src/features/dashboard/sandbox/terminal/view.tsx index 4b7292377..7b65fb90d 100644 --- a/src/features/dashboard/sandbox/terminal/view.tsx +++ b/src/features/dashboard/sandbox/terminal/view.tsx @@ -3,6 +3,7 @@ import { type ReactNode, useMemo, useState } from 'react' import LoadingLayout from '@/features/dashboard/loading-layout' import DashboardTerminal from '@/features/dashboard/terminal/dashboard-terminal' +import type { TerminalPtyOptions } from '@/features/dashboard/terminal/pty-options' import { useRouteParams } from '@/lib/hooks/use-route-params' import { useDashboard } from '../../context' import { useSandboxContext } from '../context' @@ -10,6 +11,8 @@ import SandboxInspectNotFound from '../inspect/not-found' interface SandboxTerminalViewProps { command?: string + ptyOptions?: TerminalPtyOptions + requiresConfirmation?: boolean userId: string } @@ -17,6 +20,8 @@ const SANDBOX_TERMINAL_RESUME_TIMEOUT_MS = 70_000 export default function SandboxTerminalView({ command, + ptyOptions, + requiresConfirmation = false, userId, }: SandboxTerminalViewProps) { const [shouldResumeSandbox, setShouldResumeSandbox] = useState(false) @@ -34,10 +39,12 @@ export default function SandboxTerminalView({ const launchTarget = useMemo( () => ({ command, + ptyOptions, + requiresConfirmation, sandboxId, template: sandboxTemplate, }), - [command, sandboxId, sandboxTemplate] + [command, ptyOptions, requiresConfirmation, sandboxId, sandboxTemplate] ) const finishSandboxResume = async () => { @@ -84,6 +91,7 @@ export default function SandboxTerminalView({ return (
{ diff --git a/src/features/dashboard/terminal/constants.ts b/src/features/dashboard/terminal/constants.ts index 6382dc5c2..458649595 100644 --- a/src/features/dashboard/terminal/constants.ts +++ b/src/features/dashboard/terminal/constants.ts @@ -3,7 +3,7 @@ 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_PTY_SETTINGS_COOKIE_PREFIX = 'dashboard-terminal-pty' export const TERMINAL_SESSION_STORAGE_PREFIX = 'dashboard-terminal-session' -export const DEFAULT_CWD = '/home/user' export const TERMINAL_AUTOSTART_DEBOUNCE_MS = 300 export const TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS = 15_000 diff --git a/src/features/dashboard/terminal/dashboard-terminal-command-dialog.tsx b/src/features/dashboard/terminal/dashboard-terminal-command-dialog.tsx index 34c6e902d..ea99e821e 100644 --- a/src/features/dashboard/terminal/dashboard-terminal-command-dialog.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal-command-dialog.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useId, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import { Button } from '@/ui/primitives/button' import { Dialog, @@ -11,13 +11,19 @@ import { DialogTitle, } from '@/ui/primitives/dialog' import { WarningIcon } from '@/ui/primitives/icons' +import { Input } from '@/ui/primitives/input' import { Textarea } from '@/ui/primitives/textarea' +import { + formatEnvVars, + parseEnvVars, + type TerminalPtyOptions, +} from './pty-options' import type { PendingTerminalLaunch } from './types' interface DashboardTerminalCommandDialogProps { launch: PendingTerminalLaunch | null onCancel: () => void - onConfirm: (command: string) => void + onConfirm: (command?: string, ptyOptions?: TerminalPtyOptions) => void } export default function DashboardTerminalCommandDialog({ @@ -26,8 +32,16 @@ export default function DashboardTerminalCommandDialog({ onConfirm, }: DashboardTerminalCommandDialogProps) { const commandInputId = useId() + const userInputId = useId() + const envInputId = useId() + const userInputRef = useRef(null) + const envInputRef = useRef(null) const [command, setCommand] = useState('') + const hasCommand = !!launch?.command?.trim() const normalizedCommand = command.trim() + const canEditUser = + !!launch?.target?.requiresConfirmation && !!launch.target.ptyOptions?.user + const canEditEnv = !!launch?.target?.requiresConfirmation useEffect(() => { setCommand(launch?.command ?? '') @@ -40,10 +54,13 @@ export default function DashboardTerminalCommandDialog({
- Review terminal command + + {hasCommand ? 'Review terminal command' : 'Review terminal launch'} + - This command will run inside a persistent E2B sandbox after the - terminal opens. + {hasCommand + ? 'This command will run inside a persistent E2B sandbox after the terminal opens.' + : 'This terminal will open with the connection settings from the URL.'} @@ -63,21 +80,67 @@ export default function DashboardTerminalCommandDialog({ {launch.target?.template ?? 'base'}
-
- -