mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-22 00:31:30 +00:00
A trailing space in the Open Terminal bearer token broke only the interactive terminal: HTTP calls put the token in the Authorization header, where the spec strips trailing whitespace, so the connection test, file listing and tool calls all worked. The terminal WebSocket can't set headers from the browser, so it sends the token inside a JSON auth message that preserves the space verbatim, failing auth with [Connection closed]. Normalize the key on save so whitespace never enters storage, and trim it in the WebSocket auth message so existing saved configs work without re-saving. Fixes #25613 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
286 lines
8.2 KiB
Svelte
286 lines
8.2 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy, getContext } from 'svelte';
|
|
import { Terminal } from '@xterm/xterm';
|
|
import { FitAddon } from '@xterm/addon-fit';
|
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
|
import '@xterm/xterm/css/xterm.css';
|
|
|
|
import { terminalServers, settings, selectedTerminalId, user } from '$lib/stores';
|
|
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
|
|
|
const i18n = getContext('i18n');
|
|
|
|
export let overlay = false;
|
|
export let chatId: string | null = null;
|
|
|
|
let terminalEl: HTMLDivElement;
|
|
let term: Terminal | null = null;
|
|
let fitAddon: FitAddon | null = null;
|
|
let ws: WebSocket | null = null;
|
|
export let connected = false;
|
|
export let connecting = false;
|
|
let resizeObserver: ResizeObserver | null = null;
|
|
let pingInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
// Resolve the active terminal server's info for the WebSocket URL
|
|
const getTerminalInfo = (): { serverId: string; baseUrl: string } | null => {
|
|
// System terminal (admin-configured, has an `id`)
|
|
const systemTerminals = ($terminalServers ?? []).filter((t: any) => t.id);
|
|
const systemMatch = systemTerminals.find((t: any) => t.id === $selectedTerminalId);
|
|
if (systemMatch) {
|
|
// For system terminals, WS goes through the Open WebUI backend proxy
|
|
return { serverId: systemMatch.id, baseUrl: WEBUI_API_BASE_URL };
|
|
}
|
|
|
|
// Direct terminal (user-configured, matched by URL)
|
|
const directTerminals = ($settings?.terminalServers ?? []).filter((s: any) => s.url);
|
|
const directMatch = directTerminals.find((s: any) => s.url === $selectedTerminalId);
|
|
if (directMatch) {
|
|
// For direct terminals, construct WS URL from the server URL directly
|
|
return { serverId: '__direct__', baseUrl: directMatch.url };
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const connect = async () => {
|
|
if (ws) disconnect();
|
|
|
|
const info = getTerminalInfo();
|
|
if (!info) return;
|
|
|
|
connecting = true;
|
|
|
|
const token = localStorage.getItem('token') ?? '';
|
|
|
|
try {
|
|
let sessionId: string;
|
|
let wsUrl: string;
|
|
let authToken: string;
|
|
|
|
if (info.serverId === '__direct__') {
|
|
// Direct connection to open-terminal
|
|
const base = info.baseUrl.replace(/\/$/, '');
|
|
const directTerminals = ($settings?.terminalServers ?? []).filter((s: any) => s.url);
|
|
const directMatch = directTerminals.find((s: any) => s.url === $selectedTerminalId);
|
|
const apiKey = directMatch?.key ?? '';
|
|
authToken = apiKey;
|
|
|
|
// Create session
|
|
const createHeaders: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
|
|
if (chatId) createHeaders['X-Session-Id'] = chatId;
|
|
const res = await fetch(`${base}/api/terminals`, {
|
|
method: 'POST',
|
|
headers: createHeaders
|
|
});
|
|
if (!res.ok) throw new Error(`Failed to create session: ${res.status}`);
|
|
const session = await res.json();
|
|
sessionId = session.id;
|
|
|
|
const wsBase = base.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
|
|
wsUrl = `${wsBase}/api/terminals/${sessionId}`;
|
|
} else {
|
|
// System terminal — proxy through Open WebUI backend
|
|
const base = info.baseUrl.replace(/\/$/, '');
|
|
authToken = token;
|
|
|
|
// Create session via proxy
|
|
const proxyHeaders: Record<string, string> = { Authorization: `Bearer ${token}` };
|
|
if (chatId) proxyHeaders['X-Session-Id'] = chatId;
|
|
const res = await fetch(`${base}/terminals/${info.serverId}/api/terminals`, {
|
|
method: 'POST',
|
|
headers: proxyHeaders
|
|
});
|
|
if (!res.ok) throw new Error(`Failed to create session: ${res.status}`);
|
|
const session = await res.json();
|
|
sessionId = session.id;
|
|
|
|
const wsBase = base.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
|
|
wsUrl = `${wsBase}/terminals/${info.serverId}/api/terminals/${sessionId}`;
|
|
}
|
|
|
|
ws = new WebSocket(wsUrl);
|
|
ws.binaryType = 'arraybuffer';
|
|
|
|
ws.onopen = () => {
|
|
// First-message auth (no token in URL)
|
|
if (ws) {
|
|
ws.send(JSON.stringify({ type: 'auth', token: authToken.trim() }));
|
|
}
|
|
connected = true;
|
|
connecting = false;
|
|
// Focus the terminal so it receives keyboard input immediately
|
|
term?.focus();
|
|
// Send initial resize
|
|
if (term && ws) {
|
|
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
}
|
|
// Keepalive ping to prevent idle timeout from proxies/LBs
|
|
if (pingInterval) clearInterval(pingInterval);
|
|
pingInterval = setInterval(() => {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'ping' }));
|
|
}
|
|
}, 25000);
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
if (term) {
|
|
if (event.data instanceof ArrayBuffer) {
|
|
term.write(new Uint8Array(event.data));
|
|
} else {
|
|
term.write(event.data);
|
|
}
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
connected = false;
|
|
connecting = false;
|
|
if (term) {
|
|
term.write('\r\n\x1b[90m[Connection closed]\x1b[0m\r\n');
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
connected = false;
|
|
connecting = false;
|
|
};
|
|
} catch (err) {
|
|
connecting = false;
|
|
if (term) {
|
|
term.write(`\r\n\x1b[31m[Error: ${err}]\x1b[0m\r\n`);
|
|
}
|
|
}
|
|
};
|
|
|
|
const disconnect = () => {
|
|
if (pingInterval) {
|
|
clearInterval(pingInterval);
|
|
pingInterval = null;
|
|
}
|
|
if (ws) {
|
|
ws.close();
|
|
ws = null;
|
|
}
|
|
connected = false;
|
|
connecting = false;
|
|
};
|
|
|
|
const initTerminal = () => {
|
|
if (!terminalEl || term) return;
|
|
|
|
term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: 13,
|
|
fontFamily:
|
|
"'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace",
|
|
theme: {
|
|
background: '#000000',
|
|
foreground: '#c0c0c0',
|
|
cursor: '#ffffff',
|
|
cursorAccent: '#000000',
|
|
selectionBackground: '#444444',
|
|
selectionForeground: '#ffffff',
|
|
black: '#000000',
|
|
red: '#cd0000',
|
|
green: '#00cd00',
|
|
yellow: '#cdcd00',
|
|
blue: '#0000ee',
|
|
magenta: '#cd00cd',
|
|
cyan: '#00cdcd',
|
|
white: '#e5e5e5',
|
|
brightBlack: '#7f7f7f',
|
|
brightRed: '#ff0000',
|
|
brightGreen: '#00ff00',
|
|
brightYellow: '#ffff00',
|
|
brightBlue: '#5c5cff',
|
|
brightMagenta: '#ff00ff',
|
|
brightCyan: '#00ffff',
|
|
brightWhite: '#ffffff'
|
|
},
|
|
allowProposedApi: true,
|
|
scrollback: 5000
|
|
});
|
|
|
|
fitAddon = new FitAddon();
|
|
term.loadAddon(fitAddon);
|
|
term.loadAddon(new WebLinksAddon());
|
|
|
|
term.open(terminalEl);
|
|
|
|
// Fit after a frame so the container has dimensions
|
|
requestAnimationFrame(() => {
|
|
fitAddon?.fit();
|
|
});
|
|
|
|
// Forward keystrokes to WebSocket
|
|
term.onData((data) => {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(new TextEncoder().encode(data));
|
|
}
|
|
});
|
|
|
|
// Forward binary data (e.g. paste with special chars)
|
|
term.onBinary((data) => {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
const buffer = new Uint8Array(data.length);
|
|
for (let i = 0; i < data.length; i++) {
|
|
buffer[i] = data.charCodeAt(i) & 0xff;
|
|
}
|
|
ws.send(buffer);
|
|
}
|
|
});
|
|
|
|
// Ensure all key events are processed by xterm.js and not intercepted
|
|
// by the browser or surrounding UI (fixes vi/vim keystroke handling).
|
|
term.attachCustomKeyEventHandler(() => true);
|
|
|
|
// Handle resize
|
|
term.onResize(({ cols, rows }) => {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
|
}
|
|
});
|
|
|
|
// Watch container size changes
|
|
resizeObserver = new ResizeObserver(() => {
|
|
requestAnimationFrame(() => {
|
|
fitAddon?.fit();
|
|
});
|
|
});
|
|
resizeObserver.observe(terminalEl);
|
|
|
|
// Connection is handled by the reactive block below (which fires
|
|
// when `term` is set here), so we intentionally do NOT call
|
|
// connect() to avoid creating a duplicate WebSocket whose onclose
|
|
// handler would write a spurious "[Connection closed]" message.
|
|
};
|
|
|
|
// Reconnect when the selected terminal changes
|
|
$: if ($selectedTerminalId !== undefined && term) {
|
|
// Clear the terminal screen and reconnect to the new server
|
|
disconnect();
|
|
term.clear();
|
|
if ($selectedTerminalId) {
|
|
connect();
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
initTerminal();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
disconnect();
|
|
resizeObserver?.disconnect();
|
|
term?.dispose();
|
|
term = null;
|
|
fitAddon = null;
|
|
});
|
|
</script>
|
|
|
|
<div class="h-full min-h-0 relative">
|
|
<div bind:this={terminalEl} class="absolute inset-0 px-0.5" class:pointer-events-none={overlay} />
|
|
</div>
|