1088 lines
37 KiB
Python
1088 lines
37 KiB
Python
#!/usr/bin/env python3
|
|
"""Fail-closed G7 public WSS and physical-microphone soak runner.
|
|
|
|
Unlike ``soak-voice-websocket.py``, this runner has no synthetic authentication,
|
|
consent, STT, turn-generation, or TTS overrides. Authenticated preflight uses an
|
|
existing learner session cookie, an existing owned voice session, and the public
|
|
TLS WebSocket route. Full soak additionally captures a physical microphone through
|
|
ffmpeg.
|
|
|
|
The runner never writes raw audio, cookie values, session IDs, transcripts, or
|
|
replies to its evidence. Its production soak duration is at least 3,120 seconds
|
|
so the concurrent proof window can retain a full 3,000-second intersection. Use
|
|
``--preflight-only`` for a route/auth/provider-readiness audit that
|
|
never enumerates or opens a microphone. Full soak cannot enumerate or capture a
|
|
microphone unless the operator passes ``--confirm-physical-capture`` explicitly.
|
|
|
|
Exit codes:
|
|
0 = preflight or full soak passed
|
|
1 = a live contract failed
|
|
2 = invalid CLI usage (argparse)
|
|
3 = an external prerequisite is absent or microphone speech is too quiet
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import time
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
|
|
from websockets.asyncio.client import ClientConnection, connect
|
|
from websockets.exceptions import ConnectionClosed
|
|
|
|
|
|
DEFAULT_WSS_URL = "wss://api-vignette.chanpaca.net/voice/ws"
|
|
DEFAULT_ORIGIN = "https://vignette.chanpaca.net"
|
|
DEFAULT_COOKIE_ENV = "VIGNETTE_PUBLIC_SESSION_COOKIE"
|
|
DEFAULT_SESSION_ENV = "VIGNETTE_PUBLIC_VOICE_SESSION_ID"
|
|
DEFAULT_COOKIE_NAME = "__Host-vignette_sid"
|
|
MIN_PRODUCTION_DURATION_SECONDS = 3_120.0
|
|
DEFAULT_DURATION_SECONDS = MIN_PRODUCTION_DURATION_SECONDS
|
|
DEFAULT_TURN_INTERVAL_SECONDS = 60.0
|
|
DEFAULT_HEARTBEAT_SECONDS = 20.0
|
|
DEFAULT_CAPTURE_SECONDS = 3.0
|
|
DEFAULT_MIN_RMS_DBFS = -65.0
|
|
DEFAULT_FRAME_TIMEOUT_SECONDS = 120.0
|
|
DEFAULT_AUDIO_CHUNK_BYTES = 16_384
|
|
|
|
|
|
class GateFailure(RuntimeError):
|
|
"""The public route violated a required contract."""
|
|
|
|
|
|
class GateBlocked(RuntimeError):
|
|
"""A required external prerequisite isn't available."""
|
|
|
|
def __init__(self, blockers: list[str]) -> None:
|
|
self.blockers = sorted(set(blockers))
|
|
super().__init__(",".join(self.blockers))
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class PcmMetrics:
|
|
bytes_captured: int
|
|
sample_rate_hz: int
|
|
channels: int
|
|
sample_width_bytes: int
|
|
duration_seconds: float
|
|
rms_dbfs: float
|
|
peak_dbfs: float
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class TurnTranscriptMetrics:
|
|
turn_number: int
|
|
interim_transcript_frames: int
|
|
speech_final_transcript_frames: int
|
|
first_interim_latency_ms: float | None
|
|
speech_final_latency_ms: float | None
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ExternalSoakEvidence:
|
|
schema_version: str = "vignette.g7-public-voice-soak.v4"
|
|
mode: str = "preflight"
|
|
status: str = "running"
|
|
started_at_utc: str = field(default_factory=lambda: utc_now())
|
|
ended_at_utc: str | None = None
|
|
target_host: str = ""
|
|
target_path: str = ""
|
|
origin: str = ""
|
|
contractual_duration_seconds: float = DEFAULT_DURATION_SECONDS
|
|
requested_duration_seconds: float = 0.0
|
|
elapsed_seconds: float = 0.0
|
|
microphone_device_enumerated: bool = False
|
|
physical_capture_confirmed: bool = False
|
|
physical_microphone_used: bool = False
|
|
microphone_device: str = ""
|
|
raw_audio_retained: bool = False
|
|
public_wss_used: bool = False
|
|
authenticated_public_wss_ready: bool = False
|
|
real_database_session_binding_required: bool = True
|
|
provider_overrides_used: bool = False
|
|
expected_stt_provider: str = ""
|
|
expected_stt_model: str = ""
|
|
expected_tts_provider: str = ""
|
|
expected_tts_model: str = ""
|
|
ready_stt_provider: str | None = None
|
|
ready_stt_model: str | None = None
|
|
ready_tts_provider: str | None = None
|
|
ready_tts_model: str | None = None
|
|
ready_provider_metadata_validated: bool = False
|
|
cookie_env: str = ""
|
|
cookie_present: bool = False
|
|
cookie_value_logged: bool = False
|
|
session_id_present: bool = False
|
|
session_id_sha256: str | None = None
|
|
tls_version: str | None = None
|
|
tls_cipher: str | None = None
|
|
peer_certificate_not_after: str | None = None
|
|
response_server: str | None = None
|
|
cloudflare_ray_present: bool = False
|
|
unauthenticated_handshake_accepted: bool = False
|
|
unauthenticated_close_code: int | None = None
|
|
connections_opened: int = 0
|
|
application_pongs: int = 0
|
|
turns_attempted: int = 0
|
|
turns_succeeded: int = 0
|
|
transcript_frames: int = 0
|
|
interim_transcript_frames: int = 0
|
|
speech_final_transcript_frames: int = 0
|
|
transcript_latency_origin: str = "audio_start_sent_to_client_frame_observed"
|
|
first_interim_latency_ms_min: float | None = None
|
|
first_interim_latency_ms_max: float | None = None
|
|
speech_final_latency_ms_min: float | None = None
|
|
speech_final_latency_ms_max: float | None = None
|
|
turn_transcript_metrics: list[TurnTranscriptMetrics] = field(default_factory=list)
|
|
reply_frames: int = 0
|
|
tts_end_frames: int = 0
|
|
tts_binary_frames: int = 0
|
|
tts_binary_bytes: int = 0
|
|
captured_pcm_bytes: int = 0
|
|
microphone_rms_dbfs_min: float | None = None
|
|
microphone_rms_dbfs_max: float | None = None
|
|
microphone_peak_dbfs_max: float | None = None
|
|
close_code: int | None = None
|
|
blockers: list[str] = field(default_factory=list)
|
|
failure_type: str | None = None
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def sha256_text(value: str) -> str:
|
|
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def target_metadata(wss_url: str) -> tuple[str, str]:
|
|
parsed = urlsplit(wss_url)
|
|
return parsed.netloc, parsed.path
|
|
|
|
|
|
def with_session_id(wss_url: str, session_id: str) -> str:
|
|
parsed = urlsplit(wss_url)
|
|
if parsed.scheme != "wss":
|
|
raise ValueError("--wss-url must use wss://")
|
|
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
|
query["session_id"] = session_id
|
|
return urlunsplit(
|
|
(parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment)
|
|
)
|
|
|
|
|
|
def pcm_metrics(
|
|
audio: bytes,
|
|
*,
|
|
sample_rate_hz: int = 16_000,
|
|
channels: int = 1,
|
|
sample_width_bytes: int = 2,
|
|
) -> PcmMetrics:
|
|
if not audio or len(audio) % sample_width_bytes:
|
|
raise GateFailure("microphone_capture_invalid_pcm")
|
|
samples = struct.unpack(f"<{len(audio) // 2}h", audio)
|
|
squares = sum(sample * sample for sample in samples)
|
|
rms = math.sqrt(squares / len(samples))
|
|
peak = max(abs(sample) for sample in samples)
|
|
|
|
def dbfs(value: float) -> float:
|
|
if value <= 0:
|
|
return -120.0
|
|
return round(20 * math.log10(value / 32_768), 3)
|
|
|
|
frames = len(audio) / (channels * sample_width_bytes)
|
|
return PcmMetrics(
|
|
bytes_captured=len(audio),
|
|
sample_rate_hz=sample_rate_hz,
|
|
channels=channels,
|
|
sample_width_bytes=sample_width_bytes,
|
|
duration_seconds=round(frames / sample_rate_hz, 3),
|
|
rms_dbfs=dbfs(rms),
|
|
peak_dbfs=dbfs(peak),
|
|
)
|
|
|
|
|
|
def capture_microphone_pcm(
|
|
*,
|
|
ffmpeg: str,
|
|
microphone_device: str,
|
|
capture_seconds: float,
|
|
) -> tuple[bytes, PcmMetrics]:
|
|
command = [
|
|
ffmpeg,
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-f",
|
|
"dshow",
|
|
"-i",
|
|
f"audio={microphone_device}",
|
|
"-t",
|
|
str(capture_seconds),
|
|
"-ac",
|
|
"1",
|
|
"-ar",
|
|
"16000",
|
|
"-f",
|
|
"s16le",
|
|
"pipe:1",
|
|
]
|
|
try:
|
|
completed = subprocess.run(
|
|
command,
|
|
check=False,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
timeout=capture_seconds + 15,
|
|
shell=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
raise GateBlocked(["physical_microphone_capture_unavailable"]) from exc
|
|
if completed.returncode != 0 or not completed.stdout:
|
|
raise GateBlocked(["physical_microphone_capture_unavailable"])
|
|
metrics = pcm_metrics(completed.stdout)
|
|
return completed.stdout, metrics
|
|
|
|
|
|
def dshow_audio_devices(*, ffmpeg: str) -> list[str]:
|
|
"""Enumerate DirectShow audio names without opening a capture stream."""
|
|
|
|
command = [
|
|
ffmpeg,
|
|
"-hide_banner",
|
|
"-list_devices",
|
|
"true",
|
|
"-f",
|
|
"dshow",
|
|
"-i",
|
|
"dummy",
|
|
]
|
|
try:
|
|
completed = subprocess.run(
|
|
command,
|
|
check=False,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
timeout=15,
|
|
shell=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
raise GateBlocked(["physical_microphone_enumeration_unavailable"]) from exc
|
|
output = completed.stderr.decode("utf-8", errors="replace")
|
|
devices: list[str] = []
|
|
for line in output.splitlines():
|
|
marker = '" (audio)'
|
|
if marker not in line:
|
|
continue
|
|
prefix = line.split(marker, 1)[0]
|
|
if '"' not in prefix:
|
|
continue
|
|
devices.append(prefix.rsplit('"', 1)[-1])
|
|
if not devices:
|
|
raise GateBlocked(["physical_microphone_enumeration_unavailable"])
|
|
return devices
|
|
|
|
|
|
def record_pcm_metrics(
|
|
evidence: ExternalSoakEvidence,
|
|
metrics: PcmMetrics,
|
|
) -> None:
|
|
evidence.physical_microphone_used = True
|
|
evidence.captured_pcm_bytes += metrics.bytes_captured
|
|
if evidence.microphone_rms_dbfs_min is None:
|
|
evidence.microphone_rms_dbfs_min = metrics.rms_dbfs
|
|
else:
|
|
evidence.microphone_rms_dbfs_min = min(
|
|
evidence.microphone_rms_dbfs_min,
|
|
metrics.rms_dbfs,
|
|
)
|
|
if evidence.microphone_rms_dbfs_max is None:
|
|
evidence.microphone_rms_dbfs_max = metrics.rms_dbfs
|
|
else:
|
|
evidence.microphone_rms_dbfs_max = max(
|
|
evidence.microphone_rms_dbfs_max,
|
|
metrics.rms_dbfs,
|
|
)
|
|
if evidence.microphone_peak_dbfs_max is None:
|
|
evidence.microphone_peak_dbfs_max = metrics.peak_dbfs
|
|
else:
|
|
evidence.microphone_peak_dbfs_max = max(
|
|
evidence.microphone_peak_dbfs_max,
|
|
metrics.peak_dbfs,
|
|
)
|
|
|
|
|
|
def json_frame(raw: str | bytes) -> dict[str, Any] | None:
|
|
if isinstance(raw, bytes):
|
|
return None
|
|
try:
|
|
payload = json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise GateFailure("public_wss_non_json_frame") from exc
|
|
if not isinstance(payload, dict):
|
|
raise GateFailure("public_wss_non_object_frame")
|
|
return payload
|
|
|
|
|
|
def _ready_metadata_value(ready: dict[str, Any], field_name: str) -> str:
|
|
value = ready.get(field_name)
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise GateFailure(
|
|
f"authenticated_public_wss_ready_metadata_missing:{field_name}"
|
|
)
|
|
normalized = value.strip()
|
|
if len(normalized) > 160 or any(
|
|
ord(character) < 32 or ord(character) == 127 for character in normalized
|
|
):
|
|
raise GateFailure(
|
|
f"authenticated_public_wss_ready_metadata_invalid:{field_name}"
|
|
)
|
|
return normalized
|
|
|
|
|
|
def validate_ready_provider_metadata(
|
|
ready: dict[str, Any],
|
|
evidence: ExternalSoakEvidence,
|
|
*,
|
|
expected_stt_provider: str,
|
|
expected_stt_model: str,
|
|
expected_tts_provider: str,
|
|
expected_tts_model: str,
|
|
) -> None:
|
|
"""Record only allowlisted provider metadata and enforce the expected route."""
|
|
|
|
actual = {
|
|
"stt_provider": _ready_metadata_value(ready, "stt_provider"),
|
|
"stt_model": _ready_metadata_value(ready, "stt_model"),
|
|
"tts_provider": _ready_metadata_value(ready, "tts_provider"),
|
|
"tts_model": _ready_metadata_value(ready, "tts_model"),
|
|
}
|
|
expected = {
|
|
"stt_provider": expected_stt_provider,
|
|
"stt_model": expected_stt_model,
|
|
"tts_provider": expected_tts_provider,
|
|
"tts_model": expected_tts_model,
|
|
}
|
|
evidence.expected_stt_provider = expected_stt_provider
|
|
evidence.expected_stt_model = expected_stt_model
|
|
evidence.expected_tts_provider = expected_tts_provider
|
|
evidence.expected_tts_model = expected_tts_model
|
|
evidence.ready_stt_provider = actual["stt_provider"]
|
|
evidence.ready_stt_model = actual["stt_model"]
|
|
evidence.ready_tts_provider = actual["tts_provider"]
|
|
evidence.ready_tts_model = actual["tts_model"]
|
|
for field_name, expected_value in expected.items():
|
|
if actual[field_name] != expected_value:
|
|
raise GateFailure(f"authenticated_public_wss_{field_name}_mismatch")
|
|
evidence.ready_provider_metadata_validated = True
|
|
|
|
|
|
async def receive_until(
|
|
websocket: ClientConnection,
|
|
predicate,
|
|
*,
|
|
timeout_seconds: float,
|
|
observed_at: list[float] | None = None,
|
|
) -> list[str | bytes]:
|
|
deadline = time.monotonic() + timeout_seconds
|
|
frames: list[str | bytes] = []
|
|
while time.monotonic() < deadline:
|
|
remaining = max(0.01, deadline - time.monotonic())
|
|
try:
|
|
raw = await asyncio.wait_for(websocket.recv(), timeout=remaining)
|
|
except TimeoutError as exc:
|
|
raise GateFailure("public_wss_frame_timeout") from exc
|
|
frames.append(raw)
|
|
if observed_at is not None:
|
|
observed_at.append(time.monotonic())
|
|
payload = json_frame(raw)
|
|
if payload is not None and payload.get("type") == "error":
|
|
code = str(payload.get("code") or "unspecified")
|
|
raise GateFailure(f"public_wss_error_frame:{code}")
|
|
if payload is not None and payload.get("type") == "degraded":
|
|
raise GateFailure("public_wss_degraded_frame")
|
|
if predicate(raw, payload):
|
|
return frames
|
|
raise GateFailure("public_wss_predicate_timeout")
|
|
|
|
|
|
def _merge_latency_range(
|
|
current_min: float | None,
|
|
current_max: float | None,
|
|
value: float | None,
|
|
) -> tuple[float | None, float | None]:
|
|
if value is None:
|
|
return current_min, current_max
|
|
return (
|
|
value if current_min is None else min(current_min, value),
|
|
value if current_max is None else max(current_max, value),
|
|
)
|
|
|
|
|
|
def record_turn_transcript_metrics(
|
|
evidence: ExternalSoakEvidence,
|
|
*,
|
|
payloads_with_observed_at: list[tuple[dict[str, Any], float]],
|
|
turn_started_at: float,
|
|
) -> TurnTranscriptMetrics:
|
|
transcript_frames = [
|
|
(payload, observed_at)
|
|
for payload, observed_at in payloads_with_observed_at
|
|
if payload.get("type") == "transcript"
|
|
]
|
|
interim_frames = [
|
|
(payload, observed_at)
|
|
for payload, observed_at in transcript_frames
|
|
if payload.get("final") is False and payload.get("speech_final") is False
|
|
]
|
|
speech_final_frames = [
|
|
(payload, observed_at)
|
|
for payload, observed_at in transcript_frames
|
|
if payload.get("final") is True and payload.get("speech_final") is True
|
|
]
|
|
|
|
def latency_ms(observed_at: float) -> float:
|
|
return round(max(0.0, observed_at - turn_started_at) * 1_000, 3)
|
|
|
|
first_interim_latency_ms = (
|
|
latency_ms(interim_frames[0][1]) if interim_frames else None
|
|
)
|
|
speech_final_latency_ms = (
|
|
latency_ms(speech_final_frames[0][1])
|
|
if len(speech_final_frames) == 1
|
|
else None
|
|
)
|
|
metrics = TurnTranscriptMetrics(
|
|
turn_number=evidence.turns_attempted,
|
|
interim_transcript_frames=len(interim_frames),
|
|
speech_final_transcript_frames=len(speech_final_frames),
|
|
first_interim_latency_ms=first_interim_latency_ms,
|
|
speech_final_latency_ms=speech_final_latency_ms,
|
|
)
|
|
evidence.transcript_frames += len(transcript_frames)
|
|
evidence.interim_transcript_frames += metrics.interim_transcript_frames
|
|
evidence.speech_final_transcript_frames += metrics.speech_final_transcript_frames
|
|
(
|
|
evidence.first_interim_latency_ms_min,
|
|
evidence.first_interim_latency_ms_max,
|
|
) = _merge_latency_range(
|
|
evidence.first_interim_latency_ms_min,
|
|
evidence.first_interim_latency_ms_max,
|
|
metrics.first_interim_latency_ms,
|
|
)
|
|
(
|
|
evidence.speech_final_latency_ms_min,
|
|
evidence.speech_final_latency_ms_max,
|
|
) = _merge_latency_range(
|
|
evidence.speech_final_latency_ms_min,
|
|
evidence.speech_final_latency_ms_max,
|
|
metrics.speech_final_latency_ms,
|
|
)
|
|
evidence.turn_transcript_metrics.append(metrics)
|
|
return metrics
|
|
|
|
|
|
async def receive_type(
|
|
websocket: ClientConnection,
|
|
expected_type: str,
|
|
*,
|
|
timeout_seconds: float,
|
|
) -> tuple[dict[str, Any], list[str | bytes]]:
|
|
frames = await receive_until(
|
|
websocket,
|
|
lambda _raw, payload: (
|
|
payload is not None and payload.get("type") == expected_type
|
|
),
|
|
timeout_seconds=timeout_seconds,
|
|
)
|
|
payload = json_frame(frames[-1])
|
|
assert payload is not None
|
|
return payload, frames
|
|
|
|
|
|
def record_network_metadata(
|
|
websocket: ClientConnection,
|
|
evidence: ExternalSoakEvidence,
|
|
) -> None:
|
|
evidence.public_wss_used = True
|
|
evidence.connections_opened += 1
|
|
headers = getattr(getattr(websocket, "response", None), "headers", None)
|
|
if headers is not None:
|
|
evidence.response_server = headers.get("server")
|
|
evidence.cloudflare_ray_present = bool(headers.get("cf-ray"))
|
|
transport = getattr(websocket, "transport", None)
|
|
ssl_object = transport.get_extra_info("ssl_object") if transport else None
|
|
if ssl_object is None:
|
|
return
|
|
evidence.tls_version = ssl_object.version()
|
|
cipher = ssl_object.cipher()
|
|
evidence.tls_cipher = cipher[0] if cipher else None
|
|
certificate = ssl_object.getpeercert() or {}
|
|
evidence.peer_certificate_not_after = certificate.get("notAfter")
|
|
|
|
|
|
async def expect_close_code(
|
|
websocket: ClientConnection,
|
|
*,
|
|
timeout_seconds: float,
|
|
) -> int:
|
|
try:
|
|
while True:
|
|
await asyncio.wait_for(websocket.recv(), timeout=timeout_seconds)
|
|
except ConnectionClosed as exc:
|
|
return exc.code
|
|
except TimeoutError as exc:
|
|
raise GateFailure("public_wss_close_timeout") from exc
|
|
|
|
|
|
async def unauthenticated_route_probe(
|
|
*,
|
|
wss_url: str,
|
|
origin: str,
|
|
timeout_seconds: float,
|
|
evidence: ExternalSoakEvidence,
|
|
) -> None:
|
|
probe_url = with_session_id(wss_url, "g7-external-readonly-probe")
|
|
websocket = await connect(
|
|
probe_url,
|
|
origin=origin,
|
|
open_timeout=min(timeout_seconds, 15),
|
|
close_timeout=5,
|
|
ping_interval=None,
|
|
max_size=1_048_576,
|
|
)
|
|
record_network_metadata(websocket, evidence)
|
|
raw = await asyncio.wait_for(websocket.recv(), timeout=min(timeout_seconds, 15))
|
|
payload = json_frame(raw)
|
|
if payload != {"type": "error", "detail": "not authenticated"}:
|
|
raise GateFailure("public_wss_unauthenticated_contract_drift")
|
|
evidence.unauthenticated_handshake_accepted = True
|
|
evidence.unauthenticated_close_code = await expect_close_code(
|
|
websocket,
|
|
timeout_seconds=min(timeout_seconds, 15),
|
|
)
|
|
if evidence.unauthenticated_close_code != 1008:
|
|
raise GateFailure("public_wss_unauthenticated_close_drift")
|
|
|
|
|
|
async def open_authenticated(
|
|
*,
|
|
wss_url: str,
|
|
origin: str,
|
|
session_id: str,
|
|
cookie_name: str,
|
|
cookie_value: str,
|
|
timeout_seconds: float,
|
|
evidence: ExternalSoakEvidence,
|
|
expected_stt_provider: str,
|
|
expected_stt_model: str,
|
|
expected_tts_provider: str,
|
|
expected_tts_model: str,
|
|
) -> ClientConnection:
|
|
if any(character in cookie_value for character in "\r\n;"):
|
|
raise GateBlocked(["authenticated_session_cookie_invalid"])
|
|
websocket = await connect(
|
|
with_session_id(wss_url, session_id),
|
|
origin=origin,
|
|
additional_headers={"Cookie": f"{cookie_name}={cookie_value}"},
|
|
open_timeout=min(timeout_seconds, 15),
|
|
close_timeout=10,
|
|
ping_interval=20,
|
|
ping_timeout=20,
|
|
max_size=32 * 1024 * 1024,
|
|
max_queue=16,
|
|
)
|
|
record_network_metadata(websocket, evidence)
|
|
try:
|
|
ready, _ = await receive_type(
|
|
websocket,
|
|
"ready",
|
|
timeout_seconds=min(timeout_seconds, 30),
|
|
)
|
|
if ready.get("session_id") != session_id or ready.get("state") != "idle":
|
|
raise GateFailure("authenticated_public_wss_ready_drift")
|
|
validate_ready_provider_metadata(
|
|
ready,
|
|
evidence,
|
|
expected_stt_provider=expected_stt_provider,
|
|
expected_stt_model=expected_stt_model,
|
|
expected_tts_provider=expected_tts_provider,
|
|
expected_tts_model=expected_tts_model,
|
|
)
|
|
except Exception:
|
|
await websocket.close()
|
|
raise
|
|
evidence.authenticated_public_wss_ready = True
|
|
return websocket
|
|
|
|
|
|
async def application_ping(
|
|
websocket: ClientConnection,
|
|
evidence: ExternalSoakEvidence,
|
|
*,
|
|
timeout_seconds: float,
|
|
) -> None:
|
|
await websocket.send(json.dumps({"type": "ping"}))
|
|
await receive_type(
|
|
websocket,
|
|
"pong",
|
|
timeout_seconds=min(timeout_seconds, 30),
|
|
)
|
|
evidence.application_pongs += 1
|
|
|
|
|
|
async def run_audio_turn(
|
|
websocket: ClientConnection,
|
|
audio: bytes,
|
|
evidence: ExternalSoakEvidence,
|
|
*,
|
|
timeout_seconds: float,
|
|
chunk_bytes: int = DEFAULT_AUDIO_CHUNK_BYTES,
|
|
) -> None:
|
|
evidence.turns_attempted += 1
|
|
turn_started_at = time.monotonic()
|
|
await websocket.send(
|
|
json.dumps(
|
|
{
|
|
"type": "audio_start",
|
|
"format": "pcm",
|
|
"sample_rate": 16_000,
|
|
"channels": 1,
|
|
"sample_width": 2,
|
|
}
|
|
)
|
|
)
|
|
listening, _ = await receive_type(
|
|
websocket,
|
|
"state",
|
|
timeout_seconds=min(timeout_seconds, 30),
|
|
)
|
|
if listening.get("state") != "listening":
|
|
raise GateFailure("public_wss_listening_state_drift")
|
|
for start in range(0, len(audio), chunk_bytes):
|
|
await websocket.send(audio[start : start + chunk_bytes])
|
|
await websocket.send(
|
|
json.dumps(
|
|
{
|
|
"type": "audio_end",
|
|
"format": "pcm",
|
|
"sample_rate": 16_000,
|
|
"channels": 1,
|
|
"sample_width": 2,
|
|
}
|
|
)
|
|
)
|
|
observed_at: list[float] = []
|
|
frames = await receive_until(
|
|
websocket,
|
|
lambda _raw, payload: (
|
|
payload is not None
|
|
and payload.get("type") == "state"
|
|
and payload.get("state") == "idle"
|
|
),
|
|
timeout_seconds=timeout_seconds,
|
|
observed_at=observed_at,
|
|
)
|
|
payloads_with_observed_at = [
|
|
(payload, frame_observed_at)
|
|
for raw, frame_observed_at in zip(frames, observed_at, strict=True)
|
|
if (payload := json_frame(raw)) is not None
|
|
]
|
|
payloads = [payload for payload, _observed_at in payloads_with_observed_at]
|
|
types = [str(payload.get("type")) for payload in payloads]
|
|
transcript_metrics = record_turn_transcript_metrics(
|
|
evidence,
|
|
payloads_with_observed_at=payloads_with_observed_at,
|
|
turn_started_at=turn_started_at,
|
|
)
|
|
if transcript_metrics.interim_transcript_frames < 1:
|
|
raise GateFailure("public_wss_interim_transcript_missing")
|
|
if transcript_metrics.speech_final_transcript_frames < 1:
|
|
raise GateFailure("public_wss_speech_final_transcript_missing")
|
|
if transcript_metrics.speech_final_transcript_frames > 1:
|
|
raise GateFailure("public_wss_speech_final_transcript_duplicate")
|
|
required = {"reply", "tts_end"}
|
|
if not required.issubset(types):
|
|
raise GateFailure("public_wss_turn_contract_incomplete")
|
|
binary_frames = [raw for raw in frames if isinstance(raw, bytes)]
|
|
if not binary_frames:
|
|
raise GateFailure("public_wss_tts_binary_missing")
|
|
evidence.reply_frames += types.count("reply")
|
|
evidence.tts_end_frames += types.count("tts_end")
|
|
evidence.tts_binary_frames += len(binary_frames)
|
|
evidence.tts_binary_bytes += sum(len(raw) for raw in binary_frames)
|
|
evidence.turns_succeeded += 1
|
|
|
|
|
|
async def wait_with_heartbeats(
|
|
websocket: ClientConnection,
|
|
evidence: ExternalSoakEvidence,
|
|
*,
|
|
seconds: float,
|
|
heartbeat_seconds: float,
|
|
timeout_seconds: float,
|
|
) -> None:
|
|
deadline = time.monotonic() + max(0.0, seconds)
|
|
while time.monotonic() < deadline:
|
|
remaining = deadline - time.monotonic()
|
|
await asyncio.sleep(min(remaining, heartbeat_seconds))
|
|
if time.monotonic() < deadline or seconds >= heartbeat_seconds:
|
|
await application_ping(
|
|
websocket,
|
|
evidence,
|
|
timeout_seconds=timeout_seconds,
|
|
)
|
|
|
|
|
|
def external_blockers(
|
|
*,
|
|
cookie_present: bool,
|
|
session_id_present: bool,
|
|
device_enumerated: bool,
|
|
capture_confirmed: bool,
|
|
microphone_rms_dbfs: float | None,
|
|
minimum_rms_dbfs: float,
|
|
) -> list[str]:
|
|
blockers: list[str] = []
|
|
if not cookie_present:
|
|
blockers.append("authenticated_session_cookie_missing")
|
|
if not session_id_present:
|
|
blockers.append("owned_voice_session_id_missing")
|
|
if not device_enumerated:
|
|
blockers.append("physical_microphone_device_not_enumerated")
|
|
if not capture_confirmed:
|
|
blockers.append("physical_microphone_capture_not_confirmed")
|
|
elif microphone_rms_dbfs is not None and microphone_rms_dbfs < minimum_rms_dbfs:
|
|
blockers.append("physical_microphone_signal_below_threshold")
|
|
return blockers
|
|
|
|
|
|
async def run_preflight(
|
|
args: argparse.Namespace,
|
|
evidence: ExternalSoakEvidence,
|
|
*,
|
|
cookie_value: str,
|
|
session_id: str,
|
|
) -> None:
|
|
await unauthenticated_route_probe(
|
|
wss_url=args.wss_url,
|
|
origin=args.origin,
|
|
timeout_seconds=args.frame_timeout_seconds,
|
|
evidence=evidence,
|
|
)
|
|
blockers: list[str] = []
|
|
if not cookie_value:
|
|
blockers.append("authenticated_session_cookie_missing")
|
|
if not session_id:
|
|
blockers.append("owned_voice_session_id_missing")
|
|
if blockers:
|
|
raise GateBlocked(blockers)
|
|
websocket = await open_authenticated(
|
|
wss_url=args.wss_url,
|
|
origin=args.origin,
|
|
session_id=session_id,
|
|
cookie_name=args.cookie_name,
|
|
cookie_value=cookie_value,
|
|
timeout_seconds=args.frame_timeout_seconds,
|
|
evidence=evidence,
|
|
expected_stt_provider=args.expected_stt_provider,
|
|
expected_stt_model=args.expected_stt_model,
|
|
expected_tts_provider=args.expected_tts_provider,
|
|
expected_tts_model=args.expected_tts_model,
|
|
)
|
|
try:
|
|
await application_ping(
|
|
websocket,
|
|
evidence,
|
|
timeout_seconds=args.frame_timeout_seconds,
|
|
)
|
|
await websocket.send(json.dumps({"type": "close"}))
|
|
evidence.close_code = await expect_close_code(
|
|
websocket,
|
|
timeout_seconds=min(args.frame_timeout_seconds, 30),
|
|
)
|
|
if evidence.close_code != 1000:
|
|
raise GateFailure("authenticated_public_wss_close_drift")
|
|
finally:
|
|
await websocket.close()
|
|
|
|
|
|
async def run_soak(
|
|
args: argparse.Namespace,
|
|
evidence: ExternalSoakEvidence,
|
|
*,
|
|
cookie_value: str,
|
|
session_id: str,
|
|
) -> None:
|
|
blockers = []
|
|
if not cookie_value:
|
|
blockers.append("authenticated_session_cookie_missing")
|
|
if not session_id:
|
|
blockers.append("owned_voice_session_id_missing")
|
|
if not args.confirm_physical_capture:
|
|
blockers.append("physical_microphone_capture_not_confirmed")
|
|
if blockers:
|
|
raise GateBlocked(blockers)
|
|
evidence.physical_capture_confirmed = True
|
|
devices = await asyncio.to_thread(dshow_audio_devices, ffmpeg=args.ffmpeg)
|
|
evidence.microphone_device_enumerated = args.microphone_device in devices
|
|
if not evidence.microphone_device_enumerated:
|
|
raise GateBlocked(["physical_microphone_device_not_enumerated"])
|
|
websocket = await open_authenticated(
|
|
wss_url=args.wss_url,
|
|
origin=args.origin,
|
|
session_id=session_id,
|
|
cookie_name=args.cookie_name,
|
|
cookie_value=cookie_value,
|
|
timeout_seconds=args.frame_timeout_seconds,
|
|
evidence=evidence,
|
|
expected_stt_provider=args.expected_stt_provider,
|
|
expected_stt_model=args.expected_stt_model,
|
|
expected_tts_provider=args.expected_tts_provider,
|
|
expected_tts_model=args.expected_tts_model,
|
|
)
|
|
started = time.monotonic()
|
|
deadline = started + args.duration_seconds
|
|
try:
|
|
await application_ping(
|
|
websocket,
|
|
evidence,
|
|
timeout_seconds=args.frame_timeout_seconds,
|
|
)
|
|
while time.monotonic() < deadline or evidence.turns_attempted == 0:
|
|
audio, metrics = await asyncio.to_thread(
|
|
capture_microphone_pcm,
|
|
ffmpeg=args.ffmpeg,
|
|
microphone_device=args.microphone_device,
|
|
capture_seconds=args.capture_seconds,
|
|
)
|
|
record_pcm_metrics(evidence, metrics)
|
|
if metrics.rms_dbfs < args.min_rms_dbfs:
|
|
raise GateBlocked(["physical_microphone_signal_below_threshold"])
|
|
await run_audio_turn(
|
|
websocket,
|
|
audio,
|
|
evidence,
|
|
timeout_seconds=args.frame_timeout_seconds,
|
|
)
|
|
remaining = deadline - time.monotonic()
|
|
if remaining > 0:
|
|
await wait_with_heartbeats(
|
|
websocket,
|
|
evidence,
|
|
seconds=min(remaining, args.turn_interval_seconds),
|
|
heartbeat_seconds=args.heartbeat_seconds,
|
|
timeout_seconds=args.frame_timeout_seconds,
|
|
)
|
|
evidence.elapsed_seconds = round(time.monotonic() - started, 3)
|
|
if evidence.elapsed_seconds < args.duration_seconds:
|
|
raise GateFailure("public_wss_soak_ended_early")
|
|
await websocket.send(json.dumps({"type": "close"}))
|
|
evidence.close_code = await expect_close_code(
|
|
websocket,
|
|
timeout_seconds=min(args.frame_timeout_seconds, 30),
|
|
)
|
|
if evidence.close_code != 1000:
|
|
raise GateFailure("authenticated_public_wss_close_drift")
|
|
finally:
|
|
await websocket.close()
|
|
|
|
|
|
def parser() -> argparse.ArgumentParser:
|
|
result = argparse.ArgumentParser(
|
|
description=(
|
|
"Physical-microphone and authenticated public /voice/ws soak. "
|
|
"Production requires at least 3,120 seconds so the concurrent "
|
|
"proof retains a 3,000-second common window."
|
|
)
|
|
)
|
|
result.add_argument("--preflight-only", action="store_true")
|
|
result.add_argument("--wss-url", default=DEFAULT_WSS_URL)
|
|
result.add_argument("--origin", default=DEFAULT_ORIGIN)
|
|
result.add_argument("--cookie-env", default=DEFAULT_COOKIE_ENV)
|
|
result.add_argument("--session-env", default=DEFAULT_SESSION_ENV)
|
|
result.add_argument("--cookie-name", default=DEFAULT_COOKIE_NAME)
|
|
result.add_argument("--session-id")
|
|
result.add_argument("--expected-stt-provider", required=True)
|
|
result.add_argument("--expected-stt-model", required=True)
|
|
result.add_argument("--expected-tts-provider", required=True)
|
|
result.add_argument("--expected-tts-model", required=True)
|
|
result.add_argument(
|
|
"--microphone-device",
|
|
help="required for full soak; ignored by --preflight-only",
|
|
)
|
|
result.add_argument(
|
|
"--confirm-physical-capture",
|
|
action="store_true",
|
|
help="explicitly authorize opening the named microphone for this run",
|
|
)
|
|
result.add_argument("--ffmpeg", default=shutil.which("ffmpeg") or "ffmpeg")
|
|
result.add_argument(
|
|
"--duration-seconds", type=float, default=DEFAULT_DURATION_SECONDS
|
|
)
|
|
result.add_argument(
|
|
"--turn-interval-seconds",
|
|
type=float,
|
|
default=DEFAULT_TURN_INTERVAL_SECONDS,
|
|
)
|
|
result.add_argument(
|
|
"--heartbeat-seconds",
|
|
type=float,
|
|
default=DEFAULT_HEARTBEAT_SECONDS,
|
|
)
|
|
result.add_argument(
|
|
"--capture-seconds",
|
|
type=float,
|
|
default=DEFAULT_CAPTURE_SECONDS,
|
|
)
|
|
result.add_argument("--min-rms-dbfs", type=float, default=DEFAULT_MIN_RMS_DBFS)
|
|
result.add_argument(
|
|
"--frame-timeout-seconds",
|
|
type=float,
|
|
default=DEFAULT_FRAME_TIMEOUT_SECONDS,
|
|
)
|
|
result.add_argument("--evidence-output", type=Path)
|
|
return result
|
|
|
|
|
|
def validate_args(args: argparse.Namespace, cli: argparse.ArgumentParser) -> None:
|
|
try:
|
|
with_session_id(args.wss_url, "validation")
|
|
except ValueError as exc:
|
|
cli.error(str(exc))
|
|
if args.duration_seconds <= 0:
|
|
cli.error("--duration-seconds must be > 0")
|
|
if (
|
|
not args.preflight_only
|
|
and args.duration_seconds < MIN_PRODUCTION_DURATION_SECONDS
|
|
):
|
|
cli.error(
|
|
"--duration-seconds must be >= 3120 for a production physical soak"
|
|
)
|
|
if args.turn_interval_seconds <= 0:
|
|
cli.error("--turn-interval-seconds must be > 0")
|
|
if args.heartbeat_seconds <= 0:
|
|
cli.error("--heartbeat-seconds must be > 0")
|
|
if args.capture_seconds <= 0:
|
|
cli.error("--capture-seconds must be > 0")
|
|
if not -120 <= args.min_rms_dbfs <= 0:
|
|
cli.error("--min-rms-dbfs must be between -120 and 0")
|
|
if args.frame_timeout_seconds <= 0:
|
|
cli.error("--frame-timeout-seconds must be > 0")
|
|
if not args.cookie_env or not args.session_env or not args.cookie_name:
|
|
cli.error("cookie/session environment names and cookie name must be non-empty")
|
|
for field_name in (
|
|
"expected_stt_provider",
|
|
"expected_stt_model",
|
|
"expected_tts_provider",
|
|
"expected_tts_model",
|
|
):
|
|
value = getattr(args, field_name)
|
|
if (
|
|
not value.strip()
|
|
or len(value.strip()) > 160
|
|
or any(
|
|
ord(character) < 32 or ord(character) == 127
|
|
for character in value.strip()
|
|
)
|
|
):
|
|
option_name = "--" + field_name.replace("_", "-")
|
|
cli.error(f"{option_name} must be a non-empty provider/model identifier")
|
|
setattr(args, field_name, value.strip())
|
|
if not args.preflight_only and not (args.microphone_device or "").strip():
|
|
cli.error("--microphone-device is required for full soak")
|
|
|
|
|
|
def evidence_json(evidence: ExternalSoakEvidence) -> str:
|
|
return json.dumps(asdict(evidence), ensure_ascii=False, indent=2, sort_keys=True)
|
|
|
|
|
|
def emit_evidence(
|
|
evidence: ExternalSoakEvidence,
|
|
output: Path | None,
|
|
) -> None:
|
|
payload = evidence_json(evidence) + "\n"
|
|
if output is not None:
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_text(payload, encoding="utf-8")
|
|
print(payload, end="")
|
|
|
|
|
|
def main() -> int:
|
|
cli = parser()
|
|
args = cli.parse_args()
|
|
validate_args(args, cli)
|
|
session_id = (args.session_id or os.getenv(args.session_env, "")).strip()
|
|
cookie_value = os.getenv(args.cookie_env, "").strip()
|
|
host, path = target_metadata(args.wss_url)
|
|
evidence = ExternalSoakEvidence(
|
|
mode="preflight" if args.preflight_only else "public_soak",
|
|
target_host=host,
|
|
target_path=path,
|
|
origin=args.origin,
|
|
requested_duration_seconds=(
|
|
0.0 if args.preflight_only else args.duration_seconds
|
|
),
|
|
microphone_device=("" if args.preflight_only else args.microphone_device),
|
|
expected_stt_provider=args.expected_stt_provider,
|
|
expected_stt_model=args.expected_stt_model,
|
|
expected_tts_provider=args.expected_tts_provider,
|
|
expected_tts_model=args.expected_tts_model,
|
|
cookie_env=args.cookie_env,
|
|
cookie_present=bool(cookie_value),
|
|
session_id_present=bool(session_id),
|
|
session_id_sha256=sha256_text(session_id) if session_id else None,
|
|
)
|
|
try:
|
|
if args.preflight_only:
|
|
asyncio.run(
|
|
run_preflight(
|
|
args,
|
|
evidence,
|
|
cookie_value=cookie_value,
|
|
session_id=session_id,
|
|
)
|
|
)
|
|
else:
|
|
asyncio.run(
|
|
run_soak(
|
|
args,
|
|
evidence,
|
|
cookie_value=cookie_value,
|
|
session_id=session_id,
|
|
)
|
|
)
|
|
evidence.status = "passed"
|
|
evidence.ended_at_utc = utc_now()
|
|
emit_evidence(evidence, args.evidence_output)
|
|
return 0
|
|
except GateBlocked as exc:
|
|
evidence.status = "blocked"
|
|
evidence.blockers = exc.blockers
|
|
evidence.ended_at_utc = utc_now()
|
|
emit_evidence(evidence, args.evidence_output)
|
|
return 3
|
|
except Exception as exc:
|
|
evidence.status = "failed"
|
|
evidence.failure_type = (
|
|
str(exc) if isinstance(exc, GateFailure) else type(exc).__name__
|
|
)
|
|
evidence.ended_at_utc = utc_now()
|
|
emit_evidence(evidence, args.evidence_output)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|