ltcai 11.5.0 → 11.5.2
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 +86 -47
- package/docs/CHANGELOG.md +76 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/HYBRID_CLOUD_KG_STREAMING.md +8 -4
- package/docs/MULTI_AGENT_RUNTIME.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/WORKFLOW_DESIGNER.md +1 -1
- package/docs/architecture.md +4 -4
- package/docs/kg-schema.md +1 -1
- package/docs/v11.5.1_RUST_FULL_LOOP_PLAN.md +85 -0
- package/docs/v11.5.2_TIGHT_SHIP_PLAN.md +144 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/proactive.py +1 -1
- package/lattice_brain/graph/projection/v2_schema.py +0 -28
- package/lattice_brain/graph/vector_index/base.py +0 -3
- package/lattice_brain/graph/vector_index/brute_force.py +0 -4
- package/lattice_brain/graph/vector_index/hnsw.py +0 -3
- package/lattice_brain/graph/vector_index/quantized.py +0 -3
- package/lattice_brain/ingestion_jobs.py +0 -3
- package/lattice_brain/memory.py +0 -27
- package/lattice_brain/portability/sharing.py +0 -4
- package/lattice_brain/quality.py +0 -49
- package/lattice_brain/runtime/agent_runtime.py +1 -1
- package/lattice_brain/runtime/hooks.py +0 -13
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/sealed_box.py +0 -4
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +12 -12
- package/latticeai/api/agent_worker_seam.py +389 -0
- package/latticeai/api/auth.py +25 -2
- package/latticeai/api/chat.py +3 -3
- package/latticeai/api/chat_agent_http.py +3 -12
- package/latticeai/api/chat_helpers.py +10 -13
- package/latticeai/api/computer_use.py +31 -42
- package/latticeai/api/health.py +21 -1
- package/latticeai/api/local_files.py +14 -0
- package/latticeai/api/permissions.py +38 -12
- package/latticeai/api/search.py +24 -0
- package/latticeai/api/static_routes.py +4 -1
- package/latticeai/cli/runtime.py +7 -3
- package/latticeai/core/config.py +47 -3
- package/latticeai/core/csrf.py +28 -2
- package/latticeai/core/embedding_providers/__init__.py +3 -5
- package/latticeai/core/embedding_providers/base.py +1 -1
- package/latticeai/core/embedding_providers/text.py +1 -1
- package/latticeai/core/enterprise.py +1 -4
- package/latticeai/core/http_origin.py +146 -0
- package/latticeai/core/invitations.py +3 -2
- package/latticeai/core/io_utils.py +2 -11
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +40 -0
- package/latticeai/core/model_compat.py +2 -8
- package/latticeai/core/module_probe.py +37 -0
- package/latticeai/core/run_explain.py +5 -2
- package/latticeai/core/run_store.py +2 -2
- package/latticeai/core/security.py +13 -0
- package/latticeai/core/sessions.py +3 -2
- package/latticeai/core/sse.py +25 -0
- package/latticeai/core/users.py +0 -9
- package/latticeai/core/workspace_graph_trace.py +2 -8
- package/latticeai/core/workspace_os.py +0 -10
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_os_utils.py +34 -0
- package/latticeai/core/workspace_review_items.py +2 -8
- package/latticeai/core/workspace_runs.py +2 -8
- package/latticeai/core/workspace_skills.py +2 -7
- package/latticeai/models/router/documents.py +0 -35
- package/latticeai/models/router/registry.py +0 -4
- package/latticeai/runtime/access_runtime.py +16 -4
- package/latticeai/runtime/build_phases/features.py +25 -2
- package/latticeai/runtime/build_phases/web.py +2 -0
- package/latticeai/runtime/feature_toggle_wiring.py +12 -18
- package/latticeai/runtime/network_boundary_wiring.py +8 -19
- package/latticeai/runtime/permission_mode_wiring.py +6 -13
- package/latticeai/runtime/router_registration.py +4 -0
- package/latticeai/runtime/runtime_context.py +1 -13
- package/latticeai/runtime/service_singletons.py +55 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence/proposals.py +0 -5
- package/latticeai/services/change_proposals.py +2 -36
- package/latticeai/services/chronicle.py +4 -6
- package/latticeai/services/command_center.py +4 -4
- package/latticeai/services/evidence_actions.py +5 -2
- package/latticeai/services/hybrid_chat.py +2 -2
- package/latticeai/services/hybrid_policy.py +8 -57
- package/latticeai/services/mode_store.py +132 -0
- package/latticeai/services/model_capability_registry.py +0 -15
- package/latticeai/services/model_catalog.py +0 -7
- package/latticeai/services/model_loading.py +3 -2
- package/latticeai/services/model_runtime/__init__.py +0 -57
- package/latticeai/services/model_runtime/engines.py +27 -128
- package/latticeai/services/model_runtime/loading.py +2 -2
- package/latticeai/services/network_boundary_service.py +7 -44
- package/latticeai/services/permission_mode_service.py +8 -56
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/setup_detection.py +67 -1
- package/latticeai/services/tool_dispatch.py +0 -4
- package/latticeai/services/upload_service.py +7 -20
- package/latticeai/services/workspace_service.py +0 -6
- package/latticeai/setup/auto_setup.py +15 -26
- package/latticeai/setup/wizard/catalog.py +5 -2
- package/latticeai/setup/wizard/detect.py +6 -27
- package/latticeai/setup/wizard/paths.py +2 -5
- package/latticeai/setup/wizard/plans.py +2 -2
- package/package.json +2 -4
- package/scripts/bump_version.py +14 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_legacy_debt.mjs +1 -1
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/generate_agent_loop_fixtures.py +994 -0
- package/scripts/generate_rust_parity_fixtures.py +72 -161
- package/scripts/parity_fixture_corpus_context.py +162 -0
- package/scripts/parity_fixture_corpus_docgen.py +341 -0
- package/scripts/release_screen_claims.json +20 -0
- package/src-tauri/Cargo.lock +13 -7
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/src/backend.rs +76 -2
- package/src-tauri/src/main.rs +13 -3
- package/src-tauri/tauri.conf.json +2 -2
- package/static/app/asset-manifest.json +41 -41
- package/static/app/assets/{Act-CWnxSCgN.js → Act-DcQizkl1.js} +1 -1
- package/static/app/assets/{AdminConsole-BEQYU6kF.js → AdminConsole-cf4npybT.js} +1 -1
- package/static/app/assets/{Brain-DWu1BhFg.js → Brain-3VCSHFcn.js} +1 -1
- package/static/app/assets/{BrainHome-95Hilr9R.js → BrainHome-Qm8eaztx.js} +1 -1
- package/static/app/assets/{BrainSignals-QdeqCpAF.js → BrainSignals-DS9BtKOW.js} +1 -1
- package/static/app/assets/{Capture-BHpCxnzb.js → Capture-DiQ219jW.js} +1 -1
- package/static/app/assets/{Chronicle-B4xYKoed.js → Chronicle-BGvuAchH.js} +1 -1
- package/static/app/assets/{CommandPalette-BVXnttSz.js → CommandPalette-Bqhm0Urn.js} +1 -1
- package/static/app/assets/{Library-DgYcHome.js → Library-BV6NnF0a.js} +1 -1
- package/static/app/assets/{LivingBrain-CrJLDbf7.js → LivingBrain-GzenJchP.js} +1 -1
- package/static/app/assets/{ProductFlow-DFlScKoJ.js → ProductFlow-DEP6-vML.js} +1 -1
- package/static/app/assets/{ReviewCard-Cy5f48Pj.js → ReviewCard-CNZ7XjWG.js} +1 -1
- package/static/app/assets/{System-NF8IfhTa.js → System-CieofHQa.js} +1 -1
- package/static/app/assets/arrow-left-kfsrk0mv.js +1 -0
- package/static/app/assets/{bot-CucuhLhm.js → bot-B_K1Tdmw.js} +1 -1
- package/static/app/assets/{brain-BBnSryW_.js → brain-DWyaV1L1.js} +1 -1
- package/static/app/assets/{button-C2GUj2Ai.js → button-aTn4s84A.js} +1 -1
- package/static/app/assets/circle-check-qqLug9nU.js +1 -0
- package/static/app/assets/{circle-pause-CbkWzBmG.js → circle-pause-xKgeGXkT.js} +1 -1
- package/static/app/assets/{circle-play-7lEaqHdJ.js → circle-play-DkT6tYPX.js} +1 -1
- package/static/app/assets/{cpu-DAlCXlIy.js → cpu-85xYObUC.js} +1 -1
- package/static/app/assets/{download-RNhuuJwh.js → download-B5Fm7YXo.js} +1 -1
- package/static/app/assets/{folder-open-CLW4odzM.js → folder-open-kk2Xa52u.js} +1 -1
- package/static/app/assets/{hard-drive-NKEiDIAJ.js → hard-drive-DkA3zBW_.js} +1 -1
- package/static/app/assets/{index-DMurvUuR.js → index-BMPdTmlY.js} +3 -3
- package/static/app/assets/index-DxmOfNRi.css +2 -0
- package/static/app/assets/{input-D2UhPC1X.js → input-B0nRf2jO.js} +1 -1
- package/static/app/assets/{link-2-6amKbP_P.js → link-2-Dwb4gnTc.js} +1 -1
- package/static/app/assets/{permissionCopy-Cu9TZtdR.js → permissionCopy-CQDUBrOZ.js} +1 -1
- package/static/app/assets/{primitives-gPsccucr.js → primitives-SNp0LRJz.js} +1 -1
- package/static/app/assets/search-BcHqkjoy.js +1 -0
- package/static/app/assets/{share-2-Bau7KkPq.js → share-2-BsrxFglO.js} +1 -1
- package/static/app/assets/{shield-alert-BufNYypi.js → shield-alert-5BStfp2_.js} +1 -1
- package/static/app/assets/{textarea-BQnVWhYs.js → textarea-Cg8IUA-k.js} +1 -1
- package/static/app/assets/{useFocusTrap-B3_w60si.js → useFocusTrap-CYKvE46M.js} +1 -1
- package/static/app/assets/{useMutation-BHhCflT6.js → useMutation-CSn9t1op.js} +1 -1
- package/static/app/assets/{useQuery-rBWfI-5t.js → useQuery-CY2OI2uy.js} +1 -1
- package/static/app/assets/{utils-V_5-wxr5.js → utils-Ddol2RWD.js} +1 -1
- package/static/app/assets/{workspace-K1zjYUHj.js → workspace-BqDwOz_p.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/desktop/electron/README.md +0 -9
- package/desktop/electron/main.cjs +0 -58
- package/desktop/electron/preload.cjs +0 -5
- package/latticeai/core/graph_curator.py +0 -11
- package/latticeai/core/hooks.py +0 -11
- package/latticeai/core/local_embeddings.py +0 -104
- package/latticeai/core/multi_agent.py +0 -11
- package/latticeai/core/workflow_engine.py +0 -11
- package/latticeai/services/ingestion.py +0 -11
- package/latticeai/services/kg_portability.py +0 -11
- package/latticeai/services/multimodal_streaming.py +0 -129
- package/scripts/measure_brain_home_fill.mjs +0 -141
- package/static/app/assets/arrow-left-DwkSYrjR.js +0 -1
- package/static/app/assets/circle-check-CxOVPwYq.js +0 -1
- package/static/app/assets/index-BLPb5lmE.css +0 -2
- package/static/app/assets/search-Cj_TKk_2.js +0 -1
|
@@ -11,7 +11,9 @@ from fastapi.responses import StreamingResponse
|
|
|
11
11
|
from pydantic import BaseModel
|
|
12
12
|
|
|
13
13
|
from lattice_brain.runtime.hooks import dispatch_tool
|
|
14
|
+
from latticeai.api.workspace_scope import resolve_workspace_scope
|
|
14
15
|
from latticeai.core.agent import extract_action as _extract_agent_action
|
|
16
|
+
from latticeai.core.sse import sse_frame
|
|
15
17
|
from latticeai.services.tool_dispatch import enforce_tool_policy
|
|
16
18
|
from latticeai.tools import (
|
|
17
19
|
AGENT_ROOT,
|
|
@@ -121,34 +123,24 @@ def create_computer_use_router(
|
|
|
121
123
|
) -> APIRouter:
|
|
122
124
|
router = APIRouter()
|
|
123
125
|
|
|
124
|
-
def _requested_workspace(request: Request) -> Optional[str]:
|
|
125
|
-
header = request.headers.get("X-Workspace-Id")
|
|
126
|
-
if header and header.strip():
|
|
127
|
-
return header.strip()
|
|
128
|
-
query = request.query_params.get("workspace_id")
|
|
129
|
-
return query.strip() if query and query.strip() else None
|
|
130
|
-
|
|
131
126
|
def _write_workspace(request: Request, current_user: str) -> Optional[str]:
|
|
132
127
|
"""Resolve only the CU agent's durable write scope.
|
|
133
128
|
|
|
134
129
|
The other ``/cu`` actions remain host operations and do not acquire a
|
|
135
130
|
workspace dependency. A no-auth local request without an explicit
|
|
136
|
-
scope preserves its legacy unscoped history behavior
|
|
137
|
-
|
|
131
|
+
scope preserves its legacy unscoped history behavior — that is exactly
|
|
132
|
+
what ``allow_unscoped_anonymous`` names in the shared resolver;
|
|
133
|
+
explicit scopes and every authenticated request go through the normal
|
|
134
|
+
write gate.
|
|
138
135
|
"""
|
|
139
136
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
requested,
|
|
148
|
-
current_user or None,
|
|
149
|
-
)
|
|
150
|
-
except PermissionError as exc:
|
|
151
|
-
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
137
|
+
return resolve_workspace_scope(
|
|
138
|
+
request,
|
|
139
|
+
user=current_user,
|
|
140
|
+
workspace_service=workspace_service,
|
|
141
|
+
write=True,
|
|
142
|
+
allow_unscoped_anonymous=True,
|
|
143
|
+
)
|
|
152
144
|
|
|
153
145
|
def _audit_args(name: str, args: dict) -> dict:
|
|
154
146
|
"""Return audit-safe argument metadata without storing typed text."""
|
|
@@ -351,17 +343,14 @@ def create_computer_use_router(
|
|
|
351
343
|
task_lower = (req.task or "").lower()
|
|
352
344
|
url_match = re.search(r"(https?://[^\s]+|localhost:\d+[^\s]*|127\.0\.0\.1:\d+[^\s]*)", req.task or "")
|
|
353
345
|
|
|
354
|
-
def _send(event: str, data: dict) -> str:
|
|
355
|
-
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
|
356
|
-
|
|
357
346
|
if ("chrome" in task_lower or "크롬" in task_lower) and any(
|
|
358
347
|
word in task_lower for word in ["open", "열", "켜", "실행", "띄"]
|
|
359
348
|
):
|
|
360
|
-
yield
|
|
349
|
+
yield sse_frame("start", {"task": req.task, "max_steps": 1})
|
|
361
350
|
try:
|
|
362
351
|
if url_match:
|
|
363
352
|
url = url_match.group(1)
|
|
364
|
-
yield
|
|
353
|
+
yield sse_frame(
|
|
365
354
|
"action",
|
|
366
355
|
{"step": 1, "action": "computer_open_url", "args": {"url": url, "app": "Google Chrome"}},
|
|
367
356
|
)
|
|
@@ -371,11 +360,11 @@ def create_computer_use_router(
|
|
|
371
360
|
lambda: computer_open_url(url, "Google Chrome"),
|
|
372
361
|
current_user=current_user,
|
|
373
362
|
)
|
|
374
|
-
yield
|
|
363
|
+
yield sse_frame("result", {"step": 1, "action": "computer_open_url", "result": result})
|
|
375
364
|
message = f"Google Chrome에서 {url}을 열었습니다."
|
|
376
365
|
action_name = "computer_open_url"
|
|
377
366
|
else:
|
|
378
|
-
yield
|
|
367
|
+
yield sse_frame(
|
|
379
368
|
"action",
|
|
380
369
|
{"step": 1, "action": "computer_open_app", "args": {"app": "Google Chrome"}},
|
|
381
370
|
)
|
|
@@ -385,26 +374,26 @@ def create_computer_use_router(
|
|
|
385
374
|
lambda: computer_open_app("Google Chrome"),
|
|
386
375
|
current_user=current_user,
|
|
387
376
|
)
|
|
388
|
-
yield
|
|
377
|
+
yield sse_frame("result", {"step": 1, "action": "computer_open_app", "result": result})
|
|
389
378
|
message = "Google Chrome을 열었습니다."
|
|
390
379
|
action_name = "computer_open_app"
|
|
391
380
|
_save_completed("user", req.task)
|
|
392
381
|
_save_completed("assistant", message)
|
|
393
|
-
yield
|
|
382
|
+
yield sse_frame("final", {"message": message, "steps": [{"step": 1, "action": action_name, "result": result}]})
|
|
394
383
|
except HTTPException as exc:
|
|
395
|
-
yield
|
|
384
|
+
yield sse_frame("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc.detail)})
|
|
396
385
|
except (ToolError, PermissionError) as exc:
|
|
397
|
-
yield
|
|
386
|
+
yield sse_frame("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc)})
|
|
398
387
|
return
|
|
399
388
|
|
|
400
389
|
if not model_router.current_model_id:
|
|
401
|
-
yield
|
|
390
|
+
yield sse_frame("error", {"error": "No model loaded."})
|
|
402
391
|
return
|
|
403
392
|
|
|
404
393
|
transcript: List[Dict[str, Any]] = []
|
|
405
394
|
last_screenshot_b64: Optional[str] = None
|
|
406
395
|
max_steps = max(1, min(req.max_steps, 20))
|
|
407
|
-
yield
|
|
396
|
+
yield sse_frame("start", {"task": req.task, "max_steps": max_steps})
|
|
408
397
|
|
|
409
398
|
for step in range(max_steps):
|
|
410
399
|
context = (
|
|
@@ -423,7 +412,7 @@ def create_computer_use_router(
|
|
|
423
412
|
try:
|
|
424
413
|
action = _extract_agent_action(str(raw))
|
|
425
414
|
except ValueError as exc:
|
|
426
|
-
yield
|
|
415
|
+
yield sse_frame("error", {"step": step + 1, "error": str(exc), "raw": str(raw)})
|
|
427
416
|
break
|
|
428
417
|
|
|
429
418
|
name = action.get("action")
|
|
@@ -432,10 +421,10 @@ def create_computer_use_router(
|
|
|
432
421
|
message = action.get("message", "작업을 완료했습니다.")
|
|
433
422
|
_save_completed("user", req.task)
|
|
434
423
|
_save_completed("assistant", message)
|
|
435
|
-
yield
|
|
424
|
+
yield sse_frame("final", {"message": message, "steps": transcript})
|
|
436
425
|
return
|
|
437
426
|
|
|
438
|
-
yield
|
|
427
|
+
yield sse_frame("action", {"step": step + 1, "action": name, "args": args})
|
|
439
428
|
try:
|
|
440
429
|
result = _dispatch(
|
|
441
430
|
name,
|
|
@@ -449,7 +438,7 @@ def create_computer_use_router(
|
|
|
449
438
|
result_summary = {k: v for k, v in result.items() if k != "screenshot_b64"}
|
|
450
439
|
result_summary["screenshot_captured"] = True
|
|
451
440
|
transcript.append({"step": step + 1, "action": name, "args": args, "result": result_summary})
|
|
452
|
-
yield
|
|
441
|
+
yield sse_frame(
|
|
453
442
|
"screenshot",
|
|
454
443
|
{
|
|
455
444
|
"step": step + 1,
|
|
@@ -461,17 +450,17 @@ def create_computer_use_router(
|
|
|
461
450
|
else:
|
|
462
451
|
last_screenshot_b64 = None
|
|
463
452
|
transcript.append({"step": step + 1, "action": name, "args": args, "result": result})
|
|
464
|
-
yield
|
|
453
|
+
yield sse_frame("result", {"step": step + 1, "action": name, "result": result})
|
|
465
454
|
except HTTPException as exc:
|
|
466
455
|
error_str = str(exc.detail)
|
|
467
456
|
transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
|
|
468
|
-
yield
|
|
457
|
+
yield sse_frame("tool_error", {"step": step + 1, "action": name, "error": error_str})
|
|
469
458
|
except (ToolError, PermissionError, KeyError, TypeError) as exc:
|
|
470
459
|
error_str = str(exc)
|
|
471
460
|
transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
|
|
472
|
-
yield
|
|
461
|
+
yield sse_frame("tool_error", {"step": step + 1, "action": name, "error": error_str})
|
|
473
462
|
|
|
474
|
-
yield
|
|
463
|
+
yield sse_frame("done", {"steps": len(transcript), "transcript": transcript})
|
|
475
464
|
|
|
476
465
|
return StreamingResponse(
|
|
477
466
|
_stream(),
|
package/latticeai/api/health.py
CHANGED
|
@@ -19,6 +19,7 @@ def create_health_router(
|
|
|
19
19
|
engine_status: Callable[[], List[dict]],
|
|
20
20
|
get_current_user: Callable[[Request], Optional[str]],
|
|
21
21
|
require_auth: bool,
|
|
22
|
+
externally_reachable: bool,
|
|
22
23
|
app_version: str,
|
|
23
24
|
app_mode: str,
|
|
24
25
|
) -> APIRouter:
|
|
@@ -29,9 +30,28 @@ def create_health_router(
|
|
|
29
30
|
if require_auth and not get_current_user(request):
|
|
30
31
|
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
|
|
31
32
|
|
|
33
|
+
def _access_posture() -> dict:
|
|
34
|
+
"""The two facts ``trusted_local_owner`` is computed from.
|
|
35
|
+
|
|
36
|
+
Stated on the *unauthenticated* part of ``/health`` on purpose: the Rust
|
|
37
|
+
front door polls this endpoint already, and it gates its own native
|
|
38
|
+
``/rust/*`` lanes on the answer. Those lanes read the store with no
|
|
39
|
+
credential at all, so without this they had no way to tell an
|
|
40
|
+
optional-auth loopback worker (where that is exactly right) from a
|
|
41
|
+
``LATTICEAI_REQUIRE_AUTH=true`` one (where it is a hole). Neither field
|
|
42
|
+
is a secret: an unauthenticated caller discovers both by being refused.
|
|
43
|
+
"""
|
|
44
|
+
return {
|
|
45
|
+
"require_auth": bool(require_auth),
|
|
46
|
+
"externally_reachable": bool(externally_reachable),
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
@router.get("/health")
|
|
33
50
|
async def health(request: Request):
|
|
34
|
-
base =
|
|
51
|
+
base = {
|
|
52
|
+
**svc.health_base(version=app_version, mode=app_mode),
|
|
53
|
+
"access": _access_posture(),
|
|
54
|
+
}
|
|
35
55
|
if not get_current_user(request) and require_auth:
|
|
36
56
|
return base
|
|
37
57
|
engines = await asyncio.to_thread(engine_status)
|
|
@@ -310,6 +310,20 @@ def create_local_files_router(
|
|
|
310
310
|
except PermissionError as exc:
|
|
311
311
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
312
312
|
|
|
313
|
+
@router.get("/api/ingestion/multimodal")
|
|
314
|
+
async def ingestion_multimodal_status(request: Request):
|
|
315
|
+
"""What this install will do with a picture, a recording, or a video.
|
|
316
|
+
|
|
317
|
+
FEATURE_STATUS has described this answer — including which of the three
|
|
318
|
+
reasons a video would be refused for — since 11.2.0, but the pipeline
|
|
319
|
+
method that produces it had no route, so no surface could ever show it
|
|
320
|
+
and "why was my video not indexed?" had no answer short of reading the
|
|
321
|
+
server's environment.
|
|
322
|
+
"""
|
|
323
|
+
require_user(request)
|
|
324
|
+
_require_pipeline()
|
|
325
|
+
return ingestion_pipeline.multimodal_status()
|
|
326
|
+
|
|
313
327
|
@router.get("/api/ingestion/jobs")
|
|
314
328
|
async def ingestion_jobs(request: Request, limit: int = 20):
|
|
315
329
|
"""Recent background ingestion jobs (newest first)."""
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import hashlib
|
|
6
5
|
import json
|
|
7
6
|
import logging
|
|
8
7
|
import os
|
|
@@ -15,7 +14,9 @@ from typing import Any, Dict, Optional, Tuple
|
|
|
15
14
|
|
|
16
15
|
from fastapi import APIRouter, HTTPException, Request
|
|
17
16
|
|
|
17
|
+
from latticeai.core.http_origin import request_external_origin
|
|
18
18
|
from latticeai.core.io_utils import atomic_write_json
|
|
19
|
+
from latticeai.core.security import sha256_hex
|
|
19
20
|
|
|
20
21
|
_PERMISSION_ACTION_LABELS = {
|
|
21
22
|
"list": "폴더 목록 보기",
|
|
@@ -39,16 +40,32 @@ class PermissionGateway:
|
|
|
39
40
|
self.discord_permission_channel = config.discord_permission_channel
|
|
40
41
|
self.permission_monitor_secret = config.permission_monitor_secret
|
|
41
42
|
self.perm_queue_file = data_dir / "permission_queue.json"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
self._explicit_ui_url = os.getenv("LATTICEAI_PERMISSION_UI_URL", "").strip()
|
|
44
|
+
self._public_url = os.getenv("LATTICEAI_PUBLIC_URL", "").strip().rstrip("/")
|
|
45
|
+
# The worker's own port. Behind the desktop gateway this is the
|
|
46
|
+
# *internal* one, which is why a notification built from it sends the
|
|
47
|
+
# approver to a door only the gateway can open — hence
|
|
48
|
+
# ``_front_door``, learned from the request that asked for approval.
|
|
49
|
+
self._loopback_ui_origin = f"http://127.0.0.1:{getattr(config, 'port', 4825)}"
|
|
50
|
+
self._front_door: Optional[str] = None
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def permission_ui_url(self) -> str:
|
|
54
|
+
"""Where to send the person who has to approve this.
|
|
55
|
+
|
|
56
|
+
Operator configuration wins — an explicit URL or a public URL is a
|
|
57
|
+
deliberate statement about how this install is reached. Only when
|
|
58
|
+
nothing is configured does the front door observed on the request that
|
|
59
|
+
triggered the approval beat the worker's own loopback address.
|
|
60
|
+
"""
|
|
61
|
+
if self._explicit_ui_url:
|
|
62
|
+
return self._explicit_ui_url
|
|
63
|
+
base = self._public_url or self._front_door or self._loopback_ui_origin
|
|
64
|
+
return f"{base.rstrip('/')}/app#/admin/permissions"
|
|
48
65
|
|
|
49
66
|
@staticmethod
|
|
50
67
|
def token_hash(token: str) -> str:
|
|
51
|
-
return
|
|
68
|
+
return sha256_hex(str(token or ""))
|
|
52
69
|
|
|
53
70
|
@staticmethod
|
|
54
71
|
def token_hint(token: str) -> str:
|
|
@@ -78,9 +95,6 @@ class PermissionGateway:
|
|
|
78
95
|
except Exception as exc:
|
|
79
96
|
logging.warning("perm_queue_write failed: %s", exc)
|
|
80
97
|
|
|
81
|
-
def _perm_queue_remove(self, token: str) -> None:
|
|
82
|
-
self._perm_queue_remove_key(self.token_hash(token))
|
|
83
|
-
|
|
84
98
|
def _perm_queue_remove_key(self, key: str) -> None:
|
|
85
99
|
try:
|
|
86
100
|
with self.perm_queue_lock:
|
|
@@ -117,7 +131,7 @@ class PermissionGateway:
|
|
|
117
131
|
|
|
118
132
|
@staticmethod
|
|
119
133
|
def content_fingerprint(content: str = "") -> str:
|
|
120
|
-
return
|
|
134
|
+
return sha256_hex(content)
|
|
121
135
|
|
|
122
136
|
def _notify_discord_permission_sync(self, token: str, path: str, action: str, user_email: str) -> None:
|
|
123
137
|
sent = False
|
|
@@ -225,8 +239,20 @@ class PermissionGateway:
|
|
|
225
239
|
email = self.get_current_user(request)
|
|
226
240
|
if not email:
|
|
227
241
|
raise HTTPException(status_code=401, detail="로컬 파일 접근은 로그인 세션이 필요합니다.")
|
|
242
|
+
# Every local-file approval passes through here immediately before the
|
|
243
|
+
# permission record (and its notification) is created, so this is the
|
|
244
|
+
# request whose front door the approver should be sent back to. The
|
|
245
|
+
# forwarded authority is only believed from a loopback or configured
|
|
246
|
+
# proxy peer — the same rule CSRF and the invite link use.
|
|
247
|
+
self.remember_front_door(request)
|
|
228
248
|
return email
|
|
229
249
|
|
|
250
|
+
def remember_front_door(self, request: Request) -> None:
|
|
251
|
+
"""Record the external origin this request came through, if any."""
|
|
252
|
+
origin = request_external_origin(request)
|
|
253
|
+
if origin:
|
|
254
|
+
self._front_door = origin
|
|
255
|
+
|
|
230
256
|
def require_local_approval(
|
|
231
257
|
self,
|
|
232
258
|
*,
|
package/latticeai/api/search.py
CHANGED
|
@@ -32,6 +32,12 @@ class HybridSearchRequest(SearchRequest):
|
|
|
32
32
|
image_fusion: bool = False
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
class GraphSearchRequest(SearchRequest):
|
|
36
|
+
#: How far to walk out from the directly-matching nodes. The service
|
|
37
|
+
#: clamps this to 0–3; 0 means "matches only, no expansion".
|
|
38
|
+
expand_depth: int = 1
|
|
39
|
+
|
|
40
|
+
|
|
35
41
|
class GraphNodeRequest(BaseModel):
|
|
36
42
|
node_id: str = Field(..., min_length=1)
|
|
37
43
|
include_neighbors: bool = True
|
|
@@ -164,6 +170,24 @@ def create_search_router(
|
|
|
164
170
|
except ValueError as exc:
|
|
165
171
|
_raise_graph_error(exc)
|
|
166
172
|
|
|
173
|
+
@router.post("/api/search/graph")
|
|
174
|
+
async def graph_search(req: GraphSearchRequest, request: Request) -> Dict[str, Any]:
|
|
175
|
+
"""Relationship-aware search: direct matches plus what they connect to.
|
|
176
|
+
|
|
177
|
+
The third retrieval channel. It was in ``_ScopedSearchService._SCOPED``
|
|
178
|
+
from the start — so the workspace guard was ready for it — but no
|
|
179
|
+
handler ever called it, which made the allow-list entry the only
|
|
180
|
+
evidence the channel was supposed to be reachable at all.
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
return _guarded(request).graph_search(
|
|
184
|
+
req.query,
|
|
185
|
+
limit=req.limit,
|
|
186
|
+
expand_depth=req.expand_depth,
|
|
187
|
+
)
|
|
188
|
+
except ValueError as exc:
|
|
189
|
+
_raise_graph_error(exc)
|
|
190
|
+
|
|
167
191
|
@router.get("/api/graph")
|
|
168
192
|
async def graph(request: Request, limit: int = 300) -> Dict[str, Any]:
|
|
169
193
|
try:
|
|
@@ -25,7 +25,10 @@ PRODUCTION_CSP = (
|
|
|
25
25
|
"style-src 'self' 'unsafe-inline'; "
|
|
26
26
|
"img-src 'self' data: blob: http://127.0.0.1:*; "
|
|
27
27
|
"font-src 'self' data:; "
|
|
28
|
-
|
|
28
|
+
# No `ws:`: this product has no WebSocket endpoint anywhere. The live
|
|
29
|
+
# surfaces (chat, agent steps, index progress) are server-sent events, read
|
|
30
|
+
# with fetch/ReadableStream, which `connect-src` already covers over http.
|
|
31
|
+
"connect-src 'self' http://127.0.0.1:*; "
|
|
29
32
|
"frame-src 'none'; "
|
|
30
33
|
"object-src 'none'; "
|
|
31
34
|
"base-uri 'none'; "
|
package/latticeai/cli/runtime.py
CHANGED
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import importlib.util
|
|
6
5
|
import os
|
|
7
6
|
from pathlib import Path
|
|
8
7
|
|
|
8
|
+
from latticeai.core.module_probe import module_available
|
|
9
|
+
|
|
10
|
+
#: Historical name. ``cli.entrypoint`` imports it from here and the suite
|
|
11
|
+
#: monkeypatches it there, so the binding has to exist on this module.
|
|
12
|
+
_has_module = module_available
|
|
13
|
+
|
|
9
14
|
|
|
10
15
|
def _load_env_file(path: Path) -> None:
|
|
11
16
|
if not path.exists():
|
|
@@ -33,5 +38,4 @@ def _apply_extra_path() -> None:
|
|
|
33
38
|
os.environ["PATH"] = os.pathsep.join(current)
|
|
34
39
|
|
|
35
40
|
|
|
36
|
-
|
|
37
|
-
return importlib.util.find_spec(name) is not None
|
|
41
|
+
|
package/latticeai/core/config.py
CHANGED
|
@@ -18,6 +18,7 @@ concept and are read dynamically at call time.
|
|
|
18
18
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
+
import os
|
|
21
22
|
import sys
|
|
22
23
|
from dataclasses import dataclass
|
|
23
24
|
from pathlib import Path
|
|
@@ -25,7 +26,50 @@ from typing import List, Mapping, Optional
|
|
|
25
26
|
|
|
26
27
|
from latticeai.core.security import host_is_loopback
|
|
27
28
|
|
|
28
|
-
__all__ = [
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Config",
|
|
31
|
+
"SSO_CALLBACK_PATH",
|
|
32
|
+
"default_data_dir",
|
|
33
|
+
"default_sso_redirect_uri",
|
|
34
|
+
"_value",
|
|
35
|
+
"_str",
|
|
36
|
+
"_bool",
|
|
37
|
+
"_int",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
#: The path the identity provider sends the browser back to.
|
|
41
|
+
SSO_CALLBACK_PATH = "/auth/sso/callback"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def default_sso_redirect_uri(port: int) -> str:
|
|
45
|
+
"""The redirect URI when the operator has registered none.
|
|
46
|
+
|
|
47
|
+
Named rather than inlined because ``api/auth`` has to recognise it: this
|
|
48
|
+
exact string means "nobody chose a callback URL", which is the only case in
|
|
49
|
+
which the login flow may substitute the front door it was actually reached
|
|
50
|
+
through. A URI the operator *did* configure is registered with the identity
|
|
51
|
+
provider and must be sent back verbatim.
|
|
52
|
+
"""
|
|
53
|
+
return f"http://localhost:{port}{SSO_CALLBACK_PATH}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
#: Where everything durable lives when the operator names nothing.
|
|
57
|
+
DEFAULT_DATA_DIR_NAME = ".ltcai"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def default_data_dir(env: Optional[Mapping[str, str]] = None) -> Path:
|
|
61
|
+
"""The data directory ``LATTICEAI_DATA_DIR`` names, else ``~/.ltcai``.
|
|
62
|
+
|
|
63
|
+
Three runtime wiring modules each carried a byte-identical copy of this,
|
|
64
|
+
which meant a lazily-initialized service could disagree with ``Config``
|
|
65
|
+
about where the store lives — and the loser writes a second, invisible
|
|
66
|
+
copy of the user's data. ``env=None`` reads the live process environment,
|
|
67
|
+
which is what the lazy wiring paths need.
|
|
68
|
+
"""
|
|
69
|
+
source = os.environ if env is None else env
|
|
70
|
+
raw = (source.get("LATTICEAI_DATA_DIR") or "").strip()
|
|
71
|
+
return Path(raw) if raw else Path.home() / DEFAULT_DATA_DIR_NAME
|
|
72
|
+
|
|
29
73
|
|
|
30
74
|
def _value(env: Mapping[str, str], key: str, default: str = "") -> str:
|
|
31
75
|
"""Mirror the legacy ``env_value``: ``getenv(key) or default or ""`` (no strip)."""
|
|
@@ -177,14 +221,14 @@ class Config:
|
|
|
177
221
|
# download path, the on-disk cache dir and the catalog key all agree.
|
|
178
222
|
local_model = _value(env, "LATTICEAI_LOCAL_MODEL", "mlx-community/gemma-4-12B-it-4bit")
|
|
179
223
|
|
|
180
|
-
data_dir =
|
|
224
|
+
data_dir = default_data_dir(env)
|
|
181
225
|
static_dir = Path(_value(env, "LATTICEAI_STATIC_DIR", str(base_dir / "static")))
|
|
182
226
|
if not static_dir.exists():
|
|
183
227
|
packaged_static = Path(sys.prefix) / "static"
|
|
184
228
|
if packaged_static.exists():
|
|
185
229
|
static_dir = packaged_static
|
|
186
230
|
|
|
187
|
-
default_sso_redirect =
|
|
231
|
+
default_sso_redirect = default_sso_redirect_uri(port)
|
|
188
232
|
|
|
189
233
|
return cls(
|
|
190
234
|
app_mode=app_mode,
|
package/latticeai/core/csrf.py
CHANGED
|
@@ -29,6 +29,12 @@ Threat model, stated so the exemptions are checkable rather than vibes:
|
|
|
29
29
|
the desktop shell, the VS Code extension). That is trusted only while the
|
|
30
30
|
server is bound to loopback, where "not a browser" and "already on this
|
|
31
31
|
machine" coincide. On a reachable bind it fails closed.
|
|
32
|
+
* **Behind a proxy, ``Host`` is not the front door.** The desktop gateway
|
|
33
|
+
replaces it with the worker's internal authority, so the ``Origin == Host``
|
|
34
|
+
fallback below compared a browser's real origin against a port the browser
|
|
35
|
+
has never heard of and refused every write. The front door's own name comes
|
|
36
|
+
from :mod:`latticeai.core.http_origin`, which believes ``X-Forwarded-Host``
|
|
37
|
+
only from a peer that may forward.
|
|
32
38
|
"""
|
|
33
39
|
|
|
34
40
|
from __future__ import annotations
|
|
@@ -48,6 +54,11 @@ from typing import (
|
|
|
48
54
|
)
|
|
49
55
|
from urllib.parse import urlsplit
|
|
50
56
|
|
|
57
|
+
from latticeai.core.http_origin import (
|
|
58
|
+
FORWARDED_HOST_HEADER,
|
|
59
|
+
effective_host,
|
|
60
|
+
)
|
|
61
|
+
|
|
51
62
|
__all__ = [
|
|
52
63
|
"CSRFDecision",
|
|
53
64
|
"CSRFOriginPolicy",
|
|
@@ -176,6 +187,8 @@ class CSRFOriginPolicy:
|
|
|
176
187
|
self,
|
|
177
188
|
origin: Tuple[str, str, Optional[int]],
|
|
178
189
|
host_header: Optional[str],
|
|
190
|
+
forwarded_host: Optional[str] = None,
|
|
191
|
+
peer: Optional[str] = None,
|
|
179
192
|
) -> bool:
|
|
180
193
|
if any(_same_site(origin, trusted) for trusted in self._trusted):
|
|
181
194
|
return True
|
|
@@ -184,7 +197,13 @@ class CSRFOriginPolicy:
|
|
|
184
197
|
# page this server actually served — which is precisely "not cross
|
|
185
198
|
# site". This is what keeps reverse-proxied hostnames working without
|
|
186
199
|
# every operator having to enumerate them.
|
|
187
|
-
|
|
200
|
+
#
|
|
201
|
+
# "The request's own Host" is the *front door's*, not the internal one a
|
|
202
|
+
# proxy hop rewrote it to, which is why the forwarded authority is
|
|
203
|
+
# resolved first (and only from a peer that may forward).
|
|
204
|
+
own = normalize_origin(
|
|
205
|
+
effective_host(host=host_header, forwarded_host=forwarded_host, peer=peer)
|
|
206
|
+
)
|
|
188
207
|
return own is not None and _same_site(origin, own)
|
|
189
208
|
|
|
190
209
|
def evaluate(
|
|
@@ -196,6 +215,8 @@ class CSRFOriginPolicy:
|
|
|
196
215
|
host: Optional[str],
|
|
197
216
|
cookie_header: Optional[str],
|
|
198
217
|
authorization: Optional[str],
|
|
218
|
+
forwarded_host: Optional[str] = None,
|
|
219
|
+
peer: Optional[str] = None,
|
|
199
220
|
) -> CSRFDecision:
|
|
200
221
|
"""Allow/deny one request. Pure: no I/O, no app state."""
|
|
201
222
|
if method.upper() in SAFE_METHODS:
|
|
@@ -219,7 +240,7 @@ class CSRFOriginPolicy:
|
|
|
219
240
|
return CSRFDecision(True, "no-origin-loopback-bind")
|
|
220
241
|
return CSRFDecision(False, "no-origin-reachable-bind")
|
|
221
242
|
|
|
222
|
-
if self._origin_is_trusted(stated, host):
|
|
243
|
+
if self._origin_is_trusted(stated, host, forwarded_host, peer):
|
|
223
244
|
return CSRFDecision(True, "same-site-or-trusted-origin")
|
|
224
245
|
return CSRFDecision(False, "cross-site-origin")
|
|
225
246
|
|
|
@@ -261,6 +282,9 @@ class CSRFOriginGuardMiddleware:
|
|
|
261
282
|
key.decode("latin-1").lower(): value.decode("latin-1")
|
|
262
283
|
for key, value in scope.get("headers") or []
|
|
263
284
|
}
|
|
285
|
+
# ``client`` is ``(host, port)`` under every ASGI server, and absent
|
|
286
|
+
# under a test transport that does not model a socket.
|
|
287
|
+
client = scope.get("client") or ()
|
|
264
288
|
decision = self.policy.evaluate(
|
|
265
289
|
method=str(scope.get("method") or "GET"),
|
|
266
290
|
origin=headers.get("origin"),
|
|
@@ -268,6 +292,8 @@ class CSRFOriginGuardMiddleware:
|
|
|
268
292
|
host=headers.get("host"),
|
|
269
293
|
cookie_header=headers.get("cookie"),
|
|
270
294
|
authorization=headers.get("authorization"),
|
|
295
|
+
forwarded_host=headers.get(FORWARDED_HOST_HEADER),
|
|
296
|
+
peer=client[0] if client else None,
|
|
271
297
|
)
|
|
272
298
|
if decision.allowed:
|
|
273
299
|
await self.app(scope, receive, send)
|
|
@@ -15,7 +15,7 @@ swapped behind a single interface as long as every implementation agrees on:
|
|
|
15
15
|
holds the five concrete text implementations:
|
|
16
16
|
|
|
17
17
|
1. :class:`HashEmbeddingProvider` — deterministic, offline, always-available
|
|
18
|
-
fallback (wraps
|
|
18
|
+
fallback (wraps :class:`~lattice_brain.embeddings.LocalEmbeddingModel`).
|
|
19
19
|
2. :class:`MLXEmbeddingProvider` — local Apple-Silicon embedding models.
|
|
20
20
|
3. :class:`OllamaEmbeddingProvider` — a local/remote Ollama server.
|
|
21
21
|
4. :class:`OpenAICompatibleEmbeddingProvider` — any ``/v1/embeddings`` endpoint
|
|
@@ -65,10 +65,8 @@ from __future__ import annotations
|
|
|
65
65
|
# the two names it imported for its own use were part of its surface, and the
|
|
66
66
|
# suite imports them from here. Re-exported in the redundant-alias form so they
|
|
67
67
|
# read as deliberate rather than as leftover imports.
|
|
68
|
-
from
|
|
69
|
-
|
|
70
|
-
)
|
|
71
|
-
from latticeai.core.local_embeddings import LocalEmbeddingModel as LocalEmbeddingModel
|
|
68
|
+
from lattice_brain.embeddings import DEFAULT_EMBEDDING_DIM as DEFAULT_EMBEDDING_DIM
|
|
69
|
+
from lattice_brain.embeddings import LocalEmbeddingModel as LocalEmbeddingModel
|
|
72
70
|
|
|
73
71
|
from .base import _KNOWN_DIMS as _KNOWN_DIMS
|
|
74
72
|
from .base import EmbeddingProvider as EmbeddingProvider
|
|
@@ -19,7 +19,7 @@ import struct
|
|
|
19
19
|
from dataclasses import dataclass, field
|
|
20
20
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence
|
|
21
21
|
|
|
22
|
-
from
|
|
22
|
+
from lattice_brain.embeddings import DEFAULT_EMBEDDING_DIM
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
class EmbeddingUnavailable(RuntimeError):
|
|
@@ -14,7 +14,7 @@ import os
|
|
|
14
14
|
from dataclasses import dataclass
|
|
15
15
|
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
16
16
|
|
|
17
|
-
from
|
|
17
|
+
from lattice_brain.embeddings import DEFAULT_EMBEDDING_DIM, LocalEmbeddingModel
|
|
18
18
|
|
|
19
19
|
from .base import (
|
|
20
20
|
EmbeddingProvider,
|
|
@@ -22,7 +22,7 @@ from __future__ import annotations
|
|
|
22
22
|
|
|
23
23
|
import os
|
|
24
24
|
from enum import Enum
|
|
25
|
-
from typing import Dict,
|
|
25
|
+
from typing import Dict, Protocol, runtime_checkable
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
class Edition(str, Enum):
|
|
@@ -115,9 +115,6 @@ class CapabilityRegistry:
|
|
|
115
115
|
# A misbehaving plugin must never break a Community request.
|
|
116
116
|
return False
|
|
117
117
|
|
|
118
|
-
def available_capabilities(self) -> List[EnterpriseCapability]:
|
|
119
|
-
return [cap for cap in EnterpriseCapability if self.is_capability_enabled(cap)]
|
|
120
|
-
|
|
121
118
|
def describe(self) -> Dict[str, object]:
|
|
122
119
|
"""Edition + capability matrix for ``/workspace/editions`` and admin UI."""
|
|
123
120
|
return {
|