1
0
forked from wrenn/wrenn

Harden terminal: binary-safe base64, auto-reconnect, session limits

- Replace btoa/atob with TextEncoder/TextDecoder for binary-safe base64
  encoding — fixes crash on multi-byte UTF-8 input (emoji, CJK, accents)
- Auto-reconnect on abnormal WebSocket close while session is live
- Cap concurrent sessions at 8 with disabled "+" button at limit
- Guard all ws.send() calls with try/catch via wsSend() wrapper
- Clean up input flush timer on session close and component destroy
- Close all sessions when capsule stops running (isRunning → false)
- Clean up orphaned display entry if DOM container fails to render
This commit is contained in:
2026-04-11 05:35:53 +06:00
parent 1826af37a5
commit d2202c4f49

View File

@ -28,8 +28,12 @@
ws: WebSocket | null; ws: WebSocket | null;
resizeObserver: ResizeObserver | null; resizeObserver: ResizeObserver | null;
fitDebounce: ReturnType<typeof setTimeout> | null; fitDebounce: ReturnType<typeof setTimeout> | null;
inputFlushTimer: ReturnType<typeof setTimeout> | null;
inputBuffer: string;
}; };
const MAX_SESSIONS = 8;
let sessions = $state<SessionDisplay[]>([]); let sessions = $state<SessionDisplay[]>([]);
const internals = new Map<number, SessionInternal>(); const internals = new Map<number, SessionInternal>();
let activeSessionId = $state<number | null>(null); let activeSessionId = $state<number | null>(null);
@ -73,12 +77,33 @@
brightWhite: '#eae7e2', brightWhite: '#eae7e2',
}; };
// Binary-safe base64 encode (handles multi-byte UTF-8 from xterm onData)
function toBase64(str: string): string {
return btoa(
Array.from(new TextEncoder().encode(str), (b) => String.fromCharCode(b)).join('')
);
}
// Binary-safe base64 decode (handles raw PTY bytes)
function fromBase64(b64: string): string {
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
function getWsUrl(): string { function getWsUrl(): string {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = auth.token ? `?token=${encodeURIComponent(auth.token)}` : ''; const token = auth.token ? `?token=${encodeURIComponent(auth.token)}` : '';
return `${proto}//${window.location.host}/api/v1/sandboxes/${sandboxId}/pty${token}`; return `${proto}//${window.location.host}/api/v1/sandboxes/${sandboxId}/pty${token}`;
} }
function wsSend(ws: WebSocket | null, data: string) {
try {
if (ws?.readyState === WebSocket.OPEN) ws.send(data);
} catch {
// Connection closing — ignore
}
}
function updateSession(id: number, updates: Partial<SessionDisplay>) { function updateSession(id: number, updates: Partial<SessionDisplay>) {
const idx = sessions.findIndex(s => s.id === id); const idx = sessions.findIndex(s => s.id === id);
if (idx === -1) return; if (idx === -1) return;
@ -122,8 +147,18 @@
} }
}); });
// Close all sessions when capsule stops running
$effect(() => {
if (!isRunning && sessions.length > 0) {
// Copy IDs to avoid mutating during iteration
const ids = sessions.map(s => s.id);
for (const id of ids) closeSession(id);
}
});
async function createSession() { async function createSession() {
if (!isRunning || !containerRef) return; if (!isRunning || !containerRef) return;
if (sessions.length >= MAX_SESSIONS) return;
await loadModules(); await loadModules();
const id = nextId++; const id = nextId++;
@ -140,7 +175,12 @@
await tick(); await tick();
const el = containerRef?.querySelector(`[data-session-id="${id}"]`) as HTMLDivElement | null; const el = containerRef?.querySelector(`[data-session-id="${id}"]`) as HTMLDivElement | null;
if (!el) return; if (!el) {
// DOM didn't render — clean up the orphaned display entry
sessions = sessions.filter(s => s.id !== id);
if (activeSessionId === id) activeSessionId = null;
return;
}
const fitAddon = new FitAddonClass(); const fitAddon = new FitAddonClass();
const term = new TerminalClass({ const term = new TerminalClass({
@ -167,6 +207,8 @@
ws: null, ws: null,
resizeObserver: null, resizeObserver: null,
fitDebounce: null, fitDebounce: null,
inputFlushTimer: null,
inputBuffer: '',
}; };
internals.set(id, internal); internals.set(id, internal);
@ -183,34 +225,27 @@
internal.resizeObserver.observe(el); internal.resizeObserver.observe(el);
// Register input/resize handlers ONCE per terminal (not per connection). // Register input/resize handlers ONCE per terminal (not per connection).
// Buffer keystrokes and flush every 100ms to reduce request volume.
// 100ms is imperceptible but coalesces paste
// operations and fast typing into single messages.
let inputBuffer = '';
let inputFlushTimer: ReturnType<typeof setTimeout> | null = null;
function flushInput() { function flushInput() {
inputFlushTimer = null; const int = internals.get(id);
if (!inputBuffer) return; if (!int) return;
const i = internals.get(id); int.inputFlushTimer = null;
if (i?.ws?.readyState === WebSocket.OPEN) { if (!int.inputBuffer) return;
i.ws.send(JSON.stringify({ type: 'input', data: btoa(inputBuffer) })); wsSend(int.ws, JSON.stringify({ type: 'input', data: toBase64(int.inputBuffer) }));
} int.inputBuffer = '';
inputBuffer = '';
} }
term.onData((data: string) => { term.onData((data: string) => {
inputBuffer += data; const int = internals.get(id);
if (!inputFlushTimer) { if (!int) return;
inputFlushTimer = setTimeout(flushInput, 100); int.inputBuffer += data;
if (!int.inputFlushTimer) {
int.inputFlushTimer = setTimeout(flushInput, 50);
} }
}); });
term.onResize(({ cols, rows }: { cols: number; rows: number }) => { term.onResize(({ cols, rows }: { cols: number; rows: number }) => {
const i = internals.get(id); const i = internals.get(id);
if (i?.ws?.readyState === WebSocket.OPEN) { wsSend(i?.ws ?? null, JSON.stringify({ type: 'resize', cols, rows }));
i.ws.send(JSON.stringify({ type: 'resize', cols, rows }));
}
}); });
connectSession(id); connectSession(id);
@ -240,7 +275,7 @@
msg.cmd = '/bin/bash'; msg.cmd = '/bin/bash';
msg.envs = { TERM: 'xterm-256color' }; msg.envs = { TERM: 'xterm-256color' };
} }
ws.send(JSON.stringify(msg)); wsSend(ws, JSON.stringify(msg));
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
@ -256,7 +291,7 @@
if (activeSessionId === id) int.term.focus(); if (activeSessionId === id) int.term.focus();
break; break;
case 'output': case 'output':
if (msg.data) int.term.write(atob(msg.data)); if (msg.data) int.term.write(fromBase64(msg.data));
break; break;
case 'exit': case 'exit':
closeSession(id); closeSession(id);
@ -268,7 +303,7 @@
} }
break; break;
case 'ping': case 'ping':
ws.send(JSON.stringify({ type: 'pong' })); wsSend(ws, JSON.stringify({ type: 'pong' }));
break; break;
} }
} catch { } catch {
@ -276,9 +311,19 @@
} }
}; };
ws.onclose = () => { ws.onclose = (event) => {
const s = sessions.find(s => s.id === id); const s = sessions.find(s => s.id === id);
if (s?.state === 'connected') { if (!s) return;
// Abnormal close with a live session — auto-reconnect
if (!event.wasClean && s.state === 'connected' && s.ptyTag) {
updateSession(id, { state: 'connecting', errorMessage: null });
int.term.write('\r\n\x1b[38;2;107;104;98m[reconnecting...]\x1b[0m\r\n');
setTimeout(() => connectSession(id, s.ptyTag ?? undefined), 1000);
return;
}
if (s.state === 'connected') {
updateSession(id, { state: 'disconnected' }); updateSession(id, { state: 'disconnected' });
} }
}; };
@ -306,10 +351,9 @@
const int = internals.get(id); const int = internals.get(id);
if (int) { if (int) {
if (int.fitDebounce) clearTimeout(int.fitDebounce); if (int.fitDebounce) clearTimeout(int.fitDebounce);
if (int.inputFlushTimer) clearTimeout(int.inputFlushTimer);
int.resizeObserver?.disconnect(); int.resizeObserver?.disconnect();
if (int.ws?.readyState === WebSocket.OPEN) { wsSend(int.ws, JSON.stringify({ type: 'kill' }));
int.ws.send(JSON.stringify({ type: 'kill' }));
}
int.ws?.close(); int.ws?.close();
int.term?.dispose(); int.term?.dispose();
internals.delete(id); internals.delete(id);
@ -347,6 +391,7 @@
onDestroy(() => { onDestroy(() => {
for (const [, int] of internals) { for (const [, int] of internals) {
if (int.fitDebounce) clearTimeout(int.fitDebounce); if (int.fitDebounce) clearTimeout(int.fitDebounce);
if (int.inputFlushTimer) clearTimeout(int.inputFlushTimer);
int.resizeObserver?.disconnect(); int.resizeObserver?.disconnect();
int.ws?.close(); int.ws?.close();
int.term?.dispose(); int.term?.dispose();
@ -360,7 +405,6 @@
padding: 12px 4px 12px 16px; padding: 12px 4px 12px 16px;
height: 100%; height: 100%;
} }
/* Paint xterm's own background to cover any fractional-pixel gaps */
.terminal-container :global(.xterm-viewport), .terminal-container :global(.xterm-viewport),
.terminal-container :global(.xterm-screen) { .terminal-container :global(.xterm-screen) {
background-color: #0a0c0b !important; background-color: #0a0c0b !important;
@ -406,7 +450,6 @@
.term-tab-active::after { .term-tab-active::after {
display: none; display: none;
} }
/* Hide separator on tab right before active tab */
.term-tab:has(+ .term-tab-active)::after { .term-tab:has(+ .term-tab-active)::after {
display: none; display: none;
} }
@ -428,9 +471,8 @@
</div> </div>
</div> </div>
{:else} {:else}
<!-- Unified session bar: tabs + status integrated into one row (hidden when no sessions) --> <!-- Unified session bar (hidden when no sessions) -->
<div class="flex items-stretch bg-[var(--color-bg-1)]" style:display={sessions.length === 0 ? 'none' : 'flex'}> <div class="flex items-stretch bg-[var(--color-bg-1)]" style:display={sessions.length === 0 ? 'none' : 'flex'}>
<!-- Tabs -->
<div class="tab-scroll flex items-stretch overflow-x-auto"> <div class="tab-scroll flex items-stretch overflow-x-auto">
{#each sessions as session (session.id)} {#each sessions as session (session.id)}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
@ -475,21 +517,19 @@
{/each} {/each}
</div> </div>
<!-- New tab button -->
<button <button
onclick={createSession} onclick={createSession}
class="flex shrink-0 items-center justify-center aspect-square self-stretch border-b border-[var(--color-border)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-2)] hover:text-[var(--color-text-primary)]" disabled={sessions.length >= MAX_SESSIONS}
title="New terminal session" class="flex shrink-0 items-center justify-center aspect-square self-stretch border-b border-[var(--color-border)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-2)] hover:text-[var(--color-text-primary)] disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-[var(--color-text-tertiary)]"
title={sessions.length >= MAX_SESSIONS ? `Maximum ${MAX_SESSIONS} sessions` : 'New terminal session'}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /> <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
</button> </button>
<!-- Spacer fills remaining width with bg-1, has bottom border like inactive area -->
<div class="flex-1 border-b border-[var(--color-border)] bg-[var(--color-bg-1)]"></div> <div class="flex-1 border-b border-[var(--color-border)] bg-[var(--color-bg-1)]"></div>
<!-- Right-side status info (reconnect button, pty tag) -->
{#if activeSession} {#if activeSession}
<div class="flex items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] pr-4"> <div class="flex items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] pr-4">
{#if activeSession.state === 'error' && activeSession.errorMessage} {#if activeSession.state === 'error' && activeSession.errorMessage}