fix(ws1): harden HTTP API + declare fastapi/uvicorn deps

Security follow-ups from the per-PR review (non-blocking MEDIUMs):
- Eager _get_token() at make_app build time so a missing AGENT_TEAM_API_TOKEN
  fails fast instead of serving requests first (matches the docstring contract).
- Disable /docs, /redoc, /openapi.json (no auth dependency in FastAPI) — the
  API is VPN-only/127.0.0.1 and should not expose its schema unauthenticated.
- Scrub raw exception text and subprocess stderr from 500 response bodies;
  log server-side instead (avoid internal-path/state disclosure).
- Bound /orchestrator/invoke concurrency with a semaphore (429 over the cap)
  so an authenticated caller cannot exhaust the box via many 600s subprocesses.

Also pin fastapi/uvicorn in requirements.txt (WS1 dep). With fastapi now
installed in CI, the previously skip-guarded TestClient tests run for real;
the importorskip guard stays as a no-op safety net.

Tests: 23 pass (adds docs-disabled + concurrency-429 cases).
This commit is contained in:
Adam Moussa 2026-06-23 12:27:23 -04:00
parent 366d07a84e
commit b6c66877f5
3 changed files with 96 additions and 12 deletions

View file

@ -43,6 +43,7 @@ import hmac
import os
import subprocess
import sys
import threading
from pathlib import Path
from typing import Any
@ -58,6 +59,14 @@ AGENT_TEAM_API_TOKEN_ENV = "AGENT_TEAM_API_TOKEN"
_DEFAULT_HOST = "127.0.0.1"
_DEFAULT_PORT = 8765
# Bound how many `/orchestrator/invoke` subprocesses can run at once. Each spawns
# a 600s-timeout child; without a cap an authenticated caller could exhaust CPU /
# memory / file descriptors by firing many concurrent invocations (DoS). The
# endpoint runs in Starlette's threadpool, so a threading semaphore is the right
# primitive; over-limit requests get 429 rather than queueing unboundedly.
_MAX_CONCURRENT_INVOKES = 2
_invoke_semaphore = threading.BoundedSemaphore(_MAX_CONCURRENT_INVOKES)
# ---------------------------------------------------------------------------
# Pydantic request/response models (module-level so FastAPI resolves them).
@ -169,10 +178,23 @@ def make_app(
"pip install fastapi uvicorn"
) from exc
# Fail fast at app-build time if the bearer token is unconfigured, so a
# misconfigured deploy never reaches a serving state (matches the module
# docstring's "refuses to start without a secret" contract). The per-request
# check still calls _get_token() so a token rotation/unset after boot is
# also caught.
_get_token()
# VPN-only internal API: disable the interactive docs and the OpenAPI schema
# so the endpoint surface is not exposed unauthenticated (these routes carry
# no auth dependency by design in FastAPI).
app = FastAPI(
title="Sea Haven agent-team API",
description="Internal HTTP API for the agent-team orchestrator (VPN-only, 127.0.0.1).",
version="0.1.0",
docs_url=None,
redoc_url=None,
openapi_url=None,
)
_bearer = HTTPBearer(auto_error=False)
@ -232,7 +254,13 @@ def make_app(
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
# Do not leak the raw exception text (may carry internal paths /
# state) into the response body; log it server-side and return a
# generic message.
print(f"[api] get_task error for {thread_id!r}: {exc!r}", file=sys.stderr)
raise HTTPException(
status_code=500, detail="internal error retrieving task state"
) from exc
return TaskStateResponse(thread_id=thread_id, state=state_dict)
@app.post(
@ -255,6 +283,13 @@ def make_app(
status_code=503,
detail=f"orchestrator run.py not found at {run_py}",
)
# Bound concurrent subprocess fan-out (DoS guard). Non-blocking acquire:
# over the cap we reject with 429 rather than pile up 600s children.
if not _invoke_semaphore.acquire(blocking=False):
raise HTTPException(
status_code=429,
detail="too many concurrent orchestrator invocations; retry later",
)
try:
completed = subprocess.run( # noqa: S603 - args are not shell-interpolated
[sys.executable, str(run_py), request.prompt],
@ -268,10 +303,19 @@ def make_app(
status_code=504, detail="orchestrator invocation timed out"
) from None
except subprocess.CalledProcessError as exc:
# Log stderr server-side; do not return it in the response body
# (may carry internal paths / orchestrator internals).
print(
f"[api] orchestrator_invoke failed (exit {exc.returncode}): "
f"{(exc.stderr or '')[:1000]}",
file=sys.stderr,
)
raise HTTPException(
status_code=500,
detail=f"orchestrator call failed (exit {exc.returncode}): {exc.stderr[:500]}",
detail=f"orchestrator call failed (exit {exc.returncode})",
) from exc
finally:
_invoke_semaphore.release()
return OrchestratorInvokeResponse(text=completed.stdout)
return app

View file

@ -378,19 +378,56 @@ def test_api_orchestrator_invoke_calls_subprocess(
@requires_fastapi
def test_api_no_token_env_raises_on_request(monkeypatch: Any) -> None:
def test_api_no_token_env_raises_at_build(monkeypatch: Any) -> None:
from agent_team.api import make_app
monkeypatch.delenv("AGENT_TEAM_API_TOKEN", raising=False)
# Fail-fast: an unconfigured token must raise at app-build time, not serve a
# request first (the eager _get_token() in make_app).
with pytest.raises(RuntimeError, match="AGENT_TEAM_API_TOKEN"):
make_app(coordinator=_make_stub_coordinator())
@requires_fastapi
def test_api_docs_and_openapi_disabled(api_token: str) -> None:
from fastapi.testclient import TestClient
from agent_team.api import make_app
monkeypatch.delenv("AGENT_TEAM_API_TOKEN", raising=False)
app = make_app(coordinator=_make_stub_coordinator())
client = TestClient(app, raise_server_exceptions=False)
# A missing env var raises RuntimeError inside the dependency — should 500
# or 401; either way the request must not succeed.
resp = client.post(
"/tasks",
json={"task": "hi"},
headers={"Authorization": "Bearer anything"},
)
assert resp.status_code in (401, 500)
# The unauthenticated docs/schema routes must be disabled (VPN-only API).
for path in ("/docs", "/redoc", "/openapi.json"):
assert client.get(path).status_code == 404, path
@requires_fastapi
def test_api_invoke_concurrency_cap_returns_429(api_token: str, tmp_path: Any) -> None:
import agent_team.api as api_mod
from fastapi.testclient import TestClient
from agent_team.api import make_app
fake_run = tmp_path / "run.py"
fake_run.write_text("import sys; print(sys.argv[1])")
app = make_app(coordinator=_make_stub_coordinator())
# Exhaust the bounded semaphore so the request sees no free slot → 429.
acquired = [
api_mod._invoke_semaphore.acquire(blocking=False)
for _ in range(api_mod._MAX_CONCURRENT_INVOKES)
]
try:
assert all(acquired)
with patch("agent_team.api._resolve_run_py", return_value=fake_run):
client = TestClient(app, raise_server_exceptions=False)
resp = client.post(
"/orchestrator/invoke",
json={"prompt": "hello"},
headers={"Authorization": f"Bearer {api_token}"},
)
assert resp.status_code == 429
finally:
for ok in acquired:
if ok:
api_mod._invoke_semaphore.release()

View file

@ -7,3 +7,6 @@ langchain-google-genai==4.2.5
langchain-community==0.4.2
composio-langgraph==0.15.0
python-dotenv==1.2.2
# WS1 agent-team HTTP API (agent_team/api.py): FastAPI app + uvicorn ASGI server.
fastapi==0.136.1
uvicorn==0.46.0