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.
- package/README.md +80 -46
- package/auto_setup.py +7 -853
- package/desktop/electron/README.md +9 -0
- package/desktop/electron/main.cjs +5 -3
- package/docs/CHANGELOG.md +146 -2
- 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/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 +1 -6
- package/lattice_brain/context.py +66 -9
- package/lattice_brain/graph/_kg_fsutil.py +1 -5
- package/lattice_brain/graph/discovery_index.py +174 -20
- 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 +140 -39
- package/lattice_brain/graph/retrieval_docgen.py +37 -4
- package/lattice_brain/ingestion.py +4 -7
- package/lattice_brain/portability.py +28 -14
- package/lattice_brain/runtime/agent_runtime.py +5 -9
- 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 +20 -2
- 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 +320 -850
- 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 +24 -1
- 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 +62 -9
- 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/mcp.py +102 -23
- package/latticeai/api/models.py +72 -33
- package/latticeai/api/network.py +5 -5
- package/latticeai/api/permissions.py +67 -26
- 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 +96 -14
- package/latticeai/api/workflow_designer.py +37 -0
- package/latticeai/api/workspace.py +2 -1
- package/latticeai/app_factory.py +110 -52
- 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 +9 -1
- package/latticeai/core/legacy_compatibility.py +24 -3
- package/latticeai/core/marketplace.py +1 -1
- 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 +1 -5
- 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 +0 -5
- 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 +94 -43
- package/latticeai/models/router.py +130 -36
- package/latticeai/runtime/access_runtime.py +62 -4
- package/latticeai/runtime/automation_runtime.py +13 -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/hooks_runtime.py +1 -1
- package/latticeai/runtime/model_wiring.py +33 -5
- package/latticeai/runtime/namespace_runtime.py +62 -72
- 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/stages.py +27 -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 +55 -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 +83 -23
- package/latticeai/services/product_readiness.py +19 -17
- package/latticeai/services/review_queue.py +12 -0
- package/latticeai/services/router_context.py +1 -0
- package/latticeai/services/run_executor.py +4 -9
- package/latticeai/services/search_service.py +203 -28
- 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 +1 -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 -1260
- 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/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/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-21lIXx2E.js +0 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
- package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
- package/static/app/assets/Library-bFMtyni3.js +0 -1
- package/static/app/assets/System-K6krGCqn.js +0 -1
- package/static/app/assets/index-C4R3ws30.js +0 -17
- package/static/app/assets/index-ChSeOB02.css +0 -2
- package/static/app/assets/primitives-sQU3it5I.js +0 -1
- package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
- package/tools/knowledge.py +0 -95
package/latticeai/api/auth.py
CHANGED
|
@@ -5,7 +5,8 @@ import hashlib
|
|
|
5
5
|
import logging
|
|
6
6
|
import secrets
|
|
7
7
|
import time
|
|
8
|
-
from
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
215
|
+
if entry is None or time.time() - entry.issued_at > 300:
|
|
172
216
|
raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
|
|
173
|
-
|
|
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(
|
|
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(
|
|
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")
|
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,
|