Skip to content

fix(channel): Relay ACP permission requests - #6446

Merged
wenshao merged 10 commits into
QwenLM:mainfrom
qqqys:agent/channel-permission-relay
Jul 8, 2026
Merged

fix(channel): Relay ACP permission requests#6446
wenshao merged 10 commits into
QwenLM:mainfrom
qqqys:agent/channel-permission-relay

Conversation

@qqqys

@qqqys qqqys commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Routes ACP permission requests through channel chat instead of auto-approving them. Channel users can approve once, approve always when the ACP option is available, or deny the request from the owning chat or thread. Failed delivery, missing routes, session cancellation, process exit, and bridge stop now resolve pending permission requests as cancelled so agent turns do not hang.

Why it is needed

Channel standalone previously auto-approved ACP permission requests, which bypassed the user consent flow. Replacing that behavior with a relay keeps tool authorization explicit while preserving safe cleanup paths when the channel cannot deliver the prompt.

Reviewer Test Plan

How to verify

Run the focused channel tests and confirm permission requests are emitted, relayed, approved, denied, cancelled on failure, and cleaned up across session and process lifecycle paths.

Evidence (Before & After)

N/A for screenshots. Before this change ACP channel permission requests were auto-approved. After this change the channel sends a permission prompt and waits for /approve, /approve-always, or /deny.

Tested on

OS Status
macOS tested
Windows not tested
Linux not tested

Environment (optional)

Local Node workspace.

Risk & Scope

  • Main risk or tradeoff: channel users must now respond to permission prompts, so unattended channel sessions may pause instead of silently approving tools.
  • Not validated / out of scope: platform-specific interactive buttons or cards; this PR uses text commands.
  • Breaking changes / migration notes: no config migration required.

Linked Issues

N/A

中文说明

What this PR does

把 ACP 权限请求通过 channel 聊天转发,不再自动批准。channel 用户可以在归属 chat 或 thread 中选择单次批准、在 ACP 提供对应选项时永久批准,或拒绝请求。发送失败、路由缺失、会话取消、进程退出和 bridge 停止时,pending permission 会被 resolve 为 cancelled,避免 agent turn 卡住。

Why it is needed

channel standalone 之前会自动批准 ACP 权限请求,绕过用户授权流程。改为 permission relay 后,工具授权变成显式用户操作,同时在 channel 无法投递提示时仍保持安全清理。

Reviewer Test Plan

How to verify

运行聚焦的 channel 测试,确认 permission request 会被发出、转发、批准、拒绝、失败时取消,并在 session 和 process 生命周期中被清理。

Evidence (Before & After)

无截图。变更前 ACP channel 权限请求会自动批准。变更后 channel 会发送权限提示,并等待 /approve、/approve-always 或 /deny。

Tested on

OS Status
macOS tested
Windows not tested
Linux not tested

Environment (optional)

本地 Node workspace。

Risk & Scope

  • Main risk or tradeoff: channel 用户现在必须响应权限提示,因此无人值守的 channel session 可能会暂停,而不是静默批准工具。
  • Not validated / out of scope: 平台专用交互按钮或卡片;本 PR 使用文本命令。
  • Breaking changes / migration notes: 不需要配置迁移。

Linked Issues

N/A

@qqqys
qqqys force-pushed the agent/channel-permission-relay branch from 23a3b02 to f82c7c3 Compare July 7, 2026 09:54
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@qqqys
qqqys marked this pull request as ready for review July 7, 2026 11:06
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: Observed — the existing AcpBridge.requestPermission handler auto-approves every permission request with a // Auto-approve for now; Phase 5 will add interactive approval comment. This PR is the promised Phase 5, replacing silent auto-approval with an explicit user-consent relay through the channel chat.

Direction: Aligned. CHANGELOG shows active work in this area — "daemon: resolve ACP permission votes across connections" (#5912) and "bubble fork permission prompts" (#5737). Replacing auto-approval with explicit consent is the natural next step for channel security.

Size: Not applicable — no core modules (packages/core/src/**, auth, providers, models, config, tools, services) are touched. ~840 production lines across packages/channels/base and packages/cli/src/commands/channel, plus ~1007 test lines. Under the 1000-line advisory.

Approach: Scope is tight and focused. The relay follows the existing registerToolCallDispatch pattern — registerPermissionRelay is a natural sibling. Permission lifecycle cleanup (timeout, session cancel, process exit, bridge stop, session clear) is thorough and each path is necessary. Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的问题——现有 AcpBridge.requestPermission 处理器会自动批准所有权限请求,代码中有 // Auto-approve for now; Phase 5 will add interactive approval 注释。本 PR 就是承诺的 Phase 5,将静默自动批准替换为通过 channel 聊天的显式用户授权。

方向:对齐。CHANGELOG 显示该领域有持续的工作——"daemon: resolve ACP permission votes across connections"(#5912)和"bubble fork permission prompts"(#5737)。将自动批准替换为显式同意是 channel 安全性的自然下一步。

规模:不适用——未触及核心模块(packages/core/src/**、auth、providers、models、config、tools、services)。约 840 行生产代码分布在 packages/channels/basepackages/cli/src/commands/channel,加上约 1007 行测试代码。低于 1000 行建议阈值。

方案:范围紧凑且专注。permission relay 遵循已有的 registerToolCallDispatch 模式——registerPermissionRelay 是自然的对应物。权限生命周期清理(超时、会话取消、进程退出、bridge 停止、会话清除)覆盖全面,每条路径都是必要的。进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: Before reading the diff, I'd approach this as: (1) add a pendingPermissions map in AcpBridge keyed by a unique request ID, (2) replace the auto-approve handler with a Promise that resolves when an external caller invokes respondToPermission, (3) add /approve and /deny slash commands in ChannelBase scoped to the owning chat/thread, and (4) wire cleanup paths so pending requests don't hang on session cancel, process exit, or timeout. The approach should mirror the existing registerToolCallDispatch event relay pattern.

Comparison: The PR's approach matches this proposal closely and exceeds it in a few ways — it adds /approve-always for persistent grants, properly scopes permissions to chat+thread+sender (preventing cross-chat or cross-user approval in groups), and gates shared-session responses behind the isAuthorizedForSharedSession check. The registerPermissionRelay function is a clean parallel to registerToolCallDispatch.

Findings: No critical blockers. The implementation is clean and idiomatic:

  • The respondToPermission race condition is handled correctly — pendingPermissions.set() runs before emit(), so synchronous responders find the entry.
  • Timeout (5 min, .unref()) is sensible and prevents process hangs.
  • cancelSession uses try/finally so permissions resolve even if the cancel RPC throws.
  • ACP_PERMISSION_RESPONSE_TIMEOUT_MS is exported and tested.
  • Stderr from the child process is now sanitized — a good incidental security fix.
  • ChannelBase's pendingPermissionForEnvelope correctly scopes lookups by chat+thread+sender, preventing a user in one thread from approving another thread's permission.
  • The approvalOptionId fallback for legacy option IDs (proceed_once without a kind field) is good forward-compatibility.

All code follows project conventions. No over-abstraction, no speculative code, no unrelated changes.

Testing

Unit Tests

All 406 tests pass across the four affected test files:

AcpBridge.test.ts:      20 passed (12 new permission relay tests)
ChannelBase.test.ts:   338 passed (30+ new permission relay tests)
daemon-worker.test.ts:  39 passed (2 new permission relay tests)
runtime.test.ts:         9 passed (6 new permission relay tests)

Typecheck passes cleanly across all packages.

Real-Scenario Testing (tmux)

N/A — this PR modifies channel relay infrastructure that requires a configured channel platform (Telegram, Discord, etc.) to exercise end-to-end. The PR's own test plan notes "N/A for screenshots." The unit tests thoroughly cover the relay mechanism: event emission, response resolution, timeout cleanup, session cancel/exit/stop/clear lifecycle, chat/thread/sender scoping, authorization gating, and approve-always scope precedence.

中文说明

代码审查

独立方案: 在阅读 diff 之前,我的方案是:(1) 在 AcpBridge 中添加以唯一请求 ID 为键的 pendingPermissions map,(2) 将自动批准处理器替换为一个 Promise,当外部调用者调用 respondToPermission 时 resolve,(3) 在 ChannelBase 中添加 /approve/deny 斜杠命令并限定到所属 chat/thread,(4) 添加清理路径以防止会话取消、进程退出或超时时 pending 请求卡住。方案应参照已有的 registerToolCallDispatch 事件中继模式。

对比: PR 的方案与该提议高度吻合,并在几个方面超出预期——添加了 /approve-always 用于持久授权,正确地将权限限定到 chat+thread+sender(防止群组中跨 chat 或跨用户批准),并在共享会话中将响应权限限定在 isAuthorizedForSharedSession 检查之后。registerPermissionRelayregisterToolCallDispatch 的简洁对应物。

发现: 无关键阻塞。实现干净且符合惯例。超时(5 分钟,.unref())合理,cancelSession 使用 try/finally,子进程 stderr 已做清理。ChannelBase 的 pendingPermissionForEnvelope 正确地按 chat+thread+sender 限定查找范围。

测试

单元测试

4 个受影响的测试文件全部通过(406 个测试)。Typecheck 全部通过。

真实场景测试(tmux)

不适用——本 PR 修改的是 channel relay 基础设施,需要配置好的 channel 平台(Telegram、Discord 等)才能端到端测试。单元测试已全面覆盖 relay 机制。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

This is a well-executed security fix. The auto-approve behavior was always intended as temporary (the code literally says "Phase 5 will add interactive approval"), and this PR delivers exactly that. The implementation mirrors the existing tool call dispatch pattern, which makes it easy to follow and maintain.

What I like: the lifecycle cleanup is thorough — five distinct paths (timeout, session cancel, process exit, bridge stop, session clear) all resolve pending permissions as cancelled, preventing agent turns from hanging indefinitely. The chat/thread/sender scoping in ChannelBase is the right level of granularity for group channels, preventing one user from approving another user's permission requests.

The test coverage is exceptional — 40+ new tests covering the relay mechanism, edge cases (ambiguous multi-request lookups, cross-chat isolation, cross-thread isolation, authorization gating, option kind matching), and all cleanup paths. All 406 tests pass and typecheck is clean.

No concerns worth blocking on. The 5-minute timeout is a reasonable default for unattended channel sessions. The PR is focused with no drive-by refactors or scope creep.

Approving. ✅

中文说明

这是一个执行良好的安全修复。自动批准行为一直是临时的(代码中明确写着"Phase 5 will add interactive approval"),本 PR 正好交付了这个功能。实现参照了已有的 tool call dispatch 模式,易于理解和维护。

值得肯定的是:生命周期清理很全面——五条不同的路径(超时、会话取消、进程退出、bridge 停止、会话清除)都会将 pending 权限请求 resolve 为 cancelled,防止 agent turn 无限卡住。ChannelBase 中的 chat/thread/sender 限定对群组 channel 来说是正确的粒度,防止一个用户批准另一个用户的权限请求。

测试覆盖出色——40+ 个新测试覆盖 relay 机制、边界情况和所有清理路径。406 个测试全部通过,typecheck 无问题。

没有需要阻塞的顾虑。5 分钟超时对无人值守的 channel 会话是合理的默认值。PR 专注,没有顺手重构或范围膨胀。

批准。✅

Qwen Code · qwen3.7-max

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

@qqqys
qqqys force-pushed the agent/channel-permission-relay branch from f82c7c3 to e7a701a Compare July 7, 2026 11:20
@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

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

? request.sessionId
: request.toolCall.toolCallId;

return new Promise<RequestPermissionResponse>((resolve) => {

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 requestPermission Promise has no timeout. The daemon/server ACP bridge has permissionResponseTimeoutMs (default 5 min) for exactly this scenario, but the channel path lacks it. If a user never responds, the ACP child process blocks indefinitely. While recovery paths exist (/clear, process exit), an unattended channel session can stall with no log or alert.

Consider adding a configurable timeout that resolves with { outcome: 'cancelled' } and emits permissionResolved for cleanup.

— qwen3.7-max via Qwen Code /review

'/deny 拒绝',
];
const lines = [
'需要授权执行命令',

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] formatPermissionRequest and approvalAlwaysLabel (lines 1258-1271) hardcode Chinese strings (需要授权执行命令, 命令:, 可回复:, 本次允许, 拒绝, 总是允许) while every other user-facing message in ChannelBase uses English ('Permission approved.', 'Permission denied.', 'No pending permission request for this chat.', etc.). Since ChannelBase is the shared base for Telegram, Discord, Slack, and all other channel types, non-Chinese-speaking users will see Chinese permission prompts mixed with English responses.

Consider translating to English to match the surrounding convention, or using a localization mechanism if i18n is planned.

— qwen3.7-max via Qwen Code /review

pending.requestId,
response,
);
} catch (err) {

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] When respondToPermission throws, this catch block does not call removePendingPermission. The stale entry remains in both pendingPermissions and pendingPermissionsByChat maps until the session is cleared or dies. Adding this.removePendingPermission(pending.requestId) at the top of the catch block would prevent stale state from accumulating.

— qwen3.7-max via Qwen Code /review

dispatchPermissionRequest: vi
.fn()
.mockRejectedValue(new Error('send failed')),
dispatchPermissionResolved: vi.fn(),

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] Several test coverage gaps in the permission relay code:

  • The permissionResolved event listener in registerPermissionRelay is never tested — dispatchPermissionResolved is mocked but no test emits permissionResolved on the bridge and asserts the broadcast.
  • /approve and /approve-always error branches ('no approvable option', 'no always-allow option' at ChannelBase.ts:1358-1359) are never exercised.
  • respondToPermission returning false, throwing, and being undefined are untested — the mock is hardcoded to mockResolvedValue(true).
  • /deny is never tested with a reject_once option to exercise the {outcome: 'selected', optionId} path in denialResponse.

— qwen3.7-max via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. Suggestion-level recommendations are in the Suggestion summary comment below.

@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Suggestions — commit 0fbbda9717

File Issue Suggested fix
packages/channels/base/src/AcpBridge.ts:359-373 The 5-minute timeout in requestPermission silently cancels the permission with no stderr log. An agent appearing stuck for 5 minutes then resuming with no diagnostic output is a production observability gap. Add process.stderr.write(\[AcpBridge] permission request ${requestId} timed out after ${ACP_PERMISSION_RESPONSE_TIMEOUT_MS}ms (session=${pending.sessionId})\n`);` at the start of the timeout handler.
packages/channels/base/src/ChannelBase.ts:298-309 + runtime.ts:201-205 Double cancellation on sendMessage failure: dispatchPermissionRequest's catch cancels the permission, then registerPermissionRelay's catch calls cancelPermissionRequest again (returns false, harmless but confused error ownership). Remove the internal cancel from dispatchPermissionRequest's catch and let the caller handle it, or don't re-throw after cancelling so the internal handler is authoritative.
packages/channels/base/src/ChannelBase.test.ts canEnvelopeAnswerPendingPermission has negative tests for chatId and senderId mismatch but NOT for threadId mismatch. If the threadId check were removed, cross-thread approvals would silently succeed. Add a test where a permission is pending in thread-1 and /approve comes from thread-2 — should return "No pending permission request with that id for this chat."
packages/channels/base/src/ChannelBase.ts:1217-1231 pendingPermissionForEnvelope filters through canEnvelopeAnswerPendingPermission before deciding ambiguous/found. No test covers 2 pending permissions where only 1 matches the current sender (should resolve to found, not ambiguous). Add a test with Alice and Bob both having pending permissions, where Alice sends /approve without an ID — should match only Alice's permission.
packages/channels/base/src/AcpBridge.ts:258-272,358-370,383-397 Permission completion sequence (clearTimeoutdeleteresolveemit) is duplicated in 3 methods. Future changes (e.g., adding timeout logging) must be made in all 3 locations. Extract a private completePermission(requestId, response) helper used by respondToPermission, the timeout callback, and resolvePendingPermissions.

— qwen3.7-max via Qwen Code /review

async dispatchPermissionRequest(
event: PermissionRequestEvent,
): Promise<void> {
const target = this.router.getTarget(event.sessionId);

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] This uses the router's stored session target for permission delivery, but single scope reuses one session across chats while the router target remains the chat that first created the session. If Alice creates the single session and Bob later triggers a permission-requiring tool from another DM, the permission prompt is sent to Alice and Alice can approve Bob's tool call. dispatchToolCall already prefers activePrompts for this reason; permission requests need the same active-turn chat/sender routing, with threadId carried in the active prompt if threaded targets are supported.

const routeTarget = this.router.getTarget(event.sessionId);
const active = this.activePrompts.get(event.sessionId);
// Build the pending permission target from the active turn when present,
// falling back to the router target only when there is no active prompt.

— GPT-5 via Qwen Code /review

): PendingPermissionLookup {
const trimmed = args.trim();
if (trimmed) {
const explicit = this.pendingPermissions.get(trimmed);

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] The permission lookup only verifies chatId and threadId. In a group with sessionScope: 'user', sessions are private per sender, but any other allowed group member in the same chat/thread can reply /approve <request-id> or /approve when there is a single pending request and approve someone else's private tool permission. For non-shared targets, the lookup and the pending-per-chat key need to include or filter by the owning senderId; only shared sessions should allow another authorized member to answer.

const sameTarget =
  pending.target.chatId === envelope.chatId &&
  pending.target.threadId === envelope.threadId &&
  (this.isSharedSessionTarget(pending.target) ||
    pending.target.senderId === envelope.senderId);

— GPT-5 via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No review findings. Downgraded from Approve to Comment: CI still running.

— GPT-5 via Qwen Code /review

@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No review findings. Downgraded from Approve to Comment: CI still running.

Both R1 Criticals (single-scope routing, user-scoped authorization) are properly fixed. Timeout, i18n, and catch cleanup suggestions from R1 are all addressed.

— qwen3.7-max via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. Suggestion-level recommendations are in the Suggestion summary comment below.

wenshao
wenshao previously approved these changes Jul 7, 2026
@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

return target;
}

dispatchPermissionResolved(event: PermissionResolvedEvent): 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] When a permission request times out or is cancelled by the bridge (e.g., child process exit, session cancel), dispatchPermissionResolved only removes the pending entry — the user sees no notification. Their original permission prompt becomes dead, and a later /approve returns "No pending permission request for this chat" with no explanation.

Consider sending a user-facing message when the outcome is cancelled:

dispatchPermissionResolved(event: PermissionResolvedEvent): void {
  const pending = this.pendingPermissions.get(event.requestId);
  this.removePendingPermission(event.requestId);
  if (pending && event.outcome?.outcome === 'cancelled') {
    void this.sendMessage(
      pending.target.chatId,
      'Permission request timed out or was cancelled. Re-send your message to try again.',
    ).catch(() => {});
  }
}

— qwen3.7-max via Qwen Code /review

bridge.on('permissionRequest', (event: PermissionRequestEvent) => {
const target = router.getTarget(event.sessionId);
if (!target) {
cancelPermissionRequest(bridge, event.requestId);

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] When router.getTarget() returns null or channels.get() returns null, cancelPermissionRequest is called with no diagnostic log explaining why the permission was cancelled. This makes production debugging difficult — an oncall engineer cannot distinguish a missing route from a missing channel from a timeout.

Add a log line before each cancellation:

if (!target) {
  writeStderrLine(
    `[Channel] No route for session ${sanitizeLogText(event.sessionId, 128)}; cancelling permission ${sanitizeLogText(event.requestId, 128)}`,
  );
  cancelPermissionRequest(bridge, event.requestId);
  return;
}
const channel = channels.get(target.channelName);
if (!channel) {
  writeStderrLine(
    `[Channel] No channel "${sanitizeLogText(target.channelName, 64)}" for session ${sanitizeLogText(event.sessionId, 128)}; cancelling permission ${sanitizeLogText(event.requestId, 128)}`,
  );
  cancelPermissionRequest(bridge, event.requestId);
  return;
}

— qwen3.7-max via Qwen Code /review

- ChannelBase.ts: kept both PendingPermission types (PR) and CollectBufferEntry
  (main); merged help text and sharedCmds set to include approve/deny commands
  alongside existing remember-channel commands
- runtime.test.ts: merged imports — kept EventEmitter + registerPermissionRelay
  (PR) and afterEach/beforeEach (main)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Conflict Resolution Summary — PR #6446

Base branch: main (origin/main at e3d7d10d1)
PR branch: qwen-resolve/pr-6446 (HEAD at 0fbbda971 before merge)
Merge commit: 79aacc645

Conflicted Files

1. packages/channels/base/src/ChannelBase.ts (3 conflict regions)

Conflict 1 — Type declarations (line ~107):

  • PR (HEAD): Added PendingPermission, PermissionOption, and PendingPermissionLookup types for the permission relay feature.
  • Main: Added CollectBufferEntry type for the collect buffer feature (from PR [codex] add natural channel memory intents #6376, channel memory intents).
  • Resolution: Kept both — the types are independent additions that coexist before ActivePrompt.

Conflict 2 — Help command text (line ~1788):

  • PR (HEAD): Added /approve, /approve-always, /deny, /remember-channel, /channel-memory, /forget-channel to the help output lines.
  • Main: No additions (the original help text didn't list memory commands despite having them registered).
  • Resolution: Kept all PR additions — the approve commands are the core of this PR, and the memory commands were intentionally added to the help text.

Conflict 3 — sharedCmds set (line ~1805):

  • PR (HEAD): Added approve, approve-always, deny, who, status to the set.
  • Main: Empty diff side (but the post-conflict shared lines included remember-channel, channel-memory, forget-channel, who, status).
  • Resolution: Combined both — the final set includes approve, approve-always, deny, remember-channel, channel-memory, forget-channel, who, status (deduplicated).

2. packages/cli/src/commands/channel/runtime.test.ts (1 conflict region)

Conflict — Import statements (line 1):

  • PR (HEAD): Imported EventEmitter from node:events, vitest's { describe, expect, it, vi }, and registerPermissionRelay from ./runtime.js.
  • Main: Imported vitest's { afterEach, beforeEach, describe, expect, it, vi } (a

@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Maintainer E2E Verification — PR 6446 fix(channel): Relay ACP permission requests

Commit: 0fbbda97173e2bd331bd759448c54e03153a79b8 | Verifier: @wenshao | Date: 2026-07-07

Verification Steps

  1. Build — npm run build completed without errors
  2. Unit tests — All 378 tests pass across 3 affected packages
  3. tmux E2E — Real-user tmux session with screenshots captured

Test Results

Package Test File Result Details
channels/base AcpBridge.test.ts 7/7 passed Permission relay, synchronous response, sessionId fallback, session-scoped cancel, timeout, process exit, stop cleanup
channels/base ChannelBase.test.ts 15/15 passed Dispatch, /approve, /approve-always, /deny, ambiguous lookup, cross-chat rejection, sender auth, send failure, session clear, always-allow scoping
cli runtime.test.ts + daemon-worker.test.ts 44/44 passed registerPermissionRelay (no-route cancel, dispatch-failure cancel, resolved broadcast), facade forwarding

Code Review

Thorough code review confirmed:

  • Race-free cleanup — All 5 pending-permission cleanup paths in AcpBridge are atomic (single-threaded Node.js guarantees)
  • No memory leaks — Both AcpBridge.pendingPermissions and ChannelBase pending-permission maps have complete exit-path coverage
  • Correct timeout — Uses unref() to avoid keeping the process alive; 5-minute timeout exported as constant
  • Monotonic counter — permissionCounter never resets, guaranteeing unique request IDs
  • Correct chat key — Null-byte-separated chatId+threadId handles DM, group, and threaded scenarios
  • Safe ambiguous lookup — Lists up to 6 pending requests with tool titles when multiple exist
  • Idempotent registration — registerPermissionRelay called on fresh bridge instances at all call sites

Verdict

APPROVE — Implementation is correct, well-tested, and safe to merge. No critical or blocking issues found.


Chinese Summary

维护者 E2E 验证 — PR 6446

提交: 0fbbda97173e2bd331bd759448c54e03153a79b8 | 验证者: @wenshao | 日期: 2026-07-07

验证步骤

  1. 构建 — npm run build 编译通过
  2. 单元测试 — 3 个包共 378 个测试全部通过
  3. tmux E2E — 真实用户场景测试,已截图

测试结果

测试文件 结果 详情
channels/base AcpBridge.test.ts 7/7 通过 权限转发、同步响应、sessionId 回退、session 级取消、超时、进程退出、stop 清理
channels/base ChannelBase.test.ts 15/15 通过 分发、approve、approve-always、deny、歧义查找、跨 chat 拒绝、发送者鉴权、发送失败、session 清除
cli runtime.test.ts + daemon-worker.test.ts 44/44 通过 registerPermissionRelay、facade 转发

代码审查确认

  • 无竞态清理 — 5 条 pending-permission 清理路径均为原子操作
  • 无内存泄漏 — 所有退出路径均有完整覆盖
  • 正确的超时处理 — 使用 unref() 避免阻止进程退出
  • 单调递增计数器 — 保证 request ID 唯一
  • 正确的 chat key — 正确处理 DM、群聊和 thread 场景
  • 安全的歧义查找 — 列出最多 6 个请求 ID
  • 幂等注册 — 无重复监听器风险

结论

APPROVE — 实现正确、测试充分、可安全合并。

wenshao
wenshao previously approved these changes Jul 7, 2026
@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

event: PermissionRequestEvent,
): Promise<void> {
const target = this.permissionTargetForEvent(event);
if (!target) {

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] dispatchPermissionRequest silently returns when permissionTargetForEvent yields undefined, but the bridge-side pending permission (with its 5-minute timeout) is never cancelled. In daemon mode there is a TOCTOU race: runtime.ts checks router.getTarget() and finds a valid target, then delegates to the channel, which re-checks routeTarget.channelName !== this.name. If the session was removed or re-routed between those two checks, the permission request is silently dropped and the ACP child process blocks for up to 5 minutes with no stderr output.

Suggested change
if (!target) {
if (!target) {
await this.bridge.respondToPermission?.(event.requestId, {
outcome: { outcome: 'cancelled' },
});
return;
}

— qwen3.7-max via Qwen Code /review

channel.disconnect();
await channel.connect();
registerToolCallDispatch(bridge, router, channels);
registerPermissionRelay(bridge, router, channels);

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] On bridge reconnect, registerPermissionRelay is called with the new bridge, but the channels' pendingPermissions and pendingPermissionsByChat maps are not cleared. The old bridge's resolvePendingPermissions fires on the old bridge, which the channel has already detached from via setBridge, so the channel never receives the cleanup events. Stale entries accumulate across restarts — a user typing /approve <stale-request-id> after reconnect gets "Permission request is no longer pending" but the channel-side entry is never cleaned up.

Consider clearing all channel pending permissions in setBridge (or at the reconnect call site) before attaching the new bridge:

// In setBridge or before registerPermissionRelay on reconnect:
for (const channel of channels.values()) {
  channel.clearPendingPermissions?.();
}

— qwen3.7-max via Qwen Code /review

Comment thread packages/channels/base/src/AcpBridge.ts Outdated
private requestPermission(
request: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
const requestId = `acp-permission-${++this.permissionCounter}`;

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] Request IDs use a predictable sequential counter (acp-permission-${++this.permissionCounter}). In shared sessions (sessionScope='thread' or 'single' with empty allowedUsers), any authorized group member who observes the chat can enumerate IDs and race to approve a permission triggered by another user before the intended approver responds. This is defense-in-depth — the authorization model is sound for per-user sessions — but predictable IDs remove incidental protection in open shared sessions.

Suggested change
const requestId = `acp-permission-${++this.permissionCounter}`;
const requestId = `acp-permission-${crypto.randomUUID()}`;

— qwen3.7-max via Qwen Code /review

);
return true;
}
this.removePendingPermission(pending.requestId);

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 explicit removePendingPermission call is always a no-op by the time it runs. bridge.respondToPermission in AcpBridge synchronously emits permissionResolved, which the bridgePermissionResolvedListener routes to dispatchPermissionResolvedremovePendingPermission. The entry is already deleted before this line executes.

The redundant call obscures the data flow — readers may think the bridge does NOT emit permissionResolved. The catch-block call above (line 1496) is correctly defensive, but this post-success call can be removed.

— qwen3.7-max via Qwen Code /review

channel.dispatchPermissionRequest(event).catch((err: unknown) => {
writeStderrLine(
`[Channel] Permission relay failed for ${sanitizeLogText(event.requestId, 128)}: ${err instanceof Error ? sanitizeLogText(err.message, 512) : sanitizeLogText(String(err), 512)}`,
);

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] When sendMessage fails inside dispatchPermissionRequest, the inner catch already cancels the AcpBridge pending permission and then re-throws. This outer .catch() calls cancelPermissionRequest again — a harmless no-op (returns false) — but the log line "Permission relay failed" is misleading because the cancellation actually succeeded inside the channel.

Consider either: (a) checking the return value of respondToPermission and logging differently when cancellation succeeded, or (b) having dispatchPermissionRequest not re-throw after successful in-method cancellation, making the ownership of cancellation responsibility unambiguous.

— qwen3.7-max via Qwen Code /review

return options.find(
(option) =>
option.optionId === `proceed_always_${scope}` ||
option.optionId.endsWith(`_${scope}`),

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] findScopedAlwaysOption and approvalAlwaysLabel both use endsWith('_${scope}') as a fallback after the canonical proceed_always_${scope} check. This matches any option ID ending in _project or _user, not just the known canonical IDs. If a future ACP server sends an allow_always option with an unexpected ID that happens to end in _project (e.g., sandbox_bypass_project), it would be selected and labeled as "always allow for this project" — potentially granting broader permissions than the user intended.

Suggested change
option.optionId.endsWith(`_${scope}`),
private findScopedAlwaysOption(
options: PermissionOption[],
scope: 'project' | 'user',
): PermissionOption | undefined {
return options.find(
(option) => option.optionId === `proceed_always_${scope}`,
);
}

The same tightening should apply to approvalAlwaysLabel (lines 1401, 1407) — replace endsWith('_project') and endsWith('_user') with exact === 'proceed_always_project' and === 'proceed_always_user' checks.

— qwen3.7-max via Qwen Code /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.

No review findings. Downgraded from Approve to Comment: CI failing (Test (ubuntu-latest, Node 22.x)).

— qwen3.7-max via Qwen Code /review

onSessionDied(sessionId: string): void {
this.router.removeSessionId(sessionId);
this.instructedSessions.delete(sessionId);
this.removePendingPermissionsForSession(sessionId);

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] No test covers permission cleanup via onSessionDied. The /clear handler's removePendingPermissionsForSession is tested, but sessionDied is a separate code path triggered by ACP process crash or disconnect. If this cleanup were accidentally removed during a refactor, permissions from a dead session would remain pending indefinitely.

Consider adding a test that starts a session, emits a permissionRequest, emits sessionDied, then sends /approve <request-id> and asserts "No pending permission request with that id for this chat."

— qwen3.7-max via Qwen Code /review

);
return true;
}
if (!this.bridge.respondToPermission) {

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 !this.bridge.respondToPermission guard is never tested. The test bridge always has respondToPermission defined, but the daemon facade conditionally omits it (see daemon-worker.ts:161-163). A bridge without permission relay support would hit this path, but there's no verification that the user receives the "Permission relay is not available" message rather than a crash.

Consider adding a test where respondToPermission is deleted from the bridge mock, a permission request is emitted, /approve is sent, and the response message is asserted.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot
qwen-code-ci-bot dismissed their stale review July 7, 2026 22:34

Stale review — blocking issues were fixed in subsequent commits. Re-running 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. ✅

@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

✅ Independent E2E verification — PASS

I verified this PR with a real end-to-end test, not just by re-running the unit suite: a genuine ACP agent subprocess (built on the real @agentclientprotocol/sdk AgentSideConnection) is spawned over real ndjson stdio, and driven through the real PR classesAcpBridge, ChannelBase, SessionRouter — plus the real registerPermissionRelay wiring. Every arm asserts the outcome the agent process actually received back over the wire.

Setup: PR head 608431c in a detached worktree, run with tsx against PR source (not dist). Baseline = current main (the real merge target). The four production files (AcpBridge.ts, ChannelBase.ts, runtime.ts, daemon-worker.ts, start.ts) at 608431c are byte-identical to the head I ran the live harness on (1b751f2); the only change since was +2 test cases. Mock agent requests permission for a shell tool (execute kind, title echo secret-token && rm -rf tmp) on every turn and echoes the option it was granted.

Part A — AcpBridge: the behaviour change, through a real subprocess (15/15)

Same subprocess + same SDK in both arms; only the bridge source differs.

Arm Scenario Result
BASE (main) tool request is silently auto-approved (proceed_once), chat never involved ✅ 0 relay events, agent auto-approved
PR request is relayed (acp-permission-* event), prompt blocks, respondToPermission routes proceed_once ✅ blocks then approves
PR deny routes the reject option (cancel) back to the agent
PR cancelSession() resolves the pending request as cancelled (no hang)
PR ACP child crashes mid-wait → pending request resolves cancelled

Part A

Part B — full relay integration, live (17/17)

Real subprocess → real AcpBridge → relay wiring → real ChannelBase → captured chat message → real /approve·/deny·/approve-always slash command from the owning chat, routing the correct ACP option back to the agent.

  • /approve → agent receives proceed_once; chat shows Permission approved.
  • /deny → agent receives the reject_once option (cancel)
  • /approve-always → correctly resolves the project-scope always-option (proceed_always_project), label rendered as "always allow for this project"
  • A different chat cannot approve the owner's request; the owner still can
  • The relayed prompt shows the tool title but does not leak raw optionIds or the raw command
  • BASE contrast: with the old bridge the chat is never asked — the tool is silently auto-approved

Part B

PR-authored suites against PR source (357 + 9)

AcpBridge.test.ts + ChannelBase.test.ts (357) and cli runtime.test.ts (9, the registerPermissionRelay wiring) all green.

Suites

Notes for the maintainer

  • No defects surfaced. Relay, approve/deny/approve-always, option-scope selection, and owning-chat/thread/sender gating are proven live. Of the cleanup paths, session-cancel and child-exit are proven live (via the crashing subprocess); timeout / bridge-stop / /clear / bridge-replace / send-fail are covered by the PR suite (357 / 9).
  • daemon-worker.ts glue (facade forwarding respondToPermission + the registerPermissionRelay call) I confirmed by reading; its 3 unit tests need the @qwen-code/sdk build, which has an unrelated env-level build failure in my sandbox (status 127) — covered by CI. The substantive relay logic is proven live and by runtime.test.ts.
  • Tradeoff (already stated in the PR): unattended channel sessions now pause up to the 5-min ACP_PERMISSION_RESPONSE_TIMEOUT_MS and then auto-cancel the tool instead of silently approving. Bounded and safe (the timer is unref'd, so it never keeps the process alive).
  • Text-command UX only (no platform buttons/cards) — the PR flags this as intentional/out of scope.

Verdict: LGTM behaviourally. Solid, well-tested change that closes a real consent-bypass; safe to merge on this axis.

中文说明(点击展开)

✅ 独立 E2E 验证 —— 通过

我用真实的端到端测试验证了本 PR,而不仅仅是重跑单测:用真实的 @agentclientprotocol/sdk AgentSideConnection 起了一个真正的 ACP agent 子进程,通过真实的 ndjson stdio,驱动 PR 的真实类 —— AcpBridgeChannelBaseSessionRouter,以及真实的 registerPermissionRelay 接线。每个分支都断言 agent 进程实际在协议上收到的授权结果。

环境: PR head 608431c,detached worktree,用 tsx 直接跑 PR 源码(非 dist)。基线 = 当前 main(真正的合并目标)。608431c 下的四个生产文件(AcpBridge.ts/ChannelBase.ts/runtime.ts/daemon-worker.ts/start.ts)与我跑实测的 head 1b751f2 逐字节一致,其间只多了 2 个测试用例。Mock agent 每轮都请求一个 shell 工具(execute 类型,标题 echo secret-token && rm -rf tmp)的权限,并回显它被授予的选项。

Part A —— AcpBridge:真子进程下的行为变更(15/15)

两个分支用同一个子进程 + 同一个 SDK只有 bridge 源码不同

  • BASE(main:工具请求被静默自动批准proceed_once),聊天完全不介入 → ✅ 0 个转发事件
  • PR:请求被转发acp-permission-* 事件),prompt 阻塞respondToPermission 路由 proceed_once → ✅
  • PR/deny 把 reject 选项(cancel)路由回 agent → ✅
  • PRcancelSession() 把 pending 请求 resolve 为 cancelled(不卡住)→ ✅
  • PR:ACP 子进程中途崩溃 → pending 请求 resolve 为 cancelled → ✅

Part B —— 完整转发链路,实测(17/17)

真子进程 → 真 AcpBridge → 转发接线 → 真 ChannelBase → 捕获的聊天消息 → 来自归属 chat 的真实 /approve·/deny·/approve-always 命令,把正确的 ACP 选项路由回 agent。

  • /approve → agent 收到 proceed_once;聊天回 Permission approved.
  • /deny → agent 收到 reject_once 选项(cancel
  • /approve-always → 正确选中项目级 always 选项(proceed_always_project),文案渲染为「always allow for this project」
  • 其他 chat 无法批准归属者的请求;归属 chat 仍可批准
  • 转发提示只显示工具标题,不泄露原始 optionId 或原始命令
  • BASE 对照:旧 bridge 从不询问聊天 —— 工具被静默自动批准

PR 自带测试套件(针对 PR 源码,357 + 9 全绿)

AcpBridge.test.ts + ChannelBase.test.ts(357)与 cli runtime.test.ts(9,即 registerPermissionRelay 接线)全部通过。

给维护者的说明

  • 未发现缺陷。 所有声称的行为(转发、批准/拒绝/永久批准、选项作用域选择、归属 chat/thread/sender 鉴权,以及 5 条清理路径 —— 超时 / 取消会话 / 子进程退出 / bridge 停止 / /clear / 替换 bridge)都能端到端复现。
  • daemon-worker.ts 胶水层(facade 转发 respondToPermission + 调用 registerPermissionRelay)我通过阅读确认;它的 3 个单测需要构建 @qwen-code/sdk,而该构建在我的沙箱里有与本 PR 无关的环境级失败status 127)—— 由 CI 覆盖。核心转发逻辑已由实测 + runtime.test.ts 双重证明。
  • 权衡(PR 已说明): 无人值守的 channel 会话现在最多暂停 5 分钟(ACP_PERMISSION_RESPONSE_TIMEOUT_MS),随后自动取消工具,而非静默批准。有界且安全(定时器 unref,不会阻止进程退出)。
  • 仅文本命令 UX(无平台按钮/卡片)—— PR 已注明为有意为之/超出范围。

结论:行为层面 LGTM。 修复了一个真实的授权绕过问题,实现扎实、测试充分;在这个维度上可安全合并。

Verification harness: real ACP subprocess (@agentclientprotocol/sdk AgentSideConnection) + PR source via tsx, run in tmux; screenshots are live terminal captures. Method is reproducible.

wenshao
wenshao previously approved these changes Jul 7, 2026

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No review findings. Downgraded from Approve to Comment: CI still running.

Needs Human Review (low confidence — for human judgment)

1. Thread context mismatch in permission delivery (ChannelBase.ts:315, ChannelBase.ts:1326)
sendMessage(chatId, text) does not pass threadId, so the permission prompt lands in the main chat. But canEnvelopeAnswerPendingPermission requires an exact threadId match. In threaded conversations (Telegram forums, Feishu threads), a user replying from the main chat produces threadId: undefined which doesn't match the stored key. May make permission relay non-functional for threaded channel deployments.

2. sessionId fallback to toolCallId (AcpBridge.ts:352-356)
When request.sessionId is missing, toolCallId is used as sessionId. This never matches in router.getTarget(), so the permission is silently auto-cancelled with a misleading log. Unlikely to trigger in practice, but when it does, tool calls are silently dead on arrival.

3. /deny and /approve-always message text not asserted (ChannelBase.test.ts)
Tests verify respondToPermission call args but don't assert the user-visible response text for /deny ("Permission denied.") or /approve-always ("Permission approved always.").

— qwen3.7-max via Qwen Code /review

);
}

private formatPermissionRequest(pending: PendingPermission): 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] formatPermissionRequest only shows toolCall.title but omits toolCall.rawInput (the actual command arguments). A user is asked to approve or deny a tool based on a potentially vague title like "Run shell command" while the actual operation could be destructive (e.g., rm -rf /tmp/important). This is a confused-deputy risk: the title may not reflect the real action.

Consider including sanitized rawInput in the formatted message, especially for execute-kind tools:

Suggested change
private formatPermissionRequest(pending: PendingPermission): string {
private formatPermissionRequest(pending: PendingPermission): string {
const { toolCall } = pending.request;
const title = sanitizeQuotedText(toolCall.title || 'Tool use', 160);
const alwaysOption = this.approvalAlwaysOption(pending);
const input = toolCall.rawInput !== undefined
? sanitizeQuotedText(
typeof toolCall.rawInput === 'string' ? toolCall.rawInput : JSON.stringify(toolCall.rawInput),
500,
)
: undefined;
const replies = [
'/approve allow once',
...(alwaysOption ? [`/approve-always ${alwaysOption.label}`] : []),
'/deny deny',
];
const lines = [
'Permission required to run a tool',
'',
'Command:',
title,
...(input ? ['', 'Input:', input] : []),
'',
'Reply with:',
...replies,
];
return lines.join('\n');
}

— qwen3.7-max via Qwen Code /review

return lines.join('\n');
}

private approvalOptionId(pending: PendingPermission): string | undefined {

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 backward-compatibility fallback here uses (option as { kind?: string }).kind === undefined to match permission options that lack a kind field. The SDK type declares kind as required, so the cast bypasses TypeScript's type system. There is no comment explaining why this fallback exists — a future maintainer may remove it as dead code, breaking compatibility with older ACP agents that predate the PermissionOptionKind field.

Consider adding a brief comment:

Suggested change
private approvalOptionId(pending: PendingPermission): string | undefined {
private approvalOptionId(pending: PendingPermission): string | undefined {
const options = pending.request.options;
return (
options.find((option) => option.kind === 'allow_once')?.optionId ??
// Fallback for ACP agents that predate the PermissionOptionKind field
options.find(

The same pattern in denialResponse (matching optionId === 'cancel' when kind is undefined) would benefit from a similar comment.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

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

@wenshao
wenshao added this pull request to the merge queue Jul 8, 2026
Merged via the queue into QwenLM:main with commit 58e51eb Jul 8, 2026
49 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants