fix(ci): heal a symlinked workspace instead of wedging the runner on it - #9498
Conversation
The hardened wipe guard refuses any workspace that canonicalizes outside the runner workspace. That refusal is correct, and it created a permanent failure: when a previous job leaves the workspace replaced by a symlink pointing outside — or by any non-directory — the guard resolves it to the target, refuses, and exits 1 having removed nothing. Nothing else clears that state, so every later job on the runner dies at the same line, forever. The pre-guard code wiped through the link and self-healed by accident. Reproduced against main's own step text before this change. Heal it: the link itself lives inside the runner workspace and is safe to unlink, and only once it is gone can a legitimate wipe proceed. The layer has to sit before canonicalization — afterwards the path has already resolved to the target and the allowlist refuses before any repair can happen — which means it judges a raw path, and that is where the first attempt at this (closed with #9369) went wrong. A raw `"$RWS"/*` match accepts `$RWS/link/sub` as a string while the kernel resolves it through an intermediate symlink to a file outside the runner workspace, so the unlink and the mkdir landed outside and only then did the allowlist refuse the wipe. Here the containment is judged on the canonicalized PARENT — never on $WS, which would resolve through the very link being removed — and the unlink then acts on the raw path, so it takes the link and never follows it. Four more constraints the same review surfaced: the raw trailing-slash strip moves ahead of the predicates (both `[ -L "$WS/" ]` and `[ ! -d "$WS/" ]` resolve through a link and report its target, so one slash hides the corruption); the allowlist root is prepared before the heal, since it bounds it, and an empty $RUNNER_WORKSPACE would degenerate the containment pattern to the match-all `/*`; both the unlink and the mkdir fail closed, because under `-e` a failure that is not the last command of an && list is swallowed and would leave the wipe running on a corrupt path; and the heal logs what it found and where the link pointed, since this incident otherwise leaves no trace at all. All three copies get it — the two triage wipes and the A/B wipe — with per-suite fixtures: the wedge healed (link gone, directory recreated, target's contents intact), the intermediate-symlink attack refused with the outside file unmutated and zero rm calls, the non-directory half, the trailing-slash spelling, the fail-closed unlink, and the ordinary workspace where the heal must not fire at all. Mutation-checked layer by layer; each has a fixture that fails when it is removed. One pre-existing test changes meaning: the canonicalization pin used a symlinked workspace and asserted refusal, which is now the healed path. It moves to a vector the heal does not touch — an intermediate symlink whose far end is a directory — and keeps its mutation strength: with the canonicalization deleted, find resolves the link and hands the outside directory's entries to the rm recorder. Closes #9480
|
Re-run on Template looks good ✓ Problem: observed, not theoretical. #9480 documents the wedge with a reproduction against Direction: this is the repo's own CI infrastructure un-wedging the self-hosted pool — squarely in scope. The guard's refusal is correct; what was missing is the repair, and the repair stays bounded to a path whose canonical parent sits inside the runner workspace. Size: not a core-module change (workflow YAML + workflow tests only). 243 changed production lines across the two workflow files vs 676 test lines — the weight is deliberately in fixtures. Approach: the scope is still right, and the new commits tighten it rather than widen it. The heal runs before canonicalization and containment is judged on the canonicalized parent — the shape #9480 prescribes — and Risk: no elevated risk signals — no high-risk paths matched. Moving on to code review. 🔍 中文说明由 模板完整 ✓ 问题: 已观测到,不是理论问题。#9480 记录了 wedge 现象,并提供了针对 方向: 这是仓库自身 CI 基础设施的自愈修复,完全在范围内。守卫的拒绝是正确的,缺的是修复层,且修复被限制在规范化父目录位于 runner workspace 内部的路径上。 规模: 不涉及核心模块(仅 workflow YAML + workflow 测试)。两个 workflow 文件共 243 行生产代码变更,对比 676 行测试代码——重量刻意放在 fixture 上。 方案: 范围依然合理,新 commit 收紧而非扩大了范围。heal 在规范化之前运行、容器性判断基于规范化的父目录——正是 #9480 规定的形状—— 风险: 无升级风险信号——未命中高风险路径。 进入代码审查 🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code reviewRe-run: the heal shape reviewed at
State at HEAD re-verified across all three copies: identical layer order (raw trailing-slash strip → allowlist root → heal judging the canonicalized parent → canonicalize → Testing evidence (this PR's own CI, fetched once via the API)Unattended run — PR code is never executed here; the evidence below is the PR's own CI on the reviewed commit, complete and fully green. The two lanes that matter for this change both ran:
Not verified: a real wedge recovery on a live self-hosted runner — producing it would require deliberately corrupting a pool runner, and neither sandboxed lane can settle this specific claim ( 中文说明代码审查重跑说明:
已在 HEAD 复核三份拷贝:层次顺序一致(原始尾斜杠剥离 → allowlist 根 → 以规范化父目录判定容器性的 heal → 规范化 → 测试证据(该 PR 自己的 CI,通过 API 一次性抓取)无人值守运行——这里绝不执行 PR 代码;证据是 PR 自身在被审 commit 上的 CI,已全部完成且全绿。与本改动相关的两条通道都已运行: 未验证:在真实自托管 runner 上的 wedge 恢复——产生它需要故意破坏池 runner,且两条沙箱通道都无法钉住这一具体论断( — Qwen Code · qwen3.8-max Reviewed at |
|
Confidence: 5/5 — clean across every stage; the three new commits closed the round-one findings (forged-annotation vector, mkdir-leg fixture, header accuracy) and the one real CI failure without touching the reviewed heal shape. Stepping back: the motivation is still a permanent runner wedge with a reproduction, and the implementation remains faithful to the shape #9480 distilled from the earlier attempt's review. What the delta added is exactly the kind of detail that usually survives review — the heal's own log line carrying attacker-chosen bytes on a lane that runs contributor code — and it was caught, fixed in all three copies, and pinned by an executed fixture that asserts no output line starts with My independent proposal found no simpler path, and the new commits didn't open one: healing after canonicalization is impossible (the path has already resolved to the target and the allowlist refuses first), raw-path containment is precisely the hole this closes, and the log sanitization sits on the only channel the runner parses as commands. Every change in the diff is necessary for the stated goal; the fourth copy stays explicitly scoped out with convergence tracked at #9265. Verdict: approve. CI is complete and green on 中文说明置信度:5/5 —— 每个阶段都干净;三个新 commit 关闭了第一轮评审的发现(伪造注解向量、mkdir 分支 fixture、头注释准确性)和唯一一次真实 CI 失败,且没有改动已审查的 heal 形状。 退一步看:动机仍是带有复现的 runner 永久性 wedge,实现依旧忠实于 #9480 从早前尝试的评审中提炼出的形状。增量补丁所补上的正是通常会被评审漏掉的那种细节——在运行贡献者代码的通道上,heal 自己的日志行携带攻击者可控字节——它被抓住、在三份拷贝中修复,并被一个断言"没有任何输出行以 我的独立方案没有找到更简路径,新 commit 也没有开辟新路径:规范化之后无法 heal(路径已解析到目标,allowlist 会先拒绝),原始路径容器性正是本 PR 要堵的洞,日志消毒恰好落在 runner 唯一解析为命令的通道上。diff 中每个改动都为既定目标所必需;第四份拷贝仍被明确排除,收敛由 #9265 跟踪。 结论:批准。CI 已在 — Qwen Code · qwen3.8-max Reviewed at |
🩺 serve daemon A/BBuilt the PR base vs this PR head ✅ No response changes against the PR base across 4 scenario(s). — Qwen Code · serve A/B |
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
Not linted (tool limitation, not a blocker): the executable-script lint — .github/workflows/qwen-triage.yml: actionlint embedded-shell source mapping is not yet supported — not linted; the executable-script lint — .github/workflows/serve-ab.yml: actionlint embedded-shell source mapping is not yet supported — not linted.
中文说明
已审查——无阻断问题。 建议见行内评论。
未检查(工具限制,非阻断):the executable-script lint — .github/workflows/qwen-triage.yml: actionlint embedded-shell source mapping is not yet supported — not linted; the executable-script lint — .github/workflows/serve-ab.yml: actionlint embedded-shell source mapping is not yet supported — not linted。
— qwen3.8-max via Qwen Code /review (v0.21.14)
Three findings from the first review round on this layer. The heal logged the symlink's target inside a `::warning::` line. The target is bytes a PREVIOUS job chose — on the verify lane that job may have run a contributor's code — and the runner parses `::` at the start of any stdout line as a workflow command, so a target of $'…\n::error::forged' let the step reporting the corruption forge an annotation. The annotation now carries no untrusted bytes: the target is stripped of line breaks, capped, and printed on its own prefixed line, where a leading `::` cannot begin a command. Verified against the real step text — the forged line lands as data, and no output line starts with `::error::`. The mkdir leg's refusal had no executed fixture while its `rm -f` sibling had one. It does not need a permission trick: `rm -f` returns 0 for a path whose parent is not a directory (it reads as "already absent"), and the mkdir that follows cannot succeed — so the branch is reachable, and a swallowed failure there would run the wipe against a path that does not exist. Fixtures in both suites, and it runs as root too. And the post-run triage copy's header still said this copy "predates the checkout-heal hardening and never received it" while carrying the whole guard plus the heal directly underneath. That header is the in-code inventory the eventual convergence of these copies will read; understating it is how a sync strips layers in the wrong direction.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
Not linted (tool limitation, not a blocker): the executable-script lint — .github/workflows/qwen-triage.yml: actionlint embedded-shell source mapping is not yet supported — not linted; the executable-script lint — .github/workflows/serve-ab.yml: actionlint embedded-shell source mapping is not yet supported — not linted.
Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:
scripts/tests/serve-ab-workflow.test.js:242 — [review] rmRecorder helper introduced but the reshaped allowlist-escape test keeps an inline byte-equivalent copy of the recorder setup.github/workflows/serve-ab.yml:88 — [review] serve-ab wipe step header left stale by this diff — omits the heal layer the sibling qwen-triage headers now inventory.github/workflows/serve-ab.yml:123 (+2 locations) — [probe] the heal's refusal echo interpolates the resolved HEAL_PARENT path unsanitized — same workflow-command class R1-1 closed for the readlink target.github/workflows/qwen-triage.yml:2651 — [review] the heal's non-symlink branch deletes the corrupting object without recording what it was — the one incident class where the evidence is destroyed.github/workflows/qwen-triage.yml:2673 — [review] the restructuring deleted the only documented rationale for why the RUNNER_WORKSPACE allowlist holds inside the verify job's container
中文说明
已审查——无阻断问题。 建议见行内评论。
未检查(工具限制,非阻断):the executable-script lint — .github/workflows/qwen-triage.yml: actionlint embedded-shell source mapping is not yet supported — not linted; the executable-script lint — .github/workflows/serve-ab.yml: actionlint embedded-shell source mapping is not yet supported — not linted。
收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 5 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.21.14)
| // both predicates: `[ -L "$WS/" ]` is false and `[ ! -d "$WS/" ]` | ||
| // resolves through the link, so one trailing slash hides the | ||
| // corruption entirely. | ||
| const stepName = WIPE_STEPS[0]; |
There was a problem hiding this comment.
[Suggestion] Same finding as the comment anchored at line 2063 of this file: this fixture ('heals a non-directory workspace, and sees it through a trailing slash') drives only WIPE_STEPS[0] instead of looping WIPE_STEPS, leaving the after-wipe copy's heal unpinned for this vector. A mutant deleting the tr -d '\r\n' | cut -c1-200 sanitization from the after-wipe heal ships green — verified at this head: copy 2's real step text against the forged-target fixture emits a line-leading ::error::forged-annotation while both suites still report 169 passed. Wrap this fixture in for (const stepName of WIPE_STEPS) { … }.
中文说明
[建议] 与锚定在本文件 2063 行的评论是同一发现:本 fixture('heals a non-directory workspace, and sees it through a trailing slash')只驱动 WIPE_STEPS[0] 而不是对 WIPE_STEPS 循环,使得该向量下 after-wipe 副本的 heal 未被钉住。从 after-wipe heal 删除 tr -d '\r\n' | cut -c1-200 消毒管线的 mutant 会全绿通过——已在本 head 验证:copy 2 的真实步骤文本对 forged-target fixture 运行会发出行首 ::error::forged-annotation,而两套测试仍报告 169 通过。把本 fixture 包进 for (const stepName of WIPE_STEPS) { … }。
— qwen3.8-max via Qwen Code /review (v0.21.14)
| try { | ||
| const res = spawnSync( | ||
| 'bash', | ||
| ['-e', '-o', 'pipefail', '-c', extractRun(WIPE_STEPS[0])], |
There was a problem hiding this comment.
[Suggestion] Three of the six new heal exec fixtures drive only WIPE_STEPS[0] ("Wipe workspace before external code") — this one, 'heals a non-directory workspace, and sees it through a trailing slash' (line 1995), and 'fails closed when the corrupt workspace cannot be unlinked' (line 2136) — while their siblings loop for (const stepName of WIPE_STEPS). For those vectors the after-wipe copy's heal is pinned only by the structural string test, which never mentions the tr -d '\r\n' | cut -c1-200 sanitization pipeline. Deleting that pipeline from the "Wipe workspace after external code" heal ships green: a corrupted workspace whose symlink target is $'…\n::error::forged' then emits a forged workflow annotation from the post-run wipe — exactly the injection this round's R1-1 fix closed. Verified at this head: baseline 169 passed; with the pipeline deleted from copy 2 only, the suites still report 169 passed, and running copy 2's real step text against the forged-target fixture flips — mutant forged_line_count=1 with a line-leading ::error::forged-annotation, original forged_line_count=0. Wrap the three single-step fixtures in for (const stepName of WIPE_STEPS) { … }, the same shape the mkdir fail-closed fixture already uses.
中文说明
[建议] 六个新的 heal exec fixture 中有三个只驱动 WIPE_STEPS[0]("Wipe workspace before external code")——即本条、'heals a non-directory workspace, and sees it through a trailing slash'(1995 行)和 'fails closed when the corrupt workspace cannot be unlinked'(2136 行)——而它们的兄弟 fixture 都用 for (const stepName of WIPE_STEPS) 循环。对这些向量,after-wipe 副本的 heal 只被结构化字符串测试钉住,而该测试从未提及 tr -d '\r\n' | cut -c1-200 消毒管线。把该管线从 "Wipe workspace after external code" 的 heal 中删掉后,测试仍全绿:符号链接目标为 $'…\n::error::forged' 的损坏 workspace 会从 post-run wipe 发出伪造的 workflow annotation——正是本轮 R1-1 修复所封堵的注入。已在本 head 验证:基线 169 通过;仅从 copy 2 删除该管线后套件仍报告 169 通过;用 copy 2 的真实步骤文本对 forged-target fixture 运行发生翻转——mutant forged_line_count=1 且出现行首 ::error::forged-annotation,original forged_line_count=0。把这三个单步 fixture 包进 for (const stepName of WIPE_STEPS) { … },与 mkdir fail-closed fixture 已使用的形状一致。
— qwen3.8-max via Qwen Code /review (v0.21.14)
| try { | ||
| const res = spawnSync( | ||
| 'bash', | ||
| ['-e', '-o', 'pipefail', '-c', extractRun(WIPE_STEPS[0])], |
There was a problem hiding this comment.
[Suggestion] Same finding as the comment anchored at line 2063 of this file: this fixture ('fails closed when the corrupt workspace cannot be unlinked') drives only WIPE_STEPS[0] instead of looping WIPE_STEPS, leaving the after-wipe copy's heal unpinned for this vector. A mutant deleting the tr -d '\r\n' | cut -c1-200 sanitization from the after-wipe heal ships green — verified at this head: copy 2's real step text against the forged-target fixture emits a line-leading ::error::forged-annotation while both suites still report 169 passed. Wrap this fixture in for (const stepName of WIPE_STEPS) { … }.
中文说明
[建议] 与锚定在本文件 2063 行的评论是同一发现:本 fixture('fails closed when the corrupt workspace cannot be unlinked')只驱动 WIPE_STEPS[0] 而不是对 WIPE_STEPS 循环,使得该向量下 after-wipe 副本的 heal 未被钉住。从 after-wipe heal 删除 tr -d '\r\n' | cut -c1-200 消毒管线的 mutant 会全绿通过——已在本 head 验证:copy 2 的真实步骤文本对 forged-target fixture 运行会发出行首 ::error::forged-annotation,而两套测试仍报告 169 通过。把本 fixture 包进 for (const stepName of WIPE_STEPS) { … }。
— qwen3.8-max via Qwen Code /review (v0.21.14)
|
@qwen-code /takeover |
|
🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. Remove the 中文说明🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 |
|
🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下: Round summary — PR #9498One commit this round: Feedback triageActed on — round-2 automated suggestions (one finding, three anchors):
Verification is source-blind, so the claimed defect was reproduced before changing anything: with the Re-verified, already fixed in
No action required:
Note: pre-existing flake observed during verificationIntermittent failures were observed in VerificationCommands actually run, with results:
中文说明本轮摘要 — PR #9498本轮一个提交: 反馈分类已处理 — 第 2 轮自动化建议(同一发现的三个锚点):
验证是来源无关的,因此在改动任何代码之前先复现了所声称的缺陷:仅删除 after-wipe 副本 heal 中的 已在
无需处理:
说明:验证过程中观察到的既有 flake
验证实际执行的命令及结果:
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。 🧠 Handled by Qwen Code · model/模型 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Not linted (tool limitation, not a blocker): the executable-script lint — .github/workflows/qwen-triage.yml: actionlint embedded-shell source mapping is not yet supported — not linted; the executable-script lint — .github/workflows/serve-ab.yml: actionlint embedded-shell source mapping is not yet supported — not linted.
Deferred under the convergence posture (round 3, not a blocker) — recorded, not requested in this round:
.github/workflows/serve-ab.yml:88 — [review] serve-ab wipe header left stale by this diff — omits the heal layer the sibling qwen-triage headers now inventory.github/workflows/qwen-triage.yml:2671 — [review] the restructuring deleted the only documented rationale for why the RUNNER_WORKSPACE allowlist holds inside the verify job's container
中文说明
未检查(工具限制,非阻断):the executable-script lint — .github/workflows/qwen-triage.yml: actionlint embedded-shell source mapping is not yet supported — not linted; the executable-script lint — .github/workflows/serve-ab.yml: actionlint embedded-shell source mapping is not yet supported — not linted。
收敛姿态下延后(第 3 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.21.14)
|
🤖 Addressed the latest review feedback (round 2/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 2/100 轮)。改动内容与我反驳保留之处如下: Round summary — PR #9498One commit this round: Feedback triageActed on — The review was downgraded from Approve to Comment because the Serve A/B check was not green. Diagnosed from the check evidence rather than guessed:
Change: raised the job's Note: the definitive green requires the fresh Serve A/B run the workflow triggers when this commit is pushed; CI cannot be triggered or observed from inside this round. No action required:
VerificationCommands actually run this round, in order:
中文说明本轮总结 — PR #9498本轮一个提交: 反馈分诊已处理 — 评审从批准降级为评论,原因是 Serve A/B 检查未通过。以下诊断基于检查证据,而非猜测:
改动: 将任务的 说明:最终的全绿需要工作流在本提交推送后触发的新一轮 Serve A/B 运行;本轮内部无法触发或观测 CI。 无需处理:
验证本轮实际执行的命令,按顺序:
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。 🧠 Handled by Qwen Code · model/模型 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline.
2 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- rmRecorder helper wired up at only one of six recorder sites — already reported (round-2 deferred list, review 4978204557)
- serve-ab wipe preamble/header left stale, omits the heal layer — already reported (round-2 and round-3 deferred lists, reviews 4978204557 and 4980407818)
Not explored to full depth (tool budget reached): "agent 6b": none** — all planned checks completed within the tool budget..
Not linted (tool limitation, not a blocker): the executable-script lint — .github/workflows/qwen-triage.yml: actionlint embedded-shell source mapping is not yet supported — not linted; the executable-script lint — .github/workflows/serve-ab.yml: actionlint embedded-shell source mapping is not yet supported — not linted.
中文说明
已审查。 建议见行内评论。
本轮确认的 2 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未探索到全部深度(达到工具调用预算):"agent 6b":none** — all planned checks completed within the tool budget.。
未检查(工具限制,非阻断):the executable-script lint — .github/workflows/qwen-triage.yml: actionlint embedded-shell source mapping is not yet supported — not linted; the executable-script lint — .github/workflows/serve-ab.yml: actionlint embedded-shell source mapping is not yet supported — not linted。
— qwen3.8-max via Qwen Code /review (v0.21.14)
| # 30-minute bound, cancelling it. | ||
| timeout-minutes: 45 |
There was a problem hiding this comment.
[Suggestion] The timeout-minutes bump 30→45 is unrelated scope bundled into a PR that closes #9480, and neither the title nor the description mentions it. It landed in the final commit as the autofix loop's response to this PR's own CI cancellation (a Serve A/B run hitting the old 30-minute bound — CI run 32337705262 cancelled at 30m20s), so the merge record reads heal-only even though the PR also changes the job's timeout policy. Two concrete consequences: if a problem later surfaces in the heal layer and this PR is reverted, the revert silently drops the 45-minute headroom with it, re-exposing Serve A/B cancellations on slow runners; and anyone bisecting Serve A/B timeout behavior, or generating a changelog from the PR title/description, gets no record of the policy change. The change itself is sound — it is recorded in the code comment above and pinned by a test asserting timeout-minutes >= 45 — so a one-line mention under "What this PR does" or "Risk & Scope" is enough to keep the merge record complete.
中文说明
timeout-minutes 从 30 提到 45 与关闭 #9480 的修复无关,属于顺带进入本 PR 的范围,但标题和描述都完全没有提及。它是 autofix 循环对本 PR 自身 CI 取消的响应(某次 Serve A/B 运行撞上了旧的 30 分钟上限——CI run 32337705262 在 30 分 20 秒时被取消),落在最后一个提交里,因此合并记录读起来只有 heal 修复,尽管本 PR 实际上还改变了该 job 的超时策略。这有两个具体后果:如果 heal 层日后出问题导致本 PR 被 revert,revert 会顺带悄悄移除 45 分钟的余量,让 Serve A/B 在慢 runner 上重新面临被取消的问题;任何对 Serve A/B 超时行为做 bisect、或从 PR 标题/描述生成 changelog 的人,也都查不到这次策略变更。改动本身是合理的——上方代码注释已记录,且有断言 timeout-minutes >= 45 的测试钉住——只需在 "What this PR does" 或 "Risk & Scope" 中加一行说明,即可让合并记录完整。
— qwen3.8-max via Qwen Code /review (v0.21.14)
|
🔀 Base updated: red check(s) [Test (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run. 中文说明🔀 已更新 base:红色检查 [Test (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers.
2 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- serve-ab wipe step header left stale by this diff, omitting the heal layer — already reported (round-2 and round-3 deferred lists, reviews 4978204557 and 4980407818)
- restructuring deleted the only documented rationale for the RUNNER_WORKSPACE allowlist inside the container job — already reported (round-2 and round-3 deferred lists, reviews 4978204557 and 4980407818)
Not linted (tool limitation, not a blocker): the executable-script lint — .github/workflows/qwen-triage.yml: actionlint embedded-shell source mapping is not yet supported — not linted; the executable-script lint — .github/workflows/serve-ab.yml: actionlint embedded-shell source mapping is not yet supported — not linted.
Deferred under the convergence posture (round 5, not a blocker) — recorded, not requested in this round:
.github/workflows/serve-ab.yml:116 (+2 locations) — [review] heal layer pasted into three wipe copies with no parity pin against drift (deferred under the round-5 code-age rule — code unchanged since the previous reviewed commit).github/workflows/serve-ab.yml:64 — [review] R4-1 timeout-minutes bump 30→45 is unrelated scope bundled into a PR that closes #9480 — still stands from round 4, deferred under the convergence posture
中文说明
已审查——无阻断问题。
本轮确认的 2 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未检查(工具限制,非阻断):the executable-script lint — .github/workflows/qwen-triage.yml: actionlint embedded-shell source mapping is not yet supported — not linted; the executable-script lint — .github/workflows/serve-ab.yml: actionlint embedded-shell source mapping is not yet supported — not linted。
收敛姿态下延后(第 5 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.21.14)
|
@qwen-code /triage |
|
Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 492 passed · 6 failed · 498 total Flakiness gate: ✅ 2 changed test file(s) x 5 identical rounds, no divergence 中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:492 通过 · 6 失败 · 498 总计 抖动门:✅ 2 changed test file(s) x 5 identical rounds, no divergence Verification reportVerification report — PR #9498
|
| Scenario (× 3 copies) | base (HEAD^1) | head (PR) |
|---|---|---|
| 01 wedge: WS = symlink → outside dir | exit≠0, link survives, nothing removed (wedged) | exit 0, link gone, real empty dir, canary intact, summary written |
02 attack: $RWS/link/sub, link → outside file |
exit≠0, 0 rm calls, file intact | exit≠0 via heal containment, 0 rm calls, file intact |
| 03 healthy dir with planted vectors | exit 0, emptied | exit 0, emptied, heal does not fire |
| 04 WS is a plain file | exit 0 silent (triage-post) / loud (others), file stays | exit 0, file replaced by real dir |
| 05 wedge + trailing slash | exit≠0, wedged | exit 0, healed |
| 06 symlink whose parent is outside RWS | exit≠0, untouched | exit≠0 via heal containment, 0 rm calls |
07/08 RWS unset / / |
exit≠0, untouched | exit≠0 before any heal, untouched |
09–12 target with \n::error::, \r\n::warning::/::group::, 300-char, %s%n |
exit≠0 (refusal) | exit 0, heal log line clean: only legit :: line is the healing warning; target capped at 200, printed as data |
13 mkdir leg ($RWS/file/sub) |
silent 0 (triage-post) / loud (others) | exit≠0 could not recreate |
| 14 rm leg (parent 0555, non-root) | exit≠0, wedged | exit≠0 could not remove, link still there |
| 15 symlink → dir inside RWS | exit 0, wipes through the link (target emptied) | exit 0, heals; target contents kept |
258/258 assertions pass (90 cells; expected base failures count as passes). Witness: 01-ab-wedge-base-vs-head.png — base prints ::error::refusing to wipe workspace outside…, exit 1, workspace STILL A SYMLINK; head prints ::warning::healing workspace … it was a symlink + the target line, exit 0, real directory, canary intact.
Cross-copy: the guard+heal layers are byte-identical in all three copies (code-line diff empty up to the pre-existing wipe bodies, which intentionally differ: triage-pre has the remaining check, triage-post sweep+summary, serve-ab a bare find). The post-run copy's new header ("same guard, layer for layer") now states the truth — the base header's understatement is gone.
Mutation matrix
One reverted layer per mutant, same battery, all three copies (gen-mutants.mjs, results in mutant-runs/):
| mutant | killed by | note |
|---|---|---|
| m0 heal reverted (positive control) | wedge + 35 more scenarios red | restores base wedge; also shows the base refusal message itself forges (see F1) |
m1 judge $WS instead of parent |
wedge red (heal refuses the repair) | attack still refused — matches PR's claim |
| m2 heal after canonicalization | wedge + inner-link red | |
| m3 containment arm deleted | attack + parent-outside red (rm recorder non-empty) | the historical bug shape |
| m4 raw trailing-slash strip deleted | trailing-slash wedge red | |
| m5 rm leg fail-open | perm fixture message red | behaviour still fail-closed via the mkdir leg on the same unwritable parent — message is what pins it |
| m6 mkdir leg fail-open | mkdir-leg red; on triage-post silent exit 0 | the mkdir leg is the only catch on that copy |
| m7 raw parent string (earlier attempt) | attack red |
8/8 killed; unmutated head 258/258. Witness: 02-mutation-matrix-all-killed.png.
Vacuity: reverting the heal block in the real workflow files turns 15 tests red in the two suites (Tests 15 failed | 155 passed), each on its intended assertion (e.g. expected '…' to contain 'could not recreate', wedge expected 1 to be +0); restored, the suites pass 170/170, 0 skipped (non-root here; a name-filtered run confirms the two root-skipped fail-closed-unlink fixtures executed and passed: Tests 2 passed | 168 skipped). Witness: 04-gate-suites-at-head.png. Note: the PR body's "163 passing, 2 skipped as root" predates the third commit's added fixtures; the command and expectations match the final head.
Findings
F1 (moderate) — refusal messages still carry a forgeable annotation; the class commit 2 closed on the heal log line survives on its siblings
Commit 2's rationale: the symlink target is bytes a previous job chose, and the runner parses :: at the start of any stdout line as a workflow command. The heal log line is now safe (battery 09–12: clean). But the refusal messages interpolate paths that a previous job can shape through an intermediate symlink, and two of them reproduce the forgery on HEAD in all three copies:
- Heal refusal (new in this PR):
HEAL_PARENTisrealpathof the raw parent, which resolves an intermediate symlink whose target contains a newline. - Allowlist refusal (pre-existing): canonicalized
WSresolves through an intermediate symlink whose far end is a real directory whose name contains a newline (heal's predicates see a directory and skip).
Repro (6/6 cells forge on head, 0/6 on the fix proposal):
node tmp/pr9498-verify-*/injection-sibling-probe.mjs tmp/pr9498-verify-*/ head
# triage-pre/P1-heal-refusal: exit=1 FORGED(1)
# forged line: "::error::forged-from-heal-refusal, runner workspace: …)"
# triage-pre/P2-allowlist-refusal: exit=1 FORGED(1) … (all three copies)Blast radius: the same interpolation exists in the .. arm and the denylist arm (${WS} after canonicalization); the two probed sites suffice to prove the class. Bounding what does not hold: every refusal still exits 1 with zero filesystem mutation — the security-critical containment is intact; the impact is the log/annotation channel (forged ::error:: lines from contributor-chosen bytes on a pool where the previous job may have run PR code). The m0 mutant shows the base allowlist site forged identically, so only the heal-refusal site is new with this PR.
Measured minimal fix (scratch copy, not applied to the PR)
Apply commit 2's own treatment (tr -d '\r\n' | cut -c1-200) to the interpolated values: a HEAL_PARENT_LOG in the heal refusal, and a WS_LOG computed once after canonicalization for the ../denylist/allowlist messages (matching keeps using the real values). Measured in fix-proposal/: injection probe 0/6 forged; full head battery 172/172 (benign behaviour unchanged); witness 03-injection-siblings-head-vs-fix.png. The suite's textual pins are substring-based (toContain('refusing to heal workspace outside') etc.) and survive the change.
F2 (informational) — serve-ab.yml timeout 30→45 is an unverified knob
The autofix commit raises the job bound citing a slow-runner overrun. Locally only the knob is verifiable (30 at base, 45 at head); run history needs the API, which this environment lacks. Not a defect; recorded so a maintainer can check the cited run.
Not covered
- Per-commit attribution: depth-2 checkout; metadata lists 6 commits but only
0f542848is locally reachable (rev-list HEAD^1..HEAD^2= 1). All evidence is against the aggregateHEAD^1..HEADdiff. - yamllint: not installable in-container (no
pip3, nopipmodule, no PyYAML). Substitutes that ran: actionlint (live-proven: plantedtimeout-minutes: '45'caught at the exact line, exit 1; clean tree exit 0),yamlparser over both workflows at head and post-revert, shellcheck 0.11.0 at--severity=warningon all six extracted scripts (both arms),bash -non 30 scripts including all 24 mutants. The repo's own shellcheck wrapper exits 0 despite style findings (its pipeline ends insed), which is a pre-existing property of the gate, not of this PR. - Race between heal and checkout: steps run sequentially on one runner; no interleaving driven.
- Fourth copy (
qwen-code-pr-review.yml): declared out of scope by the PR and verified untouched; it still lacks this heal, as the description says. - Real-runner semantics:
RUNNER_WORKSPACE/GITHUB_WORKSPACEcontainer translation and pool behaviour are asserted from the step text and this container (samenode:22-bookworm, GNU coreutils 9.1), not from a live self-hosted run. - Windows/macOS: suites exclude Windows; heal fixtures gate on GNU
realpath(absent on macOS default toolchain) — matching the PR's own⚠️ /N/A matrix.
Methodology
Environment: CI verify container at the merge ref (HEAD merge commit, HEAD^1 base tip, HEAD^2 PR head), non-root uid 1000. All harnesses are mock-free: they drive the step text extracted verbatim (YAML parser, extract-steps.mjs) from each arm under bash --noprofile --norc -e -o pipefail, with scratch mkdtemp sandboxes, an rm recorder on refusal cells, and filesystem-state oracles on heal cells. Raw logs: cells/, logs/, mutant-runs/; harnesses: ab-harness.mjs, gen-mutants.mjs, injection-sibling-probe.mjs, gen-fix.mjs, witness-*.mjs, tally-assertions.mjs. Assertion totals in assertions.json (492/6/498) are parsed from these recorded results; the six fails are F1's forged cells on head.
Flakiness gate log
rounds=5 files=2 skipped=0
file scripts/tests/qwen-triage-workflow.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/qwen-triage-workflow.test.js
file scripts/tests/serve-ab-workflow.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/serve-ab-workflow.test.js
per-file results (P=pass F=fail I=infra-exit, one letter per run):
scripts/tests/qwen-triage-workflow.test.js: PPPPP
scripts/tests/serve-ab-workflow.test.js: PPPPP
verdict: pass
summary: 2 changed test file(s) x 5 identical rounds, no divergence
--- per-invocation detail (full copy in the artifact) ---
round 1 · scripts/tests/qwen-triage-workflow.test.js: P (exit 0)
round 1 · scripts/tests/serve-ab-workflow.test.js: P (exit 0)
round 2 · scripts/tests/qwen-triage-workflow.test.js: P (exit 0)
round 2 · scripts/tests/serve-ab-workflow.test.js: P (exit 0)
round 3 · scripts/tests/qwen-triage-workflow.test.js: P (exit 0)
round 3 · scripts/tests/serve-ab-workflow.test.js: P (exit 0)
round 4 · scripts/tests/qwen-triage-workflow.test.js: P (exit 0)
round 4 · scripts/tests/serve-ab-workflow.test.js: P (exit 0)
round 5 · scripts/tests/qwen-triage-workflow.test.js: P (exit 0)
round 5 · scripts/tests/serve-ab-workflow.test.js: P (exit 0)
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
yiliang114
left a comment
There was a problem hiding this comment.
LGTM. CI green. The symlink heal is correctly ordered and fail-closed: raw-path trailing-slash strip first, allowlist root prepared before the heal, parent canonicalized (never the link itself), untrusted link-target bytes stripped of \r\n/capped before echo (to prevent forged workflow commands), and rm -f on the raw path so it unlinks the link without following it. The same hardening is applied to both wipe copies, with test coverage pinning each layer.
|
Released in v0.21.15. |




What this PR does
Adds a heal for a workspace that a previous job replaced with a symlink, or with any non-directory, to the three hardened pool wipes: both
qwen-triage.ymlverify wipes and theserve-ab.ymlpre-checkout wipe. The link itself lives inside the runner workspace and is safe to unlink; the heal removes it, recreates the directory, and lets the ordinary wipe proceed. Everything else about the guard is unchanged, and on a healthy workspace the layer does not fire at all.Why it's needed
The hardened guard refuses any workspace that canonicalizes outside the runner workspace. That refusal is right, and it created a permanent failure. When the workspace is a symlink pointing outside, canonicalization resolves it to the target, the allowlist refuses, and the step exits 1 having removed nothing — so nothing clears the corruption, and every later job that reaches this step dies at the same line. Before the guard the wipe went through the link and self-healed by accident; after it, the runner is wedged until a human intervenes. Reproduced against
main's own step text, extracted verbatim:The heal has to run before canonicalization — afterwards the path has already resolved to the target and the allowlist refuses before any repair can happen — which means it judges a raw path, and that is exactly where the earlier attempt at this layer went wrong (it was carried on a branch that has since been closed). A raw
"$RWS"/*match accepts$RWS/link/subas a string while the kernel resolves it through an intermediate symlink to a file outside the runner workspace, so the unlink and the mkdir landed outside and only then did the allowlist refuse the wipe. Here the containment is judged on the canonicalized parent — never on the workspace path itself, which would resolve through the very link being removed — and the unlink then acts on the raw path, so it takes the link and never follows it.Four further constraints, each from that earlier review: the raw trailing-slash strip moves ahead of the predicates, because
[ -L "$WS/" ]and[ ! -d "$WS/" ]both resolve through a link and report its target, so one slash hides the corruption entirely; the allowlist root is prepared before the heal, since it bounds what the heal may touch, and an emptyRUNNER_WORKSPACEwould degenerate the containment pattern to the match-all/*; both the unlink and the mkdir fail closed, because under-ea failure that is not the last command of an&&list is swallowed and would leave the wipe running against a corrupt path; and the heal logs what it found and where the link pointed, since this incident otherwise leaves no trace at all.Reviewer Test Plan
How to verify
Run the two suites —
npx vitest run --config ./scripts/tests/vitest.config.ts scripts/tests/qwen-triage-workflow.test.js scripts/tests/serve-ab-workflow.test.js— 163 passing, 2 skipped as root. Every heal assertion is an exec fixture that drives the real step text out of the workflow, and where a regression would delete something,rmis a PATH-fronted recorder, so the assertion is on the decision the guard made and nothing on the machine can be lost.The fixtures worth reading are the wedge and the attack. The wedge: a workspace that is a symlink to a directory outside the runner workspace is healed, the link is gone, a real empty directory stands in its place, and the target still holds its canary — the unlink took the link, not what it pointed at. The attack: with an intermediate symlink and a file at the resolved target, the step refuses before touching anything, the file is still a file with its contents, and the rm recorder is empty. That second one is the shape the earlier attempt shipped, where the mutation landed ahead of the refusal.
Also covered per copy: the non-directory half of the predicate, the trailing-slash spelling, the fail-closed unlink (skipped as root, since root bypasses the mode bits the fixture needs), and an ordinary workspace where the heal must not fire — the path every real run takes.
Mutation-checked layer by layer, with the behavioural fixture named for each: judging the workspace instead of its parent, and moving the heal after canonicalization, both break the wedge fixture; deleting the containment arm breaks the attack fixture; dropping the raw strip breaks the trailing-slash fixture; making either leg fail-open breaks the permission fixture (verified under a non-root user, since it skips as root).
One pre-existing test changes meaning and is worth a look: the canonicalization pin used a symlinked workspace and asserted refusal, which is now the healed path. It moves to a vector the heal does not touch — an intermediate symlink whose far end is a directory — and keeps its mutation strength: with the canonicalization line deleted,
findresolves the link through the kernel and hands the outside directory's entries to the rm recorder.Evidence (Before & After)
N/A — CI infrastructure, no user-visible output. Before: a symlinked workspace refuses with exit 1 and the corruption survives, permanently. After: the same input logs
::warning::healing workspace … it was a symlink to …, removes the link, recreates the directory, and wipes it; a workspace reached through an intermediate symlink still refuses, now before any filesystem mutation.Tested on
Environment (optional)
Unit tests on Linux, plus the repository's workflow linter. The suites are excluded on Windows; the heal fixtures gate on a GNU
realpathprobe, because the guard fails closed without one and there is no canonicalization to observe.Risk & Scope
qwen-code-pr-review.yml's checkout heal is the weakest of the four (fail-open canonicalization, no..arms, and now no symlink heal); it is a different exit contract and belongs in its own change — and re-syncing these three from it would undo the hardening. Converging all four into one guarded-wipe script under.github/scripts/remains the open item from ci: back-port the checkout-heal wipe guard to the triage and serve-ab workspace wipes (deferred from #9220) #9265.Linked Issues
Closes #9480
中文说明
这个 PR 做了什么
给三处已加固的 pool wipe(
qwen-triage.yml的两处 verify wipe 与serve-ab.yml的 checkout 前 wipe)增加"自愈":当先前的 job 把 workspace 换成符号链接、或任何非目录时,把它修好。链接本身位于 runner workspace 之内、可安全 unlink;自愈删掉它、重建目录,然后让原本的 wipe 正常进行。guard 的其余部分不变,健康的 workspace 根本不会触发这一层。为什么需要
加固后的 guard 会拒绝任何规范化后落在 runner workspace 之外的 workspace。这个拒绝是对的,但它制造了一个永久性故障:当 workspace 是一个指向外部的符号链接时,规范化把它解析到目标,allowlist 拒绝,步骤以 1 退出且什么都没删——于是没有任何机制会清理这份损坏,此后每个走到这一步的 job 都死在同一行。加 guard 之前,wipe 会穿过链接从而"意外自愈";加固之后,runner 就此楔死,直到有人手工介入。上方代码块是对
main自身步骤原文的复现。自愈必须跑在规范化之前——之后路径已经解析到目标,allowlist 会在任何修复发生前拒绝——这意味着它面对的是原始路径,而这正是先前那次尝试(所在分支已关闭)出错的地方:原始的
"$RWS"/*会把$RWS/link/sub当字符串接受,而内核会经由中间符号链接把它解析到 runner workspace 之外的一个文件,于是 unlink 与 mkdir 落在外面,之后 allowlist 才拒绝 wipe。本 PR 把包含判断放在规范化后的父目录上——绝不放在 workspace 路径自身,因为那会穿过正要被删除的那条链接——随后 unlink 作用于原始路径,因此只会取走链接本身,绝不跟随它。另有四条约束,同样来自那次评审:原始尾斜杠剥离移到判定之前(
[ -L "$WS/" ]与[ ! -d "$WS/" ]都会穿过链接并报告其目标,一个斜杠就能让损坏完全隐形);allowlist 根在自愈之前准备好,因为它是自愈的边界,而空的RUNNER_WORKSPACE会把包含模式退化成匹配一切的/*;unlink 与 mkdir 两条腿都 fail closed,因为在-e下非&&列表末尾的失败会被吞掉,被吞掉就意味着 wipe 会在仍然损坏的路径上继续;自愈会记录它发现了什么、链接指向哪里,否则这个 incident 不留任何痕迹。Reviewer 测试计划
如何验证
运行两套:
npx vitest run --config ./scripts/tests/vitest.config.ts scripts/tests/qwen-triage-workflow.test.js scripts/tests/serve-ab-workflow.test.js,163 通过、2 个因 root 跳过。所有自愈断言都是 exec fixture,直接驱动从 workflow 中提取的真实步骤文本;凡是回归会导致删除的地方,rm都被 PATH 前置的记录器替换,因此断言落在"guard 做了什么决定"上,机器上不可能丢任何东西。最值得读的是"楔死"与"攻击"两条。楔死:指向 runner workspace 之外目录的符号链接式 workspace 被自愈,链接消失、原地变成真实空目录,而目标处的金丝雀仍在——unlink 取走的是链接,不是它指向的东西。攻击:中间符号链接 + 解析目标处为文件时,步骤在触碰任何东西之前就拒绝,文件仍是文件且内容不变,rm 记录器为空。第二条正是先前那次尝试翻车的形状:改动发生在拒绝之前。
每份副本还覆盖:判定的"非目录"另一半、尾斜杠拼写、fail-closed 的 unlink(以 root 运行时跳过,因为 root 会绕过该 fixture 所需的权限位),以及"普通 workspace 不得触发自愈"——那是每次真实运行都会走的路径。
逐层做了变异测试,并指明抓住它的行为 fixture:判自身而非父目录、以及把自愈挪到规范化之后,都会让"楔死"fixture 变红;删掉包含判断分支会让"攻击"fixture 变红;去掉原始剥离会让尾斜杠 fixture 变红;把任一条腿改成 fail-open 会让权限 fixture 变红(已在非 root 用户下验证,因为它在 root 下跳过)。
有一条既有测试的含义发生了变化,值得一看:原先钉规范化的用例用的正是"符号链接式 workspace + 断言拒绝",而那现在是被自愈的路径。它改用自愈不会触碰的向量——中间符号链接、末端是目录——并保持了变异强度:删掉规范化那行后,
find会经内核解析链接,把外部目录的条目交给 rm 记录器。证据(Before & After)
N/A——CI 基础设施改动,无用户可见输出。之前:符号链接式 workspace 以 exit 1 拒绝,损坏永久保留。之后:同样输入会打印
::warning::healing workspace … it was a symlink to …,删除链接、重建目录并完成 wipe;而经由中间符号链接抵达的 workspace 仍然被拒绝,且拒绝发生在任何文件系统改动之前。测试平台
环境(可选)
Linux 上的单元测试,外加仓库自带的 workflow linter。两套件在 Windows 上被排除;自愈 fixture 以 GNU
realpath探测为前提,因为没有它时 guard 直接 fail closed,也就没有规范化行为可供观察。风险与范围
qwen-code-pr-review.yml的 checkout heal 是四份中最弱的(fail-open 规范化、没有..分支、现在也没有符号链接自愈);它的退出契约不同,应作为独立改动——而且从它反向同步这三份会撤销加固。把四份收敛成.github/scripts/下一份 guarded-wipe 脚本,仍是 ci: back-port the checkout-heal wipe guard to the triage and serve-ab workspace wipes (deferred from #9220) #9265 留下的开放项。关联 Issue
Closes #9480