fix(channel): Relay ACP permission requests - #6446
Conversation
23a3b02 to
f82c7c3
Compare
|
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)为单个提交。 |
|
Thanks for the PR! Template looks good ✓ Problem: Observed — the existing 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 ( Approach: Scope is tight and focused. The relay follows the existing 中文说明感谢贡献! 模板完整 ✓ 问题:已观测到的问题——现有 方向:对齐。CHANGELOG 显示该领域有持续的工作——"daemon: resolve ACP permission votes across connections"(#5912)和"bubble fork permission prompts"(#5737)。将自动批准替换为显式同意是 channel 安全性的自然下一步。 规模:不适用——未触及核心模块( 方案:范围紧凑且专注。permission relay 遵循已有的 — Qwen Code · qwen3.7-max |
Code ReviewIndependent proposal: Before reading the diff, I'd approach this as: (1) add a Comparison: The PR's approach matches this proposal closely and exceeds it in a few ways — it adds Findings: No critical blockers. The implementation is clean and idiomatic:
All code follows project conventions. No over-abstraction, no speculative code, no unrelated changes. TestingUnit TestsAll 406 tests pass across the four affected test files: 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 为键的 对比: PR 的方案与该提议高度吻合,并在几个方面超出预期——添加了 发现: 无关键阻塞。实现干净且符合惯例。超时(5 分钟, 测试单元测试4 个受影响的测试文件全部通过(406 个测试)。Typecheck 全部通过。 真实场景测试(tmux)不适用——本 PR 修改的是 channel relay 基础设施,需要配置好的 channel 平台(Telegram、Discord 等)才能端到端测试。单元测试已全面覆盖 relay 机制。 — Qwen Code · qwen3.7-max |
|
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
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
f82c7c3 to
e7a701a
Compare
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
| ? request.sessionId | ||
| : request.toolCall.toolCallId; | ||
|
|
||
| return new Promise<RequestPermissionResponse>((resolve) => { |
There was a problem hiding this comment.
[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 = [ | ||
| '需要授权执行命令', |
There was a problem hiding this comment.
[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) { |
There was a problem hiding this comment.
[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(), |
There was a problem hiding this comment.
[Suggestion] Several test coverage gaps in the permission relay code:
- The
permissionResolvedevent listener inregisterPermissionRelayis never tested —dispatchPermissionResolvedis mocked but no test emitspermissionResolvedon the bridge and asserts the broadcast. /approveand/approve-alwayserror branches ('no approvable option','no always-allow option'at ChannelBase.ts:1358-1359) are never exercised.respondToPermissionreturningfalse, throwing, and beingundefinedare untested — the mock is hardcoded tomockResolvedValue(true)./denyis never tested with areject_onceoption to exercise the{outcome: 'selected', optionId}path indenialResponse.
— qwen3.7-max via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestion-level recommendations are in the Suggestion summary comment below.
Suggestions — commit
|
| 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 (clearTimeout → delete → resolve → emit) 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); |
There was a problem hiding this comment.
[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); |
There was a problem hiding this comment.
[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
left a comment
There was a problem hiding this comment.
No review findings. Downgraded from Approve to Comment: CI still running.
— GPT-5 via Qwen Code /review
|
@qwen-code /triage |
wenshao
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestion-level recommendations are in the Suggestion summary comment below.
|
@qwen-code /resolve |
| return target; | ||
| } | ||
|
|
||
| dispatchPermissionResolved(event: PermissionResolvedEvent): void { |
There was a problem hiding this comment.
[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); |
There was a problem hiding this comment.
[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 resolved the merge conflicts and pushed the branch update. Conflict Resolution Summary — PR #6446Base branch: Conflicted Files1.
|
Maintainer E2E Verification — PR 6446
|
| 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
验证步骤
- 构建 —
npm run build编译通过 - 单元测试 — 3 个包共 378 个测试全部通过
- 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 — 实现正确、测试充分、可安全合并。
|
@qwen-code /triage |
| event: PermissionRequestEvent, | ||
| ): Promise<void> { | ||
| const target = this.permissionTargetForEvent(event); | ||
| if (!target) { |
There was a problem hiding this comment.
[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.
| 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); |
There was a problem hiding this comment.
[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
| private requestPermission( | ||
| request: RequestPermissionRequest, | ||
| ): Promise<RequestPermissionResponse> { | ||
| const requestId = `acp-permission-${++this.permissionCounter}`; |
There was a problem hiding this comment.
[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.
| 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); |
There was a problem hiding this comment.
[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 dispatchPermissionResolved → removePendingPermission. 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)}`, | ||
| ); |
There was a problem hiding this comment.
[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}`), |
There was a problem hiding this comment.
[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.
| 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
left a comment
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
[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) { |
There was a problem hiding this comment.
[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
|
@qwen-code /triage |
Stale review — blocking issues were fixed in subsequent commits. Re-running triage.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
✅ Independent E2E verification — PASSI 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 Setup: PR head Part A —
|
| 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 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 receivesproceed_once; chat showsPermission approved./deny→ agent receives thereject_onceoption (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
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.
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.tsglue (facade forwardingrespondToPermission+ theregisterPermissionRelaycall) I confirmed by reading; its 3 unit tests need the@qwen-code/sdkbuild, which has an unrelated env-level build failure in my sandbox (status 127) — covered by CI. The substantive relay logic is proven live and byruntime.test.ts.- Tradeoff (already stated in the PR): unattended channel sessions now pause up to the 5-min
ACP_PERMISSION_RESPONSE_TIMEOUT_MSand then auto-cancel the tool instead of silently approving. Bounded and safe (the timer isunref'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 的真实类 —— AcpBridge、ChannelBase、SessionRouter,以及真实的 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 → ✅ - PR:
cancelSession()把 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.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
[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:
| 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 { |
There was a problem hiding this comment.
[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:
| 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
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅



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
Environment (optional)
Local Node workspace.
Risk & Scope
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
Environment (optional)
本地 Node workspace。
Risk & Scope
Linked Issues
N/A