ltcai 4.2.0 → 4.3.1

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 (51) hide show
  1. package/README.md +28 -21
  2. package/bin/ltcai.js +6 -2
  3. package/docs/CHANGELOG.md +72 -0
  4. package/docs/V4_3_PORTABILITY_ARCHITECTURE.md +69 -0
  5. package/docs/V4_3_PRIVACY_AUDIT.md +60 -0
  6. package/docs/V4_3_PRODUCT_HARDENING_REPORT.md +53 -0
  7. package/docs/V4_3_VALIDATION_REPORT.md +58 -0
  8. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +19 -25
  9. package/frontend/openapi.json +213 -1
  10. package/frontend/src/App.tsx +15 -1
  11. package/frontend/src/api/client.ts +26 -1
  12. package/frontend/src/api/openapi.ts +268 -0
  13. package/frontend/src/pages/Act.tsx +63 -2
  14. package/frontend/src/pages/Library.tsx +9 -3
  15. package/frontend/src/pages/System.tsx +58 -0
  16. package/lattice_brain/__init__.py +1 -1
  17. package/lattice_brain/archive.py +360 -47
  18. package/lattice_brain/storage/sqlite.py +15 -2
  19. package/latticeai/__init__.py +1 -1
  20. package/latticeai/api/admin.py +11 -0
  21. package/latticeai/api/agents.py +3 -1
  22. package/latticeai/api/models.py +66 -18
  23. package/latticeai/api/portability.py +59 -2
  24. package/latticeai/app_factory.py +9 -0
  25. package/latticeai/brain/projection.py +12 -2
  26. package/latticeai/brain/retrieval.py +10 -0
  27. package/latticeai/brain/store.py +6 -1
  28. package/latticeai/core/config.py +4 -2
  29. package/latticeai/core/marketplace.py +1 -1
  30. package/latticeai/core/multi_agent.py +1 -1
  31. package/latticeai/core/product_hardening.py +218 -0
  32. package/latticeai/core/workspace_os.py +1 -1
  33. package/latticeai/services/agent_runtime.py +52 -12
  34. package/latticeai/services/kg_portability.py +147 -4
  35. package/latticeai/services/model_runtime.py +83 -2
  36. package/ltcai_cli.py +16 -4
  37. package/package.json +5 -4
  38. package/requirements.txt +17 -0
  39. package/scripts/clean_release_artifacts.mjs +27 -0
  40. package/scripts/lint_frontend.mjs +5 -0
  41. package/scripts/validate_release_artifacts.py +10 -0
  42. package/src-tauri/Cargo.lock +1 -1
  43. package/src-tauri/Cargo.toml +1 -1
  44. package/src-tauri/src/main.rs +356 -24
  45. package/src-tauri/tauri.conf.json +20 -1
  46. package/static/app/asset-manifest.json +5 -5
  47. package/static/app/assets/{index-C_HAkbAg.js → index-BhPuj8rT.js} +45 -45
  48. package/static/app/assets/index-BhPuj8rT.js.map +1 -0
  49. package/static/app/assets/{index-CDjiH_se.css → index-yZswHE3d.css} +1 -1
  50. package/static/app/index.html +2 -2
  51. package/static/app/assets/index-C_HAkbAg.js.map +0 -1
@@ -62,6 +62,7 @@ class LoadModelRequest(BaseModel):
62
62
  user_email: Optional[str] = None
63
63
  adapter_path: Optional[str] = None
64
64
  draft_model_id: Optional[str] = None
65
+ allow_download: bool = False
65
66
 
66
67
 
67
68
  class InstallEngineRequest(BaseModel):
@@ -82,6 +83,7 @@ class PrepareModelRequest(BaseModel):
82
83
  model: str
83
84
  engine: Optional[str] = None
84
85
  user_email: Optional[str] = None
86
+ allow_download: bool = False
85
87
 
86
88
 
87
89
  class VerifyCloudRequest(BaseModel):
@@ -127,9 +129,57 @@ def create_models_router(
127
129
  REQUIRE_AUTH = require_auth
128
130
  _list_compat_profiles = list_compat_profiles
129
131
 
130
- def _recommended_with_engine_options(items: List[Dict[str, object]]) -> List[Dict[str, object]]:
132
+ def _recommended_with_engine_options(
133
+ items: List[Dict[str, object]],
134
+ engines: Optional[List[Dict[str, object]]] = None,
135
+ loaded_ids: Optional[List[str]] = None,
136
+ current_id: Optional[str] = None,
137
+ ) -> List[Dict[str, object]]:
138
+ engine_lookup = {str(engine.get("id") or ""): engine for engine in engines or []}
139
+ model_lookup: Dict[str, Dict[str, object]] = {}
140
+ for engine in engines or []:
141
+ engine_id = str(engine.get("id") or "")
142
+ for model in engine.get("models") or []:
143
+ if isinstance(model, dict):
144
+ model_lookup[str(model.get("id") or "")] = {**model, "_engine": engine_id}
145
+ loaded = set(loaded_ids or [])
131
146
  out: List[Dict[str, object]] = []
132
147
  for item in items:
148
+ short_id = str(item["id"]).lower()
149
+ aliases = MODEL_ENGINE_ALIASES.get(short_id) or {}
150
+ options: List[Dict[str, str]] = []
151
+ for engine_name in ("local_mlx", "ollama", "lmstudio", "llamacpp", "vllm"):
152
+ real = aliases.get(engine_name)
153
+ if not real:
154
+ continue
155
+ options.append({
156
+ "engine": engine_name,
157
+ "model_id": real,
158
+ "load_id": real if engine_name == "local_mlx" else f"{engine_name}:{real}",
159
+ })
160
+ if not options:
161
+ options.append({"engine": "local_mlx", "model_id": item["id"], "load_id": item["id"]})
162
+ recommended_engine = options[0]["engine"]
163
+ load_id = options[0]["load_id"]
164
+ engine_info = engine_lookup.get(recommended_engine) or {}
165
+ model_info = model_lookup.get(load_id) or model_lookup.get(str(item["id"])) or {}
166
+ pulled = bool(model_info.get("pulled"))
167
+ is_loaded = load_id in loaded or str(item["id"]) in loaded or current_id in {load_id, str(item["id"])}
168
+ engine_installed = bool(engine_info.get("installed"))
169
+ pullable = bool(item.get("pullable", True))
170
+ download_required = bool(pullable and not pulled and not is_loaded)
171
+ if is_loaded:
172
+ load_status = "loaded"
173
+ unavailable_reason = None
174
+ elif not engine_installed:
175
+ load_status = "unavailable"
176
+ unavailable_reason = f"{engine_info.get('name') or recommended_engine} runtime is not installed."
177
+ elif download_required:
178
+ load_status = "download_required"
179
+ unavailable_reason = "Model files are not present locally. Downloads are opt-in and never start from token/model presence alone."
180
+ else:
181
+ load_status = "ready"
182
+ unavailable_reason = None
133
183
  base = {
134
184
  "id": item["id"],
135
185
  "name": item["name"],
@@ -144,23 +194,15 @@ def create_models_router(
144
194
  "run_location": item.get("run_location"),
145
195
  "internet_requirement": item.get("internet_requirement"),
146
196
  "source_display_order": item.get("source_display_order"),
197
+ "pulled": pulled,
198
+ "download_required": download_required,
199
+ "load_available": is_loaded or (engine_installed and not download_required),
200
+ "load_status": load_status,
201
+ "unavailable_reason": unavailable_reason,
147
202
  }
148
- short_id = str(item["id"]).lower()
149
- aliases = MODEL_ENGINE_ALIASES.get(short_id) or {}
150
- options: List[Dict[str, str]] = []
151
- for engine_name in ("local_mlx", "ollama", "lmstudio", "llamacpp", "vllm"):
152
- real = aliases.get(engine_name)
153
- if not real:
154
- continue
155
- options.append({
156
- "engine": engine_name,
157
- "model_id": real,
158
- "load_id": real if engine_name == "local_mlx" else f"{engine_name}:{real}",
159
- })
160
- if not options:
161
- options.append({"engine": "local_mlx", "model_id": item["id"], "load_id": item["id"]})
162
203
  base["engine_options"] = options
163
- base["recommended_engine"] = options[0]["engine"]
204
+ base["recommended_engine"] = recommended_engine
205
+ base["recommended_load_id"] = load_id
164
206
  out.append(base)
165
207
  return out
166
208
 
@@ -232,6 +274,7 @@ def create_models_router(
232
274
  require_user(request)
233
275
  return await prepare_and_load_model(
234
276
  req.model, request, engine=req.engine, user_email=req.user_email,
277
+ allow_download=req.allow_download,
235
278
  )
236
279
 
237
280
  @router.post("/engines/prepare-model/stream")
@@ -242,6 +285,7 @@ def create_models_router(
242
285
  try:
243
286
  async for chunk in prepare_and_load_model_stream(
244
287
  req.model, request, engine=req.engine, user_email=req.user_email,
288
+ allow_download=req.allow_download,
245
289
  ):
246
290
  yield chunk
247
291
  except HTTPException as exc:
@@ -287,10 +331,13 @@ def create_models_router(
287
331
 
288
332
  @router.get("/models")
289
333
  async def list_models():
334
+ engines = await asyncio.to_thread(engine_status)
290
335
  recommended = _recommended_with_engine_options(
291
- list(filter_lower_family_versions(ENGINE_MODEL_CATALOG.get("local_mlx", [])))
336
+ list(filter_lower_family_versions(ENGINE_MODEL_CATALOG.get("local_mlx", []))),
337
+ engines=engines,
338
+ loaded_ids=_router.loaded_model_ids,
339
+ current_id=_router.current_model_id,
292
340
  )
293
- engines = await asyncio.to_thread(engine_status)
294
341
  return {
295
342
  "recommended": recommended,
296
343
  "cloud": _router.detected_cloud_models(),
@@ -319,6 +366,7 @@ def create_models_router(
319
366
  return await prepare_and_load_model(
320
367
  model_id, request, engine=req.engine, user_email=req.user_email,
321
368
  adapter_path=req.adapter_path, draft_model_id=req.draft_model_id,
369
+ allow_download=req.allow_download,
322
370
  )
323
371
  except HTTPException:
324
372
  raise
@@ -26,6 +26,8 @@ class BackupRequest(BaseModel):
26
26
  class RestoreRequest(BaseModel):
27
27
  path: str
28
28
  verify: bool = True
29
+ dry_run: bool = False
30
+ confirm: bool = False
29
31
 
30
32
 
31
33
  class EncryptedArchiveRequest(BaseModel):
@@ -36,6 +38,18 @@ class EncryptedArchiveRequest(BaseModel):
36
38
  class EncryptedRestoreRequest(BaseModel):
37
39
  path: str
38
40
  passphrase: str
41
+ dry_run: bool = False
42
+ confirm: bool = False
43
+
44
+
45
+ class EncryptedInspectRequest(BaseModel):
46
+ path: str
47
+ passphrase: Optional[str] = None
48
+
49
+
50
+ class EncryptedVerifyRequest(BaseModel):
51
+ path: str
52
+ passphrase: str
39
53
 
40
54
 
41
55
  class DockerPostgresRequest(BaseModel):
@@ -74,6 +88,12 @@ def create_portability_router(
74
88
  _require_service()
75
89
  return service.storage_status()
76
90
 
91
+ @router.get("/api/knowledge-graph/backup-health")
92
+ async def backup_health(request: Request):
93
+ require_user(request)
94
+ _require_service()
95
+ return service.backup_health()
96
+
77
97
  @router.get("/api/knowledge-graph/provenance")
78
98
  async def recent_provenance(request: Request, limit: int = 50, source_type: Optional[str] = None):
79
99
  """Recent ingestions (provenance trail) for the ingestion-sources UI."""
@@ -114,7 +134,7 @@ def create_portability_router(
114
134
  require_admin(request)
115
135
  _require_service()
116
136
  try:
117
- return service.restore(req.path, verify=req.verify)
137
+ return service.restore(req.path, verify=req.verify, dry_run=req.dry_run, confirm=req.confirm)
118
138
  except (ValueError, FileNotFoundError) as exc:
119
139
  raise HTTPException(status_code=400, detail=str(exc))
120
140
 
@@ -127,12 +147,49 @@ def create_portability_router(
127
147
  except (ValueError, FileNotFoundError) as exc:
128
148
  raise HTTPException(status_code=400, detail=str(exc))
129
149
 
150
+ @router.post("/api/knowledge-graph/archive/inspect")
151
+ async def inspect_encrypted_archive(req: EncryptedInspectRequest, request: Request):
152
+ require_admin(request)
153
+ _require_service()
154
+ try:
155
+ return service.inspect_encrypted_archive(req.path, passphrase=req.passphrase)
156
+ except (ValueError, FileNotFoundError) as exc:
157
+ raise HTTPException(status_code=400, detail=str(exc))
158
+
159
+ @router.post("/api/knowledge-graph/archive/verify")
160
+ async def verify_encrypted_archive(req: EncryptedVerifyRequest, request: Request):
161
+ require_admin(request)
162
+ _require_service()
163
+ result = service.verify_encrypted_archive(req.path, passphrase=req.passphrase)
164
+ if not result.get("ok"):
165
+ raise HTTPException(status_code=400, detail="; ".join(result.get("errors") or ["Archive verification failed."]))
166
+ return result
167
+
168
+ @router.post("/api/knowledge-graph/archive/import")
169
+ async def import_encrypted_archive(req: EncryptedRestoreRequest, request: Request):
170
+ require_admin(request)
171
+ _require_service()
172
+ try:
173
+ return service.import_encrypted_archive(
174
+ req.path,
175
+ passphrase=req.passphrase,
176
+ dry_run=req.dry_run,
177
+ confirm=req.confirm,
178
+ )
179
+ except (ValueError, FileNotFoundError) as exc:
180
+ raise HTTPException(status_code=400, detail=str(exc))
181
+
130
182
  @router.post("/api/knowledge-graph/archive/restore")
131
183
  async def restore_encrypted_archive(req: EncryptedRestoreRequest, request: Request):
132
184
  require_admin(request)
133
185
  _require_service()
134
186
  try:
135
- return service.restore_encrypted_archive(req.path, passphrase=req.passphrase)
187
+ return service.restore_encrypted_archive(
188
+ req.path,
189
+ passphrase=req.passphrase,
190
+ dry_run=req.dry_run,
191
+ confirm=req.confirm,
192
+ )
136
193
  except (ValueError, FileNotFoundError) as exc:
137
194
  raise HTTPException(status_code=400, detail=str(exc))
138
195
 
@@ -154,6 +154,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
154
154
  from latticeai.api.hooks import create_hooks_router
155
155
  from latticeai.core.hooks import HooksRegistry
156
156
  from latticeai.core.builtin_hooks import register_builtin_hook_runners
157
+ from latticeai.core.product_hardening import build_product_hardening_status
157
158
  from latticeai.api.agent_registry import create_agent_registry_router
158
159
  from latticeai.core.agent_registry import AgentRegistry
159
160
  from latticeai.api.memory import create_memory_router
@@ -1256,6 +1257,13 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
1256
1257
  except Exception as e:
1257
1258
  return {"error": str(e)}
1258
1259
 
1260
+ def _product_hardening_status():
1261
+ return build_product_hardening_status(
1262
+ config=CONFIG,
1263
+ portability=KG_PORTABILITY,
1264
+ device_identity=DEVICE_IDENTITY,
1265
+ )
1266
+
1259
1267
  app.include_router(create_admin_router(
1260
1268
  require_admin=require_admin, require_user=require_user,
1261
1269
  load_users=load_users, save_users=save_users,
@@ -1270,6 +1278,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
1270
1278
  invite_code=INVITE_CODE, invite_gate_enabled=INVITE_GATE_ENABLED,
1271
1279
  default_port=DEFAULT_PORT,
1272
1280
  policy_matrix=policy_matrix,
1281
+ product_hardening_status=_product_hardening_status,
1273
1282
  ))
1274
1283
 
1275
1284
  app.include_router(create_invitations_router(
@@ -94,17 +94,24 @@ class KnowledgeGraphProjectionMixin:
94
94
  """
95
95
  if KGStoreV2 is None or _exec_script is None:
96
96
  return
97
+ self._v2_projection_available = False
97
98
  try:
98
99
  self._backup_before_v2_flip()
99
100
  with self._connect() as conn:
100
101
  conn.execute("BEGIN")
101
102
  stale = self._projection_version(conn) != _PROJECTION_VERSION
103
+ # Reconstruction views are non-authoritative. Recreate them on
104
+ # every startup so older SQLite rename migrations cannot strand
105
+ # a view against a temporary table such as edges_v2_old.
106
+ for stmt in (
107
+ "DROP VIEW IF EXISTS kgv2_edges",
108
+ "DROP VIEW IF EXISTS kgv2_nodes",
109
+ ):
110
+ conn.execute(stmt)
102
111
  if stale:
103
112
  # The projection is non-authoritative; drop it so init_schema
104
113
  # recreates the tables with the current normalized columns.
105
114
  for stmt in (
106
- "DROP VIEW IF EXISTS kgv2_edges",
107
- "DROP VIEW IF EXISTS kgv2_nodes",
108
115
  "DROP TABLE IF EXISTS edges_v2",
109
116
  "DROP TABLE IF EXISTS nodes_v2",
110
117
  ):
@@ -128,6 +135,9 @@ class KnowledgeGraphProjectionMixin:
128
135
  (_V2_WRITE_MASTER_KEY, _V2_WRITE_MASTER_KEY, mastered_at),
129
136
  )
130
137
  conn.execute(f"PRAGMA user_version={_KG_DB_FORMAT_VERSION}")
138
+ conn.execute("SELECT 1 FROM kgv2_nodes LIMIT 1").fetchone()
139
+ conn.execute("SELECT 1 FROM kgv2_edges LIMIT 1").fetchone()
140
+ self._v2_projection_available = True
131
141
  except Exception as e:
132
142
  logging.warning("knowledge_graph: v2 schema init/backfill skipped: %s", e)
133
143
 
@@ -879,6 +879,16 @@ class KnowledgeGraphRetrievalMixin:
879
879
  if isinstance(storage_capabilities, dict)
880
880
  else "bruteforce-cosine"
881
881
  ),
882
+ "vector_search_mode": (
883
+ (storage_capabilities.get("metadata") or {}).get("vector_mode")
884
+ if isinstance(storage_capabilities, dict)
885
+ else "fallback"
886
+ ),
887
+ "sqlite_vec_ann_available": (
888
+ bool((storage_capabilities.get("metadata") or {}).get("sqlite_vec_ann_available"))
889
+ if isinstance(storage_capabilities, dict)
890
+ else False
891
+ ),
882
892
  },
883
893
  "source_items": len(source_items),
884
894
  "indexed_items": sum(vector_counts.values()),
@@ -53,10 +53,15 @@ class KnowledgeGraphStore(
53
53
  self._embedding_model = (
54
54
  embedder if embedder is not None else LocalEmbeddingModel()
55
55
  )
56
+ self._v2_projection_available = False
56
57
  self._init_db()
57
58
  # Read graph queries from the v2 projection (kgv2_* views) when available.
58
59
  # Toggle off (e.g. in tests) to compare against the legacy tables.
59
- self._read_from_v2 = KGStoreV2 is not None and _READ_FROM_V2_DEFAULT
60
+ self._read_from_v2 = (
61
+ KGStoreV2 is not None
62
+ and _READ_FROM_V2_DEFAULT
63
+ and self._v2_projection_available
64
+ )
60
65
 
61
66
  def _read_tables(self) -> tuple:
62
67
  """Return (nodes_table, edges_table) for read queries.
@@ -157,13 +157,15 @@ class Config:
157
157
  if packaged_static.exists():
158
158
  static_dir = packaged_static
159
159
 
160
+ default_sso_redirect = f"http://localhost:{port}/auth/sso/callback"
161
+
160
162
  return cls(
161
163
  app_mode=app_mode,
162
164
  is_public=is_public,
163
165
  host=host,
164
166
  port=port,
165
167
  network_exposed=network_exposed,
166
- enable_telegram=_bool(env, "LATTICEAI_ENABLE_TELEGRAM", default=not is_public),
168
+ enable_telegram=_bool(env, "LATTICEAI_ENABLE_TELEGRAM", default=False),
167
169
  enable_graph=_bool(env, "LATTICEAI_ENABLE_GRAPH", default=True),
168
170
  autoload_models=_bool(env, "LATTICEAI_AUTOLOAD_MODELS", default=is_public),
169
171
  model_idle_unload_seconds=_int(env, "LATTICEAI_MODEL_IDLE_UNLOAD_SECONDS", 0),
@@ -196,7 +198,7 @@ class Config:
196
198
  sso_discovery_url=_value(env, "OIDC_DISCOVERY_URL", ""),
197
199
  sso_client_id=_value(env, "OIDC_CLIENT_ID", ""),
198
200
  sso_client_secret=_value(env, "OIDC_CLIENT_SECRET", ""),
199
- sso_redirect_uri=_value(env, "OIDC_REDIRECT_URI", "http://localhost:4825/auth/sso/callback"),
201
+ sso_redirect_uri=_value(env, "OIDC_REDIRECT_URI", default_sso_redirect),
200
202
  sso_provider_name=_value(env, "OIDC_PROVIDER_NAME", "SSO"),
201
203
  discord_permission_webhook=_value(env, "LATTICEAI_DISCORD_PERMISSION_WEBHOOK", ""),
202
204
  discord_bot_token=_value(env, "LATTICEAI_DISCORD_BOT_TOKEN", ""),
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "4.2.0"
14
+ MARKETPLACE_VERSION = "4.3.1"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent")
16
16
 
17
17
 
@@ -14,7 +14,7 @@ from datetime import datetime
14
14
  from typing import Any, Callable, Dict, List, Optional
15
15
 
16
16
 
17
- MULTI_AGENT_VERSION = "4.2.0"
17
+ MULTI_AGENT_VERSION = "4.3.1"
18
18
 
19
19
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
20
20
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -0,0 +1,218 @@
1
+ """Product hardening and privacy status helpers.
2
+
3
+ These helpers are read-only and must not perform network probes. They describe
4
+ the local-first startup posture and distinguish available credentials from
5
+ enabled outbound communication.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Mapping, Optional
14
+
15
+ from latticeai import __version__
16
+ from latticeai.core.config import Config
17
+
18
+
19
+ def _bool(env: Mapping[str, str], key: str, default: bool = False) -> bool:
20
+ raw = env.get(key)
21
+ if raw is None:
22
+ return default
23
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
24
+
25
+
26
+ def _present(env: Mapping[str, str], *keys: str) -> bool:
27
+ return any(bool(str(env.get(key) or "").strip()) for key in keys)
28
+
29
+
30
+ def external_integration_status(
31
+ config: Config,
32
+ *,
33
+ env: Optional[Mapping[str, str]] = None,
34
+ ) -> Dict[str, Any]:
35
+ if env is None:
36
+ env = os.environ
37
+ telegram_credentials = _present(env, "LATTICEAI_TELEGRAM_BOT_TOKEN", "TELEGRAM_BOT_TOKEN")
38
+ brain_network_auto_push = _bool(env, "LATTICEAI_BRAIN_NETWORK_AUTO_PUSH", default=False)
39
+ updater_enabled = _bool(env, "LATTICEAI_ENABLE_UPDATES", default=False)
40
+ model_downloads_enabled = _bool(env, "LATTICEAI_ALLOW_MODEL_DOWNLOADS", default=False) or bool(config.autoload_models)
41
+ docker_auto_start = _bool(env, "LATTICEAI_DOCKER_AUTO_START", default=False)
42
+ external_connectors_enabled = _bool(env, "LATTICEAI_ENABLE_EXTERNAL_CONNECTORS", default=False)
43
+ postgres_enabled = config.storage_engine == "postgres" and bool(config.postgres_dsn)
44
+ return {
45
+ "local_only_default": default_startup_local_only(config, env=env),
46
+ "integrations": {
47
+ "telegram": {
48
+ "enabled": bool(config.enable_telegram),
49
+ "credential_present": telegram_credentials,
50
+ "opt_in_required": True,
51
+ "automatic_egress": bool(config.enable_telegram),
52
+ "detail": (
53
+ "enabled by LATTICEAI_ENABLE_TELEGRAM"
54
+ if config.enable_telegram
55
+ else "disabled; token presence alone does not start Telegram"
56
+ ),
57
+ },
58
+ "brain_network": {
59
+ "enabled": brain_network_auto_push,
60
+ "credential_present": False,
61
+ "opt_in_required": True,
62
+ "automatic_egress": brain_network_auto_push,
63
+ "detail": "peer pushes are user/admin initiated; no automatic peer sync by default",
64
+ },
65
+ "updates": {
66
+ "enabled": updater_enabled,
67
+ "credential_present": False,
68
+ "opt_in_required": True,
69
+ "automatic_egress": updater_enabled,
70
+ "detail": "desktop updater checks are disabled unless LATTICEAI_ENABLE_UPDATES is true",
71
+ },
72
+ "model_downloads": {
73
+ "enabled": model_downloads_enabled,
74
+ "credential_present": _present(env, "HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN"),
75
+ "opt_in_required": True,
76
+ "automatic_egress": bool(config.autoload_models),
77
+ "detail": "model downloads require an explicit load/autoload setting",
78
+ },
79
+ "docker": {
80
+ "enabled": docker_auto_start,
81
+ "credential_present": False,
82
+ "opt_in_required": True,
83
+ "automatic_egress": docker_auto_start,
84
+ "detail": "Docker setup requires explicit runtime consent; auto-start is disabled by default",
85
+ },
86
+ "postgres": {
87
+ "enabled": postgres_enabled,
88
+ "credential_present": bool(config.postgres_dsn),
89
+ "opt_in_required": True,
90
+ "automatic_egress": postgres_enabled,
91
+ "detail": "Postgres scale mode is used only when storage engine and DSN are explicitly configured",
92
+ },
93
+ "external_connectors": {
94
+ "enabled": external_connectors_enabled,
95
+ "credential_present": _present(
96
+ env,
97
+ "OPENAI_API_KEY",
98
+ "ANTHROPIC_API_KEY",
99
+ "GITHUB_TOKEN",
100
+ "SLACK_BOT_TOKEN",
101
+ "DISCORD_BOT_TOKEN",
102
+ ),
103
+ "opt_in_required": True,
104
+ "automatic_egress": external_connectors_enabled,
105
+ "detail": "connector credentials are inert until the connector is explicitly enabled and invoked",
106
+ },
107
+ },
108
+ }
109
+
110
+
111
+ def default_startup_local_only(
112
+ config: Config,
113
+ *,
114
+ env: Optional[Mapping[str, str]] = None,
115
+ ) -> bool:
116
+ if env is None:
117
+ env = os.environ
118
+ local_embedding = config.embedding_provider in {"", "hash", "local", "fallback", "sqlite"}
119
+ external = external_integration_status_no_recurse(config, env=env)
120
+ return (
121
+ not config.network_exposed
122
+ and not config.cors_allow_network
123
+ and not config.enable_telegram
124
+ and not config.autoload_models
125
+ and config.storage_engine == "sqlite"
126
+ and local_embedding
127
+ and not any(item["automatic_egress"] for item in external.values())
128
+ )
129
+
130
+
131
+ def external_integration_status_no_recurse(
132
+ config: Config,
133
+ *,
134
+ env: Mapping[str, str],
135
+ ) -> Dict[str, Dict[str, Any]]:
136
+ return {
137
+ "brain_network": {"automatic_egress": _bool(env, "LATTICEAI_BRAIN_NETWORK_AUTO_PUSH", default=False)},
138
+ "updates": {"automatic_egress": _bool(env, "LATTICEAI_ENABLE_UPDATES", default=False)},
139
+ "docker": {"automatic_egress": _bool(env, "LATTICEAI_DOCKER_AUTO_START", default=False)},
140
+ "postgres": {"automatic_egress": config.storage_engine == "postgres" and bool(config.postgres_dsn)},
141
+ "external_connectors": {"automatic_egress": _bool(env, "LATTICEAI_ENABLE_EXTERNAL_CONNECTORS", default=False)},
142
+ }
143
+
144
+
145
+ def build_product_hardening_status(
146
+ *,
147
+ config: Config,
148
+ portability: Any = None,
149
+ device_identity: Any = None,
150
+ env: Optional[Mapping[str, str]] = None,
151
+ ) -> Dict[str, Any]:
152
+ if env is None:
153
+ env = os.environ
154
+ storage = {"available": False}
155
+ backup = {"available": False}
156
+ if portability is not None and getattr(portability, "available", lambda: False)():
157
+ storage = portability.storage_status()
158
+ backup = portability.backup_health()
159
+ identity = {}
160
+ if device_identity is not None:
161
+ identity = device_identity.describe()
162
+ data_dir = Path(config.data_dir)
163
+ return {
164
+ "version": __version__,
165
+ "startup": {
166
+ "local_only_default": default_startup_local_only(config, env=env),
167
+ "host": config.host,
168
+ "port": config.port,
169
+ "network_exposed": config.network_exposed,
170
+ "auth_required": config.require_auth,
171
+ "cors_network_allowed": config.cors_allow_network,
172
+ },
173
+ "desktop": {
174
+ "sidecar_lifecycle": "managed",
175
+ "restart_supported": True,
176
+ "shutdown_supported": True,
177
+ "updater": {
178
+ "enabled": _bool(env, "LATTICEAI_ENABLE_UPDATES", default=False),
179
+ "limitation": "No external update checks run unless explicitly enabled by policy.",
180
+ },
181
+ },
182
+ "first_run": {
183
+ "data_dir": str(data_dir),
184
+ "data_dir_exists": data_dir.exists(),
185
+ "python_available": shutil.which("python3") is not None or shutil.which("python") is not None,
186
+ "docker_available": shutil.which("docker") is not None,
187
+ "docker_required": False,
188
+ "postgres_required": False,
189
+ },
190
+ "privacy": external_integration_status(config, env=env),
191
+ "storage": storage,
192
+ "backup": backup,
193
+ "device_identity": identity,
194
+ "permissions": {
195
+ "export_requires_admin": True,
196
+ "import_requires_admin": True,
197
+ "restore_requires_admin": True,
198
+ "destructive_restore_requires_confirmation": True,
199
+ "workspace_isolation_enforced": True,
200
+ "audit_log_visible_to_admin": True,
201
+ },
202
+ "failure_policy": {
203
+ "archive_corruption": "fail_closed",
204
+ "partial_archive": "fail_closed",
205
+ "signature_mismatch": "fail_closed",
206
+ "unsupported_version": "fail_closed",
207
+ "missing_docker": "honest_unavailable",
208
+ "missing_postgres": "honest_unavailable",
209
+ "permission_denied": "honest_error",
210
+ },
211
+ }
212
+
213
+
214
+ __all__ = [
215
+ "build_product_hardening_status",
216
+ "default_startup_local_only",
217
+ "external_integration_status",
218
+ ]
@@ -19,7 +19,7 @@ from pathlib import Path
19
19
  from typing import Any, Callable, Dict, Iterable, List, Optional
20
20
 
21
21
 
22
- WORKSPACE_OS_VERSION = "4.2.0"
22
+ WORKSPACE_OS_VERSION = "4.3.1"
23
23
 
24
24
  # Workspace types separate single-user Personal workspaces from shared
25
25
  # Organization workspaces. Both keep the same local-first JSON store; the type