mindforge-memory 6.4.1__py3-none-any.whl

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 (64) hide show
  1. MindForge.py +20 -0
  2. mindforge/__init__.py +111 -0
  3. mindforge/adapters/__init__.py +18 -0
  4. mindforge/adapters/claude_adapter.py +84 -0
  5. mindforge/adapters/crewai_memory.py +76 -0
  6. mindforge/adapters/generic_api.py +183 -0
  7. mindforge/adapters/langgraph_store.py +463 -0
  8. mindforge/adapters/openclaw_adapter.py +91 -0
  9. mindforge/api/__init__.py +1 -0
  10. mindforge/api/server.py +1567 -0
  11. mindforge/cli/__init__.py +1 -0
  12. mindforge/cli/main.py +13607 -0
  13. mindforge/core/__init__.py +37 -0
  14. mindforge/core/embedding.py +847 -0
  15. mindforge/core/encryption.py +597 -0
  16. mindforge/core/indexer.py +363 -0
  17. mindforge/core/mindforge.py +6298 -0
  18. mindforge/core/paths.py +43 -0
  19. mindforge/core/query.py +501 -0
  20. mindforge/core/storage.py +18656 -0
  21. mindforge/core/types.py +314 -0
  22. mindforge/core/version.py +56 -0
  23. mindforge/daemon/__init__.py +0 -0
  24. mindforge/daemon/server.py +277 -0
  25. mindforge/mcp/__init__.py +4 -0
  26. mindforge/mcp/server.py +2559 -0
  27. mindforge/modules/__init__.py +138 -0
  28. mindforge/modules/categorizer.py +151 -0
  29. mindforge/modules/conflict_detector.py +356 -0
  30. mindforge/modules/connectors.py +331 -0
  31. mindforge/modules/context_window.py +337 -0
  32. mindforge/modules/event_bus.py +572 -0
  33. mindforge/modules/evolution.py +210 -0
  34. mindforge/modules/experience.py +402 -0
  35. mindforge/modules/federated.py +722 -0
  36. mindforge/modules/federated_acl.py +393 -0
  37. mindforge/modules/follow_up.py +315 -0
  38. mindforge/modules/hybrid_search.py +565 -0
  39. mindforge/modules/importer.py +374 -0
  40. mindforge/modules/integrator.py +196 -0
  41. mindforge/modules/intent_router.py +359 -0
  42. mindforge/modules/knowledge_graph.py +395 -0
  43. mindforge/modules/memory_compressor.py +592 -0
  44. mindforge/modules/memory_decay.py +474 -0
  45. mindforge/modules/memory_passport.py +250 -0
  46. mindforge/modules/multi_agent.py +534 -0
  47. mindforge/modules/multimodal.py +304 -0
  48. mindforge/modules/outcome_learning.py +186 -0
  49. mindforge/modules/personality.py +275 -0
  50. mindforge/modules/principled_forgetting.py +258 -0
  51. mindforge/modules/privacy.py +736 -0
  52. mindforge/modules/recall.py +263 -0
  53. mindforge/modules/session_focus.py +305 -0
  54. mindforge/modules/share_conflict.py +521 -0
  55. mindforge/modules/skill_extractor.py +340 -0
  56. mindforge/modules/skill_pack.py +205 -0
  57. mindforge/modules/timeline_viz.py +206 -0
  58. mindforge/modules/v6.py +931 -0
  59. mindforge_memory-6.4.1.dist-info/METADATA +281 -0
  60. mindforge_memory-6.4.1.dist-info/RECORD +64 -0
  61. mindforge_memory-6.4.1.dist-info/WHEEL +5 -0
  62. mindforge_memory-6.4.1.dist-info/entry_points.txt +3 -0
  63. mindforge_memory-6.4.1.dist-info/licenses/LICENSE +21 -0
  64. mindforge_memory-6.4.1.dist-info/top_level.txt +2 -0
@@ -0,0 +1,1567 @@
1
+ """
2
+ MindForge REST API Server
3
+ ================================
4
+
5
+ 标准 REST API,让非 Python 应用(JS、Go、移动端)也能直接调用 MindForge。
6
+
7
+ 基于 Python 内置 http.server,无需额外依赖(FastAPI/Flask 可选)。
8
+
9
+ 路由表(v5.7.0 P2 补充,便于调试;v6.1.2 校正口径)
10
+ ----------------------------------------------------
11
+ 手动分发:BaseHTTPRequestHandler.do_GET / do_POST / do_PUT / do_DELETE
12
+ 中以 if/elif 按 path 匹配(无装饰器路由)。限流(v5.7.0 P2 分级):
13
+ 读 100 req/60s/IP、写 30、批量导入 10(_check_rate_limit(write/heavy));
14
+ 认证:除 /api/health 与 / 外均需 _check_auth。
15
+
16
+ **本表是摘选,不是权威清单。** 当前实现的分发点共 **60** 个
17
+ (按方法:GET 37 / POST 21 / PUT 1 / DELETE 1,口径见
18
+ `scripts/check_docs_consistency.py` 第 [8] 组——它直接解析 `do_*` 方法体,
19
+ 数字漂移会被 CI 拦下)。此前这里写着「路由表」却只列了 24 条,
20
+ 读代码的人据此估算接口规模,会低估一半以上。
21
+
22
+ Method Path 说明 分发位置
23
+ ------ ---------------------- ------------------------ --------------
24
+ GET / 最小化元信息(未授权可读) do_GET
25
+ GET /api/health 健康状态(未授权可读) do_GET
26
+ GET /api/stats 统计信息(需认证) do_GET
27
+ GET /api/tags 标签列表(需认证) do_GET
28
+ GET /api/search 搜索(?q=&limit=&explain=) do_GET
29
+ GET /api/memories 列出(?limit=&offset=&…) do_GET
30
+ GET /api/memories/{id} 获取单条(需认证) do_GET
31
+ GET /api/export 导出 JSON(上限 5000 条) do_GET
32
+ GET /api/valid-at 按事实有效时间查询(v5.7.6) do_GET
33
+ GET /api/connectors 列出可用数据连接器(v5.7.7) do_GET
34
+ POST /api/agent/forget Agent 自主遗忘(v5.7.7) do_POST
35
+ POST /api/conflicts/reconcile 冲突自动调和(v5.7.7) do_POST
36
+ POST /api/memories 添加记忆(JSON body) do_POST
37
+ POST /api/import 批量导入(≤10000 条) do_POST
38
+ PUT /api/memories/{id} 更新记忆(JSON body) do_PUT
39
+ DELETE /api/memories/{id} 删除记忆 do_DELETE
40
+
41
+ # ---- v6.0.0 正式版:v6 端口 + 6G AI 记忆预览 ----
42
+ GET /api/v6/status v6 端口总览(多 Agent + 6G) do_GET
43
+ GET /api/preview/channels 6G 预览通道列表 do_GET
44
+ GET /api/preview/index 6G 增量索引统计 do_GET
45
+ GET /api/preview/timeline 6G 单记忆时间线(?memory_id=) do_GET
46
+ GET /api/forget/stats 遗忘引擎统计 do_GET
47
+ POST /api/preview/open 开启 6G 预览通道 do_POST
48
+ POST /api/preview/close 关闭并清理 6G 预览通道 do_POST
49
+ POST /api/preview/snapshot 快照记忆进预览通道 do_POST
50
+ POST /api/preview/distill 6G 上下文蒸馏 do_POST
51
+ POST /api/forget/run 有原则遗忘 GC(v6.1.2 起支持
52
+ body.days 冷却窗口) do_POST
53
+ # 其余(时间线、技能包、护照、跟进、画像、联邦、审计等)见 do_* 分发。
54
+
55
+ 启动方式:
56
+ MindForge serve --api
57
+ MindForge serve --api --port 9000
58
+ MindForge serve --api --host 0.0.0.0
59
+ """
60
+
61
+ import json
62
+ import logging
63
+ import re
64
+ import sys
65
+ import os
66
+ import time
67
+ import hmac
68
+ import threading
69
+ from pathlib import Path
70
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
71
+ from urllib.parse import urlparse, parse_qs
72
+ from collections import defaultdict
73
+ from typing import Dict, List, Optional, Any
74
+ from mindforge import __version__ as MF_VERSION
75
+
76
+ try:
77
+ from mindforge.core.encryption import SecurityError
78
+ except ImportError:
79
+
80
+ class SecurityError(Exception):
81
+ pass
82
+
83
+
84
+ # 确保项目根目录在 path 中
85
+ _PROJECT_ROOT = Path(__file__).parent.parent
86
+ if str(_PROJECT_ROOT) not in sys.path:
87
+ sys.path.insert(0, str(_PROJECT_ROOT))
88
+
89
+ logger = logging.getLogger(__name__)
90
+
91
+ # v5.4.8 安全修复:请求体大小限制(10MB)
92
+ MAX_BODY_SIZE = 10 * 1024 * 1024
93
+
94
+
95
+ # v5.4.7 修复 H-7:简单速率限制器
96
+ class _RateLimiter:
97
+ """基于 IP 的请求速率限制
98
+
99
+ v5.6.3 稳定性修复:
100
+ - 清理过期记录后,若某 IP 的计数已归零则删除该键,避免冷 IP 永久驻留内存;
101
+ - 对跟踪的 IP 总数设上限(默认 10000),超出时丢弃最久未活动的 IP,
102
+ 防止长期运行(数月)后字典无界增长导致内存泄漏。
103
+ """
104
+
105
+ _MAX_TRACKED_IPS = 10000
106
+
107
+ def __init__(self, max_requests=100, window_seconds=60):
108
+ self.max_requests = max_requests
109
+ self.window = window_seconds
110
+ self._requests = defaultdict(list)
111
+ self._lock = threading.Lock()
112
+
113
+ def check(self, client_ip: str) -> bool:
114
+ """返回 True 表示允许,False 表示限流
115
+
116
+ v5.7.1 修复:原实现在 append 后才判断空键(此时 times 必非空,
117
+ 清理逻辑是死代码)。改为过滤后立即清理空键——某 IP 的历史请求
118
+ 全部过期时删除该键,避免冷 IP 永久驻留字典。
119
+ """
120
+ now = time.time()
121
+ with self._lock:
122
+ times = [t for t in self._requests[client_ip] if now - t < self.window]
123
+ # 过滤后为空 → 该 IP 历史已过期,删除空键避免常驻
124
+ if not times:
125
+ self._requests.pop(client_ip, None)
126
+ else:
127
+ self._requests[client_ip] = times
128
+ if len(times) >= self.max_requests:
129
+ return False
130
+ times.append(now)
131
+ self._requests[client_ip] = times
132
+ # IP 总数上限:丢弃最久未活动的键
133
+ if len(self._requests) > self._MAX_TRACKED_IPS:
134
+ excess = len(self._requests) - self._MAX_TRACKED_IPS
135
+ for stale_ip in list(self._requests.keys())[:excess]:
136
+ self._requests.pop(stale_ip, None)
137
+ return True
138
+
139
+
140
+ _rate_limiter = _RateLimiter(max_requests=100, window_seconds=60)
141
+ # v5.7.0 P2:写路径配额更严(30 req/60s/IP);批量/导入端点再降一档(10 req/60s/IP)
142
+ _write_limiter = _RateLimiter(max_requests=30, window_seconds=60)
143
+ _import_limiter = _RateLimiter(max_requests=10, window_seconds=60)
144
+
145
+
146
+ def _normalize_ip(ip: str) -> str:
147
+ """将 IP 规范化为限流 key
148
+
149
+ P2 #15 修复:IPv6 主机可生成大量临时地址绕过限流。
150
+ 对 IPv6 使用 /64 子网前缀作为限流 key(同一子网共享计数)。
151
+ IPv4 直接使用原地址。
152
+ """
153
+ import ipaddress
154
+
155
+ try:
156
+ addr = ipaddress.ip_address(ip)
157
+ except ValueError:
158
+ return ip # 解析失败直接用原值(fail-open,但仅用于限流 key)
159
+ if isinstance(addr, ipaddress.IPv6Address):
160
+ # 取 /64 前缀
161
+ network = ipaddress.IPv6Network((addr, 64), strict=False)
162
+ return str(network.network_address) + "/64"
163
+ return ip
164
+
165
+
166
+ # P0-003: 限制最大并发线程数的 HTTP 服务器
167
+ class BoundedThreadingHTTPServer(ThreadingHTTPServer):
168
+ """带最大并发线程数限制的 HTTP 服务器
169
+
170
+ 防止大量并发请求耗尽服务器资源(线程、内存、文件描述符)。
171
+ 超过限制时返回 503 Service Unavailable。
172
+ """
173
+
174
+ max_threads = 50
175
+ daemon_threads = True
176
+
177
+ def __init__(self, *args, **kwargs):
178
+ super().__init__(*args, **kwargs)
179
+ self._active_threads = 0
180
+ self._thread_lock = threading.Lock()
181
+ self._max_threads = self.__class__.max_threads
182
+
183
+ def process_request(self, request, client_address):
184
+ """覆盖 process_request 以限制并发线程数"""
185
+ with self._thread_lock:
186
+ if self._active_threads >= self._max_threads:
187
+ # 超过并发限制,返回 503
188
+ try:
189
+ body = b'{"error": "Too many concurrent requests"}'
190
+ request.sendall(
191
+ b"HTTP/1.1 503 Service Unavailable\r\n"
192
+ b"Content-Type: application/json\r\n"
193
+ b"Content-Length: " + str(len(body)).encode() + b"\r\n"
194
+ b"\r\n" + body
195
+ )
196
+ except Exception:
197
+ pass
198
+ request.close()
199
+ return
200
+ self._active_threads += 1
201
+
202
+ # 在新线程中处理请求
203
+ t = threading.Thread(
204
+ target=self._process_request_thread,
205
+ args=(request, client_address),
206
+ daemon=True,
207
+ )
208
+ # v5.5.8 修复:t.start() 失败(如线程资源耗尽)时计数不会回退,
209
+ # 并发槽位会逐步泄漏直至服务永久返回 503。
210
+ try:
211
+ t.start()
212
+ except RuntimeError:
213
+ with self._thread_lock:
214
+ self._active_threads -= 1
215
+ try:
216
+ body = b'{"error": "Failed to start worker thread"}'
217
+ request.sendall(
218
+ b"HTTP/1.1 503 Service Unavailable\r\n"
219
+ b"Content-Type: application/json\r\n"
220
+ b"Content-Length: " + str(len(body)).encode() + b"\r\n"
221
+ b"\r\n" + body
222
+ )
223
+ except Exception:
224
+ pass
225
+ request.close()
226
+
227
+ def _process_request_thread(self, request, client_address):
228
+ try:
229
+ self.finish_request(request, client_address)
230
+ except Exception:
231
+ self.handle_error(request, client_address)
232
+ finally:
233
+ self.shutdown_request(request)
234
+ # v5.7.1:请求线程结束时显式关闭本线程的 SQLite 连接,
235
+ # 避免 ThreadingHTTPServer 每请求新建线程导致连接对象堆积。
236
+ try:
237
+ mf = MindForgeAPIHandler.mindforge
238
+ if mf is not None and hasattr(mf, "storage"):
239
+ mf.storage.close_thread_conn()
240
+ except Exception:
241
+ pass
242
+ with self._thread_lock:
243
+ self._active_threads -= 1
244
+
245
+
246
+ def _safe_int(value, default=10, min_val=1, max_val=10000):
247
+ """v5.4.7 修复 H-1:安全解析整数参数"""
248
+ try:
249
+ v = int(value)
250
+ return max(min_val, min(max_val, v))
251
+ except (ValueError, TypeError):
252
+ return default
253
+
254
+
255
+ def _safe_float(value, default=0.3, min_val=0.0, max_val=1.0):
256
+ """v5.4.7 修复 H-1:安全解析浮点参数"""
257
+ try:
258
+ v = float(value)
259
+ return max(min_val, min(max_val, v))
260
+ except (ValueError, TypeError):
261
+ return default
262
+
263
+
264
+ _TAG_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
265
+
266
+
267
+ def _sanitize_tag(tag) -> str:
268
+ """P2-04 安全修复:归一化标签字符串后再聚合/返回。
269
+
270
+ /api/tags 的两条解析路径(JSON 与逗号分隔 fallback)都可能带回未经校验的
271
+ 标签值:非字符串对象、含控制字符或超长二进制串。统一转成 str、去除首尾空白、
272
+ 删除 ASCII 控制字符并截断到 64 字符,避免异常标签进入 JSON 响应。
273
+ """
274
+ if not isinstance(tag, str):
275
+ tag = str(tag)
276
+ tag = _TAG_CONTROL_RE.sub("", tag).strip()
277
+ return tag[:64]
278
+
279
+
280
+ def _sanitize_category(value, default=None) -> Optional[str]:
281
+ """v5.7.0 P2:分类参数校验——必须是字符串,剔除控制字符后截断,空值回落默认。"""
282
+ if value is None:
283
+ return default
284
+ if not isinstance(value, str):
285
+ return default
286
+ cat = _TAG_CONTROL_RE.sub("", value).strip()
287
+ if not cat:
288
+ return default
289
+ return cat[:64]
290
+
291
+
292
+ def _validate_tags(value) -> List[str]:
293
+ """v5.7.0 P2:标签参数校验——必须是字符串列表,逐项消毒并限制数量(≤50)。"""
294
+ if value is None:
295
+ return []
296
+ if not isinstance(value, list):
297
+ raise ValueError("Field 'tags' must be a list of strings")
298
+ cleaned: List[str] = []
299
+ for t in value:
300
+ if not isinstance(t, str):
301
+ raise ValueError("Field 'tags' must be a list of strings")
302
+ ct = _sanitize_tag(t)
303
+ if ct:
304
+ cleaned.append(ct)
305
+ if len(cleaned) >= 50:
306
+ break
307
+ return cleaned
308
+
309
+
310
+ _IMPORTANCE_VALUES = {"LOW", "MEDIUM", "HIGH", "CRITICAL"}
311
+
312
+
313
+ def _validate_importance(value):
314
+ """v5.7.0 P2:importance 必须是合法枚举值(大小写不敏感),返回规范大写。"""
315
+ if value is None:
316
+ return None
317
+ if not isinstance(value, str):
318
+ raise ValueError("Field 'importance' must be one of LOW/MEDIUM/HIGH/CRITICAL")
319
+ up = value.strip().upper()
320
+ if up not in _IMPORTANCE_VALUES:
321
+ raise ValueError("Field 'importance' must be one of LOW/MEDIUM/HIGH/CRITICAL")
322
+ return up
323
+
324
+
325
+ def _validate_starred(value):
326
+ """v5.7.0 P2:starred 必须是布尔(或 0/1)。"""
327
+ if value is None:
328
+ return None
329
+ if isinstance(value, bool):
330
+ return int(value)
331
+ if isinstance(value, int) and value in (0, 1):
332
+ return value
333
+ raise ValueError("Field 'starred' must be a boolean")
334
+
335
+
336
+ def _validate_source_agent(value) -> str:
337
+ """v5.7.0 P2:source_agent 必须是字符串,截断到 128。"""
338
+ if value is None:
339
+ return ""
340
+ if not isinstance(value, str):
341
+ raise ValueError("Field 'source_agent' must be a string")
342
+ return value[:128]
343
+
344
+
345
+ def _validate_float_ts(value) -> float:
346
+ """v5.7.6:valid_from/valid_to 时间戳校验(None/0 → 0.0,非法抛 ValueError)。"""
347
+ if value is None:
348
+ return 0.0
349
+ if isinstance(value, bool):
350
+ raise ValueError("Field 'valid_from'/'valid_to' must be a number")
351
+ try:
352
+ f = float(value)
353
+ except (TypeError, ValueError):
354
+ raise ValueError("Field 'valid_from'/'valid_to' must be a number")
355
+ if f < 0:
356
+ raise ValueError("Field 'valid_from'/'valid_to' must be >= 0")
357
+ return f
358
+
359
+
360
+ def _log_unhandled_api_error(exc: Exception) -> None:
361
+ """P3-03:统一记录未捕获异常,避免 traceback 把内部文件路径写进常规日志。
362
+
363
+ logger.exception 会把完整回溯(含绝对路径、帧信息)打到 INFO/ERROR 级日志里。
364
+ 这里降级:常规级别只记异常类型名,完整回溯仅在 DEBUG 级按需输出。
365
+ HTTP 响应始终是通用的 Internal server error,不含任何异常详情。
366
+ """
367
+ logger.error("API error: %s", type(exc).__name__)
368
+ if logger.isEnabledFor(logging.DEBUG):
369
+ logger.debug("API error traceback", exc_info=exc)
370
+
371
+
372
+ class MindForgeAPIHandler(BaseHTTPRequestHandler):
373
+ """REST API 请求处理器"""
374
+
375
+ # MindForge 实例由 server 注入
376
+ mindforge = None
377
+
378
+ # v5.7.2 安全加固:服务端 TLS 启用时下发 HSTS(由 start_api_server 设置)
379
+ _tls_enabled = False
380
+
381
+ # HTTPS 反向代理场景:显式信任代理(MINDFORGE_TRUST_PROXY=1)后,
382
+ # 请求带 X-Forwarded-Proto: https 时同样下发 HSTS(由 start_api_server 设置)。
383
+ # 默认关闭——纯 HTTP 直连绝不因客户端伪造的 X-Forwarded-Proto 下发 HSTS,
384
+ # 避免 HSTS pinning 攻击把浏览器钉死在某个不支持 HTTPS 的主机上。
385
+ _trust_proxy = False
386
+
387
+ def _send_json(self, data, status=200):
388
+ body = json.dumps(data, ensure_ascii=False, default=str).encode("utf-8")
389
+ self.send_response(status)
390
+ self.send_header("Content-Type", "application/json; charset=utf-8")
391
+ self.send_header("Content-Length", str(len(body)))
392
+ # v5.7.2 安全加固:统一安全响应头
393
+ # - X-Content-Type-Options: nosniff 防止 MIME 嗅探
394
+ # - X-Frame-Options: DENY 防止点击劫持
395
+ # - Referrer-Policy: no-referrer 不泄漏来源 URL
396
+ # - Cache-Control: no-store 敏感记忆数据禁止落入浏览器/中间缓存
397
+ # - Strict-Transport-Security: 仅服务端 TLS 启用时下发(见 start_api_server)
398
+ self.send_header("X-Content-Type-Options", "nosniff")
399
+ self.send_header("X-Frame-Options", "DENY")
400
+ self.send_header("Referrer-Policy", "no-referrer")
401
+ self.send_header("Cache-Control", "no-store")
402
+ # 用 getattr 兜底:响应桩/测试对象可能不是 MindForgeAPIHandler 实例。
403
+ # HSTS 下发条件(二选一):
404
+ # 1) 服务端自启 TLS(_tls_enabled,由 ssl_certfile 启用);
405
+ # 2) 显式信任 HTTPS 反代(MINDFORGE_TRUST_PROXY=1)且请求经
406
+ # X-Forwarded-Proto: https 到达——纯 HTTP 直连绝不因伪造头下发。
407
+ if getattr(self, "_tls_enabled", False):
408
+ self.send_header("Strict-Transport-Security", "max-age=31536000")
409
+ elif (
410
+ getattr(self, "_trust_proxy", False)
411
+ and self.headers.get("X-Forwarded-Proto", "").lower() == "https"
412
+ ):
413
+ self.send_header("Strict-Transport-Security", "max-age=31536000")
414
+ # v5.4.8 安全修复:CORS 限制为配置的源(默认仅允许同源)
415
+ allowed_origin = os.environ.get("MINDFORGE_CORS_ORIGIN", "")
416
+ if allowed_origin:
417
+ self.send_header("Access-Control-Allow-Origin", allowed_origin)
418
+ # v5.6.5 P2 #15:Allow-Methods/Headers 仅在显式配置 Allow-Origin
419
+ # (即确实允许跨域)时才下发,避免未开启 CORS 却无条件暴露跨域策略。
420
+ self.send_header(
421
+ "Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"
422
+ )
423
+ self.send_header(
424
+ "Access-Control-Allow-Headers", "Content-Type, Authorization"
425
+ )
426
+ self.end_headers()
427
+ self.wfile.write(body)
428
+
429
+ def _read_body(self):
430
+ # v5.6.2 安全修复:Content-Length 非数值时返回 None,不抛异常
431
+ try:
432
+ content_length = int(self.headers.get("Content-Length", 0))
433
+ except (ValueError, TypeError):
434
+ return None
435
+ if content_length == 0:
436
+ # v5.5.3 fix: 返回 None 让调用方统一处理错误响应,避免双重写入
437
+ return None
438
+ # v5.4.8 安全修复:请求体大小限制
439
+ if content_length > MAX_BODY_SIZE:
440
+ # v5.9.0 P3-3:有界 drain 后响应 413——客户端真实在传 body 时
441
+ # 读掉有限量剩余数据(避免 Broken pipe),但声明大 body 却不
442
+ # 发送的客户端(挂起攻击)在短超时后立即得到 413,不阻塞。
443
+ drained = 0
444
+ try:
445
+ self.connection.settimeout(2.0)
446
+ cap = min(content_length, MAX_BODY_SIZE + 1_048_576)
447
+ while drained < cap:
448
+ chunk = self.rfile.read(min(65536, cap - drained))
449
+ if not chunk:
450
+ break
451
+ drained += len(chunk)
452
+ except Exception:
453
+ pass
454
+ finally:
455
+ try:
456
+ self.connection.settimeout(None)
457
+ except Exception:
458
+ pass
459
+ self._send_json(
460
+ {
461
+ "error": f"Request body too large (max {MAX_BODY_SIZE // 1024 // 1024}MB)"
462
+ },
463
+ 413,
464
+ )
465
+ return None
466
+ if content_length < 0:
467
+ return None
468
+ raw = self.rfile.read(content_length)
469
+ try:
470
+ # P2 #23 修复:外部输入用安全 JSON 解析(深度+大小限制)
471
+ from mindforge.core.storage import _safe_json_loads
472
+
473
+ return _safe_json_loads(raw.decode("utf-8"))
474
+ except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
475
+ return None
476
+
477
+ def _extract_mem_id(self, path: str, prefix: str = "/api/memories/") -> str:
478
+ """从路径中安全提取记忆 ID(v5.6.2 安全修复:防路径遍历/注入)
479
+
480
+ 拒绝包含路径分隔符、空字节、超长值等可疑输入。
481
+ """
482
+ if not path.startswith(prefix):
483
+ return ""
484
+ mem_id = path[len(prefix) :]
485
+ # v5.6.2 安全校验:拒绝路径遍历字符、空字节、过长 ID
486
+ if not mem_id or len(mem_id) > 128:
487
+ return ""
488
+ if "\x00" in mem_id or "/" in mem_id or "\\" in mem_id:
489
+ return ""
490
+ if ".." in mem_id:
491
+ return ""
492
+ return mem_id
493
+
494
+ def _client_identity_trusted(self) -> bool:
495
+ """v5.6.5 P1 #9:是否信任客户端自报身份(X-Agent-Id / agent)。
496
+
497
+ ``X-Agent-Id`` 可被任意客户端伪造,默认**不信任**,防止通过冒充其他
498
+ Agent 身份绕过 list/export 的隐私过滤。仅当运维显式设置
499
+ ``MINDFORGE_TRUST_AGENT_HEADER=1`` 且已配置网关级 Bearer 认证
500
+ (MINDFORGE_API_KEY)时才接受该头——适用于身份由可信网关鉴权后
501
+ 透传的部署。完整的端到端 Agent 身份联邦属于 v6.0 方案。
502
+ """
503
+ if not os.environ.get("MINDFORGE_API_KEY", ""):
504
+ return False # 无网关认证的裸 noauth 模式绝不信任自报身份
505
+ flag = os.environ.get("MINDFORGE_TRUST_AGENT_HEADER", "").strip().lower()
506
+ return flag in ("1", "true", "yes", "on")
507
+
508
+ def _extract_actor(self, qs: Dict[str, list]) -> str:
509
+ """提取用于 list/export 隐私过滤的调用者身份(F1,v5.6.5 P1 #9 加固)。
510
+
511
+ - 可信部署(见 _client_identity_trusted):``X-Agent-Id`` 头 > ``agent`` 查询参数;
512
+ - 默认可信度不足:忽略客户端自报身份,回落到服务端固定的
513
+ ``MINDFORGE_AGENT_ID``(未配置则为 "",即本地单属主不过滤),
514
+ 杜绝伪造 Agent 身份跨租户读取。
515
+ """
516
+ if self._client_identity_trusted():
517
+ try:
518
+ header_actor = (self.headers.get("X-Agent-Id") or "").strip()
519
+ except Exception:
520
+ header_actor = ""
521
+ if header_actor:
522
+ return header_actor[:128]
523
+ query_actor = (qs.get("agent", [""])[0] or "").strip()
524
+ return query_actor[:128]
525
+ # 默认:不采信任何客户端自报身份
526
+ server_agent = (os.environ.get("MINDFORGE_AGENT_ID", "") or "").strip()[:128]
527
+ if server_agent:
528
+ return server_agent
529
+ # v5.7.3 安全修复:已配置 API Key 的部署中,未声明的调用者一律按
530
+ # anonymous 处理(fail-closed),仅可访问 PUBLIC 级记忆;杜绝空身份
531
+ # 绕过 _filter_by_privacy 全量读取 PRIVATE/STRICT 记忆。
532
+ if (os.environ.get("MINDFORGE_API_KEY", "") or "").strip():
533
+ return "anonymous"
534
+ # 本地单用户无 API Key 模式:保留空身份(本机全量可见),兼容 CLI 场景
535
+ return ""
536
+
537
+ def _check_auth(self):
538
+ """验证 Bearer Token(通过 MINDFORGE_API_KEY 环境变量配置)
539
+
540
+ v5.6.3 安全增强(P1 #12 修复):
541
+ - MINDFORGE_ALLOW_NOAUTH 默认 "0"(fail-closed),多用户主机安全。
542
+ 单用户本地使用需显式设 MINDFORGE_ALLOW_NOAUTH=1 开启无认证模式。
543
+ - 设置 MINDFORGE_API_KEY 后,所有非 /api/health 端点必须携带正确 Bearer Token。
544
+ - 非 localhost 绑定且无 API Key 时,启动阶段直接拒绝(fail-closed)。
545
+ """
546
+ api_key = os.environ.get("MINDFORGE_API_KEY", "")
547
+ if not api_key:
548
+ # P1 修复:默认 fail-closed,显式设置 ALLOW_NOAUTH=1 才开放本地无认证
549
+ allow_noauth = os.environ.get("MINDFORGE_ALLOW_NOAUTH", "0").strip().lower()
550
+ if allow_noauth not in ("1", "true", "yes", "on"):
551
+ self._send_json(
552
+ {"error": "Unauthorized (MINDFORGE_API_KEY required)"}, 401
553
+ )
554
+ return False
555
+ # 显式开启的无认证模式:记录一次性告警
556
+ if not getattr(self.__class__, "_auth_warned", False):
557
+ logger.warning(
558
+ "MINDFORGE_API_KEY not set — API is open to all LOCAL requests. "
559
+ "Set MINDFORGE_API_KEY to require authentication, or set "
560
+ "MINDFORGE_ALLOW_NOAUTH=0 to force auth on all interfaces. "
561
+ "Binding to a non-localhost host without MINDFORGE_API_KEY is refused at startup."
562
+ )
563
+ self.__class__._auth_warned = True
564
+ return True
565
+ auth_header = self.headers.get("Authorization", "")
566
+ if auth_header.startswith("Bearer "):
567
+ token = auth_header[7:]
568
+ if hmac.compare_digest(token, api_key):
569
+ return True
570
+ self._send_json({"error": "Unauthorized"}, 401)
571
+ return False
572
+
573
+ def do_OPTIONS(self):
574
+ # v5.6.2 安全修复:OPTIONS 也走限流,防止 CORS Preflight DoS
575
+ if not self._check_rate_limit():
576
+ return
577
+ self._send_json({"status": "ok"})
578
+
579
+ def _check_rate_limit(self, write: bool = False, heavy: bool = False) -> bool:
580
+ """v5.6.1 安全修复:统一限流检查(所有 HTTP 方法共用)。
581
+ 返回 True 表示允许,False 表示已返回 429。
582
+
583
+ v5.7.0 P2 分级限流:
584
+ - 读路径:全局 100 req/60s/IP;
585
+ - 写路径(POST/PUT/DELETE):额外 30 req/60s/IP;
586
+ - 批量/导入端点:额外 10 req/60s/IP。
587
+ """
588
+ client_ip = self.client_address[0]
589
+ rate_key = _normalize_ip(client_ip)
590
+ if not _rate_limiter.check(rate_key):
591
+ self._send_json({"error": "Rate limit exceeded. Try again later."}, 429)
592
+ return False
593
+ if write and not _write_limiter.check(rate_key):
594
+ self._send_json({"error": "Rate limit exceeded for write operations."}, 429)
595
+ return False
596
+ if heavy and not _import_limiter.check(rate_key):
597
+ self._send_json({"error": "Rate limit exceeded for bulk operations."}, 429)
598
+ return False
599
+ return True
600
+
601
+ def do_GET(self):
602
+ parsed = urlparse(self.path)
603
+ path = parsed.path.rstrip("/") or "/"
604
+ qs = parse_qs(parsed.query)
605
+
606
+ # v5.4.7 修复 H-7:速率限制
607
+ if not self._check_rate_limit():
608
+ return
609
+
610
+ if path != "/api/health" and not self._check_auth():
611
+ return
612
+
613
+ try:
614
+ if path == "/api/health":
615
+ try:
616
+ result = self.mindforge.health_check()
617
+ except Exception as he:
618
+ # v5.6.5 P3 #22:存储不可用属服务暂时不可用,返回 503 而非 500
619
+ logger.error("health_check storage failure: %s", he)
620
+ self._send_json(
621
+ {"status": "unhealthy", "error": "storage unavailable"}, 503
622
+ )
623
+ return
624
+ # P2-02 安全修复:/api/health 全程无需认证(见 do_GET 的分发条件),
625
+ # 因此无论是否配置 MINDFORGE_API_KEY,都只回最小化状态,绝不外泄
626
+ # total_memories / db_size_bytes / 缺失索引名 / recommendations 等指纹信息。
627
+ # 需要详细健康诊断请使用已认证的 /api/stats 等端点。
628
+ self._send_json({"status": result.get("status", "unknown")})
629
+
630
+ elif path == "/api/stats":
631
+ result = self.mindforge.stats()
632
+ self._send_json(result)
633
+
634
+ elif path == "/api/meta/report":
635
+ # v5.9.0 记忆健康报告:仅统计与元数据,不含明文,位于鉴权网关之后
636
+ self._send_json(self.mindforge.memory_report())
637
+
638
+ elif path == "/api/meta/privacy":
639
+ # v5.9.2 隐私健康体检:仅统计与元数据,不含明文,位于鉴权网关之后
640
+ self._send_json(self.mindforge.privacy_report())
641
+
642
+ elif path == "/api/meta/graph":
643
+ # v5.9.3 知识图谱导出(不含记忆明文,max_privacy 隐私过滤)
644
+ mp = (qs.get("max_privacy", ["INTERNAL"])[0] or "INTERNAL").upper()
645
+ if mp not in ("PUBLIC", "INTERNAL", "PRIVATE", "STRICT"):
646
+ mp = "INTERNAL"
647
+ self._send_json(self.mindforge.export_graph(max_privacy=mp))
648
+
649
+ elif path == "/api/audit":
650
+ # v5.9.0 审计日志导出(GDPR/取证):limit/since/actor 过滤
651
+ limit = _safe_int(
652
+ qs.get("limit", ["100"])[0], default=100, min_val=1, max_val=10000
653
+ )
654
+ since = 0.0
655
+ try:
656
+ since = float(qs.get("since", ["0"])[0])
657
+ except (ValueError, TypeError):
658
+ since = 0.0
659
+ actor = qs.get("actor", [""])[0] or ""
660
+ self._send_json({
661
+ "count": limit,
662
+ "entries": self.mindforge.export_audit(
663
+ limit=limit, since=since, actor=actor),
664
+ })
665
+
666
+ elif path == "/api/tags":
667
+ conn = self.mindforge.storage._get_conn()
668
+ rows = conn.execute(
669
+ "SELECT tags FROM memories WHERE tags IS NOT NULL AND tags != '' AND tags != '[]' LIMIT 10000" # P1-002: 限制查询行数
670
+ ).fetchall()
671
+ tag_counts = {}
672
+ for row in rows:
673
+ try:
674
+ tags = (
675
+ json.loads(row[0])
676
+ if row[0].strip().startswith("[")
677
+ else [t.strip() for t in row[0].split(",") if t.strip()]
678
+ )
679
+ except (json.JSONDecodeError, TypeError):
680
+ continue
681
+ if not isinstance(tags, list):
682
+ continue
683
+ for tag in tags:
684
+ # P2-04:JSON 与逗号分隔两条路径统一消毒后再计数
685
+ clean = _sanitize_tag(tag)
686
+ if not clean:
687
+ continue
688
+ tag_counts[clean] = tag_counts.get(clean, 0) + 1
689
+ self._send_json(
690
+ {
691
+ "tags": sorted(
692
+ tag_counts.items(), key=lambda x: x[1], reverse=True
693
+ )
694
+ }
695
+ )
696
+
697
+ elif path == "/api/search":
698
+ q = qs.get("q", [""])[0]
699
+ if not q:
700
+ self._send_json({"error": "Missing query parameter 'q'"}, 400)
701
+ return
702
+ # v5.4.7 修复 H-1:安全解析参数
703
+ limit = _safe_int(
704
+ qs.get("limit", ["10"])[0], default=10, min_val=1, max_val=1000
705
+ )
706
+ min_relevance = _safe_float(
707
+ qs.get("min_relevance", ["0.3"])[0],
708
+ default=0.3,
709
+ min_val=0.0,
710
+ max_val=1.0,
711
+ )
712
+ categories = qs.get("categories", None)
713
+ # v6.3.2: explain=1/true 时返回每条结果的得分分解
714
+ explain_raw = (qs.get("explain", ["false"])[0] or "false").strip().lower()
715
+ explain = explain_raw in ("1", "true", "yes", "on")
716
+ result = self.mindforge.search(
717
+ query=q,
718
+ max_results=limit,
719
+ min_relevance=min_relevance,
720
+ categories=categories,
721
+ # v5.7.3 安全修复:透传调用者身份,search 内部按隐私等级过滤
722
+ agent_id=self._extract_actor(qs),
723
+ session_id=(qs.get("session", [""])[0] or ""),
724
+ explain=explain,
725
+ )
726
+ chunks = []
727
+ if hasattr(result, "chunks"):
728
+ for chunk in result.chunks:
729
+ item = {
730
+ "id": chunk.memory_id,
731
+ "content": chunk.content,
732
+ "category": chunk.category,
733
+ "relevance_score": chunk.relevance_score,
734
+ "tags": chunk.tags if hasattr(chunk, "tags") else [],
735
+ }
736
+ # v6.3.2: 仅 explain=true 时携带(不请求则响应形状与旧版完全一致)
737
+ if explain:
738
+ item["score_breakdown"] = getattr(
739
+ chunk, "score_breakdown", None
740
+ )
741
+ chunks.append(item)
742
+ self._send_json(
743
+ {
744
+ "query": q,
745
+ "results": chunks,
746
+ "total": len(chunks),
747
+ "approximate": getattr(result, "approximate", False),
748
+ }
749
+ )
750
+
751
+ elif path == "/api/memories":
752
+ # v5.4.7 修复 H-1:安全解析参数
753
+ limit = _safe_int(
754
+ qs.get("limit", ["50"])[0], default=50, min_val=1, max_val=10000
755
+ )
756
+ offset = _safe_int(
757
+ qs.get("offset", ["0"])[0], default=0, min_val=0, max_val=1000000
758
+ )
759
+ # v5.7.0 P2:分类参数消毒
760
+ category = _sanitize_category(
761
+ qs.get("category", [None])[0], default=None
762
+ )
763
+ # F1 修复:列表接口接入隐私过滤(X-Agent-Id 头 / agent 查询参数)
764
+ entries = self.mindforge.list(
765
+ category=category,
766
+ limit=limit,
767
+ offset=offset,
768
+ actor=self._extract_actor(qs),
769
+ session_id=(qs.get("session", [""])[0] or ""),
770
+ )
771
+ memories = []
772
+ for e in entries:
773
+ memories.append(e.to_dict() if hasattr(e, "to_dict") else vars(e))
774
+ self._send_json(
775
+ {
776
+ "memories": memories,
777
+ "total": len(memories),
778
+ "limit": limit,
779
+ "offset": offset,
780
+ }
781
+ )
782
+
783
+ elif path.startswith("/api/memories/"):
784
+ mem_id = self._extract_mem_id(path)
785
+ if not mem_id:
786
+ self._send_json({"error": "Invalid memory ID"}, 400)
787
+ return
788
+ entry = self.mindforge.get(
789
+ mem_id,
790
+ # v5.7.3 安全修复:透传调用者身份,单条读取同样校验隐私等级
791
+ actor=self._extract_actor(qs),
792
+ session_id=(qs.get("session", [""])[0] or ""),
793
+ )
794
+ if entry:
795
+ self._send_json(
796
+ entry.to_dict() if hasattr(entry, "to_dict") else vars(entry)
797
+ )
798
+ else:
799
+ self._send_json({"error": "Memory not found"}, 404)
800
+
801
+ elif path == "/api/export":
802
+ max_export_limit = 5000 # P1-001: 导出上限 5000 条
803
+ # F1 修复:导出同样是记忆读取入口,必须带上调用者身份做隐私过滤
804
+ entries = self.mindforge.list(
805
+ limit=max_export_limit,
806
+ actor=self._extract_actor(qs),
807
+ session_id=(qs.get("session", [""])[0] or ""),
808
+ )
809
+ memories = [
810
+ e.to_dict() if hasattr(e, "to_dict") else vars(e) for e in entries
811
+ ]
812
+ # P3-3: 截断时添加 truncated 标志
813
+ truncated = len(entries) >= max_export_limit
814
+ self._send_json(
815
+ {
816
+ "version": MF_VERSION,
817
+ "total": len(memories),
818
+ "truncated": truncated,
819
+ "max_limit": max_export_limit if truncated else None,
820
+ "memories": memories,
821
+ }
822
+ )
823
+
824
+ elif path == "/api/valid-at":
825
+ # v5.7.6 新增:Bi-temporal as-of 查询(事实有效时间)
826
+ ts_raw = qs.get("ts", [None])[0]
827
+ ts = None
828
+ if ts_raw is not None:
829
+ try:
830
+ ts = float(ts_raw)
831
+ except (TypeError, ValueError):
832
+ self._send_json({"error": "Invalid 'ts' parameter"}, 400)
833
+ return
834
+ limit = _safe_int(
835
+ qs.get("limit", ["50"])[0], default=50, min_val=1, max_val=10000
836
+ )
837
+ category = _sanitize_category(
838
+ qs.get("category", [None])[0], default=None
839
+ )
840
+ entries = self.mindforge.valid_at(
841
+ timestamp=ts,
842
+ category=category,
843
+ limit=limit,
844
+ actor=self._extract_actor(qs),
845
+ session_id=(qs.get("session", [""])[0] or ""),
846
+ )
847
+ memories = [
848
+ e.to_dict() if hasattr(e, "to_dict") else vars(e) for e in entries
849
+ ]
850
+ self._send_json(
851
+ {"as_of": ts, "total": len(memories), "memories": memories}
852
+ )
853
+
854
+
855
+ elif path == "/api/embedding/status":
856
+ # v5.7.8:嵌入向量状态(后端/模型/维度/已嵌入数)
857
+ try:
858
+ st = self.mindforge.get_embedding_status()
859
+ self._send_json(st)
860
+ except Exception as e:
861
+ _log_unhandled_api_error(e)
862
+ self._send_json({"error": "Internal server error"}, 500)
863
+ elif path == "/api/skills/stats":
864
+ self._send_json(self.mindforge.skill_stats())
865
+ elif path == "/api/skills/match":
866
+ query = qs.get("query", [""])[0]
867
+ if not query:
868
+ self._send_json({"error": "Missing 'query' param"}, 400)
869
+ return
870
+ try:
871
+ limit = int(qs.get("limit", ["5"])[0] or 5)
872
+ except ValueError:
873
+ limit = 5
874
+ self._send_json({"query": query,
875
+ "skills": self.mindforge.skill_match(query, limit=limit)})
876
+ elif path == "/api/skills":
877
+ try:
878
+ limit = int(qs.get("limit", ["50"])[0] or 50)
879
+ except ValueError:
880
+ limit = 50
881
+ self._send_json({"skills": self.mindforge.skill_list(limit=limit)})
882
+ elif path == "/api/skills/render":
883
+ name = qs.get("name", [""])[0]
884
+ if not name:
885
+ self._send_json({"error": "Missing 'name' param"}, 400)
886
+ return
887
+ params = {}
888
+ for p in qs.get("param", []):
889
+ if ":" in p:
890
+ k, v = p.split(":", 1)
891
+ params[k] = v
892
+ out = self.mindforge.skill_render(name, **params)
893
+ if out is None:
894
+ self._send_json({"error": f"skill not found: {name}"}, 404)
895
+ return
896
+ self._send_json({"name": name, "rendered": out})
897
+ elif path == "/api/experience/cases":
898
+ try:
899
+ limit = int(qs.get("limit", ["20"])[0] or 20)
900
+ except ValueError:
901
+ limit = 20
902
+ self._send_json({"cases": self.mindforge.skill_cases(
903
+ limit=limit, outcome=qs.get("outcome", [""])[0])})
904
+ elif path == "/api/graph/stats":
905
+ self._send_json(self.mindforge.graph_stats())
906
+ elif path == "/api/graph/related":
907
+ entity = qs.get("entity", [""])[0]
908
+ if not entity:
909
+ self._send_json({"error": "Missing 'entity' param"}, 400)
910
+ return
911
+ try:
912
+ depth = int(qs.get("depth", ["2"])[0] or 2)
913
+ except ValueError:
914
+ depth = 2
915
+ self._send_json({"entity": entity, "depth": depth,
916
+ "related": self.mindforge.graph_related(entity, depth=depth)})
917
+ elif path == "/api/graph/path":
918
+ frm = qs.get("from", [""])[0]
919
+ to = qs.get("to", [""])[0]
920
+ if not frm or not to:
921
+ self._send_json({"error": "Missing 'from'/'to' params"}, 400)
922
+ return
923
+ self._send_json(self.mindforge.graph_path(frm, to) or {"path": None})
924
+ elif path == "/api/connectors":
925
+ from mindforge.modules.connectors import list_connectors
926
+ self._send_json({"connectors": list_connectors()})
927
+ elif path == "/api/timeline":
928
+ # v6.0.0 功能 2:记忆时间线可视化(聚合统计,不含明文)
929
+ try:
930
+ days = int(qs.get("days", ["365"])[0] or 365)
931
+ except (ValueError, TypeError):
932
+ days = 365
933
+ self._send_json(self.mindforge.timeline_visual(
934
+ category=qs.get("category", [""])[0] or None, days=days))
935
+ elif path == "/api/skill-pack/export":
936
+ # v6.0.0 功能 3:导出技能包
937
+ self._send_json(self.mindforge.skill_pack_export())
938
+ elif path == "/api/passport/export":
939
+ # v6.0.0 功能 6:导出记忆护照
940
+ self._send_json(self.mindforge.passport_export())
941
+ elif path == "/api/passport/status":
942
+ # v6.0.0 功能 6:护照状态
943
+ self._send_json(self.mindforge.passport_status())
944
+ elif path == "/api/evolution/stats":
945
+ # v6.0.0 功能 4:演变统计
946
+ self._send_json(self.mindforge.evolution_stats())
947
+ elif path == "/api/evolution/history":
948
+ # v6.0.0 功能 4:演变历史
949
+ mid = qs.get("memory_id", [""])[0]
950
+ if not mid:
951
+ self._send_json({"error": "Missing 'memory_id' param"}, 400)
952
+ return
953
+ try:
954
+ limit = int(qs.get("limit", ["50"])[0] or 50)
955
+ except (ValueError, TypeError):
956
+ limit = 50
957
+ self._send_json({"memory_id": mid,
958
+ "evolutions": self.mindforge.list_evolutions(mid, limit=limit)})
959
+ elif path == "/api/outcome/stats":
960
+ # v6.0.0 功能 5:结果学习统计
961
+ self._send_json(self.mindforge.outcome_stats())
962
+ elif path == "/api/forget/stats":
963
+ # v6.0.0 功能 7:遗忘统计
964
+ self._send_json(self.mindforge.forget_stats())
965
+ elif path == "/api/forget/explain":
966
+ # v6.0.0 功能 7:解释遗忘
967
+ mid = qs.get("memory_id", [""])[0]
968
+ if not mid:
969
+ self._send_json({"error": "Missing 'memory_id' param"}, 400)
970
+ return
971
+ self._send_json(self.mindforge.forget_explain(mid))
972
+ elif path == "/api/follow-ups/due":
973
+ # v6.0.0 功能 8:到期跟进
974
+ try:
975
+ limit = int(qs.get("limit", ["20"])[0] or 20)
976
+ except (ValueError, TypeError):
977
+ limit = 20
978
+ self._send_json({"due": self.mindforge.follow_up_due(limit=limit)})
979
+ elif path == "/api/follow-ups":
980
+ # v6.0.0 功能 8:跟进列表
981
+ try:
982
+ limit = int(qs.get("limit", ["50"])[0] or 50)
983
+ except (ValueError, TypeError):
984
+ limit = 50
985
+ include_resolved = qs.get("include_resolved", ["false"])[0].lower() == "true"
986
+ self._send_json({"follow_ups": self.mindforge.follow_up_list(
987
+ include_resolved=include_resolved, limit=limit)})
988
+
989
+ elif path == "/api/v6/status":
990
+ # v6.0.0 正式版:v6 端口总览(多 Agent 协作 + 6G AI 记忆预览)
991
+ self._send_json(self.mindforge.v6_status())
992
+ elif path == "/api/preview/channels":
993
+ # v6.0.0 正式版:6G 预览通道列表
994
+ include_closed = qs.get("include_closed", ["false"])[0].lower() == "true"
995
+ self._send_json({
996
+ "channels": self.mindforge.memory_preview.list_preview_channels(
997
+ include_closed=include_closed),
998
+ })
999
+ elif path == "/api/preview/index":
1000
+ # v6.0.0 正式版:6G 增量索引统计
1001
+ self._send_json(self.mindforge.memory_preview.incremental_index_stats())
1002
+ elif path == "/api/preview/timeline":
1003
+ # v6.0.0 正式版:6G 单记忆时间线
1004
+ mid = qs.get("memory_id", [""])[0]
1005
+ if not mid:
1006
+ self._send_json({"error": "Missing 'memory_id' param"}, 400)
1007
+ return
1008
+ self._send_json(self.mindforge.memory_preview.timeline(mid))
1009
+
1010
+ elif path == "/":
1011
+ # v5.6.5 P2 #16:根路径不再枚举完整 API 端点清单,
1012
+ # 避免在信息探测阶段向未授权方暴露攻击面。
1013
+ self._send_json(
1014
+ {
1015
+ "name": "MindForge REST API",
1016
+ "version": MF_VERSION,
1017
+ "health": "/api/health",
1018
+ }
1019
+ )
1020
+
1021
+ else:
1022
+ self._send_json({"error": "Not found"}, 404)
1023
+
1024
+ except Exception as e:
1025
+ _log_unhandled_api_error(e)
1026
+ self._send_json({"error": "Internal server error"}, 500)
1027
+
1028
+ def do_POST(self):
1029
+ parsed = urlparse(self.path)
1030
+ path = parsed.path.rstrip("/") or "/"
1031
+
1032
+ # v5.6.1: 写操作也加限流;v5.7.0 P2:写路径走更严配额
1033
+ if not self._check_rate_limit(write=True):
1034
+ return
1035
+
1036
+ if not self._check_auth():
1037
+ return
1038
+
1039
+ try:
1040
+ body = self._read_body()
1041
+ if body is None:
1042
+ self._send_json({"error": "Invalid JSON body"}, 400)
1043
+ return
1044
+ # v5.5.8: 校验 JSON body 必须是 dict(非数组/字符串/数字)
1045
+ if not isinstance(body, dict):
1046
+ self._send_json({"error": "Request body must be a JSON object"}, 400)
1047
+ return
1048
+
1049
+ if path == "/api/memories":
1050
+ content = body.get("content", "")
1051
+ # v5.6.3 安全修复:content 必须是字符串,否则下游 add() 会抛
1052
+ # ValueError 并最终返回 500。在此提前返回 400,给出明确错误。
1053
+ if not isinstance(content, str):
1054
+ self._send_json({"error": "Field 'content' must be a string"}, 400)
1055
+ return
1056
+ if not content:
1057
+ self._send_json({"error": "Missing 'content' field"}, 400)
1058
+ return
1059
+ # v5.7.0 P2:统一参数校验层
1060
+ try:
1061
+ category = _sanitize_category(
1062
+ body.get("category", "general"), default="general"
1063
+ )
1064
+ tags = _validate_tags(body.get("tags", []))
1065
+ importance = _validate_importance(body.get("importance"))
1066
+ starred = _validate_starred(body.get("starred"))
1067
+ source_agent = _validate_source_agent(body.get("source_agent", ""))
1068
+ valid_from = _validate_float_ts(body.get("valid_from"))
1069
+ valid_to = _validate_float_ts(body.get("valid_to"))
1070
+ except ValueError as ve:
1071
+ self._send_json({"error": str(ve)}, 400)
1072
+ return
1073
+ entry = self.mindforge.add(
1074
+ content=content,
1075
+ category=category,
1076
+ tags=tags,
1077
+ importance=importance,
1078
+ starred=starred,
1079
+ source_agent=source_agent,
1080
+ valid_from=valid_from,
1081
+ valid_to=valid_to,
1082
+ )
1083
+ self._send_json(
1084
+ entry.to_dict() if hasattr(entry, "to_dict") else vars(entry), 201
1085
+ )
1086
+
1087
+ elif path == "/api/agent/forget":
1088
+ mid = body.get("id", "")
1089
+ if not isinstance(mid, str) or not mid:
1090
+ self._send_json({"error": "Missing 'id' field"}, 400)
1091
+ return
1092
+ ok = self.mindforge.agent_forget(
1093
+ mid, reason=body.get("reason", ""),
1094
+ actor=body.get("actor", ""),
1095
+ session_id=body.get("session_id", ""))
1096
+ if not ok:
1097
+ self._send_json({"error": "memory not found or not accessible"}, 404)
1098
+ return
1099
+ self._send_json({"status": "forgotten", "id": mid})
1100
+ elif path == "/api/conflicts/reconcile":
1101
+ auto = body.get("auto", True)
1102
+ if not isinstance(auto, bool):
1103
+ self._send_json({"error": "Field 'auto' must be a boolean"}, 400)
1104
+ return
1105
+ memory_ids = body.get("memory_ids")
1106
+ if memory_ids is not None and not isinstance(memory_ids, list):
1107
+ self._send_json({"error": "'memory_ids' must be a list"}, 400)
1108
+ return
1109
+ result = self.mindforge.reconcile_conflicts(
1110
+ memory_ids=memory_ids, auto=auto)
1111
+ self._send_json({"status": "ok", **result})
1112
+ elif path == "/api/experience":
1113
+ task = body.get("task", "")
1114
+ if not isinstance(task, str) or not task.strip():
1115
+ self._send_json({"error": "Field 'task' must be a non-empty string"}, 400)
1116
+ return
1117
+ content = body.get("content", "")
1118
+ result = body.get("result", "")
1119
+ outcome = body.get("outcome", "success")
1120
+ tags = body.get("tags")
1121
+ source_ids = body.get("source_memory_ids")
1122
+ if tags is not None and not isinstance(tags, list):
1123
+ self._send_json({"error": "'tags' must be a list"}, 400)
1124
+ return
1125
+ if source_ids is not None and not isinstance(source_ids, list):
1126
+ self._send_json({"error": "'source_memory_ids' must be a list"}, 400)
1127
+ return
1128
+ try:
1129
+ case = self.mindforge.record_experience(
1130
+ task=task, content=content, result=result, outcome=outcome,
1131
+ duration_seconds=float(body.get("duration_seconds", 0.0) or 0.0),
1132
+ tags=tags, source_memory_ids=source_ids,
1133
+ agent_id=str(body.get("agent_id", "") or ""))
1134
+ except (ValueError, TypeError) as ve:
1135
+ self._send_json({"error": str(ve)}, 400)
1136
+ return
1137
+ self._send_json(case, 201)
1138
+ elif path == "/api/skills/distill":
1139
+ try:
1140
+ mc = int(body.get("min_cluster_size", 2) or 2)
1141
+ except (ValueError, TypeError):
1142
+ mc = 2
1143
+ self._send_json({"status": "ok",
1144
+ **self.mindforge.distill_skills(min_cluster_size=mc)})
1145
+ elif path == "/api/graph/extract":
1146
+ mid = body.get("memory_id", "")
1147
+ if mid is not None and not isinstance(mid, str):
1148
+ self._send_json({"error": "'memory_id' must be a string"}, 400)
1149
+ return
1150
+ try:
1151
+ stats = (self.mindforge.extract_graph(memory_id=mid) if mid
1152
+ else self.mindforge.extract_graph())
1153
+ except ValueError as ve:
1154
+ self._send_json({"error": str(ve)}, 400)
1155
+ return
1156
+ self._send_json({"status": "ok", **stats})
1157
+ elif path == "/api/import":
1158
+ memories = body.get("memories", [])
1159
+ # v5.7.0 P2:批量导入端点额外限流(10 req/60s/IP)
1160
+ if not self._check_rate_limit(heavy=True):
1161
+ return
1162
+ # v5.4.8 安全修复:导入批次大小限制
1163
+ MAX_IMPORT_BATCH = 10000
1164
+ # v5.7.0 P2:memories 必须是列表,否则 400 而非 500
1165
+ if not isinstance(memories, list):
1166
+ self._send_json({"error": "Field 'memories' must be a list"}, 400)
1167
+ return
1168
+ if len(memories) > MAX_IMPORT_BATCH:
1169
+ self._send_json(
1170
+ {"error": f"Too many memories (max {MAX_IMPORT_BATCH})"}, 400
1171
+ )
1172
+ return
1173
+ imported = 0
1174
+ failed = 0
1175
+ for mem in memories:
1176
+ # v5.7.3 修复:非 dict 条目计入 failed,不再整批抛 AttributeError->500
1177
+ if not isinstance(mem, dict):
1178
+ failed += 1
1179
+ continue
1180
+ # v5.7.0 P2:逐条参数校验,非法条目计入 failed
1181
+ try:
1182
+ content = mem.get("content", "")
1183
+ if not isinstance(content, str) or not content.strip():
1184
+ failed += 1
1185
+ continue
1186
+ category = _sanitize_category(
1187
+ mem.get("category", "general"), default="general"
1188
+ )
1189
+ tags = _validate_tags(mem.get("tags", []))
1190
+ importance = _validate_importance(mem.get("importance"))
1191
+ starred = _validate_starred(mem.get("starred"))
1192
+ source_agent = _validate_source_agent(
1193
+ mem.get("source_agent", "")
1194
+ )
1195
+ except ValueError:
1196
+ failed += 1
1197
+ continue
1198
+ try:
1199
+ self.mindforge.add(
1200
+ content=content,
1201
+ category=category,
1202
+ tags=tags,
1203
+ importance=importance,
1204
+ starred=starred,
1205
+ source_agent=source_agent,
1206
+ )
1207
+ imported += 1
1208
+ except Exception:
1209
+ failed += 1
1210
+ self._send_json({"imported": imported, "failed": failed})
1211
+
1212
+ elif path == "/api/import-chat":
1213
+ # v6.0.0 功能 1:一键导入 ChatGPT/Claude 对话
1214
+ data = body.get("data")
1215
+ if not isinstance(data, dict):
1216
+ self._send_json({"error": "Field 'data' must be a JSON object"}, 400)
1217
+ return
1218
+ if not self._check_rate_limit(heavy=True):
1219
+ return
1220
+ self._send_json(self.mindforge.import_chat_history(
1221
+ data, dry_run=bool(body.get("dry_run", False))))
1222
+ elif path == "/api/skill-pack/import":
1223
+ # v6.0.0 功能 3:导入技能包
1224
+ pack = body.get("pack")
1225
+ if not isinstance(pack, dict):
1226
+ self._send_json({"error": "Field 'pack' must be a JSON object"}, 400)
1227
+ return
1228
+ mode = body.get("mode", "merge")
1229
+ if mode not in ("merge", "replace"):
1230
+ self._send_json({"error": "mode must be 'merge' or 'replace'"}, 400)
1231
+ return
1232
+ v = self.mindforge.skill_pack_validate(pack)
1233
+ if not v.get("valid"):
1234
+ self._send_json({"error": "Invalid skill pack",
1235
+ "details": v.get("errors")}, 400)
1236
+ return
1237
+ self._send_json(self.mindforge.skill_pack_import(pack, mode=mode))
1238
+ elif path == "/api/passport/import":
1239
+ # v6.0.0 功能 6:导入记忆护照
1240
+ pp = body.get("passport")
1241
+ if not isinstance(pp, dict):
1242
+ self._send_json({"error": "Field 'passport' must be a JSON object"}, 400)
1243
+ return
1244
+ mode = body.get("mode", "merge")
1245
+ if mode not in ("merge", "replace"):
1246
+ self._send_json({"error": "mode must be 'merge' or 'replace'"}, 400)
1247
+ return
1248
+ self._send_json(self.mindforge.passport_import(pp, mode=mode))
1249
+ elif path == "/api/passport/revoke":
1250
+ # v6.0.0 功能 6:撤销记忆护照
1251
+ pid = body.get("passport_id", "")
1252
+ if not isinstance(pid, str) or not pid:
1253
+ self._send_json({"error": "Missing 'passport_id' field"}, 400)
1254
+ return
1255
+ self._send_json(self.mindforge.passport_revoke(pid))
1256
+ elif path == "/api/evolve":
1257
+ # v6.0.0 功能 4:记忆演变
1258
+ mid = body.get("memory_id", "")
1259
+ if not isinstance(mid, str) or not mid:
1260
+ self._send_json({"error": "Missing 'memory_id' field"}, 400)
1261
+ return
1262
+ self._send_json(self.mindforge.evolve_memory(
1263
+ mid, content=body.get("content"), reason=body.get("reason", "")))
1264
+ elif path == "/api/learn-outcome":
1265
+ # v6.0.0 功能 5:按结果学习
1266
+ mid = body.get("memory_id", "")
1267
+ if not isinstance(mid, str) or not mid:
1268
+ self._send_json({"error": "Missing 'memory_id' field"}, 400)
1269
+ return
1270
+ outcome = body.get("outcome", "success")
1271
+ if outcome not in ("success", "partial", "failure"):
1272
+ self._send_json({"error": "outcome must be success/partial/failure"}, 400)
1273
+ return
1274
+ self._send_json(self.mindforge.learn_from_outcome(
1275
+ mid, outcome=outcome, note=body.get("note", "")))
1276
+ elif path == "/api/forget/run":
1277
+ # v6.0.0 功能 7:有原则遗忘 GC
1278
+ # v6.1.2:days(冷却窗口)终于被实现真正使用,这里一并接通
1279
+ self._send_json(self.mindforge.forget_run(
1280
+ dry_run=bool(body.get("dry_run", False)),
1281
+ days=_safe_int(body.get("days", 30), default=30,
1282
+ min_val=0, max_val=3650)))
1283
+ elif path == "/api/follow-ups":
1284
+ # v6.0.0 功能 8:登记跟进
1285
+ mid = body.get("memory_id", "")
1286
+ if not isinstance(mid, str) or not mid:
1287
+ self._send_json({"error": "Missing 'memory_id' field"}, 400)
1288
+ return
1289
+ self._send_json(self.mindforge.follow_up_create(
1290
+ mid, note=body.get("note", "")))
1291
+ elif path == "/api/follow-ups/resolve":
1292
+ # v6.0.0 功能 8:办结跟进
1293
+ fid = body.get("follow_up_id", "")
1294
+ if not isinstance(fid, str) or not fid:
1295
+ self._send_json({"error": "Missing 'follow_up_id' field"}, 400)
1296
+ return
1297
+ self._send_json(self.mindforge.follow_up_resolve(fid))
1298
+ elif path == "/api/follow-ups/nag":
1299
+ # v6.1.1 功能 8:主动催办到期跟进并生成回执
1300
+ try:
1301
+ interval = float(body.get("interval_hours", 24.0) or 24.0)
1302
+ except (ValueError, TypeError):
1303
+ interval = 24.0
1304
+ try:
1305
+ max_nags = int(body.get("max_nags", 3) or 3)
1306
+ except (ValueError, TypeError):
1307
+ max_nags = 3
1308
+ self._send_json(self.mindforge.follow_up_nag(
1309
+ interval_hours=interval, max_nags=max_nags))
1310
+
1311
+ elif path == "/api/preview/open":
1312
+ # v6.0.0 正式版:开启 6G 预览通道(与稳定版并行,互不污染)
1313
+ cfg = body.get("config")
1314
+ if cfg is not None and not isinstance(cfg, dict):
1315
+ self._send_json({"error": "Field 'config' must be an object"}, 400)
1316
+ return
1317
+ self._send_json(self.mindforge.memory_preview.open_preview_channel(cfg))
1318
+ elif path == "/api/preview/close":
1319
+ # v6.0.0 正式版:关闭并清理 6G 预览通道
1320
+ cid = body.get("channel_id", "")
1321
+ if not isinstance(cid, str) or not cid:
1322
+ self._send_json({"error": "Missing 'channel_id' field"}, 400)
1323
+ return
1324
+ # v6.1.2:purge 参数接通(默认 true = 删除独立库文件;
1325
+ # false 只标记关闭、保留文件供查看)
1326
+ self._send_json(
1327
+ self.mindforge.memory_preview.close_preview_channel(
1328
+ cid, purge=bool(body.get("purge", True))))
1329
+ elif path == "/api/preview/snapshot":
1330
+ # v6.0.0 正式版:把主库记忆快照进预览通道(不修改主库)
1331
+ cid = body.get("channel_id", "")
1332
+ mid = body.get("memory_id", "")
1333
+ if not isinstance(cid, str) or not cid:
1334
+ self._send_json({"error": "Missing 'channel_id' field"}, 400)
1335
+ return
1336
+ if not isinstance(mid, str) or not mid:
1337
+ self._send_json({"error": "Missing 'memory_id' field"}, 400)
1338
+ return
1339
+ self._send_json(self.mindforge.memory_preview.snapshot_memory(cid, mid))
1340
+ elif path == "/api/preview/distill":
1341
+ # v6.0.0 正式版:6G 上下文蒸馏(分层摘要 + Token 压缩)
1342
+ mids = body.get("memory_ids", [])
1343
+ if not isinstance(mids, list) or not mids:
1344
+ self._send_json({"error": "Missing 'memory_ids' array field"}, 400)
1345
+ return
1346
+ try:
1347
+ max_tokens = int(body.get("max_tokens", 2000))
1348
+ except (ValueError, TypeError):
1349
+ max_tokens = 2000
1350
+ self._send_json(self.mindforge.memory_preview.distill_context(
1351
+ mids, max_tokens=max_tokens))
1352
+
1353
+ else:
1354
+ self._send_json({"error": "Not found"}, 404)
1355
+
1356
+ except Exception as e:
1357
+ _log_unhandled_api_error(e)
1358
+ self._send_json({"error": "Internal server error"}, 500)
1359
+
1360
+ def do_PUT(self):
1361
+ parsed = urlparse(self.path)
1362
+ path = parsed.path.rstrip("/") or "/"
1363
+ qs = parse_qs(parsed.query)
1364
+
1365
+ # v5.6.1: 写操作也加限流;v5.7.0 P2:写路径走更严配额
1366
+ if not self._check_rate_limit(write=True):
1367
+ return
1368
+
1369
+ if not self._check_auth():
1370
+ return
1371
+
1372
+ try:
1373
+ body = self._read_body()
1374
+ if body is None:
1375
+ self._send_json({"error": "Invalid JSON body"}, 400)
1376
+ return
1377
+ # v5.5.8: 校验 JSON body 必须是 dict
1378
+ if not isinstance(body, dict):
1379
+ self._send_json({"error": "Request body must be a JSON object"}, 400)
1380
+ return
1381
+
1382
+ if path.startswith("/api/memories/"):
1383
+ mem_id = self._extract_mem_id(path)
1384
+ if not mem_id:
1385
+ self._send_json({"error": "Invalid memory ID"}, 400)
1386
+ return
1387
+ content = body.get("content")
1388
+ if content is not None and not isinstance(content, str):
1389
+ self._send_json({"error": "Field 'content' must be a string"}, 400)
1390
+ return
1391
+ # v5.7.0 P2:统一参数校验层
1392
+ try:
1393
+ category = _sanitize_category(body.get("category"), default=None)
1394
+ tags = (
1395
+ _validate_tags(body.get("tags"))
1396
+ if body.get("tags") is not None
1397
+ else None
1398
+ )
1399
+ importance = _validate_importance(body.get("importance"))
1400
+ starred = _validate_starred(body.get("starred"))
1401
+ valid_from = (
1402
+ _validate_float_ts(body.get("valid_from"))
1403
+ if "valid_from" in body else None
1404
+ )
1405
+ valid_to = (
1406
+ _validate_float_ts(body.get("valid_to"))
1407
+ if "valid_to" in body else None
1408
+ )
1409
+ except ValueError as ve:
1410
+ self._send_json({"error": str(ve)}, 400)
1411
+ return
1412
+ success = self.mindforge.update(
1413
+ memory_id=mem_id,
1414
+ content=content,
1415
+ category=category,
1416
+ tags=tags,
1417
+ importance=importance,
1418
+ starred=starred,
1419
+ valid_from=valid_from,
1420
+ valid_to=valid_to,
1421
+ # v5.7.3 安全修复:写路径透传调用者身份,
1422
+ # update 内部 check_access 按来源校验,杜绝持 Key 越权改删
1423
+ actor=self._extract_actor(qs),
1424
+ session_id=(qs.get("session", [""])[0] or ""),
1425
+ )
1426
+ if success:
1427
+ self._send_json({"status": "updated", "id": mem_id})
1428
+ else:
1429
+ self._send_json({"error": "Update failed or memory not found"}, 404)
1430
+ else:
1431
+ self._send_json({"error": "Not found"}, 404)
1432
+
1433
+ except Exception as e:
1434
+ _log_unhandled_api_error(e)
1435
+ self._send_json({"error": "Internal server error"}, 500)
1436
+
1437
+ def do_DELETE(self):
1438
+ parsed = urlparse(self.path)
1439
+ path = parsed.path.rstrip("/") or "/"
1440
+ qs = parse_qs(parsed.query)
1441
+
1442
+ # v5.6.1: 写操作也加限流;v5.7.0 P2:写路径走更严配额
1443
+ if not self._check_rate_limit(write=True):
1444
+ return
1445
+
1446
+ if not self._check_auth():
1447
+ return
1448
+
1449
+ try:
1450
+ if path.startswith("/api/memories/"):
1451
+ mem_id = self._extract_mem_id(path)
1452
+ if not mem_id:
1453
+ self._send_json({"error": "Invalid memory ID"}, 400)
1454
+ return
1455
+ success = self.mindforge.delete(
1456
+ mem_id,
1457
+ # v5.7.3 安全修复:删除路径透传调用者身份,按来源校验属主,
1458
+ # 杜绝持 Key 越权删除他人 PRIVATE/STRICT 记忆
1459
+ actor=self._extract_actor(qs),
1460
+ session_id=(qs.get("session", [""])[0] or ""),
1461
+ )
1462
+ if success:
1463
+ self._send_json({"status": "deleted", "id": mem_id})
1464
+ else:
1465
+ self._send_json({"error": "Delete failed or memory not found"}, 404)
1466
+ else:
1467
+ self._send_json({"error": "Not found"}, 404)
1468
+
1469
+ except Exception as e:
1470
+ _log_unhandled_api_error(e)
1471
+ self._send_json({"error": "Internal server error"}, 500)
1472
+
1473
+ def log_message(self, format, *args):
1474
+ # v5.7.3 隐私加固:访问日志剥离 query string 与路径中的记忆 ID,
1475
+ # 避免搜索词/记忆 ID 落盘。
1476
+ try:
1477
+ safe_args = list(args)
1478
+ if len(safe_args) >= 2 and isinstance(safe_args[1], str):
1479
+ line = safe_args[1]
1480
+ # 拆分请求行,仅保留 method + path(query 丢弃)
1481
+ parts = line.split()
1482
+ if len(parts) >= 2 and parts[1].startswith("/"):
1483
+ path = urlparse(parts[1]).path or "/"
1484
+ else:
1485
+ path = urlparse(line).path or "/"
1486
+ # 记忆 ID 这类路径参数统一脱敏为占位符
1487
+ path = re.sub(r"/api/memories/[^/?#]+", "/api/memories/{id}", path)
1488
+ safe_args[1] = f"{parts[0]} {path}" if parts else path
1489
+ logger.info("%s - %s", self.address_string(), format % tuple(safe_args))
1490
+ except Exception:
1491
+ logger.info("%s - %s", self.address_string(), format % args)
1492
+
1493
+
1494
+ def start_api_server(
1495
+ mindforge_instance,
1496
+ host="127.0.0.1",
1497
+ port=8080,
1498
+ ssl_certfile: str = "",
1499
+ ssl_keyfile: str = "",
1500
+ ):
1501
+ """启动 REST API 服务器
1502
+
1503
+ Args:
1504
+ mindforge_instance: MindForge 实例
1505
+ host: 绑定地址
1506
+ port: 端口
1507
+ ssl_certfile: TLS 证书文件路径(启用 HTTPS)
1508
+ ssl_keyfile: TLS 私钥文件路径(启用 HTTPS)
1509
+
1510
+ v5.6.1 安全修复:新增 TLS 支持,通过 ssl_certfile / ssl_keyfile 启用 HTTPS,
1511
+ 避免 Bearer Token 和记忆数据明文传输。
1512
+ """
1513
+ MindForgeAPIHandler.mindforge = mindforge_instance
1514
+
1515
+ # v5.6.1 安全修复:非 localhost 绑定且未设置 API Key 时拒绝启动
1516
+ api_key = os.environ.get("MINDFORGE_API_KEY", "")
1517
+ is_localhost = host in ("127.0.0.1", "localhost", "::1", "0:0:0:0:0:0:0:1")
1518
+ # v5.7.2 安全加固:API Key 强度校验(<16 字符给警告)
1519
+ if api_key and len(api_key) < 16:
1520
+ logger.warning(
1521
+ "MINDFORGE_API_KEY 长度仅 %d 字符,建议至少 16 字符("
1522
+ "建议: python -c 'import secrets; print(secrets.token_urlsafe(32))'",
1523
+ len(api_key),
1524
+ )
1525
+ if not is_localhost and not api_key:
1526
+ raise SecurityError(
1527
+ "拒绝在非 localhost 地址上启动无认证的 API。"
1528
+ "请设置 MINDFORGE_API_KEY 环境变量,或仅绑定到 127.0.0.1。"
1529
+ )
1530
+
1531
+ # P0-003: 使用带并发限制的多线程服务器
1532
+ server = BoundedThreadingHTTPServer((host, port), MindForgeAPIHandler)
1533
+
1534
+ # v5.6.1: TLS 支持
1535
+ use_https = bool(ssl_certfile)
1536
+ # v5.7.2 安全加固:TLS 启用时下发 HSTS;纯 HTTP 本地部署不下发(避免误伤)
1537
+ MindForgeAPIHandler._tls_enabled = use_https
1538
+ # HTTPS 反向代理场景:MINDFORGE_TRUST_PROXY=1 显式信任代理后,
1539
+ # 请求带 X-Forwarded-Proto: https 时同样下发 HSTS(见 _send_json)。
1540
+ MindForgeAPIHandler._trust_proxy = os.environ.get(
1541
+ "MINDFORGE_TRUST_PROXY", "0"
1542
+ ).strip().lower() in ("1", "true", "yes", "on")
1543
+ if use_https:
1544
+ import ssl as _ssl
1545
+
1546
+ if not ssl_keyfile:
1547
+ ssl_keyfile = ssl_certfile
1548
+ ctx = _ssl.SSLContext(_ssl.PROTOCOL_TLS_SERVER)
1549
+ ctx.minimum_version = _ssl.TLSVersion.TLSv1_2
1550
+ ctx.load_cert_chain(certfile=ssl_certfile, keyfile=ssl_keyfile)
1551
+ server.socket = ctx.wrap_socket(server.socket, server_side=True)
1552
+ proto = "https"
1553
+ else:
1554
+ proto = "http"
1555
+
1556
+ print(f"MindForge REST API serving on {proto}://{host}:{port}")
1557
+ print(f" Max concurrent threads: {BoundedThreadingHTTPServer.max_threads}")
1558
+ if use_https:
1559
+ print(f" TLS: enabled (cert: {ssl_certfile})")
1560
+ print(" Endpoints: /api/memories, /api/search, /api/stats, /api/health, ...")
1561
+ print(" Press Ctrl+C to stop")
1562
+
1563
+ try:
1564
+ server.serve_forever()
1565
+ except KeyboardInterrupt:
1566
+ print("\nAPI server stopped.")
1567
+ server.server_close()