@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.
- package/CHANGELOG.md +1037 -50
- package/README.md +59 -36
- package/adapters/sandbox/index.js +25 -0
- package/adapters/sandbox/index.ts +30 -0
- package/lib/client.d.ts +27 -0
- package/lib/client.js +27 -0
- package/lib/endpoint.d.ts +42 -0
- package/lib/endpoint.js +357 -0
- package/lib/index.d.ts +3 -55
- package/lib/index.js +3 -176
- package/lib/protocol.d.ts +71 -0
- package/lib/protocol.js +295 -0
- package/lib/run-config.d.ts +10 -0
- package/lib/run-config.js +30 -0
- package/package.json +64 -23
- package/pages/index/RichTextEditor.js +366 -0
- package/{client → pages/index}/RichTextEditor.tsx +59 -13
- package/pages/index/SandboxChat.js +615 -0
- package/pages/index/SandboxChat.tsx +1186 -0
- package/pages/index/agentTrace.js +559 -0
- package/pages/index/agentTrace.ts +646 -0
- package/pages/index/index.js +18 -0
- package/pages/index/index.tsx +18 -0
- package/pages/index/playgroundState.js +126 -0
- package/pages/index/playgroundState.ts +172 -0
- package/pages/index/sandboxTransport.js +45 -0
- package/pages/index/sandboxTransport.ts +45 -0
- package/plugin.js +8 -0
- package/schema.json +75 -0
- package/src/client.ts +47 -0
- package/src/endpoint.ts +420 -0
- package/src/index.ts +26 -238
- package/src/protocol.ts +385 -0
- package/src/run-config.ts +41 -0
- package/LICENSE +0 -21
- package/client/Sandbox.tsx +0 -493
- package/client/index.tsx +0 -11
- package/client/tsconfig.json +0 -7
- package/dist/index.js +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
package/src/endpoint.ts
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { Endpoint } from 'zhin.js/adapter';
|
|
2
|
+
/**
|
|
3
|
+
* SandboxWsEndpoint — WebSocket lifecycle and OutboundMessageService bridge for /sandbox.
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { type EndpointSendRequest } from 'zhin.js/adapter';
|
|
8
|
+
import type { HttpHost, WsConnection } from '@zhin.js/host-http';
|
|
9
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
10
|
+
import type { CapabilityId } from 'zhin.js';
|
|
11
|
+
import {
|
|
12
|
+
bindSandboxWsSocket,
|
|
13
|
+
formatSandboxOutbound,
|
|
14
|
+
parseSandboxWsPayload,
|
|
15
|
+
sandboxInboundConversation,
|
|
16
|
+
whenWsOpen,
|
|
17
|
+
type ResolvedSandboxBot,
|
|
18
|
+
type SandboxWsSocket,
|
|
19
|
+
} from './protocol.js';
|
|
20
|
+
import { SandboxClient, type SandboxClientConnection } from './client.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 多 sandbox endpoint 共用同一个 HttpHost 时,同 path 的所有 WS listener
|
|
24
|
+
* 都会被回调(入站重复、出站互窜)。按 endpoint 名隔离挂载路径:
|
|
25
|
+
* 首个占用 `/sandbox`(保持 Console 默认兼容),其余退到 `/sandbox/<name>`。
|
|
26
|
+
* 认领记录按 HttpHost 隔离,endpoint stop() 时必须 release。
|
|
27
|
+
*/
|
|
28
|
+
interface SandboxWsPathClaim {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly owners: Set<symbol>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const claimedWsPaths = new WeakMap<HttpHost, Map<string, SandboxWsPathClaim>>();
|
|
34
|
+
|
|
35
|
+
function claimSandboxWsPath(
|
|
36
|
+
http: HttpHost,
|
|
37
|
+
name: string,
|
|
38
|
+
): { readonly path: string; readonly release: () => void } {
|
|
39
|
+
let claims = claimedWsPaths.get(http);
|
|
40
|
+
if (!claims) {
|
|
41
|
+
claims = new Map();
|
|
42
|
+
claimedWsPaths.set(http, claims);
|
|
43
|
+
}
|
|
44
|
+
const candidates = ['/sandbox', `/sandbox/${encodeURIComponent(name)}`];
|
|
45
|
+
let path = candidates.find(
|
|
46
|
+
(candidate) => !claims!.has(candidate) || claims!.get(candidate)?.name === name,
|
|
47
|
+
);
|
|
48
|
+
if (!path) {
|
|
49
|
+
let index = 2;
|
|
50
|
+
path = `/sandbox/${encodeURIComponent(name)}-${index}`;
|
|
51
|
+
while (claims.has(path)) {
|
|
52
|
+
index += 1;
|
|
53
|
+
path = `/sandbox/${encodeURIComponent(name)}-${index}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const owner = Symbol(name);
|
|
57
|
+
const claim = claims.get(path) ?? { name, owners: new Set<symbol>() };
|
|
58
|
+
claim.owners.add(owner);
|
|
59
|
+
claims.set(path, claim);
|
|
60
|
+
const claimed = path;
|
|
61
|
+
const registry = claims;
|
|
62
|
+
return {
|
|
63
|
+
path: claimed,
|
|
64
|
+
release: () => {
|
|
65
|
+
claim.owners.delete(owner);
|
|
66
|
+
if (claim.owners.size === 0 && registry.get(claimed) === claim) {
|
|
67
|
+
registry.delete(claimed);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface SandboxConnection {
|
|
74
|
+
readonly target: string;
|
|
75
|
+
readonly owner: string;
|
|
76
|
+
readonly socket: SandboxWsSocket;
|
|
77
|
+
readonly release: () => void;
|
|
78
|
+
/** true = 占位连接(尚无真实 WS 客户端),send 命中时按 miss 处理。 */
|
|
79
|
+
readonly placeholder?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type ShellIsolationStatus = Readonly<{
|
|
83
|
+
available: boolean;
|
|
84
|
+
provider: 'docker';
|
|
85
|
+
message: string;
|
|
86
|
+
}>;
|
|
87
|
+
|
|
88
|
+
type ShellIsolationProbe = () => Promise<ShellIsolationStatus>;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Share an in-flight readiness probe across reconnects while still allowing a
|
|
92
|
+
* later connection to refresh the informational status after it settles.
|
|
93
|
+
* Kept internal to this module's package surface; tests import it directly.
|
|
94
|
+
*/
|
|
95
|
+
export function createSandboxReadinessGate(probe: ShellIsolationProbe): Readonly<{
|
|
96
|
+
afterProbe: (deliver: (status: ShellIsolationStatus) => void) => void;
|
|
97
|
+
}> {
|
|
98
|
+
let inFlight: Promise<ShellIsolationStatus> | undefined;
|
|
99
|
+
const acquire = (): Promise<ShellIsolationStatus> => {
|
|
100
|
+
if (inFlight) return inFlight;
|
|
101
|
+
const pending = probe();
|
|
102
|
+
const shared = pending.finally(() => {
|
|
103
|
+
if (inFlight === shared) inFlight = undefined;
|
|
104
|
+
});
|
|
105
|
+
inFlight = shared;
|
|
106
|
+
return shared;
|
|
107
|
+
};
|
|
108
|
+
return Object.freeze({
|
|
109
|
+
afterProbe: (deliver) => {
|
|
110
|
+
void acquire().then(deliver);
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface SandboxEndpointOptions {
|
|
116
|
+
readonly id: CapabilityId;
|
|
117
|
+
readonly http: HttpHost;
|
|
118
|
+
readonly defaults: ResolvedSandboxBot;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Sandbox 是本地开发/测试面,无平台社交图谱(好友/群/频道),
|
|
123
|
+
* 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
|
|
124
|
+
*/
|
|
125
|
+
export class SandboxWsEndpoint extends Endpoint<SandboxClient> {
|
|
126
|
+
readonly client: SandboxClient;
|
|
127
|
+
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
128
|
+
|
|
129
|
+
readonly #options: SandboxEndpointOptions;
|
|
130
|
+
readonly #connections = new Map<string, SandboxConnection>();
|
|
131
|
+
readonly #readiness = createSandboxReadinessGate(probeShellIsolation);
|
|
132
|
+
#wsHandleRelease?: () => void;
|
|
133
|
+
#wsPathRelease?: () => void;
|
|
134
|
+
#wsPath = '/sandbox';
|
|
135
|
+
#open = false;
|
|
136
|
+
#started = false;
|
|
137
|
+
|
|
138
|
+
constructor(options: SandboxEndpointOptions) {
|
|
139
|
+
super();
|
|
140
|
+
this.#logger = getAdapterLogger('sandbox', options.defaults.id);
|
|
141
|
+
this.#options = options;
|
|
142
|
+
this.client = new SandboxClient(
|
|
143
|
+
() => this.#wsPath,
|
|
144
|
+
() => [...this.#connections.values()].map((connection) => ({
|
|
145
|
+
target: connection.target,
|
|
146
|
+
owner: connection.owner,
|
|
147
|
+
socket: connection.socket,
|
|
148
|
+
placeholder: connection.placeholder === true,
|
|
149
|
+
} satisfies SandboxClientConnection)),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Live endpoint id (config `id`) — Console endpoint.list/resolve uses it. */
|
|
154
|
+
get name(): string {
|
|
155
|
+
return this.#options.defaults.id;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
start(): void {
|
|
159
|
+
if (this.#started) return;
|
|
160
|
+
this.#started = true;
|
|
161
|
+
// 多 endpoint 同 path 会被全部回调(入站重复、出站互窜),按名隔离。
|
|
162
|
+
const claim = claimSandboxWsPath(this.#options.http, this.#options.defaults.id);
|
|
163
|
+
this.#wsPathRelease = claim.release;
|
|
164
|
+
this.#wsPath = claim.path;
|
|
165
|
+
const handle = this.#options.http.ws(claim.path);
|
|
166
|
+
this.#wsHandleRelease = handle.onConnection((connection) => {
|
|
167
|
+
this.#acceptConnection(connection);
|
|
168
|
+
});
|
|
169
|
+
if (!this.#options.defaults.randomNamePerConnection) {
|
|
170
|
+
this.#ensurePlaceholder(this.#options.defaults.id, this.#options.defaults.owner);
|
|
171
|
+
}
|
|
172
|
+
this.#logger.info(`ws mounted ${claim.path}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
open(): void {
|
|
176
|
+
this.#open = true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
close(): void {
|
|
180
|
+
this.#open = false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
stop(): void {
|
|
184
|
+
this.#open = false;
|
|
185
|
+
this.#wsHandleRelease?.();
|
|
186
|
+
this.#wsHandleRelease = undefined;
|
|
187
|
+
this.#wsPathRelease?.();
|
|
188
|
+
this.#wsPathRelease = undefined;
|
|
189
|
+
for (const connection of this.#connections.values()) {
|
|
190
|
+
connection.release();
|
|
191
|
+
if (!connection.placeholder) {
|
|
192
|
+
try {
|
|
193
|
+
connection.socket.close(1001, 'sandbox endpoint stopped');
|
|
194
|
+
} catch {
|
|
195
|
+
/* already closed */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
this.#connections.clear();
|
|
200
|
+
this.#started = false;
|
|
201
|
+
this.#logger.debug(formatCompact({ op: 'sandbox_stopped' }));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
send({ conversation, payload }: EndpointSendRequest): string {
|
|
205
|
+
if (!this.#open) throw new Error('Sandbox Endpoint is not open');
|
|
206
|
+
// Reply targets this endpoint's own live socket (fixed bot name, or the
|
|
207
|
+
// only live random-name connection); conversation carries kind/id stamp.
|
|
208
|
+
const connection = this.#connections.get(this.#options.defaults.id)
|
|
209
|
+
?? this.#findLiveConnection();
|
|
210
|
+
if (!connection) {
|
|
211
|
+
this.#logger.debug(formatCompact({
|
|
212
|
+
op: 'sandbox_send_miss',
|
|
213
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
214
|
+
}));
|
|
215
|
+
throw new Error('Sandbox Endpoint has no live connection');
|
|
216
|
+
}
|
|
217
|
+
if (connection.placeholder) {
|
|
218
|
+
this.#logger.debug(formatCompact({
|
|
219
|
+
op: 'sandbox_send_placeholder',
|
|
220
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
221
|
+
}));
|
|
222
|
+
throw new Error('Sandbox Endpoint has no live connection');
|
|
223
|
+
}
|
|
224
|
+
// Console UI filters by type+id; stamp the conversation onto outbound wire.
|
|
225
|
+
connection.socket.send(formatSandboxOutbound(payload, {
|
|
226
|
+
type: conversation.kind,
|
|
227
|
+
id: conversation.id,
|
|
228
|
+
bot: this.#options.defaults.id,
|
|
229
|
+
endpoint: connection.target,
|
|
230
|
+
}));
|
|
231
|
+
this.#logger.debug(formatCompact({
|
|
232
|
+
op: 'sandbox_send',
|
|
233
|
+
target: connection.target,
|
|
234
|
+
channelType: conversation.kind,
|
|
235
|
+
channelId: conversation.id,
|
|
236
|
+
}));
|
|
237
|
+
return randomUUID();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Prefer a real (non-placeholder) socket when reply target key is wrong/stale. */
|
|
241
|
+
#findLiveConnection(): SandboxConnection | undefined {
|
|
242
|
+
for (const connection of this.#connections.values()) {
|
|
243
|
+
if (!connection.placeholder) return connection;
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#acceptConnection(connection: WsConnection): void {
|
|
249
|
+
const target = this.#options.defaults.randomNamePerConnection
|
|
250
|
+
? `sandbox-${randomUUID().slice(0, 8)}`
|
|
251
|
+
: this.#options.defaults.id;
|
|
252
|
+
const owner = this.#options.defaults.owner;
|
|
253
|
+
const canExecute = connection.authScope === 'full';
|
|
254
|
+
const socket = connection.socket as SandboxWsSocket;
|
|
255
|
+
// Fixed-name mode reuses `target`; dropping the prior entry without
|
|
256
|
+
// closing its socket leaves a zombie browser tab that still looks
|
|
257
|
+
// connected but never receives outbound traffic.
|
|
258
|
+
const previous = this.#connections.get(target);
|
|
259
|
+
if (previous) {
|
|
260
|
+
previous.release();
|
|
261
|
+
if (!previous.placeholder) {
|
|
262
|
+
try {
|
|
263
|
+
previous.socket.close(4000, 'replaced by new sandbox client');
|
|
264
|
+
} catch {
|
|
265
|
+
/* already closed */
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
this.#connections.delete(target);
|
|
269
|
+
}
|
|
270
|
+
const release = bindSandboxWsSocket(socket, {
|
|
271
|
+
onMessage: (raw) => {
|
|
272
|
+
void this.#emitPlatformEvent('message', raw);
|
|
273
|
+
if (!canExecute) {
|
|
274
|
+
socket.send(JSON.stringify({
|
|
275
|
+
type: 'error',
|
|
276
|
+
id: owner,
|
|
277
|
+
endpoint: target,
|
|
278
|
+
content: [{ type: 'text', data: { text: '当前连接是只读演示权限,不能运行 Agent 任务。' } }],
|
|
279
|
+
timestamp: Date.now(),
|
|
280
|
+
}));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const parsed = parseSandboxWsPayload(raw);
|
|
284
|
+
const sceneId = parsed.id || owner;
|
|
285
|
+
const conversation = sandboxInboundConversation(String(this.#options.id), {
|
|
286
|
+
type: parsed.type,
|
|
287
|
+
id: sceneId,
|
|
288
|
+
});
|
|
289
|
+
this.#logger.debug(formatCompact({
|
|
290
|
+
op: 'sandbox_recv',
|
|
291
|
+
target,
|
|
292
|
+
sender: owner,
|
|
293
|
+
channelType: parsed.type,
|
|
294
|
+
channelId: sceneId,
|
|
295
|
+
text: parsed.text.slice(0, 80),
|
|
296
|
+
}));
|
|
297
|
+
// Don't gate on #open — inbound must always reach the gateway so
|
|
298
|
+
// Command/AI dispatch and outbound replies work.
|
|
299
|
+
void this.emit('message.receive', {
|
|
300
|
+
conversation,
|
|
301
|
+
...(parsed.messageId ? { message: { conversation, id: parsed.messageId } } : {}),
|
|
302
|
+
content: parsed.text,
|
|
303
|
+
sender: { id: owner },
|
|
304
|
+
endpointId: target,
|
|
305
|
+
metadata: Object.freeze({
|
|
306
|
+
type: parsed.type,
|
|
307
|
+
channelType: parsed.type,
|
|
308
|
+
channelId: parsed.id || owner,
|
|
309
|
+
elements: parsed.content,
|
|
310
|
+
timestamp: parsed.timestamp,
|
|
311
|
+
...(parsed.action ? { action: parsed.action } : {}),
|
|
312
|
+
...(parsed.agentRun ? { sandboxAgentRun: parsed.agentRun } : {}),
|
|
313
|
+
}),
|
|
314
|
+
}).catch((err) => {
|
|
315
|
+
this.#logger.warn(formatCompact({
|
|
316
|
+
op: 'sandbox_gateway_receive_failed',
|
|
317
|
+
target,
|
|
318
|
+
error: err instanceof Error ? err.message : String(err),
|
|
319
|
+
}));
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
onClose: () => {
|
|
323
|
+
void this.#emitPlatformEvent('connection.close', { target, owner });
|
|
324
|
+
// Only drop the map entry if we still own this socket — a replace
|
|
325
|
+
// may have already swapped in a newer connection for the same target.
|
|
326
|
+
const current = this.#connections.get(target);
|
|
327
|
+
if (current && current.socket === socket) {
|
|
328
|
+
this.#connections.delete(target);
|
|
329
|
+
this.#logger.debug(formatCompact({ op: 'sandbox_ws_closed', target }));
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
onError: (err) => {
|
|
333
|
+
void this.#emitPlatformEvent('connection.error', { target, owner, error: err });
|
|
334
|
+
this.#logger.warn(formatCompact({
|
|
335
|
+
op: 'sandbox_ws_error',
|
|
336
|
+
target,
|
|
337
|
+
error: err instanceof Error ? err.message : String(err),
|
|
338
|
+
}));
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
this.#connections.set(target, { target, owner, socket, release });
|
|
342
|
+
void this.#emitPlatformEvent('connection.open', { target, owner, socket });
|
|
343
|
+
this.#logger.debug(formatCompact({ op: 'sandbox_ws_connected', target, owner }));
|
|
344
|
+
this.#readiness.afterProbe((shellIsolation) => {
|
|
345
|
+
const current = this.#connections.get(target);
|
|
346
|
+
if (!current || current.socket !== socket) return;
|
|
347
|
+
const readyPayload = JSON.stringify({
|
|
348
|
+
type: 'ready',
|
|
349
|
+
id: owner,
|
|
350
|
+
endpoint: target,
|
|
351
|
+
workingDirectory: process.cwd(),
|
|
352
|
+
canExecute,
|
|
353
|
+
shellIsolation,
|
|
354
|
+
content: [{
|
|
355
|
+
type: 'text',
|
|
356
|
+
data: {
|
|
357
|
+
text: [
|
|
358
|
+
`已连接 Sandbox「${target}」`,
|
|
359
|
+
`与 Node Host 控制台沙盒协议一致(${this.#wsPath})`,
|
|
360
|
+
'Agent 试验台会话已启用持久化运行配置。',
|
|
361
|
+
].join('\n'),
|
|
362
|
+
},
|
|
363
|
+
}],
|
|
364
|
+
timestamp: Date.now(),
|
|
365
|
+
});
|
|
366
|
+
whenWsOpen(socket, () => socket.send(readyPayload));
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
#ensurePlaceholder(name: string, owner: string): void {
|
|
371
|
+
if (this.#connections.has(name)) return;
|
|
372
|
+
this.#connections.set(name, {
|
|
373
|
+
target: name,
|
|
374
|
+
owner,
|
|
375
|
+
socket: { send: () => undefined, close: () => undefined },
|
|
376
|
+
release: () => undefined,
|
|
377
|
+
placeholder: true,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async #emitPlatformEvent(name: string, event: unknown): Promise<void> {
|
|
382
|
+
await this.emitPlatform(name, event).catch((error) => {
|
|
383
|
+
this.#logger.warn(formatCompact({
|
|
384
|
+
op: 'sandbox_platform_event_failed',
|
|
385
|
+
event: name,
|
|
386
|
+
error: error instanceof Error ? error.message : String(error),
|
|
387
|
+
}));
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function probeShellIsolation(): Promise<ShellIsolationStatus> {
|
|
393
|
+
return new Promise<ShellIsolationStatus>((resolve) => {
|
|
394
|
+
execFile('docker', ['info', '--format', '{{.ServerVersion}}'], {
|
|
395
|
+
encoding: 'utf8',
|
|
396
|
+
timeout: 500,
|
|
397
|
+
}, (error, stdout) => {
|
|
398
|
+
if (!error) {
|
|
399
|
+
const version = stdout.trim();
|
|
400
|
+
resolve(Object.freeze({
|
|
401
|
+
available: true,
|
|
402
|
+
provider: 'docker',
|
|
403
|
+
message: version ? `Docker ${version}` : 'Docker ready',
|
|
404
|
+
}));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
resolve(Object.freeze({
|
|
408
|
+
available: false,
|
|
409
|
+
provider: 'docker',
|
|
410
|
+
message: error.killed || error.code === 'ETIMEDOUT'
|
|
411
|
+
? 'Docker readiness check timed out'
|
|
412
|
+
: 'Docker daemon is unavailable',
|
|
413
|
+
}));
|
|
414
|
+
});
|
|
415
|
+
}).catch(() => Object.freeze({
|
|
416
|
+
available: false,
|
|
417
|
+
provider: 'docker' as const,
|
|
418
|
+
message: 'Docker is unavailable',
|
|
419
|
+
}));
|
|
420
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,238 +1,26 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
MessageType,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
namespace Plugin {
|
|
28
|
-
interface Contexts {
|
|
29
|
-
router: Router;
|
|
30
|
-
web: any;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface Adapters {
|
|
35
|
-
sandbox: SandboxAdapter;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const plugin = usePlugin();
|
|
40
|
-
const logger = plugin.logger;
|
|
41
|
-
|
|
42
|
-
interface WebSocketMessage {
|
|
43
|
-
type: MessageType;
|
|
44
|
-
id: string;
|
|
45
|
-
content: MessageElement[] | string;
|
|
46
|
-
timestamp: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export class SandboxBot extends EventEmitter implements Bot<SandboxConfig, { content: MessageElement[]; ts: number }> {
|
|
50
|
-
$connected: boolean = false;
|
|
51
|
-
|
|
52
|
-
get $id() {
|
|
53
|
-
return this.$config.name;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
private logger = logger;
|
|
57
|
-
|
|
58
|
-
constructor(public adapter: SandboxAdapter, public $config: SandboxConfig) {
|
|
59
|
-
super();
|
|
60
|
-
this.$config.ws.on("message", (data) => {
|
|
61
|
-
const message = JSON.parse(data.toString()) as WebSocketMessage;
|
|
62
|
-
// 确保 content 是 MessageElement[] 格式
|
|
63
|
-
const content: MessageElement[] = typeof message.content === 'string'
|
|
64
|
-
? [{ type: 'text', data: { text: message.content } }]
|
|
65
|
-
: message.content;
|
|
66
|
-
this.logger.debug(`${this.$config.name} recv ${message.type}(${message.id}):${segment.raw(content)}`);
|
|
67
|
-
const formattedMessage = this.$formatMessage({ content: content, type: message.type, id: message.id, ts: message.timestamp });
|
|
68
|
-
this.adapter.emit("message.receive", formattedMessage);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
this.$config.ws.on("close", () => {
|
|
72
|
-
this.logger.debug(`Sandbox bot ${this.$config.name} disconnected`);
|
|
73
|
-
this.$connected = false;
|
|
74
|
-
// 从 adapter 中移除 bot
|
|
75
|
-
this.adapter.bots.delete(this.$id);
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async $connect(): Promise<void> {
|
|
80
|
-
this.$connected = true;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async $disconnect(): Promise<void> {
|
|
84
|
-
this.$config.ws.close();
|
|
85
|
-
this.$connected = false;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
$formatMessage({ content, type, id, ts }: { content: MessageElement[]; id: string; type: MessageType; ts: number }) {
|
|
89
|
-
// 沙箱模式:发言者即为 owner
|
|
90
|
-
if (!this.$config.owner) this.$config.owner = id;
|
|
91
|
-
const message = Message.from(
|
|
92
|
-
{ content, ts },
|
|
93
|
-
{
|
|
94
|
-
$id: `${ts}`,
|
|
95
|
-
$adapter: "sandbox" as const,
|
|
96
|
-
$bot: `${this.$config.name}`,
|
|
97
|
-
$sender: {
|
|
98
|
-
id: `${id}`,
|
|
99
|
-
name: `mock`,
|
|
100
|
-
},
|
|
101
|
-
$channel: {
|
|
102
|
-
id: `${id}`,
|
|
103
|
-
type: type,
|
|
104
|
-
},
|
|
105
|
-
$content: content,
|
|
106
|
-
$raw: segment.raw(content),
|
|
107
|
-
$timestamp: ts,
|
|
108
|
-
$recall: async () => {
|
|
109
|
-
await this.$recallMessage(message.$id);
|
|
110
|
-
},
|
|
111
|
-
$reply: async (content: SendContent, quote?: boolean | string): Promise<string> => {
|
|
112
|
-
if (!Array.isArray(content)) content = [content];
|
|
113
|
-
if (quote) content.unshift({ type: "reply", data: { id: typeof quote === "boolean" ? message.$id : quote } });
|
|
114
|
-
return await this.adapter.sendMessage({
|
|
115
|
-
...message.$channel,
|
|
116
|
-
context: "sandbox",
|
|
117
|
-
bot: `${this.$config.name}`,
|
|
118
|
-
content,
|
|
119
|
-
});
|
|
120
|
-
},
|
|
121
|
-
}
|
|
122
|
-
);
|
|
123
|
-
return message;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async $sendMessage(options: SendOptions): Promise<string> {
|
|
127
|
-
if (!this.$connected) return "";
|
|
128
|
-
this.logger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(options.content)}`);
|
|
129
|
-
options.bot = this.$config.name;
|
|
130
|
-
options.context = "sandbox";
|
|
131
|
-
this.$config.ws.send(
|
|
132
|
-
JSON.stringify({
|
|
133
|
-
...options,
|
|
134
|
-
content: options.content, // 发送消息段数组
|
|
135
|
-
timestamp: Date.now(),
|
|
136
|
-
})
|
|
137
|
-
);
|
|
138
|
-
return "";
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
async $recallMessage(id: string): Promise<void> {
|
|
142
|
-
// 沙盒不支持撤回消息
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
class SandboxAdapter extends Adapter<SandboxBot> {
|
|
147
|
-
wss?: ReturnType<Router["ws"]>;
|
|
148
|
-
|
|
149
|
-
constructor(plugin: Plugin) {
|
|
150
|
-
super(plugin, "sandbox", []);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
createBot(config: SandboxConfig): SandboxBot {
|
|
154
|
-
const bot = new SandboxBot(this, config);
|
|
155
|
-
// 将 bot 添加到 bots Map 中
|
|
156
|
-
this.bots.set(bot.$id, bot);
|
|
157
|
-
return bot;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
async start(): Promise<void> {
|
|
161
|
-
// start 方法会在 mounted 时被调用
|
|
162
|
-
// WebSocket server 的创建在 useContext("router") 中处理
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
async setupWebSocket(router: Router): Promise<void> {
|
|
166
|
-
if (this.wss) return; // 已经设置过了
|
|
167
|
-
// 创建 WebSocket server
|
|
168
|
-
this.wss = router.ws("/sandbox");
|
|
169
|
-
|
|
170
|
-
this.wss.on("connection", (ws: WebSocket, req) => {
|
|
171
|
-
// 为每个连接创建一个唯一的 bot 名称
|
|
172
|
-
const botName = `sandbox-${Math.random().toString(36).slice(2, 9)}`;
|
|
173
|
-
logger.debug(`New sandbox connection: ${botName} from ${req.socket.remoteAddress}`);
|
|
174
|
-
|
|
175
|
-
// 创建 bot 配置
|
|
176
|
-
const config: SandboxConfig = {
|
|
177
|
-
context: "sandbox",
|
|
178
|
-
ws,
|
|
179
|
-
name: botName,
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
// 创建并连接 bot
|
|
183
|
-
const bot = this.createBot(config);
|
|
184
|
-
bot.$connect();
|
|
185
|
-
|
|
186
|
-
// WebSocket 关闭时清理
|
|
187
|
-
ws.on("close", () => {
|
|
188
|
-
logger.debug(`Sandbox connection closed: ${botName}`);
|
|
189
|
-
this.bots.delete(bot.$id);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
ws.on("error", (error) => {
|
|
193
|
-
logger.error(`Sandbox WebSocket error for ${botName}:`, error);
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
logger.debug("Sandbox WebSocket server started at /sandbox");
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const { provide } = usePlugin();
|
|
202
|
-
|
|
203
|
-
provide({
|
|
204
|
-
name: "sandbox",
|
|
205
|
-
description: "Sandbox Adapter",
|
|
206
|
-
mounted: async (p: Plugin) => {
|
|
207
|
-
const adapter = new SandboxAdapter(p);
|
|
208
|
-
await adapter.start();
|
|
209
|
-
return adapter;
|
|
210
|
-
},
|
|
211
|
-
dispose: async (adapter: SandboxAdapter) => {
|
|
212
|
-
// 关闭所有 bot 连接
|
|
213
|
-
for (const bot of adapter.bots.values()) {
|
|
214
|
-
await bot.$disconnect();
|
|
215
|
-
}
|
|
216
|
-
// 关闭 WebSocket server
|
|
217
|
-
adapter.wss?.close();
|
|
218
|
-
await adapter.stop();
|
|
219
|
-
},
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
// 使用 router 上下文创建 WebSocket server
|
|
223
|
-
plugin.useContext("router", async (router: Router) => {
|
|
224
|
-
// 等待 sandbox adapter 就绪
|
|
225
|
-
plugin.useContext("sandbox", async (adapter: SandboxAdapter) => {
|
|
226
|
-
await adapter.setupWebSocket(router);
|
|
227
|
-
});
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
// 使用 web 上下文注册客户端入口
|
|
231
|
-
plugin.useContext("web", (web: any) => {
|
|
232
|
-
// 注册 Sandbox 适配器的客户端入口文件
|
|
233
|
-
const dispose = web.addEntry({
|
|
234
|
-
production: path.resolve(import.meta.dirname, "../dist/index.js"),
|
|
235
|
-
development: path.resolve(import.meta.dirname, "../client/index.tsx"),
|
|
236
|
-
});
|
|
237
|
-
return dispose;
|
|
238
|
-
});
|
|
1
|
+
export {
|
|
2
|
+
bindSandboxWsSocket,
|
|
3
|
+
formatSandboxOutbound,
|
|
4
|
+
normalizeSandboxOutboundSegments,
|
|
5
|
+
parseSandboxWsPayload,
|
|
6
|
+
resolveSandboxEndpoint,
|
|
7
|
+
sandboxInboundConversation,
|
|
8
|
+
whenWsOpen,
|
|
9
|
+
type MessageElement,
|
|
10
|
+
type MessageType,
|
|
11
|
+
type ResolvedSandboxBot,
|
|
12
|
+
type SandboxEndpointConfig,
|
|
13
|
+
type SandboxWsSocket,
|
|
14
|
+
} from './protocol.js';
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
SandboxClient,
|
|
18
|
+
sandboxClient,
|
|
19
|
+
type SandboxClientEventMap,
|
|
20
|
+
type SandboxClientConnection,
|
|
21
|
+
} from './client.js';
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
SandboxWsEndpoint,
|
|
25
|
+
type SandboxEndpointOptions,
|
|
26
|
+
} from './endpoint.js';
|