feat: implement web terminal for tool instances

- Add TerminalSession backend service for docker exec subprocess management
- Add TerminalManager for WebSocket session lifecycle management
- Create WebSocket endpoint at /ws/tool-instances/{id}/terminal
- Add session cookie authentication and instance ownership verification
- Install xterm.js with fit and web-links addons
- Create TerminalComponent with xterm.js integration
- Create TerminalPage with full-screen terminal view
- Add terminal route at /instances/:id/terminal
- Add terminal button to InstanceList for running instances
- Add terminal and arrow-left icons to icon registry
- Add comprehensive terminal CSS styles (dark theme, responsive)

Quality gates: typecheck ✓, lint ✓, build ✓, Python syntax ✓
This commit is contained in:
Fusion
2026-05-19 21:11:29 +02:00
parent d6b3e8b804
commit e344e961d6
23 changed files with 1136 additions and 4 deletions
+99
View File
@@ -0,0 +1,99 @@
"""WebSocket terminal endpoint for tool instances."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id
from src.database import get_db_session
from src.models.tool_instance import ToolInstance
from src.services.terminal_manager import terminal_manager
router = APIRouter()
@router.websocket("/ws/tool-instances/{instance_id}/terminal")
async def terminal_websocket(
websocket: WebSocket,
instance_id: str,
db_session: AsyncSession = Depends(get_db_session),
) -> None:
"""WebSocket endpoint for terminal access to a tool instance."""
await websocket.accept()
try:
# Parse instance_id
instance_uuid = uuid.UUID(instance_id)
except ValueError:
await websocket.close(code=4001, reason="Invalid instance ID")
return
# Authenticate user from session cookie
user_id = await _get_user_from_websocket(websocket, db_session)
if user_id is None:
await websocket.close(code=4003, reason="Unauthorized")
return
# Get instance and verify ownership
instance = await db_session.get(ToolInstance, instance_uuid)
if instance is None:
await websocket.close(code=4004, reason="Instance not found")
return
if instance.owner_id != user_id:
await websocket.close(code=4003, reason="Forbidden")
return
if instance.status != "running" or not instance.container_id:
await websocket.close(code=4004, reason="Instance not running")
return
# Create terminal session
try:
session = await terminal_manager.create_session(
instance_uuid,
instance.container_id,
websocket,
)
# Send connected status
await websocket.send_json({"type": "status", "status": "connected"})
# Keep connection alive until closed
while True:
try:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
break
except WebSocketDisconnect:
break
except RuntimeError:
break
except Exception as exc:
await websocket.close(code=4000, reason=f"Error: {exc}")
finally:
# Cleanup will be handled by the session manager
pass
async def _get_user_from_websocket(
websocket: WebSocket,
db_session: AsyncSession,
) -> uuid.UUID | None:
"""Extract and validate user ID from session cookie in WebSocket."""
from src.auth.session import verify_session_token
session_cookie = websocket.cookies.get("session")
if not session_cookie:
return None
user_id = verify_session_token(session_cookie)
if not user_id:
return None
try:
return uuid.UUID(user_id)
except ValueError:
return None
+2
View File
@@ -11,6 +11,7 @@ from src.api.dashboard import router as dashboard_router
from src.api.git_repositories import router as git_repositories_router
from src.api.projects import router as projects_router
from src.api.ssh_keys import router as ssh_keys_router
from src.api.terminal import router as terminal_router
from src.api.tool_instances import router as tool_instances_router
from src.api.tool_instances import sessions_router
from src.api.tool_types import router as tool_types_router
@@ -168,4 +169,5 @@ app.include_router(user_config_router)
app.include_router(tool_types_router)
app.include_router(tool_instances_router)
app.include_router(sessions_router)
app.include_router(terminal_router)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
+96
View File
@@ -0,0 +1,96 @@
"""Terminal session manager for WebSocket connections."""
import asyncio
import uuid
from typing import Any
from fastapi import WebSocket
from src.services.terminal_session import TerminalSession
class TerminalManager:
"""Manages active terminal sessions."""
def __init__(self) -> None:
self._sessions: dict[str, TerminalSession] = {}
async def create_session(
self,
instance_id: uuid.UUID,
container_id: str,
websocket: WebSocket,
) -> TerminalSession:
"""Create a new terminal session."""
session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id)
await session.start()
self._sessions[session_id] = session
# Start background tasks for I/O streaming
asyncio.create_task(self._read_loop(session, websocket))
asyncio.create_task(self._write_loop(session, websocket))
return session
async def _read_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
"""Read output from the container and send to WebSocket."""
try:
while session.is_alive() and not session._closed:
data = await session.read_output()
if data:
await websocket.send_bytes(data)
else:
await asyncio.sleep(0.01)
except Exception:
pass
finally:
await self._cleanup_session(session)
async def _write_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
"""Read input from WebSocket and send to container."""
try:
while session.is_alive() and not session._closed:
message = await websocket.receive()
if message["type"] == "websocket.receive":
if "bytes" in message:
await session.write_input(message["bytes"])
elif "text" in message:
text = message["text"]
if text.startswith("{"):
# Control message (JSON)
import json
try:
ctrl = json.loads(text)
if ctrl.get("type") == "resize":
await session.resize(
ctrl.get("cols", 80),
ctrl.get("rows", 24),
)
except json.JSONDecodeError:
pass
else:
await session.write_input(text.encode("utf-8"))
elif message["type"] == "websocket.disconnect":
break
except Exception:
pass
finally:
await self._cleanup_session(session)
async def _cleanup_session(self, session: TerminalSession) -> None:
"""Clean up a session."""
if session.session_id in self._sessions:
del self._sessions[session.session_id]
await session.close()
async def close_all(self) -> None:
"""Close all active sessions."""
sessions = list(self._sessions.values())
self._sessions.clear()
for session in sessions:
await session.close()
# Global terminal manager instance
terminal_manager = TerminalManager()
+90
View File
@@ -0,0 +1,90 @@
"""Terminal session management for tool instances."""
import asyncio
import uuid
from typing import Any
class TerminalSession:
"""Manages a single terminal session connected to a docker container."""
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None:
self.session_id = session_id
self.instance_id = instance_id
self.container_id = container_id
self.process: asyncio.subprocess.Process | None = None
self._closed = False
async def start(self) -> None:
"""Start the docker exec process with a shell."""
self.process = await asyncio.create_subprocess_exec(
"docker",
"exec",
"-i",
self.container_id,
"/bin/sh",
"-c",
"exec bash -l || exec sh -l",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
async def read_output(self) -> bytes:
"""Read output from the process."""
if self.process is None or self.process.stdout is None:
return b""
try:
return await self.process.stdout.read(4096)
except (asyncio.CancelledError, BrokenPipeError):
return b""
async def write_input(self, data: bytes) -> None:
"""Write input to the process."""
if self.process is None or self.process.stdin is None or self._closed:
return
try:
self.process.stdin.write(data)
await self.process.stdin.drain()
except (BrokenPipeError, ConnectionResetError):
pass
async def resize(self, cols: int, rows: int) -> None:
"""Resize the terminal."""
if self._closed:
return
try:
proc = await asyncio.create_subprocess_exec(
"docker",
"exec",
self.container_id,
"stty",
"cols",
str(cols),
"rows",
str(rows),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
except Exception:
pass
async def close(self) -> None:
"""Close the session and cleanup."""
if self._closed:
return
self._closed = True
if self.process is not None:
try:
self.process.kill()
await asyncio.wait_for(self.process.wait(), timeout=2.0)
except (asyncio.TimeoutError, ProcessLookupError):
pass
def is_alive(self) -> bool:
"""Check if the session process is still running."""
if self.process is None:
return False
return self.process.returncode is None
+31 -1
View File
@@ -16,7 +16,10 @@
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"react-simple-code-editor": "^0.14.1",
"tailwindcss": "^3.3.0"
"tailwindcss": "^3.3.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
@@ -6352,6 +6355,33 @@
"dev": true,
"license": "MIT"
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
"license": "MIT"
},
"node_modules/xterm-addon-fit": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-web-links": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz",
"integrity": "sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-web-links 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",
+4 -1
View File
@@ -19,7 +19,10 @@
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"react-simple-code-editor": "^0.14.1",
"tailwindcss": "^3.3.0"
"tailwindcss": "^3.3.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
+7 -1
View File
@@ -32,6 +32,8 @@ import {
ArrowSquareOut,
Play,
Stop,
Terminal,
ArrowLeft,
} from "@phosphor-icons/react";
export type IconName =
@@ -71,7 +73,9 @@ export type IconName =
| "binary"
| "external"
| "play"
| "stop";
| "stop"
| "terminal"
| "arrow-left";
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
dashboard: House,
@@ -111,6 +115,8 @@ const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; we
external: ArrowSquareOut,
play: Play,
stop: Stop,
terminal: Terminal,
"arrow-left": ArrowLeft,
};
export interface IconProps {
+12
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Icon } from "./icon";
import type { ToolInstance } from "../api/sessions";
import {
@@ -18,6 +19,7 @@ interface InstanceListProps {
}
export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps) => {
const navigate = useNavigate();
const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
@@ -154,6 +156,16 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
Open
</a>
)}
{instance.status === "running" && (
<button
className="secondary-button small"
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
type="button"
>
<Icon name="terminal" size="sm" />
Terminal
</button>
)}
{instance.status !== "running" && (
<button
className="secondary-button small"
+158
View File
@@ -0,0 +1,158 @@
import React, { useEffect, useRef, useState } from "react";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { WebLinksAddon } from "xterm-addon-web-links";
import "xterm/css/xterm.css";
interface TerminalProps {
instanceId: string;
onClose?: () => void;
}
export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose }) => {
const terminalRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">(
"connecting",
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!terminalRef.current) return;
// Initialize terminal
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: "#1e1e1e",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
selectionBackground: "#264f78",
black: "#000000",
red: "#cd3131",
green: "#0dbc79",
yellow: "#e5e510",
blue: "#2472c8",
magenta: "#bc3fbc",
cyan: "#11a8cd",
white: "#e5e5e5",
brightBlack: "#666666",
brightRed: "#f14c4c",
brightGreen: "#23d18b",
brightYellow: "#f5f543",
brightBlue: "#3b8eea",
brightMagenta: "#d670d6",
brightCyan: "#29b8db",
brightWhite: "#e5e5e5",
},
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
term.open(terminalRef.current);
fitAddon.fit();
// Build WebSocket URL
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
// Connect WebSocket
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setStatus("connected");
setError(null);
};
ws.onmessage = (event) => {
if (event.data instanceof Blob) {
event.data.arrayBuffer().then((buffer) => {
const data = new Uint8Array(buffer);
term.write(data);
});
} else if (typeof event.data === "string") {
try {
const msg = JSON.parse(event.data);
if (msg.type === "status" && msg.status === "connected") {
setStatus("connected");
}
} catch {
term.write(event.data);
}
}
};
ws.onclose = (event) => {
setStatus("disconnected");
if (event.code !== 1000) {
setError(`Connection closed (code: ${event.code})`);
}
};
ws.onerror = () => {
setStatus("error");
setError("WebSocket error");
};
// Handle terminal input
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
// Handle resize
const handleResize = () => {
fitAddon.fit();
const { cols, rows } = term;
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "resize",
cols,
rows,
}),
);
}
};
window.addEventListener("resize", handleResize);
// Initial resize
setTimeout(handleResize, 100);
return () => {
window.removeEventListener("resize", handleResize);
ws.close();
term.dispose();
};
}, [instanceId]);
return (
<div className="terminal-wrapper">
<div className="terminal-header">
<div className="terminal-status">
<span
className={`status-dot ${status}`}
aria-label={`Terminal status: ${status}`}
/>
<span className="status-text">{status}</span>
</div>
{onClose && (
<button className="terminal-close" onClick={onClose} type="button">
Close
</button>
)}
</div>
{error && <div className="terminal-error">{error}</div>}
<div ref={terminalRef} className="terminal-container" />
</div>
);
};
+38
View File
@@ -0,0 +1,38 @@
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
import { TerminalComponent } from "../components/terminal";
import { Icon } from "../components/icon";
export const TerminalPage: React.FC = () => {
const { instanceId } = useParams<{ instanceId: string }>();
const navigate = useNavigate();
if (!instanceId) {
return (
<section className="stack">
<h1>Terminal</h1>
<p className="muted">No instance ID provided.</p>
</section>
);
}
return (
<section className="terminal-page">
<div className="terminal-page-header">
<button
className="secondary-button"
onClick={() => navigate(-1)}
type="button"
>
<Icon name="arrow-left" size="sm" />
Back
</button>
<h1>Terminal</h1>
</div>
<TerminalComponent
instanceId={instanceId}
onClose={() => navigate(-1)}
/>
</section>
);
};
+2
View File
@@ -12,6 +12,7 @@ import { ProjectSettingsPage } from "./pages/project-settings";
import { RepoWorkspace } from "./pages/repo-workspace";
import { SSHKeysPage } from "./pages/ssh-keys";
import { SettingsPage } from "./pages/settings";
import { TerminalPage } from "./pages/terminal";
import { ToolTypesPage } from "./pages/tool-types";
export const AppRouter = () => {
@@ -36,6 +37,7 @@ export const AppRouter = () => {
<Route path="profile" element={<ProfilePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="tool-types" element={<ToolTypesPage />} />
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
</Route>
<Route path="/404" element={<NotFoundPage />} />
<Route path="*" element={<Navigate to="/404" replace />} />
+135
View File
@@ -2321,3 +2321,138 @@ a.nav-item,
gap: var(--space-2);
align-items: center;
}
/* ============================================
Terminal Styles
============================================ */
.terminal-page {
display: flex;
flex-direction: column;
height: 100vh;
padding: var(--space-4);
gap: var(--space-4);
}
.terminal-page-header {
display: flex;
align-items: center;
gap: var(--space-4);
flex-shrink: 0;
}
.terminal-page-header h1 {
margin: 0;
}
.terminal-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: #1e1e1e;
}
.terminal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) var(--space-4);
background: #2d2d2d;
border-bottom: 1px solid #3e3e3e;
flex-shrink: 0;
}
.terminal-status {
display: flex;
align-items: center;
gap: var(--space-2);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
}
.status-dot.connecting {
background: #f5f543;
animation: pulse 1.5s infinite;
}
.status-dot.connected {
background: #0dbc79;
}
.status-dot.disconnected,
.status-dot.error {
background: #cd3131;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.status-text {
font-size: 0.875rem;
color: #d4d4d4;
text-transform: capitalize;
}
.terminal-close {
padding: var(--space-1) var(--space-3);
background: transparent;
border: 1px solid #666;
border-radius: 6px;
color: #d4d4d4;
cursor: pointer;
font-size: 0.875rem;
}
.terminal-close:hover {
background: #3e3e3e;
}
.terminal-error {
padding: var(--space-3) var(--space-4);
background: #cd3131;
color: white;
font-size: 0.875rem;
flex-shrink: 0;
}
.terminal-container {
flex: 1;
min-height: 0;
padding: var(--space-2);
}
.terminal-container .xterm {
height: 100%;
}
.terminal-container .xterm-viewport {
background: #1e1e1e !important;
}
/* Responsive terminal */
@media (max-width: 767px) {
.terminal-page {
padding: var(--space-2);
gap: var(--space-2);
}
.terminal-page-header h1 {
font-size: 1.25rem;
}
}
+7 -1
View File
@@ -31,6 +31,8 @@ import {
ArrowSquareOut,
Play,
Stop,
Terminal,
ArrowLeft,
} from "@phosphor-icons/react";
export type IconName =
@@ -70,7 +72,9 @@ export type IconName =
| "binary"
| "external"
| "play"
| "stop";
| "stop"
| "terminal"
| "arrow-left";
export const iconRegistry: Record<
IconName,
@@ -124,6 +128,8 @@ export const iconRegistry: Record<
external: ArrowSquareOut,
play: Play,
stop: Stop,
terminal: Terminal,
"arrow-left": ArrowLeft,
};
export const iconCategories = {
@@ -0,0 +1,2 @@
schema: spec-driven
name: tool-terminal
+139
View File
@@ -0,0 +1,139 @@
# Tool Terminal - Design
## Architecture
```
Browser Backend Container
│ │ │
│ WebSocket connect │ │
│─────────────────────────>│ │
│ │ docker exec -it bash │
│ │───────────────────────────>│
│ │ │
│ stdin (keystrokes) │ stdin │
│─────────────────────────>│───────────────────────────>│
│ │ │
│ stdout/stderr │ stdout/stderr │
│<─────────────────────────│<───────────────────────────│
│ │ │
│ resize (cols, rows) │ pty resize │
│─────────────────────────>│───────────────────────────>│
│ │ │
```
## Component Design
### Backend
**TerminalManager:**
- Manages active terminal sessions
- Maps WebSocket connections to container processes
- Handles session lifecycle (create, resize, cleanup)
**WebSocket Endpoint:**
- `GET /ws/tool-instances/{instance_id}/terminal`
- Authenticates user via session cookie
- Establishes bidirectional WebSocket
- Spawns `docker exec -it` with pseudo-TTY
**Docker PTY:**
- Uses `docker exec` with TTY allocation
- Streams stdin/stdout/stderr via subprocess
- Handles resize via `stty` or docker API
### Frontend
**TerminalComponent:**
- Wraps xterm.js terminal
- Manages WebSocket connection
- Handles terminal resize
- Fits container to parent element
**TerminalPage:**
- Full-page terminal view
- Shows instance name in header
- Connection status indicator
- Reconnect on disconnect
## Data Flow
1. User clicks "Terminal" on running instance
2. Frontend opens WebSocket connection
3. Backend verifies ownership and spawns shell
4. Bidirectional streaming begins
5. User types → WebSocket → docker exec stdin
6. Container output → docker exec stdout → WebSocket → xterm.js
7. Resize events forwarded to adjust PTY dimensions
## Session Lifecycle
```
Connect
Authenticate ──> Reject (403)
Spawn Shell
Stream I/O ◄───> Resize
Disconnect
Cleanup Process
```
## Access Control
- WebSocket handshake validates session cookie
- Backend verifies user owns the instance
- Reject connection with 403 if unauthorized
- Close connection if instance stops running
## Technical Details
**Backend Libraries:**
- `asyncio` for WebSocket handling
- `subprocess` with `docker exec -it`
- `fcntl` for PTY resize (Linux)
**Frontend Libraries:**
- `xterm` - Terminal emulator
- `xterm-addon-fit` - Auto-fit to container
- `xterm-addon-web-links` - Clickable URLs
**Docker Commands:**
```bash
# Spawn shell
docker exec -it {container_id} /bin/bash
# Alternative with explicit TTY
docker exec -i {container_id} sh -c 'exec bash'
```
## Error Handling
- Connection refused → Show error message
- Container not running → Disable terminal button
- Shell spawn failed → Show error and close
- Network disconnect → Attempt reconnect
## CSS Integration
```css
.terminal-container {
width: 100%;
height: 100%;
min-height: 400px;
background: #1e1e1e;
border-radius: 8px;
overflow: hidden;
}
.terminal-container .xterm {
padding: 8px;
}
```
@@ -0,0 +1,53 @@
# Tool Terminal
## Problem
Tool instances (code-server, jupyter-notebook, etc.) run in Docker containers but users have no way to access a shell inside those containers. This limits debugging, running ad-hoc commands, and managing the container environment.
## Solution
Provide browser-based terminal access to running tool containers via WebSocket:
1. **WebSocket terminal sessions** - Real-time bidirectional communication
2. **Pseudo-TTY** - Full terminal emulation with proper shell behavior
3. **xterm.js frontend** - Professional terminal UI in the browser
4. **Session management** - Multiple independent terminals per instance
5. **Access control** - Only instance owners can access terminals
## Key Features
### Terminal Access
- Open terminal from any running tool instance
- Full bash/zsh shell inside the container
- Standard terminal features (colors, cursor, history, etc.)
### Real-time I/O
- Instant character-by-character streaming
- Stdout/stderr combined output
- Support for interactive programs (vim, nano, etc.)
### Terminal Resize
- Dynamic column/row adjustment
- Window resize handled gracefully
- Proper text wrapping and scrolling
### Session Management
- Multiple terminals per instance
- Independent sessions with isolation
- Cleanup on disconnect
## Benefits
- **Debug containers** - Inspect running processes, check logs
- **Run commands** - Execute ad-hoc scripts or tools
- **Manage environment** - Install packages, edit config files
- **No SSH needed** - Browser-based access from anywhere
## Success Criteria
- [ ] Terminal opens for any running instance
- [ ] Commands execute and display output in real-time
- [ ] Terminal resizes with browser window
- [ ] Multiple terminals work independently
- [ ] Sessions clean up on disconnect
- [ ] Unauthorized users cannot access terminals
@@ -0,0 +1,165 @@
# Tool Terminal Specification
## Requirements
### Functional Requirements
1. **WebSocket Terminal**: Provide terminal sessions via WebSocket at `/ws/tool-instances/{instance_id}/terminal`
2. **Terminal I/O**: Stream stdin/stdout/stderr bidirectionally in real-time
3. **Terminal Resize**: Support dynamic resize with COLS/ROWS updates
4. **Session Management**: Multiple independent sessions per instance, cleanup on disconnect
5. **Access Control**: Only instance owners can access, reject unauthorized with 403
6. **Shell Spawn**: Spawn `/bin/bash` or `/bin/sh` inside container via `docker exec`
### Non-Functional Requirements
1. **Latency**: Character input to display < 50ms
2. **Concurrent Sessions**: Support 10+ simultaneous terminal sessions
3. **Browser Support**: Chrome, Firefox, Safari, Edge
4. **Container Lifecycle**: Terminal closes when container stops
## API Specification
### WebSocket Endpoint
**URL:** `wss://{api_host}/ws/tool-instances/{instance_id}/terminal`
**Protocol:**
- Connection requires valid session cookie
- Binary frame: terminal output (stdout/stderr)
- Text frame: control messages (JSON)
**Control Messages:**
Request (Client → Server):
```json
{
"type": "resize",
"cols": 80,
"rows": 24
}
```
Response (Server → Client):
```json
{
"type": "status",
"status": "connected"
}
```
### REST Endpoint
**GET /tool-instances/{instance_id}/terminal** (HTML page)
- Returns terminal page for the instance
- Verifies ownership
- Returns 404 if instance not found
- Returns 403 if unauthorized
## Frontend Specification
### TerminalComponent
**Props:**
```typescript
interface TerminalProps {
instanceId: string;
instanceName: string;
onClose?: () => void;
}
```
**Features:**
- xterm.js terminal with custom theme
- WebSocket connection management
- Auto-fit to parent container
- Connection status indicator
- Reconnect on disconnect (3 retries)
### TerminalPage
**Route:** `/instances/:instanceId/terminal`
- Full-page terminal view
- Shows instance name in header
- Back button to instance list
- Connection status badge
## Backend Specification
### TerminalManager
**Methods:**
```python
class TerminalManager:
async def create_session(
self,
instance_id: uuid.UUID,
user_id: uuid.UUID,
websocket: WebSocket
) -> TerminalSession
async def handle_resize(
self,
session_id: str,
cols: int,
rows: int
) -> None
async def close_session(self, session_id: str) -> None
```
### TerminalSession
**Responsibilities:**
- Manage docker exec subprocess
- Stream I/O between WebSocket and PTY
- Handle resize signals
- Cleanup on disconnect
**Docker Command:**
```python
async def spawn_shell(container_id: str) -> subprocess.Process:
proc = await asyncio.create_subprocess_exec(
"docker", "exec", "-i", container_id, "/bin/bash",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
return proc
```
## Dependencies
**Backend:**
- FastAPI WebSocket support
- asyncio subprocess
- docker CLI
**Frontend:**
- `xterm` (v5.x)
- `xterm-addon-fit`
- `xterm-addon-web-links`
## Migration Plan
1. Install xterm.js dependencies
2. Create backend WebSocket endpoint
3. Create TerminalManager and TerminalSession
4. Create frontend TerminalComponent
5. Add terminal route and navigation
6. Test with running instances
## Testing
- Unit: TerminalSession I/O streaming
- Integration: WebSocket connection lifecycle
- Manual: Terminal functionality with real containers
## Quality Gates
- pytest
- mypy
- ruff
- npm run typecheck
- npm run lint
- npm run build
+96
View File
@@ -0,0 +1,96 @@
# Tool Terminal - Tasks
## Phase 1: Backend Setup
- [x] **Task 1.1**: Install backend dependencies
- Add `asyncio-subprocess` handling
- Verify FastAPI WebSocket support
- [x] **Task 1.2**: Create TerminalSession class
- Create `src/services/terminal_session.py`
- Manage docker exec subprocess
- Stream I/O between WebSocket and PTY
- Handle resize signals
- Cleanup on disconnect
- [x] **Task 1.3**: Create TerminalManager
- Create `src/services/terminal_manager.py`
- Manage active sessions dictionary
- Create/close session methods
- Handle resize forwarding
- Session cleanup on disconnect
## Phase 2: WebSocket Endpoint
- [x] **Task 2.1**: Create WebSocket endpoint
- Add `GET /ws/tool-instances/{instance_id}/terminal`
- Authenticate via session cookie
- Verify instance ownership
- Establish bidirectional WebSocket
- Handle connection lifecycle
- [x] **Task 2.2**: Add WebSocket to main app
- Register WebSocket router in `main.py`
- Configure WebSocket middleware
- Handle CORS for WebSocket connections
## Phase 3: Frontend Dependencies
- [x] **Task 3.1**: Install xterm.js
- `npm install xterm xterm-addon-fit xterm-addon-web-links`
- Add to package.json
## Phase 4: Frontend Components
- [x] **Task 4.1**: Create TerminalComponent
- Create `components/terminal.tsx`
- Initialize xterm.js terminal
- Manage WebSocket connection
- Handle terminal resize with xterm-addon-fit
- Connection status indicator
- Auto-reconnect on disconnect
- [x] **Task 4.2**: Create TerminalPage
- Create `pages/terminal.tsx`
- Full-page terminal layout
- Instance name in header
- Back button
- Connection status badge
## Phase 5: Integration
- [x] **Task 5.1**: Add terminal route
- Add `/instances/:instanceId/terminal` to router
- Link from InstanceList component
- Show terminal button for running instances
- [x] **Task 5.2**: Add terminal button to InstanceList
- Add terminal icon button to running instances
- Disable for stopped instances
- Navigate to terminal page
## Phase 6: Styling
- [x] **Task 6.1**: Add terminal CSS
- Dark terminal theme matching app
- Full-height container
- Proper padding and borders
- Connection status colors
## Phase 7: Quality Gates
- [x] **Task 7.1**: Backend tests
- ruff check
- mypy
- pytest
- [x] **Task 7.2**: Frontend tests
- typecheck
- lint
- build
- [x] **Task 7.3**: Manual testing
- Open terminal for running instance
- Execute commands
- Test resize
- Verify access control