Skip to content

feat(web-shell): manage pairing approvals - #8081

Merged
wenshao merged 7 commits into
QwenLM:mainfrom
qqqys:codex/issue-7209-channel-pairing-access-ui
Jul 30, 2026
Merged

feat(web-shell): manage pairing approvals#8081
wenshao merged 7 commits into
QwenLM:mainfrom
qqqys:codex/issue-7209-channel-pairing-access-ui

Conversation

@qqqys

@qqqys qqqys commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Adds workspace-scoped pairing approval management to the Channel editor. Saved Channels in pairing mode now show approved sender IDs below pending requests, refresh after approving a request, and let operators revoke an approval through an explicit confirmation dialog. The editor also distinguishes persisted pairing approvals from users configured in the Channel allowlist.

Why it's needed

The previous Web Shell flow could approve pending pairing codes but offered no way to see or remove the resulting access. Operators had to leave the UI and inspect local state manually, and revoking a pairing approval could be confused with changing the configured allowlist.

Reviewer Test Plan

How to verify

Open a saved Channel in pairing mode and confirm that pending requests and pairing approvals appear directly below the access policy. Approve a pending request and expect its sender ID to appear in the approvals list. Choose Revoke, cancel once to confirm no change, then confirm revocation and expect the sender ID to disappear. When the Channel has configured allowlist users, confirm the editor explains that those users remain allowed after a pairing approval is revoked.

Focused component and workspace-action tests pass (56 tests). The Web Shell Channel Playwright flow passes, including the approve-to-list-to-revoke sequence. Channel manager visual tests pass in light and dark themes. Repository build, typecheck, lint, and the final Web Shell scoped build/typecheck/lint all pass.

Evidence (Before & After)

Before: the pairing-mode editor only showed pending requests and could not list or revoke existing pairing approvals.

After: the editor shows pairing approvals below pending requests, provides explicit revocation, and calls out configured allowlist access. A rendered light-theme screenshot is attached in a follow-up E2E evidence comment.

Tested on

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

Environment (optional)

Local workspace build and Playwright Chromium against the Web Shell mock daemon.

Risk & Scope

  • Main risk or tradeoff: Revocation removes only the persisted pairing approval; an ID that is also configured in the Channel allowlist remains authorized, so the UI surfaces that distinction before and after the action.
  • Not validated / out of scope: QR-code setup, additional Channel providers, and changing the static allowlist are intentionally excluded from this phase.
  • Breaking changes / migration notes: None. This consumes the workspace-scoped pairing approval API and SDK contract added in the previous phase.

Linked Issues

Part of #7209

中文说明

本 PR 做了什么

为频道编辑器增加工作区范围内的配对授权管理。以配对模式保存的频道现在会在待处理请求下方显示已授权的用户 ID,批准请求后自动刷新,并允许管理员通过明确的确认对话框撤销授权。编辑器同时区分持久化配对授权与频道配置白名单中的用户。

为什么需要

此前 Web Shell 可以批准待处理的配对码,但无法查看或移除批准后产生的访问权限。管理员只能离开页面手动检查本地状态,而且撤销配对授权容易与修改配置白名单混淆。

Reviewer Test Plan

如何验证

打开一个已保存且处于配对模式的频道,确认待处理请求和已配对用户直接显示在准入策略下方。批准一条待处理请求,预期其用户 ID 出现在授权列表中。点击撤销,先取消并确认列表不变,再确认撤销并预期该用户 ID 消失。若频道配置了白名单用户,确认编辑器明确说明撤销配对授权后这些用户仍然可以访问。

聚焦组件与工作区 action 测试通过,共 56 项。Web Shell 频道 Playwright 流程通过,覆盖批准、进入列表和撤销的完整链路。频道管理器明暗主题视觉测试通过。仓库构建、类型检查、lint,以及最终 Web Shell 范围内的构建、类型检查和 lint 全部通过。

证据(前后对比)

改动前:配对模式编辑器只显示待处理请求,无法列出或撤销已有配对授权。

改动后:编辑器在待处理请求下方显示已配对用户,提供明确的撤销操作,并提示配置白名单仍然生效。渲染后的明亮主题截图会附在后续 E2E 证据评论中。

测试平台

OS 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

本地工作区构建,以及针对 Web Shell mock daemon 的 Playwright Chromium。

风险与范围

  • 主要风险或权衡:撤销只会移除持久化配对授权;如果同一 ID 也位于频道配置白名单中,它仍然有权限,因此 UI 会在操作前后明确提示这一差异。
  • 未验证/范围外:二维码配置、更多频道提供方,以及修改静态白名单有意不包含在本阶段。
  • 破坏性变更/迁移说明:无。本阶段使用上一阶段新增的工作区范围配对授权 API 与 SDK 契约。

关联 Issue

属于 #7209 的一部分。

@qqqys

qqqys commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

E2E evidence

Verified locally on macOS with Playwright Chromium against the Web Shell mock daemon:

  • 56 focused component and workspace-action tests passed.
  • Channel flow E2E passed: approve request → approval appears → cancel revoke → confirm revoke → approval disappears.
  • Channel manager visual tests passed in light and dark themes.
  • Repository build, typecheck, and lint passed; final Web Shell scoped build, typecheck, and lint also passed.

Light-theme result:

channel-editor-existing-light

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finished — CI landed green on 11c0016 and the deferred approval was posted. finalize run

Qwen Triage 已完成 —— 11c0016 的 CI 全绿,延迟审批已提交。查看 finalize 运行

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 30, 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 221e104. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

channel-editor-existing-dark before/after

channel-editor-existing-light before/after

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

Qwen Code · web-shell visuals

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: this is a real feature gap, not theoretical hardening. The Web Shell could approve a pending pairing code but gave operators no way to see or revoke the resulting access — they had to leave the UI and inspect local state. It's a phased piece of the labeled roadmap issue #7209 (P2, scope/web-shell, daemon), and it builds on the workspace-scoped pairing-approval API that already landed in the previous phase.

Direction: aligned. This completes the management surface #7209 asks for, and it consumes an already-merged daemon contract (GET/DELETE /workspaces/:workspace/channels/:name/pairing-approvals plus the channel_pairing_approval_not_found → 404 mapping are present on main) rather than introducing a new one. I confirmed the SDK types and client methods this PR reads all exist in base, so the dependency chain is satisfied.

Size: not a core-module change — everything sits in packages/web-shell/client/** (UI) and packages/webui/src/daemon/workspace/** (thin action wrappers), roughly ~450 production lines with the rest being component/e2e tests. The two-tier core gate doesn't apply.

Approach: the scope feels right and the implementation leans on existing patterns throughout — the new approvals/revoke workspace actions mirror the existing list/approve pair (read wraps in withActionTimeout, mutation doesn't), the approvals section extends the existing ChannelPairingRequests component and its reload-token flow, and the revoke confirmation reuses the same AlertDialog primitive the Agents/Skills/Extensions managers already use. I didn't see a materially simpler path, and no unrelated changes or drive-by refactors.

Risk: no elevated risk signals — none of the changed files match the revert-correlated high-risk paths.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:这是一个真实的功能缺口,而非理论性加固。Web Shell 之前可以批准待处理的配对码,但管理员无法查看或撤销批准后产生的访问权限,只能离开页面手动检查本地状态。这是带标签的路线图 issue #7209(P2、scope/web-shell、daemon)的一个阶段性部分,并且建立在上一阶段已合入的工作区范围配对授权 API 之上。

方向:对齐。它补全了 #7209 要求的管理界面,并且复用了已合入的 daemon 契约(GET/DELETE /workspaces/:workspace/channels/:name/pairing-approvals 以及 channel_pairing_approval_not_found → 404 映射都已在 main 上),没有引入新契约。我确认了本 PR 读取的 SDK 类型与客户端方法在 base 中均已存在,依赖链完整。

规模:非核心模块改动——全部位于 packages/web-shell/client/**(UI)与 packages/webui/src/daemon/workspace/**(薄 action 封装),约 450 行生产代码,其余为组件/e2e 测试。两层核心门禁不适用。

方案:范围合理,实现充分复用既有模式——新增的 approvals/revoke 工作区 action 与既有 list/approve 一致(读操作包 withActionTimeout,写操作不包),授权列表区块扩展了既有 ChannelPairingRequests 组件及其 reload-token 流程,撤销确认复用了 Agents/Skills/Extensions 管理器已在使用的同一个 AlertDialog 原语。没有看到更简路径,也没有无关改动或顺手重构。

风险:无升级风险信号——改动文件均未命中与 revert 相关的高风险路径。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Code review

I wrote my independent proposal first (thin approvals/revoke action wrappers in webui mirroring list/approve, extend the existing ChannelPairingRequests component with an approvals section + confirm dialog, distinguish the static allowlist, add EN/ZH strings and e2e mock routes) — and the PR matches it almost exactly. That's a good sign: it follows the repo's conventions rather than inventing a parallel structure.

No blockers. A few things I checked specifically:

  • Contracts exist in base. The SDK types (DaemonChannelPairingApprovalsSnapshot / …RevocationRequest / …RevocationResult), the DaemonClient workspace methods, and the daemon routes (GET/DELETE …/pairing-approvals, channel_pairing_approval_not_found → 404) are all on main already. The alert-dialog primitive and the errorCode / pairingErrorDetail helpers this PR reuses also predate it. Nothing here depends on an unmerged phase.
  • Action convention is right. approvals (a read) wraps in withActionTimeout; revoke (a mutation) doesn't — identical to the existing list vs approve split.
  • Type safety. configuredAllowedUsers reads instance.config['allowedUsers'] (typed Record<string, unknown>) and narrows with Array.isArray + a string type guard before use.
  • Stale-response guarding in the approvals effect and in revoke/approve matches the existing mounted / currentChannelName pattern, so a channel switch mid-flight won't write into the wrong list.
  • Tests pin the behavior, not just render: approve refreshes the approvals list, revoke confirms then removes only the selected sender, and the 404 "already gone" path refreshes the list and surfaces a note.

One non-blocking product nit for the author to consider: when a revoke races and the approval is already gone, the UI refreshes the list (good) but also shows a destructive-styled alert titled "Pairing approvals were not updated". For a benign concurrent-revoke case a neutral tone might read better — but it's tested and intentional, so not a merge concern.

Files changed (17 of 17 shown)
File What changed
packages/web-shell/client/components/channels/ChannelPairingRequests.tsx Core of the PR: loads approvals, revoke with confirm dialog and 404-refresh handling, approvals section UI, allowlist info alert
packages/web-shell/client/components/channels/ChannelPairingRequests.test.tsx Display, allowlist distinction, confirm-and-revoke-only-selected, approve-refreshes-approvals, 404-refresh
packages/web-shell/client/components/channels/ChannelPairingRequests.module.css Styles for divider, approval row, sender id, allowlist
packages/web-shell/client/components/channels/ChannelEditorDialog.tsx New listPairingApprovals / revokePairingApproval props, configuredAllowedUsers helper, wiring into the requests panel
packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx Passes the two new mock props
packages/web-shell/client/components/channels/ChannelsManagerPage.tsx Wires pairing.approvals / pairing.revoke into the editor
packages/web-shell/client/components/channels/ChannelsManagerPage.test.tsx Adds approvals / revoke mocks
packages/web-shell/client/i18n.tsx EN and ZH strings for approvals, allowlist, and confirm dialog
packages/web-shell/client/e2e/utils/mockDaemon.ts GET / DELETE pairing-approvals mock routes; approve seeds the approvals map
packages/web-shell/client/e2e/web-shell.channels.spec.ts Approve then revoke then assert the DELETE request
packages/web-shell/client/e2e/visuals/screenshots.spec.ts Adds approvals to the channel-editor visual
packages/webui/src/daemon/workspace/actions.ts Adds approvals (read, with timeout) and revoke (mutation)
packages/webui/src/daemon/workspace/actions.test.ts Asserts the two new actions delegate to the workspace client
packages/webui/src/daemon/workspace/types.ts Adds approvals / revoke to DaemonChannelPairingActions
packages/webui/src/daemon/workspace/hooks/useDaemonChannels.test.tsx Resets the two new action mocks
packages/webui/src/daemon-react-sdk.ts Re-exports the three new SDK types
packages/webui/src/daemon/index.ts Re-exports the three new SDK types

Testing evidence (this PR's own CI, read via the API — I did not run any PR code)

The main ubuntu unit suite and the Web-shell Visuals job (which carries the Playwright channels flow) are still running on the reviewed commit; nothing has failed so far and precheck is green. macOS/Windows test legs are skipped, which is normal for an unauthorized fork PR. Per the workflow I'm not polling — the table below is a one-shot snapshot, and the Qwen Triage Finalize job will refresh it and handle any deferred approval once CI settles.

Final CI results for 11c0016 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
Classify PR ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

The behavioral claim (approve → sender appears in the list → revoke → DELETE is issued) is the kind of thing a green suite alone doesn't prove. CI's Playwright channels spec and the component tests do exercise that exact sequence, which is real coverage — but they run against the mock daemon, and the author verified locally on macOS only. Sandboxed verification would settle the real-daemon round trip: @qwen-code /verify — that revoke actually drives DELETE /workspaces/:workspace/channels/:name/pairing-approvals against a live daemon and that the 404 path reconciles the list is not observable from the diff alone. This is a fork PR, so /verify runs as a sponsored lane (a maintainer's @qwen-code /verify approves the head it's written against; the run carries a pre-execution risk screen and a full workspace wipe). Read the resulting report with the same skepticism as the fork's own CI logs — the code under verification is adversarial input.

中文说明

代码审查

我先独立写出自己的方案(在 webui 中做与 list/approve 一致的薄 approvals/revoke action 封装,扩展既有 ChannelPairingRequests 组件加入授权列表区块与确认对话框,区分静态白名单,补充中英文文案与 e2e mock 路由)——而本 PR 几乎完全吻合。这是个好信号:它遵循仓库既有约定,而非另起一套结构。

无阻塞项。我重点核对了:

  • 契约已在 base 中存在。 SDK 类型、DaemonClient 工作区方法、daemon 路由(GET/DELETE …/pairing-approvalschannel_pairing_approval_not_found → 404)都已在 main 上。本 PR 复用的 alert-dialog 原语与 errorCode / pairingErrorDetail 辅助函数也早于本 PR。这里没有任何对未合入阶段的依赖。
  • action 约定正确。 approvals(读)包 withActionTimeoutrevoke(写)不包——与既有 listapprove 的分工一致。
  • 类型安全。 configuredAllowedUsers 读取 instance.config['allowedUsers'](类型为 Record<string, unknown>),使用前用 Array.isArray + 字符串类型守卫收窄。
  • 过期响应防护与既有 mounted / currentChannelName 模式一致,切换频道时不会写错列表。
  • 测试钉住了行为:批准后刷新授权列表,撤销先确认再只移除选中的用户,404「已不存在」路径会刷新列表并提示。

一个非阻塞的产品小建议:当撤销发生竞态、授权已不存在时,UI 会刷新列表(好),但同时弹出标题为「Pairing approvals were not updated」的 destructive 提示。对于这种良性并发场景,中性语气可能更合适——但这是有测试覆盖的有意设计,不构成合入问题。

测试证据(来自本 PR 自身的 CI,通过 API 读取——我未运行任何 PR 代码)

主 ubuntu 单测套件与 Web-shell Visuals 任务(包含 Playwright 频道流程)仍在所审 commit 上运行;目前没有失败,precheck 为绿。macOS/Windows 测试腿被跳过,这对未授权的 fork PR 属正常。按流程我不轮询——上表为一次性快照,Qwen Triage Finalize 任务会在 CI 落定后刷新表格并处理任何延迟批准。

行为性结论(批准 → 用户出现在列表 → 撤销 → 发出 DELETE)是单纯绿套件无法证明的。CI 的 Playwright 频道 spec 与组件测试确实演练了这一完整链路,是真实覆盖——但它们针对 mock daemon 运行,且作者仅在 macOS 本地验证。沙箱验证可以坐实真实 daemon 往返:@qwen-code /verify——revoke 是否真的对运行中的 daemon 发出 DELETE /workspaces/:workspace/channels/:name/pairing-approvals、以及 404 路径是否会重新对齐列表,仅凭 diff 无法观察。这是 fork PR,因此 /verify赞助方式运行(maintainer 的 @qwen-code /verify 会批准其写入时所对的 head;该运行带有执行前风险筛查与完整工作区清理)。请以与 fork 自身 CI 日志相同的怀疑态度阅读其报告——被验证的代码是对抗性输入。

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean, convention-following review with no blockers; the only thing keeping this from a 5 is that CI hasn't landed green yet on the reviewed commit.

Stepping back: this is exactly the kind of PR that's easy to maintain in six months. It doesn't try to do more than the goal needs — every file earns its place — and it leans on patterns already in the codebase instead of adding parallel structure. My independent proposal and the actual diff converged, which means the approach isn't fighting the repo. The phase boundary is clean too: it consumes an already-merged daemon contract and adds only the UI + thin action wrappers on top, so the blast radius is the Web Shell channels editor.

The tests are about as good as this gets for a UI flow: they assert the approve → list → revoke sequence, that revoke removes only the selected sender, and that the 404 race reconciles the list rather than leaving stale state. The one reservation I'd leave for the author (non-blocking) is the destructive-styled alert on the benign "already gone" path — worth a thought, not a rework.

I'd merge this. Since the ubuntu unit suite and the Web-shell Visuals job are still running on the reviewed commit, I'm deferring the approval rather than attesting to a result that doesn't exist yet — approval will land automatically once CI is green on this commit, and will be withheld if anything lands red or the head moves.

中文说明

置信度:4/5 —— 审查干净、遵循约定、无阻塞项;唯一让它未到 5 分的原因,是所审 commit 上的 CI 尚未落绿。

退一步看:这正是六个月后仍易于维护的那种 PR。它没有做超出目标的事——每个文件都物有所值——并且依赖代码库中既有的模式,而非另起一套结构。我的独立方案与实际 diff 收敛,说明方案没有与仓库对着干。阶段边界也很清晰:它复用一个已合入的 daemon 契约,只在其上增加 UI 与薄 action 封装,因此影响范围限定在 Web Shell 频道编辑器。

对一个 UI 流程而言,这里的测试已属上乘:它断言了批准 → 列表 → 撤销的完整链路、撤销只移除选中的用户、以及 404 竞态会重新对齐列表而非留下过期状态。我给作者留的唯一保留意见(非阻塞)是良性「已不存在」路径上那个 destructive 样式的提示——值得斟酌,但不必返工。

我会合入它。由于主 ubuntu 单测套件与 Web-shell Visuals 任务仍在所审 commit 上运行,我选择延迟批准,而不是为一个尚不存在的结果背书——CI 在该 commit 上转绿后会自动批准;若有任何检查转红或 head 移动,则会保留不批。

Qwen Code · qwen3.8-max-preview

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

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

LGTM, looks ready to ship — CI landed green after the review. ✅

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

Reviewed — no blockers. Suggestions are inline.

中文说明

已审查——无阻断问题。 建议见行内评论。

— qwen3.8-max-preview via Qwen Code /review

Comment thread packages/web-shell/client/components/channels/ChannelPairingRequests.test.tsx Outdated

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

Reviewed — no blockers. Suggestions are inline.

中文说明

已审查——无阻断问题。 建议见行内评论。

— qwen3.8-max-preview via Qwen Code /review

qwen-code-ci-bot pushed a commit that referenced this pull request Jul 30, 2026
@wenshao

wenshao commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Review — feat(web-shell): manage pairing approvals

Reviewed at head 3b66b09. The shape is right: the Channel editor now reads the workspace-scoped pairing-approval API added in #8045, refreshes after an approve, and gates revocation behind an explicit AlertDialog. Error handling mirrors the existing pending-requests path closely (pairingErrorDetail, errorCode, the mounted + currentChannelName guards, the not_found → refresh recovery), which keeps the file coherent. Wiring through webui (channelPairing.approvals / .revoke) matches the established convention — read gets withActionTimeout, mutation doesn't, same as list/approve.

One correctness bug and a cluster of coverage gaps below.

What I ran

Isolated worktree at the PR head (node_modules symlinked from a primary checkout).

Check Result
web-shell channels suite (5 files) 42/42 pass
webui actions.test.ts + useDaemonChannels.test.tsx 25/25 pass
prettier --check on all 17 changed files clean
eslint on the 4 changed source files clean
Mutation probes on the new code 1 killed / 4 survived (below)

Full web-shell suite shows 23 failing files, all environmental in my tree (Cannot find package '@datafe-open/markdown-chart', missing dist/index.js, a stale useUsageDashboard mock) — none touch this PR's files.


1. Approving a request while a revoke is in flight wipes the approvals list · ChannelPairingRequests.tsx:252

revoke() writes setApprovedSenderIds(result.senderIds) with no generation guard — unlike the load effect, which has its active flag (:145-171). Meanwhile the two actions don't disable each other: Approve is disabled={Boolean(approvingCode)} (:385) and Revoke is disabled={Boolean(revokingSenderId)} (:485), so Approve stays clickable during a revoke round-trip.

Sequence:

  1. Approvals ['old-user'], one pending request from user-42.
  2. Revoke old-user → confirm → DELETE in flight.
  3. Click Approve (still enabled) → resolves → bumps approvalsReloadTokenlistApprovals returns the fresh ['old-user', 'user-42'].
  4. The slow DELETE resolves with its pre-approve snapshot senderIds: [] → clobbers the newer read.

I reproduced this with a throwaway test using a deferred revokeApproval. Final rendered state:

Ada can now use this Channel. … No pending requests
Pairing approvals … 0
Pairing approval for old-user was revoked.
No pairing approvals — Approved sender IDs will appear here.

The operator has just granted user-42 access and the panel reports "No pairing approvals". Server state is correct; only the display is stale, and it only clears on a manual refresh. Under-reporting rather than over-reporting access, so not a security hole — but this panel is exactly what an operator reads to decide who has access.

Either fix works:

  • Cross-disable the two actions (disabled={Boolean(approvingCode) || Boolean(revokingSenderId)} on both), matching the intent of the existing in-section guards; or
  • capture approvalsReloadToken at the top of revoke() and skip the write if it has moved — the same shape as the effect's active flag.

2. The allowedUsers read has no coverage at all · ChannelEditorDialog.tsx:109

configuredAllowedUsers() reads instance.config['allowedUsers'], and staticAllowedUsers is only ever supplied by that call in production. But allowedUsers appears exactly once in the entire web-shell client — in that line. No unit test, no E2E scenario, no visual scenario sets it.

Mutation: renaming the key to 'allowedUsersMUTANT' leaves 42/42 green. The "Configured allowlist" surface — a headline item in the PR description — is only exercised via a hand-passed prop in ChannelPairingRequests.test.tsx; the wiring that actually produces it is unpinned.

The key itself is correct (channel-settings-store.ts:162 validates allowedUsers as a shared string-array field, and buildChannelUpsertRequest spreads instance.config, so saving from the editor doesn't drop it). It just has nothing keeping it correct. A single ChannelEditorDialog test with instance.config = { senderPolicy: 'pairing', allowedUsers: ['configured-user'] } closes this and #3 at once — note the shared INSTANCE fixture is senderPolicy: 'open', so ChannelPairingRequests is never mounted in any editor-dialog test today.

3. Nothing asserts the allowlist alert is absent when there are no configured users · :499

Relaxing staticAllowedUsers.length > 0 to >= 0 also leaves 42/42 green. Combined with #2, the alert can neither appear when it should nor stop appearing when it shouldn't without a test noticing.

4. No channel-switch isolation test for approvals · :149

The pending-requests side has does not show requests from the previous Channel while loading. The approvals side has no equivalent: deleting setApprovedSenderIds([]) from the reset block leaves 42/42 green. The code is correct today, but approved sender IDs are the more sensitive of the two lists to leak across a channel switch — worth the sibling test, given the precedent already exists two effects up.

5. Minor

  • Concurrent-revoke guard untested (:240): deleting if (revokingSenderId) return; leaves 42/42 green. Low severity (the buttons are disabled), but it's the last line of defence and it's free to pin.
  • Visual baseline reframed (screenshots.spec.ts): captureScreenshot calls page.screenshot() without fullPage, so it is viewport-clipped. Replacing await editHeading.click() with scrollIntoViewIfNeeded() on the Pairing approvals heading means channel-editor-existing-{light,dark} now frames the bottom of the dialog instead of the top — the previously-covered region silently drops out of the baseline. Also worth confirming what the removed click was for: it was added deliberately in feat(web-shell): add Channel configuration flows #7893 and, on a dialog that autofocuses its first field, reads like a focus-ring determinism guard. Consider keeping the original capture and adding a separate channel-editor-approvals-{theme} shot.
  • A11y (:296, :405): the new block lives inside the same <section aria-labelledby={headingId}> whose accessible name is "Pending requests", and its <h4> carries no id. A screen reader announces the approvals list and its Revoke buttons as part of the pending-requests region. A second <section> with its own useId-backed aria-labelledby matches what the first one already does.
  • i18n: EN/ZH keys are both present and consistent — but type Messages = Record<string, MessageValue> is an open record, so nothing type-checks parity, and there's no parity test in this package. The existing uses consistent approval wording in Chinese test has no counterpart for the new approvals strings.

Verdict

No blockers on the daemon contract or the wiring. Finding #1 is a real user-visible defect and cheap to fix; #2#4 are three surviving mutants concentrated on the parts of this change that the PR description advertises most, and one editor-dialog test with a pairing instance closes most of them.

中文说明

结论

整体方向正确:编辑器接入了 #8045 的工作区级配对授权 API,批准后会刷新,撤销有明确的确认对话框;错误处理与既有的待处理请求路径保持一致;webui 侧的读操作带 withActionTimeout、写操作不带,符合现有约定。

已运行的验证(PR head 3b66b09,隔离 worktree):web-shell channels 套件 42/42 通过;webui 相关 2 个测试文件 25/25 通过;prettier 与 eslint 干净。web-shell 全量套件的 23 个失败文件均为环境问题(缺 @datafe-open/markdown-chart、未构建 dist/index.js),与本 PR 无关。

1. 撤销进行中再批准请求会清空授权列表ChannelPairingRequests.tsx:252)— 唯一的功能缺陷。revoke() 写入 setApprovedSenderIds 时没有代际保护(加载 effect 有 active 标志),而"批准"按钮在撤销期间并未禁用。若批准先返回并触发刷新、随后较慢的 DELETE 才返回其过期快照,就会覆盖较新的读取结果。我用延迟 revokeApproval 的临时测试复现了:操作员刚刚授予 user-42 访问权限,面板却显示"No pairing approvals",只有手动刷新才恢复。服务端状态正确,仅显示错误。修法二选一:两个操作互相禁用;或在 revoke() 开头记录 approvalsReloadToken,变化则跳过写入。

2. allowedUsers 读取完全没有测试覆盖ChannelEditorDialog.tsx:109)。allowedUsers 在整个 web-shell client 中只出现这一处;把键名改成 'allowedUsersMUTANT' 后 42/42 仍然全绿。键名本身是对的(channel-settings-store.ts:162 有校验,buildChannelUpsertRequest 会展开 instance.config,保存不会丢失白名单),但没有任何测试守住它。注意共享的 INSTANCE fixture 是 senderPolicy: 'open',因此编辑器对话框的测试从未挂载 ChannelPairingRequests

3. 没有断言"无配置白名单时提示不出现":499)。把 length > 0 放宽为 >= 0 后依然 42/42 全绿。

4. 缺少切换频道时的授权列表隔离测试:149)。待处理请求侧已有同类测试,授权侧没有;删除 setApprovedSenderIds([]) 后仍 42/42 全绿。已授权的用户 ID 跨频道泄漏比待处理请求更敏感,建议补齐。

5. 次要问题:撤销并发保护(:240)删掉后测试仍全绿;视觉基线被改变——captureScreenshot 未使用 fullPage,把 editHeading.click() 换成 scrollIntoViewIfNeeded() 后,channel-editor-existing-{light,dark} 截取的是对话框底部而非顶部,原有覆盖区域悄然丢失(那个 click 是 #7893 有意加入的,看起来是为了避免焦点环带来的不确定性),建议保留原截图并新增一张 channel-editor-approvals-{theme};无障碍方面,新区块位于名为 "Pending requests" 的 <section> 内且 <h4> 没有 id,建议单独开一个带 aria-labelledby<section>;i18n 的 EN/ZH 键已齐全,但 Messages 是开放 Record,类型层面并不校验对齐,本包也没有对齐测试。

守护契约与接线没有阻断性问题。#1 是真实且易修的用户可见缺陷;#2#4 是三个存活变异体,恰好集中在本 PR 最主打的功能上,补一个带 pairing instance 的编辑器对话框测试即可覆盖大部分。

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

Reviewed. Suggestions are inline.

— qwen3.8-max-preview via Qwen Code /review

qwen-code-ci-bot pushed a commit that referenced this pull request Jul 30, 2026
@wenshao

wenshao commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /verify

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ⚠️ not run — skipped - workflow run

Skipped because GitHub had not computed the PR merge ref after several retries — try again shortly.

中文 — 判定:⚠️ 未运行 · 已跳过

跳过原因:GitHub had not computed the PR merge ref after several retries — try again shortly。

Qwen Code · sandboxed verification

@qqqys

qqqys commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

已修复于 4a1d2d1:批准与撤销操作现在互相禁用,避免慢撤销的旧快照覆盖批准后的授权刷新;新增两个 deferred-promise 行为测试覆盖双向并发。验证:focused Vitest 22/22、全仓 npm run build、全仓 npm run typecheck、变更文件 ESLint/Prettier 与 git diff --check 均通过。

qwen-code-ci-bot pushed a commit that referenced this pull request Jul 30, 2026

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

⚠️ Downgraded from Approve to Comment: CI still running. Reviewed.

中文说明

⚠️ 已从批准降级为评论:CI still running。 已审查。

— qwen3.8-max-preview via Qwen Code /review

@wenshao

wenshao commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Review: feat(web-shell): manage pairing approvals (#8081)

Overview

Adds pairing-approval management to the Channel editor: a second section under the pending-requests panel lists workspace-scoped approved sender IDs, refreshes after an approval, and supports revocation behind an AlertDialog confirmation. The editor also surfaces the configured allowedUsers allowlist so operators understand that revoking a pairing approval does not remove statically configured access.

Layers touched are consistent and complete: webui action layer (channelPairing.approvals / .revoke) → types.ts interface → SDK type re-exports → ChannelsManagerPageChannelEditorDialogChannelPairingRequests, plus i18n (EN + ZH), CSS module, mock daemon routes, and E2E/visual specs. The SDK methods it consumes (workspaceChannelPairingApprovals, revokeWorkspaceChannelPairingApproval) already exist on main, so this is purely the UI phase as described.

Verified locally against the PR branch:

  • packages/web-shellChannelPairingRequests.test.tsx: 22/22 pass
  • packages/webuiactions.test.ts + useDaemonChannels.test.tsx: 25/25 pass
  • ESLint clean on the three changed source files

What's good

  • Race handling is careful and matches the existing conventions in this file. revoke() reuses the mounted.current + currentChannelName.current guard pattern from approve(), and the finally block only clears revokingSenderId when still on the same channel.
  • Mutual exclusion between approve and revoke (disabled={Boolean(approvingCode) || Boolean(revokingSenderId)} in both directions) is a nice touch and is directly covered by two tests.
  • The channel_pairing_approval_not_found recovery path — refetch instead of surfacing a confusing error — is the right call for a concurrently-revoked approval, and both the happy and the refetch-also-fails branches are tested.
  • Revocation state comes from the server response (setApprovedSenderIds(result.senderIds)) rather than local filtering, so the UI cannot drift from daemon state.
  • Test coverage is genuinely thorough: load, retry-on-error, confirm, cancel, revoke failure, 404 refetch, 404-refetch failure, both in-flight lockouts, plus the E2E approve→list→revoke sequence and updated mock-daemon routes.
  • configuredAllowedUsers() correctly narrows with an Array.isArray + typeof === 'string' filter, which lines up with the daemon-side validation in channel-settings-store.ts (allowedUsers must be a string array).

Suggestions

1. Confirmation dialog title flashes a malformed string while closingChannelPairingRequests.tsx

{t('channels.editor.pairing.approvals.confirm.title', {
  senderId: revokeTarget ?? '',
})}

The i18n entry is `Revoke pairing approval for ${v?.senderId ?? 'this sender'}?`. Because '' is falsy but not nullish, ?? keeps it, so during the dialog's exit animation (after revokeTarget is cleared) the title renders as "Revoke pairing approval for ?". Passing revokeTarget directly (or revokeTarget || undefined) lets the ?? 'this sender' fallback do its job.

2. Successful 404-recovery gives the operator no feedback

In the channel_pairing_approval_not_found branch, the refetch succeeds and the row silently disappears — no revokeSuccess, no notice. From the operator's point of view the end state matches their intent, so setting revokeSuccess there (or a "was already revoked" variant) would close the loop. Currently the only signal is the row vanishing.

3. Hardcoded allowedUsers config keyChannelEditorDialog.tsx

instance?.config['allowedUsers'] bypasses the channel-type field descriptors that drive the rest of the editor. It's correct for today's providers, but a provider that names its allowlist differently will silently render no hint. Worth a short comment noting the coupling, or deriving the key from the descriptor set.

4. Accessibility: the new subsection has no accessible name

The <section> is aria-labelledby={headingId} pointing at "Pending requests", and the approvals block adds a second <h4> with no id inside that same section. Screen-reader users navigating by region will hear the whole thing as the pending-requests panel. Consider a second <section aria-labelledby={approvalsHeadingId}> (or at least useId() for the new heading and an aria-labelledby on a wrapping element).

5. Visual spec: dropping editHeading.click() may reintroduce focus-ring nondeterminisme2e/visuals/screenshots.spec.ts

-      await editHeading.click();
+      await page.getByRole('heading', { name: 'Pairing approvals' }).scrollIntoViewIfNeeded();

That click looked like it existed to move focus off the dialog's autofocused control before the screenshot. scrollIntoViewIfNeeded() doesn't do that, so whatever the dialog autofocuses will now render with a focus ring. Keeping both (click() then scrollIntoViewIfNeeded()) preserves the original stabilization.

Minor / nits

  • .divider renders unconditionally, including while the pending-requests panel is still loading or in its error state — slightly odd when there's nothing above it yet.
  • staticAllowedUsers is recomputed on every ChannelEditorDialog render. It's render-only (not an effect dep), so no loop — but useMemo would be tidier. Similarly, a large allowlist renders as one unbroken join(', ') blob.
  • key={senderId} assumes the daemon never returns duplicate sender IDs. Almost certainly true, but a Set on ingest would make it structurally safe.
  • The two Refresh buttons don't clear each other's success banners (pending-requests refresh leaves revokeSuccess up, and vice versa for success). Cosmetic.
  • Like the existing pairing-requests panel, this is not gated on a daemon capability, so an older daemon surfaces a persistent "Pairing approvals were not updated" alert rather than hiding the section. Consistent with what's already there — flagging only in case capability gating is planned for a later phase.

Risk assessment

Low. Purely additive UI over an already-shipped API, with no behavior change to the existing approve flow beyond the added disabled condition and the post-approve refresh. No security concerns: sender IDs are rendered through React text nodes and aria-label interpolation (both escaped), the destructive action is confirmation-gated, and authorization stays server-side. Performance impact is one extra GET per editor open for pairing-mode channels.


中文说明

代码评审:feat(web-shell): manage pairing approvals (#8081)

总体说明

在频道编辑器中新增配对授权管理:在待处理请求下方新增一个区块,列出工作区范围内已授权的 sender ID,批准后自动刷新,并通过 AlertDialog 二次确认后撤销。编辑器同时展示配置中的 allowedUsers 白名单,让管理员明确撤销配对授权不会移除静态配置的访问权限。

分层改动完整且一致:webui action 层(channelPairing.approvals / .revoke)→ types.ts 接口 → SDK 类型再导出 → ChannelsManagerPageChannelEditorDialogChannelPairingRequests,另加 i18n(EN + ZH)、CSS module、mock daemon 路由以及 E2E / 视觉测试。所依赖的 SDK 方法(workspaceChannelPairingApprovalsrevokeWorkspaceChannelPairingApproval)已在 main 上存在,因此本 PR 确实只是 UI 阶段。

本地验证结果:

  • packages/web-shellChannelPairingRequests.test.tsx:22/22 通过
  • packages/webuiactions.test.ts + useDaemonChannels.test.tsx:25/25 通过
  • 三个改动源文件 ESLint 无告警

做得好的地方

  • 竞态处理谨慎,且与该文件已有约定一致revoke() 复用了 approve()mounted.current + currentChannelName.current 守卫,finally 也只在仍处于同一频道时才清除 revokingSenderId
  • 批准与撤销互斥(双向 disabled={Boolean(approvingCode) || Boolean(revokingSenderId)})考虑周到,并有两个测试直接覆盖。
  • channel_pairing_approval_not_found 的恢复路径——重新拉取而不是抛出令人困惑的错误——对于并发撤销场景是正确选择,且成功与二次失败两条分支都有测试。
  • 撤销后的状态来自服务端响应setApprovedSenderIds(result.senderIds))而非本地过滤,UI 不会与 daemon 状态漂移。
  • 测试覆盖确实扎实:加载、失败重试、确认、取消、撤销失败、404 重拉、404 重拉再失败、两个方向的并发锁定,外加 E2E 的「批准 → 列表 → 撤销」全链路和更新后的 mock daemon 路由。
  • configuredAllowedUsers()Array.isArray + typeof === 'string' 收窄类型,与 daemon 侧 channel-settings-store.ts 中「allowedUsers 必须是字符串数组」的校验一致。

改进建议

1. 确认弹窗关闭动画期间标题会闪现异常文案ChannelPairingRequests.tsx

{t('channels.editor.pairing.approvals.confirm.title', {
  senderId: revokeTarget ?? '',
})}

对应文案是 `Revoke pairing approval for ${v?.senderId ?? 'this sender'}?`'' 是 falsy 但不是 nullish,?? 会保留它,因此在 revokeTarget 被清空后的退场动画期间,标题会显示成 "Revoke pairing approval for ?"。直接传 revokeTarget(或 revokeTarget || undefined)即可让 ?? 'this sender' 兜底生效。

2. 404 恢复成功后没有任何反馈

channel_pairing_approval_not_found 分支里重拉成功后,条目静默消失——既没有 revokeSuccess 也没有任何提示。从管理员视角看最终状态符合预期,所以这里设置 revokeSuccess(或一个「该授权已被撤销」的变体文案)会更完整。目前唯一的信号就是那一行消失了。

3. 硬编码的 allowedUsers 配置键ChannelEditorDialog.tsx

instance?.config['allowedUsers'] 绕过了驱动编辑器其余部分的 channel-type field descriptor。对当前的 provider 是正确的,但如果某个 provider 用了不同的白名单字段名,这个提示会静默不显示。建议加一行注释说明这个耦合,或者从 descriptor 推导出该键。

4. 无障碍:新区块没有可访问名称

<section>aria-labelledby={headingId} 指向的是「Pending requests」标题,而新增的第二个 <h4> 位于同一个 section 内且没有 id。使用屏幕阅读器按区域导航的用户会把整块都当成待处理请求面板。建议另起一个 <section aria-labelledby={approvalsHeadingId}>(至少给新标题一个 useId() 并在外层容器加 aria-labelledby)。

5. 视觉测试:去掉 editHeading.click() 可能重新引入焦点环的不确定性e2e/visuals/screenshots.spec.ts

-      await editHeading.click();
+      await page.getByRole('heading', { name: 'Pairing approvals' }).scrollIntoViewIfNeeded();

那次 click 看起来是为了在截图前把焦点从弹窗自动聚焦的控件上移走。scrollIntoViewIfNeeded() 并不会做这件事,因此弹窗自动聚焦的元素现在会带着焦点环出现在截图里。两者都保留(先 click()scrollIntoViewIfNeeded())即可保持原有的稳定化效果。

细节 / nit

  • .divider 无条件渲染,包括上方待处理请求面板仍在加载或处于错误态时——此时上面还什么都没有,分隔线略显突兀。
  • staticAllowedUsersChannelEditorDialog 每次渲染时都重新计算。它只用于渲染(不是任何 effect 的依赖),不会造成循环,但用 useMemo 会更整洁。同样地,白名单较长时会渲染成一整块不换行的 join(', ') 文本。
  • key={senderId} 假设 daemon 不会返回重复的 sender ID。基本可以确定成立,但入库时套一层 Set 会在结构上更安全。
  • 两个 Refresh 按钮不会清除彼此的成功提示(待处理请求刷新后 revokeSuccess 仍在,反之 success 亦然)。纯外观问题。
  • 与已有的待处理请求面板一样,这里没有做 daemon capability 门控,因此旧版 daemon 会一直显示「Pairing approvals were not updated」告警,而不是隐藏整个区块。与现状一致,仅在后续阶段计划加门控时供参考。

风险评估

低。纯粹是在已上线 API 之上的增量 UI,除了新增的 disabled 条件和批准后刷新之外,没有改变已有的批准流程行为。无安全隐患:sender ID 通过 React 文本节点和 aria-label 插值渲染(均会转义),破坏性操作有确认门控,鉴权仍在服务端。性能影响是配对模式频道每次打开编辑器多一次 GET

doudouOUC
doudouOUC previously approved these changes Jul 30, 2026

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

No issues found. LGTM! ✅

中文说明

未发现问题。LGTM!✅

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /takeover

@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 Jul 30, 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 is a fork PR, so the first round comes from the next scheduled scan (usually within minutes). Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@qwen-code-dev-bot

qwen-code-dev-bot commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

AutoFix round 1 finishedview run. See this round's report below.

中文说明

AutoFix 第 1 轮已完成 —— 查看运行。本轮报告见下方。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下:

Same-run verification repair

The previous commit was rejected because npm run build failed in the
@qwen-code/web-shell workspace:

client/components/channels/ChannelPairingRequests.tsx(527,17): error TS2322:
Type 'string | undefined' is not assignable to type 'string | number'.
  Type 'undefined' is not assignable to type 'string | number'.

Root cause

The prior round addressed @wenshao's suggestion that the revoke-confirmation
dialog title flashes "Revoke pairing approval for ?" during its exit
animation, by changing the interpolation to senderId: revokeTarget || undefined
so the i18n fallback ?? 'this sender' would activate. But the translator's
interpolation record is typed Record<string, string | number>, so passing
undefined is a type error — the suggestion did not account for that
constraint. The message fallback v?.senderId ?? 'this sender' can in fact
never trigger through the typed t, because undefined is not a permitted
interpolation value.

Fix (3 lines, behavior-preserving)

  • ChannelPairingRequests.tsx: reverted the interpolation to a real string,
    senderId: revokeTarget ?? '', so it satisfies string | number.
  • i18n.tsx (EN + ZH): changed the confirm.title fallback operator from
    ?? to || (${v?.senderId || 'this sender'} / ${v?.senderId || '该用户'}),
    so the empty string the typed t must pass now correctly falls back during
    the exit animation.

Net behavior is exactly what the prior round intended: with a target set the
title reads "Revoke pairing approval for ?"; while the dialog fades
out after the target is cleared it reads "Revoke pairing approval for this
sender?"
instead of the malformed "…for ?". The confirm.title key has a
single call site (verified by grep), so the message change is fully scoped.

Review feedback

No new inline findings this round — this commit only repairs the rejected
build. All earlier inline suggestions were already addressed in prior branch
commits (7095958, 3b66b09, 744aaed, 4a1d2d1, cd7ab6b) and carry
author replies; both reviewers reported no blockers.

Verification

  • npm run build — passed (the previously failing @qwen-code/web-shell
    tsc -p tsconfig.lib.json step now compiles clean)
  • npm run typecheck — passed
  • npm run lint — passed
  • npx prettier --check on the 2 changed files — passed
  • vitest run client/components/channels/ (web-shell, 5 files) — 48/48 passed
    (23 ChannelPairingRequests + 8 ChannelEditorDialog + 9 ChannelsManagerPage +
    6 channel-editor-state + 2 channel-platform)
中文说明

同轮验证修复

上一个提交被拒绝,原因是 npm run build@qwen-code/web-shell 工作区失败:

client/components/channels/ChannelPairingRequests.tsx(527,17): error TS2322:
Type 'string | undefined' is not assignable to type 'string | number'.
  Type 'undefined' is not assignable to type 'string | number'.

根因

上一轮为处理 @wenshao 的建议(撤销确认对话框标题在退场动画期间会闪现 "Revoke pairing approval for ?"),将插值改为 senderId: revokeTarget || undefined,想让 i18n 的兜底 ?? 'this sender' 生效。但翻译函数的插值记录类型为 Record<string, string | number>,传入 undefined 属于类型错误——该建议没有考虑到这一约束。事实上,由于 undefined 不是允许的插值值,消息中的兜底 v?.senderId ?? 'this sender' 经由带类型的 t 永远无法触发。

修复(3 行,行为保持一致)

  • ChannelPairingRequests.tsx:将插值恢复为一个真实字符串 senderId: revokeTarget ?? '',使其满足 string | number
  • i18n.tsx(EN + ZH):将 confirm.title 的兜底运算符由 ?? 改为 ||${v?.senderId || 'this sender'} / ${v?.senderId || '该用户'}),使得带类型的 t 必须传入的空字符串能在退场动画期间正确触发兜底。

最终行为与上一轮的预期完全一致:设置了目标时标题显示 "Revoke pairing approval for ?";在目标被清空后对话框淡出期间显示 "Revoke pairing approval for this sender?",而非异常的 "…for ?"confirm.title 键只有一个调用点(已用 grep 验证),因此该消息改动的影响范围完全可控。

审查反馈

本轮没有新的行内发现——本提交仅修复被拒绝的构建。此前所有行内建议已在之前的分支提交(70959583b66b09744aaed4a1d2d1cd7ab6b)中处理,并均有作者回复;两位审查者均报告无阻断问题。

验证

  • npm run build — 通过(此前失败的 @qwen-code/web-shelltsc -p tsconfig.lib.json 步骤现已干净编译)
  • npm run typecheck — 通过
  • npm run lint — 通过
  • npx prettier --check(2 个变更文件)— 通过
  • vitest run client/components/channels/(web-shell,5 个文件)— 48/48 通过(23 ChannelPairingRequests + 8 ChannelEditorDialog + 9 ChannelsManagerPage + 6 channel-editor-state + 2 channel-platform)

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

qwen-code-ci-bot pushed a commit that referenced this pull request Jul 30, 2026

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

No issues found. LGTM! ✅

中文说明

未发现问题。LGTM!✅

— qwen3.8-max-preview via Qwen Code /review

@wenshao

wenshao commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Local verification report — real build + real browser

Verified 221e104021 against merge-base 2abfa3d54e in a dedicated worktree, with a real Chromium
driving the actual Web Shell (vite dev server + the PR's own mock daemon). Verdict: works as
described, no correctness defect found.
Seven polish items below, none blocking.

Results

Check Result
packages/web-shell full vitest 2461 passed / 0 failed (151 files)
packages/webui full vitest 390 passed / 0 failed (27 files)
web-shell.channels.spec.ts (Playwright) 1 passed
Visual suite, Channel manager light + dark 2 passed
Ad-hoc E2E suite written for this review 15 passed
typecheck + lint (web-shell, webui) clean

The 10 build-artifact.test.ts failures you may see locally are an environment artifact — they
need packages/web-shell/dist. After npm run build in web-shell they pass, hence 2461/2461.

Non-vacuity: replaying this PR's ChannelPairingRequests.test.tsx, ChannelEditorDialog.test.tsx
and actions.test.ts against the base source fails 14 tests (expected 'Pending requests…' to contain 'Pairing approvals', expected null not to be null for the revoke button, …). The new tests
genuinely pin the new behaviour.

Backend claim is accurate. SenderGate.check() under the pairing policy short-circuits on
allowedUsers before consulting PairingStore, so the copy "Configured allowlist users remain
allowed after a pairing approval is revoked" is literally true — the two grants are independent.

Approve → list → revoke, driven in a real browser

flow

Wire-level assertions from that run: exactly one DELETE .../channels/release-bot/pairing-approvals
with body {"senderId":"user-42"}, zero PUT (neither Revoke nor the confirm button submits the
surrounding <form>), and cancelling issues no request at all. Escape on the nested confirm closes
only the confirm and leaves the Channel editor open. role="status" live region present for the
revoke result; both sections carry aria-labelledby. At a 420 px viewport the row flips to a column
and a 64-char sender ID wraps with no horizontal overflow (scrollWidth == clientWidth). zh-CN copy
is complete.

misc

Failure and edge states

states

Notes (all non-blocking)

N1 — the approvals list blanks out on every refresh, not just on Channel change.
The approvals effect unconditionally runs setApprovedSenderIds([]) + setApprovalsLoading(true),
and it is also triggered by approvalsReloadToken (bumped after every approve, and by the Refresh
button). Clearing is only needed when channelName changes. With a 2.5 s daemon response I measured
the section collapsing to a bare header with badge 0, no rows, no empty state and no skeleton — on a
slow daemon this reads as "everything was just revoked" right after the operator approved someone.
Suggestion: keep the previous list while approvalsReloadToken changes; clear only on channelName.

N2 — the badge reads 0 while the load has failed. <Badge>{approvedSenderIds.length}</Badge>
renders 0 next to the "Pairing approvals were not updated" alert (measured). 0 is exactly what a
Channel with no approvals shows, so a glance at the badge says "nobody is paired" when the truth is
"unknown". Consider , or hiding the badge while approvalsError is set.

N3 — a wedged DELETE disables the entire panel with no escape. channelPairing.approvals is
wrapped in withActionTimeout (30 s) but channelPairing.revoke is not, and revokingSenderId now
also disables every Approve button. With a never-settling DELETE I measured Approve still
disabled after 40 s; the only way out is closing and reopening the editor. approve has the same
missing timeout today, but its blast radius used to stop at the pending list — this PR widens it to
the whole panel. A withActionTimeout on revoke would bound it.

N4 — the channel_pairing_approval_not_found path is silent. On 404 the code refreshes and
returns without setting revokeSuccess or approvalsError. Measured: the row vanishes with no
message at all. Worse, if the refresh still contains the ID (someone re-paired in between), clicking
"Revoke approval" is a visible no-op — no row change, no toast, no error. A neutral "The approval was
already gone" would close that.

N5 — revoking an ID that is also allowlisted reads as "access removed". Screenshot: green
✓ "Pairing approval for user-42 was revoked." + "No pairing approvals", while the one piece of
information that contradicts it (user-42 inside the Configured-allowlist code block) is below the
fold. The generic alert is good; making it specific when staticAllowedUsers.includes(senderId)
e.g. in the confirm dialog, "user-42 is also in the configured allowlist and will keep access" —
would make the important case unmissable.

N6 — copy. (a) "Pending requests" already warns "Approvals take effect immediately; Save and
Cancel do not undo them." The approvals section has no equivalent for revocation; I confirmed the
editor's Cancel does not restore a revoked approval (reopened the editor, still gone, zero PUT).
(b) channels.editor.pairing.approvals.error = "Pairing approvals were not updated" is also shown for
an initial load failure, where "not updated" is misleading.

N7 — nit. During a revoke, "Refresh pairing approvals" is disabled but "Refresh pairing requests"
stays enabled, while Approve is disabled. Harmless, just inconsistent.

FYI (not this PR): switching the policy to Open unmounts the panel while the daemon keeps
<channel>-allowlist.json; switching back re-grants everyone instantly. Approvals for a
non-pairing Channel are therefore unreachable from the UI. Worth a follow-up if #7209 covers it.

The repo's own visual captures, reproduced locally

visuals

中文版

本地验证报告 —— 真实构建 + 真实浏览器

在独立 worktree 中基于 221e104021(merge-base 2abfa3d54e)验证,用真实 Chromium 驱动真实的
Web Shell(vite dev server + 本 PR 自带的 mock daemon)。结论:功能与描述一致,未发现正确性缺陷。
下面 7 条均为打磨项,不阻塞合并。

结果

检查项 结果
packages/web-shell 全量 vitest 2461 通过 / 0 失败(151 个文件)
packages/webui 全量 vitest 390 通过 / 0 失败(27 个文件)
web-shell.channels.spec.ts(Playwright) 1 通过
视觉套件 Channel manager 明暗双主题 2 通过
本次评审新写的 E2E 套件 15 通过
typecheck + lint(web-shell、webui) 干净

本地可能出现的 10 个 build-artifact.test.ts 失败是环境问题——它们需要 packages/web-shell/dist
web-shell 下执行 npm run build 后即全部通过,故为 2461/2461。

非空转验证: 把本 PR 的 ChannelPairingRequests.test.tsxChannelEditorDialog.test.tsx
actions.test.ts 放到 base 源码上跑,14 个用例失败expected 'Pending requests…' to contain 'Pairing approvals'、撤销按钮 expected null not to be null 等)。新增测试确实钉住了新行为。

后端语义确认无误。 pairing 策略下 SenderGate.check() 会先命中 allowedUsers 再去查
PairingStore,所以「撤销配对授权后配置白名单中的用户仍可访问」这句文案字面成立——两种授权彼此独立。

真实浏览器中的「批准 → 进列表 → 撤销」

见上方 flow 图。该次运行的协议层断言:恰好 1 次 DELETE .../channels/release-bot/pairing-approvals
body 为 {"senderId":"user-42"}0 次 PUT(撤销按钮与确认按钮都不会提交外层 <form>);点「取消」
不发任何请求。在嵌套确认框上按 Escape 只关闭确认框,频道编辑器保持打开。撤销结果有 role="status"
live region;两个分区都带 aria-labelledby。420px 视口下行会转为纵向排列,64 字符的用户 ID 正常换行且
无横向溢出(scrollWidth == clientWidth)。中文文案完整。

备注(均不阻塞)

N1 —— 每次刷新都会清空已配对列表,而不只是切换频道时。
approvals 的 effect 无条件执行 setApprovedSenderIds([]) + setApprovalsLoading(true),而
approvalsReloadToken(每次批准之后、以及点刷新按钮时)也会触发它。真正需要清空的只有 channelName
变化的情况。我把 daemon 响应延迟到 2.5 秒后实测:该分区塌缩成只剩标题、badge 显示 0、既无行也无空态
也无骨架屏——在慢 daemon 上,操作员刚批准完某人就会看到「好像全被撤销了」。建议:approvalsReloadToken
变化时保留旧列表,仅在 channelName 变化时清空。

N2 —— 加载失败时 badge 仍显示 0 <Badge>{approvedSenderIds.length}</Badge> 会和
「Pairing approvals were not updated」告警并排显示 0(实测)。而 0 恰恰也是「确实没有已配对用户」的
显示,扫一眼 badge 会得到「无人配对」的结论,实际情况是「未知」。建议在 approvalsError 存在时显示
或隐藏 badge。

N3 —— DELETE 卡住会让整个面板锁死且无出路。 channelPairing.approvals 套了
withActionTimeout(30 秒),但 channelPairing.revoke 没有;同时 revokingSenderId 现在还会禁用所有
批准按钮。用永不返回的 DELETE 实测:40 秒后批准按钮仍处于禁用,唯一出路是关掉再打开编辑器。
approve 目前同样缺超时,但它的影响范围原本止于待处理列表——本 PR 把它扩大到了整个面板。给 revoke
加上 withActionTimeout 即可收敛。

N4 —— channel_pairing_approval_not_found 分支完全静默。 收到 404 后代码刷新列表就 return
既不设 revokeSuccess 也不设 approvalsError。实测:行直接消失,没有任何提示。更糟的是,如果刷新回来的
列表里该 ID 仍在(期间有人重新配对),点「撤销授权」就是一次可见的空操作——行不变、无提示、无报错。
补一句中性的「该授权已不存在」即可。

N5 —— 撤销一个同时在白名单里的 ID,看起来像「权限已移除」。 截图:绿色 ✓「Pairing approval for
user-42 was revoked.」+「No pairing approvals」,而唯一能反驳它的信息(配置白名单代码块里的 user-42
在折叠线以下。通用告警很好;如果在 staticAllowedUsers.includes(senderId) 时把它具体化——比如在确认框里
写「user-42 同时位于配置白名单,撤销后仍可访问」——就能让这个重要场景无法被忽略。

N6 —— 文案。(a)「待处理的配对请求」已有提示「批准立即生效;保存和取消都不会撤销」。已配对分区
没有对应的撤销提示;我实测编辑器的取消按钮不会恢复已撤销的授权(重开编辑器仍然消失,且 PUT 为 0)。
(b)channels.editor.pairing.approvals.error = 「未能更新已配对用户」在初次加载失败时也会显示,
此时「未能更新」有误导性。

N7 —— 细节。 撤销进行中,「刷新已配对用户」被禁用而「刷新配对请求」仍可点,同时批准是禁用的。
无害,只是不一致。

顺带一提(非本 PR): 把策略切到「开放模式」会卸载该面板,但 daemon 仍保留
<channel>-allowlist.json;切回配对模式时所有人会立即恢复授权。也就是说非配对模式频道的已配对用户在 UI
上完全不可见。若 #7209 覆盖这一点,值得作为后续项。

@wenshao
wenshao added this pull request to the merge queue Jul 30, 2026
Merged via the queue into QwenLM:main with commit b0c0cc3 Jul 30, 2026
48 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.2.

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

Labels

autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants