Terminal / api /terminal.py
Pulka
sync: 1 changed, 0 deleted — from push 5ce28d8b (2026-06-19T14:21 UTC)
b638da5 verified
Raw
History Blame
17.6 kB
"""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 ────────────────────────────────────────────────────
@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('<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