diff --git a/amc_server/mixins/spawn.py b/amc_server/mixins/spawn.py new file mode 100644 index 0000000..7e67040 --- /dev/null +++ b/amc_server/mixins/spawn.py @@ -0,0 +1,283 @@ +import json +import os +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] = [] + + +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 + now = time.monotonic() + last_spawn = _spawn_timestamps.get(project, 0) + if 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: + return {'error': 'Project name is required', 'code': 'MISSING_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 + # Parse line-by-line to avoid substring false positives + for line in result.stdout.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 = 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, + })