ltcai 8.9.0 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +81 -58
  2. package/auto_setup.py +7 -904
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +221 -238
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  13. package/docs/TRUST_MODEL.md +5 -2
  14. package/docs/WHY_LATTICE.md +15 -10
  15. package/docs/WORKFLOW_DESIGNER.md +22 -0
  16. package/docs/kg-schema.md +13 -2
  17. package/docs/mcp-tools.md +17 -6
  18. package/docs/privacy.md +19 -3
  19. package/docs/public-deploy.md +32 -3
  20. package/docs/security-model.md +46 -11
  21. package/lattice_brain/__init__.py +1 -1
  22. package/lattice_brain/archive.py +4 -14
  23. package/lattice_brain/context.py +66 -9
  24. package/lattice_brain/embeddings.py +38 -2
  25. package/lattice_brain/graph/_kg_common.py +27 -462
  26. package/lattice_brain/graph/_kg_constants.py +243 -0
  27. package/lattice_brain/graph/_kg_fsutil.py +293 -0
  28. package/lattice_brain/graph/discovery.py +0 -948
  29. package/lattice_brain/graph/discovery_index.py +1126 -0
  30. package/lattice_brain/graph/documents.py +44 -9
  31. package/lattice_brain/graph/ingest.py +47 -20
  32. package/lattice_brain/graph/provenance.py +13 -6
  33. package/lattice_brain/graph/retrieval.py +141 -610
  34. package/lattice_brain/graph/retrieval_docgen.py +243 -0
  35. package/lattice_brain/graph/retrieval_vector.py +460 -0
  36. package/lattice_brain/graph/store.py +6 -0
  37. package/lattice_brain/ingestion.py +69 -4
  38. package/lattice_brain/portability.py +29 -23
  39. package/lattice_brain/quality.py +98 -4
  40. package/lattice_brain/runtime/agent_runtime.py +169 -7
  41. package/lattice_brain/runtime/hooks.py +30 -13
  42. package/lattice_brain/runtime/multi_agent.py +27 -8
  43. package/lattice_brain/runtime/statuses.py +10 -0
  44. package/lattice_brain/utils.py +46 -0
  45. package/lattice_brain/workflow.py +1 -5
  46. package/latticeai/__init__.py +1 -1
  47. package/latticeai/api/admin.py +1 -1
  48. package/latticeai/api/agent_registry.py +10 -8
  49. package/latticeai/api/agents.py +22 -3
  50. package/latticeai/api/auth.py +78 -16
  51. package/latticeai/api/browser.py +303 -34
  52. package/latticeai/api/chat.py +355 -952
  53. package/latticeai/api/chat_agent_http.py +356 -0
  54. package/latticeai/api/chat_contracts.py +54 -0
  55. package/latticeai/api/chat_documents.py +256 -0
  56. package/latticeai/api/chat_helpers.py +250 -0
  57. package/latticeai/api/chat_history.py +99 -0
  58. package/latticeai/api/chat_intents.py +302 -0
  59. package/latticeai/api/chat_stream.py +115 -0
  60. package/latticeai/api/computer_use.py +211 -40
  61. package/latticeai/api/health.py +9 -3
  62. package/latticeai/api/hooks.py +17 -6
  63. package/latticeai/api/knowledge_graph.py +78 -14
  64. package/latticeai/api/local_files.py +7 -2
  65. package/latticeai/api/marketplace.py +11 -0
  66. package/latticeai/api/mcp.py +102 -23
  67. package/latticeai/api/models.py +72 -33
  68. package/latticeai/api/network.py +5 -5
  69. package/latticeai/api/permissions.py +70 -29
  70. package/latticeai/api/plugins.py +21 -7
  71. package/latticeai/api/portability.py +5 -5
  72. package/latticeai/api/realtime.py +33 -5
  73. package/latticeai/api/setup.py +2 -2
  74. package/latticeai/api/static_routes.py +123 -24
  75. package/latticeai/api/tools.py +97 -14
  76. package/latticeai/api/workflow_designer.py +37 -0
  77. package/latticeai/api/workspace.py +2 -1
  78. package/latticeai/app_factory.py +185 -405
  79. package/latticeai/core/agent.py +19 -9
  80. package/latticeai/core/agent_registry.py +1 -5
  81. package/latticeai/core/config.py +23 -3
  82. package/latticeai/core/context_builder.py +21 -3
  83. package/latticeai/core/invitations.py +1 -4
  84. package/latticeai/core/io_utils.py +45 -0
  85. package/latticeai/core/legacy_compatibility.py +24 -3
  86. package/latticeai/core/local_embeddings.py +2 -4
  87. package/latticeai/core/marketplace.py +33 -2
  88. package/latticeai/core/mcp_catalog.py +450 -0
  89. package/latticeai/core/mcp_registry.py +2 -441
  90. package/latticeai/core/plugins.py +15 -0
  91. package/latticeai/core/policy.py +1 -1
  92. package/latticeai/core/realtime.py +38 -15
  93. package/latticeai/core/sessions.py +7 -2
  94. package/latticeai/core/timeutil.py +10 -0
  95. package/latticeai/core/tool_registry.py +90 -18
  96. package/latticeai/core/users.py +5 -14
  97. package/latticeai/core/workspace_graph_trace.py +31 -5
  98. package/latticeai/core/workspace_memory.py +3 -1
  99. package/latticeai/core/workspace_os.py +18 -7
  100. package/latticeai/core/workspace_os_utils.py +2 -21
  101. package/latticeai/core/workspace_permissions.py +2 -1
  102. package/latticeai/core/workspace_plugins.py +1 -1
  103. package/latticeai/core/workspace_runs.py +14 -5
  104. package/latticeai/core/workspace_skills.py +1 -1
  105. package/latticeai/core/workspace_snapshots.py +2 -1
  106. package/latticeai/core/workspace_timeline.py +2 -1
  107. package/latticeai/integrations/telegram_bot.py +96 -40
  108. package/latticeai/models/model_providers.py +111 -0
  109. package/latticeai/models/router.py +189 -173
  110. package/latticeai/runtime/access_runtime.py +62 -4
  111. package/latticeai/runtime/audit_runtime.py +27 -16
  112. package/latticeai/runtime/automation_runtime.py +22 -7
  113. package/latticeai/runtime/brain_runtime.py +19 -7
  114. package/latticeai/runtime/chat_wiring.py +2 -0
  115. package/latticeai/runtime/config_runtime.py +58 -26
  116. package/latticeai/runtime/context_runtime.py +16 -4
  117. package/latticeai/runtime/history_runtime.py +163 -0
  118. package/latticeai/runtime/hooks_runtime.py +1 -1
  119. package/latticeai/runtime/model_wiring.py +33 -5
  120. package/latticeai/runtime/namespace_runtime.py +163 -0
  121. package/latticeai/runtime/network_config_runtime.py +56 -0
  122. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  123. package/latticeai/runtime/review_wiring.py +1 -1
  124. package/latticeai/runtime/router_registration.py +34 -1
  125. package/latticeai/runtime/security_runtime.py +110 -13
  126. package/latticeai/runtime/sso_config_runtime.py +128 -0
  127. package/latticeai/runtime/stages.py +27 -0
  128. package/latticeai/runtime/user_key_runtime.py +106 -0
  129. package/latticeai/server_app.py +5 -1
  130. package/latticeai/services/architecture_readiness.py +74 -5
  131. package/latticeai/services/brain_automation.py +3 -26
  132. package/latticeai/services/chat_service.py +205 -15
  133. package/latticeai/services/local_knowledge.py +423 -0
  134. package/latticeai/services/memory_service.py +268 -21
  135. package/latticeai/services/model_engines.py +48 -33
  136. package/latticeai/services/model_errors.py +17 -0
  137. package/latticeai/services/model_loading.py +28 -25
  138. package/latticeai/services/model_runtime.py +228 -162
  139. package/latticeai/services/p_reinforce.py +22 -3
  140. package/latticeai/services/platform_runtime.py +92 -24
  141. package/latticeai/services/product_readiness.py +19 -17
  142. package/latticeai/services/review_queue.py +76 -11
  143. package/latticeai/services/router_context.py +1 -0
  144. package/latticeai/services/run_executor.py +25 -9
  145. package/latticeai/services/search_service.py +203 -28
  146. package/latticeai/services/setup_detection.py +80 -0
  147. package/latticeai/services/tool_dispatch.py +28 -5
  148. package/latticeai/services/triggers.py +53 -4
  149. package/latticeai/services/upload_service.py +13 -2
  150. package/latticeai/setup/__init__.py +25 -0
  151. package/latticeai/setup/auto_setup.py +857 -0
  152. package/latticeai/setup/wizard.py +1264 -0
  153. package/latticeai/tools/__init__.py +278 -0
  154. package/{tools → latticeai/tools}/commands.py +67 -7
  155. package/{tools → latticeai/tools}/computer.py +1 -1
  156. package/{tools → latticeai/tools}/documents.py +1 -1
  157. package/{tools → latticeai/tools}/filesystem.py +3 -3
  158. package/latticeai/tools/knowledge.py +178 -0
  159. package/{tools → latticeai/tools}/local_files.py +7 -1
  160. package/{tools → latticeai/tools}/network.py +0 -1
  161. package/local_knowledge_api.py +4 -342
  162. package/package.json +22 -2
  163. package/scripts/brain_quality_eval.py +3 -1
  164. package/scripts/bump_version.py +3 -0
  165. package/scripts/capture_release_evidence.mjs +180 -0
  166. package/scripts/check_current_release_docs.mjs +142 -0
  167. package/scripts/check_i18n_literals.mjs +107 -31
  168. package/scripts/check_openapi_drift.mjs +56 -0
  169. package/scripts/export_openapi.py +67 -7
  170. package/scripts/i18n_literal_allowlist.json +22 -24
  171. package/scripts/run_integration_tests.mjs +72 -10
  172. package/scripts/validate_release_artifacts.py +49 -7
  173. package/scripts/wheel_smoke.py +5 -0
  174. package/setup_wizard.py +3 -1304
  175. package/src-tauri/Cargo.lock +1 -1
  176. package/src-tauri/Cargo.toml +1 -1
  177. package/src-tauri/tauri.conf.json +1 -1
  178. package/static/app/asset-manifest.json +11 -11
  179. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  180. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  181. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  182. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  183. package/static/app/assets/System-80lHW0Ux.js +1 -0
  184. package/static/app/assets/index-BiMofBTM.js +17 -0
  185. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  186. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  187. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  188. package/static/app/index.html +2 -2
  189. package/static/css/tokens.css +4 -2
  190. package/static/sw.js +1 -1
  191. package/tools/__init__.py +21 -269
  192. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  193. package/latticeai/runtime/app_context_runtime.py +0 -13
  194. package/latticeai/runtime/sso_runtime.py +0 -52
  195. package/latticeai/runtime/tail_wiring.py +0 -21
  196. package/scripts/com.pts.claudecode.discord.plist +0 -31
  197. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  198. package/scripts/start-pts-claudecode-discord.sh +0 -51
  199. package/static/app/assets/Act-fZokUnC0.js +0 -1
  200. package/static/app/assets/Brain-DtyuWubr.js +0 -321
  201. package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
  202. package/static/app/assets/Library-C9kyFkSt.js +0 -1
  203. package/static/app/assets/System-VbChmX7r.js +0 -1
  204. package/static/app/assets/index-DCh5AoXt.css +0 -2
  205. package/static/app/assets/index-DPdcPoF0.js +0 -17
  206. package/static/app/assets/primitives-DFeanEV6.js +0 -1
  207. package/static/app/assets/textarea-CD8UNKIy.js +0 -1
  208. package/tools/knowledge.py +0 -95
@@ -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,