From 815509341c87e3c2ebaeb86b4829cb06b4b16384 Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Thu, 13 Aug 2026 11:00:50 -0500 Subject: [PATCH 1/3] fix(sync): unblock trusted delivery bootstrap --- .github/actions/path-classifier/classify.js | 50 ++++++++++++++-- .../scripts/__tests__/path-classifier.test.js | 60 +++++++++++++++++++ docs/keepalive/Agents.md | 2 +- docs/keepalive/GoalsAndPlumbing.md | 2 +- docs/ops/CONSUMER_REPO_MAINTENANCE.md | 12 +++- .../actions/path-classifier/classify.js | 50 ++++++++++++++-- .../workflows/agents-81-gate-followups.yml | 58 +++++++++++++++++- .../test_workflow_agents_consolidation.py | 21 +++++++ 8 files changed, 241 insertions(+), 14 deletions(-) diff --git a/.github/actions/path-classifier/classify.js b/.github/actions/path-classifier/classify.js index a89c0a8ca..77ef82cde 100644 --- a/.github/actions/path-classifier/classify.js +++ b/.github/actions/path-classifier/classify.js @@ -238,9 +238,32 @@ function readContractAtRef(ref, contractPath) { return runGit(['show', `${ref}:${contractPath}`]); } +function isAddOnlyContractDiff(diffText, contractPath) { + return String(diffText || '') + .split(/\r?\n/) + .some((line) => line === `A\t${contractPath}`); +} + +function contractAddedBetweenRefs(baseSha, headSha, contractPath) { + const added = runGit([ + 'diff', + '--name-status', + '--diff-filter=A', + baseSha, + headSha, + '--', + contractPath, + ]); + return isAddOnlyContractDiff(added, contractPath); +} + function loadDeliveryContract( githubContext = {}, - { readTrustedContract = readContractAtRef } = {}, + { + readTrustedContract = readContractAtRef, + readBootstrapContract = readContractAtRef, + isBootstrapAddition = contractAddedBetweenRefs, + } = {}, ) { const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); const relativeContractPath = '.github/scripts/sync_pr_lease_contract.js'; @@ -255,9 +278,27 @@ function loadDeliveryContract( const source = readTrustedContract(baseSha, relativeContractPath); return compileDeliveryContract(source, `${baseSha}:${relativeContractPath}`); } catch { - // Stable generated deliveries fail closed when the trusted base contract - // cannot be loaded; never fall back to the candidate checkout. - return null; + // A consumer's first stable-delivery rollout necessarily predates the + // lease contract on its base. Permit only that exact add-only bootstrap: + // same repository, exact observed head, and the contract path added (not + // modified or renamed) between base and head. Maint 71 remains the final + // boundary and independently requires the exact generated head to carry + // a valid GitHub-recognized signature before it can merge. + const headSha = pullRequest?.head?.sha || ''; + const headRepository = pullRequest?.head?.repo?.full_name || ''; + const baseRepository = pullRequest?.base?.repo?.full_name || ''; + if (!headSha || !headRepository || headRepository !== baseRepository) { + return null; + } + try { + if (!isBootstrapAddition(baseSha, headSha, relativeContractPath)) { + return null; + } + const source = readBootstrapContract(headSha, relativeContractPath); + return compileDeliveryContract(source, `${headSha}:${relativeContractPath}`); + } catch { + return null; + } } } @@ -452,6 +493,7 @@ module.exports = { OUTPUT_NAMES, classifyFiles, globToRegExp, + isAddOnlyContractDiff, isStableDeliveryPullRequest, listChangedFiles, loadConfig, diff --git a/.github/scripts/__tests__/path-classifier.test.js b/.github/scripts/__tests__/path-classifier.test.js index d94943a98..29bf46476 100644 --- a/.github/scripts/__tests__/path-classifier.test.js +++ b/.github/scripts/__tests__/path-classifier.test.js @@ -7,6 +7,7 @@ const { DEFAULT_CATEGORIES, classifyFiles, globToRegExp, + isAddOnlyContractDiff, isStableDeliveryPullRequest, listChangedFiles, loadDeliveryContract, @@ -190,10 +191,69 @@ test('stable delivery fails closed when the trusted base contract is unavailable readTrustedContract: () => { throw new Error('base object unavailable'); }, + isBootstrapAddition: () => false, }); assert.equal(contract, null); }); +test('stable delivery bootstraps an add-only contract when the trusted base predates it', () => { + const contractSource = require('node:fs').readFileSync( + require('node:path').join(__dirname, '..', 'sync_pr_lease_contract.js'), + 'utf8', + ); + const observed = {}; + const contract = loadDeliveryContract(deliveryContext(deliveryRecord), { + readTrustedContract: () => { + throw new Error('contract absent from base'); + }, + isBootstrapAddition: (baseSha, headSha, contractPath) => { + Object.assign(observed, { baseSha, headSha, contractPath }); + return true; + }, + readBootstrapContract: (ref, contractPath) => { + observed.bootstrapRef = ref; + observed.bootstrapPath = contractPath; + return contractSource; + }, + }); + + assert.deepEqual(observed, { + baseSha: 'trusted-base-sha', + headSha: 'head-abc', + contractPath: '.github/scripts/sync_pr_lease_contract.js', + bootstrapRef: 'head-abc', + bootstrapPath: '.github/scripts/sync_pr_lease_contract.js', + }); + assert.equal(contract.mergeEligibility(deliveryRecord, { requireSealed: true }).eligible, false); +}); + +test('stable delivery bootstrap recognizes only an exact added contract path', () => { + const contractPath = '.github/scripts/sync_pr_lease_contract.js'; + assert.equal(isAddOnlyContractDiff(`A\t${contractPath}`, contractPath), true); + assert.equal(isAddOnlyContractDiff(`M\t${contractPath}`, contractPath), false); + assert.equal(isAddOnlyContractDiff(`R100\told.js\t${contractPath}`, contractPath), false); + assert.equal(isAddOnlyContractDiff(`A\t${contractPath}.bak`, contractPath), false); +}); + +test('stable delivery bootstrap rejects fork heads even when they add the contract', () => { + let bootstrapRead = false; + const contract = loadDeliveryContract( + deliveryContext(deliveryRecord, { fork: true }), + { + readTrustedContract: () => { + throw new Error('contract absent from base'); + }, + isBootstrapAddition: () => true, + readBootstrapContract: () => { + bootstrapRead = true; + return ''; + }, + }, + ); + assert.equal(contract, null); + assert.equal(bootstrapRead, false); +}); + test('stable delivery reuses its trusted-base fetch for changed-file classification', () => { const context = deliveryContext(deliveryRecord); let fetches = 0; diff --git a/docs/keepalive/Agents.md b/docs/keepalive/Agents.md index cd8c17013..a6267399a 100644 --- a/docs/keepalive/Agents.md +++ b/docs/keepalive/Agents.md @@ -55,7 +55,7 @@ Auto-pilot pipeline: 1. **PR body is the contract**: Auto-pilot writes structured tasks into the PR body. Keepalive reads these tasks via the task appendix. If the PR body format changes, both must be updated together. -2. **Labels are handoff signals**: Auto-pilot applies the selected registry-backed `agent:` label (for example, `agent:codex` or `agent:claude`) and keepalive activates. Every non-transient run/fix failure records automation-owned recovery state and explicitly dispatches a bounded retry through the active workflow (`agents-keepalive-loop.yml` in the root lane or `agents-81-gate-followups.yml` in consolidated consumers). A failed direct dispatch defers that durable lease for the hourly sweep instead of adding a sticky `agent:retry` label; after 3 failures, the current strategy pauses for the same hourly recovery sweep. It does not infer that a human is required. Each hourly sweep wakeup bypasses state debounce so current state is re-evaluated, while ordinary wakeups retain completed-runner debounce. A possible authority boundary enters an independent scheduled challenge whose durable fingerprint is derived only from the routed agent's registry-backed required credentials, shared registry authority credentials, finite permission targets, and HTTP 401/403; arbitrary runner text is never copied or persisted. The root lane mints one of the dedicated app tokens before writing the summary, and the sweep reads durable state only from a marked summary comment owned by the `stranske-keepalive[bot]` or `agents-workflows-bot[bot]` app; user comments and generic workflow-bot comments cannot nominate or replace a challenge. Only the sweep passes a valid HMAC-signed claim using `KEEPALIVE_AUTHORITY_SIGNING_KEY` and binding that fingerprint to the repository, PR, random nonce, and exact sweep run/attempt; that signed due claim alone may bypass runner debounce and force-dispatch. A generic retry or another workflow sharing `github-actions[bot]` cannot confirm the challenge. Missing signing material fails closed to an ordinary non-forced recheck. A matching second failure may record the projected human action and apply `needs-human`. A different auth failure stays automation-owned and is challenged again on the next sweep. +2. **Labels are handoff signals**: Auto-pilot applies the selected registry-backed `agent:` label (for example, `agent:codex` or `agent:claude`) and keepalive activates. Every non-transient run/fix failure records automation-owned recovery state and explicitly dispatches a bounded retry through the active workflow (`agents-keepalive-loop.yml` in the root lane or `agents-81-gate-followups.yml` in consolidated consumers). A failed direct dispatch defers that durable lease for the hourly sweep instead of adding a sticky `agent:retry` label; after 3 failures, the current strategy pauses for the same hourly recovery sweep. It does not infer that a human is required. Each hourly sweep wakeup bypasses state debounce so current state is re-evaluated, while ordinary wakeups retain completed-runner debounce. A possible authority boundary enters an independent scheduled challenge whose durable fingerprint is derived only from the routed agent's registry-backed required credentials, shared registry authority credentials, finite permission targets, and HTTP 401/403; arbitrary runner text is never copied or persisted. Both the root and consolidated consumer lanes mint a dedicated keepalive or Workflows App token before writing the summary, and the sweep reads durable state only from a marked summary comment owned by the `stranske-keepalive[bot]` or `agents-workflows-bot[bot]` app; user comments and generic workflow-bot comments cannot nominate or replace a challenge. Only the sweep passes a valid HMAC-signed claim using `KEEPALIVE_AUTHORITY_SIGNING_KEY` and binding that fingerprint to the repository, PR, random nonce, and exact sweep run/attempt; that signed due claim alone may bypass runner debounce and force-dispatch. A generic retry or another workflow sharing `github-actions[bot]` cannot confirm the challenge. Missing signing material fails closed to an ordinary non-forced recheck. A matching second failure may record the projected human action and apply `needs-human`. A different auth failure stays automation-owned and is challenged again on the next sweep. 3. **Gate is the trigger**: Keepalive is event-driven via Gate `workflow_run` completion. Auto-pilot's `monitor-pr` step watches for keepalive progress. Neither polls — both react to events. diff --git a/docs/keepalive/GoalsAndPlumbing.md b/docs/keepalive/GoalsAndPlumbing.md index 7ea725b09..6eed206e6 100644 --- a/docs/keepalive/GoalsAndPlumbing.md +++ b/docs/keepalive/GoalsAndPlumbing.md @@ -85,7 +85,7 @@ If any requirement fails, keepalive stays silent—no PR comments. Operators may - Respect the `agents:paused` label, which blocks *all* keepalive activity. - Every non-transient run/fix failure records automation-owned retry state and explicitly dispatches a bounded retry while below the failure threshold. If direct dispatch fails, the durable lease is deferred for the hourly sweep to retry directly; automation does not add a sticky `agent:retry` label. - After repeated failures (default: 3), the loop pauses the current strategy and dispatches one non-recursive forced recovery lease. A complete-but-failing Gate uses that lease for one real fix attempt even when its ordinary Gate-fix budget is exhausted; after the forced run, the hourly keepalive sweep owns the next recovery review. -- A possible access or authority boundary adds `agent:needs-attention` with an immediately due independent challenge. Durable state extracts only a closed allowlist of facts: a credential named by the routed agent's registry-backed `required_secrets` or the registry's shared authority list, a finite permission target, and HTTP 401/403. Its fingerprint and human action are derived only from those facts; arbitrary runner text is never copied or persisted. Every hourly sweep wakeup bypasses state debounce so an unchanged zero-commit round is re-evaluated, while ordinary wakeups retain completed-runner debounce. The sweep selects that state only from a marked summary comment authored by the dedicated `stranske-keepalive[bot]` or `agents-workflows-bot[bot]` app; arbitrary users and generic workflow bots cannot inject a later marker. Only a due claim carrying that exact fingerprint in an HMAC signed with the dedicated `KEEPALIVE_AUTHORITY_SIGNING_KEY` and bound to the repository, PR, random nonce, and exact sweep run/attempt may bypass runner debounce and force-dispatch. Generic retries and other workflows sharing `github-actions[bot]` cannot confirm the challenge; missing or invalid signing material fails closed to an ordinary non-forced recheck. A green recheck clears the challenge. Only the signed sweep-selected projection failing again may record its allowlisted credential or permission remedy before replacing `agent:needs-attention` with `needs-human`; a different auth failure starts a new automation-owned challenge that the next sweep can evaluate. The reviewed-repo controller challenges confirmed holds again after 24 hours. +- A possible access or authority boundary adds `agent:needs-attention` with an immediately due independent challenge. Durable state extracts only a closed allowlist of facts: a credential named by the routed agent's registry-backed `required_secrets` or the registry's shared authority list, a finite permission target, and HTTP 401/403. Its fingerprint and human action are derived only from those facts; arbitrary runner text is never copied or persisted. Every hourly sweep wakeup bypasses state debounce so an unchanged zero-commit round is re-evaluated, while ordinary wakeups retain completed-runner debounce. Both the root and consolidated consumer lanes mint a dedicated keepalive or Workflows App token for their summary writer. The sweep selects durable state only from a marked summary comment authored by the corresponding `stranske-keepalive[bot]` or `agents-workflows-bot[bot]` app; arbitrary users and generic workflow bots cannot inject a later marker. Only a due claim carrying that exact fingerprint in an HMAC signed with the dedicated `KEEPALIVE_AUTHORITY_SIGNING_KEY` and bound to the repository, PR, random nonce, and exact sweep run/attempt may bypass runner debounce and force-dispatch. Generic retries and other workflows sharing `github-actions[bot]` cannot confirm the challenge; missing or invalid signing material fails closed to an ordinary non-forced recheck. A green recheck clears the challenge. Only the signed sweep-selected projection failing again may record its allowlisted credential or permission remedy before replacing `agent:needs-attention` with `needs-human`; a different auth failure starts a new automation-owned challenge that the next sweep can evaluate. The reviewed-repo controller challenges confirmed holds again after 24 hours. - Agent delegation treats two consecutive zero-progress rounds as stalled. Commit churn is not progress unless it advances checklist state or reaches a green Gate. **To resume after failure:** diff --git a/docs/ops/CONSUMER_REPO_MAINTENANCE.md b/docs/ops/CONSUMER_REPO_MAINTENANCE.md index f765b51a0..f01c2e591 100644 --- a/docs/ops/CONSUMER_REPO_MAINTENANCE.md +++ b/docs/ops/CONSUMER_REPO_MAINTENANCE.md @@ -351,10 +351,16 @@ Gate summary rejects an unsealed stable delivery, while the shared merge guard rejects `sync:delivery-staging` for every merger except Maint 71's verified sealed path. The staging hold remains until the merge succeeds. -The standard Gate's generated-delivery job also checks out +The standard Gate's generated-delivery job normally checks out `sync_pr_lease_contract.js` from the exact pull-request base SHA, not from the -candidate head. A contract change therefore cannot define its own acceptance -rule. Missing or unreadable trusted-base enforcement code is a hard failure. +candidate head, so a contract change cannot redefine an existing acceptance +rule. The one first-rollout exception is a same-repository stable delivery whose +base does not yet contain the contract and whose exact head adds that path. The +classifier may compile that add-only bootstrap copy; modifications, renames, +fork heads, missing head SHAs, and unreadable copies still fail closed. Maint 71 +remains the final boundary and independently requires the exact generated head +to carry a valid GitHub-recognized signature before merge. After the bootstrap +lands, every later delivery returns to trusted-base-only enforcement. Generated `sync/workflows-*` PRs are excluded from both the basic and agent autofix lanes. Their intentional pre-seal Gate failure is a delivery hold, not diff --git a/templates/consumer-repo/.github/actions/path-classifier/classify.js b/templates/consumer-repo/.github/actions/path-classifier/classify.js index a89c0a8ca..77ef82cde 100644 --- a/templates/consumer-repo/.github/actions/path-classifier/classify.js +++ b/templates/consumer-repo/.github/actions/path-classifier/classify.js @@ -238,9 +238,32 @@ function readContractAtRef(ref, contractPath) { return runGit(['show', `${ref}:${contractPath}`]); } +function isAddOnlyContractDiff(diffText, contractPath) { + return String(diffText || '') + .split(/\r?\n/) + .some((line) => line === `A\t${contractPath}`); +} + +function contractAddedBetweenRefs(baseSha, headSha, contractPath) { + const added = runGit([ + 'diff', + '--name-status', + '--diff-filter=A', + baseSha, + headSha, + '--', + contractPath, + ]); + return isAddOnlyContractDiff(added, contractPath); +} + function loadDeliveryContract( githubContext = {}, - { readTrustedContract = readContractAtRef } = {}, + { + readTrustedContract = readContractAtRef, + readBootstrapContract = readContractAtRef, + isBootstrapAddition = contractAddedBetweenRefs, + } = {}, ) { const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); const relativeContractPath = '.github/scripts/sync_pr_lease_contract.js'; @@ -255,9 +278,27 @@ function loadDeliveryContract( const source = readTrustedContract(baseSha, relativeContractPath); return compileDeliveryContract(source, `${baseSha}:${relativeContractPath}`); } catch { - // Stable generated deliveries fail closed when the trusted base contract - // cannot be loaded; never fall back to the candidate checkout. - return null; + // A consumer's first stable-delivery rollout necessarily predates the + // lease contract on its base. Permit only that exact add-only bootstrap: + // same repository, exact observed head, and the contract path added (not + // modified or renamed) between base and head. Maint 71 remains the final + // boundary and independently requires the exact generated head to carry + // a valid GitHub-recognized signature before it can merge. + const headSha = pullRequest?.head?.sha || ''; + const headRepository = pullRequest?.head?.repo?.full_name || ''; + const baseRepository = pullRequest?.base?.repo?.full_name || ''; + if (!headSha || !headRepository || headRepository !== baseRepository) { + return null; + } + try { + if (!isBootstrapAddition(baseSha, headSha, relativeContractPath)) { + return null; + } + const source = readBootstrapContract(headSha, relativeContractPath); + return compileDeliveryContract(source, `${headSha}:${relativeContractPath}`); + } catch { + return null; + } } } @@ -452,6 +493,7 @@ module.exports = { OUTPUT_NAMES, classifyFiles, globToRegExp, + isAddOnlyContractDiff, isStableDeliveryPullRequest, listChangedFiles, loadConfig, diff --git a/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml b/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml index 7d0ccf647..5936cd505 100644 --- a/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml +++ b/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml @@ -786,6 +786,58 @@ jobs: core.setOutput('llm_tasks_count', llmCompletedTasks.length); core.setOutput('commit_tasks_count', result.sources?.commit || 0); + - name: Mint KEEPALIVE_APP summary token + id: summary_keepalive_app_token + if: ${{ env.KEEPALIVE_APP_ID != '' && env.KEEPALIVE_APP_PRIVATE_KEY != '' }} + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + continue-on-error: true + env: + KEEPALIVE_APP_ID: ${{ secrets.KEEPALIVE_APP_ID || '' }} + KEEPALIVE_APP_PRIVATE_KEY: ${{ secrets.KEEPALIVE_APP_PRIVATE_KEY || '' }} + with: + app-id: ${{ env.KEEPALIVE_APP_ID }} + private-key: ${{ env.KEEPALIVE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-actions: write + permission-contents: read + permission-issues: write + permission-pull-requests: read + + - name: Mint WORKFLOWS_APP summary token + id: summary_workflows_app_token + if: | + steps.summary_keepalive_app_token.outputs.token == '' && + env.WORKFLOWS_APP_ID != '' && + env.WORKFLOWS_APP_PRIVATE_KEY != '' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + env: + WORKFLOWS_APP_ID: ${{ secrets.WORKFLOWS_APP_ID || '' }} + WORKFLOWS_APP_PRIVATE_KEY: ${{ secrets.WORKFLOWS_APP_PRIVATE_KEY || '' }} + with: + app-id: ${{ env.WORKFLOWS_APP_ID }} + private-key: ${{ env.WORKFLOWS_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-actions: write + permission-contents: read + permission-issues: write + permission-pull-requests: read + + - name: Require trusted keepalive summary writer + env: + KEEPALIVE_SUMMARY_TOKEN: >- + ${{ + steps.summary_keepalive_app_token.outputs.token || + steps.summary_workflows_app_token.outputs.token || + '' + }} + run: | + if [ -z "$KEEPALIVE_SUMMARY_TOKEN" ]; then + echo "::error::A dedicated keepalive or Workflows App token is required to persist trusted keepalive state." + exit 1 + fi + - name: Update summary comment id: update-summary uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 @@ -814,7 +866,11 @@ jobs: AUTHORITY_CHALLENGE_SIGNING_KEY: >- ${{ secrets.KEEPALIVE_AUTHORITY_SIGNING_KEY || '' }} with: - github-token: ${{ secrets.GITHUB_TOKEN }} + github-token: >- + ${{ + steps.summary_keepalive_app_token.outputs.token || + steps.summary_workflows_app_token.outputs.token + }} script: | const { updateKeepaliveLoopSummary } = require('./.github/scripts/keepalive_loop.js'); diff --git a/tests/workflows/test_workflow_agents_consolidation.py b/tests/workflows/test_workflow_agents_consolidation.py index 5839ceafe..5e8cd190b 100644 --- a/tests/workflows/test_workflow_agents_consolidation.py +++ b/tests/workflows/test_workflow_agents_consolidation.py @@ -709,6 +709,27 @@ def test_keepalive_recovery_uses_active_lane_and_forces_only_due_challenges(): assert "steps.summary_keepalive_app_token.outputs.token ||" in update_summary assert "steps.summary_workflows_app_token.outputs.token" in update_summary assert "github-token: ${{ secrets.GITHUB_TOKEN }}" not in update_summary + + consumer_summary_start = consumer_loop.index(" summary:") + consumer_summary_end = consumer_loop.index(" prepare:", consumer_summary_start) + consumer_summary = consumer_loop[consumer_summary_start:consumer_summary_end] + assert "id: summary_keepalive_app_token" in consumer_summary + assert "id: summary_workflows_app_token" in consumer_summary + assert consumer_summary.count("repositories: ${{ github.event.repository.name }}") == 2 + for permission in ( + "permission-actions: write", + "permission-contents: read", + "permission-issues: write", + "permission-pull-requests: read", + ): + assert consumer_summary.count(permission) == 2 + assert "Require trusted keepalive summary writer" in consumer_summary + consumer_update_summary = consumer_summary[ + consumer_summary.index("- name: Update summary comment") : + ] + assert "steps.summary_keepalive_app_token.outputs.token ||" in consumer_update_summary + assert "steps.summary_workflows_app_token.outputs.token" in consumer_update_summary + assert "github-token: ${{ secrets.GITHUB_TOKEN }}" not in consumer_update_summary for path in sweep_paths: text = path.read_text(encoding="utf-8") assert "force_retry: String(Boolean(dueChallenge))" in text From f32dc1029073d5e51bedbcab5250d7d82e38f9f2 Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Thu, 13 Aug 2026 11:12:34 -0500 Subject: [PATCH 2/3] fix(keepalive): migrate legacy summary writers --- .../scripts/__tests__/keepalive-loop.test.js | 45 +++++++++++++++++++ .../scripts/__tests__/path-classifier.test.js | 33 ++++++++++++++ .github/scripts/keepalive_loop.js | 25 ++++++++++- .github/scripts/keepalive_state.js | 2 + .github/workflows/agents-keepalive-loop.yml | 7 +++ docs/keepalive/Agents.md | 2 +- docs/keepalive/GoalsAndPlumbing.md | 2 +- .../.github/scripts/keepalive_loop.js | 25 ++++++++++- .../.github/scripts/keepalive_state.js | 2 + .../workflows/agents-81-gate-followups.yml | 7 +++ .../test_workflow_agents_consolidation.py | 7 +++ 11 files changed, 151 insertions(+), 6 deletions(-) diff --git a/.github/scripts/__tests__/keepalive-loop.test.js b/.github/scripts/__tests__/keepalive-loop.test.js index 8b0829659..66d5c795a 100644 --- a/.github/scripts/__tests__/keepalive-loop.test.js +++ b/.github/scripts/__tests__/keepalive-loop.test.js @@ -992,6 +992,51 @@ test('updateKeepaliveLoopSummary increments iteration and clears failures on suc assert.match(github.actions[0].body, /"failure":\{\}/); }); +test('updateKeepaliveLoopSummary migrates legacy state to the selected App writer', async () => { + const existingState = [ + '', + formatStateComment({ + trace: 'legacy-trace', + iteration: 1, + max_iterations: 5, + failure_threshold: 3, + }), + ].join('\n'); + const github = buildGithubStub({ + comments: [{ + id: 44, + body: existingState, + html_url: 'https://example.com/44', + user: { login: 'github-actions[bot]', type: 'Bot' }, + }], + }); + + await updateKeepaliveLoopSummary({ + github, + context: buildContext(123), + core: buildCore(), + inputs: { + prNumber: 123, + action: 'run', + runResult: 'success', + gateConclusion: 'success', + tasksTotal: 2, + tasksUnchecked: 1, + keepaliveEnabled: true, + iteration: 1, + maxIterations: 5, + failureThreshold: 3, + trace: 'legacy-trace', + trusted_summary_author: 'stranske-keepalive[bot]', + }, + }); + + assert.equal(github.actions[0].type, 'create'); + assert.match(github.actions[0].body, /keepalive-loop-summary/); + assert.match(github.actions[0].body, /"trace":"legacy-trace"/); + assert.equal(github.actions.some((action) => action.commentId === 44), false); +}); + test('updateKeepaliveLoopSummary ignores status-only checklist metrics for reconciliation', async () => { const pr = { number: 1234, diff --git a/.github/scripts/__tests__/path-classifier.test.js b/.github/scripts/__tests__/path-classifier.test.js index 29bf46476..de9da0963 100644 --- a/.github/scripts/__tests__/path-classifier.test.js +++ b/.github/scripts/__tests__/path-classifier.test.js @@ -227,6 +227,39 @@ test('stable delivery bootstraps an add-only contract when the trusted base pred assert.equal(contract.mergeEligibility(deliveryRecord, { requireSealed: true }).eligible, false); }); +test('stable delivery bootstrap fails closed without an exact head SHA', () => { + const context = deliveryContext(deliveryRecord); + context.event.pull_request.head.sha = ''; + let bootstrapRead = false; + const contract = loadDeliveryContract(context, { + readTrustedContract: () => { + throw new Error('contract absent from base'); + }, + isBootstrapAddition: () => true, + readBootstrapContract: () => { + bootstrapRead = true; + return ''; + }, + }); + + assert.equal(contract, null); + assert.equal(bootstrapRead, false); +}); + +test('stable delivery bootstrap fails closed when the exact head contract is unreadable', () => { + const contract = loadDeliveryContract(deliveryContext(deliveryRecord), { + readTrustedContract: () => { + throw new Error('contract absent from base'); + }, + isBootstrapAddition: () => true, + readBootstrapContract: () => { + throw new Error('head object unavailable'); + }, + }); + + assert.equal(contract, null); +}); + test('stable delivery bootstrap recognizes only an exact added contract path', () => { const contractPath = '.github/scripts/sync_pr_lease_contract.js'; assert.equal(isAddOnlyContractDiff(`A\t${contractPath}`, contractPath), true); diff --git a/.github/scripts/keepalive_loop.js b/.github/scripts/keepalive_loop.js index 6b0c2a030..e1e578cad 100644 --- a/.github/scripts/keepalive_loop.js +++ b/.github/scripts/keepalive_loop.js @@ -3201,12 +3201,33 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in const delegationShouldSwitch = toBool(inputs.delegation_should_switch ?? inputs.delegationShouldSwitch, false); const agentRoutingMode = normalise(inputs.agent_routing_mode ?? inputs.agentRoutingMode); - const { state: previousState, commentId } = await loadKeepaliveState({ + const { + state: previousState, + commentId, + commentAuthorLogin, + commentAuthorType, + } = await loadKeepaliveState({ github, context, prNumber, trace: stateTrace, }); + const trustedSummaryAuthor = normalise( + inputs.trusted_summary_author ?? inputs.trustedSummaryAuthor, + ).toLowerCase(); + const existingSummaryAuthor = normalise(commentAuthorLogin).toLowerCase(); + const existingSummaryAuthorType = normalise(commentAuthorType).toLowerCase(); + const migrateSummaryWriter = Boolean( + commentId && + trustedSummaryAuthor && + (existingSummaryAuthor !== trustedSummaryAuthor || existingSummaryAuthorType !== 'bot'), + ); + if (migrateSummaryWriter) { + core?.info?.( + `Creating a trusted App-owned keepalive summary; existing writer ` + + `${existingSummaryAuthor || 'unknown'} is not ${trustedSummaryAuthor}.`, + ); + } const hasTasksTotalInput = tasksTotalInput !== undefined && tasksTotalInput !== ''; const hasTasksUncheckedInput = tasksUncheckedInput !== undefined && tasksUncheckedInput !== ''; @@ -4394,7 +4415,7 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in // This prevents duplicate failure notifications on PRs try { - let summaryCommentId = commentId; + let summaryCommentId = migrateSummaryWriter ? 0 : commentId; const persistSummary = async (body) => { if (summaryCommentId) { await github.rest.issues.updateComment({ diff --git a/.github/scripts/keepalive_state.js b/.github/scripts/keepalive_state.js index decd77284..bb0d37a88 100644 --- a/.github/scripts/keepalive_state.js +++ b/.github/scripts/keepalive_state.js @@ -407,6 +407,8 @@ async function loadKeepaliveState({ github: rawGithub, context, prNumber, trace state: loadedState, commentId: existing.comment?.id ? Number(existing.comment.id) : 0, commentUrl: existing.comment?.html_url || '', + commentAuthorLogin: existing.comment?.user?.login || '', + commentAuthorType: existing.comment?.user?.type || '', }; } diff --git a/.github/workflows/agents-keepalive-loop.yml b/.github/workflows/agents-keepalive-loop.yml index 29776ed6b..ee0a7d402 100644 --- a/.github/workflows/agents-keepalive-loop.yml +++ b/.github/workflows/agents-keepalive-loop.yml @@ -1539,6 +1539,12 @@ jobs: github.event.inputs.authority_challenge_claim || '' }} AUTHORITY_CHALLENGE_SIGNING_KEY: >- ${{ secrets.KEEPALIVE_AUTHORITY_SIGNING_KEY || '' }} + KEEPALIVE_SUMMARY_WRITER: >- + ${{ + steps.summary_keepalive_app_token.outputs.token != '' && + 'stranske-keepalive[bot]' || + 'agents-workflows-bot[bot]' + }} with: github-token: >- ${{ @@ -1658,6 +1664,7 @@ jobs: process.env.AUTHORITY_CHALLENGE_CLAIM || '', authority_challenge_signing_key: process.env.AUTHORITY_CHALLENGE_SIGNING_KEY || '', + trusted_summary_author: process.env.KEEPALIVE_SUMMARY_WRITER || '', retry_workflow_id: 'agents-keepalive-loop.yml', }; await updateKeepaliveLoopSummary({ github, context, core, inputs }); diff --git a/docs/keepalive/Agents.md b/docs/keepalive/Agents.md index a6267399a..5a152fabc 100644 --- a/docs/keepalive/Agents.md +++ b/docs/keepalive/Agents.md @@ -55,7 +55,7 @@ Auto-pilot pipeline: 1. **PR body is the contract**: Auto-pilot writes structured tasks into the PR body. Keepalive reads these tasks via the task appendix. If the PR body format changes, both must be updated together. -2. **Labels are handoff signals**: Auto-pilot applies the selected registry-backed `agent:` label (for example, `agent:codex` or `agent:claude`) and keepalive activates. Every non-transient run/fix failure records automation-owned recovery state and explicitly dispatches a bounded retry through the active workflow (`agents-keepalive-loop.yml` in the root lane or `agents-81-gate-followups.yml` in consolidated consumers). A failed direct dispatch defers that durable lease for the hourly sweep instead of adding a sticky `agent:retry` label; after 3 failures, the current strategy pauses for the same hourly recovery sweep. It does not infer that a human is required. Each hourly sweep wakeup bypasses state debounce so current state is re-evaluated, while ordinary wakeups retain completed-runner debounce. A possible authority boundary enters an independent scheduled challenge whose durable fingerprint is derived only from the routed agent's registry-backed required credentials, shared registry authority credentials, finite permission targets, and HTTP 401/403; arbitrary runner text is never copied or persisted. Both the root and consolidated consumer lanes mint a dedicated keepalive or Workflows App token before writing the summary, and the sweep reads durable state only from a marked summary comment owned by the `stranske-keepalive[bot]` or `agents-workflows-bot[bot]` app; user comments and generic workflow-bot comments cannot nominate or replace a challenge. Only the sweep passes a valid HMAC-signed claim using `KEEPALIVE_AUTHORITY_SIGNING_KEY` and binding that fingerprint to the repository, PR, random nonce, and exact sweep run/attempt; that signed due claim alone may bypass runner debounce and force-dispatch. A generic retry or another workflow sharing `github-actions[bot]` cannot confirm the challenge. Missing signing material fails closed to an ordinary non-forced recheck. A matching second failure may record the projected human action and apply `needs-human`. A different auth failure stays automation-owned and is challenged again on the next sweep. +2. **Labels are handoff signals**: Auto-pilot applies the selected registry-backed `agent:` label (for example, `agent:codex` or `agent:claude`) and keepalive activates. Every non-transient run/fix failure records automation-owned recovery state and explicitly dispatches a bounded retry through the active workflow (`agents-keepalive-loop.yml` in the root lane or `agents-81-gate-followups.yml` in consolidated consumers). A failed direct dispatch defers that durable lease for the hourly sweep instead of adding a sticky `agent:retry` label; after 3 failures, the current strategy pauses for the same hourly recovery sweep. It does not infer that a human is required. Each hourly sweep wakeup bypasses state debounce so current state is re-evaluated, while ordinary wakeups retain completed-runner debounce. A possible authority boundary enters an independent scheduled challenge whose durable fingerprint is derived only from the routed agent's registry-backed required credentials, shared registry authority credentials, finite permission targets, and HTTP 401/403; arbitrary runner text is never copied or persisted. Both the root and consolidated consumer lanes mint a dedicated keepalive or Workflows App token before writing the summary, and the sweep reads durable state only from a marked summary comment owned by the `stranske-keepalive[bot]` or `agents-workflows-bot[bot]` app; user comments and generic workflow-bot comments cannot nominate or replace a challenge. When the selected App encounters a legacy summary owned by another writer, it preserves the parsed state in a new comment owned by that App because editing the legacy comment would retain its original untrusted author. Only the sweep passes a valid HMAC-signed claim using `KEEPALIVE_AUTHORITY_SIGNING_KEY` and binding that fingerprint to the repository, PR, random nonce, and exact sweep run/attempt; that signed due claim alone may bypass runner debounce and force-dispatch. A generic retry or another workflow sharing `github-actions[bot]` cannot confirm the challenge. Missing signing material fails closed to an ordinary non-forced recheck. A matching second failure may record the projected human action and apply `needs-human`. A different auth failure stays automation-owned and is challenged again on the next sweep. 3. **Gate is the trigger**: Keepalive is event-driven via Gate `workflow_run` completion. Auto-pilot's `monitor-pr` step watches for keepalive progress. Neither polls — both react to events. diff --git a/docs/keepalive/GoalsAndPlumbing.md b/docs/keepalive/GoalsAndPlumbing.md index 6eed206e6..11fa7b69c 100644 --- a/docs/keepalive/GoalsAndPlumbing.md +++ b/docs/keepalive/GoalsAndPlumbing.md @@ -85,7 +85,7 @@ If any requirement fails, keepalive stays silent—no PR comments. Operators may - Respect the `agents:paused` label, which blocks *all* keepalive activity. - Every non-transient run/fix failure records automation-owned retry state and explicitly dispatches a bounded retry while below the failure threshold. If direct dispatch fails, the durable lease is deferred for the hourly sweep to retry directly; automation does not add a sticky `agent:retry` label. - After repeated failures (default: 3), the loop pauses the current strategy and dispatches one non-recursive forced recovery lease. A complete-but-failing Gate uses that lease for one real fix attempt even when its ordinary Gate-fix budget is exhausted; after the forced run, the hourly keepalive sweep owns the next recovery review. -- A possible access or authority boundary adds `agent:needs-attention` with an immediately due independent challenge. Durable state extracts only a closed allowlist of facts: a credential named by the routed agent's registry-backed `required_secrets` or the registry's shared authority list, a finite permission target, and HTTP 401/403. Its fingerprint and human action are derived only from those facts; arbitrary runner text is never copied or persisted. Every hourly sweep wakeup bypasses state debounce so an unchanged zero-commit round is re-evaluated, while ordinary wakeups retain completed-runner debounce. Both the root and consolidated consumer lanes mint a dedicated keepalive or Workflows App token for their summary writer. The sweep selects durable state only from a marked summary comment authored by the corresponding `stranske-keepalive[bot]` or `agents-workflows-bot[bot]` app; arbitrary users and generic workflow bots cannot inject a later marker. Only a due claim carrying that exact fingerprint in an HMAC signed with the dedicated `KEEPALIVE_AUTHORITY_SIGNING_KEY` and bound to the repository, PR, random nonce, and exact sweep run/attempt may bypass runner debounce and force-dispatch. Generic retries and other workflows sharing `github-actions[bot]` cannot confirm the challenge; missing or invalid signing material fails closed to an ordinary non-forced recheck. A green recheck clears the challenge. Only the signed sweep-selected projection failing again may record its allowlisted credential or permission remedy before replacing `agent:needs-attention` with `needs-human`; a different auth failure starts a new automation-owned challenge that the next sweep can evaluate. The reviewed-repo controller challenges confirmed holds again after 24 hours. +- A possible access or authority boundary adds `agent:needs-attention` with an immediately due independent challenge. Durable state extracts only a closed allowlist of facts: a credential named by the routed agent's registry-backed `required_secrets` or the registry's shared authority list, a finite permission target, and HTTP 401/403. Its fingerprint and human action are derived only from those facts; arbitrary runner text is never copied or persisted. Every hourly sweep wakeup bypasses state debounce so an unchanged zero-commit round is re-evaluated, while ordinary wakeups retain completed-runner debounce. Both the root and consolidated consumer lanes mint a dedicated keepalive or Workflows App token for their summary writer. The sweep selects durable state only from a marked summary comment authored by the corresponding `stranske-keepalive[bot]` or `agents-workflows-bot[bot]` app; arbitrary users and generic workflow bots cannot inject a later marker. If a selected App finds a legacy summary owned by another writer, it migrates the parsed state to a newly created App-owned comment instead of editing the legacy marker in place, because GitHub preserves the original comment author. Only a due claim carrying that exact fingerprint in an HMAC signed with the dedicated `KEEPALIVE_AUTHORITY_SIGNING_KEY` and bound to the repository, PR, random nonce, and exact sweep run/attempt may bypass runner debounce and force-dispatch. Generic retries and other workflows sharing `github-actions[bot]` cannot confirm the challenge; missing or invalid signing material fails closed to an ordinary non-forced recheck. A green recheck clears the challenge. Only the signed sweep-selected projection failing again may record its allowlisted credential or permission remedy before replacing `agent:needs-attention` with `needs-human`; a different auth failure starts a new automation-owned challenge that the next sweep can evaluate. The reviewed-repo controller challenges confirmed holds again after 24 hours. - Agent delegation treats two consecutive zero-progress rounds as stalled. Commit churn is not progress unless it advances checklist state or reaches a green Gate. **To resume after failure:** diff --git a/templates/consumer-repo/.github/scripts/keepalive_loop.js b/templates/consumer-repo/.github/scripts/keepalive_loop.js index 6b0c2a030..e1e578cad 100644 --- a/templates/consumer-repo/.github/scripts/keepalive_loop.js +++ b/templates/consumer-repo/.github/scripts/keepalive_loop.js @@ -3201,12 +3201,33 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in const delegationShouldSwitch = toBool(inputs.delegation_should_switch ?? inputs.delegationShouldSwitch, false); const agentRoutingMode = normalise(inputs.agent_routing_mode ?? inputs.agentRoutingMode); - const { state: previousState, commentId } = await loadKeepaliveState({ + const { + state: previousState, + commentId, + commentAuthorLogin, + commentAuthorType, + } = await loadKeepaliveState({ github, context, prNumber, trace: stateTrace, }); + const trustedSummaryAuthor = normalise( + inputs.trusted_summary_author ?? inputs.trustedSummaryAuthor, + ).toLowerCase(); + const existingSummaryAuthor = normalise(commentAuthorLogin).toLowerCase(); + const existingSummaryAuthorType = normalise(commentAuthorType).toLowerCase(); + const migrateSummaryWriter = Boolean( + commentId && + trustedSummaryAuthor && + (existingSummaryAuthor !== trustedSummaryAuthor || existingSummaryAuthorType !== 'bot'), + ); + if (migrateSummaryWriter) { + core?.info?.( + `Creating a trusted App-owned keepalive summary; existing writer ` + + `${existingSummaryAuthor || 'unknown'} is not ${trustedSummaryAuthor}.`, + ); + } const hasTasksTotalInput = tasksTotalInput !== undefined && tasksTotalInput !== ''; const hasTasksUncheckedInput = tasksUncheckedInput !== undefined && tasksUncheckedInput !== ''; @@ -4394,7 +4415,7 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in // This prevents duplicate failure notifications on PRs try { - let summaryCommentId = commentId; + let summaryCommentId = migrateSummaryWriter ? 0 : commentId; const persistSummary = async (body) => { if (summaryCommentId) { await github.rest.issues.updateComment({ diff --git a/templates/consumer-repo/.github/scripts/keepalive_state.js b/templates/consumer-repo/.github/scripts/keepalive_state.js index decd77284..bb0d37a88 100644 --- a/templates/consumer-repo/.github/scripts/keepalive_state.js +++ b/templates/consumer-repo/.github/scripts/keepalive_state.js @@ -407,6 +407,8 @@ async function loadKeepaliveState({ github: rawGithub, context, prNumber, trace state: loadedState, commentId: existing.comment?.id ? Number(existing.comment.id) : 0, commentUrl: existing.comment?.html_url || '', + commentAuthorLogin: existing.comment?.user?.login || '', + commentAuthorType: existing.comment?.user?.type || '', }; } diff --git a/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml b/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml index 5936cd505..c09a3426f 100644 --- a/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml +++ b/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml @@ -865,6 +865,12 @@ jobs: github.event.inputs.authority_challenge_claim || '' }} AUTHORITY_CHALLENGE_SIGNING_KEY: >- ${{ secrets.KEEPALIVE_AUTHORITY_SIGNING_KEY || '' }} + KEEPALIVE_SUMMARY_WRITER: >- + ${{ + steps.summary_keepalive_app_token.outputs.token != '' && + 'stranske-keepalive[bot]' || + 'agents-workflows-bot[bot]' + }} with: github-token: >- ${{ @@ -953,6 +959,7 @@ jobs: process.env.AUTHORITY_CHALLENGE_CLAIM || '', authority_challenge_signing_key: process.env.AUTHORITY_CHALLENGE_SIGNING_KEY || '', + trusted_summary_author: process.env.KEEPALIVE_SUMMARY_WRITER || '', retry_workflow_id: 'agents-81-gate-followups.yml', }; await updateKeepaliveLoopSummary({ github, context, core, inputs }); diff --git a/tests/workflows/test_workflow_agents_consolidation.py b/tests/workflows/test_workflow_agents_consolidation.py index 5e8cd190b..b95df90c2 100644 --- a/tests/workflows/test_workflow_agents_consolidation.py +++ b/tests/workflows/test_workflow_agents_consolidation.py @@ -709,6 +709,8 @@ def test_keepalive_recovery_uses_active_lane_and_forces_only_due_challenges(): assert "steps.summary_keepalive_app_token.outputs.token ||" in update_summary assert "steps.summary_workflows_app_token.outputs.token" in update_summary assert "github-token: ${{ secrets.GITHUB_TOKEN }}" not in update_summary + assert "github-token: ${{ github.token }}" not in update_summary + assert "trusted_summary_author: process.env.KEEPALIVE_SUMMARY_WRITER" in update_summary consumer_summary_start = consumer_loop.index(" summary:") consumer_summary_end = consumer_loop.index(" prepare:", consumer_summary_start) @@ -730,6 +732,11 @@ def test_keepalive_recovery_uses_active_lane_and_forces_only_due_challenges(): assert "steps.summary_keepalive_app_token.outputs.token ||" in consumer_update_summary assert "steps.summary_workflows_app_token.outputs.token" in consumer_update_summary assert "github-token: ${{ secrets.GITHUB_TOKEN }}" not in consumer_update_summary + assert "github-token: ${{ github.token }}" not in consumer_update_summary + assert ( + "trusted_summary_author: process.env.KEEPALIVE_SUMMARY_WRITER" + in consumer_update_summary + ) for path in sweep_paths: text = path.read_text(encoding="utf-8") assert "force_retry: String(Boolean(dueChallenge))" in text From 56ed8184930767464c24f91661600880739cb58f Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Thu, 13 Aug 2026 11:14:28 -0500 Subject: [PATCH 3/3] style: format keepalive workflow assertion --- tests/workflows/test_workflow_agents_consolidation.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/workflows/test_workflow_agents_consolidation.py b/tests/workflows/test_workflow_agents_consolidation.py index b95df90c2..0fd95b218 100644 --- a/tests/workflows/test_workflow_agents_consolidation.py +++ b/tests/workflows/test_workflow_agents_consolidation.py @@ -733,10 +733,7 @@ def test_keepalive_recovery_uses_active_lane_and_forces_only_due_challenges(): assert "steps.summary_workflows_app_token.outputs.token" in consumer_update_summary assert "github-token: ${{ secrets.GITHUB_TOKEN }}" not in consumer_update_summary assert "github-token: ${{ github.token }}" not in consumer_update_summary - assert ( - "trusted_summary_author: process.env.KEEPALIVE_SUMMARY_WRITER" - in consumer_update_summary - ) + assert "trusted_summary_author: process.env.KEEPALIVE_SUMMARY_WRITER" in consumer_update_summary for path in sweep_paths: text = path.read_text(encoding="utf-8") assert "force_retry: String(Boolean(dueChallenge))" in text