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 00000000..6d8a0893 Binary files /dev/null and b/docs/images/editor/container-exec-modal.png differ 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 */}