mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
Merge remote-tracking branch 'origin/main' into bb/skills-renovate
This commit is contained in:
commit
65415b1a12
119 changed files with 13920 additions and 336 deletions
681
tests/hermes_cli/test_console_engine.py
Normal file
681
tests/hermes_cli/test_console_engine.py
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.console_engine import HermesConsoleEngine, run_console_repl
|
||||
|
||||
|
||||
EXPECTED_CONSOLE_COMMANDS = {
|
||||
("status",),
|
||||
("doctor",),
|
||||
("logs",),
|
||||
("version",),
|
||||
("dump",),
|
||||
("debug", "share"),
|
||||
("debug", "delete"),
|
||||
("prompt-size",),
|
||||
("insights",),
|
||||
("security", "audit"),
|
||||
("portal", "info"),
|
||||
("portal", "tools"),
|
||||
("backup",),
|
||||
("import",),
|
||||
("send",),
|
||||
("config", "show"),
|
||||
("config", "path"),
|
||||
("config", "env-path"),
|
||||
("config", "check"),
|
||||
("config", "migrate"),
|
||||
("config", "set"),
|
||||
("sessions", "list"),
|
||||
("sessions", "stats"),
|
||||
("sessions", "export"),
|
||||
("sessions", "rename"),
|
||||
("sessions", "optimize"),
|
||||
("sessions", "repair"),
|
||||
("cron", "list"),
|
||||
("cron", "status"),
|
||||
("cron", "create"),
|
||||
("cron", "edit"),
|
||||
("cron", "pause"),
|
||||
("cron", "resume"),
|
||||
("cron", "run"),
|
||||
("cron", "remove"),
|
||||
("cron", "tick"),
|
||||
("profile",),
|
||||
("profile", "list"),
|
||||
("profile", "show"),
|
||||
("profile", "info"),
|
||||
("profile", "create"),
|
||||
("profile", "use"),
|
||||
("profile", "describe"),
|
||||
("profile", "rename"),
|
||||
("profile", "delete"),
|
||||
("profile", "export"),
|
||||
("profile", "import"),
|
||||
("profile", "install"),
|
||||
("profile", "update"),
|
||||
("tools", "list"),
|
||||
("tools", "enable"),
|
||||
("tools", "disable"),
|
||||
("tools", "post-setup"),
|
||||
("plugins", "list"),
|
||||
("plugins", "enable"),
|
||||
("plugins", "disable"),
|
||||
("plugins", "install"),
|
||||
("plugins", "update"),
|
||||
("plugins", "remove"),
|
||||
("skills", "browse"),
|
||||
("skills", "search"),
|
||||
("skills", "inspect"),
|
||||
("skills", "list"),
|
||||
("skills", "check"),
|
||||
("skills", "list-modified"),
|
||||
("skills", "diff"),
|
||||
("skills", "install"),
|
||||
("skills", "update"),
|
||||
("skills", "audit"),
|
||||
("skills", "uninstall"),
|
||||
("skills", "reset"),
|
||||
("skills", "opt-in"),
|
||||
("skills", "opt-out"),
|
||||
("skills", "repair-official"),
|
||||
("skills", "snapshot", "export"),
|
||||
("skills", "snapshot", "import"),
|
||||
("skills", "tap", "list"),
|
||||
("skills", "tap", "add"),
|
||||
("skills", "tap", "remove"),
|
||||
("mcp", "list"),
|
||||
("mcp", "catalog"),
|
||||
("mcp", "test"),
|
||||
("mcp", "add"),
|
||||
("mcp", "remove"),
|
||||
("mcp", "install"),
|
||||
("mcp", "login"),
|
||||
("mcp", "reauth"),
|
||||
("mcp", "configure"),
|
||||
("mcp", "picker"),
|
||||
("memory", "status"),
|
||||
("memory", "off"),
|
||||
("memory", "reset"),
|
||||
("auth", "list"),
|
||||
("auth", "status"),
|
||||
("auth", "reset"),
|
||||
("auth", "add"),
|
||||
("auth", "remove"),
|
||||
("auth", "logout"),
|
||||
("auth", "spotify", "status"),
|
||||
("auth", "spotify", "login"),
|
||||
("auth", "spotify", "logout"),
|
||||
("pairing", "list"),
|
||||
("pairing", "approve"),
|
||||
("pairing", "revoke"),
|
||||
("pairing", "clear-pending"),
|
||||
("webhook", "list"),
|
||||
("webhook", "subscribe"),
|
||||
("webhook", "remove"),
|
||||
("webhook", "test"),
|
||||
("hooks", "list"),
|
||||
("hooks", "test"),
|
||||
("hooks", "doctor"),
|
||||
("hooks", "revoke"),
|
||||
("slack", "manifest"),
|
||||
("project", "list"),
|
||||
("project", "show"),
|
||||
("project", "create"),
|
||||
("project", "add-folder"),
|
||||
("project", "remove-folder"),
|
||||
("project", "rename"),
|
||||
("project", "set-primary"),
|
||||
("project", "use"),
|
||||
("project", "archive"),
|
||||
("project", "restore"),
|
||||
("project", "bind-board"),
|
||||
("kanban", "init"),
|
||||
("kanban", "boards", "list"),
|
||||
("kanban", "boards", "create"),
|
||||
("kanban", "boards", "rm"),
|
||||
("kanban", "boards", "switch"),
|
||||
("kanban", "boards", "current"),
|
||||
("kanban", "boards", "rename"),
|
||||
("kanban", "boards", "set-workdir"),
|
||||
("kanban", "create"),
|
||||
("kanban", "list"),
|
||||
("kanban", "show"),
|
||||
("kanban", "assign"),
|
||||
("kanban", "reclaim"),
|
||||
("kanban", "reassign"),
|
||||
("kanban", "diagnose"),
|
||||
("kanban", "link"),
|
||||
("kanban", "unlink"),
|
||||
("kanban", "claim"),
|
||||
("kanban", "comment"),
|
||||
("kanban", "complete"),
|
||||
("kanban", "edit"),
|
||||
("kanban", "block"),
|
||||
("kanban", "schedule"),
|
||||
("kanban", "unblock"),
|
||||
("kanban", "promote"),
|
||||
("kanban", "archive"),
|
||||
("kanban", "stats"),
|
||||
("kanban", "runs"),
|
||||
("kanban", "heartbeat"),
|
||||
("kanban", "assignments"),
|
||||
("kanban", "context"),
|
||||
("bundles", "list"),
|
||||
("bundles", "show"),
|
||||
("bundles", "create"),
|
||||
("bundles", "delete"),
|
||||
("bundles", "reload"),
|
||||
("checkpoints", "status"),
|
||||
("checkpoints", "list"),
|
||||
("checkpoints", "prune"),
|
||||
("checkpoints", "clear"),
|
||||
("checkpoints", "clear-legacy"),
|
||||
("curator", "status"),
|
||||
("curator", "run"),
|
||||
("curator", "pause"),
|
||||
("curator", "resume"),
|
||||
("curator", "pin"),
|
||||
("curator", "unpin"),
|
||||
("curator", "restore"),
|
||||
("curator", "list-archived"),
|
||||
("curator", "archive"),
|
||||
("curator", "prune"),
|
||||
("curator", "backup"),
|
||||
("curator", "rollback"),
|
||||
("pets", "list"),
|
||||
("pets", "install"),
|
||||
("pets", "select"),
|
||||
("pets", "show"),
|
||||
("pets", "off"),
|
||||
("pets", "scale"),
|
||||
("pets", "remove"),
|
||||
("pets", "doctor"),
|
||||
}
|
||||
|
||||
|
||||
MUTATING_CONFIRMATION_SMOKE_COMMANDS = [
|
||||
"config set console.test true",
|
||||
"config migrate",
|
||||
"sessions rename abc123 new title",
|
||||
"sessions optimize",
|
||||
"cron create 'every 1h' 'say hello'",
|
||||
"cron remove abc123",
|
||||
"profile create tester --no-alias --no-skills",
|
||||
"profile delete tester",
|
||||
"tools disable web",
|
||||
"plugins install owner/repo --no-enable",
|
||||
"skills install openai/skills/example",
|
||||
"mcp add demo --url https://example.com/sse",
|
||||
"mcp configure github",
|
||||
"mcp picker",
|
||||
"backup --quick -o /tmp/hermes-console-test.zip",
|
||||
"import /tmp/hermes-console-test.zip",
|
||||
"send --to telegram hello",
|
||||
"memory reset --target memory",
|
||||
"auth remove openrouter 1",
|
||||
"pairing approve abc123",
|
||||
"webhook subscribe test --prompt hello",
|
||||
"hooks test pre_tool_call",
|
||||
"project create demo",
|
||||
"kanban create 'demo task'",
|
||||
"bundles create demo --skill skill-a",
|
||||
"checkpoints prune",
|
||||
"curator pause",
|
||||
"pets install cat",
|
||||
]
|
||||
|
||||
|
||||
def test_console_parses_bare_and_hermes_prefixed_commands(_isolate_hermes_home):
|
||||
engine = HermesConsoleEngine()
|
||||
|
||||
bare = engine.execute("config path")
|
||||
prefixed = engine.execute("hermes config path")
|
||||
|
||||
assert bare.status == "ok"
|
||||
assert prefixed.status == "ok"
|
||||
assert bare.output == prefixed.output
|
||||
assert bare.output.endswith("config.yaml")
|
||||
|
||||
|
||||
def test_console_status_hides_cli_next_step_footer(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
_isolate_hermes_home,
|
||||
):
|
||||
import hermes_cli.status as status_mod
|
||||
|
||||
def fake_show_status(_args):
|
||||
print("◆ Sessions")
|
||||
print("Active: 3 session(s)")
|
||||
print()
|
||||
rule = "\u2500" * 60
|
||||
print(f"\x1b[2m{rule}\x1b[0m")
|
||||
print("\x1b[2m Run 'hermes doctor' for detailed diagnostics\x1b[0m")
|
||||
print("\x1b[2m Run 'hermes setup' to configure\x1b[0m")
|
||||
print()
|
||||
|
||||
monkeypatch.setattr(status_mod, "show_status", fake_show_status)
|
||||
|
||||
result = HermesConsoleEngine().execute("status")
|
||||
|
||||
assert result.status == "ok"
|
||||
assert "Sessions" in result.output
|
||||
assert "Active: 3 session(s)" in result.output
|
||||
assert "hermes doctor" not in result.output
|
||||
assert "hermes setup" not in result.output
|
||||
assert "\u2500" not in result.output
|
||||
|
||||
|
||||
def test_console_status_hides_osc_linked_cli_next_step_footer(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
_isolate_hermes_home,
|
||||
):
|
||||
import hermes_cli.status as status_mod
|
||||
|
||||
def osc_link(text: str) -> str:
|
||||
return f"\x1b]8;;https://example.test\x1b\\{text}\x1b]8;;\x1b\\"
|
||||
|
||||
def fake_show_status(_args):
|
||||
print("◆ Sessions")
|
||||
print("Active: 3 session(s)")
|
||||
print()
|
||||
print(osc_link("\u2500" * 60))
|
||||
print(osc_link(" Run 'hermes doctor' for detailed diagnostics"))
|
||||
print(osc_link(" Run 'hermes setup' to configure"))
|
||||
print()
|
||||
|
||||
monkeypatch.setattr(status_mod, "show_status", fake_show_status)
|
||||
|
||||
result = HermesConsoleEngine().execute("status")
|
||||
|
||||
assert result.status == "ok"
|
||||
assert "Sessions" in result.output
|
||||
assert "Active: 3 session(s)" in result.output
|
||||
assert "hermes doctor" not in result.output
|
||||
assert "hermes setup" not in result.output
|
||||
assert "https://example.test" not in result.output
|
||||
assert "\u2500" not in result.output
|
||||
|
||||
|
||||
def test_console_help_uses_cli_subcommand_summaries():
|
||||
help_text = HermesConsoleEngine().help_text()
|
||||
|
||||
assert "skills list" in help_text
|
||||
assert "List installed skills" in help_text
|
||||
assert "Show all tools and their enabled/disabled status" in help_text
|
||||
assert "Remove an MCP server" in help_text
|
||||
assert "Check pet setup + terminal graphics support" in help_text
|
||||
assert "Run `hermes skills list`" not in help_text
|
||||
assert "Run `hermes tools list`" not in help_text
|
||||
|
||||
|
||||
def test_console_help_table_keeps_long_summaries_compact():
|
||||
help_text = HermesConsoleEngine().help_text()
|
||||
|
||||
slack_line = next(
|
||||
line for line in help_text.splitlines() if line.strip().startswith("slack manifest")
|
||||
)
|
||||
|
||||
assert len(slack_line) <= 112
|
||||
assert slack_line.endswith("...")
|
||||
|
||||
|
||||
def test_console_help_for_command_uses_cli_summary():
|
||||
help_text = HermesConsoleEngine().help_text("skills list")
|
||||
|
||||
assert help_text == "skills list\nList installed skills"
|
||||
|
||||
|
||||
def test_console_registry_covers_non_admin_cli_surface():
|
||||
registered = set(HermesConsoleEngine().commands)
|
||||
|
||||
missing = EXPECTED_CONSOLE_COMMANDS - registered
|
||||
|
||||
assert missing == set()
|
||||
|
||||
|
||||
EXPECTED_HOSTED_CONSOLE_COMMANDS = {
|
||||
("status",),
|
||||
("doctor",),
|
||||
("logs",),
|
||||
("version",),
|
||||
("prompt-size",),
|
||||
("insights",),
|
||||
("security", "audit"),
|
||||
("portal", "info"),
|
||||
("portal", "tools"),
|
||||
("send",),
|
||||
("config", "show"),
|
||||
("config", "path"),
|
||||
("config", "env-path"),
|
||||
("config", "check"),
|
||||
("config", "migrate"),
|
||||
("config", "set"),
|
||||
("sessions", "list"),
|
||||
("sessions", "stats"),
|
||||
("sessions", "export"),
|
||||
("sessions", "rename"),
|
||||
("sessions", "optimize"),
|
||||
("sessions", "repair"),
|
||||
("cron", "list"),
|
||||
("cron", "status"),
|
||||
("cron", "create"),
|
||||
("cron", "edit"),
|
||||
("cron", "pause"),
|
||||
("cron", "resume"),
|
||||
("cron", "run"),
|
||||
("cron", "remove"),
|
||||
("cron", "tick"),
|
||||
("profile",),
|
||||
("profile", "list"),
|
||||
("profile", "show"),
|
||||
("profile", "info"),
|
||||
("tools", "list"),
|
||||
("tools", "enable"),
|
||||
("tools", "disable"),
|
||||
("tools", "post-setup"),
|
||||
("skills", "browse"),
|
||||
("skills", "search"),
|
||||
("skills", "inspect"),
|
||||
("skills", "list"),
|
||||
("skills", "check"),
|
||||
("skills", "list-modified"),
|
||||
("skills", "diff"),
|
||||
("skills", "install"),
|
||||
("skills", "update"),
|
||||
("skills", "audit"),
|
||||
("skills", "uninstall"),
|
||||
("skills", "reset"),
|
||||
("skills", "opt-in"),
|
||||
("skills", "opt-out"),
|
||||
("skills", "repair-official"),
|
||||
("skills", "snapshot", "export"),
|
||||
("skills", "tap", "list"),
|
||||
("mcp", "list"),
|
||||
("mcp", "catalog"),
|
||||
("mcp", "test"),
|
||||
("mcp", "add"),
|
||||
("mcp", "remove"),
|
||||
("mcp", "install"),
|
||||
("mcp", "login"),
|
||||
("mcp", "reauth"),
|
||||
("mcp", "configure"),
|
||||
("mcp", "picker"),
|
||||
("memory", "status"),
|
||||
("auth", "list"),
|
||||
("auth", "status"),
|
||||
("auth", "reset"),
|
||||
("auth", "spotify", "status"),
|
||||
("pairing", "list"),
|
||||
("pairing", "approve"),
|
||||
("pairing", "revoke"),
|
||||
("pairing", "clear-pending"),
|
||||
("webhook", "list"),
|
||||
("webhook", "subscribe"),
|
||||
("webhook", "remove"),
|
||||
("webhook", "test"),
|
||||
}
|
||||
|
||||
|
||||
def test_hosted_console_registry_exposes_only_hosted_safe_surface():
|
||||
engine = HermesConsoleEngine(context="hosted")
|
||||
hosted = {
|
||||
path for path, command in engine.commands.items() if "hosted" in command.contexts
|
||||
}
|
||||
|
||||
assert hosted == EXPECTED_HOSTED_CONSOLE_COMMANDS
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"line",
|
||||
[
|
||||
"portal login",
|
||||
"auth add nous --type oauth",
|
||||
"auth logout nous",
|
||||
"profile create tester",
|
||||
"profile use default",
|
||||
"plugins list",
|
||||
"plugins install owner/repo",
|
||||
"kanban list",
|
||||
"hooks list",
|
||||
"checkpoints clear",
|
||||
"curator pause",
|
||||
"pets install cat",
|
||||
"backup --quick",
|
||||
"import /tmp/hermes-console-test.zip",
|
||||
"mcp serve",
|
||||
"model",
|
||||
"setup",
|
||||
"dashboard",
|
||||
"gateway restart",
|
||||
"update",
|
||||
"uninstall",
|
||||
],
|
||||
)
|
||||
def test_hosted_console_rejects_local_only_or_dangerous_commands(line):
|
||||
result = HermesConsoleEngine(context="hosted").execute(line)
|
||||
|
||||
assert result.status == "error"
|
||||
assert result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"line",
|
||||
[
|
||||
"mcp add demo --url https://example.com/sse",
|
||||
"mcp install n8n",
|
||||
"mcp configure github",
|
||||
"mcp picker",
|
||||
"config set display.interface cli",
|
||||
"cron create 'every 1h' 'say hello'",
|
||||
],
|
||||
)
|
||||
def test_hosted_console_allows_guarded_useful_commands_before_confirmation(line):
|
||||
result = HermesConsoleEngine(context="hosted").execute(line)
|
||||
|
||||
assert result.status == "confirm_required"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"line",
|
||||
[
|
||||
"mcp add local --command npx --args foo",
|
||||
"mcp add local --preset unsafe",
|
||||
"mcp add local --url file:///tmp/server",
|
||||
"config set model.provider openrouter",
|
||||
"config set portal.url https://evil.example",
|
||||
"cron create 'every 1h' 'say hello' --script scripts/ping.py",
|
||||
"cron create 'every 1h' 'say hello' --no-agent",
|
||||
"cron edit abc123 --workdir /tmp/project",
|
||||
],
|
||||
)
|
||||
def test_hosted_console_blocks_known_footgun_arguments_before_confirmation(line):
|
||||
result = HermesConsoleEngine(context="hosted").execute(line)
|
||||
|
||||
assert result.status == "error"
|
||||
assert result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"line",
|
||||
[
|
||||
"sessions delete abc123",
|
||||
"sessions prune --older-than 1",
|
||||
"chat",
|
||||
"--cli",
|
||||
"--tui",
|
||||
"oneshot hello",
|
||||
"model",
|
||||
"setup",
|
||||
"postinstall",
|
||||
"fallback add",
|
||||
"moa configure",
|
||||
"claw migrate",
|
||||
"gateway restart",
|
||||
"gateway start",
|
||||
"gateway stop",
|
||||
"dashboard",
|
||||
"serve",
|
||||
"proxy start",
|
||||
"mcp serve",
|
||||
"skills config",
|
||||
"skills publish ./skill",
|
||||
"completion bash",
|
||||
"acp",
|
||||
"update",
|
||||
"uninstall",
|
||||
"gui",
|
||||
"desktop",
|
||||
"login",
|
||||
"logout",
|
||||
"--tui",
|
||||
"logs | cat",
|
||||
"config show > out.txt",
|
||||
],
|
||||
)
|
||||
def test_console_rejects_destructive_and_shell_like_commands(line):
|
||||
result = HermesConsoleEngine().execute(line)
|
||||
|
||||
assert result.status == "error"
|
||||
assert result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("line", MUTATING_CONFIRMATION_SMOKE_COMMANDS)
|
||||
def test_mutating_console_commands_require_confirmation(line):
|
||||
result = HermesConsoleEngine().execute(line)
|
||||
|
||||
assert result.status == "confirm_required"
|
||||
assert result.confirmation_message
|
||||
|
||||
|
||||
def test_help_lists_supported_commands_and_not_full_cli():
|
||||
result = HermesConsoleEngine().execute("help")
|
||||
|
||||
assert result.status == "ok"
|
||||
assert "sessions list" in result.output
|
||||
assert "config set" in result.output
|
||||
assert "dashboard" not in result.output
|
||||
assert "gateway restart" not in result.output
|
||||
|
||||
|
||||
def test_config_set_requires_confirmation_then_writes(_isolate_hermes_home):
|
||||
engine = HermesConsoleEngine()
|
||||
|
||||
pending = engine.execute("config set console.test true")
|
||||
assert pending.status == "confirm_required"
|
||||
|
||||
from hermes_cli.config import read_raw_config
|
||||
|
||||
assert read_raw_config() == {}
|
||||
|
||||
result = engine.execute("config set console.test true", confirmed=True)
|
||||
|
||||
assert result.status == "ok"
|
||||
assert "console.test" in result.output
|
||||
assert read_raw_config()["console"]["test"] is True
|
||||
|
||||
|
||||
def test_sessions_list_and_stats_use_isolated_session_store(_isolate_hermes_home):
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session("chat-session", source="cli", model="test/model")
|
||||
db.create_session("tool-session", source="tool", model="test/model")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
engine = HermesConsoleEngine()
|
||||
listed = engine.execute("sessions list --limit 10")
|
||||
stats = engine.execute("sessions stats")
|
||||
|
||||
assert listed.status == "ok"
|
||||
assert "chat-session" in listed.output
|
||||
assert "tool-session" not in listed.output
|
||||
assert "Total sessions: 2" in stats.output
|
||||
assert "Listable sessions: 1" in stats.output
|
||||
|
||||
|
||||
def test_cron_pause_resume_and_run_require_confirmation(_isolate_hermes_home):
|
||||
from cron.jobs import create_job, get_job
|
||||
|
||||
job = create_job(prompt="say hello", schedule="every 1h", name="alpha")
|
||||
engine = HermesConsoleEngine()
|
||||
|
||||
pending = engine.execute(f"cron pause {job['id']}")
|
||||
assert pending.status == "confirm_required"
|
||||
stored = get_job(job["id"])
|
||||
assert stored is not None
|
||||
assert stored["state"] == "scheduled"
|
||||
|
||||
paused = engine.execute(f"cron pause {job['id']}", confirmed=True)
|
||||
assert paused.status == "ok"
|
||||
stored = get_job(job["id"])
|
||||
assert stored is not None
|
||||
assert stored["state"] == "paused"
|
||||
|
||||
resumed = engine.execute("cron resume alpha", confirmed=True)
|
||||
assert resumed.status == "ok"
|
||||
stored = get_job(job["id"])
|
||||
assert stored is not None
|
||||
assert stored["state"] == "scheduled"
|
||||
|
||||
triggered = engine.execute("cron run alpha", confirmed=True)
|
||||
assert triggered.status == "ok"
|
||||
assert "Triggered job" in triggered.output
|
||||
|
||||
|
||||
def test_repl_runs_non_interactive_lines_without_prompts(_isolate_hermes_home):
|
||||
stdin = io.StringIO("help\nexit\n")
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
|
||||
code = run_console_repl(
|
||||
stdin=stdin,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
assert code == 0
|
||||
assert "Hermes Console" in stdout.getvalue()
|
||||
assert "hermes>" not in stdout.getvalue()
|
||||
assert stderr.getvalue() == ""
|
||||
|
||||
|
||||
def test_repl_refuses_non_interactive_confirmation(_isolate_hermes_home):
|
||||
stdin = io.StringIO("config set console.test true\n")
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
|
||||
code = run_console_repl(
|
||||
stdin=stdin,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
assert code == 1
|
||||
assert "Confirmation required" in stderr.getvalue()
|
||||
|
||||
|
||||
def test_main_console_subcommand_smoke(_isolate_hermes_home):
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "hermes_cli.main", "console"],
|
||||
cwd=Path(__file__).resolve().parents[2],
|
||||
input="help\nexit\n",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "Hermes Console" in result.stdout
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
"""Tests for the WS-upgrade auth helper (Phase 5 task 5.2).
|
||||
|
||||
The dashboard's four WS endpoints (``/api/pty``, ``/api/ws``, ``/api/pub``,
|
||||
``/api/events``) share an auth gate: ``_ws_auth_ok``. In loopback mode it
|
||||
accepts ``?token=<_SESSION_TOKEN>``; in gated mode it accepts a single-use
|
||||
``?ticket=`` minted by ``POST /api/auth/ws-ticket``.
|
||||
The dashboard's WS endpoints (``/api/pty``, ``/api/console``, ``/api/ws``,
|
||||
``/api/pub``, ``/api/events``) share an auth gate: ``_ws_auth_ok``. In
|
||||
loopback mode it accepts ``?token=<_SESSION_TOKEN>``; in gated mode it accepts
|
||||
a single-use ``?ticket=`` minted by ``POST /api/auth/ws-ticket``.
|
||||
|
||||
These tests exercise the helper at the unit level (no actual WS upgrade)
|
||||
plus the ticket-mint endpoint under realistic gated-mode setup. We don't
|
||||
|
|
@ -315,9 +315,10 @@ class TestWsRequestIsAllowedGated:
|
|||
(intended only for unauthenticated loopback dev) must not also reject
|
||||
those upgrades: the OAuth gate + single-use ticket is the auth.
|
||||
|
||||
Regression coverage: every WS endpoint (``/api/pty``, ``/api/ws``,
|
||||
``/api/pub``, ``/api/events``) calls ``_ws_request_is_allowed`` after
|
||||
``_ws_auth_ok``. If the peer-IP check rejects gated mode, the chat
|
||||
Regression coverage: every WS endpoint (``/api/pty``, ``/api/console``,
|
||||
``/api/ws``, ``/api/pub``, ``/api/events``) calls
|
||||
``_ws_request_is_allowed`` after ``_ws_auth_ok``. If the peer-IP check
|
||||
rejects gated mode, the chat
|
||||
tab + sidebar tool feed silently fail to connect even after a
|
||||
successful OAuth login.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -207,6 +207,51 @@ class TestProviderPersistsAfterModelSave:
|
|||
assert model.get("base_url") == "https://packy.example.com/v1"
|
||||
assert model.get("api_mode") == "codex_responses"
|
||||
|
||||
def test_named_custom_provider_with_builtin_slug_persists_custom_prefix(
|
||||
self, config_home, monkeypatch
|
||||
):
|
||||
"""providers.<builtin-slug> must persist as a named custom provider."""
|
||||
import yaml
|
||||
|
||||
from hermes_cli.main import _model_flow_named_custom
|
||||
|
||||
config_path = config_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"providers:\n"
|
||||
" minimax-cn:\n"
|
||||
" name: MiniMax CN Proxy\n"
|
||||
" api: https://mimimax.cn/v1\n"
|
||||
" key_env: MINIMAX_CN_PROXY_KEY\n"
|
||||
" transport: chat_completions\n"
|
||||
" model: MiniMax-M3\n"
|
||||
" default_model: MiniMax-M3\n"
|
||||
)
|
||||
monkeypatch.setenv("MINIMAX_CN_PROXY_KEY", "proxy-secret")
|
||||
|
||||
provider_info = {
|
||||
"name": "MiniMax CN Proxy",
|
||||
"base_url": "https://mimimax.cn/v1",
|
||||
"api_key": "",
|
||||
"key_env": "MINIMAX_CN_PROXY_KEY",
|
||||
"model": "MiniMax-M3",
|
||||
"api_mode": "chat_completions",
|
||||
"provider_key": "minimax-cn",
|
||||
}
|
||||
|
||||
with patch("hermes_cli.auth._save_model_choice"), \
|
||||
patch("hermes_cli.auth.deactivate_provider"), \
|
||||
patch("hermes_cli.models.fetch_api_models", return_value=["MiniMax-M3"]), \
|
||||
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=OSError("no tty in test")), \
|
||||
patch("builtins.input", return_value="1"):
|
||||
_model_flow_named_custom({}, provider_info)
|
||||
|
||||
config = yaml.safe_load(config_path.read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict)
|
||||
assert model.get("provider") == "custom:minimax-cn"
|
||||
assert "base_url" not in model
|
||||
assert "api_key" not in model
|
||||
|
||||
def test_copilot_acp_provider_saved_when_selected(self, config_home):
|
||||
"""_model_flow_copilot_acp should persist provider/base_url/model together."""
|
||||
from hermes_cli.main import _model_flow_copilot_acp
|
||||
|
|
@ -555,4 +600,3 @@ class TestZaiEndpointPicker:
|
|||
_select_zai_endpoint(custom_url)
|
||||
|
||||
assert captured["default"] == expected_default
|
||||
|
||||
|
|
|
|||
|
|
@ -1272,6 +1272,33 @@ def test_resolve_requested_provider_precedence(monkeypatch):
|
|||
assert rp.resolve_requested_provider() == "auto"
|
||||
|
||||
|
||||
def test_resolve_runtime_provider_named_custom_with_builtin_slug(monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_CN_PROXY_KEY", "proxy-secret")
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
lambda: {
|
||||
"model": {"provider": "custom:minimax-cn"},
|
||||
"providers": {
|
||||
"minimax-cn": {
|
||||
"name": "MiniMax CN Proxy",
|
||||
"api": "https://mimimax.cn/v1",
|
||||
"key_env": "MINIMAX_CN_PROXY_KEY",
|
||||
"transport": "chat_completions",
|
||||
"default_model": "MiniMax-M3",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
resolved = rp.resolve_runtime_provider()
|
||||
|
||||
assert resolved["provider"] == "custom"
|
||||
assert resolved["base_url"] == "https://mimimax.cn/v1"
|
||||
assert resolved["api_key"] == "proxy-secret"
|
||||
assert resolved["api_mode"] == "chat_completions"
|
||||
|
||||
|
||||
# ── api_mode config override tests ──────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
346
tests/hermes_cli/test_update_venv_health.py
Normal file
346
tests/hermes_cli/test_update_venv_health.py
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
"""Tests for the Windows half-updated-venv hardening (July 2026 incident).
|
||||
|
||||
Covers three additions to ``hermes update``:
|
||||
|
||||
1. ``_venv_core_imports_healthy`` — the venv health probe that lets an
|
||||
"Already up to date" checkout still repair a broken dependency install.
|
||||
2. ``_detect_venv_python_processes`` — the venv-interpreter process guard
|
||||
that refuses to mutate the venv while a desktop backend / stray python
|
||||
holds .pyd files mapped.
|
||||
3. The commit_count == 0 repair branch wiring in ``_cmd_update_impl``.
|
||||
|
||||
All Windows-specific paths are exercised via ``_is_windows`` patching so
|
||||
they run on any host (same approach as test_update_concurrent_quarantine).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import main as cli_main
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _venv_core_imports_healthy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_venv_health_reports_healthy_when_no_venv(tmp_path):
|
||||
"""No venv python in a DEV checkout → nothing to probe → healthy."""
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path):
|
||||
healthy, detail = cli_main._venv_core_imports_healthy()
|
||||
assert healthy is True
|
||||
assert detail == ""
|
||||
|
||||
|
||||
def test_venv_health_missing_venv_unhealthy_on_managed_install(tmp_path):
|
||||
"""On a managed install (bootstrap marker) the venv IS the install —
|
||||
its absence must be reported unhealthy so the repair lane runs instead
|
||||
of 'Already up to date!'."""
|
||||
(tmp_path / ".hermes-bootstrap-complete").write_text("done")
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path):
|
||||
healthy, detail = cli_main._venv_core_imports_healthy()
|
||||
assert healthy is False
|
||||
assert "venv python missing" in detail
|
||||
|
||||
|
||||
def test_venv_health_missing_venv_unhealthy_with_interrupted_marker(tmp_path):
|
||||
"""An interrupted-update breadcrumb also flips missing-venv to unhealthy."""
|
||||
(tmp_path / ".update-incomplete").write_text("started=1\npid=1\n")
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path):
|
||||
healthy, detail = cli_main._venv_core_imports_healthy()
|
||||
assert healthy is False
|
||||
assert "venv python missing" in detail
|
||||
|
||||
|
||||
def _fake_venv_python(tmp_path, *, windows: bool = False):
|
||||
bin_dir = tmp_path / "venv" / ("Scripts" if windows else "bin")
|
||||
bin_dir.mkdir(parents=True)
|
||||
py = bin_dir / ("python.exe" if windows else "python")
|
||||
py.write_bytes(b"")
|
||||
return py
|
||||
|
||||
|
||||
def test_venv_health_reports_missing_imports(tmp_path):
|
||||
"""Probe output lines are surfaced as the unhealthy detail."""
|
||||
_fake_venv_python(tmp_path)
|
||||
|
||||
fake = SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout="fastapi: No module named 'annotated_doc'\n",
|
||||
stderr="",
|
||||
)
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.object(
|
||||
cli_main.subprocess, "run", return_value=fake
|
||||
):
|
||||
healthy, detail = cli_main._venv_core_imports_healthy()
|
||||
|
||||
assert healthy is False
|
||||
assert "annotated_doc" in detail
|
||||
|
||||
|
||||
def test_venv_health_healthy_when_probe_clean(tmp_path):
|
||||
_fake_venv_python(tmp_path)
|
||||
fake = SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.object(
|
||||
cli_main.subprocess, "run", return_value=fake
|
||||
):
|
||||
healthy, detail = cli_main._venv_core_imports_healthy()
|
||||
assert healthy is True
|
||||
|
||||
|
||||
def test_venv_health_broken_interpreter_is_unhealthy(tmp_path):
|
||||
"""Nonzero exit with no module list = interpreter itself is broken."""
|
||||
_fake_venv_python(tmp_path)
|
||||
fake = SimpleNamespace(returncode=1, stdout="", stderr="Fatal Python error: init failed\n")
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.object(
|
||||
cli_main.subprocess, "run", return_value=fake
|
||||
):
|
||||
healthy, detail = cli_main._venv_core_imports_healthy()
|
||||
assert healthy is False
|
||||
assert "Fatal Python error" in detail
|
||||
|
||||
|
||||
def test_venv_health_probe_failure_reports_healthy(tmp_path):
|
||||
"""A probe that can't run must NOT force needless reinstalls."""
|
||||
_fake_venv_python(tmp_path)
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.object(
|
||||
cli_main.subprocess,
|
||||
"run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="python", timeout=60),
|
||||
):
|
||||
healthy, _detail = cli_main._venv_core_imports_healthy()
|
||||
assert healthy is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _detect_venv_python_processes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _proc(pid: int, exe: str, name: str, cmdline: list[str] | None = None, cwd: str = ""):
|
||||
proc = MagicMock()
|
||||
proc.info = {
|
||||
"pid": pid,
|
||||
"exe": exe,
|
||||
"name": name,
|
||||
"cmdline": cmdline or [],
|
||||
"cwd": cwd,
|
||||
}
|
||||
return proc
|
||||
|
||||
|
||||
def test_detect_venv_python_off_windows_is_empty():
|
||||
with patch.object(cli_main, "_is_windows", return_value=False):
|
||||
assert cli_main._detect_venv_python_processes() == []
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_detect_venv_python_finds_backend(_winp, tmp_path):
|
||||
venv_py = str(tmp_path / "venv" / "Scripts" / "python.exe")
|
||||
other_py = "C:\\Python311\\python.exe"
|
||||
|
||||
me = MagicMock()
|
||||
me.parents.return_value = []
|
||||
fake_psutil = types.SimpleNamespace(
|
||||
process_iter=lambda attrs: iter(
|
||||
[
|
||||
_proc(101, venv_py, "python.exe", ["python.exe", "-m", "hermes_cli.main", "serve"]),
|
||||
_proc(102, other_py, "python.exe", ["python.exe", "somescript.py"]),
|
||||
]
|
||||
),
|
||||
Process=lambda *a, **k: me,
|
||||
)
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.dict(
|
||||
sys.modules, {"psutil": fake_psutil}
|
||||
):
|
||||
matches = cli_main._detect_venv_python_processes()
|
||||
|
||||
assert [m[0] for m in matches] == [101]
|
||||
assert "serve" in matches[0][2]
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_detect_venv_python_excludes_self_and_ancestors(_winp, tmp_path):
|
||||
import os as _os
|
||||
|
||||
venv_py = str(tmp_path / "venv" / "Scripts" / "python.exe")
|
||||
parent = MagicMock()
|
||||
parent.pid = 555
|
||||
me = MagicMock()
|
||||
me.parents.return_value = [parent]
|
||||
fake_psutil = types.SimpleNamespace(
|
||||
process_iter=lambda attrs: iter(
|
||||
[
|
||||
_proc(_os.getpid(), venv_py, "python.exe"),
|
||||
_proc(555, venv_py, "hermes.exe"),
|
||||
]
|
||||
),
|
||||
Process=lambda *a, **k: me,
|
||||
)
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.dict(
|
||||
sys.modules, {"psutil": fake_psutil}
|
||||
):
|
||||
assert cli_main._detect_venv_python_processes() == []
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_detect_venv_python_no_psutil_is_empty(_winp, tmp_path):
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.dict(
|
||||
sys.modules, {"psutil": None}
|
||||
):
|
||||
assert cli_main._detect_venv_python_processes() == []
|
||||
|
||||
|
||||
def test_format_venv_holders_message_flags_desktop_backend(tmp_path):
|
||||
matches = [
|
||||
(101, "python.exe", "python.exe -m hermes_cli.main serve --host 127.0.0.1"),
|
||||
(102, "pythonw.exe", "pythonw.exe -m hermes_cli.main gateway run"),
|
||||
]
|
||||
msg = cli_main._format_venv_python_holders_message(matches)
|
||||
assert "101" in msg
|
||||
assert "desktop app" in msg.lower()
|
||||
assert "gateway" in msg
|
||||
assert "hermes update" in msg
|
||||
assert "--force-venv" in msg
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_detect_venv_python_catches_outside_venv_trampoline(_winp, tmp_path):
|
||||
"""uv/base-interpreter trampoline: exe OUTSIDE the venv, but the cmdline
|
||||
clearly runs Hermes from this install → must still be flagged as a holder
|
||||
(it imports from the venv and holds its .pyd files)."""
|
||||
base_py = "C:\\Python311\\python.exe"
|
||||
venv_path = str(tmp_path / "venv" / "Scripts" / "python.exe")
|
||||
|
||||
me = MagicMock()
|
||||
me.parents.return_value = []
|
||||
fake_psutil = types.SimpleNamespace(
|
||||
process_iter=lambda attrs: iter(
|
||||
[
|
||||
# cmdline references the venv path directly
|
||||
_proc(201, base_py, "python.exe", [base_py, venv_path, "-m", "x"]),
|
||||
# `-m hermes_cli.main serve` with the install root as cwd
|
||||
_proc(
|
||||
202,
|
||||
base_py,
|
||||
"python.exe",
|
||||
[base_py, "-m", "hermes_cli.main", "serve"],
|
||||
cwd=str(tmp_path),
|
||||
),
|
||||
# unrelated base-interpreter python → NOT a holder
|
||||
_proc(203, base_py, "python.exe", [base_py, "somescript.py"], cwd="C:\\other"),
|
||||
]
|
||||
),
|
||||
Process=lambda *a, **k: me,
|
||||
)
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.dict(
|
||||
sys.modules, {"psutil": fake_psutil}
|
||||
):
|
||||
matches = cli_main._detect_venv_python_processes()
|
||||
|
||||
assert sorted(m[0] for m in matches) == [201, 202]
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_detect_venv_hermes_cli_cmdline_outside_install_not_matched(_winp, tmp_path):
|
||||
"""A hermes_cli.main process belonging to a DIFFERENT install (neither
|
||||
install root in cmdline nor cwd under it) must not be flagged."""
|
||||
base_py = "C:\\Python311\\python.exe"
|
||||
me = MagicMock()
|
||||
me.parents.return_value = []
|
||||
fake_psutil = types.SimpleNamespace(
|
||||
process_iter=lambda attrs: iter(
|
||||
[
|
||||
_proc(
|
||||
301,
|
||||
base_py,
|
||||
"python.exe",
|
||||
[base_py, "-m", "hermes_cli.main", "serve"],
|
||||
cwd="C:\\other-install",
|
||||
),
|
||||
]
|
||||
),
|
||||
Process=lambda *a, **k: me,
|
||||
)
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.dict(
|
||||
sys.modules, {"psutil": fake_psutil}
|
||||
):
|
||||
assert cli_main._detect_venv_python_processes() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --force vs --force-venv gating of the venv-holder guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _update_args(**overrides):
|
||||
defaults = dict(
|
||||
gateway=False,
|
||||
check=False,
|
||||
no_backup=True,
|
||||
backup=False,
|
||||
yes=True,
|
||||
branch=None,
|
||||
force=False,
|
||||
force_venv=False,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def _run_update_until_guard(args):
|
||||
"""Drive _cmd_update_impl just far enough to hit the venv-holder guard.
|
||||
|
||||
Everything before the guard is stubbed; the guard firing is observed via
|
||||
SystemExit(2). The first statement AFTER the guard is
|
||||
``git_dir = PROJECT_ROOT / ".git"`` — a PROJECT_ROOT sentinel whose
|
||||
``__truediv__`` raises marks 'guard passed'."""
|
||||
|
||||
class _PastGuard(Exception):
|
||||
pass
|
||||
|
||||
class _RootSentinel:
|
||||
def __truediv__(self, _other):
|
||||
raise _PastGuard
|
||||
|
||||
with patch.object(cli_main, "_is_windows", return_value=True), patch.object(
|
||||
cli_main, "_venv_scripts_dir", return_value=None
|
||||
), patch.object(cli_main, "_run_pre_update_backup"), patch.object(
|
||||
cli_main, "_pause_windows_gateways_for_update", return_value=None
|
||||
), patch.object(
|
||||
cli_main, "_resume_windows_gateways_after_update"
|
||||
), patch.object(
|
||||
cli_main,
|
||||
"_detect_venv_python_processes",
|
||||
return_value=[(101, "python.exe", "python.exe -m hermes_cli.main serve")],
|
||||
), patch.object(
|
||||
cli_main, "PROJECT_ROOT", _RootSentinel()
|
||||
):
|
||||
try:
|
||||
cli_main._cmd_update_impl(args, gateway_mode=False)
|
||||
except _PastGuard:
|
||||
return "past_guard"
|
||||
except SystemExit as exc:
|
||||
return f"exit_{exc.code}"
|
||||
return "returned"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"force,force_venv,expected",
|
||||
[
|
||||
(False, False, "exit_2"), # guard fires
|
||||
(True, False, "exit_2"), # plain --force does NOT bypass the venv guard
|
||||
(False, True, "past_guard"), # --force-venv is the explicit escape hatch
|
||||
(True, True, "past_guard"),
|
||||
],
|
||||
)
|
||||
def test_venv_holder_guard_force_semantics(force, force_venv, expected, capsys):
|
||||
result = _run_update_until_guard(_update_args(force=force, force_venv=force_venv))
|
||||
assert result == expected, capsys.readouterr().out
|
||||
134
tests/hermes_cli/test_web_server_console_ws.py
Normal file
134
tests/hermes_cli/test_web_server_console_ws.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""Dashboard Hermes Console websocket tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from hermes_cli import web_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console_client(monkeypatch, _isolate_hermes_home):
|
||||
previous_auth_required = getattr(web_server.app.state, "auth_required", None)
|
||||
previous_bound_host = getattr(web_server.app.state, "bound_host", None)
|
||||
web_server.app.state.auth_required = False
|
||||
web_server.app.state.bound_host = None
|
||||
monkeypatch.setattr(web_server, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
close = getattr(client, "close", None)
|
||||
if close is not None:
|
||||
close()
|
||||
if previous_auth_required is None:
|
||||
if hasattr(web_server.app.state, "auth_required"):
|
||||
delattr(web_server.app.state, "auth_required")
|
||||
else:
|
||||
web_server.app.state.auth_required = previous_auth_required
|
||||
if previous_bound_host is None:
|
||||
if hasattr(web_server.app.state, "bound_host"):
|
||||
delattr(web_server.app.state, "bound_host")
|
||||
else:
|
||||
web_server.app.state.bound_host = previous_bound_host
|
||||
|
||||
|
||||
def _url(token: str | None = None, **params: str) -> str:
|
||||
query = {"token": web_server._SESSION_TOKEN, **params}
|
||||
if token is not None:
|
||||
query["token"] = token
|
||||
return f"/api/console?{urlencode(query)}"
|
||||
|
||||
|
||||
def _recv_until(conn, frame_type: str, *, status: str | None = None) -> dict:
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
frame = conn.receive_json()
|
||||
if frame.get("type") != frame_type:
|
||||
continue
|
||||
if status is not None and frame.get("status") != status:
|
||||
continue
|
||||
return frame
|
||||
raise AssertionError(f"Timed out waiting for {frame_type} frame")
|
||||
|
||||
|
||||
def test_console_ws_rejects_missing_or_bad_token(console_client):
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with console_client.websocket_connect("/api/console"):
|
||||
pass
|
||||
assert exc.value.code == 4401
|
||||
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with console_client.websocket_connect(_url(token="wrong")):
|
||||
pass
|
||||
assert exc.value.code == 4401
|
||||
|
||||
|
||||
def test_console_ws_runs_read_only_command(console_client):
|
||||
with console_client.websocket_connect(_url()) as conn:
|
||||
ready = conn.receive_json()
|
||||
assert ready["type"] == "ready"
|
||||
assert ready["context"] == "local"
|
||||
assert ready["prompt"] == "hermes> "
|
||||
|
||||
conn.send_json({"type": "input", "line": "help"})
|
||||
|
||||
output = _recv_until(conn, "output")
|
||||
assert "Hermes Console" in output["data"]
|
||||
complete = _recv_until(conn, "complete", status="ok")
|
||||
assert complete["prompt"] == "hermes> "
|
||||
|
||||
|
||||
def test_console_ws_confirmed_command_executes_after_confirmation(console_client):
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
with console_client.websocket_connect(_url()) as conn:
|
||||
assert conn.receive_json()["type"] == "ready"
|
||||
conn.send_json({"type": "input", "line": "config set display.interface cli"})
|
||||
|
||||
confirmation = _recv_until(conn, "confirm_required")
|
||||
assert confirmation["command"] == "config set display.interface cli"
|
||||
assert confirmation["message"]
|
||||
|
||||
conn.send_json({"type": "confirm", "command": confirmation["command"]})
|
||||
_recv_until(conn, "complete", status="ok")
|
||||
|
||||
assert load_config()["display"]["interface"] == "cli"
|
||||
|
||||
|
||||
def test_console_ws_uses_hosted_context_for_opt_data_policy(console_client, monkeypatch):
|
||||
monkeypatch.setattr(web_server, "_default_hermes_root_is_opt_data", lambda: True)
|
||||
|
||||
with console_client.websocket_connect(_url()) as conn:
|
||||
ready = conn.receive_json()
|
||||
assert ready["type"] == "ready"
|
||||
assert ready["context"] == "hosted"
|
||||
|
||||
conn.send_json({"type": "input", "line": "profile create nope"})
|
||||
|
||||
error = _recv_until(conn, "error")
|
||||
assert "hosted Hermes Console" in error["message"]
|
||||
|
||||
|
||||
def test_console_ws_cancel_returns_to_prompt(console_client, monkeypatch):
|
||||
from hermes_cli.console_engine import ConsoleResult, HermesConsoleEngine
|
||||
|
||||
def slow_execute(self, line: str, *, confirmed: bool = False):
|
||||
time.sleep(0.5)
|
||||
return ConsoleResult("ok", output="late", command=line)
|
||||
|
||||
monkeypatch.setattr(HermesConsoleEngine, "execute", slow_execute)
|
||||
|
||||
with console_client.websocket_connect(_url()) as conn:
|
||||
assert conn.receive_json()["type"] == "ready"
|
||||
conn.send_json({"type": "input", "line": "status"})
|
||||
conn.send_json({"type": "cancel"})
|
||||
|
||||
complete = _recv_until(conn, "complete", status="cancelled")
|
||||
assert complete["prompt"] == "hermes> "
|
||||
|
|
@ -488,3 +488,75 @@ def test_stream_upload_cleans_temp_on_cancellation(forced_files_client):
|
|||
# ... and no .upload temp file was left behind.
|
||||
leftovers = [p.name for p in target.parent.iterdir() if ".upload" in p.name]
|
||||
assert leftovers == [], f"temp upload files leaked on cancellation: {leftovers}"
|
||||
|
||||
|
||||
def test_sensitive_env_files_hidden_from_listing(forced_files_client):
|
||||
"""Regression test for #57505: .env files must not appear in directory listings."""
|
||||
client, root = forced_files_client
|
||||
|
||||
# Create a regular file and .env variants including shorthand suffixes.
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
regular = root / "config.txt"
|
||||
regular.write_text("safe content")
|
||||
env_file = root / ".env"
|
||||
env_file.write_text("SECRET_KEY=abc123")
|
||||
env_local = root / ".env.local"
|
||||
env_local.write_text("LOCAL_SECRET=def456")
|
||||
env_prod = root / ".env.prod"
|
||||
env_prod.write_text("PROD_SECRET=ghi789")
|
||||
|
||||
listing = client.get("/api/files", params={"path": str(root)})
|
||||
assert listing.status_code == 200
|
||||
names = [e["name"] for e in listing.json()["entries"]]
|
||||
assert "config.txt" in names
|
||||
assert ".env" not in names
|
||||
assert ".env.local" not in names
|
||||
assert ".env.prod" not in names
|
||||
|
||||
|
||||
def test_sensitive_env_files_blocked_read(forced_files_client):
|
||||
"""Regression test for #57505: .env files must not be readable."""
|
||||
client, root = forced_files_client
|
||||
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
env_file = root / ".env"
|
||||
env_file.write_text("SECRET_KEY=abc123")
|
||||
|
||||
resp = client.get("/api/files/read", params={"path": str(env_file)})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_sensitive_env_files_blocked_download(forced_files_client):
|
||||
"""Regression test for #57505: .env files must not be downloadable."""
|
||||
client, root = forced_files_client
|
||||
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
env_file = root / ".env"
|
||||
env_file.write_text("SECRET_KEY=abc123")
|
||||
|
||||
resp = client.get("/api/files/download", params={"path": str(env_file)})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_sensitive_env_suffix_variants_blocked(forced_files_client):
|
||||
"""Regression: .env.<suffix> shorthand variants (e.g. .env.prod) must also be blocked."""
|
||||
client, root = forced_files_client
|
||||
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
for suffix in ("prod", "dev", "staging.local", "ci"):
|
||||
p = root / f".env.{suffix}"
|
||||
p.write_text(f"SECRET_{suffix}=abc123")
|
||||
assert client.get("/api/files/read", params={"path": str(p)}).status_code == 403
|
||||
assert client.get("/api/files/download", params={"path": str(p)}).status_code == 403
|
||||
|
||||
|
||||
def test_sensitive_env_case_insensitive_blocked(forced_files_client):
|
||||
"""Regression: .ENV / .Env.local casings must be blocked too (case-insensitive FS mounts)."""
|
||||
client, root = forced_files_client
|
||||
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
for name in (".ENV", ".Env.local", ".eNv.PROD"):
|
||||
p = root / name
|
||||
p.write_text("SECRET=abc123")
|
||||
assert client.get("/api/files/read", params={"path": str(p)}).status_code == 403
|
||||
assert client.get("/api/files/download", params={"path": str(p)}).status_code == 403
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue