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.
@@ -2,220 +2,144 @@
2
2
 
3
3
  [English](../README.md) | 简体中文
4
4
 
5
- 把商城、SaaS 或其他业务系统接到 BailingHub 后,本地 DeepSeek Harness 智能体就能直接操作
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
- 思考、工具选择与编排留在本地 DSH;BailingHub 负责向本地智能体提供已授权的业务上下文、
17
- 可用能力、审批状态、调用恢复与审计记录。
13
+ 智能体会为每次调用选择对应授权,不需要你反复切换当前连接。具体能查什么、能改什么,仍取决于
14
+ 业务系统开放的能力和各账号权限。本版尚不提供不同系统或不同路由之间的编排。
18
15
 
19
- 这是独立社区集成,不是 DeepSeek 官方开发、认证、合作、背书或推荐的插件。
16
+ 本版也能把可见沟通过程与业务操作关联起来。记录上传失败后,可以在联网或重启后继续补传,
17
+ 不会因此重新执行业务操作。
20
18
 
21
- > **当前稳定版本线:**`dsh-bailinghub@0.3.0` 使用下文说明的原生 Agent Client 流程。
22
- > 公开 `0.1.1` 仅作为明确的静态 MCP 兼容路径继续保留。
19
+ 这是独立社区集成,不是 DeepSeek 官方开发、认证、合作、背书或推荐的插件。
23
20
 
24
- 希望用最短路径完成首次使用,可以直接阅读[三分钟开始使用](GETTING_STARTED.zh-CN.md)。
21
+ ## 安装与开始使用
25
22
 
26
- ## 0.3 Agent Client 的关系
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
- ```text
29
- DeepSeek Harness 本地智能体
30
- -> dsh-bailinghub 原生 Cordis 适配器
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
- 部署者和业务接入开发者需要先在自己的 BailingHub 中准备这些公开标识:
37
+ ## 为每个会话选择账号
51
38
 
52
- 1. 一套可访问的 HTTPS BailingHub,并部署匹配版本的 Agent Auth 与 Agent API;
53
- 2. 一个公开 Agent Client 应用标识 `clientAppId`;
54
- 3. 至少一个允许授权的 workspace;在 Agent Client v1 中,workspace id 就是
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
- 最终用户**不需要**在插件中填写业务 API 地址、业务账号密码、Tool Provider 签名密钥、
60
- BailingHub Client Token 或模型提供方 Key。
43
+ **新建会话,在发送第一条消息前**执行:
61
44
 
62
- ## 安装 0.3 版本线
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
- `dsh-bailinghub@0.3.0` 会自动安装精确兼容的 `bailinghub-mcp-server@0.3.0` 依赖。
78
- DSH 用户不应该再自行猜测或单独安装某个 SDK 版本。
51
+ 把占位符换成列表里的固定连接键,不是连接名称。也可以只选一份授权;多份授权必须属于同一个
52
+ 中枢、Client App 和 workspace。等待设置成功回显后,再发送业务请求。
79
53
 
80
- ## 配置一个中枢连接
54
+ **新会话默认是普通聊天,选择业务范围后才会提供业务工具。** 登录成功或切换默认连接不会自动
55
+ 开启业务访问。`/bailinghub scope none` 可显式选择普通聊天。第一条用户消息会固定这个范围;
56
+ 之后要增减账号,或从普通聊天改成业务会话,都需要新建会话。
81
57
 
82
- 原生插件只有四个宿主配置字段:
58
+ 同一套能力不必为每家店重复声明。智能体选择每次调用使用的授权,不会拿到凭据;各项操作仍受
59
+ 对应账号权限和审批规则约束。需要审批时,在会话仍运行的情况下,继续的是原调用。
83
60
 
84
- | 插件字段 | 环境变量 | 含义 | 是否 Secret |
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
- ```bash
94
- export BAILINGHUB_HUB_URL='https://hub.example.com'
95
- export BAILINGHUB_CLIENT_APP_ID='example-agent-client'
96
- export BAILINGHUB_WORKSPACE='order_assistant'
97
- export BAILINGHUB_CONNECTION_NAME='default'
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
- 也可以通过 DSH 的插件设置界面填写同样四个字段。不要在 Cordis Patch 中增加 Token、授权
101
- 页面地址、业务域名或任何凭据。中枢会根据 Client App 找到唯一业务授权入口。
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
- ```bash
107
- dsh --profile web --dump-config
108
- dsh web
109
- ```
87
+ 重开已保存的业务会话时,必须核验全部原授权后才恢复原范围。离线重开后,可以联网并在**同一
88
+ 会话**执行 `/bailinghub archive sync` 或 `/bailinghub scope` 重试核验。这恢复的是范围与
89
+ 已保存记录的补传,**不恢复进程重启前的业务调用、待审批操作或未完成任务**。未开始的已保存
90
+ 草稿需要重新选择;没有有效旧范围快照的已开始会话不能自动采用今天的默认账号。
110
91
 
111
- ## 浏览器授权与使用
92
+ ## 哪些信息会共享和保存
112
93
 
113
- 在 DSH 中依次执行:
94
+ 选中账号的业务上下文与可见用户请求会进入同一个本地智能体及模型会话。合并后的沟通归档按完整
95
+ 授权集合控制访问,仅有其中一份授权不能读取混合会话。若这些账号的数据需要彼此隔离,应使用
96
+ 不同会话。
114
97
 
115
- ```text
116
- /bailinghub login
117
- /bailinghub doctor
118
- /bailinghub status
119
- /bailinghub workspaces
120
- ```
98
+ 采集从本版启用后的业务轮次开始,只包含可见文本,不包含附件、隐藏思考或全部历史会话,也不会
99
+ 自动清除用户粘贴在正文里的秘密。检测到历史缺口会明确显示;宿主不提供持久历史时,覆盖度为
100
+ `unverified`,不会声称完整。
121
101
 
122
- `login` 会在系统浏览器打开中枢管理员配置的唯一业务授权入口。业务授权页负责登录、切换账号、
123
- 选择租户,并确认最终业务身份和申请的 workspace,然后返回受 `state` 与 PKCE S256 保护的
124
- 随机回环回调。Access Token 与 Refresh Token 只进入 SDK 所有的安全存储,不会写入插件配置,
125
- 也不会由命令输出。
102
+ 本机私有待上传记录含有**明文任务正文**,已上传的事件也会保留,直到宿主或用户自行清理;目前
103
+ 没有自动保留期限。删除本机记录不等于删除中枢记录。授权凭据仍由 SDK 安全存储。启用业务访问前
104
+ 请阅读[隐私说明](../PRIVACY.md)与[安全策略](../SECURITY.md)。
126
105
 
127
- 常用命令:
106
+ ## 常用管理命令
128
107
 
129
108
  | 命令 | 用途 |
130
109
  | --- | --- |
131
- | `/bailinghub doctor` | 在不输出凭据的前提下检查宿主 API、公开配置、SDK、授权状态和 workspace 连通性 |
132
- | `/bailinghub connections list` | 查看本机公开连接元数据与授权状态,不输出 Token |
133
- | `/bailinghub connections add <名称> <中枢地址> <clientAppId> <workspace>` | 创建并选择另一个本机连接实例;公开绑定可以与已有实例相同 |
134
- | `/bailinghub connections use <名称或连接键>` | 只为之后新建的会话选择一个已登记连接 |
135
- | `/bailinghub connections remove <名称或连接键>` | 先远程撤销 Agent Session,再删除本机凭据和公开元数据 |
136
- | `/bailinghub login` | 在浏览器授权当前 Hub/client/workspace |
137
- | `/bailinghub status` | 查看当前连接状态,但不输出凭据 |
138
- | `/bailinghub workspaces` | 查看当前业务授权允许使用的 workspace |
139
- | `/bailinghub use <workspace>` | 为新会话切换到另一个已授权 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
- 插件四字段是启动连接。其他连接可用 `connections add` 登记;BailingHub 控制台“智能体客户端”
144
- 页面也能生成同样的不含秘密命令。重启后,适配器会在第一个新 Agent 会话或用户命令前读取 SDK
145
- registry,并采用其中当前连接的公开元数据;registry 缺失或不可用时安全回退到这四个启动字段。
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
- export BAILINGHUB_BASE_URL='https://hub.example.com'
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
- ```text
207
- mcp__bailinghub__submit_governed_job
208
- mcp__bailinghub__get_governed_job
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
- 0.3 Agent Client 不会自动读取或迁移 0.1 Client Token。测试升级或回滚时必须显式固定版本,
213
- 并遵循 [0.1 到 0.3 的迁移边界](MIGRATION_VNEXT.md)。
135
+ 请使用 Native Tool Mode。DSH Code Mode 无法安全呈现本轮动态工具结构,因此明确降级。
136
+ 版本范围见[兼容矩阵](COMPATIBILITY.md)。
214
137
 
215
- ## 兼容范围与反馈
138
+ ## 旧版 0.1.1 与反馈
216
139
 
217
- 0.3.0 只对 [COMPATIBILITY.md](COMPATIBILITY.md) 中列出的版本完成了验证。DeepSeek
218
- Harness 仍是 Developer Preview,每次 Harness 升级都必须重新执行 Native Lifecycle Smoke。
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
- 请勿附带 Token、私有部署地址、个人信息或生产业务数据。
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
+ }