ltcai 8.9.0 → 9.1.0

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 (208) hide show
  1. package/README.md +81 -58
  2. package/auto_setup.py +7 -904
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +221 -238
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  13. package/docs/TRUST_MODEL.md +5 -2
  14. package/docs/WHY_LATTICE.md +15 -10
  15. package/docs/WORKFLOW_DESIGNER.md +22 -0
  16. package/docs/kg-schema.md +13 -2
  17. package/docs/mcp-tools.md +17 -6
  18. package/docs/privacy.md +19 -3
  19. package/docs/public-deploy.md +32 -3
  20. package/docs/security-model.md +46 -11
  21. package/lattice_brain/__init__.py +1 -1
  22. package/lattice_brain/archive.py +4 -14
  23. package/lattice_brain/context.py +66 -9
  24. package/lattice_brain/embeddings.py +38 -2
  25. package/lattice_brain/graph/_kg_common.py +27 -462
  26. package/lattice_brain/graph/_kg_constants.py +243 -0
  27. package/lattice_brain/graph/_kg_fsutil.py +293 -0
  28. package/lattice_brain/graph/discovery.py +0 -948
  29. package/lattice_brain/graph/discovery_index.py +1126 -0
  30. package/lattice_brain/graph/documents.py +44 -9
  31. package/lattice_brain/graph/ingest.py +47 -20
  32. package/lattice_brain/graph/provenance.py +13 -6
  33. package/lattice_brain/graph/retrieval.py +141 -610
  34. package/lattice_brain/graph/retrieval_docgen.py +243 -0
  35. package/lattice_brain/graph/retrieval_vector.py +460 -0
  36. package/lattice_brain/graph/store.py +6 -0
  37. package/lattice_brain/ingestion.py +69 -4
  38. package/lattice_brain/portability.py +29 -23
  39. package/lattice_brain/quality.py +98 -4
  40. package/lattice_brain/runtime/agent_runtime.py +169 -7
  41. package/lattice_brain/runtime/hooks.py +30 -13
  42. package/lattice_brain/runtime/multi_agent.py +27 -8
  43. package/lattice_brain/runtime/statuses.py +10 -0
  44. package/lattice_brain/utils.py +46 -0
  45. package/lattice_brain/workflow.py +1 -5
  46. package/latticeai/__init__.py +1 -1
  47. package/latticeai/api/admin.py +1 -1
  48. package/latticeai/api/agent_registry.py +10 -8
  49. package/latticeai/api/agents.py +22 -3
  50. package/latticeai/api/auth.py +78 -16
  51. package/latticeai/api/browser.py +303 -34
  52. package/latticeai/api/chat.py +355 -952
  53. package/latticeai/api/chat_agent_http.py +356 -0
  54. package/latticeai/api/chat_contracts.py +54 -0
  55. package/latticeai/api/chat_documents.py +256 -0
  56. package/latticeai/api/chat_helpers.py +250 -0
  57. package/latticeai/api/chat_history.py +99 -0
  58. package/latticeai/api/chat_intents.py +302 -0
  59. package/latticeai/api/chat_stream.py +115 -0
  60. package/latticeai/api/computer_use.py +211 -40
  61. package/latticeai/api/health.py +9 -3
  62. package/latticeai/api/hooks.py +17 -6
  63. package/latticeai/api/knowledge_graph.py +78 -14
  64. package/latticeai/api/local_files.py +7 -2
  65. package/latticeai/api/marketplace.py +11 -0
  66. package/latticeai/api/mcp.py +102 -23
  67. package/latticeai/api/models.py +72 -33
  68. package/latticeai/api/network.py +5 -5
  69. package/latticeai/api/permissions.py +70 -29
  70. package/latticeai/api/plugins.py +21 -7
  71. package/latticeai/api/portability.py +5 -5
  72. package/latticeai/api/realtime.py +33 -5
  73. package/latticeai/api/setup.py +2 -2
  74. package/latticeai/api/static_routes.py +123 -24
  75. package/latticeai/api/tools.py +97 -14
  76. package/latticeai/api/workflow_designer.py +37 -0
  77. package/latticeai/api/workspace.py +2 -1
  78. package/latticeai/app_factory.py +185 -405
  79. package/latticeai/core/agent.py +19 -9
  80. package/latticeai/core/agent_registry.py +1 -5
  81. package/latticeai/core/config.py +23 -3
  82. package/latticeai/core/context_builder.py +21 -3
  83. package/latticeai/core/invitations.py +1 -4
  84. package/latticeai/core/io_utils.py +45 -0
  85. package/latticeai/core/legacy_compatibility.py +24 -3
  86. package/latticeai/core/local_embeddings.py +2 -4
  87. package/latticeai/core/marketplace.py +33 -2
  88. package/latticeai/core/mcp_catalog.py +450 -0
  89. package/latticeai/core/mcp_registry.py +2 -441
  90. package/latticeai/core/plugins.py +15 -0
  91. package/latticeai/core/policy.py +1 -1
  92. package/latticeai/core/realtime.py +38 -15
  93. package/latticeai/core/sessions.py +7 -2
  94. package/latticeai/core/timeutil.py +10 -0
  95. package/latticeai/core/tool_registry.py +90 -18
  96. package/latticeai/core/users.py +5 -14
  97. package/latticeai/core/workspace_graph_trace.py +31 -5
  98. package/latticeai/core/workspace_memory.py +3 -1
  99. package/latticeai/core/workspace_os.py +18 -7
  100. package/latticeai/core/workspace_os_utils.py +2 -21
  101. package/latticeai/core/workspace_permissions.py +2 -1
  102. package/latticeai/core/workspace_plugins.py +1 -1
  103. package/latticeai/core/workspace_runs.py +14 -5
  104. package/latticeai/core/workspace_skills.py +1 -1
  105. package/latticeai/core/workspace_snapshots.py +2 -1
  106. package/latticeai/core/workspace_timeline.py +2 -1
  107. package/latticeai/integrations/telegram_bot.py +96 -40
  108. package/latticeai/models/model_providers.py +111 -0
  109. package/latticeai/models/router.py +189 -173
  110. package/latticeai/runtime/access_runtime.py +62 -4
  111. package/latticeai/runtime/audit_runtime.py +27 -16
  112. package/latticeai/runtime/automation_runtime.py +22 -7
  113. package/latticeai/runtime/brain_runtime.py +19 -7
  114. package/latticeai/runtime/chat_wiring.py +2 -0
  115. package/latticeai/runtime/config_runtime.py +58 -26
  116. package/latticeai/runtime/context_runtime.py +16 -4
  117. package/latticeai/runtime/history_runtime.py +163 -0
  118. package/latticeai/runtime/hooks_runtime.py +1 -1
  119. package/latticeai/runtime/model_wiring.py +33 -5
  120. package/latticeai/runtime/namespace_runtime.py +163 -0
  121. package/latticeai/runtime/network_config_runtime.py +56 -0
  122. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  123. package/latticeai/runtime/review_wiring.py +1 -1
  124. package/latticeai/runtime/router_registration.py +34 -1
  125. package/latticeai/runtime/security_runtime.py +110 -13
  126. package/latticeai/runtime/sso_config_runtime.py +128 -0
  127. package/latticeai/runtime/stages.py +27 -0
  128. package/latticeai/runtime/user_key_runtime.py +106 -0
  129. package/latticeai/server_app.py +5 -1
  130. package/latticeai/services/architecture_readiness.py +74 -5
  131. package/latticeai/services/brain_automation.py +3 -26
  132. package/latticeai/services/chat_service.py +205 -15
  133. package/latticeai/services/local_knowledge.py +423 -0
  134. package/latticeai/services/memory_service.py +268 -21
  135. package/latticeai/services/model_engines.py +48 -33
  136. package/latticeai/services/model_errors.py +17 -0
  137. package/latticeai/services/model_loading.py +28 -25
  138. package/latticeai/services/model_runtime.py +228 -162
  139. package/latticeai/services/p_reinforce.py +22 -3
  140. package/latticeai/services/platform_runtime.py +92 -24
  141. package/latticeai/services/product_readiness.py +19 -17
  142. package/latticeai/services/review_queue.py +76 -11
  143. package/latticeai/services/router_context.py +1 -0
  144. package/latticeai/services/run_executor.py +25 -9
  145. package/latticeai/services/search_service.py +203 -28
  146. package/latticeai/services/setup_detection.py +80 -0
  147. package/latticeai/services/tool_dispatch.py +28 -5
  148. package/latticeai/services/triggers.py +53 -4
  149. package/latticeai/services/upload_service.py +13 -2
  150. package/latticeai/setup/__init__.py +25 -0
  151. package/latticeai/setup/auto_setup.py +857 -0
  152. package/latticeai/setup/wizard.py +1264 -0
  153. package/latticeai/tools/__init__.py +278 -0
  154. package/{tools → latticeai/tools}/commands.py +67 -7
  155. package/{tools → latticeai/tools}/computer.py +1 -1
  156. package/{tools → latticeai/tools}/documents.py +1 -1
  157. package/{tools → latticeai/tools}/filesystem.py +3 -3
  158. package/latticeai/tools/knowledge.py +178 -0
  159. package/{tools → latticeai/tools}/local_files.py +7 -1
  160. package/{tools → latticeai/tools}/network.py +0 -1
  161. package/local_knowledge_api.py +4 -342
  162. package/package.json +22 -2
  163. package/scripts/brain_quality_eval.py +3 -1
  164. package/scripts/bump_version.py +3 -0
  165. package/scripts/capture_release_evidence.mjs +180 -0
  166. package/scripts/check_current_release_docs.mjs +142 -0
  167. package/scripts/check_i18n_literals.mjs +107 -31
  168. package/scripts/check_openapi_drift.mjs +56 -0
  169. package/scripts/export_openapi.py +67 -7
  170. package/scripts/i18n_literal_allowlist.json +22 -24
  171. package/scripts/run_integration_tests.mjs +72 -10
  172. package/scripts/validate_release_artifacts.py +49 -7
  173. package/scripts/wheel_smoke.py +5 -0
  174. package/setup_wizard.py +3 -1304
  175. package/src-tauri/Cargo.lock +1 -1
  176. package/src-tauri/Cargo.toml +1 -1
  177. package/src-tauri/tauri.conf.json +1 -1
  178. package/static/app/asset-manifest.json +11 -11
  179. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  180. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  181. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  182. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  183. package/static/app/assets/System-80lHW0Ux.js +1 -0
  184. package/static/app/assets/index-BiMofBTM.js +17 -0
  185. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  186. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  187. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  188. package/static/app/index.html +2 -2
  189. package/static/css/tokens.css +4 -2
  190. package/static/sw.js +1 -1
  191. package/tools/__init__.py +21 -269
  192. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  193. package/latticeai/runtime/app_context_runtime.py +0 -13
  194. package/latticeai/runtime/sso_runtime.py +0 -52
  195. package/latticeai/runtime/tail_wiring.py +0 -21
  196. package/scripts/com.pts.claudecode.discord.plist +0 -31
  197. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  198. package/scripts/start-pts-claudecode-discord.sh +0 -51
  199. package/static/app/assets/Act-fZokUnC0.js +0 -1
  200. package/static/app/assets/Brain-DtyuWubr.js +0 -321
  201. package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
  202. package/static/app/assets/Library-C9kyFkSt.js +0 -1
  203. package/static/app/assets/System-VbChmX7r.js +0 -1
  204. package/static/app/assets/index-DCh5AoXt.css +0 -2
  205. package/static/app/assets/index-DPdcPoF0.js +0 -17
  206. package/static/app/assets/primitives-DFeanEV6.js +0 -1
  207. package/static/app/assets/textarea-CD8UNKIy.js +0 -1
  208. package/tools/knowledge.py +0 -95
@@ -2,7 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
6
+ import hmac
7
+ import secrets
5
8
  import subprocess
9
+ import time
6
10
  from dataclasses import dataclass
7
11
  from pathlib import Path
8
12
  from typing import Callable, Optional
@@ -26,6 +30,66 @@ PRODUCTION_CSP = (
26
30
  "frame-ancestors 'none'"
27
31
  )
28
32
 
33
+ INVITE_COOKIE_NAME = "lattice_invite"
34
+ INVITE_COOKIE_TTL_SECONDS = 60 * 60 * 24 * 7
35
+
36
+
37
+ def _sign_invite_cookie(secret: str, *, now: Optional[int] = None) -> str:
38
+ """Create an expiring, nonce-bearing invite-gate capability cookie."""
39
+
40
+ issued_at = int(time.time() if now is None else now)
41
+ expires_at = issued_at + INVITE_COOKIE_TTL_SECONDS
42
+ nonce = secrets.token_urlsafe(24)
43
+ payload = f"{expires_at}.{nonce}"
44
+ signature = hmac.new(
45
+ str(secret).encode("utf-8"),
46
+ payload.encode("utf-8"),
47
+ hashlib.sha256,
48
+ ).hexdigest()
49
+ return f"v1.{payload}.{signature}"
50
+
51
+
52
+ def _verify_invite_cookie(
53
+ value: Optional[str],
54
+ secret: str,
55
+ *,
56
+ now: Optional[int] = None,
57
+ ) -> bool:
58
+ """Verify version, expiry and HMAC without trusting client claims."""
59
+
60
+ if not value or not secret:
61
+ return False
62
+ try:
63
+ version, raw_expiry, nonce, supplied_signature = value.split(".", 3)
64
+ expires_at = int(raw_expiry)
65
+ except (TypeError, ValueError):
66
+ return False
67
+ if version != "v1" or not nonce or expires_at <= int(time.time() if now is None else now):
68
+ return False
69
+ payload = f"{expires_at}.{nonce}"
70
+ expected_signature = hmac.new(
71
+ str(secret).encode("utf-8"),
72
+ payload.encode("utf-8"),
73
+ hashlib.sha256,
74
+ ).hexdigest()
75
+ return hmac.compare_digest(supplied_signature, expected_signature)
76
+
77
+
78
+ def _invite_denied_response() -> HTMLResponse:
79
+ return HTMLResponse(
80
+ content="""
81
+ <body style="background:#0f1115; color:white; display:flex; flex-direction:column; align-items:center; justify-content:center; height:100vh; font-family:sans-serif;">
82
+ <div style="background:#16191f; padding:40px; border-radius:24px; border:1px solid rgba(255,255,255,0.1); text-align:center; box-shadow: 0 20px 40px rgba(0,0,0,0.5);">
83
+ <div style="font-size:48px; margin-bottom:20px;">🔒</div>
84
+ <h1 style="color:#378ADD; margin:0; font-size:24px;">Invitation Required</h1>
85
+ <p style="color:#94a3b8; margin:20px 0; line-height:1.6;">이 서비스는 비공개로 운영되고 있습니다.<br>선생님께 받은 <b>초대용 전용 링크</b>를 통해 접속해 주세요.</p>
86
+ <div style="margin-top:30px; padding-top:20px; border-top:1px solid rgba(255,255,255,0.05); font-size:11px; color:rgba(255,255,255,0.2); letter-spacing:1px;">LATTICE AI</div>
87
+ </div>
88
+ </body>
89
+ """,
90
+ status_code=403,
91
+ )
92
+
29
93
 
30
94
  def ui_file_response(path: Path) -> FileResponse:
31
95
  response = FileResponse(path)
@@ -40,6 +104,7 @@ class StaticRoutesBundle:
40
104
  router: APIRouter
41
105
  ui_file_response: Callable[[Path], FileResponse]
42
106
  local_sysinfo: Callable[[Request], object]
107
+ invite_authorized: Callable[[Request], bool]
43
108
 
44
109
 
45
110
  def create_static_routes_router(
@@ -50,46 +115,69 @@ def create_static_routes_router(
50
115
  app_mode: str,
51
116
  model_router,
52
117
  require_user,
118
+ invite_cookie_secret: str = "",
119
+ secure_cookies: bool = False,
53
120
  ) -> StaticRoutesBundle:
54
121
  api_router = APIRouter()
55
122
  STATIC_DIR = static_dir
56
123
  INVITE_GATE_ENABLED = invite_gate_enabled
57
124
  INVITE_CODE = invite_code
125
+ INVITE_COOKIE_SECRET = invite_cookie_secret or secrets.token_urlsafe(48)
126
+ SECURE_COOKIES = bool(secure_cookies)
58
127
  APP_MODE = app_mode
59
128
  router = model_router
60
129
 
130
+ def invite_authorized(request: Request) -> bool:
131
+ """Verify the request's signed invite claim at every gated boundary."""
132
+
133
+ if not INVITE_GATE_ENABLED:
134
+ return True
135
+ return _verify_invite_cookie(
136
+ request.cookies.get(INVITE_COOKIE_NAME),
137
+ INVITE_COOKIE_SECRET,
138
+ )
139
+
61
140
  @api_router.get("/")
62
- async def root(request: Request, code: Optional[str] = None, authorized: Optional[str] = Cookie(None)):
141
+ async def root(
142
+ request: Request,
143
+ code: Optional[str] = None,
144
+ invite_cookie: Optional[str] = Cookie(None, alias=INVITE_COOKIE_NAME),
145
+ ):
63
146
  """로그인/회원가입 페이지. 초대 게이트 활성화 시 코드 검증 후 진입."""
64
147
  if not INVITE_GATE_ENABLED:
65
148
  return app_redirect("account", request)
66
-
67
- # 1. 이미 쿠키로 인증된 경우
68
- if authorized == "true":
69
- return app_redirect("account", request)
70
-
149
+
150
+ # 1. 유효한 서버 서명 쿠키가 있는 경우
151
+ if invite_authorized(request):
152
+ return app_redirect("account")
153
+
71
154
  # 2. 초대 코드가 일치하는 경우 (최초 진입)
72
- if code == INVITE_CODE:
73
- response = app_redirect("account", request)
74
- response.set_cookie(key="authorized", value="true", httponly=True, samesite="lax", max_age=60*60*24*7)
155
+ if INVITE_CODE and code and secrets.compare_digest(code, INVITE_CODE):
156
+ # Do not retain the invitation code in the redirect URL/history.
157
+ response = app_redirect("account")
158
+ response.set_cookie(
159
+ key=INVITE_COOKIE_NAME,
160
+ value=_sign_invite_cookie(INVITE_COOKIE_SECRET),
161
+ httponly=True,
162
+ secure=SECURE_COOKIES,
163
+ samesite="lax",
164
+ max_age=INVITE_COOKIE_TTL_SECONDS,
165
+ path="/",
166
+ )
75
167
  return response
76
-
168
+
77
169
  # 3. 인증 실패 시 차단 화면
78
- return HTMLResponse(content="""
79
- <body style="background:#0f1115; color:white; display:flex; flex-direction:column; align-items:center; justify-content:center; height:100vh; font-family:sans-serif;">
80
- <div style="background:#16191f; padding:40px; border-radius:24px; border:1px solid rgba(255,255,255,0.1); text-align:center; box-shadow: 0 20px 40px rgba(0,0,0,0.5);">
81
- <div style="font-size:48px; margin-bottom:20px;">🔒</div>
82
- <h1 style="color:#378ADD; margin:0; font-size:24px;">Invitation Required</h1>
83
- <p style="color:#94a3b8; margin:20px 0; line-height:1.6;">이 서비스는 비공개로 운영되고 있습니다.<br>선생님께 받은 <b>초대용 전용 링크</b>를 통해 접속해 주세요.</p>
84
- <div style="margin-top:30px; padding-top:20px; border-top:1px solid rgba(255,255,255,0.05); font-size:11px; color:rgba(255,255,255,0.2); letter-spacing:1px;">LATTICE AI</div>
85
- </div>
86
- </body>
87
- """, status_code=403)
170
+ return _invite_denied_response()
88
171
 
89
172
 
90
173
  @api_router.get("/account")
91
- async def account_page():
174
+ async def account_page(
175
+ request: Request,
176
+ invite_cookie: Optional[str] = Cookie(None, alias=INVITE_COOKIE_NAME),
177
+ ):
92
178
  """Direct login/register page route used by logout and manual navigation."""
179
+ if INVITE_GATE_ENABLED and not invite_authorized(request):
180
+ return _invite_denied_response()
93
181
  return app_redirect("account")
94
182
 
95
183
 
@@ -127,8 +215,13 @@ def create_static_routes_router(
127
215
 
128
216
 
129
217
  @api_router.get("/app")
130
- async def app_shell(request: Request):
218
+ async def app_shell(
219
+ request: Request,
220
+ invite_cookie: Optional[str] = Cookie(None, alias=INVITE_COOKIE_NAME),
221
+ ):
131
222
  """React desktop single-page workspace shell."""
223
+ if INVITE_GATE_ENABLED and not invite_authorized(request):
224
+ return _invite_denied_response()
132
225
  page = STATIC_DIR / "app" / "index.html"
133
226
  if not page.exists():
134
227
  raise HTTPException(status_code=404, detail="React shell not found.")
@@ -143,8 +236,9 @@ def create_static_routes_router(
143
236
  # (latticeai.api.workspace), included below after its dependencies are defined.
144
237
 
145
238
  @api_router.get("/status")
146
- async def status():
239
+ async def status(request: Request):
147
240
  """서버 상태 및 현재 로드된 모델 정보를 반환합니다."""
241
+ require_user(request)
148
242
  return {
149
243
  "message": "🧠 Lattice AI MLX Server is running!",
150
244
  "status": "online",
@@ -193,4 +287,9 @@ def create_static_routes_router(
193
287
  result["error"] = str(e)
194
288
  return result
195
289
 
196
- return StaticRoutesBundle(api_router, ui_file_response, local_sysinfo)
290
+ return StaticRoutesBundle(
291
+ api_router,
292
+ ui_file_response,
293
+ local_sysinfo,
294
+ invite_authorized,
295
+ )
@@ -31,8 +31,7 @@ from latticeai.services.tool_dispatch import (
31
31
  tool_registry_manifest,
32
32
  )
33
33
  from latticeai.services.router_context import ToolRouterContext
34
- from latticeai.services.p_reinforce import BRAIN_DIR
35
- from tools import (
34
+ from latticeai.tools import (
36
35
  AGENT_ROOT,
37
36
  ToolError,
38
37
  build_project,
@@ -51,6 +50,7 @@ from tools import (
51
50
  inspect_html,
52
51
  knowledge_save,
53
52
  knowledge_search,
53
+ knowledge_scope_root,
54
54
  knowledge_tree,
55
55
  list_dir,
56
56
  network_status,
@@ -207,6 +207,7 @@ def create_tools_router(
207
207
  mcp_public_item=None,
208
208
  hooks=None,
209
209
  allowed_workspaces_for=None,
210
+ workspace_service=None,
210
211
  ) -> APIRouter:
211
212
  if tool_context is not None:
212
213
  config = tool_context.config
@@ -233,6 +234,7 @@ def create_tools_router(
233
234
  mcp_public_item = tool_context.mcp_public_item
234
235
  hooks = tool_context.hooks
235
236
  allowed_workspaces_for = tool_context.allowed_workspaces_for
237
+ workspace_service = tool_context.workspace_service
236
238
 
237
239
  api_router = APIRouter()
238
240
  HOOKS = hooks
@@ -313,6 +315,37 @@ def create_tools_router(
313
315
  if require_auth and user_email and allowed_workspaces_for is not None:
314
316
  scope["allowed_workspaces"] = allowed_workspaces_for(user_email)
315
317
  return scope
318
+
319
+ def _requested_workspace(request: Request) -> Optional[str]:
320
+ return (
321
+ request.headers.get("X-Workspace-Id")
322
+ or request.query_params.get("workspace_id")
323
+ or None
324
+ )
325
+
326
+ def _knowledge_scope(request: Request, current_user: str, *, write: bool) -> Dict[str, str]:
327
+ # Preserve the historical shared vault only for explicit single-user,
328
+ # no-auth local mode. Authenticated deployments always partition by
329
+ # both authorized workspace and account.
330
+ if not bool(getattr(CONFIG, "require_auth", False)):
331
+ return {}
332
+ requested = _requested_workspace(request)
333
+ try:
334
+ if workspace_service is not None:
335
+ resolver = (
336
+ workspace_service.resolve_write_scope
337
+ if write
338
+ else workspace_service.resolve_read_scope
339
+ )
340
+ workspace_id = resolver(requested, current_user)
341
+ else:
342
+ workspace_id = requested or "personal"
343
+ allowed = allowed_workspaces_for(current_user) if allowed_workspaces_for else None
344
+ if allowed is not None and workspace_id not in set(allowed):
345
+ raise PermissionError(f"workspace '{workspace_id}' is not readable")
346
+ except PermissionError as exc:
347
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
348
+ return {"workspace_id": str(workspace_id), "user_email": current_user}
316
349
 
317
350
 
318
351
  @api_router.post("/tools/list_dir")
@@ -437,14 +470,23 @@ def create_tools_router(
437
470
  @api_router.post("/tools/read_document")
438
471
  async def tools_read_document(req: ToolPathRequest, request: Request):
439
472
  current_user = require_user(request)
440
- if Path(req.path).expanduser().is_absolute():
473
+ raw_path = Path(req.path).expanduser()
474
+ target = raw_path.resolve() if raw_path.is_absolute() else (AGENT_ROOT / raw_path).resolve()
475
+ inside_agent_workspace = target == AGENT_ROOT or AGENT_ROOT in target.parents
476
+ if not inside_agent_workspace:
441
477
  permission_gateway.require_local_approval(
442
478
  token=req.approval_token,
443
- path=req.path,
479
+ path=str(target),
444
480
  action="read",
445
481
  user_email=current_user,
446
482
  )
447
- return _tool_response(read_document, req.path, current_user=current_user)
483
+ return _tool_response(
484
+ read_document,
485
+ str(target),
486
+ current_user=current_user,
487
+ source="workspace" if inside_agent_workspace else "approved_local",
488
+ trusted_admin=True,
489
+ )
448
490
 
449
491
 
450
492
  @api_router.get("/tools/pdf_pages")
@@ -518,12 +560,14 @@ def create_tools_router(
518
560
  append_audit_event=append_audit_event,
519
561
  enforce_rate_limit=enforce_rate_limit,
520
562
  hooks=HOOKS,
563
+ workspace_service=workspace_service,
521
564
  )
522
565
 
523
566
 
524
567
  api_router.include_router(permissions_router)
525
568
  api_router.include_router(create_local_files_router(
526
569
  require_user=require_user,
570
+ require_admin=require_admin,
527
571
  tool_response=_tool_response,
528
572
  permission_gateway=permission_gateway,
529
573
  knowledge_graph=KNOWLEDGE_GRAPH,
@@ -534,6 +578,7 @@ def create_tools_router(
534
578
  hooks=HOOKS,
535
579
  data_dir=DATA_DIR,
536
580
  allowed_workspaces_for=allowed_workspaces_for,
581
+ workspace_service=workspace_service,
537
582
  ))
538
583
  api_router.include_router(create_computer_use_router(
539
584
  model_router=router,
@@ -541,51 +586,87 @@ def create_tools_router(
541
586
  tool_response=_tool_response,
542
587
  save_to_history=save_to_history,
543
588
  hooks=HOOKS,
589
+ append_audit_event=append_audit_event,
590
+ workspace_service=workspace_service,
544
591
  ))
545
592
 
546
593
  @api_router.post("/tools/knowledge_save")
547
594
  async def tools_knowledge_save(req: ToolKnowledgeSaveRequest, request: Request):
548
595
  current_user = require_user(request)
549
- return _tool_response(knowledge_save, req.content, req.folder, req.title, current_user=current_user)
596
+ scope = _knowledge_scope(request, current_user, write=True)
597
+ return _tool_response(
598
+ knowledge_save,
599
+ req.content,
600
+ req.folder,
601
+ req.title,
602
+ current_user=current_user,
603
+ **scope,
604
+ )
550
605
 
551
606
 
552
607
  @api_router.post("/tools/knowledge_search")
553
608
  async def tools_knowledge_search(req: ToolKnowledgeSearchRequest, request: Request):
554
609
  current_user = require_user(request)
555
- return _tool_response(knowledge_search, req.query, req.max_results, current_user=current_user)
610
+ scope = _knowledge_scope(request, current_user, write=False)
611
+ return _tool_response(
612
+ knowledge_search,
613
+ req.query,
614
+ req.max_results,
615
+ current_user=current_user,
616
+ **scope,
617
+ )
556
618
 
557
619
 
558
620
  @api_router.get("/tools/knowledge_tree")
559
621
  async def tools_knowledge_tree(request: Request):
560
622
  current_user = require_user(request)
561
- return _tool_response(knowledge_tree, current_user=current_user)
623
+ scope = _knowledge_scope(request, current_user, write=False)
624
+ return _tool_response(knowledge_tree, current_user=current_user, **scope)
562
625
 
563
626
 
564
627
  @api_router.post("/tools/obsidian_save")
565
628
  async def tools_obsidian_save(req: ToolKnowledgeSaveRequest, request: Request):
566
629
  current_user = require_user(request)
567
- return _tool_response(obsidian_save, req.content, req.folder, req.title, current_user=current_user)
630
+ scope = _knowledge_scope(request, current_user, write=True)
631
+ return _tool_response(
632
+ obsidian_save,
633
+ req.content,
634
+ req.folder,
635
+ req.title,
636
+ current_user=current_user,
637
+ **scope,
638
+ )
568
639
 
569
640
 
570
641
  @api_router.post("/tools/obsidian_search")
571
642
  async def tools_obsidian_search(req: ToolKnowledgeSearchRequest, request: Request):
572
643
  current_user = require_user(request)
573
- return _tool_response(obsidian_search, req.query, req.max_results, current_user=current_user)
644
+ scope = _knowledge_scope(request, current_user, write=False)
645
+ return _tool_response(
646
+ obsidian_search,
647
+ req.query,
648
+ req.max_results,
649
+ current_user=current_user,
650
+ **scope,
651
+ )
574
652
 
575
653
 
576
654
  @api_router.get("/tools/obsidian_tree")
577
655
  async def tools_obsidian_tree(request: Request):
578
656
  current_user = require_user(request)
579
- return _tool_response(obsidian_tree, current_user=current_user)
657
+ scope = _knowledge_scope(request, current_user, write=False)
658
+ return _tool_response(obsidian_tree, current_user=current_user, **scope)
580
659
 
581
660
 
582
661
  @api_router.get("/obsidian/status")
583
662
  async def obsidian_status(request: Request):
584
- require_user(request)
663
+ current_user = require_user(request)
664
+ scope = _knowledge_scope(request, current_user, write=False)
665
+ root = knowledge_scope_root(**scope)
585
666
  return {
586
667
  "status": "ok",
587
- "vault_root": str(BRAIN_DIR),
588
- "folders": [path.name for path in BRAIN_DIR.iterdir() if path.is_dir()] if BRAIN_DIR.exists() else [],
668
+ "vault_root": str(root),
669
+ "folders": [path.name for path in root.iterdir() if path.is_dir()] if root.exists() else [],
589
670
  "ocr_engine": shutil.which("tesseract") or None,
590
671
  }
591
672
 
@@ -679,6 +760,8 @@ def create_tools_router(
679
760
  knowledge_graph=KNOWLEDGE_GRAPH,
680
761
  ingestion_pipeline=ingestion_pipeline,
681
762
  data_dir=DATA_DIR,
763
+ allowed_workspaces_for=allowed_workspaces_for,
764
+ workspace_service=workspace_service,
682
765
  ))
683
766
 
684
767
  return api_router
@@ -13,6 +13,7 @@ actually drive plugins, skills, and multi-agent runs.
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ from copy import deepcopy
16
17
  from pathlib import Path
17
18
  from typing import Any, Callable, Dict, List, Optional
18
19
 
@@ -283,6 +284,42 @@ def create_workflow_designer_router(
283
284
  store.list_workflows(workspace_id=scope).get("workflows"), recipe_id
284
285
  )
285
286
  if existing is not None:
287
+ existing_metadata = existing.get("metadata") or {}
288
+ if req.enabled and existing_metadata.get("automation_state") != "enabled":
289
+ enabled_nodes = deepcopy(existing.get("nodes") or definition["nodes"])
290
+ for node in enabled_nodes:
291
+ if node.get("type") != "trigger":
292
+ continue
293
+ node["config"] = {
294
+ **(node.get("config") or {}),
295
+ "enabled": True,
296
+ "review_queue": True,
297
+ "consent_required": True,
298
+ }
299
+ errors = validate_definition({
300
+ "name": existing.get("name") or definition["name"],
301
+ "nodes": enabled_nodes,
302
+ })
303
+ if errors:
304
+ raise HTTPException(status_code=400, detail={"validation_errors": errors})
305
+ enabled_metadata = {
306
+ **existing_metadata,
307
+ "automation_state": "enabled",
308
+ "requires_user_enable": False,
309
+ }
310
+ existing = store.update_workflow_definition(
311
+ str(existing.get("id") or existing.get("workflow_id") or ""),
312
+ name=existing.get("name") or definition["name"],
313
+ nodes=enabled_nodes,
314
+ metadata=enabled_metadata,
315
+ workspace_id=scope,
316
+ )
317
+ append_audit_event(
318
+ "brain_automation_recipe_enabled",
319
+ user_email=current_user,
320
+ workflow_id=existing.get("id"),
321
+ recipe_id=recipe_id,
322
+ )
286
323
  return {
287
324
  "workflow": existing,
288
325
  "recipe": existing.get("metadata") or definition["metadata"],
@@ -290,7 +290,7 @@ def create_workspace_router(context: AppContext) -> APIRouter:
290
290
  # not_recommended) for this machine, used by the onboarding model step.
291
291
  catalog = None
292
292
  try:
293
- from auto_setup import probe as auto_setup_probe
293
+ from latticeai.setup.auto_setup import probe as auto_setup_probe
294
294
  from latticeai.services.model_recommendation import recommend_catalog
295
295
  profile = await asyncio.to_thread(lambda: auto_setup_probe().to_json())
296
296
  catalog = recommend_catalog(profile, engine="local_mlx")
@@ -714,6 +714,7 @@ def create_workspace_router(context: AppContext) -> APIRouter:
714
714
  req.action,
715
715
  user_email=current_user or None,
716
716
  source="vscode",
717
+ workspace_id=workflow.get("workspace_id"),
717
718
  metadata={
718
719
  "file_path": req.file_path,
719
720
  "language": req.language,