* feat(channels): add dmPolicy config to disable private/DM messages
Add DmGate class mirroring GroupGate to gate DM/private messages in
channel adapters. Operators can now set dmPolicy: 'disabled' in their
channel config to silently drop all DM messages while keeping group
messages active.
Closes#6392
* fix(channels): address review feedback for dmPolicy
- Add dmPolicy: 'open' to all test config factories (8 files) to
maintain type correctness with required ChannelConfig field
- Add integration tests in ChannelBase.test.ts:
- preflightInbound: DM dropped + group passes when dmPolicy=disabled
- isStoredLoopTargetAuthorized: DM loop job disabled + group passes
- Add dmPolicy assertions in config-utils.test.ts (default + explicit)
- Keep dmPolicy as required field (not optional) for strict parity
with groupPolicy
* feat(channels): add natural channel memory intents
* fix(channels): add explicit guard and exhaustiveness check for clear_confirm intent
The clear_confirm path was handled as implicit fall-through at the bottom
of handleChannelMemoryIntent. If a new intent kind were added to the
ChannelMemoryIntent union, it would silently execute clearChannelMemory
without user confirmation — a data-loss risk.
Add explicit if (intent.kind === 'clear_confirm') guard and a
const _exhaustive: never assertion so TypeScript flags any unhandled
kinds at compile time.
* fix(channels): close session leak in classifier and fix regex separator
- BridgeChannelMemoryIntentClassifier now wraps prompt() in try/finally
to always call cancelSession(), preventing daemon session leaks on
every classifier invocation. Cleanup errors are caught so they cannot
mask a successful classification result.
- Add missing optional punctuation separator to the 以后记住 regex
pattern for consistency with other Chinese remember patterns.
* fix(channels): enforce pending clear state for channel memory confirmation
The clear_confirm intent executed clearChannelMemory directly without
verifying a prior clear_request was issued for the same chat. Any
authorized user could clear any chat's memory by sending the confirmation
phrase standalone, bypassing the two-step flow.
Add a per-target pending clear map (chatId + threadId, 60s TTL) that
is set during clear_request and verified+consumed during clear_confirm.
Standalone confirmation phrases now get rejected with a prompt to
issue the clear request first.
* fix(channels): include senderId in pendingClears key to prevent cross-user confirmation
User A could initiate clear_request in a group chat and User B could
confirm it, since the pending key only included chatId+threadId.
Add senderId to the key so only the user who initiated the clear
can confirm it.
* fix(channels): harden memory intent review fixes
* fix(channels): cover memory clear sender guard
* fix(channels): block group memory mutations
* fix(channels): avoid ambiguous memory saves
* test(channels): cover memory classifier cleanup
* test(channels): cover memory clear expiry
* fix(channels): restore channel memory slash aliases
* test(channels): cover memory intent edge cases
* fix(qqbot): validate gateway URL protocol to prevent SSRF
- Add validateGatewayUrl(): enforce wss:// protocol, warn on unexpected hostnames
- Integrate into fetchGatewayUrl() return path
- Truncate error body in fetchAccessToken() to 80 chars
- Add 6 tests covering protocol rejection, wss acceptance, and edge cases
* fix(qqbot): atomic state persistence and error log sanitization
State persistence hardening:
- Atomic saveQQState() via tmp+renameSync with disposed guard and unref()
- Atomic flushQQState() with {mode: 0o600} permissions
- Entry type validation in restoreQQState() for chatTypeMap and msgSeqMap
Error log sanitization:
- Wrap all user-controlled data in process.stderr.write() with sanitizeLogText()
- Covering: connect retry, sendMessage errors, state persistence failures,
token refresh, malformed gateway, WebSocket errors, reconnect, and
C2C/group handler error paths
* fix(qqbot): add replyMsgId validation in restoreQQState()
Add type/length validation for replyMsgId entries when restoring from
persisted state, consistent with the existing chatTypeMap and msgSeqMap
input validation filters. Entries must be strings ≤ 128 chars.
* fix(qqbot): add test coverage for restoreQQState filters, atomic writes, and gateway URL validation
* docs(qqbot): update restoreQQState doc and add disposed guard comment
- Update restoreQQState JSDoc: document validation instead of "trusts persisted JSON"
- Add inline comment explaining why saveQQState has disposed guard but flushQQState doesn't
* docs(qqbot): update validateGatewayUrl docstring to reflect TLS enforcement, not SSRF
* fix(qqbot): address wenshao review — hostname rejection, error sanitization, msgSeqMap validation
* fix(qqbot): address PR review — drain body, narrow gateway hostname
- Drain resp.body?.cancel() in fetchAccessToken error to prevent
Undici connection leaks on repeated token failures
- Narrow validateGatewayUrl hostname check from broad Tencent
wildcards (.tencent.com, .tencentcs.com) to only *.qq.com
to prevent attacker-controlled Tencent Cloud API Gateway
domains from passing validation
* test(qqbot): tighten token-error assertion to exact match
* test(qqbot): add connect retry sanitization + msgSeqMap edge-case tests
Add test verifying the final connect() retry sanitizes newline/control
characters in the thrown error message. Add tests for fractional,
overflow, and Infinity values in msgSeqMap restore validation to
prevent regression of the Number.isSafeInteger fix.
* fix(qqbot): address wenshao review round 3 — error preservation, validation logging, URL normalization
* test(qqbot): fix connect retry sanitization assertion — sanitizeLogText preserves readable content, not censor words
* fix(qqbot): address wenshao review — error preservation, validation logging, URL userinfo stripping, test coverage
* fix(qqbot): add Array.isArray guards and replyMsgId drop logging in restoreQQState
Prevent TypeError from .filter() on non-array state values (e.g. object
from partial write). Missing Array.isArray() guard caused all three maps
to be lost on a single corrupted section — now each map independently
validates with both truthiness + Array.isArray() before filtering.
Also add replyMsgId drop-count logging (was missing while chatTypeMap
and msgSeqMap already had it).
* fix(qqbot): drain response body in token error path, sanitize URL TypeError, clean tmp on atomic write failure
Three fixes from wenshao review:
1. fetchAccessToken: cancel unconsumed response body to prevent Undici TCP
socket leak (could exhaust connection pool over hours in long-running daemon)
2. validateGatewayUrl: strip raw URL from TypeError message to prevent
log injection via malformed URL strings
3. saveQQState/flushQQState: unlinkSync(tmpPath) in catch blocks to
prevent orphaned .tmp files when renameSync fails (cross-device, Docker)
* fix(qqbot): test Infinity rejection via 1e999 raw JSON, not JSON.stringify nullification
* fix(qqbot): address PR #6200 review — beforeExit hook, key validation, error clarity
- Add beforeExit hook to flush debounced state on abnormal process exit
(SIGKILL, OOM, crash) when unref'd 500ms timer has pending writes
- Validate map keys (typeof string, ≤256 chars) in restoreQQState filters
alongside existing value validation to prevent non-string key bloat
- Improve gateway hostname error message to include expected domain
* fix(qqbot): address PR #6200 review round — non-object JSON restore, disposed guard, beforeExit dedup, comment fix
* fix(qqbot): hoist tmpPath declaration before try block in saveQQState
* fix(qqbot): update beforeExit JSDoc to accurately describe behavior
* fix(qqbot): drain response body in fetchGatewayUrl error path
* chore: ignore local worktrees
* docs(channels): design identity and task lifecycle p0
* docs(channels): plan identity and task lifecycle p0
* feat(channels): add identity and task lifecycle metadata
* fix(channels): suppress cancelled tool call lifecycle
* docs: add channel lifecycle status adapter design
* docs: add channel lifecycle status adapter plan
* feat(channels): map telegram lifecycle to typing
* feat(channels): map weixin lifecycle to typing
* fix(channels): reset weixin typing state after failed start
* feat(channels): map dingtalk lifecycle to reactions
* feat(channels): add feishu card status labels
* fix(channels): preserve feishu collapsible status labels
* feat(channels): show feishu lifecycle card status
* fix(channels): cover loop lifecycle metadata
* fix(channels): preserve feishu terminal card status
* chore: remove internal task reports from branch
* fix(channels): clear late lifecycle status starts
* fix(channels): harden lifecycle event edges
* fix(channels): clean adapter lifecycle state
* fix(channels): finalize cancelled lifecycle before cleanup
* fix(feishu): keep cancelled card status visible
* docs(channels): add lifecycle status no-op coverage
* fix(channels): address lifecycle review suggestions
* docs(channels): align lifecycle status documentation
* fix(channels): route adapter stop through lifecycle cancel
* fix(channels): close lifecycle cancellation races
* fix(channels): keep first feishu terminal status
* fix(telegram): guard lifecycle typing updates
* test(qqbot): cover lifecycle status no-ops
* fix(feishu): preserve completed card race status
* docs(lifecycle): align status review docs
* fix(channels): separate pending cancel state
* fix(channels): clean adapter lifecycle status edges
* fix(channels): order clear cancellation lifecycle
* fix(feishu): preserve user stop status label
* fix(channels): suppress loop chunks during pending cancel
* test(channels): use active session in cancel regression
* fix(channels): harden adapter cancellation tests
* fix(channels): sanitize lifecycle tool fields
* fix(channels): route shared tool call lifecycle
* fix(channels): preserve pending cancel intent
* fix(channels): preserve pending cancel intent
* fix(telegram): track typing by session
* fix(feishu): preserve stop status during finalization
* fix(channels): preserve responses after failed cancel
* fix(channels): tighten lifecycle cancellation reasons
* fix(channels): close lifecycle cancel races and validate identity config
Address the outstanding review findings on #6105:
- carry a typed reason on ChannelLoopSkippedError and report disabled
loops as 'dropped' instead of 'timeout'
- treat a turn as committed once delivery starts: /cancel re-checks
deliveryStarted after the cancel RPC settles, and neither prompt path
lets a late-settling cancel rewrite a delivered turn into cancelled
(or follow a /clear cancellation with completed)
- tag failed lifecycle events with phase: agent vs delivery
- validate identity/memoryScope shape at config parse time instead of
throwing an opaque TypeError on the first prompt of every session
- guard onPromptStart hooks, pass job.id to loop onPromptStart/End,
append the boundary block after operator instructions, gate
/who + /status identity lines on configured identity/memoryScope,
cache the boundary prompt, and route error logs through
lifecycleError()
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* test(qqbot): keep lifecycle mock in sync
* fix(channels): unify cancel state machine and adapter lifecycle edges
Address the remaining review findings on #6114:
- /cancel now delegates to requestActivePromptCancellation — one cancel
state machine for slash command and adapter stop buttons; the helper
refuses to claim success once delivery started and sanitizes its logs
- share isTerminalTaskLifecycleType from channel-base instead of four
hand-rolled terminal checks
- DingTalk: only attach reactions for message ids seen inbound (loop job
ids no longer trigger doomed emotion API calls), log reaction API
failures, and recall reactions when a session dies
- Feishu: track userStopped on the card state so every wind-down path
renders 已停止生成 after a Stop click, and pass terminal labels through
updateCard's statusLabel param at the remaining baked-text sites
- Weixin: guard the typing .then() against a racing disconnect
- document onTaskLifecycle as the canonical hook (onPromptStart/End are
back-compat), fix TS4111 bracket access in the DingTalk test
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(channels): keep failed terminal event when cancel settles post-delivery
Self-review follow-up: once delivery started, the catch paths no longer
reconcile a pending cancel — a late-resolving cancel RPC used to flip
cancelled=true there, suppressing the failed emit while the /cancel
handler (seeing deliveryStarted) also declined to emit, leaving a
started task with no terminal lifecycle event at all.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(channels): close self-review findings on adapter lifecycle edges
- DingTalk: track inbound message ids in a capped insertion-ordered set
instead of the TTL-swept dedup map, so a turn queued minutes behind a
long predecessor still attaches its reaction
- Feishu: reset userStopped when the cancel RPC fails (later wind-down
must render the real terminal status), and give handleStop's plain
message fallback the same ---/label shape the strip regex expects
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(channels): hold streamed chunks while a cancel is pending
Chunks arriving while a /cancel RPC is in flight were pushed straight
into the BlockStreamer, which can send a block on a size/paragraph
threshold before the cancel resolves — leaking output a successful
cancel can never recall. Hold the pending-window chunks instead: replay
them (block streaming + text_chunk transcript) when the cancel fails,
discard them when it succeeds. onResponseChunk stays live through the
window so adapter-accumulated display state has no permanent hole on a
failed cancel; adapters gate visible updates on their own stop flags.
Also stop passing the loop job id to onPromptStart/onPromptEnd (and the
/clear eviction path): the hook contract is inbound platform message
ids, and adapters act on them — cards and reactions keyed to a fake id.
Lifecycle events still carry job.id for correlation.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(feishu): strip every status-label layout from quoted-reply context
Replace the two $-anchored strip regexes in extractCardText with one
line-granular status-block filter. The anchored patterns missed four
layouts the cards actually render: the two-block truncation card
(notice block + label block), terminal labels joined mid-string before
a collapsible panel body, the 停止失败,请重试 label (never in the
alternation), and label-only stopped cards where the divider leads the
text. Tests now assert the real rendered shapes.
Also update the adapter-cancellation suppression test to the new
hold-and-replay chunk semantics from the base layer.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(feishu): ignore loop job ids in prompt hooks
* fix(channels): defer adapter chunks while cancel is pending
* fix(feishu): preserve bare status text in quotes
* fix(channels): release held chunks on failed cancel
* fix(feishu): preserve generated stop fallback status
* fix(telegram): clear typing on dead sessions
* fix(feishu): share status label definitions
---------
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Add optional `listSessions()` method to `ChannelAgentBridge` interface
so channel consumers can enumerate sessions currently attached to the
bridge. Only `DaemonChannelBridge` implements it — reads from the
internal `sessions` Map and `activePrompts` Set to build a snapshot.
The daemon-worker facade forwards the method unconditionally when
present, matching the existing `getAvailableCommands` pattern.
🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
* chore: ignore local worktrees
* docs(channels): design identity and task lifecycle p0
* docs(channels): plan identity and task lifecycle p0
* feat(channels): add identity and task lifecycle metadata
* fix(channels): suppress cancelled tool call lifecycle
* fix(channels): cover loop lifecycle metadata
* fix(channels): harden lifecycle event edges
* fix(channels): finalize cancelled lifecycle before cleanup
* fix(channels): address lifecycle review suggestions
* fix(channels): close lifecycle cancellation races
* fix(channels): separate pending cancel state
* fix(channels): order clear cancellation lifecycle
* fix(channels): suppress loop chunks during pending cancel
* test(channels): use active session in cancel regression
* fix(channels): sanitize lifecycle tool fields
* fix(channels): route shared tool call lifecycle
* fix(channels): preserve pending cancel intent
* fix(channels): preserve responses after failed cancel
* fix(channels): tighten lifecycle cancellation reasons
* fix(channels): close lifecycle cancel races and validate identity config
Address the outstanding review findings on #6105:
- carry a typed reason on ChannelLoopSkippedError and report disabled
loops as 'dropped' instead of 'timeout'
- treat a turn as committed once delivery starts: /cancel re-checks
deliveryStarted after the cancel RPC settles, and neither prompt path
lets a late-settling cancel rewrite a delivered turn into cancelled
(or follow a /clear cancellation with completed)
- tag failed lifecycle events with phase: agent vs delivery
- validate identity/memoryScope shape at config parse time instead of
throwing an opaque TypeError on the first prompt of every session
- guard onPromptStart hooks, pass job.id to loop onPromptStart/End,
append the boundary block after operator instructions, gate
/who + /status identity lines on configured identity/memoryScope,
cache the boundary prompt, and route error logs through
lifecycleError()
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(channels): keep failed terminal event when cancel settles post-delivery
Self-review follow-up: once delivery started, the catch paths no longer
reconcile a pending cancel — a late-resolving cancel RPC used to flip
cancelled=true there, suppressing the failed emit while the /cancel
handler (seeing deliveryStarted) also declined to emit, leaving a
started task with no terminal lifecycle event at all.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(channels): hold streamed chunks while a cancel is pending
Chunks arriving while a /cancel RPC is in flight were pushed straight
into the BlockStreamer, which can send a block on a size/paragraph
threshold before the cancel resolves — leaking output a successful
cancel can never recall. Hold the pending-window chunks instead: replay
them (block streaming + text_chunk transcript) when the cancel fails,
discard them when it succeeds. onResponseChunk stays live through the
window so adapter-accumulated display state has no permanent hole on a
failed cancel; adapters gate visible updates on their own stop flags.
Also stop passing the loop job id to onPromptStart/onPromptEnd (and the
/clear eviction path): the hook contract is inbound platform message
ids, and adapters act on them — cards and reactions keyed to a fake id.
Lifecycle events still carry job.id for correlation.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(channels): defer adapter chunks while cancel is pending
---------
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
DingTalk could only reply through the per-message sessionWebhook, so channel
loops (#6073) rejected /loop add on DingTalk and could never deliver scheduled
results cold. Implement proactive send via the robot groupMessages API with a
self-managed access token (the stream SDK only refreshes on connect, so a
long-lived socket serves a stale token after ~2h), a 15s request timeout so a
hung send cannot freeze the session queue, and errors that carry the DingTalk
error detail so /loop inspect is diagnosable. Group targets only; DM and
webhook-URL fallback targets are rejected up front.
Also give the loop prompt a delivery-contract line so the model treats a
scheduled turn as content to produce rather than an action it must send itself
— without it the model went hunting for a webhook to post through.
Closes#6168
Co-authored-by: Qwen-Coder <noreply@qwen.ai>
* fix(channels): replace setTimeout(0) drain with turn_complete SSE barrier
DaemonChannelBridge.prompt() used a setTimeout(0) heuristic to wait for
late SSE text chunks after session.prompt() resolved. This was unreliable
because it depended on microtask timing rather than a deterministic signal.
Replace with a turn barrier that awaits the turn_complete SSE event from
handleEvent, which guarantees all preceding session_update events (text
chunks) have been processed. Also handle turn_error by logging the error
data and resolving the barrier.
Add resolveTurnBarrier calls in both dropSession and cancelSession to
prevent deadlocks when sessions terminate while prompt() is awaiting the
barrier.
* fix(channels): resolve turn barrier before session.cancel() in cancelSession
Move resolveTurnBarrier and abortActivePrompts before the await
session.cancel() call, consistent with dropSession ordering. This
prevents the barrier from hanging if cancel() blocks on a network
timeout.
* fix(channels): race turn barrier against setTimeout fallback for non-SSE paths
DaemonSessionClient.prompt() can return directly without turn_complete
in non-SSE paths (blocking HTTP when subscriptionActive is false,
non-202 responses). The barrier would hang in these cases.
Use Promise.race so turn_complete wins deterministically on the SSE
path (microtask beats macrotask) while setTimeout(0) provides a safe
fallback for non-SSE paths.
Also update the turn_error test to match real SDK behavior where
session.prompt() rejects on turn_error via _dispatchTurnEvent.
* feat(channels): add group history backfill
* fix(channels): harden group history backfill
* fix(channels): address group history review gaps
* fix(channels): apply wildcard group history limit
* docs(channels): add "qwen tag" RFC — channel-resident multiplayer agent
Design for a persistent, multiplayer agent that lives in a chat channel
(DingTalk-first), built on the existing channel adapters (qwen channel start,
packages/channels/*) and the qwen serve daemon rather than a new service.
Covers the phased plan: Phase 0 multiplayer identity, Phase 1 proactive engine
(scheduler + cold-group push, daemon migration), Phase 2 channel memory +
governance, plus tradeoffs and resolved design decisions.
Part of #5887
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* feat(channels): qwen tag Phase 0 — multiplayer identity & group-safe commands
In a thread-scoped group every member shares one session, which surfaces a few
gaps in the channel layer. Phase 0 closes them as a backward-compatible
increment on the existing AcpBridge channel path:
- Inject a [sender] marker into the prompt for group turns so the agent can tell
speakers apart; skipped for 1:1 chats and for already-prefixed re-entries.
- Add Envelope.alreadyPrefixed so collect-mode coalescing does not double-prefix
the already-tagged buffered text.
- Require "/clear confirm" in groups — a bare /clear no longer wipes the shared
session for the whole channel; DMs still clear directly.
- Add a read-only /who reporting channel / workspace / session scope without
creating a session.
- Drop DingTalk group messages with no conversationId, so the shared session is
never keyed on the expiring sessionWebhook.
- Fix the stale ChannelConfig.dispatchMode JSDoc (runtime default is 'steer').
Adds unit tests for sender attribution, the collect double-prefix guard, the
group /clear confirmation, and the read-only /who.
Part of #5887
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): scope group clear confirmation
* fix(channels): address review on qwen tag Phase 0
- Restrict /clear in a shared (thread) group to config.allowedUsers (when set)
and require an explicit "confirm"; only the shared case is gated — DMs and
per-user groups clear directly. Reconcile the RFC (OD-4) with this approach
(a hyphenated /clear-channel isn't parseable; per-member owner-gate waits on
the identity model, OD-3/OD-11).
- Sanitize the injected [sender] marker (strip brackets/CR/LF, cap length) so a
crafted nick can't break out of or spoof the attribution tag.
- Surface to stderr when a collect-mode coalesced re-entry drops buffered turns
instead of swallowing the error.
- Narrow Envelope.alreadyPrefixed to the literal `true` (internal-only flag).
- Expose DingtalkChannel.isUnroutableGroupMessage and test the
group-without-conversationId drop; replace flaky setTimeout sequencing in the
collect test with vi.waitFor; add tests for /who (active + private scope) and
/clear authorization in a shared group.
Part of #5887
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): preserve group command routing
* fix(channels): harden group routing (map leak, injection, slash attribution)
Address the surviving Phase-0 review criticals:
- /clear now purges every per-session map (sessionQueues, activePrompts,
collectBuffers), not just instructedSessions, so a long-running gateway
doesn't leak dead session entries.
- QQ group adapter sanitizes the self-prefixed sender name. It sets
alreadyPrefixed, which bypasses ChannelBase's [..]/newline/length guard,
so a crafted QQ nickname could otherwise inject brackets/newlines.
- Unrecognized slash commands in a shared group keep their [sender]
attribution; recognized commands (a local handler or a forwarded agent
command) still reach the agent verbatim.
- Sanitize quoted referencedText (strip control chars, cap at 500) so it
can't inject newlines/instructions or balloon the prompt.
- /who reports only the workspace basename, not the absolute cwd.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): close Phase-0 review criticals (quote injection, /clear cancel, drain log)
- referencedText: strip the wrapper's own delimiters (" [ ]) in addition to
control chars, so a quoted message can't break out of [Replying to: "..."]
and inject its own top-level instructions.
- /clear: cancel any in-flight bridge.prompt() for the cleared session(s) and
drop their buffered follow-ups before purging the maps, so a running turn
can't deliver a stale response into — or resurrect — the cleared session.
- collect-drain failure log now includes the sessionId and last sender so
concurrent sessions are diagnosable.
- Extract the duplicated sender-name sanitizer into a shared sanitizeSenderName
helper used by both ChannelBase and QQChannel; reuse isSharedGroupSession for
the /who scope note.
- Tests: quote-breakout payload, /clear cancels in-flight, /who in a DM,
single-scope hasSession/removeSession, and a sanitizeSenderName unit test.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): make group slash-command pass-through race-free and invalidate queued turns on /clear
CRITICAL 1 (sender attribution race): availableCommands is populated
asynchronously by the ACP available_commands_update notification, so a
registration check could see an empty list on a fresh session and wrongly
[sender]-prefix the first real /command — corrupting it into plain text.
Stop inline-prefixing ANY slash-shaped message: pass it through verbatim so
it always parses as a command regardless of load/registration state. Also
widen the command token to include - and : so /compress-fast and /git:commit
parse as commands. Tradeoff: an UNREGISTERED group slash command no longer
carries [sender] attribution (inverse of the earlier R2 ask) — not breaking
real commands is the safer default; flagged for maintainers.
CRITICAL 2 (/clear vs queued followups): a teammate turn that entered
handleInbound() before /clear confirm had already captured the prev.then()
chain and would still run bridge.prompt() against the just-cleared session.
Add a per-session generation counter bumped up-front by /clear; a queued turn
snapshots it at enqueue and bails if it was bumped before the turn dequeued.
Tests: fresh-session/empty-availableCommands pass-through, hyphenated command,
unrecognized command (flagged behavior), non-slash still prefixed; /clear
genuinely awaits a PENDING in-flight turn before confirming; /clear confirm
invalidates an already-queued followup (no resurrection); collect-drain
failure logs the lost count with sessionId + sender to stderr.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): only skip group [sender] tag for real slash commands
The group attribution check keyed off the lenient parseCommand(), which
accepts slash-prefixed paths (e.g. /tmp/foo) as commands, so that prose
reached the model without the [sender] tag. Decide command-vs-prose with
a dedicated isSlashCommand() that mirrors the CLI's classifier
(cli ui/utils/commandUtils.ts isSlashCommand): reject //, /*, a bare /,
and any path separator in the first token. Slash-prefixed paths and
comments now keep their speaker attribution, while real commands
(/compress, /git:commit) still pass through verbatim. Purely lexical, so
it stays race-free against the async command list.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): bound /clear's wait on a wedged in-flight turn
doClear awaited active.done after cancelSession, but active.done only
resolves in the prompt() finally. If the ACP child is wedged (stuck tool
call, not reading stdin, or crashed without closing), cancelSession may
throw (swallowed) or succeed yet the prompt never returns, so active.done
never resolves and /clear — and the whole channel — hangs forever.
Race the wait against a CLEAR_CANCEL_TIMEOUT_MS (3s) timeout and purge
anyway when it wins: the per-session maps are still purged and the
generation already bumped, so a turn that settles later is invalidated.
Cancellation stays best-effort.
Tests: a wedged session whose active.done never resolves still completes
/clear (purges every map, replies "Session cleared") within the timeout
via fake timers — no real wait(ms); plus /clear Confirm / CONFIRM
(mixed-case) is accepted, guarding the handler's .toLowerCase().
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): test QQ group slash branch, log dropped queued turns, reclaim cleared generations
Address review feedback on the Phase-0 qwen-tag /clear + group-slash work:
- QQChannel: cover the previously-untested `isSlash` branch of handleGroup
— a group `/clear` is forwarded verbatim (no `[sender]` tag) and must NOT
set `alreadyPrefixed`. Catches a regression that always sets it.
- ChannelBase: when a followup turn is dequeued after `/clear` bumped the
session generation, log the drop (with sessionId) instead of returning
silently, so an unanswered queued message is diagnosable.
- ChannelBase: reclaim the bumped `sessionGenerations` entry once the cleared
session's queue drains (or immediately when nothing was queued), so a
long-running gateway no longer leaks one entry per `/clear`. Reclamation is
deferred and guarded so it can't delete an entry a still-queued turn needs,
and a wedged in-flight turn can't block `/clear`.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): harden qwen-tag group session gating, cancel bounds, and prompt sanitization
Address the latest PR #5888 review batch (4 Criticals + suggestions):
- Treat sessionScope 'single' as a SHARED group session: the shared-scope
predicate now covers both 'thread' and 'single' (via SHARED_SCOPES), so a
'single'-scoped group can no longer bypass /clear confirm + allowlist gating
and wipe the channel-wide __single__ session with a bare /clear.
- Bound the steer-mode wind-down wait: race active.done against
CLEAR_CANCEL_TIMEOUT_MS (and fire-and-forget the cancel) so a wedged ACP child
can't pin the session queue forever, matching the /clear hardening.
- Make /clear's cancelSession request fire-and-forget so a wedged cancel request
can't hang /clear before the bounded active.done wait even starts.
- Strip Unicode line/paragraph separators (U+2028/U+2029) and bidi overrides
(U+202A-U+202E, U+2066-U+2069) from sender names and quoted text via shared
sanitize helpers (sanitizeSenderName, new sanitizeQuotedText); route
referencedText and attachment filenames through the shared quoted sanitizer.
- Add sender/conversation context to the generation-bail drop log and the
DingTalk unroutable-group-message drop log.
- Tests: fix TS4111 bracket-access in ChannelBase tests (test-inclusive
typecheck clean); add load-bearing coverage for single-scope sharing, wedged
steer, wedged /clear cancel, Unicode/bidi sanitization, and the
generation-reclamation guard fire paths.
- Correct the isSlashCommand JSDoc to document the intentional bare-'/'
divergence from the CLI classifier; note SessionRouter's by-sender scan does
not match single-scoped keys (latent today).
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): stop block-streaming on /clear, not just cancel it
doClear flipped active.cancelled = true but never called
active.stopStreaming(), unlike the /cancel handler. In block-streaming
channels, text already buffered in the BlockStreamer could still be
emitted by the idle timer during/after /clear, leaking a stale response
into the just-cleared session. Mirror /cancel: also tear down the
streamer on the cancelled prompt in the clear path.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): gate /clear for single-scope DMs and unstick wedged steer queue
Two follow-up fixes in ChannelBase, both on my recent fixes.
CRITICAL 1: `single` scope maps EVERY sender — group OR DM — to the one
`__single__` session, but the shared-session guard also required `isGroup`,
so anyone who could DM the bot could bare-/clear the channel-wide session
without the confirm + allowedUsers gate. The shared-session predicate now
treats `single` as shared regardless of isGroup (`thread` stays group-only);
/help and /who follow the same predicate.
CRITICAL 2: the bounded steer wait stopped `await active.done` from hanging,
but the replacement turn was still chained behind the wedged turn's
never-resolving sessionQueues tail, so the follow-up (and every later
message) hung forever. On the steer timeout we now re-seed the chain
(prev = resolved) so the next turn starts a fresh chain; the wedged turn
stays cancelled, so a late settle still can't deliver a stale response.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): sanitize attachment filePath and tighten isSlashCommand
The attachment filePath was embedded raw in the prompt while the filename beside it was sanitized. Adapters build the path by basename()-ing the user-supplied filename, so its last segment carries the same attacker-controlled chars (brackets, newlines, U+2028, bidi overrides), letting a crafted filename inject prompt lines via the path. Route the rendered path through the same sanitizeQuotedText neutralization; att.filePath itself is left intact, and benign names render unchanged so the agent's read-file tool still resolves them.
Also tighten isSlashCommand to require parseCommand()'s token charset ([a-zA-Z0-9_:-]+ plus an optional @botname). A non-command-shaped input like /cafe or a zero-width-laden token previously skipped the group [sender] tag yet was not a runnable command, reaching the shared session as unattributed prose. It now keeps its attribution.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): guard activePrompt cleanup against replacement-turn clobber
The per-turn `finally` in ChannelBase deleted `activePrompts[sessionId]`
unconditionally. With the steer-mode bounded wait, this corrupts steer
protection: when turn A wedges (its `bridge.prompt()` never resolves), the
`CLEAR_CANCEL_TIMEOUT_MS` race times out (`steerWedged`), and turn B starts a
fresh chain and re-seeds `activePrompts` with its own entry. When A's prompt
finally settles and reaches its `finally`, the unconditional delete removes
B's entry — so a later turn C sees `activePrompts.get(sessionId) === undefined`
and silently loses steer protection (it forwards verbatim instead of
cancelling + re-prompting).
Fix: capture this turn's own `ActivePrompt` and compare-and-delete — only clear
the entry if it is still ours (`activePrompts.get(sessionId) === promptState`).
Sibling-map audit (same later-settling-wedged-turn clobber):
- collectBuffers: also gated on the same `stillCurrent` flag. A replaced wedged
turn must not drain the buffer the live replacement turn now owns (reachable
via mixed-mode single-scope, where a steer turn and collect follow-ups share
one session). Behavior-preserving: in all serialized flows the turn is still
current at its finally, so the drain runs exactly as before.
- sessionQueues / sessionGenerations / instructedSessions: not mutated in the
per-turn finally (only in /clear), so no per-turn clobber. Queued turns are
already guarded against /clear by the sessionGenerations counter.
- promptState.resolve(), bridge.off, streamer.stop, onPromptEnd: per-turn-owned
cleanup, kept unconditional (a replaced wedged turn must still release any
steer/clear waiter racing its done promise).
Adds a deterministic test (manual deferred, no timers/sleeps) reproducing A
wedged -> B replaces -> A settles late, asserting B's entry survives and a
following turn C still engages steer protection. Reverting the guard fails it.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): guard onPromptEnd on superseded turns and strip control chars from sender names
When a steer-replacement turn B re-seeds a session after the bounded
wind-down wait times out, a wedged predecessor turn A can settle late and
run its finally. onPromptEnd hides a session/chat-scoped working indicator
(e.g. the Telegram/WeChat typing indicator keyed by chatId), so A's
previously-unconditional onPromptEnd would stop B's indicator while B is
still working. Guard onPromptEnd with the same stillCurrent identity check
that already protects the per-session map cleanup, so a superseded turn can
no longer clear the successor's indicator. A's own teardown (textChunk
listener, streamer, promptState.resolve) stays unconditional.
Bring sanitizeSenderName to parity with its sibling sanitizeQuotedText by
stripping C0/DEL control chars: a crafted display name with \x07/\x1b
otherwise reaches the [name] prompt tag. Mirror the same strip in the qqbot
send.test.ts mock so a control-char regression in the real helper is caught.
Add a diagnostic stderr line when a steer abandons a wedged turn, matching
the existing queue-drop log.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): preserve path chars in attachment filePath; trim sender names
Addresses ci-bot review on the channel prompt-sanitization paths.
filePath over-sanitization (the main regression): a prior hardening pass
routed the rendered attachment path through sanitizeQuotedText, which strips
the `[`, `]`, and `"` characters. Those are valid, common filesystem path
characters (e.g. a Next.js `app/[slug]/page.tsx` route, a quoted segment, or a
space in a folder name), so stripping them advertised a `saved to:` path that
does not exist on disk and broke the agent's read-file tool. A path rendered
alone on its own line cannot use brackets/quotes/spaces to break out of that
line, so the only real injection risk is characters that break or reorder the
line. Add a path-safe neutralizer, sanitizePromptPath, that strips ONLY C0/DEL
controls (incl. CR/LF), the Unicode line/paragraph separators, and bidi
overrides (the PROMPT_UNSAFE_INVISIBLES set), preserving everything else
byte-intact and without capping length. The human-readable fileName label keeps
sanitizeQuotedText (bracket-stripping is fine for a quoted label).
steer generation-capture ordering: in steer mode the session generation was
snapshotted AFTER the bounded wind-down wait. A concurrent /clear (e.g. another
sender's clear-confirm on a shared session) that bumps the generation DURING
that wait was therefore invisible: the snapshot read the post-clear value, the
dequeue equality guard still held, and the turn ran against the just-cleared
session. Capture preSteerGeneration BEFORE the wait and use it for the guard so
a /clear during the wait is detected and the turn bails.
sender-name fallback: sanitizeSenderName now trims after the length cap and
returns an 'unknown' default, so a name made entirely of strippable chars
(brackets/newlines) no longer renders an anonymous bracket tag. Both call sites
embed the result as a bracket tag with no fallback of their own, so the default
lives in the shared helper; the qqbot test mock mirrors the new contract.
Adds load-bearing, mutation-checked tests for each change (deterministic,
fake-timer driven for the steer/clear race; no real-time waits).
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): stop streamer on steer-cancel and align slash-command trim
Two ci-bot review findings on the steer/slash-command paths in ChannelBase.
1. steer-cancel must stop the BlockStreamer, not just cancel. The steer
handler flipped active.cancelled but never called stopStreaming(), unlike
the sibling doClear path. cancelled alone only suppresses NEW chunks: text
already buffered in a wedged turn's streamer is still flushed by the idle
timer (~1500ms), which fires before the 3000ms steer wind-down bound, so it
leaks into the chat after the replacement turn has begun. Mirror doClear and
stop the streamer immediately after cancelling.
2. parseCommand now trims its input so it agrees with isSlashCommand (which
already trims). Before, " /help" (leading space, common from IME/copy-paste)
made isSlashCommand return true — suppressing the group [sender] tag — while
parseCommand returned null, so the command reached the agent unattributed.
Trimming closes that attribution gap; it changes nothing else (the
no-whitespace path is unaffected, and /tmp/foo still has no handler).
Also document the forward-looking late-cancel concern at the steer cancel site:
cancelSession is keyed only by sessionId and the replacement prompt reuses it.
The shipping AcpBridge sends cancel+prompt over one in-order stdin stream with
cancel enqueued first, so the child always processes the cancel before the new
prompt — the replacement turn is safe today. A future network/daemon bridge
with cancel latency could reorder them; the proper fix is turn-scoped
cancellation (a Bridge-contract change), deferred to avoid an API break here.
Tests: a steered wedged turn with buffered streamer text invokes stopStreaming
so the idle-timer flush can't deliver stale text after the replacement turn
begins; " /help" in a group is handled as a command (no [sender] tag, no
forward) while /help and /git:commit still parse. Both are mutation-checked.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): block shell commands in shared sessions; dedupe cancel path
Phase 0 ships shared/group sessions (sessionScope 'thread' and 'single')
but has no per-sender trust model — the [sender] marker is explicitly not
a trust boundary. The pre-existing bang (`!`) handler in ChannelBase runs
arbitrary host shell commands, so in a shared session ANY participant
could run `!rm -rf /`. Gate it with the same isSharedSession predicate
used for the destructive-/clear confirm gate: refuse with a one-line
notice in shared sessions, keep direct execution in a 1:1 session (the
lone user is the operator). isSharedSession is promoted from a closure to
a private method so both gates share one source of truth.
Extract the duplicated cancel + bounded-wait sequence from doClear and the
steer branch into a private cancelAndAwaitActive() helper (the duplication
previously caused steer to miss stopStreaming()). steer keeps its pre-wait
generation snapshot and uses the boolean result; doClear ignores it.
Also: trim the over-verbose steer/clear comment blocks to the load-bearing
why; cap sanitizePromptPath at 1024 chars (defense-in-depth) like its
siblings; and make the qqbot send.test mock use the real sanitizeSenderName
via vi.importActual instead of an inline re-implementation that can drift.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): run onPromptEnd after /clear; only skip it for superseded turns
The per-turn `finally` guarded `onPromptEnd` behind `stillCurrent` alone. When
`/clear` cancels an in-flight turn with NO replacement it deletes that turn's
`activePrompts` entry, so if the cancelled prompt settles late `stillCurrent` is
false and `onPromptEnd` was skipped — leaking the platform cleanup several
adapters do there (Telegram clears its typing interval, DingTalk recalls the
working reaction, Weixin clears typing, Feishu finalizes card state). The
`stillCurrent`-only guard was only meant to stop a STEER-superseded turn from
clobbering its replacement's indicator, but it wrongly also caught the
no-replacement `/clear` case.
Distinguish the two: add `ActivePrompt.superseded`, set on the OLD turn only when
a steer replacement actually takes over the session slot. The `finally` now runs
`onPromptEnd` UNLESS the turn was superseded; the `activePrompts.delete` and the
collect-drain stay `stillCurrent`-gated (a superseded turn must not drain the
replacement's buffer). Regression + collect-drain tests added with mutation
checks.
Also addresses review nits: extract a shared command-token regex constant so
parseCommand and isSlashCommand can't drift; report `single` scope as "shared
channel-wide" in /who (it is shared across all DMs and groups, not just one
group); append a truncation ellipsis in sanitizeQuotedText so a cut
quote/filename is detectable; and include the message text in the
generation-bail drop log so an ignored message is diagnosable.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): require the slash-command token to immediately follow the slash
isSlashCommand used trimmed.slice(1).trimStart() before taking the first token, so a space after the slash (`/ foo`) still classified as a command. parseCommand's regex requires the token immediately after `/`, so it returned null for the same input. In a shared group session that divergence suppressed the [sender] attribution (isSlashCommand true) while running no command (parseCommand null), letting `/ foo` reach the agent unattributed — the exact failure the file's comment warns about.
Remove the .trimStart() so the token must immediately follow the slash; `/ foo` now splits to an empty first token and is treated as prose, agreeing with parseCommand. Normal commands (/help, /git:commit, /compress-fast, /cmd@bot) are unaffected. Adds an invariant test that isSlashCommand and parseCommand agree on `/ foo` (both false) and /help (both true), plus a behavioral test that `/ foo` keeps its [sender] tag.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): don't clobber a post-/clear turn's indicator; tidy refusal + diagnostics
Addresses wenshao's review on PR #5888:
- Refusal text: `!` shell commands are "disabled in shared sessions" (drop
"group" — isSharedSession is also true for single-scope DMs, which are not
groups).
- /clear diagnostic: capture cancelAndAwaitActive's result and log on timeout,
mirroring the steer "abandoned a wedged turn" message, so a wedged /clear is
observable instead of silently "worked".
- /clear indicator clobber: a /clear that evicts a wedged turn never set
`superseded`, so the turn's late-settling finally still ran onPromptEnd and
killed the indicator a turn started AFTER the /clear now owns. Add a
`clearEvicted` flag: /clear runs the wedged turn's own onPromptEnd at eviction
time (no replacement exists yet) and marks it clearEvicted; the late finally
then skips onPromptEnd. onPromptEnd now fires exactly once for the evicted
turn, at clear-time. The finally guard becomes
`stillCurrent || (!superseded && !clearEvicted)`. ActivePrompt carries the
originating chatId/messageId so the eviction can target the right indicator.
- Dropped-queued-turn log: sanitize the attacker-controlled message text
(render newline visibly, strip C0/DEL incl. CR/ESC) before it reaches an
operator's terminal, matching every other embed path.
Tests: complete the single-scope DM `!` refusal test (not-called + length +
not-forwarded); update the group/DM/1:1 refusal assertions to the new text;
re-point the "/clear settles late" test to clear-time cleanup; add a
replacement-after-/clear test asserting a late settle does not end a later
turn's indicator; strengthen the dropped-turn-log test with control-char input.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): fix root tsc errors in channel tests; gate /who; sanitize dingtalk nick log
Root `tsc --noEmit` failed on 9 type errors in two channel test files
that per-package builds and `--workspace` typechecks miss (both exclude
test files). Fix the test types only — no production type changes:
- qqbot/dingtalk: `QQChannel`/`DingtalkChannel` come from `await import()`
as values, so annotate instances with `InstanceType<typeof X>`.
- dingtalk test config was missing the required `token` field of
`ChannelConfig`.
- qqbot mock: bracket-access the index-signature `router` and type the
assignment so `{}` is assignable to `Record<string, unknown>`.
/who: gate it to authorized senders in shared sessions, mirroring /clear
— /who leaks the workspace basename, so non-members shouldn't see it.
dingtalk: sanitize `senderNick` with the shared `sanitizeSenderName`
before writing it to stderr, so a crafted nick with CR/LF/control chars
can't fragment or inject log lines.
!-shell gate: refuse `!` shell commands in ALL groups (gate on
`envelope.isGroup`, not just shared sessions) — a user-scope group is not
a shared session yet is still multi-operator, so members could reach the
host shell. The refusal now runs BEFORE router.resolve, so a refused
command never creates a session. Single-scope DMs stay refused.
tests: the qqbot/dingtalk suites mock `@qwen-code/channel-base` and pull
the real `sanitizeSenderName` via the package export, which resolves to
base/dist and broke clean package-local runs. Alias the specifier to
base's source in each vitest config (mirrors cli) so the suites run
without a prior `tsc --build` of base.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): bump generation and release indicator on steer-abandon, mirroring /clear
When a steer abandons a wedged turn it re-seeds a fresh queue chain but, unlike doClear, did neither of the two protections doClear runs on a wedged-turn eviction. Both bugs surface when mixed dispatch modes collapse onto one session (single/user scope).
FIX 1 (generation bump): a followup queued behind the wedged turn stays on the now-orphaned chain. Without bumping sessionGenerations, when the wedged turn late-settles the followup PASSES the dequeue guard and runs the unguarded activePrompts.set, clobbering the live steer replacement -> two concurrent bridge.prompts on one session (duplicated responses + double tool execution). Bump the generation up-front (skipping if a /clear raced the wind-down wait) and advance the replacement's captured snapshot so it proceeds while the orphaned followup bails.
FIX 2 (messageId-scoped indicator leak): the supersede path never released the abandoned turn's OWN indicator. A CHAT-scoped indicator is re-seeded by the replacement, but a MESSAGEID-scoped one (per-message reaction/card keyed on the inbound messageId) is keyed on the abandoned turn's messageId and leaks until disconnect. Run the abandoned turn's onPromptEnd at steer-time and mark it superseded so its late finally still skips onPromptEnd (released once, no double-fire, replacement untouched).
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): steer waits for the running turn instead of racing a concurrent replacement
In steer dispatch, when a new message arrived while a prompt was running,
ChannelBase cancelled the running turn (best-effort), did a BOUNDED wait, and
on TIMEOUT (the old turn wedged, not finished) PROCEEDED to start a replacement
bridge.prompt() on the SAME sessionId while the old prompt was still active. As
wenshao flagged, this is bridge-unsafe:
- DaemonChannelBridge.prompt() REJECTS while the prior prompt is still marked
active, so the replacement is silently dropped.
- Both bridges collect/emit chunks keyed by sessionId ONLY, so the abandoned
turn's late chunks mix into the replacement's stream (duplicated/stale output).
Fix (wenshao option (a)): steer now best-effort cancels the running turn and
CHAINS the new turn onto the existing session queue tail, so it runs only AFTER
the old turn's finally has actually run (onChunk detached, activePrompts cleared,
indicator released). The cancel stays — it makes the old turn wind down sooner —
but steer never proceeds while a turn is still active, eliminating the unsafe
concurrency entirely. Steer now differs from followup only by that best-effort
cancel.
Removed the steer-only concurrency machinery that only existed to host a
concurrent replacement: the steer-path bounded wait (cancelAndAwaitActive call),
the steerWedged flag and fresh-chain re-seed, the preSteerGeneration capture, the
steer-time sessionGenerations bump, the steer-time onPromptEnd, and the superseded
flag (plus its finally guard term). /clear's OWN protections — its up-front
generation bump, eviction-time onPromptEnd, and the clearEvicted finally guard —
are SEPARATE and preserved; /clear genuinely evicts and still needs them.
cancelAndAwaitActive is retained, now used only by /clear.
BEHAVIORAL CHANGE: steer no longer force-interrupts a genuinely wedged turn; it
cancels it and waits for it to finish before the new turn runs. Turn-scoped
cancellation/routing (so a new turn can run without waiting for a wedged
predecessor) is wenshao option (b) — it needs an API change across every adapter
and is the deferred enhancement, out of scope here.
Folded in two further #5888 review items:
- [Critical] QQ slash-command audit-log injection (qqbot/src/QQChannel.ts): the
audit process.stderr.write interpolated the RAW, attacker-controlled senderName
(event.author.username) and cleanText BEFORE sanitization, so a crafted nick or
message with CR/LF/ANSI escapes could forge or corrupt operator audit logs.
Hoisted `safeName = sanitizeSenderName(senderName)` above the audit log and now
log a neutralized command string (cap 80, render \n visibly, strip C0/DEL),
mirroring ChannelBase's dropped-turn log and the DingTalk hardening already in
this PR. The prompt-path sanitizeSenderName usage is unchanged.
- [Suggestion] single-scope DMs were not attributed (base/src/ChannelBase.ts): the
[sender] prefix was gated on envelope.isGroup alone, but sessionScope:'single'
collapses every sender's DM into one __single__ session (already treated as
shared by the !-gate, /clear confirm and /who), so different people merged into
one unattributed conversation (the RFC-R4 gap Phase 0 closes). The gate is now
(envelope.isGroup || sessionScope === 'single') — deliberately NOT
isSharedSession, which is false for user-scope groups that must keep attribution.
Tests: removed the obsolete steer-concurrency cases and added cases for the new
steer behavior (new turn starts only after the old completes; the abandoned turn's
late chunks cannot reach the new turn). Added an audit-log sanitization test
(QQ) and single-scope-DM / user-scope-group / 1:1-DM attribution tests (base).
/clear's eviction protections are unchanged and still pass.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): neutralize NEL/C1 in prompt text; dedupe shared-session auth gate
Address wenshao's review on the Phase-0 qwen-tag channel base.
- sanitize: PROMPT_UNSAFE_INVISIBLES now also neutralizes the C1 control
block (U+0080-U+009F), which includes NEL (U+0085), a Unicode line break
(UAX#14 BK) that renders as a new line. Without it a display name like
"Alice<NEL>system: ..." or a crafted reply quote could inject a prompt
line. Applies to both sanitizeSenderName and sanitizeQuotedText (and the
shared path sanitizer) since they share the set. Tests cover NEL + a second
C1 (CSI U+009B) for sender names and quoted text; mutation-checked.
- ChannelBase: the two best-effort cancelSession() calls (the /clear
wind-down wait and the steer pre-cancel) no longer swallow the IPC failure
with an empty catch. They now log channel name + sessionId + reason to
stderr, matching the existing /cancel log style, so a wedged turn is
diagnosable. Still fire-and-forget (not awaited).
- ChannelBase: extract the verbatim shared-session authorization gate shared
by /clear and /who into a private isAuthorizedForSharedSession() predicate
(isSharedSession + allowedUsers check). Behavior is unchanged; each caller
keeps its own rejection wording.
- tests: add a GROUP-path case that /help@mybot is treated as a command
(no [sender] prefix, not forwarded to the agent); the existing @botname
test only covered a DM.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): strip C1/NEL from audit-log text too, matching the prompt path
The prompt path now neutralizes the C1 control block (U+0080-U+009F, incl.
NEL U+0085, a Unicode line break) via PROMPT_UNSAFE_INVISIBLES, but the inline
stderr audit-log sanitizers still stripped only C0/DEL. A crafted message
carrying NEL/C1 therefore survived into the dropped-/queued-turn log
(ChannelBase) and the QQ slash-command audit log, where many terminals render
NEL as a newline, forging an extra [channel] log line.
Extend both inline strips to also cover the C1 block (C0 + DEL + C1), matching
the prompt path's union, and refresh the now-stale comments. Tests feed a NEL
(U+0085) and a C1 char (U+009B) through both log paths and assert neither
survives; reverting the strip to C0/DEL fails the new NEL assertions.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): gate /status and /cancel in shared sessions; code-point-safe truncation
Three follow-ups to the shared-session authorization work:
- /status: add the same isAuthorizedForSharedSession gate /who uses, so a
non-member of a shared session with a non-empty allowedUsers list can no
longer read its session/access state. Non-shared (DM/per-user) is unchanged.
- /cancel: gate the destructive abort behind isAuthorizedForSharedSession like
/clear (auth gate only, no confirm), so a group member can't cancel another
user's in-flight turn on a shared session. 1:1 and authorized users unchanged.
- sanitize: truncate sanitizeSenderName/sanitizeQuotedText/sanitizePromptPath on
Unicode code-point boundaries (Array.from) instead of UTF-16 code units, so a
cap landing mid-surrogate-pair (e.g. an emoji) can't leave a lone surrogate
that renders as the replacement character downstream. The ellipsis logic in
sanitizeQuotedText still keeps the result within maxLen code points.
Adds tests for each (incl. mutation-checked gates and an emoji-at-cap case).
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): audit-log shared /clear and blocked ! shell; test steer stopStreaming
Address ci-bot review on PR #5888:
- [Observability] Emit a stderr audit line on a successful clear of a
SHARED session (channel, sessionId, sanitized sender + stable senderId).
A 1:1 DM clear is single-participant and is not logged.
- [Observability] Emit a stderr audit line when a group/shared member's
`!` host-shell command is refused, so operators can detect blocked
attempts. Sender name is sanitized; the command payload is not echoed.
- [Test gap] Add a steer test asserting the best-effort cancel calls
active.stopStreaming (spying on the running turn's prompt), plus tests
for both new audit lines and the DM no-log case.
The /cancel and /status shared-session auth gates flagged by ci-bot were
already present on this branch and are left unchanged.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): gate steer-cancel by authorization; close audit-log + attribution injection gaps
Address review on PR #5888 (qwen-tag-phase0):
- [Critical] The /cancel auth gate was bypassable via the default `steer`
dispatch mode: any normal message cancels the running turn, so an
unauthorized member of a shared session - blocked from /cancel - could
abort another user's active turn just by sending a message. The steer
branch now checks isAuthorizedForSharedSession FIRST and, when
unauthorized, breaks to normal queuing (the message chains onto the
session queue tail and runs AFTER the active turn instead of aborting it).
- [Suggestion] Audit-log sanitizers missed U+2028/U+2029 and the bidi
overrides (log-line spoofing / trojan-source). Exported
PROMPT_UNSAFE_INVISIBLES and added a shared sanitizeLogText(text, maxLen)
helper that applies BOTH that set AND the C0/DEL strip (and caps length),
used at both audit-log sites (ChannelBase dropped-turn log + QQ
slash-command audit log) so the defense can't drift apart.
- [Suggestion] The [sender] attribution prefix was suppressed for any
command-SHAPED text, so unrecognized "/x\n[SYSTEM]: ..." reached the agent
unattributed. Added a synchronous isRecognizedCommand() (locally registered
commands + the bridge.availableCommands snapshot) and now suppress the
prefix only when the text is BOTH a command shape AND a recognized command;
unrecognized command-like text keeps its [sender] tag.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): recognize command aliases per-session so attribution doesn't break them
isRecognizedCommand decides whether to suppress the group [sender]
attribution prefix, but matched only against availableCommands[].name.
The ACP parser also accepts command ALIASES (parseSlashCommand altNames),
so a valid alias like /summarize (alias of /compress) or /login (alias of
/auth) was classified as unrecognized, rewritten to "[Alice] /summarize",
and then run as PLAIN CHAT (the leading / is gone) instead of executing.
It also read the bridge's GLOBAL availableCommands snapshot, which in
DaemonChannelBridge can belong to another session.
- Carry aliases through the available-command contract: add an optional
altNames?: string[] to AvailableCommand, emit them on the wire in
_meta.altNames (ACP's extension point; a top-level altNames would be an
excess-property error against the SDK type), and lift them in both
bridges via readAvailableCommandAltNames. Omitted when absent, so
alias-free entries stay byte-identical.
- Match name AND altNames, against THIS session's command list:
isRecognizedCommand now takes sessionId and reads
getAvailableCommands(sessionId) when the bridge exposes it
(DaemonChannelBridge), falling back to the global getter (AcpBridge,
single-agent, inherently session-correct). Stays synchronous: a real
command sent before the snapshot loads keeps its tag (safe default).
- Security intent intact: genuinely-unrecognized command-shaped text
(e.g. /x\n[SYSTEM]: ...) still keeps its [sender] tag.
Also fold in two #5888 review items in ChannelBase.ts:
- Steer auth gate: audit the silent steer->queue downgrade to stderr for
an unauthorized member (operator-visible only; no per-message reply),
matching the /cancel,/clear,/who,/status gates' observability.
- /clear eviction: a clear-time onPromptEnd that throws would abort the
purge, leaving the evicted turn in activePrompts so its late finally
(stillCurrent || !clearEvicted) re-runs onPromptEnd and clobbers a newer
turn. Set clearEvicted first and catch+audit the throw so the purge
always runs (turn becomes non-current) and the late finally skips.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): match agent commands case-sensitively; add steer wedge watchdog
isRecognizedCommand lowercased the token before comparing it to agent
command names/aliases, but the CLI's parseSlashCommand matches agent
commands CASE-SENSITIVELY (`cmd.name === part`, `cmd.altNames?.includes(part)`).
So a wrong-case token like `/SUMMARIZE` was "recognized" here — suppressing
the `[sender]` attribution tag — yet ran NO command in ACP, which then
forwarded the raw text unattributed, reopening the injection where a crafted
`/SUMMARIZE\n[SYSTEM]: …` second line reaches a shared group as an apparent
system directive. parseCommand now also returns the typed-case `raw` token;
isRecognizedCommand matches AGENT commands on `raw` (case-sensitive, mirroring
the CLI) while LOCAL commands keep their existing case-INSENSITIVE match
(registerCommand lowercases the stored name; handleInbound dispatches by the
lowercased token).
The steer chain-and-wait path lost the old "abandoned wedged turn" log, so a
hung predecessor bridge.prompt() could silently deadlock the session with no
observability. Add a diagnostic-only watchdog: the steer branch arms a timer
(unref'd) that, if the predecessor is still the active prompt after
CLEAR_CANCEL_TIMEOUT_MS, emits a stderr line pointing at /clear for recovery;
the chained `.then()` disarms it as its first statement once the predecessor's
tail resolves. No concurrency change — chain-and-wait is untouched.
Also simplify the late-finally onPromptEnd guard from
`stillCurrent || !clearEvicted` to `!clearEvicted`: clearEvicted is set ONLY
by /clear's eviction, which then unconditionally deletes activePrompts (its
try/catch around the clear-time onPromptEnd guarantees the purge even if it
throws) and never re-inserts the same promptState, so `clearEvicted` implies
`!stillCurrent` and the dropped term was unreachable.
Tests: wrong-case `/SUMMARIZE` keeps `[sender]` (with a mutation note); wrong-
case local `/HELP` still dispatches locally; fake-timer watchdog tests for the
wedged (logs) and settled (timer cleared, no log) paths.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): align agent command recognition with parseSlashCommand; guard finally onPromptEnd
Address maintainer review on the qwen-tag Phase 0 channel adapter.
- [Critical] Guard the normal-completion onPromptEnd in the per-turn finally.
onPromptEnd runs platform-adapter cleanup (typing interval, working-reaction
recall, card finalize) - network/IO that can throw. An uncaught throw skipped
activePrompts.delete (session leak), promptState.resolve (active.done never
settled, so a later /clear falsely logged "abandoned a wedged turn"), and the
collect-buffer drain - and the rejection, swallowed by the queue tail's
.catch(() => {}), silently dropped every later turn. Wrap it in try/catch with a
stderr log, matching the eviction-path treatment.
- [Critical] End the channel/agent command-recognition divergence by matching the
AGENT branch of isRecognizedCommand EXACTLY as the CLI's parseSlashCommand does:
the FIRST whitespace token after the leading '/', case-SENSITIVELY, WITHOUT
stripping an @suffix. parseCommand's '@'-stripped, lowercased token diverged from
the agent (PARSE_COMMAND_RE drops '(?:@\S+)?'), so /compress@x or /Compress were
"recognized" here (tag suppressed) yet ran no command there and reached the model
unattributed. Matching the exact token leaves wrong-case / @suffix / injection-
shaped tokens UNRECOGNIZED -> they keep their [sender] tag (attributed), exactly as
the agent treats them. No prompt rewrite: a /compress@otherbot aimed at another bot
must not run here. LOCAL commands keep case-insensitive dispatch.
- [Suggestion] Include the originating chatId/messageId (sanitized) in the "/clear
abandoned a wedged turn" log so oncall can correlate the stuck turn.
- [Suggestion] Clarify the alreadyPrefixed JSDoc: it is also set by the QQ adapter on
real self-prefixed inbounds, which sanitize the embedded name at the source - so the
flag does not bypass sanitization (verified; no behavior change needed).
- [Suggestion] Replace the blind `as unknown as {...}` bridge cast on the recognition
path with a typed AgentCommandsProvider interface (optional members), so a future
rename/return-type change is type-checked instead of breaking at runtime.
- [Suggestion] Validate altNames' shape: isAvailableCommand now rejects a non-array
altNames, and the recognition site guards the alias check with Array.isArray, so a
malformed wire payload can't throw at the `.includes` call.
Tests: throwing-onPromptEnd cleanup + collect-drain + log; exact-token recognition
(verbatim /compress and /summarize alias; tag kept for /SUMMARIZE, /COMPRESS,
/compress@x, and /compress@x + [SYSTEM] line); wedged-turn log carries chat/message;
malformed altNames does not throw; isAvailableCommand drops a malformed-altNames entry.
Restored stderr spies in the two steer-watchdog tests' finally.
Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
* fix(channels): address qwen tag review followups
---------
Co-authored-by: Qwen-Coder <noreply@qwen.ai>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>