@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.
- package/CHANGELOG.md +961 -49
- package/README.md +60 -36
- package/adapters/sandbox.js +25 -0
- package/adapters/sandbox.ts +30 -0
- package/agent/skills/sandbox.md +33 -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 -53
- package/lib/index.js +3 -173
- package/lib/protocol.d.ts +78 -0
- package/lib/protocol.js +301 -0
- package/lib/run-config.d.ts +10 -0
- package/lib/run-config.js +30 -0
- package/package.json +60 -24
- package/pages/RichTextEditor.js +366 -0
- package/{client → pages}/RichTextEditor.tsx +59 -13
- package/pages/SandboxChat.js +615 -0
- package/pages/SandboxChat.tsx +1186 -0
- 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 +18 -0
- package/pages/index.tsx +18 -0
- 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/pages/sandboxTransport.js +45 -0
- package/pages/sandboxTransport.ts +45 -0
- package/plugin.js +8 -0
- package/schema.json +76 -0
- package/src/client.ts +47 -0
- package/src/endpoint.ts +420 -0
- package/src/index.ts +26 -234
- package/src/protocol.ts +398 -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/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# @zhin.js/adapter-sandbox
|
|
2
2
|
|
|
3
|
-
Zhin.js Sandbox 适配器,基于 WebSocket
|
|
3
|
+
Zhin.js Sandbox 适配器,基于 WebSocket 的本地测试适配器;浏览器端聊天 UI 在 **[Remote Console](https://console.zhin.dev)**(Host 仅 Console API)中打开 Sandbox 窗口调试。
|
|
4
4
|
|
|
5
5
|
## 功能特性
|
|
6
6
|
|
|
7
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
- `@zhin.js/
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
- console
|
|
54
|
+
sandbox:
|
|
55
|
+
endpoints: []
|
|
39
56
|
```
|
|
40
57
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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.
|
|
64
|
-
2.
|
|
65
|
-
3.
|
|
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
|
-
|
|
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 界面
|
package/lib/client.d.ts
ADDED
|
@@ -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 {};
|
package/lib/endpoint.js
ADDED
|
@@ -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
|
+
}
|