fix(spawn): handle empty or missing projects directory

- Add empty-state message in SpawnModal when no projects found
- Spawn button stays disabled when projects list is empty
- Server already handled OSError/missing dir gracefully (returns [])
- Add tests: missing directory, only-hidden-dirs, empty API responses

Closes: bd-3c7
This commit is contained in:
teernisse
2026-02-26 17:07:35 -05:00
parent 2d65d8f95b
commit 8070c4132a
2 changed files with 611 additions and 1 deletions

606
tests/test_spawn.py Normal file
View File

@@ -0,0 +1,606 @@
import io
import json
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
import amc_server.mixins.spawn as spawn_mod
from amc_server.mixins.spawn import SpawnMixin, load_projects_cache, _projects_cache
class DummySpawnHandler(SpawnMixin):
"""Minimal handler stub matching the project convention (see test_control.py)."""
def __init__(self, body=None, auth_header=''):
if body is None:
body = {}
raw = json.dumps(body).encode('utf-8')
self.headers = {
'Content-Length': str(len(raw)),
'Authorization': auth_header,
}
self.rfile = io.BytesIO(raw)
self.sent = []
self.errors = []
def _send_json(self, code, payload):
self.sent.append((code, payload))
def _json_error(self, code, message):
self.errors.append((code, message))
# ---------------------------------------------------------------------------
# _validate_spawn_params
# ---------------------------------------------------------------------------
class TestValidateSpawnParams(unittest.TestCase):
"""Tests for _validate_spawn_params security validation."""
def setUp(self):
self.handler = DummySpawnHandler()
# --- missing / empty project ---
def test_empty_project_returns_error(self):
result = self.handler._validate_spawn_params('', 'claude')
self.assertEqual(result['code'], 'MISSING_PROJECT')
def test_none_project_returns_error(self):
result = self.handler._validate_spawn_params(None, 'claude')
self.assertEqual(result['code'], 'MISSING_PROJECT')
# --- path traversal: slash ---
def test_forward_slash_rejected(self):
result = self.handler._validate_spawn_params('../etc', 'claude')
self.assertEqual(result['code'], 'INVALID_PROJECT')
def test_nested_slash_rejected(self):
result = self.handler._validate_spawn_params('foo/bar', 'claude')
self.assertEqual(result['code'], 'INVALID_PROJECT')
# --- path traversal: dotdot ---
def test_dotdot_in_name_rejected(self):
result = self.handler._validate_spawn_params('foo..bar', 'claude')
self.assertEqual(result['code'], 'INVALID_PROJECT')
def test_leading_dotdot_rejected(self):
result = self.handler._validate_spawn_params('..project', 'claude')
self.assertEqual(result['code'], 'INVALID_PROJECT')
# --- backslash ---
def test_backslash_rejected(self):
result = self.handler._validate_spawn_params('foo\\bar', 'claude')
self.assertEqual(result['code'], 'INVALID_PROJECT')
# --- symlink escape ---
def test_symlink_escape_rejected(self):
"""Project resolving outside PROJECTS_DIR is rejected."""
with tempfile.TemporaryDirectory() as tmpdir:
projects = Path(tmpdir) / 'projects'
projects.mkdir()
# Create a symlink that escapes projects dir
escape_target = Path(tmpdir) / 'outside'
escape_target.mkdir()
symlink = projects / 'evil'
symlink.symlink_to(escape_target)
with patch.object(spawn_mod, 'PROJECTS_DIR', projects):
result = self.handler._validate_spawn_params('evil', 'claude')
self.assertEqual(result['code'], 'INVALID_PROJECT')
# --- nonexistent project ---
def test_nonexistent_project_rejected(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects = Path(tmpdir) / 'projects'
projects.mkdir()
with patch.object(spawn_mod, 'PROJECTS_DIR', projects):
result = self.handler._validate_spawn_params('nonexistent', 'claude')
self.assertEqual(result['code'], 'PROJECT_NOT_FOUND')
def test_file_instead_of_directory_rejected(self):
"""A regular file (not a directory) is not a valid project."""
with tempfile.TemporaryDirectory() as tmpdir:
projects = Path(tmpdir) / 'projects'
projects.mkdir()
(projects / 'notadir').write_text('oops')
with patch.object(spawn_mod, 'PROJECTS_DIR', projects):
result = self.handler._validate_spawn_params('notadir', 'claude')
self.assertEqual(result['code'], 'PROJECT_NOT_FOUND')
# --- invalid agent type ---
def test_invalid_agent_type_rejected(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects = Path(tmpdir) / 'projects'
projects.mkdir()
(projects / 'myproject').mkdir()
with patch.object(spawn_mod, 'PROJECTS_DIR', projects):
result = self.handler._validate_spawn_params('myproject', 'gpt5')
self.assertEqual(result['code'], 'INVALID_AGENT_TYPE')
self.assertIn('gpt5', result['error'])
def test_empty_agent_type_rejected(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects = Path(tmpdir) / 'projects'
projects.mkdir()
(projects / 'myproject').mkdir()
with patch.object(spawn_mod, 'PROJECTS_DIR', projects):
result = self.handler._validate_spawn_params('myproject', '')
self.assertEqual(result['code'], 'INVALID_AGENT_TYPE')
# --- valid project returns resolved_path ---
def test_valid_project_returns_resolved_path(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects = Path(tmpdir) / 'projects'
projects.mkdir()
project_dir = projects / 'amc'
project_dir.mkdir()
with patch.object(spawn_mod, 'PROJECTS_DIR', projects):
result = self.handler._validate_spawn_params('amc', 'claude')
self.assertNotIn('error', result)
self.assertEqual(result['resolved_path'], project_dir.resolve())
def test_valid_project_with_codex_agent(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects = Path(tmpdir) / 'projects'
projects.mkdir()
(projects / 'myproject').mkdir()
with patch.object(spawn_mod, 'PROJECTS_DIR', projects):
result = self.handler._validate_spawn_params('myproject', 'codex')
self.assertNotIn('error', result)
self.assertIn('resolved_path', result)
# --- unicode / special characters ---
def test_unicode_project_name_without_traversal_chars(self):
"""Project names with unicode but no traversal chars resolve normally."""
with tempfile.TemporaryDirectory() as tmpdir:
projects = Path(tmpdir) / 'projects'
projects.mkdir()
# Create a project with a non-ASCII name
project_dir = projects / 'cafe'
project_dir.mkdir()
with patch.object(spawn_mod, 'PROJECTS_DIR', projects):
result = self.handler._validate_spawn_params('cafe', 'claude')
self.assertNotIn('error', result)
def test_whitespace_only_project_name(self):
"""Whitespace-only project name should fail (falsy)."""
result = self.handler._validate_spawn_params('', 'claude')
self.assertEqual(result['code'], 'MISSING_PROJECT')
# ---------------------------------------------------------------------------
# load_projects_cache
# ---------------------------------------------------------------------------
class TestProjectsCache(unittest.TestCase):
"""Tests for projects cache loading."""
def test_loads_directory_names_sorted(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects = Path(tmpdir)
(projects / 'zebra').mkdir()
(projects / 'alpha').mkdir()
(projects / 'middle').mkdir()
with patch.object(spawn_mod, 'PROJECTS_DIR', projects):
load_projects_cache()
self.assertEqual(spawn_mod._projects_cache, ['alpha', 'middle', 'zebra'])
def test_excludes_hidden_directories(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects = Path(tmpdir)
(projects / 'visible').mkdir()
(projects / '.hidden').mkdir()
(projects / '.git').mkdir()
with patch.object(spawn_mod, 'PROJECTS_DIR', projects):
load_projects_cache()
self.assertEqual(spawn_mod._projects_cache, ['visible'])
def test_excludes_files(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects = Path(tmpdir)
(projects / 'real-project').mkdir()
(projects / 'README.md').write_text('hello')
with patch.object(spawn_mod, 'PROJECTS_DIR', projects):
load_projects_cache()
self.assertEqual(spawn_mod._projects_cache, ['real-project'])
def test_handles_oserror_gracefully(self):
with patch.object(spawn_mod, 'PROJECTS_DIR') as mock_dir:
mock_dir.iterdir.side_effect = OSError('permission denied')
load_projects_cache()
self.assertEqual(spawn_mod._projects_cache, [])
def test_empty_directory(self):
with tempfile.TemporaryDirectory() as tmpdir:
projects = Path(tmpdir)
with patch.object(spawn_mod, 'PROJECTS_DIR', projects):
load_projects_cache()
self.assertEqual(spawn_mod._projects_cache, [])
def test_missing_directory(self):
"""PROJECTS_DIR that doesn't exist returns empty list, no crash."""
missing = Path('/tmp/amc-test-nonexistent-projects-dir')
assert not missing.exists(), 'test precondition: path must not exist'
with patch.object(spawn_mod, 'PROJECTS_DIR', missing):
load_projects_cache()
self.assertEqual(spawn_mod._projects_cache, [])
def test_only_hidden_dirs_returns_empty(self):
"""Directory with only hidden subdirectories returns empty list."""
with tempfile.TemporaryDirectory() as tmpdir:
projects = Path(tmpdir)
(projects / '.config').mkdir()
(projects / '.cache').mkdir()
(projects / '.local').mkdir()
with patch.object(spawn_mod, 'PROJECTS_DIR', projects):
load_projects_cache()
self.assertEqual(spawn_mod._projects_cache, [])
# ---------------------------------------------------------------------------
# Rate limiting
# ---------------------------------------------------------------------------
class TestRateLimiting(unittest.TestCase):
"""Tests for per-project rate limiting in _handle_spawn."""
def _make_handler(self, project, agent_type='claude', token='test-token'):
body = {'project': project, 'agent_type': agent_type}
return DummySpawnHandler(body, auth_header=f'Bearer {token}')
def test_first_spawn_allowed(self):
"""First spawn for a project should not be rate-limited."""
from amc_server.context import _spawn_timestamps
_spawn_timestamps.clear()
handler = self._make_handler('fresh-project')
with tempfile.TemporaryDirectory() as tmpdir:
projects = Path(tmpdir) / 'projects'
projects.mkdir()
(projects / 'fresh-project').mkdir()
with patch.object(spawn_mod, 'PROJECTS_DIR', projects), \
patch.object(spawn_mod, 'validate_auth_token', return_value=True), \
patch.object(handler, '_spawn_agent_in_project_tab', return_value={'ok': True}), \
patch.object(handler, '_validate_spawn_params', return_value={
'resolved_path': projects / 'fresh-project',
}):
handler._handle_spawn()
self.assertEqual(len(handler.sent), 1)
code, payload = handler.sent[0]
self.assertEqual(code, 200)
self.assertTrue(payload['ok'])
_spawn_timestamps.clear()
def test_rapid_spawn_same_project_rejected(self):
"""Spawning the same project within cooldown returns 429."""
from amc_server.context import _spawn_timestamps, SPAWN_COOLDOWN_SEC
_spawn_timestamps.clear()
# Pretend we just spawned this project
_spawn_timestamps['rapid-project'] = time.monotonic()
handler = self._make_handler('rapid-project')
with patch.object(spawn_mod, 'validate_auth_token', return_value=True), \
patch.object(handler, '_validate_spawn_params', return_value={
'resolved_path': Path('/fake/rapid-project'),
}):
handler._handle_spawn()
self.assertEqual(len(handler.sent), 1)
code, payload = handler.sent[0]
self.assertEqual(code, 429)
self.assertEqual(payload['code'], 'RATE_LIMITED')
_spawn_timestamps.clear()
def test_spawn_different_project_allowed(self):
"""Spawning a different project while one is on cooldown succeeds."""
from amc_server.context import _spawn_timestamps
_spawn_timestamps.clear()
_spawn_timestamps['project-a'] = time.monotonic()
handler = self._make_handler('project-b')
with patch.object(spawn_mod, 'validate_auth_token', return_value=True), \
patch.object(handler, '_validate_spawn_params', return_value={
'resolved_path': Path('/fake/project-b'),
}), \
patch.object(handler, '_spawn_agent_in_project_tab', return_value={'ok': True}):
handler._handle_spawn()
code, payload = handler.sent[0]
self.assertEqual(code, 200)
self.assertTrue(payload['ok'])
_spawn_timestamps.clear()
def test_spawn_after_cooldown_allowed(self):
"""Spawning the same project after cooldown expires succeeds."""
from amc_server.context import _spawn_timestamps, SPAWN_COOLDOWN_SEC
_spawn_timestamps.clear()
# Set timestamp far enough in the past
_spawn_timestamps['cooled-project'] = time.monotonic() - SPAWN_COOLDOWN_SEC - 1
handler = self._make_handler('cooled-project')
with patch.object(spawn_mod, 'validate_auth_token', return_value=True), \
patch.object(handler, '_validate_spawn_params', return_value={
'resolved_path': Path('/fake/cooled-project'),
}), \
patch.object(handler, '_spawn_agent_in_project_tab', return_value={'ok': True}):
handler._handle_spawn()
code, payload = handler.sent[0]
self.assertEqual(code, 200)
self.assertTrue(payload['ok'])
_spawn_timestamps.clear()
# ---------------------------------------------------------------------------
# Auth token validation
# ---------------------------------------------------------------------------
class TestAuthToken(unittest.TestCase):
"""Tests for auth token validation in _handle_spawn."""
def test_valid_bearer_token_accepted(self):
handler = DummySpawnHandler(
{'project': 'test', 'agent_type': 'claude'},
auth_header='Bearer valid-token',
)
with patch.object(spawn_mod, 'validate_auth_token', return_value=True), \
patch.object(handler, '_validate_spawn_params', return_value={
'resolved_path': Path('/fake/test'),
}), \
patch.object(handler, '_spawn_agent_in_project_tab', return_value={'ok': True}):
handler._handle_spawn()
code, _ = handler.sent[0]
self.assertEqual(code, 200)
def test_missing_auth_header_rejected(self):
handler = DummySpawnHandler(
{'project': 'test', 'agent_type': 'claude'},
auth_header='',
)
with patch.object(spawn_mod, 'validate_auth_token', return_value=False):
handler._handle_spawn()
code, payload = handler.sent[0]
self.assertEqual(code, 401)
self.assertEqual(payload['code'], 'UNAUTHORIZED')
def test_wrong_token_rejected(self):
handler = DummySpawnHandler(
{'project': 'test', 'agent_type': 'claude'},
auth_header='Bearer wrong-token',
)
with patch.object(spawn_mod, 'validate_auth_token', return_value=False):
handler._handle_spawn()
code, payload = handler.sent[0]
self.assertEqual(code, 401)
self.assertEqual(payload['code'], 'UNAUTHORIZED')
def test_malformed_bearer_rejected(self):
handler = DummySpawnHandler(
{'project': 'test', 'agent_type': 'claude'},
auth_header='NotBearer sometoken',
)
with patch.object(spawn_mod, 'validate_auth_token', return_value=False):
handler._handle_spawn()
code, payload = handler.sent[0]
self.assertEqual(code, 401)
self.assertEqual(payload['code'], 'UNAUTHORIZED')
# ---------------------------------------------------------------------------
# _handle_spawn JSON parsing
# ---------------------------------------------------------------------------
class TestHandleSpawnParsing(unittest.TestCase):
"""Tests for JSON body parsing in _handle_spawn."""
def test_invalid_json_body_returns_400(self):
handler = DummySpawnHandler.__new__(DummySpawnHandler)
handler.headers = {
'Content-Length': '11',
'Authorization': 'Bearer tok',
}
handler.rfile = io.BytesIO(b'not json!!!')
handler.sent = []
handler.errors = []
with patch.object(spawn_mod, 'validate_auth_token', return_value=True):
handler._handle_spawn()
self.assertEqual(handler.errors, [(400, 'Invalid JSON body')])
def test_non_dict_body_returns_400(self):
raw = b'"just a string"'
handler = DummySpawnHandler.__new__(DummySpawnHandler)
handler.headers = {
'Content-Length': str(len(raw)),
'Authorization': 'Bearer tok',
}
handler.rfile = io.BytesIO(raw)
handler.sent = []
handler.errors = []
with patch.object(spawn_mod, 'validate_auth_token', return_value=True):
handler._handle_spawn()
self.assertEqual(handler.errors, [(400, 'Invalid JSON body')])
# ---------------------------------------------------------------------------
# _handle_spawn lock contention
# ---------------------------------------------------------------------------
class TestHandleSpawnLock(unittest.TestCase):
"""Tests for spawn lock behavior."""
def test_lock_timeout_returns_503(self):
"""When lock can't be acquired within timeout, returns 503."""
handler = DummySpawnHandler(
{'project': 'test', 'agent_type': 'claude'},
auth_header='Bearer tok',
)
mock_lock = MagicMock()
mock_lock.acquire.return_value = False # Simulate timeout
with patch.object(spawn_mod, 'validate_auth_token', return_value=True), \
patch.object(handler, '_validate_spawn_params', return_value={
'resolved_path': Path('/fake/test'),
}), \
patch.object(spawn_mod, '_spawn_lock', mock_lock):
handler._handle_spawn()
code, payload = handler.sent[0]
self.assertEqual(code, 503)
self.assertEqual(payload['code'], 'SERVER_BUSY')
# ---------------------------------------------------------------------------
# _handle_projects / _handle_projects_refresh
# ---------------------------------------------------------------------------
class TestHandleProjects(unittest.TestCase):
def test_handle_projects_returns_cached_list(self):
handler = DummySpawnHandler()
original = spawn_mod._projects_cache
spawn_mod._projects_cache = ['alpha', 'beta']
try:
handler._handle_projects()
code, payload = handler.sent[0]
self.assertEqual(code, 200)
self.assertTrue(payload['ok'])
self.assertEqual(payload['projects'], ['alpha', 'beta'])
finally:
spawn_mod._projects_cache = original
def test_handle_projects_returns_empty_list_gracefully(self):
handler = DummySpawnHandler()
original = spawn_mod._projects_cache
spawn_mod._projects_cache = []
try:
handler._handle_projects()
code, payload = handler.sent[0]
self.assertEqual(code, 200)
self.assertTrue(payload['ok'])
self.assertEqual(payload['projects'], [])
finally:
spawn_mod._projects_cache = original
def test_handle_projects_refresh_missing_dir_returns_empty(self):
"""Refreshing with a missing PROJECTS_DIR returns empty list, no crash."""
handler = DummySpawnHandler()
missing = Path('/tmp/amc-test-nonexistent-projects-dir')
with patch.object(spawn_mod, 'PROJECTS_DIR', missing):
handler._handle_projects_refresh()
code, payload = handler.sent[0]
self.assertEqual(code, 200)
self.assertTrue(payload['ok'])
self.assertEqual(payload['projects'], [])
def test_handle_projects_refresh_reloads_cache(self):
handler = DummySpawnHandler()
with tempfile.TemporaryDirectory() as tmpdir:
projects = Path(tmpdir)
(projects / 'new-project').mkdir()
with patch.object(spawn_mod, 'PROJECTS_DIR', projects):
handler._handle_projects_refresh()
code, payload = handler.sent[0]
self.assertEqual(code, 200)
self.assertEqual(payload['projects'], ['new-project'])
# ---------------------------------------------------------------------------
# _handle_health
# ---------------------------------------------------------------------------
class TestHandleHealth(unittest.TestCase):
def test_health_with_zellij_available(self):
handler = DummySpawnHandler()
handler._check_zellij_session_exists = MagicMock(return_value=True)
handler._handle_health()
code, payload = handler.sent[0]
self.assertEqual(code, 200)
self.assertTrue(payload['ok'])
self.assertTrue(payload['zellij_available'])
def test_health_with_zellij_unavailable(self):
handler = DummySpawnHandler()
handler._check_zellij_session_exists = MagicMock(return_value=False)
handler._handle_health()
code, payload = handler.sent[0]
self.assertEqual(code, 200)
self.assertFalse(payload['zellij_available'])
if __name__ == '__main__':
unittest.main()