Files
VoiceStudio/backend/api/routers/settings.py
T
Palash DebnathandClaude Opus 4.8 1cfda2f44e feat(settings): configurable models directory (#64) (#149)
* feat(settings): configurable models directory (#64)

Let users pick where model weights download (the HuggingFace / Torch
cache) instead of being pinned to ~/.cache/huggingface — useful when the
system drive is small or slow.

Backend:
- core/user_env.py: durable per-user env file (~/.config/omnivoice/env)
  helper with upsert/unset that preserves other keys and writes 0600.
  main.py already loads this at startup before importing torch/HF, so the
  value takes effect on the next launch. Path resolves at call time via an
  OMNIVOICE_ENV_FILE override so it's robust to module re-import in tests.
- settings.py: GET/PUT /api/settings/storage/models-dir — validates the
  dir is writable (mkdir + write-probe → 400 if not), persists the choice,
  and writes OMNIVOICE_CACHE_DIR to the durable env. Empty path clears →
  reverts to default. Returns restart_required since an in-use cache can't
  be safely moved mid-process. Loopback-gated like the other settings.

Frontend:
- StoragePanel: Models tab panel to view/set/reset the directory, shows
  effective vs configured vs default + a restart note.

Cross-platform default parity preserved (default cache path is the HF
default on every OS); local-first (no network); backward-compatible
(absent setting → existing behavior). No version bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#64): harden models-dir input + clear CodeQL hygiene flags

- settings.py: reject control/NUL chars in the path with a 400 before any
  filesystem call (an embedded NUL otherwise raised ValueError → 500). Also
  serves as the explicit input-validation barrier for the user-chosen path
  (loopback-gated same-user local file picker — no cross-privilege boundary).
- test_user_env.py: use `with open(...)` so the file is closed and the assert
  has no side effects.
- user_env.py: comment the best-effort chmod except clause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(#64): single source of truth for models dir + review fixes

Address CodeRabbit + Greptile review on PR #149:

- P1 (both bots): the settings_store copy of the models dir was only ever
  read by this GET endpoint, so it was a redundant cache that could diverge
  from the durable env file (the value main.py actually reads). Drop it —
  the per-user env file (OMNIVOICE_CACHE_DIR) is now the single source of
  truth: PUT writes it, GET reads it back. No divergence possible.
- XDG-aware default (CodeRabbit): _default_models_dir now honors
  XDG_CACHE_HOME, matching huggingface_hub's real default on Linux.
- Atomic 0600 write (Greptile, security): user_env writes via an os.open
  opener that creates the file 0600 from the start — no world-readable
  window before chmod for a file that can hold HF_TOKEN.
- _read_lines only swallows FileNotFoundError; other OSErrors propagate so
  an upsert can't silently drop existing keys on a transient read failure.
- Guard makedirs("") when the env path is a bare filename (no parent).
- Best-effort write-probe cleanup in a finally; raise ... from e.
- a11y: label the models-dir input via aria-labelledby/aria-describedby.
- OS-neutral unwritable-dir test (mock makedirs) instead of Unix-only
  /dev/null path semantics.

12 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 16:52:50 +05:30

288 lines
11 KiB
Python

"""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.
The state endpoint duplicates `/system/hf-token/state` (which lives on
`system.py` for legacy-router compatibility); both return the same shape.
"""
from __future__ import annotations
import logging
import os
from dataclasses import asdict
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from api.dependencies import require_loopback
logger = logging.getLogger("omnivoice.api.settings")
router = APIRouter(
prefix="/api/settings",
tags=["settings"],
dependencies=[Depends(require_loopback)],
)
class _HFTokenBody(BaseModel):
token: str = Field(..., min_length=1, description="HuggingFace access token")
def _state_response() -> dict:
"""Return the same shape the React panel renders. Never includes raw token."""
from services import token_resolver
s = token_resolver.state()
return {
"active": s["active"],
"sources": [asdict(row) for row in s["sources"]],
}
@router.post("/hf-token")
def save_hf_token(body: _HFTokenBody):
"""Persist a new HF token to the encrypted settings store + the HF
canonical file (via huggingface_hub.login). Returns the updated
cascade state."""
token = body.token.strip()
if not token:
raise HTTPException(status_code=400, detail="token must be non-empty")
from services import token_resolver
try:
token_resolver.save_app_token(token)
except Exception:
logger.exception("save_app_token failed")
raise HTTPException(status_code=500, detail="Failed to save HF token")
return _state_response()
@router.delete("/hf-token")
def clear_hf_token(also_clear_hf_cli: bool = Query(False)):
"""Clear the App-source token. Optionally also call huggingface_hub.logout
to clear the canonical HF file. Returns the updated cascade state."""
from services import token_resolver
try:
token_resolver.clear_app_token(also_clear_hf_cli=also_clear_hf_cli)
except Exception:
logger.exception("clear_app_token failed")
raise HTTPException(status_code=500, detail="Failed to clear HF token")
return _state_response()
@router.get("/hf-token/state")
def get_hf_token_state():
"""3-source HF token cascade state for the Settings UI."""
return _state_response()
# ── Performance settings (INST-12) ────────────────────────────────────────
# Threat T-02-04: same loopback guard as the hf-token endpoints via the
# router-level `require_loopback` dep.
_TORCH_COMPILE_KEY = "perf.torch_compile_disabled"
class _TorchCompileBody(BaseModel):
enabled: bool = Field(..., description="True to set TORCH_COMPILE_DISABLE=1 on engine subprocesses")
def _torch_compile_state() -> dict:
import sys
from services import settings_store
raw = settings_store.get_text(_TORCH_COMPILE_KEY, "0")
return {"enabled": raw == "1", "platform": sys.platform}
@router.get("/perf/torch-compile-disabled")
def get_torch_compile_disabled():
"""Return the current torch.compile-disabled toggle + the runtime platform.
UI uses the platform to render the toggle disabled (with an explainer)
on non-Windows hosts, since the OOM is Windows-specific (issue #65)."""
return _torch_compile_state()
@router.put("/perf/torch-compile-disabled")
def set_torch_compile_disabled(body: _TorchCompileBody):
"""Persist the toggle. Honoured by `services.engine_env.build_engine_env()`
which injects TORCH_COMPILE_DISABLE=1 on Windows when enabled."""
from services import settings_store
try:
settings_store.set_text(_TORCH_COMPILE_KEY, "1" if body.enabled else "0")
except Exception:
logger.exception("set_torch_compile_disabled failed")
raise HTTPException(status_code=500, detail="Failed to persist setting")
return _torch_compile_state()
# ── License acceptance (Phase 3 Plan 03-01 / TTS-05) ──────────────────────
# Frontend ``SupertonicLicenseDialog`` flips the engine-license bit via this
# endpoint. The handler is loopback-gated (router-level dep) and the
# engine_id is allow-listed so an arbitrary string cannot be persisted.
# Threat T-03-04 in the plan frontmatter: this is an honest-acknowledgment
# gate, not a security boundary; the loopback + allow-list keeps the
# attack surface tight regardless.
#: Engines that have an in-tree acceptance dialog. Adding a new engine
#: here means adding a corresponding frontend dialog + a license URLs
#: dict in its constants module. Until that, the API refuses the write.
_LICENSE_ALLOWED_ENGINES: frozenset[str] = frozenset({"supertonic3"})
class _LicenseAcceptBody(BaseModel):
engine_id: str = Field(..., min_length=1, max_length=64)
accepted: bool = Field(..., description="True to accept the license terms")
@router.post("/license")
def post_license_acceptance(body: _LicenseAcceptBody) -> dict:
"""Persist a per-engine license-acceptance boolean.
Returns ``{"ok": True, "engine_id": ..., "accepted": ...}`` so the
caller can update its UI without a second round-trip. Validation:
``engine_id`` must be in the in-tree allow-list ‑‑ refuses arbitrary
keys so the settings table can't be polluted via this route.
"""
eid = body.engine_id.strip().lower()
if eid not in _LICENSE_ALLOWED_ENGINES:
raise HTTPException(
status_code=400,
detail=(
f"engine_id {eid!r} is not in the license allow-list "
f"{sorted(_LICENSE_ALLOWED_ENGINES)}"
),
)
from services import settings_store
try:
settings_store.set_license_accepted(eid, body.accepted)
except Exception:
logger.exception("set_license_accepted failed for %s", eid)
raise HTTPException(status_code=500, detail="Failed to persist license acceptance")
return {"ok": True, "engine_id": eid, "accepted": bool(body.accepted)}
@router.get("/license/{engine_id}")
def get_license_acceptance(engine_id: str) -> dict:
"""Return ``{"engine_id": ..., "accepted": bool}``.
Same allow-list as the POST handler so an unknown engine id is a
400 rather than a silent ``accepted=false`` for a non-existent
engine.
"""
eid = engine_id.strip().lower()
if eid not in _LICENSE_ALLOWED_ENGINES:
raise HTTPException(
status_code=400,
detail=(
f"engine_id {eid!r} is not in the license allow-list "
f"{sorted(_LICENSE_ALLOWED_ENGINES)}"
),
)
from services import settings_store
try:
accepted = settings_store.get_license_accepted(eid)
except Exception:
logger.exception("get_license_accepted failed for %s", eid)
raise HTTPException(status_code=500, detail="Failed to read license acceptance")
return {"engine_id": eid, "accepted": bool(accepted)}
# ── Storage: configurable models directory (#64) ──────────────────────────
# Where HuggingFace / Torch download model weights. The user's choice is
# persisted durably to the per-user env file as OMNIVOICE_CACHE_DIR, which
# main.py maps to HF_HOME / HF_HUB_CACHE / TORCH_HOME at startup. That env file
# is the *single source of truth*: PUT writes it, GET reads it back — there is
# no second store to diverge from. Takes effect on the next backend restart
# (a storage-location change can't safely move an in-use cache mid-process).
_MODELS_DIR_ENV = "OMNIVOICE_CACHE_DIR"
def _default_models_dir() -> str:
"""huggingface_hub's default cache root, honoring XDG_CACHE_HOME on Linux
(matches HF so GET reports the *true* default the backend would use)."""
base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
return os.path.join(base, "huggingface")
def _effective_models_dir() -> str:
return (
os.environ.get("HF_HUB_CACHE")
or os.environ.get("HUGGINGFACE_HUB_CACHE")
or os.environ.get("HF_HOME")
or _default_models_dir()
)
class _ModelsDirBody(BaseModel):
path: str = Field(default="", description="Absolute directory; empty clears → default cache")
@router.get("/storage/models-dir")
def get_models_dir():
"""Current models directory: the persisted choice (from the durable env
file — the same value main.py reads at startup), what's effective in this
process, and the platform default."""
from core import user_env
configured = user_env.get_user_env(_MODELS_DIR_ENV) or None
return {
"configured": configured,
"effective": _effective_models_dir(),
"default": _default_models_dir(),
"restart_required": False,
}
@router.put("/storage/models-dir")
def set_models_dir(body: _ModelsDirBody):
"""Set (or clear, with an empty path) the models download directory.
Validates the directory is writable, then writes OMNIVOICE_CACHE_DIR to the
durable per-user env file so main.py applies it on the next launch. The env
file is the only persisted store, so GET can never diverge from what was
saved. Returns restart_required=True.
"""
from core import user_env
raw = (body.path or "").strip()
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).
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in raw):
raise HTTPException(status_code=400, detail="Path contains invalid control characters")
path = os.path.abspath(os.path.expanduser(raw))
try:
os.makedirs(path, exist_ok=True)
probe = os.path.join(path, ".omnivoice_write_test")
with open(probe, "w", encoding="utf-8") as f:
f.write("ok")
except OSError as e:
raise HTTPException(status_code=400, detail=f"Directory is not writable: {e}") from e
finally:
# Best-effort cleanup; a failed remove (concurrent process, perm change)
# must not leave the request hanging or mask the real error.
try:
os.remove(os.path.join(path, ".omnivoice_write_test"))
except OSError:
pass
user_env.set_user_env(_MODELS_DIR_ENV, path)
return {"configured": path, "effective": _effective_models_dir(), "restart_required": True}