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.
- package/README.md +81 -58
- package/auto_setup.py +7 -904
- package/desktop/electron/README.md +9 -0
- package/desktop/electron/main.cjs +5 -3
- package/docs/CHANGELOG.md +221 -238
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
- package/docs/DEVELOPMENT.md +53 -14
- package/docs/LEGACY_COMPATIBILITY.md +4 -4
- package/docs/ONBOARDING.md +10 -2
- package/docs/OPERATIONS.md +8 -4
- package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
- package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
- package/docs/TRUST_MODEL.md +5 -2
- package/docs/WHY_LATTICE.md +15 -10
- package/docs/WORKFLOW_DESIGNER.md +22 -0
- package/docs/kg-schema.md +13 -2
- package/docs/mcp-tools.md +17 -6
- package/docs/privacy.md +19 -3
- package/docs/public-deploy.md +32 -3
- package/docs/security-model.md +46 -11
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +4 -14
- package/lattice_brain/context.py +66 -9
- package/lattice_brain/embeddings.py +38 -2
- package/lattice_brain/graph/_kg_common.py +27 -462
- package/lattice_brain/graph/_kg_constants.py +243 -0
- package/lattice_brain/graph/_kg_fsutil.py +293 -0
- package/lattice_brain/graph/discovery.py +0 -948
- package/lattice_brain/graph/discovery_index.py +1126 -0
- package/lattice_brain/graph/documents.py +44 -9
- package/lattice_brain/graph/ingest.py +47 -20
- package/lattice_brain/graph/provenance.py +13 -6
- package/lattice_brain/graph/retrieval.py +141 -610
- package/lattice_brain/graph/retrieval_docgen.py +243 -0
- package/lattice_brain/graph/retrieval_vector.py +460 -0
- package/lattice_brain/graph/store.py +6 -0
- package/lattice_brain/ingestion.py +69 -4
- package/lattice_brain/portability.py +29 -23
- package/lattice_brain/quality.py +98 -4
- package/lattice_brain/runtime/agent_runtime.py +169 -7
- package/lattice_brain/runtime/hooks.py +30 -13
- package/lattice_brain/runtime/multi_agent.py +27 -8
- package/lattice_brain/runtime/statuses.py +10 -0
- package/lattice_brain/utils.py +46 -0
- package/lattice_brain/workflow.py +1 -5
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agent_registry.py +10 -8
- package/latticeai/api/agents.py +22 -3
- package/latticeai/api/auth.py +78 -16
- package/latticeai/api/browser.py +303 -34
- package/latticeai/api/chat.py +355 -952
- package/latticeai/api/chat_agent_http.py +356 -0
- package/latticeai/api/chat_contracts.py +54 -0
- package/latticeai/api/chat_documents.py +256 -0
- package/latticeai/api/chat_helpers.py +250 -0
- package/latticeai/api/chat_history.py +99 -0
- package/latticeai/api/chat_intents.py +302 -0
- package/latticeai/api/chat_stream.py +115 -0
- package/latticeai/api/computer_use.py +211 -40
- package/latticeai/api/health.py +9 -3
- package/latticeai/api/hooks.py +17 -6
- package/latticeai/api/knowledge_graph.py +78 -14
- package/latticeai/api/local_files.py +7 -2
- package/latticeai/api/marketplace.py +11 -0
- package/latticeai/api/mcp.py +102 -23
- package/latticeai/api/models.py +72 -33
- package/latticeai/api/network.py +5 -5
- package/latticeai/api/permissions.py +70 -29
- package/latticeai/api/plugins.py +21 -7
- package/latticeai/api/portability.py +5 -5
- package/latticeai/api/realtime.py +33 -5
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +123 -24
- package/latticeai/api/tools.py +97 -14
- package/latticeai/api/workflow_designer.py +37 -0
- package/latticeai/api/workspace.py +2 -1
- package/latticeai/app_factory.py +185 -405
- package/latticeai/core/agent.py +19 -9
- package/latticeai/core/agent_registry.py +1 -5
- package/latticeai/core/config.py +23 -3
- package/latticeai/core/context_builder.py +21 -3
- package/latticeai/core/invitations.py +1 -4
- package/latticeai/core/io_utils.py +45 -0
- package/latticeai/core/legacy_compatibility.py +24 -3
- package/latticeai/core/local_embeddings.py +2 -4
- package/latticeai/core/marketplace.py +33 -2
- package/latticeai/core/mcp_catalog.py +450 -0
- package/latticeai/core/mcp_registry.py +2 -441
- package/latticeai/core/plugins.py +15 -0
- package/latticeai/core/policy.py +1 -1
- package/latticeai/core/realtime.py +38 -15
- package/latticeai/core/sessions.py +7 -2
- package/latticeai/core/timeutil.py +10 -0
- package/latticeai/core/tool_registry.py +90 -18
- package/latticeai/core/users.py +5 -14
- package/latticeai/core/workspace_graph_trace.py +31 -5
- package/latticeai/core/workspace_memory.py +3 -1
- package/latticeai/core/workspace_os.py +18 -7
- package/latticeai/core/workspace_os_utils.py +2 -21
- package/latticeai/core/workspace_permissions.py +2 -1
- package/latticeai/core/workspace_plugins.py +1 -1
- package/latticeai/core/workspace_runs.py +14 -5
- package/latticeai/core/workspace_skills.py +1 -1
- package/latticeai/core/workspace_snapshots.py +2 -1
- package/latticeai/core/workspace_timeline.py +2 -1
- package/latticeai/integrations/telegram_bot.py +96 -40
- package/latticeai/models/model_providers.py +111 -0
- package/latticeai/models/router.py +189 -173
- package/latticeai/runtime/access_runtime.py +62 -4
- package/latticeai/runtime/audit_runtime.py +27 -16
- package/latticeai/runtime/automation_runtime.py +22 -7
- package/latticeai/runtime/brain_runtime.py +19 -7
- package/latticeai/runtime/chat_wiring.py +2 -0
- package/latticeai/runtime/config_runtime.py +58 -26
- package/latticeai/runtime/context_runtime.py +16 -4
- package/latticeai/runtime/history_runtime.py +163 -0
- package/latticeai/runtime/hooks_runtime.py +1 -1
- package/latticeai/runtime/model_wiring.py +33 -5
- package/latticeai/runtime/namespace_runtime.py +163 -0
- package/latticeai/runtime/network_config_runtime.py +56 -0
- package/latticeai/runtime/platform_runtime_wiring.py +4 -3
- package/latticeai/runtime/review_wiring.py +1 -1
- package/latticeai/runtime/router_registration.py +34 -1
- package/latticeai/runtime/security_runtime.py +110 -13
- package/latticeai/runtime/sso_config_runtime.py +128 -0
- package/latticeai/runtime/stages.py +27 -0
- package/latticeai/runtime/user_key_runtime.py +106 -0
- package/latticeai/server_app.py +5 -1
- package/latticeai/services/architecture_readiness.py +74 -5
- package/latticeai/services/brain_automation.py +3 -26
- package/latticeai/services/chat_service.py +205 -15
- package/latticeai/services/local_knowledge.py +423 -0
- package/latticeai/services/memory_service.py +268 -21
- package/latticeai/services/model_engines.py +48 -33
- package/latticeai/services/model_errors.py +17 -0
- package/latticeai/services/model_loading.py +28 -25
- package/latticeai/services/model_runtime.py +228 -162
- package/latticeai/services/p_reinforce.py +22 -3
- package/latticeai/services/platform_runtime.py +92 -24
- package/latticeai/services/product_readiness.py +19 -17
- package/latticeai/services/review_queue.py +76 -11
- package/latticeai/services/router_context.py +1 -0
- package/latticeai/services/run_executor.py +25 -9
- package/latticeai/services/search_service.py +203 -28
- package/latticeai/services/setup_detection.py +80 -0
- package/latticeai/services/tool_dispatch.py +28 -5
- package/latticeai/services/triggers.py +53 -4
- package/latticeai/services/upload_service.py +13 -2
- package/latticeai/setup/__init__.py +25 -0
- package/latticeai/setup/auto_setup.py +857 -0
- package/latticeai/setup/wizard.py +1264 -0
- package/latticeai/tools/__init__.py +278 -0
- package/{tools → latticeai/tools}/commands.py +67 -7
- package/{tools → latticeai/tools}/computer.py +1 -1
- package/{tools → latticeai/tools}/documents.py +1 -1
- package/{tools → latticeai/tools}/filesystem.py +3 -3
- package/latticeai/tools/knowledge.py +178 -0
- package/{tools → latticeai/tools}/local_files.py +7 -1
- package/{tools → latticeai/tools}/network.py +0 -1
- package/local_knowledge_api.py +4 -342
- package/package.json +22 -2
- package/scripts/brain_quality_eval.py +3 -1
- package/scripts/bump_version.py +3 -0
- package/scripts/capture_release_evidence.mjs +180 -0
- package/scripts/check_current_release_docs.mjs +142 -0
- package/scripts/check_i18n_literals.mjs +107 -31
- package/scripts/check_openapi_drift.mjs +56 -0
- package/scripts/export_openapi.py +67 -7
- package/scripts/i18n_literal_allowlist.json +22 -24
- package/scripts/run_integration_tests.mjs +72 -10
- package/scripts/validate_release_artifacts.py +49 -7
- package/scripts/wheel_smoke.py +5 -0
- package/setup_wizard.py +3 -1304
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/Act-Bzz0bUyW.js +1 -0
- package/static/app/assets/Brain-Dj2J20YA.js +321 -0
- package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
- package/static/app/assets/Library-B03FP1Yx.js +1 -0
- package/static/app/assets/System-80lHW0Ux.js +1 -0
- package/static/app/assets/index-BiMofBTM.js +17 -0
- package/static/app/assets/index-Bmx9rzTc.css +2 -0
- package/static/app/assets/primitives-Q1A96_7v.js +1 -0
- package/static/app/assets/textarea-D13RtnTo.js +1 -0
- package/static/app/index.html +2 -2
- package/static/css/tokens.css +4 -2
- package/static/sw.js +1 -1
- package/tools/__init__.py +21 -269
- package/docs/CODE_REVIEW_2026-07-06.md +0 -764
- package/latticeai/runtime/app_context_runtime.py +0 -13
- package/latticeai/runtime/sso_runtime.py +0 -52
- package/latticeai/runtime/tail_wiring.py +0 -21
- package/scripts/com.pts.claudecode.discord.plist +0 -31
- package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
- package/scripts/start-pts-claudecode-discord.sh +0 -51
- package/static/app/assets/Act-fZokUnC0.js +0 -1
- package/static/app/assets/Brain-DtyuWubr.js +0 -321
- package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
- package/static/app/assets/Library-C9kyFkSt.js +0 -1
- package/static/app/assets/System-VbChmX7r.js +0 -1
- package/static/app/assets/index-DCh5AoXt.css +0 -2
- package/static/app/assets/index-DPdcPoF0.js +0 -17
- package/static/app/assets/primitives-DFeanEV6.js +0 -1
- package/static/app/assets/textarea-CD8UNKIy.js +0 -1
- package/tools/knowledge.py +0 -95
package/latticeai/api/browser.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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=
|
|
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
|
-
#
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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=
|
|
479
|
+
workspace_id=workspace_id,
|
|
211
480
|
metadata={"has_selection": bool(req.selected_text)},
|
|
212
481
|
),
|
|
213
482
|
user_email=user,
|