@dsh-edge/dsh-edge-agent 0.0.0-stage → 0.1.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.
Files changed (238) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/LICENSE +21 -0
  3. package/README.md +181 -2
  4. package/config/edge.example.json +179 -0
  5. package/config/privacy-policy.json +202 -0
  6. package/cordis.desktop-safe.patch.yml +129 -0
  7. package/cordis.patch.yml +382 -0
  8. package/cordis.yml +374 -0
  9. package/lib/attestation.d.ts +2 -0
  10. package/lib/attestation.d.ts.map +1 -0
  11. package/lib/attestation.js +2 -0
  12. package/lib/audit-log.d.ts +2 -0
  13. package/lib/audit-log.d.ts.map +1 -0
  14. package/lib/audit-log.js +2 -0
  15. package/lib/client.js +87 -0
  16. package/lib/cloud-llm-adapter.d.ts +2 -0
  17. package/lib/cloud-llm-adapter.d.ts.map +1 -0
  18. package/lib/cloud-llm-adapter.js +2 -0
  19. package/lib/dsh-adapter/attestation.d.ts +42 -0
  20. package/lib/dsh-adapter/attestation.d.ts.map +1 -0
  21. package/lib/dsh-adapter/attestation.js +40 -0
  22. package/lib/dsh-adapter/audit-log.d.ts +139 -0
  23. package/lib/dsh-adapter/audit-log.d.ts.map +1 -0
  24. package/lib/dsh-adapter/audit-log.js +376 -0
  25. package/lib/dsh-adapter/cloud-egress.d.ts +181 -0
  26. package/lib/dsh-adapter/cloud-egress.d.ts.map +1 -0
  27. package/lib/dsh-adapter/cloud-egress.js +257 -0
  28. package/lib/dsh-adapter/cloud-llm-adapter.d.ts +416 -0
  29. package/lib/dsh-adapter/cloud-llm-adapter.d.ts.map +1 -0
  30. package/lib/dsh-adapter/cloud-llm-adapter.js +1472 -0
  31. package/lib/dsh-adapter/contract.d.ts +80 -0
  32. package/lib/dsh-adapter/contract.d.ts.map +1 -0
  33. package/lib/dsh-adapter/contract.js +194 -0
  34. package/lib/dsh-adapter/desktop-cookie-janitor.d.ts +214 -0
  35. package/lib/dsh-adapter/desktop-cookie-janitor.d.ts.map +1 -0
  36. package/lib/dsh-adapter/desktop-cookie-janitor.js +409 -0
  37. package/lib/dsh-adapter/dsh-edge-agent.d.ts +48 -0
  38. package/lib/dsh-adapter/dsh-edge-agent.d.ts.map +1 -0
  39. package/lib/dsh-adapter/dsh-edge-agent.js +64 -0
  40. package/lib/dsh-adapter/edge-config.d.ts +484 -0
  41. package/lib/dsh-adapter/edge-config.d.ts.map +1 -0
  42. package/lib/dsh-adapter/edge-config.js +898 -0
  43. package/lib/dsh-adapter/edge-context-view.d.ts +184 -0
  44. package/lib/dsh-adapter/edge-context-view.d.ts.map +1 -0
  45. package/lib/dsh-adapter/edge-context-view.js +483 -0
  46. package/lib/dsh-adapter/effective-model.d.ts +85 -0
  47. package/lib/dsh-adapter/effective-model.d.ts.map +1 -0
  48. package/lib/dsh-adapter/effective-model.js +171 -0
  49. package/lib/dsh-adapter/etscli-shell-guard.d.ts +244 -0
  50. package/lib/dsh-adapter/etscli-shell-guard.d.ts.map +1 -0
  51. package/lib/dsh-adapter/etscli-shell-guard.js +420 -0
  52. package/lib/dsh-adapter/etscli-tools.d.ts +112 -0
  53. package/lib/dsh-adapter/etscli-tools.d.ts.map +1 -0
  54. package/lib/dsh-adapter/etscli-tools.js +393 -0
  55. package/lib/dsh-adapter/file-logger.d.ts +294 -0
  56. package/lib/dsh-adapter/file-logger.d.ts.map +1 -0
  57. package/lib/dsh-adapter/file-logger.js +564 -0
  58. package/lib/dsh-adapter/function-call-external.d.ts +357 -0
  59. package/lib/dsh-adapter/function-call-external.d.ts.map +1 -0
  60. package/lib/dsh-adapter/function-call-external.js +1005 -0
  61. package/lib/dsh-adapter/host-egress-guard.d.ts +161 -0
  62. package/lib/dsh-adapter/host-egress-guard.d.ts.map +1 -0
  63. package/lib/dsh-adapter/host-egress-guard.js +213 -0
  64. package/lib/dsh-adapter/host-llm-trace.d.ts +198 -0
  65. package/lib/dsh-adapter/host-llm-trace.d.ts.map +1 -0
  66. package/lib/dsh-adapter/host-llm-trace.js +344 -0
  67. package/lib/dsh-adapter/http-api-routes.d.ts +61 -0
  68. package/lib/dsh-adapter/http-api-routes.d.ts.map +1 -0
  69. package/lib/dsh-adapter/http-api-routes.js +79 -0
  70. package/lib/dsh-adapter/http-api-sessions.d.ts +105 -0
  71. package/lib/dsh-adapter/http-api-sessions.d.ts.map +1 -0
  72. package/lib/dsh-adapter/http-api-sessions.js +173 -0
  73. package/lib/dsh-adapter/http-api.d.ts +221 -0
  74. package/lib/dsh-adapter/http-api.d.ts.map +1 -0
  75. package/lib/dsh-adapter/http-api.js +1811 -0
  76. package/lib/dsh-adapter/index.d.ts +62 -0
  77. package/lib/dsh-adapter/index.d.ts.map +1 -0
  78. package/lib/dsh-adapter/index.js +97 -0
  79. package/lib/dsh-adapter/input-guard-messages.d.ts +56 -0
  80. package/lib/dsh-adapter/input-guard-messages.d.ts.map +1 -0
  81. package/lib/dsh-adapter/input-guard-messages.js +136 -0
  82. package/lib/dsh-adapter/input-guard.d.ts +508 -0
  83. package/lib/dsh-adapter/input-guard.d.ts.map +1 -0
  84. package/lib/dsh-adapter/input-guard.js +2174 -0
  85. package/lib/dsh-adapter/intent-prompts.d.ts +77 -0
  86. package/lib/dsh-adapter/intent-prompts.d.ts.map +1 -0
  87. package/lib/dsh-adapter/intent-prompts.js +97 -0
  88. package/lib/dsh-adapter/intent-router.d.ts +710 -0
  89. package/lib/dsh-adapter/intent-router.d.ts.map +1 -0
  90. package/lib/dsh-adapter/intent-router.js +2802 -0
  91. package/lib/dsh-adapter/intent-signals.d.ts +208 -0
  92. package/lib/dsh-adapter/intent-signals.d.ts.map +1 -0
  93. package/lib/dsh-adapter/intent-signals.js +391 -0
  94. package/lib/dsh-adapter/invariant-install.d.ts +16 -0
  95. package/lib/dsh-adapter/invariant-install.d.ts.map +1 -0
  96. package/lib/dsh-adapter/invariant-install.js +15 -0
  97. package/lib/dsh-adapter/invariant.d.ts +16 -0
  98. package/lib/dsh-adapter/invariant.d.ts.map +1 -0
  99. package/lib/dsh-adapter/invariant.js +22 -0
  100. package/lib/dsh-adapter/llm-adapters.d.ts +19 -0
  101. package/lib/dsh-adapter/llm-adapters.d.ts.map +1 -0
  102. package/lib/dsh-adapter/llm-adapters.js +16 -0
  103. package/lib/dsh-adapter/llm-internal-caller.d.ts +92 -0
  104. package/lib/dsh-adapter/llm-internal-caller.d.ts.map +1 -0
  105. package/lib/dsh-adapter/llm-internal-caller.js +100 -0
  106. package/lib/dsh-adapter/llm-max-turns.d.ts +55 -0
  107. package/lib/dsh-adapter/llm-max-turns.d.ts.map +1 -0
  108. package/lib/dsh-adapter/llm-max-turns.js +127 -0
  109. package/lib/dsh-adapter/llm-trace-store.d.ts +131 -0
  110. package/lib/dsh-adapter/llm-trace-store.d.ts.map +1 -0
  111. package/lib/dsh-adapter/llm-trace-store.js +205 -0
  112. package/lib/dsh-adapter/llm-trace.d.ts +450 -0
  113. package/lib/dsh-adapter/llm-trace.d.ts.map +1 -0
  114. package/lib/dsh-adapter/llm-trace.js +463 -0
  115. package/lib/dsh-adapter/local-llm-adapter.d.ts +141 -0
  116. package/lib/dsh-adapter/local-llm-adapter.d.ts.map +1 -0
  117. package/lib/dsh-adapter/local-llm-adapter.js +787 -0
  118. package/lib/dsh-adapter/local-storage.d.ts +96 -0
  119. package/lib/dsh-adapter/local-storage.d.ts.map +1 -0
  120. package/lib/dsh-adapter/local-storage.js +198 -0
  121. package/lib/dsh-adapter/model-router.d.ts +79 -0
  122. package/lib/dsh-adapter/model-router.d.ts.map +1 -0
  123. package/lib/dsh-adapter/model-router.js +107 -0
  124. package/lib/dsh-adapter/ohttp-gateway.d.ts +49 -0
  125. package/lib/dsh-adapter/ohttp-gateway.d.ts.map +1 -0
  126. package/lib/dsh-adapter/ohttp-gateway.js +32 -0
  127. package/lib/dsh-adapter/openai-sse.d.ts +85 -0
  128. package/lib/dsh-adapter/openai-sse.d.ts.map +1 -0
  129. package/lib/dsh-adapter/openai-sse.js +462 -0
  130. package/lib/dsh-adapter/pii-utils.d.ts +96 -0
  131. package/lib/dsh-adapter/pii-utils.d.ts.map +1 -0
  132. package/lib/dsh-adapter/pii-utils.js +324 -0
  133. package/lib/dsh-adapter/plugin-host.d.ts +38 -0
  134. package/lib/dsh-adapter/plugin-host.d.ts.map +1 -0
  135. package/lib/dsh-adapter/plugin-host.js +38 -0
  136. package/lib/dsh-adapter/plugin.d.ts +46 -0
  137. package/lib/dsh-adapter/plugin.d.ts.map +1 -0
  138. package/lib/dsh-adapter/plugin.js +100 -0
  139. package/lib/dsh-adapter/privacy-policy.d.ts +237 -0
  140. package/lib/dsh-adapter/privacy-policy.d.ts.map +1 -0
  141. package/lib/dsh-adapter/privacy-policy.js +391 -0
  142. package/lib/dsh-adapter/prompt-guard.d.ts +152 -0
  143. package/lib/dsh-adapter/prompt-guard.d.ts.map +1 -0
  144. package/lib/dsh-adapter/prompt-guard.js +872 -0
  145. package/lib/dsh-adapter/realm-skill-bridge.d.ts +365 -0
  146. package/lib/dsh-adapter/realm-skill-bridge.d.ts.map +1 -0
  147. package/lib/dsh-adapter/realm-skill-bridge.js +1180 -0
  148. package/lib/dsh-adapter/sanitizer-pipeline.d.ts +140 -0
  149. package/lib/dsh-adapter/sanitizer-pipeline.d.ts.map +1 -0
  150. package/lib/dsh-adapter/sanitizer-pipeline.js +431 -0
  151. package/lib/dsh-adapter/secure-bridge.d.ts +47 -0
  152. package/lib/dsh-adapter/secure-bridge.d.ts.map +1 -0
  153. package/lib/dsh-adapter/secure-bridge.js +35 -0
  154. package/lib/dsh-adapter/session-attribution.d.ts +70 -0
  155. package/lib/dsh-adapter/session-attribution.d.ts.map +1 -0
  156. package/lib/dsh-adapter/session-attribution.js +76 -0
  157. package/lib/dsh-adapter/sessions.d.ts +86 -0
  158. package/lib/dsh-adapter/sessions.d.ts.map +1 -0
  159. package/lib/dsh-adapter/sessions.js +179 -0
  160. package/lib/dsh-adapter/tcm.d.ts +55 -0
  161. package/lib/dsh-adapter/tcm.d.ts.map +1 -0
  162. package/lib/dsh-adapter/tcm.js +94 -0
  163. package/lib/dsh-adapter/td-bus.d.ts +87 -0
  164. package/lib/dsh-adapter/td-bus.d.ts.map +1 -0
  165. package/lib/dsh-adapter/td-bus.js +196 -0
  166. package/lib/dsh-adapter/tool-runner.d.ts +185 -0
  167. package/lib/dsh-adapter/tool-runner.d.ts.map +1 -0
  168. package/lib/dsh-adapter/tool-runner.js +271 -0
  169. package/lib/dsh-adapter/tools.d.ts +118 -0
  170. package/lib/dsh-adapter/tools.d.ts.map +1 -0
  171. package/lib/dsh-adapter/tools.js +292 -0
  172. package/lib/dsh-adapter/types.d.ts +23 -0
  173. package/lib/dsh-adapter/types.d.ts.map +1 -0
  174. package/lib/dsh-adapter/types.js +16 -0
  175. package/lib/dsh-adapter/voice-input.d.ts +30 -0
  176. package/lib/dsh-adapter/voice-input.d.ts.map +1 -0
  177. package/lib/dsh-adapter/voice-input.js +34 -0
  178. package/lib/dsh-adapter/web-search-tavily-bocha.d.ts +152 -0
  179. package/lib/dsh-adapter/web-search-tavily-bocha.d.ts.map +1 -0
  180. package/lib/dsh-adapter/web-search-tavily-bocha.js +289 -0
  181. package/lib/http-api.d.ts +2 -0
  182. package/lib/http-api.d.ts.map +1 -0
  183. package/lib/http-api.js +2 -0
  184. package/lib/index.d.ts +48 -0
  185. package/lib/index.d.ts.map +1 -0
  186. package/lib/index.js +49 -0
  187. package/lib/intent-router.d.ts +2 -0
  188. package/lib/intent-router.d.ts.map +1 -0
  189. package/lib/intent-router.js +2 -0
  190. package/lib/llm-adapters.d.ts +2 -0
  191. package/lib/llm-adapters.d.ts.map +1 -0
  192. package/lib/llm-adapters.js +2 -0
  193. package/lib/local-llm-adapter.d.ts +2 -0
  194. package/lib/local-llm-adapter.d.ts.map +1 -0
  195. package/lib/local-llm-adapter.js +2 -0
  196. package/lib/local-storage.d.ts +2 -0
  197. package/lib/local-storage.d.ts.map +1 -0
  198. package/lib/local-storage.js +2 -0
  199. package/lib/model-router.d.ts +2 -0
  200. package/lib/model-router.d.ts.map +1 -0
  201. package/lib/model-router.js +2 -0
  202. package/lib/ohttp-gateway.d.ts +2 -0
  203. package/lib/ohttp-gateway.d.ts.map +1 -0
  204. package/lib/ohttp-gateway.js +2 -0
  205. package/lib/plugin-host.d.ts +2 -0
  206. package/lib/plugin-host.d.ts.map +1 -0
  207. package/lib/plugin-host.js +2 -0
  208. package/lib/privacy-policy.d.ts +2 -0
  209. package/lib/privacy-policy.d.ts.map +1 -0
  210. package/lib/privacy-policy.js +2 -0
  211. package/lib/prompt-guard.d.ts +2 -0
  212. package/lib/prompt-guard.d.ts.map +1 -0
  213. package/lib/prompt-guard.js +2 -0
  214. package/lib/sanitizer-pipeline.d.ts +2 -0
  215. package/lib/sanitizer-pipeline.d.ts.map +1 -0
  216. package/lib/sanitizer-pipeline.js +2 -0
  217. package/lib/secure-bridge.d.ts +2 -0
  218. package/lib/secure-bridge.d.ts.map +1 -0
  219. package/lib/secure-bridge.js +2 -0
  220. package/lib/sessions.d.ts +2 -0
  221. package/lib/sessions.d.ts.map +1 -0
  222. package/lib/sessions.js +2 -0
  223. package/lib/tcm.d.ts +2 -0
  224. package/lib/tcm.d.ts.map +1 -0
  225. package/lib/tcm.js +2 -0
  226. package/lib/td-bus.d.ts +2 -0
  227. package/lib/td-bus.d.ts.map +1 -0
  228. package/lib/td-bus.js +2 -0
  229. package/lib/tools.d.ts +2 -0
  230. package/lib/tools.d.ts.map +1 -0
  231. package/lib/tools.js +2 -0
  232. package/lib/voice-input.d.ts +2 -0
  233. package/lib/voice-input.d.ts.map +1 -0
  234. package/lib/voice-input.js +2 -0
  235. package/package.json +253 -3
  236. package/presets/agent-preset-edge.json +50 -0
  237. package/presets/edge.desktop-safe.patch.yml +503 -0
  238. package/presets/edge.patch.yml +557 -0
@@ -0,0 +1,1180 @@
1
+ /**
2
+ * realm-skill-bridge — Realm-aware skill and tool isolation for dsh-edge-agent.
3
+ *
4
+ * Reads a user-editable JSON config (`skill-realms.json`) that maps skill/tool
5
+ * names (or glob patterns) to LOCAL | CLOUD realms. Tags every discovered skill
6
+ * and tool accordingly, then enforces the mapping at three enforcement points:
7
+ *
8
+ * Layer 1 (agent/pre-step): filter the <available_skills> catalog so each
9
+ * agent only sees skills in its own realm. Skills tagged `hidden` are
10
+ * invisible to every agent.
11
+ *
12
+ * Layer 1.5-replacement (system-prompt/assemble): filter the tool schema
13
+ * list BEFORE dsh-agent-loop wraps it in deepFreeze and passes to the
14
+ * LLM. Replaces the deleted `llm/stream` tools probe — `payload.tools`
15
+ * at llm/stream is already frozen (dsh-agent-loop/lib/index.js:729
16
+ * `markAgentLoopRequest(deepFreeze({ ... tools: header.tools, ... }))`)
17
+ * so splice/defineProperty mutations silently fail there. The
18
+ * system-prompt/assemble waterfall value IS authoritative (per the dsh
19
+ * type declaration: "the returned value is authoritative") and tools are
20
+ * a plain array at that point. After this layer the LLM never sees
21
+ * out-of-realm tools.
22
+ *
23
+ * Layer 3 (tools/pre-execute): deny tool execution when the tool's realm
24
+ * does not match the calling agent's realm (defense-in-depth — also
25
+ * catches tools added via runtime registrations that bypass assembly).
26
+ *
27
+ * The config file is hot-reloaded via stat-polling (same pattern as
28
+ * privacy-policy.ts) so changes take effect without a restart.
29
+ *
30
+ * @module dsh-edge-agent/realm-skill-bridge
31
+ */
32
+ import { existsSync } from 'node:fs';
33
+ import { copyFile, readFile, stat } from 'node:fs/promises';
34
+ import { dirname, resolve } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { Service } from '@deepseek-ai/cordis';
37
+ import Schema from '@deepseek-ai/schemastery';
38
+ import { edgeHome, loadEdgeConfig } from './edge-config.js';
39
+ import { sessionIdField } from './session-attribution.js';
40
+ export const name = 'realm-skill-bridge';
41
+ // §7.92.2 (2026-09-22): 必须声明全部 soft deps — `edgeTools` + `tools` 是
42
+ // tagToolsFromConfig() 实际读取的 ctx 属性。cordis Proxy 在未声明时会
43
+ // 静默 throw "cannot get property X without inject",原 silent catch 吞
44
+ // 掉导致 Layer 1.5-replacement 装死 1 天。必须跟 cordis.patch.yml 一致。
45
+ export const inject = ['auditLog', 'skills', 'edgeTools', 'tools'];
46
+ export const Config = Schema.object({
47
+ // §7.102 "one config file": no defaults here — apply() fills configPath
48
+ // from config/edge.json (privacy.realmConfigPath) or the
49
+ // ~/.dsh-edge-agent/skill-realms.json default, and the hot-reload knobs
50
+ // from privacy.realmHotReload / realmWatchMs. (The old computed default
51
+ // froze the HOME lookup at module-load time.)
52
+ configPath: Schema.string(),
53
+ hotReload: Schema.boolean(),
54
+ watchIntervalMs: Schema.natural(),
55
+ });
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+ // Glob matching
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+ /**
60
+ * Minimal glob matcher — supports `*` as "any sequence of characters".
61
+ * We do not pull in the `minimatch` dependency for this; the only patterns
62
+ * used in skill-realms.json are simple suffix globs like "lark-*".
63
+ */
64
+ export function globMatch(pattern, value) {
65
+ if (!pattern.includes('*'))
66
+ return pattern === value;
67
+ // Escape regex-special chars (except `*` which we convert to `.*`).
68
+ const regex = new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
69
+ return regex.test(value);
70
+ }
71
+ // ─────────────────────────────────────────────────────────────────────────────
72
+ // Config loading
73
+ // ─────────────────────────────────────────────────────────────────────────────
74
+ /**
75
+ * 把工具返回的 error message 变成一行可读的摘要。
76
+ *
77
+ * §7.151(2026-10-29)修:原来只有 `typeof message === 'string'` 才记内容,
78
+ * 否则一律打 `'unknown'`。但宿主的工具失败**大多数不是字符串** ——
79
+ * `deepseek-harness/docs/subsystems/user-questions.md:212` 明确写着
80
+ * `UserQuestionError` 继承 `HarnessError`,`ctx.tools.execute()` 保留
81
+ * `{ name, code }`(`NO_PROVIDER` / `BAD_INTENT` / `EMPTY_QUESTIONS` /
82
+ * `ASK_ABORTED`)。这些是**对象**,于是每一次这类失败在 ops log 里都只留下
83
+ * `errorKind=unknown` —— 真实错误码被吞掉,排查时无从下手
84
+ * (本轮就是这样误判了 ask_user_question 的失败原因)。
85
+ *
86
+ * 现在:字符串直接截断;对象/数组 JSON 序列化后截断;取不到就退回
87
+ * `String(message)`。**信息不能因为形态不是字符串就丢。**
88
+ */
89
+ export function describeToolError(message) {
90
+ if (typeof message === 'string')
91
+ return message.slice(0, 100);
92
+ if (message === undefined || message === null)
93
+ return 'unknown';
94
+ try {
95
+ const json = JSON.stringify(message);
96
+ if (typeof json === 'string' && json.length > 0)
97
+ return json.slice(0, 100);
98
+ }
99
+ catch { /* 循环引用 / 不可序列化 → 退回 String() */ }
100
+ try {
101
+ return String(message).slice(0, 100);
102
+ }
103
+ catch {
104
+ return 'unknown';
105
+ }
106
+ }
107
+ /**
108
+ * 默认屏蔽清单(§7.150, 2026-10-29)。
109
+ *
110
+ * 这些是**宿主自带、但在 dsh 会话里要么无意义、要么必然失败**的工具。
111
+ * 实机取证(本机审计链 + ops log):
112
+ *
113
+ * | 工具 | 现象 | 为什么挡 |
114
+ * |---|---|---|
115
+ * | `get_goal` | 14:05 主人发「登录的密盒」,模型调它 → **返回空** → 模型把「我在循环调它拿到 null」的自白当最终回复输出 | Goal 族的契约是「没设 Goal 就是空」。dsh 的普通会话没有 Goal ⇒ 必然空。模型在 27 个工具里挑「查状态」时误选它 |
116
+ * | `create_goal` / `update_goal` | 14:28 换 qwen3.8-flash 后仍误选 `update_goal` → **执行报错** `status=error errorKind=unknown` | 同族。dsh 会话不建 Goal,这两个要么无意义要么报错 |
117
+ * | `ask_user_question` | 14:30 模型调它两次 → **执行报错** `status=error errorKind=unknown` | §7.150 初稿把这条归因为「模型传了 `{"text":...}`、参数不匹配」—— **那是误判**:`{text:…}` 是 `TraceText` 的**字段名**(装的是 arguments 字符串),模型实际传的是 `{"questions":[…]}`,**格式正确**。真因是宿主侧 `UserQuestionError`(带 `{name, code}`)⇒ 工具在桌面版执行失败;而 `errorKind=unknown` 是**本插件日志自己打的占位符**(只认字符串 message),真实错误码被吞(§7.151 修)。**屏蔽本身仍然成立**:工具确实连败两次,挡住比让它反复重试干净 |
118
+ *
119
+ * 挡掉之后 agent 改用**纯文本**提问 —— 实测可行:14:30 那轮模型直接输出了
120
+ * 完整的密盒列表 + 「或者告诉我要登录哪台设备」。
121
+ *
122
+ * **这份清单是默认值,不是不可改的**:`skill-realms.json` 里显式写
123
+ * `"get_goal": "LOCAL"` 就能放开(config 覆盖 default,见 `loadConfig` 与
124
+ * `tagToolsFromConfig` 的 config 循环)。
125
+ */
126
+ const DEFAULT_CONFIG = {
127
+ skills: {},
128
+ tools: {
129
+ get_goal: 'hidden',
130
+ create_goal: 'hidden',
131
+ update_goal: 'hidden',
132
+ ask_user_question: 'hidden',
133
+ },
134
+ defaultRealm: 'hidden',
135
+ };
136
+ /**
137
+ * Read and parse skill-realms.json. Returns DEFAULT_CONFIG on any failure
138
+ * (missing file, invalid JSON, schema error) so the plugin still boots —
139
+ * everything just becomes hidden until the user creates/fixes the file.
140
+ */
141
+ async function loadConfig(configPath) {
142
+ try {
143
+ const raw = await readFile(configPath, 'utf-8');
144
+ const parsed = JSON.parse(raw);
145
+ return {
146
+ skills: parsed.skills && typeof parsed.skills === 'object' ? parsed.skills : {},
147
+ tools: {
148
+ // §7.150: 默认屏蔽清单与文件**合并**,文件优先。
149
+ //
150
+ // 合并而不是替换,是为了两件事同时成立:
151
+ // 1. 没写 skill-realms.json 的机器也能拿到默认屏蔽(修一次,全机器生效);
152
+ // 2. 写了文件的机器能**逐个放开** —— `"get_goal": "LOCAL"` 即可。
153
+ //
154
+ // 「放开」还需要 config 循环把 default 的 'hidden' 移除,否则
155
+ // Set{hidden, LOCAL} 里 hidden 仍在 ⇒ 照样被挡(见 tagToolsFromConfig)。
156
+ ...DEFAULT_CONFIG.tools,
157
+ ...(parsed.tools && typeof parsed.tools === 'object' ? parsed.tools : {}),
158
+ },
159
+ defaultRealm: isValidRealm(parsed.defaultRealm) ? parsed.defaultRealm : 'hidden',
160
+ };
161
+ }
162
+ catch {
163
+ return { ...DEFAULT_CONFIG };
164
+ }
165
+ }
166
+ function isValidRealm(value) {
167
+ return value === 'LOCAL' || value === 'CLOUD' || value === 'SANITIZED' || value === 'hidden';
168
+ }
169
+ /**
170
+ * §7.47.6 (2026-09-13): serialize the `turnEnds` object from dsh-agent-loop
171
+ * `turn/end` event into a compact ops-log-friendly string.
172
+ *
173
+ * Background: dsh-agent-loop appends `turnEnds = { kind, ...payload }` where
174
+ * kind ∈ { 'completed', 'max-tokens', 'blocked', 'aborted', 'unknown' }.
175
+ * For 'aborted' it also carries `reason: <AbortSignal.reason>`. The pre-fix
176
+ * code dropped any non-string to 'unknown' — silently hiding the actual
177
+ * termination cause. Now: stringify in `kind=key=value` form so the ops log
178
+ * reveals e.g. `kind=aborted reason=signal-aborted` or `kind=max-tokens`.
179
+ *
180
+ * Falls back to 'unknown' only when dsh-agent-loop emits no reason at all
181
+ * (truly undefined — should never happen in normal flow but guard anyway).
182
+ */
183
+ function serializeTurnEndReason(reason) {
184
+ if (typeof reason === 'string')
185
+ return reason;
186
+ if (reason === null || reason === undefined)
187
+ return 'unknown';
188
+ if (typeof reason !== 'object')
189
+ return `unknown(${String(reason)})`;
190
+ const obj = reason;
191
+ const kind = typeof obj.kind === 'string' ? obj.kind : 'unknown';
192
+ // Pull the 2 most useful fields besides kind. For 'aborted', include the
193
+ // nested reason text so ops log shows what actually aborted the signal.
194
+ const parts = [`kind=${kind}`];
195
+ if (obj.reason !== undefined) {
196
+ const r = obj.reason;
197
+ if (typeof r === 'string')
198
+ parts.push(`reason=${r}`);
199
+ else if (r !== null && typeof r === 'object') {
200
+ const rkind = r.kind;
201
+ if (typeof rkind === 'string')
202
+ parts.push(`reason.kind=${rkind}`);
203
+ }
204
+ }
205
+ if (typeof obj.tokenCount === 'number')
206
+ parts.push(`tokenCount=${obj.tokenCount}`);
207
+ return parts.join(' ');
208
+ }
209
+ /** Copy the bundled default template to configPath if the file does not exist. */
210
+ async function ensureDefaultConfig(configPath) {
211
+ if (existsSync(configPath))
212
+ return;
213
+ // The template lives next to the compiled JS: <repo>/templates/skill-realms.default.json.
214
+ // At runtime this plugin is at <repo>/src/dsh-adapter/realm-skill-bridge.ts → compiled to
215
+ // dist/dsh-adapter/realm-skill-bridge.js. The repo root is three levels up from dist.
216
+ let templatePath;
217
+ try {
218
+ const thisFile = fileURLToPath(import.meta.url);
219
+ const repoRoot = resolve(dirname(thisFile), '..', '..', '..');
220
+ templatePath = resolve(repoRoot, 'templates', 'skill-realms.default.json');
221
+ }
222
+ catch {
223
+ return;
224
+ }
225
+ if (!existsSync(templatePath))
226
+ return;
227
+ try {
228
+ const dir = dirname(configPath);
229
+ const { mkdir } = await import('node:fs/promises');
230
+ await mkdir(dir, { recursive: true });
231
+ await copyFile(templatePath, configPath);
232
+ }
233
+ catch {
234
+ // Best-effort bootstrap; non-fatal.
235
+ }
236
+ }
237
+ // ─────────────────────────────────────────────────────────────────────────────
238
+ // Service
239
+ // ─────────────────────────────────────────────────────────────────────────────
240
+ export class RealmSkillBridgeService extends Service {
241
+ config;
242
+ auditLog;
243
+ /** Skill name → realm tag (including 'hidden'). */
244
+ skillRealms = new Map();
245
+ /** Tool name → set of realm tags. A tool can belong to multiple realms. */
246
+ toolRealms = new Map();
247
+ /** Session id → realm. Written by http-api after intent classification. */
248
+ sessionRealms = new Map();
249
+ /** sessionRealms 的 LRU 上限;活跃会话远小于此(http-api 默认 8)。 */
250
+ static SESSION_REALMS_CAP = 512;
251
+ /** Parsed realm config. Refreshed on hot-reload. */
252
+ realmConfig = { ...DEFAULT_CONFIG };
253
+ /** mtime of the config file at last load (for stat-polling). */
254
+ lastMtime = 0;
255
+ /** Hot-reload timer handle. */
256
+ watchTimer;
257
+ constructor(ctx, config, auditLog) {
258
+ super(ctx, 'realmSkillBridge');
259
+ this.config = config;
260
+ this.auditLog = auditLog;
261
+ }
262
+ /** Boot: ensure config file exists, load it, tag skills/tools. */
263
+ async start() {
264
+ await ensureDefaultConfig(this.config.configPath);
265
+ await this.reloadConfig();
266
+ await this.tagSkillsAsync();
267
+ this.tagToolsFromConfig();
268
+ if (this.config.hotReload) {
269
+ this.watchTimer = setInterval(() => {
270
+ void this.checkForUpdates();
271
+ }, this.config.watchIntervalMs);
272
+ if (typeof this.watchTimer.unref === 'function')
273
+ this.watchTimer.unref();
274
+ }
275
+ }
276
+ /** Stop hot-reload timer. */
277
+ stop() {
278
+ if (this.watchTimer !== undefined) {
279
+ clearInterval(this.watchTimer);
280
+ this.watchTimer = undefined;
281
+ }
282
+ }
283
+ /** Drop all per-session realm tags (plugin dispose path). */
284
+ clearSessionRealms() {
285
+ this.sessionRealms.clear();
286
+ }
287
+ // ── Public API ──────────────────────────────────────────────────────────────
288
+ /** Set the realm for a session (called by http-api after intent classification). */
289
+ setSessionRealm(sessionId, realm) {
290
+ if (realm === 'hidden')
291
+ return;
292
+ // LRU:delete-then-set 让最近路由的 session 排队尾;超限淘汰队头。
293
+ // 该 Map 曾只增不减(每 session 一条,进程生命周期内累积),
294
+ // 同文件的 sessionStartTimes 反而在 turn/end 有 delete —— 补齐。
295
+ const map = this.sessionRealms;
296
+ map.delete(sessionId);
297
+ map.set(sessionId, realm);
298
+ while (map.size > RealmSkillBridgeService.SESSION_REALMS_CAP) {
299
+ const oldest = map.keys().next().value;
300
+ if (oldest === undefined)
301
+ break;
302
+ map.delete(oldest);
303
+ }
304
+ }
305
+ /** Resolve the realm for an agent by looking up its session.
306
+ * §7.36 / §7.37 (2026-09-04): prefer `session.id` — input-guard registers
307
+ * via `payload.agent.session.id` (input-guard.ts:537) and that is the
308
+ * only key under which sessionRealms holds the realm. Reading
309
+ * `session.header.id` first was a wrong assumption that caused layer-1.5
310
+ * (llm/stream tools filter) to silently no-op: getAgentRealm returned
311
+ * undefined → filterToolsField skipped → tools.filtered never fired. */
312
+ getAgentRealm(agent) {
313
+ const sessionId = agent?.session?.id;
314
+ if (!sessionId)
315
+ return undefined;
316
+ return this.sessionRealms.get(sessionId);
317
+ }
318
+ /**
319
+ * Resolve realm directly from a sessionId string. Used by llm/stream hook
320
+ * where the agent's session.id path is unreliable and payload.sessionId
321
+ * is the canonical key.
322
+ */
323
+ getSessionRealm(sessionId) {
324
+ if (!sessionId)
325
+ return undefined;
326
+ return this.sessionRealms.get(sessionId);
327
+ }
328
+ /** Look up the realm tag for a skill name.
329
+ * Prefers the cached tag (populated by tagSkills on skills/change), but
330
+ * falls back to resolveSkillRealm so skills not yet discovered still get
331
+ * the correct realm from the config file. */
332
+ skillRealm(name) {
333
+ return this.skillRealms.get(name) ?? this.resolveSkillRealm(name);
334
+ }
335
+ /** Look up the realm tags for a tool name. Returns undefined if no realm
336
+ * constraint is configured (tool is unconstrained). */
337
+ toolRealm(name) {
338
+ return this.toolRealms.get(name);
339
+ }
340
+ // ── Config reload ───────────────────────────────────────────────────────────
341
+ async reloadConfig() {
342
+ const path = this.config.configPath;
343
+ if (!existsSync(path)) {
344
+ this.realmConfig = { ...DEFAULT_CONFIG };
345
+ this.lastMtime = 0;
346
+ return;
347
+ }
348
+ try {
349
+ const stats = await stat(path);
350
+ this.realmConfig = await loadConfig(path);
351
+ this.lastMtime = stats.mtimeMs;
352
+ }
353
+ catch {
354
+ // Parse failure — keep previous config.
355
+ }
356
+ }
357
+ async checkForUpdates() {
358
+ const path = this.config.configPath;
359
+ if (!existsSync(path))
360
+ return;
361
+ try {
362
+ const stats = await stat(path);
363
+ if (stats.mtimeMs > this.lastMtime) {
364
+ await this.reloadConfig();
365
+ this.tagSkills();
366
+ this.tagToolsFromConfig();
367
+ this.ctx.logger?.info?.('[realm-skill-bridge] config reloaded');
368
+ }
369
+ }
370
+ catch {
371
+ // stat error — ignore, will retry next tick.
372
+ }
373
+ }
374
+ // ── Realm resolution ────────────────────────────────────────────────────────
375
+ /**
376
+ * Resolve the realm for a skill by name.
377
+ * Priority:
378
+ * 1. skill-realms.json exact name match
379
+ * 2. skill-realms.json glob match (in declaration order)
380
+ * 3. defaultRealm
381
+ *
382
+ * Note: SKILL.md frontmatter `metadata.realm` is intentionally NOT consumed
383
+ * here — the config file is the single source of truth. This avoids a
384
+ * dependency on `ctx.skills.get()` (which reads the full markdown body)
385
+ * during the hot-path tag loop.
386
+ */
387
+ resolveSkillRealm(name) {
388
+ const cfg = this.realmConfig.skills;
389
+ // Exact match
390
+ if (cfg[name] !== undefined)
391
+ return cfg[name];
392
+ // Glob match (declaration order)
393
+ for (const pattern of Object.keys(cfg)) {
394
+ if (pattern.includes('*') && globMatch(pattern, name)) {
395
+ return cfg[pattern];
396
+ }
397
+ }
398
+ return this.realmConfig.defaultRealm;
399
+ }
400
+ // ── Tagging ─────────────────────────────────────────────────────────────────
401
+ /** Re-scan ctx.skills.list() and tag each skill by realm. */
402
+ tagSkills() {
403
+ this.skillRealms.clear();
404
+ // ctx.skills.list() is async; we fire-and-forget here because tagging
405
+ // runs on a timer and on skills/change events. The next tick will
406
+ // converge. For the initial boot, apply() awaits this separately.
407
+ void (async () => {
408
+ try {
409
+ const list = await this.ctx.skills.list();
410
+ for (const skill of list) {
411
+ const realm = this.resolveSkillRealm(skill.name);
412
+ this.skillRealms.set(skill.name, realm);
413
+ }
414
+ this.logTaggingResult();
415
+ }
416
+ catch {
417
+ // skills service not yet injected — will retry on next tick or skills/change.
418
+ }
419
+ })();
420
+ }
421
+ /** Synchronous variant for await contexts. */
422
+ async tagSkillsAsync() {
423
+ this.skillRealms.clear();
424
+ try {
425
+ // Debug: check if ctx.skills exists
426
+ const skillsService = this.ctx.skills;
427
+ this.ctx.logger?.info?.(`[realm-skill-bridge] ctx.skills exists: ${skillsService !== undefined}`);
428
+ // Debug: check ctx.skills methods
429
+ if (skillsService && typeof skillsService === 'object') {
430
+ const methods = Object.keys(skillsService);
431
+ this.ctx.logger?.info?.(`[realm-skill-bridge] ctx.skills methods: ${methods.join(', ')}`);
432
+ }
433
+ // Try calling list() with and without cwd
434
+ const list = await this.ctx.skills.list();
435
+ this.ctx.logger?.info?.(`[realm-skill-bridge] ctx.skills.list() returned ${list.length} skills`);
436
+ if (list.length > 0) {
437
+ this.ctx.logger?.info?.(`[realm-skill-bridge] first 5 skills: ${list.slice(0, 5).map(s => s.name).join(', ')}`);
438
+ }
439
+ // Try with cwd parameter
440
+ try {
441
+ const listWithCwd = await this.ctx.skills.list({ cwd: process.cwd() });
442
+ this.ctx.logger?.info?.(`[realm-skill-bridge] ctx.skills.list({cwd}) returned ${listWithCwd.length} skills`);
443
+ }
444
+ catch (err) {
445
+ this.ctx.logger?.info?.(`[realm-skill-bridge] ctx.skills.list({cwd}) error: ${err}`);
446
+ }
447
+ for (const skill of list) {
448
+ const realm = this.resolveSkillRealm(skill.name);
449
+ this.skillRealms.set(skill.name, realm);
450
+ }
451
+ }
452
+ catch (err) {
453
+ this.ctx.logger?.info?.(`[realm-skill-bridge] tagSkillsAsync error: ${err}`);
454
+ }
455
+ this.logTaggingResult();
456
+ }
457
+ logTaggingResult() {
458
+ const visible = [...this.skillRealms.entries()].filter(([, r]) => r !== 'hidden');
459
+ const hidden = this.skillRealms.size - visible.length;
460
+ const summary = visible.map(([n, r]) => `${n}=${r}`).join(', ');
461
+ this.ctx.logger?.info?.(`[realm-skill-bridge] ${this.skillRealms.size} skills (${hidden} hidden): ${summary || '(none)'}`);
462
+ }
463
+ /** Apply tool realm tags from skill-realms.json to ctx.edgeTools. */
464
+ tagToolsFromConfig() {
465
+ let edgeTools;
466
+ try {
467
+ edgeTools = this.ctx.edgeTools;
468
+ }
469
+ catch {
470
+ // Cordis proxy throws when edgeTools is not injected — that's fine,
471
+ // we just skip the edgeTools.setRealm() calls.
472
+ }
473
+ this.toolRealms.clear();
474
+ // §7.92.2 (2026-09-22): seed from native map (DSH_NATIVE_REALM_MAP /
475
+ // DSH_NATIVE_MULTI_REALM, applied to edgeTools at boot by
476
+ // edge-tools-registry) BEFORE the config loop so config wins on conflict.
477
+ // Without this seed, filterToolsField would never filter anything when
478
+ // user hasn't written skill-realms.json yet — web_search / web_fetch
479
+ // would stay visible to LOCAL agents (defeating the purpose of Layer
480
+ // 1.5-replacement). The config loop below adds more tags on top.
481
+ //
482
+ // Requires `tools` + `edgeTools` in plugin inject list (see
483
+ // cordis.patch.yml) — the cordis proxy throws "cannot get property X
484
+ // without inject" if a plugin reads a service it didn't declare.
485
+ if (edgeTools) {
486
+ const nativeResolver = edgeTools;
487
+ // Walk every tool known to edgeTools, ask it for native realms,
488
+ // and copy into this.toolRealms.
489
+ try {
490
+ const allTools = this.ctx.tools?.view?.()?.visible;
491
+ if (allTools instanceof Map) {
492
+ for (const name of allTools.keys()) {
493
+ // 1. explicit setRealm (Set<ToolRealm>)
494
+ const explicit = nativeResolver.realm?.get(name);
495
+ if (explicit !== undefined && explicit.size > 0) {
496
+ const out = this.toolRealms.get(name) ?? new Set();
497
+ for (const r of explicit) {
498
+ // edgeTools stores string-set realm values; map into RealmTag
499
+ if (r === 'LOCAL' || r === 'CLOUD' || r === 'SANITIZED' || r === 'hidden') {
500
+ out.add(r);
501
+ }
502
+ }
503
+ if (out.size > 0)
504
+ this.toolRealms.set(name, out);
505
+ continue;
506
+ }
507
+ // 2. resolveRealms falls through to classifyByPrefix → CLOUD; we
508
+ // don't want prefix tags (would over-restrict). So only accept
509
+ // values when edgeTools has them via explicit + native map path.
510
+ // realmOf (single value) is the right probe — it returns
511
+ // undefined for prefix-only entries.
512
+ const single = edgeTools.realmOf?.(name);
513
+ if (single === 'LOCAL' || single === 'CLOUD' || single === 'SANITIZED' || single === 'hidden') {
514
+ const out = this.toolRealms.get(name) ?? new Set();
515
+ out.add(single);
516
+ this.toolRealms.set(name, out);
517
+ }
518
+ }
519
+ }
520
+ }
521
+ catch {
522
+ // edgeTools.view() may throw if tools service hasn't mounted yet;
523
+ // tools/change will re-fire this function once it's available.
524
+ }
525
+ }
526
+ for (const [toolName, realm] of Object.entries(this.realmConfig.tools)) {
527
+ // Store realm tag in a Set (supports multi-realm if configured multiple times)
528
+ const existing = this.toolRealms.get(toolName) ?? new Set();
529
+ // §7.150: config 覆盖 default —— 先移除 'hidden'。
530
+ //
531
+ // 不移除的话,DEFAULT_CONFIG 的 'hidden' 与文件里的 'LOCAL' 会并存成
532
+ // Set{hidden, LOCAL},而 hidden 在 filterToolsField / checkToolRealm 里
533
+ // 都是**优先判定**(`toolRealms.has('hidden')`)⇒ 文件根本放不开。
534
+ //
535
+ // 「config wins on conflict」这条注释(见上面 native seed 段)在这里才
536
+ // 真正成立:native seed 的 hidden 同样会被 config 覆盖。
537
+ existing.delete('hidden');
538
+ existing.add(realm);
539
+ this.toolRealms.set(toolName, existing);
540
+ // Only push non-hidden realms to edgeTools (which doesn't understand 'hidden').
541
+ if (edgeTools && (realm === 'LOCAL' || realm === 'CLOUD' || realm === 'SANITIZED')) {
542
+ edgeTools.setRealm(toolName, realm);
543
+ }
544
+ }
545
+ }
546
+ // ── Layer 1: Skill catalog filtering ────────────────────────────────────────
547
+ /**
548
+ * Filter the <available_skills> catalog so the agent only sees skills in
549
+ * its realm. Skills tagged 'hidden' are removed from every agent's catalog.
550
+ *
551
+ * §7.37 (2026-09-04): Two message shapes carry the catalog:
552
+ * (a) standalone UserMessage with source.kind === 'skill-catalog'
553
+ * (string content).
554
+ * (b) dsh-system-prompt plugin snapshot with
555
+ * source.kind === 'plugin' && source.plugin ===
556
+ * '@deepseek-ai/dsh-system-prompt' (array content blocks,
557
+ * one of which is type:'text' containing `<available_skills>`).
558
+ * Both must be filtered or the catalog leaks. (a) is the canonical
559
+ * skill-catalog shape; (b) is what TUI / WebUI actually see because
560
+ * dsh-system-prompt co-locates the catalog with sandbox/approval policy
561
+ * snapshots — without this filter, LLM sees ALL 32 skills regardless
562
+ * of realm.
563
+ *
564
+ * We parse each ` - \`skill-name\`: Description` line, look up the skill's
565
+ * realm, and drop disallowed ones.
566
+ */
567
+ filterSkillCatalog(messages, agentRealm) {
568
+ return this.filterSkillCatalogDetailed(messages, agentRealm).messages;
569
+ }
570
+ /**
571
+ * §7.46 (2026-09-11): 返回 `{ messages, droppedCount }` 让 pre-step
572
+ * listener 能 emit `catalogDropped=N` 到 ops log(§7.44.1 chat-finished
573
+ * line 加 realm/duration 时同步加的;详见 PROGRESS §7.46)。返回 messages
574
+ * 是 filterSkillCatalog 的同一引用,方便测试不破坏。
575
+ */
576
+ filterSkillCatalogDetailed(messages, agentRealm) {
577
+ if (agentRealm === 'hidden')
578
+ return { messages, droppedCount: 0 };
579
+ let totalDropped = 0;
580
+ const newMessages = messages.map((msg) => {
581
+ if (!isCatalogMessage(msg))
582
+ return msg;
583
+ // (a) string content
584
+ if (typeof msg.content === 'string') {
585
+ if (!msg.content.includes('<available_skills>'))
586
+ return msg;
587
+ const result = this.filterCatalogText(msg.content, agentRealm);
588
+ totalDropped += result.droppedCount;
589
+ if (!result.changed)
590
+ return msg;
591
+ return { ...msg, content: result.text };
592
+ }
593
+ // (b) array content blocks (dsh-system-prompt snapshot shape)
594
+ if (Array.isArray(msg.content)) {
595
+ let changed = false;
596
+ const newBlocks = msg.content.map((block) => {
597
+ if (!isTextBlock(block) || typeof block.text !== 'string')
598
+ return block;
599
+ if (!block.text.includes('<available_skills>'))
600
+ return block;
601
+ const result = this.filterCatalogText(block.text, agentRealm);
602
+ totalDropped += result.droppedCount;
603
+ if (!result.changed)
604
+ return block;
605
+ changed = true;
606
+ return { ...block, text: result.text };
607
+ });
608
+ if (!changed)
609
+ return msg;
610
+ return { ...msg, content: newBlocks };
611
+ }
612
+ return msg;
613
+ });
614
+ return { messages: newMessages, droppedCount: totalDropped };
615
+ }
616
+ /**
617
+ * Pure function: parse `<available_skills>` content, drop lines whose
618
+ * skill name resolves to a realm other than `agentRealm` (or 'hidden').
619
+ * Returns `{ text, changed }` so the caller can short-circuit on no-op.
620
+ *
621
+ * §7.45 (2026-09-11): SANITIZED agents see every non-hidden skill —
622
+ * matches `filterToolsField`'s SANITIZED branch below. Before this
623
+ * fix, `skillRealm(name)` only returns `LOCAL`/`CLOUD`/`hidden` (never
624
+ * `'SANITIZED'`), so `realm !== agentRealm` was always true for SANITIZED
625
+ * agents and the entire catalog was dropped (audit observed
626
+ * `droppedCount=33` for a session whose agentRealm=SANITIZED — LLM saw
627
+ * zero skills and silently answered "no mibao tool available"). Skill
628
+ * realm tag is fail-closed (default 'hidden'), so SANITIZED must
629
+ * explicitly opt out of the strict-equality check.
630
+ *
631
+ * §7.47.1 (2026-09-12): Same dual fix for LOCAL/CLOUD. The catalog
632
+ * filter uses `skillRealm` (single realm), but tools are tagged with a
633
+ * realm SET (`toolRealms: Set<RealmTag>`). `bash` skill.realm = 'LOCAL'
634
+ * yet bash.toolRealms = {LOCAL, CLOUD}. Before this fix, a CLOUD agent
635
+ * (e.g. news query) saw every LOCAL skill dropped — `droppedCount=11`
636
+ * for news session, identical to the SANITIZED bug. Symmetric for
637
+ * LOCAL agent: CLOUD skill (e.g. web_search, web_fetch) was dropped.
638
+ * The fix extends SANITIZED's opt-in to ALL agent realms: any
639
+ * non-'hidden' skill is kept in the catalog. Layer 3 (tools/pre-execute)
640
+ * remains the authoritative gate, mirroring `filterToolsField`'s
641
+ * semantics where any non-hidden tool is offered to the LLM and only
642
+ * blocked at execution time.
643
+ */
644
+ filterCatalogText(content, agentRealm) {
645
+ const lines = content.split('\n');
646
+ const out = [];
647
+ let changed = false;
648
+ let droppedCount = 0;
649
+ for (const line of lines) {
650
+ const m = line.match(/^-\s+`([^`]+)`:\s*(.*)$/);
651
+ if (m !== null) {
652
+ const skillName = m[1];
653
+ const realm = this.skillRealm(skillName);
654
+ if (realm === 'hidden') {
655
+ changed = true;
656
+ droppedCount++;
657
+ continue;
658
+ }
659
+ // §7.45 + §7.47.1: ALL agent realms see every non-hidden skill.
660
+ // Layer 3 (tools/pre-execute) is the authoritative realm gate —
661
+ // see filterToolsField line ~578-588 + checkToolRealm line ~612.
662
+ // The catalog is a discovery surface; tool execution is the
663
+ // privacy boundary. Restricting catalog by realm left LLM
664
+ // without knowledge of tools it could otherwise safely call.
665
+ void agentRealm;
666
+ }
667
+ out.push(line);
668
+ }
669
+ if (droppedCount > 0) {
670
+ try {
671
+ this.auditLog?.log?.({
672
+ level: 'info',
673
+ kind: 'realm-skill-bridge.catalog.filtered',
674
+ source: 'realm-skill-bridge',
675
+ payload: { agentRealm, droppedCount },
676
+ });
677
+ }
678
+ catch { /* debug only */ }
679
+ }
680
+ return { text: out.join('\n'), changed, droppedCount };
681
+ }
682
+ // ── Layer 1.5: Filter LLM-visible tools list by agent realm ────────────────
683
+ // §7.37 (2026-09-04): <available_skills> catalog text is injected by
684
+ // dsh-system-prompt plugin BELOW the dsh hook surface — none of
685
+ // agent/pre-step, agent/request, llm/stream sees it. The only tool-level
686
+ // surface in any hook is `payload.tools` on llm/stream, so we filter
687
+ // CLOUD tools out of LLM's view there. LLM doesn't see web_search /
688
+ // web_fetch as available → won't pick them when LOCAL realm is set.
689
+ // Layer 3 (tools/pre-execute) remains the authoritative gate.
690
+ /**
691
+ * Pure function: filter the `tools` array carried on llm/stream payload so
692
+ * that the LLM does not see tools whose realm set does not include
693
+ * `agentRealm`. Tools whose realm is undefined (not in our config) are
694
+ * preserved (fail-closed: we only restrict what we explicitly tagged).
695
+ * Returns `{ tools, changed, removed }` so callers can short-circuit.
696
+ *
697
+ * SANITIZED special case: an agent whose input was sanitized may safely
698
+ * use both LOCAL tools (file ops don't leak) and CLOUD tools (input was
699
+ * already redacted). So SANITIZED agents see every non-hidden tool.
700
+ *
701
+ * §7.93 Phase 2 (§7.93.1 通用化): `options.audit === false` suppresses the
702
+ * `realm-skill-bridge.tools.filtered` audit entry. Used by
703
+ * getToolSchemasForExternal() so the LLM-path filter audit isn't duplicated
704
+ * per external call — function-call-external emits its own `external.*`
705
+ * audit kinds. Default (omitted) keeps the existing audited behavior.
706
+ */
707
+ filterToolsField(tools, agentRealm, options) {
708
+ const kept = [];
709
+ const removed = [];
710
+ for (const t of tools) {
711
+ const name = typeof t === 'string'
712
+ ? t
713
+ : (t?.name
714
+ ?? t?.type?.name
715
+ ?? null);
716
+ if (name === null) {
717
+ kept.push(t);
718
+ continue;
719
+ }
720
+ const toolRealms = this.toolRealm(name);
721
+ // Unknown tool (no realm tag) — pass through; Layer 3 has final say
722
+ if (toolRealms === undefined) {
723
+ kept.push(t);
724
+ continue;
725
+ }
726
+ // hidden tools are never visible
727
+ if (toolRealms.has('hidden')) {
728
+ removed.push(name);
729
+ continue;
730
+ }
731
+ // §7.37: SANITIZED agents see all non-hidden tools (LOCAL + CLOUD).
732
+ if (agentRealm === 'SANITIZED') {
733
+ kept.push(t);
734
+ continue;
735
+ }
736
+ if (toolRealms.has(agentRealm)) {
737
+ kept.push(t);
738
+ }
739
+ else {
740
+ removed.push(name);
741
+ }
742
+ }
743
+ if (removed.length > 0 && options?.audit !== false) {
744
+ try {
745
+ // §7.148: 工具过滤行也要带 sessionId —— 实测本仓 132 条
746
+ // `realm-skill-bridge.tools.filtered` 无一带 sessionId,全落全局桶。
747
+ this.auditLog?.log({
748
+ level: 'info',
749
+ kind: 'realm-skill-bridge.tools.filtered',
750
+ source: 'realm-skill-bridge',
751
+ payload: { ...sessionIdField(options?.sessionId), agentRealm, removedCount: removed.length, removed },
752
+ });
753
+ }
754
+ catch { /* debug only */ }
755
+ }
756
+ return { tools: kept, changed: removed.length > 0, removed };
757
+ }
758
+ // ── §7.93 Phase 2 (§7.93.1 通用化): external tool-schema face ───────────────
759
+ // function-call-external (Phase 4+) POSTs these to the external caller's
760
+ // /extract endpoint (current: cactus-needle 3.0.1 / needle-http :8081).
761
+ // Single source of truth: the SAME registry projection the LLM sees
762
+ // (`ctx.tools.schemas()` → name/description/parameters whitelist, per
763
+ // dsh-tools "Project visible definitions onto the allowlisted model-facing
764
+ // schema fields") filtered by the SAME realm semantics as filterToolsField.
765
+ // No separate tool list, no drift (user decision #8).
766
+ /**
767
+ * Build the external caller's wire-format tool schemas for one intent's realm.
768
+ *
769
+ * Pipeline: registry projection → realm filter (filterToolsField semantics,
770
+ * audit suppressed — caller emits `external.*` audit) → shape normalization.
771
+ *
772
+ * Failure contract: registry unavailable (cordis proxy throw before the
773
+ * `tools` service mounts, §7.92.2 trap) or empty → returns []. Callers MUST
774
+ * treat [] as "no external caller" and fall through to the LLM
775
+ * self-extraction path — never POST an empty tools array to the external
776
+ * caller (it would echo/hallucinate).
777
+ */
778
+ getToolSchemasForExternal(intent) {
779
+ const raw = this.readRegistrySchemas();
780
+ if (raw.length === 0)
781
+ return [];
782
+ const filtered = this.filterToolsField(raw, intent, { audit: false });
783
+ return normalizeExternalToolSchemas(filtered.tools);
784
+ }
785
+ /**
786
+ * Defensive read of `ctx.tools.schemas()` (global view). Returns [] when
787
+ * the service is not mounted yet or throws — the cordis proxy raises
788
+ * "cannot get property tools without inject" on unbound fibers, and
789
+ * tools/change re-fires tagging once available (same pattern as
790
+ * tagToolsFromConfig, §7.92.2).
791
+ */
792
+ readRegistrySchemas() {
793
+ try {
794
+ const toolsSvc = this.ctx.tools;
795
+ const schemas = toolsSvc?.schemas?.();
796
+ return Array.isArray(schemas) ? schemas : [];
797
+ }
798
+ catch {
799
+ return [];
800
+ }
801
+ }
802
+ // ── Layer 3: Tool execution gating ──────────────────────────────────────────
803
+ /**
804
+ * Check whether a tool call is allowed in the calling agent's realm.
805
+ * Returns a deny reason string, or undefined if the call is allowed.
806
+ * A tool can belong to multiple realms (e.g. bash → LOCAL+CLOUD); the call
807
+ * is allowed if the agent's realm is in the tool's realm set.
808
+ */
809
+ checkToolRealm(toolName, agentRealm) {
810
+ if (agentRealm === undefined)
811
+ return undefined; // unknown agent → allow (fail-open for now)
812
+ if (agentRealm === 'hidden')
813
+ return `Agent realm is 'hidden'; no tools allowed`;
814
+ const toolRealms = this.toolRealm(toolName);
815
+ if (toolRealms === undefined)
816
+ return undefined; // unknown tool → no realm constraint
817
+ if (toolRealms.has('hidden'))
818
+ return `Tool "${toolName}" is hidden`;
819
+ if (!toolRealms.has(agentRealm)) {
820
+ const realms = [...toolRealms].join(', ');
821
+ return `Tool "${toolName}" (${realms}) not allowed in ${agentRealm} realm`;
822
+ }
823
+ return undefined;
824
+ }
825
+ /** Log a realm denial to the audit log (security level). */
826
+ auditDeny(toolName, toolRealms, agentRealm, sessionId) {
827
+ this.auditLog?.log({
828
+ level: 'security',
829
+ kind: 'tool.realm.denied',
830
+ source: 'realm-skill-bridge',
831
+ // §7.148: 拒绝行也要带 sessionId —— 否则它落全局桶,Edge 面板的拦截
832
+ // 面板说不清「哪一轮的哪个工具被拒」。
833
+ payload: { ...sessionIdField(sessionId), tool: toolName, toolRealms: [...toolRealms], agentRealm },
834
+ });
835
+ }
836
+ }
837
+ // ─────────────────────────────────────────────────────────────────────────────
838
+ // §7.93 Phase 2 (§7.93.1 通用化): external schema normalization (pure, exported for tests)
839
+ // ─────────────────────────────────────────────────────────────────────────────
840
+ function isPlainRecord(value) {
841
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
842
+ }
843
+ /**
844
+ * Normalize registry projections onto the external caller's flat wire contract
845
+ * (`{name, description, parameters}`; current cactus-needle 3.0.1 impl follows
846
+ * `~/work/needle/API.md` v1.1 §4.5):
847
+ *
848
+ * - entries without a non-empty string `name` are dropped (caller keys
849
+ * function_calls by name — a nameless schema is unexecutable)
850
+ * - missing/non-string `description` → '' (caller tolerates it; the tool
851
+ * name + parameters carry the selection signal)
852
+ * - missing/non-object `parameters` → permissive `{type:'object',
853
+ * properties:{}}` root (an unconstrained-args tool, e.g. no-arg status
854
+ * probes; JSON Schema semantics: absent properties = any object)
855
+ *
856
+ * Defensive by design: the registry projection is already whitelisted
857
+ * upstream, but a runtime-registered tool with a hand-rolled shape must not
858
+ * crash the external path — worst case it is dropped and the LLM path covers it.
859
+ */
860
+ export function normalizeExternalToolSchemas(tools) {
861
+ const out = [];
862
+ for (const t of tools) {
863
+ if (!isPlainRecord(t))
864
+ continue;
865
+ const name = t.name;
866
+ if (typeof name !== 'string' || name === '')
867
+ continue;
868
+ out.push({
869
+ name,
870
+ description: typeof t.description === 'string' ? t.description : '',
871
+ parameters: isPlainRecord(t.parameters)
872
+ ? t.parameters
873
+ : { type: 'object', properties: {} },
874
+ });
875
+ }
876
+ return out;
877
+ }
878
+ function isTextBlock(block) {
879
+ return typeof block === 'object' && block !== null && block.type === 'text';
880
+ }
881
+ /**
882
+ * Recognise a message as a carrier of `<available_skills>` catalog content.
883
+ *
884
+ * Two shapes are accepted (§7.37):
885
+ * - `source.kind === 'skill-catalog'` — canonical dsh-tool-skill message
886
+ * (string content).
887
+ * - `source.kind === 'plugin' && source.plugin === '@deepseek-ai/dsh-system-prompt'`
888
+ * — dsh-system-prompt snapshot that bundles the catalog alongside
889
+ * sandbox/approval policy text. Content is array of text blocks; one
890
+ * block contains `<available_skills>`.
891
+ *
892
+ * Both must be filtered or LLM sees the full skill catalog and ignores
893
+ * realm boundaries.
894
+ */
895
+ function isCatalogMessage(msg) {
896
+ const src = msg.source;
897
+ if (!src)
898
+ return false;
899
+ if (src.kind === 'skill-catalog')
900
+ return true;
901
+ if (src.kind === 'plugin' && src.plugin === '@deepseek-ai/dsh-system-prompt') {
902
+ // Cheap probe: any text in the message that mentions <available_skills>?
903
+ const c = msg.content;
904
+ if (typeof c === 'string')
905
+ return c.includes('<available_skills>');
906
+ if (Array.isArray(c)) {
907
+ return c.some((b) => isTextBlock(b) && typeof b.text === 'string' && b.text.includes('<available_skills>'));
908
+ }
909
+ }
910
+ return false;
911
+ }
912
+ // ─────────────────────────────────────────────────────────────────────────────
913
+ // Plugin entry point
914
+ // ─────────────────────────────────────────────────────────────────────────────
915
+ export async function apply(ctx, config) {
916
+ // §7.102: fill unset fields from config/edge.json (privacy.*).
917
+ const p = loadEdgeConfig().privacy;
918
+ const c = config;
919
+ if (c.configPath === undefined) {
920
+ c.configPath = p?.realmConfigPath && p.realmConfigPath.length > 0
921
+ ? p.realmConfigPath
922
+ : edgeHome('skill-realms.json');
923
+ }
924
+ if (c.hotReload === undefined && p?.realmHotReload !== undefined)
925
+ c.hotReload = p.realmHotReload;
926
+ if (c.watchIntervalMs === undefined && p?.realmWatchMs !== undefined)
927
+ c.watchIntervalMs = p.realmWatchMs;
928
+ const auditLog = ctx.auditLog;
929
+ const service = new RealmSkillBridgeService(ctx, config, auditLog);
930
+ // Initial async tagging (needs ctx.skills.list() which is async).
931
+ await service.start();
932
+ // ── Subscribe to skills/change ────────────────────────────────────────────
933
+ const disposeSkillsChange = ctx.on?.('skills/change', () => {
934
+ void service['tagSkillsAsync']();
935
+ });
936
+ // ── Subscribe to tools/change (to re-apply tool realm tags) ───────────────
937
+ const disposeToolsChange = ctx.on?.('tools/change', () => {
938
+ service['tagToolsFromConfig']();
939
+ });
940
+ // ── Layer 1: Filter skill catalog on every agent step ─────────────────────
941
+ // §7.36 (2026-09-04): 必须传 { global: true } —— agent/pre-step 默认 per-agent,
942
+ // 只在注册时的 agent 实例上生效。http-api /v1/chat 路径上的 record.agent 是
943
+ // 动态创建的(每次 followup 新 agent),per-agent 注册的 hook 收不到,导致
944
+ // Layer 1 catalog filter 和 Layer 3 tool gating 永远不触发 → fail-open。
945
+ // input-guard 用了 { global: true },realm-skill-bridge 之前漏了 → 整个
946
+ // fail-open 链路"hook 在,但 agent 收不到"。
947
+ //
948
+ // §7.136 (2026-10-28, 主人裁决「只有边缘模式才有本项目能力」): 上面那条
949
+ // 顾虑在宿主的 **scope** 语义下已经不成立 —— agent 事件用
950
+ // `scopeTarget(agent, agent)` 派发,filter 规则是「监听器的 scope 必须是
951
+ // 派发 scope 或其祖先」。动态创建的 Agent 同样经宿主工厂创建、注册表把它
952
+ // bind 到当前 preset ⇒ scope 链一样成立。
953
+ //
954
+ // 而 `{ global: true }` 会**绕过**这个过滤 ⇒ 护栏在任何工作方式下都生效、
955
+ // 标准模式不是纯宿主。所以这里去掉它:本插件挂在边缘模式 preset 的 scope,
956
+ // 绑定标准模式的 Agent 收不到这些 hook。
957
+ //
958
+ // §7.37 (2026-09-04): catalog filter on agent/pre-step is currently a no-op
959
+ // because decision.messages is empty (msgCount=0) — <available_skills> is
960
+ // injected by dsh-system-prompt plugin below the hook surface. Kept as a
961
+ // no-op hook (decision.messages filter still runs in case future DSH
962
+ // versions populate it) and supplemented by Layer 1.5 (llm/stream tools).
963
+ const disposePreStep = ctx.on?.('agent/pre-step', async (payload, next) => {
964
+ const decision = (await next());
965
+ if (decision.kind === 'reject')
966
+ return decision;
967
+ const p = payload;
968
+ const agentRealm = service.getAgentRealm(p.agent);
969
+ if (agentRealm === undefined || decision.messages === undefined)
970
+ return decision;
971
+ // §7.46 (2026-09-11): 用 detailed 版本拿到 droppedCount,
972
+ // emit ops log 让"哪些 skill 被 filter 掉"在每个 chat 都可见
973
+ const result = service.filterSkillCatalogDetailed(decision.messages, agentRealm);
974
+ if (result.messages === decision.messages)
975
+ return decision;
976
+ if (result.droppedCount > 0 || agentRealm !== 'hidden') {
977
+ const sessionId = p.agent?.session?.id ?? 'unknown';
978
+ try {
979
+ ctx.logger?.info?.(`[realm-skill-bridge] pre-step filter sessionId=${sessionId} realm=${agentRealm} catalogDropped=${result.droppedCount}`);
980
+ }
981
+ catch { /* debug only */ }
982
+ }
983
+ return { ...decision, messages: result.messages };
984
+ });
985
+ // ── Layer 1.5-replacement: Filter tool schemas at system-prompt/assemble ───
986
+ // §7.37.x (2026-09-22): replace the deleted `llm/stream` tools probe.
987
+ // Why the original probe is dead:
988
+ // dsh-agent-loop's buildRequest() deepFreezes the entire request payload
989
+ // (config + messages + system + tools + sessionId + signal) BEFORE the
990
+ // `llm/stream` waterfall fires. See @deepseek-ai/dsh-agent-loop/lib/index.js:729:
991
+ // markAgentLoopRequest(deepFreeze({
992
+ // ...header.config,
993
+ // messages: boundaryMessages,
994
+ // ...header.system !== void 0 ? { system: header.system } : {},
995
+ // ...header.tools !== void 0 ? { tools: header.tools } : {},
996
+ // sessionId: this.session.id, signal,
997
+ // }))
998
+ // After this, payload.tools at `llm/stream` is frozen — splice, push,
999
+ // Object.defineProperty all silently fail.
1000
+ //
1001
+ // Why system-prompt/assemble works:
1002
+ // dsh-system-prompt/lib/index.js:339 builds assembly.tools via plain
1003
+ // array concat (no freeze), then runs the `system-prompt/assemble`
1004
+ // waterfall (line 342). The dsh type declaration explicitly states:
1005
+ // "the returned value is authoritative"
1006
+ // so we can return a new assembly with filtered tools. dsh-agent-loop
1007
+ // then picks up `assembly.tools` and freezes them only later (line 729).
1008
+ //
1009
+ // Trade-off: `system-prompt/assemble` runs in `agent.preStep()` (every
1010
+ // step), so we filter once per LLM call. Layer 3 (tools/pre-execute)
1011
+ // remains the authoritative execution-time gate for runtime-added tools.
1012
+ // Layer 1.5-replacement here is a UX gate — LLM doesn't even ATTEMPT a
1013
+ // cross-realm tool call when its schema is hidden.
1014
+ const disposeAssemble = ctx.on?.('system-prompt/assemble', async (assembly, ctx2, next) => {
1015
+ const result = (await next());
1016
+ if (!result || !Array.isArray(result.tools))
1017
+ return result;
1018
+ const assembleCtx = ctx2;
1019
+ const sessionId = assembleCtx?.scope?.session?.id;
1020
+ const agentRealm = service.getSessionRealm(sessionId);
1021
+ // No realm classified yet → fail-open at this step. http-api sets
1022
+ // session realm via service.setSessionRealm() in the same request
1023
+ // lifecycle, so undefined should not happen in production. Layer 3
1024
+ // (tools/pre-execute) catches anything that slips through.
1025
+ if (agentRealm === undefined)
1026
+ return result;
1027
+ const filter = service.filterToolsField(result.tools, agentRealm, { sessionId });
1028
+ if (!filter.changed)
1029
+ return result;
1030
+ ctx.logger?.info?.(`[realm-skill-bridge] assemble filter sessionId=${sessionId ?? 'unknown'} realm=${agentRealm} removedCount=${filter.removed.length} removed=${filter.removed.join(',')}`);
1031
+ return { ...result, tools: filter.tools };
1032
+ });
1033
+ // ── Layer 3: Gate tool execution by realm ─────────────────────────────────
1034
+ const disposePreExec = ctx.on?.('tools/pre-execute', async (exec, next) => {
1035
+ const e = exec;
1036
+ const agentRealm = service.getAgentRealm(e.agent);
1037
+ const rawSessionId = e.agent?.session?.id;
1038
+ // §7.148: ops log 的显示约定保留 `'unknown'`;但**审计载荷**要拿真值 ——
1039
+ // `'unknown'` 不是会话 id,写进载荷会造出一个不存在的桶(§7.147 口径)。
1040
+ const sessionId = rawSessionId ?? 'unknown';
1041
+ const denyReason = service.checkToolRealm(e.name, agentRealm);
1042
+ // §7.46.7 (2026-09-12): 尝试从 exec.args/input/arguments 抽出 skill name —
1043
+ // 当 tool=skill 时,ops log 应该报具体 skill 名(ets-auth 等),而不是只报 tool=skill。
1044
+ // dshtool-skill 的参数是 { name: '<skill>' }(lib/index.js:62),但 dsh-agent-loop
1045
+ // 把 args 放在哪个字段(input / args / arguments)随版本变,所以三个都试。
1046
+ // mibao-empty-result-diagnosis 排查时就是因为 ops log 只报 tool=skill
1047
+ // 看不出 LLM 实际选了哪个 skill("查密盒登录状态"应该选 ets-auth,
1048
+ // 但 trace 完全看不到 skill 名字,无法判断是选错 skill 还是 skill 执行失败)。
1049
+ let skillName;
1050
+ if (e.name === 'skill') {
1051
+ const argsObj = e.input ?? e.args ?? e.arguments;
1052
+ const raw = argsObj && typeof argsObj === 'object' ? argsObj.name : undefined;
1053
+ skillName = typeof raw === 'string' ? raw : undefined;
1054
+ }
1055
+ // §7.46 (2026-09-11): ops log 标记每个 tool call 的 allow/deny 决策,
1056
+ // 配合 chat-received/finished/preflight/pre-step 形成完整 flow log
1057
+ try {
1058
+ const skillSuffix = skillName ? ` skillName=${skillName}` : '';
1059
+ if (denyReason !== undefined) {
1060
+ ctx.logger?.info?.(`[realm-skill-bridge] tool deny sessionId=${sessionId} tool=${e.name}${skillSuffix} realm=${agentRealm ?? 'unknown'} reason=${denyReason}`);
1061
+ }
1062
+ else {
1063
+ ctx.logger?.info?.(`[realm-skill-bridge] tool allow sessionId=${sessionId} tool=${e.name}${skillSuffix} realm=${agentRealm ?? 'unknown'}`);
1064
+ }
1065
+ }
1066
+ catch { /* debug only */ }
1067
+ if (denyReason !== undefined) {
1068
+ const toolRealms = service.toolRealm(e.name) ?? new Set(['hidden']);
1069
+ service['auditDeny'](e.name, toolRealms, agentRealm ?? 'hidden', rawSessionId);
1070
+ return { kind: 'deny', reason: denyReason };
1071
+ }
1072
+ return next();
1073
+ });
1074
+ // ── Layer 3.5: tool execution result ops log (§7.47.6) ──────────────────
1075
+ //
1076
+ // user feedback 2026-09-13: "每次修BUG通过日志来修理,确保日志里有所有错误的信息"
1077
+ //
1078
+ // tools/pre-execute (Layer 3 above) only emits the realm check decision.
1079
+ // The actual tool execution result (success / error / duration / signal-aborted)
1080
+ // was completely invisible in ops log — 150 tool allow lines vs 3 tool result
1081
+ // lines. Diagnosing "news query returned empty because web_search hung"
1082
+ // required manual trace, not ops log. Now: subscribe to the serial
1083
+ // `tools/result` event (dispatched by dsh-tools after `tools/post-execute`
1084
+ // waterfall finishes — pure observation, no next chain so we can't break it).
1085
+ //
1086
+ // Payload shape (from dsh-tools): exec = { name, agent, signal, ... },
1087
+ // result = { isError: boolean, message?: unknown, ... }. We emit metadata
1088
+ // only — full result content stays in audit-log (opt-in via
1089
+ // DSH_EDGE_LOG_LEVEL=debug).
1090
+ const disposeToolResult = ctx.on?.('tools/result', (exec, result) => {
1091
+ try {
1092
+ const e = exec;
1093
+ const r = result;
1094
+ const toolName = typeof e?.name === 'string' ? e.name : '<unknown>';
1095
+ const sessionId = e?.agent?.session?.id ?? 'unknown';
1096
+ const isError = r?.isError === true;
1097
+ const signalAborted = r?.signalAborted === true;
1098
+ const status = signalAborted ? 'aborted' : isError ? 'error' : 'ok';
1099
+ try {
1100
+ ctx.logger?.info?.(`[realm-skill-bridge] tool result sessionId=${sessionId} tool=${toolName} status=${status}${isError ? ` errorKind=${describeToolError(r?.message)}` : ''}`);
1101
+ }
1102
+ catch { /* debug only */ }
1103
+ }
1104
+ catch { /* debug only */ }
1105
+ });
1106
+ // ── Flow-level ops log for every chat round-trip (§7.44.1) ───────────────
1107
+ //
1108
+ // 用户反馈 §7.44 把 ops log 默认从 debug 翻到 info 后,TUI 输入的请求
1109
+ // 在 log 里没有记录 —— 因为 TUI 不走 http-api(http-api 才有 flow
1110
+ // info log 'POST /v1/chat 200 ...ms'),TUI 直接走 dsh-agent-loop +
1111
+ // Session。session/event 上的 request/header 和 turn/end 是 user
1112
+ // prompt lifecycle 的最稳两个 marker(每个 chat round-trip 各 fire
1113
+ // 一次,TUI / http-api / 未来 webui 都覆盖)。
1114
+ //
1115
+ // 这里 emit 单行 info(metadata only:session id + turn + reason),
1116
+ // 不带任何 user PII;body capture 仍走 audit-log + opt-in
1117
+ // DSH_EDGE_LOG_LEVEL=debug。
1118
+ // §7.46 (2026-09-11): per-session start time map 算 chat duration,
1119
+ // 配合 chat finished line 的 realm= 让"这条 chat 路由了哪个 realm /
1120
+ // 用了多久"在 ops log 一目了然。
1121
+ const sessionStartTimes = new Map();
1122
+ const disposeSessionEvents = ctx.on?.('session/event', (session, evt) => {
1123
+ const e = evt;
1124
+ if (e === undefined || typeof e.type !== 'string')
1125
+ return;
1126
+ const sessionId = session?.id ?? 'unknown';
1127
+ // §7.44.1 (2026-09-11): 为 TUI / 任何非 http-api 入口加 flow-level
1128
+ // info log —— §7.44 把 ops log 默认翻 info 后,TUI 走 dsh-agent-loop
1129
+ // 不再被 file-logger 默认捕到(http-api 才有自己的 POST /v1/chat 200
1130
+ // 行),整轮 chat 在 log 里无痕迹。session/event 是 dsh 内部一套
1131
+ // per-session 事件流,覆盖所有 entry point(TUI / http-api / 未来
1132
+ // webui 都走)。每个 chat round-trip fire 一次 'request/header'
1133
+ // (agent-loop 在 user prompt 起点 emit,see
1134
+ // @deepseek-ai/dsh-agent-loop/lib/index.js)和一次 'turn/end',
1135
+ // 我们在 info 级各 emit 单行(metadata only:不带 user 文本 / PII),
1136
+ // 让 ops log 始终能看到"什么时候来了什么请求、什么时候结束"。
1137
+ if (e.type === 'request/header') {
1138
+ // 之前的 'startsSeries=true' 过滤在本路径下永远不命中(实测 data
1139
+ // 里不带 startsSeries 字段,session/event 的 data shape 跟
1140
+ // SessionEventMap 声明不完全一致),改成无条件 log 每个 request/
1141
+ // header 事件 —— 实测每个 chat 恰好 fire 1 次(user/message 那
1142
+ // 种 N 次是因为 session hydration / 重注入,与 request series
1143
+ // 不是一回事)。
1144
+ const data = e.data;
1145
+ const reason = typeof data?.reason === 'string' ? data.reason : 'unknown';
1146
+ const ss = data?.startsSeries === true ? ' series-start' : '';
1147
+ sessionStartTimes.set(sessionId, Date.now());
1148
+ ctx.logger?.info?.(`[realm-skill-bridge] chat received sessionId=${sessionId} reason=${reason}${ss}`);
1149
+ }
1150
+ else if (e.type === 'turn/end') {
1151
+ const data = e.data ?? {};
1152
+ const turn = typeof data.turn === 'number' ? data.turn : -1;
1153
+ // §7.47.6 (2026-09-13): serialize turnEnds object instead of dropping
1154
+ // to 'unknown'. dsh-agent-loop emits `turnEnds = { kind: "aborted",
1155
+ // reason: signal.reason }` (object, not string). Pre-§7.47.6 code
1156
+ // checked `typeof === 'string'` and silently fell back to 'unknown',
1157
+ // hiding abort reasons like max-turns / signal-abort / completed.
1158
+ // Now: stringify object into compact key=value form for ops log.
1159
+ const reason = serializeTurnEndReason(data.reason);
1160
+ const startMs = sessionStartTimes.get(sessionId);
1161
+ const durationMs = startMs !== undefined ? Date.now() - startMs : -1;
1162
+ const realm = service.getSessionRealm(sessionId) ?? 'unset';
1163
+ const durationStr = durationMs >= 0 ? ` duration=${durationMs}ms` : ' duration=-1';
1164
+ ctx.logger?.info?.(`[realm-skill-bridge] chat finished sessionId=${sessionId} turn=${turn} reason=${reason} realm=${realm}${durationStr}`);
1165
+ sessionStartTimes.delete(sessionId);
1166
+ }
1167
+ });
1168
+ return async () => {
1169
+ service.stop();
1170
+ service.clearSessionRealms();
1171
+ disposeSkillsChange?.();
1172
+ disposeToolsChange?.();
1173
+ disposePreStep?.();
1174
+ disposeAssemble?.();
1175
+ disposePreExec?.();
1176
+ disposeToolResult?.();
1177
+ disposeSessionEvents?.();
1178
+ sessionStartTimes.clear();
1179
+ };
1180
+ }