Compare commits

..

2 Commits

Author SHA1 Message Date
alt-glitch 9aed1b2fe7 fix: restore accidentally deleted websocket close code assertion 2026-04-25 10:06:46 +05:30
alt-glitch f87dbdf0a8 fix(web): reject empty values in PUT /api/env
The endpoint accepted empty strings, allowing any .env key to be
silently blanked out from the web UI. Add Pydantic validators to
reject empty keys and values.
2026-04-25 09:59:11 +05:30
5 changed files with 52 additions and 117 deletions
+15 -1
View File
@@ -53,7 +53,7 @@ try:
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
except ImportError:
raise SystemExit(
"Web UI requires fastapi and uvicorn.\n"
@@ -425,6 +425,20 @@ class EnvVarUpdate(BaseModel):
key: str
value: str
@field_validator("key")
@classmethod
def key_must_be_nonempty(cls, v: str) -> str:
if not v.strip():
raise ValueError("key must not be empty")
return v
@field_validator("value")
@classmethod
def value_must_be_nonempty(cls, v: str) -> str:
if not v.strip():
raise ValueError("value must not be empty; use DELETE /api/env to remove a key")
return v
class EnvVarDelete(BaseModel):
key: str
+6 -33
View File
@@ -478,9 +478,7 @@ class HindsightMemoryProvider(MemoryProvider):
existing = {}
if config_path.exists():
try:
parsed = json.loads(config_path.read_text())
if isinstance(parsed, dict):
existing = parsed
existing = json.loads(config_path.read_text())
except Exception:
pass
existing.update(values)
@@ -591,35 +589,12 @@ class HindsightMemoryProvider(MemoryProvider):
val = input(f" LLM model [{default_model}]: ").strip()
provider_config["llm_model"] = val or default_model
existing_llm_key = os.environ.get("HINDSIGHT_LLM_API_KEY", "")
if not existing_llm_key:
existing_llm_key = _load_simple_env(Path(hermes_home) / ".env").get(
"HINDSIGHT_LLM_API_KEY",
"",
)
if not existing_llm_key:
saved_config = dict(provider_config)
config_path = Path(hermes_home) / "hindsight" / "config.json"
try:
parsed = json.loads(config_path.read_text(encoding="utf-8"))
if isinstance(parsed, dict):
saved_config.update(parsed)
except Exception:
pass
saved_config.update(provider_config)
existing_llm_key = _load_simple_env(
_embedded_profile_env_path(saved_config)
).get("HINDSIGHT_API_LLM_API_KEY", "")
if existing_llm_key:
masked = f"...{existing_llm_key[-4:]}" if len(existing_llm_key) > 4 else "set"
sys.stdout.write(f" LLM API key (current: {masked}, blank to keep): ")
else:
sys.stdout.write(" LLM API key: ")
sys.stdout.write(" LLM API key: ")
sys.stdout.flush()
llm_key = getpass.getpass(prompt="") if sys.stdin.isatty() else sys.stdin.readline().strip()
if not llm_key and existing_llm_key:
llm_key = existing_llm_key
# Always write explicitly (including empty) so the provider sees ""
# rather than a missing variable. The daemon reads from .env at
# startup and fails when HINDSIGHT_LLM_API_KEY is unset.
env_writes["HINDSIGHT_LLM_API_KEY"] = llm_key
# Step 4: Save everything
@@ -627,9 +602,7 @@ class HindsightMemoryProvider(MemoryProvider):
provider_config["recall_budget"] = "mid"
# Read existing timeout from config if present, otherwise use default
existing_timeout = self._config.get("timeout") if self._config else None
if not existing_timeout:
existing_timeout = _load_simple_env(Path(hermes_home) / ".env").get("HINDSIGHT_TIMEOUT")
timeout_val = int(existing_timeout) if existing_timeout else _DEFAULT_TIMEOUT
timeout_val = existing_timeout if existing_timeout else _DEFAULT_TIMEOUT
provider_config["timeout"] = timeout_val
env_writes["HINDSIGHT_TIMEOUT"] = str(timeout_val)
config["memory"]["provider"] = "hindsight"
-2
View File
@@ -504,8 +504,6 @@ AUTHOR_MAP = {
"screenmachine@gmail.com": "teknium1",
"chenzeshi@live.com": "chen1749144759",
"mor.aleksandr@yahoo.com": "MorAlekss",
"poruru.code@gmail.com": "poruru-code",
"138243371+poruru-code@users.noreply.github.com": "poruru-code",
}
+31
View File
@@ -1925,3 +1925,34 @@ class TestPtyWebSocket:
):
pass
assert exc.value.code == 4400
class TestEnvVarUpdateValidation:
"""PUT /api/env must reject empty values to prevent .env key destruction."""
def test_rejects_empty_value(self):
from hermes_cli.web_server import EnvVarUpdate
import pydantic
with pytest.raises(pydantic.ValidationError):
EnvVarUpdate(key="SOME_KEY", value="")
def test_rejects_whitespace_only_value(self):
from hermes_cli.web_server import EnvVarUpdate
import pydantic
with pytest.raises(pydantic.ValidationError):
EnvVarUpdate(key="SOME_KEY", value=" ")
def test_accepts_nonempty_value(self):
from hermes_cli.web_server import EnvVarUpdate
update = EnvVarUpdate(key="SOME_KEY", value="sk-abc123")
assert update.value == "sk-abc123"
def test_rejects_empty_key(self):
from hermes_cli.web_server import EnvVarUpdate
import pydantic
with pytest.raises(pydantic.ValidationError):
EnvVarUpdate(key="", value="some-value")
@@ -329,89 +329,8 @@ class TestPostSetup:
profile_env = user_home / ".hindsight" / "profiles" / "hermes.env"
assert profile_env.exists()
assert (hermes_home / ".env").read_text() == "HINDSIGHT_LLM_API_KEY=existing-key\nHINDSIGHT_TIMEOUT=120\n"
assert "HINDSIGHT_API_LLM_API_KEY=existing-key\n" in profile_env.read_text()
def test_local_embedded_setup_preserves_existing_key_from_nondefault_profile_env_when_input_left_blank(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes-home"
user_home = tmp_path / "user-home"
user_home.mkdir()
monkeypatch.setenv("HOME", str(user_home))
selections = iter([1, 0]) # local_embedded, openai
monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: next(selections))
monkeypatch.setattr("shutil.which", lambda name: None)
monkeypatch.setattr("builtins.input", lambda prompt="": "")
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
monkeypatch.setattr("getpass.getpass", lambda prompt="": "")
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
provider = HindsightMemoryProvider()
provider.save_config({"profile": "coder"}, str(hermes_home))
profile_env = user_home / ".hindsight" / "profiles" / "coder.env"
profile_env.parent.mkdir(parents=True, exist_ok=True)
profile_env.write_text("HINDSIGHT_API_LLM_API_KEY=existing-key\n")
provider.post_setup(str(hermes_home), {"memory": {}})
assert (hermes_home / ".env").read_text() == "HINDSIGHT_LLM_API_KEY=existing-key\nHINDSIGHT_TIMEOUT=120\n"
assert "HINDSIGHT_API_LLM_API_KEY=existing-key\n" in profile_env.read_text()
def test_local_embedded_setup_ignores_nondict_saved_config_when_input_left_blank(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes-home"
user_home = tmp_path / "user-home"
user_home.mkdir()
monkeypatch.setenv("HOME", str(user_home))
selections = iter([1, 0]) # local_embedded, openai
monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: next(selections))
monkeypatch.setattr("shutil.which", lambda name: None)
monkeypatch.setattr("builtins.input", lambda prompt="": "")
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
monkeypatch.setattr("getpass.getpass", lambda prompt="": "")
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
config_path = hermes_home / "hindsight" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text("[]")
env_path = hermes_home / ".env"
env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.write_text("HINDSIGHT_LLM_API_KEY=existing-key\n")
provider = HindsightMemoryProvider()
provider.post_setup(str(hermes_home), {"memory": {}})
profile_env = user_home / ".hindsight" / "profiles" / "hermes.env"
assert profile_env.exists()
assert (hermes_home / ".env").read_text() == "HINDSIGHT_LLM_API_KEY=existing-key\nHINDSIGHT_TIMEOUT=120\n"
assert "HINDSIGHT_API_LLM_API_KEY=existing-key\n" in profile_env.read_text()
def test_local_embedded_setup_preserves_existing_timeout(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes-home"
user_home = tmp_path / "user-home"
user_home.mkdir()
monkeypatch.setenv("HOME", str(user_home))
selections = iter([1, 0]) # local_embedded, openai
monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: next(selections))
monkeypatch.setattr("shutil.which", lambda name: None)
monkeypatch.setattr("builtins.input", lambda prompt="": "")
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
monkeypatch.setattr("getpass.getpass", lambda prompt="": "sk-test")
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
env_path = hermes_home / ".env"
env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.write_text("HINDSIGHT_LLM_API_KEY=sk-test\nHINDSIGHT_TIMEOUT=300\n")
provider = HindsightMemoryProvider()
provider.post_setup(str(hermes_home), {"memory": {}})
env_content = (hermes_home / ".env").read_text()
assert "HINDSIGHT_TIMEOUT=300" in env_content
# ---------------------------------------------------------------------------
# Tool handler tests