From 3e7e33c62ee6e4957bfdafe81bdd2764a6900efe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 16 Jul 2026 06:32:50 +0900 Subject: [PATCH 1/5] fix(review): bootstrap full-hour model sessions --- .github/workflows/opencode-review.yml | 48 ++++++++++++------------- scripts/ci/test_strix_quick_gate.sh | 12 +++---- tests/test_opencode_agent_contract.py | 50 +++++++++++++-------------- 3 files changed, 55 insertions(+), 55 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 94586043..fbd5b891 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -1514,10 +1514,9 @@ jobs: # continue-on-error, so it survives the job failing. model_pool_outcome: ${{ steps.opencode_review_model_pool.outputs.review_status }} # Coverage and current-head evidence are prepared before the model pool. - # The model pool has a 60-minute retry budget and its publish gate can wait - # for long same-head package/GPU checks, so 150 minutes releases genuinely - # hung runners without truncating the bounded multi-provider review path. - timeout-minutes: 150 + # A single legitimate review may need a full hour. Keep enough room for the + # bounded 195-minute provider pool plus final current-head publication gates. + timeout-minutes: 240 permissions: actions: read checks: read @@ -3368,7 +3367,7 @@ jobs: - name: Run OpenCode PR Review model pool id: opencode_review_model_pool if: needs.coverage-evidence.result == 'success' - timeout-minutes: 65 + timeout-minutes: 205 continue-on-error: true env: STRIX_GITHUB_MODELS_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN || github.token }} @@ -3395,13 +3394,14 @@ jobs: # the SAME model 5x let a rate-limited/hung leader consume the whole # step, so the pool never reached a healthy fallback model. OPENCODE_MODEL_ATTEMPTS: "1" - # Keep stale providers from pinning required review jobs for hours. - # Adversarial validation needs enough room to read the evidence, but - # dynamic cadence and the outer watchdog still bound each current-head run. - OPENCODE_RUN_TIMEOUT_SECONDS: "600" - OPENCODE_EXPORT_TIMEOUT_SECONDS: "120" - OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "3600" - OPENCODE_POOL_STEP_TIMEOUT_SECONDS: "2100" + # Preserve reviews that legitimately need tens of minutes to inspect a + # large repository. Changed-file count is not a repository-complexity + # proxy, so every cadence class gets 90 minutes per candidate while the + # bounded provider-pool watchdog remains the outer guard. + OPENCODE_RUN_TIMEOUT_SECONDS: "5400" + OPENCODE_EXPORT_TIMEOUT_SECONDS: "180" + OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "11700" + OPENCODE_POOL_STEP_TIMEOUT_SECONDS: "12000" # Keep cycling through the high-sensitivity candidate catalog until # the retry budget or step timeout is exhausted; a single invalid # cycle can be all provider formatting noise rather than review @@ -3410,16 +3410,16 @@ jobs: OPENCODE_DYNAMIC_REVIEW_CADENCE: "true" OPENCODE_SMALL_CHANGE_FILE_THRESHOLD: "3" OPENCODE_MEDIUM_CHANGE_FILE_THRESHOLD: "20" - OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS: "600" - OPENCODE_SMALL_CHANGE_TOTAL_BUDGET_SECONDS: "3600" - OPENCODE_MEDIUM_CHANGE_RUN_TIMEOUT_SECONDS: "600" - OPENCODE_MEDIUM_CHANGE_TOTAL_BUDGET_SECONDS: "3600" - OPENCODE_LARGE_CHANGE_RUN_TIMEOUT_SECONDS: "600" - OPENCODE_LARGE_CHANGE_TOTAL_BUDGET_SECONDS: "3600" - OPENCODE_UNKNOWN_CHANGE_RUN_TIMEOUT_SECONDS: "600" - OPENCODE_UNKNOWN_CHANGE_TOTAL_BUDGET_SECONDS: "3600" - OPENCODE_DYNAMIC_RUN_TIMEOUT_CAP_SECONDS: "600" - OPENCODE_DYNAMIC_TOTAL_BUDGET_CAP_SECONDS: "1800" + OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS: "5400" + OPENCODE_SMALL_CHANGE_TOTAL_BUDGET_SECONDS: "11700" + OPENCODE_MEDIUM_CHANGE_RUN_TIMEOUT_SECONDS: "5400" + OPENCODE_MEDIUM_CHANGE_TOTAL_BUDGET_SECONDS: "11700" + OPENCODE_LARGE_CHANGE_RUN_TIMEOUT_SECONDS: "5400" + OPENCODE_LARGE_CHANGE_TOTAL_BUDGET_SECONDS: "11700" + OPENCODE_UNKNOWN_CHANGE_RUN_TIMEOUT_SECONDS: "5400" + OPENCODE_UNKNOWN_CHANGE_TOTAL_BUDGET_SECONDS: "11700" + OPENCODE_DYNAMIC_RUN_TIMEOUT_CAP_SECONDS: "5400" + OPENCODE_DYNAMIC_TOTAL_BUDGET_CAP_SECONDS: "11700" OPENCODE_DYNAMIC_MAX_CYCLES_CAP: "0" # This installation currently reports a 4k request-body limit for # GitHub Models GPT-5 endpoints even though the public catalog is @@ -3429,8 +3429,8 @@ jobs: OPENCODE_DYNAMIC_MAX_CYCLES: "0" CENTRAL_REVIEW_PROCESS_FALLBACK_ELIGIBLE: ${{ steps.central_review_process_fallback_scope.outputs.eligible || 'false' }} CENTRAL_REVIEW_PROCESS_FALLBACK_SCOPE_LABEL: ${{ steps.central_review_process_fallback_scope.outputs.scope_label || 'unsupported' }} - OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_RUN_TIMEOUT_SECONDS: "600" - OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_TOTAL_BUDGET_SECONDS: "3600" + OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_RUN_TIMEOUT_SECONDS: "5400" + OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_TOTAL_BUDGET_SECONDS: "11700" OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_MAX_CYCLES: "1" OPENCODE_BACKOFF_INITIAL_SECONDS: "30" OPENCODE_BACKOFF_MAX_SECONDS: "30" diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 3de19fa2..4ba43840 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -647,13 +647,13 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "skipping remaining attempts for this model" "opencode review skips same-model retries after context-window overflow" assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" "exceeded your current quota" "strix wrapper neutralizes quota-only provider failures without vulnerability reports" assert_file_contains "$REPO_ROOT/scripts/ci/strix_quick_gate.sh" "billing details" "strix quick gate classifies provider quota starvation as infrastructure" - assert_file_contains "$workflow_file" 'timeout-minutes: 150' "opencode review target releases stalled review runners within the bounded queue budget" + assert_file_contains "$workflow_file" 'timeout-minutes: 240' "opencode review target leaves room for the bounded long-review pool and publication gates" assert_file_contains "$workflow_file" 'timeout-minutes: 12' "opencode evidence preparation fails closed before it ties up the review queue" - assert_file_contains "$workflow_file" 'timeout-minutes: 65' "opencode model pool gives multiple candidates a bounded review window while capping stalled model attempts" + assert_file_contains "$workflow_file" 'timeout-minutes: 205' "opencode model pool preserves full-hour candidates within a bounded provider-pool window" assert_file_contains "$workflow_file" 'timeout-minutes: 34' "opencode fast approval publication is bounded around the dynamic image and package/GPU check wait" assert_file_contains "$workflow_file" 'continue-on-error: true' "opencode approval gate still runs after model-pool failure to publish a reason" - assert_file_contains "$workflow_file" 'OPENCODE_RUN_TIMEOUT_SECONDS: "600"' "opencode primary review advances after a bounded stalled provider attempt" - assert_file_contains "$workflow_file" 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "3600"' "opencode model pool exits before the step timeout so the approval gate can publish a reason" + assert_file_contains "$workflow_file" 'OPENCODE_RUN_TIMEOUT_SECONDS: "5400"' "opencode primary review preserves legitimate full-hour provider sessions" + assert_file_contains "$workflow_file" 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "11700"' "opencode model pool exits before the step timeout so the approval gate can publish a reason" assert_file_contains "$workflow_file" 'OPENCODE_POOL_MAX_CYCLES: "0"' "opencode model pool keeps cycling until the bounded retry budget or step timeout is exhausted" assert_file_contains "$workflow_file" "needs.coverage-evidence.result == 'success'" "opencode model pool only runs after coverage evidence passed" assert_file_contains "$workflow_file" "id: opencode_review_model_pool" "opencode DeepSeek V3 fallback still runs after a primary model timeout or step failure when coverage evidence passed" @@ -805,7 +805,7 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { 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" - assert_file_contains "$workflow_file" 'OPENCODE_RUN_TIMEOUT_SECONDS: "600"' "opencode catalog fallback advances after a bounded stalled provider attempt" + assert_file_contains "$workflow_file" 'OPENCODE_RUN_TIMEOUT_SECONDS: "5400"' "opencode catalog fallback preserves legitimate full-hour provider sessions" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "OpenCode %s attempt %s/%s failed" "opencode catalog fallback records per-model retry failures" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "exponential backoff" "opencode model retry paths use exponential backoff instead of fixed sleeps" assert_file_contains "$workflow_file" "github-models/deepseek/deepseek-v3-0324 openai/gpt-5.6-luna github-models/openai/gpt-4.1 github-models/openai/gpt-5" "opencode review tries DeepSeek V3 before OpenAI fallbacks" @@ -1118,7 +1118,7 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "collect_failed_check_evidence.sh" "opencode review workflow collects failed check logs and annotations" assert_file_contains "$workflow_file" 'HEAD_SHA: ${{ needs.validate-pr-metadata.outputs.head_sha }}' "opencode evidence step passes the live validated HEAD_SHA to failed-check evidence collection" assert_file_contains "$workflow_file" "FAILED_CHECK_EVIDENCE_ATTEMPTS" "opencode review workflow bounds waiting for peer check failures before model review" - assert_file_contains "$workflow_file" 'timeout-minutes: 65' "opencode model stage has a bounded multi-provider timeout" + assert_file_contains "$workflow_file" 'timeout-minutes: 205' "opencode model stage has a bounded long-review multi-provider timeout" assert_file_contains "$workflow_file" 'timeout-minutes: 12' "opencode evidence preparation has a bounded peer-check wait timeout" assert_file_contains "$workflow_file" 'FAILED_CHECK_EVIDENCE_ATTEMPTS: "6"' "opencode review workflow keeps pre-model peer-check waiting bounded for required workflow DX" assert_file_contains "$workflow_file" 'FAILED_CHECK_EVIDENCE_SLEEP_SECONDS: "5"' "opencode review workflow retries peer-check evidence without stalling the model stage for Strix-scale durations" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 1ff1ff18..c2d16f31 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -797,11 +797,11 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "CENTRAL_REVIEW_PROCESS_FALLBACK_ELIGIBLE" in workflow assert "CENTRAL_REVIEW_PROCESS_FALLBACK_SCOPE_LABEL" in workflow assert ( - 'OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_RUN_TIMEOUT_SECONDS: "600"' + 'OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_RUN_TIMEOUT_SECONDS: "5400"' in workflow ) assert ( - 'OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_TOTAL_BUDGET_SECONDS: "3600"' + 'OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_TOTAL_BUDGET_SECONDS: "11700"' in workflow ) assert 'OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_MAX_CYCLES: "1"' in workflow @@ -870,18 +870,18 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): r"Prepare bounded OpenCode review evidence[\s\S]{0,120}timeout-minutes: 12", workflow, ) - assert re.search(r"opencode-review-target:[\s\S]*?timeout-minutes: 150", workflow) + assert re.search(r"opencode-review-target:[\s\S]*?timeout-minutes: 240", workflow) assert "timeout-minutes: 12" in workflow assert re.search( - r"Run OpenCode PR Review model pool[\s\S]{0,240}timeout-minutes: 65", workflow + r"Run OpenCode PR Review model pool[\s\S]{0,240}timeout-minutes: 205", workflow ) - assert 'OPENCODE_SMALL_CHANGE_TOTAL_BUDGET_SECONDS: "3600"' in workflow - assert 'OPENCODE_MEDIUM_CHANGE_TOTAL_BUDGET_SECONDS: "3600"' in workflow - assert 'OPENCODE_LARGE_CHANGE_TOTAL_BUDGET_SECONDS: "3600"' in workflow - assert 'OPENCODE_UNKNOWN_CHANGE_TOTAL_BUDGET_SECONDS: "3600"' in workflow - assert 'OPENCODE_RUN_TIMEOUT_SECONDS: "600"' in workflow - assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "3600"' in workflow - assert 'OPENCODE_POOL_STEP_TIMEOUT_SECONDS: "2100"' in workflow + assert 'OPENCODE_SMALL_CHANGE_TOTAL_BUDGET_SECONDS: "11700"' in workflow + assert 'OPENCODE_MEDIUM_CHANGE_TOTAL_BUDGET_SECONDS: "11700"' in workflow + assert 'OPENCODE_LARGE_CHANGE_TOTAL_BUDGET_SECONDS: "11700"' in workflow + assert 'OPENCODE_UNKNOWN_CHANGE_TOTAL_BUDGET_SECONDS: "11700"' in workflow + assert 'OPENCODE_RUN_TIMEOUT_SECONDS: "5400"' in workflow + assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "11700"' in workflow + assert 'OPENCODE_POOL_STEP_TIMEOUT_SECONDS: "12000"' in workflow assert ( 'timeout --kill-after=30s "${OPENCODE_POOL_STEP_TIMEOUT_SECONDS:-3600}s"' in workflow @@ -924,26 +924,26 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): 'github-models/deepseek/deepseek-r1"' ) in workflow assert 'OPENCODE_MODEL_ATTEMPTS: "1"' in workflow - assert 'OPENCODE_RUN_TIMEOUT_SECONDS: "600"' in workflow - assert 'OPENCODE_EXPORT_TIMEOUT_SECONDS: "120"' in workflow - assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "3600"' in workflow - assert 'OPENCODE_POOL_STEP_TIMEOUT_SECONDS: "2100"' in workflow + assert 'OPENCODE_RUN_TIMEOUT_SECONDS: "5400"' in workflow + assert 'OPENCODE_EXPORT_TIMEOUT_SECONDS: "180"' in workflow + assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "11700"' in workflow + assert 'OPENCODE_POOL_STEP_TIMEOUT_SECONDS: "12000"' in workflow assert 'OPENCODE_POOL_MAX_CYCLES: "0"' in workflow assert 'OPENCODE_DYNAMIC_REVIEW_CADENCE: "true"' in workflow assert ( "OPENCODE_CHANGED_FILES_FILE: ${{ runner.temp }}/opencode-changed-files.txt" in workflow ) - assert 'OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS: "600"' in workflow - assert 'OPENCODE_SMALL_CHANGE_TOTAL_BUDGET_SECONDS: "3600"' in workflow - assert 'OPENCODE_MEDIUM_CHANGE_RUN_TIMEOUT_SECONDS: "600"' in workflow - assert 'OPENCODE_MEDIUM_CHANGE_TOTAL_BUDGET_SECONDS: "3600"' in workflow - assert 'OPENCODE_LARGE_CHANGE_RUN_TIMEOUT_SECONDS: "600"' in workflow - assert 'OPENCODE_LARGE_CHANGE_TOTAL_BUDGET_SECONDS: "3600"' in workflow - assert 'OPENCODE_UNKNOWN_CHANGE_RUN_TIMEOUT_SECONDS: "600"' in workflow - assert 'OPENCODE_UNKNOWN_CHANGE_TOTAL_BUDGET_SECONDS: "3600"' in workflow - assert 'OPENCODE_DYNAMIC_RUN_TIMEOUT_CAP_SECONDS: "600"' in workflow - assert 'OPENCODE_DYNAMIC_TOTAL_BUDGET_CAP_SECONDS: "1800"' in workflow + assert 'OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS: "5400"' in workflow + assert 'OPENCODE_SMALL_CHANGE_TOTAL_BUDGET_SECONDS: "11700"' in workflow + assert 'OPENCODE_MEDIUM_CHANGE_RUN_TIMEOUT_SECONDS: "5400"' in workflow + assert 'OPENCODE_MEDIUM_CHANGE_TOTAL_BUDGET_SECONDS: "11700"' in workflow + assert 'OPENCODE_LARGE_CHANGE_RUN_TIMEOUT_SECONDS: "5400"' in workflow + assert 'OPENCODE_LARGE_CHANGE_TOTAL_BUDGET_SECONDS: "11700"' in workflow + assert 'OPENCODE_UNKNOWN_CHANGE_RUN_TIMEOUT_SECONDS: "5400"' in workflow + assert 'OPENCODE_UNKNOWN_CHANGE_TOTAL_BUDGET_SECONDS: "11700"' in workflow + assert 'OPENCODE_DYNAMIC_RUN_TIMEOUT_CAP_SECONDS: "5400"' in workflow + assert 'OPENCODE_DYNAMIC_TOTAL_BUDGET_CAP_SECONDS: "11700"' in workflow assert 'OPENCODE_DYNAMIC_MAX_CYCLES_CAP: "0"' in workflow assert 'OPENCODE_GITHUB_GPT5_RUN_TIMEOUT_SECONDS: "45"' in workflow assert 'OPENCODE_DYNAMIC_MAX_CYCLES: "0"' in workflow From e56fab8622c795c46c7c6694180f92a297f155f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 16 Jul 2026 09:36:13 +0900 Subject: [PATCH 2/5] fix(review): contain long-running review budget --- .github/workflows/opencode-review.yml | 8 ++-- scripts/ci/test_strix_quick_gate.sh | 2 +- tests/test_opencode_agent_contract.py | 58 +++++++++++++++++++++++++-- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index fbd5b891..569defe9 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -1514,9 +1514,11 @@ jobs: # continue-on-error, so it survives the job failing. model_pool_outcome: ${{ steps.opencode_review_model_pool.outputs.review_status }} # Coverage and current-head evidence are prepared before the model pool. - # A single legitimate review may need a full hour. Keep enough room for the - # bounded 195-minute provider pool plus final current-head publication gates. - timeout-minutes: 240 + # A single legitimate review may need a full hour. The enclosing job must + # contain the 12-minute evidence step, 205-minute provider-pool step, the + # 36-minute publication gate, and setup/cleanup overhead without truncating + # a late current-head verdict or its bounded failure reason. + timeout-minutes: 300 permissions: actions: read checks: read diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 4ba43840..86b40eaa 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -647,7 +647,7 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "skipping remaining attempts for this model" "opencode review skips same-model retries after context-window overflow" assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" "exceeded your current quota" "strix wrapper neutralizes quota-only provider failures without vulnerability reports" assert_file_contains "$REPO_ROOT/scripts/ci/strix_quick_gate.sh" "billing details" "strix quick gate classifies provider quota starvation as infrastructure" - assert_file_contains "$workflow_file" 'timeout-minutes: 240' "opencode review target leaves room for the bounded long-review pool and publication gates" + assert_file_contains "$workflow_file" 'timeout-minutes: 300' "opencode review target contains evidence, the bounded long-review pool, publication, and cleanup overhead" assert_file_contains "$workflow_file" 'timeout-minutes: 12' "opencode evidence preparation fails closed before it ties up the review queue" assert_file_contains "$workflow_file" 'timeout-minutes: 205' "opencode model pool preserves full-hour candidates within a bounded provider-pool window" assert_file_contains "$workflow_file" 'timeout-minutes: 34' "opencode fast approval publication is bounded around the dynamic image and package/GPU check wait" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index c2d16f31..b72bf9eb 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -516,7 +516,10 @@ def test_autofix_worker_resolves_merge_conflicts_fail_closed(): worker = Path(".github/workflows/pr-review-autofix.yml").read_text(encoding="utf-8") assert "types: [pr-review-autofix]" in worker - assert "RESOLVE_CONFLICT: ${{ github.event.client_payload.resolve_conflict || 'false' }}" in worker + assert ( + "RESOLVE_CONFLICT: ${{ github.event.client_payload.resolve_conflict || 'false' }}" + in worker + ) # The review-feedback fix steps do not run in conflict mode. assert worker.count("if: env.RESOLVE_CONFLICT != 'true'") >= 3 # The dedicated conflict step exists and is fail-closed. @@ -870,7 +873,7 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): r"Prepare bounded OpenCode review evidence[\s\S]{0,120}timeout-minutes: 12", workflow, ) - assert re.search(r"opencode-review-target:[\s\S]*?timeout-minutes: 240", workflow) + assert re.search(r"opencode-review-target:[\s\S]*?timeout-minutes: 300", workflow) assert "timeout-minutes: 12" in workflow assert re.search( r"Run OpenCode PR Review model pool[\s\S]{0,240}timeout-minutes: 205", workflow @@ -1145,6 +1148,48 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "forced smooth scrolling" in prompt_template +def test_opencode_job_timeout_contains_full_sequential_review_budget(): + """Keep the outer job alive through evidence, review, and publication.""" + workflow = Path(".github/workflows/opencode-review.yml").read_text(encoding="utf-8") + + def timeout_minutes(pattern: str) -> int: + match = re.search(pattern, workflow, re.MULTILINE) + assert match, f"missing timeout contract: {pattern}" + return int(match.group(1)) + + job_timeout = timeout_minutes( + r"^ opencode-review-target:\n[\s\S]{0,4000}?^ timeout-minutes: (\d+)$" + ) + evidence_timeout = timeout_minutes( + r"^ - name: Prepare bounded OpenCode review evidence\n" + r"[\s\S]{0,200}?^ timeout-minutes: (\d+)$" + ) + model_pool_timeout = timeout_minutes( + r"^ - name: Run OpenCode PR Review model pool\n" + r"[\s\S]{0,300}?^ timeout-minutes: (\d+)$" + ) + fast_publish_timeout = timeout_minutes( + r"^ - name: Publish central OpenCode fast approval\n" + r"[\s\S]{0,500}?^ timeout-minutes: (\d+)$" + ) + normal_publish_timeout = timeout_minutes( + r"^ - name: Publish OpenCode review outcome\n" + r"[\s\S]{0,1200}?^ timeout-minutes: (\d+)$" + ) + setup_and_cleanup_margin = 30 + required_timeout = ( + evidence_timeout + + model_pool_timeout + + max(fast_publish_timeout, normal_publish_timeout) + + setup_and_cleanup_margin + ) + + assert job_timeout >= required_timeout, ( + "opencode-review-target can terminate before publishing the bounded " + f"current-head result: job={job_timeout}m required={required_timeout}m" + ) + + def test_opencode_approval_gate_shell_is_parseable(): """Guard the large inline approval shell against YAML-valid syntax breaks.""" if os.name == "nt": @@ -1388,7 +1433,10 @@ def test_opencode_privileged_review_security_boundaries_are_fail_closed(): assert "scripts/ci/codegraph-package/package-lock.json" in codegraph_step assert 'cd "$CODEGRAPH_TRUSTED_ROOT"' in codegraph_step assert "npm ci --ignore-scripts --omit=dev --no-audit --no-fund" in codegraph_step - assert "npm audit --package-lock-only --omit=dev --audit-level=moderate" in codegraph_step + assert ( + "npm audit --package-lock-only --omit=dev --audit-level=moderate" + in codegraph_step + ) assert 'patched_picomatch_version" != "4.0.4"' in codegraph_step assert 'locked_version" != "4.0.4"' in codegraph_step assert "Hardened CodeGraph platform bundle" in codegraph_step @@ -1464,7 +1512,9 @@ def test_opencode_strix_security_regressions_are_closed(): assert " validate-pr-metadata:\n" in workflow assert "^ContextualWisdomLab/[A-Za-z0-9_.-]+$" in workflow - assert "repository_dispatch metadata does not match the live pull request" in workflow + assert ( + "repository_dispatch metadata does not match the live pull request" in workflow + ) assert "needs.validate-pr-metadata.outputs.base_sha" in workflow assert "needs.validate-pr-metadata.outputs.head_sha" in workflow assert "metadata changed before OIDC" in workflow From 2aebdb65a8fca4bb0ca10a3522d255d75361f684 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 16 Jul 2026 12:31:16 +0900 Subject: [PATCH 3/5] fix(review): authenticate dispatches and empty dependency projects --- .github/workflows/opencode-review.yml | 210 +++++++++++--------------- scripts/ci/test_strix_quick_gate.sh | 18 ++- tests/test_opencode_agent_contract.py | 198 ++++++++++++++++++++++-- 3 files changed, 285 insertions(+), 141 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 569defe9..9345e91e 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -66,6 +66,12 @@ jobs: env: GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }} EVENT_NAME: ${{ github.event_name }} + # A rerun retains github.actor from the original dispatch; authorize + # the identity that initiated the current run or rerun instead. + DISPATCH_ACTOR: ${{ github.triggering_actor }} + DISPATCH_SENDER: ${{ github.event.sender.login || '' }} + ALLOWED_DISPATCH_ACTOR: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_ACTOR }} + ALLOWED_DISPATCH_TARGETS: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS }} TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.event.client_payload.target_repository || github.repository }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.client_payload.pr_number }} SUPPLIED_BASE_REF: ${{ github.event.client_payload.pr_base_ref || '' }} @@ -74,6 +80,30 @@ jobs: SUPPLIED_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha || '' }} run: | set -euo pipefail + if [ "$EVENT_NAME" = "repository_dispatch" ]; then + if [ -z "$ALLOWED_DISPATCH_ACTOR" ] || + [ "$DISPATCH_ACTOR" != "$ALLOWED_DISPATCH_ACTOR" ] || + [ "$DISPATCH_SENDER" != "$ALLOWED_DISPATCH_ACTOR" ]; then + printf '::error::repository_dispatch authorization rejected actor=%s sender=%s because both must match the configured scheduler identity.\n' "${DISPATCH_ACTOR:-}" "${DISPATCH_SENDER:-}" + exit 1 + fi + + target_allowed=0 + IFS=',' read -r -a allowed_dispatch_targets <<<"$ALLOWED_DISPATCH_TARGETS" + for allowed_target in "${allowed_dispatch_targets[@]}"; do + allowed_target="${allowed_target//[[:space:]]/}" + if [ -n "$allowed_target" ] && [ "$TARGET_REPOSITORY" = "$allowed_target" ]; then + target_allowed=1 + break + fi + done + if [ "$target_allowed" -ne 1 ]; then + printf '::error::repository_dispatch authorization rejected target=%s because it is absent from the configured exact repository allowlist.\n' "${TARGET_REPOSITORY:-}" + exit 1 + fi + printf 'Authorized repository_dispatch actor=%s sender=%s target=%s.\n' "$DISPATCH_ACTOR" "$DISPATCH_SENDER" "$TARGET_REPOSITORY" + fi + if ! [[ "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then printf '::error::PR metadata validation rejected a target outside ContextualWisdomLab or an invalid pull request number. target=%s pr=%s\n' "${TARGET_REPOSITORY:-}" "${PR_NUMBER:-}" @@ -133,14 +163,7 @@ jobs: needs: [validate-pr-metadata] if: >- needs.validate-pr-metadata.result == 'success' - && ( - github.event_name == 'repository_dispatch' - || ( - github.event_name == 'pull_request_target' - && github.event.action != 'closed' - && github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name - ) - ) + && github.event_name == 'repository_dispatch' runs-on: ubuntu-latest permissions: contents: read @@ -287,14 +310,7 @@ jobs: always() && needs.validate-pr-metadata.result == 'success' && needs.coverage-source-tree.result != 'cancelled' - && ( - github.event_name == 'repository_dispatch' - || ( - github.event_name == 'pull_request_target' - && github.event.action != 'closed' - && github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name - ) - ) + && github.event_name == 'repository_dispatch' runs-on: ubuntu-latest permissions: # The PR tree arrives through a same-run artifact. No repository-content, @@ -575,6 +591,46 @@ jobs: PY } + pyproject_has_no_selected_dependencies() { + python3 - "$1" "$2" <<'PY' + import sys + import tomllib + + with open(sys.argv[1], "rb") as fh: + data = tomllib.load(fh) + + selection = sys.argv[2] + project = data.get("project", {}) + dynamic = project.get("dynamic", []) + if not isinstance(dynamic, list): + raise SystemExit(2) + if "dependencies" in dynamic: + raise SystemExit(1) + + dependencies = project.get("dependencies", []) + if not isinstance(dependencies, list): + raise SystemExit(2) + selected = list(dependencies) + + if selection == "group-dev": + group = data.get("dependency-groups", {}).get("dev", []) + if not isinstance(group, list): + raise SystemExit(2) + selected.extend(group) + elif selection == "extra-dev": + if "optional-dependencies" in dynamic: + raise SystemExit(1) + extra = project.get("optional-dependencies", {}).get("dev", []) + if not isinstance(extra, list): + raise SystemExit(2) + selected.extend(extra) + elif selection != "runtime": + raise SystemExit(2) + + raise SystemExit(0 if not selected else 1) + PY + } + run_python_uv_lock_check() { local project_dir="$1" if [ -f "${project_dir}/uv.lock" ]; then @@ -594,9 +650,20 @@ jobs: if [ -f "$pyproject_file" ]; then run_python_uv_lock_check "$project_dir" if pyproject_has_dev_dependency_group "$pyproject_file"; then + dependency_selection="group-dev" + elif pyproject_has_dev_optional_extra "$pyproject_file"; then + dependency_selection="extra-dev" + else + dependency_selection="runtime" + fi + + if pyproject_has_no_selected_dependencies "$pyproject_file" "$dependency_selection"; then + run_and_capture "Python project dependencies (${project_dir})" \ + python3 -c 'print("No selected runtime/dev dependencies are declared; safe dependency materialization is not applicable.")' + elif [ "$dependency_selection" = "group-dev" ]; then run_and_capture "Python project dependencies (${project_dir})" \ uv sync --project "$project_dir" --group dev --no-build --no-install-project - elif pyproject_has_dev_optional_extra "$pyproject_file"; then + elif [ "$dependency_selection" = "extra-dev" ]; then run_and_capture "Python project dependencies (${project_dir})" \ uv sync --project "$project_dir" --extra dev --no-build --no-install-project else @@ -1499,20 +1566,8 @@ jobs: always() && needs.validate-pr-metadata.result == 'success' && needs.coverage-evidence.result != 'cancelled' - && ( - github.event_name == 'repository_dispatch' - || ( - github.event_name == 'pull_request_target' - && github.event.action != 'closed' - && github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name - ) - ) + && github.event_name == 'repository_dispatch' runs-on: ubuntu-latest - outputs: - # The exhausted-pool deferred retry job reads this after a fail-closed - # MODEL_OUTPUT_UNAVAILABLE exit; the model-pool step records it under - # continue-on-error, so it survives the job failing. - model_pool_outcome: ${{ steps.opencode_review_model_pool.outputs.review_status }} # Coverage and current-head evidence are prepared before the model pool. # A single legitimate review may need a full hour. The enclosing job must # contain the 12-minute evidence step, 205-minute provider-pool step, the @@ -7388,98 +7443,3 @@ jobs: if [ "$scheduler_status" -ne 0 ]; then printf '::warning::Merge scheduler follow-up failed after approval; leaving OpenCode review intact. Repository=%s base=%s. The scheduled and PR-event scheduler paths remain authoritative.\n' "$GH_REPOSITORY" "$base_branch" fi - - opencode-exhausted-retry: - name: opencode-exhausted-retry - needs: [opencode-review-target] - # A pool-exhausted required review fails closed with MODEL_OUTPUT_UNAVAILABLE - # after the publish gate has already verified that current-head coverage, - # peer checks, code-scanning alerts, and review threads are clean, so a - # deferred same-head retry is safe and needs no human. GitHub Actions has no - # delayed-dispatch primitive, so this job holds a small runner for one - # backoff window and then re-dispatches the central same-head review through - # the same trusted repository_dispatch path the merge scheduler uses. It - # retries once; outages longer than the backoff window stay owned by the - # merge scheduler org-sweep heartbeat, which keeps re-dispatching the same - # current head until a model produces a verdict. - if: >- - always() - && github.event_name == 'pull_request_target' - && github.event.action != 'closed' - && needs.opencode-review-target.result == 'failure' - && needs.opencode-review-target.outputs.model_pool_outcome == 'exhausted' - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - pull-requests: read - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - steps: - - name: Dispatch deferred same-head review retry after model-pool exhaustion - env: - GH_TOKEN: ${{ github.token }} - # Cross-repository dispatch of the central workflow needs a PAT; the - # target-repository runner token cannot dispatch workflows that live - # in the central .github repository, and the OpenCode app token has - # no Actions permission. - RETRY_DISPATCH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN }} - RETRY_DISPATCH_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || 'none' }} - # One fixed backoff window before the single deferred retry. GitHub - # Models per-minute throttles recover well inside this window; daily - # quota exhaustion outlives any in-workflow delay and is the org - # sweep's job. - RETRY_DELAY_SECONDS: "300" - CENTRAL_WORKFLOW_REPOSITORY: ContextualWisdomLab/.github - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_BASE_REF: ${{ github.event.pull_request.base.ref }} - PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} - PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - if [ -z "${RETRY_DISPATCH_TOKEN:-}" ]; then - echo "::warning::Deferred exhausted-pool retry skipped because no cross-repository dispatch credential (PR_REVIEW_MERGE_TOKEN or OPENCODE_APPROVE_TOKEN) is configured; the merge scheduler org sweep remains the retry path." - exit 0 - fi - - printf 'OpenCode model pool exhausted for head %s; sleeping %ss before one deferred same-head review dispatch with the %s token.\n' \ - "$PR_HEAD_SHA" "$RETRY_DELAY_SECONDS" "$RETRY_DISPATCH_TOKEN_SOURCE" - sleep "$RETRY_DELAY_SECONDS" - - # A head pushed or a PR closed during the backoff window already has - # (or no longer needs) its own fresh required review run. - live_pr="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '[.state, .head.sha] | join(" ")' || true)" - live_state="${live_pr%% *}" - live_head="${live_pr##* }" - if [ "$live_state" != "open" ] || [ "$live_head" != "$PR_HEAD_SHA" ]; then - printf 'Deferred exhausted-pool retry skipped: PR #%s state=%s live head=%s no longer matches exhausted head %s.\n' \ - "$PR_NUMBER" "${live_state:-unknown}" "${live_head:-unknown}" "$PR_HEAD_SHA" - exit 0 - fi - - dispatch_payload="$( - jq -n \ - --arg target_repository "$GITHUB_REPOSITORY" \ - --argjson pr_number "$PR_NUMBER" \ - --arg pr_base_ref "$PR_BASE_REF" \ - --arg pr_base_sha "$PR_BASE_SHA" \ - --arg pr_head_ref "$PR_HEAD_REF" \ - --arg pr_head_sha "$PR_HEAD_SHA" \ - '{event_type: "opencode-review", client_payload: { - target_repository: $target_repository, - pr_number: $pr_number, - pr_base_ref: $pr_base_ref, - pr_base_sha: $pr_base_sha, - pr_head_ref: $pr_head_ref, - pr_head_sha: $pr_head_sha - }}' - )" - if ! GH_TOKEN="$RETRY_DISPATCH_TOKEN" gh api -X POST \ - "repos/${CENTRAL_WORKFLOW_REPOSITORY}/dispatches" \ - --input - <<<"$dispatch_payload"; then - echo "::warning::Deferred exhausted-pool retry dispatch failed; the merge scheduler org sweep remains the retry path." - exit 0 - fi - printf 'Deferred same-head OpenCode review retry dispatched for %s#%s head %s.\n' \ - "$GITHUB_REPOSITORY" "$PR_NUMBER" "$PR_HEAD_SHA" diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 86b40eaa..24a60f35 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -472,8 +472,16 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" 'cancel-in-progress: true' "opencode review cancels stale in-progress review attempts when a newer PR event arrives" assert_file_contains "$workflow_file" "Materialize pull request merge tree for coverage measurement" "opencode pull_request coverage execution materializes the exact base/head merge tree" assert_file_contains "$workflow_file" "stale OpenCode run: event head=" "opencode review side effects are skipped for stale heads" - assert_file_contains "$workflow_file" "github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name" "opencode pull_request coverage execution is limited to same-repository PR heads using the target PR base repo" + assert_file_not_contains "$workflow_file" "github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name" "opencode never treats a same-repository pull_request_target head as authorization to execute PR-controlled code" assert_file_not_contains "$workflow_file" "github.event.pull_request.head.repo.full_name == github.repository" "opencode required workflow must not compare PR head repo to the central workflow source repository" + assert_file_contains "$workflow_file" 'DISPATCH_ACTOR: ${{ github.triggering_actor }}' "opencode repository dispatch binds authorization to the current run initiator" + assert_file_not_contains "$workflow_file" 'DISPATCH_ACTOR: ${{ github.actor }}' "opencode repository dispatch rejects reruns initiated by a different actor" + assert_file_contains "$workflow_file" "DISPATCH_SENDER: \${{ github.event.sender.login || '' }}" "opencode repository dispatch independently binds the sender identity" + assert_file_contains "$workflow_file" 'ALLOWED_DISPATCH_ACTOR: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_ACTOR }}' "opencode repository dispatch uses the protected scheduler identity" + assert_file_contains "$workflow_file" 'ALLOWED_DISPATCH_TARGETS: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS }}' "opencode repository dispatch uses an exact target repository allowlist" + assert_file_contains "$workflow_file" "repository_dispatch authorization rejected actor=" "opencode repository dispatch fails visibly for an unauthorized actor" + assert_file_contains "$workflow_file" "repository_dispatch authorization rejected target=" "opencode repository dispatch fails visibly for a disallowed target" + assert_file_contains "$workflow_file" '&& github.event_name == '\''repository_dispatch'\''' "opencode coverage and review execution require an authorized default-branch dispatch" assert_file_contains "$workflow_file" "needs.coverage-evidence.result != 'cancelled'" "opencode review does not enqueue stale side-effect jobs after coverage evidence cancellation" assert_file_contains "$workflow_file" "opencode-review-target:" "opencode trusted review job owns the required check surface" assert_file_contains "$workflow_file" "Initialize CodeGraph index for OpenCode" "opencode review workflow initializes CodeGraph before review" @@ -526,7 +534,7 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "legacy github-actions approval bridge" "opencode stale-review bridge uses a distinct publication label" assert_file_contains "$workflow_file" 'COVERAGE_SOURCE_WORKDIR: ${{ github.workspace }}/pr-head' "opencode coverage keeps PR-head data outside the trusted workflow root" assert_file_contains "$workflow_file" 'COVERAGE_SOURCE_WORKDIR: ${{ github.workspace }}/pr-head' "opencode coverage measures the PR-head checkout explicitly" - assert_file_contains "$workflow_file" "pr_head_ref:" "opencode repository_dispatch carries the PR head branch required by current-head code-scanning verification" + assert_file_contains "$REPO_ROOT/scripts/ci/pr_review_merge_scheduler.py" '"pr_head_ref":' "central scheduler repository_dispatch carries the PR head branch required by current-head code-scanning verification" assert_file_contains "$workflow_file" 'github.event.client_payload.pr_head_ref' "opencode review wires the PR head branch into current-head code-scanning verification" assert_file_contains "$workflow_file" 'statuses: write' "opencode repository_dispatch can publish GitHub Actions sourced current-head status evidence" assert_file_contains "$workflow_file" "Publish repository_dispatch OpenCode status" "opencode repository_dispatch publishes same-head status evidence for required checks" @@ -655,6 +663,8 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" 'OPENCODE_RUN_TIMEOUT_SECONDS: "5400"' "opencode primary review preserves legitimate full-hour provider sessions" assert_file_contains "$workflow_file" 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "11700"' "opencode model pool exits before the step timeout so the approval gate can publish a reason" assert_file_contains "$workflow_file" 'OPENCODE_POOL_MAX_CYCLES: "0"' "opencode model pool keeps cycling until the bounded retry budget or step timeout is exhausted" + assert_file_not_contains "$workflow_file" 'opencode-exhausted-retry:' "opencode model exhaustion retries stay owned by the least-privilege central scheduler" + assert_file_not_contains "$workflow_file" 'RETRY_DISPATCH_TOKEN' "opencode does not retain a recursive write-token dispatch path" assert_file_contains "$workflow_file" "needs.coverage-evidence.result == 'success'" "opencode model pool only runs after coverage evidence passed" assert_file_contains "$workflow_file" "id: opencode_review_model_pool" "opencode DeepSeek V3 fallback still runs after a primary model timeout or step failure when coverage evidence passed" assert_file_contains "$workflow_file" "always()" "opencode fallback chain uses always() so failed model steps cannot skip every fallback" @@ -867,8 +877,8 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_not_contains "$workflow_file" "gh workflow run pr-review-merge-scheduler.yml" "opencode approval must not rely on repo-local workflow dispatch for organization required workflows" assert_file_contains "$workflow_file" "gh api \"repos/\${GH_REPOSITORY}\" --jq '.default_branch // empty'" "opencode scheduler dispatch uses the target repository default branch" assert_file_contains "$workflow_file" 'base_branch="${PR_BASE_REF:-${default_branch:-main}}"' "opencode scheduler follow-up derives the target base branch instead of hard-coding main" - assert_file_contains "$workflow_file" 'event_type: "opencode-review"' "opencode deferred review retry uses the dedicated repository-dispatch event" - assert_file_contains "$workflow_file" 'repos/${CENTRAL_WORKFLOW_REPOSITORY}/dispatches' "opencode deferred review retry targets the central default-branch dispatch endpoint" + assert_file_contains "$REPO_ROOT/scripts/ci/pr_review_merge_scheduler.py" '"event_type": "opencode-review"' "central scheduler review retry uses the dedicated repository-dispatch event" + assert_file_contains "$REPO_ROOT/scripts/ci/pr_review_merge_scheduler.py" 'repos/{dispatch_repo}/dispatches' "central scheduler review retry targets the default-branch repository-dispatch endpoint" assert_file_not_contains "$workflow_file" "gh workflow run" "opencode deferred retry cannot select a privileged workflow ref" assert_file_contains "$workflow_file" "continue-on-error: true" "opencode post-approval scheduler dispatch failure does not fail a completed approval check" assert_file_contains "$workflow_file" "Merge scheduler follow-up failed after approval; leaving OpenCode review intact." "opencode post-approval scheduler failure is reported as a warning" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index b72bf9eb..d19ca24c 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -3,6 +3,7 @@ import re import shutil import subprocess +import sys import textwrap from pathlib import Path @@ -239,8 +240,8 @@ def test_opencode_ignores_superseded_cancelled_rollup_checks(): ) in workflow -def test_opencode_target_coverage_materializes_merge_tree_without_checkout_action(): - """Avoid pull_request_target action checkouts of untrusted PR refs.""" +def test_opencode_target_coverage_materializes_only_after_authorized_dispatch(): + """Keep PR-controlled test execution off the pull_request_target path.""" workflow = Path(".github/workflows/opencode-review.yml").read_text(encoding="utf-8") assert "required-workflow-bootstrap:" in workflow assert "Required OpenCode workflow run materialized for this PR event." in workflow @@ -248,10 +249,6 @@ def test_opencode_target_coverage_materializes_merge_tree_without_checkout_actio bootstrap_end = workflow.index("\n validate-pr-metadata:", bootstrap_start) bootstrap_job = workflow[bootstrap_start:bootstrap_end] assert "\n if:" not in bootstrap_job - assert ( - "github.event.pull_request.head.repo.full_name == " - "github.event.pull_request.base.repo.full_name" - ) in workflow assert ( "github.event.pull_request.head.repo.full_name == github.repository" not in workflow @@ -262,6 +259,8 @@ def test_opencode_target_coverage_materializes_merge_tree_without_checkout_actio source_start = workflow.index(" coverage-source-tree:\n") source_end = workflow.index("\n coverage-evidence:", source_start) source_job = workflow[source_start:source_end] + assert "github.event_name == 'repository_dispatch'" in source_job + assert "github.event_name == 'pull_request_target'" not in source_job assert "id-token: write" in source_job assert ( "Exchange OpenCode app token for target repository coverage reads" in source_job @@ -277,6 +276,8 @@ def test_opencode_target_coverage_materializes_merge_tree_without_checkout_actio coverage_start = workflow.index(" coverage-evidence:\n") coverage_end = workflow.index("\n opencode-review-target:", coverage_start) coverage_job = workflow[coverage_start:coverage_end] + assert "github.event_name == 'repository_dispatch'" in coverage_job + assert "github.event_name == 'pull_request_target'" not in coverage_job assert "id-token: write" not in coverage_job assert "Report coverage source materialization failure" in coverage_job assert ( @@ -332,12 +333,187 @@ def test_opencode_target_coverage_materializes_merge_tree_without_checkout_actio assert "package.metadata.opencode.coverage.minimum_lines" in measure_step assert '--fail-under-lines "$threshold"' in measure_step assert "run_python_uv_lock_check()" in measure_step + assert "pyproject_has_no_selected_dependencies()" in measure_step assert "Python uv lockfile consistency (${project_dir})" in measure_step assert "uv lock --check" in measure_step assert measure_step.index( 'run_python_uv_lock_check "$project_dir"' ) < measure_step.index('uv sync --project "$project_dir" --group dev') + target_start = workflow.index(" opencode-review-target:\n") + target_job = workflow[target_start:] + target_condition = target_job.split(" runs-on:", 1)[0] + assert "github.event_name == 'repository_dispatch'" in target_condition + assert "github.event_name == 'pull_request_target'" not in target_condition + + +def test_opencode_repository_dispatch_authorization_is_fail_closed(): + """Reject an untrusted dispatcher or a target outside the exact allowlist.""" + workflow = Path(".github/workflows/opencode-review.yml").read_text(encoding="utf-8") + validate_step = workflow.split( + " - name: Bind workflow inputs to live organization pull request metadata\n", + 1, + )[1].split(" pull_request_json=", 1)[0] + shell = textwrap.dedent(validate_step.split(" run: |\n", 1)[1]) + + assert "DISPATCH_ACTOR: ${{ github.triggering_actor }}" in validate_step + assert "DISPATCH_ACTOR: ${{ github.actor }}" not in validate_step + assert "DISPATCH_SENDER: ${{ github.event.sender.login || '' }}" in validate_step + assert ( + "ALLOWED_DISPATCH_ACTOR: " + "${{ vars.OPENCODE_REPOSITORY_DISPATCH_ACTOR }}" in validate_step + ) + assert ( + "ALLOWED_DISPATCH_TARGETS: " + "${{ vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS }}" in validate_step + ) + + base_env = { + **os.environ, + "EVENT_NAME": "repository_dispatch", + "DISPATCH_ACTOR": "github-actions[bot]", + "DISPATCH_SENDER": "github-actions[bot]", + "ALLOWED_DISPATCH_ACTOR": "github-actions[bot]", + "ALLOWED_DISPATCH_TARGETS": ( + "ContextualWisdomLab/.github,ContextualWisdomLab/naruon" + ), + "TARGET_REPOSITORY": "ContextualWisdomLab/naruon", + "PR_NUMBER": "1085", + } + + authorized = subprocess.run( + ["bash", "-c", shell], + env=base_env, + text=True, + capture_output=True, + check=False, + ) + assert authorized.returncode == 0, authorized.stderr + assert "Authorized repository_dispatch actor=" in authorized.stdout + + for overrides, expected_reason in ( + ({"ALLOWED_DISPATCH_ACTOR": ""}, "rejected actor="), + ({"DISPATCH_SENDER": "seonghobae"}, "rejected actor="), + ( + {"ALLOWED_DISPATCH_TARGETS": "ContextualWisdomLab/.github"}, + "rejected target=ContextualWisdomLab/naruon", + ), + ): + rejected = subprocess.run( + ["bash", "-c", shell], + env={**base_env, **overrides}, + text=True, + capture_output=True, + check=False, + ) + assert rejected.returncode == 1 + assert expected_reason in rejected.stdout + + +def test_opencode_model_exhaustion_retry_stays_owned_by_central_scheduler(): + """Do not broaden workflow permissions for a recursive review dispatch.""" + workflow = Path(".github/workflows/opencode-review.yml").read_text(encoding="utf-8") + assert "opencode-exhausted-retry:" not in workflow + assert "RETRY_DISPATCH_TOKEN" not in workflow + assert "contents: write" not in workflow + + +def test_opencode_empty_pyproject_dependency_probe_is_fail_closed(tmp_path): + """Skip only declaratively empty dependency sets without running build hooks.""" + workflow = Path(".github/workflows/opencode-review.yml").read_text(encoding="utf-8") + function = workflow.split( + " pyproject_has_no_selected_dependencies() {\n", 1 + )[1].split("\n PY\n }", 1)[0] + probe = textwrap.dedent(function.split("<<'PY'\n", 1)[1]) + pyproject = tmp_path / "pyproject.toml" + + def run(source: str, selection: str) -> subprocess.CompletedProcess[str]: + pyproject.write_text(textwrap.dedent(source), encoding="utf-8") + return subprocess.run( + [sys.executable, "-", str(pyproject), selection], + input=probe, + capture_output=True, + text=True, + check=False, + ) + + assert ( + run( + """ + [project] + name = "empty" + dynamic = ["version"] + dependencies = [] + """, + "runtime", + ).returncode + == 0 + ) + assert ( + run( + """ + [project] + name = "runtime" + version = "1.0.0" + dependencies = ["pydantic>=2"] + """, + "runtime", + ).returncode + == 1 + ) + assert ( + run( + """ + [project] + name = "group" + version = "1.0.0" + dependencies = [] + + [dependency-groups] + dev = ["pytest>=8"] + """, + "group-dev", + ).returncode + == 1 + ) + assert ( + run( + """ + [project] + name = "dynamic" + version = "1.0.0" + dynamic = ["dependencies"] + """, + "runtime", + ).returncode + == 1 + ) + assert ( + run( + """ + [project] + name = "dynamic-extra" + version = "1.0.0" + dynamic = ["optional-dependencies"] + dependencies = [] + """, + "extra-dev", + ).returncode + == 1 + ) + assert ( + run( + """ + [project] + name = "malformed" + version = "1.0.0" + dependencies = "pytest" + """, + "runtime", + ).returncode + == 2 + ) + def test_opencode_coverage_prefers_declared_pnpm_runner_before_npm(): """pnpm workspaces must not be measured through npm after corepack setup.""" @@ -1370,8 +1546,7 @@ def test_opencode_privileged_review_security_boundaries_are_fail_closed(): coverage_end = workflow.index("\n opencode-review-target:", coverage_start) coverage_job = workflow[coverage_start:coverage_end] target_start = coverage_end + 1 - target_end = workflow.index("\n opencode-exhausted-retry:", target_start) - target_job = workflow[target_start:target_end] + target_job = workflow[target_start:] assert 'scripts/ci/safe_pytest_command.py" discover' in coverage_job assert 'scripts/ci/safe_pytest_command.py" execute' in coverage_job @@ -1402,10 +1577,9 @@ def test_opencode_privileged_review_security_boundaries_are_fail_closed(): assert "cargo install cargo-llvm-cov --version 0.8.7 --locked" in coverage_job assert "install.packages(" not in coverage_job - assert ( - "github.event.pull_request.head.repo.full_name == " - "github.event.pull_request.base.repo.full_name" - ) in target_job + target_condition = target_job.split(" runs-on:", 1)[0] + assert "github.event_name == 'repository_dispatch'" in target_condition + assert "github.event_name == 'pull_request_target'" not in target_condition assert "pull_request_target:" in workflow.split("permissions:", 1)[0] assert "\n pull_request:\n" not in workflow.split("permissions:", 1)[0] assert workflow.count("ref: ${{ steps.trusted_source.outputs.ref }}") == 1 From e7b94f664ea43634b6d16a64fe5226645a8b6586 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 16 Jul 2026 12:15:52 +0900 Subject: [PATCH 4/5] fix(strix): scan container changes with full head context --- scripts/ci/strix_quick_gate.sh | 35 +++++++++++++++++++++++++++++ scripts/ci/test_strix_quick_gate.sh | 30 ++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index 5b2bdebc..23d8ada6 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -1655,6 +1655,19 @@ PY LAST_PULL_REQUEST_SCOPE_DIR="$scope_dir" } +pull_request_changes_require_full_head_context() { + local changed_file normalized_changed_file + for changed_file in "${CHANGED_FILES[@]}"; do + normalized_changed_file="$(normalize_changed_file_path "$changed_file")" || return 2 + case "$normalized_changed_file" in + Dockerfile | */Dockerfile | Dockerfile.* | */Dockerfile.* | Containerfile | */Containerfile | Containerfile.* | */Containerfile.* | docker-compose.yml | */docker-compose.yml | docker-compose.yaml | */docker-compose.yaml | docker-compose.*.yml | */docker-compose.*.yml | docker-compose.*.yaml | */docker-compose.*.yaml | compose.yml | */compose.yml | compose.yaml | */compose.yaml | compose.*.yml | */compose.*.yml | compose.*.yaml | */compose.*.yaml) + return 0 + ;; + esac + done + return 1 +} + prepare_pull_request_scan_scope() { if ! is_pull_request_event; then return 0 @@ -1693,6 +1706,28 @@ prepare_pull_request_scan_scope() { CHANGED_FILES=("${scoped_changed_files[@]}") local total_files="${#CHANGED_FILES[@]}" + if pull_request_head_blob_required; then + local full_context_rc=0 + pull_request_changes_require_full_head_context || full_context_rc=$? + case "$full_context_rc" in + 0) + local build_full_context_rc=0 + build_pull_request_head_tree_scope_dir || build_full_context_rc=$? + if [ "$build_full_context_rc" -ne 0 ]; then + return 2 + fi + TARGET_PATH="$LAST_PULL_REQUEST_SCOPE_DIR" + TARGET_PATH_IS_INTERNAL_PR_SCOPE=1 + printf "Container build manifest changed; materialized full PR-head blob scope so referenced source paths are available while %s changed file(s) remain the findings boundary.\n" "$total_files" >&2 + return 0 + ;; + 1) + ;; + *) + return 2 + ;; + esac + fi derive_pull_request_full_target_path() { python3 - "$REPO_ROOT" "$@" <<'PY' from pathlib import Path diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 24a60f35..2c342fed 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -5837,6 +5837,18 @@ run_filtered_gate_case_if_requested() { pull-request-target-gitlink-is-explicitly-skipped) run_pull_request_target_gitlink_is_explicitly_skipped_case ;; + pull-request-target-dockerfile-change-uses-full-head-context) + run_pull_request_target_head_scope_case \ + "pull-request-target-dockerfile-change-uses-full-head-context" \ + "Dockerfile" \ + "FROM python:3.12-slim AS base" \ + "FROM python:3.12-slim AS head" \ + "0" \ + "0" \ + "." \ + "1" \ + "Container build manifest changed; materialized full PR-head blob scope" + ;; *) record_failure "unknown STRIX_TEST_CASE_FILTER '${STRIX_TEST_CASE_FILTER:-}'" ;; @@ -5858,6 +5870,8 @@ run_pull_request_target_head_scope_case() { local disable_pr_scoping="${5-0}" local make_head_executable="${6-0}" local target_path="${7-.}" + local expected_full_head_scope="${8-$disable_pr_scoping}" + local expected_scope_message="${9-}" local tmp_dir tmp_dir="$(mktemp -d)" @@ -5984,7 +5998,7 @@ EOF FAKE_STRIX_UNEXPECTED_BASE_CONTENT="$unexpected_base_content" \ FAKE_STRIX_EXPECTED_UNCHANGED_FILE="docs/full-scope-context.md" \ FAKE_STRIX_EXPECTED_UNCHANGED_CONTENT="HEAD_FULL_SCOPE_CONTEXT_SHOULD_BE_SCANNED" \ - FAKE_STRIX_EXPECT_FULL_HEAD_SCOPE="$disable_pr_scoping" \ + FAKE_STRIX_EXPECT_FULL_HEAD_SCOPE="$expected_full_head_scope" \ STRIX_DISABLE_PR_SCOPING="$disable_pr_scoping" \ STRIX_LLM_FILE="$strix_llm_file" \ LLM_API_KEY_FILE="$llm_api_key_file" \ @@ -5997,6 +6011,9 @@ EOF assert_equals "0" "$rc" "case=$case_name exit code" assert_file_contains "$output_log" "scan ok with PR head content" "case=$case_name output" + if [ -n "$expected_scope_message" ]; then + assert_file_contains "$output_log" "$expected_scope_message" "case=$case_name scope reason" + fi rm -rf "$tmp_dir" } @@ -8412,6 +8429,17 @@ run_pull_request_target_head_scope_case \ "HEAD_NESTED_CONTENT_SHOULD_BE_SCANNED" \ "1" +run_pull_request_target_head_scope_case \ + "pull-request-target-dockerfile-change-uses-full-head-context" \ + "Dockerfile" \ + "FROM python:3.12-slim AS base" \ + "FROM python:3.12-slim AS head" \ + "0" \ + "0" \ + "." \ + "1" \ + "Container build manifest changed; materialized full PR-head blob scope" + run_pull_request_target_bounded_head_context_scope_case run_pull_request_target_changed_context_scope_uses_pr_head_case From 918a2672a96bf75137300c2227d187aa37f0cfce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 16 Jul 2026 13:07:25 +0900 Subject: [PATCH 5/5] fix(ci): isolate untrusted review execution --- .github/workflows/opencode-review.yml | 113 ++++++++++++++++---------- .github/workflows/strix.yml | 15 ++++ scripts/ci/strix_quick_gate.sh | 41 ++++++++-- scripts/ci/test_strix_quick_gate.sh | 64 +++++++++++++-- tests/test_opencode_agent_contract.py | 14 +++- 5 files changed, 189 insertions(+), 58 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 9345e91e..54bd1558 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -429,6 +429,39 @@ jobs: exit "$replay_status" fi + # Run every trusted follow-up before executing pull-request code. Even a + # credential-free test can write runner command files, so no trusted shell + # step may consume state after coverage measurement begins. + - name: Enforce changed-file syntax gate + env: + PR_BASE_SHA: ${{ needs.validate-pr-metadata.outputs.base_sha }} + COVERAGE_SOURCE_WORKDIR: ${{ github.workspace }}/pr-head + run: | + set -euo pipefail + # Deterministic per-file syntax check on the PR's changed files. The + # LLM reviewer reads diffs and the test suite only exercises imported + # files, so a syntax error in a changed file that no test imports (or + # in a language with no wired-in runner) could otherwise be approved. + changed_files_file="${RUNNER_TEMP}/opencode-syntax-changed-files.txt" + if ! git -C "$COVERAGE_SOURCE_WORKDIR" diff --name-only "$PR_BASE_SHA" HEAD >"$changed_files_file" 2>/dev/null; then + : >"$changed_files_file" + fi + syntax_report="${RUNNER_TEMP}/opencode-syntax-report.txt" + syntax_status=0 + ( cd "$COVERAGE_SOURCE_WORKDIR" && python3 "${GITHUB_WORKSPACE}/scripts/ci/changed_file_syntax_gate.py" --changed-files-file "$changed_files_file" ) >"$syntax_report" 2>&1 || syntax_status=$? + cat "$syntax_report" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + printf '## Changed-file syntax gate\n\n```text\n' + cat "$syntax_report" + printf '\n```\n' + } >>"$GITHUB_STEP_SUMMARY" + fi + if [ "$syntax_status" -ne 0 ]; then + echo "::error::A changed file has a syntax error; OpenCode approval is blocked until it parses on the current head." + exit 1 + fi + - name: Install Python coverage measurement tools run: >- python3 -m pip install --disable-pip-version-check --require-hashes @@ -440,6 +473,10 @@ jobs: PR_BASE_SHA: ${{ needs.validate-pr-metadata.outputs.base_sha }} PR_HEAD_SHA: ${{ needs.validate-pr-metadata.outputs.head_sha }} COVERAGE_SOURCE_WORKDIR: ${{ github.workspace }}/pr-head + # Apply wheel-only resolution in the same step that consumes + # pull-request dependency metadata. A value on an earlier step does + # not cross the GitHub Actions step boundary. + UV_NO_BUILD: "1" run: | set -euo pipefail unset ACTIONS_ID_TOKEN_REQUEST_TOKEN ACTIONS_ID_TOKEN_REQUEST_URL ACTIONS_RUNTIME_TOKEN GH_TOKEN GITHUB_TOKEN @@ -486,7 +523,19 @@ jobs: append '```text' append_command "$@" set +e - timeout 900 "$@" >"$log_file" 2>&1 + timeout 900 env \ + -u ACTIONS_ID_TOKEN_REQUEST_TOKEN \ + -u ACTIONS_ID_TOKEN_REQUEST_URL \ + -u ACTIONS_RUNTIME_TOKEN \ + -u GH_TOKEN \ + -u GITHUB_TOKEN \ + GITHUB_ENV=/dev/null \ + GITHUB_PATH=/dev/null \ + GITHUB_OUTPUT=/dev/null \ + GITHUB_STEP_SUMMARY=/dev/null \ + BASH_ENV=/dev/null \ + UV_NO_BUILD=1 \ + "$@" >"$log_file" 2>&1 local rc=$? set -e emit_captured_log "$log_file" @@ -512,7 +561,19 @@ jobs: append '```text' append_command "$@" set +e - timeout 900 "$@" >"$log_file" 2>&1 + timeout 900 env \ + -u ACTIONS_ID_TOKEN_REQUEST_TOKEN \ + -u ACTIONS_ID_TOKEN_REQUEST_URL \ + -u ACTIONS_RUNTIME_TOKEN \ + -u GH_TOKEN \ + -u GITHUB_TOKEN \ + GITHUB_ENV=/dev/null \ + GITHUB_PATH=/dev/null \ + GITHUB_OUTPUT=/dev/null \ + GITHUB_STEP_SUMMARY=/dev/null \ + BASH_ENV=/dev/null \ + UV_NO_BUILD=1 \ + "$@" >"$log_file" 2>&1 local rc=$? set -e emit_captured_log "$log_file" @@ -642,7 +703,7 @@ jobs: install_python_project_dependencies() { if [ -f requirements.txt ]; then run_and_capture "Python project dependencies (requirements.txt)" \ - uv run --no-project --with-requirements requirements.txt python -c 'import sys; print("binary-only requirements resolved with", sys.executable)' + uv run --no-project --no-build --with-requirements requirements.txt python -c 'import sys; print("binary-only requirements resolved with", sys.executable)' fi while IFS= read -r project_dir; do @@ -672,11 +733,11 @@ jobs: fi if [ -f "${project_dir}/requirements.txt" ]; then run_and_capture "Python project dependencies (${project_dir}/requirements.txt in uv env)" \ - bash -c 'cd "$1" && uv run --no-project --with-requirements requirements.txt python -c "import sys; print(\"binary-only requirements resolved with\", sys.executable)"' bash "$project_dir" + bash -c 'cd "$1" && uv run --no-project --no-build --with-requirements requirements.txt python -c "import sys; print(\"binary-only requirements resolved with\", sys.executable)"' bash "$project_dir" fi elif [ "$project_dir" != "." ] && [ -f "${project_dir}/requirements.txt" ]; then run_and_capture "Python project dependencies (${project_dir}/requirements.txt)" \ - bash -c 'cd "$1" && uv run --no-project --with-requirements requirements.txt python -c "import sys; print(\"binary-only requirements resolved with\", sys.executable)"' bash "$project_dir" + bash -c 'cd "$1" && uv run --no-project --no-build --with-requirements requirements.txt python -c "import sys; print(\"binary-only requirements resolved with\", sys.executable)"' bash "$project_dir" fi done < <(tracked_python_projects_with_tests) } @@ -704,20 +765,20 @@ jobs: done <<<"$configured_commands_json" elif [ -f "${project_dir}/pyproject.toml" ]; then run_and_capture "Python coverage with missing-line report (${project_dir})" \ - bash -c 'cd "$1" && PYTHONPATH=. uv run --with coverage --with pytest coverage run -m pytest tests && uv run --with coverage coverage report --show-missing' bash "$project_dir" + bash -c 'cd "$1" && PYTHONPATH=. uv run --no-build --with coverage --with pytest coverage run -m pytest tests && uv run --no-build --with coverage coverage report --show-missing' bash "$project_dir" elif [ -f "${project_dir}/requirements.txt" ]; then run_and_capture "Python coverage with missing-line report (${project_dir})" \ - bash -c 'cd "$1" && PYTHONPATH=. uv run --with-requirements requirements.txt --with coverage --with pytest coverage run -m pytest tests && uv run --with-requirements requirements.txt --with coverage coverage report --show-missing' bash "$project_dir" + bash -c 'cd "$1" && PYTHONPATH=. uv run --no-build --with-requirements requirements.txt --with coverage --with pytest coverage run -m pytest tests && uv run --no-build --with-requirements requirements.txt --with coverage coverage report --show-missing' bash "$project_dir" else run_and_capture "Python coverage with missing-line report (${project_dir})" \ - bash -c 'cd "$1" && PYTHONPATH=. uv run --with coverage --with pytest coverage run -m pytest tests && uv run --with coverage coverage report --show-missing' bash "$project_dir" + bash -c 'cd "$1" && PYTHONPATH=. uv run --no-build --with coverage --with pytest coverage run -m pytest tests && uv run --no-build --with coverage coverage report --show-missing' bash "$project_dir" fi done < <(tracked_python_projects_with_tests) if [ "$measured_projects" -eq 0 ]; then if has_tracked_files '*.py'; then run_and_capture "Python coverage with missing-line report" \ - bash -c 'PYTHONPATH=. uv run --with coverage --with pytest coverage run -m pytest && uv run --with coverage coverage report --show-missing' + bash -c 'PYTHONPATH=. uv run --no-build --with coverage --with pytest coverage run -m pytest && uv run --no-build --with coverage coverage report --show-missing' elif python3 -c 'import pytest_cov' >/dev/null 2>&1; then run_and_capture "Python pytest-cov coverage" python3 -m pytest --cov=. --cov-report=term-missing else @@ -844,10 +905,10 @@ jobs: measured_projects=1 if [ -f "${project_dir}/pyproject.toml" ]; then run_and_capture "Python docstring coverage (${project_dir})" \ - bash -c 'cd "$1" && PYTHONPATH=. uv run pytest tests/test_docstrings.py' bash "$project_dir" + bash -c 'cd "$1" && PYTHONPATH=. uv run --no-build pytest tests/test_docstrings.py' bash "$project_dir" elif [ -f "${project_dir}/requirements.txt" ]; then run_and_capture "Python docstring coverage (${project_dir})" \ - bash -c 'cd "$1" && PYTHONPATH=. uv run --with-requirements requirements.txt --with pytest python -m pytest tests/test_docstrings.py' bash "$project_dir" + bash -c 'cd "$1" && PYTHONPATH=. uv run --no-build --with-requirements requirements.txt --with pytest python -m pytest tests/test_docstrings.py' bash "$project_dir" else run_and_capture "Python docstring coverage (${project_dir})" \ bash -c 'cd "$1" && PYTHONPATH=. python3 -m pytest tests/test_docstrings.py' bash "$project_dir" @@ -1529,36 +1590,6 @@ jobs: exit 1 fi - - name: Enforce changed-file syntax gate - env: - PR_BASE_SHA: ${{ needs.validate-pr-metadata.outputs.base_sha }} - COVERAGE_SOURCE_WORKDIR: ${{ github.workspace }}/pr-head - run: | - set -euo pipefail - # Deterministic per-file syntax check on the PR's changed files. The - # LLM reviewer reads diffs and the test suite only exercises imported - # files, so a syntax error in a changed file that no test imports (or - # in a language with no wired-in runner) could otherwise be approved. - changed_files_file="${RUNNER_TEMP}/opencode-syntax-changed-files.txt" - if ! git -C "$COVERAGE_SOURCE_WORKDIR" diff --name-only "$PR_BASE_SHA" HEAD >"$changed_files_file" 2>/dev/null; then - : >"$changed_files_file" - fi - syntax_report="${RUNNER_TEMP}/opencode-syntax-report.txt" - syntax_status=0 - ( cd "$COVERAGE_SOURCE_WORKDIR" && python3 "${GITHUB_WORKSPACE}/scripts/ci/changed_file_syntax_gate.py" --changed-files-file "$changed_files_file" ) >"$syntax_report" 2>&1 || syntax_status=$? - cat "$syntax_report" - if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then - { - printf '## Changed-file syntax gate\n\n```text\n' - cat "$syntax_report" - printf '\n```\n' - } >>"$GITHUB_STEP_SUMMARY" - fi - if [ "$syntax_status" -ne 0 ]; then - echo "::error::A changed file has a syntax error; OpenCode approval is blocked until it parses on the current head." - exit 1 - fi - opencode-review-target: name: opencode-review needs: [validate-pr-metadata, coverage-evidence] diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index 6b701e81..97b7d60c 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -492,7 +492,22 @@ jobs: if: steps.gate.outputs.enabled == 'true' working-directory: trusted-strix-source run: | + set -euo pipefail python3 -m pip install --disable-pip-version-check --no-cache-dir --require-hashes -r requirements-strix-ci-hashes.txt + strix_executable="$(command -v strix || true)" + if [ -z "$strix_executable" ] || [[ "$strix_executable" != /* ]] \ + || [ ! -f "$strix_executable" ] || [ -L "$strix_executable" ] \ + || [ ! -x "$strix_executable" ]; then + echo "::error::Pinned Strix installation did not produce a trusted absolute executable path." + exit 1 + fi + case "$strix_executable" in + "$GITHUB_WORKSPACE"/*|"$RUNNER_TEMP"/*) + echo "::error::Refusing a Strix executable from a workspace or runner-temp path." + exit 1 + ;; + esac + printf 'STRIX_EXECUTABLE_PATH=%s\n' "$strix_executable" >> "$GITHUB_ENV" - name: Mask LLM API key if: steps.gate.outputs.enabled == 'true' diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index 23d8ada6..0673750b 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -38,6 +38,7 @@ DEFAULT_PROVIDER="" LLM_API_BASE_FILE="${LLM_API_BASE_FILE:-}" STRIX_GITHUB_MODELS_API_BASE_FILE="${STRIX_GITHUB_MODELS_API_BASE_FILE:-}" STRIX_INPUT_FILE_ROOT="${STRIX_INPUT_FILE_ROOT:-${RUNNER_TEMP:-}}" +STRIX_EXECUTABLE_PATH="${STRIX_EXECUTABLE_PATH:-}" STRIX_TRANSIENT_RETRY_PER_MODEL="${STRIX_TRANSIENT_RETRY_PER_MODEL:-0}" STRIX_TRANSIENT_RETRY_BACKOFF_SECONDS="${STRIX_TRANSIENT_RETRY_BACKOFF_SECONDS:-3}" STRIX_FAIL_ON_MIN_SEVERITY="${STRIX_FAIL_ON_MIN_SEVERITY:-MEDIUM}" @@ -2415,14 +2416,14 @@ run_strix_once() { set -o pipefail set +e STRIX_CHILD_MODEL="$child_model" \ - STRIX_CHILD_LLM_API_KEY="$child_llm_api_key" \ - STRIX_CHILD_LLM_API_BASE="$llm_api_base_value" \ - STRIX_CHILD_REPORTS_DIR="$ACTIVE_REPORTS_DIR" \ - python3 - "$timeout_seconds" "$resolved_target_path" "$SCAN_MODE" "$STRIX_LOG" <<'PY' + STRIX_CHILD_LLM_API_KEY="$child_llm_api_key" \ + STRIX_CHILD_LLM_API_BASE="$llm_api_base_value" \ + STRIX_CHILD_REPORTS_DIR="$ACTIVE_REPORTS_DIR" \ + STRIX_CHILD_EXECUTABLE_PATH="$STRIX_EXECUTABLE_PATH" \ + python3 - "$timeout_seconds" "$resolved_target_path" "$SCAN_MODE" "$STRIX_LOG" <<'PY' import os import pathlib import signal -import shutil import subprocess import sys @@ -2495,11 +2496,26 @@ if llm_api_base: else: child_env.pop("LLM_API_BASE", None) -resolved_strix_bin = shutil.which("strix") or "" -if not resolved_strix_bin: - sys.stderr.write("ERROR: strix executable not found in PATH.\n") +configured_strix_bin = os.environ.get("STRIX_CHILD_EXECUTABLE_PATH", "") +if not configured_strix_bin: + sys.stderr.write("ERROR: STRIX_EXECUTABLE_PATH must name the trusted installed Strix executable.\n") raise SystemExit(127) -resolved_strix_bin = str(pathlib.Path(resolved_strix_bin).resolve(strict=True)) +configured_strix_path = pathlib.Path(configured_strix_bin) +if not configured_strix_path.is_absolute() or configured_strix_path.is_symlink(): + sys.stderr.write("ERROR: STRIX_EXECUTABLE_PATH must be an absolute non-symlink path.\n") + raise SystemExit(127) +try: + resolved_strix_path = configured_strix_path.resolve(strict=True) +except OSError as exc: + sys.stderr.write(f"ERROR: STRIX_EXECUTABLE_PATH could not be canonicalized: {exc}\n") + raise SystemExit(127) +if not resolved_strix_path.is_file() or not os.access(resolved_strix_path, os.X_OK): + sys.stderr.write("ERROR: STRIX_EXECUTABLE_PATH must be an executable regular file.\n") + raise SystemExit(127) +if resolved_strix_path.stat().st_uid != os.geteuid(): + sys.stderr.write("ERROR: STRIX_EXECUTABLE_PATH must be owned by the current runner identity.\n") + raise SystemExit(127) +resolved_strix_bin = str(resolved_strix_path) try: target_cwd = pathlib.Path(target_path).resolve(strict=True) @@ -2509,6 +2525,13 @@ except OSError as exc: if not target_cwd.is_dir(): sys.stderr.write("ERROR: Strix target path must be a directory.\n") raise SystemExit(2) +try: + resolved_strix_path.relative_to(target_cwd) +except ValueError: + pass +else: + sys.stderr.write("ERROR: STRIX_EXECUTABLE_PATH must be outside the untrusted scan target.\n") + raise SystemExit(127) if any(ch in str(target_cwd) for ch in ("\x00", "\n", "\r")): sys.stderr.write("ERROR: Strix target path contains unsupported control characters.\n") raise SystemExit(2) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 2c342fed..3cac33b7 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -843,6 +843,13 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "Coverage merge tree could not be materialized" "coverage evidence logs an actionable merge-tree failure reason" assert_file_contains "$workflow_file" "--require-hashes" "coverage tooling installs from a hash-pinned lock" assert_file_contains "$workflow_file" "--only-binary=:all: -r requirements-opencode-review-ci-hashes.txt" "coverage tooling installs only binary packages from the pinned lock" + assert_file_contains "$workflow_file" 'GITHUB_ENV=/dev/null' "PR-controlled coverage commands cannot write runner environment command files" + assert_file_contains "$workflow_file" 'GITHUB_PATH=/dev/null' "PR-controlled coverage commands cannot extend later-step PATH" + assert_file_contains "$workflow_file" 'GITHUB_OUTPUT=/dev/null' "PR-controlled coverage commands cannot forge trusted step outputs" + assert_file_contains "$workflow_file" 'BASH_ENV=/dev/null' "PR-controlled coverage commands cannot persist shell startup hooks" + assert_file_contains "$workflow_file" 'UV_NO_BUILD: "1"' "coverage wheel-only policy is scoped to the dependency-consuming measure step" + assert_file_contains "$workflow_file" 'uv run --no-project --no-build --with-requirements' "requirements resolution rejects PR-controlled source builds" + assert_file_contains "$workflow_file" 'uv run --no-build --with coverage' "coverage resolution rejects PR-controlled source builds" assert_file_contains "$workflow_file" 'ref: ${{ steps.trusted_source.outputs.ref }}' "OpenCode review checks out validated central trusted scripts for same-head validation" assert_file_contains "$workflow_file" 'COVERAGE_EVIDENCE_RESULT: ${{ needs.coverage-evidence.result || '\''skipped'\'' }}' "opencode approval receives the coverage-evidence job conclusion" assert_file_contains "$workflow_file" 'PR_BASE_SHA: ${{ needs.validate-pr-metadata.outputs.base_sha }}' "coverage evidence receives the live validated PR base SHA for changed-file scoped measurement" @@ -859,6 +866,10 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "pnpm install --frozen-lockfile --ignore-scripts" "coverage dependency installation suppresses pnpm lifecycle hooks" assert_file_contains "$workflow_file" "yarn install --immutable --mode=skip-builds" "coverage dependency installation suppresses Yarn build hooks" assert_file_contains "$workflow_file" "--no-build --no-install-project" "coverage dependency installation refuses PR-controlled Python build backends" + assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'STRIX_EXECUTABLE_PATH=%s' "Strix workflow captures the pinned installation executable before scanning" + assert_file_contains "$GATE_SCRIPT" 'STRIX_EXECUTABLE_PATH must name the trusted installed Strix executable' "Strix gate requires an explicit trusted executable path" + assert_file_contains "$GATE_SCRIPT" 'STRIX_EXECUTABLE_PATH must be outside the untrusted scan target' "Strix executable cannot come from the scan target" + assert_file_not_contains "$GATE_SCRIPT" 'shutil.which("strix")' "Strix gate never resolves its credential-bearing executable through inherited PATH" assert_file_not_contains "$workflow_file" "https://sh.rustup.rs" "coverage refuses a mutable Rust network installer" assert_file_contains "$workflow_file" "cargo install cargo-llvm-cov --version 0.8.7 --locked" "coverage pins cargo-llvm-cov" assert_file_contains "$workflow_file" "Run merge scheduler after approval" "opencode approval runs the merge scheduler after current-head review publication" @@ -907,20 +918,20 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "package.metadata.opencode.coverage.minimum_lines" "opencode coverage evidence documents the Rust coverage baseline metadata key" assert_file_contains "$workflow_file" '--fail-under-lines "$threshold"' "opencode coverage evidence enforces the resolved Rust line coverage threshold" assert_file_contains "$workflow_file" "Python project dependencies (requirements.txt)" "opencode coverage evidence records repository Python dependency installation" - assert_file_contains "$workflow_file" "uv run --no-project --with-requirements requirements.txt" "opencode coverage evidence resolves binary-only repository Python requirements before pytest" + assert_file_contains "$workflow_file" "uv run --no-project --no-build --with-requirements requirements.txt" "opencode coverage evidence resolves wheel-only repository Python requirements before pytest" assert_file_contains "$workflow_file" "'requirements.txt' '*/requirements.txt'" "opencode coverage evidence discovers nested requirements-only Python test projects" assert_file_contains "$workflow_file" "Python project dependencies (\${project_dir}/requirements.txt)" "opencode coverage evidence installs nested requirements-only Python project dependencies" assert_file_contains "$workflow_file" "Python uv lockfile consistency (\${project_dir})" "opencode coverage evidence logs uv lockfile drift before installing uv-managed Python dependencies" assert_file_contains "$workflow_file" "uv lock --check" "opencode coverage evidence rejects stale uv lockfiles before pytest" assert_file_contains "$workflow_file" "uv sync --project" "opencode coverage evidence installs uv-managed Python project dependencies before pytest" - assert_file_contains "$workflow_file" 'cd "$1" && uv run --no-project --with-requirements requirements.txt' "opencode coverage evidence resolves requirements without executing a PR project backend" + assert_file_contains "$workflow_file" 'cd "$1" && uv run --no-project --no-build --with-requirements requirements.txt' "opencode coverage evidence resolves requirements without executing a PR project backend" assert_file_contains "$workflow_file" "--extra dev" "opencode coverage evidence installs pyproject optional dev extras when repositories do not use dependency-groups" assert_file_contains "$workflow_file" "configured_python_ci_test_commands()" "opencode coverage evidence prefers repository-configured CI pytest commands before falling back to the full tests tree" assert_file_contains "$workflow_file" 'safe_pytest_command.py" discover' "opencode coverage evidence discovers default CI workflow pytest commands through the trusted shell-free parser" assert_file_contains "$workflow_file" "Python configured CI test suite" "opencode coverage evidence labels repository-configured pytest evidence separately" - assert_file_contains "$workflow_file" 'cd "$1" && PYTHONPATH=. uv run pytest tests' "opencode coverage evidence runs uv-managed Python project tests inside their project environment" - assert_file_contains "$workflow_file" 'cd "$1" && PYTHONPATH=. uv run --with-requirements requirements.txt --with coverage --with pytest coverage run -m pytest tests' "opencode coverage evidence runs requirements-only Python project coverage inside its dependency environment" - assert_file_contains "$workflow_file" 'cd "$1" && PYTHONPATH=. uv run --with-requirements requirements.txt --with pytest python -m pytest tests/test_docstrings.py' "opencode coverage evidence runs requirements-only Python docstring tests inside its dependency environment" + assert_file_contains "$workflow_file" 'cd "$1" && PYTHONPATH=. uv run --no-build pytest tests' "opencode coverage evidence runs uv-managed Python project tests without source builds" + assert_file_contains "$workflow_file" 'cd "$1" && PYTHONPATH=. uv run --no-build --with-requirements requirements.txt --with coverage --with pytest coverage run -m pytest tests' "opencode coverage evidence runs requirements-only Python project coverage without source builds" + assert_file_contains "$workflow_file" 'cd "$1" && PYTHONPATH=. uv run --no-build --with-requirements requirements.txt --with pytest python -m pytest tests/test_docstrings.py' "opencode coverage evidence runs requirements-only Python docstring tests without source builds" assert_file_contains "$workflow_file" "JavaScript/TypeScript dependencies (npm ci, lifecycle hooks disabled)" "opencode coverage evidence installs npm workspace dependencies without lifecycle hooks before JS coverage" assert_file_contains "$workflow_file" "coverage/coverage-summary.json" "opencode coverage evidence reads JS coverage summaries instead of trusting test exit codes" assert_file_contains "$workflow_file" "coverage/coverage-final.json" "opencode coverage evidence supports Vitest Istanbul final coverage files" @@ -3074,15 +3085,23 @@ run_gate_case() { # Separate bin/ (fake strix + helper files) from workspace/ (target path) # so grep -r over the target path never matches the fake strix script itself. local bin_dir="$tmp_dir/bin" + local untrusted_bin_dir="$tmp_dir/untrusted-bin" local workspace_dir="$tmp_dir/workspace" local repo_root_dir="$workspace_dir/smart-crawling-server" - mkdir -p "$bin_dir" "$repo_root_dir/src" + mkdir -p "$bin_dir" "$untrusted_bin_dir" "$repo_root_dir/src" mkdir -p "$repo_root_dir/scripts/ci" local gate_under_test="$repo_root_dir/scripts/ci/strix_quick_gate.sh" cp "$GATE_SCRIPT" "$gate_under_test" cp "$REPO_ROOT/scripts/ci/strix_model_utils.sh" "$repo_root_dir/scripts/ci/strix_model_utils.sh" chmod +x "$gate_under_test" local fake_strix="$bin_dir/strix" + local path_hijack_log="$tmp_dir/path-hijack.log" + cat >"$untrusted_bin_dir/strix" <<'EOF' +#!/usr/bin/env bash +printf 'inherited PATH executable was invoked\n' >"${FAKE_STRIX_PATH_HIJACK_LOG:?}" +exit 99 +EOF + chmod +x "$untrusted_bin_dir/strix" local call_log="$tmp_dir/calls.log" local api_base_log="$tmp_dir/api_base.log" local target_log="$tmp_dir/target.log" @@ -5255,7 +5274,9 @@ PY set +e local env_cmd=( - PATH="$bin_dir:$PATH" + PATH="$untrusted_bin_dir:$bin_dir:$PATH" + STRIX_EXECUTABLE_PATH="$fake_strix" + FAKE_STRIX_PATH_HIJACK_LOG="$path_hijack_log" STRIX_INPUT_FILE_ROOT="$tmp_dir" GITHUB_EVENT_NAME="" GITHUB_EVENT_PATH="" @@ -5418,6 +5439,9 @@ PY call_count="$(wc -l <"$call_log" | tr -d ' ')" fi assert_equals "$expected_calls" "$call_count" "scenario=$scenario strix call count" + if [ -e "$path_hijack_log" ]; then + record_failure "scenario=$scenario selected a PATH-controlled Strix executable instead of STRIX_EXECUTABLE_PATH" + fi if [ -n "$expected_model_sequence" ]; then local actual_model_sequence="" @@ -5532,6 +5556,16 @@ run_filtered_gate_case_if_requested() { "") return 0 ;; + success) + run_gate_case "success" \ + "vertex_ai/ready-primary" \ + "vertex_ai/fallback-one vertex_ai/fallback-two" \ + "0" \ + "scan ok" \ + "1" \ + "vertex_ai/ready-primary" \ + "" + ;; github-models-token-limit-fallback-success) run_gate_case "github-models-token-limit-fallback-success" \ "openai/gpt-5" \ @@ -5988,6 +6022,7 @@ EOF cd "$repo_root_dir" env -u GITHUB_EVENT_PATH \ PATH="$bin_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$bin_dir/strix" \ STRIX_INPUT_FILE_ROOT="$tmp_dir" \ GITHUB_EVENT_NAME="pull_request_target" \ PR_BASE_SHA="$base_sha" \ @@ -6110,6 +6145,7 @@ EOS cd "$repo_root_dir" env -u GITHUB_EVENT_PATH \ PATH="$bin_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$bin_dir/strix" \ STRIX_INPUT_FILE_ROOT="$tmp_dir" \ GITHUB_EVENT_NAME="pull_request_target" \ PR_BASE_SHA="$base_sha" \ @@ -6217,6 +6253,7 @@ EOF cd "$repo_root_dir" env -u GITHUB_EVENT_PATH \ PATH="$bin_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$bin_dir/strix" \ STRIX_INPUT_FILE_ROOT="$tmp_dir" \ GITHUB_EVENT_NAME="pull_request_target" \ PR_BASE_SHA="$base_sha" \ @@ -6355,6 +6392,7 @@ EOF cd "$repo_root_dir" env -u GITHUB_EVENT_PATH \ PATH="$bin_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$bin_dir/strix" \ STRIX_INPUT_FILE_ROOT="$tmp_dir" \ GITHUB_EVENT_NAME="pull_request_target" \ PR_BASE_SHA="$base_sha" \ @@ -6392,6 +6430,7 @@ EOF cd "$repo_root_dir" env -u GITHUB_EVENT_PATH \ PATH="$bin_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$bin_dir/strix" \ STRIX_INPUT_FILE_ROOT="$tmp_dir" \ GITHUB_EVENT_NAME="pull_request" \ STRIX_TEST_CHANGED_FILES_OVERRIDE="$(printf '%s\n%s' '../outside.py' "$changed_file")" \ @@ -6599,6 +6638,7 @@ EOF cd "$repo_root_dir" env -u GITHUB_EVENT_PATH -u STRIX_TEST_CHANGED_FILES_OVERRIDE \ PATH="$bin_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$bin_dir/strix" \ STRIX_INPUT_FILE_ROOT="$tmp_dir" \ GITHUB_EVENT_NAME="pull_request_target" \ PR_BASE_SHA="$base_sha" \ @@ -6791,6 +6831,7 @@ EOF cd "$repo_root_dir" env -u GITHUB_EVENT_PATH \ PATH="$bin_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$bin_dir/strix" \ STRIX_INPUT_FILE_ROOT="$tmp_dir" \ GITHUB_EVENT_NAME="pull_request_target" \ PR_BASE_SHA="$base_sha" \ @@ -6888,6 +6929,7 @@ EOF cd "$repo_root_dir" env -u GITHUB_EVENT_PATH -u STRIX_TEST_CHANGED_FILES_OVERRIDE \ PATH="$bin_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$bin_dir/strix" \ STRIX_INPUT_FILE_ROOT="$tmp_dir" \ GITHUB_EVENT_NAME="pull_request_target" \ PR_BASE_SHA="$base_sha" \ @@ -7021,6 +7063,7 @@ EOF cd "$repo_root_dir" env -u GITHUB_EVENT_PATH -u STRIX_TEST_CHANGED_FILES_OVERRIDE \ PATH="$bin_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$bin_dir/strix" \ STRIX_INPUT_FILE_ROOT="$tmp_dir" \ REAL_GIT_PATH="$real_git" \ FAKE_GIT_FAIL_COMMAND="$fake_git_fail_command" \ @@ -7114,6 +7157,7 @@ EOF cd "$repo_root_dir" env -u GITHUB_EVENT_PATH -u STRIX_TEST_CHANGED_FILES_OVERRIDE \ PATH="$bin_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$bin_dir/strix" \ STRIX_INPUT_FILE_ROOT="$tmp_dir" \ GITHUB_EVENT_NAME="pull_request_target" \ PR_BASE_SHA="$base_sha" \ @@ -7200,6 +7244,7 @@ EOF cd "$repo_root_dir" env -u GITHUB_EVENT_PATH -u STRIX_TEST_CHANGED_FILES_OVERRIDE \ PATH="$bin_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$bin_dir/strix" \ STRIX_INPUT_FILE_ROOT="$tmp_dir" \ GITHUB_EVENT_NAME="pull_request_target" \ PR_BASE_SHA="$base_sha" \ @@ -7273,6 +7318,7 @@ EOF cd "$repo_root_dir" env -u GITHUB_EVENT_PATH -u STRIX_TEST_CHANGED_FILES_OVERRIDE \ PATH="$bin_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$bin_dir/strix" \ STRIX_INPUT_FILE_ROOT="$tmp_dir" \ GITHUB_EVENT_NAME="pull_request_target" \ PR_BASE_SHA="$base_sha" \ @@ -7344,6 +7390,7 @@ EOF cd "$repo_root_dir" env -u STRIX_TEST_PR_SCA_STATUS_OVERRIDE \ PATH="$bin_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$bin_dir/strix" \ STRIX_INPUT_FILE_ROOT="$tmp_dir" \ GITHUB_EVENT_NAME="pull_request_target" \ GITHUB_EVENT_PATH="$event_payload_file" \ @@ -7427,6 +7474,7 @@ EOF cd "$repo_root_dir" env -u GITHUB_EVENT_NAME -u GITHUB_EVENT_PATH -u STRIX_TEST_CHANGED_FILES_OVERRIDE -u STRIX_INPUT_FILE_ROOT \ PATH="$bin_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$bin_dir/strix" \ STRIX_INPUT_FILE_ROOT="$tmp_dir" \ STRIX_DISABLE_PR_SCOPING="0" \ FAKE_STRIX_CHILD_PID_FILE="$child_pid_file" \ @@ -7555,6 +7603,7 @@ EOF cd "$repo_root_dir" env -u GITHUB_EVENT_NAME -u GITHUB_EVENT_PATH -u STRIX_TEST_CHANGED_FILES_OVERRIDE -u STRIX_INPUT_FILE_ROOT \ PATH="$bin_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$bin_dir/strix" \ STRIX_INPUT_FILE_ROOT="$tmp_dir" \ STRIX_DISABLE_PR_SCOPING="0" \ FAKE_STRIX_CALL_COUNT_FILE="$call_count_file" \ @@ -8261,6 +8310,7 @@ EOF cd "$repo_root_dir" env -u GITHUB_EVENT_NAME -u GITHUB_EVENT_PATH -u STRIX_TEST_CHANGED_FILES_OVERRIDE \ PATH="$bin_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$bin_dir/strix" \ STRIX_INPUT_FILE_ROOT="$tmp_dir" \ FAKE_STRIX_CALL_LOG="$call_log" \ STRIX_LLM_FILE="$strix_llm_file" \ diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index d19ca24c..c41721d5 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1545,6 +1545,9 @@ def test_opencode_privileged_review_security_boundaries_are_fail_closed(): coverage_start = workflow.index(" coverage-evidence:\n") coverage_end = workflow.index("\n opencode-review-target:", coverage_start) coverage_job = workflow[coverage_start:coverage_end] + syntax_step = coverage_job.index(" - name: Enforce changed-file syntax gate\n") + measure_step = coverage_job.index(" - name: Measure test and docstring evidence\n") + measure = coverage_job[measure_step:] target_start = coverage_end + 1 target_job = workflow[target_start:] @@ -1564,7 +1567,16 @@ def test_opencode_privileged_review_security_boundaries_are_fail_closed(): assert "actions: read" in coverage_job assert "contents: read" not in coverage_job assert 'GITHUB_TOKEN: ""' in coverage_job - assert 'UV_NO_BUILD: "1"' in coverage_job + assert syntax_step < measure_step + assert "\n - name:" not in measure.split("\n run: |", 1)[1] + assert 'UV_NO_BUILD: "1"' in measure + assert measure.count("GITHUB_ENV=/dev/null") == 2 + assert measure.count("GITHUB_PATH=/dev/null") == 2 + assert measure.count("GITHUB_OUTPUT=/dev/null") == 2 + assert measure.count("GITHUB_STEP_SUMMARY=/dev/null") == 2 + assert measure.count("BASH_ENV=/dev/null") == 2 + assert "uv run --no-project --no-build --with-requirements" in measure + assert "uv run --no-build --with coverage" in measure assert ( 'uv sync --project "$project_dir" --group dev --no-build --no-install-project' in coverage_job