mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
fix(tui): apply terminal backend config before launch
Some checks are pending
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build and Publish / build-amd64 (push) Waiting to run
Docker Build and Publish / build-arm64 (push) Waiting to run
Docker Build and Publish / merge (push) Blocked by required conditions
Lint (ruff + ty) / ruff + ty diff (push) Waiting to run
Lint (ruff + ty) / ruff enforcement (blocking) (push) Waiting to run
Lint (ruff + ty) / Windows footguns (blocking) (push) Waiting to run
Nix / nix (macos-latest) (push) Waiting to run
Nix / nix (ubuntu-latest) (push) Waiting to run
OSV-Scanner / Scan lockfiles (push) Waiting to run
Build Skills Index / build-index (push) Waiting to run
Build Skills Index / trigger-deploy (push) Blocked by required conditions
Tests / test (1) (push) Waiting to run
Tests / test (2) (push) Waiting to run
Tests / test (3) (push) Waiting to run
Tests / test (4) (push) Waiting to run
Tests / test (5) (push) Waiting to run
Tests / test (6) (push) Waiting to run
Tests / save-durations (push) Blocked by required conditions
Tests / e2e (push) Waiting to run
uv.lock check / uv lock --check (push) Waiting to run
Some checks are pending
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build and Publish / build-amd64 (push) Waiting to run
Docker Build and Publish / build-arm64 (push) Waiting to run
Docker Build and Publish / merge (push) Blocked by required conditions
Lint (ruff + ty) / ruff + ty diff (push) Waiting to run
Lint (ruff + ty) / ruff enforcement (blocking) (push) Waiting to run
Lint (ruff + ty) / Windows footguns (blocking) (push) Waiting to run
Nix / nix (macos-latest) (push) Waiting to run
Nix / nix (ubuntu-latest) (push) Waiting to run
OSV-Scanner / Scan lockfiles (push) Waiting to run
Build Skills Index / build-index (push) Waiting to run
Build Skills Index / trigger-deploy (push) Blocked by required conditions
Tests / test (1) (push) Waiting to run
Tests / test (2) (push) Waiting to run
Tests / test (3) (push) Waiting to run
Tests / test (4) (push) Waiting to run
Tests / test (5) (push) Waiting to run
Tests / test (6) (push) Waiting to run
Tests / save-durations (push) Blocked by required conditions
Tests / e2e (push) Waiting to run
uv.lock check / uv lock --check (push) Waiting to run
This commit is contained in:
parent
dbbd1d4d05
commit
f8adefdebf
5 changed files with 175 additions and 30 deletions
|
|
@ -13,6 +13,7 @@ This module provides:
|
|||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
|
|
@ -5152,6 +5153,94 @@ def load_config_readonly() -> Dict[str, Any]:
|
|||
return _load_config_impl(want_deepcopy=False)
|
||||
|
||||
|
||||
TERMINAL_CONFIG_ENV_MAP = {
|
||||
"backend": "TERMINAL_ENV",
|
||||
"modal_mode": "TERMINAL_MODAL_MODE",
|
||||
"cwd": "TERMINAL_CWD",
|
||||
"timeout": "TERMINAL_TIMEOUT",
|
||||
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
|
||||
"docker_image": "TERMINAL_DOCKER_IMAGE",
|
||||
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
|
||||
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
|
||||
"modal_image": "TERMINAL_MODAL_IMAGE",
|
||||
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
|
||||
"ssh_host": "TERMINAL_SSH_HOST",
|
||||
"ssh_user": "TERMINAL_SSH_USER",
|
||||
"ssh_port": "TERMINAL_SSH_PORT",
|
||||
"ssh_key": "TERMINAL_SSH_KEY",
|
||||
"container_cpu": "TERMINAL_CONTAINER_CPU",
|
||||
"container_memory": "TERMINAL_CONTAINER_MEMORY",
|
||||
"container_disk": "TERMINAL_CONTAINER_DISK",
|
||||
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
|
||||
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
|
||||
"docker_env": "TERMINAL_DOCKER_ENV",
|
||||
"docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
|
||||
"docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS",
|
||||
"docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
|
||||
"docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
|
||||
"docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
|
||||
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
|
||||
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
|
||||
}
|
||||
|
||||
|
||||
def _terminal_env_value(value: Any) -> str:
|
||||
if isinstance(value, (list, dict)):
|
||||
return json.dumps(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def terminal_config_env_var_for_key(key: str) -> Optional[str]:
|
||||
"""Return the env var mirrored by a ``terminal.*`` config key."""
|
||||
prefix = "terminal."
|
||||
if not key.startswith(prefix):
|
||||
return None
|
||||
return TERMINAL_CONFIG_ENV_MAP.get(key[len(prefix):])
|
||||
|
||||
|
||||
def apply_terminal_config_to_env(
|
||||
*,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
override: Optional[bool] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""Bridge ``terminal.*`` config into the env vars terminal tools read.
|
||||
|
||||
``tools.terminal_tool`` is intentionally environment-driven because it also
|
||||
runs in child processes (TUI, dashboard PTY, gateway workers). This helper
|
||||
gives those child-process launch paths the same config bridge as classic
|
||||
CLI without importing ``cli.py`` and paying for its startup side effects.
|
||||
|
||||
When the user config contains a ``terminal`` section, config.yaml is
|
||||
authoritative and overrides existing env values. Otherwise defaults only
|
||||
backfill missing env vars so exported/.env values keep working.
|
||||
"""
|
||||
target = os.environ if env is None else env
|
||||
|
||||
raw_config = read_raw_config()
|
||||
file_has_terminal_config = isinstance(raw_config.get("terminal"), dict)
|
||||
should_override = file_has_terminal_config if override is None else override
|
||||
|
||||
cfg = config if config is not None else load_config_readonly()
|
||||
terminal_cfg = cfg.get("terminal", {}) if isinstance(cfg, dict) else {}
|
||||
if not isinstance(terminal_cfg, dict):
|
||||
return target
|
||||
|
||||
for cfg_key, env_var in TERMINAL_CONFIG_ENV_MAP.items():
|
||||
if cfg_key not in terminal_cfg:
|
||||
continue
|
||||
value = terminal_cfg[cfg_key]
|
||||
if cfg_key == "cwd":
|
||||
raw_cwd = str(value or "").strip()
|
||||
if raw_cwd in {".", "auto", "cwd"}:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
value = os.path.expanduser(value)
|
||||
if should_override or env_var not in target:
|
||||
target[env_var] = _terminal_env_value(value)
|
||||
return target
|
||||
|
||||
|
||||
def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
|
||||
with _CONFIG_LOCK:
|
||||
ensure_hermes_home()
|
||||
|
|
@ -6040,36 +6129,9 @@ def set_config_value(key: str, value: str):
|
|||
|
||||
# Keep .env in sync for keys that terminal_tool reads directly from env vars.
|
||||
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
|
||||
_config_to_env_sync = {
|
||||
"terminal.backend": "TERMINAL_ENV",
|
||||
"terminal.modal_mode": "TERMINAL_MODAL_MODE",
|
||||
"terminal.docker_image": "TERMINAL_DOCKER_IMAGE",
|
||||
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
|
||||
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
|
||||
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
|
||||
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
|
||||
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
|
||||
"terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
|
||||
"terminal.docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
|
||||
"terminal.docker_env": "TERMINAL_DOCKER_ENV",
|
||||
# JSON-valued keys (terminal_tool parses these via json.loads). The user
|
||||
# passes JSON on the CLI, so str(value) below already yields valid JSON —
|
||||
# same as terminal.docker_env. cli.py and gateway/run.py bridge these too.
|
||||
"terminal.docker_volumes": "TERMINAL_DOCKER_VOLUMES",
|
||||
"terminal.docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
|
||||
# terminal.cwd intentionally excluded — CLI resolves at runtime,
|
||||
# gateway bridges it in gateway/run.py. Persisting to .env causes
|
||||
# stale values to poison child processes.
|
||||
"terminal.timeout": "TERMINAL_TIMEOUT",
|
||||
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
|
||||
"terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL",
|
||||
"terminal.container_cpu": "TERMINAL_CONTAINER_CPU",
|
||||
"terminal.container_memory": "TERMINAL_CONTAINER_MEMORY",
|
||||
"terminal.container_disk": "TERMINAL_CONTAINER_DISK",
|
||||
"terminal.container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
|
||||
}
|
||||
if key in _config_to_env_sync:
|
||||
save_env_value(_config_to_env_sync[key], str(value))
|
||||
env_var = terminal_config_env_var_for_key(key)
|
||||
if env_var and key != "terminal.cwd":
|
||||
save_env_value(env_var, _terminal_env_value(value))
|
||||
|
||||
print(f"✓ Set {key} = {value} in {config_path}")
|
||||
|
||||
|
|
|
|||
|
|
@ -1825,6 +1825,11 @@ def _launch_tui(
|
|||
import tempfile
|
||||
|
||||
env = os.environ.copy()
|
||||
try:
|
||||
from hermes_cli.config import apply_terminal_config_to_env
|
||||
apply_terminal_config_to_env(env=env)
|
||||
except Exception:
|
||||
logger.debug("Failed to apply terminal config bridge for TUI launch", exc_info=True)
|
||||
active_session_fd, active_session_file = tempfile.mkstemp(
|
||||
prefix="hermes-tui-active-session-", suffix=".json"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8573,6 +8573,11 @@ def _resolve_chat_argv(
|
|||
|
||||
argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False)
|
||||
env = os.environ.copy()
|
||||
try:
|
||||
from hermes_cli.config import apply_terminal_config_to_env
|
||||
apply_terminal_config_to_env(env=env)
|
||||
except Exception:
|
||||
_log.debug("Failed to apply terminal config bridge for dashboard chat", exc_info=True)
|
||||
env.setdefault("NODE_ENV", "production")
|
||||
# Browser-embedded chat should prefer stable wheel-based scrollback over
|
||||
# native terminal mouse tracking. When mouse tracking is enabled, wheel
|
||||
|
|
|
|||
|
|
@ -896,6 +896,46 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod):
|
|||
assert env["NODE_ENV"] == "production"
|
||||
|
||||
|
||||
def test_launch_tui_applies_terminal_backend_config(
|
||||
monkeypatch, main_mod, _isolate_hermes_home
|
||||
):
|
||||
captured = {}
|
||||
config_path = Path(os.environ["HERMES_HOME"]) / "config.yaml"
|
||||
config_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"terminal:",
|
||||
" backend: docker",
|
||||
" docker_image: example/hermes-tools:latest",
|
||||
" docker_extra_args:",
|
||||
" - --network=host",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
monkeypatch.delenv("TERMINAL_DOCKER_IMAGE", raising=False)
|
||||
monkeypatch.delenv("TERMINAL_DOCKER_EXTRA_ARGS", raising=False)
|
||||
|
||||
monkeypatch.setattr(
|
||||
main_mod,
|
||||
"_make_tui_argv",
|
||||
lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main_mod.subprocess,
|
||||
"call",
|
||||
lambda argv, cwd=None, env=None: captured.update({"env": env}) or 1,
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod._launch_tui()
|
||||
|
||||
assert captured["env"]["TERMINAL_ENV"] == "docker"
|
||||
assert captured["env"]["TERMINAL_DOCKER_IMAGE"] == "example/hermes-tools:latest"
|
||||
assert captured["env"]["TERMINAL_DOCKER_EXTRA_ARGS"] == '["--network=host"]'
|
||||
|
||||
|
||||
def test_launch_tui_exit_code_42_relaunches_update(monkeypatch, main_mod):
|
||||
from unittest.mock import patch
|
||||
|
||||
|
|
|
|||
|
|
@ -4146,6 +4146,39 @@ class TestPtyWebSocket:
|
|||
assert env["HERMES_TUI_INLINE"] == "1"
|
||||
assert env["HERMES_TUI_DISABLE_MOUSE"] == "1"
|
||||
|
||||
def test_resolve_chat_argv_applies_terminal_backend_config(
|
||||
self, monkeypatch, _isolate_hermes_home
|
||||
):
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
config_path = Path(os.environ["HERMES_HOME"]) / "config.yaml"
|
||||
config_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"terminal:",
|
||||
" backend: docker",
|
||||
" docker_image: example/hermes-tools:latest",
|
||||
" docker_extra_args:",
|
||||
" - --network=host",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
monkeypatch.delenv("TERMINAL_DOCKER_IMAGE", raising=False)
|
||||
monkeypatch.delenv("TERMINAL_DOCKER_EXTRA_ARGS", raising=False)
|
||||
monkeypatch.setattr(
|
||||
main_mod,
|
||||
"_make_tui_argv",
|
||||
lambda project_root, tui_dev=False: (["node", "dist/entry.js"], "/tmp/ui-tui"),
|
||||
)
|
||||
|
||||
_argv, _cwd, env = self.ws_module._resolve_chat_argv()
|
||||
|
||||
assert env["TERMINAL_ENV"] == "docker"
|
||||
assert env["TERMINAL_DOCKER_IMAGE"] == "example/hermes-tools:latest"
|
||||
assert env["TERMINAL_DOCKER_EXTRA_ARGS"] == '["--network=host"]'
|
||||
|
||||
def test_rejects_when_embedded_chat_disabled(self, monkeypatch):
|
||||
monkeypatch.setattr(self.ws_module, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", False)
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue