dsh-bailinghub 0.3.0 → 0.4.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 +46 -1
- package/PRIVACY.md +101 -7
- package/README.md +128 -199
- package/SECURITY.md +95 -3
- package/docs/AGENT_CLIENT_CONTRACT.md +268 -23
- package/docs/COMPATIBILITY.md +51 -4
- package/docs/GETTING_STARTED.md +95 -61
- package/docs/GETTING_STARTED.zh-CN.md +82 -52
- package/docs/MIGRATION_VNEXT.md +79 -17
- package/docs/PROJECT_BOUNDARIES.md +3 -2
- package/docs/README.zh-CN.md +98 -174
- package/lib/authorizations.js +61 -0
- package/lib/conversation-archive-store.js +265 -0
- package/lib/conversation-outbox.js +230 -0
- package/lib/index.js +3 -0
- package/lib/runtime.js +657 -47
- package/lib/session-scope-store.js +265 -0
- package/lib/session-scope.js +319 -0
- package/lib/transport.js +11 -1
- package/package.json +2 -2
package/docs/README.zh-CN.md
CHANGED
|
@@ -2,220 +2,144 @@
|
|
|
2
2
|
|
|
3
3
|
[English](../README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
账号权限和审批规则决定,执行过程也会记录在 BailingHub 中。
|
|
5
|
+
让本地 DeepSeek Harness 智能体操作已经接入 BailingHub 的业务系统:查询记录、修改允许的字段,
|
|
6
|
+
需要审批时继续走原有规则。BailingHub 会记录用了哪份授权、做了什么,以及业务系统返回的结果。
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
**0.4.0 支持在一个会话中使用同一系统的多份授权。** 例如,分别授权 A 店和 B 店后,新建会话并
|
|
9
|
+
选中两者,就可以说:
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
- 修改允许编辑的字段或业务状态;
|
|
13
|
-
- 完成其他已经授权的后台操作;
|
|
14
|
-
- 把结果返回到本地对话,同时在 BailingHub 中保留对应的工具步骤。
|
|
11
|
+
> 对比今天 A 店和 B 店的营业情况,按门店分别说明。
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
智能体会为每次调用选择对应授权,不需要你反复切换当前连接。具体能查什么、能改什么,仍取决于
|
|
14
|
+
业务系统开放的能力和各账号权限。本版尚不提供不同系统或不同路由之间的编排。
|
|
18
15
|
|
|
19
|
-
|
|
16
|
+
本版也能把可见沟通过程与业务操作关联起来。记录上传失败后,可以在联网或重启后继续补传,
|
|
17
|
+
不会因此重新执行业务操作。
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
> 公开 `0.1.1` 仅作为明确的静态 MCP 兼容路径继续保留。
|
|
19
|
+
这是独立社区集成,不是 DeepSeek 官方开发、认证、合作、背书或推荐的插件。
|
|
23
20
|
|
|
24
|
-
|
|
21
|
+
## 安装与开始使用
|
|
25
22
|
|
|
26
|
-
|
|
23
|
+
需要 Node.js `22.19.0+` 或 `24+`、pnpm,以及兼容的 DeepSeek Harness。管理员应先完成业务系统
|
|
24
|
+
接入。配套版本为 **BailingHub Core 0.6.1 → BailingHub MCP/SDK 0.4.0 → 本插件 0.4.0**。
|
|
27
25
|
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
-> bailinghub-mcp-server/sdk
|
|
32
|
-
-> BailingHub Agent Auth + Agent API
|
|
33
|
-
-> 部署者选择的业务接入与最终业务授权
|
|
26
|
+
```bash
|
|
27
|
+
npm install --global pnpm @deepseek-ai/dsh@0.1.1-rc.2
|
|
28
|
+
dsh plugin --profile web add dsh-bailinghub@0.4.0
|
|
34
29
|
```
|
|
35
30
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
- **BailingHub Core** 负责 Agent Auth、可信业务身份、运行时上下文、知识库与记忆投影、
|
|
39
|
-
能力治理、审批、调用状态和审计记录。
|
|
40
|
-
- **`bailinghub-mcp-server/sdk`** 负责浏览器登录、PKCE、凭据存储与刷新,以及按
|
|
41
|
-
Hub/client/workspace 选择连接和映射 HTTP DTO。
|
|
42
|
-
- **`dsh-bailinghub`** 只负责 DSH 会话、提示词、命令和动态工具生命周期,不保存凭据,
|
|
43
|
-
也不直接调用业务 API。
|
|
44
|
-
|
|
45
|
-
Agent Client 不是 BailingHub 现有的“执行器”。执行器接收中枢任务并处理必须靠近某台机器
|
|
46
|
-
完成的工作;Agent Client 则把交互式思考与编排循环放在用户本地 DSH 智能体中。
|
|
31
|
+
插件会自动安装精确依赖 `bailinghub-mcp-server@0.4.0`,无需另装 SDK。
|
|
32
|
+
已经使用旧版的用户请先看[0.3 到 0.4 的迁移步骤](MIGRATION_VNEXT.md)。
|
|
47
33
|
|
|
48
|
-
|
|
34
|
+
按照[开始使用指南](GETTING_STARTED.zh-CN.md)填写管理员提供的四项公开连接信息,再到浏览器授权。
|
|
35
|
+
不要把业务密码、Client Token、签名密钥或模型 Key 填进插件设置或聊天消息。
|
|
49
36
|
|
|
50
|
-
|
|
37
|
+
## 为每个会话选择账号
|
|
51
38
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
BailingHub route id;
|
|
56
|
-
4. 在中枢 Client App 上配置一个稳定且不绑定具体账号、租户的业务授权入口,并在该 route
|
|
57
|
-
后方接通受治理的 ACC/Tool Provider 能力。登录、切换账号和选择租户都由业务授权页完成。
|
|
39
|
+
在原业务授权页面分别授权 A 店和 B 店。创建连接时使用清晰的本机名称,例如 `A 店`、`B 店`,
|
|
40
|
+
并在授权页核对实际业务身份。名称是你提供的标签,不证明身份,也不授予权限;`default`、
|
|
41
|
+
`default-2` 无法让智能体知道你指的是哪家店。
|
|
58
42
|
|
|
59
|
-
|
|
60
|
-
BailingHub Client Token 或模型提供方 Key。
|
|
43
|
+
**新建会话,在发送第一条消息前**执行:
|
|
61
44
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
- Node.js `22.19.0+` 或 `24+`;
|
|
67
|
-
- `pnpm` 与兼容矩阵中列出的 DeepSeek Harness 版本;
|
|
68
|
-
- 已完成上面的 BailingHub 接入准备。
|
|
69
|
-
|
|
70
|
-
将精确稳定版本安装到 DSH Web Profile:
|
|
71
|
-
|
|
72
|
-
```bash
|
|
73
|
-
npm install --global pnpm @deepseek-ai/dsh@0.1.1-rc.2
|
|
74
|
-
dsh plugin --profile web add dsh-bailinghub@0.3.0
|
|
45
|
+
```text
|
|
46
|
+
/bailinghub connections list
|
|
47
|
+
/bailinghub scope set <A店连接键> <B店连接键>
|
|
48
|
+
/bailinghub scope
|
|
75
49
|
```
|
|
76
50
|
|
|
77
|
-
|
|
78
|
-
|
|
51
|
+
把占位符换成列表里的固定连接键,不是连接名称。也可以只选一份授权;多份授权必须属于同一个
|
|
52
|
+
中枢、Client App 和 workspace。等待设置成功回显后,再发送业务请求。
|
|
79
53
|
|
|
80
|
-
|
|
54
|
+
**新会话默认是普通聊天,选择业务范围后才会提供业务工具。** 登录成功或切换默认连接不会自动
|
|
55
|
+
开启业务访问。`/bailinghub scope none` 可显式选择普通聊天。第一条用户消息会固定这个范围;
|
|
56
|
+
之后要增减账号,或从普通聊天改成业务会话,都需要新建会话。
|
|
81
57
|
|
|
82
|
-
|
|
58
|
+
同一套能力不必为每家店重复声明。智能体选择每次调用使用的授权,不会拿到凭据;各项操作仍受
|
|
59
|
+
对应账号权限和审批规则约束。需要审批时,在会话仍运行的情况下,继续的是原调用。
|
|
83
60
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
| `hubUrl` | `BAILINGHUB_HUB_URL` | 开发者自己部署的 BailingHub 公共 HTTPS 地址 | 否 |
|
|
87
|
-
| `clientAppId` | `BAILINGHUB_CLIENT_APP_ID` | 在该中枢注册的公共 Agent Client 应用标识 | 否 |
|
|
88
|
-
| `workspace` | `BAILINGHUB_WORKSPACE` | 初始已授权 workspace/route id | 否 |
|
|
89
|
-
| `connectionName` | `BAILINGHUB_CONNECTION_NAME` | 用户选择的本机连接名称 | 否 |
|
|
61
|
+
任意一份选中授权撤销、被替换或暂时无法核验时,整个会话的业务访问暂停,不会偷偷改用其他账号。
|
|
62
|
+
临时断网可以在联网后重试原范围;已确认的撤销或身份变化,需要新建会话并选择有效授权。
|
|
90
63
|
|
|
91
|
-
|
|
64
|
+
## 查看沟通过程与操作结果
|
|
92
65
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
66
|
+
配合 Core 0.6.1 和 SDK 0.4.0,BailingHub 可以把可见的用户消息、助手回复、轮次,以及原业务
|
|
67
|
+
执行记录的关联放在同一份会话记录中。各份授权仍保留自己的业务调用记录,汇总回答不会复制到
|
|
68
|
+
每个账号的记忆中。
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
/bailinghub archive status
|
|
72
|
+
/bailinghub archive sync
|
|
98
73
|
```
|
|
99
74
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
`connectionName` 只是用户控制的本机连接选择器,不是账号、租户或身份声明。
|
|
75
|
+
`archive status` 查看沟通记录是否已上传;`archive sync` 补传已保存记录,不重做业务操作。
|
|
76
|
+
它与 `/bailinghub sync` 不同:后者只重试当前运行中会话的待同步执行结尾记录。
|
|
103
77
|
|
|
104
|
-
|
|
78
|
+
| 状态 | 含义 |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| `synced` | 已保存事件已获中枢确认,不代表业务操作成功 |
|
|
81
|
+
| `pending` | 还没传完,恢复连接后可以重试 |
|
|
82
|
+
| `blocked` | 原授权核验阻止上传,可用 `/bailinghub scope` 查看 |
|
|
83
|
+
| `unsupported` | 当前 SDK 或中枢不支持这套归档契约 |
|
|
84
|
+
| `storage_error` | 本地写入失败,部分可见消息可能尚未安全保存 |
|
|
85
|
+
| `recovery_gap` | 对照 DSH 历史发现归档缺失,记录不完整 |
|
|
105
86
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
87
|
+
重开已保存的业务会话时,必须核验全部原授权后才恢复原范围。离线重开后,可以联网并在**同一
|
|
88
|
+
会话**执行 `/bailinghub archive sync` 或 `/bailinghub scope` 重试核验。这恢复的是范围与
|
|
89
|
+
已保存记录的补传,**不恢复进程重启前的业务调用、待审批操作或未完成任务**。未开始的已保存
|
|
90
|
+
草稿需要重新选择;没有有效旧范围快照的已开始会话不能自动采用今天的默认账号。
|
|
110
91
|
|
|
111
|
-
##
|
|
92
|
+
## 哪些信息会共享和保存
|
|
112
93
|
|
|
113
|
-
|
|
94
|
+
选中账号的业务上下文与可见用户请求会进入同一个本地智能体及模型会话。合并后的沟通归档按完整
|
|
95
|
+
授权集合控制访问,仅有其中一份授权不能读取混合会话。若这些账号的数据需要彼此隔离,应使用
|
|
96
|
+
不同会话。
|
|
114
97
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
/bailinghub status
|
|
119
|
-
/bailinghub workspaces
|
|
120
|
-
```
|
|
98
|
+
采集从本版启用后的业务轮次开始,只包含可见文本,不包含附件、隐藏思考或全部历史会话,也不会
|
|
99
|
+
自动清除用户粘贴在正文里的秘密。检测到历史缺口会明确显示;宿主不提供持久历史时,覆盖度为
|
|
100
|
+
`unverified`,不会声称完整。
|
|
121
101
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
也不会由命令输出。
|
|
102
|
+
本机私有待上传记录含有**明文任务正文**,已上传的事件也会保留,直到宿主或用户自行清理;目前
|
|
103
|
+
没有自动保留期限。删除本机记录不等于删除中枢记录。授权凭据仍由 SDK 安全存储。启用业务访问前
|
|
104
|
+
请阅读[隐私说明](../PRIVACY.md)与[安全策略](../SECURITY.md)。
|
|
126
105
|
|
|
127
|
-
|
|
106
|
+
## 常用管理命令
|
|
128
107
|
|
|
129
108
|
| 命令 | 用途 |
|
|
130
109
|
| --- | --- |
|
|
131
|
-
| `/bailinghub doctor` |
|
|
132
|
-
| `/bailinghub
|
|
133
|
-
| `/bailinghub
|
|
134
|
-
| `/bailinghub connections
|
|
135
|
-
| `/bailinghub connections
|
|
136
|
-
| `/bailinghub
|
|
137
|
-
| `/bailinghub
|
|
138
|
-
| `/bailinghub workspaces` |
|
|
139
|
-
| `/bailinghub use <workspace>` |
|
|
140
|
-
| `/bailinghub sync` | 重试同步待处理的可见回复,不重复业务工具调用 |
|
|
110
|
+
| `/bailinghub doctor` | 检查配置、SDK、授权及 workspace,不输出凭据 |
|
|
111
|
+
| `/bailinghub login` | 在浏览器授权当前连接 |
|
|
112
|
+
| `/bailinghub status` | 查看当前连接的授权状态 |
|
|
113
|
+
| `/bailinghub connections list` | 查看连接名称、固定连接键和授权状态 |
|
|
114
|
+
| `/bailinghub connections add <名称> <中枢地址> <clientAppId> <workspace>` | 创建并选择一个待管理的连接;含空格的名称加引号 |
|
|
115
|
+
| `/bailinghub connections use <名称或连接键>` | 选择要管理或授权的连接,不改变已有会话范围 |
|
|
116
|
+
| `/bailinghub connections remove <名称或连接键>` | 先撤销远端 Agent Session,再删除本机凭据 |
|
|
117
|
+
| `/bailinghub workspaces` | 查看当前授权允许的 workspace |
|
|
118
|
+
| `/bailinghub use <workspace>` | 为连接管理切换到另一已授权 workspace |
|
|
141
119
|
| `/bailinghub logout` | 撤销并删除当前 Agent Session |
|
|
142
120
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
连接名含空格时需要加引号。执行 `connections use` 后,如果该绑定尚未授权,再执行
|
|
147
|
-
`/bailinghub login`。
|
|
148
|
-
|
|
149
|
-
连接选择只能由用户斜杠命令发起,不会作为模型工具暴露。切换只影响之后创建的 Agent 会话,已有
|
|
150
|
-
会话继续固定在原连接与 workspace。`/bailinghub use <workspace>` 是另一件事:只有当前 Agent
|
|
151
|
-
Session 已经允许目标 workspace 时才成功。
|
|
152
|
-
|
|
153
|
-
删除当前连接后,适配器会读取 SDK registry,把剩余的当前连接(包括没有别名的连接)设为新会话
|
|
154
|
-
默认值;删除最后一个连接后则明确进入未配置状态。删除后的 registry 刷新失败不会把已经成功的
|
|
155
|
-
删除改写成错误;如果删除的是非当前连接,刷新不可用时也会保留仍然有效的默认连接。
|
|
156
|
-
|
|
157
|
-
对于同一个 `Hub + clientAppId + workspace` 公开绑定,最终身份由业务授权页及其可信
|
|
158
|
-
`on_behalf_of` 结果决定。如果另一个本机连接名已经授权同一身份,SDK 会用本次连接覆盖旧连接,
|
|
159
|
-
并撤销旧 Agent Session;不同可信身份则继续作为相互独立的连接。如果从一个已经属于其他身份的
|
|
160
|
-
`connectionName` 发起登录,SDK 会保留原连接名及其 Session,为新身份分配一个不冲突的本机名称
|
|
161
|
-
(例如 `default-2`),并把新连接设为后续会话的当前选择。用户可以用 `connections list` 查看
|
|
162
|
-
两者,再用 `connections use <名称或连接键>` 显式切换。如果登录结果返回
|
|
163
|
-
`cleanupRequired: true`,说明新连接仍然授权成功,但一个或多个同绑定旧连接还需要显式清理;
|
|
164
|
-
如果身份检查被推迟,此时还不能断言它们是同一身份。不要重复授权;先查看 `connections list`,
|
|
165
|
-
再对提示的旧连接执行
|
|
166
|
-
`/bailinghub connections remove <名称或连接键>`。
|
|
167
|
-
|
|
168
|
-
首次验收时,新建一个 DSH 会话,先做一次只读查询,再做一次允许的修改。确认 BailingHub
|
|
169
|
-
后台能看到同一个会话、run、可见最终回复和工具调用轨迹。需要审批的能力必须在审批后恢复
|
|
170
|
-
原 invocation,不能生成替代业务调用。
|
|
171
|
-
|
|
172
|
-
本版本在 DSH Code Mode 下会明确降级,因为当前 Code Mode 无法安全呈现本轮动态 Schema。
|
|
173
|
-
需要执行受治理业务操作时应使用 Native Tool Mode。
|
|
174
|
-
|
|
175
|
-
## 安全与隐私边界
|
|
176
|
-
|
|
177
|
-
- 模型不能通过工具参数选择 Hub URL、workspace、本机连接、业务身份、凭据、审批结论或能力版本;
|
|
178
|
-
- SDK 在 macOS 使用 Keychain;Windows 凭据文件保存在 LocalAppData 并由 CurrentUser DPAPI
|
|
179
|
-
保护,Windows PowerShell 或 DPAPI 不可用时失败关闭,不会降级为明文;Linux 与其他 POSIX
|
|
180
|
-
系统必须显式启用安全文件回退;
|
|
181
|
-
- BailingHub 对每次治理调用重新校验身份、scope、审批、幂等与调用状态,业务系统仍执行
|
|
182
|
-
最终权限判断;
|
|
183
|
-
- 适配器会发送 Agent Client 契约所需的可见用户输入、受治理工具参数/结果和可见最终回复,
|
|
184
|
-
但不会上传隐藏思考片段;
|
|
185
|
-
- 本插件只治理它注册的 BailingHub 工具,不会拦截 DSH 其他工具或模型提供方流量。
|
|
186
|
-
|
|
187
|
-
生产使用前请阅读[安全策略](../SECURITY.md)、[隐私说明](../PRIVACY.md)、
|
|
188
|
-
[Agent Client 契约](AGENT_CLIENT_CONTRACT.md)和[兼容范围](COMPATIBILITY.md)。
|
|
189
|
-
|
|
190
|
-
## 公开 0.1.x 静态兼容模式
|
|
191
|
-
|
|
192
|
-
公开 `dsh-bailinghub@0.1.1` 仍是不可变的纯配置 Bundle。它通过 DSH 内置 MCP Client 启动
|
|
193
|
-
`bailinghub-mcp-server@0.1.1`,把运营者提供的一个 Client Token 固定绑定到一个 route,
|
|
194
|
-
并由 BailingHub 完成编排。
|
|
195
|
-
|
|
196
|
-
```bash
|
|
197
|
-
dsh plugin --profile web add dsh-bailinghub@0.1.1
|
|
121
|
+
连接管理与范围选择都是用户命令,不是模型工具。再次授权同一可信身份会替换旧连接和旧 Agent
|
|
122
|
+
Session;不同身份独立保留。若登录提示需要清理,新连接已经授权成功,应检查提示的旧条目并重试
|
|
123
|
+
删除,不要再次授权。详情见[身份与连接规则](AGENT_CLIENT_CONTRACT.md#browser-identity-and-local-reconciliation)。
|
|
198
124
|
|
|
199
|
-
|
|
200
|
-
export BAILINGHUB_CLIENT_TOKEN='replace-with-a-route-scoped-client-token'
|
|
201
|
-
export BAILINGHUB_ROUTE='order_assistant'
|
|
202
|
-
```
|
|
125
|
+
## 给接入开发者
|
|
203
126
|
|
|
204
|
-
|
|
127
|
+
DSH 负责思考与工具编排;BailingHub Core 负责可信身份、治理、审批、调用状态和审计;SDK 负责
|
|
128
|
+
浏览器授权、安全凭据和 HTTP 映射。本插件只适配 DSH 的会话、提示词、命令、工具和可见事件,
|
|
129
|
+
不直接调用业务 API,也不治理其他 DSH 工具。
|
|
205
130
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
mcp__bailinghub__wait_for_governed_job
|
|
210
|
-
```
|
|
131
|
+
业务系统继续声明原有能力,为每个身份分别授权即可。自定义 DSH 宿主需接入[范围选择与恢复 API](AGENT_CLIENT_CONTRACT.md#host-owned-session-scope-api),
|
|
132
|
+
在首条消息前显示确认;原生斜杠命令已经使用这些 API。参数结构、持久化和恢复细节见
|
|
133
|
+
[Agent Client 契约](AGENT_CLIENT_CONTRACT.md)。
|
|
211
134
|
|
|
212
|
-
|
|
213
|
-
|
|
135
|
+
请使用 Native Tool Mode。DSH Code Mode 无法安全呈现本轮动态工具结构,因此明确降级。
|
|
136
|
+
版本范围见[兼容矩阵](COMPATIBILITY.md)。
|
|
214
137
|
|
|
215
|
-
##
|
|
138
|
+
## 旧版 0.1.1 与反馈
|
|
216
139
|
|
|
217
|
-
0.
|
|
218
|
-
|
|
140
|
+
公开 `dsh-bailinghub@0.1.1` 继续作为独立的静态 MCP 兼容路径:它启动
|
|
141
|
+
`bailinghub-mcp-server@0.1.1`,使用运营者提供的固定路由 Client Token,由 BailingHub 编排。
|
|
142
|
+
0.4.0 不会读取或转换该凭据。使用旧路径时继续固定旧版本,并参考[迁移说明](MIGRATION_VNEXT.md)。
|
|
219
143
|
|
|
220
|
-
问题请提交到 [GitHub Issues](https://github.com/bailinghub/bailinghub-dsh-plugin/issues)
|
|
221
|
-
|
|
144
|
+
问题请提交到 [GitHub Issues](https://github.com/bailinghub/bailinghub-dsh-plugin/issues),提供版本与
|
|
145
|
+
脱敏错误,不附带 Token、私有地址、个人信息或生产业务数据。兼容测试与下载量不代表生产采用。
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
function canonical(value) {
|
|
2
|
+
if (Array.isArray(value)) return value.map(canonical)
|
|
3
|
+
if (value && typeof value === 'object') {
|
|
4
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]))
|
|
5
|
+
}
|
|
6
|
+
return value
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** A declaration can be shared; its authorization, run and revision cannot. */
|
|
10
|
+
export function declarationKey(tool) {
|
|
11
|
+
return JSON.stringify(canonical(tool))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function sharedDeclarations(targets, limit, preferredNames = []) {
|
|
15
|
+
const grouped = new Map()
|
|
16
|
+
const conflicts = new Set()
|
|
17
|
+
for (const target of targets) {
|
|
18
|
+
const run = target.currentRun
|
|
19
|
+
if (run?.status !== 'active') continue
|
|
20
|
+
for (const tool of run.activeTools) {
|
|
21
|
+
const key = declarationKey(tool)
|
|
22
|
+
const existing = grouped.get(tool.name)
|
|
23
|
+
if (existing && existing.key !== key) conflicts.add(tool.name)
|
|
24
|
+
if (!existing) grouped.set(tool.name, { tool, key, targets: [] })
|
|
25
|
+
grouped.get(tool.name).targets.push(target)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const priority = new Map(preferredNames.map((name, index) => [name, index]))
|
|
29
|
+
const tools = [...grouped.values()].filter(({ tool }) => !conflicts.has(tool.name))
|
|
30
|
+
.sort((a, b) => (priority.get(a.tool.name) ?? Infinity) - (priority.get(b.tool.name) ?? Infinity))
|
|
31
|
+
return { tools: tools.slice(0, limit), conflicts: [...conflicts].sort(), omitted: Math.max(0, tools.length - limit) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function authorizationEnvelope(tool, refs) {
|
|
35
|
+
return {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
authorization_ref: {
|
|
39
|
+
type: 'string', enum: refs,
|
|
40
|
+
description: 'Use the authorization matching the user\'s intended account. Ask when the target is ambiguous.',
|
|
41
|
+
},
|
|
42
|
+
arguments: structuredClone(tool.parameters),
|
|
43
|
+
},
|
|
44
|
+
required: ['authorization_ref', 'arguments'],
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function unwrapAuthorization(value) {
|
|
50
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
51
|
+
Object.keys(value).some((key) => key !== 'authorization_ref' && key !== 'arguments') ||
|
|
52
|
+
typeof value.authorization_ref !== 'string' || !value.arguments ||
|
|
53
|
+
typeof value.arguments !== 'object' || Array.isArray(value.arguments)) {
|
|
54
|
+
throw new TypeError('An authorization_ref and an object of business arguments are required')
|
|
55
|
+
}
|
|
56
|
+
return { ref: value.authorization_ref, arguments: structuredClone(value.arguments) }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function authorizedResult(target, result) {
|
|
60
|
+
return { authorization_ref: target.authorization.ref, authorization_label: target.authorization.label, result }
|
|
61
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { lstat, mkdir, open, rename, unlink } from 'node:fs/promises'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { join, resolve } from 'node:path'
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
7
|
+
|
|
8
|
+
const SCHEMA = 'bailing.agent-conversation-outbox.v1'
|
|
9
|
+
const MAX_BYTES = 32 * 1024 * 1024
|
|
10
|
+
const LOCK_TIMEOUT_MS = 1_000
|
|
11
|
+
const SECRET_KEYS = new Set(['token', 'accesstoken', 'refreshtoken', 'password', 'clientsecret', 'authorization', 'credential', 'credentials'])
|
|
12
|
+
|
|
13
|
+
function storeError(code = 'ARCHIVE_STORE_UNAVAILABLE') {
|
|
14
|
+
const error = new Error(code === 'ARCHIVE_STORE_CONFLICT'
|
|
15
|
+
? 'The conversation archive changed or is being saved. Read it again before retrying.'
|
|
16
|
+
: 'The conversation archive store is unavailable. The archive was not safely saved.')
|
|
17
|
+
error.code = code
|
|
18
|
+
return error
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function safeError(error) {
|
|
22
|
+
return ['ARCHIVE_STORE_CONFLICT', 'ARCHIVE_STORE_UNAVAILABLE'].includes(error?.code)
|
|
23
|
+
? error : storeError()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sessionKey(sessionId) {
|
|
27
|
+
if (typeof sessionId !== 'string' || !sessionId.length || sessionId.length > 4_096) throw storeError()
|
|
28
|
+
return createHash('sha256').update(sessionId).digest('hex')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The archive coordinator owns the event schema. This boundary additionally refuses
|
|
32
|
+
// non-JSON values and credential fields so stores cannot become credential caches.
|
|
33
|
+
function snapshotJson(value, seen = new Set()) {
|
|
34
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
|
|
35
|
+
if (typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)) return value
|
|
36
|
+
if (!value || typeof value !== 'object' || seen.has(value)) throw storeError()
|
|
37
|
+
const array = Array.isArray(value)
|
|
38
|
+
if (!array && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw storeError()
|
|
39
|
+
seen.add(value)
|
|
40
|
+
try {
|
|
41
|
+
const entries = Reflect.ownKeys(value).filter((key) => !array || key !== 'length')
|
|
42
|
+
if (array && (entries.length !== value.length || entries.some((key, index) => key !== String(index)))) throw storeError()
|
|
43
|
+
const copy = array ? [] : {}
|
|
44
|
+
for (const key of entries) {
|
|
45
|
+
const property = Object.getOwnPropertyDescriptor(value, key)
|
|
46
|
+
if (typeof key !== 'string' || !property.enumerable || !Object.hasOwn(property, 'value') ||
|
|
47
|
+
SECRET_KEYS.has(key.replace(/[_-]/gu, '').toLowerCase()) || ['__proto__', 'constructor', 'prototype'].includes(key)) throw storeError()
|
|
48
|
+
copy[key] = snapshotJson(property.value, seen)
|
|
49
|
+
}
|
|
50
|
+
return copy
|
|
51
|
+
} finally {
|
|
52
|
+
seen.delete(value)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function snapshotRecord(sessionId, record) {
|
|
57
|
+
const copy = snapshotJson(record)
|
|
58
|
+
if (!copy || Array.isArray(copy) || copy.schema !== SCHEMA || copy.sessionId !== sessionId ||
|
|
59
|
+
!Number.isSafeInteger(copy.revision) || copy.revision < 1) throw storeError()
|
|
60
|
+
if (Buffer.byteLength(JSON.stringify(copy)) > MAX_BYTES) throw storeError()
|
|
61
|
+
return copy
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function nextRecord(sessionId, record, expectedRevision) {
|
|
65
|
+
sessionKey(sessionId)
|
|
66
|
+
if (expectedRevision !== null && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1)) throw storeError()
|
|
67
|
+
const copy = snapshotRecord(sessionId, record)
|
|
68
|
+
if (copy.revision !== (expectedRevision ?? 0) + 1) throw storeError()
|
|
69
|
+
return copy
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function assertRevision(current, expectedRevision) {
|
|
73
|
+
if ((current?.revision ?? null) !== expectedRevision) throw storeError('ARCHIVE_STORE_CONFLICT')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function assertOwned(stat, kind) {
|
|
77
|
+
if (!(kind === 'directory' ? stat.isDirectory() : stat.isFile()) ||
|
|
78
|
+
(kind === 'file' && stat.nlink !== 1) ||
|
|
79
|
+
(typeof process.getuid === 'function' && stat.uid !== process.getuid()) ||
|
|
80
|
+
(process.platform !== 'win32' && (stat.mode & 0o777) !== (kind === 'directory' ? 0o700 : 0o600))) throw storeError()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sameFile(a, b) {
|
|
84
|
+
return a.dev === b.dev && a.ino === b.ino
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function defaultDirectory() {
|
|
88
|
+
const configured = process.env.DSH_HOME
|
|
89
|
+
const base = typeof configured === 'string' && configured.trim() ? configured : join(homedir(), '.dsh')
|
|
90
|
+
const expanded = base === '~' ? homedir() : /^~[/\\]/u.test(base) ? join(homedir(), base.slice(2)) : base
|
|
91
|
+
return join(resolve(expanded), 'plugins', 'dsh-bailinghub', 'conversation-outbox')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function ensureDirectory(directory, create) {
|
|
95
|
+
if (create) await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
96
|
+
let stat
|
|
97
|
+
try {
|
|
98
|
+
stat = await lstat(directory)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (!create && error.code === 'ENOENT') return false
|
|
101
|
+
throw error
|
|
102
|
+
}
|
|
103
|
+
assertOwned(stat, 'directory')
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function readRecord(path, sessionId) {
|
|
108
|
+
let before
|
|
109
|
+
try {
|
|
110
|
+
before = await lstat(path)
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error.code === 'ENOENT') return null
|
|
113
|
+
throw error
|
|
114
|
+
}
|
|
115
|
+
assertOwned(before, 'file')
|
|
116
|
+
if (before.size > MAX_BYTES) throw storeError()
|
|
117
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
118
|
+
try {
|
|
119
|
+
const opened = await file.stat()
|
|
120
|
+
assertOwned(opened, 'file')
|
|
121
|
+
if (!sameFile(before, opened) || opened.size > MAX_BYTES) throw storeError()
|
|
122
|
+
// Bound the read even if an abnormal writer grows the file after fstat.
|
|
123
|
+
const bytes = Buffer.alloc(MAX_BYTES + 1)
|
|
124
|
+
let length = 0
|
|
125
|
+
while (length < bytes.length) {
|
|
126
|
+
const result = await file.read(bytes, length, bytes.length - length, length)
|
|
127
|
+
if (!result.bytesRead) break
|
|
128
|
+
length += result.bytesRead
|
|
129
|
+
}
|
|
130
|
+
if (length > MAX_BYTES) throw storeError()
|
|
131
|
+
return snapshotRecord(sessionId, JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, length))))
|
|
132
|
+
} finally {
|
|
133
|
+
await file.close()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function acquireLock(path) {
|
|
138
|
+
const deadline = performance.now() + LOCK_TIMEOUT_MS
|
|
139
|
+
while (true) {
|
|
140
|
+
let file
|
|
141
|
+
let stat
|
|
142
|
+
try {
|
|
143
|
+
file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
|
|
144
|
+
stat = await file.stat()
|
|
145
|
+
await file.chmod(0o600)
|
|
146
|
+
assertOwned(await file.stat(), 'file')
|
|
147
|
+
return { file, stat }
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (file) {
|
|
150
|
+
if (stat) await releaseLock(path, { file, stat })
|
|
151
|
+
else await file.close().catch(() => {})
|
|
152
|
+
}
|
|
153
|
+
if (error.code !== 'EEXIST') throw error
|
|
154
|
+
try {
|
|
155
|
+
assertOwned(await lstat(path), 'file')
|
|
156
|
+
} catch (inspectionError) {
|
|
157
|
+
if (inspectionError.code === 'ENOENT') continue
|
|
158
|
+
throw inspectionError
|
|
159
|
+
}
|
|
160
|
+
if (performance.now() >= deadline) throw storeError('ARCHIVE_STORE_CONFLICT')
|
|
161
|
+
// Never infer staleness or remove another process's lock.
|
|
162
|
+
await delay(20)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function releaseLock(path, lock) {
|
|
168
|
+
try {
|
|
169
|
+
const current = await lstat(path)
|
|
170
|
+
if (!sameFile(current, lock.stat) || !current.isFile()) throw storeError()
|
|
171
|
+
await unlink(path)
|
|
172
|
+
} finally {
|
|
173
|
+
await lock.file.close()
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function syncDirectory(directory) {
|
|
178
|
+
// Windows does not expose fsync on directory handles through Node.
|
|
179
|
+
if (process.platform === 'win32') return
|
|
180
|
+
const handle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
|
|
181
|
+
try {
|
|
182
|
+
assertOwned(await handle.stat(), 'directory')
|
|
183
|
+
await handle.sync()
|
|
184
|
+
} finally {
|
|
185
|
+
await handle.close()
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Private transcript outboxes. No user directory is touched until load/save. */
|
|
190
|
+
export function createFileConversationArchiveStore({ directory } = {}) {
|
|
191
|
+
if (directory !== undefined && (typeof directory !== 'string' || !directory.trim())) throw storeError()
|
|
192
|
+
const root = directory === undefined ? defaultDirectory() : resolve(directory)
|
|
193
|
+
return {
|
|
194
|
+
async load(sessionId) {
|
|
195
|
+
try {
|
|
196
|
+
const key = sessionKey(sessionId)
|
|
197
|
+
if (!await ensureDirectory(root, false)) return null
|
|
198
|
+
return await readRecord(join(root, `${key}.json`), sessionId)
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw safeError(error)
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
async save(sessionId, record, expectedRevision) {
|
|
204
|
+
let lock
|
|
205
|
+
let temporaryPath
|
|
206
|
+
const key = sessionKey(sessionId)
|
|
207
|
+
const path = join(root, `${key}.json`)
|
|
208
|
+
const lockPath = join(root, `${key}.lock`)
|
|
209
|
+
try {
|
|
210
|
+
const copy = nextRecord(sessionId, record, expectedRevision)
|
|
211
|
+
await ensureDirectory(root, true)
|
|
212
|
+
lock = await acquireLock(lockPath)
|
|
213
|
+
assertRevision(await readRecord(path, sessionId), expectedRevision)
|
|
214
|
+
temporaryPath = join(root, `.${key}.${randomUUID()}.tmp`)
|
|
215
|
+
const temporary = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
|
|
216
|
+
try {
|
|
217
|
+
await temporary.chmod(0o600)
|
|
218
|
+
assertOwned(await temporary.stat(), 'file')
|
|
219
|
+
await temporary.writeFile(JSON.stringify(copy), 'utf8')
|
|
220
|
+
await temporary.sync()
|
|
221
|
+
} finally {
|
|
222
|
+
await temporary.close()
|
|
223
|
+
}
|
|
224
|
+
await rename(temporaryPath, path)
|
|
225
|
+
temporaryPath = undefined
|
|
226
|
+
await syncDirectory(root)
|
|
227
|
+
return snapshotRecord(sessionId, copy)
|
|
228
|
+
} catch (error) {
|
|
229
|
+
throw safeError(error)
|
|
230
|
+
} finally {
|
|
231
|
+
try {
|
|
232
|
+
if (temporaryPath) await unlink(temporaryPath).catch((error) => {
|
|
233
|
+
if (error.code !== 'ENOENT') throw storeError()
|
|
234
|
+
})
|
|
235
|
+
} finally {
|
|
236
|
+
if (lock) {
|
|
237
|
+
try {
|
|
238
|
+
await releaseLock(lockPath, lock)
|
|
239
|
+
} catch {
|
|
240
|
+
throw storeError()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Explicit in-memory adapter: CAS-compatible, but never survives process restart. */
|
|
250
|
+
export function createMemoryConversationArchiveStore() {
|
|
251
|
+
const records = new Map()
|
|
252
|
+
return {
|
|
253
|
+
async load(sessionId) {
|
|
254
|
+
sessionKey(sessionId)
|
|
255
|
+
const record = records.get(sessionId)
|
|
256
|
+
return record ? snapshotRecord(sessionId, record) : null
|
|
257
|
+
},
|
|
258
|
+
async save(sessionId, record, expectedRevision) {
|
|
259
|
+
const copy = nextRecord(sessionId, record, expectedRevision)
|
|
260
|
+
assertRevision(records.get(sessionId), expectedRevision)
|
|
261
|
+
records.set(sessionId, copy)
|
|
262
|
+
return snapshotRecord(sessionId, copy)
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
}
|