@modesto_ma/dsh-channels 0.3.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.
- package/README.md +123 -53
- package/docs/access-control-design.md +7 -5
- package/docs/attachment-pipeline-v2.md +310 -0
- package/docs/feasibility-and-design.md +1 -1
- package/docs/feishu-onboarding.md +127 -21
- package/docs/persistence.md +6 -4
- package/docs/refactor-plan.md +2 -2
- package/docs/workspace-isolation-design.md +13 -11
- package/lib/client.js +179 -288
- package/lib/client.js.map +1 -1
- package/lib/index.js +12125 -6382
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/cards.ts +9 -9
- package/src/access/controller.ts +10 -10
- package/src/access/index.ts +1 -0
- package/src/access/policy.ts +10 -10
- package/src/access/requests.ts +32 -31
- package/src/access/store.ts +12 -12
- package/src/access/targets.ts +56 -0
- package/src/cards/approval.ts +96 -26
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +1 -0
- package/src/cards/picker.ts +115 -0
- package/src/cards/streaming.ts +56 -7
- package/src/client/api.ts +5 -25
- package/src/client/panel/access-card.tsx +24 -15
- package/src/client/panel/access-tab.tsx +3 -3
- package/src/client/panel/connection-tab.tsx +70 -1
- package/src/client/panel/index.tsx +11 -62
- package/src/client/panel/shared.tsx +0 -23
- package/src/client/panel/workspace-tab.tsx +2 -18
- package/src/client/styles.ts +2 -0
- package/src/commands/registry.ts +2 -2
- package/src/config.ts +190 -32
- package/src/domain.ts +28 -18
- package/src/feishu/agent-context.ts +65 -19
- package/src/feishu/inbound-attachments.ts +440 -3
- package/src/feishu/manifest.ts +247 -0
- package/src/feishu/message-read.ts +1330 -0
- package/src/feishu/native-attachments.ts +998 -0
- package/src/feishu/scopes.ts +171 -0
- package/src/feishu/send-file.ts +543 -0
- package/src/feishu/slash-commands.ts +134 -0
- package/src/feishu/tools.ts +2305 -0
- package/src/feishu-channel.ts +322 -6
- package/src/host/agent.ts +55 -0
- package/src/host/attachments.ts +210 -0
- package/src/host/index.ts +1 -0
- package/src/index.ts +5 -4
- package/src/interactions/approvals.ts +187 -7
- package/src/interactions/pending.ts +3 -3
- package/src/routing/inbound.ts +620 -49
- package/src/service.ts +808 -375
- package/src/session/manager.ts +67 -13
- package/src/session/routes.ts +2 -3
- package/src/settings/contract.ts +90 -89
- package/src/settings/overlay.ts +124 -13
- package/src/settings/routes.ts +3 -17
- package/src/settings/surface.ts +156 -82
- package/src/streamer.ts +94 -5
- package/src/workspaces.ts +39 -26
- package/src/cleanup.ts +0 -535
- package/src/client/panel/cleanup-card.tsx +0 -106
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host bridge, attachment surface: the minimal `ctx.attachments` face this
|
|
3
|
+
* plugin consumes (durable image/file storage) plus the content-block and
|
|
4
|
+
* prompt-part shapes it produces.
|
|
5
|
+
*
|
|
6
|
+
* Everything here is declared locally and structurally. The DSH attachment
|
|
7
|
+
* packages are never imported at runtime and are not runtime dependencies of
|
|
8
|
+
* this plugin, so these types mirror their published shapes by convention:
|
|
9
|
+
* only the members this plugin actually reads or calls are modeled, and every
|
|
10
|
+
* service lookup tolerates a deployment without the service. The exact
|
|
11
|
+
* upstream package (`@deepseek-ai/dsh-attachment`, DSH 0.2.1-rc.2) has more
|
|
12
|
+
* operations (batches, request projection, encoded uploads); add them here
|
|
13
|
+
* only when a task needs them.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-channels/host/attachments
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ---- Durable attachment vocabulary --------------------------------------
|
|
19
|
+
|
|
20
|
+
/** Opaque content-addressed attachment identifier (never a filesystem path). */
|
|
21
|
+
export type AttachmentId = string
|
|
22
|
+
|
|
23
|
+
/** Raster image formats accepted by the version-one attachment path. */
|
|
24
|
+
export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
|
|
25
|
+
|
|
26
|
+
/** Durable, serializable reference to one immutable normalized image. */
|
|
27
|
+
export interface ImageAttachmentRef {
|
|
28
|
+
/** Opaque storage identifier; never a filesystem path or bearer URL. */
|
|
29
|
+
attachmentId: AttachmentId
|
|
30
|
+
/** Media type verified from the stored bytes. */
|
|
31
|
+
mediaType: ImageMediaType
|
|
32
|
+
/** Exact encoded byte length. */
|
|
33
|
+
bytes: number
|
|
34
|
+
/** Intrinsic encoded width in pixels. */
|
|
35
|
+
width: number
|
|
36
|
+
/** Intrinsic encoded height in pixels. */
|
|
37
|
+
height: number
|
|
38
|
+
/** Optional display name stripped of local path information. */
|
|
39
|
+
name?: string
|
|
40
|
+
/**
|
|
41
|
+
* Input dimensions after applying EXIF orientation and before normalization
|
|
42
|
+
* scaling. Present only when normalization reduced the image.
|
|
43
|
+
*/
|
|
44
|
+
originalDimensions?: {
|
|
45
|
+
width: number
|
|
46
|
+
height: number
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Durable, serializable reference to one verbatim stored file. Files are
|
|
52
|
+
* stored byte-for-byte with no normalization; `attachmentId` is the sha256
|
|
53
|
+
* digest of exactly those bytes.
|
|
54
|
+
*/
|
|
55
|
+
export interface FileAttachmentRef {
|
|
56
|
+
/** Opaque content-addressed storage identifier; never a filesystem path. */
|
|
57
|
+
attachmentId: AttachmentId
|
|
58
|
+
/** Sanitized display filename, also the stored object's leaf name. */
|
|
59
|
+
name: string
|
|
60
|
+
/** Exact byte length. */
|
|
61
|
+
bytes: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Deployment-resolved image limits used by upload admission. */
|
|
65
|
+
export interface ImageAttachmentLimits {
|
|
66
|
+
maxImageBytes: number
|
|
67
|
+
maxImagesPerMessage: number
|
|
68
|
+
maxMessageImageBytes: number
|
|
69
|
+
maxImagePixels: number
|
|
70
|
+
/** Maximum intrinsic width and maximum intrinsic height in pixels for one image. */
|
|
71
|
+
maxImageDimension: number
|
|
72
|
+
mediaTypes: readonly ImageMediaType[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Request to validate and durably commit one image. */
|
|
76
|
+
export interface SaveImageAttachment {
|
|
77
|
+
/** Encoded bytes, checked against the declared media type by the service. */
|
|
78
|
+
data: Uint8Array
|
|
79
|
+
/** Caller-declared media type, checked against fully decoded bytes. */
|
|
80
|
+
mediaType: ImageMediaType
|
|
81
|
+
/** Optional display name; never interpreted as a path. */
|
|
82
|
+
name?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Request to durably commit one file from bounded byte chunks. */
|
|
86
|
+
export interface SaveFileStreamAttachment {
|
|
87
|
+
/** Exact file bytes in order; providers must not retain the whole sequence. */
|
|
88
|
+
data: AsyncIterable<Uint8Array>
|
|
89
|
+
/** Optional cancellation for source reads and storage writes. */
|
|
90
|
+
signal?: AbortSignal
|
|
91
|
+
/** Optional display name; never interpreted as a path. */
|
|
92
|
+
name?: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One text/image part of host prompt content whose images still need admission. */
|
|
96
|
+
export type PromptContentPart = {
|
|
97
|
+
readonly type: 'text'
|
|
98
|
+
readonly text: string
|
|
99
|
+
} | {
|
|
100
|
+
readonly type: 'image'
|
|
101
|
+
readonly mediaType: ImageMediaType
|
|
102
|
+
readonly data: string
|
|
103
|
+
readonly name?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */
|
|
107
|
+
export type AttachmentAdmissionPart = PromptContentPart | {
|
|
108
|
+
readonly type: 'file'
|
|
109
|
+
readonly attachment: FileAttachmentRef
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Host-admitted prompt content with every attachment represented by its durable reference. */
|
|
113
|
+
export type AdmittedPromptContentPart = {
|
|
114
|
+
readonly type: 'text'
|
|
115
|
+
readonly text: string
|
|
116
|
+
} | {
|
|
117
|
+
readonly type: 'image'
|
|
118
|
+
readonly attachment: ImageAttachmentRef
|
|
119
|
+
} | {
|
|
120
|
+
readonly type: 'file'
|
|
121
|
+
readonly attachment: FileAttachmentRef
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- Content blocks -----------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A durable raster image reference as it appears in model content
|
|
128
|
+
* (`ImageBlock` in `@deepseek-ai/dsh-llm`). The block is role-neutral; this
|
|
129
|
+
* plugin only produces it for user content.
|
|
130
|
+
*/
|
|
131
|
+
export interface ImageBlock {
|
|
132
|
+
type: 'image'
|
|
133
|
+
/** Immutable bytes and intrinsic display metadata owned by the service. */
|
|
134
|
+
attachment: ImageAttachmentRef
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A durable verbatim file reference as it appears in model content
|
|
139
|
+
* (`FileBlock` in `@deepseek-ai/dsh-llm`). Files never reach a provider as
|
|
140
|
+
* bytes: request assembly projects them to handle text, so a channel tool that
|
|
141
|
+
* cannot deliver this block must fall back to {@link formatFileHandle}-style
|
|
142
|
+
* text instead.
|
|
143
|
+
*/
|
|
144
|
+
export interface FileBlock {
|
|
145
|
+
type: 'file'
|
|
146
|
+
/** Immutable verbatim bytes and display metadata owned by the service. */
|
|
147
|
+
attachment: FileAttachmentRef
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- Failure vocabulary -------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Stable attachment failure codes the host routes on
|
|
154
|
+
* (`AttachmentErrorCode` in `@deepseek-ai/dsh-attachment`). Kept as a
|
|
155
|
+
* type-only union; the runtime classifier lives in the Feishu storage module.
|
|
156
|
+
*/
|
|
157
|
+
export type AttachmentErrorCode =
|
|
158
|
+
| 'TOO_MANY_IMAGES'
|
|
159
|
+
| 'IMAGES_TOO_LARGE'
|
|
160
|
+
| 'UNSUPPORTED_IMAGE_TYPE'
|
|
161
|
+
| 'INVALID_IMAGE_BASE64'
|
|
162
|
+
| 'INVALID_IMAGE'
|
|
163
|
+
| 'IMAGE_TYPE_MISMATCH'
|
|
164
|
+
| 'IMAGE_TOO_LARGE'
|
|
165
|
+
| 'IMAGE_TOO_MANY_PIXELS'
|
|
166
|
+
| 'IMAGE_DIMENSION_TOO_LARGE'
|
|
167
|
+
| 'INVALID_FILE_BASE64'
|
|
168
|
+
| 'INVALID_ATTACHMENT_REF'
|
|
169
|
+
| 'ATTACHMENT_CORRUPT'
|
|
170
|
+
| 'ATTACHMENT_WRITE_FAILED'
|
|
171
|
+
| 'ATTACHMENT_NOT_FOUND'
|
|
172
|
+
| 'ATTACHMENT_READ_FAILED'
|
|
173
|
+
| 'ATTACHMENT_PROJECTION_UNSUPPORTED'
|
|
174
|
+
| 'ATTACHMENT_FILES_UNSUPPORTED'
|
|
175
|
+
|
|
176
|
+
// ---- Service surface ----------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The narrow `ctx.attachments` service face. Every method mirrors the
|
|
180
|
+
* upstream contract; callers must go through a module that tolerates absence
|
|
181
|
+
* (for example `src/feishu/native-attachments.ts`) rather than branching on
|
|
182
|
+
* this type themselves, so the degradation path exists in exactly one place.
|
|
183
|
+
*/
|
|
184
|
+
export interface AttachmentStore {
|
|
185
|
+
/** Deployment-resolved image policy; absent on minimal embedding hosts. */
|
|
186
|
+
readonly imageLimits?: ImageAttachmentLimits
|
|
187
|
+
/** Validate, normalize, and durably commit one image. */
|
|
188
|
+
saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>
|
|
189
|
+
/** Durably commit one verbatim file from bounded chunks (backpressure, no full buffering). */
|
|
190
|
+
saveFileStream(input: SaveFileStreamAttachment): Promise<FileAttachmentRef>
|
|
191
|
+
/**
|
|
192
|
+
* Locate the verbatim stored file object in the harness host filesystem.
|
|
193
|
+
* @returns an absolute host path, or undefined when this backend is not
|
|
194
|
+
* host-file-backed.
|
|
195
|
+
* @throws an attachment error when the durable reference is invalid.
|
|
196
|
+
*/
|
|
197
|
+
fileHostPath(ref: FileAttachmentRef): string | undefined
|
|
198
|
+
/**
|
|
199
|
+
* Locate the provider-owned normalized image object in the harness host
|
|
200
|
+
* filesystem.
|
|
201
|
+
* @returns an absolute host path, or undefined when this backend is not
|
|
202
|
+
* host-file-backed.
|
|
203
|
+
* @throws an attachment error when the durable reference is invalid.
|
|
204
|
+
*/
|
|
205
|
+
imageHostPath(ref: ImageAttachmentRef): string | undefined
|
|
206
|
+
/** Replace every uploaded image in one host prompt with its durable reference. */
|
|
207
|
+
admitPromptContent(content: readonly AttachmentAdmissionPart[]): Promise<AdmittedPromptContentPart[]>
|
|
208
|
+
/** Optional host-provided classifier for attachment failures (cross-install safe). */
|
|
209
|
+
isAttachmentError?(error: unknown): boolean
|
|
210
|
+
}
|
package/src/host/index.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -41,7 +41,7 @@ export type * from './questions.ts'
|
|
|
41
41
|
export type * from './settings/contract.ts'
|
|
42
42
|
export * from './access/index.ts'
|
|
43
43
|
export type { AccessGroupRecord, AccessRequestRecord, AccessUserRecord } from './domain.ts'
|
|
44
|
-
export { CATALOG_ROUTE,
|
|
44
|
+
export { CATALOG_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, EDITABLE_KEYS, PANEL_COPY, PANEL_ROUTE, PICK_DIRECTORY_ROUTE, RECONNECT_ROUTE, RESTART_KEYS, STATUS_ROUTE } from './settings/contract.ts'
|
|
45
45
|
export type { PickDirectoryResponse } from './settings/contract.ts'
|
|
46
46
|
export { defaultOverlayPath, mergeOverlay, normalizeOverlay, OverlayStore, parseOverlay, pickEditable, resolveDshHome } from './settings/overlay.ts'
|
|
47
47
|
export { applyCatalog, emptyCatalog, FIELD_DESCRIPTORS, normalizeCredentialValue, readOnlyFields, SettingsSurface, withDirectoryBrowse } from './settings/surface.ts'
|
|
@@ -63,7 +63,7 @@ export { createLogicalSessionExport, DSH_CHANNELS_VERSION, LOGICAL_EXPORT_FORMAT
|
|
|
63
63
|
export { addUsage, applyProjectionViews, averageDecodeTokensPerSecond, averageTtftMs, cacheHitPct, EMPTY_TOTALS, foldAll, foldEvent, foldSessionStats, measureSessionContext, readProjectionViews, usageFromBuckets } from './stats.ts'
|
|
64
64
|
export { FeishuService } from './service.ts'
|
|
65
65
|
export { TurnStreamer } from './streamer.ts'
|
|
66
|
-
export { ensureWorkspace,
|
|
66
|
+
export { ensureWorkspace, stagingPathFor, stagingRoot, topicDirFor, topicTokenFor, workspacePathFor, workspaceTitleFor, worktreeBranchFor } from './workspaces.ts'
|
|
67
67
|
export { accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, appendSessionHistory, chatRouteKey, expiredRouteRecordSchema, feishuDomain, normalizeRouteRecord, normalizeSessionHistory, routeRecordSchema, sessionDirOf, sessionHistoryEntrySchema, inflightSnapshotSchema, processedRecordSchema } from './domain.ts'
|
|
68
68
|
|
|
69
69
|
/** Cordis function-plugin name. */
|
|
@@ -105,9 +105,10 @@ export function apply(ctx: HostContext, config?: unknown): void {
|
|
|
105
105
|
const resolved = resolveConfig(config)
|
|
106
106
|
const factory: ChannelFactory = (config as FeishuPluginOptions | undefined)?.channelFactory
|
|
107
107
|
?? defaultChannelFactory
|
|
108
|
-
// Web settings overlay (optional
|
|
108
|
+
// Web settings overlay at `$DSH_HOME/dsh-channels/settings.json` (optional
|
|
109
|
+
// file; degrades to "no overrides").
|
|
109
110
|
const overlay = new OverlayStore(
|
|
110
|
-
|
|
111
|
+
defaultOverlayPath(),
|
|
111
112
|
{ warn: (message, ...args) => ctx.logger?.warn?.(message, ...args) },
|
|
112
113
|
)
|
|
113
114
|
const service = new FeishuService(ctx, resolved, factory, undefined, overlay)
|
|
@@ -4,8 +4,16 @@
|
|
|
4
4
|
*
|
|
5
5
|
* The answerer is registered with `prepend: true` on the plugin context so it
|
|
6
6
|
* runs BEFORE generic answerers: a request for a session this channel routes
|
|
7
|
-
* is claimed here, and the decision card
|
|
8
|
-
* Requests for sessions the channel does NOT route
|
|
7
|
+
* is claimed here, and the decision card (or a typed topic reply) becomes the
|
|
8
|
+
* only way to answer. Requests for sessions the channel does NOT route
|
|
9
|
+
* delegate through `next()`.
|
|
10
|
+
*
|
|
11
|
+
* The host request carries no tool arguments, so the answerer keeps the
|
|
12
|
+
* current turn's `tool/call` arguments per session (fed from `session/event`)
|
|
13
|
+
* and looks the asked call up by `callId` to show what will run. Standing
|
|
14
|
+
* "stop asking in this topic" grants live only in memory, keyed by session,
|
|
15
|
+
* tool name, and requested sandbox mode; they end with the session (`/renew`),
|
|
16
|
+
* the topic, or the process.
|
|
9
17
|
*
|
|
10
18
|
* Rendering lives in `cards/approval.ts`; the shared pending-card lifecycle
|
|
11
19
|
* (delivery, owner gating, timeout, settle-once, withdrawal) lives in
|
|
@@ -17,7 +25,7 @@
|
|
|
17
25
|
import { randomUUID } from 'node:crypto'
|
|
18
26
|
import { buildApprovalCard, buildApprovalSettledCard } from '../cards/approval.ts'
|
|
19
27
|
import type { ApprovalCardState, ApprovalQuestion } from '../cards/approval.ts'
|
|
20
|
-
import type { ApprovalOutcome, ApprovalRequest } from '../dsh-types.ts'
|
|
28
|
+
import type { ApprovalOutcome, ApprovalRequest, SessionEvent } from '../dsh-types.ts'
|
|
21
29
|
import type { CardActionEvent, FeishuChannel } from '../feishu-channel.ts'
|
|
22
30
|
import { PendingCardTable } from './pending.ts'
|
|
23
31
|
import type { InteractionRoute, PendingCardEntry } from './pending.ts'
|
|
@@ -33,16 +41,87 @@ export interface ApprovalHooks {
|
|
|
33
41
|
routeFor(sessionId: string): ApprovalRoute | undefined
|
|
34
42
|
/** Log a non-fatal error (delivery/update failures). */
|
|
35
43
|
onError(sessionId: string, error: unknown): void
|
|
44
|
+
/**
|
|
45
|
+
* The tool names a session is currently waiting on for approval (empty when
|
|
46
|
+
* none), called on every open and settle so the streaming card can show it.
|
|
47
|
+
*/
|
|
48
|
+
onWaitingChange?(sessionId: string, toolNames: readonly string[]): void
|
|
36
49
|
}
|
|
37
50
|
|
|
51
|
+
/**
|
|
52
|
+
* How one typed topic reply was consumed by {@link ApprovalAnswerer.submitText}:
|
|
53
|
+
* a keyword decision (`allowed`, `remembered`, `denied`), or any other text
|
|
54
|
+
* that rejected every pending request of the session (`superseded`) and must
|
|
55
|
+
* still be delivered as the next instruction.
|
|
56
|
+
*/
|
|
57
|
+
export type ApprovalTextResult = 'allowed' | 'remembered' | 'denied' | 'superseded'
|
|
58
|
+
|
|
38
59
|
/** One in-flight approval question: its card content and its resolver. */
|
|
39
60
|
interface Pending extends PendingCardEntry {
|
|
40
61
|
question: ApprovalQuestion
|
|
62
|
+
/** Standing-grant key for "stop asking in this topic". */
|
|
63
|
+
grantKey: string
|
|
64
|
+
/** Open order, so a typed reply answers the newest card. */
|
|
65
|
+
openedAt: number
|
|
41
66
|
resolve(outcome: ApprovalOutcome): void
|
|
42
67
|
}
|
|
43
68
|
|
|
69
|
+
/** One recorded `tool/call` of a session's current turn. */
|
|
70
|
+
interface RecordedCall {
|
|
71
|
+
turn: number
|
|
72
|
+
name: string
|
|
73
|
+
arguments: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ALLOW_WORDS = new Set(['允许', '同意', '可以', '好', '好的', '行', '确认', 'y', 'yes', 'ok', 'allow', 'approve'])
|
|
77
|
+
const DENY_WORDS = new Set(['拒绝', '不允许', '不行', '不', '否', '不同意', 'n', 'no', 'deny', 'reject'])
|
|
78
|
+
const ALWAYS_WORDS = new Set(['全部允许', '都允许', '允许所有', '允许全部', '总是允许', '始终允许', 'allow all', 'always'])
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Classify one typed reply. Trailing punctuation and case are ignored; the
|
|
82
|
+
* whole message must be the keyword so ordinary sentences never match.
|
|
83
|
+
* @param text - the reply text with mentions already stripped.
|
|
84
|
+
* @returns The keyword decision, or `undefined` for any other text.
|
|
85
|
+
*/
|
|
86
|
+
export function classifyApprovalReply(text: string): 'allow' | 'always' | 'deny' | undefined {
|
|
87
|
+
const word = text.trim().toLowerCase().replace(/[\s。.!!~~]+$/u, '')
|
|
88
|
+
if (ALWAYS_WORDS.has(word)) return 'always'
|
|
89
|
+
if (ALLOW_WORDS.has(word)) return 'allow'
|
|
90
|
+
if (DENY_WORDS.has(word)) return 'deny'
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read what a call will run and the sandbox mode it asks for. `bash` shows
|
|
96
|
+
* its `command`; other tools show their raw arguments. Arguments are
|
|
97
|
+
* model-produced JSON, so a parse failure shows the raw text.
|
|
98
|
+
*/
|
|
99
|
+
function describeCall(call: RecordedCall | undefined): { command?: string; escalateTo?: string } {
|
|
100
|
+
if (call === undefined) return {}
|
|
101
|
+
let args: Record<string, unknown> | undefined
|
|
102
|
+
try {
|
|
103
|
+
const parsed: unknown = JSON.parse(call.arguments)
|
|
104
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) args = parsed as Record<string, unknown>
|
|
105
|
+
} catch {
|
|
106
|
+
// Unparseable arguments: shown verbatim below.
|
|
107
|
+
}
|
|
108
|
+
if (args === undefined) return call.arguments === '' ? {} : { command: call.arguments }
|
|
109
|
+
const escalateTo = typeof args.sandbox_permissions === 'string' && args.sandbox_permissions !== ''
|
|
110
|
+
? args.sandbox_permissions
|
|
111
|
+
: undefined
|
|
112
|
+
const command = typeof args.command === 'string' && args.command !== ''
|
|
113
|
+
? args.command
|
|
114
|
+
: JSON.stringify(args, null, 2)
|
|
115
|
+
return { command, ...(escalateTo === undefined ? {} : { escalateTo }) }
|
|
116
|
+
}
|
|
117
|
+
|
|
44
118
|
export class ApprovalAnswerer {
|
|
45
119
|
private readonly table: PendingCardTable<Pending>
|
|
120
|
+
/** sessionId → callId → the current turn's recorded tool calls. */
|
|
121
|
+
private readonly calls = new Map<string, Map<string, RecordedCall>>()
|
|
122
|
+
/** sessionId → standing "stop asking in this topic" grant keys. */
|
|
123
|
+
private readonly grants = new Map<string, Set<string>>()
|
|
124
|
+
private opened = 0
|
|
46
125
|
|
|
47
126
|
constructor(
|
|
48
127
|
channel: FeishuChannel,
|
|
@@ -57,18 +136,52 @@ export class ApprovalAnswerer {
|
|
|
57
136
|
return this.table.count()
|
|
58
137
|
}
|
|
59
138
|
|
|
139
|
+
/** Whether a session currently has an undecided approval card. */
|
|
140
|
+
hasPendingFor(sessionId: string): boolean {
|
|
141
|
+
return this.pendingOf(sessionId).length > 0
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Track the current turn's tool calls so a later approval can show what it
|
|
146
|
+
* approves. Only `tool/call` and `turn/end` are read; nothing earlier than
|
|
147
|
+
* the current turn is kept.
|
|
148
|
+
*/
|
|
149
|
+
onSessionEvent(sessionId: string, event: SessionEvent): void {
|
|
150
|
+
if (event.type === 'tool/call') {
|
|
151
|
+
let calls = this.calls.get(sessionId)
|
|
152
|
+
if (calls === undefined) {
|
|
153
|
+
calls = new Map()
|
|
154
|
+
this.calls.set(sessionId, calls)
|
|
155
|
+
}
|
|
156
|
+
calls.set(event.data.callId, { turn: event.data.turn, name: event.data.name, arguments: event.data.arguments })
|
|
157
|
+
} else if (event.type === 'turn/end') {
|
|
158
|
+
const calls = this.calls.get(sessionId)
|
|
159
|
+
if (calls === undefined) return
|
|
160
|
+
for (const [callId, call] of calls) {
|
|
161
|
+
if (call.turn <= event.data.turn) calls.delete(callId)
|
|
162
|
+
}
|
|
163
|
+
if (calls.size === 0) this.calls.delete(sessionId)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
60
167
|
/**
|
|
61
168
|
* The `approval/request` waterfall listener. Never rejects: the host service
|
|
62
169
|
* already contains answerer failures to the fail-closed `'unavailable'`.
|
|
63
170
|
*/
|
|
64
171
|
onRequest(req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome> {
|
|
65
|
-
const
|
|
172
|
+
const sessionId = req.agent.session.id
|
|
173
|
+
const route = this.hooks.routeFor(sessionId)
|
|
66
174
|
if (route === undefined) return next()
|
|
67
175
|
// The abort window between the service's own signal check and this
|
|
68
176
|
// listener is closed by settling synchronously: an aborted ask never
|
|
69
177
|
// opens a question.
|
|
70
178
|
if (req.signal?.aborted === true) return Promise.resolve('cancelled')
|
|
71
179
|
|
|
180
|
+
const call = req.callId === undefined ? undefined : this.calls.get(sessionId)?.get(req.callId)
|
|
181
|
+
const described = describeCall(call)
|
|
182
|
+
const grantKey = `${req.toolName}\u0000${described.escalateTo ?? ''}`
|
|
183
|
+
if (this.grants.get(sessionId)?.has(grantKey) === true) return Promise.resolve('allowed-once')
|
|
184
|
+
|
|
72
185
|
const token = randomUUID()
|
|
73
186
|
// `displayReason` (DSH 0.2.0) is presentation-only and locale-aware;
|
|
74
187
|
// `reason` remains the audit-trail fallback.
|
|
@@ -77,13 +190,14 @@ export class ApprovalAnswerer {
|
|
|
77
190
|
token,
|
|
78
191
|
toolName: req.toolName,
|
|
79
192
|
...(displayReason === undefined ? {} : { reason: displayReason }),
|
|
193
|
+
...described,
|
|
80
194
|
timeoutMs: this.timeoutMs,
|
|
81
|
-
sessionId
|
|
195
|
+
sessionId,
|
|
82
196
|
}
|
|
83
197
|
return new Promise<ApprovalOutcome>((resolve) => {
|
|
84
198
|
const entry: Pending = {
|
|
85
199
|
token,
|
|
86
|
-
sessionId
|
|
200
|
+
sessionId,
|
|
87
201
|
route,
|
|
88
202
|
messageId: undefined,
|
|
89
203
|
settled: false,
|
|
@@ -92,6 +206,8 @@ export class ApprovalAnswerer {
|
|
|
92
206
|
onAbort: undefined,
|
|
93
207
|
unauthorizedNoted: false,
|
|
94
208
|
question,
|
|
209
|
+
grantKey,
|
|
210
|
+
openedAt: ++this.opened,
|
|
95
211
|
resolve,
|
|
96
212
|
}
|
|
97
213
|
const onAbort = (): void => {
|
|
@@ -105,6 +221,7 @@ export class ApprovalAnswerer {
|
|
|
105
221
|
// discards a late resolution (worst case: the card times out).
|
|
106
222
|
req.signal?.addEventListener('abort', onAbort, { once: true })
|
|
107
223
|
this.table.add(entry)
|
|
224
|
+
this.notifyWaiting(sessionId)
|
|
108
225
|
void this.table.deliver(entry, {
|
|
109
226
|
card: buildApprovalCard(question),
|
|
110
227
|
onWithdrawn: e => this.table.patch(e, buildApprovalSettledCard(e.question, 'cancelled')),
|
|
@@ -114,6 +231,7 @@ export class ApprovalAnswerer {
|
|
|
114
231
|
// No card → no human answerer → fail closed.
|
|
115
232
|
onFailure: e => {
|
|
116
233
|
this.table.close(e, () => {})
|
|
234
|
+
this.notifyWaiting(e.sessionId)
|
|
117
235
|
e.resolve('unavailable')
|
|
118
236
|
},
|
|
119
237
|
})
|
|
@@ -134,12 +252,72 @@ export class ApprovalAnswerer {
|
|
|
134
252
|
if (entry === undefined) return
|
|
135
253
|
const decision = (event.action.value as { decision?: unknown } | undefined)?.decision
|
|
136
254
|
if (decision === 'allow') this.settle(entry, 'allowed-once', 'allowed')
|
|
255
|
+
else if (decision === 'always') this.remember(entry)
|
|
137
256
|
else if (decision === 'deny') this.settle(entry, 'rejected', 'denied')
|
|
138
257
|
}
|
|
139
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Answer a session's pending approvals with one typed topic reply. The
|
|
261
|
+
* caller has already checked that the sender owns the topic. A keyword
|
|
262
|
+
* decides the newest card; any other text rejects every pending card of the
|
|
263
|
+
* session and is reported as `superseded` so the caller still delivers it.
|
|
264
|
+
* @param sessionId - the session the reply was routed to.
|
|
265
|
+
* @param text - the reply text.
|
|
266
|
+
* @returns How the reply was consumed, or `undefined` when nothing is pending.
|
|
267
|
+
*/
|
|
268
|
+
submitText(sessionId: string, text: string): ApprovalTextResult | undefined {
|
|
269
|
+
const pending = this.pendingOf(sessionId)
|
|
270
|
+
const newest = pending.at(-1)
|
|
271
|
+
if (newest === undefined) return undefined
|
|
272
|
+
switch (classifyApprovalReply(text)) {
|
|
273
|
+
case 'allow':
|
|
274
|
+
this.settle(newest, 'allowed-once', 'allowed-text')
|
|
275
|
+
return 'allowed'
|
|
276
|
+
case 'always':
|
|
277
|
+
this.remember(newest)
|
|
278
|
+
return 'remembered'
|
|
279
|
+
case 'deny':
|
|
280
|
+
this.settle(newest, 'rejected', 'denied-text')
|
|
281
|
+
return 'denied'
|
|
282
|
+
case undefined:
|
|
283
|
+
for (const entry of pending) this.settle(entry, 'rejected', 'superseded')
|
|
284
|
+
return 'superseded'
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
140
288
|
/** Withdraw every in-flight question (plugin shutdown). */
|
|
141
289
|
dispose(): void {
|
|
142
290
|
this.table.withdrawAll(entry => this.settle(entry, 'cancelled'))
|
|
291
|
+
this.calls.clear()
|
|
292
|
+
this.grants.clear()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Undecided entries of one session, oldest first. */
|
|
296
|
+
private pendingOf(sessionId: string): Pending[] {
|
|
297
|
+
return [...this.table.values()]
|
|
298
|
+
.filter(entry => entry.sessionId === sessionId && !entry.settled)
|
|
299
|
+
.sort((left, right) => left.openedAt - right.openedAt)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Record a standing grant for the entry's tool + mode, allow it, and allow
|
|
304
|
+
* every other pending card of the session the grant now covers.
|
|
305
|
+
*/
|
|
306
|
+
private remember(entry: Pending): void {
|
|
307
|
+
let grants = this.grants.get(entry.sessionId)
|
|
308
|
+
if (grants === undefined) {
|
|
309
|
+
grants = new Set()
|
|
310
|
+
this.grants.set(entry.sessionId, grants)
|
|
311
|
+
}
|
|
312
|
+
grants.add(entry.grantKey)
|
|
313
|
+
for (const candidate of this.pendingOf(entry.sessionId)) {
|
|
314
|
+
if (candidate.grantKey === entry.grantKey) this.settle(candidate, 'allowed-once', 'remembered')
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Report the session's remaining waits to the streaming card. */
|
|
319
|
+
private notifyWaiting(sessionId: string): void {
|
|
320
|
+
this.hooks.onWaitingChange?.(sessionId, this.pendingOf(sessionId).map(entry => entry.question.toolName))
|
|
143
321
|
}
|
|
144
322
|
|
|
145
323
|
/** Settle once, then patch the card to its terminal state. */
|
|
@@ -149,6 +327,8 @@ export class ApprovalAnswerer {
|
|
|
149
327
|
this.table.patch(entry, buildApprovalSettledCard(entry.question, cardState))
|
|
150
328
|
}
|
|
151
329
|
})
|
|
152
|
-
if (closed)
|
|
330
|
+
if (!closed) return
|
|
331
|
+
this.notifyWaiting(entry.sessionId)
|
|
332
|
+
entry.resolve(outcome)
|
|
153
333
|
}
|
|
154
334
|
}
|
|
@@ -20,8 +20,8 @@ export interface InteractionRoute {
|
|
|
20
20
|
cardMessageId: string
|
|
21
21
|
/** Whether the anchor lives in a real topic (replyInThread). */
|
|
22
22
|
inThread: boolean
|
|
23
|
-
/** The session owner (
|
|
24
|
-
|
|
23
|
+
/** The session owner (user_id) — the only operator allowed to decide. */
|
|
24
|
+
senderUserId: string
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/** Fields every pending interaction card carries. */
|
|
@@ -96,7 +96,7 @@ export class PendingCardTable<E extends PendingCardEntry> {
|
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
if (entry === undefined || entry.settled) return undefined
|
|
99
|
-
if (event.operator.
|
|
99
|
+
if (event.operator.userId !== entry.route.senderUserId) {
|
|
100
100
|
onUnauthorized(entry)
|
|
101
101
|
return undefined
|
|
102
102
|
}
|