@dsh-edge/dsh-edge-agent 0.0.0-stage → 0.1.1

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 +142 -0
  2. package/LICENSE +21 -0
  3. package/README.md +372 -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 +341 -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,898 @@
1
+ /**
2
+ * edge-config — the single read point for dsh-edge-agent runtime settings.
3
+ *
4
+ * §7.102 (2026-10-21): replaces the two previous chains:
5
+ *
6
+ * old: .env (bare names) → .env.sh (export DSH_EDGE_* derived) → cordis.patch.yml
7
+ * (process.env.DSH_EDGE_LOCAL_LLM_BASEURL, etc.)
8
+ *
9
+ * new: config/edge.json → this loader → plugin Config
10
+ *
11
+ * Why the old chain could not serve every profile:
12
+ *
13
+ * - dsh-tui / dsh-web / dsh-edge (CLI): install.sh sourced
14
+ * ~/.dsh/profiles/<p>/.env.sh, so the derived DSH_EDGE_* vars were
15
+ * present in the process env.
16
+ * - desktop (DeepSeek Harness.exe, Electron): started by the GUI, never
17
+ * goes through install.sh, so process.env had none of them. Every
18
+ * `!!js process.env.DSH_EDGE_LOCAL_LLM_BASEURL || undefined` in the
19
+ * patch resolved to undefined and the plugin fell back to its Schema
20
+ * default (127.0.0.1:8080 — a port owned by an unrelated daemon).
21
+ *
22
+ * The new chain has no process-level dependency: the plugin reads the JSON
23
+ * file itself, so a file that ships inside the bundle works identically in
24
+ * every profile.
25
+ *
26
+ * Lookup order (first hit wins) — §7.182 (2026-11-11, operator ruling):
27
+ * 1. <bundle root>/config/edge.json — the INSTALLED bundle's own config
28
+ * directory. "插件装在哪,配置就在哪" — the user opens the plugin folder
29
+ * and finds the file they are supposed to edit. (§7.182: in a SOURCE
30
+ * CHECKOUT this candidate is SKIPPED — see isSourceCheckout() below —
31
+ * because the repository's config/ directory is no longer a supported
32
+ * drop point.)
33
+ * 2. ~/.dsh-edge-agent/edge.json — the operator's home copy. Survives
34
+ * a reinstall, a profile deletion, and every install channel (npm / git
35
+ * / tarball), so it is the落点 for anything long-lived.
36
+ * (no third candidate — the shipped template is NOT read at runtime; the
37
+ * template's values live in DEFAULT_EDGE_CONFIG below, so "no config file"
38
+ * means exactly what the template used to mean.)
39
+ *
40
+ * §7.182 (operator ruling): **the repository's config/ directory is no longer
41
+ * a supported drop point for the live config.** Why:
42
+ * - it holds the operator's plaintext API keys, and a git checkout is the
43
+ * one place where they can leak into history (§7.178 deleted every such
44
+ * local config);
45
+ * - the npm / git / tarball channels never had that file anyway (the
46
+ * packaging gates exclude `config/edge.json` on purpose), so it was never
47
+ * a cross-channel location;
48
+ * - a source checkout and an installed bundle resolve to the SAME path
49
+ * (`edgeBundleRoot()` walks up from this module), so the ruling is
50
+ * enforced by isSourceCheckout() — in a checkout, candidate #1 is
51
+ * skipped and the runtime reads the same home copy the desktop does.
52
+ * ⇒ One config, two legal落点: the installed bundle's own config dir (where
53
+ * the user looks) and `~/.dsh-edge-agent/edge.json` (where it survives).
54
+ *
55
+ * The loader is deliberately read-only and synchronous: config is read once
56
+ * at plugin apply() time. Hot reload is the caller's job (privacy-policy
57
+ * already implements its own watcher for its own file).
58
+ */
59
+ import { existsSync, readFileSync, statSync } from 'node:fs';
60
+ import { homedir } from 'node:os';
61
+ import { dirname, join, resolve } from 'node:path';
62
+ import { fileURLToPath } from 'node:url';
63
+ /* ── Defaults (last-resort fallbacks; no local endpoints) ─────────────── */
64
+ export const DEFAULT_EDGE_CONFIG = {
65
+ secrets: {},
66
+ http: { host: '127.0.0.1', port: 21017 },
67
+ llm: {
68
+ // No baseUrl → the local-LLM route is DELEGATE mode (§7.171: forward to the
69
+ // host profile's own model provider), NOT "no route at all".
70
+ // A hard-coded port here collided with unrelated daemons (see
71
+ // local-llm-adapter.ts Config.baseUrl docs), so "unset" is the only safe
72
+ // default — and unset now means delegate, which never touches a port.
73
+ //
74
+ // The model is deliberately left unset: each plugin owns its own Schema
75
+ // default (local-llm-adapter says 'qwen3-4b', http-api's route fallback
76
+ // says 'qwen3-4b-instruct-2507'). A single default here would silently
77
+ // override the plugin's own value.
78
+ local: { stripTools: false, enableThinking: false, delegate: true },
79
+ cloud: { delegate: true },
80
+ },
81
+ stages: {
82
+ // §7.182: 60000 (not 20000/30000) — the shipped template used to supply
83
+ // this value and it was the candidate #3 fallback; the template is no
84
+ // longer a runtime candidate, so its deployment values live HERE now.
85
+ // Without this, "no config file" would silently shrink the guard's and
86
+ // the classifier's per-call timeout by 3x / 2x.
87
+ guard: { provider: 'local-llm', enabled: true, timeoutMs: 60000, maxInput: 2000 },
88
+ intent: {
89
+ provider: 'local-llm',
90
+ timeoutMs: 60000,
91
+ confidenceThreshold: 0.6,
92
+ maxInput: 2000,
93
+ recentLimit: 3,
94
+ // §7.107 (2026-10-23): an undecided turn stays on the device. The
95
+ // cloud needs an explicit public-web signal — see the intent-router's
96
+ // CLOUD keyword list and the privacy policy's CLOUD rule.
97
+ defaultCategory: 'LOCAL',
98
+ cloudKeywords: [],
99
+ localEntities: [],
100
+ fallbackKeywords: [],
101
+ // §7.124: 内置词表已覆盖常见家电;此处是 operator 追加面。
102
+ deviceCommands: { devices: [], actions: [] },
103
+ // §7.115: the LLM classifier is the fallback tier (jev skips go here).
104
+ llmFallback: true,
105
+ },
106
+ },
107
+ jev: {
108
+ // §7.182 (2026-11-11, owner ruling): OFF by default. jev is an optional
109
+ // accelerator that needs a local decision-model service on :8001 ⇒ with
110
+ // no config file it must not be probed. Opt in with jev.enabled=true.
111
+ enabled: false,
112
+ url: 'http://127.0.0.1:8001',
113
+ timeoutMs: 2500,
114
+ confidenceThreshold: 0.85,
115
+ riskThreshold: 0.7,
116
+ breakerTtlMs: 30000,
117
+ bootProbeEnabled: true,
118
+ bootProbeTimeoutMs: 2000,
119
+ breakerProbeMs: 5000,
120
+ probeTimeoutMs: 1000,
121
+ // §7.182: the template used to carry these and was the runtime fallback;
122
+ // it is no longer a candidate ⇒ they live here now (the service-level
123
+ // `??` literals agree with these values — see intent-router.ts).
124
+ provider: 'local',
125
+ model: 'decision-model-preview',
126
+ authScheme: 'raw',
127
+ path: '/systemone',
128
+ localAdoptFloor: 0.5,
129
+ // §7.182: `apiKeyRef` is deliberately NOT folded in. The template points
130
+ // it at `secrets.bailianApiKey`, a slot that only exists once the
131
+ // operator fills it — with the template as a runtime candidate that made
132
+ // every start log "密钥引用指向不存在的槽位" (the §7.171 noise). A code
133
+ // default must not carry a reference to a slot it cannot fill: jev's
134
+ // default provider is the local ONNX one, which needs no key at all.
135
+ },
136
+ functionCallExternal: {
137
+ // Code-level fallback = the historical Schema defaults (function-call-
138
+ // external.test.ts EXPECTED_DEFAULTS pins them). Deployment values such
139
+ // as the 15000 ms needle timeout live in config/edge.json instead.
140
+ enabled: false,
141
+ url: 'http://127.0.0.1:8081',
142
+ // §7.182: the template's deployment values moved here (the template is no
143
+ // longer a runtime candidate) ⇒ "no config file" still means what the
144
+ // template used to mean.
145
+ timeoutMs: 15000,
146
+ confidenceThreshold: 0.5,
147
+ breakerTtlMs: 30000,
148
+ bootProbeEnabled: true,
149
+ bootProbeTimeoutMs: 2000,
150
+ breakerProbeMs: 5000,
151
+ probeTimeoutMs: 1000,
152
+ },
153
+ privacy: {
154
+ inputGuardEnabled: true,
155
+ rejectPolicyEnabled: true,
156
+ sanitizeDowngradeEnabled: true,
157
+ realmHotReload: true,
158
+ realmWatchMs: 5000,
159
+ policyHotReload: true,
160
+ policyWatchMs: 5000,
161
+ // policyPath / realmConfigPath stay unset → plugins derive via edgeHome().
162
+ },
163
+ resources: {
164
+ maxSessions: 100,
165
+ maxBodyBytes: 1048576,
166
+ sessionIdleMs: 1800000,
167
+ sanMaxSessions: 1000,
168
+ sanSweepMs: 60000,
169
+ sanTtlMs: 900000,
170
+ },
171
+ audit: {
172
+ minLevel: 'info',
173
+ flushMs: 1000,
174
+ logLevel: 'info',
175
+ logMaxAgeDays: 1,
176
+ logMaxRotated: 7,
177
+ // root / logRoot stay unset → plugins derive them via edgeHome().
178
+ },
179
+ tee: { type: 'simulated' },
180
+ storage: {},
181
+ etscli: { timeoutMs: 30000 },
182
+ threat: { cloudRate: 20, blockLevel: 'critical' },
183
+ // §7.182 (2026-11-11, owner ruling): Tavily / 博查 are OFF by default ⇒
184
+ // out of the box the web-search chain is DeepSeek-only
185
+ // (secrets.deepseekApiKey). Opt in per tier.
186
+ webSearch: { tavilyEnabled: false, bochaEnabled: false },
187
+ agent: {
188
+ persona: 'You are edge-agent, a privacy-first AI assistant running on the user\'s device.',
189
+ localToolsWritable: true,
190
+ permissionMode: 'workspace-write',
191
+ },
192
+ };
193
+ /* ── Loading ──────────────────────────────────────────────────────────── */
194
+ let cached;
195
+ let cachedPath;
196
+ /** 指纹(path + mtime + size + env 覆盖值)—— loadEdgeConfig(true) 的热路径
197
+ * 短路用:input-guard 每个云路由 turn 都 force 重载一次(§7.102 桌面 GUI
198
+ * 没 .env 链,secrets 只能在文件里),文件没变 + env 没变时没必要每次
199
+ * existsSync+readFileSync+JSON.parse 阻塞事件循环。 */
200
+ let cachedStat;
201
+ let cachedEnvSig;
202
+ /**
203
+ * Root of the installed bundle (the directory holding `lib/` and
204
+ * `config/`), or the repo root when running from source.
205
+ *
206
+ * src/dsh-adapter/ → repo root (dev) or bundle root (installed).
207
+ */
208
+ export function edgeBundleRoot() {
209
+ const here = dirname(fileURLToPath(import.meta.url));
210
+ return resolve(here, '..', '..');
211
+ }
212
+ /**
213
+ * Is the bundle root a SOURCE CHECKOUT (this repository) rather than an
214
+ * installed bundle?
215
+ *
216
+ * §7.182 (2026-11-11): the repository's `config/` directory is no longer a
217
+ * supported drop point for the live config — it is where plaintext API keys
218
+ * would live inside a git checkout. An installed bundle (npm / git / tarball
219
+ * / local directory) carries `lib/` and `config/` but never `src/`, so the
220
+ * two are told apart reliably:
221
+ *
222
+ * · source checkout → `src/dsh-adapter/` exists → candidate #1 SKIPPED
223
+ * · installed bundle → no `src/` tree → candidate #1 ACTIVE
224
+ *
225
+ * ⇒ In a checkout the runtime reads the SAME home copy the desktop does
226
+ * (`~/.dsh-edge-agent/edge.json`), so dev and production cannot diverge.
227
+ */
228
+ export function isSourceCheckout(bundleRoot = edgeBundleRoot()) {
229
+ return existsSync(join(bundleRoot, 'src', 'dsh-adapter'));
230
+ }
231
+ /**
232
+ * Candidate paths, in priority order — **exactly two** (§7.182).
233
+ *
234
+ * 1. the INSTALLED bundle's own `config/edge.json` — where the user looks
235
+ * ("插件装在哪,配置就在哪"); skipped in a source checkout (see
236
+ * isSourceCheckout());
237
+ * 2. `~/.dsh-edge-agent/edge.json` — the durable落点: survives a reinstall,
238
+ * a profile deletion, and every install channel.
239
+ *
240
+ * There is NO third candidate. The shipped template (`config/edge.example.json`)
241
+ * used to be candidate #3; §7.182 dropped it, and its values were folded into
242
+ * DEFAULT_EDGE_CONFIG instead ⇒ "no config file" still means exactly what the
243
+ * template used to mean. The template file itself keeps shipping — it is what
244
+ * an operator copies from, not a runtime input.
245
+ */
246
+ export function edgeConfigCandidates(bundleRoot = edgeBundleRoot(), homeDir = homedir()) {
247
+ // §7.102: DSH_EDGE_CONFIG_FILE=off disables file-based configuration
248
+ // entirely. Unit tests set it so a developer's local edge.json (real
249
+ // endpoints, real API keys) cannot leak into the assertions — the suite must
250
+ // be reproducible on any machine.
251
+ if (typeof process !== 'undefined' && process.env?.DSH_EDGE_CONFIG_FILE === 'off') {
252
+ return [];
253
+ }
254
+ // §7.182: EXACTLY two candidates, in this order:
255
+ // 1. <安装目录>/config/edge.json — 顺位 1(插件装在哪,配置就在哪)
256
+ // 2. ~/.dsh-edge-agent/edge.json — 顺位 2(重装 / 删 profile 都活着)
257
+ // 顺位 1 不存在才轮到顺位 2(loadEdgeConfig 按顺序命中第一个存在的文件)。
258
+ // 插件目录下的 edge.example.json **永远不是候选** —— 它只是给人抄的模板。
259
+ // bundleRoot / homeDir 可注入 ⇒ 门禁能在临时目录上验证真实优先级,
260
+ // 而不必往运维的真 home 里写文件。
261
+ const candidates = [];
262
+ if (!isSourceCheckout(bundleRoot)) {
263
+ candidates.push(join(bundleRoot, 'config', 'edge.json'));
264
+ }
265
+ candidates.push(join(homeDir, '.dsh-edge-agent', 'edge.json'));
266
+ return candidates;
267
+ }
268
+ /** Path of the file that was actually loaded, or undefined when defaults
269
+ * are in use. Exposed for diagnostics / audit.
270
+ *
271
+ * §7.182: 命中的是两档候选里的哪一个(顺位 1 插件目录 / 顺位 2 home);
272
+ * 模板 edge.example.json 永远不会是命中项。 */
273
+ export function edgeConfigPath() {
274
+ return cachedPath;
275
+ }
276
+ /**
277
+ * Default data location under the user home (`~/.dsh-edge-agent/...`).
278
+ * Plugins use this when the corresponding edge.json path field is unset —
279
+ * the old patch rows derived the same expression via `!!js HOME`, which is
280
+ * what §7.102's "one config file" rule moves into code.
281
+ */
282
+ export function edgeHome(...segments) {
283
+ return join(homedir(), '.dsh-edge-agent', ...segments);
284
+ }
285
+ /**
286
+ * Env override layer.
287
+ *
288
+ * The file (config/edge.json) is the source of truth, but the historical
289
+ * DSH_EDGE_* env vars stay usable as a per-process override — CI jobs,
290
+ * one-off debugging, and the existing unit tests all rely on them. An env
291
+ * var only wins when it is actually set; when it is absent the file value
292
+ * is used. This keeps the migration non-breaking in both directions.
293
+ */
294
+ const ENV_OVERRIDES = [
295
+ {
296
+ env: 'DSH_EDGE_LOCAL_LLM_BASEURL',
297
+ apply: (cfg, v) => { cfg.llm.local.baseUrl = v; },
298
+ },
299
+ {
300
+ env: 'DSH_EDGE_LOCAL_LLM_MODEL',
301
+ apply: (cfg, v) => { cfg.llm.local.model = v; },
302
+ },
303
+ {
304
+ env: 'DSH_EDGE_LOCAL_LLM_KEY_ENV',
305
+ apply: (cfg, v) => { cfg.llm.local.keyRef = v; },
306
+ },
307
+ {
308
+ env: 'DSH_EDGE_CLOUD_LLM_BASEURL',
309
+ apply: (cfg, v) => { cfg.llm.cloud.baseUrl = v; },
310
+ },
311
+ {
312
+ env: 'DSH_EDGE_CLOUD_KEY_ENV',
313
+ apply: (cfg, v) => { cfg.llm.cloud.keyRef = v; },
314
+ },
315
+ {
316
+ env: 'DSH_EDGE_CLOUD_DELEGATE',
317
+ apply: (cfg, v) => { cfg.llm.cloud.delegate = v !== 'false'; },
318
+ },
319
+ {
320
+ env: 'DSH_EDGE_CLOUD_DELEGATE_PROVIDER',
321
+ apply: (cfg, v) => { cfg.llm.cloud.delegateProvider = v; },
322
+ },
323
+ {
324
+ env: 'DSH_EDGE_CLOUD_DELEGATE_MODEL',
325
+ apply: (cfg, v) => { cfg.llm.cloud.delegateModel = v; },
326
+ },
327
+ {
328
+ env: 'DSH_EDGE_MODEL',
329
+ apply: (cfg, v) => { cfg.llm.cloud.model = v; },
330
+ },
331
+ {
332
+ env: 'EDGE_API_KEY',
333
+ apply: (cfg, v) => { cfg.secrets.edgeApiKey = v; },
334
+ },
335
+ {
336
+ // Alias of EDGE_API_KEY. scripts/edge-config-env.mjs exports both
337
+ // spellings (the bare one for the legacy e2e/curl callers, the
338
+ // prefixed one for the plugin side); without a consumer here the
339
+ // prefixed spelling was a dead export.
340
+ env: 'DSH_EDGE_HTTP_TOKEN',
341
+ apply: (cfg, v) => { cfg.secrets.edgeApiKey = v; },
342
+ },
343
+ {
344
+ // The bare secret names are the spellings the retired .env used
345
+ // (`.env.sh` did `set -a` and exported them verbatim). The pools
346
+ // resolve their key through `keyRef` → `secrets[keyRef]`, so without
347
+ // these four mappings an env-only deployment (no config/edge.json)
348
+ // would silently lose the credential and every authenticated call
349
+ // would fail with 401. §7.102 keeps the file as the source of truth;
350
+ // these keep the legacy env chain working on top of it.
351
+ env: 'DEEPSEEK_API_KEY',
352
+ apply: (cfg, v) => { cfg.secrets.deepseekApiKey = v; },
353
+ },
354
+ {
355
+ env: 'DASHSCOPE_API_KEY',
356
+ apply: (cfg, v) => { cfg.secrets.dashscopeApiKey = v; },
357
+ },
358
+ {
359
+ env: 'TAVILY_API_KEY',
360
+ apply: (cfg, v) => { cfg.secrets.tavilyApiKey = v; },
361
+ },
362
+ {
363
+ env: 'BOCHA_API_KEY',
364
+ apply: (cfg, v) => { cfg.secrets.bochaApiKey = v; },
365
+ },
366
+ {
367
+ env: 'DSH_EDGE_HTTP_HOST',
368
+ apply: (cfg, v) => { cfg.http.host = v; },
369
+ },
370
+ {
371
+ env: 'DSH_EDGE_HTTP_PORT',
372
+ apply: (cfg, v) => { cfg.http.port = Number(v); },
373
+ },
374
+ {
375
+ env: 'DSH_EDGE_AUDIT_MIN_LEVEL',
376
+ apply: (cfg, v) => { cfg.audit.minLevel = v; },
377
+ },
378
+ {
379
+ env: 'DSH_EDGE_AUDIT_FLUSH_MS',
380
+ apply: (cfg, v) => { cfg.audit.flushMs = Number(v); },
381
+ },
382
+ {
383
+ env: 'DSH_EDGE_LOG_LEVEL',
384
+ apply: (cfg, v) => { cfg.audit.logLevel = v; },
385
+ },
386
+ {
387
+ env: 'DSH_EDGE_LOG_MAX_AGE_DAYS',
388
+ apply: (cfg, v) => { cfg.audit.logMaxAgeDays = Number(v); },
389
+ },
390
+ {
391
+ env: 'DSH_EDGE_LOG_MAX_ROTATED',
392
+ apply: (cfg, v) => { cfg.audit.logMaxRotated = Number(v); },
393
+ },
394
+ {
395
+ env: 'DSH_EDGE_MAX_SESSIONS',
396
+ apply: (cfg, v) => { cfg.resources.maxSessions = Number(v); },
397
+ },
398
+ {
399
+ env: 'DSH_EDGE_MAX_BODY_BYTES',
400
+ apply: (cfg, v) => { cfg.resources.maxBodyBytes = Number(v); },
401
+ },
402
+ {
403
+ env: 'DSH_EDGE_SESSION_IDLE_MS',
404
+ apply: (cfg, v) => { cfg.resources.sessionIdleMs = Number(v); },
405
+ },
406
+ {
407
+ env: 'DSH_EDGE_TEE_TYPE',
408
+ apply: (cfg, v) => { cfg.tee.type = v; },
409
+ },
410
+ {
411
+ env: 'DSH_EDGE_PERMISSION_MODE',
412
+ apply: (cfg, v) => { cfg.agent.permissionMode = v; },
413
+ },
414
+ {
415
+ env: 'DSH_EDGE_REALM_HOT_RELOAD',
416
+ apply: (cfg, v) => { cfg.privacy.realmHotReload = v !== 'false'; },
417
+ },
418
+ {
419
+ env: 'DSH_EDGE_REALM_WATCH_MS',
420
+ apply: (cfg, v) => { cfg.privacy.realmWatchMs = Number(v); },
421
+ },
422
+ {
423
+ env: 'DSH_EDGE_POLICY_HOT_RELOAD',
424
+ apply: (cfg, v) => { cfg.privacy.policyHotReload = v !== 'false'; },
425
+ },
426
+ {
427
+ env: 'DSH_EDGE_POLICY_WATCH_MS',
428
+ apply: (cfg, v) => { cfg.privacy.policyWatchMs = Number(v); },
429
+ },
430
+ {
431
+ env: 'DSH_EDGE_INTENT_JEV_CONFIDENCE_THRESHOLD',
432
+ apply: (cfg, v) => { cfg.jev.confidenceThreshold = Number(v); },
433
+ },
434
+ {
435
+ // 方向分闸的 LOCAL 侧门:LOCAL 倾向的判决用低门即采纳(默认本地,
436
+ // 不落 LLM),非 LOCAL 仍走 confidenceThreshold(出网要严门)。
437
+ env: 'DSH_EDGE_INTENT_JEV_LOCAL_ADOPT_FLOOR',
438
+ apply: (cfg, v) => { cfg.jev.localAdoptFloor = Number(v); },
439
+ },
440
+ {
441
+ env: 'DSH_EDGE_INTENT_JEV_RISK_THRESHOLD',
442
+ apply: (cfg, v) => { cfg.jev.riskThreshold = Number(v); },
443
+ },
444
+ {
445
+ env: 'DSH_EDGE_INTENT_JEV_BREAKER_TTL_MS',
446
+ apply: (cfg, v) => { cfg.jev.breakerTtlMs = Number(v); },
447
+ },
448
+ {
449
+ env: 'DSH_EDGE_INTENT_JEV_BOOT_PROBE_ENABLED',
450
+ apply: (cfg, v) => { cfg.jev.bootProbeEnabled = v !== 'false'; },
451
+ },
452
+ {
453
+ env: 'DSH_EDGE_INTENT_JEV_BOOT_PROBE_TIMEOUT_MS',
454
+ apply: (cfg, v) => { cfg.jev.bootProbeTimeoutMs = Number(v); },
455
+ },
456
+ {
457
+ env: 'DSH_EDGE_INTENT_JEV_BREAKER_PROBE_MS',
458
+ apply: (cfg, v) => { cfg.jev.breakerProbeMs = Number(v); },
459
+ },
460
+ {
461
+ env: 'DSH_EDGE_INTENT_JEV_PROBE_TIMEOUT_MS',
462
+ apply: (cfg, v) => { cfg.jev.probeTimeoutMs = Number(v); },
463
+ },
464
+ {
465
+ env: 'DSH_EDGE_GUARD_MODEL',
466
+ apply: (cfg, v) => { cfg.stages.guard.enabled = v !== 'false'; },
467
+ },
468
+ {
469
+ env: 'DSH_EDGE_GUARD_MODEL_PROVIDER',
470
+ apply: (cfg, v) => { cfg.stages.guard.provider = v; },
471
+ },
472
+ {
473
+ env: 'DSH_EDGE_GUARD_MODEL_MODEL',
474
+ apply: (cfg, v) => { cfg.stages.guard.model = v; },
475
+ },
476
+ {
477
+ // §7.102 follow-up (2026-10-21): these two were emitted by
478
+ // scripts/edge-config-env.mjs but had no consumer here, so an operator
479
+ // who exported them saw them silently do nothing. The guard stage has
480
+ // had `timeoutMs` / `maxInput` in the file schema all along.
481
+ env: 'DSH_EDGE_GUARD_MODEL_TIMEOUT_MS',
482
+ apply: (cfg, v) => { cfg.stages.guard.timeoutMs = Number(v); },
483
+ },
484
+ {
485
+ env: 'DSH_EDGE_GUARD_MODEL_MAX_INPUT',
486
+ apply: (cfg, v) => { cfg.stages.guard.maxInput = Number(v); },
487
+ },
488
+ {
489
+ env: 'DSH_EDGE_INTENT_PROVIDER',
490
+ apply: (cfg, v) => { cfg.stages.intent.provider = v; },
491
+ },
492
+ {
493
+ env: 'DSH_EDGE_INTENT_MODEL',
494
+ apply: (cfg, v) => { cfg.stages.intent.model = v; },
495
+ },
496
+ {
497
+ env: 'DSH_EDGE_INTENT_CONFIDENCE_THRESHOLD',
498
+ apply: (cfg, v) => { cfg.stages.intent.confidenceThreshold = Number(v); },
499
+ },
500
+ {
501
+ env: 'DSH_EDGE_INTENT_LLM_TIMEOUT_MS',
502
+ apply: (cfg, v) => { cfg.stages.intent.timeoutMs = Number(v); },
503
+ },
504
+ {
505
+ env: 'DSH_EDGE_INTENT_RECENT_LIMIT',
506
+ apply: (cfg, v) => { cfg.stages.intent.recentLimit = Number(v); },
507
+ },
508
+ {
509
+ env: 'DSH_EDGE_INTENT_MAX_INPUT',
510
+ apply: (cfg, v) => { cfg.stages.intent.maxInput = Number(v); },
511
+ },
512
+ {
513
+ env: 'DSH_EDGE_INTENT_CLOUD_KEYWORDS',
514
+ apply: (cfg, v) => { cfg.stages.intent.cloudKeywords = v.split(',').filter(Boolean); },
515
+ },
516
+ {
517
+ env: 'DSH_EDGE_INTENT_LOCAL_ENTITIES',
518
+ apply: (cfg, v) => { cfg.stages.intent.localEntities = v.split(',').filter(Boolean); },
519
+ },
520
+ {
521
+ env: 'DSH_EDGE_INTENT_FALLBACK_KEYWORDS',
522
+ apply: (cfg, v) => { cfg.stages.intent.fallbackKeywords = v.split(',').filter(Boolean); },
523
+ },
524
+ {
525
+ env: 'DSH_EDGE_INTENT_LLM_FALLBACK',
526
+ apply: (cfg, v) => { cfg.stages.intent.llmFallback = v !== 'false'; },
527
+ },
528
+ {
529
+ env: 'DSH_EDGE_INTENT_JEV_ENABLED',
530
+ apply: (cfg, v) => { cfg.jev.enabled = v !== 'false'; },
531
+ },
532
+ {
533
+ env: 'DSH_EDGE_INTENT_JEV_URL',
534
+ apply: (cfg, v) => { cfg.jev.url = v; },
535
+ },
536
+ {
537
+ env: 'DSH_EDGE_INTENT_JEV_TIMEOUT_MS',
538
+ apply: (cfg, v) => { cfg.jev.timeoutMs = Number(v); },
539
+ },
540
+ {
541
+ env: 'DSH_EDGE_INPUT_GUARD',
542
+ apply: (cfg, v) => { cfg.privacy.inputGuardEnabled = v !== 'false'; },
543
+ },
544
+ {
545
+ env: 'DSH_EDGE_REJECT_POLICY',
546
+ apply: (cfg, v) => { cfg.privacy.rejectPolicyEnabled = v !== 'false'; },
547
+ },
548
+ {
549
+ env: 'DSH_EDGE_SANITIZE_DOWNGRADE',
550
+ apply: (cfg, v) => { cfg.privacy.sanitizeDowngradeEnabled = v !== 'false'; },
551
+ },
552
+ {
553
+ env: 'DSH_EDGE_SAN_TTL_MS',
554
+ apply: (cfg, v) => { cfg.privacy.sanTtlMs = Number(v); },
555
+ },
556
+ {
557
+ env: 'DSH_EDGE_SAN_MAX_SESSIONS',
558
+ apply: (cfg, v) => { cfg.privacy.sanMaxSessions = Number(v); },
559
+ },
560
+ {
561
+ env: 'DSH_EDGE_SAN_SWEEP_MS',
562
+ apply: (cfg, v) => { cfg.privacy.sanSweepMs = Number(v); },
563
+ },
564
+ {
565
+ env: 'DSH_EDGE_TD_CLOUD_RATE',
566
+ apply: (cfg, v) => { cfg.threat.cloudRate = Number(v); },
567
+ },
568
+ {
569
+ env: 'DSH_EDGE_TD_BLOCK_LEVEL',
570
+ apply: (cfg, v) => { cfg.threat.blockLevel = v; },
571
+ },
572
+ {
573
+ env: 'DSH_EDGE_FUNCTION_CALL_EXTERNAL_ENABLED',
574
+ apply: (cfg, v) => { cfg.functionCallExternal.enabled = v === 'true'; },
575
+ },
576
+ {
577
+ env: 'DSH_EDGE_FUNCTION_CALL_EXTERNAL_URL',
578
+ apply: (cfg, v) => { cfg.functionCallExternal.url = v; },
579
+ },
580
+ {
581
+ env: 'DSH_EDGE_FUNCTION_CALL_EXTERNAL_TIMEOUT_MS',
582
+ apply: (cfg, v) => { cfg.functionCallExternal.timeoutMs = Number(v); },
583
+ },
584
+ {
585
+ env: 'DSH_EDGE_FUNCTION_CALL_EXTERNAL_CONFIDENCE_THRESHOLD',
586
+ apply: (cfg, v) => { cfg.functionCallExternal.confidenceThreshold = Number(v); },
587
+ },
588
+ {
589
+ env: 'DSH_EDGE_LOCAL_LLM_STRIP_TOOLS',
590
+ apply: (cfg, v) => { cfg.llm.local.stripTools = v === 'true'; },
591
+ },
592
+ {
593
+ env: 'DSH_EDGE_LOCAL_LLM_ENABLE_THINKING',
594
+ apply: (cfg, v) => { cfg.llm.local.enableThinking = v === 'true'; },
595
+ },
596
+ { env: 'DSH_EDGE_LOG_ROOT', apply: (cfg, v) => { cfg.audit.logRoot = v; } },
597
+ { env: 'DSH_EDGE_AUDIT_ROOT', apply: (cfg, v) => { cfg.audit.root = v; } },
598
+ { env: 'DSH_EDGE_STORAGE_ROOT', apply: (cfg, v) => { cfg.storage.root = v; } },
599
+ { env: 'DSH_EDGE_PRIVACY_POLICY_PATH', apply: (cfg, v) => { cfg.privacy.policyPath = v; } },
600
+ { env: 'DSH_EDGE_REALM_CONFIG', apply: (cfg, v) => { cfg.privacy.realmConfigPath = v; } },
601
+ { env: 'DSH_EDGE_ETSCLI_BIN', apply: (cfg, v) => { cfg.etscli.bin = v; } },
602
+ { env: 'DSH_EDGE_ETSCLI_TIMEOUT_MS', apply: (cfg, v) => { cfg.etscli.timeoutMs = Number(v); } },
603
+ ];
604
+ /** A fresh deep copy of the built-in defaults. */
605
+ function deepCopyDefaults() {
606
+ return {
607
+ ...DEFAULT_EDGE_CONFIG,
608
+ secrets: { ...DEFAULT_EDGE_CONFIG.secrets },
609
+ http: { ...DEFAULT_EDGE_CONFIG.http },
610
+ llm: {
611
+ local: { ...DEFAULT_EDGE_CONFIG.llm.local },
612
+ cloud: { ...DEFAULT_EDGE_CONFIG.llm.cloud },
613
+ },
614
+ stages: {
615
+ guard: { ...DEFAULT_EDGE_CONFIG.stages.guard },
616
+ intent: { ...DEFAULT_EDGE_CONFIG.stages.intent },
617
+ },
618
+ jev: { ...DEFAULT_EDGE_CONFIG.jev },
619
+ functionCallExternal: { ...DEFAULT_EDGE_CONFIG.functionCallExternal },
620
+ privacy: { ...DEFAULT_EDGE_CONFIG.privacy },
621
+ resources: { ...DEFAULT_EDGE_CONFIG.resources },
622
+ audit: { ...DEFAULT_EDGE_CONFIG.audit },
623
+ tee: { ...DEFAULT_EDGE_CONFIG.tee },
624
+ threat: { ...DEFAULT_EDGE_CONFIG.threat },
625
+ webSearch: { ...DEFAULT_EDGE_CONFIG.webSearch },
626
+ agent: { ...DEFAULT_EDGE_CONFIG.agent },
627
+ };
628
+ }
629
+ /**
630
+ * Apply the env override layer on top of a freshly loaded config.
631
+ *
632
+ * §7.154: exported so scripts/edge-config-from-env.ts can apply the SAME
633
+ * mapping the runtime uses without writing the template to the target file
634
+ * first. That script used to copy the template over the operator's live
635
+ * config (destroying every secret that only existed in the real file) even
636
+ * under --dry-run — it needed this layer, and the file round-trip was the
637
+ * only way it could reach it.
638
+ *
639
+ * Mutates and returns `cfg`.
640
+ */
641
+ export function applyEnvOverrides(cfg) {
642
+ if (typeof process === 'undefined' || !process.env)
643
+ return cfg;
644
+ for (const { env, apply } of ENV_OVERRIDES) {
645
+ const value = process.env[env];
646
+ if (value === undefined || value.length === 0)
647
+ continue;
648
+ try {
649
+ apply(cfg, value);
650
+ }
651
+ catch {
652
+ // A malformed override must not take the whole config down.
653
+ }
654
+ }
655
+ return cfg;
656
+ }
657
+ /**
658
+ * Load the effective configuration. Cached after the first call — callers
659
+ * that need a re-read can call resetEdgeConfigCache().
660
+ */
661
+ export function loadEdgeConfig(force = false, candidates) {
662
+ if (cached && !force)
663
+ return cached;
664
+ // force 快速路径:文件指纹(mtime+size)与 env 覆盖值都没变 → 直接回
665
+ // 缓存。语义不变 —— force 想要的是"看到配置的最新值",指纹比对恰好
666
+ // 保证这一点;只有真的动了文件/环境才付 readFileSync+JSON.parse。
667
+ if (cached && cachedStat) {
668
+ const sig = envOverrideSignature();
669
+ if (sig === cachedEnvSig) {
670
+ try {
671
+ const st = statSync(cachedStat.path);
672
+ if (st.mtimeMs === cachedStat.mtimeMs && st.size === cachedStat.size)
673
+ return cached;
674
+ }
675
+ catch {
676
+ /* 文件没了 → 落回候选扫描 */
677
+ }
678
+ }
679
+ }
680
+ // §7.182: 候选表默认两档(顺位 1 插件目录 / 顺位 2 home),顺序即优先级 ——
681
+ // 第一个**存在且能解析**的文件胜出;顺位 1 不存在才落到顺位 2。
682
+ // 插件目录下的 edge.example.json 不在候选表里,任何情况下都不会被读到。
683
+ for (const candidate of candidates ?? edgeConfigCandidates()) {
684
+ if (!existsSync(candidate))
685
+ continue;
686
+ try {
687
+ const raw = readFileSync(candidate, 'utf-8');
688
+ const parsed = JSON.parse(raw);
689
+ // Deep-merge so a partial file still inherits the defaults for the
690
+ // sections it does not mention.
691
+ const merged = {
692
+ ...DEFAULT_EDGE_CONFIG,
693
+ ...parsed,
694
+ secrets: { ...DEFAULT_EDGE_CONFIG.secrets, ...parsed.secrets },
695
+ http: { ...DEFAULT_EDGE_CONFIG.http, ...parsed.http },
696
+ llm: {
697
+ local: { ...DEFAULT_EDGE_CONFIG.llm.local, ...parsed.llm?.local },
698
+ cloud: { ...DEFAULT_EDGE_CONFIG.llm.cloud, ...parsed.llm?.cloud },
699
+ },
700
+ stages: {
701
+ guard: { ...DEFAULT_EDGE_CONFIG.stages.guard, ...parsed.stages?.guard },
702
+ intent: { ...DEFAULT_EDGE_CONFIG.stages.intent, ...parsed.stages?.intent },
703
+ },
704
+ jev: { ...DEFAULT_EDGE_CONFIG.jev, ...parsed.jev },
705
+ functionCallExternal: { ...DEFAULT_EDGE_CONFIG.functionCallExternal, ...parsed.functionCallExternal },
706
+ privacy: { ...DEFAULT_EDGE_CONFIG.privacy, ...parsed.privacy },
707
+ resources: { ...DEFAULT_EDGE_CONFIG.resources, ...parsed.resources },
708
+ audit: { ...DEFAULT_EDGE_CONFIG.audit, ...parsed.audit },
709
+ tee: { ...DEFAULT_EDGE_CONFIG.tee, ...parsed.tee },
710
+ threat: { ...DEFAULT_EDGE_CONFIG.threat, ...parsed.threat },
711
+ webSearch: { ...DEFAULT_EDGE_CONFIG.webSearch, ...parsed.webSearch },
712
+ agent: { ...DEFAULT_EDGE_CONFIG.agent, ...parsed.agent },
713
+ storage: { ...DEFAULT_EDGE_CONFIG.storage, ...parsed.storage },
714
+ etscli: { ...DEFAULT_EDGE_CONFIG.etscli, ...parsed.etscli },
715
+ };
716
+ cached = applyEnvOverrides(merged);
717
+ cachedPath = candidate;
718
+ try {
719
+ const st = statSync(candidate);
720
+ cachedStat = { path: candidate, mtimeMs: st.mtimeMs, size: st.size };
721
+ }
722
+ catch {
723
+ cachedStat = undefined;
724
+ }
725
+ cachedEnvSig = envOverrideSignature();
726
+ return cached;
727
+ }
728
+ catch {
729
+ // A malformed file must not take the whole bundle down — fall through
730
+ // to the next candidate and eventually to the defaults.
731
+ }
732
+ }
733
+ // Deep-copy before applying the env overrides: a shallow spread would
734
+ // share the nested section objects with DEFAULT_EDGE_CONFIG, so an env
735
+ // override would permanently mutate the module-level defaults and leak
736
+ // into every later load (observed: a test that set DSH_EDGE_MODEL kept
737
+ // seeing 'deepseek-flash' after the env was cleared).
738
+ cached = applyEnvOverrides(deepCopyDefaults());
739
+ cachedPath = undefined;
740
+ cachedStat = undefined;
741
+ cachedEnvSig = envOverrideSignature();
742
+ return cached;
743
+ }
744
+ /** Drop the cache so the next loadEdgeConfig() re-reads from disk. */
745
+ export function resetEdgeConfigCache() {
746
+ cached = undefined;
747
+ cachedPath = undefined;
748
+ cachedStat = undefined;
749
+ cachedEnvSig = undefined;
750
+ }
751
+ /** ENV_OVERRIDES 覆盖值的指纹 —— force 快速路径必须感知 env 变化(测试
752
+ * 会改 process.env 后期望重载生效)。 */
753
+ function envOverrideSignature() {
754
+ if (typeof process === 'undefined' || !process.env)
755
+ return '';
756
+ let sig = '';
757
+ for (const { env } of ENV_OVERRIDES)
758
+ sig += `${env}=${process.env[env] ?? ''}\n`;
759
+ return sig;
760
+ }
761
+ /* ── Typed accessors ──────────────────────────────────────────────────── */
762
+ /**
763
+ * Merge one section of the edge config into a plugin's cordis Config.
764
+ *
765
+ * The cordis patch row no longer carries any local configuration (§7.102) —
766
+ * it only declares the plugin. The plugin therefore fills its own Config
767
+ * from this file at apply() time.
768
+ *
769
+ * @param section which section of edge.json to read
770
+ * @param mapping config field name -> edge.json field name
771
+ * @param target the plugin's Config object, mutated in place
772
+ * @param ctx optional logger; when given, changes are logged
773
+ */
774
+ export function mergeEdgeConfig(section, mapping, target, logger) {
775
+ const cfg = loadEdgeConfig();
776
+ const values = cfg[section];
777
+ if (!values)
778
+ return target;
779
+ // The generic T cannot be written through an index signature (TS2862), so
780
+ // the mutation goes through a widened view of the same object.
781
+ const sink = target;
782
+ for (const [configField, edgeField] of Object.entries(mapping)) {
783
+ const value = values[edgeField];
784
+ if (value === undefined || value === null)
785
+ continue;
786
+ if (typeof value === 'string' && value.length === 0)
787
+ continue;
788
+ if (sink[configField] === value)
789
+ continue;
790
+ logger?.info?.(`[edge-config] ${section}.${edgeField} → ${configField}: `
791
+ + `${String(sink[configField])} → ${String(value)}`);
792
+ sink[configField] = value;
793
+ }
794
+ return target;
795
+ }
796
+ /** Resolve a key reference (e.g. 'dashscopeApiKey') to its literal value. */
797
+ export function resolveSecret(cfg, ref) {
798
+ if (!ref)
799
+ return undefined;
800
+ const value = cfg.secrets?.[ref];
801
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
802
+ }
803
+ /**
804
+ * Every secret reference in the config that points at a slot which is not
805
+ * there (or is empty).
806
+ *
807
+ * @param cfg the config to inspect; defaults to the effective one
808
+ */
809
+ export function danglingSecretRefs(cfg = loadEdgeConfig()) {
810
+ const secrets = (cfg.secrets ?? {});
811
+ const out = [];
812
+ const check = (field, ref) => {
813
+ if (!ref)
814
+ return;
815
+ const value = secrets[ref];
816
+ if (typeof value === 'string' && value.length > 0)
817
+ return;
818
+ out.push({ field, ref });
819
+ };
820
+ check('llm.local.keyRef', cfg.llm?.local?.keyRef);
821
+ check('llm.cloud.keyRef', cfg.llm?.cloud?.keyRef);
822
+ // jev carries the Bailian MaaS decision-model key (§7.112).
823
+ check('jev.apiKeyRef', cfg.jev?.apiKeyRef);
824
+ return out;
825
+ }
826
+ /**
827
+ * One-line human summary of dangling references, for an operator-facing log.
828
+ */
829
+ export function formatDanglingSecretRefs(refs) {
830
+ return refs.map((r) => `${r.field} → secrets.${r.ref}(该槽位不存在或为空)`).join('; ');
831
+ }
832
+ /**
833
+ * The local-LLM pool with its secret resolved into an api key.
834
+ *
835
+ * §7.171: `baseUrl` 留空 = DELEGATE 模式(转发宿主提供商),与 `llm.cloud` 对称。
836
+ * `delegate: false` 是 kill switch —— 回到 opt-in 语义(不注册任何路由)。
837
+ */
838
+ export function localPool(cfg = loadEdgeConfig()) {
839
+ const pool = cfg.llm?.local ?? {};
840
+ return {
841
+ baseUrl: pool.baseUrl || undefined,
842
+ model: pool.model || undefined,
843
+ apiKey: resolveSecret(cfg, pool.keyRef),
844
+ stripTools: pool.stripTools ?? false,
845
+ enableThinking: pool.enableThinking ?? false,
846
+ reasoningEffortNone: pool.reasoningEffortNone ?? false,
847
+ delegate: pool.delegate ?? true,
848
+ delegateProvider: pool.delegateProvider || undefined,
849
+ delegateModel: pool.delegateModel || undefined,
850
+ };
851
+ }
852
+ /** The cloud-LLM pool. Empty baseUrl means DELEGATE mode. */
853
+ export function cloudPool(cfg = loadEdgeConfig()) {
854
+ const pool = cfg.llm?.cloud ?? {};
855
+ return {
856
+ baseUrl: pool.baseUrl || undefined,
857
+ model: pool.model || undefined,
858
+ apiKey: resolveSecret(cfg, pool.keyRef),
859
+ delegate: pool.delegate ?? true,
860
+ delegateProvider: pool.delegateProvider || undefined,
861
+ delegateModel: pool.delegateModel || undefined,
862
+ };
863
+ }
864
+ /** §7.112 (2026-10-25): the jev intent-recognizer pool. `apiKey` resolves
865
+ * `apiKeyRef` → `secrets.*` — the Bailian MaaS systemone endpoint requires
866
+ * an Authorization header, which the local ONNX jev never needed. */
867
+ export function jevPool(cfg = loadEdgeConfig()) {
868
+ const pool = cfg.jev ?? {};
869
+ return {
870
+ enabled: pool.enabled,
871
+ url: pool.url || undefined,
872
+ provider: pool.provider || undefined,
873
+ model: pool.model || undefined,
874
+ apiKey: resolveSecret(cfg, pool.apiKeyRef),
875
+ authScheme: pool.authScheme || undefined,
876
+ path: pool.path || undefined,
877
+ };
878
+ }
879
+ /**
880
+ * The model an internal LLM stage should use.
881
+ *
882
+ * §7.100 privacy invariant: internal stages (guard detection, intent
883
+ * classification) must only ever target the LOCAL pool. The stage config
884
+ * may name a provider, but a cloud provider is refused here — sending the
885
+ * raw user text to a cloud model would break the edge-side privacy promise.
886
+ * The caller falls back to its keyword / regex rules when this returns
887
+ * undefined and no local endpoint is reachable.
888
+ */
889
+ export function stageModel(stage, cfg = loadEdgeConfig()) {
890
+ const stageCfg = cfg.stages?.[stage];
891
+ if (!stageCfg)
892
+ return undefined;
893
+ const provider = stageCfg.provider || 'local-llm';
894
+ if (provider !== 'local-llm')
895
+ return undefined;
896
+ const model = stageCfg.model || localPool(cfg).model;
897
+ return { provider, model };
898
+ }