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:
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Generated
+31
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user