Files
amc/amc_server/mixins/spawn.py
teernisse 69175f08f9 fix(spawn): strip ANSI codes from zellij list-sessions output
Zellij outputs colored text with ANSI escape codes, which caused
session name parsing to fail. Now strips escape codes before parsing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 17:27:56 -05:00

312 lines
12 KiB
Python

import json
import os
import re
import subprocess
import time
import uuid
from amc_server.context import (
PROJECTS_DIR, SESSIONS_DIR, ZELLIJ_BIN, ZELLIJ_SESSION,
_spawn_lock, _spawn_timestamps, SPAWN_COOLDOWN_SEC,
validate_auth_token,
)
from amc_server.logging_utils import LOGGER
# Agent commands (AC-8, AC-9: full autonomous permissions)
AGENT_COMMANDS = {
'claude': ['claude', '--dangerously-skip-permissions'],
'codex': ['codex', '--dangerously-bypass-approvals-and-sandbox'],
}
# Module-level cache for projects list (AC-33)
_projects_cache: list[str] = []
# Characters unsafe for Zellij pane/tab names: control chars, quotes, backticks
_UNSAFE_PANE_CHARS = re.compile(r'[\x00-\x1f\x7f"\'`]')
def _sanitize_pane_name(name):
"""Sanitize a string for use as a Zellij pane name.
Replaces control characters and quotes with underscores, collapses runs
of whitespace into a single space, and truncates to 64 chars.
"""
name = _UNSAFE_PANE_CHARS.sub('_', name)
name = re.sub(r'\s+', ' ', name).strip()
return name[:64] if name else 'unnamed'
def load_projects_cache():
"""Scan ~/projects/ and cache the list. Called on server start."""
global _projects_cache
try:
projects = []
for entry in PROJECTS_DIR.iterdir():
if entry.is_dir() and not entry.name.startswith('.'):
projects.append(entry.name)
projects.sort()
_projects_cache = projects
except OSError:
_projects_cache = []
class SpawnMixin:
def _handle_spawn(self):
"""POST /api/spawn handler."""
# Verify auth token (AC-38)
auth_header = self.headers.get('Authorization', '')
if not validate_auth_token(auth_header):
self._send_json(401, {'ok': False, 'error': 'Unauthorized', 'code': 'UNAUTHORIZED'})
return
# Parse JSON body
try:
content_length = int(self.headers.get('Content-Length', 0))
body = json.loads(self.rfile.read(content_length))
if not isinstance(body, dict):
self._json_error(400, 'Invalid JSON body')
return
except (json.JSONDecodeError, ValueError):
self._json_error(400, 'Invalid JSON body')
return
project = body.get('project', '')
agent_type = body.get('agent_type', '')
# Validate params (returns resolved_path to avoid TOCTOU)
validation = self._validate_spawn_params(project, agent_type)
if 'error' in validation:
self._send_json(400, {
'ok': False,
'error': validation['error'],
'code': validation['code'],
})
return
resolved_path = validation['resolved_path']
spawn_id = str(uuid.uuid4())
# Acquire _spawn_lock with 15s timeout
acquired = _spawn_lock.acquire(timeout=15)
if not acquired:
self._send_json(503, {
'ok': False,
'error': 'Server busy - another spawn in progress',
'code': 'SERVER_BUSY',
})
return
try:
# Check rate limit inside lock
# Use None sentinel to distinguish "never spawned" from "spawned at time 0"
# (time.monotonic() can be close to 0 on fresh process start)
now = time.monotonic()
last_spawn = _spawn_timestamps.get(project)
if last_spawn is not None and now - last_spawn < SPAWN_COOLDOWN_SEC:
remaining = SPAWN_COOLDOWN_SEC - (now - last_spawn)
self._send_json(429, {
'ok': False,
'error': f'Rate limited - wait {remaining:.0f}s before spawning in {project}',
'code': 'RATE_LIMITED',
})
return
# Execute spawn
result = self._spawn_agent_in_project_tab(
project, resolved_path, agent_type, spawn_id,
)
# Update timestamp only on success
if result.get('ok'):
_spawn_timestamps[project] = time.monotonic()
status_code = 200 if result.get('ok') else 500
result['spawn_id'] = spawn_id
self._send_json(status_code, result)
finally:
_spawn_lock.release()
def _validate_spawn_params(self, project, agent_type):
"""Validate spawn parameters. Returns resolved_path or error dict."""
if not project or not isinstance(project, str):
return {'error': 'Project name is required', 'code': 'MISSING_PROJECT'}
# Reject whitespace-only names
if not project.strip():
return {'error': 'Project name is required', 'code': 'MISSING_PROJECT'}
# Reject null bytes and control characters (U+0000-U+001F, U+007F)
if '\x00' in project or re.search(r'[\x00-\x1f\x7f]', project):
return {'error': 'Invalid project name', 'code': 'INVALID_PROJECT'}
# Reject path traversal characters (/, \, ..)
if '/' in project or '\\' in project or '..' in project:
return {'error': 'Invalid project name', 'code': 'INVALID_PROJECT'}
# Resolve symlinks and verify under PROJECTS_DIR
candidate = PROJECTS_DIR / project
try:
resolved = candidate.resolve()
except OSError:
return {'error': f'Project not found: {project}', 'code': 'PROJECT_NOT_FOUND'}
# Symlink escape check
try:
resolved.relative_to(PROJECTS_DIR.resolve())
except ValueError:
return {'error': 'Invalid project name', 'code': 'INVALID_PROJECT'}
if not resolved.is_dir():
return {'error': f'Project not found: {project}', 'code': 'PROJECT_NOT_FOUND'}
if agent_type not in AGENT_COMMANDS:
return {
'error': f'Invalid agent type: {agent_type}. Must be one of: {", ".join(sorted(AGENT_COMMANDS))}',
'code': 'INVALID_AGENT_TYPE',
}
return {'resolved_path': resolved}
def _check_zellij_session_exists(self):
"""Check if the target Zellij session exists."""
try:
result = subprocess.run(
[ZELLIJ_BIN, 'list-sessions'],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return False
# Strip ANSI escape codes (Zellij outputs colored text)
ansi_pattern = re.compile(r'\x1b\[[0-9;]*m')
output = ansi_pattern.sub('', result.stdout)
# Parse line-by-line to avoid substring false positives
for line in output.splitlines():
# Zellij outputs "session_name [Created ...]" or just "session_name"
session_name = line.strip().split()[0] if line.strip() else ''
if session_name == ZELLIJ_SESSION:
return True
return False
except FileNotFoundError:
return False
except subprocess.TimeoutExpired:
return False
except OSError:
return False
def _wait_for_session_file(self, spawn_id, timeout=10.0):
"""Poll for a session file matching spawn_id."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
for f in SESSIONS_DIR.glob('*.json'):
try:
data = json.loads(f.read_text())
if isinstance(data, dict) and data.get('spawn_id') == spawn_id:
return True
except (json.JSONDecodeError, OSError):
continue
except OSError:
pass
time.sleep(0.25)
return False
def _spawn_agent_in_project_tab(self, project, project_path, agent_type, spawn_id):
"""Spawn an agent in a project-named Zellij tab."""
# Check session exists
if not self._check_zellij_session_exists():
return {
'ok': False,
'error': f'Zellij session "{ZELLIJ_SESSION}" not found',
'code': 'SESSION_NOT_FOUND',
}
# Create/switch to project tab
try:
result = subprocess.run(
[
ZELLIJ_BIN, '--session', ZELLIJ_SESSION,
'action', 'go-to-tab-name', '--create', project,
],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return {
'ok': False,
'error': f'Failed to create tab: {result.stderr.strip() or "unknown error"}',
'code': 'TAB_ERROR',
}
except FileNotFoundError:
return {'ok': False, 'error': f'Zellij not found at {ZELLIJ_BIN}', 'code': 'ZELLIJ_NOT_FOUND'}
except subprocess.TimeoutExpired:
return {'ok': False, 'error': 'Zellij tab creation timed out', 'code': 'TIMEOUT'}
except OSError as e:
return {'ok': False, 'error': str(e), 'code': 'SPAWN_ERROR'}
# Build agent command
agent_cmd = AGENT_COMMANDS[agent_type]
pane_name = _sanitize_pane_name(f'{agent_type}-{project}')
# Spawn pane with agent command
env = os.environ.copy()
env['AMC_SPAWN_ID'] = spawn_id
try:
result = subprocess.run(
[
ZELLIJ_BIN, '--session', ZELLIJ_SESSION,
'action', 'new-pane',
'--name', pane_name,
'--cwd', str(project_path),
'--',
] + agent_cmd,
env=env,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return {
'ok': False,
'error': f'Failed to spawn pane: {result.stderr.strip() or "unknown error"}',
'code': 'SPAWN_ERROR',
}
except FileNotFoundError:
return {'ok': False, 'error': f'Zellij not found at {ZELLIJ_BIN}', 'code': 'ZELLIJ_NOT_FOUND'}
except subprocess.TimeoutExpired:
return {'ok': False, 'error': 'Pane spawn timed out', 'code': 'TIMEOUT'}
except OSError as e:
return {'ok': False, 'error': str(e), 'code': 'SPAWN_ERROR'}
# Wait for session file to appear
found = self._wait_for_session_file(spawn_id)
if not found:
LOGGER.warning(
'Session file not found for spawn_id=%s after timeout (agent may still be starting)',
spawn_id,
)
return {'ok': True, 'session_file_found': found}
def _handle_projects(self):
"""GET /api/projects - return cached projects list."""
self._send_json(200, {'ok': True, 'projects': list(_projects_cache)})
def _handle_projects_refresh(self):
"""POST /api/projects/refresh - refresh and return projects list."""
load_projects_cache()
self._send_json(200, {'ok': True, 'projects': list(_projects_cache)})
def _handle_health(self):
"""GET /api/health - check server and Zellij status."""
zellij_ok = self._check_zellij_session_exists()
self._send_json(200, {
'ok': True,
'zellij_session': ZELLIJ_SESSION,
'zellij_available': zellij_ok,
})