"""backend/api/terminal.py — WebSocket PTY terminal (S354 + S754-B + S755).""" import os, asyncio, pty, fcntl, struct, termios, json, shlex, time, logging from pathlib import Path from fastapi import APIRouter, WebSocket, WebSocketDisconnect router = APIRouter() _logger = logging.getLogger("terminal") # ── Startup script (S755) ───────────────────────────────────────────────────── # Scritto in /data/.bashrc_agente e sourciate da bash via --rcfile. # Configura venv Python + npm persistenti, Playwright, workspace, aliases, prompt. # Versione: aggiornare _STARTUP_VER ad ogni modifica per forzare riscrittura su /data/. _STARTUP_VER = 'S755-v1' _STARTUP_PATH = Path('/data/.bashrc_agente') _STARTUP_FALLBACK = Path('/tmp/.bashrc_agente') # Contenuto dello script — usa printf invece di echo -e per portabilità POSIX. _STARTUP_SCRIPT = f'''# agente-ai terminal setup — {_STARTUP_VER} # Sourcato da bash --rcfile (sessioni interattive tmux). # NON modificare manualmente: viene riscritto ad ogni deploy del backend. # ── 1. Python venv persistente (/data/venv) ────────────────────────────────── # --system-site-packages: eredita numpy, playwright, ecc. già installati nel container. # pip install → /data/venv/lib/python*/site-packages/ (persiste tra restart HF Space). _AGENTE_VENV="/data/venv" if [ -d "${{_AGENTE_VENV}}/bin" ]; then source "${{_AGENTE_VENV}}/bin/activate" else printf "\\033[33m⚙ Prima attivazione: creo venv Python in /data/venv...\\033[0m\\n" python3 -m venv "${{_AGENTE_VENV}}" --system-site-packages 2>&1 if [ -d "${{_AGENTE_VENV}}/bin" ]; then source "${{_AGENTE_VENV}}/bin/activate" printf "\\033[32m✅ Venv creato. pip install ora persiste tra restart.\\033[0m\\n" else printf "\\033[31m✗ Venv fallito — installa comunque (non persistente)\\033[0m\\n" fi fi # ── 2. Node.js global packages persistenti (/data/npm-global) ──────────────── # npm install -g X → /data/npm-global/lib/node_modules/ (persiste tra restart). export NPM_CONFIG_PREFIX="/data/npm-global" export PATH="/data/npm-global/bin:${{PATH}}" # ── 3. Playwright / Chromium ────────────────────────────────────────────────── # Chromium baked nel Docker image a /ms-playwright → già persistente tra restart. # /data/playwright-browsers per browser aggiuntivi installati dal terminale. export PLAYWRIGHT_BROWSERS_PATH="${{PLAYWRIGHT_BROWSERS_PATH:-/ms-playwright}}" # ── 4. Workspace di default ─────────────────────────────────────────────────── export AGENTE_WORKSPACE="/data/workspace" mkdir -p "${{AGENTE_WORKSPACE}}" 2>/dev/null || true # ── 5. Aliases utili ────────────────────────────────────────────────────────── alias ll='ls -lah --color=auto' alias la='ls -la --color=auto' alias l='ls --color=auto' alias py='python3' alias ..='cd ..' alias ...='cd ../..' alias serve='python3 -m http.server' alias clip='xclip -selection clipboard 2>/dev/null || pbcopy 2>/dev/null || true' # ── 6. Prompt colorato ──────────────────────────────────────────────────────── export PS1='\\[\\033[1;32m\\]\\u\\[\\033[0m\\]:\\[\\033[1;34m\\]\\w\\[\\033[0m\\]\\$ ' # ── 7. Welcome banner ───────────────────────────────────────────────────────── _py_ver=$(python3 --version 2>&1 | cut -d' ' -f2 || printf '?') _node_ver=$(node --version 2>/dev/null || printf 'n/a') printf "\\033[1;35m⚡ agente-ai\\033[0m" printf " | py \\033[36m${{_py_ver}}\\033[0m" printf " | node \\033[36m${{_node_ver}}\\033[0m" printf " | pip→/data/venv ✓" printf " | npm→/data/npm-global ✓" printf " | playwright+chromium ✓\\n" unset _py_ver _node_ver ''' def _ensure_startup_script() -> str: """ Scrive /data/.bashrc_agente se assente o con versione diversa. Ritorna il path effettivo dove è stato scritto. Fallback a /tmp/.bashrc_agente se /data/ non è scrivibile. """ for target in [_STARTUP_PATH, _STARTUP_FALLBACK]: try: # Riscrivi solo se versione diversa (aggiornamenti deploy) if target.exists(): existing = target.read_text(errors='replace') if _STARTUP_VER in existing: return str(target) # già aggiornato target.parent.mkdir(parents=True, exist_ok=True) target.write_text(_STARTUP_SCRIPT) _logger.info('terminal: startup script written to %s', target) return str(target) except (PermissionError, OSError) as exc: _logger.debug('terminal: cannot write startup script to %s — %s', target, exc) continue # Fallback estremo: ritorna path non-esistente, bash userà il proprio .bashrc return str(_STARTUP_FALLBACK) # ── Terminal state persistence (S754-B) ─────────────────────────────────────── # Salva il cwd corrente su /data/ (volume persistente HF Spaces) al disconnect. # Al reconnect, se la sessione tmux è nuova (HF restart), inietta `cd `. # Pattern identico a vault.py: /data/ → fallback /tmp/ se non scrivibile. def _resolve_state_path() -> Path: for candidate in [Path('/data/.terminal_state.json'), Path('/tmp/.terminal_state.json')]: try: candidate.parent.mkdir(parents=True, exist_ok=True) return candidate except (PermissionError, OSError): continue return Path('/tmp/.terminal_state.json') _STATE_PATH = _resolve_state_path() def _load_state() -> dict: """Carica l'ultimo stato salvato {cwd, saved_at}. Silenzioso su errore.""" try: if _STATE_PATH.exists(): data = json.loads(_STATE_PATH.read_text()) cwd = data.get('cwd', '') # Valida: cwd deve esistere e non essere troppo vecchio (48h) if cwd and Path(cwd).is_dir(): age = int(time.time()) - data.get('saved_at', 0) if age < 172_800: # 48 ore return data except Exception as _exc: _logger.debug("[terminal] silenced %s", type(_exc).__name__) # noqa: BLE001 return {} async def _save_state(session: str = 'main') -> None: """Salva il cwd corrente della sessione tmux in _STATE_PATH.""" try: proc = await asyncio.create_subprocess_exec( 'tmux', 'display-message', '-p', '-t', session, '#{pane_current_path}', stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, ) stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=2.0) cwd = stdout.decode().strip() if cwd and Path(cwd).is_dir(): _STATE_PATH.write_text(json.dumps({'cwd': cwd, 'saved_at': int(time.time())})) _logger.debug('terminal: state saved — cwd=%s', cwd) except Exception as exc: _logger.debug('terminal: save state failed — %s', exc) def _schedule_save(loop: asyncio.AbstractEventLoop) -> None: """ Fire-and-forget save — avvolge create_task in try/except per evitare 'Task was destroyed but it is pending' warning se il loop chiude prima. """ try: loop.create_task(_save_state()) except RuntimeError: pass # loop già chiuso # ── /api/terminal/packages ──────────────────────────────────────────────────── @router.get('/api/terminal/packages') async def terminal_packages(): """ Restituisce i pacchetti installati nel venv Python (/data/venv) e i pacchetti npm globali (/data/npm-global). L'agente può chiamare questo endpoint per sapere cosa è già disponibile senza dover eseguire \'pip list\' o \'npm list -g\' nel terminale PTY. Response: venv_pip — lista [{name, version}] dal venv /data/venv npm_global — lista [{name, version}] da /data/npm-global venv_path — path del venv Python usato npm_prefix — path del prefisso npm globale generated_at — timestamp Unix della risposta """ async def _run_cmd(cmd: list[str], extra_env: dict | None = None) -> str: """Esegue un comando e ritorna stdout. Mai lancia eccezioni.""" try: env = {**os.environ, **(extra_env or {})} proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, env=env, ) stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10.0) return stdout.decode('utf-8', errors='replace').strip() except Exception: return '' # Scegli pip dal venv se disponibile, altrimenti pip di sistema _venv_pip = '/data/venv/bin/pip' pip_cmd = [_venv_pip, 'list', '--format=json'] if Path(_venv_pip).exists() \ else ['pip', 'list', '--format=json'] # npm list -g con prefisso persistente npm_cmd = ['npm', 'list', '-g', '--depth=0', '--json'] npm_env = {'NPM_CONFIG_PREFIX': '/data/npm-global'} # Esegui entrambi in parallelo pip_raw, npm_raw = await asyncio.gather( _run_cmd(pip_cmd), _run_cmd(npm_cmd, extra_env=npm_env), ) # Parse pip — formato: [{"name": "numpy", "version": "1.26.4"}, ...] venv_pip: list[dict] = [] try: venv_pip = json.loads(pip_raw) if pip_raw else [] except Exception as _exc: _logger.debug("[terminal] silenced %s", type(_exc).__name__) # noqa: BLE001 # Parse npm — formato: {"dependencies": {"tsx": {"version": "4.7.0"}, ...}} npm_global: list[dict] = [] try: npm_data = json.loads(npm_raw) if npm_raw else {} for pkg_name, pkg_info in (npm_data.get('dependencies') or {}).items(): npm_global.append({ 'name': pkg_name, 'version': pkg_info.get('version', '') if isinstance(pkg_info, dict) else '', }) except Exception as _exc: _logger.debug("[terminal] silenced %s", type(_exc).__name__) # noqa: BLE001 return { 'venv_pip': venv_pip, 'npm_global': npm_global, 'venv_path': '/data/venv', 'npm_prefix': '/data/npm-global', 'generated_at': int(time.time()), } @router.websocket('/ws/terminal') async def terminal_ws(ws: WebSocket): """ PTY WebSocket — stdin dal client → PTY master, stdout PTY master → client. Supporta qualsiasi sequenza ANSI (colori, resize, ecc.). S755: lancia bash --rcfile /data/.bashrc_agente per: - Python venv persistente (/data/venv, pip install persiste) - npm global persistente (/data/npm-global) - Playwright/Chromium già disponibili (/ms-playwright, baked in Docker) - Aliases, prompt colorato, welcome banner """ # S125-sec: auth WebSocket PTY. # Prod (CF Pages→CF Worker→HF): CF Worker aggiunge ?token=TERMINAL_SECRET lato edge (S480). # Dev: VITE_TERMINAL_SECRET da .env.local. # Fail-closed: se TERMINAL_SECRET non è configurato usa INTERNAL_TOKEN come fallback — # previene accesso diretto a HF Space bypassando CF Worker. _terminal_secret = os.getenv('TERMINAL_SECRET', '') _internal_token = os.getenv('INTERNAL_TOKEN', '') _ws_required_tok = _terminal_secret or _internal_token if _ws_required_tok and ws.query_params.get('token') != _ws_required_tok: await ws.close(code=4403) return await ws.accept() loop = asyncio.get_event_loop() # S755: assicura che /data/.bashrc_agente esista e sia aggiornato startup_script = _ensure_startup_script() # S754-B: verifica se la sessione tmux 'main' esiste già prima di crearla. # Se non esiste → sarà nuova → ripristina cwd salvato. # Se esiste → attach a sessione viva → stato già preservato da tmux. _has_proc = await asyncio.create_subprocess_exec( 'tmux', 'has-session', '-t', 'main', stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) await _has_proc.wait() _is_new_session = (_has_proc.returncode != 0) master_fd, slave_fd = pty.openpty() # S447: tmux new-session -A mantiene la sessione bash viva quando il WebSocket si chiude. # S755: usa bash --rcfile per attivare venv + npm + aliases ad ogni nuova sessione. # Su re-attach (-A, sessione già viva), il comando è ignorato: env già configurato. proc = await asyncio.create_subprocess_exec( 'tmux', 'new-session', '-A', '-s', 'main', 'bash', '--rcfile', startup_script, stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, close_fds=True, env={ **os.environ, 'TERM': 'xterm-256color', 'COLORTERM': 'truecolor', # S755: variabili di ambiente ereditate dalla sessione tmux 'PLAYWRIGHT_BROWSERS_PATH': os.getenv('PLAYWRIGHT_BROWSERS_PATH', '/ms-playwright'), 'NPM_CONFIG_PREFIX': '/data/npm-global', }, ) os.close(slave_fd) # S754-B + S755: se è una sessione nuova e abbiamo uno stato salvato, ripristina il cwd. # 300ms invece di 150ms per permettere al rcfile + venv setup di completarsi prima dell'inject. if _is_new_session: _state = _load_state() _saved_cwd = _state.get('cwd', '') if _saved_cwd: await asyncio.sleep(0.30) _restore_cmd = f'cd {shlex.quote(_saved_cwd)}\n' _banner_cmd = ( f'printf "\\033[32m[▶ Sessione ripristinata: {_saved_cwd}]\\033[0m\\n"\n' ) try: os.write(master_fd, _restore_cmd.encode()) os.write(master_fd, _banner_cmd.encode()) except OSError as _exc: _logger.debug("[terminal] silenced %s", type(_exc).__name__) # noqa: BLE001 closed = asyncio.Event() _last_save = time.monotonic() # S754-B: traccia ultimo salvataggio periodico async def _reader(): nonlocal _last_save try: while not closed.is_set(): try: data = await loop.run_in_executor(None, lambda: os.read(master_fd, 4096)) if data: await ws.send_bytes(data) # S754-B: salvataggio periodico ogni 60s durante attività _now = time.monotonic() if _now - _last_save > 60: _last_save = _now _schedule_save(loop) except OSError: break except Exception as _exc: _logger.debug("[terminal] silenced %s", type(_exc).__name__) # noqa: BLE001 finally: closed.set() async def _writer(): try: async for msg in ws.iter_bytes(): if not msg: continue if len(msg) == 5 and msg[0] == 0x01: rows, cols = struct.unpack_from('