feat(agent-call): implement remediation plan
This commit is contained in:
+2023
-425
File diff suppressed because it is too large
Load Diff
+58
-14
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from http import HTTPStatus
|
||||
@@ -15,6 +16,7 @@ READ_SCOPE = "outbound.read"
|
||||
CONTROL_SCOPE = "outbound.control"
|
||||
UPLOAD_SCOPE = "recording.upload"
|
||||
COMPLETE_SCOPE = "recording.complete"
|
||||
REPLAY_SCOPE = "outbound.replay"
|
||||
|
||||
|
||||
class AgentCallHandler(BaseHTTPRequestHandler):
|
||||
@@ -37,7 +39,7 @@ class AgentCallHandler(BaseHTTPRequestHandler):
|
||||
raise ServiceError("TENANT_ID_REQUIRED", "X-Tenant-ID is required", 400)
|
||||
return value
|
||||
|
||||
def _authorize(self, scope: str, tenant_id: str | None = None) -> None:
|
||||
def _authorize(self, scope: str, tenant_id: str | None = None) -> dict[str, Any]:
|
||||
token_header = self.headers.get("Authorization", "")
|
||||
if not token_header.startswith("Bearer "):
|
||||
raise ServiceError("UNAUTHORIZED", "bearer authentication is required", 401)
|
||||
@@ -49,12 +51,24 @@ class AgentCallHandler(BaseHTTPRequestHandler):
|
||||
if not isinstance(identity, dict):
|
||||
raise ServiceError("UNAUTHORIZED", "bearer token is not authorized", 401)
|
||||
scopes = identity.get("scopes", [])
|
||||
tenant_ids = identity.get("tenant_ids", [])
|
||||
if not isinstance(scopes, list) or not all(
|
||||
isinstance(item, str) for item in scopes
|
||||
):
|
||||
raise ServiceError("UNAUTHORIZED", "token scopes are invalid", 401)
|
||||
if not isinstance(tenant_ids, list) or not all(
|
||||
isinstance(item, str) for item in tenant_ids
|
||||
):
|
||||
raise ServiceError("UNAUTHORIZED", "token tenant scope is invalid", 401)
|
||||
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)
|
||||
if (
|
||||
tenant_id is not None
|
||||
and "*" not in tenant_ids
|
||||
and tenant_id not in tenant_ids
|
||||
):
|
||||
raise ServiceError("FORBIDDEN", "tenant scope is missing", 403)
|
||||
return identity
|
||||
|
||||
def _body(self) -> dict[str, Any]:
|
||||
raw_length = self.headers.get("Content-Length")
|
||||
@@ -69,6 +83,8 @@ class AgentCallHandler(BaseHTTPRequestHandler):
|
||||
)
|
||||
try:
|
||||
raw = self.rfile.read(length)
|
||||
if len(raw) != length:
|
||||
raise ValidationError("BODY_TRUNCATED", "request body was truncated")
|
||||
body = _loads(raw.decode("utf-8"))
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValidationError(
|
||||
@@ -80,10 +96,21 @@ class AgentCallHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def _header(self, name: str) -> str:
|
||||
value = self.headers.get(name, "")
|
||||
if not value:
|
||||
if not value or len(value) > 128 or any(ch.isspace() for ch in value):
|
||||
raise ServiceError("HEADER_REQUIRED", f"{name} is required", 400)
|
||||
return value
|
||||
|
||||
def _actor_id(self) -> str:
|
||||
token_header = self.headers.get("Authorization", "")
|
||||
token = token_header[7:] if token_header.startswith("Bearer ") else ""
|
||||
identity = _loads(os.environ.get("HTTP_TOKENS"), {})
|
||||
principal = identity.get(token) if isinstance(identity, dict) else None
|
||||
if isinstance(principal, dict):
|
||||
value = principal.get("client_id") or principal.get("subject")
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return "token:" + hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
def _send(
|
||||
self, status: int, body: Any, content_type: str = "application/json"
|
||||
) -> None:
|
||||
@@ -195,7 +222,7 @@ class AgentCallHandler(BaseHTTPRequestHandler):
|
||||
and parts[5] == "replays"
|
||||
):
|
||||
tenant_id = self._tenant_id()
|
||||
self._authorize(CONTROL_SCOPE, tenant_id)
|
||||
self._authorize(REPLAY_SCOPE, tenant_id)
|
||||
result = self.service.create_replay(
|
||||
tenant_id,
|
||||
"call",
|
||||
@@ -213,7 +240,7 @@ class AgentCallHandler(BaseHTTPRequestHandler):
|
||||
and parts[5] == "replays"
|
||||
):
|
||||
tenant_id = self._tenant_id()
|
||||
self._authorize(CONTROL_SCOPE, tenant_id)
|
||||
self._authorize(REPLAY_SCOPE, tenant_id)
|
||||
result = self.service.create_replay(
|
||||
tenant_id,
|
||||
"command",
|
||||
@@ -231,8 +258,9 @@ class AgentCallHandler(BaseHTTPRequestHandler):
|
||||
):
|
||||
tenant_id = self._tenant_id()
|
||||
self._authorize(UPLOAD_SCOPE, tenant_id)
|
||||
idempotency_key = self._header("Idempotency-Key")
|
||||
result_status, result = self.service.create_upload(
|
||||
tenant_id, self._body()
|
||||
tenant_id, self._body(), idempotency_key, self._actor_id()
|
||||
)
|
||||
self._send(result_status, result)
|
||||
return
|
||||
@@ -245,7 +273,10 @@ class AgentCallHandler(BaseHTTPRequestHandler):
|
||||
):
|
||||
tenant_id = self._tenant_id()
|
||||
self._authorize(COMPLETE_SCOPE, tenant_id)
|
||||
result = self.service.complete_upload(tenant_id, parts[4], self._body())
|
||||
idempotency_key = self._header("Idempotency-Key")
|
||||
result = self.service.complete_upload(
|
||||
tenant_id, parts[4], self._body(), idempotency_key, self._actor_id()
|
||||
)
|
||||
self._send(200, result)
|
||||
return
|
||||
if method == "PUT" and len(parts) == 3 and parts[:2] == ["_mock", "oss"]:
|
||||
@@ -260,8 +291,8 @@ class AgentCallHandler(BaseHTTPRequestHandler):
|
||||
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
|
||||
if length < 0 or length > self.service.profile.get("limits", {}).get(
|
||||
"max_recording_bytes", 16777216
|
||||
):
|
||||
raise ServiceError(
|
||||
"BODY_TOO_LARGE",
|
||||
@@ -274,6 +305,10 @@ class AgentCallHandler(BaseHTTPRequestHandler):
|
||||
raise ServiceError(
|
||||
"UPLOAD_INTERRUPTED", "upload connection interrupted", 400
|
||||
) from exc
|
||||
if len(data) != length:
|
||||
raise ServiceError(
|
||||
"UPLOAD_INTERRUPTED", "upload body was truncated", 400
|
||||
)
|
||||
upload = self.service.store.one(
|
||||
"SELECT tenant_id FROM uploads WHERE upload_id=?", (parts[2],)
|
||||
)
|
||||
@@ -287,7 +322,7 @@ class AgentCallHandler(BaseHTTPRequestHandler):
|
||||
self._error(exc, request_id)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
return
|
||||
except Exception:
|
||||
except (OSError, RuntimeError, TypeError, ValueError, KeyError):
|
||||
self._error(
|
||||
ServiceError("INTERNAL_ERROR", "request could not be completed", 500),
|
||||
request_id,
|
||||
@@ -304,8 +339,17 @@ class AgentCallHandler(BaseHTTPRequestHandler):
|
||||
if not isinstance(identity, dict):
|
||||
return False
|
||||
tenant_ids = identity.get("tenant_ids", [])
|
||||
scopes = identity.get("scopes", [])
|
||||
if not isinstance(tenant_ids, list) or not all(
|
||||
isinstance(item, str) for item in tenant_ids
|
||||
):
|
||||
return False
|
||||
if not isinstance(scopes, list) or not all(
|
||||
isinstance(item, str) for item in scopes
|
||||
):
|
||||
return False
|
||||
return ("*" in tenant_ids or tenant_id in tenant_ids) and (
|
||||
scope in identity.get("scopes", []) or "*" in identity.get("scopes", [])
|
||||
scope in scopes or "*" in scopes
|
||||
)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
|
||||
+40
-3
@@ -6,6 +6,7 @@ ARI, RTP, AI-stream, or OSS integration.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
@@ -82,9 +83,23 @@ class MockRTP:
|
||||
def __init__(self, journal: ProtocolJournal) -> None:
|
||||
self.journal = journal
|
||||
|
||||
def packet(self, call_id: str, direction: str, sequence: int) -> None:
|
||||
def packet(
|
||||
self,
|
||||
call_id: str,
|
||||
direction: str,
|
||||
sequence: int,
|
||||
payload: bytes | None = None,
|
||||
) -> None:
|
||||
payload = payload if payload is not None else b"\x01\x02" * 160
|
||||
self.journal.append(
|
||||
"rtp", "packet", call_id=call_id, direction=direction, sequence=sequence
|
||||
"rtp",
|
||||
"packet",
|
||||
call_id=call_id,
|
||||
direction=direction,
|
||||
sequence=sequence,
|
||||
payload_bytes=len(payload),
|
||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
non_silent=any(payload),
|
||||
)
|
||||
|
||||
|
||||
@@ -93,7 +108,12 @@ class MockAI:
|
||||
self.journal = journal
|
||||
|
||||
def turn(
|
||||
self, call_id: str, turn_index: int, speaker: str, playback_state: str
|
||||
self,
|
||||
call_id: str,
|
||||
turn_index: int,
|
||||
speaker: str,
|
||||
playback_state: str,
|
||||
text: str | None = None,
|
||||
) -> None:
|
||||
self.journal.append(
|
||||
"ai",
|
||||
@@ -102,4 +122,21 @@ class MockAI:
|
||||
turn_index=turn_index,
|
||||
speaker=speaker,
|
||||
playback_state=playback_state,
|
||||
text=text or f"mock-{speaker}-turn-{turn_index}",
|
||||
)
|
||||
self.journal.append(
|
||||
"ai",
|
||||
"playback",
|
||||
call_id=call_id,
|
||||
turn_index=turn_index,
|
||||
state="sent",
|
||||
)
|
||||
self.journal.append(
|
||||
"ai",
|
||||
"playback",
|
||||
call_id=call_id,
|
||||
turn_index=turn_index,
|
||||
state=playback_state
|
||||
if playback_state in {"confirmed", "playback_confirmed", "unknown"}
|
||||
else "unknown",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user