Implement amc_server/mixins/spawn.py with: - _handle_spawn: POST /api/spawn handler with auth, validation, rate limiting - _validate_spawn_params: path traversal/symlink escape protection - _check_zellij_session_exists: session availability check - _wait_for_session_file: poll for spawn correlation via spawn_id - _spawn_agent_in_project_tab: Zellij tab creation + pane spawn - _handle_projects: GET cached project list - _handle_projects_refresh: POST to refresh cache - _handle_health: Zellij availability check - load_projects_cache: module-level project directory scanner Closes bd-5m4
284 lines
10 KiB
Python
284 lines
10 KiB
Python
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,
|
|
})
|