vignette/scripts/soak-public-voice-websocket.py

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())