Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,8 @@ class ConfigUpdate(BaseModel):
class EnvVarUpdate(BaseModel):
key: str
value: str
category: Optional[str] = None
description: Optional[str] = None


class EnvVarDelete(BaseModel):
Expand All @@ -460,6 +462,68 @@ class EnvVarReveal(BaseModel):
key: str


_CUSTOM_ENV_METADATA_FILE = "custom_env_keys.json"
_CUSTOM_ENV_CATEGORIES = {"provider", "tool", "messaging", "setting", "custom"}


def _custom_env_metadata_path() -> Path:
return get_hermes_home() / _CUSTOM_ENV_METADATA_FILE


def _load_custom_env_metadata() -> Dict[str, Dict[str, Any]]:
path = _custom_env_metadata_path()
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
_log.warning("Failed to read custom env key metadata from %s", path, exc_info=True)
return {}
if not isinstance(data, dict):
return {}
cleaned: Dict[str, Dict[str, Any]] = {}
for key, value in data.items():
if isinstance(key, str) and isinstance(value, dict):
cleaned[key] = value
return cleaned


def _save_custom_env_metadata(metadata: Dict[str, Dict[str, Any]]) -> None:
path = _custom_env_metadata_path()
path.parent.mkdir(parents=True, exist_ok=True)
serializable = {k: v for k, v in sorted(metadata.items()) if isinstance(v, dict)}
path.write_text(json.dumps(serializable, indent=2, sort_keys=True) + "\n", encoding="utf-8")


def _sanitize_custom_env_category(category: Optional[str]) -> str:
if category in _CUSTOM_ENV_CATEGORIES:
return category
return "custom"


def _update_custom_env_metadata(key: str, category: Optional[str], description: Optional[str]) -> None:
if key in OPTIONAL_ENV_VARS:
return
metadata = _load_custom_env_metadata()
existing = metadata.get(key, {})
info = dict(existing)
info["category"] = _sanitize_custom_env_category(category or existing.get("category"))
if description is not None:
info["description"] = description.strip()
elif "description" not in info:
info["description"] = "Custom environment variable"
metadata[key] = info
_save_custom_env_metadata(metadata)


def _remove_custom_env_metadata(key: str) -> None:
metadata = _load_custom_env_metadata()
if key not in metadata:
return
metadata.pop(key, None)
_save_custom_env_metadata(metadata)


class ModelAssignment(BaseModel):
"""Payload for POST /api/model/set — assign a provider/model to a slot.

Expand Down Expand Up @@ -1199,6 +1263,7 @@ async def update_config(body: ConfigUpdate):
@app.get("/api/env")
async def get_env_vars():
env_on_disk = load_env()
custom_metadata = _load_custom_env_metadata()
result = {}
for var_name, info in OPTIONAL_ENV_VARS.items():
value = env_on_disk.get(var_name)
Expand All @@ -1212,13 +1277,30 @@ async def get_env_vars():
"tools": info.get("tools", []),
"advanced": info.get("advanced", False),
}

for var_name, value in sorted(env_on_disk.items()):
if var_name in result:
continue
info = custom_metadata.get(var_name, {})
result[var_name] = {
"is_set": bool(value),
"redacted_value": redact_key(value) if value else None,
"description": info.get("description") or "Custom environment variable",
"url": info.get("url"),
"category": _sanitize_custom_env_category(info.get("category")),
"is_password": True,
"tools": info.get("tools", []) if isinstance(info.get("tools", []), list) else [],
"advanced": bool(info.get("advanced", False)),
"custom": True,
}
return result


@app.put("/api/env")
async def set_env_var(body: EnvVarUpdate):
try:
save_env_value(body.key, body.value)
_update_custom_env_metadata(body.key, body.category, body.description)
return {"ok": True, "key": body.key}
except Exception:
_log.exception("PUT /api/env failed")
Expand All @@ -1231,6 +1313,7 @@ async def remove_env_var(body: EnvVarDelete):
removed = remove_env_value(body.key)
if not removed:
raise HTTPException(status_code=404, detail=f"{body.key} not found in .env")
_remove_custom_env_metadata(body.key)
return {"ok": True, "key": body.key}
except HTTPException:
raise
Expand Down
74 changes: 74 additions & 0 deletions tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,80 @@ def test_get_env_vars(self):
# Should contain known env var names
assert any(k.endswith("_API_KEY") or k.endswith("_TOKEN") for k in data.keys())

def test_get_env_vars_includes_custom_keys_after_save(self):
"""GET /api/env should return arbitrary keys saved to .env so refresh keeps them visible."""
from hermes_cli.config import save_env_value

save_env_value("CUSTOM_REFRESH_TEST_KEY", "custom-secret-value-12345")

resp = self.client.get("/api/env")

assert resp.status_code == 200
data = resp.json()
assert data["CUSTOM_REFRESH_TEST_KEY"] == {
"is_set": True,
"redacted_value": "cust...2345",
"description": "Custom environment variable",
"url": None,
"category": "custom",
"is_password": True,
"tools": [],
"advanced": False,
"custom": True,
}

def test_set_env_var_persists_custom_metadata(self):
"""PUT /api/env should persist custom category/description outside .env."""
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN

resp = self.client.put(
"/api/env",
json={
"key": "CUSTOM_METADATA_TEST_KEY",
"value": "metadata-secret-value-12345",
"category": "tool",
"description": "Used by a local MCP tool",
},
headers={_SESSION_HEADER_NAME: _SESSION_TOKEN},
)
assert resp.status_code == 200

data = self.client.get("/api/env").json()
assert data["CUSTOM_METADATA_TEST_KEY"]["is_set"] is True
assert data["CUSTOM_METADATA_TEST_KEY"]["custom"] is True
assert data["CUSTOM_METADATA_TEST_KEY"]["category"] == "tool"
assert data["CUSTOM_METADATA_TEST_KEY"]["description"] == "Used by a local MCP tool"

def test_delete_env_var_removes_custom_metadata(self):
"""DELETE /api/env should remove sidecar metadata for deleted custom keys."""
from hermes_constants import get_hermes_home
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN

put_resp = self.client.put(
"/api/env",
json={
"key": "CUSTOM_DELETE_METADATA_TEST_KEY",
"value": "delete-secret-value-12345",
"category": "provider",
"description": "Temporary provider key",
},
headers={_SESSION_HEADER_NAME: _SESSION_TOKEN},
)
assert put_resp.status_code == 200

delete_resp = self.client.request(
"DELETE",
"/api/env",
json={"key": "CUSTOM_DELETE_METADATA_TEST_KEY"},
headers={_SESSION_HEADER_NAME: _SESSION_TOKEN},
)
assert delete_resp.status_code == 200

metadata_path = get_hermes_home() / "custom_env_keys.json"
if metadata_path.exists():
metadata = json.loads(metadata_path.read_text())
assert "CUSTOM_DELETE_METADATA_TEST_KEY" not in metadata

def test_reveal_env_var(self, tmp_path):
"""POST /api/env/reveal should return the real unredacted value."""
from hermes_cli.config import save_env_value
Expand Down
9 changes: 7 additions & 2 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,15 @@ export const api = {
body: JSON.stringify({ yaml_text }),
}),
getEnvVars: () => fetchJSON<Record<string, EnvVarInfo>>("/api/env"),
setEnvVar: (key: string, value: string) =>
setEnvVar: (
key: string,
value: string,
metadata?: { category?: string; description?: string },
) =>
fetchJSON<{ ok: boolean }>("/api/env", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, value }),
body: JSON.stringify({ key, value, ...(metadata ?? {}) }),
}),
deleteEnvVar: (key: string) =>
fetchJSON<{ ok: boolean }>("/api/env", {
Expand Down Expand Up @@ -421,6 +425,7 @@ export interface EnvVarInfo {
is_password: boolean;
tools: string[];
advanced: boolean;
custom?: boolean;
}

export interface SessionMessage {
Expand Down
Loading