@zhin.js/adapter-sandbox 1.0.69 → 1.1.0

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 (46) hide show
  1. package/CHANGELOG.md +961 -49
  2. package/README.md +60 -36
  3. package/adapters/sandbox.js +25 -0
  4. package/adapters/sandbox.ts +30 -0
  5. package/agent/skills/sandbox.md +33 -0
  6. package/lib/client.d.ts +27 -0
  7. package/lib/client.js +27 -0
  8. package/lib/endpoint.d.ts +42 -0
  9. package/lib/endpoint.js +357 -0
  10. package/lib/index.d.ts +3 -53
  11. package/lib/index.js +3 -173
  12. package/lib/protocol.d.ts +78 -0
  13. package/lib/protocol.js +301 -0
  14. package/lib/run-config.d.ts +10 -0
  15. package/lib/run-config.js +30 -0
  16. package/package.json +60 -24
  17. package/pages/RichTextEditor.js +366 -0
  18. package/{client → pages}/RichTextEditor.tsx +59 -13
  19. package/pages/SandboxChat.js +615 -0
  20. package/pages/SandboxChat.tsx +1186 -0
  21. package/pages/agentTrace.js +559 -0
  22. package/pages/agentTrace.test.js +235 -0
  23. package/pages/agentTrace.test.ts +265 -0
  24. package/pages/agentTrace.ts +646 -0
  25. package/pages/index.js +18 -0
  26. package/pages/index.tsx +18 -0
  27. package/pages/playgroundState.js +126 -0
  28. package/pages/playgroundState.test.js +92 -0
  29. package/pages/playgroundState.test.ts +105 -0
  30. package/pages/playgroundState.ts +172 -0
  31. package/pages/sandboxTransport.js +45 -0
  32. package/pages/sandboxTransport.ts +45 -0
  33. package/plugin.js +8 -0
  34. package/schema.json +76 -0
  35. package/src/client.ts +47 -0
  36. package/src/endpoint.ts +420 -0
  37. package/src/index.ts +26 -234
  38. package/src/protocol.ts +398 -0
  39. package/src/run-config.ts +41 -0
  40. package/LICENSE +0 -21
  41. package/client/Sandbox.tsx +0 -493
  42. package/client/index.tsx +0 -11
  43. package/client/tsconfig.json +0 -7
  44. package/dist/index.js +0 -1
  45. package/lib/index.d.ts.map +0 -1
  46. package/lib/index.js.map +0 -1
package/src/client.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { SandboxWsSocket } from './protocol.js';
2
+ import { defineEndpointClient } from 'zhin.js/adapter';
3
+
4
+ export interface SandboxClientConnection {
5
+ readonly target: string;
6
+ readonly owner: string;
7
+ readonly socket: SandboxWsSocket;
8
+ readonly placeholder: boolean;
9
+ }
10
+
11
+ /** Direct view of the live Sandbox protocol clients owned by one Endpoint. */
12
+ export class SandboxClient {
13
+ constructor(
14
+ private readonly resolvePath: () => string,
15
+ private readonly resolveConnections: () => Iterable<SandboxClientConnection>,
16
+ ) {}
17
+
18
+ get path(): string {
19
+ return this.resolvePath();
20
+ }
21
+
22
+ connections(): readonly SandboxClientConnection[] {
23
+ return Object.freeze([...this.resolveConnections()]);
24
+ }
25
+
26
+ connection(target: string): SandboxClientConnection | undefined {
27
+ return this.connections().find((connection) => connection.target === target);
28
+ }
29
+
30
+ send(target: string, payload: string): void {
31
+ const connection = this.connection(target);
32
+ if (!connection || connection.placeholder) {
33
+ throw new Error(`Sandbox client ${target} is not connected`);
34
+ }
35
+ connection.socket.send(payload);
36
+ }
37
+ }
38
+
39
+ export type SandboxClientEventMap = Record<string, unknown>;
40
+
41
+ declare module '@zhin.js/feature-kit' {
42
+ interface AdapterClientRegistry {
43
+ readonly sandbox: { readonly client: SandboxClient; readonly events: SandboxClientEventMap };
44
+ }
45
+ }
46
+
47
+ export const sandboxClient = defineEndpointClient<SandboxClient, SandboxClientEventMap>('sandbox');
@@ -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
+ }