add a docker attach terminal mode for hawser-edge environments

This commit is contained in:
Jarek Krochmalski committed 2026-09-13 15:31:28 +02:00
1 parent 27e260ee21
commit 3e45ca2610
5 files changed
+102 -46

No files matched your search

+36 -13
View File
@@ -153,8 +153,16 @@ globalThis.__terminalHandleExecMessage = (msg) => {
}
if (msg.type === 'exec_output') {
const data = Buffer.from(msg.data, 'base64').toString('utf-8');
session.ws.send(JSON.stringify({ type: 'output', data }));
const bytes = Buffer.from(msg.data, 'base64');
// Attach sessions carry a stream state: demultiplex non-TTY frames before
// forwarding. Exec sessions are raw TTY text and pass straight through.
if (session.streamState) {
for (const text of processDockerStreamChunk(bytes, session.streamState)) {
if (text) session.ws.send(JSON.stringify({ type: 'output', data: text }));
}
} else {
session.ws.send(JSON.stringify({ type: 'output', data: bytes.toString('utf-8') }));
}
return;
}
@@ -430,14 +438,21 @@ async function handleTerminalConnection(ws, url, connId) {
target = { type: 'socket', connectionType: 'socket', socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' };
}
// Hawser Edge currently exposes an exec-only terminal protocol.
// Hawser Edge relays exec and (for capable agents) attach through the agent.
if (target.connectionType === 'hawser-edge') {
let multiplexed = false;
if (mode === 'attach') {
ws.send(JSON.stringify({ type: 'error', message: 'Container attach is not supported for Edge environments' }));
ws.close();
return;
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;
}
handleEdgeExec(ws, connId, containerId, shell, user, target.environmentId);
handleEdgeExec(ws, connId, containerId, shell, user, target.environmentId, mode, multiplexed);
return;
}
@@ -584,20 +599,27 @@ async function handleTerminalConnection(ws, url, connId) {
}
/**
* Handle Hawser Edge exec session.
* Sends exec commands through the Hawser WebSocket relay.
* Handle Hawser Edge exec or attach session.
* Sends exec/attach commands through the Hawser WebSocket relay. Attach reuses the
* exec_* protocol with attach:true; for non-TTY containers the agent pipes a
* multiplexed stream, demultiplexed here via the session's stream state.
*/
function handleEdgeExec(ws, connId, containerId, shell, user, environmentId) {
function handleEdgeExec(ws, connId, containerId, shell, user, environmentId, mode = 'exec', multiplexed = false) {
if (typeof globalThis.__hawserSendMessage !== 'function') {
ws.send(JSON.stringify({ type: 'error', message: 'Edge agent handler not ready' }));
ws.close();
return;
}
const attach = mode === 'attach';
const execId = randomUUID();
edgeExecSessions.set(execId, { ws, execId, environmentId });
// Attach output is a raw hijacked stream (no HTTP headers); only the multiplexing
// demux is needed, so seed the state with headersStripped already true.
const streamState = attach ? createDockerStreamState(multiplexed) : null;
if (streamState) streamState.headersStripped = true;
edgeExecSessions.set(execId, { ws, execId, environmentId, streamState });
// Send exec_start to the Hawser agent
// Send exec_start (attach:true reuses the exec relay) to the Hawser agent
const execStartMsg = JSON.stringify({
type: 'exec_start',
execId,
@@ -605,7 +627,8 @@ function handleEdgeExec(ws, connId, containerId, shell, user, environmentId) {
cmd: shell,
user,
cols: 120,
rows: 30
rows: 30,
attach
});
const sent = globalThis.__hawserSendMessage(environmentId, execStartMsg);
+1 -8
View File
@@ -134,9 +134,6 @@
// Derived: current environment details for reactive port URL generation
const currentEnvDetails = $derived($environments.find(e => e.id === $currentEnvironment?.id) ?? null);
// hawser-edge exposes an exec-only terminal protocol (the agent has no attach path),
// so don't offer Attach there - the server would reject it anyway.
const attachSupported = $derived(currentEnvDetails?.connectionType !== 'hawser-edge');
// Search and sort state - initialize from URL for persistence across navigation
const initialSearch = $page.url.searchParams.get('search')
@@ -1133,8 +1130,7 @@
}
function startTerminal(container: ContainerInfo) {
// Edge is exec-only; never start an attach session there even if a stale mode lingers.
const mode: TerminalMode = attachSupported ? terminalMode : 'exec';
const mode: TerminalMode = terminalMode;
if (mode === 'exec') saveUserForContainer(container.id, terminalUser);
terminalCustomUsers = getCustomUsers();
const terminal: ActiveTerminal = {
@@ -2205,8 +2201,6 @@
</div>
{:else}
<div class="p-3 space-y-3">
<!-- Mode picker only when attach is possible; edge is exec-only, so it's hidden there. -->
{#if attachSupported}
<div class="space-y-1.5">
<Label class="text-xs">Mode</Label>
<Select.Root type="single" value={terminalMode} onValueChange={(value) => {
@@ -2234,7 +2228,6 @@
</Select.Content>
</Select.Root>
</div>
{/if}
{#if terminalMode === 'exec'}
<div class="space-y-1.5">
<Label class="text-xs">Shell</Label>
+2 -10
View File
@@ -33,11 +33,6 @@
let shellDetection = $state<ShellDetectionResult | null>(null);
let detectingShells = $state(false);
// hawser-edge exposes an exec-only terminal protocol, so attach isn't offered there.
const attachSupported = $derived(
($environments.find(e => e.id === $currentEnvironment?.id)?.connectionType ?? 'socket') !== 'hawser-edge'
);
// Shell/user options
let terminalMode = $state<TerminalMode>('exec');
let selectedShell = $state('/bin/bash');
@@ -221,7 +216,7 @@
onMount(async () => {
customUsers = getCustomUsers();
if ($page.url.searchParams.get('mode') === 'attach' && attachSupported) {
if ($page.url.searchParams.get('mode') === 'attach') {
terminalMode = 'attach';
}
await fetchContainers();
@@ -316,8 +311,6 @@
</div>
{/if}
</div>
<!-- Mode picker only when attach is possible; edge is exec-only, so it's hidden there. -->
{#if attachSupported}
<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}>
@@ -342,7 +335,6 @@
</Select.Content>
</Select.Root>
</div>
{/if}
{#if selectedContainer}
<Button size="sm" variant="ghost" onclick={clearSelection} class="h-9 px-3 text-sm text-muted-foreground hover:text-foreground">
@@ -544,7 +536,7 @@
containerName={selectedContainer.name}
shell={selectedShell}
user={committedUser}
mode={attachSupported ? terminalMode : 'exec'}
mode={terminalMode}
{envId}
fontSize={terminalFontSize}
/>
+29
View File
@@ -128,3 +128,32 @@ describe('decodeChunkedDockerBody', () => {
expect(st.chunkEnded).toBe(true);
});
});
// Edge attach seeds the state with headersStripped=true and feeds RAW hijacked bytes
// (no HTTP response header): non-TTY output is multiplexed, TTY output is raw. This
// mirrors handleEdgeExec / __terminalHandleExecMessage exactly.
describe('processDockerStreamChunk - edge attach seeding (headersStripped, no HTTP header)', () => {
function attachState(multiplexed: boolean) {
const st = createDockerStreamState(multiplexed);
st.headersStripped = true;
return st;
}
test('demuxes multiplexed frames fed directly (non-TTY attach)', () => {
const st = attachState(true);
const out = processDockerStreamChunk(Buffer.concat([frame(1, 'out '), frame(2, 'err')]), st);
expect(out.join('')).toBe('out err');
});
test('passes raw bytes through unchanged (TTY attach)', () => {
const st = attachState(false);
expect(processDockerStreamChunk(Buffer.from('hello'), st).join('')).toBe('hello');
});
test('reassembles a frame split across two chunks', () => {
const st = attachState(true);
const f = frame(1, 'split');
expect(processDockerStreamChunk(f.subarray(0, 4), st)).toEqual([]);
expect(processDockerStreamChunk(f.subarray(4), st).join('')).toBe('split');
});
});
+34 -15
View File
@@ -188,8 +188,8 @@ import { createDockerStreamState, processDockerStreamChunk, type DockerStreamSta
// ============ Hawser Edge Exec Messages ============
function createExecStartMessage(execId: string, containerId: string, shell: string, user: string, cols = 120, rows = 30) {
return { type: 'exec_start', execId, containerId, cmd: shell, user, cols, rows };
function createExecStartMessage(execId: string, containerId: string, shell: string, user: string, cols = 120, rows = 30, attach = false) {
return { type: 'exec_start', execId, containerId, cmd: shell, user, cols, rows, attach };
}
function createExecInputMessage(execId: string, data: string) {
@@ -399,7 +399,7 @@ const dockerStreams = new Map<string, { stream: any; execId: string | null; cont
let wsConnectionCounter = 0;
// Map to track Edge exec sessions (execId -> frontend WebSocket)
const edgeExecSessions = new Map<string, { ws: any; execId: string; environmentId: number }>();
const edgeExecSessions = new Map<string, { ws: any; execId: string; environmentId: number; streamState?: DockerStreamState }>();
// Cleanup interval reference - only started in dev mode
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
@@ -630,13 +630,8 @@ function webSocketPlugin(): Plugin {
const target = getDockerTarget(envId);
try {
// Hawser Edge currently exposes an exec-only terminal protocol.
// Hawser Edge relays exec and (for capable agents) attach.
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' }));
@@ -644,11 +639,28 @@ function webSocketPlugin(): Plugin {
return;
}
const attach = mode === 'attach';
let streamState: DockerStreamState | undefined;
if (attach) {
let containerTty = false;
try {
if (typeof globalThis.__terminalGetContainerTty === 'function') {
containerTty = await globalThis.__terminalGetContainerTty(containerId, envId);
}
} catch {
// Keep multiplexing enabled if the TTY setting cannot be read.
}
// Attach output is a raw hijacked stream (no HTTP headers); only demux
// is needed, so seed the state with headersStripped already true.
streamState = createDockerStreamState(!containerTty);
streamState.headersStripped = true;
}
const execId = crypto.randomUUID();
edgeExecSessions.set(execId, { ws, execId, environmentId: target.environmentId });
edgeExecSessions.set(execId, { ws, execId, environmentId: target.environmentId, streamState });
meta.edgeExecId = execId;
const execStartMsg = createExecStartMessage(execId, containerId, shell, user);
const execStartMsg = createExecStartMessage(execId, containerId, shell, user, 120, 30, attach);
conn.ws.send(JSON.stringify(execStartMsg));
return;
}
@@ -1173,12 +1185,19 @@ async function handleHawserMessage(ws: any, msg: any) {
// Frontend doesn't need explicit ready message, it's already waiting for output
}
} else if (msg.type === 'exec_output') {
// Terminal output from exec session
// Terminal output from exec/attach session
const session = edgeExecSessions.get(msg.execId);
if (session?.ws?.readyState === 1) {
// Decode base64 data
const data = Buffer.from(msg.data, 'base64').toString('utf-8');
session.ws.send(JSON.stringify({ type: 'output', data }));
const bytes = Buffer.from(msg.data, 'base64');
// Attach sessions carry a stream state: demultiplex non-TTY frames.
// Exec sessions are raw TTY text and pass straight through.
if (session.streamState) {
for (const text of processDockerStreamChunk(bytes, session.streamState)) {
if (text) session.ws.send(JSON.stringify({ type: 'output', data: text }));
}
} else {
session.ws.send(JSON.stringify({ type: 'output', data: bytes.toString('utf-8') }));
}
}
} else if (msg.type === 'exec_end') {
// Exec session ended