diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 1f8e74bc86..ca90c602d9 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -3997,7 +3997,7 @@ jobs: "npm": "@ai-sdk/openai-compatible", "name": "Contextual Orchestrator", "options": { - "baseURL": "{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}", + "baseURL": "{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}/v1", "apiKey": "{env:CONTEXTUAL_ORCHESTRATOR_TOKEN}" }, "models": { diff --git a/.github/workflows/pr-review-autofix.yml b/.github/workflows/pr-review-autofix.yml index 1b7849a0c5..c087e98ca3 100644 --- a/.github/workflows/pr-review-autofix.yml +++ b/.github/workflows/pr-review-autofix.yml @@ -364,7 +364,7 @@ jobs: "npm": "@ai-sdk/openai-compatible", "name": "Contextual Orchestrator", "options": { - "baseURL": "{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}", + "baseURL": "{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}/v1", "apiKey": "{env:CONTEXTUAL_ORCHESTRATOR_TOKEN}" }, "models": { diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index f15b29f564..706a6567d7 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -976,6 +976,12 @@ jobs: # Defined before the gate loop so the bounded retry decision below # can classify outcomes without duplicating the patterns later. backend_unavailable_signal='STRIX_PROVIDER_UNAVAILABLE|RateLimitError|Too many requests\. For more on scraping GitHub|exceeded your current quota|insufficient_quota|billing details|"status"[[:space:]]*:[[:space:]]*"RESOURCE_EXHAUSTED"|tokens_limit_reached|Request body too large|Max size:[[:space:]]*[0-9]+[[:space:]]+tokens|Error code:[[:space:]]*500[^[:cntrl:]]*internal_error|Error code:[[:space:]]*413|LLM CONNECTION FAILED|Could not establish connection to the language model|LLM warm-up failed|Configured model and fallback models were unavailable|Configured Vertex model and fallback models were unavailable|emitted provider infrastructure or failure-signal output|before provider infrastructure failure|litellm(\.exceptions)?\.NotFoundError[^[:cntrl:]]*Nvidia_nimException[^[:cntrl:]]*Error code:[[:space:]]*404|Error during penetration test: loginAsGuest failed after [0-9]+ attempts: curl exit 7: curl: \(7\) Failed to connect to 127\.0\.0\.1 port 48080' + # Scanner tooling breakage (Caido GraphQL query/cursor errors) is + # not a provider outcome. It can occur while providers are healthy + # and it can coexist with genuine provider rate limits, so it gets + # its own typed notice instead of being folded into the provider + # verdict. See docs/doctoring/review-failure-taxonomy.md. + tooling_error_signal='Invalid HTTPQL query|Failed to parse cursor|TransportQueryError|caido_sdk_client\.errors' model_behavior_error_signal='(^|[^A-Za-z0-9_])(agents|pydantic_ai|strix)(\.[A-Za-z_][A-Za-z0-9_]*)*\.ModelBehaviorError([^A-Za-z0-9_]|$)' # Any evidence that a vulnerability was actually reported. Its presence # forces a hard failure so real findings are NEVER downgraded. Keep the @@ -1029,6 +1035,13 @@ jobs: # Classify provider/backend exhaustion only when no vulnerability # finding was emitted. Classification improves diagnosis; it never # converts an incomplete scan into passing security evidence. + # Report scanner tooling breakage on its own, whatever the provider + # verdict turns out to be. This never changes the exit code: an + # incomplete scan stays non-passing either way. + if grep -Eq "$tooling_error_signal" "$strix_neutralization_scope_log"; then + echo "::error title=STRIX_TOOLING_ERROR::Strix scanner tooling failed (Caido GraphQL query or cursor error). This is a scanner defect, not a provider outage; a provider notice may also follow. See the strix-reports artifact and run log." + fi + if ( grep -Eiq "$backend_unavailable_signal" "$strix_neutralization_scope_log" \ || grep -Eq "$model_behavior_error_signal" "$strix_neutralization_scope_log" ) \ && ! grep -Eiq "$reported_vulnerability_signal" "$strix_neutralization_scope_log"; then diff --git a/docs/doctoring/review-failure-taxonomy.md b/docs/doctoring/review-failure-taxonomy.md new file mode 100644 index 0000000000..45c064d337 --- /dev/null +++ b/docs/doctoring/review-failure-taxonomy.md @@ -0,0 +1,51 @@ +# Review failure taxonomy: gateway routing, scanner tooling, dispatch admission + +On 2026-09-21 the central review pipeline looked like a provider outage. It was not. +Three unrelated failures were being read as one. + +## What the hosted evidence showed + +Every run reached the vendored contextual-orchestrator sidecar and every run reported +`provider secrets present: 5 of 5`, including runs that predate any credential change. +The gateway answered its own preflight with `status: ready` and `finish_reason: stop`. +Provider credentials were never the blocker. + +## 1. OpenCode: gateway routing, reported as `Error: not found` + +The sidecar exports `CONTEXTUAL_ORCHESTRATOR_BASE_URL` as a bare `scheme://host:port`. +Noema and Strix append `/v1/chat/completions` themselves. OpenCode's +`@ai-sdk/openai-compatible` provider appends only `/chat/completions`, so it posted to an +unprefixed path. The gateway serves `/v1/chat/completions` and answers anything else with +`route_not_found`, whose message is the bare string `not found` — which OpenCode printed +verbatim as `Error: not found`, half a second after its banner had already resolved the +agent and model. + +Reproduced locally against a stub gateway with the installed OpenCode CLI: an unprefixed +`baseURL` produced `POST /chat/completions`, and `{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}/v1` +produced `POST /v1/chat/completions`. The `/v1` belongs in the OpenCode provider options, +never in the sidecar export — moving it there would double-prefix Noema and Strix. + +The banner is the tell: once `> · ` has printed, agent and model already +resolved, so a later `not found` is a transport answer, not configuration lookup. + +## 2. Strix: scanner tooling, previously folded into the provider verdict + +A failing scan emitted Caido GraphQL errors (`Invalid HTTPQL query`, `Failed to parse +cursor`, `TransportQueryError`) while the gateway was healthy. Genuine provider rate +limits appeared in the same log, so the single `STRIX_PROVIDER_UNAVAILABLE` notice was not +wrong — it was incomplete, and it hid a scanner defect behind an infrastructure label. +`strix.yml` now emits `STRIX_TOOLING_ERROR` on its own whenever a tooling signature +appears. Both notices can appear together. Neither changes the exit code: an incomplete +scan stays non-passing. + +## 3. Dispatch admission: never a gateway outcome + +`repository_dispatch authorization rejected` and `repository_dispatch metadata does not +match the live pull request` fire before the sidecar is provisioned. Counting them as +review-pipeline outages inflates the apparent provider failure rate. + +## Rule + +Attribute a review failure to the provider only after the gateway request itself failed. +Name the gateway's served path, the scanner's own errors, and admission gates as separate +classes. `tests/test_review_failure_taxonomy_contract.py` pins all three. diff --git a/opencode.jsonc b/opencode.jsonc index 8946175a13..3b5f34e2a6 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -294,12 +294,15 @@ // routes prioritized by scripts/ci/zdr_policy.py. Requires // CONTEXTUAL_ORCHESTRATOR_BASE_URL and CONTEXTUAL_ORCHESTRATOR_TOKEN, // which scripts/ci/contextual_orchestrator_review_sidecar.sh provisions on - // each runner before OpenCode starts. + // each runner before OpenCode starts. The sidecar exports a bare + // scheme://host:port, while the OpenAI-compatible provider appends only + // `/chat/completions`, so the `/v1` prefix belongs here. Without it the + // gateway answers route_not_found and OpenCode prints `Error: not found`. "contextual-orchestrator": { "npm": "@ai-sdk/openai-compatible", "name": "Contextual Orchestrator", "options": { - "baseURL": "{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}", + "baseURL": "{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}/v1", "apiKey": "{env:CONTEXTUAL_ORCHESTRATOR_TOKEN}" }, "models": { diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 150b9102b3..938f717363 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -683,7 +683,7 @@ assert_opencode_review_uses_codegraph_and_contextual_orchestrator() { assert_file_contains "$workflow_file" '"model": "contextual-orchestrator/orchestrator/free"' "opencode review uses the gateway free pool" assert_file_contains "$workflow_file" '"small_model": "contextual-orchestrator/orchestrator/free"' "opencode review uses the gateway for the small model" assert_file_contains "$workflow_file" '"enabled_providers": ["contextual-orchestrator"]' "opencode review enables only the gateway provider" - assert_file_contains "$workflow_file" '"baseURL": "{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}"' "opencode review routes model traffic through the gateway origin" + assert_file_contains "$workflow_file" '"baseURL": "{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}/v1"' "opencode review routes model traffic through the gateway origin" assert_file_contains "$workflow_file" '"apiKey": "{env:CONTEXTUAL_ORCHESTRATOR_TOKEN}"' "opencode review routes model credentials through the gateway token" assert_file_not_contains "$workflow_file" "https://models.github.ai/inference" "opencode review has no direct GitHub Models endpoint" assert_file_not_contains "$workflow_file" "https://openrouter.ai/api/v1" "opencode review has no direct OpenRouter endpoint" @@ -1319,7 +1319,7 @@ assert_opencode_review_uses_codegraph_and_contextual_orchestrator() { assert_file_contains "$workflow_file" "Run OpenCode PR Review model pool" "opencode review starts the central model pool" assert_file_contains "$workflow_file" "Provision contextual-orchestrator review sidecar" "opencode review provisions the gateway before model execution" assert_file_contains "$workflow_file" '"enabled_providers": ["contextual-orchestrator"]' "opencode review keeps model execution gateway-only" - assert_file_contains "$workflow_file" '"baseURL": "{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}"' "opencode review binds the gateway origin in generated config" + assert_file_contains "$workflow_file" '"baseURL": "{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}/v1"' "opencode review binds the gateway origin in generated config" assert_file_contains "$workflow_file" '"apiKey": "{env:CONTEXTUAL_ORCHESTRATOR_TOKEN}"' "opencode review binds the gateway token in generated config" assert_file_not_contains "$workflow_file" "github-models/" "opencode review has no direct GitHub Models candidates" assert_file_not_contains "$workflow_file" "openai/gpt-" "opencode review has no direct OpenAI candidates" diff --git a/tests/test_contextual_orchestrator_review_sidecar_contract.py b/tests/test_contextual_orchestrator_review_sidecar_contract.py index b279d33a98..40b9e0da04 100644 --- a/tests/test_contextual_orchestrator_review_sidecar_contract.py +++ b/tests/test_contextual_orchestrator_review_sidecar_contract.py @@ -437,7 +437,7 @@ def test_opencode_config_defaults_to_the_contextual_gateway() -> None: assert f'"model": "{GATEWAY_MODEL}"' in config assert f'"small_model": "{GATEWAY_MODEL}"' in config assert '"enabled_providers": ["contextual-orchestrator"' in config - assert '"baseURL": "{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}"' in config + assert '"baseURL": "{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}/v1"' in config assert '"apiKey": "{env:CONTEXTUAL_ORCHESTRATOR_TOKEN}"' in config assert '"orchestrator/free": {' in config diff --git a/tests/test_opencode_gateway_route_integration.py b/tests/test_opencode_gateway_route_integration.py new file mode 100644 index 0000000000..3893914c0e --- /dev/null +++ b/tests/test_opencode_gateway_route_integration.py @@ -0,0 +1,204 @@ +"""Executed proof that OpenCode requests the gateway's served route. + +The string contracts in ``test_review_failure_taxonomy_contract.py`` pin what +the config says. They cannot show what the OpenCode CLI actually sends, which +is where the 2026-08-27..2026-09-21 outage lived: the provider appended only +``/chat/completions`` to a bare origin, the gateway served ``/v1/…`` and +answered ``route_not_found`` whose message is the bare string ``not found``. + +These tests run the installed OpenCode CLI against a stub that answers exactly +like the vendored gateway — the served route succeeds, every other route 404s +with the gateway's own wording — and record which path the CLI asked for. The +tracked ``opencode.jsonc`` provider block is the input, so the proof follows +the shipped config instead of a copy of it. + +They skip when no ``opencode`` binary is present (CI images for the quality +workflows do not install it); local execution is the evidence. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +import subprocess +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +_ORG_REPO_ROOT = Path(__file__).resolve().parents[1] +OPENCODE_CONFIG = _ORG_REPO_ROOT / "opencode.jsonc" + +GATEWAY_SERVED_ROUTE = "/v1/chat/completions" +GATEWAY_ROUTE_NOT_FOUND_MESSAGE = "not found" +CLI_TIMEOUT_SECONDS = 120 +CLI_EXIT_GRACE_SECONDS = 20 + + +def _tracked_provider_block() -> dict: + """Return the gateway provider block exactly as opencode.jsonc ships it.""" + text = OPENCODE_CONFIG.read_text(encoding="utf-8") + without_comments = "\n".join( + line for line in text.splitlines() if not line.lstrip().startswith("//") + ) + config = json.loads(without_comments) + return config["provider"]["contextual-orchestrator"] + + +class _GatewayStub(BaseHTTPRequestHandler): + """Answer like the vendored gateway: one served route, 404 elsewhere.""" + + requested_paths: list[str] = [] + + def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + """Record the requested path and answer as the gateway would.""" + length = int(self.headers.get("content-length") or 0) + self.rfile.read(length) + type(self).requested_paths.append(self.path) + if self.path == GATEWAY_SERVED_ROUTE: + payload = { + "id": "stub", + "object": "chat.completion", + "created": 0, + "model": "orchestrator/free", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + self._send(200, payload) + return + self._send( + 404, + {"error": {"message": GATEWAY_ROUTE_NOT_FOUND_MESSAGE, "code": "route_not_found"}}, + ) + + def _send(self, status: int, payload: dict) -> None: + """Write one JSON response with an explicit content length.""" + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args: object) -> None: + """Silence the default stderr access log.""" + + +@pytest.fixture(name="gateway_stub") +def gateway_stub_fixture(): + """Serve the gateway stub on a loopback port for one test.""" + _GatewayStub.requested_paths = [] + server = HTTPServer(("127.0.0.1", 0), _GatewayStub) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}", _GatewayStub.requested_paths + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def _run_opencode( + tmp_path: Path, + base_url_template: str, + gateway_origin: str, + requested_paths: list[str], +) -> tuple[str, str]: + """Run the OpenCode CLI until it asks the gateway for one route.""" + provider = _tracked_provider_block() + provider = json.loads(json.dumps(provider)) + provider["options"]["baseURL"] = base_url_template + config_home = tmp_path / "config" + (config_home / "opencode").mkdir(parents=True) + (config_home / "opencode" / "opencode.json").write_text( + json.dumps( + { + "$schema": "https://opencode.ai/config.json", + "model": "contextual-orchestrator/orchestrator/free", + "small_model": "contextual-orchestrator/orchestrator/free", + "enabled_providers": ["contextual-orchestrator"], + "provider": {"contextual-orchestrator": provider}, + } + ), + encoding="utf-8", + ) + project = tmp_path / "project" + project.mkdir() + environment = { + "PATH": os.environ.get("PATH", ""), + "HOME": str(tmp_path / "home"), + "XDG_CONFIG_HOME": str(config_home), + "NO_COLOR": "1", + "CONTEXTUAL_ORCHESTRATOR_BASE_URL": gateway_origin, + "CONTEXTUAL_ORCHESTRATOR_TOKEN": "stub-token", + } + (tmp_path / "home").mkdir() + process = subprocess.Popen( + [ + "opencode", + "run", + "reply with ok", + "--pure", + "--model", + "contextual-orchestrator/orchestrator/free", + ], + cwd=project, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + # The served route answers successfully, after which the agent keeps + # working; the requested path is the evidence, so stop as soon as one + # arrives. An unserved route makes the CLI exit on its own. + deadline = time.monotonic() + CLI_TIMEOUT_SECONDS + while time.monotonic() < deadline: + if requested_paths or process.poll() is not None: + break + time.sleep(0.2) + try: + return process.communicate(timeout=CLI_EXIT_GRACE_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + return process.communicate() + + +pytestmark = pytest.mark.skipif( + shutil.which("opencode") is None, + reason="OpenCode CLI is not installed on this runner", +) + + +def test_tracked_config_requests_the_gateway_served_route(gateway_stub, tmp_path) -> None: + """The shipped baseURL must make the CLI ask for the served /v1 route.""" + origin, requested_paths = gateway_stub + base_url = _tracked_provider_block()["options"]["baseURL"] + _run_opencode(tmp_path, base_url, origin, requested_paths) + assert requested_paths, "the CLI issued no request to the gateway stub" + assert requested_paths[0] == GATEWAY_SERVED_ROUTE + + +def test_bare_origin_reproduces_the_route_not_found_outage(gateway_stub, tmp_path) -> None: + """Dropping /v1 must reproduce the unserved path and the gateway wording.""" + origin, requested_paths = gateway_stub + bare = "{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}" + stdout, stderr = _run_opencode(tmp_path, bare, origin, requested_paths) + assert requested_paths, "the CLI issued no request to the gateway stub" + assert requested_paths[0] == "/chat/completions" + assert requested_paths[0] != GATEWAY_SERVED_ROUTE + combined = f"{stdout}\n{stderr}".casefold() + assert GATEWAY_ROUTE_NOT_FOUND_MESSAGE in combined diff --git a/tests/test_pr_review_autofix_nvidia_nim_contract.py b/tests/test_pr_review_autofix_nvidia_nim_contract.py index c2d7c26082..9f56754387 100644 --- a/tests/test_pr_review_autofix_nvidia_nim_contract.py +++ b/tests/test_pr_review_autofix_nvidia_nim_contract.py @@ -17,7 +17,7 @@ DOCTORING_RECORD = Path("docs/doctoring/hourly-nvidia-nim-autofix.md") CHANGELOG = Path("CHANGELOG.md") REVIEW_DISPATCH_WORKFLOW = Path(".github/workflows/opencode-review-dispatch.yml") -REVIEW_DISPATCH_BLOB_SHA = "1f8e74bc8683ec54510fbabca71d8322b93fe9ad" +REVIEW_DISPATCH_BLOB_SHA = "ca90c602d95512ccba3daa581d44464e8871c63c" def _workflow_text(path: Path) -> str: @@ -44,7 +44,7 @@ def test_scheduled_autofix_routes_through_contextual_orchestrator() -> None: '"orchestrator/free": {', '"reasoningEffort": "high"', '"npm": "@ai-sdk/openai-compatible"', - '"baseURL": "{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}"', + '"baseURL": "{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}/v1"', '"apiKey": "{env:CONTEXTUAL_ORCHESTRATOR_TOKEN}"', "contextual_orchestrator_review_sidecar.sh", "BYTEZ_API_KEY: ${{ secrets.BYTEZ_API_KEY }}", diff --git a/tests/test_review_failure_taxonomy_contract.py b/tests/test_review_failure_taxonomy_contract.py new file mode 100644 index 0000000000..bc2b599f96 --- /dev/null +++ b/tests/test_review_failure_taxonomy_contract.py @@ -0,0 +1,130 @@ +"""Contract tests separating gateway routing, tooling, and dispatch failures. + +Three distinct failure classes were collapsed into one "provider unavailable" +reading during the 2026-09-21 review outage: + +* OpenCode reached the vendored gateway but posted to an unprefixed path, + so the gateway answered ``route_not_found`` and OpenCode surfaced its + message verbatim as ``Error: not found``. The provider credentials were + healthy throughout. +* Strix emitted Caido GraphQL tooling errors alongside genuine provider + rate limits, and the workflow reported only the provider class. +* OpenCode Review Dispatch rejected a repository_dispatch before the + sidecar existed, which is neither a gateway nor a provider outcome. + +These contracts keep each class independently observable. +""" + +from __future__ import annotations + +import json +from pathlib import Path +import re + +_ORG_REPO_ROOT = Path(__file__).resolve().parents[1] + +AUTOFIX_WORKFLOW = _ORG_REPO_ROOT / ".github/workflows/pr-review-autofix.yml" +OPENCODE_DISPATCH_WORKFLOW = ( + _ORG_REPO_ROOT / ".github/workflows/opencode-review-dispatch.yml" +) +STRIX_WORKFLOW = _ORG_REPO_ROOT / ".github/workflows/strix.yml" +OPENCODE_CONFIG = _ORG_REPO_ROOT / "opencode.jsonc" + +GATEWAY_SERVED_CHAT_PATH = "/v1/chat/completions" +EXPECTED_BASE_URL = "{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}/v1" + + +def _read(path: Path) -> str: + """Return one tracked contract file as UTF-8 text.""" + return path.read_text(encoding="utf-8") + + +def _generated_opencode_configs(workflow_text: str) -> list[dict]: + """Return every ``jq -n`` generated OpenCode config in one workflow.""" + matches = re.findall( + r"jq -n(?:[^']*)'(\{.*?\})' >\"\$\{[A-Z_]+\}/opencode\.jsonc\"", + workflow_text, + re.DOTALL, + ) + return [json.loads(match) for match in matches] + + +def _strip_jsonc_comments(text: str) -> str: + """Drop ``//`` line comments so a JSONC config parses as JSON.""" + return "\n".join( + line for line in text.splitlines() if not line.lstrip().startswith("//") + ) + + +def test_autofix_opencode_provider_targets_the_served_gateway_path() -> None: + """The autofix provider baseURL must resolve to the gateway's /v1 routes.""" + configs = _generated_opencode_configs(_read(AUTOFIX_WORKFLOW)) + assert configs, "no generated OpenCode config found in the autofix workflow" + for config in configs: + options = config["provider"]["contextual-orchestrator"]["options"] + assert options["baseURL"] == EXPECTED_BASE_URL + + +def test_dispatch_opencode_provider_targets_the_served_gateway_path() -> None: + """The dispatch gateway overlay must carry the same /v1 prefix. + + The dispatch workflow writes a provider-free base config and then layers + the gateway provider on with a second ``jq`` filter, so the assertion is + on every ``baseURL`` the workflow binds for that provider. + """ + workflow = _read(OPENCODE_DISPATCH_WORKFLOW) + bound = re.findall( + r'"baseURL": "(\{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL\}[^"]*)"', workflow + ) + assert bound, "the dispatch workflow binds no gateway baseURL" + assert set(bound) == {EXPECTED_BASE_URL} + + +def test_repository_opencode_config_targets_the_served_gateway_path() -> None: + """The tracked reviewer config must not drop the gateway's /v1 prefix.""" + config = json.loads(_strip_jsonc_comments(_read(OPENCODE_CONFIG))) + options = config["provider"]["contextual-orchestrator"]["options"] + assert options["baseURL"] == EXPECTED_BASE_URL + + +def test_strix_reports_caido_tooling_failures_as_their_own_class() -> None: + """Caido GraphQL breakage must not be reported only as a provider outage.""" + workflow = _read(STRIX_WORKFLOW) + assert "tooling_error_signal=" in workflow + for signature in ( + "Invalid HTTPQL query", + "Failed to parse cursor", + "TransportQueryError", + ): + assert signature in workflow + assert "STRIX_TOOLING_ERROR" in workflow + tooling_index = workflow.index("STRIX_TOOLING_ERROR") + provider_index = workflow.index("STRIX_PROVIDER_UNAVAILABLE::Strix could not") + assert tooling_index < provider_index, ( + "the tooling class must be emitted before the provider verdict so a " + "scanner defect is never filed only as a provider outage" + ) + + +def test_dispatch_admission_failures_are_not_provider_failures() -> None: + """Dispatch admission rejections must stay outside the provider vocabulary.""" + workflow = _read(OPENCODE_DISPATCH_WORKFLOW) + admission_messages = ( + "repository_dispatch authorization rejected", + "repository_dispatch metadata does not match the live pull request", + ) + for message in admission_messages: + assert message in workflow + line = next( + candidate + for candidate in workflow.splitlines() + if message in candidate + ) + assert "provider" not in line.lower() + assert "orchestrator" not in line.lower() + sidecar_index = workflow.index("Provision contextual-orchestrator review sidecar") + for message in admission_messages: + assert workflow.index(message) < sidecar_index, ( + "dispatch admission runs before any sidecar exists, so its failure " + "cannot be attributed to the gateway or a provider" + )