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
This commit is contained in:
@@ -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"<!-- AMC_AUTH_TOKEN -->",
|
||||
f'<script>window.AMC_AUTH_TOKEN = "{ctx._auth_token}";</script>'.encode(),
|
||||
)
|
||||
|
||||
# No caching during development
|
||||
self._send_bytes_response(
|
||||
200,
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user