Skip to content

Commit a3ca1c9

Browse files
authored
Merge branch 'dev' into fix-skill-extract-first-object-3965
2 parents 9a99a62 + 9d7a3d6 commit a3ca1c9

10 files changed

Lines changed: 125 additions & 17 deletions

core/platform_compat.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ def is_wsl() -> bool:
300300
import sys
301301
if sys.platform.startswith("linux") or os.name == "posix":
302302
try:
303-
with open("/proc/version", "r") as f:
303+
with open("/proc/version", "r", encoding="utf-8", errors="ignore") as f:
304304
if "microsoft" in f.read().lower():
305305
return True
306306
except Exception:

routes/cookbook_helpers.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
"""cookbook_helpers.py — validators + small helpers shared by the cookbook routes.
22
Extracted from cookbook_routes.py; the routes module imports the symbols it needs."""
33

4+
import json
45
import logging
56
import ntpath
67
import os
78
import posixpath
89
import re
910
import shlex
11+
from pathlib import Path
1012

1113
from fastapi import HTTPException
1214
from pydantic import BaseModel
@@ -90,6 +92,24 @@ def _validate_token(v: str | None) -> str | None:
9092
return v
9193

9294

95+
def load_stored_hf_token(*, state_path: Path | str | None = None) -> str:
96+
"""Return the decrypted HF token from cookbook_state.json, else env fallback."""
97+
path = Path(state_path) if state_path else Path(os.environ.get("DATA_DIR", "data")) / "cookbook_state.json"
98+
token = ""
99+
if path.exists():
100+
try:
101+
state = json.loads(path.read_text(encoding="utf-8"))
102+
env = state.get("env") if isinstance(state, dict) else {}
103+
if isinstance(env, dict) and env.get("hfToken"):
104+
from src.secret_storage import decrypt
105+
token = decrypt(env.get("hfToken") or "")
106+
except Exception:
107+
token = ""
108+
if not token:
109+
token = (os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or "").strip()
110+
return token
111+
112+
93113
def _validate_local_dir(v: str | None) -> str | None:
94114
if v is None or v == "":
95115
return None

routes/cookbook_routes.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
_ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase,
4141
_safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
4242
_append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script,
43+
load_stored_hf_token,
44+
_append_vllm_linux_preflight_lines, _ollama_bind_from_cmd, _pip_install_fallback_chain,
45+
_pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
46+
_diagnose_serve_output, run_ssh_command_async,
4347
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
4448
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
4549
ModelDownloadRequest, ServeRequest,
@@ -234,14 +238,7 @@ def _state_for_storage(state, on_disk=None):
234238
return state
235239

236240
def _load_stored_hf_token() -> str:
237-
if not _cookbook_state_path.exists():
238-
return ""
239-
try:
240-
state = json.loads(_cookbook_state_path.read_text(encoding="utf-8"))
241-
env = state.get("env") if isinstance(state, dict) else {}
242-
return _decrypt_secret(env.get("hfToken") if isinstance(env, dict) else "")
243-
except Exception:
244-
return ""
241+
return load_stored_hf_token(state_path=_cookbook_state_path)
245242

246243
def _cookbook_ssh_dir() -> Path:
247244
# The Docker image keeps cookbook keys under /app/.ssh; that path only

src/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ def load_features() -> dict:
283283
if not isinstance(saved, dict):
284284
raise ValueError("features must be an object")
285285
merged = {**DEFAULT_FEATURES, **saved}
286-
except (FileNotFoundError, json.JSONDecodeError, ValueError):
286+
except (FileNotFoundError, PermissionError, json.JSONDecodeError, ValueError):
287287
merged = dict(DEFAULT_FEATURES)
288288
_features_cache = (now, merged)
289289
return merged

src/tool_implementations.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2054,13 +2054,14 @@ async def _cookbook_env_for_host(host: str) -> Dict[str, Any]:
20542054
else:
20552055
env_prefix = f'eval "$(conda shell.bash hook)" && conda activate {env_path}'
20562056

2057+
from routes.cookbook_helpers import load_stored_hf_token
20572058
return {
20582059
"env_prefix": env_prefix,
20592060
"env_type": env_kind,
20602061
"env_path": env_path,
20612062
"gpus": env_root.get("gpus") or "",
20622063
"platform": platform,
2063-
"hf_token": env_root.get("hfToken") or "",
2064+
"hf_token": load_stored_hf_token(),
20642065
"ssh_port": ssh_port,
20652066
}
20662067

static/js/cookbook-hwfit.js

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,12 +1506,10 @@ export function _hwfitInit() {
15061506
clearTimeout(_hwfitDebounce);
15071507
_hwfitDebounce = setTimeout(() => _hwfitFetch(), 400);
15081508
});
1509-
// HF Token
1510-
const hfToken = document.getElementById('hwfit-hftoken');
1511-
if (hfToken) {
1512-
hfToken.addEventListener('change', () => { _envState.hfToken = hfToken.value.trim(); _persistEnvState(); });
1513-
hfToken.addEventListener('input', () => { _envState.hfToken = hfToken.value.trim(); });
1514-
}
1509+
// HF token save is owned by cookbook.js (_wireTabEvents) — do not wire a
1510+
// second change/input handler here. The old duplicate ran after cookbook.js
1511+
// cleared the input on save and overwrote _envState.hfToken with "", so the
1512+
// debounced state sync never persisted the token to cookbook_state.json.
15151513

15161514
// Rebuild all server select dropdowns with current servers
15171515
function _rebuildServerSelect() {

tests/test_cookbook_hf_token.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Cookbook HF token persistence and lookup."""
2+
3+
import json
4+
import os
5+
6+
import pytest
7+
8+
from routes.cookbook_helpers import load_stored_hf_token
9+
from src.secret_storage import encrypt
10+
11+
12+
def test_load_stored_hf_token_reads_encrypted_state(tmp_path, monkeypatch):
13+
monkeypatch.setenv("DATA_DIR", str(tmp_path))
14+
state_path = tmp_path / "cookbook_state.json"
15+
state_path.write_text(
16+
json.dumps({"env": {"hfToken": encrypt("hf_test_token_12345")}}),
17+
encoding="utf-8",
18+
)
19+
assert load_stored_hf_token() == "hf_test_token_12345"
20+
assert load_stored_hf_token(state_path=state_path) == "hf_test_token_12345"
21+
22+
23+
def test_load_stored_hf_token_falls_back_to_env_when_state_missing(tmp_path, monkeypatch):
24+
monkeypatch.setenv("DATA_DIR", str(tmp_path))
25+
monkeypatch.setenv("HF_TOKEN", "hf_from_env")
26+
assert load_stored_hf_token() == "hf_from_env"
27+
28+
29+
def test_load_stored_hf_token_prefers_state_over_env(tmp_path, monkeypatch):
30+
monkeypatch.setenv("DATA_DIR", str(tmp_path))
31+
monkeypatch.setenv("HF_TOKEN", "hf_from_env")
32+
state_path = tmp_path / "cookbook_state.json"
33+
state_path.write_text(
34+
json.dumps({"env": {"hfToken": encrypt("hf_from_state")}}),
35+
encoding="utf-8",
36+
)
37+
assert load_stored_hf_token() == "hf_from_state"
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""load_features() must degrade to defaults if features.json is unreadable.
2+
3+
load_settings() already catches PermissionError, but load_features() did not, so
4+
an unreadable data/features.json (e.g. root-owned after a deploy) raised instead
5+
of falling back to DEFAULT_FEATURES, taking down GET /api/auth/features.
6+
"""
7+
import builtins
8+
9+
import src.settings as settings
10+
11+
12+
def test_load_features_degrades_on_permission_error(monkeypatch):
13+
# Ensure the cache does not short-circuit the read.
14+
monkeypatch.setattr(settings, "_features_cache", None, raising=False)
15+
16+
real_open = builtins.open
17+
18+
def deny(path, *args, **kwargs):
19+
if str(path) == str(settings.FEATURES_FILE):
20+
raise PermissionError("denied")
21+
return real_open(path, *args, **kwargs)
22+
23+
monkeypatch.setattr(builtins, "open", deny)
24+
25+
result = settings.load_features()
26+
assert result == dict(settings.DEFAULT_FEATURES)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from unittest.mock import MagicMock
2+
3+
import routes.memory_routes as memory_routes
4+
from src.memory import MemoryManager
5+
6+
7+
def test_memory_search_returns_only_callers_memories(monkeypatch, tmp_path):
8+
manager = MemoryManager(str(tmp_path))
9+
alice_memory = manager.add_entry("Project codename is Odyssey", owner="alice")
10+
bob_memory = manager.add_entry("Project codename is Odyssey", owner="bob")
11+
manager.save([alice_memory, bob_memory])
12+
13+
monkeypatch.setattr(memory_routes, "get_current_user", lambda request: "bob")
14+
router = memory_routes.setup_memory_routes(manager, MagicMock())
15+
search = next(
16+
route.endpoint
17+
for route in router.routes
18+
if route.path == "/api/memory/search" and "POST" in route.methods
19+
)
20+
21+
result = search(
22+
request=None,
23+
query="Project codename is Odyssey",
24+
session_id=None,
25+
category=None,
26+
)
27+
28+
assert [memory["id"] for memory in result["memories"]] == [bob_memory["id"]]

tests/test_platform_compat.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def test_is_wsl_true_when_proc_version_mentions_microsoft(monkeypatch):
8383
def fake_open(path, mode="r", *args, **kwargs):
8484
assert path == "/proc/version"
8585
assert mode == "r"
86+
assert kwargs == {"encoding": "utf-8", "errors": "ignore"}
8687
return io.StringIO("Linux version 6.6.0 microsoft standard")
8788

8889
monkeypatch.setattr("builtins.open", fake_open)

0 commit comments

Comments
 (0)