ltcai 3.6.0 → 4.0.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/README.md +39 -31
  2. package/docs/CHANGELOG.md +64 -0
  3. package/docs/REALTIME_COLLABORATION.md +3 -3
  4. package/docs/V3_FRONTEND.md +9 -8
  5. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  6. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +552 -0
  7. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  8. package/docs/kg-schema.md +51 -53
  9. package/docs/spec-vs-impl.md +10 -10
  10. package/kg_schema.py +2 -520
  11. package/knowledge_graph.py +37 -4629
  12. package/knowledge_graph_api.py +11 -127
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/admin.py +16 -17
  15. package/latticeai/api/agents.py +20 -7
  16. package/latticeai/api/auth.py +46 -15
  17. package/latticeai/api/chat.py +112 -76
  18. package/latticeai/api/health.py +1 -1
  19. package/latticeai/api/hooks.py +1 -1
  20. package/latticeai/api/invitations.py +100 -0
  21. package/latticeai/api/knowledge_graph.py +139 -0
  22. package/latticeai/api/local_files.py +1 -1
  23. package/latticeai/api/mcp.py +23 -11
  24. package/latticeai/api/memory.py +1 -1
  25. package/latticeai/api/models.py +1 -1
  26. package/latticeai/api/network.py +81 -0
  27. package/latticeai/api/plugins.py +3 -6
  28. package/latticeai/api/realtime.py +5 -8
  29. package/latticeai/api/search.py +26 -2
  30. package/latticeai/api/security_dashboard.py +2 -3
  31. package/latticeai/api/setup.py +2 -2
  32. package/latticeai/api/static_routes.py +11 -16
  33. package/latticeai/api/tools.py +3 -0
  34. package/latticeai/api/ui_redirects.py +26 -0
  35. package/latticeai/api/workflow_designer.py +85 -6
  36. package/latticeai/api/workspace.py +93 -57
  37. package/latticeai/app_factory.py +1781 -0
  38. package/latticeai/brain/__init__.py +18 -0
  39. package/latticeai/brain/_kg_common.py +1123 -0
  40. package/latticeai/brain/context.py +213 -0
  41. package/latticeai/brain/conversations.py +236 -0
  42. package/latticeai/brain/discovery.py +1455 -0
  43. package/latticeai/brain/documents.py +218 -0
  44. package/latticeai/brain/identity.py +175 -0
  45. package/latticeai/brain/ingest.py +644 -0
  46. package/latticeai/brain/memory.py +102 -0
  47. package/latticeai/brain/network.py +205 -0
  48. package/latticeai/brain/projection.py +561 -0
  49. package/latticeai/brain/provenance.py +401 -0
  50. package/latticeai/brain/retrieval.py +1316 -0
  51. package/latticeai/brain/schema.py +640 -0
  52. package/latticeai/brain/store.py +216 -0
  53. package/latticeai/brain/write_master.py +225 -0
  54. package/latticeai/core/agent.py +31 -7
  55. package/latticeai/core/audit.py +0 -7
  56. package/latticeai/core/config.py +1 -1
  57. package/latticeai/core/context_builder.py +1 -2
  58. package/latticeai/core/enterprise.py +1 -1
  59. package/latticeai/core/graph_curator.py +2 -2
  60. package/latticeai/core/invitations.py +131 -0
  61. package/latticeai/core/marketplace.py +1 -1
  62. package/latticeai/core/mcp_registry.py +791 -0
  63. package/latticeai/core/model_compat.py +1 -1
  64. package/latticeai/core/model_resolution.py +0 -1
  65. package/latticeai/core/multi_agent.py +238 -4
  66. package/latticeai/core/policy.py +54 -0
  67. package/latticeai/core/realtime.py +65 -44
  68. package/latticeai/core/security.py +1 -1
  69. package/latticeai/core/sessions.py +66 -10
  70. package/latticeai/core/users.py +147 -0
  71. package/latticeai/core/workflow_engine.py +114 -2
  72. package/latticeai/core/workspace_os.py +477 -29
  73. package/latticeai/models/__init__.py +7 -0
  74. package/latticeai/models/router.py +779 -0
  75. package/latticeai/server_app.py +29 -1536
  76. package/latticeai/services/agent_runtime.py +243 -4
  77. package/latticeai/services/app_context.py +75 -14
  78. package/latticeai/services/ingestion.py +47 -0
  79. package/latticeai/services/kg_portability.py +33 -3
  80. package/latticeai/services/memory_service.py +39 -11
  81. package/latticeai/services/model_runtime.py +2 -5
  82. package/latticeai/services/platform_runtime.py +100 -23
  83. package/latticeai/services/run_executor.py +328 -0
  84. package/latticeai/services/search_service.py +17 -8
  85. package/latticeai/services/tool_dispatch.py +12 -2
  86. package/latticeai/services/triggers.py +241 -0
  87. package/latticeai/services/upload_service.py +37 -12
  88. package/latticeai/services/workspace_service.py +55 -16
  89. package/llm_router.py +29 -772
  90. package/ltcai_cli.py +1 -2
  91. package/mcp_registry.py +25 -788
  92. package/p_reinforce.py +124 -14
  93. package/package.json +10 -20
  94. package/scripts/bump_version.py +99 -0
  95. package/scripts/generate_diagrams.py +0 -1
  96. package/scripts/lint_v3.mjs +105 -18
  97. package/scripts/validate_release_artifacts.py +0 -1
  98. package/scripts/wheel_smoke.py +142 -0
  99. package/server.py +11 -7
  100. package/setup_wizard.py +1142 -0
  101. package/static/sw.js +81 -52
  102. package/static/v3/asset-manifest.json +33 -25
  103. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  104. package/static/v3/css/lattice.base.css +1 -1
  105. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  106. package/static/v3/css/lattice.components.css +1 -1
  107. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  108. package/static/v3/css/lattice.shell.css +1 -1
  109. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  110. package/static/v3/css/lattice.tokens.css +3 -0
  111. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  112. package/static/v3/css/lattice.views.css +2 -2
  113. package/static/v3/index.html +3 -4
  114. package/static/v3/js/{app.c541f955.js → app.c5c80c46.js} +1 -1
  115. package/static/v3/js/core/{api.33d6320e.js → api.ba0fbf14.js} +58 -1
  116. package/static/v3/js/core/api.js +57 -0
  117. package/static/v3/js/core/i18n.880e1fec.js +575 -0
  118. package/static/v3/js/core/i18n.js +575 -0
  119. package/static/v3/js/core/routes.37522821.js +101 -0
  120. package/static/v3/js/core/routes.js +71 -63
  121. package/static/v3/js/core/{shell.8c163e0e.js → shell.e3f6bbfa.js} +68 -39
  122. package/static/v3/js/core/shell.js +66 -37
  123. package/static/v3/js/core/{store.34ebd5e6.js → store.7b2aa044.js} +11 -1
  124. package/static/v3/js/core/store.js +11 -1
  125. package/static/v3/js/views/account.eff40715.js +143 -0
  126. package/static/v3/js/views/account.js +143 -0
  127. package/static/v3/js/views/activity.0d271ef9.js +67 -0
  128. package/static/v3/js/views/activity.js +67 -0
  129. package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
  130. package/static/v3/js/views/admin-users.js +4 -6
  131. package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
  132. package/static/v3/js/views/agents.js +35 -12
  133. package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
  134. package/static/v3/js/views/chat.js +23 -0
  135. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  136. package/static/v3/js/views/graph-canvas.js +509 -0
  137. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  138. package/static/v3/js/views/hybrid-search.js +1 -2
  139. package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.4d09c537.js} +60 -44
  140. package/static/v3/js/views/knowledge-graph.js +60 -44
  141. package/static/v3/js/views/network.52a4f181.js +97 -0
  142. package/static/v3/js/views/network.js +97 -0
  143. package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
  144. package/static/v3/js/views/planning.js +26 -5
  145. package/static/v3/js/views/runs.b63b2afa.js +144 -0
  146. package/static/v3/js/views/runs.js +144 -0
  147. package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
  148. package/static/v3/js/views/settings.js +7 -8
  149. package/static/v3/js/views/snapshots.6f5db095.js +135 -0
  150. package/static/v3/js/views/snapshots.js +135 -0
  151. package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
  152. package/static/v3/js/views/workflows.js +87 -2
  153. package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
  154. package/static/v3/js/views/workspace-admin.js +156 -0
  155. package/static/vendor/chart.umd.min.js +20 -0
  156. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  157. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  158. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  159. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  160. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  161. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  162. package/static/vendor/fonts/inter.css +44 -0
  163. package/static/vendor/icons/tabler-icons.min.css +4 -0
  164. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  165. package/static/vendor/marked.min.js +69 -0
  166. package/telegram_bot.py +1 -2
  167. package/tools/commands.py +4 -2
  168. package/tools/computer.py +1 -1
  169. package/tools/documents.py +1 -3
  170. package/tools/filesystem.py +0 -4
  171. package/tools/knowledge.py +1 -3
  172. package/tools/network.py +1 -3
  173. package/codex_telegram_bot.py +0 -195
  174. package/docs/assets/v3.4.0/agent-run.png +0 -0
  175. package/docs/assets/v3.4.0/agents.png +0 -0
  176. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  177. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  178. package/docs/assets/v3.4.0/chat.png +0 -0
  179. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  180. package/docs/assets/v3.4.0/files.png +0 -0
  181. package/docs/assets/v3.4.0/home.png +0 -0
  182. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  183. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  184. package/docs/assets/v3.4.0/local-agent.png +0 -0
  185. package/docs/assets/v3.4.0/memory.png +0 -0
  186. package/docs/assets/v3.4.0/settings.png +0 -0
  187. package/docs/assets/v3.4.0/vision-input.png +0 -0
  188. package/docs/assets/v3.4.0/workflows.png +0 -0
  189. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  190. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  191. package/docs/assets/v3.4.1/local-agent.png +0 -0
  192. package/docs/images/admin-dashboard.png +0 -0
  193. package/docs/images/architecture.png +0 -0
  194. package/docs/images/enterprise.png +0 -0
  195. package/docs/images/graph.png +0 -0
  196. package/docs/images/hero.gif +0 -0
  197. package/docs/images/knowledge-graph.png +0 -0
  198. package/docs/images/lattice-ai-demo.gif +0 -0
  199. package/docs/images/lattice-ai-hero.png +0 -0
  200. package/docs/images/logo.svg +0 -33
  201. package/docs/images/mobile-responsive.png +0 -0
  202. package/docs/images/model-recommendation.png +0 -0
  203. package/docs/images/onboarding.png +0 -0
  204. package/docs/images/organization.png +0 -0
  205. package/docs/images/pipeline.png +0 -0
  206. package/docs/images/screenshot-admin.png +0 -0
  207. package/docs/images/screenshot-chat.png +0 -0
  208. package/docs/images/screenshot-graph.png +0 -0
  209. package/docs/images/skills.png +0 -0
  210. package/docs/images/workspace-dark.png +0 -0
  211. package/docs/images/workspace-light.png +0 -0
  212. package/docs/images/workspace.png +0 -0
  213. package/requirements.txt +0 -16
  214. package/static/account.html +0 -115
  215. package/static/activity.html +0 -73
  216. package/static/admin.html +0 -488
  217. package/static/agents.html +0 -139
  218. package/static/chat.html +0 -844
  219. package/static/css/reference/account.css +0 -439
  220. package/static/css/reference/admin.css +0 -610
  221. package/static/css/reference/base.css +0 -1661
  222. package/static/css/reference/chat.css +0 -4623
  223. package/static/css/reference/graph.css +0 -1016
  224. package/static/css/responsive.css +0 -861
  225. package/static/graph.html +0 -124
  226. package/static/platform.css +0 -104
  227. package/static/plugins.html +0 -136
  228. package/static/scripts/account.js +0 -238
  229. package/static/scripts/admin.js +0 -1614
  230. package/static/scripts/chat.js +0 -5081
  231. package/static/scripts/graph.js +0 -1804
  232. package/static/scripts/platform.js +0 -64
  233. package/static/scripts/ux.js +0 -167
  234. package/static/scripts/workspace.js +0 -948
  235. package/static/v3/js/core/routes.2ce3815a.js +0 -93
  236. package/static/workflows.html +0 -146
  237. package/static/workspace.css +0 -1121
  238. package/static/workspace.html +0 -357
@@ -1,130 +1,14 @@
1
- """Knowledge graph page and API routes."""
1
+ """Deprecation shim — the knowledge graph router moved in v4.
2
2
 
3
- from pathlib import Path
4
- from typing import Any, Callable, Dict, Optional
3
+ The ``/knowledge-graph/*`` data router (and legacy ``/graph`` page routes)
4
+ now live in :mod:`latticeai.api.knowledge_graph`. This root module remains
5
+ importable for the deprecation window and will be removed in a future major
6
+ release.
7
+ """
5
8
 
6
- from fastapi import APIRouter, HTTPException, Request
7
- from fastapi.responses import FileResponse
8
- from pydantic import BaseModel
9
+ from latticeai.api.knowledge_graph import ( # noqa: F401
10
+ KnowledgeGraphIngestRequest,
11
+ create_knowledge_graph_router,
12
+ )
9
13
 
10
-
11
- class KnowledgeGraphIngestRequest(BaseModel):
12
- type: str
13
- content: str = ""
14
- role: Optional[str] = None
15
- title: Optional[str] = None
16
- source: Optional[str] = None
17
- conversation_id: Optional[str] = None
18
- user_email: Optional[str] = None
19
- user_nickname: Optional[str] = None
20
- metadata: Optional[Dict[str, Any]] = None
21
-
22
-
23
- def create_knowledge_graph_router(
24
- *,
25
- get_graph: Callable[[], Any],
26
- require_graph: Callable[[], None],
27
- require_user: Callable[[Request], str],
28
- static_dir: Path,
29
- ) -> APIRouter:
30
- router = APIRouter()
31
-
32
- def graph():
33
- require_graph()
34
- return get_graph()
35
-
36
- @router.get("/graph")
37
- async def knowledge_graph_page(request: Request):
38
- """Serve the interactive knowledge graph canvas UI."""
39
- graph()
40
- require_user(request)
41
- response = FileResponse(static_dir / "graph.html")
42
- response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
43
- response.headers["Pragma"] = "no-cache"
44
- response.headers["Expires"] = "0"
45
- return response
46
-
47
- @router.get("/knowledge-graph")
48
- async def knowledge_graph_legacy_page(request: Request):
49
- """Backward-compatible route for the graph page."""
50
- graph()
51
- require_user(request)
52
- response = FileResponse(static_dir / "graph.html")
53
- response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
54
- response.headers["Pragma"] = "no-cache"
55
- response.headers["Expires"] = "0"
56
- return response
57
-
58
- @router.get("/knowledge-graph/stats")
59
- async def knowledge_graph_stats(request: Request):
60
- require_user(request)
61
- return graph().stats()
62
-
63
- @router.get("/knowledge-graph/schema")
64
- async def knowledge_graph_schema(request: Request):
65
- require_user(request)
66
- stats = graph().stats()
67
- return {
68
- "legacy_schema_version": stats.get("schema_version"),
69
- "v2_schema_available": stats.get("v2_schema_available"),
70
- "v2": stats.get("v2"),
71
- }
72
-
73
- @router.get("/knowledge-graph/graph")
74
- async def knowledge_graph_data(request: Request, limit: int = 300):
75
- require_user(request)
76
- return graph().graph(limit)
77
-
78
- @router.get("/knowledge-graph/documents")
79
- async def knowledge_graph_documents(request: Request, limit: int = 200):
80
- """Ingested documents (uploads + indexed local docs) with index state.
81
-
82
- Backs the Files view so uploaded content is visible end-to-end:
83
- upload → Files → Knowledge Graph → Hybrid Search → Chat.
84
- """
85
- require_user(request)
86
- return graph().list_documents(limit)
87
-
88
- @router.get("/knowledge-graph/search")
89
- async def knowledge_graph_search(q: str, request: Request, limit: int = 30):
90
- require_user(request)
91
- if not q or not q.strip():
92
- return {"query": q, "matches": []}
93
- return graph().search(q, limit)
94
-
95
- @router.get("/knowledge-graph/context")
96
- async def knowledge_graph_context(q: str, request: Request, limit: int = 6):
97
- require_user(request)
98
- return {"query": q, "context": graph().context_for_query(q, limit)}
99
-
100
- @router.get("/knowledge-graph/neighbors/{node_id:path}")
101
- async def knowledge_graph_neighbors(node_id: str, request: Request):
102
- require_user(request)
103
- if not node_id:
104
- raise HTTPException(status_code=400, detail="node_id required")
105
- return graph().neighbors(node_id)
106
-
107
- @router.post("/knowledge-graph/ingest")
108
- async def knowledge_graph_ingest(req: KnowledgeGraphIngestRequest, request: Request):
109
- current_user = require_user(request)
110
- kg = graph()
111
- event_type = (req.type or "").strip().lower()
112
- if event_type not in {"message", "ai_response", "note"}:
113
- raise HTTPException(status_code=400, detail="지원하는 type: message, ai_response, note")
114
- role = req.role or ("assistant" if event_type == "ai_response" else "user")
115
- return kg.ingest_message(
116
- role,
117
- req.content,
118
- user_email=req.user_email or current_user,
119
- user_nickname=req.user_nickname,
120
- source=req.source or "mcp",
121
- conversation_id=req.conversation_id,
122
- raw={
123
- "type": req.type,
124
- "title": req.title,
125
- "content": req.content,
126
- "metadata": req.metadata or {},
127
- },
128
- )
129
-
130
- return router
14
+ __all__ = ["KnowledgeGraphIngestRequest", "create_knowledge_graph_router"]
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "3.6.0"
3
+ __version__ = "4.0.1"
@@ -2,7 +2,7 @@
2
2
 
3
3
  import logging
4
4
  from collections import defaultdict
5
- from typing import Any, Callable, Dict, List, Optional
5
+ from typing import Callable, Dict, List, Optional
6
6
 
7
7
  from fastapi import APIRouter, HTTPException, Request
8
8
  from pydantic import BaseModel
@@ -55,6 +55,7 @@ def create_admin_router(
55
55
  invite_code: str,
56
56
  invite_gate_enabled: bool,
57
57
  default_port: int,
58
+ policy_matrix: Optional[Callable[[], List[Dict[str, object]]]] = None,
58
59
  ) -> APIRouter:
59
60
  router = APIRouter()
60
61
 
@@ -109,16 +110,6 @@ def create_admin_router(
109
110
  report["graph"] = {"error": str(e)}
110
111
  return report
111
112
 
112
- # Canonical RBAC capability map — which product areas each role can reach.
113
- # This is the real access policy the app enforces, not sample data.
114
- _ROLE_CAPS = {
115
- "owner": ["all"],
116
- "admin": ["users", "policies", "audit", "security", "chat", "search", "files", "pipeline"],
117
- "member": ["chat", "search", "files", "pipeline"],
118
- "user": ["chat", "search", "files", "pipeline"],
119
- "viewer": ["chat", "search"],
120
- }
121
-
122
113
  @router.get("/admin/roles")
123
114
  async def admin_roles(request: Request):
124
115
  _, users = require_admin(request)
@@ -126,13 +117,21 @@ def create_admin_router(
126
117
  for email, user in users.items():
127
118
  role = (get_user_role(email, users) or "user").lower()
128
119
  counts[role] += 1
129
- # Always surface the two RBAC tiers the app actually distinguishes so the
130
- # matrix is meaningful even before extra users exist.
131
- for base in ("admin", "user"):
132
- counts.setdefault(base, counts.get(base, 0))
120
+ matrix = policy_matrix() if policy_matrix else [
121
+ {"role": "admin", "caps": ["all"]},
122
+ {"role": "user", "caps": ["chat", "search"]},
123
+ ]
124
+ policy_caps = {
125
+ str(item.get("role") or "user"): list(item.get("caps") or [])
126
+ for item in matrix
127
+ if isinstance(item, dict)
128
+ }
129
+ for role in policy_caps:
130
+ counts.setdefault(role, 0)
131
+ order = {"owner": 0, "admin": 1, "member": 2, "user": 3, "viewer": 4}
133
132
  roles = [
134
- {"role": role, "members": counts.get(role, 0), "caps": _ROLE_CAPS.get(role, ["chat", "search"])}
135
- for role in sorted(counts, key=lambda r: (r != "owner", r != "admin", r))
133
+ {"role": role, "members": counts.get(role, 0), "caps": policy_caps.get(role, [])}
134
+ for role in sorted(counts, key=lambda r: (order.get(r, 99), r))
136
135
  ]
137
136
  return {"roles": roles}
138
137
 
@@ -14,6 +14,8 @@ from typing import Any, Callable, Dict, List, Optional
14
14
  from fastapi import APIRouter, HTTPException, Request
15
15
  from pydantic import BaseModel
16
16
 
17
+ from latticeai.api.ui_redirects import app_redirect
18
+
17
19
 
18
20
  class AgentRunRequest(BaseModel):
19
21
  goal: str
@@ -41,6 +43,7 @@ def create_agents_router(
41
43
  ui_file_response: Optional[Callable[[Path], Any]] = None,
42
44
  static_dir: Optional[Path] = None,
43
45
  agent_runtime: Any = None,
46
+ run_executor: Any = None,
44
47
  ) -> APIRouter:
45
48
  from latticeai.core.multi_agent import AGENT_ROLES, ROLE_AGENT_IDS
46
49
  from latticeai.services.agent_runtime import AgentRuntime
@@ -91,12 +94,7 @@ def create_agents_router(
91
94
  @router.get("/agents")
92
95
  async def agents_page(request: Request):
93
96
  require_user(request)
94
- if ui_file_response is None or static_dir is None:
95
- raise HTTPException(status_code=404, detail="Multi-Agent UI not available.")
96
- page = static_dir / "agents.html"
97
- if not page.exists():
98
- raise HTTPException(status_code=404, detail="Multi-Agent UI not found.")
99
- return ui_file_response(page)
97
+ return app_redirect("agents", request)
100
98
 
101
99
  @router.get("/agents/api/roles")
102
100
  async def agent_roles(request: Request):
@@ -163,7 +161,22 @@ def create_agents_router(
163
161
  current_user = require_user(request)
164
162
  scope = gate_write(request)
165
163
  try:
166
- return runtime.start(
164
+ if run_executor is not None:
165
+ return await run_executor.start_agent(
166
+ req.goal,
167
+ user_email=current_user or None,
168
+ scope=scope,
169
+ roles=req.roles or None,
170
+ inputs=req.inputs,
171
+ max_retries=req.max_retries,
172
+ )
173
+ # Worker thread: an LLM-backed run blocks on model generation and
174
+ # must not stall the event loop (the sync model bridge also
175
+ # requires a loop-free thread).
176
+ import asyncio
177
+
178
+ return await asyncio.to_thread(
179
+ runtime.start,
167
180
  req.goal,
168
181
  user_email=current_user or None,
169
182
  scope=scope,
@@ -1,5 +1,7 @@
1
1
  """Authentication API router: register, login, logout, SSO, profile."""
2
2
 
3
+ import base64
4
+ import hashlib
3
5
  import logging
4
6
  import secrets
5
7
  import time
@@ -10,6 +12,7 @@ from fastapi import APIRouter, HTTPException, Request
10
12
  from fastapi.responses import JSONResponse, RedirectResponse
11
13
  from pydantic import BaseModel
12
14
 
15
+ from latticeai.core.users import normalize_email
13
16
  from latticeai.core.oidc import (
14
17
  OIDCValidationError,
15
18
  fetch_jwks as _default_fetch_jwks,
@@ -64,27 +67,42 @@ def create_auth_router(
64
67
  open_registration: bool,
65
68
  session_ttl: int,
66
69
  require_auth: bool = True,
70
+ ensure_identity: Optional[Callable[[str, Dict], None]] = None,
67
71
  verify_id_token: Callable[..., Dict] = _default_verify_id_token,
68
72
  fetch_jwks: Callable[[str], Awaitable[Dict]] = _default_fetch_jwks,
69
73
  ) -> APIRouter:
70
74
  router = APIRouter()
71
75
 
76
+ def _enforce_password_policy(password: str) -> None:
77
+ # Real policy (v4): length >= 8 with letters AND digits. A 4-char
78
+ # minimum was not a policy.
79
+ pw = str(password or "")
80
+ if len(pw) < 8 or not any(c.isalpha() for c in pw) or not any(c.isdigit() for c in pw):
81
+ raise HTTPException(
82
+ status_code=400,
83
+ detail="비밀번호는 8자 이상이며 영문자와 숫자를 모두 포함해야 합니다.",
84
+ )
85
+
72
86
  @router.post("/register")
73
87
  async def register(req: UserRegister, request: Request):
74
88
  check_ip_rate_limit(client_ip(request), "register", max_calls=5, window_secs=3600)
75
89
  if not open_registration:
76
90
  raise HTTPException(status_code=403, detail="회원가입이 비활성화되어 있습니다. 관리자에게 문의하세요.")
91
+ _enforce_password_policy(req.password)
92
+ email = normalize_email(req.email)
77
93
  users = load_users()
78
- if req.email in users:
94
+ if email in users:
79
95
  raise HTTPException(status_code=400, detail="이미 존재하는 이메일입니다.")
80
96
  role = "admin" if not users else "user"
81
- users[req.email] = {
97
+ users[email] = {
82
98
  "password": hash_password(req.password),
83
99
  "name": req.name,
84
100
  "nickname": req.nickname,
85
101
  "role": role,
86
102
  "disabled": False,
87
103
  }
104
+ if ensure_identity is not None:
105
+ ensure_identity(email, users[email])
88
106
  save_users(users)
89
107
  msg = "회원가입 성공! 첫 번째 사용자로 관리자 권한이 부여되었습니다." if role == "admin" else "회원가입 성공!"
90
108
  return {"status": "ok", "message": msg, "role": role}
@@ -92,19 +110,20 @@ def create_auth_router(
92
110
  @router.post("/login")
93
111
  async def login(req: UserLogin, request: Request):
94
112
  check_ip_rate_limit(client_ip(request), "login", max_calls=10, window_secs=300)
113
+ email = normalize_email(req.email)
95
114
  users = load_users()
96
- user = users.get(req.email)
97
- if not user or not verify_and_migrate(req.email, req.password, user.get("password", ""), users):
115
+ user = users.get(email)
116
+ if not user or not verify_and_migrate(email, req.password, user.get("password", ""), users):
98
117
  raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 틀렸습니다.")
99
118
  if user.get("disabled"):
100
119
  raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
101
- role = get_user_role(req.email, users)
102
- token = create_session(req.email)
120
+ role = get_user_role(email, users)
121
+ token = create_session(email)
103
122
  response = JSONResponse(content={
104
123
  "status": "ok",
105
124
  "nickname": user["nickname"],
106
125
  "name": user["name"],
107
- "email": req.email,
126
+ "email": email,
108
127
  "role": role,
109
128
  "is_admin": role == "admin",
110
129
  })
@@ -123,7 +142,15 @@ def create_auth_router(
123
142
  raise HTTPException(status_code=503, detail="SSO가 설정되지 않았습니다.")
124
143
  state = secrets.token_urlsafe(16)
125
144
  nonce = secrets.token_urlsafe(16)
126
- _sso_states[state] = (time.time(), nonce)
145
+ # PKCE (S256): bind the token exchange to this login, so an
146
+ # intercepted authorization code is useless without the verifier.
147
+ code_verifier = secrets.token_urlsafe(48)
148
+ code_challenge = (
149
+ base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest())
150
+ .rstrip(b"=")
151
+ .decode("ascii")
152
+ )
153
+ _sso_states[state] = (time.time(), nonce, code_verifier)
127
154
  params = urlencode({
128
155
  "client_id": settings["client_id"],
129
156
  "response_type": "code",
@@ -131,6 +158,8 @@ def create_auth_router(
131
158
  "scope": settings.get("scopes") or "openid email profile",
132
159
  "state": state,
133
160
  "nonce": nonce,
161
+ "code_challenge": code_challenge,
162
+ "code_challenge_method": "S256",
134
163
  })
135
164
  return RedirectResponse(f"{discovery['authorization_endpoint']}?{params}")
136
165
 
@@ -141,7 +170,7 @@ def create_auth_router(
141
170
  entry = _sso_states.pop(state, None)
142
171
  if entry is None or time.time() - entry[0] > 300:
143
172
  raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
144
- _, nonce = entry
173
+ _, nonce, code_verifier = entry
145
174
  settings = get_sso_settings()
146
175
  discovery = await get_sso_discovery()
147
176
  if not settings.get("enabled") or not discovery:
@@ -154,6 +183,7 @@ def create_auth_router(
154
183
  "redirect_uri": settings["redirect_uri"],
155
184
  "client_id": settings["client_id"],
156
185
  "client_secret": settings["client_secret"],
186
+ "code_verifier": code_verifier,
157
187
  }, headers={"Accept": "application/json"}, timeout=15)
158
188
  tokens = r.json()
159
189
  id_token = tokens.get("id_token")
@@ -178,7 +208,7 @@ def create_auth_router(
178
208
  except Exception as exc: # discovery/JWKS fetch failure → fail closed
179
209
  logging.warning("SSO token validation error: %s", exc)
180
210
  raise HTTPException(status_code=502, detail="SSO 공급자 검증에 실패했습니다.")
181
- email = payload.get("email") or payload.get("preferred_username") or payload.get("upn") or ""
211
+ email = normalize_email(payload.get("email") or payload.get("preferred_username") or payload.get("upn") or "")
182
212
  if not email:
183
213
  raise HTTPException(status_code=400, detail="이메일을 확인할 수 없습니다.")
184
214
  users = load_users()
@@ -192,6 +222,8 @@ def create_auth_router(
192
222
  "disabled": False,
193
223
  "sso": True,
194
224
  }
225
+ if ensure_identity is not None:
226
+ ensure_identity(email, users[email])
195
227
  save_users(users)
196
228
  if users[email].get("disabled"):
197
229
  raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
@@ -211,11 +243,10 @@ def create_auth_router(
211
243
 
212
244
  @router.post("/account/change-password")
213
245
  async def change_password(req: ChangePasswordRequest, request: Request):
214
- email = require_user(request)
246
+ email = normalize_email(require_user(request))
215
247
  if not email:
216
248
  raise HTTPException(status_code=401, detail="인증이 필요합니다.")
217
- if len(req.new_password) < 4:
218
- raise HTTPException(status_code=400, detail="새 비밀번호는 4자 이상이어야 합니다.")
249
+ _enforce_password_policy(req.new_password)
219
250
  users = load_users()
220
251
  user = users.get(email)
221
252
  if not user:
@@ -228,7 +259,7 @@ def create_auth_router(
228
259
 
229
260
  @router.patch("/account/profile")
230
261
  async def update_profile(req: UpdateProfileRequest, request: Request):
231
- email = require_user(request)
262
+ email = normalize_email(require_user(request))
232
263
  if not email:
233
264
  raise HTTPException(status_code=401, detail="인증이 필요합니다.")
234
265
  if req.name is not None and not req.name.strip():
@@ -248,7 +279,7 @@ def create_auth_router(
248
279
 
249
280
  @router.get("/account/profile")
250
281
  async def get_profile(request: Request):
251
- email = require_user(request)
282
+ email = normalize_email(require_user(request))
252
283
  if not email:
253
284
  if require_auth:
254
285
  raise HTTPException(status_code=401, detail="인증이 필요합니다.")