ltcai 9.0.0 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +80 -46
  2. package/auto_setup.py +7 -853
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +146 -2
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/TRUST_MODEL.md +5 -2
  13. package/docs/WHY_LATTICE.md +15 -10
  14. package/docs/WORKFLOW_DESIGNER.md +22 -0
  15. package/docs/kg-schema.md +13 -2
  16. package/docs/mcp-tools.md +17 -6
  17. package/docs/privacy.md +19 -3
  18. package/docs/public-deploy.md +32 -3
  19. package/docs/security-model.md +46 -11
  20. package/lattice_brain/__init__.py +1 -1
  21. package/lattice_brain/archive.py +1 -6
  22. package/lattice_brain/context.py +66 -9
  23. package/lattice_brain/graph/_kg_fsutil.py +1 -5
  24. package/lattice_brain/graph/discovery_index.py +174 -20
  25. package/lattice_brain/graph/documents.py +44 -9
  26. package/lattice_brain/graph/ingest.py +47 -20
  27. package/lattice_brain/graph/provenance.py +13 -6
  28. package/lattice_brain/graph/retrieval.py +140 -39
  29. package/lattice_brain/graph/retrieval_docgen.py +37 -4
  30. package/lattice_brain/ingestion.py +4 -7
  31. package/lattice_brain/portability.py +28 -14
  32. package/lattice_brain/runtime/agent_runtime.py +5 -9
  33. package/lattice_brain/runtime/hooks.py +30 -13
  34. package/lattice_brain/runtime/multi_agent.py +27 -8
  35. package/lattice_brain/runtime/statuses.py +10 -0
  36. package/lattice_brain/utils.py +20 -2
  37. package/lattice_brain/workflow.py +1 -5
  38. package/latticeai/__init__.py +1 -1
  39. package/latticeai/api/admin.py +1 -1
  40. package/latticeai/api/agent_registry.py +10 -8
  41. package/latticeai/api/agents.py +22 -3
  42. package/latticeai/api/auth.py +78 -16
  43. package/latticeai/api/browser.py +303 -34
  44. package/latticeai/api/chat.py +320 -850
  45. package/latticeai/api/chat_agent_http.py +356 -0
  46. package/latticeai/api/chat_contracts.py +54 -0
  47. package/latticeai/api/chat_documents.py +256 -0
  48. package/latticeai/api/chat_helpers.py +24 -1
  49. package/latticeai/api/chat_history.py +99 -0
  50. package/latticeai/api/chat_intents.py +302 -0
  51. package/latticeai/api/chat_stream.py +115 -0
  52. package/latticeai/api/computer_use.py +62 -9
  53. package/latticeai/api/health.py +9 -3
  54. package/latticeai/api/hooks.py +17 -6
  55. package/latticeai/api/knowledge_graph.py +78 -14
  56. package/latticeai/api/local_files.py +7 -2
  57. package/latticeai/api/mcp.py +102 -23
  58. package/latticeai/api/models.py +72 -33
  59. package/latticeai/api/network.py +5 -5
  60. package/latticeai/api/permissions.py +67 -26
  61. package/latticeai/api/plugins.py +21 -7
  62. package/latticeai/api/portability.py +5 -5
  63. package/latticeai/api/realtime.py +33 -5
  64. package/latticeai/api/setup.py +2 -2
  65. package/latticeai/api/static_routes.py +123 -24
  66. package/latticeai/api/tools.py +96 -14
  67. package/latticeai/api/workflow_designer.py +37 -0
  68. package/latticeai/api/workspace.py +2 -1
  69. package/latticeai/app_factory.py +110 -52
  70. package/latticeai/core/agent.py +19 -9
  71. package/latticeai/core/agent_registry.py +1 -5
  72. package/latticeai/core/config.py +23 -3
  73. package/latticeai/core/context_builder.py +21 -3
  74. package/latticeai/core/invitations.py +1 -4
  75. package/latticeai/core/io_utils.py +9 -1
  76. package/latticeai/core/legacy_compatibility.py +24 -3
  77. package/latticeai/core/marketplace.py +1 -1
  78. package/latticeai/core/plugins.py +15 -0
  79. package/latticeai/core/policy.py +1 -1
  80. package/latticeai/core/realtime.py +38 -15
  81. package/latticeai/core/sessions.py +7 -2
  82. package/latticeai/core/timeutil.py +10 -0
  83. package/latticeai/core/tool_registry.py +90 -18
  84. package/latticeai/core/users.py +1 -5
  85. package/latticeai/core/workspace_graph_trace.py +31 -5
  86. package/latticeai/core/workspace_memory.py +3 -1
  87. package/latticeai/core/workspace_os.py +18 -7
  88. package/latticeai/core/workspace_os_utils.py +0 -5
  89. package/latticeai/core/workspace_permissions.py +2 -1
  90. package/latticeai/core/workspace_plugins.py +1 -1
  91. package/latticeai/core/workspace_runs.py +14 -5
  92. package/latticeai/core/workspace_skills.py +1 -1
  93. package/latticeai/core/workspace_snapshots.py +2 -1
  94. package/latticeai/core/workspace_timeline.py +2 -1
  95. package/latticeai/integrations/telegram_bot.py +94 -43
  96. package/latticeai/models/router.py +130 -36
  97. package/latticeai/runtime/access_runtime.py +62 -4
  98. package/latticeai/runtime/automation_runtime.py +13 -7
  99. package/latticeai/runtime/brain_runtime.py +19 -7
  100. package/latticeai/runtime/chat_wiring.py +2 -0
  101. package/latticeai/runtime/config_runtime.py +58 -26
  102. package/latticeai/runtime/context_runtime.py +16 -4
  103. package/latticeai/runtime/hooks_runtime.py +1 -1
  104. package/latticeai/runtime/model_wiring.py +33 -5
  105. package/latticeai/runtime/namespace_runtime.py +62 -72
  106. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  107. package/latticeai/runtime/review_wiring.py +1 -1
  108. package/latticeai/runtime/router_registration.py +34 -1
  109. package/latticeai/runtime/security_runtime.py +110 -13
  110. package/latticeai/runtime/stages.py +27 -0
  111. package/latticeai/server_app.py +5 -1
  112. package/latticeai/services/architecture_readiness.py +74 -5
  113. package/latticeai/services/brain_automation.py +3 -26
  114. package/latticeai/services/chat_service.py +205 -15
  115. package/latticeai/services/local_knowledge.py +423 -0
  116. package/latticeai/services/memory_service.py +55 -21
  117. package/latticeai/services/model_engines.py +48 -33
  118. package/latticeai/services/model_errors.py +17 -0
  119. package/latticeai/services/model_loading.py +28 -25
  120. package/latticeai/services/model_runtime.py +228 -162
  121. package/latticeai/services/p_reinforce.py +22 -3
  122. package/latticeai/services/platform_runtime.py +83 -23
  123. package/latticeai/services/product_readiness.py +19 -17
  124. package/latticeai/services/review_queue.py +12 -0
  125. package/latticeai/services/router_context.py +1 -0
  126. package/latticeai/services/run_executor.py +4 -9
  127. package/latticeai/services/search_service.py +203 -28
  128. package/latticeai/services/tool_dispatch.py +28 -5
  129. package/latticeai/services/triggers.py +53 -4
  130. package/latticeai/services/upload_service.py +13 -2
  131. package/latticeai/setup/__init__.py +25 -0
  132. package/latticeai/setup/auto_setup.py +857 -0
  133. package/latticeai/setup/wizard.py +1264 -0
  134. package/latticeai/tools/__init__.py +278 -0
  135. package/{tools → latticeai/tools}/commands.py +67 -7
  136. package/{tools → latticeai/tools}/computer.py +1 -1
  137. package/{tools → latticeai/tools}/documents.py +1 -1
  138. package/{tools → latticeai/tools}/filesystem.py +3 -3
  139. package/latticeai/tools/knowledge.py +178 -0
  140. package/{tools → latticeai/tools}/local_files.py +1 -1
  141. package/{tools → latticeai/tools}/network.py +0 -1
  142. package/local_knowledge_api.py +4 -342
  143. package/package.json +22 -2
  144. package/scripts/brain_quality_eval.py +3 -1
  145. package/scripts/bump_version.py +3 -0
  146. package/scripts/capture_release_evidence.mjs +180 -0
  147. package/scripts/check_current_release_docs.mjs +142 -0
  148. package/scripts/check_i18n_literals.mjs +107 -31
  149. package/scripts/check_openapi_drift.mjs +56 -0
  150. package/scripts/export_openapi.py +67 -7
  151. package/scripts/i18n_literal_allowlist.json +22 -24
  152. package/scripts/run_integration_tests.mjs +72 -10
  153. package/scripts/validate_release_artifacts.py +49 -7
  154. package/scripts/wheel_smoke.py +5 -0
  155. package/setup_wizard.py +3 -1260
  156. package/src-tauri/Cargo.lock +1 -1
  157. package/src-tauri/Cargo.toml +1 -1
  158. package/src-tauri/tauri.conf.json +1 -1
  159. package/static/app/asset-manifest.json +11 -11
  160. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  161. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  162. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  163. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  164. package/static/app/assets/System-80lHW0Ux.js +1 -0
  165. package/static/app/assets/index-BiMofBTM.js +17 -0
  166. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  167. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  168. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  169. package/static/app/index.html +2 -2
  170. package/static/sw.js +1 -1
  171. package/tools/__init__.py +21 -269
  172. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  173. package/latticeai/runtime/app_context_runtime.py +0 -13
  174. package/latticeai/runtime/tail_wiring.py +0 -21
  175. package/scripts/com.pts.claudecode.discord.plist +0 -31
  176. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  177. package/scripts/start-pts-claudecode-discord.sh +0 -51
  178. package/static/app/assets/Act-21lIXx2E.js +0 -1
  179. package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
  180. package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
  181. package/static/app/assets/Library-bFMtyni3.js +0 -1
  182. package/static/app/assets/System-K6krGCqn.js +0 -1
  183. package/static/app/assets/index-C4R3ws30.js +0 -17
  184. package/static/app/assets/index-ChSeOB02.css +0 -2
  185. package/static/app/assets/primitives-sQU3it5I.js +0 -1
  186. package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
  187. package/tools/knowledge.py +0 -95
@@ -5,7 +5,8 @@ import hashlib
5
5
  import logging
6
6
  import secrets
7
7
  import time
8
- from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
8
+ from dataclasses import dataclass
9
+ from typing import Any, Awaitable, Callable, Dict, Optional
9
10
  from urllib.parse import urlencode
10
11
 
11
12
  from fastapi import APIRouter, HTTPException, Request
@@ -42,9 +43,20 @@ class UpdateProfileRequest(BaseModel):
42
43
  nickname: Optional[str] = None
43
44
 
44
45
 
45
- # state → (issued_at, nonce). The nonce binds the eventual ID token to *this*
46
- # login attempt (replay / token-injection defence); the timestamp expires it.
47
- _sso_states: Dict[str, Tuple[float, str]] = {}
46
+ @dataclass(frozen=True)
47
+ class _SSOLoginState:
48
+ """Server-side SSO transaction state consumed exactly once."""
49
+
50
+ issued_at: float
51
+ nonce: str
52
+ code_verifier: str
53
+ invite_authorized: bool
54
+
55
+
56
+ # The nonce and PKCE verifier bind the callback/token to this login attempt.
57
+ # The signed invite cookie is verified once at login and reduced to a
58
+ # server-side claim, so the callback never trusts a client-supplied invite bit.
59
+ _sso_states: Dict[str, _SSOLoginState] = {}
48
60
 
49
61
 
50
62
  def create_auth_router(
@@ -67,7 +79,10 @@ def create_auth_router(
67
79
  open_registration: bool,
68
80
  session_ttl: int,
69
81
  require_auth: bool = True,
82
+ secure_cookies: bool = False,
70
83
  ensure_identity: Optional[Callable[[str, Dict], None]] = None,
84
+ invite_gate_enabled: bool = False,
85
+ invite_authorized: Optional[Callable[[Request], bool]] = None,
71
86
  verify_id_token: Callable[..., Dict] = _default_verify_id_token,
72
87
  fetch_jwks: Callable[[str], Awaitable[Dict]] = _default_fetch_jwks,
73
88
  ) -> APIRouter:
@@ -86,7 +101,19 @@ def create_auth_router(
86
101
  @router.post("/register")
87
102
  async def register(req: UserRegister, request: Request):
88
103
  check_ip_rate_limit(client_ip(request), "register", max_calls=5, window_secs=3600)
89
- if not open_registration:
104
+ invite_claim = bool(
105
+ invite_gate_enabled
106
+ and invite_authorized is not None
107
+ and invite_authorized(request)
108
+ )
109
+ if invite_gate_enabled and not invite_claim:
110
+ raise HTTPException(
111
+ status_code=403,
112
+ detail="유효한 서명 초대 권한이 필요합니다.",
113
+ )
114
+ # Closed public registration stays closed unless the operator enabled
115
+ # the invite gate and this exact request carries a valid signed claim.
116
+ if not open_registration and not invite_claim:
90
117
  raise HTTPException(status_code=403, detail="회원가입이 비활성화되어 있습니다. 관리자에게 문의하세요.")
91
118
  _enforce_password_policy(req.password)
92
119
  email = normalize_email(req.email)
@@ -127,7 +154,14 @@ def create_auth_router(
127
154
  "role": role,
128
155
  "is_admin": role == "admin",
129
156
  })
130
- response.set_cookie(key="session_token", value=token, httponly=True, samesite="lax", max_age=session_ttl)
157
+ response.set_cookie(
158
+ key="session_token",
159
+ value=token,
160
+ httponly=True,
161
+ secure=secure_cookies,
162
+ samesite="lax",
163
+ max_age=session_ttl,
164
+ )
131
165
  return response
132
166
 
133
167
  @router.get("/auth/sso/config")
@@ -135,7 +169,7 @@ def create_auth_router(
135
169
  return public_sso_config()
136
170
 
137
171
  @router.get("/auth/sso/login")
138
- async def sso_login():
172
+ async def sso_login(request: Request):
139
173
  settings = get_sso_settings()
140
174
  discovery = await get_sso_discovery()
141
175
  if not settings.get("enabled") or not discovery:
@@ -150,7 +184,19 @@ def create_auth_router(
150
184
  .rstrip(b"=")
151
185
  .decode("ascii")
152
186
  )
153
- _sso_states[state] = (time.time(), nonce, code_verifier)
187
+ invite_claim = bool(
188
+ not invite_gate_enabled
189
+ or (
190
+ invite_authorized is not None
191
+ and invite_authorized(request)
192
+ )
193
+ )
194
+ _sso_states[state] = _SSOLoginState(
195
+ issued_at=time.time(),
196
+ nonce=nonce,
197
+ code_verifier=code_verifier,
198
+ invite_authorized=invite_claim,
199
+ )
154
200
  params = urlencode({
155
201
  "client_id": settings["client_id"],
156
202
  "response_type": "code",
@@ -165,12 +211,11 @@ def create_auth_router(
165
211
 
166
212
  @router.get("/auth/sso/callback")
167
213
  async def sso_callback(code: str = "", state: str = "", error: str = ""):
168
- if error:
169
- return RedirectResponse(f"/?sso_error={error}")
170
214
  entry = _sso_states.pop(state, None)
171
- if entry is None or time.time() - entry[0] > 300:
215
+ if entry is None or time.time() - entry.issued_at > 300:
172
216
  raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
173
- _, nonce, code_verifier = entry
217
+ if error:
218
+ return RedirectResponse(f"/?{urlencode({'sso_error': error})}")
174
219
  settings = get_sso_settings()
175
220
  discovery = await get_sso_discovery()
176
221
  if not settings.get("enabled") or not discovery:
@@ -183,7 +228,7 @@ def create_auth_router(
183
228
  "redirect_uri": settings["redirect_uri"],
184
229
  "client_id": settings["client_id"],
185
230
  "client_secret": settings["client_secret"],
186
- "code_verifier": code_verifier,
231
+ "code_verifier": entry.code_verifier,
187
232
  }, headers={"Accept": "application/json"}, timeout=15)
188
233
  tokens = r.json()
189
234
  id_token = tokens.get("id_token")
@@ -200,7 +245,7 @@ def create_auth_router(
200
245
  jwks=jwks,
201
246
  issuer=issuer,
202
247
  audience=settings["client_id"],
203
- nonce=nonce,
248
+ nonce=entry.nonce,
204
249
  )
205
250
  except OIDCValidationError as exc:
206
251
  logging.warning("SSO ID token rejected: %s", exc)
@@ -213,6 +258,11 @@ def create_auth_router(
213
258
  raise HTTPException(status_code=400, detail="이메일을 확인할 수 없습니다.")
214
259
  users = load_users()
215
260
  if email not in users:
261
+ if invite_gate_enabled and not entry.invite_authorized:
262
+ raise HTTPException(
263
+ status_code=403,
264
+ detail="신규 SSO 계정에는 유효한 서명 초대 권한이 필요합니다.",
265
+ )
216
266
  is_first = len(users) == 0
217
267
  users[email] = {
218
268
  "password": "",
@@ -229,7 +279,14 @@ def create_auth_router(
229
279
  raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
230
280
  token = create_session(email)
231
281
  resp = RedirectResponse("/app", status_code=302)
232
- resp.set_cookie("session_token", token, httponly=True, samesite="lax", max_age=session_ttl)
282
+ resp.set_cookie(
283
+ "session_token",
284
+ token,
285
+ httponly=True,
286
+ secure=secure_cookies,
287
+ samesite="lax",
288
+ max_age=session_ttl,
289
+ )
233
290
  return resp
234
291
 
235
292
  @router.post("/logout")
@@ -238,7 +295,12 @@ def create_auth_router(
238
295
  if token:
239
296
  invalidate_session(token)
240
297
  response = JSONResponse(content={"status": "ok"})
241
- response.delete_cookie("session_token")
298
+ response.delete_cookie(
299
+ "session_token",
300
+ secure=secure_cookies,
301
+ httponly=True,
302
+ samesite="lax",
303
+ )
242
304
  return response
243
305
 
244
306
  @router.post("/account/change-password")
@@ -17,18 +17,31 @@ Two layers, both feeding ``IngestionPipeline.ingest``:
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import ipaddress
21
+ import socket
20
22
  from html.parser import HTMLParser
21
23
  from typing import Any, Callable, Optional, Tuple
22
- from urllib.parse import urlparse
24
+ from urllib.parse import SplitResult, urljoin, urlsplit, urlunsplit
23
25
 
24
26
  from fastapi import APIRouter, HTTPException, Request
25
27
  from pydantic import BaseModel
26
28
 
29
+ from latticeai import __version__
27
30
  from lattice_brain.ingestion import IngestionItem
28
31
 
29
32
  MAX_TAB_BYTES = 4 * 1024 * 1024 # 4 MB per captured tab payload
30
33
  MAX_URL_FETCH_BYTES = 4 * 1024 * 1024 # 4 MB cap on a fetched page
31
34
  URL_FETCH_TIMEOUT = 12.0 # seconds
35
+ MAX_URL_LENGTH = 8192
36
+ MAX_URL_REDIRECTS = 5
37
+
38
+ _REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
39
+ _TEXTUAL_APPLICATION_TYPES = frozenset({
40
+ "application/json",
41
+ "application/ld+json",
42
+ "application/xhtml+xml",
43
+ "application/xml",
44
+ })
32
45
 
33
46
 
34
47
  class BrowserFetchError(Exception):
@@ -86,7 +99,157 @@ def extract_readable_text(html: str) -> Tuple[str, str]:
86
99
  return parser.title.strip(), parser.text()
87
100
 
88
101
 
89
- def _default_fetch_url(url: str) -> Tuple[str, str]:
102
+ def _parse_http_url(url: str) -> tuple[str, SplitResult, str, int]:
103
+ """Parse a URL into safe, unambiguous HTTP connection components."""
104
+ cleaned = (url or "").strip()
105
+ if not cleaned:
106
+ raise ValueError("url is required.")
107
+ if len(cleaned) > MAX_URL_LENGTH:
108
+ raise ValueError("URL is too long.")
109
+ if "\\" in cleaned or any(ord(char) < 32 or ord(char) == 127 for char in cleaned):
110
+ raise ValueError("Malformed URL.")
111
+
112
+ try:
113
+ parsed = urlsplit(cleaned)
114
+ hostname = parsed.hostname
115
+ port = parsed.port
116
+ except ValueError as exc:
117
+ raise ValueError("Malformed URL.") from exc
118
+ if parsed.scheme.lower() not in ("http", "https"):
119
+ raise ValueError("Only http(s) URLs are supported.")
120
+ if not parsed.netloc or not hostname:
121
+ raise ValueError("Malformed URL.")
122
+ if parsed.username is not None or parsed.password is not None:
123
+ raise ValueError("URLs containing credentials are not supported.")
124
+ if "%" in hostname:
125
+ # Scoped IPv6 literals are interface-local by definition and also have
126
+ # inconsistent URL parser semantics across HTTP clients.
127
+ raise ValueError("Scoped IP addresses are not supported.")
128
+
129
+ hostname = hostname.rstrip(".")
130
+ if not hostname:
131
+ raise ValueError("Malformed URL.")
132
+ try:
133
+ ascii_hostname = hostname.encode("idna").decode("ascii").lower()
134
+ except UnicodeError as exc:
135
+ raise ValueError("Malformed URL hostname.") from exc
136
+ if len(ascii_hostname) > 253:
137
+ raise ValueError("Malformed URL hostname.")
138
+
139
+ port = port if port is not None else (443 if parsed.scheme.lower() == "https" else 80)
140
+ if port < 1 or port > 65535:
141
+ raise ValueError("Malformed URL port.")
142
+ return cleaned, parsed, ascii_hostname, port
143
+
144
+
145
+ def _is_public_ip(value: str) -> bool:
146
+ """Return whether *value* is globally routable (IPv4 or IPv6)."""
147
+ try:
148
+ address = ipaddress.ip_address(value)
149
+ except ValueError:
150
+ return False
151
+ # Some Python releases report multicast addresses as ``is_global``. Keep
152
+ # the security classes explicit instead of relying on that single flag.
153
+ return address.is_global and not any((
154
+ address.is_loopback,
155
+ address.is_private,
156
+ address.is_link_local,
157
+ address.is_multicast,
158
+ address.is_unspecified,
159
+ address.is_reserved,
160
+ getattr(address, "is_site_local", False),
161
+ ))
162
+
163
+
164
+ def _resolve_public_target(
165
+ url: str,
166
+ *,
167
+ resolver: Optional[Callable[..., Any]] = None,
168
+ ) -> tuple[str, SplitResult, str, int, tuple[str, ...]]:
169
+ """Resolve a URL and reject every non-public address before connecting."""
170
+ try:
171
+ cleaned, parsed, hostname, port = _parse_http_url(url)
172
+ except ValueError as exc:
173
+ raise BrowserFetchError(str(exc)) from exc
174
+
175
+ if hostname == "localhost" or hostname.endswith(".localhost"):
176
+ raise BrowserFetchError("Local and private network URLs are not allowed.")
177
+
178
+ resolve = resolver or socket.getaddrinfo
179
+ try:
180
+ records = resolve(
181
+ hostname,
182
+ port,
183
+ family=socket.AF_UNSPEC,
184
+ type=socket.SOCK_STREAM,
185
+ )
186
+ except (OSError, socket.gaierror) as exc:
187
+ raise BrowserFetchError(f"Could not resolve the page host: {hostname}.") from exc
188
+
189
+ addresses: list[str] = []
190
+ for record in records or ():
191
+ try:
192
+ address = str(record[4][0])
193
+ except (IndexError, TypeError):
194
+ raise BrowserFetchError("The page host returned an invalid DNS record.")
195
+ if not _is_public_ip(address):
196
+ raise BrowserFetchError("Local and private network URLs are not allowed.")
197
+ if address not in addresses:
198
+ addresses.append(address)
199
+ if not addresses:
200
+ raise BrowserFetchError(f"Could not resolve the page host: {hostname}.")
201
+ return cleaned, parsed, hostname, port, tuple(addresses)
202
+
203
+
204
+ def _origin_host_header(hostname: str, port: int, scheme: str) -> str:
205
+ try:
206
+ is_ipv6 = ipaddress.ip_address(hostname).version == 6
207
+ except ValueError:
208
+ is_ipv6 = False
209
+ host = f"[{hostname}]" if is_ipv6 else hostname
210
+ default_port = 443 if scheme == "https" else 80
211
+ return host if port == default_port else f"{host}:{port}"
212
+
213
+
214
+ def _pinned_request_url(parsed: SplitResult, address: str, port: int) -> str:
215
+ """Build a URL that connects to a pre-validated DNS result.
216
+
217
+ The request retains the original Host header and TLS SNI separately. This
218
+ closes the DNS-rebinding window between validation and socket connection.
219
+ """
220
+ ip = ipaddress.ip_address(address)
221
+ host = f"[{ip.compressed}]" if ip.version == 6 else ip.compressed
222
+ default_port = 443 if parsed.scheme.lower() == "https" else 80
223
+ netloc = host if port == default_port else f"{host}:{port}"
224
+ return urlunsplit((parsed.scheme.lower(), netloc, parsed.path or "/", parsed.query, ""))
225
+
226
+
227
+ def _read_limited_response(response: Any, max_bytes: int) -> bytes:
228
+ content_length = response.headers.get("content-length")
229
+ if content_length:
230
+ try:
231
+ declared_size = int(content_length)
232
+ except ValueError:
233
+ declared_size = -1
234
+ if declared_size > max_bytes:
235
+ raise BrowserFetchError("The page is too large to ingest.")
236
+
237
+ body = bytearray()
238
+ for chunk in response.iter_bytes():
239
+ if len(body) + len(chunk) > max_bytes:
240
+ raise BrowserFetchError("The page is too large to ingest.")
241
+ body.extend(chunk)
242
+ return bytes(body)
243
+
244
+
245
+ def _default_fetch_url(
246
+ url: str,
247
+ *,
248
+ resolver: Optional[Callable[..., Any]] = None,
249
+ transport: Any = None,
250
+ max_bytes: int = MAX_URL_FETCH_BYTES,
251
+ max_redirects: int = MAX_URL_REDIRECTS,
252
+ ) -> Tuple[str, str]:
90
253
  """Fetch a public URL on the local runtime and extract readable text.
91
254
 
92
255
  Raises :class:`BrowserFetchError` on any non-success (blocked, login wall,
@@ -94,39 +257,113 @@ def _default_fetch_url(url: str) -> Tuple[str, str]:
94
257
  """
95
258
  import httpx
96
259
 
260
+ if max_bytes < 1 or max_redirects < 0:
261
+ raise BrowserFetchError("Invalid URL fetch limits.")
262
+
263
+ client_options: dict[str, Any] = {
264
+ "follow_redirects": False,
265
+ "timeout": URL_FETCH_TIMEOUT,
266
+ "trust_env": False,
267
+ "limits": httpx.Limits(max_keepalive_connections=0),
268
+ }
269
+ if transport is not None:
270
+ client_options["transport"] = transport
271
+
272
+ current_url = url
97
273
  try:
98
- with httpx.Client(
99
- follow_redirects=True, timeout=URL_FETCH_TIMEOUT,
100
- headers={"User-Agent": "LatticeAI-local/3.6 (+local-first knowledge graph)"},
101
- ) as client:
102
- resp = client.get(url)
274
+ with httpx.Client(**client_options) as client:
275
+ for redirect_count in range(max_redirects + 1):
276
+ current_url, parsed, hostname, port, addresses = _resolve_public_target(
277
+ current_url,
278
+ resolver=resolver,
279
+ )
280
+ next_url: Optional[str] = None
281
+ connect_errors: list[Exception] = []
282
+
283
+ for address in addresses:
284
+ request = client.build_request(
285
+ "GET",
286
+ _pinned_request_url(parsed, address, port),
287
+ headers={
288
+ "Accept": "text/html, text/plain;q=0.9, application/xhtml+xml;q=0.8",
289
+ "Accept-Encoding": "identity",
290
+ "Connection": "close",
291
+ "Host": _origin_host_header(hostname, port, parsed.scheme.lower()),
292
+ "User-Agent": f"LatticeAI-local/{__version__} (+local-first knowledge graph)",
293
+ },
294
+ extensions={"sni_hostname": hostname},
295
+ )
296
+ response = None
297
+ try:
298
+ response = client.send(request, stream=True)
299
+ except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
300
+ connect_errors.append(exc)
301
+ continue
302
+
303
+ try:
304
+ if response.status_code in _REDIRECT_STATUSES:
305
+ location = (response.headers.get("location") or "").strip()
306
+ if not location:
307
+ raise BrowserFetchError("The page returned a redirect without a location.")
308
+ if redirect_count >= max_redirects:
309
+ raise BrowserFetchError("The page redirected too many times.")
310
+ # Join against the original public URL, never the pinned
311
+ # IP URL exposed only to the transport.
312
+ next_url = urljoin(current_url, location)
313
+ break
314
+ if response.status_code in (401, 403):
315
+ raise BrowserFetchError(
316
+ "The page is login-required or blocked "
317
+ f"(HTTP {response.status_code})."
318
+ )
319
+ if response.status_code >= 400:
320
+ raise BrowserFetchError(
321
+ f"The page returned HTTP {response.status_code}."
322
+ )
323
+
324
+ content_type = response.headers.get("content-type", "")
325
+ media_type = content_type.split(";", 1)[0].strip().lower()
326
+ if not (
327
+ media_type.startswith("text/")
328
+ or media_type in _TEXTUAL_APPLICATION_TYPES
329
+ ):
330
+ raise BrowserFetchError(
331
+ f"Unsupported content type: {content_type or 'unknown'}."
332
+ )
333
+
334
+ raw_body = _read_limited_response(response, max_bytes)
335
+ try:
336
+ encoding = response.encoding or "utf-8"
337
+ body = raw_body.decode(encoding, "replace")
338
+ except (LookupError, UnicodeError):
339
+ body = raw_body.decode("utf-8", "replace")
340
+ title, text = extract_readable_text(body)
341
+ return (title or current_url, text)
342
+ finally:
343
+ response.close()
344
+
345
+ if next_url is not None:
346
+ current_url = next_url
347
+ continue
348
+ if connect_errors:
349
+ raise BrowserFetchError(
350
+ f"Could not reach the page: {connect_errors[-1]}"
351
+ ) from connect_errors[-1]
352
+ raise BrowserFetchError("Could not reach the page.")
353
+ except BrowserFetchError:
354
+ raise
103
355
  except httpx.HTTPError as exc:
104
356
  raise BrowserFetchError(f"Could not reach the page: {exc}") from exc
105
357
 
106
- if resp.status_code in (401, 403):
107
- raise BrowserFetchError("The page is login-required or blocked (HTTP %s)." % resp.status_code)
108
- if resp.status_code >= 400:
109
- raise BrowserFetchError(f"The page returned HTTP {resp.status_code}.")
110
- content_type = resp.headers.get("content-type", "")
111
- if content_type and "html" not in content_type and "text" not in content_type:
112
- raise BrowserFetchError(f"Unsupported content type: {content_type or 'unknown'}.")
113
- body = resp.text or ""
114
- if len(body.encode("utf-8", "ignore")) > MAX_URL_FETCH_BYTES:
115
- body = body.encode("utf-8", "ignore")[:MAX_URL_FETCH_BYTES].decode("utf-8", "ignore")
116
- title, text = extract_readable_text(body)
117
- return (title or url, text)
358
+ raise BrowserFetchError("The page redirected too many times.")
118
359
 
119
360
 
120
361
  def _validate_http_url(url: str) -> str:
121
- url = (url or "").strip()
122
- if not url:
123
- raise HTTPException(status_code=400, detail="url is required.")
124
- parsed = urlparse(url)
125
- if parsed.scheme not in ("http", "https"):
126
- raise HTTPException(status_code=400, detail="Only http(s) URLs are supported.")
127
- if not parsed.netloc:
128
- raise HTTPException(status_code=400, detail="Malformed URL.")
129
- return url
362
+ try:
363
+ cleaned, _parsed, _hostname, _port = _parse_http_url(url)
364
+ except ValueError as exc:
365
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
366
+ return cleaned
130
367
 
131
368
 
132
369
  # ── request models ───────────────────────────────────────────────────────────
@@ -149,6 +386,7 @@ def create_browser_router(
149
386
  *,
150
387
  pipeline: Any,
151
388
  require_user: Callable[[Request], str],
389
+ workspace_service: Any = None,
152
390
  fetch_url: Optional[Callable[[str], Tuple[str, str]]] = None,
153
391
  max_tab_bytes: int = MAX_TAB_BYTES,
154
392
  ) -> APIRouter:
@@ -159,11 +397,28 @@ def create_browser_router(
159
397
  if pipeline is None or not pipeline.available():
160
398
  raise HTTPException(status_code=503, detail="Knowledge Graph ingestion is disabled.")
161
399
 
400
+ def _write_workspace(request: Request, body_workspace: Optional[str], user: str) -> Optional[str]:
401
+ header_workspace = request.headers.get("X-Workspace-Id")
402
+ header_workspace = header_workspace.strip() if header_workspace and header_workspace.strip() else None
403
+ query_workspace = request.query_params.get("workspace_id")
404
+ query_workspace = query_workspace.strip() if query_workspace and query_workspace.strip() else None
405
+ supplied = [value for value in (body_workspace, header_workspace, query_workspace) if value]
406
+ if len(set(supplied)) > 1:
407
+ raise HTTPException(status_code=403, detail="Workspace selectors must match.")
408
+ requested = supplied[0] if supplied else None
409
+ if workspace_service is None:
410
+ return requested
411
+ try:
412
+ return workspace_service.resolve_write_scope(requested, user or None)
413
+ except PermissionError as exc:
414
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
415
+
162
416
  @router.post("/api/browser/read-url")
163
417
  async def read_url(req: ReadUrlRequest, request: Request):
164
418
  """Fetch a public URL locally and ingest it as a web_url source."""
165
419
  user = require_user(request)
166
420
  _require_pipeline()
421
+ workspace_id = _write_workspace(request, req.workspace_id, user)
167
422
  url = _validate_http_url(req.url)
168
423
  try:
169
424
  title, text = _fetch(url)
@@ -176,7 +431,7 @@ def create_browser_router(
176
431
  res = pipeline.ingest(
177
432
  IngestionItem(
178
433
  source_type="web_url", title=title, text=text, source_uri=url,
179
- owner=user, workspace_id=req.workspace_id,
434
+ owner=user, workspace_id=workspace_id,
180
435
  ),
181
436
  user_email=user,
182
437
  )
@@ -187,11 +442,25 @@ def create_browser_router(
187
442
  """Ingest a payload captured from the local browser extension."""
188
443
  user = require_user(request)
189
444
  _require_pipeline()
445
+ workspace_id = _write_workspace(request, req.workspace_id, user)
190
446
  url = _validate_http_url(req.url)
191
- # Sanitize: reject an oversized payload before doing any work.
192
- for value in (req.text, req.html, req.selected_text):
193
- if value and len(value.encode("utf-8", "ignore")) > max_tab_bytes:
194
- raise HTTPException(status_code=413, detail="Captured payload is too large.")
447
+ # Bound the entire capture, not merely each field independently. The
448
+ # extension commonly supplies full text, selection, and HTML together.
449
+ captured_bytes = sum(
450
+ len(value.encode("utf-8", "ignore"))
451
+ for value in (
452
+ req.url,
453
+ req.title,
454
+ req.text,
455
+ req.selected_text,
456
+ req.html,
457
+ req.captured_at,
458
+ workspace_id,
459
+ )
460
+ if value
461
+ )
462
+ if captured_bytes > max_tab_bytes:
463
+ raise HTTPException(status_code=413, detail="Captured payload is too large.")
195
464
  text = (req.text or "").strip()
196
465
  if not text and req.html:
197
466
  _title, text = extract_readable_text(req.html)
@@ -207,7 +476,7 @@ def create_browser_router(
207
476
  source_uri=url,
208
477
  captured_at=req.captured_at,
209
478
  owner=user,
210
- workspace_id=req.workspace_id,
479
+ workspace_id=workspace_id,
211
480
  metadata={"has_selection": bool(req.selected_text)},
212
481
  ),
213
482
  user_email=user,