diff --git a/openhack/__main__.py b/openhack/__main__.py index 9c853d9..84e54e5 100644 --- a/openhack/__main__.py +++ b/openhack/__main__.py @@ -19,6 +19,34 @@ import sys +def _require_auth() -> bool: + """Validate that the active LLM provider has credentials configured.""" + import os + from openhack import providers + from openhack.config import settings + + active_provider = settings.llm_provider + if active_provider != "openhack": + if providers.is_connected(active_provider): + return True + spec = providers.get_spec(active_provider) + if spec and spec.required_env: + missing = [e for e in spec.required_env if not os.environ.get(e)] + if missing: + print(f"Error: provider '{active_provider}' is missing required environment: {', '.join(missing)}.") + return False + key_env = spec.api_key_env if spec else f"{active_provider.upper()}_API_KEY" + print(f"Error: provider '{active_provider}' is not configured.") + print(f"Set {key_env} or run 'openhack' to configure credentials.") + return False + + if not settings.openhack_api_key and not providers.is_connected("openhack"): + print("Error: not logged in.") + print("Run 'openhack --login' to set up your account, or set OPENHACK_API_KEY.") + return False + return True + + def _cmd_scan(): """Run a headless scan on a directory.""" from pathlib import Path @@ -31,10 +59,7 @@ def _cmd_scan(): print("Usage: openhack --scan [path]") return - from openhack.config import settings - if not settings.openhack_api_key: - print("Error: not logged in.") - print("Run 'openhack --login' to set up your account, or set OPENHACK_API_KEY.") + if not _require_auth(): return import asyncio @@ -62,10 +87,7 @@ def _cmd_hack(): if not target.is_dir(): target = Path.cwd() - from openhack.config import settings - if not settings.openhack_api_key: - print("Error: not logged in.") - print("Run 'openhack --login' to set up your account, or set OPENHACK_API_KEY.") + if not _require_auth(): return from openhack.interactive_runner import run_task @@ -90,10 +112,7 @@ def _cmd_plan(): if not target.is_dir(): target = Path.cwd() - from openhack.config import settings - if not settings.openhack_api_key: - print("Error: not logged in.") - print("Run 'openhack --login' to set up your account, or set OPENHACK_API_KEY.") + if not _require_auth(): return from openhack.interactive_runner import run_plan @@ -112,10 +131,7 @@ def _cmd_agent(): if not target.is_dir(): target = Path.cwd() - from openhack.config import settings - if not settings.openhack_api_key: - print("Error: not logged in.") - print("Run 'openhack --login' to set up your account, or set OPENHACK_API_KEY.") + if not _require_auth(): return from openhack.interactive_runner import run_repl diff --git a/openhack/config.py b/openhack/config.py index e936492..d334d92 100644 --- a/openhack/config.py +++ b/openhack/config.py @@ -227,6 +227,8 @@ def _build_settings() -> Settings: for key, val in user_cfg.items(): if val is not None and val != "": env_overrides[key.upper()] = str(val) + if key == "provider": + env_overrides["LLM_PROVIDER"] = str(resolve_provider(str(val))) dotenv_keys = _dotenv_nonempty_keys(Path(".env")) old_env = {} diff --git a/openhack/providers.py b/openhack/providers.py index 4bfe5e6..083e91b 100644 --- a/openhack/providers.py +++ b/openhack/providers.py @@ -293,7 +293,10 @@ def is_connected(name: str) -> bool: and (credential.get("access") or credential.get("refresh")) ): return True - return bool(spec.keyless_default and config.get("provider") == name) + return bool( + spec.keyless_default + and (config.get("provider") == name or os.environ.get("LLM_PROVIDER") == name) + ) def connected_provider_ids( @@ -327,7 +330,10 @@ def connected_provider_ids( os.environ.get(spec.api_key_env) or has_api_key or has_oauth - or (spec.keyless_default and config.get("provider") == spec.name) + or ( + spec.keyless_default + and (config.get("provider") == spec.name or os.environ.get("LLM_PROVIDER") == spec.name) + ) ): connected.add(spec.name) return connected diff --git a/tests/test_cli_dispatch.py b/tests/test_cli_dispatch.py index 7babbfe..36935b4 100644 --- a/tests/test_cli_dispatch.py +++ b/tests/test_cli_dispatch.py @@ -101,3 +101,79 @@ def test_launch_tui_never_runs_first_time_setup(monkeypatch): m._launch_tui() assert calls == [{"resume_session_id": None}] + + +def test_require_auth_passes_for_connected_byok_provider(monkeypatch): + from openhack.config import settings + monkeypatch.setattr(settings, "llm_provider", "openai") + monkeypatch.setattr(settings, "openhack_api_key", None) + monkeypatch.setenv("OPENAI_API_KEY", "sk-test-openai") + + out = io.StringIO() + with contextlib.redirect_stdout(out): + assert m._require_auth() is True + assert out.getvalue() == "" + + +def test_require_auth_fails_for_disconnected_byok_provider(monkeypatch): + from openhack.config import settings + monkeypatch.setattr(settings, "llm_provider", "openai") + monkeypatch.setattr(settings, "openhack_api_key", None) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setattr("openhack.provider_auth.all_credentials", lambda: {}) + + out = io.StringIO() + with contextlib.redirect_stdout(out): + assert m._require_auth() is False + assert "Error: provider 'openai' is not configured." in out.getvalue() + assert "OPENAI_API_KEY" in out.getvalue() + + +def test_require_auth_fails_for_openhack_when_logged_out(monkeypatch): + from openhack.config import settings + monkeypatch.setattr(settings, "llm_provider", "openhack") + monkeypatch.setattr(settings, "openhack_api_key", None) + monkeypatch.delenv("OPENHACK_API_KEY", raising=False) + monkeypatch.setattr("openhack.config.load_user_config", lambda: {}) + + out = io.StringIO() + with contextlib.redirect_stdout(out): + assert m._require_auth() is False + assert "Error: not logged in." in out.getvalue() + + +def test_require_auth_passes_for_openhack_when_logged_in(monkeypatch): + from openhack.config import settings + monkeypatch.setattr(settings, "llm_provider", "openhack") + monkeypatch.setattr(settings, "openhack_api_key", "sk-openhack-test") + + out = io.StringIO() + with contextlib.redirect_stdout(out): + assert m._require_auth() is True + assert out.getvalue() == "" + + +def test_cmd_scan_runs_with_byok_provider_without_openhack_key(monkeypatch, tmp_path): + from openhack.config import settings + monkeypatch.setattr(settings, "llm_provider", "openai") + monkeypatch.setattr(settings, "openhack_api_key", None) + monkeypatch.setenv("OPENAI_API_KEY", "sk-test-openai") + + target_dir = tmp_path / "sample-project" + target_dir.mkdir() + monkeypatch.setattr("sys.argv", ["openhack", "--scan", str(target_dir)]) + + scan_invoked = [] + + async def _fake_run(target): + scan_invoked.append(target) + + monkeypatch.setattr("openhack.headless_scan.run_headless_scan", _fake_run) + + out = io.StringIO() + with contextlib.redirect_stdout(out): + m._cmd_scan() + + assert scan_invoked == [str(target_dir)] + assert "Error: not logged in" not in out.getvalue() +