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.
@@ -2,178 +2,144 @@
2
2
 
3
3
  [English](../README.md) | 简体中文
4
4
 
5
- 让本地 DeepSeek Harness 智能体发现并调用由自托管 BailingHub 治理的业务能力。思考、工具
6
- 选择与编排留在本地 DSH;可信身份、运行时上下文、候选能力裁剪、审批、调用恢复与审计仍由
7
- BailingHub 负责。
5
+ 让本地 DeepSeek Harness 智能体操作已经接入 BailingHub 的业务系统:查询记录、修改允许的字段,
6
+ 需要审批时继续走原有规则。BailingHub 会记录用了哪份授权、做了什么,以及业务系统返回的结果。
8
7
 
9
- 这是独立社区集成,不是 DeepSeek 官方开发、认证、合作、背书或推荐的插件。
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
- - **BailingHub Core** 负责 Agent Auth、可信业务身份、运行时上下文、知识库与记忆投影、
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
- Agent Client 不是 BailingHub 现有的“执行器”。执行器接收中枢任务并处理必须靠近某台机器
34
- 完成的工作;Agent Client 则把交互式思考与编排循环放在用户本地 DSH 智能体中。
13
+ 智能体会为每次调用选择对应授权,不需要你反复切换当前连接。具体能查什么、能改什么,仍取决于
14
+ 业务系统开放的能力和各账号权限。本版尚不提供不同系统或不同路由之间的编排。
35
15
 
36
- ## 安装前准备
16
+ 本版也能把可见沟通过程与业务操作关联起来。记录上传失败后,可以在联网或重启后继续补传,
17
+ 不会因此重新执行业务操作。
37
18
 
38
- 部署者和业务接入开发者需要先在自己的 BailingHub 中准备这些公开标识:
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
- - Node.js `22.19.0+` 或 `24+`;
54
- - `pnpm` 与 DeepSeek Harness `0.1.0-rc.7`;
55
- - 已完成上面的 BailingHub 接入准备。
21
+ ## 安装与开始使用
56
22
 
57
- 将精确稳定版本安装到 DSH Web Profile:
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.0-rc.7
61
- dsh plugin --profile web add dsh-bailinghub@0.2.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
- `dsh-bailinghub@0.2.0` 会自动安装精确兼容的 `bailinghub-mcp-server@0.2.0` 依赖。
65
- DSH 用户不应该再自行猜测或单独安装某个 SDK 版本。
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
- | 插件字段 | 环境变量 | 含义 | 是否 Secret |
72
- | --- | --- | --- | --- |
73
- | `hubUrl` | `BAILINGHUB_HUB_URL` | 开发者自己部署的 BailingHub 公共 HTTPS 地址 | 否 |
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
- ```bash
81
- export BAILINGHUB_HUB_URL='https://hub.example.com'
82
- export BAILINGHUB_CLIENT_APP_ID='example-agent-client'
83
- export BAILINGHUB_WORKSPACE='order_assistant'
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
- 也可以通过 DSH 的插件设置界面填写同样四个字段。不要在 Cordis Patch 中增加 Token、授权
88
- 页面地址、业务域名或任何凭据。
51
+ 把占位符换成列表里的固定连接键,不是连接名称。也可以只选一份授权;多份授权必须属于同一个
52
+ 中枢、Client App 和 workspace。等待设置成功回显后,再发送业务请求。
89
53
 
90
- 启动前检查最终合成配置:
54
+ **新会话默认是普通聊天,选择业务范围后才会提供业务工具。** 登录成功或切换默认连接不会自动
55
+ 开启业务访问。`/bailinghub scope none` 可显式选择普通聊天。第一条用户消息会固定这个范围;
56
+ 之后要增减账号,或从普通聊天改成业务会话,都需要新建会话。
91
57
 
92
- ```bash
93
- dsh --profile web --dump-config
94
- dsh web
95
- ```
58
+ 同一套能力不必为每家店重复声明。智能体选择每次调用使用的授权,不会拿到凭据;各项操作仍受
59
+ 对应账号权限和审批规则约束。需要审批时,在会话仍运行的情况下,继续的是原调用。
96
60
 
97
- ## 浏览器授权与使用
61
+ 任意一份选中授权撤销、被替换或暂时无法核验时,整个会话的业务访问暂停,不会偷偷改用其他账号。
62
+ 临时断网可以在联网后重试原范围;已确认的撤销或身份变化,需要新建会话并选择有效授权。
98
63
 
99
- 在 DSH 中依次执行:
64
+ ## 查看沟通过程与操作结果
65
+
66
+ 配合 Core 0.6.1 和 SDK 0.4.0,BailingHub 可以把可见的用户消息、助手回复、轮次,以及原业务
67
+ 执行记录的关联放在同一份会话记录中。各份授权仍保留自己的业务调用记录,汇总回答不会复制到
68
+ 每个账号的记忆中。
100
69
 
101
70
  ```text
102
- /bailinghub login
103
- /bailinghub status
104
- /bailinghub workspaces
71
+ /bailinghub archive status
72
+ /bailinghub archive sync
105
73
  ```
106
74
 
107
- `login` 会打开系统浏览器。业务侧授权页面负责确认当前已登录的业务身份和申请的
108
- workspace,然后返回受 `state` 与 PKCE S256 保护的随机回环回调。Access Token 与 Refresh
109
- Token 只进入 SDK 所有的安全存储,不会写入插件配置,也不会由命令输出。
110
-
111
- 常用命令:
75
+ `archive status` 查看沟通记录是否已上传;`archive sync` 补传已保存记录,不重做业务操作。
76
+ 它与 `/bailinghub sync` 不同:后者只重试当前运行中会话的待同步执行结尾记录。
112
77
 
113
- | 命令 | 用途 |
78
+ | 状态 | 含义 |
114
79
  | --- | --- |
115
- | `/bailinghub login` | 在浏览器授权当前 Hub/client/workspace |
116
- | `/bailinghub status` | 查看当前连接状态,但不输出凭据 |
117
- | `/bailinghub workspaces` | 查看当前业务授权允许使用的 workspace |
118
- | `/bailinghub use <workspace>` | 为新会话切换到另一个已授权 workspace |
119
- | `/bailinghub sync` | 重试同步待处理的可见回复,不重复业务工具调用 |
120
- | `/bailinghub logout` | 撤销并删除当前 Agent Session |
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
- 首次验收时,新建一个 DSH 会话,先做一次只读查询,再做一次允许的修改。确认 BailingHub
128
- 后台能看到同一个会话、run、可见最终回复和工具调用轨迹。需要审批的能力必须在审批后恢复
129
- 原 invocation,不能生成替代业务调用。
87
+ 重开已保存的业务会话时,必须核验全部原授权后才恢复原范围。离线重开后,可以联网并在**同一
88
+ 会话**执行 `/bailinghub archive sync` 或 `/bailinghub scope` 重试核验。这恢复的是范围与
89
+ 已保存记录的补传,**不恢复进程重启前的业务调用、待审批操作或未完成任务**。未开始的已保存
90
+ 草稿需要重新选择;没有有效旧范围快照的已开始会话不能自动采用今天的默认账号。
130
91
 
131
- 本版本在 DSH Code Mode 下会明确降级,因为当前 Code Mode 无法安全呈现本轮动态 Schema。
132
- 需要执行受治理业务操作时应使用 Native Tool Mode。
92
+ ## 哪些信息会共享和保存
133
93
 
134
- ## 安全与隐私边界
94
+ 选中账号的业务上下文与可见用户请求会进入同一个本地智能体及模型会话。合并后的沟通归档按完整
95
+ 授权集合控制访问,仅有其中一份授权不能读取混合会话。若这些账号的数据需要彼此隔离,应使用
96
+ 不同会话。
135
97
 
136
- - 模型不能通过工具参数选择 Hub URL、workspace、身份、凭据、审批结论或能力版本;
137
- - SDK 在 macOS 使用 Keychain;Linux 与其他 POSIX 系统必须显式启用安全文件回退;0.2.0
138
- 暂不支持 Windows Agent Session 凭据存储;
139
- - BailingHub 对每次治理调用重新校验身份、scope、审批、幂等与调用状态,业务系统仍执行
140
- 最终权限判断;
141
- - 适配器会发送 Agent Client 契约所需的可见用户输入、受治理工具参数/结果和可见最终回复,
142
- 但不会上传隐藏思考片段;
143
- - 本插件只治理它注册的 BailingHub 工具,不会拦截 DSH 其他工具或模型提供方流量。
98
+ 采集从本版启用后的业务轮次开始,只包含可见文本,不包含附件、隐藏思考或全部历史会话,也不会
99
+ 自动清除用户粘贴在正文里的秘密。检测到历史缺口会明确显示;宿主不提供持久历史时,覆盖度为
100
+ `unverified`,不会声称完整。
144
101
 
145
- 生产使用前请阅读[安全策略](../SECURITY.md)、[隐私说明](../PRIVACY.md)、
146
- [Agent Client 契约](AGENT_CLIENT_CONTRACT.md)和[兼容范围](COMPATIBILITY.md)。
102
+ 本机私有待上传记录含有**明文任务正文**,已上传的事件也会保留,直到宿主或用户自行清理;目前
103
+ 没有自动保留期限。删除本机记录不等于删除中枢记录。授权凭据仍由 SDK 安全存储。启用业务访问前
104
+ 请阅读[隐私说明](../PRIVACY.md)与[安全策略](../SECURITY.md)。
147
105
 
148
- ## 公开 0.1.x 静态兼容模式
106
+ ## 常用管理命令
149
107
 
150
- 公开 `dsh-bailinghub@0.1.1` 仍是不可变的纯配置 Bundle。它通过 DSH 内置 MCP Client 启动
151
- `bailinghub-mcp-server@0.1.1`,把运营者提供的一个 Client Token 固定绑定到一个 route,
152
- 并由 BailingHub 完成编排。
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
- ```bash
155
- dsh plugin --profile web add dsh-bailinghub@0.1.1
121
+ 连接管理与范围选择都是用户命令,不是模型工具。再次授权同一可信身份会替换旧连接和旧 Agent
122
+ Session;不同身份独立保留。若登录提示需要清理,新连接已经授权成功,应检查提示的旧条目并重试
123
+ 删除,不要再次授权。详情见[身份与连接规则](AGENT_CLIENT_CONTRACT.md#browser-identity-and-local-reconciliation)。
156
124
 
157
- export BAILINGHUB_BASE_URL='https://hub.example.com'
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
- ```text
165
- mcp__bailinghub__submit_governed_job
166
- mcp__bailinghub__get_governed_job
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
- 0.2 Agent Client 不会自动读取或迁移 0.1 Client Token。测试升级或回滚时必须显式固定版本,
171
- 并遵循 [0.1 到 0.2 的迁移边界](MIGRATION_VNEXT.md)。
135
+ 请使用 Native Tool Mode。DSH Code Mode 无法安全呈现本轮动态工具结构,因此明确降级。
136
+ 版本范围见[兼容矩阵](COMPATIBILITY.md)。
172
137
 
173
- ## 兼容范围与反馈
138
+ ## 旧版 0.1.1 与反馈
174
139
 
175
- 0.2.0 只对 [COMPATIBILITY.md](COMPATIBILITY.md) 中列出的版本完成了验证。DeepSeek
176
- 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)。
177
143
 
178
- 问题请提交到 [GitHub Issues](https://github.com/bailinghub/bailinghub-dsh-plugin/issues)。
179
- 请勿附带 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
+ }