ltcai 9.0.0 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/README.md +88 -46
  2. package/auto_setup.py +7 -853
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +178 -2
  6. package/docs/COMMUNITY_AND_PLUGINS.md +4 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/TRUST_MODEL.md +5 -2
  13. package/docs/WHY_LATTICE.md +15 -10
  14. package/docs/WORKFLOW_DESIGNER.md +22 -0
  15. package/docs/kg-schema.md +13 -2
  16. package/docs/mcp-tools.md +17 -6
  17. package/docs/privacy.md +19 -3
  18. package/docs/public-deploy.md +32 -3
  19. package/docs/security-model.md +46 -11
  20. package/lattice_brain/__init__.py +1 -1
  21. package/lattice_brain/archive.py +1 -6
  22. package/lattice_brain/context.py +66 -9
  23. package/lattice_brain/graph/_kg_fsutil.py +1 -5
  24. package/lattice_brain/graph/discovery_index.py +174 -20
  25. package/lattice_brain/graph/documents.py +44 -9
  26. package/lattice_brain/graph/ingest.py +47 -20
  27. package/lattice_brain/graph/provenance.py +13 -6
  28. package/lattice_brain/graph/retrieval.py +140 -39
  29. package/lattice_brain/graph/retrieval_docgen.py +37 -4
  30. package/lattice_brain/ingestion.py +4 -7
  31. package/lattice_brain/portability.py +28 -14
  32. package/lattice_brain/runtime/agent_runtime.py +5 -9
  33. package/lattice_brain/runtime/hooks.py +30 -13
  34. package/lattice_brain/runtime/multi_agent.py +27 -8
  35. package/lattice_brain/runtime/statuses.py +10 -0
  36. package/lattice_brain/utils.py +20 -2
  37. package/lattice_brain/workflow.py +1 -5
  38. package/latticeai/__init__.py +1 -1
  39. package/latticeai/api/admin.py +1 -1
  40. package/latticeai/api/agent_registry.py +10 -8
  41. package/latticeai/api/agents.py +22 -3
  42. package/latticeai/api/auth.py +78 -16
  43. package/latticeai/api/browser.py +303 -34
  44. package/latticeai/api/chat.py +320 -850
  45. package/latticeai/api/chat_agent_http.py +356 -0
  46. package/latticeai/api/chat_contracts.py +54 -0
  47. package/latticeai/api/chat_documents.py +256 -0
  48. package/latticeai/api/chat_helpers.py +28 -1
  49. package/latticeai/api/chat_history.py +99 -0
  50. package/latticeai/api/chat_intents.py +316 -0
  51. package/latticeai/api/chat_stream.py +115 -0
  52. package/latticeai/api/computer_use.py +62 -9
  53. package/latticeai/api/health.py +9 -3
  54. package/latticeai/api/hooks.py +17 -6
  55. package/latticeai/api/knowledge_graph.py +78 -14
  56. package/latticeai/api/local_files.py +7 -2
  57. package/latticeai/api/mcp.py +102 -23
  58. package/latticeai/api/models.py +72 -33
  59. package/latticeai/api/network.py +5 -5
  60. package/latticeai/api/permissions.py +67 -26
  61. package/latticeai/api/plugins.py +21 -7
  62. package/latticeai/api/portability.py +5 -5
  63. package/latticeai/api/realtime.py +33 -5
  64. package/latticeai/api/setup.py +2 -2
  65. package/latticeai/api/static_routes.py +123 -24
  66. package/latticeai/api/tools.py +96 -14
  67. package/latticeai/api/workflow_designer.py +37 -0
  68. package/latticeai/api/workspace.py +2 -1
  69. package/latticeai/app_factory.py +110 -52
  70. package/latticeai/core/agent.py +50 -13
  71. package/latticeai/core/agent_prompts.py +5 -0
  72. package/latticeai/core/agent_registry.py +1 -5
  73. package/latticeai/core/config.py +23 -3
  74. package/latticeai/core/context_builder.py +21 -3
  75. package/latticeai/core/file_generation.py +451 -0
  76. package/latticeai/core/invitations.py +1 -4
  77. package/latticeai/core/io_utils.py +9 -1
  78. package/latticeai/core/legacy_compatibility.py +24 -3
  79. package/latticeai/core/marketplace.py +1 -1
  80. package/latticeai/core/plugins.py +15 -0
  81. package/latticeai/core/policy.py +1 -1
  82. package/latticeai/core/realtime.py +38 -15
  83. package/latticeai/core/sessions.py +7 -2
  84. package/latticeai/core/timeutil.py +10 -0
  85. package/latticeai/core/tool_registry.py +90 -18
  86. package/latticeai/core/users.py +1 -5
  87. package/latticeai/core/workspace_graph_trace.py +31 -5
  88. package/latticeai/core/workspace_memory.py +3 -1
  89. package/latticeai/core/workspace_os.py +18 -7
  90. package/latticeai/core/workspace_os_utils.py +0 -5
  91. package/latticeai/core/workspace_permissions.py +2 -1
  92. package/latticeai/core/workspace_plugins.py +1 -1
  93. package/latticeai/core/workspace_runs.py +14 -5
  94. package/latticeai/core/workspace_skills.py +1 -1
  95. package/latticeai/core/workspace_snapshots.py +2 -1
  96. package/latticeai/core/workspace_timeline.py +2 -1
  97. package/latticeai/integrations/telegram_bot.py +94 -43
  98. package/latticeai/models/router.py +130 -36
  99. package/latticeai/runtime/access_runtime.py +62 -4
  100. package/latticeai/runtime/automation_runtime.py +13 -7
  101. package/latticeai/runtime/brain_runtime.py +19 -7
  102. package/latticeai/runtime/chat_wiring.py +2 -0
  103. package/latticeai/runtime/config_runtime.py +58 -26
  104. package/latticeai/runtime/context_runtime.py +16 -4
  105. package/latticeai/runtime/hooks_runtime.py +1 -1
  106. package/latticeai/runtime/model_wiring.py +33 -5
  107. package/latticeai/runtime/namespace_runtime.py +62 -72
  108. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  109. package/latticeai/runtime/review_wiring.py +1 -1
  110. package/latticeai/runtime/router_registration.py +34 -1
  111. package/latticeai/runtime/security_runtime.py +110 -13
  112. package/latticeai/runtime/stages.py +27 -0
  113. package/latticeai/server_app.py +5 -1
  114. package/latticeai/services/architecture_readiness.py +74 -5
  115. package/latticeai/services/brain_automation.py +3 -26
  116. package/latticeai/services/chat_service.py +205 -15
  117. package/latticeai/services/local_knowledge.py +423 -0
  118. package/latticeai/services/memory_service.py +55 -21
  119. package/latticeai/services/model_engines.py +48 -33
  120. package/latticeai/services/model_errors.py +17 -0
  121. package/latticeai/services/model_loading.py +28 -25
  122. package/latticeai/services/model_runtime.py +228 -162
  123. package/latticeai/services/p_reinforce.py +22 -3
  124. package/latticeai/services/platform_runtime.py +83 -23
  125. package/latticeai/services/product_readiness.py +19 -17
  126. package/latticeai/services/review_queue.py +12 -0
  127. package/latticeai/services/router_context.py +1 -0
  128. package/latticeai/services/run_executor.py +4 -9
  129. package/latticeai/services/search_service.py +203 -28
  130. package/latticeai/services/tool_dispatch.py +28 -5
  131. package/latticeai/services/triggers.py +53 -4
  132. package/latticeai/services/upload_service.py +13 -2
  133. package/latticeai/setup/__init__.py +25 -0
  134. package/latticeai/setup/auto_setup.py +857 -0
  135. package/latticeai/setup/wizard.py +1264 -0
  136. package/latticeai/tools/__init__.py +278 -0
  137. package/{tools → latticeai/tools}/commands.py +67 -7
  138. package/{tools → latticeai/tools}/computer.py +1 -1
  139. package/{tools → latticeai/tools}/documents.py +1 -1
  140. package/{tools → latticeai/tools}/filesystem.py +3 -3
  141. package/latticeai/tools/knowledge.py +178 -0
  142. package/{tools → latticeai/tools}/local_files.py +1 -1
  143. package/{tools → latticeai/tools}/network.py +0 -1
  144. package/local_knowledge_api.py +4 -342
  145. package/package.json +22 -2
  146. package/scripts/brain_quality_eval.py +3 -1
  147. package/scripts/bump_version.py +3 -0
  148. package/scripts/capture_release_evidence.mjs +180 -0
  149. package/scripts/check_current_release_docs.mjs +142 -0
  150. package/scripts/check_i18n_literals.mjs +107 -31
  151. package/scripts/check_openapi_drift.mjs +56 -0
  152. package/scripts/export_openapi.py +67 -7
  153. package/scripts/i18n_literal_allowlist.json +22 -24
  154. package/scripts/run_integration_tests.mjs +72 -10
  155. package/scripts/validate_release_artifacts.py +49 -7
  156. package/scripts/wheel_smoke.py +5 -0
  157. package/setup_wizard.py +3 -1260
  158. package/src-tauri/Cargo.lock +1 -1
  159. package/src-tauri/Cargo.toml +1 -1
  160. package/src-tauri/tauri.conf.json +1 -1
  161. package/static/app/asset-manifest.json +11 -11
  162. package/static/app/assets/Act-C3dBrWE-.js +1 -0
  163. package/static/app/assets/Brain-DBYgdcjt.js +321 -0
  164. package/static/app/assets/Capture-Cf3hqRtN.js +1 -0
  165. package/static/app/assets/Library-CFfkNn3s.js +1 -0
  166. package/static/app/assets/System-BOurbT-v.js +1 -0
  167. package/static/app/assets/index-A3M9sElj.js +17 -0
  168. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  169. package/static/app/assets/primitives-DcUUmhdC.js +1 -0
  170. package/static/app/assets/textarea-BklR6zN4.js +1 -0
  171. package/static/app/index.html +2 -2
  172. package/static/sw.js +1 -1
  173. package/tools/__init__.py +21 -269
  174. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  175. package/latticeai/runtime/app_context_runtime.py +0 -13
  176. package/latticeai/runtime/tail_wiring.py +0 -21
  177. package/scripts/com.pts.claudecode.discord.plist +0 -31
  178. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  179. package/scripts/start-pts-claudecode-discord.sh +0 -51
  180. package/static/app/assets/Act-21lIXx2E.js +0 -1
  181. package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
  182. package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
  183. package/static/app/assets/Library-bFMtyni3.js +0 -1
  184. package/static/app/assets/System-K6krGCqn.js +0 -1
  185. package/static/app/assets/index-C4R3ws30.js +0 -17
  186. package/static/app/assets/index-ChSeOB02.css +0 -2
  187. package/static/app/assets/primitives-sQU3it5I.js +0 -1
  188. package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
  189. package/tools/knowledge.py +0 -95
@@ -5,10 +5,8 @@ Extracted from ``server_app.py`` in v1.3.0. Paths and schemas unchanged:
5
5
  ``/setup/set-api-key``.
6
6
 
7
7
  Mirrors the established router-factory convention: the heavy provider/runtime
8
- helpers (engine_status, prepare_and_load_model, download_hf_model,
9
- verify_cloud_models, …) remain owned by server_app for now and are injected here
10
- as callables, so this module has no import cycle and adds no import-time
11
- side effects.
8
+ helpers are injected as bound service callables. This module owns the sole
9
+ translation from transport-neutral ``ModelRuntimeError`` failures to HTTP.
12
10
  """
13
11
 
14
12
  from __future__ import annotations
@@ -16,12 +14,14 @@ from __future__ import annotations
16
14
  import asyncio
17
15
  import logging
18
16
  import subprocess
19
- from typing import Any, Callable, Dict, List, Optional
17
+ from typing import Any, Callable, Dict, List, NoReturn, Optional
20
18
 
21
19
  from fastapi import APIRouter, HTTPException, Request
22
20
  from fastapi.responses import StreamingResponse
23
21
  from pydantic import BaseModel
24
22
 
23
+ from latticeai.services.model_errors import ModelRuntimeError
24
+
25
25
 
26
26
  def _vision_capability(current_model_id: Optional[str], engines: Any) -> Dict[str, Any]:
27
27
  """Whether the active model can accept image input (VLM).
@@ -97,6 +97,7 @@ def create_models_router(
97
97
  *,
98
98
  model_router: Any,
99
99
  require_user: Callable[[Request], str],
100
+ require_admin: Callable[[Request], tuple],
100
101
  get_current_user: Callable[[Request], Optional[str]],
101
102
  load_users: Callable[[], Dict],
102
103
  get_user_role: Callable[..., str],
@@ -131,6 +132,27 @@ def create_models_router(
131
132
  REQUIRE_AUTH = require_auth
132
133
  _list_compat_profiles = list_compat_profiles
133
134
 
135
+ def _normalized_identity(value: Optional[str]) -> str:
136
+ return str(value or "").strip().lower()
137
+
138
+ def _authorize_model_admin(request: Request, claimed_email: Optional[str] = None) -> str:
139
+ """Authenticate model operations and gate host-global state to admins."""
140
+ current_user = require_user(request)
141
+ if REQUIRE_AUTH:
142
+ if claimed_email and _normalized_identity(claimed_email) != _normalized_identity(current_user):
143
+ raise HTTPException(status_code=403, detail="다른 사용자의 모델 자격 증명을 사용할 수 없습니다.")
144
+ require_admin(request)
145
+ return current_user
146
+
147
+ def _effective_email(current_user: str, claimed_email: Optional[str]) -> Optional[str]:
148
+ # Authenticated callers may only act as their session identity. The
149
+ # legacy body field remains usable solely in explicit no-auth/local
150
+ # mode for backward compatibility.
151
+ return current_user if REQUIRE_AUTH else claimed_email or current_user or None
152
+
153
+ def _raise_model_http(exc: ModelRuntimeError) -> NoReturn:
154
+ raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
155
+
134
156
  def _recommended_with_engine_options(
135
157
  items: List[Dict[str, object]],
136
158
  engines: Optional[List[Dict[str, object]]] = None,
@@ -281,20 +303,26 @@ def create_models_router(
281
303
 
282
304
  @router.post("/engines/install")
283
305
  async def engines_install(req: InstallEngineRequest, request: Request):
284
- require_user(request)
285
- if req.confirmation_token:
286
- return install_engine(req.engine, confirmation_token=req.confirmation_token)
287
- return install_engine(req.engine)
306
+ _authorize_model_admin(request)
307
+ try:
308
+ if req.confirmation_token:
309
+ return install_engine(req.engine, confirmation_token=req.confirmation_token)
310
+ return install_engine(req.engine)
311
+ except ModelRuntimeError as exc:
312
+ _raise_model_http(exc)
288
313
 
289
314
  @router.post("/engines/verify-cloud")
290
315
  async def engines_verify_cloud(req: VerifyCloudRequest, request: Request):
291
- require_user(request)
292
- results = await verify_cloud_models(force=req.force, provider_filter=req.provider)
316
+ _authorize_model_admin(request)
317
+ try:
318
+ results = await verify_cloud_models(force=req.force, provider_filter=req.provider)
319
+ except ModelRuntimeError as exc:
320
+ _raise_model_http(exc)
293
321
  return {"verified": results, "ttl_seconds": CLOUD_VERIFY_TTL_SECONDS}
294
322
 
295
323
  @router.post("/engines/pull-model")
296
324
  async def pull_ollama_model(req: PullModelRequest, request: Request):
297
- require_user(request)
325
+ _authorize_model_admin(request)
298
326
  if not req.allow_download:
299
327
  raise HTTPException(
300
328
  status_code=403,
@@ -315,7 +343,10 @@ def create_models_router(
315
343
  raise HTTPException(status_code=400, detail="모델 이름이 비어 있습니다.")
316
344
 
317
345
  if provider == "ollama":
318
- ensure_ollama_server()
346
+ try:
347
+ ensure_ollama_server()
348
+ except ModelRuntimeError as exc:
349
+ _raise_model_http(exc)
319
350
  ollama = local_binary("ollama")
320
351
  if not ollama:
321
352
  raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
@@ -342,19 +373,25 @@ def create_models_router(
342
373
 
343
374
  if provider in {"vllm", "llamacpp", "local_mlx", "mlx"}:
344
375
  download_provider = "local_mlx" if provider == "mlx" else provider
345
- result = download_hf_model(model_name, download_provider)
376
+ try:
377
+ result = download_hf_model(model_name, download_provider)
378
+ except ModelRuntimeError as exc:
379
+ _raise_model_http(exc)
346
380
  return {"provider": provider, "model": model_name, "returncode": 0, **result}
347
381
 
348
382
  raise HTTPException(status_code=400, detail=f"{provider} 엔진 모델 다운로드는 아직 자동화되지 않았습니다.")
349
383
 
350
384
  @router.post("/engines/prepare-model")
351
385
  async def engines_prepare_model(req: PrepareModelRequest, request: Request):
352
- require_user(request)
386
+ current_user = _authorize_model_admin(request, req.user_email)
353
387
  try:
354
388
  return await prepare_and_load_model(
355
- req.model, request, engine=req.engine, user_email=req.user_email,
389
+ req.model, request, engine=req.engine,
390
+ user_email=_effective_email(current_user, req.user_email),
356
391
  allow_download=req.allow_download,
357
392
  )
393
+ except ModelRuntimeError as exc:
394
+ _raise_model_http(exc)
358
395
  except HTTPException:
359
396
  raise
360
397
  except Exception as exc:
@@ -367,16 +404,17 @@ def create_models_router(
367
404
 
368
405
  @router.post("/engines/prepare-model/stream")
369
406
  async def engines_prepare_model_stream(req: PrepareModelRequest, request: Request):
370
- require_user(request)
407
+ current_user = _authorize_model_admin(request, req.user_email)
408
+ effective_email = _effective_email(current_user, req.user_email)
371
409
 
372
410
  async def event_stream():
373
411
  try:
374
412
  async for chunk in prepare_and_load_model_stream(
375
- req.model, request, engine=req.engine, user_email=req.user_email,
413
+ req.model, request, engine=req.engine, user_email=effective_email,
376
414
  allow_download=req.allow_download,
377
415
  ):
378
416
  yield chunk
379
- except HTTPException as exc:
417
+ except (HTTPException, ModelRuntimeError) as exc:
380
418
  yield sse_event("error", {
381
419
  "status_code": exc.status_code,
382
420
  "detail": exc.detail or "모델 준비에 실패했습니다.",
@@ -404,14 +442,10 @@ def create_models_router(
404
442
  raise HTTPException(status_code=400, detail="알 수 없는 프로바이더입니다.")
405
443
  if not req.key.strip():
406
444
  raise HTTPException(status_code=400, detail="API 키가 비어있습니다.")
407
- current_user = get_current_user(request)
408
- if REQUIRE_AUTH and not current_user:
409
- raise HTTPException(status_code=401, detail="인증이 필요합니다.")
410
- if req.user_email and req.user_email != current_user:
411
- users = load_users()
412
- if get_user_role(current_user or "", users) != "admin":
413
- raise HTTPException(status_code=403, detail="다른 사용자의 API 키를 설정할 권한이 없습니다.")
414
- target_email = (req.user_email or current_user or "").strip()
445
+ current_user = require_user(request)
446
+ if REQUIRE_AUTH and req.user_email and _normalized_identity(req.user_email) != _normalized_identity(current_user):
447
+ raise HTTPException(status_code=403, detail="다른 사용자의 API 키를 설정할 권한이 없습니다.")
448
+ target_email = str(_effective_email(current_user, req.user_email) or "").strip()
415
449
  if not target_email:
416
450
  raise HTTPException(status_code=400, detail="사용자 식별이 필요합니다. 로그인 후 다시 시도하세요.")
417
451
  set_user_api_key(target_email, req.provider, req.key.strip())
@@ -420,7 +454,8 @@ def create_models_router(
420
454
  # ── Models ────────────────────────────────────────────────────────────
421
455
 
422
456
  @router.get("/models")
423
- async def list_models():
457
+ async def list_models(request: Request):
458
+ _authorize_model_admin(request)
424
459
  engines = await asyncio.to_thread(engine_status)
425
460
  recommended = _recommended_with_engine_options(
426
461
  list(filter_lower_family_versions(ENGINE_MODEL_CATALOG.get("local_mlx", []))),
@@ -457,6 +492,7 @@ def create_models_router(
457
492
 
458
493
  @router.post("/models/load")
459
494
  async def load_model(req: LoadModelRequest, request: Request):
495
+ current_user = _authorize_model_admin(request, req.user_email)
460
496
  try:
461
497
  from latticeai.core.model_compat import friendly_model_runtime_error, model_runtime_compatibility
462
498
 
@@ -471,10 +507,13 @@ def create_models_router(
471
507
  if compatibility.get("supported") is False:
472
508
  raise HTTPException(status_code=400, detail=compatibility)
473
509
  return await prepare_and_load_model(
474
- model_id, request, engine=req.engine, user_email=req.user_email,
510
+ model_id, request, engine=req.engine,
511
+ user_email=_effective_email(current_user, req.user_email),
475
512
  adapter_path=req.adapter_path, draft_model_id=req.draft_model_id,
476
513
  allow_download=req.allow_download,
477
514
  )
515
+ except ModelRuntimeError as exc:
516
+ _raise_model_http(exc)
478
517
  except HTTPException:
479
518
  raise
480
519
  except Exception as e:
@@ -485,7 +524,7 @@ def create_models_router(
485
524
 
486
525
  @router.post("/models/switch/{model_id:path}")
487
526
  async def switch_model(model_id: str, request: Request):
488
- require_user(request)
527
+ _authorize_model_admin(request)
489
528
  try:
490
529
  _router.switch_model(model_id)
491
530
  return {"status": "ok", "current": _router.current_model_id}
@@ -494,13 +533,13 @@ def create_models_router(
494
533
 
495
534
  @router.delete("/models/unload/{model_id:path}")
496
535
  async def unload_model(model_id: str, request: Request):
497
- require_user(request)
536
+ _authorize_model_admin(request)
498
537
  _router.unload_model(model_id)
499
538
  return {"status": "ok", "unloaded": model_id}
500
539
 
501
540
  @router.delete("/models/unload-all")
502
541
  async def unload_all_models(request: Request):
503
- require_user(request)
542
+ _authorize_model_admin(request)
504
543
  unloaded = _router.loaded_model_ids
505
544
  _router.unload_all()
506
545
  return {"status": "ok", "unloaded": unloaded}
@@ -513,7 +552,7 @@ def create_models_router(
513
552
  hardware, load_strategy, license, safety_notes) from the structured registry.
514
553
  """
515
554
  require_user(request)
516
- from auto_setup import probe as auto_setup_probe
555
+ from latticeai.setup.auto_setup import probe as auto_setup_probe
517
556
  from latticeai.services.model_recommendation import recommend_catalog
518
557
 
519
558
  profile = await asyncio.to_thread(lambda: auto_setup_probe().to_json())
@@ -23,7 +23,7 @@ class PeerPushRequest(BaseModel):
23
23
  workspace_id: Optional[str] = None
24
24
 
25
25
 
26
- def create_network_router(*, network, identity, require_user) -> APIRouter:
26
+ def create_network_router(*, network, identity, require_user, require_admin) -> APIRouter:
27
27
  router = APIRouter()
28
28
 
29
29
  @router.get("/network/identity")
@@ -33,12 +33,12 @@ def create_network_router(*, network, identity, require_user) -> APIRouter:
33
33
 
34
34
  @router.get("/network/peers")
35
35
  async def network_peers(request: Request):
36
- require_user(request)
36
+ require_admin(request)
37
37
  return {"peers": network.list_peers()}
38
38
 
39
39
  @router.post("/network/peers")
40
40
  async def network_pair(req: PeerPairRequest, request: Request):
41
- require_user(request)
41
+ require_admin(request)
42
42
  try:
43
43
  return {"status": "paired", "peer": network.add_peer(
44
44
  name=req.name, base_url=req.base_url, public_key=req.public_key,
@@ -48,7 +48,7 @@ def create_network_router(*, network, identity, require_user) -> APIRouter:
48
48
 
49
49
  @router.delete("/network/peers/{peer_id}")
50
50
  async def network_unpair(peer_id: str, request: Request):
51
- require_user(request)
51
+ require_admin(request)
52
52
  try:
53
53
  return network.remove_peer(peer_id)
54
54
  except FileNotFoundError as exc:
@@ -56,7 +56,7 @@ def create_network_router(*, network, identity, require_user) -> APIRouter:
56
56
 
57
57
  @router.post("/network/push/{peer_id}")
58
58
  async def network_push(peer_id: str, req: PeerPushRequest, request: Request):
59
- require_user(request)
59
+ require_admin(request)
60
60
  try:
61
61
  return network.push_to_peer(peer_id, workspace_id=req.workspace_id)
62
62
  except FileNotFoundError as exc:
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import hashlib
6
6
  import json
7
7
  import logging
8
+ import os
8
9
  import secrets
9
10
  import threading
10
11
  import time
@@ -14,6 +15,8 @@ from typing import Dict, Optional, Tuple
14
15
 
15
16
  from fastapi import APIRouter, HTTPException, Request
16
17
 
18
+ from latticeai.core.io_utils import atomic_write_json
19
+
17
20
 
18
21
  _PERMISSION_ACTION_LABELS = {
19
22
  "list": "폴더 목록 보기",
@@ -37,6 +40,12 @@ class PermissionGateway:
37
40
  self.discord_permission_channel = config.discord_permission_channel
38
41
  self.permission_monitor_secret = config.permission_monitor_secret
39
42
  self.perm_queue_file = data_dir / "permission_queue.json"
43
+ explicit_ui_url = os.getenv("LATTICEAI_PERMISSION_UI_URL", "").strip()
44
+ public_url = os.getenv("LATTICEAI_PUBLIC_URL", "").strip().rstrip("/")
45
+ default_ui_url = f"http://127.0.0.1:{getattr(config, 'port', 4825)}/app#/admin/permissions"
46
+ self.permission_ui_url = explicit_ui_url or (
47
+ f"{public_url}/app#/admin/permissions" if public_url else default_ui_url
48
+ )
40
49
 
41
50
  @staticmethod
42
51
  def token_hash(token: str) -> str:
@@ -56,10 +65,9 @@ class PermissionGateway:
56
65
  return {}
57
66
 
58
67
  def _write_queue(self, queue: Dict) -> None:
59
- self.perm_queue_file.parent.mkdir(parents=True, exist_ok=True)
60
- tmp = self.perm_queue_file.with_suffix(self.perm_queue_file.suffix + ".tmp")
61
- tmp.write_text(json.dumps(queue, ensure_ascii=False, indent=2), encoding="utf-8")
62
- tmp.replace(self.perm_queue_file)
68
+ # Approval metadata contains local paths and identities. Keep writes
69
+ # atomic and private even when the caller's umask is permissive.
70
+ atomic_write_json(self.perm_queue_file, queue)
63
71
 
64
72
  def _perm_queue_write(self, token: str, record: Dict[str, object]) -> None:
65
73
  try:
@@ -72,8 +80,10 @@ class PermissionGateway:
72
80
  logging.warning("perm_queue_write failed: %s", exc)
73
81
 
74
82
  def _perm_queue_remove(self, token: str) -> None:
83
+ self._perm_queue_remove_key(self.token_hash(token))
84
+
85
+ def _perm_queue_remove_key(self, key: str) -> None:
75
86
  try:
76
- key = self.token_hash(token)
77
87
  with self.perm_queue_lock:
78
88
  queue = self._read_queue()
79
89
  queue.pop(key, None)
@@ -81,6 +91,27 @@ class PermissionGateway:
81
91
  except Exception as exc:
82
92
  logging.warning("perm_queue_remove failed: %s", exc)
83
93
 
94
+ def resolve_approval_key(self, token_or_hint: str) -> str:
95
+ """Resolve either the private token or its UI-safe eight-char hint.
96
+
97
+ Only an authenticated administrator/monitor reaches approval routes.
98
+ Supporting a unique hint lets the admin UI act on pending requests
99
+ without ever receiving the bearer-grade full token.
100
+ """
101
+ value = str(token_or_hint or "")
102
+ direct_key = self.token_hash(value)
103
+ with self.local_approval_lock:
104
+ if direct_key in self.local_approvals:
105
+ return direct_key
106
+ matches = [
107
+ key
108
+ for key, record in self.local_approvals.items()
109
+ if len(value) == 8 and record.get("token_hint") == value
110
+ ]
111
+ if len(matches) > 1:
112
+ raise HTTPException(status_code=409, detail="요청 ID가 중복되었습니다. 관리자 목록을 새로고침하세요.")
113
+ return matches[0] if matches else direct_key
114
+
84
115
  @staticmethod
85
116
  def normalize_local_path_for_approval(path: str) -> str:
86
117
  return str(Path(path).expanduser().resolve())
@@ -91,6 +122,7 @@ class PermissionGateway:
91
122
 
92
123
  def _notify_discord_permission_sync(self, token: str, path: str, action: str, user_email: str) -> None:
93
124
  sent = False
125
+ token_hint = self.token_hint(token)
94
126
  if self.discord_bot_token and self.discord_permission_channel:
95
127
  action_label = _PERMISSION_ACTION_LABELS.get(action, action)
96
128
  expires_at_iso = time.strftime(
@@ -102,9 +134,9 @@ class PermissionGateway:
102
134
  f"**경로:** `{path}`\n"
103
135
  f"**작업:** {action_label}\n"
104
136
  f"**요청자:** {user_email}\n"
105
- f"**토큰:** `{token}`\n"
137
+ f"**요청 ID:** `{token_hint}`\n"
106
138
  f"**만료:** {expires_at_iso}\n\n"
107
- f"승인하려면 `승인 {token[:8]}` / 거부하려면 `거부 {token[:8]}` 라고 답장하세요."
139
+ f"승인/거부: {self.permission_ui_url}"
108
140
  )
109
141
  payload = json.dumps({"content": msg}, ensure_ascii=False).encode("utf-8")
110
142
  try:
@@ -139,15 +171,11 @@ class PermissionGateway:
139
171
  {"name": "경로", "value": f"`{path}`", "inline": False},
140
172
  {"name": "작업", "value": action_label, "inline": True},
141
173
  {"name": "요청자", "value": user_email, "inline": True},
142
- {"name": "토큰", "value": f"`{token}`", "inline": False},
174
+ {"name": "요청 ID", "value": f"`{token_hint}`", "inline": False},
143
175
  {"name": "만료", "value": expires_at_iso, "inline": True},
144
176
  ],
145
177
  "footer": {
146
- "text": (
147
- "승인: POST /permissions/approve/{token} | "
148
- "거부: POST /permissions/deny/{token} | "
149
- "목록: GET /permissions/pending"
150
- )
178
+ "text": f"승인/거부 UI: {self.permission_ui_url}"
151
179
  },
152
180
  }
153
181
  ]
@@ -232,19 +260,31 @@ class PermissionGateway:
232
260
  raise HTTPException(status_code=403, detail="승인된 파일 내용과 요청 내용이 다릅니다.")
233
261
 
234
262
  def check_permission_auth(self, request: Request, token: Optional[str] = None) -> None:
263
+ """Authorize an approval decision.
264
+
265
+ Possession of the approval token is deliberately *not* sufficient:
266
+ the requester receives that token in order to poll status, so allowing
267
+ token ownership here would let them approve their own request.
268
+ """
269
+ _ = token # retained for compatibility with existing call sites
235
270
  if self.permission_monitor_secret:
236
271
  auth_header = request.headers.get("Authorization", "")
237
- if auth_header == f"Bearer {self.permission_monitor_secret}":
238
- return
239
- if token:
240
- key = self.token_hash(token)
241
- current_user = self.get_current_user(request)
242
- with self.local_approval_lock:
243
- record = self.local_approvals.get(key)
244
- if current_user and record and record.get("user_email") == current_user:
272
+ expected = f"Bearer {self.permission_monitor_secret}"
273
+ if secrets.compare_digest(auth_header, expected):
245
274
  return
246
275
  self.require_admin(request)
247
276
 
277
+ def require_permission_status_owner(self, *, token: str, user_email: str) -> None:
278
+ """Allow approval-status polling only to the original requester."""
279
+ key = self.token_hash(token)
280
+ with self.local_approval_lock:
281
+ record = self.local_approvals.get(key)
282
+ # Missing/expired tokens intentionally retain the generic status
283
+ # response. For a live request, however, a different account must not
284
+ # learn or poll its state even if it obtains the URL.
285
+ if record and record.get("user_email") != user_email:
286
+ raise HTTPException(status_code=403, detail="다른 사용자의 승인 상태는 조회할 수 없습니다.")
287
+
248
288
  def ensure_path_allowed(self, path: str, *, action: str) -> None:
249
289
  if action != "write":
250
290
  return
@@ -296,7 +336,7 @@ def create_permissions_router(
296
336
  @router.post("/permissions/approve/{token}")
297
337
  async def permissions_approve(token: str, request: Request):
298
338
  gateway.check_permission_auth(request, token)
299
- key = gateway.token_hash(token)
339
+ key = gateway.resolve_approval_key(token)
300
340
  with gateway.local_approval_lock:
301
341
  record = gateway.local_approvals.get(key)
302
342
  if not record:
@@ -305,7 +345,7 @@ def create_permissions_router(
305
345
  gateway.local_approvals.pop(key, None)
306
346
  raise HTTPException(status_code=410, detail="토큰이 만료되었습니다.")
307
347
  record["approved"] = True
308
- gateway._perm_queue_remove(token)
348
+ gateway._perm_queue_remove_key(key)
309
349
  logging.info(
310
350
  "Permission approved: token=%s path=%s action=%s user=%s",
311
351
  gateway.token_hint(token),
@@ -324,10 +364,10 @@ def create_permissions_router(
324
364
  @router.post("/permissions/deny/{token}")
325
365
  async def permissions_deny(token: str, request: Request):
326
366
  gateway.check_permission_auth(request, token)
327
- key = gateway.token_hash(token)
367
+ key = gateway.resolve_approval_key(token)
328
368
  with gateway.local_approval_lock:
329
369
  record = gateway.local_approvals.pop(key, None)
330
- gateway._perm_queue_remove(token)
370
+ gateway._perm_queue_remove_key(key)
331
371
  if not record:
332
372
  raise HTTPException(status_code=404, detail="토큰이 없거나 이미 처리되었습니다.")
333
373
  logging.info(
@@ -347,7 +387,8 @@ def create_permissions_router(
347
387
 
348
388
  @router.get("/permissions/status/{token}")
349
389
  async def permissions_status(token: str, request: Request):
350
- require_user(request)
390
+ current_user = require_user(request)
391
+ gateway.require_permission_status_owner(token=token, user_email=current_user)
351
392
  now = time.time()
352
393
  key = gateway.token_hash(token)
353
394
  with gateway.local_approval_lock:
@@ -40,8 +40,11 @@ def create_plugins_router(
40
40
  require_user: Callable[[Request], str],
41
41
  require_admin: Callable[[Request], Any],
42
42
  append_audit_event: Callable[..., None],
43
+ gate_write: Optional[Callable[[Request], Optional[str]]] = None,
43
44
  register_skill: Optional[Callable[[str, str], Any]] = None,
44
- plugin_runners_factory: Optional[Callable[[], Dict[str, Callable[..., Any]]]] = None,
45
+ plugin_runners_factory: Optional[
46
+ Callable[[str, Optional[str]], Dict[str, Callable[..., Any]]]
47
+ ] = None,
45
48
  ui_file_response: Optional[Callable[[Path], Any]] = None,
46
49
  static_dir: Optional[Path] = None,
47
50
  ) -> APIRouter:
@@ -93,19 +96,30 @@ def create_plugins_router(
93
96
 
94
97
  @router.post("/plugins/enable")
95
98
  async def plugin_enable(req: PluginActionRequest, request: Request):
96
- require_user(request)
97
- return {"plugin": registry.set_enabled(req.plugin_id, True)}
99
+ admin_email, _ = require_admin(request)
100
+ plugin = registry.set_enabled(req.plugin_id, True)
101
+ append_audit_event("plugin_enable", user_email=admin_email, plugin=req.plugin_id)
102
+ return {"plugin": plugin}
98
103
 
99
104
  @router.post("/plugins/disable")
100
105
  async def plugin_disable(req: PluginActionRequest, request: Request):
101
- require_user(request)
102
- return {"plugin": registry.set_enabled(req.plugin_id, False)}
106
+ admin_email, _ = require_admin(request)
107
+ plugin = registry.set_enabled(req.plugin_id, False)
108
+ append_audit_event("plugin_disable", user_email=admin_email, plugin=req.plugin_id)
109
+ return {"plugin": plugin}
103
110
 
104
111
  @router.post("/plugins/execute")
105
112
  async def plugin_execute(req: PluginExecuteRequest, request: Request):
106
113
  current_user = require_user(request)
107
- runners = plugin_runners_factory() if plugin_runners_factory else {}
108
- result = registry.execute_action(req.plugin_id, req.action, req.args, runners=runners)
114
+ scope = gate_write(request) if gate_write is not None else None
115
+ runners = plugin_runners_factory(current_user, scope) if plugin_runners_factory else {}
116
+ result = registry.execute_action(
117
+ req.plugin_id,
118
+ req.action,
119
+ req.args,
120
+ runners=runners,
121
+ workspace_id=scope,
122
+ )
109
123
  append_audit_event("plugin_execute", user_email=current_user, plugin=req.plugin_id, action=req.action, status=result.status)
110
124
  return result.as_dict()
111
125
 
@@ -1,8 +1,8 @@
1
1
  """Knowledge Graph portability routes — local export / import / backup / restore.
2
2
 
3
- Reads (export, status) require a signed-in user. Mutating operations (import,
4
- backup, restore, file export) require admin because the graph is machine-global,
5
- not workspace-scoped. Nothing here touches a cloud service.
3
+ Status reads require a signed-in user. Whole-graph exports, provenance, and all
4
+ mutating operations require admin because the graph is machine-global, not
5
+ workspace-scoped. Nothing here touches a cloud service.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -97,14 +97,14 @@ def create_portability_router(
97
97
  @router.get("/api/knowledge-graph/provenance")
98
98
  async def recent_provenance(request: Request, limit: int = 50, source_type: Optional[str] = None):
99
99
  """Recent ingestions (provenance trail) for the ingestion-sources UI."""
100
- require_user(request)
100
+ require_admin(request)
101
101
  _require_service()
102
102
  return service.recent_ingestions(limit=limit, source_type=source_type)
103
103
 
104
104
  @router.post("/api/knowledge-graph/export")
105
105
  async def export_graph(request: Request):
106
106
  """Logical JSON export of the whole graph (read-only)."""
107
- require_user(request)
107
+ require_admin(request)
108
108
  _require_service()
109
109
  return service.export()
110
110
 
@@ -12,7 +12,7 @@ import secrets
12
12
  from pathlib import Path
13
13
  from typing import Any, Callable, Optional, Set
14
14
 
15
- from fastapi import APIRouter, Request
15
+ from fastapi import APIRouter, HTTPException, Request
16
16
  from fastapi.responses import StreamingResponse
17
17
  from pydantic import BaseModel
18
18
 
@@ -48,8 +48,21 @@ def create_realtime_router(
48
48
  sub_id = secrets.token_urlsafe(12)
49
49
  sub = bus.add_subscriber(sub_id, workspace_scope=scope, user=user or None)
50
50
 
51
+ def refresh_authorization(current_sub) -> bool:
52
+ try:
53
+ refreshed_user = require_user(request)
54
+ except Exception:
55
+ return False
56
+ if refreshed_user != user:
57
+ return False
58
+ current_sub.workspace_scope = allowed_scopes(refreshed_user or None)
59
+ return True
60
+
51
61
  async def event_gen():
52
- async for frame in bus.stream(sub):
62
+ async for frame in bus.stream(
63
+ sub,
64
+ refresh_authorization=refresh_authorization,
65
+ ):
53
66
  if await request.is_disconnected():
54
67
  break
55
68
  yield frame
@@ -76,15 +89,30 @@ def create_realtime_router(
76
89
  @router.post("/realtime/presence/join")
77
90
  async def realtime_join(req: PresenceRequest, request: Request):
78
91
  user = require_user(request)
92
+ scope = allowed_scopes(user or None)
93
+ workspace_id = req.workspace_id
94
+ if scope is not None:
95
+ if workspace_id is None:
96
+ if not scope:
97
+ raise HTTPException(status_code=403, detail="No accessible workspace for presence.")
98
+ workspace_id = "personal" if "personal" in scope else sorted(scope)[0]
99
+ elif workspace_id not in scope:
100
+ raise HTTPException(status_code=403, detail="Workspace presence access denied.")
79
101
  client_id = req.client_id or secrets.token_urlsafe(8)
80
- record = bus.join(client_id, user=user or None, workspace_id=req.workspace_id)
102
+ try:
103
+ record = bus.join(client_id, user=user or None, workspace_id=workspace_id)
104
+ except PermissionError as exc:
105
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
81
106
  return {"presence": record}
82
107
 
83
108
  @router.post("/realtime/presence/leave")
84
109
  async def realtime_leave(req: PresenceRequest, request: Request):
85
- require_user(request)
110
+ user = require_user(request)
86
111
  if req.client_id:
87
- bus.leave(req.client_id)
112
+ try:
113
+ bus.leave(req.client_id, user=user or None)
114
+ except PermissionError as exc:
115
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
88
116
  return {"status": "ok"}
89
117
 
90
118
  return router
@@ -9,7 +9,7 @@ from fastapi.responses import StreamingResponse
9
9
  from pydantic import BaseModel
10
10
 
11
11
  from latticeai.services.process_audit import command_plan
12
- from auto_setup import (
12
+ from latticeai.setup.auto_setup import (
13
13
  plan as auto_setup_plan,
14
14
  preset as auto_setup_preset,
15
15
  probe as auto_setup_probe,
@@ -17,7 +17,7 @@ from auto_setup import (
17
17
  verify as auto_setup_verify,
18
18
  )
19
19
  from latticeai.models.router import parse_model_ref
20
- from setup_wizard import get_recommendations, install_stream, open_url, scan_environment
20
+ from latticeai.setup.wizard import get_recommendations, install_stream, open_url, scan_environment
21
21
 
22
22
 
23
23
  class SetupInstallRequest(BaseModel):