spec: add SPEC-013 Windows Session Selection and Backstage Mode
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 8m5s
Build and Test / Build Server (Linux) (push) Successful in 11m24s
Build and Test / Security Audit (push) Successful in 4m30s
Build and Test / Build Summary (push) Successful in 12s

This commit is contained in:
2026-05-31 07:54:10 -07:00
parent b3e8f32734
commit 5637e4c1f9
2 changed files with 718 additions and 0 deletions

View File

@@ -62,6 +62,7 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001](
- [x] Protobuf-over-WSS transport, Zstd frame compression
- [~] React/TS web viewer (`dashboard/src/components/RemoteViewer.tsx`) — embeddable session viewer
- [ ] **Headless Linux mode (direct TTY access)** — P2 — Terminal-based remote access for Linux servers without GUI. PTY spawn (`openpty`), xterm.js web viewer, full ANSI/VT100 support. Enables server management, container debugging, emergency recovery via GuruConnect dashboard with audit logging. SSH replacement with centralized auth. ([SPEC-012](specs/SPEC-012-headless-linux-tty.md))
- [ ] **Windows session selection and backstage mode** — P2 — Enumerate and switch between Windows user sessions (Terminal Services/RDP/Fast User Switching) and access Session 0 (backstage) for system-level admin tasks. ScreenConnect parity: session selector shows all logged-on users, instant switching without reconnect. Backstage mode provides terminal/command interface for services management without disrupting any user desktop. Critical for multi-user server environments. ([SPEC-013](specs/SPEC-013-session-selection-and-backstage.md))
- [ ] Multi-monitor switching — P2
- [ ] File transfer — P3 (out of scope for native-remote-control v1)
- [ ] Session recording — P3 (out of scope for native-remote-control v1)

View File

@@ -0,0 +1,717 @@
# SPEC-013: Windows Session Selection and Backstage Mode
**Status:** Proposed
**Priority:** P2
**Requested By:** Mike Swanson (2026-05-30)
**Estimated Effort:** Large
## Overview
Enable GuruConnect to enumerate and switch between multiple Windows user sessions (Terminal Services/RDP/Fast User Switching) and access Session 0 (backstage mode) for system-level administrative tasks. This addresses a critical gap for MSPs managing multi-user Windows servers and workstations — ScreenConnect's session selector allows technicians to see all logged-on users and switch between them instantly, and the backstage mode provides a command/task-manager interface for Session 0 (services) without disrupting any user's desktop. Success criteria: technicians can view and select from all active sessions in the dashboard/viewer, switch sessions mid-stream without reconnecting, and enter backstage mode to run commands and view services when no user desktop is needed.
## Scope
### Included in v1
**Session Enumeration and Switching:**
- Agent enumerates all Windows sessions via `WTSEnumerateSessions` API at startup and on-demand
- Reports session list to server: session ID, user name, session state (active/disconnected), session type (console/RDP)
- Viewer displays session selector UI (dropdown or list) showing all available sessions
- User selects a session → server sends `SwitchSession` message → agent switches capture/input to that session
- Session switching without WebSocket disconnect (same session token, just changes the desktop being captured)
- Dashboard shows session count and logged-on users per machine
**Backstage Mode (Session 0 Access):**
- Agent can enter "backstage" mode targeting Session 0 (services session)
- Backstage mode provides a terminal/command interface (not DXGI screen capture, since Session 0 has no interactive desktop in modern Windows)
- Dashboard/viewer shows backstage UI with:
- Command prompt / PowerShell terminal (PTY-like, text-based)
- Running services list (via `EnumServicesStatusEx`)
- Running processes list (via `EnumProcesses` / `CreateToolhelp32Snapshot`)
- System info panel (OS version, uptime, logged-on sessions)
- Backstage mode uses `SessionType::BACKSTAGE` (already in protobuf line 42)
- Agent runs commands in Session 0 context via `CreateProcessAsUser` with Session 0 token
**Protobuf Extensions:**
- New messages: `SessionInfo`, `SessionList`, `SwitchSession`, `BackstageCommand`, `BackstageOutput`
- Extend `AgentStatus` to include session list
- Extend `StartStream` to specify target session ID
**Security:**
- Session switching requires elevated (admin) agent process
- Backstage mode is admin-only (gated server-side by user role)
- Audit log records all session switches and backstage commands
### Explicitly out of scope
- **Session shadowing** (viewing a session while another user is active in it) — defer to Phase 2; complex Terminal Services permission model
- **Session connect/disconnect control** (logging users off, disconnecting RDP sessions) — defer; high-impact action
- **Cross-session clipboard** — clipboard currently session-scoped; multi-session clipboard is a future enhancement
- **Linux/macOS session switching** — this spec is Windows-only; cross-platform session concepts differ significantly
- **Backstage GUI tools** (registry editor, event log viewer) — v1 is terminal/command-only; GUI tools are future enhancements
- **Session 0 screen capture** — Session 0 has no interactive desktop on modern Windows (Session 0 isolation, Vista+); backstage is terminal/data-only
## Architecture
### Agent Changes
**Session Management Module** (`agent/src/session/mod.rs` or new `agent/src/sessions/`)
- **Enumerate sessions:**
```rust
use windows::Win32::System::RemoteDesktop::{
WTSEnumerateSessionsW, WTSFreeMemory, WTSQuerySessionInformationW,
WTS_SESSION_INFOW, WTSUserName, WTSSessionInfo, WTS_CURRENT_SERVER_HANDLE
};
pub struct SessionInfo {
pub session_id: u32,
pub user_name: String,
pub state: SessionState, // Active, Disconnected, Idle
pub session_type: SessionType, // Console, RDP
}
pub fn enumerate_sessions() -> Result<Vec<SessionInfo>> {
// Call WTSEnumerateSessionsW
// For each session, call WTSQuerySessionInformationW(WTSUserName, WTSSessionInfo)
// Filter out Session 0 (services) from user session list
}
```
- **Switch session desktop:**
```rust
use windows::Win32::System::RemoteDesktop::WTSGetActiveConsoleSessionId;
use windows::Win32::UI::WindowsAndMessaging::{OpenDesktop, CloseDesktop, SwitchDesktop};
pub struct SessionContext {
pub session_id: u32,
pub desktop_handle: HDESK, // Handle to the session's desktop
}
pub fn switch_to_session(session_id: u32) -> Result<SessionContext> {
// OpenDesktop for the target session (desktop name: "WinSta0\\Default" or session-specific)
// SwitchDesktop to make it the active desktop for capture
// Reinitialize DXGI capturer on the new desktop
}
```
- **Backstage mode (Session 0 command execution):**
```rust
use windows::Win32::System::Threading::{CreateProcessAsUserW, PROCESS_INFORMATION};
use windows::Win32::Security::{LogonUserW, DuplicateTokenEx};
pub struct BackstageSession {
pub session_id: u32, // Always 0 for backstage
pub process_handle: HANDLE,
pub stdout_pipe: HANDLE,
}
pub fn execute_backstage_command(command: &str) -> Result<BackstageOutput> {
// CreateProcessAsUserW with Session 0 token (obtained via WTSQueryUserToken for Session 0)
// Capture stdout/stderr via anonymous pipes
// Return output as text (no screen capture)
}
pub fn list_services() -> Result<Vec<ServiceInfo>> {
// EnumServicesStatusEx(SC_MANAGER_ENUMERATE_SERVICE)
}
pub fn list_processes() -> Result<Vec<ProcessInfo>> {
// CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS)
}
```
**Capture Module Changes** (`agent/src/capture/mod.rs`, `agent/src/capture/dxgi.rs`)
- Refactor `DxgiCapturer::new()` to accept a `session_id` parameter
- `OpenDesktop` for the target session before initializing DXGI duplication
- Store current session ID in capturer state; reinitialize on session switch
- Backstage mode does NOT use DXGI — it's terminal/data-only
**Input Injection Changes** (`agent/src/input/mod.rs`)
- Ensure `SendInput` targets the correct session's desktop (already handles this via desktop handle)
- Backstage mode does NOT inject input (command execution only)
**New Files:**
- `agent/src/sessions/mod.rs` — session enumeration, switching, backstage execution
- `agent/src/sessions/wts.rs` — WTS API wrappers (enumerate, query, get token)
- `agent/src/sessions/backstage.rs` — Session 0 command execution, service/process enumeration
### Relay Server Changes
**Session State Management** (`server/src/relay/mod.rs` or `server/src/session/`)
- Extend `SessionState` struct to track current session ID:
```rust
pub struct SessionState {
pub session_id: String,
pub session_type: SessionType, // SCREEN_CONTROL, VIEW_ONLY, BACKSTAGE
pub target_session_id: Option<u32>, // Windows session ID (1, 2, 3, etc.) or 0 for backstage
// ... existing fields
}
```
- Handle `SessionList` message from agent (periodic update of available sessions)
- Store session list in `SessionState` for dashboard queries
- Handle `SwitchSession` message from viewer → forward to agent
- Handle `BackstageCommand` message from viewer → forward to agent
- Handle `BackstageOutput` message from agent → forward to viewer
**API Endpoints** (`server/src/api/sessions.rs`)
- `GET /api/sessions/:session_id/windows-sessions` — return list of Windows sessions for this agent
```json
{
"sessions": [
{"id": 1, "user": "jdoe", "state": "Active", "type": "Console"},
{"id": 2, "user": "admin", "state": "Disconnected", "type": "RDP"}
]
}
```
- Backstage commands are sent via WebSocket, not HTTP (same as mouse/keyboard input)
**Database Schema** (`server/migrations/`)
- Extend `connect_sessions` table:
```sql
ALTER TABLE connect_sessions ADD COLUMN windows_session_id INT NULL;
ALTER TABLE connect_sessions ADD COLUMN session_type VARCHAR(20) DEFAULT 'screen_control';
```
- Extend `events` table to log session switches:
```sql
INSERT INTO events (session_id, event_type, details) VALUES
('...', 'session_switched', '{"from_session": 1, "to_session": 2, "user": "admin"}');
```
### Protobuf Changes (`proto/guruconnect.proto`)
```protobuf
// Session information (Windows Terminal Services session)
message SessionInfo {
uint32 session_id = 1; // Windows session ID (1, 2, 3, etc.)
string user_name = 2; // Logged-on user name
SessionState state = 3; // Active, Disconnected, Idle
WindowsSessionType type = 4; // Console, RDP
}
enum SessionState {
SESSION_ACTIVE = 0;
SESSION_DISCONNECTED = 1;
SESSION_IDLE = 2;
}
enum WindowsSessionType {
SESSION_CONSOLE = 0; // Local console session
SESSION_RDP = 1; // Remote Desktop session
}
// List of available Windows sessions (agent -> server)
message SessionList {
repeated SessionInfo sessions = 1;
uint32 current_session_id = 2; // Which session the agent is currently capturing
}
// Switch to a different Windows session (viewer -> agent)
message SwitchSession {
uint32 target_session_id = 1; // Session ID to switch to (0 for backstage)
}
// Backstage command execution (viewer -> agent)
message BackstageCommand {
BackstageCommandType command_type = 1;
string command_text = 2; // For EXEC_COMMAND: PowerShell or CMD command
}
enum BackstageCommandType {
BACKSTAGE_EXEC_COMMAND = 0; // Execute a command in Session 0
BACKSTAGE_LIST_SERVICES = 1; // Enumerate running services
BACKSTAGE_LIST_PROCESSES = 2; // Enumerate running processes
BACKSTAGE_SYSTEM_INFO = 3; // Get system information
}
// Backstage output (agent -> viewer)
message BackstageOutput {
BackstageCommandType command_type = 1;
oneof output {
CommandOutput command_output = 10;
ServiceList service_list = 11;
ProcessList process_list = 12;
SystemInfo system_info = 13;
}
}
message CommandOutput {
string stdout = 1;
string stderr = 2;
int32 exit_code = 3;
}
message ServiceList {
repeated ServiceInfo services = 1;
}
message ServiceInfo {
string name = 1;
string display_name = 2;
ServiceStatus status = 3; // Running, Stopped, Paused
string startup_type = 4; // Automatic, Manual, Disabled
}
enum ServiceStatus {
SERVICE_RUNNING = 0;
SERVICE_STOPPED = 1;
SERVICE_PAUSED = 2;
}
message ProcessList {
repeated ProcessInfo processes = 1;
}
message ProcessInfo {
uint32 pid = 1;
string name = 2;
string user = 3;
uint64 memory_kb = 4;
}
message SystemInfo {
string os_version = 1;
string hostname = 2;
uint64 uptime_secs = 3;
repeated SessionInfo logged_on_sessions = 4;
}
// Extend Message wrapper to include new message types
message Message {
oneof payload {
// ... existing messages ...
// Session switching (field numbers 90-99)
SessionList session_list = 90;
SwitchSession switch_session = 91;
BackstageCommand backstage_command = 92;
BackstageOutput backstage_output = 93;
}
}
```
### Dashboard/Viewer Changes
**Dashboard** (`server/static/dashboard.html` or React component)
- Machine detail view shows "Sessions (3)" next to online status
- Click sessions count → modal showing session list with Switch button
- "Enter Backstage" button for admin users (gated by `user.role === 'admin'`)
**Native Viewer** (`agent/src/viewer/mod.rs`)
- Session selector dropdown in viewer toolbar
- Populate dropdown from `SessionList` message
- On selection change → send `SwitchSession` message
- "Backstage Mode" button (toggle) — switches to terminal UI instead of video frames
**Web Viewer** (`dashboard/src/components/RemoteViewer.tsx`)
- Same session selector dropdown as native viewer
- Backstage mode shows a terminal-like UI (xterm.js or custom) displaying command output
- Text input for commands, buttons for List Services / List Processes / System Info
## Implementation Details
### Files to Create
**Agent:**
- `agent/src/sessions/mod.rs` (300 lines) — session enumeration, switching, state
- `agent/src/sessions/wts.rs` (200 lines) — WTS API wrappers
- `agent/src/sessions/backstage.rs` (400 lines) — Session 0 command execution, service/process enum
**Server:**
- `server/src/relay/sessions.rs` (150 lines) — session list state, switch logic
- `server/migrations/00N_session_selection.sql` (50 lines) — schema changes
**Dashboard:**
- `server/static/js/session-selector.js` (200 lines) — session dropdown UI
- `server/static/css/backstage.css` (100 lines) — backstage terminal styling
### Files to Modify
**Agent:**
- `agent/src/capture/dxgi.rs:42` — Add session ID parameter to `DxgiCapturer::new()`
- `agent/src/capture/mod.rs:76` — Call `OpenDesktop` for target session before DXGI init
- `agent/src/session/mod.rs:120` — Extend session loop to handle `SwitchSession` and `BackstageCommand`
- `agent/src/main.rs:15` — Add `mod sessions;`
**Server:**
- `server/src/relay/mod.rs:85` — Handle `SessionList`, `SwitchSession`, `BackstageCommand`, `BackstageOutput` messages
- `server/src/api/sessions.rs:40` — Add endpoint for session list query
- `server/src/db/events.rs:25` — Log session switch events
**Proto:**
- `proto/guruconnect.proto:454` — Add new message types (as shown above)
### Key Logic
**Agent Session Enumeration:**
```rust
// agent/src/sessions/wts.rs
use windows::Win32::System::RemoteDesktop::{
WTSEnumerateSessionsW, WTSQuerySessionInformationW, WTSFreeMemory,
WTS_SESSION_INFOW, WTSUserName, WTSConnectState, WTS_CURRENT_SERVER_HANDLE
};
pub fn enumerate_sessions() -> Result<Vec<SessionInfo>> {
let mut sessions = Vec::new();
let mut session_info_ptr: *mut WTS_SESSION_INFOW = std::ptr::null_mut();
let mut count: u32 = 0;
unsafe {
if WTSEnumerateSessionsW(
WTS_CURRENT_SERVER_HANDLE,
0, // Reserved
1, // Version
&mut session_info_ptr,
&mut count
).as_bool() {
let session_array = std::slice::from_raw_parts(session_info_ptr, count as usize);
for wts_session in session_array {
// Skip Session 0 (services) for user session list
if wts_session.SessionId == 0 {
continue;
}
// Query user name
let mut user_name_ptr: *mut u16 = std::ptr::null_mut();
let mut bytes_returned: u32 = 0;
if WTSQuerySessionInformationW(
WTS_CURRENT_SERVER_HANDLE,
wts_session.SessionId,
WTSUserName,
&mut user_name_ptr as *mut _ as *mut _,
&mut bytes_returned
).as_bool() {
let user_name = String::from_utf16_lossy(
std::slice::from_raw_parts(user_name_ptr, (bytes_returned / 2) as usize)
);
sessions.push(SessionInfo {
session_id: wts_session.SessionId,
user_name,
state: map_wts_state(wts_session.State),
session_type: if wts_session.pWinStationName.to_string()?.contains("RDP") {
WindowsSessionType::RDP
} else {
WindowsSessionType::Console
}
});
WTSFreeMemory(user_name_ptr as *mut _);
}
}
WTSFreeMemory(session_info_ptr as *mut _);
}
}
Ok(sessions)
}
```
**Agent Session Switching:**
```rust
// agent/src/sessions/mod.rs
use windows::Win32::UI::WindowsAndMessaging::{
OpenDesktopW, CloseDesktop, SwitchDesktop, HDESK
};
use windows::core::PCWSTR;
pub struct SessionManager {
current_session_id: u32,
desktop_handle: Option<HDESK>,
}
impl SessionManager {
pub fn switch_session(&mut self, target_session_id: u32) -> Result<()> {
tracing::info!("Switching from session {} to {}", self.current_session_id, target_session_id);
// Close current desktop handle
if let Some(handle) = self.desktop_handle.take() {
unsafe { CloseDesktop(handle) };
}
// Open the target session's desktop
// Desktop name format: "WinSta0\\Default" for console session
let desktop_name = format!("WinSta0\\Default");
let desktop_name_wide: Vec<u16> = desktop_name.encode_utf16().chain(Some(0)).collect();
let desktop_handle = unsafe {
OpenDesktopW(
PCWSTR::from_raw(desktop_name_wide.as_ptr()),
0, // Flags
false.into(), // Inherit handles
0x0001 | 0x0002 | 0x0004 | 0x0008, // DESKTOP_READOBJECTS | DESKTOP_CREATEWINDOW | etc.
)?
};
// Switch to the new desktop
unsafe {
SwitchDesktop(desktop_handle)?;
}
self.current_session_id = target_session_id;
self.desktop_handle = Some(desktop_handle);
// Reinitialize screen capturer for the new desktop
// (caller must recreate DxgiCapturer)
Ok(())
}
}
```
**Backstage Command Execution:**
```rust
// agent/src/sessions/backstage.rs
use windows::Win32::System::Threading::{CreateProcessW, PROCESS_INFORMATION, STARTUPINFOW};
use windows::Win32::System::Pipes::{CreatePipe, ReadFile};
pub fn execute_command(command: &str) -> Result<CommandOutput> {
// Create anonymous pipes for stdout/stderr
let mut stdout_read: HANDLE = HANDLE::default();
let mut stdout_write: HANDLE = HANDLE::default();
unsafe {
CreatePipe(&mut stdout_read, &mut stdout_write, None, 0)?;
}
// Launch cmd.exe /c <command> with redirected output
let command_line = format!("cmd.exe /c {}", command);
let mut command_line_wide: Vec<u16> = command_line.encode_utf16().chain(Some(0)).collect();
let mut startup_info = STARTUPINFOW {
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
hStdOutput: stdout_write,
hStdError: stdout_write,
dwFlags: STARTF_USESTDHANDLES,
..Default::default()
};
let mut process_info = PROCESS_INFORMATION::default();
unsafe {
CreateProcessW(
None,
PWSTR::from_raw(command_line_wide.as_mut_ptr()),
None,
None,
true.into(), // Inherit handles
0,
None,
None,
&startup_info,
&mut process_info
)?;
CloseHandle(stdout_write);
// Read output
let mut output = Vec::new();
let mut buffer = [0u8; 4096];
let mut bytes_read: u32 = 0;
loop {
if ReadFile(stdout_read, Some(&mut buffer), Some(&mut bytes_read), None).is_ok() {
if bytes_read == 0 {
break;
}
output.extend_from_slice(&buffer[..bytes_read as usize]);
} else {
break;
}
}
// Wait for process to exit
WaitForSingleObject(process_info.hProcess, INFINITE);
let mut exit_code: u32 = 0;
GetExitCodeProcess(process_info.hProcess, &mut exit_code)?;
CloseHandle(process_info.hProcess);
CloseHandle(process_info.hThread);
CloseHandle(stdout_read);
Ok(CommandOutput {
stdout: String::from_utf8_lossy(&output).to_string(),
stderr: String::new(),
exit_code: exit_code as i32,
})
}
}
```
## Security Considerations
### Authentication & Authorization
- **Session switching requires elevated agent:** Agent must run as SYSTEM or Administrator to call WTS APIs and `OpenDesktop` across sessions
- **Backstage mode is admin-only:** Server checks `user.role === 'admin'` before allowing `BackstageCommand` messages
- **Audit logging:** All session switches and backstage commands are logged to `events` table with:
- Timestamp
- Technician user ID
- Session ID (from/to)
- Command text (for backstage)
- Output (sanitized, no secrets)
### Input Validation
- **Session ID bounds checking:** Agent validates `target_session_id` exists in `WTSEnumerateSessions` results before switching
- **Command injection prevention:** Backstage commands are executed via `cmd.exe /c` with proper escaping; no shell metacharacter expansion
- **Output sanitization:** Command output is truncated to 100KB max to prevent memory exhaustion
### Threat Model
- **Malicious session switch:** Attacker with viewer access tries to switch to a privileged session (e.g., admin RDP session) → mitigated by server-side session ownership validation (can only switch sessions on machines you have access to)
- **Backstage command abuse:** Attacker executes destructive commands (e.g., `rd /s /q C:\`) → mitigated by admin-only role gate + audit logging
- **Session 0 privilege escalation:** Attacker uses backstage to run code as SYSTEM → inherent risk, same as any admin tool; audit log is the control
### Audit Events
**New event types:**
- `session_switched` — Log when technician switches Windows sessions
- `backstage_command` — Log all backstage command executions
- `backstage_service_query` — Log service list queries
- `backstage_process_query` — Log process list queries
**Event schema:**
```sql
CREATE TABLE events (
id UUID PRIMARY KEY,
session_id UUID REFERENCES connect_sessions(id),
user_id UUID REFERENCES users(id),
event_type VARCHAR(50) NOT NULL,
details JSONB NOT NULL, -- {"from_session": 1, "to_session": 2, "command": "ipconfig", "exit_code": 0}
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
## Testing Strategy
### Unit Tests
**Agent (Rust):**
- `sessions::wts::test_enumerate_sessions()` — mock WTS API, verify session parsing
- `sessions::backstage::test_execute_command()` — mock CreateProcess, verify output capture
- `sessions::test_switch_session()` — mock OpenDesktop/SwitchDesktop, verify state change
**Server (Rust):**
- `relay::sessions::test_session_list_update()` — verify session list parsing and storage
- `relay::sessions::test_switch_session_message()` — verify message forwarding to agent
- `api::sessions::test_get_sessions_endpoint()` — verify API response format
### Integration Tests
**End-to-end session switching:**
1. Start agent on Windows machine with 2 logged-on users (console + RDP)
2. Connect viewer to session
3. Agent sends `SessionList` with 2 sessions
4. Viewer sends `SwitchSession(2)`
5. Agent switches to RDP session, restarts DXGI capture
6. Viewer receives frames from RDP session desktop
7. Verify session switch logged in `events` table
**Backstage command execution:**
1. Admin user connects to agent
2. Enter backstage mode
3. Execute command: `ipconfig /all`
4. Verify output displayed in viewer
5. Execute command: `sc query wuauserv` (query Windows Update service)
6. Verify service status returned
7. Verify commands logged with full details
### Manual Testing Scenarios
1. **Multi-user server:**
- Windows Server 2019 with 3 RDP users logged in
- Enumerate sessions → verify all 3 shown in dropdown
- Switch between sessions → verify screen updates correctly
- Verify no session 0 in dropdown (backstage mode is separate)
2. **Fast User Switching workstation:**
- Windows 10 workstation with 2 users (one active, one locked)
- Enumerate sessions → verify both shown with correct state
- Switch to locked session → verify lock screen is captured
3. **Backstage mode:**
- Connect to agent
- Enter backstage → verify no video frames, just terminal UI
- Run `hostname` → verify output
- List services → verify service table displayed
- List processes → verify process list
- Exit backstage → return to normal screen control
4. **Permission enforcement:**
- Non-admin user tries to enter backstage → verify 403 Forbidden
- Non-admin user tries to send `BackstageCommand` → server rejects
5. **Audit logging:**
- Perform session switch + backstage commands
- Query `events` table → verify all actions logged with timestamps, user IDs, details
### CI/CD Additions
- **Windows test VM:** Gitea Actions Windows runner with 2 test users configured (via Local Users and Groups)
- **Automated test:** PowerShell script to create RDP session, run integration test, verify session switch
- **Audit log verification:** Integration test queries events table after actions, asserts correct event_type and details
## Effort Estimate & Dependencies
**Size:** Large (8-10 weeks, 1 developer)
**Breakdown:**
- Agent session enumeration + WTS API wrappers: 1.5 weeks
- Agent session switching (OpenDesktop, SwitchDesktop, DXGI reinit): 2 weeks
- Agent backstage mode (command execution, service/process enum): 2 weeks
- Server session state management + protobuf: 1 week
- Dashboard session selector UI: 1 week
- Backstage terminal UI (web viewer): 1.5 weeks
- Testing (integration tests, manual scenarios, audit verification): 1.5 weeks
- Buffer for Windows session edge cases (disconnected sessions, fast user switching quirks): 1 week
**Dependencies:**
- **SPEC-002 v2 Phase 1 completion** — per-agent keys and secure session core must be stable (already shipped)
- **Admin role enforcement** — server must have role-based access control for backstage gating (already exists: `users.role`)
- **Event logging infrastructure** — audit events table and logging (already exists: `events` table)
**Unblocks:**
- Multi-user server management (critical for MSPs with Windows Server environments)
- Session 0 administrative tasks without GUI disruption (backstage mode)
- SPEC-005 machines list view (can show session count and logged-on users per machine)
- Future: session recording (can record specific session, not just active console)
## Open Questions
1. **Session 0 GUI in older Windows?** — Windows XP/Server 2003 allowed interactive Session 0. GuruConnect targets Windows 7+ where Session 0 is non-interactive. Confirm backstage terminal-only approach is acceptable, or add a "force capture Session 0 desktop" option for legacy systems?
2. **Session switching during active control?** — If technician is moving the mouse and switches sessions mid-action, should the agent queue the switch until input is idle, or switch immediately? (Recommend: immediate switch, input events are session-scoped.)
3. **Disconnected session handling?** — If technician switches to a disconnected RDP session, should the agent attempt to reconnect it (via `WTSConnectSession`), or just capture the "disconnected" screen? (Recommend: just capture; reconnection is a high-impact action.)
4. **Backstage command timeout?** — Long-running commands (e.g., `chkdsk`) could hang. Implement a timeout (e.g., 60 seconds) and kill the process? (Recommend: yes, 60s default, configurable.)
5. **Clipboard across sessions?** — Current clipboard implementation is session-scoped. Should session switching clear the clipboard state, or try to preserve it? (Recommend: clear on switch; cross-session clipboard is a future enhancement.)
---
**Cross-references:**
- SPEC-002: v2 modernization (protobuf already has `BACKSTAGE = 2`)
- SPEC-005: Machines list view (can show session count + logged-on users)
- SPEC-008: Structured errors (session switch failures need clear error codes)
- REQUIREMENTS.md:498 — "Backstage Tools (No Screen Required)"
- REQUIREMENTS.md:727 — "Backstage / Silent Support Mode"