dsh-bailinghub 0.2.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 +70 -0
- package/PRIVACY.md +108 -9
- package/README.md +129 -148
- package/SECURITY.md +114 -4
- package/docs/AGENT_CLIENT_CONTRACT.md +330 -23
- package/docs/COMPATIBILITY.md +75 -12
- package/docs/GETTING_STARTED.md +126 -0
- package/docs/GETTING_STARTED.zh-CN.md +113 -0
- package/docs/MIGRATION_VNEXT.md +106 -22
- package/docs/PROJECT_BOUNDARIES.md +4 -2
- package/docs/README.zh-CN.md +98 -132
- 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 +1070 -64
- package/lib/session-scope-store.js +265 -0
- package/lib/session-scope.js +319 -0
- package/lib/transport.js +16 -2
- package/package.json +14 -4
package/docs/README.zh-CN.md
CHANGED
|
@@ -2,178 +2,144 @@
|
|
|
2
2
|
|
|
3
3
|
[English](../README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
让本地 DeepSeek Harness
|
|
6
|
-
|
|
7
|
-
BailingHub 负责。
|
|
5
|
+
让本地 DeepSeek Harness 智能体操作已经接入 BailingHub 的业务系统:查询记录、修改允许的字段,
|
|
6
|
+
需要审批时继续走原有规则。BailingHub 会记录用了哪份授权、做了什么,以及业务系统返回的结果。
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
> **当前稳定版本线:**`dsh-bailinghub@0.2.0` 使用下文说明的原生 Agent Client 流程。
|
|
12
|
-
> 公开 `0.1.1` 仅作为明确的静态 MCP 兼容路径继续保留。
|
|
13
|
-
|
|
14
|
-
## 0.2 Agent Client 的关系
|
|
15
|
-
|
|
16
|
-
```text
|
|
17
|
-
DeepSeek Harness 本地智能体
|
|
18
|
-
-> dsh-bailinghub 原生 Cordis 适配器
|
|
19
|
-
-> bailinghub-mcp-server/sdk
|
|
20
|
-
-> BailingHub Agent Auth + Agent API
|
|
21
|
-
-> 部署者选择的业务接入与最终业务授权
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
各层职责保持独立:
|
|
8
|
+
**0.4.0 支持在一个会话中使用同一系统的多份授权。** 例如,分别授权 A 店和 B 店后,新建会话并
|
|
9
|
+
选中两者,就可以说:
|
|
25
10
|
|
|
26
|
-
|
|
27
|
-
能力治理、审批、调用状态和审计记录。
|
|
28
|
-
- **`bailinghub-mcp-server/sdk`** 负责浏览器登录、PKCE、凭据存储与刷新,以及按
|
|
29
|
-
Hub/client/workspace 隔离连接和映射 HTTP DTO。
|
|
30
|
-
- **`dsh-bailinghub`** 只负责 DSH 会话、提示词、命令和动态工具生命周期,不保存凭据,
|
|
31
|
-
也不直接调用业务 API。
|
|
11
|
+
> 对比今天 A 店和 B 店的营业情况,按门店分别说明。
|
|
32
12
|
|
|
33
|
-
|
|
34
|
-
|
|
13
|
+
智能体会为每次调用选择对应授权,不需要你反复切换当前连接。具体能查什么、能改什么,仍取决于
|
|
14
|
+
业务系统开放的能力和各账号权限。本版尚不提供不同系统或不同路由之间的编排。
|
|
35
15
|
|
|
36
|
-
|
|
16
|
+
本版也能把可见沟通过程与业务操作关联起来。记录上传失败后,可以在联网或重启后继续补传,
|
|
17
|
+
不会因此重新执行业务操作。
|
|
37
18
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
1. 一套可访问的 HTTPS BailingHub,并部署匹配版本的 Agent Auth 与 Agent API;
|
|
41
|
-
2. 一个公开 Agent Client 应用标识 `clientAppId`;
|
|
42
|
-
3. 至少一个允许授权的 workspace;在 Agent Client v1 中,workspace id 就是
|
|
43
|
-
BailingHub route id;
|
|
44
|
-
4. 该 route 后方已经接通业务授权页面,以及受治理的 ACC/Tool Provider 能力。
|
|
45
|
-
|
|
46
|
-
最终用户**不需要**在插件中填写业务 API 地址、业务账号密码、Tool Provider 签名密钥、
|
|
47
|
-
BailingHub Client Token 或模型提供方 Key。
|
|
48
|
-
|
|
49
|
-
## 安装 0.2 版本线
|
|
50
|
-
|
|
51
|
-
前置条件:
|
|
19
|
+
这是独立社区集成,不是 DeepSeek 官方开发、认证、合作、背书或推荐的插件。
|
|
52
20
|
|
|
53
|
-
|
|
54
|
-
- `pnpm` 与 DeepSeek Harness `0.1.0-rc.7`;
|
|
55
|
-
- 已完成上面的 BailingHub 接入准备。
|
|
21
|
+
## 安装与开始使用
|
|
56
22
|
|
|
57
|
-
|
|
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**。
|
|
58
25
|
|
|
59
26
|
```bash
|
|
60
|
-
npm install --global pnpm @deepseek-ai/dsh@0.1.
|
|
61
|
-
dsh plugin --profile web add dsh-bailinghub@0.
|
|
27
|
+
npm install --global pnpm @deepseek-ai/dsh@0.1.1-rc.2
|
|
28
|
+
dsh plugin --profile web add dsh-bailinghub@0.4.0
|
|
62
29
|
```
|
|
63
30
|
|
|
64
|
-
|
|
65
|
-
|
|
31
|
+
插件会自动安装精确依赖 `bailinghub-mcp-server@0.4.0`,无需另装 SDK。
|
|
32
|
+
已经使用旧版的用户请先看[0.3 到 0.4 的迁移步骤](MIGRATION_VNEXT.md)。
|
|
66
33
|
|
|
67
|
-
|
|
34
|
+
按照[开始使用指南](GETTING_STARTED.zh-CN.md)填写管理员提供的四项公开连接信息,再到浏览器授权。
|
|
35
|
+
不要把业务密码、Client Token、签名密钥或模型 Key 填进插件设置或聊天消息。
|
|
68
36
|
|
|
69
|
-
|
|
37
|
+
## 为每个会话选择账号
|
|
70
38
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
| `clientAppId` | `BAILINGHUB_CLIENT_APP_ID` | 在该中枢注册的公共 Agent Client 应用标识 | 否 |
|
|
75
|
-
| `workspace` | `BAILINGHUB_WORKSPACE` | 初始已授权 workspace/route id | 否 |
|
|
76
|
-
| `connectionName` | `BAILINGHUB_CONNECTION_NAME` | 当前 SDK 隔离连接的本地别名 | 否 |
|
|
39
|
+
在原业务授权页面分别授权 A 店和 B 店。创建连接时使用清晰的本机名称,例如 `A 店`、`B 店`,
|
|
40
|
+
并在授权页核对实际业务身份。名称是你提供的标签,不证明身份,也不授予权限;`default`、
|
|
41
|
+
`default-2` 无法让智能体知道你指的是哪家店。
|
|
77
42
|
|
|
78
|
-
|
|
43
|
+
**新建会话,在发送第一条消息前**执行:
|
|
79
44
|
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
export BAILINGHUB_CONNECTION_NAME='default'
|
|
45
|
+
```text
|
|
46
|
+
/bailinghub connections list
|
|
47
|
+
/bailinghub scope set <A店连接键> <B店连接键>
|
|
48
|
+
/bailinghub scope
|
|
85
49
|
```
|
|
86
50
|
|
|
87
|
-
|
|
88
|
-
|
|
51
|
+
把占位符换成列表里的固定连接键,不是连接名称。也可以只选一份授权;多份授权必须属于同一个
|
|
52
|
+
中枢、Client App 和 workspace。等待设置成功回显后,再发送业务请求。
|
|
89
53
|
|
|
90
|
-
|
|
54
|
+
**新会话默认是普通聊天,选择业务范围后才会提供业务工具。** 登录成功或切换默认连接不会自动
|
|
55
|
+
开启业务访问。`/bailinghub scope none` 可显式选择普通聊天。第一条用户消息会固定这个范围;
|
|
56
|
+
之后要增减账号,或从普通聊天改成业务会话,都需要新建会话。
|
|
91
57
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
dsh web
|
|
95
|
-
```
|
|
58
|
+
同一套能力不必为每家店重复声明。智能体选择每次调用使用的授权,不会拿到凭据;各项操作仍受
|
|
59
|
+
对应账号权限和审批规则约束。需要审批时,在会话仍运行的情况下,继续的是原调用。
|
|
96
60
|
|
|
97
|
-
|
|
61
|
+
任意一份选中授权撤销、被替换或暂时无法核验时,整个会话的业务访问暂停,不会偷偷改用其他账号。
|
|
62
|
+
临时断网可以在联网后重试原范围;已确认的撤销或身份变化,需要新建会话并选择有效授权。
|
|
98
63
|
|
|
99
|
-
|
|
64
|
+
## 查看沟通过程与操作结果
|
|
65
|
+
|
|
66
|
+
配合 Core 0.6.1 和 SDK 0.4.0,BailingHub 可以把可见的用户消息、助手回复、轮次,以及原业务
|
|
67
|
+
执行记录的关联放在同一份会话记录中。各份授权仍保留自己的业务调用记录,汇总回答不会复制到
|
|
68
|
+
每个账号的记忆中。
|
|
100
69
|
|
|
101
70
|
```text
|
|
102
|
-
/bailinghub
|
|
103
|
-
/bailinghub
|
|
104
|
-
/bailinghub workspaces
|
|
71
|
+
/bailinghub archive status
|
|
72
|
+
/bailinghub archive sync
|
|
105
73
|
```
|
|
106
74
|
|
|
107
|
-
`
|
|
108
|
-
|
|
109
|
-
Token 只进入 SDK 所有的安全存储,不会写入插件配置,也不会由命令输出。
|
|
110
|
-
|
|
111
|
-
常用命令:
|
|
75
|
+
`archive status` 查看沟通记录是否已上传;`archive sync` 补传已保存记录,不重做业务操作。
|
|
76
|
+
它与 `/bailinghub sync` 不同:后者只重试当前运行中会话的待同步执行结尾记录。
|
|
112
77
|
|
|
113
|
-
|
|
|
78
|
+
| 状态 | 含义 |
|
|
114
79
|
| --- | --- |
|
|
115
|
-
|
|
|
116
|
-
|
|
|
117
|
-
| `/bailinghub
|
|
118
|
-
|
|
|
119
|
-
|
|
|
120
|
-
|
|
|
121
|
-
|
|
122
|
-
标准 v1 登录只申请当前配置的 workspace。`use` 只有在当前 Agent Session 明确包含目标
|
|
123
|
-
workspace 时才会成功,不能借此任意切换中枢 route。当前命令始终使用这个插件实例配置的四个
|
|
124
|
-
字段,不接受连接别名选择器。连接另一套 Hub 或 route 时,应使用第二个 DSH Profile/插件实例,
|
|
125
|
-
或修改四字段并重新加载当前 Profile;设置新的 `connectionName` 后再完成浏览器授权。
|
|
80
|
+
| `synced` | 已保存事件已获中枢确认,不代表业务操作成功 |
|
|
81
|
+
| `pending` | 还没传完,恢复连接后可以重试 |
|
|
82
|
+
| `blocked` | 原授权核验阻止上传,可用 `/bailinghub scope` 查看 |
|
|
83
|
+
| `unsupported` | 当前 SDK 或中枢不支持这套归档契约 |
|
|
84
|
+
| `storage_error` | 本地写入失败,部分可见消息可能尚未安全保存 |
|
|
85
|
+
| `recovery_gap` | 对照 DSH 历史发现归档缺失,记录不完整 |
|
|
126
86
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
87
|
+
重开已保存的业务会话时,必须核验全部原授权后才恢复原范围。离线重开后,可以联网并在**同一
|
|
88
|
+
会话**执行 `/bailinghub archive sync` 或 `/bailinghub scope` 重试核验。这恢复的是范围与
|
|
89
|
+
已保存记录的补传,**不恢复进程重启前的业务调用、待审批操作或未完成任务**。未开始的已保存
|
|
90
|
+
草稿需要重新选择;没有有效旧范围快照的已开始会话不能自动采用今天的默认账号。
|
|
130
91
|
|
|
131
|
-
|
|
132
|
-
需要执行受治理业务操作时应使用 Native Tool Mode。
|
|
92
|
+
## 哪些信息会共享和保存
|
|
133
93
|
|
|
134
|
-
|
|
94
|
+
选中账号的业务上下文与可见用户请求会进入同一个本地智能体及模型会话。合并后的沟通归档按完整
|
|
95
|
+
授权集合控制访问,仅有其中一份授权不能读取混合会话。若这些账号的数据需要彼此隔离,应使用
|
|
96
|
+
不同会话。
|
|
135
97
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
- BailingHub 对每次治理调用重新校验身份、scope、审批、幂等与调用状态,业务系统仍执行
|
|
140
|
-
最终权限判断;
|
|
141
|
-
- 适配器会发送 Agent Client 契约所需的可见用户输入、受治理工具参数/结果和可见最终回复,
|
|
142
|
-
但不会上传隐藏思考片段;
|
|
143
|
-
- 本插件只治理它注册的 BailingHub 工具,不会拦截 DSH 其他工具或模型提供方流量。
|
|
98
|
+
采集从本版启用后的业务轮次开始,只包含可见文本,不包含附件、隐藏思考或全部历史会话,也不会
|
|
99
|
+
自动清除用户粘贴在正文里的秘密。检测到历史缺口会明确显示;宿主不提供持久历史时,覆盖度为
|
|
100
|
+
`unverified`,不会声称完整。
|
|
144
101
|
|
|
145
|
-
|
|
146
|
-
|
|
102
|
+
本机私有待上传记录含有**明文任务正文**,已上传的事件也会保留,直到宿主或用户自行清理;目前
|
|
103
|
+
没有自动保留期限。删除本机记录不等于删除中枢记录。授权凭据仍由 SDK 安全存储。启用业务访问前
|
|
104
|
+
请阅读[隐私说明](../PRIVACY.md)与[安全策略](../SECURITY.md)。
|
|
147
105
|
|
|
148
|
-
##
|
|
106
|
+
## 常用管理命令
|
|
149
107
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
108
|
+
| 命令 | 用途 |
|
|
109
|
+
| --- | --- |
|
|
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 |
|
|
119
|
+
| `/bailinghub logout` | 撤销并删除当前 Agent Session |
|
|
153
120
|
|
|
154
|
-
|
|
155
|
-
|
|
121
|
+
连接管理与范围选择都是用户命令,不是模型工具。再次授权同一可信身份会替换旧连接和旧 Agent
|
|
122
|
+
Session;不同身份独立保留。若登录提示需要清理,新连接已经授权成功,应检查提示的旧条目并重试
|
|
123
|
+
删除,不要再次授权。详情见[身份与连接规则](AGENT_CLIENT_CONTRACT.md#browser-identity-and-local-reconciliation)。
|
|
156
124
|
|
|
157
|
-
|
|
158
|
-
export BAILINGHUB_CLIENT_TOKEN='replace-with-a-route-scoped-client-token'
|
|
159
|
-
export BAILINGHUB_ROUTE='order_assistant'
|
|
160
|
-
```
|
|
125
|
+
## 给接入开发者
|
|
161
126
|
|
|
162
|
-
|
|
127
|
+
DSH 负责思考与工具编排;BailingHub Core 负责可信身份、治理、审批、调用状态和审计;SDK 负责
|
|
128
|
+
浏览器授权、安全凭据和 HTTP 映射。本插件只适配 DSH 的会话、提示词、命令、工具和可见事件,
|
|
129
|
+
不直接调用业务 API,也不治理其他 DSH 工具。
|
|
163
130
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
mcp__bailinghub__wait_for_governed_job
|
|
168
|
-
```
|
|
131
|
+
业务系统继续声明原有能力,为每个身份分别授权即可。自定义 DSH 宿主需接入[范围选择与恢复 API](AGENT_CLIENT_CONTRACT.md#host-owned-session-scope-api),
|
|
132
|
+
在首条消息前显示确认;原生斜杠命令已经使用这些 API。参数结构、持久化和恢复细节见
|
|
133
|
+
[Agent Client 契约](AGENT_CLIENT_CONTRACT.md)。
|
|
169
134
|
|
|
170
|
-
|
|
171
|
-
|
|
135
|
+
请使用 Native Tool Mode。DSH Code Mode 无法安全呈现本轮动态工具结构,因此明确降级。
|
|
136
|
+
版本范围见[兼容矩阵](COMPATIBILITY.md)。
|
|
172
137
|
|
|
173
|
-
##
|
|
138
|
+
## 旧版 0.1.1 与反馈
|
|
174
139
|
|
|
175
|
-
0.
|
|
176
|
-
|
|
140
|
+
公开 `dsh-bailinghub@0.1.1` 继续作为独立的静态 MCP 兼容路径:它启动
|
|
141
|
+
`bailinghub-mcp-server@0.1.1`,使用运营者提供的固定路由 Client Token,由 BailingHub 编排。
|
|
142
|
+
0.4.0 不会读取或转换该凭据。使用旧路径时继续固定旧版本,并参考[迁移说明](MIGRATION_VNEXT.md)。
|
|
177
143
|
|
|
178
|
-
问题请提交到 [GitHub Issues](https://github.com/bailinghub/bailinghub-dsh-plugin/issues)
|
|
179
|
-
|
|
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
|
+
}
|