This commit is contained in:
Timothy Jaeryang Baek
2026-08-09 13:22:46 -06:00
parent 5caa91a493
commit 2dadc5435a
7 changed files with 101 additions and 38 deletions
+9 -5
View File
@@ -23,6 +23,7 @@ from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.terminals import (
TERMINAL_CONTEXT_HEADER,
get_terminal_server_url,
is_terminal_orchestrator,
terminal_context_available,
terminal_context_config,
terminal_context_id,
@@ -233,7 +234,7 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
The client must send ``{"type": "auth", "token": "<jwt>"}`` as its first
message after connecting.
Returns ``(user, connection, chat_id)`` on success, or ``None`` after
Returns ``(user, connection, chat_id, token)`` on success, or ``None`` after
closing *ws* with an appropriate error code.
"""
import asyncio
@@ -247,7 +248,8 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
if payload.get('type') != 'auth':
await ws.close(code=4001, reason='Expected auth message')
return None
user = await get_verified_user_by_token(payload.get('token', ''), getattr(ws.app.state, 'redis', None))
token = payload.get('token', '')
user = await get_verified_user_by_token(token, getattr(ws.app.state, 'redis', None))
if user is None:
await ws.close(code=4001, reason='Invalid token')
return None
@@ -279,7 +281,7 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
if not terminal_context_available(connection, 'chat'):
await ws.close(code=4003, reason='Terminal server is not available in chats')
return None
return user, connection, chat_id if isinstance(chat_id, str) else ''
return user, connection, chat_id if isinstance(chat_id, str) else '', token
@router.websocket('/{server_id}/api/terminals/{session_id}')
@@ -292,14 +294,14 @@ async def ws_terminal(
Uses first-message auth: the client sends ``{"type": "auth", "token": "<jwt>"}``
as its first message. The proxy validates the JWT, then connects to the
upstream terminal server and authenticates with the server's API key.
upstream terminal server using the configured terminal auth mode.
"""
await ws.accept()
result = await _resolve_authenticated_connection(ws, server_id)
if result is None:
return
user, connection, chat_id = result
user, connection, chat_id, token = result
base_url = get_terminal_server_url(connection)
if not base_url:
@@ -347,6 +349,8 @@ async def ws_terminal(
if auth_type == 'bearer':
key = normalize_bearer_token(connection.get('key', ''))
await upstream.send_str(_json.dumps({'type': 'auth', 'token': key}))
elif auth_type == 'session' and is_terminal_orchestrator(connection):
await upstream.send_str(_json.dumps({'type': 'auth', 'token': token}))
await publish_event(
app,
+8 -2
View File
@@ -3,6 +3,12 @@ export type FileEntry = {
type: 'file' | 'directory';
size?: number;
modified?: number;
writable?: boolean;
};
export type TerminalFileList = {
entries: FileEntry[];
writable?: boolean;
};
export type ListeningPort = {
@@ -85,7 +91,7 @@ export const listFiles = async (
apiKey: string,
path: string = '/',
sessionId?: string
): Promise<FileEntry[] | null> => {
): Promise<TerminalFileList | null> => {
// The endpoint uses `directory` as the query param name
const url = `${baseUrl.replace(/\/$/, '')}/files/list?directory=${encodeURIComponent(path)}`;
const headers: Record<string, string> = bearerHeaders(apiKey);
@@ -99,7 +105,7 @@ export const listFiles = async (
console.error('open-terminal listFiles error:', err);
return null;
});
return res?.entries ?? null;
return res?.entries ? { entries: res.entries, writable: res.writable } : null;
};
export const readFile = async (
+39 -18
View File
@@ -93,6 +93,7 @@
let currentPath = savedPath;
let fileRoot: TerminalFileRoot | null = null;
let entries: FileEntry[] = [];
let currentWritable = true;
let loading = false;
let error: string | null = null;
@@ -175,6 +176,7 @@
// ── File preview state ───────────────────────────────────────────────
let selectedFile: string | null = null;
let selectedFileWritable = true;
let previewPort: number | null = null;
let fileContent: string | null = null;
let fileImageUrl: string | null = null;
@@ -410,6 +412,7 @@
loading = true;
error = null;
selectedFile = null;
selectedFileWritable = true;
previewPort = null;
clearFilePreview();
clearSelection();
@@ -428,7 +431,8 @@
'Failed to load directory. Check your Terminal connection in Settings → Integrations.';
entries = [];
} else {
entries = sortEntries(result);
currentWritable = result.writable !== false;
entries = sortEntries(result.entries);
}
};
@@ -439,6 +443,7 @@
}
const filePath = `${currentPath}${entry.name}`;
selectedFileWritable = entry.writable !== false;
pushNavHistory(currentPath, filePath);
const terminal = selectedTerminal;
@@ -566,6 +571,7 @@
// ── Drag-and-drop upload ─────────────────────────────────────────────
const handleDragOver = (e: DragEvent) => {
if (selectedFile) return;
if (!currentWritable) return;
if (!e.dataTransfer?.types.includes('Files')) return;
e.preventDefault();
e.stopPropagation();
@@ -578,7 +584,7 @@
isDragOver = false;
const terminal = selectedTerminal;
if (selectedFile || !terminal) return;
if (selectedFile || !terminal || !currentWritable) return;
const droppedFiles = Array.from(e.dataTransfer?.files ?? []);
if (!droppedFiles.length) return;
@@ -593,7 +599,7 @@
const handleUploadFiles = async (files: File[]) => {
const terminal = selectedTerminal;
if (!files.length || !terminal) return;
if (!files.length || !terminal || !currentWritable) return;
uploading = true;
for (const file of files) {
@@ -605,6 +611,7 @@
// ── Folder creation ──────────────────────────────────────────────────
const startNewFolder = async () => {
if (!currentWritable) return;
creatingFolder = true;
newFolderName = '';
await tick();
@@ -634,6 +641,7 @@
// ── File creation ────────────────────────────────────────────────────
const startNewFile = async () => {
if (!currentWritable) return;
creatingFile = true;
newFileName = '';
await tick();
@@ -658,7 +666,7 @@
// ── Delete ───────────────────────────────────────────────────────────
const handleDelete = async (path: string, name: string) => {
const terminal = selectedTerminal;
if (!terminal) return;
if (!terminal || !currentWritable) return;
const result = await deleteEntry(terminal.url, terminal.key, path, chatId ?? undefined);
toast[result ? 'success' : 'error'](
@@ -668,6 +676,7 @@
};
const requestDelete = (path: string, name: string) => {
if (!currentWritable) return;
deleteTarget = { path, name };
showDeleteConfirm = true;
};
@@ -675,7 +684,7 @@
// ── Move (drag-and-drop) ────────────────────────────────────────────
const handleMove = async (source: string, destFolder: string) => {
const terminal = selectedTerminal;
if (!terminal) return;
if (!terminal || !currentWritable) return;
const fileName = source.split('/').pop() ?? '';
const destination = `${destFolder}${fileName}`;
@@ -704,7 +713,7 @@
// ── Rename ──────────────────────────────────────────────────────────
const handleRename = async (oldPath: string, newName: string) => {
const terminal = selectedTerminal;
if (!terminal || !newName) return;
if (!terminal || !newName || !currentWritable) return;
const dir = oldPath.substring(0, oldPath.lastIndexOf('/') + 1) || currentPath;
const destination = `${dir}${newName}`;
@@ -733,6 +742,13 @@
$: selectedCount = selectedEntries.size;
$: hasSelectedFiles = [...selectedEntries].some((p) => !p.endsWith('/'));
$: selectedEntriesWritable =
currentWritable &&
[...selectedEntries].every((path) => {
const name = path.replace(/\/$/, '').split('/').pop();
const entry = entries.find((item) => item.name === name);
return entry?.writable !== false;
});
const clearSelection = () => {
selectedEntries = new Set();
@@ -792,7 +808,7 @@
const bulkDelete = async () => {
const terminal = selectedTerminal;
if (!terminal) return;
if (!terminal || !selectedEntriesWritable) return;
const paths = [...selectedEntries];
let ok = 0;
@@ -1046,6 +1062,7 @@
breadcrumbs={buildBreadcrumbs(currentPath)}
{selectedFile}
{loading}
writable={currentWritable}
{canGoBack}
{canGoForward}
{sortBy}
@@ -1143,9 +1160,9 @@
{#if isHtml && showRaw}
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.saveCodeFile()}
disabled={saving}
disabled={saving || !selectedFileWritable}
aria-label={$i18n.t('Save')}
>
{#if saving}
@@ -1171,9 +1188,9 @@
{:else if isMarkdown && showRaw}
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.saveCodeFile()}
disabled={saving}
disabled={saving || !selectedFileWritable}
aria-label={$i18n.t('Save')}
>
{#if saving}
@@ -1199,9 +1216,9 @@
{:else if isCode}
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.saveCodeFile()}
disabled={saving}
disabled={saving || !selectedFileWritable}
aria-label={$i18n.t('Save')}
>
{#if saving}
@@ -1243,9 +1260,9 @@
</Tooltip>
<Tooltip content={$i18n.t('Save')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.saveEdit()}
disabled={saving}
disabled={saving || !selectedFileWritable}
aria-label={$i18n.t('Save')}
>
{#if saving}
@@ -1269,8 +1286,9 @@
{:else}
<Tooltip content={$i18n.t('Edit')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.startEdit()}
disabled={!selectedFileWritable}
aria-label={$i18n.t('Edit')}
>
<PenAlt className="size-3.5" />
@@ -1309,7 +1327,7 @@
<Tooltip content={$i18n.t('Download')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={() => downloadFile(selectedFile)}
on:click={() => selectedFile && downloadFile(selectedFile)}
aria-label={$i18n.t('Download')}
>
<svg
@@ -1335,6 +1353,7 @@
<BulkActionBar
count={selectedCount}
hasFiles={hasSelectedFiles}
canDelete={selectedEntriesWritable}
onDelete={() => {
deleteTarget = { path: '__bulk__', name: `${selectedCount} items` };
showDeleteConfirm = true;
@@ -1370,6 +1389,7 @@
bind:saving
bind:currentSlide
{selectedFile}
readOnly={!selectedFileWritable}
{fileLoading}
{fileImageUrl}
{fileVideoUrl}
@@ -1394,7 +1414,7 @@
overlay={overlay || isDraggingHandle}
onSave={async (content) => {
const terminal = selectedTerminal;
if (!terminal || !selectedFile) return;
if (!terminal || !selectedFile || !selectedFileWritable) return;
const fileName = selectedFile.split('/').pop() ?? 'file';
const dir = selectedFile.substring(0, selectedFile.lastIndexOf('/') + 1) || '/';
const file = new File([content], fileName, { type: 'text/plain' });
@@ -1490,6 +1510,7 @@
onSelect={handleSelect}
onLongPress={enterSelectionMode}
showDate={sortBy === 'date'}
parentWritable={currentWritable}
/>
{/each}
</ul>
@@ -7,6 +7,7 @@
export let count: number = 0;
export let hasFiles: boolean = false;
export let canDelete = true;
export let onDelete: () => void = () => {};
export let onDownload: () => void = () => {};
@@ -64,8 +65,9 @@
<Tooltip content={$i18n.t('Delete')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={onDelete}
disabled={!canDelete}
aria-label={$i18n.t('Delete')}
>
<GarbageBin className="size-3.5" />
@@ -32,6 +32,10 @@
export let onSelect: (entry: FileEntry, event: MouseEvent) => void = () => {};
export let onLongPress: () => void = () => {};
export let showDate: boolean = false;
export let parentWritable = true;
$: writable = entry.writable !== false;
$: canMutate = parentWritable && writable;
const formatRelativeTime = (epoch: number): string => {
const diff = Math.floor(Date.now() / 1000) - epoch;
@@ -143,6 +147,7 @@
role={entry.type === 'directory' ? 'button' : undefined}
on:dragover={(e) => {
if (entry.type !== 'directory') return;
if (!writable) return;
if (!e.dataTransfer?.types.includes('application/x-terminal-file-move')) return;
e.preventDefault();
e.stopPropagation();
@@ -155,6 +160,7 @@
}}
on:drop={(e) => {
if (entry.type !== 'directory') return;
if (!writable) return;
const raw = e.dataTransfer?.getData('application/x-terminal-file-move');
if (!raw) return;
e.preventDefault();
@@ -173,8 +179,12 @@
>
<button
class="flex-1 flex items-center gap-2 px-3 py-1.5 text-left min-w-0"
draggable={true}
draggable={canMutate}
on:dragstart={(e) => {
if (!canMutate) {
e.preventDefault();
return;
}
const filePath = `${currentPath}${entry.name}`;
// If dragging a selected item, drag all selected
if (selected && selectedPaths.size > 1) {
@@ -282,6 +292,9 @@
{entry.name}
</span>
{/if}
{#if !writable && !renaming}
<span class="text-[10px] text-gray-400 shrink-0">Read-only</span>
{/if}
{#if entry.type === 'file' && entry.size !== undefined && !renaming}
{#if showDate && entry.modified}
<span class="text-[10px] text-gray-400 shrink-0"
@@ -354,9 +367,11 @@
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[13px] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[13px] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition disabled:opacity-40 disabled:hover:bg-transparent"
disabled={!canMutate}
on:click={(e) => {
e.stopPropagation();
if (!canMutate) return;
startRename();
}}
>
@@ -366,9 +381,11 @@
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[13px] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[13px] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition disabled:opacity-40 disabled:hover:bg-transparent"
disabled={!canMutate}
on:click={(e) => {
e.stopPropagation();
if (!canMutate) return;
onDelete(`${currentPath}${entry.name}`, entry.name);
}}
>
@@ -14,6 +14,7 @@
export let breadcrumbs: { label: string; path: string }[] = [];
export let selectedFile: string | null = null;
export let loading = false;
export let writable = true;
export let onNavigate: (path: string) => void = () => {};
export let onRefresh: () => void = () => {};
@@ -114,6 +115,7 @@
: ''}"
on:click={() => onNavigate(crumb.path)}
on:dragover={(e) => {
if (!writable) return;
if (!e.dataTransfer?.types.includes('application/x-terminal-file-move')) return;
e.preventDefault();
e.stopPropagation();
@@ -123,6 +125,7 @@
if (dragOverCrumb === i) dragOverCrumb = null;
}}
on:drop={(e) => {
if (!writable) return;
const raw = e.dataTransfer?.getData('application/x-terminal-file-move');
if (!raw) return;
e.preventDefault();
@@ -145,6 +148,11 @@
</span>
{/if}
</div>
{#if !writable}
<span class="text-[10px] text-gray-400 dark:text-gray-500 shrink-0">
Read-only
</span>
{/if}
<Tooltip content={$i18n.t('Refresh')}>
<button
@@ -240,8 +248,9 @@
</Dropdown>
<Tooltip content={$i18n.t('New Folder')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={onNewFolder}
disabled={!writable}
aria-label={$i18n.t('New Folder')}
>
<NewFolderAlt className="size-3.5" />
@@ -249,8 +258,9 @@
</Tooltip>
<Tooltip content={$i18n.t('New File')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={onNewFile}
disabled={!writable}
aria-label={$i18n.t('New File')}
>
<FilePlusAlt className="size-3.5" />
@@ -279,8 +289,9 @@
</Tooltip>
<Tooltip content={$i18n.t('Upload')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => uploadInput?.click()}
disabled={!writable}
aria-label={$i18n.t('Upload')}
>
<svg
@@ -305,7 +316,7 @@
multiple
hidden
on:change={async () => {
if (!uploadInput?.files?.length) return;
if (!writable || !uploadInput?.files?.length) return;
onUploadFiles(Array.from(uploadInput.files));
uploadInput.value = '';
}}
@@ -41,6 +41,7 @@
export let onSheetChange: ((sheet: string) => void) | null = null;
export let overlay = false;
export let readOnly = false;
export let onSave: ((content: string) => Promise<void>) | null = null;
@@ -59,6 +60,7 @@
};
export const startEdit = async () => {
if (readOnly) return;
editContent = fileContent ?? '';
editing = true;
showRaw = true;
@@ -67,7 +69,7 @@
};
export const saveEdit = async () => {
if (!onSave) return;
if (!onSave || readOnly) return;
saving = true;
await onSave(editContent);
saving = false;
@@ -81,7 +83,7 @@
/** Save code file directly from CodeMirror */
export const saveCodeFile = async () => {
if (!onSave) return;
if (!onSave || readOnly) return;
saving = true;
const content = fileCodeEditorRef?.getValue() ?? '';
await onSave(content);
@@ -426,7 +428,7 @@
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
filePath={selectedFile}
{onSave}
onSave={readOnly ? null : onSave}
/>
</div>
{:else if isMarkdown && !showRaw}
@@ -439,7 +441,7 @@
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
filePath={selectedFile}
{onSave}
onSave={readOnly ? null : onSave}
/>
</div>
{:else if isCsv && !showRaw && csvRows.length > 0}
@@ -496,7 +498,7 @@
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
filePath={selectedFile}
{onSave}
onSave={readOnly ? null : onSave}
/>
</div>
{:else if isSvg && highlightedHtml && !showRaw}