ltcai 11.5.1 → 11.5.2

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 (176) hide show
  1. package/README.md +85 -53
  2. package/docs/CHANGELOG.md +54 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/HYBRID_CLOUD_KG_STREAMING.md +8 -4
  6. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  7. package/docs/ONBOARDING.md +1 -1
  8. package/docs/OPERATIONS.md +1 -1
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/WORKFLOW_DESIGNER.md +1 -1
  12. package/docs/architecture.md +4 -4
  13. package/docs/kg-schema.md +1 -1
  14. package/docs/v11.5.2_TIGHT_SHIP_PLAN.md +144 -0
  15. package/lattice_brain/__init__.py +1 -1
  16. package/lattice_brain/graph/proactive.py +1 -1
  17. package/lattice_brain/graph/projection/v2_schema.py +0 -28
  18. package/lattice_brain/graph/vector_index/base.py +0 -3
  19. package/lattice_brain/graph/vector_index/brute_force.py +0 -4
  20. package/lattice_brain/graph/vector_index/hnsw.py +0 -3
  21. package/lattice_brain/graph/vector_index/quantized.py +0 -3
  22. package/lattice_brain/ingestion_jobs.py +0 -3
  23. package/lattice_brain/memory.py +0 -27
  24. package/lattice_brain/portability/sharing.py +0 -4
  25. package/lattice_brain/quality.py +0 -49
  26. package/lattice_brain/runtime/agent_runtime.py +1 -1
  27. package/lattice_brain/runtime/hooks.py +0 -13
  28. package/lattice_brain/runtime/multi_agent.py +1 -1
  29. package/lattice_brain/sealed_box.py +0 -4
  30. package/latticeai/__init__.py +1 -1
  31. package/latticeai/api/admin.py +12 -12
  32. package/latticeai/api/auth.py +25 -2
  33. package/latticeai/api/chat.py +3 -3
  34. package/latticeai/api/chat_agent_http.py +3 -12
  35. package/latticeai/api/chat_helpers.py +10 -13
  36. package/latticeai/api/computer_use.py +31 -42
  37. package/latticeai/api/health.py +21 -1
  38. package/latticeai/api/local_files.py +14 -0
  39. package/latticeai/api/permissions.py +38 -12
  40. package/latticeai/api/search.py +24 -0
  41. package/latticeai/api/static_routes.py +4 -1
  42. package/latticeai/cli/runtime.py +7 -3
  43. package/latticeai/core/config.py +47 -3
  44. package/latticeai/core/csrf.py +28 -2
  45. package/latticeai/core/embedding_providers/__init__.py +3 -5
  46. package/latticeai/core/embedding_providers/base.py +1 -1
  47. package/latticeai/core/embedding_providers/text.py +1 -1
  48. package/latticeai/core/enterprise.py +1 -4
  49. package/latticeai/core/http_origin.py +146 -0
  50. package/latticeai/core/invitations.py +3 -2
  51. package/latticeai/core/io_utils.py +2 -11
  52. package/latticeai/core/legacy_compatibility.py +1 -1
  53. package/latticeai/core/marketplace.py +1 -1
  54. package/latticeai/core/messages.py +5 -0
  55. package/latticeai/core/model_compat.py +2 -8
  56. package/latticeai/core/module_probe.py +37 -0
  57. package/latticeai/core/run_explain.py +5 -2
  58. package/latticeai/core/run_store.py +2 -2
  59. package/latticeai/core/security.py +13 -0
  60. package/latticeai/core/sessions.py +3 -2
  61. package/latticeai/core/sse.py +25 -0
  62. package/latticeai/core/users.py +0 -9
  63. package/latticeai/core/workspace_graph_trace.py +2 -8
  64. package/latticeai/core/workspace_os.py +0 -10
  65. package/latticeai/core/workspace_os_constants.py +1 -1
  66. package/latticeai/core/workspace_os_utils.py +34 -0
  67. package/latticeai/core/workspace_review_items.py +2 -8
  68. package/latticeai/core/workspace_runs.py +2 -8
  69. package/latticeai/core/workspace_skills.py +2 -7
  70. package/latticeai/models/router/documents.py +0 -35
  71. package/latticeai/models/router/registry.py +0 -4
  72. package/latticeai/runtime/access_runtime.py +16 -4
  73. package/latticeai/runtime/build_phases/features.py +2 -0
  74. package/latticeai/runtime/build_phases/web.py +2 -0
  75. package/latticeai/runtime/feature_toggle_wiring.py +12 -18
  76. package/latticeai/runtime/network_boundary_wiring.py +8 -19
  77. package/latticeai/runtime/permission_mode_wiring.py +6 -13
  78. package/latticeai/runtime/router_registration.py +4 -0
  79. package/latticeai/runtime/runtime_context.py +1 -13
  80. package/latticeai/runtime/service_singletons.py +55 -0
  81. package/latticeai/services/architecture_readiness.py +1 -1
  82. package/latticeai/services/brain_intelligence/proposals.py +0 -5
  83. package/latticeai/services/change_proposals.py +2 -36
  84. package/latticeai/services/chronicle.py +4 -6
  85. package/latticeai/services/command_center.py +4 -4
  86. package/latticeai/services/evidence_actions.py +5 -2
  87. package/latticeai/services/hybrid_chat.py +2 -2
  88. package/latticeai/services/hybrid_policy.py +8 -57
  89. package/latticeai/services/mode_store.py +132 -0
  90. package/latticeai/services/model_capability_registry.py +0 -15
  91. package/latticeai/services/model_catalog.py +0 -7
  92. package/latticeai/services/model_loading.py +3 -2
  93. package/latticeai/services/model_runtime/__init__.py +0 -57
  94. package/latticeai/services/model_runtime/engines.py +27 -128
  95. package/latticeai/services/model_runtime/loading.py +2 -2
  96. package/latticeai/services/network_boundary_service.py +7 -44
  97. package/latticeai/services/permission_mode_service.py +8 -56
  98. package/latticeai/services/product_readiness.py +1 -1
  99. package/latticeai/services/setup_detection.py +67 -1
  100. package/latticeai/services/tool_dispatch.py +0 -4
  101. package/latticeai/services/upload_service.py +7 -20
  102. package/latticeai/services/workspace_service.py +0 -6
  103. package/latticeai/setup/auto_setup.py +15 -26
  104. package/latticeai/setup/wizard/catalog.py +5 -2
  105. package/latticeai/setup/wizard/detect.py +6 -27
  106. package/latticeai/setup/wizard/paths.py +2 -5
  107. package/latticeai/setup/wizard/plans.py +2 -2
  108. package/package.json +2 -4
  109. package/scripts/bump_version.py +14 -1
  110. package/scripts/check_current_release_docs.mjs +1 -1
  111. package/scripts/check_legacy_debt.mjs +1 -1
  112. package/scripts/generate_agent_loop_fixtures.py +87 -0
  113. package/scripts/generate_rust_parity_fixtures.py +3 -95
  114. package/scripts/parity_fixture_corpus_context.py +162 -0
  115. package/scripts/release_screen_claims.json +10 -0
  116. package/src-tauri/Cargo.lock +7 -7
  117. package/src-tauri/Cargo.toml +1 -1
  118. package/src-tauri/src/backend.rs +76 -2
  119. package/src-tauri/src/main.rs +13 -3
  120. package/src-tauri/tauri.conf.json +2 -2
  121. package/static/app/asset-manifest.json +40 -40
  122. package/static/app/assets/{Act-Drs_jd-O.js → Act-DcQizkl1.js} +1 -1
  123. package/static/app/assets/{AdminConsole-BqNx5oF6.js → AdminConsole-cf4npybT.js} +1 -1
  124. package/static/app/assets/{Brain-C7bdNmz-.js → Brain-3VCSHFcn.js} +1 -1
  125. package/static/app/assets/{BrainHome-DcTnINcI.js → BrainHome-Qm8eaztx.js} +1 -1
  126. package/static/app/assets/{BrainSignals-Df1MRIPD.js → BrainSignals-DS9BtKOW.js} +1 -1
  127. package/static/app/assets/{Capture-Dgsxvnbx.js → Capture-DiQ219jW.js} +1 -1
  128. package/static/app/assets/{Chronicle-BrkTxJK1.js → Chronicle-BGvuAchH.js} +1 -1
  129. package/static/app/assets/{CommandPalette-DDziw7Oj.js → CommandPalette-Bqhm0Urn.js} +1 -1
  130. package/static/app/assets/{Library-CYxeuraA.js → Library-BV6NnF0a.js} +1 -1
  131. package/static/app/assets/{LivingBrain-CNp6mvCm.js → LivingBrain-GzenJchP.js} +1 -1
  132. package/static/app/assets/{ProductFlow-BVsowu3Z.js → ProductFlow-DEP6-vML.js} +1 -1
  133. package/static/app/assets/{ReviewCard-D1LbOfJS.js → ReviewCard-CNZ7XjWG.js} +1 -1
  134. package/static/app/assets/{System-DcRAq7wj.js → System-CieofHQa.js} +1 -1
  135. package/static/app/assets/arrow-left-kfsrk0mv.js +1 -0
  136. package/static/app/assets/{bot-BK2xQ9mN.js → bot-B_K1Tdmw.js} +1 -1
  137. package/static/app/assets/{brain-BZNztFBb.js → brain-DWyaV1L1.js} +1 -1
  138. package/static/app/assets/{button-CjueubVZ.js → button-aTn4s84A.js} +1 -1
  139. package/static/app/assets/circle-check-qqLug9nU.js +1 -0
  140. package/static/app/assets/{circle-pause-HRjSmpyY.js → circle-pause-xKgeGXkT.js} +1 -1
  141. package/static/app/assets/{circle-play-CMcKDVwu.js → circle-play-DkT6tYPX.js} +1 -1
  142. package/static/app/assets/{cpu-B_MyTfYr.js → cpu-85xYObUC.js} +1 -1
  143. package/static/app/assets/{download-CTy0EByV.js → download-B5Fm7YXo.js} +1 -1
  144. package/static/app/assets/{folder-open-DF6bLhTg.js → folder-open-kk2Xa52u.js} +1 -1
  145. package/static/app/assets/{hard-drive-CpWHG77C.js → hard-drive-DkA3zBW_.js} +1 -1
  146. package/static/app/assets/{index-Dd6abJHX.js → index-BMPdTmlY.js} +3 -3
  147. package/static/app/assets/{input-bVgIRN3s.js → input-B0nRf2jO.js} +1 -1
  148. package/static/app/assets/{link-2-bg6CG5Vk.js → link-2-Dwb4gnTc.js} +1 -1
  149. package/static/app/assets/{permissionCopy-DFTf1HDZ.js → permissionCopy-CQDUBrOZ.js} +1 -1
  150. package/static/app/assets/{primitives-D7D-sQ3T.js → primitives-SNp0LRJz.js} +1 -1
  151. package/static/app/assets/search-BcHqkjoy.js +1 -0
  152. package/static/app/assets/{share-2-BLzo7U4L.js → share-2-BsrxFglO.js} +1 -1
  153. package/static/app/assets/{shield-alert-BmngqnsJ.js → shield-alert-5BStfp2_.js} +1 -1
  154. package/static/app/assets/{textarea-DOSlAQQ3.js → textarea-Cg8IUA-k.js} +1 -1
  155. package/static/app/assets/{useFocusTrap-pZhgeee4.js → useFocusTrap-CYKvE46M.js} +1 -1
  156. package/static/app/assets/{useMutation-BMDwNk4I.js → useMutation-CSn9t1op.js} +1 -1
  157. package/static/app/assets/{useQuery-CSjKttRo.js → useQuery-CY2OI2uy.js} +1 -1
  158. package/static/app/assets/{utils-D3u_yv7B.js → utils-Ddol2RWD.js} +1 -1
  159. package/static/app/assets/{workspace-H3bMjYXC.js → workspace-BqDwOz_p.js} +1 -1
  160. package/static/app/index.html +3 -3
  161. package/static/sw.js +1 -1
  162. package/desktop/electron/README.md +0 -9
  163. package/desktop/electron/main.cjs +0 -58
  164. package/desktop/electron/preload.cjs +0 -5
  165. package/latticeai/core/graph_curator.py +0 -11
  166. package/latticeai/core/hooks.py +0 -11
  167. package/latticeai/core/local_embeddings.py +0 -104
  168. package/latticeai/core/multi_agent.py +0 -11
  169. package/latticeai/core/workflow_engine.py +0 -11
  170. package/latticeai/services/ingestion.py +0 -11
  171. package/latticeai/services/kg_portability.py +0 -11
  172. package/latticeai/services/multimodal_streaming.py +0 -129
  173. package/scripts/measure_brain_home_fill.mjs +0 -141
  174. package/static/app/assets/arrow-left-BHYmOTWU.js +0 -1
  175. package/static/app/assets/circle-check-CLeWJr67.js +0 -1
  176. package/static/app/assets/search-BgSVX6NG.js +0 -1
@@ -310,6 +310,20 @@ def create_local_files_router(
310
310
  except PermissionError as exc:
311
311
  raise HTTPException(status_code=403, detail=str(exc)) from exc
312
312
 
313
+ @router.get("/api/ingestion/multimodal")
314
+ async def ingestion_multimodal_status(request: Request):
315
+ """What this install will do with a picture, a recording, or a video.
316
+
317
+ FEATURE_STATUS has described this answer — including which of the three
318
+ reasons a video would be refused for — since 11.2.0, but the pipeline
319
+ method that produces it had no route, so no surface could ever show it
320
+ and "why was my video not indexed?" had no answer short of reading the
321
+ server's environment.
322
+ """
323
+ require_user(request)
324
+ _require_pipeline()
325
+ return ingestion_pipeline.multimodal_status()
326
+
313
327
  @router.get("/api/ingestion/jobs")
314
328
  async def ingestion_jobs(request: Request, limit: int = 20):
315
329
  """Recent background ingestion jobs (newest first)."""
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import hashlib
6
5
  import json
7
6
  import logging
8
7
  import os
@@ -15,7 +14,9 @@ from typing import Any, Dict, Optional, Tuple
15
14
 
16
15
  from fastapi import APIRouter, HTTPException, Request
17
16
 
17
+ from latticeai.core.http_origin import request_external_origin
18
18
  from latticeai.core.io_utils import atomic_write_json
19
+ from latticeai.core.security import sha256_hex
19
20
 
20
21
  _PERMISSION_ACTION_LABELS = {
21
22
  "list": "폴더 목록 보기",
@@ -39,16 +40,32 @@ class PermissionGateway:
39
40
  self.discord_permission_channel = config.discord_permission_channel
40
41
  self.permission_monitor_secret = config.permission_monitor_secret
41
42
  self.perm_queue_file = data_dir / "permission_queue.json"
42
- explicit_ui_url = os.getenv("LATTICEAI_PERMISSION_UI_URL", "").strip()
43
- public_url = os.getenv("LATTICEAI_PUBLIC_URL", "").strip().rstrip("/")
44
- default_ui_url = f"http://127.0.0.1:{getattr(config, 'port', 4825)}/app#/admin/permissions"
45
- self.permission_ui_url = explicit_ui_url or (
46
- f"{public_url}/app#/admin/permissions" if public_url else default_ui_url
47
- )
43
+ self._explicit_ui_url = os.getenv("LATTICEAI_PERMISSION_UI_URL", "").strip()
44
+ self._public_url = os.getenv("LATTICEAI_PUBLIC_URL", "").strip().rstrip("/")
45
+ # The worker's own port. Behind the desktop gateway this is the
46
+ # *internal* one, which is why a notification built from it sends the
47
+ # approver to a door only the gateway can open — hence
48
+ # ``_front_door``, learned from the request that asked for approval.
49
+ self._loopback_ui_origin = f"http://127.0.0.1:{getattr(config, 'port', 4825)}"
50
+ self._front_door: Optional[str] = None
51
+
52
+ @property
53
+ def permission_ui_url(self) -> str:
54
+ """Where to send the person who has to approve this.
55
+
56
+ Operator configuration wins — an explicit URL or a public URL is a
57
+ deliberate statement about how this install is reached. Only when
58
+ nothing is configured does the front door observed on the request that
59
+ triggered the approval beat the worker's own loopback address.
60
+ """
61
+ if self._explicit_ui_url:
62
+ return self._explicit_ui_url
63
+ base = self._public_url or self._front_door or self._loopback_ui_origin
64
+ return f"{base.rstrip('/')}/app#/admin/permissions"
48
65
 
49
66
  @staticmethod
50
67
  def token_hash(token: str) -> str:
51
- return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()
68
+ return sha256_hex(str(token or ""))
52
69
 
53
70
  @staticmethod
54
71
  def token_hint(token: str) -> str:
@@ -78,9 +95,6 @@ class PermissionGateway:
78
95
  except Exception as exc:
79
96
  logging.warning("perm_queue_write failed: %s", exc)
80
97
 
81
- def _perm_queue_remove(self, token: str) -> None:
82
- self._perm_queue_remove_key(self.token_hash(token))
83
-
84
98
  def _perm_queue_remove_key(self, key: str) -> None:
85
99
  try:
86
100
  with self.perm_queue_lock:
@@ -117,7 +131,7 @@ class PermissionGateway:
117
131
 
118
132
  @staticmethod
119
133
  def content_fingerprint(content: str = "") -> str:
120
- return hashlib.sha256(content.encode("utf-8")).hexdigest()
134
+ return sha256_hex(content)
121
135
 
122
136
  def _notify_discord_permission_sync(self, token: str, path: str, action: str, user_email: str) -> None:
123
137
  sent = False
@@ -225,8 +239,20 @@ class PermissionGateway:
225
239
  email = self.get_current_user(request)
226
240
  if not email:
227
241
  raise HTTPException(status_code=401, detail="로컬 파일 접근은 로그인 세션이 필요합니다.")
242
+ # Every local-file approval passes through here immediately before the
243
+ # permission record (and its notification) is created, so this is the
244
+ # request whose front door the approver should be sent back to. The
245
+ # forwarded authority is only believed from a loopback or configured
246
+ # proxy peer — the same rule CSRF and the invite link use.
247
+ self.remember_front_door(request)
228
248
  return email
229
249
 
250
+ def remember_front_door(self, request: Request) -> None:
251
+ """Record the external origin this request came through, if any."""
252
+ origin = request_external_origin(request)
253
+ if origin:
254
+ self._front_door = origin
255
+
230
256
  def require_local_approval(
231
257
  self,
232
258
  *,
@@ -32,6 +32,12 @@ class HybridSearchRequest(SearchRequest):
32
32
  image_fusion: bool = False
33
33
 
34
34
 
35
+ class GraphSearchRequest(SearchRequest):
36
+ #: How far to walk out from the directly-matching nodes. The service
37
+ #: clamps this to 0–3; 0 means "matches only, no expansion".
38
+ expand_depth: int = 1
39
+
40
+
35
41
  class GraphNodeRequest(BaseModel):
36
42
  node_id: str = Field(..., min_length=1)
37
43
  include_neighbors: bool = True
@@ -164,6 +170,24 @@ def create_search_router(
164
170
  except ValueError as exc:
165
171
  _raise_graph_error(exc)
166
172
 
173
+ @router.post("/api/search/graph")
174
+ async def graph_search(req: GraphSearchRequest, request: Request) -> Dict[str, Any]:
175
+ """Relationship-aware search: direct matches plus what they connect to.
176
+
177
+ The third retrieval channel. It was in ``_ScopedSearchService._SCOPED``
178
+ from the start — so the workspace guard was ready for it — but no
179
+ handler ever called it, which made the allow-list entry the only
180
+ evidence the channel was supposed to be reachable at all.
181
+ """
182
+ try:
183
+ return _guarded(request).graph_search(
184
+ req.query,
185
+ limit=req.limit,
186
+ expand_depth=req.expand_depth,
187
+ )
188
+ except ValueError as exc:
189
+ _raise_graph_error(exc)
190
+
167
191
  @router.get("/api/graph")
168
192
  async def graph(request: Request, limit: int = 300) -> Dict[str, Any]:
169
193
  try:
@@ -25,7 +25,10 @@ PRODUCTION_CSP = (
25
25
  "style-src 'self' 'unsafe-inline'; "
26
26
  "img-src 'self' data: blob: http://127.0.0.1:*; "
27
27
  "font-src 'self' data:; "
28
- "connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; "
28
+ # No `ws:`: this product has no WebSocket endpoint anywhere. The live
29
+ # surfaces (chat, agent steps, index progress) are server-sent events, read
30
+ # with fetch/ReadableStream, which `connect-src` already covers over http.
31
+ "connect-src 'self' http://127.0.0.1:*; "
29
32
  "frame-src 'none'; "
30
33
  "object-src 'none'; "
31
34
  "base-uri 'none'; "
@@ -2,10 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import importlib.util
6
5
  import os
7
6
  from pathlib import Path
8
7
 
8
+ from latticeai.core.module_probe import module_available
9
+
10
+ #: Historical name. ``cli.entrypoint`` imports it from here and the suite
11
+ #: monkeypatches it there, so the binding has to exist on this module.
12
+ _has_module = module_available
13
+
9
14
 
10
15
  def _load_env_file(path: Path) -> None:
11
16
  if not path.exists():
@@ -33,5 +38,4 @@ def _apply_extra_path() -> None:
33
38
  os.environ["PATH"] = os.pathsep.join(current)
34
39
 
35
40
 
36
- def _has_module(name: str) -> bool:
37
- return importlib.util.find_spec(name) is not None
41
+
@@ -18,6 +18,7 @@ concept and are read dynamically at call time.
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ import os
21
22
  import sys
22
23
  from dataclasses import dataclass
23
24
  from pathlib import Path
@@ -25,7 +26,50 @@ from typing import List, Mapping, Optional
25
26
 
26
27
  from latticeai.core.security import host_is_loopback
27
28
 
28
- __all__ = ["Config", "_value", "_str", "_bool", "_int"]
29
+ __all__ = [
30
+ "Config",
31
+ "SSO_CALLBACK_PATH",
32
+ "default_data_dir",
33
+ "default_sso_redirect_uri",
34
+ "_value",
35
+ "_str",
36
+ "_bool",
37
+ "_int",
38
+ ]
39
+
40
+ #: The path the identity provider sends the browser back to.
41
+ SSO_CALLBACK_PATH = "/auth/sso/callback"
42
+
43
+
44
+ def default_sso_redirect_uri(port: int) -> str:
45
+ """The redirect URI when the operator has registered none.
46
+
47
+ Named rather than inlined because ``api/auth`` has to recognise it: this
48
+ exact string means "nobody chose a callback URL", which is the only case in
49
+ which the login flow may substitute the front door it was actually reached
50
+ through. A URI the operator *did* configure is registered with the identity
51
+ provider and must be sent back verbatim.
52
+ """
53
+ return f"http://localhost:{port}{SSO_CALLBACK_PATH}"
54
+
55
+
56
+ #: Where everything durable lives when the operator names nothing.
57
+ DEFAULT_DATA_DIR_NAME = ".ltcai"
58
+
59
+
60
+ def default_data_dir(env: Optional[Mapping[str, str]] = None) -> Path:
61
+ """The data directory ``LATTICEAI_DATA_DIR`` names, else ``~/.ltcai``.
62
+
63
+ Three runtime wiring modules each carried a byte-identical copy of this,
64
+ which meant a lazily-initialized service could disagree with ``Config``
65
+ about where the store lives — and the loser writes a second, invisible
66
+ copy of the user's data. ``env=None`` reads the live process environment,
67
+ which is what the lazy wiring paths need.
68
+ """
69
+ source = os.environ if env is None else env
70
+ raw = (source.get("LATTICEAI_DATA_DIR") or "").strip()
71
+ return Path(raw) if raw else Path.home() / DEFAULT_DATA_DIR_NAME
72
+
29
73
 
30
74
  def _value(env: Mapping[str, str], key: str, default: str = "") -> str:
31
75
  """Mirror the legacy ``env_value``: ``getenv(key) or default or ""`` (no strip)."""
@@ -177,14 +221,14 @@ class Config:
177
221
  # download path, the on-disk cache dir and the catalog key all agree.
178
222
  local_model = _value(env, "LATTICEAI_LOCAL_MODEL", "mlx-community/gemma-4-12B-it-4bit")
179
223
 
180
- data_dir = Path(_value(env, "LATTICEAI_DATA_DIR", str(Path.home() / ".ltcai")))
224
+ data_dir = default_data_dir(env)
181
225
  static_dir = Path(_value(env, "LATTICEAI_STATIC_DIR", str(base_dir / "static")))
182
226
  if not static_dir.exists():
183
227
  packaged_static = Path(sys.prefix) / "static"
184
228
  if packaged_static.exists():
185
229
  static_dir = packaged_static
186
230
 
187
- default_sso_redirect = f"http://localhost:{port}/auth/sso/callback"
231
+ default_sso_redirect = default_sso_redirect_uri(port)
188
232
 
189
233
  return cls(
190
234
  app_mode=app_mode,
@@ -29,6 +29,12 @@ Threat model, stated so the exemptions are checkable rather than vibes:
29
29
  the desktop shell, the VS Code extension). That is trusted only while the
30
30
  server is bound to loopback, where "not a browser" and "already on this
31
31
  machine" coincide. On a reachable bind it fails closed.
32
+ * **Behind a proxy, ``Host`` is not the front door.** The desktop gateway
33
+ replaces it with the worker's internal authority, so the ``Origin == Host``
34
+ fallback below compared a browser's real origin against a port the browser
35
+ has never heard of and refused every write. The front door's own name comes
36
+ from :mod:`latticeai.core.http_origin`, which believes ``X-Forwarded-Host``
37
+ only from a peer that may forward.
32
38
  """
33
39
 
34
40
  from __future__ import annotations
@@ -48,6 +54,11 @@ from typing import (
48
54
  )
49
55
  from urllib.parse import urlsplit
50
56
 
57
+ from latticeai.core.http_origin import (
58
+ FORWARDED_HOST_HEADER,
59
+ effective_host,
60
+ )
61
+
51
62
  __all__ = [
52
63
  "CSRFDecision",
53
64
  "CSRFOriginPolicy",
@@ -176,6 +187,8 @@ class CSRFOriginPolicy:
176
187
  self,
177
188
  origin: Tuple[str, str, Optional[int]],
178
189
  host_header: Optional[str],
190
+ forwarded_host: Optional[str] = None,
191
+ peer: Optional[str] = None,
179
192
  ) -> bool:
180
193
  if any(_same_site(origin, trusted) for trusted in self._trusted):
181
194
  return True
@@ -184,7 +197,13 @@ class CSRFOriginPolicy:
184
197
  # page this server actually served — which is precisely "not cross
185
198
  # site". This is what keeps reverse-proxied hostnames working without
186
199
  # every operator having to enumerate them.
187
- own = normalize_origin(host_header)
200
+ #
201
+ # "The request's own Host" is the *front door's*, not the internal one a
202
+ # proxy hop rewrote it to, which is why the forwarded authority is
203
+ # resolved first (and only from a peer that may forward).
204
+ own = normalize_origin(
205
+ effective_host(host=host_header, forwarded_host=forwarded_host, peer=peer)
206
+ )
188
207
  return own is not None and _same_site(origin, own)
189
208
 
190
209
  def evaluate(
@@ -196,6 +215,8 @@ class CSRFOriginPolicy:
196
215
  host: Optional[str],
197
216
  cookie_header: Optional[str],
198
217
  authorization: Optional[str],
218
+ forwarded_host: Optional[str] = None,
219
+ peer: Optional[str] = None,
199
220
  ) -> CSRFDecision:
200
221
  """Allow/deny one request. Pure: no I/O, no app state."""
201
222
  if method.upper() in SAFE_METHODS:
@@ -219,7 +240,7 @@ class CSRFOriginPolicy:
219
240
  return CSRFDecision(True, "no-origin-loopback-bind")
220
241
  return CSRFDecision(False, "no-origin-reachable-bind")
221
242
 
222
- if self._origin_is_trusted(stated, host):
243
+ if self._origin_is_trusted(stated, host, forwarded_host, peer):
223
244
  return CSRFDecision(True, "same-site-or-trusted-origin")
224
245
  return CSRFDecision(False, "cross-site-origin")
225
246
 
@@ -261,6 +282,9 @@ class CSRFOriginGuardMiddleware:
261
282
  key.decode("latin-1").lower(): value.decode("latin-1")
262
283
  for key, value in scope.get("headers") or []
263
284
  }
285
+ # ``client`` is ``(host, port)`` under every ASGI server, and absent
286
+ # under a test transport that does not model a socket.
287
+ client = scope.get("client") or ()
264
288
  decision = self.policy.evaluate(
265
289
  method=str(scope.get("method") or "GET"),
266
290
  origin=headers.get("origin"),
@@ -268,6 +292,8 @@ class CSRFOriginGuardMiddleware:
268
292
  host=headers.get("host"),
269
293
  cookie_header=headers.get("cookie"),
270
294
  authorization=headers.get("authorization"),
295
+ forwarded_host=headers.get(FORWARDED_HOST_HEADER),
296
+ peer=client[0] if client else None,
271
297
  )
272
298
  if decision.allowed:
273
299
  await self.app(scope, receive, send)
@@ -15,7 +15,7 @@ swapped behind a single interface as long as every implementation agrees on:
15
15
  holds the five concrete text implementations:
16
16
 
17
17
  1. :class:`HashEmbeddingProvider` — deterministic, offline, always-available
18
- fallback (wraps the legacy :class:`~latticeai.core.local_embeddings.LocalEmbeddingModel`).
18
+ fallback (wraps :class:`~lattice_brain.embeddings.LocalEmbeddingModel`).
19
19
  2. :class:`MLXEmbeddingProvider` — local Apple-Silicon embedding models.
20
20
  3. :class:`OllamaEmbeddingProvider` — a local/remote Ollama server.
21
21
  4. :class:`OpenAICompatibleEmbeddingProvider` — any ``/v1/embeddings`` endpoint
@@ -65,10 +65,8 @@ from __future__ import annotations
65
65
  # the two names it imported for its own use were part of its surface, and the
66
66
  # suite imports them from here. Re-exported in the redundant-alias form so they
67
67
  # read as deliberate rather than as leftover imports.
68
- from latticeai.core.local_embeddings import (
69
- DEFAULT_EMBEDDING_DIM as DEFAULT_EMBEDDING_DIM,
70
- )
71
- from latticeai.core.local_embeddings import LocalEmbeddingModel as LocalEmbeddingModel
68
+ from lattice_brain.embeddings import DEFAULT_EMBEDDING_DIM as DEFAULT_EMBEDDING_DIM
69
+ from lattice_brain.embeddings import LocalEmbeddingModel as LocalEmbeddingModel
72
70
 
73
71
  from .base import _KNOWN_DIMS as _KNOWN_DIMS
74
72
  from .base import EmbeddingProvider as EmbeddingProvider
@@ -19,7 +19,7 @@ import struct
19
19
  from dataclasses import dataclass, field
20
20
  from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence
21
21
 
22
- from latticeai.core.local_embeddings import DEFAULT_EMBEDDING_DIM
22
+ from lattice_brain.embeddings import DEFAULT_EMBEDDING_DIM
23
23
 
24
24
 
25
25
  class EmbeddingUnavailable(RuntimeError):
@@ -14,7 +14,7 @@ import os
14
14
  from dataclasses import dataclass
15
15
  from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
16
16
 
17
- from latticeai.core.local_embeddings import DEFAULT_EMBEDDING_DIM, LocalEmbeddingModel
17
+ from lattice_brain.embeddings import DEFAULT_EMBEDDING_DIM, LocalEmbeddingModel
18
18
 
19
19
  from .base import (
20
20
  EmbeddingProvider,
@@ -22,7 +22,7 @@ from __future__ import annotations
22
22
 
23
23
  import os
24
24
  from enum import Enum
25
- from typing import Dict, List, Protocol, runtime_checkable
25
+ from typing import Dict, Protocol, runtime_checkable
26
26
 
27
27
 
28
28
  class Edition(str, Enum):
@@ -115,9 +115,6 @@ class CapabilityRegistry:
115
115
  # A misbehaving plugin must never break a Community request.
116
116
  return False
117
117
 
118
- def available_capabilities(self) -> List[EnterpriseCapability]:
119
- return [cap for cap in EnterpriseCapability if self.is_capability_enabled(cap)]
120
-
121
118
  def describe(self) -> Dict[str, object]:
122
119
  """Edition + capability matrix for ``/workspace/editions`` and admin UI."""
123
120
  return {
@@ -0,0 +1,146 @@
1
+ """Which host a request *really* arrived at, when something proxied it.
2
+
3
+ The desktop front door is a reverse proxy: the browser talks to the gateway on
4
+ one port and the gateway forwards to the worker on another. ``Host`` is a
5
+ hop-by-hop header, so it is replaced on the way out and the worker sees only its
6
+ own internal authority — which is why every cookie-authenticated write through
7
+ an adopted worker was rejected as ``csrf_origin_rejected``. The gateway states
8
+ the fact it destroyed in ``X-Forwarded-Host`` / ``X-Forwarded-Proto``; this
9
+ module decides when to believe it.
10
+
11
+ Threat model, so the rule is checkable rather than felt:
12
+
13
+ * **A browser cannot set these headers.** ``X-Forwarded-*`` are not on the
14
+ fetch-safelist and a page cannot add them cross-origin without a preflight
15
+ the CORS allowlist rejects. So no *web page* can reach this code path.
16
+ * **A local non-browser caller could already forge ``Origin``.** curl on this
17
+ machine can send any ``Origin`` it likes, and the loopback exemption in
18
+ :mod:`latticeai.core.csrf` already trusts it. Honouring a forwarded host from
19
+ the same caller widens nothing that was not already open.
20
+ * **Anyone else is not trusted.** Off-loopback the header is honoured only from
21
+ a peer the operator listed in ``LATTICEAI_TRUSTED_PROXIES`` — the same
22
+ allowlist ``latticeai.core.security.client_ip`` uses for the same reason, and
23
+ empty by default.
24
+
25
+ The functions are pure: the caller supplies the headers and the peer address, so
26
+ the policy is testable without an ASGI app or a socket.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import ipaddress
32
+ from typing import Any, Optional
33
+
34
+ __all__ = [
35
+ "FORWARDED_HOST_HEADER",
36
+ "FORWARDED_PROTO_HEADER",
37
+ "effective_host",
38
+ "effective_origin",
39
+ "peer_may_forward",
40
+ "request_external_origin",
41
+ ]
42
+
43
+ #: The authority the client asked for, as stated by the proxy in front of us.
44
+ FORWARDED_HOST_HEADER = "x-forwarded-host"
45
+ #: The scheme the client used to reach that proxy.
46
+ FORWARDED_PROTO_HEADER = "x-forwarded-proto"
47
+
48
+ # Only these two are ever taken from a forwarded scheme; anything else is a
49
+ # value we would have to reason about, and the fallback is already correct.
50
+ _KNOWN_SCHEMES = ("https", "http")
51
+
52
+
53
+ def peer_may_forward(peer: Optional[str]) -> bool:
54
+ """Whether ``X-Forwarded-*`` from this direct peer may be believed.
55
+
56
+ Loopback, or a member of the configured trusted-proxy allowlist. An
57
+ unparseable or absent peer is *not* trusted: a request whose origin we
58
+ cannot establish is exactly the one whose claims we must not take.
59
+ """
60
+ if not peer:
61
+ return False
62
+ try:
63
+ address = ipaddress.ip_address(str(peer).strip())
64
+ except ValueError:
65
+ return False
66
+ if address.is_loopback:
67
+ return True
68
+ from latticeai.core.security import _peer_is_trusted_proxy
69
+
70
+ return _peer_is_trusted_proxy(str(peer).strip())
71
+
72
+
73
+ def _first(value: Optional[str]) -> str:
74
+ """The first entry of a possibly comma-joined forwarded header."""
75
+ return (value or "").split(",")[0].strip()
76
+
77
+
78
+ def effective_host(
79
+ *,
80
+ host: Optional[str],
81
+ forwarded_host: Optional[str] = None,
82
+ peer: Optional[str] = None,
83
+ ) -> Optional[str]:
84
+ """The authority the client aimed at: the forwarded one when believable.
85
+
86
+ Falls back to the request's own ``Host`` in every other case — no proxy, a
87
+ peer we do not trust, or a forwarded header that is empty. ``None`` only
88
+ when there is nothing to report at all (an HTTP/1.0 request with no ``Host``
89
+ and no proxy), which callers already have to handle.
90
+ """
91
+ if peer_may_forward(peer):
92
+ claimed = _first(forwarded_host)
93
+ if claimed:
94
+ return claimed
95
+ own = (host or "").strip()
96
+ return own or None
97
+
98
+
99
+ def effective_origin(
100
+ *,
101
+ host: Optional[str],
102
+ scheme: str = "http",
103
+ forwarded_host: Optional[str] = None,
104
+ forwarded_proto: Optional[str] = None,
105
+ peer: Optional[str] = None,
106
+ ) -> Optional[str]:
107
+ """``scheme://authority`` for the front door this request came through.
108
+
109
+ ``scheme`` is what *this* server is speaking; the forwarded scheme wins when
110
+ the peer is trusted and states one we recognise, because a TLS-terminating
111
+ proxy speaks plain HTTP to us while the browser is on ``https`` and a link
112
+ built with the wrong scheme is a broken link.
113
+ """
114
+ authority = effective_host(host=host, forwarded_host=forwarded_host, peer=peer)
115
+ if not authority:
116
+ return None
117
+ resolved = (scheme or "http").strip().lower() or "http"
118
+ if peer_may_forward(peer):
119
+ claimed = _first(forwarded_proto).lower()
120
+ if claimed in _KNOWN_SCHEMES:
121
+ resolved = claimed
122
+ return f"{resolved}://{authority}"
123
+
124
+
125
+ def request_external_origin(request: Any, *, fallback: Optional[str] = None) -> Optional[str]:
126
+ """:func:`effective_origin` for a live request — the *link-building* seam.
127
+
128
+ CSRF asks "is this same-origin?"; this asks the other question: "what URL
129
+ should I hand a person so they can come back here?" Both must read the same
130
+ front door, or an invite link mails out the internal worker port that only
131
+ the gateway can reach.
132
+
133
+ ``fallback`` is returned when the request states no authority at all, so a
134
+ caller with a configured public URL keeps it.
135
+ """
136
+ headers = getattr(request, "headers", None) or {}
137
+ client = getattr(request, "client", None)
138
+ url = getattr(request, "url", None)
139
+ origin = effective_origin(
140
+ host=headers.get("host"),
141
+ scheme=getattr(url, "scheme", "http") or "http",
142
+ forwarded_host=headers.get(FORWARDED_HOST_HEADER),
143
+ forwarded_proto=headers.get(FORWARDED_PROTO_HEADER),
144
+ peer=getattr(client, "host", None) if client else None,
145
+ )
146
+ return origin or fallback
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import hashlib
6
5
  import json
7
6
  import secrets
8
7
  from datetime import datetime, timedelta
@@ -10,6 +9,7 @@ from pathlib import Path
10
9
  from typing import Any, Dict, List, Optional
11
10
 
12
11
  from latticeai.core.quiet import quiet
12
+ from latticeai.core.security import sha256_hex
13
13
 
14
14
  from .timeutil import local_now as _now
15
15
 
@@ -19,7 +19,8 @@ def _iso(dt: datetime) -> str:
19
19
 
20
20
 
21
21
  def _hash_token(token: str) -> str:
22
- return hashlib.sha256(token.encode("utf-8")).hexdigest()
22
+ """Strict by design: an invite code is always a string by the time it lands here."""
23
+ return sha256_hex(token)
23
24
 
24
25
 
25
26
  def _atomic_write(path: Path, data: Dict[str, Any]) -> None:
@@ -5,10 +5,10 @@ from __future__ import annotations
5
5
  import hashlib
6
6
  import json
7
7
  import os
8
- from datetime import datetime
9
8
  from pathlib import Path
10
- from typing import Any, Dict, Optional
9
+ from typing import Any, Dict
11
10
 
11
+ from lattice_brain.utils import parse_iso as parse_iso
12
12
  from latticeai.core.quiet import quiet
13
13
 
14
14
 
@@ -27,15 +27,6 @@ def atomic_write_json(path: Path, payload: Dict[str, Any]) -> None:
27
27
  quiet()
28
28
 
29
29
 
30
- def parse_iso(value: Optional[str]) -> Optional[datetime]:
31
- if not value:
32
- return None
33
- try:
34
- return datetime.fromisoformat(str(value))
35
- except (TypeError, ValueError):
36
- return None
37
-
38
-
39
30
  def sha256_file(path: Path) -> str:
40
31
  digest = hashlib.sha256()
41
32
  with open(path, "rb") as fh:
@@ -13,7 +13,7 @@ from dataclasses import dataclass
13
13
  from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
- LEGACY_COMPATIBILITY_VERSION = "11.5.1"
16
+ LEGACY_COMPATIBILITY_VERSION = "11.5.2"
17
17
 
18
18
 
19
19
  @dataclass(frozen=True)
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
- MARKETPLACE_VERSION = "11.5.1"
13
+ MARKETPLACE_VERSION = "11.5.2"
14
14
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
15
15
 
16
16
 
@@ -623,6 +623,11 @@ MESSAGES: Dict[str, Dict[str, str]] = {
623
623
  }
624
624
 
625
625
 
626
+ def bilingual(ko: str, en: str) -> Dict[str, str]:
627
+ """One Korean/English phrase pair, in the shape every caller renders."""
628
+ return {"ko": ko, "en": en}
629
+
630
+
626
631
  def resolve_language(request: Any, default: str = DEFAULT_LANGUAGE) -> str:
627
632
  """Language for this request: the product's choice, then the browser's.
628
633