@zhin.js/adapter-sandbox 1.0.70 → 1.1.2

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.
@@ -0,0 +1,385 @@
1
+ /** Sandbox WebSocket wire protocol helpers (no legacy Adapter/Endpoint). */
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import { isMediaRef, type ConversationKind, type ConversationRef } from '@zhin.js/im-contract';
5
+ import { formatCompact, getLogger } from '@zhin.js/logger';
6
+ import {
7
+ normalizeSandboxAgentRunConfig,
8
+ type SandboxAgentRunConfig,
9
+ } from './run-config.js';
10
+
11
+ const logger = getLogger('sandbox');
12
+
13
+ export type MessageType = 'private' | 'group' | 'guild' | 'direct' | 'channel';
14
+
15
+ export interface MessageElement {
16
+ readonly type: string;
17
+ readonly data?: Record<string, unknown>;
18
+ }
19
+
20
+ export interface SandboxWsSocket {
21
+ send(data: string): void;
22
+ close(code?: number, reason?: string): void;
23
+ on?(event: 'message' | 'close' | 'error', listener: (...args: unknown[]) => void): void;
24
+ off?(
25
+ event: 'message' | 'close' | 'error',
26
+ listener: (...args: unknown[]) => void,
27
+ ): void;
28
+ addEventListener?(
29
+ type: 'message' | 'close' | 'error',
30
+ listener: (ev: Event | MessageEvent | CloseEvent) => void,
31
+ ): void;
32
+ removeEventListener?(
33
+ type: 'message' | 'close' | 'error',
34
+ listener: (ev: Event | MessageEvent | CloseEvent) => void,
35
+ ): void;
36
+ }
37
+
38
+ export type ResolvedSandboxBot = {
39
+ readonly context: 'sandbox';
40
+ readonly id: string;
41
+ readonly owner: string;
42
+ readonly randomNamePerConnection: boolean;
43
+ };
44
+
45
+ /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
46
+ export interface SandboxEndpointConfig {
47
+ readonly id?: string;
48
+ readonly owner?: string;
49
+ }
50
+
51
+ export function resolveSandboxEndpoint(
52
+ config: SandboxEndpointConfig,
53
+ ): ResolvedSandboxBot {
54
+ const id = optionalEndpointField(config.id) ?? 'sandbox-bot';
55
+ const owner = optionalEndpointField(config.owner) ?? 'sandbox-user';
56
+ return {
57
+ context: 'sandbox',
58
+ id,
59
+ owner,
60
+ // The endpoint id participates in the Agent session key. Keep it stable
61
+ // across browser reconnects and Host restarts so a persisted playground
62
+ // session resumes the same Agent context.
63
+ randomNamePerConnection: false,
64
+ };
65
+ }
66
+
67
+ function optionalEndpointField(value: unknown): string | undefined {
68
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
69
+ }
70
+
71
+ export function bindSandboxWsSocket(
72
+ ws: SandboxWsSocket,
73
+ handlers: {
74
+ onMessage: (raw: string) => void;
75
+ onClose: () => void;
76
+ onError?: (err: unknown) => void;
77
+ },
78
+ ): () => void {
79
+ if (typeof ws.on === 'function') {
80
+ const onMessage = (...args: unknown[]) => {
81
+ const data = args[0];
82
+ const raw = typeof data === 'string'
83
+ ? data
84
+ : data instanceof ArrayBuffer
85
+ ? new TextDecoder().decode(data)
86
+ : Buffer.isBuffer(data)
87
+ ? data.toString()
88
+ : String(data ?? '');
89
+ handlers.onMessage(raw);
90
+ };
91
+ ws.on('message', onMessage);
92
+ ws.on('close', handlers.onClose);
93
+ if (handlers.onError) ws.on('error', handlers.onError);
94
+ return () => {
95
+ ws.off?.('message', onMessage);
96
+ ws.off?.('close', handlers.onClose);
97
+ if (handlers.onError) ws.off?.('error', handlers.onError);
98
+ };
99
+ }
100
+ const onMessage = (ev: Event) => {
101
+ const data = (ev as MessageEvent).data;
102
+ handlers.onMessage(typeof data === 'string' ? data : '');
103
+ };
104
+ const onClose = () => handlers.onClose();
105
+ const onError = handlers.onError
106
+ ? () => handlers.onError?.(new Error('WebSocket error'))
107
+ : undefined;
108
+ ws.addEventListener!('message', onMessage);
109
+ ws.addEventListener!('close', onClose);
110
+ if (onError) ws.addEventListener!('error', onError);
111
+ return () => {
112
+ ws.removeEventListener!('message', onMessage);
113
+ ws.removeEventListener!('close', onClose);
114
+ if (onError) ws.removeEventListener!('error', onError);
115
+ };
116
+ }
117
+
118
+ export function parseSandboxWsPayload(raw: string): {
119
+ type: MessageType;
120
+ id: string;
121
+ messageId?: string;
122
+ content: MessageElement[];
123
+ timestamp: number;
124
+ text: string;
125
+ action?: { id: string; payload: string };
126
+ agentRun?: SandboxAgentRunConfig;
127
+ } {
128
+ let payload: {
129
+ type?: MessageType;
130
+ id?: string;
131
+ content?: MessageElement[] | string;
132
+ text?: string;
133
+ timestamp?: number;
134
+ messageId?: unknown;
135
+ agentRun?: unknown;
136
+ };
137
+ try {
138
+ payload = JSON.parse(raw) as typeof payload;
139
+ } catch {
140
+ payload = { text: raw };
141
+ }
142
+ const type = payload.type ?? 'private';
143
+ const id = payload.id ?? 'sandbox-user';
144
+ const content: MessageElement[] = typeof payload.content === 'string'
145
+ ? [{ type: 'text', data: { text: payload.content } }]
146
+ : Array.isArray(payload.content)
147
+ ? payload.content
148
+ : [{ type: 'text', data: { text: payload.text ?? raw } }];
149
+
150
+ const actionSegment = content.find((segment) => segment.type === 'action');
151
+ let action: { id: string; payload: string } | undefined;
152
+ if (actionSegment?.data) {
153
+ const actionPayload = typeof actionSegment.data.payload === 'string'
154
+ ? actionSegment.data.payload
155
+ : typeof actionSegment.data.id === 'string'
156
+ ? actionSegment.data.id
157
+ : '';
158
+ const actionId = typeof actionSegment.data.id === 'string'
159
+ ? actionSegment.data.id
160
+ : actionPayload;
161
+ if (actionId || actionPayload) {
162
+ action = { id: actionId || actionPayload, payload: actionPayload || actionId };
163
+ }
164
+ }
165
+
166
+ let text = content
167
+ .flatMap((segment) => (segment.type === 'text' && typeof segment.data?.text === 'string'
168
+ ? [segment.data.text]
169
+ : []))
170
+ .join('\n');
171
+ if (!text.trim()) {
172
+ text = (typeof payload.text === 'string' && payload.text.trim())
173
+ ? payload.text
174
+ : action?.payload ?? raw;
175
+ }
176
+ const agentRun = normalizeSandboxAgentRunConfig(payload.agentRun);
177
+ const rawMessageId = typeof payload.messageId === 'string' ? payload.messageId.trim() : '';
178
+ const messageId = /^[A-Za-z0-9._:-]{1,160}$/u.test(rawMessageId) ? rawMessageId : undefined;
179
+ return {
180
+ type,
181
+ id,
182
+ ...(messageId ? { messageId } : {}),
183
+ content,
184
+ timestamp: payload.timestamp ?? Date.now(),
185
+ text,
186
+ action,
187
+ ...(agentRun ? { agentRun } : {}),
188
+ };
189
+ }
190
+
191
+ /**
192
+ * 入站归一化 → ConversationRef。sandbox 无平台社交图谱:
193
+ * `private`/`group`/`channel` 直映射;`direct`(私聊)归 'private';
194
+ * `guild`(频道容器语义)归 'channel'。无 guild/temp 容器信息,不产生 parent。
195
+ */
196
+ export function sandboxInboundConversation(
197
+ endpointKey: string,
198
+ msg: { readonly type: MessageType; readonly id: string },
199
+ ): ConversationRef {
200
+ const kind: ConversationKind = msg.type === 'direct'
201
+ ? 'private'
202
+ : msg.type === 'guild'
203
+ ? 'channel'
204
+ : msg.type;
205
+ return {
206
+ endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
207
+ kind,
208
+ id: msg.id,
209
+ };
210
+ }
211
+
212
+ export type SandboxOutboundChannel = {
213
+ readonly type?: string;
214
+ readonly id?: string;
215
+ readonly bot?: string;
216
+ readonly endpoint?: string;
217
+ readonly messageId?: string;
218
+ };
219
+
220
+ const MEDIA_SEGMENT_TYPES = new Set(['image', 'audio', 'video', 'file']);
221
+
222
+ /**
223
+ * base64 内联值:Console UI 的 resolveMediaSrc 只识别 data: / base64://
224
+ * 前缀;裸 base64 补前缀(有 mime_type 时拼成可直转 data: URL 的形状)。
225
+ */
226
+ function toInlineBase64Value(value: string, mimeType?: string): string {
227
+ const trimmed = value.trim();
228
+ if (trimmed.startsWith('base64://') || trimmed.startsWith('data:')) return trimmed;
229
+ return mimeType
230
+ ? `base64://${mimeType};base64,${trimmed}`
231
+ : `base64://${trimmed}`;
232
+ }
233
+
234
+ /**
235
+ * 出站媒体段归一(canonical MediaRef 唯一来源,不读 legacy url/file/base64/src):
236
+ * - kind=url → 浏览器直连 URL,原样透传;
237
+ * - kind=base64 → 内联直发(补 base64:// 前缀供 Console UI 解析);
238
+ * - kind=path → 读盘物化为 base64 内联(sandbox 无平台上传通道);
239
+ * - kind=file → sandbox 无不透明引用通道,丢弃。
240
+ * 无 canonical `data.media` 的媒体段一律 warn + 丢弃。
241
+ */
242
+ function normalizeMediaSegment(segment: MessageElement): MessageElement | null {
243
+ const data = segment.data ?? {};
244
+ const media = data.media;
245
+ if (!isMediaRef(media)) {
246
+ logger.warn(formatCompact({
247
+ op: 'sandbox_outbound_media_dropped',
248
+ type: segment.type,
249
+ reason: 'missing_media_ref',
250
+ }));
251
+ return null;
252
+ }
253
+ if (media.kind === 'url') return { type: segment.type, data };
254
+ if (media.kind === 'base64') {
255
+ return {
256
+ type: segment.type,
257
+ data: {
258
+ ...data,
259
+ media: { ...media, value: toInlineBase64Value(media.value, media.mime_type) },
260
+ },
261
+ };
262
+ }
263
+ if (media.kind === 'path') {
264
+ try {
265
+ const base64 = readFileSync(media.value).toString('base64');
266
+ return {
267
+ type: segment.type,
268
+ data: {
269
+ ...data,
270
+ media: {
271
+ ...media,
272
+ kind: 'base64',
273
+ value: toInlineBase64Value(base64, media.mime_type),
274
+ },
275
+ },
276
+ };
277
+ } catch (err) {
278
+ logger.warn(formatCompact({
279
+ op: 'sandbox_outbound_media_dropped',
280
+ type: segment.type,
281
+ reason: 'path_read_failed',
282
+ error: err instanceof Error ? err.message : String(err),
283
+ }));
284
+ return null;
285
+ }
286
+ }
287
+ logger.warn(formatCompact({
288
+ op: 'sandbox_outbound_media_dropped',
289
+ type: segment.type,
290
+ reason: 'unsupported_media_kind',
291
+ }));
292
+ return null;
293
+ }
294
+
295
+ /** 出站段数组归一:媒体段走 MediaRef-only 归一,其余段原样透传。 */
296
+ export function normalizeSandboxOutboundSegments(segments: readonly unknown[]): unknown[] {
297
+ const out: unknown[] = [];
298
+ for (const item of segments) {
299
+ if (
300
+ item
301
+ && typeof item === 'object'
302
+ && !Array.isArray(item)
303
+ && MEDIA_SEGMENT_TYPES.has(String((item as MessageElement).type))
304
+ ) {
305
+ const normalized = normalizeMediaSegment(item as MessageElement);
306
+ if (normalized) out.push(normalized);
307
+ continue;
308
+ }
309
+ out.push(item);
310
+ }
311
+ return out;
312
+ }
313
+
314
+ /**
315
+ * Wire-encode an already-rendered outbound payload.
316
+ * Stamps `channel` so Console SandboxChat can filter by type+id (otherwise
317
+ * replies look like they disappeared).
318
+ */
319
+ export function formatSandboxOutbound(
320
+ payload: unknown,
321
+ channel: SandboxOutboundChannel = {},
322
+ ): string {
323
+ const stamp: Record<string, unknown> = {};
324
+ if (channel.type) stamp.type = channel.type;
325
+ if (channel.id) stamp.id = channel.id;
326
+ if (channel.bot) stamp.bot = channel.bot;
327
+ if (channel.endpoint) stamp.endpoint = channel.endpoint;
328
+ if (channel.messageId) stamp.messageId = channel.messageId;
329
+
330
+ if (typeof payload === 'string') {
331
+ return JSON.stringify({
332
+ ...stamp,
333
+ content: [{ type: 'text', data: { text: payload } }],
334
+ timestamp: Date.now(),
335
+ });
336
+ }
337
+ if (Array.isArray(payload)) {
338
+ return JSON.stringify({
339
+ ...stamp,
340
+ content: normalizeSandboxOutboundSegments(payload),
341
+ timestamp: Date.now(),
342
+ });
343
+ }
344
+ // Already a wire envelope ({ content, type, … }) — pass through so the
345
+ // Console UI can read `content` / `type` without an extra nesting layer.
346
+ // Bare segment objects ({ type: 'text', data: … }) still need wrapping.
347
+ if (
348
+ payload
349
+ && typeof payload === 'object'
350
+ && !Array.isArray(payload)
351
+ && (
352
+ 'content' in (payload as object)
353
+ || 'type' in (payload as object) && 'timestamp' in (payload as object)
354
+ )
355
+ ) {
356
+ const envelope = payload as Record<string, unknown>;
357
+ return JSON.stringify({
358
+ ...stamp,
359
+ ...envelope,
360
+ ...(Array.isArray(envelope.content)
361
+ ? { content: normalizeSandboxOutboundSegments(envelope.content) }
362
+ : {}),
363
+ type: envelope.type ?? stamp.type,
364
+ id: envelope.id ?? stamp.id,
365
+ timestamp: typeof envelope.timestamp === 'number' ? envelope.timestamp : Date.now(),
366
+ });
367
+ }
368
+ return JSON.stringify({ ...stamp, content: payload, timestamp: Date.now() });
369
+ }
370
+
371
+ /** WebSocket.OPEN 常量值;Node <22 无全局 WebSocket,不能用 WebSocket.OPEN。 */
372
+ const WS_OPEN = 1;
373
+
374
+ export function whenWsOpen(ws: SandboxWsSocket, fn: () => void): void {
375
+ const std = ws as WebSocket;
376
+ if (typeof std.readyState === 'number') {
377
+ if (std.readyState === WS_OPEN) {
378
+ fn();
379
+ return;
380
+ }
381
+ std.addEventListener('open', fn, { once: true });
382
+ return;
383
+ }
384
+ fn();
385
+ }
@@ -0,0 +1,41 @@
1
+ export type SandboxSafetyMode = 'read-only' | 'workspace-write' | 'danger-full-access';
2
+ export type SandboxApprovalMode = 'ask' | 'auto' | 'bypass';
3
+
4
+ export interface SandboxAgentRunConfig {
5
+ readonly workingDirectory: string;
6
+ readonly safetyMode: SandboxSafetyMode;
7
+ readonly approvalMode: SandboxApprovalMode;
8
+ readonly networkAccess: boolean;
9
+ }
10
+
11
+ export const DEFAULT_SANDBOX_AGENT_RUN_CONFIG: SandboxAgentRunConfig = Object.freeze({
12
+ workingDirectory: '',
13
+ safetyMode: 'workspace-write',
14
+ approvalMode: 'ask',
15
+ networkAccess: false,
16
+ });
17
+
18
+ const SAFETY_MODES = new Set<SandboxSafetyMode>(['read-only', 'workspace-write', 'danger-full-access']);
19
+ const APPROVAL_MODES = new Set<SandboxApprovalMode>(['ask', 'auto', 'bypass']);
20
+
21
+ export function normalizeSandboxAgentRunConfig(value: unknown): SandboxAgentRunConfig | undefined {
22
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
23
+ const input = value as Record<string, unknown>;
24
+ const workingDirectory = typeof input.workingDirectory === 'string'
25
+ ? input.workingDirectory.trim().slice(0, 4096)
26
+ : '';
27
+ const safetyMode = SAFETY_MODES.has(input.safetyMode as SandboxSafetyMode)
28
+ ? input.safetyMode as SandboxSafetyMode
29
+ : DEFAULT_SANDBOX_AGENT_RUN_CONFIG.safetyMode;
30
+ const approvalMode = APPROVAL_MODES.has(input.approvalMode as SandboxApprovalMode)
31
+ ? input.approvalMode as SandboxApprovalMode
32
+ : DEFAULT_SANDBOX_AGENT_RUN_CONFIG.approvalMode;
33
+ return Object.freeze({
34
+ workingDirectory,
35
+ safetyMode,
36
+ approvalMode,
37
+ // Full host access cannot be combined with a portable network namespace.
38
+ // Keep the contract honest: danger mode includes network authority.
39
+ networkAccess: safetyMode === 'danger-full-access' || input.networkAccess === true,
40
+ });
41
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2025 凉菜
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.