From c4ff58347e45c52b0a296aea72de3cfdefd09d6d Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 14 Apr 2026 08:23:05 -0400 Subject: [PATCH] fix(container-exec): harden with security fixes, validation, and test coverage (#577) * fix(container-exec): harden with security fixes, validation, and test coverage - Enforce admin role at WebSocket upgrade for container exec sessions - Validate container is running before creating exec - Fix bash-to-sh fallback (move .start() inside try/catch) - Register container-exec as a capability for fleet visibility - Add standard and diagnostic logging for exec lifecycle - Fix false Admiral license claim in API docs - Fix design system violations in BashExecModal (hardcoded colors) - Remove duplicate legacy xterm dependencies - Add 18-test suite covering auth, validation, fallback, and cleanup * fix(container-exec): use correct Duplex type for exec stream The stream variable was typed as NodeJS.ReadWriteStream, which lacks .destroy(). Dockerode's Exec.start() returns stream.Duplex per its type definitions. This caused tsc to fail while Vitest (which skips full type checking) passed. --- backend/src/__tests__/exec.test.ts | 375 ++++++++++++++++++++ backend/src/index.ts | 29 +- backend/src/services/CapabilityRegistry.ts | 1 + backend/src/services/DockerController.ts | 75 ++-- docs/api-reference/overview.mdx | 2 +- docs/features/editor.mdx | 27 +- docs/images/editor/container-exec-modal.png | Bin 0 -> 15551 bytes frontend/package-lock.json | 24 +- frontend/package.json | 4 +- frontend/src/components/BashExecModal.tsx | 9 +- frontend/src/lib/capabilities.ts | 1 + 11 files changed, 487 insertions(+), 60 deletions(-) create mode 100644 backend/src/__tests__/exec.test.ts create mode 100644 docs/images/editor/container-exec-modal.png diff --git a/backend/src/__tests__/exec.test.ts b/backend/src/__tests__/exec.test.ts new file mode 100644 index 00000000..e9e4718b --- /dev/null +++ b/backend/src/__tests__/exec.test.ts @@ -0,0 +1,375 @@ +/** + * Tests for container exec: state validation, shell fallback, + * input handling, cleanup, and WebSocket upgrade auth enforcement. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import { EventEmitter } from 'events'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +// ── Hoisted mocks ────────────────────────────────────────────────────── + +const { mockDocker, mockContainer, mockExecInstance } = vi.hoisted(() => { + const mockExecInstance = { + start: vi.fn(), + resize: vi.fn().mockResolvedValue(undefined), + }; + + const mockContainer = { + inspect: vi.fn(), + exec: vi.fn(), + }; + + const mockDocker = { + getContainer: vi.fn().mockReturnValue(mockContainer), + listContainers: vi.fn().mockResolvedValue([]), + listImages: vi.fn().mockResolvedValue([]), + listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }), + listNetworks: vi.fn().mockResolvedValue([]), + df: vi.fn().mockResolvedValue({ LayersSize: 0, Images: [], Containers: [], Volumes: [] }), + pruneContainers: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }), + pruneImages: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }), + pruneNetworks: vi.fn().mockResolvedValue({}), + pruneVolumes: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }), + }; + + return { mockDocker, mockContainer, mockExecInstance }; +}); + +vi.mock('../services/NodeRegistry', () => ({ + NodeRegistry: { + getInstance: () => ({ + getDocker: () => mockDocker, + getDefaultNodeId: () => 1, + getNode: () => ({ id: 1, type: 'local', name: 'Local' }), + }), + }, +})); + +vi.mock('child_process', () => ({ + exec: vi.fn(), + execFile: vi.fn(), + spawn: vi.fn(), +})); +vi.mock('util', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, promisify: actual.promisify }; +}); + +import DockerController from '../services/DockerController'; +import WebSocket from 'ws'; + +// ── Helper: mock stream (fresh per test) ─────────────────────────────── + +function createMockStream() { + const stream = new EventEmitter(); + (stream as EventEmitter & { write: ReturnType; destroy: ReturnType }).write = vi.fn(); + (stream as EventEmitter & { write: ReturnType; destroy: ReturnType }).destroy = vi.fn(); + return stream as EventEmitter & { write: ReturnType; destroy: ReturnType }; +} + +// ── Helper: mock WebSocket ───────────────────────────────────────────── + +function createMockWs(): WebSocket { + const ws = Object.assign(new EventEmitter(), { + readyState: WebSocket.OPEN, + send: vi.fn(), + close: vi.fn(), + terminate: vi.fn(), + ping: vi.fn(), + pong: vi.fn(), + }); + return ws as unknown as WebSocket; +} + +let mockStream: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + mockStream = createMockStream(); + // Reset defaults + mockContainer.inspect.mockResolvedValue({ State: { Running: true } }); + mockContainer.exec.mockResolvedValue(mockExecInstance); + mockExecInstance.start.mockResolvedValue(mockStream); + mockExecInstance.resize.mockResolvedValue(undefined); +}); + +// ── execContainer: input validation ──────────────────────────────────── + +describe('DockerController.execContainer - input validation', () => { + it('rejects empty containerId', async () => { + const ws = createMockWs(); + const dc = DockerController.getInstance(1); + await dc.execContainer('', ws); + + expect(ws.send).toHaveBeenCalledWith( + expect.stringContaining('No container ID provided'), + ); + expect(ws.close).toHaveBeenCalled(); + expect(mockDocker.getContainer).not.toHaveBeenCalled(); + }); +}); + +// ── execContainer: container state validation ────────────────────────── + +describe('DockerController.execContainer - state validation', () => { + it('rejects exec on a stopped container', async () => { + mockContainer.inspect.mockResolvedValue({ State: { Running: false } }); + + const ws = createMockWs(); + const dc = DockerController.getInstance(1); + await dc.execContainer('abc123', ws); + + expect(ws.send).toHaveBeenCalledWith( + expect.stringContaining('Container is not running'), + ); + expect(ws.close).toHaveBeenCalled(); + expect(mockContainer.exec).not.toHaveBeenCalled(); + }); + + it('proceeds when container is running', async () => { + const ws = createMockWs(); + const dc = DockerController.getInstance(1); + await dc.execContainer('abc123', ws); + + expect(mockContainer.inspect).toHaveBeenCalled(); + expect(mockContainer.exec).toHaveBeenCalled(); + expect(mockExecInstance.start).toHaveBeenCalled(); + expect(ws.close).not.toHaveBeenCalled(); + }); +}); + +// ── execContainer: shell fallback ────────────────────────────────────── + +describe('DockerController.execContainer - shell fallback', () => { + it('falls back to /bin/sh when /bin/bash exec creation fails', async () => { + mockContainer.exec + .mockRejectedValueOnce(new Error('OCI: bash not found')) + .mockResolvedValueOnce(mockExecInstance); + + const ws = createMockWs(); + const dc = DockerController.getInstance(1); + await dc.execContainer('abc123', ws); + + expect(mockContainer.exec).toHaveBeenCalledTimes(2); + expect(mockContainer.exec.mock.calls[0][0]).toMatchObject({ Cmd: ['/bin/bash'] }); + expect(mockContainer.exec.mock.calls[1][0]).toMatchObject({ Cmd: ['/bin/sh'] }); + expect(mockExecInstance.start).toHaveBeenCalled(); + }); + + it('falls back to /bin/sh when /bin/bash start() fails', async () => { + // Exec creation succeeds for bash but start() fails (common Docker behavior) + const failingExec = { + start: vi.fn().mockRejectedValueOnce(new Error('exec failed: bash not found')), + resize: vi.fn(), + }; + mockContainer.exec + .mockResolvedValueOnce(failingExec) + .mockResolvedValueOnce(mockExecInstance); + + const ws = createMockWs(); + const dc = DockerController.getInstance(1); + await dc.execContainer('abc123', ws); + + expect(mockContainer.exec).toHaveBeenCalledTimes(2); + expect(failingExec.start).toHaveBeenCalled(); + expect(mockExecInstance.start).toHaveBeenCalled(); + }); + + it('sends error to client when both shells fail', async () => { + mockContainer.exec + .mockRejectedValueOnce(new Error('bash not found')) + .mockRejectedValueOnce(new Error('sh not found')); + + const ws = createMockWs(); + const dc = DockerController.getInstance(1); + await dc.execContainer('abc123', ws); + + expect(ws.send).toHaveBeenCalledWith( + expect.stringContaining('Failed to start shell'), + ); + }); +}); + +// ── execContainer: stream piping ─────────────────────────────────────── + +describe('DockerController.execContainer - stream handling', () => { + it('forwards container output to WebSocket', async () => { + const ws = createMockWs(); + const dc = DockerController.getInstance(1); + await dc.execContainer('abc123', ws); + + mockStream.emit('data', Buffer.from('hello world')); + expect(ws.send).toHaveBeenCalledWith('hello world'); + }); + + it('handles input messages from client', async () => { + const ws = createMockWs(); + const dc = DockerController.getInstance(1); + await dc.execContainer('abc123', ws); + + (ws as unknown as EventEmitter).emit( + 'message', + Buffer.from(JSON.stringify({ type: 'input', data: 'ls\n' })), + ); + expect(mockStream.write).toHaveBeenCalledWith('ls\n'); + }); + + it('handles resize messages from client', async () => { + const ws = createMockWs(); + const dc = DockerController.getInstance(1); + await dc.execContainer('abc123', ws); + + (ws as unknown as EventEmitter).emit( + 'message', + Buffer.from(JSON.stringify({ type: 'resize', rows: 24, cols: 80 })), + ); + expect(mockExecInstance.resize).toHaveBeenCalledWith({ h: 24, w: 80 }); + }); + + it('handles ping messages without error', async () => { + const ws = createMockWs(); + const dc = DockerController.getInstance(1); + await dc.execContainer('abc123', ws); + + (ws as unknown as EventEmitter).emit( + 'message', + Buffer.from(JSON.stringify({ type: 'ping' })), + ); + expect(mockStream.write).not.toHaveBeenCalled(); + }); + + it('handles malformed JSON messages gracefully', async () => { + const ws = createMockWs(); + const dc = DockerController.getInstance(1); + await dc.execContainer('abc123', ws); + + // Should not throw + (ws as unknown as EventEmitter).emit('message', Buffer.from('not json')); + expect(mockStream.write).not.toHaveBeenCalled(); + }); + + it('closes WebSocket when stream ends', async () => { + const ws = createMockWs(); + const dc = DockerController.getInstance(1); + await dc.execContainer('abc123', ws); + + mockStream.emit('end'); + expect(ws.close).toHaveBeenCalled(); + }); +}); + +// ── execContainer: cleanup ───────────────────────────────────────────── + +describe('DockerController.execContainer - cleanup', () => { + it('destroys stream when WebSocket closes', async () => { + const ws = createMockWs(); + const dc = DockerController.getInstance(1); + await dc.execContainer('abc123', ws); + + (ws as unknown as EventEmitter).emit('close'); + expect(mockStream.destroy).toHaveBeenCalled(); + }); + + it('handles double-destroy gracefully', async () => { + mockStream.destroy.mockImplementationOnce(() => { + throw new Error('Already destroyed'); + }); + + const ws = createMockWs(); + const dc = DockerController.getInstance(1); + await dc.execContainer('abc123', ws); + + // Should not throw + (ws as unknown as EventEmitter).emit('close'); + expect(mockStream.destroy).toHaveBeenCalled(); + }); +}); + +// ── WebSocket upgrade: auth enforcement ──────────────────────────────── + +describe('WebSocket upgrade - exec auth enforcement', () => { + let tmpDir: string; + let server: import('http').Server; + + beforeAll(async () => { + // Clear module mocks so the real NodeRegistry is used for integration tests + vi.restoreAllMocks(); + tmpDir = await setupTestDb(); + const mod = await import('../index'); + server = mod.server; + await new Promise((resolve) => server.listen(0, resolve)); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + cleanupTestDb(tmpDir); + }); + + function getWsUrl(path = '/ws'): string { + const addr = server.address(); + if (!addr || typeof addr === 'string') throw new Error('Server not listening'); + return `ws://127.0.0.1:${addr.port}${path}`; + } + + it('rejects WebSocket upgrade with no token (401)', async () => { + const ws = new WebSocket(getWsUrl()); + const code = await new Promise((resolve) => { + ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); + ws.on('error', () => resolve(0)); + }); + expect(code).toBe(401); + }); + + it('rejects WebSocket upgrade with non-admin token (403)', async () => { + // Add a non-admin user + const { DatabaseService } = await import('../services/DatabaseService'); + const bcrypt = await import('bcrypt'); + const hash = await bcrypt.hash('viewerpass', 1); + try { + DatabaseService.getInstance().addUser({ username: 'viewer', password_hash: hash, role: 'viewer' }); + } catch { + // User may already exist + } + + const token = jwt.sign( + { username: 'viewer', role: 'viewer' }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + const ws = new WebSocket(getWsUrl(), { headers: { Cookie: `sencho_token=${token}` } }); + const code = await new Promise((resolve) => { + ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); + ws.on('error', () => resolve(0)); + }); + expect(code).toBe(403); + }); + + it('rejects WebSocket upgrade with node_proxy token (403)', async () => { + const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } }); + const code = await new Promise((resolve) => { + ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); + ws.on('error', () => resolve(0)); + }); + expect(code).toBe(403); + }); + + it('accepts WebSocket upgrade with admin token', async () => { + const token = jwt.sign( + { username: TEST_USERNAME, role: 'admin' }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + const ws = new WebSocket(getWsUrl(), { headers: { Cookie: `sencho_token=${token}` } }); + const connected = await new Promise((resolve) => { + ws.on('open', () => { + ws.close(); + resolve(true); + }); + ws.on('error', () => resolve(false)); + ws.on('unexpected-response', () => resolve(false)); + }); + expect(connected).toBe(true); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index e6309c2e..85ff2104 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2862,7 +2862,7 @@ server.on('upgrade', async (req, socket, head) => { const settings = DatabaseService.getInstance().getGlobalSettings(); const jwtSecret = settings.auth_jwt_secret; if (!jwtSecret) throw new Error('No JWT secret'); - const decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string }; + const decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string; role?: string; tv?: number }; // Node proxy tokens are machine-to-machine credentials and must never be granted // interactive terminal access (host console or container exec). @@ -3075,6 +3075,33 @@ server.on('upgrade', async (req, socket, head) => { socket.destroy(); return; } + // Admin enforcement: container exec requires admin role. + // console_session tokens are already admin-gated at creation time. + // API tokens reaching this point have full-admin scope (read-only/deploy-only blocked above). + if (!decoded.scope) { + // User session token: verify admin role against the database (not the JWT) + // so role changes take effect immediately, matching authMiddleware behavior. + const execUser = decoded.username ? DatabaseService.getInstance().getUserByUsername(decoded.username) : undefined; + if (!execUser) { + console.warn('[Exec] User account not found:', decoded.username); + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + if (decoded.tv !== undefined && execUser.token_version !== decoded.tv) { + console.warn('[Exec] Session invalidated (token version mismatch):', decoded.username); + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + if (execUser.role !== 'admin') { + console.warn('[Exec] Non-admin user rejected:', decoded.username); + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + socket.destroy(); + return; + } + } + if (isDebugEnabled()) console.debug('[Exec:diag] WS upgrade for exec path', { nodeId, username: decoded.username, scope: decoded.scope || 'user-session' }); wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req); }); diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 1b06210d..bcb01ac7 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -25,6 +25,7 @@ export const CAPABILITIES = [ 'notifications', 'notification-routing', 'host-console', + 'container-exec', 'audit-log', 'scheduled-ops', 'sso', diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 6eeb9e9b..8ecd2532 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -1042,29 +1042,47 @@ class DockerController { */ public async execContainer(containerId: string, ws: WebSocket) { try { - const container = this.docker.getContainer(containerId); - - // Try bash first, fall back to sh - let exec: Docker.Exec; - try { - exec = await container.exec({ - AttachStdin: true, - AttachStdout: true, - AttachStderr: true, - Tty: true, - Cmd: ['/bin/bash'], - }); - } catch { - exec = await container.exec({ - AttachStdin: true, - AttachStdout: true, - AttachStderr: true, - Tty: true, - Cmd: ['/bin/sh'], - }); + // Input validation + if (!containerId || typeof containerId !== 'string') { + console.warn('[Exec] Empty or invalid containerId'); + if (ws.readyState === WebSocket.OPEN) { + ws.send('\r\n\x1b[31mError: No container ID provided\x1b[0m\r\n'); + ws.close(); + } + return; } - const stream = await exec.start({ hijack: true, stdin: true }); + const container = this.docker.getContainer(containerId); + + // Verify the container is running before attempting exec + const info = await container.inspect(); + if (!info.State?.Running) { + console.warn('[Exec] Container not running:', containerId); + if (ws.readyState === WebSocket.OPEN) { + ws.send('\r\n\x1b[31mError: Container is not running\x1b[0m\r\n'); + ws.close(); + } + return; + } + + // Try bash first, fall back to sh. + // Both exec creation AND start must be inside the try/catch because + // some runtimes reject unknown binaries at start(), not at creation. + const execOpts = { AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true } as const; + let dockerExec: Docker.Exec; + let stream: import('stream').Duplex; + let shellType = '/bin/bash'; + try { + dockerExec = await container.exec({ ...execOpts, Cmd: ['/bin/bash'] }); + stream = await dockerExec.start({ hijack: true, stdin: true }); + } catch { + shellType = '/bin/sh'; + dockerExec = await container.exec({ ...execOpts, Cmd: ['/bin/sh'] }); + stream = await dockerExec.start({ hijack: true, stdin: true }); + } + + if (isDebugEnabled()) console.debug('[Exec:diag] Creating exec', { containerId, shell: shellType }); + console.log('[Exec] Shell session started', { containerId, shell: shellType }); // --- Downstream: container output → client --- stream.on('data', (chunk: Buffer) => { @@ -1074,10 +1092,11 @@ class DockerController { }); stream.on('error', (err: Error) => { - console.error('Exec stream error:', err.message); + console.error('[Exec] Stream error:', err.message, { containerId }); }); stream.on('end', () => { + console.log('[Exec] Shell session ended', { containerId, reason: 'stream-end' }); if (ws.readyState === WebSocket.OPEN) { ws.close(); } @@ -1097,9 +1116,10 @@ class DockerController { case 'resize': if (msg.rows && msg.cols) { - exec.resize({ h: msg.rows, w: msg.cols }).catch((e: Error) => { + if (isDebugEnabled()) console.debug('[Exec:diag] Terminal resize', { containerId, rows: msg.rows, cols: msg.cols }); + dockerExec.resize({ h: msg.rows, w: msg.cols }).catch((e: Error) => { // Exec may have ended before resize completes - console.warn('[DockerController] Exec resize failed (exec may have ended):', e.message); + console.warn('[Exec] Resize failed (exec may have ended):', e.message); }); } break; @@ -1110,23 +1130,24 @@ class DockerController { } } catch (e) { // Non-JSON or malformed WebSocket message - console.warn('[DockerController] Ignoring malformed exec WS message:', (e as Error).message); + console.warn('[Exec] Ignoring malformed WS message:', (e as Error).message); } }); // --- Cleanup: prevent zombie processes --- ws.on('close', () => { + console.log('[Exec] Shell session ended', { containerId, reason: 'ws-close' }); try { stream.destroy(); } catch (e) { // Stream already destroyed before WS close - console.warn('[DockerController] Exec stream already destroyed on WS close:', (e as Error).message); + console.warn('[Exec] Stream already destroyed on WS close:', (e as Error).message); } }); } catch (error) { const err = error as Error; - console.error('Failed to exec container:', err.message); + console.error('[Exec] Failed to start shell:', err.message, { containerId }); if (ws.readyState === WebSocket.OPEN) { ws.send(`\r\n\x1b[31mFailed to start shell: ${err.message}\x1b[0m\r\n`); } diff --git a/docs/api-reference/overview.mdx b/docs/api-reference/overview.mdx index 2649094f..60113ac7 100644 --- a/docs/api-reference/overview.mdx +++ b/docs/api-reference/overview.mdx @@ -203,5 +203,5 @@ ws.send(JSON.stringify({ type: "resize", cols: 120, rows: 40 })); - Container exec requires a Sencho **Admiral Team** license and is blocked for API tokens and node proxy tokens. + Container exec requires an **admin** role. API tokens with `read-only` or `deploy-only` scope are blocked, as are node proxy tokens. diff --git a/docs/features/editor.mdx b/docs/features/editor.mdx index 2797fc7e..fed4186d 100644 --- a/docs/features/editor.mdx +++ b/docs/features/editor.mdx @@ -64,12 +64,35 @@ The terminal supports search (find text within the log output) and export (downl ### Container terminal (exec) -The terminal modal from the container action buttons gives you an interactive bash shell inside a running container, equivalent to `docker exec -it bash`. It uses a full xterm.js emulator with color support and tab completion. +The terminal modal from the container action buttons gives you an interactive bash shell inside a running container, equivalent to `docker exec -it bash`. It uses a full terminal emulator with colour support, tab completion, and automatic resizing when you resize the browser window. + +#### Access requirements + +- **Admin role required.** Only admin users see the terminal button in the container actions. Non-admin users cannot open a shell session. +- **Container must be running.** The button is disabled for stopped containers, and the backend will reject exec attempts against non-running containers. +- **API token restrictions.** API tokens with `read-only` or `deploy-only` scope cannot open exec sessions. Only `full-admin` API tokens are permitted. + + + Container exec modal showing a connected bash session with a root shell prompt + + +#### Shell selection + +Sencho tries `/bin/bash` first. If bash is not available inside the container, it automatically falls back to `/bin/sh`. This happens transparently; no user action is needed. - The container terminal requires the container to have `bash` (or `sh`) installed. Minimal images (e.g. Alpine-based) may need `sh` instead. + The container terminal requires the container to have `bash` or `sh` installed. Minimal base images (e.g. `scratch`, `distroless`) that ship no shell will not work with container exec. +#### Troubleshooting + +| Symptom | Cause | Resolution | +|---------|-------|------------| +| "Container is not running" error | The container stopped between clicking the button and the exec starting | Start the container and try again | +| "Failed to start shell" error | The container image has no shell binary (`/bin/bash` or `/bin/sh`) | Use an image that includes a shell, or install one in your Dockerfile | +| Terminal connects but immediately shows "Session ended" | The shell process inside the container exited immediately | Check the container logs for errors; the container's entrypoint may be overriding the shell | +| Terminal is unresponsive | WebSocket connection dropped silently | Close the modal and reopen it to establish a new session | + ## Log viewer The log viewer (opened from the container action buttons) streams output from a single container in real-time using Server-Sent Events. Logs auto-scroll to the bottom as new lines arrive. Close the modal to stop the stream. diff --git a/docs/images/editor/container-exec-modal.png b/docs/images/editor/container-exec-modal.png new file mode 100644 index 0000000000000000000000000000000000000000..6d8a0893b3784d17c5812fd347bd2e81458abeba GIT binary patch literal 15551 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 literal 0 HcmV?d00001 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7b4264eb..a9ca076a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -49,9 +49,7 @@ "react-use-measure": "^2.1.7", "recharts": "^3.8.1", "tailwind-merge": "^3.5.0", - "tailwindcss-animate": "^1.0.7", - "xterm-addon-search": "^0.13.0", - "xterm-addon-serialize": "^0.11.0" + "tailwindcss-animate": "^1.0.7" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -6035,26 +6033,6 @@ "node": ">=0.10.0" } }, - "node_modules/xterm-addon-search": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/xterm-addon-search/-/xterm-addon-search-0.13.0.tgz", - "integrity": "sha512-sDUwG4CnqxUjSEFh676DlS3gsh3XYCzAvBPSvJ5OPgF3MRL3iHLPfsb06doRicLC2xXNpeG2cWk8x1qpESWJMA==", - "deprecated": "This package is now deprecated. Move to @xterm/addon-search instead.", - "license": "MIT", - "peerDependencies": { - "xterm": "^5.0.0" - } - }, - "node_modules/xterm-addon-serialize": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/xterm-addon-serialize/-/xterm-addon-serialize-0.11.0.tgz", - "integrity": "sha512-2CNDnmLdLkNWfsxNFkGsI5FE9W/BbsMzeOrbu59yNqH9L6k1gmL+Ab6VXxEp2NQUJSzaiqi6t0nFR5k5EDkVIg==", - "deprecated": "This package is now deprecated. Move to @xterm/addon-serialize instead.", - "license": "MIT", - "peerDependencies": { - "xterm": "^5.0.0" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index ece8c136..791addf2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,9 +51,7 @@ "react-use-measure": "^2.1.7", "recharts": "^3.8.1", "tailwind-merge": "^3.5.0", - "tailwindcss-animate": "^1.0.7", - "xterm-addon-search": "^0.13.0", - "xterm-addon-serialize": "^0.11.0" + "tailwindcss-animate": "^1.0.7" }, "overrides": { "dompurify": "^3.3.3" diff --git a/frontend/src/components/BashExecModal.tsx b/frontend/src/components/BashExecModal.tsx index 5ea5384d..13e104cb 100644 --- a/frontend/src/components/BashExecModal.tsx +++ b/frontend/src/components/BashExecModal.tsx @@ -85,9 +85,12 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN initTimeoutRef.current = setTimeout(checkAndInit, 50); function initTerminal(containerEl: HTMLDivElement) { + // xterm.js requires literal color strings in its theme config; CSS variables + // and oklch() are not supported by the canvas renderer. These values are + // intentionally hardcoded to match the terminal well aesthetic. const term = new Terminal({ theme: { - background: '#1e1e1e', + background: '#0a0a0a', foreground: '#d4d4d4', cursor: '#ffffff', cursorAccent: '#000000', @@ -224,12 +227,12 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN )} - + Interactive bash terminal session for {containerName} {/* Styling wrapper - padding and rounded corners go here */} -
+
{/* Clean xterm container - NO padding, NO overflow-hidden, explicit dimensions */}