Files
agent-call/agent_call/http.py
T

329 lines
13 KiB
Python

"""HTTP control/query adapter for the contract-defined non-execution endpoints."""
from __future__ import annotations
import json
import os
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import unquote, urlsplit
from .core import AgentCallService, ServiceError, ValidationError, _integer, _loads
READ_SCOPE = "outbound.read"
CONTROL_SCOPE = "outbound.control"
UPLOAD_SCOPE = "recording.upload"
COMPLETE_SCOPE = "recording.complete"
class AgentCallHandler(BaseHTTPRequestHandler):
service: AgentCallService
server_version = "agent-call/0.1"
def log_message(self, format: str, *args: Any) -> None:
# Request IDs are enough for local diagnostics; never log auth headers or bodies.
return
def _request_id(self) -> str:
value = self.headers.get("X-Request-ID", "")
if not value or len(value) > 128 or any(ch.isspace() for ch in value):
raise ServiceError("REQUEST_ID_REQUIRED", "X-Request-ID is required", 400)
return value
def _tenant_id(self) -> str:
value = self.headers.get("X-Tenant-ID", "")
if not value or len(value) > 128 or any(ch.isspace() for ch in value):
raise ServiceError("TENANT_ID_REQUIRED", "X-Tenant-ID is required", 400)
return value
def _authorize(self, scope: str, tenant_id: str | None = None) -> None:
token_header = self.headers.get("Authorization", "")
if not token_header.startswith("Bearer "):
raise ServiceError("UNAUTHORIZED", "bearer authentication is required", 401)
token = token_header[7:]
token_map = _loads(os.environ.get("HTTP_TOKENS"), {})
if not isinstance(token_map, dict):
raise ServiceError("UNAUTHORIZED", "token configuration is invalid", 401)
identity = token_map.get(token)
if not isinstance(identity, dict):
raise ServiceError("UNAUTHORIZED", "bearer token is not authorized", 401)
scopes = identity.get("scopes", [])
if scope not in scopes and "*" not in scopes:
raise ServiceError("FORBIDDEN", "required scope is missing", 403)
if tenant_id is not None:
tenant_ids = identity.get("tenant_ids", [])
if "*" not in tenant_ids and tenant_id not in tenant_ids:
raise ServiceError("FORBIDDEN", "tenant scope is missing", 403)
def _body(self) -> dict[str, Any]:
raw_length = self.headers.get("Content-Length")
if not raw_length:
raise ValidationError("BODY_REQUIRED", "JSON request body is required")
length = _integer(raw_length)
if length < 1 or length > self.service.profile.get("limits", {}).get(
"max_http_bytes", 65536
):
raise ServiceError(
"BODY_TOO_LARGE", "request body exceeds the HTTP limit", 413
)
try:
raw = self.rfile.read(length)
body = _loads(raw.decode("utf-8"))
except UnicodeDecodeError as exc:
raise ValidationError(
"INVALID_JSON", "request body must be UTF-8 JSON"
) from exc
if not isinstance(body, dict):
raise ValidationError("INVALID_JSON", "request body must be a JSON object")
return body
def _header(self, name: str) -> str:
value = self.headers.get(name, "")
if not value:
raise ServiceError("HEADER_REQUIRED", f"{name} is required", 400)
return value
def _send(
self, status: int, body: Any, content_type: str = "application/json"
) -> None:
encoded = json.dumps(
body, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(encoded)
def _send_text(self, status: int, body: str, content_type: str) -> None:
encoded = body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(encoded)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(encoded)
def _error(self, exc: ServiceError, request_id: str | None = None) -> None:
body = {
"type": "about:blank",
"title": HTTPStatus(exc.status).phrase
if exc.status in HTTPStatus._value2member_map_
else "Request failed",
"status": exc.status,
"code": exc.code,
"detail": exc.detail,
"request_id": request_id or self.headers.get("X-Request-ID", "unknown"),
"retryable": exc.retryable,
}
try:
self._send(exc.status, body, "application/problem+json")
except (BrokenPipeError, ConnectionResetError):
return
def _dispatch(self, method: str) -> None:
request_id: str | None = None
try:
path = urlsplit(self.path).path
parts = [unquote(part) for part in path.split("/") if part]
if method == "GET" and path == "/healthz/live":
self._send(200, {"status": "live", "mode": self.service.mode})
return
if method == "GET" and path == "/healthz/ready":
if not self.service.ready():
raise ServiceError(
"NOT_READY", "dependency health check failed", 503, True
)
self._send(200, {"status": "ready", "mode": self.service.mode})
return
if method == "GET" and path == "/metrics":
self._send_text(
200, self.service.metrics(), "text/plain; version=0.0.4"
)
return
if not (
method == "PUT" and len(parts) == 3 and parts[:2] == ["_mock", "oss"]
):
request_id = self._request_id()
if (
method == "GET"
and len(parts) == 5
and parts[:3] == ["internal", "v1", "outbound"]
and parts[3] == "commands"
):
tenant_id = self._tenant_id()
self._authorize(READ_SCOPE, tenant_id)
self._send(200, self.service.get_command(tenant_id, parts[4]))
return
if (
method == "GET"
and len(parts) == 5
and parts[:3] == ["internal", "v1", "outbound"]
and parts[3] == "calls"
):
tenant_id = self._tenant_id()
self._authorize(READ_SCOPE, tenant_id)
self._send(200, self.service.get_call(tenant_id, parts[4]))
return
if (
method == "POST"
and len(parts) == 6
and parts[:3] == ["internal", "v1", "outbound"]
and parts[3] == "tasks"
and parts[5] == "controls"
):
tenant_id = self._tenant_id()
self._authorize(CONTROL_SCOPE, tenant_id)
body = self._body()
command_id = self._header("Idempotency-Key")
result = self.service.control_task(
tenant_id,
parts[4],
body,
command_id,
allow_hangup=self._has_scope("outbound.hangup", tenant_id),
)
self._send(202, result)
return
if (
method == "POST"
and len(parts) == 6
and parts[:3] == ["internal", "v1", "outbound"]
and parts[3] == "calls"
and parts[5] == "replays"
):
tenant_id = self._tenant_id()
self._authorize(CONTROL_SCOPE, tenant_id)
result = self.service.create_replay(
tenant_id,
"call",
parts[4],
self._body(),
self._header("Idempotency-Key"),
)
self._send(202, result)
return
if (
method == "POST"
and len(parts) == 6
and parts[:3] == ["internal", "v1", "outbound"]
and parts[3] == "commands"
and parts[5] == "replays"
):
tenant_id = self._tenant_id()
self._authorize(CONTROL_SCOPE, tenant_id)
result = self.service.create_replay(
tenant_id,
"command",
parts[4],
self._body(),
self._header("Idempotency-Key"),
)
self._send(202, result)
return
if (
method == "POST"
and len(parts) == 4
and parts[:3] == ["internal", "v1", "outbound"]
and parts[3] == "recording-uploads"
):
tenant_id = self._tenant_id()
self._authorize(UPLOAD_SCOPE, tenant_id)
result_status, result = self.service.create_upload(
tenant_id, self._body()
)
self._send(result_status, result)
return
if (
method == "POST"
and len(parts) == 6
and parts[:3] == ["internal", "v1", "outbound"]
and parts[3] == "recording-uploads"
and parts[5] == "complete"
):
tenant_id = self._tenant_id()
self._authorize(COMPLETE_SCOPE, tenant_id)
result = self.service.complete_upload(tenant_id, parts[4], self._body())
self._send(200, result)
return
if method == "PUT" and len(parts) == 3 and parts[:2] == ["_mock", "oss"]:
if self.service.mode == "real":
raise ServiceError(
"MOCK_DISABLED",
"mock object storage is disabled in real mode",
404,
)
token = self._header("X-Mock-Upload-Token")
raw_length = self.headers.get("Content-Length")
if not raw_length:
raise ValidationError("BODY_REQUIRED", "upload body is required")
length = _integer(raw_length)
if length > self.service.profile.get("limits", {}).get(
"recording_max_bytes", 16777216
):
raise ServiceError(
"BODY_TOO_LARGE",
"recording exceeds the configured size limit",
413,
)
try:
data = self.rfile.read(length)
except (BrokenPipeError, ConnectionResetError) as exc:
raise ServiceError(
"UPLOAD_INTERRUPTED", "upload connection interrupted", 400
) from exc
upload = self.service.store.one(
"SELECT tenant_id FROM uploads WHERE upload_id=?", (parts[2],)
)
if upload is None:
raise ServiceError("NOT_FOUND", "upload session not found", 404)
self.service.put_upload(parts[2], token, data)
self._send(200, {"status": "uploaded", "upload_id": parts[2]})
return
raise ServiceError("NOT_FOUND", "route not found", 404)
except ServiceError as exc:
self._error(exc, request_id)
except (BrokenPipeError, ConnectionResetError):
return
except Exception:
self._error(
ServiceError("INTERNAL_ERROR", "request could not be completed", 500),
request_id,
)
def _has_scope(self, scope: str, tenant_id: str) -> bool:
token_header = self.headers.get("Authorization", "")
if not token_header.startswith("Bearer "):
return False
token_map = _loads(os.environ.get("HTTP_TOKENS"), {})
identity = (
token_map.get(token_header[7:]) if isinstance(token_map, dict) else None
)
if not isinstance(identity, dict):
return False
tenant_ids = identity.get("tenant_ids", [])
return ("*" in tenant_ids or tenant_id in tenant_ids) and (
scope in identity.get("scopes", []) or "*" in identity.get("scopes", [])
)
def do_GET(self) -> None:
self._dispatch("GET")
def do_POST(self) -> None:
self._dispatch("POST")
def do_PUT(self) -> None:
self._dispatch("PUT")
def make_server(
service: AgentCallService, host: str = "127.0.0.1", port: int = 8080
) -> ThreadingHTTPServer:
class BoundHandler(AgentCallHandler):
pass
BoundHandler.service = service
return ThreadingHTTPServer((host, port), BoundHandler)