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
*
* 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
*
* Usage: node ./server.js
@@ -235,11 +235,139 @@ wss.on('connection', (ws, req) => {
* - __terminalGetTarget(envId) - resolves connection info from environment
* - __terminalCreateExec(containerId, shell, user, envId) - creates exec via Docker API
* - __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) {
const pathParts = url.pathname.split('/');
const containerIdIndex = pathParts.indexOf('containers') + 1;
const containerId = pathParts[containerIdIndex];
const mode = url.searchParams.get('mode') === 'attach' ? 'attach' : 'exec';
const shell = url.searchParams.get('shell') || '/bin/sh';
const user = url.searchParams.get('user') || 'root';
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' };
}
// Handle Hawser Edge mode - relay through agent WebSocket
// Hawser Edge currently exposes an exec-only terminal protocol.
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);
return;
}
// Create exec instance via SvelteKit app (handles all connection types)
let execId;
if (typeof globalThis.__terminalCreateExec === 'function') {
execId = await globalThis.__terminalCreateExec(containerId, shell, user, envId);
let execId = null;
let streamPath;
let streamBody = '';
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 {
// Fallback: create exec directly via local socket
execId = await createExecLocal(containerId, shell, user, target.socketPath || '/var/run/docker.sock');
// Create exec instance via SvelteKit app (handles all connection types)
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
const startBody = JSON.stringify({ Detach: false, Tty: true });
// Open raw bidirectional stream to Docker for the attach or exec session.
let dockerStream;
if (target.type === 'socket') {
@@ -333,45 +483,16 @@ async function handleTerminalConnection(ws, url, connId) {
}
dockerStream.on('connect', () => {
const host = target.host || 'localhost';
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
);
dockerStream.write(buildDockerStreamRequest(streamPath, target, streamBody));
});
let headersStripped = false;
let isChunked = false;
const streamState = createDockerStreamState(multiplexed);
dockerStream.on('data', (data) => {
if (ws.readyState !== 1) return;
let text = data.toString('utf-8');
if (!headersStripped) {
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 }));
for (const text of processDockerStreamChunk(data, streamState)) {
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) {
dockerStream.write(msg.data);
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
// Use SvelteKit's resize function if available (works for all connection types)
if (typeof globalThis.__terminalResizeExec === 'function') {
globalThis.__terminalResizeExec(execId, msg.cols, msg.rows, envId).catch(() => {});
if (mode === 'attach') {
if (typeof globalThis.__terminalResizeContainer === 'function') {
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 {
// Fallback: resize via local socket
const socketPath = target.socketPath || '/var/run/docker.sock';
const resizeReq = httpRequest({
socketPath,
path: `/exec/${execId}/resize?h=${msg.rows}&w=${msg.cols}`,
method: 'POST',
}, () => {});
resizeReq.on('error', () => {});
resizeReq.end();
// Use SvelteKit's resize function if available (works for all connection types)
if (typeof globalThis.__terminalResizeExec === 'function') {
globalThis.__terminalResizeExec(execId, msg.cols, msg.rows, envId).catch(() => {});
} else {
// Fallback: resize via local socket
const socketPath = target.socketPath || '/var/run/docker.sock';
const resizeReq = httpRequest({
socketPath,
path: `/exec/${execId}/resize?h=${msg.rows}&w=${msg.cols}`,
method: 'POST',
}, () => {});
resizeReq.on('error', () => {});
resizeReq.end();
}
}
}
} 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
* This is used by the terminal to connect directly to the Docker API
@@ -4561,6 +4570,8 @@ declare global {
}>) | 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 __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) => {
@@ -4598,6 +4609,15 @@ globalThis.__terminalResizeExec = async (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
export async function getDiskUsage(envId?: number | null) {
return dockerJsonRequest('/system/df', {}, envId);
+3
View File
@@ -14,6 +14,9 @@ export interface NewerVersion {
digest?: string;
}
/** Terminal connection mode: an interactive `docker exec` shell, or `docker attach` to PID 1. */
export type TerminalMode = 'exec' | 'attach';
export interface ContainerInfo {
id: string;
name: string;
+110 -72
View File
@@ -67,7 +67,8 @@
Copy,
Loader2,
AlertCircle,
Tag
Tag,
Unplug
} from 'lucide-svelte';
import { broom } from '@lucide/lab';
import { copyToClipboard } from '$lib/utils/clipboard';
@@ -83,7 +84,7 @@
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
import VersionUpdateBadge from '$lib/components/VersionUpdateBadge.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 { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
import { containerStore } from '$lib/stores/containers';
@@ -258,10 +259,12 @@
containerName: string;
shell: string;
user: string;
mode: TerminalMode;
}
let activeTerminals = $state<ActiveTerminal[]>([]);
let currentTerminalContainerId = $state<string | null>(null);
let terminalPopoverStates = $state<Record<string, boolean>>({});
let terminalMode = $state<TerminalMode>('exec');
let terminalShell = $state('/bin/bash');
let terminalUser = $state('root');
let terminalCustomUser = $state('');
@@ -1121,19 +1124,23 @@
const savedUser = getSavedUser(container.id);
terminalUser = savedUser ?? 'root';
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
terminalPopoverStates[container.id] = true;
}
}
function startTerminal(container: ContainerInfo) {
saveUserForContainer(container.id, terminalUser);
if (terminalMode === 'exec') saveUserForContainer(container.id, terminalUser);
terminalCustomUsers = getCustomUsers();
const terminal: ActiveTerminal = {
containerId: container.id,
containerName: container.name,
shell: terminalShell,
user: terminalUser
user: terminalUser,
mode: terminalMode
};
activeTerminals = [...activeTerminals, terminal];
currentTerminalContainerId = container.id;
@@ -1434,7 +1441,7 @@
onkeydown={(e) => e.key === 'Escape' && (searchQuery = '')}
class="pl-8 h-8 w-48 text-sm"
/>
</div>
</div>
<!-- Status filter (multi-select). The synthetic 'update-available'
entry appears once at least one container has a pending update,
and ANDs with selected real states (#1063). -->
@@ -2160,7 +2167,7 @@
{:else}
<Popover.Root open={terminalPopoverStates[container.id] ?? false} onOpenChange={(open) => {
terminalPopoverStates[container.id] = open;
if (open) detectContainerShells(container.id);
if (open && terminalMode === 'exec') detectContainerShells(container.id);
}}>
<Popover.Trigger
onclick={(e: MouseEvent) => e.stopPropagation()}
@@ -2175,12 +2182,12 @@
<span class="text-xs font-medium truncate" title={container.name}>{container.name}</span>
</div>
</div>
{#if detectingShellsFor === container.id}
{#if terminalMode === 'exec' && detectingShellsFor === container.id}
<div class="p-4 text-center">
<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>
</div>
{:else if !anyShellAvailableFor(container.id)}
{:else if terminalMode === 'exec' && !anyShellAvailableFor(container.id)}
<div class="p-4 text-center">
<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>
@@ -2189,76 +2196,105 @@
{:else}
<div class="p-3 space-y-3">
<div class="space-y-1.5">
<Label class="text-xs">Shell</Label>
<Select.Root type="single" bind:value={terminalShell}>
<Label class="text-xs">Mode</Label>
<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">
<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>
{#if terminalMode === 'attach'}
<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.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 value="exec" label="Shell (exec)">
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
Shell (exec)
</Select.Item>
<Select.Item value="attach" label="Attach to process">
<Unplug class="w-3 h-3 mr-1.5 text-muted-foreground" />
Attach to process
</Select.Item>
</Select.Content>
</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>
{/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>
{/each}
{#if terminalCustomUsers.length > 0}
{#if terminalCustomUsers.length > 0}
<div class="h-px bg-border my-1"></div>
{#each terminalCustomUsers as cu}
<div class="flex items-center group">
<Select.Item value={cu} label={cu} class="flex-1">
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
{cu}
</Select.Item>
<button
type="button"
class="p-1 mr-1 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
onclick={(e) => { e.stopPropagation(); e.preventDefault(); removeCustomUser(cu); terminalCustomUsers = getCustomUsers(); if (terminalUser === cu) { terminalUser = 'root'; } }}
title="Remove user"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
{/each}
{/if}
<div class="h-px bg-border my-1"></div>
{#each terminalCustomUsers as cu}
<div class="flex items-center group">
<Select.Item value={cu} label={cu} class="flex-1">
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
{cu}
</Select.Item>
<button
type="button"
class="p-1 mr-1 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
onclick={(e) => { e.stopPropagation(); e.preventDefault(); removeCustomUser(cu); terminalCustomUsers = getCustomUsers(); if (terminalUser === cu) { terminalUser = 'root'; } }}
title="Remove user"
>
<Trash2 class="w-3 h-3" />
</button>
</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)}>
<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>
{/if}
<Button size="sm" class="w-full h-7 text-xs" onclick={() => startTerminal(container)}>
<Terminal class="w-3 h-3" />
Connect
</Button>
@@ -2436,6 +2472,7 @@
containerName={t.containerName}
shell={t.shell}
user={t.user}
mode={t.mode}
visible={t.containerId === currentTerminalContainerId}
envId={envId}
fillHeight={true}
@@ -2473,6 +2510,7 @@
containerName={t.containerName}
shell={t.shell}
user={t.user}
mode={t.mode}
visible={t.containerId === currentTerminalContainerId}
envId={envId}
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 PageHeader from '$lib/components/PageHeader.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 Terminal from './Terminal.svelte';
import { NoEnvironment } from '$lib/components/ui/empty-state';
@@ -34,6 +34,7 @@
let detectingShells = $state(false);
// Shell/user options
let terminalMode = $state<TerminalMode>('exec');
let selectedShell = $state('/bin/bash');
let selectedUser = $state('root');
let customUserInput = $state('');
@@ -193,7 +194,7 @@
// Watch for shell/user changes while connected and trigger reconnect
$effect(() => {
if (selectedContainer && connected && terminalComponent) {
if (terminalMode === 'exec' && selectedContainer && connected && terminalComponent) {
if (selectedShell !== prevShell || committedUser !== prevUser) {
terminalComponent.reconnect();
}
@@ -215,6 +216,9 @@
onMount(async () => {
customUsers = getCustomUsers();
if ($page.url.searchParams.get('mode') === 'attach') {
terminalMode = 'attach';
}
await fetchContainers();
// Check for container ID in URL query parameter
@@ -307,6 +311,30 @@
</div>
{/if}
</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}
<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>
{/if}
<!-- Shell selector - always visible -->
<!-- Shell selector - only used by exec mode -->
{#if terminalMode === 'exec'}
<div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">Shell:</Label>
{#if detectingShells}
@@ -374,7 +403,7 @@
{/if}
</div>
<!-- User selector - always visible -->
<!-- User selector - only used by exec mode -->
<div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">User:</Label>
<Select.Root type="single" bind:value={selectedUser} onValueChange={onUserSelectChange}>
@@ -422,6 +451,7 @@
</Select.Content>
</Select.Root>
</div>
{/if}
</div>
<!-- Shell output - full height -->
@@ -433,14 +463,14 @@
<p>Select a container to open shell</p>
</div>
</div>
{:else if detectingShells}
{:else if terminalMode === 'exec' && detectingShells}
<div class="flex items-center justify-center h-full text-muted-foreground">
<div class="text-center">
<Loader2 class="w-12 h-12 mx-auto mb-3 opacity-50 animate-spin" />
<p>Detecting available shells...</p>
</div>
</div>
{:else if !anyShellAvailable}
{:else if terminalMode === 'exec' && !anyShellAvailable}
<div class="flex items-center justify-center h-full text-muted-foreground">
<div class="text-center">
<AlertCircle class="w-12 h-12 mx-auto mb-3 opacity-50 text-amber-500" />
@@ -499,13 +529,14 @@
</div>
</div>
<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
bind:this={terminalComponent}
containerId={selectedContainer.id}
containerName={selectedContainer.name}
shell={selectedShell}
user={committedUser}
mode={terminalMode}
{envId}
fontSize={terminalFontSize}
/>
+17 -4
View File
@@ -3,6 +3,7 @@
import { copyToClipboard } from '$lib/utils/clipboard';
import { themeStore } from '$lib/stores/theme';
import { getMonospaceFont } from '$lib/themes';
import type { TerminalMode } from '$lib/types';
// Dynamic imports for browser-only xterm
let TerminalClass: any;
@@ -16,11 +17,12 @@
shell: string;
user: string;
envId: number | null;
mode?: TerminalMode;
fontSize?: number;
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 fitAddon: any = null;
@@ -178,13 +180,24 @@
// In production, connect to the same port as the app
const isDev = import.meta.env.DEV;
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) {
wsUrl += `&envId=${envId}`;
}
terminal.writeln(`\x1b[90mConnecting to ${containerName}...\x1b[0m`);
terminal.writeln(`\x1b[90mShell: ${shell}, User: ${user || 'default'}\x1b[0m`);
terminal.writeln(`\x1b[90m${mode === 'attach' ? 'Attaching to' : 'Connecting to'} ${containerName}...\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('');
ws = new WebSocket(wsUrl);
+16 -11
View File
@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import { X, GripHorizontal, RefreshCw, Copy, Trash2 } from 'lucide-svelte';
import * as Select from '$lib/components/ui/select';
import type { TerminalMode } from '$lib/types';
import Terminal from './Terminal.svelte';
interface Props {
@@ -9,13 +10,14 @@
containerName: string;
shell: string;
user: string;
mode?: TerminalMode;
visible: boolean;
envId: number | null;
fillHeight?: boolean;
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 panelRef: HTMLDivElement;
@@ -167,7 +169,7 @@
<!-- 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 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>
{#if connected}
<span class="inline-flex items-center gap-1 text-xs text-green-500">
@@ -229,14 +231,17 @@
<!-- Terminal content -->
<div class="flex-1 overflow-hidden p-1">
<Terminal
bind:this={terminalComponent}
{containerId}
{containerName}
{shell}
{user}
{envId}
{fontSize}
/>
{#key `${containerId}-${mode}-${shell}-${user}`}
<Terminal
bind:this={terminalComponent}
{containerId}
{containerName}
{shell}
{user}
{mode}
{envId}
{fontSize}
/>
{/key}
</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 {
const body = JSON.stringify({ Detach: false, Tty: true });
function buildDockerStreamHttpRequest(path: string, target: DockerTarget, body = ''): string {
const tokenHeader = target.type === 'tcp' && target.hawserToken
? `X-Hawser-Token: ${target.hawserToken}\r\n`
: '';
// Use actual host for proper routing through reverse proxies like Caddy
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 ============
function processTerminalOutput(
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;
}
import { createDockerStreamState, processDockerStreamChunk, type DockerStreamState } from './src/lib/server/docker-stream-core';
// ============ 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)
// 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
let wsConnectionCounter = 0;
@@ -597,6 +592,7 @@ function webSocketPlugin(): Plugin {
const containerIdIndex = pathParts.indexOf('containers') + 1;
const containerId = pathParts[containerIdIndex];
const mode = url.searchParams.get('mode') === 'attach' ? 'attach' : 'exec';
const shell = url.searchParams.get('shell') || '/bin/sh';
const user = url.searchParams.get('user') || 'root';
const envIdParam = url.searchParams.get('envId');
@@ -634,8 +630,13 @@ function webSocketPlugin(): Plugin {
const target = getDockerTarget(envId);
try {
// Handle Hawser Edge mode differently - use WebSocket protocol
// Hawser Edge currently exposes an exec-only terminal protocol.
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);
if (!conn) {
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)
const exec = await createExecForWs(containerId, [shell], user, target);
const execId = exec.Id;
let headersStripped = false;
const state = { isChunked: false };
let execId: string | null = null;
let streamPath: string;
let streamBody = '';
let multiplexed = 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
let dockerStream: net.Socket;
@@ -679,30 +690,13 @@ function webSocketPlugin(): Plugin {
}
dockerStream.on('connect', () => {
const httpRequest = buildExecStartHttpRequest(execId, target);
dockerStream.write(httpRequest);
dockerStream.write(buildDockerStreamHttpRequest(streamPath, target, streamBody));
});
dockerStream.on('data', (data: Buffer) => {
if (ws.readyState === WsWebSocket.OPEN) {
let text = data.toString('utf-8');
if (!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);
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 }));
for (const text of processDockerStreamChunk(data, state)) {
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) {
console.error('[Terminal WS] Connection error:', error?.message || error);
ws.send(JSON.stringify({ type: 'error', message: error.message }));
@@ -780,8 +774,12 @@ function webSocketPlugin(): Plugin {
const msg = JSON.parse(message.toString());
if (msg.type === 'input' && d.stream) {
d.stream.write(msg.data);
} else if (msg.type === 'resize' && d.execId) {
resizeExecForWs(d.execId, msg.cols, msg.rows, d.target);
} else if (msg.type === 'resize') {
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 {
if (d.stream) {