-
Notifications
You must be signed in to change notification settings - Fork 3k
fix(review): repair permissions before giving up on worktree cleanup #9748
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
afeaedf
04ace8a
be0f808
7a9fe5d
dcea310
490bafe
5b8f50d
d6c6db6
8fd0342
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -1842,6 +1842,20 @@ jobs: | |||||||||||||
| # the next job on this reused runner can delete qwen-review/* branches. | ||||||||||||||
| # The sweep deletes all review artifacts, not just this PR's: safe because | ||||||||||||||
| # a runner executes one job at a time. | ||||||||||||||
| # | ||||||||||||||
| # The removal owns its own permission repair. A containerised job on this | ||||||||||||||
| # shared pool can leave a review worktree owned by another uid and | ||||||||||||||
| # read-only (measured, run 32577821716 / PR #9718: a leftover | ||||||||||||||
| # scratch-verify tree held files this job's user could not unlink, and | ||||||||||||||
| # the NEXT review's checkout died on them with EACCES — both the | ||||||||||||||
| # pre-checkout ownership restore and the checkout's own wipe degraded | ||||||||||||||
| # because the runner had no passwordless sudo). A removal that gives up | ||||||||||||||
| # on the first EACCES re-poisons the next job, so a failed rm gets a | ||||||||||||||
| # repair ladder instead: chmod what this user owns, then passwordless | ||||||||||||||
| # sudo chown/chmod where the pool member has it, each followed by a | ||||||||||||||
| # retry. Members without sudo still degrade to a named warning — | ||||||||||||||
| # nothing unprivileged can remove a foreign-owned tree — but the heal | ||||||||||||||
| # chain must never fail the job. | ||||||||||||||
| - name: 'Clean review worktrees' | ||||||||||||||
| if: 'always()' | ||||||||||||||
| timeout-minutes: 5 | ||||||||||||||
|
|
@@ -1853,6 +1867,65 @@ jobs: | |||||||||||||
| fi | ||||||||||||||
|
|
||||||||||||||
| GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE") | ||||||||||||||
|
|
||||||||||||||
| # The repair ladder for one leftover tree (see the step comment). | ||||||||||||||
| # A path outside the workspace or resolving through symlinks is | ||||||||||||||
| # refused rather than repaired: the sudo leg escalates to root, | ||||||||||||||
| # and a planted link would aim a chown/chmod -R outside the | ||||||||||||||
| # workspace. Warning echoes strip newlines from the path first: | ||||||||||||||
| # leftover names are untrusted glob entries, and a fresh line on | ||||||||||||||
| # the runner's stdout would parse as a workflow command. | ||||||||||||||
| remove_review_tree() { | ||||||||||||||
| local abs="$1" | ||||||||||||||
| case "$abs" in | ||||||||||||||
| /*) : ;; | ||||||||||||||
| *) abs="$GITHUB_WORKSPACE/$abs" ;; | ||||||||||||||
| esac | ||||||||||||||
| [ -e "$abs" ] || [ -L "$abs" ] || return 0 | ||||||||||||||
| rm -rf "$abs" 2>/dev/null && return 0 | ||||||||||||||
| # Refuse a path that resolves through symlinks, but compare | ||||||||||||||
| # against the workspace's OWN resolved path: an ancestor the | ||||||||||||||
| # workspace itself sits under (a macOS /tmp -> /private/tmp | ||||||||||||||
| # local run) is legitimate and must not read as a redirect — | ||||||||||||||
| # only a symlink planted BELOW the workspace does. The refusal | ||||||||||||||
| # names the branch that fired so the on-call knows which case | ||||||||||||||
| # hit. | ||||||||||||||
| local ws_real rel abs_real reason='' | ||||||||||||||
| ws_real="$(realpath -- "$GITHUB_WORKSPACE" 2>/dev/null)" || | ||||||||||||||
| ws_real="$GITHUB_WORKSPACE" | ||||||||||||||
| case "$abs" in | ||||||||||||||
| "$GITHUB_WORKSPACE"/*) rel="${abs#"$GITHUB_WORKSPACE/"}" ;; | ||||||||||||||
| *) rel='' ;; | ||||||||||||||
| esac | ||||||||||||||
| abs_real="$(realpath -- "$abs" 2>/dev/null)" || abs_real='' | ||||||||||||||
| if [ -z "$rel" ]; then | ||||||||||||||
| reason='outside the workspace' | ||||||||||||||
| elif [ -L "$abs" ]; then | ||||||||||||||
| reason='path is a symlink' | ||||||||||||||
| elif [ -z "$abs_real" ]; then | ||||||||||||||
| reason='path could not be resolved' | ||||||||||||||
| elif [ "$abs_real" != "$ws_real/$rel" ]; then | ||||||||||||||
| reason='resolves through symlinks' | ||||||||||||||
| fi | ||||||||||||||
| if [ -n "$reason" ]; then | ||||||||||||||
| echo "::warning::refusing to repair review worktree path (${reason}): ${abs//$'\n'/ }" | ||||||||||||||
| return 0 | ||||||||||||||
| fi | ||||||||||||||
| chmod -R u+rwX "$abs" 2>/dev/null || true | ||||||||||||||
| rm -rf "$abs" 2>/dev/null && return 0 | ||||||||||||||
| local sudo_probe='absent' | ||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The failure warning's
Suggested change
中文说明失败 warning 里的 — qwen3.8-max via Qwen Code /review (v0.22.0) |
||||||||||||||
| if command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then | ||||||||||||||
| sudo_probe='ok' | ||||||||||||||
| sudo -n chown -R "$(id -u):$(id -g)" "$abs" 2>/dev/null || true | ||||||||||||||
| sudo -n chmod -R u+rwX "$abs" 2>/dev/null || true | ||||||||||||||
| fi | ||||||||||||||
| rm -rf "$abs" 2>/dev/null && return 0 | ||||||||||||||
| echo "::warning::could not remove review worktree: ${abs//$'\n'/ } (permission repair failed; sudo: $sudo_probe; owner: $(ls -ld "$abs" 2>/dev/null | awk '{print $3}'))" | ||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] The newline sanitization this round added covers the two direct Witness — probe of the extracted step against a root-owned leftover named a standalone, runner-parseable command line; with the fix below applied the same probe printed a single line — zero injected commands.
Suggested change
Apply the same 中文说明本轮新增的换行净化只覆盖两个直接的 critical 证据:对提取出的步骤做 probe——root 属主、名字为 — qwen3.8-max via Qwen Code /review (v0.22.0) |
||||||||||||||
| # return 0 even when the warning echo fails: the heal chain must | ||||||||||||||
| # never fail the job. | ||||||||||||||
| return 0 | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+1924
to
+1929
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The new leftover loop is the first place raw shell-glob names reach step stdout. A leftover entry whose name embeds a newline followed by
Suggested change
中文说明新增的残留目录循环是 shell glob 原始文件名第一次直接进入步骤 stdout。名字中嵌入换行加 — qwen3.8-max via Qwen Code /review (v0.22.0) |
||||||||||||||
|
|
||||||||||||||
| "${GIT_SAFE[@]}" worktree prune -v || true | ||||||||||||||
| "${GIT_SAFE[@]}" worktree list --porcelain \ | ||||||||||||||
| | awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \ | ||||||||||||||
|
|
@@ -1873,10 +1946,21 @@ jobs: | |||||||||||||
| continue | ||||||||||||||
| ;; | ||||||||||||||
| esac | ||||||||||||||
| # `git worktree remove` unlinks entries the same way rm does, | ||||||||||||||
| # so a foreign-owned entry defeats it too; the repair ladder | ||||||||||||||
| # retries it, and whatever git still leaves behind goes through | ||||||||||||||
| # the same ladder below (registrations are pruned afterwards). | ||||||||||||||
| "${GIT_SAFE[@]}" worktree remove --force "$worktree" || | ||||||||||||||
| echo "::warning::could not remove review worktree: $worktree" | ||||||||||||||
| remove_review_tree "$worktree" | ||||||||||||||
| done || true | ||||||||||||||
| rm -rf .qwen/tmp/review-pr-* 2>/dev/null || true | ||||||||||||||
| # Survivors of the glob are exactly the permission-poisoned trees; | ||||||||||||||
| # run each through the repair ladder individually so one poisoned | ||||||||||||||
| # entry cannot mask its siblings. | ||||||||||||||
| for leftover in .qwen/tmp/review-pr-*; do | ||||||||||||||
| [ -e "$leftover" ] || [ -L "$leftover" ] || continue | ||||||||||||||
| remove_review_tree "$leftover" | ||||||||||||||
| done | ||||||||||||||
| "${GIT_SAFE[@]}" worktree prune -v || true | ||||||||||||||
| "${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \ | ||||||||||||||
| | while read -r review_ref; do | ||||||||||||||
|
|
||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -181,6 +181,41 @@ describe('review worktree cleanup steps', () => { | |||||||||||||||||||
| expect(reviewCleanStep).toContain( | ||||||||||||||||||||
| `rm -f ${toPosix(REVIEW_TMP_DIR)}/${LEASE_PREFIX}pr-*.json`, | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| // A failed rm must not be left to poison the next job's checkout: the | ||||||||||||||||||||
| // sweep owns its own permission repair — chmod, then passwordless sudo | ||||||||||||||||||||
| // chown/chmod where the pool member has it — and retries the removal per | ||||||||||||||||||||
| // leftover entry (measured, run 32577821716 / PR #9718: a foreign-owned | ||||||||||||||||||||
| // scratch-verify tree killed the next review at checkout with EACCES). | ||||||||||||||||||||
| // Pin the ladder's EFFECT, not mechanism substrings: those double-match | ||||||||||||||||||||
| // (the non-sudo chmod rung hides inside the sudo line) and let a | ||||||||||||||||||||
| // rewrite silently drop the ladder back to warn-and-leave. | ||||||||||||||||||||
| const reviewCleanCode = stripComments(reviewCleanStep); | ||||||||||||||||||||
| // Three removal attempts: the initial rm plus one retry after EACH | ||||||||||||||||||||
| // repair rung, so a chmod-repaired tree never escalates to sudo. | ||||||||||||||||||||
| expect(reviewCleanCode.match(/rm -rf "\$abs"/g)).toHaveLength(3); | ||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The nine new pins assert presence and counts of the ladder's text, but not its order or effect — and mutation runs against the real suite show the gap is real. Three behavior-breaking mutations keep all 5 tests green: setting Witness — mutation arms in a freshly reset scratch tree, real vitest suite: baseline 5/5 green;
Suggested change
Stronger still: extract the function from the step text and execute it against a tmp fixture (a self-owned read-only tree removed by the chmod rung — skipped when running as root, where CAP_DAC_OVERRIDE makes the fixture vacuous; a planted symlink refused with the target untouched; a newline-bearing name yielding a single-line warning), capability-gated like the existing 中文说明这九条新断言 pin 住的是梯子文本的"存在与计数",而不是其"顺序或效果"——对真实套件做的 mutation 运行证明缺口是真实的。三种破坏行为的变异都让 5 条测试全绿:把 证据:在全新重置的临时树中对真实 vitest 套件做 mutation——基线 5/5 绿; — qwen3.8-max via Qwen Code /review (v0.22.0) |
||||||||||||||||||||
| // The non-sudo rung must exist as its own command, not just inside the | ||||||||||||||||||||
| // sudo line. | ||||||||||||||||||||
| expect(reviewCleanCode).toMatch(/^\s*chmod -R u\+rwX "\$abs"/m); | ||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The rungs' Witness: Suggested fix — pin the guarded form, and extend the sudo pins to carry their guards too: expect(reviewCleanCode).toMatch(/^\s*chmod -R u\+rwX "\$abs" 2>\/dev\/null \|\| true$/m);中文说明各梯级的 证据:变异(删防护)+ 现有套件 8/8 全绿;按 runner 标志做 errexit 探针:裸调用点 exit=1(尾部未执行),管道调用点 exit=0(被吸收);建议断言在变异下红、干净代码下绿。 建议修复——钉住带防护的形式(并把 sudo 断言扩展为同样带上防护): expect(reviewCleanCode).toMatch(/^\s*chmod -R u\+rwX "\$abs" 2>\/dev\/null \|\| true$/m);— qwen3.8-max via Qwen Code /review (v0.22.0) |
||||||||||||||||||||
| expect(reviewCleanCode).toContain('sudo -n chown -R'); | ||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The sudo rung is pinned only by the verb substring Witness: Suggested fix — pin both sudo rung lines in full: expect(reviewCleanCode).toContain('sudo -n chown -R "$(id -u):$(id -g)" "$abs" 2>/dev/null || true');
expect(reviewCleanCode).toContain('sudo -n chmod -R u+rwX "$abs" 2>/dev/null || true');or add a capability-gated fixture (probe 中文说明sudo 梯级只被动词子串 证据:删除 chmod 梯级的变异 8/8 全绿; 建议修复——完整钉住两条 sudo 梯级: expect(reviewCleanCode).toContain('sudo -n chown -R "$(id -u):$(id -g)" "$abs" 2>/dev/null || true');
expect(reviewCleanCode).toContain('sudo -n chmod -R u+rwX "$abs" 2>/dev/null || true');或添加能力门控夹具(探测 — qwen3.8-max via Qwen Code /review (v0.22.0) |
||||||||||||||||||||
| expect(reviewCleanCode).toContain('remove_review_tree "$leftover"'); | ||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The leftover loop's glob — the call site that feeds every permission-poisoned tree into the ladder — is the only glob site in the sweep pinned to nothing. The sibling sites are tied to Witness: Suggested fix: expect(reviewCleanStep).toContain(`for leftover in ${worktreePrefix}*; do`);中文说明残留循环的 glob——把每棵权限投毒树喂给梯子的调用点——是清扫中唯一没有被任何断言钉住的 glob 位点。兄弟位点都绑定到 证据:变异 glob 建议修复: expect(reviewCleanStep).toContain(`for leftover in ${worktreePrefix}*; do`);— qwen3.8-max via Qwen Code /review (v0.22.0) |
||||||||||||||||||||
| // The symlink-refusal guard must survive, including the direction of | ||||||||||||||||||||
| // its comparison and the deciding reason it now carries. | ||||||||||||||||||||
| expect(reviewCleanCode).toContain( | ||||||||||||||||||||
| 'refusing to repair review worktree path (${reason})', | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| expect(reviewCleanCode).toContain('!= "$ws_real/$rel"'); | ||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This pin captures the condition of the Witness: Suggested fix: add a fixture where 中文说明该断言捕获了 证据:变异(分支体置空)下 8/8 全绿;植入祖先探针——干净代码:拒绝 warning、子文件保持 400→400;变异:兄弟子树被穿过链接删除;建议夹具在干净代码下绿(9/9)、变异下红。 建议修复:添加夹具——工作区内的 — qwen3.8-max via Qwen Code /review (v0.22.0)
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred to the next round. This round batched the Critical plus the eight pin-level findings (the round batch bound); the four behavioral fixtures form one coherent batch and get it in full next round rather than a rushed tail here. Nothing in this round's commit weakens the cited mutant — the 中文说明推迟到下一轮。本轮已批处理 Critical 加八条断言级发现(达到单轮批处理上限);四个行为夹具构成一个内聚批次,将在下一轮完整实现,而不是在本轮仓促收尾。本轮提交没有任何改动削弱所引用的变异——
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The pin suite pins the guard comparison that consumes Witness: Suggested fix (the sibling expect(reviewCleanCode).toContain("abs_real=\"$(realpath -- \"$abs\" 2>/dev/null)\" || abs_real=''");中文说明断言套件钉住了消费 证据:删除兜底的变异 8/8 全绿;按 runner 标志做探针——符号链接环:现有代码 exit=0(warning + 尾部执行)对变异 exit=1(循环中止、尾部跳过);目标祖先缺失的悬空链接同样翻转;建议断言在干净代码下绿、变异下红。 建议修复(兄弟的 expect(reviewCleanCode).toContain("abs_real=\"$(realpath -- \"$abs\" 2>/dev/null)\" || abs_real=''");— qwen3.8-max via Qwen Code /review (v0.22.0) |
||||||||||||||||||||
| // Leftover names are untrusted glob entries: both warnings must strip | ||||||||||||||||||||
| // newlines, or a hostile name injects a workflow command into the log. | ||||||||||||||||||||
| expect(reviewCleanCode.match(/\$\{abs\/\/\$'\\n'\/ \}/g)).toHaveLength(2); | ||||||||||||||||||||
| // The failure warning carries the deciding state (sudo probe + owner), | ||||||||||||||||||||
| // and the function returns 0 unconditionally: even a failed warning | ||||||||||||||||||||
| // echo must not fail the `if: always()` job via errexit. | ||||||||||||||||||||
| expect(reviewCleanCode).toMatch( | ||||||||||||||||||||
| /could not remove review worktree[^\n]*sudo: \$sudo_probe[^\n]*owner:/, | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| expect(reviewCleanCode).toMatch( | ||||||||||||||||||||
| /could not remove review worktree[^\n]*\n\s*return 0/, | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| it('keeps the pre-checkout agent-state sweep pinned to paths.ts', () => { | ||||||||||||||||||||
|
|
@@ -192,7 +227,9 @@ describe('review worktree cleanup steps', () => { | |||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| it('uses one identical worktree filter at every list-driven sweep', () => { | ||||||||||||||||||||
| const filter = reviewCleanStep.match(/awk '([^']+)'/)?.[1]; | ||||||||||||||||||||
| // The step's owner-extraction awk is not a worktree filter: anchor | ||||||||||||||||||||
| // on the filter's shape, not the first awk in the step. | ||||||||||||||||||||
| const filter = reviewCleanStep.match(/awk '(\$1 == "worktree"[^']+)'/)?.[1]; | ||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This diff pastes the same filter-extraction regex plus the identical two-line rationale comment into two tests (here and in the behavioral filter test below) instead of deriving it once — even though this file's own convention hoists exactly this kind of shared derivation from the parsed YAML to module scope ( // next to reviewCleanStep (rationale comment kept once, here)
const worktreeFilter = reviewCleanStep.match(
/awk '(\$1 == "worktree"[^']+)'/,
)?.[1];
// in both tests:
const filter = worktreeFilter;中文说明本 diff 把同一个过滤器提取正则和完全相同的两行注释复制进了两个测试(此处与下方的行为过滤器测试),而不是只推导一次——尽管本文件的既有惯例就是把这类从解析出的 YAML 推导的共享量提升到模块作用域( — qwen3.8-max via Qwen Code /review (v0.22.0) |
||||||||||||||||||||
| expect(filter).toBeTruthy(); | ||||||||||||||||||||
| for (const { id, run } of ciCleanSteps) { | ||||||||||||||||||||
| expect(run, id).toContain(`awk '${filter}'`); | ||||||||||||||||||||
|
|
@@ -202,7 +239,11 @@ describe('review worktree cleanup steps', () => { | |||||||||||||||||||
| it.skipIf(!awkAvailable)( | ||||||||||||||||||||
| 'filter selects review worktrees only, never the main checkout', | ||||||||||||||||||||
| () => { | ||||||||||||||||||||
| const filter = reviewCleanStep.match(/awk '([^']+)'/)?.[1]; | ||||||||||||||||||||
| // The step's owner-extraction awk is not a worktree filter: anchor | ||||||||||||||||||||
| // on the filter's shape, not the first awk in the step. | ||||||||||||||||||||
| const filter = reviewCleanStep.match( | ||||||||||||||||||||
| /awk '(\$1 == "worktree"[^']+)'/, | ||||||||||||||||||||
| )?.[1]; | ||||||||||||||||||||
| const main = '/home/runner/work/qwen-code/qwen-code'; | ||||||||||||||||||||
| const review = `${main}/.qwen/tmp/review-pr-42`; | ||||||||||||||||||||
| const out = spawnSync('awk', [filter], { | ||||||||||||||||||||
|
|
||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] The comment says each rung is "each followed by a retry", but
remove_review_treeretriesrm -rfexactly once, after ALL rungs — there is no retry between the chmod rung and the sudo rung, so the root leg escalates even when chmod already repaired the tree. This file is deliberately comment-driven and its cleanup recipe is contract-pinned by tests, so a maintainer auditing when the root leg fires will conclude a chmod-repaired tree is removed before sudo is attempted — the opposite of what runs. Reword the comment (or insertrm -rf "$abs" 2>/dev/null && return 0between the two rungs to make the code match, which also skips the root leg when chmod sufficed):中文说明
注释称每个梯级都“各跟一次重试”,但
remove_review_tree只在所有梯级之后重试一次rm -rf——chmod 梯级与 sudo 梯级之间并没有重试,因此即使 chmod 已经修好了树,root 梯级仍会升级执行。该文件刻意以注释驱动,且其清理流程被契约测试 pin 住,审计 root 梯级触发时机的维护者会得出“chmod 修好的树会在动用 sudo 之前被删除”的结论——与实际行为相反。建议改写注释(或在两个梯级之间插入rm -rf "$abs" 2>/dev/null && return 0使代码与注释一致,这样 chmod 已修复时还能跳过 root 梯级)(见上方 suggestion)。— qwen3.8-max via Qwen Code /review (v0.22.0)