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.
- package/README.md +85 -53
- package/docs/CHANGELOG.md +54 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/HYBRID_CLOUD_KG_STREAMING.md +8 -4
- package/docs/MULTI_AGENT_RUNTIME.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/WORKFLOW_DESIGNER.md +1 -1
- package/docs/architecture.md +4 -4
- package/docs/kg-schema.md +1 -1
- package/docs/v11.5.2_TIGHT_SHIP_PLAN.md +144 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/proactive.py +1 -1
- package/lattice_brain/graph/projection/v2_schema.py +0 -28
- package/lattice_brain/graph/vector_index/base.py +0 -3
- package/lattice_brain/graph/vector_index/brute_force.py +0 -4
- package/lattice_brain/graph/vector_index/hnsw.py +0 -3
- package/lattice_brain/graph/vector_index/quantized.py +0 -3
- package/lattice_brain/ingestion_jobs.py +0 -3
- package/lattice_brain/memory.py +0 -27
- package/lattice_brain/portability/sharing.py +0 -4
- package/lattice_brain/quality.py +0 -49
- package/lattice_brain/runtime/agent_runtime.py +1 -1
- package/lattice_brain/runtime/hooks.py +0 -13
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/sealed_box.py +0 -4
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +12 -12
- package/latticeai/api/auth.py +25 -2
- package/latticeai/api/chat.py +3 -3
- package/latticeai/api/chat_agent_http.py +3 -12
- package/latticeai/api/chat_helpers.py +10 -13
- package/latticeai/api/computer_use.py +31 -42
- package/latticeai/api/health.py +21 -1
- package/latticeai/api/local_files.py +14 -0
- package/latticeai/api/permissions.py +38 -12
- package/latticeai/api/search.py +24 -0
- package/latticeai/api/static_routes.py +4 -1
- package/latticeai/cli/runtime.py +7 -3
- package/latticeai/core/config.py +47 -3
- package/latticeai/core/csrf.py +28 -2
- package/latticeai/core/embedding_providers/__init__.py +3 -5
- package/latticeai/core/embedding_providers/base.py +1 -1
- package/latticeai/core/embedding_providers/text.py +1 -1
- package/latticeai/core/enterprise.py +1 -4
- package/latticeai/core/http_origin.py +146 -0
- package/latticeai/core/invitations.py +3 -2
- package/latticeai/core/io_utils.py +2 -11
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +5 -0
- package/latticeai/core/model_compat.py +2 -8
- package/latticeai/core/module_probe.py +37 -0
- package/latticeai/core/run_explain.py +5 -2
- package/latticeai/core/run_store.py +2 -2
- package/latticeai/core/security.py +13 -0
- package/latticeai/core/sessions.py +3 -2
- package/latticeai/core/sse.py +25 -0
- package/latticeai/core/users.py +0 -9
- package/latticeai/core/workspace_graph_trace.py +2 -8
- package/latticeai/core/workspace_os.py +0 -10
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_os_utils.py +34 -0
- package/latticeai/core/workspace_review_items.py +2 -8
- package/latticeai/core/workspace_runs.py +2 -8
- package/latticeai/core/workspace_skills.py +2 -7
- package/latticeai/models/router/documents.py +0 -35
- package/latticeai/models/router/registry.py +0 -4
- package/latticeai/runtime/access_runtime.py +16 -4
- package/latticeai/runtime/build_phases/features.py +2 -0
- package/latticeai/runtime/build_phases/web.py +2 -0
- package/latticeai/runtime/feature_toggle_wiring.py +12 -18
- package/latticeai/runtime/network_boundary_wiring.py +8 -19
- package/latticeai/runtime/permission_mode_wiring.py +6 -13
- package/latticeai/runtime/router_registration.py +4 -0
- package/latticeai/runtime/runtime_context.py +1 -13
- package/latticeai/runtime/service_singletons.py +55 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence/proposals.py +0 -5
- package/latticeai/services/change_proposals.py +2 -36
- package/latticeai/services/chronicle.py +4 -6
- package/latticeai/services/command_center.py +4 -4
- package/latticeai/services/evidence_actions.py +5 -2
- package/latticeai/services/hybrid_chat.py +2 -2
- package/latticeai/services/hybrid_policy.py +8 -57
- package/latticeai/services/mode_store.py +132 -0
- package/latticeai/services/model_capability_registry.py +0 -15
- package/latticeai/services/model_catalog.py +0 -7
- package/latticeai/services/model_loading.py +3 -2
- package/latticeai/services/model_runtime/__init__.py +0 -57
- package/latticeai/services/model_runtime/engines.py +27 -128
- package/latticeai/services/model_runtime/loading.py +2 -2
- package/latticeai/services/network_boundary_service.py +7 -44
- package/latticeai/services/permission_mode_service.py +8 -56
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/setup_detection.py +67 -1
- package/latticeai/services/tool_dispatch.py +0 -4
- package/latticeai/services/upload_service.py +7 -20
- package/latticeai/services/workspace_service.py +0 -6
- package/latticeai/setup/auto_setup.py +15 -26
- package/latticeai/setup/wizard/catalog.py +5 -2
- package/latticeai/setup/wizard/detect.py +6 -27
- package/latticeai/setup/wizard/paths.py +2 -5
- package/latticeai/setup/wizard/plans.py +2 -2
- package/package.json +2 -4
- package/scripts/bump_version.py +14 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_legacy_debt.mjs +1 -1
- package/scripts/generate_agent_loop_fixtures.py +87 -0
- package/scripts/generate_rust_parity_fixtures.py +3 -95
- package/scripts/parity_fixture_corpus_context.py +162 -0
- package/scripts/release_screen_claims.json +10 -0
- package/src-tauri/Cargo.lock +7 -7
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/src/backend.rs +76 -2
- package/src-tauri/src/main.rs +13 -3
- package/src-tauri/tauri.conf.json +2 -2
- package/static/app/asset-manifest.json +40 -40
- package/static/app/assets/{Act-Drs_jd-O.js → Act-DcQizkl1.js} +1 -1
- package/static/app/assets/{AdminConsole-BqNx5oF6.js → AdminConsole-cf4npybT.js} +1 -1
- package/static/app/assets/{Brain-C7bdNmz-.js → Brain-3VCSHFcn.js} +1 -1
- package/static/app/assets/{BrainHome-DcTnINcI.js → BrainHome-Qm8eaztx.js} +1 -1
- package/static/app/assets/{BrainSignals-Df1MRIPD.js → BrainSignals-DS9BtKOW.js} +1 -1
- package/static/app/assets/{Capture-Dgsxvnbx.js → Capture-DiQ219jW.js} +1 -1
- package/static/app/assets/{Chronicle-BrkTxJK1.js → Chronicle-BGvuAchH.js} +1 -1
- package/static/app/assets/{CommandPalette-DDziw7Oj.js → CommandPalette-Bqhm0Urn.js} +1 -1
- package/static/app/assets/{Library-CYxeuraA.js → Library-BV6NnF0a.js} +1 -1
- package/static/app/assets/{LivingBrain-CNp6mvCm.js → LivingBrain-GzenJchP.js} +1 -1
- package/static/app/assets/{ProductFlow-BVsowu3Z.js → ProductFlow-DEP6-vML.js} +1 -1
- package/static/app/assets/{ReviewCard-D1LbOfJS.js → ReviewCard-CNZ7XjWG.js} +1 -1
- package/static/app/assets/{System-DcRAq7wj.js → System-CieofHQa.js} +1 -1
- package/static/app/assets/arrow-left-kfsrk0mv.js +1 -0
- package/static/app/assets/{bot-BK2xQ9mN.js → bot-B_K1Tdmw.js} +1 -1
- package/static/app/assets/{brain-BZNztFBb.js → brain-DWyaV1L1.js} +1 -1
- package/static/app/assets/{button-CjueubVZ.js → button-aTn4s84A.js} +1 -1
- package/static/app/assets/circle-check-qqLug9nU.js +1 -0
- package/static/app/assets/{circle-pause-HRjSmpyY.js → circle-pause-xKgeGXkT.js} +1 -1
- package/static/app/assets/{circle-play-CMcKDVwu.js → circle-play-DkT6tYPX.js} +1 -1
- package/static/app/assets/{cpu-B_MyTfYr.js → cpu-85xYObUC.js} +1 -1
- package/static/app/assets/{download-CTy0EByV.js → download-B5Fm7YXo.js} +1 -1
- package/static/app/assets/{folder-open-DF6bLhTg.js → folder-open-kk2Xa52u.js} +1 -1
- package/static/app/assets/{hard-drive-CpWHG77C.js → hard-drive-DkA3zBW_.js} +1 -1
- package/static/app/assets/{index-Dd6abJHX.js → index-BMPdTmlY.js} +3 -3
- package/static/app/assets/{input-bVgIRN3s.js → input-B0nRf2jO.js} +1 -1
- package/static/app/assets/{link-2-bg6CG5Vk.js → link-2-Dwb4gnTc.js} +1 -1
- package/static/app/assets/{permissionCopy-DFTf1HDZ.js → permissionCopy-CQDUBrOZ.js} +1 -1
- package/static/app/assets/{primitives-D7D-sQ3T.js → primitives-SNp0LRJz.js} +1 -1
- package/static/app/assets/search-BcHqkjoy.js +1 -0
- package/static/app/assets/{share-2-BLzo7U4L.js → share-2-BsrxFglO.js} +1 -1
- package/static/app/assets/{shield-alert-BmngqnsJ.js → shield-alert-5BStfp2_.js} +1 -1
- package/static/app/assets/{textarea-DOSlAQQ3.js → textarea-Cg8IUA-k.js} +1 -1
- package/static/app/assets/{useFocusTrap-pZhgeee4.js → useFocusTrap-CYKvE46M.js} +1 -1
- package/static/app/assets/{useMutation-BMDwNk4I.js → useMutation-CSn9t1op.js} +1 -1
- package/static/app/assets/{useQuery-CSjKttRo.js → useQuery-CY2OI2uy.js} +1 -1
- package/static/app/assets/{utils-D3u_yv7B.js → utils-Ddol2RWD.js} +1 -1
- package/static/app/assets/{workspace-H3bMjYXC.js → workspace-BqDwOz_p.js} +1 -1
- package/static/app/index.html +3 -3
- package/static/sw.js +1 -1
- package/desktop/electron/README.md +0 -9
- package/desktop/electron/main.cjs +0 -58
- package/desktop/electron/preload.cjs +0 -5
- package/latticeai/core/graph_curator.py +0 -11
- package/latticeai/core/hooks.py +0 -11
- package/latticeai/core/local_embeddings.py +0 -104
- package/latticeai/core/multi_agent.py +0 -11
- package/latticeai/core/workflow_engine.py +0 -11
- package/latticeai/services/ingestion.py +0 -11
- package/latticeai/services/kg_portability.py +0 -11
- package/latticeai/services/multimodal_streaming.py +0 -129
- package/scripts/measure_brain_home_fill.mjs +0 -141
- package/static/app/assets/arrow-left-BHYmOTWU.js +0 -1
- package/static/app/assets/circle-check-CLeWJr67.js +0 -1
- 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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
|
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
|
*,
|
package/latticeai/api/search.py
CHANGED
|
@@ -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
|
-
|
|
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'; "
|
package/latticeai/cli/runtime.py
CHANGED
|
@@ -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
|
-
|
|
37
|
-
return importlib.util.find_spec(name) is not None
|
|
41
|
+
|
package/latticeai/core/config.py
CHANGED
|
@@ -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__ = [
|
|
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 =
|
|
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 =
|
|
231
|
+
default_sso_redirect = default_sso_redirect_uri(port)
|
|
188
232
|
|
|
189
233
|
return cls(
|
|
190
234
|
app_mode=app_mode,
|
package/latticeai/core/csrf.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
69
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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:
|
|
@@ -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.
|
|
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
|
|