diff --git a/CHANGELOG.md b/CHANGELOG.md index 50771571..805aff82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,9 +41,14 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently. ### Fixed +- Custom MLX model IDs and saved voice instructions are now validated in bounded time, so malformed input cannot stall the backend. (#1446) +- Streaming and provider failures now return stable recovery guidance without exposing exception details. (#1462) +- Server-mode settings mutations require the admin API key, while host destinations and executable paths can only be selected through the native desktop app. (#1448) +- Automatic model-mirror checks now reject untrusted URLs before opening a network connection. (#1447) - Sidecar engines no longer break when a library they load prints to the console. Those bytes landed in the middle of the engine's data stream, failing the generation and leaving the connection scrambled for every request after it. (#1428) — thanks @1335-Group! - A generation abandoned while stuck on an internal lock now says so, instead of blaming your hardware and suggesting shorter text. Nothing had been computed, so none of that advice applied. (#1416, #1419) - A machine with a GPU that ends up on CPU now says why — a missing device node, a permissions problem, a card newer than the installed ROCm, an `HSA_OVERRIDE_GFX_VERSION` that is doing more harm than good, or an NVIDIA driver the container can't reach each read differently. Before, all of them looked identical to having no GPU at all. (#1274, #1228) +- The first generation on an engine that still has to install itself no longer gives up part-way. The install reports progress now, so the generation waits for it instead of hitting its own five-minute limit. (#1414) - A slow machine is no longer told its IndexTTS-2 install isn't there. The check that confirms an engine's virtualenv gave up after 10 seconds and counted that as a broken install, so a cold first run 500'd; it now waits longer and treats slow as unproven, not broken. (#1414) — thanks @OracleNightmare! - A broken Python environment now says so, instead of blaming the app's own install. A missing or mismatched torch/transformers surfaced as "omnivoice not importable" and sent people reinstalling the wrong thing. (#1415) - A model that fails to load at startup no longer leaves the app looking healthy while producing nothing — the failure and its remedy now show up in the model status. (#1415) diff --git a/backend/api/dependencies.py b/backend/api/dependencies.py index 83bf17a5..2c8bc394 100644 --- a/backend/api/dependencies.py +++ b/backend/api/dependencies.py @@ -186,6 +186,45 @@ def require_loopback(request: Request) -> None: raise HTTPException(status_code=403, detail="loopback origin required") +def require_admin(request: Request) -> None: + """Gate RCE/filesystem-capable admin routers. + + Desktop callers keep the loopback-only contract. Docker cannot reliably + observe the host operator as loopback, so authenticated remote admin stays + available there, but every state-changing request must present the long API + key. An unconfigured server must never expose executable-path or filesystem + settings to every client that can reach its published port. + + Read-only requests retain the bare-Docker bootstrap behaviour until an API + key is configured. Share PINs and trusted CIDRs are consumption credentials; + neither authorizes this gate. + """ + host = request.client.host if request.client else None + if is_loopback(host): + return + if _server_mode(): + method = str(getattr(request, "method", "GET")).upper() + read_only = method in {"GET", "HEAD", "OPTIONS"} + if read_only and not os.environ.get("OMNIVOICE_API_KEY", "").strip(): + return + if _request_presents_admin_credential(request): + return + raise HTTPException(status_code=403, detail="loopback origin or admin API key required") + + +def require_desktop(request: Request) -> None: + """Gate capabilities that may select or execute host filesystem paths. + + An API key authorizes remote administration, not access to the desktop + shell's native file-picker boundary. These capabilities therefore remain + strictly loopback-only even when server mode is enabled. + """ + host = request.client.host if request.client else None + if is_loopback(host): + return + raise HTTPException(status_code=403, detail="desktop origin required") + + def require_local(request: Request) -> None: """Reject any request whose client.host is not loopback OR on a configured trusted network. The consumption-tier companion to :func:`require_loopback`: diff --git a/backend/api/routers/dub_core.py b/backend/api/routers/dub_core.py index 5198bacd..1821ca8f 100644 --- a/backend/api/routers/dub_core.py +++ b/backend/api/routers/dub_core.py @@ -734,7 +734,7 @@ async def dub_transcribe_stream( preflight_error = asr_model_missing_detail(e.payload) preflight_payload = e.payload except Exception as e: - logger.exception("transcribe preflight: ASR load failed (job=%r)", job_id) + logger.error("Transcription preflight ASR load failed") from core.failure import build_failure f = build_failure(e, stage="transcribe-preflight", include_diagnostic=False) preflight_error = "ASR backend initialization failed: " + f["reason"] + ( @@ -765,9 +765,10 @@ async def dub_transcribe_stream( try: audio_np, sr = await loop.run_in_executor(_cpu_pool, _load) - except Exception as e: + except Exception: # Terminal error → always emit `done` (see preflight note, #578). - yield _sse_event("error", {"detail": f"audio load failed: {e}", "retryable": True}) + from core.public_errors import stream_failure + yield _sse_event("error", stream_failure("transcription_failed")) yield _sse_event("done", {}) return @@ -867,9 +868,16 @@ async def dub_transcribe_stream( continue turns.append({"start": s0 + offset, "end": s1 + offset, "speaker": spk}) return {"chunks": shifted, "language": r.get("language"), "speaker_turns": turns} - except Exception as e: - logger.exception("chunk transcribe failed (backend=%s)", _asr_backend.id) - return {"chunks": [], "language": None, "error": str(e)} + except Exception: + logger.error("Chunk transcription failed (backend=%s)", _asr_backend.id) + from core.public_errors import stream_failure + failure = stream_failure("transcription_failed") + return { + "chunks": [], + "language": None, + "error": failure["detail"], + "error_code": failure["code"], + } # Retry a failed/timed-out chunk once on a fresh pool before giving # up. Otherwise a transient wedge on the FIRST chunk (whisperx often @@ -900,7 +908,7 @@ async def dub_transcribe_stream( yield _sse_event("ping", {}) try: part = task.result() - except ASRTimeoutError as e: + except ASRTimeoutError: # The guard already reset the pool; keep the actionable # message (it names the durable fixes, and — after repeated # timeouts — the crash-isolated engine escape hatch). @@ -910,7 +918,14 @@ async def dub_transcribe_stream( i + 1, chunks_n, transcribe_timeout_s, _attempt, _CHUNK_TRANSCRIBE_ATTEMPTS, job_id, ) - part = {"chunks": [], "language": None, "error": str(e)} + from core.public_errors import stream_failure + failure = stream_failure("transcription_timeout") + part = { + "chunks": [], + "language": None, + "error": failure["detail"], + "error_code": failure["code"], + } # Success → keep it. Failure/timeout → retry once on a fresh # worker (the internal _transcribe_chunk except returns an # error-part; the timeout path already reset the pool). @@ -964,6 +979,7 @@ async def dub_transcribe_stream( "segments": chunk_segs, "progress": (i + 1) / chunks_n, "error": part.get("error"), + "error_code": part.get("error_code"), }) if job.get("aborted"): @@ -1447,12 +1463,10 @@ async def dub_transcribe_stream( try: async for ev in _gen_body(): yield ev - except Exception as e: # noqa: BLE001 — last-resort stream finalizer - logger.exception("transcribe stream crashed (job=%r)", job_id) - from core.failure import build_failure - f = build_failure(e, stage="transcribe", include_diagnostic=False) - detail = f["reason"] + (f" — {f['hint']}" if f.get("hint") else "") - yield _sse_event("error", {"detail": detail, "retryable": True}) + except Exception: # noqa: BLE001 — last-resort stream finalizer + logger.error("Transcription stream failed unexpectedly") + from core.public_errors import stream_failure + yield _sse_event("error", stream_failure("transcription_failed")) yield _sse_event("done", {}) finally: # Last-resort VRAM release (see _loaded_asr above): covers crashes, diff --git a/backend/api/routers/engines.py b/backend/api/routers/engines.py index 774b7f84..8da81e5c 100644 --- a/backend/api/routers/engines.py +++ b/backend/api/routers/engines.py @@ -16,11 +16,12 @@ Environment variables (`OMNIVOICE_TTS_BACKEND`, `OMNIVOICE_ASR_BACKEND`, a backend without Settings silently undoing it. """ import os -import re import threading from time import perf_counter from fastapi import APIRouter, Depends, HTTPException +from huggingface_hub import utils as hf_utils +from huggingface_hub.errors import HFValidationError from pydantic import BaseModel from api.dependencies import require_loopback @@ -37,6 +38,16 @@ _FAMILIES = { "llm": (llm_backend, "llm_backend"), } +def _is_hf_repo_id(value: str) -> bool: + """Validate the route's ``owner/repo`` contract in bounded time.""" + if not isinstance(value, str) or len(value) > 96 or value.count("/") != 1: + return False + try: + hf_utils.validate_repo_id(value) + except (HFValidationError, TypeError): + return False + return True + @router.get("/engines") def list_all_engines(): @@ -579,11 +590,11 @@ def select_engine(req: SelectEngineRequest): # Anything else (typo'd key, malformed id) is rejected outright # rather than silently persisted as a "custom repo" that then fails # to resolve at load time. - if req.model_id not in known_keys and not re.fullmatch(r"[\w.-]+/[\w.-]+", req.model_id): + if req.model_id not in known_keys and not _is_hf_repo_id(req.model_id): raise HTTPException( 400, - f"Unknown mlx-audio model: {req.model_id!r}. Expected one of " - f"{sorted(known_keys)} or a HF repo id like 'owner/name'.", + "Unknown mlx-audio model. Expected a curated model key or a " + "Hugging Face repo ID like 'owner/name'.", ) prefs.set_("mlx_audio_model_id", req.model_id) prefs.set_(pref_key, req.backend_id) diff --git a/backend/api/routers/generation.py b/backend/api/routers/generation.py index d6346f97..3b7f5d82 100644 --- a/backend/api/routers/generation.py +++ b/backend/api/routers/generation.py @@ -1538,17 +1538,19 @@ async def generate_speech( # In-band error frame carries the machine-readable retryable # marker (#1190) — an NDJSON consumer can back off instead of # guessing from the prose. - logger.error("Streaming generate timed out: %s", e) - yield _line({ - "type": "error", "detail": str(e), "retryable": True, - "retry_after": getattr(e, "retry_after", 30), - }) - except ValueError as e: - logger.error("Streaming generate validation failed: %s", e) - yield _line({"type": "error", "detail": str(e)}) - except Exception as e: - logger.error("Streaming generate failed: %s\n%s", e, traceback.format_exc()) - yield _line({"type": "error", "detail": _safe_exc_text(e)}) + logger.error("Streaming generation capacity unavailable") + from core.public_errors import stream_failure + failure = stream_failure("generation_busy") + failure["retry_after"] = getattr(e, "retry_after", 30) + yield _line({"type": "error", **failure}) + except ValueError: + logger.error("Streaming generation request rejected") + from core.public_errors import stream_failure + yield _line({"type": "error", **stream_failure("invalid_request")}) + except Exception: + logger.error("Streaming generation failed unexpectedly") + from core.public_errors import stream_failure + yield _line({"type": "error", **stream_failure("generation_failed")}) finally: # Ownership of the temp reference clip moves to this generator # in stream mode (the route returns before rendering starts). diff --git a/backend/api/routers/media_tools.py b/backend/api/routers/media_tools.py index 95a93e38..770cd865 100644 --- a/backend/api/routers/media_tools.py +++ b/backend/api/routers/media_tools.py @@ -19,7 +19,7 @@ router = APIRouter(dependencies=[Depends(require_loopback)]) class CustomPathRequest(BaseModel): - path: str + authorization: str def _svc(): @@ -61,8 +61,13 @@ def media_tools_ytdlp_restore(): @router.post("/media-tools/{tool}/custom-path") def media_tools_custom_path(tool: str, body: CustomPathRequest): + from core.path_authorization import PathAuthorizationError, consume + try: - return _svc().set_custom_path(tool, body.path) + path = consume(body.authorization, tool) + return _svc().set_custom_path(tool, path) + except PathAuthorizationError as e: + raise HTTPException(status_code=403, detail=str(e)) from e except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/api/routers/settings.py b/backend/api/routers/settings.py index b4394f50..10290140 100644 --- a/backend/api/routers/settings.py +++ b/backend/api/routers/settings.py @@ -1,11 +1,10 @@ """Settings API — HF token save/clear/state endpoints (Phase 1 AUTH-03 backend half). These endpoints are the backend half of the Wave 2 Settings → API Keys -panel. Threat T-01-03 mitigation: every write endpoint is gated by the -router-level `require_loopback` dep, so non-loopback origins get 403 -before the handler runs. Reads are loopback-gated too — the masked -token preview is useful telemetry that we still don't want exposed on -the LAN. +panel. Threat T-01-03 mitigation: the router-level `require_admin` dependency +keeps desktop callers loopback-only and requires the long API key for every +remote server-mode mutation. Read-only bare-Docker discovery remains available +until an API key is configured; once configured, reads require it too. The state endpoint duplicates `/system/hf-token/state` (which lives on `system.py` for legacy-router compatibility); both return the same shape. @@ -20,14 +19,14 @@ from dataclasses import asdict from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field -from api.dependencies import require_loopback +from api.dependencies import require_admin logger = logging.getLogger("omnivoice.api.settings") router = APIRouter( prefix="/api/settings", tags=["settings"], - dependencies=[Depends(require_loopback)], + dependencies=[Depends(require_admin)], ) @@ -445,42 +444,20 @@ def test_llm_provider(provider_id: str): "reply": reply[:80], "latency_ms": int((_time.monotonic() - t0) * 1000), } - except Exception as e: # noqa: BLE001 — surface a clean, scrubbed error to the UI + except Exception as e: # noqa: BLE001 — classify without exposing diagnostics kind = _classify_llm_error(e) - detail = _scrub_llm_detail(e, api_key) - # A 404 from a LOCAL server is almost never a wrong URL — the request - # reached it — it is a model name the server does not have loaded. The - # generic "check the model name and Base URL path" sends the user to - # audit a URL that works. Ask what IS loaded and say so (#1332). + from core.public_errors import provider_failure + failure = provider_failure(kind) + # A successful local catalog probe proves the cached model is stale. + # Invalidate it, but never include catalog or exception text in the + # response: both are controlled by the provider. if kind == "not_found" and p.local: available = _local_models(base_url, api_key) - asked = llm_providers.resolve_model(p) - if available: - detail = ( - f"{p.display_name} is running, but has no model named " - f"{asked!r}. Loaded right now: {', '.join(available[:10])}" - + (" …" if len(available) > 10 else "") - + ". Pick one in the Model field above." - ) - # The cached discovery, if any, produced a name this server - # rejects — most likely the user swapped the loaded model - # inside the local app. Drop it so the next attempt re-asks - # rather than repeating the same 404 until the TTL expires. + if available is not None: llm_providers.forget_discovered_models(p.id) - elif available == []: - detail = ( - f"{p.display_name} is running, but reports no loaded models, " - f"so {asked!r} cannot be served. Load a model in " - f"{p.display_name} first, then test again." - ) - llm_providers.forget_discovered_models(p.id) - # available is None: the model listing itself failed, so nothing - # here is established. Keep the generic 404 text rather than - # inventing a diagnosis the lookup did not support. return { "ok": False, - "kind": kind, - "detail": detail, + **failure, "latency_ms": int((_time.monotonic() - t0) * 1000), } @@ -529,10 +506,10 @@ def list_llm_provider_models(provider_id: str): # can say "first 200 shown" rather than implying it's the full list. return {"ok": True, "models": ids[:200], "truncated": len(ids) > 200} except Exception as e: # noqa: BLE001 + from core.public_errors import provider_failure return { "ok": False, - "kind": _classify_llm_error(e), - "detail": _scrub_llm_detail(e, api_key), + **provider_failure(_classify_llm_error(e)), "models": [], } @@ -685,7 +662,7 @@ def _effective_models_dir() -> str: class _ModelsDirBody(BaseModel): - path: str = Field(default="", description="Absolute directory; empty clears → default cache") + authorization: str = Field(description="One-shot native desktop authorization") @router.get("/storage/models-dir") @@ -714,17 +691,18 @@ def set_models_dir(body: _ModelsDirBody): saved. Returns restart_required=True. """ from core import user_env + from core.path_authorization import PathAuthorizationError, consume - raw = (body.path or "").strip() + try: + raw = consume(body.authorization, "models_dir").strip() + except PathAuthorizationError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc if not raw: user_env.unset_user_env(_MODELS_DIR_ENV) return {"configured": None, "default": _default_models_dir(), "restart_required": True} - # Reject control characters / NUL before touching the filesystem: an - # embedded NUL makes os.makedirs raise ValueError (→ 500). This is also - # the input-validation barrier for the path before it reaches any fs call - # (the dir is user-chosen by design — this is a loopback-gated, same-user - # local file picker, not a cross-privilege boundary). + # Tauri already validates this before issuing the capability. Keep the + # backend checks as defense in depth against a corrupt capability file. if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in raw): raise HTTPException(status_code=400, detail="Path contains invalid control characters") diff --git a/backend/api/routers/system.py b/backend/api/routers/system.py index 89fc1856..12a6b995 100644 --- a/backend/api/routers/system.py +++ b/backend/api/routers/system.py @@ -11,7 +11,7 @@ from core.prefs import set_ as prefs_set, delete as prefs_delete from services import network_share from services import tailscale as _tailscale from api.schemas import SysinfoResponse, SystemInfoResponse, ModelStatusResponse -from api.dependencies import require_loopback +from api.dependencies import is_loopback, require_admin from fastapi.responses import FileResponse, StreamingResponse import torch import shutil @@ -21,17 +21,16 @@ from core.version import APP_VERSION from services.model_manager import get_model_status, get_best_device, resolve_omnivoice_checkpoint from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg -# Router-level loopback gate. Every route mounted on `router` (GET + POST, -# present and future) is gated by `require_loopback`, which 403s any request -# whose `client.host` is not a loopback address. This closes the same trust +# Router-level admin gate. Every route mounted on `router` (GET + POST, +# present and future) is gated by `require_admin`: desktop requests must be +# loopback; server-mode mutations require the long API key. This closes the trust # boundary that PR #81 only patched on `/system/set-env` and that the # 260518-ivy deferred-items file enumerated for follow-up: /model/unload/*, # /system/logs/clear, /system/logs/tauri/clear, /system/flush-memory, # /clean-audio (POSTs) plus the read-side info-disclosure routes # /system/info, /system/logs, /system/logs/tauri, /system/logs/stream. -# This router only ever serves the local Tauri shell and the dev frontend -# at http://127.0.0.1:3901 — both are loopback origins. -router = APIRouter(dependencies=[Depends(require_loopback)]) +# Native Tauri/dev callers remain loopback and need no credential. +router = APIRouter(dependencies=[Depends(require_admin)]) logger = logging.getLogger("omnivoice.api") # Cache device checks at module load — they don't change at runtime @@ -806,7 +805,6 @@ async def ack_crash(): PERSISTENT_KEYS = { "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", - "FFMPEG_PATH", "FFPROBE_PATH", "TRANSLATE_BASE_URL", "TRANSLATE_API_KEY", "TRANSLATE_MODEL", "DEEPL_API_KEY", "DEEPL_BASE_URL", "MICROSOFT_API_KEY", "MICROSOFT_BASE_URL", @@ -843,7 +841,7 @@ async def set_env_var(body: dict): are set on ``os.environ`` for the running process. The loopback-origin gate that previously lived inline here is now applied - at the router level via `dependencies=[Depends(require_loopback)]` on + at the router level via `dependencies=[Depends(require_admin)]` on `router` — see the top of this file. Every route on this router is gated, including this one. The 403 body and behavior are unchanged. """ @@ -858,23 +856,6 @@ async def set_env_var(body: dict): ) if value: - # Validate executable paths if the user is setting them manually. - # Reject control characters / null bytes (defense-in-depth against - # path-injection), then require an existing regular file. NOTE: this - # endpoint is loopback-only and MUST remain so — a remote caller able - # to set FFMPEG_PATH/FFPROBE_PATH could point it at an arbitrary - # binary (RCE). Network sharing must never expose /system/set-env. - if key in ("FFMPEG_PATH", "FFPROBE_PATH"): - if any(ord(c) < 0x20 or ord(c) == 0x7F for c in value): - raise HTTPException( - status_code=400, - detail="Invalid path: control characters are not allowed", - ) - if not os.path.isfile(value): - raise HTTPException( - status_code=400, - detail=f"File not found: {value}", - ) # Port keys must be a numeric string in the unprivileged range so a # typo can't drop the backend onto a privileged port (<1024) or an # out-of-range value uvicorn would reject at bind time. @@ -1101,12 +1082,21 @@ def quarantine_status(): # ── Network sharing (loopback-only control surface) ────────────────────────── @router.get("/system/network/state") -async def network_state(): +async def network_state(request: Request): st = network_share.get_state() + # PIN-only server mode permits unauthenticated read-only discovery, but the + # PIN is itself a consumption credential. Reveal it only to the native + # loopback UI or to a remote caller that already passed the configured + # long API-key gate. The boolean lets headless dashboards remain useful. + host = request.client.host if request.client else None + may_reveal_pin = is_loopback(host) or bool( + os.environ.get("OMNIVOICE_API_KEY", "").strip() + ) return { "enabled": st.enabled, "share_port": st.share_port, - "pin": st.pin, + "pin": st.pin if may_reveal_pin else None, + "pin_required": bool(st.pin), "lan_addresses": st.lan_addresses, } diff --git a/backend/core/path_authorization.py b/backend/core/path_authorization.py new file mode 100644 index 00000000..86d88620 --- /dev/null +++ b/backend/core/path_authorization.py @@ -0,0 +1,88 @@ +"""Consume one-shot host paths authorized by the native Tauri process. + +The web API never accepts a filesystem destination or executable path. Tauri +validates the user's native IPC request, writes a private capability file, and +only the unguessable capability token crosses loopback HTTP. +""" +from __future__ import annotations + +import json +import os +import re +import secrets +import stat + +from core.config import DATA_DIR + +_TOKEN_RE = re.compile(r"[0-9a-f]{64}\Z") +_KINDS = {"models_dir", "ffmpeg", "ffprobe"} +_AUTH_DIR = os.path.join(DATA_DIR, ".path-authorizations") + + +class PathAuthorizationError(ValueError): + pass + + +def consume(token: str, expected_kind: str) -> str: + """Consume and return a single Tauri-authorized path. + + Capability files are one-shot and opened without following symlinks. Tauri + writes them into the app's private data directory; source/Docker callers + cannot mint a valid token through HTTP. + """ + if expected_kind not in _KINDS or not _TOKEN_RE.fullmatch(token or ""): + raise PathAuthorizationError("Invalid or expired desktop authorization") + root = _AUTH_DIR + candidate = None + try: + for entry in os.scandir(root): + if not _TOKEN_RE.fullmatch(entry.name.removesuffix(".json")): + continue + if not entry.is_file(follow_symlinks=False): + continue + try: + with open(entry.path, "r", encoding="utf-8") as handle: + probe = json.load(handle) + except (OSError, UnicodeError, json.JSONDecodeError): + continue # Ignore corrupt/stale capabilities; they authorize nothing. + if isinstance(probe, dict) and secrets.compare_digest( + str(probe.get("token", "")), token + ): + candidate = entry.path + break + if candidate is None: + raise OSError("capability not found") + claimed = os.path.join(root, f".consuming-{os.getpid()}-{secrets.token_hex(16)}") + os.replace(candidate, claimed) + except OSError as exc: + raise PathAuthorizationError("Invalid or expired desktop authorization") from exc + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + fd = os.open(claimed, flags) + except OSError as exc: + raise PathAuthorizationError("Invalid or expired desktop authorization") from exc + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_size > 16_384: + raise PathAuthorizationError("Invalid desktop authorization") + with os.fdopen(fd, "r", encoding="utf-8") as handle: + fd = -1 + payload = json.load(handle) + except (OSError, UnicodeError, json.JSONDecodeError, TypeError) as exc: + raise PathAuthorizationError("Invalid desktop authorization") from exc + finally: + if fd >= 0: + os.close(fd) + try: + os.unlink(claimed) + except OSError: + pass # Best-effort cleanup; the random claimed name cannot be reused. + if not isinstance(payload, dict): + raise PathAuthorizationError("Invalid desktop authorization") + if not secrets.compare_digest(str(payload.get("token", "")), token): + raise PathAuthorizationError("Invalid desktop authorization") + if payload.get("kind") != expected_kind or not isinstance(payload.get("path"), str): + raise PathAuthorizationError("Desktop authorization does not match this setting") + return payload["path"] diff --git a/backend/core/public_errors.py b/backend/core/public_errors.py new file mode 100644 index 00000000..fec94cd6 --- /dev/null +++ b/backend/core/public_errors.py @@ -0,0 +1,53 @@ +"""Data-independent error metadata safe for API and streaming responses.""" +from __future__ import annotations + +_PROVIDER_DETAILS = { + "auth": "Authentication failed. Check the provider API key.", + "not_found": "Provider or model not found. Check the model and Base URL.", + "rate_limit": "The provider rate limit was reached. Try again later.", + "network": "The provider could not be reached. Check the connection and Base URL.", + "config": "Configure the provider before using it.", + "error": "The provider request failed. Try again.", +} + + +def provider_failure(kind: str) -> dict[str, str]: + """Return a stable provider error class and remediation message.""" + safe_kind = kind if kind in _PROVIDER_DETAILS else "error" + return {"kind": safe_kind, "detail": _PROVIDER_DETAILS[safe_kind]} + + +def stream_failure(code: str) -> dict[str, object]: + """Return stable stream metadata selected only from an internal code.""" + failures: dict[str, dict[str, object]] = { + "generation_busy": { + "code": "generation_busy", + "detail": "Generation capacity is busy. Try again shortly.", + "retryable": True, + }, + "invalid_request": { + "code": "invalid_request", + "detail": "The generation request could not be processed.", + "retryable": False, + }, + "generation_failed": { + "code": "generation_failed", + "detail": "Generation failed. Check the selected engine and try again.", + "retryable": True, + }, + "transcription_failed": { + "code": "transcription_failed", + "detail": "Transcription failed. Check the selected ASR engine and try again.", + "retryable": True, + }, + "transcription_timeout": { + "code": "transcription_timeout", + "detail": ( + "Transcription timed out while the backend is running. Increase " + "OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S or select the " + "faster-whisper-isolated engine, then try again." + ), + "retryable": True, + }, + } + return dict(failures.get(code, failures["generation_failed"])) diff --git a/backend/services/director.py b/backend/services/director.py index ee830d8b..59b39dbd 100644 --- a/backend/services/director.py +++ b/backend/services/director.py @@ -160,10 +160,10 @@ def parse(text: str) -> Direction: try: body = llm.chat(system=_LLM_PROMPT, user=text) - except Exception as e: - logger.warning("director LLM parse failed: %s", e) + except Exception: + logger.warning("director LLM parse failed; using heuristic parser") d = _heuristic_parse(text) - d.error = f"llm-parse-failed: {e}" + d.error = "llm-parse-failed" return d raw = body.strip() diff --git a/backend/services/endpoint_race.py b/backend/services/endpoint_race.py index 52da0bf8..a0e59391 100644 --- a/backend/services/endpoint_race.py +++ b/backend/services/endpoint_race.py @@ -83,6 +83,26 @@ _race_lock = threading.Lock() _FAILOVER_ATTEMPTED: set[str] = set() +def _is_allowed_probe_endpoint(endpoint: str) -> bool: + """Only probe the two fixed HTTPS origins shipped by VoiceStudio.""" + try: + parsed = urlsplit(endpoint) + port = parsed.port + except (TypeError, ValueError): + return False + return ( + parsed.scheme == "https" + and parsed.hostname in {urlsplit(CANONICAL_ENDPOINT).hostname, + urlsplit(COMMUNITY_MIRROR).hostname} + and port in (None, 443) + and parsed.username is None + and parsed.password is None + and parsed.path in ("", "/") + and not parsed.query + and not parsed.fragment + ) + + @dataclass class ProbeResult: endpoint: str @@ -120,11 +140,13 @@ def probe_endpoint(endpoint: str, timeout: float = PROBE_TIMEOUT_S) -> ProbeResu Any HTTP response (even an error status) counts as reachable — the probe measures whether the network path works, not whether a specific resource exists. Never raises.""" + if not _is_allowed_probe_endpoint(endpoint): + return ProbeResult(endpoint=endpoint, reachable=False, error="invalid_endpoint") url = endpoint.rstrip("/") + "/" req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "VoiceStudio-endpoint-probe"}) start = time.monotonic() try: - with urllib.request.urlopen(req, timeout=timeout): + with urllib.request.urlopen(req, timeout=timeout): # nosec B310 -- fixed HTTPS allowlist above pass except urllib.error.HTTPError: pass # the server answered → reachable @@ -143,6 +165,8 @@ def throughput_probe(endpoint: str, timeout: float = PROBE_TIMEOUT_S) -> Optiona Used only as a tiebreak confirmation when latency says the mirror is decisively faster — throughput is what a multi-GB download actually feels. Best-effort; any failure returns None (tiebreak skipped).""" + if not _is_allowed_probe_endpoint(endpoint): + return None url = endpoint.rstrip("/") + _THROUGHPUT_SAMPLE_PATH req = urllib.request.Request( url, @@ -155,7 +179,7 @@ def throughput_probe(endpoint: str, timeout: float = PROBE_TIMEOUT_S) -> Optiona total = 0 start = time.monotonic() try: - with urllib.request.urlopen(req, timeout=timeout) as resp: + with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310 -- fixed HTTPS allowlist above while total < _THROUGHPUT_SAMPLE_BYTES and time.monotonic() < deadline: chunk = resp.read(min(65536, _THROUGHPUT_SAMPLE_BYTES - total)) if not chunk: diff --git a/backend/services/speech_rate.py b/backend/services/speech_rate.py index c2787358..98a43505 100644 --- a/backend/services/speech_rate.py +++ b/backend/services/speech_rate.py @@ -184,9 +184,12 @@ def adjust_for_slot( system=system, user="\n".join(user_lines), temperature=0.2, # pinned like the Fast path — default 1.0 drifts/invents ) - except Exception as e: - logger.warning("speech-rate attempt %d failed: %s", attempt, e) - return {"text": best[0], "rate_ratio": best[1], "attempts": attempt - 1, "error": str(e)} + except Exception: + logger.warning("speech-rate provider attempt %d failed", attempt) + return { + "text": best[0], "rate_ratio": best[1], + "attempts": attempt - 1, "error": "fit-provider-failed", + } if next_text and next_text.strip(): candidate = next_text.strip() diff --git a/backend/services/subprocess_backend.py b/backend/services/subprocess_backend.py index c7dbe026..088b2ae6 100644 --- a/backend/services/subprocess_backend.py +++ b/backend/services/subprocess_backend.py @@ -40,6 +40,7 @@ from __future__ import annotations import atexit import base64 +import contextlib import json import logging import os @@ -263,6 +264,91 @@ def _ensure_reaper_running() -> None: # ── Base class ───────────────────────────────────────────────────────────── +#: How often to prove liveness while an engine's venv is being resolved. +#: Matches the sidecar's own cold-load cadence (#1367) so the guarded waiter +#: sees the same rhythm from both steps. +_RESOLVE_HEARTBEAT_S = 5.0 + + +@contextlib.contextmanager +def _heartbeat_while_resolving(engine_id: str): + """Report progress while a slow engine-venv resolution runs (#1414). + + A probe that spawns interpreters, or a bootstrap that installs torch, can + outlast the generate budget on its own. Both are demonstrably *working* + the whole time, so the deadline should extend rather than expire — which + is what the execution clock's load heartbeat is for. + + Two details that are easy to get wrong: + + * **Pool jobs only.** An off-pool caller never runs on a thread the clock + tracks, and heartbeating from one would credit an ident a pool worker + might later reuse — up to a grace period of unearned extension, which + is the #1379 lesson. + * **The resolving thread's ident, not the beater's.** The heartbeat runs + on a helper thread so it can tick while resolution blocks, but the job + the clock is watching is the caller's. Capturing the ident up front is + what makes the extension land on the right job. + + Never raises: a failed heartbeat must not fail a generation. + """ + try: + from services.model_manager import running_on_gpu_pool + + on_pool = running_on_gpu_pool() + except Exception: # noqa: BLE001 — best-effort by construction + on_pool = False + if not on_pool: + yield + return + + ident = threading.get_ident() + stop = threading.Event() + + def _beat(): + try: + from services.model_manager import ( + MODEL_LOAD_HEARTBEAT_GRACE_S, _MODEL_LOAD_ACTIVITY, + ) + except Exception: # noqa: BLE001 + return + while not stop.wait(_RESOLVE_HEARTBEAT_S): + try: + _MODEL_LOAD_ACTIVITY[ident] = ( + time.monotonic(), MODEL_LOAD_HEARTBEAT_GRACE_S, + ) + except Exception: # noqa: BLE001 + return + + t = threading.Thread( + target=_beat, name=f"{engine_id}-resolve-heartbeat", daemon=True, + ) + t.start() + try: + yield + finally: + stop.set() + # Join, don't just signal. `_beat()` can be past its `stop.wait()` and + # already committed to a write at the moment the flag is set, so + # signalling alone lets that write land at an arbitrary later point. + # + # That matters because of what runs next: `_run_on_gpu_pool`'s `_job` + # pops this ident from `_MODEL_LOAD_ACTIVITY` in its `finally` + # (model_manager.py) precisely so a stale beat cannot vouch for a + # future job — GPU-pool idents are reused. A write arriving after that + # pop resurrects the entry, and the next job scheduled onto this + # worker inherits a heartbeat it never emitted: the wedge detector + # reads it as live progress and keeps extending a job that is stuck. + # + # Joining orders the last write BEFORE the pop, so the pop clears it. + # This must not be bounded: returning while the helper is still alive + # would recreate the late-write race this join closes. The helper's + # only work after ``wait`` is an in-memory mapping assignment guarded + # by its own broad exception handler, so there is no blocking external + # operation to time out here. + t.join() + + class SubprocessBackend(TTSBackend): """Long-lived sidecar-process TTS backend. Subclasses provide ``venv_python()`` and ``sidecar_script()``; the base class owns @@ -379,7 +465,20 @@ class SubprocessBackend(TTSBackend): else: kwargs["start_new_session"] = True - python_path = str(self.venv_python()) + # `venv_python()` resolves the engine's interpreter, and on a cold + # first run that is not cheap: it spawns each candidate to import the + # engine (bounded, but tens of seconds on a slow disk), and if none is + # installed it can run the whole `uv venv` + `uv pip install` + # bootstrap — minutes, by design. + # + # All of that happens on a GPU-pool worker, inside a generate request + # whose execution budget is 300s by default. Nothing along the way + # reported progress, so the budget expired mid-install and the job was + # abandoned and blamed on the machine's compute (#1414). The sidecar's + # own cold load already heartbeats for exactly this reason (#1367); + # resolution is the step before it that never did. + with _heartbeat_while_resolving(self.id): + python_path = str(self.venv_python()) script_path = str(self.sidecar_script()) # #1172 class: validate the interpreter before exec so a broken / # half-installed engine venv (0-byte or truncated python, dangling diff --git a/backend/tests/test_batch.py b/backend/tests/test_batch.py index 7bbbdc62..064a6c76 100644 --- a/backend/tests/test_batch.py +++ b/backend/tests/test_batch.py @@ -180,7 +180,8 @@ class TestDeleteJob: assert client.get(f"/batch/jobs/{r['job_id']}").status_code == 404 def test_delete_not_found(self, client): - assert client.delete("/batch/jobs/nope").status_code == 404 + response = client.delete("/batch/jobs/nope") + assert response.status_code == 404 class TestSetProgress: diff --git a/backend/tests/test_run_sentinel.py b/backend/tests/test_run_sentinel.py index e4dee0ce..fa1259ed 100644 --- a/backend/tests/test_run_sentinel.py +++ b/backend/tests/test_run_sentinel.py @@ -15,6 +15,7 @@ import os import subprocess import sys import time +from pathlib import Path import pytest from fastapi import FastAPI @@ -244,12 +245,13 @@ def test_version_gate_hides_other_release_records_and_reads_never_write(sentinel store["records"][0]["version"] = "0.0.1" with open(run_sentinel.CRASH_RECORD_PATH, "w", encoding="utf-8") as f: json.dump(store, f) - before = open(run_sentinel.CRASH_RECORD_PATH, "rb").read() + before = Path(run_sentinel.CRASH_RECORD_PATH).read_bytes() assert run_sentinel.newest_record("9.9.9") is None, "other release = stale" # Preview stamps match their base release (X.Y.Z-N == X.Y.Z). assert run_sentinel.newest_record("0.0.1-7") is not None - assert open(run_sentinel.CRASH_RECORD_PATH, "rb").read() == before, ( + actual = Path(run_sentinel.CRASH_RECORD_PATH).read_bytes() + assert actual == before, ( "the read path must never write (crash.rs read-only contract)" ) # Versionless legacy records never surface either. diff --git a/docs/api-auth.md b/docs/api-auth.md index f707e3f1..8d5a9e18 100644 --- a/docs/api-auth.md +++ b/docs/api-auth.md @@ -21,7 +21,8 @@ tools keep working unchanged whichever gate is set. > VoiceStudio separates **consumption** (TTS, dictation, voices) from > **administration** (`/system/*`, `/api/settings/*` — RCE-class). The PIN and > trusted networks are *consumption* credentials; the **admin surface is only -> ever reached from loopback or with the API key** (see [Admin routes](#admin-routes-and-server-mode)). +> ever reached from loopback or with the API key**. Host-path capabilities stay +> desktop-only even with a key (see [Admin routes](#admin-routes-and-server-mode)). > Both gates can be active at once. The PIN and the API key are independent; when > both are set, each is checked on the paths it covers. @@ -206,13 +207,22 @@ origin is unenforceable — NAT rewrites the source and even a requirement is dropped (issue #261, else the operator is 403'd out of their own `/system/*`). It is replaced by a **credential rule**, not removed: -- **No credential configured** (no API key, no PIN) → admin is open. The bare - Docker flow; exposure rests entirely on your port mapping / firewall. +- **No API key configured** → read-only admin discovery remains available for + the bare Docker bootstrap flow, but `POST`/`PUT`/`PATCH`/`DELETE` requests are + denied. Set `OMNIVOICE_API_KEY` before changing settings remotely. - **A credential is configured** → admin requires the **API key** (`Authorization: Bearer` / `?api_key` / `ov_key` cookie), or genuine loopback. The **6-digit share PIN does not gate admin** (it is brute-forceable), and trusted-network - membership never does either. So a **PIN-only** server-mode deployment keeps - admin loopback-only; remote admin requires the long API key. + membership never does either. A **PIN-only** server-mode deployment therefore + allows remote read-only discovery but blocks remote mutations; remote writes + require the long API key. Discovery never returns the share PIN itself; only + loopback or a caller already authenticated with the API key can read it. + +Host paths are never selected through HTTP. The native Tauri process validates +model-cache destinations and custom FFmpeg/FFprobe binaries, writes a private +one-shot capability, and only that opaque authorization reaches the backend. +`/system/set-env` does not accept executable-path keys at all. Server mode and +an API key do not weaken that native boundary. This is the fix for a real escalation (#1213): before it, server mode made the admin gate a no-op, so with an API key set *and* a trusted CIDR configured, a LAN diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index f177b98e..24471215 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -2947,6 +2947,7 @@ dependencies = [ "dirs-next", "enigo", "fs4", + "getrandom 0.3.4", "libc", "log", "reqwest", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 221bed66..68fb498f 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ tauri-build = { version = "2.6.0", features = [] } [dependencies] serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } +getrandom = "0.3" log = "0.4" tauri = { version = "2.11.0", features = ["macos-private-api", "protocol-asset", "tray-icon", "image-png"] } tauri-plugin-log = "2" diff --git a/frontend/src-tauri/src/commands.rs b/frontend/src-tauri/src/commands.rs index b619c81d..4be42a13 100644 --- a/frontend/src-tauri/src/commands.rs +++ b/frontend/src-tauri/src/commands.rs @@ -5,13 +5,119 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; use std::time::Duration; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tauri::image::Image; use crate::{AppFlags, TrayHandle, DictationShortcutState}; use crate::{TRAY_ICON_DEFAULT, TRAY_ICON_RECORDING}; use crate::config::{load_config, save_config}; +// ── Native host-path authorization ─────────────────────────────────────── + +#[derive(Serialize, Deserialize)] +struct AuthorizedHostPath { + token: String, + kind: String, + path: String, +} + +pub fn path_authorization_dir(app: &tauri::AppHandle) -> PathBuf { + crate::setup::resolved_data_dir(app) + .unwrap_or_else(crate::setup::default_data_dir) + .join(".path-authorizations") +} + +fn validate_host_path(kind: &str, raw: &str) -> Result { + if !matches!(kind, "models_dir" | "ffmpeg" | "ffprobe") { + return Err("Unsupported host-path capability".into()); + } + if raw.chars().any(|c| c.is_control()) { + return Err("Path contains invalid control characters".into()); + } + if kind == "models_dir" && raw.is_empty() { + return Ok(PathBuf::new()); // explicit reset to the platform default + } + let path = PathBuf::from(raw); + if !path.is_absolute() { + return Err("Path must be absolute".into()); + } + if kind == "models_dir" { + fs::create_dir_all(&path).map_err(|e| format!("Directory is not writable: {e}"))?; + let probe = path.join(".voicestudio-write-test"); + fs::write(&probe, b"ok").map_err(|e| format!("Directory is not writable: {e}"))?; + let _ = fs::remove_file(probe); + } else { + if !path.is_file() { + return Err("Selected media tool is not a file".into()); + } + let status = crate::tools::no_window( + std::process::Command::new(&path) + .arg("-version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()), + ) + .status() + .map_err(|e| format!("Selected media tool could not run: {e}"))?; + if !status.success() { + return Err("Selected media tool failed its version check".into()); + } + } + Ok(path) +} + +#[tauri::command] +pub fn authorize_host_path( + app: tauri::AppHandle, + kind: String, + path: String, +) -> Result { + let validated = validate_host_path(&kind, path.trim())?; + let mut random = [0_u8; 32]; + getrandom::fill(&mut random).map_err(|e| format!("Secure randomness unavailable: {e}"))?; + let token: String = random.iter().map(|b| format!("{b:02x}")).collect(); + let dir = path_authorization_dir(&app); + fs::create_dir_all(&dir).map_err(|e| format!("Could not create authorization store: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)) + .map_err(|e| format!("Could not protect authorization store: {e}"))?; + } + let target = dir.join(format!("{token}.json")); + let payload = AuthorizedHostPath { + token: token.clone(), + kind, + path: validated.to_string_lossy().into_owned(), + }; + fs::write(&target, serde_json::to_vec(&payload).map_err(|e| e.to_string())?) + .map_err(|e| format!("Could not authorize path: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("Could not protect authorization: {e}"))?; + } + Ok(token) +} + +#[cfg(test)] +mod host_path_authorization_tests { + use super::validate_host_path; + use std::path::PathBuf; + + #[test] + fn rejects_unknown_relative_and_control_character_paths() { + assert!(validate_host_path("shell", "/tmp/tool").is_err()); + assert!(validate_host_path("models_dir", "relative/models").is_err()); + assert!(validate_host_path("models_dir", "/tmp/bad\npath").is_err()); + } + + #[test] + fn empty_models_path_is_the_authorized_default_reset() { + assert_eq!(validate_host_path("models_dir", "").unwrap(), PathBuf::new()); + } +} + // ── System metrics ──────────────────────────────────────────────────────── #[derive(Serialize, Clone)] diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 4313d44f..71c7897e 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -422,6 +422,7 @@ pub fn run() { updater_channel::install_update, updater_channel::list_releases, commands::get_sysinfo, + commands::authorize_host_path, commands::read_log_tail, commands::hf_cache_scan, commands::simulate_paste, diff --git a/frontend/src/components/settings/AudioToolsPanel.jsx b/frontend/src/components/settings/AudioToolsPanel.jsx index d7808327..4d1bc522 100644 --- a/frontend/src/components/settings/AudioToolsPanel.jsx +++ b/frontend/src/components/settings/AudioToolsPanel.jsx @@ -267,7 +267,27 @@ export default function AudioToolsPanel() { const onToolAction = useCallback( async (path, body) => { - const ok = await post(path, body); + let requestBody = body; + if (path.endsWith('/custom-path')) { + try { + const { invoke } = await import('@tauri-apps/api/core'); + const tool = path.includes('/ffprobe/') ? 'ffprobe' : 'ffmpeg'; + const authorization = await invoke('authorize_host_path', { + kind: tool, + path: body?.path || '', + }); + requestBody = { authorization }; + } catch (e) { + toast.error( + t('settings.audio_tools_path_failed', { + message: e.message || String(e), + defaultValue: "Couldn't set path: {{message}}", + }), + ); + return; + } + } + const ok = await post(path, requestBody); if (ok && (path.endsWith('/custom-path') || path.endsWith('/use-system'))) { toast.success( t('settings.audio_tools_path_set', { diff --git a/frontend/src/components/settings/AudioToolsPanel.test.jsx b/frontend/src/components/settings/AudioToolsPanel.test.jsx index 34fea7e9..a596fcec 100644 --- a/frontend/src/components/settings/AudioToolsPanel.test.jsx +++ b/frontend/src/components/settings/AudioToolsPanel.test.jsx @@ -11,6 +11,8 @@ vi.mock('../../api/client', () => ({ apiJson: vi.fn(), apiFetch: vi.fn(), })); +const invoke = vi.fn(); +vi.mock('@tauri-apps/api/core', () => ({ invoke: (...args) => invoke(...args) })); import { toast } from 'react-hot-toast'; import { apiJson, apiFetch } from '../../api/client'; @@ -57,6 +59,7 @@ describe('AudioToolsPanel — power-user surface for the media tools', () => { vi.clearAllMocks(); apiJson.mockResolvedValue(JSON.parse(JSON.stringify(STATUS))); apiFetch.mockResolvedValue(okResponse); + invoke.mockResolvedValue('c'.repeat(64)); }); it('renders one row per tool with version, path, and origin badge', async () => { @@ -85,6 +88,26 @@ describe('AudioToolsPanel — power-user surface for the media tools', () => { await waitFor(() => expect(toast.success).toHaveBeenCalled()); }); + it('sends only a native one-shot authorization for a custom executable', async () => { + render(); + fireEvent.click(await screen.findByLabelText('FFmpeg: Choose file…')); + const input = await screen.findByLabelText('FFmpeg binary path'); + fireEvent.change(input, { target: { value: '/opt/tools/ffmpeg' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('authorize_host_path', { + kind: 'ffmpeg', + path: '/opt/tools/ffmpeg', + }), + ); + expect(apiFetch).toHaveBeenCalledWith( + '/media-tools/ffmpeg/custom-path', + expect.objectContaining({ body: JSON.stringify({ authorization: 'c'.repeat(64) }) }), + ); + expect(apiFetch.mock.calls.flat().join(' ')).not.toContain('/opt/tools/ffmpeg'); + }); + it('Restore bundled is per-tool and always available (safe revert)', async () => { render(); fireEvent.click(await screen.findByLabelText('FFprobe: Restore bundled')); diff --git a/frontend/src/components/settings/StoragePanel.jsx b/frontend/src/components/settings/StoragePanel.jsx index 64d08e20..7cf29dec 100644 --- a/frontend/src/components/settings/StoragePanel.jsx +++ b/frontend/src/components/settings/StoragePanel.jsx @@ -9,7 +9,7 @@ * Endpoints: * GET /api/settings/storage/models-dir * → {configured, effective, default, restart_required} - * PUT /api/settings/storage/models-dir body {path} (empty path clears) + * Native IPC authorizes the path, then PUT sends only the one-shot token. */ import React, { useCallback, useEffect, useState } from 'react'; import { HardDrive } from 'lucide-react'; @@ -52,10 +52,12 @@ export default function StoragePanel() { setSaving(true); setError(null); try { + const { invoke } = await import('@tauri-apps/api/core'); + const authorization = await invoke('authorize_host_path', { kind: 'models_dir', path }); const res = await apiFetch('/api/settings/storage/models-dir', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path }), + body: JSON.stringify({ authorization }), }); if (!res.ok) { const b = await res.json().catch(() => ({})); diff --git a/frontend/src/components/settings/StoragePanel.test.jsx b/frontend/src/components/settings/StoragePanel.test.jsx new file mode 100644 index 00000000..262b9ce1 --- /dev/null +++ b/frontend/src/components/settings/StoragePanel.test.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +vi.mock('react-hot-toast', () => ({ default: { success: vi.fn() } })); +const apiJson = vi.fn(); +const apiFetch = vi.fn(); +vi.mock('../../api/client', () => ({ + apiJson: (...args) => apiJson(...args), + apiFetch: (...args) => apiFetch(...args), +})); +const invoke = vi.fn(); +vi.mock('@tauri-apps/api/core', () => ({ invoke: (...args) => invoke(...args) })); + +import StoragePanel from './StoragePanel'; + +describe('StoragePanel native path boundary', () => { + it('never sends the selected models directory through HTTP', async () => { + apiJson.mockResolvedValue({ configured: '', effective: '/cache', default: '/default' }); + apiFetch.mockResolvedValue({ + ok: true, + json: async () => ({ configured: '/private/models', restart_required: true }), + }); + invoke.mockResolvedValue('d'.repeat(64)); + render(); + + const input = await screen.findByTestId('models-dir-input'); + fireEvent.change(input, { target: { value: '/private/models' } }); + fireEvent.click(screen.getByTestId('models-dir-save')); + + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('authorize_host_path', { + kind: 'models_dir', + path: '/private/models', + }), + ); + expect(apiFetch).toHaveBeenCalledWith( + '/api/settings/storage/models-dir', + expect.objectContaining({ body: JSON.stringify({ authorization: 'd'.repeat(64) }) }), + ); + expect(apiFetch.mock.calls.flat().join(' ')).not.toContain('/private/models'); + }); +}); diff --git a/omnivoice/utils/voice_design.py b/omnivoice/utils/voice_design.py index e7800229..aeaf4dd1 100644 --- a/omnivoice/utils/voice_design.py +++ b/omnivoice/utils/voice_design.py @@ -108,7 +108,12 @@ def sanitize_instruct(raw): """ if not raw: return "" - return _valid_instruct_from_items(re.split(r"\s*[,,]\s*", str(raw).strip())) + # Strip each comma-delimited item after splitting. A pattern with ``\s*`` + # on both sides of the delimiter backtracks quadratically when a poisoned + # stored value contains a long whitespace run without a comma (GHAS #778). + return _valid_instruct_from_items( + item.strip() for item in re.split(r"[,,]", str(raw).strip()) + ) def instruct_from_vd_states(vd_states): diff --git a/tests/backend/api/test_engines_route_shape.py b/tests/backend/api/test_engines_route_shape.py index ba41d4e8..97094ebc 100644 --- a/tests/backend/api/test_engines_route_shape.py +++ b/tests/backend/api/test_engines_route_shape.py @@ -20,6 +20,7 @@ from __future__ import annotations import re import sys +from time import perf_counter import pytest @@ -420,6 +421,45 @@ def test_select_mlx_audio_raw_repo_id_accepted(fresh_app, monkeypatch): assert _prefs.get("mlx_audio_model_id") == "mlx-community/Some-Other-Model-4bit" +def test_select_mlx_audio_repo_id_accepts_underscore_prefixes(fresh_app, monkeypatch): + _make_mlx_audio_available(monkeypatch) + r = _client(fresh_app).post( + "/engines/select", + json={ + "family": "tts", "backend_id": "mlx-audio", + "model_id": "_owner/_repo", + }, + ) + assert r.status_code == 200, r.text + + +@pytest.mark.parametrize( + "model_id", + [ + "owner/repo/extra", + "-owner/repo", + "owner/.repo", + "owner/repo--name", + "owner/repo..name", + "owner/repo.", + "owner/repo.git", + f"owner/{'a' * 97}", + "-" * 100_000, + ], +) +def test_select_mlx_audio_rejects_malformed_repo_ids(fresh_app, monkeypatch, model_id): + _make_mlx_audio_available(monkeypatch) + started = perf_counter() + r = _client(fresh_app).post( + "/engines/select", + json={"family": "tts", "backend_id": "mlx-audio", "model_id": model_id}, + ) + assert r.status_code == 400 + assert perf_counter() - started < 0.5 + assert len(r.content) < 256 + assert model_id[:100] not in r.text + + def test_select_mlx_audio_without_model_id_does_not_touch_pref(fresh_app, monkeypatch): """Selecting mlx-audio without a model_id (e.g. an older frontend) must leave any existing mlx_audio_model_id pref untouched.""" diff --git a/tests/test_api.py b/tests/test_api.py index 182b971b..fe12b1e0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -600,6 +600,55 @@ def test_set_env_allows_loopback(): os.environ["HF_TOKEN"] = original +def test_server_mode_remote_without_api_key_cannot_set_executable_path(monkeypatch, tmp_path): + """GHAS #506: bare Docker exposure must not become an executable setter.""" + from fastapi.testclient import TestClient + from main import app + + executable = tmp_path / "ffmpeg" + executable.write_bytes(b"not actually executable") + original = os.environ.get("FFMPEG_PATH") + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False) + try: + response = TestClient(app, client=("172.17.0.1", 50000)).post( + "/system/set-env", + json={"key": "FFMPEG_PATH", "value": str(executable)}, + ) + assert response.status_code == 403 + assert os.environ.get("FFMPEG_PATH") == original + finally: + if original is None: + os.environ.pop("FFMPEG_PATH", None) + else: + os.environ["FFMPEG_PATH"] = original + + +def test_set_env_never_accepts_executable_path_even_with_admin_key(monkeypatch, tmp_path): + """Executable selection exists only behind native IPC, never this API.""" + from fastapi.testclient import TestClient + from main import app + + executable = tmp_path / "ffmpeg" + executable.write_bytes(b"not actually executable") + original = os.environ.get("FFMPEG_PATH") + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret") + try: + response = TestClient(app, client=("172.17.0.1", 50000)).post( + "/system/set-env", + headers={"authorization": "Bearer s3cret"}, + json={"key": "FFMPEG_PATH", "value": str(executable)}, + ) + assert response.status_code == 400 + assert os.environ.get("FFMPEG_PATH") == original + finally: + if original is None: + os.environ.pop("FFMPEG_PATH", None) + else: + os.environ["FFMPEG_PATH"] = original + + def test_set_env_loopback_still_validates_allowlist(): """Even on the loopback path, keys outside the allow-list must return 400 — the new guard must NOT bypass the existing allow-list enforcement.""" @@ -686,4 +735,3 @@ def test_static_audio_served_with_canonical_mime(): ) finally: tmp_wav.unlink(missing_ok=True) - diff --git a/tests/test_asr_model_missing.py b/tests/test_asr_model_missing.py index 5f2b6efa..e47b8abc 100644 --- a/tests/test_asr_model_missing.py +++ b/tests/test_asr_model_missing.py @@ -366,7 +366,8 @@ class TestEndpoints: _offline_asr_missing(cached=True): r = client.get("/dub/transcribe-stream/j4") assert r.status_code == 200 - assert "audio load failed" in r.text + assert "Transcription failed. Check the selected ASR engine and try again." in r.text + assert str(wav) not in r.text backend.unload.assert_called_once() diff --git a/tests/test_dub_transcribe.py b/tests/test_dub_transcribe.py index 94985da3..f170eb36 100644 --- a/tests/test_dub_transcribe.py +++ b/tests/test_dub_transcribe.py @@ -160,7 +160,9 @@ def test_transcribe_stream_surfaces_model_load_failure(tmp_path, monkeypatch): assert "CUDA driver init failed: simulated" in body, body -def test_transcribe_stream_never_closes_without_terminal_event(tmp_path, monkeypatch): +def test_transcribe_stream_never_closes_without_terminal_event( + tmp_path, monkeypatch, caplog +): """Regression #516: an unanticipated exception INSIDE the stream body (one that escapes the per-chunk handler, e.g. segmentation blowing up) must still end the stream with a terminal `error` then `done` — never a silent @@ -203,7 +205,7 @@ def test_transcribe_stream_never_closes_without_terminal_event(tmp_path, monkeyp # Make the post-chunk segmentation (outside the per-chunk try/except) blow # up — the exact class of "unanticipated escape" the guard must catch. def _boom_segment(*a, **k): - raise RuntimeError("segmentation exploded: simulated") + raise RuntimeError("API_KEY=dub-secret /home/alice/private-video.mp4") monkeypatch.setattr(dc, "segment_transcript", _boom_segment) # Don't touch the GPU/TTS during the test. monkeypatch.setattr(dc, "offload_tts_for_asr", lambda *a, **k: None) @@ -222,12 +224,79 @@ def test_transcribe_stream_never_closes_without_terminal_event(tmp_path, monkeyp # The stream must end with a terminal error followed by done. assert "event: error" in body, body - assert "segmentation exploded: simulated" in body, body + assert "transcription_failed" in body, body + assert "Transcription failed. Check the selected ASR engine and try again." in body, body + assert "dub-secret" not in body, body + assert "Traceback" not in body, body + assert "dub-secret" not in caplog.text + assert "/home/alice/private-video.mp4" not in caplog.text err_idx = body.rfind("event: error") done_idx = body.rfind("event: done") assert done_idx > err_idx >= 0, f"error must precede the terminal done: {body}" +def test_transcribe_chunk_failure_uses_stable_public_metadata( + tmp_path, monkeypatch, caplog +): + """Inner ASR failures must not serialize provider secrets or local paths.""" + import asyncio + from api.routers import dub_core as dc + + job_id = "t_chunk_secret" + audio = tmp_path / "a.wav" + _make_wav(audio, seconds=1.0) + dc._dub_jobs[job_id] = { + "audio_path": str(audio), "vocals_path": None, "scene_cuts": [], + } + + fake_model = MagicMock() + fake_model._asr_pipe = MagicMock() + + async def _ok_model(): + return fake_model + + class _FailingASR: + id = "fake" + + def ensure_loaded(self): + pass + + def transcribe(self, path, *, word_timestamps=True): + raise RuntimeError("TOKEN=chunk-secret /home/alice/private-audio.wav") + + def unload(self): + pass + + monkeypatch.setattr(dc, "get_model", _ok_model) + monkeypatch.setattr(dc, "_CHUNK_TRANSCRIBE_ATTEMPTS", 1) + monkeypatch.setattr(dc, "offload_tts_for_asr", lambda *a, **k: None) + monkeypatch.setattr( + "services.asr_backend.get_active_asr_backend", + lambda *a, **k: _FailingASR(), + ) + + async def _collect(): + resp = await dc.dub_transcribe_stream(job_id) + parts = [] + async for chunk in resp.body_iterator: + parts.append( + chunk.decode() if isinstance(chunk, (bytes, bytearray)) else str(chunk) + ) + return "".join(parts) + + try: + body = asyncio.run(_collect()) + finally: + dc._dub_jobs.pop(job_id, None) + + assert "transcription_failed" in body, body + assert "Transcription failed. Check the selected ASR engine and try again." in body + assert "chunk-secret" not in body + assert "/home/alice/private-audio.wav" not in body + assert "chunk-secret" not in caplog.text + assert "/home/alice/private-audio.wav" not in caplog.text + + def test_transcribe_stream_surfaces_asr_load_failure_at_preflight(tmp_path, monkeypatch): """Regression #578: the reported failure mode is the *ASR model* failing to load (WhisperX: faster-whisper weights / CTranslate2-cuDNN mismatch / the @@ -334,7 +403,10 @@ def test_transcribe_stream_preflight_crash_is_a_structured_error(monkeypatch): body = asyncio.run(_collect()) assert "event: error" in body, body - assert "job store exploded: simulated" in body, body + assert "transcription_failed" in body, body + assert "Transcription failed. Check the selected ASR engine and try again." in body, body + assert "job store exploded: simulated" not in body, body + assert "Traceback" not in body, body err_idx = body.rfind("event: error") done_idx = body.rfind("event: done") assert done_idx > err_idx >= 0, f"error must precede the terminal done: {body}" diff --git a/tests/test_endpoint_race.py b/tests/test_endpoint_race.py index b32f91b0..521837af 100644 --- a/tests/test_endpoint_race.py +++ b/tests/test_endpoint_race.py @@ -79,6 +79,30 @@ def test_hint_only_reorders_never_drops(er): assert set(prober.calls) == {er.CANONICAL_ENDPOINT, er.COMMUNITY_MIRROR} +@pytest.mark.parametrize( + "endpoint", + [ + "file:///etc/passwd", + "http://huggingface.co", + "https://huggingface.co.evil.example", + "https://huggingface.co@evil.example", + "https://huggingface.co:444", + "https://huggingface.co/model", + "https://huggingface.co?redirect=file:///etc/passwd", + ], +) +def test_probe_rejects_unapproved_origins(er, endpoint): + # The suite-wide network guard replaces the actual prober, so exercise the + # validation chokepoint directly. Both real network helpers call it before + # constructing a Request or reaching urlopen. + assert er._is_allowed_probe_endpoint(endpoint) is False + + +@pytest.mark.parametrize("endpoint", ["https://huggingface.co", "https://hf-mirror.com/"]) +def test_probe_allows_only_shipped_https_origins(er, endpoint): + assert er._is_allowed_probe_endpoint(endpoint) is True + + # ── Decision policy matrix ────────────────────────────────────────────────── def test_both_reachable_similar_latency_prefers_canonical(er): diff --git a/tests/test_generate_streaming.py b/tests/test_generate_streaming.py index 57fcb3cc..696558b9 100644 --- a/tests/test_generate_streaming.py +++ b/tests/test_generate_streaming.py @@ -111,7 +111,9 @@ def _make_deterministic_engine(engine_id="stream-fake", *, delay_s=0.0, def generate(self, text, **kw) -> torch.Tensor: type(self).calls.append((text, time.monotonic())) if fail_on_call is not None and len(type(self).calls) == fail_on_call: - raise RuntimeError("engine exploded mid-stream (test)") + raise RuntimeError( + "TOKEN=stream-secret /home/alice/private-reference.wav" + ) if delay_s: time.sleep(delay_s) # Deterministic, text-dependent waveform (crc-seeded sine-ish ramp). @@ -277,8 +279,9 @@ def test_stream_final_file_identical_to_classic_output(client, monkeypatch, assert done["duration"] > 0 -def test_stream_midstream_error_yields_error_event(client, monkeypatch, - no_omnivoice_model): +def test_stream_midstream_error_yields_error_event( + client, monkeypatch, no_omnivoice_model, caplog +): """Chunk 2 blowing up must surface as an in-band error event AFTER the already-delivered chunk — no done, no history row, no saved file.""" fake = _make_deterministic_engine(fail_on_call=2) @@ -294,7 +297,13 @@ def test_stream_midstream_error_yields_error_event(client, monkeypatch, assert "chunk" in types # chunk 0 was delivered before the crash assert types[-1] == "error" assert "done" not in types - assert events[-1][0]["detail"] # actionable message for the fallback log + error = events[-1][0] + assert error["code"] == "generation_failed" + assert error["detail"] == "Generation failed. Check the selected engine and try again." + assert "stream-secret" not in repr(error) + assert "Traceback" not in repr(error) + assert "stream-secret" not in caplog.text + assert "/home/alice/private-reference.wav" not in caplog.text after_ids = {h["id"] for h in client.get("/history").json()} assert after_ids == before_ids # nothing was recorded for the failure diff --git a/tests/test_ghas_false_positive_invariants.py b/tests/test_ghas_false_positive_invariants.py new file mode 100644 index 00000000..c5610b5b --- /dev/null +++ b/tests/test_ghas_false_positive_invariants.py @@ -0,0 +1,161 @@ +"""Regression evidence for reviewed GHAS false positives. + +These tests intentionally inspect the narrow security invariants at the +reported sinks. They keep future refactors from making a dismissed alert +silently become exploitable while avoiding heavyweight model imports. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _source(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def _function(path: str, name: str) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, str]: + source = _source(path) + tree = ast.parse(source) + node = next( + item + for item in ast.walk(tree) + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name == name + ) + return node, ast.get_source_segment(source, node) or "" + + +def test_dynamic_updates_only_interpolate_allowlisted_columns_and_placeholders(): + cases = ( + ( + "backend/api/routers/glossary.py", + "update_term", + '("source", "target", "note")', + "UPDATE glossary_terms", + ), + ( + "backend/api/routers/profiles.py", + "update_profile", + '("name", "ref_text", "instruct", "language", "personality")', + "UPDATE voice_profiles", + ), + ) + for path, function, allowlist, table in cases: + _, body = _function(path, function) + assert allowlist in body + assert 'fields.append(f"{col} = ?")' in body + assert table in body + assert "params" in body + # User values and IDs are never formatted into the statement. + assert "{val}" not in body + assert "{profile_id}" not in body + assert "{term_id}" not in body + assert "{project_id}" not in body + + +def test_history_reference_query_varies_only_placeholder_arity(): + _, body = _function( + "backend/api/routers/generation.py", "_remove_wav_if_unreferenced" + ) + assert 'placeholders = ",".join("?" for _ in exclude_ids)' in body + assert 'f" AND id NOT IN ({placeholders})"' in body + assert "(audio_path, *exclude_ids)" in body + assert "{audio_path}" not in body + assert "{exclude_ids}" not in body + + +def test_media_download_sink_requires_https_size_and_sha256(): + _, body = _function("backend/services/media_tools.py", "_download") + assert 'if not url.startswith("https://")' in body + assert "done != expected_size" in body + assert "digest != expected_sha256" in body + assert body.index('url.startswith("https://")') < body.index("urlopen(") + + +def test_pypi_metadata_and_wheel_digest_reach_the_verified_download_sink(): + source = _source("backend/services/media_tools.py") + assert '_PYPI_YTDLP_URL = "https://pypi.org/pypi/yt-dlp/json"' in source + _, fetch = _function("backend/services/media_tools.py", "_fetch_pypi_ytdlp") + assert 'artifact["url"]' in fetch + assert 'artifact["digests"]["sha256"]' in fetch + _, update = _function("backend/services/media_tools.py", "_do_update_ytdlp") + assert "version, url, sha = _fetch_pypi_ytdlp()" in update + assert '_download(url, whl, sha, None, op="ytdlp_update")' in update + + +def test_diagnostic_probe_is_a_guarded_constant_https_head_request(): + source = _source("backend/core/diagnose.py") + assert '_HUB_URL = "https://huggingface.co"' in source + _, body = _function("backend/core/diagnose.py", "_check_network") + assert 'if not _HUB_URL.startswith("https://")' in body + assert 'Request(_HUB_URL, method="HEAD")' in body + assert body.index('_HUB_URL.startswith("https://")') < body.index("urlopen(") + + +def test_health_check_url_and_server_are_both_pinned_to_loopback(): + source = _source("backend/main.py") + assert 'HEALTH_URL = f"http://127.0.0.1:{_port}/health"' in source + assert 'uvicorn.run(app, host="127.0.0.1", port=_port' in source + assert "_port = network_share.backend_port()" in source + + +def test_huggingface_cache_probe_is_forced_offline(): + _, body = _function("backend/services/model_manager.py", "_checkpoint_in_local_cache") + assert "snapshot_download(checkpoint, local_files_only=True)" in body + assert "local_files_only=False" not in body + + +def test_pep562_exports_are_backed_by_lazy_attribute_resolvers(): + package = _source("omnivoice/__init__.py") + assert '__all__ = ["OmniVoice", "OmniVoiceConfig", "OmniVoiceGenerationConfig"]' in package + assert "def __getattr__(name):" in package + assert "if name in __all__:" in package + assert "return getattr(_m, name)" in package + + backend = _source("backend/engines/omnivoice_gguf/backend.py") + assert '"OmniVoiceGGUFBackend",' in backend + assert 'if name == "OmniVoiceGGUFBackend":' in backend + assert "return _make_backend_class()" in backend + + +def test_secret_error_logs_never_include_plaintext_or_ciphertext_variables(): + _, body = _function("backend/services/settings_store.py", "get_secret") + tree = ast.parse(body) + log_calls = [ + call + for call in ast.walk(tree) + if isinstance(call, ast.Call) + and isinstance(call.func, ast.Attribute) + and call.func.attr in {"error", "warning", "exception"} + ] + assert len(log_calls) == 3 + for call in log_calls: + argument_names = { + node.id for arg in call.args[1:] for node in ast.walk(arg) if isinstance(node, ast.Name) + } + assert argument_names <= {"name"} + rendered = ast.unparse(call) + assert "row" not in rendered + assert "key" not in rendered + + +def test_dataset_script_handles_are_closed_by_outer_finally_blocks(): + cases = ( + ("omnivoice/scripts/denoise_audio.py", "main"), + ("omnivoice/scripts/extract_audio_tokens.py", "main"), + ("omnivoice/scripts/extract_audio_tokens_add_noise.py", "main"), + ) + for path, function in cases: + _, body = _function(path, function) + assert "tar_writer = None" in body + assert "jsonl_file = None" in body + assert "finally:" in body + finally_body = body.rsplit("finally:", 1)[1] + assert "if tar_writer is not None:" in finally_body + assert "tar_writer.close()" in finally_body + assert "if jsonl_file is not None:" in finally_body + assert "jsonl_file.close()" in finally_body diff --git a/tests/test_llm_providers_router.py b/tests/test_llm_providers_router.py index efe29ea4..738a67a6 100644 --- a/tests/test_llm_providers_router.py +++ b/tests/test_llm_providers_router.py @@ -179,7 +179,9 @@ def test_probe_failure_detail_is_scrubbed(settings_mod, monkeypatch): "boom key=gsk-test-123 at /Users/someone/secret")) body = settings_mod.test_llm_provider("groq") assert body["ok"] is False - assert "gsk-test-123" not in body["detail"] + assert body["detail"] == "The provider request failed. Try again." + assert "gsk-test-123" not in repr(body) + assert "/Users/someone" not in repr(body) # ── /models discovery ─────────────────────────────────────────────────────── @@ -204,6 +206,17 @@ def test_models_failure_is_classified(settings_mod, monkeypatch): _fake_openai(monkeypatch, raise_exc=exc) body = settings_mod.list_llm_provider_models("groq") assert body["ok"] is False and body["kind"] == "auth" and body["models"] == [] + assert body["detail"] == "Authentication failed. Check the provider API key." + + +def test_models_failure_omits_trace_path_and_secret(settings_mod, monkeypatch): + _configure_groq(settings_mod) + private = "Traceback: key=gsk-test-123 at /home/alice/provider.py" + _fake_openai(monkeypatch, raise_exc=RuntimeError(private)) + body = settings_mod.list_llm_provider_models("groq") + assert body["kind"] == "error" + assert body["detail"] == "The provider request failed. Try again." + assert private not in repr(body) def test_models_not_truncated_under_cap(settings_mod, monkeypatch): diff --git a/tests/test_llm_skills.py b/tests/test_llm_skills.py index 903359ed..1db2e038 100644 --- a/tests/test_llm_skills.py +++ b/tests/test_llm_skills.py @@ -268,6 +268,24 @@ def test_disabled_direction_parse_uses_heuristic(skills, store, monkeypatch): assert d.tokens.get("energy") == ["urgent"] # heuristic still delivers +def test_direction_failure_returns_stable_error(skills, store, monkeypatch, caplog): + _activate_groq(store) + from services import director + + private = "Traceback: token=private-value at /home/alice/director.py" + + class _Fake: + id = "openai-compat" + def chat(self, **kw): + raise RuntimeError(private) + + monkeypatch.setattr(director, "get_active_llm_backend", lambda: _Fake()) + d = director.parse("urgent and surprised") + assert d.error == "llm-parse-failed" + assert private not in repr(d) + assert private not in caplog.text + + def test_disabled_slot_fitting_returns_no_llm_marker(skills, store, monkeypatch): _activate_groq(store) from services import speech_rate @@ -287,6 +305,24 @@ def test_disabled_slot_fitting_returns_no_llm_marker(skills, store, monkeypatch) assert res["error"] == "no-llm" and res["text"] == long_text +def test_slot_fit_failure_returns_stable_error(skills, store, monkeypatch, caplog): + _activate_groq(store) + from services import speech_rate + + private = "Traceback: token=private-value at /home/alice/rate.py" + + class _Fake: + id = "openai-compat" + def chat(self, **kw): + raise RuntimeError(private) + + monkeypatch.setattr(speech_rate, "get_active_llm_backend", lambda: _Fake()) + res = speech_rate.adjust_for_slot("x" * 30, slot_seconds=1.0, target_lang="en") + assert res["error"] == "fit-provider-failed" + assert private not in repr(res) + assert private not in caplog.text + + def test_disabled_glossary_extract_503s(skills, store): _activate_groq(store) from fastapi import HTTPException diff --git a/tests/test_loopback_server_mode.py b/tests/test_loopback_server_mode.py index 8145e248..3369f0e1 100644 --- a/tests/test_loopback_server_mode.py +++ b/tests/test_loopback_server_mode.py @@ -10,7 +10,36 @@ from types import SimpleNamespace import pytest from fastapi import HTTPException -from api.dependencies import is_loopback, is_local_host, require_local, require_loopback +def _dependency(name): + # Resolve at test execution time: other suites intentionally replace + # ``api.*`` modules in sys.modules while probing cold-start behavior. + from api import dependencies + + return getattr(dependencies, name) + + +def is_loopback(*args, **kwargs): + return _dependency("is_loopback")(*args, **kwargs) + + +def is_local_host(*args, **kwargs): + return _dependency("is_local_host")(*args, **kwargs) + + +def require_admin(*args, **kwargs): + return _dependency("require_admin")(*args, **kwargs) + + +def require_desktop(*args, **kwargs): + return _dependency("require_desktop")(*args, **kwargs) + + +def require_local(*args, **kwargs): + return _dependency("require_local")(*args, **kwargs) + + +def require_loopback(*args, **kwargs): + return _dependency("require_loopback")(*args, **kwargs) def _req(host): @@ -134,7 +163,7 @@ def test_require_local_rejects_untrusted_non_loopback(monkeypatch): assert exc.value.status_code == 403 -def _req_full(host, *, headers=None, query=None, cookies=None, pin=None): +def _req_full(host, *, headers=None, query=None, cookies=None, pin=None, method="GET"): """Richer stub carrying the channels the admin-credential check reads: headers, query params, cookies, and app.state.network_share.pin.""" ns = SimpleNamespace(pin=pin) if pin is not None else None @@ -145,6 +174,7 @@ def _req_full(host, *, headers=None, query=None, cookies=None, pin=None): query_params=query or {}, cookies=cookies or {}, app=app, + method=method, ) @@ -231,6 +261,62 @@ def test_server_mode_loopback_admin_never_needs_credential(monkeypatch): require_loopback(_req_full("127.0.0.1")) # must not raise +# GHAS #506/#440/#441: require_loopback permits an unconfigured bare Docker +# server for compatibility. RCE/filesystem-capable routers use the stricter, +# method-aware admin gate instead. + + +@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE"]) +def test_server_mode_admin_mutation_requires_api_key_when_unconfigured(monkeypatch, method): + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False) + with pytest.raises(HTTPException) as exc: + require_admin(_req_full("172.17.0.1", method=method)) + assert exc.value.status_code == 403 + + +def test_server_mode_admin_read_keeps_bare_docker_bootstrap(monkeypatch): + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False) + require_admin(_req_full("172.17.0.1", method="GET")) + + +def test_server_mode_admin_read_keeps_pin_only_discovery(monkeypatch): + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False) + monkeypatch.setenv("OMNIVOICE_SHARE_PIN", "123456") + require_admin(_req_full("172.17.0.1", method="GET")) + + +def test_server_mode_admin_mutation_allows_api_key(monkeypatch): + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret") + require_admin(_req_full( + "172.17.0.1", + method="POST", + headers={"authorization": "Bearer s3cret"}, + )) + + +def test_server_mode_desktop_capability_rejects_remote_api_key(monkeypatch): + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret") + with pytest.raises(HTTPException) as exc: + require_desktop(_req_full( + "172.17.0.1", + method="POST", + headers={"authorization": "Bearer s3cret"}, + )) + assert exc.value.status_code == 403 + + +@pytest.mark.parametrize("method", ["GET", "POST", "PUT", "DELETE"]) +def test_loopback_admin_never_needs_api_key(monkeypatch, method): + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False) + require_admin(_req_full("127.0.0.1", method=method)) + + def test_is_local_host_unwraps_ipv4_mapped_ipv6(monkeypatch): # Dual-stack proxies (Caddy, Node.js) pass ::ffff:192.168.1.5 — should # match an IPv4 CIDR after unwrapping the mapped address. diff --git a/tests/test_media_tools.py b/tests/test_media_tools.py index bee4309a..94cd8f1e 100644 --- a/tests/test_media_tools.py +++ b/tests/test_media_tools.py @@ -10,6 +10,7 @@ from __future__ import annotations import hashlib import io +import json import os import zipfile from unittest.mock import patch @@ -27,6 +28,10 @@ def mt(monkeypatch, tmp_path): monkeypatch.setattr(prefs, "_PREFS_PATH", str(tmp_path / "prefs.json")) monkeypatch.setattr(mt_mod, "media_tools_dir", lambda: str(tmp_path / "media_tools")) + auth_dir = tmp_path / "path-authorizations" + auth_dir.mkdir() + from core import path_authorization + monkeypatch.setattr(path_authorization, "_AUTH_DIR", str(auth_dir)) for op in mt_mod._ops.values(): op.update(state="idle", progress=0.0, error=None) mt_mod._version_cache.clear() @@ -382,11 +387,28 @@ def test_router_status_and_acquire_endpoints(mt, monkeypatch): assert r.json()["state"] == "running" -def test_router_custom_path_maps_validation_to_400(mt): +def test_router_custom_path_rejects_raw_http_path(mt): c = _client() r = c.post("/media-tools/ffmpeg/custom-path", json={"path": "/no/such/binary"}) - assert r.status_code == 400 - assert "not found" in r.json()["detail"].lower() + assert r.status_code == 422 + + +def test_router_custom_path_consumes_native_authorization(mt, monkeypatch, tmp_path): + binary = tmp_path / "ffmpeg" + binary.write_bytes(b"native-authorized") + monkeypatch.setattr(mt, "_binary_runs", lambda _path: True) + token = "b" * 64 + from core import path_authorization + auth_file = os.path.join(path_authorization._AUTH_DIR, f"{token}.json") + with open(auth_file, "w", encoding="utf-8") as handle: + json.dump({"token": token, "kind": "ffmpeg", "path": str(binary)}, handle) + c = _client() + response = c.post( + "/media-tools/ffmpeg/custom-path", json={"authorization": token} + ) + assert response.status_code == 200 + assert os.environ["FFMPEG_PATH"] == str(binary) + assert not os.path.exists(auth_file) def test_router_use_system_maps_lookup_to_404(mt, monkeypatch): diff --git a/tests/test_models_dir_setting.py b/tests/test_models_dir_setting.py index 6ac7532c..d0983482 100644 --- a/tests/test_models_dir_setting.py +++ b/tests/test_models_dir_setting.py @@ -8,6 +8,7 @@ second store to diverge from. from __future__ import annotations import os +import json import fastapi import pytest @@ -24,12 +25,25 @@ def env(tmp_path, monkeypatch): # module object — a setattr monkeypatch wouldn't reach the endpoint's copy. envfile = str(tmp_path / "env") monkeypatch.setenv("OMNIVOICE_ENV_FILE", envfile) + auth_dir = tmp_path / "authorizations" + auth_dir.mkdir() + from core import path_authorization + monkeypatch.setattr(path_authorization, "_AUTH_DIR", str(auth_dir)) return envfile +def _body(path, kind="models_dir"): + from core import path_authorization + auth_dir = path_authorization._AUTH_DIR + token = "a" * 64 + with open(os.path.join(auth_dir, f"{token}.json"), "w", encoding="utf-8") as f: + json.dump({"token": token, "kind": kind, "path": path}, f) + return s._ModelsDirBody(authorization=token) + + def test_set_persists_and_writes_durable_env(env, tmp_path): target = str(tmp_path / "models") - res = s.set_models_dir(s._ModelsDirBody(path=target)) + res = s.set_models_dir(_body(target)) abs_target = os.path.abspath(target) assert res["configured"] == abs_target assert res["restart_required"] is True @@ -47,7 +61,7 @@ def test_rejects_unwritable_dir(env, monkeypatch, tmp_path): monkeypatch.setattr(os, "makedirs", boom) with pytest.raises(fastapi.HTTPException) as ei: - s.set_models_dir(s._ModelsDirBody(path=str(tmp_path / "ro"))) + s.set_models_dir(_body(str(tmp_path / "ro"))) assert ei.value.status_code == 400 @@ -55,13 +69,69 @@ def test_rejects_path_with_null_byte(env): # An embedded NUL would otherwise blow up os.makedirs with a ValueError # (→ 500). Validate up front and return a clean 400 instead. with pytest.raises(fastapi.HTTPException) as ei: - s.set_models_dir(s._ModelsDirBody(path="/tmp/mo\x00dels")) + s.set_models_dir(_body("/tmp/mo\x00dels")) assert ei.value.status_code == 400 +def test_server_mode_remote_without_api_key_cannot_create_models_dir(env, monkeypatch, tmp_path): + """GHAS #440/#441: a published bare Docker port is not filesystem auth.""" + from fastapi.testclient import TestClient + + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False) + target = tmp_path / "must-not-exist" + app = fastapi.FastAPI() + app.include_router(s.router) + response = TestClient(app, client=("172.17.0.1", 50000)).put( + "/api/settings/storage/models-dir", + json={"path": str(target)}, + ) + assert response.status_code == 403 + assert not target.exists() + + +def test_server_mode_admin_key_cannot_supply_raw_models_path(env, monkeypatch, tmp_path): + """An admin key is not a native path authorization.""" + from fastapi.testclient import TestClient + + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.setenv("OMNIVOICE_API_KEY", "s3cret") + target = tmp_path / "must-not-exist" + app = fastapi.FastAPI() + app.include_router(s.router) + response = TestClient(app, client=("172.17.0.1", 50000)).put( + "/api/settings/storage/models-dir", + headers={"authorization": "Bearer s3cret"}, + json={"path": str(target)}, + ) + assert response.status_code == 422 + assert not target.exists() + + +def test_loopback_raw_path_is_not_a_models_directory_authorization(env, tmp_path): + from fastapi.testclient import TestClient + + target = tmp_path / "must-not-exist" + app = fastapi.FastAPI() + app.include_router(s.router) + response = TestClient(app, client=("127.0.0.1", 50000)).put( + "/api/settings/storage/models-dir", json={"path": str(target)} + ) + assert response.status_code == 422 + assert not target.exists() + + +def test_models_directory_authorization_is_one_shot(env, tmp_path): + body = _body(str(tmp_path / "models")) + assert s.set_models_dir(body)["configured"] + with pytest.raises(fastapi.HTTPException) as exc: + s.set_models_dir(body) + assert exc.value.status_code == 403 + + def test_clear_reverts_to_default(env): user_env.set_user_env("OMNIVOICE_CACHE_DIR", "/old") - res = s.set_models_dir(s._ModelsDirBody(path="")) + res = s.set_models_dir(_body("")) assert res["configured"] is None assert res["restart_required"] is True assert user_env.get_user_env("OMNIVOICE_CACHE_DIR") is None @@ -84,7 +154,7 @@ def test_path_with_spaces_survives_the_full_persistence_chain(env, tmp_path, mon still shows the chosen folder. Pin the whole chain byte-for-byte: endpoint → env file → load_into_environ → os.environ → GET.""" target = str(tmp_path / "Program Data" / "OmniVoice" / "Model Cache") - res = s.set_models_dir(s._ModelsDirBody(path=target)) + res = s.set_models_dir(_body(target)) abs_target = os.path.abspath(target) assert res["configured"] == abs_target assert user_env.get_user_env("OMNIVOICE_CACHE_DIR") == abs_target diff --git a/tests/test_network_share.py b/tests/test_network_share.py index 9cd32090..a2f05184 100644 --- a/tests/test_network_share.py +++ b/tests/test_network_share.py @@ -75,6 +75,35 @@ def test_network_state_endpoint_defaults_disabled(): assert r.json()["enabled"] is False +def test_pin_only_remote_discovery_never_returns_share_pin(monkeypatch): + import importlib + + from main import app + + # Resolve the exact module instance held by the live router. The full suite + # deliberately replaces app modules in sys.modules, so the module-level + # ``ns`` test helper may no longer be the endpoint's dependency. + live_network_share = importlib.import_module("api.routers.system").network_share + + monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") + monkeypatch.delenv("OMNIVOICE_API_KEY", raising=False) + monkeypatch.setattr( + live_network_share, + "_state", + live_network_share.ShareState(True, 3901, "123456", ["192.168.1.10"]), + ) + # Keep the consumption middleware inert: this endpoint is testing the + # intentional admin read-only exception itself, before a PIN is supplied. + monkeypatch.setattr(app.state, "network_share", None, raising=False) + response = TestClient(app, client=("172.17.0.1", 50000)).get( + "/system/network/state" + ) + assert response.status_code == 200 + assert response.json()["pin"] is None + assert response.json()["pin_required"] is True + assert "123456" not in response.text + + def test_network_control_rejects_non_loopback(): from main import app c = TestClient(app, client=("10.0.0.5", 9999)) diff --git a/tests/test_resolve_heartbeat_1414.py b/tests/test_resolve_heartbeat_1414.py new file mode 100644 index 00000000..a52f44b1 --- /dev/null +++ b/tests/test_resolve_heartbeat_1414.py @@ -0,0 +1,286 @@ +"""Resolving an engine's venv is work, and work must report progress (#1414). + +`SubprocessBackend._spawn()` calls `venv_python()`, and on a cold first run +that is not cheap: the probe spawns each candidate interpreter to import the +engine (tens of seconds on a slow disk), and if none is installed it can run +the whole `uv venv` + `uv pip install` bootstrap — which is bounded at 900 s +*by design*, because installing torch takes minutes. + +All of that happens on a GPU-pool worker, inside a generate request whose +execution budget defaults to 300 s. Nothing along the way reported progress, +so the budget expired part-way through and the job was abandoned — and, until +#1424, blamed on the machine's compute. The first generation that triggers a +bootstrap could therefore never succeed, no matter how good the hardware. + +The sidecar's own cold model load already heartbeats for exactly this reason +(#1367). Resolution is the step immediately before it that never did. +""" +from __future__ import annotations + +import threading +from contextlib import contextmanager +from pathlib import Path + +import pytest + + +@pytest.fixture +def sb(): + """Resolved at run time, not import time.""" + import services.subprocess_backend as _sb + + return _sb + + +@pytest.fixture +def mm(): + import services.model_manager as _mm + + return _mm + + +def test_a_slow_resolution_extends_the_deadline(sb, mm, monkeypatch): + """The whole point: a resolution that outlives one heartbeat interval + leaves proof of life on the execution clock.""" + monkeypatch.setattr(sb, "_RESOLVE_HEARTBEAT_S", 0) + monkeypatch.setattr(mm, "running_on_gpu_pool", lambda: True) + ident = threading.get_ident() + mm._MODEL_LOAD_ACTIVITY.pop(ident, None) + + wrote = threading.Event() + + class _SignallingMap(dict): + def __setitem__(self, key, value): + super().__setitem__(key, value) + wrote.set() + + monkeypatch.setattr(mm, "_MODEL_LOAD_ACTIVITY", _SignallingMap()) + with sb._heartbeat_while_resolving("indextts2"): + assert wrote.wait(2), "the heartbeat thread never reported progress" + + assert ident in mm._MODEL_LOAD_ACTIVITY, ( + "a slow venv resolution reported no progress — the generate budget " + "expires part-way through the install it is waiting for" + ) + mm._MODEL_LOAD_ACTIVITY.pop(ident, None) + + +def test_it_credits_the_resolving_thread_not_the_beater(sb, mm, monkeypatch): + """The heartbeat runs on a helper thread so it can tick while resolution + blocks — but the job the clock is watching is the caller's. Crediting the + helper's ident would extend nothing and would poison an ident a pool + worker may later reuse. + + Asserted against the beater's OWN ident rather than a before/after diff of + the activity map: other tests in the suite have live pool workers, so the + diff is not this test's to own. + """ + monkeypatch.setattr(sb, "_RESOLVE_HEARTBEAT_S", 0) + monkeypatch.setattr(mm, "running_on_gpu_pool", lambda: True) + ident = threading.get_ident() + mm._MODEL_LOAD_ACTIVITY.pop(ident, None) + beater_idents: set[int] = set() + wrote = threading.Event() + + class _SignallingMap(dict): + def __setitem__(self, key, value): + super().__setitem__(key, value) + wrote.set() + + monkeypatch.setattr(mm, "_MODEL_LOAD_ACTIVITY", _SignallingMap()) + + real_thread = threading.Thread + + class _Recording(real_thread): + def run(self): + beater_idents.add(threading.get_ident()) + super().run() + + monkeypatch.setattr(sb.threading, "Thread", _Recording) + + with sb._heartbeat_while_resolving("indextts2"): + assert wrote.wait(2), "the heartbeat thread never reported progress" + + assert ident in mm._MODEL_LOAD_ACTIVITY, "the caller's job was never credited" + assert beater_idents, "no heartbeat thread ran" + assert not (beater_idents & set(mm._MODEL_LOAD_ACTIVITY)), ( + "the heartbeat thread credited its own ident — that extends nothing " + "and poisons an ident a pool worker may later reuse" + ) + mm._MODEL_LOAD_ACTIVITY.pop(ident, None) + + +def test_an_off_pool_caller_never_heartbeats(sb, mm, monkeypatch): + """#1379's lesson: an off-pool thread's ident is not tracked by the clock, + and a pool worker that later reuses it would inherit unearned extension.""" + monkeypatch.setattr(mm, "running_on_gpu_pool", lambda: False) + ident = threading.get_ident() + mm._MODEL_LOAD_ACTIVITY.pop(ident, None) + + class _UnexpectedThread: + def __init__(self, *args, **kwargs): + raise AssertionError("an off-pool call started a heartbeat helper") + + monkeypatch.setattr(sb.threading, "Thread", _UnexpectedThread) + with sb._heartbeat_while_resolving("indextts2"): + pass + + assert ident not in mm._MODEL_LOAD_ACTIVITY + + +def test_the_beater_stops_when_resolution_finishes(sb, mm, monkeypatch): + """A thread per spawn that never exits would accumulate one per generate.""" + monkeypatch.setattr(sb, "_RESOLVE_HEARTBEAT_S", 0) + monkeypatch.setattr(mm, "running_on_gpu_pool", lambda: True) + exited = threading.Event() + real_thread = threading.Thread + + class _Recording(real_thread): + def run(self): + try: + super().run() + finally: + exited.set() + + monkeypatch.setattr(sb.threading, "Thread", _Recording) + + with sb._heartbeat_while_resolving("indextts2"): + pass + + assert exited.wait(2), "heartbeat helper survived context exit" + mm._MODEL_LOAD_ACTIVITY.pop(threading.get_ident(), None) + + +def test_a_broken_heartbeat_does_not_break_the_spawn(sb, monkeypatch): + """Never raises: a generation must not fail because progress reporting + could not import or could not write.""" + monkeypatch.setattr(sb, "_RESOLVE_HEARTBEAT_S", 0.01) + + import services.model_manager as mm + + monkeypatch.setattr( + mm, "running_on_gpu_pool", + lambda: (_ for _ in ()).throw(RuntimeError("clock is broken")), + ) + with sb._heartbeat_while_resolving("indextts2"): + pass # the point is that this block is reached and exits cleanly + + +def test_spawn_wraps_the_resolution(sb, monkeypatch): + """A guard against the wrapper being dropped in a later refactor: the + heartbeat is worthless if `venv_python()` is called outside it.""" + active = False + resolved_inside = threading.Event() + + @contextmanager + def _recording_heartbeat(_engine_id): + nonlocal active + active = True + try: + yield + finally: + active = False + + class _StopAfterResolution(RuntimeError): + pass + + class _Backend(sb.SubprocessBackend): + id = "test" + + @property + def sample_rate(self): + return 24_000 + + @property + def supported_languages(self): + return ["en"] + + @classmethod + def is_available(cls): + return True, "ready" + + @classmethod + def venv_python(cls): + assert active, "venv_python() ran outside the heartbeat context" + resolved_inside.set() + return Path("python") + + @classmethod + def sidecar_script(cls): + raise _StopAfterResolution + + monkeypatch.setattr(sb, "_heartbeat_while_resolving", _recording_heartbeat) + backend = _Backend.__new__(_Backend) + backend._proc = None + with pytest.raises(_StopAfterResolution): + backend._spawn() + assert resolved_inside.is_set() + + +def test_no_heartbeat_write_escapes_the_context(sb, mm, monkeypatch): + """The late-write race (CodeRabbit, #1426). + + `_beat()` can be past its `stop.wait()` and already committed to a write + at the moment the context exits. Signalling the stop flag without joining + lets that write land afterwards — and `_run_on_gpu_pool`'s `_job` pops + this ident right after, precisely so a stale beat cannot vouch for a later + job on the same (reused) worker ident. A write that arrives after the pop + resurrects the entry, and the next job inherits a heartbeat it never sent: + the wedge detector reads it as progress and keeps extending a stuck job. + + The interleaving is forced rather than waited for. A patched writer parks + inside the write until the test releases it, so the exit path must be the + thing that waits — if it only signals, the write lands after the context + and the assertion catches it deterministically, on every run and every + scheduler. + """ + monkeypatch.setattr(sb, "_RESOLVE_HEARTBEAT_S", 0) + monkeypatch.setattr(mm, "running_on_gpu_pool", lambda: True) + ident = threading.get_ident() + mm._MODEL_LOAD_ACTIVITY.pop(ident, None) + + in_write = threading.Event() + release = threading.Event() + write_finished = threading.Event() + + class _ParkingMap(dict): + """Stalls the heartbeat mid-write so the exit path has to wait.""" + + def __setitem__(self, key, value): + in_write.set() + assert release.wait(5), "test did not release the parked write" + super().__setitem__(key, value) + write_finished.set() + + context_exited = threading.Event() + monkeypatch.setattr(mm, "_MODEL_LOAD_ACTIVITY", _ParkingMap()) + + joined = threading.Event() + real_thread = threading.Thread + + class _JoinRecordingThread(real_thread): + def join(self, *args, **kwargs): + joined.set() + return super().join(*args, **kwargs) + + monkeypatch.setattr(sb.threading, "Thread", _JoinRecordingThread) + + def _run_context(): + with sb._heartbeat_while_resolving("indextts2"): + assert in_write.wait(5), "heartbeat never attempted a write" + context_exited.set() + + runner = real_thread(target=_run_context) + runner.start() + try: + assert in_write.wait(5), "heartbeat never reached the parked write" + assert joined.wait(2), "context exit did not wait for the heartbeat helper" + assert not context_exited.is_set(), "context exited before the write finished" + finally: + release.set() + runner.join(5) + + assert write_finished.is_set(), "parked heartbeat write did not finish" + assert not runner.is_alive(), "resolve context did not exit after the write" + assert context_exited.is_set() + mm._MODEL_LOAD_ACTIVITY.pop(ident, None) diff --git a/tests/test_voice_design_instruct_heal.py b/tests/test_voice_design_instruct_heal.py index bafbc53c..989bb72c 100644 --- a/tests/test_voice_design_instruct_heal.py +++ b/tests/test_voice_design_instruct_heal.py @@ -7,6 +7,8 @@ strip poison ("[object Object]", freeform prose) down to whitelist tags, recovering a design from ``vd_states`` when the stored value is unusable. """ import json +from statistics import median +import time import pytest @@ -57,6 +59,27 @@ def test_sanitize_handles_empty_and_poison(bad): assert sanitize_instruct(bad) == "" +def test_sanitize_long_whitespace_run_is_linear_time(): + sizes = (2_000, 8_000, 32_000) + timings = [] + for size in sizes: + poisoned = "female" + (" " * size) + "not-a-tag" + assert sanitize_instruct(poisoned) == "" # warm timer/cache paths + samples = [] + for _ in range(5): + started = time.perf_counter_ns() + assert sanitize_instruct(poisoned) == "" + samples.append(time.perf_counter_ns() - started) + timings.append(median(samples)) + + # Input grows 4x per step and 16x overall. Generous multipliers and a + # 20 ms scheduling floor tolerate noisy shared runners while still + # separating linear split/strip behavior from the former quadratic regex. + for smaller, larger in zip(timings, timings[1:]): + assert larger <= max(smaller * 10, 20_000_000) + assert timings[-1] <= max(timings[0] * 48, 20_000_000) + + def test_instruct_from_vd_states_dict_drops_auto(): vd = {"gender": "female", "age": "Auto", "pitch": "high pitch", "accent": "british accent"} assert instruct_from_vd_states(vd) == "female, high pitch, british accent" diff --git a/uv.lock b/uv.lock index 22fa18e9..874d4b4e 100644 --- a/uv.lock +++ b/uv.lock @@ -70,7 +70,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.1" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -82,108 +82,108 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, - { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, - { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, - { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, - { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, - { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, - { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, - { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, - { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, - { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, - { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, - { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, - { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, - { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, - { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, - { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, - { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, - { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, - { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, - { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, - { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, - { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, - { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, - { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, - { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, - { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, - { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, - { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, - { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, - { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, - { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, - { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, - { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, - { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, - { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, - { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, - { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, - { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, - { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, - { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, - { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]]