@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/CHANGELOG.md +40 -0
- package/adapters/sandbox.js +2 -1
- package/adapters/sandbox.ts +2 -1
- package/lib/endpoint.d.ts +18 -2
- package/lib/endpoint.js +86 -12
- package/lib/protocol.d.ts +3 -0
- package/lib/protocol.js +18 -2
- package/lib/run-config.d.ts +10 -0
- package/lib/run-config.js +30 -0
- package/package.json +16 -16
- package/pages/RichTextEditor.js +23 -8
- package/pages/RichTextEditor.tsx +20 -8
- package/pages/SandboxChat.js +315 -63
- package/pages/SandboxChat.tsx +693 -179
- package/pages/agentTrace.js +559 -0
- package/pages/agentTrace.test.js +235 -0
- package/pages/agentTrace.test.ts +265 -0
- package/pages/agentTrace.ts +646 -0
- package/pages/index.js +2 -2
- package/pages/index.tsx +2 -2
- package/pages/playgroundState.js +126 -0
- package/pages/playgroundState.test.js +92 -0
- package/pages/playgroundState.test.ts +105 -0
- package/pages/playgroundState.ts +172 -0
- package/src/endpoint.ts +98 -14
- package/src/protocol.ts +25 -2
- package/src/run-config.ts +41 -0
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):
|
|
148
|
-
if (!this.#open)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
262
|
+
const sceneId = parsed.id || owner;
|
|
216
263
|
const conversation = sandboxInboundConversation(String(this.#options.id), {
|
|
217
264
|
type: parsed.type,
|
|
218
|
-
id:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|