Spaces:
Configuration error
Configuration error
| """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 <cwd>`. | |
| # 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 ──────────────────────────────────────────────────── | |
| 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()), | |
| } | |
| 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('<HH', msg, 1) | |
| fcntl.ioctl(master_fd, termios.TIOCSWINSZ, | |
| struct.pack('HHHH', rows, cols, 0, 0)) | |
| else: | |
| os.write(master_fd, msg) | |
| except WebSocketDisconnect as _exc: | |
| _logger.debug("[terminal] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| except Exception as _exc: | |
| _logger.debug("[terminal] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| finally: | |
| closed.set() | |
| try: | |
| await asyncio.gather(_reader(), _writer()) | |
| finally: | |
| closed.set() | |
| # S754-B: salva lo stato prima di terminare il processo. | |
| # La sessione tmux è ancora viva qui (proc è il CLIENT tmux, non il SERVER). | |
| try: | |
| await asyncio.wait_for(_save_state(), timeout=2.5) | |
| except Exception as _exc: | |
| _logger.debug("[terminal] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| try: | |
| proc.terminate() | |
| except Exception as _exc: | |
| _logger.debug("[terminal] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| # Gap-5-FIX: attendi terminazione subprocess con timeout — previene processi zombie | |
| try: | |
| await asyncio.wait_for(proc.wait(), timeout=2.0) | |
| except (asyncio.TimeoutError, Exception): | |
| try: | |
| proc.kill() # SIGKILL se SIGTERM non basta entro 2s | |
| except Exception as _exc: | |
| _logger.debug("[terminal] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| # Chiudi PTY master dopo la terminazione del processo | |
| try: | |
| os.close(master_fd) | |
| except Exception as _exc: | |
| _logger.debug("[terminal] silenced %s", type(_exc).__name__) # noqa: BLE001 | |