@zhin.js/adapter-sandbox 7.0.12 → 7.0.15

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/src/endpoint.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  * SandboxWsEndpoint — WebSocket lifecycle and MessageGateway bridge for /sandbox.
3
3
  */
4
4
  import { randomUUID } from 'node:crypto';
5
+ import { execFile } from 'node:child_process';
5
6
  import type { EndpointInstance, EndpointSendRequest } from 'zhin.js/adapter';
6
- import type { MessageGateway } from '@zhin.js/core/runtime';
7
+ import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
7
8
  import type { HttpHost, WsConnection } from '@zhin.js/host-http';
8
9
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
9
10
  import type { CapabilityId } from 'zhin.js';
@@ -66,9 +67,43 @@ interface SandboxConnection {
66
67
  readonly placeholder?: boolean;
67
68
  }
68
69
 
70
+ type ShellIsolationStatus = Readonly<{
71
+ available: boolean;
72
+ provider: 'docker';
73
+ message: string;
74
+ }>;
75
+
76
+ type ShellIsolationProbe = () => Promise<ShellIsolationStatus>;
77
+
78
+ /**
79
+ * Share an in-flight readiness probe across reconnects while still allowing a
80
+ * later connection to refresh the informational status after it settles.
81
+ * Kept internal to this module's package surface; tests import it directly.
82
+ */
83
+ export function createSandboxReadinessGate(probe: ShellIsolationProbe): Readonly<{
84
+ afterProbe: (deliver: (status: ShellIsolationStatus) => void) => void;
85
+ }> {
86
+ let inFlight: Promise<ShellIsolationStatus> | undefined;
87
+ const acquire = (): Promise<ShellIsolationStatus> => {
88
+ if (inFlight) return inFlight;
89
+ const pending = probe();
90
+ const shared = pending.finally(() => {
91
+ if (inFlight === shared) inFlight = undefined;
92
+ });
93
+ inFlight = shared;
94
+ return shared;
95
+ };
96
+ return Object.freeze({
97
+ afterProbe: (deliver) => {
98
+ void acquire().then(deliver);
99
+ },
100
+ });
101
+ }
102
+
69
103
  export interface SandboxEndpointOptions {
70
104
  readonly id: CapabilityId;
71
105
  readonly gateway: MessageGateway;
106
+ readonly sideEvents?: SideEventGateway;
72
107
  readonly http: HttpHost;
73
108
  readonly defaults: ResolvedSandboxBot;
74
109
  }
@@ -82,6 +117,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
82
117
 
83
118
  readonly #options: SandboxEndpointOptions;
84
119
  readonly #connections = new Map<string, SandboxConnection>();
120
+ readonly #readiness = createSandboxReadinessGate(probeShellIsolation);
85
121
  #wsHandleRelease?: () => void;
86
122
  #wsPathRelease?: () => void;
87
123
  #wsPath = '/sandbox';
@@ -144,8 +180,8 @@ export class SandboxWsEndpoint implements EndpointInstance {
144
180
  this.#logger.debug(formatCompact({ op: 'sandbox_stopped' }));
145
181
  }
146
182
 
147
- send({ conversation, payload }: EndpointSendRequest): unknown {
148
- if (!this.#open) return undefined;
183
+ send({ conversation, payload }: EndpointSendRequest): string {
184
+ if (!this.#open) throw new Error('Sandbox Endpoint is not open');
149
185
  // Reply targets this endpoint's own live socket (fixed bot name, or the
150
186
  // only live random-name connection); conversation carries kind/id stamp.
151
187
  const connection = this.#connections.get(this.#options.defaults.id)
@@ -155,14 +191,14 @@ export class SandboxWsEndpoint implements EndpointInstance {
155
191
  op: 'sandbox_send_miss',
156
192
  target: `${conversation.kind}:${conversation.id}`,
157
193
  }));
158
- return undefined;
194
+ throw new Error('Sandbox Endpoint has no live connection');
159
195
  }
160
196
  if (connection.placeholder) {
161
197
  this.#logger.debug(formatCompact({
162
198
  op: 'sandbox_send_placeholder',
163
199
  target: `${conversation.kind}:${conversation.id}`,
164
200
  }));
165
- return undefined;
201
+ throw new Error('Sandbox Endpoint has no live connection');
166
202
  }
167
203
  // Console UI filters by type+id; stamp the conversation onto outbound wire.
168
204
  connection.socket.send(formatSandboxOutbound(payload, {
@@ -177,7 +213,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
177
213
  channelType: conversation.kind,
178
214
  channelId: conversation.id,
179
215
  }));
180
- return payload;
216
+ return randomUUID();
181
217
  }
182
218
 
183
219
  /** Prefer a real (non-placeholder) socket when reply target key is wrong/stale. */
@@ -193,6 +229,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
193
229
  ? `sandbox-${randomUUID().slice(0, 8)}`
194
230
  : this.#options.defaults.id;
195
231
  const owner = this.#options.defaults.owner;
232
+ const canExecute = connection.authScope === 'full';
196
233
  const socket = connection.socket as SandboxWsSocket;
197
234
  // Fixed-name mode reuses `target`; dropping the prior entry without
198
235
  // closing its socket leaves a zombie browser tab that still looks
@@ -211,26 +248,37 @@ export class SandboxWsEndpoint implements EndpointInstance {
211
248
  }
212
249
  const release = bindSandboxWsSocket(socket, {
213
250
  onMessage: (raw) => {
251
+ if (!canExecute) {
252
+ socket.send(JSON.stringify({
253
+ type: 'error',
254
+ id: owner,
255
+ endpoint: target,
256
+ content: [{ type: 'text', data: { text: '当前连接是只读演示权限,不能运行 Agent 任务。' } }],
257
+ timestamp: Date.now(),
258
+ }));
259
+ return;
260
+ }
214
261
  const parsed = parseSandboxWsPayload(raw);
215
- const sender = parsed.id || owner;
262
+ const sceneId = parsed.id || owner;
216
263
  const conversation = sandboxInboundConversation(String(this.#options.id), {
217
264
  type: parsed.type,
218
- id: sender,
265
+ id: sceneId,
219
266
  });
220
267
  this.#logger.debug(formatCompact({
221
268
  op: 'sandbox_recv',
222
269
  target,
223
- sender,
270
+ sender: owner,
224
271
  channelType: parsed.type,
225
- channelId: sender,
272
+ channelId: sceneId,
226
273
  text: parsed.text.slice(0, 80),
227
274
  }));
228
275
  // Don't gate on #open — inbound must always reach the gateway so
229
276
  // Command/AI dispatch and outbound replies work.
230
277
  void this.#options.gateway.receive({
231
278
  conversation,
279
+ ...(parsed.messageId ? { message: { conversation, id: parsed.messageId } } : {}),
232
280
  content: parsed.text,
233
- sender: { id: sender },
281
+ sender: { id: owner },
234
282
  endpointId: target,
235
283
  metadata: Object.freeze({
236
284
  type: parsed.type,
@@ -239,6 +287,7 @@ export class SandboxWsEndpoint implements EndpointInstance {
239
287
  elements: parsed.content,
240
288
  timestamp: parsed.timestamp,
241
289
  ...(parsed.action ? { action: parsed.action } : {}),
290
+ ...(parsed.agentRun ? { sandboxAgentRun: parsed.agentRun } : {}),
242
291
  }),
243
292
  }).catch((err) => {
244
293
  this.#logger.warn(formatCompact({
@@ -267,25 +316,30 @@ export class SandboxWsEndpoint implements EndpointInstance {
267
316
  });
268
317
  this.#connections.set(target, { target, owner, socket, release });
269
318
  this.#logger.debug(formatCompact({ op: 'sandbox_ws_connected', target, owner }));
270
- if (!this.#options.defaults.randomNamePerConnection) {
319
+ this.#readiness.afterProbe((shellIsolation) => {
320
+ const current = this.#connections.get(target);
321
+ if (!current || current.socket !== socket) return;
271
322
  const readyPayload = JSON.stringify({
272
323
  type: 'ready',
273
324
  id: owner,
274
325
  endpoint: target,
326
+ workingDirectory: process.cwd(),
327
+ canExecute,
328
+ shellIsolation,
275
329
  content: [{
276
330
  type: 'text',
277
331
  data: {
278
332
  text: [
279
333
  `已连接 Sandbox「${target}」`,
280
334
  `与 Node Host 控制台沙盒协议一致(${this.#wsPath})`,
281
- '命令: help · ping · zt · status',
335
+ 'Agent 试验台会话已启用持久化运行配置。',
282
336
  ].join('\n'),
283
337
  },
284
338
  }],
285
339
  timestamp: Date.now(),
286
340
  });
287
341
  whenWsOpen(socket, () => socket.send(readyPayload));
288
- }
342
+ });
289
343
  }
290
344
 
291
345
  #ensurePlaceholder(name: string, owner: string): void {
@@ -299,3 +353,33 @@ export class SandboxWsEndpoint implements EndpointInstance {
299
353
  });
300
354
  }
301
355
  }
356
+
357
+ function probeShellIsolation(): Promise<ShellIsolationStatus> {
358
+ return new Promise<ShellIsolationStatus>((resolve) => {
359
+ execFile('docker', ['info', '--format', '{{.ServerVersion}}'], {
360
+ encoding: 'utf8',
361
+ timeout: 500,
362
+ }, (error, stdout) => {
363
+ if (!error) {
364
+ const version = stdout.trim();
365
+ resolve(Object.freeze({
366
+ available: true,
367
+ provider: 'docker',
368
+ message: version ? `Docker ${version}` : 'Docker ready',
369
+ }));
370
+ return;
371
+ }
372
+ resolve(Object.freeze({
373
+ available: false,
374
+ provider: 'docker',
375
+ message: error.killed || error.code === 'ETIMEDOUT'
376
+ ? 'Docker readiness check timed out'
377
+ : 'Docker daemon is unavailable',
378
+ }));
379
+ });
380
+ }).catch(() => Object.freeze({
381
+ available: false,
382
+ provider: 'docker' as const,
383
+ message: 'Docker is unavailable',
384
+ }));
385
+ }
package/src/protocol.ts CHANGED
@@ -4,6 +4,10 @@ import { readFileSync } from 'node:fs';
4
4
  import { isMediaRef } from '@zhin.js/core';
5
5
  import type { ConversationKind, ConversationRef } from '@zhin.js/im-contract';
6
6
  import { formatCompact, getLogger } from '@zhin.js/logger';
7
+ import {
8
+ normalizeSandboxAgentRunConfig,
9
+ type SandboxAgentRunConfig,
10
+ } from './run-config.js';
7
11
 
8
12
  const logger = getLogger('sandbox');
9
13
 
@@ -70,7 +74,10 @@ export function resolveSandboxEndpoint(
70
74
  context: 'sandbox',
71
75
  id,
72
76
  owner,
73
- randomNamePerConnection: !fixedName,
77
+ // The endpoint id participates in the Agent session key. Keep it stable
78
+ // across browser reconnects and Host restarts so a persisted playground
79
+ // session resumes the same Agent context.
80
+ randomNamePerConnection: false,
74
81
  };
75
82
  }
76
83
 
@@ -124,10 +131,12 @@ export function bindSandboxWsSocket(
124
131
  export function parseSandboxWsPayload(raw: string): {
125
132
  type: MessageType;
126
133
  id: string;
134
+ messageId?: string;
127
135
  content: MessageElement[];
128
136
  timestamp: number;
129
137
  text: string;
130
138
  action?: { id: string; payload: string };
139
+ agentRun?: SandboxAgentRunConfig;
131
140
  } {
132
141
  let payload: {
133
142
  type?: MessageType;
@@ -135,6 +144,8 @@ export function parseSandboxWsPayload(raw: string): {
135
144
  content?: MessageElement[] | string;
136
145
  text?: string;
137
146
  timestamp?: number;
147
+ messageId?: unknown;
148
+ agentRun?: unknown;
138
149
  };
139
150
  try {
140
151
  payload = JSON.parse(raw) as typeof payload;
@@ -175,7 +186,19 @@ export function parseSandboxWsPayload(raw: string): {
175
186
  ? payload.text
176
187
  : action?.payload ?? raw;
177
188
  }
178
- return { type, id, content, timestamp: payload.timestamp ?? Date.now(), text, action };
189
+ const agentRun = normalizeSandboxAgentRunConfig(payload.agentRun);
190
+ const rawMessageId = typeof payload.messageId === 'string' ? payload.messageId.trim() : '';
191
+ const messageId = /^[A-Za-z0-9._:-]{1,160}$/u.test(rawMessageId) ? rawMessageId : undefined;
192
+ return {
193
+ type,
194
+ id,
195
+ ...(messageId ? { messageId } : {}),
196
+ content,
197
+ timestamp: payload.timestamp ?? Date.now(),
198
+ text,
199
+ action,
200
+ ...(agentRun ? { agentRun } : {}),
201
+ };
179
202
  }
180
203
 
181
204
  /**
@@ -0,0 +1,41 @@
1
+ export type SandboxSafetyMode = 'read-only' | 'workspace-write' | 'danger-full-access';
2
+ export type SandboxApprovalMode = 'ask' | 'deny' | 'allow';
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', 'deny', 'allow']);
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
+ }