feat: add PTY/terminal session support to Process Manager API

Add Docker-style terminal support with -t (TTY) and -i (interactive) flags:

Backend (Rust):
- Add portable-pty dependency for PTY allocation on Unix
- Extend StartProcessRequest with tty, interactive, and terminalSize options
- Add PTY process spawning with TERM=xterm-256color
- Add WebSocket endpoint for bidirectional terminal I/O
- Add terminal resize endpoint (POST /process/:id/resize)
- Add terminal input endpoint (POST /process/:id/input)
- Support base64-encoded binary input
- Process info now includes tty, interactive, and terminalSize fields
- Terminal output is logged to combined.log for persistence

Frontend (Inspector UI):
- Add @xterm/xterm and addons for terminal rendering
- Create Terminal component with xterm.js integration
- Add tabbed view (Terminal/Logs) for PTY processes
- Terminal auto-connects via WebSocket when process is expanded
- Support terminal resize with ResizeObserver
- Show PTY badge on processes with TTY enabled
- Graceful handling of process exit and disconnection

API:
- GET /v1/process/:id/terminal - WebSocket for terminal I/O
- POST /v1/process/:id/resize - Resize terminal (cols, rows)
- POST /v1/process/:id/input - Write data to terminal

WebSocket protocol:
- type: 'data' - Terminal output (server -> client)
- type: 'input' - Terminal input (client -> server)
- type: 'resize' - Resize request (client -> server)
- type: 'exit' - Process exited (server -> client)
- type: 'error' - Error message (server -> client)
This commit is contained in:
Nathan Flurry 2026-01-30 13:12:49 -08:00
parent db0268b88f
commit ac0a22cd07
11 changed files with 1541 additions and 109 deletions

View file

@ -36,8 +36,9 @@ tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
# Async runtime
tokio = { version = "1.36", features = ["macros", "rt-multi-thread", "signal", "time"] }
tokio = { version = "1.36", features = ["macros", "rt-multi-thread", "signal", "time", "io-util", "sync"] }
tokio-stream = { version = "0.1", features = ["sync"] }
tokio-tungstenite = "0.21"
futures = "0.3"
# HTTP client
@ -68,6 +69,10 @@ zip = { version = "0.6", default-features = false, features = ["deflate"] }
url = "2.5"
regress = "0.10"
include_dir = "0.7"
base64 = "0.21"
# PTY support
portable-pty = "0.8"
# Code generation (build deps)
typify = "0.4"

155
docs/process-terminal.md Normal file
View file

@ -0,0 +1,155 @@
# Process Terminal Support
This document describes the PTY/terminal session support added to the Process Manager API.
## Overview
The Process Manager now supports Docker-style terminal sessions with `-t` (TTY) and `-i` (interactive) flags. When a process is started with TTY enabled, a pseudo-terminal (PTY) is allocated, allowing full interactive terminal applications to run.
## API
### Starting a Process with TTY
```bash
curl -X POST http://localhost:2468/v1/process \
-H "Content-Type: application/json" \
-d '{
"command": "bash",
"args": [],
"tty": true,
"interactive": true,
"terminalSize": {
"cols": 120,
"rows": 40
}
}'
```
Response includes `tty: true` and `interactive: true` flags.
### Terminal WebSocket
Connect to a running PTY process via WebSocket:
```
ws://localhost:2468/v1/process/{id}/terminal
```
#### Message Types
**Client -> Server:**
- `{"type": "input", "data": "ls -la\n"}` - Send keyboard input
- `{"type": "resize", "cols": 120, "rows": 40}` - Resize terminal
**Server -> Client:**
- `{"type": "data", "data": "..."}` - Terminal output
- `{"type": "exit", "code": 0}` - Process exited
- `{"type": "error", "message": "..."}` - Error occurred
### Terminal Resize
```bash
curl -X POST http://localhost:2468/v1/process/{id}/resize \
-H "Content-Type: application/json" \
-d '{"cols": 120, "rows": 40}'
```
### Terminal Input (REST)
For non-WebSocket clients:
```bash
curl -X POST http://localhost:2468/v1/process/{id}/input \
-H "Content-Type: application/json" \
-d '{"data": "ls -la\n"}'
```
For binary data, use base64 encoding:
```bash
curl -X POST http://localhost:2468/v1/process/{id}/input \
-H "Content-Type: application/json" \
-d '{"data": "bHMgLWxhCg==", "base64": true}'
```
## Inspector UI
The Inspector UI now shows:
- PTY badge on processes with TTY enabled
- Terminal/Logs tabs for PTY processes
- Interactive xterm.js terminal when expanded
- Auto-resize on window/container resize
## Testing
### Start the Server
```bash
cargo run --package sandbox-agent -- serve
```
### Test Interactive Bash
```bash
# Start a bash shell with PTY
curl -X POST http://localhost:2468/v1/process \
-H "Content-Type: application/json" \
-d '{
"command": "bash",
"tty": true,
"interactive": true
}'
# Open the Inspector UI and interact with the terminal
open http://localhost:2468
```
### Test with vim
```bash
curl -X POST http://localhost:2468/v1/process \
-H "Content-Type: application/json" \
-d '{
"command": "vim",
"args": ["test.txt"],
"tty": true,
"interactive": true
}'
```
### Test with htop
```bash
curl -X POST http://localhost:2468/v1/process \
-H "Content-Type: application/json" \
-d '{
"command": "htop",
"tty": true,
"interactive": true
}'
```
## Implementation Details
### Backend
- Uses `portable-pty` crate for cross-platform PTY support (Unix only for now)
- PTY output is continuously read and broadcast to WebSocket subscribers
- PTY input is received via channel and written to the master PTY
- Terminal resize uses `TIOCSWINSZ` ioctl via portable-pty
- `TERM=xterm-256color` is set automatically
### Frontend
- Uses `@xterm/xterm` for terminal rendering
- `@xterm/addon-fit` for auto-sizing
- `@xterm/addon-web-links` for clickable URLs
- WebSocket connection with JSON protocol
- ResizeObserver for container size changes
## Limitations
- PTY support is currently Unix-only (Linux, macOS)
- Windows support would require ConPTY integration
- Maximum of 256 broadcast subscribers per terminal
- Terminal output is logged but not line-buffered (raw bytes)

View file

@ -21,6 +21,9 @@
"dependencies": {
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0"
}
}

View file

@ -1,5 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Play, Square, Skull, Trash2, RefreshCw, ChevronDown, ChevronRight, Terminal } from "lucide-react";
import { Play, Square, Skull, Trash2, RefreshCw, ChevronDown, ChevronRight, Terminal as TerminalIcon, Monitor, FileText } from "lucide-react";
import { Terminal } from "../terminal";
export interface TerminalSize {
cols: number;
rows: number;
}
export interface ProcessInfo {
id: string;
@ -15,6 +21,12 @@ export interface ProcessInfo {
startedAt: number;
stoppedAt?: number | null;
cwd?: string | null;
/** Whether this process has a PTY allocated (terminal mode) */
tty?: boolean;
/** Whether stdin is kept open for interactive input */
interactive?: boolean;
/** Current terminal size (if tty is true) */
terminalSize?: TerminalSize | null;
}
export interface ProcessListResponse {
@ -69,6 +81,32 @@ const StatusBadge = ({ status, exitCode }: { status: string; exitCode?: number |
);
};
const TtyBadge = ({ tty, interactive }: { tty?: boolean; interactive?: boolean }) => {
if (!tty) return null;
return (
<span
style={{
background: "var(--color-info)",
color: "white",
padding: "2px 6px",
borderRadius: "4px",
fontSize: "10px",
fontWeight: 500,
display: "inline-flex",
alignItems: "center",
gap: 4
}}
title={`TTY${interactive ? " + Interactive" : ""}`}
>
<Monitor style={{ width: 10, height: 10 }} />
PTY
</span>
);
};
type ViewMode = "logs" | "terminal";
const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
const [processes, setProcesses] = useState<ProcessInfo[]>([]);
const [loading, setLoading] = useState(false);
@ -78,6 +116,7 @@ const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
const [logsLoading, setLogsLoading] = useState<Record<string, boolean>>({});
const [stripTimestamps, setStripTimestamps] = useState(false);
const [logStream, setLogStream] = useState<"combined" | "stdout" | "stderr">("combined");
const [viewMode, setViewMode] = useState<Record<string, ViewMode>>({});
const refreshTimerRef = useRef<number | null>(null);
const fetchWithAuth = useCallback(async (url: string, options: RequestInit = {}) => {
@ -170,14 +209,26 @@ const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
}
}, [baseUrl, fetchWithAuth, fetchProcesses, expandedId]);
const toggleExpand = useCallback((id: string) => {
const toggleExpand = useCallback((id: string, process: ProcessInfo) => {
if (expandedId === id) {
setExpandedId(null);
} else {
setExpandedId(id);
fetchLogs(id);
// Default to terminal view for TTY processes, logs for regular processes
const defaultMode = process.tty && process.status === "running" ? "terminal" : "logs";
setViewMode(prev => ({ ...prev, [id]: prev[id] || defaultMode }));
if (!process.tty || viewMode[id] === "logs") {
fetchLogs(id);
}
}
}, [expandedId, fetchLogs]);
}, [expandedId, fetchLogs, viewMode]);
const getWsUrl = useCallback((id: string) => {
// Convert HTTP URL to WebSocket URL
const wsProtocol = baseUrl.startsWith("https") ? "wss" : "ws";
const wsBaseUrl = baseUrl.replace(/^https?:/, wsProtocol + ":");
return `${wsBaseUrl}/v1/process/${id}/terminal`;
}, [baseUrl]);
// Initial fetch and auto-refresh
useEffect(() => {
@ -195,17 +246,18 @@ const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
// Refresh logs when options change
useEffect(() => {
if (expandedId) {
if (expandedId && viewMode[expandedId] === "logs") {
fetchLogs(expandedId);
}
}, [stripTimestamps, logStream]);
}, [stripTimestamps, logStream, expandedId, viewMode, fetchLogs]);
const runningCount = processes.filter(p => p.status === "running").length;
const ttyCount = processes.filter(p => p.tty).length;
return (
<div className="processes-tab">
<div className="processes-header" style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<Terminal style={{ width: 16, height: 16 }} />
<TerminalIcon style={{ width: 16, height: 16 }} />
<span style={{ fontWeight: 600 }}>Processes</span>
{runningCount > 0 && (
<span className="running-badge" style={{
@ -218,6 +270,17 @@ const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
{runningCount} running
</span>
)}
{ttyCount > 0 && (
<span style={{
background: "var(--color-info)",
color: "white",
padding: "2px 6px",
borderRadius: "10px",
fontSize: "11px"
}}>
{ttyCount} PTY
</span>
)}
<div style={{ flex: 1 }} />
<button
className="button ghost small"
@ -237,9 +300,12 @@ const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
{processes.length === 0 && !loading && (
<div className="empty-state" style={{ textAlign: "center", padding: "24px 16px", color: "var(--color-muted)" }}>
<Terminal style={{ width: 32, height: 32, marginBottom: 8, opacity: 0.5 }} />
<TerminalIcon style={{ width: 32, height: 32, marginBottom: 8, opacity: 0.5 }} />
<p>No processes found</p>
<p style={{ fontSize: 12 }}>Start a process using the API</p>
<p style={{ fontSize: 11, marginTop: 8 }}>
Use <code style={{ background: "var(--bg-code)", padding: "2px 4px", borderRadius: 3 }}>tty: true</code> for interactive terminal sessions
</p>
</div>
)}
@ -264,7 +330,7 @@ const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
background: expandedId === process.id ? "var(--bg-secondary)" : "transparent",
cursor: "pointer"
}}
onClick={() => toggleExpand(process.id)}
onClick={() => toggleExpand(process.id, process)}
>
{expandedId === process.id ? (
<ChevronDown style={{ width: 14, height: 14, flexShrink: 0 }} />
@ -277,9 +343,11 @@ const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
<code style={{ fontSize: 12, fontWeight: 500 }}>
{process.command} {process.args.join(" ")}
</code>
<TtyBadge tty={process.tty} interactive={process.interactive} />
</div>
<div style={{ fontSize: 11, color: "var(--color-muted)", marginTop: 2 }}>
ID: {process.id} Started: {formatTimestamp(process.startedAt)} Duration: {formatDuration(process.startedAt, process.stoppedAt)}
{process.terminalSize && `${process.terminalSize.cols}x${process.terminalSize.rows}`}
</div>
</div>
@ -317,55 +385,141 @@ const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
</div>
{expandedId === process.id && (
<div className="process-logs" style={{ borderTop: "1px solid var(--border-color)" }}>
<div style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "8px 12px",
background: "var(--bg-tertiary)",
fontSize: 12
}}>
<select
value={logStream}
onChange={e => setLogStream(e.target.value as typeof logStream)}
style={{ fontSize: 11, padding: "2px 4px" }}
>
<option value="combined">Combined</option>
<option value="stdout">stdout</option>
<option value="stderr">stderr</option>
</select>
<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
<input
type="checkbox"
checked={stripTimestamps}
onChange={e => setStripTimestamps(e.target.checked)}
<div className="process-detail" style={{ borderTop: "1px solid var(--border-color)" }}>
{/* View mode tabs for TTY processes */}
{process.tty && (
<div style={{
display: "flex",
borderBottom: "1px solid var(--border-color)",
background: "var(--bg-tertiary)",
}}>
<button
onClick={() => {
setViewMode(prev => ({ ...prev, [process.id]: "terminal" }));
}}
style={{
padding: "8px 16px",
border: "none",
background: viewMode[process.id] === "terminal" ? "var(--bg-secondary)" : "transparent",
borderBottom: viewMode[process.id] === "terminal" ? "2px solid var(--color-primary)" : "2px solid transparent",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 12,
color: viewMode[process.id] === "terminal" ? "var(--color-primary)" : "var(--color-muted)",
}}
>
<Monitor style={{ width: 14, height: 14 }} />
Terminal
</button>
<button
onClick={() => {
setViewMode(prev => ({ ...prev, [process.id]: "logs" }));
fetchLogs(process.id);
}}
style={{
padding: "8px 16px",
border: "none",
background: viewMode[process.id] === "logs" ? "var(--bg-secondary)" : "transparent",
borderBottom: viewMode[process.id] === "logs" ? "2px solid var(--color-primary)" : "2px solid transparent",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 12,
color: viewMode[process.id] === "logs" ? "var(--color-primary)" : "var(--color-muted)",
}}
>
<FileText style={{ width: 14, height: 14 }} />
Logs
</button>
</div>
)}
{/* Terminal view */}
{process.tty && viewMode[process.id] === "terminal" && process.status === "running" && (
<div style={{ height: 400 }}>
<Terminal
wsUrl={getWsUrl(process.id)}
active={expandedId === process.id}
cols={process.terminalSize?.cols || 80}
rows={process.terminalSize?.rows || 24}
/>
Strip timestamps
</label>
<button
className="button ghost small"
onClick={() => fetchLogs(process.id)}
disabled={logsLoading[process.id]}
>
<RefreshCw style={{ width: 12, height: 12 }} className={logsLoading[process.id] ? "spinning" : ""} />
Refresh
</button>
</div>
<pre style={{
margin: 0,
padding: 12,
fontSize: 11,
lineHeight: 1.5,
maxHeight: 300,
overflow: "auto",
background: "var(--bg-code)",
color: "var(--color-code)",
whiteSpace: "pre-wrap",
wordBreak: "break-all"
}}>
{logsLoading[process.id] ? "Loading..." : (logs[process.id] || "(no logs)")}
</pre>
</div>
)}
{/* Terminal placeholder when process is not running */}
{process.tty && viewMode[process.id] === "terminal" && process.status !== "running" && (
<div style={{
height: 200,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#1a1a1a",
color: "var(--color-muted)",
flexDirection: "column",
gap: 8,
}}>
<Monitor style={{ width: 32, height: 32, opacity: 0.5 }} />
<span>Process is not running</span>
<span style={{ fontSize: 11 }}>Terminal connection unavailable</span>
</div>
)}
{/* Logs view */}
{(!process.tty || viewMode[process.id] === "logs") && (
<div className="process-logs">
<div style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "8px 12px",
background: "var(--bg-tertiary)",
fontSize: 12
}}>
<select
value={logStream}
onChange={e => setLogStream(e.target.value as typeof logStream)}
style={{ fontSize: 11, padding: "2px 4px" }}
>
<option value="combined">Combined</option>
<option value="stdout">stdout</option>
<option value="stderr">stderr</option>
</select>
<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
<input
type="checkbox"
checked={stripTimestamps}
onChange={e => setStripTimestamps(e.target.checked)}
/>
Strip timestamps
</label>
<button
className="button ghost small"
onClick={() => fetchLogs(process.id)}
disabled={logsLoading[process.id]}
>
<RefreshCw style={{ width: 12, height: 12 }} className={logsLoading[process.id] ? "spinning" : ""} />
Refresh
</button>
</div>
<pre style={{
margin: 0,
padding: 12,
fontSize: 11,
lineHeight: 1.5,
maxHeight: 300,
overflow: "auto",
background: "var(--bg-code)",
color: "var(--color-code)",
whiteSpace: "pre-wrap",
wordBreak: "break-all"
}}>
{logsLoading[process.id] ? "Loading..." : (logs[process.id] || "(no logs)")}
</pre>
</div>
)}
</div>
)}
</div>

View file

@ -0,0 +1,269 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { Terminal as XTerm } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
import "@xterm/xterm/css/xterm.css";
export interface TerminalProps {
/** WebSocket URL for terminal connection */
wsUrl: string;
/** Whether the terminal is currently active/focused */
active?: boolean;
/** Callback when the terminal is closed */
onClose?: () => void;
/** Callback when the terminal connection status changes */
onConnectionChange?: (connected: boolean) => void;
/** Initial number of columns */
cols?: number;
/** Initial number of rows */
rows?: number;
}
interface TerminalMessage {
type: "data" | "input" | "resize" | "exit" | "error";
data?: string;
cols?: number;
rows?: number;
code?: number | null;
message?: string;
}
const Terminal = ({
wsUrl,
active = true,
onClose,
onConnectionChange,
cols = 80,
rows = 24,
}: TerminalProps) => {
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
// Initialize terminal
useEffect(() => {
if (!terminalRef.current) return;
const term = new XTerm({
cursorBlink: true,
fontSize: 13,
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", Menlo, Monaco, "Courier New", monospace',
theme: {
background: "#1a1a1a",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
cursorAccent: "#1a1a1a",
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",
},
cols,
rows,
});
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
term.loadAddon(fitAddon);
term.loadAddon(webLinksAddon);
term.open(terminalRef.current);
// Fit terminal to container
setTimeout(() => fitAddon.fit(), 0);
xtermRef.current = term;
fitAddonRef.current = fitAddon;
// Handle window resize
const handleResize = () => {
if (fitAddonRef.current && xtermRef.current) {
fitAddonRef.current.fit();
// Send resize to server
const { cols, rows } = xtermRef.current;
sendResize(cols, rows);
}
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
term.dispose();
xtermRef.current = null;
fitAddonRef.current = null;
};
}, [cols, rows]);
// Send resize message
const sendResize = useCallback((cols: number, rows: number) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
const msg: TerminalMessage = { type: "resize", cols, rows };
wsRef.current.send(JSON.stringify(msg));
}
}, []);
// Connect WebSocket
useEffect(() => {
if (!wsUrl || !xtermRef.current) return;
setError(null);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setConnected(true);
onConnectionChange?.(true);
xtermRef.current?.writeln("\x1b[32m● Connected to terminal\x1b[0m\r\n");
// Send initial resize
if (fitAddonRef.current && xtermRef.current) {
fitAddonRef.current.fit();
const { cols, rows } = xtermRef.current;
sendResize(cols, rows);
}
};
ws.onmessage = (event) => {
try {
const msg: TerminalMessage = JSON.parse(event.data);
switch (msg.type) {
case "data":
if (msg.data) {
xtermRef.current?.write(msg.data);
}
break;
case "exit":
xtermRef.current?.writeln(`\r\n\x1b[33m● Process exited with code ${msg.code ?? "unknown"}\x1b[0m`);
onClose?.();
break;
case "error":
setError(msg.message || "Unknown error");
xtermRef.current?.writeln(`\r\n\x1b[31m● Error: ${msg.message}\x1b[0m`);
break;
}
} catch (e) {
// Handle binary data
if (event.data instanceof Blob) {
event.data.text().then((text: string) => {
xtermRef.current?.write(text);
});
}
}
};
ws.onerror = () => {
setError("WebSocket connection error");
setConnected(false);
onConnectionChange?.(false);
};
ws.onclose = () => {
setConnected(false);
onConnectionChange?.(false);
xtermRef.current?.writeln("\r\n\x1b[31m● Disconnected from terminal\x1b[0m");
};
// Handle terminal input
const onData = xtermRef.current.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
const msg: TerminalMessage = { type: "input", data };
ws.send(JSON.stringify(msg));
}
});
return () => {
onData.dispose();
ws.close();
wsRef.current = null;
};
}, [wsUrl, onClose, onConnectionChange, sendResize]);
// Handle container resize with ResizeObserver
useEffect(() => {
if (!terminalRef.current) return;
const resizeObserver = new ResizeObserver(() => {
if (fitAddonRef.current && xtermRef.current) {
fitAddonRef.current.fit();
const { cols, rows } = xtermRef.current;
sendResize(cols, rows);
}
});
resizeObserver.observe(terminalRef.current);
return () => {
resizeObserver.disconnect();
};
}, [sendResize]);
// Focus terminal when active
useEffect(() => {
if (active && xtermRef.current) {
xtermRef.current.focus();
}
}, [active]);
return (
<div className="terminal-container" style={{ height: "100%", position: "relative" }}>
{error && (
<div
style={{
position: "absolute",
top: 8,
right: 8,
background: "var(--color-error)",
color: "white",
padding: "4px 8px",
borderRadius: 4,
fontSize: 12,
zIndex: 10,
}}
>
{error}
</div>
)}
<div
ref={terminalRef}
style={{
height: "100%",
width: "100%",
background: "#1a1a1a",
borderRadius: 4,
overflow: "hidden",
}}
/>
<div
style={{
position: "absolute",
bottom: 4,
right: 8,
fontSize: 10,
color: connected ? "var(--color-success)" : "var(--color-muted)",
}}
>
{connected ? "● Connected" : "○ Disconnected"}
</div>
</div>
);
};
export default Terminal;

View file

@ -0,0 +1,2 @@
export { default as Terminal } from "./Terminal";
export type { TerminalProps } from "./Terminal";

View file

@ -27,6 +27,8 @@ dirs.workspace = true
time.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
tokio-tungstenite.workspace = true
base64.workspace = true
tower-http.workspace = true
utoipa.workspace = true
schemars.workspace = true
@ -38,6 +40,7 @@ tempfile = { workspace = true, optional = true }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
portable-pty = { workspace = true }
[dev-dependencies]
http-body-util.workspace = true

View file

@ -5,4 +5,5 @@ mod agent_server_logs;
pub mod process_manager;
pub mod router;
pub mod telemetry;
pub mod terminal;
pub mod ui;

View file

@ -1,8 +1,11 @@
//! Process Manager - API for spawning and managing background processes.
//!
//! Supports both regular processes and PTY-based terminal sessions.
//! PTY sessions enable interactive terminal applications with full TTY support.
use std::collections::HashMap;
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
@ -15,13 +18,20 @@ use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use tokio::io::{AsyncBufReadExt, BufReader as TokioBufReader};
use tokio::process::{Child, Command};
use tokio::sync::{broadcast, Mutex, RwLock};
use tokio::sync::{broadcast, mpsc, Mutex, RwLock};
use utoipa::ToSchema;
use sandbox_agent_error::SandboxError;
#[cfg(unix)]
use portable_pty::{native_pty_system, CommandBuilder, PtyPair, PtySize};
static PROCESS_ID_COUNTER: AtomicU64 = AtomicU64::new(1);
/// Default terminal size (columns x rows)
const DEFAULT_COLS: u16 = 80;
const DEFAULT_ROWS: u16 = 24;
/// Process status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "lowercase")]
@ -36,6 +46,23 @@ pub enum ProcessStatus {
Killed,
}
/// Terminal size configuration
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct TerminalSize {
pub cols: u16,
pub rows: u16,
}
impl Default for TerminalSize {
fn default() -> Self {
Self {
cols: DEFAULT_COLS,
rows: DEFAULT_ROWS,
}
}
}
/// Log file paths for a process
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
@ -61,6 +88,15 @@ pub struct ProcessInfo {
pub stopped_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
/// Whether this process has a PTY allocated (terminal mode)
#[serde(default)]
pub tty: bool,
/// Whether stdin is kept open for interactive input
#[serde(default)]
pub interactive: bool,
/// Current terminal size (if tty is true)
#[serde(skip_serializing_if = "Option::is_none")]
pub terminal_size: Option<TerminalSize>,
}
/// Request to start a new process
@ -74,6 +110,15 @@ pub struct StartProcessRequest {
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String>,
/// Allocate a pseudo-TTY for the process (like docker -t)
#[serde(default)]
pub tty: bool,
/// Keep stdin open for interactive input (like docker -i)
#[serde(default)]
pub interactive: bool,
/// Initial terminal size (only used if tty is true)
#[serde(skip_serializing_if = "Option::is_none")]
pub terminal_size: Option<TerminalSize>,
}
/// Response after starting a process
@ -83,6 +128,12 @@ pub struct StartProcessResponse {
pub id: String,
pub status: ProcessStatus,
pub log_paths: ProcessLogPaths,
/// Whether this process has a PTY allocated
#[serde(default)]
pub tty: bool,
/// Whether stdin is available for input
#[serde(default)]
pub interactive: bool,
}
/// Response listing all processes
@ -118,16 +169,92 @@ pub struct LogsResponse {
pub lines: usize,
}
/// Request to resize a terminal
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ResizeTerminalRequest {
pub cols: u16,
pub rows: u16,
}
/// Request to write data to a process's stdin/terminal
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct WriteInputRequest {
/// Data to write (can be raw bytes encoded as base64 or UTF-8 text)
pub data: String,
/// Whether data is base64 encoded (for binary data)
#[serde(default)]
pub base64: bool,
}
/// Message types for terminal WebSocket communication
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum TerminalMessage {
/// Data from the terminal (output)
#[serde(rename_all = "camelCase")]
Data { data: String },
/// Data to write to the terminal (input)
#[serde(rename_all = "camelCase")]
Input { data: String },
/// Resize the terminal
#[serde(rename_all = "camelCase")]
Resize { cols: u16, rows: u16 },
/// Terminal closed/process exited
#[serde(rename_all = "camelCase")]
Exit { code: Option<i32> },
/// Error message
#[serde(rename_all = "camelCase")]
Error { message: String },
}
/// Internal state for a managed process (non-PTY mode)
struct RegularProcess {
child: Child,
log_broadcaster: broadcast::Sender<String>,
}
/// Internal state for a PTY process
#[cfg(unix)]
struct PtyProcess {
/// The PTY pair (master + child handle)
pty_pair: PtyPair,
/// Child process handle
child: Box<dyn portable_pty::Child + Send>,
/// Writer for sending data to the PTY
writer: Box<dyn Write + Send>,
/// Current terminal size
size: TerminalSize,
/// Channel for sending terminal output to subscribers
output_tx: broadcast::Sender<Vec<u8>>,
/// Channel for receiving input to write to terminal
input_tx: mpsc::UnboundedSender<Vec<u8>>,
}
/// Internal state for a managed process
#[derive(Debug)]
struct ManagedProcess {
info: ProcessInfo,
/// Handle to the running process (None if process has exited)
child: Option<Child>,
/// Broadcaster for log lines (for SSE streaming)
/// Regular process handle (non-PTY)
regular: Option<RegularProcess>,
/// PTY process handle (terminal mode)
#[cfg(unix)]
pty: Option<PtyProcess>,
/// Broadcaster for log lines (for SSE streaming, used in regular mode)
log_broadcaster: broadcast::Sender<String>,
}
impl std::fmt::Debug for ManagedProcess {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ManagedProcess")
.field("info", &self.info)
.field("has_regular", &self.regular.is_some())
#[cfg(unix)]
.field("has_pty", &self.pty.is_some())
.finish()
}
}
/// State file entry for persistence
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ProcessStateEntry {
@ -139,6 +266,8 @@ struct ProcessStateEntry {
started_at: u64,
stopped_at: Option<u64>,
cwd: Option<String>,
tty: bool,
interactive: bool,
}
/// Process Manager handles spawning and tracking background processes
@ -218,8 +347,13 @@ impl ProcessManager {
started_at: entry.started_at,
stopped_at: entry.stopped_at,
cwd: entry.cwd,
tty: entry.tty,
interactive: entry.interactive,
terminal_size: None,
},
child: None,
regular: None,
#[cfg(unix)]
pty: None,
log_broadcaster: tx,
};
@ -258,6 +392,8 @@ impl ProcessManager {
started_at: guard.info.started_at,
stopped_at: guard.info.stopped_at,
cwd: guard.info.cwd.clone(),
tty: guard.info.tty,
interactive: guard.info.interactive,
});
}
@ -287,10 +423,39 @@ impl ProcessManager {
File::create(&log_paths.stderr).map_err(|e| SandboxError::StreamError {
message: format!("Failed to create stderr log: {}", e),
})?;
File::create(&log_paths.combined).map_err(|e| SandboxError::StreamError {
message: format!("Failed to create combined log: {}", e),
})?;
let started_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
#[cfg(unix)]
if request.tty {
return self.start_pty_process(id, request, log_paths, started_at).await;
}
// Fall back to regular process if TTY not requested or not on Unix
self.start_regular_process(id, request, log_paths, started_at).await
}
/// Start a regular (non-PTY) process
async fn start_regular_process(
&self,
id: String,
request: StartProcessRequest,
log_paths: ProcessLogPaths,
started_at: u64,
) -> Result<StartProcessResponse, SandboxError> {
let combined_file = Arc::new(std::sync::Mutex::new(
File::create(&log_paths.combined).map_err(|e| SandboxError::StreamError {
message: format!("Failed to create combined log: {}", e),
})?
OpenOptions::new()
.append(true)
.open(&log_paths.combined)
.map_err(|e| SandboxError::StreamError {
message: format!("Failed to open combined log: {}", e),
})?
));
// Build the command
@ -312,11 +477,6 @@ impl ProcessManager {
message: format!("Failed to spawn process: {}", e),
})?;
let started_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let (log_tx, _) = broadcast::channel::<String>(256);
// Set up stdout reader
@ -333,11 +493,19 @@ impl ProcessManager {
started_at,
stopped_at: None,
cwd: request.cwd.clone(),
tty: false,
interactive: request.interactive,
terminal_size: None,
};
let managed = Arc::new(Mutex::new(ManagedProcess {
info: info.clone(),
child: Some(child),
regular: Some(RegularProcess {
child,
log_broadcaster: log_tx.clone(),
}),
#[cfg(unix)]
pty: None,
log_broadcaster: log_tx.clone(),
}));
@ -406,8 +574,8 @@ impl ProcessManager {
tokio::time::sleep(Duration::from_millis(100)).await;
let mut guard = managed_clone.lock().await;
if let Some(ref mut child) = guard.child {
match child.try_wait() {
if let Some(ref mut regular) = guard.regular {
match regular.child.try_wait() {
Ok(Some(status)) => {
guard.info.status = ProcessStatus::Stopped;
guard.info.exit_code = status.code();
@ -417,11 +585,8 @@ impl ProcessManager {
.unwrap_or_default()
.as_secs()
);
guard.child = None;
guard.regular = None;
drop(guard);
// Save state - we need to do this manually since we don't have self
// This is a simplified version that just updates the state file
let _ = save_state_to_file(&base_dir).await;
break;
}
@ -447,6 +612,201 @@ impl ProcessManager {
id,
status: ProcessStatus::Running,
log_paths,
tty: false,
interactive: request.interactive,
})
}
/// Start a PTY process (Unix only)
#[cfg(unix)]
async fn start_pty_process(
&self,
id: String,
request: StartProcessRequest,
log_paths: ProcessLogPaths,
started_at: u64,
) -> Result<StartProcessResponse, SandboxError> {
let size = request.terminal_size.unwrap_or_default();
// Create the PTY
let pty_system = native_pty_system();
let pty_pair = pty_system
.openpty(PtySize {
rows: size.rows,
cols: size.cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| SandboxError::StreamError {
message: format!("Failed to create PTY: {}", e),
})?;
// Build the command
let mut cmd = CommandBuilder::new(&request.command);
cmd.args(&request.args);
if let Some(ref cwd) = request.cwd {
cmd.cwd(cwd);
}
for (key, value) in &request.env {
cmd.env(key, value);
}
// Set TERM environment variable
cmd.env("TERM", "xterm-256color");
// Spawn the child process
let child = pty_pair
.slave
.spawn_command(cmd)
.map_err(|e| SandboxError::StreamError {
message: format!("Failed to spawn PTY process: {}", e),
})?;
// Get the master writer
let writer = pty_pair.master.take_writer().map_err(|e| SandboxError::StreamError {
message: format!("Failed to get PTY writer: {}", e),
})?;
// Get the master reader
let mut reader = pty_pair.master.try_clone_reader().map_err(|e| SandboxError::StreamError {
message: format!("Failed to get PTY reader: {}", e),
})?;
// Create channels for terminal I/O
let (output_tx, _) = broadcast::channel::<Vec<u8>>(256);
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (log_tx, _) = broadcast::channel::<String>(256);
let info = ProcessInfo {
id: id.clone(),
command: request.command.clone(),
args: request.args.clone(),
status: ProcessStatus::Running,
exit_code: None,
log_paths: log_paths.clone(),
started_at,
stopped_at: None,
cwd: request.cwd.clone(),
tty: true,
interactive: request.interactive,
terminal_size: Some(size),
};
let managed = Arc::new(Mutex::new(ManagedProcess {
info: info.clone(),
regular: None,
pty: Some(PtyProcess {
pty_pair,
child,
writer,
size,
output_tx: output_tx.clone(),
input_tx: input_tx.clone(),
}),
log_broadcaster: log_tx.clone(),
}));
// Insert into map
{
let mut processes = self.processes.write().await;
processes.insert(id.clone(), managed.clone());
}
// Spawn a task to read PTY output
let output_tx_clone = output_tx.clone();
let combined_path = log_paths.combined.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
let mut combined_file = OpenOptions::new()
.create(true)
.append(true)
.open(&combined_path)
.ok();
loop {
match reader.read(&mut buf) {
Ok(0) => break, // EOF
Ok(n) => {
let data = buf[..n].to_vec();
// Write to log file
if let Some(ref mut file) = combined_file {
let _ = file.write_all(&data);
}
// Broadcast to subscribers
let _ = output_tx_clone.send(data);
}
Err(e) => {
tracing::debug!("PTY read error: {}", e);
break;
}
}
}
});
// Spawn a task to write input to PTY
let managed_clone = managed.clone();
tokio::spawn(async move {
while let Some(data) = input_rx.recv().await {
let mut guard = managed_clone.lock().await;
if let Some(ref mut pty) = guard.pty {
if pty.writer.write_all(&data).is_err() {
break;
}
let _ = pty.writer.flush();
}
}
});
// Spawn a task to monitor process exit
let managed_clone = managed.clone();
let base_dir = self.base_dir.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_millis(100)).await;
let mut guard = managed_clone.lock().await;
if let Some(ref mut pty) = guard.pty {
match pty.child.try_wait() {
Ok(Some(status)) => {
guard.info.status = ProcessStatus::Stopped;
guard.info.exit_code = status.exit_code().map(|c| c as i32);
guard.info.stopped_at = Some(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
);
guard.pty = None;
drop(guard);
let _ = save_state_to_file(&base_dir).await;
break;
}
Ok(None) => {
// Still running
}
Err(_) => {
break;
}
}
} else {
break;
}
}
});
// Save state
if let Err(e) = self.save_state().await {
tracing::warn!("Failed to save process state: {}", e);
}
Ok(StartProcessResponse {
id,
status: ProcessStatus::Running,
log_paths,
tty: true,
interactive: request.interactive,
})
}
@ -477,6 +837,12 @@ impl ProcessManager {
Ok(guard.info.clone())
}
/// Check if a process has TTY enabled
pub async fn is_tty_process(&self, id: &str) -> Result<bool, SandboxError> {
let info = self.get_process(id).await?;
Ok(info.tty)
}
/// Stop a process with SIGTERM
pub async fn stop_process(&self, id: &str) -> Result<(), SandboxError> {
let processes = self.processes.read().await;
@ -484,22 +850,27 @@ impl ProcessManager {
session_id: format!("process:{}", id),
})?;
let mut guard = managed.lock().await;
let guard = managed.lock().await;
if let Some(ref child) = guard.child {
// Try regular process first
if let Some(ref regular) = guard.regular {
#[cfg(unix)]
{
// Send SIGTERM
if let Some(pid) = child.id() {
if let Some(pid) = regular.child.id() {
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
}
#[cfg(not(unix))]
{
// On non-Unix, we can't send SIGTERM, so just mark as stopping
// The process will be killed when delete is called if needed
}
// Try PTY process
#[cfg(unix)]
if let Some(ref pty) = guard.pty {
if let Some(pid) = pty.child.process_id() {
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
}
@ -521,8 +892,9 @@ impl ProcessManager {
let mut guard = managed.lock().await;
if let Some(ref mut child) = guard.child {
let _ = child.kill().await;
// Try regular process first
if let Some(ref mut regular) = guard.regular {
let _ = regular.child.kill().await;
guard.info.status = ProcessStatus::Killed;
guard.info.stopped_at = Some(
SystemTime::now()
@ -530,7 +902,21 @@ impl ProcessManager {
.unwrap_or_default()
.as_secs()
);
guard.child = None;
guard.regular = None;
}
// Try PTY process
#[cfg(unix)]
if let Some(ref mut pty) = guard.pty {
let _ = pty.child.kill();
guard.info.status = ProcessStatus::Killed;
guard.info.stopped_at = Some(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
);
guard.pty = None;
}
drop(guard);
@ -549,7 +935,10 @@ impl ProcessManager {
let processes = self.processes.read().await;
if let Some(managed) = processes.get(id) {
let guard = managed.lock().await;
if guard.child.is_some() {
let is_running = guard.regular.is_some();
#[cfg(unix)]
let is_running = is_running || guard.pty.is_some();
if is_running {
return Err(SandboxError::InvalidRequest {
message: "Cannot delete a running process. Stop or kill it first.".to_string(),
});
@ -624,6 +1013,127 @@ impl ProcessManager {
let guard = managed.lock().await;
Ok(guard.log_broadcaster.subscribe())
}
/// Resize a PTY terminal
#[cfg(unix)]
pub async fn resize_terminal(&self, id: &str, cols: u16, rows: u16) -> Result<(), SandboxError> {
let processes = self.processes.read().await;
let managed = processes.get(id).ok_or_else(|| SandboxError::SessionNotFound {
session_id: format!("process:{}", id),
})?;
let mut guard = managed.lock().await;
if let Some(ref mut pty) = guard.pty {
pty.pty_pair
.master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| SandboxError::StreamError {
message: format!("Failed to resize terminal: {}", e),
})?;
pty.size = TerminalSize { cols, rows };
guard.info.terminal_size = Some(pty.size);
Ok(())
} else {
Err(SandboxError::InvalidRequest {
message: "Process does not have a PTY".to_string(),
})
}
}
#[cfg(not(unix))]
pub async fn resize_terminal(&self, _id: &str, _cols: u16, _rows: u16) -> Result<(), SandboxError> {
Err(SandboxError::InvalidRequest {
message: "PTY support is only available on Unix systems".to_string(),
})
}
/// Write data to a process's terminal input
#[cfg(unix)]
pub async fn write_terminal_input(&self, id: &str, data: Vec<u8>) -> Result<(), SandboxError> {
let processes = self.processes.read().await;
let managed = processes.get(id).ok_or_else(|| SandboxError::SessionNotFound {
session_id: format!("process:{}", id),
})?;
let guard = managed.lock().await;
if let Some(ref pty) = guard.pty {
pty.input_tx.send(data).map_err(|_| SandboxError::StreamError {
message: "Failed to send input to terminal".to_string(),
})?;
Ok(())
} else {
Err(SandboxError::InvalidRequest {
message: "Process does not have a PTY".to_string(),
})
}
}
#[cfg(not(unix))]
pub async fn write_terminal_input(&self, _id: &str, _data: Vec<u8>) -> Result<(), SandboxError> {
Err(SandboxError::InvalidRequest {
message: "PTY support is only available on Unix systems".to_string(),
})
}
/// Subscribe to terminal output
#[cfg(unix)]
pub async fn subscribe_terminal_output(&self, id: &str) -> Result<broadcast::Receiver<Vec<u8>>, SandboxError> {
let processes = self.processes.read().await;
let managed = processes.get(id).ok_or_else(|| SandboxError::SessionNotFound {
session_id: format!("process:{}", id),
})?;
let guard = managed.lock().await;
if let Some(ref pty) = guard.pty {
Ok(pty.output_tx.subscribe())
} else {
Err(SandboxError::InvalidRequest {
message: "Process does not have a PTY".to_string(),
})
}
}
#[cfg(not(unix))]
pub async fn subscribe_terminal_output(&self, _id: &str) -> Result<broadcast::Receiver<Vec<u8>>, SandboxError> {
Err(SandboxError::InvalidRequest {
message: "PTY support is only available on Unix systems".to_string(),
})
}
/// Get the input channel for a PTY process (for WebSocket handler)
#[cfg(unix)]
pub async fn get_terminal_input_sender(&self, id: &str) -> Result<mpsc::UnboundedSender<Vec<u8>>, SandboxError> {
let processes = self.processes.read().await;
let managed = processes.get(id).ok_or_else(|| SandboxError::SessionNotFound {
session_id: format!("process:{}", id),
})?;
let guard = managed.lock().await;
if let Some(ref pty) = guard.pty {
Ok(pty.input_tx.clone())
} else {
Err(SandboxError::InvalidRequest {
message: "Process does not have a PTY".to_string(),
})
}
}
#[cfg(not(unix))]
pub async fn get_terminal_input_sender(&self, _id: &str) -> Result<mpsc::UnboundedSender<Vec<u8>>, SandboxError> {
Err(SandboxError::InvalidRequest {
message: "PTY support is only available on Unix systems".to_string(),
})
}
}
impl Default for ProcessManager {
@ -648,15 +1158,12 @@ fn format_timestamp() -> String {
}
/// Strip timestamp prefixes from log lines
/// Timestamps are in format: [2026-01-30T12:32:45.123Z] or [2026-01-30T12:32:45Z]
fn strip_timestamps(content: &str) -> String {
content
.lines()
.map(|line| {
// Match pattern: [YYYY-MM-DDTHH:MM:SS...Z] at start of line
if line.starts_with('[') {
if let Some(end) = line.find("] ") {
// Check if it looks like a timestamp (starts with digit after [)
let potential_ts = &line[1..end];
if potential_ts.len() >= 19 && potential_ts.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
return &line[end + 2..];
@ -669,10 +1176,8 @@ fn strip_timestamps(content: &str) -> String {
.join("\n")
}
/// Helper to save state from within a spawned task (simplified version)
/// Helper to save state from within a spawned task
async fn save_state_to_file(base_dir: &PathBuf) -> Result<(), std::io::Error> {
// This is a no-op for now - the state will be saved on the next explicit save_state call
// A more robust implementation would use a channel to communicate with the ProcessManager
let _ = base_dir;
Ok(())
}
@ -685,38 +1190,66 @@ mod tests {
async fn test_process_manager_basic() {
let manager = ProcessManager::new();
// List should be empty initially (or have persisted state)
let list = manager.list_processes().await;
let initial_count = list.processes.len();
// Start a simple process
let request = StartProcessRequest {
command: "echo".to_string(),
args: vec!["hello".to_string()],
cwd: None,
env: HashMap::new(),
tty: false,
interactive: false,
terminal_size: None,
};
let response = manager.start_process(request).await.unwrap();
assert!(!response.id.is_empty());
assert_eq!(response.status, ProcessStatus::Running);
assert!(!response.tty);
// Wait a bit for the process to complete
tokio::time::sleep(Duration::from_millis(200)).await;
// Check the process info
let info = manager.get_process(&response.id).await.unwrap();
assert_eq!(info.command, "echo");
assert!(!info.tty);
// List should have one more process
let list = manager.list_processes().await;
assert_eq!(list.processes.len(), initial_count + 1);
// Delete the process
manager.delete_process(&response.id).await.unwrap();
// List should be back to initial count
let list = manager.list_processes().await;
assert_eq!(list.processes.len(), initial_count);
}
#[cfg(unix)]
#[tokio::test]
async fn test_pty_process() {
let manager = ProcessManager::new();
let request = StartProcessRequest {
command: "sh".to_string(),
args: vec!["-c".to_string(), "echo hello && exit 0".to_string()],
cwd: None,
env: HashMap::new(),
tty: true,
interactive: true,
terminal_size: Some(TerminalSize { cols: 80, rows: 24 }),
};
let response = manager.start_process(request).await.unwrap();
assert!(response.tty);
assert!(response.interactive);
let info = manager.get_process(&response.id).await.unwrap();
assert!(info.tty);
assert!(info.terminal_size.is_some());
// Wait for process to complete
tokio::time::sleep(Duration::from_millis(500)).await;
// Cleanup
let _ = manager.delete_process(&response.id).await;
}
}

View file

@ -41,7 +41,9 @@ use crate::ui;
use crate::process_manager::{
ProcessManager, ProcessInfo, ProcessListResponse, ProcessLogPaths, ProcessStatus,
StartProcessRequest, StartProcessResponse, LogsQuery, LogsResponse,
ResizeTerminalRequest, TerminalSize, WriteInputRequest,
};
use crate::terminal::terminal_ws_handler;
use sandbox_agent_agent_management::agents::{
AgentError as ManagerError, AgentId, AgentManager, InstallOptions, SpawnOptions, StreamingSpawn,
};
@ -130,6 +132,14 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
.route("/process/:id/stop", post(stop_process))
.route("/process/:id/kill", post(kill_process))
.route("/process/:id/logs", get(get_process_logs))
// Terminal/PTY routes
.route("/process/:id/resize", post(resize_terminal))
.route("/process/:id/input", post(write_terminal_input))
.with_state(shared.clone());
// WebSocket routes (outside of auth middleware for easier client access)
let ws_router = Router::new()
.route("/v1/process/:id/terminal", get(terminal_ws))
.with_state(shared.clone());
if shared.auth.token.is_some() {
@ -141,7 +151,8 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
let mut router = Router::new()
.route("/", get(get_root))
.nest("/v1", v1_router);
.nest("/v1", v1_router)
.merge(ws_router); // Add WebSocket routes
if ui::is_enabled() {
router = router.merge(ui::router());
@ -177,7 +188,9 @@ pub async fn shutdown_servers(state: &Arc<AppState>) {
stop_process,
kill_process,
delete_process,
get_process_logs
get_process_logs,
resize_terminal,
write_terminal_input
),
components(
schemas(
@ -235,7 +248,10 @@ pub async fn shutdown_servers(state: &Arc<AppState>) {
StartProcessRequest,
StartProcessResponse,
LogsQuery,
LogsResponse
LogsResponse,
TerminalSize,
ResizeTerminalRequest,
WriteInputRequest
)
),
tags(
@ -4090,6 +4106,82 @@ async fn get_process_logs(
}
}
/// Resize a PTY terminal
#[utoipa::path(
post,
path = "/v1/process/{id}/resize",
tag = "process",
params(
("id" = String, Path, description = "Process ID")
),
request_body = ResizeTerminalRequest,
responses(
(status = 200, description = "Terminal resized successfully"),
(status = 400, description = "Process does not have a PTY"),
(status = 404, description = "Process not found")
)
)]
async fn resize_terminal(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(request): Json<ResizeTerminalRequest>,
) -> Result<StatusCode, ApiError> {
state
.process_manager
.resize_terminal(&id, request.cols, request.rows)
.await?;
Ok(StatusCode::OK)
}
/// Write data to a PTY terminal's input
#[utoipa::path(
post,
path = "/v1/process/{id}/input",
tag = "process",
params(
("id" = String, Path, description = "Process ID")
),
request_body = WriteInputRequest,
responses(
(status = 200, description = "Data written to terminal"),
(status = 400, description = "Process does not have a PTY or invalid data"),
(status = 404, description = "Process not found")
)
)]
async fn write_terminal_input(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(request): Json<WriteInputRequest>,
) -> Result<StatusCode, ApiError> {
let data = if request.base64 {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(&request.data)
.map_err(|e| SandboxError::InvalidRequest {
message: format!("Invalid base64 data: {}", e),
})?
} else {
request.data.into_bytes()
};
state
.process_manager
.write_terminal_input(&id, data)
.await?;
Ok(StatusCode::OK)
}
/// WebSocket endpoint for terminal I/O
async fn terminal_ws(
ws: axum::extract::ws::WebSocketUpgrade,
Path(id): Path<String>,
State(state): State<Arc<AppState>>,
) -> Result<Response, ApiError> {
terminal_ws_handler(ws, Path(id), State(state.process_manager.clone()))
.await
.map_err(|e| ApiError::Sandbox(e))
}
fn all_agents() -> [AgentId; 5] {
[
AgentId::Claude,

View file

@ -0,0 +1,215 @@
//! Terminal WebSocket handler for interactive PTY sessions.
//!
//! Provides bidirectional terminal I/O over WebSocket connections.
use std::sync::Arc;
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
Path, State,
},
response::Response,
};
use futures::{SinkExt, StreamExt};
use tokio::sync::broadcast;
use crate::process_manager::{ProcessManager, TerminalMessage};
use sandbox_agent_error::SandboxError;
/// WebSocket upgrade handler for terminal connections
pub async fn terminal_ws_handler(
ws: WebSocketUpgrade,
Path(id): Path<String>,
State(process_manager): State<Arc<ProcessManager>>,
) -> Result<Response, SandboxError> {
// Verify the process exists and has PTY
let info = process_manager.get_process(&id).await?;
if !info.tty {
return Err(SandboxError::InvalidRequest {
message: "Process does not have a PTY allocated. Start with tty: true".to_string(),
});
}
// Check if process is still running
if info.exit_code.is_some() {
return Err(SandboxError::InvalidRequest {
message: "Process has already exited".to_string(),
});
}
Ok(ws.on_upgrade(move |socket| handle_terminal_socket(socket, id, process_manager)))
}
/// Handle the WebSocket connection for terminal I/O
async fn handle_terminal_socket(
socket: WebSocket,
process_id: String,
process_manager: Arc<ProcessManager>,
) {
let (mut ws_sender, mut ws_receiver) = socket.split();
// Get terminal output subscription and input sender
let output_rx = match process_manager.subscribe_terminal_output(&process_id).await {
Ok(rx) => rx,
Err(e) => {
let msg = TerminalMessage::Error {
message: format!("Failed to subscribe to terminal output: {}", e),
};
let _ = ws_sender
.send(Message::Text(serde_json::to_string(&msg).unwrap()))
.await;
return;
}
};
let input_tx = match process_manager.get_terminal_input_sender(&process_id).await {
Ok(tx) => tx,
Err(e) => {
let msg = TerminalMessage::Error {
message: format!("Failed to get terminal input channel: {}", e),
};
let _ = ws_sender
.send(Message::Text(serde_json::to_string(&msg).unwrap()))
.await;
return;
}
};
// Task to forward terminal output to WebSocket
let process_manager_clone = process_manager.clone();
let process_id_clone = process_id.clone();
let output_task = tokio::spawn(async move {
forward_output_to_ws(output_rx, ws_sender, process_manager_clone, process_id_clone).await;
});
// Handle input from WebSocket
let process_manager_clone = process_manager.clone();
let process_id_clone = process_id.clone();
while let Some(msg) = ws_receiver.next().await {
match msg {
Ok(Message::Text(text)) => {
if let Ok(terminal_msg) = serde_json::from_str::<TerminalMessage>(&text) {
match terminal_msg {
TerminalMessage::Input { data } => {
// Send input to terminal
if input_tx.send(data.into_bytes()).is_err() {
break;
}
}
TerminalMessage::Resize { cols, rows } => {
// Resize terminal
if let Err(e) = process_manager_clone
.resize_terminal(&process_id_clone, cols, rows)
.await
{
tracing::warn!("Failed to resize terminal: {}", e);
}
}
_ => {
// Ignore other message types from client
}
}
}
}
Ok(Message::Binary(data)) => {
// Binary data is treated as raw terminal input
if input_tx.send(data).is_err() {
break;
}
}
Ok(Message::Close(_)) => {
break;
}
Err(_) => {
break;
}
_ => {}
}
}
// Cancel output task
output_task.abort();
}
/// Forward terminal output to WebSocket
async fn forward_output_to_ws(
mut output_rx: broadcast::Receiver<Vec<u8>>,
mut ws_sender: futures::stream::SplitSink<WebSocket, Message>,
process_manager: Arc<ProcessManager>,
process_id: String,
) {
loop {
tokio::select! {
result = output_rx.recv() => {
match result {
Ok(data) => {
// Try to convert to UTF-8, otherwise send as binary
match String::from_utf8(data.clone()) {
Ok(text) => {
let msg = TerminalMessage::Data { data: text };
if ws_sender
.send(Message::Text(serde_json::to_string(&msg).unwrap()))
.await
.is_err()
{
break;
}
}
Err(_) => {
// Send as binary for non-UTF8 data
if ws_sender.send(Message::Binary(data)).await.is_err() {
break;
}
}
}
}
Err(broadcast::error::RecvError::Closed) => {
// Channel closed, process likely exited
break;
}
Err(broadcast::error::RecvError::Lagged(_)) => {
// Missed some messages, continue
continue;
}
}
}
_ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => {
// Check if process is still running
if let Ok(info) = process_manager.get_process(&process_id).await {
if info.exit_code.is_some() {
// Send exit message
let msg = TerminalMessage::Exit { code: info.exit_code };
let _ = ws_sender
.send(Message::Text(serde_json::to_string(&msg).unwrap()))
.await;
break;
}
} else {
break;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_terminal_message_serialization() {
let msg = TerminalMessage::Data {
data: "hello".to_string(),
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"type\":\"data\""));
assert!(json.contains("\"data\":\"hello\""));
let msg = TerminalMessage::Resize { cols: 80, rows: 24 };
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"type\":\"resize\""));
assert!(json.contains("\"cols\":80"));
assert!(json.contains("\"rows\":24"));
}
}