fix: run tool terminal sessions as container user instead of root

- Remove compose-level user: 0:0 override from manifest_compiler.py so the
  entrypoint can start as root, fix mount ownership, and drop privileges to
  the container user internally.
- Add get_manifest_container_user() helper to resolve the manifest-declared
  container user (with uid:gid fallback).
- Pass container user through TerminalSession, TerminalManager, and the
  terminal WebSocket handler so docker exec is invoked with --user <user>.
- Update and add unit tests for the manifest compiler and terminal session.
- Record the additional root-user fix in the fix-pi-container-mount-permissions
  OpenSpec change/tasks.

Quality gates: pytest tests/unit/ (226 passed), pytest tests/services/test_terminal_manager_multi.py (7 passed), ruff check on changed files (clean), mypy on changed files (clean)
This commit is contained in:
Developer
2026-06-17 20:51:46 +00:00
parent 3e59a257dc
commit 9f720930ea
28 changed files with 358 additions and 131 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src
## role
Core backend API package for the "Headquarter API" FastAPI application, handling configuration, database setup, logging, and application bootstrap.
Core FastAPI web application package that bootstraps and configures the Headquarter API service with database, authentication, logging, and modular routing infrastructure.
## parent
index: apps/api/.pi-map.index.md
map: apps/api/.pi-map.md
+7 -7
View File
@@ -4,17 +4,17 @@ dir: apps/api/src
index: apps/api/src/.pi-map.index.md
## role
Core backend API package for the "Headquarter API" FastAPI application, handling configuration, database setup, logging, and application bootstrap.
Core bootstrap and infrastructure package for the Headquarter API FastAPI application, handling configuration, database connectivity, logging, and application startup.
## files
- __init__.py | Marks the directory as a Python package for the Headquarter API.
- config.py | Defines application configuration settings with environment variable loading, database URL construction, and computed properties for service URLs and security settings. | exp: class:Settings, func:build_database_url(user: str, password: str, host: str, port: int, database: str) → str | dep: pydantic, pydantic_settings
- database.py | Sets up an async SQLAlchemy database engine with retry logic and runs Alembic migrations via subprocess to initialize the database. | exp: func:init_database(max_retries, retry_delay) → bool, call:range, call:engine.connect, call:test_conn.execute, call:text, call:test_conn.close, call:logger.info, call:asyncio.get_event_loop().run_in_executor, call:subprocess.run, call:os.path.dirname, call:os.path.abspath, call:logger.debug, call:logger.error, call:asyncio.sleep, call:str(exc).lower, call:logger.warning | dep: asyncio, logging, os, subprocess, sqlalchemy.ext.asyncio, sqlalchemy.pool, src.config, sqlalchemy
- logging_config.py | Configures structured JSON logging with correlation ID injection, request/response logging middleware, and exception handling for a FastAPI application. | exp: class:CorrelationIdFilter, method:filter(self, record: logging.LogRecord) → bool, call:get_correlation_id, class:JSONFormatter, method:format(self, record: logging.LogRecord) → str, call:self.formatTime, call:record.getMessage, call:getattr, call:self.formatException, call:json.dumps, method:formatTime(self, record: logging.LogRecord, datefmt) → str, call:time.strftime, call:time.gmtime, class:RequestLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:time.time, call:logger.info, call:call_next, call:int, call:logger.error, call:type, call:traceback.format_exc, class:ExceptionLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:call_next, call:logger.critical, call:traceback.format_exc, func:configure_logging(level) → None, call:JSONFormatter, call:logging.StreamHandler, call:console_handler.setFormatter, call:console_handler.addFilter, call:CorrelationIdFilter, call:root_logger.setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("sqlalchemy.engine").setLevel, call:logger.info, call:logging.getLevelName | dep: json, logging, sys, time, traceback, collections.abc, fastapi, starlette.middleware.base, src.services.shared.correlation, starlette
- main.py | Bootstraps a FastAPI application called "Headquarter API" with database initialization, health monitoring, CORS, logging middleware, and modular API routers. | exp: func:_sanitize_validation_errors(errors), call:error.get, call:str, call:ctx.items, call:isinstance, call:type, call:sanitized.append, func:validation_exception_handler(request: Request, exc: RequestValidationError), call:exc.errors, call:logger.warning, call:_sanitize_validation_errors, call:JSONResponse, func:on_startup(), call:logger.info, call:init_database, call:logger.error, call:sys.exit, call:_health_monitor.start, call:seed_builtin_tool_types, func:on_shutdown(), call:logger.info, call:_health_monitor.stop | dep: logging, os, fastapi, fastapi.exceptions, fastapi.middleware.cors, fastapi.responses, fastapi.staticfiles, src.api.config, src.api.project, src.api.system, src.api.tool, src.api.user, src.api.workspace, src.config, src.models, src.database, src.logging_config, src.seeds.builtin_tool_types, src.services.instance, src.services.shared, sys, src.api
- config.py | Defines application configuration settings with environment-based overrides using Pydantic, including database URLs, domain/public URL resolution, Authentik OAuth integration, session/JWT settings, and cookie security policies. | exp: class:Settings, func:build_database_url(user: str, password: str, host: str, port: int, database: str) → str | dep: pydantic, pydantic_settings
- database.py | Configures an async SQLAlchemy database engine with session management and provides retry logic for database initialization via Alembic migrations. | exp: func:init_database(max_retries, retry_delay) → bool, call:range, call:engine.connect, call:test_conn.execute, call:text, call:test_conn.close, call:logger.info, call:asyncio.get_event_loop().run_in_executor, call:subprocess.run, call:os.path.dirname, call:os.path.abspath, call:logger.debug, call:logger.error, call:asyncio.sleep, call:str(exc).lower, call:logger.warning | dep: asyncio, logging, os, subprocess, sqlalchemy.ext.asyncio, sqlalchemy.pool, src.config, sqlalchemy
- logging_config.py | Configures structured JSON logging with correlation IDs and HTTP request/exception middleware for a FastAPI application. | exp: class:CorrelationIdFilter, method:filter(self, record: logging.LogRecord) → bool, call:get_correlation_id, class:JSONFormatter, method:format(self, record: logging.LogRecord) → str, call:self.formatTime, call:record.getMessage, call:getattr, call:self.formatException, call:json.dumps, method:formatTime(self, record: logging.LogRecord, datefmt) → str, call:time.strftime, call:time.gmtime, class:RequestLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:time.time, call:logger.info, call:call_next, call:int, call:logger.error, call:type, call:traceback.format_exc, class:ExceptionLoggingMiddleware, method:dispatch(self, request: Request, call_next: Callable) → Response, call:call_next, call:logger.critical, call:traceback.format_exc, func:configure_logging(level) → None, call:JSONFormatter, call:logging.StreamHandler, call:console_handler.setFormatter, call:console_handler.addFilter, call:CorrelationIdFilter, call:root_logger.setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("sqlalchemy.engine").setLevel, call:logger.info, call:logging.getLevelName | dep: json, logging, sys, time, traceback, collections.abc, fastapi, starlette.middleware.base, src.services.shared.correlation, starlette
- main.py | Bootstraps a FastAPI application for "Headquarter API" with CORS, logging, validation error handling, database initialization, health monitoring, and registration of modular API routers. | exp: func:_sanitize_validation_errors(errors), call:error.get, call:str, call:ctx.items, call:isinstance, call:type, call:sanitized.append, func:validation_exception_handler(request: Request, exc: RequestValidationError), call:exc.errors, call:logger.warning, call:_sanitize_validation_errors, call:JSONResponse, func:on_startup(), call:logger.info, call:init_database, call:logger.error, call:sys.exit, call:_health_monitor.start, call:seed_builtin_tool_types, func:on_shutdown(), call:logger.info, call:_health_monitor.stop | dep: logging, os, fastapi, fastapi.exceptions, fastapi.middleware.cors, fastapi.responses, fastapi.staticfiles, src.api.config, src.api.project, src.api.system, src.api.tool, src.api.user, src.api.workspace, src.config, src.models, src.database, src.logging_config, src.seeds.builtin_tool_types, src.services.instance, src.services.shared, sys, src.api.*
## arch
Layered architecture with separation of concerns across config (settings/env), database (async SQLAlchemy with Alembic migrations), logging (structured JSON with middleware), and main (app composition with modular routers and health monitoring).
Layered infrastructure pattern with Pydantic-based settings management, async SQLAlchemy with Alembic migrations, structured JSON logging with correlation ID tracking, and modular FastAPI router registration with middleware composition.
## tags
src, logging, database, api, call:logger.info, middleware, fastapi, filter
src, database, logging, api, call:logger.info, fastapi, filter, call:logging.get
## symbols
- Settings
- CorrelationIdFilter
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/api
## role
Provides shared Pydantic validators and package initialization for API schema validation across the API layer.
Defines reusable Pydantic validation utilities for API schema fields used across API endpoints.
## parent
index: apps/api/src/.pi-map.index.md
map: apps/api/src/.pi-map.md
+4 -4
View File
@@ -4,14 +4,14 @@ dir: apps/api/src/api
index: apps/api/src/api/.pi-map.index.md
## role
Provides shared Pydantic validators and package initialization for API schema validation across the API layer.
Defines reusable Pydantic validation utilities for API schema fields used across API endpoints.
## files
- __init__.py | Marks the directory as a Python package for API routers.
- shared_validators.py | Provides reusable Pydantic validators for API schema fields including mount paths, file dictionaries, environment variables, and volume mounts. | exp: func:validate_mount_path(v: str | None) → str | None, call:v.startswith, raise:ValueError, func:validate_files(v: dict | None, max_size_bytes) → dict | None, call:v.items, call:path.startswith, call:len, call:content.encode, raise:ValueError, func:validate_env_vars(v: dict | None) → dict | None, call:isinstance, raise:ValueError, func:validate_volumes(v: list | None) → list | None, call:isinstance, call:enumerate, raise:ValueError
- shared_validators.py | Provides reusable Pydantic validation functions for API schema fields including mount paths, file uploads, environment variables, and volume mounts. | exp: func:validate_mount_path(v: str | None) → str | None, call:v.startswith, raise:ValueError, func:validate_files(v: dict | None, max_size_bytes) → dict | None, call:v.items, call:path.startswith, call:len, call:content.encode, raise:ValueError, func:validate_env_vars(v: dict | None) → dict | None, call:isinstance, raise:ValueError, func:validate_volumes(v: list | None) → list | None, call:isinstance, call:enumerate, raise:ValueError
## arch
Utility module pattern with reusable cross-cutting Pydantic validators for common API field types (mount paths, files, env vars, volumes).
Utility module pattern with shared validation functions for common Docker/container resource types (mounts, volumes, files, environment variables).
## tags
validate, raise:value, error, call:isinstance, validators, mount, api, init
validate, raise:value, error, call:isinstance, mount, api, init, path
## symbols
- validate_mount_path
- validate_files
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/api/system
## role
Provides system-level API endpoints for monitoring, administration, and operational infrastructure including health checks, dashboards, real-time events, notifications, and container terminal access.
Provides system-level infrastructure endpoints for monitoring, real-time communication, and instance management in the API service.
## parent
index: apps/api/src/api/.pi-map.index.md
map: apps/api/src/api/.pi-map.md
+10 -10
View File
@@ -4,19 +4,19 @@ dir: apps/api/src/api/system
index: apps/api/src/api/system/.pi-map.index.md
## role
Provides system-level API endpoints for monitoring, administration, and operational infrastructure including health checks, dashboards, real-time events, notifications, and container terminal access.
Provides system-level infrastructure endpoints for monitoring, real-time communication, and instance management in the API service.
## files
- __init__.py | Aggregates and re-exports system API routers from submodules for centralized access. | dep: src.api.system.dashboard, src.api.system.events, src.api.system.health, src.api.system.instance_proxy, src.api.system.notifications, src.api.system.terminal
- dashboard.py | Provides a FastAPI endpoint that returns a dashboard summary with counts of projects, repositories, SSH keys, and recent activity for the authenticated user. | exp: func:get_dashboard_summary(user_id, session) → dict, call:session.execute, call:select(func.count()).select_from(Project).where, call:func.count, call:projects_result.scalar, call:select(func.count()).select_from(GitRepository).where, call:repos_result.scalar, call:select(func.count()).select_from(SSHKey).where, call:ssh_keys_result.scalar, call:select(Project) .where(Project.owner_id == user_id) .order_by(Project.created_at.desc()) .limit, call:Project.created_at.desc, call:recent_projects.scalars().all | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.models.project
- events.py | Implements an SSE streaming endpoint that broadcasts instance events to authenticated users with per-user connection limits and backpressure handling. | exp: func:events_stream(request: Request, user_id) → StreamingResponse, call:_connection_counts.get, call:InstanceEventBus, call:asyncio.Queue, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait, call:event_bus.subscribe, call:asyncio.wait_for, call:queue.get, call:json.dumps, call:unsubscribe, call:max, call:_connection_counts.pop, call:StreamingResponse, call:event_generator, raise:HTTPException, func:event_generator() → AsyncGenerator[str, None], call:InstanceEventBus, call:asyncio.Queue, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait, call:event_bus.subscribe, call:asyncio.wait_for, call:queue.get, call:json.dumps, call:unsubscribe, call:max, call:_connection_counts.get, call:_connection_counts.pop, func:on_event(payload: InstanceEventPayload) → None, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait | dep: asyncio, contextlib, json, uuid, collections.abc, fastapi, fastapi.responses, src.auth.dependencies, src.services.instance.event_bus
- health.py | Provides FastAPI health check endpoints that monitor database connectivity, disk space, and system uptime with performance timing. | exp: func:health_check() → dict[str, Any], call:HealthChecks, call:time_module.perf_counter, call:SessionLocal, call:session.execute, call:text, call:DatabaseHealth, call:round, call:shutil.disk_usage, call:DiskHealth, call:HealthResponse( status=overall_status, timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), version="0.1.0", checks=checks, uptime_seconds=round(time.time() - _start_time, 2), ).model_dump, call:datetime.now(timezone.utc).isoformat().replace, call:time.time, func:health_check_db() → dict[str, Any], call:time_module.perf_counter, call:SessionLocal, call:session.execute, call:text, call:DatabaseHealthResponse( status="healthy", response_time_ms=round(db_time, 2), ).model_dump, call:round, call:DatabaseHealthResponse( status="unhealthy", response_time_ms=0.0, ).model_dump | dep: time, datetime, typing, fastapi, sqlalchemy, src.database, src.schemas.system, shutil
- instance_proxy.py | HTTP proxy router that forwards incoming requests to running containerized tool instances after verifying ownership and instance status. | exp: func:_proxy_request(request: Request, instance_id: uuid.UUID, path: str, user_id: uuid.UUID, session: AsyncSession) → Response, call:session.get, call:str, call:request.headers.items, call:key.lower, call:httpx.AsyncClient, call:request.body, call:client.request, call:logger.error, call:dict, call:response_headers.pop, call:Response, raise:HTTPException, func:proxy_to_instance(request: Request, instance_id: uuid.UUID, path, user_id, session) → Response, call:_proxy_request | dep: logging, uuid, httpx, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models
- notifications.py | Provides FastAPI REST endpoints for managing user notifications including listing, marking read/unread, dismissing, and clearing all notifications with support for muted categories. | exp: class:NotificationItem, class:NotificationListResponse, class:UnreadCountResponse, class:MarkAllReadResponse, class:ClearAllResponse, func:_get_mute_categories(session: AsyncSession, user_id: uuid.UUID) → list[str], call:session.execute, call:select(UserConfig).where, call:result.scalar_one_or_none, call:config.config.get, call:isinstance, func:list_notifications(limit, offset, unread_only, user, session) → NotificationListResponse, call:_get_mute_categories, call:notification_service.list_notifications, call:NotificationListResponse, call:NotificationItem.model_validate, func:get_unread_count(user, session) → UnreadCountResponse, call:notification_service.get_unread_count, call:UnreadCountResponse, func:mark_notification_read(notification_id: uuid.UUID, user, session) → NotificationItem, call:notification_service.mark_read, call:NotificationItem.model_validate, raise:HTTPException, func:mark_all_read(user, session) → MarkAllReadResponse, call:notification_service.mark_all_read, call:MarkAllReadResponse, func:clear_all_notifications(user, session) → ClearAllResponse, call:notification_service.dismiss_all, call:ClearAllResponse, func:dismiss_notification(notification_id: uuid.UUID, user, session) → None, call:notification_service.dismiss, raise:HTTPException | dep: uuid, datetime, fastapi, pydantic, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models.user, src.models, src.services.shared.notification_service, sqlalchemy
- terminal.py | Provides WebSocket and REST endpoints for browser-based terminal access to running Docker container tool instances, supporting multiple named sessions, authentication, and terminal reset/reconnect functionality. | exp: class:SessionRef, method:__init__(self, session, slot_session_id), func:terminal_websocket_default(websocket: WebSocket, instance_id: str, db_session) → None, call:_handle_terminal_websocket, func:terminal_websocket_specific(websocket: WebSocket, instance_id: str, session_id: str, db_session) → None, call:_handle_terminal_websocket, func:_handle_terminal_websocket(websocket: WebSocket, instance_id: str, target_session_id: str | None, db_session: AsyncSession) → None, call:logger.debug, call:websocket.accept, call:uuid.UUID, call:logger.error, call:websocket.close, call:_get_user_from_websocket, call:logger.warning, call:db_session.get, call:get_container_status, call:terminal_manager.get_or_create_session, call:terminal_manager.get_session, call:logger.info, call:terminal_manager.create_session, call:terminal_manager._find_key_by_internal_id, call:terminal_manager.attach_websocket, call:websocket.send_json, call:SessionRef, call:asyncio.create_task, call:_write_loop, call:_heartbeat_loop, call:asyncio.wait, call:len, call:task.cancel, call:str, call:suppress, call:terminal_manager.detach_websocket, func:_write_loop(session_ref: SessionRef, websocket, instance_id: str) → None, call:session.is_alive, call:asyncio.sleep, call:websocket.receive, call:session.write_input, call:text.startswith, call:json.loads, call:ctrl.get, call:logger.debug, call:session.resize, call:session.acknowledge_data, call:websocket.send_json, call:terminal_manager.reset_session, call:terminal_manager.attach_websocket, call:text.encode, func:_heartbeat_loop(websocket: WebSocket) → None, call:asyncio.sleep, call:websocket.send_json, func:_get_terminal_instance(instance_id: uuid.UUID, user_id: uuid.UUID, db_session: AsyncSession) → ToolInstance, call:db_session.get, raise:HTTPException, func:list_terminal_sessions(instance_id: uuid.UUID, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.execute, call:select(TerminalSessionModel) .where(TerminalSessionModel.instance_id == instance_id) .where(TerminalSessionModel.status != "closed") .order_by, call:TerminalSessionModel.created_at.asc, call:result.scalars().all, call:terminal_manager.get_session, call:str, call:sessions.append, call:live_session.has_websockets, call:row.created_at.isoformat, call:row.last_activity_at.isoformat, func:create_terminal_session(instance_id: uuid.UUID, data: dict, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.get, call:data.get, call:terminal_manager.create_session, raise:HTTPException, func:close_terminal_session(instance_id: uuid.UUID, session_id: str, user_id, db_session) → dict, call:_get_terminal_instance, call:terminal_manager._find_key_by_internal_id, call:str, call:terminal_manager.get_session, call:terminal_manager.close_session, raise:HTTPException, func:reset_specific_terminal_session(instance_id: uuid.UUID, session_id: str, user_id, db_session) → dict, call:_get_terminal_instance, call:terminal_manager._find_key_by_internal_id, call:str, call:terminal_manager.get_session, call:db_session.get, call:terminal_manager.reset_session, raise:HTTPException, func:rename_terminal_session(instance_id: uuid.UUID, session_id: str, data: dict, user_id, db_session) → dict, call:_get_terminal_instance, call:data.get, call:isinstance, call:terminal_manager.get_session, call:str, call:db_session.get, call:uuid.UUID, call:db_session.commit, raise:HTTPException, func:reset_terminal_session(instance_id: uuid.UUID, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.get, call:terminal_manager.reset_session, call:logger.info, call:str, call:logger.error, raise:HTTPException, func:_get_user_from_websocket(websocket: WebSocket, db_session: AsyncSession) → uuid.UUID | None, call:websocket.cookies.get, call:Settings, call:decode_session_cookie, call:uuid.UUID, call:str | dep: asyncio, json, logging, uuid, contextlib, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, starlette.websockets, src.auth.dependencies, src.models, src.services.terminal.terminal_manager, src.services.docker, src.auth.session, src.config, starlette
- __init__.py | Aggregates and re-exports system API router modules for centralized access | dep: src.api.system.dashboard, src.api.system.events, src.api.system.health, src.api.system.instance_proxy, src.api.system.notifications, src.api.system.terminal
- dashboard.py | Provides a FastAPI endpoint that returns a dashboard summary with aggregated counts and recent activity for the authenticated user. | exp: func:get_dashboard_summary(user_id, session) → dict, call:session.execute, call:select(func.count()).select_from(Project).where, call:func.count, call:projects_result.scalar, call:select(func.count()).select_from(GitRepository).where, call:repos_result.scalar, call:select(func.count()).select_from(SSHKey).where, call:ssh_keys_result.scalar, call:select(Project) .where(Project.owner_id == user_id) .order_by(Project.created_at.desc()) .limit, call:Project.created_at.desc, call:recent_projects.scalars().all | dep: uuid, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models, src.models.project
- events.py | Implements an SSE streaming endpoint that delivers instance events to authenticated users with per-user connection limiting and automatic heartbeat pings. | exp: func:events_stream(request: Request, user_id) → StreamingResponse, call:_connection_counts.get, call:InstanceEventBus, call:asyncio.Queue, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait, call:event_bus.subscribe, call:asyncio.wait_for, call:queue.get, call:json.dumps, call:unsubscribe, call:max, call:_connection_counts.pop, call:StreamingResponse, call:event_generator, raise:HTTPException, func:event_generator() → AsyncGenerator[str, None], call:InstanceEventBus, call:asyncio.Queue, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait, call:event_bus.subscribe, call:asyncio.wait_for, call:queue.get, call:json.dumps, call:unsubscribe, call:max, call:_connection_counts.get, call:_connection_counts.pop, func:on_event(payload: InstanceEventPayload) → None, call:queue.put_nowait, call:contextlib.suppress, call:queue.get_nowait | dep: asyncio, contextlib, json, uuid, collections.abc, fastapi, fastapi.responses, src.auth.dependencies, src.services.instance.event_bus
- health.py | Provides FastAPI health check endpoints that monitor system health including database connectivity/response time and disk usage, returning structured health status responses. | exp: func:health_check() → dict[str, Any], call:HealthChecks, call:time_module.perf_counter, call:SessionLocal, call:session.execute, call:text, call:DatabaseHealth, call:round, call:shutil.disk_usage, call:DiskHealth, call:HealthResponse( status=overall_status, timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), version="0.1.0", checks=checks, uptime_seconds=round(time.time() - _start_time, 2), ).model_dump, call:datetime.now(timezone.utc).isoformat().replace, call:time.time, func:health_check_db() → dict[str, Any], call:time_module.perf_counter, call:SessionLocal, call:session.execute, call:text, call:DatabaseHealthResponse( status="healthy", response_time_ms=round(db_time, 2), ).model_dump, call:round, call:DatabaseHealthResponse( status="unhealthy", response_time_ms=0.0, ).model_dump | dep: time, datetime, typing, fastapi, sqlalchemy, src.database, src.schemas.system, shutil
- instance_proxy.py | HTTP request proxy router that forwards requests from authenticated users to their running containerized tool instances. | exp: func:_proxy_request(request: Request, instance_id: uuid.UUID, path: str, user_id: uuid.UUID, session: AsyncSession) → Response, call:session.get, call:str, call:request.headers.items, call:key.lower, call:httpx.AsyncClient, call:request.body, call:client.request, call:logger.error, call:dict, call:response_headers.pop, call:Response, raise:HTTPException, func:proxy_to_instance(request: Request, instance_id: uuid.UUID, path, user_id, session) → Response, call:_proxy_request | dep: logging, uuid, httpx, fastapi, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models
- notifications.py | Defines FastAPI REST endpoints for managing user notifications (list, unread count, mark read, dismiss, clear all) with support for muted categories. | exp: class:NotificationItem, class:NotificationListResponse, class:UnreadCountResponse, class:MarkAllReadResponse, class:ClearAllResponse, func:_get_mute_categories(session: AsyncSession, user_id: uuid.UUID) → list[str], call:session.execute, call:select(UserConfig).where, call:result.scalar_one_or_none, call:config.config.get, call:isinstance, func:list_notifications(limit, offset, unread_only, user, session) → NotificationListResponse, call:_get_mute_categories, call:notification_service.list_notifications, call:NotificationListResponse, call:NotificationItem.model_validate, func:get_unread_count(user, session) → UnreadCountResponse, call:notification_service.get_unread_count, call:UnreadCountResponse, func:mark_notification_read(notification_id: uuid.UUID, user, session) → NotificationItem, call:notification_service.mark_read, call:NotificationItem.model_validate, raise:HTTPException, func:mark_all_read(user, session) → MarkAllReadResponse, call:notification_service.mark_all_read, call:MarkAllReadResponse, func:clear_all_notifications(user, session) → ClearAllResponse, call:notification_service.dismiss_all, call:ClearAllResponse, func:dismiss_notification(notification_id: uuid.UUID, user, session) → None, call:notification_service.dismiss, raise:HTTPException | dep: uuid, datetime, fastapi, pydantic, sqlalchemy.ext.asyncio, src.auth.dependencies, src.models.user, src.models, src.services.shared.notification_service, sqlalchemy, src.models.UserConfig
- terminal.py | Provides WebSocket endpoints for browser-based terminal access to running tool instances, handling session lifecycle, authentication, input/output streaming, and terminal resize/reset operations. | exp: class:SessionRef, method:__init__(self, session, slot_session_id), func:terminal_websocket_default(websocket: WebSocket, instance_id: str, db_session) → None, call:_handle_terminal_websocket, func:terminal_websocket_specific(websocket: WebSocket, instance_id: str, session_id: str, db_session) → None, call:_handle_terminal_websocket, func:_resolve_container_user(db_session: AsyncSession, instance: ToolInstance) → str | None, call:db_session.get, call:dict, call:resolve_base, call:deep_merge, call:get_manifest_container_user, func:_handle_terminal_websocket(websocket: WebSocket, instance_id: str, target_session_id: str | None, db_session: AsyncSession) → None, call:logger.debug, call:websocket.accept, call:uuid.UUID, call:logger.error, call:websocket.close, call:_get_user_from_websocket, call:logger.warning, call:db_session.get, call:get_container_status, call:_resolve_container_user, call:terminal_manager.get_or_create_session, call:terminal_manager.get_session, call:logger.info, call:terminal_manager.create_session, call:terminal_manager._find_key_by_internal_id, call:terminal_manager.attach_websocket, call:websocket.send_json, call:SessionRef, call:asyncio.create_task, call:_write_loop, call:_heartbeat_loop, call:asyncio.wait, call:len, call:task.cancel, call:str, call:suppress, call:terminal_manager.detach_websocket, func:_write_loop(session_ref: SessionRef, websocket, instance_id: str) → None, call:session.is_alive, call:asyncio.sleep, call:websocket.receive, call:session.write_input, call:text.startswith, call:json.loads, call:ctrl.get, call:logger.debug, call:session.resize, call:session.acknowledge_data, call:websocket.send_json, call:terminal_manager.reset_session, call:terminal_manager.attach_websocket, call:text.encode, func:_heartbeat_loop(websocket: WebSocket) → None, call:asyncio.sleep, call:websocket.send_json, func:_get_terminal_instance(instance_id: uuid.UUID, user_id: uuid.UUID, db_session: AsyncSession) → ToolInstance, call:db_session.get, raise:HTTPException, func:list_terminal_sessions(instance_id: uuid.UUID, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.execute, call:select(TerminalSessionModel) .where(TerminalSessionModel.instance_id == instance_id) .where(TerminalSessionModel.status != "closed") .order_by, call:TerminalSessionModel.created_at.asc, call:result.scalars().all, call:terminal_manager.get_session, call:str, call:sessions.append, call:live_session.has_websockets, call:row.created_at.isoformat, call:row.last_activity_at.isoformat, func:create_terminal_session(instance_id: uuid.UUID, data: dict, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.get, call:data.get, call:_resolve_container_user, call:terminal_manager.create_session, raise:HTTPException, func:close_terminal_session(instance_id: uuid.UUID, session_id: str, user_id, db_session) → dict, call:_get_terminal_instance, call:terminal_manager._find_key_by_internal_id, call:str, call:terminal_manager.get_session, call:terminal_manager.close_session, raise:HTTPException, func:reset_specific_terminal_session(instance_id: uuid.UUID, session_id: str, user_id, db_session) → dict, call:_get_terminal_instance, call:terminal_manager._find_key_by_internal_id, call:str, call:terminal_manager.get_session, call:db_session.get, call:_resolve_container_user, call:terminal_manager.reset_session, raise:HTTPException, func:rename_terminal_session(instance_id: uuid.UUID, session_id: str, data: dict, user_id, db_session) → dict, call:_get_terminal_instance, call:data.get, call:isinstance, call:terminal_manager.get_session, call:str, call:db_session.get, call:uuid.UUID, call:db_session.commit, raise:HTTPException, func:reset_terminal_session(instance_id: uuid.UUID, user_id, db_session) → dict, call:_get_terminal_instance, call:db_session.get, call:_resolve_container_user, call:terminal_manager.reset_session, call:logger.info, call:str, call:logger.error, raise:HTTPException, func:_get_user_from_websocket(websocket: WebSocket, db_session: AsyncSession) → uuid.UUID | None, call:websocket.cookies.get, call:Settings, call:decode_session_cookie, call:uuid.UUID, call:str | dep: asyncio, json, logging, uuid, contextlib, fastapi, sqlalchemy, sqlalchemy.ext.asyncio, starlette.websockets, src.auth.dependencies, src.models, src.services.build.manifest_compiler, src.services.terminal.terminal_manager, src.services.docker, src.auth.session, src.config, starlette
## arch
FastAPI router composition pattern with modular sub-routers aggregated via __init__.py, combining synchronous REST endpoints, SSE streaming, and WebSocket connections for real-time features, with authentication enforcement and resource ownership validation across operational and infrastructure concerns.
Modular FastAPI router composition with separate modules for distinct protocols (REST/SSE/WebSocket/proxy), each handling authentication, connection lifecycle management, and resource-specific business logic.
## tags
terminal, session, call:terminal, call:, get, src, response, instance
terminal, call:, session, call:terminal, src, get, response, websocket
## symbols
- NotificationItem
- NotificationListResponse
+57 -2
View File
@@ -13,9 +13,18 @@ from starlette.websockets import WebSocketDisconnect
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import TerminalSessionModel
from src.models import ToolDefinitionManifest
from src.models import ToolInstance
from src.models import ToolType
from src.services.terminal.terminal_manager import MaxSessionsExceededError, terminal_manager
from src.services.build.manifest_compiler import (
deep_merge,
get_manifest_container_user,
resolve_base,
)
from src.services.terminal.terminal_manager import (
MaxSessionsExceededError,
terminal_manager,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -57,6 +66,35 @@ async def terminal_websocket_specific(
await _handle_terminal_websocket(websocket, instance_id, session_id, db_session)
async def _resolve_container_user(
db_session: AsyncSession,
instance: ToolInstance,
) -> str | None:
"""Resolve the container user for docker exec from the tool manifest.
For manifest-based tools, the user declared in the manifest (or its base
definition) is returned so terminal sessions run with the same privileges
as the main container process. Legacy tools return None, preserving the
previous behavior.
"""
tool_type = await db_session.get(ToolType, instance.tool_type_id)
if not tool_type or tool_type.definition_type != "manifest":
return None
if not tool_type.manifest_id or not tool_type.manifest:
return None
manifest = dict(tool_type.manifest.manifest)
if tool_type.manifest.base_definition_id:
base_def = await db_session.get(
ToolDefinitionManifest, tool_type.manifest.base_definition_id
)
if base_def:
manifest = resolve_base(deep_merge(dict(base_def.manifest), manifest))
return get_manifest_container_user(manifest)
async def _handle_terminal_websocket(
websocket: WebSocket,
instance_id: str,
@@ -139,7 +177,7 @@ async def _handle_terminal_websocket(
)
return
# Fetch tool type to get startup_command
# Fetch tool type to get startup_command and container_user
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
if startup_command:
@@ -149,6 +187,14 @@ async def _handle_terminal_websocket(
startup_command,
)
container_user = await _resolve_container_user(db_session, instance)
if container_user:
logger.debug(
"Terminal sessions for instance %s will run as user %s",
instance_id,
container_user,
)
session = None
# Get or create terminal session
@@ -159,6 +205,7 @@ async def _handle_terminal_websocket(
instance_uuid,
instance.container_id,
startup_command=startup_command,
container_user=container_user,
)
slot_session_id = "default"
else:
@@ -189,6 +236,7 @@ async def _handle_terminal_websocket(
startup_command=startup_command,
name=db_row.name,
session_id=target_session_id,
container_user=container_user,
)
else:
logger.warning(
@@ -318,6 +366,7 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
startup_command=session.startup_command,
session_id=session_ref.slot_session_id,
name=session.name,
container_user=session.container_user,
)
# Update the mutable session reference
@@ -486,6 +535,7 @@ async def create_terminal_session(
startup_command = tool_type.startup_command if tool_type else None
name = data.get("name")
container_user = await _resolve_container_user(db_session, instance)
try:
session = await terminal_manager.create_session(
@@ -493,6 +543,7 @@ async def create_terminal_session(
instance.container_id,
startup_command=startup_command,
name=name,
container_user=container_user,
)
except MaxSessionsExceededError:
raise HTTPException(
@@ -595,6 +646,7 @@ async def reset_specific_terminal_session(
# Preserve name if possible
live_session = terminal_manager.get_session(str(instance_id), session_id)
name = live_session.name if live_session else None
container_user = await _resolve_container_user(db_session, instance)
new_session = await terminal_manager.reset_session(
instance_id,
@@ -602,6 +654,7 @@ async def reset_specific_terminal_session(
startup_command=startup_command,
session_id=key[1],
name=name,
container_user=container_user,
)
return {
@@ -687,6 +740,7 @@ async def reset_terminal_session(
# Fetch tool type to get startup_command
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
container_user = await _resolve_container_user(db_session, instance)
try:
# Reset the default session
@@ -694,6 +748,7 @@ async def reset_terminal_session(
instance_id,
instance.container_id,
startup_command=startup_command,
container_user=container_user,
)
logger.info(
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/api/src/services
## role
Provides utility services for the API application, including tmux window management functionality.
Provides a command to swap the position of two tmux panes within a window or between windows
## parent
index: apps/api/src/.pi-map.index.md
map: apps/api/src/.pi-map.md
@@ -408,7 +408,6 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
Docker Compose YAML content.
"""
runtime = manifest.get("runtime", {})
user = manifest.get("user")
interface_type = manifest["interface_type"]
home_dir = get_manifest_home_dir(manifest)
@@ -436,12 +435,11 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
else:
service["working_dir"] = f"{home_dir}/{workspace_name}"
# The entrypoint starts as root (Dockerfile does not set USER) so it can
# fix mount ownership. It drops privileges to the container user internally
# before exec-ing the real command, so do not set compose-level user
# override here.
if user:
service["user"] = "0:0"
# The Dockerfile does not set USER so the entrypoint starts as root,
# fixes mount ownership, and drops privileges to the container user
# internally. Do not set a compose-level user override: that would pin
# the container metadata to root and make docker exec sessions run as
# root even after the entrypoint drops privileges.
# Ports for web tools
default_port = manifest.get("default_port")
@@ -568,6 +566,35 @@ def get_manifest_home_dir(manifest: dict) -> str:
return "/root"
def get_manifest_container_user(manifest: dict) -> str | None:
"""Resolve the container user identifier from a manifest.
Returns the user name when available so that docker exec sessions can
attach as the container user instead of defaulting to root. Falls back
to ``uid:gid`` when a name is absent but numeric ids are present.
Args:
manifest: Fully resolved manifest JSON.
Returns:
User name (e.g. ``user``), ``uid:gid`` string, or None when the
manifest does not declare a user.
"""
user = manifest.get("user")
if not user:
return None
name = user.get("name")
if name:
return name
uid = user.get("uid")
gid = user.get("gid")
if uid is not None and gid is not None:
return f"{uid}:{gid}"
return None
def compute_image_tag(tool_name: str, manifest: dict) -> str:
"""Compute a deterministic image tag from manifest content.
@@ -2,7 +2,7 @@
dir: apps/api/src/services/terminal
## role
Provides backend infrastructure for managing interactive WebSocket-based terminal sessions within Docker containers, including session lifecycle, PTY I/O handling, and resource cleanup.
Provides WebSocket-based terminal session management for containerized environments with PTY support, session persistence, and resource limits.
## parent
index: apps/api/src/services/.pi-map.index.md
map: apps/api/src/services/.pi-map.md
+5 -5
View File
@@ -4,13 +4,13 @@ dir: apps/api/src/services/terminal
index: apps/api/src/services/terminal/.pi-map.index.md
## role
Provides backend infrastructure for managing interactive WebSocket-based terminal sessions within Docker containers, including session lifecycle, PTY I/O handling, and resource cleanup.
Provides WebSocket-based terminal session management for containerized environments with PTY support, session persistence, and resource limits.
## files
- __init__.py | Package initialization file that exports the public API for the terminal services module | dep: src.services.terminal.terminal_manager, src.services.terminal.terminal_session
- terminal_manager.py | Manages active terminal sessions with WebSocket support, idle cleanup, database persistence, and per-instance session limits. | exp: class:MaxSessionsExceededError, method:__init__(self, instance_id: str, max_sessions) → None, call:super().__init__, class:TerminalManager, method:__init__(self) → None, call:self._start_idle_check, method:_start_idle_check(self) → None, call:self._idle_check_task.done, call:asyncio.get_running_loop, call:loop.create_task, call:self._idle_check_loop, method:_idle_check_loop(self) → None, call:asyncio.sleep, call:self._cleanup_idle_sessions, call:logger.error, method:_cleanup_idle_sessions(self) → None, call:list, call:self._sessions.items, call:session.is_idle, call:idle_keys.append, call:logger.info, call:self._sessions.pop, call:session.close, call:asyncio.create_task, call:self._mark_closed_in_db, method:_insert_db_session_row(self, session_id: str, instance_id: uuid.UUID, name: str) → None, call:SessionLocal, call:pg_insert(TerminalSessionModel) .values( id=uuid.UUID(session_id), instance_id=instance_id, name=name, status="active", created_at=datetime.now(timezone.utc), last_activity_at=datetime.now(timezone.utc), ) .on_conflict_do_nothing, call:uuid.UUID, call:datetime.now, call:db_session.execute, call:db_session.commit, call:logger.debug, call:logger.error, method:_mark_closed_in_db(self, session_id: str) → None, call:SessionLocal, call:db_session.get, call:uuid.UUID, call:datetime.now, call:db_session.commit, call:logger.debug, call:logger.error, method:_count_sessions_for_instance(self, instance_id_str: str) → int, call:sum, method:create_session(self, instance_id: uuid.UUID, container_id: str, startup_command, name, session_id) → TerminalSession, call:str, call:self._count_sessions_for_instance, call:uuid.uuid4, call:TerminalSession, call:session.start, call:asyncio.create_task, call:self._insert_db_session_row, call:logger.info, raise:MaxSessionsExceededError, method:get_or_create_session(self, instance_id: uuid.UUID, container_id: str, startup_command) → TerminalSession, call:self._start_idle_check, call:str, call:session.is_alive, call:logger.debug, call:session.close, call:logger.info, call:uuid.uuid4, call:TerminalSession, call:session.start, call:asyncio.create_task, call:self._insert_db_session_row, method:get_session(self, instance_id: str, session_id: str) → TerminalSession | None, call:self._sessions.get, call:self._sessions.items, method:_find_key_by_internal_id(self, instance_id: str, internal_session_id: str) → tuple[str, str] | None, call:self._sessions.items, method:get_sessions_for_instance(self, instance_id: str) → list[TerminalSession], call:self._sessions.items, method:close_session(self, instance_id: str, session_id: str) → None, call:self._sessions.pop, call:session.close, call:asyncio.create_task, call:self._mark_closed_in_db, call:logger.info, method:attach_websocket(self, session: TerminalSession, websocket: WebSocket) → None, call:session.has_websockets, call:logger.debug, call:list, call:ws.close, call:session._websockets.clear, call:session.attach_websocket, call:session.get_buffer, call:websocket.send_bytes, method:detach_websocket(self, session: TerminalSession, websocket: WebSocket) → None, call:session.detach_websocket, method:reset_session(self, instance_id: uuid.UUID, container_id: str, startup_command, session_id, name) → TerminalSession, call:str, call:logger.debug, call:self._sessions.pop, call:old_session.close, call:asyncio.create_task, call:self._mark_closed_in_db, call:uuid.uuid4, call:TerminalSession, call:new_session.start, call:self._insert_db_session_row, method:close_all(self) → None, call:list, call:self._sessions.values, call:self._sessions.clear, call:session.close, call:self._idle_check_task.done, call:self._idle_check_task.cancel | dep: asyncio, logging, uuid, datetime, fastapi, sqlalchemy.dialects.postgresql, src.database, src.models, src.services.terminal.terminal_session, fastapi.WebSocket
- terminal_session.py | Manages a high-performance terminal session using asyncio-native event-driven I/O with PTY for docker exec processes, featuring output batching, flow control, and WebSocket broadcasting. | exp: class:TerminalSession, method:__init__(self, session_id: str, instance_id: uuid.UUID, container_id: str, startup_command, name) → None, call:deque, call:set, call:time.time, call:self._generate_name, call:str, call:bytearray, call:asyncio.Lock, method:start(self, startup_command) → None, call:pty.openpty, call:self._set_terminal_size, call:logger.debug, call:asyncio.create_subprocess_exec, call:os.close, call:time.time, call:self._start_reading, method:_start_reading(self) → None, call:asyncio.get_event_loop, call:loop.add_reader, call:logger.debug, call:logger.error, method:_stop_reading(self) → None, call:asyncio.get_event_loop, call:loop.remove_reader, method:_on_fd_readable(self) → None, call:os.read, call:logger.debug, call:self._handle_eof, call:self._add_to_buffer, call:time.time, call:self._queue_output, method:_add_to_buffer(self, data: bytes) → None, call:self._output_buffer.append, call:len, call:self._output_buffer.popleft, method:_queue_output(self, data: bytes) → None, call:self._batch_buffer.extend, call:len, call:self._pause_output, call:asyncio.get_event_loop, call:loop.call_later, method:_flush_batch_sync(self) → None, call:self._batch_buffer.clear, call:bytes, call:set, call:list, call:asyncio.create_task, call:self._send_bytes, call:dead_sockets.add, method:_send_bytes(self, ws: Any, payload: bytes) → None, call:ws.send_bytes, call:self._websockets.discard, method:acknowledge_data(self, char_count: int) → None, call:max, call:self._resume_output, call:self._ack_timeout_handle.cancel, call:asyncio.get_event_loop, call:loop.call_later, method:_ack_timeout_fallback(self) → None, call:logger.warning, call:self._resume_output, method:_pause_output(self) → None, call:self._stop_reading, call:logger.debug, method:_resume_output(self) → None, call:self._start_reading, call:logger.debug, method:get_buffer(self) → bytes, call:b"".join, method:_handle_eof(self) → None, call:self._stop_reading, call:self.process._transport.close, call:set, call:self._websockets.clear, call:asyncio.create_task, call:ws.close, call:logger.info, method:write_input(self, data: bytes) → None, call:os.write, call:time.time, call:logger.debug, call:self._handle_eof, method:_set_terminal_size(self, cols: int, rows: int) → None, call:logger.warning, call:struct.pack, call:fcntl.ioctl, call:logger.debug, call:logger.error, method:resize(self, cols: int, rows: int) → None, call:logger.warning, call:logger.debug, call:self._set_terminal_size, call:os.kill, method:reset(self) → None, call:self.close, call:self._output_buffer.clear, call:self._websockets.clear, call:self._batch_buffer.clear, method:close(self) → None, call:self._stop_reading, call:self._batch_timer.cancel, call:self._ack_timeout_handle.cancel, call:os.close, call:self.process.kill, call:asyncio.wait_for, call:self.process.wait, method:is_alive(self) → bool, method:is_idle(self) → bool, call:time.time, method:attach_websocket(self, websocket: Any) → None, call:self._websockets.add, call:time.time, method:detach_websocket(self, websocket: Any) → None, call:self._websockets.discard, method:has_websockets(self) → bool, call:len, method:send_to_all(self, data: bytes) → None, call:set, call:ws.send_bytes, call:dead_sockets.add, call:self._websockets.discard, method:read_output(self) → bytes | dep: asyncio, logging, os, pty, signal, struct, fcntl, time, uuid, collections, typing, collections.deque, typing.Any
- __init__.py | Exports the public API for the terminal services module by re-exporting TerminalManager, TerminalSession, and MaxSessionsExceededError. | dep: src.services.terminal.terminal_manager, src.services.terminal.terminal_session
- terminal_manager.py | Manages WebSocket-based terminal sessions with persistence, idle cleanup, and per-instance session limits for containerized environments. | exp: class:MaxSessionsExceededError, method:__init__(self, instance_id: str, max_sessions) → None, call:super().__init__, class:TerminalManager, method:__init__(self) → None, call:self._start_idle_check, method:_start_idle_check(self) → None, call:self._idle_check_task.done, call:asyncio.get_running_loop, call:loop.create_task, call:self._idle_check_loop, method:_idle_check_loop(self) → None, call:asyncio.sleep, call:self._cleanup_idle_sessions, call:logger.error, method:_cleanup_idle_sessions(self) → None, call:list, call:self._sessions.items, call:session.is_idle, call:idle_keys.append, call:logger.info, call:self._sessions.get, call:idle_session.close, call:asyncio.create_task, call:self._mark_closed_in_db, method:_insert_db_session_row(self, session_id: str, instance_id: uuid.UUID, name: str) → None, call:SessionLocal, call:pg_insert(TerminalSessionModel) .values( id=uuid.UUID(session_id), instance_id=instance_id, name=name, status="active", created_at=datetime.now(timezone.utc), last_activity_at=datetime.now(timezone.utc), ) .on_conflict_do_nothing, call:uuid.UUID, call:datetime.now, call:db_session.execute, call:db_session.commit, call:logger.debug, call:logger.error, method:_mark_closed_in_db(self, session_id: str) → None, call:SessionLocal, call:db_session.get, call:uuid.UUID, call:datetime.now, call:db_session.commit, call:logger.debug, call:logger.error, method:_count_sessions_for_instance(self, instance_id_str: str) → int, call:sum, method:create_session(self, instance_id: uuid.UUID, container_id: str, startup_command, name, session_id, container_user) → TerminalSession, call:str, call:self._count_sessions_for_instance, call:uuid.uuid4, call:TerminalSession, call:session.start, call:asyncio.create_task, call:self._insert_db_session_row, call:logger.info, raise:MaxSessionsExceededError, method:get_or_create_session(self, instance_id: uuid.UUID, container_id: str, startup_command, container_user) → TerminalSession, call:self._start_idle_check, call:str, call:session.is_alive, call:logger.debug, call:session.close, call:logger.info, call:uuid.uuid4, call:TerminalSession, call:session.start, call:asyncio.create_task, call:self._insert_db_session_row, method:get_session(self, instance_id: str, session_id: str) → TerminalSession | None, call:self._sessions.get, call:self._sessions.items, method:_find_key_by_internal_id(self, instance_id: str, internal_session_id: str) → tuple[str, str] | None, call:self._sessions.items, method:get_sessions_for_instance(self, instance_id: str) → list[TerminalSession], call:self._sessions.items, method:close_session(self, instance_id: str, session_id: str) → None, call:self._sessions.pop, call:session.close, call:asyncio.create_task, call:self._mark_closed_in_db, call:logger.info, method:attach_websocket(self, session: TerminalSession, websocket: WebSocket) → None, call:session.has_websockets, call:logger.debug, call:list, call:ws.close, call:session._websockets.clear, call:session.attach_websocket, call:session.get_buffer, call:websocket.send_bytes, method:detach_websocket(self, session: TerminalSession, websocket: WebSocket) → None, call:session.detach_websocket, method:reset_session(self, instance_id: uuid.UUID, container_id: str, startup_command, session_id, name, container_user) → TerminalSession, call:str, call:logger.debug, call:self._sessions.pop, call:old_session.close, call:asyncio.create_task, call:self._mark_closed_in_db, call:uuid.uuid4, call:TerminalSession, call:new_session.start, call:self._insert_db_session_row, method:close_all(self) → None, call:list, call:self._sessions.values, call:self._sessions.clear, call:session.close, call:self._idle_check_task.done, call:self._idle_check_task.cancel | dep: asyncio, logging, uuid, datetime, fastapi, sqlalchemy.dialects.postgresql, src.database, src.models, src.services.terminal.terminal_session, fastapi.WebSocket
- terminal_session.py | Manages an asyncio-native terminal session using PTY with docker exec, featuring event-driven I/O, output batching, and flow control for WebSocket clients. | exp: class:TerminalSession, method:__init__(self, session_id: str, instance_id: uuid.UUID, container_id: str, startup_command, name, container_user) → None, call:deque, call:set, call:time.time, call:self._generate_name, call:str, call:bytearray, call:asyncio.Lock, method:start(self, startup_command) → None, call:exec_cmd.extend, call:pty.openpty, call:self._set_terminal_size, call:logger.debug, call:asyncio.create_subprocess_exec, call:os.close, call:time.time, call:self._start_reading, method:_start_reading(self) → None, call:asyncio.get_event_loop, call:loop.add_reader, call:logger.debug, call:logger.error, method:_stop_reading(self) → None, call:asyncio.get_event_loop, call:loop.remove_reader, method:_on_fd_readable(self) → None, call:os.read, call:logger.debug, call:self._handle_eof, call:self._add_to_buffer, call:time.time, call:self._queue_output, method:_add_to_buffer(self, data: bytes) → None, call:self._output_buffer.append, call:len, call:self._output_buffer.popleft, method:_queue_output(self, data: bytes) → None, call:self._batch_buffer.extend, call:len, call:self._pause_output, call:asyncio.get_event_loop, call:loop.call_later, method:_flush_batch_sync(self) → None, call:self._batch_buffer.clear, call:bytes, call:set, call:list, call:asyncio.create_task, call:self._send_bytes, call:dead_sockets.add, method:_send_bytes(self, ws: Any, payload: bytes) → None, call:ws.send_bytes, call:self._websockets.discard, method:acknowledge_data(self, char_count: int) → None, call:max, call:self._resume_output, call:self._ack_timeout_handle.cancel, call:asyncio.get_event_loop, call:loop.call_later, method:_ack_timeout_fallback(self) → None, call:logger.warning, call:self._resume_output, method:_pause_output(self) → None, call:self._stop_reading, call:logger.debug, method:_resume_output(self) → None, call:self._start_reading, call:logger.debug, method:get_buffer(self) → bytes, call:b"".join, method:_handle_eof(self) → None, call:self._stop_reading, call:self.process._transport.close, call:set, call:self._websockets.clear, call:asyncio.create_task, call:ws.close, call:logger.info, method:write_input(self, data: bytes) → None, call:os.write, call:time.time, call:logger.debug, call:self._handle_eof, method:_set_terminal_size(self, cols: int, rows: int) → None, call:logger.warning, call:struct.pack, call:fcntl.ioctl, call:logger.debug, call:logger.error, method:resize(self, cols: int, rows: int) → None, call:logger.warning, call:logger.debug, call:self._set_terminal_size, call:os.kill, method:reset(self) → None, call:self.close, call:self._output_buffer.clear, call:self._websockets.clear, call:self._batch_buffer.clear, method:close(self) → None, call:self._stop_reading, call:self._batch_timer.cancel, call:self._ack_timeout_handle.cancel, call:os.close, call:self.process.kill, call:asyncio.wait_for, call:self.process.wait, method:is_alive(self) → bool, method:is_idle(self) → bool, call:time.time, method:attach_websocket(self, websocket: Any) → None, call:self._websockets.add, call:time.time, method:detach_websocket(self, websocket: Any) → None, call:self._websockets.discard, method:has_websockets(self) → bool, call:len, method:send_to_all(self, data: bytes) → None, call:set, call:ws.send_bytes, call:dead_sockets.add, call:self._websockets.discard, method:read_output(self) → bytes | dep: asyncio, logging, os, pty, signal, struct, fcntl, time, uuid, collections, typing, collections.deque, typing.Any
## arch
Asyncio-native event-driven architecture with producer-consumer pattern for terminal output batching, WebSocket pub/sub broadcasting, session state machine with idle timeout and database persistence, and per-instance resource limits enforced by a centralized manager.
Asyncio-native event-driven architecture using PTY/docker exec with manager pattern (TerminalManager orchestrates TerminalSession instances), featuring output batching, flow control, idle cleanup, and per-instance session limiting.
## tags
call:self., session, call:logger.debug, idle, output, terminal, check, task
## symbols
@@ -72,9 +72,10 @@ class TerminalManager:
session_id,
instance_id,
)
session = self._sessions.pop(key, None)
if session:
await session.close()
idle_session = self._sessions.get(key)
if idle_session is not None:
del self._sessions[key]
await idle_session.close()
# Update DB status fire-and-forget
asyncio.create_task(self._mark_closed_in_db(session_id))
@@ -141,6 +142,7 @@ class TerminalManager:
startup_command: str | None = None,
name: str | None = None,
session_id: str | None = None,
container_user: str | None = None,
) -> TerminalSession:
"""Create a new terminal session for an instance.
@@ -152,6 +154,8 @@ class TerminalManager:
container_id: Docker container ID.
startup_command: Optional startup command to run.
name: Optional session name (auto-generated if omitted).
session_id: Optional explicit session UUID.
container_user: Optional container user for docker exec.
Returns:
The newly created TerminalSession.
@@ -177,6 +181,7 @@ class TerminalManager:
container_id=container_id,
startup_command=startup_command,
name=name,
container_user=container_user,
)
await session.start(startup_command=startup_command)
@@ -201,6 +206,7 @@ class TerminalManager:
instance_id: uuid.UUID,
container_id: str,
startup_command: str | None = None,
container_user: str | None = None,
) -> TerminalSession:
"""Get existing session or create a new one.
@@ -243,6 +249,7 @@ class TerminalManager:
container_id=container_id,
startup_command=startup_command,
name="Session 1",
container_user=container_user,
)
await session.start(startup_command=startup_command)
self._sessions[key] = session
@@ -358,6 +365,7 @@ class TerminalManager:
startup_command: str | None = None,
session_id: str | None = None,
name: str | None = None,
container_user: str | None = None,
) -> TerminalSession:
"""Reset a session by killing it and creating a new one.
@@ -367,6 +375,7 @@ class TerminalManager:
startup_command: Optional startup command.
session_id: Specific session to reset. If None, resets the default session.
name: Optional name to preserve for the new session.
container_user: Optional container user for docker exec.
Returns:
The newly created TerminalSession.
@@ -400,6 +409,7 @@ class TerminalManager:
container_id=container_id,
startup_command=startup_command,
name=old_name or ("Session 1" if target_session_id == "default" else None),
container_user=container_user,
)
await new_session.start(startup_command=startup_command)
self._sessions[key] = new_session
@@ -55,10 +55,12 @@ class TerminalSession:
container_id: str,
startup_command: str | None = None,
name: str | None = None,
container_user: str | None = None,
) -> None:
self.session_id = session_id
self.instance_id = instance_id
self.container_id = container_id
self.container_user = container_user
self.startup_command = startup_command
self.process: asyncio.subprocess.Process | None = None
self._closed = False
@@ -105,6 +107,19 @@ class TerminalSession:
async def start(self, startup_command: str | None = None) -> None:
"""Start the docker exec process with a shell using a PTY."""
# Build the docker exec command. When the tool manifest declares a
# non-root container user, run the shell as that user so terminal
# sessions match the privileges of the main container process.
exec_cmd = [
"docker",
"exec",
"-it",
"-e",
"TERM=xterm-256color",
]
if self.container_user:
exec_cmd.extend(["--user", self.container_user])
# Create a pseudo-terminal on the host
self._master_fd, slave_fd = pty.openpty()
@@ -131,16 +146,9 @@ class TerminalSession:
shell_cmd = "bash -il"
# Start docker exec with the slave fd as stdin/stdout/stderr
exec_cmd.extend([self.container_id, "bash", "-c", shell_cmd])
self.process = await asyncio.create_subprocess_exec(
"docker",
"exec",
"-it",
"-e",
"TERM=xterm-256color",
self.container_id,
"bash",
"-c",
shell_cmd,
*exec_cmd,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,