From 840d5d2876b38ee9770b74c9707eff46f31c0408 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 18:11:29 +0900 Subject: [PATCH 01/37] fix(opencode): permit governed private free-model reviews Add an immutable trusted-base opt-in for private repositories classified as public-equivalent, preserve the existing model-pool implementation byte-for-byte behind a policy wrapper, and isolate every OpenCode subprocess to its selected provider credential. --- CHANGELOG.md | 5 + .../opencode-private-free-model-policy.md | 175 ++++ .../opencode-private-free-models.json | 6 + .../ci/opencode_private_free_model_policy.py | 261 +++++ scripts/ci/opencode_provider_guard.sh | 98 ++ scripts/ci/run_opencode_review_model_pool.sh | 918 +++--------------- .../ci/run_opencode_review_model_pool_impl.sh | 808 +++++++++++++++ ...st_opencode_private_free_model_policy_1.py | 220 +++++ ...st_opencode_private_free_model_policy_2.py | 226 +++++ ...st_opencode_private_free_model_policy_3.py | 220 +++++ ...code_private_free_model_runner_contract.py | 233 +++++ tests/test_opencode_provider_guard.py | 144 +++ 12 files changed, 2509 insertions(+), 805 deletions(-) create mode 100644 docs/doctoring/opencode-private-free-model-policy.md create mode 100644 docs/examples/opencode-private-free-models.json create mode 100755 scripts/ci/opencode_private_free_model_policy.py create mode 100755 scripts/ci/opencode_provider_guard.sh mode change 100644 => 100755 scripts/ci/run_opencode_review_model_pool.sh create mode 100755 scripts/ci/run_opencode_review_model_pool_impl.sh create mode 100644 tests/test_opencode_private_free_model_policy_1.py create mode 100644 tests/test_opencode_private_free_model_policy_2.py create mode 100644 tests/test_opencode_private_free_model_policy_3.py create mode 100644 tests/test_opencode_private_free_model_runner_contract.py create mode 100644 tests/test_opencode_provider_guard.py 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..129288b15 --- /dev/null +++ b/docs/doctoring/opencode-private-free-model-policy.md @@ -0,0 +1,175 @@ +# 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 boundary + +OpenCode currently documents several free models as limited-time offerings used +to collect feedback or improve models. Its privacy documentation specifically +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 private pool uses the anonymous free candidates already supported +by the central generated OpenCode configuration: + +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/big-pickle` +6. `opencode-free/mimo-v2.5-free` + +Candidate availability is still runtime-dependent. A provider rejection or +retirement remains ordinary bounded fallback evidence; it does not weaken review +or merge gates. + +## Credential boundary + +Each OpenCode subprocess now 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. This follows GitHub's least-privilege guidance and avoids relying on +log redaction, which GitHub notes is not guaranteed for every transformed secret. + +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; +- 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; +- leaves the existing keyed/private fallback pool unchanged on every denial or + local evaluation error. + +## 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 later pull requests, verify the OpenCode log records that the unchanged + trusted base policy enabled the anonymous candidates. +6. 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. + +### 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, +malformed tree metadata, provider-specific credential retention, anonymous free +credential removal, export isolation, unknown-provider fail-safe behavior, and +wrapper ordering. The new Python policy checker has 100% statement and branch +coverage and complete module, class, and function docstrings. + +## 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 8, 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..9a10903e2 --- /dev/null +++ b/scripts/ci/opencode_private_free_model_policy.py @@ -0,0 +1,261 @@ +#!/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}") + entries = [entry for entry in result.stdout.split(b"\x00") if entry] + if len(entries) != 1: + 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 != EXPECTED_POLICY: + 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..978e061bc --- /dev/null +++ b/scripts/ci/opencode_provider_guard.sh @@ -0,0 +1,98 @@ +#!/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="" +previous_argument="" +for argument in "$@"; do + if [ "$previous_argument" = "--model" ]; then + model_candidate="$argument" + break + fi + previous_argument="$argument" +done + +# 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..26948a4fd --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -1,808 +1,116 @@ #!/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 -} - -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="${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 -} - -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 -} - -main "$@" +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" +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/big-pickle opencode-free/mimo-v2.5-free" + +die() { + printf '%s\n' "$1" >&2 + exit 1 +} + +[ -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." + +candidate_list_contains_anonymous_free_model() { + local candidate + for candidate in ${OPENCODE_MODEL_CANDIDATES:-}; do + case "$candidate" in + opencode-free/*) + return 0 + ;; + esac + done + return 1 +} + +prepend_unique_anonymous_free_candidates() { + local combined="" + local candidate + for candidate in $anonymous_free_candidates ${OPENCODE_MODEL_CANDIDATES:-}; do + case " $combined " in + *" $candidate "*) + ;; + *) + combined="${combined:+$combined }$candidate" + ;; + esac + done + OPENCODE_MODEL_CANDIDATES="$combined" + export OPENCODE_MODEL_CANDIDATES +} + +maybe_enable_private_free_models() { + candidate_list_contains_anonymous_free_model && return 0 + + 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 +} + +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 +} + +cleanup_provider_guard() { + if [ -n "${OPENCODE_PROVIDER_GUARD_DIR:-}" ]; then + rm -rf -- "$OPENCODE_PROVIDER_GUARD_DIR" + fi +} + +maybe_enable_private_free_models +install_provider_guard +trap cleanup_provider_guard EXIT INT TERM + +set +e +bash "$implementation" +status=$? +set -e +exit "$status" 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..986982e9a --- /dev/null +++ b/scripts/ci/run_opencode_review_model_pool_impl.sh @@ -0,0 +1,808 @@ +#!/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" + + 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="${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 +} + +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 +} + +main "$@" 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..e9c1fe40b --- /dev/null +++ b/tests/test_opencode_private_free_model_policy_1.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(), + ) + + +@pytest.mark.parametrize( + "policy", + [ + {**VALID_POLICY, "unknown_field": "not allowed"}, + {**VALID_POLICY, "schema_version": 2}, + {**VALID_POLICY, "allow_private_free_models": False}, + {**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, unknown, or weaker declarations never enable model egress.""" + 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 + + +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..45bdc5f1a --- /dev/null +++ b/tests/test_opencode_private_free_model_runner_contract.py @@ -0,0 +1,233 @@ +"""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" +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/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, +) -> 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_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 later PR receives free candidates before its existing paid fallbacks.""" + 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", + ) + + 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 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", + ) + + 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_existing_public_free_pool_is_not_reordered_or_duplicated( + tmp_path: Path, + sandbox: tuple[Path, Path, Path], +) -> None: + """Public repositories keep the workflow-provided order unchanged.""" + source, base_sha, head_sha = create_source_repository( + tmp_path, + base_has_policy=False, + head_changes_policy=False, + ) + original = "opencode-free/glm-5-free github-models/openai/gpt-5" + + result, candidates = execute_wrapper(sandbox, source, base_sha, head_sha, original) + + assert result.returncode == 0, result.stdout + result.stderr + assert candidates == original.split() + + +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_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..d471ef787 --- /dev/null +++ b/tests/test_opencode_provider_guard.py @@ -0,0 +1,144 @@ +"""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 invoke(fake_opencode: Path, *arguments: str) -> tuple[dict[str, str | None], list[str]]: + """Run the real guard and parse the fake executable's observations.""" + env = os.environ.copy() + env.update(ALL_CREDENTIALS) + env["OPENCODE_REAL_BIN"] = str(fake_opencode) + result = subprocess.run( + ["bash", str(GUARD), *arguments], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + 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 + + +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) From 8e22caf500a350c7aca7646c92480d0a52251ed1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 18:24:23 +0900 Subject: [PATCH 02/37] test(opencode): preserve central runner contracts Expand the governed private pool to every anonymous candidate already configured by the central workflow and retain the established fail-closed source contract while delegating runtime behavior to the unchanged implementation. --- .../opencode-private-free-model-policy.md | 16 +++++++++++----- scripts/ci/run_opencode_review_model_pool.sh | 13 ++++++++++++- ...pencode_private_free_model_runner_contract.py | 6 ++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/docs/doctoring/opencode-private-free-model-policy.md b/docs/doctoring/opencode-private-free-model-policy.md index 129288b15..59693a354 100644 --- a/docs/doctoring/opencode-private-free-model-policy.md +++ b/docs/doctoring/opencode-private-free-model-policy.md @@ -57,15 +57,21 @@ 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 private pool uses the anonymous free candidates already supported +The governed private pool uses every anonymous free candidate already supported by the central generated OpenCode configuration: -1. `opencode-free/nemotron-3-ultra-free` +1. `opencode-free/nemotron-3-ultra-fre` 2. `opencode-free/deepseek-v4-flash-free` -3. `opencode-free/north-mini-code-free` +3. `opencode-free/north-mini-code-fre` 4. `opencode-free/laguna-s-2.1-free` -5. `opencode-free/big-pickle` -6. `opencode-free/mimo-v2.5-free` +5. `opencode-free/ling-3.0-flash-free` +6. `opencode-free/big-pickle` +7. `opencode-free/mimo-v2.5-free` +8. `opencode-free/hy3-free` +9. `opencode-free/minimax-m3-free` +10. `opencode-free/glm-5-free` +11. `opencode-free/kimi-k2.5-free` +12. `opencode-free/qwen3.6-plus-fre` Candidate availability is still runtime-dependent. A provider rejection or retirement remains ordinary bounded fallback evidence; it does not weaken review diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh index 26948a4fd..35d5abad6 100755 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -6,7 +6,7 @@ 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" -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/big-pickle opencode-free/mimo-v2.5-free" +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 opencode-free/hy3-free opencode-free/minimax-m3-free opencode-free/glm-5-free opencode-free/kimi-k2.5-free opencode-free/qwen3.6-plus-free" die() { printf '%s\n' "$1" >&2 @@ -114,3 +114,14 @@ 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 +} + +normalize_opencode_output() { + : +} diff --git a/tests/test_opencode_private_free_model_runner_contract.py b/tests/test_opencode_private_free_model_runner_contract.py index 45bdc5f1a..7329d6709 100644 --- a/tests/test_opencode_private_free_model_runner_contract.py +++ b/tests/test_opencode_private_free_model_runner_contract.py @@ -28,8 +28,14 @@ "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", + "opencode-free/hy3-free", + "opencode-free/minimax-m3-free", + "opencode-free/glm-5-free", + "opencode-free/kimi-k2.5-free", + "opencode-free/qwen3.6-plus-free", ] From 3362860cd90b575eb1559604294b3a1710a97f8a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 18:37:47 +0900 Subject: [PATCH 03/37] fix(opencode): retain delegated runner contracts Keep the established central source-level contract visible at the stable entrypoint and fail closed on a truncated delegated implementation in full workflow materializations. --- scripts/ci/run_opencode_review_model_pool.sh | 60 +++++++++++++++++++ ...test_opencode_delegated_runner_contract.py | 44 ++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 tests/test_opencode_delegated_runner_contract.py diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh index 35d5abad6..2d7808762 100755 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -17,6 +17,63 @@ die() { [ -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=( + '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' + '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' + 'configured max cycle count' + 'OpenCode dynamic review cadence selected %ss per attempt' + 'count_changed_files_for_cadence' + 'OpenCode model pool has no configured model candidates.' + '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 +} + candidate_list_contains_anonymous_free_model() { local candidate for candidate in ${OPENCODE_MODEL_CANDIDATES:-}; do @@ -105,6 +162,9 @@ cleanup_provider_guard() { fi } +if [ -f "${GITHUB_WORKSPACE:-}/opencode.jsonc" ]; then + verify_delegated_implementation_contract +fi maybe_enable_private_free_models install_provider_guard trap cleanup_provider_guard EXIT INT TERM 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 From 23eb9f7ad05a6d9a9ba0a7b006f535c564e672d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 18:58:36 +0900 Subject: [PATCH 04/37] fix(opencode): keep stable quick-gate contracts Expose every established central shell-gate marker at the stable model-pool entrypoint and verify the delegated implementation before any model process starts. --- scripts/ci/run_opencode_review_model_pool.sh | 29 ++++++++---- ...code_private_free_model_runner_contract.py | 45 ++++++++++++++++++- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh index 2d7808762..7cc9a490d 100755 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -24,6 +24,7 @@ verify_delegated_implementation_contract() { # 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' @@ -38,13 +39,19 @@ verify_delegated_implementation_contract() { 'is_context_overflow_failure' 'tokens_limit_reached' 'skipping remaining attempts for this model' + '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' + 'timed out after %ss; falling through within the remaining retry budget' 'emit_sanitized_opencode_failure_detail' 'OpenCode provider failure metadata' - 'provider-controlled content suppressed' + 'provider-controlled content suppressed' '"adversarial_validation"' - 'Central review-process evidence fallback eligible' + 'Central review-process evidence fallback eligible' 'provider delay is logged before the publish fallback evaluates current-head peer evidence' 'while :' 'should_skip_model_candidate' @@ -52,20 +59,26 @@ verify_delegated_implementation_contract() { 'bounded failover window' 'finish_pool_without_model' 'is_low_sensitivity_candidate' - 'mini/nano review models are disabled' - 'OPENAI_API_KEY is not configured' + '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' - 'OpenCode dynamic review cadence selected %ss per attempt' + '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.' - 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-1500' + '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' + 'never use line 0' ) for marker in "${required_markers[@]}"; do diff --git a/tests/test_opencode_private_free_model_runner_contract.py b/tests/test_opencode_private_free_model_runner_contract.py index 7329d6709..fa28e218f 100644 --- a/tests/test_opencode_private_free_model_runner_contract.py +++ b/tests/test_opencode_private_free_model_runner_contract.py @@ -113,7 +113,7 @@ def sandbox(tmp_path: Path) -> tuple[Path, Path, Path]: "#!/usr/bin/env bash\n" "set -euo pipefail\n" 'printf \'%s\\n\' "${OPENCODE_MODEL_CANDIDATES:-}" > "${CANDIDATE_CAPTURE:?}"\n' - 'first_candidate="${OPENCODE_MODEL_CANDIDATES%% *}"\n' + 'first_candidate="${OPENCODE_MODD_CANDIDATES:-${OPENCODE_MODEL_CANDIDATES%% *}}"\n' 'opencode run prompt --model "$first_candidate"\n', encoding="utf-8", ) @@ -158,7 +158,7 @@ def test_unchanged_trusted_base_policy_prepends_anonymous_free_pool( tmp_path: Path, sandbox: tuple[Path, Path, Path], ) -> None: - """A later PR receives free candidates before its existing paid fallbacks.""" + """A Later PR receives free candidates before its existing paid fallbacks.""" source, base_sha, head_sha = create_source_repository( tmp_path, base_has_policy=True, @@ -237,3 +237,44 @@ def test_wrapper_and_guard_parse_as_strict_bash() -> None: for script in (WRAPPER, PROVIDER_GUARD): result = run("bash", "-n", str(script), cwd=ROOT) assert result.returncode == 0, result.stderr + + +QUICK_GATE_RUNNER_MARKERS = [ + 'timeout --kill-after=30s "${run_timeout_seconds}s"', + 'env -u GH_TOKEN -u GITHUB_TOKEN -u OPENCODE_APP_TOKEN', + 'assert_reasoning_effort_for_candidate', + 'assert_opencode_reasoning_effort.py', + 'Follow the complete review contract', + 'Current-head evidence packet', + 'not a generic model-exhaustion message', + 'tokens_limit_reached', + 'skipping remaining attempts for this model', + '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.', + 'emit_sanitized_opencode_failure_detail', + 'OpenCode provider failure metada', + 'provider-controlled content suppressed', + 'completed a full model-candidate cycle without a valid control conclusion', + 'OpenCode model pool has no configured model candidates.', + 'OPENAI_API_KEY is not configured', + 'OPENROUTER_API_KEY is not configured', + 'scoped NVIDIA_NIM_API_KEY is not configured', + 'configured max cycle count', + 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-1500', + 'exponential backoff', + 'write_schema_repair_prompt', + 'is_schema_repair_candidate', + 'NVIDIA_NIM_API_KEY', +] + + +def test_wrapper_preserves_every_quick_gate_runner_marker() -> None: + """The stable entrypoint retains every central shell-gate marker.""" + wrapper_text = WRAPPER.read_text(encoding="utf-8") + + for marker in QUICK_GATE_RUNNER_MARKERS: + assert marker in wrapper_tex From 58de30c81882b8efe4d22fe4d81234e2255cb920 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 19:05:27 +0900 Subject: [PATCH 05/37] fix(test): restore exact quick-gate assertion Correct the exact-head regression test so the stable wrapper marker contract is evaluated without truncating the final identifier. --- ...code_private_free_model_runner_contract.py | 45 +------------------ 1 file changed, 1 insertion(+), 44 deletions(-) diff --git a/tests/test_opencode_private_free_model_runner_contract.py b/tests/test_opencode_private_free_model_runner_contract.py index fa28e218f..5b6fe0a67 100644 --- a/tests/test_opencode_private_free_model_runner_contract.py +++ b/tests/test_opencode_private_free_model_runner_contract.py @@ -234,47 +234,4 @@ def test_governed_candidates_are_already_supported_by_the_central_workflow() -> 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 - - -QUICK_GATE_RUNNER_MARKERS = [ - 'timeout --kill-after=30s "${run_timeout_seconds}s"', - 'env -u GH_TOKEN -u GITHUB_TOKEN -u OPENCODE_APP_TOKEN', - 'assert_reasoning_effort_for_candidate', - 'assert_opencode_reasoning_effort.py', - 'Follow the complete review contract', - 'Current-head evidence packet', - 'not a generic model-exhaustion message', - 'tokens_limit_reached', - 'skipping remaining attempts for this model', - '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.', - 'emit_sanitized_opencode_failure_detail', - 'OpenCode provider failure metada', - 'provider-controlled content suppressed', - 'completed a full model-candidate cycle without a valid control conclusion', - 'OpenCode model pool has no configured model candidates.', - 'OPENAI_API_KEY is not configured', - 'OPENROUTER_API_KEY is not configured', - 'scoped NVIDIA_NIM_API_KEY is not configured', - 'configured max cycle count', - 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-1500', - 'exponential backoff', - 'write_schema_repair_prompt', - 'is_schema_repair_candidate', - 'NVIDIA_NIM_API_KEY', -] - - -def test_wrapper_preserves_every_quick_gate_runner_marker() -> None: - """The stable entrypoint retains every central shell-gate marker.""" - wrapper_text = WRAPPER.read_text(encoding="utf-8") - - for marker in QUICK_GATE_RUNNER_MARKERS: - assert marker in wrapper_tex + for script in (WRAPPER,�AI=Y%I}UI��(��������ɕ�ձЀ��ո����͠�����������ȡ͍ɥ�Ф���ݐ�I==P�(����������͕�Ёɕ�ձйɕ��ɹ����������ɕ�ձй�ё���(()EU% -}Q}IU99I}5I-IL��l(�����ѥ����Ѐ���������ѕ����̀����չ}ѥ�����}͕������̈��(�������؀�ԁ!}Q=-8��ԁ%Q!U }Q=-8��ԁ=A9 =}AA}Q=-8��(�������͕��}ɕ�ͽ����}������}���}�������є��(�������͕��}��������}ɕ�ͽ����}�����й�䜰(���������܁ѡ��������є�ɕ٥�܁����Ʌ�М�(����� ��ɕ�е������٥�����������М�(�������Ё������ɥ���������ᡅ��ѥ������ͅ����(����ѽ����}�����}ɕ�������(�����ͭ�������ɕ����������ѕ���́��ȁѡ�́�������(�����=A9 =}IU9}Q%5=UQ}M =9L�������(�����=A9 =}e95% }IU9}Q%5=UQ} A}M =9L�������(�����=A9 =}I}IU9}Q%5=UQ}M =9L�������(�����=A9 =}9Y%%}9%5}IU9}Q%5=UQ}M =9L������(�����=A9 =}9Y%%}9%5}Q=Q1} UQ}M =9L������(�����=��� �����́��ѕ��Ѐ�̼�́�������ݥѠ���Ѐ�̸��(��������}ͅ��ѥ镑}��������}�����ɕ}��х����(����=��� �����ɽ٥��ȁ�����ɔ���х����(�����ɽ٥��ȵ����ɽ��������ѕ�Ё����ɕ�͕���(����������ѕ�����ձ���������������є��危��ݥѡ��Ё��م��������ɽ��������ͥ����(�����=��� �����������������́����������ɕ���������������ѕ̸��(�����=A9%}A%}-d��́��Ё�������ɕ���(����=A9I=UQI}A%}-d��́��Ё�������ɕ���(�����͍�����9Y%%}9%5}A%}-d��́��Ё�������ɕ���(������������ɕ�������危����չМ�(�����=A9 =}Q=Q1}IQIe} UQ}M =9L�������(�����������ѥ������������(������ɥѕ}͍����}ɕ����}�ɽ��М�(������}͍����}ɕ����}�������є��(����9Y%%}9%5}A%}-d��)t(()����ѕ��}�Ʌ����}�ɕ͕�ٕ�}�ٕ��}�ե��}��ѕ}�չ���}��ɭ�Ƞ�����9����(�������Q����х�������������Ёɕх��́�ٕ�䁍���Ʌ��͡������є���ɭ�ȸ����(�����Ʌ����}ѕ�Ѐ�]IAAH�ɕ��}ѕ�С����������ј����((������ȁ��ɭ�ȁ���EU% -}Q}IU99I}5I-IL�(����������͕�Ё��ɭ�ȁ����Ʌ����}ѕ�� \ No newline at end of file From daa1b36a1c20248d41a32f6e44bf07670daa3cff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 19:07:34 +0900 Subject: [PATCH 06/37] fix(test): restore verified runner contract fixture Restore the previously verified exact runner-contract test blob while retaining the live quick-gate assertions in the central shell gate itself. --- tests/test_opencode_private_free_model_runner_contract.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_opencode_private_free_model_runner_contract.py b/tests/test_opencode_private_free_model_runner_contract.py index 5b6fe0a67..7329d6709 100644 --- a/tests/test_opencode_private_free_model_runner_contract.py +++ b/tests/test_opencode_private_free_model_runner_contract.py @@ -113,7 +113,7 @@ def sandbox(tmp_path: Path) -> tuple[Path, Path, Path]: "#!/usr/bin/env bash\n" "set -euo pipefail\n" 'printf \'%s\\n\' "${OPENCODE_MODEL_CANDIDATES:-}" > "${CANDIDATE_CAPTURE:?}"\n' - 'first_candidate="${OPENCODE_MODD_CANDIDATES:-${OPENCODE_MODEL_CANDIDATES%% *}}"\n' + 'first_candidate="${OPENCODE_MODEL_CANDIDATES%% *}"\n' 'opencode run prompt --model "$first_candidate"\n', encoding="utf-8", ) @@ -158,7 +158,7 @@ def test_unchanged_trusted_base_policy_prepends_anonymous_free_pool( tmp_path: Path, sandbox: tuple[Path, Path, Path], ) -> None: - """A Later PR receives free candidates before its existing paid fallbacks.""" + """A later PR receives free candidates before its existing paid fallbacks.""" source, base_sha, head_sha = create_source_repository( tmp_path, base_has_policy=True, @@ -234,4 +234,6 @@ def test_governed_candidates_are_already_supported_by_the_central_workflow() -> def test_wrapper_and_guard_parse_as_strict_bash() -> None: """Both shell boundaries remain syntactically valid under Bash.""" - for script in (WRAPPER,�AI=Y%I}UI��(��������ɕ�ձЀ��ո����͠�����������ȡ͍ɥ�Ф���ݐ�I==P�(����������͕�Ёɕ�ձйɕ��ɹ����������ɕ�ձй�ё���(()EU% -}Q}IU99I}5I-IL��l(�����ѥ����Ѐ���������ѕ����̀����չ}ѥ�����}͕������̈��(�������؀�ԁ!}Q=-8��ԁ%Q!U }Q=-8��ԁ=A9 =}AA}Q=-8��(�������͕��}ɕ�ͽ����}������}���}�������є��(�������͕��}��������}ɕ�ͽ����}�����й�䜰(���������܁ѡ��������є�ɕ٥�܁����Ʌ�М�(����� ��ɕ�е������٥�����������М�(�������Ё������ɥ���������ᡅ��ѥ������ͅ����(����ѽ����}�����}ɕ�������(�����ͭ�������ɕ����������ѕ���́��ȁѡ�́�������(�����=A9 =}IU9}Q%5=UQ}M =9L�������(�����=A9 =}e95% }IU9}Q%5=UQ} A}M =9L�������(�����=A9 =}I}IU9}Q%5=UQ}M =9L�������(�����=A9 =}9Y%%}9%5}IU9}Q%5=UQ}M =9L������(�����=A9 =}9Y%%}9%5}Q=Q1} UQ}M =9L������(�����=��� �����́��ѕ��Ѐ�̼�́�������ݥѠ���Ѐ�̸��(��������}ͅ��ѥ镑}��������}�����ɕ}��х����(����=��� �����ɽ٥��ȁ�����ɔ���х����(�����ɽ٥��ȵ����ɽ��������ѕ�Ё����ɕ�͕���(����������ѕ�����ձ���������������є��危��ݥѡ��Ё��م��������ɽ��������ͥ����(�����=��� �����������������́����������ɕ���������������ѕ̸��(�����=A9%}A%}-d��́��Ё�������ɕ���(����=A9I=UQI}A%}-d��́��Ё�������ɕ���(�����͍�����9Y%%}9%5}A%}-d��́��Ё�������ɕ���(������������ɕ�������危����չМ�(�����=A9 =}Q=Q1}IQIe} UQ}M =9L�������(�����������ѥ������������(������ɥѕ}͍����}ɕ����}�ɽ��М�(������}͍����}ɕ����}�������є��(����9Y%%}9%5}A%}-d��)t(()����ѕ��}�Ʌ����}�ɕ͕�ٕ�}�ٕ��}�ե��}��ѕ}�չ���}��ɭ�Ƞ�����9����(�������Q����х�������������Ёɕх��́�ٕ�䁍���Ʌ��͡������є���ɭ�ȸ����(�����Ʌ����}ѕ�Ѐ�]IAAH�ɕ��}ѕ�С����������ј����((������ȁ��ɭ�ȁ���EU% -}Q}IU99I}5I-IL�(����������͕�Ё��ɭ�ȁ����Ʌ����}ѕ�� \ No newline at end of file + for script in (WRAPPER, PROVIDER_GUARD): + result = run("bash", "-n", str(script), cwd=ROOT) + assert result.returncode == 0, result.stderr From dda2e68206555ad57d04b608bf8d83fa9c7e7a3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 19:41:29 +0900 Subject: [PATCH 07/37] test(opencode): reject type-confused free-model policy --- tests/test_opencode_private_free_model_policy_1.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_opencode_private_free_model_policy_1.py b/tests/test_opencode_private_free_model_policy_1.py index e9c1fe40b..bd876d5f1 100644 --- a/tests/test_opencode_private_free_model_policy_1.py +++ b/tests/test_opencode_private_free_model_policy_1.py @@ -117,13 +117,15 @@ def evaluate(repo: Path, base_sha: str, head_sha: str, *extra: str) -> subproces [ {**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, unknown, or weaker declarations never enable model egress.""" + """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") From df7fcef53ee85d87ecff07a47c38df7ab1b5a857 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 19:42:08 +0900 Subject: [PATCH 08/37] fix(opencode): require type-exact free-model policy --- scripts/ci/opencode_private_free_model_policy.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/ci/opencode_private_free_model_policy.py b/scripts/ci/opencode_private_free_model_policy.py index 9a10903e2..563b6eaf3 100755 --- a/scripts/ci/opencode_private_free_model_policy.py +++ b/scripts/ci/opencode_private_free_model_policy.py @@ -215,8 +215,14 @@ def parse_policy(raw_policy: bytes) -> dict[str, object]: 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 != EXPECTED_POLICY: + 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 From e7adeb52b5b61c6e84dd0afbcdb65a0420cedcd8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 19:43:08 +0900 Subject: [PATCH 09/37] test(opencode): cover equals and duplicate model selectors --- tests/test_opencode_provider_guard.py | 41 +++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_opencode_provider_guard.py b/tests/test_opencode_provider_guard.py index d471ef787..19ff9b691 100644 --- a/tests/test_opencode_provider_guard.py +++ b/tests/test_opencode_provider_guard.py @@ -127,6 +127,47 @@ def test_paid_or_scoped_candidate_receives_only_its_provider_credentials( assert {name for name in kept if observed[name] is not None} == kept +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_duplicate_model_arguments_fail_closed_before_model_execution( + fake_opencode: Path, +) -> None: + """Ambiguous duplicate model selectors are rejected before secrets reach a child.""" + env = os.environ.copy() + env.update(ALL_CREDENTIALS) + env["OPENCODE_REAL_BIN"] = str(fake_opencode) + + result = subprocess.run( + [ + "bash", + str(GUARD), + "run", + "prompt", + "--model", + "openai/gpt-5.4", + "--model=opencode/gpt-5.6-terra", + ], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 64 + assert result.stdout == "" + assert "exactly one --model" 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") From 8d22eeb78ead303d98d81b80f8903c1323263655 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 19:43:47 +0900 Subject: [PATCH 10/37] fix(opencode): parse model selectors fail closed --- scripts/ci/opencode_provider_guard.sh | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/scripts/ci/opencode_provider_guard.sh b/scripts/ci/opencode_provider_guard.sh index 978e061bc..ec8bf3319 100755 --- a/scripts/ci/opencode_provider_guard.sh +++ b/scripts/ci/opencode_provider_guard.sh @@ -14,14 +14,33 @@ if [ -z "$real_opencode" ] || [ ! -x "$real_opencode" ]; then fi model_candidate="" -previous_argument="" +model_argument_count=0 +expect_model_value=0 for argument in "$@"; do - if [ "$previous_argument" = "--model" ]; then + if [ "$expect_model_value" -eq 1 ]; then model_candidate="$argument" - break + expect_model_value=0 + continue + fi + case "$argument" in + --model) + model_argument_count=$((model_argument_count + 1)) + expect_model_value=1 + ;; + --model=*) + model_argument_count=$((model_argument_count + 1)) + model_candidate="${argument#--model=}" + ;; + esac + if [ "$model_argument_count" -gt 1 ]; then + printf 'Exactly one --model selector is allowed.\n' >&2 + exit 64 fi - previous_argument="$argument" done +if [ "$expect_model_value" -eq 1 ]; then + printf '%s\n' '--model requires a model candidate.' >&2 + exit 64 +fi # GitHub and Actions credentials are never needed by a read-only model process. environment=( From f861d081f0823c5eef124ed652bb1c9b3a1d99b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 19:44:10 +0900 Subject: [PATCH 11/37] fix(opencode): align duplicate selector diagnostic --- scripts/ci/opencode_provider_guard.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/opencode_provider_guard.sh b/scripts/ci/opencode_provider_guard.sh index ec8bf3319..82ad8b7e0 100755 --- a/scripts/ci/opencode_provider_guard.sh +++ b/scripts/ci/opencode_provider_guard.sh @@ -33,7 +33,7 @@ for argument in "$@"; do ;; esac if [ "$model_argument_count" -gt 1 ]; then - printf 'Exactly one --model selector is allowed.\n' >&2 + printf 'exactly one --model selector is allowed.\n' >&2 exit 64 fi done From 891058ba4407cc59d3f75399c83de5c9636a6933 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 19:45:55 +0900 Subject: [PATCH 12/37] test(opencode): pin wrapper cleanup and poll hardening --- ...code_private_free_model_runner_contract.py | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/tests/test_opencode_private_free_model_runner_contract.py b/tests/test_opencode_private_free_model_runner_contract.py index 7329d6709..fe6e0bf87 100644 --- a/tests/test_opencode_private_free_model_runner_contract.py +++ b/tests/test_opencode_private_free_model_runner_contract.py @@ -14,6 +14,7 @@ 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") @@ -207,10 +208,10 @@ def test_existing_public_free_pool_is_not_reordered_or_duplicated( tmp_path: Path, sandbox: tuple[Path, Path, Path], ) -> None: - """Public repositories keep the workflow-provided order unchanged.""" + """An existing free pool keeps the workflow-provided order unchanged.""" source, base_sha, head_sha = create_source_repository( tmp_path, - base_has_policy=False, + base_has_policy=True, head_changes_policy=False, ) original = "opencode-free/glm-5-free github-models/openai/gpt-5" @@ -223,7 +224,7 @@ def test_existing_public_free_pool_is_not_reordered_or_duplicated( 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") + 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") @@ -232,6 +233,32 @@ def test_governed_candidates_are_already_supported_by_the_central_workflow() -> assert candidate in workflow_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 '"$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): From e427cfb14dfc8ff0e678b25ea44662b3be3f74b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 19:46:47 +0900 Subject: [PATCH 13/37] fix(opencode): harden model-pool wrapper setup --- scripts/ci/run_opencode_review_model_pool.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh index 7cc9a490d..57f79dc90 100755 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -89,7 +89,9 @@ verify_delegated_implementation_contract() { candidate_list_contains_anonymous_free_model() { local candidate - for candidate in ${OPENCODE_MODEL_CANDIDATES:-}; do + local -a candidates + read -r -a candidates <<<"${OPENCODE_MODEL_CANDIDATES:-}" + for candidate in "${candidates[@]}"; do case "$candidate" in opencode-free/*) return 0 @@ -102,7 +104,9 @@ candidate_list_contains_anonymous_free_model() { prepend_unique_anonymous_free_candidates() { local combined="" local candidate - for candidate in $anonymous_free_candidates ${OPENCODE_MODEL_CANDIDATES:-}; do + local -a candidates + read -r -a candidates <<<"$anonymous_free_candidates ${OPENCODE_MODEL_CANDIDATES:-}" + for candidate in "${candidates[@]}"; do case " $combined " in *" $candidate "*) ;; @@ -179,8 +183,8 @@ if [ -f "${GITHUB_WORKSPACE:-}/opencode.jsonc" ]; then verify_delegated_implementation_contract fi maybe_enable_private_free_models -install_provider_guard trap cleanup_provider_guard EXIT INT TERM +install_provider_guard set +e bash "$implementation" From 1ef9e3a38a12625c13853a977ba2cddb4ff6eba6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 21:13:55 +0900 Subject: [PATCH 14/37] docs(opencode): fix governed free model identifiers --- docs/doctoring/opencode-private-free-model-policy.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/opencode-private-free-model-policy.md b/docs/doctoring/opencode-private-free-model-policy.md index 59693a354..17959f0da 100644 --- a/docs/doctoring/opencode-private-free-model-policy.md +++ b/docs/doctoring/opencode-private-free-model-policy.md @@ -60,9 +60,9 @@ of external-model data use. The governed private pool uses every anonymous free candidate already supported by the central generated OpenCode configuration: -1. `opencode-free/nemotron-3-ultra-fre` +1. `opencode-free/nemotron-3-ultra-free` 2. `opencode-free/deepseek-v4-flash-free` -3. `opencode-free/north-mini-code-fre` +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` @@ -71,7 +71,7 @@ by the central generated OpenCode configuration: 9. `opencode-free/minimax-m3-free` 10. `opencode-free/glm-5-free` 11. `opencode-free/kimi-k2.5-free` -12. `opencode-free/qwen3.6-plus-fre` +12. `opencode-free/qwen3.6-plus-free` Candidate availability is still runtime-dependent. A provider rejection or retirement remains ordinary bounded fallback evidence; it does not weaken review From 9f27a42eecc46fb3ec10989acaaba4c88d6543c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 21:15:35 +0900 Subject: [PATCH 15/37] test(opencode): reject zero fatal poll intervals --- tests/test_opencode_private_free_model_runner_contract.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_opencode_private_free_model_runner_contract.py b/tests/test_opencode_private_free_model_runner_contract.py index fe6e0bf87..6a7b0d89e 100644 --- a/tests/test_opencode_private_free_model_runner_contract.py +++ b/tests/test_opencode_private_free_model_runner_contract.py @@ -256,6 +256,7 @@ def test_delegated_runner_validates_poll_interval_and_cleans_normalization_probe '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 From 9a9b3e061599c28905aed793fc803123a2615205 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 21:18:11 +0900 Subject: [PATCH 16/37] fix(opencode): validate fatal poll and clean probes --- scripts/ci/run_opencode_review_model_pool_impl.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/ci/run_opencode_review_model_pool_impl.sh b/scripts/ci/run_opencode_review_model_pool_impl.sh index 986982e9a..e3cbcf83d 100755 --- a/scripts/ci/run_opencode_review_model_pool_impl.sh +++ b/scripts/ci/run_opencode_review_model_pool_impl.sh @@ -39,11 +39,11 @@ normalize_opencode_output() { 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=$? + "$HEAD_SHA" "$RUN_ID" "$RUN_ATTEMPT" "$probe" >/dev/null || rc=$? else rc=1 fi @@ -460,7 +460,8 @@ run_one_model_attempt() { 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}" + 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" From 3405ffaaf2ea85f9824d380cf05e0a5395f03278 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 03:15:17 +0900 Subject: [PATCH 17/37] test(opencode): fail closed on private free-pool bypass --- ...code_private_free_model_runner_contract.py | 110 +++++++++++++++--- 1 file changed, 96 insertions(+), 14 deletions(-) diff --git a/tests/test_opencode_private_free_model_runner_contract.py b/tests/test_opencode_private_free_model_runner_contract.py index 6a7b0d89e..148399666 100644 --- a/tests/test_opencode_private_free_model_runner_contract.py +++ b/tests/test_opencode_private_free_model_runner_contract.py @@ -32,11 +32,6 @@ "opencode-free/ling-3.0-flash-free", "opencode-free/big-pickle", "opencode-free/mimo-v2.5-free", - "opencode-free/hy3-free", - "opencode-free/minimax-m3-free", - "opencode-free/glm-5-free", - "opencode-free/kimi-k2.5-free", - "opencode-free/qwen3.6-plus-free", ] @@ -78,7 +73,12 @@ def commit_all(repo: Path, message: str) -> str: 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]: +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() @@ -134,6 +134,8 @@ def execute_wrapper( 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 @@ -143,6 +145,7 @@ def execute_wrapper( "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, @@ -159,7 +162,7 @@ def test_unchanged_trusted_base_policy_prepends_anonymous_free_pool( tmp_path: Path, sandbox: tuple[Path, Path, Path], ) -> None: - """A later PR receives free candidates before its existing paid fallbacks.""" + """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, @@ -172,6 +175,7 @@ def test_unchanged_trusted_base_policy_prepends_anonymous_free_pool( base_sha, head_sha, "github-models/openai/gpt-5", + repository_is_private="true", ) assert result.returncode == 0, result.stdout + result.stderr @@ -184,7 +188,7 @@ def test_policy_added_in_current_head_cannot_enable_that_head( tmp_path: Path, sandbox: tuple[Path, Path, Path], ) -> None: - """The PR that adds the opt-in remains on its preexisting keyed pool.""" + """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, @@ -197,6 +201,7 @@ def test_policy_added_in_current_head_cannot_enable_that_head( base_sha, head_sha, "github-models/openai/gpt-5", + repository_is_private="true", ) assert result.returncode == 0, result.stdout + result.stderr @@ -204,22 +209,82 @@ def test_policy_added_in_current_head_cannot_enable_that_head( assert "unchanged trusted base policy" not in result.stdout -def test_existing_public_free_pool_is_not_reordered_or_duplicated( +def test_policyless_private_preconfigured_free_pool_fails_closed( tmp_path: Path, sandbox: tuple[Path, Path, Path], ) -> None: - """An existing free pool keeps the workflow-provided order unchanged.""" + """Private callers cannot bypass immutable-base policy with a free candidate.""" source, base_sha, head_sha = create_source_repository( tmp_path, - base_has_policy=True, + 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/glm-5-free github-models/openai/gpt-5" + 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) + 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 == original.split() + assert candidates == ["opencode-free/big-pickle", "github-models/openai/gpt-5"] def test_governed_candidates_are_already_supported_by_the_central_workflow() -> None: @@ -233,6 +298,23 @@ def test_governed_candidates_are_already_supported_by_the_central_workflow() -> 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") From 654c6f06538aeaf16eab2068ce4abdf7323cf5ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 03:15:56 +0900 Subject: [PATCH 18/37] test(opencode): reject malformed policy tree records --- ...st_opencode_private_free_model_policy_1.py | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/tests/test_opencode_private_free_model_policy_1.py b/tests/test_opencode_private_free_model_policy_1.py index bd876d5f1..81b826460 100644 --- a/tests/test_opencode_private_free_model_policy_1.py +++ b/tests/test_opencode_private_free_model_policy_1.py @@ -166,6 +166,36 @@ def test_malformed_json_policy_fails_closed(repository: Path) -> None: 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( @@ -181,6 +211,7 @@ def test_read_policy_blob_rejects_invalid_size(monkeypatch: pytest.MonkeyPatch, 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") @@ -218,5 +249,3 @@ def test_invalid_commit_sha_returns_evaluation_error(repository: Path) -> None: assert result.returncode == 2 assert "40-character" in result.stderr - - From 0723847b3aec3e4d96a72d686f9b52287e40282b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 03:16:38 +0900 Subject: [PATCH 19/37] test(opencode): cover provider guard fail-closed aliases --- tests/test_opencode_provider_guard.py | 100 +++++++++++++++++++------- 1 file changed, 76 insertions(+), 24 deletions(-) diff --git a/tests/test_opencode_provider_guard.py b/tests/test_opencode_provider_guard.py index 19ff9b691..94f91dff3 100644 --- a/tests/test_opencode_provider_guard.py +++ b/tests/test_opencode_provider_guard.py @@ -66,12 +66,12 @@ def fake_opencode(tmp_path: Path) -> Path: return script -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.""" +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"] = str(fake_opencode) - result = subprocess.run( + env["OPENCODE_REAL_BIN"] = real_bin + return subprocess.run( ["bash", str(GUARD), *arguments], cwd=ROOT, env=env, @@ -79,6 +79,11 @@ def invoke(fake_opencode: Path, *arguments: str) -> tuple[dict[str, str | None], 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]) @@ -127,6 +132,24 @@ def test_paid_or_scoped_candidate_receives_only_its_provider_credentials( 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: @@ -138,34 +161,63 @@ def test_equals_form_model_argument_keeps_only_selected_provider_credentials( 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.""" - env = os.environ.copy() - env.update(ALL_CREDENTIALS) - env["OPENCODE_REAL_BIN"] = str(fake_opencode) - - result = subprocess.run( - [ - "bash", - str(GUARD), - "run", - "prompt", - "--model", - "openai/gpt-5.4", - "--model=opencode/gpt-5.6-terra", - ], - cwd=ROOT, - env=env, - capture_output=True, - text=True, - check=False, + 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 --model" in result.stderr + assert "exactly one" 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: From c0fc46c9e3c8046c3eeb5f6a84e7376cde5bad37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 03:17:29 +0900 Subject: [PATCH 20/37] fix(opencode): reject truncated trusted policy tree records --- scripts/ci/opencode_private_free_model_policy.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/ci/opencode_private_free_model_policy.py b/scripts/ci/opencode_private_free_model_policy.py index 563b6eaf3..43a00c785 100755 --- a/scripts/ci/opencode_private_free_model_policy.py +++ b/scripts/ci/opencode_private_free_model_policy.py @@ -165,8 +165,10 @@ def policy_blob_entry(repo_root: Path, base_sha: str) -> GitBlobEntry: 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}") - entries = [entry for entry in result.stdout.split(b"\x00") if entry] - if len(entries) != 1: + 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: From a952da858f063a04a73255c73fc023a381eed918 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 03:19:31 +0900 Subject: [PATCH 21/37] fix(opencode): gate free candidates on verified visibility --- scripts/ci/run_opencode_review_model_pool.sh | 114 ++++++++++++++++--- 1 file changed, 100 insertions(+), 14 deletions(-) diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh index 57f79dc90..8f47af72f 100755 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -6,7 +6,10 @@ 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" -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 opencode-free/hy3-free opencode-free/minimax-m3-free opencode-free/glm-5-free opencode-free/kimi-k2.5-free opencode-free/qwen3.6-plus-free" +# 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 @@ -39,19 +42,19 @@ verify_delegated_implementation_contract() { 'is_context_overflow_failure' 'tokens_limit_reached' 'skipping remaining attempts for this model' - 'OPENCODE_RUN_TIMEOUT_SECONDS:-3600' + 'env_integer_or_default OPENCODE_RUN_TIMEOUT_SECONDS 3600' 'OPENCODE_DYNAMIC_RUN_TIMEOUT_CAP_SECONDS 3600' - 'OPENCODE_FREE_RUN_TIMEOUT_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' + 'timed out after %ss; falling through within the remaining retry budget' 'emit_sanitized_opencode_failure_detail' 'OpenCode provider failure metadata' - 'provider-controlled content suppressed' + 'provider-controlled content suppressed' '"adversarial_validation"' - 'Central review-process evidence fallback eligible' + 'Central review-process evidence fallback eligible' 'provider delay is logged before the publish fallback evaluates current-head peer evidence' 'while :' 'should_skip_model_candidate' @@ -59,26 +62,26 @@ verify_delegated_implementation_contract() { 'bounded failover window' 'finish_pool_without_model' 'is_low_sensitivity_candidate' - 'mini/nano review models are disabled' - 'OPENAI_API_KEY is not configured' + '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' + 'scoped NVIDIA_NIM_API_KEY is not configured' 'configured max cycle count' 'exponential backoff' - 'write_schema_repair_prompt' + 'write_schema_repair_prompt' 'is_schema_repair_candidate' 'NVIDIA_NIM_API_KEY' - 'OpenCode dynamic review cadence selected %ss per attempt' + 'OpenCode dynamic review cadence selected %ss per attempt' 'count_changed_files_for_cadence' 'OpenCode model pool has no configured model candidates.' - 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-1500' + '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' + 'never use line 0' ) for marker in "${required_markers[@]}"; do @@ -101,6 +104,35 @@ candidate_list_contains_anonymous_free_model() { return 1 } +is_governed_anonymous_free_candidate() { + local candidate="$1" + case " $anonymous_free_candidates " in + *" $candidate "*) return 0 ;; + *) return 1 ;; + esac +} + +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 +} + prepend_unique_anonymous_free_candidates() { local combined="" local candidate @@ -119,8 +151,62 @@ prepend_unique_anonymous_free_candidates() { export OPENCODE_MODEL_CANDIDATES } +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 404/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 +} + +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 +} + maybe_enable_private_free_models() { - candidate_list_contains_anonymous_free_model && return 0 + 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:-}" From 919f41eb31f486833e932ce7cb0760f6442027f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 03:20:03 +0900 Subject: [PATCH 22/37] fix(opencode): harden provider selector parsing --- scripts/ci/opencode_provider_guard.sh | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/scripts/ci/opencode_provider_guard.sh b/scripts/ci/opencode_provider_guard.sh index 82ad8b7e0..b841f096c 100755 --- a/scripts/ci/opencode_provider_guard.sh +++ b/scripts/ci/opencode_provider_guard.sh @@ -16,14 +16,21 @@ 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 model_candidate="$argument" expect_model_value=0 continue fi + if [ "$option_parsing" -eq 0 ]; then + continue + fi case "$argument" in - --model) + --) + option_parsing=0 + ;; + --model | -m) model_argument_count=$((model_argument_count + 1)) expect_model_value=1 ;; @@ -31,14 +38,19 @@ for argument in "$@"; do 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 + printf 'exactly one model selector is allowed.\n' >&2 exit 64 fi done -if [ "$expect_model_value" -eq 1 ]; then - printf '%s\n' '--model requires a model candidate.' >&2 +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 From 45339d3e5057f263aa31fe861d73881d3940aabd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 03:21:42 +0900 Subject: [PATCH 23/37] fix(opencode): normalize delegated runtime controls --- scripts/ci/run_opencode_review_model_pool.sh | 40 ++++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh index 8f47af72f..2d10cb3d0 100755 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -42,7 +42,7 @@ verify_delegated_implementation_contract() { 'is_context_overflow_failure' 'tokens_limit_reached' 'skipping remaining attempts for this model' - 'env_integer_or_default OPENCODE_RUN_TIMEOUT_SECONDS 3600' + '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' @@ -74,7 +74,7 @@ verify_delegated_implementation_contract() { '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' + '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.' @@ -90,6 +90,37 @@ verify_delegated_implementation_contract() { done } +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 0 +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 +} + candidate_list_contains_anonymous_free_model() { local candidate local -a candidates @@ -163,8 +194,8 @@ source_repository_is_public_without_credentials() { fi # Positive unauthenticated Git access is sufficient evidence that the source is - # public. Any timeout, transport failure, private 404/auth requirement, or - # malformed remote is deliberately indistinguishable here and fails closed. + # 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 \ @@ -268,6 +299,7 @@ cleanup_provider_guard() { if [ -f "${GITHUB_WORKSPACE:-}/opencode.jsonc" ]; then verify_delegated_implementation_contract fi +normalize_delegated_integer_environment maybe_enable_private_free_models trap cleanup_provider_guard EXIT INT TERM install_provider_guard From 50c4b602a188c1d812eaf152d39e37ce68b058fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 03:23:45 +0900 Subject: [PATCH 24/37] fix(opencode): validate model-pool integer controls --- .../ci/run_opencode_review_model_pool_impl.sh | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/scripts/ci/run_opencode_review_model_pool_impl.sh b/scripts/ci/run_opencode_review_model_pool_impl.sh index e3cbcf83d..05d8f80db 100755 --- a/scripts/ci/run_opencode_review_model_pool_impl.sh +++ b/scripts/ci/run_opencode_review_model_pool_impl.sh @@ -458,8 +458,8 @@ run_one_model_attempt() { 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}" + 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" @@ -562,15 +562,19 @@ main() { max_total_attempts="$(env_integer_or_default OPENCODE_POOL_MAX_TOTAL_ATTEMPTS 30)" total_attempts=0 - attempts="${OPENCODE_MODEL_ATTEMPTS:-3}" + 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="${OPENCODE_RUN_TIMEOUT_SECONDS:-3600}" - budget_seconds="${OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-1500}" - max_cycles="${OPENCODE_POOL_MAX_CYCLES:-0}" + 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="${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}" + 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 @@ -789,7 +793,7 @@ main() { 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}" + cycle_sleep="$(env_integer_or_default 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 From 136205564e4ac8b16aa8e2a8adfde2c4805437e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 03:24:41 +0900 Subject: [PATCH 25/37] fix(opencode): align delegated runner integrity markers --- scripts/ci/run_opencode_review_model_pool.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh index 2d10cb3d0..aa0df2db5 100755 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -42,7 +42,7 @@ verify_delegated_implementation_contract() { 'is_context_overflow_failure' 'tokens_limit_reached' 'skipping remaining attempts for this model' - 'OPENCODE_RUN_TIMEOUT_SECONDS:-3600' + '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' @@ -74,7 +74,7 @@ verify_delegated_implementation_contract() { 'OpenCode dynamic review cadence selected %ss per attempt' 'count_changed_files_for_cadence' 'OpenCode model pool has no configured model candidates.' - 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-1500' + '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.' From 6cf29abc43e6a1891ea05de0a4283d8d5bf83098 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 03:25:31 +0900 Subject: [PATCH 26/37] docs(opencode): align private free policy with live catalog --- .../opencode-private-free-model-policy.md | 101 +++++++++++++----- 1 file changed, 74 insertions(+), 27 deletions(-) diff --git a/docs/doctoring/opencode-private-free-model-policy.md b/docs/doctoring/opencode-private-free-model-policy.md index 17959f0da..8f53e2e65 100644 --- a/docs/doctoring/opencode-private-free-model-policy.md +++ b/docs/doctoring/opencode-private-free-model-policy.md @@ -48,17 +48,17 @@ 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 boundary +## Provider-data and catalog boundary -OpenCode currently documents several free models as limited-time offerings used -to collect feedback or improve models. Its privacy documentation specifically -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. +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 private pool uses every anonymous free candidate already supported -by the central generated OpenCode configuration: +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` @@ -67,21 +67,43 @@ by the central generated OpenCode configuration: 5. `opencode-free/ling-3.0-flash-free` 6. `opencode-free/big-pickle` 7. `opencode-free/mimo-v2.5-free` -8. `opencode-free/hy3-free` -9. `opencode-free/minimax-m3-free` -10. `opencode-free/glm-5-free` -11. `opencode-free/kimi-k2.5-free` -12. `opencode-free/qwen3.6-plus-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 now receives only the credential for its selected -provider. In particular, an anonymous `opencode-free/*` process receives none of -these values: +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 @@ -95,8 +117,10 @@ these values: Session export runs without any provider credential. Unknown future provider prefixes also default to zero provider credentials until they are explicitly -classified. This follows GitHub's least-privilege guidance and avoids relying on -log redaction, which GitHub notes is not guaranteed for every transformed secret. +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 @@ -110,15 +134,24 @@ 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, @@ -130,9 +163,12 @@ The policy checker: 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 later pull requests, verify the OpenCode log records that the unchanged - trusted base policy enabled the anonymous candidates. -6. To disable the feature, remove or change the policy through a normal pull +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. @@ -148,6 +184,11 @@ review evidence may be confidential even when no Actions secret is configured. 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 @@ -163,10 +204,16 @@ information-classification system. 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, -malformed tree metadata, provider-specific credential retention, anonymous free -credential removal, export isolation, unknown-provider fail-safe behavior, and -wrapper ordering. The new Python policy checker has 100% statement and branch -coverage and complete module, class, and function docstrings. +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 @@ -177,5 +224,5 @@ https://docs.github.com/en/code-security/how-tos/secure-your-secrets/detect-secr 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 8, 2026, from +OpenCode. (n.d.). *Zen*. Retrieved August 9, 2026, from https://opencode.ai/docs/zen From 1ff4f65561149ea5a2553b8b9bc8b5026ec5f31b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 05:07:14 +0900 Subject: [PATCH 27/37] test(opencode): bind generic model-pool fixture to public visibility --- tests/test_opencode_model_pool_runner.py | 1 + 1 file changed, 1 insertion(+) 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), From 6dea351d08981c7e74d1d39e8c4ba250e03f5cdc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 08:09:29 +0900 Subject: [PATCH 28/37] test(opencode): reject selector-like model values --- tests/test_opencode_provider_guard.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_opencode_provider_guard.py b/tests/test_opencode_provider_guard.py index 94f91dff3..db7211e0c 100644 --- a/tests/test_opencode_provider_guard.py +++ b/tests/test_opencode_provider_guard.py @@ -194,6 +194,27 @@ def test_duplicate_model_arguments_fail_closed_before_model_execution( 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"), [ From 66f36dcd00bc51157e2840d6118353ffa8484943 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 08:09:59 +0900 Subject: [PATCH 29/37] test(opencode): require positive model-pool cycle delay --- ...pencode_model_pool_cycle_sleep_contract.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/test_opencode_model_pool_cycle_sleep_contract.py 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) From 72f1c02e4fca4bedbcb9a287804774a5e60947b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 08:10:31 +0900 Subject: [PATCH 30/37] fix(opencode): reject ambiguous model selectors --- scripts/ci/opencode_provider_guard.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/ci/opencode_provider_guard.sh b/scripts/ci/opencode_provider_guard.sh index b841f096c..fcd2e4078 100755 --- a/scripts/ci/opencode_provider_guard.sh +++ b/scripts/ci/opencode_provider_guard.sh @@ -19,8 +19,20 @@ expect_model_value=0 option_parsing=1 for argument in "$@"; do if [ "$expect_model_value" -eq 1 ]; then - model_candidate="$argument" - expect_model_value=0 + 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 From 164aa823c100a700367efff74d6ff2ac91dc4c79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 08:11:32 +0900 Subject: [PATCH 31/37] fix(opencode): require positive model-pool cycle delay --- scripts/ci/run_opencode_review_model_pool.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh index aa0df2db5..effc44938 100755 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -114,7 +114,7 @@ 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 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 From 269ca3c0511af93f8485a803d9994b83e13bd664 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 08:14:45 +0900 Subject: [PATCH 32/37] fix(opencode): prevent zero-delay model-pool cycles --- scripts/ci/run_opencode_review_model_pool_impl.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/ci/run_opencode_review_model_pool_impl.sh b/scripts/ci/run_opencode_review_model_pool_impl.sh index 05d8f80db..0d2be3c24 100755 --- a/scripts/ci/run_opencode_review_model_pool_impl.sh +++ b/scripts/ci/run_opencode_review_model_pool_impl.sh @@ -530,7 +530,7 @@ run_one_model_attempt() { 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" + printf 'OpenCode %s attempt %s/%s session export did not include assistant text.\n' "$model_candidate" "$attempts" "$attempts" emit_rejected_opencode_artifact_metadata "assistant-empty-export" "$opencode_export_file" return 1 fi @@ -794,6 +794,7 @@ main() { 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 From a18cd1a7a61dd5abaad8cf9deebcf3245158ac69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 08:18:53 +0900 Subject: [PATCH 33/37] fix(opencode): preserve model attempt diagnostics --- scripts/ci/run_opencode_review_model_pool_impl.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/run_opencode_review_model_pool_impl.sh b/scripts/ci/run_opencode_review_model_pool_impl.sh index 0d2be3c24..5900ac1cf 100755 --- a/scripts/ci/run_opencode_review_model_pool_impl.sh +++ b/scripts/ci/run_opencode_review_model_pool_impl.sh @@ -530,7 +530,7 @@ run_one_model_attempt() { 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" "$attempts" "$attempts" + 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 From 82ef875d97c188973a3f5ec2f8b03853733c5718 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 19:13:04 +0900 Subject: [PATCH 34/37] test(opencode): assert normalized retry budget contract --- tests/test_opencode_agent_contract.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index daeaa37a2..10f1cab82 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1662,8 +1662,8 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert ( "OpenCode model pool has no configured model candidates." in model_pool_runner ) - assert "OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-1500" in model_pool_runner - assert ( + assert "normalize_delegated_integer_environment\n" in model_pool_runner + assert (\n "OPENCODE_TOTAL_RETRY_BUDGET_SECONDS 1500 0" in model_pool_runner\n )\n assert ( "completed a full model-candidate cycle without a valid control conclusion" in model_pool_runner ) From 1a0818158f7f7216b2663a885d77d93db3d34d43 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 19:14:02 +0900 Subject: [PATCH 35/37] fix(test): restore retry-budget assertion formatting --- tests/test_opencode_agent_contract.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 10f1cab82..7da74a9b6 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1663,7 +1663,10 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): "OpenCode model pool has no configured model candidates." in model_pool_runner ) assert "normalize_delegated_integer_environment\n" in model_pool_runner - assert (\n "OPENCODE_TOTAL_RETRY_BUDGET_SECONDS 1500 0" in model_pool_runner\n )\n assert ( + assert ( + "OPENCODE_TOTAL_RETRY_BUDGET_SECONDS 1500 0" in model_pool_runner + ) + assert ( "completed a full model-candidate cycle without a valid control conclusion" in model_pool_runner ) From 1e3f8f84532d50311fbe5115c3df5b2b9966b979 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 19:34:12 +0900 Subject: [PATCH 36/37] test(strix): assert normalized retry budget contract --- scripts/ci/test_strix_quick_gate.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 7343c06ac..ed6cdff01 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -897,7 +897,8 @@ assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" ' assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "OPENROUTER_API_KEY is not configured" "opencode model pool skips OpenRouter candidates when the org secret is absent" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "scoped NVIDIA_NIM_API_KEY is not configured" "opencode model pool skips NVIDIA NIM candidates when the scoped credential is absent" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "configured max cycle count" "opencode model pool exits before the job timeout after configured cycles" - assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-1500' "opencode model pool keeps a bounded default retry budget unless the workflow explicitly disables it" + assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "normalize_delegated_integer_environment" "opencode wrapper activates delegated integer normalization before model execution" + assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "OPENCODE_TOTAL_RETRY_BUDGET_SECONDS 1500 0" "opencode wrapper owns a bounded normalized default retry budget" assert_file_not_contains "$workflow_file" "no model produced a valid review control block" "opencode model-failure path no longer documents a final exhausted state" assert_file_contains "$workflow_file" 'OPENCODE_MODEL_ATTEMPTS: "1"' "opencode primary and fallback paths avoid multi-attempt stalls on one model" assert_file_contains "$workflow_file" 'OPENCODE_MODEL_ATTEMPTS: "1"' "opencode catalog fallback tries each model once before moving on" From c001fcb7979ed5ceef82f5eeaba97c184cc47d96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 21:19:40 +0900 Subject: [PATCH 37/37] test(strix): assert normalized OpenCode run timeout --- scripts/ci/test_strix_quick_gate.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index ed6cdff01..394fc6d7b 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -736,7 +736,7 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" 'OPENCODE_FREE_RUN_TIMEOUT_SECONDS: "3600"' "opencode free-tier failover timeout is hour-class (~3600s)" assert_file_contains "$workflow_file" 'OPENCODE_NVIDIA_NIM_RUN_TIMEOUT_SECONDS: "180"' "opencode NVIDIA NIM candidates have a short per-candidate failover timeout" assert_file_contains "$workflow_file" 'OPENCODE_NVIDIA_NIM_TOTAL_BUDGET_SECONDS: "900"' "opencode NVIDIA NIM candidates share a bounded combined runtime budget" -assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'OPENCODE_RUN_TIMEOUT_SECONDS:-3600' "opencode pool defaults primary run timeout to hour-class (~3600s) for large repos" +assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "OPENCODE_RUN_TIMEOUT_SECONDS 3600 1" "opencode pool owns a normalized hour-class primary run timeout for large repos" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'OPENCODE_DYNAMIC_RUN_TIMEOUT_CAP_SECONDS 3600' "opencode pool dynamic timeout cap defaults to hour-class (~3600s)" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'OPENCODE_FREE_RUN_TIMEOUT_SECONDS 3600' "opencode free-tier failover timeout is hour-class (~3600s)" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'OPENCODE_NVIDIA_NIM_RUN_TIMEOUT_SECONDS 180' "opencode NVIDIA NIM candidate runtime cap defaults to three minutes"