# SPEC-012: Headless Linux Mode (Serial Console + PTY Shell Access)

**Status:** Proposed
**Priority:** P2
**Requested By:** Mike Swanson (2026-05-30)
**Estimated Effort:** Medium (5-7 weeks)

## Overview

Enable GuruConnect agent support for headless Linux servers (no X11/Wayland GUI) by providing two modes of terminal access: **Serial Console Mode** for boot-level access (GRUB, kernel messages, panics) and **PTY Shell Mode** for normal server management. This addresses critical server management use cases—from emergency recovery to routine administration—without requiring SSH. The viewer displays a terminal emulator (xterm.js web viewer) connected to either the system serial console (`/dev/ttyS0`) or a pseudo-TTY shell session. Serial Console Mode provides true "remote console" access like KVM-over-IP or IPMI Serial-over-LAN, seeing everything the physical monitor would show. PTY Shell Mode provides an interactive shell for normal management tasks. Success criteria: technician can access GRUB bootloader, view kernel boot messages, handle kernel panics, AND perform routine server management—all via GuruConnect dashboard with centralized authentication and audit logging.

**Use Cases:**
- **Boot-level access:** GRUB menu selection, kernel parameter editing, single-user mode
- **Emergency recovery:** Kernel panic diagnosis, filesystem repair, systemd rescue shell
- **Server management:** Package updates, configuration changes, log review (normal shell access)
- **Container debugging:** Exec into running containers via GuruConnect
- **MSP consolidation:** One tool for desktop support (GUI), server boot recovery (console), and server management (shell)

**Success Criteria:**
- **Serial Console Mode:** View GRUB bootloader, kernel boot messages, kernel panics, login prompts—as if sitting at physical console
- **PTY Shell Mode:** Interactive shell (bash/zsh) with full ANSI color, cursor control, vim/nano/htop support
- GuruConnect agent runs on Ubuntu Server 22.04 minimal install (no desktop packages)
- Dashboard mode selector: "Console" vs. "Shell" per agent (user chooses at connection time)
- Same protobuf-over-WSS transport, support-code and persistent-agent authentication
- Audit logging: session recording for both console and shell modes

## Scope

### Included in v1

**Mode 1: Serial Console Mode (True Remote Console)**
- Open system serial console device (`/dev/ttyS0` or `/dev/console`) for raw I/O
- Relay all bytes bidirectionally: console output → `TerminalData` → viewer; viewer input → `TerminalInput` → console
- **Sees everything:** GRUB bootloader menu, kernel boot messages, systemd startup, login prompts, kernel panics
- **Boot-time interaction:** Select GRUB entries, edit kernel parameters, boot into single-user mode
- Requires root privileges (serial console access restricted to root)
- Requires serial console enabled on target server (GRUB + kernel parameters configured)
- No PTY spawning—direct device I/O, like `screen /dev/ttyS0 115200`
- Agent config flag: `console_mode: true` + `console_device: "/dev/ttyS0"`

**Mode 2: PTY Shell Mode (Interactive Shell)**
- Detect headless environment (no DISPLAY, no X11/Wayland libraries) at runtime
- Spawn pseudo-TTY (PTY) via `openpty()` + fork/exec shell (`/bin/bash -l` or user's `$SHELL`)
- Terminal I/O: read PTY output → encode as protobuf `TerminalData` → send via WebSocket
- Input: receive protobuf `TerminalInput` → write to PTY master
- Terminal resize: handle `TerminalResize` message → send `SIGWINCH` to PTY
- Fallback shell selection: `$SHELL` env var → `/bin/bash` → `/bin/sh`
- Graceful PTY cleanup on session end (send exit command, wait for shell exit, close PTY)
- Standard user privileges (runs as agent service user)

**Mode Selection:**
- Dashboard shows mode selector when connecting to headless agent: "Console" vs. "Shell"
- "Console" button: viewer sends `mode: console` in connection request → agent opens `/dev/ttyS0`
- "Shell" button: viewer sends `mode: shell` in connection request → agent spawns PTY
- Agent config specifies default mode if serial console unavailable
- If serial console device doesn't exist or permission denied, fall back to PTY shell mode with warning

**Both Modes Share:**
- Same agent binary: `guruconnect` detects headless and offers both modes
- Same xterm.js viewer (handles both serial console and PTY identically)

**Viewer (Web Viewer):**
- xterm.js-based terminal emulator embedded in `viewer.html`
- Connects to same `/ws/viewer` endpoint with session JWT
- Relay server detects `TerminalData` frames (not `FrameData`) and routes accordingly
- Terminal controls: resize on window resize, copy/paste support, configurable font size
- Session toolbar: connection status, terminal size (e.g., "80x24"), reconnect button

**Viewer (Native Desktop Viewer - optional Phase 2):**
- Defer native viewer terminal support to Phase 2
- v1: web viewer only for terminal sessions (show "Open in browser" prompt if launched via `guruconnect://`)

**Protobuf Protocol:**
- New message types: `TerminalData` (PTY output), `TerminalInput` (keyboard input), `TerminalResize` (window size)
- `AgentStatus` includes `terminal_mode: bool` flag (true for headless agents)
- Dashboard shows terminal icon for headless agents, camera icon for GUI agents

**Dashboard:**
- Detect `terminal_mode: true` in agent status
- "Connect" button opens web viewer in terminal mode (not screen capture mode)
- Agent list shows "Terminal" badge for headless agents

**Session Recording (Audit):**
- Log all terminal I/O to `events` table or separate `terminal_sessions` table
- Playback: recorded session can be replayed as "terminal recording" (asciicast format or raw PTY dump)

### Explicitly out of scope

- **GUI mode on headless agents** — v1 is terminal-only; no attempt to start Xvfb or launch GUI apps
- **SSH key management** — agent uses GuruConnect auth (support code / agent key), not SSH keys
- **File transfer via terminal** — defer to SPEC (file transfer is a separate roadmap item for all agent types)
- **Multi-user terminal sessions** — v1 is single-session console/PTY; no tmux/screen built-in sharing
- **Windows terminal mode** — defer; Windows Server typically has GUI (RDP) or SSH (OpenSSH)
- **macOS terminal mode** — defer; macOS servers are rare and typically have GUI access
- **Framebuffer capture (`/dev/fb0`)** — defer; serial console is more reliable and doesn't require framebuffer device

### Serial Console Setup Requirements (Mode 1)

To use Serial Console Mode, the target Linux server must be configured to output to serial console. This is a **one-time setup per server** (typically done during provisioning):

**Step 1: Configure GRUB**

Edit `/etc/default/grub`:
```bash
# Enable serial console output at 115200 baud
GRUB_TERMINAL="serial console"
GRUB_SERIAL_COMMAND="serial --speed=115200 --unit=0 --word=8 --parity=no --stop=1"

# Kernel console output to both VGA (tty0) and serial (ttyS0)
GRUB_CMDLINE_LINUX="console=tty0 console=ttyS0,115200n8"
```

Update GRUB and reboot:
```bash
sudo update-grub     # Debian/Ubuntu
# OR
sudo grub2-mkconfig -o /boot/grub2/grub.cfg  # RHEL/CentOS
sudo reboot
```

**Step 2: Enable getty on Serial Console**

Ensure a login prompt appears on serial console after boot:
```bash
sudo systemctl enable serial-getty@ttyS0.service
sudo systemctl start serial-getty@ttyS0.service
```

**Step 3: Verify**

Test serial console locally before configuring GuruConnect:
```bash
sudo screen /dev/ttyS0 115200
# Should see kernel messages, login prompt
```

**What This Provides:**
- ✓ GRUB bootloader menu visible via serial console
- ✓ Kernel boot messages stream to serial console
- ✓ Login prompt on `/dev/ttyS0` after boot
- ✓ Kernel panics output to serial console
- ✓ systemd rescue shell accessible via serial console

**Compatibility:**
- Physical servers: Uses hardware serial port (COM1 = ttyS0)
- Virtual machines: VMware/Proxmox/KVM expose virtual serial port; configure VM to attach serial port
- Cloud VMs: AWS, GCP, Azure offer "Serial Console" feature (already configured); GuruConnect agent can relay it

## Architecture

### Agent Mode Selection

**Connection request handling:**

```rust
// agent/src/session/terminal.rs
pub async fn handle_terminal_session(
    ws: WebSocketClient,
    mode: TerminalMode,  // Console or Shell
    support_code: String
) -> Result<()> {
    match mode {
        TerminalMode::Console => run_console_session(ws, support_code).await,
        TerminalMode::Shell => run_shell_session(ws, support_code).await,
    }
}

pub enum TerminalMode {
    Console,  // Serial console (/dev/ttyS0)
    Shell,    // PTY shell session
}
```

### Agent Serial Console Handling (Mode 1)

**Serial device open:**

```rust
// agent/src/platform/linux/console.rs
use std::fs::OpenOptions;
use std::os::unix::io::AsRawFd;

pub struct ConsoleSession {
    device_fd: RawFd,
    device_path: String,  // "/dev/ttyS0" or "/dev/console"
}

impl ConsoleSession {
    pub fn open(device_path: &str) -> Result<Self> {
        // Open serial console device for read/write
        // Requires root privileges
        let file = OpenOptions::new()
            .read(true)
            .write(true)
            .open(device_path)
            .context("Failed to open serial console - requires root")?;

        let device_fd = file.as_raw_fd();

        // Configure terminal settings (115200 baud, 8N1)
        unsafe {
            let mut termios: libc::termios = std::mem::zeroed();
            if libc::tcgetattr(device_fd, &mut termios) != 0 {
                return Err(anyhow!("tcgetattr failed"));
            }

            // Set baud rate to 115200
            libc::cfsetispeed(&mut termios, libc::B115200);
            libc::cfsetospeed(&mut termios, libc::B115200);

            // 8N1 (8 data bits, no parity, 1 stop bit)
            termios.c_cflag &= !libc::CSIZE;
            termios.c_cflag |= libc::CS8;
            termios.c_cflag &= !(libc::PARENB | libc::PARODD);
            termios.c_cflag &= !libc::CSTOPB;

            // Raw mode (no line buffering, no echo)
            libc::cfmakeraw(&mut termios);

            if libc::tcsetattr(device_fd, libc::TCSANOW, &termios) != 0 {
                return Err(anyhow!("tcsetattr failed"));
            }
        }

        Ok(ConsoleSession {
            device_fd,
            device_path: device_path.to_string(),
        })
    }

    pub fn read(&self, buf: &mut [u8]) -> Result<usize> {
        unsafe {
            let n = libc::read(self.device_fd, buf.as_mut_ptr() as *mut _, buf.len());
            if n < 0 {
                Err(anyhow!("Console read failed"))
            } else {
                Ok(n as usize)
            }
        }
    }

    pub fn write(&self, data: &[u8]) -> Result<()> {
        unsafe {
            let n = libc::write(self.device_fd, data.as_ptr() as *const _, data.len());
            if n < 0 {
                Err(anyhow!("Console write failed"))
            } else {
                Ok(())
            }
        }
    }
}

impl Drop for ConsoleSession {
    fn drop(&mut self) {
        unsafe { libc::close(self.device_fd); }
    }
}
```

**Console session loop:**

```rust
// agent/src/session/console.rs
pub async fn run_console_session(ws: WebSocketClient, support_code: String) -> Result<()> {
    // Try /dev/ttyS0 first, fall back to /dev/console
    let console = ConsoleSession::open("/dev/ttyS0")
        .or_else(|_| ConsoleSession::open("/dev/console"))?;

    // Status update: terminal mode, console
    ws.send(AgentStatus {
        terminal_mode: true,
        console_mode: true,  // NEW flag
        os: "Linux".to_string(),
        // ...
    }).await?;

    let mut buf = vec![0u8; 4096];
    loop {
        tokio::select! {
            // Read console output, send to relay
            Ok(n) = tokio::task::spawn_blocking({
                let fd = console.device_fd;
                move || unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) }
            }) => {
                if n > 0 {
                    ws.send(TerminalData {
                        data: buf[..n as usize].to_vec(),
                    }).await?;
                }
            }

            // Receive input from relay, write to console
            Some(msg) = ws.recv() => {
                match msg {
                    Message::TerminalInput(input) => {
                        console.write(&input.data)?;
                    }
                    Message::Disconnect => break,
                    // Note: Resize ignored for serial console (not applicable)
                    _ => {}
                }
            }
        }
    }

    Ok(())
}
```

### Agent PTY Handling (Mode 2)

**Headless detection:**

```rust
// agent/src/platform/linux/headless.rs
pub fn is_headless() -> bool {
    // Check if DISPLAY is unset and no X11/Wayland session detected
    std::env::var("DISPLAY").is_err() &&
    std::env::var("WAYLAND_DISPLAY").is_err() &&
    !std::path::Path::new("/tmp/.X11-unix").exists()
}
```

**PTY spawn:**

```rust
// agent/src/platform/linux/pty.rs
use libc::{openpty, fork, execvp, dup2, STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO, winsize, TIOCSWINSZ};
use std::os::unix::io::RawFd;

pub struct PtySession {
    master_fd: RawFd,
    child_pid: libc::pid_t,
    cols: u16,
    rows: u16,
}

impl PtySession {
    pub fn spawn(shell: &str, cols: u16, rows: u16) -> Result<Self> {
        let mut master_fd: RawFd = 0;
        let mut slave_fd: RawFd = 0;
        let mut winsize = winsize {
            ws_row: rows,
            ws_col: cols,
            ws_xpixel: 0,
            ws_ypixel: 0,
        };

        unsafe {
            if openpty(&mut master_fd, &mut slave_fd, std::ptr::null_mut(),
                       std::ptr::null(), &mut winsize as *mut _) != 0 {
                return Err(anyhow!("openpty failed"));
            }

            let pid = fork();
            if pid == 0 {
                // Child process: exec shell
                dup2(slave_fd, STDIN_FILENO);
                dup2(slave_fd, STDOUT_FILENO);
                dup2(slave_fd, STDERR_FILENO);
                libc::close(master_fd);
                libc::close(slave_fd);

                let shell_cstr = CString::new(shell)?;
                let args = [shell_cstr.as_ptr(), std::ptr::null()];
                execvp(shell_cstr.as_ptr(), args.as_ptr());
                std::process::exit(1); // exec failed
            } else {
                // Parent process: close slave, return master FD
                libc::close(slave_fd);
                Ok(PtySession {
                    master_fd,
                    child_pid: pid,
                    cols,
                    rows,
                })
            }
        }
    }

    pub fn read(&self, buf: &mut [u8]) -> Result<usize> {
        unsafe {
            let n = libc::read(self.master_fd, buf.as_mut_ptr() as *mut _, buf.len());
            if n < 0 {
                Err(anyhow!("PTY read failed"))
            } else {
                Ok(n as usize)
            }
        }
    }

    pub fn write(&self, data: &[u8]) -> Result<()> {
        unsafe {
            let n = libc::write(self.master_fd, data.as_ptr() as *const _, data.len());
            if n < 0 {
                Err(anyhow!("PTY write failed"))
            } else {
                Ok(())
            }
        }
    }

    pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
        self.cols = cols;
        self.rows = rows;
        let winsize = winsize {
            ws_row: rows,
            ws_col: cols,
            ws_xpixel: 0,
            ws_ypixel: 0,
        };
        unsafe {
            if libc::ioctl(self.master_fd, TIOCSWINSZ, &winsize as *const _) != 0 {
                return Err(anyhow!("TIOCSWINSZ failed"));
            }
        }
        // Send SIGWINCH to child process group
        unsafe { libc::kill(-self.child_pid, libc::SIGWINCH); }
        Ok(())
    }
}

impl Drop for PtySession {
    fn drop(&mut self) {
        unsafe {
            libc::close(self.master_fd);
            // Send SIGTERM to child, wait briefly, then SIGKILL if still alive
            libc::kill(self.child_pid, libc::SIGTERM);
            std::thread::sleep(std::time::Duration::from_millis(500));
            libc::waitpid(self.child_pid, std::ptr::null_mut(), libc::WNOHANG);
        }
    }
}
```

**Agent session loop:**

```rust
// agent/src/session/terminal.rs
pub async fn run_terminal_session(ws: WebSocketClient, support_code: String) -> Result<()> {
    let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
    let mut pty = PtySession::spawn(&shell, 80, 24)?;

    // Status update: terminal mode
    ws.send(AgentStatus {
        terminal_mode: true,
        os: "Linux".to_string(),
        // ...
    }).await?;

    let mut buf = vec![0u8; 4096];
    loop {
        tokio::select! {
            // Read PTY output, send to relay
            Ok(n) = tokio::task::spawn_blocking({
                let master = pty.master_fd;
                move || unsafe { libc::read(master, buf.as_mut_ptr() as *mut _, buf.len()) }
            }) => {
                if n > 0 {
                    ws.send(TerminalData {
                        data: buf[..n as usize].to_vec(),
                    }).await?;
                }
            }

            // Receive input from relay, write to PTY
            Some(msg) = ws.recv() => {
                match msg {
                    Message::TerminalInput(input) => {
                        pty.write(&input.data)?;
                    }
                    Message::TerminalResize(resize) => {
                        pty.resize(resize.cols, resize.rows)?;
                    }
                    Message::Disconnect => break,
                    _ => {}
                }
            }
        }
    }

    Ok(())
}
```

### Protobuf Protocol Extensions

```protobuf
// proto/guruconnect.proto

message AgentStatus {
  // Existing fields...
  optional bool terminal_mode = 21;   // true for headless agents
  optional bool console_mode = 22;    // true for serial console mode, false for PTY shell mode
}

message TerminalData {
  bytes data = 1;  // Raw terminal output (PTY or serial console, includes ANSI escape sequences)
}

message TerminalInput {
  bytes data = 1;  // Keyboard input from viewer (UTF-8 encoded)
}

message TerminalResize {
  uint32 cols = 1;  // Terminal width (characters)
  uint32 rows = 2;  // Terminal height (lines)
  // Note: Resize only applies to PTY shell mode; serial console ignores this
}

enum TerminalModeRequest {
  SHELL = 0;    // Request PTY shell session
  CONSOLE = 1;  // Request serial console session
}

message SessionRequest {
  // Existing fields...
  optional TerminalModeRequest terminal_mode_request = 10;  // NEW: viewer specifies console vs. shell
}

// Update AgentMessage and ViewerMessage unions
message AgentMessage {
  oneof message {
    AgentStatus status = 1;
    FrameData frame = 2;
    TerminalData terminal_data = 10;  // NEW
  }
}

message ViewerMessage {
  oneof message {
    InputEvent input = 1;
    TerminalInput terminal_input = 10;  // NEW
    TerminalResize terminal_resize = 11;  // NEW
  }
}
```

### Relay Server Changes

**Route terminal vs. screen capture sessions:**

```rust
// server/src/relay/mod.rs
async fn handle_agent_message(msg: AgentMessage, session: &Session) {
    match msg.message {
        Some(agent_message::Message::Status(status)) => {
            session.terminal_mode = status.terminal_mode.unwrap_or(false);
            // Store in DB: UPDATE sessions SET terminal_mode = ? WHERE id = ?
        }
        Some(agent_message::Message::TerminalData(data)) => {
            // Forward to viewer WebSocket
            if let Some(viewer_ws) = session.viewer_ws.lock().await.as_mut() {
                viewer_ws.send(ViewerMessage {
                    message: Some(viewer_message::Message::TerminalData(data))
                }).await?;
            }
            // Optional: append to terminal_recording buffer for audit
        }
        Some(agent_message::Message::Frame(frame)) => {
            // Existing screen capture logic...
        }
        _ => {}
    }
}

async fn handle_viewer_message(msg: ViewerMessage, session: &Session) {
    match msg.message {
        Some(viewer_message::Message::TerminalInput(input)) => {
            // Forward to agent WebSocket
            if let Some(agent_ws) = session.agent_ws.lock().await.as_mut() {
                agent_ws.send(AgentMessage {
                    message: Some(agent_message::Message::TerminalInput(input))
                }).await?;
            }
        }
        Some(viewer_message::Message::TerminalResize(resize)) => {
            // Forward resize to agent
            if let Some(agent_ws) = session.agent_ws.lock().await.as_mut() {
                agent_ws.send(AgentMessage {
                    message: Some(agent_message::Message::TerminalResize(resize))
                }).await?;
            }
        }
        Some(viewer_message::Message::Input(input)) => {
            // Existing GUI input logic...
        }
        _ => {}
    }
}
```

### Web Viewer (xterm.js)

**HTML template:**

```html
<!-- server/static/viewer-terminal.html -->
<!DOCTYPE html>
<html>
<head>
    <title>GuruConnect Terminal</title>
    <link rel="stylesheet" href="/vendor/xterm/xterm.css" />
    <script src="/vendor/xterm/xterm.js"></script>
    <script src="/vendor/xterm/xterm-addon-fit.js"></script>
    <style>
        #terminal { height: 100vh; }
        .toolbar { background: #333; color: #fff; padding: 8px; }
    </style>
</head>
<body>
    <div class="toolbar">
        <span id="status">Connecting...</span>
        <span id="size" style="float: right;">80x24</span>
    </div>
    <div id="terminal"></div>
    <script>
        const term = new Terminal({
            cursorBlink: true,
            fontSize: 14,
            fontFamily: 'Consolas, "Courier New", monospace',
            theme: {
                background: '#1e1e1e',
                foreground: '#d4d4d4',
            }
        });
        const fitAddon = new FitAddon.FitAddon();
        term.loadAddon(fitAddon);
        term.open(document.getElementById('terminal'));
        fitAddon.fit();

        const ws = new WebSocket(`wss://${location.host}/ws/viewer?token=${TOKEN}&session=${SESSION_ID}`);

        ws.onopen = () => {
            document.getElementById('status').textContent = 'Connected';
            // Send initial terminal size
            ws.send(encodeTerminalResize(term.cols, term.rows));
        };

        ws.onmessage = (event) => {
            const msg = decodeProtobuf(event.data);
            if (msg.terminal_data) {
                term.write(new Uint8Array(msg.terminal_data.data));
            }
        };

        term.onData((data) => {
            ws.send(encodeTerminalInput(data));
        });

        term.onResize(({ cols, rows }) => {
            document.getElementById('size').textContent = `${cols}x${rows}`;
            ws.send(encodeTerminalResize(cols, rows));
        });

        window.addEventListener('resize', () => fitAddon.fit());
    </script>
</body>
</html>
```

### Dashboard Mode Selector

```javascript
// server/static/dashboard.js
function renderAgentRow(agent) {
    const icon = agent.terminal_mode
        ? '<i class="icon-terminal"></i> Terminal'
        : '<i class="icon-screen"></i> Screen';

    // For headless agents, show mode selector (Console vs. Shell)
    let connectButtons;
    if (agent.terminal_mode && agent.online) {
        connectButtons = `
            <div class="terminal-mode-selector">
                <button class="btn-console" onclick="connectToTerminal('${agent.id}', 'console')"
                        title="Serial console access (GRUB, boot, panics)">
                    Console
                </button>
                <button class="btn-shell" onclick="connectToTerminal('${agent.id}', 'shell')"
                        title="Interactive shell (bash/zsh)">
                    Shell
                </button>
            </div>
        `;
    } else if (!agent.terminal_mode && agent.online) {
        // GUI agent
        connectButtons = `<button onclick="connectToAgent('${agent.id}')">Connect</button>`;
    } else {
        connectButtons = '<span>Offline</span>';
    }

    return `<tr>
        <td>${agent.name}</td>
        <td>${icon}</td>
        <td>${agent.os} ${agent.os_version}</td>
        <td>${connectButtons}</td>
    </tr>`;
}

function connectToAgent(agentId) {
    // GUI agent connection
    window.open(`/viewer.html?session=${agentId}&token=${JWT}`, '_blank');
}

function connectToTerminal(agentId, mode) {
    // Terminal agent connection with mode parameter
    window.open(`/viewer-terminal.html?session=${agentId}&token=${JWT}&mode=${mode}`, '_blank');
}
```

**Dashboard UI for headless agents:**
- Shows two buttons: "Console" and "Shell"
- "Console" button: opens serial console session (GRUB, boot messages, panics)
- "Shell" button: opens PTY shell session (normal server management)
- Tooltip on hover explains each mode
- Mode parameter passed to viewer via URL query string

### Database Schema

**Minor addition to `sessions` table:**

```sql
-- migrations/012_terminal_mode.sql
ALTER TABLE connect_sessions ADD COLUMN terminal_mode BOOLEAN DEFAULT FALSE;

-- Optional: separate table for terminal recordings
CREATE TABLE IF NOT EXISTS terminal_recordings (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    session_id UUID REFERENCES connect_sessions(id) ON DELETE CASCADE,
    started_at TIMESTAMPTZ DEFAULT NOW(),
    ended_at TIMESTAMPTZ,
    recording_data BYTEA,  -- asciicast JSON or raw PTY dump (compressed)
    size_bytes BIGINT,
    INDEX idx_terminal_recordings_session (session_id)
);
```

## Implementation Details

### Files to Create

**Agent (Linux-specific):**
- `agent/src/platform/linux/console.rs` — NEW: Serial console device I/O (`/dev/ttyS0`, termios config)
- `agent/src/session/console.rs` — NEW: Console session loop (serial device ↔ WebSocket)
- `agent/src/platform/linux/pty.rs` — PTY spawn, I/O, resize (openpty, fork, exec)
- `agent/src/platform/linux/headless.rs` — Headless detection logic
- `agent/src/session/terminal.rs` — Mode dispatcher (console vs. shell), shell session loop

**Server:**
- `server/src/relay/terminal.rs` — Terminal message routing (TerminalData/Input/Resize)
- `server/static/viewer-terminal.html` — xterm.js-based web terminal viewer
- `server/static/vendor/xterm/` — xterm.js library files (CDN or bundled)
- `server/migrations/012_terminal_mode.sql` — Schema update

**Protobuf:**
- `proto/guruconnect.proto` — Add TerminalData, TerminalInput, TerminalResize messages

**Dashboard:**
- `server/static/dashboard.js` — Detect `terminal_mode`, render terminal icon, route to terminal viewer

### Key Dependencies

```toml
# agent/Cargo.toml (Linux-specific)
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"  # openpty, fork, exec, ioctl
nix = "0.27"  # Safe wrappers for POSIX APIs
```

**xterm.js (web viewer):**
- Version: 5.3.0+ (latest stable)
- Addons: `xterm-addon-fit` (auto-resize)
- Delivery: CDN link or bundled in `server/static/vendor/xterm/`

## Security Considerations

### Serial Console Access (Mode 1)

- **Requires root privileges:** Opening `/dev/ttyS0` or `/dev/console` requires root access
- **Implication:** Agent must run as root for console mode, OR use capabilities (`CAP_SYS_TTY_CONFIG`)
- **Boot-level control:** Serial console grants full boot-time control (GRUB menu, kernel parameters, single-user mode)
- **Risk:** Attacker with console access can modify bootloader, disable security features, boot into recovery
- **Mitigation 1:** Restrict console mode to authorized users only (dashboard RBAC: "console_access" permission)
- **Mitigation 2:** Require MFA for console mode sessions (stronger auth than shell mode)
- **Mitigation 3:** Audit logging: record ALL console I/O with immutable timestamps
- **Mitigation 4:** Alert on console mode connections (notify admin when console session starts)

**Recommended deployment:**
- Run agent as unprivileged user for shell mode (default)
- For console mode: either run agent as root OR grant `CAP_SYS_TTY_CONFIG` capability via systemd unit

### Shell Access Risk (Mode 2)

- **Privilege escalation:** PTY spawns shell as the agent's user (typically unprivileged `guruconnect` service user)
- **Mitigation 1:** Run agent as unprivileged user, use `sudo` for privileged commands
- **Mitigation 2:** Add `allowed_commands` whitelist (optional Phase 2 feature) — restrict to specific binaries
- **Mitigation 3:** Audit logging: record all terminal I/O for compliance review

### Authentication

**Same as GUI agents:**
- Support-code for ad-hoc sessions (6-digit, time-limited)
- Persistent agent key for managed servers (per-agent `cak_*` key from SPEC-004)
- Viewer JWT token required for WebSocket connection

### Session Recording (Compliance)

- **Optional toggle:** dashboard setting "Record terminal sessions" (default: ON for compliance)
- **Storage:** `terminal_recordings` table (BYTEA column, compressed)
- **Playback:** Admin dashboard can replay terminal sessions as asciicast (xterm.js built-in playback)
- **Retention:** configurable (default: 90 days, auto-purge older recordings)

### Input Sanitization

- **No sanitization needed:** PTY handles raw bytes; ANSI escape sequences are terminal-native
- **DoS risk:** Malicious viewer could spam resize events; rate-limit `TerminalResize` (max 10/sec)

## Testing Strategy

### Unit Tests

- PTY spawn/cleanup: verify `openpty()` success, shell exec, FD management
- Terminal I/O: mock PTY master FD, test read/write buffers
- Protobuf serialization: TerminalData/Input/Resize round-trip

### Integration Tests

- **Headless VM:** Ubuntu Server 22.04 minimal (no desktop packages)
- **Agent install:** `guruconnect` binary, systemd service, no X11 deps
- **Connect flow:** Dashboard → "Connect" → xterm.js viewer → type `ls`, verify output
- **Resize:** Browser window resize → PTY receives SIGWINCH → `htop` redraws correctly
- **Session cleanup:** Close viewer → PTY process exits gracefully

### Manual Testing Scenarios

1. **Basic shell interaction:**
   - Connect to headless agent via dashboard
   - Type `ls -la`, verify colorized output
   - Run `vim test.txt`, verify cursor movement, editing, save/quit
   - Run `htop`, verify full-screen TUI app renders correctly

2. **Terminal resize:**
   - Start session at default 80x24
   - Resize browser window to 120x40
   - Run `tput cols; tput lines` → verify output matches
   - Run `htop` → verify UI scales to new dimensions

3. **Multi-line output:**
   - Run `dmesg | head -100` → verify scrollback works
   - Run `journalctl -f` → verify live log streaming

4. **Session recording playback:**
   - Perform session actions (ls, vim, htop)
   - End session
   - Admin dashboard → "View Recording" → verify asciicast playback

5. **Privilege escalation (sudo):**
   - Agent runs as `guruconnect` user (non-root)
   - Connect via terminal
   - Run `sudo apt update` → enter sudo password → verify command executes
   - Run `whoami` → verify shows `root` after sudo

### Performance

- **Latency target:** <100ms round-trip for input (same as GUI mode)
- **Bandwidth:** ~1-5 KB/sec for typical terminal I/O (much lower than screen capture)
- **Stress test:** Run `yes` command (infinite output) → verify relay doesn't OOM, rate-limit applied

## Effort Estimate & Dependencies

**Size:** Medium (5-7 weeks, 1 developer)

**Breakdown:**
- Serial console implementation (Linux agent): 1.5 weeks
- PTY implementation (Linux agent): 1.5 weeks
- Mode selection + dispatcher: 0.5 weeks
- Protobuf protocol updates (mode enum, console_mode flag): 0.5 weeks
- Relay server terminal routing: 1 week
- xterm.js web viewer integration: 1 week
- Dashboard mode selector UI + routing: 0.5 weeks
- Session recording + playback (both modes): 1 week
- Testing (console + shell modes), edge cases, systemd integration: 1.5 weeks
- Documentation (setup guide for serial console): 0.5 weeks

**Dependencies:**
- **SPEC-010 Linux agent base** — PTY mode extends the Linux agent; can be implemented in parallel with SPEC-010's GUI capture
- **xterm.js library** — mature, well-tested (used by VS Code, Jupyter, many commercial products)
- **libc/nix crates** — standard Rust POSIX bindings
- **SPEC-004 per-agent keys** — already shipped for persistent agent auth

**Unblocks:**
- **Boot-level access** (GRUB menu, kernel parameters, single-user mode) via serial console mode
- **Emergency recovery** (kernel panics, filesystem repair, systemd rescue shell) via serial console
- **Server management** (Linux VMs, containers, bare metal) via shell mode
- **SSH replacement** with centralized audit logging and GuruConnect auth
- **Container debugging** (exec into running containers via GuruConnect)
- **KVM-over-IP alternative** (serial console provides text-mode equivalent to IPMI Serial-over-LAN)

## Open Questions

1. **Serial console permissions - root vs. capabilities?** — Opening `/dev/ttyS0` requires root. Options: (a) run agent as root for console mode, (b) use Linux capabilities (`CAP_SYS_TTY_CONFIG`), (c) add agent user to `dialout` group (may not work for `/dev/console`). Recommend (b) via systemd unit: `AmbientCapabilities=CAP_SYS_TTY_CONFIG`.

2. **Default mode if serial console unavailable?** — If `/dev/ttyS0` doesn't exist or permission denied, fall back to shell mode automatically or show error? Recommend auto-fallback with warning message in viewer.

3. **Serial console baud rate?** — v1 hardcodes 115200 (industry standard). Phase 2: make configurable if slower links needed (9600, 38400).

4. **Shell selection (PTY mode)?** — v1: `$SHELL` env var → `/bin/bash` → `/bin/sh`. Phase 2: dashboard setting to override shell per agent (`/bin/zsh`, `/bin/fish`).

5. **Concurrent sessions?** — v1: one console/shell session per agent connection (like SSH). Phase 2: tmux/screen integration for multi-viewer session sharing.

6. **Terminal recording format?** — Asciicast (JSON, industry standard, xterm.js playback support) vs. raw dump (more compact, custom playback). Recommend asciicast for v1.

7. **Command whitelisting (shell mode)?** — Optional Phase 2 feature. v1 is unrestricted shell access (same as SSH). Add `allowed_commands` array to agent config if compliance requires it.

8. **RBAC for console vs. shell access?** — Should some users only have shell access (not console, which grants boot-level control)? Recommend yes: add `console_access` permission, separate from `shell_access`.

9. **MFA for console mode?** — Given boot-level control risk, require MFA for console mode sessions? Defer to Phase 2 (MFA is a broader GuruConnect feature).

10. **Windows/macOS terminal mode?** — Defer. Windows Server typically uses RDP or SSH (OpenSSH built-in since Server 2019). macOS servers are rare. Linux headless servers are the primary use case.

11. **File upload/download via terminal?** — v1: use standard tools (`scp`, `rsync`, `wget`). Phase 2: integrate with SPEC (file transfer) for dashboard-native upload/download.

---

**Cross-references:**
- SPEC-010: Cross-platform agents (macOS/Linux GUI) — headless mode extends Linux agent with PTY alternative
- SPEC-004: Stable machine identity — headless agents use same deterministic `machine_uid` (`/etc/machine-id`)
- ADR-001: GuruConnect is standalone — headless mode doesn't require GuruRMM integration
- Future: File transfer spec (roadmap item) — will integrate with terminal mode for `scp`-like functionality
