* 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>
85 lines
3.2 KiB
Python
85 lines
3.2 KiB
Python
"""Durable per-user environment file (`~/.config/omnivoice/env`).
|
|
|
|
`main.py` loads this file at startup (via dotenv) before importing torch/HF, so
|
|
values written here take effect on the next backend launch. Used by the
|
|
configurable models directory (#64): the Settings endpoint upserts
|
|
``OMNIVOICE_CACHE_DIR`` here, which main.py then maps to
|
|
``HF_HOME`` / ``HF_HUB_CACHE`` / ``TORCH_HOME``.
|
|
|
|
Format is dotenv-style ``KEY=value`` lines. Upsert preserves other keys (e.g. a
|
|
persisted ``HF_TOKEN``) and writes the file ``0600`` (it can hold secrets).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Optional
|
|
|
|
USER_ENV_PATH = os.path.expanduser("~/.config/omnivoice/env")
|
|
|
|
|
|
def _read_lines(path: str) -> list[str]:
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return f.read().splitlines()
|
|
except FileNotFoundError:
|
|
return []
|
|
# Any *other* OSError (permission, I/O error) propagates: collapsing it to
|
|
# an empty baseline would make a subsequent upsert silently drop existing
|
|
# keys (e.g. a persisted HF_TOKEN) when it rewrites the file.
|
|
|
|
|
|
def _opener_0600(path: str, flags: int) -> int:
|
|
# Create the file with 0600 from the start (no world-readable window before
|
|
# a follow-up chmod) — it can hold secrets like HF_TOKEN. The mode is
|
|
# masked by umask but only ever *more* restrictive; non-POSIX platforms
|
|
# ignore the mode bits.
|
|
return os.open(path, flags, 0o600)
|
|
|
|
|
|
def _write_lines(path: str, lines: list[str]) -> None:
|
|
parent = os.path.dirname(path)
|
|
if parent: # bare filename (e.g. an OMNIVOICE_ENV_FILE override) has no parent
|
|
os.makedirs(parent, exist_ok=True)
|
|
body = "\n".join(lines)
|
|
if body and not body.endswith("\n"):
|
|
body += "\n"
|
|
with open(path, "w", encoding="utf-8", opener=_opener_0600) as f:
|
|
f.write(body)
|
|
try:
|
|
os.chmod(path, 0o600) # tighten an existing file that predates the opener
|
|
except OSError:
|
|
pass # best-effort; some filesystems/Windows don't support chmod
|
|
|
|
|
|
def get_user_env(key: str, path: Optional[str] = None) -> Optional[str]:
|
|
path = path or os.environ.get("OMNIVOICE_ENV_FILE") or USER_ENV_PATH # resolved at call time so tests can monkeypatch
|
|
prefix = f"{key}="
|
|
for line in _read_lines(path):
|
|
if line.startswith(prefix):
|
|
return line[len(prefix):]
|
|
return None
|
|
|
|
|
|
def set_user_env(key: str, value: str, path: Optional[str] = None) -> None:
|
|
"""Upsert ``KEY=value``, preserving all other lines."""
|
|
path = path or os.environ.get("OMNIVOICE_ENV_FILE") or USER_ENV_PATH
|
|
prefix = f"{key}="
|
|
lines = _read_lines(path)
|
|
replaced = False
|
|
for i, line in enumerate(lines):
|
|
if line.startswith(prefix):
|
|
lines[i] = f"{key}={value}"
|
|
replaced = True
|
|
break
|
|
if not replaced:
|
|
lines.append(f"{key}={value}")
|
|
_write_lines(path, lines)
|
|
|
|
|
|
def unset_user_env(key: str, path: Optional[str] = None) -> None:
|
|
"""Remove ``KEY=...`` if present, preserving all other lines."""
|
|
path = path or os.environ.get("OMNIVOICE_ENV_FILE") or USER_ENV_PATH
|
|
prefix = f"{key}="
|
|
lines = [ln for ln in _read_lines(path) if not ln.startswith(prefix)]
|
|
_write_lines(path, lines)
|