mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-10-20 05:15:31 +00:00
fix(tui): apply terminal backend config before launch
Some checks failed
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
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
Build Skills Index / build-index (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
Some checks failed
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
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
Build Skills Index / build-index (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
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 copy
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
|
@ -5152,6 +5153,94 @@ def load_config_readonly() -> Dict[str, Any]:
|
||||||
return _load_config_impl(want_deepcopy=False)
|
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]:
|
def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
|
||||||
with _CONFIG_LOCK:
|
with _CONFIG_LOCK:
|
||||||
ensure_hermes_home()
|
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.
|
# 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.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
|
||||||
_config_to_env_sync = {
|
env_var = terminal_config_env_var_for_key(key)
|
||||||
"terminal.backend": "TERMINAL_ENV",
|
if env_var and key != "terminal.cwd":
|
||||||
"terminal.modal_mode": "TERMINAL_MODAL_MODE",
|
save_env_value(env_var, _terminal_env_value(value))
|
||||||
"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))
|
|
||||||
|
|
||||||
print(f"✓ Set {key} = {value} in {config_path}")
|
print(f"✓ Set {key} = {value} in {config_path}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1825,6 +1825,11 @@ def _launch_tui(
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
env = os.environ.copy()
|
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(
|
active_session_fd, active_session_file = tempfile.mkstemp(
|
||||||
prefix="hermes-tui-active-session-", suffix=".json"
|
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)
|
argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False)
|
||||||
env = os.environ.copy()
|
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")
|
env.setdefault("NODE_ENV", "production")
|
||||||
# Browser-embedded chat should prefer stable wheel-based scrollback over
|
# Browser-embedded chat should prefer stable wheel-based scrollback over
|
||||||
# native terminal mouse tracking. When mouse tracking is enabled, wheel
|
# 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"
|
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):
|
def test_launch_tui_exit_code_42_relaunches_update(monkeypatch, main_mod):
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4146,6 +4146,39 @@ class TestPtyWebSocket:
|
||||||
assert env["HERMES_TUI_INLINE"] == "1"
|
assert env["HERMES_TUI_INLINE"] == "1"
|
||||||
assert env["HERMES_TUI_DISABLE_MOUSE"] == "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):
|
def test_rejects_when_embedded_chat_disabled(self, monkeypatch):
|
||||||
monkeypatch.setattr(self.ws_module, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", False)
|
monkeypatch.setattr(self.ws_module, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", False)
|
||||||
from starlette.websockets import WebSocketDisconnect
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue