@shgroup/dsh-serenity-hooks 1.69.0 → 1.69.2

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/lib/acp-auth.d.ts DELETED
@@ -1,273 +0,0 @@
1
- /**
2
- * acp-auth.ts — ACP 面的**认证 + 身份**层(v1.59.0,owner 2026-10-17 令)
3
- *
4
- * ## 它解决什么
5
- *
6
- * owner 令(逐字):「将微信桥升级为 IM 桥,每个 CCC 可以选择微信实现或 ACP 实现,
7
- * 微信实现保留不变向前兼容,**ACP 实现就可以暴露端口并绑定 skiff,并且要随即生成认证用的 token**」。
8
- *
9
- * 面 E(`acp-face.ts`)原本**无认证、无 CCC 归属、无角色** —— 本模块补上这三件:
10
- * ① **token**:每 CCC 一枚,落**该 CCC 的 localstore**(credential scope)。
11
- * ② **CCC 归属**:连接建立时由 token 反查 ⇒ **服务端权威**(不拿客户端自述的 `cwd` 当身份)。
12
- * ③ **角色**:该 CCC 的 `im.acp.role` ⇒ 供装配层把 ACP 会话套上 skiff 角色。
13
- *
14
- * ## 🔴 架构决策:一面多 token,不是每 CCC 一个端口
15
- *
16
- * | | 甲:每 CCC 一个端口 | **乙:一面多 token** ← 选它 |
17
- * |---|---|---|
18
- * | 端口 | 随 CCC 数增长 | **固定 3200** |
19
- * | 身份来源 | 端口号 | **token(一一对应 CCC)** |
20
- *
21
- * **判据**:`token → CCC` 是一一对应,而 `cwd` 是**客户端可填的**。
22
- * ⇒ 用 token 定 CCC 比用 cwd 定 CCC 强:前者**服务端权威**,后者**客户端自述**。
23
- * ⇒ 连接一建立就知道"这是哪个 CCC",**该连接的所有会话都归它**,角色绑定无需猜。
24
- * ⚠️ `cwd` 仍要校验(见 `cwdInsideRoot`):防一枚 token 越权到别的 CCC。
25
- *
26
- * ## 🔴 为什么认证放在 WS upgrade 层,而不在 ACP 协议里
27
- *
28
- * `dsh-acp` 的 `authenticate()` **恒成功**且 `authMethods: []`(读其 `lib/index.js` 逐字),
29
- * 且它**不导出**可替换的认证缝 ⇒ 改协议层要重写它的接线(P0′ 已实测此路不通)。
30
- * 而**我们自己持有 HTTP server**(自持面)⇒ 在 `upgrade` **升级之前**校验,
31
- * 失败即 `401 + destroy` ⇒ **连接根本进不来**。
32
- *
33
- * ## 边界
34
- *
35
- * 本模块**零 DSH 依赖**(只 `node:crypto` / `node:fs` / `node:path` 与本仓纯逻辑)⇒ 可独立单测。
36
- */
37
- import { type ImSettings, type ImAcpSettings, type ImAcpAccount } from './ccc.js';
38
- /** 该账户的凭据键(`IM_ACP_TOKEN_<ACCOUNT_ID>`);`accountId` 必填 */
39
- export declare function acpTokenKeyFor(accountId: string): string;
40
- /**
41
- * 该**账户**的 ACP token(**读**;不存在返回 null)。
42
- *
43
- * 🔴 **不自动生成** —— 生成是**写操作**,只在显式要求时做(`ensureAcpToken`)。
44
- * 读路径永远无副作用:否则每次判定都会改 localstore。
45
- *
46
- * @param accountId 账户键(必填 —— 凭据按账户分离,没有"CCC 默认 token"这回事)
47
- */
48
- export declare function readAcpToken(root: string, accountId: string): string | null;
49
- /**
50
- * 生成(或返回既有)ACP token 并落该 CCC 的 localstore。
51
- *
52
- * ⚠️ **幂等**:已有 token 时**原样返回**(不轮换)—— 轮换会让在用的客户端立刻断线,
53
- * 必须是一次显式动作(`resetAcpToken`)。
54
- *
55
- * @param accountId 多账户时指明哪个账户;省略 = 单账户简写键
56
- */
57
- export declare function ensureAcpToken(root: string, accountId: string): string;
58
- /**
59
- * 轮换 token(显式动作;返回新值)。旧 token 立即失效。
60
- * @param accountId 账户键
61
- */
62
- export declare function resetAcpToken(root: string, accountId: string): string;
63
- /**
64
- * 常时比较(防时序侧信道)。
65
- *
66
- * 🔴 两个长度不等时 `timingSafeEqual` **会抛** ⇒ 先比长度并立刻返回 false。
67
- * ⚠️ 那一步本身**泄漏长度**(可接受:token 长度固定且公开)。
68
- */
69
- export declare function safeEqual(a: string, b: string): boolean;
70
- /** token 指纹(诊断面用;不可逆) */
71
- export declare function tokenFingerprint(token: string): string;
72
- /**
73
- * 该账户的**主会话**键(`IM_ACP_SESSION_<ACCOUNT_ID>`)。
74
- *
75
- * 🔴 为什么放在 **credential** 节而不是 config 节:它跟 token 一样是**每账户一值的键控数据**
76
- * (键由 `accountId` 派生、随账户生命周期存亡)。放 config 节要另造一层 `section.key`
77
- * 结构,而这一层的唯一读者就是本模块 ⇒ 多一层结构 = 多一处会漂的形状。
78
- *
79
- * ⚠️ 它**不是凭据**(会话 id 不是秘密)—— 共享 credential 节只是为了复用它既有的
80
- * 「按 accountId 派生键 + 删账户时一并清理」这套语义,不引入第二套键规则。
81
- */
82
- export declare function acpSessionKeyFor(accountId: string): string;
83
- /**
84
- * 读该账户的主会话 id(不存在 ⇒ `null`)。
85
- *
86
- * 🔴 **读路径无副作用**(与 `readAcpToken` 同纪律):不存在的含义是「这个账户还没有主会话」,
87
- * 而那时的正确行为是**让 App 去建**(`session/list` 回空数组),不是替它编一个。
88
- */
89
- export declare function readAcpMainSession(root: string, accountId: string): string | null;
90
- /**
91
- * 记下该账户的主会话 id(**写操作**;由 `session/new` 的出站响应触发)。
92
- *
93
- * 🔴 **后写覆盖先写**(last-wins):owner 要的是"一条长期对话",而"再建一条"是用户
94
- * 在 App 里显式点「新对话」的结果 ⇒ 那时他要的就是**新的那条**。
95
- * ⛔ **被否的两种做法**:① 已有主会话就拒建(用户会看到"点了没反应");
96
- * ② 把 `session/new` 改写成 `session/resume`(响应形状不同,要让客户端认一条
97
- * **不存在于协议里**的响应 —— 那是拿协议兼容性换一个我们本来就有的语义)。
98
- */
99
- export declare function writeAcpMainSession(root: string, accountId: string, sessionId: string): void;
100
- /**
101
- * 从一次 HTTP upgrade 请求里取出候选 token。
102
- *
103
- * 两种携带方式都认(客户端差异;Agmente 等用具名头,脚本常用查询串):
104
- * ① `Authorization: Bearer <t>`
105
- * ② `?token=<t>`(查询串)
106
- *
107
- * @returns 取到的 token;两者都无 → null
108
- */
109
- export declare function extractToken(headers: Record<string, unknown>, url: string | undefined): string | null;
110
- /**
111
- * 一个"可接受 token 的账户"——**认证闸真正比对的单元**。
112
- *
113
- * 🔴 v1.59.1 的关键抽象变更:原先是"CCC ↔ 一枚 token",现在是
114
- * **"(CCC, 账户)↔ 一枚 token ↔ 一个角色"**。同一个 CCC 可以有多个账户
115
- * (一台手机、一个脚本各一枚),彼此**互不影响**:撤一个不动另一个。
116
- */
117
- export interface AcpAccount {
118
- /** 归属 CCC 根 */
119
- root: string;
120
- /** 账户键(单账户简写 ⇒ `''`) */
121
- accountId: string;
122
- /** 凭据键(localstore) */
123
- tokenKey: string;
124
- /** 该账户的 token(**读时现取**;不缓存) */
125
- token: string;
126
- /** 该账户绑定的 skiff 角色名 */
127
- role: string;
128
- }
129
- /**
130
- * 把该 CCC 的配置**展开成账户列表**(读时现算,**无快照**)。
131
- *
132
- * 🔴 **`im.acp.accounts` 是必填项** —— 没有它就没有账户,**一个都认证不了**。
133
- * 刻意**不提供**"单账户简写"退回路径(v1.59.1):那会多出一条永不生效的分支,
134
- * 而它的唯一作用是把"配置写错了"伪装成"用了简写"。
135
- *
136
- * 判据(三条都由调用方以 fail-closed 消费):
137
- * - `enabled !== false`(缺省 true)
138
- * - `role` 非空(空 ⇒ 该账户**不出现**在列表里 ⇒ 必然登录失败)
139
- * - token 存在(不存在 ⇒ 同样不出现)
140
- *
141
- * ⚠️ 本函数**纯读,绝不生成 token** —— 生成是写操作,归 `ensureAcpChannelTokens`。
142
- * 这样 upgrade 路径可以随时现算而不冒"每次连接都写 localstore"的风险。
143
- */
144
- export declare function expandAcpAccounts(root: string, paths?: string[]): AcpAccount[];
145
- /**
146
- * **让配置里声明的账户都拥有 token**(写操作;幂等;由装配层与配置变更时调用)。
147
- *
148
- * 🔴 owner 令「**随即生成**认证用的 token」的落地点。与 v1.59.0 的差别:
149
- * 那时只在**启动**时跑一次 ⇒ 加一台设备要重启;现在**任何一次判定前都可以跑**,
150
- * 且撤账户后不会被"复活"(只对**当前声明里**的账户生成)。
151
- *
152
- * @returns 本次**新生成**的账户键列表(已存在的原样返回,不轮换)
153
- */
154
- export declare function ensureAcpChannelTokens(root: string, paths?: string[]): string[];
155
- /**
156
- * 校验一枚候选 token,返回它归属的**账户**(未命中返回 null)。
157
- *
158
- * @param candidate 客户端出示的 token
159
- * @param roots 候选 CCC 根集合(由调用方枚举——本模块不认识"CCC 在哪")
160
- */
161
- export declare function resolveTokenAccount(candidate: string, roots: readonly string[], paths?: string[]): AcpAccount | null;
162
- /**
163
- * 枚举**有可用 ACP 账户**的 CCC(供准入名单用)。
164
- *
165
- * 判据:`im.channel === 'acp'` ∧ `im.acp.enabled === true` ∧ **至少一个账户有 token 且配了角色**。
166
- * ⚠️ 缺一即不算(fail-closed:宁可连不上,不可放行一个半配置的通道)。
167
- */
168
- export declare function listAcpRoots(roots: readonly string[], paths?: string[]): string[];
169
- /** 读该 CCC 的 `im` 段(未配置 ⇒ 归一缺省;永不 undefined) */
170
- export declare function readImSettings(root: string, paths?: string[]): Required<Pick<ImSettings, 'channel'>> & {
171
- acp: ImAcpSettings;
172
- };
173
- /**
174
- * 该 CCC 是否启用 ACP 入站通道。
175
- *
176
- * 🔴 **两个条件都真才算**:`im.channel === 'acp'` **且** `im.acp.enabled === true`。
177
- * 为什么双键:`channel` 表达"选了哪条路",`enabled` 表达"现在开不开" ⇒
178
- * 让 owner 能**选好 ACP 但暂时关着**(不用删配置),与既有各面的 `enabled` 范式一致。
179
- */
180
- export declare function acpChannelEnabled(root: string, paths?: string[]): boolean;
181
- /**
182
- * 客户端 `session/new` 传来的 `cwd` 是否落在该 token 归属的 CCC 内。
183
- *
184
- * 🔴 **为什么必须校验**:token 定的是"哪个 CCC",而 `cwd` 由**客户端**指定。
185
- * 不校验 ⇒ 一枚 CCC A 的 token 可以把会话开到 CCC B(越权读到另一个 CCC 的上下文)。
186
- * 这与 CCC 自身的路径围墙(P3)是同一条纪律,只是发生在**接入**这一层。
187
- *
188
- * 判据用 `relative()` 而非 `startsWith`(后者会把 `/a/bc` 误判为在 `/a/b` 内)。
189
- */
190
- export declare function cwdInsideRoot(cwd: string, root: string): boolean;
191
- /**
192
- * 一次性解析:候选 token → 归属 **账户**(含 CCC 根、账户键、角色)。
193
- *
194
- * @returns 账户;token 无效 / 通道未启用 / 无角色 → null(**调用方一律拒绝连接**)
195
- */
196
- export declare function authorizeAcpConnection(candidate: string | null, roots: readonly string[], paths?: string[]): AcpAccount | null;
197
- /** 单个账户的对外可见形态(**绝不含 token 明文**) */
198
- export interface AcpAccountStatus {
199
- accountId: string;
200
- name: string | null;
201
- role: string | null;
202
- enabled: boolean;
203
- hasToken: boolean;
204
- tokenFingerprint: string | null;
205
- /**
206
- * 该账户的**主会话** id(owner「每账户固定一条长期对话」的那一条;`null` = 还没建)。
207
- *
208
- * 🔴 会话 id **不是秘密**,故这里给明文(与 token 不同)—— 它的用途是**可诊断**:
209
- * 面板/排障时要能回答"这台手机此刻钉的是哪条对话",否则出问题时只能靠猜。
210
- */
211
- mainSession: string | null;
212
- /** 该账户此刻**能不能被认证闸接受**(三条判据全真) */
213
- usable: boolean;
214
- /** 不能用的原因(能用 ⇒ null)—— 直接指向该改哪个字段 */
215
- blockedBy: string | null;
216
- }
217
- /** 该 CCC 的 ACP 通道对外**可见形态**(诊断面用;**绝不含 token 明文**) */
218
- export declare function acpChannelStatus(root: string, paths?: string[]): {
219
- channel: 'weixin' | 'acp';
220
- enabled: boolean;
221
- accounts: AcpAccountStatus[];
222
- };
223
- /** 只读:该 CCC 的 localstore 是否存在(避免对不存在的 CCC 白跑 token 反查) */
224
- export declare function hasLocalstore(root: string): boolean;
225
- /** 只读:localstore 所在目录(诊断路径展示;不创建) */
226
- export declare function localstoreDir(root: string): string;
227
- /**
228
- * 该 CCC 是否"看起来像"一个可用 ACP 宿主(只读低成本预筛)。
229
- * ⚠️ 与 `acpChannelEnabled` 分开:本函数**不读配置**(只判文件存在),用于快速排除。
230
- */
231
- export declare function looksLikeCcc(root: string): boolean;
232
- /** 排重后的 token 归属表(诊断用;同一 token 出现在两个账户 ⇒ 响亮暴露,不静默取第一个) */
233
- export declare function tokenOwnerAudit(roots: readonly string[]): {
234
- owners: Array<{
235
- root: string;
236
- accountId: string;
237
- fingerprint: string;
238
- }>;
239
- duplicates: Array<{
240
- fingerprint: string;
241
- owners: string[];
242
- }>;
243
- };
244
- /** 测试辅助:读 store 里该账户键的原始值(绕开 getEntry 的归一) */
245
- export declare function __rawTokenForTest(root: string, accountId: string): string | undefined;
246
- /**
247
- * 写回 `im.acp.accounts`(保留文件里其余所有字段)。
248
- *
249
- * 🔴 **只碰这一个键**:`im.channel` / `im.acp.enabled` 归面板总开关与人工配置,
250
- * 本函数不越权改它们(否则"加一台设备"会顺手把通道开关也改了)。
251
- */
252
- export declare function saveAcpAccounts(root: string, accounts: ImAcpAccount[], paths?: string[]): ImAcpAccount[];
253
- /**
254
- * 规范化一条账户声明(面板传入的任意形状 → 合法声明;非法 ⇒ null)。
255
- *
256
- * 🔴 返回类型里 `role` 是**必填**(不是 `ImAcpAccount` 的可选形态)——
257
- * 本函数的契约就是"要么给一个角色齐备的账户,要么给 null"。
258
- * 这样调用方**不必再判一次** `role` 有没有(那正是会漂的第二处判据)。
259
- */
260
- export declare function normalizeAcpAccount(input: unknown): (ImAcpAccount & {
261
- accountId: string;
262
- role: string;
263
- }) | null;
264
- /** 增/改一条账户(按 accountId 定位;写入后返回全表) */
265
- export declare function upsertAcpAccount(root: string, account: ImAcpAccount, paths?: string[]): ImAcpAccount[];
266
- /**
267
- * 删一条账户(**同时清掉它的 token + 主会话**,避免留死键)。
268
- *
269
- * 🔴 主会话键**必须一起清**:否则日后用同一个 `accountId` 重建账户时,它会**静默继承**
270
- * 那条旧会话 —— 而那条会话的 cwd/角色都是上一代账户的。这个失效**没有任何信号**
271
- * (列表上它看着就是一条正常会话)。
272
- */
273
- export declare function removeAcpAccount(root: string, accountId: string, paths?: string[]): ImAcpAccount[];
package/lib/acp-face.d.ts DELETED
@@ -1,244 +0,0 @@
1
- /**
2
- * acp-face.ts — **面 E:官方 ACP 面**(2026-10-16,owner 令「跑在 dsh web 进程内」)
3
- *
4
- * ## 它是什么
5
- *
6
- * 把**官方** `@deepseek-ai/dsh-acp` 挂进**本进程**(宿主 `dsh web` 那条 Cordis 树),
7
- * 让标准 ACP 客户端(Agmente 等)经 **WebSocket** 登入本 CCC。
8
- *
9
- * ## 🔴 为什么是"自持面"而不是借宿主 `webServer`(本模块的核心设计决策)
10
- *
11
- * 两条路,**选乙**:
12
- * · 甲:`ctx.webServer.registerUpgrade({path})` —— 探针(P0′)走的那条;
13
- * · 乙:**本插件自己起一个 listener**(面宿主统一范式),在它上面挂 `'upgrade'` ← **本模块**
14
- *
15
- * 选乙的理由(按强度):
16
- * ① 🔴 **不需要往 `HostWebServer` 本地接口补 `registerUpgrade`** —— 面宿主是**我们自己的**,
17
- * `FaceHandle.server` 就是原始 `node:http` server(`face-host.ts:81` 注释逐字指出
18
- * *"面若需挂自己的 server 级事件(gateway 的 `'upgrade'`)在此挂"*)⇒ **少动一处宿主缝**;
19
- * ② ✅ **先例在生产里跑着**:`gateway.ts:454` 的 `handle.server.on('upgrade', …)` 就是这条路;
20
- * ③ ✅ **与三个既有面同范式**(A 宿主监听/B C D 插件监听)⇒ **零新概念**;
21
- * ④ ✅ **端口独立**(不动宿主 `webServer.host/port`)—— 而 owner Q3 恰恰说
22
- * 「**先 `0.0.0.0` 监听即可**」,自持面**可直接绑 `0.0.0.0`**(面自己的 `host`)⇒ 更贴那条裁决;
23
- * ⑤ ✅ **拆卸免费**(`stopAllFaces` 是"插件卸载/HMR 拆卸自起 listener 的唯一入口")。
24
- *
25
- * ## 🔴 为什么不走 `AcpServer` 而直接调 `dsh-acp` 的 `apply`(P0′ 实测结论)
26
- *
27
- * `AcpServer` 的入参是 `{ agent: AgentConnector }`,而 **`dsh-acp` 不导出它的 connector**
28
- * (它在 `apply` 内部自建)⇒ 走 `AcpServer` 就得**重写 `dsh-acp` 全部 onRequest 接线**。
29
- * ⇒ 最短路 = 自己把 socket 适配成 ACP `Stream`,交给 `apply` 的 `config.stream`
30
- * (其 JSDoc 逐字:*"Runtime-only transport override; production uses stdio"*)。
31
- *
32
- * ## ⚠️ 三个**已实测**的坑(照做,别重新发明)
33
- *
34
- * ① **直接调 `apply` 时,`inject` 要替 `dsh-acp` 声明它需要的全部服务** ——
35
- * 实测逐字失败过:`cannot get property "sessionPersistence" without inject`
36
- * (抛自 `dsh-acp/lib/index.js:1074`)。故本模块 `inject` 含
37
- * `agents` / `llm` / `sessionPersistence` / `sessions`。
38
- * ② **`dsh-acp` 的 `Config` schema 里没有 `stream` 字段** ⇒ 只能**程序化传入**,不可经 YAML。
39
- * ③ **`wsToAcpStream` 的元素是"已解析的 JSON-RPC 消息"**(不是字节)——
40
- * 官方 `ndJsonStream` 干的就是"字节 ↔ 消息"的分帧,我们照它的语义自己分帧(按行)。
41
- *
42
- * ## 🔴 安全边界(不许含混)
43
- *
44
- * **ACP 协议本身不做认证**(`dsh-acp` 的 `authenticate` 恒成功),而本面按 owner 裁决绑
45
- * **`0.0.0.0`** ⇒ **必须有外部接入层兜底**(现为 Cloudflare Access)。
46
- * ⛔ 「隧道不是 ACC 该关心的事」(Q3 裁决)**成立**,但「因此不需要认证层」**不成立** ——
47
- * 故本模块的启动日志与 `status()` **都显式带这条前提**。
48
- */
49
- import type { Context } from 'cordis';
50
- import { type FaceHandle } from './face-host.js';
51
- /**
52
- * 刷新"本机有哪些 CCC"(async,枚举宿主三来源)。
53
- *
54
- * 🔴 **失败必须响亮**:v1.59.0 这里是 `void refresh(...)` 且没有 catch ——
55
- * 枚举失败会让名单清空 ⇒ **所有连接一律 401**,而现场只有一行 stdout 看不到的日志。
56
- * 这正是本轨记过的「调用没报错 ≠ 已验证」。
57
- *
58
- * @returns 本次枚举到的根数(0 = 一个都没发现,日志会说为什么)
59
- */
60
- export declare function refreshAcpRoots(ctx: Context): Promise<number>;
61
- /** 当前准入名单(诊断用只读快照;**现算**,不是缓存值) */
62
- export declare function acpRootsSnapshot(): readonly string[];
63
- /**
64
- * 测试注入钩子(**生产零调用**):直接置"本机有哪些 CCC 候选根"。
65
- *
66
- * ⚠️ 为什么需要:`refreshAcpRoots()` 依赖 `listCccs(ctx)`(要真 CCC 环境)。
67
- * 而认证闸本身是**纯逻辑**,测试要能单独驱动它 —— 否则"闸真的拦得住吗"这件事
68
- * 只能靠集成环境验,而集成环境恰恰是最不容易跑的那种。
69
- *
70
- * 🔴 v1.59.1:**置的是候选根,不是"放行名单"** —— 每个根会不会被放行,
71
- * 仍由它自己的 `serenity.json` + localstore **现算**决定(测试照样要走真配置)。
72
- */
73
- export declare function __setAcpRootsForTest(roots: readonly string[] | null): void;
74
- export declare function acpFaceActive(): boolean;
75
- export declare function acpFacePort(): number | null;
76
- /**
77
- * 面是否在监听(意图读取)。
78
- * ⚠️ 与 `startFace` 的关系同其余各面:**启动本身不读它**(`startFace` 是机械入口),
79
- * 由装配层用它决定"起还是停"。
80
- */
81
- export declare function acpFaceEnabled(): boolean;
82
- /**
83
- * 把客户端给的 `cwd` 归一化成宿主要求的**绝对路径**(v1.59.1)。
84
- *
85
- * ## 为什么需要
86
- * 宿主 `dsh-acp` 在每次 `session/new` 都有这一行(`lib/index.js` 逐字):
87
- * ```js
88
- * if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
89
- * ```
90
- * 而**连接归属哪个 CCC 已由 token 决定**(服务端权威)⇒ 让用户再手打一遍绝对路径
91
- * 是**同一个信息说两遍**,且那第二遍正是最容易打错的一遍。
92
- *
93
- * ## 规则(唯一一条)
94
- * **非绝对路径 ⇒ 一律解释为「本连接归属 CCC 的根」**。
95
- *
96
- * | 客户端填 | 送宿主 |
97
- * |---|---|
98
- * | `serenity` / `a` / `.` / 空 | `/…/home-serenity`(该 token 的 CCC 根) |
99
- * | `/…/home-serenity`(已绝对且在根内) | 原样 |
100
- * | `/…/home-serenity/sub` | 原样(仍在根内) |
101
- * | `/etc`(绝对但在根外) | **原样透传,交给 `cwdInsideRoot` 拒** |
102
- *
103
- * 🔴 **最后一行为什么不在这里直接拒**:越权判据必须**只有一处**(`cwdInsideRoot`,
104
- * 它也管着角色认领),在这里再判一次就是**两份会漂的实现**。归一化只负责"把非绝对变成绝对"。
105
- *
106
- * @param raw 客户端原样给的 `cwd`(可能是 `undefined`)
107
- * @param cccRoot 本连接归属的 CCC 根
108
- */
109
- export declare function normalizeClientCwd(raw: unknown, cccRoot: string): string;
110
- /**
111
- * 一条连接的**会话钉住**状态(owner「每账户固定一条」的执行单元)。
112
- *
113
- * 生命周期 = 一条 WS 连接。⚠️ **跨连接的真相在 localstore**(`IM_ACP_SESSION_<ACCOUNT_ID>`);
114
- * 本结构只是"这条连接上,请求 id 对应哪个方法"的记账 + 那两个方法的处理口径。
115
- */
116
- interface AcpSessionPin {
117
- /** 本连接归属的 CCC 根(合成 `SessionInfo.cwd` 时用 —— 那也是我们当初钉的值) */
118
- root: string;
119
- /** 本连接归属的账户(主会话键由它派生) */
120
- accountId: string;
121
- /** 在飞的请求:`id` → 方法名(**出站改写唯一的方法来源**) */
122
- pending: Map<string, string>;
123
- }
124
- /** 建一条连接的钉住状态 */
125
- export declare function createAcpSessionPin(root: string, accountId: string): AcpSessionPin;
126
- /**
127
- * 入站记账:把"这条请求的方法"记住,供出站按 `id` 反查。
128
- *
129
- * 判据:**既要有 `id`,又要有 `method`** —— 只满足一个的是响应(`id` 无 `method`)
130
- * 或通知(`method` 无 `id`)⇒ 与我们无关,不记。
131
- */
132
- export declare function recordAcpPending(pin: AcpSessionPin, msg: unknown): void;
133
- /**
134
- * 出站改写(本功能的落点)。返回**要发给客户端的那条消息**。
135
- *
136
- * | 原响应 | 改写后 |
137
- * |---|---|
138
- * | `session/list` | `{sessions:[该账户那一条]}`;**没有主会话 ⇒ `{sessions:[]}`** |
139
- * | `session/new` | 原样,**同时**把 `result.sessionId` 记为该账户的主会话 |
140
- * | 其余(含所有 error 响应、所有通知) | 原样 |
141
- *
142
- * 🔴 **没有主会话时回空数组,绝不回退成全量列表** —— 退回去正是 owner 说
143
- * 「23 个对我没什么用」的那个东西。空数组会让 App 引导用户新建,而新建即建立主会话。
144
- *
145
- * 🔴 `nextCursor` **一律丢掉**:收窄到一条之后,分页游标失去意义;
146
- * 留着它会让客户端继续翻页(而后续页仍会被收窄成同一条 ⇒ 前端可能出现重复项)。
147
- */
148
- export declare function rewriteAcpResponse(pin: AcpSessionPin, msg: unknown): unknown;
149
- /** 面的运行统计(`dashboard` / 诊断面用;**只读快照**) */
150
- export interface AcpFaceStatus {
151
- active: boolean;
152
- port: number | null;
153
- host: string;
154
- enabled: boolean;
155
- connections: number;
156
- applied: number;
157
- /** 🔴 被认证闸拒掉的连接数(v1.59.0):它 > 0 说明**有人在敲门或 token 配错了** */
158
- rejected: number;
159
- /** 当前准入的 CCC 数(启用了 ACP 通道 + 已生成 token) */
160
- authorizedCccs: number;
161
- errors: Array<{
162
- at: number;
163
- applied: boolean;
164
- error?: string;
165
- }>;
166
- /** 🔴 认证状态一句话(v1.59.0 取代原"本面无认证") */
167
- authNote: string;
168
- }
169
- export declare function acpFaceStatus(): AcpFaceStatus;
170
- /**
171
- * 启动面 E(幂等;重复调用只起一次)。
172
- *
173
- * 🔴 **失败语义**:本面**默认关**且是**可选面** ⇒ 起不来**不得**影响插件其余部分
174
- * (调用方负责 catch;本函数把失败原样抛出,由装配层记日志)。
175
- */
176
- export declare function startAcpFace(ctx: Context): Promise<FaceHandle>;
177
- /**
178
- * 停面 E(幂等)。
179
- *
180
- * ⚠️ **与其余各面同款注意**:`stopFace` **不用** `closeAllConnections()`(那会掐断在飞的请求);
181
- * WS 连接由 `wss.close()` 与 socket 自身生命周期收束。
182
- */
183
- export declare function stopAcpFace(): boolean;
184
- /**
185
- * 解析 ACP 会话该用的 `{provider, model}`。
186
- *
187
- * ## 🔴 为什么必须有这个函数(2026-10-16 容器实测抓出的**真缺陷**)
188
- *
189
- * 早先我们是**裸传** `provider: readAcpProvider(), model: readAcpModel()` —— 而它们读的是
190
- * **环境变量**,默认**未设** ⇒ 传下去就是 `undefined`。后果**不是"用默认模型"**:
191
- * 上游 `dsh-acp` 的 `agentOptions()`(`lib/index.js:1356-1361`)**只在字段非 undefined 时才带上**:
192
- * ```js
193
- * ...config.provider !== void 0 ? { provider: config.provider } : {},
194
- * ...config.model !== void 0 ? { model: config.model } : {}
195
- * ```
196
- * ⇒ **我们传 undefined,agent 就真的没有模型** ⇒ 第一条 `session/prompt` 必失败,逐字报:
197
- * `Internal error: turn failed: prompt variable "{{model}}" has no value for this assembly
198
- * (section "deployment:persona-prefix")`。
199
- *
200
- * ⚠️ **网页端不中招**(它经 `agent-default-model` 层拿到默认值)⇒ **只有 ACP 这条入口坏** ——
201
- * 也就是说:**手机上第一条消息必然失败,而本机怎么试都正常**。
202
- * 🔴 这个缺陷 **typecheck / 2812 条单测 / deploy 预检 全绿**(它是运行时合成形态,
203
- * 静态面结构性地看不见)⇒ **只有"容器 + 真模型"那一跑能抓到它**。
204
- *
205
- * ## 修法 = **读宿主自己的默认模型服务**(不另造真相源)
206
- *
207
- * 宿主暴露服务 `ctx.agentDefaultModel.currentSelection()`,其契约逐字写着
208
- * *"Default model selection for Agents created **without an explicit model**"*
209
- * (`dsh-agent-default-model/lib/types/index.d.ts`)—— 这正是"没显式指定时该用什么"的**权威出处**。
210
- * ⇒ 我们只是**把它转交给 ACP**,不引入第二套缺省。
211
- *
212
- * 优先级(**显式 > 宿主默认**):`SERENITY_ACP_PROVIDER/MODEL` 环境变量(调试用)> 宿主默认。
213
- * ⚠️ **一处如实标注的弱化**:这里**不打日志**(每次连接都解析一次,打日志会刷屏);
214
- * 若宿主服务缺失,**退回"两个都 undefined"=旧行为**(即那条已知会失败的形态)——
215
- * 而不是静默编一个模型。⇒ 副作用是"没有默认模型时仍会失败",但那**与宿主自身缺配置时的行为一致**。
216
- *
217
- * 🔵 **公开导出**(而非模块私有):它是一个**纯函数 + 有独立契约**的单元(本仓同类先例 =
218
- * `host/access.ts` 的一组读取器),且**那个真缺陷正是在它这一层**(裸传 undefined)——
219
- * 回归钉必须能**直接钉住它**,而不是隔着 WS 握手去间接观察(那样测不出"取到了什么")。
220
- */
221
- export declare function resolveAcpSelection(ctx: Context): {
222
- provider?: string;
223
- model?: string;
224
- };
225
- /**
226
- * 装配(`index.ts` 的 `apply` 调用):按**面板/部署键**启停面 E,配置变化热同步。
227
- *
228
- * 🔴 **W3 接线**(2026-10-16):本轮之前 `startAcpFace` 虽已实现却**没有任何调用点**
229
- * —— 即"面写好了但从不起"。本函数补上它,形式与两个兄弟面一致
230
- * (`registerWeixinSendApi` / `registerDiagramTool`):
231
- *
232
- * ① **反应式**:面板拨开关**只改配置、不重跑 `apply`**(v1.51.1 实测过的那条铁律)
233
- * ⇒ 必须监听事件才会"拨了就有反应";
234
- * ② 监听**宿主的真事件** `settings/document-updated`(按 ns 过滤),
235
- * ⛔ **不监听** `serenity/settings-changed` —— 那个事件**全仓没有任何 `emit` 点**
236
- * (=死通道;见 `registerDiagramTool` 的注释),照它接线等于接了个永远不响的铃;
237
- * ③ 再挂 `agent/created`/`session/created` 兜底(宿主事件面若换名,至少还有这条能自愈);
238
- * ④ 启动时同步一次(`apply` 时按当前配置定初始态)。
239
- *
240
- * ⚠️ **失败语义**:本面**默认关**且**依赖可选**(`dsh-acp` 是 peer)⇒ 起不来**只记日志**,
241
- * 绝不抛出(抛出会让整个插件加载失败,而它只是一个可选面)。
242
- */
243
- export declare function registerAcpFace(ctx: Context): void;
244
- export {};