Skip to content

feat(channels): support group pairing - #8440

Merged
wenshao merged 14 commits into
QwenLM:mainfrom
qqqys:codex/issue-7209-channel-group-pairing-api
Aug 7, 2026
Merged

feat(channels): support group pairing#8440
wenshao merged 14 commits into
QwenLM:mainfrom
qqqys:codex/issue-7209-channel-group-pairing-api

Conversation

@qqqys

@qqqys qqqys commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Adds pairing as a groupPolicy value so a group chat can be approved once by its stable chat ID and then used by every member. Group pairing requests share the existing pairing-code flow, retain the initiating sender for audit context, and store approvals separately from user approvals within the selected workspace. The daemon API and TypeScript SDK now expose typed user/group subjects, group approval snapshots, and group revocation.

The runtime only creates a group pairing request after a real mention or reply. Ambient messages and stored loop authorization checks stay silent. Once a group is approved, group history and scheduled loop authorization follow the group approval while direct messages continue to follow senderPolicy.

Why it's needed

User pairing is too granular for group chats: requiring every member of one group to pair separately is repetitive and does not match the group-level trust decision. This provides one approval per group without introducing another top-level policy field or weakening direct-message access control.

Reviewer Test Plan

How to verify

  1. Configure a channel with groupPolicy: "pairing", mention or reply to the bot from an unknown group, and confirm one pending request identifies the group plus the initiating member.
  2. Approve the code through the existing CLI command or daemon endpoint, then confirm another member of the same group can use the bot while an unapproved direct-message sender remains blocked.
  3. Revoke the approval with groupId, confirm the group is blocked again, and verify ambient messages or stored loop checks do not create a new pairing request.
  4. Configure the same channel name in two workspaces and confirm group approvals remain isolated by workspace.

Evidence (Before & After)

N/A — this PR adds runtime, daemon API, SDK, CLI, and documentation support. The Channel management UI is intentionally deferred to the next staged PR.

Tested on

OS Status
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

Environment (optional)

Node.js workspace with package-local Vitest suites. Verified 605 Channel Base tests, 87 CLI service/route tests, and 318 TypeScript SDK tests, plus the repository build, typecheck, lint, Prettier check, and diff check.

Risk & Scope

  • Main risk or tradeoff: User and group requests share the existing three-request pending cap; approvals are separated into user and group allowlist files so an identifier collision cannot cross authorization domains.
  • Not validated / out of scope: Web Shell configuration and approval UI, QR-based channel authentication, and live platform E2E are deferred to later staged PRs.
  • Breaking changes / migration notes: No settings migration is required. Existing pending user requests are normalized as user subjects, existing user allowlists remain unchanged, and SDK response fields are additive.

Linked Issues

Part of #7209

中文说明

本 PR 做了什么

groupPolicy 增加 pairing 值,使群聊可以按稳定的 chat ID 只审批一次,随后群内所有成员都能使用。群配对复用现有配对码流程,保留发起成员用于审计,并在所选工作空间内将群审批与用户审批分开存储。daemon API 和 TypeScript SDK 现在会暴露带类型的用户/群主体、群审批快照以及群撤销能力。

运行时只会在真实 @ 机器人或回复机器人时创建群配对请求。普通群消息和后台定时任务授权检查都会保持静默。群审批后,群历史和定时任务授权会跟随群审批,而私聊仍继续遵循 senderPolicy

为什么需要

用户级配对不适合群聊:要求同一个群的每个成员分别配对既重复,也不符合按群建立信任的决策方式。本 PR 让每个群只需审批一次,同时不引入新的顶层策略字段,也不会放宽私聊访问控制。

Reviewer 测试计划

如何验证

  1. 将频道配置为 groupPolicy: "pairing",从未知群 @ 或回复机器人,确认只产生一个待审批请求,并同时标识群和发起成员。
  2. 通过现有 CLI 命令或 daemon 接口批准配对码,确认同群另一位成员可以使用机器人,而未批准的私聊用户仍被拦截。
  3. 使用 groupId 撤销审批,确认该群重新被拦截,并确认普通群消息或定时任务检查不会自动生成新的配对请求。
  4. 在两个工作空间中配置同名频道,确认群审批按工作空间隔离。

前后对比证据

N/A — 本 PR 只增加运行时、daemon API、SDK、CLI 和文档能力。频道管理 UI 会在下一个分阶段 PR 中处理。

测试平台

OS 状态
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

环境(可选)

Node.js workspace,使用各 package 的 Vitest。已验证 605 个 Channel Base 测试、87 个 CLI service/route 测试和 318 个 TypeScript SDK 测试,并通过仓库 build、typecheck、lint、Prettier 检查和 diff 检查。

风险与范围

  • 主要风险或权衡:用户请求和群请求共享现有的三个待处理请求上限;用户审批与群审批使用不同 allowlist 文件,因此相同标识不会跨授权域生效。
  • 未验证 / 范围外:Web Shell 配置与审批 UI、扫码频道认证、真实平台 E2E,将留到后续分阶段 PR。
  • 破坏性变更 / 迁移说明:无需 settings 迁移。已有的待处理用户请求会被规范化为用户主体,已有用户 allowlist 保持不变,SDK 响应字段均为增量扩展。

关联 Issue

Part of #7209

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 3, 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 Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Re-run at 9c71442 — the only movement since the last pass (f46e265) is a maintainer merge of main into the branch (verified via the merge's parents: no branch-side commits, identical per-file stats), so this is a gate re-check over unchanged feature code.

Template looks good ✓

Problem: unchanged — the next staged slice of the Channel management roadmap (#7209), and the granularity gap is real: one group chat currently means one pairing approval per member, which doesn't match a group-level trust decision.

Direction: aligned, unchanged from the last pass — group pairing sits inside the roadmap issue's scope, and claude-code's CHANGELOG continues to show chat-channel investment in the same direction.

Size: ~824 production-logic lines (including ~63 of web-shell e2e mock-daemon plumbing), ~2,068 test lines, ~211 docs lines, 54 files. Unchanged against main since the last pass. Still above the 500-production-line mark and cross-package, so it stays flagged for maintainer awareness per the core-module rules (feat type — no block on size). Below the 1,000-line advisory.

Approach: unchanged and still sound — group subjects reuse the existing pairing-code flow, group approvals live in a separate {channel}-groups.json so identifier collisions can't cross authorization domains, ambient messages and stored loop-auth checks stay silent, and the GitHub/GitLab lanes mirror the preflight bypass without ever synthesizing mentions into unapproved groups. One staleness note from the last pass still stands: the PR body says the Web Shell UI is "deferred to the next staged PR", but the diff includes it (pulled in through the review rounds) — worth a one-line body update, not a blocker.

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

Moving on to code review. 🔍

中文说明

9c71442 上重新运行 —— 距上次通过(f46e265)的唯一变化是 maintainer 将 main 合入了本分支(已通过合并提交的父提交核实:分支侧无新提交,逐文件统计完全一致),因此本次是对未变动的功能代码重新过门槛。

模板完整 ✓

问题:结论不变 —— 这是 Channel 管理路线图(#7209)的下一个分阶段切片,粒度缺口真实存在:今天一个群聊意味着每个成员各做一次配对审批,与群级别的信任决策不匹配。

方向:对齐,与上次结论一致 —— 群配对在路线图 issue 的范围内,claude-code 的 CHANGELOG 也持续出现同方向的 chat-channel 投入。

规模:约 824 行生产逻辑(含约 63 行 web-shell e2e mock-daemon 管线),约 2,068 行测试,约 211 行文档,54 个文件。相对 main 的 diff 与上次一致。仍超过 500 生产行门槛且跨包,按核心模块规则继续标记供 maintainer 知悉(feat 类型 —— 不因规模阻塞)。低于 1,000 行大 PR 提示线。

方案:不变且依然合理 —— 群主体复用现有配对码流程,群审批单独存放在 {channel}-groups.json,标识符冲突无法跨越授权域;普通群消息与后台定时任务授权检查保持静默;GitHub/GitLab 通道镜像 preflight 旁路但不向未批准群合成 mention。上次遗留的一个描述陈旧问题仍在:PR 正文称 Web Shell UI "推迟到下一个分阶段 PR",但 diff 中已包含(评审轮次拉入)—— 建议更新一行正文,不构成阻塞。

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

进入代码审查。🔍

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 9c71442, 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 4 scenario(s).

Qwen Code · serve A/B

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Code review

This re-run re-attests the diff at 9c71442. Since the last full pass at f46e265 the branch gained exactly one commit — a merge of main (parents verified), no branch-side edits, identical per-file stats — so the functional diff under review is the same one, and I re-verified the load-bearing hunks directly against this head rather than trusting that. My independent baseline for "approve a group once by its stable chat ID" is unchanged: typed subjects in the existing PairingStore, a separate group allowlist file, a pairing branch in GroupGate that drops ambient traffic before any request exists, and fail-closed route validation. The PR still matches it.

The properties that matter are all structurally enforced, not flag-checked:

  • Ambient silence. GroupGate returns pairing_trigger_required for unmentioned/non-reply messages before touching the store, and the stored-loop authorization path passes createPairingRequest: false. Background traffic can never create requests — confirmed again in this head's hunks.
  • Group approval bypasses the sender gate in group context only. preflightInbound short-circuits for approved groups (envelope.isGroup && groupPolicy === 'pairing'), DMs keep flowing through senderPolicy; approving a group does not approve its initiator, and the tests pin both directions.
  • The store fails closed. Approve persists the allowlist before consuming the pending request (a failed write doesn't burn the code); an existing-but-unreadable group allowlist is refused on approve rather than rebuilt from []; the group allowlist gets atomic temp+rename.
  • One sender holds one pending request across subjects — closes the slot-hoisting hole in the shared 3-request cap, with group-worded rejection so the group isn't told about a member's unrelated DM request.
  • GitHub/GitLab lanes mirror the preflight bypass without widening it: directed follow-ups from an approved repo pass, but a mention is never synthesized into an unapproved pairing group, and the first-contact body is recorded as consumed so a re-listed thread can't re-dispatch the same intent.
  • Route validation fails closed: both senderId and groupId → 400, neither → 400, empty string → 400, each with a distinct code; the SDK revocation type makes the two mutually exclusive at compile time.
  • The Web Shell UI degrades gracefully against older daemonsgroupIds ?? [] and a user-subject fallback when subject is absent.

No correctness, security, or convention blockers found. The three non-blocking notes from the last pass stand: (1) the PR body still says the Web Shell UI is deferred while the diff includes it; (2) the user allowlist and pending file still use plain writeFileSync while the new group allowlist gets temp+rename — pre-existing asymmetry, the new file is strictly the more careful one; (3) on GitHub/GitLab a "group" is a repo, so per-repo approval semantics deserve a line in the plugin docs eventually. None merge-blocking.

The group pairing flow this PR introduces:

sequenceDiagram
    participant P1 as Chat member
    participant P2 as Channel runtime preflight
    participant P3 as GroupGate
    participant P4 as PairingStore
    participant P5 as Operator CLI or daemon
    P1->>P2: mention or reply in unapproved group
    P2->>P3: check envelope
    P3->>P4: createGroupRequest (chat id, initiator)
    P4-->>P3: pairing code
    P3-->>P2: pairing_required
    P2-->>P1: code posted once per group and code
    P5->>P4: approve code
    P4->>P4: write group allowlist, then consume request
    P1->>P2: later message from any member
    P2->>P3: check envelope
    P3-->>P2: approved group passes, sender gate skipped in group context
Loading
Files changed (30 of 54 shown)
File What changed
packages/channels/base/src/PairingStore.ts typed user/group subjects, separate group allowlist file, one-pending-per-sender, approve persists before consuming, fail-closed reads, atomic group writes
packages/channels/base/src/GroupGate.ts pairing branch that drops ambient messages before request creation, createPairingRequest opt-out, isGroupApproved helper
packages/channels/base/src/SenderGate.ts pairing result carries the typed CreatePairingRequestResult instead of a bare code
packages/channels/base/src/ChannelBase.ts preflight wiring for group pairing, once-per-group public notification, loop-auth and history follow group approval
packages/channels/base/src/types.ts GroupPolicy gains the pairing value
packages/channels/base/src/index.ts exports the new subject/result types
packages/channels/github/src/GithubAdapter.ts directed lane mirrors the approved-group bypass, never synthesizes mentions into unapproved groups, consumes first-contact body
packages/channels/github/src/index.ts pairing offered in the channel settings catalog
packages/channels/gitlab/src/GitlabAdapter.ts same directed-lane mirroring as GitHub
packages/channels/gitlab/src/index.ts pairing offered in the channel settings catalog
packages/cli/src/commands/channel/pairing.ts list and approve print group subjects with the initiating member
packages/cli/src/serve/channel-management-service.ts revocation by typed subject, approvals snapshot carries groupIds, pairing enabled when either policy is pairing
packages/cli/src/serve/routes/workspace-channel-management.ts revoke body parses exactly one of senderId or groupId, fail-closed 400s
packages/sdk-typescript/src/daemon/types.ts optional subject on requests, groupIds on snapshots, compile-time exclusive revocation union
packages/web-shell/client/components/channels/ChannelPairingRequests.tsx approvals list and revocation handle user and group targets, old-daemon fallbacks
packages/web-shell/client/components/channels/ChannelEditorDialog.tsx group policy editor offers pairing
packages/web-shell/client/i18n.tsx group subject and requested-by strings
packages/webui/src/daemon/workspace/actions.ts revocation action plumbs the typed subject
packages/channels/base/src/PairingStore.test.ts subject dedup, cap, expiry, fail-closed reads, migration of the group file
packages/channels/base/src/GroupGate.test.ts ambient silence, trigger opt-out, approved-group fallthrough
packages/channels/base/src/ChannelBase.test.ts preflight wiring, once-per-group notification, loop-auth and history behavior
packages/channels/base/src/SenderGate.test.ts typed pairing results
packages/channels/github/src/GithubAdapter.test.ts directed lane for approved paired repos, no synthesized mentions
packages/cli/src/serve/channel-management-service.test.ts service-level approve/revoke by subject, error paths
packages/cli/src/serve/routes/workspace-channel-management.test.ts route validation matrix for the revoke body
packages/web-shell/client/components/channels/ChannelPairingRequests.test.tsx UI approval/revocation flows for both subject kinds
packages/web-shell/client/e2e/web-shell.channels.spec.ts e2e smoke for the pairing UI
packages/sdk-typescript/test/unit/DaemonClient.test.ts SDK round-trip for group approvals and revocation
docs/users/features/channels/overview.md documents the group pairing policy end to end
docs/users/features/channels/github.md per-repo pairing semantics for the GitHub channel (gitlab.md mirrors it)
…and 24 more files remaining docs pages, i18n/e2e plumbing, registry and webui type exports

Testing evidence — the PR's own CI at this commit (never run by the triage)

Unattended run, so no PR code was built or executed here; this is the check signal on 9c71442 itself. Every substantive check is green on the merge commit. The two exceptions are bot orchestration, not PR CI: review-scan is the autofix scan triggered by the review event, and it failed with metadata_fetch_failed after three transient GitHub-API retries ("Forced PR #8440 admission blocked: metadata_fetch_failed") — an infra blip, no relation to the diff; review-pr is the automatic review job from the author's /review command, still running and marked non-blocking by this repo's own config.

Check Conclusion
Test (ubuntu-latest, Node 22.x) success
Desktop Shell (ubuntu-22.04) success
Live Host (macos-latest) success
Serve A/B (ubuntu-latest, Node 22.x) success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) success
Capture web-shell visuals (ubuntu-latest, Node 22.x) success
Real daemon E2E / Java 11 success
ubuntu-latest / Java 17, Java 21 success
macos-latest / Java 21 success
windows-latest / Java 21 success
precheck-pr / precheck success
Test (macos-latest, Node 22.x) skipped
Test (windows-latest, Node 22.x) skipped
Integration Tests (CLI, No Sandbox) skipped
review-pr in_progress
review-scan failure (bot orchestration — infra)

The central claim here is behavioural, and the strongest evidence for it is not mine to produce on the CI path: the maintainer's real-stack verification (real runtime, real daemon, real SDK, real browser, plus three mutation tests) is on record in this thread at the prior head f46e265, and the current head differs from it only by the main merge — same feature diff, now covered by the green E2E checks above (Real daemon E2E, Serve A/B, web-shell E2E Smoke). Sandboxed verification would settle any remaining gap at the current head: a sponsored @qwen-code /verify run (the author is a fork, so a maintainer's comment triggers it, with the pre-execution risk screen and workspace wipe) would pin 9c71442 the way the real-stack run pinned f46e265 — name it only if you want belt-and-braces, since the delta between the two heads carries no feature change.

中文说明

代码审查:本次重跑在 9c71442 上重新背书 diff。自上次在 f46e265 的完整审查以来,分支只多了一个合并 main 的提交(父提交已核实),分支侧无改动、逐文件统计一致,因此待审的功能 diff 与上次相同;我没有依赖这一事实,而是直接在本 head 上复核了关键 hunk。我独立设想的方案(PairingStore 中的类型化主体、独立群 allowlist 文件、GroupGate 在产生请求前丢弃普通消息的 pairing 分支、失败关闭的路由校验)与 PR 一致。

关键属性都是结构性保证:普通消息静默(先于 store 返回 pairing_trigger_required,定时任务授权检查传 createPairingRequest: false);群批准仅在群上下文绕过发送者门(DM 仍走 senderPolicy);store 失败关闭(先落盘 allowlist 再消费请求、不可读 allowlist 拒绝重建、原子写);同一发送者跨主体只能持有一个 pending 请求;GitHub/GitLab 通道镜像旁路但不向未批准群合成 mention;撤销路由对歧义请求体返回 400;Web Shell UI 对旧 daemon 优雅降级。未发现正确性、安全或规范层面的阻塞问题。上次三条非阻塞备注仍然成立:PR 正文关于 UI 推迟的描述已过时;用户 allowlist 仍用普通写而群 allowlist 用 temp+rename(既有不对称,新文件更谨慎);GitHub/GitLab 上"群"即仓库,按仓库审批的语义值得日后写进插件文档。

测试证据:无人值守运行,未构建或执行任何 PR 代码;上表为 9c71442 本 commit 的 CI 信号。全部实质检查通过。两个例外属于机器人编排而非 PR CI:review-scan(autofix 扫描)在三次瞬态 API 重试后以 metadata_fetch_failed 失败,属基础设施抖动;review-pr(作者 /review 触发的自动评审)仍在运行,本仓库配置中即为非阻塞。行为性主张的最强证据是 maintainer 在本线程记录的上一 head f46e265 真实栈验证;当前 head 与其仅差一次 main 合并,功能 diff 未变且已被本 head 的绿色 E2E 覆盖。如需在当前 head 再加一道保险,可由 maintainer 发起受助 @qwen-code /verify(fork 作者,带执行前风险筛查与工作区清理)。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — clean review and green CI at this commit with the maintainer's real-stack sign-off already on record; the Stage 0 size escalation (~824 production lines, cross-package) caps the bot verdict at defer, and that cap is pure policy, not doubt.

Stepping back: the only change since the last pass is the maintainer's own merge of main into the branch — no feature code moved, and the full suite re-ran green on the merge commit, so the assessment carries over with the new attestation. The approach remains the one I'd have proposed independently: extend the existing pairing-code flow to group subjects instead of inventing a second mechanism, with the authorization boundary (group approvals in their own file, fail-closed everywhere an ambiguous state exists) doing the work instead of flags. The scope looks large on paper — 54 files — but decomposes into one small store/gate change plus the surfaces the review rounds explicitly pulled in; nothing in the diff is drive-by, and every round of review feedback since has been addressed or answered.

What withholds the bot's approval is mechanical, not substantive: a cross-package feat above 500 production lines is escalated for maintainer awareness under the core-module rules, and that escalation caps the bot at a comment-only outcome. ⏸️ Deferring to @wenshao — except the human call this policy exists to demand is already on record twice over: the real-stack verification of every Reviewer Test Plan step (plus mutation tests) at f46e265, and approvals pinned to exactly this commit 9c71442 at 02:26 and 04:59 UTC today, the second one after the main merge. Recording the cap here so the thread shows why the bot vote is withheld rather than reading it as reservation about the code. For clarity on the PR state: the bot's standing CHANGES_REQUESTED reviews date from rounds 1–8 and target long-superseded commits — every finding since was addressed, and this pass found nothing new. The non-blocking notes (description staleness, allowlist write-symmetry, per-repo approval semantics in the plugin docs) stand as follow-ups, none merge-blocking.

中文说明

信心:3/5 —— 该 commit 上审查干净、CI 全绿,且 maintainer 的真实栈背书已记录在案;Stage 0 规模升级(约 824 行生产代码、跨包)将机器人结论上限锁定在 defer,该上限纯属策略,而非疑虑。

退一步看:距上次通过的唯一变化是 maintainer 亲自把 main 合入本分支 —— 功能代码未动,完整测试套件在合并提交上重新全绿,因此结论随新的背书延续。方案仍是我会独立提出的那一个:把现有配对码流程扩展到群主体,而不是再造一套机制;授权边界(群审批单独成文件、一切歧义状态处失败关闭)承担了原本要靠标志位做的工作。54 个文件看似庞大,但可分解为一个小的 store/gate 改动加上评审轮次明确要求纳入的各个面;diff 中没有顺手改动,此后每一轮评审反馈都已处理或回应。

机器人不投批准票的原因是程序性的,而非实质性的:跨包且超过 500 生产行的 feat 按核心模块规则需升级供 maintainer 知悉,该升级把机器人锁定在仅评论的结论。⏸️ 转交 @wenshao —— 只是该策略所要求的人工裁决已经两次记录在案:在 f46e265 上对 Reviewer Test Plan 每一步的真实栈验证(外加变异测试),以及今天 02:26 与 04:59(UTC)两次钉在恰好这个 commit 9c71442 上的批准,第二次还在 main 合并之后。在此记录上限,是为了让线程显示机器人弃权的原因,而不是被读成对代码有保留。关于 PR 状态的说明:机器人挂起的 CHANGES_REQUESTED 评审来自第 1–8 轮,针对的是早已被取代的提交 —— 其后的每项发现都已处理,本次审查也没有新发现。非阻塞备注(描述过时、allowlist 写入对称性、插件文档中按仓库审批的语义)作为后续事项保留,均不阻塞合并。

Qwen Code · qwen3.8-max

Reviewed at 9c7144211bf17fbb05198e13779b01da3448aede · 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.

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

中文说明

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

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

Comment on lines 3060 to 3064
senderId: string;
senderName: string;
subject?: DaemonChannelPairingSubject;
code: string;
createdAt: number;

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] R1-1: The Web Shell pairing panel is not subject-aware. This diff makes subject the approval target and demotes senderId/senderName to "the initiating sender", but the shipped panel (ChannelPairingRequests.tsx, untouched here) still renders senderName || senderId, lists only snapshot.senderIds, and revokes with { senderId } only — there are zero subject|groupIds matches in the web-shell channel components.

Failure scenario: on the supported dual config (senderPolicy:'pairing' + groupPolicy:'pairing') the panel IS shown (it gates on senderPolicy === 'pairing', ChannelEditorDialog.tsx:663). A mention in an unapproved group shows a row labeled "Alice" (the initiator); the operator clicks Approve believing they pair one user — the daemon approves the entire group, granting every member bot access; the success toast again says "Alice"; the grant never appears in the approvals list and cannot be revoked from the UI.

Suggested fix: render request.subject (e.g. Group: {name} ({id}) — requested by {senderName}), report the subject in the approve toast, show snapshot.groupIds in the approvals list, and extend the webui revoke action to pass { groupId } (the SDK union already supports it) — or hide group requests in the panel until the staged UI PR lands.

中文说明

Web Shell 配对面板不感知 subject。本 diff 将 subject 作为审批对象,senderId/senderName 降级为"发起成员",但已上架的面板(ChannelPairingRequests.tsx,本 PR 未改动)仍渲染 senderName || senderId、审批列表只读 snapshot.senderIds、撤销只发 { senderId }——web-shell 频道组件中没有任何 subject|groupIds 引用。

失败场景:在官方支持的双配置(senderPolicy:'pairing' + groupPolicy:'pairing')下面板会显示(显示条件为 senderPolicy === 'pairing')。未审批群中的 @ 会生成一条显示为 "Alice"(发起者)的请求;运营者以为在为单个用户授权并点击批准——daemon 实际批准了整个群,所有群成员都获得机器人使用权;成功提示仍显示 "Alice";该授权不出现在审批列表中,也无法在 UI 中撤销。

建议修复:渲染 request.subject(如 Group: {name} ({id}) — requested by {senderName})、批准提示使用 subject、审批列表展示 snapshot.groupIds、将 webui 撤销扩展为可传 { groupId }(SDK 联合类型已支持)——或在分阶段 UI PR 落地前在面板中隐藏群请求。

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

Comment on lines +65 to +68
this.groupAllowlistPath = path.join(
this.dir,
`${safeChannelName}-group-allowlist.json`,
);

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] R1-10: Allowlist filename suffix collision across channels. The new group allowlist file is <name>-group-allowlist.json; because that suffix ends with the pre-existing user-allowlist suffix -allowlist.json, a channel named support and a channel named support-group in the same workspace resolve to the identical path — channel support's GROUP allowlist file IS channel support-group's USER allowlist file. Channel names are unrestricted config keys, and pre-PR the two suffixes were disjoint, so this collision class is introduced by this diff.

Failure scenario (probe-confirmed in this tree): approving a group on support writes the group ID into support-group's user allowlist (it surfaces in that channel's approvals; where user and group IDs share a namespace, a matching sender becomes approved on a channel nobody approved them for), and revoking on support-group removes channel support's group approval. The same hazard exists in the legacy unscoped layout.

Suggested fix: use a suffix that does not embed another suffix, e.g. -groups.json (only PairingStore and the README reference the spelling), plus a regression test mirroring the workspace-scoping one: stores for channels support and support-group in one workspace, approve a group on one, assert the other channel's approvals are unaffected.

中文说明

allowlist 文件名后缀跨频道冲突。新的群 allowlist 文件为 <name>-group-allowlist.json;由于该后缀既有用户 allowlist 后缀 -allowlist.json 结尾,同一工作空间内名为 supportsupport-group 的两个频道会解析到同一路径——support 的群 allowlist 文件就是 support-group 的用户 allowlist 文件。频道名是不受限的配置键,且 PR 之前两个后缀互不包含,因此这一冲突类是本 diff 引入的。

失败场景(已在本树用探针证实):在 support 上批准一个群会把群 ID 写入 support-group用户 allowlist(会出现在该频道的审批列表;在用户与群 ID 共用命名空间时,匹配的发送者会在无人批准的频道上获得授权);在 support-group 上撤销会移除 support 的群授权。旧的无作用域布局同样存在该风险。

建议修复:改用不嵌套其他后缀的文件名,如 -groups.json(仅 PairingStore 与 README 引用该拼写),并仿照工作空间隔离测试新增回归测试:在同一工作空间构造 supportsupport-group 两个 store,在其中一个批准群,断言另一个频道的审批不受影响。

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

Comment on lines +212 to +216
const existing = active.find(
(request) =>
request.subject.type === subject.type &&
request.subject.id === subject.id,
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-2: Subject-based pending-request dedup has no test. The dedup match changed from sender-keyed (r.senderId === senderId) to (subject.type, subject.id); no test calls createGroupRequest twice for an existing subject (whether from the same or a different initiating sender).

Failure scenario: if the match regresses to sender-keyed, two different members mentioning the bot in the same group create two pending entries with distinct codes instead of reusing one — pairing list shows duplicates for one group, each consumes one of the 3 cap slots, and a third group's mention hits the overflow path despite only two groups having asked. All tests stay green.

Suggested fix: add a PairingStore test — createGroupRequest('group-1', …, 'alice', …) then createGroupRequest('group-1', …, 'bob', …) returns the same code and listPending() has exactly one entry with subject.id === 'group-1' and senderId still 'alice'.

中文说明

按 subject 去重的待处理请求逻辑没有测试。去重匹配从按发送者(r.senderId === senderId)改为按 (subject.type, subject.id);没有任何测试对同一 subject 二次调用 createGroupRequest(无论发起者是否相同)。

失败场景:若匹配退化为按发送者,同一群内两个不同成员 @ 机器人会创建两条不同配对码的待处理请求而非复用一条——pairing list 显示同一群的重复项,各占 3 个上限槽位之一,第三个群的 @ 会触发溢出提示,尽管实际只有两个群请求过。所有测试仍为绿色。

建议修复:新增 PairingStore 测试——先 createGroupRequest('group-1', …, 'alice', …)createGroupRequest('group-1', …, 'bob', …),断言返回相同配对码且 listPending() 仅一条 subject.id === 'group-1'senderId 仍为 'alice' 的记录。

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

Comment on lines +64 to +69
if (
options.createPairingRequest === false ||
(!envelope.isMentioned && !envelope.isReplyToBot)
) {
return { allowed: false, reason: 'pairing_trigger_required' };
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-3: The pairing trigger matrix has two untested cells. (a) isReplyToBot: true under the pairing policy appears in no test, though the docs added by this PR promise "A deliberate mention or reply from an unknown group creates one pairing request". (b) No test pairs requireMention: false with the pairing policy, so the documented "unmentioned messages never create a request even with requireMention: false" guarantee is unpinned.

Failure scenario: (a) deleting && !envelope.isReplyToBot makes reply-only groups unpairable, all tests green; (b) deleting the in-block trigger check (harmless under default requireMention: true) lets ambient chatter in requireMention: false groups create requests and consume the pending cap, all tests green.

Suggested fix: add to the pairing policy describe — a test with envelope({ isGroup: true, isReplyToBot: true }) + a store asserting a request is created; and a test with new GroupGate('pairing', { '*': { requireMention: false } }, store) + an unmentioned envelope asserting pairing_trigger_required and an empty pending list.

中文说明

配对触发矩阵有两格未测试。(a) 配对策略下 isReplyToBot: true 没有任何测试,而本 PR 新增文档承诺"未审批群的明确 @ 或回复会创建一个配对请求";(b) 没有测试将 requireMention: false 与配对策略组合,因此文档中"即使群设置 requireMention: false,未 @ 消息也绝不创建请求"的保证未被固化。

失败场景:(a) 删除 && !envelope.isReplyToBot 会使"仅回复"的群永远无法配对,所有测试仍绿;(b) 删除块内触发检查(在默认 requireMention: true 下无害)会让 requireMention: false 群的普通消息创建请求并占满待处理上限,所有测试仍绿。

建议修复:在 pairing policy describe 中新增两个测试——envelope({ isGroup: true, isReplyToBot: true }) + store,断言创建了请求;new GroupGate('pairing', { '*': { requireMention: false } }, store) + 未 @ 消息,断言返回 pairing_trigger_required 且待处理列表为空。

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

Comment on lines +5776 to +5780
protected async onGroupPairingRequired(
chatId: string,
code: string | null,
threadId?: string,
): Promise<void> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-4: Group pairing notification content and the MAX_PENDING overflow branch are untested. The only test covering onGroupPairingRequired asserts expect(ch.sent).toHaveLength(1) — nothing about the text; the code === null branch ("Too many pending pairing requests…") appears in zero test files, and no test seeds the group pending queue to cap. The adjacent user-pairing tests DO assert content (toContain('pairing code')).

Failure scenario: dropping ${code} from the template, or inverting if (code) so every group gets the cap message, ships green — groups can never learn their code from the chat.

Suggested fix: in the group test assert expect(ch.sent[0]!.text).toContain(request!.code) (ideally also 'pairing approve'); add one cap test: seed 3 pending requests, mention from a fourth group, assert the "Too many pending pairing requests" message is sent.

中文说明

群配对通知内容与 MAX_PENDING 溢出分支未被测试。覆盖 onGroupPairingRequired 的唯一测试只断言 ch.sent 长度为 1,不断言文本;code === null 分支("Too many pending pairing requests…")在任何测试文件中都未出现,也没有测试把群待处理队列填满到上限。相邻的用户配对测试是有内容断言的(toContain('pairing code'))。

失败场景:从模板中删掉 ${code},或反转 if (code) 使每个群都收到上限提示,都能在所有测试绿色的情况下上线——群永远无法从聊天中获知配对码。

建议修复:在群测试中断言 expect(ch.sent[0]!.text).toContain(request!.code)(最好也断言 'pairing approve');新增一个上限测试:预置 3 条待处理请求后让第四个群 @,断言发送了 "Too many pending pairing requests" 消息。

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

Comment on lines +4846 to +4849
if (envelope.isGroup && this.config.groupPolicy === 'pairing') {
this.markPreflighted(envelope);
return true;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-22 (pattern 3/3 — DM negative space under groupPolicy:'pairing'): this approved-group early return is scoped to group messages only by envelope.isGroup &&, and no test sends a DM on a groupPolicy: 'pairing' channel — so the live inbound path's DM guard is unpinned (distinct from R1-14's GroupGate-level and R1-20's loop-path mutants; this mutant over-admits).

Failure scenario (probe-confirmed): deleting envelope.isGroup && leaves all 605 channel-base tests green, and a probe DM from an unlisted stranger reaches the agent session (bridge.prompt called) under both senderPolicy:'allowlist' (empty) and 'pairing' — no rejection, no pairing request — contradicting the doc guarantee this PR adds: "senderPolicy continues to control direct messages".

Suggested fix: add a ChannelBase inbound test on groupPolicy: 'pairing' — (a) senderPolicy: 'allowlist', allowedUsers: [] asserting an unapproved sender's DM is rejected (no bridge.prompt); (b) senderPolicy: 'pairing' asserting the DM triggers the user pairing flow (code notification) rather than a session.

中文说明

(模式 3/3 —— groupPolicy:'pairing' 下的 DM 负空间)这个"已审批群提前返回"仅靠 envelope.isGroup && 限定于群消息,而没有任何测试在 groupPolicy: 'pairing' 频道上发送 DM——因此实时入站路径上的 DM 守卫未被固化(不同于 R1-14 的 GroupGate 层与 R1-20 的循环路径变异体;此变异体是过度放行)。

失败场景(已用探针证实):删除 envelope.isGroup && 后全部 605 个 channel-base 测试仍绿,探针中未列入名单的陌生人的 DM 在 senderPolicy:'allowlist'(空名单)与 'pairing' 下都能到达 agent 会话(bridge.prompt 被调用)——无拒绝、无配对请求——与本 PR 新增的文档保证"senderPolicy 继续控制私聊"相悖。

建议修复:新增 groupPolicy: 'pairing' 的 ChannelBase 入站测试——(a) senderPolicy: 'allowlist', allowedUsers: [] 时断言未审批发送者的 DM 被拒绝(bridge.prompt 未调用);(b) senderPolicy: 'pairing' 时断言 DM 触发用户配对流程(发送配对码)而非进入会话。

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

Comment on lines 33 to +34
private allowlistPath: string;
private groupAllowlistPath: string;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-15: The new group-allowlist file is missing from migrateLegacyState. legacyPairs copies only <name>-pairing.json and <name>-allowlist.json from the legacy global layout before writing the one-shot .migrated sentinel; this PR adds a third state file but no third migration pair. The legacy layout remains reachable (no-cwd construction is supported and tested; hand-written channel configs may omit cwd).

Failure scenario: a channel running in the legacy layout with groupPolicy:'pairing' accumulates approved groups in the global <name>-group-allowlist.json; when the channel later resolves to a scoped layout, the migration copies only user state and seals the gate — approved groups are silently lost (group messages drop or re-trigger pairing) and the orphaned file is never absorbed. Fails safe (drops access, grants none), but loses approvals the migration exists to preserve.

Suggested fix: add a third pair to legacyPairs[path.join(channelsRoot, ${channelName}-group-allowlist.json), this.groupAllowlistPath] — plus a grandfathering test that seeds a legacy group-allowlist file and asserts the scoped store reports the group approved.

中文说明

新的群 allowlist 文件未纳入 migrateLegacyStatelegacyPairs 在写入一次性的 .migrated 哨兵前只从旧的全局布局复制 <name>-pairing.json<name>-allowlist.json;本 PR 新增了第三个状态文件却没有新增第三个迁移对。旧布局仍然可达(不传 cwd 的构造受支持且有测试;手写频道配置可能省略 cwd)。

失败场景:以旧布局运行且 groupPolicy:'pairing' 的频道会在全局 <name>-group-allowlist.json 中积累已审批群;当该频道后续解析到带作用域的布局时,迁移只复制用户状态并永久封口——已审批群被静默丢失(群消息被丢弃或重新触发配对),孤儿文件再也不会被吸收。失败方向安全(只收不放),但丢失了迁移本应保留的授权。

建议修复:为 legacyPairs 增加第三对——[path.join(channelsRoot, ${channelName}-group-allowlist.json), this.groupAllowlistPath]——并新增祖父条款测试:预置旧布局的群 allowlist 文件,断言带作用域的 store 报告该群已审批。

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

Comment on lines +575 to +578
const revoked =
subject.type === 'group'
? store.revokeGroup(subject.id)
: store.revoke(subject.id);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-16: The group revocation not-found path is unpinned end-to-end. No test drives revokeGroup returning false through the service/route: the user-flow double-revoke 404 IS tested, the new group test revokes only the sole present entry, route-level 404 tests mock the service, and PairingStore.test.ts has no revokeGroup(...)).toBe(false) assertion.

Failure scenario: a mutant making the group path always report success (e.g. store.revokeGroup(subject.id) || true) passes the whole suite green — DELETE .../pairing-approvals with a never-approved (or already-revoked) { groupId } returns 200 success instead of 404 channel_pairing_approval_not_found, so an operator believes they revoked a group's bot access when nothing changed.

Suggested fix: in 'manages group pairing when groupPolicy uses pairing mode', after the successful revocation add await expect(service.revokePairingApproval('bot', { type: 'group', id: 'group-1' })).rejects.toMatchObject({ code: 'channel_pairing_approval_not_found' }); (mirroring the user-flow double-revoke).

中文说明

群撤销"未找到"路径端到端未被固化。没有测试让 revokeGroup 返回 false 并穿透 service/路由:用户流程的二次撤销 404 有测试,新群测试只撤销唯一存在的条目,路由层 404 测试 mock 了 service,PairingStore.test.ts 也没有 revokeGroup(...)).toBe(false) 断言。

失败场景:让群路径恒报成功的变异体(如 store.revokeGroup(subject.id) || true)可在整套测试绿色下通过——对从未批准(或已撤销)的 { groupId } 执行 DELETE .../pairing-approvals 会返回 200 成功而非 404 channel_pairing_approval_not_found,运营者以为撤销了某群的机器人权限,实际什么都没变。

建议修复:在 'manages group pairing when groupPolicy uses pairing mode' 的成功撤销之后追加 await expect(service.revokePairingApproval('bot', { type: 'group', id: 'group-1' })).rejects.toMatchObject({ code: 'channel_pairing_approval_not_found' });(仿照用户流程的二次撤销)。

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

Comment on lines +407 to +410
if (
config['senderPolicy'] !== 'pairing' &&
config['groupPolicy'] !== 'pairing'
) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-17: The rewritten pairing-enabled guard's negative space is unpinned. No test pairs a present but non-pairing groupPolicy ('open'/'allowlist'/'disabled') with a non-pairing senderPolicy; the tested configs are senderPolicy:'pairing' (no groupPolicy), senderPolicy:'open' + groupPolicy:'pairing', and senderPolicy:'open' (no groupPolicy).

Failure scenario: the mutant config['groupPolicy'] !== 'pairing'config['groupPolicy'] === undefined passes every current test. Under it, on a channel configured senderPolicy:'open', groupPolicy:'open' (no pairing anywhere), all four pairing operations succeed instead of 409 channel_pairing_not_enabled — an operator can approve a stale pending code on a fully-open channel and write allowlist entries that nothing enforces.

Suggested fix: add a negative case to the service test — config { type: 'dingtalk', senderPolicy: 'open', groupPolicy: 'allowlist' } (and/or 'disabled'), expecting all four pairing operations to reject with code: 'channel_pairing_not_enabled'.

中文说明

重写后的"配对已启用"守卫的负空间未被固化。没有测试用存在但非配对groupPolicy'open'/'allowlist'/'disabled')搭配非配对 senderPolicy;现有测试配置为 senderPolicy:'pairing'(无 groupPolicy)、senderPolicy:'open' + groupPolicy:'pairing'senderPolicy:'open'(无 groupPolicy)。

失败场景:变异体 config['groupPolicy'] !== 'pairing'config['groupPolicy'] === undefined 可通过当前全部测试。在该变异体下,配置为 senderPolicy:'open', groupPolicy:'open'(完全无配对)的频道上,四个配对操作都会成功而非返回 409 channel_pairing_not_enabled——运营者可以在全开放频道上批准过期的待处理配对码,写入无人强制执行的 allowlist 条目。

建议修复:在 service 测试中新增反例——配置 { type: 'dingtalk', senderPolicy: 'open', groupPolicy: 'allowlist' }(和/或 'disabled'),断言四个配对操作均以 code: 'channel_pairing_not_enabled' 拒绝。

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

Comment on lines 369 to 371
DaemonChannelPairingRequest,
DaemonChannelPairingSubject,
DaemonChannelPairingRequestsSnapshot,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-23: DaemonChannelPairingSubject is missing from the webui re-export barrels. This PR exports the new type from both SDK barrels but not from the curated @qwen-code/webui barrels (packages/webui/src/daemon/index.ts and daemon-react-sdk.ts), where every sibling pairing type IS re-exported under a comment stating the list exists "to allow web-shell and other UI packages to depend only on @qwen-code/webui". The re-exported parent DaemonChannelPairingRequest now has a member (subject) whose type cannot be named from the same barrel.

Failure scenario: the subject-aware rendering the Web Shell panel needs (see R1-1) cannot name request.subject's type from the barrel the contract tells UI packages to use, forcing the direct @qwen-code/sdk/daemon import the barrels exist to prevent; the barrel surface silently diverges from the SDK surface at the exact release the family grows. (Current consumers bypass the barrel for this family, so nothing breaks today — impact bounded at Suggestion.)

Suggested fix: add DaemonChannelPairingSubject next to DaemonChannelPairingRequest in the export type { ... } from '@qwen-code/sdk/daemon' block of packages/webui/src/daemon/index.ts and in the pairing-family block of packages/webui/src/daemon-react-sdk.ts.

中文说明

DaemonChannelPairingSubject 未加入 webui 的重导出桶。本 PR 在两个 SDK 桶中导出了新类型,却没有加入精选的 @qwen-code/webui 桶(packages/webui/src/daemon/index.tsdaemon-react-sdk.ts)——那里重导出了所有同族配对类型,且注释写明该列表的存在是"为了让 web-shell 和其他 UI 包只依赖 @qwen-code/webui"。被重导出的父类型 DaemonChannelPairingRequest 现在有一个成员(subject),其类型无法从同一个桶中命名。

失败场景:Web Shell 面板所需的 subject 感知渲染(见 R1-1)无法从契约指定的桶中命名 request.subject 的类型,被迫直接 import @qwen-code/sdk/daemon——正是这些桶要避免的;桶表面在该类型族扩展的版本悄悄与 SDK 表面脱节。(当前消费者本就绕过该桶使用此类型族,因此今天不会有东西坏掉——影响界定为 Suggestion。)

建议修复:在 packages/webui/src/daemon/index.tsexport type { ... } from '@qwen-code/sdk/daemon' 块中、以及 packages/webui/src/daemon-react-sdk.ts 的配对类型族块中,把 DaemonChannelPairingSubject 加在 DaemonChannelPairingRequest 旁边。

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 3, 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 9c71442. 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

github-channel-editor-credential-dark before/after

github-channel-editor-credential-light before/after

github-channel-editor-dark before/after

github-channel-editor-light before/after

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

Qwen Code · web-shell visuals

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

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

中文说明

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

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

Comment on lines +613 to +618
const effectiveGroupPolicy = String(
draft.values['groupPolicy'] ?? '',
);
const showPairing =
effectivePolicy === 'pairing' ||
effectiveGroupPolicy === 'pairing';

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] R2-1: The group-pairing half of the showPairing gate reads draft.values['groupPolicy'], which is only populated when the channel descriptor declares a groupPolicy management field. Only the github and gitlab adapters declare one; the manageable IM adapters (dingtalk, wecom, feishu) do not — and createChannelEditorDraft has no instance-config fallback for groupPolicy, unlike senderPolicy above. For a dingtalk/wecom/feishu channel configured senderPolicy: 'open'|'allowlist' + groupPolicy: 'pairing' — a first-class config this PR's daemon service and its own service test (manages group pairing when groupPolicy uses pairing mode) explicitly support — showPairing is false and the pairing panel (pending group requests, approval, revocation — the Web Shell surface this PR adds) never renders. The inner render decision (~line 669) correctly consults instance?.config.groupPolicy === 'pairing' — asymmetry inside the same diff. Probe-confirmed: rendering this dialog with the real dingtalk descriptor and instance {senderPolicy: 'open', groupPolicy: 'pairing'} shows no pairing section and never calls listPairingRequests; the fix below flips the probe and keeps all 34 existing dialog/panel tests green. Note: the new test shows pairing management when only group pairing is enabled masks this because its synthetic descriptor fabricates a groupPolicy field no real IM plugin provides — it should use the real descriptor shape. — Failure scenario: operator configures a dingtalk/wecom/feishu channel with senderPolicy: 'open' + groupPolicy: 'pairing'; an unapproved group mentions the bot and a pending group pairing request is created; opening the channel editor in Web Shell shows no pairing section at all, and no other Web UI surface exists (ChannelsManagerPage wires it only into this dialog). Approve/revoke from Web Shell is impossible; the operator must fall back to the CLI with no indication anything is missing.

Suggested change
const effectiveGroupPolicy = String(
draft.values['groupPolicy'] ?? '',
);
const showPairing =
effectivePolicy === 'pairing' ||
effectiveGroupPolicy === 'pairing';
const hasDescriptorGroupPolicy = descriptor.fields.some(
(f) => f.key === 'groupPolicy',
);
const effectiveGroupPolicy = hasDescriptorGroupPolicy
? String(draft.values['groupPolicy'] ?? '')
: String(instance?.config.groupPolicy ?? '');
const showPairing =
effectivePolicy === 'pairing' ||
effectiveGroupPolicy === 'pairing';
中文说明

showPairing 门控中 group-pairing 的一半读取 draft.values['groupPolicy'],而该值只有在频道 descriptor 声明了 groupPolicy 管理字段时才会被填充。只有 github 和 gitlab adapter 声明了该字段;可管理的 IM adapter(dingtalk、wecom、feishu)都没有——而且 createChannelEditorDraftgroupPolicy 没有 instance-config 回退(上面的 senderPolicy 有)。对于配置了 senderPolicy: 'open'|'allowlist' + groupPolicy: 'pairing' 的 dingtalk/wecom/feishu 频道——这是本 PR 的 daemon service 及其自身 service 测试(manages group pairing when groupPolicy uses pairing mode)明确支持的一等配置——showPairing 为 false,配对面板(待审批群请求、批准、撤销,即本 PR 新增的 Web Shell 能力)永远不会渲染。而内层渲染判断(约第 669 行)却正确地读取 instance?.config.groupPolicy === 'pairing'——同一 diff 内存在不对称。已用探针证实:使用真实 dingtalk descriptor 和 instance {senderPolicy: 'open', groupPolicy: 'pairing'} 渲染该对话框时不显示配对区域,且从不调用 listPairingRequests;下方修复可翻转探针结果,且 34 个既有 dialog/panel 测试全部保持绿色。注意:新测试 shows pairing management when only group pairing is enabled 掩盖了该缺陷,因为其合成 descriptor 虚构了真实 IM 插件都不提供的 groupPolicy 字段——应改用真实 descriptor 形态。失败场景:运营者为 dingtalk/wecom/feishu 频道配置 senderPolicy: 'open' + groupPolicy: 'pairing';未审批群 @ 机器人后产生待审批群配对请求;在 Web Shell 打开频道编辑器时看不到任何配对区域,也没有其他 Web UI 入口(ChannelsManagerPage 只把该面板接入此对话框)。无法在 Web Shell 中批准/撤销,运营者只能回退到 CLI,且没有任何提示。

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

Comment thread packages/channels/base/src/GroupGate.ts Outdated
Comment on lines +37 to +43
* Mention gating runs before sender gate so that unmentioned messages
* in groups don't trigger pairing flows.
*/
check(envelope: Envelope): GroupCheckResult {
check(
envelope: Envelope,
options: { createPairingRequest?: boolean } = {},
): GroupCheckResult {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-2: This diff inserts the pairing gate into check() between the allowlist step and mention gating, but the doc comment above it (lines 30-39) still lists only the pre-PR three stages — its summary (policy + allowlist + mention gating) and numbered evaluation order omit the only step with a side effect (writing pairing requests) and the only step whose trigger conditions (options.createPairingRequest, isMentioned/isReplyToBot) are not obvious from call sites. The comment is this file's self-declared ordering contract, and its closing sentence now misattributes ambient-message protection; the precise intra-check() ordering is specified nowhere else. Every sibling doc surface (overview.md, README, protocol doc) was updated by this diff — this comment was overlooked. Behavioral harms are largely test-mitigated (GroupGate.test.ts pins pairing_trigger_required; the ChannelBase ambient test pins no request creation), which is why this stays a Suggestion. — Failure scenario: a maintainer extending or reordering gate logic reads the 3-step contract, concludes mention gating is the last gate and nothing between steps 2 and 3 has side effects, and reorders or adds an early return; ambient messages could then reach the pairing-request creation path. — Suggested fix (spans comment lines outside this hunk, so no one-click block): update the summary to Full group check: policy + allowlist + pairing + mention gating., insert evaluation-order step 3 = group pairing (pairing mode, group not approved → drop; an explicit mention/reply creates or returns a pending pairing request unless options.createPairingRequest is false), and renumber mention gating to step 4.

中文说明

本 diff 在 check() 的 allowlist 步骤与 mention gating 之间插入了 pairing 门控,但上方文档注释(第 30-39 行)仍只列出 PR 前的三个阶段——其摘要(policy + allowlist + mention gating)和编号评估顺序遗漏了唯一带副作用的步骤(写入配对请求),也遗漏了唯一触发条件不直观的步骤(options.createPairingRequestisMentioned/isReplyToBot)。该注释是本文件自声明的顺序契约,其结尾句现在将环境消息防护归因错误;check() 内部的精确顺序在别处无任何说明。本 diff 更新了所有同级文档面(overview.md、README、protocol 文档)——唯独漏掉了这条注释。行为层面的危害大部分已被测试缓解(GroupGate.test.ts 固定了 pairing_trigger_required;ChannelBase 环境消息测试固定了不创建请求),因此仍为 Suggestion。失败场景:维护者扩展或重排门控逻辑时读到这份三步契约,误以为 mention gating 是最后一道门、步骤 2 与 3 之间没有副作用,于是重排顺序或新增提前返回;环境消息便可能进入配对请求创建路径。建议修复(涉及本 hunk 之外的注释行,故不提供一键 suggestion):将摘要改为 Full group check: policy + allowlist + pairing + mention gating.,在评估顺序中插入第 3 步 = group pairing(pairing 模式下群未审批 → 丢弃;显式 @/回复会创建或返回待处理配对请求,除非 options.createPairingRequest 为 false),并将 mention gating 重新编号为第 4 步。

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

qwen-code-ci-bot pushed a commit that referenced this pull request Aug 3, 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.

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

中文说明

已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

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

Comment on lines +4788 to +4791
const lines =
this.config.groupPolicy === 'pairing'
? entries
: entries.filter((entry) => this.gate.isAllowed(entry.senderId));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Under groupPolicy: 'pairing', group-history injection skips the per-entry sender filter here, and the drain site in processInbound performs no group-approval re-check. Probe-verified at HEAD: a prompt accepted before a group revocation but executing after it (queued turn or collect-buffer re-entry) still gets the revoked group's recorded history injected (- [Bob] background observed post-revoke; adding a drain-time approval re-check flipped the probe). Blast radius is bounded — history is keyed per [channel, chatId, threadId], so only an envelope from the same group drains it, and every post-revocation trigger from that group is blocked at preflight (no cross-group/DM leak) — hence Suggestion rather than Critical. — Failure scenario: group approved → ambient member messages recorded to group history → operator revokes the group → an in-flight/queued prompt for that same group is constructed with the revoked group's history entries injected into model context.

Suggested fix: re-check group approval at the drain site before prepending (e.g. groupGate.check(envelope, { createPairingRequest: false })), or clear the group's pending history entries on revokeGroup.

中文说明

groupPolicy: 'pairing' 下,此处群历史注入跳过了逐条 sender 过滤,且 processInbound 中的消费点不做群授权复查。已在 HEAD 用探针证实:群撤销已受理、撤销才执行的 prompt(排队轮次或 collect-buffer 重入)仍会注入该群已记录的历史(撤销后观察到 - [Bob] background;在消费点加入授权复查后探针结果翻转)。影响面有界——历史按 [channel, chatId, threadId] 隔离,只有同群的 envelope 会消费它,且撤销后该群的所有触发都会在 preflight 被拦截(无跨群/私聊泄漏)——因此是 Suggestion 而非 Critical。失败场景:群被批准 → 普通成员消息被记入群历史 → 运营者撤销该群 → 该群一个在途/排队的 prompt 构造时注入了已撤销群的历史内容。

建议修复:在消费点拼接前复查群授权(如 groupGate.check(envelope, { createPairingRequest: false })),或在 revokeGroup 时清空该群的待注入历史。

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

@qqqys qqqys Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

已修复:在群历史 drain 后、注入 prompt 前重新检查当前群 pairing approval;群已撤销时丢弃已 drain 的旧历史。

验证证据:cd packages/channels/base && npx vitest run src/ChannelBase.test.ts src/GroupGate.test.ts(580 passed);npm run build --workspace @qwen-code/channel-base 通过;npm run typecheck 通过;commit 8af2dccef5

Comment on lines +619 to +621
const showPairing =
effectivePolicy === 'pairing' ||
effectiveGroupPolicy === 'pairing';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The PR description still declares the Web Shell UI out of scope — Evidence says "The Channel management UI is intentionally deferred to the next staged PR", and Risk & Scope lists "Web Shell configuration and approval UI" as not validated — yet this diff ships a Web Shell pairing surface: this showPairing gate with group-policy awareness, the subject-aware ChannelPairingRequests panel, EN+ZH i18n strings, and 4 component tests. The Reviewer Test Plan's four steps cover only CLI/daemon flows. — Failure scenario: a maintainer verifying against the Test Plan, or writing release notes from Risk & Scope, exercises/documents a strictly smaller surface than ships; the group-pairing Web Shell flow (panel visibility for group-only configs, group approval/revocation UI) lands verified only by unit tests.

Suggested fix: update the PR description — remove the "UI deferred" claims from Evidence and Risk & Scope, and add a Web Shell verification step (e.g. configure a channel with groupPolicy: 'pairing' whose descriptor has no groupPolicy management field such as dingtalk, open the channel editor, and confirm the pending group request shows Group: {name} — Requested by {sender} and group approval/revocation work).

中文说明

PR 描述仍声明 Web Shell UI 不在本 PR 范围内——Evidence 写着 "The Channel management UI is intentionally deferred to the next staged PR",Risk & Scope 将 "Web Shell configuration and approval UI" 列为未验证——但本 diff 实际上线了 Web Shell 配对能力:这个感知 group policy 的 showPairing 门控、感知 subject 的 ChannelPairingRequests 面板、中英文 i18n 文案以及 4 个组件测试。Reviewer Test Plan 的四步只覆盖 CLI/daemon 流程。失败场景:维护者按 Test Plan 验证、或依据 Risk & Scope 撰写发布说明时,实际验证/记录的范围小于真实上线范围;群配对 Web Shell 流程(仅群配置下面板可见性、群批准/撤销 UI)只有单元测试验证。

建议修复:更新 PR 描述——移除 Evidence 与 Risk & Scope 中 "UI 延后" 的表述,并补充 Web Shell 验证步骤(例如为 descriptor 没有 groupPolicy 管理字段的频道如 dingtalk 配置 groupPolicy: 'pairing',打开频道编辑器,确认待审批群请求显示 Group: {name} — Requested by {sender},且群批准/撤销可用)。

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Deferred to maintainer. The substance of this finding stands — the PR description still declares the Web Shell UI out of scope while this PR does ship it: the group-policy-aware showPairing gate, the subject-aware ChannelPairingRequests panel, EN+ZH i18n strings, and component tests that now also cover the descriptor-driven gate branch, group approval/revocation, and the 404-refresh recovery. However, this autofix round can only commit code in the checkout; it cannot edit the PR description (no GitHub write access by design), so the fix cannot land here. Recommendation for the maintainer: remove the "UI deferred" claims from Evidence and Risk & Scope, and add a Web Shell verification step (e.g. configure a dingtalk channel with senderPolicy: 'open' + groupPolicy: 'pairing', open the channel editor, and confirm the pending group request renders Group: {name} — Requested by {sender} and group approval/revocation work). Open question: update the description now, or after the follow-up staged UI work lands?

中文说明

延迟至维护者处理。该发现的事实部分成立——PR 描述仍声明 Web Shell UI 不在范围内,但本 PR 实际上线了该能力:感知 group policy 的 showPairing 门控、感知 subject 的 ChannelPairingRequests 面板、中英文 i18n 文案,以及现已覆盖 descriptor 驱动门控分支、群批准/撤销、404 刷新恢复的组件测试。然而本轮 autofix 只能在检出中提交代码,无法编辑 PR 描述(按设计不持有 GitHub 写权限),因此该修复无法在本轮落地。给维护者的建议:移除 Evidence 与 Risk & Scope 中 "UI 延后" 的表述,并补充 Web Shell 验证步骤(例如为 dingtalk 频道配置 senderPolicy: 'open' + groupPolicy: 'pairing',打开频道编辑器,确认待审批群请求显示 Group: {name} — Requested by {sender},且群批准/撤销可用)。待决问题:现在就更新 PR 描述,还是等后续分阶段 UI 工作落地后再更新?

Comment thread packages/channels/base/README.md Outdated
| `check(envelope)` | Returns `{ allowed: boolean, reason?: 'disabled' \| 'not_allowlisted' \| 'mention_required' }` |
| Method | Description |
| ----------------- | -------------------------------------------------------------- |
| `check(envelope)` | Returns the group policy decision and an optional pairing code |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This rewritten row omits the new options parameter ({ createPairingRequest?: boolean }) and the store-mutating side effect: probe-verified, a default check(envelope) call on a mention/reply from an unapproved group creates (or reuses) a pending pairing request — exactly the flag ChannelBase's own stored-loop probe must pass to stay read-only. Same-group probes dedupe, but read-only probes across 3+ distinct unapproved groups exhaust MAX_PENDING (3), after which real pairing gets pairingCode: null. — Failure scenario: an adapter author following this README probes gate state read-only across 3+ distinct unapproved groups (e.g. filtering proactive delivery targets); real group pairing requests then fail with "Too many pending pairing requests. Please try again later." until the 1-hour expiry.

Suggested change
| `check(envelope)` | Returns the group policy decision and an optional pairing code |
| `check(envelope, options?)` | Returns the group policy decision and an optional pairing code. Under `pairing`, a mention/reply from an unapproved group creates (or reuses) a pending pairing request; pass `{ createPairingRequest: false }` for read-only probes |
中文说明

该行重写后遗漏了新的 options 参数({ createPairingRequest?: boolean })及其对 store 的副作用:已用探针证实,对未审批群的 @/回复以默认参数调用 check(envelope) 会创建(或复用)一个待审批配对请求——这正是 ChannelBase 自身的定时任务探测为保持只读必须传的参数。同群探测会去重,但跨 3 个以上不同未审批群的只读探测会耗尽 MAX_PENDING(3),此后真实配对只能拿到 pairingCode: null。失败场景:adapter 作者按本 README 跨 3 个以上不同未审批群只读探测门状态(如过滤主动投递目标);真实群配对请求随后在 1 小时过期前一直得到 "Too many pending pairing requests. Please try again later."。

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

| `groupPolicy` | No | Group chat access: `disabled` (default), `allowlist`, or `open`. See [Group Chats](#group-chats) |
| `groupPolicy` | No | Group chat access: `disabled` (default), `allowlist`, `pairing`, or `open`. See [Group Chats](#group-chats) |
| `dmPolicy` | No | Private/DM access: `open` (default) or `disabled` (silently drop all DMs). Useful for group-only bots |
| `groupHistoryLimit` | No | Opt-in group history backfill. `0` or omitted disables it. A positive number persists that many authorized, unmentioned group messages for the next bot mention/reply. |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This row still says only authorized unmentioned group messages are persisted, but this PR rewrote the sibling bullet below to include "members of an approved paired group" and made recordPendingGroupHistory skip the sender check for approved paired groups (the PR's own test persists Bob's ambient message although he is on no allowlist). The row was accurate pre-PR; the contradiction is introduced by this diff, and it concerns what data is written to disk. — Failure scenario: with groupPolicy: 'pairing' + senderPolicy: 'allowlist' and an approved group, an operator reading this table believes only individually authorized senders' ambient messages are persisted, when in fact every unmentioned message from any paired-group member is written to the group-history JSONL and later injected into model context — two contradicting persistence statements on the same page.

Suggested change
| `groupHistoryLimit` | No | Opt-in group history backfill. `0` or omitted disables it. A positive number persists that many authorized, unmentioned group messages for the next bot mention/reply. |
| `groupHistoryLimit` | No | Opt-in group history backfill. `0` or omitted disables it. A positive number persists that many unmentioned group messages from authorized senders or members of approved paired groups for the next bot mention/reply. |
中文说明

该行仍只说"已授权"发送者的未 @ 群消息会被持久化,但本 PR 已把下方的条目改写为包含"已批准配对群的成员",并使 recordPendingGroupHistory 对已批准配对群跳过 sender 检查(本 PR 自己的测试就验证了 Bob 不在任何 allowlist 上时其普通消息仍被持久化)。该行在 PR 之前是准确的;矛盾由本 diff 引入,且涉及写入磁盘的数据范围。失败场景:groupPolicy: 'pairing' + senderPolicy: 'allowlist' 且某群已获批准时,运营者读该表格会以为只有单独授权发送者的普通消息被持久化,而实际上配对群任何成员的未 @ 消息都会写入群历史 JSONL 并在之后注入模型上下文——同一页面出现两处互相矛盾的持久化表述。

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

qwen-code-ci-bot pushed a commit that referenced this pull request Aug 4, 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.

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

中文说明

已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

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

Comment on lines +105 to +107
it('keeps group approvals separate from similarly named channel user approvals', () => {
const groupStore = new PairingStore('support', workspaceA);
const userStore = new PairingStore('support-group', workspaceA);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R4-1: Same-store user/group allowlist separation is unpinned by tests. The test that looks like it covers this uses two DIFFERENT stores (support vs support-group), so it passes trivially regardless of the separation logic; 'approves a group without approving the member who requested it' only asserts isApproved('sender-1') is false — a group-1 id leaked into the user allowlist would not be caught. — Failure scenario: probe-confirmed — a mutant where approve()'s group branch additionally pushes request.subject.id into the user allowlist ships green across all suites (607 + 75 tests). On platforms where user and group IDs share a namespace, an ID approved only as a group then grants sender-level access. — Suggested fix (in 'approves a group without approving the member who requested it', after store.approve(code!)):

expect(store.isApproved('group-1')).toBe(false);
expect(store.getAllowlist()).toEqual([]);
中文说明

同一 store 内用户/群 allowlist 的隔离没有测试固化。看似覆盖该性质的测试使用了两个不同的 store(supportsupport-group),无论隔离逻辑是否正确都会通过;而 'approves a group without approving the member who requested it' 只断言 isApproved('sender-1') 为 false——群 ID group-1 泄漏进用户 allowlist 不会被发现。— 失败场景:已用探针验证——approve() 群分支额外把 request.subject.id 推入用户 allowlist 的变异体在全部测试绿色下通过(607 + 75 个测试)。在用户与群 ID 共用命名空间的平台上,仅作为群被批准的 ID 会获得个人级访问权限。— 建议修复(在 'approves a group without approving the member who requested it' 中 store.approve(code!) 之后):

expect(store.isApproved('group-1')).toBe(false);
expect(store.getAllowlist()).toEqual([]);

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

已修复 + 验证证据:同一 PairingStore 现在明确断言群组 ID 不会进入用户 allowlist,且用户撤销路径不会误删群组授权。npx vitest run src/PairingStore.test.ts 27/27 通过;Prettier 与 git diff --check 通过。

Comment on lines +426 to +430
await service.approvePairing('bot', code!);
await expect(service.pairingApprovals('bot')).resolves.toEqual({
senderIds: [],
groupIds: ['group-1'],
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R4-2: approved.subject on the approval response is pinned by no test for group approvals. This new group test discards the approvePairing result; the user-variant asserts only objectContaining({senderId, code}) — fields a group request also carries; the route test mocks the service. — Failure scenario: probe-confirmed — a service-layer mutant stripping subject from group approvals ships green; the daemon then returns an approval response without approved.subject, web-shell's requestSubject() falls back to the initiating sender, and the success toast announces the individual user when access was actually granted to the whole group. — Suggested fix:

await expect(service.approvePairing('bot', code!)).resolves.toEqual({
  approved: expect.objectContaining({
    subject: { type: 'group', id: 'group-1', name: 'Release Team' },
  }),
  requests: [],
});
中文说明

群批准的响应中 approved.subject 没有任何测试固化。新增的群测试丢弃了 approvePairing 的返回值;用户变体只断言 objectContaining({senderId, code})——群请求同样携带这些字段;路由测试 mock 了 service。— 失败场景:已用探针验证——service 层去掉群批准 subject 的变异体全绿通过;daemon 随后返回不含 approved.subject 的批准响应,web-shell 的 requestSubject() 回退到发起成员,成功提示会显示个人用户,而实际授权的是整个群。— 建议修复:

await expect(service.approvePairing('bot', code!)).resolves.toEqual({
  approved: expect.objectContaining({
    subject: { type: 'group', id: 'group-1', name: 'Release Team' },
  }),
  requests: [],
});

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

Comment on lines +336 to +338
expect(response.status).toBe(200);
expect(secondaryService.revokePairingApproval).toHaveBeenCalledWith('bot', {
type: 'group',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R4-3: The pairing routes' success-path HTTP response bodies are asserted at no layer. This new test pins only status 200 and service call args — all 26 .body assertions in this file avoid pairing success responses, and the GET pairing-approvals body is equally unasserted despite the mock returning groupIds. — Failure scenario: probe-confirmed — a reshape that keeps the service call but serializes only {revoked} ships 12/12 green. Web-shell reads result.senderIds / result.groupIds ?? [] off the revoke response; with the fields missing, setApprovedSenderIds(result.senderIds) stores undefined and the approvals render breaks after any successful revoke while on-disk allowlists stay intact. — Suggested fix:

expect(response.body).toEqual({
  revoked: 'group-1',
  senderIds: [],
  groupIds: [],
});

and pin the GET body in the workspace test: expect(approvals.body).toEqual({ senderIds: ['sender-1'], groupIds: ['group-1'] });

中文说明

配对路由成功路径的 HTTP 响应体在任何层级都没有断言。新增测试只固化了 status 200 和 service 调用参数——本文件全部 26 处 .body 断言都不涉及配对成功响应;GET pairing-approvals 的响应体同样未断言(mock 明明返回了 groupIds)。— 失败场景:已用探针验证——保留 service 调用但只序列化 {revoked} 的改写在 12/12 测试绿色下通过。web-shell 从撤销响应读取 result.senderIds / result.groupIds ?? [];字段缺失时 setApprovedSenderIds(result.senderIds) 会存入 undefined,任何成功撤销后审批列表渲染都会出错,而磁盘 allowlist 并未变化。— 建议修复:

expect(response.body).toEqual({
  revoked: 'group-1',
  senderIds: [],
  groupIds: [],
});

并在工作空间测试中固化 GET 响应体:expect(approvals.body).toEqual({ senderIds: ['sender-1'], groupIds: ['group-1'] });

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

Comment on lines +616 to +618
const effectiveGroupPolicy = descriptorGroupPolicy
? String(draft.values['groupPolicy'] ?? '')
: String(instance?.config.groupPolicy ?? '');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R4-4: The descriptor branch of this fixed effectiveGroupPolicy gate has zero test coverage — every dialog test renders with descriptors WITHOUT a groupPolicy field (DINGTALK/OPTIONAL_SECRET), so only the instance-config fallback branch is exercised. — Failure scenario: probe-confirmed — collapsing the ternary to the instance-config fallback ships green across all dialog tests. For descriptor-driven channel types (github/gitlab) with a hand-set groupPolicy: 'pairing' config, editing the policy draft back to a non-pairing value keeps the pairing panel visible during editing, inconsistent with senderPolicy's live-reacting path. — Suggested fix: add a dialog test with a descriptor declaring a groupPolicy field and instance config groupPolicy: 'open'; change the draft field to 'pairing' and assert the pre-save pairing affordance (the Save pairing mode first alert) appears.

中文说明

修复后的 effectiveGroupPolicy 门控中 descriptor 分支的测试覆盖为零——所有对话框测试都使用不含 groupPolicy 字段的 descriptor(DINGTALK/OPTIONAL_SECRET),因此只有 instance-config 回退分支被执行。— 失败场景:已用探针验证——把三元表达式坍缩为 instance-config 回退后全部对话框测试仍绿色。对于 descriptor 驱动的频道类型(github/gitlab),手工配置 groupPolicy: 'pairing' 后,把草稿中的策略改回非 pairing 值时配对面板在编辑期间仍然可见,与 senderPolicy 的实时响应行为不一致。— 建议修复:新增一个对话框测试,使用声明了 groupPolicy 字段的 descriptor,instance 配置为 groupPolicy: 'open';把草稿字段改为 'pairing',断言出现保存前提示(Save pairing mode first 警告)。

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

Comment on lines +161 to +163
expect(container.textContent).toContain(
'Group: Release Team can now use this Channel.',
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R4-5: Pending-list replacement after group approval is unpinned. This group-approval test never asserts the approved group request is removed from the pending list; the user-side twin asserts 'No pending requests' and not.toContain('ABCD1234'). — Failure scenario: probe-confirmed — a mutant making the pending-list replacement conditional on subject type ships 34/34 green; the stale pending row stays visible with a live Approve button, and re-clicking it yields a channel_pairing_request_not_found error toast instead of the empty state. — Suggested fix (after the approve click):

expect(container.textContent).toContain('No pending requests');
expect(container.textContent).not.toContain('GROUP123');
中文说明

群批准后待处理列表的替换未被固化。这个群批准测试从未断言已批准的群请求从待处理列表移除;用户侧的对应测试断言了 'No pending requests'not.toContain('ABCD1234')。— 失败场景:已用探针验证——让待处理列表替换按主体类型有条件执行的变异体在 34/34 测试绿色下通过;过期的待处理行仍显示且 Approve 按钮可用,再次点击会得到 channel_pairing_request_not_found 错误提示而非空状态。— 建议修复(在批准点击之后):

expect(container.textContent).toContain('No pending requests');
expect(container.textContent).not.toContain('GROUP123');

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

Comment on lines 309 to 314
const snapshot = await listApprovals(revokeChannel);
if (mounted.current && currentChannelName.current === revokeChannel) {
setApprovedSenderIds(snapshot.senderIds);
setApprovedGroupIds(snapshot.groupIds ?? []);
}
return;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R4-6: The group mirror line added to this channel_pairing_approval_not_found recovery path has no group-target test — all three revoke-failure tests use the user target paired-user only, and the new group test covers only the success path. — Failure scenario: probe-confirmed — dropping the setApprovedGroupIds line on this 404 path ships 25/25 green. Two admins holding the panel: one revokes a group the other just revoked → daemon 404 → the refresh silently resets only the user list, desyncing displayed group approvals until a manual refresh. — Suggested fix: add a group variant of the 404-refresh test — seed {senderIds:['paired-user'], groupIds:['group-7','group-8']}, reject revokeApproval with code:'channel_pairing_approval_not_found', assert the revoked group-7 row disappears while Revoke Group: group-8 and Revoke paired-user survive.

中文说明

这条 channel_pairing_approval_not_found 恢复路径新增的群镜像行没有群目标测试——三个撤销失败测试都只使用用户目标 paired-user,新的群测试只覆盖成功路径。— 失败场景:已用探针验证——删除该 404 路径上的 setApprovedGroupIds 行后 25/25 测试绿色。两位管理员同时操作面板:一人撤销了另一人刚撤销的群 → daemon 返回 404 → 刷新只静默重置了用户列表,显示的群审批与实际状态脱节,直到手动刷新。— 建议修复:新增 404 刷新测试的群变体——预置 {senderIds:['paired-user'], groupIds:['group-7','group-8']},让 revokeApprovalcode:'channel_pairing_approval_not_found' 拒绝,断言已撤销的 group-7 行消失而 Revoke Group: group-8Revoke paired-user 仍在。

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

Comment on lines +285 to +287
expect(revokeApproval).toHaveBeenCalledWith('release-bot', {
groupId: 'group-7',
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R4-7: This group-revoke test asserts no SURVIVING entries from the revoke result, so the success-path setApprovedGroupIds(result.groupIds ?? []) mirror is unpinned (the user-path test pins its mirror via toContain('second-user')). — Failure scenario: probe-confirmed — mutants setApprovedGroupIds(result.senderIds) (cross-wire) and setApprovedGroupIds([]) both ship 25/25 green; after a successful group revocation, all other approved groups silently vanish from the displayed list until a manual refresh. — Suggested fix: seed groupIds: ['group-7','group-8'], revoke result { revoked: 'group-7', senderIds: ['paired-user'], groupIds: ['group-8'] }, and assert Revoke Group: group-8 and paired-user still render.

中文说明

这个群撤销测试没有断言撤销结果中幸存的条目,因此成功路径上 setApprovedGroupIds(result.groupIds ?? []) 的镜像未被固化(用户路径测试通过 toContain('second-user') 固化了其镜像)。— 失败场景:已用探针验证——变异体 setApprovedGroupIds(result.senderIds)(错接)与 setApprovedGroupIds([]) 均在 25/25 测试绿色下通过;一次成功的群撤销后,其他已批准群会从显示列表中静默消失,直到手动刷新。— 建议修复:预置 groupIds: ['group-7','group-8'],撤销结果设为 { revoked: 'group-7', senderIds: ['paired-user'], groupIds: ['group-8'] },断言 Revoke Group: group-8paired-user 仍然渲染。

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

Comment on lines 519 to +521
{!approvalsLoading &&
!approvalsError &&
approvedSenderIds.length === 0 ? (
approvedTargets.length === 0 ? (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R4-8: The approvals empty-state condition and header badge derive from approvedTargets, but no test pins either against a group-inclusive snapshot — the only group-only scenario asserts the revoke button exists, never that 'No pairing approvals' is absent, and no test asserts the badge count. — Failure scenario: probe-confirmed — mutants regressing either derivation to approvedSenderIds ship 34/34 green; a channel with only group approvals renders the "No pairing approvals" empty state directly above the listed, revocable group row, and the header badge shows 0 next to a non-empty list. — Suggested fix (in 'identifies the group and requesting member…', after the reload with { senderIds: [], groupIds: ['group-7'] }):

expect(container.textContent).not.toContain('No pairing approvals');
中文说明

审批空状态条件与头部计数徽标都取自 approvedTargets,但没有任何测试在含群快照上固化二者——唯一的纯群场景只断言撤销按钮存在,从未断言 'No pairing approvals' 不出现,也没有测试断言徽标数字。— 失败场景:已用探针验证——把任一派生回退为 approvedSenderIds 的变异体在 34/34 测试绿色下通过;只有群审批的频道会在已列出、可撤销的群行上方渲染 "No pairing approvals" 空状态,头部徽标在非空列表旁显示 0。— 建议修复(在 'identifies the group and requesting member…' 中,以 { senderIds: [], groupIds: ['group-7'] } 重新加载之后):

expect(container.textContent).not.toContain('No pairing approvals');

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

Comment on lines 179 to +181
setApprovalsLoading(true);
setApprovedSenderIds([]);
setApprovedGroupIds([]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R4-9: This new setApprovedGroupIds([]) reset in the approvals reload effect is pinned by no test — the channel-switch test seeds only senderIds: ['paired-user']. — Failure scenario: probe-confirmed — deleting this line ships 34/34 green. Admin views Channel A with an approved group, switches to Channel B whose listApprovals is slow — Channel A's stale group row stays visible AND clickable; clicking sends the revocation against the WRONG channel's store (404, silent refresh), showing the admin a live approval the selected channel never had. — Suggested fix: extend the channel-switch test to also seed groupIds: ['group-7'] on the first snapshot (assert the Revoke Group: group-7 button exists before the switch), then assert after switching: expect(container.textContent).not.toContain('group-7').

中文说明

审批重新加载 effect 中新增的这行 setApprovedGroupIds([]) 重置没有测试固化——频道切换测试只预置了 senderIds: ['paired-user']。— 失败场景:已用探针验证——删除该行后 34/34 测试绿色。管理员查看有已批准群的频道 A,切换到 listApprovals 较慢的频道 B——频道 A 的过期群行仍然可见且可点击;点击会把撤销请求发到错误频道的 store(404,静默刷新),管理员看到的是所选频道从未拥有的审批。— 建议修复:扩展频道切换测试,在第一个快照中同时预置 groupIds: ['group-7'](切换前断言 Revoke Group: group-7 按钮存在),切换后断言:expect(container.textContent).not.toContain('group-7')

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

qwen-code-ci-bot pushed a commit that referenced this pull request Aug 4, 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.

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

中文说明

已审查。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

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

@qqqys

qqqys commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

@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 Aug 5, 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)即可释放。

@qqqys

qqqys commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /review

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor
_Qwen Code review request accepted. Review is queued in [workflow run](https://github.com/QwenLM/qwen-code/actions/runs/31156145032)._

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔀 Base updated: red check(s) [Test (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run.

中文说明

🔀 已更新 base:红色检查 [Test (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。

qwen-code-ci-bot pushed a commit that referenced this pull request Aug 5, 2026
@qwen-code-dev-bot

qwen-code-dev-bot commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

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

中文说明

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

@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. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

中文说明

已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

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

export type SessionScope = 'user' | 'thread' | 'chat_thread' | 'single';
export type ChannelType = string;
export type GroupPolicy = 'disabled' | 'allowlist' | 'open';
export type GroupPolicy = 'disabled' | 'allowlist' | 'pairing' | 'open';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Extending GroupPolicy with 'pairing' makes it a first-class documented value, but the GitLab surface still asserts pairing cannot dispatch todos: the connect-time warning (packages/channels/gitlab/src/GitlabAdapter.ts:79-83must be "open" (or "allowlist" ...) for todos to be dispatched), the descriptor help text (packages/channels/gitlab/src/index.ts:35), and docs/users/features/channels/gitlab.md (config-table row, the ⚠️ groupPolicy Must Be "open" or "allowlist" section, and How-It-Works step 6). Probe-confirmed at HEAD: a GitLab todo under groupPolicy: 'pairing' DOES dispatch after one-time group approval (prompt calls 0 → approve → 1), because the adapter force-mentions todos, so pairing requests are created exactly as overview.md now documents — Failure scenario: operator configures a GitLab channel with groupPolicy: 'pairing', the false warning fires on every connect and the docs declare pairing cannot work, so the operator reverts to open (weaker posture) or files a bogus bug.

Suggested fix: accept 'pairing' in the warning condition/message, update the descriptor help text, and sweep gitlab.md to state that pairing dispatches todos after one-time group approval. (Related-but-distinct from R1-6, which covers the descriptor options list under assertManagedConfig.)

中文说明

GroupPolicy 新增 'pairing' 后它已成为一等公民并有文档支持,但 GitLab 侧仍断言 pairing 无法分发 todos:连接时告警(GitlabAdapter.ts:79-83 —— must be "open" (or "allowlist" ...) for todos to be dispatched)、descriptor 帮助文案(gitlab/src/index.ts:35)以及 gitlab.md(配置表行、⚠️ groupPolicy Must Be "open" or "allowlist" 小节、How-It-Works 第 6 步)。已在 HEAD 用探针证实:groupPolicy: 'pairing' 下 GitLab todo 在一次性群审批后确实会分发(prompt 调用 0 → 批准 → 1),因为 adapter 对 todo 强制标记 mention,配对请求会按 overview.md 新文档所述正常创建。失败场景:运营者为 GitLab 频道配置 groupPolicy: 'pairing',每次连接都触发虚假告警且文档声称 pairing 不可用,于是回退到 open(更弱的权限姿态)或提交错误 bug。建议修复:在告警条件/文案中接受 'pairing',更新 descriptor 帮助文案,并同步修订 gitlab.md,说明 pairing 在一次性群审批后可分发 todos。(与 R1-6 相关但不同:R1-6 针对 assertManagedConfig 下 descriptor 的 options 列表。)

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

Comment on lines +242 to +244
expect(revokeApproval).toHaveBeenCalledWith('release-bot', {
senderId: 'paired-user',
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The group pairing flow this PR ships cannot be exercised by the channels Playwright suite: the e2e mock daemon (packages/web-shell/client/e2e/utils/mockDaemon.ts) still models pairing approvals as a sender-ID-only map — its approve handler records approved.senderId (the initiating member) into the user approval list instead of approving the group, GET pairing-approvals never returns groupIds, and the DELETE handler reads only senderId, so the { groupId } revocation this test asserts the UI sends would yield 404 channel_pairing_approval_not_found. The suite already exercises user pairing end-to-end, so this is live infrastructure left newly divergent by the PR's contract changes — Failure scenario: any spec extending web-shell.channels.spec.ts for group pairing observes silently wrong behavior (wrong identity approved in the mock, empty group-approvals section, 404 on group revoke) instead of a clear harness gap.

Suggested fix: model group approvals in the mock — approve by approved.subject.id when approved.subject?.type === 'group', return { senderIds, groupIds } from GET pairing-approvals, and in DELETE read groupId when senderId is absent and reply { revoked, senderIds, groupIds }.

中文说明

本 PR 交付的群配对流程无法被 channels Playwright 套件验证:e2e mock daemon(client/e2e/utils/mockDaemon.ts)仍把配对审批建模为仅 sender-ID 的 map——approve 处理器把 approved.senderId(发起成员)记入用户审批列表而不是批准群组;GET pairing-approvals 从不返回 groupIds;DELETE 处理器只读 senderId,因此本测试所断言的 UI 新请求 { groupId } 会得到 404 channel_pairing_approval_not_found。该套件已在端到端验证用户配对,属于被本 PR 契约变更新近拉开差距的在用基础设施。失败场景:任何为群配对扩展 web-shell.channels.spec.ts 的用例都会观察到静默的错误行为(mock 中错误的身份被批准、群审批列表为空、群撤销 404),而不是明确的 harness 缺口。建议修复:在 mock 中建模群审批——当 approved.subject?.type === 'group' 时按 approved.subject.id 批准;GET 返回 { senderIds, groupIds };DELETE 在无 senderId 时读取 groupId 并回复 { revoked, senderIds, groupIds }

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

| `instructions` | No | Custom instructions prepended to the first message of each session |
| `webhooks` | No | Webhook sources and delivery targets for daemon-managed channels. See [Webhook-triggered tasks](#webhook-triggered-tasks) |
| `groupPolicy` | No | Group chat access: `disabled` (default), `allowlist`, or `open`. See [Group Chats](#group-chats) |
| `groupPolicy` | No | Group chat access: `disabled` (default), `allowlist`, `pairing`, or `open`. See [Group Chats](#group-chats) |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This row was updated for 'pairing', but every sibling enumeration in the per-channel and plugin docs was missed and now contradicts it. Verified locations: plugins.md:57 (standard-options table — normative for custom channels, and ChannelBase applies group pairing generically), telegram.md:73/:108, feishu.md:124/:173, dingtalk.md:118/:164, qqbot.md:103/:162, wecom.md:82 — group-setup steps and/or "Bot doesn't respond in groups" troubleshooting bullets still say "allowlist" or "open" only — Failure scenario: operator sets groupPolicy: "pairing" (valid, runtime-supported) whose group is simply not yet approved, hits a troubleshooting bullet declaring the value must be "allowlist" or "open", concludes pairing is invalid or broken, and switches to "open" (weaker posture) or files a bug; extension authors mirroring plugins.md omit pairing support.

Suggested fix: add "pairing" to these enumerations, noting the group must be approved before responses start.

中文说明

本行已为 'pairing' 更新,但各频道页与插件文档中的同类枚举全部遗漏,现与 overview 矛盾。已核实的位置:plugins.md:57(标准选项表——对自定义频道是规范性文档,且 ChannelBase 对群配对是通用实现)、telegram.md:73/:108、feishu.md:124/:173、dingtalk.md:118/:164、qqbot.md:103/:162、wecom.md:82——群配置步骤和/或"机器人在群里不响应"的排查条目仍只写 "allowlist""open"。失败场景:运营者设置 groupPolicy: "pairing"(合法且运行时支持),其群只是尚未获批,却看到排查条目声称该值只能是 "allowlist""open",便认为 pairing 非法或已损坏,转而改用 "open"(更弱姿态)或提交 bug;照抄 plugins.md 的扩展作者会漏掉 pairing 支持。建议修复:在上述枚举中补上 "pairing",并注明群需获批后才会开始响应。

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

Comment on lines +43 to +46
const subject =
req.subject.type === 'group'
? `Group: ${req.subject.name} (${req.subject.id}) Requested by: ${req.senderName} (${req.senderId})`
: `Sender: ${req.subject.name} (${req.subject.id})`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This command now renders and approves group subjects, and the docs this PR adds direct operators to qwen channel pairing approve for groups — but the parent registration in packages/cli/src/commands/channel.ts:16 still declares describe: 'Manage DM pairing requests', so qwen channel --help misdescribes the command's scope at the precise moment product messaging routes group operators to it — Failure scenario: operator reads the DM-only help line and concludes the Group: … entry shown by pairing list cannot be approved from the CLI, detouring to the raw daemon API or filing a bug.

Suggested fix: describe: 'Manage DM and group pairing requests' in channel.ts:16.

中文说明

该命令现在会渲染并批准群组主体,且本 PR 新增文档引导运营者用 qwen channel pairing approve 批准群组——但父级注册(channel.ts:16)仍声明 describe: 'Manage DM pairing requests',导致 qwen channel --help 在产品文案正把群运营者引向此处时错误描述了命令范围。失败场景:运营者看到"仅 DM"的帮助文案,认为 pairing list 中的 Group: … 条目无法通过 CLI 批准,转而使用原始 daemon API 或提交 bug。建议修复:将 channel.ts:16 改为 describe: 'Manage DM and group pairing requests'

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


const code = generateCode();
active.push({ senderId, senderName, code, createdAt: now });
active.push({ senderId, senderName, subject, code, createdAt: now });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Subject-keyed dedup + the shared MAX_PENDING = 3 cap let one sender occupy every pending slot: dedup now matches on (subject.type, subject.id), so one member mentioning the bot once in each of three different unapproved groups creates three distinct group subjects that fill the shared cap — pre-PR, sender-keyed dedup guaranteed one sender ≤ one slot, so this diff introduced the monopoly — Failure scenario: every subsequent pairing attempt on that channel (any DM pairing, any other group) gets 'Too many pending pairing requests' for up to the 1-hour expiry, and there is no deny/reject/clear path (CLI has only list/approve; no daemon route deletes pending requests), so the only remedies are approving the unvetted groups, hand-editing <scope>/<channel>-pairing.json, or waiting it out. Under groupPolicy: 'pairing' bot presence in groups is not operator-gated (e.g. on Telegram anyone can add a bot to a group they create), so queue-jamming stays a concrete cost to third parties.

Suggested fix: additionally cap pending requests per initiating senderId, or add a pairing deny <name> <code> command / daemon route so operators can clear unvetted pending requests without approving them.

中文说明

按 subject 去重 + 共享的 MAX_PENDING = 3 上限使单个发送者可占满所有待处理槽位:去重现在按 (subject.type, subject.id) 匹配,因此同一成员在三个不同未批准群里各 @ 一次机器人即可产生三个不同的群主体并填满共享上限——PR 之前按 senderId 去重保证一个发送者最多占一个槽位,是本 diff 引入了该垄断。失败场景:此后该频道上的一切配对尝试(任何私聊配对、任何其他群)都会在最长 1 小时过期前得到"Too many pending pairing requests";且没有 deny/reject/clear 途径(CLI 只有 list/approve,daemon 也没有删除待处理请求的路由),唯一补救是批准这些未审查的群、手改 <scope>/<channel>-pairing.json 或干等。groupPolicy: 'pairing' 下机器人入群不受运营者控制(例如 Telegram 上任何人都可把机器人拉进自建群),因此堵塞队列对第三方仍是实际代价。建议修复:对同一发起 senderId 的待处理请求额外设上限,或新增 pairing deny <name> <code> 命令/daemon 路由,让运营者无需批准即可清理未审查的待处理请求。

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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔀 Base updated: red check(s) [review-pr] pass on current main — merged current main via update-branch; CI will re-run.

中文说明

🔀 已更新 base:红色检查 [review-pr] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。

qwen-code-ci-bot pushed a commit that referenced this pull request Aug 6, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

No action needed on PR #8440 this round

Triage result

  • No new review feedback. There are no reviews, inline comments, or issue-level comments from maintainers or the automated reviewer newer than the last evaluation cutoff (2026-08-06T10:30:48Z). A cross-check of the raw feedback dumps confirms the only two comments after the cutoff are the bot's own Round 10 summary ("no changes needed") and the base-update notification; neither is actionable feedback.
  • The only flagged check is review-pr, and its conclusion is CANCELLED, not FAILURE. Per the check data, the job ran from 12:40:14Z to 16:13:58Z on 2026-08-06 before it was cancelled. This is the automated PR-review workflow itself; a cancellation carries no failure log or error evidence tying it to this PR's code, so there is no code-level defect to diagnose or fix. Re-running the cancelled job is a workflow-owned action, not a code change this agent can make (it has no GitHub credentials and performs no network writes).
  • All other checks on the current head (f46e265dd9) completed green: Test (ubuntu-latest, Node 22.x), Desktop Shell (ubuntu-22.04), web-shell E2E Smoke, Serve A/B, Capture web-shell visuals, the full SDK Java matrix (including Real daemon E2E), Live Host (macos-latest), Classify PR, labeling, and the PR precheck all finished with SUCCESS; the remaining entries are SKIPPED by their own platform/condition filters (macos/windows test legs, coverage posting, etc.).
  • No conflict resolution needed (--conflict false); current main is already merged into this branch via the 12:34Z update-branch merge, which is the current head.

Decision

No code changes were made this round and no commit is required. The cancelled review-pr run should be re-run (or superseded) at the workflow level. The working tree remains exactly at the last verified head.

中文说明

本轮 PR #8440 无需改动

分诊结果

  • 没有新的评审反馈。 在上次评估截止时间(2026-08-06T10:30:48Z)之后,没有来自维护者或自动评审器的评审、行内评论或 issue 级评论。交叉核对原始反馈数据确认:截止时间之后的两条评论均为机器人自身的内容——第 10 轮总结("无需改动")和基础分支更新通知——两者都不是可执行的反馈。
  • 唯一被标记的检查是 review-pr,其结论为 CANCELLED(已取消),而不是 FAILURE(失败)。 根据检查数据,该任务于 2026-08-06 从 12:40:14Z 运行到 16:13:58Z 后被取消。这是自动 PR 评审工作流本身;取消不携带任何能把问题关联到本 PR 代码的失败日志或错误证据,因此没有可诊断或可修复的代码级缺陷。重新运行被取消的任务是工作流的职责,不是本 agent 能做的代码改动(本 agent 没有 GitHub 凭据,也不执行任何网络写操作)。
  • 当前 head(f46e265dd9)上的所有其他检查均为绿色: Test (ubuntu-latest, Node 22.x)、Desktop Shell (ubuntu-22.04)、web-shell E2E Smoke、Serve A/B、Capture web-shell visuals、完整的 SDK Java 矩阵(含 Real daemon E2E)、Live Host (macos-latest)、Classify PR、打标签以及 PR 预检均以 SUCCESS 完成;其余条目为 SKIPPED,由其自身的平台/条件过滤决定(macos/windows 测试分支、覆盖率发布等)。
  • 无需解决冲突--conflict false);当前 main 已通过 12:34Z 的 update-branch 合并进入本分支,该合并提交即为当前 head。

决定

本轮未做任何代码改动,也无需提交。被取消的 review-pr 运行应在工作流层面重新运行(或被更新的运行取代)。工作树保持在最后已验证的 head 不变。

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


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

@wenshao

wenshao commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — real-stack run of group pairing

I built this PR locally and exercised it end to end on a real stack: the real qwen channel start runtime, the real qwen serve daemon, the real TypeScript SDK, and the real Web Shell in a real browser. Everything in the Reviewer Test Plan reproduces, and I found no defects. Details, evidence, and three mutation tests below.

Commit tested: f46e265dd96a3b0c724c25770ea781b85aea5d7b (current PR head)

Environment

Piece What it actually was
Build Clean worktree, npm ci + full npm run build + npm run bundle (root dist/cli.js)
Runtime Real qwen channel start and daemon-managed workers under qwen serve --channel
Channel adapter A real channel-plugin extension (ChannelPlugin + ChannelBase subclass) exposing an HTTP control plane, so I could set isGroup / isMentioned / isReplyToBot honestly, exactly as a Telegram or Feishu adapter builds them. Nothing in the gate, store, or pairing pipeline is stubbed.
Model Recording OpenAI-compatible server (OPENAI_BASE_URL), so "the agent really answered" is verifiable from both sides
Isolation Dedicated QWEN_HOME, two workspaces wsA / wsB, both configuring a channel named groupbot
UI qwen serve with bearer auth serving the bundled Web Shell, driven by Playwright/Chromium

1. Runtime behaviour (Reviewer Test Plan steps 1–3)

One continuous session on a channel with senderPolicy: "pairing" + groupPolicy: "pairing":

runtime timeline

Claim Result
Ambient (unmentioned) message from an unknown group is silent ✅ No reply, and no groupbot-pairing.json was even created
A mention creates exactly one request identifying group + initiator subject: {type: "group", id: "group-42", name: "Release Room"}, senderId: "alice"
A different member re-mentioning reuses the same code ✅ Bob's mention returned code JWAFSHFU; no second request, no second public post
Ambient messages after the request stay silent ✅ Carol's message produced nothing
qwen channel pairing list shows the group and the initiator Code: JWAFSHFU Group: Release Room (group-42) Requested by: Alice (alice)
qwen channel pairing approve reports the group Approved: group Release Room (group-42) can now use channel "groupbot".
After approval, an unpaired member of that group is served dave — never paired, not in any allowlist — got a full agent turn (confirmed on the model server's request ledger)
The same unpaired user in a DM stays blocked ✅ Same dave got a user pairing code in DM; group approval did not leak into senderPolicy
Approval lands in -groups.json, not -allowlist.json groupbot-groups.json = ["group-42"]; no user allowlist file existed at all

2. Workspace isolation (Reviewer Test Plan step 4)

Same channel name, same chatId, two workspaces:

workspace isolation

group-42 is approved in wsA and gets a fresh pairing code in wsB; the two stores live in different scope directories and wsB has no -groups.json at all.

3. Revocation, and silence on stored-loop checks

Revoked over the daemon API (DELETE /workspace/channels/groupbot/pairing-approvals with {"groupId":"group-42"}{"revoked":"group-42","senderIds":[],"groupIds":[]}). Afterwards an ambient message in that group was silent again, and a mention re-opened a normal group request.

The "stored loop authorization checks stay silent" claim is the one I most wanted to see for real, so I drove it with an actual scheduled loop:

scheduled loop

ivy — approved only through her group, never as a user — created a * * * * * loop that really fired and delivered an agent reply. After revoking the group, the next tick set "enabled": false in cron.json without posting anything to the group and without creating a pairing request. That is exactly what createPairingRequest: false is there for.

4. Daemon API and SDK

Against the live daemon:

  • GET .../pairing-requests returns typed subjects for both kinds ({"type":"group","id":"group-77","name":"Ops War Room"} alongside {"type":"user","id":"dave",...}).
  • GET .../pairing-approvals{"senderIds":[],"groupIds":["group-42"]}.
  • DELETE .../pairing-approvals validation is correct: both fields → 400 invalid_channel_pairing_subject; neither → 400 invalid_channel_pairing_subject; {"groupId":""}400 invalid_channel_pairing_group_id.
  • The published SDK (@qwen-code/sdk/daemon) round-trips list → approve → approvals → revoke-by-groupId against the daemon, and DaemonChannelPairingRevocationRequest really does reject {senderId, groupId} together at compile time (verified with a @ts-expect-error that tsc accepts as used).

5. Web Shell UI (real daemon, real browser)

Pending group requests After approving a group Revoke confirmation
pending approved revoke

The requests are those the real runtime produced. Approving "Group: Release Team" in the browser wrote ["group-99"] to release-bot-groups.json on disk; revoking wrote [] back. User and group entries render distinctly and the group rows carry "Requested by".

6. Mutation tests — are these hunks load-bearing?

Each mutation reverts exactly one PR hunk on the PR tree, rebuilds, and replays the same real message:

mutations

All three regress, so all three are necessary. M2 is worth highlighting: without the one-line || config.groupPolicy === 'pairing' in the PairingStore wiring, a senderPolicy: "open" + groupPolicy: "pairing" channel is permanently un-approvable — every mention answers "Too many pending pairing requests" and no request is ever recorded.

7. Authorization-domain separation

I also probed the security property called out in the PR description. A DM sender whose ID is literally group-77 (colliding with a real group id) was approved; it landed in groupbot-allowlist.json only, and group group-77 still got a pairing code on its next mention. No cross-domain leak.

8. Suites on this tree

packages/channels/base 1004/1004 · packages/channels/github 203/203 · packages/sdk-typescript 1456/1456.

Notes for the merge decision (none are blockers)

  1. No CLI revoke. qwen channel pairing has list and approve but no revoke. Group revocation is only reachable through the daemon API or Web Shell, so an operator running standalone qwen channel start has no first-class way to undo a group approval short of editing <qwen-home>/channels/<scope>/<name>-groups.json. Worth a follow-up.
  2. Plugin channels are invisible in the Web Shell editor. The daemon process never calls loadChannelsFromExtensions(), so an extension-provided channel type is projected down to { type } and the pairing panel never renders for it. This is pre-existing and unrelated to this PR, but it meant the UI leg had to run against a built-in channel type; the pairing state behind it was still produced by the real runtime and read from the same store files.
  3. Not covered here: live Telegram / Feishu / DingTalk / QQ platform runs, and the GitHub/GitLab directed-lane changes, which I only exercised through the PR's own suites (203 passing) rather than against a fake forge API.

Verdict from my side: the runtime, store, CLI, daemon API, SDK, and UI all behave as the description says, the authorization boundaries hold under adversarial probing, and the changed lines are demonstrably necessary. LGTM.

中文版本

Maintainer 验证 —— 群配对的真实环境端到端跑测

我在本地构建了这个 PR,并在真实栈上做了端到端验证:真实的 qwen channel start 运行时、真实的 qwen serve daemon、真实的 TypeScript SDK,以及真实浏览器里的 Web Shell。Reviewer 测试计划里的每一条都能复现,没有发现缺陷。 细节、证据和三个变异测试见下。

测试的 commit: f46e265dd96a3b0c724c25770ea781b85aea5d7b(当前 PR head)

环境

组件 实际用的是什么
构建 干净 worktree,npm ci + 完整 npm run build + npm run bundle(根 dist/cli.js
运行时 真实 qwen channel start,以及 qwen serve --channel 的 daemon 托管 worker
频道适配器 一个真实的 channel 插件扩展(ChannelPlugin + ChannelBase 子类),带 HTTP 控制面,因此我能像 Telegram/飞书适配器那样如实设置 isGroup / isMentioned / isReplyToBot。gate、store、配对流程没有任何一处被打桩。
模型 记录型 OpenAI 兼容服务(OPENAI_BASE_URL),所以"agent 真的回答了"可以两边对账
隔离 独立 QWEN_HOME,两个工作空间 wsA / wsB,都配置了同名频道 groupbot
UI 开了 bearer 认证的 qwen serve 提供打包好的 Web Shell,用 Playwright/Chromium 驱动

1. 运行时行为(测试计划第 1–3 步)

senderPolicy: "pairing" + groupPolicy: "pairing" 的频道上一次连续会话(见上文第一张截图):

结论 结果
未知群的普通(未 @)消息保持静默 ✅ 无回复,连 groupbot-pairing.json 都没有被创建
@ 之后只产生一个请求,同时标识群和发起人 subject: {type: "group", id: "group-42", name: "Release Room"}senderId: "alice"
另一位成员再次 @ 复用同一个码 ✅ Bob 的 @ 拿到同一个码 JWAFSHFU;没有第二个请求,也没有第二次公开提示
请求产生后的普通消息仍然静默 ✅ Carol 的消息什么都没触发
qwen channel pairing list 展示群与发起人 Code: JWAFSHFU Group: Release Room (group-42) Requested by: Alice (alice)
qwen channel pairing approve 提示的是群 Approved: group Release Room (group-42) can now use channel "groupbot".
批准后,群里未配对的成员可以使用 dave——从未配对、不在任何 allowlist——拿到了完整的一轮 agent 回复(在模型服务的请求账本上核对过)
同一个未配对用户在私聊里仍被拦截 ✅ 同一个 dave 在私聊里拿到的是用户配对码;群审批没有渗漏到 senderPolicy
审批写入 -groups.json 而非 -allowlist.json groupbot-groups.json = ["group-42"];用户 allowlist 文件根本不存在

2. 工作空间隔离(测试计划第 4 步)

同一个频道名、同一个 chatId、两个工作空间(见上文第二张截图):group-42wsA 已批准,在 wsB 拿到全新的配对码;两个 store 位于不同的 scope 目录,wsB 完全没有 -groups.json

3. 撤销,以及定时任务检查的静默性

通过 daemon API 撤销(DELETE /workspace/channels/groupbot/pairing-approvals,body {"groupId":"group-42"}{"revoked":"group-42","senderIds":[],"groupIds":[]})。之后该群的普通消息重新静默,@ 则重新开出一个正常的群请求。

"定时任务授权检查保持静默"这条我最想看到真实证据,所以用真正的定时任务跑了一遍(见上文第三张截图):ivy 仅通过获得授权、从未作为用户被批准,她创建的 * * * * * 定时任务真的触发并投递了 agent 回复。撤销群审批后,下一次 tick 把 cron.json 置为 "enabled": false没有向群里发任何消息,也没有创建配对请求——这正是 createPairingRequest: false 的意义。

4. Daemon API 与 SDK

对着运行中的 daemon:

  • GET .../pairing-requests 两种主体都返回带类型的 subject({"type":"group","id":"group-77","name":"Ops War Room"}{"type":"user","id":"dave",...} 并存)。
  • GET .../pairing-approvals{"senderIds":[],"groupIds":["group-42"]}
  • DELETE .../pairing-approvals 的校验正确:两个字段都给 → 400 invalid_channel_pairing_subject;都不给 → 400 invalid_channel_pairing_subject{"groupId":""}400 invalid_channel_pairing_group_id
  • 已发布的 SDK(@qwen-code/sdk/daemon)对 daemon 完整跑通 list → approve → approvals → 按 groupId 撤销;并且 DaemonChannelPairingRevocationRequest 确实在编译期拒绝同时给 {senderId, groupId}(用 @ts-expect-error 验证,tsc 认为该指令被用上了)。

5. Web Shell UI(真实 daemon、真实浏览器)

见上文三张 UI 截图。列表里的请求就是真实运行时产生的。在浏览器里批准 "Group: Release Team" 后,磁盘上的 release-bot-groups.json 变成 ["group-99"];撤销后写回 []。用户与群条目渲染有区分,群条目带 "Requested by"。

6. 变异测试 —— 这些改动是否必要

每个变异只在 PR 树上回退一个 hunk,重新构建,再重放同一条真实消息(见上文变异截图)。三个都出现回归,说明三处都必要。其中 M2 值得强调:少了 PairingStore 接线里那一行 || config.groupPolicy === 'pairing',一个 senderPolicy: "open" + groupPolicy: "pairing" 的频道会永久无法被批准——每次 @ 都回 "Too many pending pairing requests",而且请求根本不会被记录下来。

7. 授权域隔离

我还验证了 PR 描述里提到的安全性质:让一个 ID 恰好是 group-77(与真实群 id 冲突)的私聊发送者通过配对并批准,结果只写进了 groupbot-allowlist.json,而群 group-77 下一次 @ 仍然拿到配对码。没有跨域渗漏。

8. 本树上的测试套件

packages/channels/base 1004/1004 · packages/channels/github 203/203 · packages/sdk-typescript 1456/1456。

合入前值得留意的点(都不是阻塞项)

  1. CLI 没有 revoke。 qwen channel pairing 只有 listapprove。群撤销只能走 daemon API 或 Web Shell,因此只跑独立 qwen channel start 的运维者,除了手改 <qwen-home>/channels/<scope>/<name>-groups.json 之外没有正规手段撤销群审批。建议后续补上。
  2. 插件频道在 Web Shell 编辑器里不可见。 daemon 进程从不调用 loadChannelsFromExtensions(),所以扩展提供的频道类型会被裁剪成 { type },配对面板不会渲染。这是既有行为、与本 PR 无关,但导致 UI 这一环必须挂在内置频道类型上;其背后的配对状态仍然是真实运行时产生的,读的也是同一批 store 文件。
  3. 本次未覆盖: 真实的 Telegram / 飞书 / 钉钉 / QQ 平台联调,以及 GitHub/GitLab 定向 lane 的改动(那部分只跑了 PR 自带的 203 个用例,没有对着伪造 forge API 实跑)。

我这边的结论:运行时、存储、CLI、daemon API、SDK 和 UI 的行为都与描述一致,授权边界在对抗性探测下成立,改动的每一行都被证明是必要的。LGTM。

@wenshao

wenshao commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (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: 2995 passed · 0 failed · 2995 total

中文 — 判定:✅ 通过 · 可合入(agent 判定)

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

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

Verification report

PR 8440 Deep Verification — feat(channels): support group pairing

Verdict: merge-ready — 2995/2995 scripted assertions passed, 0 failures.
Verified head: f46e265dd96a3b0c724c25770ea781b85aea5d7b (git rev-parse HEAD^2),
base tip fca8f3c1f (HEAD^1), effective diff git diff HEAD^1..HEAD (54 files, +2795/−308).

中文摘要

结论:merge-ready。2995 条脚本化断言全部通过,0 失败。

  • A/B 结论:在完全相同的配置与消息序列下,head 与 base 两个编译产物分别驱动真实
    ChannelBase 运行时。核心翻转成立:base 上群成员必须逐人配对(alice 获批后 bob 仍被拦截、
    反复收到个人配对码);head 上一次群审批即放行全群(bob 的消息直达 agent),撤销 groupId
    后全群重新被拦截。head 34/34、base 20/20 断言通过(见
    01-ab-head-vs-base.png02-ab-head-arm.png)。
  • 边界与 wire 验证:PairingStore 边界探测 33/33(旧格式迁移、用户/群标识碰撞隔离、
    共享上限、损坏的群 allowlist 拒绝重建且不烧毁配对码、工作区隔离、../ 名称不越界、
    群请求 1 小时过期);daemon 路由经真实 loopback HTTP 验证 35/35(typed subject 上线、
    senderId/groupId 互斥校验、404/409/401 语义、跨工作区隔离);定时任务授权契约 5/5
    createPairingRequest:false 永不产生请求)。
  • 变异矩阵:未变异对照 644 测试全绿;7 个变异体(含阳性对照)全部被杀死,无存活。
  • 门禁:channels/base 1004、cli 相关 5 文件 92、sdk-typescript 1464、web-shell 组件 37、
    github 适配器 203、gitlab 适配器 59、Playwright e2e 1 —— 全部通过。
  • Findings:仅 1 条 nit(群 allowlist 损坏告警措辞与严格模式下实际行为略有出入)。
  • 未覆盖:真实平台 E2E(本就声明延后);逐 commit 归因(浅克隆 depth 2 不可达);
    全仓 typecheck/lint(由 PR 自身 CI 覆盖,未重跑)。

Scope selection

  • Central claim: with groupPolicy: 'pairing', one mention/reply from an
    unapproved group creates exactly one group pairing request (stable chat ID +
    initiating member retained); approving the code grants every member access;
    revoking by groupId blocks the group again; DMs keep following senderPolicy.
  • Secondary 1: ambient (unmentioned, non-reply) messages and stored-loop
    authorization checks never create pairing requests.
  • Secondary 2: user and group approvals are domain-separated (no identifier
    collision crosses) and stay workspace-scoped.

Central claim — A/B load-bearing proof

Both arms run the same config (groupPolicy:'pairing', senderPolicy:'pairing')
and the same envelope sequence against the compiled channel-base dist of each
tree (HEAD vs HEAD^1 worktree rebuilt with tsc --build; base dist verified to
contain no group-pairing code and no @qwen-code/* imports, so no workspace-symlink
cross-contamination — the package's only dependency is @agentclientprotocol/sdk,
unchanged in this PR, and the PR touches no lockfile). Only the three abstract
platform methods (connect/sendMessage/disconnect) are implemented by the probe
channel; all gating/pairing/store logic is each tree's own compiled code against a
real temp QWEN_HOME. Harness: ab-harness.mjs.

# Cell (observable oracle) base (fca8f3c1f) head (f46e265dd)
S0 ambient group msg → pending / sent / prompts 0 / 0 / 0 (dropped by mention gate) 0 / 0 / 0 (pairing_trigger_required)
S1 alice mentions bot → pending subject; notice 1 per-user request (alice); DM-wording notice 1 group request group:g1, initiator alice retained; group notice with code
S2 bob (2nd member) mentions → requests; notices 2nd per-user request; 2nd notice same request reused; no 2nd notice
S3 approve first code only alice approved; bob still pending group g1 approved; user allowlist untouched; pending 0
S4 bob mentions again → dispatched? blocked, 3rd code notice dispatched to agent (prompts=1), no new notice
S5 DM from carol user pairing request (unchanged flow) user pairing request, subject user:carol (DM flow intact)
S6/S7 revokeGroup('g1'); bob mentions no group API exists on base revoked=true; blocked again; new group request + re-notice
S8/S9 user vs group approvals on disk single user allowlist file separate -allowlist.json / -groups.json; group approval never adds members to user list

Result: head 34/34 assertions pass; base 20/20 pass — including the cells
where base is expected to show per-user granularity (bob blocked after alice's
approval). That expected-broken behavior is exactly what flips on head, so the
change is load-bearing. Evidence: 01-ab-head-vs-base.png, 02-ab-head-arm.png.

Control caveat: on base, groupPolicy:'pairing' is not a value the base settings
validator accepts (its enum lacks it — tmp/base-tree/packages/cli/src/serve/channel-settings-store.ts);
the base arm drives ChannelBase directly, which tolerates the unknown policy by
falling through to mention-gating + per-user sender pairing. That is the honest
runtime semantics a pre-PR build would apply to the same config object.

Config-surface acceptance at head was verified over real HTTP in the route oracle
(cell W1: PUT /workspace/channels/gh-bot with groupPolicy:'pairing' → 200 via
the real github plugin validation; groupPolicy:'bogus-value' → 400).

Reviewer Test Plan, step by step

  1. One pending request identifies group + initiating member — A/B S1/S2
    (subject group:g1 Release Team, initiator alice; bob's mention reuses the
    same request and does not post a second notice) and route cell W4. ✅
  2. Approve → another member can use it; unapproved DM sender stays blocked
    A/B S3/S4/S5 (bob dispatched after one approval; carol's DM still pairs per-user;
    on base this step cannot complete — bob stays blocked, which is the point of the PR). ✅
  3. Revoke by groupId blocks again; ambient + stored-loop checks create no
    requests
    — A/B S6/S7; loop-contract probe L1–L4 (5/5: createPairingRequest:false
    never creates a request, approved groups pass for any member, revocation denies
    silently); the suite itself pins loop-job behavior under pairing
    (ChannelBase.test.ts ≈L18969/L19051). ✅
  4. Group approvals isolated by workspace — store-boundary B6 (two cwds, same
    channel name: approval in A invisible in B) and route cell W11 (same channel
    name in two workspaces over the qualified /workspaces/:workspace routes:
    primary groupIds=['team-chat-1'], secondary []). ✅

Mutation matrix (vacuity + pinning)

Suites: GroupGate + PairingStore + SenderGate + ChannelBase (644 tests).
Unmutated control: 644/644 green (logs/mutation-control.log).

Mutant Change Result Pinning test(s) (first)
M0 (positive control) remove same-subject dedup in createSubjectRequest killed (5 red) reuses one pending request per group regardless of the initiating sender
M1 delete GroupGate pairing branch killed (14 red) pairs a mentioned group once and lets other members use it
M2 delete sender_pending guard killed (4 red) limits each sender to one pending request across subjects
M3 approve() consumes request before persisting approval (pre-PR order) killed (1 red) refuses to rebuild an unreadable group allowlist on approve — fails the intended assertion: expected [] to have a length of 1 but got +0 (pending request burned)
M4 disable strict refusal of unreadable group allowlist killed (1 red) same durability test
M5 remove approved-group sender-gate bypass in preflight killed (5 red) backfills messages from members of an approved paired group
M6 ambient messages create requests killed (3 red) does not create group pairing requests from ambient messages

No survivors; the positive control (M0) proves the harness can land a kill, and M3's
failure message shows the revert fails the behavioral assertion (pending request
consumed despite failed allowlist write), not an import/setup break.
Evidence: 06-mutation-matrix.png; raw logs logs/mutation-*.log. Tree restored
after each mutant (git status clean).

Wire-oracle harnesses

  • Daemon routes over loopback HTTP (route-oracle.mjs, 35/35,
    04-route-wire-oracle.png): real compiled routes + real
    createChannelManagementService + real WorkspaceChannelSettingsStore + real
    PairingStore files; only the worker-manager (process lifecycle, unrelated to
    pairing) and auth middleware are faked, mirroring the repo's own route tests.
    Cells: upsert accepts groupPolicy:'pairing' and rejects bogus values; empty
    snapshot; typed group subject + initiator on the wire; unknown code → 404;
    approve → approved.subject.type==='group'; approvals snapshot
    {senderIds:[], groupIds:['team-chat-1']}; DELETE with both/neither ids → 400
    invalid_channel_pairing_subject; non-string/empty groupId → 400
    invalid_channel_pairing_group_id; revoke by groupId → 200 and store agrees;
    repeat revoke → 404; user revoke via senderId leaves group domain untouched;
    non-pairing channel → 409; reads require bearer token (401); Cache-Control: no-store; unknown instance → 404; workspace isolation on the qualified routes.
  • PairingStore boundary (store-boundary.mjs, 33/33, 03-store-boundary.png):
    legacy pending entries normalize to user subjects and approve cleanly (migration
    claim); identifier collision dup-id approved/revoked independently in both
    domains; shared 3-slot cap across users+groups (cap_reached); sender_pending
    in both directions while a second member still reuses the group's code; corrupted
    groups.json → non-strict read treats as empty with stderr warning, strict
    approve path throws refusing to rewrite unreadable group allowlist and the
    pending request survives (code still usable after repair); workspace isolation;
    ../evil channel name stays inside the scope dir; group requests expire at 1 h.
  • Stored-loop contract (/tmp/loop-contract-probe.mjs, 5/5,
    05-loop-contract.png): GroupGate.check(env, {createPairingRequest:false})
    denies unapproved groups as pairing_trigger_required without creating requests,
    passes any member of an approved group, and denies silently after revocation —
    the exact contract isStoredLoopTargetAuthorized relies on.

Targeted gates (exact counts, all at head)

Suite Result
packages/channels/base full (19 files) 1004/1004
packages/cli — pairing, management service, routes, settings store, registry (5 files) 92/92
packages/sdk-typescript full (32 files) 1464/1464
packages/web-shell — ChannelEditorDialog + ChannelPairingRequests 37/37
packages/channels/github (adapter mention-synthesis guards) 203/203
packages/channels/gitlab 59/59
packages/web-shell Playwright e2e web-shell.channels.spec.ts (incl. group approve/revoke UI) 1/1

The author's claimed counts (605 / 87 / 318) are subsets of these full-suite
numbers; the full suites are green. Base-side rebuild compiled cleanly with
tsc --build (exit 0). Repo-wide build/typecheck/lint were already run by the
PR's own CI and were not re-run here.

Findings

  1. Nit — warning wording vs strict behavior. When *-groups.json exists but is
    unreadable, the non-strict read warns that stored approvals "will be lost on the
    next approve" (PairingStore.readGroupAllowlist). But while the file stays
    unreadable, approve() throws instead (the strict path this PR adds) — so no
    approve can succeed and nothing is lost until the file becomes readable again
    (e.g., manual repair/truncation, after which only post-repair approvals remain —
    measured in B5). The operator-visible consequence is the same (approvals inactive
    until fixed); only the "next approve" phrasing is slightly inaccurate. Measured,
    no behavioral impact; no fix required.

No blocking findings. Specifically checked and not problems: drain-discard in
drainPendingGroupHistory (only reachable via a concurrent-revocation race, where
discarding is the fail-closed direction); GithubAdapter's synthesized-mention guard
for unapproved pairing groups (pinned by the adapter's own +230 test lines);
sender_pending group rejection wording deliberately not attributing a member's
pending request to the group.

Not covered

  • Per-commit attribution: checkout is depth 2 (git rev-parse --is-shallow-repository = true; git rev-list --count HEAD^1..HEAD^2 returns 1
    at the shallow boundary vs 13 commits in the metadata snapshot). The aggregate
    HEAD^1..HEAD diff is what was verified; individual review-round commits were
    not exercised separately.
  • Live platform E2E (real Telegram/GitHub/etc. traffic) — declared out of scope
    by the PR itself; adapter behavior verified via their unit suites only.
  • Repo-wide typecheck/lint/prettier — covered by the PR's CI; not re-run.
  • QR-based channel auth, Web Shell config UI beyond the changed components
    deferred by the PR to later staged PRs.
  • Drain-discarding of group history under adversarial scheduling — analyzed
    statically (fail-closed); no dedicated harness cell.
  • Web-shell Playwright run emits vite proxy ECONNREFUSED noise for routes the
    mock daemon intercepts; the spec passed regardless (this is the spec's designed
    mock seam, not a failure).

Methodology

Environment: CI verify container (node:22-bookworm), working tree = merge commit
933f926600 (refs/pull/8440/merge), HEAD^1 = base tip, HEAD^2 = PR head.
npm ci + npm run build pre-ran at HEAD. Base control: git worktree add tmp/base-tree HEAD^1, root node_modules symlinked in (lockfile untouched by the
PR), packages/channels/base recompiled there with tsc --build (exit 0); base
dist asserted free of group-pairing code and of any @&#8203;qwen-code/* import (the
package's sole runtime dep is @&#8203;agentclientprotocol/sdk), realpath of the base
dist confirmed inside the base tree. Harnesses drove compiled dist/ output with
real temp QWEN_HOME filesystems and real loopback HTTP — no stubs of code under
test; the probe channel implements only abstract platform methods, the route oracle
fakes only worker-lifecycle and auth middleware (as the repo's own tests do).
Mutations applied to source, suites run, git checkout -- restored after each
(worktree verified clean at the end). Raw per-cell logs live in logs/
(mutation-*.log, sdk-suite.log); harness scripts in this directory
(ab-harness.mjs, store-boundary.mjs, route-oracle.mjs, run-mutations.sh).
Assertion accounting: harness assertions 127 (34+20+33+35+5) + gate tests 2860
(1004+92+1464+37+203+59+1) + mutation checks 8 (1 control-green + 7 kills) =
2995 pass, 0 fail.

Evidence images

01-ab-head-vs-base

02-ab-head-arm

03-store-boundary

04-route-wire-oracle

05-loop-contract

06-mutation-matrix

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

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on f46e265dd96a3b0c724c25770ea781b85aea5d7b — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 f46e265dd96a3b0c724c25770ea781b85aea5d7b既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

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

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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

Autofix review round on PR #8440: no action needed

This round found no actionable feedback on PR #8440 (group pairing), so no
code was changed and no commit was made. The branch stays at the verified head
f46e265dd9.

Feedback triage

# Source Content Classification Disposition
1 @wenshao (maintainer verification, issue comment) Real-stack end-to-end verification — runtime, PairingStore, CLI, daemon API, SDK, and Web Shell all behave as described; authorization boundaries hold under adversarial probing; mutation tests confirm the hunks are load-bearing. LGTM Positive verification No action
2 @wenshao note 1 — no CLI revoke subcommand Explicitly flagged "none are blockers" and "worth a follow-up" Out of scope for this PR Deferred to a follow-up issue/PR, per the maintainer's own framing
3 @wenshao note 2 — plugin channels invisible in Web Shell editor Explicitly "pre-existing and unrelated to this PR" Out of scope No action
4 @wenshao note 3 — live platform runs not covered Declared out of scope by the PR itself Informational No action
5 @qwen-code-ci-bot (sandboxed verification) ✅ passed — merge-ready; 2995/2995 scripted assertions, no blocking findings Positive verification No action
6 Verification-report nit — warning wording in PairingStore.readGroupAllowlist Explicitly "Measured, no behavioral impact; no fix required" Declined See below

Why finding #6 was declined

The sandboxed verification's only finding is a wording nit: the non-strict read
of an unreadable *-groups.json warns that approvals "will be lost on the next
approve", while the strict approve() path actually throws until the file is
readable again. The report itself measured this, concluded there is no
behavioral impact
, and stated no fix required. The operator-visible
consequence (approvals inactive until the file is repaired) is identical either
way.

Both verifications tested and approved the exact current head
(f46e265dd9). Editing the warning string now would invalidate the commit both
verifiers exercised and trigger another full deterministic verification round
for zero behavioral gain — pure diff churn, not value. This matches the
Simplicity First principle: not worth the diff growth. If a maintainer still
wants the wording tightened, it is a one-line follow-up.

State

  • No reviews, no inline comments, no failed checks, no still-red checks were
    present in this round's feedback.
  • Nothing was resolved, declined-as-inline, or escalated, so no
    resolved-comments.txt or comment-replies.json is produced.
  • Working tree clean; branch unchanged at f46e265dd9.
中文说明

PR #8440 的 Autofix 评审轮次:无需处理

本轮在 PR #8440(群配对)上没有发现任何可处理的反馈,因此未修改任何代码、未产生任何提交。分支保持在已验证的 head f46e265dd9

反馈分类

# 来源 内容 分类 处理
1 @wenshao(maintainer 验证,issue 评论) 真实栈端到端验证 —— 运行时、PairingStore、CLI、daemon API、SDK 与 Web Shell 行为均与描述一致;授权边界在对抗性探测下成立;变异测试证明各改动块均不可或缺。LGTM 正向验证 无需处理
2 @wenshao 备注 1 —— CLI 缺少 revoke 子命令 明确标注"都不是阻塞项"且"建议后续补上" 本 PR 范围之外 按 maintainer 自己的表述,留给后续 issue/PR
3 @wenshao 备注 2 —— 插件频道在 Web Shell 编辑器里不可见 明确标注"既有行为、与本 PR 无关" 范围之外 无需处理
4 @wenshao 备注 3 —— 未覆盖真实平台联调 PR 本身已声明延后 信息性说明 无需处理
5 @qwen-code-ci-bot(沙箱验证) ✅ 通过 —— 可合入;2995/2995 条脚本断言,无阻塞性发现 正向验证 无需处理
6 验证报告 nit —— PairingStore.readGroupAllowlist 告警措辞 明确标注"实测无行为影响;无需修复" 拒绝(Declined) 见下

为什么拒绝第 6 条

沙箱验证唯一的发现是一个措辞 nit:当 *-groups.json 存在但不可读时,非严格读取会告警称已存审批"将在下次 approve 时丢失",而严格的 approve() 路径实际上会直接抛错,直到文件恢复可读为止。报告本身实测后认定无行为影响,并明确写了无需修复。两种措辞下运维侧可见的结果完全一致(文件修复前审批处于失效状态)。

两次验证测试并批准的都是当前这个 head(f46e265dd9)。现在去改告警字符串只会让两个验证器实际跑过的 commit 失效,并为零行为收益再触发一轮完整的确定性验证 —— 纯属 diff 空转,没有价值。这也符合 Simplicity First 原则:不值得为此增加 diff。若 maintainer 仍希望收紧措辞,那是一行的后续改动。

状态

  • 本轮反馈中没有 reviews、没有 inline comments、没有失败检查、没有持续飘红的检查。
  • 本轮既没有解决任何 inline 发现,也没有拒绝/升级任何 inline 发现,因此不产出 resolved-comments.txtcomment-replies.json
  • 工作树干净;分支保持在 f46e265dd9 未变。

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


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

@qqqys

qqqys commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /review

@wenshao

wenshao commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (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: 2996 passed · 0 failed · 2996 total

中文 — 判定:✅ 通过 · 可合入(agent 判定)

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

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

Verification report

PR 8440 Deep Verification (follow-up round) — feat(channels): support group pairing

Verdict: merge-ready — 2996/2996 scripted assertions passed, 0 failures.
Verified head: 9c7144211bf17fbb05198e13779b01da3448aede (git rev-parse HEAD^2), base tip 650e085fec82fe892ece710b144dc35e6f890768 (HEAD^1), effective diff git diff HEAD^1..HEAD (54 files, +2795/−308, sha256 cfb573c7…5c44e2). This is a follow-up round: the previous substantive report verified head f46e265dd and returned merge-ready; every measurement below was re-run from scratch at the new head — nothing carried forward by shortcut.

中文 — 判定:✅ 通过 · 可合入(agent 判定)· 复测轮

本轮为复测:上一轮在 head f46e265dd 上给出 merge-ready;新 head 9c7144211b 仅是"上一轮已验证 head 与当前 main(650e085fec,core 转录分页修复,不涉及频道代码)的合并",且合并提交树与 PR head 树完全一致(tree(HEAD)==tree(HEAD^2))、有效 diff 统计与上轮完全相同(54 文件,+2795/−308)。尽管如此,本轮未做任何结转豁免:全部测量在新 head 上重建重跑。

  • A/B 结论(见 01-ab-head-vs-base.png):同一配置与消息序列分别驱动 head 与 base 两套编译产物。核心翻转再次成立:base 上群成员必须逐人配对(alice 获批后 bob 仍被拦截、反复收到个人配对码,18/18 断言按预期"坏");head 上一次群审批即放行全群、按 groupId 撤销后全群重新被拦截(26/26)。
  • 边界与 wire:PairingStore 边界 27/27(旧格式迁移含群 allowlist、标识碰撞隔离、共享上限、sender_pending、损坏群 allowlist 严格拒绝且不烧毁配对码、工作区隔离、../ 不越界、1 小时过期);daemon 路由真实 loopback HTTP 24/24(typed subject、互斥校验、404/409/401 语义、跨工作区隔离);定时任务授权契约 head 10/10、base 对照 6/6(授权检查永不产生配对请求)。
  • 变异矩阵:未变异对照 644 全绿;7 个变异体(含阳性对照)全部杀死、无存活;二次重跑结果逐位相同(5/14/4/1/1/5/3 红)。
  • 门禁:channels/base 1004、cli 5 文件 92(并植入违规验证门禁为活)、sdk-typescript 1464、web-shell 组件 37、github 203、gitlab 59、webui actions 16、Playwright e2e 1 —— 全部通过。
  • 上轮唯一 nit(告警措辞):复测仍存在、行为未变,严重度不变(见状态表)。
  • 未覆盖:真实平台 E2E(PR 声明延后);逐 commit 归因(浅克隆不可达);全仓 typecheck/lint(PR 自身 CI);群历史 drain 的对抗调度仅静态分析。

Previous-finding status (follow-up round)

Previous substantive report: merge-ready at head f46e265dd with 1 nit and 0 blocking findings.

# Previous finding Severity Status at new head 9c7144211b
1 Warning wording vs strict behavior: readGroupAllowlist non-strict path warns stored group approvals "will be lost on the next approve", but while the file stays unreadable approve() throws (strict path) so no approve can succeed and nothing is lost nit stands (unchanged), re-measured. Cell B9: the warning text is byte-identical at the new head, and 0/3 approve attempts succeeded while chan-groups.json was corrupted (each threw refusing to rewrite unreadable group allowlist); the pending code stayed usable and approved after repair (B5). Behaviorally harmless; wording still slightly inaccurate.

Previously "checked and not problems" — re-verified at the new head: drain-discard in drainPendingGroupHistory remains fail-closed (static re-read of the unchanged hunk; no dedicated cell — see Not covered); GithubAdapter synthesized-mention guard re-pinned by the adapter gate (203/203, +230 test lines from the PR); sender_pending group rejection wording (deliberately not attributing a member's pending request to the group) unchanged in source and covered by the ChannelBase suite.

Delta since the previous round

The new head 9c7144211b is a pure merge whose parents are the previously verified head f46e265dd and the current base tip 650e085fec (a core transcript-pagination fix touching no channel/cli/sdk/web-shell surface). Three facts establish the verified surface is unchanged: tree(HEAD) == tree(HEAD^2) (c30cb603… — the CI pull-merge added nothing), the effective diff stat is identical to the previous round's aggregate (54 files, +2795/−308), and the PR touches no package.json/package-lock.json. The parent commits themselves are unreachable (depth-2 checkout), so the identity rests on those three facts plus full re-measurement rather than on a direct f46e265dd..9c7144211b diff.

Scope selection

  • Central claim: with groupPolicy: 'pairing', one mention/reply from an unapproved group creates exactly one group pairing request (stable chat ID + initiating member retained); approving the code grants every member access; revoking by groupId blocks the group again; DMs keep following senderPolicy.
  • Secondary 1: ambient (unmentioned, non-reply) messages and stored-loop authorization checks never create pairing requests.
  • Secondary 2: user and group approvals are domain separated (no identifier collision crosses) and stay workspace-scoped.

Central claim — A/B load-bearing proof

Both arms run the same config (groupPolicy:'pairing', senderPolicy:'pairing') and the same envelope sequence against the compiled channel-base dist of each tree (HEAD vs a HEAD^1 worktree rebuilt with tsc --build, exit 0). Isolation asserted before trusting the control: base dist contains 0 occurrences of createGroupRequest/pairing_trigger_required (head dist contains both), and readlink -f of the loaded base ChannelBase.js resolves inside the base worktree. The package's only runtime dependency is @agentclientprotocol/sdk (no @qwen-code/* imports), and the PR leaves the lockfile untouched, so reusing the root node_modules is a clean control. Probe channel implements only the three abstract platform methods; all gating/pairing/store logic is each tree's own compiled code against a real temp QWEN_HOME. Harness: ab-harness.mjs. Witness: 01-ab-head-vs-base.png.

# Cell (observable oracle) base (650e085fec) head (9c7144211b)
S0 ambient group msg → pending / sent / prompts 0 / 0 / 0 (mention-gate drop) 0 / 0 / 0 (pairing_trigger_required)
S1 alice mentions bot → pending subject; notice 1 per-user request, no typed subject; DM wording 1 group request group:g1 "Release Team", initiator alice/Alice retained; group-wording notice with code
S2 bob (2nd member) mentions → requests; notices 2nd per-user request; 2nd notice same request reused (same code); no 2nd notice
S3 approve first code only alice approved; bob still pending (1 left) group g1 approved; user allowlist untouched; pending 0
S4 bob mentions again → dispatched? blocked, 3rd code notice, prompts 0 dispatched (prompts 1); extra send is the agent reply, not a pairing notice
S5 DM from carol user pairing request; notice user pairing request, subject user:carol; DM wording intact
S6 revoke g1 no group API exists (typeof revokeGroup === 'undefined') revokeGroup('g1') === true; group no longer approved
S7 bob mentions after revocation still blocked per-user; re-noticed blocked again; new group request + re-notice with new code
S8 on-disk domains no -groups.json ever written dedicated -groups.json; user allowlist file empty/absent — no cross-leak

Result: head 26/26 assertions pass; base 18/18 pass — the base cells encode the expected per-user behavior (bob blocked after alice's approval), and that expected-broken behavior is exactly what flips on head, so the change is load-bearing.

Control caveat (same as previous round, re-verified): on base the settings validator's groupPolicy enum lacks 'pairing' (channel-settings-store.ts at HEAD^1); the base arm drives ChannelBase directly, which tolerates the unknown policy by falling through to mention-gating + per-user sender pairing — the honest runtime semantics a pre-PR build applies to the same config object. Config-surface acceptance at head was verified over real HTTP (cell W1: upsert with groupPolicy:'pairing' → 200 through the real github plugin descriptor; 'bogus-value' → 400 channel_settings_invalid_config).

Reviewer Test Plan, step by step

  1. One pending request identifies group + initiating member — A/B S1/S2 (subject group:g1 Release Team, initiator alice; bob's mention reuses the request, no second notice) and wire cell W3. ✅
  2. Approve → another member can use it; unapproved DM sender stays blocked — A/B S3/S4/S5 (bob dispatched after one approval; carol's DM still pairs per-user; on base this step cannot complete — bob stays blocked, which is the point of the PR). ✅
  3. Revoke by groupId blocks again; ambient + stored-loop checks create no requests — A/B S6/S7; loop-contract probe head 10/10 (the real isStoredLoopTargetAuthorized predicate denies unapproved groups, passes any member once approved, and never creates a request — even when the check synthesizes a mention/reply envelope); base contrast 6/6 (group loop authz required per-user approval). ✅
  4. Group approvals isolated by workspace — store cell B6 (two cwds, same channel name: approval in A invisible in B) and wire cells W16 (same channel name in two workspaces over the qualified /workspaces/:workspace routes: secondary groupIds=['other-team'], primary ['team-chat-2']; qualified revoke works). ✅

All four steps were performable — no unreachable step found this round.

Mutation matrix (vacuity + pinning)

Suites: GroupGate + PairingStore + SenderGate + ChannelBase (644 tests). Unmutated control: 644/644 green in 14 s. Each mutant = one exact string replacement (asserted to match exactly once), suite run, git checkout -- restore; runner run-mutations.mjs, raw logs logs/mutation-*.log. The matrix was run twice (second run produced the capture); both runs byte-identical in red counts. Witness: 05-mutation-matrix.png.

Mutant Change Result First failing test (behavioral)
M0 (positive control) remove same-subject dedup in createSubjectRequest killed (5 red) reuses one pending request per group regardless of the initiating sender
M1 delete GroupGate pairing branch killed (14 red) does not create a pairing request for ambient group messages
M2 delete sender_pending guard killed (4 red) limits each sender to one pending request across subjects
M3 approve() consumes request before persisting approval (pre-PR order) killed (1 red) refuses to rebuild an unreadable group allowlist on approve — fails the intended assertion: expected [] to have a length of 1 but got +0 (pending request burned despite failed allowlist write)
M4 disable strict refusal of unreadable group allowlist (readGroupAllowlist(true)false) killed (1 red) same durability test
M5 remove approved-group sender-gate bypass in preflight killed (5 red) backfills messages from members of an approved paired group
M6 ambient messages create requests killed (3 red) does not create a pairing request for ambient group messages

No survivors. M0 proves the harness can land a kill; M3/M4 failures quote expected-versus-actual values (behavioral, not import/setup breaks). Tree verified clean after the run (git status --porcelain empty).

Wire-oracle harnesses (all mock-free against compiled dist/)

  • Daemon routes over loopback HTTP (route-oracle.mjs, 24/24, witness 03-route-wire-oracle.png): real compiled registerWorkspaceChannelManagementRoutes + real createChannelManagementService + real WorkspaceChannelSettingsStore + real PairingStore files, driven with fetch over a real 127.0.0.1 socket; only the worker manager (process lifecycle, irrelevant to pairing — committedChannelNames() = [] keeps every channel stopped and reloadWorkspace uncalled) and the auth middleware are faked, mirroring the repo's own route tests. Cells: upsert accepts groupPolicy:'pairing' via the real github descriptor / rejects bogus values; empty snapshot + Cache-Control: no-store; typed group subject + initiator on the wire; unknown code → 404 channel_pairing_request_not_found (case accepted: approval used a lowercase code); approve → approved.subject.type==='group'; approvals snapshot {senderIds:[], groupIds:['team-chat-1']}; DELETE with both/neither id → 400 invalid_channel_pairing_subject; empty/non-string groupId → 400 invalid_channel_pairing_group_id; revoke by groupId → 200 and the store file agrees; repeat revoke → 404; user revoke leaves group domain untouched; non-pairing channel → 409 channel_pairing_not_enabled; no bearer → 401; unknown instance → 404; unknown workspace selector → 400 workspace_mismatch (rejected before store access); workspace isolation on qualified routes both directions.
  • PairingStore boundary (store-boundary.mjs, 27/27, witness 02-store-boundary.png): legacy global files (pending without subject, user allowlist, group allowlist) all grandfathered into the scoped dir with a sentinel; legacy pending normalizes to a user subject and approves cleanly; identifier collision dup-id approved/revoked independently in both domains in all four orders; shared 3-slot cap across users+groups; sender_pending both directions and across domains while a second member still reuses the group code; corrupted groups.json → non-strict read empty with stderr warning, strict approve throws refusing to rewrite unreadable group allowlist, pending request survives (code approves after repair); workspace isolation; ../evil channel name stays inside the scope dir (..%2Fevil-*.json); group requests expire at 1 h and the slot is reusable; B9 nit re-measurement (wording stands, 0/3 approves succeed while unreadable).
  • Stored-loop contract (loop-contract.mjs, head 10/10 + base contrast 6/6, witness 04-loop-contract-both-arms.png): drives the real private isStoredLoopTargetAuthorized(target, senderName) — the exact predicate loop jobs call — on both arms. Head: unapproved group denied with zero requests created; approved group passes for a member never seen; revoked group denied silently; DMs follow senderPolicy; group approval alone authorizes loops without any per-user pairing. Base contrast: group loop targets pass only for individually paired members, no group API exists — the control was run on both arms as required.

Targeted gates (exact counts, all at head)

Suite Result
packages/channels/base full (19 files) 1004/1004
packages/cli — pairing, management service, routes, settings store, registry (5 files) 92/92
packages/sdk-typescript full (32 files) 1464/1464
packages/web-shell — ChannelEditorDialog + ChannelPairingRequests 37/37
packages/channels/github 203/203
packages/channels/gitlab 59/59
packages/webui — daemon workspace actions 16/16
packages/web-shell Playwright e2e web-shell.channels.spec.ts 1/1

Gate liveness proven, not assumed: for channels/base, the mutation matrix itself is the plant — the identical vitest invocation went red under 7 mutants (up to 14 failing tests). For the cli gate, an additional plant: changing the group subject line in pairing.ts from Group: … to Sender: … turned exactly 1 pairing.test.ts test red (1 failed | 4 passed); source restored, tree clean. The author's claimed counts (605 / 87 / 318) remain subsets of these full-suite numbers.

Findings

  1. Nit (carried from previous round, stands) — warning wording vs strict behavior in PairingStore.readGroupAllowlist: while *-groups.json exists but is unreadable, the non-strict read warns approvals "will be lost on the next approve", but approve() throws on the strict path until the file is readable, so nothing is lost to an approve (B9: 0/3 succeeded). Operator-visible consequence unchanged (approvals inactive until repair); only the phrasing is slightly inaccurate. No behavioral impact; no fix required.

No blocking findings. No new findings introduced by the delta merge.

Not covered

  • Per-commit attribution: checkout is depth 2; git rev-list --count HEAD^1..HEAD^2 returns 1 at the shallow boundary vs 14 commits in the metadata snapshot, and the new head's parents (f46e265dd, 650e085fec — the latter reachable only as HEAD^1) are otherwise unreachable. The aggregate HEAD^1..HEAD diff is what was verified; per-commit review-round attribution stays out of reach (same as previous round).
  • Direct diff against the previously verified head: f46e265dd is not an object in this clone, so git diff f46e265dd..9c7144211b could not be computed; the "delta is only the main merge" conclusion rests on tree(HEAD)==tree(HEAD^2), identical diff stat, and the merge's parentage — plus full re-measurement regardless.
  • Live platform E2E (real Telegram/GitHub/etc. traffic) — declared out of scope by the PR; adapter behavior verified via their unit suites only.
  • Repo-wide typecheck/lint/prettier — covered by the PR's own CI; not re-run.
  • QR-based channel auth, Web Shell config/approval UI beyond the changed components — deferred by the PR to later staged PRs.
  • Drain-discarding of group history under adversarial scheduling — static re-read only (fail-closed direction: drain() runs, then entries are discarded if the group is not approved at that moment; a concurrent revocation can only discard, never leak). No dedicated harness cell.
  • Web-shell Playwright run emits vite proxy ECONNREFUSED noise for routes the mock daemon intercepts; the spec passed regardless (its designed mock seam, not a failure).

Methodology

Environment: CI verify container (node:22-bookworm), working tree = merge commit 19595f0361 (refs/pull/8440/merge), HEAD^1 = base tip 650e085fec, HEAD^2 = PR head 9c7144211b. npm ci + npm run build pre-ran at HEAD. Base control: git worktree add tmp/base-tree HEAD^1, root node_modules symlinked (PR touches no lockfile; base package's sole runtime dep is @agentclientprotocol/sdk), packages/channels/base recompiled there with tsc --build (exit 0); base dist asserted free of group-pairing code and realpath-confirmed inside the base tree; worktree force-removed after the A/B cells and captures were complete. Harnesses drove compiled dist/ output with real temp QWEN_HOME filesystems and real loopback HTTP — no stubs of code under test; the probe channel implements only abstract platform methods, and the route oracle fakes only worker-lifecycle and auth middleware (as the repo's own tests do). Mutations were applied to source via exact single-occurrence string replacements, suites run, git checkout -- restored after each (worktree verified clean; matrix run twice, identical). Raw per-cell logs live in logs/ (mutation-*.log, gate-*.log, ab-*.stderr.log); harness scripts in this directory (ab-harness.mjs, store-boundary.mjs, route-oracle.mjs, loop-contract.mjs, run-mutations.mjs). Evidence images were produced with scripts/verify-capture.mjs. Assertion accounting: harness assertions 111 (26+18 A/B, 27 store, 24 routes, 16 loop [head 10 + base contrast 6]) + mutation checks 8 (1 control-green + 7 kills) + gate-liveness plant 1 + gate tests 2876 (1004+92+1464+37+203+59+16+1) = 2996 pass, 0 fail.

Evidence images

01-ab-head-vs-base

02-store-boundary

03-route-wire-oracle

04-loop-contract-both-arms

05-mutation-matrix

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

Qwen Code · sandboxed verification

@BenGuanRan
BenGuanRan self-requested a review August 7, 2026 08:47
BenGuanRan
BenGuanRan previously approved these changes Aug 7, 2026

@BenGuanRan BenGuanRan 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 the latest head. Verified all prior Critical findings against the current code: subject-aware Web Shell group approval/revocation, collision-free group allowlist storage, the effective group-policy UI gate, and GitHub aggregate-lane behavior for both approved follow-ups and unapproved ambient comments. The exact head passed the Ubuntu test job and Web Shell E2E; the skipped CLI no-sandbox integration job is noted. No blocking findings.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on 9c7144211bf17fbb05198e13779b01da3448aede — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 9c7144211bf17fbb05198e13779b01da3448aede既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

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

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

@BenGuanRan
BenGuanRan dismissed their stale review August 7, 2026 08:57

Reset premature approval; code review and review comment must come first.

@BenGuanRan
BenGuanRan self-requested a review August 7, 2026 08:57

Copy link
Copy Markdown
Collaborator

Review of head 9c71442 across three axes: status of prior change requests, impact on existing flows, and memory/disk behavior. Verified against source at the exact head.

Prior Critical findings — all verified fixed at this head

Finding Fix verified
R1-1 Web Shell pairing panel not subject-aware Panel renders request.subject, lists snapshot.groupIds, revokes via { groupId } (ChannelPairingRequests.tsx); tests cover group identify/approve/revoke by group ID
R1-10 Allowlist suffix collision (support vs support-group) Group allowlist moved to <name>-groups.json (PairingStore.ts); regression test with support/support-group stores in one workspace
R2-1 Pairing panel hidden for IM adapters lacking a groupPolicy field showPairing gate falls back to instance.config.groupPolicy (ChannelEditorDialog.tsx); test uses the real DingTalk descriptor shape with instance-config pairing
R8-1 GitHub aggregate lane dropped approved-group follow-ups Guard extended to senderPolicy === 'pairing' || groupPolicy === 'pairing'; directed lane allowed mirrors the group-approval bypass (GithubAdapter.ts); new adapter tests
R9-1 Synthesized mention turned ambient comments into pairing requests isMentioned is synthesized only for approved pairing groups; test covers unapproved repo under senderPolicy: 'open'

Also confirmed addressed: R9-15 (group-specific sender_pending wording), R1-16 (legacy migration includes the group allowlist), R1-23 (SDK/webui exports), R6-5 (one pending request per sender), R9-11 (group-allowlist read-error logging + strict mode), R9-4 (body key recorded). Remaining open threads are test-coverage and doc-wording suggestions.

Impact on existing flows

  • Every runtime behavior change is gated on the new groupPolicy: 'pairing' value; non-pairing configurations are equivalent to pre-PR (checked hunk-by-hunk across ChannelBase.ts, GroupGate, GithubAdapter, GitlabAdapter, daemon service/routes, and the settings store).
  • Shared-code changes stay backward-compatible: the SenderGate result shape change keeps DM semantics identical (the new sender_pending rejection is only reachable when one sender holds both a user and a group request, impossible pre-PR); the revoke route accepts {senderId} \| {groupId} with {senderId}-only callers unchanged; SDK fields are additive.
  • No settings migration is required; legacy pending requests normalize to user subjects on read.

Memory / disk

  • Bounded: pending requests cap at 3 with a 1-hour expiry purged on creation; allowlists are small JSON (group allowlist written via atomic temp+rename); group history is bounded by groupHistoryLimit (default 0 = disabled), 1 KB per entry, a 1000-key LRU, and periodic compaction; GitHub cursor arrays are trimmed by MAX_DISPATCHED.
  • Minor non-blocking observations: the in-memory groupPairingNotified map has no eviction (one tiny entry per distinct group since process start; code documents at most one re-post after a restart), and the group-history file is keyed globally by channel name rather than workspace-scoped (pre-existing design, still bounded).

CI at this head: Test (ubuntu-latest, Node 22.x) passed (15m57s); macOS/Windows/CLI no-sandbox integration jobs were skipped.

中文说明

在 head 9c71442 上从三个维度审查:既有 change requests 状态、对已有链路的影响、内存/磁盘行为,均按该 commit 源码逐条核对。

既有 Critical 均已修复:R1-1(Web Shell 面板已按 subject 渲染/审批/撤销群请求,含测试);R1-10(群 allowlist 改为 <name>-groups.json,含 support/support-group 回归测试);R2-1(面板显示回退到 instance 配置,测试使用真实 DingTalk descriptor 形态);R8-1(GitHub aggregate lane guard 扩展 + directed lane 镜像群批准旁路,含新测试);R9-1(仅对已批准群合成 mention,含未批准仓库 + senderPolicy: 'open' 测试)。另确认 R9-15、R1-16、R1-23、R6-5、R9-11、R9-4 已处理;其余 open thread 均为测试覆盖与文档措辞建议。

对已有链路的影响:所有运行时行为变更均由新增的 groupPolicy: 'pairing' 门控,非 pairing 配置与 PR 前等价;共享代码变更(SenderGate 结果形态、revoke 路由 union、SDK 增量字段)均向后兼容;无需 settings 迁移,旧 pending 请求读取时规范化为用户主体。

内存/磁盘:均有界——pending 上限 3 且 1 小时过期清理;allowlist 为小 JSON(群 allowlist 原子写);群历史受 groupHistoryLimit(默认关闭)、每条 1KB、1000 键 LRU 与定期 compact 约束;GitHub cursor 数组按 MAX_DISPATCHED 裁剪。两个非阻塞观察:groupPairingNotified Map 不清理(每条字节级,重启后最多多发一次);群历史文件按频道名全局存放(既有设计,仍有上限)。

该 head 的 CI:Ubuntu Node 22 测试通过(15m57s);macOS/Windows/CLI 无沙箱集成 job 跳过。

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

Approved after verifying all prior Critical findings fixed at head 9c71442 (details in the review comment above): subject-aware Web Shell panel, collision-free group allowlist storage, effective group-policy UI gate, and GitHub aggregate/directed lane behavior. Existing flows unaffected (all changes gated on the new groupPolicy: 'pairing'), memory/disk bounded. Ubuntu Node 22 test job green at this head; CLI no-sandbox integration job remains skipped.

@wenshao
wenshao added this pull request to the merge queue Aug 7, 2026
Merged via the queue into QwenLM:main with commit 3edecac Aug 7, 2026
71 of 75 checks passed
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