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`, } }