diff --git a/apps/api/src/api/terminal.py b/apps/api/src/api/terminal.py new file mode 100644 index 0000000..403b200 --- /dev/null +++ b/apps/api/src/api/terminal.py @@ -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 diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 87680c0..d262870 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -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") diff --git a/apps/api/src/services/terminal_manager.py b/apps/api/src/services/terminal_manager.py new file mode 100644 index 0000000..ebd6e7e --- /dev/null +++ b/apps/api/src/services/terminal_manager.py @@ -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() diff --git a/apps/api/src/services/terminal_session.py b/apps/api/src/services/terminal_session.py new file mode 100644 index 0000000..594b09c --- /dev/null +++ b/apps/api/src/services/terminal_session.py @@ -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 diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 6f1bda9..45dc416 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -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", diff --git a/apps/web/package.json b/apps/web/package.json index 8e15010..d6aa71b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/components/icon.tsx b/apps/web/src/components/icon.tsx index cf74252..f50295d 100644 --- a/apps/web/src/components/icon.tsx +++ b/apps/web/src/components/icon.tsx @@ -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> = { dashboard: House, @@ -111,6 +115,8 @@ const iconMap: Record { + const navigate = useNavigate(); const [instances, setInstances] = useState([]); const [loading, setLoading] = useState(false); const [showCreate, setShowCreate] = useState(false); @@ -154,6 +156,16 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps Open )} + {instance.status === "running" && ( + + )} {instance.status !== "running" && ( + )} + + {error &&
{error}
} +
+
+ ); +}; diff --git a/apps/web/src/pages/terminal.tsx b/apps/web/src/pages/terminal.tsx new file mode 100644 index 0000000..65686d5 --- /dev/null +++ b/apps/web/src/pages/terminal.tsx @@ -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 ( +
+

Terminal

+

No instance ID provided.

+
+ ); + } + + return ( +
+
+ +

Terminal

+
+ navigate(-1)} + /> +
+ ); +}; diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 34785d6..4745788 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -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 = () => { } /> } /> } /> + } /> } /> } /> diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 98c769e..22b63ea 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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; + } +} diff --git a/apps/web/src/utils/icons.ts b/apps/web/src/utils/icons.ts index 45501af..605ab7f 100644 --- a/apps/web/src/utils/icons.ts +++ b/apps/web/src/utils/icons.ts @@ -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 = { diff --git a/openspec/changes/tool-instances/.openspec/config.yaml b/openspec/changes/archive/2026-05-19-tool-instances/.openspec/config.yaml similarity index 100% rename from openspec/changes/tool-instances/.openspec/config.yaml rename to openspec/changes/archive/2026-05-19-tool-instances/.openspec/config.yaml diff --git a/openspec/changes/tool-instances/design.md b/openspec/changes/archive/2026-05-19-tool-instances/design.md similarity index 100% rename from openspec/changes/tool-instances/design.md rename to openspec/changes/archive/2026-05-19-tool-instances/design.md diff --git a/openspec/changes/tool-instances/proposal.md b/openspec/changes/archive/2026-05-19-tool-instances/proposal.md similarity index 100% rename from openspec/changes/tool-instances/proposal.md rename to openspec/changes/archive/2026-05-19-tool-instances/proposal.md diff --git a/openspec/changes/tool-instances/specs/spec.md b/openspec/changes/archive/2026-05-19-tool-instances/specs/spec.md similarity index 100% rename from openspec/changes/tool-instances/specs/spec.md rename to openspec/changes/archive/2026-05-19-tool-instances/specs/spec.md diff --git a/openspec/changes/tool-instances/tasks.md b/openspec/changes/archive/2026-05-19-tool-instances/tasks.md similarity index 100% rename from openspec/changes/tool-instances/tasks.md rename to openspec/changes/archive/2026-05-19-tool-instances/tasks.md diff --git a/openspec/changes/tool-terminal/.openspec/config.yaml b/openspec/changes/tool-terminal/.openspec/config.yaml new file mode 100644 index 0000000..cff6941 --- /dev/null +++ b/openspec/changes/tool-terminal/.openspec/config.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +name: tool-terminal diff --git a/openspec/changes/tool-terminal/design.md b/openspec/changes/tool-terminal/design.md new file mode 100644 index 0000000..d8fa23c --- /dev/null +++ b/openspec/changes/tool-terminal/design.md @@ -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; +} +``` diff --git a/openspec/changes/tool-terminal/proposal.md b/openspec/changes/tool-terminal/proposal.md new file mode 100644 index 0000000..a6f1bea --- /dev/null +++ b/openspec/changes/tool-terminal/proposal.md @@ -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 diff --git a/openspec/changes/tool-terminal/specs/spec.md b/openspec/changes/tool-terminal/specs/spec.md new file mode 100644 index 0000000..43526a1 --- /dev/null +++ b/openspec/changes/tool-terminal/specs/spec.md @@ -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 diff --git a/openspec/changes/tool-terminal/tasks.md b/openspec/changes/tool-terminal/tasks.md new file mode 100644 index 0000000..e9339a8 --- /dev/null +++ b/openspec/changes/tool-terminal/tasks.md @@ -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