diff --git a/server.js b/server.js
index 98bf2b2..b9bdb00 100644
--- a/server.js
+++ b/server.js
@@ -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);
diff --git a/src/routes/containers/+page.svelte b/src/routes/containers/+page.svelte
index a0663b9..1910e32 100644
--- a/src/routes/containers/+page.svelte
+++ b/src/routes/containers/+page.svelte
@@ -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 @@
{:else}
-
- {#if attachSupported}
{
@@ -2234,7 +2228,6 @@
- {/if}
{#if terminalMode === 'exec'}
diff --git a/src/routes/terminal/+page.svelte b/src/routes/terminal/+page.svelte
index 6c8b27f..32e9e46 100644
--- a/src/routes/terminal/+page.svelte
+++ b/src/routes/terminal/+page.svelte
@@ -33,11 +33,6 @@
let shellDetection = $state(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('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 @@
{/if}
-
- {#if attachSupported}
terminalMode = value as TerminalMode}>
@@ -342,7 +335,6 @@
- {/if}
{#if selectedContainer}