@qwen-code/channel-base 0.19.7 → 0.19.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { basename, join } from 'node:path';
2
2
  import { BlockStreamer } from './BlockStreamer.js';
3
3
  import { GroupGate } from './GroupGate.js';
4
+ import { DmGate } from './DmGate.js';
4
5
  import { GroupHistoryStore } from './group-history-store.js';
5
6
  import { SenderGate } from './SenderGate.js';
6
7
  import { PairingStore } from './PairingStore.js';
@@ -53,6 +54,7 @@ export class ChannelBase {
53
54
  config;
54
55
  bridge;
55
56
  groupGate;
57
+ dmGate;
56
58
  gate;
57
59
  router;
58
60
  name;
@@ -88,6 +90,16 @@ export class ChannelBase {
88
90
  bridgeSessionDiedListener = (event) => {
89
91
  this.onSessionDied(event.sessionId);
90
92
  };
93
+ bridgePermissionRequestListener = (event) => {
94
+ void this.dispatchPermissionRequest(event).catch((err) => {
95
+ process.stderr.write(`[${this.name}] permission relay failed for request ${sanitizeLogText(event.requestId, 128)}: ${this.lifecycleError(err)}\n`);
96
+ });
97
+ };
98
+ bridgePermissionResolvedListener = (event) => {
99
+ this.dispatchPermissionResolved(event);
100
+ };
101
+ pendingPermissions = new Map();
102
+ pendingPermissionsByChat = new Map();
91
103
  channelLoopToolHandler = {
92
104
  canHandle: (sessionId) => this.router.getTarget(sessionId)?.channelName === this.name,
93
105
  create: (sessionId, input) => this.createLoopFromTool(sessionId, input),
@@ -119,6 +131,83 @@ export class ChannelBase {
119
131
  }
120
132
  this.onToolCall(chatId, event);
121
133
  }
134
+ async dispatchPermissionRequest(event) {
135
+ const target = this.permissionTargetForEvent(event);
136
+ if (!target) {
137
+ try {
138
+ await this.bridge.respondToPermission?.(event.requestId, {
139
+ outcome: { outcome: 'cancelled' },
140
+ });
141
+ }
142
+ catch (respondErr) {
143
+ process.stderr.write(`[${this.name}] permission cancellation failed for request ${sanitizeLogText(event.requestId, 128)}: ${this.lifecycleError(respondErr)}\n`);
144
+ }
145
+ return;
146
+ }
147
+ this.removePendingPermission(event.requestId);
148
+ const pending = {
149
+ requestId: event.requestId,
150
+ sessionId: event.sessionId,
151
+ target,
152
+ request: event.request,
153
+ };
154
+ this.pendingPermissions.set(event.requestId, pending);
155
+ const chatKey = this.permissionChatKey(target);
156
+ const requestIds = this.pendingPermissionsByChat.get(chatKey) ?? [];
157
+ requestIds.push(event.requestId);
158
+ this.pendingPermissionsByChat.set(chatKey, requestIds);
159
+ try {
160
+ const text = this.formatPermissionRequest(pending);
161
+ if (target.threadId !== undefined &&
162
+ this.supportsProactiveSend() &&
163
+ this.supportsProactiveTarget(target)) {
164
+ await this.pushProactive(target, text);
165
+ }
166
+ else {
167
+ await this.sendMessage(target.chatId, text);
168
+ }
169
+ }
170
+ catch (err) {
171
+ this.removePendingPermission(event.requestId);
172
+ try {
173
+ await this.bridge.respondToPermission?.(event.requestId, {
174
+ outcome: { outcome: 'cancelled' },
175
+ });
176
+ }
177
+ catch (respondErr) {
178
+ process.stderr.write(`[${this.name}] permission cancellation failed for request ${sanitizeLogText(event.requestId, 128)}: ${this.lifecycleError(respondErr)}\n`);
179
+ }
180
+ throw err;
181
+ }
182
+ }
183
+ permissionTargetForEvent(event) {
184
+ const routeTarget = this.router.getTarget(event.sessionId);
185
+ if (!routeTarget || routeTarget.channelName !== this.name) {
186
+ return undefined;
187
+ }
188
+ const active = this.activePrompts.get(event.sessionId);
189
+ if (!active) {
190
+ return routeTarget;
191
+ }
192
+ const target = {
193
+ channelName: routeTarget.channelName,
194
+ senderId: active.senderId ?? routeTarget.senderId,
195
+ chatId: active.chatId,
196
+ };
197
+ if (active.threadId !== undefined) {
198
+ target.threadId = active.threadId;
199
+ }
200
+ if (active.isGroup !== undefined) {
201
+ target.isGroup = active.isGroup;
202
+ }
203
+ else if (routeTarget.isGroup !== undefined) {
204
+ target.isGroup = routeTarget.isGroup;
205
+ }
206
+ return target;
207
+ }
208
+ dispatchPermissionResolved(event) {
209
+ this.removePendingPermission(event.requestId);
210
+ }
122
211
  constructor(name, config, bridge, options) {
123
212
  this.name = name;
124
213
  this.config = config;
@@ -132,6 +221,7 @@ export class ChannelBase {
132
221
  join(getGlobalQwenDir(), 'channels', `${encodeURIComponent(name)}-group-history.jsonl`));
133
222
  this.loopController = options?.loopController;
134
223
  this.groupGate = new GroupGate(config.groupPolicy, config.groups);
224
+ this.dmGate = new DmGate(config.dmPolicy);
135
225
  const pairingStore = config.senderPolicy === 'pairing' ? new PairingStore(name) : undefined;
136
226
  this.gate = new SenderGate(config.senderPolicy, config.allowedUsers, pairingStore);
137
227
  this.router =
@@ -261,6 +351,7 @@ export class ChannelBase {
261
351
  if (this.registerBridgeEvents) {
262
352
  this.detachBridgeEvents(this.bridge);
263
353
  }
354
+ this.clearPendingPermissions();
264
355
  this.router.setBridge(bridge);
265
356
  this.bridge = bridge;
266
357
  if (this.loopController) {
@@ -360,6 +451,8 @@ export class ChannelBase {
360
451
  done,
361
452
  resolve: doneResolve,
362
453
  chatId: job.target.chatId,
454
+ threadId: job.target.threadId,
455
+ isGroup: job.target.isGroup,
363
456
  messageId: job.id,
364
457
  senderId: job.target.senderId,
365
458
  senderName: job.createdBy,
@@ -667,14 +760,19 @@ export class ChannelBase {
667
760
  onSessionDied(sessionId) {
668
761
  this.router.removeSessionId(sessionId);
669
762
  this.instructedSessions.delete(sessionId);
763
+ this.removePendingPermissionsForSession(sessionId);
670
764
  }
671
765
  attachBridgeEvents(bridge) {
672
766
  bridge.on('toolCall', this.bridgeToolCallListener);
673
767
  bridge.on('sessionDied', this.bridgeSessionDiedListener);
768
+ bridge.on('permissionRequest', this.bridgePermissionRequestListener);
769
+ bridge.on('permissionResolved', this.bridgePermissionResolvedListener);
674
770
  }
675
771
  detachBridgeEvents(bridge) {
676
772
  bridge.off('toolCall', this.bridgeToolCallListener);
677
773
  bridge.off('sessionDied', this.bridgeSessionDiedListener);
774
+ bridge.off('permissionRequest', this.bridgePermissionRequestListener);
775
+ bridge.off('permissionResolved', this.bridgePermissionResolvedListener);
678
776
  }
679
777
  /**
680
778
  * Called when a prompt actually begins processing (inside the session queue).
@@ -741,6 +839,217 @@ export class ChannelBase {
741
839
  return true;
742
840
  });
743
841
  }
842
+ permissionChatKey(target) {
843
+ return `${target.chatId}\0${target.threadId ?? ''}`;
844
+ }
845
+ pendingPermissionIdsForChatKey(chatKey) {
846
+ const requestIds = this.pendingPermissionsByChat.get(chatKey);
847
+ if (!requestIds) {
848
+ return [];
849
+ }
850
+ const live = requestIds.filter((id) => this.pendingPermissions.has(id));
851
+ if (live.length === 0) {
852
+ this.pendingPermissionsByChat.delete(chatKey);
853
+ }
854
+ else if (live.length !== requestIds.length) {
855
+ this.pendingPermissionsByChat.set(chatKey, live);
856
+ }
857
+ return live;
858
+ }
859
+ removePendingPermission(requestId) {
860
+ const pending = this.pendingPermissions.get(requestId);
861
+ if (!pending) {
862
+ return;
863
+ }
864
+ this.pendingPermissions.delete(requestId);
865
+ const chatKey = this.permissionChatKey(pending.target);
866
+ const requestIds = this.pendingPermissionsByChat.get(chatKey);
867
+ if (!requestIds) {
868
+ return;
869
+ }
870
+ const remaining = requestIds.filter((id) => id !== requestId);
871
+ if (remaining.length === 0) {
872
+ this.pendingPermissionsByChat.delete(chatKey);
873
+ }
874
+ else {
875
+ this.pendingPermissionsByChat.set(chatKey, remaining);
876
+ }
877
+ }
878
+ removePendingPermissionsForSession(sessionId) {
879
+ const requestIds = Array.from(this.pendingPermissions)
880
+ .filter(([, pending]) => pending.sessionId === sessionId)
881
+ .map(([requestId]) => requestId);
882
+ for (const requestId of requestIds) {
883
+ this.removePendingPermission(requestId);
884
+ }
885
+ }
886
+ clearPendingPermissions() {
887
+ this.pendingPermissions.clear();
888
+ this.pendingPermissionsByChat.clear();
889
+ }
890
+ pendingPermissionForEnvelope(envelope, args) {
891
+ const trimmed = args.trim();
892
+ if (trimmed) {
893
+ const explicit = this.pendingPermissions.get(trimmed);
894
+ if (explicit &&
895
+ this.canEnvelopeAnswerPendingPermission(envelope, explicit)) {
896
+ return { kind: 'found', pending: explicit };
897
+ }
898
+ return { kind: 'none', explicit: true };
899
+ }
900
+ const requestIds = this.pendingPermissionIdsForChatKey(this.permissionChatKey(envelope));
901
+ if (requestIds.length === 0) {
902
+ return { kind: 'none', explicit: false };
903
+ }
904
+ const matching = requestIds
905
+ .map((id) => this.pendingPermissions.get(id))
906
+ .filter((pending) => pending !== undefined &&
907
+ this.canEnvelopeAnswerPendingPermission(envelope, pending));
908
+ if (matching.length === 0) {
909
+ return { kind: 'none', explicit: false };
910
+ }
911
+ if (matching.length > 1) {
912
+ return {
913
+ kind: 'ambiguous',
914
+ requestIds: matching.map((pending) => pending.requestId),
915
+ };
916
+ }
917
+ return { kind: 'found', pending: matching[0] };
918
+ }
919
+ canEnvelopeAnswerPendingPermission(envelope, pending) {
920
+ return (pending.target.chatId === envelope.chatId &&
921
+ pending.target.threadId === envelope.threadId &&
922
+ (this.isSharedSessionTarget(pending.target) ||
923
+ pending.target.senderId === envelope.senderId));
924
+ }
925
+ formatPermissionRequest(pending) {
926
+ const { toolCall } = pending.request;
927
+ const title = sanitizeQuotedText(toolCall.title || 'Tool use', 160);
928
+ const alwaysOption = this.approvalAlwaysOption(pending);
929
+ const replies = [
930
+ '/approve allow once',
931
+ ...(alwaysOption ? [`/approve-always ${alwaysOption.label}`] : []),
932
+ '/deny deny',
933
+ ];
934
+ const lines = [
935
+ 'Permission required to run a tool',
936
+ '',
937
+ 'Command:',
938
+ title,
939
+ '',
940
+ 'Reply with:',
941
+ ...replies,
942
+ ];
943
+ return lines.join('\n');
944
+ }
945
+ approvalOptionId(pending) {
946
+ const options = pending.request.options;
947
+ return (options.find((option) => option.kind === 'allow_once')?.optionId ??
948
+ options.find((option) => option.optionId === 'proceed_once' &&
949
+ option.kind === undefined)?.optionId);
950
+ }
951
+ approvalAlwaysOption(pending) {
952
+ const options = pending.request.options.filter((option) => option.kind === 'allow_always');
953
+ const option = this.findScopedAlwaysOption(options, 'project') ??
954
+ this.findScopedAlwaysOption(options, 'user') ??
955
+ options[0];
956
+ if (!option) {
957
+ return undefined;
958
+ }
959
+ return {
960
+ optionId: option.optionId,
961
+ label: this.approvalAlwaysLabel(option),
962
+ };
963
+ }
964
+ findScopedAlwaysOption(options, scope) {
965
+ return options.find((option) => option.optionId === `proceed_always_${scope}`);
966
+ }
967
+ approvalAlwaysLabel(option) {
968
+ if (option.optionId === 'proceed_always_project') {
969
+ return 'always allow for this project';
970
+ }
971
+ if (option.optionId === 'proceed_always_user') {
972
+ return 'always allow for this user';
973
+ }
974
+ return 'always allow';
975
+ }
976
+ denialResponse(pending) {
977
+ const option = pending.request.options.find((candidate) => candidate.kind === 'reject_once') ??
978
+ pending.request.options.find((candidate) => candidate.optionId === 'cancel' &&
979
+ candidate.kind === undefined);
980
+ if (option) {
981
+ return { outcome: { outcome: 'selected', optionId: option.optionId } };
982
+ }
983
+ return { outcome: { outcome: 'cancelled' } };
984
+ }
985
+ async handlePermissionResponseCommand(envelope, args, decision) {
986
+ if (!this.isAuthorizedForSharedSession(envelope)) {
987
+ await this.sendMessage(envelope.chatId, 'Only authorized members can answer permission requests in this shared session.');
988
+ return true;
989
+ }
990
+ const lookup = this.pendingPermissionForEnvelope(envelope, args);
991
+ if (lookup.kind === 'ambiguous') {
992
+ const requestList = lookup.requestIds
993
+ .slice(0, 6)
994
+ .map((id) => {
995
+ const pending = this.pendingPermissions.get(id);
996
+ const title = pending
997
+ ? `: ${sanitizeQuotedText(pending.request.toolCall.title || 'Tool use', 160)}`
998
+ : '';
999
+ return `- ${sanitizeQuotedText(id, 128)}${title}`;
1000
+ })
1001
+ .join('\n');
1002
+ await this.sendMessage(envelope.chatId, `Multiple permission requests are pending for this chat. Reply with /${decision} <request-id>.\n${requestList}`);
1003
+ return true;
1004
+ }
1005
+ if (lookup.kind === 'none') {
1006
+ await this.sendMessage(envelope.chatId, lookup.explicit
1007
+ ? 'No pending permission request with that id for this chat.'
1008
+ : 'No pending permission request for this chat.');
1009
+ return true;
1010
+ }
1011
+ if (!this.bridge.respondToPermission) {
1012
+ await this.sendMessage(envelope.chatId, 'Permission relay is not available for this session.');
1013
+ return true;
1014
+ }
1015
+ const { pending } = lookup;
1016
+ const response = (() => {
1017
+ if (decision === 'deny') {
1018
+ return this.denialResponse(pending);
1019
+ }
1020
+ const optionId = decision === 'approve'
1021
+ ? this.approvalOptionId(pending)
1022
+ : this.approvalAlwaysOption(pending)?.optionId;
1023
+ return optionId
1024
+ ? { outcome: { outcome: 'selected', optionId } }
1025
+ : undefined;
1026
+ })();
1027
+ if (!response) {
1028
+ await this.sendMessage(envelope.chatId, decision === 'approve-always'
1029
+ ? 'This permission request has no always-allow option.'
1030
+ : 'This permission request has no approvable option.');
1031
+ return true;
1032
+ }
1033
+ let accepted;
1034
+ try {
1035
+ accepted = await this.bridge.respondToPermission(pending.requestId, response);
1036
+ }
1037
+ catch (err) {
1038
+ this.removePendingPermission(pending.requestId);
1039
+ process.stderr.write(`[${this.name}] permission response failed for request ${sanitizeLogText(pending.requestId, 128)}: ${this.lifecycleError(err)}\n`);
1040
+ await this.sendMessage(envelope.chatId, 'Failed to answer the permission request.');
1041
+ return true;
1042
+ }
1043
+ this.removePendingPermission(pending.requestId);
1044
+ await this.sendMessage(envelope.chatId, accepted
1045
+ ? decision === 'approve'
1046
+ ? 'Permission approved.'
1047
+ : decision === 'approve-always'
1048
+ ? 'Permission approved always.'
1049
+ : 'Permission denied.'
1050
+ : 'Permission request is no longer pending.');
1051
+ return true;
1052
+ }
744
1053
  /** Register shared slash commands. Called from constructor. */
745
1054
  registerSharedCommands() {
746
1055
  const doClear = async (envelope) => {
@@ -760,6 +1069,7 @@ export class ChannelBase {
760
1069
  // already queued onto this session sees a stale generation and bails
761
1070
  // instead of running bridge.prompt() against the cleared session.
762
1071
  this.sessionGenerations.set(id, (this.sessionGenerations.get(id) ?? 0) + 1);
1072
+ this.removePendingPermissionsForSession(id);
763
1073
  // Cancel an in-flight turn (and drop its buffered follow-ups) before
764
1074
  // purging, so a running prompt can't deliver a stale response into —
765
1075
  // or resurrect via collect-drain — the just-cleared session.
@@ -858,6 +1168,9 @@ export class ChannelBase {
858
1168
  this.registerCommand('clear', clearHandler);
859
1169
  this.registerCommand('reset', clearHandler);
860
1170
  this.registerCommand('new', clearHandler);
1171
+ this.registerCommand('approve', (envelope, args) => this.handlePermissionResponseCommand(envelope, args, 'approve'));
1172
+ this.registerCommand('approve-always', (envelope, args) => this.handlePermissionResponseCommand(envelope, args, 'approve-always'));
1173
+ this.registerCommand('deny', (envelope, args) => this.handlePermissionResponseCommand(envelope, args, 'deny'));
861
1174
  this.registerCommand('remember-channel', async (envelope, args) => {
862
1175
  const text = args.trim();
863
1176
  if (text === '') {
@@ -936,6 +1249,9 @@ export class ChannelBase {
936
1249
  : '/clear — Clear your session (aliases: /reset, /new)',
937
1250
  '/who — Show current session & workspace',
938
1251
  '/status — Show session info',
1252
+ '/approve [request-id] — Approve a pending permission request',
1253
+ '/approve-always [request-id] — Always approve a pending permission request',
1254
+ '/deny [request-id] — Deny a pending permission request',
939
1255
  ];
940
1256
  // Platform-specific commands (registered by adapters, not shared ones)
941
1257
  const sharedCmds = new Set([
@@ -943,6 +1259,9 @@ export class ChannelBase {
943
1259
  'clear',
944
1260
  'reset',
945
1261
  'new',
1262
+ 'approve',
1263
+ 'approve-always',
1264
+ 'deny',
946
1265
  'remember-channel',
947
1266
  'channel-memory',
948
1267
  'forget-channel',
@@ -1309,6 +1628,7 @@ export class ChannelBase {
1309
1628
  isReplyToBot: true,
1310
1629
  };
1311
1630
  return (this.groupGate.check(envelope).allowed &&
1631
+ this.dmGate.check(envelope).allowed &&
1312
1632
  this.gate.isAllowed(normalizedTarget.senderId) &&
1313
1633
  this.isAuthorizedForSharedSession(envelope));
1314
1634
  }
@@ -1800,6 +2120,10 @@ export class ChannelBase {
1800
2120
  }
1801
2121
  return false;
1802
2122
  }
2123
+ const dmResult = this.dmGate.check(envelope);
2124
+ if (!dmResult.allowed) {
2125
+ return false;
2126
+ }
1803
2127
  const result = this.gate.check(envelope.senderId, envelope.senderName);
1804
2128
  if (!result.allowed) {
1805
2129
  if (result.pairingCode !== undefined) {
@@ -2170,6 +2494,8 @@ export class ChannelBase {
2170
2494
  done,
2171
2495
  resolve: doneResolve,
2172
2496
  chatId: envelope.chatId,
2497
+ threadId: envelope.threadId,
2498
+ isGroup: envelope.isGroup,
2173
2499
  messageId: envelope.messageId,
2174
2500
  senderId: envelope.senderId,
2175
2501
  senderName: envelope.senderName,