Add docker attach support (#1377)

* Add attach mode

* Better UI

* Remove unnecessary message

* extract + test the docker stream demux; reset terminal mode per session

- Pull the Docker hijacked-stream decoder (HTTP header strip, chunked
  decode, 8-byte multiplexed frame demux + raw/TTY fallback) into a pure
  src/lib/server/docker-stream-core.ts with unit tests covering raw
  passthrough, stdout/stderr demux, frames split across socket events,
  a split header, chunked reassembly, and the raw-fallback path.
- vite.config.ts now imports the core instead of carrying its own copy.
  server.js runs against ./build and can't import the TS core at runtime,
  so it keeps the inline copy with a note to keep the two in sync.
- containers page: default a fresh terminal session to exec so a prior
  container's Attach choice (shared terminalMode) doesn't carry over.

---------

Co-authored-by: Jarek Krochmalski <[email protected]>
This commit is contained in:
Ivan KaraandJarek Krochmalski authored and GitHub committed 2026-09-13 14:08:19 +02:00
1 parent 560b21e03f
commit 0ca3b86f82
10 files changed
+688 -208

No files matched your search

+191 -55
View File
@@ -2,7 +2,7 @@
* Production Server Wrapper * Production Server Wrapper
* *
* Wraps @sveltejs/adapter-node's output with WebSocket support for: * Wraps @sveltejs/adapter-node's output with WebSocket support for:
* - Terminal exec connections (xterm.js ↔ Docker exec) * - Terminal connections (xterm.js ↔ Docker exec/attach)
* - Hawser Edge agent connections * - Hawser Edge agent connections
* *
* Usage: node ./server.js * Usage: node ./server.js
@@ -235,11 +235,139 @@ wss.on('connection', (ws, req) => {
* - __terminalGetTarget(envId) - resolves connection info from environment * - __terminalGetTarget(envId) - resolves connection info from environment
* - __terminalCreateExec(containerId, shell, user, envId) - creates exec via Docker API * - __terminalCreateExec(containerId, shell, user, envId) - creates exec via Docker API
* - __terminalResizeExec(execId, cols, rows, envId) - resizes exec terminal * - __terminalResizeExec(execId, cols, rows, envId) - resizes exec terminal
* - __terminalGetContainerTty(containerId, envId) - reads the container TTY setting
* - __terminalResizeContainer(containerId, cols, rows, envId) - resizes an attached TTY
*/ */
// NOTE: createDockerStreamState/decodeChunkedDockerBody/processDockerStreamChunk below
// mirror src/lib/server/docker-stream-core.ts (the tested source of truth). server.js
// runs against ./build and cannot import the TS core at runtime, so the logic is kept
// inline here; keep the two in sync (vite.config.ts imports the core directly).
function buildDockerStreamRequest(path, target, body = '') {
const host = target.host || 'localhost';
const tokenHeader = target.hawserToken ? `X-Hawser-Token: ${target.hawserToken}\r\n` : '';
return (
`POST ${path} HTTP/1.1\r\n` +
`Host: ${host}\r\n` +
`Content-Type: application/json\r\n` +
`${tokenHeader}` +
`Connection: Upgrade\r\n` +
`Upgrade: tcp\r\n` +
`Content-Length: ${Buffer.byteLength(body)}\r\n` +
`\r\n` +
body
);
}
function createDockerStreamState(multiplexed = false) {
return {
headersStripped: false,
isChunked: false,
headerBuffer: Buffer.alloc(0),
chunkBuffer: Buffer.alloc(0),
chunkSize: null,
chunkEnded: false,
multiplexed,
streamBuffer: Buffer.alloc(0)
};
}
function decodeChunkedDockerBody(data, state) {
state.chunkBuffer = Buffer.concat([state.chunkBuffer, data]);
const chunks = [];
while (!state.chunkEnded) {
if (state.chunkSize === null) {
const lineEnd = state.chunkBuffer.indexOf('\r\n');
if (lineEnd < 0) break;
const sizeText = state.chunkBuffer.slice(0, lineEnd).toString('ascii').split(';', 1)[0];
const size = parseInt(sizeText, 16);
if (!Number.isFinite(size) || size < 0) {
state.chunkEnded = true;
chunks.push(state.chunkBuffer);
state.chunkBuffer = Buffer.alloc(0);
break;
}
state.chunkBuffer = state.chunkBuffer.slice(lineEnd + 2);
state.chunkSize = size;
if (size === 0) {
state.chunkEnded = true;
state.chunkBuffer = Buffer.alloc(0);
break;
}
}
if (state.chunkBuffer.length < state.chunkSize + 2) break;
chunks.push(state.chunkBuffer.slice(0, state.chunkSize));
state.chunkBuffer = state.chunkBuffer.slice(state.chunkSize + 2);
state.chunkSize = null;
}
return chunks;
}
function processDockerStreamChunk(data, state) {
let buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
if (!state.headersStripped) {
state.headerBuffer = Buffer.concat([state.headerBuffer, buffer]);
const headerEnd = state.headerBuffer.indexOf('\r\n\r\n');
if (headerEnd < 0) return [];
const headers = state.headerBuffer.slice(0, headerEnd).toString('ascii').toLowerCase();
state.isChunked = headers.includes('transfer-encoding: chunked');
buffer = state.headerBuffer.slice(headerEnd + 4);
state.headerBuffer = Buffer.alloc(0);
state.headersStripped = true;
}
const bodyChunks = state.isChunked ? decodeChunkedDockerBody(buffer, state) : [buffer];
const output = [];
for (const body of bodyChunks) {
if (!body.length) continue;
if (!state.multiplexed) {
output.push(body.toString('utf-8'));
continue;
}
state.streamBuffer = Buffer.concat([state.streamBuffer, body]);
while (state.streamBuffer.length > 0) {
if (state.streamBuffer.length < 8) break;
const streamType = state.streamBuffer.readUInt8(0);
const frameSize = state.streamBuffer.readUInt32BE(4);
if (
streamType > 2 ||
state.streamBuffer[1] !== 0 ||
state.streamBuffer[2] !== 0 ||
state.streamBuffer[3] !== 0 ||
frameSize > 10 * 1024 * 1024
) {
// TTY output is normally raw. Fall back to raw output if a Docker
// proxy did not preserve the expected multiplexed framing.
output.push(state.streamBuffer.toString('utf-8'));
state.streamBuffer = Buffer.alloc(0);
state.multiplexed = false;
break;
}
if (state.streamBuffer.length < 8 + frameSize) break;
if (streamType === 1 || streamType === 2) {
output.push(state.streamBuffer.slice(8, 8 + frameSize).toString('utf-8'));
}
state.streamBuffer = state.streamBuffer.slice(8 + frameSize);
}
}
return output;
}
async function handleTerminalConnection(ws, url, connId) { async function handleTerminalConnection(ws, url, connId) {
const pathParts = url.pathname.split('/'); const pathParts = url.pathname.split('/');
const containerIdIndex = pathParts.indexOf('containers') + 1; const containerIdIndex = pathParts.indexOf('containers') + 1;
const containerId = pathParts[containerIdIndex]; const containerId = pathParts[containerIdIndex];
const mode = url.searchParams.get('mode') === 'attach' ? 'attach' : 'exec';
const shell = url.searchParams.get('shell') || '/bin/sh'; const shell = url.searchParams.get('shell') || '/bin/sh';
const user = url.searchParams.get('user') || 'root'; const user = url.searchParams.get('user') || 'root';
const envIdParam = url.searchParams.get('envId'); const envIdParam = url.searchParams.get('envId');
@@ -294,23 +422,45 @@ async function handleTerminalConnection(ws, url, connId) {
target = { type: 'socket', connectionType: 'socket', socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' }; target = { type: 'socket', connectionType: 'socket', socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' };
} }
// Handle Hawser Edge mode - relay through agent WebSocket // Hawser Edge currently exposes an exec-only terminal protocol.
if (target.connectionType === 'hawser-edge') { if (target.connectionType === 'hawser-edge') {
if (mode === 'attach') {
ws.send(JSON.stringify({ type: 'error', message: 'Container attach is not supported for Edge environments' }));
ws.close();
return;
}
handleEdgeExec(ws, connId, containerId, shell, user, target.environmentId); handleEdgeExec(ws, connId, containerId, shell, user, target.environmentId);
return; return;
} }
// Create exec instance via SvelteKit app (handles all connection types) let execId = null;
let execId; let streamPath;
if (typeof globalThis.__terminalCreateExec === 'function') { let streamBody = '';
execId = await globalThis.__terminalCreateExec(containerId, shell, user, envId); let multiplexed = false;
if (mode === 'attach') {
let containerTty = false;
if (typeof globalThis.__terminalGetContainerTty === 'function') {
try {
containerTty = await globalThis.__terminalGetContainerTty(containerId, envId);
} catch {
// Keep multiplexing enabled if the TTY setting cannot be read.
}
}
multiplexed = !containerTty;
streamPath = `/containers/${encodeURIComponent(containerId)}/attach?stream=1&stdin=1&stdout=1&stderr=1`;
} else { } else {
// Fallback: create exec directly via local socket // Create exec instance via SvelteKit app (handles all connection types)
execId = await createExecLocal(containerId, shell, user, target.socketPath || '/var/run/docker.sock'); if (typeof globalThis.__terminalCreateExec === 'function') {
execId = await globalThis.__terminalCreateExec(containerId, shell, user, envId);
} else {
// Fallback: create exec directly via local socket
execId = await createExecLocal(containerId, shell, user, target.socketPath || '/var/run/docker.sock');
}
streamPath = `/exec/${execId}/start`;
streamBody = JSON.stringify({ Detach: false, Tty: true });
} }
// Open raw bidirectional stream to Docker for the exec session // Open raw bidirectional stream to Docker for the attach or exec session.
const startBody = JSON.stringify({ Detach: false, Tty: true });
let dockerStream; let dockerStream;
if (target.type === 'socket') { if (target.type === 'socket') {
@@ -333,45 +483,16 @@ async function handleTerminalConnection(ws, url, connId) {
} }
dockerStream.on('connect', () => { dockerStream.on('connect', () => {
const host = target.host || 'localhost'; dockerStream.write(buildDockerStreamRequest(streamPath, target, streamBody));
const tokenHeader = target.hawserToken ? `X-Hawser-Token: ${target.hawserToken}\r\n` : '';
dockerStream.write(
`POST /exec/${execId}/start HTTP/1.1\r\n` +
`Host: ${host}\r\n` +
`Content-Type: application/json\r\n` +
`${tokenHeader}` +
`Connection: Upgrade\r\n` +
`Upgrade: tcp\r\n` +
`Content-Length: ${Buffer.byteLength(startBody)}\r\n` +
`\r\n` +
startBody
);
}); });
let headersStripped = false; const streamState = createDockerStreamState(multiplexed);
let isChunked = false;
dockerStream.on('data', (data) => { dockerStream.on('data', (data) => {
if (ws.readyState !== 1) return; if (ws.readyState !== 1) return;
let text = data.toString('utf-8'); for (const text of processDockerStreamChunk(data, streamState)) {
if (!headersStripped) { if (text) ws.send(JSON.stringify({ type: 'output', data: text }));
if (text.toLowerCase().includes('transfer-encoding: chunked')) {
isChunked = true;
}
const headerEnd = text.indexOf('\r\n\r\n');
if (headerEnd > -1) {
text = text.slice(headerEnd + 4);
headersStripped = true;
} else if (text.startsWith('HTTP/')) {
return;
}
}
if (isChunked && text) {
text = text.replace(/^[0-9a-fA-F]+\r\n/gm, '').replace(/\r\n$/g, '');
}
if (text) {
ws.send(JSON.stringify({ type: 'output', data: text }));
} }
}); });
@@ -396,19 +517,34 @@ async function handleTerminalConnection(ws, url, connId) {
if (msg.type === 'input' && msg.data) { if (msg.type === 'input' && msg.data) {
dockerStream.write(msg.data); dockerStream.write(msg.data);
} else if (msg.type === 'resize' && msg.cols && msg.rows) { } else if (msg.type === 'resize' && msg.cols && msg.rows) {
// Use SvelteKit's resize function if available (works for all connection types) if (mode === 'attach') {
if (typeof globalThis.__terminalResizeExec === 'function') { if (typeof globalThis.__terminalResizeContainer === 'function') {
globalThis.__terminalResizeExec(execId, msg.cols, msg.rows, envId).catch(() => {}); globalThis.__terminalResizeContainer(containerId, msg.cols, msg.rows, envId).catch(() => {});
} else if (target.type === 'socket') {
const socketPath = target.socketPath || '/var/run/docker.sock';
const resizeReq = httpRequest({
socketPath,
path: `/containers/${encodeURIComponent(containerId)}/resize?h=${msg.rows}&w=${msg.cols}`,
method: 'POST',
}, () => {});
resizeReq.on('error', () => {});
resizeReq.end();
}
} else { } else {
// Fallback: resize via local socket // Use SvelteKit's resize function if available (works for all connection types)
const socketPath = target.socketPath || '/var/run/docker.sock'; if (typeof globalThis.__terminalResizeExec === 'function') {
const resizeReq = httpRequest({ globalThis.__terminalResizeExec(execId, msg.cols, msg.rows, envId).catch(() => {});
socketPath, } else {
path: `/exec/${execId}/resize?h=${msg.rows}&w=${msg.cols}`, // Fallback: resize via local socket
method: 'POST', const socketPath = target.socketPath || '/var/run/docker.sock';
}, () => {}); const resizeReq = httpRequest({
resizeReq.on('error', () => {}); socketPath,
resizeReq.end(); path: `/exec/${execId}/resize?h=${msg.rows}&w=${msg.cols}`,
method: 'POST',
}, () => {});
resizeReq.on('error', () => {});
resizeReq.end();
}
} }
} }
} catch {} } catch {}
+138
View File
@@ -0,0 +1,138 @@
/**
* Pure Docker hijacked-stream decoding for the terminal WebSocket handlers.
*
* `docker exec` with a TTY returns a RAW byte stream, but `docker attach` to a
* container started WITHOUT a tty returns a MULTIPLEXED stream: each frame is an
* 8-byte header (stream type in byte 0, 32-bit big-endian payload size in bytes
* 4-7) followed by the payload. The daemon may also chunk the HTTP body. This
* module strips the HTTP response headers, decodes chunked transfer-encoding, and
* demultiplexes the frames into plain output strings.
*
* No imports / no I/O so it is unit-testable in the bun runner and shared by BOTH
* WebSocket servers (server.js in production, vite.config.ts in dev) instead of
* being duplicated in each.
*/
export interface DockerStreamState {
headersStripped: boolean;
isChunked: boolean;
headerBuffer: Buffer;
chunkBuffer: Buffer;
chunkSize: number | null;
chunkEnded: boolean;
multiplexed: boolean;
streamBuffer: Buffer;
}
export function createDockerStreamState(multiplexed = false): DockerStreamState {
return {
headersStripped: false,
isChunked: false,
headerBuffer: Buffer.alloc(0),
chunkBuffer: Buffer.alloc(0),
chunkSize: null,
chunkEnded: false,
multiplexed,
streamBuffer: Buffer.alloc(0)
};
}
/** Decode HTTP chunked transfer-encoding into raw body buffers. */
export function decodeChunkedDockerBody(data: Buffer, state: DockerStreamState): Buffer[] {
state.chunkBuffer = Buffer.concat([state.chunkBuffer, data]);
const chunks: Buffer[] = [];
while (!state.chunkEnded) {
if (state.chunkSize === null) {
const lineEnd = state.chunkBuffer.indexOf('\r\n');
if (lineEnd < 0) break;
const sizeText = state.chunkBuffer.slice(0, lineEnd).toString('ascii').split(';', 1)[0];
const size = parseInt(sizeText, 16);
if (!Number.isFinite(size) || size < 0) {
// Malformed size line: surface what we have and stop parsing this body.
state.chunkEnded = true;
chunks.push(state.chunkBuffer);
state.chunkBuffer = Buffer.alloc(0);
break;
}
state.chunkBuffer = state.chunkBuffer.slice(lineEnd + 2);
state.chunkSize = size;
if (size === 0) {
state.chunkEnded = true;
state.chunkBuffer = Buffer.alloc(0);
break;
}
}
if (state.chunkBuffer.length < state.chunkSize + 2) break;
chunks.push(state.chunkBuffer.slice(0, state.chunkSize));
state.chunkBuffer = state.chunkBuffer.slice(state.chunkSize + 2);
state.chunkSize = null;
}
return chunks;
}
/**
* Feed one raw socket chunk through the decoder and return zero or more decoded
* output strings. Strips HTTP headers on the first call, decodes chunked bodies,
* and demultiplexes 8-byte-framed output; a non-multiplexed (TTY) stream passes
* through unchanged. Falls back to raw if the framing looks wrong (a proxy that
* didn't preserve it).
*/
export function processDockerStreamChunk(data: Buffer | Uint8Array | string, state: DockerStreamState): string[] {
let buffer = Buffer.isBuffer(data) ? data : Buffer.from(data as Uint8Array);
if (!state.headersStripped) {
state.headerBuffer = Buffer.concat([state.headerBuffer, buffer]);
const headerEnd = state.headerBuffer.indexOf('\r\n\r\n');
if (headerEnd < 0) return [];
const headers = state.headerBuffer.slice(0, headerEnd).toString('ascii').toLowerCase();
state.isChunked = headers.includes('transfer-encoding: chunked');
buffer = state.headerBuffer.slice(headerEnd + 4);
state.headerBuffer = Buffer.alloc(0);
state.headersStripped = true;
}
const bodyChunks = state.isChunked ? decodeChunkedDockerBody(buffer, state) : [buffer];
const output: string[] = [];
for (const body of bodyChunks) {
if (!body.length) continue;
if (!state.multiplexed) {
output.push(body.toString('utf-8'));
continue;
}
state.streamBuffer = Buffer.concat([state.streamBuffer, body]);
while (state.streamBuffer.length > 0) {
if (state.streamBuffer.length < 8) break;
const streamType = state.streamBuffer.readUInt8(0);
const frameSize = state.streamBuffer.readUInt32BE(4);
if (
streamType > 2 ||
state.streamBuffer[1] !== 0 ||
state.streamBuffer[2] !== 0 ||
state.streamBuffer[3] !== 0 ||
frameSize > 10 * 1024 * 1024
) {
// TTY output is normally raw. Fall back to raw output if a Docker
// proxy did not preserve the expected multiplexed framing.
output.push(state.streamBuffer.toString('utf-8'));
state.streamBuffer = Buffer.alloc(0);
state.multiplexed = false;
break;
}
if (state.streamBuffer.length < 8 + frameSize) break;
if (streamType === 1 || streamType === 2) {
output.push(state.streamBuffer.slice(8, 8 + frameSize).toString('utf-8'));
}
state.streamBuffer = state.streamBuffer.slice(8 + frameSize);
}
}
return output;
}
+20
View File
@@ -4522,6 +4522,15 @@ export async function resizeExec(execId: string, cols: number, rows: number, env
} }
} }
export async function resizeContainer(containerId: string, cols: number, rows: number, envId?: number | null) {
try {
const response = await dockerFetch(`/containers/${containerId}/resize?h=${rows}&w=${cols}`, { method: 'POST' }, envId);
await drainResponse(response);
} catch {
// Resize may fail if the container is not attached to a TTY, ignore
}
}
/** /**
* Get Docker connection info for direct WebSocket connections from the client * Get Docker connection info for direct WebSocket connections from the client
* This is used by the terminal to connect directly to the Docker API * This is used by the terminal to connect directly to the Docker API
@@ -4561,6 +4570,8 @@ declare global {
}>) | undefined; }>) | undefined;
var __terminalCreateExec: ((containerId: string, shell: string, user: string, envId?: number) => Promise<string>) | undefined; var __terminalCreateExec: ((containerId: string, shell: string, user: string, envId?: number) => Promise<string>) | undefined;
var __terminalResizeExec: ((execId: string, cols: number, rows: number, envId?: number) => Promise<void>) | undefined; var __terminalResizeExec: ((execId: string, cols: number, rows: number, envId?: number) => Promise<void>) | undefined;
var __terminalGetContainerTty: ((containerId: string, envId?: number) => Promise<boolean>) | undefined;
var __terminalResizeContainer: ((containerId: string, cols: number, rows: number, envId?: number) => Promise<void>) | undefined;
} }
globalThis.__terminalGetTarget = async (envId?: number) => { globalThis.__terminalGetTarget = async (envId?: number) => {
@@ -4598,6 +4609,15 @@ globalThis.__terminalResizeExec = async (execId, cols, rows, envId) => {
await resizeExec(execId, cols, rows, envId); await resizeExec(execId, cols, rows, envId);
}; };
globalThis.__terminalGetContainerTty = async (containerId, envId) => {
const container = await inspectContainer(containerId, envId);
return Boolean(container.Config?.Tty);
};
globalThis.__terminalResizeContainer = async (containerId, cols, rows, envId) => {
await resizeContainer(containerId, cols, rows, envId);
};
// System disk usage // System disk usage
export async function getDiskUsage(envId?: number | null) { export async function getDiskUsage(envId?: number | null) {
return dockerJsonRequest('/system/df', {}, envId); return dockerJsonRequest('/system/df', {}, envId);
+3
View File
@@ -14,6 +14,9 @@ export interface NewerVersion {
digest?: string; digest?: string;
} }
/** Terminal connection mode: an interactive `docker exec` shell, or `docker attach` to PID 1. */
export type TerminalMode = 'exec' | 'attach';
export interface ContainerInfo { export interface ContainerInfo {
id: string; id: string;
name: string; name: string;
+110 -72
View File
@@ -67,7 +67,8 @@
Copy, Copy,
Loader2, Loader2,
AlertCircle, AlertCircle,
Tag Tag,
Unplug
} from 'lucide-svelte'; } from 'lucide-svelte';
import { broom } from '@lucide/lab'; import { broom } from '@lucide/lab';
import { copyToClipboard } from '$lib/utils/clipboard'; import { copyToClipboard } from '$lib/utils/clipboard';
@@ -83,7 +84,7 @@
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte'; import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
import VersionUpdateBadge from '$lib/components/VersionUpdateBadge.svelte'; import VersionUpdateBadge from '$lib/components/VersionUpdateBadge.svelte';
import VersionUpdateModal from '$lib/components/VersionUpdateModal.svelte'; import VersionUpdateModal from '$lib/components/VersionUpdateModal.svelte';
import type { ContainerInfo } from '$lib/types'; import type { ContainerInfo, TerminalMode } from '$lib/types';
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state'; import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment'; import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
import { containerStore } from '$lib/stores/containers'; import { containerStore } from '$lib/stores/containers';
@@ -258,10 +259,12 @@
containerName: string; containerName: string;
shell: string; shell: string;
user: string; user: string;
mode: TerminalMode;
} }
let activeTerminals = $state<ActiveTerminal[]>([]); let activeTerminals = $state<ActiveTerminal[]>([]);
let currentTerminalContainerId = $state<string | null>(null); let currentTerminalContainerId = $state<string | null>(null);
let terminalPopoverStates = $state<Record<string, boolean>>({}); let terminalPopoverStates = $state<Record<string, boolean>>({});
let terminalMode = $state<TerminalMode>('exec');
let terminalShell = $state('/bin/bash'); let terminalShell = $state('/bin/bash');
let terminalUser = $state('root'); let terminalUser = $state('root');
let terminalCustomUser = $state(''); let terminalCustomUser = $state('');
@@ -1121,19 +1124,23 @@
const savedUser = getSavedUser(container.id); const savedUser = getSavedUser(container.id);
terminalUser = savedUser ?? 'root'; terminalUser = savedUser ?? 'root';
terminalCustomUsers = getCustomUsers(); terminalCustomUsers = getCustomUsers();
// Default a fresh session to exec so a prior container's Attach choice
// (terminalMode is shared) doesn't carry over to this one.
terminalMode = 'exec';
// Show popover to configure new terminal // Show popover to configure new terminal
terminalPopoverStates[container.id] = true; terminalPopoverStates[container.id] = true;
} }
} }
function startTerminal(container: ContainerInfo) { function startTerminal(container: ContainerInfo) {
saveUserForContainer(container.id, terminalUser); if (terminalMode === 'exec') saveUserForContainer(container.id, terminalUser);
terminalCustomUsers = getCustomUsers(); terminalCustomUsers = getCustomUsers();
const terminal: ActiveTerminal = { const terminal: ActiveTerminal = {
containerId: container.id, containerId: container.id,
containerName: container.name, containerName: container.name,
shell: terminalShell, shell: terminalShell,
user: terminalUser user: terminalUser,
mode: terminalMode
}; };
activeTerminals = [...activeTerminals, terminal]; activeTerminals = [...activeTerminals, terminal];
currentTerminalContainerId = container.id; currentTerminalContainerId = container.id;
@@ -1434,7 +1441,7 @@
onkeydown={(e) => e.key === 'Escape' && (searchQuery = '')} onkeydown={(e) => e.key === 'Escape' && (searchQuery = '')}
class="pl-8 h-8 w-48 text-sm" class="pl-8 h-8 w-48 text-sm"
/> />
</div> </div>
<!-- Status filter (multi-select). The synthetic 'update-available' <!-- Status filter (multi-select). The synthetic 'update-available'
entry appears once at least one container has a pending update, entry appears once at least one container has a pending update,
and ANDs with selected real states (#1063). --> and ANDs with selected real states (#1063). -->
@@ -2160,7 +2167,7 @@
{:else} {:else}
<Popover.Root open={terminalPopoverStates[container.id] ?? false} onOpenChange={(open) => { <Popover.Root open={terminalPopoverStates[container.id] ?? false} onOpenChange={(open) => {
terminalPopoverStates[container.id] = open; terminalPopoverStates[container.id] = open;
if (open) detectContainerShells(container.id); if (open && terminalMode === 'exec') detectContainerShells(container.id);
}}> }}>
<Popover.Trigger <Popover.Trigger
onclick={(e: MouseEvent) => e.stopPropagation()} onclick={(e: MouseEvent) => e.stopPropagation()}
@@ -2175,12 +2182,12 @@
<span class="text-xs font-medium truncate" title={container.name}>{container.name}</span> <span class="text-xs font-medium truncate" title={container.name}>{container.name}</span>
</div> </div>
</div> </div>
{#if detectingShellsFor === container.id} {#if terminalMode === 'exec' && detectingShellsFor === container.id}
<div class="p-4 text-center"> <div class="p-4 text-center">
<Loader2 class="w-5 h-5 mx-auto mb-2 text-muted-foreground animate-spin" /> <Loader2 class="w-5 h-5 mx-auto mb-2 text-muted-foreground animate-spin" />
<p class="text-xs text-muted-foreground">Detecting shells...</p> <p class="text-xs text-muted-foreground">Detecting shells...</p>
</div> </div>
{:else if !anyShellAvailableFor(container.id)} {:else if terminalMode === 'exec' && !anyShellAvailableFor(container.id)}
<div class="p-4 text-center"> <div class="p-4 text-center">
<AlertCircle class="w-5 h-5 mx-auto mb-2 text-amber-500" /> <AlertCircle class="w-5 h-5 mx-auto mb-2 text-amber-500" />
<p class="text-xs font-medium text-amber-500">No shell available</p> <p class="text-xs font-medium text-amber-500">No shell available</p>
@@ -2189,76 +2196,105 @@
{:else} {:else}
<div class="p-3 space-y-3"> <div class="p-3 space-y-3">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label class="text-xs">Shell</Label> <Label class="text-xs">Mode</Label>
<Select.Root type="single" bind:value={terminalShell}> <Select.Root type="single" value={terminalMode} onValueChange={(value) => {
terminalMode = value as TerminalMode;
if (terminalMode === 'exec') detectContainerShells(container.id);
}}>
<Select.Trigger class="w-full h-8 text-xs"> <Select.Trigger class="w-full h-8 text-xs">
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" /> {#if terminalMode === 'attach'}
<span>{shellDetectionCache[container.id]?.allShells.find(o => o.path === terminalShell)?.label || 'Select'}</span> <Unplug class="w-3 h-3 mr-1.5 text-muted-foreground" />
Attach to process
{:else}
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
Shell (exec)
{/if}
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>
{#if shellDetectionCache[container.id]} <Select.Item value="exec" label="Shell (exec)">
{#each shellDetectionCache[container.id].allShells as option} <Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
<Select.Item value={option.path} label={option.label} disabled={!option.available}> Shell (exec)
<Shell class="w-3 h-3 mr-1.5 {option.available ? 'text-green-500' : 'text-muted-foreground/40'}" /> </Select.Item>
<span class={option.available ? 'text-foreground' : 'text-muted-foreground/60'}> <Select.Item value="attach" label="Attach to process">
{option.label} <Unplug class="w-3 h-3 mr-1.5 text-muted-foreground" />
{#if !option.available} Attach to process
<span class="text-xs ml-1">(unavailable)</span> </Select.Item>
{/if} </Select.Content>
</span> </Select.Root>
</div>
{#if terminalMode === 'exec'}
<div class="space-y-1.5">
<Label class="text-xs">Shell</Label>
<Select.Root type="single" bind:value={terminalShell}>
<Select.Trigger class="w-full h-8 text-xs">
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
<span>{shellDetectionCache[container.id]?.allShells.find(o => o.path === terminalShell)?.label || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#if shellDetectionCache[container.id]}
{#each shellDetectionCache[container.id].allShells as option}
<Select.Item value={option.path} label={option.label} disabled={!option.available}>
<Shell class="w-3 h-3 mr-1.5 {option.available ? 'text-green-500' : 'text-muted-foreground/40'}" />
<span class={option.available ? 'text-foreground' : 'text-muted-foreground/60'}>
{option.label}
{#if !option.available}
<span class="text-xs ml-1">(unavailable)</span>
{/if}
</span>
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label class="text-xs">User</Label>
<Select.Root type="single" bind:value={terminalUser}>
<Select.Trigger class="w-full h-8 text-xs">
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
<span>{userOptions.find(o => o.value === terminalUser)?.label || terminalUser || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#each userOptions as option}
<Select.Item value={option.value} label={option.label}>
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
{option.label}
</Select.Item> </Select.Item>
{/each} {/each}
{/if} {#if terminalCustomUsers.length > 0}
</Select.Content> <div class="h-px bg-border my-1"></div>
</Select.Root> {#each terminalCustomUsers as cu}
</div> <div class="flex items-center group">
<div class="space-y-1.5"> <Select.Item value={cu} label={cu} class="flex-1">
<Label class="text-xs">User</Label> <User class="w-3 h-3 mr-1.5 text-muted-foreground" />
<Select.Root type="single" bind:value={terminalUser}> {cu}
<Select.Trigger class="w-full h-8 text-xs"> </Select.Item>
<User class="w-3 h-3 mr-1.5 text-muted-foreground" /> <button
<span>{userOptions.find(o => o.value === terminalUser)?.label || terminalUser || 'Select'}</span> type="button"
</Select.Trigger> class="p-1 mr-1 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
<Select.Content> onclick={(e) => { e.stopPropagation(); e.preventDefault(); removeCustomUser(cu); terminalCustomUsers = getCustomUsers(); if (terminalUser === cu) { terminalUser = 'root'; } }}
{#each userOptions as option} title="Remove user"
<Select.Item value={option.value} label={option.label}> >
<User class="w-3 h-3 mr-1.5 text-muted-foreground" /> <Trash2 class="w-3 h-3" />
{option.label} </button>
</Select.Item> </div>
{/each} {/each}
{#if terminalCustomUsers.length > 0} {/if}
<div class="h-px bg-border my-1"></div> <div class="h-px bg-border my-1"></div>
{#each terminalCustomUsers as cu} <div class="px-2 py-1">
<div class="flex items-center group"> <Input
<Select.Item value={cu} label={cu} class="flex-1"> class="h-7 text-xs"
<User class="w-3 h-3 mr-1.5 text-muted-foreground" /> placeholder="Add user... (Enter)"
{cu} bind:value={terminalCustomUser}
</Select.Item> onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Enter' && terminalCustomUser.trim()) { const u = terminalCustomUser.trim(); terminalUser = u; saveUserForContainer(container.id, u); terminalCustomUsers = getCustomUsers(); terminalCustomUser = ''; } }}
<button onclick={(e) => e.stopPropagation()}
type="button" />
class="p-1 mr-1 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity" </div>
onclick={(e) => { e.stopPropagation(); e.preventDefault(); removeCustomUser(cu); terminalCustomUsers = getCustomUsers(); if (terminalUser === cu) { terminalUser = 'root'; } }} </Select.Content>
title="Remove user" </Select.Root>
> </div>
<Trash2 class="w-3 h-3" /> {/if}
</button> <Button size="sm" class="w-full h-7 text-xs" onclick={() => startTerminal(container)}>
</div>
{/each}
{/if}
<div class="h-px bg-border my-1"></div>
<div class="px-2 py-1">
<Input
class="h-7 text-xs"
placeholder="Add user... (Enter)"
bind:value={terminalCustomUser}
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Enter' && terminalCustomUser.trim()) { const u = terminalCustomUser.trim(); terminalUser = u; saveUserForContainer(container.id, u); terminalCustomUsers = getCustomUsers(); terminalCustomUser = ''; } }}
onclick={(e) => e.stopPropagation()}
/>
</div>
</Select.Content>
</Select.Root>
</div>
<Button size="sm" class="w-full h-7 text-xs" onclick={() => startTerminal(container)}>
<Terminal class="w-3 h-3" /> <Terminal class="w-3 h-3" />
Connect Connect
</Button> </Button>
@@ -2436,6 +2472,7 @@
containerName={t.containerName} containerName={t.containerName}
shell={t.shell} shell={t.shell}
user={t.user} user={t.user}
mode={t.mode}
visible={t.containerId === currentTerminalContainerId} visible={t.containerId === currentTerminalContainerId}
envId={envId} envId={envId}
fillHeight={true} fillHeight={true}
@@ -2473,6 +2510,7 @@
containerName={t.containerName} containerName={t.containerName}
shell={t.shell} shell={t.shell}
user={t.user} user={t.user}
mode={t.mode}
visible={t.containerId === currentTerminalContainerId} visible={t.containerId === currentTerminalContainerId}
envId={envId} envId={envId}
onClose={() => closeTerminal(t.containerId)} onClose={() => closeTerminal(t.containerId)}
+38 -7
View File
@@ -12,7 +12,7 @@
import { Search, ChevronDown, Terminal as TerminalIcon, Unplug, RefreshCw, Trash2, Copy, Shell, User, Loader2, AlertCircle } from 'lucide-svelte'; import { Search, ChevronDown, Terminal as TerminalIcon, Unplug, RefreshCw, Trash2, Copy, Shell, User, Loader2, AlertCircle } from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import ContainerIcon from '$lib/components/ContainerIcon.svelte'; import ContainerIcon from '$lib/components/ContainerIcon.svelte';
import type { ContainerInfo } from '$lib/types'; import type { ContainerInfo, TerminalMode } from '$lib/types';
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment'; import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
import Terminal from './Terminal.svelte'; import Terminal from './Terminal.svelte';
import { NoEnvironment } from '$lib/components/ui/empty-state'; import { NoEnvironment } from '$lib/components/ui/empty-state';
@@ -34,6 +34,7 @@
let detectingShells = $state(false); let detectingShells = $state(false);
// Shell/user options // Shell/user options
let terminalMode = $state<TerminalMode>('exec');
let selectedShell = $state('/bin/bash'); let selectedShell = $state('/bin/bash');
let selectedUser = $state('root'); let selectedUser = $state('root');
let customUserInput = $state(''); let customUserInput = $state('');
@@ -193,7 +194,7 @@
// Watch for shell/user changes while connected and trigger reconnect // Watch for shell/user changes while connected and trigger reconnect
$effect(() => { $effect(() => {
if (selectedContainer && connected && terminalComponent) { if (terminalMode === 'exec' && selectedContainer && connected && terminalComponent) {
if (selectedShell !== prevShell || committedUser !== prevUser) { if (selectedShell !== prevShell || committedUser !== prevUser) {
terminalComponent.reconnect(); terminalComponent.reconnect();
} }
@@ -215,6 +216,9 @@
onMount(async () => { onMount(async () => {
customUsers = getCustomUsers(); customUsers = getCustomUsers();
if ($page.url.searchParams.get('mode') === 'attach') {
terminalMode = 'attach';
}
await fetchContainers(); await fetchContainers();
// Check for container ID in URL query parameter // Check for container ID in URL query parameter
@@ -307,6 +311,30 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">Mode:</Label>
<Select.Root type="single" value={terminalMode} onValueChange={(value) => terminalMode = value as TerminalMode}>
<Select.Trigger class="h-9 w-48">
{#if terminalMode === 'attach'}
<Unplug class="w-4 h-4 mr-2 text-muted-foreground" />
<span>Attach to process</span>
{:else}
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
<span>Shell (exec)</span>
{/if}
</Select.Trigger>
<Select.Content>
<Select.Item value="exec" label="Shell (exec)">
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
Shell (exec)
</Select.Item>
<Select.Item value="attach" label="Attach to process">
<Unplug class="w-4 h-4 mr-2 text-muted-foreground" />
Attach to process
</Select.Item>
</Select.Content>
</Select.Root>
</div>
{#if selectedContainer} {#if selectedContainer}
<Button size="sm" variant="ghost" onclick={clearSelection} class="h-9 px-3 text-sm text-muted-foreground hover:text-foreground"> <Button size="sm" variant="ghost" onclick={clearSelection} class="h-9 px-3 text-sm text-muted-foreground hover:text-foreground">
@@ -315,7 +343,8 @@
</Button> </Button>
{/if} {/if}
<!-- Shell selector - always visible --> <!-- Shell selector - only used by exec mode -->
{#if terminalMode === 'exec'}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">Shell:</Label> <Label class="text-sm text-muted-foreground">Shell:</Label>
{#if detectingShells} {#if detectingShells}
@@ -374,7 +403,7 @@
{/if} {/if}
</div> </div>
<!-- User selector - always visible --> <!-- User selector - only used by exec mode -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">User:</Label> <Label class="text-sm text-muted-foreground">User:</Label>
<Select.Root type="single" bind:value={selectedUser} onValueChange={onUserSelectChange}> <Select.Root type="single" bind:value={selectedUser} onValueChange={onUserSelectChange}>
@@ -422,6 +451,7 @@
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
</div> </div>
{/if}
</div> </div>
<!-- Shell output - full height --> <!-- Shell output - full height -->
@@ -433,14 +463,14 @@
<p>Select a container to open shell</p> <p>Select a container to open shell</p>
</div> </div>
</div> </div>
{:else if detectingShells} {:else if terminalMode === 'exec' && detectingShells}
<div class="flex items-center justify-center h-full text-muted-foreground"> <div class="flex items-center justify-center h-full text-muted-foreground">
<div class="text-center"> <div class="text-center">
<Loader2 class="w-12 h-12 mx-auto mb-3 opacity-50 animate-spin" /> <Loader2 class="w-12 h-12 mx-auto mb-3 opacity-50 animate-spin" />
<p>Detecting available shells...</p> <p>Detecting available shells...</p>
</div> </div>
</div> </div>
{:else if !anyShellAvailable} {:else if terminalMode === 'exec' && !anyShellAvailable}
<div class="flex items-center justify-center h-full text-muted-foreground"> <div class="flex items-center justify-center h-full text-muted-foreground">
<div class="text-center"> <div class="text-center">
<AlertCircle class="w-12 h-12 mx-auto mb-3 opacity-50 text-amber-500" /> <AlertCircle class="w-12 h-12 mx-auto mb-3 opacity-50 text-amber-500" />
@@ -499,13 +529,14 @@
</div> </div>
</div> </div>
<div class="flex-1 min-h-0 w-full"> <div class="flex-1 min-h-0 w-full">
{#key `${selectedContainer.id}-${selectedShell}-${committedUser}`} {#key `${selectedContainer.id}-${terminalMode}-${terminalMode === 'exec' ? selectedShell : ''}-${terminalMode === 'exec' ? committedUser : ''}`}
<Terminal <Terminal
bind:this={terminalComponent} bind:this={terminalComponent}
containerId={selectedContainer.id} containerId={selectedContainer.id}
containerName={selectedContainer.name} containerName={selectedContainer.name}
shell={selectedShell} shell={selectedShell}
user={committedUser} user={committedUser}
mode={terminalMode}
{envId} {envId}
fontSize={terminalFontSize} fontSize={terminalFontSize}
/> />
+17 -4
View File
@@ -3,6 +3,7 @@
import { copyToClipboard } from '$lib/utils/clipboard'; import { copyToClipboard } from '$lib/utils/clipboard';
import { themeStore } from '$lib/stores/theme'; import { themeStore } from '$lib/stores/theme';
import { getMonospaceFont } from '$lib/themes'; import { getMonospaceFont } from '$lib/themes';
import type { TerminalMode } from '$lib/types';
// Dynamic imports for browser-only xterm // Dynamic imports for browser-only xterm
let TerminalClass: any; let TerminalClass: any;
@@ -16,11 +17,12 @@
shell: string; shell: string;
user: string; user: string;
envId: number | null; envId: number | null;
mode?: TerminalMode;
fontSize?: number; fontSize?: number;
autoConnect?: boolean; autoConnect?: boolean;
} }
let { containerId, containerName, shell, user, envId, fontSize = 13, autoConnect = true }: Props = $props(); let { containerId, containerName, shell, user, envId, mode = 'exec', fontSize = 13, autoConnect = true }: Props = $props();
let terminal: any = null; let terminal: any = null;
let fitAddon: any = null; let fitAddon: any = null;
@@ -178,13 +180,24 @@
// In production, connect to the same port as the app // In production, connect to the same port as the app
const isDev = import.meta.env.DEV; const isDev = import.meta.env.DEV;
const portPart = isDev ? ':5174' : (window.location.port ? `:${window.location.port}` : ''); const portPart = isDev ? ':5174' : (window.location.port ? `:${window.location.port}` : '');
let wsUrl = `${protocol}//${wsHost}${portPart}/api/containers/${containerId}/exec?shell=${encodeURIComponent(shell)}&user=${encodeURIComponent(user)}`; const params = new URLSearchParams({
shell,
user
});
if (mode === 'attach') {
params.set('mode', 'attach');
}
let wsUrl = `${protocol}//${wsHost}${portPart}/api/containers/${containerId}/exec?${params}`;
if (envId) { if (envId) {
wsUrl += `&envId=${envId}`; wsUrl += `&envId=${envId}`;
} }
terminal.writeln(`\x1b[90mConnecting to ${containerName}...\x1b[0m`); terminal.writeln(`\x1b[90m${mode === 'attach' ? 'Attaching to' : 'Connecting to'} ${containerName}...\x1b[0m`);
terminal.writeln(`\x1b[90mShell: ${shell}, User: ${user || 'default'}\x1b[0m`); if (mode === 'attach') {
terminal.writeln('\x1b[90mDocker attach: container main process\x1b[0m');
} else {
terminal.writeln(`\x1b[90mShell: ${shell}, User: ${user || 'default'}\x1b[0m`);
}
terminal.writeln(''); terminal.writeln('');
ws = new WebSocket(wsUrl); ws = new WebSocket(wsUrl);
+16 -11
View File
@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { X, GripHorizontal, RefreshCw, Copy, Trash2 } from 'lucide-svelte'; import { X, GripHorizontal, RefreshCw, Copy, Trash2 } from 'lucide-svelte';
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import type { TerminalMode } from '$lib/types';
import Terminal from './Terminal.svelte'; import Terminal from './Terminal.svelte';
interface Props { interface Props {
@@ -9,13 +10,14 @@
containerName: string; containerName: string;
shell: string; shell: string;
user: string; user: string;
mode?: TerminalMode;
visible: boolean; visible: boolean;
envId: number | null; envId: number | null;
fillHeight?: boolean; fillHeight?: boolean;
onClose: () => void; onClose: () => void;
} }
let { containerId, containerName, shell, user, visible, envId, fillHeight = false, onClose }: Props = $props(); let { containerId, containerName, shell, user, mode = 'exec', visible, envId, fillHeight = false, onClose }: Props = $props();
let terminalComponent: ReturnType<typeof Terminal>; let terminalComponent: ReturnType<typeof Terminal>;
let panelRef: HTMLDivElement; let panelRef: HTMLDivElement;
@@ -167,7 +169,7 @@
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800 bg-zinc-900/50"> <div class="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800 bg-zinc-900/50">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-xs text-zinc-400">Terminal:</span> <span class="text-xs text-zinc-400">{mode === 'attach' ? 'Attach:' : 'Terminal:'}</span>
<span class="text-xs text-zinc-200 font-medium">{containerName}</span> <span class="text-xs text-zinc-200 font-medium">{containerName}</span>
{#if connected} {#if connected}
<span class="inline-flex items-center gap-1 text-xs text-green-500"> <span class="inline-flex items-center gap-1 text-xs text-green-500">
@@ -229,14 +231,17 @@
<!-- Terminal content --> <!-- Terminal content -->
<div class="flex-1 overflow-hidden p-1"> <div class="flex-1 overflow-hidden p-1">
<Terminal {#key `${containerId}-${mode}-${shell}-${user}`}
bind:this={terminalComponent} <Terminal
{containerId} bind:this={terminalComponent}
{containerName} {containerId}
{shell} {containerName}
{user} {shell}
{envId} {user}
{fontSize} {mode}
/> {envId}
{fontSize}
/>
{/key}
</div> </div>
</div> </div>
+98
View File
@@ -0,0 +1,98 @@
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
import { describe, expect, test } from 'bun:test';
import {
createDockerStreamState,
decodeChunkedDockerBody,
processDockerStreamChunk
} from '../src/lib/server/docker-stream-core';
const HTTP_HEAD = 'HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\n';
const HTTP_HEAD_CHUNKED = 'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n';
/** Build one 8-byte-framed multiplexed frame: type 1=stdout, 2=stderr. */
function frame(type: number, payload: string): Buffer {
const body = Buffer.from(payload, 'utf-8');
const header = Buffer.alloc(8);
header.writeUInt8(type, 0);
header.writeUInt32BE(body.length, 4);
return Buffer.concat([header, body]);
}
describe('processDockerStreamChunk - raw (TTY) stream', () => {
test('passes raw body through once HTTP headers are stripped', () => {
const st = createDockerStreamState(false);
const out = processDockerStreamChunk(Buffer.from(HTTP_HEAD + 'hello world'), st);
expect(out.join('')).toBe('hello world');
});
test('buffers until the full HTTP header terminator arrives', () => {
const st = createDockerStreamState(false);
// header split across two socket events - nothing emitted until \r\n\r\n seen
expect(processDockerStreamChunk(Buffer.from('HTTP/1.1 200 OK\r\n'), st)).toEqual([]);
const out = processDockerStreamChunk(Buffer.from('\r\nlate'), st);
expect(out.join('')).toBe('late');
});
});
describe('processDockerStreamChunk - multiplexed (non-TTY attach) stream', () => {
test('demuxes stdout and stderr frames', () => {
const st = createDockerStreamState(true);
const data = Buffer.concat([Buffer.from(HTTP_HEAD), frame(1, 'out '), frame(2, 'err')]);
expect(processDockerStreamChunk(data, st).join('')).toBe('out err');
});
test('reassembles a frame split across two socket events', () => {
const st = createDockerStreamState(true);
const f = frame(1, 'hello');
// deliver the header + first 2 payload bytes, then the rest
const first = Buffer.concat([Buffer.from(HTTP_HEAD), f.slice(0, 8 + 2)]);
const second = f.slice(8 + 2);
expect(processDockerStreamChunk(first, st)).toEqual([]); // incomplete frame -> nothing yet
expect(processDockerStreamChunk(second, st).join('')).toBe('hello');
});
test('reassembles a frame whose 8-byte header itself is split', () => {
const st = createDockerStreamState(true);
const f = frame(2, 'x');
const first = Buffer.concat([Buffer.from(HTTP_HEAD), f.slice(0, 4)]); // half the header
const second = f.slice(4);
expect(processDockerStreamChunk(first, st)).toEqual([]);
expect(processDockerStreamChunk(second, st).join('')).toBe('x');
});
test('falls back to raw when the framing is not valid multiplexed (proxy stripped it)', () => {
const st = createDockerStreamState(true);
// a byte-0 stream type of 5 is invalid -> raw fallback, and stays raw after
const bogus = Buffer.concat([Buffer.from(HTTP_HEAD), Buffer.from([5, 0, 0, 0, 0, 0, 0, 1]), Buffer.from('raw')]);
const out = processDockerStreamChunk(bogus, st);
expect(out.join('')).toContain('raw');
expect(st.multiplexed).toBe(false);
// subsequent bytes now pass through raw
expect(processDockerStreamChunk(Buffer.from('more'), st).join('')).toBe('more');
});
});
describe('decodeChunkedDockerBody', () => {
test('decodes a chunked body and drops the framing', () => {
const st = createDockerStreamState(false);
// header says chunked; body: "5\r\nhello\r\n"
const out = processDockerStreamChunk(Buffer.from(HTTP_HEAD_CHUNKED + '5\r\nhello\r\n'), st);
expect(out.join('')).toBe('hello');
});
test('reassembles a chunk whose data is split across two events', () => {
const st = createDockerStreamState(false);
processDockerStreamChunk(Buffer.from(HTTP_HEAD_CHUNKED + '5\r\nhel'), st);
const out = processDockerStreamChunk(Buffer.from('lo\r\n'), st);
expect(out.join('')).toBe('hello');
});
test('the 0-size terminating chunk ends the body', () => {
const st = createDockerStreamState(false);
st.headersStripped = true;
st.isChunked = true;
const out = decodeChunkedDockerBody(Buffer.from('3\r\nabc\r\n0\r\n\r\n'), st);
expect(Buffer.concat(out).toString()).toBe('abc');
expect(st.chunkEnded).toBe(true);
});
});
+57 -59
View File
@@ -171,45 +171,20 @@ function resolveDockerTarget(
}; };
} }
// ============ Exec API Helpers ============ // ============ Terminal Stream Helpers ============
function buildExecStartHttpRequest(execId: string, target: DockerTarget): string { function buildDockerStreamHttpRequest(path: string, target: DockerTarget, body = ''): string {
const body = JSON.stringify({ Detach: false, Tty: true });
const tokenHeader = target.type === 'tcp' && target.hawserToken const tokenHeader = target.type === 'tcp' && target.hawserToken
? `X-Hawser-Token: ${target.hawserToken}\r\n` ? `X-Hawser-Token: ${target.hawserToken}\r\n`
: ''; : '';
// Use actual host for proper routing through reverse proxies like Caddy // Use actual host for proper routing through reverse proxies like Caddy
const host = target.host || 'localhost'; const host = target.host || 'localhost';
return `POST /exec/${execId}/start HTTP/1.1\r\nHost: ${host}\r\nContent-Type: application/json\r\n${tokenHeader}Connection: Upgrade\r\nUpgrade: tcp\r\nContent-Length: ${body.length}\r\n\r\n${body}`; return `POST ${path} HTTP/1.1\r\nHost: ${host}\r\nContent-Type: application/json\r\n${tokenHeader}Connection: Upgrade\r\nUpgrade: tcp\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
} }
// ============ Stream Processing ============ // ============ Stream Processing ============
function processTerminalOutput( import { createDockerStreamState, processDockerStreamChunk, type DockerStreamState } from './src/lib/server/docker-stream-core';
data: string,
state: { headersStripped: boolean; isChunked: boolean }
): string | null {
let text = data;
if (!state.headersStripped) {
if (text.toLowerCase().includes('transfer-encoding: chunked')) {
state.isChunked = true;
}
const headerEnd = text.indexOf('\r\n\r\n');
if (headerEnd > -1) {
text = text.slice(headerEnd + 4);
state.headersStripped = true;
} else if (text.startsWith('HTTP/')) {
return null;
}
}
if (state.isChunked && text) {
text = text.replace(/^[0-9a-fA-F]+\r\n/gm, '').replace(/\r\n$/g, '');
}
return text || null;
}
// ============ Hawser Edge Exec Messages ============ // ============ Hawser Edge Exec Messages ============
@@ -396,9 +371,29 @@ async function resizeExecForWs(execId: string, cols: number, rows: number, targe
} }
} }
async function resizeContainerForWs(containerId: string, cols: number, rows: number, target: ReturnType<typeof getDockerTarget>): Promise<void> {
try {
await dockerHttpRequest('POST', `/containers/${encodeURIComponent(containerId)}/resize?h=${rows}&w=${cols}`, target);
} catch {
// Ignore resize errors
}
}
async function getContainerTtyForWs(containerId: string, target: ReturnType<typeof getDockerTarget>): Promise<boolean> {
try {
const response = await dockerHttpRequest('GET', `/containers/${encodeURIComponent(containerId)}/json`, target);
if (response.statusCode === 200) {
return Boolean(JSON.parse(response.body).Config?.Tty);
}
} catch {
// Keep multiplexing enabled if the TTY setting cannot be read.
}
return false;
}
// Map to track Docker streams per WebSocket (keyed by unique connection ID) // Map to track Docker streams per WebSocket (keyed by unique connection ID)
// Includes WebSocket reference for orphan detection // Includes WebSocket reference for orphan detection
const dockerStreams = new Map<string, { stream: any; execId: string; target: ReturnType<typeof getDockerTarget>; state: { isChunked: boolean }; ws: any }>(); const dockerStreams = new Map<string, { stream: any; execId: string | null; containerId: string; mode: 'exec' | 'attach'; target: ReturnType<typeof getDockerTarget>; state: DockerStreamState; ws: any }>();
// Counter for unique WebSocket connection IDs // Counter for unique WebSocket connection IDs
let wsConnectionCounter = 0; let wsConnectionCounter = 0;
@@ -597,6 +592,7 @@ function webSocketPlugin(): Plugin {
const containerIdIndex = pathParts.indexOf('containers') + 1; const containerIdIndex = pathParts.indexOf('containers') + 1;
const containerId = pathParts[containerIdIndex]; const containerId = pathParts[containerIdIndex];
const mode = url.searchParams.get('mode') === 'attach' ? 'attach' : 'exec';
const shell = url.searchParams.get('shell') || '/bin/sh'; const shell = url.searchParams.get('shell') || '/bin/sh';
const user = url.searchParams.get('user') || 'root'; const user = url.searchParams.get('user') || 'root';
const envIdParam = url.searchParams.get('envId'); const envIdParam = url.searchParams.get('envId');
@@ -634,8 +630,13 @@ function webSocketPlugin(): Plugin {
const target = getDockerTarget(envId); const target = getDockerTarget(envId);
try { try {
// Handle Hawser Edge mode differently - use WebSocket protocol // Hawser Edge currently exposes an exec-only terminal protocol.
if (target.type === 'hawser-edge') { if (target.type === 'hawser-edge') {
if (mode === 'attach') {
ws.send(JSON.stringify({ type: 'error', message: 'Container attach is not supported for Edge environments' }));
ws.close();
return;
}
const conn = edgeConnections.get(target.environmentId); const conn = edgeConnections.get(target.environmentId);
if (!conn) { if (!conn) {
ws.send(JSON.stringify({ type: 'error', message: 'Edge agent not connected' })); ws.send(JSON.stringify({ type: 'error', message: 'Edge agent not connected' }));
@@ -653,11 +654,21 @@ function webSocketPlugin(): Plugin {
} }
// Direct Docker connection (unix or tcp/hawser-standard) // Direct Docker connection (unix or tcp/hawser-standard)
const exec = await createExecForWs(containerId, [shell], user, target); let execId: string | null = null;
const execId = exec.Id; let streamPath: string;
let streamBody = '';
let headersStripped = false; let multiplexed = false;
const state = { isChunked: false }; if (mode === 'attach') {
const containerTty = await getContainerTtyForWs(containerId, target);
multiplexed = !containerTty;
streamPath = `/containers/${encodeURIComponent(containerId)}/attach?stream=1&stdin=1&stdout=1&stderr=1`;
} else {
const exec = await createExecForWs(containerId, [shell], user, target);
execId = exec.Id;
streamPath = `/exec/${execId}/start`;
streamBody = JSON.stringify({ Detach: false, Tty: true });
}
const state = createDockerStreamState(multiplexed);
// Create Node.js TCP/Unix socket connection to Docker // Create Node.js TCP/Unix socket connection to Docker
let dockerStream: net.Socket; let dockerStream: net.Socket;
@@ -679,30 +690,13 @@ function webSocketPlugin(): Plugin {
} }
dockerStream.on('connect', () => { dockerStream.on('connect', () => {
const httpRequest = buildExecStartHttpRequest(execId, target); dockerStream.write(buildDockerStreamHttpRequest(streamPath, target, streamBody));
dockerStream.write(httpRequest);
}); });
dockerStream.on('data', (data: Buffer) => { dockerStream.on('data', (data: Buffer) => {
if (ws.readyState === WsWebSocket.OPEN) { if (ws.readyState === WsWebSocket.OPEN) {
let text = data.toString('utf-8'); for (const text of processDockerStreamChunk(data, state)) {
if (!headersStripped) { if (text) ws.send(JSON.stringify({ type: 'output', data: text }));
if (text.toLowerCase().includes('transfer-encoding: chunked')) {
state.isChunked = true;
}
const headerEnd = text.indexOf('\r\n\r\n');
if (headerEnd > -1) {
text = text.slice(headerEnd + 4);
headersStripped = true;
} else if (text.startsWith('HTTP/')) {
return;
}
}
if (state.isChunked && text) {
text = text.replace(/^[0-9a-fA-F]+\r\n/gm, '').replace(/\r\n$/g, '');
}
if (text) {
ws.send(JSON.stringify({ type: 'output', data: text }));
} }
} }
}); });
@@ -721,7 +715,7 @@ function webSocketPlugin(): Plugin {
} }
}); });
dockerStreams.set(connId, { stream: dockerStream, execId, target, state, ws }); dockerStreams.set(connId, { stream: dockerStream, execId, containerId, mode, target, state, ws });
} catch (error: any) { } catch (error: any) {
console.error('[Terminal WS] Connection error:', error?.message || error); console.error('[Terminal WS] Connection error:', error?.message || error);
ws.send(JSON.stringify({ type: 'error', message: error.message })); ws.send(JSON.stringify({ type: 'error', message: error.message }));
@@ -780,8 +774,12 @@ function webSocketPlugin(): Plugin {
const msg = JSON.parse(message.toString()); const msg = JSON.parse(message.toString());
if (msg.type === 'input' && d.stream) { if (msg.type === 'input' && d.stream) {
d.stream.write(msg.data); d.stream.write(msg.data);
} else if (msg.type === 'resize' && d.execId) { } else if (msg.type === 'resize') {
resizeExecForWs(d.execId, msg.cols, msg.rows, d.target); if (d.mode === 'attach') {
resizeContainerForWs(d.containerId, msg.cols, msg.rows, d.target);
} else if (d.execId) {
resizeExecForWs(d.execId, msg.cols, msg.rows, d.target);
}
} }
} catch { } catch {
if (d.stream) { if (d.stream) {