From ebcc88dac6b17844b96d6b9355e5767651616582 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sun, 23 Aug 2026 13:08:43 +0800 Subject: [PATCH 01/19] feat(autofix): keep the round status comment live during long rounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review-address round can run for hours while the PR status comment stays frozen at "working", so on the PR page a healthy long round and a dead one look identical. Start a detached heartbeat loop with the announcement that re-PATCHes the same comment every ~10 min with elapsed time and last agent activity, and deep-link "Watch live progress" to the matrix leg's own live log. The loop lifetime is bounded to the sandboxed agent phase: the verification gate kills it before the first step that runs branch code on the host, and every kill target travels through expression context — WORKDIR is sandbox-writable, so no WORKDIR file is ever read as a kill target. Full rationale in qwen-autofix.md#af-148/af-149 and docs/design/autofix-round-heartbeat.md. --- .github/scripts/autofix-status-heartbeat.sh | 137 ++++++ .../scripts/autofix-status-heartbeat.test.mjs | 410 ++++++++++++++++++ .github/workflows/.size-baseline | 2 +- .github/workflows/ci.yml | 2 +- .github/workflows/qwen-autofix.md | 110 +++++ .github/workflows/qwen-autofix.yml | 103 ++++- docs/design/autofix-round-heartbeat.md | 217 +++++++++ scripts/tests/qwen-autofix-workflow.test.js | 220 +++++++++- 8 files changed, 1183 insertions(+), 18 deletions(-) create mode 100644 .github/scripts/autofix-status-heartbeat.sh create mode 100644 .github/scripts/autofix-status-heartbeat.test.mjs create mode 100644 docs/design/autofix-round-heartbeat.md diff --git a/.github/scripts/autofix-status-heartbeat.sh b/.github/scripts/autofix-status-heartbeat.sh new file mode 100644 index 00000000000..aee59902353 --- /dev/null +++ b/.github/scripts/autofix-status-heartbeat.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Live-progress heartbeat for the autofix round status comment. +# +# A review-address round can run for hours (130-minute agent step, 330- +# minute job) while the PR's status comment stays frozen at "working" — +# a healthy long round and a dead one look identical on the PR page. +# 'Post autofix status comment' starts this script as a detached loop; +# every interval it re-PATCHes the SAME status comment with elapsed time +# and last agent activity, and 'Finalize autofix status comment' kills it +# before writing the terminal text. Full rationale → qwen-autofix.md#af-148. +# +# Subcommands: +# body — print the full bilingual working-state comment body to stdout. +# Used for the initial post AND by every loop tick, so the two +# can never drift apart. +# loop — sleep–compose–PATCH until killed or a self-exit bound trips. +# +# Environment (both): HB_ROUND (display round, already +1'd by the step), +# HB_CAP, HB_URL, HB_WORKDIR, HB_START_EPOCH; NOW_EPOCH overrides the +# clock for tests. loop additionally needs: HB_REPO, HB_COMMENT_ID, and +# GITHUB_TOKEN for gh; HB_INTERVAL_SECONDS (default 600) and +# HB_MAX_AGE_SECONDS (default 43200) bound the pulse. +# +# Kill contract: the loop writes heartbeat.pid (diagnostics + its own +# self-exit check), checks heartbeat-stop, and exits on either signal or +# when its own age cap trips. The killers target the pid the launch +# recorded in EXPRESSION CONTEXT (steps.post_status.outputs.heartbeat_pid) +# — WORKDIR is sandbox-writable, so no WORKDIR file is ever read as a kill +# target. The round's verification gate kills the loop before running any +# branch code on the host; finalize and the always() cleanup kill again. +# +# PAT note: the loop holds the bot PAT in its environment. Its lifetime is +# bounded to the sandboxed agent phase — the agent executes PR content only +# inside the docker sandbox there, so no fork code runs on the host beside +# this loop; the verification gate ends the loop BEFORE the first step that +# runs branch code on the host. See af-148 for the trade. + +# -e is deliberately absent: the (( ... < 0 )) clamp guards exit non-zero +# on a false test and are load-bearing here. pipefail matches the sibling +# scripts' house line. +set -uo pipefail + +MARKER='' + +require() { + local name + for name in "$@"; do + if [[ -z "${!name:-}" ]]; then + echo "autofix-status-heartbeat: ${name} is required" >&2 + exit 2 + fi + done +} + +emit_body() { + require HB_ROUND HB_CAP HB_URL HB_WORKDIR HB_START_EPOCH + local now elapsed_min mtime active_min line_en line_zh + now="${NOW_EPOCH:-$(date +%s)}" + elapsed_min=$(( (now - HB_START_EPOCH) / 60 )) + (( elapsed_min < 0 )) && elapsed_min=0 + if [[ -f "${HB_WORKDIR}/agent.log" ]]; then + # date -r FILE reads the file's mtime on both GNU and BSD date. + mtime="$(date -r "${HB_WORKDIR}/agent.log" +%s 2>/dev/null || echo "${now}")" + active_min=$(( (now - mtime) / 60 )) + (( active_min < 0 )) && active_min=0 + line_en="⏱ Running for ${elapsed_min} min · agent active ${active_min} min ago" + line_zh="⏱ 已运行 ${elapsed_min} 分钟 · agent 最近活动在 ${active_min} 分钟前" + else + line_en="⏱ Running for ${elapsed_min} min · agent starting" + line_zh="⏱ 已运行 ${elapsed_min} 分钟 · agent 准备中" + fi + printf '%s\n\n🔄 **AutoFix is working on this PR** — round %s/%s. [Watch live progress](%s); this round posts its report here when it finishes.\n%s\n\n
\n中文说明\n\n🔄 **AutoFix 正在处理此 PR** —— 第 %s/%s 轮。[查看实时进度](%s);本轮结束后会在此发布报告。\n%s\n\n
' \ + "${MARKER}" "${HB_ROUND}" "${HB_CAP}" "${HB_URL}" "${line_en}" \ + "${HB_ROUND}" "${HB_CAP}" "${HB_URL}" "${line_zh}" +} + +run_loop() { + require HB_REPO HB_COMMENT_ID HB_WORKDIR + # Self-detach from the launching step: log to WORKDIR and never hold the + # step's pipes, or the step would never report completion. + exec >> "${HB_WORKDIR}/heartbeat.log" 2>&1 < /dev/null + echo "$$" > "${HB_WORKDIR}/heartbeat.pid" + local interval="${HB_INTERVAL_SECONDS:-600}" + local max_age="${HB_MAX_AGE_SECONDS:-43200}" + # Numeric guards: a malformed or zero override must degrade to the + # defaults, never into a sleep-less busy loop hammering the API. + [[ "${interval}" =~ ^[1-9][0-9]*$ ]] || interval=600 + [[ "${max_age}" =~ ^[1-9][0-9]*$ ]] || max_age=43200 + local start="${HB_START_EPOCH:-$(date +%s)}" + echo "$(date -u +%FT%TZ) heartbeat started: comment ${HB_COMMENT_ID} interval ${interval}s max_age ${max_age}s" + while :; do + sleep "${interval}" + local now age body + now="$(date +%s)" + age=$(( now - start )) + if (( age > max_age )); then + echo "$(date -u +%FT%TZ) self-exit: age ${age}s exceeds ${max_age}s" + exit 0 + fi + if [[ ! -f "${HB_WORKDIR}/heartbeat.pid" ]]; then + echo "$(date -u +%FT%TZ) self-exit: pid file removed" + exit 0 + fi + if [[ -f "${HB_WORKDIR}/heartbeat-stop" ]]; then + echo "$(date -u +%FT%TZ) self-exit: stop marker present" + exit 0 + fi + if ! body="$(emit_body)"; then + echo "$(date -u +%FT%TZ) body composition failed; skipping this tick" + continue + fi + # Best-effort: a transient API failure skips one tick, never the pulse. + # `timeout` bounds the request itself — a black-holed connection must + # not stall the loop past the age cap, which only runs between ticks + # (a stuck gh would hold the PAT forever). `timeout` is coreutils on + # the Linux pool; hosts without it (macOS dev runs) fall back to the + # unbounded call. + GH_PATCH=(gh) + if command -v timeout > /dev/null 2>&1; then + GH_PATCH=(timeout 60 gh) + fi + if ! "${GH_PATCH[@]}" api --method PATCH \ + "repos/${HB_REPO}/issues/comments/${HB_COMMENT_ID}" \ + -f body="${body}" > /dev/null 2>&1; then + echo "$(date -u +%FT%TZ) PATCH failed; continuing" + fi + done +} + +case "${1:-}" in + body) emit_body ;; + loop) run_loop ;; + *) + echo "usage: $(basename "$0") {body|loop}" >&2 + exit 2 + ;; +esac diff --git a/.github/scripts/autofix-status-heartbeat.test.mjs b/.github/scripts/autofix-status-heartbeat.test.mjs new file mode 100644 index 00000000000..88f35679856 --- /dev/null +++ b/.github/scripts/autofix-status-heartbeat.test.mjs @@ -0,0 +1,410 @@ +// Behavioral tests for the round-heartbeat script: the body text shape and +// the loop's pulse, self-exit bounds, and failure tolerance. The workflow +// wiring pins live in scripts/tests/qwen-autofix-workflow.test.js. +import assert from 'node:assert/strict'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; +import { afterEach, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const script = join(scriptsDir, 'autofix-status-heartbeat.sh'); + +const cleanups = []; +function freshTmp() { + const dir = mkdtempSync(join(tmpdir(), 'autofix-heartbeat-')); + cleanups.push(dir); + return dir; +} +afterEach(() => { + while (cleanups.length) { + rmSync(cleanups.pop(), { recursive: true, force: true }); + } +}); + +// A fake `gh` that records every invocation (NUL-separated argv, one file +// per call) and fails when GH_FAIL=1. +function fakeGhBin(dir) { + const bin = join(dir, 'bin'); + const records = join(dir, 'calls'); + mkdirSync(bin, { recursive: true }); + mkdirSync(records, { recursive: true }); + const gh = join(bin, 'gh'); + writeFileSync( + gh, + [ + '#!/usr/bin/env bash', + 'set -u', + 'n=$(( $(ls -1 "${GH_RECORD_DIR}" | wc -l) + 1 ))', + 'for a in "$@"; do printf \'%s\\0\' "$a"; done > "${GH_RECORD_DIR}/call-${n}"', + '[ "${GH_FAIL:-0}" = "1" ] && exit 1', + 'exit 0', + ].join('\n'), + ); + chmodSync(gh, 0o755); + return { bin, records }; +} + +function readCalls(records) { + return readdirSync(records) + .filter((name) => name.startsWith('call-')) + .sort() + .map((name) => + readFileSync(join(records, name), 'utf8').split('\0').filter(Boolean), + ); +} + +function bodyEnv(overrides = {}) { + const workdir = overrides.HB_WORKDIR ?? freshTmp(); + return { + HB_ROUND: '3', + HB_CAP: '100', + HB_URL: 'https://example.test/actions/runs/1/job/2', + HB_WORKDIR: workdir, + HB_START_EPOCH: '1000000', + ...overrides, + }; +} + +function runBody(env) { + const res = spawnSync('bash', [script, 'body'], { + env: { ...process.env, ...env }, + encoding: 'utf8', + }); + assert.equal(res.status, 0, res.stderr); + return res.stdout; +} + +describe('autofix-status-heartbeat body', () => { + it('renders the bilingual working comment with the starting state', () => { + const body = runBody(bodyEnv({ NOW_EPOCH: '1000120' })); + assert.ok(body.startsWith('')); + assert.ok(body.includes('round 3/100')); + assert.ok( + body.includes( + '[Watch live progress](https://example.test/actions/runs/1/job/2)', + ), + ); + assert.ok(body.includes('⏱ Running for 2 min · agent starting')); + assert.ok(body.includes('中文说明')); + assert.ok(body.includes('第 3/100 轮')); + assert.ok(body.includes('⏱ 已运行 2 分钟 · agent 准备中')); + assert.ok( + body.includes('this round posts its report here when it finishes.'), + ); + }); + + it('reports agent activity from the agent.log mtime', () => { + const workdir = freshTmp(); + const log = join(workdir, 'agent.log'); + writeFileSync(log, ''); + // mtime 5 minutes (300s) before NOW_EPOCH=1000600 → active 5 min ago; + // elapsed is from HB_START_EPOCH=1000000 → 10 min. + utimesSync(log, 1000600 - 300, 1000600 - 300); + const body = runBody( + bodyEnv({ HB_WORKDIR: workdir, NOW_EPOCH: '1000600' }), + ); + assert.ok(body.includes('⏱ Running for 10 min · agent active 5 min ago')); + assert.ok(body.includes('⏱ 已运行 10 分钟 · agent 最近活动在 5 分钟前')); + }); + + it('clamps a future mtime to "active 0 min ago" instead of negative', () => { + const workdir = freshTmp(); + const log = join(workdir, 'agent.log'); + writeFileSync(log, ''); + utimesSync(log, 1000600, 1000600); + const body = runBody( + bodyEnv({ HB_WORKDIR: workdir, NOW_EPOCH: '1000300' }), + ); + assert.ok(body.includes('agent active 0 min ago')); + assert.ok(body.includes('Running for 5 min')); + assert.ok(body.includes('最近活动在 0 分钟前')); + }); + + it('clamps a clock skew before the start epoch to "Running for 0 min"', () => { + const body = runBody(bodyEnv({ NOW_EPOCH: '999000' })); + assert.ok(body.includes('Running for 0 min')); + assert.ok(body.includes('已运行 0 分钟')); + }); + + it('refuses to run without its required environment', () => { + const res = spawnSync('bash', [script, 'body'], { + env: { ...process.env, HB_ROUND: '3' }, + encoding: 'utf8', + }); + assert.equal(res.status, 2); + assert.match(res.stderr, /is required/); + }); + + it('rejects an unknown subcommand', () => { + const res = spawnSync('bash', [script, 'bogus'], { + env: process.env, + encoding: 'utf8', + }); + assert.equal(res.status, 2); + assert.match(res.stderr, /usage:/); + }); +}); + +describe('autofix-status-heartbeat loop', () => { + function loopEnv(dir, gh, overrides = {}) { + const workdir = join(dir, 'work'); + mkdirSync(workdir, { recursive: true }); + return { + env: { + ...process.env, + PATH: `${gh.bin}:${process.env.PATH}`, + GH_RECORD_DIR: gh.records, + HB_REPO: 'octo/repo', + HB_COMMENT_ID: '777', + HB_ROUND: '2', + HB_CAP: '100', + HB_URL: 'https://example.test/run', + HB_WORKDIR: workdir, + HB_START_EPOCH: String(Math.floor(Date.now() / 1000)), + HB_INTERVAL_SECONDS: '1', + ...overrides, + }, + workdir, + }; + } + + function startLoop(env) { + return spawn('bash', [script, 'loop'], { + env, + stdio: 'ignore', + detached: true, + }); + } + + async function waitFor(predicate, timeoutMs, stepMs = 100) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return true; + await new Promise((resolve) => setTimeout(resolve, stepMs)); + } + return predicate(); + } + + // Resolves with the exit code, or 'timeout' after the budget. ALWAYS + // clears its timer — a leftover setTimeout firing after the test ends + // shows up as uncaughtException-style asynchronous activity in node:test. + function awaitExit(child, timeoutMs) { + return new Promise((resolve) => { + const timer = setTimeout(() => { + killGroup(child); + resolve('timeout'); + }, timeoutMs); + child.on('exit', (code) => { + clearTimeout(timer); + resolve(code); + }); + }); + } + + function killGroup(child) { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + // the group is already gone — nothing left to kill + } + } + + it('PATCHes the same comment on every tick with growing elapsed time', async () => { + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env, workdir } = loopEnv(dir, gh); + const child = startLoop(env); + try { + const ok = await waitFor(() => readCalls(gh.records).length >= 2, 8000); + assert.ok(ok, 'expected at least two PATCH calls'); + const calls = readCalls(gh.records); + for (const argv of calls) { + assert.ok(argv.includes('--method')); + assert.ok(argv.includes('PATCH')); + assert.ok( + argv.includes('repos/octo/repo/issues/comments/777'), + `unexpected PATCH target: ${argv.join(' ')}`, + ); + const bodyArg = argv.find((a) => a.startsWith('body=')); + assert.ok(bodyArg, 'PATCH must carry -f body=...'); + assert.ok(bodyArg.includes('')); + } + const bodyOf = (argv) => argv.find((a) => a.startsWith('body=')); + const m = (s) => s.match(/Running for (\d+) min/)?.[1]; + assert.ok( + Number(m(bodyOf(calls.at(-1)))) >= Number(m(bodyOf(calls[0]))), + 'elapsed minutes must not go backwards between ticks', + ); + // The loop registered its OWN pid — the value the killers must + // target, so it must be the loop process itself. + const pid = readFileSync(join(workdir, 'heartbeat.pid'), 'utf8').trim(); + assert.equal(pid, String(child.pid)); + } finally { + killGroup(child); + } + }); + + it('sleeps between ticks instead of busy-looping', async () => { + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env } = loopEnv(dir, gh, { HB_INTERVAL_SECONDS: '1' }); + const child = startLoop(env); + try { + // With a 1s interval, ~2.5s of runtime yields 2-3 ticks; a sleep-less + // busy loop would produce orders of magnitude more. + await waitFor(() => readCalls(gh.records).length >= 2, 8000); + await new Promise((resolve) => setTimeout(resolve, 1500)); + const count = readCalls(gh.records).length; + assert.ok( + count <= 5, + `expected a bounded tick count with a 1s interval, got ${count}`, + ); + } finally { + killGroup(child); + } + }); + + it('self-exits when the pid file disappears', async () => { + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env, workdir } = loopEnv(dir, gh); + const child = startLoop(env); + try { + const started = await waitFor( + () => existsSync(join(workdir, 'heartbeat.pid')), + 8000, + ); + assert.ok(started, 'the loop must register its pid first'); + rmSync(join(workdir, 'heartbeat.pid')); + const code = await awaitExit(child, 8000); + assert.equal(code, 0, 'a missing pid file must end the loop cleanly'); + const logText = readFileSync(join(workdir, 'heartbeat.log'), 'utf8'); + assert.match(logText, /self-exit: pid file removed/); + } finally { + killGroup(child); + } + }); + + it('degrades malformed interval and age-cap overrides to defaults', async () => { + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env, workdir } = loopEnv(dir, gh, { + HB_INTERVAL_SECONDS: 'abc', + HB_MAX_AGE_SECONDS: '0', + }); + const child = startLoop(env); + try { + const ok = await waitFor( + () => existsSync(join(workdir, 'heartbeat.log')), + 8000, + ); + assert.ok(ok, 'the loop must start and log its parameters'); + const logText = readFileSync(join(workdir, 'heartbeat.log'), 'utf8'); + assert.match(logText, /interval 600s max_age 43200s/); + } finally { + killGroup(child); + } + }); + + it('skips a tick whose body composition fails and keeps looping', async () => { + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env, workdir } = loopEnv(dir, gh, { HB_URL: '' }); + const child = startLoop(env); + try { + const ok = await waitFor( + () => + existsSync(join(workdir, 'heartbeat.log')) && + /body composition failed/.test( + readFileSync(join(workdir, 'heartbeat.log'), 'utf8'), + ), + 8000, + ); + assert.ok(ok, 'a failed compose must be logged, not fatal'); + assert.equal( + readCalls(gh.records).length, + 0, + 'no PATCH may go out with a body that failed to compose', + ); + assert.ok(child.exitCode === null, 'the loop must keep running'); + } finally { + killGroup(child); + } + }); + + it('refuses to loop without its required environment', async () => { + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env } = loopEnv(dir, gh); + delete env.HB_COMMENT_ID; + const child = spawn('bash', [script, 'loop'], { + env, + stdio: ['ignore', 'ignore', 'pipe'], + detached: true, + }); + let stderr = ''; + child.stderr.on('data', (d) => { + stderr += d; + }); + const code = await awaitExit(child, 8000); + assert.equal(code, 2); + assert.match(stderr, /HB_COMMENT_ID is required/); + }); + + it('self-exits at the age cap', async () => { + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env, workdir } = loopEnv(dir, gh, { + HB_MAX_AGE_SECONDS: '1', + HB_START_EPOCH: String(Math.floor(Date.now() / 1000) - 5), + }); + const child = startLoop(env); + const code = await awaitExit(child, 8000); + assert.equal(code, 0, 'the age cap must end the loop cleanly'); + const logText = readFileSync(join(workdir, 'heartbeat.log'), 'utf8'); + assert.match(logText, /self-exit: age/); + }); + + it('stops on the heartbeat-stop marker', async () => { + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env, workdir } = loopEnv(dir, gh); + const child = startLoop(env); + try { + writeFileSync(join(workdir, 'heartbeat-stop'), ''); + const code = await awaitExit(child, 8000); + assert.equal(code, 0, 'the stop marker must end the loop cleanly'); + } finally { + killGroup(child); + } + }); + + it('keeps pulsing through a failing gh', async () => { + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env, workdir } = loopEnv(dir, gh, { GH_FAIL: '1' }); + const child = startLoop(env); + try { + const ok = await waitFor(() => readCalls(gh.records).length >= 2, 8000); + assert.ok(ok, 'a failing PATCH must not stop the loop'); + assert.ok(child.exitCode === null, 'loop must still be alive'); + const logText = readFileSync(join(workdir, 'heartbeat.log'), 'utf8'); + assert.match(logText, /PATCH failed; continuing/); + } finally { + killGroup(child); + } + }); +}); diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 849c5c3be44..4a72756496f 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -34,7 +34,7 @@ 6495 pr-self-report-label.yml 9646 qwen-autofix-fork-bridge.yml 5942 qwen-autofix-fork-signal.yml -397656 qwen-autofix.yml +404055 qwen-autofix.yml 7061 qwen-ci-flaky-rerun.yml 151937 qwen-code-pr-review.yml 79041 qwen-fleet-shepherd.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c3e0f329bf..f86e394e540 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ env: # BOTH the github_ci_only helper step and the full-profile Test step, so a # new helper test can't be added to one path and silently dropped from the # other. - HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs' + HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs' jobs: classify_pr: diff --git a/.github/workflows/qwen-autofix.md b/.github/workflows/qwen-autofix.md index 9846d3ee2bc..d2e2c496161 100644 --- a/.github/workflows/qwen-autofix.md +++ b/.github/workflows/qwen-autofix.md @@ -239,6 +239,8 @@ task-oriented guides — what a maintainer types and what happens next — see: - [145. review-address · Report dry-run / failure — CUMULATIVE timeout breaker — the sibling of the consecutive one above, for the…](#af-145) - [146. review-address · Report dry-run / failure — The agent committed (verify recorded committed=true before any gate could fail),…](#af-146) - [147. review-address · Report dry-run / failure — Same byte-budget hygiene as the English excerpt above. 3000 bytes ≈ 1000 CJK…](#af-147) +- [148. review-address · Post autofix status comment — Round heartbeat: the announcement freezes at "working" for the whole round…](#af-148) +- [149. review-address · Post autofix status comment — Deep-link "Watch live progress" to THIS matrix leg's live log, not just the run…](#af-149) --- @@ -3717,3 +3719,111 @@ forbids HTML in failure.zh.md), but must not be able to open or close a
/ that swallows the closing tag the workflow emits below. ``` + + + +### 148. review-address · Post autofix status comment — Round heartbeat: the announcement freezes at "working" for the whole round… + +In `review-address` · `Post autofix status comment`. + +```text +Round heartbeat: the announcement freezes at "working" for +the whole round — up to the 130-minute agent step plus gate +and repair — so on the PR page a healthy long round and a +dead one look identical (observed on #9739: ~1.5h of +silence). A detached loop started here re-PATCHes the SAME +comment every 10 min with elapsed time and last agent +activity (agent.log mtime — run-agent.mjs writes every +stream event there; no parsing, and the thinking phase's +10-minute stream-idle window shows as an honest "active N +min ago"). EDITING one comment, not posting: a managed PR +can run 100 rounds, and edits raise no issue_comment events +(no workflow fan-out) and no notifications. The body renders +through the heartbeat script's 'body' subcommand for the +initial post AND every tick, so the two texts cannot drift. + +LIFETIME is bounded to the sandboxed agent phase: the +verification gate kills the loop BEFORE it runs the +branch's own build/tests ON THE HOST. The first design +claimed "no fork code runs on the host beside this loop" +for the whole round and review proved that false — the gate +script says plainly that the branch's code runs there as +the runner user. A PAT-holding host process concurrent with +host-side branch code is a /proc//environ read away +from leaking the token (same UID; only the pool's ptrace +scope stands between, and it is not pinned anywhere), so +the pulse covers the agent step — the longest, sandboxed +phase — and dies before the gate. The comment holds its +last tick through gate/repair; finalize flips the terminal +text. + +KILL TARGETS travel through EXPRESSION CONTEXT: post_status +records $! as heartbeat_pid, and the gate / finalize / the +always() cleanup kill that value. Never a pid read from a +WORKDIR file: the agent's docker sandbox mounts the host +/tmp on the same path and runs as this same user, so branch +code the agent executes can plant any value in +heartbeat.pid — an arbitrary same-UID kill in the hand of +the next killer (this file class is known-hostile: the gate +refuses to re-read its verdict from WORKDIR for the same +reason). The on-disk pid file survives for diagnostics and +the loop's OWN existence self-check only — tampering there +can at worst end the pulse early or forge its "active" +figure, never reach a kill or the token. The killers are +INLINE bash in the yml: no PR-branch-controlled file is +ever executed in a PAT-bearing or post-agent context, and +the gate's own kill uses absolute-path/builtin command +words per that step's shadowing doctrine. + +PAT TRADE, chosen deliberately within that lifetime: the +loop holds the bot PAT in env — a temporal overlap the +"THIS step holds no PAT" rule (af-126) otherwise avoids. +Accepted because within the agent phase the token never +touches disk, the only host processes concurrent with the +loop are trusted (run-agent.mjs, the bundled CLI), and the +overlap ends deterministically at the gate. The alternative +— heartbeat from the schedule scan or a watcher job — lands +every ~40-70 min in this repo (af-027) and would re-derive +comment id, run identity and liveness remotely: too slow +and too much machinery for a pulse. + +ORPHAN DISCIPLINE on the persistent pool: the loop +self-exits on a missing pid file (a crashed prior round's +orphan ends at its next self-check once this round's reset +wipes the dir — no cross-run kill, which would need a file +pid and re-open the untrusted-target hole), a heartbeat-stop +marker, or a 12h age cap far past the 330-minute job +timeout; each tick's gh call is additionally wrapped in a +60s timeout so a black-holed connection cannot stall the +loop past the cap. Killers that run in-round touch the stop +marker BEFORE killing so a missed kill still ends the loop +at its next self-check — a tick landing after the terminal +text would overwrite it with a live-looking "working" line +— and finalize additionally sleeps past one PATCH +round-trip so an already-dispatched tick cannot land after +the terminal text server-side. +``` + + + +### 149. review-address · Post autofix status comment — Deep-link "Watch live progress" to THIS matrix leg's live log, not just the run… + +In `review-address` · `Post autofix status comment`. + +```text +Deep-link "Watch live progress" to THIS matrix leg's live +log, not just the run page: the run page lists every leg of +the scan and the reader must find which one is theirs. The +job id comes from the current run ATTEMPT's jobs listing +matched on the name prefix "review-address (," — the +matrix name format this job has always used — read through +jq with --arg so the PR number enters as data, never string +interpolation. BEST-EFFORT by construction: any lookup +failure (API error, unexpected shape) leaves the run URL, so +the link is never worse than before this existed. The +finalize text keeps the run URL on purpose: once the round +ends the run page is the right destination (all steps, all +attempts), and one less thing to re-resolve on the +crashed-agent paths where this step's outputs may be all +that survived. +``` diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 9073e3697fc..47c2cdd3581 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -3610,7 +3610,12 @@ jobs: (umask 077; mkdir -p "${WORKDIR}") # Age-sweep abandoned run-scoped dirs on this shared /tmp: a hard # runner kill skips the always() teardown and run_id never repeats, - # so nothing else ever reclaims them. + # so nothing else ever reclaims them. The rm above also removes a + # crashed prior round's heartbeat.pid — the heartbeat loop ends + # itself at the next self-check when that file disappears; no + # cross-run kill here, because a pid read from a WORKDIR file is + # an untrusted kill target (WORKDIR is sandbox-writable). + # Full rationale → qwen-autofix.md#af-148 find /tmp -maxdepth 1 -name 'autofix*' -mmin +1440 -exec rm -rf {} + 2>/dev/null || true # The reused workspace's .git accumulates unreferenced objects # across fetch runs on this persistent pool; prune them. @@ -3630,6 +3635,7 @@ jobs: cp .github/scripts/resolve-owning-packages.sh "${RUNNER_TEMP}/resolve-owning-packages.sh" cp .github/scripts/run-autofix-review-verification.sh "${RUNNER_TEMP}/run-autofix-review-verification.sh" cp .github/scripts/resanitize-git-config.sh "${RUNNER_TEMP}/resanitize-git-config.sh" + cp .github/scripts/autofix-status-heartbeat.sh "${RUNNER_TEMP}/autofix-status-heartbeat.sh" # The staged copies' trusted-base provenance holds at cp time only: # RUNNER_TEMP is writable by the branch/agent code later steps run # on this host, so record each digest in GITHUB_OUTPUT — expression @@ -3638,6 +3644,7 @@ jobs: # Full rationale → qwen-autofix.md#af-111 echo "resanitize_sha256=$(sha256sum "${RUNNER_TEMP}/resanitize-git-config.sh" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" echo "verify_runner_sha256=$(sha256sum "${RUNNER_TEMP}/run-autofix-review-verification.sh" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" + echo "heartbeat_sha256=$(sha256sum "${RUNNER_TEMP}/autofix-status-heartbeat.sh" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" # The upsert script travels as CONTENT, not as a staged copy: it # runs in a clean child that reads it from this expression-context # output, so there is no agent-writable copy to protect and no @@ -4714,6 +4721,8 @@ jobs: GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' EFFECTIVE_ROUND: '${{ steps.prepare.outputs.effective_round }}' RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + SERVER_URL: '${{ github.server_url }}' + HEARTBEAT_SHA256: '${{ steps.stage.outputs.heartbeat_sha256 }}' run: |- set -uo pipefail MARKER='' @@ -4724,9 +4733,29 @@ jobs: if [[ "${ROUND_DISPLAY}" =~ ^[0-9]+$ ]]; then ROUND_DISPLAY="$((ROUND_DISPLAY + 1))" fi - BODY="$(printf '%s\n\n🔄 **AutoFix is working on this PR** — round %s/%s. [Watch live progress](%s); this round posts its report here when it finishes.\n\n
\n中文说明\n\n🔄 **AutoFix 正在处理此 PR** —— 第 %s/%s 轮。[查看实时进度](%s);本轮结束后会在此发布报告。\n\n
' \ - "${MARKER}" "${ROUND_DISPLAY}" "${MAX_ROUNDS}" "${RUN_URL}" \ - "${ROUND_DISPLAY}" "${MAX_ROUNDS}" "${RUN_URL}")" + # Deep-link "Watch live progress" to THIS matrix leg's live log + # instead of the run page. Best-effort: any lookup failure keeps + # the run URL, so the link is never worse than before. + # Full rationale → qwen-autofix.md#af-149 + JOB_URL="${RUN_URL}" + JOB_ID="$(gh api "repos/${REPO}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}/jobs" --paginate | + jq -rs --arg pr "${PR}" \ + '[ .[] | .jobs[]? | select((.name // "") | startswith("review-address (\($pr),")) | .id ] | last // empty')" || + JOB_ID='' + if [[ "${JOB_ID}" =~ ^[0-9]+$ ]]; then + JOB_URL="${SERVER_URL}/${REPO}/actions/runs/${GITHUB_RUN_ID}/job/${JOB_ID}" + fi + # The working-comment text lives in the heartbeat script: the + # initial post and every later tick render through the SAME 'body' + # subcommand, so they cannot drift. The working tree is PR-branch + # code by this point — run the staged copy, digest-verified from + # expression context (same doctrine as resanitize-git-config.sh). + # Full rationale → qwen-autofix.md#af-148 + /usr/bin/echo "${HEARTBEAT_SHA256} ${RUNNER_TEMP}/autofix-status-heartbeat.sh" | /usr/bin/sha256sum -c - > /dev/null + START_EPOCH="$(date +%s)" + BODY="$(HB_ROUND="${ROUND_DISPLAY}" HB_CAP="${MAX_ROUNDS}" HB_URL="${JOB_URL}" \ + HB_WORKDIR="${WORKDIR}" HB_START_EPOCH="${START_EPOCH}" \ + bash --norc "${RUNNER_TEMP}/autofix-status-heartbeat.sh" body)" STATUS_ID="$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate | jq -rs --arg m "${MARKER}" --arg ab "${AUTOFIX_BOT}" \ '[ .[][] | select((.user.login // "") == $ab) @@ -4744,8 +4773,27 @@ jobs: echo "::warning::Failed to post the autofix status comment on PR #${PR}; continuing." } fi + # Round heartbeat: while the agent works, a detached loop + # re-PATCHes this comment every ~10 min with elapsed time and + # last agent activity, so the PR can tell a long round from a + # dead one. It holds the PAT in env — a deliberate trade bounded + # to the sandboxed agent phase: the verification gate kills the + # loop BEFORE the first step that runs branch code on the host. + # The pid travels to the killers through EXPRESSION CONTEXT — + # WORKDIR is sandbox-writable, so no WORKDIR file is ever read + # as a kill target. Full rationale → qwen-autofix.md#af-148 + HEARTBEAT_PID='' + if [[ -n "${STATUS_ID}" ]]; then + HB_REPO="${REPO}" HB_COMMENT_ID="${STATUS_ID}" \ + HB_ROUND="${ROUND_DISPLAY}" HB_CAP="${MAX_ROUNDS}" HB_URL="${JOB_URL}" \ + HB_WORKDIR="${WORKDIR}" HB_START_EPOCH="${START_EPOCH}" \ + setsid bash --norc "${RUNNER_TEMP}/autofix-status-heartbeat.sh" loop & + HEARTBEAT_PID=$! + disown 2> /dev/null || true + fi # Hand the id to the finalize step so it does not repeat this scan. echo "comment_id=${STATUS_ID}" >> "${GITHUB_OUTPUT}" + echo "heartbeat_pid=${HEARTBEAT_PID}" >> "${GITHUB_OUTPUT}" - name: 'Triage and address' id: 'address' @@ -4902,6 +4950,21 @@ jobs: # the gate enforces presence + shape before any push decision. KISS_AUDIT: '${{ steps.prepare.outputs.kiss_audit }}' run: |- + # The round heartbeat ends HERE: this is the first step that runs + # branch code ON THE HOST (the agent phase sandboxes it in + # docker), and the loop holds the bot PAT in its env — the + # overlap is bounded to the sandboxed agent phase, never the + # host-side gate. Kill from the pid the launch recorded in + # expression context; WORKDIR files are sandbox-writable and are + # never read as kill targets. Absolute-path/builtin-only command + # words, same doctrine as the gate body below. + # Full rationale → qwen-autofix.md#af-148 + /usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true + HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}" + if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then + builtin kill -- -"${HB_PID}" 2> /dev/null || true + builtin kill "${HB_PID}" 2> /dev/null || true + fi # The gate decides whether the PAT push runs, and the first pass # executes the branch's own build/test on the host before the # second — so verify the staged runner's digest (recorded in @@ -5297,7 +5360,7 @@ jobs: if git rev-parse --verify "${BRANCH}" > /dev/null 2>&1; then git diff "origin/main...${BRANCH}" > "${WORKDIR}/pr.diff" || true fi - for f in feedback.md address-summary.md no-action.md failure.md failure.zh.md handoff.md gate-rejection.md gate-advisories.md growth-audit.json agent-api-error agent-api-error-kind agent-timeout resolved-comments.txt comment-replies.json deferred-findings.json deferred-findings.carry.json deferred-findings.unmerged.json pr.diff; do + for f in feedback.md address-summary.md no-action.md failure.md failure.zh.md handoff.md gate-rejection.md gate-advisories.md growth-audit.json agent-api-error agent-api-error-kind agent-timeout resolved-comments.txt comment-replies.json deferred-findings.json deferred-findings.carry.json deferred-findings.unmerged.json pr.diff heartbeat.log; do if [[ -f "${WORKDIR}/${f}" ]]; then echo "=============== ${f} ===============" # Agent-written content: a line-start `::` would be parsed as a @@ -6026,6 +6089,24 @@ jobs: PUSH_REPORTED: '${{ steps.push_report.outputs.round_reported }}' run: |- set -uo pipefail + # Stop the round heartbeat BEFORE flipping this comment to its + # terminal text: a tick landing after the finalize would + # overwrite it with a live-looking "working" line. The gate + # already killed the loop before the host-side branch code; this + # is the belt to its braces (a missed kill there must not outlive + # the round). Kill target comes from expression context — a pid + # read from a WORKDIR file would be an untrusted kill target + # (WORKDIR is sandbox-writable). The stop marker ends the loop on + # its next self-check even if BOTH kills miss, and the sleep lets + # an already-dispatched tick PATCH land before the terminal text + # goes up. Full rationale → qwen-autofix.md#af-148 + touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true + HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}" + if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then + kill -- -"${HB_PID}" 2>/dev/null || true + kill "${HB_PID}" 2>/dev/null || true + sleep 2 + fi MARKER='' if [[ -z "${STATUS_ID}" ]]; then echo "This round posted no status comment on PR #${PR}; nothing to finalize." @@ -6072,4 +6153,14 @@ jobs: # autofix workspace if that PR is never addressed again. - name: 'Clean up autofix workdir' if: 'always()' - run: 'rm -rf "${WORKDIR}"' + run: |- + # Last heartbeat kill (the gate and finalize already did this on + # their paths) from the expression-context pid — never from a + # WORKDIR file, which is sandbox-writable. + # Full rationale → qwen-autofix.md#af-148 + HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}" + if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then + kill -- -"${HB_PID}" 2>/dev/null || true + kill "${HB_PID}" 2>/dev/null || true + fi + rm -rf "${WORKDIR}" diff --git a/docs/design/autofix-round-heartbeat.md b/docs/design/autofix-round-heartbeat.md new file mode 100644 index 00000000000..1420b820299 --- /dev/null +++ b/docs/design/autofix-round-heartbeat.md @@ -0,0 +1,217 @@ +# Autofix round heartbeat: live progress on the PR during a round + +## Problem statement + +A review-address round can run for a long time: the `Triage and address` +step alone has a 130-minute timeout (120-minute agent budget), and a round +that reaches the repair path can occupy most of the job's 330 minutes. +During all of that, the PR shows exactly one static signal — the +`` comment reading "🔄 AutoFix is working on this PR — +round N/M", posted by `Post autofix status comment` and not touched again +until `Finalize autofix status comment` runs at the very end. + +Observed on PR #9739 (2026-08-22/23): round 2 was dispatched at 00:30 UTC +and ran ~1.5h before posting anything. To a maintainer watching the PR, a +healthy long round and a dead one were indistinguishable; the only recourse +was opening the Actions run and digging through logs. The gap: **no +liveness or progress signal reaches the PR itself between round start and +round end.** + +## Current state + +- `Post autofix status comment` (`qwen-autofix.yml` ~L4709) upserts the + status comment (one per PR, PATCHed each round) and hands + `comment_id` to finalize via step outputs. It runs in the PAT-bearing + context (`GITHUB_TOKEN: secrets.CI_DEV_BOT_PAT`). +- `Triage and address` (~L4750) runs `run-agent.mjs`, which streams every + model/tool event into `${WORKDIR}/agent.log`. This step deliberately + holds **no PAT** (its own comment records that) — the agent executes + PR-branch content and must not see the bot credential. +- `Finalize autofix status comment` (~L6008, `if: always()` minus stale / + dry-run) PATCHes the same comment to its terminal text. +- `Clean up autofix workdir` (last step, `if: always()`) removes + `${WORKDIR}`; `Reset autofix workspace` (~L3603) removes it again at the + start of the next same-PR run and age-sweeps `/tmp/autofix*` dirs. +- The "Watch live progress" link points at the run + (`actions/runs/`), one click above the in-progress job's log. +- Trust staging (`Stage trusted schema gate and agent runner`, ~L3625): + scripts invoked after `Prepare branch and feedback` has switched the + working tree to the PR branch must come from staged copies in + `RUNNER_TEMP` taken from the trusted base checkout — the working-tree + copy at that point is fork code. Two doctrines exist: staged copy + + sha256 digest re-verified at invocation, and content-in-`GITHUB_OUTPUT` + heredoc for PAT-bearing steps that run after the agent. + +## Proposed change + +### A. Heartbeat that keeps the status comment live + +At the end of `Post autofix status comment` (PAT already in env, comment id +known), start a detached background loop that every ~10 minutes PATCHes the +same status comment with the same bilingual "working" text plus one +progress line: + +> ⏱ Running for 42 min · agent active 3 min ago +> (⏱ 已运行 42 分钟 · agent 最近活动在 3 分钟前) + +- **Elapsed**: wall-clock since round start. +- **Agent activity**: mtime of `${WORKDIR}/agent.log` — no parsing, no + dependency on event shape. Before the file exists: "agent starting". + The thinking phase can legitimately go quiet for up to run-agent.mjs's + 10-minute stream-idle window; the displayed figure is honest, not + interpreted. +- The loop survives step boundaries (stdout/stderr redirected to + `${WORKDIR}/heartbeat.log`, stdin from `/dev/null`, launched via + `setsid` so it owns a process group on the persistent pool). + +Lifetime and kill discipline (the persistent self-hosted pool makes +orphan loops unacceptable): + +1. **The heartbeat is bounded to the sandboxed agent phase.** The + `Verification gate` step is the first one that runs branch code ON THE + HOST (the agent phase sandboxes it in docker), so it kills the loop + before launching the gate. The pulse covers the longest phase (the + ≤130-minute agent step); the comment holds its last tick through + gate/repair and finalize flips the terminal text. +2. Kill targets travel through **expression context**: the launch records + `$!` as a `heartbeat_pid` step output, and the gate / finalize / + cleanup kill that value. A pid read from a WORKDIR file would be an + untrusted kill target — the agent's docker sandbox mounts the host + `/tmp` on the same path and runs as the same user, so branch code the + agent executes can plant any value there. The on-disk + `${WORKDIR}/heartbeat.pid` survives for diagnostics and the loop's own + self-checks only. +3. `Finalize autofix status comment` touches `heartbeat-stop` and kills + **before** its own PATCH (avoids racing the terminal text), then sleeps + past one PATCH round-trip so an already-dispatched tick cannot land + after the terminal text server-side. +4. `Clean up autofix workdir` (`always()`) kills again as belt-and-braces. +5. `Reset autofix workspace` does NOT kill: a cross-run pid would have to + come from the untrusted file class. Wiping `WORKDIR` removes the pid + file, and the loop self-exits at its next self-check; a crash-leftover + orphan therefore dies within one interval (worst case: one stale + "working" tick on the comment, re-PATCHed by the new round). +6. Self-exit bounds inside the loop: stop if the pid file disappears, if + `heartbeat-stop` exists, or at a hard age cap (12h, far beyond the + 330-minute job timeout); each tick's `gh` call is wrapped in `timeout +60` so a black-holed connection cannot stall the loop past the cap. + +The kill logic is inline in the yml (4-6 lines each), **not** a script +call: the killers run in PAT-bearing or post-agent steps, and executing a +script from the PR-branch working tree there is exactly the swap hazard +the staging doctrine exists to prevent. + +### B. Deep-link the "Watch live progress" anchor to the job + +`Post autofix status comment` resolves the numeric job id of the running +`review-address` matrix leg (jobs listing of the current run attempt, +matched by name prefix `review-address (,`) and links +`actions/runs//job/` — one click straight to the live log. +Best-effort: on any lookup failure it falls back to the run URL, so the +comment is never worse than today. + +### Implementation shape + +- New script `.github/scripts/autofix-status-heartbeat.sh`: + - `body` subcommand prints the full bilingual working-state comment body + (marker, round line, progress line, collapsed Chinese), given round / + cap / URL / progress inputs. Single source of truth for the text — + `Post autofix status comment` uses it for the initial post and the + loop reuses it each tick, so the two cannot drift. + - `loop` subcommand: sleep–compose–PATCH until killed or self-exit + bound. Interval/age-cap overrides are regex-validated (a malformed or + zero value degrades to the default — never a sleep-less busy loop). +- The script is staged in `Stage trusted schema gate and agent runner` + (cp from the trusted base to `RUNNER_TEMP`, sha256 recorded in + `GITHUB_OUTPUT`), and `Post autofix status comment` verifies the digest + before invoking — the same pattern as `resanitize-git-config.sh`. It + runs BEFORE the agent (no check→use window across agent execution for + the invocation; bash parses the whole script at start, so a later swap + of the staged copy cannot alter the running loop). +- `${WORKDIR}/heartbeat.log` is added to the `Show run artifacts` echo + list (the whole-WORKDIR artifact upload picks it up automatically), so + a dead or silent heartbeat is diagnosable after the fact. + +## Key design decisions + +1. **Edit the existing status comment rather than posting new ones.** A + managed PR can run 100 rounds; per-round comment stacks are already + prevented by the PATCH discipline, and heartbeat posts would multiply + that by ~13 per round. Comment edits generate no notifications and no + `issue_comment` events (no workflow fan-out). +2. **Heartbeat lives in the review-address job, not a watcher job or the + schedule scan.** It needs the comment id, the run/job identity, and + `agent.log` — all local to the job. A separate job would have to poll + for all of them. The schedule scan lands every ~40-70 min in this repo + (not the 10 min the cron implies), too slow to be the pulse. +3. **PAT exposure trade-off, made explicit and lifetime-bounded.** The + heartbeat holds the bot PAT in its environment — a temporal overlap the + surrounding design otherwise avoids ("This step holds no PAT"). The + first revision bounded this only by "the agent sandboxes fork code"; + review proved that insufficient, because the verification gate then + runs the branch's own build/tests ON THE HOST as the runner user, and + a same-UID `/proc//environ` read from that code would expose the + token. The overlap is therefore bounded to the sandboxed agent phase: + the gate kills the loop before any host-side branch code runs + (lifetime rule 1). Within that phase the token never touches disk and + the only concurrent host processes are trusted (run-agent.mjs, the + bundled CLI). The alternative that avoids the overlap entirely — + heartbeat from the schedule scan or a watcher job — was rejected on + cadence and complexity (decision 2). The trade-off is recorded in the + yml comment so future readers see it was chosen, not overlooked. +4. **Kill targets never come from WORKDIR.** The sandbox mounts the host + `/tmp` on the same path and runs as the same user, so branch code can + write arbitrary values into `heartbeat.pid`; a killer that read it + would perform attacker-chosen process termination. Killers therefore + read the pid from expression context (the launch's `$!`), and the + on-disk file is diagnostics + the loop's own self-check only. +5. **agent.log mtime as the liveness signal.** No stream parsing, no + coupling to event schema or prompt structure; run-agent.mjs already + writes every event there. Coarse ("active N min ago") is exactly what + answers "is it dead?". +6. **Inline kill, staged start.** Script execution happens once, at start, + digest-verified, before the agent; the killers are inline `kill` calls + so no PR-branch-controlled file is ever executed in a PAT-bearing or + post-agent context. +7. **10-minute cadence.** ≤13 PATCHes per 130-minute round, ≤20 + concurrent legs — negligible API load. Frequent enough that a stuck + round shows a stale "active" timestamp within one interval. + +## Files affected + +| File | Change | +| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `.github/scripts/autofix-status-heartbeat.sh` | New: `body` + `loop` subcommands. | +| `.github/workflows/qwen-autofix.yml` | Stage + digest the script; `Post autofix status comment` uses it, resolves the job deep link, starts the loop and records its pid; the verification gate kills it before host-side branch code; finalize / cleanup kill again from the expression-context pid; artifact list gains `heartbeat.log`. | +| `.github/workflows/qwen-autofix.md` | New af-148/af-149 design records + TOC entries. | +| `.github/workflows/ci.yml` | Register the new behavioral suite in `HELPER_TESTS`. | +| `.github/workflows/.size-baseline` | Ratchet line for the yml growth. | +| `scripts/tests/qwen-autofix-workflow.test.js` | Pin the new wiring (start/kill sites, digest verification, deep link fallback, artifact list). | +| `.github/scripts/autofix-status-heartbeat.test.mjs` | Behavioral tests for the script (body shape, loop PATCH cadence, self-exit bounds) with a fake `gh`. | + +## Scope boundaries + +- Review-address lane only. The issue lane posts no status comment; the + fork-bridge/signal lanes dispatch into the same review-address job and + are covered automatically. +- **Out of scope (deliberately)**: repairing a "working" comment orphaned + by a hard runner kill where even `always()` finalize never ran (scan- + side stale-heal); richer milestone progress (findings addressed i/N); + a live-updating commit status. Each is a separate value judgment. + +## Residual risks (accepted) + +- **Liveness-signal integrity.** The sandbox can delete `heartbeat.pid` + (ending the pulse early), touch `heartbeat-stop`, or bump `agent.log`'s + mtime to forge "agent active 0 min ago". The attacker can only mislabel + their own round's progress — no token, no execution, no kill reach — so + this is accepted rather than engineered around. +- **Post-gate silence.** After the gate kills the loop, the comment holds + its last tick until finalize. A round deep in gate/repair looks quieter + than it is; the run link stays live, which is the recourse. + +## Open questions + +None blocking. Cadence (10 min) and age cap (12h) are repo-variable- +friendly constants but ship as literals until a reason to configure +appears. diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 15ceb42e051..78cacb77c6f 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -40,6 +40,8 @@ const reviewVerificationRunner = readFileSync( ); const pushAndReportScriptPath = '.github/scripts/autofix-push-and-report.sh'; const pushAndReportScript = readFileSync(pushAndReportScriptPath, 'utf8'); +const heartbeatScriptPath = '.github/scripts/autofix-status-heartbeat.sh'; +const heartbeatScript = readFileSync(heartbeatScriptPath, 'utf8'); const upsertDeferredScript = readFileSync( '.github/scripts/upsert-deferred-issue.sh', 'utf8', @@ -3129,7 +3131,7 @@ describe('qwen-autofix workflow', () => { // forces a deliberate test update, however it is spaced or line-wrapped: // bump this count AND pipe the new site through the normalizer (bumping // the count below too) — bumping this pin alone leaves toBe(12) green. - expect(workflowWithScripts.split('--paginate').length - 1).toBe(21); + expect(workflowWithScripts.split('--paginate').length - 1).toBe(22); // scan ic + pr-events + ic re-fetch + scan rv/rc + prepare rv/rc/ic + // report COMMENTS_JSON fallback + the cap-branch release-evidence events // fetch (R4-1) + the scan park gate's rv/rc fetches (the wake mirror @@ -3149,7 +3151,11 @@ describe('qwen-autofix workflow', () => { // fetch in resolve_and_reply_threads is the same class again — a GraphQL // paginate whose `--jq '…nodes[]'` stream is slurped straight into // THREADS_JSON — so it bumps the total pin above without joining the - // normalizer count below. + // normalizer count below. The heartbeat's deep-link job lookup (af-149) + // is the same class once more: the run-attempt jobs listing is slurped + // by `jq -rs` into a shell variable to resolve ONE job id, never a + // WORKDIR file, so it bumps the total pin without joining the count + // below. expect(workflow.split("jq -s 'add // []'").length - 1).toBe(12); // Empty-input semantics: a total gh failure feeds the fallback an EMPTY // stream, where the normalizer filter must yield '[]' and not 'null' — @@ -11469,6 +11475,20 @@ exit 1 index < gateLaunchTokens.length - 1 ? `${token} \\` : token, ), ]; + // The review gate additionally kills the round heartbeat FIRST — it is + // the first step that runs branch code on the host, and the loop holds + // the bot PAT (af-148). Parent-shell statements under the same + // doctrine: absolute-path/builtin command words, step-level pin as the + // kill target, nothing executable read from disk. The repair gate does + // not repeat it — the loop is already dead by then. + const heartbeatKillStatements = [ + '/usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true', + 'HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}"', + 'if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then', + 'builtin kill -- -"${HB_PID}" 2> /dev/null || true', + 'builtin kill "${HB_PID}" 2> /dev/null || true', + 'fi', + ]; // Bash breaks words only on ASCII space/tab/newline: strip ASCII // whitespace only, so a line carrying any other "whitespace" (NBSP, // U+2000–U+200A, U+2028, ...) keeps it and fails the exact match. @@ -11482,12 +11502,15 @@ exit 1 .map((line) => line.replace(/^[ \t]+|[ \t]+$/g, '')) .filter((line) => line !== '' && !line.startsWith('#')); expect(gateBodyStatementsOf('run: |-\n \u00a0# x')).toEqual(['\u00a0# x']); - for (const step of [ - reviewVerificationGateStep, - repairVerificationGateStep, + for (const [step, bodyStatements] of [ + [ + reviewVerificationGateStep, + [...heartbeatKillStatements, ...gateBodyStatements], + ], + [repairVerificationGateStep, gateBodyStatements], ]) { expect(step).toMatch(gateLaunchPin); - expect(gateBodyStatementsOf(step)).toEqual(gateBodyStatements); + expect(gateBodyStatementsOf(step)).toEqual(bodyStatements); // Exactly one launch per step: a second, unpinned `bash --norc` (the // pinned block demoted into a never-run arm) must fail here (R2-1). expect((step.match(/bash --norc/g) ?? []).length).toBe(1); @@ -15562,7 +15585,7 @@ exit 1 'for f in decision.json pr-title.txt pr-body.md e2e-report.md failure.md failure.zh.md fix.diff; do', ); expect(reviewAddressJob).toContain( - 'for f in feedback.md address-summary.md no-action.md failure.md failure.zh.md handoff.md gate-rejection.md gate-advisories.md growth-audit.json agent-api-error agent-api-error-kind agent-timeout resolved-comments.txt comment-replies.json deferred-findings.json deferred-findings.carry.json deferred-findings.unmerged.json pr.diff; do', + 'for f in feedback.md address-summary.md no-action.md failure.md failure.zh.md handoff.md gate-rejection.md gate-advisories.md growth-audit.json agent-api-error agent-api-error-kind agent-timeout resolved-comments.txt comment-replies.json deferred-findings.json deferred-findings.carry.json deferred-findings.unmerged.json pr.diff heartbeat.log; do', ); expect(reviewAddressReportStep).toContain( 'for f in address-summary.md no-action.md failure.md failure.zh.md handoff.md; do', @@ -15586,7 +15609,13 @@ exit 1 expect(postStatusCommentStep).toContain( 'actions/runs/${{ github.run_id }}', ); - expect(postStatusCommentStep).toContain('Watch live progress'); + // The working-comment text renders through the heartbeat script's 'body' + // subcommand — the initial post and every later tick use the SAME body, + // so they cannot drift (af-148). The strings themselves are pinned on + // the script below. + expect(postStatusCommentStep).toContain( + 'bash --norc "${RUNNER_TEMP}/autofix-status-heartbeat.sh" body', + ); // Announced only for a round that will really run, and never on a dry run. expect(postStatusCommentStep).toContain( "steps.prepare.outputs.stale != 'true'", @@ -15603,8 +15632,10 @@ exit 1 expect(postStatusCommentStep).toContain('continuing.'); expect(finalizeStatusCommentStep).toContain('set -uo pipefail'); expect(finalizeStatusCommentStep).toContain('continuing.'); - // Repository convention for anything posted verbatim as a PR comment. - expect(postStatusCommentStep).toContain('中文说明'); + // Repository convention for anything posted verbatim as a PR comment — + // the bilingual wrapper lives in the heartbeat script now (same body on + // every tick). + expect(heartbeatScript).toContain('中文说明'); // Runs on every ending (including a crashed agent) so no finished round // leaves a live-looking "working" line behind. @@ -15681,6 +15712,175 @@ exit 1 ); }); + it('keeps the round status comment live with a heartbeat and a job deep link', () => { + // A round can run for hours while the announcement freezes at + // "working" — on the PR page a long round and a dead one look + // identical (#9739). The heartbeat re-PATCHes the same comment every + // ~10 min with elapsed time and last agent activity; the deep link + // lands the "Watch live progress" anchor on the leg's own log page. + // Full rationale → qwen-autofix.md#af-148, af-149. + + // The comment body is owned by the script: the initial post and every + // tick render through the same 'body' subcommand, so pin the text + // there, not on one step. + expect(heartbeatScript).toContain(''); + expect(heartbeatScript).toContain('Watch live progress'); + expect(heartbeatScript).toContain('查看实时进度'); + expect(heartbeatScript).toContain('round %s/%s'); + expect(heartbeatScript).toContain('agent active'); + expect(heartbeatScript).toContain('agent starting'); + // Loop self-discipline on the persistent pool: an orphan loop would + // edit the comment forever, so it must die on every teardown signal + // AND on its own age cap. + expect(heartbeatScript).toContain('heartbeat.pid'); + expect(heartbeatScript).toContain('heartbeat-stop'); + expect(heartbeatScript).toContain('HB_MAX_AGE_SECONDS'); + expect(heartbeatScript).toContain('HB_INTERVAL_SECONDS'); + // A failed PATCH skips one tick, never the pulse. + expect(heartbeatScript).toContain('PATCH failed; continuing'); + // The loop must not hold the launching step's pipes or the step never + // completes. + expect(heartbeatScript).toContain('exec >> "${HB_WORKDIR}/heartbeat.log"'); + expect(spawnSync('bash', ['-n', heartbeatScriptPath]).status).toBe(0); + + // Staging: the working tree is PR-branch code by post_status, so the + // script travels as a trusted-base staged copy with a digest recorded + // in expression context (the af-111 doctrine). + const stageStep = + reviewAddressJob.match( + /- name: 'Stage trusted schema gate and agent runner'[\s\S]*?(?=\n {6}- name: ')/, + )?.[0] ?? ''; + expect(stageStep).toContain( + 'cp .github/scripts/autofix-status-heartbeat.sh "${RUNNER_TEMP}/autofix-status-heartbeat.sh"', + ); + expect(stageStep).toContain( + 'echo "heartbeat_sha256=$(sha256sum "${RUNNER_TEMP}/autofix-status-heartbeat.sh" | cut -d\' \' -f1)" >> "${GITHUB_OUTPUT}"', + ); + expect(postStatusCommentStep).toContain( + "HEARTBEAT_SHA256: '${{ steps.stage.outputs.heartbeat_sha256 }}'", + ); + expect(postStatusCommentStep).toContain( + '/usr/bin/echo "${HEARTBEAT_SHA256} ${RUNNER_TEMP}/autofix-status-heartbeat.sh" | /usr/bin/sha256sum -c - > /dev/null', + ); + // ...and the check must precede the FIRST use: a check moved below + // the invocation would verify nothing before the script runs (the + // af-111 doctrine this wiring cites). + expect(postStatusCommentStep.indexOf('sha256sum -c')).toBeLessThan( + postStatusCommentStep.indexOf('autofix-status-heartbeat.sh" body'), + ); + + // Deep link: attempt-scoped jobs listing, PR number enters jq as DATA + // (--arg, never interpolation), and any lookup failure keeps the run + // URL fallback — never worse than before. + expect(postStatusCommentStep).toContain( + 'repos/${REPO}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}/jobs', + ); + expect(postStatusCommentStep).toContain('--arg pr "${PR}"'); + expect(postStatusCommentStep).toContain( + 'startswith("review-address (\\($pr),")', + ); + expect(postStatusCommentStep).toContain('JOB_URL="${RUN_URL}"'); + expect(postStatusCommentStep).toContain( + 'JOB_URL="${SERVER_URL}/${REPO}/actions/runs/${GITHUB_RUN_ID}/job/${JOB_ID}"', + ); + // The deep link must actually REACH the consumers: the initial body + // and every loop tick both render HB_URL, and the JOB_ID gate + empty + // fallback are the "never worse than before" semantics. + expect(postStatusCommentStep.split('HB_URL="${JOB_URL}"').length - 1).toBe( + 2, + ); + expect(postStatusCommentStep).toContain('if [[ "${JOB_ID}" =~ ^[0-9]+$ ]]'); + expect(postStatusCommentStep).toContain("JOB_ID=''"); + + // Launch: detached (setsid + self-redirected output), gated on a + // comment that actually exists (the gate wraps the launch itself, not + // just the earlier PATCH-or-create), carrying the round identity and + // recording the pid for the killers in EXPRESSION CONTEXT. + expect(postStatusCommentStep).toContain( + 'setsid bash --norc "${RUNNER_TEMP}/autofix-status-heartbeat.sh" loop &', + ); + expect(postStatusCommentStep).toContain( + 'if [[ -n "${STATUS_ID}" ]]; then\n HB_REPO=', + ); + expect(postStatusCommentStep).toContain('HB_COMMENT_ID="${STATUS_ID}"'); + expect(postStatusCommentStep).toContain('HB_START_EPOCH="${START_EPOCH}"'); + expect(postStatusCommentStep).toContain('HEARTBEAT_PID=$!'); + expect(postStatusCommentStep).toContain( + 'echo "heartbeat_pid=${HEARTBEAT_PID}" >> "${GITHUB_OUTPUT}"', + ); + + // Kill discipline (af-148): kill targets come from expression context + // — a pid read from a WORKDIR file would be an untrusted kill target, + // the sandbox mounts the host /tmp on the same path and runs as this + // same user. The verification gate kills FIRST — before the first step + // that runs branch code on the host — finalize and the always() + // cleanup kill again. NOBODY kills from heartbeat.pid: the resets do + // not kill at all (a cross-run pid could only come from that + // untrusted file; wiping the dir ends the loop at its own next + // self-check). + const killTarget = '${{ steps.post_status.outputs.heartbeat_pid }}'; + // The review lane's gate — extracted from reviewAddressJob itself, so + // a kill planted in the issue lane's same-named step cannot satisfy + // these pins. + const gateStep = + reviewAddressJob.match( + /- name: 'Verification gate'[\s\S]*?(?=\n[ ]{6}- name: ')/, + )?.[0] ?? ''; + expect(gateStep).toContain('heartbeat-stop'); + expect(gateStep).toContain(`HB_PID="${killTarget}"`); + expect(gateStep).toContain('kill -- -"${HB_PID}"'); + expect(finalizeStatusCommentStep).toContain('heartbeat-stop'); + expect(finalizeStatusCommentStep).toContain(`HB_PID="${killTarget}"`); + expect(finalizeStatusCommentStep).toContain('kill -- -"${HB_PID}"'); + expect(finalizeStatusCommentStep.indexOf('heartbeat-stop')).toBeLessThan( + finalizeStatusCommentStep.indexOf('--method PATCH'), + ); + // A tick already dispatched when the kill lands can still be applied + // server-side after the terminal text: finalize sleeps past one PATCH + // round-trip before its own PATCH. + expect(finalizeStatusCommentStep).toContain('sleep 2'); + const cleanupStep = + reviewAddressJob.match( + /- name: 'Clean up autofix workdir'[\s\S]*?(?=\n[ ]{6}- name: '|\n[ ]{2}# ==========|$)/, + )?.[0] ?? ''; + expect(cleanupStep).toContain(`HB_PID="${killTarget}"`); + expect(cleanupStep).toContain('kill -- -"${HB_PID}"'); + expect(cleanupStep.indexOf('kill -- -"${HB_PID}"')).toBeLessThan( + cleanupStep.indexOf('rm -rf "${WORKDIR}"'), + ); + // Same-round killers also carry the bare-pid fallback; the gate uses + // the builtin form of the step's shadowing doctrine. + expect(gateStep).toContain('builtin kill "${HB_PID}"'); + expect(finalizeStatusCommentStep).toContain('kill "${HB_PID}"'); + expect(cleanupStep).toContain('kill "${HB_PID}"'); + // Neither reset step carries a kill (a cross-run pid could only come + // from the untrusted file class; the comments may still explain why), + // and none of the kill sites EXECUTES the heartbeat script (the + // working-tree copy is fork code by the time any of them runs). + for (const step of resetAutofixWorkspaceSteps) { + expect(step).not.toContain('kill -- -'); + expect(step).not.toContain('HB_PID'); + } + for (const killer of [gateStep, finalizeStatusCommentStep, cleanupStep]) { + expect(killer).not.toContain('autofix-status-heartbeat.sh'); + } + + // The pulse is diagnosable after the fact: heartbeat.log rides the + // review lane's run-artifact echo list (and the whole-WORKDIR upload). + const reviewShowArtifactsStep = + reviewAddressJob.match( + /- name: 'Show run artifacts'[\s\S]*?(?=\n[ ]{6}- name: 'Upload run artifacts')/, + )?.[0] ?? ''; + expect(reviewShowArtifactsStep).toContain('heartbeat.log'); + + // The behavioral suite below is the only real coverage for the script + // itself; pin its CI membership so deleting the HELPER_TESTS entry + // cannot silently disable it (precedent: resolve-sandbox-image). + expect(ciWorkflow).toContain( + '.github/scripts/autofix-status-heartbeat.test.mjs', + ); + }); + it('renders the whole managed fleet into the run summary', () => { // Diagnosing a stall used to mean listing bot PRs, regexing each one's eval // markers, and cross-checking checks and fork state by hand - so stalls From 7fc3fd579686d0bb4bff143b67a277057fe7be0c Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sun, 23 Aug 2026 16:48:05 +0800 Subject: [PATCH 02/19] fix(autofix): harden the heartbeat self-exit and pin its behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the round heartbeat (PR review of the previous commit): - The orphan self-exit is now an IDENTITY check, not an existence check: WORKDIR is PR-scoped, so the next round recreates heartbeat.pid at the same path, and existence alone let a hard-killed round's orphan pass and keep PATCHing its stale body onto the comment. The loop compares the file against its own pid; removed or replaced ends it. Mirror test added. - run_loop validates every variable a tick needs up front — a launch missing a body var now fails fast instead of producing an immortal loop that never pulses (the exact failure the feature eliminates); the dead HB_START_EPOCH fallback is gone with it. - The deep-link jq filter gains a behavioral oracle (extracted verbatim, run through real jq against paginate-shaped fixtures: cross-page match, comma guard against a shorter PR number, deliberate last choice, empty input), and the timeout wrapper is pinned through a PATH shim proving gh runs under it. --- .github/scripts/autofix-status-heartbeat.sh | 19 +++- .../scripts/autofix-status-heartbeat.test.mjs | 107 +++++++++++++++--- .github/workflows/qwen-autofix.md | 30 +++-- docs/design/autofix-round-heartbeat.md | 31 +++-- scripts/tests/qwen-autofix-workflow.test.js | 38 +++++++ 5 files changed, 181 insertions(+), 44 deletions(-) diff --git a/.github/scripts/autofix-status-heartbeat.sh b/.github/scripts/autofix-status-heartbeat.sh index aee59902353..35c7f3aa62f 100644 --- a/.github/scripts/autofix-status-heartbeat.sh +++ b/.github/scripts/autofix-status-heartbeat.sh @@ -75,7 +75,11 @@ emit_body() { } run_loop() { - require HB_REPO HB_COMMENT_ID HB_WORKDIR + # Validate EVERYTHING a tick needs, not just the loop's own three: a + # launch missing a body var would otherwise produce an immortal loop + # that never pulses — the exact "healthy round looks dead" failure this + # feature eliminates. Fail fast instead. + require HB_REPO HB_COMMENT_ID HB_WORKDIR HB_ROUND HB_CAP HB_URL HB_START_EPOCH # Self-detach from the launching step: log to WORKDIR and never hold the # step's pipes, or the step would never report completion. exec >> "${HB_WORKDIR}/heartbeat.log" 2>&1 < /dev/null @@ -86,7 +90,7 @@ run_loop() { # defaults, never into a sleep-less busy loop hammering the API. [[ "${interval}" =~ ^[1-9][0-9]*$ ]] || interval=600 [[ "${max_age}" =~ ^[1-9][0-9]*$ ]] || max_age=43200 - local start="${HB_START_EPOCH:-$(date +%s)}" + local start="${HB_START_EPOCH}" echo "$(date -u +%FT%TZ) heartbeat started: comment ${HB_COMMENT_ID} interval ${interval}s max_age ${max_age}s" while :; do sleep "${interval}" @@ -97,8 +101,15 @@ run_loop() { echo "$(date -u +%FT%TZ) self-exit: age ${age}s exceeds ${max_age}s" exit 0 fi - if [[ ! -f "${HB_WORKDIR}/heartbeat.pid" ]]; then - echo "$(date -u +%FT%TZ) self-exit: pid file removed" + # IDENTITY, not existence: WORKDIR is PR-scoped (/tmp/autofix-review-), + # so after a crashed round's reset the NEXT round recreates heartbeat.pid + # at the same path. An existence check would let the orphaned old loop + # pass and keep PATCHing with its stale launch env, alternating with the + # new round's body on the same comment. The file must still hold THIS + # loop's own pid — removed OR replaced (by a newer round) ends the loop. + # This reads the file to self-identify only; it never kills anything. + if [[ "$(cat "${HB_WORKDIR}/heartbeat.pid" 2> /dev/null)" != "$$" ]]; then + echo "$(date -u +%FT%TZ) self-exit: pid file removed or replaced" exit 0 fi if [[ -f "${HB_WORKDIR}/heartbeat-stop" ]]; then diff --git a/.github/scripts/autofix-status-heartbeat.test.mjs b/.github/scripts/autofix-status-heartbeat.test.mjs index 88f35679856..2c7992cf30f 100644 --- a/.github/scripts/autofix-status-heartbeat.test.mjs +++ b/.github/scripts/autofix-status-heartbeat.test.mjs @@ -66,6 +66,31 @@ function readCalls(records) { ); } +// A fake `timeout` that records its argv and immediately execs its tail. +// Placed FIRST on PATH, it shadows coreutils `timeout` on Linux and +// supplies it on hosts without one (macOS dev), so the loop's black-hole +// guard is exercised deterministically on every host: the assertion is +// that `gh` runs UNDER `timeout `, which the shim proves by +// recording the duration and then running gh itself. +function fakeTimeoutBin(binDir, dir) { + const records = join(dir, 'timeout-calls'); + mkdirSync(records, { recursive: true }); + const timeout = join(binDir, 'timeout'); + writeFileSync( + timeout, + [ + '#!/usr/bin/env bash', + 'set -u', + 'n=$(( $(ls -1 "${TIMEOUT_RECORD_DIR}" | wc -l) + 1 ))', + 'for a in "$@"; do printf \'%s\\0\' "$a"; done > "${TIMEOUT_RECORD_DIR}/call-${n}"', + 'shift', + 'exec "$@"', + ].join('\n'), + ); + chmodSync(timeout, 0o755); + return records; +} + function bodyEnv(overrides = {}) { const workdir = overrides.HB_WORKDIR ?? freshTmp(); return { @@ -298,6 +323,31 @@ describe('autofix-status-heartbeat loop', () => { } }); + it('self-exits when the pid file is REPLACED by a newer round', async () => { + // The orphan scenario: WORKDIR is PR-scoped, so the next round of the + // same PR recreates heartbeat.pid at the same path. The old loop must + // recognize the foreign pid and exit, not keep pulsing with its stale + // launch env (alternating stale bodies onto the same comment). + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env, workdir } = loopEnv(dir, gh); + const child = startLoop(env); + try { + const started = await waitFor( + () => existsSync(join(workdir, 'heartbeat.pid')), + 8000, + ); + assert.ok(started, 'the loop must register its pid first'); + writeFileSync(join(workdir, 'heartbeat.pid'), '999999\n'); + const code = await awaitExit(child, 8000); + assert.equal(code, 0, 'a replaced pid file must end the loop cleanly'); + const logText = readFileSync(join(workdir, 'heartbeat.log'), 'utf8'); + assert.match(logText, /self-exit: pid file removed or replaced/); + } finally { + killGroup(child); + } + }); + it('degrades malformed interval and age-cap overrides to defaults', async () => { const dir = freshTmp(); const gh = fakeGhBin(dir); @@ -319,27 +369,26 @@ describe('autofix-status-heartbeat loop', () => { } }); - it('skips a tick whose body composition fails and keeps looping', async () => { + it('runs each PATCH under timeout so a black-holed request cannot outlive the age cap', async () => { + // The age cap only runs BETWEEN ticks; a hung `gh api` inside a tick + // would stall the loop there forever, holding the PAT past the cap. + // The `timeout 60` wrapper is the guard — pin that gh actually runs + // under it (the shim records the bound, then execs gh). const dir = freshTmp(); const gh = fakeGhBin(dir); - const { env, workdir } = loopEnv(dir, gh, { HB_URL: '' }); + const timeoutRecords = fakeTimeoutBin(gh.bin, dir); + const { env } = loopEnv(dir, gh, { TIMEOUT_RECORD_DIR: timeoutRecords }); const child = startLoop(env); try { - const ok = await waitFor( - () => - existsSync(join(workdir, 'heartbeat.log')) && - /body composition failed/.test( - readFileSync(join(workdir, 'heartbeat.log'), 'utf8'), - ), - 8000, - ); - assert.ok(ok, 'a failed compose must be logged, not fatal'); - assert.equal( - readCalls(gh.records).length, - 0, - 'no PATCH may go out with a body that failed to compose', + const ok = await waitFor(() => readCalls(gh.records).length >= 1, 8000); + assert.ok(ok, 'the shim must exec gh through to its record'); + const timeoutCalls = readCalls(timeoutRecords); + assert.ok( + timeoutCalls.length >= 1, + 'gh must run UNDER timeout, not bare', ); - assert.ok(child.exitCode === null, 'the loop must keep running'); + assert.equal(timeoutCalls[0][0], '60', 'the bound must be 60s'); + assert.equal(timeoutCalls[0][1], 'gh'); } finally { killGroup(child); } @@ -364,6 +413,32 @@ describe('autofix-status-heartbeat loop', () => { assert.match(stderr, /HB_COMMENT_ID is required/); }); + it('refuses to loop when a BODY var is missing — no immortal unpulsing loop', async () => { + // A launch missing a body var (HB_ROUND here) must fail fast, not + // produce a loop that lives to the age cap logging "body composition + // failed" every tick while the status comment freezes — the exact + // "healthy round looks dead" failure this feature eliminates. + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env, workdir } = loopEnv(dir, gh); + delete env.HB_ROUND; + const child = spawn('bash', [script, 'loop'], { + env, + stdio: ['ignore', 'ignore', 'pipe'], + detached: true, + }); + let stderr = ''; + child.stderr.on('data', (d) => { + stderr += d; + }); + const code = await awaitExit(child, 8000); + assert.equal(code, 2); + assert.match(stderr, /HB_ROUND is required/); + // Fail fast BEFORE registering anything: no pid file, no log. + assert.ok(!existsSync(join(workdir, 'heartbeat.pid'))); + assert.ok(!existsSync(join(workdir, 'heartbeat.log'))); + }); + it('self-exits at the age cap', async () => { const dir = freshTmp(); const gh = fakeGhBin(dir); diff --git a/.github/workflows/qwen-autofix.md b/.github/workflows/qwen-autofix.md index d2e2c496161..f3c05576a4f 100644 --- a/.github/workflows/qwen-autofix.md +++ b/.github/workflows/qwen-autofix.md @@ -3788,18 +3788,24 @@ comment id, run identity and liveness remotely: too slow and too much machinery for a pulse. ORPHAN DISCIPLINE on the persistent pool: the loop -self-exits on a missing pid file (a crashed prior round's -orphan ends at its next self-check once this round's reset -wipes the dir — no cross-run kill, which would need a file -pid and re-open the untrusted-target hole), a heartbeat-stop -marker, or a 12h age cap far past the 330-minute job -timeout; each tick's gh call is additionally wrapped in a -60s timeout so a black-holed connection cannot stall the -loop past the cap. Killers that run in-round touch the stop -marker BEFORE killing so a missed kill still ends the loop -at its next self-check — a tick landing after the terminal -text would overwrite it with a live-looking "working" line -— and finalize additionally sleeps past one PATCH +self-exits when the pid file no longer holds ITS OWN pid. +This is an identity check, not an existence check — WORKDIR +is PR-scoped, so after a crashed round's reset the next +round recreates heartbeat.pid at the SAME path; existence +alone would let the orphaned old loop pass and keep PATCHing +its stale body onto the comment. Reading the file to +self-identify is safe (the loop never kills anything); the +killers never read it, which is what keeps the +untrusted-target hole closed — no cross-run kill. The other +bounds: a heartbeat-stop marker, or a 12h age cap far past +the 330-minute job timeout; each tick's gh call is +additionally wrapped in a 60s timeout so a black-holed +connection cannot stall the loop past the cap. Killers that +run in-round touch the stop marker BEFORE killing so a +missed kill still ends the loop at its next self-check — a +tick landing after the terminal text would overwrite it +with a live-looking "working" line — and finalize +additionally sleeps past one PATCH round-trip so an already-dispatched tick cannot land after the terminal text server-side. ``` diff --git a/docs/design/autofix-round-heartbeat.md b/docs/design/autofix-round-heartbeat.md index 1420b820299..293302bb077 100644 --- a/docs/design/autofix-round-heartbeat.md +++ b/docs/design/autofix-round-heartbeat.md @@ -88,13 +88,19 @@ orphan loops unacceptable): 4. `Clean up autofix workdir` (`always()`) kills again as belt-and-braces. 5. `Reset autofix workspace` does NOT kill: a cross-run pid would have to come from the untrusted file class. Wiping `WORKDIR` removes the pid - file, and the loop self-exits at its next self-check; a crash-leftover - orphan therefore dies within one interval (worst case: one stale - "working" tick on the comment, re-PATCHed by the new round). -6. Self-exit bounds inside the loop: stop if the pid file disappears, if - `heartbeat-stop` exists, or at a hard age cap (12h, far beyond the - 330-minute job timeout); each tick's `gh` call is wrapped in `timeout -60` so a black-holed connection cannot stall the loop past the cap. + file, and the loop self-exits at its next identity self-check; a + crash-leftover orphan therefore dies within one interval (worst case: + one stale "working" tick already past its identity check, re-PATCHed + by the new round). +6. Self-exit bounds inside the loop: stop if the pid file no longer holds + the loop's OWN pid — an identity check, not an existence check, + because `WORKDIR` is PR-scoped and the next round recreates + `heartbeat.pid` at the same path, which an existence check would let + the orphan pass (reading the file here is safe: the loop only + self-identifies, it never kills anything); stop if `heartbeat-stop` + exists, or at a hard age cap (12h, far beyond the 330-minute job + timeout); each tick's `gh` call is wrapped in `timeout 60` so a + black-holed connection cannot stall the loop past the cap. The kill logic is inline in the yml (4-6 lines each), **not** a script call: the killers run in PAT-bearing or post-agent steps, and executing a @@ -201,11 +207,12 @@ comment is never worse than today. ## Residual risks (accepted) -- **Liveness-signal integrity.** The sandbox can delete `heartbeat.pid` - (ending the pulse early), touch `heartbeat-stop`, or bump `agent.log`'s - mtime to forge "agent active 0 min ago". The attacker can only mislabel - their own round's progress — no token, no execution, no kill reach — so - this is accepted rather than engineered around. +- **Liveness-signal integrity.** The sandbox can delete or overwrite + `heartbeat.pid` (ending the pulse early via the identity self-check), + touch `heartbeat-stop`, or bump `agent.log`'s mtime to forge "agent + active 0 min ago". The attacker can only mislabel or silence their own + round's progress — no token, no execution, no kill reach — so this is + accepted rather than engineered around. - **Post-gate silence.** After the gate kills the loop, the comment holds its last tick until finalize. A round deep in gate/repair looks quieter than it is; the run link stays live, which is the recourse. diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 78cacb77c6f..b529a68431e 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -15791,6 +15791,44 @@ exit 1 ); expect(postStatusCommentStep).toContain('if [[ "${JOB_ID}" =~ ^[0-9]+$ ]]'); expect(postStatusCommentStep).toContain("JOB_ID=''"); + // Behavioral oracle for the filter itself (substring pins cannot catch + // last→first or a collapsed slurp): extract it verbatim and run it + // through real jq against paginate-shaped fixtures, the same pattern + // the LAST_ENGAGE_ACK_TS test uses. + const jobIdProgram = postStatusCommentStep.match( + /jq -rs --arg pr "\$\{PR\}" \\\n\s*'([\s\S]*?)'\)" \|/, + )?.[1]; + expect(jobIdProgram).toBeTruthy(); + const runJobIdFilter = (pr, input) => + execFileSync('jq', ['-rs', '--arg', 'pr', pr, jobIdProgram], { + encoding: 'utf8', + input, + }).trim(); + // One matching job across two concatenated page documents, plus the + // comma guard: PR 12's prefix must NOT match PR 123's job name. + const twoPages = + JSON.stringify({ + total_count: 2, + jobs: [{ name: 'route', id: 1 }], + }) + + JSON.stringify({ + total_count: 2, + jobs: [{ name: 'review-address (123, feat/x, 123, 1, x)', id: 3 }], + }); + expect(runJobIdFilter('123', twoPages)).toBe('3'); + expect(runJobIdFilter('12', twoPages)).toBe(''); + // Two matches pin the DELIBERATE `last` choice (a last→first mutation + // would deep-link the wrong leg through the numeric guard). + const twoMatches = + JSON.stringify({ + jobs: [{ name: 'review-address (123, a, 1)', id: 10 }], + }) + + JSON.stringify({ + jobs: [{ name: 'review-address (123, b, 1)', id: 20 }], + }); + expect(runJobIdFilter('123', twoMatches)).toBe('20'); + // Empty input yields empty output — the run-URL fallback gate. + expect(runJobIdFilter('123', '')).toBe(''); // Launch: detached (setsid + self-redirected output), gated on a // comment that actually exists (the gate wraps the launch itself, not From e0c4c9ce33e471e60bdf79b1b4b6ff8f7e93695d Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sun, 23 Aug 2026 12:28:34 +0000 Subject: [PATCH 03/19] fix(autofix): fail fast on a token-less heartbeat launch; correct orphan docs --- .github/scripts/autofix-status-heartbeat.sh | 7 +++++ .../scripts/autofix-status-heartbeat.test.mjs | 28 +++++++++++++++++++ .github/workflows/qwen-autofix.md | 10 ++++++- .github/workflows/qwen-autofix.yml | 12 ++++---- docs/design/autofix-round-heartbeat.md | 23 ++++++++++++--- 5 files changed, 70 insertions(+), 10 deletions(-) diff --git a/.github/scripts/autofix-status-heartbeat.sh b/.github/scripts/autofix-status-heartbeat.sh index 35c7f3aa62f..cb7726ed291 100644 --- a/.github/scripts/autofix-status-heartbeat.sh +++ b/.github/scripts/autofix-status-heartbeat.sh @@ -80,6 +80,13 @@ run_loop() { # that never pulses — the exact "healthy round looks dead" failure this # feature eliminates. Fail fast instead. require HB_REPO HB_COMMENT_ID HB_WORKDIR HB_ROUND HB_CAP HB_URL HB_START_EPOCH + # gh auth rides on GITHUB_TOKEN (or GH_TOKEN): a launch without it must + # fail fast like any other missing input, not degrade to an immortal + # loop that logs "PATCH failed" every tick and never pulses. + [[ -n "${GITHUB_TOKEN:-}${GH_TOKEN:-}" ]] || { + echo "autofix-status-heartbeat: GITHUB_TOKEN (or GH_TOKEN) is required" >&2 + exit 2 + } # Self-detach from the launching step: log to WORKDIR and never hold the # step's pipes, or the step would never report completion. exec >> "${HB_WORKDIR}/heartbeat.log" 2>&1 < /dev/null diff --git a/.github/scripts/autofix-status-heartbeat.test.mjs b/.github/scripts/autofix-status-heartbeat.test.mjs index 2c7992cf30f..06b5ecdffbd 100644 --- a/.github/scripts/autofix-status-heartbeat.test.mjs +++ b/.github/scripts/autofix-status-heartbeat.test.mjs @@ -192,6 +192,7 @@ describe('autofix-status-heartbeat loop', () => { ...process.env, PATH: `${gh.bin}:${process.env.PATH}`, GH_RECORD_DIR: gh.records, + GITHUB_TOKEN: 'fake', HB_REPO: 'octo/repo', HB_COMMENT_ID: '777', HB_ROUND: '2', @@ -413,6 +414,33 @@ describe('autofix-status-heartbeat loop', () => { assert.match(stderr, /HB_COMMENT_ID is required/); }); + it('refuses to loop without a gh token — no immortal never-pulsing loop', async () => { + // The header contract names GITHUB_TOKEN among the loop's needs: a + // launch without it must fail fast like any other missing input, not + // live to the age cap logging "PATCH failed" every tick while the + // status comment freezes. + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env, workdir } = loopEnv(dir, gh); + delete env.GITHUB_TOKEN; + delete env.GH_TOKEN; + const child = spawn('bash', [script, 'loop'], { + env, + stdio: ['ignore', 'ignore', 'pipe'], + detached: true, + }); + let stderr = ''; + child.stderr.on('data', (d) => { + stderr += d; + }); + const code = await awaitExit(child, 8000); + assert.equal(code, 2); + assert.match(stderr, /GITHUB_TOKEN \(or GH_TOKEN\) is required/); + // Fail fast BEFORE registering anything: no pid file, no log. + assert.ok(!existsSync(join(workdir, 'heartbeat.pid'))); + assert.ok(!existsSync(join(workdir, 'heartbeat.log'))); + }); + it('refuses to loop when a BODY var is missing — no immortal unpulsing loop', async () => { // A launch missing a body var (HB_ROUND here) must fail fast, not // produce a loop that lives to the age cap logging "body composition diff --git a/.github/workflows/qwen-autofix.md b/.github/workflows/qwen-autofix.md index f3c05576a4f..f66c309a205 100644 --- a/.github/workflows/qwen-autofix.md +++ b/.github/workflows/qwen-autofix.md @@ -3793,7 +3793,15 @@ This is an identity check, not an existence check — WORKDIR is PR-scoped, so after a crashed round's reset the next round recreates heartbeat.pid at the SAME path; existence alone would let the orphaned old loop pass and keep PATCHing -its stale body onto the comment. Reading the file to +its stale body onto the comment. Reclamation by rewrite is +HOST-LOCAL: it fires only when the next same-PR round reuses +the orphan's host. Cross-host — the fleet's general case, +no per-PR runner affinity — nothing rewrites the file, the +orphan keeps passing its own identity check, and it pulses +its stale body onto the shared comment until the 12h age cap +(accepted residual risk: liveness-text corruption only, +bounded by the cap; a cross-run kill keyed on a WORKDIR pid +would reopen the untrusted-kill-target hole). Reading the file to self-identify is safe (the loop never kills anything); the killers never read it, which is what keeps the untrusted-target hole closed — no cross-run kill. The other diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 47c2cdd3581..2741a95373a 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -3610,11 +3610,13 @@ jobs: (umask 077; mkdir -p "${WORKDIR}") # Age-sweep abandoned run-scoped dirs on this shared /tmp: a hard # runner kill skips the always() teardown and run_id never repeats, - # so nothing else ever reclaims them. The rm above also removes a - # crashed prior round's heartbeat.pid — the heartbeat loop ends - # itself at the next self-check when that file disappears; no - # cross-run kill here, because a pid read from a WORKDIR file is - # an untrusted kill target (WORKDIR is sandbox-writable). + # so nothing else ever reclaims them. WHEN a crashed prior round + # ran on THIS host, the rm above also removes its heartbeat.pid + # and that loop ends itself at the next identity self-check; + # cross-host the orphan keeps its own pid file and pulses until + # its age cap (accepted residual risk). No cross-run kill here, + # because a pid read from a WORKDIR file is an untrusted kill + # target (WORKDIR is sandbox-writable). # Full rationale → qwen-autofix.md#af-148 find /tmp -maxdepth 1 -name 'autofix*' -mmin +1440 -exec rm -rf {} + 2>/dev/null || true # The reused workspace's .git accumulates unreferenced objects diff --git a/docs/design/autofix-round-heartbeat.md b/docs/design/autofix-round-heartbeat.md index 293302bb077..654e0902890 100644 --- a/docs/design/autofix-round-heartbeat.md +++ b/docs/design/autofix-round-heartbeat.md @@ -88,10 +88,14 @@ orphan loops unacceptable): 4. `Clean up autofix workdir` (`always()`) kills again as belt-and-braces. 5. `Reset autofix workspace` does NOT kill: a cross-run pid would have to come from the untrusted file class. Wiping `WORKDIR` removes the pid - file, and the loop self-exits at its next identity self-check; a - crash-leftover orphan therefore dies within one interval (worst case: - one stale "working" tick already past its identity check, re-PATCHed - by the new round). + file, and the loop self-exits at its next identity self-check when + the next round reuses the orphan's host; cross-host, nothing rewrites + the pid file, so a crash-leftover orphan keeps pulsing its stale body + until the 12h age cap — alternating with the live round's bodies and + overwriting later rounds' terminal text within one interval of + finalize. Worst same-host case: one stale "working" tick already past + its identity check, re-PATCHed by the new round. The cross-host + window is an accepted residual risk (below), bounded by the age cap. 6. Self-exit bounds inside the loop: stop if the pid file no longer holds the loop's OWN pid — an identity check, not an existence check, because `WORKDIR` is PR-scoped and the next round recreates @@ -216,6 +220,17 @@ comment is never worse than today. - **Post-gate silence.** After the gate kills the loop, the comment holds its last tick until finalize. A round deep in gate/repair looks quieter than it is; the run link stays live, which is the recourse. +- **Cross-host orphan pulsing.** The identity self-check only reclaims a + crash-leftover orphan when the next same-PR round reuses the orphan's + host; the pool is multi-host with no per-PR runner affinity, so the + general case leaves the orphan passing its own check and re-PATCHing + its stale body onto the shared status comment until the 12h age cap — + alternating with live rounds' bodies and overwriting terminal text + within one interval of finalize. Accepted: the damage is confined to + the comment's liveness text (no kill or token reach), it is bounded by + the age cap, and the alternative — a cross-run kill keyed on a WORKDIR + pid — reopens the untrusted-kill-target hole. Tightening the age cap + toward the 330-minute job timeout would shrink every orphan window. ## Open questions From 32ac987a8daa05f96ceb81f036761e103013e260 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sun, 23 Aug 2026 18:01:47 +0000 Subject: [PATCH 04/19] fix(autofix): close the heartbeat's token paths at the kill and the gh call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 review findings on the round heartbeat, each reproduced on the pool's host class before the fix: - The killers killed the loop's pid and process group, but each tick's `timeout 60 gh` subtree runs in its OWN process group (coreutils timeout default) under the loop's setsid session — a kill landing mid-tick left it alive holding the PAT for up to 60s (witnessed: group+pid kill leaves the subtree reparented with the canary token in /proc//environ). All three killers now also kill the session; the behavioral suite pins both the escape and the fix. - The step's gh calls and every loop tick ran gh without the af-112 hermetic pins, so a planted http_unix_socket in the shared HOME's ~/.config/gh received the tick's Authorization header WITH the PAT (witnessed with the pool's gh). run_loop now mints a fresh GH_CONFIG_DIR and drops planted GH_TOKEN/GH_ENTERPRISE_TOKEN itself, post_status takes the same preamble before its first gh call, and the fail-fast check accepts the step-level GITHUB_TOKEN only. - The default age cap drops from 12h to just past the 330-minute job envelope: only a crash-leftover orphan ever reaches it, and it bounds how long that orphan holds the PAT in /proc//environ — readable by any same-UID host process, as a sibling-read probe on this host class confirms (ptrace_scope gates attach, not this read). The af-148 record and the design doc now state that real residual profile instead of "liveness-text corruption only". --- .github/scripts/autofix-status-heartbeat.sh | 48 ++++-- .../scripts/autofix-status-heartbeat.test.mjs | 156 +++++++++++++++++- .github/workflows/qwen-autofix.md | 61 +++++-- .github/workflows/qwen-autofix.yml | 36 +++- docs/design/autofix-round-heartbeat.md | 52 ++++-- scripts/tests/qwen-autofix-workflow.test.js | 45 +++++ 6 files changed, 343 insertions(+), 55 deletions(-) diff --git a/.github/scripts/autofix-status-heartbeat.sh b/.github/scripts/autofix-status-heartbeat.sh index cb7726ed291..641651489d1 100644 --- a/.github/scripts/autofix-status-heartbeat.sh +++ b/.github/scripts/autofix-status-heartbeat.sh @@ -19,21 +19,28 @@ # HB_CAP, HB_URL, HB_WORKDIR, HB_START_EPOCH; NOW_EPOCH overrides the # clock for tests. loop additionally needs: HB_REPO, HB_COMMENT_ID, and # GITHUB_TOKEN for gh; HB_INTERVAL_SECONDS (default 600) and -# HB_MAX_AGE_SECONDS (default 43200) bound the pulse. +# HB_MAX_AGE_SECONDS (default 20400) bound the pulse. # # Kill contract: the loop writes heartbeat.pid (diagnostics + its own # self-exit check), checks heartbeat-stop, and exits on either signal or # when its own age cap trips. The killers target the pid the launch # recorded in EXPRESSION CONTEXT (steps.post_status.outputs.heartbeat_pid) # — WORKDIR is sandbox-writable, so no WORKDIR file is ever read as a kill -# target. The round's verification gate kills the loop before running any -# branch code on the host; finalize and the always() cleanup kill again. +# target — and kill the pid, its process group, AND its whole session: +# each tick's `timeout 60 gh` subtree runs in its OWN process group +# (coreutils timeout default) under the loop's setsid session, so a +# group/pid kill alone leaves it alive holding the PAT for up to 60s. The +# round's verification gate kills the loop before running any branch code +# on the host; finalize and the always() cleanup kill again. # # PAT note: the loop holds the bot PAT in its environment. Its lifetime is # bounded to the sandboxed agent phase — the agent executes PR content only # inside the docker sandbox there, so no fork code runs on the host beside # this loop; the verification gate ends the loop BEFORE the first step that -# runs branch code on the host. See af-148 for the trade. +# runs branch code on the host. Every gh call additionally runs under the +# af-112 hermetic pins (pinned GH_HOST, dropped GH_TOKEN/GH_ENTERPRISE_TOKEN, +# fresh GH_CONFIG_DIR), so a transport reroute planted in the shared HOME's +# gh config cannot intercept the token. See af-148 for the trade. # -e is deliberately absent: the (( ... < 0 )) clamp guards exit non-zero # on a false test and are load-bearing here. pipefail matches the sibling @@ -80,23 +87,42 @@ run_loop() { # that never pulses — the exact "healthy round looks dead" failure this # feature eliminates. Fail fast instead. require HB_REPO HB_COMMENT_ID HB_WORKDIR HB_ROUND HB_CAP HB_URL HB_START_EPOCH - # gh auth rides on GITHUB_TOKEN (or GH_TOKEN): a launch without it must - # fail fast like any other missing input, not degrade to an immortal - # loop that logs "PATCH failed" every tick and never pulses. - [[ -n "${GITHUB_TOKEN:-}${GH_TOKEN:-}" ]] || { - echo "autofix-status-heartbeat: GITHUB_TOKEN (or GH_TOKEN) is required" >&2 + # gh auth rides on the step-level GITHUB_TOKEN only: the hermetic pins + # below drop any planted GH_TOKEN/GH_ENTERPRISE_TOKEN (a planted channel + # must not outrank the inline token), so accepting them here would admit + # a launch the pins then leave credential-less — an immortal loop logging + # "PATCH failed" every tick and never pulsing. Fail fast instead. + [[ -n "${GITHUB_TOKEN:-}" ]] || { + echo "autofix-status-heartbeat: GITHUB_TOKEN is required" >&2 exit 2 } + # Hermetic pins for every gh call this loop makes (the af-112 doctrine): + # pinned host, planted tokens dropped, and a fresh empty GH_CONFIG_DIR + # instead of the default ~/.config/gh on the shared attacker-writable + # HOME — its config.yml can carry http_unix_socket, which would deliver + # the tick's Authorization header (the bot PAT) to a planted listener. + local gh_config_dir + if ! gh_config_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/autofix-gh-config.XXXXXX")"; then + echo "autofix-status-heartbeat: could not create a gh config dir" >&2 + exit 2 + fi + export GH_HOST=github.com + unset GH_ENTERPRISE_TOKEN GH_TOKEN + export GH_CONFIG_DIR="${gh_config_dir}" # Self-detach from the launching step: log to WORKDIR and never hold the # step's pipes, or the step would never report completion. exec >> "${HB_WORKDIR}/heartbeat.log" 2>&1 < /dev/null echo "$$" > "${HB_WORKDIR}/heartbeat.pid" local interval="${HB_INTERVAL_SECONDS:-600}" - local max_age="${HB_MAX_AGE_SECONDS:-43200}" + # Just past the 330-minute job envelope: a live round's loop dies at the + # gate or finalize well inside the job, so only a crash-leftover orphan + # ever reaches the cap — and the cap bounds how long that orphan holds + # the PAT in /proc//environ, so it stays tight. + local max_age="${HB_MAX_AGE_SECONDS:-20400}" # Numeric guards: a malformed or zero override must degrade to the # defaults, never into a sleep-less busy loop hammering the API. [[ "${interval}" =~ ^[1-9][0-9]*$ ]] || interval=600 - [[ "${max_age}" =~ ^[1-9][0-9]*$ ]] || max_age=43200 + [[ "${max_age}" =~ ^[1-9][0-9]*$ ]] || max_age=20400 local start="${HB_START_EPOCH}" echo "$(date -u +%FT%TZ) heartbeat started: comment ${HB_COMMENT_ID} interval ${interval}s max_age ${max_age}s" while :; do diff --git a/.github/scripts/autofix-status-heartbeat.test.mjs b/.github/scripts/autofix-status-heartbeat.test.mjs index 06b5ecdffbd..ff37f47d63f 100644 --- a/.github/scripts/autofix-status-heartbeat.test.mjs +++ b/.github/scripts/autofix-status-heartbeat.test.mjs @@ -35,7 +35,9 @@ afterEach(() => { }); // A fake `gh` that records every invocation (NUL-separated argv, one file -// per call) and fails when GH_FAIL=1. +// per call), logs the gh-visible env channels the hermetic-pin witness +// asserts on, fails when GH_FAIL=1, and holds the tick in flight for +// GH_SLEEP_SECONDS (default 0) so kill-topology tests can land mid-tick. function fakeGhBin(dir) { const bin = join(dir, 'bin'); const records = join(dir, 'calls'); @@ -49,7 +51,11 @@ function fakeGhBin(dir) { 'set -u', 'n=$(( $(ls -1 "${GH_RECORD_DIR}" | wc -l) + 1 ))', 'for a in "$@"; do printf \'%s\\0\' "$a"; done > "${GH_RECORD_DIR}/call-${n}"', + "printf 'GH_HOST=%s GH_CONFIG_DIR=%s GH_TOKEN=%s GH_ENTERPRISE_TOKEN=%s\\n' \\", + ' "${GH_HOST:-}" "${GH_CONFIG_DIR:-}" "${GH_TOKEN:-}" "${GH_ENTERPRISE_TOKEN:-}" \\', + ' >> "${GH_RECORD_DIR}/gh-env.log"', '[ "${GH_FAIL:-0}" = "1" ] && exit 1', + 'sleep "${GH_SLEEP_SECONDS:-0}"', 'exit 0', ].join('\n'), ); @@ -364,7 +370,7 @@ describe('autofix-status-heartbeat loop', () => { ); assert.ok(ok, 'the loop must start and log its parameters'); const logText = readFileSync(join(workdir, 'heartbeat.log'), 'utf8'); - assert.match(logText, /interval 600s max_age 43200s/); + assert.match(logText, /interval 600s max_age 20400s/); } finally { killGroup(child); } @@ -424,6 +430,7 @@ describe('autofix-status-heartbeat loop', () => { const { env, workdir } = loopEnv(dir, gh); delete env.GITHUB_TOKEN; delete env.GH_TOKEN; + delete env.GH_ENTERPRISE_TOKEN; const child = spawn('bash', [script, 'loop'], { env, stdio: ['ignore', 'ignore', 'pipe'], @@ -435,12 +442,38 @@ describe('autofix-status-heartbeat loop', () => { }); const code = await awaitExit(child, 8000); assert.equal(code, 2); - assert.match(stderr, /GITHUB_TOKEN \(or GH_TOKEN\) is required/); + assert.match(stderr, /GITHUB_TOKEN is required/); // Fail fast BEFORE registering anything: no pid file, no log. assert.ok(!existsSync(join(workdir, 'heartbeat.pid'))); assert.ok(!existsSync(join(workdir, 'heartbeat.log'))); }); + it('refuses a GH_TOKEN-only launch — the pins drop that channel before gh', async () => { + // The hermetic pins unset GH_TOKEN/GH_ENTERPRISE_TOKEN before any gh + // call, so accepting them at the fail-fast check would admit a launch + // the pins then leave credential-less — an immortal loop logging + // "PATCH failed" every tick and never pulsing. Auth rides on the + // step-level GITHUB_TOKEN only. + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env, workdir } = loopEnv(dir, gh); + delete env.GITHUB_TOKEN; + env.GH_TOKEN = 'planted'; + const child = spawn('bash', [script, 'loop'], { + env, + stdio: ['ignore', 'ignore', 'pipe'], + detached: true, + }); + let stderr = ''; + child.stderr.on('data', (d) => { + stderr += d; + }); + const code = await awaitExit(child, 8000); + assert.equal(code, 2); + assert.match(stderr, /GITHUB_TOKEN is required/); + assert.ok(!existsSync(join(workdir, 'heartbeat.pid'))); + }); + it('refuses to loop when a BODY var is missing — no immortal unpulsing loop', async () => { // A launch missing a body var (HB_ROUND here) must fail fast, not // produce a loop that lives to the age cap logging "body composition @@ -510,4 +543,121 @@ describe('autofix-status-heartbeat loop', () => { killGroup(child); } }); + + it('pins gh hermetically for every tick — planted channels never reach it', async () => { + // The loop holds the bot PAT in env and calls gh on a shared host: a + // planted http_unix_socket in the default ~/.config/gh would deliver + // the tick's Authorization header to a planted listener, and a planted + // GH_TOKEN would outrank the step-level GITHUB_TOKEN. Witness the + // af-112 pins from the tick's own point of view: the fake gh records + // what it actually sees. + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const poisonedConfig = join(dir, 'poisoned-gh-config'); + const runnerTemp = join(dir, 'runner-temp'); + mkdirSync(poisonedConfig, { recursive: true }); + mkdirSync(runnerTemp, { recursive: true }); + const { env } = loopEnv(dir, gh, { + GH_HOST: 'evil.example', + GH_TOKEN: 'planted-token', + GH_ENTERPRISE_TOKEN: 'planted-enterprise-token', + GH_CONFIG_DIR: poisonedConfig, + RUNNER_TEMP: runnerTemp, + }); + const child = startLoop(env); + try { + const ok = await waitFor(() => readCalls(gh.records).length >= 1, 8000); + assert.ok(ok, 'expected at least one PATCH call'); + const lines = readFileSync(join(gh.records, 'gh-env.log'), 'utf8') + .trim() + .split('\n'); + assert.ok(lines.length >= 1, 'every tick must log its gh-visible env'); + for (const line of lines) { + assert.ok(line.startsWith('GH_HOST=github.com '), line); + const cfg = line.match(/GH_CONFIG_DIR=(\S*) /)?.[1]; + assert.ok(cfg, line); + assert.ok(cfg.startsWith(runnerTemp), line); + assert.ok(existsSync(cfg), `minted gh config dir must exist: ${cfg}`); + assert.ok(line.endsWith(' GH_TOKEN= GH_ENTERPRISE_TOKEN='), line); + assert.ok(!line.includes('planted'), line); + assert.ok(!line.includes('evil.example'), line); + assert.ok(!line.includes(poisonedConfig), line); + } + } finally { + killGroup(child); + } + }); + + // The mid-tick kill-topology witness needs coreutils `timeout` (which + // gives the tick its own process group) and procps pkill/pgrep (the + // session kill and its oracle); hosts without them still carry the + // pinned statement list in the workflow test. + const haveSessionKillTools = + spawnSync('bash', [ + '-c', + 'command -v timeout >/dev/null && command -v pkill >/dev/null && command -v pgrep >/dev/null', + ]).status === 0; + + function processAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + } + + it( + 'a session kill empties the whole loop even when it lands mid-tick', + { + skip: haveSessionKillTools + ? false + : 'requires coreutils timeout + procps pkill/pgrep', + }, + async () => { + // Each tick's `timeout 60 gh` subtree runs in its OWN process group + // (coreutils timeout default) inside the loop's setsid session, so a + // group+pid kill landing mid-tick leaves it alive holding the token + // for up to 60s — the witness that drove the session kill at every + // killer. Part 1 proves the escape, part 2 proves the fix. The real + // (unshimmed) timeout runs — no fake timeout on PATH here — and a + // slow fake gh holds the tick in flight. + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env } = loopEnv(dir, gh, { GH_SLEEP_SECONDS: '15' }); + const child = startLoop(env); + const pid = child.pid; + try { + const inFlight = await waitFor( + () => readCalls(gh.records).length >= 1, + 8000, + ); + assert.ok(inFlight, 'the slow gh must put a tick in flight'); + // Part 1 — the defect: group+pid kills alone leave the tick + // subtree alive in the loop's session. + spawnSync('bash', [ + '-c', + `kill -- -${pid} 2>/dev/null || true; kill ${pid} 2>/dev/null || true`, + ]); + const escaped = await waitFor( + () => + !processAlive(pid) && + spawnSync('pgrep', ['-s', String(pid)]).status === 0, + 5000, + ); + assert.ok(escaped, 'the mid-tick subtree must escape a group+pid kill'); + // Part 2 — the fix: the session kill reaches everything sharing + // the loop's session. + spawnSync('bash', ['-c', `pkill -TERM -s ${pid} 2>/dev/null || true`]); + const emptied = await waitFor( + () => spawnSync('pgrep', ['-s', String(pid)]).status !== 0, + 5000, + ); + assert.ok(emptied, 'the session kill must empty the loop session'); + } finally { + spawnSync('bash', ['-c', `pkill -KILL -s ${pid} 2>/dev/null || true`]); + killGroup(child); + } + }, + ); }); diff --git a/.github/workflows/qwen-autofix.md b/.github/workflows/qwen-autofix.md index f66c309a205..c3ad5442d6d 100644 --- a/.github/workflows/qwen-autofix.md +++ b/.github/workflows/qwen-autofix.md @@ -3750,12 +3750,14 @@ for the whole round and review proved that false — the gate script says plainly that the branch's code runs there as the runner user. A PAT-holding host process concurrent with host-side branch code is a /proc//environ read away -from leaking the token (same UID; only the pool's ptrace -scope stands between, and it is not pinned anywhere), so -the pulse covers the agent step — the longest, sandboxed -phase — and dies before the gate. The comment holds its -last tick through gate/repair; finalize flips the terminal -text. +from leaking the token (same UID; the pool's ptrace scope +does NOT stand between — it gates ptrace attach, not this +direct same-UID read; witnessed on the pool's host class: +a non-descendant sibling extracted an environ canary with +ptrace_scope=1), so the pulse covers the agent step — the +longest, sandboxed phase — and dies before the gate. The +comment holds its last tick through gate/repair; finalize +flips the terminal text. KILL TARGETS travel through EXPRESSION CONTEXT: post_status records $! as heartbeat_pid, and the gate / finalize / the @@ -3779,10 +3781,27 @@ PAT TRADE, chosen deliberately within that lifetime: the loop holds the bot PAT in env — a temporal overlap the "THIS step holds no PAT" rule (af-126) otherwise avoids. Accepted because within the agent phase the token never -touches disk, the only host processes concurrent with the -loop are trusted (run-agent.mjs, the bundled CLI), and the -overlap ends deterministically at the gate. The alternative -— heartbeat from the schedule scan or a watcher job — lands +touches disk and the only host processes concurrent with +the loop are trusted (run-agent.mjs, the bundled CLI) — +plus two hardenings that keep the overlap honest. KILL: +the overlap ends at the gate only if the kill covers the +loop's whole SESSION — each tick's `timeout 60 gh` subtree +runs in its OWN process group (coreutils timeout default) +under the loop's setsid session, so a group/pid kill +landing mid-tick leaves it alive holding the PAT for up to +60s (witnessed on the pool's host class); all three +killers therefore kill pid, group, AND session. PINS: the +step's gh calls and every tick run under the af-112 +hermetic pins (pinned GH_HOST, dropped +GH_TOKEN/GH_ENTERPRISE_TOKEN, fresh GH_CONFIG_DIR) — +without them the default ~/.config/gh on the shared +attacker-writable HOME can carry http_unix_socket, and a +planted same-UID listener then receives the tick's +Authorization header WITH the PAT (witnessed with the +pool's gh): exfil with no orphan, no /proc read and no +kill miss, inside the legitimate overlap, where none of +the trade arguments above reaches. The alternative — +heartbeat from the schedule scan or a watcher job — lands every ~40-70 min in this repo (af-027) and would re-derive comment id, run identity and liveness remotely: too slow and too much machinery for a pulse. @@ -3798,15 +3817,23 @@ HOST-LOCAL: it fires only when the next same-PR round reuses the orphan's host. Cross-host — the fleet's general case, no per-PR runner affinity — nothing rewrites the file, the orphan keeps passing its own identity check, and it pulses -its stale body onto the shared comment until the 12h age cap -(accepted residual risk: liveness-text corruption only, -bounded by the cap; a cross-run kill keyed on a WORKDIR pid -would reopen the untrusted-kill-target hole). Reading the file to -self-identify is safe (the loop never kills anything); the +its stale body onto the shared comment until the age cap +(accepted residual risk, with its REAL profile: the orphan +holds the bot PAT in /proc//environ until the cap, +and any same-UID process on that host — including another +PR's round running its gate's host-side build/tests — +reads it directly; the pool's ptrace scope does not gate +this read, as witnessed above. The cap therefore sits just +past the 330-minute job envelope — only a crash orphan +ever reaches it, so the cap IS the bound on its token +window. A cross-run kill keyed on a WORKDIR pid would +reopen the untrusted-kill-target hole, so reclamation +stays host-local). Reading the file to self-identify is +safe (the loop never kills anything); the killers never read it, which is what keeps the untrusted-target hole closed — no cross-run kill. The other -bounds: a heartbeat-stop marker, or a 12h age cap far past -the 330-minute job timeout; each tick's gh call is +bounds: a heartbeat-stop marker, or the age cap just past +the 330-minute job envelope; each tick's gh call is additionally wrapped in a 60s timeout so a black-holed connection cannot stall the loop past the cap. Killers that run in-round touch the stop marker BEFORE killing so a diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 2741a95373a..1f2d2208f8c 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -4727,6 +4727,17 @@ jobs: HEARTBEAT_SHA256: '${{ steps.stage.outputs.heartbeat_sha256 }}' run: |- set -uo pipefail + # The gh calls in this step (deep-link lookup, comment upsert) and + # the heartbeat loop launched here all carry the bot PAT: take the + # af-112 hermetic pins BEFORE the first call — pinned host, planted + # tokens dropped, and a fresh empty GH_CONFIG_DIR instead of the + # default ~/.config/gh on the shared attacker-writable HOME (its + # config.yml http_unix_socket would deliver the Authorization + # header — the PAT — to a planted same-UID listener). + # Full rationale → qwen-autofix.md#af-112 + export GH_HOST=github.com + unset GH_ENTERPRISE_TOKEN GH_TOKEN + export GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")" MARKER='' ROUND_DISPLAY="${EFFECTIVE_ROUND:-${ROUND}}" # ROUND counts rounds already DONE; every other message numbers the @@ -4958,14 +4969,20 @@ jobs: # overlap is bounded to the sandboxed agent phase, never the # host-side gate. Kill from the pid the launch recorded in # expression context; WORKDIR files are sandbox-writable and are - # never read as kill targets. Absolute-path/builtin-only command - # words, same doctrine as the gate body below. + # never read as kill targets. The session kill covers a kill + # landing MID-TICK: each tick's `timeout 60 gh` subtree sits in + # its OWN process group (coreutils timeout default), which the + # group+pid kills miss — but the loop owns its session via + # setsid, so -s targets exactly this round's tree. + # Absolute-path/builtin-only command words, same doctrine as the + # gate body below. # Full rationale → qwen-autofix.md#af-148 /usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}" if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then builtin kill -- -"${HB_PID}" 2> /dev/null || true builtin kill "${HB_PID}" 2> /dev/null || true + /usr/bin/pkill -TERM -s "${HB_PID}" 2> /dev/null || true fi # The gate decides whether the PAT push runs, and the first pass # executes the branch's own build/test on the host before the @@ -6098,15 +6115,18 @@ jobs: # is the belt to its braces (a missed kill there must not outlive # the round). Kill target comes from expression context — a pid # read from a WORKDIR file would be an untrusted kill target - # (WORKDIR is sandbox-writable). The stop marker ends the loop on - # its next self-check even if BOTH kills miss, and the sleep lets - # an already-dispatched tick PATCH land before the terminal text - # goes up. Full rationale → qwen-autofix.md#af-148 + # (WORKDIR is sandbox-writable). The session kill covers a kill + # landing mid-tick (the tick's timeout/gh subtree sits in its own + # process group under the loop's session). The stop marker ends + # the loop on its next self-check even if the kills miss, and the + # sleep lets an already-dispatched tick PATCH land before the + # terminal text goes up. Full rationale → qwen-autofix.md#af-148 touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}" if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then kill -- -"${HB_PID}" 2>/dev/null || true kill "${HB_PID}" 2>/dev/null || true + pkill -TERM -s "${HB_PID}" 2>/dev/null || true sleep 2 fi MARKER='' @@ -6158,11 +6178,13 @@ jobs: run: |- # Last heartbeat kill (the gate and finalize already did this on # their paths) from the expression-context pid — never from a - # WORKDIR file, which is sandbox-writable. + # WORKDIR file, which is sandbox-writable. The session kill + # covers the mid-tick subtree, same as its twins. # Full rationale → qwen-autofix.md#af-148 HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}" if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then kill -- -"${HB_PID}" 2>/dev/null || true kill "${HB_PID}" 2>/dev/null || true + pkill -TERM -s "${HB_PID}" 2>/dev/null || true fi rm -rf "${WORKDIR}" diff --git a/docs/design/autofix-round-heartbeat.md b/docs/design/autofix-round-heartbeat.md index 654e0902890..201819a9e6a 100644 --- a/docs/design/autofix-round-heartbeat.md +++ b/docs/design/autofix-round-heartbeat.md @@ -75,7 +75,11 @@ orphan loops unacceptable): gate/repair and finalize flips the terminal text. 2. Kill targets travel through **expression context**: the launch records `$!` as a `heartbeat_pid` step output, and the gate / finalize / - cleanup kill that value. A pid read from a WORKDIR file would be an + cleanup kill that value — the pid, its process group, AND its session: + each tick's `timeout 60 gh` subtree runs in its own process group + (coreutils `timeout` default) under the loop's setsid session, so a + group/pid kill alone leaves it alive holding the PAT for up to 60s. + A pid read from a WORKDIR file would be an untrusted kill target — the agent's docker sandbox mounts the host `/tmp` on the same path and runs as the same user, so branch code the agent executes can plant any value there. The on-disk @@ -91,7 +95,7 @@ orphan loops unacceptable): file, and the loop self-exits at its next identity self-check when the next round reuses the orphan's host; cross-host, nothing rewrites the pid file, so a crash-leftover orphan keeps pulsing its stale body - until the 12h age cap — alternating with the live round's bodies and + until the age cap — alternating with the live round's bodies and overwriting later rounds' terminal text within one interval of finalize. Worst same-host case: one stale "working" tick already past its identity check, re-PATCHed by the new round. The cross-host @@ -102,8 +106,11 @@ orphan loops unacceptable): `heartbeat.pid` at the same path, which an existence check would let the orphan pass (reading the file here is safe: the loop only self-identifies, it never kills anything); stop if `heartbeat-stop` - exists, or at a hard age cap (12h, far beyond the 330-minute job - timeout); each tick's `gh` call is wrapped in `timeout 60` so a + exists, or at a hard age cap set just past the 330-minute job + envelope (a live round's loop dies at the gate or finalize well inside + the job, so only a crash orphan ever reaches the cap — and the cap + bounds how long that orphan holds the PAT in `/proc//environ`); + each tick's `gh` call is wrapped in `timeout 60` so a black-holed connection cannot stall the loop past the cap. The kill logic is inline in the yml (4-6 lines each), **not** a script @@ -162,10 +169,17 @@ comment is never worse than today. runs the branch's own build/tests ON THE HOST as the runner user, and a same-UID `/proc//environ` read from that code would expose the token. The overlap is therefore bounded to the sandboxed agent phase: - the gate kills the loop before any host-side branch code runs - (lifetime rule 1). Within that phase the token never touches disk and - the only concurrent host processes are trusted (run-agent.mjs, the - bundled CLI). The alternative that avoids the overlap entirely — + the gate kills the loop's whole session before any host-side branch + code runs (lifetime rule 1; the kill covers the session because an + in-flight tick's `timeout 60 gh` subtree sits in its own process group + under the loop's session). Within that phase the token never touches + disk, the only concurrent host processes are trusted (run-agent.mjs, + the bundled CLI), and the step's gh calls and every tick run under the + af-112 hermetic pins (pinned host, planted tokens dropped, fresh + `GH_CONFIG_DIR`) — a planted `http_unix_socket` in the shared HOME's + gh config would otherwise deliver the tick's Authorization header to a + same-UID listener inside the legitimate overlap. The alternative that + avoids the overlap entirely — heartbeat from the schedule scan or a watcher job — was rejected on cadence and complexity (decision 2). The trade-off is recorded in the yml comment so future readers see it was chosen, not overlooked. @@ -224,16 +238,20 @@ comment is never worse than today. crash-leftover orphan when the next same-PR round reuses the orphan's host; the pool is multi-host with no per-PR runner affinity, so the general case leaves the orphan passing its own check and re-PATCHing - its stale body onto the shared status comment until the 12h age cap — + its stale body onto the shared status comment until the age cap — alternating with live rounds' bodies and overwriting terminal text - within one interval of finalize. Accepted: the damage is confined to - the comment's liveness text (no kill or token reach), it is bounded by - the age cap, and the alternative — a cross-run kill keyed on a WORKDIR - pid — reopens the untrusted-kill-target hole. Tightening the age cap - toward the 330-minute job timeout would shrink every orphan window. + within one interval of finalize. Accepted, with its real profile: the + orphan holds the bot PAT in `/proc//environ` until the cap, and + any same-UID process on that host — including another PR's round + running its gate's host-side build/tests — reads it directly (the + pool's ptrace scope gates ptrace attach, not this read; witnessed on + the pool's host class). The cap therefore sits just past the + 330-minute job envelope, bounding the orphan's token window to roughly + one job duration; the alternative — a cross-run kill keyed on a + WORKDIR pid — reopens the untrusted-kill-target hole. ## Open questions -None blocking. Cadence (10 min) and age cap (12h) are repo-variable- -friendly constants but ship as literals until a reason to configure -appears. +None blocking. Cadence (10 min) and the age cap (just past the +330-minute job envelope) are repo-variable-friendly constants but ship +as literals until a reason to configure appears. diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index b529a68431e..fe8b94ffcb5 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -10650,6 +10650,9 @@ exit 1 [publishPrStep, 'GH_TOKEN="${GITHUB_TOKEN}" gh api user'], [pushAndReportStep, 'GH_TOKEN="${GITHUB_TOKEN}" gh api user'], [prepareStep, 'PR_LIVE="$(gh pr view'], + // The heartbeat's step: deep-link lookup, comment upsert, and the + // loop launch all carry the PAT; the first gh call is the deep link. + [postStatusCommentStep, 'JOB_ID="$(gh api'], ]) { const ghPin = step.indexOf('export GH_HOST=github.com'); expect(ghPin).toBeGreaterThan(-1); @@ -11487,6 +11490,11 @@ exit 1 'if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then', 'builtin kill -- -"${HB_PID}" 2> /dev/null || true', 'builtin kill "${HB_PID}" 2> /dev/null || true', + // The mid-tick cover: each tick's `timeout 60 gh` subtree runs in + // its OWN process group (coreutils timeout default) under the + // loop's setsid session, so the group+pid kills above miss a kill + // landing mid-tick and leave it holding the PAT for up to 60s. + '/usr/bin/pkill -TERM -s "${HB_PID}" 2> /dev/null || true', 'fi', ]; // Bash breaks words only on ASCII space/tab/newline: strip ASCII @@ -15736,6 +15744,26 @@ exit 1 expect(heartbeatScript).toContain('heartbeat-stop'); expect(heartbeatScript).toContain('HB_MAX_AGE_SECONDS'); expect(heartbeatScript).toContain('HB_INTERVAL_SECONDS'); + // The default age cap sits just past the 330-minute job envelope: a + // live round's loop dies at the gate or finalize well inside the job, + // so only a crash orphan reaches it — and the cap bounds how long that + // orphan holds the PAT in /proc//environ. A 12h default would + // reopen the window this cap exists to shrink. + expect(heartbeatScript).toContain('HB_MAX_AGE_SECONDS:-20400'); + expect(heartbeatScript).toContain('|| max_age=20400'); + // Every tick's gh call runs under the af-112 hermetic pins, minted + // inside the loop itself: a planted http_unix_socket in the shared + // HOME's gh config would otherwise deliver the tick's Authorization + // header (the PAT) to a planted same-UID listener. + expect(heartbeatScript).toContain('export GH_HOST=github.com'); + expect(heartbeatScript).toContain('unset GH_ENTERPRISE_TOKEN GH_TOKEN'); + expect(heartbeatScript).toContain( + 'export GH_CONFIG_DIR="${gh_config_dir}"', + ); + // Auth rides on the step-level GITHUB_TOKEN only: the pins drop any + // planted GH_TOKEN, so the fail-fast check must not admit it. + expect(heartbeatScript).toContain('GITHUB_TOKEN is required'); + expect(heartbeatScript).not.toContain('${GITHUB_TOKEN:-}${GH_TOKEN:-}'); // A failed PATCH skips one tick, never the pulse. expect(heartbeatScript).toContain('PATCH failed; continuing'); // The loop must not hold the launching step's pipes or the step never @@ -15891,6 +15919,23 @@ exit 1 expect(gateStep).toContain('builtin kill "${HB_PID}"'); expect(finalizeStatusCommentStep).toContain('kill "${HB_PID}"'); expect(cleanupStep).toContain('kill "${HB_PID}"'); + // A kill landing MID-TICK must reach the tick too: each tick's + // `timeout 60 gh` subtree runs in its OWN process group (coreutils + // timeout default) inside the loop's setsid session, so the group+pid + // kills alone leave it alive holding the PAT for up to 60s (witnessed + // on the pool's host class). Every killer therefore also kills the + // session — the gate's copy rides its statement-list pin above in the + // absolute-path form of its shadowing doctrine — and it must land + // where the others do: before the terminal PATCH (finalize) and before + // the workdir wipe (cleanup). + expect(finalizeStatusCommentStep).toContain('pkill -TERM -s "${HB_PID}"'); + expect(cleanupStep).toContain('pkill -TERM -s "${HB_PID}"'); + expect( + finalizeStatusCommentStep.indexOf('pkill -TERM -s "${HB_PID}"'), + ).toBeLessThan(finalizeStatusCommentStep.indexOf('--method PATCH')); + expect(cleanupStep.indexOf('pkill -TERM -s "${HB_PID}"')).toBeLessThan( + cleanupStep.indexOf('rm -rf "${WORKDIR}"'), + ); // Neither reset step carries a kill (a cross-run pid could only come // from the untrusted file class; the comments may still explain why), // and none of the kill sites EXECUTES the heartbeat script (the From 114af68f213a1f925f5553045a52979933eb4443 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sun, 23 Aug 2026 21:23:27 +0000 Subject: [PATCH 05/19] fix(autofix): degrade pre-merge rounds past the absent heartbeat script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 review findings on the round heartbeat: - [Critical] The staging cp of the heartbeat script and its digest echo carried no guard, but the script is NEW in this PR: the trusted base (pre-merge main) lacks it, so any run that resolves the workflow from the PR's own ref — pull_request labeled/unlabeled on an in-repo takeover-managed PR, or workflow_dispatch on its branch — checks out the trusted base and dies on the bare cp under the runner's -eo pipefail, killing the whole round instead of degrading (witnessed: the extracted stage step exits 1 with "cp: cannot stat" on the merge-base tree). Same rule as the upsert capture in the same step: the cp carries `|| true`, the digest records only when the copy exists, and post_status now guards the empty digest — falling back to the pre-PR inline body (byte-identical to it) and skipping the heartbeat launch instead of digest-checking and running a staged copy that was never staged. Witnessed on the fixed step: exit 0 on the merge-base tree with an empty digest, and the consumer probe renders the pre-PR body there while the digest arm renders the script body. - [Suggestion] The hermetic witness logged every gh-visible credential channel except GITHUB_TOKEN — the loop's sole credential since the round-3 fail-fast. The fake gh shim now logs it and the witness asserts it reaches gh; mutation probe: broadening the loop's unset to drop GITHUB_TOKEN kept the old suite green but fails the new assertion, while in production every tick would fail authentication. --- .../scripts/autofix-status-heartbeat.test.mjs | 12 +++++-- .github/workflows/qwen-autofix.yml | 32 +++++++++++++++---- scripts/tests/qwen-autofix-workflow.test.js | 31 ++++++++++++++---- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/.github/scripts/autofix-status-heartbeat.test.mjs b/.github/scripts/autofix-status-heartbeat.test.mjs index ff37f47d63f..fc8285dc67c 100644 --- a/.github/scripts/autofix-status-heartbeat.test.mjs +++ b/.github/scripts/autofix-status-heartbeat.test.mjs @@ -51,8 +51,8 @@ function fakeGhBin(dir) { 'set -u', 'n=$(( $(ls -1 "${GH_RECORD_DIR}" | wc -l) + 1 ))', 'for a in "$@"; do printf \'%s\\0\' "$a"; done > "${GH_RECORD_DIR}/call-${n}"', - "printf 'GH_HOST=%s GH_CONFIG_DIR=%s GH_TOKEN=%s GH_ENTERPRISE_TOKEN=%s\\n' \\", - ' "${GH_HOST:-}" "${GH_CONFIG_DIR:-}" "${GH_TOKEN:-}" "${GH_ENTERPRISE_TOKEN:-}" \\', + "printf 'GH_HOST=%s GH_CONFIG_DIR=%s GITHUB_TOKEN=%s GH_TOKEN=%s GH_ENTERPRISE_TOKEN=%s\\n' \\", + ' "${GH_HOST:-}" "${GH_CONFIG_DIR:-}" "${GITHUB_TOKEN:-}" "${GH_TOKEN:-}" "${GH_ENTERPRISE_TOKEN:-}" \\', ' >> "${GH_RECORD_DIR}/gh-env.log"', '[ "${GH_FAIL:-0}" = "1" ] && exit 1', 'sleep "${GH_SLEEP_SECONDS:-0}"', @@ -578,6 +578,14 @@ describe('autofix-status-heartbeat loop', () => { assert.ok(cfg, line); assert.ok(cfg.startsWith(runnerTemp), line); assert.ok(existsSync(cfg), `minted gh config dir must exist: ${cfg}`); + // GITHUB_TOKEN is the loop's SOLE credential channel now — witness + // the surviving channel reaches gh, not only that the planted ones + // do not: a scrub broadened to drop it would keep this suite green + // while every production tick fails authentication. + assert.ok( + line.includes(' GITHUB_TOKEN=fake'), + `the step-level GITHUB_TOKEN must reach gh: ${line}`, + ); assert.ok(line.endsWith(' GH_TOKEN= GH_ENTERPRISE_TOKEN='), line); assert.ok(!line.includes('planted'), line); assert.ok(!line.includes('evil.example'), line); diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 1f2d2208f8c..00a38eb7cac 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -3637,7 +3637,13 @@ jobs: cp .github/scripts/resolve-owning-packages.sh "${RUNNER_TEMP}/resolve-owning-packages.sh" cp .github/scripts/run-autofix-review-verification.sh "${RUNNER_TEMP}/run-autofix-review-verification.sh" cp .github/scripts/resanitize-git-config.sh "${RUNNER_TEMP}/resanitize-git-config.sh" - cp .github/scripts/autofix-status-heartbeat.sh "${RUNNER_TEMP}/autofix-status-heartbeat.sh" + # Absent from the trusted base until this PR merges, and this step + # runs under -e: a hard failure here would kill every pre-merge + # round — same rule as the upsert capture below. `|| true`, and the + # digest records only when the copy exists; an empty output reaches + # the consumer's own empty guard, which degrades to the pre-PR + # inline body. + cp .github/scripts/autofix-status-heartbeat.sh "${RUNNER_TEMP}/autofix-status-heartbeat.sh" 2> /dev/null || true # The staged copies' trusted-base provenance holds at cp time only: # RUNNER_TEMP is writable by the branch/agent code later steps run # on this host, so record each digest in GITHUB_OUTPUT — expression @@ -3646,7 +3652,9 @@ jobs: # Full rationale → qwen-autofix.md#af-111 echo "resanitize_sha256=$(sha256sum "${RUNNER_TEMP}/resanitize-git-config.sh" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" echo "verify_runner_sha256=$(sha256sum "${RUNNER_TEMP}/run-autofix-review-verification.sh" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" - echo "heartbeat_sha256=$(sha256sum "${RUNNER_TEMP}/autofix-status-heartbeat.sh" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" + if [[ -f "${RUNNER_TEMP}/autofix-status-heartbeat.sh" ]]; then + echo "heartbeat_sha256=$(sha256sum "${RUNNER_TEMP}/autofix-status-heartbeat.sh" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" + fi # The upsert script travels as CONTENT, not as a staged copy: it # runs in a clean child that reads it from this expression-context # output, so there is no agent-writable copy to protect and no @@ -4763,12 +4771,22 @@ jobs: # subcommand, so they cannot drift. The working tree is PR-branch # code by this point — run the staged copy, digest-verified from # expression context (same doctrine as resanitize-git-config.sh). + # An EMPTY digest means the script was absent from the trusted + # base at staging (pre-merge rounds of a PR whose workflow + # resolves from its own ref): degrade to the pre-PR inline body + # and skip the heartbeat — never fail the round. # Full rationale → qwen-autofix.md#af-148 - /usr/bin/echo "${HEARTBEAT_SHA256} ${RUNNER_TEMP}/autofix-status-heartbeat.sh" | /usr/bin/sha256sum -c - > /dev/null START_EPOCH="$(date +%s)" - BODY="$(HB_ROUND="${ROUND_DISPLAY}" HB_CAP="${MAX_ROUNDS}" HB_URL="${JOB_URL}" \ - HB_WORKDIR="${WORKDIR}" HB_START_EPOCH="${START_EPOCH}" \ - bash --norc "${RUNNER_TEMP}/autofix-status-heartbeat.sh" body)" + if [[ -n "${HEARTBEAT_SHA256}" ]]; then + /usr/bin/echo "${HEARTBEAT_SHA256} ${RUNNER_TEMP}/autofix-status-heartbeat.sh" | /usr/bin/sha256sum -c - > /dev/null + BODY="$(HB_ROUND="${ROUND_DISPLAY}" HB_CAP="${MAX_ROUNDS}" HB_URL="${JOB_URL}" \ + HB_WORKDIR="${WORKDIR}" HB_START_EPOCH="${START_EPOCH}" \ + bash --norc "${RUNNER_TEMP}/autofix-status-heartbeat.sh" body)" + else + BODY="$(printf '%s\n\n🔄 **AutoFix is working on this PR** — round %s/%s. [Watch live progress](%s); this round posts its report here when it finishes.\n\n
\n中文说明\n\n🔄 **AutoFix 正在处理此 PR** —— 第 %s/%s 轮。[查看实时进度](%s);本轮结束后会在此发布报告。\n\n
' \ + "${MARKER}" "${ROUND_DISPLAY}" "${MAX_ROUNDS}" "${RUN_URL}" \ + "${ROUND_DISPLAY}" "${MAX_ROUNDS}" "${RUN_URL}")" + fi STATUS_ID="$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate | jq -rs --arg m "${MARKER}" --arg ab "${AUTOFIX_BOT}" \ '[ .[][] | select((.user.login // "") == $ab) @@ -4796,7 +4814,7 @@ jobs: # WORKDIR is sandbox-writable, so no WORKDIR file is ever read # as a kill target. Full rationale → qwen-autofix.md#af-148 HEARTBEAT_PID='' - if [[ -n "${STATUS_ID}" ]]; then + if [[ -n "${STATUS_ID}" && -n "${HEARTBEAT_SHA256}" ]]; then HB_REPO="${REPO}" HB_COMMENT_ID="${STATUS_ID}" \ HB_ROUND="${ROUND_DISPLAY}" HB_CAP="${MAX_ROUNDS}" HB_URL="${JOB_URL}" \ HB_WORKDIR="${WORKDIR}" HB_START_EPOCH="${START_EPOCH}" \ diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index fe8b94ffcb5..9352768ae2a 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -15773,16 +15773,24 @@ exit 1 // Staging: the working tree is PR-branch code by post_status, so the // script travels as a trusted-base staged copy with a digest recorded - // in expression context (the af-111 doctrine). + // in expression context (the af-111 doctrine). The script itself is NEW + // in this PR, so the trusted base (pre-merge main) lacks it: the cp and + // the digest echo carry the same guard the upsert capture below uses — + // `|| true` and record-only-if-present — because a bare cp exits this + // -e step (killing every pre-merge round) on any run whose workflow + // resolves from the PR's own ref, and an empty digest degrades the + // consumer instead (witnessed on the merge-base tree). const stageStep = reviewAddressJob.match( /- name: 'Stage trusted schema gate and agent runner'[\s\S]*?(?=\n {6}- name: ')/, )?.[0] ?? ''; expect(stageStep).toContain( - 'cp .github/scripts/autofix-status-heartbeat.sh "${RUNNER_TEMP}/autofix-status-heartbeat.sh"', + 'cp .github/scripts/autofix-status-heartbeat.sh "${RUNNER_TEMP}/autofix-status-heartbeat.sh" 2> /dev/null || true', ); expect(stageStep).toContain( - 'echo "heartbeat_sha256=$(sha256sum "${RUNNER_TEMP}/autofix-status-heartbeat.sh" | cut -d\' \' -f1)" >> "${GITHUB_OUTPUT}"', + 'if [[ -f "${RUNNER_TEMP}/autofix-status-heartbeat.sh" ]]; then\n' + + ' echo "heartbeat_sha256=$(sha256sum "${RUNNER_TEMP}/autofix-status-heartbeat.sh" | cut -d\' \' -f1)" >> "${GITHUB_OUTPUT}"\n' + + ' fi', ); expect(postStatusCommentStep).toContain( "HEARTBEAT_SHA256: '${{ steps.stage.outputs.heartbeat_sha256 }}'", @@ -15796,6 +15804,16 @@ exit 1 expect(postStatusCommentStep.indexOf('sha256sum -c')).toBeLessThan( postStatusCommentStep.indexOf('autofix-status-heartbeat.sh" body'), ); + // Consumer side of the same guard: an empty digest (script absent from + // the trusted base) degrades to the pre-PR inline body and skips the + // heartbeat, instead of digest-checking and running a staged copy that + // was never staged. + expect(postStatusCommentStep).toContain( + 'if [[ -n "${HEARTBEAT_SHA256}" ]]; then', + ); + expect(postStatusCommentStep).toContain( + '🔄 **AutoFix is working on this PR** — round %s/%s.', + ); // Deep link: attempt-scoped jobs listing, PR number enters jq as DATA // (--arg, never interpolation), and any lookup failure keeps the run @@ -15860,13 +15878,14 @@ exit 1 // Launch: detached (setsid + self-redirected output), gated on a // comment that actually exists (the gate wraps the launch itself, not - // just the earlier PATCH-or-create), carrying the round identity and - // recording the pid for the killers in EXPRESSION CONTEXT. + // just the earlier PATCH-or-create) AND on a staged script (an empty + // digest means the trusted base lacks it), carrying the round identity + // and recording the pid for the killers in EXPRESSION CONTEXT. expect(postStatusCommentStep).toContain( 'setsid bash --norc "${RUNNER_TEMP}/autofix-status-heartbeat.sh" loop &', ); expect(postStatusCommentStep).toContain( - 'if [[ -n "${STATUS_ID}" ]]; then\n HB_REPO=', + 'if [[ -n "${STATUS_ID}" && -n "${HEARTBEAT_SHA256}" ]]; then\n HB_REPO=', ); expect(postStatusCommentStep).toContain('HB_COMMENT_ID="${STATUS_ID}"'); expect(postStatusCommentStep).toContain('HB_START_EPOCH="${START_EPOCH}"'); From b34aae0b570a6bd79b81f77eb46bdfd7795c2d71 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 24 Aug 2026 01:59:05 +0000 Subject: [PATCH 06/19] fix(autofix): pin the heartbeat's command resolution off the plantable PATH --- .github/scripts/autofix-status-heartbeat.sh | 22 ++++- .../scripts/autofix-status-heartbeat.test.mjs | 87 +++++++++++++++++-- .github/workflows/qwen-autofix.md | 25 ++++-- .github/workflows/qwen-autofix.yml | 21 +++-- scripts/tests/qwen-autofix-workflow.test.js | 59 +++++++++++-- 5 files changed, 186 insertions(+), 28 deletions(-) diff --git a/.github/scripts/autofix-status-heartbeat.sh b/.github/scripts/autofix-status-heartbeat.sh index 641651489d1..0713b0043c9 100644 --- a/.github/scripts/autofix-status-heartbeat.sh +++ b/.github/scripts/autofix-status-heartbeat.sh @@ -17,9 +17,11 @@ # # Environment (both): HB_ROUND (display round, already +1'd by the step), # HB_CAP, HB_URL, HB_WORKDIR, HB_START_EPOCH; NOW_EPOCH overrides the -# clock for tests. loop additionally needs: HB_REPO, HB_COMMENT_ID, and -# GITHUB_TOKEN for gh; HB_INTERVAL_SECONDS (default 600) and -# HB_MAX_AGE_SECONDS (default 20400) bound the pulse. +# clock for tests. loop additionally needs: HB_REPO, HB_COMMENT_ID, +# GITHUB_TOKEN for gh, and TRUSTED_PATH (the launcher's stage-time PATH +# capture the tick re-pins; a launch without it fails fast); +# HB_INTERVAL_SECONDS (default 600) and HB_MAX_AGE_SECONDS (default +# 20400) bound the pulse. # # Kill contract: the loop writes heartbeat.pid (diagnostics + its own # self-exit check), checks heartbeat-stop, and exits on either signal or @@ -86,7 +88,7 @@ run_loop() { # launch missing a body var would otherwise produce an immortal loop # that never pulses — the exact "healthy round looks dead" failure this # feature eliminates. Fail fast instead. - require HB_REPO HB_COMMENT_ID HB_WORKDIR HB_ROUND HB_CAP HB_URL HB_START_EPOCH + require HB_REPO HB_COMMENT_ID HB_WORKDIR HB_ROUND HB_CAP HB_URL HB_START_EPOCH TRUSTED_PATH # gh auth rides on the step-level GITHUB_TOKEN only: the hermetic pins # below drop any planted GH_TOKEN/GH_ENTERPRISE_TOKEN (a planted channel # must not outrank the inline token), so accepting them here would admit @@ -96,6 +98,18 @@ run_loop() { echo "autofix-status-heartbeat: GITHUB_TOKEN is required" >&2 exit 2 } + # Binary-resolution channel: the tick resolves its externals (gh, + # timeout, sleep, date, cat — and the mktemp below) by name, and the + # ambient PATH carries same-UID-writable dirs ahead of the system ones + # (the job's own $GITHUB_PATH append puts ${RUNNER_TEMP}/qwen-bin + # there), so a plant in one of them would be resolved by the next tick + # with the PAT in env. Pin PATH from the launcher's step-level + # TRUSTED_PATH instead — the R6-3 doctrine: expression-context + # derived, and step env outranks $GITHUB_ENV plants. post_status pins + # its own PATH the same way before the launch; the loop re-pins so no + # future launcher can hand it an ambient PATH. + # Full rationale → qwen-autofix.md#af-148 + export PATH="${TRUSTED_PATH}" # Hermetic pins for every gh call this loop makes (the af-112 doctrine): # pinned host, planted tokens dropped, and a fresh empty GH_CONFIG_DIR # instead of the default ~/.config/gh on the shared attacker-writable diff --git a/.github/scripts/autofix-status-heartbeat.test.mjs b/.github/scripts/autofix-status-heartbeat.test.mjs index fc8285dc67c..c954a373bab 100644 --- a/.github/scripts/autofix-status-heartbeat.test.mjs +++ b/.github/scripts/autofix-status-heartbeat.test.mjs @@ -193,10 +193,15 @@ describe('autofix-status-heartbeat loop', () => { function loopEnv(dir, gh, overrides = {}) { const workdir = join(dir, 'work'); mkdirSync(workdir, { recursive: true }); + // The loop pins its tick PATH from the launcher-supplied TRUSTED_PATH + // (af-148): the fakes travel through that capture, never through an + // ambient PATH the tick no longer trusts. + const trustedPath = `${gh.bin}:${process.env.PATH}`; return { env: { ...process.env, - PATH: `${gh.bin}:${process.env.PATH}`, + PATH: trustedPath, + TRUSTED_PATH: trustedPath, GH_RECORD_DIR: gh.records, GITHUB_TOKEN: 'fake', HB_REPO: 'octo/repo', @@ -364,10 +369,18 @@ describe('autofix-status-heartbeat loop', () => { }); const child = startLoop(env); try { - const ok = await waitFor( - () => existsSync(join(workdir, 'heartbeat.log')), - 8000, - ); + // Gate on CONTENT, not existence: `exec >> heartbeat.log` creates + // the file empty and the first line forks `date -u` before writing, + // so an existence-gated poll can land in the exists-but-empty + // window, read '' and throw on the match below — a red lane with + // no product defect. + const ok = await waitFor(() => { + const log = join(workdir, 'heartbeat.log'); + return ( + existsSync(log) && + readFileSync(log, 'utf8').includes('heartbeat started') + ); + }, 8000); assert.ok(ok, 'the loop must start and log its parameters'); const logText = readFileSync(join(workdir, 'heartbeat.log'), 'utf8'); assert.match(logText, /interval 600s max_age 20400s/); @@ -474,6 +487,32 @@ describe('autofix-status-heartbeat loop', () => { assert.ok(!existsSync(join(workdir, 'heartbeat.pid'))); }); + it('refuses to loop without TRUSTED_PATH — no tick on an unpinned PATH', async () => { + // The tick's PATH pin comes from the launcher's step-level capture; + // a launch without it must fail fast like any other missing input, + // never run its ticks resolving externals through an ambient, + // plantable PATH. + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env, workdir } = loopEnv(dir, gh); + delete env.TRUSTED_PATH; + const child = spawn('bash', [script, 'loop'], { + env, + stdio: ['ignore', 'ignore', 'pipe'], + detached: true, + }); + let stderr = ''; + child.stderr.on('data', (d) => { + stderr += d; + }); + const code = await awaitExit(child, 8000); + assert.equal(code, 2); + assert.match(stderr, /TRUSTED_PATH is required/); + // Fail fast BEFORE registering anything: no pid file, no log. + assert.ok(!existsSync(join(workdir, 'heartbeat.pid'))); + assert.ok(!existsSync(join(workdir, 'heartbeat.log'))); + }); + it('refuses to loop when a BODY var is missing — no immortal unpulsing loop', async () => { // A launch missing a body var (HB_ROUND here) must fail fast, not // produce a loop that lives to the age cap logging "body composition @@ -544,6 +583,44 @@ describe('autofix-status-heartbeat loop', () => { } }); + it('pins the tick PATH from TRUSTED_PATH — a plant ahead of it is never resolved', async () => { + // The loop holds the bot PAT and resolves its tick externals by name; + // the ambient PATH carries same-UID-writable dirs ahead of the system + // ones (the job's own $GITHUB_PATH append puts ${RUNNER_TEMP}/qwen-bin + // there; pool hosts carry writable _work/_temp entries). Witness the + // pin from the tick's own resolution: a planted gh FIRST on the + // ambient PATH (outside TRUSTED_PATH) must never run, while the gh + // inside TRUSTED_PATH still serves every tick. + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const plantDir = join(dir, 'plant'); + mkdirSync(plantDir, { recursive: true }); + const plantLog = join(dir, 'plant-exfil.log'); + writeFileSync( + join(plantDir, 'gh'), + [ + '#!/usr/bin/env bash', + `printf 'PLANTED_GH_EXECUTED GITHUB_TOKEN=%s\\n' "\${GITHUB_TOKEN:-}" >> "${plantLog}"`, + 'exit 0', + ].join('\n'), + ); + chmodSync(join(plantDir, 'gh'), 0o755); + const { env } = loopEnv(dir, gh, { + PATH: `${plantDir}:${gh.bin}:${process.env.PATH}`, + }); + const child = startLoop(env); + try { + const ok = await waitFor(() => readCalls(gh.records).length >= 1, 8000); + assert.ok(ok, 'the gh inside TRUSTED_PATH must serve the tick'); + assert.ok( + !existsSync(plantLog), + 'a plant on the ambient PATH must never be resolved', + ); + } finally { + killGroup(child); + } + }); + it('pins gh hermetically for every tick — planted channels never reach it', async () => { // The loop holds the bot PAT in env and calls gh on a shared host: a // planted http_unix_socket in the default ~/.config/gh would deliver diff --git a/.github/workflows/qwen-autofix.md b/.github/workflows/qwen-autofix.md index c3ad5442d6d..414f41a282f 100644 --- a/.github/workflows/qwen-autofix.md +++ b/.github/workflows/qwen-autofix.md @@ -3800,11 +3800,26 @@ planted same-UID listener then receives the tick's Authorization header WITH the PAT (witnessed with the pool's gh): exfil with no orphan, no /proc read and no kill miss, inside the legitimate overlap, where none of -the trade arguments above reaches. The alternative — -heartbeat from the schedule scan or a watcher job — lands -every ~40-70 min in this repo (af-027) and would re-derive -comment id, run identity and liveness remotely: too slow -and too much machinery for a pulse. +the trade arguments above reaches. RESOLUTION: the af-112 +pins close gh's CONFIG channel; the binary-resolution +channel is closed separately. The PAT-bearing step and +the loop both pin PATH from the stage-time TRUSTED_PATH +capture BEFORE the first command word resolves (the R6-3 +doctrine): the job's own $GITHUB_PATH append keeps +${RUNNER_TEMP}/qwen-bin ahead of /usr/bin, and a same-UID +plant of gh/timeout/setsid/touch in any writable dir on +the ambient PATH would otherwise be resolved with the PAT +in env — witnessed: a planted setsid at launch and a +planted gh mid-tick both received the token; the pinned +forms never reached the plant. The loop validates the +capture like any other launch input and fails fast +without it; the killers in PAT-bearing steps take the +same absolute-path/builtin command words as the gate's +kill block. The alternative — heartbeat from the schedule +scan or a watcher job — lands every ~40-70 min in this +repo (af-027) and would re-derive comment id, run +identity and liveness remotely: too slow and too much +machinery for a pulse. ORPHAN DISCIPLINE on the persistent pool: the loop self-exits when the pid file no longer holds ITS OWN pid. diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 00a38eb7cac..b828ef9e16b 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -4733,8 +4733,16 @@ jobs: RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' SERVER_URL: '${{ github.server_url }}' HEARTBEAT_SHA256: '${{ steps.stage.outputs.heartbeat_sha256 }}' + TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' run: |- set -uo pipefail + # Binary resolution: this step and the heartbeat loop it launches + # carry the bot PAT, and the job's own $GITHUB_PATH append keeps + # ${RUNNER_TEMP}/qwen-bin ahead of /usr/bin here — pin PATH from + # the stage-time capture BEFORE the first command word resolves + # (the R6-3 doctrine the sibling PAT steps apply; the loop + # re-pins from the same capture). Full rationale → af-148 + export PATH="${TRUSTED_PATH}" # The gh calls in this step (deep-link lookup, comment upsert) and # the heartbeat loop launched here all carry the bot PAT: take the # af-112 hermetic pins BEFORE the first call — pinned host, planted @@ -6139,13 +6147,16 @@ jobs: # the loop on its next self-check even if the kills miss, and the # sleep lets an already-dispatched tick PATCH land before the # terminal text goes up. Full rationale → qwen-autofix.md#af-148 - touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true + # Command words take the gate kill block's absolute-path/builtin + # form: this step holds the PAT, and bare names are PATH-resolved + # (kill additionally shadowable by a $GITHUB_ENV BASH_FUNC plant). + /usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}" if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then - kill -- -"${HB_PID}" 2>/dev/null || true - kill "${HB_PID}" 2>/dev/null || true - pkill -TERM -s "${HB_PID}" 2>/dev/null || true - sleep 2 + builtin kill -- -"${HB_PID}" 2>/dev/null || true + builtin kill "${HB_PID}" 2>/dev/null || true + /usr/bin/pkill -TERM -s "${HB_PID}" 2>/dev/null || true + /usr/bin/sleep 2 fi MARKER='' if [[ -z "${STATUS_ID}" ]]; then diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 9352768ae2a..121b9baa465 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -10635,8 +10635,15 @@ exit 1 expect(patBlockOf(publishPrStep)).toBeTruthy(); expect(patBlockOf(pushAndReportStep)).toBe(patBlockOf(publishPrStep)); expect(patBlockOf(prepareStep)).toBe(patBlockOf(publishPrStep)); - // Each PAT step carries the trusted-PATH env wiring. - for (const step of [publishPrStep, pushAndReportStep, prepareStep]) { + // Each PAT step carries the trusted-PATH env wiring. post_status's + // covers the step's own externals AND seeds the heartbeat loop's pin: + // the loop re-pins its tick PATH from this capture (af-148). + for (const step of [ + publishPrStep, + pushAndReportStep, + prepareStep, + postStatusCommentStep, + ]) { expect(step).toContain( "TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}'", ); @@ -10665,6 +10672,15 @@ exit 1 expect(step.indexOf(firstGh)).toBeGreaterThan(-1); expect(ghPin).toBeLessThan(step.indexOf(firstGh)); } + // post_status pins PATH BEFORE its first external resolves (the + // mktemp minting the gh config dir): every command word after it — + // gh, jq, date, and the setsid/bash of the heartbeat launch — + // resolves under the stage-time capture, never the ambient PATH the + // job's own $GITHUB_PATH append keeps plantable (af-148). + expect(postStatusCommentStep).toContain('export PATH="${TRUSTED_PATH}"'); + expect( + postStatusCommentStep.indexOf('export PATH="${TRUSTED_PATH}"'), + ).toBeLessThan(postStatusCommentStep.indexOf('mktemp')); // DRIFT ALARM, NOT A BOUNDARY. The guarantee that a planted channel // cannot reach the privileged work is the `env -i` clean child, pinned // separately below; no regex over source text can be that guarantee, @@ -15764,6 +15780,16 @@ exit 1 // planted GH_TOKEN, so the fail-fast check must not admit it. expect(heartbeatScript).toContain('GITHUB_TOKEN is required'); expect(heartbeatScript).not.toContain('${GITHUB_TOKEN:-}${GH_TOKEN:-}'); + // The tick's externals (gh, timeout, sleep, date, cat) resolve by + // name while the loop holds the PAT, and the ambient PATH carries + // same-UID-writable dirs ahead of the system ones — so the loop + // re-pins PATH from the launcher's step-level TRUSTED_PATH capture + // and fails fast on a launch without it (af-148). + expect(heartbeatScript).toContain('export PATH="${TRUSTED_PATH}"'); + // The capture is validated with the other launch inputs, so a launch + // without it fails fast before registering anything (pinned + // behaviorally by the script's own suite). + expect(heartbeatScript).toContain('HB_START_EPOCH TRUSTED_PATH'); // A failed PATCH skips one tick, never the pulse. expect(heartbeatScript).toContain('PATCH failed; continuing'); // The loop must not hold the launching step's pipes or the step never @@ -15914,16 +15940,25 @@ exit 1 expect(gateStep).toContain('heartbeat-stop'); expect(gateStep).toContain(`HB_PID="${killTarget}"`); expect(gateStep).toContain('kill -- -"${HB_PID}"'); - expect(finalizeStatusCommentStep).toContain('heartbeat-stop'); + // Finalize holds the PAT like the gate, so its kill block takes the + // same absolute-path/builtin form: bare names are PATH-resolved (the + // job's own $GITHUB_PATH append keeps ${RUNNER_TEMP}/qwen-bin ahead + // of /usr/bin here) and bare kill is shadowable by a $GITHUB_ENV + // BASH_FUNC plant (af-148). + expect(finalizeStatusCommentStep).toContain( + '/usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true', + ); expect(finalizeStatusCommentStep).toContain(`HB_PID="${killTarget}"`); - expect(finalizeStatusCommentStep).toContain('kill -- -"${HB_PID}"'); + expect(finalizeStatusCommentStep).toContain( + 'builtin kill -- -"${HB_PID}" 2>/dev/null || true', + ); expect(finalizeStatusCommentStep.indexOf('heartbeat-stop')).toBeLessThan( finalizeStatusCommentStep.indexOf('--method PATCH'), ); // A tick already dispatched when the kill lands can still be applied // server-side after the terminal text: finalize sleeps past one PATCH // round-trip before its own PATCH. - expect(finalizeStatusCommentStep).toContain('sleep 2'); + expect(finalizeStatusCommentStep).toContain('/usr/bin/sleep 2'); const cleanupStep = reviewAddressJob.match( /- name: 'Clean up autofix workdir'[\s\S]*?(?=\n[ ]{6}- name: '|\n[ ]{2}# ==========|$)/, @@ -15933,10 +15968,14 @@ exit 1 expect(cleanupStep.indexOf('kill -- -"${HB_PID}"')).toBeLessThan( cleanupStep.indexOf('rm -rf "${WORKDIR}"'), ); - // Same-round killers also carry the bare-pid fallback; the gate uses - // the builtin form of the step's shadowing doctrine. + // Same-round killers also carry the bare-pid fallback. The gate and + // finalize hold the PAT and take the builtin form of the step's + // shadowing doctrine; cleanup carries no token and keeps the bare + // form. expect(gateStep).toContain('builtin kill "${HB_PID}"'); - expect(finalizeStatusCommentStep).toContain('kill "${HB_PID}"'); + expect(finalizeStatusCommentStep).toContain( + 'builtin kill "${HB_PID}" 2>/dev/null || true', + ); expect(cleanupStep).toContain('kill "${HB_PID}"'); // A kill landing MID-TICK must reach the tick too: each tick's // `timeout 60 gh` subtree runs in its OWN process group (coreutils @@ -15947,7 +15986,9 @@ exit 1 // absolute-path form of its shadowing doctrine — and it must land // where the others do: before the terminal PATCH (finalize) and before // the workdir wipe (cleanup). - expect(finalizeStatusCommentStep).toContain('pkill -TERM -s "${HB_PID}"'); + expect(finalizeStatusCommentStep).toContain( + '/usr/bin/pkill -TERM -s "${HB_PID}" 2>/dev/null || true', + ); expect(cleanupStep).toContain('pkill -TERM -s "${HB_PID}"'); expect( finalizeStatusCommentStep.indexOf('pkill -TERM -s "${HB_PID}"'), From ae2c6827f1da727fbcdfe9c66e6b56f1115c5129 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 24 Aug 2026 05:56:01 +0000 Subject: [PATCH 07/19] fix(autofix): clear the staged heartbeat plant and re-verify at the loop launch --- .github/workflows/.size-baseline | 2 +- .github/workflows/qwen-autofix.yml | 11 ++ scripts/tests/qwen-autofix-workflow.test.js | 174 +++++++++++++++++++- 3 files changed, 185 insertions(+), 2 deletions(-) diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 4a72756496f..247f4469681 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -34,7 +34,7 @@ 6495 pr-self-report-label.yml 9646 qwen-autofix-fork-bridge.yml 5942 qwen-autofix-fork-signal.yml -404055 qwen-autofix.yml +408743 qwen-autofix.yml 7061 qwen-ci-flaky-rerun.yml 151937 qwen-code-pr-review.yml 79041 qwen-fleet-shepherd.yml diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index b828ef9e16b..0be2642daa0 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -3643,6 +3643,10 @@ jobs: # digest records only when the copy exists; an empty output reaches # the consumer's own empty guard, which degrades to the pre-PR # inline body. + # Absent from base must imply absent on disk: this cp fails every + # pre-merge round, so a planted leftover in RUNNER_TEMP would + # otherwise be digested below and executed as trusted (af-148). + rm -f "${RUNNER_TEMP}/autofix-status-heartbeat.sh" cp .github/scripts/autofix-status-heartbeat.sh "${RUNNER_TEMP}/autofix-status-heartbeat.sh" 2> /dev/null || true # The staged copies' trusted-base provenance holds at cp time only: # RUNNER_TEMP is writable by the branch/agent code later steps run @@ -4822,7 +4826,14 @@ jobs: # WORKDIR is sandbox-writable, so no WORKDIR file is ever read # as a kill target. Full rationale → qwen-autofix.md#af-148 HEARTBEAT_PID='' + # Re-verify immediately before THIS launch (R8-1): the check above + # is separated from this second, PAT-holding execution by the two + # gh round-trips of the comment upsert — a live swap window in the + # attacker-writable RUNNER_TEMP. A mismatch fails the round closed; + # the explicit `|| exit 1` does not rely on the step's ambient + # shell options (af-023). if [[ -n "${STATUS_ID}" && -n "${HEARTBEAT_SHA256}" ]]; then + /usr/bin/echo "${HEARTBEAT_SHA256} ${RUNNER_TEMP}/autofix-status-heartbeat.sh" | /usr/bin/sha256sum -c - > /dev/null || exit 1 HB_REPO="${REPO}" HB_COMMENT_ID="${STATUS_ID}" \ HB_ROUND="${ROUND_DISPLAY}" HB_CAP="${MAX_ROUNDS}" HB_URL="${JOB_URL}" \ HB_WORKDIR="${WORKDIR}" HB_START_EPOCH="${START_EPOCH}" \ diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 121b9baa465..e285cadd74d 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -5,6 +5,7 @@ */ import { execFileSync, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { chmodSync, existsSync, @@ -15818,6 +15819,97 @@ exit 1 ' echo "heartbeat_sha256=$(sha256sum "${RUNNER_TEMP}/autofix-status-heartbeat.sh" | cut -d\' \' -f1)" >> "${GITHUB_OUTPUT}"\n' + ' fi', ); + // Absent-from-base must imply absent-on-disk (R6-1): this tolerant + // cp FAILS every pre-merge round, so a leftover planted in the + // host's persistent RUNNER_TEMP by an earlier run must not survive + // it to be digested below and executed as trusted content. + // Witness: run the stage step verbatim against a tree that lacks + // the script (the merge-base shape) with a planted leftover — no + // file may remain at the staged path and no digest may reach the + // consumers' expression context. + expect(stageStep).toContain( + 'rm -f "${RUNNER_TEMP}/autofix-status-heartbeat.sh"', + ); + expect( + stageStep.indexOf('rm -f "${RUNNER_TEMP}/autofix-status-heartbeat.sh"'), + ).toBeLessThan( + stageStep.indexOf('cp .github/scripts/autofix-status-heartbeat.sh'), + ); + const stageProbeDir = mkdtempSync(join(tmpdir(), 'hb-stage-probe-')); + const stageRunnerTemp = mkdtempSync(join(tmpdir(), 'hb-stage-temp-')); + try { + const stageGithubOutput = join(stageProbeDir, 'github-output'); + mkdirSync(join(stageProbeDir, '.github', 'scripts'), { + recursive: true, + }); + for (const name of [ + 'check-settings-schema.sh', + 'check-autofix-contracts.sh', + 'resolve-owning-packages.sh', + 'run-autofix-review-verification.sh', + 'resanitize-git-config.sh', + 'upsert-deferred-issue.sh', + 'autofix-push-and-report.sh', + ]) { + writeFileSync( + join(stageProbeDir, '.github', 'scripts', name), + readFileSync(join('.github', 'scripts', name)), + ); + } + mkdirSync(join(stageProbeDir, '.qwen', 'skills', 'autofix', 'scripts'), { + recursive: true, + }); + writeFileSync( + join(stageProbeDir, '.qwen', 'skills', 'autofix', 'SKILL.md'), + readFileSync('.qwen/skills/autofix/SKILL.md'), + ); + writeFileSync( + join( + stageProbeDir, + '.qwen', + 'skills', + 'autofix', + 'scripts', + 'run-agent.mjs', + ), + readFileSync('.qwen/skills/autofix/scripts/run-agent.mjs'), + ); + // The planted leftover: attacker content a same-UID run left at + // the staged path on this persistent host. + writeFileSync( + join(stageRunnerTemp, 'autofix-status-heartbeat.sh'), + '#!/usr/bin/env bash\necho ATTACKER_CONTROLLED "$@"\n', + ); + writeFileSync(stageGithubOutput, ''); + const stageProbe = spawnSync( + 'bash', + [ + '-c', + stageStep + .slice(stageStep.indexOf('run: |-') + 'run: |-'.length) + .replace(/^ {10}/gm, ''), + ], + { + cwd: stageProbeDir, + encoding: 'utf8', + env: { + ...process.env, + RUNNER_TEMP: stageRunnerTemp, + GITHUB_OUTPUT: stageGithubOutput, + }, + }, + ); + expect(stageProbe.status).toBe(0); + expect( + existsSync(join(stageRunnerTemp, 'autofix-status-heartbeat.sh')), + ).toBe(false); + expect(readFileSync(stageGithubOutput, 'utf8')).not.toContain( + 'heartbeat_sha256=', + ); + } finally { + rmSync(stageProbeDir, { recursive: true, force: true }); + rmSync(stageRunnerTemp, { recursive: true, force: true }); + } expect(postStatusCommentStep).toContain( "HEARTBEAT_SHA256: '${{ steps.stage.outputs.heartbeat_sha256 }}'", ); @@ -15910,9 +16002,89 @@ exit 1 expect(postStatusCommentStep).toContain( 'setsid bash --norc "${RUNNER_TEMP}/autofix-status-heartbeat.sh" loop &', ); + // ...and the launch re-verifies the staged script immediately + // before starting (R8-1 adjacency): the single check above is + // separated from this SECOND execution by the two gh round-trips + // of the comment upsert — a multi-second swap window in the same + // attacker-writable RUNNER_TEMP — and this execution is the + // PAT-holding one. A mismatch fails the round closed; the explicit + // `|| exit 1` does not rely on the step's ambient shell options + // (the af-023 doctrine). expect(postStatusCommentStep).toContain( - 'if [[ -n "${STATUS_ID}" && -n "${HEARTBEAT_SHA256}" ]]; then\n HB_REPO=', + 'if [[ -n "${STATUS_ID}" && -n "${HEARTBEAT_SHA256}" ]]; then\n' + + ' /usr/bin/echo "${HEARTBEAT_SHA256} ${RUNNER_TEMP}/autofix-status-heartbeat.sh" | /usr/bin/sha256sum -c - > /dev/null || exit 1\n' + + ' HB_REPO=', ); + expect( + postStatusCommentStep.indexOf('sha256sum -c - > /dev/null || exit 1'), + ).toBeLessThan( + postStatusCommentStep.indexOf('autofix-status-heartbeat.sh" loop'), + ); + // Witness (R6-2): run the launch block verbatim with the digest + // recorded from the ORIGINAL staged copy, then swap the file on + // disk before the block runs — the PAT-holding launch must not + // execute the swapped content. The harness sleeps past the + // detached launch's startup before asserting. + const launchProbeTemp = mkdtempSync(join(tmpdir(), 'hb-launch-temp-')); + const launchProbeWorkdir = mkdtempSync(join(tmpdir(), 'hb-launch-wd-')); + try { + const stagedScript = join(launchProbeTemp, 'autofix-status-heartbeat.sh'); + writeFileSync(stagedScript, heartbeatScript); + const stagedDigest = createHash('sha256') + .update(heartbeatScript) + .digest('hex'); + const launchBlock = + 'set -uo pipefail\n' + + postStatusCommentStep + .slice( + postStatusCommentStep.indexOf("HEARTBEAT_PID=''"), + postStatusCommentStep.indexOf('# Hand the id to the finalize step'), + ) + .replace(/^ {10}/gm, '') + + '\nsleep 0.5'; + const launchEnv = { + ...process.env, + RUNNER_TEMP: launchProbeTemp, + STATUS_ID: '12345', + HEARTBEAT_SHA256: stagedDigest, + REPO: 'octo/repo', + ROUND_DISPLAY: '7', + MAX_ROUNDS: '5', + JOB_URL: 'https://example.invalid/job/1', + WORKDIR: launchProbeWorkdir, + START_EPOCH: '1000', + }; + delete launchEnv.GITHUB_TOKEN; + delete launchEnv.GH_TOKEN; + delete launchEnv.TRUSTED_PATH; + // Swapped: the staged content changes after the digest above was + // recorded and before this launch runs. + writeFileSync( + stagedScript, + '#!/usr/bin/env bash\necho ATTACKER_LOOP_RAN > "${HB_WORKDIR}/proof"\n', + ); + const swapped = spawnSync('bash', ['-c', launchBlock], { + encoding: 'utf8', + env: launchEnv, + }); + expect(swapped.status).toBe(1); + expect(existsSync(join(launchProbeWorkdir, 'proof'))).toBe(false); + // Unswapped: an intact staged copy still launches (no + // over-block). The launched script fails fast on a missing launch + // input; that fail-fast message is the evidence it ran. + writeFileSync(stagedScript, heartbeatScript); + const intact = spawnSync('bash', ['-c', launchBlock], { + encoding: 'utf8', + env: launchEnv, + }); + expect(intact.status).toBe(0); + expect(intact.stderr).toContain( + 'autofix-status-heartbeat: TRUSTED_PATH is required', + ); + } finally { + rmSync(launchProbeTemp, { recursive: true, force: true }); + rmSync(launchProbeWorkdir, { recursive: true, force: true }); + } expect(postStatusCommentStep).toContain('HB_COMMENT_ID="${STATUS_ID}"'); expect(postStatusCommentStep).toContain('HB_START_EPOCH="${START_EPOCH}"'); expect(postStatusCommentStep).toContain('HEARTBEAT_PID=$!'); From ccbd6417b0d8266a6023e7c51c7808680a25a2c6 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 24 Aug 2026 10:24:20 +0000 Subject: [PATCH 08/19] fix(autofix): clear directory plants at staging; gate the launch witness on capability --- .github/workflows/qwen-autofix.yml | 7 +- scripts/tests/qwen-autofix-workflow.test.js | 203 ++++++++++++-------- 2 files changed, 124 insertions(+), 86 deletions(-) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 0be2642daa0..13aed8021b1 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -3646,7 +3646,12 @@ jobs: # Absent from base must imply absent on disk: this cp fails every # pre-merge round, so a planted leftover in RUNNER_TEMP would # otherwise be digested below and executed as trusted (af-148). - rm -f "${RUNNER_TEMP}/autofix-status-heartbeat.sh" + # -rf, not -f: the planted leftover can be a DIRECTORY, which rm -f + # cannot remove — the non-zero exit aborts this step under its + # ambient -eo pipefail before the tolerant cp, and nothing else + # reclaims RUNNER_TEMP on this persistent pool, so every later + # round on the host dies at staging until a human clears it. + rm -rf "${RUNNER_TEMP}/autofix-status-heartbeat.sh" cp .github/scripts/autofix-status-heartbeat.sh "${RUNNER_TEMP}/autofix-status-heartbeat.sh" 2> /dev/null || true # The staged copies' trusted-base provenance holds at cp time only: # RUNNER_TEMP is writable by the branch/agent code later steps run diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index e285cadd74d..6231c1ab9db 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -15828,10 +15828,10 @@ exit 1 // file may remain at the staged path and no digest may reach the // consumers' expression context. expect(stageStep).toContain( - 'rm -f "${RUNNER_TEMP}/autofix-status-heartbeat.sh"', + 'rm -rf "${RUNNER_TEMP}/autofix-status-heartbeat.sh"', ); expect( - stageStep.indexOf('rm -f "${RUNNER_TEMP}/autofix-status-heartbeat.sh"'), + stageStep.indexOf('rm -rf "${RUNNER_TEMP}/autofix-status-heartbeat.sh"'), ).toBeLessThan( stageStep.indexOf('cp .github/scripts/autofix-status-heartbeat.sh'), ); @@ -15875,21 +15875,33 @@ exit 1 readFileSync('.qwen/skills/autofix/scripts/run-agent.mjs'), ); // The planted leftover: attacker content a same-UID run left at - // the staged path on this persistent host. - writeFileSync( - join(stageRunnerTemp, 'autofix-status-heartbeat.sh'), - '#!/usr/bin/env bash\necho ATTACKER_CONTROLLED "$@"\n', - ); - writeFileSync(stageGithubOutput, ''); - const stageProbe = spawnSync( - 'bash', - [ - '-c', - stageStep - .slice(stageStep.indexOf('run: |-') + 'run: |-'.length) - .replace(/^ {10}/gm, ''), - ], - { + // the staged path on this persistent host. Both leftover shapes + // are plantable, and the directory one is what forces the -rf: + // rm -f exits non-zero on a directory, which under this step's + // ambient -eo pipefail aborts staging before the tolerant cp — + // and nothing else reclaims RUNNER_TEMP on this persistent pool, + // so every later round on the host dies at staging (R7-2). + const stageProbeScript = stageStep + .slice(stageStep.indexOf('run: |-') + 'run: |-'.length) + .replace(/^ {10}/gm, ''); + const plantLeftover = (asDirectory) => { + if (asDirectory) { + mkdirSync(join(stageRunnerTemp, 'autofix-status-heartbeat.sh')); + writeFileSync( + join(stageRunnerTemp, 'autofix-status-heartbeat.sh', 'payload'), + 'ATTACKER_CONTROLLED\n', + ); + } else { + writeFileSync( + join(stageRunnerTemp, 'autofix-status-heartbeat.sh'), + '#!/usr/bin/env bash\necho ATTACKER_CONTROLLED "$@"\n', + ); + } + }; + for (const asDirectory of [false, true]) { + plantLeftover(asDirectory); + writeFileSync(stageGithubOutput, ''); + const stageProbe = spawnSync('bash', ['-c', stageProbeScript], { cwd: stageProbeDir, encoding: 'utf8', env: { @@ -15897,15 +15909,15 @@ exit 1 RUNNER_TEMP: stageRunnerTemp, GITHUB_OUTPUT: stageGithubOutput, }, - }, - ); - expect(stageProbe.status).toBe(0); - expect( - existsSync(join(stageRunnerTemp, 'autofix-status-heartbeat.sh')), - ).toBe(false); - expect(readFileSync(stageGithubOutput, 'utf8')).not.toContain( - 'heartbeat_sha256=', - ); + }); + expect(stageProbe.status).toBe(0); + expect( + existsSync(join(stageRunnerTemp, 'autofix-status-heartbeat.sh')), + ).toBe(false); + expect(readFileSync(stageGithubOutput, 'utf8')).not.toContain( + 'heartbeat_sha256=', + ); + } } finally { rmSync(stageProbeDir, { recursive: true, force: true }); rmSync(stageRunnerTemp, { recursive: true, force: true }); @@ -16025,65 +16037,86 @@ exit 1 // disk before the block runs — the PAT-holding launch must not // execute the swapped content. The harness sleeps past the // detached launch's startup before asserting. - const launchProbeTemp = mkdtempSync(join(tmpdir(), 'hb-launch-temp-')); - const launchProbeWorkdir = mkdtempSync(join(tmpdir(), 'hb-launch-wd-')); - try { - const stagedScript = join(launchProbeTemp, 'autofix-status-heartbeat.sh'); - writeFileSync(stagedScript, heartbeatScript); - const stagedDigest = createHash('sha256') - .update(heartbeatScript) - .digest('hex'); - const launchBlock = - 'set -uo pipefail\n' + - postStatusCommentStep - .slice( - postStatusCommentStep.indexOf("HEARTBEAT_PID=''"), - postStatusCommentStep.indexOf('# Hand the id to the finalize step'), - ) - .replace(/^ {10}/gm, '') + - '\nsleep 0.5'; - const launchEnv = { - ...process.env, - RUNNER_TEMP: launchProbeTemp, - STATUS_ID: '12345', - HEARTBEAT_SHA256: stagedDigest, - REPO: 'octo/repo', - ROUND_DISPLAY: '7', - MAX_ROUNDS: '5', - JOB_URL: 'https://example.invalid/job/1', - WORKDIR: launchProbeWorkdir, - START_EPOCH: '1000', - }; - delete launchEnv.GITHUB_TOKEN; - delete launchEnv.GH_TOKEN; - delete launchEnv.TRUSTED_PATH; - // Swapped: the staged content changes after the digest above was - // recorded and before this launch runs. - writeFileSync( - stagedScript, - '#!/usr/bin/env bash\necho ATTACKER_LOOP_RAN > "${HB_WORKDIR}/proof"\n', - ); - const swapped = spawnSync('bash', ['-c', launchBlock], { - encoding: 'utf8', - env: launchEnv, - }); - expect(swapped.status).toBe(1); - expect(existsSync(join(launchProbeWorkdir, 'proof'))).toBe(false); - // Unswapped: an intact staged copy still launches (no - // over-block). The launched script fails fast on a missing launch - // input; that fail-fast message is the evidence it ran. - writeFileSync(stagedScript, heartbeatScript); - const intact = spawnSync('bash', ['-c', launchBlock], { - encoding: 'utf8', - env: launchEnv, - }); - expect(intact.status).toBe(0); - expect(intact.stderr).toContain( - 'autofix-status-heartbeat: TRUSTED_PATH is required', - ); - } finally { - rmSync(launchProbeTemp, { recursive: true, force: true }); - rmSync(launchProbeWorkdir, { recursive: true, force: true }); + // The verbatim block hard-requires /usr/bin/sha256sum and setsid, + // which macOS does not ship (shasum instead of GNU coreutils, + // SIP-locked /usr/bin; setsid is util-linux-only), while the + // merge_group-gated macOS test lane still collects this suite (the + // vitest config excludes it only on win32). The production workflow + // is Linux-only, so gate the probe on capability, not platform + // (precedent: haveSessionKillTools in + // autofix-status-heartbeat.test.mjs); the string pins stay + // unconditional. + const launchWitnessSupported = + spawnSync('bash', [ + '-c', + 'command -v setsid >/dev/null 2>&1 && test -x /usr/bin/sha256sum', + ]).status === 0; + if (launchWitnessSupported) { + const launchProbeTemp = mkdtempSync(join(tmpdir(), 'hb-launch-temp-')); + const launchProbeWorkdir = mkdtempSync(join(tmpdir(), 'hb-launch-wd-')); + try { + const stagedScript = join( + launchProbeTemp, + 'autofix-status-heartbeat.sh', + ); + writeFileSync(stagedScript, heartbeatScript); + const stagedDigest = createHash('sha256') + .update(heartbeatScript) + .digest('hex'); + const launchBlock = + 'set -uo pipefail\n' + + postStatusCommentStep + .slice( + postStatusCommentStep.indexOf("HEARTBEAT_PID=''"), + postStatusCommentStep.indexOf( + '# Hand the id to the finalize step', + ), + ) + .replace(/^ {10}/gm, '') + + '\nsleep 0.5'; + const launchEnv = { + ...process.env, + RUNNER_TEMP: launchProbeTemp, + STATUS_ID: '12345', + HEARTBEAT_SHA256: stagedDigest, + REPO: 'octo/repo', + ROUND_DISPLAY: '7', + MAX_ROUNDS: '5', + JOB_URL: 'https://example.invalid/job/1', + WORKDIR: launchProbeWorkdir, + START_EPOCH: '1000', + }; + delete launchEnv.GITHUB_TOKEN; + delete launchEnv.GH_TOKEN; + delete launchEnv.TRUSTED_PATH; + // Swapped: the staged content changes after the digest above was + // recorded and before this launch runs. + writeFileSync( + stagedScript, + '#!/usr/bin/env bash\necho ATTACKER_LOOP_RAN > "${HB_WORKDIR}/proof"\n', + ); + const swapped = spawnSync('bash', ['-c', launchBlock], { + encoding: 'utf8', + env: launchEnv, + }); + expect(swapped.status).toBe(1); + expect(existsSync(join(launchProbeWorkdir, 'proof'))).toBe(false); + // Unswapped: an intact staged copy still launches (no + // over-block). The launched script fails fast on a missing launch + // input; that fail-fast message is the evidence it ran. + writeFileSync(stagedScript, heartbeatScript); + const intact = spawnSync('bash', ['-c', launchBlock], { + encoding: 'utf8', + env: launchEnv, + }); + expect(intact.status).toBe(0); + expect(intact.stderr).toContain( + 'autofix-status-heartbeat: TRUSTED_PATH is required', + ); + } finally { + rmSync(launchProbeTemp, { recursive: true, force: true }); + rmSync(launchProbeWorkdir, { recursive: true, force: true }); + } } expect(postStatusCommentStep).toContain('HB_COMMENT_ID="${STATUS_ID}"'); expect(postStatusCommentStep).toContain('HB_START_EPOCH="${START_EPOCH}"'); From 6cb68dd1362b5a8ab2e9b10f91cf597f9f18d463 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Tue, 25 Aug 2026 00:41:47 +0800 Subject: [PATCH 09/19] fix(ci): record ci.yml's accumulated growth in the size baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ratchet compares against the merge commit: ci.yml grew 4068 bytes on main since this branch's base (within the 4096 allowance, so no baseline update was owed by those PRs), and this PR's one-line HELPER_TESTS registration (+50) pushed the total past the allowance. Record the merged size 73900 — the same shape of fix as #9822. --- .github/workflows/.size-baseline | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 0fb4bf22cc2..7e6451d1178 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -18,7 +18,7 @@ 4638 build-and-publish-image.yml 49610 cd-cua-driver.yml 2076 cd-mobile-mcp.yml -69782 ci.yml +73900 ci.yml 1482 codeql.yml 9389 comment-attachment-guard.yml 31677 desktop-release.yml From a36c1219f43697826b1feb6d841ec38f0862c67c Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Tue, 25 Aug 2026 00:58:21 +0800 Subject: [PATCH 10/19] fix(ci): exact-size the qwen-autofix.yml baseline line after integration The parallel hardening rounds recorded 408743 while the shipped file is 409115; the gate passes either way (within allowance) but the ratchet line should record the exact size. --- .github/workflows/.size-baseline | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 0a549af9a65..21a2e2aeddb 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -34,7 +34,7 @@ 6495 pr-self-report-label.yml 9646 qwen-autofix-fork-bridge.yml 5942 qwen-autofix-fork-signal.yml -408743 qwen-autofix.yml +409115 qwen-autofix.yml 7061 qwen-ci-flaky-rerun.yml 158010 qwen-code-pr-review.yml 79041 qwen-fleet-shepherd.yml From edeb7edc057f9a6d6f821d35810d48df882e5bf2 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 24 Aug 2026 20:55:00 +0000 Subject: [PATCH 11/19] fix(ci): close round-8 heartbeat findings R8-1/R8-2/R8-3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R8-1: post_status mints GH_CONFIG_DIR fail-closed — a bare export masks a failing mktemp and gh falls back to the attacker-writable ~/.config/gh. R8-2: NOW_EPOCH accepts numeric overrides only — bash arithmetic expansion recursively evaluates a planted value's command substitution inside the PAT-holding body/loop. R8-3: finalize takes the gate steps' startup-channel pins (BASH_ENV, SHELLOPTS, LD_*) and pins PATH from the stage-time capture before its bare terminal gh call. Each fix carries its own witness: a planted-clock body test, a failing-mktemp behavioral probe of the step's opening block, and step-level pins; all three verified by mutation probe. --- .github/scripts/autofix-status-heartbeat.sh | 9 +- .../scripts/autofix-status-heartbeat.test.mjs | 13 +++ .github/workflows/.size-baseline | 2 +- .github/workflows/qwen-autofix.yml | 32 ++++++- scripts/tests/qwen-autofix-workflow.test.js | 90 ++++++++++++++++++- 5 files changed, 139 insertions(+), 7 deletions(-) diff --git a/.github/scripts/autofix-status-heartbeat.sh b/.github/scripts/autofix-status-heartbeat.sh index 0713b0043c9..3fa2468cc1a 100644 --- a/.github/scripts/autofix-status-heartbeat.sh +++ b/.github/scripts/autofix-status-heartbeat.sh @@ -64,7 +64,14 @@ require() { emit_body() { require HB_ROUND HB_CAP HB_URL HB_WORKDIR HB_START_EPOCH local now elapsed_min mtime active_min line_en line_zh - now="${NOW_EPOCH:-$(date +%s)}" + # NOW_EPOCH is the test-only clock override — no production launcher + # sets it, so a value can only arrive through an env plant. Bash + # arithmetic expansion recursively evaluates the variable's value, so a + # planted value's embedded command substitution would EXECUTE inside + # this PAT-holding process. Accept a numeric override only; anything + # else falls back to the real clock. + now="${NOW_EPOCH:-}" + [[ "${now}" =~ ^[0-9]+$ ]] || now="$(date +%s)" elapsed_min=$(( (now - HB_START_EPOCH) / 60 )) (( elapsed_min < 0 )) && elapsed_min=0 if [[ -f "${HB_WORKDIR}/agent.log" ]]; then diff --git a/.github/scripts/autofix-status-heartbeat.test.mjs b/.github/scripts/autofix-status-heartbeat.test.mjs index c954a373bab..af4746d0b7e 100644 --- a/.github/scripts/autofix-status-heartbeat.test.mjs +++ b/.github/scripts/autofix-status-heartbeat.test.mjs @@ -170,6 +170,19 @@ describe('autofix-status-heartbeat body', () => { assert.ok(body.includes('已运行 0 分钟')); }); + it('ignores a non-numeric NOW_EPOCH plant instead of evaluating it', () => { + // NOW_EPOCH is the test-only clock override — no production launcher + // sets it, so a value can only arrive through an env plant. Bash + // arithmetic expansion recursively evaluates the variable's value, so + // a planted value's embedded command substitution would EXECUTE inside + // the PAT-holding body subcommand; the numeric guard must drop it and + // fall back to the real clock. + const probe = join(freshTmp(), 'pwned'); + const body = runBody(bodyEnv({ NOW_EPOCH: `HOME[$(touch "${probe}")]` })); + assert.ok(!existsSync(probe), 'a planted NOW_EPOCH must not execute'); + assert.match(body, /Running for \d+ min/); + }); + it('refuses to run without its required environment', () => { const res = spawnSync('bash', [script, 'body'], { env: { ...process.env, HB_ROUND: '3' }, diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 21a2e2aeddb..33ed7af9872 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -34,7 +34,7 @@ 6495 pr-self-report-label.yml 9646 qwen-autofix-fork-bridge.yml 5942 qwen-autofix-fork-signal.yml -409115 qwen-autofix.yml +410889 qwen-autofix.yml 7061 qwen-ci-flaky-rerun.yml 158010 qwen-code-pr-review.yml 79041 qwen-fleet-shepherd.yml diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 13aed8021b1..04700fbe742 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -4762,7 +4762,17 @@ jobs: # Full rationale → qwen-autofix.md#af-112 export GH_HOST=github.com unset GH_ENTERPRISE_TOKEN GH_TOKEN - export GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")" + # Fail CLOSED: a declaration builtin does not propagate a failing + # command substitution, so the bare-export shape would mask a + # failing mktemp and continue with an EMPTY GH_CONFIG_DIR — gh + # treats that exactly as unset and falls back to the shared + # attacker-writable ~/.config/gh this pin exists to close off. + # The shape mirrors the upsert child and the loop's own mint. + if ! GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")"; then + echo "::error::autofix heartbeat: could not create hermetic gh config dir; refusing gh calls with the PAT" + exit 1 + fi + export GH_CONFIG_DIR MARKER='' ROUND_DISPLAY="${EFFECTIVE_ROUND:-${ROUND}}" # ROUND counts rounds already DONE; every other message numbers the @@ -6133,6 +6143,22 @@ jobs: if: |- ${{ always() && steps.prepare.outputs.stale != 'true' && needs.route.outputs.dry_run != 'true' }} env: + # Startup-channel pins, the gate steps' doctrine: BASH_ENV is + # sourced by bash at process STARTUP, before line 1 of the body + # below, and SHELLOPTS is the sibling option-import channel — a + # body-side unset is one hop late, so both are pinned empty at + # step level, which outranks any $GITHUB_ENV plant. The LD_* + # family is mapped by ld.so at startup the same way (an in-body + # unset cannot unload a library already mapped into THIS step's + # bash; ld.so ignores empty values). This step holds the PAT, so + # a plant sourced at startup runs with it. + # Full rationale → qwen-autofix.md#af-148 + BASH_ENV: '' + SHELLOPTS: '' + LD_PRELOAD: '' + LD_AUDIT: '' + LD_LIBRARY_PATH: '' + TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' EFFECTIVE_ROUND: '${{ steps.prepare.outputs.effective_round }}' OUTCOME: '${{ steps.final_verify.outputs.outcome }}' @@ -6150,6 +6176,10 @@ jobs: PUSH_REPORTED: '${{ steps.push_report.outputs.round_reported }}' run: |- set -uo pipefail + # This step holds the PAT: pin PATH from the stage-time capture + # BEFORE the bare gh below resolves (the R6-3 doctrine the + # sibling PAT steps apply). Full rationale → qwen-autofix.md#af-148 + export PATH="${TRUSTED_PATH}" # Stop the round heartbeat BEFORE flipping this comment to its # terminal text: a tick landing after the finalize would # overwrite it with a live-looking "working" line. The gate diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index b02593402e6..f46c8d2ec2e 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -10665,13 +10665,75 @@ exit 1 const ghPin = step.indexOf('export GH_HOST=github.com'); expect(ghPin).toBeGreaterThan(-1); expect(step).toMatch(/unset GH_ENTERPRISE_TOKEN GH_TOKEN/); - // GH_CONFIG_DIR is PINNED to a fresh throwaway (unsetting it falls - // back to the attacker-writable ~/.config/gh with http_unix_socket). + expect(step.indexOf(firstGh)).toBeGreaterThan(-1); + expect(ghPin).toBeLessThan(step.indexOf(firstGh)); + } + // GH_CONFIG_DIR is PINNED to a fresh throwaway (unsetting it falls + // back to the attacker-writable ~/.config/gh with http_unix_socket). + for (const step of [publishPrStep, pushAndReportStep, prepareStep]) { expect(step).toContain( 'export GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")"', ); - expect(step.indexOf(firstGh)).toBeGreaterThan(-1); - expect(ghPin).toBeLessThan(step.indexOf(firstGh)); + } + // post_status mints fail-CLOSED (R8-1): a declaration builtin does not + // propagate a failing command substitution, so the bare-export shape + // would mask a failing mktemp and continue with an EMPTY GH_CONFIG_DIR + // — gh treats that exactly as unset and falls back to the shared + // attacker-writable ~/.config/gh the pin exists to close off. The + // shape mirrors the upsert child and the loop's own mint. + expect(postStatusCommentStep).toContain( + 'if ! GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")"; then', + ); + expect(postStatusCommentStep).toContain('refusing gh calls with the PAT'); + expect(postStatusCommentStep).toMatch(/\n\s*export GH_CONFIG_DIR\n/); + // Witness (R8-1): run the step's opening block verbatim with a mktemp + // that fails (a shim first on the TRUSTED_PATH the block pins itself + // to) — the step must REFUSE the PAT-carrying gh calls instead of + // continuing with an empty GH_CONFIG_DIR; the intact arm mints a real + // dir under RUNNER_TEMP. + const ghcfgOpenBlock = + postStatusCommentStep + .slice( + postStatusCommentStep.indexOf('run: |-') + 'run: |-'.length, + postStatusCommentStep.indexOf("MARKER=''"), + ) + .replace(/^ {10}/gm, '') + + '\nprintf \'GH_CONFIG_DIR=%s\\n\' "${GH_CONFIG_DIR}"\n'; + const ghcfgTemp = mkdtempSync(join(tmpdir(), 'hb-ghcfg-temp-')); + const ghcfgShimDir = mkdtempSync(join(tmpdir(), 'hb-ghcfg-shim-')); + try { + writeFileSync( + join(ghcfgShimDir, 'mktemp'), + '#!/usr/bin/env bash\nexit 1\n', + ); + chmodSync(join(ghcfgShimDir, 'mktemp'), 0o755); + const mktempFailing = spawnSync('bash', ['-c', ghcfgOpenBlock], { + encoding: 'utf8', + env: { + ...process.env, + TRUSTED_PATH: `${ghcfgShimDir}:/usr/bin:/bin`, + RUNNER_TEMP: ghcfgTemp, + }, + }); + expect(mktempFailing.status).toBe(1); + expect(mktempFailing.stdout).toContain( + '::error::autofix heartbeat: could not create hermetic gh config dir', + ); + const mktempIntact = spawnSync('bash', ['-c', ghcfgOpenBlock], { + encoding: 'utf8', + env: { + ...process.env, + TRUSTED_PATH: '/usr/bin:/bin', + RUNNER_TEMP: ghcfgTemp, + }, + }); + expect(mktempIntact.status).toBe(0); + const minted = mktempIntact.stdout.match(/GH_CONFIG_DIR=(.*)/)?.[1] ?? ''; + expect(minted.startsWith(ghcfgTemp)).toBe(true); + expect(existsSync(minted)).toBe(true); + } finally { + rmSync(ghcfgTemp, { recursive: true, force: true }); + rmSync(ghcfgShimDir, { recursive: true, force: true }); } // post_status pins PATH BEFORE its first external resolves (the // mktemp minting the gh config dir): every command word after it — @@ -16164,6 +16226,26 @@ exit 1 // server-side after the terminal text: finalize sleeps past one PATCH // round-trip before its own PATCH. expect(finalizeStatusCommentStep).toContain('/usr/bin/sleep 2'); + // Finalize holds the PAT too, so it takes the gate steps' + // startup-channel pins at step level — BASH_ENV is sourced at process + // STARTUP before line 1 of the body (an in-body unset is one hop + // late), SHELLOPTS is the sibling option-import channel, and the LD_* + // family is mapped by ld.so at startup the same way — plus the PATH + // pin before its bare terminal gh call (af-148, R8-3). + expect(finalizeStatusCommentStep).toContain("BASH_ENV: ''"); + expect(finalizeStatusCommentStep).toContain("SHELLOPTS: ''"); + expect(finalizeStatusCommentStep).toContain("LD_PRELOAD: ''"); + expect(finalizeStatusCommentStep).toContain("LD_AUDIT: ''"); + expect(finalizeStatusCommentStep).toContain("LD_LIBRARY_PATH: ''"); + expect(finalizeStatusCommentStep).toContain( + "TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}'", + ); + expect(finalizeStatusCommentStep).toContain( + 'export PATH="${TRUSTED_PATH}"', + ); + expect( + finalizeStatusCommentStep.indexOf('export PATH="${TRUSTED_PATH}"'), + ).toBeLessThan(finalizeStatusCommentStep.indexOf('--method PATCH')); const cleanupStep = reviewAddressJob.match( /- name: 'Clean up autofix workdir'[\s\S]*?(?=\n[ ]{6}- name: '|\n[ ]{2}# ==========|$)/, From aac8a2a56befa3911680266953ff3d131fd0c7c6 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Tue, 25 Aug 2026 00:27:32 +0000 Subject: [PATCH 12/19] fix(ci): close round-9 finding R9-1 with an env -i finalize child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The finalize step holds the bot PAT, and the startup-channel pins its previous hardening added close only the named variable channels (BASH_ENV/SHELLOPTS/LD_*): bash also imports BASH_FUNC_%% env entries as functions at startup even under --norc, ahead of builtins and PATH, under attacker-chosen names no env: block can enumerate. A $GITHUB_ENV plant (reachable: the verification gate runs the branch's own build/tests on the host before this always() step, and the runner's set-env blocklist does not cover BASH_FUNC keys) could shadow the step's set/export/builtin/gh words and run with the token — probe-verified on this host for every one of those words. Close the class, not the enumeration: the whole PAT-touching body now runs through the gate's env -i clean-child form (R6-4), re-declaring only what it needs from step-level pins and expression context. HOME additionally rides the stage-time capture so a planted HOME cannot repoint gh's config search (af-112/R8-3 doctrine). Witnesses: structural adjacency-chain pin over the launch plus parent-body boundary pins, a workflow-wide clean-child count update, and a behavioral probe whose poisoned arm plants a BASH_FUNC function for every command word the old inline body resolved and requires none of them to run. Mutation probes: dropping env -i, dropping the token allowlist entry, and inserting a parent statement each fail the new tests; restored, the suite is green. --- .github/workflows/qwen-autofix.yml | 178 +++++++++++-------- scripts/tests/qwen-autofix-workflow.test.js | 180 +++++++++++++++++++- 2 files changed, 278 insertions(+), 80 deletions(-) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 04700fbe742..e7c4a45e24f 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -6144,14 +6144,17 @@ jobs: ${{ always() && steps.prepare.outputs.stale != 'true' && needs.route.outputs.dry_run != 'true' }} env: # Startup-channel pins, the gate steps' doctrine: BASH_ENV is - # sourced by bash at process STARTUP, before line 1 of the body - # below, and SHELLOPTS is the sibling option-import channel — a + # sourced by bash at process STARTUP, before the launch below, + # and SHELLOPTS is the sibling option-import channel — a # body-side unset is one hop late, so both are pinned empty at # step level, which outranks any $GITHUB_ENV plant. The LD_* - # family is mapped by ld.so at startup the same way (an in-body - # unset cannot unload a library already mapped into THIS step's - # bash; ld.so ignores empty values). This step holds the PAT, so - # a plant sourced at startup runs with it. + # family is mapped by ld.so at startup the same way. These pins + # protect the parent shell that executes the launch; the + # BASH_FUNC_%% channel no env: block can enumerate — the + # attacker chooses the name, and bash imports the entries as + # functions at startup even under --norc, ahead of builtins and + # PATH — is closed by the env -i clean child below (R9-1), the + # gate's R6-4 pattern. # Full rationale → qwen-autofix.md#af-148 BASH_ENV: '' SHELLOPTS: '' @@ -6159,6 +6162,11 @@ jobs: LD_AUDIT: '' LD_LIBRARY_PATH: '' TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' + # HOME re-enters the clean child below: a $GITHUB_ENV-planted + # HOME repoints gh's config search at an attacker-writable dir + # (af-112's http_unix_socket exfil). Pin it from the stage-time + # capture, the TRUSTED_PATH doctrine above (R8-3). + HOME: '${{ steps.stage.outputs.trusted_home }}' GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' EFFECTIVE_ROUND: '${{ steps.prepare.outputs.effective_round }}' OUTCOME: '${{ steps.final_verify.outputs.outcome }}' @@ -6175,71 +6183,99 @@ jobs: # must not assert a report that never posted. PUSH_REPORTED: '${{ steps.push_report.outputs.round_reported }}' run: |- - set -uo pipefail - # This step holds the PAT: pin PATH from the stage-time capture - # BEFORE the bare gh below resolves (the R6-3 doctrine the - # sibling PAT steps apply). Full rationale → qwen-autofix.md#af-148 - export PATH="${TRUSTED_PATH}" - # Stop the round heartbeat BEFORE flipping this comment to its - # terminal text: a tick landing after the finalize would - # overwrite it with a live-looking "working" line. The gate - # already killed the loop before the host-side branch code; this - # is the belt to its braces (a missed kill there must not outlive - # the round). Kill target comes from expression context — a pid - # read from a WORKDIR file would be an untrusted kill target - # (WORKDIR is sandbox-writable). The session kill covers a kill - # landing mid-tick (the tick's timeout/gh subtree sits in its own - # process group under the loop's session). The stop marker ends - # the loop on its next self-check even if the kills miss, and the - # sleep lets an already-dispatched tick PATCH land before the - # terminal text goes up. Full rationale → qwen-autofix.md#af-148 - # Command words take the gate kill block's absolute-path/builtin - # form: this step holds the PAT, and bare names are PATH-resolved - # (kill additionally shadowable by a $GITHUB_ENV BASH_FUNC plant). - /usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true - HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}" - if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then - builtin kill -- -"${HB_PID}" 2>/dev/null || true - builtin kill "${HB_PID}" 2>/dev/null || true - /usr/bin/pkill -TERM -s "${HB_PID}" 2>/dev/null || true - /usr/bin/sleep 2 - fi - MARKER='' - if [[ -z "${STATUS_ID}" ]]; then - echo "This round posted no status comment on PR #${PR}; nothing to finalize." - exit 0 - fi - ROUND_DISPLAY="${EFFECTIVE_ROUND:-${ROUND}}" - # ROUND counts rounds already DONE; every other message numbers the - # round being performed (the report posts ROUND + 1). Match it, or - # the same round carries two different numbers in one thread. - if [[ "${ROUND_DISPLAY}" =~ ^[0-9]+$ ]]; then - ROUND_DISPLAY="$((ROUND_DISPLAY + 1))" - fi - # 'fixed'/'noop'/'handoff'/'dirty_handoff'/'committed_handoff' are - # the outcomes that published a round report — handoff and the two - # brake-violation rejections post their note + eval marker from the - # report step; anything else means the round stopped before - # publishing one. fixed/noop additionally condition on the push - # step's own success output: an env plant can kill that step's - # shell at execve (silent exit 0, gate body never ran), and the - # success text would otherwise assert a report that never posted. - PUBLISHED=true - if [[ "${OUTCOME:-}" == 'fixed' || "${OUTCOME:-}" == 'noop' ]] && [[ "${PUSH_REPORTED:-}" != 'true' ]]; then - PUBLISHED=false - fi - if [[ "${PUBLISHED}" == 'true' && ( "${OUTCOME:-}" == 'fixed' || "${OUTCOME:-}" == 'noop' || "${OUTCOME:-}" == 'handoff' || "${OUTCOME:-}" == 'dirty_handoff' || "${OUTCOME:-}" == 'committed_handoff' ) ]]; then - EN="$(printf '✅ **AutoFix round %s finished** — [view run](%s). See this round'"'"'s report below.' "${ROUND_DISPLAY}" "${RUN_URL}")" - ZH="$(printf '✅ **AutoFix 第 %s 轮已完成** —— [查看运行](%s)。本轮报告见下方。' "${ROUND_DISPLAY}" "${RUN_URL}")" - else - EN="$(printf '⚠️ **AutoFix round %s ended without publishing a report** — [view run](%s).' "${ROUND_DISPLAY}" "${RUN_URL}")" - ZH="$(printf '⚠️ **AutoFix 第 %s 轮结束但未发布报告** —— [查看运行](%s)。' "${ROUND_DISPLAY}" "${RUN_URL}")" - fi - BODY="$(printf '%s\n\n%s\n\n
\n中文说明\n\n%s\n\n
' \ - "${MARKER}" "${EN}" "${ZH}")" - gh api --method PATCH "repos/${REPO}/issues/comments/${STATUS_ID}" \ - -f body="${BODY}" > /dev/null || - echo "::warning::Failed to finalize the autofix status comment on PR #${PR}; continuing." + # This step holds the PAT, and its shell inherits every + # $GITHUB_ENV plant earlier steps left — including + # BASH_FUNC_%% imports, which bash loads as functions at + # STARTUP even under --norc, ahead of builtins and PATH, under + # attacker-chosen names no step-level pin can enumerate + # (probe-verified: plants shadowing set/export/builtin/gh each + # hijacked this step's former inline body). Close the class, + # not the enumeration: the whole PAT-touching body runs in the + # gate's env -i clean-child form, re-declaring only what it + # needs from the step-level pins above and expression context. + # PATH reaches the child through the allowlist, so the body + # carries no in-shell pin of its own; inside the clean child + # no import survives, so bare and builtin words resolve + # soundly again. The kill target travels through expression + # context — a pid read from a WORKDIR file would be an + # untrusted kill target (WORKDIR is sandbox-writable). + # Full rationale → qwen-autofix.md#af-148 + LD_PRELOAD= LD_AUDIT= LD_LIBRARY_PATH= \ + /usr/bin/env -i \ + PATH="${TRUSTED_PATH}" \ + HOME="${HOME}" \ + GITHUB_TOKEN="${GITHUB_TOKEN}" \ + WORKDIR="${WORKDIR}" \ + HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}" \ + STATUS_ID="${STATUS_ID}" \ + PUSH_REPORTED="${PUSH_REPORTED}" \ + OUTCOME="${OUTCOME}" \ + EFFECTIVE_ROUND="${EFFECTIVE_ROUND}" \ + ROUND="${ROUND}" \ + RUN_URL="${RUN_URL}" \ + REPO="${REPO}" \ + PR="${PR}" \ + bash --norc -c ' + set -uo pipefail + # Stop the round heartbeat BEFORE flipping this comment to + # its terminal text: a tick landing after the finalize would + # overwrite it with a live-looking "working" line. The gate + # already killed the loop before the host-side branch code; + # this is the belt to its braces (a missed kill there must + # not outlive the round). The session kill covers a kill + # landing mid-tick (the timeout/gh subtree of a tick sits + # in its own process group under the session of the loop). + # The stop marker ends the loop on its next self-check even + # if the kills miss, and the sleep lets an already- + # dispatched tick PATCH land before the terminal text goes + # up. Full rationale → qwen-autofix.md#af-148 + /usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true + if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then + builtin kill -- -"${HB_PID}" 2>/dev/null || true + builtin kill "${HB_PID}" 2>/dev/null || true + /usr/bin/pkill -TERM -s "${HB_PID}" 2>/dev/null || true + /usr/bin/sleep 2 + fi + MARKER="" + if [[ -z "${STATUS_ID}" ]]; then + echo "This round posted no status comment on PR #${PR}; nothing to finalize." + exit 0 + fi + ROUND_DISPLAY="${EFFECTIVE_ROUND:-${ROUND}}" + # ROUND counts rounds already DONE; every other message + # numbers the round being performed (the report posts + # ROUND + 1). Match it, or the same round carries two + # different numbers in one thread. + if [[ "${ROUND_DISPLAY}" =~ ^[0-9]+$ ]]; then + ROUND_DISPLAY="$((ROUND_DISPLAY + 1))" + fi + # "fixed"/"noop"/"handoff"/"dirty_handoff"/ + # "committed_handoff" are the outcomes that published a + # round report — handoff and the two brake-violation + # rejections post their note + eval marker from the report + # step; anything else means the round stopped before + # publishing one. fixed/noop additionally condition on the + # success output of the push step: an env plant can kill + # that shell at execve (silent exit 0, gate body never + # ran), and the success text would otherwise assert a + # report that never posted. + PUBLISHED=true + if [[ "${OUTCOME:-}" == "fixed" || "${OUTCOME:-}" == "noop" ]] && [[ "${PUSH_REPORTED:-}" != "true" ]]; then + PUBLISHED=false + fi + if [[ "${PUBLISHED}" == "true" && ( "${OUTCOME:-}" == "fixed" || "${OUTCOME:-}" == "noop" || "${OUTCOME:-}" == "handoff" || "${OUTCOME:-}" == "dirty_handoff" || "${OUTCOME:-}" == "committed_handoff" ) ]]; then + EN="$(printf "✅ **AutoFix round %s finished** — [view run](%s). See this round'"'"'s report below." "${ROUND_DISPLAY}" "${RUN_URL}")" + ZH="$(printf "✅ **AutoFix 第 %s 轮已完成** —— [查看运行](%s)。本轮报告见下方。" "${ROUND_DISPLAY}" "${RUN_URL}")" + else + EN="$(printf "⚠️ **AutoFix round %s ended without publishing a report** — [view run](%s)." "${ROUND_DISPLAY}" "${RUN_URL}")" + ZH="$(printf "⚠️ **AutoFix 第 %s 轮结束但未发布报告** —— [查看运行](%s)。" "${ROUND_DISPLAY}" "${RUN_URL}")" + fi + BODY="$(printf "%s\n\n%s\n\n
\n中文说明\n\n%s\n\n
" \ + "${MARKER}" "${EN}" "${ZH}")" + gh api --method PATCH "repos/${REPO}/issues/comments/${STATUS_ID}" \ + -f body="${BODY}" > /dev/null || + echo "::warning::Failed to finalize the autofix status comment on PR #${PR}; continuing." + ' # Nothing else removes the per-target WORKDIR; PR numbers only # increase, so on the persistent pool every addressed PR would leave diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index f46c8d2ec2e..52afc2e4d2d 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -13316,10 +13316,12 @@ exit 1 const launchIdx = argStart; expect(execIdx).toBeGreaterThan(launchIdx); } - // Four clean children: the two deferred-findings upserts plus the two + // Five clean children: the two deferred-findings upserts, the two // verification-gate launches (the gate runs after the agent step's - // branch code, so its bash must inherit nothing at all). - expect(workflowWithScripts.split('/usr/bin/env -i \\').length - 1).toBe(4); + // branch code, so its bash must inherit nothing at all), and the + // finalize step's PAT-touching body (R9-1: BASH_FUNC imports close + // only inside an env -i child). + expect(workflowWithScripts.split('/usr/bin/env -i \\').length - 1).toBe(5); // R5-6: the failure-path child is near-verbatim of run_deferred_upsert's // child — tie their shared security scaffold together so drift in one is // caught. Compare the allow-list + prelude (everything up to where the @@ -15779,7 +15781,7 @@ exit 1 // additionally prove publication through the push step's output, since // an env plant can no-op that step with exit 0 (R3-1). expect(finalizeStatusCommentStep).toContain( - '[[ "${PUBLISHED}" == \'true\' && ( "${OUTCOME:-}" == \'fixed\' || "${OUTCOME:-}" == \'noop\' || "${OUTCOME:-}" == \'handoff\' || "${OUTCOME:-}" == \'dirty_handoff\' || "${OUTCOME:-}" == \'committed_handoff\' ) ]]', + '[[ "${PUBLISHED}" == "true" && ( "${OUTCOME:-}" == "fixed" || "${OUTCOME:-}" == "noop" || "${OUTCOME:-}" == "handoff" || "${OUTCOME:-}" == "dirty_handoff" || "${OUTCOME:-}" == "committed_handoff" ) ]]', ); // R3-1 downstream half: an env plant can kill 'Push and report's shell // at execve (silent exit 0, gate body never ran) — the in-step sentinel @@ -15792,7 +15794,7 @@ exit 1 ); expect(finalizeStatusCommentStep).toContain('PUBLISHED=true'); expect(finalizeStatusCommentStep).toMatch( - /if \[\[ "\$\{OUTCOME:-\}" == 'fixed' \|\| "\$\{OUTCOME:-\}" == 'noop' \]\] && \[\[ "\$\{PUSH_REPORTED:-\}" != 'true' \]\]; then\n\s*PUBLISHED=false/, + /if \[\[ "\$\{OUTCOME:-\}" == "fixed" \|\| "\$\{OUTCOME:-\}" == "noop" \]\] && \[\[ "\$\{PUSH_REPORTED:-\}" != "true" \]\]; then\n\s*PUBLISHED=false/, ); expect(finalizeStatusCommentStep).toContain( 'ended without publishing a report', @@ -16230,8 +16232,7 @@ exit 1 // startup-channel pins at step level — BASH_ENV is sourced at process // STARTUP before line 1 of the body (an in-body unset is one hop // late), SHELLOPTS is the sibling option-import channel, and the LD_* - // family is mapped by ld.so at startup the same way — plus the PATH - // pin before its bare terminal gh call (af-148, R8-3). + // family is mapped by ld.so at startup the same way (af-148, R8-3). expect(finalizeStatusCommentStep).toContain("BASH_ENV: ''"); expect(finalizeStatusCommentStep).toContain("SHELLOPTS: ''"); expect(finalizeStatusCommentStep).toContain("LD_PRELOAD: ''"); @@ -16240,12 +16241,173 @@ exit 1 expect(finalizeStatusCommentStep).toContain( "TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}'", ); + // HOME re-enters the clean child below: a $GITHUB_ENV-planted HOME + // repoints gh's config search at an attacker-writable dir (the + // af-112 http_unix_socket exfil), so it rides the stage-time capture + // like the gate children's (R8-3). expect(finalizeStatusCommentStep).toContain( - 'export PATH="${TRUSTED_PATH}"', + "HOME: '${{ steps.stage.outputs.trusted_home }}'", + ); + // R9-1: those step-level pins close the NAMED startup channels of + // the parent shell, but bash also imports BASH_FUNC_%% env + // entries as functions at startup even under --norc, ahead of + // builtins and PATH, under attacker-chosen names no env: block can + // enumerate — probe-verified on this host for every command word + // the step's former inline body resolved (set, export, builtin, the + // bare gh; a planted gh received the token even with PATH re-pinned + // in the body). The step therefore runs its whole PAT-touching body + // through the gate's env -i clean-child form (R6-4), and the launch + // is pinned STRUCTURALLY — one verbatim adjacency chain, every + // allowlist entry in order — because token-level pins alone let a + // demoted or reordered launch through (R2-1). + const finalizeLaunchTokens = [ + 'LD_PRELOAD= LD_AUDIT= LD_LIBRARY_PATH=', + '/usr/bin/env -i', + 'PATH="${TRUSTED_PATH}"', + 'HOME="${HOME}"', + 'GITHUB_TOKEN="${GITHUB_TOKEN}"', + 'WORKDIR="${WORKDIR}"', + 'HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}"', + 'STATUS_ID="${STATUS_ID}"', + 'PUSH_REPORTED="${PUSH_REPORTED}"', + 'OUTCOME="${OUTCOME}"', + 'EFFECTIVE_ROUND="${EFFECTIVE_ROUND}"', + 'ROUND="${ROUND}"', + 'RUN_URL="${RUN_URL}"', + 'REPO="${REPO}"', + 'PR="${PR}"', + "bash --norc -c '", + ]; + const finalizeLaunchPin = new RegExp( + finalizeLaunchTokens + .map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join(' \\\\\n[ \\t]*'), + ); + expect(finalizeStatusCommentStep).toMatch(finalizeLaunchPin); + // The parent body is ONLY the launch: a statement before the LD_* + // prefix, or anything after the child's closing quote, would run in + // the PAT-bearing parent shell the clean child exists to avoid — + // pin both ends. PATH reaches the child through the allowlist, + // ahead of the bare gh inside (no in-shell export pin of its own). + const finalizeStatements = finalizeStatusCommentStep + .slice(finalizeStatusCommentStep.indexOf('run: |-') + 'run: |-'.length) + .split('\n') + .map((line) => line.replace(/^[ \t]+|[ \t]+$/g, '')) + .filter((line) => line !== '' && !line.startsWith('#')); + expect(finalizeStatements[0]).toBe( + 'LD_PRELOAD= LD_AUDIT= LD_LIBRARY_PATH= \\', ); + expect(finalizeStatements[2]).toBe('PATH="${TRUSTED_PATH}" \\'); + expect(finalizeStatements[finalizeStatements.length - 1]).toBe("'"); expect( - finalizeStatusCommentStep.indexOf('export PATH="${TRUSTED_PATH}"'), + finalizeStatusCommentStep.indexOf('PATH="${TRUSTED_PATH}"'), ).toBeLessThan(finalizeStatusCommentStep.indexOf('--method PATCH')); + // Exactly one child launch: a second, unpinned `bash --norc` (the + // pinned block demoted into a never-run arm) must fail here (R2-1). + expect((finalizeStatusCommentStep.match(/bash --norc/g) ?? []).length).toBe( + 1, + ); + // Behavioral witness (both arms flip): render the run body with the + // one expression resolved, then execute it in a step-like env. The + // poisoned arm plants BASH_FUNC imports named for every command word + // the pre-R9 inline body resolved — none may run: env -i drops the + // whole class, so reverting the body back into the parent shell runs + // the plants and fails here. BASH_ENV/SHELLOPTS/LD_* plants stay out + // of the probe env on purpose: those are closed by the step-level + // pins above, which a spawn cannot model. + const finalizeRunBody = finalizeStatusCommentStep + .slice(finalizeStatusCommentStep.indexOf('run: |-') + 'run: |-'.length) + .replaceAll('${{ steps.post_status.outputs.heartbeat_pid }}', ''); + const finalizeProbeDir = mkdtempSync( + join(tmpdir(), 'autofix-finalize-r91-'), + ); + const finalizeProbeOut = join(finalizeProbeDir, 'probe-out'); + const finalizeProbeBin = join(finalizeProbeDir, 'bin'); + mkdirSync(finalizeProbeBin); + writeFileSync( + join(finalizeProbeBin, 'gh'), + [ + '#!/usr/bin/env bash', + `printf 'STUB_GH_RAN token=%s args=%s\\n' "\${GITHUB_TOKEN:-none}" "$*" >> "${finalizeProbeOut}"`, + '', + ].join('\n'), + { mode: 0o755 }, + ); + const finalizeProbeEnv = { + TRUSTED_PATH: `${finalizeProbeBin}:/usr/bin:/bin`, + HOME: finalizeProbeDir, + GITHUB_TOKEN: 'SECRET_PAT', + WORKDIR: finalizeProbeDir, + STATUS_ID: '12345', + PUSH_REPORTED: 'true', + OUTCOME: 'fixed', + EFFECTIVE_ROUND: '3', + ROUND: '3', + RUN_URL: 'https://example.invalid/runs/1', + REPO: 'octo/repo', + PR: '77', + }; + try { + // Clean arm: the child PATCHes through the stub gh with the + // step-level token and the finished-text body, and the stop + // marker lands in WORKDIR. + const clean = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', finalizeRunBody], + { encoding: 'utf8', env: finalizeProbeEnv }, + ); + expect(clean.status).toBe(0); + expect(clean.stdout).not.toContain('PLANTED'); + const cleanOut = readFileSync(finalizeProbeOut, 'utf8'); + expect(cleanOut).toContain('STUB_GH_RAN token=SECRET_PAT'); + expect(cleanOut).toContain( + 'api --method PATCH repos/octo/repo/issues/comments/12345', + ); + expect(cleanOut).toContain('AutoFix round 4 finished'); + expect(existsSync(join(finalizeProbeDir, 'heartbeat-stop'))).toBe(true); + // Poisoned arm: none of the planted functions may run, and the + // clean-child behavior must be unchanged. + rmSync(finalizeProbeOut, { force: true }); + const poisoned = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', finalizeRunBody], + { + encoding: 'utf8', + env: { + ...finalizeProbeEnv, + 'BASH_FUNC_set%%': '() { echo PLANTED_SET_RAN; }', + 'BASH_FUNC_export%%': '() { echo PLANTED_EXPORT_RAN; }', + 'BASH_FUNC_builtin%%': '() { echo PLANTED_BUILTIN_RAN; }', + 'BASH_FUNC_touch%%': '() { echo PLANTED_TOUCH_RAN; }', + 'BASH_FUNC_kill%%': '() { echo PLANTED_KILL_RAN; }', + 'BASH_FUNC_gh%%': '() { echo PLANTED_GH_RAN; }', + 'BASH_FUNC_true%%': '() { echo PLANTED_TRUE_RAN; }', + }, + }, + ); + expect(poisoned.status).toBe(0); + expect(poisoned.stdout).not.toContain('PLANTED'); + expect(poisoned.stderr).not.toContain('PLANTED'); + expect(readFileSync(finalizeProbeOut, 'utf8')).toContain( + 'STUB_GH_RAN token=SECRET_PAT', + ); + // PATCH-only doctrine: an empty STATUS_ID exits before any gh + // call, poisoned arm or not. + rmSync(finalizeProbeOut, { force: true }); + const noStatus = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', finalizeRunBody], + { + encoding: 'utf8', + env: { ...finalizeProbeEnv, STATUS_ID: '' }, + }, + ); + expect(noStatus.status).toBe(0); + expect(noStatus.stdout).toContain('nothing to finalize'); + expect(existsSync(finalizeProbeOut)).toBe(false); + } finally { + rmSync(finalizeProbeDir, { recursive: true, force: true }); + } const cleanupStep = reviewAddressJob.match( /- name: 'Clean up autofix workdir'[\s\S]*?(?=\n[ ]{6}- name: '|\n[ ]{2}# ==========|$)/, From 8375fca2f3efcaf2df0d94cb77548388a9623ad8 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Tue, 25 Aug 2026 13:00:53 +0800 Subject: [PATCH 13/19] fix(autofix): bound the heartbeat pid-identity read (R10-3) A FIFO planted at heartbeat.pid (WORKDIR is sandbox-writable) blocked the identity check's cat indefinitely, so the per-tick age cap never ran again and a crash-orphan held the bot PAT past its documented bound. Wrap the read in the already-pinned coreutils timeout (conditional form keeps the documented no-timeout host fallback); a timeout kill yields empty -> identity mismatch -> the existing clean self-exit. The timeout shim witness now scopes its pin to the gh call and additionally proves the bounded read; a gated behavioral test plants a real FIFO. --- .github/scripts/autofix-status-heartbeat.sh | 13 +++- .../scripts/autofix-status-heartbeat.test.mjs | 60 ++++++++++++++++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/.github/scripts/autofix-status-heartbeat.sh b/.github/scripts/autofix-status-heartbeat.sh index 3fa2468cc1a..35f0f7a868f 100644 --- a/.github/scripts/autofix-status-heartbeat.sh +++ b/.github/scripts/autofix-status-heartbeat.sh @@ -162,7 +162,18 @@ run_loop() { # new round's body on the same comment. The file must still hold THIS # loop's own pid — removed OR replaced (by a newer round) ends the loop. # This reads the file to self-identify only; it never kills anything. - if [[ "$(cat "${HB_WORKDIR}/heartbeat.pid" 2> /dev/null)" != "$$" ]]; then + # The read is BOUNDED: WORKDIR is sandbox-writable, so the path can hold + # a planted FIFO whose open blocks cat indefinitely — stalling the loop + # inside the tick, past the age cap above. Mirrors the gh wrapper's + # conditional timeout form below; a timeout kill yields empty → identity + # mismatch → the clean self-exit just below. + local pid_now + if command -v timeout > /dev/null 2>&1; then + pid_now="$(timeout 5 cat "${HB_WORKDIR}/heartbeat.pid" 2> /dev/null)" + else + pid_now="$(cat "${HB_WORKDIR}/heartbeat.pid" 2> /dev/null)" + fi + if [[ "${pid_now}" != "$$" ]]; then echo "$(date -u +%FT%TZ) self-exit: pid file removed or replaced" exit 0 fi diff --git a/.github/scripts/autofix-status-heartbeat.test.mjs b/.github/scripts/autofix-status-heartbeat.test.mjs index af4746d0b7e..fc96bfd1fe1 100644 --- a/.github/scripts/autofix-status-heartbeat.test.mjs +++ b/.github/scripts/autofix-status-heartbeat.test.mjs @@ -420,8 +420,24 @@ describe('autofix-status-heartbeat loop', () => { timeoutCalls.length >= 1, 'gh must run UNDER timeout, not bare', ); - assert.equal(timeoutCalls[0][0], '60', 'the bound must be 60s'); - assert.equal(timeoutCalls[0][1], 'gh'); + // The gh PATCH call specifically must be bounded. The pid-identity + // read now ALSO runs under timeout, so find the gh call rather than + // assuming it is the first recorded one. + const ghCall = timeoutCalls.find((c) => c[1] === 'gh'); + assert.ok(ghCall, 'the gh PATCH must run under timeout'); + assert.equal(ghCall[0], '60', 'the gh bound must be 60s'); + // R10-3: the pid-identity self-check must ALSO be a bounded read, so + // a planted FIFO at heartbeat.pid cannot block the loop inside the + // tick, past the age cap. The shim proves the read ran under + // `timeout 5 cat` against the pid file. + const pidRead = timeoutCalls.find( + (c) => c[0] === '5' && c[1] === 'cat', + ); + assert.ok(pidRead, 'the pid-identity read must run under timeout 5'); + assert.ok( + pidRead.some((a) => a.endsWith('heartbeat.pid')), + `the bounded read must target heartbeat.pid: ${pidRead.join(' ')}`, + ); } finally { killGroup(child); } @@ -758,4 +774,44 @@ describe('autofix-status-heartbeat loop', () => { } }, ); + + it( + 'a planted FIFO at heartbeat.pid cannot block the loop past the bounded read', + { + skip: haveSessionKillTools + ? false + : 'requires coreutils timeout (the bounded-read guard)', + }, + async () => { + // R10-3: WORKDIR is sandbox-writable, so an attacker can replace + // heartbeat.pid with a FIFO whose open blocks cat indefinitely — + // stalling the loop inside the tick, past the age cap. The bounded + // `timeout 5 cat` must kill the read, and the identity mismatch + // (empty != $$) must then end the loop cleanly. Real coreutils + // timeout runs here — no shim on PATH in this test. + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env, workdir } = loopEnv(dir, gh); + const child = startLoop(env); + try { + const started = await waitFor( + () => existsSync(join(workdir, 'heartbeat.pid')), + 8000, + ); + assert.ok(started, 'the loop must register its pid first'); + rmSync(join(workdir, 'heartbeat.pid')); + spawnSync('mkfifo', [join(workdir, 'heartbeat.pid')]); + const code = await awaitExit(child, 15000); + assert.equal( + code, + 0, + 'the bounded read must end the loop cleanly, not block it', + ); + const logText = readFileSync(join(workdir, 'heartbeat.log'), 'utf8'); + assert.match(logText, /self-exit: pid file removed or replaced/); + } finally { + killGroup(child); + } + }, + ); }); From 81b311af651606a876a0501bd0bc308644dd2ac2 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Tue, 25 Aug 2026 13:13:27 +0800 Subject: [PATCH 14/19] fix(autofix): close R10-1/R10-2 on the gate kill block and the finalize child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R10-1: the gate's heartbeat kill runs in the OUTER shell, which imports every $GITHUB_ENV plant — a BASH_FUNC_builtin%% entry shadows the `builtin` keyword itself, so `builtin kill` there is not sound (the R6-4 doctrine the gate body documents). Switch it to /usr/bin/kill, the same procps already relied on for pkill; `builtin kill` stays where it is sound, inside finalize's env -i clean child. R10-2: the finalize child's gh call carries the PAT but resolved the shared HOME's gh config — pinning HOME's path does not sanitize its contents, which gate-phase host-side branch code (same UID) can write, planting http_unix_socket to capture the Authorization header. Mirror the upsert twin: GH_HOST + RUNNER_TEMP enter the allowlist, and the child mints a hermetic GH_CONFIG_DIR fail-closed before the PATCH. Pins updated: gate statement list and kill-form pins take the absolute-path form, the finalize launch chain gains the two allowlist entries, and the probe env supplies RUNNER_TEMP for the mint. --- .github/workflows/.size-baseline | 2 +- .github/workflows/qwen-autofix.yml | 27 ++++++++++++++--- scripts/tests/qwen-autofix-workflow.test.js | 33 ++++++++++++++------- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 566d17ddd0b..ae2063f129f 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -34,7 +34,7 @@ 6495 pr-self-report-label.yml 9646 qwen-autofix-fork-bridge.yml 5942 qwen-autofix-fork-signal.yml -414583 qwen-autofix.yml +415852 qwen-autofix.yml 7061 qwen-ci-flaky-rerun.yml 158010 qwen-code-pr-review.yml 79041 qwen-fleet-shepherd.yml diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 376ee2423a6..064c291db95 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -5040,14 +5040,19 @@ jobs: # its OWN process group (coreutils timeout default), which the # group+pid kills miss — but the loop owns its session via # setsid, so -s targets exactly this round's tree. - # Absolute-path/builtin-only command words, same doctrine as the - # gate body below. + # ABSOLUTE-PATH-ONLY command words here, NOT builtin: this block + # runs in the gate's OUTER shell, which inherits every + # $GITHUB_ENV plant — a BASH_FUNC_builtin%% import shadows the + # `builtin` keyword itself (the R6-4 doctrine the gate body + # below documents). `builtin kill` is sound only inside the + # env -i clean child (finalize); here kill is /usr/bin/kill, + # the same procps already relied on for pkill. # Full rationale → qwen-autofix.md#af-148 /usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}" if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then - builtin kill -- -"${HB_PID}" 2> /dev/null || true - builtin kill "${HB_PID}" 2> /dev/null || true + /usr/bin/kill -- -"${HB_PID}" 2> /dev/null || true + /usr/bin/kill "${HB_PID}" 2> /dev/null || true /usr/bin/pkill -TERM -s "${HB_PID}" 2> /dev/null || true fi # The gate decides whether the PAT push runs, and the first pass @@ -6227,6 +6232,8 @@ jobs: PATH="${TRUSTED_PATH}" \ HOME="${HOME}" \ GITHUB_TOKEN="${GITHUB_TOKEN}" \ + GH_HOST=github.com \ + RUNNER_TEMP="${RUNNER_TEMP}" \ WORKDIR="${WORKDIR}" \ HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}" \ STATUS_ID="${STATUS_ID}" \ @@ -6258,6 +6265,18 @@ jobs: /usr/bin/pkill -TERM -s "${HB_PID}" 2>/dev/null || true /usr/bin/sleep 2 fi + # Hermetic gh config, the upsert twin'"'"'s shape: pinning + # HOME'"'"'s PATH does not sanitize its CONTENTS, which the + # gate-phase host-side branch code (same UID) can write + # before finalize — a planted http_unix_socket would capture + # the PATCH'"'"'s Authorization header. Fail closed (the + # post_status R8-1 shape): refuse the finalize PATCH rather + # than run gh with the PAT against an untrusted config. + if ! GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")"; then + echo "::error::autofix finalize: could not create hermetic gh config dir; refusing the finalize PATCH with the PAT" + exit 1 + fi + export GH_CONFIG_DIR MARKER="" if [[ -z "${STATUS_ID}" ]]; then echo "This round posted no status comment on PR #${PR}; nothing to finalize." diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 16ca9c2cbda..6bf2241e4f8 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -11628,15 +11628,18 @@ exit 1 // The review gate additionally kills the round heartbeat FIRST — it is // the first step that runs branch code on the host, and the loop holds // the bot PAT (af-148). Parent-shell statements under the same - // doctrine: absolute-path/builtin command words, step-level pin as the - // kill target, nothing executable read from disk. The repair gate does - // not repeat it — the loop is already dead by then. + // doctrine: ABSOLUTE-PATH-ONLY command words (this block runs in the + // gate's outer shell, where a BASH_FUNC_builtin%% plant shadows the + // `builtin` keyword itself — R10-1; `builtin kill` is sound only + // inside finalize's env -i clean child), step-level pin as the kill + // target, nothing executable read from disk. The repair gate does not + // repeat it — the loop is already dead by then. const heartbeatKillStatements = [ '/usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true', 'HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}"', 'if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then', - 'builtin kill -- -"${HB_PID}" 2> /dev/null || true', - 'builtin kill "${HB_PID}" 2> /dev/null || true', + '/usr/bin/kill -- -"${HB_PID}" 2> /dev/null || true', + '/usr/bin/kill "${HB_PID}" 2> /dev/null || true', // The mid-tick cover: each tick's `timeout 60 gh` subtree runs in // its OWN process group (coreutils timeout default) under the // loop's setsid session, so the group+pid kills above miss a kill @@ -16349,6 +16352,12 @@ exit 1 'PATH="${TRUSTED_PATH}"', 'HOME="${HOME}"', 'GITHUB_TOKEN="${GITHUB_TOKEN}"', + // R10-2: the child's gh call carries the PAT; GH_HOST pins the host + // and RUNNER_TEMP lets the child mint a hermetic GH_CONFIG_DIR + // (pinning HOME's path does not sanitize its contents), the upsert + // twin's shape. + 'GH_HOST=github.com', + 'RUNNER_TEMP="${RUNNER_TEMP}"', 'WORKDIR="${WORKDIR}"', 'HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}"', 'STATUS_ID="${STATUS_ID}"', @@ -16420,6 +16429,9 @@ exit 1 TRUSTED_PATH: `${finalizeProbeBin}:/usr/bin:/bin`, HOME: finalizeProbeDir, GITHUB_TOKEN: 'SECRET_PAT', + // The child mints its hermetic GH_CONFIG_DIR under RUNNER_TEMP + // (R10-2); the probe dir stands in for it. + RUNNER_TEMP: finalizeProbeDir, WORKDIR: finalizeProbeDir, STATUS_ID: '12345', PUSH_REPORTED: 'true', @@ -16500,11 +16512,12 @@ exit 1 expect(cleanupStep.indexOf('kill -- -"${HB_PID}"')).toBeLessThan( cleanupStep.indexOf('rm -rf "${WORKDIR}"'), ); - // Same-round killers also carry the bare-pid fallback. The gate and - // finalize hold the PAT and take the builtin form of the step's - // shadowing doctrine; cleanup carries no token and keeps the bare - // form. - expect(gateStep).toContain('builtin kill "${HB_PID}"'); + // Same-round killers also carry the bare-pid fallback. Finalize holds + // the PAT inside its env -i clean child, where the builtin form is + // sound; the gate's kill runs in the OUTER shell (BASH_FUNC_builtin%% + // shadowable — R10-1), so it takes the absolute-path form; cleanup + // carries no token and keeps the bare form. + expect(gateStep).toContain('/usr/bin/kill "${HB_PID}"'); expect(finalizeStatusCommentStep).toContain( 'builtin kill "${HB_PID}" 2>/dev/null || true', ); From 9f78012f566f70d41b09c244c863ac99c7814fc5 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Tue, 25 Aug 2026 10:14:01 +0000 Subject: [PATCH 15/19] fix(autofix): mint the hermetic gh config per call (R11-1) --- .github/scripts/autofix-status-heartbeat.sh | 41 ++++--- .../scripts/autofix-status-heartbeat.test.mjs | 87 ++++++++++++-- .github/workflows/qwen-autofix.md | 30 +++-- .github/workflows/qwen-autofix.yml | 47 +++++--- docs/design/autofix-round-heartbeat.md | 12 +- scripts/tests/qwen-autofix-workflow.test.js | 108 +++++++++++++----- 6 files changed, 243 insertions(+), 82 deletions(-) diff --git a/.github/scripts/autofix-status-heartbeat.sh b/.github/scripts/autofix-status-heartbeat.sh index 35f0f7a868f..d6de68b57ce 100644 --- a/.github/scripts/autofix-status-heartbeat.sh +++ b/.github/scripts/autofix-status-heartbeat.sh @@ -41,8 +41,10 @@ # this loop; the verification gate ends the loop BEFORE the first step that # runs branch code on the host. Every gh call additionally runs under the # af-112 hermetic pins (pinned GH_HOST, dropped GH_TOKEN/GH_ENTERPRISE_TOKEN, -# fresh GH_CONFIG_DIR), so a transport reroute planted in the shared HOME's -# gh config cannot intercept the token. See af-148 for the trade. +# a GH_CONFIG_DIR minted fresh milliseconds before the call and removed right +# after — a long-lived minted dir under the same-UID-writable RUNNER_TEMP is +# plantable between calls, R11-1), so a transport reroute planted in the +# shared HOME's gh config cannot intercept the token. See af-148 for the trade. # -e is deliberately absent: the (( ... < 0 )) clamp guards exit non-zero # on a false test and are load-bearing here. pipefail matches the sibling @@ -118,18 +120,18 @@ run_loop() { # Full rationale → qwen-autofix.md#af-148 export PATH="${TRUSTED_PATH}" # Hermetic pins for every gh call this loop makes (the af-112 doctrine): - # pinned host, planted tokens dropped, and a fresh empty GH_CONFIG_DIR - # instead of the default ~/.config/gh on the shared attacker-writable - # HOME — its config.yml can carry http_unix_socket, which would deliver - # the tick's Authorization header (the bot PAT) to a planted listener. - local gh_config_dir - if ! gh_config_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/autofix-gh-config.XXXXXX")"; then - echo "autofix-status-heartbeat: could not create a gh config dir" >&2 - exit 2 - fi + # pinned host and planted tokens dropped here, at launch. The config dir + # is NOT minted here: it is minted per tick, milliseconds before each gh + # call, and removed right after (below) — RUNNER_TEMP is same-UID-writable, + # so a dir minted once at launch and reused across ticks is plantable: a + # watcher knowing the stable prefix writes a config.yml carrying + # http_unix_socket into it, and every later tick delivers the PATCH's + # Authorization header (the bot PAT) to the planted socket — for the loop + # the 600s sleep before the first call made the window a certainty, not a + # race (R11-1, probe-verified). The per-call shape shrinks the window to + # one call's mint→use race, the residual the pin documents. export GH_HOST=github.com unset GH_ENTERPRISE_TOKEN GH_TOKEN - export GH_CONFIG_DIR="${gh_config_dir}" # Self-detach from the launching step: log to WORKDIR and never hold the # step's pipes, or the step would never report completion. exec >> "${HB_WORKDIR}/heartbeat.log" 2>&1 < /dev/null @@ -185,6 +187,18 @@ run_loop() { echo "$(date -u +%FT%TZ) body composition failed; skipping this tick" continue fi + # Hermetic gh config, minted milliseconds before the call and removed + # right after (the R11-1 shape): a dir reused across ticks is plantable + # under the same-UID-writable RUNNER_TEMP, and a planted http_unix_socket + # would capture the PATCH's Authorization header (the bot PAT). Fail + # CLOSED: a failed mint skips the tick, never runs gh with the PAT + # against the shared ~/.config/gh — a skip degrades one pulse, never + # the loop (the age cap still bounds it). + local gh_config_dir + if ! gh_config_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/autofix-gh-config.XXXXXX")"; then + echo "$(date -u +%FT%TZ) gh config mint failed; skipping this tick" + continue + fi # Best-effort: a transient API failure skips one tick, never the pulse. # `timeout` bounds the request itself — a black-holed connection must # not stall the loop past the age cap, which only runs between ticks @@ -195,11 +209,12 @@ run_loop() { if command -v timeout > /dev/null 2>&1; then GH_PATCH=(timeout 60 gh) fi - if ! "${GH_PATCH[@]}" api --method PATCH \ + if ! GH_CONFIG_DIR="${gh_config_dir}" "${GH_PATCH[@]}" api --method PATCH \ "repos/${HB_REPO}/issues/comments/${HB_COMMENT_ID}" \ -f body="${body}" > /dev/null 2>&1; then echo "$(date -u +%FT%TZ) PATCH failed; continuing" fi + rm -rf "${gh_config_dir}" done } diff --git a/.github/scripts/autofix-status-heartbeat.test.mjs b/.github/scripts/autofix-status-heartbeat.test.mjs index fc96bfd1fe1..47eb55e7d92 100644 --- a/.github/scripts/autofix-status-heartbeat.test.mjs +++ b/.github/scripts/autofix-status-heartbeat.test.mjs @@ -51,8 +51,11 @@ function fakeGhBin(dir) { 'set -u', 'n=$(( $(ls -1 "${GH_RECORD_DIR}" | wc -l) + 1 ))', 'for a in "$@"; do printf \'%s\\0\' "$a"; done > "${GH_RECORD_DIR}/call-${n}"', - "printf 'GH_HOST=%s GH_CONFIG_DIR=%s GITHUB_TOKEN=%s GH_TOKEN=%s GH_ENTERPRISE_TOKEN=%s\\n' \\", - ' "${GH_HOST:-}" "${GH_CONFIG_DIR:-}" "${GITHUB_TOKEN:-}" "${GH_TOKEN:-}" "${GH_ENTERPRISE_TOKEN:-}" \\', + "printf 'GH_HOST=%s GH_CONFIG_DIR=%s CFG_EXISTS=%s CFG_ENTRIES=%s GITHUB_TOKEN=%s GH_TOKEN=%s GH_ENTERPRISE_TOKEN=%s\\n' \\", + ' "${GH_HOST:-}" "${GH_CONFIG_DIR:-}" \\', + ' "$([ -d "${GH_CONFIG_DIR:-/nonexistent}" ] && echo yes || echo no)" \\', + ' "$(ls -1A "${GH_CONFIG_DIR:-/nonexistent}" 2>/dev/null | wc -l | tr -d \' \')" \\', + ' "${GITHUB_TOKEN:-}" "${GH_TOKEN:-}" "${GH_ENTERPRISE_TOKEN:-}" \\', ' >> "${GH_RECORD_DIR}/gh-env.log"', '[ "${GH_FAIL:-0}" = "1" ] && exit 1', 'sleep "${GH_SLEEP_SECONDS:-0}"', @@ -430,9 +433,7 @@ describe('autofix-status-heartbeat loop', () => { // a planted FIFO at heartbeat.pid cannot block the loop inside the // tick, past the age cap. The shim proves the read ran under // `timeout 5 cat` against the pid file. - const pidRead = timeoutCalls.find( - (c) => c[0] === '5' && c[1] === 'cat', - ); + const pidRead = timeoutCalls.find((c) => c[0] === '5' && c[1] === 'cat'); assert.ok(pidRead, 'the pid-identity read must run under timeout 5'); assert.ok( pidRead.some((a) => a.endsWith('heartbeat.pid')), @@ -650,20 +651,25 @@ describe('autofix-status-heartbeat loop', () => { } }); - it('pins gh hermetically for every tick — planted channels never reach it', async () => { + it('pins gh hermetically for every tick — a fresh config dir per call, removed after', async () => { // The loop holds the bot PAT in env and calls gh on a shared host: a // planted http_unix_socket in the default ~/.config/gh would deliver // the tick's Authorization header to a planted listener, and a planted // GH_TOKEN would outrank the step-level GITHUB_TOKEN. Witness the // af-112 pins from the tick's own point of view: the fake gh records - // what it actually sees. + // what it actually sees. R11-1: RUNNER_TEMP is same-UID-writable, so + // the dir must be minted milliseconds BEFORE each call (a watcher that + // knows the stable prefix cannot pre-seed a random path) and removed + // right AFTER — a dir reused across ticks is plantable between calls, + // and the loop's 600s sleep before the first call made that window a + // certainty, not a race. const dir = freshTmp(); const gh = fakeGhBin(dir); const poisonedConfig = join(dir, 'poisoned-gh-config'); const runnerTemp = join(dir, 'runner-temp'); mkdirSync(poisonedConfig, { recursive: true }); mkdirSync(runnerTemp, { recursive: true }); - const { env } = loopEnv(dir, gh, { + const { env, workdir } = loopEnv(dir, gh, { GH_HOST: 'evil.example', GH_TOKEN: 'planted-token', GH_ENTERPRISE_TOKEN: 'planted-enterprise-token', @@ -672,18 +678,24 @@ describe('autofix-status-heartbeat loop', () => { }); const child = startLoop(env); try { - const ok = await waitFor(() => readCalls(gh.records).length >= 1, 8000); - assert.ok(ok, 'expected at least one PATCH call'); + const ok = await waitFor(() => readCalls(gh.records).length >= 2, 8000); + assert.ok(ok, 'expected at least two PATCH calls'); const lines = readFileSync(join(gh.records, 'gh-env.log'), 'utf8') .trim() .split('\n'); - assert.ok(lines.length >= 1, 'every tick must log its gh-visible env'); + assert.ok(lines.length >= 2, 'every tick must log its gh-visible env'); + const dirs = []; for (const line of lines) { assert.ok(line.startsWith('GH_HOST=github.com '), line); const cfg = line.match(/GH_CONFIG_DIR=(\S*) /)?.[1]; assert.ok(cfg, line); assert.ok(cfg.startsWith(runnerTemp), line); - assert.ok(existsSync(cfg), `minted gh config dir must exist: ${cfg}`); + dirs.push(cfg); + // The mint precedes the call (the dir exists, empty of any plant, + // when gh loads its config) — witnessed by gh itself, since the + // post-call removal races any after-the-fact filesystem assertion. + assert.ok(line.includes(' CFG_EXISTS=yes '), line); + assert.ok(line.includes(' CFG_ENTRIES=0 '), line); // GITHUB_TOKEN is the loop's SOLE credential channel now — witness // the surviving channel reaches gh, not only that the planted ones // do not: a scrub broadened to drop it would keep this suite green @@ -697,6 +709,57 @@ describe('autofix-status-heartbeat loop', () => { assert.ok(!line.includes('evil.example'), line); assert.ok(!line.includes(poisonedConfig), line); } + // Every tick mints its OWN dir: a config.yml planted into one tick's + // dir is inert for every later tick because the path is never reused. + assert.equal( + new Set(dirs).size, + dirs.length, + 'each tick must mint a fresh GH_CONFIG_DIR', + ); + // Clean self-exit (the pid removal lands between ticks, before any + // further mint), then witness the post-call removal: no minted dir + // outlives its gh call. + rmSync(join(workdir, 'heartbeat.pid')); + const code = await awaitExit(child, 8000); + assert.equal(code, 0, 'the loop must end cleanly on pid removal'); + const leftovers = readdirSync(runnerTemp).filter((name) => + name.startsWith('autofix-gh-config.'), + ); + assert.deepEqual( + leftovers, + [], + 'minted gh config dirs must not outlive their gh call', + ); + } finally { + killGroup(child); + } + }); + + it('skips the tick on a failed config mint — never gh against a shared config', async () => { + // Fail CLOSED, the af-112 doctrine: a failing mktemp must skip the + // tick's gh call, never run gh with the PAT against an unpinned config + // (an empty GH_CONFIG_DIR falls back to the shared attacker-writable + // ~/.config/gh), and never stop the pulse — a skip degrades one tick, + // the age cap still bounds the loop. A fail-open mutant (bare + // assignment continuing on the empty value) would record gh calls and + // fail here. + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env, workdir } = loopEnv(dir, gh, { + RUNNER_TEMP: join(dir, 'no-such-runner-temp'), + }); + const child = startLoop(env); + try { + // Several tick intervals at the 1s test interval. + await new Promise((resolve) => setTimeout(resolve, 2500)); + assert.equal( + readCalls(gh.records).length, + 0, + 'a failed mint must skip the gh call entirely', + ); + assert.ok(child.exitCode === null, 'a failed mint must not end the loop'); + const logText = readFileSync(join(workdir, 'heartbeat.log'), 'utf8'); + assert.match(logText, /gh config mint failed; skipping this tick/); } finally { killGroup(child); } diff --git a/.github/workflows/qwen-autofix.md b/.github/workflows/qwen-autofix.md index c526219254e..59542e0bf0b 100644 --- a/.github/workflows/qwen-autofix.md +++ b/.github/workflows/qwen-autofix.md @@ -3802,15 +3802,27 @@ landing mid-tick leaves it alive holding the PAT for up to 60s (witnessed on the pool's host class); all three killers therefore kill pid, group, AND session. PINS: the step's gh calls and every tick run under the af-112 -hermetic pins (pinned GH_HOST, dropped -GH_TOKEN/GH_ENTERPRISE_TOKEN, fresh GH_CONFIG_DIR) — -without them the default ~/.config/gh on the shared -attacker-writable HOME can carry http_unix_socket, and a -planted same-UID listener then receives the tick's -Authorization header WITH the PAT (witnessed with the -pool's gh): exfil with no orphan, no /proc read and no -kill miss, inside the legitimate overlap, where none of -the trade arguments above reaches. RESOLUTION: the af-112 +hermetic pins — pinned GH_HOST, dropped +GH_TOKEN/GH_ENTERPRISE_TOKEN, and a fresh empty +GH_CONFIG_DIR minted around EVERY call (the loop mints +per tick, post_status mints per call inside its +hermetic_gh wrapper, finalize mints adjacent to its +single call) and removed right after. Without them the +default ~/.config/gh on the shared attacker-writable +HOME can carry http_unix_socket, and a planted same-UID +listener then receives the tick's Authorization header +WITH the PAT (witnessed with the pool's gh): exfil with +no orphan, no /proc read and no kill miss, inside the +legitimate overlap, where none of the trade arguments +above reaches. The mint sits under the same-UID-writable +RUNNER_TEMP, and a LONG-LIVED minted dir is itself +plantable between calls — a config.yml with +http_unix_socket written into it is read by the next +call, witnessed with the pool's gh on the loop's 600s +launch→first-call window (R11-1) — so the dir is minted +milliseconds before each call and removed right after: +the residual is a per-call mint→use race, not a +persistent channel. RESOLUTION: the af-112 pins close gh's CONFIG channel; the binary-resolution channel is closed separately. The PAT-bearing step and the loop both pin PATH from the stage-time TRUSTED_PATH diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 064c291db95..c3498f65b60 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -4769,24 +4769,35 @@ jobs: # The gh calls in this step (deep-link lookup, comment upsert) and # the heartbeat loop launched here all carry the bot PAT: take the # af-112 hermetic pins BEFORE the first call — pinned host, planted - # tokens dropped, and a fresh empty GH_CONFIG_DIR instead of the - # default ~/.config/gh on the shared attacker-writable HOME (its - # config.yml http_unix_socket would deliver the Authorization + # tokens dropped, and a fresh empty GH_CONFIG_DIR per call instead + # of the default ~/.config/gh on the shared attacker-writable HOME + # (its config.yml http_unix_socket would deliver the Authorization # header — the PAT — to a planted same-UID listener). # Full rationale → qwen-autofix.md#af-112 export GH_HOST=github.com unset GH_ENTERPRISE_TOKEN GH_TOKEN - # Fail CLOSED: a declaration builtin does not propagate a failing - # command substitution, so the bare-export shape would mask a - # failing mktemp and continue with an EMPTY GH_CONFIG_DIR — gh - # treats that exactly as unset and falls back to the shared - # attacker-writable ~/.config/gh this pin exists to close off. - # The shape mirrors the upsert child and the loop's own mint. - if ! GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")"; then - echo "::error::autofix heartbeat: could not create hermetic gh config dir; refusing gh calls with the PAT" - exit 1 - fi - export GH_CONFIG_DIR + # The config dir is minted PER CALL (the R11-1 shape): every gh + # call below rides this wrapper, minting a fresh empty dir + # milliseconds before the call and removing it right after. A dir + # minted once and reused across the step's calls is plantable — + # RUNNER_TEMP is same-UID-writable, and a config.yml carrying + # http_unix_socket written into it between calls would capture + # the next call's Authorization header (the PAT). Fail CLOSED: + # a failed mint refuses the call — gh never runs with the PAT + # against the shared ~/.config/gh this pin exists to close off. + # (The loop mints per tick the same way; finalize mints adjacent + # to its single call.) + hermetic_gh() { + local cfg rc + if ! cfg="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")"; then + echo "::error::autofix heartbeat: could not create hermetic gh config dir; refusing gh calls with the PAT" >&2 + return 1 + fi + GH_CONFIG_DIR="${cfg}" gh "$@" + rc=$? + rm -rf "${cfg}" + return "${rc}" + } MARKER='' ROUND_DISPLAY="${EFFECTIVE_ROUND:-${ROUND}}" # ROUND counts rounds already DONE; every other message numbers the @@ -4800,7 +4811,7 @@ jobs: # the run URL, so the link is never worse than before. # Full rationale → qwen-autofix.md#af-149 JOB_URL="${RUN_URL}" - JOB_ID="$(gh api "repos/${REPO}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}/jobs" --paginate | + JOB_ID="$(hermetic_gh api "repos/${REPO}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}/jobs" --paginate | jq -rs --arg pr "${PR}" \ '[ .[] | .jobs[]? | select((.name // "") | startswith("review-address (\($pr),")) | .id ] | last // empty')" || JOB_ID='' @@ -4828,17 +4839,17 @@ jobs: "${MARKER}" "${ROUND_DISPLAY}" "${MAX_ROUNDS}" "${RUN_URL}" \ "${ROUND_DISPLAY}" "${MAX_ROUNDS}" "${RUN_URL}")" fi - STATUS_ID="$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate | + STATUS_ID="$(hermetic_gh api "repos/${REPO}/issues/${PR}/comments" --paginate | jq -rs --arg m "${MARKER}" --arg ab "${AUTOFIX_BOT}" \ '[ .[][] | select((.user.login // "") == $ab) | select((.body // "") | contains($m)) ] | last | .id // empty')" || STATUS_ID='' if [[ -n "${STATUS_ID}" ]]; then - gh api --method PATCH "repos/${REPO}/issues/comments/${STATUS_ID}" \ + hermetic_gh api --method PATCH "repos/${REPO}/issues/comments/${STATUS_ID}" \ -f body="${BODY}" > /dev/null || echo "::warning::Failed to update the autofix status comment on PR #${PR}; continuing." else - STATUS_ID="$(gh api "repos/${REPO}/issues/${PR}/comments" \ + STATUS_ID="$(hermetic_gh api "repos/${REPO}/issues/${PR}/comments" \ -f body="${BODY}" --jq '.id')" || { STATUS_ID='' diff --git a/docs/design/autofix-round-heartbeat.md b/docs/design/autofix-round-heartbeat.md index 201819a9e6a..9765b75d16d 100644 --- a/docs/design/autofix-round-heartbeat.md +++ b/docs/design/autofix-round-heartbeat.md @@ -175,10 +175,14 @@ comment is never worse than today. under the loop's session). Within that phase the token never touches disk, the only concurrent host processes are trusted (run-agent.mjs, the bundled CLI), and the step's gh calls and every tick run under the - af-112 hermetic pins (pinned host, planted tokens dropped, fresh - `GH_CONFIG_DIR`) — a planted `http_unix_socket` in the shared HOME's - gh config would otherwise deliver the tick's Authorization header to a - same-UID listener inside the legitimate overlap. The alternative that + af-112 hermetic pins (pinned host, planted tokens dropped, a fresh + `GH_CONFIG_DIR` minted around every call and removed right after) — a + planted `http_unix_socket` in the shared HOME's gh config would + otherwise deliver the tick's Authorization header to a same-UID + listener inside the legitimate overlap. The mint sits under the + same-UID-writable `RUNNER_TEMP`, so the dir is created milliseconds + before each call and removed right after; a long-lived minted dir is + itself plantable between calls (R11-1). The alternative that avoids the overlap entirely — heartbeat from the schedule scan or a watcher job — was rejected on cadence and complexity (decision 2). The trade-off is recorded in the diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 6bf2241e4f8..f8123cc0c5b 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -10728,7 +10728,7 @@ exit 1 [prepareStep, 'PR_LIVE="$(gh pr view'], // The heartbeat's step: deep-link lookup, comment upsert, and the // loop launch all carry the PAT; the first gh call is the deep link. - [postStatusCommentStep, 'JOB_ID="$(gh api'], + [postStatusCommentStep, 'JOB_ID="$(hermetic_gh api'], ]) { const ghPin = step.indexOf('export GH_HOST=github.com'); expect(ghPin).toBeGreaterThan(-1); @@ -10743,22 +10743,36 @@ exit 1 'export GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")"', ); } - // post_status mints fail-CLOSED (R8-1): a declaration builtin does not - // propagate a failing command substitution, so the bare-export shape - // would mask a failing mktemp and continue with an EMPTY GH_CONFIG_DIR - // — gh treats that exactly as unset and falls back to the shared - // attacker-writable ~/.config/gh the pin exists to close off. The - // shape mirrors the upsert child and the loop's own mint. + // post_status mints hermetically PER CALL (R11-1): RUNNER_TEMP is + // same-UID-writable, so a dir minted once and reused across the step's + // gh calls is plantable — a config.yml carrying http_unix_socket + // written into it between calls would capture the next call's + // Authorization header (the PAT; probe-verified on the loop's twin, + // whose 600s sleep made the window a certainty). Every gh call rides + // a wrapper that mints fail-CLOSED milliseconds before the call and + // removes the dir right after; a bare call in the step would reopen + // the channel, and a long-lived export would hand every call the same + // plantable dir. + expect(postStatusCommentStep).toContain('hermetic_gh() {'); expect(postStatusCommentStep).toContain( - 'if ! GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")"; then', + 'if ! cfg="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")"; then', ); expect(postStatusCommentStep).toContain('refusing gh calls with the PAT'); - expect(postStatusCommentStep).toMatch(/\n\s*export GH_CONFIG_DIR\n/); - // Witness (R8-1): run the step's opening block verbatim with a mktemp + expect(postStatusCommentStep).toContain('GH_CONFIG_DIR="${cfg}" gh "$@"'); + expect(postStatusCommentStep).toContain('rm -rf "${cfg}"'); + expect(postStatusCommentStep).not.toMatch(/\n\s*export GH_CONFIG_DIR\n/); + // All four call sites (deep-link lookup, comment scan, PATCH, POST) + // ride the wrapper: every `gh api` in the step is a wrapped one. + expect((postStatusCommentStep.match(/gh api/g) ?? []).length).toBe(4); + expect((postStatusCommentStep.match(/hermetic_gh api/g) ?? []).length).toBe( + 4, + ); + // Witness (R11-1): run the step's opening block verbatim with a mktemp // that fails (a shim first on the TRUSTED_PATH the block pins itself - // to) — the step must REFUSE the PAT-carrying gh calls instead of - // continuing with an empty GH_CONFIG_DIR; the intact arm mints a real - // dir under RUNNER_TEMP. + // to) — the wrapper must REFUSE the call (nonzero rc, gh never runs, + // never a gh with the PAT against the shared config); the intact arm + // runs gh under a fresh dir under RUNNER_TEMP that is removed right + // after the call. const ghcfgOpenBlock = postStatusCommentStep .slice( @@ -10766,42 +10780,68 @@ exit 1 postStatusCommentStep.indexOf("MARKER=''"), ) .replace(/^ {10}/gm, '') + - '\nprintf \'GH_CONFIG_DIR=%s\\n\' "${GH_CONFIG_DIR}"\n'; + '\nif hermetic_gh probe; then\n' + + ' echo "HERMETIC_GH_RAN rc=0"\n' + + 'else\n' + + ' echo "HERMETIC_GH_REFUSED rc=$?"\n' + + 'fi\n'; const ghcfgTemp = mkdtempSync(join(tmpdir(), 'hb-ghcfg-temp-')); const ghcfgShimDir = mkdtempSync(join(tmpdir(), 'hb-ghcfg-shim-')); + const ghcfgBinDir = mkdtempSync(join(tmpdir(), 'hb-ghcfg-bin-')); try { writeFileSync( join(ghcfgShimDir, 'mktemp'), '#!/usr/bin/env bash\nexit 1\n', ); chmodSync(join(ghcfgShimDir, 'mktemp'), 0o755); + writeFileSync( + join(ghcfgBinDir, 'gh'), + [ + '#!/usr/bin/env bash', + "printf 'STUB_GH GH_CONFIG_DIR=%s EXISTS=%s\\n' \\", + ' "${GH_CONFIG_DIR:-}" \\', + ' "$([ -d "${GH_CONFIG_DIR:-/nonexistent}" ] && echo yes || echo no)"', + 'exit 0', + ].join('\n'), + ); + chmodSync(join(ghcfgBinDir, 'gh'), 0o755); + // Failing mint: the wrapper refuses — gh never runs. const mktempFailing = spawnSync('bash', ['-c', ghcfgOpenBlock], { encoding: 'utf8', env: { ...process.env, - TRUSTED_PATH: `${ghcfgShimDir}:/usr/bin:/bin`, + TRUSTED_PATH: `${ghcfgShimDir}:${ghcfgBinDir}:/usr/bin:/bin`, RUNNER_TEMP: ghcfgTemp, }, }); - expect(mktempFailing.status).toBe(1); - expect(mktempFailing.stdout).toContain( + expect(mktempFailing.status).toBe(0); + expect(mktempFailing.stdout).toContain('HERMETIC_GH_REFUSED rc=1'); + expect(mktempFailing.stdout).not.toContain('STUB_GH'); + expect(mktempFailing.stderr).toContain( '::error::autofix heartbeat: could not create hermetic gh config dir', ); + // Intact mint: gh runs under a fresh dir, removed right after. const mktempIntact = spawnSync('bash', ['-c', ghcfgOpenBlock], { encoding: 'utf8', env: { ...process.env, - TRUSTED_PATH: '/usr/bin:/bin', + TRUSTED_PATH: `${ghcfgBinDir}:/usr/bin:/bin`, RUNNER_TEMP: ghcfgTemp, }, }); expect(mktempIntact.status).toBe(0); - const minted = mktempIntact.stdout.match(/GH_CONFIG_DIR=(.*)/)?.[1] ?? ''; - expect(minted.startsWith(ghcfgTemp)).toBe(true); - expect(existsSync(minted)).toBe(true); + expect(mktempIntact.stdout).toContain('HERMETIC_GH_RAN rc=0'); + const m = mktempIntact.stdout.match( + /STUB_GH GH_CONFIG_DIR=(\S+) EXISTS=(\S+)/, + ); + expect(m).toBeTruthy(); + expect(m[1].startsWith(ghcfgTemp)).toBe(true); + expect(m[2]).toBe('yes'); + expect(existsSync(m[1])).toBe(false); } finally { rmSync(ghcfgTemp, { recursive: true, force: true }); rmSync(ghcfgShimDir, { recursive: true, force: true }); + rmSync(ghcfgBinDir, { recursive: true, force: true }); } // post_status pins PATH BEFORE its first external resolves (the // mktemp minting the gh config dir): every command word after it — @@ -15918,15 +15958,31 @@ exit 1 // reopen the window this cap exists to shrink. expect(heartbeatScript).toContain('HB_MAX_AGE_SECONDS:-20400'); expect(heartbeatScript).toContain('|| max_age=20400'); - // Every tick's gh call runs under the af-112 hermetic pins, minted - // inside the loop itself: a planted http_unix_socket in the shared - // HOME's gh config would otherwise deliver the tick's Authorization - // header (the PAT) to a planted same-UID listener. + // Every tick's gh call runs under the af-112 hermetic pins: GH_HOST + // pinned and planted tokens dropped at launch, and the config dir + // minted PER TICK inside the loop (R11-1) — RUNNER_TEMP is + // same-UID-writable, so a dir minted once at launch and reused across + // ticks is plantable (a config.yml with http_unix_socket written into + // it delivers every later tick's Authorization header — the PAT — to + // the planted socket). The mint therefore sits fail-closed, adjacent + // to the call inside the loop body, with removal right after. expect(heartbeatScript).toContain('export GH_HOST=github.com'); expect(heartbeatScript).toContain('unset GH_ENTERPRISE_TOKEN GH_TOKEN'); expect(heartbeatScript).toContain( - 'export GH_CONFIG_DIR="${gh_config_dir}"', + 'if ! gh_config_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/autofix-gh-config.XXXXXX")"; then', + ); + expect(heartbeatScript).toContain( + 'gh config mint failed; skipping this tick', ); + expect(heartbeatScript).toContain( + 'GH_CONFIG_DIR="${gh_config_dir}" "${GH_PATCH[@]}" api --method PATCH', + ); + expect(heartbeatScript).toContain('rm -rf "${gh_config_dir}"'); + // The mint is INSIDE the loop body: a hoist back to launch time + // reopens the plantable window R11-1 closed. + expect( + heartbeatScript.indexOf('mktemp -d "${RUNNER_TEMP:-/tmp}'), + ).toBeGreaterThan(heartbeatScript.indexOf('while :; do')); // Auth rides on the step-level GITHUB_TOKEN only: the pins drop any // planted GH_TOKEN, so the fail-fast check must not admit it. expect(heartbeatScript).toContain('GITHUB_TOKEN is required'); From a9f04a545470688eafbec06290bafd4a67918acb Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Tue, 25 Aug 2026 16:11:57 +0000 Subject: [PATCH 16/19] fix(autofix): gate the gh-config witness on the env-log it reads (R12-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-tick hermetic gh-config witness gated waitFor on the call-record count, then read gh-env.log without retry — but the fake gh writes the call record before it appends the env line, so under CPU load the wait passed in the window before the append landed and the assertion saw one line instead of two (19/30 focused runs red under single-core load, all at this assertion). Gate the wait on the env-log line count itself, so the predicate implies what the assertion reads. --- .github/scripts/autofix-status-heartbeat.test.mjs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/scripts/autofix-status-heartbeat.test.mjs b/.github/scripts/autofix-status-heartbeat.test.mjs index 47eb55e7d92..93fbca6ee60 100644 --- a/.github/scripts/autofix-status-heartbeat.test.mjs +++ b/.github/scripts/autofix-status-heartbeat.test.mjs @@ -678,8 +678,19 @@ describe('autofix-status-heartbeat loop', () => { }); const child = startLoop(env); try { - const ok = await waitFor(() => readCalls(gh.records).length >= 2, 8000); - assert.ok(ok, 'expected at least two PATCH calls'); + // The fake gh writes a tick's call record BEFORE it appends the env + // line, so the gate must cover what the assertion below reads — a + // call-count gate alone can pass in the window before that append + // lands, and the unretried read then sees one line instead of two. + const ok = await waitFor( + () => + readCalls(gh.records).length >= 2 && + readFileSync(join(gh.records, 'gh-env.log'), 'utf8') + .trim() + .split('\n').length >= 2, + 8000, + ); + assert.ok(ok, 'expected at least two PATCH calls and two env-log lines'); const lines = readFileSync(join(gh.records, 'gh-env.log'), 'utf8') .trim() .split('\n'); From 62c3bbd8b6571f5f2f2afa32bfa07af06e47d088 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Wed, 26 Aug 2026 13:06:52 +0000 Subject: [PATCH 17/19] fix(autofix): confirm heartbeat pid lifecycle and drain in-flight ticks before the terminal PATCH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two lifecycle races in the round heartbeat (review findings on #9771): 1. The gate/finalize/cleanup killers TERM'd the bare pid recorded at launch — its process group and session too — after validating only that it is decimal. The gate kills the loop up to a whole agent phase before finalize lands, so the pid can be REUSED by then, and the blind block kills an unrelated process (probe-verified: mapped to an unrelated detached session, the block terminated it). Every killer now confirms the pid's /proc//stat start time against the launch's capture (heartbeat_start_ticks, field 22) before signaling: a reused pid carries a different start time and a dead pid has no stat, so a failed check kills nothing. 2. Finalize slept a fixed 2s before the terminal PATCH, but killing the client cannot cancel a PATCH the server already accepted (probe-reproduced: WORKING accepted 1.67s in, TERMINAL submitted 3.80s in, the stale WORKING committed 6.67s in and flipped the comment back to live-looking). Each tick now stamps its start epoch into heartbeat-tick-inflight around its 60s-bounded gh call and removes it after; finalize drains until the stamp is absent or older than the 65s completion bound, and only then PATCHes. Both guard paths carry behavioral witnesses (a reused-pid victim must survive the mismatch arm and die on the matching arm; the drain must wait a near-fresh stamp out and proceed at once on an aged one), plus bounded-write/read guards against planted FIFOs on the stamp path. af-148 and the design doc record the doctrine. --- .github/scripts/autofix-status-heartbeat.sh | 30 +- .../scripts/autofix-status-heartbeat.test.mjs | 96 ++++++ .github/workflows/.size-baseline | 2 +- .github/workflows/qwen-autofix.md | 46 ++- .github/workflows/qwen-autofix.yml | 95 +++++- docs/design/autofix-round-heartbeat.md | 25 +- scripts/tests/qwen-autofix-workflow.test.js | 296 +++++++++++++++++- 7 files changed, 556 insertions(+), 34 deletions(-) diff --git a/.github/scripts/autofix-status-heartbeat.sh b/.github/scripts/autofix-status-heartbeat.sh index d6de68b57ce..806de7e5e4c 100644 --- a/.github/scripts/autofix-status-heartbeat.sh +++ b/.github/scripts/autofix-status-heartbeat.sh @@ -33,7 +33,18 @@ # (coreutils timeout default) under the loop's setsid session, so a # group/pid kill alone leaves it alive holding the PAT for up to 60s. The # round's verification gate kills the loop before running any branch code -# on the host; finalize and the always() cleanup kill again. +# on the host; finalize and the always() cleanup kill again. Every killer +# confirms the pid's LIFECYCLE before signaling: the launch also records +# the loop's start time (heartbeat_start_ticks), and a pid reused between +# launch and kill belongs to a different process — a killer signals only +# a pid whose /proc//stat start time still matches the launch +# capture, and kills nothing otherwise (a dead pid has no stat and a +# reused one a different start time, so a failed check always means the +# loop is already gone). Each tick additionally stamps +# heartbeat-tick-inflight with its start epoch around its gh call and +# removes it after: finalize drains that stamp before its terminal PATCH, +# because killing the client cannot cancel a request the server already +# accepted. # # PAT note: the loop holds the bot PAT in its environment. Its lifetime is # bounded to the sandboxed agent phase — the agent executes PR content only @@ -209,11 +220,28 @@ run_loop() { if command -v timeout > /dev/null 2>&1; then GH_PATCH=(timeout 60 gh) fi + # In-flight stamp for finalize's drain: killing this loop ends the + # client, but a PATCH the server already ACCEPTED still commits (the + # race that flipped a terminal comment back to "working"), so + # finalize must wait the last tick's request out before writing the + # terminal text. The stamp carries the tick's start epoch; the + # bounded call above gives finalize the 65s completion bound it + # drains against. The write is bounded like the pid-identity read: + # WORKDIR is sandbox-writable, and a planted FIFO at this path would + # otherwise block the loop inside the tick, past the age cap. A + # failed stamp degrades to the pre-drain race, never stalls the + # pulse. + if command -v timeout > /dev/null 2>&1; then + timeout 5 bash -c 'date +%s > "$0"' "${HB_WORKDIR}/heartbeat-tick-inflight" 2> /dev/null || true + else + date +%s > "${HB_WORKDIR}/heartbeat-tick-inflight" 2> /dev/null || true + fi if ! GH_CONFIG_DIR="${gh_config_dir}" "${GH_PATCH[@]}" api --method PATCH \ "repos/${HB_REPO}/issues/comments/${HB_COMMENT_ID}" \ -f body="${body}" > /dev/null 2>&1; then echo "$(date -u +%FT%TZ) PATCH failed; continuing" fi + rm -f "${HB_WORKDIR}/heartbeat-tick-inflight" 2> /dev/null || true rm -rf "${gh_config_dir}" done } diff --git a/.github/scripts/autofix-status-heartbeat.test.mjs b/.github/scripts/autofix-status-heartbeat.test.mjs index 93fbca6ee60..acb7e2c93c5 100644 --- a/.github/scripts/autofix-status-heartbeat.test.mjs +++ b/.github/scripts/autofix-status-heartbeat.test.mjs @@ -51,6 +51,13 @@ function fakeGhBin(dir) { 'set -u', 'n=$(( $(ls -1 "${GH_RECORD_DIR}" | wc -l) + 1 ))', 'for a in "$@"; do printf \'%s\\0\' "$a"; done > "${GH_RECORD_DIR}/call-${n}"', + '# The in-flight stamp witness: record, FROM INSIDE the bounded', + '# window, whether heartbeat-tick-inflight brackets this call.', + 'if [ -f "${HB_WORKDIR:-/nonexistent}/heartbeat-tick-inflight" ]; then', + ' printf \'INFLIGHT=yes CONTENT=%s\\n\' "$(cat "${HB_WORKDIR}/heartbeat-tick-inflight" 2>/dev/null)" >> "${GH_RECORD_DIR}/stamp.log"', + 'else', + ' printf \'INFLIGHT=no CONTENT=\\n\' >> "${GH_RECORD_DIR}/stamp.log"', + 'fi', "printf 'GH_HOST=%s GH_CONFIG_DIR=%s CFG_EXISTS=%s CFG_ENTRIES=%s GITHUB_TOKEN=%s GH_TOKEN=%s GH_ENTERPRISE_TOKEN=%s\\n' \\", ' "${GH_HOST:-}" "${GH_CONFIG_DIR:-}" \\', ' "$([ -d "${GH_CONFIG_DIR:-/nonexistent}" ] && echo yes || echo no)" \\', @@ -439,6 +446,17 @@ describe('autofix-status-heartbeat loop', () => { pidRead.some((a) => a.endsWith('heartbeat.pid')), `the bounded read must target heartbeat.pid: ${pidRead.join(' ')}`, ); + // The in-flight stamp WRITE is bounded the same way: a planted + // FIFO at heartbeat-tick-inflight must not block the redirect + // open inside every tick (the twin guard above pins the same + // doctrine for the pid-file read). + const stampWrite = timeoutCalls.find( + (c) => + c[0] === '5' && + c[1] === 'bash' && + c.some((a) => a.endsWith('heartbeat-tick-inflight')), + ); + assert.ok(stampWrite, 'the stamp write must run under timeout 5'); } finally { killGroup(child); } @@ -776,6 +794,49 @@ describe('autofix-status-heartbeat loop', () => { } }); + it('stamps each tick in flight around the gh call and clears it after', async () => { + // finalize's drain relies on heartbeat-tick-inflight bracketing + // every gh call: the fake gh observes the stamp FROM INSIDE the + // bounded window (present, with a numeric epoch), and the stamp + // must not outlive a clean loop exit — a stale stamp would make + // finalize wait the full bound for a request that already ended. + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env, workdir } = loopEnv(dir, gh); + const child = startLoop(env); + try { + const ok = await waitFor( + () => + readCalls(gh.records).length >= 2 && + existsSync(join(gh.records, 'stamp.log')) && + readFileSync(join(gh.records, 'stamp.log'), 'utf8').trim().split('\n') + .length >= 2, + 8000, + ); + assert.ok( + ok, + 'expected at least two PATCH calls with stamp observations', + ); + const stamps = readFileSync(join(gh.records, 'stamp.log'), 'utf8') + .trim() + .split('\n'); + for (const line of stamps) { + assert.ok(line.startsWith('INFLIGHT=yes CONTENT='), line); + assert.match(line.split('CONTENT=')[1], /^\d+$/, line); + } + // End the loop cleanly; the stamp must be gone afterwards. + rmSync(join(workdir, 'heartbeat.pid')); + const code = await awaitExit(child, 8000); + assert.equal(code, 0, 'the loop must end cleanly on pid removal'); + assert.ok( + !existsSync(join(workdir, 'heartbeat-tick-inflight')), + 'the stamp must not outlive the loop', + ); + } finally { + killGroup(child); + } + }); + // The mid-tick kill-topology witness needs coreutils `timeout` (which // gives the tick its own process group) and procps pkill/pgrep (the // session kill and its oracle); hosts without them still carry the @@ -888,4 +949,39 @@ describe('autofix-status-heartbeat loop', () => { } }, ); + + it( + 'a planted FIFO at heartbeat-tick-inflight cannot block the loop past the bounded write', + { + skip: haveSessionKillTools + ? false + : 'requires coreutils timeout (the bounded-write guard)', + }, + async () => { + // WORKDIR is sandbox-writable, so a FIFO planted at the stamp + // path would block an unguarded `date > file` redirect open + // inside EVERY tick — stalling the loop past the age cap. The + // bounded `timeout 5 bash -c` write must give up, and the tick + // must proceed to its PATCH (degrading to the pre-drain race, + // never stopping the pulse). Real coreutils timeout runs here — + // no shim on PATH in this test. + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env, workdir } = loopEnv(dir, gh); + spawnSync('mkfifo', [join(workdir, 'heartbeat-tick-inflight')]); + const child = startLoop(env); + try { + // 1s interval + the 5s write bound: the first PATCH lands + // around 6-7s in. + const ok = await waitFor( + () => readCalls(gh.records).length >= 1, + 20000, + ); + assert.ok(ok, 'the bounded stamp write must not stall the tick'); + assert.ok(child.exitCode === null, 'the loop must keep pulsing'); + } finally { + killGroup(child); + } + }, + ); }); diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 156d652642f..cb4d657e4a2 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -34,7 +34,7 @@ 6495 pr-self-report-label.yml 9646 qwen-autofix-fork-bridge.yml 5942 qwen-autofix-fork-signal.yml -415852 qwen-autofix.yml +420247 qwen-autofix.yml 7061 qwen-ci-flaky-rerun.yml 158010 qwen-code-pr-review.yml 79041 qwen-fleet-shepherd.yml diff --git a/.github/workflows/qwen-autofix.md b/.github/workflows/qwen-autofix.md index 59542e0bf0b..153711598de 100644 --- a/.github/workflows/qwen-autofix.md +++ b/.github/workflows/qwen-autofix.md @@ -3787,6 +3787,29 @@ ever executed in a PAT-bearing or post-agent context, and the gate's own kill uses absolute-path/builtin command words per that step's shadowing doctrine. +LIFECYCLE CONFIRMATION on every kill: the pid recorded at +launch can be REUSED between the launch and a kill — the +gate lands up to a whole agent phase after post_status +recorded the id, and finalize and the always() cleanup run +hours later still; by then the runner may have recycled +the number. The blind form was probe-verified fatal: +mapped to an unrelated detached session, the stale pid's +kill block terminated it (pid, group AND session TERM). +Every killer therefore confirms the pid's start time +before signaling: post_status also records the loop's +start time (heartbeat_start_ticks — field 22 of +/proc//stat, clock ticks since boot; index 19 after +stripping through the LAST ')' of the parenthesized +comm), and a killer signals only a pid whose stat still +carries exactly that value. A reused pid necessarily +carries a different start time and a dead pid carries no +stat at all, so a failed check proves the loop is gone +and killing nothing is right — the confirmation can only +ever SUPPRESS a kill, never admit a wrong one (its +residual is the narrow stat-read→signal window, the same +residual the decimal check it replaces carried for its +whole lifetime). + PAT TRADE, chosen deliberately within that lifetime: the loop holds the bot PAT in env — a temporal overlap the "THIS step holds no PAT" rule (af-126) otherwise avoids. @@ -3876,10 +3899,25 @@ connection cannot stall the loop past the cap. Killers that run in-round touch the stop marker BEFORE killing so a missed kill still ends the loop at its next self-check — a tick landing after the terminal text would overwrite it -with a live-looking "working" line — and finalize -additionally sleeps past one PATCH -round-trip so an already-dispatched tick cannot land after -the terminal text server-side. +with a live-looking "working" line. The terminal text is +additionally DRAINED, not slept past: the fixed 2s sleep +proved wrong on probe — killing the client cannot cancel a +PATCH the server already ACCEPTED (reproduced: WORKING +accepted 1.67s in, TERMINAL submitted 3.80s in, the stale +WORKING committed 6.67s in and flipped the comment back to +live-looking). Each tick therefore stamps its start epoch +into heartbeat-tick-inflight around its gh call and +removes it after; finalize waits until the stamp is ABSENT +or older than the 65s completion bound (the tick's 60s +gh timeout plus margin) BEFORE its terminal PATCH — every +request started before that bound has committed or died +by the time the terminal text goes up. The stamp is a +wait input, never a kill target: a planted fresh stamp +costs at most the bound in finalize delay, a planted +deletion reopens only the cosmetic overwrite (nothing +rides the stamp but the comment text), and finalize's +read is bounded like the loop's pid-file read so a +planted FIFO cannot stall it. ``` diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index c3498f65b60..1c8dde61eda 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -4866,6 +4866,7 @@ jobs: # WORKDIR is sandbox-writable, so no WORKDIR file is ever read # as a kill target. Full rationale → qwen-autofix.md#af-148 HEARTBEAT_PID='' + HEARTBEAT_START_TICKS='' # Re-verify immediately before THIS launch (R8-1): the check above # is separated from this second, PAT-holding execution by the two # gh round-trips of the comment upsert — a live swap window in the @@ -4880,10 +4881,22 @@ jobs: setsid bash --norc "${RUNNER_TEMP}/autofix-status-heartbeat.sh" loop & HEARTBEAT_PID=$! disown 2> /dev/null || true + # Lifecycle pin for the killers: between this launch and + # the later kills the pid above can be REUSED by the + # runner, so every killer confirms the pid's start time + # (field 22 of /proc//stat — index 19 after stripping + # through the LAST ')', since comm can carry spaces) + # against this capture before signaling. + # Full rationale → qwen-autofix.md#af-148 + HB_STAT="$(cat "/proc/${HEARTBEAT_PID}/stat" 2>/dev/null)" || HB_STAT='' + HB_REST="${HB_STAT##*) }" + HB_FIELDS=(${HB_REST}) + HEARTBEAT_START_TICKS="${HB_FIELDS[19]:-}" fi # Hand the id to the finalize step so it does not repeat this scan. echo "comment_id=${STATUS_ID}" >> "${GITHUB_OUTPUT}" echo "heartbeat_pid=${HEARTBEAT_PID}" >> "${GITHUB_OUTPUT}" + echo "heartbeat_start_ticks=${HEARTBEAT_START_TICKS}" >> "${GITHUB_OUTPUT}" - name: 'Triage and address' id: 'address' @@ -5061,7 +5074,24 @@ jobs: # Full rationale → qwen-autofix.md#af-148 /usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}" + HB_START_TICKS="${{ steps.post_status.outputs.heartbeat_start_ticks }}" + # Lifecycle-confirmed shutdown: this kill can land the whole + # agent phase after the launch recorded HB_PID, and a pid + # that old can already be REUSED — TERMing it would kill an + # unrelated process, its group and session. Signal only + # after /proc//stat still shows the start time this + # round's launch captured: a reused pid carries a different + # start time, a dead pid has no stat — a failed check means + # the loop is gone and killing nothing is right. The parse + # is syntax-only plus /usr/bin/cat, per this shell's + # shadowing doctrine. Full rationale → qwen-autofix.md#af-148 + HB_FIELDS=() if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then + HB_STAT="$(/usr/bin/cat "/proc/${HB_PID}/stat" 2>/dev/null)" || HB_STAT='' + HB_REST="${HB_STAT##*) }" + HB_FIELDS=(${HB_REST}) + fi + if [[ "${#HB_FIELDS[@]}" -gt 19 && -n "${HB_START_TICKS}" && "${HB_FIELDS[19]}" == "${HB_START_TICKS}" ]]; then /usr/bin/kill -- -"${HB_PID}" 2> /dev/null || true /usr/bin/kill "${HB_PID}" 2> /dev/null || true /usr/bin/pkill -TERM -s "${HB_PID}" 2> /dev/null || true @@ -6247,6 +6277,7 @@ jobs: RUNNER_TEMP="${RUNNER_TEMP}" \ WORKDIR="${WORKDIR}" \ HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}" \ + HB_START_TICKS="${{ steps.post_status.outputs.heartbeat_start_ticks }}" \ STATUS_ID="${STATUS_ID}" \ PUSH_REPORTED="${PUSH_REPORTED}" \ OUTCOME="${OUTCOME}" \ @@ -6257,25 +6288,49 @@ jobs: PR="${PR}" \ bash --norc -c ' set -uo pipefail - # Stop the round heartbeat BEFORE flipping this comment to - # its terminal text: a tick landing after the finalize would - # overwrite it with a live-looking "working" line. The gate - # already killed the loop before the host-side branch code; - # this is the belt to its braces (a missed kill there must - # not outlive the round). The session kill covers a kill - # landing mid-tick (the timeout/gh subtree of a tick sits - # in its own process group under the session of the loop). - # The stop marker ends the loop on its next self-check even - # if the kills miss, and the sleep lets an already- - # dispatched tick PATCH land before the terminal text goes - # up. Full rationale → qwen-autofix.md#af-148 + # Stop the round heartbeat BEFORE flipping this comment + # to its terminal text, and DRAIN it first: killing the + # client cannot cancel a PATCH the server already + # ACCEPTED, so the terminal PATCH must wait the last + # in-flight tick request out (the stamp drain below). + # The kill is lifecycle-confirmed: the gate killed this + # loop long before this step, and by now the pid + # recorded at launch can be REUSED — TERMing it would + # kill an unrelated process, its group and session. A + # failed check means the loop is already gone, so + # killing nothing is right; the stop marker ends it on + # its next self-check even if the kills miss. + # Full rationale → qwen-autofix.md#af-148 /usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true + HB_FIELDS=() if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then + HB_STAT="$(cat "/proc/${HB_PID}/stat" 2>/dev/null)" || HB_STAT='' + HB_REST="${HB_STAT##*) }" + HB_FIELDS=(${HB_REST}) + fi + if [[ "${#HB_FIELDS[@]}" -gt 19 && -n "${HB_START_TICKS:-}" && "${HB_FIELDS[19]}" == "${HB_START_TICKS}" ]]; then builtin kill -- -"${HB_PID}" 2>/dev/null || true builtin kill "${HB_PID}" 2>/dev/null || true /usr/bin/pkill -TERM -s "${HB_PID}" 2>/dev/null || true - /usr/bin/sleep 2 fi + # Drain the last in-flight tick before the terminal + # PATCH: each tick stamps its start epoch around its gh + # call (bounded to 60s), so a stamp older than 65s + # proves its request committed or died, and no stamp + # means nothing is in flight. The loop is dead by here, + # so no new stamp can appear. The read is bounded (a + # planted FIFO must not stall this step); a planted + # fresh stamp costs at most the 65s bound, a planted + # deletion reopens only the cosmetic overwrite. + DRAIN_END=$(( $(date +%s) + 65 )) + while :; do + NOW_S="$(date +%s)" + (( NOW_S >= DRAIN_END )) && break + STAMP="$(timeout 5 cat "${WORKDIR}/heartbeat-tick-inflight" 2> /dev/null)" || STAMP='' + [[ "${STAMP}" =~ ^[0-9]+$ ]] || break + (( STAMP + 65 <= NOW_S )) && break + sleep 1 + done # Hermetic gh config, the upsert twin'"'"'s shape: pinning # HOME'"'"'s PATH does not sanitize its CONTENTS, which the # gate-phase host-side branch code (same UID) can write @@ -6341,11 +6396,21 @@ jobs: run: |- # Last heartbeat kill (the gate and finalize already did this on # their paths) from the expression-context pid — never from a - # WORKDIR file, which is sandbox-writable. The session kill - # covers the mid-tick subtree, same as its twins. + # WORKDIR file, which is sandbox-writable. Lifecycle-confirmed + # like its twins and stalest of all: signal only a pid whose + # /proc//stat start time still matches the launch + # capture, kill nothing otherwise. The session kill covers + # the mid-tick subtree, same as its twins. # Full rationale → qwen-autofix.md#af-148 HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}" + HB_START_TICKS="${{ steps.post_status.outputs.heartbeat_start_ticks }}" + HB_FIELDS=() if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then + HB_STAT="$(cat "/proc/${HB_PID}/stat" 2>/dev/null)" || HB_STAT='' + HB_REST="${HB_STAT##*) }" + HB_FIELDS=(${HB_REST}) + fi + if [[ "${#HB_FIELDS[@]}" -gt 19 && -n "${HB_START_TICKS}" && "${HB_FIELDS[19]}" == "${HB_START_TICKS}" ]]; then kill -- -"${HB_PID}" 2>/dev/null || true kill "${HB_PID}" 2>/dev/null || true pkill -TERM -s "${HB_PID}" 2>/dev/null || true diff --git a/docs/design/autofix-round-heartbeat.md b/docs/design/autofix-round-heartbeat.md index 9765b75d16d..001d3f31646 100644 --- a/docs/design/autofix-round-heartbeat.md +++ b/docs/design/autofix-round-heartbeat.md @@ -84,11 +84,21 @@ orphan loops unacceptable): `/tmp` on the same path and runs as the same user, so branch code the agent executes can plant any value there. The on-disk `${WORKDIR}/heartbeat.pid` survives for diagnostics and the loop's own - self-checks only. -3. `Finalize autofix status comment` touches `heartbeat-stop` and kills - **before** its own PATCH (avoids racing the terminal text), then sleeps - past one PATCH round-trip so an already-dispatched tick cannot land - after the terminal text server-side. + self-checks only. Every kill is additionally **lifecycle-confirmed**: + the launch records the loop's start time (`heartbeat_start_ticks`, + field 22 of `/proc//stat`) and a killer signals only a pid whose + stat still carries exactly that value — a pid reused between launch + and kill carries a different start time and a dead pid has no stat, + so a failed check kills nothing. +3. `Finalize autofix status comment` touches `heartbeat-stop`, kills + (lifecycle-confirmed), and **drains the in-flight stamp before** its + own PATCH: each tick stamps `heartbeat-tick-inflight` with its start + epoch around its gh call (bounded to 60s) and removes it after, and + finalize waits until the stamp is absent or older than the 65s + completion bound. Killing the client cannot cancel a PATCH the server + already accepted — the fixed 2s sleep this replaced was + probe-refuted: a stale WORKING committed after the terminal text and + flipped the comment back to live-looking. 4. `Clean up autofix workdir` (`always()`) kills again as belt-and-braces. 5. `Reset autofix workspace` does NOT kill: a cross-run pid would have to come from the untrusted file class. Wiping `WORKDIR` removes the pid @@ -232,7 +242,10 @@ comment is never worse than today. - **Liveness-signal integrity.** The sandbox can delete or overwrite `heartbeat.pid` (ending the pulse early via the identity self-check), touch `heartbeat-stop`, or bump `agent.log`'s mtime to forge "agent - active 0 min ago". The attacker can only mislabel or silence their own + active 0 min ago". It can also plant or delete + `heartbeat-tick-inflight` — costing finalize at most the 65s drain + bound, or reopening the cosmetic terminal-overwrite race the drain + closes. The attacker can only mislabel or silence their own round's progress — no token, no execution, no kill reach — so this is accepted rather than engineered around. - **Post-gate silence.** After the gate kills the loop, the comment holds diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 16a4980fbaf..e402c9ff725 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -11685,12 +11685,27 @@ exit 1 // gate's outer shell, where a BASH_FUNC_builtin%% plant shadows the // `builtin` keyword itself — R10-1; `builtin kill` is sound only // inside finalize's env -i clean child), step-level pin as the kill - // target, nothing executable read from disk. The repair gate does not - // repeat it — the loop is already dead by then. + // target, nothing executable read from disk — and the lifecycle + // confirmation's parse is SYNTAX-ONLY plus /usr/bin/cat for the same + // reason. The repair gate does not repeat it — the loop is already + // dead by then. const heartbeatKillStatements = [ '/usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true', 'HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}"', + 'HB_START_TICKS="${{ steps.post_status.outputs.heartbeat_start_ticks }}"', + // Lifecycle confirmation before any signal: the pid recorded at + // launch can be REUSED by the time a kill lands, and a blind kill + // would TERM an unrelated process, its group and session + // (probe-verified). A reused pid carries a different + // /proc//stat start time and a dead pid has no stat, so a + // failed check kills nothing. + 'HB_FIELDS=()', 'if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then', + 'HB_STAT="$(/usr/bin/cat "/proc/${HB_PID}/stat" 2>/dev/null)" || HB_STAT=\'\'', + 'HB_REST="${HB_STAT##*) }"', + 'HB_FIELDS=(${HB_REST})', + 'fi', + 'if [[ "${#HB_FIELDS[@]}" -gt 19 && -n "${HB_START_TICKS}" && "${HB_FIELDS[19]}" == "${HB_START_TICKS}" ]]; then', '/usr/bin/kill -- -"${HB_PID}" 2> /dev/null || true', '/usr/bin/kill "${HB_PID}" 2> /dev/null || true', // The mid-tick cover: each tick's `timeout 60 gh` subtree runs in @@ -15969,6 +15984,15 @@ exit 1 // AND on its own age cap. expect(heartbeatScript).toContain('heartbeat.pid'); expect(heartbeatScript).toContain('heartbeat-stop'); + // The in-flight stamp finalize's drain waits on: written AROUND every + // gh call under a bounded `timeout 5` guard (a planted FIFO at the + // path must not stall the loop inside the tick) and removed after, + // so it never outlives its request. + expect(heartbeatScript).toContain('heartbeat-tick-inflight'); + expect(heartbeatScript).toContain('date +%s > "$0"'); + expect(heartbeatScript).toContain( + 'rm -f "${HB_WORKDIR}/heartbeat-tick-inflight" 2> /dev/null || true', + ); expect(heartbeatScript).toContain('HB_MAX_AGE_SECONDS'); expect(heartbeatScript).toContain('HB_INTERVAL_SECONDS'); // The default age cap sits just past the 330-minute job envelope: a @@ -16350,6 +16374,29 @@ exit 1 expect(postStatusCommentStep).toContain( 'echo "heartbeat_pid=${HEARTBEAT_PID}" >> "${GITHUB_OUTPUT}"', ); + // The killers' lifecycle pin (af-148): a pid recorded at launch can + // be REUSED before a kill lands, so the launch also records the + // loop's /proc//stat start time and every killer confirms it + // before signaling. Field 22 is index 19 after stripping the + // parenthesized comm — through the LAST ')', since comm can carry + // spaces. + expect(postStatusCommentStep).toContain("HEARTBEAT_START_TICKS=''"); + expect(postStatusCommentStep).toContain( + 'HB_STAT="$(cat "/proc/${HEARTBEAT_PID}/stat" 2>/dev/null)" || HB_STAT=\'\'', + ); + expect(postStatusCommentStep).toContain('HB_REST="${HB_STAT##*) }"'); + expect(postStatusCommentStep).toContain('HB_FIELDS=(${HB_REST})'); + expect(postStatusCommentStep).toContain( + 'HEARTBEAT_START_TICKS="${HB_FIELDS[19]:-}"', + ); + expect(postStatusCommentStep).toContain( + 'echo "heartbeat_start_ticks=${HEARTBEAT_START_TICKS}" >> "${GITHUB_OUTPUT}"', + ); + // The capture reads the launched pid's stat — it must follow the + // launch, or it would pin a process that is not this loop. + expect(postStatusCommentStep.indexOf('HEARTBEAT_PID=$!')).toBeLessThan( + postStatusCommentStep.indexOf('/proc/${HEARTBEAT_PID}/stat'), + ); // Kill discipline (af-148): kill targets come from expression context // — a pid read from a WORKDIR file would be an untrusted kill target, @@ -16361,6 +16408,8 @@ exit 1 // untrusted file; wiping the dir ends the loop at its own next // self-check). const killTarget = '${{ steps.post_status.outputs.heartbeat_pid }}'; + const ticksTarget = + '${{ steps.post_status.outputs.heartbeat_start_ticks }}'; // The review lane's gate — extracted from reviewAddressJob itself, so // a kill planted in the issue lane's same-named step cannot satisfy // these pins. @@ -16371,6 +16420,21 @@ exit 1 expect(gateStep).toContain('heartbeat-stop'); expect(gateStep).toContain(`HB_PID="${killTarget}"`); expect(gateStep).toContain('kill -- -"${HB_PID}"'); + // Lifecycle confirmation: the pid recorded at launch can be REUSED + // by the time a kill lands, so each killer confirms the pid's + // /proc//stat start time against the launch capture before + // signaling — a reused pid carries a different start time and a + // dead pid has no stat, so a failed check kills nothing. + expect(gateStep).toContain(`HB_START_TICKS="${ticksTarget}"`); + expect(gateStep).toContain('HB_FIELDS=(${HB_REST})'); + expect(gateStep).toContain( + 'if [[ "${#HB_FIELDS[@]}" -gt 19 && -n "${HB_START_TICKS}" && "${HB_FIELDS[19]}" == "${HB_START_TICKS}" ]]; then', + ); + // The confirmation must GATE the kills: a check that runs but cannot + // suppress them is decoration. + expect( + gateStep.indexOf('"${HB_FIELDS[19]}" == "${HB_START_TICKS}"'), + ).toBeLessThan(gateStep.indexOf('kill -- -"${HB_PID}"')); // Finalize holds the PAT like the gate, so its kill block takes the // same absolute-path/builtin form: bare names are PATH-resolved (the // job's own $GITHUB_PATH append keeps ${RUNNER_TEMP}/qwen-bin ahead @@ -16383,13 +16447,37 @@ exit 1 expect(finalizeStatusCommentStep).toContain( 'builtin kill -- -"${HB_PID}" 2>/dev/null || true', ); + expect(finalizeStatusCommentStep).toContain( + `HB_START_TICKS="${ticksTarget}"`, + ); + expect(finalizeStatusCommentStep).toContain( + 'if [[ "${#HB_FIELDS[@]}" -gt 19 && -n "${HB_START_TICKS:-}" && "${HB_FIELDS[19]}" == "${HB_START_TICKS}" ]]; then', + ); expect(finalizeStatusCommentStep.indexOf('heartbeat-stop')).toBeLessThan( finalizeStatusCommentStep.indexOf('--method PATCH'), ); - // A tick already dispatched when the kill lands can still be applied - // server-side after the terminal text: finalize sleeps past one PATCH - // round-trip before its own PATCH. - expect(finalizeStatusCommentStep).toContain('/usr/bin/sleep 2'); + // Killing the client cannot cancel a PATCH the server already + // ACCEPTED (probe-reproduced: a stale WORKING committed after the + // terminal text and flipped the comment back to live-looking), so + // the former fixed 2s sleep cannot order the terminal text last: + // finalize DRAINS the in-flight stamp instead — it waits until + // heartbeat-tick-inflight is ABSENT or older than the 65s + // completion bound (the tick's 60s gh timeout plus margin), and + // only then PATCHes. The read is bounded like the loop's pid-file + // read — a planted FIFO at the path must not stall the step. + expect(finalizeStatusCommentStep).not.toContain('/usr/bin/sleep 2'); + expect(finalizeStatusCommentStep).toContain( + 'DRAIN_END=$(( $(date +%s) + 65 ))', + ); + expect(finalizeStatusCommentStep).toContain( + 'STAMP="$(timeout 5 cat "${WORKDIR}/heartbeat-tick-inflight" 2> /dev/null)" || STAMP=\'\'', + ); + expect(finalizeStatusCommentStep).toContain( + '(( STAMP + 65 <= NOW_S )) && break', + ); + expect( + finalizeStatusCommentStep.indexOf('heartbeat-tick-inflight'), + ).toBeLessThan(finalizeStatusCommentStep.indexOf('--method PATCH')); // Finalize holds the PAT too, so it takes the gate steps' // startup-channel pins at step level — BASH_ENV is sourced at process // STARTUP before line 1 of the body (an in-body unset is one hop @@ -16436,6 +16524,7 @@ exit 1 'RUNNER_TEMP="${RUNNER_TEMP}"', 'WORKDIR="${WORKDIR}"', 'HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}"', + 'HB_START_TICKS="${{ steps.post_status.outputs.heartbeat_start_ticks }}"', 'STATUS_ID="${STATUS_ID}"', 'PUSH_REPORTED="${PUSH_REPORTED}"', 'OUTCOME="${OUTCOME}"', @@ -16485,7 +16574,8 @@ exit 1 // pins above, which a spawn cannot model. const finalizeRunBody = finalizeStatusCommentStep .slice(finalizeStatusCommentStep.indexOf('run: |-') + 'run: |-'.length) - .replaceAll('${{ steps.post_status.outputs.heartbeat_pid }}', ''); + .replaceAll('${{ steps.post_status.outputs.heartbeat_pid }}', '') + .replaceAll('${{ steps.post_status.outputs.heartbeat_start_ticks }}', ''); const finalizeProbeDir = mkdtempSync( join(tmpdir(), 'autofix-finalize-r91-'), ); @@ -16579,11 +16669,203 @@ exit 1 } finally { rmSync(finalizeProbeDir, { recursive: true, force: true }); } + // Lifecycle-confirmation witness (both arms): run the same child + // body against a live unrelated process standing in for a REUSED + // pid. A start-time MISMATCH must suppress every kill (the victim + // survives and the finalize PATCH still happens); the MATCHING + // start time admits the kill (the victim dies). Deleting the + // confirmation green-lights the blind kill again and fails the + // first arm — the exact defect the confirmation closes. + // The victim's stdio must not hold spawnSync's pipe open, or the + // launch would block until the victim exits and the pid would be + // dead before the probe reads its stat. + const lifecycleVictim = spawnSync( + 'bash', + ['-c', 'sleep 30 /dev/null 2>&1 & echo $!'], + { encoding: 'utf8' }, + ); + const victimPid = lifecycleVictim.stdout.trim(); + expect(victimPid).toMatch(/^\d+$/); + const victimTicks = spawnSync( + 'bash', + ['-c', `awk '{print $22}' /proc/${victimPid}/stat`], + { encoding: 'utf8' }, + ).stdout.trim(); + expect(victimTicks).toMatch(/^\d+$/); + const lifecycleRunBody = finalizeStatusCommentStep + .slice(finalizeStatusCommentStep.indexOf('run: |-') + 'run: |-'.length) + .replaceAll('${{ steps.post_status.outputs.heartbeat_pid }}', victimPid); + const lifecycleProbeDir = mkdtempSync( + join(tmpdir(), 'autofix-finalize-lifecycle-'), + ); + const lifecycleProbeBin = join(lifecycleProbeDir, 'bin'); + mkdirSync(lifecycleProbeBin); + const lifecycleProbeOut = join(lifecycleProbeDir, 'probe-out'); + writeFileSync( + join(lifecycleProbeBin, 'gh'), + [ + '#!/usr/bin/env bash', + `printf 'STUB_GH_RAN args=%s\\n' "$*" >> "${lifecycleProbeOut}"`, + '', + ].join('\n'), + { mode: 0o755 }, + ); + const lifecycleProbeEnv = { + TRUSTED_PATH: `${lifecycleProbeBin}:/usr/bin:/bin`, + HOME: lifecycleProbeDir, + GITHUB_TOKEN: 'SECRET_PAT', + RUNNER_TEMP: lifecycleProbeDir, + WORKDIR: lifecycleProbeDir, + STATUS_ID: '12345', + PUSH_REPORTED: 'true', + OUTCOME: 'fixed', + EFFECTIVE_ROUND: '3', + ROUND: '3', + RUN_URL: 'https://example.invalid/runs/1', + REPO: 'octo/repo', + PR: '77', + }; + const processIsAlive = (pid) => { + try { + process.kill(Number(pid), 0); + return true; + } catch { + return false; + } + }; + try { + // Mismatch arm: the recorded start time is NOT the victim's (the + // pid was reused) — every kill must be suppressed. + const mismatch = spawnSync( + 'bash', + [ + '-e', + '-o', + 'pipefail', + '-c', + lifecycleRunBody.replaceAll( + '${{ steps.post_status.outputs.heartbeat_start_ticks }}', + '1', + ), + ], + { encoding: 'utf8', env: lifecycleProbeEnv }, + ); + expect(mismatch.status).toBe(0); + expect(processIsAlive(victimPid)).toBe(true); + expect(readFileSync(lifecycleProbeOut, 'utf8')).toContain('STUB_GH_RAN'); + // Match arm: the recorded start time IS the victim's — the kill + // is admitted and lands. + rmSync(lifecycleProbeOut, { force: true }); + const match = spawnSync( + 'bash', + [ + '-e', + '-o', + 'pipefail', + '-c', + lifecycleRunBody.replaceAll( + '${{ steps.post_status.outputs.heartbeat_start_ticks }}', + victimTicks, + ), + ], + { encoding: 'utf8', env: lifecycleProbeEnv }, + ); + expect(match.status).toBe(0); + expect(processIsAlive(victimPid)).toBe(false); + } finally { + try { + process.kill(Number(victimPid), 'SIGKILL'); + } catch { + // already gone — the match arm killed it + } + rmSync(lifecycleProbeDir, { recursive: true, force: true }); + } + // Drain witness: the terminal PATCH waits the in-flight stamp out. + // A near-fresh stamp (64s old) makes finalize wait its remaining + // bound; an already-aged stamp (70s old) proceeds at once; a planted + // FIFO at the stamp path cannot stall the step. A mutant that sleeps + // a FIXED span instead of draining fails the aged arm's upper bound + // or the fresh arm's lower bound, and deleting the bounded read + // hangs the FIFO arm. + const drainProbeDir = mkdtempSync( + join(tmpdir(), 'autofix-finalize-drain-'), + ); + const drainProbeBin = join(drainProbeDir, 'bin'); + mkdirSync(drainProbeBin); + const drainProbeOut = join(drainProbeDir, 'probe-out'); + writeFileSync( + join(drainProbeBin, 'gh'), + [ + '#!/usr/bin/env bash', + `printf 'STUB_GH_RAN\\n' >> "${drainProbeOut}"`, + '', + ].join('\n'), + { mode: 0o755 }, + ); + const drainProbeEnv = { + TRUSTED_PATH: `${drainProbeBin}:/usr/bin:/bin`, + HOME: drainProbeDir, + GITHUB_TOKEN: 'SECRET_PAT', + RUNNER_TEMP: drainProbeDir, + WORKDIR: drainProbeDir, + STATUS_ID: '12345', + PUSH_REPORTED: 'true', + OUTCOME: 'fixed', + EFFECTIVE_ROUND: '3', + ROUND: '3', + RUN_URL: 'https://example.invalid/runs/1', + REPO: 'octo/repo', + PR: '77', + }; + const stampPath = join(drainProbeDir, 'heartbeat-tick-inflight'); + const runDrainArm = (prep) => { + rmSync(drainProbeOut, { force: true }); + rmSync(stampPath, { force: true }); + prep(); + const startedAt = Date.now(); + const res = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', finalizeRunBody], + { + encoding: 'utf8', + env: drainProbeEnv, + }, + ); + return { res, elapsedMs: Date.now() - startedAt }; + }; + try { + const fresh = runDrainArm(() => + writeFileSync(stampPath, String(Math.floor(Date.now() / 1000) - 64)), + ); + expect(fresh.res.status).toBe(0); + expect(readFileSync(drainProbeOut, 'utf8')).toContain('STUB_GH_RAN'); + expect(fresh.elapsedMs).toBeGreaterThanOrEqual(800); + expect(fresh.elapsedMs).toBeLessThan(10000); + const aged = runDrainArm(() => + writeFileSync(stampPath, String(Math.floor(Date.now() / 1000) - 70)), + ); + expect(aged.res.status).toBe(0); + expect(readFileSync(drainProbeOut, 'utf8')).toContain('STUB_GH_RAN'); + expect(aged.elapsedMs).toBeLessThan(4000); + const fifo = runDrainArm(() => { + spawnSync('mkfifo', [stampPath]); + }); + expect(fifo.res.status).toBe(0); + expect(readFileSync(drainProbeOut, 'utf8')).toContain('STUB_GH_RAN'); + expect(fifo.elapsedMs).toBeLessThan(15000); + } finally { + rmSync(drainProbeDir, { recursive: true, force: true }); + } const cleanupStep = reviewAddressJob.match( /- name: 'Clean up autofix workdir'[\s\S]*?(?=\n[ ]{6}- name: '|\n[ ]{2}# ==========|$)/, )?.[0] ?? ''; expect(cleanupStep).toContain(`HB_PID="${killTarget}"`); + expect(cleanupStep).toContain(`HB_START_TICKS="${ticksTarget}"`); + expect(cleanupStep).toContain('HB_FIELDS=(${HB_REST})'); + expect(cleanupStep).toContain( + 'if [[ "${#HB_FIELDS[@]}" -gt 19 && -n "${HB_START_TICKS}" && "${HB_FIELDS[19]}" == "${HB_START_TICKS}" ]]; then', + ); expect(cleanupStep).toContain('kill -- -"${HB_PID}"'); expect(cleanupStep.indexOf('kill -- -"${HB_PID}"')).toBeLessThan( cleanupStep.indexOf('rm -rf "${WORKDIR}"'), From b287dbba588a8f51f67cf95faa2ec129188b7256 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Thu, 27 Aug 2026 00:30:19 +0000 Subject: [PATCH 18/19] fix(autofix): env-route heartbeat kill targets, cap override magnitude, and de-flake the drain witness (R15-1..R16-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R15-1: gate the lifecycle-confirmation and drain witnesses on capability (procfs + coreutils timeout), the launchWitnessSupported shape — the gated macOS lane collects this suite and went red on both witnesses. R15-2: plant the drain witness's fresh stamp from inside the probe shell, relative to the drain's own clock, aged 63s — a wall-clock -64 plant left the stamp's sub-second age unknown, so a first drain check landing in the next second legitimately broke at once (probe: 13/40 immediate breaks at the boundary; 0/40 with the fix). R15-3: the launch-side lifecycle capture reads /proc through /usr/bin/cat, the gate twin's shadowing doctrine — a bare cat is shadowed by a $GITHUB_ENV-planted BASH_FUNC_cat%% function and a forged start time travels to every killer. R15-4: shellcheck disable directive on the intentional single-quoted stamp write ($0 is the inner bash's positional) — the repo's established SC2016 handling. R16-1: the two heartbeat outputs ride step-level env: blocks to all three killers (the STATUS_ID shape) instead of run-body interpolation, which substituted a forged output as shell syntax before the shell parsed; pins flipped to the env shape plus run-body absence, a forged $(touch proof) ticks arm witnesses the data path, and the producer numeric-validates the ticks before $GITHUB_OUTPUT. R16-2: magnitude bounds on the HB_INTERVAL_SECONDS / HB_MAX_AGE_SECONDS overrides — shape alone admitted a huge interval the loop never wakes from and a tiny age cap that kills the pulse after the first sleep; the cap floor is the 330-minute job envelope. --- .github/scripts/autofix-status-heartbeat.sh | 12 + .../scripts/autofix-status-heartbeat.test.mjs | 36 +- .github/workflows/qwen-autofix.md | 7 +- .github/workflows/qwen-autofix.yml | 45 +- docs/design/autofix-round-heartbeat.md | 7 +- scripts/tests/qwen-autofix-workflow.test.js | 465 ++++++++++-------- 6 files changed, 367 insertions(+), 205 deletions(-) diff --git a/.github/scripts/autofix-status-heartbeat.sh b/.github/scripts/autofix-status-heartbeat.sh index 806de7e5e4c..3e0b8e37fee 100644 --- a/.github/scripts/autofix-status-heartbeat.sh +++ b/.github/scripts/autofix-status-heartbeat.sh @@ -155,8 +155,19 @@ run_loop() { local max_age="${HB_MAX_AGE_SECONDS:-20400}" # Numeric guards: a malformed or zero override must degrade to the # defaults, never into a sleep-less busy loop hammering the API. + # Shape alone is not enough: the loop sleeps BEFORE its age check, + # so a well-formed huge interval — no production launcher sets + # either variable, so such a value can only arrive through an env + # plant — means the loop never wakes again (zero pulses, and the + # age cap that bounds an orphan's PAT window becomes unreachable), + # while a tiny age cap silently kills the pulse after the first + # sleep — the frozen comment this feature eliminates. Bound the + # magnitude too; the cap's floor is the 330-minute job envelope, so + # a live round's pulse always outlives the round. [[ "${interval}" =~ ^[1-9][0-9]*$ ]] || interval=600 + (( interval <= 3600 )) || interval=600 [[ "${max_age}" =~ ^[1-9][0-9]*$ ]] || max_age=20400 + (( max_age >= 19800 && max_age <= 21600 )) || max_age=20400 local start="${HB_START_EPOCH}" echo "$(date -u +%FT%TZ) heartbeat started: comment ${HB_COMMENT_ID} interval ${interval}s max_age ${max_age}s" while :; do @@ -232,6 +243,7 @@ run_loop() { # failed stamp degrades to the pre-drain race, never stalls the # pulse. if command -v timeout > /dev/null 2>&1; then + # shellcheck disable=SC2016 timeout 5 bash -c 'date +%s > "$0"' "${HB_WORKDIR}/heartbeat-tick-inflight" 2> /dev/null || true else date +%s > "${HB_WORKDIR}/heartbeat-tick-inflight" 2> /dev/null || true diff --git a/.github/scripts/autofix-status-heartbeat.test.mjs b/.github/scripts/autofix-status-heartbeat.test.mjs index acb7e2c93c5..5a6a64f98ec 100644 --- a/.github/scripts/autofix-status-heartbeat.test.mjs +++ b/.github/scripts/autofix-status-heartbeat.test.mjs @@ -410,6 +410,35 @@ describe('autofix-status-heartbeat loop', () => { } finally { killGroup(child); } + // Magnitude plants pass the shape guard and ride the same env + // carrier: a huge interval means the loop sleeps past every + // bound — zero pulses, and the age cap that limits an orphan's + // PAT window becomes unreachable; a tiny age cap kills the pulse + // after the first sleep. Both must degrade to the defaults like + // the malformed arm (R16-2). + const magDir = freshTmp(); + const magGh = fakeGhBin(magDir); + const { env: magEnv, workdir: magWorkdir } = loopEnv(magDir, magGh, { + HB_INTERVAL_SECONDS: '99999999999', + HB_MAX_AGE_SECONDS: '1', + }); + const magChild = startLoop(magEnv); + try { + const ok = await waitFor(() => { + const log = join(magWorkdir, 'heartbeat.log'); + return ( + existsSync(log) && + readFileSync(log, 'utf8').includes('heartbeat started') + ); + }, 8000); + assert.ok(ok, 'the loop must start and log its parameters'); + assert.match( + readFileSync(join(magWorkdir, 'heartbeat.log'), 'utf8'), + /interval 600s max_age 20400s/, + ); + } finally { + killGroup(magChild); + } }); it('runs each PATCH under timeout so a black-holed request cannot outlive the age cap', async () => { @@ -590,9 +619,12 @@ describe('autofix-status-heartbeat loop', () => { it('self-exits at the age cap', async () => { const dir = freshTmp(); const gh = fakeGhBin(dir); + // The cap sits at the magnitude floor: the guards degrade a + // smaller plant to the default, so nothing below the job + // envelope can exercise this path. const { env, workdir } = loopEnv(dir, gh, { - HB_MAX_AGE_SECONDS: '1', - HB_START_EPOCH: String(Math.floor(Date.now() / 1000) - 5), + HB_MAX_AGE_SECONDS: '19800', + HB_START_EPOCH: String(Math.floor(Date.now() / 1000) - 19805), }); const child = startLoop(env); const code = await awaitExit(child, 8000); diff --git a/.github/workflows/qwen-autofix.md b/.github/workflows/qwen-autofix.md index 153711598de..c9e0beae690 100644 --- a/.github/workflows/qwen-autofix.md +++ b/.github/workflows/qwen-autofix.md @@ -3771,7 +3771,12 @@ flips the terminal text. KILL TARGETS travel through EXPRESSION CONTEXT: post_status records $! as heartbeat_pid, and the gate / finalize / the -always() cleanup kill that value. Never a pid read from a +always() cleanup kill that value — delivered through each +killer's step-level env: block (the STATUS_ID shape), so the +runner sets it as data; an interpolation inside a run body +would substitute a forged output BEFORE the shell parses, so +it would execute as shell syntax in the consuming shell +(R16-1). Never a pid read from a WORKDIR file: the agent's docker sandbox mounts the host /tmp on the same path and runs as this same user, so branch code the agent executes can plant any value in diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 1c8dde61eda..756bfb81f6f 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -4886,13 +4886,24 @@ jobs: # runner, so every killer confirms the pid's start time # (field 22 of /proc//stat — index 19 after stripping # through the LAST ')', since comm can carry spaces) - # against this capture before signaling. + # against this capture before signaling. The read is + # /usr/bin/cat, the gate twin's shadowing doctrine: + # a bare cat is shadowed by a $GITHUB_ENV-planted + # BASH_FUNC_cat%% function, imported at bash STARTUP + # even under --norc, ahead of builtins and PATH, and + # a forged start time travels to every killer (R15-3). # Full rationale → qwen-autofix.md#af-148 - HB_STAT="$(cat "/proc/${HEARTBEAT_PID}/stat" 2>/dev/null)" || HB_STAT='' + HB_STAT="$(/usr/bin/cat "/proc/${HEARTBEAT_PID}/stat" 2>/dev/null)" || HB_STAT='' HB_REST="${HB_STAT##*) }" HB_FIELDS=(${HB_REST}) HEARTBEAT_START_TICKS="${HB_FIELDS[19]:-}" fi + # Defense in depth on the producer: the ticks come from + # the /proc parse above in this PAT-holding shell, and a + # non-numeric value must never reach $GITHUB_OUTPUT — + # the consumers route them through step env (the + # STATUS_ID shape), where the runner sets them as data. + [[ "${HEARTBEAT_START_TICKS}" =~ ^[0-9]+$ ]] || HEARTBEAT_START_TICKS='' # Hand the id to the finalize step so it does not repeat this scan. echo "comment_id=${STATUS_ID}" >> "${GITHUB_OUTPUT}" echo "heartbeat_pid=${HEARTBEAT_PID}" >> "${GITHUB_OUTPUT}" @@ -5052,6 +5063,15 @@ jobs: # Growth-audit rounds must carry a valid growth-audit.json verdict; # the gate enforces presence + shape before any push decision. KISS_AUDIT: '${{ steps.prepare.outputs.kiss_audit }}' + # The heartbeat kill targets travel through step env, + # the STATUS_ID shape: an expression interpolation + # inside the run body substitutes the value BEFORE the + # shell parses it, so a forged step output (post_status' + # shell is pollutable) would arrive as shell syntax and + # execute in this shell; step env is set by the runner + # as data (R16-1). + HB_PID: '${{ steps.post_status.outputs.heartbeat_pid }}' + HB_START_TICKS: '${{ steps.post_status.outputs.heartbeat_start_ticks }}' run: |- # The round heartbeat ends HERE: this is the first step that runs # branch code ON THE HOST (the agent phase sandboxes it in @@ -5073,8 +5093,6 @@ jobs: # the same procps already relied on for pkill. # Full rationale → qwen-autofix.md#af-148 /usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true - HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}" - HB_START_TICKS="${{ steps.post_status.outputs.heartbeat_start_ticks }}" # Lifecycle-confirmed shutdown: this kill can land the whole # agent phase after the launch recorded HB_PID, and a pid # that old can already be REUSED — TERMing it would kill an @@ -6250,6 +6268,13 @@ jobs: # can kill its shell at execve, silent exit 0) — the success text # must not assert a report that never posted. PUSH_REPORTED: '${{ steps.push_report.outputs.round_reported }}' + # The heartbeat kill targets ride step env, the STATUS_ID + # shape and the gate step's doctrine (R16-1): the clean + # child below re-declares them from these pins exactly + # like STATUS_ID, so a forged output arrives as data, + # never as run-body shell syntax. + HB_PID: '${{ steps.post_status.outputs.heartbeat_pid }}' + HB_START_TICKS: '${{ steps.post_status.outputs.heartbeat_start_ticks }}' run: |- # This step holds the PAT, and its shell inherits every # $GITHUB_ENV plant earlier steps left — including @@ -6276,8 +6301,8 @@ jobs: GH_HOST=github.com \ RUNNER_TEMP="${RUNNER_TEMP}" \ WORKDIR="${WORKDIR}" \ - HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}" \ - HB_START_TICKS="${{ steps.post_status.outputs.heartbeat_start_ticks }}" \ + HB_PID="${HB_PID}" \ + HB_START_TICKS="${HB_START_TICKS}" \ STATUS_ID="${STATUS_ID}" \ PUSH_REPORTED="${PUSH_REPORTED}" \ OUTCOME="${OUTCOME}" \ @@ -6393,6 +6418,12 @@ jobs: # autofix workspace if that PR is never addressed again. - name: 'Clean up autofix workdir' if: 'always()' + env: + # The heartbeat kill targets ride step env, the STATUS_ID + # shape and the gate step's doctrine (R16-1): the runner + # sets them as data, never as run-body shell syntax. + HB_PID: '${{ steps.post_status.outputs.heartbeat_pid }}' + HB_START_TICKS: '${{ steps.post_status.outputs.heartbeat_start_ticks }}' run: |- # Last heartbeat kill (the gate and finalize already did this on # their paths) from the expression-context pid — never from a @@ -6402,8 +6433,6 @@ jobs: # capture, kill nothing otherwise. The session kill covers # the mid-tick subtree, same as its twins. # Full rationale → qwen-autofix.md#af-148 - HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}" - HB_START_TICKS="${{ steps.post_status.outputs.heartbeat_start_ticks }}" HB_FIELDS=() if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then HB_STAT="$(cat "/proc/${HB_PID}/stat" 2>/dev/null)" || HB_STAT='' diff --git a/docs/design/autofix-round-heartbeat.md b/docs/design/autofix-round-heartbeat.md index 001d3f31646..84202e6155d 100644 --- a/docs/design/autofix-round-heartbeat.md +++ b/docs/design/autofix-round-heartbeat.md @@ -75,7 +75,12 @@ orphan loops unacceptable): gate/repair and finalize flips the terminal text. 2. Kill targets travel through **expression context**: the launch records `$!` as a `heartbeat_pid` step output, and the gate / finalize / - cleanup kill that value — the pid, its process group, AND its session: + cleanup kill that value — routed through each killer's step-level + `env:` block (the `STATUS_ID` shape) so the runner sets it as data: + a run-body interpolation would substitute a forged output BEFORE + the shell parses, so it would execute as shell syntax in the + consuming shell (R16-1). The kills cover the pid, its process + group, AND its session: each tick's `timeout 60 gh` subtree runs in its own process group (coreutils `timeout` default) under the loop's setsid session, so a group/pid kill alone leaves it alive holding the PAT for up to 60s. diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index e402c9ff725..2ccb734843e 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -11691,8 +11691,6 @@ exit 1 // dead by then. const heartbeatKillStatements = [ '/usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true', - 'HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}"', - 'HB_START_TICKS="${{ steps.post_status.outputs.heartbeat_start_ticks }}"', // Lifecycle confirmation before any signal: the pid recorded at // launch can be REUSED by the time a kill lands, and a blind kill // would TERM an unrelated process, its group and session @@ -16002,6 +16000,16 @@ exit 1 // reopen the window this cap exists to shrink. expect(heartbeatScript).toContain('HB_MAX_AGE_SECONDS:-20400'); expect(heartbeatScript).toContain('|| max_age=20400'); + // Magnitude bounds (R16-2): shape alone admits a well-formed + // plant that defeats the pulse — a huge interval the loop never + // wakes from (the age cap that bounds an orphan's PAT window + // becomes unreachable), or a tiny age cap that kills it after + // the first sleep. Overrides outside the accepted range degrade + // to the defaults. + expect(heartbeatScript).toContain('(( interval <= 3600 )) || interval=600'); + expect(heartbeatScript).toContain( + '(( max_age >= 19800 && max_age <= 21600 )) || max_age=20400', + ); // Every tick's gh call runs under the af-112 hermetic pins: GH_HOST // pinned and planted tokens dropped at launch, and the config dir // minted PER TICK inside the loop (R11-1) — RUNNER_TEMP is @@ -16382,7 +16390,7 @@ exit 1 // spaces. expect(postStatusCommentStep).toContain("HEARTBEAT_START_TICKS=''"); expect(postStatusCommentStep).toContain( - 'HB_STAT="$(cat "/proc/${HEARTBEAT_PID}/stat" 2>/dev/null)" || HB_STAT=\'\'', + 'HB_STAT="$(/usr/bin/cat "/proc/${HEARTBEAT_PID}/stat" 2>/dev/null)" || HB_STAT=\'\'', ); expect(postStatusCommentStep).toContain('HB_REST="${HB_STAT##*) }"'); expect(postStatusCommentStep).toContain('HB_FIELDS=(${HB_REST})'); @@ -16392,6 +16400,18 @@ exit 1 expect(postStatusCommentStep).toContain( 'echo "heartbeat_start_ticks=${HEARTBEAT_START_TICKS}" >> "${GITHUB_OUTPUT}"', ); + // Producer depth (R16-1): the ticks come out of a /proc parse in + // this PAT-holding shell; a non-numeric value must be dropped + // before it reaches $GITHUB_OUTPUT — the script's own NOW_EPOCH + // doctrine applied to the launch. + expect(postStatusCommentStep).toContain( + '[[ "${HEARTBEAT_START_TICKS}" =~ ^[0-9]+$ ]] || HEARTBEAT_START_TICKS=\'\'', + ); + expect( + postStatusCommentStep.indexOf('^[0-9]+$ ]] || HEARTBEAT_START_TICKS'), + ).toBeLessThan( + postStatusCommentStep.indexOf('echo "heartbeat_start_ticks='), + ); // The capture reads the launched pid's stat — it must follow the // launch, or it would pin a process that is not this loop. expect(postStatusCommentStep.indexOf('HEARTBEAT_PID=$!')).toBeLessThan( @@ -16410,6 +16430,15 @@ exit 1 const killTarget = '${{ steps.post_status.outputs.heartbeat_pid }}'; const ticksTarget = '${{ steps.post_status.outputs.heartbeat_start_ticks }}'; + // Those expressions may appear ONLY in a step env: block — the + // runner sets an env value as data. Inside a run body the runner + // substitutes the value BEFORE the shell parses, so a forged + // step output (post_status' shell is pollutable) would arrive as + // shell syntax and execute in the consuming shell — finalize's + // PAT-bearing one among them (R16-1). Every killer therefore + // takes the STATUS_ID shape, and no run body may carry either + // expression. + const runBodyOf = (stepText) => stepText.slice(stepText.indexOf('run: |-')); // The review lane's gate — extracted from reviewAddressJob itself, so // a kill planted in the issue lane's same-named step cannot satisfy // these pins. @@ -16418,14 +16447,16 @@ exit 1 /- name: 'Verification gate'[\s\S]*?(?=\n[ ]{6}- name: ')/, )?.[0] ?? ''; expect(gateStep).toContain('heartbeat-stop'); - expect(gateStep).toContain(`HB_PID="${killTarget}"`); + expect(gateStep).toContain(`HB_PID: '${killTarget}'`); + expect(gateStep).toContain(`HB_START_TICKS: '${ticksTarget}'`); + expect(runBodyOf(gateStep)).not.toContain(killTarget); + expect(runBodyOf(gateStep)).not.toContain(ticksTarget); expect(gateStep).toContain('kill -- -"${HB_PID}"'); // Lifecycle confirmation: the pid recorded at launch can be REUSED // by the time a kill lands, so each killer confirms the pid's // /proc//stat start time against the launch capture before // signaling — a reused pid carries a different start time and a // dead pid has no stat, so a failed check kills nothing. - expect(gateStep).toContain(`HB_START_TICKS="${ticksTarget}"`); expect(gateStep).toContain('HB_FIELDS=(${HB_REST})'); expect(gateStep).toContain( 'if [[ "${#HB_FIELDS[@]}" -gt 19 && -n "${HB_START_TICKS}" && "${HB_FIELDS[19]}" == "${HB_START_TICKS}" ]]; then', @@ -16443,12 +16474,14 @@ exit 1 expect(finalizeStatusCommentStep).toContain( '/usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true', ); - expect(finalizeStatusCommentStep).toContain(`HB_PID="${killTarget}"`); + expect(finalizeStatusCommentStep).toContain(`HB_PID: '${killTarget}'`); expect(finalizeStatusCommentStep).toContain( - 'builtin kill -- -"${HB_PID}" 2>/dev/null || true', + `HB_START_TICKS: '${ticksTarget}'`, ); + expect(runBodyOf(finalizeStatusCommentStep)).not.toContain(killTarget); + expect(runBodyOf(finalizeStatusCommentStep)).not.toContain(ticksTarget); expect(finalizeStatusCommentStep).toContain( - `HB_START_TICKS="${ticksTarget}"`, + 'builtin kill -- -"${HB_PID}" 2>/dev/null || true', ); expect(finalizeStatusCommentStep).toContain( 'if [[ "${#HB_FIELDS[@]}" -gt 19 && -n "${HB_START_TICKS:-}" && "${HB_FIELDS[19]}" == "${HB_START_TICKS}" ]]; then', @@ -16523,8 +16556,8 @@ exit 1 'GH_HOST=github.com', 'RUNNER_TEMP="${RUNNER_TEMP}"', 'WORKDIR="${WORKDIR}"', - 'HB_PID="${{ steps.post_status.outputs.heartbeat_pid }}"', - 'HB_START_TICKS="${{ steps.post_status.outputs.heartbeat_start_ticks }}"', + 'HB_PID="${HB_PID}"', + 'HB_START_TICKS="${HB_START_TICKS}"', 'STATUS_ID="${STATUS_ID}"', 'PUSH_REPORTED="${PUSH_REPORTED}"', 'OUTCOME="${OUTCOME}"', @@ -16572,10 +16605,11 @@ exit 1 // the plants and fails here. BASH_ENV/SHELLOPTS/LD_* plants stay out // of the probe env on purpose: those are closed by the step-level // pins above, which a spawn cannot model. - const finalizeRunBody = finalizeStatusCommentStep - .slice(finalizeStatusCommentStep.indexOf('run: |-') + 'run: |-'.length) - .replaceAll('${{ steps.post_status.outputs.heartbeat_pid }}', '') - .replaceAll('${{ steps.post_status.outputs.heartbeat_start_ticks }}', ''); + // The run body carries NO heartbeat interpolation (pinned above): + // the probe executes it exactly as the runner would. + const finalizeRunBody = finalizeStatusCommentStep.slice( + finalizeStatusCommentStep.indexOf('run: |-') + 'run: |-'.length, + ); const finalizeProbeDir = mkdtempSync( join(tmpdir(), 'autofix-finalize-r91-'), ); @@ -16666,202 +16700,247 @@ exit 1 expect(noStatus.status).toBe(0); expect(noStatus.stdout).toContain('nothing to finalize'); expect(existsSync(finalizeProbeOut)).toBe(false); + // R16-1 witness: the kill targets ride step env and expand as + // DATA — a forged ticks output carrying command substitution + // must not execute anywhere in this body. + rmSync(finalizeProbeOut, { force: true }); + const forgedProof = join(finalizeProbeDir, 'forged-proof'); + const forged = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', finalizeRunBody], + { + encoding: 'utf8', + env: { + ...finalizeProbeEnv, + HB_PID: '999999', + HB_START_TICKS: `$(touch "${forgedProof}")`, + }, + }, + ); + expect(forged.status).toBe(0); + expect(existsSync(forgedProof)).toBe(false); + expect(readFileSync(finalizeProbeOut, 'utf8')).toContain( + 'STUB_GH_RAN token=SECRET_PAT', + ); } finally { rmSync(finalizeProbeDir, { recursive: true, force: true }); } - // Lifecycle-confirmation witness (both arms): run the same child - // body against a live unrelated process standing in for a REUSED - // pid. A start-time MISMATCH must suppress every kill (the victim - // survives and the finalize PATCH still happens); the MATCHING - // start time admits the kill (the victim dies). Deleting the - // confirmation green-lights the blind kill again and fails the - // first arm — the exact defect the confirmation closes. - // The victim's stdio must not hold spawnSync's pipe open, or the - // launch would block until the victim exits and the pid would be - // dead before the probe reads its stat. - const lifecycleVictim = spawnSync( - 'bash', - ['-c', 'sleep 30 /dev/null 2>&1 & echo $!'], - { encoding: 'utf8' }, - ); - const victimPid = lifecycleVictim.stdout.trim(); - expect(victimPid).toMatch(/^\d+$/); - const victimTicks = spawnSync( - 'bash', - ['-c', `awk '{print $22}' /proc/${victimPid}/stat`], - { encoding: 'utf8' }, - ).stdout.trim(); - expect(victimTicks).toMatch(/^\d+$/); - const lifecycleRunBody = finalizeStatusCommentStep - .slice(finalizeStatusCommentStep.indexOf('run: |-') + 'run: |-'.length) - .replaceAll('${{ steps.post_status.outputs.heartbeat_pid }}', victimPid); - const lifecycleProbeDir = mkdtempSync( - join(tmpdir(), 'autofix-finalize-lifecycle-'), - ); - const lifecycleProbeBin = join(lifecycleProbeDir, 'bin'); - mkdirSync(lifecycleProbeBin); - const lifecycleProbeOut = join(lifecycleProbeDir, 'probe-out'); - writeFileSync( - join(lifecycleProbeBin, 'gh'), - [ - '#!/usr/bin/env bash', - `printf 'STUB_GH_RAN args=%s\\n' "$*" >> "${lifecycleProbeOut}"`, - '', - ].join('\n'), - { mode: 0o755 }, - ); - const lifecycleProbeEnv = { - TRUSTED_PATH: `${lifecycleProbeBin}:/usr/bin:/bin`, - HOME: lifecycleProbeDir, - GITHUB_TOKEN: 'SECRET_PAT', - RUNNER_TEMP: lifecycleProbeDir, - WORKDIR: lifecycleProbeDir, - STATUS_ID: '12345', - PUSH_REPORTED: 'true', - OUTCOME: 'fixed', - EFFECTIVE_ROUND: '3', - ROUND: '3', - RUN_URL: 'https://example.invalid/runs/1', - REPO: 'octo/repo', - PR: '77', - }; - const processIsAlive = (pid) => { - try { - process.kill(Number(pid), 0); - return true; - } catch { - return false; - } - }; - try { - // Mismatch arm: the recorded start time is NOT the victim's (the - // pid was reused) — every kill must be suppressed. - const mismatch = spawnSync( + // The lifecycle and drain witnesses below shell out to Linux-only + // facilities — procfs (the victim start-time read and the body's + // stat parse) and unprefixed coreutils `timeout` (the bounded + // stamp read) — while the merge_group/schedule/workflow_dispatch- + // gated macOS lane collects this suite (the vitest config + // excludes it only on win32). Gate on capability, not platform, + // mirroring launchWitnessSupported above; on a capable host the + // witnesses keep executing, and the string pins stay + // unconditional. + const lifecycleWitnessSupported = + spawnSync('bash', [ + '-c', + 'test -r /proc/self/stat && command -v timeout >/dev/null 2>&1', + ]).status === 0; + if (lifecycleWitnessSupported) { + // Lifecycle-confirmation witness (both arms): run the same child + // body against a live unrelated process standing in for a REUSED + // pid. A start-time MISMATCH must suppress every kill (the victim + // survives and the finalize PATCH still happens); the MATCHING + // start time admits the kill (the victim dies). Deleting the + // confirmation green-lights the blind kill again and fails the + // first arm — the exact defect the confirmation closes. The pid + // and start time travel to the body through the step env, the + // R16-1 shape pinned above. + // The victim's stdio must not hold spawnSync's pipe open, or the + // launch would block until the victim exits and the pid would be + // dead before the probe reads its stat. + const lifecycleVictim = spawnSync( 'bash', - [ - '-e', - '-o', - 'pipefail', - '-c', - lifecycleRunBody.replaceAll( - '${{ steps.post_status.outputs.heartbeat_start_ticks }}', - '1', - ), - ], - { encoding: 'utf8', env: lifecycleProbeEnv }, + ['-c', 'sleep 30 /dev/null 2>&1 & echo $!'], + { encoding: 'utf8' }, ); - expect(mismatch.status).toBe(0); - expect(processIsAlive(victimPid)).toBe(true); - expect(readFileSync(lifecycleProbeOut, 'utf8')).toContain('STUB_GH_RAN'); - // Match arm: the recorded start time IS the victim's — the kill - // is admitted and lands. - rmSync(lifecycleProbeOut, { force: true }); - const match = spawnSync( + const victimPid = lifecycleVictim.stdout.trim(); + expect(victimPid).toMatch(/^\d+$/); + const victimTicks = spawnSync( 'bash', + ['-c', `awk '{print $22}' /proc/${victimPid}/stat`], + { encoding: 'utf8' }, + ).stdout.trim(); + expect(victimTicks).toMatch(/^\d+$/); + const lifecycleProbeDir = mkdtempSync( + join(tmpdir(), 'autofix-finalize-lifecycle-'), + ); + const lifecycleProbeBin = join(lifecycleProbeDir, 'bin'); + mkdirSync(lifecycleProbeBin); + const lifecycleProbeOut = join(lifecycleProbeDir, 'probe-out'); + writeFileSync( + join(lifecycleProbeBin, 'gh'), [ - '-e', - '-o', - 'pipefail', - '-c', - lifecycleRunBody.replaceAll( - '${{ steps.post_status.outputs.heartbeat_start_ticks }}', - victimTicks, - ), - ], - { encoding: 'utf8', env: lifecycleProbeEnv }, + '#!/usr/bin/env bash', + `printf 'STUB_GH_RAN args=%s\\n' "$*" >> "${lifecycleProbeOut}"`, + '', + ].join('\n'), + { mode: 0o755 }, ); - expect(match.status).toBe(0); - expect(processIsAlive(victimPid)).toBe(false); - } finally { + const lifecycleProbeEnv = { + TRUSTED_PATH: `${lifecycleProbeBin}:/usr/bin:/bin`, + HOME: lifecycleProbeDir, + GITHUB_TOKEN: 'SECRET_PAT', + RUNNER_TEMP: lifecycleProbeDir, + WORKDIR: lifecycleProbeDir, + STATUS_ID: '12345', + PUSH_REPORTED: 'true', + OUTCOME: 'fixed', + EFFECTIVE_ROUND: '3', + ROUND: '3', + RUN_URL: 'https://example.invalid/runs/1', + REPO: 'octo/repo', + PR: '77', + }; + const processIsAlive = (pid) => { + try { + process.kill(Number(pid), 0); + return true; + } catch { + return false; + } + }; try { - process.kill(Number(victimPid), 'SIGKILL'); - } catch { - // already gone — the match arm killed it + // Mismatch arm: the recorded start time is NOT the victim's (the + // pid was reused) — every kill must be suppressed. + const mismatch = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', finalizeRunBody], + { + encoding: 'utf8', + env: { + ...lifecycleProbeEnv, + HB_PID: victimPid, + HB_START_TICKS: '1', + }, + }, + ); + expect(mismatch.status).toBe(0); + expect(processIsAlive(victimPid)).toBe(true); + expect(readFileSync(lifecycleProbeOut, 'utf8')).toContain( + 'STUB_GH_RAN', + ); + // Match arm: the recorded start time IS the victim's — the kill + // is admitted and lands. + rmSync(lifecycleProbeOut, { force: true }); + const match = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', finalizeRunBody], + { + encoding: 'utf8', + env: { + ...lifecycleProbeEnv, + HB_PID: victimPid, + HB_START_TICKS: victimTicks, + }, + }, + ); + expect(match.status).toBe(0); + expect(processIsAlive(victimPid)).toBe(false); + } finally { + try { + process.kill(Number(victimPid), 'SIGKILL'); + } catch { + // already gone — the match arm killed it + } + rmSync(lifecycleProbeDir, { recursive: true, force: true }); } - rmSync(lifecycleProbeDir, { recursive: true, force: true }); - } - // Drain witness: the terminal PATCH waits the in-flight stamp out. - // A near-fresh stamp (64s old) makes finalize wait its remaining - // bound; an already-aged stamp (70s old) proceeds at once; a planted - // FIFO at the stamp path cannot stall the step. A mutant that sleeps - // a FIXED span instead of draining fails the aged arm's upper bound - // or the fresh arm's lower bound, and deleting the bounded read - // hangs the FIFO arm. - const drainProbeDir = mkdtempSync( - join(tmpdir(), 'autofix-finalize-drain-'), - ); - const drainProbeBin = join(drainProbeDir, 'bin'); - mkdirSync(drainProbeBin); - const drainProbeOut = join(drainProbeDir, 'probe-out'); - writeFileSync( - join(drainProbeBin, 'gh'), - [ - '#!/usr/bin/env bash', - `printf 'STUB_GH_RAN\\n' >> "${drainProbeOut}"`, - '', - ].join('\n'), - { mode: 0o755 }, - ); - const drainProbeEnv = { - TRUSTED_PATH: `${drainProbeBin}:/usr/bin:/bin`, - HOME: drainProbeDir, - GITHUB_TOKEN: 'SECRET_PAT', - RUNNER_TEMP: drainProbeDir, - WORKDIR: drainProbeDir, - STATUS_ID: '12345', - PUSH_REPORTED: 'true', - OUTCOME: 'fixed', - EFFECTIVE_ROUND: '3', - ROUND: '3', - RUN_URL: 'https://example.invalid/runs/1', - REPO: 'octo/repo', - PR: '77', - }; - const stampPath = join(drainProbeDir, 'heartbeat-tick-inflight'); - const runDrainArm = (prep) => { - rmSync(drainProbeOut, { force: true }); - rmSync(stampPath, { force: true }); - prep(); - const startedAt = Date.now(); - const res = spawnSync( - 'bash', - ['-e', '-o', 'pipefail', '-c', finalizeRunBody], - { - encoding: 'utf8', - env: drainProbeEnv, - }, + // Drain witness: the terminal PATCH waits the in-flight stamp out. + // A near-fresh stamp (63–64s old) makes finalize wait its remaining + // bound; an already-aged stamp (70s old) proceeds at once; a planted + // FIFO at the stamp path cannot stall the step. A mutant that sleeps + // a FIXED span instead of draining fails the aged arm's upper bound + // or the fresh arm's lower bound, and deleting the bounded read + // hangs the FIFO arm. + const drainProbeDir = mkdtempSync( + join(tmpdir(), 'autofix-finalize-drain-'), ); - return { res, elapsedMs: Date.now() - startedAt }; - }; - try { - const fresh = runDrainArm(() => - writeFileSync(stampPath, String(Math.floor(Date.now() / 1000) - 64)), - ); - expect(fresh.res.status).toBe(0); - expect(readFileSync(drainProbeOut, 'utf8')).toContain('STUB_GH_RAN'); - expect(fresh.elapsedMs).toBeGreaterThanOrEqual(800); - expect(fresh.elapsedMs).toBeLessThan(10000); - const aged = runDrainArm(() => - writeFileSync(stampPath, String(Math.floor(Date.now() / 1000) - 70)), + const drainProbeBin = join(drainProbeDir, 'bin'); + mkdirSync(drainProbeBin); + const drainProbeOut = join(drainProbeDir, 'probe-out'); + writeFileSync( + join(drainProbeBin, 'gh'), + [ + '#!/usr/bin/env bash', + `printf 'STUB_GH_RAN\\n' >> "${drainProbeOut}"`, + '', + ].join('\n'), + { mode: 0o755 }, ); - expect(aged.res.status).toBe(0); - expect(readFileSync(drainProbeOut, 'utf8')).toContain('STUB_GH_RAN'); - expect(aged.elapsedMs).toBeLessThan(4000); - const fifo = runDrainArm(() => { - spawnSync('mkfifo', [stampPath]); - }); - expect(fifo.res.status).toBe(0); - expect(readFileSync(drainProbeOut, 'utf8')).toContain('STUB_GH_RAN'); - expect(fifo.elapsedMs).toBeLessThan(15000); - } finally { - rmSync(drainProbeDir, { recursive: true, force: true }); + const drainProbeEnv = { + TRUSTED_PATH: `${drainProbeBin}:/usr/bin:/bin`, + HOME: drainProbeDir, + GITHUB_TOKEN: 'SECRET_PAT', + RUNNER_TEMP: drainProbeDir, + WORKDIR: drainProbeDir, + STATUS_ID: '12345', + PUSH_REPORTED: 'true', + OUTCOME: 'fixed', + EFFECTIVE_ROUND: '3', + ROUND: '3', + RUN_URL: 'https://example.invalid/runs/1', + REPO: 'octo/repo', + PR: '77', + }; + const stampPath = join(drainProbeDir, 'heartbeat-tick-inflight'); + // The stamp is planted from INSIDE the probe shell, immediately + // before the drain body runs, relative to the drain's own clock — + // the shape the real loop stamps in. A wall-clock plant from THIS + // process instead leaves the stamp's sub-second age unknown to + // the drain: whenever the plant lands late enough in its second, + // the first drain check already sees a stamp >= 65s old and + // legitimately breaks at once — the assertion red while the drain + // behaves exactly as designed (R15-2). Age the fresh stamp 63s, + // not 64: the break needs NOW_S >= stamp + 65, and a 63s-old + // stamp can satisfy that on no first check, so at least one + // `sleep 1` must pass and the lower bound holds deterministically. + const runDrainArm = (stampPlant) => { + rmSync(drainProbeOut, { force: true }); + rmSync(stampPath, { force: true }); + const startedAt = Date.now(); + const res = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', `${stampPlant}\n${finalizeRunBody}`], + { + encoding: 'utf8', + env: drainProbeEnv, + }, + ); + return { res, elapsedMs: Date.now() - startedAt }; + }; + try { + const fresh = runDrainArm( + 'echo $(( $(date +%s) - 63 )) > "${WORKDIR}/heartbeat-tick-inflight"', + ); + expect(fresh.res.status).toBe(0); + expect(readFileSync(drainProbeOut, 'utf8')).toContain('STUB_GH_RAN'); + expect(fresh.elapsedMs).toBeGreaterThanOrEqual(800); + expect(fresh.elapsedMs).toBeLessThan(10000); + const aged = runDrainArm( + 'echo $(( $(date +%s) - 70 )) > "${WORKDIR}/heartbeat-tick-inflight"', + ); + expect(aged.res.status).toBe(0); + expect(readFileSync(drainProbeOut, 'utf8')).toContain('STUB_GH_RAN'); + expect(aged.elapsedMs).toBeLessThan(4000); + const fifo = runDrainArm('mkfifo "${WORKDIR}/heartbeat-tick-inflight"'); + expect(fifo.res.status).toBe(0); + expect(readFileSync(drainProbeOut, 'utf8')).toContain('STUB_GH_RAN'); + expect(fifo.elapsedMs).toBeLessThan(15000); + } finally { + rmSync(drainProbeDir, { recursive: true, force: true }); + } } const cleanupStep = reviewAddressJob.match( /- name: 'Clean up autofix workdir'[\s\S]*?(?=\n[ ]{6}- name: '|\n[ ]{2}# ==========|$)/, )?.[0] ?? ''; - expect(cleanupStep).toContain(`HB_PID="${killTarget}"`); - expect(cleanupStep).toContain(`HB_START_TICKS="${ticksTarget}"`); + expect(cleanupStep).toContain(`HB_PID: '${killTarget}'`); + expect(cleanupStep).toContain(`HB_START_TICKS: '${ticksTarget}'`); + expect(runBodyOf(cleanupStep)).not.toContain(killTarget); + expect(runBodyOf(cleanupStep)).not.toContain(ticksTarget); expect(cleanupStep).toContain('HB_FIELDS=(${HB_REST})'); expect(cleanupStep).toContain( 'if [[ "${#HB_FIELDS[@]}" -gt 19 && -n "${HB_START_TICKS}" && "${HB_FIELDS[19]}" == "${HB_START_TICKS}" ]]; then', From 7ee39a0b4e44bd3eea6ea750e64cd25422f9716d Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Thu, 27 Aug 2026 08:19:31 +0000 Subject: [PATCH 19/19] fix(autofix): byte-bound pid/stamp reads, digit-bound overrides, close-event witnesses, and absolute-path cleanup kills (R16-2..R17-4) --- .github/scripts/autofix-status-heartbeat.sh | 29 +++--- .../scripts/autofix-status-heartbeat.test.mjs | 90 ++++++++++++++++++- .github/workflows/qwen-autofix.yml | 33 +++++-- scripts/tests/qwen-autofix-workflow.test.js | 60 ++++++++++--- 4 files changed, 176 insertions(+), 36 deletions(-) diff --git a/.github/scripts/autofix-status-heartbeat.sh b/.github/scripts/autofix-status-heartbeat.sh index 41e21579b3a..a117c8024cb 100644 --- a/.github/scripts/autofix-status-heartbeat.sh +++ b/.github/scripts/autofix-status-heartbeat.sh @@ -119,7 +119,7 @@ run_loop() { exit 2 } # Binary-resolution channel: the tick resolves its externals (gh, - # timeout, sleep, date, cat — and the mktemp below) by name, and the + # timeout, sleep, date, head — and the mktemp below) by name, and the # ambient PATH carries same-UID-writable dirs ahead of the system ones # (the job's own $GITHUB_PATH append puts ${RUNNER_TEMP}/qwen-bin # there), so a plant in one of them would be resolved by the next tick @@ -163,10 +163,13 @@ run_loop() { # while a tiny age cap silently kills the pulse after the first # sleep — the frozen comment this feature eliminates. Bound the # magnitude too; the cap's floor is the 330-minute job envelope, so - # a live round's pulse always outlives the round. - [[ "${interval}" =~ ^[1-9][0-9]*$ ]] || interval=600 + # a live round's pulse always outlives the round. The digit bound + # runs FIRST: bash arithmetic wraps modulo 2^64, so a 20+-digit plant + # would pass the comparisons on its wrapped value while the original + # string still reaches sleep — the loop never wakes again (R16-2). + [[ "${interval}" =~ ^[1-9][0-9]{0,3}$ ]] || interval=600 (( interval <= 3600 )) || interval=600 - [[ "${max_age}" =~ ^[1-9][0-9]*$ ]] || max_age=20400 + [[ "${max_age}" =~ ^[1-9][0-9]{0,4}$ ]] || max_age=20400 (( max_age >= 19800 && max_age <= 21600 )) || max_age=20400 local start="${HB_START_EPOCH}" echo "$(date -u +%FT%TZ) heartbeat started: comment ${HB_COMMENT_ID} interval ${interval}s max_age ${max_age}s" @@ -186,16 +189,20 @@ run_loop() { # new round's body on the same comment. The file must still hold THIS # loop's own pid — removed OR replaced (by a newer round) ends the loop. # This reads the file to self-identify only; it never kills anything. - # The read is BOUNDED: WORKDIR is sandbox-writable, so the path can hold - # a planted FIFO whose open blocks cat indefinitely — stalling the loop - # inside the tick, past the age cap above. Mirrors the gh wrapper's - # conditional timeout form below; a timeout kill yields empty → identity - # mismatch → the clean self-exit just below. + # The read is BOUNDED in time AND bytes: WORKDIR is sandbox-writable, + # so the path can hold a planted FIFO whose open blocks the read + # indefinitely, or a symlink to an endless non-NUL stream + # (/dev/urandom) that an unbounded read would pull into bash's + # substitution buffer GB-scale inside one tick of this PAT-holding + # loop — pulse death for the rest of the round (R17-1). A pid is + # ≤ ~10 digits, so 64 bytes cover any real pid file. Mirrors the gh + # wrapper's conditional timeout form below; a timeout kill yields + # empty → identity mismatch → the clean self-exit just below. local pid_now if command -v timeout > /dev/null 2>&1; then - pid_now="$(timeout 5 cat "${HB_WORKDIR}/heartbeat.pid" 2> /dev/null)" + pid_now="$(timeout 5 head -c 64 -- "${HB_WORKDIR}/heartbeat.pid" 2> /dev/null)" else - pid_now="$(cat "${HB_WORKDIR}/heartbeat.pid" 2> /dev/null)" + pid_now="$(head -c 64 -- "${HB_WORKDIR}/heartbeat.pid" 2> /dev/null)" fi if [[ "${pid_now}" != "$$" ]]; then echo "$(date -u +%FT%TZ) self-exit: pid file removed or replaced" diff --git a/.github/scripts/autofix-status-heartbeat.test.mjs b/.github/scripts/autofix-status-heartbeat.test.mjs index f92a163df01..34dc26b7ff8 100644 --- a/.github/scripts/autofix-status-heartbeat.test.mjs +++ b/.github/scripts/autofix-status-heartbeat.test.mjs @@ -267,7 +267,10 @@ describe('autofix-status-heartbeat loop', () => { killGroup(child); resolve('timeout'); }, timeoutMs); - child.on('exit', (code) => { + // 'close', not 'exit': Node can deliver 'exit' before the child's + // stdio streams are drained, leaving the stderr assertions on '' + // under event-loop contention (R17-2, reproduced under full load). + child.on('close', (code) => { clearTimeout(timer); resolve(code); }); @@ -439,6 +442,34 @@ describe('autofix-status-heartbeat loop', () => { } finally { killGroup(magChild); } + // Bash arithmetic wraps modulo 2^64: an interval of exactly 2^64 + // wraps to 0 and passes a comparison-only `<= 3600` guard while the + // 20-digit string still reaches sleep (the loop never wakes again), + // and an age cap of 2^64+20000 wraps INTO the accepted range. The + // digit bound must reject both before any arithmetic (R16-2). + const wrapDir = freshTmp(); + const wrapGh = fakeGhBin(wrapDir); + const { env: wrapEnv, workdir: wrapWorkdir } = loopEnv(wrapDir, wrapGh, { + HB_INTERVAL_SECONDS: '18446744073709551616', + HB_MAX_AGE_SECONDS: '18446744073709571616', + }); + const wrapChild = startLoop(wrapEnv); + try { + const ok = await waitFor(() => { + const log = join(wrapWorkdir, 'heartbeat.log'); + return ( + existsSync(log) && + readFileSync(log, 'utf8').includes('heartbeat started') + ); + }, 8000); + assert.ok(ok, 'the loop must start and log its parameters'); + assert.match( + readFileSync(join(wrapWorkdir, 'heartbeat.log'), 'utf8'), + /interval 600s max_age 20400s/, + ); + } finally { + killGroup(wrapChild); + } }); it('runs each PATCH under timeout so a black-holed request cannot outlive the age cap', async () => { @@ -467,10 +498,16 @@ describe('autofix-status-heartbeat loop', () => { assert.equal(ghCall[0], '60', 'the gh bound must be 60s'); // R10-3: the pid-identity self-check must ALSO be a bounded read, so // a planted FIFO at heartbeat.pid cannot block the loop inside the - // tick, past the age cap. The shim proves the read ran under - // `timeout 5 cat` against the pid file. - const pidRead = timeoutCalls.find((c) => c[0] === '5' && c[1] === 'cat'); + // tick, past the age cap, and R17-1: bounded in BYTES too — a + // symlink to an endless stream (/dev/urandom) must not fill the + // substitution buffer GB-scale inside one tick. The shim proves + // the read ran under `timeout 5 head -c 64` against the pid file. + const pidRead = timeoutCalls.find((c) => c[0] === '5' && c[1] === 'head'); assert.ok(pidRead, 'the pid-identity read must run under timeout 5'); + assert.ok( + pidRead.includes('-c') && pidRead.includes('64'), + `the pid read must carry the byte cap: ${pidRead.join(' ')}`, + ); assert.ok( pidRead.some((a) => a.endsWith('heartbeat.pid')), `the bounded read must target heartbeat.pid: ${pidRead.join(' ')}`, @@ -982,6 +1019,51 @@ describe('autofix-status-heartbeat loop', () => { }, ); + it( + 'a planted endless stream at heartbeat.pid cannot unbound the pid read', + { + skip: haveSessionKillTools + ? false + : 'requires coreutils timeout (the bounded-read guard)', + }, + async () => { + // R17-1: WORKDIR is sandbox-writable, so the path can hold a + // symlink to /dev/urandom — openable, unlike the FIFO arm, and + // endless without blocking: an unbounded read streams it into + // bash's substitution buffer (probe-verified on the pool host: + // multi-hundred-MB capture and a full-core tick), which the time + // bound alone cannot stop. The `head -c 64` byte cap must hold; + // the identity mismatch then ends the loop cleanly. Real + // coreutils timeout runs here — no shim on PATH. + const dir = freshTmp(); + const gh = fakeGhBin(dir); + const { env, workdir } = loopEnv(dir, gh); + const child = startLoop(env); + try { + const started = await waitFor( + () => existsSync(join(workdir, 'heartbeat.pid')), + 8000, + ); + assert.ok(started, 'the loop must register its pid first'); + rmSync(join(workdir, 'heartbeat.pid')); + spawnSync('ln', ['-s', '/dev/urandom', join(workdir, 'heartbeat.pid')]); + // The bounded read lands on the next 1s tick and exits clean; + // an unbounded cat is still churning hundreds of MB when this + // budget ends (probe-measured), so it resolves 'timeout'. + const code = await awaitExit(child, 8000); + assert.equal( + code, + 0, + 'the byte-bounded read must end the loop cleanly, not stream the plant', + ); + const logText = readFileSync(join(workdir, 'heartbeat.log'), 'utf8'); + assert.match(logText, /self-exit: pid file removed or replaced/); + } finally { + killGroup(child); + } + }, + ); + it( 'a planted FIFO at heartbeat-tick-inflight cannot block the loop past the bounded write', { diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index ba2fe3aabe2..51e94badad3 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -6408,15 +6408,20 @@ jobs: # call (bounded to 60s), so a stamp older than 65s # proves its request committed or died, and no stamp # means nothing is in flight. The loop is dead by here, - # so no new stamp can appear. The read is bounded (a - # planted FIFO must not stall this step); a planted - # fresh stamp costs at most the 65s bound, a planted - # deletion reopens only the cosmetic overwrite. + # so no new stamp can appear. The read is bounded in + # time AND bytes (a planted FIFO must not stall this + # step, and a symlink to an endless non-NUL target — + # /dev/urandom or a fed FIFO — would otherwise stream + # into the substitution buffer GB-scale and kill this + # clean child before the terminal PATCH, R17-3); a + # valid stamp is ≤ ~20 bytes, so 64 covers it. A + # planted fresh stamp costs at most the 65s bound, a + # planted deletion reopens only the cosmetic overwrite. DRAIN_END=$(( $(date +%s) + 65 )) while :; do NOW_S="$(date +%s)" (( NOW_S >= DRAIN_END )) && break - STAMP="$(timeout 5 cat "${WORKDIR}/heartbeat-tick-inflight" 2> /dev/null)" || STAMP='' + STAMP="$(timeout 5 head -c 64 -- "${WORKDIR}/heartbeat-tick-inflight" 2> /dev/null)" || STAMP='' [[ "${STAMP}" =~ ^[0-9]+$ ]] || break (( STAMP + 65 <= NOW_S )) && break sleep 1 @@ -6497,16 +6502,26 @@ jobs: # /proc//stat start time still matches the launch # capture, kill nothing otherwise. The session kill covers # the mid-tick subtree, same as its twins. + # ABSOLUTE-PATH-ONLY command words, the gate twin's form: + # this block runs in the step's OUTER shell, which inherits + # every $GITHUB_ENV plant earlier steps left — a + # BASH_FUNC_cat%% import shadows cat at bash startup and + # forges the stat line (the matching start time sits in + # this step's own env), passing the lifecycle check on a + # REUSED pid so the real kill TERMs an unrelated process, + # its group and session; a planted kill or PATH-planted + # pkill no-ops the delivery instead, leaving the loop + # holding the PAT alive to its age cap (R17-4). # Full rationale → qwen-autofix.md#af-149 HB_FIELDS=() if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then - HB_STAT="$(cat "/proc/${HB_PID}/stat" 2>/dev/null)" || HB_STAT='' + HB_STAT="$(/usr/bin/cat "/proc/${HB_PID}/stat" 2>/dev/null)" || HB_STAT='' HB_REST="${HB_STAT##*) }" HB_FIELDS=(${HB_REST}) fi if [[ "${#HB_FIELDS[@]}" -gt 19 && -n "${HB_START_TICKS}" && "${HB_FIELDS[19]}" == "${HB_START_TICKS}" ]]; then - kill -- -"${HB_PID}" 2>/dev/null || true - kill "${HB_PID}" 2>/dev/null || true - pkill -TERM -s "${HB_PID}" 2>/dev/null || true + /usr/bin/kill -- -"${HB_PID}" 2> /dev/null || true + /usr/bin/kill "${HB_PID}" 2> /dev/null || true + /usr/bin/pkill -TERM -s "${HB_PID}" 2> /dev/null || true fi rm -rf "${WORKDIR}" diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 971d25bed55..38180a6c2bc 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -16669,14 +16669,17 @@ exit 1 // finalize DRAINS the in-flight stamp instead — it waits until // heartbeat-tick-inflight is ABSENT or older than the 65s // completion bound (the tick's 60s gh timeout plus margin), and - // only then PATCHes. The read is bounded like the loop's pid-file - // read — a planted FIFO at the path must not stall the step. + // only then PATCHes. The read is bounded in time AND bytes like + // the loop's pid-file read — a planted FIFO must not stall the + // step, and a symlink to an endless non-NUL target (/dev/urandom, + // a fed FIFO) must not fill the substitution buffer GB-scale and + // kill the clean child before the terminal PATCH (R17-3). expect(finalizeStatusCommentStep).not.toContain('/usr/bin/sleep 2'); expect(finalizeStatusCommentStep).toContain( 'DRAIN_END=$(( $(date +%s) + 65 ))', ); expect(finalizeStatusCommentStep).toContain( - 'STAMP="$(timeout 5 cat "${WORKDIR}/heartbeat-tick-inflight" 2> /dev/null)" || STAMP=\'\'', + 'STAMP="$(timeout 5 head -c 64 -- "${WORKDIR}/heartbeat-tick-inflight" 2> /dev/null)" || STAMP=\'\'', ); expect(finalizeStatusCommentStep).toContain( '(( STAMP + 65 <= NOW_S )) && break', @@ -17102,6 +17105,19 @@ exit 1 expect(fifo.res.status).toBe(0); expect(readFileSync(drainProbeOut, 'utf8')).toContain('STUB_GH_RAN'); expect(fifo.elapsedMs).toBeLessThan(15000); + // R17-3: an openable endless non-NUL target — a symlink to + // /dev/urandom — is the plant the FIFO arm cannot cover: the + // unbounded `cat` form streams it into the substitution buffer + // until the allocation fails and kills the clean child before + // the terminal PATCH. The vmem cap keeps a regressed unbounded + // read failing fast instead of allocating GB on the test host; + // the bounded `head -c 64` form passes under it (probe-verified). + const endless = runDrainArm( + 'ulimit -v 200000; ln -s /dev/urandom "${WORKDIR}/heartbeat-tick-inflight"', + ); + expect(endless.res.status).toBe(0); + expect(readFileSync(drainProbeOut, 'utf8')).toContain('STUB_GH_RAN'); + expect(endless.elapsedMs).toBeLessThan(10000); } finally { rmSync(drainProbeDir, { recursive: true, force: true }); } @@ -17115,23 +17131,41 @@ exit 1 expect(runBodyOf(cleanupStep)).not.toContain(killTarget); expect(runBodyOf(cleanupStep)).not.toContain(ticksTarget); expect(cleanupStep).toContain('HB_FIELDS=(${HB_REST})'); + // R17-4: the stat read takes the absolute-path form too — cleanup + // runs in the step's OUTER shell, which inherits every $GITHUB_ENV + // plant earlier steps left, and a BASH_FUNC_cat%% import shadows + // the bare word at bash startup, forging the stat line (the + // matching start time sits in this step's own env) so the + // lifecycle check passes on a REUSED pid and the kill TERMs an + // unrelated process, its group and session. + expect(cleanupStep).toContain( + 'HB_STAT="$(/usr/bin/cat "/proc/${HB_PID}/stat" 2>/dev/null)" || HB_STAT=\'\'', + ); expect(cleanupStep).toContain( 'if [[ "${#HB_FIELDS[@]}" -gt 19 && -n "${HB_START_TICKS}" && "${HB_FIELDS[19]}" == "${HB_START_TICKS}" ]]; then', ); - expect(cleanupStep).toContain('kill -- -"${HB_PID}"'); - expect(cleanupStep.indexOf('kill -- -"${HB_PID}"')).toBeLessThan( + // Same doctrine for the delivery: a planted kill or PATH-planted + // pkill no-ops it, leaving the loop holding the PAT alive to its + // age cap — the defended asset is kill-target trust and delivery, + // not the token cleanup carries. + expect(cleanupStep).toContain( + '/usr/bin/kill -- -"${HB_PID}" 2> /dev/null || true', + ); + expect(cleanupStep.indexOf('/usr/bin/kill -- -"${HB_PID}"')).toBeLessThan( cleanupStep.indexOf('rm -rf "${WORKDIR}"'), ); // Same-round killers also carry the bare-pid fallback. Finalize holds // the PAT inside its env -i clean child, where the builtin form is // sound; the gate's kill runs in the OUTER shell (BASH_FUNC_builtin%% - // shadowable — R10-1), so it takes the absolute-path form; cleanup - // carries no token and keeps the bare form. + // shadowable — R10-1), so it takes the absolute-path form, and + // cleanup takes it for the R17-4 reasons above. expect(gateStep).toContain('/usr/bin/kill "${HB_PID}"'); expect(finalizeStatusCommentStep).toContain( 'builtin kill "${HB_PID}" 2>/dev/null || true', ); - expect(cleanupStep).toContain('kill "${HB_PID}"'); + expect(cleanupStep).toContain( + '/usr/bin/kill "${HB_PID}" 2> /dev/null || true', + ); // A kill landing MID-TICK must reach the tick too: each tick's // `timeout 60 gh` subtree runs in its OWN process group (coreutils // timeout default) inside the loop's setsid session, so the group+pid @@ -17144,13 +17178,15 @@ exit 1 expect(finalizeStatusCommentStep).toContain( '/usr/bin/pkill -TERM -s "${HB_PID}" 2>/dev/null || true', ); - expect(cleanupStep).toContain('pkill -TERM -s "${HB_PID}"'); + expect(cleanupStep).toContain( + '/usr/bin/pkill -TERM -s "${HB_PID}" 2> /dev/null || true', + ); expect( finalizeStatusCommentStep.indexOf('pkill -TERM -s "${HB_PID}"'), ).toBeLessThan(finalizeStatusCommentStep.indexOf('--method PATCH')); - expect(cleanupStep.indexOf('pkill -TERM -s "${HB_PID}"')).toBeLessThan( - cleanupStep.indexOf('rm -rf "${WORKDIR}"'), - ); + expect( + cleanupStep.indexOf('/usr/bin/pkill -TERM -s "${HB_PID}"'), + ).toBeLessThan(cleanupStep.indexOf('rm -rf "${WORKDIR}"')); // Neither reset step carries a kill (a cross-run pid could only come // from the untrusted file class; the comments may still explain why), // and none of the kill sites EXECUTES the heartbeat script (the