@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,1811 @@
1
+ /**
2
+ * http-api — exposes an HTTP server from the parent DSH process so that
3
+ * external consumers (mobile apps, third-party services) can talk to a
4
+ * running agent. Optional S13 plugin.
5
+ *
6
+ * @module dsh-edge-agent/http-api
7
+ */
8
+ import { Service } from '@deepseek-ai/cordis';
9
+ import Schema from '@deepseek-ai/schemastery';
10
+ import { createServer } from 'node:http';
11
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
12
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
13
+ import { SessionId } from '@deepseek-ai/dsh-session';
14
+ import { redactString } from './pii-utils.js';
15
+ import { findRoute } from './http-api-routes.js';
16
+ import { HttpApiSessionsImpl } from './http-api-sessions.js';
17
+ import { cloudPool, loadEdgeConfig, localPool } from './edge-config.js';
18
+ /**
19
+ * Snapshot a live Session's event log, papering over the upstream API
20
+ * change: 0.1.2-alpha.x exposed `.events` directly, 0.1.2-rc.1 removed it
21
+ * in favour of `Session.snapshotEvents()`. Mirror of dsh-tui's
22
+ * `compat/liveSession.snapshotLiveSessionEvents`.
23
+ */
24
+ function snapshotSessionEvents(session) {
25
+ if (typeof session.snapshotEvents === 'function') {
26
+ return session.snapshotEvents();
27
+ }
28
+ if (Array.isArray(session.events))
29
+ return session.events;
30
+ return [];
31
+ }
32
+ export const name = 'http-api';
33
+ export const inject = ['agents', 'intentRouter', 'auditLog', 'tdBus', 'realmSkillBridge', 'edgeTools'];
34
+ // §7.102 "one config file": the bind/limit knobs carry NO Schema default —
35
+ // apply() fills them from config/edge.json (http.* / resources.*) so an
36
+ // operator editing edge.json actually changes the listener instead of
37
+ // silently hitting a default baked into the Schema.
38
+ export const Config = Schema.object({
39
+ host: Schema.string(),
40
+ port: Schema.natural(),
41
+ bearerToken: Schema.string(),
42
+ sessionIdleTimeoutMs: Schema.natural(),
43
+ maxActiveSessions: Schema.natural(),
44
+ maxRequestBodyBytes: Schema.natural(),
45
+ });
46
+ /** Max entries retained in SessionRecord.recentMessages. Must stay in sync
47
+ * with `intent-router.recentMessagesLimit` × 2 (the router reads the last
48
+ * `recentMessagesLimit` entries, so 3 pairs = 6 entries covers 3 user
49
+ * turns with their assistant responses). */
50
+ const MAX_RECENT_MESSAGES_PER_SESSION = 6;
51
+ export class HttpApiServiceImpl extends Service {
52
+ cordisCtx;
53
+ config;
54
+ agents;
55
+ auditLog;
56
+ server;
57
+ sessions = new Map();
58
+ /** Per-session ring buffer for multi-turn context. See RecentMessage for
59
+ * schema. Lives outside `sessions` so pushRecentMessage can accept writes
60
+ * before ensureSession() finishes (first-turn user message). */
61
+ sessionBuffers = new Map();
62
+ running = false;
63
+ intentRouter;
64
+ realmSkillBridge;
65
+ tdBus;
66
+ cleanupTimer;
67
+ /** Soft-dep wrapper for the upstream `sessions` plugin. See
68
+ * `./http-api-sessions.ts`. Constructed lazily in `start()` so the
69
+ * upstream plugin has time to register (boot-order race). */
70
+ sessionsHelper = new HttpApiSessionsImpl(null);
71
+ constructor(cordisCtx, config, agents, auditLog, tdBus) {
72
+ super(cordisCtx, 'httpApi');
73
+ this.cordisCtx = cordisCtx;
74
+ this.config = config;
75
+ this.agents = agents;
76
+ this.auditLog = auditLog;
77
+ this.tdBus = tdBus;
78
+ this.routes = this.buildRoutes();
79
+ }
80
+ /** Route table — built once at construction so handlers can reference
81
+ * the service via `this`. See `./http-api-routes.ts` for the matcher. */
82
+ routes;
83
+ /** Emit an audit-log entry (silent no-op if missing or wrong API). */
84
+ audit(level, kind, payload) {
85
+ if (!this.auditLog || typeof this.auditLog.log !== 'function') {
86
+ return;
87
+ }
88
+ void this.auditLog.log({ level, kind, source: 'http-api', payload }).catch(() => { });
89
+ }
90
+ /** One-line access log (visible in /tmp/dsh.log via stdout exporter).
91
+ *
92
+ * §7.123 (2026-10-27): wrapped in try/catch + optional chaining, the same
93
+ * pattern the §7.46.5 chat lines already use. This helper runs on EVERY
94
+ * request, so an unguarded `this.ctx.logger?.info?.(...)` meant a logger
95
+ * that throws (full disk, a formatter bug, a broken exporter) turned
96
+ * every HTTP call into a 500 — the observability path took the data path
97
+ * down with it. A log line must never be able to fail a request. */
98
+ accessLog(reqId, method, path, status, durationMs, extra) {
99
+ this.safeLog('info', `[http-api][${reqId}] ${method} ${path} ${status} ${durationMs}ms${extra ? ' ' + extra : ''}`);
100
+ }
101
+ /**
102
+ * §7.123 (2026-10-27): logger call that can never throw.
103
+ *
104
+ * A logger is not a trusted dependency: a full disk, a broken exporter or
105
+ * a formatter bug makes `logger.info(...)` throw. On the request path that
106
+ * turned a successful chat into a 500 (see `ensureSession`'s "Creating
107
+ * agent" line, which sat unguarded in the middle of session creation).
108
+ * Observability must never be able to fail the data path.
109
+ */
110
+ safeLog(level, line) {
111
+ try {
112
+ const logger = this.ctx?.logger;
113
+ logger?.[level]?.(line);
114
+ }
115
+ catch {
116
+ // Intentionally swallowed — see the doc comment.
117
+ }
118
+ }
119
+ /** Append a recent-message entry to the session's ring buffer. Evicts
120
+ * oldest entries when the buffer exceeds MAX_RECENT_MESSAGES_PER_SESSION.
121
+ * Trims text to 200 chars to bound prompt size (intent-router further
122
+ * truncates per-message inside the historyBlock). Always accepts the
123
+ * push — the buffer map is independent of SessionRecord, so it can be
124
+ * written to before ensureSession() finishes (first-turn user message)
125
+ * or after handleDeleteSession() drops the session (orphan cleanup
126
+ * happens on the next sweep). */
127
+ pushRecentMessage(sessionId, role, text) {
128
+ const trimmed = text.trim().slice(0, 200);
129
+ if (!trimmed)
130
+ return;
131
+ let buf = this.sessionBuffers.get(sessionId);
132
+ if (!buf) {
133
+ buf = [];
134
+ this.sessionBuffers.set(sessionId, buf);
135
+ }
136
+ buf.push({ role, text: trimmed, ts: Date.now() });
137
+ while (buf.length > MAX_RECENT_MESSAGES_PER_SESSION) {
138
+ buf.shift();
139
+ }
140
+ }
141
+ /** Read a snapshot of the session's recent-message ring buffer.
142
+ * Returns [] if the session has no history yet. The caller MUST take
143
+ * this snapshot BEFORE pushing the current user message — the router
144
+ * embeds the snapshot as the "history" block and appends the current
145
+ * query itself. */
146
+ getPriorRecentMessages(sessionId) {
147
+ const buf = this.sessionBuffers.get(sessionId);
148
+ return buf ? buf.slice() : [];
149
+ }
150
+ /** Drop the buffer for a session. Called from handleDeleteSession,
151
+ * cleanupExpiredSessions, evictLeastRecentSession, and stop().
152
+ *
153
+ * 也负责清除 sanitizer 的会话映射表——那张表里存的是**明文 PII**,
154
+ * 若只删 buffer 不 purge,PII 会一直驻留在堆里直到进程退出
155
+ * (2026-08-30 修复)。软依赖 + best-effort:sanitizer 未注册或
156
+ * purge 失败都不能影响会话删除本身。 */
157
+ dropSessionBuffer(sessionId) {
158
+ this.sessionBuffers.delete(sessionId);
159
+ // §7.51 (2026-09-13): wrap sanitizer access in try/catch. Cordis
160
+ // Proxy throws "cannot get property 'sanitizer' without inject" if
161
+ // 'sanitizer' is not in this plugin's inject list. We don't declare
162
+ // 'sanitizer' in inject (it's a soft dep — the upstream
163
+ // sanitizer-pipeline plugin may not be mounted in every deployment),
164
+ // but the property access through `this.cordisCtx` is intercepted by
165
+ // the Proxy regardless of TS-level `as unknown as` casts. Without
166
+ // the try/catch, this throws and **crashes the dsh-tui process** when
167
+ // cleanupExpiredSessions fires from a setInterval timer (Node v22
168
+ // surfaces unhandled timer exceptions as fatal errors, killing the
169
+ // whole process). Sanitizer is best-effort: skip purge if it's not
170
+ // available; sanitizer has its own TTL sweep as the backstop.
171
+ let sanitizer;
172
+ try {
173
+ sanitizer = this.cordisCtx.sanitizer;
174
+ }
175
+ catch {
176
+ // soft dep missing — ignore
177
+ return;
178
+ }
179
+ if (typeof sanitizer?.purge === 'function') {
180
+ void Promise.resolve(sanitizer.purge(sessionId)).catch(() => {
181
+ // 忽略:sanitizer 自身还有 TTL 清扫兜底
182
+ });
183
+ }
184
+ }
185
+ /**
186
+ * Resolve the actual provider + model to use for a given intent verdict.
187
+ * Falls back to the legacy hardcoded pair (`local-llm` /
188
+ * `cloud-llm` plus env-overridable model) when:
189
+ * - `modelRouter` is not registered on the cordis context (boot-order
190
+ * race: http-api boots before model-router in some configs)
191
+ * - `modelRouter.resolve` is unavailable
192
+ * - the lookup throws (defensive: never let router failure break a
193
+ * chat round-trip)
194
+ *
195
+ * Soft-dep pattern (no `inject`) — same as intent-router, because
196
+ * http-api boots ahead of model-router in the canonical plugin order.
197
+ * Extracted as a module-level pure function so unit tests can exercise
198
+ * the resolution matrix without standing up a full HttpApiServiceImpl.
199
+ */
200
+ resolveRoute(isLocal) {
201
+ return resolveRoute(this.cordisCtx, isLocal);
202
+ }
203
+ async start() {
204
+ if (this.running)
205
+ return;
206
+ // Get intentRouter after all plugins are loaded
207
+ try {
208
+ const router = this.cordisCtx.intentRouter;
209
+ if (router && typeof router.classify === 'function') {
210
+ this.intentRouter = router;
211
+ this.ctx.logger?.info?.('[http-api] intentRouter: found and valid');
212
+ }
213
+ else {
214
+ this.ctx.logger?.info?.('[http-api] intentRouter: exists but invalid', router);
215
+ }
216
+ }
217
+ catch (err) {
218
+ this.ctx.logger?.warn?.('[http-api] Error getting intentRouter:', err?.message);
219
+ }
220
+ // Get realmSkillBridge after all plugins are loaded. Optional — older
221
+ // cordis.yml configs may not mount it. When absent, Layer 1 (catalog
222
+ // filtering) and Layer 3 (tool realm gating) silently no-op.
223
+ try {
224
+ const bridge = this.cordisCtx.realmSkillBridge;
225
+ if (bridge && typeof bridge.setSessionRealm === 'function') {
226
+ this.realmSkillBridge = bridge;
227
+ this.ctx.logger?.info?.('[http-api] realmSkillBridge: found and valid');
228
+ }
229
+ }
230
+ catch (err) {
231
+ this.ctx.logger?.warn?.('[http-api] realmSkillBridge: not available:', err?.message);
232
+ }
233
+ // Phase 5 diagnostic: dump the upstream tool registry so E2E can see
234
+ // whether web_search / web_fetch are actually mounted. The Cordis proxy
235
+ // throws when `tools` hasn't injected, so wrap defensively.
236
+ try {
237
+ const edgeTools = this.cordisCtx.edgeTools;
238
+ if (edgeTools && typeof edgeTools.list === 'function') {
239
+ const allTools = edgeTools.list();
240
+ const names = allTools.map((t) => `${t.name}:${t.realm}`);
241
+ this.ctx.logger?.info?.(`[http-api] edgeTools.list() → ${names.length} tools: ${names.join(', ')}`);
242
+ const hasWebSearch = names.some((n) => n.startsWith('web_search:'));
243
+ const hasWebFetch = names.some((n) => n.startsWith('web_fetch:'));
244
+ this.ctx.logger?.info?.(`[http-api] web_search registered: ${hasWebSearch}, web_fetch registered: ${hasWebFetch}`);
245
+ }
246
+ else {
247
+ this.ctx.logger?.info?.('[http-api] edgeTools: missing or invalid');
248
+ }
249
+ }
250
+ catch (err) {
251
+ this.ctx.logger?.warn?.('[http-api] Error reading edgeTools:', err?.message);
252
+ }
253
+ // Initialize the soft-dep sessions helper. The wrapper ducks-types
254
+ // `cordisCtx.sessions` lazily so a missing upstream plugin just falls
255
+ // back to in-memory metadata (tests run that way).
256
+ this.sessionsHelper = new HttpApiSessionsImpl(this.cordisCtx);
257
+ // §7.123 (2026-10-27): the server callback must not drop the promise.
258
+ // `handleRequest` is async; an unhandled rejection inside it (a
259
+ // throwing logger, a malformed route param) would otherwise surface as
260
+ // an unhandledRejection and — depending on the host's process policy —
261
+ // terminate the process. Catch here, answer 500, and log defensively.
262
+ this.server = createServer((req, res) => {
263
+ void this.handleRequest(req, res).catch((err) => {
264
+ try {
265
+ this.ctx.logger?.error?.(`[http-api] unhandled request error: ${err?.message ?? String(err)}`);
266
+ }
267
+ catch {
268
+ // logger may be the thing that threw — do not re-throw.
269
+ }
270
+ if (!res.headersSent) {
271
+ res.writeHead(500, { 'Content-Type': 'application/json' });
272
+ }
273
+ try {
274
+ res.end(JSON.stringify({ error: 'Internal error' }));
275
+ }
276
+ catch {
277
+ // socket already gone.
278
+ }
279
+ });
280
+ });
281
+ await new Promise((resolve, reject) => {
282
+ this.server.once('error', reject);
283
+ this.server.listen(this.config.port, this.config.host, () => {
284
+ this.server.removeListener('error', reject);
285
+ resolve();
286
+ });
287
+ });
288
+ this.running = true;
289
+ // Start periodic session cleanup
290
+ if (this.config.sessionIdleTimeoutMs > 0) {
291
+ this.cleanupTimer = setInterval(() => {
292
+ this.cleanupExpiredSessions();
293
+ }, Math.min(this.config.sessionIdleTimeoutMs / 2, 60000)); // Check every min(timeout/2, 60s)
294
+ if (typeof this.cleanupTimer.unref === 'function')
295
+ this.cleanupTimer.unref();
296
+ }
297
+ this.audit('info', 'http.started', { host: this.config.host, port: this.config.port });
298
+ }
299
+ async stop() {
300
+ if (!this.running || !this.server)
301
+ return;
302
+ if (this.cleanupTimer) {
303
+ clearInterval(this.cleanupTimer);
304
+ this.cleanupTimer = undefined;
305
+ }
306
+ await new Promise((resolve) => {
307
+ this.server.close(() => resolve());
308
+ for (const record of this.sessions.values()) {
309
+ record.handle.dispose().catch(() => { });
310
+ }
311
+ this.sessions.clear();
312
+ this.sessionBuffers.clear();
313
+ });
314
+ this.running = false;
315
+ this.audit('info', 'http.stopped', {});
316
+ }
317
+ isRunning() {
318
+ return this.running;
319
+ }
320
+ endpoint() {
321
+ return this.running ? `http://${this.config.host}:${this.config.port}` : '';
322
+ }
323
+ async handleRequest(req, res) {
324
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
325
+ const path = url.pathname;
326
+ const method = req.method ?? 'GET';
327
+ const reqId = randomUUID().slice(0, 8);
328
+ const start = Date.now();
329
+ // OPTIONS preflight for the public read-only endpoints — returns 204
330
+ // without auth. CORS is NOT enabled (see docs/dual-entry-architecture.md
331
+ // §7.1), but loopback probes shouldn't get a 405 from a method-aware
332
+ // match table.
333
+ if (method === 'OPTIONS' && (path === '/v1/health' || path === '/v1/openapi.json')) {
334
+ res.writeHead(204);
335
+ res.end();
336
+ this.accessLog(reqId, 'OPTIONS', path, 204, Date.now() - start);
337
+ return;
338
+ }
339
+ // First pass: try to match a public route (no auth required).
340
+ const publicRoutes = this.routes.filter((r) => r.auth === 'public');
341
+ let publicMatch = findRoute(publicRoutes, method, path);
342
+ if (!publicMatch && method === 'HEAD') {
343
+ // RFC 7231 §4.3.2: HEAD is identical to GET except no body.
344
+ // curl -I / load-balancer probes are HEAD — fall back to GET so
345
+ // liveness probes don't trip auth and pollute audit log.
346
+ publicMatch = findRoute(publicRoutes, 'GET', path);
347
+ }
348
+ if (publicMatch) {
349
+ await this.invokeRoute(publicMatch.route, publicMatch.params, req, res, reqId, start, method);
350
+ return;
351
+ }
352
+ // Second pass: auth-required routes. Bearer check first.
353
+ if (!this.checkAuth(req)) {
354
+ res.writeHead(401, { 'Content-Type': 'application/json' });
355
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
356
+ this.accessLog(reqId, method, path, 401, Date.now() - start, 'auth-fail');
357
+ this.audit('warn', 'http.auth.fail', { reqId, method, path });
358
+ return;
359
+ }
360
+ const authedMatch = findRoute(this.routes.filter((r) => r.auth === 'bearer'), method, path);
361
+ if (!authedMatch) {
362
+ res.writeHead(404, { 'Content-Type': 'application/json' });
363
+ res.end(JSON.stringify({ error: 'Not found' }));
364
+ this.accessLog(reqId, method, path, 404, Date.now() - start);
365
+ return;
366
+ }
367
+ await this.invokeRoute(authedMatch.route, authedMatch.params, req, res, reqId, start, method);
368
+ }
369
+ /** Dispatch helper — builds the RouteContext (parsing the body only
370
+ * when the route declares `body: 'json'`), invokes the handler, and
371
+ * maps thrown errors to the right HTTP status code (mirrors the
372
+ * original if/else chain's error semantics). */
373
+ async invokeRoute(route, params, req, res, reqId, start, method) {
374
+ const ctx = { req, res, reqId, start, body: undefined };
375
+ try {
376
+ // Parse the body INSIDE the try block — readJsonBody tags errors
377
+ // with statusCode (413 / 400) that the error mapper below turns
378
+ // into the right HTTP response. Parsing outside the try would let
379
+ // those rejections bubble up as uncaught.
380
+ if (route.body === 'json') {
381
+ ctx.body = await this.readJsonBody(req);
382
+ }
383
+ await route.handler(ctx, params);
384
+ }
385
+ catch (err) {
386
+ const status = typeof err?.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 600
387
+ ? err.statusCode
388
+ : 500;
389
+ if (!res.headersSent) {
390
+ res.writeHead(status, { 'Content-Type': 'application/json' });
391
+ res.end(JSON.stringify({ error: err.message ?? 'Internal error' }));
392
+ }
393
+ else {
394
+ // SSE stream is already open; can't rewrite headers. Best we can
395
+ // do is terminate so the client sees the error.
396
+ try {
397
+ res.end();
398
+ }
399
+ catch { /* socket may be gone */ }
400
+ }
401
+ this.accessLog(reqId, method, new URL(req.url ?? '/', `http://${req.headers.host}`).pathname, status, Date.now() - start, `err="${err.message ?? 'unknown'}"`);
402
+ this.audit(status >= 500 ? 'error' : 'warn', 'http.error', { reqId, method, path: req.url, status, error: err.message });
403
+ }
404
+ }
405
+ checkAuth(req) {
406
+ // Fail-CLOSED: an unset token means "no credential exists", not "any
407
+ // credential is acceptable". The previous fail-open branch
408
+ // (`if (!this.config.bearerToken) return true`) turned a missing
409
+ // secrets.edgeApiKey into a fully open API — /v1/audit, session
410
+ // delete/fork included. apply() now refuses to start without a token,
411
+ // so reaching here with an empty token means the service was constructed
412
+ // directly (tests / embedding) — deny.
413
+ const expected = this.config.bearerToken;
414
+ if (!expected)
415
+ return false;
416
+ const auth = req.headers.authorization;
417
+ if (!auth)
418
+ return false;
419
+ const [scheme, token] = auth.split(' ');
420
+ if (scheme !== 'Bearer' || !token)
421
+ return false;
422
+ // Constant-time compare: a length-independent byte comparison keeps the
423
+ // token out of reach of timing analysis. `timingSafeEqual` throws when
424
+ // the buffers differ in length, so the length check comes first.
425
+ const a = Buffer.from(token, 'utf-8');
426
+ const b = Buffer.from(expected, 'utf-8');
427
+ if (a.length !== b.length)
428
+ return false;
429
+ return timingSafeEqual(a, b);
430
+ }
431
+ async handleChat(req, res, reqId, start, body) {
432
+ const { message, sessionId: requestedSessionId } = body ?? {};
433
+ if (!message || typeof message !== 'string') {
434
+ res.writeHead(400, { 'Content-Type': 'application/json' });
435
+ res.end(JSON.stringify({ error: 'message is required' }));
436
+ this.accessLog(reqId, 'POST', '/v1/chat', 400, Date.now() - start, 'no-message');
437
+ return;
438
+ }
439
+ const sessionId = requestedSessionId ?? randomUUID();
440
+ // §7.46.5 (2026-09-12): ops log 入口端点 — 让 "user 几时发的消息"
441
+ // 在 ops log 可见。配对 [input-guard] preflight intent + chat finished
442
+ // 形成完整 trace:收到 → 路由 → 调 LLM → 回复。messageLen 而非
443
+ // message 内容(避免 PII 落 ops log)。
444
+ try {
445
+ this.ctx.logger?.info?.(`[http-api] chat received sessionId=${sessionId} reqId=${reqId} messageLen=${message.length}`);
446
+ }
447
+ catch { /* debug only */ }
448
+ // Defense-in-depth: redact PII from the audit preview. audit-log's own
449
+ // enablePIIRedaction also redacts, but this survives misconfiguration of
450
+ // that setting. redactString is synchronous and cheap. (2026-08-24 fix)
451
+ const messagePreview = redactString(message.slice(0, 80)).redacted;
452
+ this.audit('info', 'http.chat.start', { reqId, sessionId, messageLen: message.length, messagePreview });
453
+ // Phase D: td-bus threat gate (blockLevel default: critical)
454
+ if (this.tdBus) {
455
+ // Feed each request into td-bus so it can recompute threat level.
456
+ // CLOUD-bound requests count heavier; LOCAL ones count lightly.
457
+ // For Phase D we feed all requests equally — refinement can come later.
458
+ try {
459
+ this.tdBus.recordCloudRequest();
460
+ }
461
+ catch { /* never block on td-bus faults */ }
462
+ if (typeof this.tdBus.shouldBlock === 'function' && this.tdBus.shouldBlock()) {
463
+ const threat = typeof this.tdBus.getThreatLevel === 'function'
464
+ ? this.tdBus.getThreatLevel() : 'critical';
465
+ this.audit('security', 'http.chat.threat-blocked', { reqId, sessionId, threat, endpoint: '/v1/chat' });
466
+ this.accessLog(reqId, 'POST', '/v1/chat', 503, Date.now() - start, 'threat-blocked');
467
+ res.writeHead(503, { 'Content-Type': 'application/json' });
468
+ res.end(JSON.stringify({ error: 'Service unavailable — threat level elevated', threat }));
469
+ return;
470
+ }
471
+ }
472
+ // Snapshot the per-session history BEFORE pushing the current user message,
473
+ // then push. The router embeds the snapshot as the "history" block and
474
+ // appends the current query itself — passing the buffer that already
475
+ // contains the current turn would cause prompt duplication.
476
+ const priorMessages = this.getPriorRecentMessages(sessionId);
477
+ this.pushRecentMessage(sessionId, 'user', message);
478
+ // Classify intent using intent-router
479
+ // §7.107 (2026-10-23): no classifier → LOCAL, not CLOUD. An undecided
480
+ // turn stays on the device; the cloud needs an explicit public-web signal.
481
+ let intent = { category: 'LOCAL', confidence: 0, targetRealm: 'local', rationale: 'no-classifier' };
482
+ if (this.intentRouter) {
483
+ try {
484
+ intent = await this.intentRouter.classify(message, {
485
+ recentMessages: priorMessages,
486
+ sessionId,
487
+ });
488
+ }
489
+ catch (err) {
490
+ // Intent classification failed, use default
491
+ }
492
+ }
493
+ // Contract: REJECT verdict from intent-router is a hard block. The router
494
+ // already audited `intent.rules.rejected` at security level; here we
495
+ // translate to a 403 response and short-circuit before any LLM call or
496
+ // sanitization. Aligns with design-v6 §2.15 and §4.3 waterfall semantics
497
+ // — REJECT bypasses the agent loop entirely.
498
+ if (intent.category === 'REJECT') {
499
+ const reason = intent.rationale || 'policy-rule:reject';
500
+ this.audit('warn', 'http.chat.rejected', { reqId, sessionId, reason, confidence: intent.confidence });
501
+ this.accessLog(reqId, 'POST', '/v1/chat', 403, Date.now() - start, `reject:${reason}`);
502
+ res.writeHead(403, { 'Content-Type': 'application/json' });
503
+ res.end(JSON.stringify({
504
+ error: 'Request rejected by policy',
505
+ reason,
506
+ confidence: intent.confidence,
507
+ }));
508
+ return;
509
+ }
510
+ // Select provider/model based on intent. The hardcoded pair is the
511
+ // fallback; modelRouter (registered later in boot order) takes
512
+ // precedence via lazy lookup so http-api doesn't need an inject dep.
513
+ const isLocal = intent.category === 'LOCAL';
514
+ const route = this.resolveRoute(isLocal);
515
+ this.audit('info', 'http.chat.route', { reqId, sessionId, intent: intent.category, provider: route.provider, model: route.model, resolved: route.resolved, confidence: intent.confidence, rationale: intent.rationale });
516
+ // 脱敏逻辑已由 input-guard 插件在 agent/pre-step hook 中统一处理。
517
+ // http-api 不再做 sanitizer.sanitize 或降级策略 — 所有入口(http-api /
518
+ // Web UI / CLI)共享同一套管线。sanitizer 失败时 input-guard 会阻断
519
+ // agent-loop(fail-closed),不会泄露原始 PII。
520
+ const { provider, model } = route;
521
+ // Tag session realm for Layer 1 (catalog filtering) and Layer 3 (tool gating).
522
+ // Realm is derived from intent-router's classification. input-guard may further
523
+ // refine routing (REJECT / SANITIZED) within agent-loop, but http-api's realm
524
+ // tag is the first-pass routing signal.
525
+ if (this.realmSkillBridge) {
526
+ const realm = isLocal ? 'LOCAL' : (intent.category === 'SANITIZED' ? 'SANITIZED' : 'CLOUD');
527
+ this.realmSkillBridge.setSessionRealm(sessionId, realm);
528
+ }
529
+ const record = await this.ensureSession(sessionId, provider, model);
530
+ // Wait for agent-loop driver to truly idle, not just first turn/end.
531
+ //
532
+ // §7.87.6 (2026-09-19): 之前在第一个 `turn/end` 就 resolve,但 agent-loop
533
+ // 会在 step 返回 `{kind: "completed"}` 时立即 break(即使 assistant message
534
+ // 里有没真执行的 orphan tool_call)→ 紧接着 driver 检测到 inbox.hasPending
535
+ // 起来一个新 turn(给 orphan 合成 tool-result 让 LLM 再答一次)。qwen3.5-9b
536
+ // 偶发 bash tool_call malformed(duplicate toolCallId / 空 args),tool runtime
537
+ // 拒判,executeToolCalls 不真跑 → turn 1 提前 break → HTTP 200 返中间 prose。
538
+ //
539
+ // 修法:用 `agent.whenIdle()`(`@deepseek-ai/dsh-agent-loop/lib/index.js:465`)
540
+ // 当真正的 driver idle 信号。do-while 内部 await activityDone,即使 turn 中
541
+ // 途 inbox 又 push 也能继续 wait。同时保留 session/event listener 翻译
542
+ // blocked turn(2026-08-30 fix),互不干扰。
543
+ const turnEnd = new Promise((resolve) => {
544
+ let blockedFired = false;
545
+ let lastError;
546
+ const dispose = this.cordisCtx.on('session/event', (session, event) => {
547
+ if (event.type !== 'turn/end')
548
+ return;
549
+ const blockedReason = event.data?.reason;
550
+ if (blockedReason?.kind === 'error') {
551
+ const f = blockedReason.error;
552
+ lastError = {
553
+ code: typeof f?.code === 'string' && f.code.length > 0 ? f.code : 'UNKNOWN',
554
+ message: typeof f?.message === 'string' ? f.message : 'model call failed',
555
+ status: typeof f?.status === 'number' ? f.status : undefined,
556
+ };
557
+ }
558
+ if (blockedReason?.kind === 'blocked') {
559
+ const edgeRej = record.agent._edgeRejection;
560
+ const status = edgeRej?.kind === 'attack' ? 400
561
+ : edgeRej?.kind === 'sanitizer' ? 503
562
+ : 403;
563
+ const reason = edgeRej?.reason ?? 'request blocked';
564
+ this.audit('warn', 'http.chat.blocked', {
565
+ reqId, sessionId, edgeRejection: edgeRej,
566
+ });
567
+ blockedFired = true;
568
+ dispose();
569
+ resolve({ result: '', rejection: { status, kind: edgeRej?.kind ?? 'unknown', reason, riskLevel: edgeRej?.riskLevel } });
570
+ }
571
+ });
572
+ // whenIdle() resolves once the ReactLoopAgent driver exits its activity
573
+ // loop (turn() finally block) and the queue is empty. Multi-turn chains
574
+ // (turn 1 → orphan recovery synthetic tool-result → turn 2) are awaited
575
+ // because activityDone is recreated each driver cycle.
576
+ record.agent.whenIdle().then(() => {
577
+ if (blockedFired)
578
+ return;
579
+ dispose();
580
+ const events = snapshotSessionEvents(record.agent.session);
581
+ const lastAssistant = events
582
+ .filter((e) => e.type === 'assistant/message')
583
+ .pop();
584
+ const text = lastAssistant
585
+ ? lastAssistant.data.message.content
586
+ .filter((b) => b.type === 'text')
587
+ .map((b) => b.text)
588
+ .join('')
589
+ : '';
590
+ if (text)
591
+ this.pushRecentMessage(sessionId, 'assistant', text);
592
+ // §7.100: 空 result + 观测到 error turn-end → 显式 502,不再 200 空回复。
593
+ // 有文本时维持 200(多轮里前一 turn 报错、orphan 恢复后正常答完不算故障)。
594
+ resolve(text.length === 0 && lastError ? { result: '', upstreamError: lastError } : { result: text });
595
+ }).catch((err) => {
596
+ // Driver crashed mid-loop (rare). Fall back to best-effort snapshot.
597
+ this.safeLog('warn', `[http-api] whenIdle rejected, falling back to snapshot sessionId=${sessionId} err=${String(err)}`);
598
+ if (blockedFired)
599
+ return;
600
+ dispose();
601
+ const events = snapshotSessionEvents(record.agent.session);
602
+ const lastAssistant = events
603
+ .filter((e) => e.type === 'assistant/message')
604
+ .pop();
605
+ const text = lastAssistant
606
+ ? lastAssistant.data.message.content
607
+ .filter((b) => b.type === 'text')
608
+ .map((b) => b.text)
609
+ .join('')
610
+ : '';
611
+ resolve({ result: text });
612
+ });
613
+ });
614
+ // input-guard 会在 agent/pre-step 中对消息做脱敏处理。http-api 把原文
615
+ // 送给 agent-loop,由 input-guard 按 routing 决定是否脱敏(CLOUD 路径)
616
+ // 或直接放行(LOCAL 路径)。
617
+ const userMessage = createUserMessage({
618
+ content: [{ type: 'text', text: message }],
619
+ source: { kind: 'user' },
620
+ });
621
+ record.agent._edgeRoute = {
622
+ realm: isLocal ? 'LOCAL' : (intent.category === 'SANITIZED' ? 'SANITIZED' : 'CLOUD'),
623
+ category: intent.category,
624
+ provider: route.provider,
625
+ model: route.model,
626
+ confidence: intent.confidence,
627
+ rationale: intent.rationale,
628
+ };
629
+ // Call followup - it's fire-and-forget, agent loop runs async
630
+ record.agent.followup(userMessage);
631
+ const turnResult = await turnEnd;
632
+ // 2026-08-30 fix: turn-end 是 blocked 时翻译成对应的非 200 状态。
633
+ // attack → 400;policy/intent → 403(与 line 465 对齐);sanitizer → 503。
634
+ if (turnResult.rejection) {
635
+ const r = turnResult.rejection;
636
+ const duration = Date.now() - start;
637
+ this.accessLog(reqId, 'POST', '/v1/chat', r.status, duration, `reject:${r.kind}:${r.reason}`);
638
+ res.writeHead(r.status, { 'Content-Type': 'application/json' });
639
+ res.end(JSON.stringify({
640
+ sessionId,
641
+ result: '',
642
+ error: 'Request rejected',
643
+ rejection: r,
644
+ intent: {
645
+ category: intent.category,
646
+ targetRealm: intent.targetRealm,
647
+ confidence: intent.confidence,
648
+ rationale: intent.rationale,
649
+ },
650
+ duration,
651
+ }));
652
+ return;
653
+ }
654
+ // §7.100: 上游模型调用失败(delegate 无目标 / 路由缺失 / 凭据缺失…)
655
+ // → 502 + machine-readable code。杜绝"HTTP 200 + 空 result"这类把
656
+ // 基础设施故障伪装成正常回复的响应(§7.98 L2 教训)。
657
+ if (turnResult.upstreamError) {
658
+ const u = turnResult.upstreamError;
659
+ const duration = Date.now() - start;
660
+ this.audit('warn', 'http.chat.upstream_error', {
661
+ reqId, sessionId, provider, model, code: u.code, error: u.message.slice(0, 300), upstreamStatus: u.status,
662
+ });
663
+ this.accessLog(reqId, 'POST', '/v1/chat', 502, duration, `upstream:${u.code}:${u.message.slice(0, 80)}`);
664
+ res.writeHead(502, { 'Content-Type': 'application/json' });
665
+ res.end(JSON.stringify({
666
+ sessionId,
667
+ result: '',
668
+ error: 'upstream model call failed',
669
+ code: u.code,
670
+ reason: u.message,
671
+ intent: {
672
+ category: intent.category,
673
+ targetRealm: intent.targetRealm,
674
+ confidence: intent.confidence,
675
+ rationale: intent.rationale,
676
+ },
677
+ duration,
678
+ }));
679
+ return;
680
+ }
681
+ const result = turnResult.result;
682
+ // 脱敏恢复(desanitize)已由 input-guard 插件在 llm/stream hook 中统一处理。
683
+ // 输出安全过滤(prompt-guard.checkOutput)也迁移到 input-guard,
684
+ // 以便 Web UI / CLI / SDK 所有入口统一受益。http-api 不再做安全过滤。
685
+ const duration = Date.now() - start;
686
+ // §7.46.5 (2026-09-12): ops log 响应端点 — 让 "user 几时收到回复" 在
687
+ // ops log 可见。resultLen 而非 result 内容;rejection 路径有
688
+ // accessLog (line 482/599) 已覆盖 status code + reason。
689
+ try {
690
+ this.ctx.logger?.info?.(`[http-api] chat reply sessionId=${sessionId} reqId=${reqId} provider=${provider} model=${model} durationMs=${duration} resultLen=${result.length} status=200 intent=${intent.category}`);
691
+ }
692
+ catch { /* debug only */ }
693
+ this.audit('info', 'http.chat.end', { reqId, sessionId, provider, model, durationMs: duration, resultLen: result.length });
694
+ this.accessLog(reqId, 'POST', '/v1/chat', 200, duration, `${provider}/${model} intent=${intent.category}`);
695
+ res.writeHead(200, { 'Content-Type': 'application/json' });
696
+ res.end(JSON.stringify({
697
+ sessionId,
698
+ result,
699
+ intent: {
700
+ category: intent.category,
701
+ targetRealm: intent.targetRealm,
702
+ confidence: intent.confidence,
703
+ rationale: intent.rationale,
704
+ },
705
+ duration,
706
+ }));
707
+ }
708
+ async handleChatStream(req, res, reqId, start, body) {
709
+ const { message, sessionId: requestedSessionId } = body ?? {};
710
+ if (!message || typeof message !== 'string') {
711
+ res.writeHead(400, { 'Content-Type': 'application/json' });
712
+ res.end(JSON.stringify({ error: 'message is required' }));
713
+ this.accessLog(reqId, 'POST', '/v1/chat/stream', 400, Date.now() - start, 'no-message');
714
+ return;
715
+ }
716
+ const sessionId = requestedSessionId ?? randomUUID();
717
+ this.audit('info', 'http.chat.stream.start', { reqId, sessionId, messageLen: message.length });
718
+ // Phase D: td-bus threat gate — mirrors handleChat() so the streaming
719
+ // endpoint is also covered by the elevated-rate short-circuit. SSE clients
720
+ // would otherwise sidestep the /v1/chat 503 path entirely.
721
+ if (this.tdBus) {
722
+ try {
723
+ this.tdBus.recordCloudRequest();
724
+ }
725
+ catch { /* never block on td-bus faults */ }
726
+ if (typeof this.tdBus.shouldBlock === 'function' && this.tdBus.shouldBlock()) {
727
+ const threat = typeof this.tdBus.getThreatLevel === 'function'
728
+ ? this.tdBus.getThreatLevel() : 'critical';
729
+ this.audit('security', 'http.chat.threat-blocked', { reqId, sessionId, threat, endpoint: '/v1/chat/stream' });
730
+ this.accessLog(reqId, 'POST', '/v1/chat/stream', 503, Date.now() - start, 'threat-blocked');
731
+ res.writeHead(503, { 'Content-Type': 'application/json' });
732
+ res.end(JSON.stringify({ error: 'Service unavailable — threat level elevated', threat }));
733
+ return;
734
+ }
735
+ }
736
+ // Snapshot per-session history BEFORE pushing the current user message;
737
+ // router embeds the snapshot as the "history" block and appends the
738
+ // current query itself. (See handleChat for full rationale.)
739
+ const streamPriorMessages = this.getPriorRecentMessages(sessionId);
740
+ this.pushRecentMessage(sessionId, 'user', message);
741
+ // Classify intent (same as handleChat) so SSE routes to the correct LLM
742
+ // §7.107 (2026-10-23): no classifier → LOCAL, not CLOUD. An undecided
743
+ // turn stays on the device; the cloud needs an explicit public-web signal.
744
+ let intent = { category: 'LOCAL', confidence: 0, targetRealm: 'local', rationale: 'no-classifier' };
745
+ if (this.intentRouter) {
746
+ try {
747
+ intent = await this.intentRouter.classify(message, {
748
+ recentMessages: streamPriorMessages,
749
+ sessionId,
750
+ });
751
+ }
752
+ catch (err) {
753
+ // Intent classification failed, use default
754
+ }
755
+ }
756
+ // REJECT short-circuit (same contract as handleChat): write SSE-shaped
757
+ // error event + 403, no LLM call, no sanitization. Client SSE parsers
758
+ // expect an `error` event before the stream closes.
759
+ if (intent.category === 'REJECT') {
760
+ const reason = intent.rationale || 'policy-rule:reject';
761
+ this.audit('warn', 'http.chat.stream.rejected', { reqId, sessionId, reason, confidence: intent.confidence });
762
+ this.accessLog(reqId, 'POST', '/v1/chat/stream', 403, Date.now() - start, `reject:${reason}`);
763
+ res.writeHead(403, { 'Content-Type': 'application/json' });
764
+ res.end(JSON.stringify({
765
+ error: 'Request rejected by policy',
766
+ reason,
767
+ confidence: intent.confidence,
768
+ }));
769
+ return;
770
+ }
771
+ const isLocal = intent.category === 'LOCAL';
772
+ const route = this.resolveRoute(isLocal);
773
+ this.audit('info', 'http.chat.stream.route', { reqId, sessionId, intent: intent.category, provider: route.provider, model: route.model, resolved: route.resolved });
774
+ // 脱敏逻辑已由 input-guard 插件在 agent/pre-step hook 中统一处理。
775
+ // http-api 不再做 sanitizer.sanitize 或降级策略 — 所有入口共享同一套管线。
776
+ const { provider, model } = route;
777
+ // Tag session realm for Layer 1 (catalog filtering) and Layer 3 (tool gating).
778
+ if (this.realmSkillBridge) {
779
+ const realm = isLocal ? 'LOCAL' : (intent.category === 'SANITIZED' ? 'SANITIZED' : 'CLOUD');
780
+ this.realmSkillBridge.setSessionRealm(sessionId, realm);
781
+ }
782
+ const record = await this.ensureSession(sessionId, provider, model);
783
+ res.writeHead(200, {
784
+ 'Content-Type': 'text/event-stream',
785
+ 'Cache-Control': 'no-cache',
786
+ Connection: 'keep-alive',
787
+ // Disable nginx response buffering so chunks reach the client in real
788
+ // time. Without this, deepseek-chat (long CLOUD turns, 60-90s+) gets
789
+ // batched by an upstream proxy and the SSE stream feels frozen.
790
+ 'X-Accel-Buffering': 'no',
791
+ });
792
+ this.accessLog(reqId, 'POST', '/v1/chat/stream', 200, Date.now() - start, `${provider}/${model} intent=${intent.category}`);
793
+ // Emit intent first so client knows the routing decision
794
+ res.write(`data: ${JSON.stringify({ type: 'intent', intent: { category: intent.category, targetRealm: intent.targetRealm, confidence: intent.confidence, rationale: intent.rationale } })}\n\n`);
795
+ // SSE keepalive: 15s comment frames so intermediate proxies with an
796
+ // idle-disconnect timeout (nginx default 60s, Cloudflare 100s) don't
797
+ // kill the connection mid-turn. The frame starts with `:` which makes
798
+ // it an SSE comment — clients ignore it. We write `: keepalive <ts>`
799
+ // (operator-readable in raw stream captures) rather than just `:`
800
+ // for diagnostics.
801
+ const keepaliveMs = 15_000;
802
+ const keepaliveTimer = setInterval(() => {
803
+ // Best-effort write; if the socket is gone, write returns false and
804
+ // we clear the timer on the next 'close' handler.
805
+ try {
806
+ if (!res.write(`: keepalive ${Date.now()}\n\n`)) {
807
+ // backpressure — not a failure, just a hint to stop; ignore
808
+ }
809
+ }
810
+ catch {
811
+ clearInterval(keepaliveTimer);
812
+ }
813
+ }, keepaliveMs);
814
+ if (typeof keepaliveTimer.unref === 'function')
815
+ keepaliveTimer.unref();
816
+ // DSH 0.2.x moved live assistant chunks from the session event log to
817
+ // the process-local `agent/assistant-stream` event (`session/event`
818
+ // no longer carries `assistant/chunk`). Chunks are already desanitized
819
+ // by input-guard's `llm/stream` hook, so this listener only forwards
820
+ // text deltas to the SSE client. Filter by agent/session: the event is
821
+ // global and concurrent SSE clients must not see each other's tokens.
822
+ const disposeAgentStream = typeof this.cordisCtx.on === 'function'
823
+ ? this.cordisCtx.on('agent/assistant-stream', (payload) => {
824
+ const frame = payload?.frame;
825
+ if (frame?.type !== 'chunk')
826
+ return;
827
+ const agentSessionId = payload?.agent?.session?.id;
828
+ if (agentSessionId !== undefined && String(agentSessionId) !== sessionId)
829
+ return;
830
+ const chunk = frame.chunk;
831
+ if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
832
+ res.write(`data: ${JSON.stringify({ type: 'text', text: chunk.text })}\n\n`);
833
+ }
834
+ })
835
+ : () => { };
836
+ const dispose = this.cordisCtx.on('session/event', (_session, event) => {
837
+ // Session events are also global; ignore another session's turn end.
838
+ const eventSessionId = _session?.id;
839
+ if (eventSessionId !== undefined && String(eventSessionId) !== sessionId)
840
+ return;
841
+ if (event.type === 'turn/end') {
842
+ // 2026-08-30 fix: blocked turn → SSE error 事件。headers 已经
843
+ // 200/text-event-stream 写过了,不能再改 status code,但 SSE
844
+ // 客户端能解析 error 事件 → client 收到 kind: 'attack' 自行报错。
845
+ // event shape: { type, seq, time, data: { turn, reason } }
846
+ const blockedReason = event.data?.reason;
847
+ if (blockedReason?.kind === 'blocked') {
848
+ const edgeRej = record.agent._edgeRejection;
849
+ const status = edgeRej?.kind === 'attack' ? 400
850
+ : edgeRej?.kind === 'sanitizer' ? 503
851
+ : 403;
852
+ this.accessLog(reqId, 'POST', '/v1/chat/stream', status, Date.now() - start, `reject:${edgeRej?.kind ?? 'unknown'}:${edgeRej?.reason ?? 'blocked'}`);
853
+ res.write(`data: ${JSON.stringify({
854
+ type: 'error',
855
+ status,
856
+ error: 'Request rejected',
857
+ rejection: edgeRej ?? { kind: 'unknown', reason: 'blocked' },
858
+ })}\n\n`);
859
+ try {
860
+ dispose();
861
+ }
862
+ catch { /* listener already removed */ }
863
+ try {
864
+ disposeAgentStream();
865
+ }
866
+ catch { /* listener already removed */ }
867
+ clearInterval(keepaliveTimer);
868
+ res.end();
869
+ return;
870
+ }
871
+ // §7.100: kind:'error' turn(adapter 失败被上游转成 terminal finish)
872
+ // 过去会走到下面的 done 分支 → 客户端看到 "done + 零文本"。现在翻成
873
+ // SSE error 帧(headers 已发,同 blocked 先例,状态码只进 accessLog)。
874
+ if (blockedReason?.kind === 'error') {
875
+ const f = blockedReason.error;
876
+ const code = typeof f?.code === 'string' && f.code.length > 0 ? f.code : 'UNKNOWN';
877
+ const message = typeof f?.message === 'string' ? f.message : 'model call failed';
878
+ this.audit('warn', 'http.chat.stream.upstream_error', {
879
+ reqId, sessionId, provider, model, code, error: message.slice(0, 300),
880
+ });
881
+ this.accessLog(reqId, 'POST', '/v1/chat/stream', 502, Date.now() - start, `upstream:${code}:${message.slice(0, 80)}`);
882
+ res.write(`data: ${JSON.stringify({
883
+ type: 'error',
884
+ status: 502,
885
+ error: 'upstream model call failed',
886
+ code,
887
+ reason: message,
888
+ })}\n\n`);
889
+ try {
890
+ dispose();
891
+ }
892
+ catch { /* listener already removed */ }
893
+ try {
894
+ disposeAgentStream();
895
+ }
896
+ catch { /* listener already removed */ }
897
+ clearInterval(keepaliveTimer);
898
+ res.end();
899
+ return;
900
+ }
901
+ // Pull the assembled assistant text from session.events to feed
902
+ // the multi-turn ring buffer (mirrors handleChat). Best-effort:
903
+ // skip push if the agent never produced an assistant/message
904
+ // (e.g. tool-only turn).
905
+ const lastAssistant = snapshotSessionEvents(record.agent.session)
906
+ .filter((e) => e.type === 'assistant/message')
907
+ .pop();
908
+ const assistantText = lastAssistant
909
+ ? lastAssistant.data.message.content
910
+ .filter((b) => b.type === 'text')
911
+ .map((b) => b.text)
912
+ .join('')
913
+ : '';
914
+ if (assistantText)
915
+ this.pushRecentMessage(sessionId, 'assistant', assistantText);
916
+ res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
917
+ const duration = Date.now() - start;
918
+ this.audit('info', 'http.chat.stream.end', { reqId, sessionId, provider, model, durationMs: duration });
919
+ try {
920
+ dispose();
921
+ }
922
+ catch { /* listener already removed */ }
923
+ try {
924
+ disposeAgentStream();
925
+ }
926
+ catch { /* listener already removed */ }
927
+ clearInterval(keepaliveTimer);
928
+ res.end();
929
+ }
930
+ });
931
+ // Client-disconnect cleanup — when the browser/network drops, both
932
+ // 'close' and 'aborted' fire (Node emits 'close' after the underlying
933
+ // socket is destroyed). Without this unsubscribe, every reconnect
934
+ // leaves a stale session/event listener that accumulates on the
935
+ // cordis context — memory leak under flaky-network clients. We also
936
+ // clear the keepalive timer here; if write() above threw because the
937
+ // socket was already gone, the close handler is the safety net.
938
+ let cleanedUp = false;
939
+ const onClose = () => {
940
+ if (cleanedUp)
941
+ return;
942
+ cleanedUp = true;
943
+ clearInterval(keepaliveTimer);
944
+ try {
945
+ dispose();
946
+ }
947
+ catch { /* listener already removed */ }
948
+ try {
949
+ disposeAgentStream();
950
+ }
951
+ catch { /* listener already removed */ }
952
+ this.audit('debug', 'http.chat.stream.client-closed', { reqId, sessionId });
953
+ };
954
+ req.on('close', onClose);
955
+ req.on('aborted', onClose);
956
+ // Also end the stream cleanly when the response side closes (write
957
+ // fails on a torn-down socket). Belt-and-braces.
958
+ res.on('close', onClose);
959
+ record.agent._edgeRoute = {
960
+ realm: isLocal ? 'LOCAL' : (intent.category === 'SANITIZED' ? 'SANITIZED' : 'CLOUD'),
961
+ category: intent.category,
962
+ provider: route.provider,
963
+ model: route.model,
964
+ confidence: intent.confidence,
965
+ rationale: intent.rationale,
966
+ };
967
+ const userMessage = createUserMessage({
968
+ content: [{ type: 'text', text: message }],
969
+ source: { kind: 'user' },
970
+ });
971
+ record.agent.followup(userMessage);
972
+ }
973
+ handleListSessions(res) {
974
+ const list = Array.from(this.sessions.entries()).map(([id, record]) => ({
975
+ sessionId: id,
976
+ createdAt: record.createdAt,
977
+ lastAccessedAt: record.lastAccessedAt,
978
+ }));
979
+ res.writeHead(200, { 'Content-Type': 'application/json' });
980
+ res.end(JSON.stringify({ sessions: list }));
981
+ }
982
+ /** GET /v1/models — returns the dispatcher's provider catalog. Backed
983
+ * by `modelRouter.listDetailed()`; falls back to a hardcoded pair
984
+ * when the router isn't registered (boot-order race; mirrors
985
+ * `resolveRoute()`'s soft-dep pattern at line ~1440).
986
+ *
987
+ * Soft-dep: cordis throws on `ctx.<name>` when the row isn't in
988
+ * `inject`, so wrap the lookup in try/catch and fall back. */
989
+ handleListModels(res) {
990
+ const ctx = this.cordisCtx;
991
+ let mr;
992
+ try {
993
+ mr = typeof ctx?.get === 'function' ? ctx.get('modelRouter') : ctx?.modelRouter;
994
+ }
995
+ catch {
996
+ // Cordis throws on "without inject" — treat as "not yet registered"
997
+ // and let the fallback below produce a usable response.
998
+ mr = undefined;
999
+ }
1000
+ let models;
1001
+ if (mr && typeof mr.listDetailed === 'function') {
1002
+ models = mr.listDetailed();
1003
+ }
1004
+ else {
1005
+ // Fallback: enumerate the legacy hardcoded pair so external clients
1006
+ // can still discover at least the default routes.
1007
+ models = [
1008
+ { provider: 'local-llm', realm: 'LOCAL', available: true },
1009
+ { provider: 'cloud-llm', realm: 'CLOUD', available: true },
1010
+ ];
1011
+ }
1012
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1013
+ res.end(JSON.stringify({ models }));
1014
+ }
1015
+ /** GET /v1/audit?since=&until=&kind=&level=&sessionId=&limit= — query
1016
+ * the audit-log. The audit-log API already supports level/kind/sinceMs/
1017
+ * untilMs/limit; we additionally filter on `payload.sessionId` post-
1018
+ * hoc because sessionId lives in payload (audit entries don't have a
1019
+ * first-class sessionId field — they're `(level, kind, source, payload)`
1020
+ * records). Limit defaults to 50, hard cap 500, to keep response size
1021
+ * bounded — the audit-log JSONL can be very large for long-running
1022
+ * deployments.
1023
+ *
1024
+ * Path leakage: this endpoint NEVER returns the audit file path —
1025
+ * only structured entries. Operators wanting to inspect the raw JSONL
1026
+ * should use the existing file-system route (the audit-log's config
1027
+ * already publishes its root). */
1028
+ async handleListAudit(url, res) {
1029
+ if (!this.auditLog || typeof this.auditLog.query !== 'function') {
1030
+ res.writeHead(503, { 'Content-Type': 'application/json' });
1031
+ res.end(JSON.stringify({ error: 'audit-log not available' }));
1032
+ return;
1033
+ }
1034
+ const sinceStr = url.searchParams.get('since');
1035
+ const untilStr = url.searchParams.get('until');
1036
+ const kind = url.searchParams.get('kind') ?? undefined;
1037
+ const level = url.searchParams.get('level') ?? undefined;
1038
+ const sessionId = url.searchParams.get('sessionId') ?? undefined;
1039
+ const limitStr = url.searchParams.get('limit');
1040
+ let sinceMs;
1041
+ let untilMs;
1042
+ if (sinceStr) {
1043
+ const n = Number(sinceStr);
1044
+ if (!Number.isFinite(n)) {
1045
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1046
+ res.end(JSON.stringify({ error: 'since must be a number (ms epoch)' }));
1047
+ return;
1048
+ }
1049
+ sinceMs = n;
1050
+ }
1051
+ if (untilStr) {
1052
+ const n = Number(untilStr);
1053
+ if (!Number.isFinite(n)) {
1054
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1055
+ res.end(JSON.stringify({ error: 'until must be a number (ms epoch)' }));
1056
+ return;
1057
+ }
1058
+ untilMs = n;
1059
+ }
1060
+ let limit;
1061
+ if (limitStr) {
1062
+ const n = Number(limitStr);
1063
+ if (!Number.isFinite(n) || n <= 0) {
1064
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1065
+ res.end(JSON.stringify({ error: 'limit must be a positive number' }));
1066
+ return;
1067
+ }
1068
+ limit = Math.min(Math.floor(n), 500); // hard cap 500
1069
+ }
1070
+ else {
1071
+ limit = 50; // sensible default
1072
+ }
1073
+ let entries = await this.auditLog.query({
1074
+ level: level,
1075
+ kind,
1076
+ sinceMs,
1077
+ untilMs,
1078
+ limit,
1079
+ });
1080
+ if (sessionId) {
1081
+ entries = entries.filter((e) => e?.payload?.sessionId === sessionId);
1082
+ }
1083
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1084
+ res.end(JSON.stringify({ entries, count: entries.length, limit }));
1085
+ }
1086
+ handleGetSession(id, res) {
1087
+ const record = this.sessions.get(id);
1088
+ if (!record) {
1089
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1090
+ res.end(JSON.stringify({ error: 'Session not found' }));
1091
+ return;
1092
+ }
1093
+ this.audit('debug', 'session.accessed', { sessionId: id });
1094
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1095
+ res.end(JSON.stringify({ sessionId: id, createdAt: record.createdAt, lastAccessedAt: record.lastAccessedAt }));
1096
+ }
1097
+ async handleDeleteSession(id, res) {
1098
+ const record = this.sessions.get(id);
1099
+ if (!record) {
1100
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1101
+ res.end(JSON.stringify({ error: 'Session not found' }));
1102
+ return;
1103
+ }
1104
+ await record.handle.dispose();
1105
+ this.sessions.delete(id);
1106
+ this.dropSessionBuffer(id);
1107
+ // Best-effort persistence delete. Failures don't block the 204 — the
1108
+ // next process restart will see an orphan upstream record, which is
1109
+ // recoverable (it's metadata, not the live agent state).
1110
+ void this.sessionsHelper.deleteSession(id).catch(() => { });
1111
+ // input-guard 按 sessionId 用普通 Map 维护脱敏状态(不是 WeakMap —
1112
+ // pre-step 的 session 引用与 hook 里的 sessionId 字符串对不上),
1113
+ // 自带 LRU 上限(SESSION_STATE_CAP),无需这里显式 purge。
1114
+ this.audit('info', 'session.deleted', { sessionId: id });
1115
+ res.writeHead(204);
1116
+ res.end();
1117
+ }
1118
+ /** POST /v1/sessions/{id}/cancel — best-effort cancel of any in-flight
1119
+ * turn on this session. Currently implemented by disposing the live
1120
+ * AgentHandle (same as DELETE); the session record is removed from
1121
+ * the in-memory map but persisted metadata is retained so /v1/sessions
1122
+ * still shows the historical record. Returns 202 Accepted to signal
1123
+ * async semantics (the actual abort may race the next chunk). */
1124
+ async handleCancelSession(id, res) {
1125
+ const record = this.sessions.get(id);
1126
+ if (!record) {
1127
+ // Even when not in-memory, the persistence layer may know about it.
1128
+ // We treat that as "already canceled" — idempotent cancel is safer
1129
+ // than a 404 when the underlying agent has already exited.
1130
+ void this.sessionsHelper.cancelSession(id).catch(() => { });
1131
+ res.writeHead(202, { 'Content-Type': 'application/json' });
1132
+ res.end(JSON.stringify({ sessionId: id, canceled: true, status: 'no-active-handle' }));
1133
+ return;
1134
+ }
1135
+ await record.handle.dispose().catch(() => { });
1136
+ this.sessions.delete(id);
1137
+ this.dropSessionBuffer(id);
1138
+ void this.sessionsHelper.cancelSession(id).catch(() => { });
1139
+ this.audit('info', 'session.canceled', { sessionId: id });
1140
+ res.writeHead(202, { 'Content-Type': 'application/json' });
1141
+ res.end(JSON.stringify({ sessionId: id, canceled: true, status: 'aborted' }));
1142
+ }
1143
+ /** POST /v1/sessions/{id}/rename — body `{ title }`. Updates the
1144
+ * human-readable title in the persistence layer; the in-memory map
1145
+ * is intentionally not the source of truth (titles survive restarts). */
1146
+ async handleRenameSession(id, body, res) {
1147
+ const title = body?.title;
1148
+ if (typeof title !== 'string' || title.length === 0) {
1149
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1150
+ res.end(JSON.stringify({ error: 'title is required (non-empty string)' }));
1151
+ return;
1152
+ }
1153
+ if (title.length > 200) {
1154
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1155
+ res.end(JSON.stringify({ error: 'title must be ≤ 200 chars' }));
1156
+ return;
1157
+ }
1158
+ // Verify the session is known to the in-memory map OR persistence layer.
1159
+ const inMemory = this.sessions.has(id);
1160
+ if (!inMemory) {
1161
+ const meta = await this.sessionsHelper.getSession(id).catch(() => null);
1162
+ if (!meta) {
1163
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1164
+ res.end(JSON.stringify({ error: 'Session not found' }));
1165
+ return;
1166
+ }
1167
+ }
1168
+ await this.sessionsHelper.renameSession(id, title);
1169
+ this.audit('info', 'session.renamed', { sessionId: id, title });
1170
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1171
+ res.end(JSON.stringify({ sessionId: id, title }));
1172
+ }
1173
+ /** POST /v1/sessions/{id}/fork — body `{ atMessageIndex? }` (optional
1174
+ * index into the recent-message ring buffer to fork from; defaults
1175
+ * to "everything"). Creates a fresh session record and copies the
1176
+ * source's recent messages (truncated at the requested index) into
1177
+ * the new session's ring buffer. The live AgentHandle is NOT copied —
1178
+ * the forked session starts fresh; the next followup() on it spins
1179
+ * up a new agent. This is the same semantics as the DSH webui's
1180
+ * `sessions.fork` RPC. Returns 201 Created with the new sessionId. */
1181
+ async handleForkSession(id, body, res) {
1182
+ const sourceRecord = this.sessions.get(id);
1183
+ const sourceMeta = await this.sessionsHelper.getSession(id).catch(() => null);
1184
+ if (!sourceRecord && !sourceMeta) {
1185
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1186
+ res.end(JSON.stringify({ error: 'Session not found' }));
1187
+ return;
1188
+ }
1189
+ const forkResult = await this.sessionsHelper.forkSession({
1190
+ sourceSessionId: id,
1191
+ });
1192
+ const newSessionId = forkResult.sessionId;
1193
+ // Copy the recent-message ring buffer (truncated to atMessageIndex
1194
+ // when present). This gives the forked session the prior context so
1195
+ // the next classify() sees a coherent history.
1196
+ if (sourceRecord) {
1197
+ const sourceBuf = this.sessionBuffers.get(id) ?? [];
1198
+ const idx = typeof body?.atMessageIndex === 'number'
1199
+ ? Math.max(0, Math.min(body.atMessageIndex, sourceBuf.length))
1200
+ : sourceBuf.length;
1201
+ const copied = sourceBuf.slice(0, idx);
1202
+ this.sessionBuffers.set(newSessionId, copied);
1203
+ }
1204
+ this.audit('info', 'session.forked', {
1205
+ sourceSessionId: id,
1206
+ newSessionId,
1207
+ copiedMessages: this.sessionBuffers.get(newSessionId)?.length ?? 0,
1208
+ });
1209
+ res.writeHead(201, { 'Content-Type': 'application/json' });
1210
+ res.end(JSON.stringify({
1211
+ sessionId: newSessionId,
1212
+ sourceSessionId: id,
1213
+ }));
1214
+ }
1215
+ /**
1216
+ * Resolve the preset id one new session will run under and the `setup` hook
1217
+ * that mounts it. Returns `{}` when the deployment has no agent-presets
1218
+ * roster (bare dsh CLI without dsh-agent-presets installed) — agents then
1219
+ * run on the host composition, which is the pre-preset behaviour.
1220
+ *
1221
+ * Mirrors dsh-tui's `composePreset()` (lib/types/dsh-adapter/presets.js:35).
1222
+ * The original signature is in dsh-tui's private module surface, so we
1223
+ * inline a minimal version here: `resolve()` (exact roster match) +
1224
+ * `mount(agentCtx, id)` are the two public calls every implementation
1225
+ * exposes.
1226
+ */
1227
+ async composeAgentPreset() {
1228
+ // §7.100: soft-dep idiom (mirror of resolveRoute :1764 / handleListModels
1229
+ // :1020) — Cordis ctx.get THROWS for a non-injected service, and 'presets'
1230
+ // is not in this plugin's inject list. On graphs without dsh-agent-presets
1231
+ // the bare get() took down every POST /v1/chat with a 500.
1232
+ const presets = (() => {
1233
+ try {
1234
+ return this.ctx.get?.('agentPresets');
1235
+ }
1236
+ catch {
1237
+ return undefined;
1238
+ }
1239
+ })();
1240
+ if (presets === undefined || typeof presets.resolve !== 'function' || typeof presets.mount !== 'function') {
1241
+ return {};
1242
+ }
1243
+ let resolvedId;
1244
+ try {
1245
+ resolvedId = (await presets.resolve(undefined)).id;
1246
+ }
1247
+ catch (error) {
1248
+ const reason = error instanceof Error ? error.message : String(error);
1249
+ this.safeLog('warn', `[http-api] agent preset (default) unavailable (${reason}) — composing session without a preset`);
1250
+ return {};
1251
+ }
1252
+ return {
1253
+ agentPreset: resolvedId,
1254
+ setup: async (agentCtx) => {
1255
+ await presets.mount(agentCtx, resolvedId);
1256
+ },
1257
+ };
1258
+ }
1259
+ async ensureSession(sessionId, provider, model) {
1260
+ let record = this.sessions.get(sessionId);
1261
+ if (!record) {
1262
+ // Check max active sessions and evict LRU if needed
1263
+ if (this.config.maxActiveSessions > 0 && this.sessions.size >= this.config.maxActiveSessions) {
1264
+ await this.evictLeastRecentSession();
1265
+ }
1266
+ this.safeLog('info', `[http-api] Creating agent with provider=${provider}, model=${model}`);
1267
+ // §7.38 (2026-09-04): compose preset before agents.create() so the agent
1268
+ // joins the standard preset (tools + skills + prompt sections).
1269
+ // Without this, dsh-agent-presets logs
1270
+ // "agent <id> was published without joining an agent preset; its
1271
+ // tools, prompt sections, and skill catalog resolve against the
1272
+ // empty global layer"
1273
+ // and the resulting agent emits empty `result` (LLM has no tools/prompts).
1274
+ // Mirrors dsh-tui's composePreset() (lib/types/dsh-adapter/presets.js:35).
1275
+ // Roster absence (bare dsh CLI without dsh-agent-presets) is the
1276
+ // pre-preset fallback — agents get host composition instead, which is
1277
+ // what ran before §7.38 landed.
1278
+ const composed = await this.composeAgentPreset();
1279
+ const handle = await this.agents.create({
1280
+ sessionId: SessionId(sessionId),
1281
+ agentOptions: {
1282
+ provider: provider ?? 'local-llm',
1283
+ model: model ?? 'qwen3-4b',
1284
+ },
1285
+ meta: {
1286
+ cwd: process.cwd(),
1287
+ // Durable header — a later resume re-mounts exactly this preset.
1288
+ ...(composed.agentPreset === undefined ? {} : { agentPreset: composed.agentPreset }),
1289
+ },
1290
+ // Mounts the preset's tools/skills/sections onto the agent's scope.
1291
+ // Without setup, dsh-agent warns "agent … was published without
1292
+ // joining an agent preset" and the agent runs on empty global layer.
1293
+ ...(composed.setup === undefined ? {} : { setup: composed.setup }),
1294
+ });
1295
+ this.safeLog('info', '[http-api] Agent created successfully');
1296
+ const now = Date.now();
1297
+ record = {
1298
+ agent: handle.agent,
1299
+ handle,
1300
+ createdAt: now,
1301
+ lastAccessedAt: now,
1302
+ };
1303
+ this.sessions.set(sessionId, record);
1304
+ // Persist metadata via the soft-dep sessions helper. Fire-and-forget:
1305
+ // persistence failures must never block chat round-trip — the
1306
+ // in-memory map is the source of truth for the live process.
1307
+ void this.sessionsHelper.createSession({ sessionId, provider, model }).catch(() => { });
1308
+ this.audit('info', 'session.created', { sessionId, provider, model, totalSessions: this.sessions.size });
1309
+ }
1310
+ else {
1311
+ // Update last accessed time
1312
+ record.lastAccessedAt = Date.now();
1313
+ }
1314
+ return record;
1315
+ }
1316
+ /**
1317
+ * Clean up sessions that have been idle longer than sessionIdleTimeoutMs.
1318
+ * Called periodically by the cleanup timer.
1319
+ */
1320
+ cleanupExpiredSessions() {
1321
+ if (this.config.sessionIdleTimeoutMs <= 0)
1322
+ return;
1323
+ const now = Date.now();
1324
+ const timeout = this.config.sessionIdleTimeoutMs;
1325
+ const expired = [];
1326
+ for (const [id, record] of this.sessions.entries()) {
1327
+ if (now - record.lastAccessedAt > timeout) {
1328
+ expired.push(id);
1329
+ }
1330
+ }
1331
+ for (const id of expired) {
1332
+ const record = this.sessions.get(id);
1333
+ if (record) {
1334
+ record.handle.dispose().catch(() => { });
1335
+ this.sessions.delete(id);
1336
+ this.dropSessionBuffer(id);
1337
+ this.audit('info', 'session.expired', { sessionId: id, idleMs: now - record.lastAccessedAt });
1338
+ }
1339
+ }
1340
+ if (expired.length > 0) {
1341
+ this.audit('debug', 'session.cleanup', { expiredCount: expired.length, remainingCount: this.sessions.size });
1342
+ }
1343
+ }
1344
+ /**
1345
+ * Evict the least-recently-used session to make room for a new one.
1346
+ * Called when maxActiveSessions is reached.
1347
+ */
1348
+ async evictLeastRecentSession() {
1349
+ let oldest = null;
1350
+ for (const [id, record] of this.sessions.entries()) {
1351
+ if (!oldest || record.lastAccessedAt < oldest.lastAccessedAt) {
1352
+ oldest = { id, lastAccessedAt: record.lastAccessedAt };
1353
+ }
1354
+ }
1355
+ if (oldest) {
1356
+ const record = this.sessions.get(oldest.id);
1357
+ if (record) {
1358
+ await record.handle.dispose();
1359
+ this.sessions.delete(oldest.id);
1360
+ this.dropSessionBuffer(oldest.id);
1361
+ this.audit('info', 'session.evicted', {
1362
+ sessionId: oldest.id,
1363
+ reason: 'max-active-sessions-reached',
1364
+ idleMs: Date.now() - oldest.lastAccessedAt,
1365
+ });
1366
+ }
1367
+ }
1368
+ }
1369
+ async readJsonBody(req) {
1370
+ // Defense-in-depth: cap the body size to avoid OOM from malicious clients
1371
+ // sending unbounded bodies. Per-chunk byte counting stops the connection
1372
+ // the moment the cap is breached, before `data` accumulates further.
1373
+ // Errors carry `statusCode` so the outer handler can return the right HTTP
1374
+ // code: 413 for body-too-large, 400 for malformed JSON.
1375
+ const cap = this.config.maxRequestBodyBytes;
1376
+ return new Promise((resolve, reject) => {
1377
+ let data = '';
1378
+ let totalBytes = 0;
1379
+ let aborted = false;
1380
+ const abort = (err) => {
1381
+ if (aborted)
1382
+ return;
1383
+ aborted = true;
1384
+ // best-effort: tear down the socket so the attacker can't keep streaming
1385
+ try {
1386
+ req.destroy(err);
1387
+ }
1388
+ catch { /* ignore */ }
1389
+ reject(err);
1390
+ };
1391
+ req.on('data', (chunk) => {
1392
+ if (aborted)
1393
+ return;
1394
+ const buf = typeof chunk === 'string' ? Buffer.byteLength(chunk, 'utf-8') : chunk.length;
1395
+ totalBytes += buf;
1396
+ if (cap > 0 && totalBytes > cap) {
1397
+ const err = new Error(`request body exceeds maxRequestBodyBytes=${cap}`);
1398
+ err.statusCode = 413;
1399
+ abort(err);
1400
+ return;
1401
+ }
1402
+ data += chunk;
1403
+ });
1404
+ req.on('end', () => {
1405
+ if (aborted)
1406
+ return;
1407
+ if (!data) {
1408
+ resolve({});
1409
+ return;
1410
+ }
1411
+ try {
1412
+ resolve(JSON.parse(data));
1413
+ }
1414
+ catch (err) {
1415
+ const wrapped = new Error(`malformed JSON body: ${err?.message ?? 'parse failed'}`);
1416
+ wrapped.statusCode = 400;
1417
+ wrapped.cause = err;
1418
+ reject(wrapped);
1419
+ }
1420
+ });
1421
+ req.on('error', (err) => abort(err));
1422
+ });
1423
+ }
1424
+ getOpenApiSpec() {
1425
+ // Derive the OpenAPI paths from the route table (single source of
1426
+ // truth). Per-route handler-specific bodies (chat message schema,
1427
+ // rename payload, fork body) are kept in a small lookup below so the
1428
+ // table stays focused on routing semantics.
1429
+ const chatRequestBody = {
1430
+ content: {
1431
+ 'application/json': {
1432
+ schema: {
1433
+ type: 'object',
1434
+ properties: {
1435
+ message: { type: 'string', description: 'User message text; subject to input-guard sanitize on CLOUD paths.' },
1436
+ sessionId: { type: 'string', description: 'Optional. Omit to start a fresh session; pass an existing id to continue a multi-turn conversation.' },
1437
+ },
1438
+ required: ['message'],
1439
+ },
1440
+ },
1441
+ },
1442
+ };
1443
+ const chatResponses = {
1444
+ '200': { description: 'Chat response with final assistant text and intent metadata.' },
1445
+ '400': { description: 'Bad request — message missing or malformed body.' },
1446
+ '401': { description: 'Unauthorized — missing or invalid bearer token.' },
1447
+ '403': { description: 'Forbidden — intent REJECT (privacy-policy rule).' },
1448
+ '413': { description: 'Payload Too Large — body exceeds maxRequestBodyBytes.' },
1449
+ '503': { description: 'Service Unavailable — td-bus threat gate active.' },
1450
+ };
1451
+ const streamResponses = {
1452
+ ...chatResponses,
1453
+ '200': { description: 'SSE stream: intent → text chunks → done. Includes 15s `: keepalive` comment frames.' },
1454
+ };
1455
+ const sessionPathParams = (idParam) => [{
1456
+ name: idParam,
1457
+ in: 'path',
1458
+ required: true,
1459
+ schema: { type: 'string' },
1460
+ description: 'Session id (UUID v4).',
1461
+ }];
1462
+ const renameBody = {
1463
+ content: {
1464
+ 'application/json': {
1465
+ schema: {
1466
+ type: 'object',
1467
+ properties: { title: { type: 'string', maxLength: 200 } },
1468
+ required: ['title'],
1469
+ },
1470
+ },
1471
+ },
1472
+ };
1473
+ const forkBody = {
1474
+ content: {
1475
+ 'application/json': {
1476
+ schema: {
1477
+ type: 'object',
1478
+ properties: {
1479
+ atMessageIndex: { type: 'integer', minimum: 0, description: 'Truncate the copied recent-message ring buffer at this index. Omit to copy everything.' },
1480
+ newSessionId: { type: 'string', description: 'Optional override; defaults to a fresh UUID.' },
1481
+ },
1482
+ },
1483
+ },
1484
+ },
1485
+ };
1486
+ const auditQueryParams = [
1487
+ { name: 'since', in: 'query', schema: { type: 'integer' }, description: 'Filter: ts >= since (ms epoch).' },
1488
+ { name: 'until', in: 'query', schema: { type: 'integer' }, description: 'Filter: ts <= until (ms epoch).' },
1489
+ { name: 'kind', in: 'query', schema: { type: 'string' }, description: 'Filter by event kind (e.g. "http.chat.start").' },
1490
+ { name: 'level', in: 'query', schema: { type: 'string', enum: ['debug', 'info', 'warn', 'error', 'security'] } },
1491
+ { name: 'sessionId', in: 'query', schema: { type: 'string' }, description: 'Filter by payload.sessionId (post-hoc — payload-side filter).' },
1492
+ { name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 500 }, description: 'Default 50; hard cap 500.' },
1493
+ ];
1494
+ // Per-route summary overrides (the route table already carries a
1495
+ // short summary; this map adds request bodies and richer descriptions).
1496
+ const pathExtras = {
1497
+ '/v1/chat': {
1498
+ post: {
1499
+ requestBody: chatRequestBody,
1500
+ responses: chatResponses,
1501
+ },
1502
+ },
1503
+ '/v1/chat/stream': {
1504
+ post: {
1505
+ requestBody: chatRequestBody,
1506
+ responses: streamResponses,
1507
+ },
1508
+ },
1509
+ '/v1/sessions/{id}': {
1510
+ get: {
1511
+ parameters: sessionPathParams('id'),
1512
+ responses: { '200': { description: 'Session metadata' }, '404': { description: 'Session not found' } },
1513
+ },
1514
+ delete: {
1515
+ parameters: sessionPathParams('id'),
1516
+ responses: { '204': { description: 'Deleted' }, '404': { description: 'Session not found' } },
1517
+ },
1518
+ },
1519
+ '/v1/sessions/{id}/cancel': {
1520
+ post: {
1521
+ parameters: sessionPathParams('id'),
1522
+ responses: { '202': { description: 'Cancel accepted — best-effort abort.' } },
1523
+ },
1524
+ },
1525
+ '/v1/sessions/{id}/rename': {
1526
+ post: {
1527
+ parameters: sessionPathParams('id'),
1528
+ requestBody: renameBody,
1529
+ responses: { '200': { description: 'Renamed' }, '400': { description: 'title missing or too long' }, '404': { description: 'Session not found' } },
1530
+ },
1531
+ },
1532
+ '/v1/sessions/{id}/fork': {
1533
+ post: {
1534
+ parameters: sessionPathParams('id'),
1535
+ requestBody: forkBody,
1536
+ responses: { '201': { description: 'Forked — returns new sessionId' }, '404': { description: 'Session not found' } },
1537
+ },
1538
+ },
1539
+ '/v1/models': {
1540
+ get: {
1541
+ responses: { '200': { description: 'Provider catalog with realm metadata.' } },
1542
+ },
1543
+ },
1544
+ '/v1/audit': {
1545
+ get: {
1546
+ parameters: auditQueryParams,
1547
+ responses: { '200': { description: 'Audit entries (newest-first, capped at limit).' }, '400': { description: 'Invalid filter parameter' } },
1548
+ },
1549
+ },
1550
+ };
1551
+ const paths = {};
1552
+ for (const route of this.routes) {
1553
+ // OpenAPI placeholders don't accept `{id}`; map to `{id}` already
1554
+ // matches, but for the audit endpoint we need an empty method-key
1555
+ // bucket. Initialize on first encounter.
1556
+ if (!paths[route.pattern])
1557
+ paths[route.pattern] = {};
1558
+ const op = { summary: route.summary };
1559
+ if (route.body === 'json') {
1560
+ op.requestBody = {
1561
+ content: { 'application/json': { schema: { type: 'object' } } },
1562
+ };
1563
+ }
1564
+ // Apply extras (which may add parameters, requestBody, richer responses).
1565
+ const extra = pathExtras[route.pattern]?.[route.method.toLowerCase()];
1566
+ const responses = extra?.responses ?? { '200': { description: 'OK' } };
1567
+ const merged = extra
1568
+ ? { ...op, ...extra, summary: op.summary, responses }
1569
+ : { ...op, responses };
1570
+ paths[route.pattern][route.method.toLowerCase()] = merged;
1571
+ }
1572
+ return {
1573
+ openapi: '3.0.0',
1574
+ info: {
1575
+ title: 'dsh-edge-agent HTTP API',
1576
+ version: '1.1.0',
1577
+ description: 'Program-side entry point for the dsh-edge-agent. Browser users should use the DSH official Web UI at :21018 — see docs/dual-entry-architecture.md.',
1578
+ },
1579
+ paths,
1580
+ };
1581
+ }
1582
+ /** Build the route table — public + bearer-auth. Returns an array of
1583
+ * RouteDef entries; the dispatcher (handleRequest) walks it to
1584
+ * resolve method+path → handler. Built once at construction; the
1585
+ * table is therefore stable across the service's lifetime, which
1586
+ * lets future optimization (route hot-reload, per-route metrics) hook
1587
+ * in at a single construction site. */
1588
+ buildRoutes() {
1589
+ // 路由表的 handler 闭包统一经 svc 引用服务实例 —— 50+ 处,比把每个
1590
+ // 闭包改成捕获 this 的改动面小得多,故豁免 no-this-alias。
1591
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
1592
+ const svc = this;
1593
+ return [
1594
+ // ── Public (no auth) ────────────────────────────────────────────
1595
+ {
1596
+ method: 'GET',
1597
+ pattern: '/v1/health',
1598
+ auth: 'public',
1599
+ summary: 'Liveness probe',
1600
+ handler: async (ctx) => {
1601
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
1602
+ ctx.res.end(JSON.stringify({ status: 'ok' }));
1603
+ svc.accessLog(ctx.reqId, 'GET', '/v1/health', 200, Date.now() - ctx.start);
1604
+ },
1605
+ },
1606
+ {
1607
+ method: 'GET',
1608
+ pattern: '/v1/openapi.json',
1609
+ auth: 'public',
1610
+ summary: 'OpenAPI 3.0 spec for this API',
1611
+ handler: async (ctx) => {
1612
+ ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
1613
+ ctx.res.end(JSON.stringify(svc.getOpenApiSpec()));
1614
+ svc.accessLog(ctx.reqId, 'GET', '/v1/openapi.json', 200, Date.now() - ctx.start);
1615
+ },
1616
+ },
1617
+ // ── Bearer-auth chat ─────────────────────────────────────────────
1618
+ {
1619
+ method: 'POST',
1620
+ pattern: '/v1/chat',
1621
+ auth: 'bearer',
1622
+ body: 'json',
1623
+ summary: 'Synchronous chat — returns the final assistant text after the turn completes',
1624
+ handler: async (ctx) => svc.handleChat(ctx.req, ctx.res, ctx.reqId, ctx.start, ctx.body),
1625
+ },
1626
+ {
1627
+ method: 'POST',
1628
+ pattern: '/v1/chat/stream',
1629
+ auth: 'bearer',
1630
+ body: 'json',
1631
+ summary: 'Streaming chat — SSE response with intent/text/done events',
1632
+ handler: async (ctx) => svc.handleChatStream(ctx.req, ctx.res, ctx.reqId, ctx.start, ctx.body),
1633
+ },
1634
+ // ── Bearer-auth metadata ─────────────────────────────────────────
1635
+ {
1636
+ method: 'GET',
1637
+ pattern: '/v1/models',
1638
+ auth: 'bearer',
1639
+ summary: 'List available LLM providers (LOCAL + CLOUD) with realm metadata',
1640
+ handler: async (ctx) => {
1641
+ svc.handleListModels(ctx.res);
1642
+ svc.accessLog(ctx.reqId, 'GET', '/v1/models', 200, Date.now() - ctx.start);
1643
+ },
1644
+ },
1645
+ {
1646
+ method: 'GET',
1647
+ pattern: '/v1/audit',
1648
+ auth: 'bearer',
1649
+ summary: 'Query the audit log; supports since/until/kind/level/sessionId/limit',
1650
+ handler: async (ctx) => {
1651
+ const url = new URL(ctx.req.url ?? '/', `http://${ctx.req.headers.host ?? 'localhost'}`);
1652
+ await svc.handleListAudit(url, ctx.res);
1653
+ svc.accessLog(ctx.reqId, 'GET', '/v1/audit', 200, Date.now() - ctx.start);
1654
+ },
1655
+ },
1656
+ // ── Bearer-auth session management ───────────────────────────────
1657
+ {
1658
+ method: 'GET',
1659
+ pattern: '/v1/sessions',
1660
+ auth: 'bearer',
1661
+ summary: 'List active sessions (LRU order)',
1662
+ handler: async (ctx) => {
1663
+ svc.handleListSessions(ctx.res);
1664
+ svc.accessLog(ctx.reqId, 'GET', '/v1/sessions', 200, Date.now() - ctx.start);
1665
+ },
1666
+ },
1667
+ {
1668
+ method: 'GET',
1669
+ pattern: '/v1/sessions/{id}',
1670
+ auth: 'bearer',
1671
+ summary: 'Get session metadata by id',
1672
+ handler: async (ctx, params) => {
1673
+ svc.handleGetSession(params.id, ctx.res);
1674
+ svc.accessLog(ctx.reqId, 'GET', `/v1/sessions/${params.id}`, 200, Date.now() - ctx.start);
1675
+ },
1676
+ },
1677
+ {
1678
+ method: 'DELETE',
1679
+ pattern: '/v1/sessions/{id}',
1680
+ auth: 'bearer',
1681
+ summary: 'Delete session and release its AgentHandle',
1682
+ handler: async (ctx, params) => {
1683
+ await svc.handleDeleteSession(params.id, ctx.res);
1684
+ svc.accessLog(ctx.reqId, 'DELETE', `/v1/sessions/${params.id}`, 204, Date.now() - ctx.start);
1685
+ },
1686
+ },
1687
+ {
1688
+ method: 'POST',
1689
+ pattern: '/v1/sessions/{id}/cancel',
1690
+ auth: 'bearer',
1691
+ summary: 'Cancel an in-flight turn — best-effort abort of the live AgentHandle (202 Accepted)',
1692
+ handler: async (ctx, params) => {
1693
+ await svc.handleCancelSession(params.id, ctx.res);
1694
+ svc.accessLog(ctx.reqId, 'POST', `/v1/sessions/${params.id}/cancel`, 202, Date.now() - ctx.start);
1695
+ },
1696
+ },
1697
+ {
1698
+ method: 'POST',
1699
+ pattern: '/v1/sessions/{id}/rename',
1700
+ auth: 'bearer',
1701
+ body: 'json',
1702
+ summary: 'Rename a session — body `{ title }` (≤ 200 chars); persisted via the soft-dep sessions helper',
1703
+ handler: async (ctx, params) => {
1704
+ await svc.handleRenameSession(params.id, ctx.body, ctx.res);
1705
+ svc.accessLog(ctx.reqId, 'POST', `/v1/sessions/${params.id}/rename`, 200, Date.now() - ctx.start);
1706
+ },
1707
+ },
1708
+ {
1709
+ method: 'POST',
1710
+ pattern: '/v1/sessions/{id}/fork',
1711
+ auth: 'bearer',
1712
+ body: 'json',
1713
+ summary: 'Fork a session into a new sessionId; copies recent-message ring buffer (201 Created)',
1714
+ handler: async (ctx, params) => {
1715
+ await svc.handleForkSession(params.id, ctx.body, ctx.res);
1716
+ svc.accessLog(ctx.reqId, 'POST', `/v1/sessions/${params.id}/fork`, 201, Date.now() - ctx.start);
1717
+ },
1718
+ },
1719
+ ];
1720
+ }
1721
+ }
1722
+ /**
1723
+ * Pure (exported for unit tests) route resolver. See the inline comment
1724
+ * in HttpApiServiceImpl#resolveRoute for the soft-dep / fail-open contract.
1725
+ */
1726
+ export function resolveRoute(ctx, isLocal) {
1727
+ const preferred = isLocal ? 'local-llm' : 'cloud-llm';
1728
+ // §7.102: the fallback model mirrors the pools declared in
1729
+ // config/edge.json (llm.local.model / llm.cloud.model). The env override
1730
+ // layer in edge-config.ts keeps DSH_EDGE_LOCAL_LLM_MODEL / DSH_EDGE_MODEL
1731
+ // working, so the desktop GUI and the CLI profiles end up on the same
1732
+ // model.
1733
+ const pools = loadEdgeConfig(true);
1734
+ const fallbackModel = isLocal
1735
+ ? (localPool(pools).model ?? 'qwen3-4b-instruct-2507')
1736
+ : (cloudPool(pools).model ?? 'deepseek-chat');
1737
+ const mr = typeof ctx?.get === 'function' ? (() => { try {
1738
+ return ctx.get('modelRouter');
1739
+ }
1740
+ catch {
1741
+ return undefined;
1742
+ } })() : ctx?.modelRouter;
1743
+ if (mr && typeof mr.resolve === 'function') {
1744
+ try {
1745
+ const r = mr.resolve(preferred);
1746
+ if (r && typeof r.provider === 'string' && typeof r.model === 'string') {
1747
+ // §7.54 (2026-09-14): model-router maps provider ids to themselves
1748
+ // (e.g. 'local-llm' → {provider:'local-llm', model:'local-llm'}). The
1749
+ // wire body wants the actual upstream model name (DSH_EDGE_LOCAL_LLM_MODEL
1750
+ // for local, DSH_EDGE_MODEL for cloud), not the provider id. Override
1751
+ // the routing key with the fallback model when the router returned the
1752
+ // provider id as the model. §7.75: fallback 链从硬编码 'deepseek-chat'
1753
+ // 改成读 DSH_EDGE_MODEL (.env CLOUD_LLM_MODEL 注入值)。
1754
+ const resolvedModel = (r.model === r.provider) ? fallbackModel : r.model;
1755
+ return { provider: r.provider, model: resolvedModel, resolved: true };
1756
+ }
1757
+ }
1758
+ catch {
1759
+ // fall through to hardcoded pair
1760
+ }
1761
+ }
1762
+ return { provider: preferred, model: fallbackModel, resolved: false };
1763
+ }
1764
+ export async function apply(ctx, config) {
1765
+ // §7.102: the bearer token's historic source was the bare EDGE_API_KEY env
1766
+ // exported by .env.sh (`set -a; . .env`) — with the file-based config that
1767
+ // chain is gone, so an unset config.bearerToken must fall back to
1768
+ // secrets.edgeApiKey in config/edge.json.
1769
+ const cfg = loadEdgeConfig(true);
1770
+ // §7.102: bind / session / body knobs likewise come from config/edge.json
1771
+ // (http.* / resources.*), explicit row values first, literals last.
1772
+ const c = config;
1773
+ const httpSection = cfg.http;
1774
+ const resources = cfg.resources;
1775
+ if (c.host === undefined)
1776
+ c.host = httpSection?.host && httpSection.host.length > 0 ? httpSection.host : '127.0.0.1';
1777
+ if (c.port === undefined)
1778
+ c.port = httpSection?.port ?? 21017;
1779
+ if (c.sessionIdleTimeoutMs === undefined)
1780
+ c.sessionIdleTimeoutMs = resources?.sessionIdleMs ?? 30 * 60 * 1000;
1781
+ if (c.maxActiveSessions === undefined)
1782
+ c.maxActiveSessions = resources?.maxSessions ?? 100;
1783
+ if (c.maxRequestBodyBytes === undefined)
1784
+ c.maxRequestBodyBytes = resources?.maxBodyBytes ?? 1024 * 1024;
1785
+ if (config.bearerToken === undefined) {
1786
+ const fromFile = cfg.secrets?.edgeApiKey;
1787
+ if (fromFile !== undefined && fromFile.length > 0) {
1788
+ config.bearerToken = fromFile;
1789
+ }
1790
+ }
1791
+ if (!config.bearerToken) {
1792
+ // Fail-CLOSED at startup: the API exposes /v1/audit (full decision log)
1793
+ // and session delete/fork. Serving it without a credential is worse than
1794
+ // not serving it at all — a caller cannot distinguish "no API" from
1795
+ // "open API", and the operator's intent (a loopback-only, credential-
1796
+ // gated control plane) is silently violated. Refuse to start.
1797
+ ctx.logger?.error?.('[http-api] REFUSING TO START: no bearerToken configured '
1798
+ + '(config/edge.json secrets.edgeApiKey is empty). '
1799
+ + 'Set secrets.edgeApiKey, or remove the http-api row from the patch.');
1800
+ throw new Error('http-api: bearerToken is required (set secrets.edgeApiKey in config/edge.json)');
1801
+ }
1802
+ const agents = ctx.get('agents');
1803
+ const auditLog = ctx.get('auditLog');
1804
+ const tdBus = ctx.get?.('tdBus');
1805
+ ctx.logger?.info?.(`[http-api] apply() - auditLog: ${!!auditLog}, tdBus: ${!!tdBus}`);
1806
+ const service = new HttpApiServiceImpl(ctx, config, agents, auditLog, tdBus);
1807
+ await service.start();
1808
+ return async () => {
1809
+ await service.stop();
1810
+ };
1811
+ }