From aea20971df074f6a74a8a395702a9e9546c2c25d Mon Sep 17 00:00:00 2001 From: Ricky Chen <124005584+HackedRico@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:38:51 -0400 Subject: [PATCH] fix(config): resolve the Caldera REST connection from core config The plugin hardcoded ADMIN123 as its API key fallback and localhost as its host. Caldera hashes api_key_red and api_key_blue at boot and authenticates by verifying the submitted plaintext against the stored hash, so ADMIN123 only works on a --insecure boot, and only because the stored hash happens to be a hash of it. Every server whose conf/local.yml was generated gets a random key instead, and the resulting 401 was close to undiagnosable: the tool wrapped it in a successful result and core_health_check reported "Caldera API is UP!" because the error object it type-checked was a dict. Build the URL from the host and port caldera binds to, mapping a wildcard bind to loopback since 0.0.0.0 is not a routable destination and bracketing IPv6 literals. Check the API key by asking caldera's own hasher the same question its auth service asks, in-process and without a request, and report a rejected key at boot and on the splash page instead of failing silently later. The check is diagnostic only. The key is returned either way, and an explicitly configured one is never swapped for a value the operator did not choose. It is also skipped when CALDERA_URL points elsewhere, since a remote server's keys are not ours to verify. Both env vars keep top precedence for deployments where MCP cannot reach the address caldera binds to. get_env now forwards the resolved values rather than re-reading the environment, so subprocesses receive a normalised URL; previously a CALDERA_URL without the /api/v2/ suffix reached the child raw, and the cti_pipeline server hit the login page and got a JSONDecodeError on a 200 HTML body. Drop the two Caldera entries from .env.example. CALDERA_URL there pinned port 8888 and outranked the port caldera actually listens on, so copying the file broke any deployment that had moved off the default port. --- .env.example | 10 +- PLUGIN_MCP.md | 25 +++- README.md | 3 + app/config.py | 119 ++++++++++++++---- app/mcp_gui.py | 2 + app/mcp_server.py | 8 +- app/workflows/author.py | 7 +- app/workflows/plan_execute.py | 7 +- conf/default.yml | 7 +- hook.py | 26 ++++ templates/mcp.html | 13 +- tests/test_config.py | 226 ++++++++++++++++++++++++++++++++++ 12 files changed, 406 insertions(+), 47 deletions(-) diff --git a/.env.example b/.env.example index c0cfbdd..5867b59 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,9 @@ MCP_LLM_API_BASE= # every request supplies its own via the UI's Global Model Configuration. MCP_LLM_API_KEY= -# Caldera REST API -# Defaults work for local development. Override for non-local deployments. -CALDERA_URL=http://localhost:8888 -CORE_CALDERA_API_KEY=ADMIN123 +# Caldera REST API. Both auto-resolve from caldera's main config; leave unset +# unless MCP cannot reach the address caldera binds to (CALDERA_URL), or +# caldera rejects its stock key (CORE_CALDERA_API_KEY: use the API_TOKEN +# logged once when conf/local.yml was generated). +# CALDERA_URL= +# CORE_CALDERA_API_KEY= diff --git a/PLUGIN_MCP.md b/PLUGIN_MCP.md index e352af6..88a0a87 100644 --- a/PLUGIN_MCP.md +++ b/PLUGIN_MCP.md @@ -116,8 +116,8 @@ Forwarded by the parent (see | Env var | Meaning | |---|---| -| `CALDERA_URL` | Base URL of the Caldera REST API (`http://localhost:8888/api/v2/` for local dev) | -| `CORE_CALDERA_API_KEY` | Admin key for Caldera REST (`ADMIN123` default) | +| `CALDERA_URL` | Base URL of the Caldera REST API, always suffixed `/api/v2/`. Resolved from the host and port Caldera binds to | +| `CORE_CALDERA_API_KEY` | Key for Caldera REST, resolved to whichever value Caldera's own `api_key_red` / `api_key_blue` accepts | | `DSPY_MODEL`, `DSPY_API_KEY`, `DSPY_API_BASE`, `DSPY_TEMPERATURE`, `DSPY_MAX_TOKENS` | LLM credentials, only if your tools call back into DSPy (e.g. for command synthesis) | | `PYTHONPATH` | Includes the venv's `site-packages` | @@ -125,6 +125,12 @@ Forwarded by the parent (see loads `.env` once at plugin enable; the subprocess inherits the environment. Re-loading masks parent overrides. +Both Caldera variables are resolved by the parent from Caldera's own +config, so read them and do not substitute defaults of your own. In +particular, never fall back to `ADMIN123`: Caldera hashes its API keys at +startup, and any server whose `conf/local.yml` was generated has a key +that literal will never match. + If your server needs DSPy itself (rare — only when your tools generate content with the LLM), import the lazy bootstrap from the MCP plugin's namespace: @@ -145,9 +151,12 @@ import requests class CalderaClient: def __init__(self): - self.url = os.environ.get("CALDERA_URL", "http://localhost:8888/api/v2/") + # Both are resolved by the parent from Caldera's own config. A + # hardcoded default here would silently 401 on any server whose + # conf/local.yml was generated, so fail loudly instead. + self.url = os.environ["CALDERA_URL"] self.headers = { - "KEY": os.environ.get("CORE_CALDERA_API_KEY", "ADMIN123"), + "KEY": os.environ["CORE_CALDERA_API_KEY"], "Content-Type": "application/json", } def get(self, endpoint): @@ -533,9 +542,13 @@ SERVER = Path(__file__).resolve().parents[1] / "mcp_server.py" @pytest.mark.asyncio async def test_my_server_spawns_and_lists_tools(): + # Resolve the same way the parent does rather than hardcoding, so this + # test spawns against whatever Caldera the checkout is configured for. + from plugins.mcp.app.config import caldera_connection + caldera = caldera_connection() env = os.environ.copy() - env.setdefault("CALDERA_URL", "http://localhost:8888/api/v2/") - env.setdefault("CORE_CALDERA_API_KEY", "ADMIN123") + env.setdefault("CALDERA_URL", caldera["url"]) + env.setdefault("CORE_CALDERA_API_KEY", caldera["api_key"]) params = StdioServerParameters(command=VENV_PY, args=[str(SERVER)], env=env) async with stdio_client(params) as (r, w): async with ClientSession(r, w) as session: diff --git a/README.md b/README.md index e770e6e..dd72888 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,8 @@ cp plugins/mcp/.env.example plugins/mcp/.env Both may instead be supplied per-session through the UI's Global Model Configuration. Avoid committing secrets. +Nothing needs setting for the Caldera connection. The plugin builds the REST URL from the host and port CALDERA binds to, and picks the API key CALDERA's own `api_key_red` or `api_key_blue` accepts. Set `CALDERA_URL` only when MCP runs somewhere that cannot reach the address CALDERA binds to, such as a separate container or host; running behind the `ssl` plugin is not such a case, since haproxy forwards to the same local address. Set `CORE_CALDERA_API_KEY` on any server whose `conf/local.yml` was generated. A rejected key is reported on the plugin's splash page and in the CALDERA log at startup. + ## Quick Start 1. Start CALDERA from the repository root: @@ -309,6 +311,7 @@ Use `--build` after changing Vue, CSS, or other Magma-loaded UI assets. - **MCP page does not appear**: Confirm `mcp` is listed in `conf/local.yml` and restart CALDERA. - **UI changes are missing**: Restart with `--build` so Magma rebuilds plugin UI bundles. - **Model calls fail**: Check the endpoint profile, API base, API key, and model name. The API base often needs a `/v1` suffix for OpenAI-compatible servers. +- **Caldera tools return 401 or the splash reports a rejected key**: CALDERA hashes its API keys at startup, so a generated `conf/local.yml` has a key the plugin cannot guess. Set `CORE_CALDERA_API_KEY` in `plugins/mcp/.env` to the `API_TOKEN` printed once in the CALDERA log when that file was created. - **CTI/RAG does not run**: Select at least one generated STIX bundle in Plan and Execute. CTI selection automatically enables RAG for that workflow. - **Deploy spec has review gaps**: The CTI did not provide enough grounded evidence. Add richer CTI, select more STIX bundles, or review the gaps before deployment. diff --git a/app/config.py b/app/config.py index f202c79..91cf544 100644 --- a/app/config.py +++ b/app/config.py @@ -29,6 +29,9 @@ so a deployment can pin one on disk. 3. UI overrides (non-empty fields only, and only fields not declared fields_locked: true in yaml) + +The Caldera REST connection auto-resolves from caldera's own main config, so +a stock deployment configures nothing. See caldera_connection(). """ import os @@ -159,46 +162,110 @@ def _read_caldera_main_config_from_disk() -> dict: return {} -def _caldera_url_from_server_config() -> str: +def _caldera_main_config() -> dict: + """Caldera's `main` config, from the running server or parsed from disk. + + get_config() raises KeyError in a subprocess, which never ran server.py. + """ try: main = BaseWorld.get_config() or {} except Exception: main = {} + return main or _read_caldera_main_config_from_disk() + + +def _dialable_host(host) -> str: + """Turn caldera's bind address into one we can connect to. + + A wildcard bind means "every interface" and is not routable. + """ + host = str(host or '').strip() + if host in ('', '0.0.0.0', '*'): + return '127.0.0.1' + if host in ('::', '[::]'): + return '[::1]' + if ':' in host and not host.startswith('['): + return f'[{host}]' # bare IPv6 literal; unbracketed it cannot carry a port + return host + + +def _caldera_url_from_server_config() -> str: + """Build the REST base url from the host and port caldera binds to. + + Not `app.contact.http`: that is the callback url for sandcat agents, + usually a bridge-side IP, and it is mutable at runtime. host and port are + what server.py hands to web.TCPSite, and caldera refuses to change either. + """ + main = _caldera_main_config() + return _normalise_api_url( + f"http://{_dialable_host(main.get('host'))}:{main.get('port') or 8888}" + ) - # If BaseWorld is empty (we're running as a subprocess outside the - # caldera server process), fall back to parsing the main config - # files from disk so the URL still picks up the operator's port - # override (e.g. -E local with port: 8788) instead of defaulting - # to 8888. - if not main: - main = _read_caldera_main_config_from_disk() - - # For MCP's local REST calls, always target localhost on the port - # from caldera's main config. We deliberately DON'T use - # `app.contact.http` here even though it carries a fully-formed URL: - # that field is the URL the operator wants *sandcat agents* (running - # inside the bridge network) to call home on — typically the - # bridge-side IP (e.g. http://10.10.0.1:8788). MCP and caldera run on - # the same host, so localhost: is always reachable, never - # blocked by an iptables forward rule, and removes one source of - # config drift between sandcat callbacks and MCP API calls. - port = main.get('port') or 8888 - return _normalise_api_url(f'http://localhost:{port}') + +_STOCK_API_KEY = 'ADMIN123' + +# argon2 verify costs ~33ms and the config never changes mid-process. +_key_verdicts: dict = {} + + +def _verify_key_against_core(candidate: str): + """Would the running caldera accept `candidate`? None if we cannot tell. + + Caldera stores api_key_red/blue argon2-hashed and verifies the submitted + plaintext against the hash, so the stored value is never itself a usable + key. Ask its own hasher, in-process. Caldera <= 5.3.0 stored plaintext. + """ + if not candidate: + return None + if candidate in _key_verdicts: + return _key_verdicts[candidate] + + main = _caldera_main_config() + stored = [s for s in (main.get('api_key_red'), main.get('api_key_blue')) + if isinstance(s, str) and s] + if not stored: + return None + + hashed = [s for s in stored if s.startswith('$argon2id$')] + if hashed: + try: + from app.utility.config_util import verify_hash + except Exception: + return None + verdict = any(verify_hash(s, candidate) for s in hashed) + else: + verdict = candidate in stored + + _key_verdicts[candidate] = verdict + return verdict def caldera_connection() -> dict: - """Resolve the Caldera REST connection: {url, api_key}. + """Resolve the Caldera REST connection from caldera's own config. + + Returns {url, api_key, key_valid, url_env, api_key_env}. - Both come from env vars whose names are declared in yaml's caldera - block. Falls back to local-dev values when the env vars are unset - so a fresh checkout works without configuration. + key_valid is diagnostic only: True/False/None for unchecked. The key is + returned either way and hook.py reports a mismatch, rather than swapping + in one the operator never configured. Both env vars keep top precedence. """ cfg = dict(_load_defaults().get('caldera') or {}) url_var = cfg.get('url_env', 'CALDERA_URL') key_var = cfg.get('api_key_env', 'CORE_CALDERA_API_KEY') + + # Normalise the override too: raw, a missing /api/v2/ suffix hits the + # login page and returns HTML with a 200. + url_override = os.environ.get(url_var) + url = _normalise_api_url(url_override) or _caldera_url_from_server_config() + + api_key = os.environ.get(key_var) or _STOCK_API_KEY + # An override may point at another caldera, whose keys are not ours to check. + key_valid = None if url_override else _verify_key_against_core(api_key) + return { - 'url': os.environ.get(url_var) or _caldera_url_from_server_config(), - 'api_key': os.environ.get(key_var, 'ADMIN123'), + 'url': url, + 'api_key': api_key, + 'key_valid': key_valid, 'url_env': url_var, 'api_key_env': key_var, } diff --git a/app/mcp_gui.py b/app/mcp_gui.py index 28fabad..e50c62c 100644 --- a/app/mcp_gui.py +++ b/app/mcp_gui.py @@ -74,6 +74,8 @@ def _bootstrap_context(self) -> dict: "llm_api_base": llm.get("api_base") or "", "caldera_url": caldera.get("url") or "", "caldera_api_key_env": caldera.get("api_key_env") or "CORE_CALDERA_API_KEY", + # Unknown (config unreadable) is not a failure worth flagging. + "caldera_key_rejected": caldera.get("key_valid") is False, "server_names": sorted(servers.keys()), "workflow_names": sorted(workflows.keys()), "capability_names": sorted(capabilities.keys()), diff --git a/app/mcp_server.py b/app/mcp_server.py index 944df4c..d25cd98 100644 --- a/app/mcp_server.py +++ b/app/mcp_server.py @@ -82,8 +82,12 @@ def health_check(): """ Returns the health of the Caldera API. """ - if isinstance(caldera_request.make_get_request("health"), dict): - return "Caldera API is UP!" + # The error object make_get_request returns on a non-200 is itself a dict, + # so an isinstance check reported "UP" on a 401. + result = caldera_request.make_get_request("health") + if isinstance(result, dict) and "error" in result: + return f"Caldera API at {caldera_request.caldera_url} is not usable: {result['error']}" + return "Caldera API is UP!" def filter_abilities(req, tactic: str, atomic: bool): diff --git a/app/workflows/author.py b/app/workflows/author.py index 705a912..8499b24 100644 --- a/app/workflows/author.py +++ b/app/workflows/author.py @@ -45,9 +45,12 @@ def get_env(lm_settings=None): if lm_settings.get('ssl_verify') is not None: env[ENV_SSL_VERIFY] = str(lm_settings.get('ssl_verify')).lower() + # Push the resolved values, not the raw environment: caldera_connection() + # already honours the env vars, and re-reading them here would hand the + # child a url missing the /api/v2/ suffix. caldera = caldera_connection() - env['CALDERA_URL'] = os.environ.get('CALDERA_URL') or caldera['url'] - env['CORE_CALDERA_API_KEY'] = os.environ.get('CORE_CALDERA_API_KEY') or caldera['api_key'] + env['CALDERA_URL'] = caldera['url'] + env['CORE_CALDERA_API_KEY'] = caldera['api_key'] return env diff --git a/app/workflows/plan_execute.py b/app/workflows/plan_execute.py index 044fdfd..0fd6610 100644 --- a/app/workflows/plan_execute.py +++ b/app/workflows/plan_execute.py @@ -81,9 +81,12 @@ def get_env(lm_settings=None): if lm_settings.get('ssl_verify') is not None: env[ENV_SSL_VERIFY] = str(lm_settings.get('ssl_verify')).lower() + # Push the resolved values, not the raw environment: caldera_connection() + # already honours the env vars, and re-reading them here would hand the + # child a url missing the /api/v2/ suffix. caldera = caldera_connection() - env['CALDERA_URL'] = os.environ.get('CALDERA_URL') or caldera['url'] - env['CORE_CALDERA_API_KEY'] = os.environ.get('CORE_CALDERA_API_KEY') or caldera['api_key'] + env['CALDERA_URL'] = caldera['url'] + env['CORE_CALDERA_API_KEY'] = caldera['api_key'] return env diff --git a/conf/default.yml b/conf/default.yml index 687f0b1..5101256 100644 --- a/conf/default.yml +++ b/conf/default.yml @@ -44,9 +44,10 @@ cti: offline: true use_mock: false -# Caldera REST API used by the caldera_core MCP subprocess to call /api/v2/* -# on the running Caldera server. Credentials live in plugins/mcp/.env; -# this block only names the env vars to consult. +# Caldera REST API used by the caldera_core MCP subprocess. The url and key +# auto-resolve from caldera's own main config, so a stock deployment needs no +# setup. This block only names the env vars that override that; their values +# live in plugins/mcp/.env. caldera: url_env: CALDERA_URL api_key_env: CORE_CALDERA_API_KEY diff --git a/hook.py b/hook.py index 2dc7103..8bbf3a6 100644 --- a/hook.py +++ b/hook.py @@ -110,15 +110,41 @@ def is_port_open(port, host='127.0.0.1'): from plugins.mcp.app.discovery.servers import discover_mcp_servers from plugins.mcp.app.discovery.workflows import discover_workflows from plugins.mcp.app.discovery.capabilities import discover_capabilities + from plugins.mcp.app.config import caldera_connection logging.getLogger("litellm_logging").setLevel(logging.ERROR) except ImportError as e: log.error(f"[MCP] Error importing MCP plugin modules: {e}") traceback.print_exc() + +def report_caldera_connection(): + """Log the resolved Caldera REST connection, and say so when it won't work. + + A rejected key is otherwise near-undiagnosable: caldera 401s, the tool + wraps that in a successful result, and the operator sees only a confused + agent trajectory. + """ + caldera = caldera_connection() + if caldera['key_valid'] is False: + log.warning( + f"[MCP] Caldera at {caldera['url']} rejects the configured API key. " + f"Set {caldera['api_key_env']} in plugins/mcp/.env to this server's " + f"API_TOKEN, printed once in the caldera log when conf/local.yml " + f"was generated." + ) + else: + log.info(f"[MCP] Caldera REST resolved to {caldera['url']}") + + async def enable(services): app = services.get('app_svc').application + try: + report_caldera_connection() + except Exception as e: + log.warning(f"[MCP] Could not resolve the Caldera REST connection: {e}") + # Discover MCP servers, workflows, and capabilities at boot. Each registry is # built once and handed to MCPService; nothing rescans at request time. import pathlib diff --git a/templates/mcp.html b/templates/mcp.html index dc365e6..12a9d18 100644 --- a/templates/mcp.html +++ b/templates/mcp.html @@ -26,8 +26,17 @@

Plugin readiness

  • Caldera REST: - {{ caldera_url }} - · key from {{ caldera_api_key_env }} + {% if caldera_key_rejected %} + key rejected +  {{ caldera_url }} does not accept the + configured key. Set {{ caldera_api_key_env }} + in plugins/mcp/.env to this server's API_TOKEN, + printed once in the caldera log when + conf/local.yml was generated. + {% else %} + resolved +  {{ caldera_url }} + {% endif %}
  • MCP servers discovered: {{ server_count }} diff --git a/tests/test_config.py b/tests/test_config.py index 1289a73..9b28b66 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -326,3 +326,229 @@ def test_default_only_still_loads(self, tmp_path, loader): def test_missing_both_raises(self, tmp_path, loader): with pytest.raises(FileNotFoundError): loader() + + +class TestCalderaConnection: + """Resolution from caldera's own main config. No network: the key check is + a pure in-process call to caldera's hasher.""" + + @pytest.fixture + def core(self, monkeypatch): + """Stub caldera's main config; yields it so a test can reshape it.""" + from plugins.mcp.app import config + + main = {"host": "0.0.0.0", "port": 8888} + monkeypatch.setattr(config, "_caldera_main_config", lambda: main) + monkeypatch.setattr( + config, "_load_defaults", + lambda: {"caldera": {"url_env": "CALDERA_URL", + "api_key_env": "CORE_CALDERA_API_KEY"}}, + ) + monkeypatch.delenv("CALDERA_URL", raising=False) + monkeypatch.delenv("CORE_CALDERA_API_KEY", raising=False) + config._key_verdicts.clear() + yield main + config._key_verdicts.clear() + + @staticmethod + def _hash(plaintext): + from argon2 import PasswordHasher + return PasswordHasher().hash(plaintext) + + # --- url ------------------------------------------------------------- + + def test_wildcard_bind_dials_loopback(self, core): + # 0.0.0.0 is a wildcard bind, not a routable destination. + from plugins.mcp.app.config import caldera_connection + assert caldera_connection()["url"] == "http://127.0.0.1:8888/api/v2/" + + def test_empty_host_dials_loopback(self, core): + from plugins.mcp.app.config import caldera_connection + core["host"] = "" + assert caldera_connection()["url"] == "http://127.0.0.1:8888/api/v2/" + + def test_narrowed_bind_is_used_verbatim(self, core): + # Binding one NIC leaves loopback unbound, so 127.0.0.1 would refuse. + from plugins.mcp.app.config import caldera_connection + core["host"] = "10.0.0.5" + assert caldera_connection()["url"] == "http://10.0.0.5:8888/api/v2/" + + def test_bare_ipv6_host_is_bracketed(self, core): + # Unbracketed, requests raises InvalidURL before anything is sent. + from plugins.mcp.app.config import caldera_connection + core["host"] = "fe80::1" + assert caldera_connection()["url"] == "http://[fe80::1]:8888/api/v2/" + + def test_ipv6_wildcard_dials_loopback(self, core): + from plugins.mcp.app.config import caldera_connection + core["host"] = "::" + assert caldera_connection()["url"] == "http://[::1]:8888/api/v2/" + + def test_port_override_is_honoured(self, core): + from plugins.mcp.app.config import caldera_connection + core["port"] = 8788 + assert caldera_connection()["url"] == "http://127.0.0.1:8788/api/v2/" + + def test_env_override_wins_and_is_normalised(self, core, monkeypatch): + # Raw, a suffixless override hits /health instead of /api/v2/health. + from plugins.mcp.app.config import caldera_connection + monkeypatch.setenv("CALDERA_URL", "http://caldera.example.com:9000") + assert caldera_connection()["url"] == "http://caldera.example.com:9000/api/v2/" + + def test_empty_core_config_keeps_the_historical_default(self, monkeypatch): + from plugins.mcp.app import config + monkeypatch.setattr(config, "_caldera_main_config", lambda: {}) + monkeypatch.setattr(config, "_load_defaults", lambda: {}) + monkeypatch.delenv("CALDERA_URL", raising=False) + assert config.caldera_connection()["url"] == "http://127.0.0.1:8888/api/v2/" + + # --- credential ------------------------------------------------------ + + def test_stock_key_is_used_when_caldera_accepts_it(self, core): + # --insecure hashes the shipped ADMIN123 in place, so it still verifies. + from plugins.mcp.app.config import caldera_connection + core["api_key_red"] = self._hash("ADMIN123") + conn = caldera_connection() + assert conn["api_key"] == "ADMIN123" + assert conn["key_valid"] is True + + def test_blue_key_also_counts(self, core): + from plugins.mcp.app.config import caldera_connection + core["api_key_red"] = self._hash("something-else") + core["api_key_blue"] = self._hash("ADMIN123") + assert caldera_connection()["key_valid"] is True + + def test_env_key_wins_when_it_verifies(self, core, monkeypatch): + from plugins.mcp.app.config import caldera_connection + core["api_key_red"] = self._hash("generated-token") + monkeypatch.setenv("CORE_CALDERA_API_KEY", "generated-token") + conn = caldera_connection() + assert conn["api_key"] == "generated-token" + assert conn["key_valid"] is True + + def test_rejected_key_is_still_returned_but_flagged(self, core, monkeypatch): + # A generated conf/local.yml destroys the plaintext; report, don't swap. + from plugins.mcp.app.config import caldera_connection + core["api_key_red"] = self._hash("generated-token") + monkeypatch.setenv("CORE_CALDERA_API_KEY", "stale") + conn = caldera_connection() + assert conn["api_key"] == "stale" + assert conn["key_valid"] is False + + def test_stock_key_is_not_flagged_valid_on_a_generated_config(self, core): + from plugins.mcp.app.config import caldera_connection + core["api_key_red"] = self._hash("generated-token") + conn = caldera_connection() + assert conn["api_key"] == "ADMIN123" + assert conn["key_valid"] is False + + def test_explicit_key_is_never_swapped_for_the_stock_one(self, core, monkeypatch): + # Swapping in ADMIN123 would grant red access the operator never chose. + from plugins.mcp.app.config import caldera_connection + core["api_key_red"] = self._hash("ADMIN123") + core["api_key_blue"] = self._hash("BLUEADMIN123") + monkeypatch.setenv("CORE_CALDERA_API_KEY", "BLUEADMIN123x") + conn = caldera_connection() + assert conn["api_key"] == "BLUEADMIN123x" + assert conn["key_valid"] is False + + def test_url_override_skips_verification(self, core, monkeypatch): + # A cross-host url points at a caldera whose keys we cannot check. + from plugins.mcp.app.config import caldera_connection + core["api_key_red"] = self._hash("ADMIN123") + monkeypatch.setenv("CALDERA_URL", "http://remote:8888") + monkeypatch.setenv("CORE_CALDERA_API_KEY", "remote-key") + conn = caldera_connection() + assert conn["api_key"] == "remote-key" + assert conn["key_valid"] is None + + def test_unreadable_core_config_leaves_the_verdict_unknown(self, core): + # The subprocess shape; must match the pre-fix resolver. + from plugins.mcp.app.config import caldera_connection + conn = caldera_connection() + assert conn["api_key"] == "ADMIN123" + assert conn["key_valid"] is None + + def test_plaintext_config_rejects_a_mismatched_key(self, core): + # Caldera <= 5.3.0 stored the key in the clear. + from plugins.mcp.app.config import caldera_connection + core["api_key_red"] = "plain-key" + conn = caldera_connection() + assert conn["api_key"] == "ADMIN123" + assert conn["key_valid"] is False + + def test_plaintext_config_accepts_a_matching_env_key(self, core, monkeypatch): + from plugins.mcp.app.config import caldera_connection + core["api_key_red"] = "plain-key" + monkeypatch.setenv("CORE_CALDERA_API_KEY", "plain-key") + conn = caldera_connection() + assert conn["api_key"] == "plain-key" + assert conn["key_valid"] is True + + def test_never_returns_a_password_hash_as_the_credential(self, core): + # Verification is hash-vs-plaintext, so a hash is never a usable key. + from plugins.mcp.app.config import caldera_connection + core["api_key_red"] = self._hash("ADMIN123") + core["api_key_blue"] = self._hash("BLUEADMIN123") + assert not caldera_connection()["api_key"].startswith("$argon2id$") + + def test_verdict_is_cached_per_candidate(self, core): + from plugins.mcp.app import config + core["api_key_red"] = self._hash("ADMIN123") + config.caldera_connection() + assert config._key_verdicts["ADMIN123"] is True + + +class TestCalderaReadinessPayload: + """The splash reports a rejected key, and only a genuinely rejected one.""" + + def _context(self, monkeypatch, connection): + from plugins.mcp.app import mcp_gui + monkeypatch.setattr( + mcp_gui, "llm_defaults", + lambda: {"api_key": "sk", "api_base": "https://gw/v1", "model": "m"}, + ) + monkeypatch.setattr(mcp_gui, "caldera_connection", lambda: connection) + return mcp_gui.McpGUI({}, "mcp", "desc")._bootstrap_context() + + def test_rejected_key_is_flagged(self, monkeypatch): + ctx = self._context(monkeypatch, {"url": "http://127.0.0.1:8888/api/v2/", + "key_valid": False}) + assert ctx["caldera_key_rejected"] is True + + def test_accepted_key_is_not_flagged(self, monkeypatch): + ctx = self._context(monkeypatch, {"url": "u", "key_valid": True}) + assert ctx["caldera_key_rejected"] is False + + def test_unknown_verdict_is_not_flagged(self, monkeypatch): + # A subprocess cannot check, and "unknown" is not a failure to report. + ctx = self._context(monkeypatch, {"url": "u", "key_valid": None}) + assert ctx["caldera_key_rejected"] is False + + def test_payload_never_carries_the_key(self, monkeypatch): + ctx = self._context(monkeypatch, {"url": "u", "api_key": "super-secret", + "key_valid": True}) + assert "super-secret" not in str(ctx) + + +class TestSubprocessEnv: + """Whatever the parent resolves is what the MCP subprocess must receive.""" + + @pytest.fixture(params=["plan_execute", "author"]) + def get_env(self, request, monkeypatch): + # Both workflows carry the same get_env(); neither may drift. + mod = pytest.importorskip(f"plugins.mcp.app.workflows.{request.param}") + monkeypatch.setattr( + mod, "caldera_connection", + lambda: {"url": "http://127.0.0.1:8788/api/v2/", "api_key": "resolved-key"}, + ) + return mod.get_env + + def test_pushes_the_resolved_url(self, get_env, monkeypatch): + # A raw env value skips normalisation and the child only appends to it. + monkeypatch.setenv("CALDERA_URL", "http://127.0.0.1:8788") + assert get_env()["CALDERA_URL"] == "http://127.0.0.1:8788/api/v2/" + + def test_pushes_the_resolved_key(self, get_env, monkeypatch): + monkeypatch.setenv("CORE_CALDERA_API_KEY", "stale") + assert get_env()["CORE_CALDERA_API_KEY"] == "resolved-key"