* 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>
288 lines
11 KiB
Python
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}
|