Files
hermes-agent/hermes_cli/config_value_checks.py
Ben Barclay e4374020bd fix(config-remote): adapt the remote backend to the seam as merged in #134362
puts back what that build deliberately left for the remote backend, on
main's seam shape:

- RemoteBackend implements main's protocol (read_user_doc, probe_readable)
  and is not a FileBackend; _route returns (backend, home) and explicit
  files go through _ExplicitFile, so the is_layer branches are gone.
- One bootstrap: _ensure_bootstrap_env / bootstrap_deployment select the
  backend from the process env, the launch home's .env and the managed .env.
  #134362's _dotenv_backend_override did not survive its file-only refactor,
  so nothing stacks; its rules are now tests (stale shell remote + .env file
  boots file; empty .env value resets to file; .env-only remote selects
  remote; an absent key keeps the shell value).
- The file-tooling gates main removed (backup/import/snapshot restore,
  config edit, migration write-back, config backups, profile clone and
  distribution install, raw config editor, xAI migration, gateway --config).
- profile_serve_signature uses the backend version for config.yaml.
- restore_config_model_settings_if_rewritten is a no-op without file tooling.
- PM's dependency runtime reads a home's selection from the local file it
  publishes to (config_backend.local_file_layer), so a remote deployment
  never needs the plane there or fails PM closed.
- Ratchet: _container_slots/_validate_quoted_containers move to
  config_value_checks, _count_cron_jobs to backup_restore, the gateway
  --config load to gateway/config_loader.
2026-10-27 17:33:48 -07:00

123 lines
5.4 KiB
Python

"""Value checks for single config sections (voice, timezone, web backends), run by
``hermes_cli.config.validate_config_structure`` for doctor and the startup warnings."""
from typing import Any
from hermes_cli.config_issues import ConfigIssue, _issue
def _validate_voice(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
voice_cfg = config.get("voice")
if not (isinstance(voice_cfg, dict) and "submit_mode" in voice_cfg):
return
submit_mode = voice_cfg.get("submit_mode")
normalized = submit_mode.strip().lower() if isinstance(submit_mode, str) else None
if normalized not in {"direct", "draft"}:
_issue(issues, "error", f"voice.submit_mode must be 'direct' or 'draft', got {submit_mode!r}",
"Set voice.submit_mode to direct (submit immediately) or draft (edit before sending)")
def _validate_timezone(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
"""``timezone`` must be an IANA name the runtime can load.
``hermes_time._get_zoneinfo()`` swallows an invalid name behind a single WARNING in the
gateway log, then runs the agent clock AND every cron schedule on server-local time.
Surface it here, where doctor and the startup check both look. Silent when the
interpreter has no tz database at all (bare Windows without ``tzdata``) — nothing can be
judged there.
"""
if "timezone" not in config:
return
tz = config.get("timezone")
hint = ("Use an IANA zone name such as America/New_York or Asia/Tokyo (see "
"`timedatectl list-timezones`). With an invalid value the agent clock and cron "
"schedules silently fall back to server-local time. HERMES_TIMEZONE overrides "
"this key when set.")
if tz is not None and not isinstance(tz, str):
_issue(issues, "error", f"timezone must be an IANA zone name string, got {tz!r}", hint)
return
if not (isinstance(tz, str) and tz.strip()):
return
name = tz.strip()
try:
import zoneinfo
zoneinfo.ZoneInfo("UTC") # is a tz database available at all?
except Exception:
return
try:
zoneinfo.ZoneInfo(name)
except Exception:
_issue(issues, "error", f"timezone {name!r} is not a valid IANA zone name", hint)
def _validate_web_backends(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
"""A stale web backend selection otherwise fails only at the first web_search/web_extract
call with a generic "no registered provider" error; warn at startup instead."""
# See #99199.
web_cfg = config.get("web")
if not isinstance(web_cfg, dict):
return
try:
from tools.tool_backend_helpers import removed_backend_note
except Exception:
return
seen: set = set()
for _key in ("backend", "search_backend", "extract_backend"):
_val = str(web_cfg.get(_key) or "").strip().lower()
if not _val or _val in seen:
continue
seen.add(_val)
note = removed_backend_note("web", _val)
if note:
_issue(issues, "warning",
f"web.{_key} is set to '{_val}', but {note} — "
"web_search/web_extract will fail until it is changed",
"Run 'hermes tools' and pick a different Web Search & Extract provider")
def _container_slots() -> dict[str, str]:
"""Dotted key -> ``"list"``/``"mapping"`` for every slot the schema fixes to a container:
``DEFAULT_CONFIG`` (sections included) plus the known-container table for roots it omits."""
from hermes_cli.config import _KNOWN_CONTAINER_TYPES, DEFAULT_CONFIG # late: the facade imports this module
slots: dict[str, str] = {}
def walk(node: dict[str, Any], prefix: str) -> None:
for key, value in node.items():
path = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict):
slots[path] = "mapping"
walk(value, path)
elif isinstance(value, list):
slots[path] = "list"
walk(DEFAULT_CONFIG, "")
slots.update(_KNOWN_CONTAINER_TYPES)
return slots
def _validate_quoted_containers(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
"""A container slot holding ONE quoted string (``enabled: '["a","b"]'``) is skipped by every
isinstance-gated reader while ``config get`` echoes it back, so plugins silently unmount and
exclusions silently lapse (#83308, #105706). Finding only — the file is never rewritten."""
import shlex
import hermes_yaml as yaml
from hermes_cli.config import _SCALAR_AS_ONE_ITEM_LIST_KEYS, _looks_structured_value, cfg_get
for key, kind in _container_slots().items():
# ``parse_config_string_list`` readers accept the quoted form; nothing is ignored there.
if key in _SCALAR_AS_ONE_ITEM_LIST_KEYS:
continue
value = cfg_get(config, *key.split("."))
if not isinstance(value, str) or not _looks_structured_value(value):
continue
try:
parsed = yaml.safe_load(value)
except yaml.YAMLError:
continue
if isinstance(parsed, (list, dict)):
_issue(issues, "warning",
f"{key} is the quoted string {value!r} — Hermes expects a YAML {kind} here "
"and every reader ignores the string",
f"Run: hermes config set {key} {shlex.quote(value)} (stores a real {kind}), "
"or remove the quotes in config.yaml")