Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9ba0c81
fix: repair the Windows and macOS test lane failures
wenshao Aug 22, 2026
13de53d
fix: second round of platform lane repairs from CI verification
wenshao Aug 22, 2026
228fe12
fix(ci): skip coverage report generation on non-Linux CI
wenshao Aug 22, 2026
d470f63
fix(ci): stop all-green cli/core runs exiting red on RPC timeout
wenshao Aug 22, 2026
c13770e
fix: address review on the win32 guard pre-pass and lane configs
wenshao Aug 22, 2026
e7f138a
fix: three Windows lane failures from recent main commits
wenshao Aug 22, 2026
b599554
Merge remote-tracking branch 'origin/main' into fix/windows-lane-red
wenshao Aug 22, 2026
7b8ffce
fix: address round-2 review on identity fail-closed checks and the wi…
wenshao Aug 22, 2026
dcbc846
fix(cli): deny cmd.exe rewrite syntax in the daemon git-worktree guar…
qwen-code-ci-bot Aug 23, 2026
4c49041
fix: address round-4 review on the cmd-rewrite denial reason and cmd-…
qwen-code-ci-bot Aug 23, 2026
680aae0
fix: address round-5 review by closing the divergent Windows shell su…
qwen-code-ci-bot Aug 23, 2026
eaaedb7
fix: address round-6 review by gating bash-semantics tests off the wi…
qwen-code-ci-bot Aug 23, 2026
82aabb9
fix: address round-7 review by gating the Windows-shell fail-closed r…
qwen-code-ci-bot Aug 24, 2026
ef3b51f
Merge remote-tracking branch 'origin/main' into fix/windows-lane-red
wenshao Aug 24, 2026
a8f137a
fix(cli): close the cmd.exe state-persisting entrances in the daemon …
wenshao Aug 24, 2026
b888a42
Revert "fix(cli): close the cmd.exe state-persisting entrances in the…
wenshao Aug 24, 2026
7928543
Reapply "fix(cli): close the cmd.exe state-persisting entrances in th…
wenshao Aug 24, 2026
1d59f56
Merge remote-tracking branch 'origin/main' into fix/windows-lane-red
wenshao Aug 24, 2026
3055f75
fix: address round-10 review by closing the Windows-lane guard entran…
qwen-code-ci-bot Aug 24, 2026
2328579
fix(cli): repair the round-10 build rejection by mapping chdir varian…
qwen-code-ci-bot Aug 24, 2026
bc304fd
fix(cli): make the /MIR relink-switch denial assertion lane-independent
qwen-code-ci-bot Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26794,8 +26794,12 @@ describe('createAcpSessionBridge', () => {
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('updated session metadata'),
);
// The audit line prints the id through JSON.stringify, which escapes
// the backslashes of a Windows-spelled session id.
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining(session.sessionId),
expect.stringContaining(
JSON.stringify(session.sessionId).slice(1, -1),
),
);
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('pr=9517'),
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5261,6 +5261,42 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
},
);

it.skipIf(process.platform === 'win32')(
'rejects a Live managed relocation through a symlinked allowed root',
async () => {
await withEmptyTrustedFolders(async (directory) => {
const realRoot = path.join(directory, 'real-root');
const target = path.join(realRoot, 'conversation-symlinked-root');
await fs.mkdir(target, { recursive: true, mode: 0o700 });
const linkedRoot = path.join(directory, 'Conversations');
await fs.symlink(realRoot, linkedRoot);
const canonicalTarget = await fs.realpath(target);
const settings = makeSessionSettings({
mcpServers: {},
security: { folderTrust: { enabled: true } },
});
const { agent, agentPromise, sessionId } = await bootRelocatableSession(
settings,
'expected-capability',
);

try {
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionCd, {
sessionId,
path: canonicalTarget,
allowedRoots: [linkedRoot],
managedRelocation: 'live-conversation',
}),
).rejects.toThrow('owner-only allowed root');
} finally {
mockConnectionState.resolve();
await agentPromise;
}
});
},
);

it('sessionArtifactsPersist rejects a missing session id', async () => {
const { agent, agentPromise } = await bootAcpAgent();

Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3182,8 +3182,15 @@ interface ActivePromptCall {
}

function isOwnerOnlyDirectory(stats: Stats): boolean {
if (process.platform === 'win32') return false;
if (stats.isSymbolicLink() || !stats.isDirectory()) return false;
if (process.platform === 'win32') {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Critical] Windows managed relocation bypasses folder trust without checking ACL ownership.

Failure scenario: On a multi-user Windows machine, a Live conversation root or direct child can grant another account write access through its ACL. isOwnerOnlyDirectory returns true for every ordinary Windows directory, so managedTrustAllowed bypasses trusted-folder approval and relocates the session into attacker-writable project content. The surrounding realpath and dev/ino checks establish identity, not exclusive write access.

Witness: Not run — the decisive ACL condition requires a multi-account Windows environment unavailable on this Darwin host; the code trace confirms the unconditional true reaches managedTrustAllowed and bypasses folder trust.

Suggested fix: Keep the managed-trust bypass disabled on Windows unless ownership and write ACLs can be verified with a Windows-specific mechanism, or require the normal folder-trust path on Windows.

中文说明

失败场景: 在多用户 Windows 机器上,Live 会话根目录或其直接子目录可能通过 ACL 允许其他账户写入。这里对所有普通 Windows 目录都返回 true,导致 managedTrustAllowed 跳过可信文件夹确认,并把会话迁移到攻击者可写的项目内容中。周围的 realpath 与 dev/ino 检查只能确认目录身份,不能证明只有当前用户可写。

验证: 未运行——关键 ACL 条件需要当前 Darwin 环境不具备的多账户 Windows;代码路径确认这个无条件 true 会进入 managedTrustAllowed 并绕过 folder trust。

建议修复: 除非使用 Windows 专用机制验证所有权和写入 ACL,否则不要在 Windows 上启用 managed-trust 绕过;或者在 Windows 上始终走正常的 folder-trust 流程。

— gpt-5.6-sol via Qwen Code /review (v0.21.14)

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.

Escalating for a maintainer decision — not implementing either suggested fix this round; thread left open.

Verified by code trace: on Windows isOwnerOnlyDirectory now returns true for any non-symlink directory (Node's fs.Stats exposes no ownership/ACL bits on win32), and a true result feeds managedTrustAllowed, which skips the folder-trust check for Live managed relocation. Containment on Windows rests on the symlink-rejection arm (the relocation path uses fs.lstat) plus dev/ino identity across the TOCTOU windows. On a multi-user Windows host whose ACLs let another account write into the live-session root, these checks do not prove exclusive control — the failure scenario as described is traceable.

Why neither suggested fix is actionable from this runner:

  1. Keep the bypass disabled on Windows — isOwnerOnlyDirectory gates the relocation itself (all five call sites reject on false), so this disables Live managed relocation on Windows entirely, undoing the behavior this PR restores and which the author's Windows verification run validated.
  2. Verify ownership/write ACLs with a Windows-specific mechanism — Node exposes no ACL information on Windows; an icacls/Win32-based check cannot be developed or verified from this Linux runner (no Windows lane is available to this round).

Context: the same platform trade-off is documented and made elsewhere in this codebase for daemon-managed paths — serve/live/discovery.ts skips the POSIX owner/mode checks on win32, and managed-scratch-workspace.ts's validateRootStats does the same.

Question for the maintainer: accept and document the residual Windows ACL risk (status quo — relocation additionally requires a trusted private ACP parent, one absolute allowed root, and the lstat/dev-ino checks), disable managed relocation on Windows until a real ACL mechanism exists, or schedule a Windows ACL check (icacls/Win32 binding) as follow-up work on a Windows runner? Our recommendation is status quo + follow-up ACL work, but this is a risk-acceptance call we won't make unilaterally.

中文说明

升级给维护者决策——本轮不实施所建议的两个修复方向;线程保持打开。

代码追踪确认: 在 Windows 上 isOwnerOnlyDirectory 现在对任何非符号链接目录返回 true(Node 的 fs.Stats 在 win32 上不暴露属主/ACL 位),而 true 的结果会进入 managedTrustAllowed,从而跳过 Live 托管重定位的 folder-trust 检查。Windows 上的容器性依赖符号链接拒绝分支(重定位路径使用 fs.lstat)与 TOCTOU 窗口间的 dev/ino 同一性。在多用户 Windows 主机上,如果 ACL 允许其他账户写入 live 会话根目录,这些检查无法证明独占控制——所述失败场景可以追踪成立。

为什么两个建议修复在本运行环境都不可执行:

  1. 在 Windows 上保持禁用该绕过 —— isOwnerOnlyDirectory 是重定位本身的门槛(五个调用点在返回 false 时都会拒绝),这等于在 Windows 上完全禁用 Live 托管重定位,撤销本 PR 恢复的、且作者已在 Windows 验证运行中确认过的行为。
  2. 用 Windows 专用机制验证所有权/写 ACL —— Node 在 Windows 上不暴露任何 ACL 信息;基于 icacls/Win32 的检查无法在本 Linux 运行环境上开发或验证(本轮没有可用的 Windows lane)。

背景: 代码库中其他守护进程托管路径已经记录并做出了同样的平台取舍——serve/live/discovery.ts 在 win32 上跳过 POSIX 属主/权限位检查,managed-scratch-workspace.ts 的 validateRootStats 也是如此。

给维护者的问题: 接受并在文档中记录 Windows ACL 残余风险(维持现状——重定位还额外要求受信任的私有 ACP 父进程、唯一绝对 allowed root 以及 lstat/dev-ino 检查),还是在真正的 ACL 机制出现之前在 Windows 上禁用托管重定位,或者把 Windows ACL 检查(icacls/Win32 绑定)安排为后续工作在 Windows 运行环境完成?我们的建议是维持现状 + 后续 ACL 工作,但这是风险接受决策,我们不会单方面做出。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] Follow-up on the accepted trade-off: the residual guarantee this decision rests on is itself void on the volumes this same PR calls unverifiable.

The maintainer decision above keeps the win32 relaxation and names the remaining containment as "symlink/junction rejection via lstat, and dev/ino identity re-verified across the realpath round trip at both ends". The symlink leg holds. The dev/ino leg does not, on the exact platform this branch now applies to.

This PR adds hasVerifiableInode() (packages/core/src/utils/file-identity.ts) whose own doc says "callers that rely on inode identity for a correctness or security decision must treat zero as unverifiable", and applies it in two places (review/lib/same-file.ts, serve/managed-scratch-workspace.ts). It is not applied at the three comparisons this decision leans on — acpAgent.ts:9590, :9616, :9645 compare raw dev/ino.

On a Windows host whose Live conversations root sits on exFAT/FAT32 removable media or an SMB/mapped drive, every ino is 0, so rootBefore.ino !== rootAfter.ino, targetBefore.ino !== targetAfter.ino and the final rootFinal.ino !== rootAfter.ino re-check all pass vacuously. The TOCTOU re-validation named as the substitute for the POSIX mode/uid check becomes a tautology, and the only surviving containment is the symlink test.

Cheapest fix consistent with the rest of the PR: guard those three comparisons with hasVerifiableInode and fail closed when it returns false — the same choice managed-scratch-workspace.ts now makes for its root. Note also that the one new test added for the surviving symlink leg (acpAgent.test.ts:5264) is it.skipIf(process.platform === 'win32'), so the win32 branch has no coverage on either leg.

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.

Deferred (was deferred under the round-10 cap). Applying hasVerifiableInode to the three acpAgent dev/ino comparisons (acpAgent.ts:9590, :9616, :9645) and failing closed when it returns false stays in the queue for a normal round, together with coverage for the win32 branch's surviving symlink leg. This round is a same-run verification repair: the round-10 commit was rejected because npm run build failed, so the preserved rejected commit plus exactly one follow-up commit fixes that rejection, and no other code changes fit this round's instruction.

中文说明

延后(第 10 轮已按上限延后)。对 acpAgent 三处 dev/ino 比较(acpAgent.ts:9590、:9616、:9645)应用 hasVerifiableInode 并在其返回 false 时失败即关闭,连同 win32 分支仅存符号链接腿的覆盖,保留在常规轮次队列中。本轮是同跑验证修复:第 10 轮提交因 npm run build 失败被拒,因此保留被拒提交并以恰好一个后续提交修复该拒绝,本轮指令不允许其他代码改动。

// Node's fs.Stats exposes no ownership or permission bits on Windows, so
Comment on lines +3186 to +3187

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.

Re-verified at dcbc846: the trace stands — the win32 branch of isOwnerOnlyDirectory returns true for every ordinary directory (Node's fs.Stats exposes no ownership/permission bits on Windows) and feeds managedTrustAllowed, so managed relocation skips global folder-trust approval on any directory that passes the structural identity checks, which establish identity, not exclusive write access. The decisive witness (a multi-account Windows machine with a cross-account-writable conversation root) is unavailable on this runner, and the resolution turns on a risk-acceptance call that is the maintainer's, not this bot's — so the thread stays open with the round-2 escalation, restated:

  1. Accept and document the residual risk; or
  2. keep managed relocation disabled on Windows (win32 branch returns false) pending a Windows ACL mechanism; or
  3. schedule follow-up ACL work.

Recommendation on record: option 2 (fail-closed) unless the residual risk is explicitly accepted — it preserves the guard family's deny-what-cannot-be-verified invariant at the cost of the managed-relocation convenience on Windows only. No code change this round; awaiting the decision.

中文说明

已在 dcbc846 上重新核实:追踪成立——isOwnerOnlyDirectory 的 win32 分支对每个普通目录都返回 true(Node 的 fs.Stats 在 Windows 上不暴露所有权/权限位),其结果进入 managedTrustAllowed,因此任何通过结构性身份检查的目录都会在托管重定位时跳过全局文件夹信任确认;而这些结构检查只能确认身份,不能证明独占写权限。关键验证(需要一台多账户 Windows 机器,且会话根目录可被另一账户写入)在本运行环境不可用,而处置取决于一个属于维护者而非本机器人的风险接受决策——因此线程保持开放,并重申第 2 轮的升级问题:

  1. 接受并记录残余风险;或
  2. 在 Windows ACL 机制出现前,于 Windows 上保持禁用托管重定位(win32 分支返回 false);或
  3. 安排后续 ACL 工作。

记录在案的建议:除非维护者明确接受残余风险,否则选择方案 2(失败即关闭)——它以仅失去 Windows 上的托管重定位便利为代价,保持该 guard 家族"无法验证即拒绝"的不变量。本轮未改动代码;等待决策。

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.

Still escalated — no code change this round. Re-verified at HEAD eaaedb7: the win32 branch of isOwnerOnlyDirectory returns true unconditionally, exactly as this re-check states; no commit has touched it. Closing the gap needs either a Windows ACL ownership mechanism or disabling managed relocation on Windows — both are product/scope choices, and the escalation already on this thread asks the maintainer to pick: accept and document the residual risk, keep managed relocation disabled on Windows pending an ACL mechanism, or schedule follow-up ACL work. Thread stays open for that decision (the original thread at line 3934 carries the same escalation).

中文说明

仍为升级状态——本轮无代码变更。已在 HEAD eaaedb7 复验:isOwnerOnlyDirectory 的 win32 分支确实如本复查所述无条件返回 true,且没有任何提交触碰过它。关闭该缺口需要 Windows ACL 所有权机制,或在 Windows 上禁用托管重定位——两者都是产品/范围选择,本线程已有的升级正请维护者定夺:接受并记录残余风险;在 ACL 机制出现前于 Windows 上保持禁用托管重定位;或排期后续 ACL 工作。线程保持打开等待该决策(3934 行的原始线程携带同一升级)。

Comment on lines +3186 to +3187

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.

Disposition: escalated for maintainer decision — no code change this round; thread stays open.

Code trace re-verified at this HEAD (ef3b51f3): the win32 branch of isOwnerOnlyDirectory returns true unconditionally, managedTrustAllowed becomes true on the containment pass (acpAgent.ts:9547–9611), and validateTrust() then short-circuits the global folder-trust check (acpAgent.ts:9636). The ACL exploitation itself needs a multi-account Windows machine unavailable on this runner, as the finding discloses.

Two facts frame the decision: this PR introduced the flip — origin/main returned false on win32, i.e. managed relocation was disabled there, so reverting regresses nothing that shipped; and the precedent the in-code comment cites (serve/live/discovery.ts, which does exist at packages/cli/src/serve/live/discovery.ts) governs daemon-owned runtime records under the user profile, while this predicate moves the session's working directory into project content — exactly what folder trust is designed to gate.

The options, as the escalation frames them:

  1. Accept and document the residual risk on multi-user Windows.
  2. Keep managed relocation disabled on Windows pending a Windows ACL mechanism — revert the win32 branch to fail closed; restores main's posture exactly.
  3. Schedule follow-up ACL work (e.g. an ACL check via PowerShell Get-Acl/icacls or a native call) and keep the feature meanwhile.

Recommendation: (2) is the security-conservative default — it removes the bypass with zero regression against main; if Windows managed relocation is needed before an ACL mechanism exists, (3) with the ACL check as a fast follow-up, and (1) only with explicit maintainer sign-off on the residual risk. Which of the three should this PR land?

中文说明

处理:升级给维护者决策——本轮不改代码,线程保持打开。

已在本 HEAD(ef3b51f3)上重新核对代码轨迹:isOwnerOnlyDirectory 的 win32 分支无条件返回 true,容器检查(acpAgent.ts:9547–9611)将 managedTrustAllowed 置为 true,随后 validateTrust() 短路全局文件夹信任检查(acpAgent.ts:9636)。ACL 利用本身需要本运行环境不具备的多账户 Windows 机器,正如该发现所披露。

两个事实框定了决策:是本 PR 引入了这一翻转——origin/main 在 win32 上返回 false,即托管重定位在 Windows 上原本就是禁用的,因此回退不会破坏任何已发布行为;代码内注释引用的先例(serve/live/discovery.ts,该文件确实存在于 packages/cli/src/serve/live/discovery.ts)管辖的是用户 profile 下 daemon 自有的运行时记录,而本谓词是把会话工作目录迁入项目内容——恰恰是文件夹信任要门控的对象。

升级所列的三个选项:

  1. 接受并记录多用户 Windows 上的残余风险。
  2. 在 Windows ACL 机制出现前于 Windows 上保持禁用托管重定位——将 win32 分支回退为失败即关闭;完全恢复 main 的姿态。
  3. 安排后续 ACL 工作(例如经 PowerShell Get-Acl/icacls 或原生调用的 ACL 检查),期间保留该功能。

建议:(2) 是安全保守的默认项——它消除绕过且相对 main 零回归;若在 ACL 机制出现之前就需要 Windows 托管重定位,则选 (3) 并把 ACL 检查作为快速跟进;(1) 仅在维护者对残余风险明确签字时采用。本 PR 应落地三者中的哪一个?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Maintainer decision: accepted trade-off — no code change, closing this thread.

The finding describes the weakening accurately; the disposition weighs the three available options:

  1. A Windows ACL ownership mechanism: Node exposes no ACL API; shelling out to icacls would add a fragile, locale-dependent text parse that is itself an injection surface on the path being hardened. Not viable in this layer.
  2. Disable managed relocation on Windows (restore the hard false): breaks Live conversations on Windows outright, and the lane's tests assert the relocation works.
  3. Keep the structural containment and accept the missing ownership proof on this platform.

The residual guarantee on win32 is narrower than POSIX but not empty: the relocation still requires a trusted private ACP parent, exactly one absolute allowed root, a direct-child target, symlink/junction rejection via lstat, and dev/ino identity re-verified across the realpath round trip at both ends. The ownership check on POSIX proves another local user cannot write the directory; that specific proof has no Node-level equivalent on Windows, and its practical threat model (multi-user Windows boxes running this product) is marginal. The production trust surface also does not widen the way "bypasses folder trust" suggests: the allowed root is the session's own already-trusted workspace and the target is a daemon-materialized child of it.

This is also the trade-off serve/live/discovery.ts already makes on win32 (mode/uid checks skipped there), so the codebase is now consistent. If a native ACL check becomes available later it can replace the win32 branch without API churn.

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.

Escalating for an explicit maintainer call — this is a decision among three options, not a unilateral patch. The win32 branch returns true for every ordinary directory because Node's fs.Stats exposes no ownership/permission bits on Windows, so managedTrustAllowed skips folder-trust validation; the surrounding realpath + dev/ino checks prove path identity, not exclusive write access by the current account. It is a deliberate platform trade-off matching serve/live/discovery.ts; residual risk is limited to multi-user Windows machines whose ACLs grant another account write access to the session root, and the code already documents the trade-off. Options: (a) accept + document; (b) keep managed relocation disabled on Windows until an ACL mechanism exists; (c) schedule ACL follow-up work. Recommendation: (a)+(c), matching yiliang114's pragmatic landing. Maintainers: please confirm (a)+(c), or pick (b).

中文说明

升级,等待维护者明确拍板——这是在三个选项之间做决定,而不是单方面的补丁。win32 分支对每个普通目录都返回 true,因为 Node 的 fs.Stats 在 Windows 上不暴露属主/权限位,于是 managedTrustAllowed 跳过了文件夹信任校验;外围的 realpath + dev/ino 检查证明的是路径同一性,而非当前账户的独占写权限。这是与 serve/live/discovery.ts 一致的、有意的平台权衡;残余风险仅限于那些 ACL 允许另一账户写入会话根目录的多用户 Windows 机器,代码中已用注释记录了该权衡。选项:(a) 接受并记录;(b) 在出现 ACL 机制前继续在 Windows 上禁用托管重定位;(c) 排期 ACL 后续工作。建议:(a)+(c),与 yiliang114 的务实落点一致。维护者:请确认 (a)+(c),或选择 (b)。

// the POSIX mode/uid check has no equivalent here. Containment then rests
// on the structural checks around this predicate — symlink rejection and
// dev/ino identity across the realpath round trip — the same trade-off
// serve/live/discovery.ts already makes on this platform.
return true;
}
if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,25 +73,30 @@ describe('CaptureScreenContextTool', () => {
await expect(readFile(file.path)).rejects.toThrow();
});

it('rejects a symlink and deletes only the Host-provided link', async () => {
const target = await captureFile();
const link = join(target.directory, 'linked.png');
await symlink(target.path, link);
const tool = new CaptureScreenContextTool(
async () => ({
appName: 'Finder',
accessibilityText: '',
screenshotPath: link,
}),
target.directory,
);
// The rejection relies on O_NOFOLLOW, which libuv ignores on win32; the
// tool is macOS-scoped, matching the symlink-test skips elsewhere.
it.skipIf(process.platform === 'win32')(
'rejects a symlink and deletes only the Host-provided link',
async () => {
const target = await captureFile();
const link = join(target.directory, 'linked.png');
await symlink(target.path, link);
const tool = new CaptureScreenContextTool(
async () => ({
appName: 'Finder',
accessibilityText: '',
screenshotPath: link,
}),
target.directory,
);

const result = await tool.build({}).execute(new AbortController().signal);
const result = await tool.build({}).execute(new AbortController().signal);

expect(result.error?.message).toBeTruthy();
await expect(readFile(target.path)).resolves.toEqual(PNG);
await expect(readFile(link)).rejects.toThrow();
});
expect(result.error?.message).toBeTruthy();
await expect(readFile(target.path)).resolves.toEqual(PNG);
await expect(readFile(link)).rejects.toThrow();
},
);

it('rejects a screenshot outside the Host private directory', async () => {
const outside = await captureFile();
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/commands/review/cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ const mocks = vi.hoisted(() => ({
aoneWhoamiAccount: vi.fn(() => 'reviewer'),
}));

// The fixtures below key on POSIX path literals (`/repo/.qwen/tmp/…`), but
// cleanup.ts and the helpers it calls (redirectedAncestor, promptRecordDir,
// the deadline readers) run those strings through node:path. On Windows that
// would spell them with a drive letter and backslashes, so no literal-keyed
// mock or assertion could ever match. Pin POSIX semantics for this module
// graph; on POSIX hosts this is the identity.
vi.mock('node:path', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:path')>();
return { ...actual, ...actual.posix, default: actual.posix };
});

vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return {
Expand Down
17 changes: 14 additions & 3 deletions packages/cli/src/commands/review/drive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -935,7 +935,7 @@ describe("the verify brief's bound-address recipe actually captures", () => {

it.skipIf(!have('curl') || !have('mktemp'))(
"puts the service's own address in the drive log, not the response body",
() => {
async () => {
const { pattern, script } = recipe();
const dir = mkdtempSync(join(tmpdir(), 'drv-recipe-'));
// A service whose RESPONSE BODY also carries a listening-on line: if the
Expand All @@ -948,7 +948,7 @@ describe("the verify brief's bound-address recipe actually captures", () => {
"import http from 'node:http';",
"const s=http.createServer((_q,r)=>{r.writeHead(200);r.end('listening on http://127.0.0.1:59999\\n')});",
"s.listen(0,'127.0.0.1',()=>console.log(`svc listening on http://127.0.0.1:${s.address().port}`));",
'setTimeout(()=>process.exit(0),20000);',
'setTimeout(()=>process.exit(0),5000);',
].join('\n'),
);

Expand Down Expand Up @@ -990,7 +990,18 @@ describe("the verify brief's bound-address recipe actually captures", () => {
readdirSync(dir).filter((f) => f.endsWith('.log') && f !== 'drive.log'),
).toEqual([]);

rmSync(dir, { recursive: true, force: true });
// On Windows the backgrounded service keeps the working directory
// busy (EBUSY) until its self-exit timer fires; retry the removal
// until the handle is released.
for (let attempt = 0; ; attempt++) {
try {
rmSync(dir, { recursive: true, force: true });
break;
} catch (error) {
if (attempt >= 40) throw error;
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
},
);
});
Expand Down
11 changes: 8 additions & 3 deletions packages/cli/src/commands/review/fetch-pr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createHash } from 'node:crypto';
import type { Argv, CommandModule } from 'yargs';
import { resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import {
fetchPrCommand,
countDiffChangedLines,
Expand Down Expand Up @@ -4183,7 +4184,11 @@ describe('fetch-pr --resume', () => {
});

describe('fetch-pr --resume bookkeeping is counted, not merely called', () => {
const OUT = '/tmp/fetch-report.json';
// The deadline helpers derive the record dir through resolve(OUT), so OUT
// must be a native absolute path: a POSIX literal gains a drive letter and
// backslashes on Windows, the disk-routing mock below stops matching, and
// /tmp also maps to an unwritable drive root there.
const OUT = join(tmpdir(), 'fetch-report.json');
const DIFF_BYTES = 'diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n+x\n';

function prevReport(over: Record<string, unknown> = {}): string {
Expand Down Expand Up @@ -4474,7 +4479,7 @@ describe('fetch-pr --resume bookkeeping is counted, not merely called', () => {
actualDeadline.writeRoundCapStop(OUT, 2, 2, now + 2000);

await run();
const stampsFile = `${recordDir}/budget-rounds.json`;
const stampsFile = join(recordDir, 'budget-rounds.json');
// Resume 1 kept the stop — and the stamps that price its rounds.
expect(actualDeadline.readBudgetStop(OUT)).not.toBeNull();
expect(realFs.existsSync(stampsFile)).toBe(true);
Expand Down
17 changes: 11 additions & 6 deletions packages/cli/src/commands/review/lib/git.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ function git(...args: string[]): string {
return execFileSync('git', args, { cwd: repo, encoding: 'utf8' });
}

// Git prints worktree paths with forward slashes on Windows, while
// `join`/`realpathSync` build backslash spellings there; compare both sides
// slash-normalized (the identity on POSIX).
const fwd = (value: string): string => value.replace(/\\/g, '/');

beforeEach(() => {
repo = mkdtempSync(join(tmpdir(), 'review-wt-'));

Expand Down Expand Up @@ -75,7 +80,7 @@ describe('releaseWorktree', () => {

expect(existsSync(join(repo, 'wt'))).toBe(false);
// Not `.not.toContain('wt')` — the fixture's own path holds that substring.
expect(git('worktree', 'list')).not.toContain(join(repo, 'wt'));
expect(fwd(git('worktree', 'list'))).not.toContain(fwd(join(repo, 'wt')));
});

it('removes an unregistered non-empty leftover git no longer tracks', () => {
Expand All @@ -86,7 +91,7 @@ describe('releaseWorktree', () => {
mkdirSync(join(repo, 'wt', 'junk'), { recursive: true });
writeFileSync(join(repo, 'wt', 'junk', 'f'), 'x');
// Negative control: it is not a registered worktree.
expect(git('worktree', 'list')).not.toContain(join(repo, 'wt'));
expect(fwd(git('worktree', 'list'))).not.toContain(fwd(join(repo, 'wt')));

expect(releaseWorktree(join(repo, 'wt'))).toMatchObject({
existed: true,
Expand Down Expand Up @@ -169,8 +174,8 @@ describe('releaseWorktree', () => {
// macOS (`/var` → `/private/var`): the raw spelling passes there only by
// accident — the canonical path happens to contain it as a substring —
// and would not on a Linux fixture reached through a symlinked ancestor.
expect(git('worktree', 'list')).toContain(
join(realpathSync(repo), 'victim'),
expect(fwd(git('worktree', 'list'))).toContain(
fwd(join(realpathSync(repo), 'victim')),
);
expect(existsSync(join(repo, 'victim', 'keep.txt'))).toBe(true);
});
Expand All @@ -193,8 +198,8 @@ describe('releaseWorktree', () => {
expect(got.freed).toBe(false);
expect(got.reason).toContain('symlink');
// Registered and on disk, both.
expect(git('worktree', 'list')).toContain(
join(realpathSync(repo), 'real', 'victim'),
expect(fwd(git('worktree', 'list'))).toContain(
fwd(join(realpathSync(repo), 'real', 'victim')),
);
expect(existsSync(join(repo, 'real', 'victim', 'keep.txt'))).toBe(true);
});
Expand Down
104 changes: 101 additions & 3 deletions packages/cli/src/commands/review/lib/same-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,63 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
linkSync,
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { basename, dirname, join } from 'node:path';
import { isSameFile } from './same-file.js';

// Lets a test pose as a volume that exposes no inode numbers: statSync
// reports ino 0 while enabled, everything else delegates to the real thing.
const inoZeroVolume = vi.hoisted(() => ({ enabled: false }));
// Lets a test pose as a case-insensitive volume (FAT/exFAT/SMB): every
// registered case-variant spelling stats and canonicalises as the file it
// names. The NATIVE canonicaliser reports the on-disk spelling — that is
// what GetFinalPathNameByHandleW does on Windows — while the JS walker
// echoes the caller's own spelling back.
const caseInsensitiveVolume = vi.hoisted(() => ({
aliases: new Map<string, string>(),
}));

vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const resolveAlias = (filePath: string): string =>
caseInsensitiveVolume.aliases.get(filePath) ?? filePath;
const statSync = ((filePath: string) => {
const stats = actual.statSync(resolveAlias(String(filePath)));
if (inoZeroVolume.enabled) stats.ino = 0;
return stats;
}) as typeof actual.statSync;
const realpathSync = Object.assign(
(filePath: Parameters<typeof actual.realpathSync>[0]) => {
const caller = String(filePath);
const resolved = actual.realpathSync(resolveAlias(caller));
return caseInsensitiveVolume.aliases.has(caller)
? join(dirname(resolved), basename(caller))
: resolved;
},
{
native: (filePath: Parameters<typeof actual.realpathSync>[0]) =>
actual.realpathSync(resolveAlias(String(filePath))),
},
) as unknown as typeof actual.realpathSync;
return {
...actual,
statSync,
realpathSync,
default: { ...actual, statSync, realpathSync },
};
});

describe('isSameFile', () => {
let dir: string;

Expand All @@ -31,15 +74,70 @@ describe('isSameFile', () => {
rmSync(dir, { recursive: true, force: true });
});

it('treats two hard links to one file as the same file', () => {
it('treats two hard links to one file as the same file', (ctx) => {
const original = join(dir, 'original.json');
writeFileSync(original, '{}');
const linked = join(dir, 'linked.json');
linkSync(original, linked);
// Hard-link identity rides dev/ino; on volumes that expose no inode
// numbers (ino 0) the comparison degrades to canonical spellings by
// design and cannot see through a hard link.
if (Number(statSync(original).ino) === 0) {
ctx.skip();
return;
}
expect(isSameFile(original, linked)).toBe(true);
expect(isSameFile(linked, original)).toBe(true);
});

it('decides by canonical spelling when inodes are unverifiable', () => {
// FAT/exFAT-style volumes report ino 0 for every file; the comparison
// must fall back to canonical spellings there — never equating distinct
// files through a shared zero, never missing two spellings of one path.
const left = join(dir, 'ino-left.json');
const right = join(dir, 'ino-right.json');
writeFileSync(left, '{}');
writeFileSync(right, '{}');
mkdirSync(join(dir, 'ino-real'));
writeFileSync(join(dir, 'ino-real', 'aliased.json'), '{}');
symlinkSync(join(dir, 'ino-real'), join(dir, 'ino-link'));
const aliased = join(dir, 'ino-real', 'aliased.json');
const throughLink = join(dir, 'ino-link', 'aliased.json');
inoZeroVolume.enabled = true;
try {
expect(isSameFile(left, right)).toBe(false);
expect(isSameFile(aliased, throughLink)).toBe(true);
expect(isSameFile(throughLink, aliased)).toBe(true);
} finally {
inoZeroVolume.enabled = false;
}
});

it('equates case-variant spellings when inodes are unverifiable', () => {
// FAT/exFAT/SMB volumes are case-insensitive AND report ino 0 for every
// file, so both spellings of one file stat there. The fallback must
// compare through the canonicaliser that folds case (realpathSync.native
// — GetFinalPathNameByHandleW on Windows), not the JS walker that echoes
// the caller's spelling: a false `false` silently disables the
// anti-clobber guards that consume this predicate.
const real = join(dir, 'Report.md');
writeFileSync(real, '{}');
const variant = join(dir, 'report.md');
caseInsensitiveVolume.aliases.set(variant, real);
inoZeroVolume.enabled = true;
try {
expect(isSameFile(real, variant)).toBe(true);
expect(isSameFile(variant, real)).toBe(true);
// Two genuinely distinct files stay distinct under the same pose.
const other = join(dir, 'other.md');
writeFileSync(other, '{}');
expect(isSameFile(real, other)).toBe(false);
} finally {
inoZeroVolume.enabled = false;
caseInsensitiveVolume.aliases.clear();
}
});

it('treats two distinct files as different files', () => {
const left = join(dir, 'left.json');
const right = join(dir, 'right.json');
Expand Down
Loading
Loading