mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-17 14:42:06 +00:00
tests: pin ink engine in _make_tui_argv npm-bootstrap tests (post-merge semantic fix)
Main's rewritten test_tui_npm_install.py tests call _make_tui_argv expecting the Ink/npm flow unconditionally; with the dual-engine dispatch merged in, _resolve_tui_engine() auto-selects opentui whenever ui-opentui/dist is built in the repo, routing the call away from the path under test (first subprocess became 'node --version' instead of 'npm run build'). Pin the engine to ink via an autouse fixture, mirroring the existing pinning precedent in test_tui_resume_flow.py.
This commit is contained in:
parent
ab37440ce6
commit
e1067dbbe5
756 changed files with 79874 additions and 19585 deletions
|
|
@ -4,6 +4,7 @@ import os
|
|||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -1069,6 +1070,148 @@ class TestWebServerEndpoints:
|
|||
assert "GATEWAY_PROXY_URL" not in managed
|
||||
assert "GATEWAY_PROXY_URL" in _MESSAGING_KEYS_PAGE_KEYS
|
||||
|
||||
def test_model_set_requires_confirmation_for_expensive_model(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_cost_guard.expensive_model_warning",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(message="EXPENSIVE MODEL WARNING"),
|
||||
)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "nous",
|
||||
"model": "openai/gpt-5.5-pro",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is False
|
||||
assert data["confirm_required"] is True
|
||||
assert data["confirm_message"] == "EXPENSIVE MODEL WARNING"
|
||||
|
||||
confirmed = self.client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "nous",
|
||||
"model": "openai/gpt-5.5-pro",
|
||||
"confirm_expensive_model": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert confirmed.status_code == 200
|
||||
assert confirmed.json()["ok"] is True
|
||||
|
||||
def test_model_set_normalizes_vendor_slug_for_native_provider(self, monkeypatch):
|
||||
"""'Use as → Main' with an OpenRouter slug + native provider must not
|
||||
persist the vendor-prefixed slug verbatim (it 400s against the native
|
||||
API and reads as "changing models does nothing")."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_cost_guard.expensive_model_warning",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
resp = self.client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "anthropic",
|
||||
"model": "anthropic/claude-opus-4.6",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["provider"] == "anthropic"
|
||||
# Vendor prefix stripped + dots→hyphens for the native Anthropic API.
|
||||
assert data["model"] == "claude-opus-4-6"
|
||||
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
assert cfg["model"]["provider"] == "anthropic"
|
||||
assert cfg["model"]["default"] == "claude-opus-4-6"
|
||||
|
||||
def test_model_set_maps_unknown_vendor_to_aggregator(self, monkeypatch):
|
||||
"""A bare vendor name from analytics rows (no billing_provider) is not
|
||||
a Hermes provider — keep the user's aggregator instead of writing a
|
||||
provider that can never resolve credentials."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_cost_guard.expensive_model_warning",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
from hermes_cli.config import load_config, save_config
|
||||
cfg = load_config()
|
||||
cfg["model"] = {"provider": "openrouter", "default": "openai/gpt-5.5"}
|
||||
save_config(cfg)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "moonshotai", # vendor prefix, not a provider
|
||||
"model": "moonshotai/kimi-k2.6",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["provider"] == "openrouter"
|
||||
assert data["model"] == "moonshotai/kimi-k2.6"
|
||||
|
||||
def test_model_set_keeps_aggregator_slug_unchanged(self, monkeypatch):
|
||||
"""The happy path (picker → openrouter + vendor/model) is untouched."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_cost_guard.expensive_model_warning",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
resp = self.client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "openrouter",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["provider"] == "openrouter"
|
||||
assert data["model"] == "anthropic/claude-sonnet-4.6"
|
||||
|
||||
def test_ops_import_passes_force_flag(self, tmp_path, monkeypatch):
|
||||
"""force=True must append --force so the spawned non-interactive
|
||||
`hermes import` doesn't auto-abort at the overwrite prompt."""
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
archive = tmp_path / "backup.zip"
|
||||
import zipfile
|
||||
with zipfile.ZipFile(archive, "w") as zf:
|
||||
zf.writestr("config.yaml", "model: {}\n")
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_spawn(subcommand, name):
|
||||
captured["args"] = subcommand
|
||||
captured["name"] = name
|
||||
from types import SimpleNamespace as NS
|
||||
return NS(pid=12345)
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/ops/import", json={"archive": str(archive), "force": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert captured["args"] == ["import", str(archive), "--force"]
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/ops/import", json={"archive": str(archive)},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert captured["args"] == ["import", str(archive)]
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -1363,6 +1506,17 @@ class TestWebServerEndpoints:
|
|||
}
|
||||
|
||||
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
|
||||
ws._ACTION_PROCS.pop("gateway-restart", None)
|
||||
restart_calls = []
|
||||
|
||||
class FakeRestartProc:
|
||||
pid = 4242
|
||||
|
||||
def fake_spawn_action(subcommand, name):
|
||||
restart_calls.append((subcommand, name))
|
||||
return FakeRestartProc()
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn_action)
|
||||
|
||||
start = self.client.post("/api/messaging/telegram/onboarding/start", json={})
|
||||
assert start.status_code == 200
|
||||
|
|
@ -1384,13 +1538,138 @@ class TestWebServerEndpoints:
|
|||
"ok": True,
|
||||
"platform": "telegram",
|
||||
"bot_username": "hermes_pair_ready_bot",
|
||||
"needs_restart": True,
|
||||
"needs_restart": False,
|
||||
"restart_started": True,
|
||||
"restart_action": "gateway-restart",
|
||||
"restart_pid": 4242,
|
||||
}
|
||||
assert restart_calls == [(["gateway", "restart"], "gateway-restart")]
|
||||
env = load_env()
|
||||
assert env["TELEGRAM_BOT_TOKEN"] == "123456:SECRET"
|
||||
assert env["TELEGRAM_ALLOWED_USERS"] == "123456789"
|
||||
assert load_config()["platforms"]["telegram"]["enabled"] is True
|
||||
|
||||
def test_telegram_onboarding_apply_reports_restart_failure_after_save(
|
||||
self, monkeypatch
|
||||
):
|
||||
import hermes_cli.web_server as ws
|
||||
from hermes_cli.config import load_config, load_env
|
||||
|
||||
with ws._telegram_onboarding_lock:
|
||||
ws._telegram_onboarding_pairings.clear()
|
||||
|
||||
def fake_request(method, path, *, body=None, bearer_token=None):
|
||||
if method == "POST":
|
||||
return {
|
||||
"pairing_id": "pair-restart-fails",
|
||||
"poll_token": "poll-secret",
|
||||
"suggested_username": "hermes_pair_restart_fails_bot",
|
||||
"deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_restart_fails_bot",
|
||||
"qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_restart_fails_bot",
|
||||
"expires_at": "2027-05-18T00:00:00.000Z",
|
||||
}
|
||||
assert method == "GET"
|
||||
assert path == "/v1/telegram/pairings/pair-restart-fails"
|
||||
assert bearer_token == "poll-secret"
|
||||
return {
|
||||
"status": "ready",
|
||||
"bot_username": "hermes_pair_restart_fails_bot",
|
||||
"owner_user_id": 123456789,
|
||||
"token": "123456:SECRET",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
|
||||
ws._ACTION_PROCS.pop("gateway-restart", None)
|
||||
|
||||
def fail_spawn_action(subcommand, name):
|
||||
assert subcommand == ["gateway", "restart"]
|
||||
assert name == "gateway-restart"
|
||||
raise RuntimeError("supervisor unavailable")
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn_action)
|
||||
|
||||
start = self.client.post("/api/messaging/telegram/onboarding/start", json={})
|
||||
assert start.status_code == 200
|
||||
ready = self.client.get("/api/messaging/telegram/onboarding/pair-restart-fails")
|
||||
assert ready.status_code == 200
|
||||
assert ready.json()["status"] == "ready"
|
||||
|
||||
applied = self.client.post(
|
||||
"/api/messaging/telegram/onboarding/pair-restart-fails/apply",
|
||||
json={"allowed_user_ids": ["123456789"]},
|
||||
)
|
||||
|
||||
assert applied.status_code == 200
|
||||
applied_data = applied.json()
|
||||
assert applied_data["ok"] is True
|
||||
assert applied_data["needs_restart"] is True
|
||||
assert applied_data["restart_started"] is False
|
||||
assert "supervisor unavailable" in applied_data["restart_error"]
|
||||
assert "token" not in applied_data
|
||||
env = load_env()
|
||||
assert env["TELEGRAM_BOT_TOKEN"] == "123456:SECRET"
|
||||
assert env["TELEGRAM_ALLOWED_USERS"] == "123456789"
|
||||
assert load_config()["platforms"]["telegram"]["enabled"] is True
|
||||
|
||||
def test_telegram_onboarding_apply_reuses_inflight_gateway_restart(
|
||||
self, monkeypatch
|
||||
):
|
||||
"""A live in-flight gateway restart is reused instead of spawning a
|
||||
second racing ``hermes gateway restart`` child (e.g. when a stale
|
||||
cached frontend also fires its own restart call)."""
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
with ws._telegram_onboarding_lock:
|
||||
ws._telegram_onboarding_pairings.clear()
|
||||
|
||||
def fake_request(method, path, *, body=None, bearer_token=None):
|
||||
if method == "POST":
|
||||
return {
|
||||
"pairing_id": "pair-reuse",
|
||||
"poll_token": "poll-secret",
|
||||
"suggested_username": "hermes_pair_reuse_bot",
|
||||
"deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_reuse_bot",
|
||||
"qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_reuse_bot",
|
||||
"expires_at": "2027-05-18T00:00:00.000Z",
|
||||
}
|
||||
return {
|
||||
"status": "ready",
|
||||
"bot_username": "hermes_pair_reuse_bot",
|
||||
"owner_user_id": 123456789,
|
||||
"token": "123456:SECRET",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
|
||||
|
||||
class FakeRunningProc:
|
||||
pid = 5151
|
||||
|
||||
def poll(self):
|
||||
return None # still running
|
||||
|
||||
monkeypatch.setitem(ws._ACTION_PROCS, "gateway-restart", FakeRunningProc())
|
||||
|
||||
def fail_spawn_action(subcommand, name):
|
||||
raise AssertionError("must not spawn a second concurrent restart")
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn_action)
|
||||
|
||||
start = self.client.post("/api/messaging/telegram/onboarding/start", json={})
|
||||
assert start.status_code == 200
|
||||
ready = self.client.get("/api/messaging/telegram/onboarding/pair-reuse")
|
||||
assert ready.status_code == 200
|
||||
|
||||
applied = self.client.post(
|
||||
"/api/messaging/telegram/onboarding/pair-reuse/apply",
|
||||
json={"allowed_user_ids": ["123456789"]},
|
||||
)
|
||||
|
||||
assert applied.status_code == 200
|
||||
applied_data = applied.json()
|
||||
assert applied_data["needs_restart"] is False
|
||||
assert applied_data["restart_started"] is True
|
||||
assert applied_data["restart_pid"] == 5151
|
||||
|
||||
def test_telegram_onboarding_apply_requires_ready_pairing(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
|
|
@ -1601,6 +1880,28 @@ class TestWebServerEndpoints:
|
|||
out = _apply_main_model_assignment("not-a-dict", "custom", "m", "http://x/v1")
|
||||
assert out == {"provider": "custom", "default": "m", "base_url": "http://x/v1"}
|
||||
|
||||
# api_key follows the same lifecycle as base_url:
|
||||
# supplied → persisted.
|
||||
out = _apply_main_model_assignment(
|
||||
{}, "custom", "m", "http://x/v1", "sk-secret"
|
||||
)
|
||||
assert out["api_key"] == "sk-secret"
|
||||
|
||||
# same provider, no new key → existing key preserved (re-picking a model
|
||||
# on the same custom endpoint must not wipe the saved key).
|
||||
out = _apply_main_model_assignment(
|
||||
{"provider": "custom", "base_url": "http://x/v1", "api_key": "sk-keep"},
|
||||
"custom",
|
||||
"m2",
|
||||
)
|
||||
assert out["api_key"] == "sk-keep"
|
||||
|
||||
# switching providers without a new key → stale key cleared.
|
||||
out = _apply_main_model_assignment(
|
||||
{"provider": "custom", "api_key": "sk-old"}, "openrouter", "m"
|
||||
)
|
||||
assert out["api_key"] == ""
|
||||
|
||||
def test_parse_model_ids_handles_openai_and_bare_shapes(self):
|
||||
"""Model discovery must tolerate the common /v1/models shapes and
|
||||
never raise (so a slightly non-standard local endpoint still works)."""
|
||||
|
|
@ -1657,6 +1958,45 @@ class TestWebServerEndpoints:
|
|||
assert model_cfg["default"] == "llama-3.1-8b"
|
||||
assert model_cfg["base_url"] == "http://127.0.0.1:8000/v1"
|
||||
|
||||
def test_set_model_main_custom_persists_api_key_and_registers_provider(self):
|
||||
"""A custom endpoint that requires auth must persist model.api_key (where
|
||||
the runtime reads it) AND register a named custom_providers entry so the
|
||||
endpoint reappears as a ready row in the picker — matching the
|
||||
``hermes model`` custom flow. Regression for the desktop loop where a
|
||||
keyed custom endpoint could never be configured from the GUI."""
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "custom",
|
||||
"model": "gpt-oss-120b",
|
||||
"base_url": "https://text.example.com/v1",
|
||||
"api_key": "sk-secret",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is True
|
||||
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model")
|
||||
assert isinstance(model_cfg, dict)
|
||||
assert model_cfg["provider"] == "custom"
|
||||
assert model_cfg["base_url"] == "https://text.example.com/v1"
|
||||
assert model_cfg["api_key"] == "sk-secret"
|
||||
|
||||
# Registered in custom_providers (dedup by base_url) so the picker shows
|
||||
# a proper ready row instead of the "needs setup" dead-end.
|
||||
custom = cfg.get("custom_providers") or []
|
||||
assert any(
|
||||
isinstance(e, dict)
|
||||
and e.get("base_url") == "https://text.example.com/v1"
|
||||
and e.get("api_key") == "sk-secret"
|
||||
and e.get("model") == "gpt-oss-120b"
|
||||
for e in custom
|
||||
)
|
||||
|
||||
def test_set_model_main_non_custom_clears_stale_base_url(self):
|
||||
"""Switching to a hosted provider must clear a stale base_url so the
|
||||
resolver picks that provider's own default endpoint."""
|
||||
|
|
@ -2081,6 +2421,42 @@ class TestNewEndpoints:
|
|||
resp = self.client.get("/api/cron/jobs/nonexistent-id")
|
||||
assert resp.status_code == 404
|
||||
|
||||
# --- Automation Blueprints ---
|
||||
|
||||
def test_cron_blueprints_list(self):
|
||||
resp = self.client.get("/api/cron/blueprints")
|
||||
assert resp.status_code == 200
|
||||
blueprints = resp.json()["blueprints"]
|
||||
assert len(blueprints) >= 1
|
||||
first = blueprints[0]
|
||||
assert "fields" in first
|
||||
assert first["command"].startswith("/blueprint")
|
||||
assert first["appUrl"].startswith("hermes://")
|
||||
|
||||
def test_blueprint_instantiate_creates_job(self):
|
||||
resp = self.client.post(
|
||||
"/api/cron/blueprints/instantiate",
|
||||
json={"blueprint": "morning-brief", "values": {"time": "07:30", "deliver": "local"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
job = resp.json()
|
||||
assert (job.get("schedule_display") or "").strip() == "30 7 * * *" or \
|
||||
(job.get("schedule", {}) or {}).get("expr") == "30 7 * * *"
|
||||
|
||||
def test_blueprint_instantiate_unknown_404(self):
|
||||
resp = self.client.post(
|
||||
"/api/cron/blueprints/instantiate",
|
||||
json={"blueprint": "does-not-exist", "values": {}},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_blueprint_instantiate_bad_value_422(self):
|
||||
resp = self.client.post(
|
||||
"/api/cron/blueprints/instantiate",
|
||||
json={"blueprint": "morning-brief", "values": {"time": "99:99"}},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
# --- Profiles ---
|
||||
|
||||
def test_profiles_list_includes_default(self):
|
||||
|
|
@ -2253,6 +2629,83 @@ class TestNewEndpoints:
|
|||
profiles = {p["name"]: p for p in self.client.get("/api/profiles").json()["profiles"]}
|
||||
assert profiles["fresh"]["skill_count"] == 1
|
||||
|
||||
def test_profiles_create_builder_fields_model_mcp_and_keep_skills(self, monkeypatch):
|
||||
"""Profile-builder create: model + MCP servers + keep-skills selection
|
||||
all land in the NEW profile's config, and hub installs are spawned
|
||||
scoped to that profile via ``-p <name>``."""
|
||||
from hermes_constants import (
|
||||
get_hermes_home,
|
||||
set_hermes_home_override,
|
||||
reset_hermes_home_override,
|
||||
)
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.skills_config import get_disabled_skills
|
||||
import hermes_cli.profiles as profiles_mod
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None)
|
||||
|
||||
# Seed two known skills so keep-skills "replace" has something to act on.
|
||||
def fake_seed(profile_dir, quiet=False):
|
||||
for skill in ("keep-me", "drop-me"):
|
||||
d = profile_dir / "skills" / "custom" / skill
|
||||
d.mkdir(parents=True)
|
||||
(d / "SKILL.md").write_text(f"---\nname: {skill}\n---\n", encoding="utf-8")
|
||||
return {"copied": ["keep-me", "drop-me"]}
|
||||
|
||||
monkeypatch.setattr(profiles_mod, "seed_profile_skills", fake_seed)
|
||||
|
||||
# Capture hub-install spawns instead of launching real subprocesses.
|
||||
spawned = []
|
||||
|
||||
class _FakeProc:
|
||||
pid = 4321
|
||||
|
||||
def fake_spawn(subcommand, name):
|
||||
spawned.append((list(subcommand), name))
|
||||
return _FakeProc()
|
||||
|
||||
monkeypatch.setattr(web_server, "_spawn_hermes_action", fake_spawn)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/profiles",
|
||||
json={
|
||||
"name": "builder",
|
||||
"provider": "openrouter",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"mcp_servers": [
|
||||
{"name": "ctx7", "url": "https://mcp.context7.com/mcp"},
|
||||
{"name": "bogus"}, # no url/command -> must be skipped, no 500
|
||||
],
|
||||
"keep_skills": ["keep-me"],
|
||||
"hub_skills": ["someuser/some-skill"],
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["model_set"] is True
|
||||
assert data["mcp_written"] == 1 # bogus skipped
|
||||
assert data["skills_disabled"] == 1 # drop-me disabled, keep-me kept
|
||||
assert data["hub_installs"] == [{"identifier": "someuser/some-skill", "pid": 4321}]
|
||||
|
||||
# Hub install was scoped to the new profile.
|
||||
assert spawned == [(["-p", "builder", "skills", "install", "someuser/some-skill"], "skills-install")]
|
||||
|
||||
# Verify the writes landed in the NEW profile's config, not the root.
|
||||
prof_dir = get_hermes_home() / "profiles" / "builder"
|
||||
token = set_hermes_home_override(str(prof_dir))
|
||||
try:
|
||||
cfg = load_config()
|
||||
assert cfg["model"]["default"] == "anthropic/claude-sonnet-4.6"
|
||||
assert cfg["model"]["provider"] == "openrouter"
|
||||
assert sorted((cfg.get("mcp_servers") or {}).keys()) == ["ctx7"]
|
||||
disabled = get_disabled_skills(cfg)
|
||||
assert "drop-me" in disabled
|
||||
assert "keep-me" not in disabled
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
def test_profile_open_terminal_uses_macos_terminal(self, monkeypatch):
|
||||
from hermes_constants import get_hermes_home
|
||||
import hermes_cli.web_server as web_server
|
||||
|
|
@ -4146,6 +4599,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
|
||||
|
|
@ -4159,7 +4645,7 @@ class TestPtyWebSocket:
|
|||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
|
||||
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
|
||||
)
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
|
|
@ -4172,7 +4658,7 @@ class TestPtyWebSocket:
|
|||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
|
||||
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
|
||||
)
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
|
|
@ -4185,7 +4671,7 @@ class TestPtyWebSocket:
|
|||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None: (
|
||||
lambda resume=None, sidecar_url=None, profile=None: (
|
||||
["/bin/sh", "-c", "printf hermes-ws-ok"],
|
||||
None,
|
||||
None,
|
||||
|
|
@ -4215,7 +4701,7 @@ class TestPtyWebSocket:
|
|||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
|
||||
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
|
||||
)
|
||||
with self.client.websocket_connect(self._url()) as conn:
|
||||
conn.send_bytes(b"round-trip-payload\n")
|
||||
|
|
@ -4248,7 +4734,7 @@ class TestPtyWebSocket:
|
|||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
# sleep gives the test time to push the resize before the child reads the ioctl.
|
||||
lambda resume=None, sidecar_url=None: (
|
||||
lambda resume=None, sidecar_url=None, profile=None: (
|
||||
[sys.executable, "-c", winsize_script],
|
||||
None,
|
||||
None,
|
||||
|
|
@ -4284,7 +4770,7 @@ class TestPtyWebSocket:
|
|||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
|
||||
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
|
||||
)
|
||||
# Patch PtyBridge.spawn at the web_server module's binding.
|
||||
import hermes_cli.web_server as ws_mod
|
||||
|
|
@ -4299,7 +4785,7 @@ class TestPtyWebSocket:
|
|||
def test_resume_parameter_is_forwarded_to_argv(self, monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def fake_resolve(resume=None, sidecar_url=None):
|
||||
def fake_resolve(resume=None, sidecar_url=None, profile=None):
|
||||
captured["resume"] = resume
|
||||
return (["/bin/sh", "-c", "printf resume-arg-ok"], None, None)
|
||||
|
||||
|
|
@ -4319,7 +4805,7 @@ class TestPtyWebSocket:
|
|||
same channel — which is how tool events reach the dashboard sidebar."""
|
||||
captured: dict = {}
|
||||
|
||||
def fake_resolve(resume=None, sidecar_url=None):
|
||||
def fake_resolve(resume=None, sidecar_url=None, profile=None):
|
||||
captured["sidecar_url"] = sidecar_url
|
||||
return (["/bin/sh", "-c", "printf sidecar-ok"], None, None)
|
||||
|
||||
|
|
@ -4593,6 +5079,83 @@ class TestValidateProviderCredential:
|
|||
data = self._post("OPENAI_API_KEY", " ").json()
|
||||
assert data["ok"] is False
|
||||
|
||||
def test_local_endpoint_forwards_api_key_as_bearer(self, monkeypatch):
|
||||
"""A custom endpoint that gates /v1/models behind auth must still
|
||||
enumerate models: the optional api_key is sent as a Bearer header so the
|
||||
probe doesn't come back empty (the desktop loop's root cause)."""
|
||||
captured = {}
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
is_success = True
|
||||
|
||||
def json(self):
|
||||
return {"data": [{"id": "gpt-oss-120b"}]}
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def get(self, url, *a, headers=None, **k):
|
||||
captured["url"] = url
|
||||
captured["headers"] = headers
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr("httpx.Client", _Client)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/providers/validate",
|
||||
json={
|
||||
"key": "OPENAI_BASE_URL",
|
||||
"value": "https://text.example.com/v1",
|
||||
"api_key": "sk-secret",
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
assert data["ok"] is True and data["reachable"] is True
|
||||
assert data["models"] == ["gpt-oss-120b"]
|
||||
assert captured["url"] == "https://text.example.com/v1/models"
|
||||
assert captured["headers"] == {"Authorization": "Bearer sk-secret"}
|
||||
|
||||
def test_local_endpoint_without_key_sends_no_auth_header(self, monkeypatch):
|
||||
"""No key → no Authorization header (keyless local servers unaffected)."""
|
||||
captured = {}
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
is_success = True
|
||||
|
||||
def json(self):
|
||||
return {"data": []}
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def get(self, url, *a, headers=None, **k):
|
||||
captured["headers"] = headers
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr("httpx.Client", _Client)
|
||||
|
||||
self.client.post(
|
||||
"/api/providers/validate",
|
||||
json={"key": "OPENAI_BASE_URL", "value": "http://127.0.0.1:8000/v1"},
|
||||
)
|
||||
assert captured["headers"] is None
|
||||
|
||||
|
||||
class TestDesktopCronTicker:
|
||||
"""The dashboard backend fires cron jobs itself only when desktop-spawned."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue