From 62d23793c48d32545b04bcda53c4ad9b472f396d Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 26 Feb 2026 17:00:51 -0500 Subject: [PATCH] feat(server): add spawn API HTTP routes Add routing for spawn-related endpoints to HttpMixin: - GET /api/projects -> _handle_projects - GET /api/health -> _handle_health - POST /api/spawn -> _handle_spawn - POST /api/projects/refresh -> _handle_projects_refresh Update CORS preflight (AC-39) to include GET in allowed methods and Authorization in allowed headers. Closes bd-2al --- amc_server/mixins/http.py | 23 ++++++++++++++++++--- tests/test_http.py | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/amc_server/mixins/http.py b/amc_server/mixins/http.py index f7e207a..0a528ed 100644 --- a/amc_server/mixins/http.py +++ b/amc_server/mixins/http.py @@ -1,6 +1,7 @@ import json import urllib.parse +import amc_server.context as ctx from amc_server.context import DASHBOARD_DIR from amc_server.logging_utils import LOGGER @@ -71,6 +72,10 @@ class HttpMixin: else: agent = "claude" self._serve_skills(agent) + elif self.path == "/api/projects": + self._handle_projects() + elif self.path == "/api/health": + self._handle_health() else: self._json_error(404, "Not Found") except Exception: @@ -90,6 +95,10 @@ class HttpMixin: elif self.path.startswith("/api/respond/"): session_id = urllib.parse.unquote(self.path[len("/api/respond/"):]) self._respond_to_session(session_id) + elif self.path == "/api/spawn": + self._handle_spawn() + elif self.path == "/api/projects/refresh": + self._handle_projects_refresh() else: self._json_error(404, "Not Found") except Exception: @@ -100,11 +109,12 @@ class HttpMixin: pass def do_OPTIONS(self): - # CORS preflight for respond endpoint + # CORS preflight for API endpoints (AC-39: wildcard CORS; + # localhost-only binding AC-24 is the real security boundary) self.send_response(204) self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") - self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") self.end_headers() def _serve_dashboard_file(self, file_path): @@ -137,6 +147,13 @@ class HttpMixin: ext = full_path.suffix.lower() content_type = content_types.get(ext, "application/octet-stream") + # Inject auth token into index.html for spawn endpoint security + if file_path == "index.html" and ctx._auth_token: + content = content.replace( + b"", + f''.encode(), + ) + # No caching during development self._send_bytes_response( 200, diff --git a/tests/test_http.py b/tests/test_http.py index 546930a..0e80bb3 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -268,6 +268,34 @@ class TestDoGet(unittest.TestCase): handler._json_error.assert_called_with(404, "Not Found") +class TestDoGetSpawnRoutes(unittest.TestCase): + """Tests for spawn-related GET routes.""" + + def _make_handler(self, path): + handler = DummyHttpHandler() + handler.path = path + handler._serve_dashboard_file = MagicMock() + handler._serve_state = MagicMock() + handler._serve_stream = MagicMock() + handler._serve_events = MagicMock() + handler._serve_conversation = MagicMock() + handler._serve_skills = MagicMock() + handler._handle_projects = MagicMock() + handler._handle_health = MagicMock() + handler._json_error = MagicMock() + return handler + + def test_api_projects_routed(self): + handler = self._make_handler("/api/projects") + handler.do_GET() + handler._handle_projects.assert_called_once() + + def test_api_health_routed(self): + handler = self._make_handler("/api/health") + handler.do_GET() + handler._handle_health.assert_called_once() + + class TestDoPost(unittest.TestCase): """Tests for do_POST routing edge cases.""" @@ -277,6 +305,8 @@ class TestDoPost(unittest.TestCase): handler._dismiss_dead_sessions = MagicMock() handler._dismiss_session = MagicMock() handler._respond_to_session = MagicMock() + handler._handle_spawn = MagicMock() + handler._handle_projects_refresh = MagicMock() handler._json_error = MagicMock() return handler @@ -300,6 +330,16 @@ class TestDoPost(unittest.TestCase): handler.do_POST() handler._respond_to_session.assert_called_with("session-xyz") + def test_spawn_routed(self): + handler = self._make_handler("/api/spawn") + handler.do_POST() + handler._handle_spawn.assert_called_once() + + def test_projects_refresh_routed(self): + handler = self._make_handler("/api/projects/refresh") + handler.do_POST() + handler._handle_projects_refresh.assert_called_once() + def test_unknown_post_path_returns_404(self): handler = self._make_handler("/api/unknown") handler.do_POST() @@ -316,7 +356,9 @@ class TestDoOptions(unittest.TestCase): self.assertEqual(handler.response_code, 204) self.assertEqual(handler.headers_sent["Access-Control-Allow-Origin"], "*") self.assertIn("POST", handler.headers_sent["Access-Control-Allow-Methods"]) + self.assertIn("GET", handler.headers_sent["Access-Control-Allow-Methods"]) self.assertIn("Content-Type", handler.headers_sent["Access-Control-Allow-Headers"]) + self.assertIn("Authorization", handler.headers_sent["Access-Control-Allow-Headers"]) class TestJsonError(unittest.TestCase):