{"repo":"NousResearch/hermes-agent","public_id":"905","display_label":"Setup wizard hangs in non-interactive/headless environments","summary":"These issues and fixes address the Hermes setup wizard hanging when run in non-interactive SSH or headless environments. The work involves detecting non-TTY conditions and handling headless setup flows to prevent the hang.","subsystem":"hermes_cli","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":1274,"merged_at":"2026-03-14T09:45:19+00:00"},"resolution":{"state":"resolved","by":"pr-1274"},"freshness":{"state":"stale","last_verified_at":"2026-03-14T09:45:19+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":905,"pr_number":1274,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-14T09:45:19+00:00"}],"members":[{"kind":"issue","number":905,"title":"hermes setup wizard hangs on non-interactive SSH / headless environments","url":null,"state":"closed"},{"kind":"pr","number":920,"title":"fix: detect non-interactive TTY in setup wizard to prevent hang","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1274","number":1274},{"kind":"pr","value":"#905","number":905}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":1274,"title":"fix: handle headless setup flows end-to-end","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","hermes_cli/setup.py","tests/hermes_cli/test_setup_noninteractive.py","tests/hermes_cli/test_setup_openclaw_migration.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -478,6 +478,15 @@ def cmd_chat(args):\n         print()\n         print(\"  Run:  hermes setup\")\n         print()\n+\n+        from hermes_cli.setup import is_interactive_stdin, print_noninteractive_setup_guidance\n+\n+        if not is_interactive_stdin():\n+            print_noninteractive_setup_guidance(\n+                \"No interactive TTY detected for the first-run setup prompt.\"\n+            )\n+            sys.exit(1)\n+\n         try:\n             reply = input(\"Run setup now? [Y/n] \").strip().lower()\n         except (EOFError, KeyboardInterrupt):"]},{"file":"hermes_cli/setup.py","hunks":["@@ -176,6 +176,36 @@ def print_error(text: str):\n     print(color(f\"✗ {text}\", Colors.RED))\n \n \n+def is_interactive_stdin() -> bool:\n+    \"\"\"Return True when stdin looks like a usable interactive TTY.\"\"\"\n+    stdin = getattr(sys, \"stdin\", None)\n+    if stdin is None:\n+        return False\n+    try:\n+        return bool(stdin.isatty())\n+    except Exception:\n+        return False\n+\n+\n+def print_noninteractive_setup_guidance(reason: str | None = None) -> None:\n+    \"\"\"Print guidance for headless/non-interactive setup flows.\"\"\"\n+    print()\n+    print(color(\"⚕ Hermes Setup — Non-interactive mode\", Colors.CYAN, Colors.BOLD))\n+    print()\n+    if reason:\n+        print_info(reason)\n+    print_info(\"The interactive wizard cannot be used here.\")\n+    print()\n+    print_info(\"Configure Hermes using environment variables or config commands:\")\n+    print_info(\"  hermes config set model.provider custom\")\n+    print_info(\"  hermes config set model.base_url http://localhost:8080/v1\")\n+    print_info(\"  hermes config set model.default your-model-name\")\n+    print()\n+    print_info(\"Or set OPENROUTER_API_KEY / OPENAI_API_KEY in your environment.\")\n+    print_info(\"Run 'hermes setup' in an interactive terminal to use the full wizard.\")\n+    print()\n+\n+\n def prompt(question: str, default: str = None, password: bool = False) -> str:\n     \"\"\"Prompt for input with optional default.\"\"\"\n     if default:","@@ -2338,6 +2368,17 @@ def run_setup_wizard(args):\n     config = load_config()\n     hermes_home = get_hermes_home()\n \n+    # Detect non-interactive environments (headless SSH, Docker, CI/CD)\n+    non_interactive = getattr(args, 'non_interactive', False)\n+    if not non_interactive and not is_interactive_stdin():\n+        non_interactive = True\n+\n+    if non_interactive:\n+        print_noninteractive_setup_guidance(\n+            \"Running in a non-interactive environment (no TTY detected).\"\n+        )\n+        return\n+\n     # Check if a specific section was requested\n     section = getattr(args, \"section\", None)\n     if section:"]},{"file":"tests/hermes_cli/test_setup_noninteractive.py","hunks":["@@ -0,0 +1,94 @@\n+\"\"\"Tests for non-interactive setup and first-run headless behavior.\"\"\"\n+\n+from argparse import Namespace\n+from unittest.mock import patch\n+\n+import pytest\n+\n+\n+def _make_setup_args(**overrides):\n+    return Namespace(\n+        non_interactive=overrides.get(\"non_interactive\", False),\n+        section=overrides.get(\"section\", None),\n+        reset=overrides.get(\"reset\", False),\n+    )\n+\n+\n+def _make_chat_args(**overrides):\n+    return Namespace(\n+        continue_last=overrides.get(\"continue_last\", None),\n+        resume=overrides.get(\"resume\", None),\n+        model=overrides.get(\"model\", None),\n+        provider=overrides.get(\"provider\", None),\n+        toolsets=overrides.get(\"toolsets\", None),\n+        verbose=overrides.get(\"verbose\", False),\n+        query=overrides.get(\"query\", None),\n+        worktree=overrides.get(\"worktree\", False),\n+        yolo=overrides.get(\"yolo\", False),\n+        pass_session_id=overrides.get(\"pass_session_id\", False),\n+        quiet=overrides.get(\"quiet\", False),\n+        checkpoints=overrides.get(\"checkpoints\", False),\n+    )\n+\n+\n+class TestNonInteractiveSetup:\n+    \"\"\"Verify setup paths exit cleanly in headless/non-interactive environments.\"\"\"\n+\n+    def test_non_interactive_flag_skips_wizard(self, capsys):\n+        \"\"\"--non-interactive should print guidance and not enter the wizard.\"\"\"\n+        from hermes_cli.setup import run_setup_wizard\n+\n+        args = _make_setup_args(non_interactive=True)\n+\n+        with (\n+            patch(\"hermes_cli.setup.ensure_hermes_home\"),\n+            patch(\"hermes_cli.setup.load_config\", return_value={}),\n+            patch(\"hermes_cli.setup.get_hermes_home\", return_value=\"/tmp/.hermes\"),\n+            patch(\"hermes_cli.auth.get_active_provider\", side_effect=AssertionError(\"wizard continued\")),\n+            patch(\"builtins.input\", side_effect=AssertionError(\"input should not be called\")),\n+        ):\n+            run_setup_wizard(args)\n+\n+        out = capsys.readouterr().out\n+        assert \"hermes config set model.provider custom\" in out\n+\n+    def test_no_tty_skips_wizard(self, capsys):\n+        \"\"\"When stdin has no TTY, the setup wizard should print guidance and return.\"\"\"\n+        from hermes_cli.setup import run_setup_wizard\n+\n+        args = _make_setup_args(non_interactive=False)\n+\n+        with (\n+            patch(\"hermes_cli.setup.ensure_hermes_home\"),\n+            patch(\"hermes_cli.setup.load_config\", return_value={}),\n+            patch(\"hermes_cli.setup.get_hermes_home\", return_value=\"/tmp/.hermes\"),\n+            patch(\"hermes_cli.auth.get_active_provider\", side_effect=AssertionError(\"wizard continued\")),\n+            patch(\"sys.stdin\") as mock_stdin,\n+            patch(\"builtins.input\", side_effect=AssertionError(\"input should not be called\")),\n+        ):\n+            mock_stdin.isatty.return_value = False\n+            run_setup_wizard(args)\n+\n+        out = capsys.readouterr().out\n+        assert \"hermes config set model.provider custom\" in out\n+\n+    def test_chat_first_run_headless_skips_setup_prompt(self, capsys):\n+        \"\"\"Bare `hermes` should not prompt for input when no provider exists and stdin is headless.\"\"\"\n+        from hermes_cli.main import cmd_chat\n+\n+        args = _make_chat_args()\n+\n+        with (\n+            patch(\"hermes_cli.main._has_any_provider_configured\", return_value=False),\n+            patch(\"hermes_cli.main.cmd_setup\") as mock_setup,\n+            patch(\"sys.stdin\") as mock_stdin,\n+            patch(\"builtins.input\", side_effect=AssertionError(\"input should not be called\")),\n+        ):\n+            mock_stdin.isatty.return_value = False\n+            with pytest.raises(SystemExit) as exc:\n+                cmd_chat(args)\n+\n+        assert exc.value.code == 1\n+        mock_setup.assert_not_called()\n+        out = capsys.readouterr().out\n+        assert \"hermes config set model.provider custom\" in out"]},{"file":"tests/hermes_cli/test_setup_openclaw_migration.py","hunks":["@@ -180,6 +180,7 @@ def test_migration_offered_during_first_time_setup(self, tmp_path):\n             patch.object(setup_mod, \"load_config\", return_value={}),\n             patch.object(setup_mod, \"get_hermes_home\", return_value=tmp_path),\n             patch.object(setup_mod, \"get_env_value\", return_value=\"\"),\n+            patch.object(setup_mod, \"is_interactive_stdin\", return_value=True),\n             patch(\"hermes_cli.auth.get_active_provider\", return_value=None),\n             # User presses Enter to start\n             patch(\"builtins.input\", return_value=\"\"),","@@ -214,6 +215,7 @@ def tracking_load_config():\n             patch.object(setup_mod, \"load_config\", side_effect=tracking_load_config),\n             patch.object(setup_mod, \"get_hermes_home\", return_value=tmp_path),\n             patch.object(setup_mod, \"get_env_value\", return_value=\"\"),\n+            patch.object(setup_mod, \"is_interactive_stdin\", return_value=True),\n             patch(\"hermes_cli.auth.get_active_provider\", return_value=None),\n             patch(\"builtins.input\", return_value=\"\"),\n             patch.object(setup_mod, \"_offer_openclaw_migration\", return_value=True),","@@ -244,6 +246,7 @@ def test_reloaded_config_flows_into_remaining_setup_sections(self, tmp_path):\n             ),\n             patch.object(setup_mod, \"get_hermes_home\", return_value=tmp_path),\n             patch.object(setup_mod, \"get_env_value\", return_value=\"\"),\n+            patch.object(setup_mod, \"is_interactive_stdin\", return_value=True),\n             patch(\"hermes_cli.auth.get_active_provider\", return_value=None),\n             patch(\"builtins.input\", return_value=\"\"),\n             patch.object(setup_mod, \"_offer_openclaw_migration\", return_value=True),\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}