From 629536564ef7d43a9444f464ddd1aabe4750e8ee Mon Sep 17 00:00:00 2001 From: marcorusso97 Date: Wed, 3 Jun 2026 10:43:24 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20set=20api=20key=20in=20in?= =?UTF-8?q?itialization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/docs/getting-started/installation.mdx | 23 ++++++- hackagent/cli/commands/web.py | 69 +++++++++----------- hackagent/cli/main.py | 49 ++++++++++++-- tests/unit/cli/test_cli_main.py | 74 ++++++++++++++++++++++ tests/unit/cli/test_web_command.py | 73 +++++++-------------- 5 files changed, 193 insertions(+), 95 deletions(-) diff --git a/docs/docs/getting-started/installation.mdx b/docs/docs/getting-started/installation.mdx index 64f80883..9fc1cacd 100644 --- a/docs/docs/getting-started/installation.mdx +++ b/docs/docs/getting-started/installation.mdx @@ -42,8 +42,10 @@ hackagent init ``` This will guide you through: -1. **Set verbosity level** — Control logging detail (0=ERROR to 3=DEBUG) -2. **Save configuration** — Stored in `~/.config/hackagent/config.json` +1. **Choose mode** — Local (no API key) or Remote (cloud sync) +2. **Configure remote credentials (optional)** — API key when Remote mode is enabled +3. **Set verbosity level** — Control logging detail (0=ERROR to 3=DEBUG) +4. **Save configuration** — Stored in `~/.config/hackagent/config.json` :::info Local and Remote By default, HackAgent runs locally and stores results in SQLite (`~/.local/share/hackagent/hackagent.db`). @@ -56,7 +58,24 @@ export HACKAGENT_API_KEY="your_api_key" export HACKAGENT_BASE_URL="https://api.hackagent.dev" ``` +You can also configure these values interactively with: + +```bash +hackagent init +``` + +Or set them later without re-running the wizard: + +```bash +hackagent config set --api-key YOUR_KEY +hackagent config set --base-url https://api.hackagent.dev +``` + In remote mode, results are synchronized to the HackAgent platform while local mode remains available. + +`hackagent web` follows the selected mode: +- Local mode: launches the local dashboard server (`http://127.0.0.1:7860` by default) +- Remote mode: opens `https://app.hackagent.dev` ::: --- diff --git a/hackagent/cli/commands/web.py b/hackagent/cli/commands/web.py index 75202b2c..d27bdbc9 100644 --- a/hackagent/cli/commands/web.py +++ b/hackagent/cli/commands/web.py @@ -4,13 +4,14 @@ """ ``hackagent web`` — web dashboard command. -Starts a NiceGUI server that reads from the local SQLite backend -(or the remote backend when an API key is configured) and serves the +In local mode, starts a NiceGUI server backed by local SQLite and serves the dashboard at http://:/. + +In remote mode (API key configured), opens the cloud dashboard at +https://app.hackagent.dev. """ import click -import httpx from rich.console import Console console = Console() @@ -45,9 +46,8 @@ def web(ctx, host, port, db_path, no_browser): """🌐 Launch the web dashboard. - Starts a local web server that serves a full-featured security testing - dashboard. Works in both offline mode (SQLite) and online mode (remote - API with an API key). + Local mode: starts a local web server that serves the dashboard. + Remote mode: opens the HackAgent cloud dashboard. \b Examples: @@ -56,6 +56,27 @@ def web(ctx, host, port, db_path, no_browser): hackagent web --host 0.0.0.0 # expose on all interfaces hackagent web --no-browser # skip opening a browser tab """ + from hackagent.cli.config import CLIConfig + + cli_config: CLIConfig = ctx.obj["config"] + + # In remote mode, open the cloud dashboard directly instead of serving local UI. + if cli_config.api_key: + cloud_url = "https://app.hackagent.dev" + console.print( + "[dim]Remote mode detected: using HackAgent cloud dashboard.[/dim]" + ) + console.print(f"[cyan]{cloud_url}[/cyan]") + if not no_browser: + import webbrowser + + opened = webbrowser.open(cloud_url) + if not opened: + console.print( + "[yellow]⚠️ Could not auto-open browser. Open the URL above manually.[/yellow]" + ) + return + try: from flask import Flask # noqa: F401 except ImportError: @@ -69,40 +90,12 @@ def web(ctx, host, port, db_path, no_browser): ctx.exit(1) return - from hackagent.cli.config import CLIConfig from hackagent.server.dashboard import create_app - cli_config: CLIConfig = ctx.obj["config"] - # ── Select backend ──────────────────────────────────────────────────────── - backend = None - if cli_config.api_key: - try: - from hackagent.server.client import AuthenticatedClient - from hackagent.server.storage.remote import RemoteBackend - - client = AuthenticatedClient( - base_url=cli_config.base_url, - token=cli_config.api_key, - # Never disable HTTP timeouts in web mode: a stuck remote call - # would otherwise keep the dashboard loading forever. - timeout=httpx.Timeout(15.0, connect=5.0, read=15.0, write=15.0), - ) - candidate_backend = RemoteBackend(client=client) - # Preflight check to fail fast on invalid/unreachable remote config. - candidate_backend.get_context() - backend = candidate_backend - console.print("[dim]Using remote backend.[/dim]") - except Exception as exc: - console.print( - f"[yellow]⚠️ Could not connect to remote backend ({exc}). " - "Falling back to local SQLite.[/yellow]" - ) - - if backend is None: - from hackagent.server.storage.local import LocalBackend - - backend = LocalBackend(db_path=db_path) + from hackagent.server.storage.local import LocalBackend + + backend = LocalBackend(db_path=db_path) # ── Create app ──────────────────────────────────────────────────────────── app = create_app(backend=backend) @@ -112,7 +105,7 @@ def web(ctx, host, port, db_path, no_browser): console.print() console.print("[bold]🌐 HackAgent Dashboard[/bold]") console.print(f" [cyan]→ {url}[/cyan]") - mode_label = "remote" if backend.__class__.__name__ == "RemoteBackend" else "local" + mode_label = "local" console.print(f" Mode : [cyan]{mode_label}[/cyan]") if mode_label == "local": resolved_db = db_path or "~/.local/share/hackagent/hackagent.db" diff --git a/hackagent/cli/main.py b/hackagent/cli/main.py index 5df205cd..0f17cdf3 100644 --- a/hackagent/cli/main.py +++ b/hackagent/cli/main.py @@ -268,6 +268,41 @@ def init(ctx): # Reload config from file to get the latest saved values cli_config._load_default_config() + # Mode and API key setup + console.print("\n[cyan]☁️ Mode Configuration[/cyan]") + console.print("[green]Local mode (default):[/green] no API key required") + console.print( + "[green]Remote mode:[/green] requires HackAgent API key for cloud sync" + ) + + use_remote = click.confirm( + "Enable remote mode (cloud sync)?", + default=False, + ) + + if use_remote: + if cli_config.api_key: + console.print( + "[dim]Press Enter to keep your current API key from config/environment.[/dim]" + ) + + api_key_input = click.prompt( + "HackAgent API key", + default=cli_config.api_key or "", + hide_input=True, + show_default=False, + ).strip() + + if api_key_input: + cli_config.api_key = api_key_input + else: + console.print( + "[yellow]⚠️ No API key provided. Falling back to local mode.[/yellow]" + ) + cli_config.api_key = None + else: + cli_config.api_key = None + # Verbosity level setup console.print("\n[cyan]🔊 Verbosity Level Configuration[/cyan]") console.print("0 = ERROR (only errors)") @@ -290,10 +325,16 @@ def init(ctx): cli_config.save() console.print("\n[bold green]✅ Configuration saved[/bold green]") - console.print( - "[bold green]✅ Setup complete![/bold green] " - "[dim](Local mode: results stored in ~/.local/share/hackagent/hackagent.db)[/dim]" - ) + if cli_config.api_key: + console.print( + "[bold green]✅ Setup complete![/bold green] " + "[dim](Remote mode enabled: runs can sync to the HackAgent platform)[/dim]" + ) + else: + console.print( + "[bold green]✅ Setup complete![/bold green] " + "[dim](Local mode: results stored in ~/.local/share/hackagent/hackagent.db)[/dim]" + ) if cli_config.should_show_info(): console.print("\n[bold cyan]💡 Next steps:[/bold cyan]") console.print(" [green]hackagent eval advprefix --help[/green]") diff --git a/tests/unit/cli/test_cli_main.py b/tests/unit/cli/test_cli_main.py index 267977c4..d19972f3 100644 --- a/tests/unit/cli/test_cli_main.py +++ b/tests/unit/cli/test_cli_main.py @@ -182,5 +182,79 @@ def test_no_command_launches_tui(self, mock_tui, mock_config_class): mock_tui.assert_called_once() +class TestCLIInit(unittest.TestCase): + """Test the init setup wizard.""" + + @patch("hackagent.utils.display_hackagent_splash") + @patch("hackagent.cli.main.click.prompt") + @patch("hackagent.cli.main.click.confirm") + @patch("hackagent.cli.main.CLIConfig") + def test_init_configures_remote_mode( + self, + mock_config_class, + mock_confirm, + mock_prompt, + mock_splash, + ): + """Test init captures API key when remote mode is enabled.""" + mock_config = MagicMock() + mock_config.api_key = None + mock_config.base_url = "https://api.hackagent.dev" + mock_config.verbose = 1 + mock_config.should_show_info.return_value = False + mock_config.default_config_path = MagicMock() + mock_config.default_config_path.exists.return_value = False + mock_config_class.return_value = mock_config + + # Prompt order in init: + # 1) API key, 2) verbosity level + mock_confirm.return_value = True + mock_prompt.side_effect = ["test-api-key", 2] + + runner = CliRunner() + result = runner.invoke(cli, ["init"]) + + self.assertEqual(result.exit_code, 0) + self.assertEqual(mock_config.api_key, "test-api-key") + self.assertEqual(mock_config.base_url, "https://api.hackagent.dev") + self.assertEqual(mock_config.verbose, 2) + mock_config.save.assert_called_once() + mock_splash.assert_called_once() + + @patch("hackagent.utils.display_hackagent_splash") + @patch("hackagent.cli.main.click.prompt") + @patch("hackagent.cli.main.click.confirm") + @patch("hackagent.cli.main.CLIConfig") + def test_init_local_mode_clears_api_key( + self, + mock_config_class, + mock_confirm, + mock_prompt, + mock_splash, + ): + """Test init local mode clears any existing API key from config.""" + mock_config = MagicMock() + mock_config.api_key = "existing-key" + mock_config.base_url = "https://api.hackagent.dev" + mock_config.verbose = 1 + mock_config.should_show_info.return_value = False + mock_config.default_config_path = MagicMock() + mock_config.default_config_path.exists.return_value = False + mock_config_class.return_value = mock_config + + # Local mode confirmation + verbosity prompt. + mock_confirm.return_value = False + mock_prompt.return_value = 1 + + runner = CliRunner() + result = runner.invoke(cli, ["init"]) + + self.assertEqual(result.exit_code, 0) + self.assertIsNone(mock_config.api_key) + self.assertEqual(mock_config.verbose, 1) + mock_config.save.assert_called_once() + mock_splash.assert_called_once() + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/cli/test_web_command.py b/tests/unit/cli/test_web_command.py index 84876294..cef046f9 100644 --- a/tests/unit/cli/test_web_command.py +++ b/tests/unit/cli/test_web_command.py @@ -6,21 +6,11 @@ import unittest from unittest.mock import MagicMock, patch -import httpx from click.testing import CliRunner from hackagent.cli.commands.web import web -class _DummyRemoteBackend: - def __init__(self): - self.get_context_calls = 0 - - def get_context(self): - self.get_context_calls += 1 - return {"ok": True} - - class _DummyLocalBackend: pass @@ -33,66 +23,47 @@ def _free_port_socket(self): mock_socket.__enter__.return_value.connect_ex.return_value = 1 return mock_socket - def test_web_uses_remote_backend_when_preflight_succeeds(self): + def test_web_remote_mode_opens_cloud_dashboard(self): runner = CliRunner() config = MagicMock() config.api_key = "test-key" config.base_url = "https://api.hackagent.dev" - - remote_backend = _DummyRemoteBackend() - app = MagicMock() - with ( - patch("hackagent.server.client.AuthenticatedClient") as mock_auth_client, - patch( - "hackagent.server.storage.remote.RemoteBackend", - return_value=remote_backend, - ) as mock_remote_cls, - patch("hackagent.server.storage.local.LocalBackend") as mock_local_cls, - patch( - "hackagent.server.dashboard.create_app", return_value=app - ) as mock_create_app, - patch("socket.socket", return_value=self._free_port_socket()), + patch("webbrowser.open", return_value=True) as mock_open, + patch("hackagent.server.dashboard.create_app") as mock_create_app, ): - result = runner.invoke( - web, - ["--host", "127.0.0.1", "--port", "7878"], - obj={"config": config}, - ) + result = runner.invoke(web, [], obj={"config": config}) self.assertEqual(result.exit_code, 0) - self.assertEqual(remote_backend.get_context_calls, 1) - mock_remote_cls.assert_called_once() - mock_local_cls.assert_not_called() - mock_create_app.assert_called_once_with(backend=remote_backend) - app.run.assert_called_once_with(host="127.0.0.1", port=7878, show=True) - - _, auth_kwargs = mock_auth_client.call_args - self.assertEqual(auth_kwargs["base_url"], "https://api.hackagent.dev") - self.assertEqual(auth_kwargs["token"], "test-key") - self.assertIsInstance(auth_kwargs["timeout"], httpx.Timeout) - - def test_web_falls_back_to_local_backend_when_remote_preflight_fails(self): + mock_open.assert_called_once_with("https://app.hackagent.dev") + mock_create_app.assert_not_called() + + def test_web_remote_mode_no_browser_does_not_open_browser(self): runner = CliRunner() config = MagicMock() config.api_key = "test-key" config.base_url = "https://api.hackagent.dev" - remote_backend = _DummyRemoteBackend() + with ( + patch("webbrowser.open") as mock_open, + patch("hackagent.server.dashboard.create_app") as mock_create_app, + ): + result = runner.invoke(web, ["--no-browser"], obj={"config": config}) + + self.assertEqual(result.exit_code, 0) + mock_open.assert_not_called() + mock_create_app.assert_not_called() - def _raise_preflight_error(): - raise RuntimeError("remote unavailable") + def test_web_local_mode_uses_local_dashboard(self): + runner = CliRunner() + config = MagicMock() + config.api_key = None + config.base_url = "https://api.hackagent.dev" - remote_backend.get_context = _raise_preflight_error local_backend = _DummyLocalBackend() app = MagicMock() with ( - patch("hackagent.server.client.AuthenticatedClient"), - patch( - "hackagent.server.storage.remote.RemoteBackend", - return_value=remote_backend, - ), patch( "hackagent.server.storage.local.LocalBackend", return_value=local_backend,