diff --git a/automations/README.md b/automations/README.md index 4894b49a..cadc33b1 100644 --- a/automations/README.md +++ b/automations/README.md @@ -45,7 +45,7 @@ two records of the same fact from drifting apart, and it is why the file is as s | The trigger variants a deployment can offer | The keys of `setup.form.triggers`; an entry with multiple keys is usable when at least one variant is supported. An entry without a setup form states its kind in `triggers` instead | | Schedule limits, timezone list, event choices, and model profiles | The `cron`, `timezone`, `event-source`, `event-type`, and `llm-profile` field types | | Local validation rules | The `required` flag and `constraints` on each field, applied to the selected trigger variant | -| The preflight call | `POST /v1/validate` with the entry id, the create endpoint, and the rendered payload | +| The preflight call | `POST /v1/validate` with the entry id, the create endpoint, the rendered payload, and required MCP alternatives derived from `requires.integrations` plus the integration catalog | | The created automation's name | A form field named `name`, or the entry's `name` plus the repository that was picked | | `repos` in the create request | The repo-picker field, its declared `provider`, and a non-empty field named `ref` if there is one | | `model` and `timeout` in the create request | Same-named form fields when they have values | @@ -59,6 +59,9 @@ two records of the same fact from drifting apart, and it is why the file is as s `tests/test_automation_setup.py` derives the preflight body and the payload-path mapping from the entry and checks them against the recorded fixtures, so these deletions stay honest rather than becoming assumptions. +The derived integration requirements include only MCP locators, authentication strategies, and required +secret names. Secret values remain in the deployment's credential store and are never included in the +preflight request. ## What an entry carries diff --git a/tests/fixtures/automations/custom-automation.json b/tests/fixtures/automations/custom-automation.json index d2902150..4573bb0a 100644 --- a/tests/fixtures/automations/custom-automation.json +++ b/tests/fixtures/automations/custom-automation.json @@ -45,6 +45,9 @@ "schedule": "0 9 * * 1-5", "timezone": "UTC" } + }, + "requirements": { + "integrations": [] } } }, @@ -137,6 +140,9 @@ "on": "issue_comment.created", "filter": "glob(repository.full_name, 'OpenHands/extensions') && icontains(comment.body, '@openhands')" } + }, + "requirements": { + "integrations": [] } } }, @@ -243,6 +249,9 @@ "schedule": "0 10 * * 1", "timezone": "UTC" } + }, + "requirements": { + "integrations": [] } } }, @@ -349,6 +358,9 @@ "tarball_path": "oh-internal://uploads/00000000-0000-0000-0000-000000000000", "entrypoint": "python3 main.py", "setup_script_path": "setup.sh" + }, + "requirements": { + "integrations": [] } } }, diff --git a/tests/fixtures/automations/github-pr-reviewer.json b/tests/fixtures/automations/github-pr-reviewer.json index bf324045..f4dca173 100644 --- a/tests/fixtures/automations/github-pr-reviewer.json +++ b/tests/fixtures/automations/github-pr-reviewer.json @@ -81,6 +81,9 @@ "max_new_per_run": 2 } } + }, + "requirements": { + "integrations": [] } } }, @@ -227,6 +230,9 @@ "max_new_per_run": 2 } } + }, + "requirements": { + "integrations": [] } } }, @@ -364,6 +370,9 @@ "max_new_per_run": 2 } } + }, + "requirements": { + "integrations": [] } } }, @@ -629,6 +638,9 @@ "max_new_per_run": 2 } } + }, + "requirements": { + "integrations": [] } } }, diff --git a/tests/fixtures/automations/github-repo-monitor.json b/tests/fixtures/automations/github-repo-monitor.json index 96d7569a..35771978 100644 --- a/tests/fixtures/automations/github-repo-monitor.json +++ b/tests/fixtures/automations/github-repo-monitor.json @@ -49,6 +49,28 @@ "timezone": "UTC" } } + }, + "requirements": { + "integrations": [ + { + "id": "github", + "alternatives": [ + { + "transport": "shttp", + "locator": "https://api.githubcopilot.com/mcp/", + "authStrategy": "oauth2" + }, + { + "transport": "shttp", + "locator": "https://api.githubcopilot.com/mcp/", + "authStrategy": "api_key", + "secretNames": [ + "GITHUB_PERSONAL_ACCESS_TOKEN" + ] + } + ] + } + ] } } }, @@ -184,6 +206,28 @@ "timezone": "UTC" } } + }, + "requirements": { + "integrations": [ + { + "id": "github", + "alternatives": [ + { + "transport": "shttp", + "locator": "https://api.githubcopilot.com/mcp/", + "authStrategy": "oauth2" + }, + { + "transport": "shttp", + "locator": "https://api.githubcopilot.com/mcp/", + "authStrategy": "api_key", + "secretNames": [ + "GITHUB_PERSONAL_ACCESS_TOKEN" + ] + } + ] + } + ] } } }, diff --git a/tests/fixtures/automations/upstream-fork-sync.json b/tests/fixtures/automations/upstream-fork-sync.json index ed5c4918..879bb9e4 100644 --- a/tests/fixtures/automations/upstream-fork-sync.json +++ b/tests/fixtures/automations/upstream-fork-sync.json @@ -38,6 +38,28 @@ "schedule": "0 3 * * *", "timezone": "UTC" } + }, + "requirements": { + "integrations": [ + { + "id": "github", + "alternatives": [ + { + "transport": "shttp", + "locator": "https://api.githubcopilot.com/mcp/", + "authStrategy": "oauth2" + }, + { + "transport": "shttp", + "locator": "https://api.githubcopilot.com/mcp/", + "authStrategy": "api_key", + "secretNames": [ + "GITHUB_PERSONAL_ACCESS_TOKEN" + ] + } + ] + } + ] } } }, @@ -128,6 +150,28 @@ "schedule": "*/1 * * * *", "timezone": "UTC" } + }, + "requirements": { + "integrations": [ + { + "id": "github", + "alternatives": [ + { + "transport": "shttp", + "locator": "https://api.githubcopilot.com/mcp/", + "authStrategy": "oauth2" + }, + { + "transport": "shttp", + "locator": "https://api.githubcopilot.com/mcp/", + "authStrategy": "api_key", + "secretNames": [ + "GITHUB_PERSONAL_ACCESS_TOKEN" + ] + } + ] + } + ] } } }, diff --git a/tests/test_automation_setup.py b/tests/test_automation_setup.py index 798e27dd..d3d013d0 100644 --- a/tests/test_automation_setup.py +++ b/tests/test_automation_setup.py @@ -36,6 +36,7 @@ BUILD_SCRIPT = ROOT / "scripts" / "build-automation-catalog.mjs" FIXTURE_DIR = ROOT / "tests" / "fixtures" / "automations" CAPABILITIES_PATH = FIXTURE_DIR / "capabilities.json" +INTEGRATION_CATALOG_DIR = ROOT / "integrations" / "catalog" # The standardized parts of a direct setup, identical for every automation and # therefore not declared in any entry. Which create endpoint is used follows @@ -422,9 +423,76 @@ def _derive_preflight_body( "draft": _render_payload( entry, form_values, PREFLIGHT_TARBALL_PATH, selected_trigger, selected_action ), + "requirements": _derive_preflight_requirements(entry), } +def _required_secret_fields(fields: list[dict] | None) -> list[str]: + return [ + field["key"] + for field in fields or [] + if field.get("required") and field.get("type") == "password" + ] + + +def _derive_preflight_requirements(entry: dict) -> dict: + """Derive deployment checks from required integration catalog entries.""" + integrations = [] + for integration_id, requirement in entry["requires"]["integrations"].items(): + if requirement.get("required") is False: + continue + catalog_path = INTEGRATION_CATALOG_DIR / f"{integration_id}.json" + alternatives = [] + if catalog_path.exists(): + catalog_entry = _load(catalog_path) + for option in catalog_entry["connectionOptions"]: + transport = option.get("transport") + if option.get("provider") != "mcp" or not transport: + continue + auth = option["auth"] + locator = ( + transport["serverName"] + if transport["kind"] == "stdio" + else transport["url"] + ) + credential_optional = auth.get("apiKeyOptional", False) or ( + transport["kind"] != "stdio" + and transport.get("apiKeyOptional", False) + ) + if transport["kind"] == "stdio": + transport_secret_names = [ + *_required_secret_fields(transport.get("envFields")), + *_required_secret_fields(transport.get("argFields")), + ] + else: + transport_secret_names = _required_secret_fields( + transport.get("headerFields") + ) + secret_names = list( + dict.fromkeys( + [ + *( + [auth["credentialSecretName"]] + if not credential_optional + and auth.get("credentialSecretName") + else [] + ), + *transport_secret_names, + ] + ) + ) + alternative = { + "transport": transport["kind"], + "locator": locator, + "authStrategy": auth["strategy"], + } + if secret_names: + alternative["secretNames"] = secret_names + alternatives.append(alternative) + integrations.append({"id": integration_id, "alternatives": alternatives}) + return {"integrations": integrations} + + def _derive_error_map( entry: dict, selected_trigger: str | None = None,