ltcai 8.8.0 → 9.0.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 (111) hide show
  1. package/README.md +33 -37
  2. package/auto_setup.py +84 -70
  3. package/docs/CHANGELOG.md +113 -237
  4. package/docs/CODE_REVIEW_2026-07-06.md +764 -0
  5. package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
  6. package/docs/DEVELOPMENT.md +10 -10
  7. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  8. package/docs/ONBOARDING.md +2 -2
  9. package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
  10. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  11. package/docs/TRUST_MODEL.md +5 -1
  12. package/docs/WHY_LATTICE.md +4 -3
  13. package/docs/architecture.md +4 -0
  14. package/docs/kg-schema.md +1 -1
  15. package/lattice_brain/__init__.py +1 -1
  16. package/lattice_brain/archive.py +4 -9
  17. package/lattice_brain/conversations.py +156 -21
  18. package/lattice_brain/embeddings.py +38 -2
  19. package/lattice_brain/graph/_kg_common.py +39 -496
  20. package/lattice_brain/graph/_kg_constants.py +243 -0
  21. package/lattice_brain/graph/_kg_fsutil.py +297 -0
  22. package/lattice_brain/graph/discovery.py +0 -948
  23. package/lattice_brain/graph/discovery_index.py +972 -0
  24. package/lattice_brain/graph/json_utils.py +25 -0
  25. package/lattice_brain/graph/retrieval.py +66 -597
  26. package/lattice_brain/graph/retrieval_docgen.py +210 -0
  27. package/lattice_brain/graph/retrieval_vector.py +460 -0
  28. package/lattice_brain/graph/runtime.py +16 -0
  29. package/lattice_brain/graph/store.py +6 -0
  30. package/lattice_brain/ingestion.py +68 -0
  31. package/lattice_brain/portability.py +1 -9
  32. package/lattice_brain/quality.py +98 -4
  33. package/lattice_brain/runtime/agent_runtime.py +166 -0
  34. package/lattice_brain/runtime/multi_agent.py +1 -1
  35. package/lattice_brain/utils.py +28 -0
  36. package/latticeai/__init__.py +1 -1
  37. package/latticeai/api/chat.py +368 -418
  38. package/latticeai/api/chat_helpers.py +227 -0
  39. package/latticeai/api/computer_use.py +149 -31
  40. package/latticeai/api/marketplace.py +11 -0
  41. package/latticeai/api/mcp.py +3 -2
  42. package/latticeai/api/models.py +4 -1
  43. package/latticeai/api/permissions.py +72 -33
  44. package/latticeai/api/setup.py +17 -2
  45. package/latticeai/api/tools.py +105 -62
  46. package/latticeai/app_factory.py +101 -296
  47. package/latticeai/core/agent.py +25 -7
  48. package/latticeai/core/io_utils.py +37 -0
  49. package/latticeai/core/legacy_compatibility.py +1 -1
  50. package/latticeai/core/local_embeddings.py +2 -4
  51. package/latticeai/core/marketplace.py +33 -2
  52. package/latticeai/core/mcp_catalog.py +450 -0
  53. package/latticeai/core/mcp_registry.py +2 -441
  54. package/latticeai/core/sessions.py +11 -3
  55. package/latticeai/core/tool_registry.py +15 -4
  56. package/latticeai/core/users.py +4 -9
  57. package/latticeai/core/workspace_os.py +1 -1
  58. package/latticeai/core/workspace_os_utils.py +3 -17
  59. package/latticeai/integrations/telegram_bot.py +7 -2
  60. package/latticeai/models/model_providers.py +111 -0
  61. package/latticeai/models/router.py +58 -136
  62. package/latticeai/runtime/audit_runtime.py +27 -16
  63. package/latticeai/runtime/automation_runtime.py +9 -0
  64. package/latticeai/runtime/bootstrap.py +1 -1
  65. package/latticeai/runtime/history_runtime.py +163 -0
  66. package/latticeai/runtime/namespace_runtime.py +173 -0
  67. package/latticeai/runtime/network_config_runtime.py +56 -0
  68. package/latticeai/runtime/sso_config_runtime.py +128 -0
  69. package/latticeai/runtime/user_key_runtime.py +106 -0
  70. package/latticeai/services/app_context.py +1 -0
  71. package/latticeai/services/architecture_readiness.py +2 -2
  72. package/latticeai/services/memory_service.py +213 -0
  73. package/latticeai/services/model_engines.py +79 -12
  74. package/latticeai/services/model_runtime.py +24 -4
  75. package/latticeai/services/platform_runtime.py +9 -1
  76. package/latticeai/services/process_audit.py +208 -0
  77. package/latticeai/services/product_readiness.py +11 -11
  78. package/latticeai/services/review_queue.py +64 -11
  79. package/latticeai/services/run_executor.py +21 -0
  80. package/latticeai/services/search_service.py +106 -30
  81. package/latticeai/services/setup_detection.py +80 -0
  82. package/latticeai/services/tool_dispatch.py +66 -0
  83. package/latticeai/services/workspace_service.py +15 -0
  84. package/package.json +1 -1
  85. package/scripts/check_i18n_literals.mjs +20 -8
  86. package/scripts/i18n_literal_allowlist.json +34 -0
  87. package/scripts/lint_frontend.mjs +6 -2
  88. package/setup_wizard.py +196 -74
  89. package/src-tauri/Cargo.lock +1 -1
  90. package/src-tauri/Cargo.toml +1 -1
  91. package/src-tauri/tauri.conf.json +1 -1
  92. package/static/app/asset-manifest.json +11 -11
  93. package/static/app/assets/{Act-C7K9wsO9.js → Act-21lIXx2E.js} +1 -1
  94. package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
  95. package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-BA7Z2Q1u.js} +1 -1
  96. package/static/app/assets/{Library-Cgj-EF50.js → Library-bFMtyni3.js} +1 -1
  97. package/static/app/assets/System-K6krGCqn.js +1 -0
  98. package/static/app/assets/index-C4R3ws30.js +17 -0
  99. package/static/app/assets/index-ChSeOB02.css +2 -0
  100. package/static/app/assets/primitives-sQU3it5I.js +1 -0
  101. package/static/app/assets/{textarea-CVQkN2Tk.js → textarea-DK3Fd_lR.js} +1 -1
  102. package/static/app/index.html +2 -2
  103. package/static/css/tokens.css +4 -2
  104. package/static/sw.js +1 -1
  105. package/tools/local_files.py +6 -0
  106. package/latticeai/runtime/sso_runtime.py +0 -52
  107. package/static/app/assets/Brain-I1OSzxJu.js +0 -321
  108. package/static/app/assets/System-D1Lkei3I.js +0 -1
  109. package/static/app/assets/index--P0ksosz.js +0 -17
  110. package/static/app/assets/index-DCh5AoXt.css +0 -2
  111. package/static/app/assets/primitives-BLqaKk5g.js +0 -1
@@ -107,6 +107,53 @@ class ToolDispatchService:
107
107
  detail=f"'{tool_name}' 툴은 관리자 전용입니다.",
108
108
  )
109
109
 
110
+ def user_role(self, current_user: str) -> str:
111
+ users = self.load_users()
112
+ try:
113
+ return str(self.get_user_role(current_user, users) or "user")
114
+ except Exception:
115
+ return "user"
116
+
117
+ def enforce_policy(
118
+ self,
119
+ tool_name: str,
120
+ args: Optional[dict],
121
+ *,
122
+ current_user: str,
123
+ source: str,
124
+ require_auto_approval: bool = True,
125
+ trusted_admin: bool = False,
126
+ ) -> ToolPolicy:
127
+ """Authorize a tool call before any hook or handler can execute.
128
+
129
+ This is the shared policy gate for direct HTTP/MCP/tool surfaces. Agent
130
+ runtime uses the same policy data and blocks non-auto-approved steps in
131
+ its state machine so Computer Use direct endpoints can remain unchanged
132
+ when explicitly excluded from a hardening pass.
133
+ """
134
+ policy = self.policy_for(tool_name, args or {})
135
+ if not trusted_admin:
136
+ self.check_role(tool_name, current_user)
137
+ if policy["destructive"] or policy["risk"] == "destructive":
138
+ raise HTTPException(
139
+ status_code=403,
140
+ detail=f"'{tool_name}' 툴은 파괴적 작업으로 차단되었습니다.",
141
+ )
142
+ if (
143
+ require_auto_approval
144
+ and not trusted_admin
145
+ and not policy["auto_approve"]
146
+ and self.user_role(current_user) != "admin"
147
+ ):
148
+ raise HTTPException(
149
+ status_code=403,
150
+ detail=(
151
+ f"'{tool_name}' 툴은 명시 승인이 필요합니다. "
152
+ "9.0.0에서는 승인 UI가 없는 직접 실행 경로에서 기본 차단됩니다."
153
+ ),
154
+ )
155
+ return policy
156
+
110
157
  def rollback_file(self, path: str) -> Dict[str, Any]:
111
158
  r = subprocess.run(
112
159
  ["git", "checkout", "--", path],
@@ -160,6 +207,25 @@ def check_tool_role(tool_name: str, current_user: str) -> None:
160
207
  DEFAULT_TOOL_DISPATCH_SERVICE.check_role(tool_name, current_user)
161
208
 
162
209
 
210
+ def enforce_tool_policy(
211
+ tool_name: str,
212
+ args: Optional[dict],
213
+ *,
214
+ current_user: str,
215
+ source: str,
216
+ require_auto_approval: bool = True,
217
+ trusted_admin: bool = False,
218
+ ) -> ToolPolicy:
219
+ return DEFAULT_TOOL_DISPATCH_SERVICE.enforce_policy(
220
+ tool_name,
221
+ args or {},
222
+ current_user=current_user,
223
+ source=source,
224
+ require_auto_approval=require_auto_approval,
225
+ trusted_admin=trusted_admin,
226
+ )
227
+
228
+
163
229
  def collect_created_files(transcript: list) -> list:
164
230
  files = []
165
231
  for step in transcript:
@@ -79,6 +79,21 @@ class WorkspaceService:
79
79
  def can_write(self, workspace_id: str, user_id: Optional[str]) -> bool:
80
80
  return self.store.has_permission(workspace_id, self._identity(user_id), "write")
81
81
 
82
+ def readable_workspaces(self, user_id: Optional[str]) -> list[str]:
83
+ """Return workspace ids the caller can read.
84
+
85
+ This keeps scoped read APIs from each reconstructing membership logic
86
+ from raw workspace state. The personal workspace remains readable via
87
+ the store's normal permission rules.
88
+ """
89
+ resolved_user = self._identity(user_id)
90
+ workspaces = (self.store.load_state().get("workspaces") or {})
91
+ return [
92
+ str(workspace_id)
93
+ for workspace_id in workspaces
94
+ if self.store.has_permission(str(workspace_id), resolved_user, "read")
95
+ ]
96
+
82
97
  # ── record-level authorization (by-id access must not bypass gating) ──
83
98
 
84
99
  def authorize_record_read(self, record: Dict[str, Any], user_id: Optional[str]) -> None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "8.8.0",
3
+ "version": "9.0.0",
4
4
  "description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -4,10 +4,12 @@ import { join, relative } from "node:path";
4
4
 
5
5
  const repo = join(import.meta.dirname, "..");
6
6
  const roots = [
7
- join(repo, "frontend", "src", "features", "brain"),
8
- join(repo, "frontend", "src", "features", "admin"),
9
- join(repo, "frontend", "src", "components", "onboarding"),
7
+ join(repo, "frontend", "src"),
10
8
  ];
9
+ const allowlistPath = join(repo, "scripts", "i18n_literal_allowlist.json");
10
+ const allowlist = existsSync(allowlistPath)
11
+ ? JSON.parse(readFileSync(allowlistPath, "utf8"))
12
+ : {};
11
13
 
12
14
  function walk(dir) {
13
15
  if (!existsSync(dir)) return [];
@@ -16,7 +18,9 @@ function walk(dir) {
16
18
  const path = join(dir, name);
17
19
  const stat = statSync(path);
18
20
  if (stat.isDirectory()) out.push(...walk(path));
19
- else if (name.endsWith(".tsx")) out.push(path);
21
+ else if ((name.endsWith(".tsx") || name.endsWith(".ts")) && name !== "openapi.ts") {
22
+ out.push(path);
23
+ }
20
24
  }
21
25
  return out;
22
26
  }
@@ -25,6 +29,7 @@ const rawLocalizedProps = /\b(?:aria-label|placeholder|title)=["'][^"'{]*[A-Za-z
25
29
  const rawJsxText = />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,})\s*</g;
26
30
  const rawComponentCopy = /\b(?:title|detail|description|successLabel|empty)=["'][^"'{]*[A-Za-z][^"']*["']/g;
27
31
  let failures = 0;
32
+ let allowed = 0;
28
33
 
29
34
  for (const file of roots.flatMap(walk)) {
30
35
  const text = readFileSync(file, "utf8");
@@ -38,9 +43,16 @@ for (const file of roots.flatMap(walk)) {
38
43
  matches.push(`JSX text: ${literal}`);
39
44
  }
40
45
  if (!matches.length) continue;
41
- failures += matches.length;
42
- for (const match of matches) {
43
- console.error(`${relative(repo, file)}: hardcoded localized prop: ${match}`);
46
+ const rel = relative(repo, file);
47
+ const budget = Number(allowlist[rel]?.maxFindings || 0);
48
+ if (budget >= matches.length) {
49
+ allowed += matches.length;
50
+ continue;
51
+ }
52
+ const newMatches = matches.slice(budget);
53
+ failures += newMatches.length;
54
+ for (const match of newMatches) {
55
+ console.error(`${rel}: hardcoded localized prop: ${match}`);
44
56
  }
45
57
  }
46
58
 
@@ -49,4 +61,4 @@ if (failures) {
49
61
  process.exit(1);
50
62
  }
51
63
 
52
- console.log("i18n literal check: localized props use translation keys");
64
+ console.log(`i18n literal check: localized props use translation keys (${allowed} allowlisted legacy literal(s))`);
@@ -0,0 +1,34 @@
1
+ {
2
+ "frontend/src/App.tsx": {
3
+ "maxFindings": 1,
4
+ "reason": "legacy shell aria label pending translation-key migration"
5
+ },
6
+ "frontend/src/components/LivingBrain.tsx": {
7
+ "maxFindings": 1,
8
+ "reason": "legacy visual component aria label pending translation-key migration"
9
+ },
10
+ "frontend/src/components/primitives.tsx": {
11
+ "maxFindings": 1,
12
+ "reason": "shared empty-state fallback pending translation-key migration"
13
+ },
14
+ "frontend/src/pages/Act.tsx": {
15
+ "maxFindings": 41,
16
+ "reason": "large legacy automation page pending incremental copy migration"
17
+ },
18
+ "frontend/src/pages/Brain.tsx": {
19
+ "maxFindings": 25,
20
+ "reason": "large legacy memory page pending incremental copy migration"
21
+ },
22
+ "frontend/src/pages/Capture.tsx": {
23
+ "maxFindings": 2,
24
+ "reason": "technical type-name literals are intentionally preserved for now"
25
+ },
26
+ "frontend/src/pages/Library.tsx": {
27
+ "maxFindings": 21,
28
+ "reason": "large legacy model/plugin page pending incremental copy migration"
29
+ },
30
+ "frontend/src/pages/System.tsx": {
31
+ "maxFindings": 118,
32
+ "reason": "large legacy system/admin page pending incremental copy migration"
33
+ }
34
+ }
@@ -52,8 +52,12 @@ for (const file of files) {
52
52
  console.log(`privacy: ${scanned} frontend/static files scanned`);
53
53
 
54
54
  const client = readFileSync(join(frontend, "src", "api", "client.ts"), "utf8");
55
- if (!client.includes("openapi-fetch") || !client.includes("./openapi")) {
56
- fail("frontend/src/api/client.ts must use the generated OpenAPI client");
55
+ const apiBase = readFileSync(join(frontend, "src", "api", "base.ts"), "utf8");
56
+ if (
57
+ !(client.includes("openapi-fetch") || apiBase.includes("openapi-fetch")) ||
58
+ !(client.includes("./openapi") || apiBase.includes("./openapi"))
59
+ ) {
60
+ fail("frontend/src/api/client.ts or base.ts must use the generated OpenAPI client");
57
61
  }
58
62
  if (!existsSync(join(frontend, "src", "api", "openapi.ts"))) {
59
63
  fail("generated OpenAPI types missing");
package/setup_wizard.py CHANGED
@@ -21,6 +21,20 @@ import time
21
21
  from pathlib import Path
22
22
  from typing import Any, AsyncIterator, Dict, List, Tuple
23
23
 
24
+ from latticeai.services.process_audit import (
25
+ CommandConfirmationError,
26
+ append_process_audit_event,
27
+ command_plan,
28
+ command_plan_for_commands,
29
+ require_command_confirmation,
30
+ )
31
+ from latticeai.services.setup_detection import (
32
+ detect_cuda,
33
+ detect_tools,
34
+ detect_wsl_from_text,
35
+ parse_windows_video_controllers as _parse_windows_video_controllers,
36
+ )
37
+
24
38
  # ── Helpers ───────────────────────────────────────────────────────────────────
25
39
 
26
40
  def _cmd(args: List[str], timeout: int = 10) -> str:
@@ -33,6 +47,61 @@ def _cmd(args: List[str], timeout: int = 10) -> str:
33
47
  def _sse(data: Dict) -> str:
34
48
  return f"data: {_json.dumps(data, ensure_ascii=False)}\n\n"
35
49
 
50
+
51
+ def _action_commands(action: Dict[str, Any]) -> List[List[str]]:
52
+ atype = action.get("type")
53
+ if atype == "pip":
54
+ return [[sys.executable, "-m", "pip", "install", "--upgrade", str(pkg)] for pkg in action.get("packages", [])]
55
+ if atype == "brew":
56
+ package = str(action.get("package") or "")
57
+ return [["brew", "install", package]] if package else []
58
+ return []
59
+
60
+
61
+ def _action_command_plan(action: Dict[str, Any], *, name: str) -> Dict[str, Any] | None:
62
+ commands = _action_commands(action)
63
+ if not commands:
64
+ return None
65
+ return command_plan_for_commands(
66
+ commands,
67
+ name=name,
68
+ purpose="setup_wizard_install",
69
+ metadata={"action_type": action.get("type")},
70
+ )
71
+
72
+
73
+ def _attach_action_plan(action: Dict[str, Any] | None, *, name: str) -> Dict[str, Any] | None:
74
+ if not isinstance(action, dict):
75
+ return action
76
+ plan = _action_command_plan(action, name=name)
77
+ if not plan:
78
+ return action
79
+ hydrated = dict(action)
80
+ hydrated["command_plan"] = plan
81
+ hydrated["confirmation_token"] = plan["confirmation_token"]
82
+ return hydrated
83
+
84
+
85
+ def _hydrate_install_actions(groups: Dict[str, Any]) -> Dict[str, Any]:
86
+ for group_name in ("components", "engines", "models", "mcps"):
87
+ items = groups.get(group_name)
88
+ if not isinstance(items, list):
89
+ continue
90
+ for item in items:
91
+ if isinstance(item, dict):
92
+ item["action"] = _attach_action_plan(
93
+ item.get("action"),
94
+ name=str(item.get("id") or item.get("name") or group_name),
95
+ )
96
+ return groups
97
+
98
+
99
+ def _verify_action_confirmation(action: Dict[str, Any], token: str | None, *, name: str) -> bool:
100
+ plan = _action_command_plan(action, name=name)
101
+ if not plan:
102
+ return True
103
+ return str(token or "").strip() == plan["confirmation_token"]
104
+
36
105
  OFFICIAL_DOWNLOADS: Dict[str, str] = {
37
106
  "homebrew": "https://brew.sh",
38
107
  "python": "https://www.python.org/downloads/",
@@ -348,43 +417,6 @@ def _detect_disk_free_gb() -> float:
348
417
  return 0.0
349
418
 
350
419
 
351
- def _parse_windows_video_controllers(raw: str) -> List[Dict[str, Any]]:
352
- controllers: List[Dict[str, Any]] = []
353
- if not raw:
354
- return controllers
355
- try:
356
- data = _json.loads(raw)
357
- if isinstance(data, dict):
358
- data = [data]
359
- if isinstance(data, list):
360
- for item in data:
361
- name = str(item.get("Name") or "").strip()
362
- try:
363
- ram_mb = int(item.get("AdapterRAM") or 0) // (1024 * 1024)
364
- except Exception:
365
- ram_mb = 0
366
- if name:
367
- controllers.append({"name": name, "vram_mb": ram_mb})
368
- if controllers:
369
- return controllers
370
- except Exception:
371
- pass
372
- current: Dict[str, Any] = {}
373
- for line in raw.splitlines():
374
- if line.startswith("Name="):
375
- if current:
376
- controllers.append(current)
377
- current = {"name": line.split("=", 1)[-1].strip(), "vram_mb": 0}
378
- elif line.startswith("AdapterRAM=") and current:
379
- try:
380
- current["vram_mb"] = int(line.split("=", 1)[-1].strip()) // (1024 * 1024)
381
- except ValueError:
382
- current["vram_mb"] = 0
383
- if current:
384
- controllers.append(current)
385
- return controllers
386
-
387
-
388
420
  def _detect_gpu() -> Dict[str, Any]:
389
421
  devices: List[Dict[str, Any]] = []
390
422
  nvidia_smi = _which_any("nvidia-smi")
@@ -451,37 +483,24 @@ def _detect_gpu() -> Dict[str, Any]:
451
483
 
452
484
 
453
485
  def _detect_cuda() -> Dict[str, Any]:
454
- nvidia_smi = _which_any("nvidia-smi")
455
- nvcc = _which_any("nvcc")
456
- version = ""
457
- if nvidia_smi:
458
- raw = _cmd([nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"], timeout=5)
459
- version = raw.splitlines()[0].strip() if raw.splitlines() else ""
460
- if nvcc:
461
- raw = _cmd([nvcc, "--version"], timeout=5)
462
- m = re.search(r"release\s+([\d.]+)", raw)
463
- if m:
464
- version = m.group(1)
465
- return {"available": bool(nvidia_smi or nvcc), "nvidia_smi": nvidia_smi, "nvcc": nvcc, "version": version}
486
+ available, version, nvidia_smi, nvcc = detect_cuda(_which_any, lambda args: _cmd(args, timeout=5))
487
+ return {"available": available, "nvidia_smi": nvidia_smi, "nvcc": nvcc, "version": version}
466
488
 
467
489
 
468
490
  def _detect_wsl() -> Dict[str, Any]:
469
- if platform.system() != "Linux":
470
- return {"is_wsl": False, "version": ""}
471
491
  raw = ""
472
492
  try:
473
493
  raw = Path("/proc/version").read_text(encoding="utf-8", errors="replace")
474
494
  except Exception:
475
495
  pass
476
- is_wsl = "microsoft" in raw.lower() or "wsl" in raw.lower()
477
- version = "2" if "microsoft-standard" in raw.lower() or "wsl2" in raw.lower() else ("1" if is_wsl else "")
496
+ is_wsl, version = detect_wsl_from_text(platform.system().lower(), raw)
478
497
  return {"is_wsl": is_wsl, "version": version}
479
498
 
480
499
 
481
500
  def _detect_tools() -> Dict[str, bool]:
482
501
  repair_path_for()
483
- return {t: _which_any(t) is not None
484
- for t in ["brew", "ollama", "python3", "python", "node", "npm", "git", "tesseract", "lms", "nvidia-smi", "nvcc"]}
502
+ detected = detect_tools(_which_any, ["brew", "ollama", "python3", "python", "node", "npm", "git", "tesseract", "lms", "nvidia-smi", "nvcc"])
503
+ return {tool: path is not None for tool, path in detected.items()}
485
504
 
486
505
  def _detect_mlx() -> Dict[str, Any]:
487
506
  return {
@@ -933,7 +952,7 @@ def get_recommendations(env: Dict[str, Any]) -> Dict[str, Any]:
933
952
  },
934
953
  ]
935
954
 
936
- return {
955
+ return _hydrate_install_actions({
937
956
  "components": components,
938
957
  "engines": engines,
939
958
  "models": models,
@@ -954,7 +973,7 @@ def get_recommendations(env: Dict[str, Any]) -> Dict[str, Any]:
954
973
  "is_apple_silicon": is_apple,
955
974
  "max_model_gb": round(max_model_gb, 1),
956
975
  },
957
- }
976
+ })
958
977
 
959
978
  # ── Installation Stream ───────────────────────────────────────────────────────
960
979
 
@@ -972,7 +991,12 @@ def _verify_action(action: Dict[str, Any]) -> Tuple[bool, str]:
972
991
  return True, "검증 항목 없음"
973
992
 
974
993
 
975
- async def _repair_action(action: Dict[str, Any]) -> Tuple[bool, str]:
994
+ async def _repair_action(
995
+ action: Dict[str, Any],
996
+ *,
997
+ confirmation_token: str | None = None,
998
+ actor: str | None = None,
999
+ ) -> Tuple[bool, str]:
976
1000
  binary = action.get("binary")
977
1001
  if binary:
978
1002
  repair_path_for(binary)
@@ -982,15 +1006,23 @@ async def _repair_action(action: Dict[str, Any]) -> Tuple[bool, str]:
982
1006
  if action.get("type") == "pip":
983
1007
  packages = action.get("packages", [])
984
1008
  if packages:
1009
+ if not _verify_action_confirmation(action, confirmation_token, name="repair_action"):
1010
+ return False, "설치 명령 확인 토큰이 일치하지 않습니다."
985
1011
  for pkg in packages:
986
- success, err = await _pip_install(pkg)
1012
+ success, err = await _pip_install(pkg, confirmed=True, actor=actor)
987
1013
  if not success:
988
1014
  return False, err
989
1015
  return _verify_action(action)
990
1016
  return False, "자동 복구 방법을 찾지 못했습니다."
991
1017
 
992
1018
 
993
- async def install_stream(items: List[Dict], router: Any) -> AsyncIterator[str]:
1019
+ async def install_stream(
1020
+ items: List[Dict],
1021
+ router: Any,
1022
+ *,
1023
+ confirmation_token: str | None = None,
1024
+ user_email: str | None = None,
1025
+ ) -> AsyncIterator[str]:
994
1026
  for item in items:
995
1027
  item_id = item.get("id", "unknown")
996
1028
  name = item.get("name", item_id)
@@ -1006,10 +1038,14 @@ async def install_stream(items: List[Dict], router: Any) -> AsyncIterator[str]:
1006
1038
 
1007
1039
  if atype == "pip":
1008
1040
  packages = action.get("packages", [])
1041
+ token = confirmation_token or action.get("confirmation_token") or (action.get("command_plan") or {}).get("confirmation_token")
1042
+ if not _verify_action_confirmation(action, token, name=str(item_id)):
1043
+ yield _sse({"id": item_id, "status": "error", "msg": "설치 명령 확인 토큰이 일치하지 않습니다."})
1044
+ continue
1009
1045
  ok = True
1010
1046
  for pkg in packages:
1011
1047
  yield _sse({"id": item_id, "status": "running", "msg": f"pip install {pkg} ..."})
1012
- success, err = await _pip_install(pkg)
1048
+ success, err = await _pip_install(pkg, confirmed=True, actor=user_email)
1013
1049
  if success:
1014
1050
  yield _sse({"id": item_id, "status": "progress", "msg": f"{pkg} 설치 완료"})
1015
1051
  else:
@@ -1023,13 +1059,17 @@ async def install_stream(items: List[Dict], router: Any) -> AsyncIterator[str]:
1023
1059
  yield _sse({"id": item_id, "status": "done", "msg": f"{name} 설치 · 검증 완료 ✅\n{detail}"})
1024
1060
  else:
1025
1061
  yield _sse({"id": item_id, "status": "running", "msg": f"검증 실패 — 자동 복구 중...\n{detail}"})
1026
- repaired, repair_msg = await _repair_action(action)
1062
+ repaired, repair_msg = await _repair_action(action, confirmation_token=token, actor=user_email)
1027
1063
  yield _sse({"id": item_id, "status": "done" if repaired else "error", "msg": repair_msg[:500]})
1028
1064
 
1029
1065
  elif atype == "brew":
1030
1066
  pkg = action.get("package", "")
1067
+ token = confirmation_token or action.get("confirmation_token") or (action.get("command_plan") or {}).get("confirmation_token")
1068
+ if not _verify_action_confirmation(action, token, name=str(item_id)):
1069
+ yield _sse({"id": item_id, "status": "error", "msg": "설치 명령 확인 토큰이 일치하지 않습니다."})
1070
+ continue
1031
1071
  yield _sse({"id": item_id, "status": "running", "msg": f"brew install {pkg} ..."})
1032
- success, err = await _brew_install(pkg)
1072
+ success, err = await _brew_install(pkg, confirmed=True, actor=user_email)
1033
1073
  if success:
1034
1074
  yield _sse({"id": item_id, "status": "running", "msg": "설치 완료 감지 · PATH 보정 중..."})
1035
1075
  binary = action.get("binary")
@@ -1040,7 +1080,7 @@ async def install_stream(items: List[Dict], router: Any) -> AsyncIterator[str]:
1040
1080
  yield _sse({"id": item_id, "status": "done", "msg": f"{name} 설치 · 연결 · 검증 완료 ✅\n{detail}"})
1041
1081
  else:
1042
1082
  yield _sse({"id": item_id, "status": "running", "msg": f"검증 실패 — 자동 복구 중...\n{detail}"})
1043
- repaired, repair_msg = await _repair_action(action)
1083
+ repaired, repair_msg = await _repair_action(action, confirmation_token=token, actor=user_email)
1044
1084
  yield _sse({"id": item_id, "status": "done" if repaired else "error", "msg": repair_msg[:500]})
1045
1085
  else:
1046
1086
  url = action.get("official_url") or action.get("url")
@@ -1094,49 +1134,131 @@ async def install_stream(items: List[Dict], router: Any) -> AsyncIterator[str]:
1094
1134
  yield _sse({"status": "complete", "msg": "모든 항목 처리 완료!"})
1095
1135
 
1096
1136
 
1097
- async def _pip_install(package: str) -> Tuple[bool, str]:
1137
+ async def _pip_install(
1138
+ package: str,
1139
+ *,
1140
+ confirmation_token: str | None = None,
1141
+ confirmed: bool = False,
1142
+ actor: str | None = None,
1143
+ ) -> Tuple[bool, str]:
1144
+ command = [sys.executable, "-m", "pip", "install", "--upgrade", package]
1145
+ plan = command_plan(
1146
+ command,
1147
+ name=f"pip:{package}",
1148
+ purpose="setup_wizard_install",
1149
+ metadata={"package": package},
1150
+ )
1098
1151
  try:
1152
+ if not confirmed:
1153
+ require_command_confirmation(command, confirmation_token, purpose="setup_wizard_install")
1154
+ append_process_audit_event("setup_wizard_install", plan=plan, status="started", user_email=actor)
1099
1155
  proc = await asyncio.create_subprocess_exec(
1100
- sys.executable, "-m", "pip", "install", "--upgrade", package,
1156
+ *command,
1101
1157
  stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
1102
1158
  )
1103
1159
  _, stderr = await asyncio.wait_for(proc.communicate(), timeout=600)
1160
+ stderr_text = stderr.decode(errors="replace")
1161
+ append_process_audit_event(
1162
+ "setup_wizard_install",
1163
+ plan=plan,
1164
+ status="finished",
1165
+ user_email=actor,
1166
+ returncode=proc.returncode,
1167
+ stderr=stderr_text,
1168
+ )
1104
1169
  if proc.returncode == 0:
1105
1170
  return True, ""
1106
- return False, stderr.decode(errors="replace")
1171
+ return False, stderr_text
1172
+ except CommandConfirmationError as e:
1173
+ append_process_audit_event("setup_wizard_install", plan=plan, status="denied", user_email=actor, error=str(e))
1174
+ return False, str(e)
1107
1175
  except asyncio.TimeoutError:
1176
+ append_process_audit_event("setup_wizard_install", plan=plan, status="timeout", user_email=actor)
1108
1177
  return False, "설치 시간 초과 (10분)"
1109
1178
  except Exception as e:
1179
+ append_process_audit_event("setup_wizard_install", plan=plan, status="error", user_email=actor, error=str(e))
1110
1180
  return False, str(e)
1111
1181
 
1112
1182
 
1113
- async def _brew_install(package: str) -> Tuple[bool, str]:
1183
+ async def _brew_install(
1184
+ package: str,
1185
+ *,
1186
+ confirmation_token: str | None = None,
1187
+ confirmed: bool = False,
1188
+ actor: str | None = None,
1189
+ ) -> Tuple[bool, str]:
1114
1190
  brew = shutil.which("brew")
1115
1191
  if not brew:
1116
1192
  return False, "Homebrew 미설치 — https://brew.sh 에서 설치하세요"
1193
+ command = [brew, "install", package]
1194
+ plan = command_plan(
1195
+ command,
1196
+ name=f"brew:{package}",
1197
+ purpose="setup_wizard_install",
1198
+ metadata={"package": package},
1199
+ )
1117
1200
  try:
1201
+ if not confirmed:
1202
+ require_command_confirmation(command, confirmation_token, purpose="setup_wizard_install")
1203
+ append_process_audit_event("setup_wizard_install", plan=plan, status="started", user_email=actor)
1118
1204
  proc = await asyncio.create_subprocess_exec(
1119
- brew, "install", package,
1205
+ *command,
1120
1206
  stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
1121
1207
  )
1122
1208
  _, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
1209
+ stderr_text = stderr.decode(errors="replace")
1210
+ append_process_audit_event(
1211
+ "setup_wizard_install",
1212
+ plan=plan,
1213
+ status="finished",
1214
+ user_email=actor,
1215
+ returncode=proc.returncode,
1216
+ stderr=stderr_text,
1217
+ )
1123
1218
  if proc.returncode == 0:
1124
1219
  return True, ""
1125
- return False, stderr.decode(errors="replace")
1220
+ return False, stderr_text
1221
+ except CommandConfirmationError as e:
1222
+ append_process_audit_event("setup_wizard_install", plan=plan, status="denied", user_email=actor, error=str(e))
1223
+ return False, str(e)
1126
1224
  except asyncio.TimeoutError:
1225
+ append_process_audit_event("setup_wizard_install", plan=plan, status="timeout", user_email=actor)
1127
1226
  return False, "설치 시간 초과 (5분)"
1128
1227
  except Exception as e:
1228
+ append_process_audit_event("setup_wizard_install", plan=plan, status="error", user_email=actor, error=str(e))
1129
1229
  return False, str(e)
1130
1230
 
1131
1231
 
1132
1232
  def open_url(url: str) -> None:
1233
+ command: List[str]
1133
1234
  try:
1134
1235
  system = platform.system()
1135
1236
  if system == "Darwin":
1136
- subprocess.Popen(["open", url])
1237
+ command = ["open", url]
1238
+ plan = command_plan(command, name="open_url", purpose="setup_wizard_open_url")
1239
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="started")
1240
+ subprocess.Popen(command)
1241
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="spawned")
1137
1242
  elif system == "Windows":
1138
- subprocess.Popen(["start", "", url], shell=True)
1243
+ command = ["start", "", url]
1244
+ plan = command_plan(command, name="open_url", purpose="setup_wizard_open_url")
1245
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="started")
1246
+ subprocess.Popen(command, shell=True)
1247
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="spawned")
1139
1248
  else:
1140
- subprocess.Popen(["xdg-open", url])
1141
- except Exception:
1249
+ command = ["xdg-open", url]
1250
+ plan = command_plan(command, name="open_url", purpose="setup_wizard_open_url")
1251
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="started")
1252
+ subprocess.Popen(command)
1253
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="spawned")
1254
+ except Exception as exc:
1255
+ try:
1256
+ append_process_audit_event(
1257
+ "setup_wizard_open_url",
1258
+ plan=command_plan(["open_url", url], name="open_url", purpose="setup_wizard_open_url"),
1259
+ status="error",
1260
+ error=str(exc),
1261
+ )
1262
+ except Exception:
1263
+ pass
1142
1264
  pass
@@ -1584,7 +1584,7 @@ dependencies = [
1584
1584
 
1585
1585
  [[package]]
1586
1586
  name = "lattice-ai-desktop"
1587
- version = "8.8.0"
1587
+ version = "9.0.0"
1588
1588
  dependencies = [
1589
1589
  "plist",
1590
1590
  "serde",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "lattice-ai-desktop"
3
- version = "8.8.0"
3
+ version = "9.0.0"
4
4
  description = "Lattice AI Digital Brain desktop shell"
5
5
  authors = ["TaeSoo Park"]
6
6
  edition = "2021"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://schema.tauri.app/config/2",
3
3
  "productName": "Lattice AI",
4
- "version": "8.8.0",
4
+ "version": "9.0.0",
5
5
  "identifier": "ai.lattice.desktop",
6
6
  "build": {
7
7
  "beforeDevCommand": "npm run frontend:dev",