Skip to content

feat(web-shell): manage git remotes from the workspace branch picker - #11163

Open
wenshao wants to merge 38 commits into
mainfrom
feat/git-manage-remotes
Open

feat(web-shell): manage git remotes from the workspace branch picker#11163
wenshao wants to merge 38 commits into
mainfrom
feat/git-manage-remotes

Conversation

@wenshao

@wenshao wenshao commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

The workspace git popover in the Web Shell — opened from the left sidebar's workspace git pill or the composer branch chip — gains a Manage Remotes panel: list the repository's configured remotes with their fetch and push URLs, add a remote, and remove one behind a two-click inline confirm. It is backed by three new workspace-scoped daemon routes (list / add / remove), matching SDK client methods, and a core git helper that reads remotes from git's CONFIG (git config --list --show-scope -z, NUL-framed records) instead of git remote -v or per-name get-url lookups. Mutations answer with the fresh remote list, so the panel re-renders in one round trip; a removal also refreshes the branch listing and the branch chip's tracking state, because git deletes the remote-tracking refs and upstream configuration together with the remote.

Why it's needed

The popover could pull, commit, push, and switch branches, but the remotes themselves were read-only context — remote-tracking branches were listed, grouped by a remote name the user could not change. Anyone who clones from a zip (no origin), works a triangular fork + upstream layout, or just wants to drop a stale remote had to leave the Web Shell and open a terminal. This closes that gap with the minimal useful set (list / add / remove); URL editing, rename, and fetch/prune stay out of scope.

Two details worth calling out because they shaped the implementation:

  • The listing reads config, scoped exactly. One git config --list --show-scope -z read yields the full section (urls, push urls, refspecs, promisor/partial-clone) immune to insteadOf rewriting and rendered-output annotations, and the scope filter lists the repository's own scope (local + worktree, include-sourced) — the sections git remote add/remove act on — while inherited global/system sections stay out. Add pre-flights the name across ALL scopes and refuses a same-name inherited section with 409 remote_shadows_inherited; removal re-reads the section across every scope, completes the worktree-scope half git cannot edit, sweeps the pointing branches' upstream keys and orphaned refs/remotes/<name>/* a refspec-less removal leaves, and refuses 409 remote_still_configured rather than certifying a state git would still resolve.
  • Display is sanitized, requests are raw. A .git/config the user did not author (downloaded zip, cloned repo) can carry bidi/zero-width characters in remote names and URLs. The panel strips the full Unicode Default_Ignorable set at every render boundary (rows, tooltips, aria labels, footer messages, and the search filter, so what you can see is what you can search for), while add/remove requests always carry the exact configured name — removal validation is deliberately as lenient as git itself, so a hand-edited config can always be cleaned up through the UI. Names differing only by whitespace (which CSS collapses out of the inked text) flag the same way, with the raw name spelled out as codepoint escapes in the tooltip and aria-labels.

Reviewer Test Plan

How to verify

Run the daemon against a git workspace, open the Web Shell, and expand the workspace in the left sidebar:

  1. Click the workspace's git pill → the popover opens with the usual branch actions; a new Manage Remotes… row sits after Checkout Tag or Revision… (it is also matched by the popover's search box).
  2. Click it → the panel replaces the branch list: each configured remote shows its name and fetch URL (hover for the push URL when it differs), with an add form (name + URL) at the bottom and a back arrow at the top.
  3. Add a remote → a success notice appears in the footer and the row shows up immediately. Adding the same name again surfaces git's own remote … already exists error in the footer, list unchanged.
  4. Remove → the first click on the row's trash icon arms a red Confirm; the second click removes it. Navigate back: the branch list's remote group for that remote is gone, and if the current branch tracked it, the chip's upstream/ahead-behind state refreshed too.
  5. Race behavior: delete a remote in a terminal while the panel is open, then confirm its removal in the panel → git's No such remote lands in the footer and the panel re-reads the list instead of keeping a row that can never be removed.
  6. Search while in the panel filters remotes by name/URL; a query matching nothing says No remotes match the search (distinct from No remotes configured). Entering or leaving the panel clears the query, so typing "remotes" to find the action row does not open a panel filtered to nothing.
  7. Invisible-character hardening: with a hand-edited config (e.g. a remote name or URL containing U+202E/U+200F), the row renders with those characters stripped, searching the displayed name still finds it, and removal still succeeds (the request carries the raw configured name).
  8. Partial clone: in a git clone --filter=blob:none repo with a push-URL override, the panel shows the true fetch URL and the override in the tooltip.
  9. Guard parity with the sibling branch routes: untrusted workspace → 403, closed workspace generation → 503 workspace_runtime_unavailable, ?cwd= escaping the workspace → 400, non-repo → 404 not_a_git_repository. Error bodies never contain absolute paths.

Executable evidence: packages/web-shell/client/e2e/web-shell.git-remotes.spec.ts drives flows 1–6 end-to-end in Chromium against the mocked daemon, and the daemon layer is covered against real temporary git repositories by the route suite (27 cases) and the core suite (55 cases, including the partial-clone and invisible-character shapes).

Evidence (Before & After)

Before: the popover's action list ended at Checkout Tag or Revision…; remote names appeared only as read-only group headers in the collapsed Remote branch section, with no way to add or remove one.

After: the same popover carries a Manage Remotes… row opening the panel below. Screenshots captured from the mocked-daemon Playwright run (default theme, 1280×800):

1. New entry row in the branch picker 2. Remotes panel: list + add form
3. Add form filled in 4. Add success in the footer
5. Two-click remove confirm armed (row 3)

Tested on

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

Environment (optional)

Local daemon build + Web Shell dev server; Playwright (Chromium) for the UI e2e and the screenshots above; vitest suites against real temp repos for core/routes. Full local gates green on the current head: npm run build, npm run typecheck, eslint --max-warnings 0 on every touched file, core 162/162, cli routes 100/100 (including the sibling branch-route suite), SDK 417/417, web-shell 109/109, e2e 5/5.

Risk & Scope

  • Main risk or tradeoff: the git error classifier shared by ALL workspace git routes (branches, pull, checkout, commit, remotes) gained six remote-specific branches, matched ahead of every legacy keyword branch and anchored to a line start (or git's documented two-line lock chain) on the full untruncated detail — a config-chosen remote name can carry any keyword, and a config-chosen value can carry a real newline, so any deeper line is attacker-controllable. The ordering and the newline-injection shapes are pinned by a collocated classification-table suite, and the sibling branch-route suite stays green. Error responses redact not only the workspace path and git root but any gitdir outside the cwd's tree — a linked worktree's shared main .git dir, a submodule's gitdir, symlink-canonical spellings — parsed from the .git file with head-bounded reads. Second: removal is verified, never trusted — git's own remote remove deletes tracking refs, upstream keys, and the section in that order, and every step the panel cannot see git complete (worktree-scope sections and upstream keys, refspec-less orphaned refs, inherited same-name survivors) is either completed in the scope git could not write or refused with a name-free 409.
  • Not validated / out of scope: no set-url, rename, fetch, or prune; scoped routes only (no legacy /workspace/git/remote* form, matching the GitHub-PRs precedent); a live-daemon curl pass was not run in the authoring environment (its shell guard blocks git fixture writes) — the daemon layer is instead verified by supertest route tests against real temp repos, and the UI by the Playwright spec. Windows/Linux rely on CI.
  • Breaking changes / migration notes: none — routes, SDK methods, i18n keys, and UI are purely additive.

Linked Issues

None.

中文说明

这个 PR 做了什么

Web Shell 的 workspace git 弹层(从左侧栏 workspace 的 git 胶囊或输入框的分支 chip 打开)新增 管理远程仓库(Manage Remotes) 面板:列出仓库已配置的 remotes 及其 fetch/push URL、添加 remote、以及带两段式内联确认的删除。底层由三个新的 workspace 级 daemon 路由(列表 / 添加 / 删除)、对应的 SDK 客户端方法,以及一个直接读取 git 配置(git config --list --show-scope -z,NUL 分隔记录)的 core 辅助模块支撑——不解析 git remote -v,也不按名逐个 get-url。变更类接口的响应直接携带最新列表,面板一次往返即可重渲染;删除还会刷新分支列表与分支 chip 的跟踪状态,因为 git 会把 remote-tracking refs 和 upstream 配置随 remote 一起删掉。

为什么需要

弹层原本可以 pull、commit、push、切分支,但 remotes 本身只是只读上下文——远程跟踪分支按 remote 名分组展示,而用户无法改动这些 remote。从 zip 解压(没有 origin)、fork + upstream 三角工作流、或想删掉过期 remote 的用户,都必须离开 Web Shell 去开终端。本 PR 用最小可用集合(列表 / 添加 / 删除)补上这个缺口;改 URL、重命名、fetch/prune 不在范围内。

两个影响实现方式的细节:

  • **列表直接读配置,作用域精确。**一次 git config --list --show-scope -z 即得完整 section(url、pushurl、refspec、promisor/partial-clone),免疫 insteadOf 改写与渲染注解;作用域过滤只列仓库自有作用域(local + worktree,含 include)——也就是 git remote add/remove 实际作用的部分——继承的 global/system section 一律不进列表。Add 预检覆盖全部作用域,发现同名继承 section 一律 409 remote_shadows_inherited;删除后在 git 能解析的全部作用域复核 section,补删 git 写不了的 worktree 一半,清扫指过去分支的 upstream 键与无 refspec 删除留下的孤儿 refs/remotes/<name>/*,宁可 409 remote_still_configured 也不把一个 git 仍能解析的状态说成成功。
  • **显示净化、请求原样。**用户不是自己写的 .git/config(下载的 zip、克隆的仓库)可能在 remote 名或 URL 里带 bidi/零宽字符。面板在每一个渲染边界(行、tooltip、aria 标签、footer 消息、以及搜索过滤器——保证「看得见的就搜得到」)剥掉完整的 Unicode Default_Ignorable 字符集,而添加/删除请求始终携带配置中的原始名字——删除侧的校验刻意与 git 本身一样宽松,手改出来的配置也总能通过 UI 清理掉。只差空白字符的名字(CSS 会折叠掉)同样标记,tooltip 与 aria 标签里以码点转义写出原始名字。

评审测试计划

如何验证

对着一个 git workspace 启动 daemon,打开 Web Shell,在左侧栏展开该 workspace:

  1. 点击 workspace 的 git 胶囊 → 弹层打开,是熟悉的分支操作;Checkout Tag or Revision… 之后多了一行 Manage Remotes…(弹层搜索框也能搜到它)。
  2. 点进去 → 面板替换分支列表:每个 remote 显示名字和 fetch URL(悬停可在 push URL 不同时看到),底部是添加表单(名字 + URL),顶部有返回箭头。
  3. 添加 remote → footer 出现成功提示,行立即出现。再用同名添加一次 → footer 显示 git 自己的 remote … already exists 错误,列表不变。
  4. 删除 → 第一次点行尾垃圾桶图标进入红色 Confirm 待确认态;第二次点击才真正删除。返回分支视图:该 remote 的远程分支分组消失;如果当前分支原本跟踪它,chip 的 upstream/领先落后状态也已刷新。
  5. 竞态行为:面板开着时在终端里删掉某个 remote,再在面板里确认删除 → footer 显示 git 的 No such remote,面板会重新拉取列表,而不是留着一个永远删不掉的行。
  6. 面板内搜索会按名字/URL 过滤 remotes;无命中时显示 No remotes match the search(与 No remotes configured 区分)。进入/离开面板会清空搜索词——为了找到入口而输入的 "remotes" 不会把面板过滤成空。
  7. 不可见字符加固:手改配置(如 remote 名或 URL 含 U+202E/U+200F)时,行内渲染已剥掉这些字符,按显示的名字搜索仍能找到该行,删除仍然成功(请求携带原始配置名)。
  8. Partial clone:在 git clone --filter=blob:none 且设置了 push URL 覆盖的仓库里,面板显示真实的 fetch URL,tooltip 里是覆盖后的 push URL。
  9. 与兄弟分支路由一致的守卫:不受信 workspace → 403,workspace generation 已关闭 → 503 workspace_runtime_unavailable?cwd= 逃逸 workspace → 400,非 git 仓库 → 404 not_a_git_repository。错误响应体不含绝对路径。

可执行证据:packages/web-shell/client/e2e/web-shell.git-remotes.spec.ts 在 Chromium 里对着 mock daemon 端到端跑通第 1–6 条;daemon 层由路由套件(27 例)和 core 套件(55 例,含 partial-clone 与不可见字符形态)对着真实临时 git 仓库覆盖。

前后对比

**之前:**弹层操作列表到 Checkout Tag or Revision… 为止;remote 名只作为折叠的 Remote 分支分组标题只读出现,无法增删。

**之后:**同一个弹层多了 Manage Remotes… 入口,打开下方面板。截图来自 mock daemon 的 Playwright 运行(默认主题,1280×800):

1. 分支弹层中的新入口行 2. 远程面板:列表 + 添加表单
3. 填好的添加表单 4. footer 的添加成功提示
5. 两段式删除确认待确认态(第 3 行)

测试环境

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

运行环境(可选)

本地 daemon 构建 + Web Shell dev server;UI e2e 与上述截图用 Playwright(Chromium);core/路由用 vitest 对着真实临时仓库。当前 head 上本地门禁全绿:npm run buildnpm run typecheck、所有触及文件的 eslint --max-warnings 0、core 162/162、cli 路由 100/100(含兄弟分支路由套件)、SDK 417/417、web-shell 109/109、e2e 5/5。

风险与范围

  • 主要风险/取舍:所有 workspace git 路由(branches、pull、checkout、commit、remotes)共享的错误分类器新增了六个 remote 专用分支,排在全部遗留关键字分支之前,且只在行首(或 git 文档化的两行 lock 链)上、对未截断的完整输出匹配——配置里的 remote 名可以携带任何关键字,配置值可以携带真实换行,任何更深的行都可被攻击者构造。顺序与换行注入形态都有同文件分类表套件钉住,兄弟分支路由套件全绿。错误响应除了脱敏 workspace 路径与 git 根,还会脱敏 cwd 树之外的任何 gitdir——linked worktree 共享的主仓 .git、子模块 gitdir、符号链接规范化后的拼写——从 .git 文件解析、限长读取。第二点:删除是复核过的,不是听信的——git 自己的 remote remove 按序删除跟踪 refs、upstream 键与 section,面板看不到 git 完成的每一步(worktree 作用域的 section 与 upstream 键、无 refspec 留下的孤儿 refs、同名继承残留),要么在 git 写不了的作用域补删,要么以不含名字的 409 拒绝。
  • 未验证 / 范围外:无 set-url、重命名、fetch、prune;仅 scoped 路由(无 legacy /workspace/git/remote* 形态,与 GitHub-PRs 先例一致);撰写环境未跑 live-daemon 的 curl 验证(其 shell guard 禁止 git fixture 写操作)——daemon 层改由 supertest 路由测试对真实临时仓库验证,UI 由 Playwright spec 验证。Windows/Linux 依赖 CI。
  • 破坏性变更 / 迁移说明:无——路由、SDK 方法、i18n key、UI 全部是纯增量。

关联 Issue

无。

The workspace git popover could pull, commit, push and switch branches,
but the remotes themselves were read-only context: a user who clones from
a zip (no origin), works triangular (fork + upstream), or wants to drop a
stale remote had to leave the Web Shell for a terminal.

Adds a Manage Remotes panel inside the popover — list, add, remove —
backed by new workspace-scoped daemon routes and the SDK methods for
them. The listing reads git through its structured accessors rather than
the rendered "remote -v" output, which git annotates for partial-clone
remotes and which a line parser silently misreads. Mutations answer with
the fresh list so the panel re-renders in one round trip, and a remove
also refreshes the branch listing and the tracking state of the chip,
because git deletes the remote-tracking refs and upstream config together
with the remote.

Names and URLs are validated before git is spawned (flag injection,
refname rules for add; removal stays as lenient as git itself so a
hand-edited config can always be cleaned up), and rendered through a
display sanitizer that strips the invisible-character set — a git config
the user did not author can carry bidi marks that spoof the displayed
URL.

Design doc: docs/design/git-manage-remotes.md
@wenshao

wenshao commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

E2E test report

Environment: macOS (darwin), Node 22, local build (npm run build), git 2.50.1. UI e2e: Playwright Chromium against the mocked daemon; daemon/core layers: vitest + supertest against real temporary git repositories.

Web Shell UI e2e (Playwright)

  • web-shell.git-remotes.spec.ts3/3 passed: sidebar git pill → Manage Remotes panel → list renders name+URL; add flow (daemon request body {name, url} asserted, row appears, success footer); two-click remove (first click arms only — zero requests — second removes, request body {name} asserted); back button restores the branch list; duplicate add → mock 409 surfaced verbatim in the footer with the list unchanged; search filters by name and by URL substring.
  • web-shell.git-mode.spec.ts5/5 passed (regression: now shares the extracted git-workspace fixture).

Daemon routes (supertest + real temp repos) — 27/27

List with push-URL override (set-url --push); add → fresh list + on-disk git remote confirmation; duplicate add → 409 remote_already_exists; remove → fresh list; remove missing → 404 no_such_remote; non-repo → 404 not_a_git_repository; unknown workspace → 400 workspace_mismatch; escaping ?cwd= → 400 invalid_cwd; untrusted workspace → 403 (all three endpoints); closed generation → 503 workspace_runtime_unavailable (all three); name/URL validation tables → 400; absolute-path redaction asserted on every error body; classifier-collision cases (a remote named dirty-cache still classifies as remote_already_exists / no_such_remote, not dirty_working_tree). Sibling workspace-git-branches suite: 36/36 (shared classifier regression).

Core (real temp repos) — 55/55

Structured-accessor listing: sorted order, push-URL override, partial-clone promisor remote (the [blob:none] annotation shape that breaks git remote -v parsers), url-less config entry (git's name-as-URL fallback), 24-remote batched-lookup completeness (crosses the 8-wide concurrency bound). Predicate tables: add-name/add-URL/remove-name incl. the leniency divergence (a.lock add-rejected but removable) and invisible-character rejection (bidi overrides, LRM/RLM, soft hyphen, separators). Round-trips: add/remove, duplicate error, predicate-legal names (a@b, a+b, a#b, @) verified accepted by real git remote add (no 500-path divergence), hand-configured x.lock remote listed and removable.

SDK — 416/416 (2 new)

URL/method/body composition for workspaceGitRemotes / workspaceGitRemoteAdd / workspaceGitRemoteRemove, with and without cwd.

Web Shell unit — 228/228

BranchPickerPopover 66 (panel open/list, add success + daemon-failure footer, local dash guard, two-click remove with counted post-remove branch/status refreshes + onBranchChanged, filtered-to-empty vs configured-empty copy, load-failure rendering, bidi/RLM display stripping with raw-name removal and search-by-displayed-name, sticky stash-warning survival across the panel round trip, reopen reset + confirm disarm, action-query not carried into the panel filter) + WorkspaceSection + ChatEditor regressions.

Gates (on the merge-base tree, c4cf30aa2e)

npm run build exit 0 · npm run typecheck exit 0 · eslint --max-warnings 0 clean on every touched file · prettier --check clean.

Not run / boundaries

  • Live-daemon curl E2E was not run in the authoring environment (its agent shell guard blocks git init/git remote add fixture writes in temp dirs). The daemon layer is covered instead by the route suite above against real temp repos — same HTTP → core → git chain, minus a live socket.
  • Windows/Linux not tested locally; CI covers.
  • Review process: 14-agent finder fan-out + 5 reverse-audit rounds before submission; ~25 findings (2 Critical: a partial-clone parser break and a CI lint-gate failure; the rest Suggestions) all fixed in-tree, each with a witness test, before the branch was pushed.

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 6, 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

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

Copy link
Copy Markdown
Collaborator

Thanks for the PR — re-running the gate on the current head.

Template looks good ✓ — every required heading is present, and the bilingual body, the Before/After screenshots and the OS matrix are filled in rather than stubbed.

Problem: this is a feature, not a fix, so there is no reproduction to ask for. The gap it names is observable in the current code — the popover can pull, commit, push and switch branches, but remotes are read-only context, appearing only as group headers over remote-tracking branches. Anyone on a fork + upstream layout, or working from a zip clone with no origin, has to leave the Web Shell to change one. Real, if narrow, and squarely on an established surface.

The part that is theoretical hardening rather than an observed problem is the invisible-character and confusables work: no linked issue, no report of anyone actually spoofed by a homoglyph remote name. I have not treated that as a 1b blocker, because it arrived as review-round output (the R10/R11 commits) rather than as the PR's premise — but it is the right thing to question, and I come back to it under Approach.

Direction: aligned. Finishing the workspace git popover is a natural extension of it, the routes follow the existing workspace-scoped precedent instead of adding a legacy unscoped form, and everything is additive — no public contract, auth, sandbox or telemetry surface touched. CHANGELOG (claude-code): no direct reference — every remote hit there is about Remote Control sessions, not git remotes. Not a rejection; the area is clearly ours.

Size: core paths are touched (packages/core/src/utils/git-remotes.ts, git-branches.ts, packages/core/src/index.ts) and the change spans four packages, so the two-tier core gate applies. You authored it, which exempts it by the gate's own rule — recording the numbers for the thread rather than as a block:

Bucket Lines Files
Production logic 4,391 14
Generated UTS#39 table + its generator 6,834 2
Tests and e2e 8,956 15
Design docs 1,048 2

Production logic is ~8.8x the 500-line core threshold and ~4.4x the 1000-line large-PR advisory, so both fire. The maintainer-awareness escalation is self-satisfied — you are the maintainer — but it is what caps my Stage 3 confidence at 3/5 no matter how clean the review reads. The split advisory is informational only: I am not asking for a split at this point in the PR's life, and saying so would be noise after 16 rounds.

Approach: the feature itself is proportionate — three routes, matching SDK methods, one core helper reading git config -z rather than parsing git remote -v, and a panel that swaps into the existing popover. I sketched that shape before reading the diff and would have landed in the same place.

What is not proportionate is that 6,834 of the 21,229 lines — 32% of the whole diff — exist to flag remote names that look alike: a full UTS #39 confusables table, a generator for it, and a fixed-point skeleton fold that iterates up to 8 passes because confluence is not proven. Cut all of it and the list/add/remove panel still works end to end. That is the 80/20 question the gate exists to ask, and the honest answer is the feature survives the cut intact.

I am not raising that as a blocker, for two reasons: it was pulled in by review rounds rather than volunteered, and our own rule past ~5 rounds is Criticals only. But it is worth naming, because it is the clearest instance of what that rule exists to prevent — the diff has grown 6.6x since the review first measured it, and round 16 said so explicitly while asking a human to decide whether the shape is still right. That question is above my pay grade and I have not answered it.

Two smaller reuse notes, both non-blocking:

  • The repo now carries four separate invisible/bidi character classes — INVALID_REF_CHARS in core gitDirect, bidiControlPattern in the SDK daemon ui utils, INVISIBLE_RUN in the SDK peer label, and INVISIBLE_CHARS here. The new one is genuinely broader (property-derived \p{Default_Ignorable_Code_Point}, not a hand list), so it is not a straight duplicate. But the core write-gate and the web-shell display sanitizer are documented as needing to stay "in lockstep" with only a comment enforcing it — worth a thought on whether the display side can consume the core predicate instead.
  • handleRemoteAdd validates url.trim() but passes the untrimmed url to gitRemoteAdd. Not a security gap (trimming makes the leading-- exec guard stricter, not weaker), but a URL with surrounding whitespace gets stored with it.

Risk: no elevated risk signals — the 1e high-risk path screen matched nothing in the changed files. The genuinely risky surface is the one you flagged yourself: sendGitError/redactGitPaths in workspace-git-branches.ts is shared by every workspace git route, so the six new classifier arms and the widened redaction change the error contract for branches, pull, checkout and commit too. That is where I spent the review, and it is covered in Stage 2.

One description nit: test-plan item 9 lists ?cwd= escaping the workspace as 400. That holds for the two POST routes (resolveContainedCwdOrFail); the GET silently falls back to the workspace root via resolveContainedCwd. That matches the sibling branches routes exactly, so the code is right and the sentence is just over-broad.

Moving on to code review. 🔍

中文说明

感谢贡献——在当前 head 上重跑准入门禁。

模板完整 ✓——必需标题齐全,双语正文、前后对比截图和 OS 矩阵都是实填而非占位。

**问题:**这是 feature 而非 fix,因此不存在「要求复现」这一说。它指出的缺口在现有代码里可直接观察:弹层能 pull、commit、push、切分支,但 remotes 只是只读上下文,仅作为远程跟踪分支的分组标题出现。fork + upstream 布局的用户、或从 zip 解压(没有 origin)的用户,想改一个 remote 就必须离开 Web Shell。问题真实(虽然范围窄),且落在既有界面上。

真正属于「理论性加固」而非已观测问题的,是不可见字符与 confusables 那部分:没有关联 issue,也没有任何人真的被同形字 remote 名欺骗过的报告。我没有按 1b 阻断它,因为它是评审轮次的产物(R10/R11 提交),不是这个 PR 的立项前提——但它确实值得被质疑,我在「方案」里回到这一点。

**方向:**对齐。补齐 workspace git 弹层是自然延伸;路由沿用既有 workspace 级先例,没有新增 legacy 非 scoped 形态;全部是增量——未触及公开契约、鉴权、沙箱或遥测面。CHANGELOG(claude-code):无直接对应——那里所有 remote 命中都在讲 Remote Control 会话,不是 git remote。这不构成否决,该领域显然属于我们。

**规模:**触及了核心路径(packages/core/src/utils/git-remotes.tsgit-branches.tspackages/core/src/index.ts),且跨四个包,因此两层核心门禁适用。你是作者,按门禁自身规则豁免——把数字记录在此,不作为阻断:

分类 行数 文件数
生产逻辑 4,391 14
生成的 UTS#39 表 + 生成脚本 6,834 2
测试与 e2e 8,956 15
设计文档 1,048 2

生产逻辑约为 500 行核心阈值的 8.8 倍、1000 行大 PR 提示线的 4.4 倍,两条都触发。维护者知会式升级在你这里自我满足——你就是维护者——但正是它把我的 Stage 3 信心分封顶在 3/5,无论评审读起来多干净。拆分建议仅供参考:在 PR 走到这个阶段我不会要求拆分,第 16 轮之后再提就是噪音。

**方案:**特性本身是相称的——三个路由、对应的 SDK 方法、一个直接读 git config -z(而非解析 git remote -v)的 core 辅助模块,以及一个换入既有弹层的面板。我在看 diff 之前先自己勾了一版方案,落点相同。

不相称的是:21,229 行里有 6,834 行——整个 diff 的 32%——只是为了标记「长得像」的 remote 名:一张完整的 UTS #39 confusables 表、它的生成脚本,以及一个因为收敛性未被证明而最多迭代 8 轮的定点 skeleton 折叠。把这些全砍掉,列表/添加/删除面板依然端到端可用。这正是门禁该问的 80/20 问题,诚实的答案是:特性砍完仍然完整。

我没有把它作为阻断项提出,理由有二:它是被评审轮次拉进来的,不是主动加的;而我们自己在超过 ~5 轮之后的规则是只落 Critical。但它值得被点名,因为它是那条规则要防之事的最清晰样本——diff 自评审首次测量以来涨了 6.6 倍,第 16 轮明确说了这一点,并请人类判断这个改动的形态是否还合适。那个问题超出我的职权,我没有回答。

两条较小的复用提示,均非阻断:

  • 仓库现在有四套各自独立的不可见/bidi 字符类——core gitDirectINVALID_REF_CHARS、SDK daemon ui utils 的 bidiControlPattern、SDK peer label 的 INVISIBLE_RUN,以及这里的 INVISIBLE_CHARS。新的一套确实更宽(由属性派生的 \p{Default_Ignorable_Code_Point},不是手写清单),所以不是简单重复。但 core 写入闸门与 web-shell 显示净化被文档要求「保持同步」,而保证同步的只有一句注释——值得考虑显示侧能否直接消费 core 的判定。
  • handleRemoteAdd 校验的是 url.trim(),传给 gitRemoteAdd 的却是未 trim 的 url。不是安全问题(trim 让前导 - 的执行向量闸门更严而非更松),但带首尾空白的 URL 会被原样存下来。

**风险:**无升级风险信号——1e 高风险路径筛查在变更文件中没有命中。真正有风险的面是你自己点出的那个:workspace-git-branches.ts 里的 sendGitError/redactGitPaths 被所有 workspace git 路由共享,所以六个新分类分支和加宽的脱敏同时改变了 branches、pull、checkout、commit 的错误契约。评审时间主要花在这里,详见 Stage 2。

一处描述小疵:测试计划第 9 条把 ?cwd= 逃逸 workspace 写成 400。两个 POST 路由确实如此(resolveContainedCwdOrFail);GET 则通过 resolveContainedCwd 静默回退到 workspace 根。这与兄弟分支路由完全一致,所以代码是对的,只是那句话写宽了。

进入代码审查 🔍

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 4e7f989. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

ℹ️ No screenshot changed against the PR base — but this PR edits 3 render-shaping files:

  • packages/web-shell/client/components/BranchPickerPopover.module.css
  • packages/web-shell/client/components/BranchPickerPopover.tsx
  • packages/web-shell/client/i18n.tsx

Either the change has no visual effect (logic, plumbing, a state the scenarios never reach), or no scenario renders this UI — in which case the preview cannot see it, and an empty result is a coverage gap rather than a clean bill of health. To make it visible, add a scenario to packages/web-shell/client/e2e/visuals/screenshots.spec.ts that seeds whatever state the UI is gated on; it then appears here as a head-only (NEW) capture.

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

I sketched my own approach before reading the diff — three workspace-scoped routes, matching SDK methods, one core helper reading git config -z, a panel swapping into the existing popover — and the PR lands on that shape. Where it goes past my sketch is the removal path: I would have called git remote remove and returned the fresh list. The sweep, the sibling-worktree pass, the inherited-scope pre-flight and the re-verify are all responses to witnesses the review rounds produced, and each one has a named failure behind it. So the extra machinery in git-remotes.ts is earned. The confusables table is the part that is not, and I said so in Stage 1.

The two Criticals round 16 could not settle — I read both against this commit and can give you verdicts.

R15-1, remoteTrackingRefs foreign-root derivation (git-remotes.ts ~1283-1305): closed. The reported witness was a surviving remote fork with +refs/heads/*:refs/remotes/*/main — a dest whose only wildcard sits in the middle, which the old trailing-wildcard rule contributed no foreign root for. The current chain is: no wildcard → exact root; rest === '*' → empty root; trailing /* with no other star in the prefix → the prefix; everything else → empty root. */main takes that last arm, the empty root makes the filter reject every ref, and the sweep deletes nothing under refs/remotes/. The other mid-wildcard shapes fail closed too — o*n/* falls to the last arm because its prefix still contains a star. Meanwhile the ordinary origin/* default refspec still yields the narrow root, so a normal two-remote repo sweeps exactly as before; that is consistent with your note that the first version of this fix fail-closed every repo with a default-refspec sibling.

One consequence worth stating rather than burying: when a surviving remote has a flat or mid-wildcard dest, the sweep deletes nothing at all, so a refspec-less removal leaves its orphaned refs/remotes/<name> refs behind and still certifies. I agree with the polarity — orphans are cache, the section no longer resolves, and the re-verify counts the same suppressed set so it cannot turn into a false 409 — but "sweeps orphaned refs" reads as unconditional in the PR description. If the design doc's §1 does not already carry that caveat, it should.

R15-2, sweepSiblingWorktreeKeys (git-remotes.ts ~1465-1510): the reported write-escape is gated. The sweep now runs git rev-parse --git-common-dir in the sibling and compares it to this repository's own common dir — through pathSpellings, so realpath and spelling variants both match — before worktreeScopeRecords reads anything or the config --worktree --fixed-value --unset-all writes anything. A planted .git/worktrees/*/gitdir naming an unrelated repository yields a different common dir and is skipped, which retires the reproduced witness. The prunable-record skip and the own-worktree skip both sit before the probes, so a plain repo pays no extra spawn.

Residual I could not close, and am deliberately not calling Critical: the ownership probe itself spawns git with cwd inside a path taken from repo-controlled content, before ownership is established. rev-parse --git-common-dir is plumbing that should not read the index, so the config-triggered execution vectors (core.fsmonitor, hooks) should not fire — but that is my reading of git's dispatch, not a test I ran, and the whole point of R15-2 was that this path executes git in a directory the workspace chose. If you would rather retire it than argue it, the cheap forms are requiring the sibling to sit under a path the registry already trusts, or probing the admin dir's back-pointer instead of spawning.

The shared classifier — the blast radius you flagged yourself. All six new arms are matched ahead of every legacy keyword branch, on the full untruncated redacted detail, anchored to line 1 or git's documented two-line lock chain. I checked each for cross-route capture:

  • remote .+ already exists\.?\s*$. cannot cross a newline and $ is end-of-string without the m flag, so this needs the entire composed detail to be that one line. It cannot capture a multi-line push or pull failure.
  • The three plain-Error arms (remote still configured after removal, remote section lives in an included config file, remote already configured in an inherited scope) are exact full-string matches, so the "a remote NAMED after this text" spoof your comments worry about is genuinely closed — not a substring test.
  • No such remote: is not producible by any non-remote route.
  • The could not (remove config section | set 'remote\. | unset 'branch\.) arm and its two-line lock form do change behaviour for siblings: a branch.<x>.merge write failure from checkout or branch routes now answers 409 git_config_write_failed instead of falling through to generic classification. Additive and more precise, and no existing consumer switches on the new codes — but it is a real contract change for four routes that are not about remotes, which is exactly why the sibling suite staying green matters.
  • invalid refspec → 409 remote_config_unparsable is the one arm I would have scoped: a fetch or pull dying on a malformed configured refspec now answers with a remote-flavoured error code. The status is right and the message is git's own, so this is a naming wart, not a defect — but sendGitError already receives route, so the hint is available if you ever want it.

The widened redaction. Shape arms first for the in file family and the quoted-path family, then a whitespace-token-bounded fail-closed sweep for any surviving absolute path, with the documented limit that a space-bearing path can only be owned by a shape arm. That ordering is right, and running the sweep before the 512-char slice so the loose keyword branches classify on swept text is the correct call. Bounding the /etc/gitconfig arm per token rather than with an unbounded \S* prefix avoids O(L²) synchronous CPU on a single-threaded daemon — good catch on your part. gitExternalDirs adds sync statSync/realpathSync/openSync/readSync to the shared error path; the reads are head-capped and non-regular files are rejected before open, which retires the FIFO-wedge concern I would otherwise have raised. It is still sync I/O on every git route failure, bounded and error-path-only, so acceptable — noting it, not blocking it.

Smaller notes, none blocking:

  • handleRemoteAdd validates url.trim() but passes the untrimmed value through (also in Stage 1).
  • Design docs: the Chinese version is 182 lines against the English 866. Section structure is parallel — same six subsections plus Files affected, Scope boundaries, Security notes, Open questions — and the reciprocal links are in place, so it is not the "summary or a link to the other version" that docs/design/README.md forbids. But the depth gap is large and round 16's deferred list already carries an item saying the two Security notes sections do not agree. A translation gap is a Suggestion by our rules, not a Critical; flagging it because the README asks both versions to cover the full design.
  • Adding the generated table to the type-aware lint ignore, with the OOM reason and the regeneration command spelled out, is the right way to do that.

The removal path, which carries the sweep and the refusal polarity:

sequenceDiagram
    participant P1 as User
    participant P2 as Remotes panel
    participant P3 as DaemonClient SDK
    participant P4 as Remove route
    participant P5 as Core gitRemoteRemove
    participant P6 as git
    P1->>P2: click trash twice (arm then confirm)
    P2->>P3: workspaceGitRemoteRemove(cwd, name)
    P3->>P4: POST workspaces/:workspace/git/remote/remove
    P4->>P5: gitRemoteRemove(cwd, name, env)
    P5->>P6: pre-flight reads (config -z, remote, worktree list)
    P5->>P6: remote remove -- name
    P5->>P6: sweep owned tracking refs, then sibling worktree keys
    P5->>P6: re-verify the section across every scope
    alt section still resolves somewhere
        P5-->>P4: throw remote still configured after removal
        P4-->>P2: 409 remote_still_configured
    else clean
        P5-->>P4: fresh remote list
        P4-->>P2: 200 with v and remotes
    end
    P2->>P3: refresh branches and the chip tracking state
    P2-->>P1: re-render in one round trip
Loading
Files changed (28 of 33 shown)
File What changed
packages/core/src/utils/git-remotes.ts New core module. Config-scoped listing, add with an inherited-scope pre-flight, remove with ref sweep, sibling-worktree sweep and re-verification
packages/core/src/utils/git-remotes.test.ts Suite against real temp repos, including the partial-clone and invisible-character shapes
packages/core/src/utils/git-remotes-kill.test.ts Kill and signal paths, so a timed-out read cannot answer as a negative
packages/core/src/utils/git-branches.ts Small edits to the shared runGit and branch helpers
packages/core/src/index.ts Export the new module
packages/cli/src/serve/routes/workspace-git-remotes.ts New. Three workspace-scoped routes carrying the same guards as the sibling branch routes
packages/cli/src/serve/routes/workspace-git-branches.ts The shared change. Six new classifier arms plus much wider path redaction, both affecting every git route
packages/cli/src/serve/routes/workspace-git-branches.test.ts Classification table and redaction cases pinning the arm ordering
packages/cli/src/serve/routes/workspace-git-remotes.test.ts New. Route cases against real temp repos
packages/cli/src/serve/server.ts Register the new route group alongside its siblings
packages/web-shell/client/components/BranchPickerPopover.tsx The Manage Remotes row, the panel, the add form, two-click confirm, display sanitizing
packages/web-shell/client/components/BranchPickerPopover.test.tsx Component suite including the workspace-switch and race cases. One case is red on Windows
packages/web-shell/client/components/BranchPickerPopover.module.css Panel, row and confirm-state styles
packages/web-shell/client/utils/remote-name-skeleton.ts New. Fixed-point TR39 skeleton fold over the generated table
packages/web-shell/client/utils/remote-name-skeleton.test.ts New. Fold cases
packages/web-shell/client/utils/unicodeConfusables.ts New. Generated UTS#39 prototype table, 6,576 lines of literal data
packages/web-shell/scripts/generate-confusables.mjs New. Regenerates the table from Unicode confusables.txt
packages/web-shell/client/i18n.tsx New keys for the panel, footer and confirm states
packages/web-shell/client/e2e/web-shell.git-remotes.spec.ts New. Playwright flows 1 to 6 against the mocked daemon
packages/web-shell/client/e2e/utils/mockDaemon.ts Remote endpoints and fixtures for the mock daemon
packages/sdk-typescript/src/daemon/DaemonClient.ts Three client methods
packages/sdk-typescript/src/daemon/types.ts Request and response types plus the new error codes
packages/sdk-typescript/src/daemon/index.ts Export them
packages/sdk-typescript/test/unit/DaemonClient.test.ts Client-method cases
docs/design/git-manage-remotes.md New English design doc
docs/design/git-manage-remotes.zh-CN.md New Chinese design doc, structurally parallel but much shorter
eslint.config.js Exclude the generated table from type-aware lint, which OOMs on it
…and 5 more files The two e2e spec and screenshot-capture adjustments, the confusables table sanity test, the e2e git scenario helper, and the process-env-guard test allowance

Testing

This is an unattended CI run, so I built and ran nothing — no npm, no node, no checkout of your branch, per the skill's no-PR-code rule. Everything below is read from the check runs on the reviewed commit through the API.

Check Conclusion
Test (windows-latest, Node 22.x) failure
Capture web-shell visuals (ubuntu-latest, Node 22.x) success
Desktop Shell (ubuntu-22.04) success
Desktop Shell (windows-2022) success
Integration Tests (no-AK, No Sandbox) success
Lint & Static (ubuntu-latest, Node 22.x) success
Live Host (macos-latest) success
OpenTUI no-flicker gate success
Real daemon E2E / Java 11 success
Serve A/B (ubuntu-latest, Node 22.x) success
TUI parity snapshots (ink vs opentui) success
Test (macos-latest, Node 22.x) success
Test (ubuntu-latest, Node 22.x) success
macos-latest / Java 21 success
ubuntu-latest / Java 11 success
ubuntu-latest / Java 17 success
ubuntu-latest / Java 21 success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) success
windows-latest / Java 21 success

The PR's own CI is green. Every pull_request-event run on this head completed successfully and none is pending, so nothing is deferred on that account: Qwen Code CI, Serve A/B, Web-shell Visuals, tui-parity, SDK Java and Qwen Live Host CI all report success, and the lanes that matter here — Test (ubuntu-latest, Node 22.x), Test (macos-latest, Node 22.x), Lint & Static and web-shell E2E Smoke — are green with them.

One check is red: Test (windows-latest, Node 22.x), from the workflow_dispatch run 34743588047 attempt 2 — the lane that gives this PR its Windows coverage, since the PR-event run skips it. Most of that job is pre-existing Windows noise in packages this PR does not touch: src/diff-manager.test.ts failing six cases on \ versus / path separators, plus 37, 72 and 22 failures across the proactive monitor and channel suites. I am calling those pre-existing on the evidence that none of those files appear in your diff — not on anything the log body claims.

The web-shell suite failed exactly one case out of 7,753, and it is yours:

FAIL  components/BranchPickerPopover.test.tsx > BranchPickerPopover remotes view
      > resets the remotes view and restores no focus after a workspace switch
AssertionError: expected <input …(3)></input> to be <body><div>…(2)</div></body>
                // Object.is equality
 ❯ components/BranchPickerPopover.test.tsx:3764:36

Line 3764 is expect(document.activeElement).toBe(activeBefore). activeBefore was document.body, and by the time the in-flight removal settled after the workspace switch, focus was on the search <input class="_searchInput_88f27a" placeholder="Search for branches and actions">. So on Windows the settle effect moves focus to the search box — the one thing that test was written to forbid, and your own comment above it argues any movement at all is the bug. It is a single case, on a dispatch-only lane, and it may well be a jsdom-on-Windows focus artifact rather than a product defect. But it is red in your own suite on a platform your Tested-on table marks ⚠️ untested, and I cannot tell which of the two it is from here. That is the one thing I would want answered before approving.

Not verified, and why: I did not run the daemon, the Playwright spec, or any vitest suite — the skill forbids executing PR-derived code on this path. So the counts in your description (core 162, cli routes 100, SDK 417, web-shell 109, e2e 5) are your claim, not evidence I reproduced, and the behavioural claims in test-plan items 1 to 9 — the two-click confirm, the race re-read, the invisible-character rendering, the partial-clone push-URL tooltip — rest on the mocked-daemon Playwright spec and your local runs. Nothing here exercised a live daemon against a real repository in a browser.

Sandboxed verification would settle this: @qwen-code /verify — that the removal path's sweep, sibling-worktree pass and 409 refusal polarity hold against real git on the platforms CI covers is the load-bearing claim of this PR, and it is exactly what a mocked-daemon e2e cannot show. It would also settle whether the Windows focus red is product behaviour or a jsdom artifact, by A/B-ing the settle effect against the base build. You have write access, so /verify runs unsponsored. /tmux is the wrong lane here — this is a browser surface, not the TUI.

Real-scenario testing (2c): N/A — unattended CI run, so tmux driving is out of scope by rule; the CI signal above and the /verify lane are the substitutes.

中文说明

代码审查

我在读 diff 之前先自己勾了一版方案——三个 workspace 级路由、对应的 SDK 方法、一个读 git config -z 的 core 辅助模块、一个换入既有弹层的面板——这个 PR 的落点与我一致。超出我那版方案的是删除路径:我只会调一次 git remote remove 然后返回新列表。这里的 ref 清扫、兄弟 worktree 遍历、继承作用域预检和删后复核,每一条背后都有评审轮次产出的具体见证(witness),所以 git-remotes.ts 里的额外机制是挣来的。confusables 表不是,这一点我在 Stage 1 已经说了。

第 16 轮无法定论的两个 Critical——我对着当前 commit 都读了,可以给出结论。

**R15-1,remoteTrackingRefs 的 foreign-root 推导(git-remotes.ts 约 1283-1305 行):已闭合。**被报告的见证是存活 remote fork 配了 +refs/heads/*:refs/remotes/*/main——目标端唯一的通配符在中间,旧的「尾部通配符」规则不会为它贡献任何 foreign root。当前的分支链是:无通配符 → 精确 root;rest === '*' → 空 root;尾部 /* 且前缀不含其他星号 → 该前缀;其余一律 → 空 root*/main 落到最后一条,空 root 使过滤器拒绝所有 ref,refs/remotes/ 下一个都不删。其他中间通配符形态同样 fail closed——o*n/* 因为前缀仍含星号而落到最后一条。同时常规的 origin/* 默认 refspec 仍然得到窄 root,所以普通双 remote 仓库的清扫行为与之前完全一致;这也印证了你说的「该修复的第一版会在任何存在默认 refspec 兄弟 remote 的仓库里 fail-closed」。

有一个后果值得明说而不是埋着:当存活 remote 配了扁平(refs/remotes/*)或中间通配符的目标端时,清扫会一个都不删,于是无 refspec 的删除会留下孤儿 refs/remotes/<name> ref 却仍然认证成功。我认同这个极性——孤儿只是缓存,section 已不再可解析,而且复核读的是同一个被抑制的集合,因此不会变成假的 409——但 PR 描述里「清扫孤儿 ref」读起来是无条件的。如果设计文档 §1 还没写这个例外,应该补上。

**R15-2,sweepSiblingWorktreeKeysgit-remotes.ts 约 1465-1510 行):被报告的写入逃逸已加闸门。**清扫现在会先在兄弟目录里跑 git rev-parse --git-common-dir,与本仓库自己的 common dir 比对(经 pathSpellings,realpath 与拼写变体都能匹配),之后才会让 worktreeScopeRecords 读、让 config --worktree --fixed-value --unset-all 写。一个指向无关仓库的伪造 .git/worktrees/*/gitdir 会得到不同的 common dir 而被跳过,这就作废了原先那个已复现的见证。prunable 记录跳过与自身 worktree 跳过都排在探针之前,所以普通仓库不会多付一次 spawn。

没能闭合的残留,也刻意不按 Critical 报:所有权探针本身就会在一个取自仓库可控内容的路径里 spawn git,而此时所有权尚未确立。rev-parse --git-common-dir 是管道命令,按理不读 index,所以常见的配置触发执行向量(core.fsmonitor、hooks)不应被触发——但这是我对 git 命令分派的阅读,不是我跑出来的测试,而 R15-2 的全部要点恰恰是「这条路径会在 workspace 选定的目录里执行 git」。如果你宁愿把它消掉而不是论证它,便宜的做法有两种:要求兄弟路径位于 registry 已信任的目录下,或者改为探测 admin dir 的反向指针而不 spawn。

**共享分类器——你自己点出的爆炸半径。**六个新分支全部排在遗留关键字分支之前,对未截断的完整脱敏文本匹配,锚定在第 1 行(或 git 文档化的两行 lock 链)。我逐条查了跨路由误捕:

  • remote .+ already exists\.?\s*$——. 不跨行,$ 在无 m 标志时是串尾,所以要求整个拼装后的 detail 就是这一行。它不可能捕获多行的 push/pull 输出。
  • 三个 plain-Error 分支(remote still configured after removalremote section lives in an included config fileremote already configured in an inherited scope)是完整串精确匹配,所以你注释里担心的「remote 名就叫这段文本」的伪造确实被堵住了——不是子串测试。
  • No such remote: 不可能由任何非 remote 路由产生。
  • could not (remove config section | set 'remote\. | unset 'branch\.) 这一支及其两行 lock 形态确实改变了兄弟路由的行为:来自 checkout / branch 路由的 branch.<x>.merge 写入失败,现在会答 409 git_config_write_failed,而不再落到通用分类。这是增量的、更精确的,也没有既有消费方按新错误码分支——但它是对四个与 remote 无关的路由的真实契约变更,这也正是兄弟套件保持全绿为什么重要。
  • invalid refspec → 409 remote_config_unparsable 是我唯一会想收窄的一支:fetch 或 pull 因配置里 refspec 畸形而死,现在会答一个 remote 口味的错误码。状态码是对的、消息是 git 自己的,所以这是命名瑕疵而非缺陷——但 sendGitError 本来就收 route,需要时提示是现成的。

**加宽的脱敏。**先是对 in file 族与引号路径族的形态分支,再是对任何残留绝对路径做按空白 token 限界的 fail-closed 清扫,并明确写了「带空格的路径只能由形态分支负责」这一限制。这个顺序是对的;把清扫放在 512 字符切片之前、让松散关键字分支在已清扫文本上分类,也是正确的选择。/etc/gitconfig 那一支按 token 限界而不是用无界 \S* 前缀,避开了单线程 daemon 上的 O(L²) 同步 CPU——这一点你想到了。gitExternalDirs 给共享错误路径加了同步的 statSync/realpathSync/openSync/readSync;读取是限长的、非常规文件在 open 之前就被拒,这消掉了我本来要提的 FIFO 卡死顾虑。它仍然是每次 git 路由失败时的同步 I/O,但有限界且只在错误路径上,可以接受——记录,不阻断。

较小的提示,均不阻断:

  • handleRemoteAdd 校验 url.trim(),传下去的却是未 trim 的值(Stage 1 也提了)。
  • 设计文档:中文版 182 行,英文版 866 行。章节结构是平行的——同样六个子节,加上受影响文件、范围边界、安全说明、待决问题——互链也在,所以不属于 docs/design/README.md 禁止的「摘要或只给另一版链接」。但深度差得很多,而第 16 轮的 deferred 清单里已经有一条说两个语言版本的安全说明不一致。按我们的规则翻译落差是 Suggestion 而非 Critical;提出来是因为 README 要求两版都覆盖完整设计。
  • 把生成表加入类型感知 lint 的忽略列表,并写清 OOM 原因与重新生成命令,这个做法是对的。

(删除路径的时序图见上,未重复。)

测试

这是无人值守的 CI 运行,所以我什么都没构建、没跑——没有 npm、没有 node、没有 checkout 你的分支,遵守本 skill 的「不执行 PR 代码」规则。以下全部通过 API 读取被审 commit 上的 check run。

PR 自己的 CI 是绿的。该 head 上所有 pull_request 事件的 run 都已成功完成,且无 pending,因此不存在因 CI 未落而推迟的问题:Qwen Code CI、Serve A/B、Web-shell Visuals、tui-parity、SDK Java、Qwen Live Host CI 全部 success;与此相关的关键 lane——Test (ubuntu-latest, Node 22.x)Test (macos-latest, Node 22.x)Lint & Staticweb-shell E2E Smoke——同为绿。

一个 check 是红的:Test (windows-latest, Node 22.x),来自 workflow_dispatch run 34743588047 第 2 次尝试——也就是给这个 PR 提供 Windows 覆盖的 lane(PR 事件的 run 会跳过它)。该 job 里大部分是本 PR 未触及包中的既有 Windows 噪音:src/diff-manager.test.ts\/ 路径分隔符失败 6 例,另有 proactive monitor 与 channel 套件的 37、72、22 例失败。我判定它们为既有问题的依据是这些文件都不在你的 diff 里——不是依据日志正文的任何自述。

web-shell 套件 7,753 例中恰好失败 1 例,而它是你的:

FAIL  components/BranchPickerPopover.test.tsx > BranchPickerPopover remotes view
      > resets the remotes view and restores no focus after a workspace switch
AssertionError: expected <input …(3)></input> to be <body><div>…(2)</div></body>
                // Object.is equality
 ❯ components/BranchPickerPopover.test.tsx:3764:36

第 3764 行是 expect(document.activeElement).toBe(activeBefore)activeBefore 当时是 document.body,而在途的删除于 workspace 切换之后落地时,焦点在搜索框 <input class="_searchInput_88f27a" placeholder="Search for branches and actions"> 上。也就是说在 Windows 上,settle effect 把焦点移到了搜索框——正是那个测试被写来禁止的唯一行为,而它上方的注释也论证了「任何移动都是 bug」。只有一例,且在 dispatch-only 的 lane 上,很可能是 jsdom-on-Windows 的焦点伪影而非产品缺陷。但它是在你自己的套件里、在你 Tested-on 表标为 ⚠️ 未测的平台上红着的,而我从这里分不清是哪一种。这是我在批准之前唯一想先得到答案的事。

**未验证项及原因:**我没有跑 daemon、没有跑 Playwright spec、没有跑任何 vitest 套件——本路径禁止执行 PR 派生代码。所以你描述里的数字(core 162、cli 路由 100、SDK 417、web-shell 109、e2e 5)是你的声明,不是我复现出的证据;测试计划第 1–9 条的行为性主张——两段式确认、竞态重读、不可见字符渲染、partial-clone 的 push-URL tooltip——依赖 mock daemon 的 Playwright spec 和你的本地运行。这里没有任何环节在浏览器里对着真实仓库跑过真实 daemon。

沙箱化验证可以定这件事:@qwen-code /verify——删除路径的清扫、兄弟 worktree 遍历与 409 拒绝极性在 CI 覆盖的平台上对真实 git 成立,这是本 PR 的承重主张,而这恰恰是 mock daemon e2e 无法展示的。它也能定 Windows 焦点红是产品行为还是 jsdom 伪影:对着 base 构建 A/B 一次 settle effect 即可。你有写权限,所以 /verify 不需要 sponsor。/tmux 在这里不对口——这是浏览器界面,不是 TUI。

真实场景测试(2c):N/A——无人值守 CI 运行,按规则不做 tmux 驱动;上面的 CI 信号与 /verify lane 是其替代。

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — the code is better than I expected going in and I could settle both Criticals the last round could not, but the core-size escalation caps this at 3 by policy, and I have two real uncertainties that are not mine to resolve.

⏸️ Deferring to @qqqys — not approving, and not requesting changes either. You approved this commit at 14:12 while this run was in flight; that is your vote and it does not substitute for the gate's, and nothing I found below says your approval is wrong. It says what I could not settle, so you can decide with it in front of you. I have assigned the PR to you.

On the code. I went in expecting to argue about scope and came out mostly impressed. The removal path is the load-bearing part and it is the part I would have written wrong: I would have called git remote remove, returned the fresh list, and shipped a state where a worktree-scope section, a sibling's upstream key or an inherited same-name survivor still made the name resolve. Every extra step here has a named witness from an earlier round behind it, and the polarity is consistently fail-closed — refuse with a name-free 409 rather than certify something git would still resolve. I verified R15-1 and R15-2 against this commit and both hold (Stage 2 has the reasoning). The classifier arms are anchored tightly enough that I could not construct a cross-route capture except the cosmetic invalid refspec naming wart.

I also could not find a materially simpler path that solves the same problem, which is the test I actually apply. My independent sketch matched the feature and was simpler only because it was less correct.

The exception is the confusables table, and I want to be precise about what I am and am not saying. I am not saying it is wrong — a homoglyph remote name in a .git/config you did not author is a real shape, and the display/request split (sanitize what you render, send what is configured) is the right one. I am saying that 6,834 of 21,229 lines exist to serve it, that the panel works without them, and that they arrived by review-round accretion rather than by design. Round 16 measured the diff at 6.6x what it first reviewed and asked a human whether the shape is still right. That is a legitimate question and I am not equipped to answer it — I have no view on whether this project wants UTS #39 homograph detection in its git UI, and our own rule about letting review rounds balloon a PR says somebody should have asked at round 5.

Why I am deferring rather than approving:

  1. A red test in your own suite that I cannot classify. Test (windows-latest, Node 22.x) fails one web-shell case out of 7,753 — the workspace-switch focus assertion at BranchPickerPopover.test.tsx:3764, where focus lands on the search input instead of staying on document.body. It is either a jsdom-on-Windows artifact or a real focus bug in the settle effect, and the whole test exists to forbid the behaviour. Your Tested-on table marks Windows ⚠️. The rest of that job is pre-existing noise in packages you do not touch; this one is not.
  2. Round 16 still stands as CHANGES_REQUESTED, with 95 findings carrying [unverified] because its verification loop never launched. I settled the two Criticals it could not, by reading — but I am one reader, I ran nothing, and "the reviewer could not verify" is not the same as "verified".
  3. Stage 0 escalation caps the score. 4,391 production logic lines across core paths and four packages is ~8.8x the core threshold. You authored it, so the gate exempts it and this is not a block — but the cap is mechanical and it is why this is 3/5 and not 4.

What I would do in your position: answer the Windows focus question (or delete the assertion with a reason, if it is a jsdom artifact — it is your test and your call), then land it. The deferred Suggestions from rounds 11 to 16, the zh-CN depth gap, the url.trim() asymmetry and the invalid refspec code naming all belong in a follow-up, not in round 17. Per our own rule past five rounds, Criticals only — and I found none that still stand.

I did not add another review. The PR already carries a gating CHANGES_REQUESTED and a human approval on this exact commit; a third bot voice saying "defer" in review form would only add noise.

中文说明

信心分:3/5——代码比我进来时预期的好,上一轮定不了的两个 Critical 我都能给出结论,但核心规模触发的升级按策略把分数封顶在 3,而且我还有两个不属于我职权范围的真实不确定项。

⏸️ **转交 @qqqys——既不批准,也不请求变更。**你在这次运行进行中、于 14:12 批准了这个 commit;那是你的一票,不能替代门禁的一票,而我下面所发现的内容也不说明你的批准是错的。它说明的是我没能定下来的部分,好让你在这些信息面前做决定。我已把 PR 指派给你。

**关于代码。**我本来准备就范围问题争论,结果大部分是被说服的。删除路径是承重部分,也正是我会写错的部分:我只会调一次 git remote remove、返回新列表,然后交付一个 worktree 作用域 section、兄弟分支的 upstream 键、或同名继承残留仍能让该名字可解析的状态。这里每一个额外步骤背后都有前几轮产出的具体见证,而极性一致地 fail closed——宁可给一个不含名字的 409 拒绝,也不把一个 git 仍能解析的状态说成成功。我对着当前 commit 验证了 R15-1 与 R15-2,两者都成立(推理见 Stage 2)。分类器各分支锚定得足够紧,除了 invalid refspec 那个纯属命名瑕疵的问题,我构造不出跨路由误捕。

我也没能找到一条能解决同样问题的、显著更简的路径——这才是我实际使用的判据。我独立勾的方案在特性部分与之一致,之所以更简,只是因为它更不正确。

例外是 confusables 表,我想把「在说什么」和「不在说什么」分清楚。我不是说它错——一个你没亲手写的 .git/config 里出现同形字 remote 名是真实形态,而「显示净化 / 请求原样」的切分(渲染时净化、请求时发配置原值)也是对的。我说的是:21,229 行里有 6,834 行是为它存在的,面板没有它们照样工作,而且它们是评审轮次累积进来的,不是设计进来的。第 16 轮测得 diff 是它首次评审时的 6.6 倍,并请人类判断这个形态是否还合适。这是个正当的问题,而我没有资格回答——我对「这个项目是否想在它的 git 界面里做 UTS #39 同形字检测」没有立场,而我们自己那条关于别让评审轮次把 PR 撑大的规则说,本来应该有人在第 5 轮就问。

为什么是转交而不是批准:

  1. 你自己套件里有一个我无法归类的红。Test (windows-latest, Node 22.x) 在 web-shell 的 7,753 例里失败 1 例——BranchPickerPopover.test.tsx:3764 的 workspace 切换焦点断言,焦点落到了搜索框而不是留在 document.body。它要么是 jsdom-on-Windows 的伪影,要么是 settle effect 里真实的焦点 bug,而这个测试存在的全部意义就是禁止该行为。你的 Tested-on 表把 Windows 标为 ⚠️。那个 job 的其余失败是你未触及包中的既有噪音,这一个不是。
  2. 第 16 轮仍以 CHANGES_REQUESTED 立着,95 条发现带着 [unverified],因为它的验证环节从未启动。我通过阅读定下了它定不了的两个 Critical——但我只是一个阅读者,我什么都没跑,而「评审者无法验证」不等于「已验证」。
  3. **Stage 0 升级封顶了分数。**4,391 行生产逻辑跨核心路径与四个包,约为核心阈值的 8.8 倍。你是作者,所以门禁豁免、这不构成阻断——但封顶是机械的,这也是为什么是 3/5 而不是 4。

如果我在你的位置:先把 Windows 焦点这个问题回答掉(如果确认是 jsdom 伪影,也可以带理由删掉那条断言——那是你的测试、你的决定),然后合了它。第 11 到 16 轮 deferred 的那些 Suggestion、中文文档的深度差、url.trim() 的不对称、invalid refspec 的错误码命名,全都应该进后续 issue 或 PR,而不是进第 17 轮。按我们自己超过五轮之后的规则,只落 Critical——而我没有找到仍然成立的 Critical。

我没有再提交一条评审。这个 PR 已经带着一条起门禁作用的 CHANGES_REQUESTED 和一条针对当前 commit 的人类批准;第三个机器人声音以评审形式说「转交」只会增加噪音。

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 4e7f989, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 12 scenario(s).

Qwen Code · serve A/B

@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.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • DaemonGitRemotesResult.available is declared, always true, and read by no consumer — already reported (triage stage-2 comment 5557805467)

Not explored to full depth (tool budget reached): "agent 1c": none — every check above ran to completion..

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

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未探索到全部深度(达到工具调用预算):"agent 1c"none — every check above ran to completion.

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

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

Comment thread packages/cli/src/serve/routes/workspace-git-branches.ts Outdated
Comment thread packages/core/src/utils/git-remotes.ts
Comment thread packages/core/src/utils/git-remotes.ts
Comment thread packages/core/src/utils/git-remotes.ts Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.module.css
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx
The config-scope read, removal verification and client refusal handling
carried gaps the reverse audits surfaced: include.path/worktree scopes were
under-listed yet un-removable (destructive dead ends), GIT_ALLOW_PROTOCOL
was deleted outright (a fail-open swap against config-file policy), the
classifier let echoed names/URLs steal config-write failures, and several
guards shipped without witnesses. Fixes:

- list the repository-owned scope via `config --list --show-scope -z`
  (local+worktree), completing worktree-scope removals git cannot edit
- normalize an inherited GIT_ALLOW_PROTOCOL (strip ext/fd) instead of
  deleting it; keep deny-by-default force
- order sendGitError's anchored config-write shapes first; add
  remote_config_unparsable for unparsable configured refspecs
- follow git's boolean/integer grammar exactly (valueless/empty/hex/kmg)
- keep the URL visible under row shrink; restore focus after in-flight
  mutations; disarm confirms on refusal; silent re-reads stay silent
- witness every guard above in unit/e2e suites; sync the design doc

@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.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": running packages/cli/src/serve/routes/workspace-git-remotes.test.ts with the runtime.env.effectiveEnv argument dropped, to observe the whole suite stay gree…; "agent reverse-audit (round 1)": an executed probe of the timed-out-config-read path (hanging git config --list under GIT_TIMEOUT_MS to capture the real 512-char message body) — the chain…; "agent reverse-audit (round 1)": the design doc's round-1 (unchanged) sections outside this delta's hunks, and its per-test coverage claims for git-remotes.test.ts beyond the three I confirme…; chunk 17: running the Playwright suite to measure the geometry/overflow assertions in web-shell.git-remotes.spec.ts tests 4 and 5 (dev server + browser run); reasoned f…; "agent reverse-audit (round 2)": did not execute packages/web-shell/client/e2e/web-shell.git-remotes.spec.ts in a browser — the layout, sticky and focus-clearance assertions are verified agains….

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

Not reviewed: "agent verify (round 2)" — pointed at diff lines it never opened: it made tool calls, but none of them read the diff.

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/serve/routes/workspace-git-remotes.test.ts:24 — [probe] nothing pins { strict: true } on the two git-config write routes
  • packages/web-shell/client/components/BranchPickerPopover.test.tsx:1837 — [review] two-click confirm never exercised across two rows
  • packages/web-shell/client/components/BranchPickerPopover.tsx:504 — [probe] reopen commits the previous session's (or another worktree's) remotes panel
  • packages/web-shell/client/components/BranchPickerPopover.tsx:1076 — [probe] search filter survives a mutation, hiding the remote just added
  • packages/web-shell/client/components/BranchPickerPopover.tsx:91 — [probe] display sanitizer fuses git's multi-line error diagnostics

[Critical] R2-2: [certifies-falsely] [new-surface] gitIntegerValue mirrors neither git's leading-whitespace acceptance nor its INT_MAX bound, so promisor is reported false for a config value git reads as true — suppressing the partial-clone warning badge for exactly the remote it exists for — and true for a value git refuses at read time. Measured on git 2.43.0: git config remote.origin.promisor ' 1' then git config --bool --get remote.origin.promisor exits 0 with true (same for '\t1', ' 0x1', ' 1k'; only TRAILING padding dies), while the regex at git-remotes.ts:140 has no leading-whitespace arm, so gitIntegerValue(' 1') returns null and fetchGitRemotes reports promisor: false; BranchPickerPopover.tsx:1792 gates the badge on r.promisor || r.partialCloneFilter, so a promisor remote with no partialclonefilter key renders with no badge. The bound diverges the other way: git refuses |valuefactor| > 2147483647 (2048m, 2147483648, -2g all fatal) while unbounded JS arithmetic reports promisor: true, certifying a partial clone for a config every git command fatals on. Witness: git 2.43.0 — stored " 1" -> git config --bool --get exit 0 'true'; stored "1 " -> exit 128 'fatal: bad boolean config value'; 2048m/2147483648/-2g -> fatal while the copied regex plus parseInt returns true; --list --show-scope -z byte-dumped with od -c showing the padded value verbatim. Fix: mirror strtoumax's isspace() skip and git's bound — /^\s([+-]?)(0x[0-9a-f]+|0[0-7]|[1-9][0-9])([kmg]?)$/ keeping the $ anchor so trailing padding still fails as git does, and return null when |value*factor| exceeds 2147483647. (Unanchorable: the quoted constant does not appear in any hunk of git-remotes.ts at the reviewed commit, so this blocker's only copy is here.) The fix must not violate git's asymmetry, already pinned by the row [' 1 ', false] at git-remotes.test.ts:597, nor move the value.toLowerCase() at git-remotes.ts:161 that makes '0X1f'/'1G' work. The test that must go red without this fix: new rows in the promisor it.each at git-remotes.test.ts:581 — [' 1', true], ['\t1', true], [' 1k', true], ['2048m', false], ['-2g', false] — with ['1 ', false] as the counter-mutation that keeps a blanket value.trim() from passing.

中文说明

仅完成部分审查,审查缺口已披露。

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)"running packages/cli/src/serve/routes/workspace-git-remotes.test.ts with the runtime.env.effectiveEnv argument dropped, to observe the whole suite stay gree…"agent reverse-audit (round 1)"an executed probe of the timed-out-config-read path (hanging git config --list under GIT_TIMEOUT_MS to capture the real 512-char message body) — the chain…"agent reverse-audit (round 1)"the design doc's round-1 (unchanged) sections outside this delta's hunks, and its per-test coverage claims for git-remotes.test.ts beyond the three I confirme…;chunk 17:running the Playwright suite to measure the geometry/overflow assertions in web-shell.git-remotes.spec.ts tests 4 and 5 (dev server + browser run); reasoned f…"agent reverse-audit (round 2)"did not execute packages/web-shell/client/e2e/web-shell.git-remotes.spec.ts in a browser — the layout, sticky and focus-clearance assertions are verified agains…

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

未审查:"agent verify (round 2)"——启动 prompt 为它指定了 diff 中的行,但它从未打开:有工具调用,却没有一次读取 diff。

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 5 条(原文未翻译,列表见上方英文部分)。

[Critical] R2-2: [certifies-falsely] [new-surface] gitIntegerValue mirrors neither git's leading-whitespace acceptance nor its INT_MAX bound, so promisor is reported false for a config value git reads as true — suppressing the partial-clone warning badge for exactly the remote it exists for — and true for a value git refuses at read time. Measured on git 2.43.0: git config remote.origin.promisor ' 1' then git config --bool --get remote.origin.promisor exits 0 with true (same for '\t1', ' 0x1', ' 1k'; only TRAILING padding dies), while the regex at git-remotes.ts:140 has no leading-whitespace arm, so gitIntegerValue(' 1') returns null and fetchGitRemotes reports promisor: false; BranchPickerPopover.tsx:1792 gates the badge on r.promisor || r.partialCloneFilter, so a promisor remote with no partialclonefilter key renders with no badge. The bound diverges the other way: git refuses |valuefactor| > 2147483647 (2048m, 2147483648, -2g all fatal) while unbounded JS arithmetic reports promisor: true, certifying a partial clone for a config every git command fatals on. Witness: git 2.43.0 — stored " 1" -> git config --bool --get exit 0 'true'; stored "1 " -> exit 128 'fatal: bad boolean config value'; 2048m/2147483648/-2g -> fatal while the copied regex plus parseInt returns true; --list --show-scope -z byte-dumped with od -c showing the padded value verbatim. Fix: mirror strtoumax's isspace() skip and git's bound — /^\s([+-]?)(0x[0-9a-f]+|0[0-7]|[1-9][0-9])([kmg]?)$/ keeping the $ anchor so trailing padding still fails as git does, and return null when |value*factor| exceeds 2147483647. (Unanchorable: the quoted constant does not appear in any hunk of git-remotes.ts at the reviewed commit, so this blocker's only copy is here.) The fix must not violate git's asymmetry, already pinned by the row [' 1 ', false] at git-remotes.test.ts:597, nor move the value.toLowerCase() at git-remotes.ts:161 that makes '0X1f'/'1G' work. The test that must go red without this fix: new rows in the promisor it.each at git-remotes.test.ts:581 — [' 1', true], ['\t1', true], [' 1k', true], ['2048m', false], ['-2g', false] — with ['1 ', false] as the counter-mutation that keeps a blanket value.trim() from passing.

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

Comment thread packages/core/src/utils/git-remotes.ts
Comment thread packages/cli/src/serve/routes/workspace-git-branches.ts Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx
Comment thread packages/cli/src/serve/routes/workspace-git-branches.test.ts
Comment thread packages/core/src/utils/git-remotes.ts Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx

@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.

Not explored to full depth (tool budget reached): chunk 5: none — all planned checks completed (17 of ~33 tool calls).; chunk 12: none — I did not run vitest on BranchPickerPopover.test.tsx locally, relying instead on the passing CI Test check for that file.; "agent reverse-audit (round 1)": none — but for completeness, two checks I chose not to run rather than was cut short on: whether git can store a literal NUL byte inside a config *value* (which….

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

Deferred under the convergence posture (round 3, not a blocker) — recorded, not requested in this round:

  • docs/design/git-manage-remotes.md:136 — [review] The doc justifies the transport-helper write gate with…
  • docs/design/git-manage-remotes.md:138 — [review] The design of record declares the config-file protocol…
  • docs/design/git-manage-remotes.md:171 — [review] The specified available field is a constant — the only…
  • packages/cli/src/serve/routes/workspace-git-branches.ts:109 — [review] The new /invalid refspec/i branch is inserted into the…
  • packages/cli/src/serve/routes/workspace-git-branches.ts:144 — [review] ROOT-CAUSE FAMILY (three verifier incidentals merged at…
  • packages/cli/src/serve/routes/workspace-git-remotes.test.ts:99 — [review] Nothing in the repo pins that the two new destructive POST…
  • packages/cli/src/serve/routes/workspace-git-remotes.test.ts:156 — [review] The generation test closes the guard *before* the request,…
  • packages/cli/src/serve/routes/workspace-git-remotes.test.ts:227 — [review] The add path pins its wrong-typed guards ( { name: 42 } …
  • packages/cli/src/serve/routes/workspace-git-remotes.test.ts:260 — [review] Nothing in this suite distinguishes the resolved workspace…
  • packages/cli/src/serve/routes/workspace-git-remotes.test.ts:366 — [review] The suite's only wedge covers the *config* lock; the same…
  • packages/cli/src/serve/routes/workspace-git-remotes.test.ts:461 — [review] All three path-redaction assertions in the new real-repo…
  • packages/cli/src/serve/routes/workspace-git-remotes.ts:66 — [review] The add route accepts a url of unbounded length and…
  • packages/core/src/utils/git-branches.test.ts:244 — [review] The new gitEnv test table pins only https, ssh, ext, fd…
  • packages/core/src/utils/git-branches.ts:122 — [review] The PR body's 'Breaking changes / migration notes: none —…
  • packages/core/src/utils/git-remotes-kill.test.ts:38 — [review] Two of isNoMatchConfigError 's five conjuncts — typeof…
  • packages/core/src/utils/git-remotes.test.ts:247 — [review] The one test that configures remote.<name>.promisor and…
  • packages/core/src/utils/git-remotes.test.ts:257 — [review] The only test that drives customRefspec true leaves…
  • packages/core/src/utils/git-remotes.test.ts:358 — [review] The listing deliberately survives an unparseable fetch…
  • packages/core/src/utils/git-remotes.test.ts:588 — [review] The promisor integer-grammar table cannot reach any value…
  • packages/core/src/utils/git-remotes.test.ts:794 — [review] The || scopes.has('local') half of the…
  • …and 31 more (see the run report)

Convergence: round 3 posted 7 inline comment(s), 3 of them reported for the first time; the previous round posted 7 (6 new). Findings keep coming back to the same files: packages/core/src/utils/git-remotes.ts (findings in rounds 1, 2; 3 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

中文说明

仅完成部分审查,审查缺口已披露。

未探索到全部深度(达到工具调用预算):chunk 5:none — all planned checks completed (17 of ~33 tool calls).;chunk 12:none — I did not run vitest on BranchPickerPopover.test.tsx locally, relying instead on the passing CI Test check for that file."agent reverse-audit (round 1)"none — but for completeness, two checks I chose not to run rather than was cut short on: whether git can store a literal NUL byte inside a config *value* (which…

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

收敛姿态下延后(第 3 轮,非阻断)——已记录,本轮不要求修改:共 51 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 3 轮发布了 7 条行内评论,其中 3 条是首次提出;上一轮发布了 7 条(其中 6 条首次提出)。发现反复回到同一批文件:packages/core/src/utils/git-remotes.ts(第 1、2 轮已出过发现,本轮又有 3 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

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

Comment thread packages/core/src/utils/git-remotes.ts Outdated
Comment thread packages/core/src/utils/git-remotes.ts
Comment thread packages/core/src/utils/git-remotes.ts Outdated
Comment thread packages/core/src/utils/git-remotes.ts
Comment thread packages/core/src/utils/git-remotes.ts
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx
@wenshao

wenshao commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover from 4

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Sep 7, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. This window's round counter starts at 4 (the rounds this PR spent in review before takeover), so the Critical-only brake engages after 1 more change-producing round(s) instead of a full fresh 5. Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本窗口轮次计数从 4 起算(即本 PR 托管前已进行的评审轮数),因此再经过 1 个产生改动的轮次即进入 Critical-only,而非重新计满 5 轮。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@qwen-code-dev-bot

qwen-code-dev-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

⚠️ AutoFix round 8 ended without publishing a reportview run.

中文说明

⚠️ AutoFix 第 8 轮结束但未发布报告 —— 查看运行

Seven Critical threads from the review bot's second batch, all fixed with
witnesses and a ten-round reverse audit on top:

- add pre-flights all config scopes and refuses a same-name inherited
  section (remote_shadows_inherited), after probing repository-ness;
  removal verifies resolution across every scope so an inherited survivor
  can never be reported as removed (fail-closed on killed reads)
- sendGitError shapes match git's message start only (line 1, or git's
  two-line lock chain): config-chosen names/URLs can carry any keyword,
  and config values can carry real newlines, so deeper line-initial text
  is attacker-controllable; classification reads the full redacted detail
  while the client message stays bounded
- killed config reads rethrow with the all-scope stdout dump stripped
  (stderr diagnostics preserved) on every read path
- focus restores: add submit button, remove-row button when focused,
  back-button fallback; restores are gated on lent focus, cleared on view
  exit and workspace switch; refused removal awaits the silent re-read so
  the restore lands on the converged list
- no_such_remote remove refusals also refresh branches and status

@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.

Not reviewed: build-and-test — the new Playwright spec packages/web-shell/client/e2e/web-shell.git-remotes.spec.ts is not run by any PR-gated CI job (e2e.yml has no pull_request trigger, and web_shell_e2e_smoke selects only @smoke-tagged tests while none of the five new tests carry the tag) and was not executed in this review.

Not reviewed: build-and-test — the test-efficacy probe measured nothing: its positive control never ran (the probe tree has no built dist/, so this repository’s vitest globalSetup guard stopped all 12 probes before collection), harnessValidated is null, and no mutant or hunk was probed, so this run says nothing about whether individual hunks are gated by a test.

Not reviewed: issue-fidelity — the closing-issue set could not be checked (the installed gh cannot resolve closingIssuesReferences; gh >= 2.72.0 required), so it is UNKNOWN rather than verified-empty; the PR body’s Linked Issues reads None and fidelity was judged against the description and the committed design doc.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": the BranchPickerPopover.test.tsx cell of the doc's test table — I did not open the test file, so "focus restore to the manage row on exit with a search-box fall…; "agent reverse-audit (round 1)": the workspace-git-remotes.test.ts and git-remotes.test.ts table cells beyond the two I grepped ("helper-URL rejection before spawn" at :490 and "stores the trim…; "agent reverse-audit (round 2)": the url.insteadOf connect-time probe hung inside the ext helper and hit the shell timeout before printing, so that layer's evidence is the hang itself rathe….

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

Convergence: round 4 posted 57 inline comment(s), 54 of them reported for the first time; the previous round posted 7 (3 new). Findings keep coming back to the same files: packages/web-shell/client/components/BranchPickerPopover.tsx (findings in round 2; 19 more now); packages/core/src/utils/git-remotes.ts (findings in rounds 1, 2, 3; 4 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push, or dropping this PR's reviews to --severity-floor critical, keeps the loop from re-deriving the same set. (Observation only — nothing was withheld from this review because of this observation.)

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

[Critical] R4-3: [certifies-falsely] [new-surface] The transport-helper write gate requires a leading alphanumeric, so git's empty-transport-name helper form ::<args> passes isValidRemoteUrl and is written into .git/config, even though git routes that URL through — A client calls the add-remote route with name evil, url ::sh -c id. isValidRemoteUrl('::sh -c id') returns true (non-empty, no leading -, EXECUTING_HELPER_URL needs one [A-Za-z0-9] before ::, no control/invisible chars), so runGit(cwd, ['remote','add','--',name,trimmedUrl]) runs — verified: git remote add -- evil '::sh -c id' exits 0 and writes url = ::sh -c id. Any later connect on that remote (panel pull/fetch, or the user's own git fetch evil in a terminal) takes git's helper path: GIT_TRACE=1 git ls-remote evil shows run_command: GIT_DIR=.git git remote- evil 'sh -c id' then git.c:749 trace: exec: git-remote- evil 'sh -c id' — git looks up the executable git-remote- in its exec path and then on PATH, passing the stored payload as argv, with no protocol.*.allow prompt or denial in a default environment. Unconditionally the planted remote is permanently broken (git: 'remote-' is not a git command); if any directory on the daemon's or user's PATH holds an executable named git-remote-, the stored argument string executes with it — persistence that outlives the session. The gate's own comment says "the write path rejects the form", and the existing table rejects unknown helper names (gcrypt::myrepo, 9p::ssh://host/repo) on precisely the rationale that "an installed git-remote-<name> runs at connect time under the default policy"; git-remote- is looked up the same way and is admitted. The fix must not violate this existing fact: the same table pins ['ssh://git@[::1]/repo.git', true] and ['ssh://git@[2001:db8::1]:22/o/r.git', true] (packages/core/src/utils/git-remotes.test.ts:134-135) — the widened pattern must keep accepting bracketed IPv6 literals, so :: must stay anchored at position 0 rather than matched anywhere in the URL. Please pin this with a test: packages/core/src/utils/git-remotes.test.ts — add rows to the isValidRemoteUrl it.each table (lines 130–166): ['::sh -c id', false] and ['::0', false]. Both go red against the current pattern (true !== false); reverting the pattern to /^[A-Za-z0-9][A-Za-z0-9_.+-]*::/ turns them red again, which is the mutation proof.

[Critical] R3-1: [certifies-falsely] [new-surface] Still stands. fetchGitRemotes receives git's -z byte stream as a lossily UTF-8-decoded JS string, so any config byte that is not valid UTF-8 becomes U+FFFD in the listed name and the row the panel renders can never be removed. — At HEAD the transport is unchanged: runGit calls execFileAsync (git-branches.ts:135-146) with no encoding option, so Node decodes git's NUL-framed byte stream as UTF-8 and replaces every invalid byte with U+FFFD. fetchGitRemotes then does raw.split('\0') over that decoded string and returns the lossy name to the client, which sends it back verbatim as the removal target. A .git/config the user did not author - a downloaded zip, a cloned repo, a hand-edited file - can hold a remote subsection name with a non-UTF-8 byte; the panel renders a row whose name no longer equals the name git holds, so gitRemoteRemove's git remote remove -- <lossy name> answers 'No such remote' and the row can never be removed through the UI. The -z framing exists precisely to make names unambiguous at the byte level, and the decode throws that away before the parser sees it. Please pin this with a test: A case in packages/core/src/utils/git-remotes.test.ts that writes a remote subsection name containing a non-UTF-8 byte directly into .git/config, asserts the listing either surfaces it removably or refuses it explicitly, and asserts the removal round trip succeeds - it goes red against the lossy decode.

中文说明

仅完成部分审查,审查缺口已披露。

未审查(原文为英文):build-and-test — the new Playwright spec packages/web-shell/client/e2e/web-shell.git-remotes.spec.ts is not run by any PR-gated CI job (e2e.yml has no pull_request trigger, and web_shell_e2e_smoke selects only @smoke-tagged tests while none of the five new tests carry the tag) and was not executed in this review.

未审查(原文为英文):build-and-test — the test-efficacy probe measured nothing: its positive control never ran (the probe tree has no built dist/, so this repository’s vitest globalSetup guard stopped all 12 probes before collection), harnessValidated is null, and no mutant or hunk was probed, so this run says nothing about whether individual hunks are gated by a test.

未审查(原文为英文):issue-fidelity — the closing-issue set could not be checked (the installed gh cannot resolve closingIssuesReferences; gh >= 2.72.0 required), so it is UNKNOWN rather than verified-empty; the PR body’s Linked Issues reads None and fidelity was judged against the description and the committed design doc.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)"the BranchPickerPopover.test.tsx cell of the doc's test table — I did not open the test file, so "focus restore to the manage row on exit with a search-box fall…"agent reverse-audit (round 1)"the workspace-git-remotes.test.ts and git-remotes.test.ts table cells beyond the two I grepped ("helper-URL rejection before spawn" at :490 and "stores the trim…"agent reverse-audit (round 2)"the url.insteadOf connect-time probe hung inside the ext helper and hit the shell timeout before printing, so that layer's evidence is the hang itself rathe…

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

收敛情况:第 4 轮发布了 57 条行内评论,其中 54 条是首次提出;上一轮发布了 7 条(其中 3 条首次提出)。发现反复回到同一批文件:packages/web-shell/client/components/BranchPickerPopover.tsx(第 2 轮已出过发现,本轮又有 19 条);packages/core/src/utils/git-remotes.ts(第 1、2、3 轮已出过发现,本轮又有 4 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,或将本 PR 的评审降到 --severity-floor critical,可以避免循环反复推导同一组发现。(仅为观察——本轮评审未因此扣留任何内容。)

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

[Critical] R4-3: [certifies-falsely] [new-surface] The transport-helper write gate requires a leading alphanumeric, so git's empty-transport-name helper form ::<args> passes isValidRemoteUrl and is written into .git/config, even though git routes that URL through — A client calls the add-remote route with name evil, url ::sh -c id. isValidRemoteUrl('::sh -c id') returns true (non-empty, no leading -, EXECUTING_HELPER_URL needs one [A-Za-z0-9] before ::, no control/invisible chars), so runGit(cwd, ['remote','add','--',name,trimmedUrl]) runs — verified: git remote add -- evil '::sh -c id' exits 0 and writes url = ::sh -c id. Any later connect on that remote (panel pull/fetch, or the user's own git fetch evil in a terminal) takes git's helper path: GIT_TRACE=1 git ls-remote evil shows run_command: GIT_DIR=.git git remote- evil 'sh -c id' then git.c:749 trace: exec: git-remote- evil 'sh -c id' — git looks up the executable git-remote- in its exec path and then on PATH, passing the stored payload as argv, with no protocol.*.allow prompt or denial in a default environment. Unconditionally the planted remote is permanently broken (git: 'remote-' is not a git command); if any directory on the daemon's or user's PATH holds an executable named git-remote-, the stored argument string executes with it — persistence that outlives the session. The gate's own comment says "the write path rejects the form", and the existing table rejects unknown helper names (gcrypt::myrepo, 9p::ssh://host/repo) on precisely the rationale that "an installed git-remote-<name> runs at connect time under the default policy"; git-remote- is looked up the same way and is admitted. The fix must not violate this existing fact: the same table pins ['ssh://git@[::1]/repo.git', true] and ['ssh://git@[2001:db8::1]:22/o/r.git', true] (packages/core/src/utils/git-remotes.test.ts:134-135) — the widened pattern must keep accepting bracketed IPv6 literals, so :: must stay anchored at position 0 rather than matched anywhere in the URL. Please pin this with a test: packages/core/src/utils/git-remotes.test.ts — add rows to the isValidRemoteUrl it.each table (lines 130–166): ['::sh -c id', false] and ['::0', false]. Both go red against the current pattern (true !== false); reverting the pattern to /^[A-Za-z0-9][A-Za-z0-9_.+-]*::/ turns them red again, which is the mutation proof.

[Critical] R3-1: [certifies-falsely] [new-surface] Still stands. fetchGitRemotes receives git's -z byte stream as a lossily UTF-8-decoded JS string, so any config byte that is not valid UTF-8 becomes U+FFFD in the listed name and the row the panel renders can never be removed. — At HEAD the transport is unchanged: runGit calls execFileAsync (git-branches.ts:135-146) with no encoding option, so Node decodes git's NUL-framed byte stream as UTF-8 and replaces every invalid byte with U+FFFD. fetchGitRemotes then does raw.split('\0') over that decoded string and returns the lossy name to the client, which sends it back verbatim as the removal target. A .git/config the user did not author - a downloaded zip, a cloned repo, a hand-edited file - can hold a remote subsection name with a non-UTF-8 byte; the panel renders a row whose name no longer equals the name git holds, so gitRemoteRemove's git remote remove -- <lossy name> answers 'No such remote' and the row can never be removed through the UI. The -z framing exists precisely to make names unambiguous at the byte level, and the decode throws that away before the parser sees it. Please pin this with a test: A case in packages/core/src/utils/git-remotes.test.ts that writes a remote subsection name containing a non-UTF-8 byte directly into .git/config, asserts the listing either surfaces it removably or refuses it explicitly, and asserts the removal round trip succeeds - it goes red against the lossy decode.

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

Comment thread docs/design/git-manage-remotes.md Outdated
Comment thread packages/cli/src/serve/routes/workspace-git-remotes.test.ts
Comment thread packages/core/src/utils/git-remotes.ts Outdated
Comment thread packages/core/src/utils/git-remotes.ts
Comment thread packages/core/src/utils/git-remotes.ts
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/web-shell/client/e2e/web-shell.git-remotes.spec.ts Outdated
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix updated a stale base — the fix did not pass verification, but this PR was behind main, so it merged current main in via update-branch and will retry on the next scan. A stale base (a dependency or symbol main already changed) can fail the build without being the fix's fault; if it still fails once current, it hands off to a human.

What I found before stopping:
Autofix agent finished without required output file(s): address-summary.md, no-action.md.

See the Qwen Autofix agent step logs for model/tool output.

中文说明

🤖 AutoFix 更新了一个过期的 base —— 修复未通过验证,但本 PR 落后于 main,因此已通过 update-branch 合入当前 main,并将在下次扫描时重试。过期的 base(main 已改动的依赖或符号)可能让构建失败而并非修复本身的错;若 base 更新后仍然失败,将移交人工处理。

Run log: https://github.com/QwenLM/qwen-code/actions/runs/34192434018


🧠 Handled by Qwen Code · model/模型 kimi-k3 · CLI 0.23.0

@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.

⚠️ Round 13, and the diff has grown 6.5x since this review first measured it (1743 → 11281 source diff lines). The findings below are anchored to the current patch, so they can only say where this approach leaks — never that a different approach would retire all of them at once. Before fixing them, a human should decide whether the shape of the change is still right. Advisory only: this does not affect the verdict, and nothing here is a blocker.

Not reviewed: reverse-audit rounds 1-2 reported 78 further findings that the time budget left undeduplicated and unverified — none is posted and none is counted as a blocker.

Not reviewed: verification shard 3 (6 findings) — the verifier completed but never opened its brief, so no verdict for those findings is recoverable.

Not reviewed: reverse audit of chunk 34, round 2 — the agent failed on a provider content-filter error over the generated Unicode confusables table.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 2)": the Chinese design twin's structural and claim-level sync against EN doc lines 72-472 (my whole range) — I never opened docs/design/git-manage-remotes.zh-CN.md…; "agent reverse-audit (round 2)": line-by-line verification of the §6 test-table coverage assertions in zh:134-138 (~200 individual claims across five test files) — I spot-checked only three (th…; "agent reverse-audit (round 2)": the zh:122 CSS class list ( .remotesHeader , .backButton , .remotesTitle , .remoteRow , .remoteName , .remoteBadge , .remoteUrl , .remoteRemove , .remot…; "agent reverse-audit (round 1)": the ~10k-character coverage rows at zh:131-138 ( git-remotes.test.ts , workspace-git-remotes.test.ts , BranchPickerPopover.test.tsx , e2e spec) — I verified o…; "agent reverse-audit (round 1)": zh:119's premise that git remote remove deletes tracking refs and branch tracking config BEFORE the section write (the stated justification for refreshing bra…, and 13 more.

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

Not reviewed: "agent verify" — pointed at diff lines it never opened: it made tool calls, but none of them read the diff.

Not reviewed: verification — its prompt was built, but no agent was launched with it, so the posted findings cannot be counted as verified.

⚠️ 7 finding(s) still carried the — [unverified] tag when the loop ended — the verifier never ruled on them, and they are not confirmed.

Deferred under the convergence posture (round 13, not a blocker) — recorded, not requested in this round:

  • docs/design/git-manage-remotes.md:349 — [review] EN doc attaches the HEAD refusal to the wrong predicate;…
  • packages/cli/src/serve/routes/workspace-git-branches.test.ts:594 — [review] One 450-line it mutates shared fixtures across ~20…
  • packages/cli/src/serve/routes/workspace-git-branches.ts:51 — [review] Filesystem root as a redaction key eats every slash in the…
  • packages/cli/src/serve/routes/workspace-git-branches.ts:115 — [review] in file redaction arm eats remote sideband text on…
  • packages/cli/src/serve/routes/workspace-git-branches.ts:318 — [review] ~25 full-length redaction passes over an unbounded ~20 MB…
  • packages/core/src/utils/git-remotes-kill.test.ts:66 — [review] Kill fixture pins exit 128 where real git exits 2
  • packages/core/src/utils/git-remotes-kill.test.ts:103 — [review] The signal half of isKillError has no witness in 26 tests
  • packages/core/src/utils/git-remotes.test.ts:85 — [review] Teardown retry handles only EBUSY, not Node's other four…
  • packages/core/src/utils/git-remotes.test.ts:223 — [review] The Default_Ignorable arm of INVISIBLE_CHARS has no witness
  • packages/core/src/utils/git-remotes.test.ts:526 — [review] A test named for the invalid-refspec guard cannot fail…
  • packages/core/src/utils/git-remotes.test.ts:630 — [review] Comment names a gitConfig() helper that does not exist here
  • packages/web-shell/client/components/BranchPickerPopover.test.tsx:4228 — [review] A never-resolving branch mock leaks past the end of its test
  • packages/web-shell/client/components/BranchPickerPopover.tsx:606 — [review] Remotes panel paints for a frame on reopen after dismissal
  • packages/web-shell/client/components/BranchPickerPopover.tsx:915 — [review] Unknown remove outcome is classified as "nothing was…
  • packages/web-shell/client/components/BranchPickerPopover.tsx:1009 — [review] Focus restore yanks focus from a control the user moved to
  • packages/web-shell/client/components/BranchPickerPopover.tsx:1044 — [review] A rejected add leaves a destructive remove-confirm armed
  • packages/web-shell/client/utils/unicodeConfusables.ts:11 — [review] 6,565-entry generated table committed with no integrity gate

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (4 Critical(s)), the rate of first-time findings is not falling (this round 3, previous 0), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):

standing Critical attack surface attacker-dependency blast radius
(each standing Critical)

Advisory only — it does not block this review.

中文说明

仅完成部分审查,审查缺口已披露。

⚠️ 第 13 轮,且自本审查首次测量以来 diff 已增长 6.5 倍(源码 diff 行数 1743 → 11281)。下方的发现都锚定在当前这版补丁上,因此它们只能指出这个方案在哪里漏了,而无法说明换一个方案就能一次性消除全部问题。在动手修复之前,应由人来判断这次改动的整体形态是否仍然正确。仅供参考:本段不影响判定结论,其中也没有任何阻断项。

未审查(原文为英文):reverse-audit rounds 1-2 reported 78 further findings that the time budget left undeduplicated and unverified — none is posted and none is counted as a blocker.

未审查(原文为英文):verification shard 3 (6 findings) — the verifier completed but never opened its brief, so no verdict for those findings is recoverable.

未审查(原文为英文):reverse audit of chunk 34, round 2 — the agent failed on a provider content-filter error over the generated Unicode confusables table.

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 2)"the Chinese design twin's structural and claim-level sync against EN doc lines 72-472 (my whole range) — I never opened docs/design/git-manage-remotes.zh-CN.md…"agent reverse-audit (round 2)"line-by-line verification of the §6 test-table coverage assertions in zh:134-138 (~200 individual claims across five test files) — I spot-checked only three (th…"agent reverse-audit (round 2)"the zh:122 CSS class list ( .remotesHeader , .backButton , .remotesTitle , .remoteRow , .remoteName , .remoteBadge , .remoteUrl , .remoteRemove , .remot…"agent reverse-audit (round 1)"the ~10k-character coverage rows at zh:131-138 ( git-remotes.test.ts , workspace-git-remotes.test.ts , BranchPickerPopover.test.tsx , e2e spec) — I verified o…"agent reverse-audit (round 1)"zh:119's premise that git remote remove deletes tracking refs and branch tracking config BEFORE the section write (the stated justification for refreshing bra…,另有 13 条。

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

未审查:"agent verify"——启动 prompt 为它指定了 diff 中的行,但它从未打开:有工具调用,却没有一次读取 diff。

未审查:验证——它的 prompt 已构建,但没有 agent 用它启动,发布的发现不能算作已验证。

⚠️ 循环结束时仍有 7 条发现带着 — [unverified] 标记——验证者从未对它们作出裁决,它们不算已确认。

收敛姿态下延后(第 13 轮,非阻断)——已记录,本轮不要求修改:共 17 条(原文未翻译,列表见上方英文部分)。

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 4 条 Critical),首次发现的速率没有下降(本轮 3,上一轮 0),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。

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

Comment thread packages/core/src/utils/git-remotes.ts Outdated
Comment thread packages/core/src/utils/git-remotes.ts
Comment thread packages/core/src/utils/git-remotes.ts
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
…lout

- R4-3: widen EXECUTING_HELPER_URL to admit git's empty-name helper
  form (`::payload` execs a PATH-resolved `git-remote-`); rationale
  corrected — the empty name is not on git's deny-by-default list, and
  a protocol.allow policy that would cover it is overridable from
  config files. Witness rows `::sh -c id`/`::0`/`::`; mutation of the
  pattern turns them red while the IPv6 literals stay accepted.
- R12-1: restore the local upstream backups at the TOP of the
  removal-failure path (above the !completed throw and the converge
  classification), so a refusal following an already-destructive rm
  cannot skip the rollback; a killed restore read rethrows instead of
  letting git's original answer surface un-rolled-back (kill witness
  pins the restore read's argv, separating it from the converge gate's
  scope read).
- R13-2: the converge arm excludes the removed name's own
  namespace-less top-level bare ref from sweep AND re-verify while
  keeping the longest-prefix ownership resolution whole — a bare ref
  exactly owned by a configured slashed sibling keeps that owner
  (first attempt at this fix regressed exactly that; witnesses:
  remote-HEAD symref, flat layout, slashed sibling bare ref).
  Accepted residuals disclosed: a formerly-configured name's bare-ref
  residue is orphaned, and a flat layout's slashed branch refs stay
  sweepable.
- R13-3: mark BOTH rows when a collision group's skeleton is non-ASCII
  and the variance is a table fold rather than canonical equivalence
  (skeletonAscii/allCanonical on the group); witness öö × ةة; the
  all-ASCII and pure-canonical polarities keep their pinned behavior.
- Audit fallout: the TR39 fold moves to
  client/utils/remote-name-skeleton.ts beside its table integrity gate
  (no self-maps, no value containing a table key, key folds to value,
  value is a fold fixed point, NFC); the non-discriminating
  lowercase-spoof completion test is removed (the exact-prefix test
  carries the veto witness); design docs synced EN+ZH for the third
  marking polarity, the error-path restore and its fail-closed
  masking, the converge residuals, and the new test rows.
@wenshao

wenshao commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

R13 deferred-list dispositions (review 5184892069 recorded 17 items as "not requested in this round"; triaged as maintainer). The four posted Criticals are answered in their threads: fixed in bab8e50.

Fixed in bab8e50 besides the Criticals:

  • docs/design/git-manage-remotes.md:349 (HEAD refusal attached to the wrong predicate): the removal predicate inherits NOTHING from isValidRefName; EN bullet rewritten, ZH gained the parity clause, and the converge-arm bare-ref exclusion is cross-referenced as what keeps a HEAD removal safe.
  • git-remotes-kill.test.ts:66 (fixture exit 128): probed git 2.50.1 — git remote remove of a missing remote exits 2; fixture and comment corrected.
  • git-remotes.test.ts:85 (teardown retry only EBUSY): retry set widened to EBUSY/EPERM/ENOTEMPTY (the Windows rmSync race family).
  • git-remotes.test.ts:526 (test named for the invalid-refspec guard that cannot fail): the lowercase-spoof fixture can never reach the completion regex (the invalid-refspec veto fires first on that path), so the test was non-discriminating and is removed; the exact-prefix test carries the veto witness and keeps its name.
  • git-remotes.test.ts:630 (comment names a nonexistent gitConfig()): reworded to the actual listing trim.
  • unicodeConfusables.ts:11 (table committed with no integrity gate): new client/utils/unicodeConfusables.test.ts gates the committed literal — no self-maps, no value containing a table key, every key folds to its value, every value a fixed point of the runtime fold, all values NFC; a hand-edited value turns the gate red. The fold moved beside it into client/utils/remote-name-skeleton.ts.

Declined with evidence:

  • git-remotes-kill.test.ts:103 (signal half of isKillError unwitnessed): witnessed — the SIGTERM fixtures at lines 27/81/611 drive the rethrow tests (rethrows a killed config read instead of answering an empty list and siblings).
  • git-remotes.test.ts:223 (Default_Ignorable arm unwitnessed): witnessed — the ori\u00adingin (U+00AD) and ori\u{e0041}gin (tag) rows in the add-predicate table are Default_Ignorable members.

Recorded as follow-up (not landed this round, with reasons):

  • workspace-git-branches.test.ts:594 (one 450-line it mutating shared fixtures): cosmetic test refactor; no behavior at stake.
  • workspace-git-branches.ts:51 (filesystem root as redaction key): over-redaction at an unsupported workspace shape (/); safe direction, message-quality only.
  • workspace-git-branches.ts:115 (in file arm eating remote sideband text): over-redaction (safe direction); tightening the arm's specificity queued.
  • workspace-git-branches.ts:318 (~25 full-length redaction passes over unbounded stderr): error path only, bounded by git's own stderr volume; queued behind a demonstrated cost.
  • BranchPickerPopover.test.tsx:4228 (never-resolving branch mock leaking past its test): mock hygiene; queued.
  • BranchPickerPopover.tsx:606 (remotes panel paints for a frame on reopen): cosmetic flash; queued.
  • BranchPickerPopover.tsx:915 (unknown remove outcome classified as nothing-mutated): product judgement — an indeterminate outcome must not claim success; the stale-row re-read path covers the user-visible recovery; a design call before changing the classification.
  • BranchPickerPopover.tsx:1009 (focus restore yanks focus from a control the user moved): product judgement on focus ownership; queued with a design call.
  • BranchPickerPopover.tsx:1044 (rejected add leaves a destructive remove-confirm armed): UX safety; queued — the confirm disarms on row-list changes today, the add-failure path needs its own disarm decision.
  • The copy-precision refinement from the R13-3 thread (fold-only non-ASCII rows reading (lookalike name) instead of (hidden characters)): queued separately from the polarity fix.

@wenshao

wenshao commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

CI note (macOS lane): the Test (macos-latest) job on the dispatch runs cancels at exactly its 60-minute timeout-minutes (ci.yml:1567) — and main's own nightly (run 34519827061) cancels the same way, so the overrun is pre-existing on main's suite at current runner speed, not introduced by this branch (an earlier dispatch completed the lane in ~47 min, so runner variance decides it today). Platform validation for this PR's changes was therefore done on a macOS host directly: core 236, cli 105, web-shell 125 tests green on darwin / git 2.50.1, including every git-version-sensitive witness (the remote add a/b subset refusal, the FIFO/symlink/EBUSY shapes, the confusables fold). Raising the mac lane's timeout is a main-side CI decision, deliberately not bundled here.

@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.

[Critical] Blocking finding(s) follow.

Partially reviewed — gaps disclosed.

⚠️ Round 14, and the diff has grown 6.5x since this review first measured it (1743 → 11374 source diff lines). The findings below are anchored to the current patch, so they can only say where this approach leaks — never that a different approach would retire all of them at once. Before fixing them, a human should decide whether the shape of the change is still right. Advisory only: this does not affect the verdict, and nothing here is a blocker.

Unresolved, please confirm:

  • [Critical] packages/core/src/utils/git-remotes.ts:504 (R13-2) — the no-such-remote converge arm sweeping a live top-level remote-tracking ref: this round confirmed a NEW Critical in the same converge arm at :546 (R14-4, refs a surviving remote owns), …

Not reviewed: reverse-audit pair findings (59 candidates from rounds 1-2, 12 of them Critical) — never verified: the time budget stopped before a verifier batch could be built, so they are reported terminal-only and are not confirmed blockers.

Not reviewed: test-efficacy probe — unmeasured: all 14 probes returned inconclusive (no-output) and the positive control never ran (harnessValidated null), so inert / mutant-survived / hunk-survived are unprobed scope, not clean results.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: chunk 29 — did not verify by execution whether any production caller mutates workspaceCwd/gitCwd while branchPickerOpen is true — stopped at the agent tool budget.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": matching row 698's ~150 named cases and row 701's ~90 one-by-one to test titles in git-remotes.test.ts / BranchPickerPopover.test.tsx — I spot-checked the e…; "agent reverse-audit (round 2)": none was cut short — the full 278-line range (diff 13642-13919) was read, and every claim above was checked against HEAD sources in the worktree.; chunk 3: none — no check in this territory was cut short.; "agent reverse-audit (round 2)": the 现状 table's approximate pre-change line numbers ( server.ts 约 L2403-2413, DaemonClient.ts 约 L6600-6770, i18n.tsx en 约 L34-80 / zh 约 L3523-3566, mockDae…; "agent reverse-audit (round 2)": the 问题陈述's inventory of pre-existing git remote readers was verified only for the two review-command paths ( packages/cli/src/commands/review/lib/remote-match…, and 6 more.

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

Not reviewed: "agent verify" — pointed at diff lines it never opened: it made tool calls, but none of them read the diff.

Not reviewed: verification — its prompt was built, but no agent was launched with it, so the posted findings cannot be counted as verified.

⚠️ 122 finding(s) still carried the — [unverified] tag when the loop ended — the verifier never ruled on them, and they are not confirmed.

1 Suggestion(s) were drafted inline past the resolved critical posting floor; the CLI moved them into the deferral list below (floor enforcement).

Deferred under the convergence posture (round 14, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/serve/routes/workspace-git-remotes.test.ts:80 — [review] R14-1: The hermeticity pre-condition matches any key in the bare [remote] section — notably remote.pushDefault — not a [remote "<name>"] subsection, so it decla…
  • docs/design/git-manage-remotes.md:721 (+2 locations) — [review] The "Files affected" inventory omits…
  • packages/core/src/utils/git-remotes.test.ts:1004 — [review] The test's own stated premise — "the name still resolves…
  • packages/core/src/utils/git-remotes.ts:1511 — [review] localBranchKeyValues parses git config --get-all …
  • packages/core/src/utils/git-remotes.ts:1532 — [review] The rollback path pays two sequential git config process…
  • packages/web-shell/client/components/BranchPickerPopover.tsx:2091 — [review] For a URL-side anomaly the only evidence is a hover…
  • packages/core/src/utils/git-remotes.ts:1431 — [review] The **sibling-worktree** remote.pushDefault sweep — and…
  • packages/core/src/utils/git-remotes.ts:560 — [review] The **converge arm's** post-sweep resolution refusal (a…
  • packages/web-shell/client/components/BranchPickerPopover.tsx:2218 — [review] The push-only fan-out arm of the removal-consequence badge…
  • packages/web-shell/client/e2e/web-shell.git-remotes.spec.ts:38 — [review] The PR description's "Executable evidence" line names this…
  • packages/cli/src/serve/routes/workspace-git-branches.test.ts:594 — [review] One 447-line it bundles ~17 independent redaction…
  • packages/cli/src/serve/routes/workspace-git-branches.ts:101 — [review] The /etc/gitconfig token arm tokenizes the entire…
  • packages/cli/src/serve/routes/workspace-git-branches.ts:260 — [review] readHead 's non-regular-file guard is a stat-then-open…
  • packages/core/src/utils/git-remotes-kill.test.ts:223 — [review] The ~15 kill-site sequencing tests identify *which* read…
  • packages/core/src/utils/git-remotes-kill.test.ts:393 — [review] The module-level runGit mock is never reset between…
  • packages/core/src/utils/git-remotes.test.ts:497 — [review] Six mutation-path tests are filed under…
  • packages/core/src/utils/git-remotes.test.ts:866 — [review] The otherSettings counter's one documented exception —…

Convergence: round 14 posted 6 inline comment(s), 6 of them reported for the first time; the previous round posted 4 (3 new). Findings keep coming back to the same files: packages/core/src/utils/git-remotes.ts (findings in rounds 12, 13; 3 more now); packages/web-shell/client/components/BranchPickerPopover.tsx (findings in round 13; 1 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (6 Critical(s)), the rate of first-time findings is not falling (this round 6, previous 3), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):

standing Critical attack surface attacker-dependency blast radius
(each standing Critical)

Advisory only — it does not block this review.

中文说明

仅完成部分审查,审查缺口已披露。

⚠️ 第 14 轮,且自本审查首次测量以来 diff 已增长 6.5 倍(源码 diff 行数 1743 → 11374)。下方的发现都锚定在当前这版补丁上,因此它们只能指出这个方案在哪里漏了,而无法说明换一个方案就能一次性消除全部问题。在动手修复之前,应由人来判断这次改动的整体形态是否仍然正确。仅供参考:本段不影响判定结论,其中也没有任何阻断项。

未决,请确认:共 1 条(原文未翻译,列表见上方英文部分)。

未审查(原文为英文):reverse-audit pair findings (59 candidates from rounds 1-2, 12 of them Critical) — never verified: the time budget stopped before a verifier batch could be built, so they are reported terminal-only and are not confirmed blockers.

未审查(原文为英文):test-efficacy probe — unmeasured: all 14 probes returned inconclusive (no-output) and the positive control never ran (harnessValidated null), so inert / mutant-survived / hunk-survived are unprobed scope, not clean results.

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

未审查(原文为英文):chunk 29 — did not verify by execution whether any production caller mutates workspaceCwd/gitCwd while branchPickerOpen is true — stopped at the agent tool budget.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)"matching row 698's ~150 named cases and row 701's ~90 one-by-one to test titles in git-remotes.test.ts / BranchPickerPopover.test.tsx — I spot-checked the e…"agent reverse-audit (round 2)"none was cut short — the full 278-line range (diff 13642-13919) was read, and every claim above was checked against HEAD sources in the worktree.;chunk 3:none — no check in this territory was cut short."agent reverse-audit (round 2)"the 现状 table's approximate pre-change line numbers ( server.ts 约 L2403-2413, DaemonClient.ts 约 L6600-6770, i18n.tsx en 约 L34-80 / zh 约 L3523-3566, mockDae…"agent reverse-audit (round 2)"the 问题陈述's inventory of pre-existing git remote readers was verified only for the two review-command paths ( packages/cli/src/commands/review/lib/remote-match…,另有 6 条。

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

未审查:"agent verify"——启动 prompt 为它指定了 diff 中的行,但它从未打开:有工具调用,却没有一次读取 diff。

未审查:验证——它的 prompt 已构建,但没有 agent 用它启动,发布的发现不能算作已验证。

⚠️ 循环结束时仍有 122 条发现带着 — [unverified] 标记——验证者从未对它们作出裁决,它们不算已确认。

1 条 Suggestion 在已解析的 critical 发布下限之外被起草为行内评论;CLI 已将其移入下方延后清单(下限强制执行)。

收敛姿态下延后(第 14 轮,非阻断)——已记录,本轮不要求修改:共 17 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 14 轮发布了 6 条行内评论,其中 6 条是首次提出;上一轮发布了 4 条(其中 3 条首次提出)。发现反复回到同一批文件:packages/core/src/utils/git-remotes.ts(第 12、13 轮已出过发现,本轮又有 3 条);packages/web-shell/client/components/BranchPickerPopover.tsx(第 13 轮已出过发现,本轮又有 1 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 6 条 Critical),首次发现的速率没有下降(本轮 6,上一轮 3),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。

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

Comment thread packages/web-shell/client/utils/remote-name-skeleton.ts Outdated
Comment thread packages/cli/src/serve/routes/workspace-git-branches.ts
Comment thread packages/core/src/utils/git-remotes.ts
Comment thread packages/core/src/utils/git-remotes.ts
Comment thread packages/core/src/utils/git-remotes.ts Outdated
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
… fallout

- R14-2: the TR39 fold iterates to a fixed point (capped) instead of
  running one pass — the closing NFC can compose a table key and a
  decomposed spelling only meets the table after composition; new
  remote-name-skeleton.ts carries the fold beside its integrity gate
  and fixed-point corner tests.
- R14-3: redaction gains a quoted-path arm (git's die(_('%s' …))
  family owns a quoted space-bearing payload whole) plus a slash-
  carrying fragment arm for apostrophe-bearing paths.
- R14-4: tracking-ref ownership skips refs inside a SURVIVING remote's
  fetch-dest namespace (NUL-framed get-regexp read), on sweep and
  re-verify alike; closes the flat-dest slashed-branch residual.
- R14-5: the restore's presence read is NUL-framed, so an empty value
  reads as present instead of absent (no more doubled pushDefault).
- R14-6: isSectionlessUpstream narrows to `.`/colon-bearing values;
  dangling slashed/backslash upstreams now refuse like their bare-word
  twin; win32 UNC keeps a carve (network transport, offline-share
  timeout); existing paths still certify through the path probe.
- R14-7: the post-refusal branch/status refresh runs in the same
  synchronous block as the staleness guard, ahead of the awaited
  re-read, so a mid-await workspace switch cannot seed the new
  workspace's panel with the old closure's data.
- Audit fallout: stale comments corrected (sectionless shapes, flat
  dests, probe classes), win32 UNC carve, NUL-framed dest read without
  the speculative JSON.parse arm, prettier-clean test files, and EN/ZH
  design-doc sync for all six changes plus matrix/Files-affected
  attribution of the new skeleton module and its tests.
@wenshao

wenshao commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

R14 deferred-list dispositions (review 5186199109 recorded 11 items as "not requested in this round"; triaged as maintainer). The six posted Criticals are answered in their threads: fixed in b3aa91e.

Fixed in b3aa91e besides the Criticals:

  • docs/design/git-manage-remotes.md:721 (+2 locations): the Tests matrices and Files-affected sections now credit remote-name-skeleton.ts / remote-name-skeleton.test.ts (fixed-point corners, idempotence) and the malformed paren is gone.
  • git-remotes.ts:1511 (localBranchKeyValues line framing): the R14-5 fix moved this read to --get-all -z with NUL splitting, which is exactly the framing this item asked for.

Recorded as follow-up (not landed this round, with reasons):

  • workspace-git-remotes.test.ts:80 (R14-1, hermeticity pre-condition matching any bare-[remote] key incl. remote.pushDefault): false-positive direction (a host with a pushDefault aborts the test), not unsound; test-robustness tightening queued.
  • git-remotes.test.ts:1004 (test comment premise about "the name still resolves"): comment-accuracy review; queued with a re-read of the converge-gate witness it describes.
  • git-remotes.ts:1532 (rollback pays two sequential config processes): micro-perf on the refusal path; batchable into one read, queued.
  • BranchPickerPopover.tsx:2091 (URL-side anomaly evidenced only on hover): product judgement on surfacing URL homoglyph evidence without hover; queued.
  • git-remotes.ts:1431 (sibling-worktree remote.pushDefault sweep) and :560 (converge arm's post-sweep resolution refusal): both need a design call on scope semantics; queued with the R13 carry-overs (sibling-worktree ref ownership, restore presence scope, indeterminate-outcome classification, focus yank, rejected-add armed confirm).
  • BranchPickerPopover.tsx:2218 (push-only fan-out arm of the consequence badge): badge-accuracy refinement; queued.
  • web-shell.git-remotes.spec.ts:38 (PR description's "Executable evidence" line): PR-text fix, not code; will correct in the PR body.
  • workspace-git-branches.test.ts:594 (one 447-line it bundling ~17 redaction scenarios): cosmetic test refactor carried from R13; queued.

@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.

@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.

⚠️ Round 15, and the diff has grown 6.6x since this review first measured it (1743 → 11466 source diff lines). The findings below are anchored to the current patch, so they can only say where this approach leaks — never that a different approach would retire all of them at once. Before fixing them, a human should decide whether the shape of the change is still right. Advisory only: this does not affect the verdict, and nothing here is a blocker.

Not reviewed: reverse audit round 3 - not run: the review time budget would not cover another round plus compose and submit, 79 reverse-audit candidates from the round 1/2 pair (16 Critical, 63 Suggestion) - never verified: reported terminal-only, they are not confirmed blockers, test-efficacy probe - unmeasured: all 15 probes returned inconclusive (no-output) and the positive control never ran (harnessValidated null), so inert / mutant-survived / hunk-survived are unprobed scope, not clean results, the 83 blocker threads from rounds 1-13 were not individually re-ruled - the round-14 machine ledger (6 entries) is the authoritative cumulative work list and every entry in it was ruled — the agent returned no evidence of its walk twice.

Not explored to full depth (tool budget reached): chunk 23: none — the mid-wildcard repro, the worktree-scope repro and the git-behaviour probes were all executed; nothing was cut short. I did not run the package's vites…; "agent reverse-audit (round 1)": executing the new Playwright spec (tests 2-5 in particular) against a built web-shell — npm ci + npm run build + Chromium install exceeds the remaining budg…; "agent reverse-audit (round 1)": I did not verify the §1 prose in this chunk describing the sibling-worktree sweep, the merge-key-only-with-remote-key rule, and the survivor/unmask gates ( swee…; "agent reverse-audit (round 2)": end-to-end dynamic confirmation of the sibling-override sequence (running packages/core 's git-remotes.test.ts:1004 with the added non-destruction assertions…; "agent reverse-audit (round 1)": a full word-by-word bilingual read of the six ~5-6 KB coverage-table cells (zh:134-139 vs EN:707-712) — I compared them by backtick-token set, separator counts,…, and 1 more.

Deferred under the convergence posture (round 15, not a blocker) — recorded, not requested in this round; 1 Critical(s) among them are deferred by their axes — fails-closed on new surface, where no wrong result is certified and the merge base had neither the surface nor the defect — and remain follow-up work recorded in the findings artifact:

  • packages/cli/src/serve/routes/workspace-git-remotes.test.ts:79 — [test] Critical [fails-closed] [new-surface] D15-1: The global-scope hermeticity precondition matches *any* remote.* key, while the invariant its own comment names — "an amb…
  • docs/design/git-manage-remotes.md:295 — [probe] D15-2: The doc states the sectionless-value short-circuit set as exhaustive ( ONLY for the shapes … (the local-repository '.', URLs, scp-like 'host:path') ) and then asserts the complement — "…
  • docs/design/git-manage-remotes.md:705 — [probe] D15-3: The coverage table puts a whole file's enumeration in one Markdown cell, so prettier pads all 9 rows to the widest cell — every line of the table is now exactly 6682 characters, and any…
  • docs/design/git-manage-remotes.zh-CN.md:62 — [review] D15-4: The zh §1 predicate bullets drifted from their English source: the slashed-name rationale is translated into an ungrammatical fragment that drops the reason, the removal bullet lo…
  • packages/cli/src/serve/routes/workspace-git-branches.test.ts:617 — [review] D15-5: One it() (~445 lines) bundles roughly 25 independent redaction scenarios on a single shared temp fixture that one scenario mutates in place, so they are or…
  • packages/core/src/utils/git-branches.ts:125 — [review] D15-6: The shared gitEnv now strips ext / fd out of an operator-supplied GIT_ALLOW_PROTOCOL for **every** git consumer in the product — not the remotes feature — while preserving …
  • packages/core/src/utils/git-remotes.test.ts:958 — [review] D15-7: The sibling-worktree fixture (derive the -wt path, register it for teardown, enable extensions.worktreeConfig , git worktree add ) is copy-pasted 26 times inside this chu…
  • packages/core/src/utils/git-remotes.ts:985 — [review] D15-11: removeWorktreeScopeSection 's catch { return false; } — the completion spawn itself failing — is never exercised: every worktree-scope test succeeds ( completes removal of a w…
  • packages/core/src/utils/git-remotes.ts:1794 — [review] D15-9: The win32 UNC short-circuit in isSectionlessUpstream is the only branch of the unmask/converge gate that no test reaches on any platform — nothing in git-remotes.test.ts or …
  • packages/core/src/utils/git-remotes.ts:1803 — [review] D15-10: On the certify path this gate is a strict subset of survivingUpstreamKeys , so it can never change the accept/refuse outcome — its only effect is to throw *before* sweepSiblin…
  • packages/web-shell/client/components/BranchPickerPopover.test.tsx:1888 — [review] D15-12: Only the *name* arm of the local dash guard is witnessed; the url.startsWith('-') arm has no test anywhere in the file (the only dash‑prefixed fixtu…
  • packages/web-shell/client/components/BranchPickerPopover.test.tsx:3008 — [review] D15-13: The comment states the opposite of the assertion three lines below it: it says pushl "stays plain", while the test asserts pushl *does* carry (lo…
  • packages/web-shell/client/components/BranchPickerPopover.tsx:50 — [review] D15-20: The 600 s ceiling is justified by counting only the **add** chain (5 spawns) but the same exported constant also gates the **remove** chain, whose worst-case…
  • packages/web-shell/client/components/BranchPickerPopover.tsx:556 — [review] D15-21: The cross-workspace clear of the held sticky-warning snapshot has no witness: the five sticky-warning tests ( BranchPickerPopover.test.tsx:2056 , :2084 , …
  • packages/web-shell/client/components/BranchPickerPopover.tsx:1114 — [review] D15-14: The removal's chip refresh is a no-op for the composer-branch-chip instance: ChatEditor.tsx:3318-3327 renders <BranchPickerPopover open onOpenChange wor…
  • packages/web-shell/client/components/BranchPickerPopover.tsx:1923 — [review] D15-15: The two new busyAction values ( remoteAdd , remoteRemove ) render an indicator only inside RemotesView , so a mutation still in flight leaves the branc…
  • packages/web-shell/client/components/BranchPickerPopover.tsx:1976 — [review] D15-17: The arm fires for ANY Latin + non‑Latin mix, not the "Cyrillic‑ о homoglyph shape" its comment names, so legitimate names in scripts that have no Latin co…
  • packages/web-shell/client/components/BranchPickerPopover.tsx:1986 — [review] D15-18: The collision lookup folds the RAW name, so a name whose ink differs from its raw spelling (an invisible character, or edge/repeated whitespace CSS collaps…
  • packages/web-shell/client/components/BranchPickerPopover.tsx:2033 — [review] D15-19: The marker is concatenated into the name text inside the same span that carries min-width: 0; overflow: hidden; text-overflow: ellipsis ( .remoteName , B…
  • packages/web-shell/client/utils/remote-name-skeleton.test.ts:24 — [review] D15-23: No test in either new file exercises the NFKC compatibility fallback with a char the table does not list, so the branch remote-name-skeleton.ts:36-38 exist…
  • …and 2 more (see the run report)

Convergence: round 15 posted 4 inline comment(s), 4 of them reported for the first time; the previous round posted 6 (6 new). Findings keep coming back to the same files: packages/core/src/utils/git-remotes.ts (findings in round 14; 2 more now); packages/web-shell/client/components/BranchPickerPopover.tsx (findings in round 14; 1 more now); packages/web-shell/client/utils/remote-name-skeleton.ts (findings in round 14; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

中文说明

仅完成部分审查,审查缺口已披露。

⚠️ 第 15 轮,且自本审查首次测量以来 diff 已增长 6.6 倍(源码 diff 行数 1743 → 11466)。下方的发现都锚定在当前这版补丁上,因此它们只能指出这个方案在哪里漏了,而无法说明换一个方案就能一次性消除全部问题。在动手修复之前,应由人来判断这次改动的整体形态是否仍然正确。仅供参考:本段不影响判定结论,其中也没有任何阻断项。

未审查:reverse audit round 3 - not run: the review time budget would not cover another round plus compose and submit、79 reverse-audit candidates from the round 1/2 pair (16 Critical, 63 Suggestion) - never verified: reported terminal-only, they are not confirmed blockers、test-efficacy probe - unmeasured: all 15 probes returned inconclusive (no-output) and the positive control never ran (harnessValidated null), so inert / mutant-survived / hunk-survived are unprobed scope, not clean results、the 83 blocker threads from rounds 1-13 were not individually re-ruled - the round-14 machine ledger (6 entries) is the authoritative cumulative work list and every entry in it was ruled——该 agent 连续两次未返回任何检查过程的证据。

未探索到全部深度(达到工具调用预算):chunk 23:none — the mid-wildcard repro, the worktree-scope repro and the git-behaviour probes were all executed; nothing was cut short. I did not run the package's vites…"agent reverse-audit (round 1)"executing the new Playwright spec (tests 2-5 in particular) against a built web-shell — npm ci + npm run build + Chromium install exceeds the remaining budg…"agent reverse-audit (round 1)"I did not verify the §1 prose in this chunk describing the sibling-worktree sweep, the merge-key-only-with-remote-key rule, and the survivor/unmask gates ( swee…"agent reverse-audit (round 2)"end-to-end dynamic confirmation of the sibling-override sequence (running packages/core 's git-remotes.test.ts:1004 with the added non-destruction assertions…"agent reverse-audit (round 1)"a full word-by-word bilingual read of the six ~5-6 KB coverage-table cells (zh:134-139 vs EN:707-712) — I compared them by backtick-token set, separator counts,…,另有 1 条。

收敛姿态下延后(第 15 轮,非阻断)——已记录,本轮不要求修改;其中 1 条 Critical 按其失败方向与对照基线延后——fails-closed 且 new-surface:未认证任何错误结果,且 merge base 既无该功能面也无该缺陷——作为后续工作记录在 findings 工件中:共 22 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 15 轮发布了 4 条行内评论,其中 4 条是首次提出;上一轮发布了 6 条(其中 6 条首次提出)。发现反复回到同一批文件:packages/core/src/utils/git-remotes.ts(第 14 轮已出过发现,本轮又有 2 条);packages/web-shell/client/components/BranchPickerPopover.tsx(第 14 轮已出过发现,本轮又有 1 条);packages/web-shell/client/utils/remote-name-skeleton.ts(第 14 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

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

Comment thread packages/core/src/utils/git-remotes.ts Outdated
Comment thread packages/core/src/utils/git-remotes.ts
Comment thread packages/web-shell/client/components/BranchPickerPopover.tsx Outdated
Comment thread packages/web-shell/client/utils/remote-name-skeleton.ts Outdated
…lout

- R15-1: foreign-namespace roots derived per wildcard position; mid-
  wildcard dests (refs/remotes/*/main, or*/main) fail closed. The
  reverse audit caught an unsatisfiable star-position conjunction that
  disabled the orphan sweep in any repo with another default-refspec
  remote; fixed and pinned by an origin + refspec-less orphan witness.
- R15-2: the sibling-worktree sweep verifies the sibling shares this
  repository's common dir before any read or write, so a planted
  .git/worktrees gitdir can no longer receive its unset writes;
  planted-gitdir witness, real-sibling tests unchanged.
- R15-3: the remotes search folds the needle in three case forms (the
  TR39 table is case-sensitive); Istanbul/lstanbul witness.
- R15-4: the fold is invariant under canonical equivalence — two-level
  decomposition fallback at runtime, generator closure over NFD(key),
  table regenerated (6564 entries, U+021A dropped, 27 values re-classed,
  lunate sigma preserved); full invariance sweep pinned.
- Deferred-triage fixes: hermeticity precondition regex, win32 UNC
  short-circuit witness, worktree-completion kill witness, dash-URL
  client-guard witness, NFKC-fallback pin, timeout-ceiling and
  mixed-scripts comment accuracy, zh predicate-bullet and UNC doc
  parity, pushl marker-comment accuracy.
@wenshao

wenshao commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

R15 deferred-list dispositions (review 5189545189 recorded 21 items; triaged as maintainer). The four posted Criticals are answered in their threads: fixed in 5f3752c.

Fixed in 5f3752c besides the Criticals:

  • D15-1 (hermeticity precondition matching any remote.* key): the regex now requires a subsection key (remote.<name>.), so an ambient remote.pushDefault no longer aborts the suite on a configured host.
  • D15-2 (doc short-circuit set stated as exhaustive): the EN §2 sentence now lists the win32 UNC carve inside the ONLY-set; zh mirrored.
  • D15-4 (zh §1 predicate bullets drifted): the isValidRemoteName bullet is rewritten to mirror EN exactly (duplicated fragment removed, HEAD rationale folded into the isValidRefName reuse parenthetical).
  • D15-9 (win32 UNC short-circuit untested): platform-stubbed witness added (certifies an unmasked UNC upstream on win32 without probing it).
  • D15-11 (removeWorktreeScopeSection catch unexercised): kill witness added (refuses when the worktree-section completion spawn itself is killed).
  • D15-12 (url arm of the dash guard unwitnessed): picker witness added (refuses a dash-prefixed URL client-side without calling the daemon).
  • D15-13 (comment opposite to the assertion): the pushl comment now states the all-ASCII mark-both rationale.
  • D15-17 (mixed-scripts arm comment overnames the Cyrillic shape): comment now states the arm is script-mixing generally, conservative direction.
  • D15-20 (600 s ceiling justified by the add chain only): comment now states it is a UX ceiling mirroring the pull flow, not a strict chain bound.
  • D15-23 (NFKC fallback untested): pinned (remoteNameSkeleton('¹') === remoteNameSkeleton('1'), U+00B9 absent from the table).

Declined with reasons:

  • D15-6 (gitEnv strips ext/fd from an operator-supplied GIT_ALLOW_PROTOCOL product-wide): deliberate — the transport-helper form is an RCE vector for EVERY git consumer, not just the remotes feature; a per-feature strip would leave the same vector open through pull/fetch/clone paths.
  • D15-10 (certify-path gate a strict subset of survivingUpstreamKeys): its only effect is throwing before the sibling sweep spawns git in a refused shape; harmless early refusal, kept for error-message fidelity.
  • D15-18 (collision lookup folds the RAW name): names whose ink differs from raw (invisible chars, collapsible whitespace) are already marked by the display-differs arm (nameUnusual), so the whitespace/invisible twin gap the item describes is covered; grouping by raw keeps the marker polarity pinned by existing tests.

Recorded as follow-up (not landed this round):

  • D15-3 (coverage table pads every row to the widest cell): doc-health refactor of the §6 tables.
  • D15-5 (one ~445-line it bundling ~25 redaction scenarios): test refactor.
  • D15-7 (sibling-worktree fixture copy-pasted 26 times): test refactor.
  • D15-14 (chip refresh a no-op for the composer-chip instance), D15-15 (busyAction indicator only inside RemotesView), D15-19 (marker inside the ellipsis span), D15-21 (cross-workspace sticky-snapshot clear unwitnessed): product/UX refinements queued.
  • The two unnamed items in the run report: not recoverable from the review body; will address if re-posted.

- The sibling sweep skips the invoking worktree BEFORE the ownership
  probes, so a repo whose only worktree record is its own pays no
  probe spawn (just the one lazy toplevel read, reused per sibling).
- The killed-sibling witness is re-aimed at the new spawn order and
  pins the kill to the sibling's config.worktree read; under the old
  queue the kill landed on an ownership probe while the test passed.
- The remotes search dedupes its case-folded needles and folds each
  row's ink once instead of once per needle variant.
- EN design doc: two stranded fragments repaired; ZH counterpart gets
  the missing subject in the marker sentence (parity).

@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.

[Critical] Blocking finding(s) follow.

Partially reviewed — gaps disclosed.

⚠️ Round 16, and the diff has grown 6.6x since this review first measured it (1743 → 11578 source diff lines). The findings below are anchored to the current patch, so they can only say where this approach leaks — never that a different approach would retire all of them at once. Before fixing them, a human should decide whether the shape of the change is still right. Advisory only: this does not affect the verdict, and nothing here is a blocker.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • D16-1 shared gitEnv rewrite of an inherited GIT_ALLOW_PROTOCOL affects every git and gh spawn — already reported (D15-6, PR context line 2124)

Unresolved, please confirm:

  • [Critical] R15-1 packages/core/src/utils/git-remotes.ts:1295 (remoteTrackingRefs classifying a surviving remote fetch-dest namespace as foreign) — could not reach a verdict: the code now carries an explicit guard whose comment names this exact sub…
  • [Critical] R15-2 packages/core/src/utils/git-remotes.ts:1441 (sweepSiblingWorktreeKeys taking each sibling path from git worktree list --porcelain) — could not reach a verdict: the sweep now uses -z field splitting with an explicit newline-saf…

Not explored to full depth (tool budget reached): chunk 3: none — no check was cut short by the tool ceiling.; "agent reverse-audit (round 2)": whether BranchPickerPopover.tsx validates the payload v field — I could not check, so finding 3 reports the v: 2 fixture as unchecked contract drift rathe…; "agent reverse-audit (round 1)": exhaustive witness-by-witness cross-check of the three multi-thousand-character §6 coverage rows ( git-remotes.test.ts , workspace-git-remotes.test.ts , Branc…; "agent reverse-audit (round 2)": reading survivingUpstreamKeys / sweptUpstreamResolving / isSectionlessUpstream ( git-remotes.ts ~1830-1916) against the doc's "resolves each branch.<b>.…; "agent reverse-audit (round 2)": verifying the main-path merge-pairing rule ("merge only when the same scope's remote key holds no entry naming a surviving remote") inside unsetUpstreamKeys —…, and 8 more.

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

Not reviewed: verification — its prompt was built, but no agent was launched with it, so the posted findings cannot be counted as verified.

⚠️ 95 finding(s) still carried the — [unverified] tag when the loop ended — the verifier never ruled on them, and they are not confirmed.

Deferred under the convergence posture (round 16, not a blocker) — recorded, not requested in this round:

  • docs/design/git-manage-remotes.md:35 — [review] Three of the five line pointers in the new "Current state"
  • docs/design/git-manage-remotes.md:169 — [review] The doc states two incompatible behaviours for git config
  • docs/design/git-manage-remotes.zh-CN.md:177 — [review] The two language versions of the design doc's Security not
  • packages/cli/src/serve/routes/workspace-git-branches.test.ts:401 — [review] The new block exists so the file "can go red when the tabl
  • packages/cli/src/serve/routes/workspace-git-branches.test.ts:771 — [review] The new 230-line redaction test creates a directory symlin
  • packages/cli/src/serve/routes/workspace-git-branches.ts:100 — [probe] Redaction runs over the FULL detail before the 512-char sl
  • packages/cli/src/serve/routes/workspace-git-branches.ts:154 — [review] The fail-closed sweep is whitespace-token bounded, so an a
  • packages/cli/src/serve/routes/workspace-git-branches.ts:212 — [review] A dir key is pushed without a minimum-length guard, so a
  • packages/cli/src/serve/routes/workspace-git-remotes.test.ts:79 — [review] The ambient-global precondition is thrown from a file-wide
  • packages/core/src/utils/git-remotes-kill.test.ts:12 — [review] The single module-scope runGit mock is never reset betwe
  • packages/core/src/utils/git-remotes-kill.test.ts:696 — [review] Every kill fixture in this new suite sets killed: true n
  • packages/core/src/utils/git-remotes.ts:1205 — [probe] The value-matched ( fixedValue ) sweep of remote.pushDefa
  • packages/core/src/utils/git-remotes.ts:1581 — [probe] The re-verify matches branch keys by endsWith('.remote')/
  • packages/core/src/utils/git-remotes.ts:1841 — [probe] isSectionlessUpstream classifies *every* colon-bearing u
  • packages/web-shell/client/components/BranchPickerPopover.module.css:465 — [probe] The unarmed Remove control — the first click of a two-clic
  • packages/web-shell/client/components/BranchPickerPopover.tsx:881 — [probe] The mutation error taxonomy classifies "answer not receive
  • packages/web-shell/client/components/BranchPickerPopover.tsx:1013 — [probe] The add form's local-validation guard returns *before* se
  • packages/web-shell/client/utils/unicodeConfusables.test.ts:12 — [probe] Every guard on the committed 6564-entry confusables table
  • packages/web-shell/scripts/generate-confusables.mjs:227 — [review] The entry-count floor is a fixed 5000 against a 6564-entry
  • packages/web-shell/client/components/BranchPickerPopover.module.css:511 — [review] .addRemoteButton and its two pseudo-state blocks are a v
  • …and 3 more (see the run report)

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

[Critical] R16-1 packages/core/src/utils/git-remotes.ts:817 — The removed-name certification's resolver leg is fetch-side only, so a removal whose name a url.<base>.pushInsteadOf alias still resolves push-side is certified as "no longer resolves AT ALL" — the push twin of the insteadOf case the same gate refuses, and the module already owns the helper that answers it (pushInsteadOfAliases, :881).

[Critical] R15-4 packages/web-shell/client/utils/remote-name-skeleton.ts:36 — The fold is not a function of the canonical equivalence class of its input — it decides on the arrival spelling, so two remote names that render identically get different skeletons, splitting one ink class into two collision groups. The file's own contract ("ink-identical names share a skeleton whichever spelling they arrive in") and the generator's equation value === runtimeFold(key.normalize('NFD')) (packages/web-shell/scripts/generate-confusables.mjs:118) hold only for keys in isolation; the closing NFC recomposes across a key boundary and defeats them.

中文说明

仅完成部分审查,审查缺口已披露。

⚠️ 第 16 轮,且自本审查首次测量以来 diff 已增长 6.6 倍(源码 diff 行数 1743 → 11578)。下方的发现都锚定在当前这版补丁上,因此它们只能指出这个方案在哪里漏了,而无法说明换一个方案就能一次性消除全部问题。在动手修复之前,应由人来判断这次改动的整体形态是否仍然正确。仅供参考:本段不影响判定结论,其中也没有任何阻断项。

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未决,请确认:共 2 条(原文未翻译,列表见上方英文部分)。

未探索到全部深度(达到工具调用预算):chunk 3:none — no check was cut short by the tool ceiling."agent reverse-audit (round 2)"whether BranchPickerPopover.tsx validates the payload v field — I could not check, so finding 3 reports the v: 2 fixture as unchecked contract drift rathe…"agent reverse-audit (round 1)"exhaustive witness-by-witness cross-check of the three multi-thousand-character §6 coverage rows ( git-remotes.test.ts , workspace-git-remotes.test.ts , Branc…"agent reverse-audit (round 2)"reading survivingUpstreamKeys / sweptUpstreamResolving / isSectionlessUpstream ( git-remotes.ts ~1830-1916) against the doc's "resolves each branch.<b>.…"agent reverse-audit (round 2)"verifying the main-path merge-pairing rule ("merge only when the same scope's remote key holds no entry naming a surviving remote") inside unsetUpstreamKeys —…,另有 8 条。

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

未审查:验证——它的 prompt 已构建,但没有 agent 用它启动,发布的发现不能算作已验证。

⚠️ 循环结束时仍有 95 条发现带着 — [unverified] 标记——验证者从未对它们作出裁决,它们不算已确认。

收敛姿态下延后(第 16 轮,非阻断)——已记录,本轮不要求修改:共 23 条(原文未翻译,列表见上方英文部分)。

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

[Critical] R16-1 packages/core/src/utils/git-remotes.ts:817 — The removed-name certification's resolver leg is fetch-side only, so a removal whose name a url.<base>.pushInsteadOf alias still resolves push-side is certified as "no longer resolves AT ALL" — the push twin of the insteadOf case the same gate refuses, and the module already owns the helper that answers it (pushInsteadOfAliases, :881).

[Critical] R15-4 packages/web-shell/client/utils/remote-name-skeleton.ts:36 — The fold is not a function of the canonical equivalence class of its input — it decides on the arrival spelling, so two remote names that render identically get different skeletons, splitting one ink class into two collision groups. The file's own contract ("ink-identical names share a skeleton whichever spelling they arrive in") and the generator's equation value === runtimeFold(key.normalize('NFD')) (packages/web-shell/scripts/generate-confusables.mjs:118) hold only for keys in isolation; the closing NFC recomposes across a key boundary and defeats them.

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

@wenshao

wenshao commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 287 passed · 0 failed · 287 total

Flakiness gate: ✅ 10 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:287 通过 · 0 失败 · 287 总计

抖动门:✅ 10 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #11163 deep verification — feat(web-shell): manage git remotes from the workspace branch picker

Verdict: findings — 287 scripted assertions executed, 287 pass / 0 fail. The central claim is proven load-bearing by A/B against the base build. Four concrete, non-blocking items are reported (2 display-hardening gaps in the PR's own stated threat model, 2 documented properties no test pins). Nothing reproduced a regression.

  • Verified head: 08f5009adecc7347d92f21a10abbaa974fdc255b (git rev-parse HEAD^2)
  • Base (control) : 35a9c5c62dddcb390811107e1c1247a513f17338 (HEAD^1, the merge-ref base tip)
  • Environment: CI verify lane, node:22-bookworm, node v22.23.2, git 2.39.5, 64 cores
  • Harnesses: h1-remotes-vs-git.mjs, h2-classifier-ab.mjs, h3-redactor-scaling.mjs, h4-allow-protocol-ab.mjs, h5-sanitizer-sweep.mjs, h6-m6-and-homoglyphs.mjs, mutate-classifier.py
  • Raw logs: logs/h1.loglogs/h6.log, logs/gate-{core,cli,webshell,sdk}.log, logs/mutation-matrix.log
中文摘要

结论:findings —— 共执行 287 条脚本化断言,287 通过 / 0 失败。中心主张已由与 base 构建的 A/B 证明为「load-bearing」。报告 4 项具体但不阻塞合并的问题(2 项属于 PR 自己声明的威胁模型内的显示加固缺口,2 项是代码注释声明了、但没有任何测试钉住的性质)。未复现任何回归。

A/B 结论(详见下方 Central claimClassifier A/B 两张表):

  • 中心功能(list / add / remove)在 20 个真实临时 git 仓库形态下与 git 自身逐项一致(97/97)——含 worktree 作用域 section、继承作用域同名 shadow、无 refspec 遗留孤儿 ref、refspec 不可解析、以及与分类器哨兵文本同名的 remote。
  • PR 自己点名的主要风险(所有 workspace git 路由共享的错误分类器):15/15 个既有路由形态分类完全不变;16 个 remote 形态全部从 base 的错误答案翻转为 head 的正确答案;base 在 11/49 条错误响应中泄漏绝对路径,head 为 0/49
  • 最锐利的一格:linked worktree 下 git 回显主仓 .git/config(在请求 workspace 目录树之外),base 原样泄漏,head 脱敏为 <workspace>
  • GIT_ALLOW_PROTOCOL 过滤(影响 core 里每一次 git 调用)实测为 load-bearing:base 下工作区自控的 protocol.ext.allow=always 能真的执行 ext:: helper;head 下被阻断。已实测 git 语义为严格白名单。

findings

  1. 0riginorigin 并列时两行都不标记——TR39 skeleton 缺少 casefold 步骤(skeleton('0rigin') = "Origin"skeleton('origin') = "origin")。这是最经典的同形字攻击,而 1ist/Iink/|ink 都能被标记。
  2. U+FFFC(OBJECT REPLACEMENT CHARACTER)既不被 Default_Ignorable 属性剥离,也不在 confusables 表内,且属 Common script 因而不触发 mixed-script 分支——两个机制都漏。
  3. 「loose keyword 分支只匹配 512 字符截断后的文本」这一性质没有测试钉住(变异体 M6 存活,且已证明可观测)。
  4. remote … already exists 分支的行首锚定没有测试钉住(变异体 M8 存活,注入更深一行即可观测差异)。

未覆盖范围:见 Not covered。要点:未跑 Playwright UI e2e 与 live-daemon curl;confusables 生成器无法离线重跑(需访问 unicode.org);shallow checkout 下 35 个 commit 的逐个归因不可达;未跑仓库级 npm run lint / typecheck / 全量测试。

Scope

Central claim — the Web Shell gains a Manage Remotes panel (list / add / remove) backed by three workspace-scoped daemon routes and a core helper that reads remotes from git's CONFIG, scoped to the repository, with mutations verified against git's own view rather than trusted.

Secondary claim 1 (the PR's own named main risk) — the git error classifier and path redactor shared by all workspace git routes gained six remote-specific branches and several redaction arms without regressing the pre-existing routes (branches, pull, push, checkout, commit).

Secondary claim 2 — error bodies never contain absolute paths, including a gitdir outside the cwd's tree.

Out of scope by choice, and listed under Not covered: the React rendering itself, i18n copy, the SDK client surface beyond its route contract, docs.

Central claim — 20 real-repo shapes, oracle = git itself

h1-remotes-vs-git.mjs drives the compiled packages/core/dist/src/utils/git-remotes.js against real temporary repositories created with real git. No mocks. Every expectation is read back from git (git remote, git remote -v, git remote get-url, git config --list --show-scope --show-origin, git for-each-ref refs/remotes/<name>, git branch --set-upstream-to), never from the module under test.

# shape what git was asked to confirm result
S1 plain single origin name set, fetch/push URL, defaults ✅ 8/8
S2 multiple remotes + a URL-less section git remote lists it; fetchUrl/pushUrl empty ✅ 4/4
S3 pushurl override; multi-url push fan-out git remote -v reports 2 push lines ✅ 7/7
S4 partial clone (--filter=blob:none) + push override promisor / partialclonefilter from git config ✅ 6/6
S5 insteadOf rewriting listing returns the raw configured URL, not the rewrite ✅ 4/4
S6 inherited global remote git lists it; the panel excludes it; scope really is global ✅ 3/3
S7 linked worktree, worktree-scope remote listed from the worktree, absent from the main repo ✅ 3/3
S8 custom refspec + proxy/tagOpt customRefspec, otherSettings counts ✅ 3/3
S9 bidi/invisible name & URL; exec vectors raw in the listing, gated on add, lenient on remove ✅ 12/12
S10 non-repository git's canonical not-a-git-repository text surfaces ✅ 2/2
S11 add round-trip git agrees the remote exists; one round trip carries the list ✅ 5/5
S12 duplicate add git's own remote origin already exists; URL untouched ✅ 3/3
S13 remove round-trip no remote.* record in any scope, upstream keys swept, orphan refs swept, sibling survived ✅ 9/9
S14 remove a missing remote (the panel race) git's No such remote; nothing else destroyed ✅ 3/3
S15 worktree-scope section bare git remote remove fails; the helper completes it ✅ 6/6
S16 inherited same-name section add and remove refuse; nothing mutated ✅ 5/5
S17 refspec-less remote orphaned refs/remotes/origin/* swept ✅ 5/5
S18 unparsable refspec refuses; the row survived (git dies before mutating) ✅ 3/3
S19 remote named after a classifier sentinel listed raw; removal succeeds (no sentinel collision) ✅ 3/3
S20 core error hygiene no config dump on stdout ✅ 2/2

97/97. The two shapes that most directly test the PR's "removal is verified, never trusted" claim are S15 and S16: in S15 bare git leaves the worktree-scope section behind after already destroying the tracking refs, and the helper completes the removal so that git config --show-scope finds nothing in any scope; in S16 the inherited-shadow pre-flight runs before any mutation, and the local half is confirmed still present after the refusal.

Classifier A/B — the shared error path (secondary claims 1 & 2)

h2-classifier-ab.mjs runs the identical 49-entry corpus through two esbuild bundles of workspace-git-branches.ts:

  • base = git show HEAD^1:…/workspace-git-branches.ts, with export added to sendGitError as the only edit.
  • head = the working-tree file, bundled identically.

Both arms resolve @qwen-code/qwen-code-core to the same compiled head build. That is a clean control for this function because findGitRoot lives in gitUtils.ts, which the PR does not touch (git diff --stat HEAD^1..HEAD -- packages/core/src/utils/gitUtils.ts is empty), and GIT_ERROR_MESSAGE_MAX = 512 is identical on both sides. readlink -f node_modules/@qwen-code/qwen-code-core/__w/qwen-code/qwen-code/packages/core (asserted, quoted here per the workspace-link rule).

Corpus: 25 entries captured from real failing git commands in real temp repos, plus 22 clearly-labelled synthesized shapes for payloads git cannot be coaxed into emitting (a server's push sideband, Windows/UNC spellings, deeper-line injection).

class what it asserts head control (base)
R (15 entries) pre-existing route shapes classify identically 15/15 unchanged
N (8 entries) remote shapes reach the correct new code, and base did not already answer it 16/16 base wrong on all 8
H (11 entries) a config-chosen name / deeper line cannot claim the wrong code 21/21 base fooled on 6
L (49 entries) no absolute filesystem path survives 0/49 leaked 11/49 leaked

117/117. Witness: 01-classifier-ab-base-vs-head.png.

The 16 classification deltas, all in the intended direction:

N-add-duplicate                 409/branch_already_exists  -> 409/remote_already_exists
N-remove-missing                500/__bridge__             -> 404/no_such_remote
N-worktree-section              500/__bridge__             -> 409/git_config_write_failed
N-config-lock-chain             500/__bridge__             -> 409/git_config_write_failed
N-invalid-refspec               500/__bridge__             -> 409/remote_config_unparsable
N-sentinel-still-configured     500/__bridge__             -> 409/remote_still_configured
N-sentinel-included-file        500/__bridge__             -> 409/remote_section_in_included_file
N-sentinel-inherited            500/__bridge__             -> 409/remote_shadows_inherited
H-name-dirty-remove-missing     409/dirty_working_tree     -> 404/no_such_remote
H-name-dirty-dup-add            409/dirty_working_tree     -> 409/remote_already_exists
H-name-not-a-git-repo           404/not_a_git_repository   -> 404/no_such_remote
H-name-nothing-to-commit        400/nothing_to_commit      -> 404/no_such_remote
H-name-detached-head            409/detached_head          -> 404/no_such_remote
H-name-already-exists           409/branch_already_exists  -> 404/no_such_remote
H-newline-injection             500/__bridge__             -> 409/remote_config_unparsable
L-linked-worktree-gitdir        500/__bridge__             -> 409/git_config_write_failed

The six H-name-* cells are the sharpest: a remote named after a legacy keyword makes git emit error: No such remote: '<name>', and base's unanchored keyword scan claims the name's word instead of git's own line-initial prefix. H-name-nothing-to-commit is not merely a wrong code — base answers HTTP 400 where 404 is correct. These are real git outputs, not synthesized.

H-newline-injection is a real git-emitted injection: a config-held fetch refspec carrying \n makes git unescape it into a deeper line reading fatal: No such remote: 'spoofed'. Head anchors to line 1 and answers remote_config_unparsable; the deeper line is not claimed.

Redaction: 11 base leaks → 0 at head

The sharpest single cell is L-linked-worktree-gitdir. From a linked worktree, git echoes the main repository's .git/config — a path outside the requesting workspace's tree, which the pre-existing cwd/gitRoot substitution cannot reach:

base: error: could not lock config file /tmp/h2-…/r14-mainwt/.git/config: File exists
head: error: could not lock config file <workspace>/config: File exists

The other ten base leaks are the ~/.gitconfig, $XDG_CONFIG_HOME/git/config, */etc/gitconfig, include.path-target, Windows-drive, UNC, submodule-gitdir and quoted-spacey-path shapes. Head redacts all eleven and still preserves a transport URL verbatim (L-url-survives).

Scaling of the redactor (the PR's own O(L²) claim)

h3-redactor-scaling.mjs drives the real exported sendGitError — so the measurement includes redactGitPaths — over 10 hostile payload families at 2 k / 3 k / 5 k / 20 k / 100 k characters. Untrusted writers reach this path: a malicious remote's push/fetch sideband (the code comment itself notes it "bypasses git's vreportf cap"), and config-chosen names/URLs from a repo the user did not author.

Every family is linear within noise (ratio 10.0×–28.2× for a 20× size increase). Worst single error response: 8.2 ms. The adversarial arm-7 shape (many <config>' starts with no closing quote, forcing a full scan per start) measured 13.7×. A realistic 123 041-char sideband payload cost 0.5 ms. 12/12. The per-token /etc/gitconfig decision the comment says it chose to avoid O(L²) holds up.

gitEnv() — a second shared-surface change

The PR also filters GIT_ALLOW_PROTOCOL in gitEnv(), which feeds every git invocation in core, not just the new routes. h4-allow-protocol-ab.mjs A/Bs base vs head gitEnv and then asks git what it actually does, using a repo whose .git/config pairs url = ext::<helper> with protocol.ext.allow = always — the workspace-controlled escalation the comment names. Two independent witnesses for "the helper executed": git echoing the helper's stdout, and a canary file the helper touches.

git's semantics, measured first (the oracle both arms must respect):

GIT_ALLOW_PROTOCOL protocol.ext.allow = always in repo config ext helper
unset executes
"" (set, empty) blocked
https:ssh (ext absent) blocked

So the variable is a strict allowlist: absence blocks even when the workspace config says always.

case base env head env base: helper ran head: helper ran
operator allows ext explicitly "https:ssh:ext" "https:ssh" true false
operator allows only ext "ext" "" true false
operator allows ext+fd only "ext:fd" "" true false
operator list without ext "https:ssh" "https:ssh" false false
operator allows arbitrary helper 7z "https:7z" "https:7z" false false

17/17. The strip is load-bearing and strictly safer, and the code comment's claim that an arbitrary operator-listed helper name is preserved is confirmed (7z untouched). All pre-existing env hardening survives at both arms (14 repo-selector / config-injection vars still cleared; LC_ALL/LANG still pinned to C).

Bounded sharp edge, fail-closed: an operator whose list is only helper names (ext:fd) now gets "", which is deny-all — so https is refused too (fatal: transport 'https' not allowed). This breaks functionality rather than opening a hole, needs an unusual operator configuration to trigger, and the code comment states the deny-all choice deliberately. Reported as an observation, not a defect.

Mutation matrix on the shared classifier

mutate-classifier.py applies 10 single-point mutants to workspace-git-branches.ts and runs the collocated suite (src/serve/routes/workspace-git-branches.test.ts) for each, restoring byte-identically afterwards (restore check: source identical to original = True; git status --porcelain -- packages/cli/src empty).

Witness: 02-mutation-matrix-7-of-10-killed.png.

# mutation suite classification
CONTROL, unmutated 69/69 green the kills below mean something
M1 delete the No such remote branch 🔴 11 failed killed
M2 un-anchor No such remote (drop ^) — positive control 🔴 1 failed killed
M3 remove the fail-closed absolute-path sweep 🔴 1 failed killed
M4 disable external-gitdir redaction (worktree/submodule) 🔴 1 failed killed
M5 revert detail composition to always-join stdout\nstderr 🔴 26 failed killed
M6 classify the loose keyword arms on the unbounded detail 🟢 69/69 survived → coverage gap
M7 drop the two-line lock-chain arm 🔴 6 failed killed
M8 un-anchor remote … already exists and drop $ 🟢 69/69 survived → coverage gap
M9 drop the ~/.gitconfig double-slash spelling 🔴 1 failed killed
M10 replace the per-token /etc/gitconfig arm with a whole-message \S* regex 🟢 69/69 survived → equivalent mutant

M2 is the positive control the method requires: it is a single-character deletion of the security property, it landed in the same file as the mutants, and it turned exactly one test red — so "the suite does not cover M6/M8" and "my harness never ran the suite" are distinguishable.

Both survivors were then proven observable rather than left as impressions (h6-m6-and-homoglyphs.mjs, part (a)):

payload head mutant observable?
not a git repository past the 512 cap 500/__bridge__ M6 → 404/not_a_git_repository yes
invalid reference past the 512 cap 500/__bridge__ M6 → 404/not_a_git_repository yes
benign line 1 + error: remote foo already exists. on line 2 409/branch_already_exists M8 → 409/remote_already_exists yes

So M6 and M8 are genuine coverage gaps, not dead code. M10 is classified as an equivalent mutant: the .test() guard I added to it makes the replacement near-semantically-identical, so the row shows only that the per-token form is not pinned. The O(L²) property itself is measured linear by H3 but has no test — a regression to the quadratic form would pass the suite.

Display sanitizer + TR39 skeleton — sibling sweep

The PR's stated threat model is "a .git/config the user did not author (downloaded zip, cloned repo) can carry bidi/zero-width characters in remote names and URLs", closed by two mechanisms: a property arm that strips the Default_Ignorable set, and a TR39 confusables skeleton that flags ink-identical twins. Per the sibling-sweep rule, every shape was asked of both.

h5-sanitizer-sweep.mjs drives the real client sources bundled with esbuild. 33/33.

  • Property arm: 25/28 probed zero-ink shapes are stripped, including every one the PR names (U+202E RLO, U+200F RLM) plus U+200B ZWSP (measured gc=Cf, so \p{Cf} catches it — worth stating because it is not Default_Ignorable-by-itself in every mental model), the whole Tag block, variation selectors, Hangul fillers, and U+2028/U+2029.
  • Committed generated table vs its generator's documented invariants: all 6 564 keys are single code points (the "one Map.get per code point" claim); the prototype closure is fully resolved (no value character is itself a key, so the runtime really is single-pass); every key settles under the 8-pass cap with a measured maximum of 2 outer passes (the comment claims ≤2 — confirmed); no self-entries; the file records confusables.txt 17.0.0, is marked do-not-edit, names its generator, and carries the Unicode License V3 attribution.
  • Search invariant: for every name probed, the displayed text is findable by searching it, and typing the visible characters finds a bidi-laced name.

h6-m6-and-homoglyphs.mjs part (b) then replicates the component's row-marking predicate and calibrates it before trusting it: the replication reproduces the ZWSP twin marking, the whitespace-only twin marking, a lone plain-ASCII remote not marking, three ordinary distinct remotes marking nothing, and the Cyrillic-о homoglyph (the comment's own motivating shape) marking. Witness: 03-homoglyph-sweep-two-gaps.png.

Calibrated, 9 ink-identical pairs were driven through it. 7 mark; 2 do not — see Findings 1 and 2.

Findings

None is blocking. 1 is the sharpest; all four are in the PR's own stated scope.

1. 0rigin beside origin marks neither row — the skeleton fold has no casefold step (Suggestion)

The classic digit-zero / lowercase-o homoglyph sails through, while its siblings 1ist/list, Iink/link and |ink/link are all correctly flagged.

Reproduce (h6-m6-and-homoglyphs.mjs, calibrated replication of the component predicate):

NOT MARKED  digit zero vs lowercase o   [["origin",false],["0rigin",false]]
            skeletons: ["origin","Origin"]
MARKED      digit one vs lowercase L    [["list",true],["1ist",true]]
            skeletons: ["list","list"]

Cause, measured at the table level: remoteNameSkeleton('0rigin') === 'Origin' while remoteNameSkeleton('origin') === 'origin'. confusables.txt folds 0 to the capital O, and remoteNameSkeleton never case-folds, so the two land in different skeletonGroups keys — group.count stays 1 and skeletonCollision is false. No other arm fires either: 0rigin is all-ASCII (so mixedScripts is false, 0 being Script=Common), it sanitizes to itself, and it is NFC-stable. 1l, Il and |l work only because their prototype is the lowercase l, matching the ASCII letter's own prototype.

TR39's skeleton algorithm is NFD → toCasefold → map confusables; the middle step is what makes 0rigin and origin collide. Impact is bounded: this is a display-layer defense-in-depth control, and removal requests still carry the raw configured name, so nothing is silently mis-deleted — but a user staring at two rows reading origin has no signal that one is a digit zero, which is exactly the outcome the panel's marking exists to prevent.

Minimal suggested fix (not measured against the suite — see caveat)

Compare skeletons case-insensitively for grouping while keeping the existing raw-vs-skeleton polarity for deciding which row marks:

const skeleton = remoteNameSkeleton(r.name);
const group = skeletonGroups.get(skeleton.toLowerCase());

keying the map on skeleton.toLowerCase() in the same way.

Caveat, stated rather than hidden: I did not apply and drive this through the suite within budget. It has a real tradeoff — git remote names are case-sensitive, so Origin and origin would begin to collide and both would mark. Given the source's own stated preference ("the arm is script-mixing generally, in the conservative direction"), that is arguably the intended polarity, but the author should decide, and the change needs its own fixture: remoteNameSkeleton('0rigin').toLowerCase() === remoteNameSkeleton('origin').toLowerCase().

2. U+FFFC survives both mechanisms (Nice to have)

OBJECT REPLACEMENT CHARACTER is neither Cc/Cf/Default_Ignorable (measured: gc=So, DICP=false) nor present in confusables.txt, and it is Script=Common, so the mixedScripts arm does not fire either.

NOT MARKED  object replacement char  [["origin",false],["origin",false]]
            skeletons: ["origin","origin"]

Contrast U+2800 BRAILLE PATTERN BLANK, which is also stripped by neither mechanism but is correctly marked — because it is Script=Braille, so mixedScripts catches it. That contrast is the useful part: the script-mixing arm is doing real work, and U+FFFC is the corner it cannot reach. Lower severity than Finding 1 because most fonts render U+FFFC as a visible placeholder box rather than as nothing, so the two rows do not actually present one identity.

3. "Loose keyword branches match the bounded slice" is documented but unpinned (Suggestion)

The source comment states the property explicitly ("an unbounded keyword scan reclassifies a long push/pull output by any path or URL past the cap"), and mutant M6 — switching those two arms from message to fullMessage — survives 69/69. H6 proves it is observable: a payload carrying not a git repository past char 512 flips 500/__bridge__404/not_a_git_repository. The fixture that would go red is exactly that payload; it belongs beside the existing long-lock-line case in the classification table.

4. The remote … already exists arm's line-1 anchoring is unpinned (Suggestion)

Mutant M8 (drop ^ and $) survives 69/69, yet H6 shows a deeper-line injection flipping 409/branch_already_exists409/remote_already_exists. The suite pins the anchoring for No such remote (M2 killed) and for the lock chain (M7 killed) but not for this arm. Same class as Finding 3: the property is real and reasoned about in a comment, and nothing would notice its loss.

Corrections to the PR description

Checked, none needed. The description's "six remote-specific branches" is accurate: six arms are remote-specific (remote_already_exists, no_such_remote, remote_still_configured, remote_section_in_included_file, remote_config_unparsable, remote_shadows_inherited) and the seventh (git_config_write_failed) is not remote-specific — it also covers unset 'branch.. Its disclosure that no live-daemon curl pass was run matches what I could reach (see Not covered).

Not covered

  • Playwright UI e2e (web-shell.git-remotes.spec.ts, 5 specs) and the five screenshots in the description. Not run: no browser in this container. The React rendering, the two-click confirm state machine, the panel↔branch-list navigation and the i18n copy are therefore unverified behaviourally; H5/H6 verify the sanitizer and skeleton logic those components call, not the components.
  • Live-daemon curl pass. Not run. The route layer was verified at the module level (guards read and compared against the sibling branch routes: GET uses the lenient resolveContainedCwd, both POSTs use resolveContainedCwdOrFail → 400, exactly matching lines 826/848/881/914/947/980 of the sibling) and by the PR's own supertest suite, which I ran green — but no request crossed a real socket in this round.
  • Confusables generator re-run. scripts/generate-confusables.mjs fetches https://www.unicode.org/Public/security/latest/confusables.txt; this sandbox has no network. So the committed unicodeConfusables.ts was not byte-compared against a fresh generation, and I cannot say whether it was hand-edited. I substituted an audit of the generator's own documented invariants against the committed table (H5 section E, 8/8), which is weaker: it would not catch a hand-edit that preserved those invariants. What would have calibrated it: a vendored confusables.txt 17.0.0 fixture, or network egress.
  • Per-commit attribution. The snapshot lists 35 commits; the checkout is shallow (git rev-parse --is-shallow-repositorytrue) and git rev-list HEAD^1..HEAD^2 returns 1 — the known-plausible number a shallow boundary produces instead of erroring. Only the merge commit, the base tip and the PR head are reachable, so everything here verifies the aggregate HEAD^1..HEAD diff. No per-commit table is presented.
  • Repo-wide gates. Not run: npm run lint, npm run typecheck, the full test suites, integration-tests, and the eslint-ignore claim that type-aware rules OOM on the generated table (plausible, unmeasured). Targeted workspace suites only — see below.
  • Windows / macOS. The description marks both ⚠️ and defers to CI. All measurements here are Linux/git 2.39.5. The Windows-drive and UNC redaction shapes were exercised only as synthesized payloads, never against real Windows git.
  • Base-side dependency parity. The PR touches no package.json or lockfile, so reusing the root node_modules for the base arm is a clean control; no dependency-aware A/B was needed.
  • docs/design/git-manage-remotes.md / .zh-CN.md bilingual completeness: not audited for structural parity beyond noting both files exist (866 and 182 lines — the size asymmetry suggests the Chinese version is a summary rather than a full translation, which per the house rule is a Suggestion at most and was not pursued).

Targeted gates (run, exact counts)

workspace command result
packages/core vitest run src/utils/git-remotes.test.ts src/utils/git-remotes-kill.test.ts src/utils/git-branches.test.ts 3 files, 352 tests passed
packages/cli vitest run src/serve/routes/workspace-git-remotes.test.ts src/serve/routes/workspace-git-branches.test.ts src/serve/process-env-guard.test.ts 3 files, 108 tests passed
packages/web-shell vitest run client/utils/remote-name-skeleton.test.ts client/utils/unicodeConfusables.test.ts client/components/BranchPickerPopover.test.tsx 3 files, 136 tests passed
packages/sdk-typescript vitest run test/unit/DaemonClient.test.ts 1 file, 446 tests passed

Total 1 042 tests, 0 failures. These are my file selections and are not directly comparable to the counts in the PR description (which names different groupings); no pre-existing failure needed attributing because nothing failed. The mutation-matrix control (69/69 on workspace-git-branches.test.ts) was re-run from a known-clean state immediately before the mutants.

Methodology

Everything ran inside the CI verify container on the merge-ref checkout (HEAD = merge, HEAD^1 = base tip 35a9c5c6, HEAD^2 = head 08f5009a), with npm ci and npm run build already completed by the workflow; I rebuilt nothing except the scratch esbuild bundles named below. Six harnesses (.mjs, kept in this directory so a maintainer can rerun them verbatim) drove compiled or bundled real code against real temporary git repositories and real git subprocesses — no mock of any unit under test. H1 imported packages/core/dist/src/utils/git-remotes.js and took every expectation from an independent git invocation. H2 and H6 used two esbuild bundles of workspace-git-branches.ts (base from git show HEAD^1:, with export added to sendGitError as the sole edit; head from the working tree), both externalizing @qwen-code/qwen-code-core so they share one compiled core — a clean control because findGitRoot lives in the untouched gitUtils.ts, and the workspace symlink realpath was asserted. H4 bundled base and head packages/core/src/utils/git-branches.ts the same way and used a canary file plus git's own stdout echo as two independent witnesses for helper execution. H5 and H6 bundled the real client remote-name-skeleton.ts and unicodeConfusables.ts; H6's replication of the React component's marking predicate was calibrated against five shapes the PR's own suite pins before being trusted with an unpinned one. Every scratch source file was removed and git status --porcelain confirmed the tree clean after each bundle. mutate-classifier.py restored the mutated source byte-identically after every run. Raw per-cell output is in logs/; assertions.json counts only scripted checks that actually executed, and the expected-red base cells are encoded as passing assertions rather than counted as failures.

Flakiness gate log

e2e suite, out of gate scope: packages/web-shell/client/e2e/web-shell.git-mode.spec.ts
e2e suite, out of gate scope: packages/web-shell/client/e2e/web-shell.git-remotes.spec.ts
rounds=5 files=10 skipped=2
file packages/cli/src/serve/process-env-guard.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/process-env-guard.test.ts
file packages/cli/src/serve/routes/workspace-git-branches.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/routes/workspace-git-branches.test.ts
file packages/cli/src/serve/routes/workspace-git-remotes.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/routes/workspace-git-remotes.test.ts
file packages/core/src/utils/git-branches.test.ts: (cd packages/core) npx --no-install vitest run ./src/utils/git-branches.test.ts
file packages/core/src/utils/git-remotes-kill.test.ts: (cd packages/core) npx --no-install vitest run ./src/utils/git-remotes-kill.test.ts
file packages/core/src/utils/git-remotes.test.ts: (cd packages/core) npx --no-install vitest run ./src/utils/git-remotes.test.ts
file packages/sdk-typescript/test/unit/DaemonClient.test.ts: (cd packages/sdk-typescript) npx --no-install vitest run ./test/unit/DaemonClient.test.ts
file packages/web-shell/client/components/BranchPickerPopover.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/BranchPickerPopover.test.tsx
file packages/web-shell/client/utils/remote-name-skeleton.test.ts: (cd packages/web-shell) npx --no-install vitest run ./client/utils/remote-name-skeleton.test.ts
file packages/web-shell/client/utils/unicodeConfusables.test.ts: (cd packages/web-shell) npx --no-install vitest run ./client/utils/unicodeConfusables.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/cli/src/serve/process-env-guard.test.ts: PPPPP
  packages/cli/src/serve/routes/workspace-git-branches.test.ts: PPPPP
  packages/cli/src/serve/routes/workspace-git-remotes.test.ts: PPPPP
  packages/core/src/utils/git-branches.test.ts: PPPPP
  packages/core/src/utils/git-remotes-kill.test.ts: PPPPP
  packages/core/src/utils/git-remotes.test.ts: PPPPP
  packages/sdk-typescript/test/unit/DaemonClient.test.ts: PPPPP
  packages/web-shell/client/components/BranchPickerPopover.test.tsx: PPPPP
  packages/web-shell/client/utils/remote-name-skeleton.test.ts: PPPPP
  packages/web-shell/client/utils/unicodeConfusables.test.ts: PPPPP

verdict: pass
summary: 10 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/cli/src/serve/process-env-guard.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/routes/workspace-git-branches.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/routes/workspace-git-remotes.test.ts: P (exit 0)
round 1 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 1 · packages/core/src/utils/git-remotes-kill.test.ts: P (exit 0)
round 1 · packages/core/src/utils/git-remotes.test.ts: P (exit 0)
round 1 · packages/sdk-typescript/test/unit/DaemonClient.test.ts: P (exit 0)
round 1 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/utils/remote-name-skeleton.test.ts: P (exit 0)
round 1 · packages/web-shell/client/utils/unicodeConfusables.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/process-env-guard.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/routes/workspace-git-branches.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/routes/workspace-git-remotes.test.ts: P (exit 0)
round 2 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 2 · packages/core/src/utils/git-remotes-kill.test.ts: P (exit 0)
round 2 · packages/core/src/utils/git-remotes.test.ts: P (exit 0)
round 2 · packages/sdk-typescript/test/unit/DaemonClient.test.ts: P (exit 0)
round 2 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/utils/remote-name-skeleton.test.ts: P (exit 0)
round 2 · packages/web-shell/client/utils/unicodeConfusables.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/process-env-guard.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/routes/workspace-git-branches.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/routes/workspace-git-remotes.test.ts: P (exit 0)
round 3 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 3 · packages/core/src/utils/git-remotes-kill.test.ts: P (exit 0)
round 3 · packages/core/src/utils/git-remotes.test.ts: P (exit 0)
round 3 · packages/sdk-typescript/test/unit/DaemonClient.test.ts: P (exit 0)
round 3 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/utils/remote-name-skeleton.test.ts: P (exit 0)
round 3 · packages/web-shell/client/utils/unicodeConfusables.test.ts: P (exit 0)
round 4 · packages/cli/src/serve/process-env-guard.test.ts: P (exit 0)
round 4 · packages/cli/src/serve/routes/workspace-git-branches.test.ts: P (exit 0)
round 4 · packages/cli/src/serve/routes/workspace-git-remotes.test.ts: P (exit 0)
round 4 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 4 · packages/core/src/utils/git-remotes-kill.test.ts: P (exit 0)
round 4 · packages/core/src/utils/git-remotes.test.ts: P (exit 0)
round 4 · packages/sdk-typescript/test/unit/DaemonClient.test.ts: P (exit 0)
round 4 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/utils/remote-name-skeleton.test.ts: P (exit 0)
round 4 · packages/web-shell/client/utils/unicodeConfusables.test.ts: P (exit 0)
round 5 · packages/cli/src/serve/process-env-guard.test.ts: P (exit 0)
round 5 · packages/cli/src/serve/routes/workspace-git-branches.test.ts: P (exit 0)
round 5 · packages/cli/src/serve/routes/workspace-git-remotes.test.ts: P (exit 0)
round 5 · packages/core/src/utils/git-branches.test.ts: P (exit 0)
round 5 · packages/core/src/utils/git-remotes-kill.test.ts: P (exit 0)
round 5 · packages/core/src/utils/git-remotes.test.ts: P (exit 0)
round 5 · packages/sdk-typescript/test/unit/DaemonClient.test.ts: P (exit 0)
round 5 · packages/web-shell/client/components/BranchPickerPopover.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/utils/remote-name-skeleton.test.ts: P (exit 0)
round 5 · packages/web-shell/client/utils/unicodeConfusables.test.ts: P (exit 0)

Evidence images

01-classifier-ab-base-vs-head

02-mutation-matrix-7-of-10-killed

03-homoglyph-sweep-two-gaps

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

qqqys
qqqys previously approved these changes Sep 13, 2026
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot's only review on 08f5009adecc7347d92f21a10abbaa974fdc255b is a COMMENTED one, which carries no vote — so it has no verdict of its own on this commit, and main needs two approving reviews: an approval left by another account is a separate vote and does not count as the bot's own. Two different things look like this, and the stage-3 comment above says which: the triage skill deferring on purpose at 3/5 — a fork refactor hitting the approval guardrail, or a core change escalated for maintainer awareness, both normal outcomes — or an earlier approval that a push dismissed, leaving only the comment behind, which needs a fresh review.

⚠️ 机器人在 08f5009adecc7347d92f21a10abbaa974fdc255b 上唯一的评审是 COMMENTED不带票 —— 因此它在该 commit 上没有自己的裁决,而 main 需要两个批准(其他账号的批准是另一张票)。有两种情况长这样,上方的 stage-3 评论会说明是哪一种:triage skill 在 3/5 时有意 defer(fork refactor 命中审批护栏,或核心改动被升级交由维护者把关,两者都是正常结果);或者更早的批准被一次推送作废、只剩下这条评论,此时需要重新评审。

The stage comments above were updated with the latest result. View workflow run.

上方各阶段评论已更新为最新结果。查看工作流运行

@wenshao

wenshao commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Round 2 · Live-daemon + real-browser verification on macOS / git 2.55

Maintainer pass at head 08f5009ad. The first pass was at d1e994f on Linux / git 2.47 / Node 22. This one re-verifies the four fix batches landed since — R11 c62cecf0, R13 bab8e50b, R14 b3aa91eb, R15 5f3752cd + 08f5009a — on a different platform and a much newer git, which is where config-scope, worktree and ref machinery is most likely to diverge.

Chain under test: Chromium → PR web-shell → PR SDK → real HTTP → qwen serve (built from PR source) → real git 2.55.0 → real repositories. No mock daemon, no page.route, no fixtures; every assertion is read back from disk with real git.

Verdict: merge-ready. 38/38 live checks pass; the two [Critical] items round 16 left as "could not reach a verdict" are settled empirically, and an A/B against the pre-R15 build shows both guards are load-bearing (§1). The one red check — the Windows lane — carries 149 failures against main's own 148, and the single PR-owned difference is a timing flake I reproduced deterministically here in one line (§7).

Environment

Head / merge-base 08f5009ade / ddd6eea9dd
Platform macOS 26.6.2 (Darwin 25.6.0), Node 24.18.1, git 2.55.0
Daemon node dist/cli.js serve --port 41631 --workspace <real repos> --require-auth, from npm run build && npm run bundle of the PR tree
UI the daemon's own dist/web-shell (production path, not a dev server), Chromium 1280×900 @2x, ?token=…
A/B arm the same worktree with only packages/core/src/utils/git-remotes.ts reverted to c1fdad2a (pre-R15), fully rebuilt and re-bundled

1. The two findings round 16 could not verdict — settled against real git

R15-1 · git-remotes.ts:1295 R15-2 · git-remotes.ts:1441
Question does remoteTrackingRefs classify a surviving remote's fetch-dest namespace as foreign? whose .git/worktrees entries does sweepSiblingWorktreeKeys write to?
Probe remote.keeper.fetch = +refs/heads/*:refs/remotes/*/main (mid-wildcard dest, git accepts it), so refs/remotes/gone/main is keeper's live ref; gone itself is refspec-less a hand-planted host/.git/worktrees/planted/gitdir → an unrelated repository; git worktree list --porcelain does report it as a sibling
plain git ref survives (no refspec to act on) victim untouched (git never sweeps siblings at all)
pre-R15 c1fdad2a 200 — refs/remotes/gone/main deleted 200 — victim's branch.main.remote unset
this head 200 — ref survives (whole tree foreign: fail closed) 200 — victim untouched (common-dir check precedes any read or write)

Both arms are full rebuilds driven over HTTP; the verdicts are disk reads, not log claims. Genuine siblings keep working in both arms, including one whose path carries a literal newline (§2/F).

2. Namespace ownership and the sibling sweep — 6/6, each against a plain-git control

# Claim plain git (control) this head
A a refspec-less remote's orphaned refs are swept while origin holds the default refspec (the R15-1 regression witness) leaves refs/remotes/noref/main orphaned swept; refs/remotes/origin/main untouched
B refs inside a surviving remote's fetch-dest namespace (keeper → refs/remotes/gone/*) are never swept ref survives ref survives
C a mid-wildcard dest cannot bound its namespace → fail closed ref survives ref survives (pre-R15: deleted)
D a linked worktree's own config.worktree upstream keys naming the removed remote are swept sibling keeps branch.feature.remote = gone, …merge both cleared
E a planted gitdir naming an unrelated repository receives no writes victim untouched victim untouched (pre-R15: unset)
F a sibling path carrying a literal \n and a forged worktree /tmp/fake line is parsed by NUL framing key survives swept correctly, no corruption

3. Live daemon over real HTTP — 18/18

Claim plain git (control) this head
classifier collisions: remove a missing remote named dirty-cache, already exists, not a git repository all three answer 404 no_such_remote — never the legacy keyword branches
newline injection: a fetch value carrying a real LF that forges error: Could not remove config section … git dies after the refspec parse error 409 remote_config_unparsable, section survives, row keeps showing
URL predicate: ext::sh -c id, ::sh -c id (git's empty-helper form), -oProxyCommand=id git remote add accepts all three 400 invalid_remote_url
URL predicate: ssh://git@[::1]:22/x.git, scp-like, a relative path with a space accepted accepted (200)
insteadOf rewriting git remote -v prints https://rewritten.invalid/o/r.git panel shows the configured git@example.invalid:o/r.git
partial clone + push-URL override remote -v annotation is not machine-readable promisor=true, push URL reported separately, both read from config
a remote living in config.worktree git remote remove fails: error: Could not remove config section 'remote.wt', remote survives 200, completed in the scope git cannot write
a section in an include.path'd file git reports success while the include keeps resolving the name listed, then 409 remote_section_in_included_file — the remote genuinely survives
guards ?cwd= escaping → 400 invalid_cwd (mutations), token-less mutation → 401, non-repo → 404 not_a_git_repository
redaction 0 absolute paths across every error body collected in this run

4. Inherited (global) scope — 3/3, the shapes where plain git is silently wrong

Claim plain git (control) this head
a remote defined in the user's global config git remote lists shadow as if it were the repo's not listed — the repository's own scope only
adding a same-name remote over it git remote add exits 0, and git then resolves two URLs 409 remote_shadows_inherited, nothing written locally
removing a name that exists locally and inherited git remote remove exits 0 while the name still resolves to the global URL 409, local section left intact rather than half-destroyed

5. Real browser → real daemon → real .git/config — 11/11

Manage Remotes… in the live picker The panel, listing the real repository
Add — verified on disk with git config --get Duplicate add: git's own message, nothing mutated
Two-click confirm armed (zero requests so far) A remote deleted behind the panel
  • Panel rows matched git remote exactly, before and after every mutation.
  • The first remove click issued zero HTTP requests and left the repository untouched; the second removed it from .git/config.
  • The failed duplicate add left remote.fork.url single-valued.
  • The race (a terminal git remote remove racer behind the open panel, then confirming its removal) surfaced git's real error: No such remote: 'racer' and the panel re-read the list instead of keeping an undeletable row.

New since round 1 — the R15-3/R15-4 confusable fold, and it works in the real UI:

Istanbul vs lstanbul: searching one finds both A bidi-override name: stripped, flagged, still removable
  • Typing Istanbul matched both rows and both carry (lookalike name) — the TR39 fold is case-folded in the UI, not only in unit tests.
  • The U+202E-bearing row renders as evliat (hidden characters); the raw code point never reaches the DOM, and the remove request still carried the exact configured name on the wire (65 76 202e 6c 69 61 74), which real git accepted.
  • zh-CN is complete:

6. Gates re-run locally at this head (macOS, Node 24.18.1, git 2.55.0)

Suite Result
core git-remotes + git-remotes-kill + git-branches 352/352
cli workspace-git-remotes + workspace-git-branches + process-env-guard 108/108
sdk-typescript DaemonClient 435/435
web-shell BranchPickerPopover + remote-name-skeleton + unicodeConfusables 136/136
web-shell e2e web-shell.git-remotes.spec.ts (Playwright) 5/5

Round 1 flagged one git-branches failure caused by git 2.47's wording; on git 2.55 it passes, so that note is retired.

7. The red Windows check is not this PR — and the one PR-owned difference is a timing flake

Test (windows-latest, Node 22.x) on the dispatch run for this head fails. Measured against main's own nightly (run 34713863264, b5c7635ff, same self-hosted Windows runner):

failures files
main nightly 148 47
this head 149 47

The file sets are otherwise identical (code-mode 24, scheduler 13, monitor-debug-store 12, git-branches 8, … in both). Only two files differ, one in each direction, and exactly one belongs to this PR:

BranchPickerPopover.test.tsx > resets the remotes view and restores no focus after a workspace switch

That test is byte-identical at b3aa91ebb, where the same Windows lane passed it — so it is not a new defect at this head. Cause, reproduced deterministically on macOS: the popover's own mount-time setTimeout(() => searchRef.current?.focus(), 50) (BranchPickerPopover.tsx:605 — pre-existing on main, untouched by this PR) lands inside the assertion window when the runner spends >50 ms between sampling activeBefore and the settle. Adding one line to the test reproduces the CI message byte-for-byte:

     const activeBefore = document.activeElement;
+    await act(async () => { await new Promise((r) => setTimeout(r, 80)); });
     await act(async () => { release?.({ v: 1, workspaceCwd: '/repo2', remotes: [] }); });
AssertionError: expected <input …(3)></input> to be <body><div>…(2)</div></body>

Moving that same delay above the activeBefore sample turns it green again, and the test passed 20/20 unmodified here. Suggested fix (test-side, one line): flush past the 50 ms open-autofocus before sampling activeBefore. Not a product defect — the popover autofocusing its search box 50 ms after opening is the intended pre-existing behaviour.

8. What is actually gating this merge

  • All 178 review threads are resolved (GraphQL, paginated) — zero unresolved.
  • The blocking CHANGES_REQUESTED is anchored at 5ad579c97 (2026-09-13 04:33), i.e. two fix batches behind the current head; the bot's only review at 08f5009ad is COMMENTED, which carries no vote.
  • Required lanes at head are green: Test (ubuntu-latest), Lint & Static, web-shell E2E Smoke, Serve A/B, Real daemon E2E, the Java matrix; Test (macos-latest) passed on the dispatch run.
  • So the remaining gate is human approval, not code.

9. Non-blocking observations

  • N1 (carried from round 1's N3, unchanged). The test plan's item 9 says "?cwd= escaping the workspace → 400". True for the two mutations; the GET route is deliberately lenient and falls back to the workspace root (measured again here). Wording only.
  • N2. The mid-wildcard fail-closed rule (§2/C) means a removed remote's own orphan refs are also left behind in such a repo, and the removal still answers 200 — the phantom group stays listed. This is the documented trade (docs/design/git-manage-remotes.md:344-348, "residue stays as cache — the safe polarity") and I confirmed it behaves as written; noting it only so the doc sentence and the observed behaviour are known to match.
  • N3. The §7 flake is worth the one-line test fix before the next Windows dispatch, otherwise the lane keeps carrying a red that looks PR-owned.
  • N4 — independent confirmation of the sandboxed verification's finding 1, with one refinement. The qwen-triage sandbox run posted while this pass was underway; its 287/287 result and mine agree, and I re-tested its sharpest item in the live UI. 0rigin beside origin: neither row is flagged, because the skeleton groups by a case-sensitive fold (0 folds to O, so skeleton('0rigin') = "Origin"skeleton('origin')). The refinement: the search half already handles it — R15-3 folds the needle in three case forms, so typing origin does surface 0rigin. Only the row-marking path misses the casefold, which narrows the fix to remoteSkeletonGroups rather than the fold itself.

中文说明

第 2 轮 · 在 macOS / git 2.55 上对 live daemon 与真实浏览器的验证

维护者在 head 08f5009ad 上的验证。第 1 轮d1e994f、Linux / git 2.47 / Node 22 上完成;本轮针对此后合入的四个修复批次——R11 c62cecf0、R13 bab8e50b、R14 b3aa91eb、R15 5f3752cd + 08f5009a——换到另一个平台和高得多的 git 版本复验,而配置作用域、worktree 与 ref 机制恰恰最容易在这里出现差异。

受测链路:**Chromium → PR 的 web-shell → PR 的 SDK → 真实 HTTP → qwen serve(由 PR 源码构建)→ 真实 git 2.55.0 → 真实仓库。**无 mock daemon、无 page.route、无夹具;每条断言都用真实 git 回读磁盘。

**结论:可以合入。*38/38 项实测检查通过;第 16 轮评审标记为"无法得出裁决"*的两个 [Critical] 已用实测settled,并通过与 pre-R15 构建的 A/B 证明这两个守卫确实起作用(§1)。唯一的红色检查是 Windows 通道:它有 149 个失败,而 main 自身是 148 个,唯一属于本 PR 的那一条,我在本地用一行代码确定性复现为计时抖动(§7)。

环境

Head / merge-base 08f5009ade / ddd6eea9dd
平台 macOS 26.6.2(Darwin 25.6.0)、Node 24.18.1、git 2.55.0
Daemon node dist/cli.js serve --port 41631 --workspace <真实仓库> --require-auth,由 PR 树 npm run build && npm run bundle 产出
UI daemon 自带的 dist/web-shell(生产路径,非 dev server),Chromium 1280×900 @2x?token=…
A/B 臂 同一棵树,packages/core/src/utils/git-remotes.ts 回退到 c1fdad2a(pre-R15),完整重新构建并重新打包

1. 第 16 轮无法裁决的两条 Critical —— 用真实 git settled

R15-1 · git-remotes.ts:1295 R15-2 · git-remotes.ts:1441
问题 remoteTrackingRefs 是否会把存活 remote 的 fetch 目标命名空间判为 foreign? sweepSiblingWorktreeKeys 会往谁的 .git/worktrees 条目写?
探针 remote.keeper.fetch = +refs/heads/*:refs/remotes/*/main(中间带通配的 dest,git 接受),于是 refs/remotes/gone/mainkeeper 的活引用;而 gone 本身无 refspec 手工植入 host/.git/worktrees/planted/gitdir → 指向无关仓库git worktree list --porcelain 确实把它当作 sibling 列出
plain git 引用存活(没有 refspec 可作用) victim 不受影响(git 根本不扫 sibling)
pre-R15 c1fdad2a 200 —— refs/remotes/gone/main 被删除 200 —— victim 的 branch.main.remote 被 unset
当前 head 200 —— 引用存活(整棵树判为 foreign:fail closed) 200 —— victim 不受影响(common-dir 校验先于任何读写)

两臂都是完整重建并经 HTTP 驱动;裁决来自磁盘回读,而不是日志声明。两臂里真实 sibling 都照常工作,包括路径中带真实换行的那一个(§2/F)。

2. 命名空间归属与 sibling 扫除 —— 6/6,每条都配 plain git 对照

# 主张 plain git(对照) 当前 head
A origin 持有默认 refspec 的仓库里,无 refspec 的 remote 被删后其孤儿引用仍被清扫(R15-1 回归见证) 留下孤儿 refs/remotes/noref/main 已清扫;refs/remotes/origin/main 不受影响
B 存活 remote 的 fetch dest 命名空间(keeper → refs/remotes/gone/*)内的引用绝不清扫 引用存活 引用存活
C 中间带通配的 dest 无法界定命名空间 → fail closed 引用存活 引用存活(pre-R15:被删除
D linked worktree 自身 config.worktree 中指向被删 remote 的 upstream 键被清扫 sibling 仍留 branch.feature.remote = gone…merge 两个键都已清除
E 指向无关仓库的植入 gitdir 不会被写入 victim 不受影响 victim 不受影响(pre-R15:被 unset
F sibling 路径带真实 \n 伪造了一行 worktree /tmp/fake 时,按 NUL 分帧解析 键存活 正确清扫,无错乱

3. Live daemon 真实 HTTP —— 18/18

主张 plain git(对照) 当前 head
分类器碰撞:删除名为 dirty-cachealready existsnot a git repository 的不存在 remote 三者都返回 404 no_such_remote,绝不落入遗留关键字分支
换行注入:fetch 值携带真实 LF 伪造 error: Could not remove config section … git 在 refspec 解析错误后死掉 409 remote_config_unparsable,section 存活,行仍显示
URL 谓词:ext::sh -c id::sh -c id(git 的空 helper 形态)、-oProxyCommand=id git remote add 三者全收 400 invalid_remote_url
URL 谓词:ssh://git@[::1]:22/x.git、scp 形式、含空格的相对路径 接受 接受(200)
insteadOf 改写 git remote -v 显示 https://rewritten.invalid/o/r.git 面板显示配置中git@example.invalid:o/r.git
partial clone + push URL 覆盖 remote -v 的注解无法机读 promisor=true、push URL 单独上报,均读自配置
位于 config.worktree 的 remote git remote remove 失败error: Could not remove config section 'remote.wt',remote 存活 200,在 git 写不了的作用域完成删除
include.path 文件中的 section git 报成功,而 include 仍让该名字解析成功 列出,随后 409 remote_section_in_included_file —— remote 确实存活
守卫 ?cwd= 逃逸 → 变更类 400 invalid_cwd,无 token 变更 → 401,非仓库 → 404 not_a_git_repository
脱敏 本轮收集到的所有错误响应体中,绝对路径 0 次

4. 继承(global)作用域 —— 3/3,plain git 在这些形态上是静默错误的

主张 plain git(对照) 当前 head
用户 global 配置里定义的 remote git remoteshadow 当作本仓库的列出 不列出 —— 只列仓库自有作用域
在其之上添加同名 remote git remote add 退出码 0,此后 git 解析出两个 URL 409 remote_shadows_inherited,本地未写入任何内容
删除一个本地与继承作用域都存在的名字 git remote remove 退出码 0,而该名字仍解析到 global URL 409,本地 section 完整保留,而不是被删掉一半

5. 真实浏览器 → 真实 daemon → 真实 .git/config —— 11/11

实时分支弹层中的 Manage Remotes… 面板列出真实仓库的 remotes
添加 —— 用 git config --get 在磁盘上核对 重复添加:git 自己的报错,且未产生任何改动
两段式确认已武装(此时零请求) 面板背后 remote 被删除的竞态
  • 每次变更前后,面板的行与 git remote 完全一致。
  • 第一次点击删除发出了个 HTTP 请求、仓库未被改动;第二次才从 .git/config 中删除。
  • 失败的重复添加没有把 remote.fork.url 变成多值。
  • 竞态(面板开着时在终端 git remote remove racer,再确认删除)显示 git 真实的 error: No such remote: 'racer',面板重新拉取列表,而不是留下一个删不掉的行。

相对第 1 轮的新内容 —— R15-3/R15-4 的易混字符折叠,在真实 UI 上成立:

Istanbullstanbul:搜其一,两行都命中 bidi 覆盖名:剥离、标记,仍可删除
  • 输入 Istanbul 命中两行,且两行都带 (lookalike name) —— TR39 折叠在 UI 里也做了大小写处理,不只是单测里成立。
  • U+202E 的行渲染为 evliat (hidden characters);原始码点从未进入 DOM,而删除请求在链路上仍携带配置中的原始名字(65 76 202e 6c 69 61 74),真实 git 也接受了它。
  • zh-CN 完整:

6. 在当前 head 本地复跑的门禁(macOS、Node 24.18.1、git 2.55.0)

套件 结果
core git-remotes + git-remotes-kill + git-branches 352/352
cli workspace-git-remotes + workspace-git-branches + process-env-guard 108/108
sdk-typescript DaemonClient 435/435
web-shell BranchPickerPopover + remote-name-skeleton + unicodeConfusables 136/136
web-shell e2e web-shell.git-remotes.spec.ts(Playwright) 5/5

第 1 轮记录的那个 git-branches 失败源于 git 2.47 的措辞;在 git 2.55 上已通过,该注记可以撤下。

7. 红色的 Windows 检查不是本 PR 引入 —— 唯一属于本 PR 的差异是计时抖动

本 head 的 dispatch 运行里 Test (windows-latest, Node 22.x) 失败。与 main 自己的 nightly(运行 34713863264b5c7635ff,同一台 self-hosted Windows runner)对比:

失败数 文件数
main nightly 148 47
当前 head 149 47

两边的文件集合在其余部分完全一致(code-mode 24、scheduler 13、monitor-debug-store 12、git-branches 8 … 两边相同)。只有两个文件不同,一边一个,其中只有一个属于本 PR:

BranchPickerPopover.test.tsx > resets the remotes view and restores no focus after a workspace switch

该测试在 b3aa91ebb 上逐字节相同,而同一条 Windows 通道当时是通过的——所以不是当前 head 新引入的缺陷。成因已在 macOS 上确定性复现:弹层自身挂载时的 setTimeout(() => searchRef.current?.focus(), 50)BranchPickerPopover.tsx:605main 上既有、本 PR 未触碰)会在 runner 于「采样 activeBefore」与「settle」之间花费 >50 ms 时落进断言窗口。给测试加一行即可逐字节复现 CI 的报错:

     const activeBefore = document.activeElement;
+    await act(async () => { await new Promise((r) => setTimeout(r, 80)); });
     await act(async () => { release?.({ v: 1, workspaceCwd: '/repo2', remotes: [] }); });
AssertionError: expected <input …(3)></input> to be <body><div>…(2)</div></body>

把同样的延时挪到 activeBefore 采样之前,测试即恢复绿色;未改动的原测试在本机 20/20 通过。建议修法(测试侧,一行):在采样 activeBefore 前先冲过那 50 ms 的 open-autofocus。这不是产品缺陷——弹层在打开 50 ms 后自动聚焦搜索框是既有的预期行为。

8. 真正卡住合并的是什么

  • 178 条评审线程全部已解决(GraphQL 分页核对)—— 未解决 0 条。
  • 阻塞的 CHANGES_REQUESTED 锚定在 5ad579c97(2026-09-13 04:33),也就是比当前 head 落后两个修复批次;机器人在 08f5009ad 上唯一的评审是 COMMENTED,不带票。
  • head 上的必需通道全绿:Test (ubuntu-latest)Lint & Staticweb-shell E2E SmokeServe A/BReal daemon E2E、Java 矩阵;Test (macos-latest) 在 dispatch 运行中通过。
  • 因此剩下的门是人工批准,不是代码。

9. 非阻塞观察

  • **N1(沿用第 1 轮的 N3,未变)。**测试计划第 9 条写「?cwd= 逃逸 workspace → 400」。对两个变更类接口成立;GET 路由是刻意宽松的,会回退到 workspace 根(本轮再次实测)。只是措辞问题。
  • **N2。**中间带通配的 fail-closed 规则(§2/C)意味着在这类仓库里,被删 remote 自己的孤儿引用也会被留下,而删除仍返回 200——幻影分组会继续列出。这是文档化的取舍(docs/design/git-manage-remotes.md:344-348"residue stays as cache — the safe polarity"),我确认行为与文字一致;在此记录只是为了让文档句子与实测行为的一致性有据可查。
  • N3。§7 的抖动值得在下一次 Windows dispatch 前用那一行测试修法处理掉,否则该通道会一直带着一个看起来属于本 PR 的红。
  • N4 —— 独立印证沙箱验证的 finding 1,并补一个细化。qwen-triage 的沙箱运行在本轮进行期间发布,其 287/287 的结论与本轮一致;我在真实 UI 里复测了它最锐利的那一条。0riginorigin 并列时:两行都没有被标记,因为分组用的 skeleton 是区分大小写的折叠(0 折叠为 O,于是 skeleton('0rigin') = "Origin"skeleton('origin'))。细化之处在于:搜索那一半已经处理了 —— R15-3 会把 needle 折叠成三种大小写形态,所以输入 origin 确实能搜出 0rigin。漏掉 casefold 的只有行标记这条路径,因此修法可以收敛到 remoteSkeletonGroups,而不必动折叠本身。

@ytahdn ytahdn 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.

PR 主旨 / What this PR does

这个 PR 给 web-shell 加了一套「管理 git remote」的能力:在分支/远程面板里列出、新增、删除远程,底层走 workspace 作用域的 daemon 路由,核心 helper 用 git config --list --show-scope -z(NUL 分帧)读配置并做作用域过滤。安全模型是「显示脱敏、请求原样」——每个渲染边界剥离 Default_Ignorable/bidi/零宽字符,add/remove 载荷带原始名;并用 TR39 confusables skeleton 折叠做同形碰撞检测,git argv 一律以 -- 收尾防参数注入。删除远程走「认证式」清理:先删 [remote] 段,再清扫遗留的 tracking refs / upstream key,最后用解析器复核,宁可拒绝也不谎报一个 git 仍能解析的远程已删除。

This PR adds git-remote management to the web-shell (list / add / remove) over workspace-scoped daemon routes, with a core helper that reads config via git config --list --show-scope -z (NUL-framed) and filters by scope. The security model is "display sanitized, request raw" — Default_Ignorable/bidi/zero-width stripped at every render boundary, raw name in add/remove payloads — plus a TR39 confusables-skeleton fold for homoglyph-collision detection, and ---terminated git argv to block argument injection. Removal is certifying: delete the [remote] section, sweep leftover tracking refs / upstream keys, then re-verify by resolution, preferring to refuse over certifying a remote git would still resolve.

审查方式:纯静态、逐 hunk 对照 head tree(08f5009),未运行测试/构建。CI 实质 lane 全绿,qqqys 已在同一 head approve。round-16 机器人在降级模式下把下面两条留作 Critical 未定论;我独立复核后给出校准过的结论。
Review method: static only, every hunk cross-checked against the head tree (08f5009); no tests/builds run. CI's substantive lanes are green and qqqys approved at this same head. The round-16 bot left the two items below as unverified Criticals in degraded mode; I re-checked both independently and give a calibrated verdict.


🟡 Important (1) — 删除认证的解析器腿只看 fetch 侧,漏了 pushInsteadOf 的 push 侧孪生 / removal certification's resolver leg is fetch-side only, missing the push-side pushInsteadOf twin

packages/core/src/utils/git-remotes.ts

主路径的「已删除名」认证门(:606-607)是:

if (
  (await remoteSectionScopes(cwd, name, env)).size > 0 ||
  (await remoteStillResolves(cwd, name, env))   // 只有 fetch 侧 ls-remote --get-url
) {
  throw new Error('remote still configured after removal');
}

remoteStillResolves(:817)走的是 ls-remote --get-url -- name,只解析 fetch 侧。而同一个文件里的 converge 分支(:504-528)在决定是否清扫时,显式加了一条 push 侧守卫:

!(await pushInsteadOfAliases(cwd, env)).some((alias) => name.startsWith(alias))   // :526

pushInsteadOfAliases(:881)全仓只被 :526 这一处调用;:606-607 的认证门、以及其后的 deleteRemoteTrackingRefs / unsetUpstreamKeys / sweptUpstreamResolving(:619-638)都不覆盖 push 侧解析。后果:当配置里存在一条 url.<base>.pushInsteadOf = <prefix> 且被删名 startsWith(prefix) 时,[remote "name"] 段确实被删掉了,认证门也放行「已删除」,但 git push name 仍会经 pushInsteadOf 别名解析到一个 URL——正是本模块 :593-604 注释里说要拦的那类「面板说删了、fetch/push 却还能到达」的状态。这就是 insteadOf(fetch 侧,remoteStillResolves 能抓到并拒绝)与 pushInsteadOf(push 侧,认证门放行)之间的平行兄弟成员不对称守卫

建议:给认证门补上 converge 分支已有的同一条 push 侧腿:

if (
  (await remoteSectionScopes(cwd, name, env)).size > 0 ||
  (await remoteStillResolves(cwd, name, env)) ||
  (await pushInsteadOfAliases(cwd, env)).some((alias) => name.startsWith(alias))
) {
  throw new Error('remote still configured after removal');
}

诚实的另一面:pushInsteadOf 是用户独立配置的全局 URL 重写规则,不属于 [remote] 段本身;把「段已删除」判为完成、把别名重写视为正交配置,也讲得通——这很可能就是本 head 已被 approve 的原因。但按本模块自己的教义(「宁可拒绝也不认证一个 git 仍能解析的状态」)与 converge 分支的既有处理,两条腿应当对称,所以我按 Important(而非 Critical)提出,触发面窄(需要一条前缀命中被删名的 pushInsteadOf 别名)。

The main-path "removed name" certification gate (:606-607) checks remoteSectionScopes(...).size > 0 || remoteStillResolves(...). remoteStillResolves (:817) runs ls-remote --get-url -- namefetch-side only. But the converge arm in the same file (:504-528) explicitly adds a push-side guard before deciding to sweep: !(await pushInsteadOfAliases(cwd, env)).some((alias) => name.startsWith(alias)) (:526). pushInsteadOfAliases (:881) is called from :526 and nowhere else; neither the :606-607 gate nor the later deleteRemoteTrackingRefs/unsetUpstreamKeys/sweptUpstreamResolving (:619-638) covers push-side resolution. Consequence: when config holds a url.<base>.pushInsteadOf = <prefix> and the removed name startsWith(prefix), the [remote "name"] section is genuinely deleted and the gate certifies "removed", yet git push name still resolves through the alias — exactly the "panel said removed, fetch/push still reach it" state this module's own comment (:593-604) says it refuses. This is an asymmetric sibling guard between insteadOf (fetch-side, caught by remoteStillResolves) and pushInsteadOf (push-side, certified past). Suggested fix: add the converge arm's push-side leg to the certification gate (snippet above). Honest counterpoint: pushInsteadOf is orthogonal global user config, not part of the [remote] section, so treating the section removal as complete is defensible — which likely explains the existing approval. I raise it as Important (not Critical) given the module's own doctrine and the narrow trigger (requires an alias whose prefix matches the removed name).


🟡 Important (2) — 路径脱敏的 truncatedKeys 臂重新引入了邻居臂刚消除的 O(L²) 同步正则 / the truncatedKeys redaction arm reintroduces the O(L²) sync regex its neighbor arm just removed

packages/core/src/utils/workspace-git-branches.ts

:55-63 的 truncatedKeys 臂:

for (const key of externals.truncatedKeys) {
  message = message.replace(
    new RegExp(`${escapeRegExp(key)}\\S*`, 'g'),   // :60 无界 \S* 前缀
    '<workspace>',
  );
}

紧挨着的 :100-103 那条 /etc/gitconfig 臂,本 PR 里被特意改写成按 token 处理,并留了明确注释(:94-99):

Decide per whitespace-delimited TOKEN, not with an unbounded \S* prefix: one long whitespace-free run in the payload (a rejected push's sideband data bypasses git's vreportf cap) would otherwise cost O(L²) of synchronous CPU on the daemon's single event loop, before the 512-char slice ever applies.

也就是说作者已经认识到 \S* 前缀在长无空白串上的同步 CPU 代价,并在下面一条臂里消除了它;而上面的 truncatedKeys 臂(本 PR 新增代码)又把同样的 escapeRegExp(key) + '\S*' 无界形态放了回来。truncatedKeys 来自 workspace 侧(.git-as-file 布局里的 gitdir/commondir 行,可达数千字节),redactGitPaths 在 daemon 的单线程事件循环上同步跑,一旦 payload 里出现长无空白串(如被拒 push 的 sideband 数据)就会阻塞所有会话。触发面窄(需要 .git-as-file 布局产生 truncatedKeys + 错误消息里有长无空白串),但这是共享 daemon 上的同步停顿,且与相邻臂刚立下的规约自相矛盾。

建议:沿用邻居臂的 token 习语,或对 key 长度设上限,避免 \S* 在整条未切片消息上回溯。

The truncatedKeys arm at :55-63 uses new RegExp(\${escapeRegExp(key)}\S*`, 'g')— an unbounded\S*prefix. The immediately-adjacent/etc/gitconfigarm (:100-103) was *deliberately* rewritten in this PR to be token-based, with an explicit comment (:94-99) that the\S*form "would otherwise cost O(L²) of synchronous CPU on the daemon's single event loop, before the 512-char slice ever applies." So the author already recognized the sync-CPU hazard of an unbounded\S*prefix and removed it one arm below — but the truncatedKeys arm (new code in this PR) reintroduces the same shape.truncatedKeysis workspace-controlled (gitdir/commondir lines from a.git-as-file layout, up to several KB), and redactGitPathsruns synchronously on the daemon's single-threaded event loop, so a long whitespace-free run in the payload (e.g. a rejected push's sideband data) can stall **all** sessions. The trigger is narrow, but it's a synchronous stall on a shared daemon and contradicts the convention the neighboring arm just established. Suggested fix: use the neighbor's token idiom, or cap the key length, so\S*` can't backtrack over the whole unsliced message.


🔵 Low-confidence — skeleton 折叠的合流性 / confluence of the skeleton fold

packages/web-shell/client/utils/remote-name-skeleton.ts:36

机器人(R15-4)主张:收尾的 NFC(:60)会跨 key 边界重新组合,使折叠「取决于到达拼写」而非规范等价类,从而把同一 ink 类拆成两个碰撞组;生成器方程 value === runtimeFold(key.normalize('NFD')) 只对孤立 key 成立。

我复核后降级为低置信度、非阻塞:该函数是迭代到不动点(最多 8 遍,:37),每遍都重新处理上一遍的 NFC 输出,文件注释(:26-35)恰恰举了跨边界重组的例子(0→O,NFC 组合成 Ő,其 prototype 是 Ö)来说明为什么需要多遍;生成器还会丢弃任何会循环而非收敛的条目,实测「≤2 额外遍」即收敛。因此在已提交的表内,precomposed / decomposed 两种拼写会在不动点处相遇。要把它升为阻塞项,需要一个具体的发散 witness 对(两个渲染相同、在 8 遍内落到不同 skeleton 的名字)——目前只有理论论证,没有 witness;且影响面仅限次级同形碰撞告警,主脱敏在 UI 每个渲染边界已独立核实存在。

The bot (R15-4) argues the closing NFC (:60) recomposes across key boundaries, making the fold depend on arrival spelling rather than the canonical equivalence class and splitting one ink class into two collision groups. I downgrade this to low-confidence / non-blocking: the function iterates to a fixed point (up to 8 passes, :37), re-processing the previous pass's NFC output each time, and the file's own comment (:26-35) uses exactly a cross-boundary recomposition example (0→O, NFC composes Ő, whose prototype is Ö) to justify the multi-pass design; the generator also drops any entry that would cycle rather than settle, with measured convergence "≤2 extra passes." So within the committed table, precomposed and decomposed spellings meet at the fixed point. Escalating this to blocking needs a concrete divergent witness pair (two identically-rendering names that land in different skeletons within 8 passes) — only a theoretical argument exists today, and the blast radius is a secondary homoglyph-collision warning, since the primary sanitization is independently verified present at every UI render boundary.


已核实干净 / Verified clean

  • UI 脱敏层BranchPickerPopover.tsx +1289/-248、i18n.tsx、css):12 个 DOM 边界全部经 sanitizeRemoteDisplay / sanitizeStatusText / remoteExtras 脱敏;请求载荷带原始名(remoteName.trim() / remoteUrl.trim(),remove 用原始 r.name);脱敏正则含 \p{Cc}\p{Cf}\p{Default_Ignorable}\u2028\u2029;无 dangerouslySetInnerHTML / href / src 注入面;两步确认无法粘滞/绕过/双触发;26 个新 i18n key 中英齐全;248 行删除是 branches 视图 JSX 重排缩进,未丢守卫。
    UI sanitization layer: all 12 DOM boundaries sanitized; requests carry the raw name; no dangerouslySetInnerHTML/href/src injection surface; two-click confirm cannot stick/bypass/double-fire; all 26 new i18n keys present in EN+ZH; the 248 removed lines are re-indented branches-view JSX with no guard dropped.
  • 参数注入:所有用户可控值都以 -- 收尾——remote add -- name url(:410)、remote remove -- name(:453)、ls-remote --get-url -- name(:823)、ls-remote -- name(:863)、ls-remote --get-url -- value(:925)。
    Argument injection: every user-controlled value is ---terminated.

结论 / Verdict

🔄 COMMENT(不批准)。两条独立复核为真的 Important:(1) 删除认证的 push 侧不对称守卫,(2) 路径脱敏 truncatedKeys 臂的同步 CPU 回归。二者触发面都窄,且 (1) 有正当的「正交配置」反驳——这解释了本 head 已有的 approve;但按本模块自订教义与相邻代码规约,它们值得作者确认或显式记为可接受残留。R15-4 我降级为低置信度、需具体 witness。UI 层、参数注入、CI 均干净。

🔄 COMMENT (not approving). Two independently-verified Importants: (1) the push-side asymmetric guard in removal certification, (2) the sync-CPU regression in the truncatedKeys redaction arm. Both have narrow triggers, and (1) has a legitimate "orthogonal config" counter-argument — which explains the existing approval at this head — but per the module's own doctrine and the neighboring-code convention they warrant an author fix or an explicit accepted-residual note. R15-4 I downgrade to low-confidence pending a concrete witness. The UI layer, argument injection, and CI are clean.

@ytahdn ytahdn 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.

(duplicate of my review 5191128802 posted seconds earlier — a retry double-fired; the full findings are there. / 重复评论,内容见前一条 review 5191128802,此条为重试误发。)

Sorry for the noise.

… entry

Main's context-overview rework removed the sidebar git pill and the
welcome-view branch chip this spec opened the picker through; the
merge-head smoke run failed waiting for a pill that no longer renders.
The spec now enters the scenario session and opens the picker from the
environment panel's branch row — the same route the branch-picker
visual spec uses — keeping every assertion unchanged.

@qqqys qqqys 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.

Critical-only review pass at head 789b42b92a3d73033534f6e28204954c4cd6aec0. Not approving: two Critical findings filed against the previous commit still stand at this head, because neither file changed after they were filed. One is confirmed in the head source below; the other is recorded as unconfirmed. The pass then stopped widening, so most of this surface was not audited to depth.

Standing: the removed-name certification resolves fetch-side only

packages/core/src/utils/git-remotes.ts, remoteStillResolves at :811-833 at this head:

// Whether git still RESOLVES the name after the section is gone — the
// certification the config-record checks cannot give ...
async function remoteStillResolves(cwd, name, env?) {
  try {
    const out = await runGit(cwd, ['ls-remote', '--get-url', '--', name], env);
    return (out.endsWith('\n') ? out.slice(0, -1) : out) !== name;
  } catch (err) { stripConfigDump(err); throw err; }
}

ls-remote --get-url answers the fetch side: it applies url.<base>.insteadOf and never consults url.<base>.pushInsteadOf, which is the rewrite git uses when pushing. So a removal whose name a pushInsteadOf alias still resolves push-side is certified here as "git no longer resolves this name at all", and the push twin of the alias survives a removal the panel reports as complete and verified. That is the exact failure this PR's own design says it refuses - the description states removal is verified rather than trusted and that the route answers 409 remote_still_configured "rather than certifying a state git would still resolve" - so the certification leg is the load-bearing one and it is half as wide as the claim it backs.

The comment block is explicit that this function exists to give "the certification the config-record checks cannot give" and to fail closed on any failure; the gap is not error handling but scope, and a push-side leg (git config --get-urlmatch over url.*.pushInsteadOf for the name, or the equivalent ls-remote push query) is what closes it.

This was filed against the previous commit and the file is unchanged since: the only author commits between that review and this head are Merge remote-tracking branch 'origin/main' and test(web-shell): route the remotes e2e through the post-#11700 picker entry, and the only file either touches in this area is packages/web-shell/client/e2e/web-shell.git-remotes.spec.ts.

Standing, unconfirmed: the confusable fold decides on arrival spelling

packages/web-shell/client/utils/remote-name-skeleton.ts:26-67 is likewise unchanged since the finding was filed. Reading it at head: the fold iterates to a fixed point under an 8-pass cap, consults CONFUSABLE_PROTOTYPES on the arrival code point first, then walks NFD parts with their own table chance, then NFKD, and NFC-closes at the end of each pass. The finding's claim is that the result is still not a function of the canonical equivalence class, so two names that render identically can take different skeletons and split one identity in the lookalike marker.

I could not settle that within this pass, and the code itself concedes the underlying question - "The cap guarantees termination (confluence is not proven)". What I can say is that nothing changed to answer it, so it is recorded as standing-unconfirmed rather than cleared. A test over the paired spellings the finding names (i\u0146fra / in\u0326fra, \u1E9B and its parts, a precomposed/decomposed pair whose composed form is itself a table key) would settle it in one direction or the other.

Gates this pass did not complete

All 178 review threads are flagged resolved, including 30 Critical threads from earlier rounds; those flags are not treated as evidence and I did not re-derive them. Once the two standing items were established the pass stopped, so the following were not audited to depth - which matters here because the change is 20,938 added lines across 33 files and three of its surfaces are security-relevant:

  • The three new workspace-scoped daemon routes and their guard parity with the sibling branch routes: untrusted workspace, closed workspace generation, ?cwd= escape, non-repository, and the rule that error bodies never carry absolute paths.
  • The shared git error classifier's six new remote-specific branches, which the description itself identifies as the main risk: they are matched ahead of every legacy keyword branch on untruncated detail, where a config-chosen remote name can carry any keyword and a config-chosen value can carry a real newline.
  • The redaction of gitdir paths outside the cwd tree - a linked worktree's shared main .git, a submodule gitdir, symlink-canonical spellings - parsed from the .git file under head-bounded reads.
  • The removal converge path: the worktree-scope half git cannot edit, the upstream-key sweep for pointing branches, orphaned refs/remotes/<name>/* after a refspec-less removal, and the inherited same-name survivor refusal.
  • The bidi and zero-width sanitization at each render boundary and its interaction with the search filter and the raw-name request path.

CI

This head has concluded failures, not just pending work: Lint & Static (ubuntu-latest, Node 22.x), Serve A/B (ubuntu-latest, Node 22.x) and Real daemon E2E / Java 11 all completed as failure, while Test (ubuntu-latest, Node 22.x), Test (macos-latest), Test (windows-latest), both integration lanes, web-shell E2E Smoke, Capture web-shell visuals, Desktop Shell and Live Host completed cancelled, and review-pr was still running. I did not establish attribution for any of them, and under this channel's rules an unattributed CI state does not by itself decide the review - the two standing Criticals do. The practical consequence is that this head currently carries no concluded green unit, lint or integration evidence, so nothing corroborates the local gate results the description reports.

Next step

Add the push-side leg to remoteStillResolves so the certification covers both rewrite directions, and settle the skeleton fold with the paired-spelling cases above - a test either closes it or reproduces the split. Then get a clean CI run on the new head before re-requesting review, since the cancelled lanes mean the current failures cannot be read as either this PR's or not. Given the size of this surface, splitting the unaudited list between reviewers would let the routes, the error classifier and the removal converge path each get a dedicated pass rather than a share of one budget.

A fresh `tsc --build` of packages/cli on the merged tree peaks just
above the 3072 MiB ceiling: two consecutive CI Lint runs and a local
clean-tsbuildinfo build all OOM at ~3.0/3.1 GiB, while each parent tree
alone stays under the ceiling and incremental builds never showed it.
Workspace build children inherit NODE_OPTIONS from the root build
script, so raising it there is what fits the union program; the test
ceilings are untouched.
@wenshao
wenshao enabled auto-merge September 13, 2026 21:57
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.

7 participants