diff --git a/apps/dashboard/src/components/boxes/BoxDetails.test.tsx b/apps/dashboard/src/components/boxes/BoxDetails.test.tsx
index 9514e7e1a..c71fb533f 100644
--- a/apps/dashboard/src/components/boxes/BoxDetails.test.tsx
+++ b/apps/dashboard/src/components/boxes/BoxDetails.test.tsx
@@ -18,6 +18,8 @@ const mocks = vi.hoisted(() => ({
navigate: vi.fn(),
setSearchParams: vi.fn(),
mutateAsync: vi.fn(),
+ uploadMutateAsync: vi.fn(),
+ terminalCwd: null as string | null,
}))
vi.mock('react-router-dom', () => ({
@@ -106,8 +108,29 @@ vi.mock('@/hooks/mutations/useDeleteBoxMutation', () => ({
useDeleteBoxMutation: () => ({ isPending: false, mutateAsync: mocks.mutateAsync }),
}))
+vi.mock('@/hooks/mutations/useUploadBoxFilesMutation', () => ({
+ useUploadBoxFilesMutation: () => ({ isPending: false, mutateAsync: mocks.uploadMutateAsync }),
+}))
+
vi.mock('./BoxTerminalFrame', () => ({
- BoxTerminalFrame: ({ sessionUrl }: { sessionUrl: string }) =>
{sessionUrl}
,
+ BoxTerminalFrame: ({
+ onCurrentDirChange,
+ sessionUrl,
+ }: {
+ onCurrentDirChange?: (path: string) => void
+ sessionUrl: string
+ }) => (
+
+ {sessionUrl}
+
+
+ ),
}))
function makeRunningBox(): Box {
@@ -140,6 +163,7 @@ describe('BoxDetails refresh', () => {
beforeEach(() => {
mocks.box = makeRunningBox()
+ mocks.terminalCwd = null
vi.clearAllMocks()
})
@@ -183,4 +207,29 @@ describe('BoxDetails refresh', () => {
expect(mocks.terminalRefetch).toHaveBeenCalledTimes(1)
expect(document.querySelector('[data-testid="terminal-frame"]')).toBe(frameBeforeRefresh)
})
+
+ it.each([
+ ['/tmp', '/tmp'],
+ ['/etc/nginx', '/etc'],
+ ['//etc//nginx', '/etc'],
+ ['/root/.ssh', '/root/.ssh'],
+ ['/root//.ssh/authorized_keys', '/root/.ssh'],
+ ['/home/app/../../etc', '/etc'],
+ ['/proc/self', '/proc'],
+ ])('disables uploads when the terminal cwd is blocked: %s', async (cwd, blockedPath) => {
+ mocks.terminalCwd = cwd
+ await renderBoxDetails()
+
+ await act(async () => {
+ document.querySelector('[data-testid="set-terminal-cwd"]')?.click()
+ })
+ await flushReactWork()
+
+ const uploadButton = Array.from(document.querySelectorAll('button')).find((button) =>
+ button.textContent?.includes('Upload Files'),
+ )
+ expect(uploadButton).toBeTruthy()
+ expect(uploadButton?.disabled).toBe(true)
+ expect(uploadButton?.title).toContain(blockedPath)
+ })
})
diff --git a/apps/dashboard/src/components/boxes/BoxDetails.tsx b/apps/dashboard/src/components/boxes/BoxDetails.tsx
index 87b3df9db..870a06eed 100644
--- a/apps/dashboard/src/components/boxes/BoxDetails.tsx
+++ b/apps/dashboard/src/components/boxes/BoxDetails.tsx
@@ -23,11 +23,13 @@ import { useDeleteBoxMutation } from '@/hooks/mutations/useDeleteBoxMutation'
import { useRecoverBoxMutation } from '@/hooks/mutations/useRecoverBoxMutation'
import { useStartBoxMutation } from '@/hooks/mutations/useStartBoxMutation'
import { useStopBoxMutation } from '@/hooks/mutations/useStopBoxMutation'
+import { useUploadBoxFilesMutation } from '@/hooks/mutations/useUploadBoxFilesMutation'
import { useBoxQuery } from '@/hooks/queries/useBoxQuery'
import { useConfig } from '@/hooks/useConfig'
import { useRegions } from '@/hooks/useRegions'
import { useBoxWsSync } from '@/hooks/useBoxWsSync'
import { useSelectedOrganization } from '@/hooks/useSelectedOrganization'
+import { DEFAULT_BOX_UPLOAD_DIR, getBoxUploadDestinationBlockedReason, type BoxUploadItem } from '@/lib/box-upload'
import { getBoxPublicId, getBoxPublicIdLabel } from '@/lib/box-identity'
import { handleApiError } from '@/lib/error-handling'
import { setLocalStorageItem } from '@/lib/local-storage'
@@ -43,11 +45,14 @@ import { isRecoverable, isStartable, isStoppable, isTransitioning } from '@/lib/
import { Box, BoxState, OrganizationRolePermissionsEnum, OrganizationUserRoleEnum } from '@boxlite-ai/api-client'
import { isAxiosError } from 'axios'
import { Check, Container, Copy, MoreVertical, Pause, Play, RefreshCw, RotateCcw, Trash2 } from '@/components/ui/icon'
+import { Icon as IconifyIcon } from '@iconify/react'
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { useAuth } from 'react-oidc-context'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
+import { BoxFileUploadControl, buildDroppedUploadItems } from './BoxFileUploadControl'
import { BoxTerminalTab } from './BoxTerminalTab'
+import { TERMINAL_FILE_DRAG_EVENT } from './terminalIframeSrc'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const STATUS = { running: '#5ad67d', idle: '#e0b341', stopped: '#8C919C', error: '#e0564a', dim: '#5b616e' } as const
@@ -120,6 +125,9 @@ export default function BoxDetails() {
const [copied, setCopied] = useState(false)
const [terminalRefreshSignal, setTerminalRefreshSignal] = useState(0)
const refreshRef = useRef(null)
+ const shellDropRef = useRef(null)
+ const [isShellDragging, setIsShellDragging] = useState(false)
+ const [filesCurrentDir, setFilesCurrentDir] = useState(null)
const updateOnboardingProgress = useCallback(
(progress: OnboardingProgress) => {
@@ -176,6 +184,7 @@ export default function BoxDetails() {
const stopMutation = useStopBoxMutation()
const recoverMutation = useRecoverBoxMutation()
const deleteMutation = useDeleteBoxMutation()
+ const uploadMutation = useUploadBoxFilesMutation()
const writePermitted = authenticatedUserHasPermission(OrganizationRolePermissionsEnum.WRITE_BOXES)
const deletePermitted = authenticatedUserHasPermission(OrganizationRolePermissionsEnum.DELETE_BOXES)
@@ -183,6 +192,27 @@ export default function BoxDetails() {
const anyMutating =
startMutation.isPending || stopMutation.isPending || recoverMutation.isPending || deleteMutation.isPending
const actionsDisabled = anyMutating || transitioning
+ const hasFilesCurrentDir = filesCurrentDir !== null
+ const uploadDestinationDir = filesCurrentDir ?? DEFAULT_BOX_UPLOAD_DIR
+ const uploadDestinationBlockedReason = getBoxUploadDestinationBlockedReason(uploadDestinationDir)
+ const uploadDisabledReason = !writePermitted
+ ? 'You need write access to upload files'
+ : actionsDisabled
+ ? 'Wait for the current box action to finish'
+ : !box || !isStoppable(box)
+ ? 'Start the box before uploading files'
+ : uploadDestinationBlockedReason
+ const canUpload = uploadDisabledReason === undefined
+ const canDragUpload = canUpload && hasFilesCurrentDir
+
+ useEffect(() => {
+ setFilesCurrentDir(null)
+ setIsShellDragging(false)
+ }, [box?.id, box?.state])
+
+ const handleCurrentDirChange = useCallback((path: string) => {
+ setFilesCurrentDir(path)
+ }, [])
const handleStart = async () => {
if (!box) return
@@ -235,6 +265,118 @@ export default function BoxDetails() {
}
}
+ const handleUploadFiles = async (items: BoxUploadItem[]) => {
+ if (!box || !canUpload || items.length === 0) return
+ try {
+ await uploadMutation.mutateAsync({
+ boxId: box.id,
+ detailRef: boxId,
+ destinationDir: uploadDestinationDir,
+ items,
+ })
+ toast.success(items.length === 1 ? `Uploaded ${items[0].name}` : `Uploaded ${items.length} items`)
+ } catch (error) {
+ handleApiError(error, 'Failed to upload files')
+ }
+ }
+
+ const handleUploadInputError = (error: unknown) => {
+ handleApiError(error, 'Failed to prepare upload')
+ }
+
+ const isInsideShellDropTarget = useCallback((event: Pick) => {
+ const rect = shellDropRef.current?.getBoundingClientRect()
+ return Boolean(
+ rect &&
+ event.clientX >= rect.left &&
+ event.clientX <= rect.right &&
+ event.clientY >= rect.top &&
+ event.clientY <= rect.bottom,
+ )
+ }, [])
+
+ const handleDroppedDataTransfer = useCallback(
+ async (dataTransfer: DataTransfer) => {
+ try {
+ const items = await buildDroppedUploadItems(dataTransfer)
+ if (items.length === 0) {
+ throw new Error('No uploadable files found. Drop files or a non-empty folder into the shell.')
+ }
+ await handleUploadFiles(items)
+ } catch (error) {
+ handleUploadInputError(error)
+ }
+ },
+ [handleUploadFiles, handleUploadInputError],
+ )
+
+ useEffect(() => {
+ const resetShellDragging = () => setIsShellDragging(false)
+
+ const hasDraggedFiles = (dataTransfer: DataTransfer) => Array.from(dataTransfer.types ?? []).includes('Files')
+
+ const updateShellDragState = (event: DragEvent) => {
+ if (!canDragUpload || !event.dataTransfer || !hasDraggedFiles(event.dataTransfer)) {
+ resetShellDragging()
+ return
+ }
+ if (!isInsideShellDropTarget(event)) {
+ resetShellDragging()
+ return
+ }
+ event.preventDefault()
+ event.dataTransfer.dropEffect = 'copy'
+ setIsShellDragging(true)
+ }
+
+ const handleWindowDragLeave = (event: DragEvent) => {
+ if (
+ event.clientX <= 0 ||
+ event.clientY <= 0 ||
+ event.clientX >= window.innerWidth ||
+ event.clientY >= window.innerHeight
+ ) {
+ resetShellDragging()
+ }
+ }
+
+ const handleWindowDrop = async (event: DragEvent) => {
+ if (!canDragUpload || !event.dataTransfer || !isInsideShellDropTarget(event)) {
+ resetShellDragging()
+ return
+ }
+ event.preventDefault()
+ resetShellDragging()
+ await handleDroppedDataTransfer(event.dataTransfer)
+ }
+
+ const handleTerminalFileDrag = (event: Event) => {
+ if (!canDragUpload) {
+ resetShellDragging()
+ return
+ }
+
+ const active = Boolean((event as CustomEvent<{ active?: boolean }>).detail?.active)
+ setIsShellDragging(active)
+ }
+
+ window.addEventListener('dragenter', updateShellDragState, { capture: true })
+ window.addEventListener('dragover', updateShellDragState, { capture: true })
+ window.addEventListener('drop', handleWindowDrop)
+ window.addEventListener('dragleave', handleWindowDragLeave)
+ window.addEventListener('dragend', resetShellDragging)
+ window.addEventListener(TERMINAL_FILE_DRAG_EVENT, handleTerminalFileDrag)
+
+ return () => {
+ window.removeEventListener('dragenter', updateShellDragState, { capture: true })
+ window.removeEventListener('dragover', updateShellDragState, { capture: true })
+ window.removeEventListener('drop', handleWindowDrop)
+ window.removeEventListener('dragleave', handleWindowDragLeave)
+ window.removeEventListener('dragend', resetShellDragging)
+ window.removeEventListener(TERMINAL_FILE_DRAG_EVENT, handleTerminalFileDrag)
+ }
+ }, [canDragUpload, handleDroppedDataTransfer, isInsideShellDropTarget])
+
const handleRefresh = () => {
refetch()
setTerminalRefreshSignal((signal) => signal + 1)
@@ -452,8 +594,15 @@ export default function BoxDetails() {
{/* shell / terminal */}
-
-
+
+
shell
@@ -461,10 +610,62 @@ export default function BoxDetails() {
{getBoxPublicIdLabel(box)}
+
+ {hasFilesCurrentDir && (
+
+
+
+ {uploadDestinationBlockedReason ? 'Upload disabled for' : 'Drag files/folders to'}
+
+
+ {uploadDestinationDir}
+
+
+ )}
+
+
-
+
+ {isShellDragging && canDragUpload && (
+
{
+ event.preventDefault()
+ event.dataTransfer.dropEffect = 'copy'
+ setIsShellDragging(true)
+ }}
+ onDragLeave={(event) => {
+ if (!isInsideShellDropTarget(event.nativeEvent)) setIsShellDragging(false)
+ }}
+ onDrop={async (event) => {
+ event.preventDefault()
+ event.stopPropagation()
+ setIsShellDragging(false)
+ await handleDroppedDataTransfer(event.dataTransfer)
+ }}
+ >
+ {`Drop to upload into ${uploadDestinationDir}`}
+
+ )}
>
diff --git a/apps/dashboard/src/components/boxes/BoxFileUploadControl.tsx b/apps/dashboard/src/components/boxes/BoxFileUploadControl.tsx
new file mode 100644
index 000000000..a0c2a1268
--- /dev/null
+++ b/apps/dashboard/src/components/boxes/BoxFileUploadControl.tsx
@@ -0,0 +1,215 @@
+/*
+ * Copyright 2026 BoxLite AI
+ * SPDX-License-Identifier: AGPL-3.0
+ */
+
+import { Button } from '@/components/ui/button'
+import { Spinner } from '@/components/ui/spinner'
+import {
+ buildBoxUploadItems,
+ createBoxUploadDirectoryItem,
+ createBoxUploadFileItem,
+ type BoxUploadFileEntry,
+ type BoxUploadItem,
+} from '@/lib/box-upload'
+import { Icon as IconifyIcon } from '@iconify/react'
+import { useRef } from 'react'
+
+interface BoxFileUploadControlProps {
+ disabled: boolean
+ disabledReason?: string
+ destinationDir: string
+ isUploading: boolean
+ onError: (error: unknown) => void
+ onUpload: (items: BoxUploadItem[]) => void
+}
+
+export function BoxFileUploadControl({
+ disabled,
+ disabledReason,
+ destinationDir,
+ isUploading,
+ onError,
+ onUpload,
+}: BoxFileUploadControlProps) {
+ const fileInputRef = useRef
(null)
+ const directoryInputRef = useRef(null)
+ const isDisabled = disabled || isUploading
+
+ const submitItems = (items: BoxUploadItem[]) => {
+ if (items.length === 0) return
+ onUpload(items)
+ }
+
+ const submitFiles = (fileList: FileList | File[] | null | undefined) => {
+ const files = Array.from(fileList ?? [])
+ if (files.length === 0) return
+ try {
+ submitItems(buildBoxUploadItems(files))
+ } catch (error) {
+ onError(error)
+ }
+ }
+
+ const openFilePicker = () => {
+ if (isDisabled) return
+ fileInputRef.current?.click()
+ }
+
+ const openDirectoryPicker = () => {
+ if (isDisabled) return
+ directoryInputRef.current?.click()
+ }
+
+ return (
+
+ {
+ submitFiles(event.currentTarget.files)
+ event.currentTarget.value = ''
+ }}
+ />
+ {
+ submitFiles(event.currentTarget.files)
+ event.currentTarget.value = ''
+ }}
+ />
+
+
+
+ )
+}
+
+interface UploadFileSystemEntry {
+ isDirectory: boolean
+ isFile: boolean
+ name: string
+}
+
+interface UploadFileSystemFileEntry extends UploadFileSystemEntry {
+ file: (successCallback: (file: File) => void, errorCallback?: (error: DOMException) => void) => void
+}
+
+interface UploadFileSystemDirectoryEntry extends UploadFileSystemEntry {
+ createReader: () => {
+ readEntries: (
+ successCallback: (entries: UploadFileSystemEntry[]) => void,
+ errorCallback?: (error: DOMException) => void,
+ ) => void
+ }
+}
+
+type WebkitDataTransferItem = DataTransferItem & {
+ webkitGetAsEntry?: () => unknown
+}
+
+export async function buildDroppedUploadItems(dataTransfer: DataTransfer): Promise {
+ const entries = Array.from(dataTransfer.items ?? [])
+ .map((item) => ((item as WebkitDataTransferItem).webkitGetAsEntry?.() ?? null) as UploadFileSystemEntry | null)
+ .filter((entry): entry is UploadFileSystemEntry => Boolean(entry))
+
+ if (entries.length === 0) {
+ return buildBoxUploadItems(Array.from(dataTransfer.files ?? []))
+ }
+
+ const items = await Promise.all(entries.map(readEntryAsUploadItem))
+ return items.filter((item): item is BoxUploadItem => Boolean(item))
+}
+
+async function readEntryAsUploadItem(entry: UploadFileSystemEntry): Promise {
+ if (entry.isFile) {
+ return createBoxUploadFileItem(await readFileEntry(entry as UploadFileSystemFileEntry))
+ }
+
+ if (entry.isDirectory) {
+ const files = await readDirectoryFiles(entry as UploadFileSystemDirectoryEntry, '')
+ return files.length > 0 ? createBoxUploadDirectoryItem(entry.name, files) : null
+ }
+
+ return null
+}
+
+function readFileEntry(entry: UploadFileSystemFileEntry): Promise {
+ return new Promise((resolve, reject) => {
+ entry.file(resolve, reject)
+ })
+}
+
+async function readDirectoryFiles(
+ directory: UploadFileSystemDirectoryEntry,
+ prefix: string,
+): Promise {
+ const entries = await readAllDirectoryEntries(directory)
+ const files = await Promise.all(
+ entries.map(async (entry) => {
+ if (entry.isFile) {
+ return [
+ {
+ file: await readFileEntry(entry as UploadFileSystemFileEntry),
+ relativePath: `${prefix}${entry.name}`,
+ },
+ ]
+ }
+
+ if (entry.isDirectory) {
+ return readDirectoryFiles(entry as UploadFileSystemDirectoryEntry, `${prefix}${entry.name}/`)
+ }
+
+ return []
+ }),
+ )
+
+ return files.flat()
+}
+
+function readAllDirectoryEntries(directory: UploadFileSystemDirectoryEntry): Promise {
+ const reader = directory.createReader()
+ const entries: UploadFileSystemEntry[] = []
+
+ return new Promise((resolve, reject) => {
+ const readBatch = () => {
+ reader.readEntries((batch) => {
+ if (batch.length === 0) {
+ resolve(entries)
+ return
+ }
+ entries.push(...batch)
+ readBatch()
+ }, reject)
+ }
+
+ readBatch()
+ })
+}
diff --git a/apps/dashboard/src/components/boxes/BoxTerminalFrame.tsx b/apps/dashboard/src/components/boxes/BoxTerminalFrame.tsx
index 36a1c1285..65091a95a 100644
--- a/apps/dashboard/src/components/boxes/BoxTerminalFrame.tsx
+++ b/apps/dashboard/src/components/boxes/BoxTerminalFrame.tsx
@@ -14,9 +14,10 @@ interface BoxTerminalFrameProps {
sessionUrl: string
fullscreenHref?: string
className?: string
+ onCurrentDirChange?: (path: string) => void
}
-export function BoxTerminalFrame({ sessionUrl, fullscreenHref, className }: BoxTerminalFrameProps) {
+export function BoxTerminalFrame({ sessionUrl, fullscreenHref, className, onCurrentDirChange }: BoxTerminalFrameProps) {
const deregisterRef = useRef<(() => void) | null>(null)
const iframeSrc = buildTerminalIframeSrc(sessionUrl)
@@ -24,7 +25,7 @@ export function BoxTerminalFrame({ sessionUrl, fullscreenHref, className }: BoxT
const frame = event.currentTarget.contentWindow
if (!frame) return
deregisterRef.current?.()
- deregisterRef.current = registerActiveTerminalFrame(frame, sessionUrl)
+ deregisterRef.current = registerActiveTerminalFrame(frame, sessionUrl, { onCurrentDirChange })
}
useEffect(() => {
diff --git a/apps/dashboard/src/components/boxes/BoxTerminalTab.tsx b/apps/dashboard/src/components/boxes/BoxTerminalTab.tsx
index f6d632cb1..bab2716fc 100644
--- a/apps/dashboard/src/components/boxes/BoxTerminalTab.tsx
+++ b/apps/dashboard/src/components/boxes/BoxTerminalTab.tsx
@@ -21,7 +21,13 @@ import { Play, RefreshCw, TerminalSquare } from '@/components/ui/icon'
import { toast } from 'sonner'
import { BoxTerminalFrame } from './BoxTerminalFrame'
-export function BoxTerminalTab({ box, refreshSignal = 0 }: { box: Box; refreshSignal?: number }) {
+interface BoxTerminalTabProps {
+ box: Box
+ refreshSignal?: number
+ onCurrentDirChange?: (path: string) => void
+}
+
+export function BoxTerminalTab({ box, refreshSignal = 0, onCurrentDirChange }: BoxTerminalTabProps) {
const running = isStoppable(box)
const { isTerminalActivated, activateTerminal } = useBoxSessionContext()
const { authenticatedUserHasPermission } = useSelectedOrganization()
@@ -162,6 +168,7 @@ export function BoxTerminalTab({ box, refreshSignal = 0 }: { box: Box; refreshSi
sessionUrl={session.url}
fullscreenHref={fullscreenHref}
className="h-full"
+ onCurrentDirChange={onCurrentDirChange}
/>
diff --git a/apps/dashboard/src/components/boxes/terminalIframeSrc.ts b/apps/dashboard/src/components/boxes/terminalIframeSrc.ts
index 77c1efb11..9c41271e0 100644
--- a/apps/dashboard/src/components/boxes/terminalIframeSrc.ts
+++ b/apps/dashboard/src/components/boxes/terminalIframeSrc.ts
@@ -6,11 +6,12 @@
/**
* Bridge for the box-controlled terminal iframe.
*
- * Only the bounded font-size scalar is forwarded on the iframe URL. User
- * snippets or custom key sequences stay out of URL/query/postMessage paths.
+ * Only the bounded font-size scalar is forwarded on the iframe URL. The
+ * parent page may request terminal metadata, but does not send shell input.
*/
const FONT_SIZE_KEY = 'boxlite.terminal.fontSize'
+export const TERMINAL_FILE_DRAG_EVENT = 'boxlite.terminal-file-drag'
function readNumber(key: string, min: number, max: number): number | null {
try {
@@ -45,21 +46,42 @@ export function buildTerminalIframeSrc(baseUrl: string): string {
let listenerInstalled = false
// Registered iframe windows are allowed to persist non-sensitive prefs and
-// receive paste replies with a precise targetOrigin. Registration is not a
-// user gesture, so iframe-originated messages never trigger clipboard reads.
-const activeTerminalFrames = new Map()
+// receive bounded dashboard commands with a precise targetOrigin. Registration
+// is not a user gesture, so iframe-originated messages never trigger clipboard
+// reads.
+interface ActiveTerminalFrame {
+ onCurrentDirChange?: (path: string) => void
+ origin: string
+}
+
+const activeTerminalFrames = new Map()
-export function registerActiveTerminalFrame(frame: Window, sessionUrl: string): () => void {
+export function registerActiveTerminalFrame(
+ frame: Window,
+ sessionUrl: string,
+ options: { onCurrentDirChange?: (path: string) => void } = {},
+): () => void {
if (typeof window === 'undefined') return () => {}
+ ensureTerminalPrefListener()
let origin: string
try {
origin = new URL(sessionUrl).origin
} catch {
return () => {}
}
- activeTerminalFrames.set(frame, origin)
+ activeTerminalFrames.set(frame, {
+ onCurrentDirChange: options.onCurrentDirChange,
+ origin,
+ })
+ frame.postMessage(
+ {
+ source: 'boxlite-dashboard',
+ type: 'cwd-request',
+ },
+ origin,
+ )
return () => {
- if (activeTerminalFrames.get(frame) === origin) activeTerminalFrames.delete(frame)
+ if (activeTerminalFrames.get(frame)?.origin === origin) activeTerminalFrames.delete(frame)
}
}
@@ -79,9 +101,9 @@ function ensureTerminalPrefListener() {
const senderFrame = event.source as Window | null
if (!senderFrame) return
- const registeredOrigin = activeTerminalFrames.get(senderFrame)
- if (!registeredOrigin) return
- if (event.origin !== registeredOrigin) return
+ const registeredFrame = activeTerminalFrames.get(senderFrame)
+ if (!registeredFrame) return
+ if (event.origin !== registeredFrame.origin) return
if (msg.type === 'pref') {
if (msg.key === 'fontSize' && typeof msg.value === 'number' && msg.value >= 8 && msg.value <= 32) {
@@ -94,6 +116,22 @@ function ensureTerminalPrefListener() {
return
}
+ if (msg.type === 'cwd') {
+ if (typeof msg.value === 'string' && isSafeAbsoluteBoxPath(msg.value)) {
+ registeredFrame.onCurrentDirChange?.(msg.value)
+ }
+ return
+ }
+
+ if (msg.type === 'file-drag') {
+ window.dispatchEvent(
+ new CustomEvent(TERMINAL_FILE_DRAG_EVENT, {
+ detail: { active: msg.value === 'active' },
+ }),
+ )
+ return
+ }
+
if (msg.type === 'ready') {
// Handshake ping only; no user-supplied terminal data is sent back.
return
@@ -102,3 +140,15 @@ function ensureTerminalPrefListener() {
// In particular, iframe-originated paste requests are ignored.
})
}
+
+function isSafeAbsoluteBoxPath(value: string): boolean {
+ return value.startsWith('/') && value.length <= 4096 && !hasControlCharacter(value)
+}
+
+function hasControlCharacter(value: string): boolean {
+ for (let i = 0; i < value.length; i += 1) {
+ const code = value.charCodeAt(i)
+ if (code <= 31 || code === 127) return true
+ }
+ return false
+}
diff --git a/apps/dashboard/src/hooks/mutations/useUploadBoxFilesMutation.ts b/apps/dashboard/src/hooks/mutations/useUploadBoxFilesMutation.ts
new file mode 100644
index 000000000..659e3b984
--- /dev/null
+++ b/apps/dashboard/src/hooks/mutations/useUploadBoxFilesMutation.ts
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2026 BoxLite AI
+ * SPDX-License-Identifier: AGPL-3.0
+ */
+
+import { useApi } from '@/hooks/useApi'
+import { useSelectedOrganization } from '@/hooks/useSelectedOrganization'
+import { queryKeys } from '@/hooks/queries/queryKeys'
+import { uploadBoxItemViaBoxApi } from '@/lib/cloudBox'
+import { DEFAULT_BOX_UPLOAD_DIR, type BoxUploadItem } from '@/lib/box-upload'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+interface UploadBoxFilesVariables {
+ boxId: string
+ detailRef?: string
+ destinationDir?: string
+ items: BoxUploadItem[]
+}
+
+interface UploadBoxFilesResult {
+ organizationId: string
+ uploadedPaths: string[]
+}
+
+interface UploadBoxFilesContext {
+ organizationId?: string
+}
+
+export const useUploadBoxFilesMutation = () => {
+ const api = useApi()
+ const { selectedOrganization } = useSelectedOrganization()
+ const queryClient = useQueryClient()
+
+ const invalidateBoxDetail = (organizationId: string, boxId: string, detailRef?: string) => {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.boxes.detail(organizationId, boxId),
+ })
+ if (detailRef && detailRef !== boxId) {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.boxes.detail(organizationId, detailRef),
+ })
+ }
+ }
+
+ return useMutation({
+ onMutate: () => ({
+ organizationId: selectedOrganization?.id,
+ }),
+ mutationFn: async ({ boxId, items, destinationDir = DEFAULT_BOX_UPLOAD_DIR }: UploadBoxFilesVariables) => {
+ const organizationId = selectedOrganization?.id
+ if (!organizationId) {
+ throw new Error(`Cannot upload files to box ${boxId}: no organization selected`)
+ }
+
+ const uploadedPaths: string[] = []
+ for (const item of items) {
+ uploadedPaths.push(await uploadBoxItemViaBoxApi(api, organizationId, boxId, item, destinationDir))
+ }
+ return { organizationId, uploadedPaths }
+ },
+ onSettled: (data, _error, variables, context) => {
+ const organizationId = data?.organizationId ?? context?.organizationId
+ if (variables && organizationId) {
+ invalidateBoxDetail(organizationId, variables.boxId, variables.detailRef)
+ }
+ },
+ })
+}
diff --git a/apps/dashboard/src/lib/box-upload.ts b/apps/dashboard/src/lib/box-upload.ts
new file mode 100644
index 000000000..741cadc7e
--- /dev/null
+++ b/apps/dashboard/src/lib/box-upload.ts
@@ -0,0 +1,243 @@
+/*
+ * Copyright 2026 BoxLite AI
+ * SPDX-License-Identifier: AGPL-3.0
+ */
+
+// Initial upload destination before the terminal reports a cwd.
+export const DEFAULT_BOX_UPLOAD_DIR = '/root'
+
+const TAR_BLOCK_SIZE = 512
+const BLOCKED_UPLOAD_DESTINATIONS = [
+ { path: '/tmp', reason: 'that path is tmpfs-backed' },
+ { path: '/dev/shm', reason: 'that path is tmpfs-backed' },
+ { path: '/proc', reason: 'that path is managed by the system' },
+ { path: '/sys', reason: 'that path is managed by the system' },
+ { path: '/dev', reason: 'that path is managed by the system' },
+ { path: '/run', reason: 'that path is managed by the system' },
+ { path: '/etc', reason: 'that path stores system configuration' },
+ { path: '/root/.ssh', reason: 'that path stores SSH credentials' },
+] as const
+
+export interface BoxUploadFileEntry {
+ file: File
+ relativePath: string
+}
+
+export interface BoxUploadItem {
+ kind: 'file' | 'directory'
+ name: string
+ files: BoxUploadFileEntry[]
+}
+
+export function buildBoxFileUploadPath(destinationDir: string, file: File): string {
+ return buildBoxUploadPath(destinationDir, createBoxUploadFileItem(file))
+}
+
+export function buildBoxUploadPath(destinationDir: string, item: BoxUploadItem): string {
+ const dir = normalizeDestinationDir(destinationDir)
+ return dir === '/' ? `/${item.name}` : `${dir}/${item.name}`
+}
+
+export async function createSingleFileTar(file: File): Promise {
+ return createBoxUploadTar(createBoxUploadFileItem(file))
+}
+
+export function getBoxUploadDestinationBlockedReason(destinationDir: string): string | undefined {
+ const normalizedDir = normalizeDestinationDir(destinationDir)
+ const blockedDestination = BLOCKED_UPLOAD_DESTINATIONS.find(({ path }) => isPathOrChild(normalizedDir, path))
+ if (!blockedDestination) return undefined
+ return `Uploads to ${blockedDestination.path} are disabled because ${blockedDestination.reason}`
+}
+
+export function buildBoxUploadItems(files: File[]): BoxUploadItem[] {
+ const items: BoxUploadItem[] = []
+ const directories = new Map()
+
+ for (const file of files) {
+ const relativePath = getBrowserRelativePath(file)
+ if (!relativePath) {
+ items.push(createBoxUploadFileItem(file))
+ continue
+ }
+
+ const segments = splitPathSegments(relativePath)
+
+ if (segments.length > 1) {
+ const directoryName = sanitizePathSegment(segments[0])
+ const filePath = sanitizeRelativePath(segments.slice(1).join('/'))
+ const entries = directories.get(directoryName) ?? []
+ entries.push({ file, relativePath: filePath })
+ directories.set(directoryName, entries)
+ } else {
+ items.push(createBoxUploadFileItem(file))
+ }
+ }
+
+ for (const [directoryName, entries] of directories) {
+ if (entries.length > 0) {
+ items.push(createBoxUploadDirectoryItem(directoryName, entries))
+ }
+ }
+
+ assertUniqueTopLevelUploadNames(items)
+ return items
+}
+
+function assertUniqueTopLevelUploadNames(items: BoxUploadItem[]): void {
+ const seenNames = new Set()
+
+ for (const item of items) {
+ if (seenNames.has(item.name)) {
+ throw new Error(`Multiple uploads resolve to the same destination name: ${item.name}`)
+ }
+ seenNames.add(item.name)
+ }
+}
+
+export function createBoxUploadFileItem(file: File): BoxUploadItem {
+ const fileName = sanitizeFileName(file.name)
+ return {
+ kind: 'file',
+ name: fileName,
+ files: [{ file, relativePath: fileName }],
+ }
+}
+
+export function createBoxUploadDirectoryItem(name: string, files: BoxUploadFileEntry[]): BoxUploadItem {
+ return {
+ kind: 'directory',
+ name: sanitizeFileName(name),
+ files: files.map(({ file, relativePath }) => ({
+ file,
+ relativePath: sanitizeRelativePath(relativePath),
+ })),
+ }
+}
+
+export async function createBoxUploadTar(item: BoxUploadItem): Promise {
+ const chunks: Array = []
+
+ for (const entry of item.files) {
+ const data = await readFileBytes(entry.file)
+ chunks.push(createTarHeader(entry.relativePath, data.byteLength, Math.floor(entry.file.lastModified / 1000) || 0))
+ chunks.push(data)
+ chunks.push(new Uint8Array(paddingLength(data.byteLength)))
+ }
+
+ const endBlocks = new Uint8Array(TAR_BLOCK_SIZE * 2)
+
+ return new Blob([...chunks, endBlocks], { type: 'application/x-tar' })
+}
+
+async function readFileBytes(file: File): Promise {
+ if (typeof file.arrayBuffer === 'function') {
+ return new Uint8Array(await file.arrayBuffer())
+ }
+
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onerror = () => reject(reader.error ?? new Error('Failed to read upload file'))
+ reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer))
+ reader.readAsArrayBuffer(file)
+ })
+}
+
+function normalizeDestinationDir(destinationDir: string): string {
+ const trimmed = destinationDir.trim()
+ if (!trimmed) return DEFAULT_BOX_UPLOAD_DIR
+
+ const absolutePath = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
+ const normalizedSegments: string[] = []
+
+ for (const segment of splitPathSegments(absolutePath)) {
+ if (segment === '.') continue
+ if (segment === '..') {
+ normalizedSegments.pop()
+ continue
+ }
+ normalizedSegments.push(segment)
+ }
+
+ return normalizedSegments.length > 0 ? `/${normalizedSegments.join('/')}` : '/'
+}
+
+function isPathOrChild(path: string, parent: string): boolean {
+ return path === parent || path.startsWith(`${parent}/`)
+}
+
+function sanitizeFileName(fileName: string): string {
+ const baseName = fileName.split(/[\\/]/).filter(Boolean).pop() ?? ''
+ return sanitizePathSegment(baseName) || 'upload'
+}
+
+function sanitizeRelativePath(path: string): string {
+ const segments = splitPathSegments(path).map(sanitizePathSegment).filter(Boolean)
+ return segments.length > 0 ? segments.join('/') : 'upload'
+}
+
+function sanitizePathSegment(segment: string): string {
+ const trimmed = segment.trim()
+ return trimmed && trimmed !== '.' && trimmed !== '..' ? trimmed : ''
+}
+
+function splitPathSegments(path: string): string[] {
+ return path.split(/[\\/]+/).filter(Boolean)
+}
+
+function getBrowserRelativePath(file: File): string | undefined {
+ return (file as File & { webkitRelativePath?: string }).webkitRelativePath || undefined
+}
+
+function paddingLength(length: number): number {
+ const remainder = length % TAR_BLOCK_SIZE
+ return remainder === 0 ? 0 : TAR_BLOCK_SIZE - remainder
+}
+
+function createTarHeader(fileName: string, size: number, mtime: number): Uint8Array {
+ const header = new Uint8Array(TAR_BLOCK_SIZE)
+ const encoder = new TextEncoder()
+
+ writeText(header, 0, 100, encoder.encode(fileName))
+ writeOctal(header, 100, 8, 0o644)
+ writeOctal(header, 108, 8, 0)
+ writeOctal(header, 116, 8, 0)
+ writeOctal(header, 124, 12, size)
+ writeOctal(header, 136, 12, mtime)
+ header.fill(0x20, 148, 156)
+ header[156] = '0'.charCodeAt(0)
+ writeText(header, 257, 6, encoder.encode('ustar'))
+ writeText(header, 263, 2, encoder.encode('00'))
+
+ const checksum = header.reduce((sum, byte) => sum + byte, 0)
+ writeChecksum(header, checksum)
+
+ return header
+}
+
+function writeText(target: Uint8Array, offset: number, length: number, value: Uint8Array): void {
+ if (value.byteLength > length) {
+ throw new Error('File name is too long to upload')
+ }
+ target.set(value, offset)
+}
+
+function writeOctal(target: Uint8Array, offset: number, length: number, value: number): void {
+ const raw = value.toString(8)
+ if (raw.length > length - 1) {
+ throw new Error(`Tar header value ${value} is too large to upload`)
+ }
+
+ const encoded = raw.padStart(length - 1, '0')
+ for (let index = 0; index < encoded.length; index += 1) {
+ target[offset + index] = encoded.charCodeAt(index)
+ }
+}
+
+function writeChecksum(target: Uint8Array, checksum: number): void {
+ const encoded = checksum.toString(8).padStart(6, '0')
+ for (let index = 0; index < encoded.length; index += 1) {
+ target[148 + index] = encoded.charCodeAt(index)
+ }
+ target[154] = 0
+ target[155] = 0x20
+}
diff --git a/apps/dashboard/src/lib/cloudBox.test.ts b/apps/dashboard/src/lib/cloudBox.test.ts
index 004702a1e..bb4927d71 100644
--- a/apps/dashboard/src/lib/cloudBox.test.ts
+++ b/apps/dashboard/src/lib/cloudBox.test.ts
@@ -1,5 +1,7 @@
-import { describe, expect, it } from 'vitest'
-import { toBoxApiCreateRequest } from './cloudBox'
+import { describe, expect, it, vi } from 'vitest'
+import type { ApiClient } from '@/api/apiClient'
+import { createBoxUploadDirectoryItem } from './box-upload'
+import { toBoxApiCreateRequest, uploadBoxItemViaBoxApi } from './cloudBox'
describe('toBoxApiCreateRequest', () => {
it('converts dashboard GiB memory into Box API MiB', () => {
@@ -33,4 +35,22 @@ describe('toBoxApiCreateRequest', () => {
expect(toBoxApiCreateRequest({}).memory_mib).toBeUndefined()
expect(toBoxApiCreateRequest().memory_mib).toBeUndefined()
})
+
+ it('uploads directory files under the returned directory path', async () => {
+ const put = vi.fn().mockResolvedValue({})
+ const api = { axiosInstance: { put } } as unknown as ApiClient
+ const item = createBoxUploadDirectoryItem('project', [
+ { file: new File(['console.log(1)'], 'index.ts'), relativePath: 'src/index.ts' },
+ { file: new File(['# Project'], 'README.md'), relativePath: 'README.md' },
+ ])
+
+ const remotePath = await uploadBoxItemViaBoxApi(api, 'org-1', 'box-1', item, '/workspace')
+
+ expect(remotePath).toBe('/workspace/project')
+ expect(put).toHaveBeenCalledTimes(2)
+ expect(put.mock.calls.map((call) => call[2]?.params?.path)).toEqual([
+ '/workspace/project/src/index.ts',
+ '/workspace/project/README.md',
+ ])
+ })
})
diff --git a/apps/dashboard/src/lib/cloudBox.ts b/apps/dashboard/src/lib/cloudBox.ts
index c4f11d6cb..49082c8f4 100644
--- a/apps/dashboard/src/lib/cloudBox.ts
+++ b/apps/dashboard/src/lib/cloudBox.ts
@@ -4,6 +4,7 @@
*/
import { ApiClient } from '@/api/apiClient'
+import { buildBoxUploadPath, createBoxUploadTar, createBoxUploadFileItem, type BoxUploadItem } from '@/lib/box-upload'
// Dashboard-side client for the Box API contract (openapi/box.openapi.yaml),
// served by apps/api/src/boxlite-rest. Box verbs (create/start/stop/delete)
@@ -96,3 +97,66 @@ export async function stopBoxViaBoxApi(api: ApiClient, organizationId: string, b
export async function deleteBoxViaBoxApi(api: ApiClient, organizationId: string, boxId: string): Promise {
await api.axiosInstance.delete(`${boxesBasePath(organizationId)}/${boxId}`)
}
+
+export async function uploadFileToBoxViaBoxApi(
+ api: ApiClient,
+ organizationId: string,
+ boxId: string,
+ file: File,
+ destinationDir: string,
+): Promise {
+ return uploadBoxItemViaBoxApi(api, organizationId, boxId, createBoxUploadFileItem(file), destinationDir)
+}
+
+export async function uploadBoxItemViaBoxApi(
+ api: ApiClient,
+ organizationId: string,
+ boxId: string,
+ item: BoxUploadItem,
+ destinationDir: string,
+): Promise {
+ const remotePath = buildBoxUploadPath(destinationDir, item)
+
+ try {
+ if (item.kind === 'directory') {
+ for (const entry of item.files) {
+ await uploadBoxArchive(
+ api,
+ organizationId,
+ boxId,
+ createSingleEntryUploadItem(entry),
+ `${remotePath}/${entry.relativePath}`,
+ )
+ }
+ } else {
+ await uploadBoxArchive(api, organizationId, boxId, item, remotePath)
+ }
+ } catch (error) {
+ throw new Error(`Failed to upload Box item to ${remotePath} (org=${organizationId}, box=${boxId})`, {
+ cause: error,
+ })
+ }
+
+ return remotePath
+}
+
+function createSingleEntryUploadItem(entry: BoxUploadItem['files'][number]): BoxUploadItem {
+ return {
+ kind: 'file',
+ name: entry.relativePath.split('/').filter(Boolean).pop() ?? 'upload',
+ files: [entry],
+ }
+}
+
+async function uploadBoxArchive(
+ api: ApiClient,
+ organizationId: string,
+ boxId: string,
+ item: BoxUploadItem,
+ remotePath: string,
+): Promise {
+ await api.axiosInstance.put(`${boxesBasePath(organizationId)}/${boxId}/files`, await createBoxUploadTar(item), {
+ headers: { 'Content-Type': 'application/x-tar' },
+ params: { path: remotePath },
+ })
+}
diff --git a/apps/runner/pkg/api/controllers/proxy.go b/apps/runner/pkg/api/controllers/proxy.go
index 298cefaa1..49401b232 100644
--- a/apps/runner/pkg/api/controllers/proxy.go
+++ b/apps/runner/pkg/api/controllers/proxy.go
@@ -116,7 +116,67 @@ fitAddon.fit();
var proto=location.protocol==='https:'?'wss:':'ws:';
var ws=new WebSocket(proto+'//'+location.host+location.pathname+location.search);
ws.onopen=function(){term.focus();};
-ws.onmessage=function(e){term.write(e.data);};
+var parentOrigin='';
+try{parentOrigin=new URL(document.referrer).origin;}catch(_){}
+function postParent(message){
+ if(!parentOrigin)return;
+ parent.postMessage(message,parentOrigin);
+}
+var lastCwd='';
+function postCwd(path){
+ if(!path||path.charAt(0)!=='/')return;
+ lastCwd=path;
+ postParent({source:'boxlite-terminal',type:'cwd',value:path});
+}
+function scanCwd(data){
+ var text=String(data);
+ var re=/\x1b\]7;file:\/\/[^/]*(\/[^\x07\x1b]*)(?:\x07|\x1b\\)/g;
+ var match;
+ while((match=re.exec(text))!==null){
+ var path=match[1];
+ try{path=decodeURI(path);}catch(_){}
+ postCwd(path);
+ }
+}
+window.addEventListener('message',function(event){
+ if(event.source!==parent)return;
+ if(!parentOrigin||event.origin!==parentOrigin)return;
+ var msg=event.data||{};
+ if(msg.source!=='boxlite-dashboard')return;
+ if(msg.type==='cwd-request'){
+ if(lastCwd)postCwd(lastCwd);
+ return;
+ }
+});
+function hasDraggedFiles(event){
+ var types=event.dataTransfer&&event.dataTransfer.types;
+ return types&&Array.prototype.indexOf.call(types,'Files')!==-1;
+}
+function postFileDrag(active){
+ postParent({source:'boxlite-terminal',type:'file-drag',value:active?'active':'idle'});
+}
+window.addEventListener('dragenter',function(event){
+ if(!hasDraggedFiles(event))return;
+ event.preventDefault();
+ postFileDrag(true);
+});
+window.addEventListener('dragover',function(event){
+ if(!hasDraggedFiles(event))return;
+ event.preventDefault();
+ event.dataTransfer.dropEffect='copy';
+ postFileDrag(true);
+});
+window.addEventListener('dragleave',function(event){
+ if(event.clientX<=0||event.clientY<=0||event.clientX>=window.innerWidth||event.clientY>=window.innerHeight){
+ postFileDrag(false);
+ }
+});
+window.addEventListener('drop',function(event){
+ if(!hasDraggedFiles(event))return;
+ event.preventDefault();
+ postFileDrag(false);
+});
+ws.onmessage=function(e){scanCwd(e.data);term.write(e.data);};
ws.onclose=function(){term.write('\r\n[Connection closed]\r\n');};
ws.onerror=function(){term.write('\r\n[Connection error]\r\n');};
term.onData(function(data){if(ws.readyState===WebSocket.OPEN)ws.send(data);});
diff --git a/apps/runner/pkg/api/controllers/proxy_test.go b/apps/runner/pkg/api/controllers/proxy_test.go
index 87460d243..d81bb7056 100644
--- a/apps/runner/pkg/api/controllers/proxy_test.go
+++ b/apps/runner/pkg/api/controllers/proxy_test.go
@@ -3,7 +3,10 @@
package controllers
-import "testing"
+import (
+ "strings"
+ "testing"
+)
func TestIsTerminalToolboxPath(t *testing.T) {
tests := []struct {
@@ -29,3 +32,45 @@ func TestIsTerminalToolboxPath(t *testing.T) {
})
}
}
+
+func TestTerminalHTMLDoesNotAcceptDashboardShellCommands(t *testing.T) {
+ if !strings.Contains(terminalHTML, "msg.type==='cwd-request'") {
+ t.Fatal("terminalHTML should still allow the dashboard to request terminal metadata")
+ }
+
+ forbidden := []string{
+ "msg.type==='command'",
+ "msg.command==='ls'",
+ "ws.send('ls\\r')",
+ }
+ for _, needle := range forbidden {
+ if strings.Contains(terminalHTML, needle) {
+ t.Fatalf("terminalHTML must not inject shell input from dashboard messages; found %q", needle)
+ }
+ }
+}
+
+func TestTerminalHTMLRequiresTrustedParentOrigin(t *testing.T) {
+ required := []string{
+ "if(!parentOrigin)return;",
+ "parent.postMessage(message,parentOrigin);",
+ "if(!parentOrigin||event.origin!==parentOrigin)return;",
+ }
+ for _, needle := range required {
+ if !strings.Contains(terminalHTML, needle) {
+ t.Fatalf("terminalHTML should require a trusted parent origin; missing %q", needle)
+ }
+ }
+
+ forbidden := []string{
+ "parentOrigin||'*'",
+ "postMessage(message,'*')",
+ "postMessage(message, '*')",
+ "if(parentOrigin&&event.origin!==parentOrigin)return;",
+ }
+ for _, needle := range forbidden {
+ if strings.Contains(terminalHTML, needle) {
+ t.Fatalf("terminalHTML must not fall back to wildcard parent origin; found %q", needle)
+ }
+ }
+}
diff --git a/apps/runner/pkg/shellutil/launcher.go b/apps/runner/pkg/shellutil/launcher.go
index 8f7360ad8..4f2baaf78 100644
--- a/apps/runner/pkg/shellutil/launcher.go
+++ b/apps/runner/pkg/shellutil/launcher.go
@@ -52,6 +52,8 @@ package shellutil
func DefaultInteractiveShell() (command string, args []string) {
return "/bin/sh", []string{"-c",
`cd "${HOME:-/root}" 2>/dev/null || cd /; ` +
+ `__boxlite_osc7='printf "\033]7;file://boxlite%s\007" "$PWD"'; ` +
+ `export PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }$__boxlite_osc7"; ` +
`exec $(command -v bash || command -v ash || command -v sh) -l`,
}
}