Files
hermes-agent/hermes_cli/callbacks.py
John Paul Soliva 2981ca24da fix(config): a refused .env write fails instead of reporting success and half-applying
The .env write lock (a package-managed install, or a key the administrator
pins in the managed-scope .env) refuses inside save_env_value /
remove_env_value by printing to stderr and returning. The caller cannot tell
that apart from a successful write, and the four multi-store writers built on
them carried on:

- save_provider_env_credential still rewrote config.yaml api_key mirrors of
  the old value, reloaded the pool and returned ok:true.
- remove_provider_env_credential still pruned the env-seeded credential_pool
  entry, recorded env:<VAR> in suppressed_sources and dropped config.yaml
  mirrors.
- save_env_setting / remove_env_setting still dropped the top-level
  config.yaml copy.

So `hermes config set|unset <PINNED_KEY>` printed the refusal, then "✓ Set" /
"✓ Unset", and exited 0. PUT and DELETE /api/env answered 200. With the user's
.env holding an old key and model.api_key mirroring it, a refused set left
.env on the old key and moved config.yaml to the new one.

The lifecycle writers now check the lock before any side effect and raise
ValueError with the refusal (require_env_writable). The CLI set/unset env
routes turn that into exit 1, and _env_write_errors already maps ValueError to
400, so the dashboard and Desktop get the reason. save_env_value and
remove_env_value keep their return shape for their other callers.

model.disconnect (TUI/Desktop) loops over every env var of a provider, so
it asks the lock about all of them (env_write_refusal) before removing any:
a pinned key refuses with nothing touched, where it used to strip the
user's own key first. A locked var that holds nothing is skipped, so
Disconnect keeps clearing the auth store on a package-managed install,
where every .env write is locked but api keys live in auth.json. The CLI
secret prompt prints the refusal instead of only the skill setup's log line.
2026-09-28 02:00:41 -07:00

224 lines
8.1 KiB
Python

"""Secret-capture prompt for the interactive CLI (``_secret_capture_callback`` backend)."""
import queue
import time as _time
from hermes_cli.banner import cprint, _DIM, _RST
from hermes_cli.config import save_env_value_secure
from hermes_cli.secret_prompt import masked_secret_prompt
from hermes_constants import display_hermes_home
def _invalidate(cli) -> None:
if hasattr(cli, "_paint_now"):
cli._paint_now()
elif getattr(cli, "_app", None):
cli._app.invalidate()
def _clear_secret_input(cli) -> None:
"""Drop stale draft input so Enter never stores it as the secret."""
try:
if hasattr(cli, "_clear_secret_input_buffer"):
cli._clear_secret_input_buffer()
elif getattr(cli, "_app", None):
cli._app.current_buffer.reset()
except Exception:
pass
def _skipped(var_name: str, reason: str, message: str) -> dict:
return {
"success": True, "reason": reason, "stored_as": var_name,
"validated": False, "skipped": True, "message": message}
def _secret_result(var_name: str, value: str) -> dict:
"""Store ``value`` (or report a skip when empty) and build the callback result dict."""
if not value:
cprint(f"\n{_DIM} ⏭ Secret entry skipped{_RST}")
return _skipped(var_name, "cancelled", "Secret setup was skipped.")
try:
stored = save_env_value_secure(var_name, value)
except ValueError as exc: # a locked or denylisted key: the skill setup only logs it, so say why here
cprint(f"\n ✗ {exc}")
raise
cprint(f"\n{_DIM} ✓ Stored secret in {display_hermes_home()}/.env as {var_name}{_RST}")
return {
**stored,
"skipped": False,
"message": "Secret stored securely. The secret value was not exposed to the model."}
def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict:
"""Prompt for a secret value through the TUI (e.g. API keys for skills).
Returns a dict with keys: success, stored_as, validated, skipped, message. The secret is stored
in ~/.hermes/.env and never exposed to the model.
"""
if not getattr(cli, "_app", None):
if not hasattr(cli, "_secret_state"):
cli._secret_state = None
if not hasattr(cli, "_secret_deadline"):
cli._secret_deadline = 0
try:
value = masked_secret_prompt(f"{prompt} (hidden, ESC or empty Enter to skip): ")
except (EOFError, KeyboardInterrupt):
value = ""
return _secret_result(var_name, value)
response_queue = queue.Queue()
cli._secret_state = {
"var_name": var_name,
"prompt": prompt,
"metadata": metadata or {},
"response_queue": response_queue}
cli._secret_deadline = _time.monotonic() + 120
if hasattr(cli, "_ring_bell"):
cli._ring_bell(prompt=True, context=f"secret needed ({var_name})")
_clear_secret_input(cli)
_invalidate(cli)
while True:
try:
value = response_queue.get(timeout=1)
except queue.Empty:
if cli._secret_deadline - _time.monotonic() <= 0:
break
_invalidate(cli)
continue
cli._secret_state = None
cli._secret_deadline = 0
_invalidate(cli)
return _secret_result(var_name, value)
cli._secret_state = None
cli._secret_deadline = 0
_clear_secret_input(cli)
_invalidate(cli)
cprint(f"\n{_DIM} ⏱ Timeout — secret capture cancelled{_RST}")
return _skipped(var_name, "timeout", "Secret setup timed out and was skipped.")
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def approval_callback(cli, command: str, description: str) -> str:
"""Prompt for dangerous command approval through the TUI.
Shows a selection UI with choices: once / session / always / deny.
When the command is longer than 70 characters, a "view" option is
included so the user can reveal the full text before deciding.
Uses cli._approval_lock to serialize concurrent requests (e.g. from
parallel delegation subtasks) so each prompt gets its own turn.
"""
lock = getattr(cli, "_approval_lock", None)
if lock is None:
import threading
cli._approval_lock = threading.Lock()
lock = cli._approval_lock
with lock:
from cli import CLI_CONFIG
timeout = CLI_CONFIG.get("approvals", {}).get("timeout", 300)
response_queue = queue.Queue()
choices = ["once", "session", "always", "deny"]
if len(command) > 70:
choices.append("view")
cli._approval_state = {
"command": command,
"description": description,
"choices": choices,
"selected": 0,
"response_queue": response_queue,
}
cli._approval_deadline = _time.monotonic() + timeout
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
while True:
try:
result = response_queue.get(timeout=1)
cli._approval_state = None
cli._approval_deadline = 0
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
return result
except queue.Empty:
remaining = cli._approval_deadline - _time.monotonic()
if remaining <= 0:
break
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cli._approval_state = None
cli._approval_deadline = 0
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
return "timeout"
def clarify_callback(cli, question, choices, multi_select=False):
"""Prompt for clarifying question through the TUI.
Sets up the interactive selection UI, then blocks until the user
responds. Returns the user's choice or a timeout message.
When ``multi_select`` is True, shows checkboxes and the user can
select multiple options with Space, confirming with Enter.
"""
from cli import CLI_CONFIG
from tools.clarify_gateway import resolve_clarify_timeout
# Canonical clarify timeout, shared with the gateway/TUI path. `<= 0`
# means unlimited (never auto-skip mid-think) → a null deadline.
timeout = resolve_clarify_timeout(CLI_CONFIG)
response_queue = queue.Queue()
is_open_ended = not choices
effective_multi = multi_select and not is_open_ended
cli._clarify_state = {
"question": question,
"choices": choices if not is_open_ended else [],
"selected": 0,
"multi_select": effective_multi,
"selected_indices": set() if effective_multi else None,
"response_queue": response_queue,
}
cli._clarify_deadline = None if timeout <= 0 else _time.monotonic() + timeout
cli._clarify_freetext = is_open_ended
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
while True:
try:
result = response_queue.get(timeout=1)
cli._clarify_deadline = None
return result
except queue.Empty:
# None deadline = unlimited: never auto-skip, just keep polling.
if cli._clarify_deadline is not None:
remaining = cli._clarify_deadline - _time.monotonic()
if remaining <= 0:
break
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cli._clarify_state = None
cli._clarify_freetext = False
cli._clarify_deadline = None
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cprint(f"\n{_DIM}(clarify timed out after {timeout}s — agent will decide){_RST}")
return (
"The user did not provide a response within the time limit. "
"Use your best judgement to make the choice and proceed."
)
# ---- END PLUGIN-COMPAT ----