diff --git a/CHANGELOG.md b/CHANGELOG.md index bf30091dd..18b08530e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Semantic Versioning where the repository publishes a release. - Added a trusted pull-request comment router for `@cwl-noema-review` and review-only `@opencode-agent` dispatches, with an organization sweep, exact-head receipts, repository allowlisting, fixed runners, immutable checkout pins, and a permanent 100% statement/branch/docstring quality gate. - Added exact-base `uv.lock` materialization that reconstructs standalone nested projects with a checksum-pinned official `uv` exporter, isolated frozen/offline execution, strict exact-pin and SHA-256 output validation, and complete Python 3.10/3.14 quality evidence. +- Added an auditable, fail-closed base-branch policy that lets explicitly `public_equivalent` private repositories prepend the anonymous OpenCode free-model pool without allowing a pull request to authorize its own external processing. ### Fixed @@ -18,3 +19,7 @@ Semantic Versioning where the repository publishes a release. - Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped. - Bound both trusted-uv quality jobs to `github.event.pull_request.head.sha` and added a permanent two-checkout regression contract so exact-head compatibility, coverage, docstring, and compilation claims cannot silently measure GitHub's generated pull-request merge revision. - Made Strix treat only a single LiteLLM provider-error line containing NVIDIA NIM context and model-catalog 404 evidence as cross-model fallback evidence, rejecting cross-line signal assembly and provider-like target source literals; moved the public default to Nemotron 3 Super 120B and added a second NVIDIA hosted candidate before GitHub Models without neutralizing reported vulnerabilities. + +### Security + +- Scoped every OpenCode child process to its selected provider credential, stripped GitHub and Actions OIDC/runtime credentials from all model processes, and ensured anonymous free models, exports, and unknown provider prefixes inherit no provider keys. diff --git a/docs/doctoring/opencode-private-free-model-policy.md b/docs/doctoring/opencode-private-free-model-policy.md new file mode 100644 index 000000000..8f53e2e65 --- /dev/null +++ b/docs/doctoring/opencode-private-free-model-policy.md @@ -0,0 +1,228 @@ +# OpenCode private-repository free-model policy + +**Status:** Implemented design decision +**Decision date:** 2026-08-08 +**Scope:** `ContextualWisdomLab/.github` OpenCode review control plane + +## Decision + +A private repository may use the anonymous `opencode-free/*` review pool only when +its trusted pull-request base commit contains the exact policy file below and the +reviewed head does not add, remove, rename, chmod, or modify that file. + +```text +.github/opencode-private-free-models.json +``` + +```json +{ + "schema_version": 1, + "allow_private_free_models": true, + "repository_data_classification": "public_equivalent", + "external_model_data_use_accepted": true +} +``` + +The declaration means all tracked repository content that OpenCode can read is +approved for processing under the external free-model terms as though it were +public. It does **not** mean the repository becomes public, and it is not a +claim that a scanner proved the absence of every secret or confidential fact. + +The pull request that introduces or changes the policy remains ineligible. The +policy takes effect only after that change has passed normal review and reached +the base branch, on a subsequent pull request. Protect this file with normal +branch protection and, where available, `CODEOWNERS` review. + +## Why an explicit policy is required + +Repository visibility and data sensitivity are different attributes. A private +repository may contain only non-sensitive open-source work, while another may +contain customer data, personal data, unreleased intellectual property, access +credentials, or confidential architecture. The absence of configured GitHub +Actions secrets does not classify the source tree. + +GitHub secret scanning is an important independent control, but it detects +supported patterns rather than proving that no confidential information exists. +For organization-owned private and internal repositories, secret scanning +requires GitHub Secret Protection on an eligible plan. Therefore, model egress +is enabled by an auditable data-owner declaration, not by a heuristic scan or by +repository visibility alone. + +## Provider-data and catalog boundary + +OpenCode documents free models as limited offerings used to collect feedback or +improve models. Its privacy documentation warns that some free endpoints may +retain or use collected data and that personal or confidential data must not be +submitted. Accordingly, the policy is restricted to `public_equivalent` +repositories and requires explicit acceptance of external-model data use. + +The governed anonymous pool is synchronized to the zero-cost OpenCode Zen catalog +published in the primary Zen documentation. At the current decision revision it +contains exactly these seven aliases: + +1. `opencode-free/nemotron-3-ultra-free` +2. `opencode-free/deepseek-v4-flash-free` +3. `opencode-free/north-mini-code-free` +4. `opencode-free/laguna-s-2.1-free` +5. `opencode-free/ling-3.0-flash-free` +6. `opencode-free/big-pickle` +7. `opencode-free/mimo-v2.5-free` + +Aliases previously carried as `hy3-free`, `minimax-m3-free`, `glm-5-free`, +`kimi-k2.5-free`, and `qwen3.6-plus-free` are not in the current documented +zero-cost catalog and are therefore removed before model selection. The wrapper +never infers that an arbitrary `opencode-free/*` prefix is actually free. A +catalog change requires an independently reviewable source update. + +Candidate availability is still runtime-dependent. A provider rejection or +retirement remains ordinary bounded fallback evidence; it does not weaken review +or merge gates. + +## Repository-visibility boundary + +Preconfigured anonymous candidates are not themselves authorization. The wrapper +first needs positive visibility evidence: + +- a trusted caller may provide `OPENCODE_REPOSITORY_IS_PRIVATE=false`; or +- when that signal is absent, the wrapper may prove only the **public** case by + performing a credential-free `git ls-remote` against a strictly validated + `https://github.com/ContextualWisdomLab/[.git]` origin. + +`true`, malformed visibility input, private/auth-required Git access, timeout, +transport failure, missing remote metadata, or any other indeterminate outcome is +fail-closed. The wrapper removes every preconfigured anonymous candidate and the +unchanged trusted-base policy becomes the sole re-enable path. The public probe +runs with GitHub, Actions, model-provider, and OIDC credentials removed and with +Git credential helpers disabled. + +This preserves public-repository behavior without treating an untrusted candidate +list as visibility evidence and prevents a private caller from bypassing policy +by pre-populating `OPENCODE_MODEL_CANDIDATES`. + +## Credential boundary + +Each OpenCode subprocess receives only the credential for its selected provider. +In particular, an anonymous `opencode-free/*` process receives none of these +values: + +- GitHub tokens +- GitHub Actions OIDC request credentials +- GitHub Actions runtime, cache, or results credentials +- OpenCode application tokens +- NVIDIA NIM keys +- OpenCode Zen keys +- OpenAI keys +- OpenRouter keys +- GitHub Models tokens + +Session export runs without any provider credential. Unknown future provider +prefixes also default to zero provider credentials until they are explicitly +classified. The guard recognizes OpenCode's long and short model selectors +(`--model`, `--model=`, `-m`, and `-m=`), rejects duplicate or missing model +selectors, and stops option parsing at `--` so argument text cannot accidentally +change credential selection. + +The model remains read-only under the existing OpenCode review agent contract. +Credential isolation does not make confidential source safe to send to an +external model; the repository-level data classification remains the primary +eligibility control. + +## Fail-closed validation + +The policy checker: + +- accepts only full 40-character base and head commit SHAs; +- reads the policy directly from the immutable base Git tree; +- rejects a policy changed by the current head; +- requires the `git ls-tree -z` response to be exactly one NUL-terminated record, + rejecting truncated or extra records rather than reconstructing delimiters; +- accepts only one regular, non-executable `100644` blob at the fixed path; +- limits the blob to 4,096 bytes; +- requires strict UTF-8 and JSON without duplicate keys; +- rejects missing or unknown fields and requires the exact canonical values; +- ignores system and user Git configuration and disables hooks and filesystem + monitors during evaluation; +- removes preconfigured anonymous candidates on private or unverified calls before + policy evaluation; and +- leaves the existing keyed/private fallback pool unchanged on every denial or + local evaluation error. + +The model-pool boundary also validates integer runtime, retry, cycle, and export +controls before shell arithmetic or `timeout` consumption. Malformed values fall +back to reviewed defaults rather than reaching Bash arithmetic or busy-looping a +runner. + +## Operating procedure + +1. Confirm the repository contains no credentials, personal data, customer data, + confidential documents, restricted source, or other data prohibited by the + free-model terms. +2. Resolve active secret-scanning alerts and enable Secret Protection, push + protection, generic patterns, and organization-specific custom patterns where + available. +3. Add the exact policy file in a separately reviewed pull request. +4. Merge that policy through normal branch protection. Its own pull request will + not use the private free pool. +5. On a later pull request, verify the OpenCode log records that the unchanged + trusted base policy enabled the anonymous candidates and verify the selected + child environment contains no GitHub, Actions, OIDC, or provider credentials. +6. Run a private negative control without the policy and verify anonymous + candidates remain disabled while configured keyed fallbacks remain available. +7. To disable the feature, remove or change the policy through a normal pull + request. The change takes effect after merge; the policy-changing pull request + itself remains fail-closed. + +## Rejected alternatives + +### Infer eligibility from missing Actions secrets + +Rejected because repository source, history, fixtures, issues, and generated +review evidence may be confidential even when no Actions secret is configured. + +### Let the current pull-request head add an opt-in marker + +Rejected because untrusted code could authorize its own external disclosure. +The marker must already exist on the base and remain unchanged in the head. + +### Trust a preconfigured `opencode-free/*` candidate as proof of eligibility + +Rejected because candidate text is not a data-classification or visibility +signal. Private or unverified callers must pass the immutable-base policy gate. + +### Send all provider keys and rely on agent instructions + +Rejected because a model process does not need unrelated credentials. Provider +selection is enforced in the process environment rather than by prompt text. + +### Treat secret scanning as a proof of public-equivalent data + +Rejected because secret scanning is a defense-in-depth detector, not a complete +information-classification system. + +## Verification evidence + +The implementation includes tests for valid base policy activation, missing and +self-added policies, head mutations, unknown and weaker declarations, duplicate +JSON keys, symlinks, oversized blobs, malformed UTF-8 and JSON, Git failures, +truncated and extra `ls-tree -z` records, provider-specific credential retention, +anonymous free credential removal, short and long model selectors, option +termination, export isolation, unknown-provider fail-safe behavior, private +preconfigured-free bypass rejection, catalog filtering, visibility fail-closed +behavior, runtime integer controls, and wrapper ordering. + +Operational acceptance remains separate from code-level tests. Issue #833 tracks +the required protected-base private canary, negative control, credential-absence +evidence, schema/evidence validation, independent review/protection gates, and +rollback rehearsal. + +## References + +GitHub. (n.d.-a). *Enabling secret scanning for your repository*. GitHub Docs. +Retrieved August 8, 2026, from +https://docs.github.com/en/code-security/how-tos/secure-your-secrets/detect-secret-leaks/enable-secret-scanning + +GitHub. (n.d.-b). *Secrets*. GitHub Docs. Retrieved August 8, 2026, from +https://docs.github.com/en/actions/concepts/security/secrets + +OpenCode. (n.d.). *Zen*. Retrieved August 9, 2026, from +https://opencode.ai/docs/zen diff --git a/docs/examples/opencode-private-free-models.json b/docs/examples/opencode-private-free-models.json new file mode 100644 index 000000000..7c4b446e9 --- /dev/null +++ b/docs/examples/opencode-private-free-models.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "allow_private_free_models": true, + "repository_data_classification": "public_equivalent", + "external_model_data_use_accepted": true +} diff --git a/scripts/ci/opencode_private_free_model_policy.py b/scripts/ci/opencode_private_free_model_policy.py new file mode 100755 index 000000000..43a00c785 --- /dev/null +++ b/scripts/ci/opencode_private_free_model_policy.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Validate a trusted base-branch opt-in for private free-model review. + +The checker reads only the fixed policy path from the pull request's base commit. +It refuses to enable free-model egress when the pull request changes that path, +so an untrusted head cannot opt itself into external processing. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import NoReturn + + +POLICY_PATH = ".github/opencode-private-free-models.json" +MAX_POLICY_BYTES = 4096 +COMMIT_SHA_PATTERN = re.compile(r"\A[0-9a-fA-F]{40}\Z") +EXPECTED_POLICY: dict[str, object] = { + "schema_version": 1, + "allow_private_free_models": True, + "repository_data_classification": "public_equivalent", + "external_model_data_use_accepted": True, +} + + +class PolicyDenied(RuntimeError): + """Expected fail-closed outcome for a missing or ineligible policy.""" + + +class PolicyEvaluationError(RuntimeError): + """Unexpected local error while evaluating the trusted Git tree.""" + + +class DuplicateJsonKey(ValueError): + """Raised when JSON contains ambiguous duplicate object keys.""" + + +@dataclass(frozen=True) +class GitBlobEntry: + """One exact regular blob entry returned by ``git ls-tree``.""" + + mode: str + object_type: str + object_sha: str + path: str + + +def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace: + """Parse bounded command-line inputs for one pull-request evaluation.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", required=True, type=Path) + parser.add_argument("--base-sha", required=True) + parser.add_argument("--head-sha", required=True) + parser.add_argument( + "--explain", + action="store_true", + help="emit a bounded eligibility or denial reason", + ) + return parser.parse_args(argv) + + +def isolated_git_environment() -> dict[str, str]: + """Return a Git environment that ignores user and system configuration.""" + environment = os.environ.copy() + environment.update( + { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_TERMINAL_PROMPT": "0", + } + ) + return environment + + +def run_git(repo_root: Path, *arguments: str, check: bool = True) -> subprocess.CompletedProcess[bytes]: + """Run a noninteractive Git command against the materialized repository.""" + command = [ + "git", + "-c", + f"safe.directory={repo_root}", + "-c", + "core.hooksPath=/dev/null", + "-c", + "core.fsmonitor=false", + "-C", + str(repo_root), + *arguments, + ] + try: + result = subprocess.run( + command, + env=isolated_git_environment(), + capture_output=True, + check=False, + timeout=20, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise PolicyEvaluationError("Git policy evaluation could not run") from exc + if check and result.returncode != 0: + raise PolicyEvaluationError("Git policy evaluation failed") + return result + + +def validate_commit_sha(value: str, label: str) -> str: + """Validate one immutable full commit SHA without accepting revision syntax.""" + if not COMMIT_SHA_PATTERN.fullmatch(value): + raise PolicyEvaluationError(f"{label} must be a full 40-character commit SHA") + return value.lower() + + +def verify_commit(repo_root: Path, commit_sha: str) -> None: + """Require the supplied SHA to resolve to a commit in the local object store.""" + run_git(repo_root, "cat-file", "-e", f"{commit_sha}^{{commit}}") + + +def require_policy_unchanged(repo_root: Path, base_sha: str, head_sha: str) -> None: + """Deny when the reviewed head adds, removes, or modifies the policy path.""" + result = run_git( + repo_root, + "diff", + "--quiet", + "--no-ext-diff", + base_sha, + head_sha, + "--", + POLICY_PATH, + check=False, + ) + if result.returncode == 1: + raise PolicyDenied( + "policy changed in the reviewed head; merge it before a later PR can opt in" + ) + if result.returncode != 0: + raise PolicyEvaluationError("Git could not compare the policy path") + + +def parse_ls_tree_entry(raw_entry: bytes) -> GitBlobEntry: + """Parse exactly one NUL-terminated ``git ls-tree`` record.""" + if not raw_entry.endswith(b"\x00"): + raise PolicyEvaluationError("Git returned an unterminated policy tree entry") + record = raw_entry[:-1] + try: + metadata, raw_path = record.split(b"\t", 1) + mode, object_type, object_sha = metadata.decode("ascii").split(" ", 2) + path = raw_path.decode("utf-8") + except (UnicodeDecodeError, ValueError) as exc: + raise PolicyEvaluationError("Git returned an invalid policy tree entry") from exc + return GitBlobEntry( + mode=mode, + object_type=object_type, + object_sha=object_sha, + path=path, + ) + + +def policy_blob_entry(repo_root: Path, base_sha: str) -> GitBlobEntry: + """Return the base commit's fixed policy entry after strict mode checks.""" + result = run_git(repo_root, "ls-tree", "-z", base_sha, "--", POLICY_PATH) + if not result.stdout: + raise PolicyDenied(f"trusted base policy is missing at {POLICY_PATH}") + if not result.stdout.endswith(b"\x00"): + raise PolicyEvaluationError("Git returned an unterminated policy tree entry") + entries = result.stdout[:-1].split(b"\x00") + if len(entries) != 1 or not entries[0]: + raise PolicyEvaluationError("Git returned more than one policy tree entry") + entry = parse_ls_tree_entry(entries[0] + b"\x00") + if entry.path != POLICY_PATH: + raise PolicyEvaluationError("Git returned a different policy path") + if entry.mode != "100644" or entry.object_type != "blob": + raise PolicyDenied("trusted base policy must be one regular non-executable file") + if not COMMIT_SHA_PATTERN.fullmatch(entry.object_sha): + raise PolicyEvaluationError("Git returned an invalid policy blob SHA") + return entry + + +def read_policy_blob(repo_root: Path, entry: GitBlobEntry) -> bytes: + """Read a bounded immutable blob directly from the trusted base tree.""" + size_result = run_git(repo_root, "cat-file", "-s", entry.object_sha) + try: + size = int(size_result.stdout.decode("ascii").strip()) + except (UnicodeDecodeError, ValueError) as exc: + raise PolicyEvaluationError("Git returned an invalid policy blob size") from exc + if size > MAX_POLICY_BYTES: + raise PolicyDenied(f"trusted base policy exceeds {MAX_POLICY_BYTES} bytes") + blob_result = run_git(repo_root, "cat-file", "blob", entry.object_sha) + if len(blob_result.stdout) != size: + raise PolicyEvaluationError("Git returned a truncated policy blob") + return blob_result.stdout + + +def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: + """Build a JSON object while rejecting every duplicate key.""" + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise DuplicateJsonKey(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def parse_policy(raw_policy: bytes) -> dict[str, object]: + """Decode strict UTF-8 JSON and require the canonical policy declaration.""" + try: + text = raw_policy.decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise PolicyDenied("trusted base policy must be valid UTF-8") from exc + try: + value = json.loads(text, object_pairs_hook=reject_duplicate_keys) + except DuplicateJsonKey as exc: + raise PolicyDenied(str(exc)) from exc + except json.JSONDecodeError as exc: + raise PolicyDenied("trusted base policy must be valid JSON") from exc + if not isinstance(value, dict) or value.keys() != EXPECTED_POLICY.keys(): + raise PolicyDenied("trusted base policy must exactly match the canonical declaration") + for key, expected in EXPECTED_POLICY.items(): + actual = value[key] + if type(actual) is not type(expected) or actual != expected: + raise PolicyDenied( + "trusted base policy must exactly match the canonical declaration" + ) + return value + + +def evaluate_policy(repo_root: Path, base_sha: str, head_sha: str) -> None: + """Raise unless the immutable base policy safely enables free-model egress.""" + resolved_root = repo_root.resolve(strict=True) + if not resolved_root.is_dir() or not (resolved_root / ".git").exists(): + raise PolicyDenied("materialized source is not a Git repository") + normalized_base = validate_commit_sha(base_sha, "base SHA") + normalized_head = validate_commit_sha(head_sha, "head SHA") + verify_commit(resolved_root, normalized_base) + verify_commit(resolved_root, normalized_head) + require_policy_unchanged(resolved_root, normalized_base, normalized_head) + entry = policy_blob_entry(resolved_root, normalized_base) + parse_policy(read_policy_blob(resolved_root, entry)) + + +def deny(reason: str, explain: bool) -> NoReturn: + """Exit with the expected ineligible status and optional bounded reason.""" + if explain: + print(f"ineligible: {reason}", file=sys.stderr) + raise SystemExit(1) + + +def main(argv: list[str] | None = None) -> int: + """Run one fail-closed policy evaluation.""" + arguments = parse_arguments(argv) + try: + evaluate_policy(arguments.repo_root, arguments.base_sha, arguments.head_sha) + except (FileNotFoundError, PolicyDenied) as exc: + deny(str(exc) or "policy denied", arguments.explain) + except PolicyEvaluationError as exc: + if arguments.explain: + print(f"error: {exc}", file=sys.stderr) + return 2 + if arguments.explain: + print(f"eligible: trusted unchanged base policy at {POLICY_PATH}") + return 0 + + +if __name__ == "__main__": # pragma: no cover - exercised by CLI integration + raise SystemExit(main()) diff --git a/scripts/ci/opencode_provider_guard.sh b/scripts/ci/opencode_provider_guard.sh new file mode 100755 index 000000000..fcd2e4078 --- /dev/null +++ b/scripts/ci/opencode_provider_guard.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Execute one OpenCode command with provider-scoped credentials only. +set -euo pipefail + +if [ "$#" -lt 1 ]; then + printf 'Usage: %s \n' "${0##*/}" >&2 + exit 64 +fi + +real_opencode="${OPENCODE_REAL_BIN:-}" +if [ -z "$real_opencode" ] || [ ! -x "$real_opencode" ]; then + printf 'OPENCODE_REAL_BIN must name the executable captured before guard activation.\n' >&2 + exit 69 +fi + +model_candidate="" +model_argument_count=0 +expect_model_value=0 +option_parsing=1 +for argument in "$@"; do + if [ "$expect_model_value" -eq 1 ]; then + case "$argument" in + --) + printf '%s\n' '--model/-m requires a model candidate.' >&2 + exit 64 + ;; + --model | -m | --model=* | -m=*) + printf 'exactly one model selector is allowed.\n' >&2 + exit 64 + ;; + *) + model_candidate="$argument" + expect_model_value=0 + ;; + esac + continue + fi + if [ "$option_parsing" -eq 0 ]; then + continue + fi + case "$argument" in + --) + option_parsing=0 + ;; + --model | -m) + model_argument_count=$((model_argument_count + 1)) + expect_model_value=1 + ;; + --model=*) + model_argument_count=$((model_argument_count + 1)) + model_candidate="${argument#--model=}" + ;; + -m=*) + model_argument_count=$((model_argument_count + 1)) + model_candidate="${argument#-m=}" + ;; + esac + if [ "$model_argument_count" -gt 1 ]; then + printf 'exactly one model selector is allowed.\n' >&2 + exit 64 + fi +done +if [ "$expect_model_value" -eq 1 ] || + { [ "$model_argument_count" -gt 0 ] && [ -z "$model_candidate" ]; }; then + printf '%s\n' '--model/-m requires a model candidate.' >&2 + exit 64 +fi + +# GitHub and Actions credentials are never needed by a read-only model process. +environment=( + env + -u GH_TOKEN + -u GITHUB_TOKEN + -u OPENCODE_APP_TOKEN + -u ACTIONS_ID_TOKEN_REQUEST_TOKEN + -u ACTIONS_ID_TOKEN_REQUEST_URL + -u ACTIONS_RUNTIME_TOKEN + -u ACTIONS_CACHE_URL + -u ACTIONS_RESULTS_URL + -u ACTIONS_RUNTIME_URL +) + +# Start with no provider credential, then keep only the selected provider's key. +case "$model_candidate" in + nvidia-nim/*) + environment+=( + -u STRIX_GITHUB_MODELS_TOKEN + -u OPENCODE_API_KEY + -u OPENAI_API_KEY + -u OPENROUTER_API_KEY + ) + ;; + opencode/*) + environment+=( + -u STRIX_GITHUB_MODELS_TOKEN + -u OPENAI_API_KEY + -u OPENROUTER_API_KEY + -u NVIDIA_API_KEY + -u NVIDIA_NIM_API_KEY + ) + ;; + openai/*) + environment+=( + -u STRIX_GITHUB_MODELS_TOKEN + -u OPENCODE_API_KEY + -u OPENROUTER_API_KEY + -u NVIDIA_API_KEY + -u NVIDIA_NIM_API_KEY + ) + ;; + openrouter/*) + environment+=( + -u STRIX_GITHUB_MODELS_TOKEN + -u OPENCODE_API_KEY + -u OPENAI_API_KEY + -u NVIDIA_API_KEY + -u NVIDIA_NIM_API_KEY + ) + ;; + github-models/*) + environment+=( + -u OPENCODE_API_KEY + -u OPENAI_API_KEY + -u OPENROUTER_API_KEY + -u NVIDIA_API_KEY + -u NVIDIA_NIM_API_KEY + ) + ;; + *) + environment+=( + -u STRIX_GITHUB_MODELS_TOKEN + -u OPENCODE_API_KEY + -u OPENAI_API_KEY + -u OPENROUTER_API_KEY + -u NVIDIA_API_KEY + -u NVIDIA_NIM_API_KEY + ) + ;; +esac + +exec "${environment[@]}" "$real_opencode" "$@" diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh old mode 100644 new mode 100755 index 986982e9a..effc44938 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -1,808 +1,322 @@ #!/usr/bin/env bash +# Add governed private-repository free candidates before the stable model-pool implementation. set -euo pipefail -: "${GITHUB_OUTPUT:=/dev/null}" - -record_review_status() { - printf 'review_status=%s\n' "$1" >>"$GITHUB_OUTPUT" -} - -record_review_model() { - printf 'review_model=%s\n' "$1" >>"$GITHUB_OUTPUT" -} - -record_pool_exhausted() { - printf 'OpenCode model pool exhausted before producing a valid control conclusion.\n' - record_review_model "" - record_review_status "exhausted" -} - -finish_pool_without_model() { - record_pool_exhausted - return 1 -} - -normalize_opencode_output() { - local output_file="$1" - - # Validate a throwaway copy, never the file itself. The publish step runs - # opencode_review_normalize_output.py on the model output, and that script - # REWRITES its input in place (it is not idempotent). If the pool normalized - # output_file directly, the publish step would normalize the already-rewritten - # content a second time and fail with "Selected successful OpenCode output did - # not include a valid control conclusion", ending the run instead of falling - # through to the next model. Mirror the publish step exactly — ANSI-strip a - # copy, then normalize — so the pool only records success for output the - # publish step will accept, and leave output_file pristine for the publish - # step to normalize itself. - local probe rc - probe="$(mktemp)" - perl -pe 's/\x1b\[[0-9;?]*[A-Za-z]//g' "$output_file" >"$probe" 2>/dev/null || cp "$output_file" "$probe" - - if python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_review_normalize_output.py" \ - "$HEAD_SHA" "$RUN_ID" "$RUN_ATTEMPT" "$probe"; then - bash "$GITHUB_WORKSPACE/scripts/ci/opencode_review_approve_gate.sh" \ - "$HEAD_SHA" "$RUN_ID" "$RUN_ATTEMPT" "$probe" >/dev/null - rc=$? - else - rc=1 - fi - rm -f "$probe" - return "$rc" -} - -backoff_sleep() { - local attempt="$1" - local initial max_sleep attempt_value - local sleep_for - if ! is_non_negative_integer "$attempt" || [ "$((10#$attempt))" -lt 1 ] || [ "$((10#$attempt))" -gt 30 ]; then - attempt="1" - fi - initial="$(env_integer_or_default OPENCODE_BACKOFF_INITIAL_SECONDS 20)" - max_sleep="$(env_integer_or_default OPENCODE_BACKOFF_MAX_SECONDS 300)" - attempt_value=$((10#$attempt)) - initial=$((10#$initial)) - max_sleep=$((10#$max_sleep)) - sleep_for=$((initial * (1 << (attempt_value - 1)))) - if [ "$sleep_for" -gt "$max_sleep" ]; then - sleep_for="$max_sleep" - fi - printf '%s\n' "$sleep_for" -} - -is_non_negative_integer() { - case "${1:-}" in - "" | *[!0-9]* | ??????????*) return 1 ;; - *) return 0 ;; - esac +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +implementation="$script_dir/run_opencode_review_model_pool_impl.sh" +policy_checker="$script_dir/opencode_private_free_model_policy.py" +provider_guard="$script_dir/opencode_provider_guard.sh" +# Keep this list synchronized to the currently documented zero-cost OpenCode Zen +# catalog. Unknown or retired `opencode-free/*` aliases are removed before the +# model pool so a pricing/catalog change cannot silently become paid egress. +anonymous_free_candidates="opencode-free/nemotron-3-ultra-free opencode-free/deepseek-v4-flash-free opencode-free/north-mini-code-free opencode-free/laguna-s-2.1-free opencode-free/ling-3.0-flash-free opencode-free/big-pickle opencode-free/mimo-v2.5-free" + +die() { + printf '%s\n' "$1" >&2 + exit 1 } -env_integer_or_default() { - local name="$1" - local default_value="$2" - local value="${!name:-}" - - if is_non_negative_integer "$value"; then - printf '%s\n' "$value" - else - printf '%s\n' "$default_value" - fi +[ -f "$implementation" ] || die "OpenCode model-pool implementation is missing." +[ -f "$policy_checker" ] || die "OpenCode private free-model policy checker is missing." +[ -f "$provider_guard" ] || die "OpenCode provider credential guard is missing." + +verify_delegated_implementation_contract() { + # These markers are both a runtime integrity contract for the delegated stable + # implementation and the source-level compatibility surface consumed by the + # central regression suite. A missing marker indicates a truncated, stale, or + # incompatible sibling implementation and must fail before any model process. + local marker + local required_markers=( + 'timeout --kill-after=30s "${run_timeout_seconds}s"' + 'assert_reasoning_effort_for_candidate' + 'assert_opencode_reasoning_effort.py' + '--config opencode.jsonc' + 'timeout --kill-after=15s "${export_timeout_seconds}s"' + 'opencode export' + 'env -u GH_TOKEN -u GITHUB_TOKEN -u OPENCODE_APP_TOKEN' + 'session export did not complete within %ss' + 'Follow the complete review contract' + 'packet-first entry point' + 'Current-head evidence packet' + 'not a generic model-exhaustion message' + 'is_context_overflow_failure' + 'tokens_limit_reached' + 'skipping remaining attempts for this model' + 'env_integer_or_default OPENCODE_RUN_TIMEOUT_SECONDS 3600' + 'OPENCODE_DYNAMIC_RUN_TIMEOUT_CAP_SECONDS 3600' + 'OPENCODE_FREE_RUN_TIMEOUT_SECONDS 3600' + 'OPENCODE_NVIDIA_NIM_RUN_TIMEOUT_SECONDS 180' + 'OPENCODE_NVIDIA_NIM_TOTAL_BUDGET_SECONDS 900' + 'OpenCode %s attempt %s/%s failed with exit %s.' + 'using %ss run timeout with %ss retry budget remaining' + 'timed out after %ss; falling through within the remaining retry budget' + 'emit_sanitized_opencode_failure_detail' + 'OpenCode provider failure metadata' + 'provider-controlled content suppressed' + '"adversarial_validation"' + 'Central review-process evidence fallback eligible' + 'provider delay is logged before the publish fallback evaluates current-head peer evidence' + 'while :' + 'should_skip_model_candidate' + 'cap_model_run_timeout' + 'bounded failover window' + 'finish_pool_without_model' + 'is_low_sensitivity_candidate' + 'mini/nano review models are disabled' + 'OPENAI_API_KEY is not configured' + 'OPENROUTER_API_KEY is not configured' + 'scoped NVIDIA_NIM_API_KEY is not configured' + 'configured max cycle count' + 'exponential backoff' + 'write_schema_repair_prompt' + 'is_schema_repair_candidate' + 'NVIDIA_NIM_API_KEY' + 'OpenCode dynamic review cadence selected %ss per attempt' + 'count_changed_files_for_cadence' + 'OpenCode model pool has no configured model candidates.' + 'env_integer_or_default OPENCODE_TOTAL_RETRY_BUDGET_SECONDS 1500' + 'completed a full model-candidate cycle without a valid control conclusion' + 'retry budget/GitHub Actions job timeout' + 'OpenCode model pool exhausted before producing a valid control conclusion.' + 'record_review_status "exhausted"' + 'Never emit raw tool-call markup' + 'Do not request changes solely because your tool call' + 'never use line 0' + ) + + for marker in "${required_markers[@]}"; do + grep -Fq -- "$marker" "$implementation" || + die "OpenCode delegated model-pool implementation contract is incomplete." + done } -cap_dynamic_cadence_for_queue() { - local timeout_cap budget_cap cycle_cap previous_run_timeout previous_budget_seconds previous_max_cycles - - timeout_cap="$(env_integer_or_default OPENCODE_DYNAMIC_RUN_TIMEOUT_CAP_SECONDS 3600)" - budget_cap="$(env_integer_or_default OPENCODE_DYNAMIC_TOTAL_BUDGET_CAP_SECONDS 7200)" - cycle_cap="$(env_integer_or_default OPENCODE_DYNAMIC_MAX_CYCLES_CAP 0)" - previous_run_timeout="$original_run_timeout" - previous_budget_seconds="$budget_seconds" - previous_max_cycles="$max_cycles" - - if [ "$timeout_cap" -gt 0 ] && [ "$original_run_timeout" -gt "$timeout_cap" ]; then - original_run_timeout="$timeout_cap" - fi - if [ "$budget_cap" -gt 0 ] && [ "$budget_seconds" -gt "$budget_cap" ]; then - budget_seconds="$budget_cap" - fi - if [ "$cycle_cap" -gt 0 ]; then - if [ "$max_cycles" -eq 0 ] || [ "$max_cycles" -gt "$cycle_cap" ]; then - max_cycles="$cycle_cap" - fi - fi - - if [ "$original_run_timeout" != "$previous_run_timeout" ] || - [ "$budget_seconds" != "$previous_budget_seconds" ] || - [ "$max_cycles" != "$previous_max_cycles" ]; then - printf 'OpenCode dynamic review cadence queue cap applied: per-attempt %ss -> %ss, total budget %ss -> %ss, max-cycles %s -> %s; set OPENCODE_DYNAMIC_*_CAP_SECONDS or OPENCODE_DYNAMIC_MAX_CYCLES_CAP to 0 to disable a specific queue cap.\n' \ - "$previous_run_timeout" "$original_run_timeout" \ - "$previous_budget_seconds" "$budget_seconds" \ - "$previous_max_cycles" "$max_cycles" - fi +normalize_delegated_integer_environment() { + local name default_value minimum value normalized + while read -r name default_value minimum; do + value="${!name:-}" + normalized="$value" + case "$value" in + "" | *[!0-9]* | ??????????*) normalized="$default_value" ;; + esac + if [ "$normalized" != "$default_value" ] || [ "$value" = "$default_value" ]; then + if [ "$minimum" -gt 0 ] && [ "$((10#$normalized))" -lt "$minimum" ]; then + normalized="$default_value" + fi + fi + if [ "$normalized" != "$value" ]; then + printf 'OpenCode normalized invalid integer environment %s to safe default %s.\n' \ + "$name" "$normalized" + fi + export "$name=$normalized" + done <<'EOF' +OPENCODE_RUN_TIMEOUT_SECONDS 3600 1 +OPENCODE_EXPORT_TIMEOUT_SECONDS 120 1 +OPENCODE_MODEL_ATTEMPTS 3 1 +OPENCODE_TOTAL_RETRY_BUDGET_SECONDS 1500 0 +OPENCODE_POOL_MAX_CYCLES 0 0 +OPENCODE_POOL_CYCLE_SLEEP_SECONDS 60 1 +OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_RUN_TIMEOUT_SECONDS 3600 1 +OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_TOTAL_BUDGET_SECONDS 3600 0 +OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_MAX_CYCLES 1 1 +EOF } -count_changed_files_for_cadence() { - local changed_files_file="${OPENCODE_CHANGED_FILES_FILE:-}" - - if [ -z "$changed_files_file" ] || [ ! -f "$changed_files_file" ]; then - return 1 - fi - awk 'NF { count += 1 } END { printf "%d\n", count + 0 }' "$changed_files_file" +candidate_list_contains_anonymous_free_model() { + local candidate + local -a candidates + read -r -a candidates <<<"${OPENCODE_MODEL_CANDIDATES:-}" + for candidate in "${candidates[@]}"; do + case "$candidate" in + opencode-free/*) + return 0 + ;; + esac + done + return 1 } -should_inline_prompt_evidence_excerpt() { - local model_candidate="$1" - - # GitHub Models OpenAI review endpoints currently reject request bodies - # above roughly 4000 tokens. Keep full evidence available as workspace - # files, but do not inline the excerpt for those candidates. - case "$model_candidate" in - github-models/openai/gpt-5 | github-models/openai/gpt-5-chat | github-models/openai/o3) - return 1 - ;; - *) - return 0 - ;; - esac +is_governed_anonymous_free_candidate() { + local candidate="$1" + case " $anonymous_free_candidates " in + *" $candidate "*) return 0 ;; + *) return 1 ;; + esac } -write_prompt() { - local model_candidate="$1" - local prompt_file="$2" - local intro - local contract_file - local evidence_excerpt_file - local evidence_file_in_workdir - - if [ -n "${OPENCODE_REVIEW_INTRO:-}" ]; then - intro="$OPENCODE_REVIEW_INTRO" - else - intro="Review PR #\${PR_NUMBER} in \${OPENCODE_SOURCE_WORKDIR} with \${model_candidate}." - fi - # Colon-safe: OpenRouter ":free" candidates would otherwise produce file - # names that Windows and actions/upload-artifact reject. - contract_file="$OPENCODE_REVIEW_WORKDIR/opencode-review-contract-${model_candidate//[\/:]/-}.md" - evidence_excerpt_file="$OPENCODE_REVIEW_WORKDIR/bounded-review-evidence-excerpt.md" - evidence_file_in_workdir="$OPENCODE_REVIEW_WORKDIR/bounded-review-evidence.md" - cp "$GITHUB_WORKSPACE/scripts/ci/opencode_review_prompt_template.md" "$contract_file" - OPENCODE_REVIEW_INTRO="$intro" \ - PROMPT_MODEL_CANDIDATE="$model_candidate" \ - python3 "$GITHUB_WORKSPACE/scripts/ci/render_opencode_prompt_template.py" "$contract_file" - - { - printf '%s\n\n' "$intro" - printf 'Follow the complete review contract in `%s`; use this launcher as a packet-first entry point, not as a reduced policy.\n' "$contract_file" - printf 'Read bounded review evidence from `%s` and source files from `%s` when tool access works.\n' "$OPENCODE_EVIDENCE_FILE" "$OPENCODE_SOURCE_WORKDIR" - printf 'Use the trusted review workspace `%s` for scripts, prompts, policy files, CodeGraph config, and validation helpers.\n\n' "$OPENCODE_REVIEW_WORKDIR" - if should_inline_prompt_evidence_excerpt "$model_candidate"; then - printf 'First review the current-head evidence excerpt in this prompt. Then inspect full evidence, changed files, focused related code, and configured structural/search tools when available.\n' - else - printf 'The current-head evidence excerpt is not inlined for this GitHub Models OpenAI candidate because that provider rejects large request bodies. First read `%s`, `%s`, changed files, focused related code, and configured structural/search tools before any conclusion.\n' "$evidence_file_in_workdir" "$evidence_excerpt_file" - fi - printf 'Never emit raw tool-call markup, MCP call syntax, function-call JSON, tool_call text, or a JSON array of tool calls. If tool calls or file reads are unavailable, do not emit progress notes or raw tool-call text.\n' - if should_inline_prompt_evidence_excerpt "$model_candidate"; then - printf 'If full-file reads do not execute, use the inlined evidence packet and its repeated current-head sections for Changed files, Focused changed hunks, Coverage execution evidence, Failed GitHub Check evidence, and unresolved thread evidence.\n' - else - printf 'If file reads do not execute for this non-inlined prompt, do not approve from memory or generic confidence. REQUEST_CHANGES only when the visible launcher text or executed file reads provide current-head evidence tied to a positive source/evidence line.\n' - fi - printf 'Do not request changes solely because your tool call, MCP call, or full-file read was not executed. Treat that as a review source limitation unless current-head evidence explicitly reports a materialization failure; any such finding must be tied to that evidence, not a generic model-exhaustion message. REQUEST_CHANGES findings must cite a positive source/evidence line; never use line 0.\n' - printf 'Always return a final control block instead of a progress summary. Return only the final review body.\n\n' - printf 'Adversarial evidence must state a concrete observed pass, failure, rejection, return value, exit code, or trace outcome and copy exactly one source-line-sha256=<64 lowercase hex> receipt with its matching path and line from the trusted receipt section; generic source-inspection or coverage-verification claims are invalid.\n' - printf 'Current-run identity values are head_sha=%s, run_id=%s, run_attempt=%s. Copy them into the one final control object required by the contract file.\n' "$HEAD_SHA" "$RUN_ID" "$RUN_ATTEMPT" - printf 'Do not quote, repeat, or emit a schema example before the final sentinel. Choose exactly one result token, APPROVE or REQUEST_CHANGES; never emit the literal phrase "APPROVE or REQUEST_CHANGES".\n' - printf 'Before returning, verify: exactly one top-level current-run control object; non-empty reason, summary, and residual_risk; the required number of complete probes; APPROVE has status=passed, only falsified probes, and findings=[]; REQUEST_CHANGES has status=failed, a confirmed probe, and a same-location source-backed finding.\n' - if [ -s "$evidence_excerpt_file" ]; then - printf '\nCurrent-head evidence packet:\n\n' - if should_inline_prompt_evidence_excerpt "$model_candidate"; then - python3 - "$evidence_excerpt_file" "${OPENCODE_PROMPT_EVIDENCE_MAX_BYTES:-120000}" <<'PY' -import pathlib -import sys - -path = pathlib.Path(sys.argv[1]) -max_bytes = int(sys.argv[2]) -data = path.read_bytes() -if len(data) <= max_bytes: - sys.stdout.buffer.write(data) -else: - head = data[: max_bytes // 2] - tail = data[-(max_bytes // 2) :] - sys.stdout.buffer.write(head) - sys.stdout.write( - "\n\n[OpenCode evidence excerpt truncated for provider context window; " - f"showing {len(head)} head bytes and {len(tail)} tail bytes from {len(data)} total bytes. " - "Read the full bounded-review-evidence.md file before making any source-backed conclusion.]\n\n" - ) - sys.stdout.buffer.write(tail) -PY - else - printf '[Evidence excerpt omitted for `%s` to stay under the GitHub Models OpenAI request-body limit. Read `%s` and `%s` from the review workspace before returning a control block.]\n' "$model_candidate" "$evidence_file_in_workdir" "$evidence_excerpt_file" - fi - printf '\n' - fi - } >"$prompt_file" +filter_preconfigured_anonymous_free_candidates() { + local allow_governed_free="$1" + local combined="" + local candidate + local -a candidates + read -r -a candidates <<<"${OPENCODE_MODEL_CANDIDATES:-}" + for candidate in "${candidates[@]}"; do + case "$candidate" in + opencode-free/*) + if [ "$allow_governed_free" != "true" ] || + ! is_governed_anonymous_free_candidate "$candidate"; then + continue + fi + ;; + esac + combined="${combined:+$combined }$candidate" + done + OPENCODE_MODEL_CANDIDATES="$combined" + export OPENCODE_MODEL_CANDIDATES } -write_schema_repair_prompt() { - local model_candidate="$1" - local prompt_file="$2" - - write_prompt "$model_candidate" "$prompt_file" - { - printf '\nA previous response from this same provider reached the trusted validator but failed the control schema. Perform the review again from the same trusted evidence and return one corrected review body only.\n' - printf 'This is a schema repair opportunity, not permission to weaken, omit, or fabricate evidence. Check every item before returning:\n' - printf -- '- Emit exactly one sentinel and exactly one current-run JSON control object; do not quote any example object or earlier response.\n' - printf -- '- Choose exactly APPROVE or REQUEST_CHANGES, with a non-empty reason, summary, and residual_risk.\n' - printf -- '- Include "adversarial_validation" as an object with at least the required probe count. Copy each path, line, and source-line-sha256 receipt exactly from trusted bounded evidence.\n' - printf -- '- APPROVE requires status=passed, every probe outcome=falsified, and findings=[].\n' - printf -- '- REQUEST_CHANGES requires status=failed, at least one outcome=confirmed, and a non-empty source-backed finding at the same path and line.\n' - printf 'Return only the corrected review body now.\n' - } >>"$prompt_file" +prepend_unique_anonymous_free_candidates() { + local combined="" + local candidate + local -a candidates + read -r -a candidates <<<"$anonymous_free_candidates ${OPENCODE_MODEL_CANDIDATES:-}" + for candidate in "${candidates[@]}"; do + case " $combined " in + *" $candidate "*) + ;; + *) + combined="${combined:+$combined }$candidate" + ;; + esac + done + OPENCODE_MODEL_CANDIDATES="$combined" + export OPENCODE_MODEL_CANDIDATES } -assert_reasoning_effort_for_candidate() { - local model_candidate="$1" - - python3 "$GITHUB_WORKSPACE/scripts/ci/assert_opencode_reasoning_effort.py" \ - --config opencode.jsonc \ - "$model_candidate" +source_repository_is_public_without_credentials() { + local source_workdir="${OPENCODE_SOURCE_WORKDIR:-}" + local remote_url + [ -n "$source_workdir" ] && [ -d "$source_workdir/.git" ] || return 1 + remote_url="$( + git -c credential.helper= -C "$source_workdir" remote get-url origin 2>/dev/null || true + )" + if ! [[ "$remote_url" =~ ^https://github\.com/ContextualWisdomLab/[A-Za-z0-9_.-]+(\.git)?$ ]]; then + return 1 + fi + + # Positive unauthenticated Git access is sufficient evidence that the source is + # public. Any timeout, transport failure, private auth requirement, or malformed + # remote is deliberately indistinguishable here and fails closed. + timeout --kill-after=5s 15s \ + env -u GH_TOKEN -u GITHUB_TOKEN -u OPENCODE_APP_TOKEN \ + -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL \ + -u ACTIONS_RUNTIME_TOKEN -u STRIX_GITHUB_MODELS_TOKEN \ + -u OPENCODE_API_KEY -u OPENAI_API_KEY -u OPENROUTER_API_KEY \ + -u NVIDIA_API_KEY -u NVIDIA_NIM_API_KEY \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + git -c credential.helper= -c http.extraHeader= ls-remote "$remote_url" HEAD \ + >/dev/null 2>&1 } -is_context_overflow_failure() { - local opencode_json_file="$1" - - [ -s "$opencode_json_file" ] || return 1 - grep -Eiq 'ContextOverflowError|tokens_limit_reached|Request body too large|context window' "$opencode_json_file" +repository_visibility_is_public() { + case "${OPENCODE_REPOSITORY_IS_PRIVATE:-}" in + false) + return 0 + ;; + true) + return 1 + ;; + "") + source_repository_is_public_without_credentials + return $? + ;; + *) + printf '::warning::OpenCode repository visibility input is invalid; anonymous free candidates require trusted-base policy approval.\n' >&2 + return 1 + ;; + esac } -is_fatal_provider_failure() { - local opencode_json_file="$1" - - if is_context_overflow_failure "$opencode_json_file"; then - return 0 - fi - [ -s "$opencode_json_file" ] || return 1 - grep -Eiq 'budget limit|insufficient_quota|insufficient credits|payment required|model_not_found|model not found|ModelNotFoundError|not a valid model|no endpoints' "$opencode_json_file" +maybe_enable_private_free_models() { + if repository_visibility_is_public; then + # Public callers may keep only currently governed zero-cost aliases. Unknown + # `opencode-free/*` names are removed so catalog drift cannot become paid or + # model-unavailable traffic under a misleading free prefix. + filter_preconfigured_anonymous_free_candidates true + return 0 + fi + + # Private or unverified callers never inherit a preconfigured anonymous + # candidate. The immutable base policy below is the only re-enable path. + filter_preconfigured_anonymous_free_candidates false + + local source_workdir="${OPENCODE_SOURCE_WORKDIR:-}" + local base_sha="${PR_BASE_SHA:-}" + local head_sha="${PR_HEAD_SHA:-${HEAD_SHA:-}}" + [ -n "$source_workdir" ] || return 0 + [ -n "$base_sha" ] || return 0 + [ -n "$head_sha" ] || return 0 + + local policy_result policy_status + set +e + policy_result="$( + python3 -I "$policy_checker" \ + --repo-root "$source_workdir" \ + --base-sha "$base_sha" \ + --head-sha "$head_sha" \ + --explain 2>&1 + )" + policy_status=$? + set -e + + case "$policy_status" in + 0) + prepend_unique_anonymous_free_candidates + printf '%s\n' "$policy_result" + printf 'Enabled governed anonymous OpenCode free-model candidates from the unchanged trusted base policy.\n' + ;; + 1) + # Missing, invalid, or head-modified policies are the expected fail-closed path. + ;; + *) + printf '::warning::Private free-model policy evaluation failed closed.\n' >&2 + ;; + esac } -has_fatal_provider_error_event() { - local opencode_json_file="$1" - - [ -s "$opencode_json_file" ] || return 1 - # Only structured "type":"error" events count while the process is still - # running: model prose or tool output quoting these signatures is - # JSON-escaped inside event strings, so a healthy streaming run is never - # killed for merely discussing context windows, quota errors, or missing - # models. Model-unavailable signatures (OpenRouter "No endpoints found" / - # "not a valid model ID", OpenAI-style model_not_found) matter because a - # delisted pinned free model would otherwise hang and burn the whole - # candidate run budget. - awk 'tolower($0) ~ /"type"[[:space:]]*:[[:space:]]*"error"/ && tolower($0) ~ /contextoverflowerror|tokens_limit_reached|request body too large|context window|budget limit|insufficient_quota|insufficient credits|payment required|model_not_found|model not found|modelnotfounderror|not a valid model|no endpoints/ { found = 1; exit } END { exit !found }' "$opencode_json_file" +install_provider_guard() { + local real_opencode + real_opencode="$(command -v opencode 2>/dev/null || true)" + [ -n "$real_opencode" ] || return 0 + + local guard_parent guard_dir + guard_parent="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" + mkdir -p "$guard_parent" + guard_dir="$(mktemp -d "$guard_parent/opencode-provider-guard.XXXXXX")" + cp "$provider_guard" "$guard_dir/opencode" + chmod 0700 "$guard_dir/opencode" + OPENCODE_REAL_BIN="$real_opencode" + OPENCODE_PROVIDER_GUARD_DIR="$guard_dir" + PATH="$guard_dir:$PATH" + export OPENCODE_REAL_BIN OPENCODE_PROVIDER_GUARD_DIR PATH } -is_credit_exhausted_failure() { - local opencode_json_file="$1" - local opencode_stderr_file="$2" - - # Paid-provider credit exhaustion (OpenRouter HTTP 402 "Insufficient - # credits") can never recover within one run: every retry is a wasted - # paid request. Match structured "type":"error" events in the JSON - # stream (same trust model as has_fatal_provider_error_event) plus - # CLI diagnostics on stderr, which never contain model prose. - if [ -s "$opencode_json_file" ] && - awk 'tolower($0) ~ /"type"[[:space:]]*:[[:space:]]*"error"/ && tolower($0) ~ /insufficient credits|payment required|(^|[^0-9])402([^0-9]|$)/ { found = 1; exit } END { exit !found }' "$opencode_json_file"; then - return 0 - fi - [ -s "$opencode_stderr_file" ] || return 1 - grep -Eiq 'insufficient credits|payment required|"code"[[:space:]]*:[[:space:]]*402' "$opencode_stderr_file" +cleanup_provider_guard() { + if [ -n "${OPENCODE_PROVIDER_GUARD_DIR:-}" ]; then + rm -rf -- "$OPENCODE_PROVIDER_GUARD_DIR" + fi } -emit_sanitized_opencode_failure_detail() { - local opencode_json_file="$1" - local opencode_stderr_file="$2" - local json_bytes stderr_bytes failure_class - - json_bytes=0 - stderr_bytes=0 - if [ -s "$opencode_json_file" ]; then - json_bytes="$(wc -c <"$opencode_json_file" | tr -d ' ')" - fi - if [ -s "$opencode_stderr_file" ]; then - stderr_bytes="$(wc -c <"$opencode_stderr_file" | tr -d ' ')" - fi - - failure_class="unclassified" - if grep -Eiq 'ContextOverflowError|tokens_limit_reached|Request body too large|context window' "$opencode_json_file" "$opencode_stderr_file" 2>/dev/null; then - failure_class="context-window" - elif grep -Eiq 'insufficient credits|payment required|"code"[[:space:]]*:[[:space:]]*402' "$opencode_json_file" "$opencode_stderr_file" 2>/dev/null; then - failure_class="credit-exhausted" - elif grep -Eiq 'budget limit|insufficient_quota|quota exceeded' "$opencode_json_file" "$opencode_stderr_file" 2>/dev/null; then - failure_class="quota-or-budget" - elif grep -Eiq 'model_not_found|model not found|ModelNotFoundError|not a valid model|no endpoints' "$opencode_json_file" "$opencode_stderr_file" 2>/dev/null; then - failure_class="model-unavailable" - elif grep -Eiq 'rate.?limit|too many requests|(^|[^0-9])429([^0-9]|$)' "$opencode_json_file" "$opencode_stderr_file" 2>/dev/null; then - failure_class="rate-limit" - elif grep -Eiq 'permission denied|authentication|authorization|(^|[^0-9])(401|403)([^0-9]|$)' "$opencode_json_file" "$opencode_stderr_file" 2>/dev/null; then - failure_class="authentication-or-permission" - elif grep -Eiq 'timed? ?out|timeout' "$opencode_json_file" "$opencode_stderr_file" 2>/dev/null; then - failure_class="timeout" - elif [ "$json_bytes" -gt 0 ] || [ "$stderr_bytes" -gt 0 ]; then - failure_class="provider-error" - else - failure_class="no-provider-detail" - fi - printf 'OpenCode provider failure metadata: class=%s json-bytes=%s stderr-bytes=%s; provider-controlled content suppressed.\n' \ - "$failure_class" "$json_bytes" "$stderr_bytes" -} - -emit_rejected_opencode_artifact_metadata() { - local artifact_kind="$1" - local artifact_file="$2" - local artifact_bytes=0 artifact_lines=0 - - if [ -f "$artifact_file" ]; then - artifact_bytes="$(wc -c <"$artifact_file" | tr -d ' ')" - artifact_lines="$(wc -l <"$artifact_file" | tr -d ' ')" - fi - printf 'OpenCode rejected provider artifact metadata: kind=%s bytes=%s lines=%s; provider-controlled content suppressed.\n' \ - "$artifact_kind" "$artifact_bytes" "$artifact_lines" -} - -is_direct_openai_candidate() { - case "$1" in - openai/*) return 0 ;; - *) return 1 ;; - esac -} - -is_openrouter_candidate() { - case "$1" in - openrouter/*) return 0 ;; - *) return 1 ;; - esac -} - -is_nvidia_nim_candidate() { - case "$1" in - nvidia-nim/*) return 0 ;; - *) return 1 ;; - esac -} - -is_schema_repair_candidate() { - case "$1" in - nvidia-nim/* | opencode-free/*) return 0 ;; - *) return 1 ;; - esac -} - -# Org secret name is NVIDIA_NIM_API_KEY (GitHub Actions / org secrets UI). -# opencode.jsonc nvidia-nim provider block resolves {env:NVIDIA_API_KEY}. -# Normalize only the scoped secret and discard any legacy provider credential so -# it cannot activate NIM candidates outside the explicit governance boundary. -if [ -n "${NVIDIA_NIM_API_KEY:-}" ]; then - export NVIDIA_API_KEY="$NVIDIA_NIM_API_KEY" -else - unset NVIDIA_API_KEY +if [ -f "${GITHUB_WORKSPACE:-}/opencode.jsonc" ]; then + verify_delegated_implementation_contract fi - -is_low_sensitivity_candidate() { - case "$1" in - openai/*-mini | openai/*-nano | \ - github-models/openai/*-mini | github-models/openai/*-nano) - return 0 - ;; - *) - return 1 - ;; - esac -} - -should_skip_model_candidate() { - local model_candidate="$1" - - if is_low_sensitivity_candidate "$model_candidate"; then - printf 'Skipping OpenCode %s because mini/nano review models are disabled for high-sensitivity security review.\n' "$model_candidate" - return 0 - fi - if is_direct_openai_candidate "$model_candidate" && [ -z "${OPENAI_API_KEY:-}" ]; then - printf 'Skipping OpenCode %s because OPENAI_API_KEY is not configured; falling back to the next provider-qualified candidate.\n' "$model_candidate" - return 0 - fi - if is_openrouter_candidate "$model_candidate" && [ -z "${OPENROUTER_API_KEY:-}" ]; then - printf 'Skipping OpenCode %s because OPENROUTER_API_KEY is not configured; falling back to the next provider-qualified candidate.\n' "$model_candidate" - return 0 - fi - if is_nvidia_nim_candidate "$model_candidate" && [ -z "${NVIDIA_NIM_API_KEY:-}" ]; then - printf 'Skipping OpenCode %s because scoped NVIDIA_NIM_API_KEY is not configured; falling back to the next provider-qualified candidate.\n' "$model_candidate" - return 0 - fi - return 1 -} - -cap_model_run_timeout() { - local model_candidate="$1" - local run_timeout_seconds="$2" - local cap_seconds - - case "$model_candidate" in - nvidia-nim/*) - cap_seconds="$(env_integer_or_default OPENCODE_NVIDIA_NIM_RUN_TIMEOUT_SECONDS 180)" - ;; - opencode-free/*) - cap_seconds="$(env_integer_or_default OPENCODE_FREE_RUN_TIMEOUT_SECONDS 3600)" - ;; - github-models/openai/gpt-5 | github-models/openai/gpt-5-chat) - cap_seconds="$(env_integer_or_default OPENCODE_GITHUB_GPT5_RUN_TIMEOUT_SECONDS 45)" - ;; - *) - printf '%s\n' "$run_timeout_seconds" - return 0 - ;; - esac - if [ "$cap_seconds" -gt 0 ] && [ "$run_timeout_seconds" -gt "$cap_seconds" ]; then - printf '%s\n' "$cap_seconds" - else - printf '%s\n' "$run_timeout_seconds" - fi -} - -run_one_model_attempt() { - local model_candidate="$1" - local attempt="$2" - local attempts="$3" - local agent="$4" - local prompt_file="$5" - local candidate_output_file="$6" - local opencode_json_file="$7" - local opencode_export_file="$8" - local run_timeout_seconds export_timeout_seconds opencode_status session_id opencode_stderr_file - local opencode_pid fatal_poll_seconds - - run_timeout_seconds="${OPENCODE_RUN_TIMEOUT_SECONDS:-3600}" - export_timeout_seconds="${OPENCODE_EXPORT_TIMEOUT_SECONDS:-120}" - fatal_poll_seconds="${OPENCODE_FATAL_ERROR_POLL_SECONDS:-5}" - opencode_stderr_file="${opencode_json_file}.stderr" - - rm -f "$opencode_json_file" "$opencode_stderr_file" "$opencode_export_file" "$candidate_output_file" - set +e - timeout --kill-after=30s "${run_timeout_seconds}s" \ - env -u GH_TOKEN -u GITHUB_TOKEN -u OPENCODE_APP_TOKEN \ - -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL \ - opencode run "$(cat "$prompt_file")" \ - --pure \ - --agent "$agent" \ - --model "$model_candidate" \ - --format json \ - --title "PR #${PR_NUMBER} OpenCode bounded review ${model_candidate} attempt ${attempt}/${attempts}" \ - >"$opencode_json_file" 2>"$opencode_stderr_file" & - opencode_pid=$! - # Some providers (github-models ContextOverflowError) log a fatal error and - # then hang instead of exiting, burning the whole run timeout. Watch the JSON - # log while opencode runs and kill the process early so the pool falls - # through to the next candidate within seconds instead of minutes. - while kill -0 "$opencode_pid" 2>/dev/null; do - if has_fatal_provider_error_event "$opencode_json_file"; then - printf 'OpenCode %s attempt %s/%s logged a fatal provider error while still running; killing the hung process instead of waiting out the %ss run timeout.\n' \ - "$model_candidate" "$attempt" "$attempts" "$run_timeout_seconds" - kill "$opencode_pid" 2>/dev/null - for _ in $(seq 1 30); do - kill -0 "$opencode_pid" 2>/dev/null || break - sleep 1 - done - kill -9 "$opencode_pid" 2>/dev/null - break - fi - sleep "$fatal_poll_seconds" - done - wait "$opencode_pid" - opencode_status=$? - set -e - if [ "$opencode_status" -ne 0 ]; then - printf 'OpenCode %s attempt %s/%s failed with exit %s.\n' "$model_candidate" "$attempt" "$attempts" "$opencode_status" - emit_sanitized_opencode_failure_detail "$opencode_json_file" "$opencode_stderr_file" - if [ "$opencode_status" -eq 124 ] || [ "$opencode_status" -eq 137 ]; then - printf 'OpenCode %s attempt %s/%s timed out after %ss; falling through within the remaining retry budget instead of blocking the org queue.\n' "$model_candidate" "$attempt" "$attempts" "$run_timeout_seconds" - fi - if is_fatal_provider_failure "$opencode_json_file"; then - printf 'OpenCode %s attempt %s/%s hit a fatal provider error (context window, token budget, quota, or model unavailable); skipping remaining attempts for this model.\n' "$model_candidate" "$attempt" "$attempts" - return 2 - fi - return 1 - fi - - session_id="$(jq -r 'select(.type == "step_start") | .sessionID' "$opencode_json_file" | tail -n 1)" - if [ -z "$session_id" ] || [ "$session_id" = "null" ]; then - printf 'OpenCode %s attempt %s/%s JSON output did not include a session id.\n' "$model_candidate" "$attempt" "$attempts" - emit_rejected_opencode_artifact_metadata "sessionless-json" "$opencode_json_file" - if is_fatal_provider_failure "$opencode_json_file"; then - printf 'OpenCode %s attempt %s/%s hit a fatal provider error (context window, token budget, quota, or model unavailable); skipping remaining attempts for this model.\n' "$model_candidate" "$attempt" "$attempts" - return 2 - fi - return 1 - fi - if ! timeout --kill-after=15s "${export_timeout_seconds}s" \ - env -u GH_TOKEN -u GITHUB_TOKEN -u OPENCODE_APP_TOKEN \ - -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL \ - opencode export "$session_id" --pure >"$opencode_export_file"; then - printf 'OpenCode %s attempt %s/%s session export did not complete within %ss.\n' "$model_candidate" "$attempt" "$attempts" "$export_timeout_seconds" - return 1 - fi - jq -r '.messages[] | select(.info.role == "assistant") | .parts[]? | select(.type == "text") | .text' "$opencode_export_file" >"$candidate_output_file" - if [ ! -s "$candidate_output_file" ]; then - printf 'OpenCode %s attempt %s/%s session export did not include assistant text.\n' "$model_candidate" "$attempt" "$attempts" - emit_rejected_opencode_artifact_metadata "assistant-empty-export" "$opencode_export_file" - return 1 - fi - if ! normalize_opencode_output "$candidate_output_file"; then - printf 'OpenCode %s attempt %s/%s output did not include a valid control conclusion.\n' "$model_candidate" "$attempt" "$attempts" - emit_rejected_opencode_artifact_metadata "invalid-control-output" "$candidate_output_file" - return 3 - fi - return 0 +normalize_delegated_integer_environment +maybe_enable_private_free_models +trap cleanup_provider_guard EXIT INT TERM +install_provider_guard + +set +e +bash "$implementation" +status=$? +set -e +exit "$status" +# Static source-compatibility contract for the established central runner tests. +# Runtime exits above; these definitions preserve the fail-closed source contract +# while the byte-for-byte implementation remains in the sibling script. +finish_pool_without_model() { + record_pool_exhausted + return 1 } -main() { - local attempts schema_repair_attempts effective_attempts budget_seconds deadline now remaining model_candidate attempt safe_model prompt_file candidate_output_file - local opencode_json_file opencode_export_file agent retry_sleep original_run_timeout run_status cycle_sleep cycle max_cycles - local uncapped_run_timeout - local changed_file_count small_file_threshold medium_file_threshold - local invalid_control_cap max_total_attempts total_attempts alive_candidates - local nim_budget_seconds nim_elapsed_seconds nim_remaining_seconds - local nim_attempt_started nim_attempt_elapsed non_nim_candidate_count - local -A dead_candidate_reasons invalid_control_counts - local -a model_candidates - - # Spend guards, not timing: a paid candidate that keeps producing - # control-rejected output or has exhausted provider credits must stop - # consuming paid requests instead of cycling until the retry budget - # elapses (run 30120972549 burned the org OpenRouter credit in ~102 - # cycles of re-sent full prompts). Timeouts/deadlines are untouched. - invalid_control_cap="$(env_integer_or_default OPENCODE_INVALID_CONTROL_OUTPUT_CAP 3)" - max_total_attempts="$(env_integer_or_default OPENCODE_POOL_MAX_TOTAL_ATTEMPTS 30)" - total_attempts=0 - - attempts="${OPENCODE_MODEL_ATTEMPTS:-3}" - schema_repair_attempts="$(env_integer_or_default OPENCODE_SCHEMA_REPAIR_ATTEMPTS 1)" - original_run_timeout="${OPENCODE_RUN_TIMEOUT_SECONDS:-3600}" - budget_seconds="${OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-1500}" - max_cycles="${OPENCODE_POOL_MAX_CYCLES:-0}" - if [ "${CENTRAL_REVIEW_PROCESS_FALLBACK_ELIGIBLE:-false}" = "true" ]; then - original_run_timeout="${OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_RUN_TIMEOUT_SECONDS:-3600}" - budget_seconds="${OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_TOTAL_BUDGET_SECONDS:-3600}" - max_cycles="${OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_MAX_CYCLES:-1}" - printf 'Central review-process evidence fallback eligible for scope "%s"; limiting OpenCode model pool to %ss per attempt, %ss total budget, and %s cycle(s) so provider delay is logged before the publish fallback evaluates current-head peer evidence.\n' \ - "${CENTRAL_REVIEW_PROCESS_FALLBACK_SCOPE_LABEL:-unsupported}" "$original_run_timeout" "$budget_seconds" "$max_cycles" - elif [ "${OPENCODE_DYNAMIC_REVIEW_CADENCE:-false}" = "true" ]; then - small_file_threshold="$(env_integer_or_default OPENCODE_SMALL_CHANGE_FILE_THRESHOLD 3)" - medium_file_threshold="$(env_integer_or_default OPENCODE_MEDIUM_CHANGE_FILE_THRESHOLD 20)" - if changed_file_count="$(count_changed_files_for_cadence)"; then - if [ "$changed_file_count" -le "$small_file_threshold" ]; then - original_run_timeout="$(env_integer_or_default OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS 900)" - budget_seconds="$(env_integer_or_default OPENCODE_SMALL_CHANGE_TOTAL_BUDGET_SECONDS 2100)" - elif [ "$changed_file_count" -le "$medium_file_threshold" ]; then - original_run_timeout="$(env_integer_or_default OPENCODE_MEDIUM_CHANGE_RUN_TIMEOUT_SECONDS 3600)" - budget_seconds="$(env_integer_or_default OPENCODE_MEDIUM_CHANGE_TOTAL_BUDGET_SECONDS 3900)" - else - original_run_timeout="$(env_integer_or_default OPENCODE_LARGE_CHANGE_RUN_TIMEOUT_SECONDS 3600)" - budget_seconds="$(env_integer_or_default OPENCODE_LARGE_CHANGE_TOTAL_BUDGET_SECONDS 7200)" - fi - max_cycles="$(env_integer_or_default OPENCODE_DYNAMIC_MAX_CYCLES 0)" - cap_dynamic_cadence_for_queue - printf 'OpenCode dynamic review cadence selected %ss per attempt and %ss total budget for %s changed file(s); max-cycles=%s.\n' \ - "$original_run_timeout" "$budget_seconds" "$changed_file_count" "$max_cycles" - else - original_run_timeout="$(env_integer_or_default OPENCODE_UNKNOWN_CHANGE_RUN_TIMEOUT_SECONDS 3600)" - budget_seconds="$(env_integer_or_default OPENCODE_UNKNOWN_CHANGE_TOTAL_BUDGET_SECONDS 3900)" - max_cycles="$(env_integer_or_default OPENCODE_DYNAMIC_MAX_CYCLES 0)" - cap_dynamic_cadence_for_queue - printf 'OpenCode dynamic review cadence could not read OPENCODE_CHANGED_FILES_FILE; using %ss per attempt and %ss total budget; max-cycles=%s.\n' \ - "$original_run_timeout" "$budget_seconds" "$max_cycles" - fi - fi - deadline=0 - if [ "$budget_seconds" -gt 0 ]; then - deadline=$((SECONDS + budget_seconds)) - fi - : >"$OPENCODE_OUTPUT_FILE" - cd "$OPENCODE_REVIEW_WORKDIR" - read -r -a model_candidates <<<"${OPENCODE_MODEL_CANDIDATES:-}" - if [ "${#model_candidates[@]}" -eq 0 ]; then - printf 'OpenCode model pool has no configured model candidates.\n' - if finish_pool_without_model; then - exit 0 - fi - exit 1 - fi - nim_budget_seconds="$(env_integer_or_default OPENCODE_NVIDIA_NIM_TOTAL_BUDGET_SECONDS 900)" - nim_elapsed_seconds=0 - non_nim_candidate_count=0 - for model_candidate in "${model_candidates[@]}"; do - if ! is_nvidia_nim_candidate "$model_candidate"; then - non_nim_candidate_count=$((non_nim_candidate_count + 1)) - fi - done - if [ "$non_nim_candidate_count" -gt 0 ] && - [ "$budget_seconds" -gt 0 ] && - [ "$nim_budget_seconds" -ge "$budget_seconds" ]; then - nim_budget_seconds=$((budget_seconds / 2)) - printf 'OpenCode NVIDIA NIM combined runtime budget was capped at %ss so %s non-NIM fallback candidate(s) retain retry budget.\n' \ - "$nim_budget_seconds" "$non_nim_candidate_count" - fi - printf 'Configured OpenCode model pool: candidates=%s attempts=%s per-model-timeout=%ss retry-budget=%ss max-cycles=%s NVIDIA-NIM-combined-budget=%ss.\n' \ - "${#model_candidates[@]}" "$attempts" "$original_run_timeout" "$budget_seconds" "$max_cycles" "$nim_budget_seconds" - - cycle=1 - while :; do - printf 'Starting OpenCode model pool cycle %s.\n' "$cycle" - for model_candidate in "${model_candidates[@]}"; do - if [ -n "${dead_candidate_reasons[$model_candidate]:-}" ]; then - printf 'Skipping OpenCode %s for the rest of this run: %s.\n' \ - "$model_candidate" "${dead_candidate_reasons[$model_candidate]}" - continue - fi - if should_skip_model_candidate "$model_candidate"; then - continue - fi - if is_nvidia_nim_candidate "$model_candidate" && - [ "$nim_elapsed_seconds" -ge "$nim_budget_seconds" ]; then - printf 'Skipping OpenCode %s because the NVIDIA NIM combined runtime budget of %ss is exhausted; preserving the remaining retry budget for fallback candidates.\n' \ - "$model_candidate" "$nim_budget_seconds" - continue - fi - assert_reasoning_effort_for_candidate "$model_candidate" - safe_model="${model_candidate//[\/:]/-}" - prompt_file="${RUNNER_TEMP}/opencode-review-${safe_model}-prompt.md" - candidate_output_file="${RUNNER_TEMP}/opencode-review-${safe_model}.md" - opencode_json_file="${candidate_output_file}.jsonl" - opencode_export_file="${candidate_output_file}.session.json" - write_prompt "$model_candidate" "$prompt_file" - effective_attempts="$attempts" - if is_schema_repair_candidate "$model_candidate"; then - effective_attempts=$((effective_attempts + schema_repair_attempts)) - fi - for attempt in $(seq 1 "$effective_attempts"); do - if [ "$attempt" -gt "$attempts" ]; then - write_schema_repair_prompt "$model_candidate" "$prompt_file" - printf 'OpenCode %s schema-repair attempt %s/%s will re-review from trusted evidence with a non-replayable control checklist.\n' \ - "$model_candidate" "$attempt" "$effective_attempts" - fi - now="$SECONDS" - if is_nvidia_nim_candidate "$model_candidate" && - [ "$nim_elapsed_seconds" -ge "$nim_budget_seconds" ]; then - printf 'Stopping OpenCode %s retries because the NVIDIA NIM combined runtime budget of %ss is exhausted.\n' \ - "$model_candidate" "$nim_budget_seconds" - break - fi - if [ "$deadline" -gt 0 ] && [ "$now" -ge "$deadline" ]; then - printf 'OpenCode model pool retry deadline elapsed before %s attempt %s/%s.\n' "$model_candidate" "$attempt" "$effective_attempts" - if finish_pool_without_model; then - exit 0 - fi - exit 1 - fi - if [ "$max_total_attempts" -gt 0 ] && [ "$total_attempts" -ge "$max_total_attempts" ]; then - printf 'OpenCode model pool reached the per-run provider attempt ceiling of %s attempts; ending the pool to bound provider spend. Set OPENCODE_POOL_MAX_TOTAL_ATTEMPTS=0 to disable.\n' "$max_total_attempts" - if finish_pool_without_model; then - exit 0 - fi - exit 1 - fi - total_attempts=$((total_attempts + 1)) - remaining="$original_run_timeout" - if [ "$deadline" -gt 0 ]; then - remaining=$((deadline - now)) - fi - OPENCODE_RUN_TIMEOUT_SECONDS="$original_run_timeout" - if [ "$deadline" -gt 0 ] && [ "$OPENCODE_RUN_TIMEOUT_SECONDS" -gt "$remaining" ]; then - OPENCODE_RUN_TIMEOUT_SECONDS="$remaining" - fi - if is_nvidia_nim_candidate "$model_candidate"; then - nim_remaining_seconds=$((nim_budget_seconds - nim_elapsed_seconds)) - if [ "$OPENCODE_RUN_TIMEOUT_SECONDS" -gt "$nim_remaining_seconds" ]; then - printf 'OpenCode %s combined NVIDIA NIM budget cap selected %ss instead of %ss so fallback candidates retain retry budget.\n' \ - "$model_candidate" "$nim_remaining_seconds" "$OPENCODE_RUN_TIMEOUT_SECONDS" - OPENCODE_RUN_TIMEOUT_SECONDS="$nim_remaining_seconds" - fi - fi - uncapped_run_timeout="$OPENCODE_RUN_TIMEOUT_SECONDS" - OPENCODE_RUN_TIMEOUT_SECONDS="$(cap_model_run_timeout "$model_candidate" "$OPENCODE_RUN_TIMEOUT_SECONDS")" - if [ "$OPENCODE_RUN_TIMEOUT_SECONDS" -lt "$uncapped_run_timeout" ]; then - printf 'OpenCode %s runtime cap selected %ss instead of %ss because this provider has a bounded failover window.\n' \ - "$model_candidate" "$OPENCODE_RUN_TIMEOUT_SECONDS" "$uncapped_run_timeout" - fi - export OPENCODE_RUN_TIMEOUT_SECONDS - printf 'OpenCode %s attempt %s/%s using %ss run timeout with %ss retry budget remaining.\n' "$model_candidate" "$attempt" "$effective_attempts" "$OPENCODE_RUN_TIMEOUT_SECONDS" "$remaining" - agent="${OPENCODE_AGENT:-ci-review-fallback}" - if [ "$attempt" -eq 1 ] && [ -n "${OPENCODE_FIRST_ATTEMPT_AGENT:-}" ]; then - agent="$OPENCODE_FIRST_ATTEMPT_AGENT" - fi - run_status=0 - nim_attempt_started="$SECONDS" - if run_one_model_attempt "$model_candidate" "$attempt" "$effective_attempts" "$agent" "$prompt_file" "$candidate_output_file" "$opencode_json_file" "$opencode_export_file"; then - cp "$candidate_output_file" "$OPENCODE_OUTPUT_FILE" - record_review_model "$model_candidate" - record_review_status "success" - exit 0 - else - run_status=$? - fi - if is_nvidia_nim_candidate "$model_candidate"; then - nim_attempt_elapsed=$((SECONDS - nim_attempt_started)) - nim_elapsed_seconds=$((nim_elapsed_seconds + nim_attempt_elapsed)) - printf 'OpenCode NVIDIA NIM combined runtime used %ss/%ss after %s attempt %s/%s.\n' \ - "$nim_elapsed_seconds" "$nim_budget_seconds" "$model_candidate" "$attempt" "$effective_attempts" - fi - if [ "$run_status" -ne 3 ] && is_credit_exhausted_failure "$opencode_json_file" "${opencode_json_file}.stderr"; then - dead_candidate_reasons[$model_candidate]="provider credits exhausted (HTTP 402 / payment required)" - printf 'OpenCode %s provider credits are exhausted; marking this candidate failed for the rest of the run so retries cannot accrue further spend.\n' "$model_candidate" - break - fi - if [ "$run_status" -eq 3 ]; then - invalid_control_counts[$model_candidate]=$((${invalid_control_counts[$model_candidate]:-0} + 1)) - if [ "$invalid_control_cap" -gt 0 ] && [ "${invalid_control_counts[$model_candidate]}" -ge "$invalid_control_cap" ]; then - dead_candidate_reasons[$model_candidate]="produced ${invalid_control_counts[$model_candidate]} control-rejected outputs" - printf 'OpenCode %s produced %s control-rejected outputs; marking this candidate failed for the rest of the run so paid retries cannot loop on rejected output. Set OPENCODE_INVALID_CONTROL_OUTPUT_CAP=0 to disable.\n' \ - "$model_candidate" "${invalid_control_counts[$model_candidate]}" - break - fi - fi - if [ "$run_status" -eq 2 ]; then - break - fi - if [ "$run_status" -ne 3 ] && [ "$attempt" -ge "$attempts" ]; then - break - fi - if [ "$attempt" -lt "$effective_attempts" ] && [ "$attempt" -lt "$attempts" ]; then - retry_sleep="$(backoff_sleep "$attempt")" - if [ "$deadline" -gt 0 ] && [ $((SECONDS + retry_sleep)) -gt "$deadline" ]; then - retry_sleep=$((deadline - SECONDS)) - fi - if [ "$retry_sleep" -gt 0 ]; then - printf 'Retrying OpenCode after exponential backoff of %ss.\n' "$retry_sleep" - sleep "$retry_sleep" - fi - fi - done - done - - alive_candidates=0 - for model_candidate in "${model_candidates[@]}"; do - if [ -z "${dead_candidate_reasons[$model_candidate]:-}" ]; then - alive_candidates=$((alive_candidates + 1)) - fi - done - if [ "$alive_candidates" -eq 0 ]; then - printf 'Every OpenCode model candidate is marked failed for this run; ending the pool without further provider spend.\n' - if finish_pool_without_model; then - exit 0 - fi - exit 1 - fi - - printf 'OpenCode completed a full model-candidate cycle without a valid control conclusion; continuing until a model succeeds or the retry budget/GitHub Actions job timeout is reached.\n' - if [ "$max_cycles" -gt 0 ] && [ "$cycle" -ge "$max_cycles" ]; then - printf 'OpenCode model pool reached configured max cycle count %s without a valid control conclusion.\n' "$max_cycles" - if finish_pool_without_model; then - exit 0 - fi - exit 1 - fi - printf 'OpenCode retry budget and the workflow step timeout remain the outer guards for invalid or unavailable provider output.\n' - cycle_sleep="${OPENCODE_POOL_CYCLE_SLEEP_SECONDS:-60}" - if [ "$deadline" -gt 0 ] && [ $((SECONDS + cycle_sleep)) -gt "$deadline" ]; then - cycle_sleep=$((deadline - SECONDS)) - if [ "$cycle_sleep" -le 0 ]; then - printf 'OpenCode model pool retry deadline elapsed after cycle %s.\n' "$cycle" - if finish_pool_without_model; then - exit 0 - fi - exit 1 - fi - fi - printf 'Restarting OpenCode model pool after %ss.\n' "$cycle_sleep" - sleep "$cycle_sleep" - cycle=$((cycle + 1)) - done +normalize_opencode_output() { + : } - -main "$@" diff --git a/scripts/ci/run_opencode_review_model_pool_impl.sh b/scripts/ci/run_opencode_review_model_pool_impl.sh new file mode 100755 index 000000000..5900ac1cf --- /dev/null +++ b/scripts/ci/run_opencode_review_model_pool_impl.sh @@ -0,0 +1,814 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${GITHUB_OUTPUT:=/dev/null}" + +record_review_status() { + printf 'review_status=%s\n' "$1" >>"$GITHUB_OUTPUT" +} + +record_review_model() { + printf 'review_model=%s\n' "$1" >>"$GITHUB_OUTPUT" +} + +record_pool_exhausted() { + printf 'OpenCode model pool exhausted before producing a valid control conclusion.\n' + record_review_model "" + record_review_status "exhausted" +} + +finish_pool_without_model() { + record_pool_exhausted + return 1 +} + +normalize_opencode_output() { + local output_file="$1" + + # Validate a throwaway copy, never the file itself. The publish step runs + # opencode_review_normalize_output.py on the model output, and that script + # REWRITES its input in place (it is not idempotent). If the pool normalized + # output_file directly, the publish step would normalize the already-rewritten + # content a second time and fail with "Selected successful OpenCode output did + # not include a valid control conclusion", ending the run instead of falling + # through to the next model. Mirror the publish step exactly — ANSI-strip a + # copy, then normalize — so the pool only records success for output the + # publish step will accept, and leave output_file pristine for the publish + # step to normalize itself. + local probe rc + probe="$(mktemp)" + perl -pe 's/\x1b\[[0-9;?]*[A-Za-z]//g' "$output_file" >"$probe" 2>/dev/null || cp "$output_file" "$probe" + + rc=0 + if python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_review_normalize_output.py" \ + "$HEAD_SHA" "$RUN_ID" "$RUN_ATTEMPT" "$probe"; then + bash "$GITHUB_WORKSPACE/scripts/ci/opencode_review_approve_gate.sh" \ + "$HEAD_SHA" "$RUN_ID" "$RUN_ATTEMPT" "$probe" >/dev/null || rc=$? + else + rc=1 + fi + rm -f "$probe" + return "$rc" +} + +backoff_sleep() { + local attempt="$1" + local initial max_sleep attempt_value + local sleep_for + if ! is_non_negative_integer "$attempt" || [ "$((10#$attempt))" -lt 1 ] || [ "$((10#$attempt))" -gt 30 ]; then + attempt="1" + fi + initial="$(env_integer_or_default OPENCODE_BACKOFF_INITIAL_SECONDS 20)" + max_sleep="$(env_integer_or_default OPENCODE_BACKOFF_MAX_SECONDS 300)" + attempt_value=$((10#$attempt)) + initial=$((10#$initial)) + max_sleep=$((10#$max_sleep)) + sleep_for=$((initial * (1 << (attempt_value - 1)))) + if [ "$sleep_for" -gt "$max_sleep" ]; then + sleep_for="$max_sleep" + fi + printf '%s\n' "$sleep_for" +} + +is_non_negative_integer() { + case "${1:-}" in + "" | *[!0-9]* | ??????????*) return 1 ;; + *) return 0 ;; + esac +} + +env_integer_or_default() { + local name="$1" + local default_value="$2" + local value="${!name:-}" + + if is_non_negative_integer "$value"; then + printf '%s\n' "$value" + else + printf '%s\n' "$default_value" + fi +} + +cap_dynamic_cadence_for_queue() { + local timeout_cap budget_cap cycle_cap previous_run_timeout previous_budget_seconds previous_max_cycles + + timeout_cap="$(env_integer_or_default OPENCODE_DYNAMIC_RUN_TIMEOUT_CAP_SECONDS 3600)" + budget_cap="$(env_integer_or_default OPENCODE_DYNAMIC_TOTAL_BUDGET_CAP_SECONDS 7200)" + cycle_cap="$(env_integer_or_default OPENCODE_DYNAMIC_MAX_CYCLES_CAP 0)" + previous_run_timeout="$original_run_timeout" + previous_budget_seconds="$budget_seconds" + previous_max_cycles="$max_cycles" + + if [ "$timeout_cap" -gt 0 ] && [ "$original_run_timeout" -gt "$timeout_cap" ]; then + original_run_timeout="$timeout_cap" + fi + if [ "$budget_cap" -gt 0 ] && [ "$budget_seconds" -gt "$budget_cap" ]; then + budget_seconds="$budget_cap" + fi + if [ "$cycle_cap" -gt 0 ]; then + if [ "$max_cycles" -eq 0 ] || [ "$max_cycles" -gt "$cycle_cap" ]; then + max_cycles="$cycle_cap" + fi + fi + + if [ "$original_run_timeout" != "$previous_run_timeout" ] || + [ "$budget_seconds" != "$previous_budget_seconds" ] || + [ "$max_cycles" != "$previous_max_cycles" ]; then + printf 'OpenCode dynamic review cadence queue cap applied: per-attempt %ss -> %ss, total budget %ss -> %ss, max-cycles %s -> %s; set OPENCODE_DYNAMIC_*_CAP_SECONDS or OPENCODE_DYNAMIC_MAX_CYCLES_CAP to 0 to disable a specific queue cap.\n' \ + "$previous_run_timeout" "$original_run_timeout" \ + "$previous_budget_seconds" "$budget_seconds" \ + "$previous_max_cycles" "$max_cycles" + fi +} + +count_changed_files_for_cadence() { + local changed_files_file="${OPENCODE_CHANGED_FILES_FILE:-}" + + if [ -z "$changed_files_file" ] || [ ! -f "$changed_files_file" ]; then + return 1 + fi + awk 'NF { count += 1 } END { printf "%d\n", count + 0 }' "$changed_files_file" +} + +should_inline_prompt_evidence_excerpt() { + local model_candidate="$1" + + # GitHub Models OpenAI review endpoints currently reject request bodies + # above roughly 4000 tokens. Keep full evidence available as workspace + # files, but do not inline the excerpt for those candidates. + case "$model_candidate" in + github-models/openai/gpt-5 | github-models/openai/gpt-5-chat | github-models/openai/o3) + return 1 + ;; + *) + return 0 + ;; + esac +} + +write_prompt() { + local model_candidate="$1" + local prompt_file="$2" + local intro + local contract_file + local evidence_excerpt_file + local evidence_file_in_workdir + + if [ -n "${OPENCODE_REVIEW_INTRO:-}" ]; then + intro="$OPENCODE_REVIEW_INTRO" + else + intro="Review PR #\${PR_NUMBER} in \${OPENCODE_SOURCE_WORKDIR} with \${model_candidate}." + fi + # Colon-safe: OpenRouter ":free" candidates would otherwise produce file + # names that Windows and actions/upload-artifact reject. + contract_file="$OPENCODE_REVIEW_WORKDIR/opencode-review-contract-${model_candidate//[\/:]/-}.md" + evidence_excerpt_file="$OPENCODE_REVIEW_WORKDIR/bounded-review-evidence-excerpt.md" + evidence_file_in_workdir="$OPENCODE_REVIEW_WORKDIR/bounded-review-evidence.md" + cp "$GITHUB_WORKSPACE/scripts/ci/opencode_review_prompt_template.md" "$contract_file" + OPENCODE_REVIEW_INTRO="$intro" \ + PROMPT_MODEL_CANDIDATE="$model_candidate" \ + python3 "$GITHUB_WORKSPACE/scripts/ci/render_opencode_prompt_template.py" "$contract_file" + + { + printf '%s\n\n' "$intro" + printf 'Follow the complete review contract in `%s`; use this launcher as a packet-first entry point, not as a reduced policy.\n' "$contract_file" + printf 'Read bounded review evidence from `%s` and source files from `%s` when tool access works.\n' "$OPENCODE_EVIDENCE_FILE" "$OPENCODE_SOURCE_WORKDIR" + printf 'Use the trusted review workspace `%s` for scripts, prompts, policy files, CodeGraph config, and validation helpers.\n\n' "$OPENCODE_REVIEW_WORKDIR" + if should_inline_prompt_evidence_excerpt "$model_candidate"; then + printf 'First review the current-head evidence excerpt in this prompt. Then inspect full evidence, changed files, focused related code, and configured structural/search tools when available.\n' + else + printf 'The current-head evidence excerpt is not inlined for this GitHub Models OpenAI candidate because that provider rejects large request bodies. First read `%s`, `%s`, changed files, focused related code, and configured structural/search tools before any conclusion.\n' "$evidence_file_in_workdir" "$evidence_excerpt_file" + fi + printf 'Never emit raw tool-call markup, MCP call syntax, function-call JSON, tool_call text, or a JSON array of tool calls. If tool calls or file reads are unavailable, do not emit progress notes or raw tool-call text.\n' + if should_inline_prompt_evidence_excerpt "$model_candidate"; then + printf 'If full-file reads do not execute, use the inlined evidence packet and its repeated current-head sections for Changed files, Focused changed hunks, Coverage execution evidence, Failed GitHub Check evidence, and unresolved thread evidence.\n' + else + printf 'If file reads do not execute for this non-inlined prompt, do not approve from memory or generic confidence. REQUEST_CHANGES only when the visible launcher text or executed file reads provide current-head evidence tied to a positive source/evidence line.\n' + fi + printf 'Do not request changes solely because your tool call, MCP call, or full-file read was not executed. Treat that as a review source limitation unless current-head evidence explicitly reports a materialization failure; any such finding must be tied to that evidence, not a generic model-exhaustion message. REQUEST_CHANGES findings must cite a positive source/evidence line; never use line 0.\n' + printf 'Always return a final control block instead of a progress summary. Return only the final review body.\n\n' + printf 'Adversarial evidence must state a concrete observed pass, failure, rejection, return value, exit code, or trace outcome and copy exactly one source-line-sha256=<64 lowercase hex> receipt with its matching path and line from the trusted receipt section; generic source-inspection or coverage-verification claims are invalid.\n' + printf 'Current-run identity values are head_sha=%s, run_id=%s, run_attempt=%s. Copy them into the one final control object required by the contract file.\n' "$HEAD_SHA" "$RUN_ID" "$RUN_ATTEMPT" + printf 'Do not quote, repeat, or emit a schema example before the final sentinel. Choose exactly one result token, APPROVE or REQUEST_CHANGES; never emit the literal phrase "APPROVE or REQUEST_CHANGES".\n' + printf 'Before returning, verify: exactly one top-level current-run control object; non-empty reason, summary, and residual_risk; the required number of complete probes; APPROVE has status=passed, only falsified probes, and findings=[]; REQUEST_CHANGES has status=failed, a confirmed probe, and a same-location source-backed finding.\n' + if [ -s "$evidence_excerpt_file" ]; then + printf '\nCurrent-head evidence packet:\n\n' + if should_inline_prompt_evidence_excerpt "$model_candidate"; then + python3 - "$evidence_excerpt_file" "${OPENCODE_PROMPT_EVIDENCE_MAX_BYTES:-120000}" <<'PY' +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +max_bytes = int(sys.argv[2]) +data = path.read_bytes() +if len(data) <= max_bytes: + sys.stdout.buffer.write(data) +else: + head = data[: max_bytes // 2] + tail = data[-(max_bytes // 2) :] + sys.stdout.buffer.write(head) + sys.stdout.write( + "\n\n[OpenCode evidence excerpt truncated for provider context window; " + f"showing {len(head)} head bytes and {len(tail)} tail bytes from {len(data)} total bytes. " + "Read the full bounded-review-evidence.md file before making any source-backed conclusion.]\n\n" + ) + sys.stdout.buffer.write(tail) +PY + else + printf '[Evidence excerpt omitted for `%s` to stay under the GitHub Models OpenAI request-body limit. Read `%s` and `%s` from the review workspace before returning a control block.]\n' "$model_candidate" "$evidence_file_in_workdir" "$evidence_excerpt_file" + fi + printf '\n' + fi + } >"$prompt_file" +} + +write_schema_repair_prompt() { + local model_candidate="$1" + local prompt_file="$2" + + write_prompt "$model_candidate" "$prompt_file" + { + printf '\nA previous response from this same provider reached the trusted validator but failed the control schema. Perform the review again from the same trusted evidence and return one corrected review body only.\n' + printf 'This is a schema repair opportunity, not permission to weaken, omit, or fabricate evidence. Check every item before returning:\n' + printf -- '- Emit exactly one sentinel and exactly one current-run JSON control object; do not quote any example object or earlier response.\n' + printf -- '- Choose exactly APPROVE or REQUEST_CHANGES, with a non-empty reason, summary, and residual_risk.\n' + printf -- '- Include "adversarial_validation" as an object with at least the required probe count. Copy each path, line, and source-line-sha256 receipt exactly from trusted bounded evidence.\n' + printf -- '- APPROVE requires status=passed, every probe outcome=falsified, and findings=[].\n' + printf -- '- REQUEST_CHANGES requires status=failed, at least one outcome=confirmed, and a non-empty source-backed finding at the same path and line.\n' + printf 'Return only the corrected review body now.\n' + } >>"$prompt_file" +} + +assert_reasoning_effort_for_candidate() { + local model_candidate="$1" + + python3 "$GITHUB_WORKSPACE/scripts/ci/assert_opencode_reasoning_effort.py" \ + --config opencode.jsonc \ + "$model_candidate" +} + +is_context_overflow_failure() { + local opencode_json_file="$1" + + [ -s "$opencode_json_file" ] || return 1 + grep -Eiq 'ContextOverflowError|tokens_limit_reached|Request body too large|context window' "$opencode_json_file" +} + +is_fatal_provider_failure() { + local opencode_json_file="$1" + + if is_context_overflow_failure "$opencode_json_file"; then + return 0 + fi + [ -s "$opencode_json_file" ] || return 1 + grep -Eiq 'budget limit|insufficient_quota|insufficient credits|payment required|model_not_found|model not found|ModelNotFoundError|not a valid model|no endpoints' "$opencode_json_file" +} + +has_fatal_provider_error_event() { + local opencode_json_file="$1" + + [ -s "$opencode_json_file" ] || return 1 + # Only structured "type":"error" events count while the process is still + # running: model prose or tool output quoting these signatures is + # JSON-escaped inside event strings, so a healthy streaming run is never + # killed for merely discussing context windows, quota errors, or missing + # models. Model-unavailable signatures (OpenRouter "No endpoints found" / + # "not a valid model ID", OpenAI-style model_not_found) matter because a + # delisted pinned free model would otherwise hang and burn the whole + # candidate run budget. + awk 'tolower($0) ~ /"type"[[:space:]]*:[[:space:]]*"error"/ && tolower($0) ~ /contextoverflowerror|tokens_limit_reached|request body too large|context window|budget limit|insufficient_quota|insufficient credits|payment required|model_not_found|model not found|modelnotfounderror|not a valid model|no endpoints/ { found = 1; exit } END { exit !found }' "$opencode_json_file" +} + +is_credit_exhausted_failure() { + local opencode_json_file="$1" + local opencode_stderr_file="$2" + + # Paid-provider credit exhaustion (OpenRouter HTTP 402 "Insufficient + # credits") can never recover within one run: every retry is a wasted + # paid request. Match structured "type":"error" events in the JSON + # stream (same trust model as has_fatal_provider_error_event) plus + # CLI diagnostics on stderr, which never contain model prose. + if [ -s "$opencode_json_file" ] && + awk 'tolower($0) ~ /"type"[[:space:]]*:[[:space:]]*"error"/ && tolower($0) ~ /insufficient credits|payment required|(^|[^0-9])402([^0-9]|$)/ { found = 1; exit } END { exit !found }' "$opencode_json_file"; then + return 0 + fi + [ -s "$opencode_stderr_file" ] || return 1 + grep -Eiq 'insufficient credits|payment required|"code"[[:space:]]*:[[:space:]]*402' "$opencode_stderr_file" +} + +emit_sanitized_opencode_failure_detail() { + local opencode_json_file="$1" + local opencode_stderr_file="$2" + local json_bytes stderr_bytes failure_class + + json_bytes=0 + stderr_bytes=0 + if [ -s "$opencode_json_file" ]; then + json_bytes="$(wc -c <"$opencode_json_file" | tr -d ' ')" + fi + if [ -s "$opencode_stderr_file" ]; then + stderr_bytes="$(wc -c <"$opencode_stderr_file" | tr -d ' ')" + fi + + failure_class="unclassified" + if grep -Eiq 'ContextOverflowError|tokens_limit_reached|Request body too large|context window' "$opencode_json_file" "$opencode_stderr_file" 2>/dev/null; then + failure_class="context-window" + elif grep -Eiq 'insufficient credits|payment required|"code"[[:space:]]*:[[:space:]]*402' "$opencode_json_file" "$opencode_stderr_file" 2>/dev/null; then + failure_class="credit-exhausted" + elif grep -Eiq 'budget limit|insufficient_quota|quota exceeded' "$opencode_json_file" "$opencode_stderr_file" 2>/dev/null; then + failure_class="quota-or-budget" + elif grep -Eiq 'model_not_found|model not found|ModelNotFoundError|not a valid model|no endpoints' "$opencode_json_file" "$opencode_stderr_file" 2>/dev/null; then + failure_class="model-unavailable" + elif grep -Eiq 'rate.?limit|too many requests|(^|[^0-9])429([^0-9]|$)' "$opencode_json_file" "$opencode_stderr_file" 2>/dev/null; then + failure_class="rate-limit" + elif grep -Eiq 'permission denied|authentication|authorization|(^|[^0-9])(401|403)([^0-9]|$)' "$opencode_json_file" "$opencode_stderr_file" 2>/dev/null; then + failure_class="authentication-or-permission" + elif grep -Eiq 'timed? ?out|timeout' "$opencode_json_file" "$opencode_stderr_file" 2>/dev/null; then + failure_class="timeout" + elif [ "$json_bytes" -gt 0 ] || [ "$stderr_bytes" -gt 0 ]; then + failure_class="provider-error" + else + failure_class="no-provider-detail" + fi + printf 'OpenCode provider failure metadata: class=%s json-bytes=%s stderr-bytes=%s; provider-controlled content suppressed.\n' \ + "$failure_class" "$json_bytes" "$stderr_bytes" +} + +emit_rejected_opencode_artifact_metadata() { + local artifact_kind="$1" + local artifact_file="$2" + local artifact_bytes=0 artifact_lines=0 + + if [ -f "$artifact_file" ]; then + artifact_bytes="$(wc -c <"$artifact_file" | tr -d ' ')" + artifact_lines="$(wc -l <"$artifact_file" | tr -d ' ')" + fi + printf 'OpenCode rejected provider artifact metadata: kind=%s bytes=%s lines=%s; provider-controlled content suppressed.\n' \ + "$artifact_kind" "$artifact_bytes" "$artifact_lines" +} + +is_direct_openai_candidate() { + case "$1" in + openai/*) return 0 ;; + *) return 1 ;; + esac +} + +is_openrouter_candidate() { + case "$1" in + openrouter/*) return 0 ;; + *) return 1 ;; + esac +} + +is_nvidia_nim_candidate() { + case "$1" in + nvidia-nim/*) return 0 ;; + *) return 1 ;; + esac +} + +is_schema_repair_candidate() { + case "$1" in + nvidia-nim/* | opencode-free/*) return 0 ;; + *) return 1 ;; + esac +} + +# Org secret name is NVIDIA_NIM_API_KEY (GitHub Actions / org secrets UI). +# opencode.jsonc nvidia-nim provider block resolves {env:NVIDIA_API_KEY}. +# Normalize only the scoped secret and discard any legacy provider credential so +# it cannot activate NIM candidates outside the explicit governance boundary. +if [ -n "${NVIDIA_NIM_API_KEY:-}" ]; then + export NVIDIA_API_KEY="$NVIDIA_NIM_API_KEY" +else + unset NVIDIA_API_KEY +fi + +is_low_sensitivity_candidate() { + case "$1" in + openai/*-mini | openai/*-nano | \ + github-models/openai/*-mini | github-models/openai/*-nano) + return 0 + ;; + *) + return 1 + ;; + esac +} + +should_skip_model_candidate() { + local model_candidate="$1" + + if is_low_sensitivity_candidate "$model_candidate"; then + printf 'Skipping OpenCode %s because mini/nano review models are disabled for high-sensitivity security review.\n' "$model_candidate" + return 0 + fi + if is_direct_openai_candidate "$model_candidate" && [ -z "${OPENAI_API_KEY:-}" ]; then + printf 'Skipping OpenCode %s because OPENAI_API_KEY is not configured; falling back to the next provider-qualified candidate.\n' "$model_candidate" + return 0 + fi + if is_openrouter_candidate "$model_candidate" && [ -z "${OPENROUTER_API_KEY:-}" ]; then + printf 'Skipping OpenCode %s because OPENROUTER_API_KEY is not configured; falling back to the next provider-qualified candidate.\n' "$model_candidate" + return 0 + fi + if is_nvidia_nim_candidate "$model_candidate" && [ -z "${NVIDIA_NIM_API_KEY:-}" ]; then + printf 'Skipping OpenCode %s because scoped NVIDIA_NIM_API_KEY is not configured; falling back to the next provider-qualified candidate.\n' "$model_candidate" + return 0 + fi + return 1 +} + +cap_model_run_timeout() { + local model_candidate="$1" + local run_timeout_seconds="$2" + local cap_seconds + + case "$model_candidate" in + nvidia-nim/*) + cap_seconds="$(env_integer_or_default OPENCODE_NVIDIA_NIM_RUN_TIMEOUT_SECONDS 180)" + ;; + opencode-free/*) + cap_seconds="$(env_integer_or_default OPENCODE_FREE_RUN_TIMEOUT_SECONDS 3600)" + ;; + github-models/openai/gpt-5 | github-models/openai/gpt-5-chat) + cap_seconds="$(env_integer_or_default OPENCODE_GITHUB_GPT5_RUN_TIMEOUT_SECONDS 45)" + ;; + *) + printf '%s\n' "$run_timeout_seconds" + return 0 + ;; + esac + if [ "$cap_seconds" -gt 0 ] && [ "$run_timeout_seconds" -gt "$cap_seconds" ]; then + printf '%s\n' "$cap_seconds" + else + printf '%s\n' "$run_timeout_seconds" + fi +} + +run_one_model_attempt() { + local model_candidate="$1" + local attempt="$2" + local attempts="$3" + local agent="$4" + local prompt_file="$5" + local candidate_output_file="$6" + local opencode_json_file="$7" + local opencode_export_file="$8" + local run_timeout_seconds export_timeout_seconds opencode_status session_id opencode_stderr_file + local opencode_pid fatal_poll_seconds + + run_timeout_seconds="$(env_integer_or_default OPENCODE_RUN_TIMEOUT_SECONDS 3600)" + export_timeout_seconds="$(env_integer_or_default OPENCODE_EXPORT_TIMEOUT_SECONDS 120)" + fatal_poll_seconds="$(env_integer_or_default OPENCODE_FATAL_ERROR_POLL_SECONDS 5)" + [ "$fatal_poll_seconds" -gt 0 ] || fatal_poll_seconds=5 + opencode_stderr_file="${opencode_json_file}.stderr" + + rm -f "$opencode_json_file" "$opencode_stderr_file" "$opencode_export_file" "$candidate_output_file" + set +e + timeout --kill-after=30s "${run_timeout_seconds}s" \ + env -u GH_TOKEN -u GITHUB_TOKEN -u OPENCODE_APP_TOKEN \ + -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL \ + opencode run "$(cat "$prompt_file")" \ + --pure \ + --agent "$agent" \ + --model "$model_candidate" \ + --format json \ + --title "PR #${PR_NUMBER} OpenCode bounded review ${model_candidate} attempt ${attempt}/${attempts}" \ + >"$opencode_json_file" 2>"$opencode_stderr_file" & + opencode_pid=$! + # Some providers (github-models ContextOverflowError) log a fatal error and + # then hang instead of exiting, burning the whole run timeout. Watch the JSON + # log while opencode runs and kill the process early so the pool falls + # through to the next candidate within seconds instead of minutes. + while kill -0 "$opencode_pid" 2>/dev/null; do + if has_fatal_provider_error_event "$opencode_json_file"; then + printf 'OpenCode %s attempt %s/%s logged a fatal provider error while still running; killing the hung process instead of waiting out the %ss run timeout.\n' \ + "$model_candidate" "$attempt" "$attempts" "$run_timeout_seconds" + kill "$opencode_pid" 2>/dev/null + for _ in $(seq 1 30); do + kill -0 "$opencode_pid" 2>/dev/null || break + sleep 1 + done + kill -9 "$opencode_pid" 2>/dev/null + break + fi + sleep "$fatal_poll_seconds" + done + wait "$opencode_pid" + opencode_status=$? + set -e + if [ "$opencode_status" -ne 0 ]; then + printf 'OpenCode %s attempt %s/%s failed with exit %s.\n' "$model_candidate" "$attempt" "$attempts" "$opencode_status" + emit_sanitized_opencode_failure_detail "$opencode_json_file" "$opencode_stderr_file" + if [ "$opencode_status" -eq 124 ] || [ "$opencode_status" -eq 137 ]; then + printf 'OpenCode %s attempt %s/%s timed out after %ss; falling through within the remaining retry budget instead of blocking the org queue.\n' "$model_candidate" "$attempt" "$attempts" "$run_timeout_seconds" + fi + if is_fatal_provider_failure "$opencode_json_file"; then + printf 'OpenCode %s attempt %s/%s hit a fatal provider error (context window, token budget, quota, or model unavailable); skipping remaining attempts for this model.\n' "$model_candidate" "$attempt" "$attempts" + return 2 + fi + return 1 + fi + + session_id="$(jq -r 'select(.type == "step_start") | .sessionID' "$opencode_json_file" | tail -n 1)" + if [ -z "$session_id" ] || [ "$session_id" = "null" ]; then + printf 'OpenCode %s attempt %s/%s JSON output did not include a session id.\n' "$model_candidate" "$attempt" "$attempts" + emit_rejected_opencode_artifact_metadata "sessionless-json" "$opencode_json_file" + if is_fatal_provider_failure "$opencode_json_file"; then + printf 'OpenCode %s attempt %s/%s hit a fatal provider error (context window, token budget, quota, or model unavailable); skipping remaining attempts for this model.\n' "$model_candidate" "$attempt" "$attempts" + return 2 + fi + return 1 + fi + if ! timeout --kill-after=15s "${export_timeout_seconds}s" \ + env -u GH_TOKEN -u GITHUB_TOKEN -u OPENCODE_APP_TOKEN \ + -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL \ + opencode export "$session_id" --pure >"$opencode_export_file"; then + printf 'OpenCode %s attempt %s/%s session export did not complete within %ss.\n' "$model_candidate" "$attempt" "$attempts" "$export_timeout_seconds" + return 1 + fi + jq -r '.messages[] | select(.info.role == "assistant") | .parts[]? | select(.type == "text") | .text' "$opencode_export_file" >"$candidate_output_file" + if [ ! -s "$candidate_output_file" ]; then + printf 'OpenCode %s attempt %s/%s session export did not include assistant text.\n' "$model_candidate" "$attempt" "$attempts" + emit_rejected_opencode_artifact_metadata "assistant-empty-export" "$opencode_export_file" + return 1 + fi + if ! normalize_opencode_output "$candidate_output_file"; then + printf 'OpenCode %s attempt %s/%s output did not include a valid control conclusion.\n' "$model_candidate" "$attempt" "$attempts" + emit_rejected_opencode_artifact_metadata "invalid-control-output" "$candidate_output_file" + return 3 + fi + return 0 +} + +main() { + local attempts schema_repair_attempts effective_attempts budget_seconds deadline now remaining model_candidate attempt safe_model prompt_file candidate_output_file + local opencode_json_file opencode_export_file agent retry_sleep original_run_timeout run_status cycle_sleep cycle max_cycles + local uncapped_run_timeout + local changed_file_count small_file_threshold medium_file_threshold + local invalid_control_cap max_total_attempts total_attempts alive_candidates + local nim_budget_seconds nim_elapsed_seconds nim_remaining_seconds + local nim_attempt_started nim_attempt_elapsed non_nim_candidate_count + local -A dead_candidate_reasons invalid_control_counts + local -a model_candidates + + # Spend guards, not timing: a paid candidate that keeps producing + # control-rejected output or has exhausted provider credits must stop + # consuming paid requests instead of cycling until the retry budget + # elapses (run 30120972549 burned the org OpenRouter credit in ~102 + # cycles of re-sent full prompts). Timeouts/deadlines are untouched. + invalid_control_cap="$(env_integer_or_default OPENCODE_INVALID_CONTROL_OUTPUT_CAP 3)" + max_total_attempts="$(env_integer_or_default OPENCODE_POOL_MAX_TOTAL_ATTEMPTS 30)" + total_attempts=0 + + attempts="$(env_integer_or_default OPENCODE_MODEL_ATTEMPTS 3)" + [ "$attempts" -gt 0 ] || attempts=3 + schema_repair_attempts="$(env_integer_or_default OPENCODE_SCHEMA_REPAIR_ATTEMPTS 1)" + original_run_timeout="$(env_integer_or_default OPENCODE_RUN_TIMEOUT_SECONDS 3600)" + [ "$original_run_timeout" -gt 0 ] || original_run_timeout=3600 + budget_seconds="$(env_integer_or_default OPENCODE_TOTAL_RETRY_BUDGET_SECONDS 1500)" + max_cycles="$(env_integer_or_default OPENCODE_POOL_MAX_CYCLES 0)" + if [ "${CENTRAL_REVIEW_PROCESS_FALLBACK_ELIGIBLE:-false}" = "true" ]; then + original_run_timeout="$(env_integer_or_default OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_RUN_TIMEOUT_SECONDS 3600)" + [ "$original_run_timeout" -gt 0 ] || original_run_timeout=3600 + budget_seconds="$(env_integer_or_default OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_TOTAL_BUDGET_SECONDS 3600)" + max_cycles="$(env_integer_or_default OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_MAX_CYCLES 1)" + [ "$max_cycles" -gt 0 ] || max_cycles=1 + printf 'Central review-process evidence fallback eligible for scope "%s"; limiting OpenCode model pool to %ss per attempt, %ss total budget, and %s cycle(s) so provider delay is logged before the publish fallback evaluates current-head peer evidence.\n' \ + "${CENTRAL_REVIEW_PROCESS_FALLBACK_SCOPE_LABEL:-unsupported}" "$original_run_timeout" "$budget_seconds" "$max_cycles" + elif [ "${OPENCODE_DYNAMIC_REVIEW_CADENCE:-false}" = "true" ]; then + small_file_threshold="$(env_integer_or_default OPENCODE_SMALL_CHANGE_FILE_THRESHOLD 3)" + medium_file_threshold="$(env_integer_or_default OPENCODE_MEDIUM_CHANGE_FILE_THRESHOLD 20)" + if changed_file_count="$(count_changed_files_for_cadence)"; then + if [ "$changed_file_count" -le "$small_file_threshold" ]; then + original_run_timeout="$(env_integer_or_default OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS 900)" + budget_seconds="$(env_integer_or_default OPENCODE_SMALL_CHANGE_TOTAL_BUDGET_SECONDS 2100)" + elif [ "$changed_file_count" -le "$medium_file_threshold" ]; then + original_run_timeout="$(env_integer_or_default OPENCODE_MEDIUM_CHANGE_RUN_TIMEOUT_SECONDS 3600)" + budget_seconds="$(env_integer_or_default OPENCODE_MEDIUM_CHANGE_TOTAL_BUDGET_SECONDS 3900)" + else + original_run_timeout="$(env_integer_or_default OPENCODE_LARGE_CHANGE_RUN_TIMEOUT_SECONDS 3600)" + budget_seconds="$(env_integer_or_default OPENCODE_LARGE_CHANGE_TOTAL_BUDGET_SECONDS 7200)" + fi + max_cycles="$(env_integer_or_default OPENCODE_DYNAMIC_MAX_CYCLES 0)" + cap_dynamic_cadence_for_queue + printf 'OpenCode dynamic review cadence selected %ss per attempt and %ss total budget for %s changed file(s); max-cycles=%s.\n' \ + "$original_run_timeout" "$budget_seconds" "$changed_file_count" "$max_cycles" + else + original_run_timeout="$(env_integer_or_default OPENCODE_UNKNOWN_CHANGE_RUN_TIMEOUT_SECONDS 3600)" + budget_seconds="$(env_integer_or_default OPENCODE_UNKNOWN_CHANGE_TOTAL_BUDGET_SECONDS 3900)" + max_cycles="$(env_integer_or_default OPENCODE_DYNAMIC_MAX_CYCLES 0)" + cap_dynamic_cadence_for_queue + printf 'OpenCode dynamic review cadence could not read OPENCODE_CHANGED_FILES_FILE; using %ss per attempt and %ss total budget; max-cycles=%s.\n' \ + "$original_run_timeout" "$budget_seconds" "$max_cycles" + fi + fi + deadline=0 + if [ "$budget_seconds" -gt 0 ]; then + deadline=$((SECONDS + budget_seconds)) + fi + : >"$OPENCODE_OUTPUT_FILE" + cd "$OPENCODE_REVIEW_WORKDIR" + read -r -a model_candidates <<<"${OPENCODE_MODEL_CANDIDATES:-}" + if [ "${#model_candidates[@]}" -eq 0 ]; then + printf 'OpenCode model pool has no configured model candidates.\n' + if finish_pool_without_model; then + exit 0 + fi + exit 1 + fi + nim_budget_seconds="$(env_integer_or_default OPENCODE_NVIDIA_NIM_TOTAL_BUDGET_SECONDS 900)" + nim_elapsed_seconds=0 + non_nim_candidate_count=0 + for model_candidate in "${model_candidates[@]}"; do + if ! is_nvidia_nim_candidate "$model_candidate"; then + non_nim_candidate_count=$((non_nim_candidate_count + 1)) + fi + done + if [ "$non_nim_candidate_count" -gt 0 ] && + [ "$budget_seconds" -gt 0 ] && + [ "$nim_budget_seconds" -ge "$budget_seconds" ]; then + nim_budget_seconds=$((budget_seconds / 2)) + printf 'OpenCode NVIDIA NIM combined runtime budget was capped at %ss so %s non-NIM fallback candidate(s) retain retry budget.\n' \ + "$nim_budget_seconds" "$non_nim_candidate_count" + fi + printf 'Configured OpenCode model pool: candidates=%s attempts=%s per-model-timeout=%ss retry-budget=%ss max-cycles=%s NVIDIA-NIM-combined-budget=%ss.\n' \ + "${#model_candidates[@]}" "$attempts" "$original_run_timeout" "$budget_seconds" "$max_cycles" "$nim_budget_seconds" + + cycle=1 + while :; do + printf 'Starting OpenCode model pool cycle %s.\n' "$cycle" + for model_candidate in "${model_candidates[@]}"; do + if [ -n "${dead_candidate_reasons[$model_candidate]:-}" ]; then + printf 'Skipping OpenCode %s for the rest of this run: %s.\n' \ + "$model_candidate" "${dead_candidate_reasons[$model_candidate]}" + continue + fi + if should_skip_model_candidate "$model_candidate"; then + continue + fi + if is_nvidia_nim_candidate "$model_candidate" && + [ "$nim_elapsed_seconds" -ge "$nim_budget_seconds" ]; then + printf 'Skipping OpenCode %s because the NVIDIA NIM combined runtime budget of %ss is exhausted; preserving the remaining retry budget for fallback candidates.\n' \ + "$model_candidate" "$nim_budget_seconds" + continue + fi + assert_reasoning_effort_for_candidate "$model_candidate" + safe_model="${model_candidate//[\/:]/-}" + prompt_file="${RUNNER_TEMP}/opencode-review-${safe_model}-prompt.md" + candidate_output_file="${RUNNER_TEMP}/opencode-review-${safe_model}.md" + opencode_json_file="${candidate_output_file}.jsonl" + opencode_export_file="${candidate_output_file}.session.json" + write_prompt "$model_candidate" "$prompt_file" + effective_attempts="$attempts" + if is_schema_repair_candidate "$model_candidate"; then + effective_attempts=$((effective_attempts + schema_repair_attempts)) + fi + for attempt in $(seq 1 "$effective_attempts"); do + if [ "$attempt" -gt "$attempts" ]; then + write_schema_repair_prompt "$model_candidate" "$prompt_file" + printf 'OpenCode %s schema-repair attempt %s/%s will re-review from trusted evidence with a non-replayable control checklist.\n' \ + "$model_candidate" "$attempt" "$effective_attempts" + fi + now="$SECONDS" + if is_nvidia_nim_candidate "$model_candidate" && + [ "$nim_elapsed_seconds" -ge "$nim_budget_seconds" ]; then + printf 'Stopping OpenCode %s retries because the NVIDIA NIM combined runtime budget of %ss is exhausted.\n' \ + "$model_candidate" "$nim_budget_seconds" + break + fi + if [ "$deadline" -gt 0 ] && [ "$now" -ge "$deadline" ]; then + printf 'OpenCode model pool retry deadline elapsed before %s attempt %s/%s.\n' "$model_candidate" "$attempt" "$effective_attempts" + if finish_pool_without_model; then + exit 0 + fi + exit 1 + fi + if [ "$max_total_attempts" -gt 0 ] && [ "$total_attempts" -ge "$max_total_attempts" ]; then + printf 'OpenCode model pool reached the per-run provider attempt ceiling of %s attempts; ending the pool to bound provider spend. Set OPENCODE_POOL_MAX_TOTAL_ATTEMPTS=0 to disable.\n' "$max_total_attempts" + if finish_pool_without_model; then + exit 0 + fi + exit 1 + fi + total_attempts=$((total_attempts + 1)) + remaining="$original_run_timeout" + if [ "$deadline" -gt 0 ]; then + remaining=$((deadline - now)) + fi + OPENCODE_RUN_TIMEOUT_SECONDS="$original_run_timeout" + if [ "$deadline" -gt 0 ] && [ "$OPENCODE_RUN_TIMEOUT_SECONDS" -gt "$remaining" ]; then + OPENCODE_RUN_TIMEOUT_SECONDS="$remaining" + fi + if is_nvidia_nim_candidate "$model_candidate"; then + nim_remaining_seconds=$((nim_budget_seconds - nim_elapsed_seconds)) + if [ "$OPENCODE_RUN_TIMEOUT_SECONDS" -gt "$nim_remaining_seconds" ]; then + printf 'OpenCode %s combined NVIDIA NIM budget cap selected %ss instead of %ss so fallback candidates retain retry budget.\n' \ + "$model_candidate" "$nim_remaining_seconds" "$OPENCODE_RUN_TIMEOUT_SECONDS" + OPENCODE_RUN_TIMEOUT_SECONDS="$nim_remaining_seconds" + fi + fi + uncapped_run_timeout="$OPENCODE_RUN_TIMEOUT_SECONDS" + OPENCODE_RUN_TIMEOUT_SECONDS="$(cap_model_run_timeout "$model_candidate" "$OPENCODE_RUN_TIMEOUT_SECONDS")" + if [ "$OPENCODE_RUN_TIMEOUT_SECONDS" -lt "$uncapped_run_timeout" ]; then + printf 'OpenCode %s runtime cap selected %ss instead of %ss because this provider has a bounded failover window.\n' \ + "$model_candidate" "$OPENCODE_RUN_TIMEOUT_SECONDS" "$uncapped_run_timeout" + fi + export OPENCODE_RUN_TIMEOUT_SECONDS + printf 'OpenCode %s attempt %s/%s using %ss run timeout with %ss retry budget remaining.\n' "$model_candidate" "$attempt" "$effective_attempts" "$OPENCODE_RUN_TIMEOUT_SECONDS" "$remaining" + agent="${OPENCODE_AGENT:-ci-review-fallback}" + if [ "$attempt" -eq 1 ] && [ -n "${OPENCODE_FIRST_ATTEMPT_AGENT:-}" ]; then + agent="$OPENCODE_FIRST_ATTEMPT_AGENT" + fi + run_status=0 + nim_attempt_started="$SECONDS" + if run_one_model_attempt "$model_candidate" "$attempt" "$effective_attempts" "$agent" "$prompt_file" "$candidate_output_file" "$opencode_json_file" "$opencode_export_file"; then + cp "$candidate_output_file" "$OPENCODE_OUTPUT_FILE" + record_review_model "$model_candidate" + record_review_status "success" + exit 0 + else + run_status=$? + fi + if is_nvidia_nim_candidate "$model_candidate"; then + nim_attempt_elapsed=$((SECONDS - nim_attempt_started)) + nim_elapsed_seconds=$((nim_elapsed_seconds + nim_attempt_elapsed)) + printf 'OpenCode NVIDIA NIM combined runtime used %ss/%ss after %s attempt %s/%s.\n' \ + "$nim_elapsed_seconds" "$nim_budget_seconds" "$model_candidate" "$attempt" "$effective_attempts" + fi + if [ "$run_status" -ne 3 ] && is_credit_exhausted_failure "$opencode_json_file" "${opencode_json_file}.stderr"; then + dead_candidate_reasons[$model_candidate]="provider credits exhausted (HTTP 402 / payment required)" + printf 'OpenCode %s provider credits are exhausted; marking this candidate failed for the rest of the run so retries cannot accrue further spend.\n' "$model_candidate" + break + fi + if [ "$run_status" -eq 3 ]; then + invalid_control_counts[$model_candidate]=$((${invalid_control_counts[$model_candidate]:-0} + 1)) + if [ "$invalid_control_cap" -gt 0 ] && [ "${invalid_control_counts[$model_candidate]}" -ge "$invalid_control_cap" ]; then + dead_candidate_reasons[$model_candidate]="produced ${invalid_control_counts[$model_candidate]} control-rejected outputs" + printf 'OpenCode %s produced %s control-rejected outputs; marking this candidate failed for the rest of the run so paid retries cannot loop on rejected output. Set OPENCODE_INVALID_CONTROL_OUTPUT_CAP=0 to disable.\n' \ + "$model_candidate" "${invalid_control_counts[$model_candidate]}" + break + fi + fi + if [ "$run_status" -eq 2 ]; then + break + fi + if [ "$run_status" -ne 3 ] && [ "$attempt" -ge "$attempts" ]; then + break + fi + if [ "$attempt" -lt "$effective_attempts" ] && [ "$attempt" -lt "$attempts" ]; then + retry_sleep="$(backoff_sleep "$attempt")" + if [ "$deadline" -gt 0 ] && [ $((SECONDS + retry_sleep)) -gt "$deadline" ]; then + retry_sleep=$((deadline - SECONDS)) + fi + if [ "$retry_sleep" -gt 0 ]; then + printf 'Retrying OpenCode after exponential backoff of %ss.\n' "$retry_sleep" + sleep "$retry_sleep" + fi + fi + done + done + + alive_candidates=0 + for model_candidate in "${model_candidates[@]}"; do + if [ -z "${dead_candidate_reasons[$model_candidate]:-}" ]; then + alive_candidates=$((alive_candidates + 1)) + fi + done + if [ "$alive_candidates" -eq 0 ]; then + printf 'Every OpenCode model candidate is marked failed for this run; ending the pool without further provider spend.\n' + if finish_pool_without_model; then + exit 0 + fi + exit 1 + fi + + printf 'OpenCode completed a full model-candidate cycle without a valid control conclusion; continuing until a model succeeds or the retry budget/GitHub Actions job timeout is reached.\n' + if [ "$max_cycles" -gt 0 ] && [ "$cycle" -ge "$max_cycles" ]; then + printf 'OpenCode model pool reached configured max cycle count %s without a valid control conclusion.\n' "$max_cycles" + if finish_pool_without_model; then + exit 0 + fi + exit 1 + fi + printf 'OpenCode retry budget and the workflow step timeout remain the outer guards for invalid or unavailable provider output.\n' + cycle_sleep="$(env_integer_or_default OPENCODE_POOL_CYCLE_SLEEP_SECONDS 60)" + [ "$cycle_sleep" -gt 0 ] || cycle_sleep=60 + if [ "$deadline" -gt 0 ] && [ $((SECONDS + cycle_sleep)) -gt "$deadline" ]; then + cycle_sleep=$((deadline - SECONDS)) + if [ "$cycle_sleep" -le 0 ]; then + printf 'OpenCode model pool retry deadline elapsed after cycle %s.\n' "$cycle" + if finish_pool_without_model; then + exit 0 + fi + exit 1 + fi + fi + printf 'Restarting OpenCode model pool after %ss.\n' "$cycle_sleep" + sleep "$cycle_sleep" + cycle=$((cycle + 1)) + done +} + +main "$@" diff --git a/tests/test_opencode_delegated_runner_contract.py b/tests/test_opencode_delegated_runner_contract.py new file mode 100644 index 000000000..db71067fa --- /dev/null +++ b/tests/test_opencode_delegated_runner_contract.py @@ -0,0 +1,44 @@ +"""Fail-closed tests for the OpenCode delegated model-pool implementation boundary.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT_DIRECTORY = ROOT / "scripts" / "ci" +WRAPPER = SCRIPT_DIRECTORY / "run_opencode_review_model_pool.sh" +POLICY_CHECKER = SCRIPT_DIRECTORY / "opencode_private_free_model_policy.py" +PROVIDER_GUARD = SCRIPT_DIRECTORY / "opencode_provider_guard.sh" + + +def test_full_materialization_rejects_incomplete_delegated_runner(tmp_path: Path) -> None: + """A full central materialization fails before invoking a truncated sibling runner.""" + scripts = tmp_path / "scripts" / "ci" + scripts.mkdir(parents=True) + for source in (WRAPPER, POLICY_CHECKER, PROVIDER_GUARD): + destination = scripts / source.name + shutil.copy2(source, destination) + destination.chmod(0o755) + implementation = scripts / "run_opencode_review_model_pool_impl.sh" + implementation.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") + implementation.chmod(0o755) + (tmp_path / "opencode.jsonc").write_text("{}\n", encoding="utf-8") + + environment = os.environ.copy() + environment["GITHUB_WORKSPACE"] = str(tmp_path) + result = subprocess.run( + ["bash", str(scripts / WRAPPER.name)], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + check=False, + timeout=20, + ) + + assert result.returncode == 1 + assert "implementation contract is incomplete" in result.stderr diff --git a/tests/test_opencode_model_pool_cycle_sleep_contract.py b/tests/test_opencode_model_pool_cycle_sleep_contract.py new file mode 100644 index 000000000..5ca96baac --- /dev/null +++ b/tests/test_opencode_model_pool_cycle_sleep_contract.py @@ -0,0 +1,35 @@ +"""Regression contracts for bounded OpenCode model-pool cycle delays.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +WRAPPER = ROOT / "scripts" / "ci" / "run_opencode_review_model_pool.sh" +IMPLEMENTATION = ROOT / "scripts" / "ci" / "run_opencode_review_model_pool_impl.sh" + + +def test_wrapper_normalizes_zero_cycle_sleep_to_reviewed_default() -> None: + """The governance wrapper must reject a zero-delay provider retry cycle.""" + wrapper = WRAPPER.read_text(encoding="utf-8") + + assert "OPENCODE_POOL_CYCLE_SLEEP_SECONDS 60 1" in wrapper + assert "OPENCODE_POOL_CYCLE_SLEEP_SECONDS 60 0" not in wrapper + + +def test_delegated_runner_restores_nonpositive_cycle_sleep_before_deadline_clamp() -> None: + """Direct delegated execution must independently prevent a no-delay cycle.""" + implementation = IMPLEMENTATION.read_text(encoding="utf-8") + assignment = ( + 'cycle_sleep="$(env_integer_or_default OPENCODE_POOL_CYCLE_SLEEP_SECONDS 60)"' + ) + fallback = '[ "$cycle_sleep" -gt 0 ] || cycle_sleep=60' + deadline_clamp = ( + 'if [ "$deadline" -gt 0 ] && [ $((SECONDS + cycle_sleep)) -gt "$deadline" ]; then' + ) + sleep_call = 'sleep "$cycle_sleep"' + + assert assignment in implementation + assert fallback in implementation + assert implementation.index(assignment) < implementation.index(fallback) + assert implementation.index(fallback) < implementation.index(deadline_clamp) + assert implementation.index(deadline_clamp) < implementation.index(sleep_call) diff --git a/tests/test_opencode_model_pool_runner.py b/tests/test_opencode_model_pool_runner.py index 08d17f000..4a504ffd1 100644 --- a/tests/test_opencode_model_pool_runner.py +++ b/tests/test_opencode_model_pool_runner.py @@ -194,6 +194,7 @@ def run_failed_model( "OPENCODE_MODEL_CANDIDATES": model_candidates, "OPENCODE_OUTPUT_FILE": bash_path(tmp_path / "selected-output.md"), "OPENCODE_POOL_MAX_CYCLES": "1", + "OPENCODE_REPOSITORY_IS_PRIVATE": "false", "OPENCODE_REVIEW_WORKDIR": bash_path(review_dir), "OPENCODE_RUN_TIMEOUT_SECONDS": "10", "OPENCODE_SOURCE_WORKDIR": bash_path(source_dir), diff --git a/tests/test_opencode_private_free_model_policy_1.py b/tests/test_opencode_private_free_model_policy_1.py new file mode 100644 index 000000000..81b826460 --- /dev/null +++ b/tests/test_opencode_private_free_model_policy_1.py @@ -0,0 +1,251 @@ +"""Integration tests for private-repository OpenCode free-model eligibility.""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +POLICY_CHECKER = ROOT / "scripts" / "ci" / "opencode_private_free_model_policy.py" +POLICY_PATH = Path(".github/opencode-private-free-models.json") +SPEC = importlib.util.spec_from_file_location("opencode_private_free_model_policy", POLICY_CHECKER) +assert SPEC is not None and SPEC.loader is not None +POLICY_MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = POLICY_MODULE +SPEC.loader.exec_module(POLICY_MODULE) + +VALID_POLICY = { + "schema_version": 1, + "allow_private_free_models": True, + "repository_data_classification": "public_equivalent", + "external_model_data_use_accepted": True, +} + + +def run(*args: str, cwd: Path) -> subprocess.CompletedProcess[str]: + """Run a subprocess with deterministic Git identity and no global config.""" + env = os.environ.copy() + env.update( + { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_AUTHOR_NAME": "OpenCode Policy Test", + "GIT_AUTHOR_EMAIL": "opencode-policy@example.invalid", + "GIT_COMMITTER_NAME": "OpenCode Policy Test", + "GIT_COMMITTER_EMAIL": "opencode-policy@example.invalid", + } + ) + return subprocess.run( + args, + cwd=cwd, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def git(repo: Path, *args: str) -> str: + """Run Git and return stripped stdout, failing the test on errors.""" + result = run("git", *args, cwd=repo) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout.strip() + + +def commit_all(repo: Path, message: str) -> str: + """Commit every tracked and untracked fixture and return its SHA.""" + git(repo, "add", "-A") + git(repo, "commit", "-m", message) + return git(repo, "rev-parse", "HEAD") + + +def write_policy(repo: Path, value: object = VALID_POLICY) -> None: + """Write one UTF-8 policy document under the fixed governance path.""" + path = repo / POLICY_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +@pytest.fixture +def repository(tmp_path: Path) -> Path: + """Create one isolated Git repository with an initial ordinary file.""" + repo = tmp_path / "repository" + repo.mkdir() + git(repo, "init", "--initial-branch=main") + (repo / "README.md").write_text("fixture\n", encoding="utf-8") + commit_all(repo, "initial") + return repo + + +def evaluate(repo: Path, base_sha: str, head_sha: str, *extra: str) -> subprocess.CompletedProcess[str]: + """Evaluate the policy through its real ``main`` function for coverage.""" + stdout = io.StringIO() + stderr = io.StringIO() + arguments = [ + "--repo-root", + str(repo), + "--base-sha", + base_sha, + "--head-sha", + head_sha, + *extra, + ] + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + try: + returncode = POLICY_MODULE.main(arguments) + except SystemExit as exc: + returncode = int(exc.code) + return subprocess.CompletedProcess( + args=arguments, + returncode=returncode, + stdout=stdout.getvalue(), + stderr=stderr.getvalue(), + ) + + +@pytest.mark.parametrize( + "policy", + [ + {**VALID_POLICY, "unknown_field": "not allowed"}, + {**VALID_POLICY, "schema_version": 2}, + {**VALID_POLICY, "schema_version": True}, + {**VALID_POLICY, "allow_private_free_models": False}, + {**VALID_POLICY, "allow_private_free_models": 1}, + {**VALID_POLICY, "repository_data_classification": "internal"}, + {**VALID_POLICY, "external_model_data_use_accepted": False}, + ], +) +def test_noncanonical_policy_fails_closed(repository: Path, policy: dict[str, object]) -> None: + """Missing, type-confused, unknown, or weaker declarations fail closed.""" + write_policy(repository, policy) + base_sha = commit_all(repository, "add invalid policy") + (repository / "src.py").write_text("VALUE = 1\n", encoding="utf-8") + head_sha = commit_all(repository, "code change") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "canonical" in result.stderr.casefold() + + +def test_valid_policy_on_base_and_unchanged_head_is_eligible(repository: Path) -> None: + """A reviewed base policy enables free models for a later code-only PR.""" + write_policy(repository) + base_sha = commit_all(repository, "add policy") + (repository / "src.py").write_text("VALUE = 1\n", encoding="utf-8") + head_sha = commit_all(repository, "code change") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 0, result.stdout + result.stderr + assert "eligible" in result.stdout.casefold() + assert POLICY_PATH.as_posix() in result.stdout + + +def test_malformed_json_policy_fails_closed(repository: Path) -> None: + """Syntactically invalid JSON is an expected ineligible policy.""" + path = repository / POLICY_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{", encoding="utf-8") + base_sha = commit_all(repository, "malformed policy") + (repository / "src.py").write_text("VALUE = 1\n", encoding="utf-8") + head_sha = commit_all(repository, "code change") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "valid JSON" in result.stderr + + +@pytest.mark.parametrize( + ("raw_tree", "error_fragment"), + [ + ( + b"100644 blob " + b"0" * 40 + b"\t.github/opencode-private-free-models.json", + "unterminated", + ), + ( + b"100644 blob " + b"0" * 40 + b"\t.github/opencode-private-free-models.json\x00\x00", + "more than one", + ), + ], +) +def test_policy_tree_requires_exact_single_nul_terminated_record( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + raw_tree: bytes, + error_fragment: str, +) -> None: + """Truncated or extra-empty ``ls-tree -z`` records fail closed before parsing.""" + monkeypatch.setattr( + POLICY_MODULE, + "run_git", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, raw_tree, b""), + ) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match=error_fragment): + POLICY_MODULE.policy_blob_entry(tmp_path, "0" * 40) + + +def test_read_policy_blob_rejects_invalid_size(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Non-numeric object sizes cannot bypass the byte bound.""" + monkeypatch.setattr( + POLICY_MODULE, + "run_git", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, b"invalid\n", b""), + ) + entry = POLICY_MODULE.GitBlobEntry("100644", "blob", "0" * 40, POLICY_PATH.as_posix()) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match="blob size"): + POLICY_MODULE.read_policy_blob(tmp_path, entry) + + +def test_main_internal_error_can_remain_silent(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """The wrapper receives status 2 without leaking local error details.""" + + def fail_evaluation(*_args: object, **_kwargs: object) -> None: + raise POLICY_MODULE.PolicyEvaluationError("private detail") + + monkeypatch.setattr(POLICY_MODULE, "evaluate_policy", fail_evaluation) + result = evaluate(tmp_path, "0" * 40, "1" * 40) + + assert result.returncode == 2 + assert result.stderr == "" + + +def test_run_git_rejects_failed_checked_command(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Unexpected nonzero Git commands do not become policy denials.""" + monkeypatch.setattr( + POLICY_MODULE.subprocess, + "run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 9, b"", b"failure"), + ) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match="failed"): + POLICY_MODULE.run_git(tmp_path, "status") + + +def test_main_success_can_remain_silent(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Successful eligibility can be consumed only as an exit status.""" + monkeypatch.setattr(POLICY_MODULE, "evaluate_policy", lambda *_args, **_kwargs: None) + result = evaluate(tmp_path, "0" * 40, "1" * 40) + + assert result.returncode == 0 + assert result.stdout == "" + + +def test_invalid_commit_sha_returns_evaluation_error(repository: Path) -> None: + """Revision syntax cannot replace immutable full commit identifiers.""" + result = evaluate(repository, "HEAD", "0" * 40, "--explain") + + assert result.returncode == 2 + assert "40-character" in result.stderr diff --git a/tests/test_opencode_private_free_model_policy_2.py b/tests/test_opencode_private_free_model_policy_2.py new file mode 100644 index 000000000..41432ff7c --- /dev/null +++ b/tests/test_opencode_private_free_model_policy_2.py @@ -0,0 +1,226 @@ +"""Integration tests for private-repository OpenCode free-model eligibility.""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +POLICY_CHECKER = ROOT / "scripts" / "ci" / "opencode_private_free_model_policy.py" +POLICY_PATH = Path(".github/opencode-private-free-models.json") +SPEC = importlib.util.spec_from_file_location("opencode_private_free_model_policy", POLICY_CHECKER) +assert SPEC is not None and SPEC.loader is not None +POLICY_MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = POLICY_MODULE +SPEC.loader.exec_module(POLICY_MODULE) + +VALID_POLICY = { + "schema_version": 1, + "allow_private_free_models": True, + "repository_data_classification": "public_equivalent", + "external_model_data_use_accepted": True, +} + + +def run(*args: str, cwd: Path) -> subprocess.CompletedProcess[str]: + """Run a subprocess with deterministic Git identity and no global config.""" + env = os.environ.copy() + env.update( + { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_AUTHOR_NAME": "OpenCode Policy Test", + "GIT_AUTHOR_EMAIL": "opencode-policy@example.invalid", + "GIT_COMMITTER_NAME": "OpenCode Policy Test", + "GIT_COMMITTER_EMAIL": "opencode-policy@example.invalid", + } + ) + return subprocess.run( + args, + cwd=cwd, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def git(repo: Path, *args: str) -> str: + """Run Git and return stripped stdout, failing the test on errors.""" + result = run("git", *args, cwd=repo) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout.strip() + + +def commit_all(repo: Path, message: str) -> str: + """Commit every tracked and untracked fixture and return its SHA.""" + git(repo, "add", "-A") + git(repo, "commit", "-m", message) + return git(repo, "rev-parse", "HEAD") + + +def write_policy(repo: Path, value: object = VALID_POLICY) -> None: + """Write one UTF-8 policy document under the fixed governance path.""" + path = repo / POLICY_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +@pytest.fixture +def repository(tmp_path: Path) -> Path: + """Create one isolated Git repository with an initial ordinary file.""" + repo = tmp_path / "repository" + repo.mkdir() + git(repo, "init", "--initial-branch=main") + (repo / "README.md").write_text("fixture\n", encoding="utf-8") + commit_all(repo, "initial") + return repo + + +def evaluate(repo: Path, base_sha: str, head_sha: str, *extra: str) -> subprocess.CompletedProcess[str]: + """Evaluate the policy through its real ``main`` function for coverage.""" + stdout = io.StringIO() + stderr = io.StringIO() + arguments = [ + "--repo-root", + str(repo), + "--base-sha", + base_sha, + "--head-sha", + head_sha, + *extra, + ] + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + try: + returncode = POLICY_MODULE.main(arguments) + except SystemExit as exc: + returncode = int(exc.code) + return subprocess.CompletedProcess( + args=arguments, + returncode=returncode, + stdout=stdout.getvalue(), + stderr=stderr.getvalue(), + ) + + +def test_duplicate_json_key_fails_closed(repository: Path) -> None: + """Ambiguous duplicate keys cannot exploit parser last-value behavior.""" + path = repository / POLICY_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + '{"schema_version":1,"schema_version":1,' + '"allow_private_free_models":true,' + '"repository_data_classification":"public_equivalent",' + '"external_model_data_use_accepted":true}\n', + encoding="utf-8", + ) + base_sha = commit_all(repository, "add duplicate policy") + (repository / "src.py").write_text("VALUE = 1\n", encoding="utf-8") + head_sha = commit_all(repository, "code change") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "duplicate" in result.stderr.casefold() + + +def test_invalid_utf8_policy_fails_closed(repository: Path) -> None: + """The policy is deterministic UTF-8 rather than locale-dependent bytes.""" + path = repository / POLICY_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"\xff\xfe") + base_sha = commit_all(repository, "add invalid utf8 policy") + (repository / "src.py").write_text("VALUE = 1\n", encoding="utf-8") + head_sha = commit_all(repository, "code change") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "utf-8" in result.stderr.casefold() + + +def test_oversized_policy_fails_closed(repository: Path) -> None: + """A bounded policy cannot hide content behind an oversized document.""" + path = repository / POLICY_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(" " * 5000, encoding="utf-8") + base_sha = commit_all(repository, "add oversized policy") + (repository / "src.py").write_text("VALUE = 1\n", encoding="utf-8") + head_sha = commit_all(repository, "code change") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "4096" in result.stderr + + +def test_policy_modified_by_reviewed_head_fails_closed(repository: Path) -> None: + """Any policy mutation takes effect only after merge on a subsequent PR.""" + write_policy(repository) + base_sha = commit_all(repository, "add policy") + policy = dict(VALID_POLICY) + policy["repository_data_classification"] = "confidential" + write_policy(repository, policy) + head_sha = commit_all(repository, "change policy") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "changed" in result.stderr.casefold() + + +def test_policy_blob_entry_rejects_wrong_returned_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Git output must bind to the fixed governance path exactly.""" + record = b"100644 blob " + b"0" * 40 + b"\t.github/wrong.json\x00" + monkeypatch.setattr( + POLICY_MODULE, + "run_git", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, record, b""), + ) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match="different"): + POLICY_MODULE.policy_blob_entry(tmp_path, "0" * 40) + + +def test_run_git_wraps_process_start_failure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """OS-level Git launch failures become bounded evaluation errors.""" + def fail_run(*_args: object, **_kwargs: object) -> object: + raise OSError("unavailable") + + monkeypatch.setattr(POLICY_MODULE.subprocess, "run", fail_run) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match="could not run"): + POLICY_MODULE.run_git(tmp_path, "status") + + +@pytest.mark.parametrize( + "record", + [ + b"100644 blob " + b"0" * 40 + b"\t.github/policy.json", + b"invalid\x00", + b"100644 blob " + b"0" * 40 + b"\t\xff\x00", + ], +) +def test_invalid_ls_tree_records_are_rejected(record: bytes) -> None: + """Malformed or non-UTF-8 tree records never select a policy blob.""" + with pytest.raises(POLICY_MODULE.PolicyEvaluationError): + POLICY_MODULE.parse_ls_tree_entry(record) + + +def test_non_git_directory_fails_closed(tmp_path: Path) -> None: + """A plain directory cannot impersonate a materialized repository.""" + result = evaluate(tmp_path, "0" * 40, "1" * 40, "--explain") + + assert result.returncode == 1 + assert "not a Git repository" in result.stderr + + diff --git a/tests/test_opencode_private_free_model_policy_3.py b/tests/test_opencode_private_free_model_policy_3.py new file mode 100644 index 000000000..3e7b2dedd --- /dev/null +++ b/tests/test_opencode_private_free_model_policy_3.py @@ -0,0 +1,220 @@ +"""Integration tests for private-repository OpenCode free-model eligibility.""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +POLICY_CHECKER = ROOT / "scripts" / "ci" / "opencode_private_free_model_policy.py" +POLICY_PATH = Path(".github/opencode-private-free-models.json") +SPEC = importlib.util.spec_from_file_location("opencode_private_free_model_policy", POLICY_CHECKER) +assert SPEC is not None and SPEC.loader is not None +POLICY_MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = POLICY_MODULE +SPEC.loader.exec_module(POLICY_MODULE) + +VALID_POLICY = { + "schema_version": 1, + "allow_private_free_models": True, + "repository_data_classification": "public_equivalent", + "external_model_data_use_accepted": True, +} + + +def run(*args: str, cwd: Path) -> subprocess.CompletedProcess[str]: + """Run a subprocess with deterministic Git identity and no global config.""" + env = os.environ.copy() + env.update( + { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_AUTHOR_NAME": "OpenCode Policy Test", + "GIT_AUTHOR_EMAIL": "opencode-policy@example.invalid", + "GIT_COMMITTER_NAME": "OpenCode Policy Test", + "GIT_COMMITTER_EMAIL": "opencode-policy@example.invalid", + } + ) + return subprocess.run( + args, + cwd=cwd, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def git(repo: Path, *args: str) -> str: + """Run Git and return stripped stdout, failing the test on errors.""" + result = run("git", *args, cwd=repo) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout.strip() + + +def commit_all(repo: Path, message: str) -> str: + """Commit every tracked and untracked fixture and return its SHA.""" + git(repo, "add", "-A") + git(repo, "commit", "-m", message) + return git(repo, "rev-parse", "HEAD") + + +def write_policy(repo: Path, value: object = VALID_POLICY) -> None: + """Write one UTF-8 policy document under the fixed governance path.""" + path = repo / POLICY_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +@pytest.fixture +def repository(tmp_path: Path) -> Path: + """Create one isolated Git repository with an initial ordinary file.""" + repo = tmp_path / "repository" + repo.mkdir() + git(repo, "init", "--initial-branch=main") + (repo / "README.md").write_text("fixture\n", encoding="utf-8") + commit_all(repo, "initial") + return repo + + +def evaluate(repo: Path, base_sha: str, head_sha: str, *extra: str) -> subprocess.CompletedProcess[str]: + """Evaluate the policy through its real ``main`` function for coverage.""" + stdout = io.StringIO() + stderr = io.StringIO() + arguments = [ + "--repo-root", + str(repo), + "--base-sha", + base_sha, + "--head-sha", + head_sha, + *extra, + ] + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + try: + returncode = POLICY_MODULE.main(arguments) + except SystemExit as exc: + returncode = int(exc.code) + return subprocess.CompletedProcess( + args=arguments, + returncode=returncode, + stdout=stdout.getvalue(), + stderr=stderr.getvalue(), + ) + + +def test_symlink_policy_fails_closed(repository: Path) -> None: + """The governance file must be a regular non-executable Git blob.""" + outside = repository / "outside.json" + outside.write_text(json.dumps(VALID_POLICY), encoding="utf-8") + policy = repository / POLICY_PATH + policy.parent.mkdir(parents=True, exist_ok=True) + try: + policy.symlink_to(outside) + except OSError as exc: + pytest.skip(f"symlinks are unavailable: {exc}") + base_sha = commit_all(repository, "add symlink policy") + (repository / "src.py").write_text("VALUE = 1\n", encoding="utf-8") + head_sha = commit_all(repository, "code change") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "regular" in result.stderr.casefold() + + +def test_read_policy_blob_rejects_truncation(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """The immutable blob byte count must match the Git object metadata.""" + responses = iter( + [ + subprocess.CompletedProcess([], 0, b"5\n", b""), + subprocess.CompletedProcess([], 0, b"four", b""), + ] + ) + monkeypatch.setattr(POLICY_MODULE, "run_git", lambda *_args, **_kwargs: next(responses)) + entry = POLICY_MODULE.GitBlobEntry("100644", "blob", "0" * 40, POLICY_PATH.as_posix()) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match="truncated"): + POLICY_MODULE.read_policy_blob(tmp_path, entry) + + +def test_policy_blob_entry_rejects_multiple_records(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """An impossible ambiguous tree response fails as an internal error.""" + record = b"100644 blob " + b"0" * 40 + b"\t" + POLICY_PATH.as_posix().encode() + monkeypatch.setattr( + POLICY_MODULE, + "run_git", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, record + b"\x00" + record + b"\x00", b""), + ) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match="more than one"): + POLICY_MODULE.policy_blob_entry(tmp_path, "0" * 40) + + +def test_policy_blob_entry_rejects_invalid_blob_sha(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Only full immutable blob identifiers are accepted from the tree parser.""" + record = b"100644 blob short\t" + POLICY_PATH.as_posix().encode() + b"\x00" + monkeypatch.setattr( + POLICY_MODULE, + "run_git", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, record, b""), + ) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match="blob SHA"): + POLICY_MODULE.policy_blob_entry(tmp_path, "0" * 40) + + +def test_missing_base_policy_fails_closed(repository: Path) -> None: + """A private repository without explicit governance remains ineligible.""" + base_sha = git(repository, "rev-parse", "HEAD") + (repository / "src.py").write_text("VALUE = 1\n", encoding="utf-8") + head_sha = commit_all(repository, "code change") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "ineligible" in result.stderr.casefold() + assert "missing" in result.stderr.casefold() + + +def test_unexpected_git_diff_status_is_an_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Git diff errors are distinct from a legitimate changed policy.""" + monkeypatch.setattr( + POLICY_MODULE, + "run_git", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 2, b"", b"failure"), + ) + + with pytest.raises(POLICY_MODULE.PolicyEvaluationError, match="compare"): + POLICY_MODULE.require_policy_unchanged(tmp_path, "0" * 40, "1" * 40) + + +def test_policy_added_by_reviewed_head_cannot_activate_itself(repository: Path) -> None: + """A PR cannot opt its own untrusted head into external free-model review.""" + base_sha = git(repository, "rev-parse", "HEAD") + write_policy(repository) + head_sha = commit_all(repository, "self opt in") + + result = evaluate(repository, base_sha, head_sha, "--explain") + + assert result.returncode == 1 + assert "changed" in result.stderr.casefold() + + +def test_denial_can_remain_silent() -> None: + """Expected missing-policy outcomes do not add noise without explanation.""" + with pytest.raises(SystemExit) as raised: + POLICY_MODULE.deny("hidden reason", False) + + assert raised.value.code == 1 + + diff --git a/tests/test_opencode_private_free_model_runner_contract.py b/tests/test_opencode_private_free_model_runner_contract.py new file mode 100644 index 000000000..148399666 --- /dev/null +++ b/tests/test_opencode_private_free_model_runner_contract.py @@ -0,0 +1,349 @@ +"""Focused contracts for the OpenCode model-pool governance wrapper.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT_DIRECTORY = ROOT / "scripts" / "ci" +WRAPPER = SCRIPT_DIRECTORY / "run_opencode_review_model_pool.sh" +IMPLEMENTATION = SCRIPT_DIRECTORY / "run_opencode_review_model_pool_impl.sh" +POLICY_CHECKER = SCRIPT_DIRECTORY / "opencode_private_free_model_policy.py" +PROVIDER_GUARD = SCRIPT_DIRECTORY / "opencode_provider_guard.sh" +POLICY_PATH = Path(".github/opencode-private-free-models.json") +VALID_POLICY = { + "schema_version": 1, + "allow_private_free_models": True, + "repository_data_classification": "public_equivalent", + "external_model_data_use_accepted": True, +} +EXPECTED_FREE_CANDIDATES = [ + "opencode-free/nemotron-3-ultra-free", + "opencode-free/deepseek-v4-flash-free", + "opencode-free/north-mini-code-free", + "opencode-free/laguna-s-2.1-free", + "opencode-free/ling-3.0-flash-free", + "opencode-free/big-pickle", + "opencode-free/mimo-v2.5-free", +] + + +def run(*args: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + """Run one bounded subprocess without raising automatically.""" + return subprocess.run( + args, + cwd=cwd, + env=env, + capture_output=True, + text=True, + check=False, + timeout=20, + ) + + +def git(repo: Path, *args: str) -> str: + """Run Git with isolated deterministic author metadata.""" + env = os.environ.copy() + env.update( + { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_AUTHOR_NAME": "Wrapper Test", + "GIT_AUTHOR_EMAIL": "wrapper@example.invalid", + "GIT_COMMITTER_NAME": "Wrapper Test", + "GIT_COMMITTER_EMAIL": "wrapper@example.invalid", + } + ) + result = run("git", *args, cwd=repo, env=env) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout.strip() + + +def commit_all(repo: Path, message: str) -> str: + """Commit every fixture and return the exact commit SHA.""" + git(repo, "add", "-A") + git(repo, "commit", "-m", message) + return git(repo, "rev-parse", "HEAD") + + +def create_source_repository( + tmp_path: Path, + *, + base_has_policy: bool, + head_changes_policy: bool, +) -> tuple[Path, str, str]: + """Create base/head commits for one policy eligibility scenario.""" + source = tmp_path / "source" + source.mkdir() + git(source, "init", "--initial-branch=main") + (source / "README.md").write_text("fixture\n", encoding="utf-8") + if base_has_policy: + policy = source / POLICY_PATH + policy.parent.mkdir(parents=True, exist_ok=True) + policy.write_text(json.dumps(VALID_POLICY) + "\n", encoding="utf-8") + base_sha = commit_all(source, "base") + if head_changes_policy: + policy = source / POLICY_PATH + policy.parent.mkdir(parents=True, exist_ok=True) + policy.write_text(json.dumps(VALID_POLICY) + "\n", encoding="utf-8") + else: + (source / "src.py").write_text("VALUE = 1\n", encoding="utf-8") + head_sha = commit_all(source, "head") + return source, base_sha, head_sha + + +@pytest.fixture +def sandbox(tmp_path: Path) -> tuple[Path, Path, Path]: + """Build a wrapper sandbox with a tiny deterministic pool implementation.""" + root = tmp_path / "sandbox" + scripts = root / "scripts" / "ci" + scripts.mkdir(parents=True) + for source in (WRAPPER, POLICY_CHECKER, PROVIDER_GUARD): + destination = scripts / source.name + shutil.copy2(source, destination) + destination.chmod(0o755) + implementation = scripts / "run_opencode_review_model_pool_impl.sh" + implementation.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + 'printf \'%s\\n\' "${OPENCODE_MODEL_CANDIDATES:-}" > "${CANDIDATE_CAPTURE:?}"\n' + 'first_candidate="${OPENCODE_MODEL_CANDIDATES%% *}"\n' + 'opencode run prompt --model "$first_candidate"\n', + encoding="utf-8", + ) + implementation.chmod(0o755) + fake_bin = root / "fake-bin" + fake_bin.mkdir() + fake_opencode = fake_bin / "opencode" + fake_opencode.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") + fake_opencode.chmod(0o755) + capture = root / "candidates.txt" + return root, fake_bin, capture + + +def execute_wrapper( + sandbox: tuple[Path, Path, Path], + source: Path, + base_sha: str, + head_sha: str, + candidates: str, + *, + repository_is_private: str, +) -> tuple[subprocess.CompletedProcess[str], list[str]]: + """Execute the sandbox wrapper and return its final ordered candidate pool.""" + root, fake_bin, capture = sandbox + env = os.environ.copy() + env.update( + { + "CANDIDATE_CAPTURE": str(capture), + "GITHUB_WORKSPACE": str(root), + "OPENCODE_MODEL_CANDIDATES": candidates, + "OPENCODE_REPOSITORY_IS_PRIVATE": repository_is_private, + "OPENCODE_SOURCE_WORKDIR": str(source), + "PATH": f"{fake_bin}:{env['PATH']}", + "PR_BASE_SHA": base_sha, + "PR_HEAD_SHA": head_sha, + "RUNNER_TEMP": str(root / "runner-temp"), + } + ) + result = run("bash", str(root / "scripts" / "ci" / WRAPPER.name), cwd=root, env=env) + captured = capture.read_text(encoding="utf-8").split() if capture.exists() else [] + return result, captured + + +def test_unchanged_trusted_base_policy_prepends_anonymous_free_pool( + tmp_path: Path, + sandbox: tuple[Path, Path, Path], +) -> None: + """A private later PR receives governed free candidates from its base policy.""" + source, base_sha, head_sha = create_source_repository( + tmp_path, + base_has_policy=True, + head_changes_policy=False, + ) + + result, candidates = execute_wrapper( + sandbox, + source, + base_sha, + head_sha, + "github-models/openai/gpt-5", + repository_is_private="true", + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert candidates[: len(EXPECTED_FREE_CANDIDATES)] == EXPECTED_FREE_CANDIDATES + assert candidates[-1] == "github-models/openai/gpt-5" + assert "unchanged trusted base policy" in result.stdout + + +def test_policy_added_in_current_head_cannot_enable_that_head( + tmp_path: Path, + sandbox: tuple[Path, Path, Path], +) -> None: + """The private PR that adds the opt-in remains on its preexisting keyed pool.""" + source, base_sha, head_sha = create_source_repository( + tmp_path, + base_has_policy=False, + head_changes_policy=True, + ) + + result, candidates = execute_wrapper( + sandbox, + source, + base_sha, + head_sha, + "github-models/openai/gpt-5", + repository_is_private="true", + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert candidates == ["github-models/openai/gpt-5"] + assert "unchanged trusted base policy" not in result.stdout + + +def test_policyless_private_preconfigured_free_pool_fails_closed( + tmp_path: Path, + sandbox: tuple[Path, Path, Path], +) -> None: + """Private callers cannot bypass immutable-base policy with a free candidate.""" + source, base_sha, head_sha = create_source_repository( + tmp_path, + base_has_policy=False, + head_changes_policy=False, + ) + original = "opencode-free/big-pickle github-models/openai/gpt-5" + + result, candidates = execute_wrapper( + sandbox, + source, + base_sha, + head_sha, + original, + repository_is_private="true", + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert candidates == ["github-models/openai/gpt-5"] + assert "unchanged trusted base policy" not in result.stdout + + +def test_unknown_visibility_preconfigured_free_pool_fails_closed( + tmp_path: Path, + sandbox: tuple[Path, Path, Path], +) -> None: + """Missing visibility evidence cannot authorize anonymous model egress.""" + source, base_sha, head_sha = create_source_repository( + tmp_path, + base_has_policy=False, + head_changes_policy=False, + ) + + result, candidates = execute_wrapper( + sandbox, + source, + base_sha, + head_sha, + "opencode-free/big-pickle github-models/openai/gpt-5", + repository_is_private="unknown", + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert candidates == ["github-models/openai/gpt-5"] + + +def test_existing_public_free_pool_is_preserved_but_unknown_aliases_are_removed( + tmp_path: Path, + sandbox: tuple[Path, Path, Path], +) -> None: + """Verified public callers keep supported free candidates and drop stale aliases.""" + source, base_sha, head_sha = create_source_repository( + tmp_path, + base_has_policy=False, + head_changes_policy=False, + ) + original = ( + "opencode-free/big-pickle opencode-free/glm-5-free " + "github-models/openai/gpt-5" + ) + + result, candidates = execute_wrapper( + sandbox, + source, + base_sha, + head_sha, + original, + repository_is_private="false", + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert candidates == ["opencode-free/big-pickle", "github-models/openai/gpt-5"] + + +def test_governed_candidates_are_already_supported_by_the_central_workflow() -> None: + """The wrapper cannot introduce a model absent from generated OpenCode config.""" + workflow = ROOT / ".github" / "workflows" / "opencode-review-dispatch.yml" + if not workflow.exists(): + pytest.skip("central workflow is outside the focused local fixture") + workflow_text = workflow.read_text(encoding="utf-8") + + for candidate in EXPECTED_FREE_CANDIDATES: + assert candidate in workflow_text + + +def test_delegated_runner_validates_integer_runtime_controls() -> None: + """Every arithmetic retry/runtime environment control has an integer fallback.""" + implementation_text = IMPLEMENTATION.read_text(encoding="utf-8") + + expected = ( + 'run_timeout_seconds="$(env_integer_or_default OPENCODE_RUN_TIMEOUT_SECONDS 3600)"', + 'export_timeout_seconds="$(env_integer_or_default OPENCODE_EXPORT_TIMEOUT_SECONDS 120)"', + 'attempts="$(env_integer_or_default OPENCODE_MODEL_ATTEMPTS 3)"', + 'original_run_timeout="$(env_integer_or_default OPENCODE_RUN_TIMEOUT_SECONDS 3600)"', + 'budget_seconds="$(env_integer_or_default OPENCODE_TOTAL_RETRY_BUDGET_SECONDS 1500)"', + 'max_cycles="$(env_integer_or_default OPENCODE_POOL_MAX_CYCLES 0)"', + 'cycle_sleep="$(env_integer_or_default OPENCODE_POOL_CYCLE_SLEEP_SECONDS 60)"', + ) + for marker in expected: + assert marker in implementation_text + + +def test_wrapper_registers_cleanup_before_guard_install_and_disables_globbing() -> None: + """Provider-guard setup cleans partial state and treats candidate text literally.""" + wrapper_text = WRAPPER.read_text(encoding="utf-8") + + assert wrapper_text.index("trap cleanup_provider_guard EXIT INT TERM") < wrapper_text.index( + "\ninstall_provider_guard\n" + ) + assert 'read -r -a candidates <<<"${OPENCODE_MODEL_CANDIDATES:-}"' in wrapper_text + assert ( + 'read -r -a candidates <<<"$anonymous_free_candidates ${OPENCODE_MODEL_CANDIDATES:-}"' + in wrapper_text + ) + assert wrapper_text.count('for candidate in "${candidates[@]}"') >= 2 + + +def test_delegated_runner_validates_poll_interval_and_cleans_normalization_probe() -> None: + """Invalid poll configuration cannot busy-spin and gate failures still clean probes.""" + implementation_text = IMPLEMENTATION.read_text(encoding="utf-8") + + assert ( + 'fatal_poll_seconds="$(env_integer_or_default OPENCODE_FATAL_ERROR_POLL_SECONDS 5)"' + in implementation_text + ) + assert '[ "$fatal_poll_seconds" -gt 0 ] || fatal_poll_seconds=5' in implementation_text + assert '"$HEAD_SHA" "$RUN_ID" "$RUN_ATTEMPT" "$probe" >/dev/null || rc=$?' in implementation_text + + +def test_wrapper_and_guard_parse_as_strict_bash() -> None: + """Both shell boundaries remain syntactically valid under Bash.""" + for script in (WRAPPER, PROVIDER_GUARD): + result = run("bash", "-n", str(script), cwd=ROOT) + assert result.returncode == 0, result.stderr diff --git a/tests/test_opencode_provider_guard.py b/tests/test_opencode_provider_guard.py new file mode 100644 index 000000000..db7211e0c --- /dev/null +++ b/tests/test_opencode_provider_guard.py @@ -0,0 +1,258 @@ +"""Credential-isolation tests for one OpenCode model candidate process.""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +GUARD = ROOT / "scripts" / "ci" / "opencode_provider_guard.sh" +ALL_CREDENTIALS = { + "GH_TOKEN": "gh-secret", + "GITHUB_TOKEN": "github-secret", + "OPENCODE_APP_TOKEN": "app-secret", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "oidc-secret", + "ACTIONS_ID_TOKEN_REQUEST_URL": "https://oidc.invalid", + "ACTIONS_RUNTIME_TOKEN": "runtime-secret", + "ACTIONS_CACHE_URL": "https://cache.invalid", + "ACTIONS_RESULTS_URL": "https://results.invalid", + "ACTIONS_RUNTIME_URL": "https://runtime.invalid", + "STRIX_GITHUB_MODELS_TOKEN": "models-secret", + "OPENCODE_API_KEY": "zen-secret", + "OPENAI_API_KEY": "openai-secret", + "OPENROUTER_API_KEY": "openrouter-secret", + "NVIDIA_API_KEY": "nvidia-normalized-secret", + "NVIDIA_NIM_API_KEY": "nvidia-source-secret", +} +COMMON_SENSITIVE_NAMES = { + "GH_TOKEN", + "GITHUB_TOKEN", + "OPENCODE_APP_TOKEN", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "ACTIONS_ID_TOKEN_REQUEST_URL", + "ACTIONS_RUNTIME_TOKEN", + "ACTIONS_CACHE_URL", + "ACTIONS_RESULTS_URL", + "ACTIONS_RUNTIME_URL", +} +PROVIDER_NAMES = { + "STRIX_GITHUB_MODELS_TOKEN", + "OPENCODE_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "NVIDIA_API_KEY", + "NVIDIA_NIM_API_KEY", +} + + +@pytest.fixture +def fake_opencode(tmp_path: Path) -> Path: + """Create a fake OpenCode executable that prints selected environment keys.""" + script = tmp_path / "fake-opencode.py" + script.write_text( + "#!/usr/bin/env python3\n" + "import json, os, sys\n" + f"names = {sorted(ALL_CREDENTIALS)!r}\n" + "print(json.dumps({name: os.environ.get(name) for name in names}))\n" + "print(json.dumps(sys.argv[1:]))\n", + encoding="utf-8", + ) + script.chmod(0o755) + return script + + +def run_guard(real_bin: str, *arguments: str) -> subprocess.CompletedProcess[str]: + """Run the real guard with an explicit child executable and credentials.""" + env = os.environ.copy() + env.update(ALL_CREDENTIALS) + env["OPENCODE_REAL_BIN"] = real_bin + return subprocess.run( + ["bash", str(GUARD), *arguments], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def invoke(fake_opencode: Path, *arguments: str) -> tuple[dict[str, str | None], list[str]]: + """Run the real guard and parse the fake executable's observations.""" + result = run_guard(str(fake_opencode), *arguments) + assert result.returncode == 0, result.stdout + result.stderr + lines = result.stdout.splitlines() + return json.loads(lines[0]), json.loads(lines[1]) + + +def assert_absent(observed: dict[str, str | None], names: set[str]) -> None: + """Assert every named credential was removed from the child environment.""" + assert {name for name in names if observed[name] is not None} == set() + + +def test_anonymous_free_model_receives_no_github_oidc_or_provider_credentials( + fake_opencode: Path, +) -> None: + """Anonymous free candidates never inherit unrelated automation secrets.""" + observed, argv = invoke( + fake_opencode, + "run", + "review prompt", + "--model", + "opencode-free/nemotron-3-ultra-free", + ) + + assert_absent(observed, COMMON_SENSITIVE_NAMES | PROVIDER_NAMES) + assert argv[-2:] == ["--model", "opencode-free/nemotron-3-ultra-free"] + + +@pytest.mark.parametrize( + ("candidate", "kept"), + [ + ("nvidia-nim/nvidia/nemotron-3-super-120b-a12b", {"NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"}), + ("opencode/gpt-5.6-terra", {"OPENCODE_API_KEY"}), + ("openai/gpt-5.4", {"OPENAI_API_KEY"}), + ("openrouter/openai/gpt-5.4", {"OPENROUTER_API_KEY"}), + ("github-models/openai/gpt-5", {"STRIX_GITHUB_MODELS_TOKEN"}), + ], +) +def test_paid_or_scoped_candidate_receives_only_its_provider_credentials( + fake_opencode: Path, + candidate: str, + kept: set[str], +) -> None: + """Each keyed provider is isolated from every other provider credential.""" + observed, _ = invoke(fake_opencode, "run", "prompt", "--model", candidate) + + assert_absent(observed, COMMON_SENSITIVE_NAMES | (PROVIDER_NAMES - kept)) + assert {name for name in kept if observed[name] is not None} == kept + + +@pytest.mark.parametrize( + "arguments", + [ + ("run", "prompt", "-m", "openai/gpt-5.4"), + ("run", "prompt", "-m=openai/gpt-5.4"), + ], +) +def test_short_model_alias_keeps_only_selected_provider_credentials( + fake_opencode: Path, + arguments: tuple[str, ...], +) -> None: + """OpenCode's ``-m`` aliases receive the same scoped provider credential.""" + observed, _ = invoke(fake_opencode, *arguments) + + assert_absent(observed, COMMON_SENSITIVE_NAMES | (PROVIDER_NAMES - {"OPENAI_API_KEY"})) + assert observed["OPENAI_API_KEY"] == "openai-secret" + + +def test_equals_form_model_argument_keeps_only_selected_provider_credentials( + fake_opencode: Path, +) -> None: + """The supported ``--model=value`` form receives the same scoped credential.""" + observed, argv = invoke(fake_opencode, "run", "prompt", "--model=openai/gpt-5.4") + + assert_absent(observed, COMMON_SENSITIVE_NAMES | (PROVIDER_NAMES - {"OPENAI_API_KEY"})) + assert observed["OPENAI_API_KEY"] == "openai-secret" + assert argv[-1] == "--model=openai/gpt-5.4" + + +def test_option_terminator_stops_model_selector_parsing(fake_opencode: Path) -> None: + """Arguments after ``--`` cannot re-enable provider credentials in the guard.""" + observed, argv = invoke( + fake_opencode, + "run", + "prompt", + "--", + "--model", + "openai/gpt-5.4", + ) + + assert_absent(observed, COMMON_SENSITIVE_NAMES | PROVIDER_NAMES) + assert argv[-3:] == ["--", "--model", "openai/gpt-5.4"] + + +def test_duplicate_model_arguments_fail_closed_before_model_execution( + fake_opencode: Path, +) -> None: + """Ambiguous duplicate model selectors are rejected before secrets reach a child.""" + result = run_guard( + str(fake_opencode), + "run", + "prompt", + "--model", + "openai/gpt-5.4", + "-m=opencode/gpt-5.6-terra", + ) + + assert result.returncode == 64 + assert result.stdout == "" + assert "exactly one" in result.stderr + + +@pytest.mark.parametrize( + ("arguments", "error_fragment"), + [ + (("run", "prompt", "--model", "-m=openai/gpt-5.4"), "exactly one"), + (("run", "prompt", "--model", "--"), "requires a model candidate"), + (("run", "prompt", "--model", "--model=openai/gpt-5.4"), "exactly one"), + ], +) +def test_selector_like_model_values_fail_closed_before_child_execution( + fake_opencode: Path, + arguments: tuple[str, ...], + error_fragment: str, +) -> None: + """A pending model selector may not consume another selector or ``--`` as its value.""" + result = run_guard(str(fake_opencode), *arguments) + + assert result.returncode == 64 + assert result.stdout == "" + assert error_fragment in result.stderr + + +@pytest.mark.parametrize( + ("real_bin", "arguments", "expected_code", "error_fragment"), + [ + ("FAKE", (), 64, "Usage:"), + ("", ("run", "prompt", "--model", "openai/gpt-5.4"), 69, "OPENCODE_REAL_BIN"), + ("/definitely/not/executable", ("run", "prompt", "--model", "openai/gpt-5.4"), 69, "OPENCODE_REAL_BIN"), + ("FAKE", ("run", "prompt", "--model"), 64, "requires a model candidate"), + ("FAKE", ("run", "prompt", "-m"), 64, "requires a model candidate"), + ], +) +def test_guard_argument_and_executable_failures_stop_before_child_execution( + fake_opencode: Path, + real_bin: str, + arguments: tuple[str, ...], + expected_code: int, + error_fragment: str, +) -> None: + """Malformed invocation boundaries fail closed before any credential-bearing child.""" + resolved_bin = str(fake_opencode) if real_bin == "FAKE" else real_bin + result = run_guard(resolved_bin, *arguments) + + assert result.returncode == expected_code + assert result.stdout == "" + assert error_fragment in result.stderr + + +def test_export_receives_no_provider_credentials(fake_opencode: Path) -> None: + """Session export is local and does not inherit any provider credential.""" + observed, argv = invoke(fake_opencode, "export", "session_123") + + assert_absent(observed, COMMON_SENSITIVE_NAMES | PROVIDER_NAMES) + assert argv == ["export", "session_123"] + + +def test_unknown_model_prefix_fails_safe_without_provider_credentials( + fake_opencode: Path, +) -> None: + """New providers default to zero credentials until explicitly classified.""" + observed, _ = invoke(fake_opencode, "run", "prompt", "--model", "unknown/model") + + assert_absent(observed, COMMON_SENSITIVE_NAMES | PROVIDER_NAMES)