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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""VPC network-profile config seam extracted from the legacy app factory.
|
|
2
|
+
|
|
3
|
+
``DEFAULT_VPC_CONFIG`` and the load/save helpers used to live inline in
|
|
4
|
+
``app_factory._build``. Behaviour and exported names are preserved exactly for
|
|
5
|
+
the legacy ``server_app`` compatibility namespace; the only change is that they
|
|
6
|
+
now live behind a builder so the factory stays a wiring path.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_vpc_runtime(*, vpc_file: Path, logging: Any) -> Dict[str, Any]:
|
|
19
|
+
"""Return ``DEFAULT_VPC_CONFIG`` and the VPC load/save helpers."""
|
|
20
|
+
|
|
21
|
+
default_vpc_config: Dict[str, Any] = {
|
|
22
|
+
"provider": "AWS",
|
|
23
|
+
"region": "ap-northeast-2",
|
|
24
|
+
"cidr_block": "10.42.0.0/16",
|
|
25
|
+
"private_subnets": ["10.42.10.0/24", "10.42.20.0/24"],
|
|
26
|
+
"endpoint": "ltcai-private.local",
|
|
27
|
+
"vpn_status": "standby",
|
|
28
|
+
"peering_status": "not_configured",
|
|
29
|
+
"notes": "로컬 MLX 브릿지를 프라이빗 서브넷 또는 VPN 뒤에서 운영할 때 쓰는 네트워크 프로필입니다.",
|
|
30
|
+
"updated_at": None,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def load_vpc_config() -> Dict:
|
|
34
|
+
if not os.path.exists(vpc_file):
|
|
35
|
+
return default_vpc_config.copy()
|
|
36
|
+
try:
|
|
37
|
+
with open(vpc_file, "r", encoding="utf-8") as f:
|
|
38
|
+
stored = json.load(f)
|
|
39
|
+
return {**default_vpc_config, **stored}
|
|
40
|
+
except Exception as e:
|
|
41
|
+
logging.warning("load_vpc_config failed (using defaults): %s", e)
|
|
42
|
+
return default_vpc_config.copy()
|
|
43
|
+
|
|
44
|
+
def save_vpc_config(config: Dict) -> None:
|
|
45
|
+
config["updated_at"] = datetime.now().isoformat()
|
|
46
|
+
with open(vpc_file, "w", encoding="utf-8") as f:
|
|
47
|
+
json.dump(config, f, ensure_ascii=False, indent=2)
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
"DEFAULT_VPC_CONFIG": default_vpc_config,
|
|
51
|
+
"load_vpc_config": load_vpc_config,
|
|
52
|
+
"save_vpc_config": save_vpc_config,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = ["build_vpc_runtime"]
|
|
@@ -24,13 +24,13 @@ def build_platform_automation_runtime(
|
|
|
24
24
|
agent_registry: Any,
|
|
25
25
|
data_dir: Any,
|
|
26
26
|
append_audit_event: Callable[..., Any],
|
|
27
|
+
memory_service: Any = None,
|
|
27
28
|
tz_name: Optional[str] = None,
|
|
28
29
|
) -> Dict[str, Any]:
|
|
29
30
|
"""Build platform services, automation services, and hook bindings.
|
|
30
31
|
|
|
31
|
-
The returned names intentionally match the
|
|
32
|
-
|
|
33
|
-
``server_app`` compatibility surface.
|
|
32
|
+
The returned names intentionally match the explicit composition-root
|
|
33
|
+
bindings consumed by the typed application stages.
|
|
34
34
|
"""
|
|
35
35
|
from latticeai.runtime.automation_runtime import build_automation_runtime
|
|
36
36
|
from latticeai.services.platform_runtime import PlatformRuntime
|
|
@@ -65,6 +65,7 @@ def build_platform_automation_runtime(
|
|
|
65
65
|
llm_generate=_llm_generate_sync,
|
|
66
66
|
llm_available=lambda: bool(getattr(model_router, "current_model_id", None)),
|
|
67
67
|
agent_registry=agent_registry,
|
|
68
|
+
memory_recall=memory_service.recall if memory_service is not None else None,
|
|
68
69
|
)
|
|
69
70
|
|
|
70
71
|
automation_runtime = build_automation_runtime(
|
|
@@ -8,17 +8,33 @@ exact include order while creating a narrow seam for the later
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
from dataclasses import dataclass
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
13
14
|
from latticeai.services.router_context import InteractionRouterContext
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class RouterBundle:
|
|
19
|
+
"""Final typed router stage after all domain routers are registered."""
|
|
20
|
+
|
|
21
|
+
app: Any
|
|
22
|
+
context: Any
|
|
23
|
+
route_count: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_router_bundle(app: Any, context: Any) -> RouterBundle:
|
|
27
|
+
return RouterBundle(app=app, context=context, route_count=len(getattr(app, "routes", ())))
|
|
28
|
+
|
|
29
|
+
|
|
16
30
|
def build_static_routes_bundle(
|
|
17
31
|
*,
|
|
18
32
|
create_static_routes_router: Any,
|
|
19
33
|
static_dir: Any,
|
|
20
34
|
invite_gate_enabled: bool,
|
|
21
35
|
invite_code: str,
|
|
36
|
+
invite_cookie_secret: str,
|
|
37
|
+
secure_cookies: bool,
|
|
22
38
|
app_mode: str,
|
|
23
39
|
model_router: Any,
|
|
24
40
|
require_user: Any,
|
|
@@ -29,6 +45,8 @@ def build_static_routes_bundle(
|
|
|
29
45
|
static_dir=static_dir,
|
|
30
46
|
invite_gate_enabled=invite_gate_enabled,
|
|
31
47
|
invite_code=invite_code,
|
|
48
|
+
invite_cookie_secret=invite_cookie_secret,
|
|
49
|
+
secure_cookies=secure_cookies,
|
|
32
50
|
app_mode=app_mode,
|
|
33
51
|
model_router=model_router,
|
|
34
52
|
require_user=require_user,
|
|
@@ -37,6 +55,7 @@ def build_static_routes_bundle(
|
|
|
37
55
|
"STATIC_ROUTES": static_routes,
|
|
38
56
|
"ui_file_response": static_routes.ui_file_response,
|
|
39
57
|
"local_sysinfo": static_routes.local_sysinfo,
|
|
58
|
+
"invite_authorized": static_routes.invite_authorized,
|
|
40
59
|
}
|
|
41
60
|
|
|
42
61
|
|
|
@@ -61,6 +80,8 @@ def build_auth_admin_security_router_bundle(
|
|
|
61
80
|
open_registration: bool,
|
|
62
81
|
session_ttl: int,
|
|
63
82
|
require_auth: bool,
|
|
83
|
+
secure_cookies: bool,
|
|
84
|
+
invite_authorized: Any,
|
|
64
85
|
ensure_identity: Any,
|
|
65
86
|
create_admin_router: Any,
|
|
66
87
|
require_admin: Any,
|
|
@@ -113,6 +134,9 @@ def build_auth_admin_security_router_bundle(
|
|
|
113
134
|
open_registration=open_registration,
|
|
114
135
|
session_ttl=session_ttl,
|
|
115
136
|
require_auth=require_auth,
|
|
137
|
+
secure_cookies=secure_cookies,
|
|
138
|
+
invite_gate_enabled=invite_gate_enabled,
|
|
139
|
+
invite_authorized=invite_authorized,
|
|
116
140
|
ensure_identity=ensure_identity,
|
|
117
141
|
)
|
|
118
142
|
|
|
@@ -290,8 +314,9 @@ def register_platform_feature_routers(
|
|
|
290
314
|
require_user=require_user,
|
|
291
315
|
require_admin=require_admin,
|
|
292
316
|
append_audit_event=append_audit_event,
|
|
317
|
+
gate_write=platform.gate_write,
|
|
293
318
|
register_skill=platform.register_plugin_skill,
|
|
294
|
-
plugin_runners_factory=
|
|
319
|
+
plugin_runners_factory=platform.plugin_capability_runners,
|
|
295
320
|
ui_file_response=ui_file_response,
|
|
296
321
|
static_dir=static_dir,
|
|
297
322
|
),
|
|
@@ -356,6 +381,7 @@ def register_health_and_model_routers(
|
|
|
356
381
|
create_models_router: Any,
|
|
357
382
|
model_router: Any,
|
|
358
383
|
require_user: Any,
|
|
384
|
+
require_admin: Any,
|
|
359
385
|
load_users: Any,
|
|
360
386
|
get_user_role: Any,
|
|
361
387
|
install_engine: Any,
|
|
@@ -391,6 +417,7 @@ def register_health_and_model_routers(
|
|
|
391
417
|
create_models_router(
|
|
392
418
|
model_router=model_router,
|
|
393
419
|
require_user=require_user,
|
|
420
|
+
require_admin=require_admin,
|
|
394
421
|
get_current_user=get_current_user,
|
|
395
422
|
load_users=load_users,
|
|
396
423
|
get_user_role=get_user_role,
|
|
@@ -469,6 +496,7 @@ def register_interaction_routers(
|
|
|
469
496
|
require_user = interaction_context.require_user
|
|
470
497
|
embedding_info = interaction_context.embedding_info
|
|
471
498
|
tool_context = interaction_context.tool_context
|
|
499
|
+
require_admin = tool_context.require_admin
|
|
472
500
|
get_current_user = tool_context.get_current_user
|
|
473
501
|
append_audit_event = tool_context.append_audit_event
|
|
474
502
|
hooks = interaction_context.hooks
|
|
@@ -515,11 +543,13 @@ def register_interaction_routers(
|
|
|
515
543
|
create_hooks_router(
|
|
516
544
|
registry=hooks,
|
|
517
545
|
require_user=require_user,
|
|
546
|
+
require_admin=require_admin,
|
|
518
547
|
append_audit_event=append_audit_event,
|
|
519
548
|
),
|
|
520
549
|
create_agent_registry_router(
|
|
521
550
|
registry=agent_registry,
|
|
522
551
|
require_user=require_user,
|
|
552
|
+
require_admin=require_admin,
|
|
523
553
|
append_audit_event=append_audit_event,
|
|
524
554
|
),
|
|
525
555
|
create_memory_router(
|
|
@@ -546,6 +576,7 @@ def register_review_and_brain_tail_routers(
|
|
|
546
576
|
append_audit_event: Any,
|
|
547
577
|
create_browser_router: Any,
|
|
548
578
|
ingestion_pipeline: Any,
|
|
579
|
+
workspace_service: Any,
|
|
549
580
|
create_portability_router: Any,
|
|
550
581
|
kg_portability: Any,
|
|
551
582
|
require_admin: Any,
|
|
@@ -573,6 +604,7 @@ def register_review_and_brain_tail_routers(
|
|
|
573
604
|
create_browser_router(
|
|
574
605
|
pipeline=ingestion_pipeline,
|
|
575
606
|
require_user=require_user,
|
|
607
|
+
workspace_service=workspace_service,
|
|
576
608
|
),
|
|
577
609
|
create_portability_router(
|
|
578
610
|
service=kg_portability,
|
|
@@ -591,6 +623,7 @@ def register_review_and_brain_tail_routers(
|
|
|
591
623
|
network=brain_network,
|
|
592
624
|
identity=device_identity,
|
|
593
625
|
require_user=require_user,
|
|
626
|
+
require_admin=require_admin,
|
|
594
627
|
),
|
|
595
628
|
create_garden_router(gardener=gardener, require_user=require_user),
|
|
596
629
|
create_setup_router(model_router=model_router, require_user=require_user),
|
|
@@ -2,26 +2,123 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import secrets
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Dict
|
|
11
|
+
|
|
12
|
+
from latticeai.runtime.stages import RuntimeStage
|
|
6
13
|
|
|
7
14
|
if TYPE_CHECKING:
|
|
8
15
|
from latticeai.core.config import Config
|
|
9
16
|
|
|
10
17
|
|
|
11
|
-
|
|
18
|
+
_SECURITY_SECRETS_FILE = "security_secrets.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class SecurityRuntime(RuntimeStage):
|
|
23
|
+
SSO_DISCOVERY_URL: str
|
|
24
|
+
SSO_CLIENT_ID: str
|
|
25
|
+
SSO_CLIENT_SECRET: str
|
|
26
|
+
SSO_REDIRECT_URI: str
|
|
27
|
+
SSO_PROVIDER_NAME: str
|
|
28
|
+
RATE_LIMIT_ENABLED: bool
|
|
29
|
+
OPEN_REGISTRATION: bool
|
|
30
|
+
INVITE_CODE: str
|
|
31
|
+
INVITE_COOKIE_SECRET: str
|
|
32
|
+
INVITE_GATE_ENABLED: bool
|
|
33
|
+
SECURE_COOKIES: bool
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _stored_security_secrets(data_dir: Path) -> Dict[str, str]:
|
|
37
|
+
path = data_dir / _SECURITY_SECRETS_FILE
|
|
38
|
+
if not path.exists():
|
|
39
|
+
return {}
|
|
40
|
+
try:
|
|
41
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
# A corrupt secret store invalidates old invite cookies, but must never
|
|
44
|
+
# make startup fall back to a shared or predictable signing key.
|
|
45
|
+
logging.warning("invite-gate secret store is unreadable; rotating secrets: %s", exc)
|
|
46
|
+
return {}
|
|
47
|
+
if not isinstance(payload, dict):
|
|
48
|
+
return {}
|
|
49
|
+
return {
|
|
50
|
+
key: str(value)
|
|
51
|
+
for key, value in payload.items()
|
|
52
|
+
if key in {"invite_code", "invite_cookie_secret"} and value
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_invite_gate_secrets(config: "Config") -> tuple[str, str]:
|
|
57
|
+
"""Resolve stable, per-install invitation secrets.
|
|
58
|
+
|
|
59
|
+
No file is created while the invitation gate is disabled. Once enabled,
|
|
60
|
+
missing values are generated with the OS CSPRNG and persisted atomically
|
|
61
|
+
with mode 0600 so restarts do not invalidate links or signed cookies.
|
|
62
|
+
Explicit environment values remain authoritative and are not copied to the
|
|
63
|
+
local secret file unnecessarily.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
if not config.invite_gate_enabled:
|
|
67
|
+
return config.invite_code, config.invite_cookie_secret
|
|
68
|
+
|
|
69
|
+
from latticeai.core.io_utils import atomic_write_json
|
|
70
|
+
|
|
71
|
+
data_dir = Path(config.data_dir)
|
|
72
|
+
stored = _stored_security_secrets(data_dir)
|
|
73
|
+
invite_code = config.invite_code or stored.get("invite_code") or secrets.token_urlsafe(24)
|
|
74
|
+
cookie_secret = (
|
|
75
|
+
config.invite_cookie_secret
|
|
76
|
+
or stored.get("invite_cookie_secret")
|
|
77
|
+
or secrets.token_urlsafe(48)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
persisted = dict(stored)
|
|
81
|
+
if not config.invite_code:
|
|
82
|
+
persisted["invite_code"] = invite_code
|
|
83
|
+
if not config.invite_cookie_secret:
|
|
84
|
+
persisted["invite_cookie_secret"] = cookie_secret
|
|
85
|
+
if persisted != stored:
|
|
86
|
+
atomic_write_json(data_dir / _SECURITY_SECRETS_FILE, persisted)
|
|
87
|
+
return invite_code, cookie_secret
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_security_runtime(config: "Config") -> SecurityRuntime:
|
|
12
91
|
"""Build auth/security-derived runtime settings from the central config."""
|
|
13
92
|
|
|
14
93
|
from latticeai.core.security import configure_trusted_proxies
|
|
15
94
|
|
|
16
95
|
configure_trusted_proxies(config.trusted_proxies)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
96
|
+
invite_code, invite_cookie_secret = _resolve_invite_gate_secrets(config)
|
|
97
|
+
secure_cookies = bool(config.is_public or config.network_exposed)
|
|
98
|
+
if secure_cookies:
|
|
99
|
+
logging.warning(
|
|
100
|
+
"Public/non-loopback mode: authentication and Secure cookies are forced; "
|
|
101
|
+
"serve Lattice AI through HTTPS/TLS or browser sessions will be rejected."
|
|
102
|
+
)
|
|
103
|
+
if config.invite_gate_enabled and not config.invite_code:
|
|
104
|
+
logging.warning(
|
|
105
|
+
"No LATTICEAI_INVITE_CODE was configured; generated a private per-install "
|
|
106
|
+
"invite code in %s.",
|
|
107
|
+
Path(config.data_dir) / _SECURITY_SECRETS_FILE,
|
|
108
|
+
)
|
|
109
|
+
return SecurityRuntime(
|
|
110
|
+
SSO_DISCOVERY_URL=config.sso_discovery_url,
|
|
111
|
+
SSO_CLIENT_ID=config.sso_client_id,
|
|
112
|
+
SSO_CLIENT_SECRET=config.sso_client_secret,
|
|
113
|
+
SSO_REDIRECT_URI=config.sso_redirect_uri,
|
|
114
|
+
SSO_PROVIDER_NAME=config.sso_provider_name,
|
|
115
|
+
RATE_LIMIT_ENABLED=config.rate_limit_enabled,
|
|
116
|
+
OPEN_REGISTRATION=config.open_registration,
|
|
117
|
+
INVITE_CODE=invite_code,
|
|
118
|
+
INVITE_COOKIE_SECRET=invite_cookie_secret,
|
|
119
|
+
INVITE_GATE_ENABLED=config.invite_gate_enabled,
|
|
120
|
+
SECURE_COOKIES=secure_cookies,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = ["SecurityRuntime", "build_security_runtime"]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""SSO / OIDC config + discovery seam extracted from the app factory.
|
|
2
|
+
|
|
3
|
+
Owns the whole SSO surface that used to be inline in ``app_factory._build``:
|
|
4
|
+
the env-default resolution, the ``sso_config.json`` load/save, the public
|
|
5
|
+
(secret-stripped) view, and the OIDC discovery document cache.
|
|
6
|
+
|
|
7
|
+
Unlike the previous split (config inline in the factory + a separate discovery
|
|
8
|
+
cache in ``sso_runtime``), the discovery cache now lives in the *same* closure
|
|
9
|
+
as ``save_sso_config``, so saving a new SSO config actually invalidates the
|
|
10
|
+
cached discovery document. The old inline ``save_sso_config`` reassigned a
|
|
11
|
+
factory-local cache variable that ``_get_sso_discovery`` never read, so the
|
|
12
|
+
invalidation was a silent no-op — this consolidation fixes that.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_sso_config_runtime(
|
|
23
|
+
*,
|
|
24
|
+
sso_file: Path,
|
|
25
|
+
discovery_url: str,
|
|
26
|
+
client_id: str,
|
|
27
|
+
client_secret: str,
|
|
28
|
+
redirect_uri: str,
|
|
29
|
+
provider_name: str,
|
|
30
|
+
logging: Any,
|
|
31
|
+
) -> Dict[str, Any]:
|
|
32
|
+
"""Return the SSO config helpers + discovery accessor as a name → value dict."""
|
|
33
|
+
|
|
34
|
+
# Single shared discovery cache: save_sso_config and _get_sso_discovery both
|
|
35
|
+
# close over it, so a config change is observed on the next discovery fetch.
|
|
36
|
+
_discovery_cache: Dict[str, Any] = {"data": None, "url": ""}
|
|
37
|
+
_sso_states: Dict[str, float] = {}
|
|
38
|
+
|
|
39
|
+
def _sso_env_defaults() -> Dict[str, object]:
|
|
40
|
+
return {
|
|
41
|
+
"enabled": bool(discovery_url and client_id and client_secret),
|
|
42
|
+
"provider_name": provider_name,
|
|
43
|
+
"discovery_url": discovery_url,
|
|
44
|
+
"client_id": client_id,
|
|
45
|
+
"client_secret": client_secret,
|
|
46
|
+
"redirect_uri": redirect_uri,
|
|
47
|
+
"scopes": "openid email profile",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def load_sso_config() -> Dict[str, object]:
|
|
51
|
+
config = _sso_env_defaults()
|
|
52
|
+
if sso_file.exists():
|
|
53
|
+
try:
|
|
54
|
+
data = json.loads(sso_file.read_text(encoding="utf-8"))
|
|
55
|
+
if isinstance(data, dict):
|
|
56
|
+
config.update({k: v for k, v in data.items() if v is not None})
|
|
57
|
+
except Exception as e:
|
|
58
|
+
logging.warning("load_sso_config failed (using env/defaults): %s", e)
|
|
59
|
+
config["provider_name"] = str(config.get("provider_name") or "SSO")
|
|
60
|
+
config["discovery_url"] = str(config.get("discovery_url") or "")
|
|
61
|
+
config["client_id"] = str(config.get("client_id") or "")
|
|
62
|
+
config["client_secret"] = str(config.get("client_secret") or "")
|
|
63
|
+
config["redirect_uri"] = str(config.get("redirect_uri") or redirect_uri)
|
|
64
|
+
config["scopes"] = str(config.get("scopes") or "openid email profile")
|
|
65
|
+
config["enabled"] = bool(config.get("enabled")) and bool(
|
|
66
|
+
config["discovery_url"] and config["client_id"] and config["client_secret"]
|
|
67
|
+
)
|
|
68
|
+
return config
|
|
69
|
+
|
|
70
|
+
def get_sso_settings() -> Dict[str, object]:
|
|
71
|
+
return load_sso_config()
|
|
72
|
+
|
|
73
|
+
def public_sso_config(config: Optional[Dict[str, object]] = None) -> Dict[str, object]:
|
|
74
|
+
cfg = config or get_sso_settings()
|
|
75
|
+
return {
|
|
76
|
+
"enabled": bool(cfg.get("enabled")),
|
|
77
|
+
"provider_name": cfg.get("provider_name") or "",
|
|
78
|
+
"discovery_url": cfg.get("discovery_url") or "",
|
|
79
|
+
"client_id": cfg.get("client_id") or "",
|
|
80
|
+
"redirect_uri": cfg.get("redirect_uri") or redirect_uri,
|
|
81
|
+
"scopes": cfg.get("scopes") or "openid email profile",
|
|
82
|
+
"secret_configured": bool(cfg.get("client_secret")),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
def save_sso_config(update: Dict[str, object]) -> Dict[str, object]:
|
|
86
|
+
current = load_sso_config()
|
|
87
|
+
if update.get("client_secret") == "":
|
|
88
|
+
update.pop("client_secret", None)
|
|
89
|
+
current.update({k: v for k, v in update.items() if v is not None})
|
|
90
|
+
current["enabled"] = bool(current.get("enabled")) and bool(
|
|
91
|
+
current.get("discovery_url") and current.get("client_id") and current.get("client_secret")
|
|
92
|
+
)
|
|
93
|
+
sso_file.write_text(json.dumps(current, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
94
|
+
_discovery_cache["data"] = None
|
|
95
|
+
_discovery_cache["url"] = ""
|
|
96
|
+
return current
|
|
97
|
+
|
|
98
|
+
async def _get_sso_discovery() -> Optional[Dict]:
|
|
99
|
+
settings = get_sso_settings()
|
|
100
|
+
url = settings.get("discovery_url", "")
|
|
101
|
+
if _discovery_cache["data"] and _discovery_cache["url"] == url:
|
|
102
|
+
return _discovery_cache["data"]
|
|
103
|
+
if not url:
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
import httpx as _httpx
|
|
107
|
+
async with _httpx.AsyncClient() as c:
|
|
108
|
+
r = await c.get(url, timeout=10)
|
|
109
|
+
r.raise_for_status()
|
|
110
|
+
_discovery_cache["data"] = r.json()
|
|
111
|
+
_discovery_cache["url"] = url
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logging.warning("SSO discovery failed: %s", e)
|
|
114
|
+
return None
|
|
115
|
+
return _discovery_cache["data"]
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"_sso_env_defaults": _sso_env_defaults,
|
|
119
|
+
"load_sso_config": load_sso_config,
|
|
120
|
+
"get_sso_settings": get_sso_settings,
|
|
121
|
+
"public_sso_config": public_sso_config,
|
|
122
|
+
"save_sso_config": save_sso_config,
|
|
123
|
+
"_get_sso_discovery": _get_sso_discovery,
|
|
124
|
+
"_sso_states": _sso_states,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = ["build_sso_config_runtime"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Typed mapping base for application assembly stages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import fields
|
|
6
|
+
from typing import Any, Iterator, Mapping
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RuntimeStage(Mapping[str, Any]):
|
|
10
|
+
"""Dataclass mixin that preserves the legacy mapping access during DI migration."""
|
|
11
|
+
|
|
12
|
+
def __getitem__(self, key: str) -> Any:
|
|
13
|
+
if key not in self:
|
|
14
|
+
raise KeyError(key)
|
|
15
|
+
return getattr(self, key)
|
|
16
|
+
|
|
17
|
+
def __iter__(self) -> Iterator[str]:
|
|
18
|
+
return (field.name for field in fields(self))
|
|
19
|
+
|
|
20
|
+
def __len__(self) -> int:
|
|
21
|
+
return len(fields(self))
|
|
22
|
+
|
|
23
|
+
def __contains__(self, key: object) -> bool:
|
|
24
|
+
return isinstance(key, str) and any(field.name == key for field in fields(self))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["RuntimeStage"]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""User profile + provider API-key helpers extracted from the app factory.
|
|
2
|
+
|
|
3
|
+
The helpers close over the user store, keyring adapter, identity migration, and
|
|
4
|
+
plaintext-key policy. Returning callables preserves the legacy ``server_app``
|
|
5
|
+
namespace while keeping ``app_factory._build`` focused on wiring.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_user_key_runtime(
|
|
14
|
+
*,
|
|
15
|
+
load_users: Any,
|
|
16
|
+
save_users: Any,
|
|
17
|
+
ensure_user_identity: Any,
|
|
18
|
+
keyring: Any,
|
|
19
|
+
allow_plaintext_api_keys: bool,
|
|
20
|
+
logging: Any,
|
|
21
|
+
http_exception: Any,
|
|
22
|
+
) -> Dict[str, Any]:
|
|
23
|
+
"""Return history-user and provider API-key helpers."""
|
|
24
|
+
|
|
25
|
+
def get_history_user(email: Optional[str], nickname: Optional[str] = None) -> Dict:
|
|
26
|
+
if not email:
|
|
27
|
+
return {"user_email": None, "user_nickname": nickname or None}
|
|
28
|
+
users = load_users()
|
|
29
|
+
user = users.get(email, {})
|
|
30
|
+
return {
|
|
31
|
+
"user_email": email,
|
|
32
|
+
"user_nickname": nickname or user.get("nickname") or user.get("name") or email,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
def get_user_api_key(email: Optional[str], provider: str) -> Optional[str]:
|
|
36
|
+
if not email:
|
|
37
|
+
return None
|
|
38
|
+
keyring_key = f"{email}:{provider}"
|
|
39
|
+
if keyring is not None:
|
|
40
|
+
try:
|
|
41
|
+
key = keyring.get_password("LatticeAI", keyring_key)
|
|
42
|
+
if key:
|
|
43
|
+
return key.strip()
|
|
44
|
+
except Exception as exc:
|
|
45
|
+
logging.warning("keyring read failed for %s: %s", provider, exc)
|
|
46
|
+
users = load_users()
|
|
47
|
+
user = users.get(email) or {}
|
|
48
|
+
api_keys = user.get("api_keys") or {}
|
|
49
|
+
key = api_keys.get(provider)
|
|
50
|
+
if isinstance(key, str) and key.strip() and allow_plaintext_api_keys:
|
|
51
|
+
return key.strip()
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def set_user_api_key(email: str, provider: str, key: str) -> None:
|
|
55
|
+
keyring_key = f"{email}:{provider}"
|
|
56
|
+
if keyring is not None:
|
|
57
|
+
try:
|
|
58
|
+
keyring.set_password("LatticeAI", keyring_key, key)
|
|
59
|
+
users = load_users()
|
|
60
|
+
user = users.get(email)
|
|
61
|
+
if user and "api_keys" in user:
|
|
62
|
+
user["api_keys"].pop(provider, None)
|
|
63
|
+
if not user["api_keys"]:
|
|
64
|
+
user.pop("api_keys", None)
|
|
65
|
+
save_users(users)
|
|
66
|
+
return
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
logging.warning("keyring write failed for %s: %s", provider, exc)
|
|
69
|
+
if not allow_plaintext_api_keys:
|
|
70
|
+
raise http_exception(
|
|
71
|
+
status_code=500,
|
|
72
|
+
detail="OS keyring에 API 키를 저장하지 못했습니다. keyring 설정을 확인하거나 LATTICEAI_ALLOW_PLAINTEXT_API_KEYS=true를 명시적으로 설정하세요.",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if not allow_plaintext_api_keys:
|
|
76
|
+
raise http_exception(
|
|
77
|
+
status_code=500,
|
|
78
|
+
detail="keyring 패키지를 사용할 수 없어 API 키를 안전하게 저장할 수 없습니다.",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
users = load_users()
|
|
82
|
+
user = users.get(email)
|
|
83
|
+
if not user:
|
|
84
|
+
user = {
|
|
85
|
+
"password_hash": "",
|
|
86
|
+
"salt": "",
|
|
87
|
+
"name": email,
|
|
88
|
+
"nickname": email,
|
|
89
|
+
"role": "user",
|
|
90
|
+
"disabled": False,
|
|
91
|
+
}
|
|
92
|
+
ensure_user_identity(email, user)
|
|
93
|
+
api_keys = user.get("api_keys") or {}
|
|
94
|
+
api_keys[provider] = key
|
|
95
|
+
user["api_keys"] = api_keys
|
|
96
|
+
users[email] = user
|
|
97
|
+
save_users(users)
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
"get_history_user": get_history_user,
|
|
101
|
+
"get_user_api_key": get_user_api_key,
|
|
102
|
+
"set_user_api_key": set_user_api_key,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = ["build_user_key_runtime"]
|
package/latticeai/server_app.py
CHANGED
|
@@ -13,6 +13,8 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
from typing import Any, List
|
|
15
15
|
|
|
16
|
+
from latticeai.runtime.namespace_runtime import SERVER_APP_EXPORTS
|
|
17
|
+
|
|
16
18
|
|
|
17
19
|
def _runtime():
|
|
18
20
|
from latticeai.app_factory import get_shared_runtime
|
|
@@ -25,6 +27,8 @@ def __getattr__(name: str) -> Any:
|
|
|
25
27
|
# Never let dunder probes (importlib, inspect, pickling) trigger the
|
|
26
28
|
# full application construction.
|
|
27
29
|
raise AttributeError(name)
|
|
30
|
+
if name not in SERVER_APP_EXPORTS:
|
|
31
|
+
raise AttributeError(f"module 'latticeai.server_app' has no attribute '{name}'")
|
|
28
32
|
try:
|
|
29
33
|
return getattr(_runtime(), name)
|
|
30
34
|
except AttributeError as exc:
|
|
@@ -34,7 +38,7 @@ def __getattr__(name: str) -> Any:
|
|
|
34
38
|
|
|
35
39
|
|
|
36
40
|
def __dir__() -> List[str]:
|
|
37
|
-
return sorted(set(globals()) | set(
|
|
41
|
+
return sorted(set(globals()) | set(SERVER_APP_EXPORTS))
|
|
38
42
|
|
|
39
43
|
|
|
40
44
|
def main() -> None:
|