diff --git a/server.js b/server.js index b9bdb00..97678f4 100644 --- a/server.js +++ b/server.js @@ -267,6 +267,13 @@ function buildDockerStreamRequest(path, target, body = '') { ); } +// Mirrors translateAttachInput in docker-stream-core.ts: for non-TTY attach map a lone +// \r (xterm Enter) to \n (no pty to do it); exec / TTY attach pass through. Keep in sync. +function translateAttachInput(data, nonTtyAttach) { + if (!nonTtyAttach) return data; + return data.replace(/\r(?!\n)/g, '\n'); +} + function createDockerStreamState(multiplexed = false) { return { headersStripped: false, @@ -538,7 +545,8 @@ async function handleTerminalConnection(ws, url, connId) { try { const msg = JSON.parse(data.toString()); if (msg.type === 'input' && msg.data) { - dockerStream.write(msg.data); + // Non-TTY attach has no pty to convert Enter (\r) to a newline. + dockerStream.write(translateAttachInput(msg.data, multiplexed)); } else if (msg.type === 'resize' && msg.cols && msg.rows) { if (mode === 'attach') { if (typeof globalThis.__terminalResizeContainer === 'function') { @@ -644,10 +652,12 @@ function handleEdgeExec(ws, connId, containerId, shell, user, environmentId, mod try { const msg = JSON.parse(data.toString()); if (msg.type === 'input' && msg.data) { + // Non-TTY attach has no pty to convert Enter (\r) to a newline. + const inputData = translateAttachInput(msg.data, attach && multiplexed); const inputMsg = JSON.stringify({ type: 'exec_input', execId, - data: Buffer.from(msg.data).toString('base64') + data: Buffer.from(inputData).toString('base64') }); globalThis.__hawserSendMessage(environmentId, inputMsg); } else if (msg.type === 'resize' && msg.cols && msg.rows) { diff --git a/src/lib/server/docker-stream-core.ts b/src/lib/server/docker-stream-core.ts index f14d329..fb505c1 100644 --- a/src/lib/server/docker-stream-core.ts +++ b/src/lib/server/docker-stream-core.ts @@ -136,3 +136,18 @@ export function processDockerStreamChunk(data: Buffer | Uint8Array | string, sta return output; } + +/** + * Translate terminal input before writing it to the container's stdin. xterm sends a + * carriage return (\r) when Enter is pressed; with a TTY the pty converts that to a + * newline, but attach to a container started without a tty has no pty, so a shell + * `read` never sees an end-of-line and the session looks unresponsive. When + * `nonTtyAttach` is set, map a lone \r to \n (leaving an existing \r\n alone) so Enter + * ends a line. For exec and TTY attach (nonTtyAttach false) the input passes through + * verbatim. The gate lives here so it is unit-tested alongside the transform, rather + * than only in the inline call-site ternaries. + */ +export function translateAttachInput(data: string, nonTtyAttach: boolean): string { + if (!nonTtyAttach) return data; + return data.replace(/\r(?!\n)/g, '\n'); +} diff --git a/tests/docker-stream-core.test.ts b/tests/docker-stream-core.test.ts index f67cfb0..c7a749f 100644 --- a/tests/docker-stream-core.test.ts +++ b/tests/docker-stream-core.test.ts @@ -3,7 +3,8 @@ import { describe, expect, test } from 'bun:test'; import { createDockerStreamState, decodeChunkedDockerBody, - processDockerStreamChunk + processDockerStreamChunk, + translateAttachInput } 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'; @@ -157,3 +158,32 @@ describe('processDockerStreamChunk - edge attach seeding (headersStripped, no HT expect(processDockerStreamChunk(f.subarray(4), st).join('')).toBe('split'); }); }); + +describe('translateAttachInput - CR to LF gated on non-TTY attach', () => { + // nonTtyAttach=true: attach to a container with no pty (the CR->LF case). + test('maps a lone carriage return (xterm Enter) to a newline', () => { + expect(translateAttachInput('ls\r', true)).toBe('ls\n'); + }); + + test('leaves an existing CRLF alone (no doubled newline)', () => { + expect(translateAttachInput('a\r\nb', true)).toBe('a\r\nb'); + }); + + test('translates multiple lone CRs', () => { + expect(translateAttachInput('one\rtwo\rthree\r', true)).toBe('one\ntwo\nthree\n'); + }); + + test('passes plain text and bare LF through unchanged', () => { + expect(translateAttachInput('hello\n', true)).toBe('hello\n'); + expect(translateAttachInput('no-eol', true)).toBe('no-eol'); + }); + + // nonTtyAttach=false: exec or TTY attach - input MUST pass through verbatim, + // including a lone \r (the pty/exec handles line-ending itself). This guards the + // gate: a flipped call site would corrupt exec keystrokes and this would fail. + test('passes a lone CR through UNCHANGED for exec / TTY attach', () => { + expect(translateAttachInput('ls\r', false)).toBe('ls\r'); + expect(translateAttachInput('\r', false)).toBe('\r'); + expect(translateAttachInput('a\rb\rc', false)).toBe('a\rb\rc'); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 53ba8b5..247cdac 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -184,7 +184,7 @@ function buildDockerStreamHttpRequest(path: string, target: DockerTarget, body = // ============ Stream Processing ============ -import { createDockerStreamState, processDockerStreamChunk, type DockerStreamState } from './src/lib/server/docker-stream-core'; +import { createDockerStreamState, processDockerStreamChunk, translateAttachInput, type DockerStreamState } from './src/lib/server/docker-stream-core'; // ============ Hawser Edge Exec Messages ============ @@ -764,7 +764,9 @@ function webSocketPlugin(): Plugin { try { const msg = JSON.parse(message.toString()); if (msg.type === 'input') { - conn.ws.send(JSON.stringify(createExecInputMessage(edgeExecId, msg.data))); + // Non-TTY attach has no pty to convert Enter (\r) to a newline. + const inputData = translateAttachInput(msg.data, !!session.streamState?.multiplexed); + conn.ws.send(JSON.stringify(createExecInputMessage(edgeExecId, inputData))); } else if (msg.type === 'resize') { conn.ws.send(JSON.stringify(createExecResizeMessage(edgeExecId, msg.cols, msg.rows))); } @@ -785,7 +787,8 @@ function webSocketPlugin(): Plugin { try { const msg = JSON.parse(message.toString()); if (msg.type === 'input' && d.stream) { - d.stream.write(msg.data); + // Non-TTY attach has no pty to convert Enter (\r) to a newline. + d.stream.write(translateAttachInput(msg.data, d.mode === 'attach' && d.state.multiplexed)); } else if (msg.type === 'resize') { if (d.mode === 'attach') { resizeContainerForWs(d.containerId, msg.cols, msg.rows, d.target);