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
@@ -351,9 +351,6 @@ class BackgroundIngestionQueue:
351
351
  def get(self, job_id: str) -> Optional[BackgroundIngestionJob]:
352
352
  return self._jobs.get(job_id)
353
353
 
354
- def list_pending(self) -> List[BackgroundIngestionJob]:
355
- return [j for j in self._jobs.values() if j.status == "queued"]
356
-
357
354
  def list_recent(self, limit: int = 20) -> List[BackgroundIngestionJob]:
358
355
  """Most recent jobs first (insertion order is schedule order)."""
359
356
  limit = max(1, int(limit))
@@ -27,33 +27,6 @@ class BrainMemory:
27
27
  def available(self) -> bool:
28
28
  return self._pipeline is not None and self._pipeline.available()
29
29
 
30
- def record_decision(
31
- self,
32
- title: str,
33
- detail: str = "",
34
- *,
35
- user_email: Optional[str] = None,
36
- workspace_id: Optional[str] = None,
37
- conversation_id: Optional[str] = None,
38
- decided_by: Optional[str] = None,
39
- metadata: Optional[Dict[str, Any]] = None,
40
- ) -> Dict[str, Any]:
41
- if not str(title or "").strip():
42
- raise ValueError("a decision needs a title")
43
- result = self._pipeline.ingest(
44
- IngestionItem(
45
- source_type="decision",
46
- title=title.strip(),
47
- text=detail,
48
- owner=user_email,
49
- workspace_id=workspace_id,
50
- conversation_id=conversation_id,
51
- metadata={"decided_by": decided_by or user_email, **(metadata or {})},
52
- ),
53
- user_email=user_email,
54
- )
55
- return result.as_dict()
56
-
57
30
  def record_experience(
58
31
  self,
59
32
  title: str,
@@ -167,10 +167,6 @@ class KGPortabilitySharingMixin(_Core):
167
167
  quiet()
168
168
  return result
169
169
 
170
- def import_from_file(self, path, *, mode: str = "merge", dry_run: bool = False) -> Dict[str, Any]:
171
- artifact = json.loads(Path(path).read_text(encoding="utf-8"))
172
- return self.import_data(artifact, mode=mode, dry_run=dry_run)
173
-
174
170
  # ── selective subgraph share (opt-in prototype, off by default) ──────────
175
171
  def share_status(self) -> Dict[str, Any]:
176
172
  """Honest read of the share surface. Safe to call while it is off.
@@ -375,15 +375,6 @@ class GraphEdgeQualityManager:
375
375
  seen[key] = e.get("id")
376
376
  return dups
377
377
 
378
- def merge_duplicate_edges(self, edges: List[Dict]) -> List[Dict]:
379
- # keep highest confidence
380
- best: Dict[Any, Dict[str, Any]] = {}
381
- for e in edges:
382
- key = (e.get("source"), e.get("target"), e.get("type"))
383
- if key not in best or e.get("confidence", 0) > best[key].get("confidence", 0):
384
- best[key] = e
385
- return list(best.values())
386
-
387
378
  def compute_quality_metrics(self, edges: List[Dict]) -> Dict[str, float]:
388
379
  if not edges:
389
380
  return {"avg_conf": 0.0, "avg_evidence": 0.0, "dup_rate": 0.0}
@@ -532,46 +523,6 @@ class LatticeBrainQuality:
532
523
  self.context_assembler = StructuredContextAssembler()
533
524
  self.benchmark = RetrievalBenchmarkRunner()
534
525
 
535
- def full_quality_pass(self, payload: Dict[str, Any]) -> Dict[str, Any]:
536
- """End-to-end quality pipeline (non-destructive)"""
537
- result = {"status": "ok", "timestamp": time.time()}
538
-
539
- # embedding
540
- if "embeddings" in payload:
541
- labels = [self.embed_labeller.label(e["id"], e.get("vector")) for e in payload["embeddings"]]
542
- result["embedding_labels"] = [label.__dict__ for label in labels]
543
- result["reindex_plan"] = self.embed_labeller.generate_reindex_plan()
544
-
545
- # hybrid retrieval
546
- if "retrieval" in payload:
547
- fused = self.hybrid.fuse(payload["retrieval"]["query"], payload["retrieval"]["candidates"])
548
- reranked = self.reranker.rerank(payload["retrieval"]["query"], fused)
549
- result["retrieval"] = {"fused": fused, "reranked": reranked}
550
-
551
- # memory
552
- if "memories" in payload:
553
- cands = self.memory_mgr.extract_candidates(payload["memories"])
554
- cands = self.memory_mgr.score_candidates(cands, payload.get("query", ""))
555
- cands = self.memory_mgr.dedupe(cands)
556
- cands = self.memory_mgr.merge(cands)
557
- cands = self.memory_mgr.detect_conflicts(cands)
558
- cands = self.memory_mgr.apply_retention(cands)
559
- result["memory_candidates"] = [c.__dict__ for c in cands]
560
-
561
- # graph
562
- if "graph_edges" in payload:
563
- eqs = [self.graph_mgr.validate_edge(e) for e in payload["graph_edges"]]
564
- result["graph_quality"] = [q.__dict__ for q in eqs]
565
- result["graph_metrics"] = self.graph_mgr.compute_quality_metrics(payload["graph_edges"])
566
-
567
- # context
568
- if "context_items" in payload:
569
- ctx = self.context_assembler.assemble(payload["context_items"])
570
- ctx = self.context_assembler.apply_guardrails(ctx)
571
- result["structured_context"] = ctx
572
-
573
- return result
574
-
575
526
 
576
527
  __all__ = [
577
528
  "BM25Scorer",
@@ -8,7 +8,7 @@
8
8
  진입점: app_factory.py:AGENT_RUNTIME (wiring root), api/agents.py, RunExecutor.
9
9
 
10
10
  Before this module the agent concern was spread across three places: the
11
- :class:`~latticeai.core.multi_agent.MultiAgentOrchestrator` (role pipeline),
11
+ :class:`~lattice_brain.runtime.multi_agent.MultiAgentOrchestrator` (role pipeline),
12
12
  the :class:`~latticeai.services.platform_runtime.PlatformRuntime` (cross-system
13
13
  wiring + an ad-hoc ``run_agent``), and ``api/agents.py`` (HTTP transport that
14
14
  also owned orchestration + persistence + audit). The frontend reached past all
@@ -181,16 +181,6 @@ class HookResult:
181
181
  }
182
182
 
183
183
 
184
- def hook_context(kind: str, event: str = "", **kwargs: Any) -> HookContext:
185
- """Factory for a :class:`HookContext` (matches the public hook vocabulary)."""
186
- return HookContext(kind, event, **kwargs)
187
-
188
-
189
- def hook_result(**kwargs: Any) -> HookResult:
190
- """Factory for a :class:`HookResult`."""
191
- return HookResult(**kwargs)
192
-
193
-
194
184
  def dispatch_tool(
195
185
  hooks: Any,
196
186
  tool_name: str,
@@ -525,9 +515,6 @@ class HooksRegistry:
525
515
  # Alias — descriptive name used by the wiring layer.
526
516
  register_runtime_hook = register_hook
527
517
 
528
- def unregister_hook(self, hook_id: str) -> None:
529
- self._runtime_runners.pop(hook_id, None)
530
-
531
518
  def has_runner(self, hook_id: str) -> bool:
532
519
  return hook_id in self._runtime_runners
533
520
 
@@ -49,7 +49,7 @@ from typing import Any, Callable, Dict, List, Optional
49
49
  from ..utils import now_iso as _now
50
50
  from .contracts import multi_agent_contract
51
51
 
52
- MULTI_AGENT_VERSION = "11.5.1"
52
+ MULTI_AGENT_VERSION = "11.5.2"
53
53
 
54
54
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
55
55
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -212,10 +212,6 @@ class RecipientIdentity:
212
212
  "path": str(self._key_file),
213
213
  }
214
214
 
215
- def seal_to_self(self, plaintext: bytes) -> Dict[str, Any]:
216
- """Seal to this Brain's own key — the honest way to test a round trip."""
217
- return seal(plaintext, recipient_public_key=self.public_key_b64)
218
-
219
215
  def unseal(self, block: Dict[str, Any]) -> bytes:
220
216
  return unseal(block, self._private)
221
217
 
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "11.5.1"
3
+ __version__ = "11.5.2"
@@ -8,6 +8,8 @@ from typing import Any, Callable, Dict, List, Optional
8
8
  from fastapi import APIRouter, Query, Request
9
9
  from pydantic import BaseModel
10
10
 
11
+ from latticeai.api.workspace_scope import requested_workspace
12
+ from latticeai.core.http_origin import request_external_origin
11
13
  from latticeai.core.messages import http_error, resolve_language
12
14
  from latticeai.core.workspace_os import DEFAULT_WORKSPACE_ID
13
15
 
@@ -65,13 +67,6 @@ def create_admin_router(
65
67
  ) -> APIRouter:
66
68
  router = APIRouter()
67
69
 
68
- def _workspace_scope(request: Request) -> Optional[str]:
69
- header = request.headers.get("X-Workspace-Id")
70
- if header and header.strip():
71
- return header.strip()
72
- query = request.query_params.get("workspace_id")
73
- return query.strip() if query and query.strip() else None
74
-
75
70
  def _matches_scope(item: Dict[str, object], workspace_id: Optional[str]) -> bool:
76
71
  if not workspace_id:
77
72
  return True
@@ -81,11 +76,11 @@ def create_admin_router(
81
76
  return str(item_scope or "") == str(workspace_id)
82
77
 
83
78
  def _scoped_history(request: Request) -> List[Dict]:
84
- scope = _workspace_scope(request)
79
+ scope = requested_workspace(request)
85
80
  return [item for item in get_history() if _matches_scope(item, scope)]
86
81
 
87
82
  def _scoped_audit_log(request: Request) -> List[Dict]:
88
- scope = _workspace_scope(request)
83
+ scope = requested_workspace(request)
89
84
  return [item for item in get_audit_log() if _matches_scope(item, scope)]
90
85
 
91
86
  def _filter_audit_log(
@@ -427,9 +422,14 @@ def create_admin_router(
427
422
  @router.get("/admin/invite-link")
428
423
  async def admin_invite_link(request: Request):
429
424
  require_admin(request)
430
- host = request.headers.get("host", f"localhost:{default_port}")
431
- scheme = "https" if request.headers.get("x-forwarded-proto") == "https" else "http"
432
- url = f"{scheme}://{host}/?code={invite_code}" if invite_gate_enabled else f"{scheme}://{host}/"
425
+ # The link is *mailed to a person*, so it has to name the door they can
426
+ # actually knock on. Behind the desktop gateway ``Host`` is the internal
427
+ # worker authority, which meant every invite emitted through the front
428
+ # door pointed at a port only the gateway can reach.
429
+ origin = request_external_origin(
430
+ request, fallback=f"http://localhost:{default_port}",
431
+ )
432
+ url = f"{origin}/?code={invite_code}" if invite_gate_enabled else f"{origin}/"
433
433
  return {"invite_url": url, "invite_code": invite_code, "gate_enabled": invite_gate_enabled}
434
434
 
435
435
  @router.get("/admin/sso")
@@ -13,6 +13,8 @@ from fastapi import APIRouter, Request
13
13
  from fastapi.responses import JSONResponse, RedirectResponse
14
14
  from pydantic import BaseModel
15
15
 
16
+ from latticeai.core.config import SSO_CALLBACK_PATH
17
+ from latticeai.core.http_origin import request_external_origin
16
18
  from latticeai.core.messages import DEFAULT_LANGUAGE, http_error, resolve_language
17
19
  from latticeai.core.oidc import (
18
20
  OIDCValidationError,
@@ -56,6 +58,10 @@ class _SSOLoginState:
56
58
  nonce: str
57
59
  code_verifier: str
58
60
  invite_authorized: bool
61
+ #: Exactly what was sent to ``authorize``. The token exchange must repeat
62
+ #: it byte for byte or the provider rejects the code, so it is carried
63
+ #: rather than recomputed from a second request that may differ.
64
+ redirect_uri: str = ""
59
65
 
60
66
 
61
67
  # The nonce and PKCE verifier bind the callback/token to this login attempt.
@@ -88,6 +94,7 @@ def create_auth_router(
88
94
  ensure_identity: Optional[Callable[[str, Dict], None]] = None,
89
95
  invite_gate_enabled: bool = False,
90
96
  invite_authorized: Optional[Callable[[Request], bool]] = None,
97
+ default_redirect_uri: Optional[str] = None,
91
98
  verify_id_token: Callable[..., Dict] = _default_verify_id_token,
92
99
  fetch_jwks: Callable[[str], Awaitable[Dict]] = _default_fetch_jwks,
93
100
  ) -> APIRouter:
@@ -169,6 +176,20 @@ def create_auth_router(
169
176
  async def sso_config_endpoint():
170
177
  return public_sso_config()
171
178
 
179
+ def _resolve_redirect_uri(request: Request, settings: Dict) -> str:
180
+ """Where the provider should send the browser back to.
181
+
182
+ A configured URI is registered with the provider and is used verbatim.
183
+ Only the *built-in default* — which names the worker's own port, and is
184
+ therefore unreachable whenever a gateway fronts it — is replaced by the
185
+ front door this login actually arrived through.
186
+ """
187
+ configured = str(settings.get("redirect_uri") or "")
188
+ if not default_redirect_uri or configured != default_redirect_uri:
189
+ return configured
190
+ origin = request_external_origin(request)
191
+ return f"{origin}{SSO_CALLBACK_PATH}" if origin else configured
192
+
172
193
  @router.get("/auth/sso/login")
173
194
  async def sso_login(request: Request):
174
195
  lang = resolve_language(request)
@@ -193,16 +214,18 @@ def create_auth_router(
193
214
  and invite_authorized(request)
194
215
  )
195
216
  )
217
+ redirect_uri = _resolve_redirect_uri(request, settings)
196
218
  _sso_states[state] = _SSOLoginState(
197
219
  issued_at=time.time(),
198
220
  nonce=nonce,
199
221
  code_verifier=code_verifier,
200
222
  invite_authorized=invite_claim,
223
+ redirect_uri=redirect_uri,
201
224
  )
202
225
  params = urlencode({
203
226
  "client_id": settings["client_id"],
204
227
  "response_type": "code",
205
- "redirect_uri": settings["redirect_uri"],
228
+ "redirect_uri": redirect_uri,
206
229
  "scope": settings.get("scopes") or "openid email profile",
207
230
  "state": state,
208
231
  "nonce": nonce,
@@ -228,7 +251,7 @@ def create_auth_router(
228
251
  r = await c.post(discovery["token_endpoint"], data={
229
252
  "grant_type": "authorization_code",
230
253
  "code": code,
231
- "redirect_uri": settings["redirect_uri"],
254
+ "redirect_uri": entry.redirect_uri or settings["redirect_uri"],
232
255
  "client_id": settings["client_id"],
233
256
  "client_secret": settings["client_secret"],
234
257
  "code_verifier": entry.code_verifier,
@@ -42,7 +42,6 @@ from latticeai.api.chat_helpers import (
42
42
  pair_user_history,
43
43
  single_text_stream,
44
44
  strip_generated_file_content,
45
- workspace_scope_from_request,
46
45
  )
47
46
  from latticeai.api.chat_history import HistoryRouteDependencies, register_history_routes
48
47
  from latticeai.api.chat_hybrid import (
@@ -51,6 +50,7 @@ from latticeai.api.chat_hybrid import (
51
50
  )
52
51
  from latticeai.api.chat_intents import ChatIntentController
53
52
  from latticeai.api.chat_stream import stream_chat
53
+ from latticeai.api.workspace_scope import requested_workspace
54
54
  from latticeai.core.messages import DEFAULT_LANGUAGE, resolve_language, translate
55
55
  from latticeai.core.project_sessions import ProjectSessionStore
56
56
  from latticeai.core.run_store import AgentRunStore
@@ -84,7 +84,7 @@ __all__ = [
84
84
  "is_clear_command",
85
85
  "format_network_status",
86
86
  "strip_generated_file_content",
87
- "workspace_scope_from_request",
87
+ "requested_workspace",
88
88
  "single_text_stream",
89
89
  ]
90
90
 
@@ -272,7 +272,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
272
272
  )
273
273
  effective_email = authenticated_identity(current_user, req.user_email, resolve_language(request))
274
274
  workspace_id = write_workspace(
275
- workspace_scope_from_request(request),
275
+ requested_workspace(request),
276
276
  current_user,
277
277
  )
278
278
  history_user = chat_service.history_user(
@@ -22,12 +22,9 @@ from latticeai.api.chat_contracts import (
22
22
  AgentRequest,
23
23
  AgentResumeRequest,
24
24
  )
25
- from latticeai.api.chat_helpers import (
26
- _LANG_HINT,
27
- detect_language,
28
- workspace_scope_from_request,
29
- )
25
+ from latticeai.api.chat_helpers import _LANG_HINT, detect_language
30
26
  from latticeai.api.chat_stream import agent_live_stream
27
+ from latticeai.api.workspace_scope import requested_workspace
31
28
  from latticeai.core.agent import AgentRunContext, AgentState, normalize_plan
32
29
  from latticeai.core.messages import resolve_language
33
30
  from latticeai.core.quiet import quiet
@@ -268,14 +265,8 @@ class AgentHTTPController:
268
265
  current_user = self.require_user(request)
269
266
  self.enforce_rate_limit(current_user, "agent")
270
267
  effective_email = self.authenticated_identity(current_user, req.user_email, resolve_language(request))
271
- header_workspace = workspace_scope_from_request(request)
272
- if req.workspace_id and header_workspace and req.workspace_id != header_workspace:
273
- raise HTTPException(
274
- status_code=403,
275
- detail="workspace_id must match X-Workspace-Id.",
276
- )
277
268
  req.workspace_id = self.write_workspace(
278
- req.workspace_id or header_workspace,
269
+ requested_workspace(request, body_workspace=req.workspace_id),
279
270
  current_user,
280
271
  )
281
272
  req.user_email = effective_email
@@ -1,8 +1,14 @@
1
1
  """Pure chat helpers: language/intent detection, file-action parsing,
2
- network-status formatting, workspace-scope extraction, and recent-context
3
- assembly. Split out of the chat router module so create_chat_router keeps
4
- only request wiring. chat.py re-imports every name, so external import
5
- sites (tests, app_factory) are unaffected.
2
+ network-status formatting, and recent-context assembly. Split out of the chat
3
+ router module so create_chat_router keeps only request wiring. chat.py
4
+ re-imports every name, so external import sites (tests, app_factory) are
5
+ unaffected.
6
+
7
+ Workspace-scope extraction used to live here too, as a verbatim copy of the
8
+ header/query derivation. 11.5.2 deleted it: the callers now use
9
+ :func:`latticeai.api.workspace_scope.requested_workspace`, so a request that
10
+ names two different workspaces is refused instead of having one selector
11
+ silently win.
6
12
  """
7
13
 
8
14
  from __future__ import annotations
@@ -11,8 +17,6 @@ import json
11
17
  import re
12
18
  from typing import Any, AsyncIterator, Dict, List, Optional
13
19
 
14
- from fastapi import Request
15
-
16
20
 
17
21
  def pair_user_history(history: List[Dict], user_email: str) -> List[Dict]:
18
22
  """Restrict history to one user's exchange.
@@ -406,13 +410,6 @@ def assess_answer_grounding(
406
410
  }
407
411
 
408
412
 
409
- def workspace_scope_from_request(request: Request) -> Optional[str]:
410
- header = request.headers.get("X-Workspace-Id")
411
- if header and header.strip():
412
- return header.strip()
413
- query = request.query_params.get("workspace_id")
414
- return query.strip() if query and query.strip() else None
415
-
416
413
  async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[str]:
417
414
  yield f"data: {json.dumps({'chunk': text, 'model': model}, ensure_ascii=False)}\n\n"
418
415
  yield "data: [DONE]\n\n"
@@ -11,7 +11,9 @@ from fastapi.responses import StreamingResponse
11
11
  from pydantic import BaseModel
12
12
 
13
13
  from lattice_brain.runtime.hooks import dispatch_tool
14
+ from latticeai.api.workspace_scope import resolve_workspace_scope
14
15
  from latticeai.core.agent import extract_action as _extract_agent_action
16
+ from latticeai.core.sse import sse_frame
15
17
  from latticeai.services.tool_dispatch import enforce_tool_policy
16
18
  from latticeai.tools import (
17
19
  AGENT_ROOT,
@@ -121,34 +123,24 @@ def create_computer_use_router(
121
123
  ) -> APIRouter:
122
124
  router = APIRouter()
123
125
 
124
- def _requested_workspace(request: Request) -> Optional[str]:
125
- header = request.headers.get("X-Workspace-Id")
126
- if header and header.strip():
127
- return header.strip()
128
- query = request.query_params.get("workspace_id")
129
- return query.strip() if query and query.strip() else None
130
-
131
126
  def _write_workspace(request: Request, current_user: str) -> Optional[str]:
132
127
  """Resolve only the CU agent's durable write scope.
133
128
 
134
129
  The other ``/cu`` actions remain host operations and do not acquire a
135
130
  workspace dependency. A no-auth local request without an explicit
136
- scope preserves its legacy unscoped history behavior; explicit scopes
137
- and every authenticated request go through the normal write gate.
131
+ scope preserves its legacy unscoped history behavior — that is exactly
132
+ what ``allow_unscoped_anonymous`` names in the shared resolver;
133
+ explicit scopes and every authenticated request go through the normal
134
+ write gate.
138
135
  """
139
136
 
140
- requested = _requested_workspace(request)
141
- if workspace_service is None:
142
- return requested
143
- if not current_user and requested is None:
144
- return None
145
- try:
146
- return workspace_service.resolve_write_scope(
147
- requested,
148
- current_user or None,
149
- )
150
- except PermissionError as exc:
151
- raise HTTPException(status_code=403, detail=str(exc)) from exc
137
+ return resolve_workspace_scope(
138
+ request,
139
+ user=current_user,
140
+ workspace_service=workspace_service,
141
+ write=True,
142
+ allow_unscoped_anonymous=True,
143
+ )
152
144
 
153
145
  def _audit_args(name: str, args: dict) -> dict:
154
146
  """Return audit-safe argument metadata without storing typed text."""
@@ -351,17 +343,14 @@ def create_computer_use_router(
351
343
  task_lower = (req.task or "").lower()
352
344
  url_match = re.search(r"(https?://[^\s]+|localhost:\d+[^\s]*|127\.0\.0\.1:\d+[^\s]*)", req.task or "")
353
345
 
354
- def _send(event: str, data: dict) -> str:
355
- return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
356
-
357
346
  if ("chrome" in task_lower or "크롬" in task_lower) and any(
358
347
  word in task_lower for word in ["open", "열", "켜", "실행", "띄"]
359
348
  ):
360
- yield _send("start", {"task": req.task, "max_steps": 1})
349
+ yield sse_frame("start", {"task": req.task, "max_steps": 1})
361
350
  try:
362
351
  if url_match:
363
352
  url = url_match.group(1)
364
- yield _send(
353
+ yield sse_frame(
365
354
  "action",
366
355
  {"step": 1, "action": "computer_open_url", "args": {"url": url, "app": "Google Chrome"}},
367
356
  )
@@ -371,11 +360,11 @@ def create_computer_use_router(
371
360
  lambda: computer_open_url(url, "Google Chrome"),
372
361
  current_user=current_user,
373
362
  )
374
- yield _send("result", {"step": 1, "action": "computer_open_url", "result": result})
363
+ yield sse_frame("result", {"step": 1, "action": "computer_open_url", "result": result})
375
364
  message = f"Google Chrome에서 {url}을 열었습니다."
376
365
  action_name = "computer_open_url"
377
366
  else:
378
- yield _send(
367
+ yield sse_frame(
379
368
  "action",
380
369
  {"step": 1, "action": "computer_open_app", "args": {"app": "Google Chrome"}},
381
370
  )
@@ -385,26 +374,26 @@ def create_computer_use_router(
385
374
  lambda: computer_open_app("Google Chrome"),
386
375
  current_user=current_user,
387
376
  )
388
- yield _send("result", {"step": 1, "action": "computer_open_app", "result": result})
377
+ yield sse_frame("result", {"step": 1, "action": "computer_open_app", "result": result})
389
378
  message = "Google Chrome을 열었습니다."
390
379
  action_name = "computer_open_app"
391
380
  _save_completed("user", req.task)
392
381
  _save_completed("assistant", message)
393
- yield _send("final", {"message": message, "steps": [{"step": 1, "action": action_name, "result": result}]})
382
+ yield sse_frame("final", {"message": message, "steps": [{"step": 1, "action": action_name, "result": result}]})
394
383
  except HTTPException as exc:
395
- yield _send("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc.detail)})
384
+ yield sse_frame("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc.detail)})
396
385
  except (ToolError, PermissionError) as exc:
397
- yield _send("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc)})
386
+ yield sse_frame("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc)})
398
387
  return
399
388
 
400
389
  if not model_router.current_model_id:
401
- yield _send("error", {"error": "No model loaded."})
390
+ yield sse_frame("error", {"error": "No model loaded."})
402
391
  return
403
392
 
404
393
  transcript: List[Dict[str, Any]] = []
405
394
  last_screenshot_b64: Optional[str] = None
406
395
  max_steps = max(1, min(req.max_steps, 20))
407
- yield _send("start", {"task": req.task, "max_steps": max_steps})
396
+ yield sse_frame("start", {"task": req.task, "max_steps": max_steps})
408
397
 
409
398
  for step in range(max_steps):
410
399
  context = (
@@ -423,7 +412,7 @@ def create_computer_use_router(
423
412
  try:
424
413
  action = _extract_agent_action(str(raw))
425
414
  except ValueError as exc:
426
- yield _send("error", {"step": step + 1, "error": str(exc), "raw": str(raw)})
415
+ yield sse_frame("error", {"step": step + 1, "error": str(exc), "raw": str(raw)})
427
416
  break
428
417
 
429
418
  name = action.get("action")
@@ -432,10 +421,10 @@ def create_computer_use_router(
432
421
  message = action.get("message", "작업을 완료했습니다.")
433
422
  _save_completed("user", req.task)
434
423
  _save_completed("assistant", message)
435
- yield _send("final", {"message": message, "steps": transcript})
424
+ yield sse_frame("final", {"message": message, "steps": transcript})
436
425
  return
437
426
 
438
- yield _send("action", {"step": step + 1, "action": name, "args": args})
427
+ yield sse_frame("action", {"step": step + 1, "action": name, "args": args})
439
428
  try:
440
429
  result = _dispatch(
441
430
  name,
@@ -449,7 +438,7 @@ def create_computer_use_router(
449
438
  result_summary = {k: v for k, v in result.items() if k != "screenshot_b64"}
450
439
  result_summary["screenshot_captured"] = True
451
440
  transcript.append({"step": step + 1, "action": name, "args": args, "result": result_summary})
452
- yield _send(
441
+ yield sse_frame(
453
442
  "screenshot",
454
443
  {
455
444
  "step": step + 1,
@@ -461,17 +450,17 @@ def create_computer_use_router(
461
450
  else:
462
451
  last_screenshot_b64 = None
463
452
  transcript.append({"step": step + 1, "action": name, "args": args, "result": result})
464
- yield _send("result", {"step": step + 1, "action": name, "result": result})
453
+ yield sse_frame("result", {"step": step + 1, "action": name, "result": result})
465
454
  except HTTPException as exc:
466
455
  error_str = str(exc.detail)
467
456
  transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
468
- yield _send("tool_error", {"step": step + 1, "action": name, "error": error_str})
457
+ yield sse_frame("tool_error", {"step": step + 1, "action": name, "error": error_str})
469
458
  except (ToolError, PermissionError, KeyError, TypeError) as exc:
470
459
  error_str = str(exc)
471
460
  transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
472
- yield _send("tool_error", {"step": step + 1, "action": name, "error": error_str})
461
+ yield sse_frame("tool_error", {"step": step + 1, "action": name, "error": error_str})
473
462
 
474
- yield _send("done", {"steps": len(transcript), "transcript": transcript})
463
+ yield sse_frame("done", {"steps": len(transcript), "transcript": transcript})
475
464
 
476
465
  return StreamingResponse(
477
466
  _stream(),
@@ -19,6 +19,7 @@ def create_health_router(
19
19
  engine_status: Callable[[], List[dict]],
20
20
  get_current_user: Callable[[Request], Optional[str]],
21
21
  require_auth: bool,
22
+ externally_reachable: bool,
22
23
  app_version: str,
23
24
  app_mode: str,
24
25
  ) -> APIRouter:
@@ -29,9 +30,28 @@ def create_health_router(
29
30
  if require_auth and not get_current_user(request):
30
31
  raise HTTPException(status_code=401, detail="인증이 필요합니다.")
31
32
 
33
+ def _access_posture() -> dict:
34
+ """The two facts ``trusted_local_owner`` is computed from.
35
+
36
+ Stated on the *unauthenticated* part of ``/health`` on purpose: the Rust
37
+ front door polls this endpoint already, and it gates its own native
38
+ ``/rust/*`` lanes on the answer. Those lanes read the store with no
39
+ credential at all, so without this they had no way to tell an
40
+ optional-auth loopback worker (where that is exactly right) from a
41
+ ``LATTICEAI_REQUIRE_AUTH=true`` one (where it is a hole). Neither field
42
+ is a secret: an unauthenticated caller discovers both by being refused.
43
+ """
44
+ return {
45
+ "require_auth": bool(require_auth),
46
+ "externally_reachable": bool(externally_reachable),
47
+ }
48
+
32
49
  @router.get("/health")
33
50
  async def health(request: Request):
34
- base = svc.health_base(version=app_version, mode=app_mode)
51
+ base = {
52
+ **svc.health_base(version=app_version, mode=app_mode),
53
+ "access": _access_posture(),
54
+ }
35
55
  if not get_current_user(request) and require_auth:
36
56
  return base
37
57
  engines = await asyncio.to_thread(engine_status)