@shgroup/dsh-serenity-hooks 1.58.2 → 1.59.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/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-serenity-hooks",
3
- "version": "1.58.2",
3
+ "version": "1.59.0",
4
4
  "main": "lib/index.js",
5
5
  "description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——14 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/ontology/serenity-skill-creator/cheap-worker/localstore/container_admin;另三个 im-bridge/acc-diag/diagram 按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 轨迹日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.7-rc.1 与 0.2.0-rc.1(双档,deepseek-ai/deepseek-harness)。",
6
6
  "engines": {
@@ -0,0 +1,156 @@
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 } from './ccc.js';
38
+ /** localstore 凭据键(credential scope ⇒ UPPER_SNAKE) */
39
+ export declare const ACP_TOKEN_KEY = "IM_ACP_TOKEN";
40
+ /**
41
+ * 该 CCC 的 ACP token(**读**;不存在返回 null)。
42
+ *
43
+ * 🔴 **不自动生成** —— 生成是**写操作**,只在 owner/面板显式要求时做
44
+ * (`ensureAcpToken`)。读路径永远无副作用:否则每次启动都会改 localstore。
45
+ */
46
+ export declare function readAcpToken(root: string): string | null;
47
+ /**
48
+ * 生成(或返回既有)ACP token 并落该 CCC 的 localstore。
49
+ *
50
+ * ⚠️ **幂等**:已有 token 时**原样返回**(不轮换)—— 轮换会让在用的客户端立刻断线,
51
+ * 必须是一次显式动作(`resetAcpToken`)。
52
+ */
53
+ export declare function ensureAcpToken(root: string): string;
54
+ /** 轮换 token(显式动作;返回新值)。旧 token 立即失效。 */
55
+ export declare function resetAcpToken(root: string): string;
56
+ /**
57
+ * 常时比较(防时序侧信道)。
58
+ *
59
+ * 🔴 两个长度不等时 `timingSafeEqual` **会抛** ⇒ 先比长度并立刻返回 false。
60
+ * ⚠️ 那一步本身**泄漏长度**(可接受:token 长度固定且公开)。
61
+ */
62
+ export declare function safeEqual(a: string, b: string): boolean;
63
+ /** 一枚 token 的归属(用于"谁拥有这个 token"的反查与排重) */
64
+ interface TokenOwner {
65
+ root: string;
66
+ token: string;
67
+ /** token 的 SHA-256(**日志与诊断里只出现它**,绝不出现 token 明文) */
68
+ fingerprint: string;
69
+ }
70
+ /** token 指纹(诊断面用;不可逆) */
71
+ export declare function tokenFingerprint(token: string): string;
72
+ /**
73
+ * 从一次 HTTP upgrade 请求里取出候选 token。
74
+ *
75
+ * 两种携带方式都认(客户端差异;Agmente 等用具名头,脚本常用查询串):
76
+ * ① `Authorization: Bearer <t>`
77
+ * ② `?token=<t>`(查询串)
78
+ *
79
+ * @returns 取到的 token;两者都无 → null
80
+ */
81
+ export declare function extractToken(headers: Record<string, unknown>, url: string | undefined): string | null;
82
+ /**
83
+ * 校验一枚候选 token,返回它归属的 CCC 根(**未命中返回 null**)。
84
+ *
85
+ * @param candidate 客户端出示的 token
86
+ * @param roots 候选 CCC 根集合(由调用方枚举——本模块不认识"CCC 在哪")
87
+ */
88
+ export declare function resolveTokenOwner(candidate: string, roots: readonly string[]): string | null;
89
+ /**
90
+ * 枚举**启用了 ACP 通道**的 CCC(供 `resolveTokenOwner` 用)。
91
+ *
92
+ * 判据(与装配层同源,见 `acpChannelEnabled`):`im.channel === 'acp'` ∧ `im.acp.enabled === true`
93
+ * ∧ token 已生成。⚠️ 三者**缺一即不算**(fail-closed:宁可连不上,不可放行一个半配置的通道)。
94
+ */
95
+ export declare function listAcpRoots(roots: readonly string[], paths?: string[]): string[];
96
+ /** 读该 CCC 的 `im` 段(未配置 ⇒ 归一缺省;永不 undefined) */
97
+ export declare function readImSettings(root: string, paths?: string[]): Required<Pick<ImSettings, 'channel'>> & {
98
+ acp: ImAcpSettings;
99
+ };
100
+ /**
101
+ * 该 CCC 是否启用 ACP 入站通道。
102
+ *
103
+ * 🔴 **两个条件都真才算**:`im.channel === 'acp'` **且** `im.acp.enabled === true`。
104
+ * 为什么双键:`channel` 表达"选了哪条路",`enabled` 表达"现在开不开" ⇒
105
+ * 让 owner 能**选好 ACP 但暂时关着**(不用删配置),与既有各面的 `enabled` 范式一致。
106
+ */
107
+ export declare function acpChannelEnabled(root: string, paths?: string[]): boolean;
108
+ /** 该 CCC 绑定的 skiff 角色名(未配置 ⇒ null) */
109
+ export declare function acpRoleFor(root: string, paths?: string[]): string | null;
110
+ /**
111
+ * 客户端 `session/new` 传来的 `cwd` 是否落在该 token 归属的 CCC 内。
112
+ *
113
+ * 🔴 **为什么必须校验**:token 定的是"哪个 CCC",而 `cwd` 由**客户端**指定。
114
+ * 不校验 ⇒ 一枚 CCC A 的 token 可以把会话开到 CCC B(越权读到另一个 CCC 的上下文)。
115
+ * 这与 CCC 自身的路径围墙(P3)是同一条纪律,只是发生在**接入**这一层。
116
+ *
117
+ * 判据用 `relative()` 而非 `startsWith`(后者会把 `/a/bc` 误判为在 `/a/b` 内)。
118
+ */
119
+ export declare function cwdInsideRoot(cwd: string, root: string): boolean;
120
+ /**
121
+ * 一次性解析:候选 token → 归属 CCC + 该 CCC 的角色。
122
+ *
123
+ * @returns `{ root, role }`;token 无效 / CCC 未启用 ACP / 角色未配置 → null(**调用方一律拒绝连接**)
124
+ */
125
+ export declare function authorizeAcpConnection(candidate: string | null, roots: readonly string[], paths?: string[]): {
126
+ root: string;
127
+ role: string;
128
+ } | null;
129
+ /** 该 CCC 的 ACP 通道对外**可见形态**(诊断面用;**绝不含 token 明文**) */
130
+ export declare function acpChannelStatus(root: string, paths?: string[]): {
131
+ channel: 'weixin' | 'acp';
132
+ enabled: boolean;
133
+ role: string | null;
134
+ hasToken: boolean;
135
+ tokenFingerprint: string | null;
136
+ };
137
+ /** 只读:该 CCC 的 localstore 是否存在(避免对不存在的 CCC 白跑 token 反查) */
138
+ export declare function hasLocalstore(root: string): boolean;
139
+ /** 只读:localstore 所在目录(诊断路径展示;不创建) */
140
+ export declare function localstoreDir(root: string): string;
141
+ /**
142
+ * 该 CCC 是否"看起来像"一个可用 ACP 宿主(只读低成本预筛)。
143
+ * ⚠️ 与 `acpChannelEnabled` 分开:本函数**不读配置**(只判文件存在),用于快速排除。
144
+ */
145
+ export declare function looksLikeCcc(root: string): boolean;
146
+ /** 排重后的 token 归属表(诊断用;同一 token 出现在两个 CCC ⇒ 响亮暴露,不静默取第一个) */
147
+ export declare function tokenOwnerAudit(roots: readonly string[]): {
148
+ owners: TokenOwner[];
149
+ duplicates: Array<{
150
+ fingerprint: string;
151
+ roots: string[];
152
+ }>;
153
+ };
154
+ /** 测试辅助:读 store 里该键的原始值(绕开 getEntry 的归一) */
155
+ export declare function __rawTokenForTest(root: string): string | undefined;
156
+ export {};
package/lib/acp-face.d.ts CHANGED
@@ -48,6 +48,18 @@
48
48
  */
49
49
  import type { Context } from 'cordis';
50
50
  import { type FaceHandle } from './face-host.js';
51
+ /** 刷新准入名单(**只收启用了 ACP 通道且已生成 token 的 CCC**)。返回新名单长度。 */
52
+ export declare function refreshAcpRoots(ctx: Context): Promise<number>;
53
+ /** 当前准入名单(诊断用只读快照) */
54
+ export declare function acpRootsSnapshot(): readonly string[];
55
+ /**
56
+ * 测试注入钩子(**生产零调用**):直接置准入名单。
57
+ *
58
+ * ⚠️ 为什么需要:`refreshAcpRoots()` 依赖 `listCccs(ctx)`(要真 CCC 环境)。
59
+ * 而认证闸本身是**纯逻辑**,测试要能单独驱动它 —— 否则"闸真的拦得住吗"这件事
60
+ * 只能靠集成环境验,而集成环境恰恰是最不容易跑的那种。
61
+ */
62
+ export declare function __setAcpRootsForTest(roots: readonly string[] | null): void;
51
63
  export declare function acpFaceActive(): boolean;
52
64
  export declare function acpFacePort(): number | null;
53
65
  /**
@@ -64,12 +76,16 @@ export interface AcpFaceStatus {
64
76
  enabled: boolean;
65
77
  connections: number;
66
78
  applied: number;
79
+ /** 🔴 被认证闸拒掉的连接数(v1.59.0):它 > 0 说明**有人在敲门或 token 配错了** */
80
+ rejected: number;
81
+ /** 当前准入的 CCC 数(启用了 ACP 通道 + 已生成 token) */
82
+ authorizedCccs: number;
67
83
  errors: Array<{
68
84
  at: number;
69
85
  applied: boolean;
70
86
  error?: string;
71
87
  }>;
72
- /** 🔴 本面**没有内建认证** —— 这条随状态一起出,避免"看到 active 就以为它是安全的" */
88
+ /** 🔴 认证状态一句话(v1.59.0 取代原"本面无认证") */
73
89
  authNote: string;
74
90
  }
75
91
  export declare function acpFaceStatus(): AcpFaceStatus;
package/lib/ccc.d.ts CHANGED
@@ -137,6 +137,16 @@ interface SerenityConfig {
137
137
  * 未配置或 enabled=false → 该 CCC 桥完全不启动(零资源占用)。
138
138
  */
139
139
  weixin?: WeixinSettings;
140
+ /**
141
+ * IM 通道选择(v1.59.0,owner 2026-10-17 令):
142
+ * 「将微信桥升级为 IM 桥,每个 CCC 可以选择微信实现或 ACP 实现,微信实现保留不变向前兼容,
143
+ * ACP 实现就可以暴露端口并绑定 skiff,并且要随即生成认证用的 token」。
144
+ *
145
+ * 🔴 **缺省 = weixin**(未配置的 CCC 行为与今天**逐字相同**)⇒ 向前兼容由**缺省值**保证,
146
+ * 不靠代码分支。选 `acp` 时该 CCC 的入站改走 ACP 面(面 E,端口 3200);
147
+ * 其 `weixin.*` 段**保留**(向前兼容),但**不再驱动入站**;出站 `im-bridge` 不受影响。
148
+ */
149
+ im?: ImSettings;
140
150
  /**
141
151
  * 专属工具声明(v1.33,S142 §32.11):**ACC 注册但默认对所有 CCC 隐藏**的工具,
142
152
  * 只有在自己这里声明的 CCC 可见(例:`["acc-diag"]` = ACC 负责人的运行态诊断)。
@@ -241,6 +251,36 @@ export interface WeixinSettings {
241
251
  */
242
252
  fallbackOnNoSend?: boolean;
243
253
  }
254
+ /**
255
+ * IM 通道选择(CCC 级;v1.59.0,owner 2026-10-17 令)。
256
+ *
257
+ * 归属判据(D23「改它要不要发版」):**选哪个通道、绑哪个角色 = CCC 的数据** ⇒ 归此处;
258
+ * **怎么校验 token、怎么把 ACP 会话套上角色 = 机制** ⇒ 归 ACC 源码。
259
+ */
260
+ export interface ImSettings {
261
+ /**
262
+ * 入站通道选择。`"weixin"`(**缺省**,=现状)| `"acp"`。
263
+ *
264
+ * 🔴 **缺省值即向前兼容**:不写这一项(或整个 `im` 段不存在)的 CCC,行为与升级前逐字相同。
265
+ * ⚠️ **只影响入站**(谁来驱动本 CCC 的 agent);**出站**(`im-bridge` 工具发送)不受本项影响。
266
+ */
267
+ channel?: 'weixin' | 'acp';
268
+ /** ACP 通道参数(仅 `channel: "acp"` 时有意义) */
269
+ acp?: ImAcpSettings;
270
+ }
271
+ /** ACP 通道参数(CCC 级) */
272
+ export interface ImAcpSettings {
273
+ /**
274
+ * 绑定的 skiff 角色名(**必须 ∈ 本 CCC 的 `skiff.roles`**)。
275
+ * 缺省 / 角色不存在 ⇒ **连接被拒**(fail-closed:宁可连不上,不可给出一个无身份、无约束的裸 agent)。
276
+ */
277
+ role?: string;
278
+ /**
279
+ * 该 CCC 的 ACP 面开关(**与 `channel: "acp"` 是「与」关系**:两者都真才算启用)。
280
+ * 缺省 false。
281
+ */
282
+ enabled?: boolean;
283
+ }
244
284
  /** 微信桥账号配置(serenity.json 内;凭据部分在 localstore) */
245
285
  export interface WeixinAccountConfig {
246
286
  /** 本地唯一键(如 wechat-1;localstore 凭据键 weixin.accounts.<accountId>.*) */
package/lib/client.js CHANGED
@@ -4439,12 +4439,14 @@ Instruction-following style:
4439
4439
  const croOn = value?.croEnabled ?? true;
4440
4440
  const unattendedOn = value?.unattendedEnabled ?? false;
4441
4441
  const diagramOn = value?.diagramEnabled ?? false;
4442
+ const acpFaceOn = value?.acpFaceEnabled ?? false;
4442
4443
  const [openGateway, setOpenGateway] = (0, react.useState)(false);
4443
4444
  const [openRebuild, setOpenRebuild] = (0, react.useState)(false);
4444
4445
  const [openSkiff, setOpenSkiff] = (0, react.useState)(false);
4445
4446
  const [openCro, setOpenCro] = (0, react.useState)(false);
4446
4447
  const [openUnattended, setOpenUnattended] = (0, react.useState)(false);
4447
4448
  const [openDiagram, setOpenDiagram] = (0, react.useState)(false);
4449
+ const [openAcpFace, setOpenAcpFace] = (0, react.useState)(false);
4448
4450
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4449
4451
  className: "ss-section",
4450
4452
  children: [
@@ -4578,9 +4580,26 @@ Instruction-following style:
4578
4580
  })
4579
4581
  }) })]
4580
4582
  }),
4581
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Group, {
4583
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Group, {
4582
4584
  title: "工具",
4583
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RowCard, {
4585
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RowCard, {
4586
+ title: "ACP 面(官方 ACP,端口 3200)",
4587
+ desc: "让标准 ACP 客户端(如 Agmente)连进本机;每 CCC 一枚 token 把关(默认关)",
4588
+ expandable: true,
4589
+ open: openAcpFace,
4590
+ onToggle: setOpenAcpFace,
4591
+ control: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toggle, {
4592
+ checked: acpFaceOn,
4593
+ onChange: (on) => toggle("acpFaceEnabled", on)
4594
+ }),
4595
+ detail: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4596
+ className: "ss-detailStack",
4597
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4598
+ className: "ss-detailIntro",
4599
+ children: "ACP 面(面 E,2026-10-17 owner 令)\n· 作用:把**官方** @deepseek-ai/dsh-acp 挂进本进程,标准 ACP 客户端经 WebSocket 登入本机\n· 与「微信桥」的关系:两者是**入站通道的两个实现**,每个 CCC 二选一(在各自的\n .opencode/serenity.json 里配 im.channel)。本开关只管**端口开不开**,不管哪个 CCC 用哪条\n· 🔴 认证:本面绑 0.0.0.0 且 ACP 协议自身**不做认证** ⇒ 由 ACC 在握手前用\n **每 CCC 一枚 token**(localstore 键 IM_ACP_TOKEN)把关;没配 token 的 CCC 一律连不上\n· 怎么用:① 在目标 CCC 配 im.channel=acp + im.acp.role=<角色名> ② 生成 token\n ③ 客户端连 ws://<本机IP>:3200,带 Authorization: Bearer <token> 或 ?token=<token>\n· ⚠️ 明文 ws:// ⇒ 局域网内可抓包;**上公网必须走 wss + Cloudflare Access**\n· 协议无认证这一条由 ACC 补齐:未过 token 闸的连接**根本升不上来**(不是\"升上来再拒\")"
4600
+ })
4601
+ })
4602
+ }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RowCard, {
4584
4603
  title: "图(diagram)",
4585
4604
  desc: "agent 给一段标签式源码,进程内直接画出 PNG 并贴进对话(不借浏览器;默认关)",
4586
4605
  expandable: true,
@@ -4597,7 +4616,7 @@ Instruction-following style:
4597
4616
  children: "图(diagram 工具)\n· 作用:agent 给一段**标签式源码**(<diagram> 根 + <rect>/<arrow>/<text>/<color>/<legend>),\n 由 ACC **在进程内**画成图并贴进对话 —— 不启浏览器、不截图\n· 产物:图进对话记录(可回看、可被后续轮次引用、可转微信);同时落两份文件在容器的\n _tmp/diagram/(.svg 可读可改 / .png 是进对话的那份)\n· 自带体检:重叠块 / 标签溢出 / 悬空端点 / 线压字,四条**只告警不拦图**;\n 写错的图元被跳过并逐条报错,其余照画\n· 中文能画:字体**随插件内置**(约 3.8 MB)—— 光栅化引擎看不见系统字体,只能喂内置的\n· 默认关:它是 owner 要的 demo 功能;**关着的时候工具根本不注册**(模型看不到它,不占上下文)\n· 图要贴进对话有个前提:**当前模型路由要声明收图像输入**。没声明时图照样画、照样落盘,\n 回执里写明原因并给路径(同「读图」工具的门)"
4598
4617
  })
4599
4618
  })
4600
- }) })
4619
+ }) })]
4601
4620
  }),
4602
4621
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Collapse, {
4603
4622
  title: "彩蛋模式",
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@ import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.js";
2
2
  import { c as hostInjected, d as hostSessions, f as hostSettings, h as hostWebServer, i as cccRootForExec, l as hostLlm, m as hostWeb, n as agentCwdFor, o as listCccs, p as hostSubagents, r as cccRootForCwd, s as hostAgents, t as NO_CCC_FROM_AGENT_CWD, u as hostService } from "./ccc-roots-GIJ3A_tw.js";
3
3
  import { _ as resolveInside, a as findSerenityRoot$1, c as matchBlacklist, d as readCccName$1, f as readCheapWorkerConfig, h as readTrajectorySkills, i as findGitRoot, l as pathInside, m as readSafeModeHiddenTools, n as SAFE_MODE_MARKER, o as isSafeModeOn, p as readExclusiveTools, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-B0ZGL9lx.js";
4
4
  import { A as localIdStamp, C as setActiveSessionInfo, D as isoLocal, E as useSession, O as localFileStamp, S as sessionsRoot, T as summarize, _ as namingTitleFor, a as pruneMissingBindings, b as resolveSessionByTitle, c as setBindingStore, d as clearActiveSessionInfo, f as createSession, g as listSessions, h as getActiveSessionInfo, i as listBoundSessionIds, k as localHuman, l as DEFAULT_SESSION_SCOPE, m as findSession, n as PLUGIN_SOURCE, o as readLastBound, p as extractSessionMdPathFromText, r as appendBound, s as resolveSessionTrajectoryLabel, t as ACC_MESSAGE_KIND, u as TRAJECTORY_ACTIONS, v as parseSessionContextFromEvents, w as showSession, x as sessionEvents$1, y as readActiveSessionMd } from "./message-source-B4P3Uw6D.js";
5
- import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as runLocalStore, h as readStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-B9gU1YXz.js";
5
+ import { _ as runLocalStore, a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as readStore, h as readGitTrack, i as matchWeixinRoute, l as weixinSessionIdFor, m as localstorePath, n as extractWeixinText, o as readWeixinSettings, p as getEntry, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia, v as writeEntry } from "./weixin-route-CSDBGLwl.js";
6
6
  import { a as FACE_PORTS, c as MECHANISM_PORTS, d as registerSkiffSession$1, f as skiffRoleFor$1, h as ACC_VERSION, i as ACP_PORT, l as SKIFF_DEBUG_PORT, m as unregisterSkiffSession$1, n as readSimpleSettings, o as GATEWAY_PORT, p as skiffSessionInfo$1, r as registerSettingsSection, s as MAIN_WEB_PORT, u as WEIXIN_SEND_PORT } from "./settings-section-DGUQZo5j.js";
7
7
  import { a as readSkiffRoles, c as roleMsmWhitelist, d as systemPromptSource, f as trajectorySubset, i as isSkiffSessionId, l as roleToolWhitelist, n as buildSkiffBasePrompt, o as resolveRoleSystemPrompt, r as createRolePromptReader, s as resolveSkiffKind } from "./skiff-role-B615L9MV.js";
8
8
  import { REGISTERED_TOOLS } from "./invariant.js";
@@ -15323,11 +15323,11 @@ function registerStatusApi(ctx, opts = {}) {
15323
15323
  sendJson$3(res, 400, { error: chk.error });
15324
15324
  return;
15325
15325
  }
15326
- const { readWeixinSettings } = await import("./weixin-route-B9gU1YXz.js").then((n) => n.u);
15326
+ const { readWeixinSettings } = await import("./weixin-route-CSDBGLwl.js").then((n) => n.u);
15327
15327
  const { weixinBridgeStatus } = await Promise.resolve().then(() => weixin_bridge_exports);
15328
15328
  const settings = readWeixinSettings(chk.root);
15329
15329
  const bridge = weixinBridgeStatus().find((b) => b.ccc === chk.root);
15330
- const { readWeixinCredential } = await import("./weixin-route-B9gU1YXz.js").then((n) => n.u);
15330
+ const { readWeixinCredential } = await import("./weixin-route-CSDBGLwl.js").then((n) => n.u);
15331
15331
  const accounts = (settings.accounts ?? []).map((a) => ({
15332
15332
  accountId: a.accountId,
15333
15333
  name: a.name ?? void 0,
@@ -15359,7 +15359,7 @@ function registerStatusApi(ctx, opts = {}) {
15359
15359
  const { fetchQRCode } = await import("./weixin-api-BrRcPiQs.js").then((n) => n.u);
15360
15360
  const { randomUUID } = await import("node:crypto");
15361
15361
  for (const [key, v] of weixinLogins) if (Date.now() - v.startedAt > WEIXIN_LOGIN_TTL_MS) weixinLogins.delete(key);
15362
- const { readWeixinSettings } = await import("./weixin-route-B9gU1YXz.js").then((n) => n.u);
15362
+ const { readWeixinSettings } = await import("./weixin-route-CSDBGLwl.js").then((n) => n.u);
15363
15363
  const qr = await fetchQRCode({ botType: readWeixinSettings(root).botType ?? void 0 });
15364
15364
  const loginKey = randomUUID();
15365
15365
  weixinLogins.set(loginKey, {
@@ -15380,7 +15380,7 @@ function registerStatusApi(ctx, opts = {}) {
15380
15380
  sendJson$3(res, 400, { error: "missing accountId" });
15381
15381
  return;
15382
15382
  }
15383
- const { removeWeixinAccount } = await import("./weixin-route-B9gU1YXz.js").then((n) => n.u);
15383
+ const { removeWeixinAccount } = await import("./weixin-route-CSDBGLwl.js").then((n) => n.u);
15384
15384
  const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
15385
15385
  removeWeixinAccount(root, body.accountId);
15386
15386
  syncCccBridge(ctx, root);
@@ -15393,7 +15393,7 @@ function registerStatusApi(ctx, opts = {}) {
15393
15393
  sendJson$3(res, 400, { error: "invalid routes (expected [{user, role}, ...])" });
15394
15394
  return;
15395
15395
  }
15396
- const { saveWeixinRoutes } = await import("./weixin-route-B9gU1YXz.js").then((n) => n.u);
15396
+ const { saveWeixinRoutes } = await import("./weixin-route-CSDBGLwl.js").then((n) => n.u);
15397
15397
  const { readSkiffRoles } = await import("./skiff-role-B615L9MV.js").then((n) => n.u);
15398
15398
  const roles = readSkiffRoles(root);
15399
15399
  for (const r of routes) if (!roles.has(r.role)) {
@@ -15406,7 +15406,7 @@ function registerStatusApi(ctx, opts = {}) {
15406
15406
  }
15407
15407
  if (body.action === "set-enabled") {
15408
15408
  const enabled = body.enabled === true;
15409
- const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-B9gU1YXz.js").then((n) => n.u);
15409
+ const { readWeixinSettings, setWeixinEnabled } = await import("./weixin-route-CSDBGLwl.js").then((n) => n.u);
15410
15410
  const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
15411
15411
  const settings = readWeixinSettings(root);
15412
15412
  if (enabled && (settings.accounts ?? []).length === 0) {
@@ -15450,7 +15450,7 @@ function registerStatusApi(ctx, opts = {}) {
15450
15450
  return;
15451
15451
  }
15452
15452
  const { pollQRStatus } = await import("./weixin-api-BrRcPiQs.js").then((n) => n.u);
15453
- const { readWeixinSettings } = await import("./weixin-route-B9gU1YXz.js").then((n) => n.u);
15453
+ const { readWeixinSettings } = await import("./weixin-route-CSDBGLwl.js").then((n) => n.u);
15454
15454
  const settings = readWeixinSettings(login.root);
15455
15455
  const status = await pollQRStatus({
15456
15456
  baseUrl: void 0,
@@ -15465,7 +15465,7 @@ function registerStatusApi(ctx, opts = {}) {
15465
15465
  });
15466
15466
  return;
15467
15467
  }
15468
- const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-B9gU1YXz.js").then((n) => n.u);
15468
+ const { upsertWeixinAccount, writeWeixinCredential, nextWeixinAccountId } = await import("./weixin-route-CSDBGLwl.js").then((n) => n.u);
15469
15469
  const { syncCccBridge } = await Promise.resolve().then(() => weixin_bridge_exports);
15470
15470
  const accountId = nextWeixinAccountId(settings);
15471
15471
  upsertWeixinAccount(login.root, {
@@ -21816,9 +21816,325 @@ function registerWeixinSendApi(ctx) {
21816
21816
  sync();
21817
21817
  }
21818
21818
  //#endregion
21819
+ //#region src/acp-auth.ts
21820
+ /**
21821
+ * acp-auth.ts — ACP 面的**认证 + 身份**层(v1.59.0,owner 2026-10-17 令)
21822
+ *
21823
+ * ## 它解决什么
21824
+ *
21825
+ * owner 令(逐字):「将微信桥升级为 IM 桥,每个 CCC 可以选择微信实现或 ACP 实现,
21826
+ * 微信实现保留不变向前兼容,**ACP 实现就可以暴露端口并绑定 skiff,并且要随即生成认证用的 token**」。
21827
+ *
21828
+ * 面 E(`acp-face.ts`)原本**无认证、无 CCC 归属、无角色** —— 本模块补上这三件:
21829
+ * ① **token**:每 CCC 一枚,落**该 CCC 的 localstore**(credential scope)。
21830
+ * ② **CCC 归属**:连接建立时由 token 反查 ⇒ **服务端权威**(不拿客户端自述的 `cwd` 当身份)。
21831
+ * ③ **角色**:该 CCC 的 `im.acp.role` ⇒ 供装配层把 ACP 会话套上 skiff 角色。
21832
+ *
21833
+ * ## 🔴 架构决策:一面多 token,不是每 CCC 一个端口
21834
+ *
21835
+ * | | 甲:每 CCC 一个端口 | **乙:一面多 token** ← 选它 |
21836
+ * |---|---|---|
21837
+ * | 端口 | 随 CCC 数增长 | **固定 3200** |
21838
+ * | 身份来源 | 端口号 | **token(一一对应 CCC)** |
21839
+ *
21840
+ * **判据**:`token → CCC` 是一一对应,而 `cwd` 是**客户端可填的**。
21841
+ * ⇒ 用 token 定 CCC 比用 cwd 定 CCC 强:前者**服务端权威**,后者**客户端自述**。
21842
+ * ⇒ 连接一建立就知道"这是哪个 CCC",**该连接的所有会话都归它**,角色绑定无需猜。
21843
+ * ⚠️ `cwd` 仍要校验(见 `cwdInsideRoot`):防一枚 token 越权到别的 CCC。
21844
+ *
21845
+ * ## 🔴 为什么认证放在 WS upgrade 层,而不在 ACP 协议里
21846
+ *
21847
+ * `dsh-acp` 的 `authenticate()` **恒成功**且 `authMethods: []`(读其 `lib/index.js` 逐字),
21848
+ * 且它**不导出**可替换的认证缝 ⇒ 改协议层要重写它的接线(P0′ 已实测此路不通)。
21849
+ * 而**我们自己持有 HTTP server**(自持面)⇒ 在 `upgrade` **升级之前**校验,
21850
+ * 失败即 `401 + destroy` ⇒ **连接根本进不来**。
21851
+ *
21852
+ * ## 边界
21853
+ *
21854
+ * 本模块**零 DSH 依赖**(只 `node:crypto` / `node:fs` / `node:path` 与本仓纯逻辑)⇒ 可独立单测。
21855
+ */
21856
+ /** localstore 凭据键(credential scope ⇒ UPPER_SNAKE) */
21857
+ const ACP_TOKEN_KEY = "IM_ACP_TOKEN";
21858
+ /** token 字节数(32 B = 256 bit;hex 后 64 字符) */
21859
+ const TOKEN_BYTES = 32;
21860
+ /**
21861
+ * 该 CCC 的 ACP token(**读**;不存在返回 null)。
21862
+ *
21863
+ * 🔴 **不自动生成** —— 生成是**写操作**,只在 owner/面板显式要求时做
21864
+ * (`ensureAcpToken`)。读路径永远无副作用:否则每次启动都会改 localstore。
21865
+ */
21866
+ function readAcpToken(root) {
21867
+ try {
21868
+ const value = getEntry(root, "credential", ACP_TOKEN_KEY);
21869
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
21870
+ } catch {
21871
+ return null;
21872
+ }
21873
+ }
21874
+ /**
21875
+ * 生成(或返回既有)ACP token 并落该 CCC 的 localstore。
21876
+ *
21877
+ * ⚠️ **幂等**:已有 token 时**原样返回**(不轮换)—— 轮换会让在用的客户端立刻断线,
21878
+ * 必须是一次显式动作(`resetAcpToken`)。
21879
+ */
21880
+ function ensureAcpToken(root) {
21881
+ const existing = readAcpToken(root);
21882
+ if (existing) return existing;
21883
+ const token = randomBytes(TOKEN_BYTES).toString("hex");
21884
+ writeEntry(root, "credential", ACP_TOKEN_KEY, token);
21885
+ return token;
21886
+ }
21887
+ /**
21888
+ * 常时比较(防时序侧信道)。
21889
+ *
21890
+ * 🔴 两个长度不等时 `timingSafeEqual` **会抛** ⇒ 先比长度并立刻返回 false。
21891
+ * ⚠️ 那一步本身**泄漏长度**(可接受:token 长度固定且公开)。
21892
+ */
21893
+ function safeEqual(a, b) {
21894
+ const bufA = Buffer.from(a, "utf8");
21895
+ const bufB = Buffer.from(b, "utf8");
21896
+ if (bufA.length !== bufB.length) return false;
21897
+ return timingSafeEqual(bufA, bufB);
21898
+ }
21899
+ /** token 指纹(诊断面用;不可逆) */
21900
+ function tokenFingerprint(token) {
21901
+ return createHash("sha256").update(token).digest("hex").slice(0, 12);
21902
+ }
21903
+ /**
21904
+ * 从一次 HTTP upgrade 请求里取出候选 token。
21905
+ *
21906
+ * 两种携带方式都认(客户端差异;Agmente 等用具名头,脚本常用查询串):
21907
+ * ① `Authorization: Bearer <t>`
21908
+ * ② `?token=<t>`(查询串)
21909
+ *
21910
+ * @returns 取到的 token;两者都无 → null
21911
+ */
21912
+ function extractToken(headers, url) {
21913
+ const auth = headers["authorization"];
21914
+ if (typeof auth === "string") {
21915
+ const m = /^Bearer\s+(.+)$/i.exec(auth.trim());
21916
+ if (m?.[1]) return m[1].trim();
21917
+ }
21918
+ if (typeof url === "string" && url !== "") try {
21919
+ const q = new URL(url, "http://placeholder.invalid").searchParams.get("token");
21920
+ if (q && q.trim() !== "") return q.trim();
21921
+ } catch {}
21922
+ return null;
21923
+ }
21924
+ /**
21925
+ * 校验一枚候选 token,返回它归属的 CCC 根(**未命中返回 null**)。
21926
+ *
21927
+ * @param candidate 客户端出示的 token
21928
+ * @param roots 候选 CCC 根集合(由调用方枚举——本模块不认识"CCC 在哪")
21929
+ */
21930
+ function resolveTokenOwner(candidate, roots) {
21931
+ if (!candidate) return null;
21932
+ for (const root of roots) {
21933
+ const token = readAcpToken(root);
21934
+ if (token && safeEqual(candidate, token)) return root;
21935
+ }
21936
+ return null;
21937
+ }
21938
+ /**
21939
+ * 枚举**启用了 ACP 通道**的 CCC(供 `resolveTokenOwner` 用)。
21940
+ *
21941
+ * 判据(与装配层同源,见 `acpChannelEnabled`):`im.channel === 'acp'` ∧ `im.acp.enabled === true`
21942
+ * ∧ token 已生成。⚠️ 三者**缺一即不算**(fail-closed:宁可连不上,不可放行一个半配置的通道)。
21943
+ */
21944
+ function listAcpRoots(roots, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
21945
+ const out = [];
21946
+ for (const root of roots) {
21947
+ if (!acpChannelEnabled(root, paths)) continue;
21948
+ if (!readAcpToken(root)) continue;
21949
+ out.push(root);
21950
+ }
21951
+ return out;
21952
+ }
21953
+ /** 读该 CCC 的 `im` 段(未配置 ⇒ 归一缺省;永不 undefined) */
21954
+ function readImSettings(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
21955
+ try {
21956
+ const im = loadSerenityConfig(root, paths).im;
21957
+ if (!im || typeof im !== "object") return {
21958
+ channel: "weixin",
21959
+ acp: {}
21960
+ };
21961
+ return {
21962
+ channel: im.channel === "acp" ? "acp" : "weixin",
21963
+ acp: im.acp && typeof im.acp === "object" ? im.acp : {}
21964
+ };
21965
+ } catch {
21966
+ return {
21967
+ channel: "weixin",
21968
+ acp: {}
21969
+ };
21970
+ }
21971
+ }
21972
+ /**
21973
+ * 该 CCC 是否启用 ACP 入站通道。
21974
+ *
21975
+ * 🔴 **两个条件都真才算**:`im.channel === 'acp'` **且** `im.acp.enabled === true`。
21976
+ * 为什么双键:`channel` 表达"选了哪条路",`enabled` 表达"现在开不开" ⇒
21977
+ * 让 owner 能**选好 ACP 但暂时关着**(不用删配置),与既有各面的 `enabled` 范式一致。
21978
+ */
21979
+ function acpChannelEnabled(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
21980
+ const im = readImSettings(root, paths);
21981
+ return im.channel === "acp" && im.acp.enabled === true;
21982
+ }
21983
+ /** 该 CCC 绑定的 skiff 角色名(未配置 ⇒ null) */
21984
+ function acpRoleFor(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
21985
+ const role = readImSettings(root, paths).acp.role;
21986
+ return typeof role === "string" && role.trim() !== "" ? role.trim() : null;
21987
+ }
21988
+ /**
21989
+ * 客户端 `session/new` 传来的 `cwd` 是否落在该 token 归属的 CCC 内。
21990
+ *
21991
+ * 🔴 **为什么必须校验**:token 定的是"哪个 CCC",而 `cwd` 由**客户端**指定。
21992
+ * 不校验 ⇒ 一枚 CCC A 的 token 可以把会话开到 CCC B(越权读到另一个 CCC 的上下文)。
21993
+ * 这与 CCC 自身的路径围墙(P3)是同一条纪律,只是发生在**接入**这一层。
21994
+ *
21995
+ * 判据用 `relative()` 而非 `startsWith`(后者会把 `/a/bc` 误判为在 `/a/b` 内)。
21996
+ */
21997
+ function cwdInsideRoot(cwd, root) {
21998
+ if (!isAbsolute(cwd)) return false;
21999
+ const rel = relative(resolve(root), resolve(cwd));
22000
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
22001
+ }
22002
+ //#endregion
21819
22003
  //#region src/acp-face.ts
21820
22004
  /** 面名(面宿主幂等键;与 FACE_PORTS 的 id 同名,便于对账) */
21821
22005
  const FACE_ACP = "acp";
22006
+ /**
22007
+ * 校验一次 upgrade 请求(**只读**:不改任何状态)。
22008
+ *
22009
+ * 判据链(任一环失败即拒,fail-closed):候选 token 缺失 ⇒ 不匹配任何 CCC ⇒
22010
+ * 该 CCC 未启用 ACP 通道 ⇒ 该 CCC 未配角色。最后一环尤其重要:**没有角色的 ACP 会话
22011
+ * 等于一个无身份、无约束的裸 agent** ⇒ 宁可连不上。
22012
+ */
22013
+ function authorizeUpgrade(req) {
22014
+ const candidate = extractToken(req.headers, req.url);
22015
+ if (!candidate) return {
22016
+ ok: false,
22017
+ reason: "未携带 token(用 Authorization: Bearer <t> 或 ?token=<t>)"
22018
+ };
22019
+ const roots = acpRoots;
22020
+ if (roots.length === 0) return {
22021
+ ok: false,
22022
+ reason: "本机没有任何启用了 ACP 通道的 CCC"
22023
+ };
22024
+ const owner = resolveTokenOwner(candidate, roots);
22025
+ if (!owner) return {
22026
+ ok: false,
22027
+ reason: "token 不匹配任何已启用 ACP 通道的 CCC"
22028
+ };
22029
+ const role = acpRoleFor(owner);
22030
+ if (!role) return {
22031
+ ok: false,
22032
+ reason: `CCC(${owner})未配置 im.acp.role`
22033
+ };
22034
+ return {
22035
+ ok: true,
22036
+ root: owner,
22037
+ role
22038
+ };
22039
+ }
22040
+ /**
22041
+ * 归属候选 CCC 的**同步**缓存。
22042
+ *
22043
+ * 🔴 为什么缓存而非每次现算:`listCccs()` 是 **async**(要读 workspace registry +
22044
+ * `sessionPersistence`),而 `'upgrade'` 回调是**同步**的 ⇒ 不能在那里 await。
22045
+ * ⇒ 启动时算一次 + 配置变更时刷新;这是**准入名单**,宁可用稍旧的名单(新 CCC 多等一次
22046
+ * 配置事件即可生效),也不让 upgrade 挂起。
22047
+ */
22048
+ let acpRoots = [];
22049
+ /** 刷新准入名单(**只收启用了 ACP 通道且已生成 token 的 CCC**)。返回新名单长度。 */
22050
+ async function refreshAcpRoots(ctx) {
22051
+ try {
22052
+ const all = (await listCccs(ctx)).map((e) => e.root);
22053
+ for (const root of all) try {
22054
+ if (acpChannelEnabled(root) && !readAcpToken(root)) {
22055
+ const token = ensureAcpToken(root);
22056
+ console.log(`[serenity-hooks] ✓ 面 E:已为 CCC 生成 ACP token(${root},指纹 ${tokenFingerprint(token)})`);
22057
+ }
22058
+ } catch (err) {
22059
+ console.warn(`[serenity-hooks] 面 E:为 CCC 生成 token 失败(${root}): ${String(err?.message ?? err)}`);
22060
+ }
22061
+ acpRoots = listAcpRoots(all);
22062
+ } catch (err) {
22063
+ acpRoots = [];
22064
+ console.warn(`[serenity-hooks] 面 E:CCC 准入名单刷新失败(已清空,连接将被拒): ${String(err?.message ?? err)}`);
22065
+ }
22066
+ return acpRoots.length;
22067
+ }
22068
+ /**
22069
+ * 把**本连接归属的 CCC 角色**套到该连接创建的 agent 上(v1.59.0)。
22070
+ *
22071
+ * ## 🔴 为什么是"认领"而不是"直接挂"
22072
+ *
22073
+ * 理想形态是:在本连接的 scoped fiber 里监听 `session/created`,**只**收到本连接建的会话。
22074
+ * 但那条路依赖一个**我没有实测过的**引擎行为 —— cordis 的 `EventsService.dispatch()` 只在
22075
+ * **派发方设了 `Context.filter`** 时才按 ctx 过滤(读 `@deepseek-ai/cordis/src/events.ts`
22076
+ * 的 `.filter(hook => hook.global || !filter || filter.call(thisArg, hook.ctx))`)。
22077
+ * ⇒ 若宿主派发 `session/created` 时**没设**过滤器,本监听器会**收到全进程**的会话
22078
+ * (网页端、别的轨迹、别的连接),**套错角色**。
22079
+ *
22080
+ * 🔴 **D119 纪律**:不许照源码的"意图"断言引擎行为 ⇒ 本实现**不赌**那条语义,
22081
+ * 改用**认领制**(两条过滤都可以自证,不依赖 filter):
22082
+ * ① **cwd 必须落在本连接归属的 CCC 内**(token 已定 CCC ⇒ 越界会话直接跳过);
22083
+ * ② **同一会话只被认领一次**(`claimed` 集合;先到先得 ⇒ 不会被另一条连接的监听器抢走)。
22084
+ *
22085
+ * ⚠️ **残余风险(如实标注)**:若**同一 CCC** 同时有网页端会话与 ACP 会话在创建,
22086
+ * 本函数可能把网页端那个会话也认领了(两者 cwd 都在该 CCC 内)。⇒ 判据是"cwd + 先到先得",
22087
+ * **不是"确属本连接"**。⚠️ **这一格必须由探针实测**(见 REBUILD-TODO 的待办);
22088
+ * 未实测前**不得**声称"只套本连接的会话"。
22089
+ *
22090
+ * 失败语义:全程不抛(本面是可选面)——角色挂不上只记日志,不阻断 ACP 接线。
22091
+ */
22092
+ function bindRoleToConnScopedAgent(scoped, root, roleName) {
22093
+ try {
22094
+ const role = readSkiffRoles(root).get(roleName);
22095
+ if (!role) {
22096
+ console.warn(`[serenity-hooks] 面 E:CCC(${root})未定义 skiff 角色 "${roleName}" ⇒ 本连接不注入角色`);
22097
+ return;
22098
+ }
22099
+ const rolePrompt = createRolePromptReader(root, role);
22100
+ const claimed = /* @__PURE__ */ new Set();
22101
+ const onCreated = (session) => {
22102
+ try {
22103
+ const id = session?.header?.id;
22104
+ const cwd = session?.header?.cwd;
22105
+ if (!id || !cwd) return;
22106
+ if (claimed.has(id)) return;
22107
+ if (!cwdInsideRoot(cwd, root)) return;
22108
+ const target = resolveAgentById(scoped, id);
22109
+ if (!target) return;
22110
+ target.ctx.systemPrompt.section({
22111
+ name: "serenity-skiff",
22112
+ order: -60,
22113
+ text: () => [buildSkiffBasePrompt(roleName, role), rolePrompt()].filter(Boolean).join("\n")
22114
+ });
22115
+ claimed.add(id);
22116
+ console.log(`[serenity-hooks] ✓ 面 E:会话 ${id} 已绑定角色 "${roleName}"(CCC=${root})`);
22117
+ } catch (err) {
22118
+ console.warn(`[serenity-hooks] 面 E:角色注入失败(不影响会话): ${String(err?.message ?? err)}`);
22119
+ }
22120
+ };
22121
+ for (const eventName of ["session/created", "agent/created"]) try {
22122
+ scoped.on(eventName, onCreated);
22123
+ } catch {}
22124
+ } catch (err) {
22125
+ console.warn(`[serenity-hooks] 面 E:角色绑定装配失败: ${String(err?.message ?? err)}`);
22126
+ }
22127
+ }
22128
+ /** 按会话 id 取 live agent(只读;取不到返回 undefined) */
22129
+ function resolveAgentById(scoped, sessionId) {
22130
+ try {
22131
+ const agent = hostInjected(scoped, "agents")?.get?.(sessionId);
22132
+ if (!agent?.ctx?.systemPrompt || typeof agent.ctx.systemPrompt.section !== "function") return void 0;
22133
+ return agent;
22134
+ } catch {
22135
+ return;
22136
+ }
22137
+ }
21822
22138
  /** 本面绑定的地址(取自集中端口表 —— **单一真相源**,不在本文件硬写) */
21823
22139
  const ACP_HOST = FACE_PORTS.acp.host ?? "127.0.0.1";
21824
22140
  /**
@@ -21892,6 +22208,7 @@ function newState() {
21892
22208
  wss: null,
21893
22209
  connections: 0,
21894
22210
  applied: 0,
22211
+ rejected: 0,
21895
22212
  lastErrors: [],
21896
22213
  disposers: []
21897
22214
  };
@@ -21938,9 +22255,33 @@ async function startAcpFace(ctx) {
21938
22255
  local.handle = handle;
21939
22256
  handle.server.on("upgrade", (req, socket, head) => {
21940
22257
  local.connections += 1;
22258
+ const verdict = authorizeUpgrade(req);
22259
+ if (!verdict.ok) {
22260
+ local.rejected += 1;
22261
+ local.lastErrors.push({
22262
+ at: Date.now(),
22263
+ applied: false,
22264
+ error: `auth: ${verdict.reason}`
22265
+ });
22266
+ if (local.lastErrors.length > MAX_ERR_KEEP) local.lastErrors.shift();
22267
+ try {
22268
+ socket.write(`HTTP/1.1 401 Unauthorized\r
22269
+ content-type: text/plain; charset=utf-8\r
22270
+ connection: close\r
22271
+ \r
22272
+ ACP 面:认证失败(${verdict.reason})。请在该 CCC 的 localstore 生成并使用 IM_ACP_TOKEN。\n`);
22273
+ } catch {}
22274
+ try {
22275
+ socket.destroy();
22276
+ } catch {}
22277
+ console.warn(`[serenity-hooks] ✗ 面 E:拒绝一条连接(${verdict.reason})`);
22278
+ return;
22279
+ }
21941
22280
  try {
21942
22281
  wss.handleUpgrade(req, socket, head, (ws) => {
21943
22282
  try {
22283
+ const connRoot = verdict.root;
22284
+ const connRole = verdict.role;
21944
22285
  const stream = wsToAcpStream(ws, (line) => {
21945
22286
  console.log(`[serenity-hooks] 面 E:丢弃一条非 JSON 帧(前 120 字): ${line}`);
21946
22287
  });
@@ -21948,6 +22289,7 @@ async function startAcpFace(ctx) {
21948
22289
  inject: ACP_REQUIRED_SERVICES,
21949
22290
  apply: (scoped) => {
21950
22291
  const sel = resolveAcpSelection(scoped);
22292
+ bindRoleToConnScopedAgent(scoped, connRoot, connRole);
21951
22293
  dshAcp.apply(scoped, {
21952
22294
  stream,
21953
22295
  provider: sel.provider,
@@ -22107,6 +22449,17 @@ function registerAcpFace(ctx) {
22107
22449
  sync();
22108
22450
  });
22109
22451
  } catch {}
22452
+ const refresh = () => {
22453
+ refreshAcpRoots(ctx).then((n) => {
22454
+ console.log(`[serenity-hooks] 面 E:CCC 准入名单已刷新(${n} 个 CCC 启用 ACP 通道)`);
22455
+ });
22456
+ };
22457
+ try {
22458
+ ctx.on("settings/document-updated", (ns) => {
22459
+ if (String(ns) === "serenity-hooks") refresh();
22460
+ });
22461
+ } catch {}
22462
+ refresh();
22110
22463
  sync();
22111
22464
  }
22112
22465
  //#endregion
@@ -555,4 +555,4 @@ function setWeixinEnabled(root, enabled) {
555
555
  return next;
556
556
  }
557
557
  //#endregion
558
- export { readWeixinCredential as a, weixinInboundDir as c, LOCALSTORE_SCOPES as d, checkLocalstoreGitCompliance as f, runLocalStore as g, readStore as h, matchWeixinRoute as i, weixinSessionIdFor as l, readGitTrack as m, extractWeixinText as n, readWeixinSettings as o, localstorePath as p, hasVoiceItem as r, sanitizeFileName as s, extractWeixinMedia as t, weixin_route_exports as u };
558
+ export { runLocalStore as _, readWeixinCredential as a, weixinInboundDir as c, LOCALSTORE_SCOPES as d, checkLocalstoreGitCompliance as f, readStore as g, readGitTrack as h, matchWeixinRoute as i, weixinSessionIdFor as l, localstorePath as m, extractWeixinText as n, readWeixinSettings as o, getEntry as p, hasVoiceItem as r, sanitizeFileName as s, extractWeixinMedia as t, weixin_route_exports as u, writeEntry as v };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.58.2",
3
+ "version": "1.59.0",
4
4
  "description": "宁静号 ACC harness(Native Cordis 插件)——给 DeepSeek Harness 装一个「AI 工作区」:14 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/ontology/serenity-skill-creator/cheap-worker/localstore/container_admin;另三个 im-bridge/acc-diag/diagram 按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 工作日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.7-rc.1 与 0.2.0-rc.1(双档)。",
5
5
  "license": "MIT",
6
6
  "repository": {