Skip to content

fix(cli): keep file identity verifiable on >2^53 NTFS volumes via bigint stats - #11875

Open
fszcd wants to merge 4 commits into
QwenLM:mainfrom
fszcd:fix/win-bigint-file-identity
Open

fszcd wants to merge 4 commits into
QwenLM:mainfrom
fszcd:fix/win-bigint-file-identity

Conversation

@fszcd

@fszcd fszcd commented Sep 14, 2026

Copy link
Copy Markdown

What this PR does

Fixes the two file-identity comparators that #11848 reported as failing open on volumes whose file ids exceed 2^53 (in practice: NTFS). review/lib/same-file.ts (isSameFile) and the standalone deletion journal's directory-identity check both statted without { bigint: true }, so a 64-bit NTFS file index arrived rounded at the JS boundary, the strict safe-integer predicate withheld verifiability, and both comparators degraded — isSameFile to canonical spellings that can never see through a hard link, sameDirectoryIdentity to comparing equal for a complete private replacement of the journal tree. Both call sites now stat with { bigint: true } (plus the two handle.stat() sites that share the journal's DirectoryIdentity), and verifiability is gated on the exact id being non-zero, keeping the canonical-spelling fallback only for genuine ino === 0n volumes (FAT/exFAT/SMB). Per the issue's constraints, the shared strict predicate hasVerifiableInode(ino: number) keeps its signature — both conversions are local to the comparators, matching the repo's existing { bigint: true } convention (no-follow-open.ts, session-writer-lease.ts, sessionArtifacts.ts, ...). The fix validates all three isSameFile alias guards (findings.ts, repo-context.ts, save-artifact.ts), which are plain consumers needing no changes of their own.

On the test side, per the triage additions on #11848: the shared node:fs mock in same-file.test.ts now forwards stat options and reports an exact bigint id under { bigint: true } versus its rounded double under a number stat; the two tests whose premise disappeared (refuses inode identity above the safe-integer range, still finds one file through an unsafe inode via canonical spelling) are rewritten around the exact-id regime, and the issue's acceptance test is added — two hard-linked names with an exact ino above 2^53 must compare as one file, and it goes red against the number-stat implementation. The Windows gate on the journal's rejects a complete private replacement %s tree on every operation comes off (the comment #11853 left there said converting to bigint stats is exactly what lets it come off), and the inode-verifiability skip gates in both touched test files narrow to the genuine ino === 0 case.

Why it's needed

On an NTFS volume whose file ids exceed 2^53, an --out hard-linked to --to-anchors was not recognised as an alias and silently overwrote the previous artifact, and the deletion journal's swap detection went inert — hasRecord answered false over an attacker-created empty tree instead of rejecting with reason: 'compromised'. Both were measured red on two Windows self-hosted CI arms at the base of #11787, and the machine this PR was written on reports real temp-dir file ids above 2^53 (fs.statSync(f).ino === 29273397578164384, Number.isSafeInteger(...) === false), so the defect is reproduced against a live NTFS volume below, not only through mocks.

Reviewer Test Plan

How to verify

  1. npx vitest run src/commands/review/lib/same-file.test.ts src/serve/conversations/standalone-deletion-journal.test.ts from packages/cli — the three new/rewritten exact-inode cases and the two un-gated journal replacement tests should pass.
  2. Red-first check: with only same-file.ts reverted to main, the new case equates hard-linked names through an exact inode above the safe-integer range fails (the mock reports the rounded double, the strict predicate refuses it, the canonical-spelling fallback answers false for the two hard-link names) — the issue's acceptance criterion.
  3. On a Windows host whose volume reports file ids above 2^53 (any recent NTFS install with enough file churn qualifies — check fs.statSync(anyFile).ino for a value > 2^53), the pre-existing test treats two hard links to one file as the same file fails on main and passes with this branch, using no mocks at all.

Evidence (Before & After)

Measured on this Windows host (Node v24.21.0, real NTFS temp volume with ino 29273397578164384 > 2^53), packages/cli:

Before (branch's tests against main's same-file.ts):

× isSameFile > treats two hard links to one file as the same file        (real volume, no mocks)
× isSameFile > equates hard-linked names through an exact inode above the safe-integer range
× isSameFile > decides by canonical spelling when inodes are unverifiable   (symlink EPERM — no developer mode, pre-existing baseline)
× isSameFile > walks up two or more missing components to canonicalise an absent path   (symlink EPERM, same baseline class)
Tests  4 failed | 6 passed (10)

After (this branch):

✓ isSameFile > treats two hard links to one file as the same file          (now runs on the real >2^53 volume)
✓ isSameFile > equates hard-linked names through an exact inode above the safe-integer range
✓ isSameFile > keeps distinct files distinct through exact inodes one rounding bucket apart
✓ isSameFile > equates case-variant spellings through their shared exact inode
Tests  2 failed | 8 passed (10)   — the 2 failures are the symlink-EPERM baseline, identical on unmodified main

Journal suite on the same host — the two tests gated off on Windows since #11787 now run and pass against a real >2^53 NTFS volume:

✓ StandaloneDeletionJournal > rejects a complete private replacement base tree on every operation    (was it.skipIf(win32))
✓ StandaloneDeletionJournal > rejects a complete private replacement state tree on every operation   (was it.skipIf(win32))
✓ StandaloneDeletionJournal > rejects journal directory replacement while clearing phases            (was skipped by the safe-integer ino gate)
Tests  22 passed | 3 skipped (25)   — the 3 skips are pre-existing win32 gates unrelated to this fix (symlink privilege, rename-while-open)

Also green: npx eslint <4 changed files> --max-warnings 0, npx prettier --experimental-cli --check <4 changed files>, and tsc --noEmit reports no errors in the changed files (the pre-existing src/ui/selection/* ink-typing errors on a fresh Windows checkout are unrelated and reproduce identically without this change).

Tested on

OS Status
🍏 macOS ⚠️
🪟 Windows
🐧 Linux ⚠️

Environment (optional)

Windows 11, Node v24.21.0, npx vitest run in packages/cli; the host's NTFS temp volume reports file ids above 2^53, which is what made the no-mock before/after reproduction possible.

Risk & Scope

  • Main risk or tradeoff: the journal's in-memory DirectoryIdentity is now bigint-shaped end to end (all three construction sites converted together, so no number/bigint mixing); the mode/uid privacy checks on the same stat objects use bigint-safe arithmetic (0o777n, BigInt(process.getuid())). Persisted JSON record schemas are untouched — records carry caller-supplied number identities and never held DirectoryIdentity.
  • Not validated / out of scope: the record-file race check in readPhase (lstat + handle.stat() on the record file) stays number-backed; both sides round consistently so it cannot fail open in the reported sense, and the issue scopes the conversion to the two directory comparators. The pre-existing symlink-EPERM test failures on Windows hosts without developer mode are unchanged.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #11848

中文说明

本 PR 做了什么

修复 #11848 报告的两个在文件 ID 超过 2^53 的卷(实践中即 NTFS)上「失败即放行」的文件身份比较器。review/lib/same-file.tsisSameFile)与独立删除日志的目录身份检查此前都未以 { bigint: true } 进行 stat,64 位 NTFS 文件索引在 JS 边界被舍入,严格的安全整数判定因此拒绝承认其可验证性,两个比较器随之退化——isSameFile 退化为永远无法看穿硬链接的规范拼写比较,sameDirectoryIdentity 则在日志目录树被完整私有替换时仍判定相等。现在两处调用点(以及共享日志 DirectoryIdentity 的两个 handle.stat() 调用点)都以 { bigint: true} 进行 stat,可验证性以精确 ID 非零为门槛,规范拼写回退仅保留给真正 ino === 0n 的卷(FAT/exFAT/SMB)。按 issue 的约束,共享的严格谓词 hasVerifiableInode(ino: number) 签名不变——两处转换都局限在各自的比较器内部,与仓库已有的 { bigint: true } 惯例一致(no-follow-open.tssession-writer-lease.tssessionArtifacts.ts 等)。本修复覆盖全部三个 isSameFile 别名守卫(findings.tsrepo-context.tssave-artifact.ts),它们都是纯消费方,自身无需改动。

测试侧按 #11848 的 triage 补充意见处理:same-file.test.ts 的共享 node:fs mock 现在转发 stat options,在 { bigint: true } 下报告精确 bigint ID、在 number stat 下报告其舍入 double;两个前提已消失的测试(refuses inode identity above the safe-integer rangestill finds one file through an unsafe inode via canonical spelling)围绕精确 ID 机制重写,并加入了 issue 的验收测试——两个硬链接名在精确 ino 超过 2^53 时必须判定为同一文件,该用例在 number-stat 实现下会变红。日志的 rejects a complete private replacement %s tree on every operation 的 Windows 跳过门被移除(#11853 留下的注释正说明转换为 bigint stats 就是摘掉它的条件),两个被触及测试文件中的 inode 可验证性跳过门都收窄到真正的 ino === 0 场景。

为什么需要

在文件 ID 超过 2^53 的 NTFS 卷上,与 --to-anchors 建立硬链接的 --out 不会被识别为别名,会静默覆盖已有产物;删除日志的换包检测也会失效——面对攻击者创建的空目录树,hasRecord 回答 false 而不是以 reason: 'compromised' 拒绝。两者都在 #11787 基线的两台 Windows 自托管 CI 机器上实测变红,而本 PR 编写所用的机器真实临时目录文件 ID 即超过 2^53(fs.statSync(f).ino === 29273397578164384Number.isSafeInteger(...) === false),因此下文的缺陷复现是在真实 NTFS 卷上完成的,而非仅靠 mock。

审查者测试计划

如何验证

  1. packages/cli 下运行 npx vitest run src/commands/review/lib/same-file.test.ts src/serve/conversations/standalone-deletion-journal.test.ts——三个新增/重写的精确 inode 用例和两个摘除跳过门的日志替换测试应全部通过。
  2. 变红验证:仅把 same-file.ts 回退到 main,新用例 equates hard-linked names through an exact inode above the safe-integer range 即失败(mock 报告舍入后的 double,严格谓词拒绝它,规范拼写回退对两个硬链接名回答 false)——即 issue 的验收标准。
  3. 在文件 ID 超过 2^53 的 Windows 主机上(任何有足够文件流转的新装 NTFS 系统都满足——检查 fs.statSync(任意文件).ino 是否大于 2^53),既有测试 treats two hard links to one file as the same filemain 上失败、在本分支上通过,全程不使用任何 mock。

证据(Before & After)

在本 Windows 主机(Node v24.21.0,真实 NTFS 临时卷 ino 29273397578164384 > 2^53)上的 packages/cli 实测:

Before(本分支的测试 + mainsame-file.ts):

× isSameFile > treats two hard links to one file as the same file        (真实卷,无 mock)
× isSameFile > equates hard-linked names through an exact inode above the safe-integer range
× isSameFile > decides by canonical spelling when inodes are unverifiable   (symlink EPERM——无开发者模式,既有基线)
× isSameFile > walks up two or more missing components to canonicalise an absent path   (symlink EPERM,同类基线)
Tests  4 failed | 6 passed (10)

After(本分支):

✓ isSameFile > treats two hard links to one file as the same file          (现在在真实 >2^53 卷上实际运行)
✓ isSameFile > equates hard-linked names through an exact inode above the safe-integer range
✓ isSameFile > keeps distinct files distinct through exact inodes one rounding bucket apart
✓ isSameFile > equates case-variant spellings through their shared exact inode
Tests  2 failed | 8 passed (10)   —— 2 个失败为 symlink-EPERM 基线,在未修改的 main 上完全相同

同一主机上的日志套件——自 #11787 起在 Windows 上被跳过的两个测试如今在真实 >2^53 NTFS 卷上运行并通过:

✓ StandaloneDeletionJournal > rejects a complete private replacement base tree on every operation    (原为 it.skipIf(win32))
✓ StandaloneDeletionJournal > rejects a complete private replacement state tree on every operation   (原为 it.skipIf(win32))
✓ StandaloneDeletionJournal > rejects journal directory replacement while clearing phases            (原被安全整数 ino 门跳过)
Tests  22 passed | 3 skipped (25)   —— 3 个跳过均为与本修复无关的既有 win32 门(symlink 特权、打开句柄时重命名)

同时通过:npx eslint <4 个改动文件> --max-warnings 0npx prettier --experimental-cli --check <4 个改动文件>,且 tsc --noEmit 在改动文件上无错误(全新 Windows 检出中 src/ui/selection/* 的 ink 类型错误为既有问题,不含本改动时可同样复现)。

测试平台

操作系统 状态
🍏 macOS ⚠️
🪟 Windows
🐧 Linux ⚠️

环境(可选)

Windows 11,Node v24.21.0,在 packages/cli 中运行 npx vitest run;本机 NTFS 临时卷报告超过 2^53 的文件 ID,使得不依赖 mock 的 before/after 复现成为可能。

风险与范围

  • 主要风险或权衡:日志的内存态 DirectoryIdentity 现已全链路为 bigint 形态(三个构造点一并转换,不存在 number/bigint 混用);同一 stat 对象上的 mode/uid 隐私检查使用 bigint 安全运算(0o777nBigInt(process.getuid()))。持久化 JSON 记录结构未动——记录携带调用方提供的 number 身份,从不保存 DirectoryIdentity
  • 未验证 / 范围之外:readPhase 中记录文件的竞争检查(对记录文件的 lstat + handle.stat())保持 number——两侧一致舍入,不会出现本 issue 所述意义上的 fail-open,且 issue 将转换范围限定为两个目录比较器。无开发者模式的 Windows 主机上既有的 symlink EPERM 测试失败保持不变。
  • 破坏性变更 / 迁移说明:无。

关联 Issue

Fixes #11848

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — this one is easy to get behind. It fixes a fail-open that a maintainer filed with measurements attached, and it follows the shape that issue asked for almost line for line.

Template looks good ✓ — every required heading is present, the OS table is filled in honestly (Windows ✅, macOS/Linux ⚠️ rather than a blanket ✅), and the Chinese section is a full translation rather than a stub.

Problem: observed, not theoretical. #11848 was filed from review finding R1-26 on #11787 and carries real output from two independent Windows self-hosted arms (promise resolved "false" instead of rejecting, where reason: 'compromised' was expected), plus a probe on the actual isSameFile hard-link fixture showing failOpen=true. Your own host reporting fs.statSync(f).ino === 29273397578164384 is what makes the no-mock before/after possible. That is about as well-evidenced as a platform-specific defect gets.

Direction: aligned. Two identity comparators degrade to a weaker check on volumes with 64-bit file ids, and the weaker check is wrong in the dangerous direction — an alias admitted, a journal swap undetected. Asking for { bigint: true } removes the reason the safe-integer workaround existed, which is the right fix rather than a patch on the symptom. CHANGELOG has a direct precedent: the reference agent shipped "Fixed false skill duplicate detection on filesystems with large inodes (e.g., ExFAT) by using 64-bit precision for inode values", and separately fixed agent loading on filesystems that report zero inodes — which is the same ino === 0 fallback case you deliberately keep.

Size: not applicable. No core paths are touched — packages/cli/src/commands/review/lib/** and packages/cli/src/serve/conversations/** are neither core modules nor cross-package, so the two-tier core gate does not apply. For reference the split is 101 production lines (same-file.ts 46, standalone-deletion-journal.ts 55) against 151 test lines, so the PR is test-heavier than production-heavier. That is the right ratio for a comparator fix.

Approach: the scope feels right, and I checked the two constraints #11848 set rather than taking the description's word for them. hasVerifiableInode keeps its (ino: number) signature and both conversions are local to their comparators ✓. The test whose premise disappeared was rewritten around the exact-id regime rather than deleted ✓. Collapsing the three copies of the identity construction into directoryIdentityOf is a net simplification, not new abstraction — they were triplicated and had to move together anyway. I did not find a materially smaller version of this change: converting only some of the journal's stat sites would leave DirectoryIdentity mixing number and bigint, which is a compile error, so the three sites are not optional scope.

One thing worth your attention before the code review, raised there rather than here as a blocker: the same !Number.isSafeInteger(inode) || inode <= 0 skip gate you narrowed in the two test files you touched still sits in two consumer test files you did not touch, and their comments now assert something false about your code. Details in the review comment.

Risk: no elevated risk signals — none of the changed files match the revert-correlated high-risk paths. Worth naming for the reviewer anyway: this is a security-boundary comparator (the deletion journal's swap detection), so the interesting question is not "does it still work" but "did any identity check get looser". I read the diff for exactly that and could not find one — the ino === 0n fallback is strictly narrower than the safe-integer gate it replaces.

Moving on to code review. 🔍

中文说明

感谢贡献 —— 这个 PR 很容易支持。它修复的是一个由维护者带着实测数据提交的 fail-open 缺陷,并且几乎逐行遵循了该 issue 给出的方案形态。

模板完整 ✓ —— 所有必需标题齐全,操作系统表格如实填写(Windows ✅,macOS/Linux ⚠️,而不是一律打 ✅),中文部分是完整翻译而非占位。

问题: 已观测,非理论性。#11848 源自 #11787 的 review finding R1-26,附带两台独立 Windows 自托管 CI 机器的真实输出(promise resolved "false" instead of rejecting,而期望是 reason: 'compromised'),以及在真实 isSameFile 硬链接 fixture 上的探针结果 failOpen=true。你本机 fs.statSync(f).ino === 29273397578164384 正是让「不依赖 mock 的 before/after」成为可能的原因。对一个平台相关缺陷来说,这已经是相当充分的证据。

方向: 对齐。两个身份比较器在文件 ID 为 64 位的卷上退化为更弱的检查,而退化方向恰恰是危险的那一侧 —— 别名被放行、日志换包未被发现。改用 { bigint: true } 消除了安全整数变通方案存在的理由,这是治本而非治标。CHANGELOG 有直接先例:参考 agent 曾发布「Fixed false skill duplicate detection on filesystems with large inodes (e.g., ExFAT) by using 64-bit precision for inode values」,另外也修复过在报告零 inode 的文件系统上加载 agent 的问题 —— 后者正是你有意保留的 ino === 0 回退场景。

规模: 不适用。未触及核心路径 —— packages/cli/src/commands/review/lib/**packages/cli/src/serve/conversations/** 既不是核心模块也不是跨包改动,因此两层核心门禁不适用。供参考:生产代码 101 行(same-file.ts 46 行、standalone-deletion-journal.ts 55 行),测试代码 151 行,测试重于生产。对一个比较器修复来说这是合适的比例。

方案: 范围合理。#11848 提出的两条约束我逐项核对过,而非仅采信 PR 描述:hasVerifiableInode 保持 (ino: number) 签名、两处转换都局限在各自比较器内部 ✓;前提已消失的测试围绕精确 ID 机制重写而非删除 ✓。把三处重复的身份构造收敛为 directoryIdentityOf 是净简化而非新增抽象 —— 它们本来就是三份重复代码,且必须一起改动。我没有找到明显更小的版本:只转换日志的部分 stat 调用点会让 DirectoryIdentity 混用 number 与 bigint,那是编译错误,所以这三处不是可选范围。

有一点请在代码审查前留意 —— 我把它放在审查意见里提出,而不是当作阻塞项:你在所触及的两个测试文件中收窄的 !Number.isSafeInteger(inode) || inode <= 0 跳过门,在你触及的两个消费方测试文件中原样保留,而它们的注释现在对你的代码作出了错误陈述。详见审查评论。

风险: 无升级风险信号 —— 改动文件均未命中与 revert 相关的高风险路径。但仍值得向审查者点明:这是一个安全边界比较器(删除日志的换包检测),因此关键问题不是「是否仍能工作」,而是「是否有身份检查被放宽」。我正是按这个角度读的 diff,没有找到 —— ino === 0n 回退严格窄于它所取代的安全整数判定。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at d5cd0c9bafa5ca86ad4d077e4fd9b18d3df0cbc8 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

I read this against the question that actually matters for a security-boundary comparator: did any identity check get looser? I could not find one, and the containment is better than the description claims.

The whole bigint surface is module-private. DirectoryIdentity and directoryIdentityOf are not exported, every method returning one is private, and every device:/inode: construction that feeds a persisted record still comes from parseIdentity (number-backed, isIdentityNumber-validated). So no bigint can reach the JSON.stringify at :321 or :555 — the "Do not know how to serialize a BigInt" crash class is closed by construction rather than by care. Your "records never held DirectoryIdentity" claim checks out, and it holds for a stronger reason than the description gives.

Also verified: all three DirectoryIdentity construction sites moved together, so there is no number/bigint mixing; dropping the hasVerifiableInode / normalizedInode imports leaves no dangling use in either file, and both stay exported with live consumers (acpAgent.ts:3425, plus the internal uses in conversation-directory-identity.ts), so nothing goes dead; inode: inodeVerifiable ? stat.ino : 0n combined with sameDirectoryIdentity still reduces to a device-only comparison exactly when ino === 0n, same as before, and ino !== 0n is strictly narrower than the safe-integer gate it replaces; the bigint mode/uid arithmetic (0o777n, 0o700n, BigInt(process.getuid())) is right for BigIntStats with the typeof process.getuid === 'function' short-circuit preserved. Your out-of-scope note on readPhase is accurate too — handle.stat() and pathStat at :532-537 are number-backed on both sides, so they round consistently and it cannot fail open in the reported sense.

Nothing below blocks the merge. Two are cleanup this PR creates, one is a follow-up.

1. Two consumer test files still skip on the platform you just fixed, and their comments now say something false. You narrowed the !Number.isSafeInteger(inode) || inode <= 0 gate in the two test files you touched, but the identical gate sits in two you did not:

  • packages/cli/src/commands/review/findings.test.ts:1460-1469refuses a --to-anchors hardlinked to a sibling file
  • packages/cli/src/commands/review/repo-context.test.ts:1017-1025rejects plan/out aliases and preserves the plan on artifact failure

Both comments assert "isSameFile stats without bigint, so the comparison degrades to realpathSync.native" and both point at #11848 as the reason the skip is tracked rather than silent. After this PR that sentence is wrong, and the gate still fires on exactly the >2^53 NTFS hosts where your fix makes the guard live — because the gate itself stats without bigint and so sees the rounded double. Net effect: the end-to-end alias guards (--to-anchors points at the same file, --out must differ) stay without CI signal on the affected platform, which is the same complaint #11848 makes about #11787 ("skipping the covering tests removed their only CI signal"). Your new same-file.test.ts case does pin the comparator itself, so the defect is covered one call down — that is why this is a suggestion and not a blocker. Narrowing both to statSync(x, { bigint: true }).ino === 0n the way you did in the other two files finishes the job for ~4 lines each, and correcting the comments is worth doing regardless since they now misdescribe the code they sit next to.

2. The doc comment on the predicate you stopped importing goes stale. conversation-directory-identity.ts:20-35 names its importers as "(the standalone deletion journal, the ACP agent, and review/lib/same-file.ts)" — after this PR only the ACP agent does — and :34-35 justifies the deliberate looseness gap with "see the same-file.ts import site for why", a pointer to a site this PR deletes. The reasoning you moved into same-file.ts's new header comment is good and I would keep it there; the cross-reference just needs to follow it.

3. Follow-up, explicitly not a request to widen this PR. The same number-backed predicate still guards two sibling comparators: conversation-workspace.ts:535 and :561 (syncStandaloneRoot, re-validating an open handle before and after sync) and acpAgent.ts:3425. On a >2^53 volume inodeVerifiable is false on both sides there as well, so the composite degrades to a device-only comparison and cannot detect a same-path replacement — the same fail-open class. #11848 scoped the conversion to two comparators and you honoured that, which is the right call for reviewability. Worth a follow-up issue so the remaining half of the family is not forgotten now that the pattern and the test idiom are both established.

Testing evidence

No CI evidence exists for this commit — CI has not run. All five pull_request workflows completed as action_required, which is the first-time-contributor authorization gate, not a result: nothing was built, typechecked, linted, or tested. The only completed checks are bot orchestration jobs (assign, label, authorize, precheck-pr), and verify / tmux-testing are skipped conditionals that never fired. There is no failing job to excerpt because no job executed.

So the compile-level claims I verified above are verified by reading, not by a green tsc. Not verified: that the four changed files typecheck, that same-file.test.ts and standalone-deletion-journal.test.ts pass, and that the two un-gated journal replacement tests actually go green on Windows. Those need the CI runs to be authorized first.

The Windows before/after numbers in the PR description are the author's claim on a single host, not evidence I re-ran — I did not execute any PR-derived code, and on an unattended run I do not. They are plausible and internally consistent (the 2 remaining same-file.test.ts failures are attributed to the pre-existing symlink-EPERM baseline on hosts without developer mode, which matches the unmodified-main behaviour), but they are not a substitute for the suite.

Check Conclusion
Qwen Code CI action_required — not run (awaiting first-time-contributor approval)
Qwen Live Host CI action_required — not run
SDK Java action_required — not run
Serve A/B action_required — not run
tui-parity action_required — not run
precheck-pr / precheck success
assign success
label success
authorize (x2) success
delay-automatic-review success
verify skipped
tmux-testing skipped
review-config skipped
resolve-pr skipped
publish-resolution / publish-tmux / publish-verify skipped
ack-review-request skipped
review-pr in_progress
triage in_progress

Sandboxed verification would settle this: @qwen-code /verify — that the two comparators now fail closed on a volume reporting ids above 2^53 is not observable from the diff, and no CI run has executed to show the un-gated journal replacement tests going green. This is the load-bearing claim of the PR: sameDirectoryIdentity answering "changed" over a private replacement, and isSameFile seeing through a hard link, both on an exact id that a number stat rounds away. Because the author does not have write access, /verify runs as a sponsored run — a maintainer's @qwen-code /verify comment approves the head it was written against, and that run carries a pre-execution risk screen plus a full workspace wipe before any PR code executes. Maintainers should still read the resulting report with the same skepticism as the fork's own CI logs: the code under verification is adversarial input, and a crafted PR can shape what a report says even though the sandbox bounds what it can do. (/tmux is not the lane here — it is unavailable for an author without write access, and this is not a TUI surface anyway.)

中文说明

代码审查

我是按「对一个安全边界比较器真正重要的问题」来读的:是否有任何身份检查被放宽? 没有找到,而且改动的封闭性比 PR 描述所说的还要好。

整个 bigint 影响面是模块私有的。DirectoryIdentitydirectoryIdentityOf 都未导出,返回它们的方法全是 private,而所有喂给持久化记录device:/inode: 构造仍然来自 parseIdentity(number 形态,经 isIdentityNumber 校验)。因此 bigint 不可能抵达 :321:555JSON.stringify —— "Do not know how to serialize a BigInt" 这一类崩溃是被结构性地排除掉的,而不是靠小心。「记录从不保存 DirectoryIdentity」这个说法成立,而且成立的理由比描述里给的更强。

同时核实:三处 DirectoryIdentity 构造点一并转换,不存在 number/bigint 混用;移除 hasVerifiableInode / normalizedInode 导入后两个文件都没有残留引用,且二者仍被导出并有活跃消费方(acpAgent.ts:3425,以及 conversation-directory-identity.ts 内部的多处使用),所以没有变成死代码;inode: inodeVerifiable ? stat.ino : 0n 配合 sameDirectoryIdentity,仍然只在 ino === 0n 时退化为仅比较 device,与改动前一致,而 ino !== 0n 严格窄于它所取代的安全整数判定;bigint 形态的 mode/uid 运算(0o777n0o700nBigInt(process.getuid()))对 BigIntStats 是正确的,且保留了 typeof process.getuid === 'function' 短路。你关于 readPhase 的范围外说明也是准确的 —— :532-537handle.stat()pathStat 两侧都是 number 形态,舍入一致,不会出现 issue 所述意义上的 fail-open。

以下均不阻塞合并。前两条是本 PR 造成的清理项,第三条是后续工作。

1. 两个消费方测试文件仍在你刚修好的平台上跳过,而且它们的注释现在陈述了错误的事实。 你在所触及的两个测试文件里收窄了 !Number.isSafeInteger(inode) || inode <= 0 跳过门,但同样的门还在两个你没动的文件里:

  • packages/cli/src/commands/review/findings.test.ts:1460-1469 —— refuses a --to-anchors hardlinked to a sibling file
  • packages/cli/src/commands/review/repo-context.test.ts:1017-1025 —— rejects plan/out aliases and preserves the plan on artifact failure

两处注释都断言「isSameFile stats without bigint,因此比较退化为 realpathSync.native」,并且都以 #11848 作为「这个跳过是被跟踪的、而非无声忽略」的理由。本 PR 之后这句话已不成立,而跳过门仍会在你修复所针对的 >2^53 NTFS 主机上触发 —— 因为门本身也是不带 bigint 去 stat 的,看到的是舍入后的 double。最终效果是:端到端的别名守卫(--to-anchors points at the same file--out must differ)在受影响平台上仍然没有 CI 信号,而这正是 #11848#11787 的批评(「跳过覆盖测试等于移除了它们唯一的 CI 信号」)。你新增的 same-file.test.ts 用例确实钉住了比较器本身,所以缺陷在下一层是被覆盖的 —— 这也是我把它是列为建议而非阻塞的原因。按你在另外两个文件里的做法把它们收窄为 statSync(x, { bigint: true }).ino === 0n,每个文件约 4 行即可收尾;注释的更正无论如何都值得做,因为它们现在错误描述了紧邻的代码。

2. 你不再导入的那个谓词,其文档注释变陈旧了。 conversation-directory-identity.ts:20-35 把导入方列为「(standalone deletion journal、ACP agent,以及 review/lib/same-file.ts)」—— 本 PR 之后只剩 ACP agent;而 :34-35 用「see the same-file.ts import site for why」来解释两处谓词故意保留的松紧差异,这个指针指向的正是本 PR 删掉的导入点。你移进 same-file.ts 新头部注释的推理写得好,我建议留在那里,只是交叉引用需要跟着搬。

3. 后续工作,明确不是要求扩大本 PR。 同一个 number 形态谓词仍在守卫两个同类比较器:conversation-workspace.ts:535:561syncStandaloneRoot,在 sync 前后复验一个已打开的句柄)以及 acpAgent.ts:3425。在 >2^53 的卷上,那里的 inodeVerifiable 两侧同样为 false,于是复合判定退化为仅比较 device,无法发现同路径替换 —— 属于同一类 fail-open。#11848 把转换范围限定为两个比较器,你也遵守了,这对可审查性是正确的取舍。建议开一个后续 issue,趁模式与测试写法都已成型,别把这一族剩下的另一半忘掉。

测试证据

该提交没有任何 CI 证据 —— CI 尚未运行。 五个 pull_request 工作流全部以 action_required 结束,这是首次贡献者的授权门,而不是结果:没有构建、没有类型检查、没有 lint、没有测试。唯一完成的检查是机器人编排任务(assignlabelauthorizeprecheck-pr),而 verify / tmux-testing 是从未触发的条件跳过项。没有失败任务可以摘录日志,因为没有任务执行过。

因此我上面核实的编译层面结论是靠阅读得出的,不是靠绿色的 tsc。未验证项:四个改动文件能否通过类型检查、same-file.test.tsstandalone-deletion-journal.test.ts 是否通过、以及两个摘除跳过门的日志替换测试在 Windows 上是否真的变绿。这些都需要先授权 CI 运行。

PR 描述里的 Windows before/after 数字是作者在单台主机上的自述,不是我复跑的证据 —— 我没有执行任何 PR 派生代码,在无人值守的 CI 运行中也不会执行。这些数字可信且内部自洽(same-file.test.ts 剩余 2 个失败被归因于无开发者模式主机上既有的 symlink-EPERM 基线,这与未修改 main 的行为一致),但它们不能替代测试套件。

上方表格中的 CI 结论由 finalize 任务在 CI 落定后就地更新。

沙箱验证可以定这件事:@qwen-code /verify —— 两个比较器如今在报告超过 2^53 文件 ID 的卷上是否失败即拒绝,无法从 diff 看出,而且没有任何 CI 运行能证明摘除跳过门的日志替换测试变绿。这正是本 PR 的核心主张:sameDirectoryIdentity 面对一次私有替换要回答「已变更」,isSameFile 要看穿硬链接,两者都依赖一个 number stat 会舍入掉的精确 ID。由于作者没有写权限,/verify受赞助运行的方式进行 —— 维护者的一句 @qwen-code /verify 会批准它所针对的那个 head,该运行额外带有执行前风险筛查,并在任何 PR 代码执行前完整清空工作区。维护者仍应以看待 fork 自身 CI 日志的同样怀疑态度阅读其报告:被验证的代码是对抗性输入,精心构造的 PR 可以影响报告说什么,尽管沙箱限定了它能做什么。(这里 /tmux 不是合适的通道 —— 对无写权限的作者不可用,而且这本来也不是 TUI 界面改动。)

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at d5cd0c9bafa5ca86ad4d077e4fd9b18d3df0cbc8 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — the fix itself is sound and I would merge it on the strength of the review, but not one line of CI has executed on this commit, and the entire PR is a claim about runtime behaviour on a platform no check has exercised.

⏸️ Deferring to a maintainer — not approving, not requesting changes.

Before reading the diff I wrote down what I would do with #11848 myself: convert tryStat to { bigint: true } and replace the shared predicate with a local ino !== 0n gate, leaving hasVerifiableInode(ino: number) alone because three other consumers still hand it numbers; convert the journal's lstat and both handle.stat() sites together, because DirectoryIdentity's fields become bigint and a partial conversion is a compile error rather than a smaller diff; move the mode/uid arithmetic to bigint literals; then rewrite the test whose premise disappears, add the acceptance case, and un-gate the Windows skips. That is what this PR does. I did not find a simpler path, and I specifically looked for one — the honest answer is that the three journal stat sites are not negotiable scope, so the diff is about as small as the fix can be.

The one thing my proposal had that the PR missed is the finding that matters: asking "which tests does this fix un-skip?" rather than "which files did it touch?" surfaces findings.test.ts:1467 and repo-context.test.ts:1023, where the same safe-integer gate still fires on precisely the >2^53 NTFS hosts this PR repairs, behind comments that now assert the opposite of what the code does. The comparator is covered one call down by the new test, so this is cleanup rather than a hole in the fix — but it means #11848's actual complaint, that the covering tests were gated off and took the CI signal with them, is only half answered.

What stops me approving is not that finding. It is that all five pull_request workflows completed as action_required — the first-time-contributor authorization gate — so nothing was compiled, linted, or tested. PENDING computes to 0 here, which under the normal reading means "CI finished, approve now", and that reading would be badly wrong: 0 in-flight runs because no run was ever permitted to start. There is also no green to defer to. The finalize job only re-fires on a PR CI workflow completion, and these have already completed, so a deferred-approval marker would sit there unhonoured forever. A maintainer has to authorize the runs first; the review can then be settled on evidence rather than on my reading of the types.

To be explicit about what I did and did not establish. By reading, I am confident the change is type-consistent, that no bigint can reach JSON.stringify, that no identity comparison got looser, and that the removed imports leave nothing dangling. I have not verified that it compiles or that a single test passes. The author's Windows before/after numbers are a credible single-host claim and I have attributed them as such throughout — they are not evidence, and I did not re-run them, because an unattended triage run never executes PR-derived code.

Two smaller reasons I am comfortable with the change on the merits, for whoever picks this up. The pattern is precedented: the reference agent shipped "using 64-bit precision for inode values" for exactly this class of false-identity bug, and separately fixed agent loading on filesystems reporting zero inodes — which is the ino === 0n fallback this PR deliberately preserves rather than optimising away. And this is not volume: the author has two open PRs here, this is the only one in this area, and it fixes a defect a maintainer filed with measurements attached, following the shape that issue prescribed.

If I were maintaining this in six months I would thank the author. The header comments explain why bigint rather than narrating what the code does, the new fixtures pin their own premises (expect(Number.isSafeInteger(Number(exact))).toBe(false), expect(Number(leftIno)).toBe(Number(rightIno))) so the tests cannot silently stop exercising the defect, and directoryIdentityOf removed a triplication that had to move in lockstep anyway. That is the opposite of a PR that looks plausible and rots.

What a maintainer needs to decide, in order: authorize the five CI runs and confirm Qwen Code CI goes green on d5cd0c9bafa5ca86ad4d077e4fd9b18d3df0cbc8; then either ask for the two consumer test gates and the stale conversation-directory-identity.ts doc pointer in this PR, or take them as a follow-up and merge — both are defensible and it is a judgement call about scope, not about correctness. @qwen-code /verify as a sponsored run is the lane that would actually prove the fail-closed claim, and finding 3 (the sibling comparators in conversation-workspace.ts and acpAgent.ts) deserves its own issue either way.

One process note so this escalation is not silently lost: I could not resolve an owner to @mention. QWEN_MAINTAINER_HANDLE is unset, the PR carries no labels so the area-owner policy has nothing to match on, there are no human reviews to fall back to, and the deterministic resolver script could not be executed in this run's sandbox. Per the workflow I am posting without a mention rather than guessing a login — so this comment is currently addressed to nobody, and someone needs to route it.

中文说明

信心度:3/5 —— 修复本身是可靠的,仅凭审查我就愿意合并它;但这个提交上没有执行过任何一行 CI,而整个 PR 主张的恰恰是一个没有任何检查覆盖到的平台上的运行时行为。

⏸️ 转交维护者 —— 不批准,也不要求修改。

在读 diff 之前,我先写下了自己会怎么修 #11848:把 tryStat 改为 { bigint: true },用一个局部的 ino !== 0n 判定替换共享谓词,同时不动 hasVerifiableInode(ino: number),因为还有三个消费方向它传 number;日志的 lstat 与两处 handle.stat() 必须一起转换,因为 DirectoryIdentity 的字段变成 bigint 后,只转一部分是编译错误而不是更小的 diff;把 mode/uid 运算改为 bigint 字面量;然后重写前提已消失的测试、补上验收用例、摘掉 Windows 跳过门。这正是本 PR 所做的。我没有找到更简的路径,而且是专门找过的 —— 老实说,日志那三处 stat 调用点不是可以商量的范围,所以这个 diff 已经接近该修复能有的最小形态。

我的方案里有、而本 PR 漏掉的那一点,正是关键发现:问「这个修复摘除了哪些测试的跳过」而不是「它改了哪些文件」,就会看到 findings.test.ts:1467repo-context.test.ts:1023 —— 同样的安全整数跳过门,仍会在本 PR 修好的那些 >2^53 NTFS 主机上触发,而门后的注释现在断言的与代码事实相反。比较器本身在下一层被新用例覆盖,所以这是清理项而非修复上的漏洞 —— 但这意味着 #11848 真正的批评(覆盖测试被跳过,CI 信号随之消失)只被回答了一半。

阻止我批准的并不是这条发现,而是:五个 pull_request 工作流全部以 action_required 结束 —— 首次贡献者授权门 —— 所以没有编译、没有 lint、没有测试。这里 PENDING 算出来是 0,按常规读法是「CI 跑完了,可以批准」,而这个读法会错得离谱:0 个在途运行,是因为从来没有运行被允许启动。这里也没有可等待的绿色结果 —— finalize 任务只在 PR CI 工作流完成时重新触发,而这些运行已经完成,所以 延迟批准标记会永远悬在那里无人兑现。必须由维护者先授权运行,之后这个审查才能基于证据、而不是基于我对类型的阅读来定案。

明确区分我确认了什么、没确认什么。通过阅读,我有信心:改动类型一致、bigint 不可能抵达 JSON.stringify、没有任何身份比较被放宽、移除的导入没有留下悬空引用。我没有验证它能编译,也没有验证任何一个测试通过。作者的 Windows before/after 数字是可信的单主机自述,我全程如此归因 —— 它们不是证据,我也没有复跑,因为无人值守的 triage 运行从不执行 PR 派生代码。

另外两点让我对改动本身放心,供接手者参考。其一,这个做法有先例:参考 agent 就为同一类错误身份缺陷发布过「using 64-bit precision for inode values」,也单独修复过在报告零 inode 的文件系统上加载 agent 的问题 —— 后者正是本 PR 有意保留、而没有顺手优化掉的 ino === 0n 回退。其二,这不是刷量:该作者在此仓库有两个开放 PR,这是本领域唯一的一个,而且修的是维护者带着实测数据提交的缺陷,并遵循了该 issue 规定的形态。

如果六个月后由我维护这段代码,我会感谢作者。头部注释解释的是为什么用 bigint,而不是复述代码在做什么;新增 fixture 钉住了自身前提(expect(Number.isSafeInteger(Number(exact))).toBe(false)expect(Number(leftIno)).toBe(Number(rightIno))),因此这些测试不会悄无声息地不再触发缺陷;directoryIdentityOf 消除了一处本来就必须同步改动的三重重复。这与「看起来合理然后慢慢腐烂」的 PR 正好相反。

维护者需要按顺序决定:先授权那五个 CI 运行,确认 Qwen Code CId5cd0c9bafa5ca86ad4d077e4fd9b18d3df0cbc8 上变绿;然后决定这两个消费方测试跳过门与 conversation-directory-identity.ts 的陈旧文档指针,是要求在本 PR 内处理,还是作为后续工作、先合并 —— 两种都站得住,这是关于范围的判断,不是关于正确性的判断。@qwen-code /verify(受赞助运行)才是能真正证明「失败即拒绝」这一主张的通道;而第 3 条发现(conversation-workspace.tsacpAgent.ts 中的同类比较器)无论如何都值得单开一个 issue。

一点流程说明,以免这次升级被无声丢掉:我无法解析出应当 @ 的负责人。QWEN_MAINTAINER_HANDLE 未设置;PR 没有任何标签,因此区域负责人策略无从匹配;没有任何人类 review 可作为兜底;而确定性解析脚本在本次运行的沙箱中无法执行。按工作流规定,我选择不猜测登录名、以不带 @ 的方式发布 —— 所以这条评论目前没有明确收件人,需要有人来路由它。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at d5cd0c9bafa5ca86ad4d077e4fd9b18d3df0cbc8 · re-run with @qwen-code /triage

@fszcd

fszcd commented Sep 14, 2026

Copy link
Copy Markdown
Author

Thank you — the "did any identity check get looser" framing made this a very easy review to answer. All three points addressed:

1. The two consumer test gates — fixed in 15b8b69. Exactly the same complaint as #11848 makes about #11787, and the fix was as cheap as you predicted. Both findings.test.ts (refuses a --to-anchors hardlinked to a sibling file) and repo-context.test.ts (rejects plan/out aliases and preserves the plan on artifact failure) now gate on statSync(x, { bigint: true }).ino === 0n, and both comments now describe the post-fix reality instead of asserting the pre-fix degradation. Verified locally on the same >2^53 host as the PR body: both end-to-end guard tests now run and pass there (previously skipped), so the alias guards have their CI signal back on exactly the platform class at issue. The remaining failures in both files on this host are the pre-existing symlink-EPERM baseline (no developer mode), unchanged.

2. The stale predicate doc comment — fixed in the same commit. conversation-directory-identity.ts now names its actual importers (the ACP agent plus the module's own identity flows), and the looseness-gap justification no longer points at the deleted import site — it points at the same-file.ts header, where the reasoning now lives. One correction to the review's parenthetical, for the record: normalizedInode keeps a second live consumer beyond acpAgent.ts — the module's own inspectConversationNamedDirectoryIdentity — so both exports stay warm either way.

3. Follow-up — filed as #11877. I verified both sites before filing, and you're right that they're the same class. Two wrinkles worth flagging there that make it genuinely a separate piece of work: ConversationRootIdentity.inode feeds the journal's persisted JSON records, and the ACP expectation arrives over the wire as JSON numbers parsed safe-integer-only — so an exact id above 2^53 can't be represented by the peer at all. The issue sketches both rather than pretending it's another two-flag conversion.

On the CI gap: understood — the five pull_request lanes are action_required pending maintainer approval, so nothing here is CI-confirmed yet. The local numbers stand as reported; happy to have them checked against real CI once a maintainer authorizes the run.

@fszcd

fszcd commented Sep 14, 2026

Copy link
Copy Markdown
Author

Small self-correction on my point 2: the review already said "plus the internal uses in conversation-directory-identity.ts" — my "correction" there was correcting something the review didn't say. The doc comment update stands; the review's parenthetical about live consumers was already accurate as written. Sorry for the noise.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed. Suggestions are inline.

Not explored to full depth (tool budget reached): "agent 4": none — no check was cut short by the tool ceiling..

Not reviewed: reverse audit — stopped before round 3 by the review time budget.

Test Plan (not a blocker): src/commands/review/lib/same-file.test.tsno such file or directory; src/serve/conversations/standalone-deletion-journal.test.tsno such file or directory; Tests 22 passed — this review observed 31272 passed; 6 passed — this review observed 31272 passed; 8 passed — this review observed 31272 passed.

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

未探索到全部深度(达到工具调用预算):"agent 4"none — no check was cut short by the tool ceiling.

未审查:反向审计——评审时间预算不足,未能开始第 3 轮。

Test Plan(非阻断):src/commands/review/lib/same-file.test.tsno such file or directory; src/serve/conversations/standalone-deletion-journal.test.tsno such file or directory; Tests 22 passed — this review observed 31272 passed; 6 passed — this review observed 31272 passed; 8 passed — this review observed 31272 passed

— qwen3.8-max via Qwen Code /review (v0.23.3)

const rightStat = tryStat(right);
if (leftStat !== undefined && rightStat !== undefined) {
if (hasVerifiableInode(leftStat.ino) && hasVerifiableInode(rightStat.ino)) {
if (leftStat.ino !== 0n && rightStat.ino !== 0n) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-1: Both gates this PR adds — this one and const inodeVerifiable = stat.ino !== 0n; in directoryIdentityOf (standalone-deletion-journal.ts:102) — restate a predicate core already exports in bigint-ready form: hasVerifiableInode(ino: number | bigint) at packages/core/src/utils/file-identity.ts:19, whose body is return Number(ino) !== 0;. The new header comment gives the reason for not using it as "core's canonical one … stays looser", but that holds only for number inputs — for every bigint the two predicates agree, so at exactly the two call sites this diff creates the stated reason does not distinguish them. The cost is that the "is this inode proof of identity" rule now has six independent expressions across the repo (file-identity.ts:19, conversation-directory-identity.ts:40, managed-scratch-workspace.ts:93, acp-bridge/src/sessionAttachments.ts:31, plus these two), and the next change to that rule has to be found and applied in each — with the two new copies sitting behind a comment telling the next reader the shared helper does not fit.

Core's signature is already (ino: number | bigint), so reusing it needs no widening and does not engage the issue's "keep the signature" constraint:

import { hasVerifiableInode } from '@qwen-code/qwen-code-core/utils/file-identity.js';
// …
if (hasVerifiableInode(leftStat.ino) && hasVerifiableInode(rightStat.ino)) {

with const inodeVerifiable = hasVerifiableInode(stat.ino); in the journal. If you would rather keep the local gates, the smaller change is to correct the rationale: the real reason to leave core's predicate alone is the issue's "conversions local to their comparators" constraint plus the bundle-closure concern managed-scratch-workspace.ts:88-92 documents — not looseness.

Witness:

divergences over 200015 bigint inputs: 0
  edges: 0n, -0n, ±1n, 2n**53n, 2n**60n+1n/+2n, 2n**64n, 2n**128n,
         2n**2000n -> Number(...) = Infinity, -(2n**2000n), 29273397578164384n
  plus a 200k random sweep to 2^2100
with the reuse fix applied:
  ✓ src/commands/review/lib/same-file.test.ts (10 tests)
  ✓ src/serve/conversations/standalone-deletion-journal.test.ts (25 tests)
  Tests  35 passed (35)

The import has to be the deep …/utils/file-identity.js path rather than the package root: AGENTS.md § Code Conventions requires production code in packages/cli/src to import core values from the module that defines them, and the barrel allowlist is closed ("drop an entry when you move its file off the root, never add one"). If you take the reuse fix, the tests that pin it are equates hard-linked names through an exact inode above the safe-integer range and decides by canonical spelling when inodes are unverifiable in same-file.test.ts, plus rejects a complete private replacement %s tree on every operation on the journal side — please confirm each goes red when the substituted predicate is removed.

中文说明

本 PR 新增的两处判定——此处这一处,以及 directoryIdentityOf 中的 const inodeVerifiable = stat.ino !== 0n;standalone-deletion-journal.ts:102)——重述了 core 已经以支持 bigint 的形式导出的谓词:packages/core/src/utils/file-identity.ts:19hasVerifiableInode(ino: number | bigint),其函数体为 return Number(ino) !== 0;。新增的头部注释把不复用它的理由写为「core 的那个更宽松」,但这只对 number 入参成立——对任何 bigint,两个谓词的结论完全一致,因此在本 diff 新建的这两个调用点上,所述理由并不能区分二者。代价是:「这个 inode 能否作为身份证明」这条规则现在在仓库中有六处独立表述(file-identity.ts:19conversation-directory-identity.ts:40managed-scratch-workspace.ts:93acp-bridge/src/sessionAttachments.ts:31,加上本 PR 新增的两处),将来任何一次对该规则的修改都必须逐处找到并同步;而新增的这两处还位于一段告诉后续读者「共享 helper 不适用」的注释之后。

core 的签名本就是 (ino: number | bigint),所以复用它不需要放宽签名,也不触及 issue 中「保持签名不变」的约束:

import { hasVerifiableInode } from '@qwen-code/qwen-code-core/utils/file-identity.js';
// …
if (hasVerifiableInode(leftStat.ino) && hasVerifiableInode(rightStat.ino)) {

日志侧对应改为 const inodeVerifiable = hasVerifiableInode(stat.ino);。如果更希望保留本地判定,那么更小的改动是修正理由:不动 core 谓词的真正原因是 issue 要求的「转换局限于各自的比较器内部」,以及 managed-scratch-workspace.ts:88-92 记录的打包闭包顾虑——而不是「更宽松」。

证据:

divergences over 200015 bigint inputs: 0
  edges: 0n, -0n, ±1n, 2n**53n, 2n**60n+1n/+2n, 2n**64n, 2n**128n,
         2n**2000n -> Number(...) = Infinity, -(2n**2000n), 29273397578164384n
  plus a 200k random sweep to 2^2100
with the reuse fix applied:
  ✓ src/commands/review/lib/same-file.test.ts (10 tests)
  ✓ src/serve/conversations/standalone-deletion-journal.test.ts (25 tests)
  Tests  35 passed (35)

该 import 必须使用深层路径 …/utils/file-identity.js 而非包根:AGENTS.md 的 Code Conventions 要求 packages/cli/src 中的生产代码从定义该值的模块导入 core 内容,且 barrel 白名单是封闭的(「把文件从根导入迁走时就删掉对应条目,绝不要新增」)。若采用复用方案,钉住它的测试是 same-file.test.ts 中的 equates hard-linked names through an exact inode above the safe-integer rangedecides by canonical spelling when inodes are unverifiable,以及日志侧的 rejects a complete private replacement %s tree on every operation——请确认移除所替换的谓词后这些用例都会变红。

— qwen3.8-max via Qwen Code /review (v0.23.3)

// exact and the alias guard under test is live on volumes whose ids
// exceed 2^53 (#11848). The only unverifiable case left is a volume
// reporting no inode numbers at all (FAT/exFAT/SMB).
if (statSync(out, { bigint: true }).ino === 0n) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-2: This is one of four copies of the same volume-verifiability skip gate that this PR has to edit in lockstep to make one semantic change — the others are repo-context.test.ts:1021, lib/same-file.test.ts:112 and the lstat variant at standalone-deletion-journal.test.ts:453 — and packages/cli/src/commands/review/lib/test-utils.ts already exists as this directory's shared-oracle module, imported by 27 suites here including repo-context.test.ts:37. The gate's meaning ("skip only where the volume reports no inode numbers at all") is therefore maintained in four places, and a future change to it that misses one copy fails silently by construction: ctx.skip() reports as skipped, never as failed, so that suite stops exercising the alias guard on exactly the platform #11848 exists to fix and nothing anywhere goes red.

One helper in test-utils.ts, carrying the #11848 rationale once:

export function inodesVerifiable(
  stat: (p: string, o: { bigint: true }) => { ino: bigint },
  path: string,
): boolean {
  return stat(path, { bigint: true }).ino !== 0n;
}

called as if (!inodesVerifiable(statSync, out)) { ctx.skip(); return; } in the three review suites. The stat binding has to be a parameter, and the journal suite's lstat variant lives outside commands/review so it may keep its local form — which is why this is worth weighing rather than taking automatically: the consolidatable part is one line per site across three sites, against AGENTS.md's "Minimum code that solves the problem".

Witness:

BASE 9efdd898 — pattern 'isSafeInteger\((inode|journalStats\.ino)|ino <= 0'
  findings.test.ts:1467  same-file.test.ts:102  repo-context.test.ts:1023
  standalone-deletion-journal.test.ts:450                    -> 4 copies
HEAD 15b8b696 — pattern 'ino === 0n' (gate sites only)
  findings.test.ts:1463  lib/same-file.test.ts:112  repo-context.test.ts:1021
  standalone-deletion-journal.test.ts:453                    -> 4 copies
all four HEAD sites are in files this diff edits
test-utils.ts: 27 importing suites in this directory; plantRepository doc (:197)
  "The oracle every host-execution witness shares, so a fixture that quietly
   stops being an attack fails in one place instead of in each suite."

The helper must take the caller's stat binding rather than importing statSync itself: test-utils.ts:117-124 records that "Callers hand over their own bindings: the parse-args suite mocks node:fs for the whole file, so bindings this module imported itself would write into the mock instead of the tree the check under test reads" — and same-file.test.ts mocks node:fs file-wide too, so a self-imported binding would read the mock or the tree unpredictably.

中文说明

这是同一个「卷可验证性」跳过门的四份副本之一,本 PR 为了完成一次语义修改必须同步改动全部四份——其余三处在 repo-context.test.ts:1021lib/same-file.test.ts:112,以及 standalone-deletion-journal.test.ts:453lstat 变体——而 packages/cli/src/commands/review/lib/test-utils.ts 本就是该目录的共享 oracle 模块,被这里 27 个测试文件导入,其中就包括 repo-context.test.ts:37。因此这条门的含义(「仅在卷完全不报告 inode 号时跳过」)被分散维护在四处,而将来某次修改只要漏掉一份,其失败在结构上就是静默的:ctx.skip() 只会上报为 skipped,永远不会上报为 failed,于是那个测试文件恰好在 #11848 要修复的平台上不再真正执行别名守卫,而任何地方都不会变红。

test-utils.ts 中提供一个 helper,把 #11848 的理由只写一遍:

export function inodesVerifiable(
  stat: (p: string, o: { bigint: true }) => { ino: bigint },
  path: string,
): boolean {
  return stat(path, { bigint: true }).ino !== 0n;
}

在三个 review 测试文件中以 if (!inodesVerifiable(statSync, out)) { ctx.skip(); return; } 调用。stat 绑定必须作为参数传入,而日志套件的 lstat 变体位于 commands/review 之外,可以保留其本地形式——这也是为什么这一条值得权衡而非直接照做:可整合的部分只是三个站点各一行,而 AGENTS.md 要求「用解决问题的最少代码」。

证据:

BASE 9efdd898 — pattern 'isSafeInteger\((inode|journalStats\.ino)|ino <= 0'
  findings.test.ts:1467  same-file.test.ts:102  repo-context.test.ts:1023
  standalone-deletion-journal.test.ts:450                    -> 4 copies
HEAD 15b8b696 — pattern 'ino === 0n' (gate sites only)
  findings.test.ts:1463  lib/same-file.test.ts:112  repo-context.test.ts:1021
  standalone-deletion-journal.test.ts:453                    -> 4 copies
all four HEAD sites are in files this diff edits
test-utils.ts: 27 importing suites in this directory; plantRepository doc (:197)
  "The oracle every host-execution witness shares, so a fixture that quietly
   stops being an attack fails in one place instead of in each suite."

该 helper 必须接收调用方传入的 stat 绑定,而不能自己导入 statSynctest-utils.ts:117-124 记录了「调用方交出它们自己的绑定:parse-args 套件对整个文件 mock 了 node:fs,因此本模块自行导入的绑定会写进 mock,而不是写进被测检查所读取的目录树」——same-file.test.ts 同样对整个文件 mock 了 node:fs,所以自行导入的绑定会不可预测地读到 mock 或真实目录树。

— qwen3.8-max via Qwen Code /review (v0.23.3)

// which rounds a 64-bit NTFS file index. Tracked in #11848 — converting that
// call to `{ bigint: true }` is what lets this gate come off.
it.skipIf(process.platform === 'win32').each(['base', 'state'] as const)(
// Regression cover for #11848: this swap detection was inert on NTFS

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-3: The journal's exact-inode comparison has no platform-independent test. same-file.test.ts pins the identical property on every platform by mocking node:fs to report one exact id under a bigint stat and its rounded double under a number stat; the journal has no equivalent, so its only discriminating witness is the case this diff un-skips for Windows — and that runs on ci.yml:1652-1662, whose test_windows.if: is merge_group || schedule || workflow_dispatch, with no pull_request. On Linux, where inode numbers sit far below 2^53, a mutant restoring the number predicate is behaviourally identical to the original, so nothing a PR triggers can tell the two apart. Concretely: replacing stat.ino !== 0n at standalone-deletion-journal.ts:102 with hasVerifiableInode(Number(stat.ino)) — the shared number predicate this diff's own comment tells maintainers to edit in lockstep — typechecks cleanly, keeps every { bigint: true } call site intact so the mixed-shape canary stays green, and restores exactly the #11848 fail-open: both sides come back inodeVerifiable: false, sameDirectoryIdentity collapses to a device-only compare, and hasRecord resolves false over a live prepared record instead of rejecting reason: 'compromised'.

Give the journal the same volume pose same-file.test.ts uses. This file already mocks node:fs/promises (currently overriding only open), so add an lstat override keyed by path — the journal directory reports 2n ** 60n + 1n when first recorded and 2n ** 60n + 2n after a rename-and-recreate — then assert every operation rejects.toMatchObject({ reason: 'compromised' }). Include the fixture guard the same-file test uses, expect(Number(beforeIno)).toBe(Number(afterIno)), so the case cannot silently stop exercising one rounding bucket.

Witness:

baseline (pristine)                              Tests  25 passed (25)
mutant A  :102 stat.ino !== 0n
          -> Number.isSafeInteger(Number(stat.ino)) && Number(stat.ino) > 0
                                                   Tests  25 passed (25)
          #11848 fail-open restored; mixed-shape canary stays green
mutant B  :141 left.inode === right.inode
          -> Number(left.inode) === Number(right.inode)
                                                   Tests  25 passed (25)
ci.yml:1652-1662  test_windows.if: merge_group || schedule || workflow_dispatch

directoryIdentityOf must keep returning inode: 0n when inodeVerifiable is false — parseIdentity rejects a persisted record whose fields disagree, via (value['inodeVerifiable'] ? value['inode'] === 0 : value['inode'] !== 0) at standalone-deletion-journal.ts:172. The mutations that must go red once the new case lands are the two above: Number(left.inode) === Number(right.inode) in sameDirectoryIdentity (:133-141), and hasVerifiableInode(Number(stat.ino)) in directoryIdentityOf — both measured green today, so please confirm the new test reds against each.

中文说明

日志的精确 inode 比较没有任何与平台无关的测试。same-file.test.ts 通过 mock node:fs——在 bigint stat 下报告一个精确 id、在 number stat 下报告其舍入后的 double——在所有平台上钉住了同一性质;日志侧没有等价物,因此它唯一能区分正反实现的见证就是本 diff 为 Windows 摘除跳过门的那个用例——而它运行在 ci.yml:1652-1662,其 test_windows.if:merge_group || schedule || workflow_dispatch,并不包含 pull_request。在 Linux 上 inode 号远低于 2^53,恢复 number 谓词的变异体与原实现在行为上完全一致,所以 PR 能触发的任何检查都无法区分二者。具体而言:把 standalone-deletion-journal.ts:102stat.ino !== 0n 换成 hasVerifiableInode(Number(stat.ino))——也就是本 diff 自己的注释要求维护者「同步修改」的那个共享 number 谓词——类型检查通过,所有 { bigint: true } 调用点保持不变因而混合形态的哨兵用例依然为绿,却恰好恢复了 #11848 的「失败即放行」:两侧都返回 inodeVerifiable: falsesameDirectoryIdentity 退化为只比较 device,于是 hasRecord 面对一份仍然存在的 prepared 记录回答 false,而不是以 reason: 'compromised' 拒绝。

给日志套件加上与 same-file.test.ts 相同的卷姿态。该文件已经 mock 了 node:fs/promises(目前只覆盖 open),因此按路径追加一个 lstat 覆盖——日志目录在首次记录时报告 2n ** 60n + 1n,在 rename 并重建之后报告 2n ** 60n + 2n——然后断言每个操作都 rejects.toMatchObject({ reason: 'compromised' })。同时加上 same-file 测试所用的 fixture 守卫 expect(Number(beforeIno)).toBe(Number(afterIno)),以免该用例在无声中不再覆盖同一个舍入桶。

证据:

baseline (pristine)                              Tests  25 passed (25)
mutant A  :102 stat.ino !== 0n
          -> Number.isSafeInteger(Number(stat.ino)) && Number(stat.ino) > 0
                                                   Tests  25 passed (25)
          #11848 fail-open restored; mixed-shape canary stays green
mutant B  :141 left.inode === right.inode
          -> Number(left.inode) === Number(right.inode)
                                                   Tests  25 passed (25)
ci.yml:1652-1662  test_windows.if: merge_group || schedule || workflow_dispatch

directoryIdentityOf 必须在 inodeVerifiable 为 false 时继续返回 inode: 0n——parseIdentity 会拒绝字段自相矛盾的持久化记录,其判定为 standalone-deletion-journal.ts:172(value['inodeVerifiable'] ? value['inode'] === 0 : value['inode'] !== 0)。新用例落地后必须变红的变异就是上面两个:sameDirectoryIdentity:133-141)中的 Number(left.inode) === Number(right.inode),以及 directoryIdentityOf 中的 hasVerifiableInode(Number(stat.ino))——两者今天实测均为绿,因此请确认新测试对每一个都会变红。

— qwen3.8-max via Qwen Code /review (v0.23.3)

@fszcd

fszcd commented Sep 15, 2026

Copy link
Copy Markdown
Author

Round 1 findings addressed in fb5cf30. Thank you — especially for R1-1: the divergence sweep over 200k bigint inputs is exactly the evidence that the "looser" framing was wrong, and reusing the core predicate is strictly better.

R1-1 (predicate reuse) — done as specified. Both comparators now import core's hasVerifiableInode from the deep path @qwen-code/qwen-code-core/utils/file-identity.js (not the barrel — the allowlist stays closed). The header comments no longer claim "looser" as the reason; they state the real one: the strict CLI predicate keeps its (ino: number) signature for its number-backed consumers per the issue's constraint, while this comparator's gate is core's bigint-ready one. Mutation runs requested:

  • same-file.ts with the number-shaped predicate substituted (Number.isSafeInteger(Number(ino)) && Number(ino) > 0): equates hard-linked names through an exact inode above the safe-integer range goes red (1 failed), the other exact-inode cases stay green.
  • same-file.ts with the gate removed entirely: decides by canonical spelling when inodes are unverifiable goes red — the ino-0 fallback is pinned.

R1-2 (gate consolidation) — done for the three review suites. inodesVerifiable(stat, path) now lives in test-utils.ts carrying the #11848 rationale once, taking the caller's own stat binding per the FixtureFs note (which matters here doubly: same-file.test.ts mocks node:fs file-wide). The journal suite keeps its local lstat-based form as you carved out. Net: three one-line call sites, one documented meaning.

R1-3 (platform-independent journal test) — done. The suite's node:fs/promises mock now poses exact per-path inode ids on both channels the journal reads (fs.lstat forwarding stat options, and handle.stat wrapped via openMock for the journal directory), and the new case rejects a journal directory replacement whose ids share one rounding bucket drives the full operation set against a replacement whose id is 2n**60n+2n to the recorded 2n**60n+1n — one double, two bigints, with the fixture guard pinning both directions. Only the journal directory is renamed; the parents keep their real unchanged identities, so on a safe-inode Linux host detection can flow only through the posed channel. Both demanded mutants confirmed red against the new test:

mutant A  directoryIdentityOf: Number.isSafeInteger(Number(stat.ino)) && Number(stat.ino) > 0
          -> 1 failed (the new test), 25 green   [fail-open restored, as predicted]
mutant B  sameDirectoryIdentity: Number(left.inode) === Number(right.inode)
          -> 1 failed (the new test), 25 green   [one rounding bucket collapses]

inode: 0n when unverifiable is preserved, so the parseIdentity invariant at :172 holds.

Full local state after the round: same-file 8/10 (2 symlink-EPERM baseline), journal 23/23 + 3 intentional win32 skips, findings 127/130 and repo-context 48/51 (failures all the pre-existing symlink-EPERM class, identical on unmodified main); eslint and prettier clean on all seven touched files; tsc --noEmit reports nothing in them.

On the Test Plan note: the two "no such file or directory" observations were the commands run from the repo root — they are package-relative paths, meant to be run from packages/cli (the PR body says so, but tersely; noted for future test plans). The 31272 passed figure is the whole-suite run, which subsumes them.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

@fszcd

fszcd commented Sep 15, 2026

Copy link
Copy Markdown
Author

@qwen-code /review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

64-bit NTFS file ids make isSameFile and the deletion-journal swap check fail open on Windows

2 participants