fix: fingerprint .env values, parse manifests like the agent, gate stale models fallback on sources

This commit is contained in:
carlotestor committed 2026-09-24 15:29:51 +00:00
1 parent 7344c8df77
commit 2dd1366033
4 files changed
+127 -27

No files matched your search

+42 -12
View File
@@ -7926,20 +7926,48 @@ def _active_profile_home() -> Path:
def _models_cache_env_fingerprint(path: Path) -> list: def _models_cache_env_fingerprint(path: Path) -> list:
"""Sorted non-empty ``.env`` key names, parsed like provider detection; values never recorded.""" """``[key, HMAC(value)]`` per non-empty ``.env`` entry, parsed like provider detection.
Values are keyed-hashed with the WebUI signing key so the cache file never holds a secret.
"""
import hmac
from api.auth import _signing_key
from api.providers import _load_env_file from api.providers import _load_env_file
return sorted(k for k, v in _load_env_file(Path(path).expanduser()).items() if v) key = _signing_key()
return [
[k, hmac.new(key, v.encode("utf-8"), hashlib.sha256).hexdigest()]
for k, v in sorted(_load_env_file(Path(path).expanduser()).items())
if v
]
def _declares_model_provider_kind(plugin_dir: Path) -> bool: def _declares_model_provider_kind(plugin_dir: Path) -> bool:
for manifest in (plugin_dir / "plugin.yaml", plugin_dir / "plugin.yml"): # Same parse as the agent's providers._declares_model_provider_kind: PyYAML, then a line scan.
try: for filename in ("plugin.yaml", "plugin.yml"):
lines = manifest.read_text(encoding="utf-8", errors="replace").splitlines() manifest = plugin_dir / filename
except OSError: if not manifest.is_file():
continue continue
pairs = (line.partition(":") for line in lines if not line[:1].isspace()) try:
return any(k.strip() == "kind" and v.strip().strip("\"'") == "model-provider" for k, _, v in pairs) text = manifest.read_text(encoding="utf-8", errors="replace")
except Exception:
return False
try:
import yaml as _yaml
data = _yaml.safe_load(text)
if isinstance(data, dict):
return str(data.get("kind", "")).strip() == "model-provider"
except Exception:
pass
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("#") or ":" not in stripped:
continue
key, _, value = stripped.partition(":")
if key.strip() == "kind":
return value.strip().strip("\"'") == "model-provider"
return False
return False return False
@@ -8148,10 +8176,10 @@ def _load_stale_models_cache_from_disk() -> dict | None:
The main cache loader enforces metadata stamps for a full cold-path cache hit. The main cache loader enforces metadata stamps for a full cold-path cache hit.
This helper intentionally does not apply that stricter policy, so we can still This helper intentionally does not apply that stricter policy, so we can still
recover a useful fallback payload when the strict loader rejected cache because recover a useful fallback payload when the strict loader rejected cache because
metadata or fingerprint fields are stale. It DOES still enforce the schema the WebUI version stamp is stale. It DOES still enforce the schema version (a
version: a cross-schema cache can have an incompatible groups/badge shape, so cross-schema cache can have an incompatible groups/badge shape) and the source
serving it to the picker could surface a broken catalog — schema mismatch is a fingerprint: a snapshot built from other config/auth/.env/plugin sources is a
hard reject even on the fallback path. wrong catalog, not merely an old one, so it is never served.
""" """
try: try:
import json as _j import json as _j
@@ -8165,6 +8193,8 @@ def _load_stale_models_cache_from_disk() -> dict | None:
return None return None
if cache.get("_schema_version") != _MODELS_CACHE_SCHEMA_VERSION: if cache.get("_schema_version") != _MODELS_CACHE_SCHEMA_VERSION:
return None return None
if cache.get("_source_fingerprint") != _models_cache_source_fingerprint():
return None
aliases = cache.get("aliases") aliases = cache.get("aliases")
if not isinstance(aliases, dict): if not isinstance(aliases, dict):
# The disk cache save path does not persist `aliases`, so a cache # The disk cache save path does not persist `aliases`, so a cache
@@ -31,10 +31,13 @@ the fingerprint captured at publish time.
| --- | --- | --- | | --- | --- | --- |
| `config_yaml` | stat identity: `mtime_ns` + size (`_models_cache_file_fingerprint`) | The file is rewritten only on deliberate user edits, and any edit can change the provider/model set, so the cheap conservative identity wins. | | `config_yaml` | stat identity: `mtime_ns` + size (`_models_cache_file_fingerprint`) | The file is rewritten only on deliberate user edits, and any edit can change the provider/model set, so the cheap conservative identity wins. |
| `auth_json` | content hash with a volatile-key deny-list (`_auth_store_semantic_fingerprint`, `_AUTH_FINGERPRINT_VOLATILE_KEYS`) | The credential store is rewritten roughly every 14 minutes by credential-pool / OAuth refresh; none of those rotating fields feed `detected_providers` or the returned catalog, and stat identity made the 24h cache churn on every refresh (RCA `t_d127953d` / `t_16551f61`). | | `auth_json` | content hash with a volatile-key deny-list (`_auth_store_semantic_fingerprint`, `_AUTH_FINGERPRINT_VOLATILE_KEYS`) | The credential store is rewritten roughly every 14 minutes by credential-pool / OAuth refresh; none of those rotating fields feed `detected_providers` or the returned catalog, and stat identity made the 24h cache churn on every refresh (RCA `t_d127953d` / `t_16551f61`). |
| `env` | sorted **names** of non-empty `.env` keys, parsed by `providers._load_env_file` (`_models_cache_env_fingerprint`); values are never recorded | Env credentials decide `detected_providers`; rotating a value does not. | | `env` | `[key, HMAC-SHA256(signing key, value)]` per non-empty `.env` entry, parsed by `providers._load_env_file` (`_models_cache_env_fingerprint`); plaintext values are never recorded | Env keys decide `detected_providers` and values such as `LM_BASE_URL` decide which endpoint is probed, so both key and value changes invalidate. |
| `plugins` | `[relpath, mtime_ns, size]` of every non-bytecode file of each model-provider plugin, discovered like `providers._scan_home_layer` (`_models_cache_plugin_fingerprint`) | The loader execs plugin code that builds `fallback_models`, so any file edit must invalidate, even without a `version` bump. | | `plugins` | `[relpath, mtime_ns, size]` of every non-bytecode file of each model-provider plugin, discovered like `providers._scan_home_layer`, flat manifests parsed with PyYAML like the agent (`_models_cache_plugin_fingerprint`) | The loader execs plugin code that builds `fallback_models`, so any file edit must invalidate, even without a `version` bump. |
| `catalog` | baked-in provider catalog sha256 (`_PROVIDER_MODELS` + `_PROVIDER_DISPLAY`) plus the Codex local catalog (`_codex_models_cache_fingerprint`, `_CODEX_CACHE_FINGERPRINT_VOLATILE_KEYS`) | A restart after a catalog change must not keep serving a persisted payload for up to 24h (#2443). Codex rewrites `~/.codex/models_cache.json` on its own timer, bumping `mtime_ns` and size while models, `etag`, and `client_version` stay identical, so the Codex axis hashes **content** with only the refresh timestamps (`fetched_at`, `updated_at`) removed (#7540, #7556). | | `catalog` | baked-in provider catalog sha256 (`_PROVIDER_MODELS` + `_PROVIDER_DISPLAY`) plus the Codex local catalog (`_codex_models_cache_fingerprint`, `_CODEX_CACHE_FINGERPRINT_VOLATILE_KEYS`) | A restart after a catalog change must not keep serving a persisted payload for up to 24h (#2443). Codex rewrites `~/.codex/models_cache.json` on its own timer, bumping `mtime_ns` and size while models, `etag`, and `client_version` stay identical, so the Codex axis hashes **content** with only the refresh timestamps (`fetched_at`, `updated_at`) removed (#7540, #7556). |
The over-budget stale fallback (`_load_stale_models_cache_from_disk`) tolerates a stale
`_webui_version` but never a source-fingerprint mismatch: such a snapshot is a wrong catalog.
## Invariant: deny-lists are one-directional ## Invariant: deny-lists are one-directional
- Both volatile-key sets are deny-lists, never allow-lists. They may remove only - Both volatile-key sets are deny-lists, never allow-lists. They may remove only
+17 -8
View File
@@ -136,16 +136,12 @@ def _build_stale_disk_cache_payload() -> dict:
"aliases": { "aliases": {
"chat": "ollama-cloud/chat-1", "chat": "ollama-cloud/chat-1",
}, },
# Current schema (so the stale-cache loader's schema guard accepts it), # Current schema and sources, but a stale _webui_version so the STRICT loader
# but a deliberately stale _webui_version + fingerprint so the STRICT # rejects it: the "recoverable stale cache" case. A source-fingerprint
# loader rejects it — this is exactly the "recoverable stale cache" case. # mismatch is a wrong catalog and is covered by the rejection test below.
"_schema_version": cfg._MODELS_CACHE_SCHEMA_VERSION, "_schema_version": cfg._MODELS_CACHE_SCHEMA_VERSION,
"_webui_version": "v999", "_webui_version": "v999",
"_source_fingerprint": { "_source_fingerprint": cfg._models_cache_source_fingerprint(),
"catalog": "stale",
"config_yaml": {"size": 42},
"auth_json": {"size": 7},
},
} }
@@ -401,6 +397,19 @@ def test_load_stale_models_cache_from_disk_rejects_cross_schema(
assert cfg._load_stale_models_cache_from_disk() is None assert cfg._load_stale_models_cache_from_disk() is None
def test_load_stale_models_cache_from_disk_rejects_source_fingerprint_mismatch(
monkeypatch,
isolate_models_catalog_state,
):
payload = _build_stale_disk_cache_payload()
payload["_source_fingerprint"] = "other-sources"
models_cache_path = isolate_models_catalog_state["models_cache_path"]
monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: models_cache_path)
models_cache_path.write_text(json.dumps(payload), encoding="utf-8")
assert cfg._load_stale_models_cache_from_disk() is None
def test_load_stale_models_cache_reconstructs_aliases_from_config( def test_load_stale_models_cache_reconstructs_aliases_from_config(
monkeypatch, monkeypatch,
isolate_models_catalog_state, isolate_models_catalog_state,
+63 -5
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import os import os
import shutil import shutil
import sys import sys
import threading
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -32,7 +33,6 @@ def env(tmp_path: Path, monkeypatch):
monkeypatch.setattr(profiles, "_is_isolated_profile_mode", lambda: False) monkeypatch.setattr(profiles, "_is_isolated_profile_mode", lambda: False)
monkeypatch.setattr(profiles._tls, "profile", None, raising=False) monkeypatch.setattr(profiles._tls, "profile", None, raising=False)
monkeypatch.setattr(cfg, "_models_cache_path", tmp_path / "models_cache.json") monkeypatch.setattr(cfg, "_models_cache_path", tmp_path / "models_cache.json")
monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: None)
monkeypatch.setattr(cfg, "_invoke_models_rebuild", lambda _builder: _catalog("fresh-model")) monkeypatch.setattr(cfg, "_invoke_models_rebuild", lambda _builder: _catalog("fresh-model"))
for attr in ("_cfg_mtime", "_cfg_path", "_cfg_fingerprint"): for attr in ("_cfg_mtime", "_cfg_path", "_cfg_fingerprint"):
monkeypatch.setattr(cfg, attr, getattr(cfg, attr), raising=False) monkeypatch.setattr(cfg, attr, getattr(cfg, attr), raising=False)
@@ -74,10 +74,10 @@ def _switch_to_demo_and_fetch() -> str:
return _fetch_as_demo()["default_model"] return _fetch_as_demo()["default_model"]
def _write_plugin(home: Path, version: str = "1.0.0", *, flat: bool = False, code: str | None = None, sub: str | None = None) -> Path: def _write_plugin(home: Path, version: str = "1.0.0", *, flat: bool = False, code: str | None = None, sub: str | None = None, kind: str = "model-provider") -> Path:
d = home / "plugins" / ("" if flat else "model-providers") / "acme" d = home / "plugins" / ("" if flat else "model-providers") / "acme"
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
(d / "plugin.yaml").write_text(f"name: acme\nkind: model-provider\nversion: {version}\n", encoding="utf-8") (d / "plugin.yaml").write_text(f"name: acme\nkind: {kind}\nversion: {version}\n", encoding="utf-8")
for name, text in (("__init__.py", code), ("models.py", sub)): for name, text in (("__init__.py", code), ("models.py", sub)):
if text is not None: if text is not None:
(d / name).write_text(text, encoding="utf-8") (d / name).write_text(text, encoding="utf-8")
@@ -92,13 +92,15 @@ def _env(text: str):
# (id, source state before the snapshot is saved, change made after it, model served after the switch) # (id, source state before the snapshot is saved, change made after it, model served after the switch)
_CASES = [ _CASES = [
("unchanged", None, None, "snapshot-model"), ("unchanged", None, None, "snapshot-model"),
("env-value-rotated", _env("DEEPSEEK_API_KEY=sk-one\n"), _env("DEEPSEEK_API_KEY=sk-two\n"), "snapshot-model"), ("env-value-rotated", _env("DEEPSEEK_API_KEY=sk-one\n"), _env("DEEPSEEK_API_KEY=sk-two\n"), "fresh-model"),
("env-base-url-changed", _env("LM_BASE_URL=http://127.0.0.1:1234/v1\n"), _env("LM_BASE_URL=http://127.0.0.1:5678/v1\n"), "fresh-model"),
("env-key-added", None, _env("DEEPSEEK_API_KEY=sk-new\n"), "fresh-model"), ("env-key-added", None, _env("DEEPSEEK_API_KEY=sk-new\n"), "fresh-model"),
("env-key-removed", _env("DEEPSEEK_API_KEY=sk-old\nOTHER=1\n"), _env("OTHER=1\n"), "fresh-model"), ("env-key-removed", _env("DEEPSEEK_API_KEY=sk-old\nOTHER=1\n"), _env("OTHER=1\n"), "fresh-model"),
# provider detection reads `export KEY=` as key `export KEY`, so the credential is gone # provider detection reads `export KEY=` as key `export KEY`, so the credential is gone
("env-export-prefix", _env("DEEPSEEK_API_KEY=sk-old\n"), _env("export DEEPSEEK_API_KEY=sk-old\n"), "fresh-model"), ("env-export-prefix", _env("DEEPSEEK_API_KEY=sk-old\n"), _env("export DEEPSEEK_API_KEY=sk-old\n"), "fresh-model"),
("plugin-installed", None, lambda h: _write_plugin(h), "fresh-model"), ("plugin-installed", None, lambda h: _write_plugin(h), "fresh-model"),
("plugin-installed-flat", None, lambda h: _write_plugin(h, flat=True), "fresh-model"), ("plugin-installed-flat", None, lambda h: _write_plugin(h, flat=True), "fresh-model"),
("plugin-installed-flat-yaml-comment", None, lambda h: _write_plugin(h, flat=True, kind="model-provider # valid YAML comment"), "fresh-model"),
("plugin-removed", lambda h: _write_plugin(h), lambda h: shutil.rmtree(h / "plugins"), "fresh-model"), ("plugin-removed", lambda h: _write_plugin(h), lambda h: shutil.rmtree(h / "plugins"), "fresh-model"),
("plugin-version-bumped", lambda h: _write_plugin(h, flat=True), lambda h: _write_plugin(h, "2.0.0", flat=True), "fresh-model"), ("plugin-version-bumped", lambda h: _write_plugin(h, flat=True), lambda h: _write_plugin(h, "2.0.0", flat=True), "fresh-model"),
("plugin-code-edited", lambda h: _write_plugin(h, code="M = (1,)\n"), lambda h: (h / "plugins/model-providers/acme/__init__.py").write_text("M = (1, 2)\n"), "fresh-model"), ("plugin-code-edited", lambda h: _write_plugin(h, code="M = (1,)\n"), lambda h: (h / "plugins/model-providers/acme/__init__.py").write_text("M = (1, 2)\n"), "fresh-model"),
@@ -118,6 +120,52 @@ def test_switch_serves_snapshot_only_while_sources_are_unchanged(env, before, ch
assert "sk-" not in env.cache.read_text(encoding="utf-8") # the fingerprint never records a secret assert "sk-" not in env.cache.read_text(encoding="utf-8") # the fingerprint never records a secret
@pytest.mark.parametrize("change", [_env("LM_BASE_URL=http://127.0.0.1:5678/v1\n"), lambda h: _write_plugin(h, flat=True)], ids=["env", "plugin"])
def test_slow_rebuild_never_serves_fingerprint_mismatched_snapshot_as_stale_fallback(env, monkeypatch, change):
_env("LM_BASE_URL=http://127.0.0.1:1234/v1\n")(env.home)
_save_demo_snapshot(env)
change(env.home)
profiles.set_request_profile("demo")
try:
assert cfg._load_models_cache_from_disk() is None
assert cfg._load_stale_models_cache_from_disk() is None
finally:
profiles.clear_request_profile()
release = threading.Event()
monkeypatch.setattr(cfg, "_LIVE_REBUILD_BUDGET_SECONDS", 0.05, raising=False)
monkeypatch.setattr(cfg, "_invoke_models_rebuild", lambda _b: release.wait(5) and _catalog("fresh-model"))
try:
assert _switch_to_demo_and_fetch() != "snapshot-model"
finally:
release.set()
def test_stale_fallback_still_serves_snapshot_when_only_webui_version_differs(env, monkeypatch):
_save_demo_snapshot(env)
monkeypatch.setattr(cfg, "_current_webui_version", lambda: "v-other")
profiles.set_request_profile("demo")
try:
assert cfg._load_models_cache_from_disk() is None
assert cfg._load_stale_models_cache_from_disk()["default_model"] == "snapshot-model"
finally:
profiles.clear_request_profile()
@pytest.mark.parametrize("manifest", [
"kind: model-provider # valid YAML comment\n",
"kind: model-provider\n",
"kind: 'model-provider'\n",
"name: x\nkind: tool\n",
"meta:\n kind: model-provider\n",
"# kind: model-provider\n",
"kind: [unclosed\nkind: model-provider\n",
])
def test_manifest_kind_parse_matches_agent(tmp_path, manifest):
agent = pytest.importorskip("providers")
(tmp_path / "plugin.yaml").write_text(manifest, encoding="utf-8")
assert cfg._declares_model_provider_kind(tmp_path) == agent._declares_model_provider_kind(tmp_path)
def test_same_profile_config_edit_still_deletes_disk_cache(env, monkeypatch): def test_same_profile_config_edit_still_deletes_disk_cache(env, monkeypatch):
_save_demo_snapshot(env) _save_demo_snapshot(env)
assert _switch_to_demo_and_fetch() == "snapshot-model" assert _switch_to_demo_and_fetch() == "snapshot-model"
@@ -147,7 +195,17 @@ def test_env_fingerprint_keys_match_provider_env_loader(env):
_env("export A=1\nB='x'\nC=\n# D=1\n")(env.home) _env("export A=1\nB='x'\nC=\n# D=1\n")(env.home)
loaded = sorted(k for k, v in _load_env_file(env.home / ".env").items() if v) loaded = sorted(k for k, v in _load_env_file(env.home / ".env").items() if v)
assert cfg._models_cache_env_fingerprint(env.home / ".env") == loaded fp = cfg._models_cache_env_fingerprint(env.home / ".env")
assert [k for k, _ in fp] == loaded
assert all(len(h) == 64 and h not in ("1", "x") for _, h in fp)
def test_env_fingerprint_is_keyed_not_a_plain_value_hash(env):
import hashlib
_env("DEEPSEEK_API_KEY=sk-secret\n")(env.home)
(_, digest), = cfg._models_cache_env_fingerprint(env.home / ".env")
assert digest != hashlib.sha256(b"sk-secret").hexdigest()
def test_plugin_bytecode_cache_does_not_churn_fingerprint(env): def test_plugin_bytecode_cache_does_not_churn_fingerprint(env):