@modesto_ma/dsh-channels 0.4.0 → 0.5.0-beta.1

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.
Files changed (58) hide show
  1. package/README.md +73 -55
  2. package/docs/access-control-design.md +7 -5
  3. package/docs/attachment-pipeline-v2.md +33 -41
  4. package/docs/feishu-onboarding.md +33 -33
  5. package/docs/persistence.md +6 -4
  6. package/docs/refactor-plan.md +1 -1
  7. package/docs/workspace-isolation-design.md +7 -8
  8. package/lib/client.js +173 -287
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +3001 -2627
  11. package/lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/access/cards.ts +9 -9
  14. package/src/access/controller.ts +10 -10
  15. package/src/access/index.ts +1 -0
  16. package/src/access/policy.ts +10 -10
  17. package/src/access/requests.ts +32 -31
  18. package/src/access/store.ts +12 -12
  19. package/src/access/targets.ts +56 -0
  20. package/src/cards/approval.ts +96 -26
  21. package/src/cards/index.ts +1 -0
  22. package/src/cards/onboarding.ts +1 -0
  23. package/src/cards/picker.ts +115 -0
  24. package/src/cards/streaming.ts +56 -7
  25. package/src/client/api.ts +5 -25
  26. package/src/client/panel/access-card.tsx +24 -15
  27. package/src/client/panel/access-tab.tsx +3 -3
  28. package/src/client/panel/connection-tab.tsx +70 -1
  29. package/src/client/panel/index.tsx +11 -62
  30. package/src/client/panel/shared.tsx +0 -23
  31. package/src/client/panel/workspace-tab.tsx +2 -18
  32. package/src/client/styles.ts +2 -0
  33. package/src/commands/registry.ts +2 -2
  34. package/src/config.ts +25 -56
  35. package/src/domain.ts +28 -125
  36. package/src/feishu/inbound-attachments.ts +1 -1
  37. package/src/feishu/manifest.ts +8 -31
  38. package/src/feishu/message-read.ts +19 -22
  39. package/src/feishu/scopes.ts +171 -0
  40. package/src/feishu/send-file.ts +543 -0
  41. package/src/feishu/slash-commands.ts +134 -0
  42. package/src/feishu/tools.ts +72 -112
  43. package/src/feishu-channel.ts +270 -9
  44. package/src/index.ts +5 -4
  45. package/src/interactions/approvals.ts +187 -7
  46. package/src/interactions/pending.ts +3 -3
  47. package/src/routing/inbound.ts +73 -106
  48. package/src/service.ts +463 -438
  49. package/src/session/manager.ts +1 -1
  50. package/src/session/routes.ts +2 -3
  51. package/src/settings/contract.ts +68 -85
  52. package/src/settings/routes.ts +3 -17
  53. package/src/settings/surface.ts +37 -88
  54. package/src/streamer.ts +94 -5
  55. package/src/workspaces.ts +21 -29
  56. package/src/cleanup.ts +0 -546
  57. package/src/client/panel/cleanup-card.tsx +0 -106
  58. package/src/feishu/pending-attachments.ts +0 -562
@@ -14,21 +14,19 @@
14
14
  * against a per-route whitelist of messages the channel has already admitted
15
15
  * as relevant:
16
16
  *
17
- * - the triggering message / a quoted message registered by the admission and
18
- * quote layers through {@link AttachmentFetchToolRuntime.noteKnownMessage};
19
- * - silently registered attachment messages from the `pending_attachments`
20
- * registry (same `(chat, thread?, sender)` scope rules);
17
+ * - the triggering message, a quoted message, and history rows, registered by
18
+ * the inbound layer through {@link AttachmentFetchToolRuntime.noteKnownMessage};
21
19
  * - messages this tool already resolved (successful or failed attempts), so a
22
- * later turn in the same session keeps its authorized resources.
20
+ * later turn in the same session keeps its authorized resources;
21
+ * - on a whitelist miss, a message the tool reads back from Feishu and finds to
22
+ * be the route owner's own message inside the route's container (its topic,
23
+ * or the main timeline of a degraded chat route); another topic never
24
+ * matches.
23
25
  *
24
- * The whitelist is an in-memory LRU per route; the pending registry is the
25
- * durable source for admitted attachment messages (silent registrations and,
26
- * once the admission task wires it, triggering messages). A prompt-injected
27
- * message id from another chat, user, or topic therefore cannot download
28
- * anything. The whitelist is not replayed from Session history: after a host
29
- * restart the admission/manifest layer re-registers the current turn's
30
- * messages, while older attachments remain reachable through the durable
31
- * pending registry until its TTL.
26
+ * The whitelist is an in-memory LRU per route and is not replayed from Session
27
+ * history; the read-back rule keeps the owner's earlier attachments fetchable
28
+ * after a host restart. A prompt-injected message id from another chat, user,
29
+ * or topic therefore cannot download anything.
32
30
  *
33
31
  * Failure model: every per-resource failure is a structured value (never a
34
32
  * thrown raw error) so one bad resource does not abort the call and session
@@ -40,7 +38,6 @@
40
38
 
41
39
  import { z } from 'zod'
42
40
  import type { FeishuConfig } from '../config.ts'
43
- import type { PendingAttachmentRecord, PendingAttachmentResource } from '../domain.ts'
44
41
  import type {
45
42
  AgentContext, ToolContentBlock, ToolDefinition, ToolExecution,
46
43
  } from '../host/agent.ts'
@@ -70,7 +67,6 @@ import {
70
67
  import type {
71
68
  AttachmentFailureCode, AttachmentResult, NativeAttachmentStore, StoredFile, StoredImage,
72
69
  } from './native-attachments.ts'
73
- import type { PendingAttachmentScope, PendingAttachmentStore } from './pending-attachments.ts'
74
70
 
75
71
  // ---- Public identity ----------------------------------------------------
76
72
 
@@ -90,10 +86,10 @@ export const DEFAULT_KNOWN_MESSAGES_PER_ROUTE = 128
90
86
  * must not block session creation; if they appear later the pending injection
91
87
  * still registers the tool.
92
88
  */
93
- const MOUNT_INJECT_WAIT_MS = 2_000
89
+ export const MOUNT_INJECT_WAIT_MS = 2_000
94
90
 
95
91
  /** Retry wait for later sessions once the first mount timed out (fast-fail). */
96
- const MOUNT_INJECT_RETRY_WAIT_MS = 250
92
+ export const MOUNT_INJECT_RETRY_WAIT_MS = 250
97
93
 
98
94
  // ---- Route / whitelist vocabulary ---------------------------------------
99
95
 
@@ -108,14 +104,13 @@ export interface AttachmentFetchRoute {
108
104
  readonly kind: 'thread' | 'chat'
109
105
  readonly chatId: string
110
106
  readonly chatType: 'p2p' | 'group'
111
- readonly senderOpenId: string
107
+ readonly senderUserId: string
112
108
  }
113
109
 
114
110
  /** Why a message became known to one route. */
115
111
  export type KnownAttachmentSource =
116
112
  | 'trigger'
117
113
  | 'quote'
118
- | 'pending'
119
114
  | 'tool-result'
120
115
  | 'manual'
121
116
  /** A message discovered while reading topic history/quote context (P2). */
@@ -138,7 +133,7 @@ export interface KnownAttachmentMessage {
138
133
  readonly kind: 'message' | 'merge_forward'
139
134
  readonly resources: readonly KnownAttachmentResource[]
140
135
  /** Sender the message was attributed to when it entered the whitelist. */
141
- readonly senderOpenId?: string
136
+ readonly senderUserId?: string
142
137
  }
143
138
 
144
139
  /** Registration input for the whitelist. */
@@ -162,17 +157,7 @@ function routesMatch(left: AttachmentFetchRoute, right: AttachmentFetchRoute): b
162
157
  && left.kind === right.kind
163
158
  && left.chatId === right.chatId
164
159
  && left.chatType === right.chatType
165
- && left.senderOpenId === right.senderOpenId
166
- }
167
-
168
- /** Pending-registry scope for one route (`threadId` only for topic routes). */
169
- export function pendingScopeOfRoute(route: AttachmentFetchRoute): PendingAttachmentScope {
170
- const threadId = threadIdOfRoute(route)
171
- return {
172
- chatId: route.chatId,
173
- senderOpenId: route.senderOpenId,
174
- ...(threadId === undefined ? {} : { threadId }),
175
- }
160
+ && left.senderUserId === right.senderUserId
176
161
  }
177
162
 
178
163
  interface WhitelistedMessage {
@@ -185,11 +170,10 @@ interface WhitelistedMessage {
185
170
  /**
186
171
  * Per-route LRU of messages the channel has declared relevant.
187
172
  *
188
- * The store is intentionally process-local: silently registered attachments
189
- * have a durable registry of their own, while trigger/quote knowledge only
190
- * lives as long as the process (after a restart the admission/manifest layer
191
- * re-registers the current turn's messages; this tool does not replay Session
192
- * history into the whitelist).
173
+ * The store is intentionally process-local: after a restart the inbound layer
174
+ * re-registers the current turn's messages, and the fetch tool's owner
175
+ * read-back authorizes the owner's earlier messages; Session history is never
176
+ * replayed into the whitelist.
193
177
  */
194
178
  export class RouteAttachmentWhitelist {
195
179
  private readonly routes = new Map<string, Map<string, WhitelistedMessage>>()
@@ -216,7 +200,7 @@ export class RouteAttachmentWhitelist {
216
200
  messageId: message.messageId,
217
201
  kind: message.kind,
218
202
  resources: message.resources.map(resource => ({ ...resource })),
219
- ...(message.senderOpenId === undefined ? {} : { senderOpenId: message.senderOpenId }),
203
+ ...(message.senderUserId === undefined ? {} : { senderUserId: message.senderUserId }),
220
204
  },
221
205
  source,
222
206
  registeredAt: now,
@@ -285,7 +269,7 @@ export type AttachmentFetchFailureCode =
285
269
 
286
270
  const FETCH_FAILURE_REASONS: Readonly<Record<string, string>> = {
287
271
  ROUTE_NOT_FOUND: '当前会话尚未绑定飞书对话,无法获取附件',
288
- AUTHORIZATION_DENIED: '该消息不在本会话的附件授权范围内(仅可获取触发消息、引用消息或附件清单中登记的消息)',
272
+ AUTHORIZATION_DENIED: '该消息不在本会话的附件授权范围内(仅可获取触发消息、引用消息、附件清单中列出的消息或本人在本话题发送的消息)',
289
273
  CHANNEL_UNAVAILABLE: '飞书连接未运行,无法获取附件',
290
274
  MESSAGE_READ_FAILED: '读取飞书消息内容失败',
291
275
  NO_RESOURCES: '该消息不包含可下载的图片或文件',
@@ -327,6 +311,9 @@ function failure(code: AttachmentFetchFailureCode, reason?: string): AttachmentF
327
311
  * schema is the authoritative argument gate; it also gives tests and
328
312
  * embedded/non-native callers a typed value.
329
313
  */
314
+ /** Feishu message id accepted from model arguments before any Feishu request. */
315
+ const MESSAGE_ID_PATTERN = /^om_[A-Za-z0-9_-]{1,128}$/
316
+
330
317
  export const attachmentFetchArgsSchema = z.object({
331
318
  message_id: z.string().min(1),
332
319
  file_key: z.string().min(1).optional(),
@@ -423,8 +410,6 @@ export interface AttachmentFetchRuntimeDeps {
423
410
  config(): FeishuConfig
424
411
  /** Live Feishu channel; undefined while disconnected/shut down. */
425
412
  channel(): FeishuChannel | undefined
426
- /** Durable silent-registration registry (authorization + consumption). */
427
- pending?(): PendingAttachmentStore | undefined
428
413
  /** Resolve the route currently bound to one session. */
429
414
  routeOfSession(sessionId: string): AttachmentFetchRoute | undefined
430
415
  /** Structured warning sink (mount degradation, unexpected failures). */
@@ -443,7 +428,6 @@ interface FetchCallContext {
443
428
  readonly native: NativeAttachmentStore
444
429
  readonly imageCap: number
445
430
  readonly channel: FeishuChannel | undefined
446
- readonly pending: PendingAttachmentStore | undefined
447
431
  }
448
432
 
449
433
  interface DownloadBudget {
@@ -496,25 +480,13 @@ export class AttachmentFetchToolRuntime {
496
480
  }
497
481
 
498
482
  /**
499
- * Whether one message id is authorized for one route: the in-memory whitelist
500
- * first, then the durable pending registry — the same sources
501
- * {@link resolveKnownMessage} uses when a fetch is executed. The
502
- * `feishu_topic_messages` tool calls this to decide whether a main-timeline
503
- * message (no `thread_id`) may be detached into the current topic route.
504
- *
505
- * Never throws: an unavailable registry fails closed.
483
+ * Whether one message id is in the route's whitelist. The
484
+ * `feishu_topic_messages` tool calls this to decide whether another sender's
485
+ * main-timeline message (no `thread_id`) may be read from the current topic
486
+ * route.
506
487
  */
507
488
  isKnownMessage(route: AttachmentFetchRoute, messageId: string): boolean {
508
- if (this.whitelist.lookup(route, messageId) !== undefined) return true
509
- const pending = this.deps.pending?.()
510
- if (pending === undefined) return false
511
- try {
512
- return pending.get(pendingScopeOfRoute(route), messageId) !== undefined
513
- } catch {
514
- // Authorization lookups fail closed; a storage hiccup must not throw into
515
- // the topic tool's read path.
516
- return false
517
- }
489
+ return this.whitelist.lookup(route, messageId) !== undefined
518
490
  }
519
491
 
520
492
  /**
@@ -566,15 +538,15 @@ export class AttachmentFetchToolRuntime {
566
538
  }
567
539
  }
568
540
 
569
- /** Drop the whitelist entries of one retired route (record GC / cleanup). */
541
+ /** Drop the whitelist entries of one retired route (record GC). */
570
542
  forgetRoute(routeKey: string): void {
571
543
  this.whitelist.forget(routeKey)
572
544
  }
573
545
 
574
546
  /**
575
547
  * Drop whitelist routes that no longer resolve as active route records. The
576
- * inbound TTL path retires a thread route without going through the service
577
- * cleanup callback, so the periodic GC reconciles the whitelist afterwards.
548
+ * inbound TTL path retires a thread route without calling `forgetRoute`, so
549
+ * the periodic GC reconciles the whitelist afterwards.
578
550
  * @returns number of route buckets dropped.
579
551
  */
580
552
  pruneRoutes(stillActive: (routeKey: string) => boolean): number {
@@ -702,7 +674,6 @@ export class AttachmentFetchToolRuntime {
702
674
  native: nativeAttachmentStoreOf(agentCtx),
703
675
  imageCap: imageByteCapOf(agentCtx),
704
676
  channel: this.deps.channel(),
705
- pending: this.deps.pending?.(),
706
677
  }
707
678
  try {
708
679
  return await this.fetch(call)
@@ -726,8 +697,11 @@ export class AttachmentFetchToolRuntime {
726
697
  }
727
698
 
728
699
  // 1. Authorize the message against this route's known resources.
729
- const known = this.resolveKnownMessage(call, messageId)
700
+ const known = await this.resolveKnownMessage(call, messageId)
730
701
  if (known === undefined) {
702
+ if (call.signal.aborted) {
703
+ return callFailure(messageId, failure(call.timeout.value ? 'TIMEOUT' : 'ABORTED'))
704
+ }
731
705
  return callFailure(messageId, failure('AUTHORIZATION_DENIED'))
732
706
  }
733
707
 
@@ -796,21 +770,6 @@ export class AttachmentFetchToolRuntime {
796
770
  }
797
771
 
798
772
  const fetched = results.filter(item => item.ok).length
799
- // A pending manifest row is consumed only once an attachment was actually
800
- // fetched: a run where every download failed keeps the row discoverable
801
- // (retries are still authorized by the whitelist entry recorded above).
802
- // The durable write is best-effort and raced against the call signal, so a
803
- // stalled storage backend can never keep the tool call pending.
804
- if (fetched > 0 && call.pending !== undefined) {
805
- try {
806
- await cancellable(
807
- Promise.resolve(call.pending.consume(pendingScopeOfRoute(call.route), known.messageId)),
808
- call.signal,
809
- )
810
- } catch {
811
- // Bookkeeping only: the attachments were already delivered.
812
- }
813
- }
814
773
  const shownMessageId = displayText(messageId)
815
774
  const summary = fetched > 0
816
775
  ? `已获取 ${fetched}/${results.length} 个附件(消息 ${shownMessageId})`
@@ -826,18 +785,35 @@ export class AttachmentFetchToolRuntime {
826
785
  }
827
786
  }
828
787
 
829
- /** Whitelist first, then the durable silent-registration registry. */
830
- private resolveKnownMessage(
788
+ /** Whitelist first, then the owner read-back. */
789
+ private async resolveKnownMessage(
831
790
  call: FetchCallContext,
832
791
  messageId: string,
833
- ): KnownAttachmentMessage | undefined {
834
- const known = this.whitelist.lookup(call.route, messageId)
835
- if (known !== undefined) return known
836
- const pending = call.pending
837
- if (pending === undefined) return undefined
838
- const record = pending.get(pendingScopeOfRoute(call.route), messageId)
839
- if (record === undefined) return undefined
840
- return knownMessageOfPending(record)
792
+ ): Promise<KnownAttachmentMessage | undefined> {
793
+ return this.whitelist.lookup(call.route, messageId) ?? await this.readOwnedMessage(call, messageId)
794
+ }
795
+
796
+ /**
797
+ * Authorize a whitelist miss by reading the message back from Feishu: the
798
+ * route owner's own, undeleted message that belongs to the route
799
+ * (`messageBelongsToRoute`) is authorized. Every read failure fails closed.
800
+ */
801
+ private async readOwnedMessage(
802
+ call: FetchCallContext,
803
+ messageId: string,
804
+ ): Promise<KnownAttachmentMessage | undefined> {
805
+ const channel = call.channel
806
+ if (channel === undefined) return undefined
807
+ // The id comes from model arguments: never send a malformed one to Feishu.
808
+ if (!MESSAGE_ID_PATTERN.test(messageId)) return undefined
809
+ const reader = new MessageReader({ channel: () => channel })
810
+ const read = await reader.readMessage(messageId, { signal: call.signal })
811
+ if (!read.ok) return undefined
812
+ const container = quoteItemsOf(read.value)
813
+ if (container === undefined || container.deleted) return undefined
814
+ if (container.senderUserId !== call.route.senderUserId) return undefined
815
+ if (!messageBelongsToRoute(call.route, container)) return undefined
816
+ return knownMessageOfItems(read.value)
841
817
  }
842
818
 
843
819
  /** Record the resolved message as known for later turns of this route. */
@@ -906,8 +882,8 @@ export class AttachmentFetchToolRuntime {
906
882
  message: KnownAttachmentMessage,
907
883
  resource: KnownAttachmentResource,
908
884
  ): Promise<AttachmentFetchResultItem> {
909
- // `containerMessageId` is part of a trusted registration (pending record or
910
- // admission-provided whitelist entry), never model input: the P0 rule makes
885
+ // `containerMessageId` is part of a trusted whitelist registration, never
886
+ // model input: the P0 rule makes
911
887
  // merged-forward child bytes downloadable only through the top-level
912
888
  // container id, so child ids are never used as download ids here.
913
889
  const downloadId = resource.containerMessageId ?? message.messageId
@@ -1505,28 +1481,12 @@ function isPostBody(record: Record<string, unknown>): boolean {
1505
1481
  return Array.isArray(record.content) || Array.isArray(record.files)
1506
1482
  }
1507
1483
 
1508
- /** Pending registry record → known message (authorization snapshot). */
1509
- function knownMessageOfPending(record: PendingAttachmentRecord): KnownAttachmentMessage {
1510
- return {
1511
- messageId: record.messageId,
1512
- kind: record.kind === 'merge_forward' ? 'merge_forward' : 'message',
1513
- senderOpenId: record.senderOpenId,
1514
- resources: record.resources.map((resource: PendingAttachmentResource): KnownAttachmentResource => ({
1515
- type: resource.type,
1516
- fileKey: resource.fileKey,
1517
- ...(resource.fileName === undefined ? {} : { fileName: resource.fileName }),
1518
- ...(resource.folder === true ? { folder: true } : {}),
1519
- ...(resource.containerMessageId === undefined ? {} : { containerMessageId: resource.containerMessageId }),
1520
- })),
1521
- }
1522
- }
1523
-
1524
1484
  /**
1525
1485
  * Wait for one cordis injection to settle, but never longer than `timeoutMs`:
1526
1486
  * a hard dependency on a service a deployment does not mount would otherwise
1527
1487
  * block agent creation forever.
1528
1488
  */
1529
- function waitForAvailability(
1489
+ export function waitForAvailability(
1530
1490
  task: PromiseLike<unknown>,
1531
1491
  timeoutMs: number,
1532
1492
  ): Promise<'ready' | 'failed' | 'timeout'> {
@@ -1664,7 +1624,7 @@ const TOPIC_MESSAGES_FETCH_UNAVAILABLE_DESCRIPTION =
1664
1624
  * Chinese error rather than a schema message.
1665
1625
  */
1666
1626
  export const topicMessagesArgsSchema = z.object({
1667
- message_id: z.string().min(1).max(160).regex(/^om_[A-Za-z0-9_-]{1,128}$/).optional(),
1627
+ message_id: z.string().min(1).max(160).regex(MESSAGE_ID_PATTERN).optional(),
1668
1628
  limit: z.number().int().min(1).max(TOPIC_MESSAGES_MAX_LIMIT).optional(),
1669
1629
  cursor: z.string().min(1).max(2048).optional(),
1670
1630
  }).strict()
@@ -1722,7 +1682,7 @@ const TOPIC_MESSAGES_FAILURE_REASONS: Readonly<Record<TopicMessagesFailureCode,
1722
1682
  TOOL_DISABLED: '话题消息读取工具已禁用',
1723
1683
  INVALID_ARGUMENT: '读取消息的参数不合法',
1724
1684
  MESSAGE_OUT_OF_ROUTE:
1725
- '该消息不在当前会话的飞书话题/聊天范围内(仅可读取当前话题的消息或本会话已登记的消息)',
1685
+ '该消息不在当前会话的飞书话题/聊天范围内(仅可读取当前话题的消息或本会话已知的消息)',
1726
1686
  MESSAGE_READ_FAILED: '读取飞书消息失败',
1727
1687
  PERMISSION_DENIED: '飞书应用缺少读取消息的权限(如 im:message:readonly),无法读取引用或话题消息',
1728
1688
  ABORTED: '读取飞书消息已取消',
@@ -1838,7 +1798,7 @@ export interface TopicMessagesRuntimeDeps {
1838
1798
  message: KnownAttachmentMessageInput,
1839
1799
  source: KnownAttachmentSource,
1840
1800
  ): void
1841
- /** Whether one message id is already authorized for the route (whitelist/pending). */
1801
+ /** Whether one message id is already in the route's whitelist. */
1842
1802
  isKnownMessage(route: AttachmentFetchRoute, messageId: string): boolean
1843
1803
  /**
1844
1804
  * Whether `feishu_attachment_fetch` is usable in this deployment (native
@@ -2122,8 +2082,8 @@ export class TopicMessagesToolRuntime {
2122
2082
  }
2123
2083
  // Route membership is enforced on the container: another chat, another
2124
2084
  // topic of the same group, or an unregistered main-timeline message is
2125
- // refused. A message already admitted to this route (trigger/quote/pending)
2126
- // may be read even without a topic id (quotes happen before the topic).
2085
+ // refused. A message already admitted to this route (trigger/quote/recent)
2086
+ // may be read even without a topic id (it precedes the topic).
2127
2087
  const known = this.deps.isKnownMessage(route, messageId)
2128
2088
  if (!messageBelongsToRoute(route, container, known ? [messageId] : [])) {
2129
2089
  return {