Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,856 @@
|
||||
"""Regression tests for the machine-dashboard multi-profile unification.
|
||||
|
||||
The dashboard is ONE machine-level management surface: config, env, MCP,
|
||||
model, and chat-PTY endpoints accept an optional ``profile`` so the global
|
||||
profile switcher can target any profile's HERMES_HOME. These tests pin:
|
||||
reads/writes land in the REQUESTED profile, the dashboard's own profile
|
||||
stays untouched, and the chat PTY env is scoped via HERMES_HOME.
|
||||
"""
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
|
||||
"""Isolated default home + one named profile, each with config + .env."""
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli import profiles
|
||||
|
||||
default_home = get_hermes_home()
|
||||
profiles_root = default_home / "profiles"
|
||||
worker_home = profiles_root / "worker_beta"
|
||||
for home in (default_home, worker_home):
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
|
||||
(worker_home / ".env").write_text("", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
||||
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
|
||||
return {"default": default_home, "worker_beta": worker_home}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, isolated_profiles):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
c = TestClient(app)
|
||||
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
return c
|
||||
|
||||
|
||||
def _cfg(home):
|
||||
return yaml.safe_load((home / "config.yaml").read_text()) or {}
|
||||
|
||||
|
||||
def _write_jobs(home, jobs):
|
||||
cron_dir = home / "cron"
|
||||
cron_dir.mkdir(parents=True, exist_ok=True)
|
||||
(cron_dir / "jobs.json").write_text(json.dumps(jobs), encoding="utf-8")
|
||||
|
||||
|
||||
class TestProfileScopedConfig:
|
||||
|
||||
|
||||
def test_config_query_param_equivalent_to_body(self, client, isolated_profiles):
|
||||
"""The SPA's fetchJSON injects ?profile= — must scope like body.profile."""
|
||||
resp = client.put(
|
||||
"/api/config?profile=worker_beta",
|
||||
json={"config": {"timezone": "Pluto/Far"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert _cfg(isolated_profiles["worker_beta"]).get("timezone") == "Pluto/Far"
|
||||
assert _cfg(isolated_profiles["default"]).get("timezone") != "Pluto/Far"
|
||||
|
||||
|
||||
|
||||
def test_unknown_profile_404(self, client, isolated_profiles):
|
||||
resp = client.get("/api/config", params={"profile": "ghost"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestProfileScopedEnv:
|
||||
def test_env_set_lands_in_target_profile_only(self, client, isolated_profiles):
|
||||
resp = client.put(
|
||||
"/api/env",
|
||||
json={"key": "FAL_KEY", "value": "test-fal-123", "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
worker_env = (isolated_profiles["worker_beta"] / ".env").read_text()
|
||||
assert "test-fal-123" in worker_env
|
||||
default_env_path = isolated_profiles["default"] / ".env"
|
||||
if default_env_path.exists():
|
||||
assert "test-fal-123" not in default_env_path.read_text()
|
||||
|
||||
|
||||
def test_env_delete_scoped(self, client, isolated_profiles):
|
||||
(isolated_profiles["worker_beta"] / ".env").write_text(
|
||||
"FAL_KEY=doomed\n", encoding="utf-8"
|
||||
)
|
||||
resp = client.request(
|
||||
"DELETE",
|
||||
"/api/env",
|
||||
json={"key": "FAL_KEY", "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "doomed" not in (isolated_profiles["worker_beta"] / ".env").read_text()
|
||||
|
||||
|
||||
class TestProfileScopedMcp:
|
||||
|
||||
def test_mcp_bearer_secret_is_profile_scoped(self, client, isolated_profiles):
|
||||
secret = "worker-only-secret"
|
||||
response = client.post(
|
||||
"/api/mcp/servers",
|
||||
params={"profile": "worker_beta"},
|
||||
json={
|
||||
"name": "profile-bearer",
|
||||
"url": "https://example.com/mcp",
|
||||
"auth": "header",
|
||||
"bearer_token": secret,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
worker_cfg = _cfg(isolated_profiles["worker_beta"])
|
||||
assert worker_cfg["mcp_servers"]["profile-bearer"]["headers"] == {
|
||||
"Authorization": "Bearer ${MCP_PROFILE_BEARER_API_KEY}",
|
||||
}
|
||||
assert secret in (isolated_profiles["worker_beta"] / ".env").read_text()
|
||||
assert not (isolated_profiles["default"] / ".env").exists()
|
||||
assert "profile-bearer" not in _cfg(isolated_profiles["default"]).get(
|
||||
"mcp_servers", {}
|
||||
)
|
||||
|
||||
|
||||
|
||||
def test_mcp_test_oauth_server_without_token_is_not_ok(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
"""An `auth: oauth` server that serves tools/list anonymously must not
|
||||
false-green: a successful probe with no token on disk reports needs-auth."""
|
||||
import hermes_cli.mcp_config as mcp_config
|
||||
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"mcp_servers:\n oauth-srv:\n url: http://x/sse\n auth: oauth\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mcp_config,
|
||||
"_probe_single_server",
|
||||
lambda name, config, connect_timeout=30, details=None: [("tool-a", "desc")],
|
||||
)
|
||||
monkeypatch.setattr(mcp_config, "_oauth_tokens_present", lambda name: False)
|
||||
|
||||
resp = client.post(
|
||||
"/api/mcp/servers/oauth-srv/test", params={"profile": "worker_beta"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["ok"] is False
|
||||
assert "oauth" in body["error"].lower()
|
||||
|
||||
# With a token present, the same probe is genuinely authenticated.
|
||||
monkeypatch.setattr(mcp_config, "_oauth_tokens_present", lambda name: True)
|
||||
resp = client.post(
|
||||
"/api/mcp/servers/oauth-srv/test", params={"profile": "worker_beta"}
|
||||
)
|
||||
assert resp.json()["ok"] is True
|
||||
|
||||
def test_mcp_test_reports_optional_schema_chars(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
"""The probe's per-tool `schema_chars` (details out-param) surfaces as an
|
||||
ADDITIVE per-tool field on the wire; tools without a size stay bare so
|
||||
older/partial probes degrade to 'no estimate' in the renderer."""
|
||||
import hermes_cli.mcp_config as mcp_config
|
||||
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"mcp_servers:\n sized-srv:\n url: http://x/mcp\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def fake_probe(name, config, connect_timeout=30, details=None):
|
||||
if details is not None:
|
||||
details["schema_chars"] = {"tool-a": 420}
|
||||
return [("tool-a", "desc-a"), ("tool-b", "desc-b")]
|
||||
|
||||
monkeypatch.setattr(mcp_config, "_probe_single_server", fake_probe)
|
||||
|
||||
resp = client.post(
|
||||
"/api/mcp/servers/sized-srv/test", params={"profile": "worker_beta"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["ok"] is True
|
||||
tools = {t["name"]: t for t in body["tools"]}
|
||||
assert tools["tool-a"]["schema_chars"] == 420
|
||||
# No size for tool-b → the key is simply absent (additive-optional).
|
||||
assert "schema_chars" not in tools["tool-b"]
|
||||
|
||||
def test_mcp_test_without_schema_chars_keeps_old_wire_shape(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
"""A probe that never fills schema_chars (older code path) produces the
|
||||
exact pre-overlay tool objects — nothing new for old renderers."""
|
||||
import hermes_cli.mcp_config as mcp_config
|
||||
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"mcp_servers:\n plain-srv:\n url: http://x/mcp\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mcp_config,
|
||||
"_probe_single_server",
|
||||
lambda name, config, connect_timeout=30, details=None: [("tool-a", "desc")],
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/mcp/servers/plain-srv/test", params={"profile": "worker_beta"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["tools"] == [{"name": "tool-a", "description": "desc"}]
|
||||
|
||||
|
||||
class TestProfileScopedModel:
|
||||
def test_model_set_main_scoped(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "openrouter",
|
||||
"model": "test/model-1",
|
||||
"confirm_expensive_model": True,
|
||||
"profile": "worker_beta",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
worker_cfg = _cfg(isolated_profiles["worker_beta"])
|
||||
model_cfg = worker_cfg.get("model", {})
|
||||
assert isinstance(model_cfg, dict)
|
||||
assert model_cfg.get("provider") == "openrouter"
|
||||
default_model = _cfg(isolated_profiles["default"]).get("model", {})
|
||||
if isinstance(default_model, dict):
|
||||
assert default_model.get("default") != "test/model-1"
|
||||
|
||||
def test_main_assignment_reports_only_target_profile_cron_impact(
|
||||
self, client, isolated_profiles
|
||||
):
|
||||
stale = {
|
||||
"name": "Worker summary",
|
||||
"enabled": True,
|
||||
"no_agent": False,
|
||||
"provider_snapshot": "openrouter",
|
||||
"model_snapshot": "old/model",
|
||||
}
|
||||
_write_jobs(
|
||||
isolated_profiles["worker_beta"], [{"id": "worker-job", **stale}]
|
||||
)
|
||||
_write_jobs(
|
||||
isolated_profiles["default"],
|
||||
[{"id": "default-job", **stale, "name": "Default summary"}],
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "nous",
|
||||
"model": "new/model",
|
||||
"confirm_expensive_model": True,
|
||||
"profile": "worker_beta",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["cron_model_impact"] == {
|
||||
"available": True,
|
||||
"guard_enabled": True,
|
||||
"affected_count": 1,
|
||||
"truncated": False,
|
||||
"jobs": [
|
||||
{
|
||||
"id": "worker-job",
|
||||
"name": "Worker summary",
|
||||
"drifted_axes": ["provider", "model"],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def test_unavailable_impact_does_not_fail_persisted_assignment(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
import cron.jobs
|
||||
|
||||
monkeypatch.setattr(cron.jobs, "load_jobs", lambda: {"malformed": True})
|
||||
|
||||
resp = client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "nous",
|
||||
"model": "new/model",
|
||||
"confirm_expensive_model": True,
|
||||
"profile": "worker_beta",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is True
|
||||
assert resp.json()["cron_model_impact"]["available"] is False
|
||||
assert _cfg(isolated_profiles["worker_beta"])["model"]["default"] == "new/model"
|
||||
|
||||
def test_auxiliary_and_confirmation_responses_have_no_impact_summary(
|
||||
self, client, isolated_profiles
|
||||
):
|
||||
_write_jobs(
|
||||
isolated_profiles["worker_beta"],
|
||||
[
|
||||
{
|
||||
"id": "worker-job",
|
||||
"enabled": True,
|
||||
"provider_snapshot": "openrouter",
|
||||
"model_snapshot": "old/model",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
auxiliary = client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "auxiliary",
|
||||
"provider": "nous",
|
||||
"model": "new/model",
|
||||
"profile": "worker_beta",
|
||||
},
|
||||
)
|
||||
confirmation = client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "openrouter",
|
||||
"model": "openai/gpt-5.5-pro",
|
||||
"profile": "worker_beta",
|
||||
},
|
||||
)
|
||||
|
||||
assert auxiliary.status_code == 200
|
||||
assert "cron_model_impact" not in auxiliary.json()
|
||||
assert confirmation.status_code == 200
|
||||
assert confirmation.json()["confirm_required"] is True
|
||||
assert "cron_model_impact" not in confirmation.json()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_model_options_uses_config_only_scope_for_selected_profile(
|
||||
self, client, monkeypatch
|
||||
):
|
||||
"""Regression (#58576): _profile_scope holds _SKILLS_PROFILE_LOCK
|
||||
across its body, and the payload build can block up to 15s on a
|
||||
models.dev cache miss — a cold request would starve concurrent
|
||||
/api/config on the same lock. The handler must scope the worker
|
||||
through _config_profile_scope (contextvar only, no lock) for the
|
||||
selected profile."""
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
scopes = []
|
||||
|
||||
@contextmanager
|
||||
def _recording_config_scope(profile):
|
||||
scopes.append(("config", profile))
|
||||
yield object()
|
||||
|
||||
@contextmanager
|
||||
def _recording_profile_scope(profile):
|
||||
scopes.append(("full", profile))
|
||||
yield object()
|
||||
|
||||
monkeypatch.setattr(
|
||||
web_server, "_config_profile_scope", _recording_config_scope
|
||||
)
|
||||
monkeypatch.setattr(web_server, "_profile_scope", _recording_profile_scope)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.inventory.load_picker_context", lambda: object()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.inventory.build_model_options_payload",
|
||||
lambda _ctx, **kwargs: {"providers": [], "model": "", "provider": ""},
|
||||
)
|
||||
|
||||
resp = client.get("/api/model/options", params={"profile": "worker_beta"})
|
||||
assert resp.status_code == 200
|
||||
# Only the config-only scope may wrap the payload build; entering
|
||||
# _profile_scope would hold _SKILLS_PROFILE_LOCK across it (#58576).
|
||||
assert scopes == [("config", "worker_beta")]
|
||||
|
||||
def test_model_info_unknown_profile_404(self, client, isolated_profiles):
|
||||
"""Regression: the broad except used to convert the 404 into a 200
|
||||
with empty model info ("no model set" — silently wrong)."""
|
||||
resp = client.get("/api/model/info", params={"profile": "ghost"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestProfileScopedPostSetup:
|
||||
def test_post_setup_spawns_with_profile_flag(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
"""Post-setup runs in a -p scoped subprocess so hooks that read
|
||||
config / write per-profile state see the same HERMES_HOME the rest
|
||||
of the drawer's writes targeted."""
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
calls = []
|
||||
|
||||
class _FakeProc:
|
||||
pid = 777
|
||||
|
||||
monkeypatch.setattr(
|
||||
web_server,
|
||||
"_spawn_hermes_action",
|
||||
lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config.valid_post_setup_keys",
|
||||
lambda: {"agent_browser"},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/tools/toolsets/browser/post-setup",
|
||||
json={"key": "agent_browser", "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert calls == [
|
||||
["-p", "worker_beta", "tools", "post-setup", "agent_browser"]
|
||||
]
|
||||
|
||||
def test_post_setup_without_profile_keeps_legacy_argv(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
calls = []
|
||||
|
||||
class _FakeProc:
|
||||
pid = 777
|
||||
|
||||
monkeypatch.setattr(
|
||||
web_server,
|
||||
"_spawn_hermes_action",
|
||||
lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config.valid_post_setup_keys",
|
||||
lambda: {"agent_browser"},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/tools/toolsets/browser/post-setup",
|
||||
json={"key": "agent_browser"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert calls == [["tools", "post-setup", "agent_browser"]]
|
||||
|
||||
|
||||
class TestProfileScopedGateway:
|
||||
|
||||
def test_status_reads_requested_profile_home(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
import hermes_cli.web_server as web_server
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
seen_homes = []
|
||||
|
||||
def fake_get_running_pid(*args, **kwargs):
|
||||
# /api/status?profile= now passes pid_path= explicitly (the TTL
|
||||
# cache would otherwise serve another profile's PID) — accept it.
|
||||
seen_homes.append(str(get_hermes_home()))
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
|
||||
# get_status probes via the TTL-cached wrapper (PR #53511 salvage);
|
||||
# patch the cached name so the fake still intercepts the probe.
|
||||
monkeypatch.setattr(web_server, "get_running_pid_cached", fake_get_running_pid)
|
||||
monkeypatch.setattr(
|
||||
web_server,
|
||||
"read_runtime_status",
|
||||
lambda *a, **k: {"gateway_state": "startup_failed", "platforms": {}},
|
||||
)
|
||||
monkeypatch.setattr(web_server, "_GATEWAY_HEALTH_URL", None)
|
||||
|
||||
resp = client.get("/api/status", params={"profile": "worker_beta"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert seen_homes[0] == str(isolated_profiles["worker_beta"])
|
||||
assert resp.json()["hermes_home"] == str(isolated_profiles["worker_beta"])
|
||||
|
||||
def test_status_uses_runtime_pid_when_profile_pid_file_is_missing(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
worker_home = isolated_profiles["worker_beta"]
|
||||
(worker_home / ".env").write_text(
|
||||
"TELEGRAM_BOT_TOKEN=worker-token\n", encoding="utf-8"
|
||||
)
|
||||
(worker_home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"platforms": {"telegram": {"enabled": True}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
runtime = {
|
||||
"pid": 4242,
|
||||
"gateway_state": "running",
|
||||
"platforms": {"telegram": {"state": "connected"}},
|
||||
"exit_reason": None,
|
||||
"updated_at": "2026-06-17T00:00:00+00:00",
|
||||
}
|
||||
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
|
||||
monkeypatch.setattr(
|
||||
web_server, "get_running_pid_cached", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(web_server, "read_runtime_status", lambda *a, **k: runtime)
|
||||
monkeypatch.setattr(
|
||||
web_server,
|
||||
"get_runtime_status_running_pid",
|
||||
lambda payload, **k: 4242,
|
||||
)
|
||||
monkeypatch.setattr(web_server, "_GATEWAY_HEALTH_URL", None)
|
||||
from gateway.config import Platform
|
||||
|
||||
class _FakeGatewayConfig:
|
||||
def get_connected_platforms(self):
|
||||
return [Platform.TELEGRAM]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.config.load_gateway_config", lambda: _FakeGatewayConfig()
|
||||
)
|
||||
|
||||
resp = client.get("/api/status", params={"profile": "worker_beta"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["gateway_running"] is True
|
||||
assert data["gateway_pid"] == 4242
|
||||
assert data["gateway_state"] == "running"
|
||||
assert data["gateway_platforms"] == {"telegram": {"state": "connected"}}
|
||||
|
||||
def test_status_keeps_fatal_platforms_on_startup_failed(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
"""startup_failed keeps FATAL per-profile entries — they're the diagnosis.
|
||||
|
||||
A multiplex gateway that dies at startup persists per-profile fatal
|
||||
entries (``alpha:telegram`` etc.). The dead-gateway platform clear must
|
||||
not erase them: exit_reason alone can't say which profile failed how.
|
||||
Non-fatal leftovers (e.g. a platform that connected before the crash)
|
||||
are still dropped — only fatals survive.
|
||||
"""
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
runtime = {
|
||||
"pid": 4242,
|
||||
"gateway_state": "startup_failed",
|
||||
"platforms": {
|
||||
"telegram": {"state": "fatal", "error_code": "telegram_auth_error"},
|
||||
"alpha:telegram": {"state": "fatal", "error_code": "credential_collision"},
|
||||
"beta:discord": {"state": "connected"},
|
||||
},
|
||||
"exit_reason": "telegram: token rejected",
|
||||
"updated_at": "2026-06-17T00:00:00+00:00",
|
||||
}
|
||||
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
|
||||
monkeypatch.setattr(
|
||||
web_server, "get_running_pid_cached", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(web_server, "read_runtime_status", lambda *a, **k: runtime)
|
||||
# Bare platform keys are checked against the configured set (fail
|
||||
# closed) — mirror a host that actually has telegram configured.
|
||||
monkeypatch.setattr(
|
||||
web_server, "_load_configured_gateway_platforms", lambda: {"telegram"}
|
||||
)
|
||||
monkeypatch.setattr(web_server, "_GATEWAY_HEALTH_URL", None)
|
||||
|
||||
resp = client.get("/api/status", params={"profile": "worker_beta"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["gateway_running"] is False
|
||||
assert data["gateway_state"] == "startup_failed"
|
||||
assert data["gateway_exit_reason"] == "telegram: token rejected"
|
||||
# Fatal entries (root and namespaced) survive; the stale non-fatal is dropped.
|
||||
assert set(data["gateway_platforms"]) == {"telegram", "alpha:telegram"}
|
||||
assert data["gateway_platforms"]["alpha:telegram"]["error_code"] == "credential_collision"
|
||||
|
||||
def test_status_clears_platforms_on_clean_stop(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
"""A cleanly stopped gateway still reports no platforms (stale-noise rule)."""
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
runtime = {
|
||||
"pid": 4242,
|
||||
"gateway_state": "stopped",
|
||||
"platforms": {"telegram": {"state": "connected"}},
|
||||
"exit_reason": None,
|
||||
"updated_at": "2026-06-17T00:00:00+00:00",
|
||||
}
|
||||
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
|
||||
monkeypatch.setattr(
|
||||
web_server, "get_running_pid_cached", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(web_server, "read_runtime_status", lambda *a, **k: runtime)
|
||||
monkeypatch.setattr(web_server, "_GATEWAY_HEALTH_URL", None)
|
||||
|
||||
resp = client.get("/api/status", params={"profile": "worker_beta"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["gateway_state"] == "stopped"
|
||||
assert data["gateway_platforms"] == {}
|
||||
|
||||
|
||||
class TestProfileScopedTelegramOnboarding:
|
||||
def test_apply_writes_target_profile_and_restarts_target(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
import time
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
with web_server._telegram_onboarding_lock:
|
||||
web_server._telegram_onboarding_pairings.clear()
|
||||
web_server._telegram_onboarding_pairings["pair-worker"] = (
|
||||
web_server._TelegramOnboardingPairing(
|
||||
poll_token="poll-secret",
|
||||
expires_at="2027-05-18T00:00:00.000Z",
|
||||
expires_at_ts=time.time() + 600,
|
||||
bot_token="123456:SECRET",
|
||||
bot_username="worker_bot",
|
||||
owner_user_id="123456789",
|
||||
)
|
||||
)
|
||||
|
||||
calls = []
|
||||
|
||||
class _FakeProc:
|
||||
pid = 889
|
||||
|
||||
monkeypatch.setattr(
|
||||
web_server,
|
||||
"_spawn_hermes_action",
|
||||
lambda subcommand, name: calls.append((list(subcommand), name)) or _FakeProc(),
|
||||
)
|
||||
web_server._ACTION_PROCS.pop("gateway-restart", None)
|
||||
web_server._ACTION_COMMANDS.pop("gateway-restart", None)
|
||||
|
||||
resp = client.post(
|
||||
"/api/messaging/telegram/onboarding/pair-worker/apply",
|
||||
params={"profile": "worker_beta"},
|
||||
json={"allowed_user_ids": ["123456789"]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["restart_started"] is True
|
||||
assert calls == [
|
||||
(["-p", "worker_beta", "gateway", "restart"], "gateway-restart")
|
||||
]
|
||||
|
||||
worker_env = (isolated_profiles["worker_beta"] / ".env").read_text()
|
||||
assert "TELEGRAM_BOT_TOKEN=123456:SECRET" in worker_env
|
||||
assert "TELEGRAM_ALLOWED_USERS=123456789" in worker_env
|
||||
default_env_path = isolated_profiles["default"] / ".env"
|
||||
if default_env_path.exists():
|
||||
assert "TELEGRAM_BOT_TOKEN" not in default_env_path.read_text()
|
||||
|
||||
worker_cfg = _cfg(isolated_profiles["worker_beta"])
|
||||
default_cfg = _cfg(isolated_profiles["default"])
|
||||
assert worker_cfg["platforms"]["telegram"]["enabled"] is True
|
||||
assert default_cfg.get("platforms", {}).get("telegram", {}).get("enabled") is not True
|
||||
|
||||
|
||||
class TestProfileScopedChatPty:
|
||||
def test_chat_argv_scopes_hermes_home(self, isolated_profiles, monkeypatch):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.main._make_tui_argv",
|
||||
lambda root, tui_dev=False: (["cat"], None),
|
||||
raising=False,
|
||||
)
|
||||
argv, cwd, env = web_server._resolve_chat_argv(profile="worker_beta")
|
||||
assert env is not None
|
||||
assert env["HERMES_HOME"] == str(isolated_profiles["worker_beta"])
|
||||
# Scoped chat must NOT attach to the dashboard's in-memory gateway.
|
||||
assert "HERMES_TUI_GATEWAY_URL" not in env
|
||||
|
||||
def test_chat_argv_bridges_selected_profile_terminal_config(
|
||||
self, isolated_profiles, monkeypatch
|
||||
):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
(isolated_profiles["default"] / "config.yaml").write_text(
|
||||
"terminal:\n"
|
||||
" backend: docker\n"
|
||||
" docker_image: launch-profile-image\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"terminal:\n"
|
||||
" backend: ssh\n"
|
||||
" ssh_host: worker.example.test\n"
|
||||
" cwd: '~'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
||||
monkeypatch.setenv("TERMINAL_DOCKER_IMAGE", "launch-profile-image")
|
||||
monkeypatch.setenv("TERMINAL_SSH_USER", "operator-user")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.main._make_tui_argv",
|
||||
lambda root, tui_dev=False: (["cat"], None),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
_argv, _cwd, env = web_server._resolve_chat_argv(profile="worker_beta")
|
||||
|
||||
assert env is not None
|
||||
assert env["HERMES_HOME"] == str(isolated_profiles["worker_beta"])
|
||||
assert env["TERMINAL_ENV"] == "ssh"
|
||||
assert env["TERMINAL_SSH_HOST"] == "worker.example.test"
|
||||
assert env["TERMINAL_CWD"] == "~"
|
||||
assert env["TERMINAL_DOCKER_IMAGE"] != "launch-profile-image"
|
||||
assert env["TERMINAL_SSH_USER"] == "operator-user"
|
||||
|
||||
def test_chat_argv_default_profile_preserves_exported_terminal_values(
|
||||
self, isolated_profiles, monkeypatch
|
||||
):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
(isolated_profiles["default"] / "config.yaml").write_text(
|
||||
"terminal:\n backend: docker\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
||||
monkeypatch.setenv("TERMINAL_SSH_USER", "operator-user")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.main._make_tui_argv",
|
||||
lambda root, tui_dev=False: (["cat"], None),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
_argv, _cwd, env = web_server._resolve_chat_argv()
|
||||
|
||||
assert env is not None
|
||||
assert env["TERMINAL_ENV"] == "docker"
|
||||
assert env["TERMINAL_SSH_USER"] == "operator-user"
|
||||
|
||||
@pytest.mark.parametrize("placeholder", [".", "auto", "cwd"])
|
||||
def test_chat_argv_placeholder_cwd_preserves_exported_value(
|
||||
self, isolated_profiles, monkeypatch, placeholder
|
||||
):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
(isolated_profiles["default"] / "config.yaml").write_text(
|
||||
f"terminal:\n backend: docker\n cwd: {placeholder}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"terminal:\n backend: ssh\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
||||
monkeypatch.setenv("TERMINAL_CWD", "/operator/work")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.main._make_tui_argv",
|
||||
lambda root, tui_dev=False: (["cat"], None),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
_argv, _cwd, env = web_server._resolve_chat_argv(profile="worker_beta")
|
||||
|
||||
assert env is not None
|
||||
assert env["TERMINAL_ENV"] == "ssh"
|
||||
assert env["TERMINAL_CWD"] == "/operator/work"
|
||||
|
||||
def test_chat_argv_warns_when_profile_terminal_bridge_fails(
|
||||
self, isolated_profiles, monkeypatch, caplog
|
||||
):
|
||||
import logging
|
||||
|
||||
import hermes_cli.config as config_mod
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
(isolated_profiles["default"] / "config.yaml").write_text(
|
||||
"terminal:\n backend: docker\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.main._make_tui_argv",
|
||||
lambda root, tui_dev=False: (["cat"], None),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
config_mod,
|
||||
"apply_terminal_config_to_env",
|
||||
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("bridge failed")),
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=web_server._log.name):
|
||||
_argv, _cwd, env = web_server._resolve_chat_argv(profile="worker_beta")
|
||||
|
||||
assert env is not None
|
||||
assert env["HERMES_HOME"] == str(isolated_profiles["worker_beta"])
|
||||
assert "TERMINAL_ENV" not in env
|
||||
assert "Failed to apply terminal config bridge for dashboard chat" in caplog.text
|
||||
|
||||
|
||||
class TestProfileScopedAudio:
|
||||
"""Audio endpoints must honor ``profile`` like the rest of the dashboard.
|
||||
|
||||
Historically /api/audio/transcribe|speak|elevenlabs/voices took no profile
|
||||
and always resolved the dashboard's own config/.env, so a non-default
|
||||
profile's TTS/STT settings were silently ignored (#53441 #45506 #66012
|
||||
#64057).
|
||||
"""
|
||||
|
||||
|
||||
|
||||
def test_transcribe_runs_inside_target_profile_home(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
import base64
|
||||
|
||||
import tools.voice_mode as voice_mode
|
||||
|
||||
seen = {}
|
||||
|
||||
def _fake_transcribe(path):
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
seen["home"] = str(get_hermes_home())
|
||||
return {"success": True, "transcript": "hi", "provider": "fake"}
|
||||
|
||||
monkeypatch.setattr(voice_mode, "transcribe_recording", _fake_transcribe)
|
||||
payload = base64.b64encode(b"\x00fakeaudio").decode("ascii")
|
||||
resp = client.post(
|
||||
"/api/audio/transcribe?profile=worker_beta",
|
||||
json={"data_url": f"data:audio/webm;base64,{payload}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["transcript"] == "hi"
|
||||
assert seen["home"] == str(isolated_profiles["worker_beta"])
|
||||
|
||||
def test_audio_endpoints_unknown_profile_404(self, client, isolated_profiles):
|
||||
resp = client.get("/api/audio/elevenlabs/voices?profile=ghost")
|
||||
assert resp.status_code == 404
|
||||
resp = client.post("/api/audio/speak?profile=ghost", json={"text": "x"})
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user