add a docker attach terminal mode
This commit is contained in:
4 files changed
+64
-6
No files matched your search
@@ -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) {
|
function createDockerStreamState(multiplexed = false) {
|
||||||
return {
|
return {
|
||||||
headersStripped: false,
|
headersStripped: false,
|
||||||
@@ -538,7 +545,8 @@ async function handleTerminalConnection(ws, url, connId) {
|
|||||||
try {
|
try {
|
||||||
const msg = JSON.parse(data.toString());
|
const msg = JSON.parse(data.toString());
|
||||||
if (msg.type === 'input' && msg.data) {
|
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) {
|
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
|
||||||
if (mode === 'attach') {
|
if (mode === 'attach') {
|
||||||
if (typeof globalThis.__terminalResizeContainer === 'function') {
|
if (typeof globalThis.__terminalResizeContainer === 'function') {
|
||||||
@@ -644,10 +652,12 @@ function handleEdgeExec(ws, connId, containerId, shell, user, environmentId, mod
|
|||||||
try {
|
try {
|
||||||
const msg = JSON.parse(data.toString());
|
const msg = JSON.parse(data.toString());
|
||||||
if (msg.type === 'input' && msg.data) {
|
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({
|
const inputMsg = JSON.stringify({
|
||||||
type: 'exec_input',
|
type: 'exec_input',
|
||||||
execId,
|
execId,
|
||||||
data: Buffer.from(msg.data).toString('base64')
|
data: Buffer.from(inputData).toString('base64')
|
||||||
});
|
});
|
||||||
globalThis.__hawserSendMessage(environmentId, inputMsg);
|
globalThis.__hawserSendMessage(environmentId, inputMsg);
|
||||||
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
|
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
|
||||||
|
|||||||
@@ -136,3 +136,18 @@ export function processDockerStreamChunk(data: Buffer | Uint8Array | string, sta
|
|||||||
|
|
||||||
return output;
|
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');
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ import { describe, expect, test } from 'bun:test';
|
|||||||
import {
|
import {
|
||||||
createDockerStreamState,
|
createDockerStreamState,
|
||||||
decodeChunkedDockerBody,
|
decodeChunkedDockerBody,
|
||||||
processDockerStreamChunk
|
processDockerStreamChunk,
|
||||||
|
translateAttachInput
|
||||||
} from '../src/lib/server/docker-stream-core';
|
} 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 = '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');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+6
-3
@@ -184,7 +184,7 @@ function buildDockerStreamHttpRequest(path: string, target: DockerTarget, body =
|
|||||||
|
|
||||||
// ============ Stream Processing ============
|
// ============ 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 ============
|
// ============ Hawser Edge Exec Messages ============
|
||||||
|
|
||||||
@@ -764,7 +764,9 @@ function webSocketPlugin(): Plugin {
|
|||||||
try {
|
try {
|
||||||
const msg = JSON.parse(message.toString());
|
const msg = JSON.parse(message.toString());
|
||||||
if (msg.type === 'input') {
|
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') {
|
} else if (msg.type === 'resize') {
|
||||||
conn.ws.send(JSON.stringify(createExecResizeMessage(edgeExecId, msg.cols, msg.rows)));
|
conn.ws.send(JSON.stringify(createExecResizeMessage(edgeExecId, msg.cols, msg.rows)));
|
||||||
}
|
}
|
||||||
@@ -785,7 +787,8 @@ function webSocketPlugin(): Plugin {
|
|||||||
try {
|
try {
|
||||||
const msg = JSON.parse(message.toString());
|
const msg = JSON.parse(message.toString());
|
||||||
if (msg.type === 'input' && d.stream) {
|
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') {
|
} else if (msg.type === 'resize') {
|
||||||
if (d.mode === 'attach') {
|
if (d.mode === 'attach') {
|
||||||
resizeContainerForWs(d.containerId, msg.cols, msg.rows, d.target);
|
resizeContainerForWs(d.containerId, msg.cols, msg.rows, d.target);
|
||||||
|
|||||||
Reference in new issue
Block a user