@zhin.js/adapter-sandbox 1.0.70 → 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 +951 -50
  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 -55
  11. package/lib/index.js +3 -176
  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 -238
  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/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # @zhin.js/adapter-sandbox
2
2
 
3
- Zhin.js Sandbox 适配器,基于 WebSocket 的本地测试适配器,配合 Web 控制台提供浏览器端聊天窗口进行调试。
3
+ Zhin.js Sandbox 适配器,基于 WebSocket 的本地测试适配器;浏览器端聊天 UI 在 **[Remote Console](https://console.zhin.dev)**(Host 仅 Console API)中打开 Sandbox 窗口调试。
4
4
 
5
5
  ## 功能特性
6
6
 
7
- - 基于 WebSocket 实时通信
7
+ - **Node Host**:WebSocket `/sandbox`
8
8
  - 浏览器端 React 聊天 UI
9
9
  - 支持多客户端同时连接
10
10
  - 无需第三方平台账号,即开即用
@@ -16,55 +16,65 @@ Zhin.js Sandbox 适配器,基于 WebSocket 的本地测试适配器,配合 W
16
16
  pnpm add @zhin.js/adapter-sandbox
17
17
  ```
18
18
 
19
+ ## 前置条件
20
+
21
+ Sandbox 不需要外部账号。只需由 `zhin runtime start` 装配 HTTP Host,并确保浏览器能访问启动日志中的 Host 地址。
22
+
19
23
  ## 依赖
20
24
 
21
- Sandbox 适配器需要以下服务插件:
25
+ ### Plugin Runtime(新,`zhin runtime start`)
26
+
27
+ - `@zhin.js/adapter` — 约定式 `adapters/sandbox.ts`
28
+ - `@zhin.js/host-http` — Root 提供的 `httpHostToken`(WebSocket `/sandbox` + Console HTTP)
29
+ - `@zhin.js/core` — `Endpoint.emit(...)` 入站、`outboundMessageToken` 出站
30
+ - `@zhin.js/page` + `pages/index.tsx` — ADR 0046 约定页(`definePage`;路由 `/sandbox`)
31
+
32
+ Root 在 `zhin runtime start` 时装载 `@zhin.js/host-http`、`ConsoleRuntime` 与
33
+ `ClientBuildModuleRuntime`。打开 `http://<host>:<port>/console` 可浏览页面;Sandbox 页
34
+ (路由 `/sandbox`,与 WebSocket `/sandbox` 同 path:GET 开页、Upgrade 走 WS)内置聊天壳。
35
+
36
+ 旧 `client/`(`register(api)` / `pageManager.addEntry`)仅保留给 legacy Host 栈参考,
37
+ **不是** Plugin Runtime 生产入口。
22
38
 
23
- - `@zhin.js/http` — HTTP 服务(提供 Router 和 WebSocket)
24
- - `@zhin.js/console` — Web 控制台(提供前端 UI)
25
- - `@zhin.js/client` — 控制台客户端框架
39
+ ### 旧 Host 栈(已删除)
40
+
41
+ 原 legacy 插件包 `@zhin.js/host-router`(HTTP 服务)与 `@zhin.js/host-api`(Host 侧 Console API,`addEntry` 注册 Sandbox 扩展)已删除;`zhin dev` 现由 `@zhin.js/cli` 自动装配 Console/HTTP Host(`@zhin.js/host-http` + `@zhin.js/pagemanager`),无需安装任何 Host 插件。
42
+
43
+ - `@zhin.js/client` — Remote Console 客户端 SDK(UI 在 zhin-console 仓库)
44
+
45
+ 出站 wire 只做 JSON 封装;旧 `segment-mapper`(canonical segments)归一化上移到 gateway/core 渲染链。
26
46
 
27
47
  ## 配置
28
48
 
29
- ```yaml
30
- # zhin.config.yml
31
- bots:
32
- - context: sandbox
33
- name: sandbox-bot
49
+ **推荐(与 [minimal-bot](../../../examples/minimal-bot/) 一致)**:`plugins.sandbox.endpoints: []`,在 Remote Console 打开「沙盒」页时经 `/sandbox` WebSocket **自动创建** bot(如 `sandbox-xxxx`),无需在 yaml 里写 `context: sandbox`。
34
50
 
51
+ ```yaml
52
+ # zhin.config.yml(Plugin Runtime)
35
53
  plugins:
36
- - adapter-sandbox
37
- - http
38
- - console
54
+ sandbox:
55
+ endpoints: []
39
56
  ```
40
57
 
41
- 或使用 TypeScript 配置:
42
-
43
- ```typescript
44
- import { defineConfig } from 'zhin.js'
45
-
46
- export default defineConfig({
47
- bots: [
48
- {
49
- context: 'sandbox',
50
- name: 'sandbox-bot',
51
- }
52
- ],
53
- plugins: [
54
- 'adapter-sandbox',
55
- 'http',
56
- 'console',
57
- ]
58
- })
58
+ 可选:若需在启动时即在 bot 列表显示**固定名称**的离线占位 bot,可显式配置:
59
+
60
+ ```yaml
61
+ plugins:
62
+ sandbox:
63
+ endpoints:
64
+ - name: sandbox-bot
65
+ context: sandbox
66
+ owner: sandbox-user
59
67
  ```
60
68
 
61
69
  ## 使用方式
62
70
 
63
- 1. 启动机器人:`pnpm dev`
64
- 2. 打开浏览器访问 Web 控制台(默认 `http://localhost:8086`)
65
- 3. 在控制台的 Sandbox 聊天窗口中发送消息进行测试
71
+ 1. 启动 Zhin 实例:`pnpm dev`(终端会打印 Host 地址,一般为 `http://127.0.0.1:8086`)
72
+ 2. 打开 **[Remote Console](https://console.zhin.dev)**,API Base 与 Host 地址一致,Token 与 `http.token` / `HTTP_TOKEN` 一致
73
+ 3. 在 Console **沙盒** 页连接后发送消息进行测试
66
74
 
67
- 每个浏览器客户端通过 WebSocket 连接后会自动创建一个 Sandbox Bot 实例,消息通过 JSON 格式在 WebSocket 上传输。
75
+ 每个浏览器客户端连接后创建 Sandbox Bot(无 yaml 固定名时为 `sandbox-xxxx`)。
76
+
77
+ 通过 `Router.ws("/sandbox")`(插件 `useContext("router")` 自动挂载)建立连接。
68
78
 
69
79
  ## 消息格式
70
80
 
@@ -85,6 +95,20 @@ Sandbox 使用 JSON 消息格式:
85
95
  - 测试命令和 AI 工具调用
86
96
  - 不依赖外部平台的功能验证
87
97
 
98
+ ## AI 工具
99
+
100
+ 技能说明见 `agent/skills/sandbox.md`(本地沙箱调试约束)。
101
+
102
+
103
+ ## 故障排查
104
+
105
+ | 现象 | 排查 |
106
+ | --- | --- |
107
+ | Console 无法连接 | 以启动日志中的 Host、端口和 token 为准 |
108
+ | Sandbox 页面空白 | 检查 HTTP Host 是否因端口占用软降级,以及鉴权/CORS 错误 |
109
+ | 刷新后没有历史消息 | 核对 Endpoint 与频道,并检查 history RPC 与 recovery gap 日志 |
110
+ | 命令或工具未生效 | 在运行时能力页确认它已进入当前 generation |
111
+
88
112
  ## 许可证
89
113
 
90
114
  MIT License
@@ -0,0 +1,25 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ /**
3
+ * Convention entry: discover `adapters/sandbox.ts` → defineAdapter.
4
+ */
5
+ import { defineAdapter } from 'zhin.js/adapter';
6
+ import { httpHostToken } from '@zhin.js/host-http';
7
+ import { SandboxWsEndpoint } from "../lib/endpoint.js";
8
+ import { resolveSandboxEndpoint, } from "../lib/protocol.js";
9
+ export { SandboxWsEndpoint } from "../lib/endpoint.js";
10
+ export default defineAdapter({
11
+ capabilities: ['inbound', 'outbound'],
12
+ // Console UI 直连 URL 媒体、直接渲染 base64 内联媒体;path 由端点读盘
13
+ // 物化为 base64 内联(sandbox 无平台上传通道)。交互段由 Console 原生承载。
14
+ segments: {
15
+ outboundMedia: ['url', 'base64', 'path'],
16
+ interactive: 'native',
17
+ },
18
+ create(context) {
19
+ return new SandboxWsEndpoint({
20
+ id: context.id,
21
+ http: context.use(httpHostToken),
22
+ defaults: resolveSandboxEndpoint(context.config),
23
+ });
24
+ },
25
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Convention entry: discover `adapters/sandbox.ts` → defineAdapter.
3
+ */
4
+ import { defineAdapter } from 'zhin.js/adapter';
5
+ import { httpHostToken } from '@zhin.js/host-http';
6
+ import { SandboxWsEndpoint } from '../src/endpoint.js';
7
+ import {
8
+ resolveSandboxEndpoint,
9
+ type SandboxAdapterConfig,
10
+ } from '../src/protocol.js';
11
+
12
+ export { SandboxWsEndpoint } from '../src/endpoint.js';
13
+ export type { SandboxEndpointOptions } from '../src/endpoint.js';
14
+
15
+ export default defineAdapter<SandboxAdapterConfig>({
16
+ capabilities: ['inbound', 'outbound'],
17
+ // Console UI 直连 URL 媒体、直接渲染 base64 内联媒体;path 由端点读盘
18
+ // 物化为 base64 内联(sandbox 无平台上传通道)。交互段由 Console 原生承载。
19
+ segments: {
20
+ outboundMedia: ['url', 'base64', 'path'],
21
+ interactive: 'native',
22
+ },
23
+ create(context) {
24
+ return new SandboxWsEndpoint({
25
+ id: context.id,
26
+ http: context.use(httpHostToken),
27
+ defaults: resolveSandboxEndpoint(context.config),
28
+ });
29
+ },
30
+ });
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: sandbox
3
+ platforms:
4
+ - sandbox
5
+ description: >-
6
+ 沙箱测试适配器:基于 WebSocket 的本地测试环境,提供浏览器内聊天 UI,
7
+ 无需第三方平台即可调试插件和命令。开发调试专用,无额外 AI 工具。
8
+ keywords:
9
+ - sandbox
10
+ - adapter:sandbox
11
+ - test
12
+ - 测试
13
+ - 沙箱
14
+ - development
15
+ - 开发
16
+ - debug
17
+ - 调试
18
+ tags:
19
+ - sandbox
20
+ - testing
21
+ - development
22
+ tools: []
23
+ ---
24
+
25
+ # 沙箱测试适配器
26
+
27
+ 本地开发调试专用。通过浏览器 Web UI 模拟聊天,无需第三方平台凭据。无 AI 工具可调用。
28
+
29
+ ## 使用场景
30
+
31
+ - 开发新插件时在本地模拟消息收发
32
+ - 调试命令路由和中间件逻辑
33
+ - 依赖 HTTP 服务和 Console 插件提供 Web 界面
@@ -0,0 +1,27 @@
1
+ import type { SandboxWsSocket } from './protocol.js';
2
+ export interface SandboxClientConnection {
3
+ readonly target: string;
4
+ readonly owner: string;
5
+ readonly socket: SandboxWsSocket;
6
+ readonly placeholder: boolean;
7
+ }
8
+ /** Direct view of the live Sandbox protocol clients owned by one Endpoint. */
9
+ export declare class SandboxClient {
10
+ private readonly resolvePath;
11
+ private readonly resolveConnections;
12
+ constructor(resolvePath: () => string, resolveConnections: () => Iterable<SandboxClientConnection>);
13
+ get path(): string;
14
+ connections(): readonly SandboxClientConnection[];
15
+ connection(target: string): SandboxClientConnection | undefined;
16
+ send(target: string, payload: string): void;
17
+ }
18
+ export type SandboxClientEventMap = Record<string, unknown>;
19
+ declare module '@zhin.js/feature-kit' {
20
+ interface AdapterClientRegistry {
21
+ readonly sandbox: {
22
+ readonly client: SandboxClient;
23
+ readonly events: SandboxClientEventMap;
24
+ };
25
+ }
26
+ }
27
+ export declare const sandboxClient: import("@zhin.js/adapter").EndpointClientToken<SandboxClient, SandboxClientEventMap>;
package/lib/client.js ADDED
@@ -0,0 +1,27 @@
1
+ import { defineEndpointClient } from 'zhin.js/adapter';
2
+ /** Direct view of the live Sandbox protocol clients owned by one Endpoint. */
3
+ export class SandboxClient {
4
+ resolvePath;
5
+ resolveConnections;
6
+ constructor(resolvePath, resolveConnections) {
7
+ this.resolvePath = resolvePath;
8
+ this.resolveConnections = resolveConnections;
9
+ }
10
+ get path() {
11
+ return this.resolvePath();
12
+ }
13
+ connections() {
14
+ return Object.freeze([...this.resolveConnections()]);
15
+ }
16
+ connection(target) {
17
+ return this.connections().find((connection) => connection.target === target);
18
+ }
19
+ send(target, payload) {
20
+ const connection = this.connection(target);
21
+ if (!connection || connection.placeholder) {
22
+ throw new Error(`Sandbox client ${target} is not connected`);
23
+ }
24
+ connection.socket.send(payload);
25
+ }
26
+ }
27
+ export const sandboxClient = defineEndpointClient('sandbox');
@@ -0,0 +1,42 @@
1
+ import { Endpoint } from 'zhin.js/adapter';
2
+ import { type EndpointSendRequest } from 'zhin.js/adapter';
3
+ import type { HttpHost } from '@zhin.js/host-http';
4
+ import type { CapabilityId } from 'zhin.js';
5
+ import { type ResolvedSandboxBot } from './protocol.js';
6
+ import { SandboxClient } from './client.js';
7
+ type ShellIsolationStatus = Readonly<{
8
+ available: boolean;
9
+ provider: 'docker';
10
+ message: string;
11
+ }>;
12
+ type ShellIsolationProbe = () => Promise<ShellIsolationStatus>;
13
+ /**
14
+ * Share an in-flight readiness probe across reconnects while still allowing a
15
+ * later connection to refresh the informational status after it settles.
16
+ * Kept internal to this module's package surface; tests import it directly.
17
+ */
18
+ export declare function createSandboxReadinessGate(probe: ShellIsolationProbe): Readonly<{
19
+ afterProbe: (deliver: (status: ShellIsolationStatus) => void) => void;
20
+ }>;
21
+ export interface SandboxEndpointOptions {
22
+ readonly id: CapabilityId;
23
+ readonly http: HttpHost;
24
+ readonly defaults: ResolvedSandboxBot;
25
+ }
26
+ /**
27
+ * Sandbox 是本地开发/测试面,无平台社交图谱(好友/群/频道),
28
+ * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
29
+ */
30
+ export declare class SandboxWsEndpoint extends Endpoint<SandboxClient> {
31
+ #private;
32
+ readonly client: SandboxClient;
33
+ constructor(options: SandboxEndpointOptions);
34
+ /** Live endpoint id (config `id`) — Console endpoint.list/resolve uses it. */
35
+ get name(): string;
36
+ start(): void;
37
+ open(): void;
38
+ close(): void;
39
+ stop(): void;
40
+ send({ conversation, payload }: EndpointSendRequest): string;
41
+ }
42
+ export {};
@@ -0,0 +1,357 @@
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 { formatCompact, getAdapterLogger } from '@zhin.js/logger';
8
+ import { bindSandboxWsSocket, formatSandboxOutbound, parseSandboxWsPayload, sandboxInboundConversation, whenWsOpen, } from './protocol.js';
9
+ import { SandboxClient } from './client.js';
10
+ const claimedWsPaths = new WeakMap();
11
+ function claimSandboxWsPath(http, name) {
12
+ let claims = claimedWsPaths.get(http);
13
+ if (!claims) {
14
+ claims = new Map();
15
+ claimedWsPaths.set(http, claims);
16
+ }
17
+ const candidates = ['/sandbox', `/sandbox/${encodeURIComponent(name)}`];
18
+ let path = candidates.find((candidate) => !claims.has(candidate) || claims.get(candidate)?.name === name);
19
+ if (!path) {
20
+ let index = 2;
21
+ path = `/sandbox/${encodeURIComponent(name)}-${index}`;
22
+ while (claims.has(path)) {
23
+ index += 1;
24
+ path = `/sandbox/${encodeURIComponent(name)}-${index}`;
25
+ }
26
+ }
27
+ const owner = Symbol(name);
28
+ const claim = claims.get(path) ?? { name, owners: new Set() };
29
+ claim.owners.add(owner);
30
+ claims.set(path, claim);
31
+ const claimed = path;
32
+ const registry = claims;
33
+ return {
34
+ path: claimed,
35
+ release: () => {
36
+ claim.owners.delete(owner);
37
+ if (claim.owners.size === 0 && registry.get(claimed) === claim) {
38
+ registry.delete(claimed);
39
+ }
40
+ },
41
+ };
42
+ }
43
+ /**
44
+ * Share an in-flight readiness probe across reconnects while still allowing a
45
+ * later connection to refresh the informational status after it settles.
46
+ * Kept internal to this module's package surface; tests import it directly.
47
+ */
48
+ export function createSandboxReadinessGate(probe) {
49
+ let inFlight;
50
+ const acquire = () => {
51
+ if (inFlight)
52
+ return inFlight;
53
+ const pending = probe();
54
+ const shared = pending.finally(() => {
55
+ if (inFlight === shared)
56
+ inFlight = undefined;
57
+ });
58
+ inFlight = shared;
59
+ return shared;
60
+ };
61
+ return Object.freeze({
62
+ afterProbe: (deliver) => {
63
+ void acquire().then(deliver);
64
+ },
65
+ });
66
+ }
67
+ /**
68
+ * Sandbox 是本地开发/测试面,无平台社交图谱(好友/群/频道),
69
+ * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
70
+ */
71
+ export class SandboxWsEndpoint extends Endpoint {
72
+ client;
73
+ #logger;
74
+ #options;
75
+ #connections = new Map();
76
+ #readiness = createSandboxReadinessGate(probeShellIsolation);
77
+ #wsHandleRelease;
78
+ #wsPathRelease;
79
+ #wsPath = '/sandbox';
80
+ #open = false;
81
+ #started = false;
82
+ constructor(options) {
83
+ super();
84
+ this.#logger = getAdapterLogger('sandbox', options.defaults.id);
85
+ this.#options = options;
86
+ this.client = new SandboxClient(() => this.#wsPath, () => [...this.#connections.values()].map((connection) => ({
87
+ target: connection.target,
88
+ owner: connection.owner,
89
+ socket: connection.socket,
90
+ placeholder: connection.placeholder === true,
91
+ })));
92
+ }
93
+ /** Live endpoint id (config `id`) — Console endpoint.list/resolve uses it. */
94
+ get name() {
95
+ return this.#options.defaults.id;
96
+ }
97
+ start() {
98
+ if (this.#started)
99
+ return;
100
+ this.#started = true;
101
+ // 多 endpoint 同 path 会被全部回调(入站重复、出站互窜),按名隔离。
102
+ const claim = claimSandboxWsPath(this.#options.http, this.#options.defaults.id);
103
+ this.#wsPathRelease = claim.release;
104
+ this.#wsPath = claim.path;
105
+ const handle = this.#options.http.ws(claim.path);
106
+ this.#wsHandleRelease = handle.onConnection((connection) => {
107
+ this.#acceptConnection(connection);
108
+ });
109
+ if (!this.#options.defaults.randomNamePerConnection) {
110
+ this.#ensurePlaceholder(this.#options.defaults.id, this.#options.defaults.owner);
111
+ }
112
+ this.#logger.info(`ws mounted ${claim.path}`);
113
+ }
114
+ open() {
115
+ this.#open = true;
116
+ }
117
+ close() {
118
+ this.#open = false;
119
+ }
120
+ stop() {
121
+ this.#open = false;
122
+ this.#wsHandleRelease?.();
123
+ this.#wsHandleRelease = undefined;
124
+ this.#wsPathRelease?.();
125
+ this.#wsPathRelease = undefined;
126
+ for (const connection of this.#connections.values()) {
127
+ connection.release();
128
+ if (!connection.placeholder) {
129
+ try {
130
+ connection.socket.close(1001, 'sandbox endpoint stopped');
131
+ }
132
+ catch {
133
+ /* already closed */
134
+ }
135
+ }
136
+ }
137
+ this.#connections.clear();
138
+ this.#started = false;
139
+ this.#logger.debug(formatCompact({ op: 'sandbox_stopped' }));
140
+ }
141
+ send({ conversation, payload }) {
142
+ if (!this.#open)
143
+ throw new Error('Sandbox Endpoint is not open');
144
+ // Reply targets this endpoint's own live socket (fixed bot name, or the
145
+ // only live random-name connection); conversation carries kind/id stamp.
146
+ const connection = this.#connections.get(this.#options.defaults.id)
147
+ ?? this.#findLiveConnection();
148
+ if (!connection) {
149
+ this.#logger.debug(formatCompact({
150
+ op: 'sandbox_send_miss',
151
+ target: `${conversation.kind}:${conversation.id}`,
152
+ }));
153
+ throw new Error('Sandbox Endpoint has no live connection');
154
+ }
155
+ if (connection.placeholder) {
156
+ this.#logger.debug(formatCompact({
157
+ op: 'sandbox_send_placeholder',
158
+ target: `${conversation.kind}:${conversation.id}`,
159
+ }));
160
+ throw new Error('Sandbox Endpoint has no live connection');
161
+ }
162
+ // Console UI filters by type+id; stamp the conversation onto outbound wire.
163
+ connection.socket.send(formatSandboxOutbound(payload, {
164
+ type: conversation.kind,
165
+ id: conversation.id,
166
+ bot: this.#options.defaults.id,
167
+ endpoint: connection.target,
168
+ }));
169
+ this.#logger.debug(formatCompact({
170
+ op: 'sandbox_send',
171
+ target: connection.target,
172
+ channelType: conversation.kind,
173
+ channelId: conversation.id,
174
+ }));
175
+ return randomUUID();
176
+ }
177
+ /** Prefer a real (non-placeholder) socket when reply target key is wrong/stale. */
178
+ #findLiveConnection() {
179
+ for (const connection of this.#connections.values()) {
180
+ if (!connection.placeholder)
181
+ return connection;
182
+ }
183
+ return undefined;
184
+ }
185
+ #acceptConnection(connection) {
186
+ const target = this.#options.defaults.randomNamePerConnection
187
+ ? `sandbox-${randomUUID().slice(0, 8)}`
188
+ : this.#options.defaults.id;
189
+ const owner = this.#options.defaults.owner;
190
+ const canExecute = connection.authScope === 'full';
191
+ const socket = connection.socket;
192
+ // Fixed-name mode reuses `target`; dropping the prior entry without
193
+ // closing its socket leaves a zombie browser tab that still looks
194
+ // connected but never receives outbound traffic.
195
+ const previous = this.#connections.get(target);
196
+ if (previous) {
197
+ previous.release();
198
+ if (!previous.placeholder) {
199
+ try {
200
+ previous.socket.close(4000, 'replaced by new sandbox client');
201
+ }
202
+ catch {
203
+ /* already closed */
204
+ }
205
+ }
206
+ this.#connections.delete(target);
207
+ }
208
+ const release = bindSandboxWsSocket(socket, {
209
+ onMessage: (raw) => {
210
+ void this.#emitPlatformEvent('message', raw);
211
+ if (!canExecute) {
212
+ socket.send(JSON.stringify({
213
+ type: 'error',
214
+ id: owner,
215
+ endpoint: target,
216
+ content: [{ type: 'text', data: { text: '当前连接是只读演示权限,不能运行 Agent 任务。' } }],
217
+ timestamp: Date.now(),
218
+ }));
219
+ return;
220
+ }
221
+ const parsed = parseSandboxWsPayload(raw);
222
+ const sceneId = parsed.id || owner;
223
+ const conversation = sandboxInboundConversation(String(this.#options.id), {
224
+ type: parsed.type,
225
+ id: sceneId,
226
+ });
227
+ this.#logger.debug(formatCompact({
228
+ op: 'sandbox_recv',
229
+ target,
230
+ sender: owner,
231
+ channelType: parsed.type,
232
+ channelId: sceneId,
233
+ text: parsed.text.slice(0, 80),
234
+ }));
235
+ // Don't gate on #open — inbound must always reach the gateway so
236
+ // Command/AI dispatch and outbound replies work.
237
+ void this.emit('message.receive', {
238
+ conversation,
239
+ ...(parsed.messageId ? { message: { conversation, id: parsed.messageId } } : {}),
240
+ content: parsed.text,
241
+ sender: { id: owner },
242
+ endpointId: target,
243
+ metadata: Object.freeze({
244
+ type: parsed.type,
245
+ channelType: parsed.type,
246
+ channelId: parsed.id || owner,
247
+ elements: parsed.content,
248
+ timestamp: parsed.timestamp,
249
+ ...(parsed.action ? { action: parsed.action } : {}),
250
+ ...(parsed.agentRun ? { sandboxAgentRun: parsed.agentRun } : {}),
251
+ }),
252
+ }).catch((err) => {
253
+ this.#logger.warn(formatCompact({
254
+ op: 'sandbox_gateway_receive_failed',
255
+ target,
256
+ error: err instanceof Error ? err.message : String(err),
257
+ }));
258
+ });
259
+ },
260
+ onClose: () => {
261
+ void this.#emitPlatformEvent('connection.close', { target, owner });
262
+ // Only drop the map entry if we still own this socket — a replace
263
+ // may have already swapped in a newer connection for the same target.
264
+ const current = this.#connections.get(target);
265
+ if (current && current.socket === socket) {
266
+ this.#connections.delete(target);
267
+ this.#logger.debug(formatCompact({ op: 'sandbox_ws_closed', target }));
268
+ }
269
+ },
270
+ onError: (err) => {
271
+ void this.#emitPlatformEvent('connection.error', { target, owner, error: err });
272
+ this.#logger.warn(formatCompact({
273
+ op: 'sandbox_ws_error',
274
+ target,
275
+ error: err instanceof Error ? err.message : String(err),
276
+ }));
277
+ },
278
+ });
279
+ this.#connections.set(target, { target, owner, socket, release });
280
+ void this.#emitPlatformEvent('connection.open', { target, owner, socket });
281
+ this.#logger.debug(formatCompact({ op: 'sandbox_ws_connected', target, owner }));
282
+ this.#readiness.afterProbe((shellIsolation) => {
283
+ const current = this.#connections.get(target);
284
+ if (!current || current.socket !== socket)
285
+ return;
286
+ const readyPayload = JSON.stringify({
287
+ type: 'ready',
288
+ id: owner,
289
+ endpoint: target,
290
+ workingDirectory: process.cwd(),
291
+ canExecute,
292
+ shellIsolation,
293
+ content: [{
294
+ type: 'text',
295
+ data: {
296
+ text: [
297
+ `已连接 Sandbox「${target}」`,
298
+ `与 Node Host 控制台沙盒协议一致(${this.#wsPath})`,
299
+ 'Agent 试验台会话已启用持久化运行配置。',
300
+ ].join('\n'),
301
+ },
302
+ }],
303
+ timestamp: Date.now(),
304
+ });
305
+ whenWsOpen(socket, () => socket.send(readyPayload));
306
+ });
307
+ }
308
+ #ensurePlaceholder(name, owner) {
309
+ if (this.#connections.has(name))
310
+ return;
311
+ this.#connections.set(name, {
312
+ target: name,
313
+ owner,
314
+ socket: { send: () => undefined, close: () => undefined },
315
+ release: () => undefined,
316
+ placeholder: true,
317
+ });
318
+ }
319
+ async #emitPlatformEvent(name, event) {
320
+ await this.emitPlatform(name, event).catch((error) => {
321
+ this.#logger.warn(formatCompact({
322
+ op: 'sandbox_platform_event_failed',
323
+ event: name,
324
+ error: error instanceof Error ? error.message : String(error),
325
+ }));
326
+ });
327
+ }
328
+ }
329
+ function probeShellIsolation() {
330
+ return new Promise((resolve) => {
331
+ execFile('docker', ['info', '--format', '{{.ServerVersion}}'], {
332
+ encoding: 'utf8',
333
+ timeout: 500,
334
+ }, (error, stdout) => {
335
+ if (!error) {
336
+ const version = stdout.trim();
337
+ resolve(Object.freeze({
338
+ available: true,
339
+ provider: 'docker',
340
+ message: version ? `Docker ${version}` : 'Docker ready',
341
+ }));
342
+ return;
343
+ }
344
+ resolve(Object.freeze({
345
+ available: false,
346
+ provider: 'docker',
347
+ message: error.killed || error.code === 'ETIMEDOUT'
348
+ ? 'Docker readiness check timed out'
349
+ : 'Docker daemon is unavailable',
350
+ }));
351
+ });
352
+ }).catch(() => Object.freeze({
353
+ available: false,
354
+ provider: 'docker',
355
+ message: 'Docker is unavailable',
356
+ }));
357
+ }