Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f9f15d6
fix(ws-security): add a pre-accept WebSocket authentication guard (#1…
mrveiss Aug 24, 2026
da57f75
fix(vnc-proxy): authenticate the VNC websocket handshake before accep…
mrveiss Aug 24, 2026
428828a
fix(terminal): authenticate and own-check the terminal websocket (#14…
mrveiss Aug 24, 2026
34c51a6
fix(terminal): attach the auth token to the terminal websocket connec…
mrveiss Aug 24, 2026
92d6c92
test(vnc-proxy): guard the authenticated VNC websocket handshake (#14…
mrveiss Aug 24, 2026
e2fb265
test(terminal): guard websocket auth and session ownership (#14960, #…
mrveiss Aug 24, 2026
2c43fe2
fix(ws-security): union WebSocket credential checks, not just query J…
mrveiss Aug 24, 2026
291536f
fix(terminal): stamp owner on the Chat Terminal's session_configs wri…
mrveiss Aug 24, 2026
f10d958
fix(terminal): authenticate and admin-gate the deprecated SSH websock…
mrveiss Aug 24, 2026
eb8b8a9
fix(terminal): attach auth credentials to the live SSH terminal clien…
mrveiss Aug 24, 2026
ad92a04
fix(terminal): stop retrying a WebSocket that never connected (#14960)
mrveiss Aug 24, 2026
46079c7
fix(terminal): update the e2e smoke script for the now-authenticated …
mrveiss Aug 24, 2026
a5b0a0d
docs(terminal): document the authenticated VNC and terminal websocket…
mrveiss Aug 24, 2026
bfe3248
Auto-fix: code formatting
github-actions[bot] Aug 24, 2026
43c14d0
fix(ci): stop a return-instead-of-assert offender and re-lower its bu…
mrveiss Aug 24, 2026
8377dbf
style(terminal): replace hardcoded hex colors in SSHTerminal.vue with…
mrveiss Aug 24, 2026
7fee79d
fix(frontend): redact auth tokens before logging WebSocket connection…
mrveiss Aug 24, 2026
5aba6e3
fix(terminal-plugin): redact auth tokens before logging WebSocket con…
mrveiss Aug 24, 2026
bb8a6fb
test(frontend): guard WebSocket URL credential redaction (#14989)
mrveiss Aug 24, 2026
1be7c19
fix(frontend): materialise the redactor's query keys without a spread…
mrveiss Aug 24, 2026
4194144
fix(frontend): cover the URL fragment and non-string input in the red…
mrveiss Aug 24, 2026
0db6802
fix(frontend): redact tokens embedded in WebSocket connection-error m…
mrveiss Aug 24, 2026
8a9ecb1
test(frontend): guard the fragment, non-string, and error-message red…
mrveiss Aug 24, 2026
83eefe9
refactor(terminal): bring three grandfathered terminal files back und…
mrveiss Aug 25, 2026
2a5be0f
Auto-fix: code formatting
github-actions[bot] Aug 25, 2026
05a98a1
chore(types): regenerate generated API types from OpenAPI schema(s)
github-actions[bot] Aug 25, 2026
ae98eb8
fix(ci): follow the formatter's line down in both ceiling copies (#14…
mrveiss Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
270 changes: 8 additions & 262 deletions autobot-backend/api/agent_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,12 @@
- docs/architecture/TERMINAL_ARCHITECTURE_DIAGRAM.md - System architecture
"""

import asyncio
from typing import Dict

from fastapi import APIRouter, Depends, HTTPException

# Host selection lives in its own sub-router (#14959) — same shape as
# terminal_tools under api/terminal.py (#185). Included below so every
# /agent-terminal/host-selection/* path stays exactly where it was.
from api.agent_terminal_host_selection import router as host_selection_router
from api.schemas_agent import (
AgentTerminalApproveResponse,
AgentTerminalExecuteResponse,
Expand All @@ -242,18 +243,12 @@
TerminalApproveCommandRequest,
TerminalCreateSessionRequest,
TerminalExecuteCommandRequest,
TerminalHostSelectionRequest,
TerminalInterruptRequest,
TerminalToolApprovalRequest,
)
from api.schemas_terminal import (
AgentTerminalCommandStateResponse,
AgentTerminalHostSelectionCancelResponse,
AgentTerminalHostSelectionGetResponse,
AgentTerminalHostSelectionRequestResponse,
AgentTerminalHostSelectionSubmitResponse,
AgentTerminalInfoResponse,
AgentTerminalPendingSelectionsResponse,
AgentTerminalSessionCreateResponse,
AgentTerminalSessionDeleteResponse,
AgentTerminalSessionDetailResponse,
Expand All @@ -277,6 +272,7 @@

# Create router
router = APIRouter(prefix="/agent-terminal", tags=["agent-terminal"])
router.include_router(host_selection_router)

# Dependency for AgentTerminalService
# CRITICAL: Use singleton pattern to maintain sessions across requests
Expand Down Expand Up @@ -345,12 +341,15 @@ async def create_agent_terminal_session(
)

# Create session
# Issue #14989: thread the authenticated creator through as owner, so the
# terminal WebSocket ownership gate (#14960) recognises them.
session = await service.create_session(
agent_id=request.agent_id,
agent_role=agent_role,
conversation_id=request.conversation_id,
host=request.host,
metadata=request.metadata,
owner=current_user.get("username"),
)

return {
Expand Down Expand Up @@ -857,256 +856,3 @@ async def agent_terminal_info(
"user_control": "Users can interrupt and take control at any time",
},
}


# ============================================================================
# Host Selection API Endpoints
# ============================================================================
# These endpoints enable agents to request host selection for SSH actions.
# The frontend displays a dialog similar to command approval for host selection.
# ============================================================================

import uuid
from datetime import datetime, timezone

# In-memory store for pending host selection requests
# In production, this would use Redis for persistence
_pending_host_selections: Dict[str, Dict] = {}
# Issue #10783 C: guard all check-then-act and iterate-while-mutate sequences
_pending_host_selections_lock = asyncio.Lock()


@router.post("/host-selection/request", response_model=AgentTerminalHostSelectionRequestResponse)
@with_error_handling(
category=ErrorCategory.SERVER_ERROR,
operation="request_host_selection",
error_code_prefix="AGENT_TERMINAL",
)
async def request_host_selection(
current_user: dict = Depends(get_current_user),
request: TerminalHostSelectionRequest = None,
):
"""
Agent requests host selection for SSH action.

Issue #744: Requires authenticated user.

This endpoint creates a pending host selection request that the frontend
will display to the user. The user selects from available infrastructure
hosts, and the selection is returned via the /host-selection/{request_id}
endpoint.

Flow:
1. Agent calls POST /host-selection/request with command/purpose
2. Backend returns request_id with status="pending_selection"
3. Frontend shows HostSelectionDialog to user
4. User selects host and calls POST /host-selection/{request_id}/select
5. Agent polls GET /host-selection/{request_id} to get selection result
"""
request_id = str(uuid.uuid4())

# Create pending selection request
async with _pending_host_selections_lock:
_pending_host_selections[request_id] = {
"request_id": request_id,
"agent_session_id": request.agent_session_id,
"command": request.command,
"purpose": request.purpose,
"preferred_host_id": request.preferred_host_id,
"allow_auto_select": request.allow_auto_select,
"status": "pending_selection",
"selected_host_id": None,
"selected_host_name": None,
"connection_info": None,
"created_at": datetime.now(tz=timezone.utc).isoformat(),
"updated_at": None,
}

logger.info(f"Host selection requested: {request_id}")

return {
"request_id": request_id,
"status": "pending_selection",
"message": "Host selection dialog should be shown to user",
}


@router.get("/host-selection/{request_id}", response_model=AgentTerminalHostSelectionGetResponse)
@with_error_handling(
category=ErrorCategory.SERVER_ERROR,
operation="get_host_selection",
error_code_prefix="AGENT_TERMINAL",
)
async def get_host_selection(
request_id: str,
current_user: dict = Depends(get_current_user),
):
"""
Get the status/result of a host selection request.

Issue #744: Requires authenticated user.

Agent polls this endpoint to check if user has made a selection.

Returns:
- status: "pending_selection", "selected", or "cancelled"
- If selected: includes host details and connection info
"""
async with _pending_host_selections_lock:
if request_id not in _pending_host_selections:
raise HTTPException(status_code=404, detail=f"Host selection request {request_id} not found")
selection = dict(_pending_host_selections[request_id])

return {
"request_id": selection["request_id"],
"status": selection["status"],
"selected_host_id": selection["selected_host_id"],
"selected_host_name": selection["selected_host_name"],
"connection_info": selection["connection_info"],
"created_at": selection["created_at"],
"updated_at": selection["updated_at"],
}


@router.post("/host-selection/{request_id}/select", response_model=AgentTerminalHostSelectionSubmitResponse)
@with_error_handling(
category=ErrorCategory.SERVER_ERROR,
operation="submit_host_selection",
error_code_prefix="AGENT_TERMINAL",
)
async def submit_host_selection(
request_id: str,
current_user: dict = Depends(get_current_user),
host_id: str = None,
host_name: str = None,
host: str = None,
ssh_port: int = 22,
username: str = "root",
remember_choice: bool = False,
):
"""
User submits their host selection.

Issue #744: Requires authenticated user.

Called by frontend when user selects a host from the dialog.

Args:
request_id: The pending selection request ID
host_id: Selected host ID from secrets
host_name: Display name of the host
host: Hostname or IP address
ssh_port: SSH port number
username: SSH username
remember_choice: Whether to use this host for future SSH commands
"""
async with _pending_host_selections_lock:
if request_id not in _pending_host_selections:
raise HTTPException(status_code=404, detail=f"Host selection request {request_id} not found")

selection = _pending_host_selections[request_id]

if selection["status"] != "pending_selection":
raise HTTPException(
status_code=400,
detail=f"Host selection request {request_id} is not pending (status: {selection['status']})",
)

# Update selection with user's choice
selection["status"] = "selected"
selection["selected_host_id"] = host_id
selection["selected_host_name"] = host_name
selection["connection_info"] = {
"host": host,
"ssh_port": ssh_port,
"username": username,
}
selection["updated_at"] = datetime.now(tz=timezone.utc).isoformat()
selection["remember_choice"] = remember_choice
connection_info = dict(selection["connection_info"])

logger.info(f"Host selected for request {request_id}: {host_name} ({username}@{host}:{ssh_port})")

return {
"status": "selected",
"request_id": request_id,
"selected_host_id": host_id,
"selected_host_name": host_name,
"connection_info": connection_info,
}


@router.post("/host-selection/{request_id}/cancel", response_model=AgentTerminalHostSelectionCancelResponse)
@with_error_handling(
category=ErrorCategory.SERVER_ERROR,
operation="cancel_host_selection",
error_code_prefix="AGENT_TERMINAL",
)
async def cancel_host_selection(
request_id: str,
current_user: dict = Depends(get_current_user),
):
"""
User cancels host selection.

Issue #744: Requires authenticated user.

Called by frontend when user closes the dialog without selecting.
"""
async with _pending_host_selections_lock:
if request_id not in _pending_host_selections:
raise HTTPException(status_code=404, detail=f"Host selection request {request_id} not found")

selection = _pending_host_selections[request_id]

if selection["status"] != "pending_selection":
raise HTTPException(
status_code=400,
detail=f"Host selection request {request_id} is not pending (status: {selection['status']})",
)

# Mark as cancelled
selection["status"] = "cancelled"
selection["updated_at"] = datetime.now(tz=timezone.utc).isoformat()

logger.info(f"Host selection cancelled for request {request_id}")

return {
"status": "cancelled",
"request_id": request_id,
}


@router.get("/host-selection", response_model=AgentTerminalPendingSelectionsResponse)
@with_error_handling(
category=ErrorCategory.SERVER_ERROR,
operation="list_pending_host_selections",
error_code_prefix="AGENT_TERMINAL",
)
async def list_pending_host_selections(
current_user: dict = Depends(get_current_user),
):
"""
List all pending host selection requests.

Issue #744: Requires authenticated user.

Frontend uses this to show any pending selection dialogs on page load.
"""
async with _pending_host_selections_lock:
pending = [
{
"request_id": s["request_id"],
"command": s["command"],
"purpose": s["purpose"],
"created_at": s["created_at"],
}
for s in _pending_host_selections.values()
if s["status"] == "pending_selection"
]

return {
"status": "success",
"pending_count": len(pending),
"pending_selections": pending,
}
Loading
Loading