Skip to content

Commit 8c3a0b6

Browse files
committed
fix: working real terminal implementation
Backend fixes: - Fix TTY stream handling (no demux needed for TTY containers) - Attach to container before starting to catch all output - Relax container security constraints for tmux compatibility - Remove overly aggressive input sanitization that broke escape sequences - Add test-connection.js for debugging Frontend fixes: - Improve RealTerminal component with better lifecycle handling - Add terminal write buffering for pending output before open - Use stable callback refs to prevent unnecessary effect re-runs - Subscribe to messages before connecting to avoid race conditions Config: - Add .env.local with local development WebSocket URL
1 parent eb655dd commit 8c3a0b6

5 files changed

Lines changed: 214 additions & 108 deletions

File tree

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"build": "tsc",
88
"start": "node dist/server.js",
99
"dev": "tsx watch src/server.ts",
10+
"test:connection": "node test-connection.js",
1011
"docker:build": "docker build -t terminal-sandbox -f Dockerfile.sandbox .",
1112
"docker:test": "docker run --rm -it --network none --memory 256m --cpus 0.5 terminal-sandbox"
1213
},

backend/src/container-manager.ts

Lines changed: 11 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import Docker from 'dockerode';
2-
import { PassThrough } from 'stream';
32

43
// =============================================================================
54
// Types
@@ -84,46 +83,30 @@ export class ContainerManager {
8483
AttachStdout: true,
8584
AttachStderr: true,
8685

87-
// Security hardening
86+
// Security hardening (relaxed for tmux compatibility)
8887
HostConfig: {
8988
// No network access
9089
NetworkMode: 'none',
9190

92-
// Read-only root filesystem
93-
ReadonlyRootfs: true,
94-
95-
// Writable tmpfs for /tmp and home
96-
Tmpfs: {
97-
'/tmp': 'rw,noexec,nosuid,size=64m',
98-
'/home/learner/.local': 'rw,noexec,nosuid,size=32m',
99-
},
91+
// Allow writable filesystem for tmux sockets and temp files
92+
// ReadonlyRootfs: true, // Disabled - tmux needs to write to various locations
10093

10194
// Resource limits
10295
Memory: this.CONTAINER_LIMITS.Memory,
10396
MemorySwap: this.CONTAINER_LIMITS.MemorySwap,
10497
NanoCpus: this.CONTAINER_LIMITS.NanoCpus,
10598
PidsLimit: this.CONTAINER_LIMITS.PidsLimit,
10699

107-
// Drop all capabilities
108-
CapDrop: ['ALL'],
109-
110100
// Security options
111101
SecurityOpt: [
112102
'no-new-privileges:true',
113-
'seccomp=unconfined', // TODO: Add custom seccomp profile
114103
],
115104

116105
// Auto-remove on exit (backup cleanup)
117106
AutoRemove: true,
118107

119108
// No privileged mode
120109
Privileged: false,
121-
122-
// Limit ulimits
123-
Ulimits: [
124-
{ Name: 'nofile', Soft: 1024, Hard: 2048 },
125-
{ Name: 'nproc', Soft: 50, Hard: 100 },
126-
],
127110
},
128111

129112
// User
@@ -146,10 +129,7 @@ export class ContainerManager {
146129
},
147130
});
148131

149-
// Start container
150-
await container.start();
151-
152-
// Attach to container
132+
// Attach BEFORE starting container to catch all output
153133
const stream = await container.attach({
154134
stream: true,
155135
stdin: true,
@@ -158,20 +138,18 @@ export class ContainerManager {
158138
hijack: true,
159139
});
160140

161-
// Handle output
162-
const stdout = new PassThrough();
163-
const stderr = new PassThrough();
164-
165-
container.modem.demuxStream(stream, stdout, stderr);
166-
167-
stdout.on('data', (chunk: Buffer) => {
141+
// Handle output - TTY mode means stream is already raw (no demux needed)
142+
stream.on('data', (chunk: Buffer) => {
168143
onOutput(chunk.toString('utf8'));
169144
});
170145

171-
stderr.on('data', (chunk: Buffer) => {
172-
onOutput(chunk.toString('utf8'));
146+
stream.on('error', (err: Error) => {
147+
console.error(`[${sessionId}] Stream error:`, err);
173148
});
174149

150+
// Start container after attaching
151+
await container.start();
152+
175153
// Store container info
176154
const containerId = container.id;
177155
this.containers.set(containerId, {

backend/src/server.ts

Lines changed: 10 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -244,42 +244,14 @@ wss.on('connection', async (ws: WebSocket, req: http.IncomingMessage) => {
244244
// =============================================================================
245245

246246
function sanitizeInput(input: string): string | null {
247-
// Block control characters except common ones
248-
const allowedControlChars = [
249-
'\x03', // Ctrl+C
250-
'\x04', // Ctrl+D
251-
'\x1a', // Ctrl+Z
252-
'\x1b', // Escape
253-
'\r', // Enter
254-
'\n', // Newline
255-
'\t', // Tab
256-
'\x7f', // Backspace
257-
];
258-
259-
let sanitized = '';
260-
for (const char of input) {
261-
const code = char.charCodeAt(0);
262-
263-
// Allow printable ASCII
264-
if (code >= 32 && code <= 126) {
265-
sanitized += char;
266-
continue;
267-
}
268-
269-
// Allow specific control characters
270-
if (allowedControlChars.includes(char)) {
271-
sanitized += char;
272-
continue;
273-
}
274-
275-
// Allow escape sequences (for arrow keys etc)
276-
if (code === 27) {
277-
sanitized += char;
278-
continue;
279-
}
280-
}
247+
// For terminal input, we need to pass through most things including:
248+
// - All printable ASCII (32-126)
249+
// - Control characters for terminal control (Ctrl+C, Ctrl+D, etc)
250+
// - Escape sequences for arrow keys, function keys, etc (\x1b[A, \x1b[B, etc)
251+
// - Carriage return, newline, tab, backspace
281252

282-
// Block potentially dangerous command patterns
253+
// Only block obviously dangerous complete command patterns
254+
// Note: This is a basic check - the container is sandboxed anyway
283255
const dangerousPatterns = [
284256
/rm\s+-rf\s+\//i,
285257
/mkfs/i,
@@ -289,13 +261,14 @@ function sanitizeInput(input: string): string | null {
289261
];
290262

291263
for (const pattern of dangerousPatterns) {
292-
if (pattern.test(sanitized)) {
264+
if (pattern.test(input)) {
293265
console.warn('Blocked dangerous input pattern');
294266
return null;
295267
}
296268
}
297269

298-
return sanitized;
270+
// Pass through the input as-is - terminal needs raw escape sequences
271+
return input;
299272
}
300273

301274
// =============================================================================

backend/test-connection.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#!/usr/bin/env node
2+
// Quick test script to verify WebSocket + Docker container works
3+
const WebSocket = require('ws');
4+
5+
const WS_URL = process.env.WS_URL || 'ws://localhost:3001/ws';
6+
7+
console.log(`Connecting to ${WS_URL}...`);
8+
9+
const ws = new WebSocket(WS_URL);
10+
11+
let sessionId = null;
12+
let ready = false;
13+
14+
ws.on('open', () => {
15+
console.log('✓ WebSocket connected');
16+
});
17+
18+
ws.on('message', (data) => {
19+
try {
20+
const msg = JSON.parse(data.toString());
21+
22+
switch (msg.type) {
23+
case 'session':
24+
sessionId = msg.id;
25+
console.log(`✓ Session ID: ${sessionId}`);
26+
break;
27+
28+
case 'status':
29+
console.log(` Status: ${msg.message}`);
30+
break;
31+
32+
case 'ready':
33+
ready = true;
34+
console.log('✓ Terminal ready!');
35+
console.log('\n--- Terminal output ---');
36+
37+
// Send resize first (tmux needs this)
38+
ws.send(JSON.stringify({ type: 'resize', cols: 80, rows: 24 }));
39+
40+
// Send a test command after a short delay
41+
setTimeout(() => {
42+
console.log('\n--- Sending: nvim --version | head -1 ---');
43+
ws.send(JSON.stringify({ type: 'input', data: 'nvim --version | head -1\r' }));
44+
}, 500);
45+
46+
// Close after 4 seconds
47+
setTimeout(() => {
48+
console.log('\n--- Test complete, closing ---');
49+
ws.close(1000, 'Test complete');
50+
}, 4000);
51+
break;
52+
53+
case 'output':
54+
process.stdout.write(msg.data);
55+
break;
56+
57+
case 'error':
58+
console.error(`✗ Error: ${msg.message}`);
59+
break;
60+
61+
case 'pong':
62+
break;
63+
64+
default:
65+
console.log(`Unknown message type: ${msg.type}`);
66+
}
67+
} catch (e) {
68+
console.error('Failed to parse message:', e);
69+
}
70+
});
71+
72+
ws.on('error', (err) => {
73+
console.error('✗ WebSocket error:', err.message);
74+
});
75+
76+
ws.on('close', (code, reason) => {
77+
console.log(`\nConnection closed: ${code} ${reason}`);
78+
process.exit(ready ? 0 : 1);
79+
});
80+
81+
// Timeout after 30 seconds
82+
setTimeout(() => {
83+
console.error('✗ Timeout waiting for ready');
84+
ws.close();
85+
process.exit(1);
86+
}, 30000);

0 commit comments

Comments
 (0)