diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index 08ea600538..f4f90ea1ee 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -1009,10 +1009,14 @@ jobs: permissions: contents: write pull-requests: read + id-token: write env: - # Consumer required workflows need the existing central dispatch credential. - # The central handler can use its repository-scoped token as fallback. + # Reads the live PR in its own repository. A consumer required workflow's + # GITHUB_TOKEN cannot create .github repository_dispatch events (HTTP 403), + # so the POST below uses the org OpenCode app token exchanged from OIDC. GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || github.token }} + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai 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 || '' }} EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.client_payload.pr_head_sha || '' }} @@ -1055,6 +1059,25 @@ jobs: echo "::notice::Noema transport re-dispatch retired because the live PR head or base moved or closed." exit 0 fi + dispatch_token="${GH_TOKEN:-}" + if [ "$GITHUB_REPOSITORY" != "ContextualWisdomLab/.github" ]; then + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "::error::Noema transport re-dispatch requires GitHub OIDC for the central app token." + exit 1 + fi + separator='&' + [[ "$ACTIONS_ID_TOKEN_REQUEST_URL" == *\?* ]] || separator='?' + oidc_token="$(curl -fsS -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "${ACTIONS_ID_TOKEN_REQUEST_URL}${separator}audience=${OIDC_AUDIENCE}" | jq -r '.value // empty')" + dispatch_token="" + if [ -n "$oidc_token" ]; then + dispatch_token="$(curl -fsS -X POST -H "Authorization: Bearer ${oidc_token}" "${OPENCODE_API_BASE_URL}/exchange_github_app_token" | jq -r '.token // empty')" + fi + if [ -z "$dispatch_token" ]; then + echo "::error::Noema transport re-dispatch could not obtain the central app token; no cross-repository dispatch was attempted." + exit 1 + fi + echo "::add-mask::$dispatch_token" + fi jq -n \ --arg target_repository "$TARGET_REPOSITORY" \ --argjson pr_number "$PR_NUMBER" \ @@ -1068,5 +1091,5 @@ jobs: pr_head_sha: $pr_head_sha, transport_retry_attempt: $transport_retry_attempt } - }' | gh api -X POST "repos/ContextualWisdomLab/.github/dispatches" --input - + }' | GH_TOKEN="$dispatch_token" gh api -X POST "repos/ContextualWisdomLab/.github/dispatches" --input - echo "::notice::Scheduled Noema transport continuation re-dispatch for ${TARGET_REPOSITORY}#${PR_NUMBER} at ${EXPECTED_HEAD_SHA} (attempt ${NEXT_ATTEMPT})." diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index abdc8af49e..16e0a96f56 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -1525,10 +1525,14 @@ jobs: permissions: contents: write pull-requests: read + id-token: write env: - # Consumer required workflows need the existing central dispatch credential. - # The central handler can use its repository-scoped token as fallback. + # Reads the live PR in its own repository. A consumer required workflow's + # GITHUB_TOKEN cannot create .github repository_dispatch events (HTTP 403), + # so the POST below uses the org OpenCode app token exchanged from OIDC. GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || github.token }} + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai 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 || '' }} EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.client_payload.pr_head_sha || '' }} @@ -1576,6 +1580,25 @@ jobs: echo "::notice::Strix transport re-dispatch retired because the live PR head or base moved or closed." exit 0 fi + dispatch_token="${GH_TOKEN:-}" + if [ "$GITHUB_REPOSITORY" != "ContextualWisdomLab/.github" ]; then + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "::error::Strix transport re-dispatch requires GitHub OIDC for the central app token." + exit 1 + fi + separator='&' + [[ "$ACTIONS_ID_TOKEN_REQUEST_URL" == *\?* ]] || separator='?' + oidc_token="$(curl -fsS -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "${ACTIONS_ID_TOKEN_REQUEST_URL}${separator}audience=${OIDC_AUDIENCE}" | jq -r '.value // empty')" + dispatch_token="" + if [ -n "$oidc_token" ]; then + dispatch_token="$(curl -fsS -X POST -H "Authorization: Bearer ${oidc_token}" "${OPENCODE_API_BASE_URL}/exchange_github_app_token" | jq -r '.token // empty')" + fi + if [ -z "$dispatch_token" ]; then + echo "::error::Strix transport re-dispatch could not obtain the central app token; no cross-repository dispatch was attempted." + exit 1 + fi + echo "::add-mask::$dispatch_token" + fi jq -n \ --arg target_repository "$TARGET_REPOSITORY" \ --argjson pr_number "$PR_NUMBER" \ @@ -1593,5 +1616,5 @@ jobs: pr_base_sha: $pr_base_sha, transport_retry_attempt: $transport_retry_attempt } - }' | gh api -X POST "repos/ContextualWisdomLab/.github/dispatches" --input - + }' | GH_TOKEN="$dispatch_token" gh api -X POST "repos/ContextualWisdomLab/.github/dispatches" --input - echo "::notice::Scheduled Strix transport continuation re-dispatch for ${TARGET_REPOSITORY}#${PR_NUMBER} at ${EXPECTED_HEAD_SHA} (attempt ${NEXT_ATTEMPT})." diff --git a/CHANGELOG.d/20260930-review-continuation-app-token.md b/CHANGELOG.d/20260930-review-continuation-app-token.md new file mode 100644 index 0000000000..bbe1d09209 --- /dev/null +++ b/CHANGELOG.d/20260930-review-continuation-app-token.md @@ -0,0 +1,5 @@ +# Fixed + +- Route bounded Noema and Strix consumer-repository transport continuations to + the central `.github` dispatcher with the existing OIDC-exchanged organization + GitHub App token, failing closed before the POST when that authority is absent. diff --git a/docs/doctoring/noema-central-transport-continuation.md b/docs/doctoring/noema-central-transport-continuation.md index f53277998d..a25dc11a18 100644 --- a/docs/doctoring/noema-central-transport-continuation.md +++ b/docs/doctoring/noema-central-transport-continuation.md @@ -16,12 +16,15 @@ require matching head, base, base repository and head repository before sending. Fork, stale, closed and unrelated-origin continuations retire or fail closed. The central handler can dispatch with its repository-scoped GitHub token. -A consumer continuation requires the existing `PR_REVIEW_MERGE_TOKEN` to read the -product PR and create a central repository dispatch; absence or insufficient -permission fails explicitly. This change does not assert that every consumer has -that credential. Native trusted-main runner restrictions, independent review, -publication fencing and the existing post-failure retry bound remain unchanged. -No model inference deadline is added. +A consumer required workflow cannot receive the central repository's +`PR_REVIEW_MERGE_TOKEN`; its `github.token` is scoped to the consumer and gets +HTTP 403 when it posts to `ContextualWisdomLab/.github/dispatches`. The consumer +continuation therefore exchanges the job OIDC token for the existing +organization GitHub App installation token and uses that token only for the +central dispatch POST. Missing OIDC or an empty exchange response fails closed +before any cross-repository POST. Native trusted-main runner restrictions, +independent review, publication fencing and the existing post-failure retry +bound remain unchanged. No model inference deadline is added. ## Evidence @@ -32,3 +35,22 @@ rejects unrelated origin and fork or changed-base evidence, and proves a rejecte POST cannot report successful continuation. The unchanged reviewer contracts are run alongside this regression. Live provider recovery and approval still require successful current-head hosted execution. + +## 2026-09-30 production recurrence and owner repair + +`ContextualWisdomLab/contextual-orchestrator#1349` reproduced the unresolved +consumer-token path at exact head +`832291c11da301e919d9dc20fda99f0847142dd8`. Required Noema Review job +`109737701886` exhausted the `orchestrator/free` gateway with HTTP 429 after +1,318.6 seconds and correctly emitted a bounded continuation delay. The +continuation job `109778469161` then waited 93 seconds and failed its POST with +`Resource not accessible by integration (HTTP 403)`. This separates the +upstream capacity outcome from the central-control defect: capacity is external, +but losing the authorized same-head retry is owned here. + +Issue #2509 and PR #2510 are the canonical owner lane. The regression executes +both the Noema and Strix continuation shells in a consumer-repository context, +proves the dispatch uses the exchanged organization App token rather than the +consumer token, and proves an empty exchange produces no POST. The repair stays +Proposed until protected-main integration and a consumer hosted run demonstrate +an accepted central dispatch for the same live PR/head/base identity. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 6e5f1c549a..ef70b9450b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -3648,3 +3648,37 @@ verdict-shape acceptance, and qualifying independent approval. **Gap / failure scene.** The v2 handler names a run with `head/base/required-run/producer-source`, but its required-workflow fallback looked up only `head/base/required-run`. When authenticated status publication is unavailable, a completed clean handler job could not be found and a rerun ended false RED. Omitting the producer source would also allow a regenerated live merge revision to reuse predecessor evidence. **Action / evidence.** Correct the fallback lookup to include the live merge source and retain fail-closed base, head, required-run, workflow-path, job-name, GHAS-identity, and SARIF checks. The test-first repair reproduced two failures, then passed 96 focused workflow-contract tests; the new edge case rejects a stale merge-source title. Ruff E9/F/I on the changed dispatch-contract file and `git diff --check` pass. Fresh hosted Checks and a qualifying independent approval are still required on the unchanged executable delta before merge. + +## 2026-09-30 consumer review-continuation dispatch authority + +**Status:** Proposed on `ContextualWisdomLab/.github#2510`; issue #2509 is the +canonical owner record. Fresh exact-head hosted Checks, a real consumer +continuation receipt, and a qualifying independent approval remain mandatory. + +**Context Map / owner.** The central `.github` review-control bounded context +owns Noema and Strix transport continuation dispatch. Product repositories +consume the protected required-workflow contract and provide only their own +repository-scoped `github.token`; they do not copy the workflow or receive a +central repository secret. + +**Gap / RCA.** On `ContextualWisdomLab/contextual-orchestrator#1349` exact head +`832291c11da301e919d9dc20fda99f0847142dd8`, Noema job `109737701886` +correctly classified an `orchestrator/free` HTTP 429 provider-capacity outcome +and requested a bounded continuation. Job `109778469161` then waited the +gateway-provided 93 seconds but posted to the central repository with the +consumer `github.token`, which GitHub rejected with HTTP 403 +`Resource not accessible by integration`. The external capacity outcome is not +treated as a source defect; loss of the authorized same-head continuation is a +central control-plane defect. + +**Action / executable provenance.** Both continuation lanes exchange the job's +GitHub OIDC token for the existing organization App installation token and use +it only for the central `repository_dispatch` POST. Missing OIDC or an empty +exchange response fails closed before any POST. The real workflow-step shells +are executed in consumer context with fake network boundaries: both Noema and +Strix prove the exchanged token is used, and both prove an empty exchange +cannot dispatch. After a non-force merge of current `main`, 22 focused tests +pass both normally and with `GITHUB_ACTIONS=true`; the adjacent literal-contract +suite is 215 passed with warnings fatal. Local evidence is not hosted delivery +evidence: completion remains HOLD until protected-main integration and a real +consumer run accept the same live PR/head/base identity. diff --git a/tests/test_noema_orchestrator_workflow_contract.py b/tests/test_noema_orchestrator_workflow_contract.py index 8de7551814..431d4f6287 100644 --- a/tests/test_noema_orchestrator_workflow_contract.py +++ b/tests/test_noema_orchestrator_workflow_contract.py @@ -251,11 +251,22 @@ def test_noema_continuation_dispatch_uses_central_handler_and_live_identity(tmp_ fake_sleep = tmp_path / "sleep" fake_sleep.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8") fake_sleep.chmod(0o755) + fake_curl = tmp_path / "curl" + fake_curl.write_text( + '#!/bin/bash\nif [[ "$*" == *exchange_github_app_token* ]]; then printf \'{"token":"app"}\'; ' + 'else printf \'{"value":"oidc"}\'; fi\n', + encoding="utf-8", + ) + fake_curl.chmod(0o755) head = "a" * 40 base = "b" * 40 env = { **os.environ, "PATH": f"{tmp_path}{os.pathsep}{os.environ.get('PATH', '')}", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request", + "ACTIONS_ID_TOKEN_REQUEST_URL": "https://oidc.example/token", + "OIDC_AUDIENCE": "opencode-github-action", + "OPENCODE_API_BASE_URL": "https://api.opencode.example", "GITHUB_REPOSITORY": "ContextualWisdomLab/demo", "TARGET_REPOSITORY": "ContextualWisdomLab/demo", "PR_NUMBER": "7", diff --git a/tests/test_strix_preflight_continuation.py b/tests/test_strix_preflight_continuation.py index 6afc0977e9..f8e6d0cdba 100644 --- a/tests/test_strix_preflight_continuation.py +++ b/tests/test_strix_preflight_continuation.py @@ -4,6 +4,8 @@ import subprocess from pathlib import Path +import pytest + from scripts.ci import strix_runtime_capacity @@ -130,3 +132,43 @@ def test_runtime_capacity_module_covers_head_and_retry_budget(tmp_path, monkeypa monkeypatch.setenv('NOEMA_TRANSPORT_RETRY_ATTEMPT', '2') assert strix_runtime_capacity.main() == 0 assert 'transport_retry_eligible=false' in output.read_text() + + +CONTINUATIONS = [('strix.yml', 'Strix'), ('noema-review.yml', 'Noema')] + + +def _consumer_dispatch(tmp_path, workflow, lane, *, exchange_token): + """Run a re-dispatch shell as a consumer required workflow with fake network.""" + source = Path('.github/workflows', workflow).read_text() + block = source.split(f' - name: Schedule bounded {lane} transport re-dispatch\n', 1)[1] + shell = '\n'.join(line[10:] for line in block.split(' run: |\n', 1)[1].splitlines()) + bindir = tmp_path / 'bin' + bindir.mkdir() + (bindir / 'gh').write_text('#!/bin/bash\nif [[ "$*" == *"-X POST"* ]]; then printf %s "$GH_TOKEN" > "$POST_TOKEN"; cat > "$POSTED"; else cat "$LIVE"; fi\n') + (bindir / 'curl').write_text('#!/bin/bash\nif [[ "$*" == *exchange_github_app_token* ]]; then printf \'{"token":"%s"}\' "$EXCHANGE_TOKEN"; else printf \'{"value":"oidc-jwt"}\'; fi\n') + (bindir / 'sleep').write_text('#!/bin/bash\nexit 0\n') + for tool in bindir.iterdir(): + tool.chmod(0o700) + repo = 'ContextualWisdomLab/fast-mlsirm' + head, base = 'a' * 40, 'b' * 40 + (tmp_path / 'live.json').write_text(json.dumps({'state': 'open', 'draft': False, 'head': {'sha': head, 'repo': {'full_name': repo}}, 'base': {'sha': base, 'ref': 'main', 'repo': {'full_name': repo}}})) + env = dict(os.environ, PATH=str(bindir) + os.pathsep + os.environ['PATH'], GITHUB_REPOSITORY=repo, GH_TOKEN='target-repository-token', TARGET_REPOSITORY=repo, PR_NUMBER='2031', EXPECTED_HEAD_SHA=head, EXPECTED_BASE_SHA=base, EXPECTED_BASE_REF='main', DELAY_SECONDS='60', NEXT_ATTEMPT='1', ACTIONS_ID_TOKEN_REQUEST_TOKEN='request-token', ACTIONS_ID_TOKEN_REQUEST_URL='https://oidc.example/token', OIDC_AUDIENCE='opencode-github-action', OPENCODE_API_BASE_URL='https://api.opencode.example', EXCHANGE_TOKEN=exchange_token, LIVE=str(tmp_path / 'live.json'), POSTED=str(tmp_path / 'post.json'), POST_TOKEN=str(tmp_path / 'post-token')) + return subprocess.run(['bash', '-c', shell], env=env, capture_output=True, text=True) + + +@pytest.mark.parametrize(('workflow', 'lane'), CONTINUATIONS) +def test_consumer_redispatch_posts_with_org_app_token_not_target_token(tmp_path, workflow, lane): + """A target repository GITHUB_TOKEN cannot create .github repository_dispatch events.""" + result = _consumer_dispatch(tmp_path, workflow, lane, exchange_token='org-app-token') + assert result.returncode == 0, result.stderr + assert (tmp_path / 'post-token').read_text() == 'org-app-token' + assert json.loads((tmp_path / 'post.json').read_text())['client_payload']['pr_number'] == 2031 + + +@pytest.mark.parametrize(('workflow', 'lane'), CONTINUATIONS) +def test_consumer_redispatch_fails_closed_without_app_token(tmp_path, workflow, lane): + """No cross-repository dispatch is attempted with the target repository token.""" + result = _consumer_dispatch(tmp_path, workflow, lane, exchange_token='') + assert result.returncode != 0 + assert 'app token' in result.stdout + result.stderr + assert not (tmp_path / 'post.json').exists()