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'}
-
-
- Command
-
-
+ {canEditUser ? (
+
+
+ User
+
+
+
+ ) : null}
+ {launch.target?.ptyOptions?.cwd ? (
+
+
+ Working directory
+
+
+ {launch.target.ptyOptions.cwd}
+
+
+ ) : null}
+ {canEditEnv ? (
+
+
+ Environment
+
+
+
+ ) : null}
+ {hasCommand ? (
+
+
+ Command
+
+
+ ) : null}
) : null}
@@ -87,10 +150,23 @@ export default function DashboardTerminalCommandDialog({
onConfirm(normalizedCommand)}
+ disabled={hasCommand && !normalizedCommand}
+ onClick={() =>
+ onConfirm(
+ hasCommand ? normalizedCommand : undefined,
+ canEditEnv
+ ? {
+ ...launch?.target?.ptyOptions,
+ user: canEditUser
+ ? userInputRef.current?.value
+ : launch?.target?.ptyOptions?.user,
+ envs: parseEnvVars(envInputRef.current?.value ?? ''),
+ }
+ : undefined
+ )
+ }
>
- Run command
+ {hasCommand ? 'Run command' : 'Start terminal'}
diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx
index f1a949a68..47e8f1e92 100644
--- a/src/features/dashboard/terminal/dashboard-terminal.tsx
+++ b/src/features/dashboard/terminal/dashboard-terminal.tsx
@@ -1,15 +1,27 @@
'use client'
import type { CommandHandle, Sandbox } from 'e2b'
-import { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react'
+import {
+ useCallback,
+ useEffect,
+ useEffectEvent,
+ useReducer,
+ useRef,
+ useState,
+} from 'react'
import { useTRPCClient } from '@/trpc/client'
import {
- DEFAULT_CWD,
TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS,
TERMINAL_AUTOSTART_DEBOUNCE_MS,
} from './constants'
import DashboardTerminalCommandDialog from './dashboard-terminal-command-dialog'
+import { normalizePtyOptions, type TerminalPtyOptions } from './pty-options'
+import PtySettingsDialog from './pty-settings-dialog'
import { openTerminalSandbox } from './sandbox-session'
+import {
+ readStoredTerminalPtyOptions,
+ writeStoredTerminalPtyOptions,
+} from './storage'
import {
normalizeTerminalTemplate,
resolveTerminalTemplateOverride,
@@ -29,6 +41,7 @@ const FLUSH_INPUT_RETRY_INTERVAL_MS = 250
const MAX_INPUT_FLUSH_RETRIES = 2
interface DashboardTerminalProps {
+ allowPtySettings?: boolean
autoStart?: boolean
getSandbox?: TerminalSandboxResolver
launchTarget?: TerminalLaunchTarget
@@ -40,7 +53,59 @@ interface DashboardTerminalProps {
userId: string
}
+type TerminalUiState = {
+ arePtySettingsLoaded: boolean
+ isPtySettingsOpen: boolean
+ pendingLaunch: PendingTerminalLaunch | null
+ ptyOptions: TerminalPtyOptions
+}
+
+type TerminalUiAction =
+ | { options: TerminalPtyOptions; type: 'loadPtyOptions' }
+ | { open: boolean; type: 'setPtySettingsOpen' }
+ | { launch: PendingTerminalLaunch | null; type: 'setPendingLaunch' }
+ | { options: TerminalPtyOptions; type: 'setPtyOptions' }
+
+function terminalUiReducer(
+ state: TerminalUiState,
+ action: TerminalUiAction
+): TerminalUiState {
+ switch (action.type) {
+ case 'loadPtyOptions':
+ return {
+ ...state,
+ arePtySettingsLoaded: true,
+ ptyOptions: action.options,
+ }
+ case 'setPtySettingsOpen':
+ return { ...state, isPtySettingsOpen: action.open }
+ case 'setPendingLaunch':
+ return { ...state, pendingLaunch: action.launch }
+ case 'setPtyOptions':
+ return { ...state, ptyOptions: action.options }
+ }
+}
+
+function getInitialPtyOptions({
+ launchTargetPtyOptions,
+ requiresConfirmation,
+}: {
+ launchTargetPtyOptions: TerminalPtyOptions
+ requiresConfirmation: boolean
+}) {
+ return requiresConfirmation ? {} : launchTargetPtyOptions
+}
+
+function parseSerializedPtyOptions(value: string): TerminalPtyOptions {
+ try {
+ return normalizePtyOptions(JSON.parse(value) as TerminalPtyOptions)
+ } catch {
+ return {}
+ }
+}
+
export default function DashboardTerminal({
+ allowPtySettings = false,
autoStart = false,
getSandbox,
launchTarget,
@@ -58,9 +123,25 @@ export default function DashboardTerminal({
const [template, setTemplate] = useState(
normalizeTerminalTemplate(launchTarget?.template) ?? 'base'
)
- const [pendingLaunch, setPendingLaunch] =
- useState(null)
-
+ const launchTargetPtyOptions = normalizePtyOptions(
+ launchTarget?.ptyOptions ?? {}
+ )
+ const launchTargetPtyOptionsKey = JSON.stringify(launchTargetPtyOptions)
+ const launchTargetRequiresConfirmation =
+ launchTarget?.requiresConfirmation === true
+ const [uiState, dispatchUi] = useReducer(terminalUiReducer, null, () => ({
+ arePtySettingsLoaded: !allowPtySettings,
+ isPtySettingsOpen: false,
+ pendingLaunch: null,
+ ptyOptions: getInitialPtyOptions({
+ launchTargetPtyOptions,
+ requiresConfirmation: launchTargetRequiresConfirmation,
+ }),
+ }))
+ const { arePtySettingsLoaded, isPtySettingsOpen, pendingLaunch, ptyOptions } =
+ uiState
+
+ const ptyOptionsRef = useRef(ptyOptions)
const sandboxRef = useRef(null)
const ptyRef = useRef(null)
const pidRef = useRef(undefined)
@@ -337,7 +418,7 @@ export default function DashboardTerminal({
rows: terminalSize.rows,
timeoutMs: 0,
requestTimeoutMs: TERMINAL_ATTACH_ATTEMPT_TIMEOUT_MS,
- cwd: DEFAULT_CWD,
+ ...normalizePtyOptions(ptyOptionsRef.current),
onData: (data) => {
appendOutput(data)
},
@@ -445,14 +526,17 @@ export default function DashboardTerminal({
return
}
- if (command.trim()) {
- // Commands can come from links, so require an explicit click before
- // sending anything into the PTY.
- setPendingLaunch({
- command: command.trim(),
- target: {
- ...options.target,
- template: nextTemplate,
+ if (command.trim() || options.target?.requiresConfirmation) {
+ // Commands and PTY settings can come from links, so require an
+ // explicit click before using them to start or write to a terminal.
+ dispatchUi({
+ type: 'setPendingLaunch',
+ launch: {
+ command: command.trim() || undefined,
+ target: {
+ ...options.target,
+ template: nextTemplate,
+ },
},
})
return
@@ -472,25 +556,39 @@ export default function DashboardTerminal({
)
const confirmPendingLaunch = useCallback(
- (command: string) => {
+ (command?: string, confirmedPtyOptions?: TerminalPtyOptions) => {
if (!pendingLaunch) return
- const normalizedCommand = command.trim()
- if (!normalizedCommand) return
+ const normalizedCommand = command?.trim() ?? ''
+ if (pendingLaunch.command && !normalizedCommand) return
const { target: launchTarget } = pendingLaunch
+ const hasConfirmedPtyOptions = confirmedPtyOptions !== undefined
+ const nextPtyOptions = hasConfirmedPtyOptions
+ ? normalizePtyOptions({
+ ...launchTarget?.ptyOptions,
+ ...confirmedPtyOptions,
+ })
+ : ptyOptionsRef.current
+ const nextLaunchTarget = {
+ ...launchTarget,
+ ...(hasConfirmedPtyOptions ? { ptyOptions: nextPtyOptions } : {}),
+ }
const launchTemplate = launchTarget?.template ?? 'base'
const launchSandboxId = launchTarget?.sandboxId
if (
status === 'ready' &&
template === launchTemplate &&
+ launchTarget?.requiresConfirmation !== true &&
launchTarget?.forceNewSandbox !== true &&
(!launchSandboxId || activeSandboxId === launchSandboxId)
) {
- setPendingLaunch(null)
- runCommand(normalizedCommand)
- if (activeSandboxId) {
+ dispatchUi({ type: 'setPendingLaunch', launch: null })
+ if (normalizedCommand) {
+ runCommand(normalizedCommand)
+ }
+ if (activeSandboxId && normalizedCommand) {
updateTerminalUrl({
clearCommand: true,
sandboxId: activeSandboxId,
@@ -503,11 +601,18 @@ export default function DashboardTerminal({
return
}
- setPendingLaunch(null)
- pendingCommandsRef.current = [normalizedCommand]
+ dispatchUi({ type: 'setPendingLaunch', launch: null })
+ if (hasConfirmedPtyOptions) {
+ ptyOptionsRef.current = nextPtyOptions
+ dispatchUi({ type: 'setPtyOptions', options: nextPtyOptions })
+ }
+ pendingCommandsRef.current = normalizedCommand ? [normalizedCommand] : []
void startTerminal({
forceNewSandbox: !launchSandboxId && template !== launchTemplate,
- target: launchTarget,
+ target: {
+ ...nextLaunchTarget,
+ requiresConfirmation: false,
+ },
})
},
[
@@ -555,7 +660,54 @@ export default function DashboardTerminal({
startTerminal,
])
+ const applyPtyOptions = (
+ options: TerminalPtyOptions,
+ { makeDefault }: { makeDefault: boolean }
+ ) => {
+ ptyOptionsRef.current = options
+ dispatchUi({ type: 'setPtyOptions', options })
+ if (makeDefault) {
+ writeStoredTerminalPtyOptions(userId, options)
+ }
+
+ if (sandboxScoped) {
+ if (!reconnectSandboxId && !getSandbox) return
+
+ void startTerminal({
+ target: reconnectTarget,
+ })
+ return
+ }
+
+ if (status === 'ready' || status === 'error') {
+ void startTerminal({ forceNewSandbox: true })
+ }
+ }
+
useEffect(() => {
+ if (!allowPtySettings) return
+
+ const storedOptions = readStoredTerminalPtyOptions(userId)
+ const currentLaunchTargetPtyOptions = parseSerializedPtyOptions(
+ launchTargetPtyOptionsKey
+ )
+ const nextOptions = normalizePtyOptions({
+ ...storedOptions,
+ ...(launchTargetRequiresConfirmation
+ ? {}
+ : currentLaunchTargetPtyOptions),
+ })
+ ptyOptionsRef.current = nextOptions
+ dispatchUi({ type: 'loadPtyOptions', options: nextOptions })
+ }, [
+ allowPtySettings,
+ launchTargetPtyOptionsKey,
+ launchTargetRequiresConfirmation,
+ userId,
+ ])
+
+ useEffect(() => {
+ if (!arePtySettingsLoaded) return
if (!autoStart || status !== 'idle' || isStartingRef.current) return
const autoStartTimer = window.setTimeout(() => {
@@ -569,7 +721,13 @@ export default function DashboardTerminal({
return () => {
window.clearTimeout(autoStartTimer)
}
- }, [autoStart, launchTarget, queueTerminalCommand, status])
+ }, [
+ arePtySettingsLoaded,
+ autoStart,
+ launchTarget,
+ queueTerminalCommand,
+ status,
+ ])
const handlePageHide = useEffectEvent((event: PageTransitionEvent) => {
if (event.persisted) return
@@ -606,6 +764,7 @@ export default function DashboardTerminal({
return (
<>
void copyTerminalText()}
+ onConfigurePty={() =>
+ dispatchUi({ type: 'setPtySettingsOpen', open: true })
+ }
onRestartTerminal={restartTerminal}
/>
+
+ dispatchUi({ type: 'setPtySettingsOpen', open })
+ }
+ />
+
setPendingLaunch(null)}
+ onCancel={() => dispatchUi({ type: 'setPendingLaunch', launch: null })}
onConfirm={confirmPendingLaunch}
/>
>
diff --git a/src/features/dashboard/terminal/pty-options.ts b/src/features/dashboard/terminal/pty-options.ts
new file mode 100644
index 000000000..870ff6e67
--- /dev/null
+++ b/src/features/dashboard/terminal/pty-options.ts
@@ -0,0 +1,113 @@
+export interface TerminalPtyOptions {
+ cwd?: string
+ envs?: Record
+ user?: string
+}
+
+type TerminalPtySearchParams = Record
+
+export function normalizePtyOptions(options: TerminalPtyOptions) {
+ const user = options.user?.trim()
+ const cwd = options.cwd?.trim()
+ const envs = options.envs
+ ? Object.fromEntries(
+ Object.entries(options.envs).filter(
+ ([key, value]) => key.trim() && value !== undefined
+ )
+ )
+ : undefined
+
+ return {
+ ...(user ? { user } : {}),
+ ...(cwd ? { cwd } : {}),
+ ...(envs && Object.keys(envs).length > 0 ? { envs } : {}),
+ }
+}
+
+export function formatEnvVars(envs: TerminalPtyOptions['envs']) {
+ if (!envs) return ''
+
+ return Object.entries(envs)
+ .map(([key, value]) => `${key}=${value}`)
+ .join('\n')
+}
+
+export function parseEnvVars(value: string) {
+ const envs: Record = {}
+
+ for (const line of value.split(/\r?\n/)) {
+ const trimmed = line.trim()
+ if (!trimmed) continue
+
+ const separator = trimmed.indexOf('=')
+ if (separator <= 0) continue
+
+ const key = trimmed.slice(0, separator).trim()
+ if (!key) continue
+
+ envs[key] = trimmed.slice(separator + 1)
+ }
+
+ return Object.keys(envs).length > 0 ? envs : undefined
+}
+
+export function parsePtyOptionsFromSearchParams(
+ searchParams: TerminalPtySearchParams
+) {
+ return normalizePtyOptions({
+ user: readFirstSearchParam(searchParams, 'user'),
+ cwd: readFirstSearchParam(searchParams, 'cwd'),
+ envs: parseEnvSearchParams(searchParams),
+ })
+}
+
+export function hasPtyOptionsSearchParams(
+ searchParams: TerminalPtySearchParams
+) {
+ return ['user', 'cwd', 'env'].some((key) => {
+ const value = searchParams[key]
+ return Array.isArray(value) ? value.length > 0 : value !== undefined
+ })
+}
+
+function readFirstSearchParam(
+ searchParams: TerminalPtySearchParams,
+ ...keys: string[]
+) {
+ for (const key of keys) {
+ const value = searchParams[key]
+ if (Array.isArray(value)) {
+ const firstValue = value.find(Boolean)
+ if (firstValue) return firstValue
+ continue
+ }
+ if (value) return value
+ }
+}
+
+function parseEnvSearchParams(searchParams: TerminalPtySearchParams) {
+ const entries = readSearchParamValues(searchParams, 'env')
+ const envs: Record = {}
+
+ for (const entry of entries) {
+ const separator = entry.indexOf('=')
+ if (separator <= 0) continue
+
+ const key = entry.slice(0, separator).trim()
+ if (!key) continue
+
+ envs[key] = entry.slice(separator + 1)
+ }
+
+ return Object.keys(envs).length > 0 ? envs : undefined
+}
+
+function readSearchParamValues(
+ searchParams: TerminalPtySearchParams,
+ key: string
+) {
+ const value = searchParams[key]
+ if (!value) return []
+
+ return Array.isArray(value) ? value : [value]
+}
diff --git a/src/features/dashboard/terminal/pty-settings-dialog.tsx b/src/features/dashboard/terminal/pty-settings-dialog.tsx
new file mode 100644
index 000000000..3a3773827
--- /dev/null
+++ b/src/features/dashboard/terminal/pty-settings-dialog.tsx
@@ -0,0 +1,143 @@
+'use client'
+
+import { useId, useState } from 'react'
+import { Button } from '@/ui/primitives/button'
+import { Checkbox } from '@/ui/primitives/checkbox'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/ui/primitives/dialog'
+import { Input } from '@/ui/primitives/input'
+import { Textarea } from '@/ui/primitives/textarea'
+import {
+ formatEnvVars,
+ parseEnvVars,
+ type TerminalPtyOptions,
+} from './pty-options'
+
+interface PtySettingsDialogProps {
+ open: boolean
+ options: TerminalPtyOptions
+ onApply: (
+ options: TerminalPtyOptions,
+ settings: { makeDefault: boolean }
+ ) => void
+ onOpenChange: (open: boolean) => void
+}
+
+export default function PtySettingsDialog({
+ open,
+ options,
+ onApply,
+ onOpenChange,
+}: PtySettingsDialogProps) {
+ return (
+
+
+
+ PTY settings
+
+ Applied when the terminal opens a new PTY.
+
+
+
+ {open ? (
+
+ ) : null}
+
+
+ )
+}
+
+function PtySettingsForm({
+ options,
+ onApply,
+ onOpenChange,
+}: Omit) {
+ const userId = useId()
+ const cwdId = useId()
+ const envsId = useId()
+ const makeDefaultId = useId()
+ const [user, setUser] = useState(options.user ?? '')
+ const [cwd, setCwd] = useState(options.cwd ?? '')
+ const [envs, setEnvs] = useState(() => formatEnvVars(options.envs))
+ const [makeDefault, setMakeDefault] = useState(false)
+
+ const applySettings = () => {
+ onApply(
+ {
+ user,
+ cwd,
+ envs: parseEnvVars(envs),
+ },
+ { makeDefault }
+ )
+ onOpenChange(false)
+ }
+
+ return (
+ <>
+
+
+ User
+ setUser(event.target.value)}
+ />
+
+
+
+
+ Working directory
+
+ setCwd(event.target.value)}
+ />
+
+
+
+
+ Environment
+
+
+
+
+ setMakeDefault(checked === true)}
+ />
+
+ Make default
+
+
+
+
+
+
+ Apply and reconnect
+
+
+ >
+ )
+}
diff --git a/src/features/dashboard/terminal/storage.ts b/src/features/dashboard/terminal/storage.ts
index 11106ddef..a9b676e6a 100644
--- a/src/features/dashboard/terminal/storage.ts
+++ b/src/features/dashboard/terminal/storage.ts
@@ -1,4 +1,9 @@
-import { TERMINAL_SESSION_STORAGE_PREFIX } from './constants'
+import { getBrowserCookie, setBrowserCookie } from '@/lib/utils/browser-cookies'
+import {
+ TERMINAL_PTY_SETTINGS_COOKIE_PREFIX,
+ TERMINAL_SESSION_STORAGE_PREFIX,
+} from './constants'
+import type { TerminalPtyOptions } from './pty-options'
import { normalizeTerminalTemplate } from './template'
import type { StoredTerminalSession } from './types'
@@ -6,6 +11,10 @@ function getTerminalSessionStorageKey(userId: string) {
return `${TERMINAL_SESSION_STORAGE_PREFIX}:${userId}`
}
+function getTerminalPtySettingsCookieKey(userId: string) {
+ return `${TERMINAL_PTY_SETTINGS_COOKIE_PREFIX}:${userId}`
+}
+
export function readStoredTerminalSession(userId: string) {
try {
const value = window.localStorage.getItem(
@@ -36,6 +45,56 @@ export function readStoredTerminalSession(userId: string) {
}
}
+export function readStoredTerminalPtyOptions(
+ userId: string
+): TerminalPtyOptions {
+ try {
+ const value = getBrowserCookie(getTerminalPtySettingsCookieKey(userId))
+ if (!value) return {}
+
+ const options = JSON.parse(value) as Partial
+
+ return normalizeStoredPtyOptions(options)
+ } catch {
+ return {}
+ }
+}
+
+export function writeStoredTerminalPtyOptions(
+ userId: string,
+ options: TerminalPtyOptions
+) {
+ try {
+ setBrowserCookie(
+ getTerminalPtySettingsCookieKey(userId),
+ JSON.stringify(normalizeStoredPtyOptions(options))
+ )
+ } catch {
+ // Terminal launch should still succeed if browser cookies are unavailable.
+ }
+}
+
+function normalizeStoredPtyOptions(
+ options: Partial
+): TerminalPtyOptions {
+ const user = typeof options.user === 'string' ? options.user : undefined
+ const cwd = typeof options.cwd === 'string' ? options.cwd : undefined
+ const envs =
+ options.envs && typeof options.envs === 'object'
+ ? Object.fromEntries(
+ Object.entries(options.envs).filter(
+ ([key, value]) => key && typeof value === 'string'
+ )
+ )
+ : undefined
+
+ return {
+ ...(user !== undefined ? { user } : {}),
+ ...(cwd !== undefined ? { cwd } : {}),
+ ...(envs && Object.keys(envs).length > 0 ? { envs } : {}),
+ }
+}
+
export function writeStoredTerminalSession(
userId: string,
session: StoredTerminalSession
diff --git a/src/features/dashboard/terminal/terminal-panel.tsx b/src/features/dashboard/terminal/terminal-panel.tsx
index 3225f9b4e..c47277ac1 100644
--- a/src/features/dashboard/terminal/terminal-panel.tsx
+++ b/src/features/dashboard/terminal/terminal-panel.tsx
@@ -1,8 +1,14 @@
import type { RefObject } from 'react'
import { IconButton } from '@/ui/primitives/icon-button'
-import { CopyIcon, RefreshIcon, TerminalIcon } from '@/ui/primitives/icons'
+import {
+ CopyIcon,
+ RefreshIcon,
+ SettingsIcon,
+ TerminalIcon,
+} from '@/ui/primitives/icons'
interface TerminalPanelProps {
+ canConfigurePty?: boolean
sandboxId?: string
template?: string
restartDisabled: boolean
@@ -10,10 +16,12 @@ interface TerminalPanelProps {
terminalContainerRef: RefObject
onFocusTerminal: () => void
onCopyTerminalText: () => void
+ onConfigurePty?: () => void
onRestartTerminal: () => void
}
export default function TerminalPanel({
+ canConfigurePty = false,
sandboxId,
template,
restartDisabled,
@@ -21,6 +29,7 @@ export default function TerminalPanel({
terminalContainerRef,
onFocusTerminal,
onCopyTerminalText,
+ onConfigurePty,
onRestartTerminal,
}: TerminalPanelProps) {
return (
@@ -31,7 +40,9 @@ export default function TerminalPanel({
template={template}
restartDisabled={restartDisabled}
restartLabel={restartLabel}
+ canConfigurePty={canConfigurePty}
onCopyTerminalText={onCopyTerminalText}
+ onConfigurePty={onConfigurePty}
onRestartTerminal={onRestartTerminal}
/>
@@ -53,12 +64,16 @@ function TerminalPanelHeader({
restartLabel,
onCopyTerminalText,
onRestartTerminal,
+ canConfigurePty,
+ onConfigurePty,
}: Pick<
TerminalPanelProps,
+ | 'canConfigurePty'
| 'sandboxId'
| 'template'
| 'restartDisabled'
| 'restartLabel'
+ | 'onConfigurePty'
| 'onCopyTerminalText'
| 'onRestartTerminal'
>) {
@@ -82,6 +97,19 @@ function TerminalPanelHeader({
+ {canConfigurePty ? (
+ event.preventDefault()}
+ onClick={onConfigurePty}
+ >
+
+
+ ) : null}
value.trim())
+ .find((value) => value.startsWith(encodedName))
+
+ if (!cookie) return undefined
+
+ return decodeURIComponent(cookie.slice(encodedName.length))
+}
diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts
index 9c087cf0d..d7005d012 100644
--- a/tests/unit/dashboard-terminal.test.ts
+++ b/tests/unit/dashboard-terminal.test.ts
@@ -1,9 +1,17 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TERMINAL_SESSION_STORAGE_PREFIX } from '@/features/dashboard/terminal/constants'
+import {
+ hasPtyOptionsSearchParams,
+ normalizePtyOptions,
+ parseEnvVars,
+ parsePtyOptionsFromSearchParams,
+} from '@/features/dashboard/terminal/pty-options'
import { openTerminalSandbox } from '@/features/dashboard/terminal/sandbox-session'
import {
clearStoredTerminalSession,
+ readStoredTerminalPtyOptions,
readStoredTerminalSession,
+ writeStoredTerminalPtyOptions,
writeStoredTerminalSession,
} from '@/features/dashboard/terminal/storage'
import {
@@ -27,11 +35,15 @@ const mockOpenTerminal = vi.fn()
function installLocalStorage() {
const values = new Map()
+ const cookies = new Map()
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
innerWidth: 1200,
+ location: {
+ protocol: 'http:',
+ },
localStorage: {
getItem: vi.fn((key: string) => values.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
@@ -44,6 +56,27 @@ function installLocalStorage() {
},
})
+ Object.defineProperty(globalThis, 'document', {
+ configurable: true,
+ value: {
+ get cookie() {
+ return Array.from(cookies)
+ .map(([key, value]) => `${key}=${value}`)
+ .join('; ')
+ },
+ set cookie(value: string) {
+ const [cookieValue] = value.split(';')
+ const separator = cookieValue.indexOf('=')
+ if (separator <= 0) return
+
+ cookies.set(
+ cookieValue.slice(0, separator),
+ cookieValue.slice(separator + 1)
+ )
+ },
+ },
+ })
+
return values
}
@@ -98,6 +131,77 @@ describe('dashboard terminal helpers', () => {
})
})
+ describe('PTY settings', () => {
+ it('normalizes optional PTY create options', () => {
+ expect(
+ normalizePtyOptions({
+ user: ' root ',
+ cwd: ' /app ',
+ envs: {
+ DEBUG: '1',
+ '': 'ignored',
+ },
+ })
+ ).toEqual({
+ user: 'root',
+ cwd: '/app',
+ envs: {
+ DEBUG: '1',
+ },
+ })
+ })
+
+ it('parses newline-delimited environment variables', () => {
+ expect(parseEnvVars('DEBUG=1\nNAME=value=with-equals\n\nbad')).toEqual({
+ DEBUG: '1',
+ NAME: 'value=with-equals',
+ })
+ expect(parseEnvVars('')).toBeUndefined()
+ })
+
+ it('parses PTY options from terminal query params', () => {
+ expect(
+ parsePtyOptionsFromSearchParams({
+ user: ' root ',
+ cwd: ' /app ',
+ env: ['DEBUG=1', 'NAME=value=with-equals', 'bad'],
+ })
+ ).toEqual({
+ user: 'root',
+ cwd: '/app',
+ envs: {
+ DEBUG: '1',
+ NAME: 'value=with-equals',
+ },
+ })
+ })
+
+ it('detects PTY options in terminal query params', () => {
+ expect(hasPtyOptionsSearchParams({})).toBe(false)
+ expect(hasPtyOptionsSearchParams({ user: '' })).toBe(true)
+ expect(hasPtyOptionsSearchParams({ env: ['DEBUG=1'] })).toBe(true)
+ })
+
+ it('round-trips saved PTY options through a cookie', () => {
+ writeStoredTerminalPtyOptions('user-123', {
+ user: 'root',
+ cwd: '/app',
+ envs: {
+ DEBUG: '1',
+ },
+ })
+
+ expect(readStoredTerminalPtyOptions('user-123')).toEqual({
+ user: 'root',
+ cwd: '/app',
+ envs: {
+ DEBUG: '1',
+ },
+ })
+ expect(window.localStorage.setItem).not.toHaveBeenCalled()
+ })
+ })
+
describe('stored terminal session', () => {
it('round-trips session data by user and ignores invalid stored values', () => {
writeStoredTerminalSession('user-123', {
diff --git a/tests/unit/sandbox-side-effects.test.ts b/tests/unit/sandbox-side-effects.test.ts
index c455d4c61..cd7d8b242 100644
--- a/tests/unit/sandbox-side-effects.test.ts
+++ b/tests/unit/sandbox-side-effects.test.ts
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTRPCContext } from '@/core/server/trpc/init'
+import { resolveBrowserSandboxUrl } from '@/core/shared/create-envd-sandbox'
import { SANDBOX_RESUME_TIMEOUT_MS } from '@/features/dashboard/sandbox/inspect/constants'
import { TERMINAL_SANDBOX_TIMEOUT_MS } from '@/features/dashboard/terminal/constants'
@@ -23,6 +24,7 @@ const sdkMock = vi.hoisted(() => ({
}))
vi.mock('e2b', () => ({
+ default: class Sandbox {},
Sandbox: {
connect: sdkMock.connect,
create: sdkMock.create,
@@ -145,4 +147,29 @@ describe('sandbox inspect/terminal client never calls the control plane', () =>
expect(source).not.toMatch(/Sandbox\.connect\s*\(/)
expect(source).not.toMatch(/Sandbox\.create\s*\(/)
})
+
+ it('rewrites browser sandboxUrl values to the client-proxy sandbox host', () => {
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: {},
+ })
+
+ expect(
+ resolveBrowserSandboxUrl('http://localhost:3002', 'sandbox-123')
+ ).toBe('http://49983-sandbox-123.localhost:3002/')
+ expect(
+ resolveBrowserSandboxUrl(
+ 'http://49983-sandbox-123.localhost:3002',
+ 'sandbox-123'
+ )
+ ).toBe('http://49983-sandbox-123.localhost:3002/')
+ })
+
+ it('dashboard terminal lets envd resolve the PTY working directory', () => {
+ const source = read(
+ 'src/features/dashboard/terminal/dashboard-terminal.tsx'
+ )
+ expect(source).not.toMatch(/cwd\s*:/)
+ expect(source).not.toContain('/home/user')
+ })
})