add a docker attach terminal mode for hawser-edge environments
This commit is contained in:
5 files changed
+102
-46
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user