mirrorneuron-cli 1.2.7__tar.gz → 1.2.15__tar.gz

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 (87) hide show
  1. {mirrorneuron_cli-1.2.7/mirrorneuron_cli.egg-info → mirrorneuron_cli-1.2.15}/PKG-INFO +1 -1
  2. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15/mirrorneuron_cli.egg-info}/PKG-INFO +1 -1
  3. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/model_cmds.py +40 -3
  4. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/skill_dependencies.py +12 -1
  5. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/sys_cmds.py +11 -5
  6. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/server_cmds.py +194 -17
  7. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_model_cmds.py +69 -0
  8. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_run_cmds.py +24 -0
  9. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_run_helpers.py +3 -0
  10. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_server_cmds.py +124 -0
  11. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/.github/workflows/ci.yml +0 -0
  12. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/.github/workflows/release.yml +0 -0
  13. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/.gitignore +0 -0
  14. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/.python-version +0 -0
  15. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/AGENTS.md +0 -0
  16. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/LICENSE +0 -0
  17. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/README.md +0 -0
  18. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/RELEASE.md +0 -0
  19. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mirrorneuron_cli.egg-info/SOURCES.txt +0 -0
  20. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mirrorneuron_cli.egg-info/dependency_links.txt +0 -0
  21. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mirrorneuron_cli.egg-info/entry_points.txt +0 -0
  22. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mirrorneuron_cli.egg-info/requires.txt +0 -0
  23. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mirrorneuron_cli.egg-info/top_level.txt +0 -0
  24. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/__init__.py +0 -0
  25. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/banner.py +0 -0
  26. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/config.py +0 -0
  27. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/error_handler.py +0 -0
  28. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/__init__.py +0 -0
  29. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/artifacts.py +0 -0
  30. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/backup_cmds.py +0 -0
  31. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/blueprint_cmds.py +0 -0
  32. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/blueprint_models.py +0 -0
  33. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/blueprint_observability.py +0 -0
  34. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/blueprint_repository.py +0 -0
  35. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/blueprint_resources.py +0 -0
  36. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/bundles.py +0 -0
  37. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/deployment_cmds.py +0 -0
  38. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/event_relay.py +0 -0
  39. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/job_cmds.py +0 -0
  40. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/resource_cmds.py +0 -0
  41. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/run_cmds.py +0 -0
  42. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/run_logs.py +0 -0
  43. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/run_manifest.py +0 -0
  44. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/runtime_health.py +0 -0
  45. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/schedule_cmds.py +0 -0
  46. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/service_cmds.py +0 -0
  47. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/skill_runtime.py +0 -0
  48. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/ui.py +0 -0
  49. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/workflow_progress.py +0 -0
  50. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/libs/workflow_validation.py +0 -0
  51. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/logging_config.py +0 -0
  52. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/main.py +0 -0
  53. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/runtime_mode.py +0 -0
  54. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/runtime_state.py +0 -0
  55. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/schemas/workflow_manifest.schema.json +0 -0
  56. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/sdk_path.py +0 -0
  57. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/shared.py +0 -0
  58. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/terminal.py +0 -0
  59. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/mn_cli/update_cmds.py +0 -0
  60. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/pyproject.toml +0 -0
  61. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/scripts/check-release-artifacts.sh +0 -0
  62. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/scripts/make-release-zip.sh +0 -0
  63. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/scripts/validate-version-tag.sh +0 -0
  64. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/setup.cfg +0 -0
  65. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/conftest.py +0 -0
  66. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_backup_cmds.py +0 -0
  67. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_blueprint_cmds.py +0 -0
  68. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_blueprint_repository.py +0 -0
  69. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_blueprint_resources.py +0 -0
  70. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_deployment_cmds.py +0 -0
  71. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_docker_network_integration.py +0 -0
  72. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_job_cmds.py +0 -0
  73. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_main.py +0 -0
  74. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_repo_hygiene.py +0 -0
  75. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_resource_cmds.py +0 -0
  76. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_runtime_health.py +0 -0
  77. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_runtime_mode.py +0 -0
  78. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_runtime_state.py +0 -0
  79. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_schedule_cmds.py +0 -0
  80. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_service_cmds.py +0 -0
  81. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_shared.py +0 -0
  82. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_sys_cmds.py +0 -0
  83. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_terminal.py +0 -0
  84. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_ui.py +0 -0
  85. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_update_cmds.py +0 -0
  86. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/tests/test_workflow_validation.py +0 -0
  87. {mirrorneuron_cli-1.2.7 → mirrorneuron_cli-1.2.15}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mirrorneuron-cli
3
- Version: 1.2.7
3
+ Version: 1.2.15
4
4
  Summary: MirrorNeuron CLI
5
5
  License-Expression: MIT
6
6
  Classifier: Programming Language :: Python :: 3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mirrorneuron-cli
3
- Version: 1.2.7
3
+ Version: 1.2.15
4
4
  Summary: MirrorNeuron CLI
5
5
  License-Expression: MIT
6
6
  Classifier: Programming Language :: Python :: 3
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import os
4
5
  import subprocess
5
6
  from typing import Annotated, Any, Optional
6
7
 
@@ -250,16 +251,21 @@ def install_model_entry(
250
251
  target = docker_model_name(entry)
251
252
  if _docker_model_cli_available():
252
253
  _ensure_runner(compatibility.backend, compatibility.accelerator)
253
- _docker(["model", "pull", target], timeout=900, stream=True)
254
+ pull_result = _pull_model(target)
254
255
  run_command = ["model", "run", "--detach"]
255
256
  resolved_context = context_size or entry.get("context_size")
256
257
  if resolved_context and _docker_model_run_supports_context_size():
257
258
  run_command.extend(["--context-size", str(resolved_context)])
258
259
  run_command.append(target)
259
260
  _docker(run_command, timeout=300)
260
- return {"entry": entry, "docker_model": target, "compatibility": payload, "transport": "docker_cli"}
261
+ return {
262
+ "entry": entry,
263
+ "docker_model": target,
264
+ "compatibility": payload,
265
+ **pull_result,
266
+ }
261
267
 
262
- api_result = dmr_api_pull_model(target, timeout=900)
268
+ api_result = dmr_api_pull_model(target, timeout=_model_pull_timeout_seconds())
263
269
  return {
264
270
  "entry": entry,
265
271
  "docker_model": target,
@@ -269,6 +275,37 @@ def install_model_entry(
269
275
  }
270
276
 
271
277
 
278
+ def _model_pull_timeout_seconds() -> float:
279
+ try:
280
+ return max(float(os.getenv("MN_DOCKER_MODEL_PULL_TIMEOUT_SECONDS", "3600")), 1.0)
281
+ except ValueError:
282
+ return 3600.0
283
+
284
+
285
+ def _pull_model(target: str) -> dict[str, Any]:
286
+ if _endpoint_responds():
287
+ api_result = dmr_api_pull_model(target, timeout=_model_pull_timeout_seconds())
288
+ return {"transport": "docker_model_runner_api", "api": api_result}
289
+ _docker_model_pull(target)
290
+ return {"transport": "docker_cli"}
291
+
292
+
293
+ def _docker_model_pull(target: str, *, attempts: int = 2) -> None:
294
+ last_error: RuntimeError | None = None
295
+ for attempt in range(1, attempts + 1):
296
+ try:
297
+ _docker(["model", "pull", target], timeout=900, stream=True)
298
+ return
299
+ except RuntimeError as exc:
300
+ if _model_installed(target):
301
+ return
302
+ last_error = exc
303
+ if attempt < attempts:
304
+ console.print("[yellow]Docker model pull failed; retrying once...[/yellow]")
305
+ if last_error is not None:
306
+ raise last_error
307
+
308
+
272
309
  def remove_model_ref(model: str, *, force: bool = False) -> None:
273
310
  if _docker_model_cli_available():
274
311
  command = ["model", "rm"]
@@ -79,8 +79,19 @@ def gar_requirement_lines(manifest: dict[str, Any] | None) -> list[str]:
79
79
  ]
80
80
 
81
81
 
82
+ def gar_requirements_file_lines(manifest: dict[str, Any] | None) -> list[str]:
83
+ requirements = pinned_skill_dependency_requirements(manifest)
84
+ if not requirements:
85
+ return []
86
+ return [
87
+ f"--index-url {GAR_PIP_INDEX_URL}",
88
+ f"--extra-index-url {PYPI_PIP_INDEX_URL}",
89
+ *requirements,
90
+ ]
91
+
92
+
82
93
  def gar_requirements_text(manifest: dict[str, Any] | None) -> str:
83
- lines = gar_requirement_lines(manifest)
94
+ lines = gar_requirements_file_lines(manifest)
84
95
  return "\n".join(lines).strip() + ("\n" if lines else "")
85
96
 
86
97
 
@@ -310,15 +310,21 @@ def ensure_context_engine(
310
310
  )
311
311
  summary = ensure_context_engine_runtime(force=force)
312
312
  progress.update(task, description="[green]Context memory is ready.")
313
+ details = [
314
+ ("Service", summary["service"]),
315
+ ("Model", summary["model"]),
316
+ ("Model status", summary.get("model_status", "unknown")),
317
+ ]
318
+ if summary.get("membrane_dir"):
319
+ details.append(("Membrane", summary["membrane_dir"]))
320
+ if summary.get("engine_image"):
321
+ details.append(("Engine image", summary["engine_image"]))
322
+
313
323
  print_success_confirmation(
314
324
  console,
315
325
  "Context engine",
316
326
  status=summary["status"],
317
- details=[
318
- ("Service", summary["service"]),
319
- ("Model", summary["model"]),
320
- ("Membrane", summary["membrane_dir"]),
321
- ],
327
+ details=details,
322
328
  next_steps="mn runtime health",
323
329
  )
324
330
  except Exception as exc:
@@ -9,6 +9,7 @@ import shutil
9
9
  import socket
10
10
  import subprocess
11
11
  import sys
12
+ import tempfile
12
13
  import time
13
14
  import urllib.request
14
15
  from datetime import datetime, timezone
@@ -112,6 +113,11 @@ RUNTIME_MODELS_OVERRIDE_FILE = "docker-compose.models.yml"
112
113
  DEFAULT_LLM_MODEL_RUNNER_MODEL = "ai/gemma4:E2B"
113
114
  DEFAULT_CONTEXT_MODEL_RUNNER_MODEL = "hf.co/homerquan/mn-context-engine-model-v-Q4_K_M"
114
115
  DEFAULT_MEMBRANE_REPO = "MirrorNeuronLab/Membrane"
116
+ DEFAULT_MEMBRANE_ENGINE_IMAGE_REPOSITORY = (
117
+ "us-central1-docker.pkg.dev/mirrorneuron-public-packages/"
118
+ "mirrorneuron-runtime/membrane-context-engine"
119
+ )
120
+ PUBLIC_GAR_PROJECT_PATH = "/mirrorneuron-public-packages/"
115
121
  CONTEXT_ENGINE_SERVICE = "membrane-context-engine"
116
122
  CONTEXT_ENGINE_CONTAINER = "mirror-neuron-context-engine"
117
123
  CONTEXT_ENGINE_MODEL_CONTAINER = "mirror-neuron-context-engine-model"
@@ -2000,36 +2006,68 @@ def ensure_context_engine_runtime(*, force: bool = False) -> dict[str, str]:
2000
2006
  )
2001
2007
 
2002
2008
  env = _runtime_base_env(True)
2003
- source_dir = _ensure_context_engine_source(env)
2004
2009
  profiles = _compose_profiles_with(env.get("COMPOSE_PROFILES"), "context")
2005
2010
  model = str(env.get("MN_CONTEXT_MODEL_RUNNER_MODEL") or DEFAULT_CONTEXT_MODEL_RUNNER_MODEL)
2011
+ engine_image = _context_engine_release_image(env)
2012
+ use_engine_image = _context_engine_image_mode_enabled(env, engine_image)
2006
2013
  updates = {
2007
2014
  "COMPOSE_PROFILES": profiles,
2008
- "MEMBRANE_DIR": str(source_dir),
2009
2015
  "MN_CONTEXT_MODEL_RUNNER_MODEL": model,
2010
2016
  }
2017
+ source_dir: Path | None = None
2018
+ if use_engine_image:
2019
+ updates["ENGINE_IMAGE"] = engine_image
2020
+ updates["MN_MEMBRANE_ENGINE_IMAGE"] = engine_image
2021
+ _remove_env_file_keys(RUNTIME_COMPOSE_ENV, {"MEMBRANE_DIR"})
2022
+ env.pop("MEMBRANE_DIR", None)
2023
+ else:
2024
+ source_dir = _ensure_context_engine_source(env)
2025
+ updates["MEMBRANE_DIR"] = str(source_dir)
2011
2026
  _write_env_file_values(RUNTIME_COMPOSE_ENV, updates)
2012
2027
  env.update(updates)
2013
2028
 
2014
2029
  _remove_non_mirror_neuron_container(CONTEXT_ENGINE_CONTAINER)
2015
2030
  _remove_non_mirror_neuron_container(CONTEXT_ENGINE_MODEL_CONTAINER)
2016
2031
  _ensure_docker_model_runner()
2032
+ model_already_installed = _docker_model_inspect_ok(model)
2033
+ model_status = "already_installed" if model_already_installed else "compose_pending"
2017
2034
 
2018
2035
  already_running = _docker_container_running(CONTEXT_ENGINE_CONTAINER)
2019
- if force or not already_running:
2020
- subprocess.run(
2021
- runtime_compose_cmd("build", CONTEXT_ENGINE_SERVICE),
2022
- check=True,
2023
- stdout=subprocess.DEVNULL,
2024
- env=env,
2025
- )
2026
- subprocess.run(
2027
- runtime_compose_cmd("up", "-d", CONTEXT_ENGINE_SERVICE),
2028
- check=True,
2029
- stdout=subprocess.DEVNULL,
2030
- env=env,
2031
- )
2036
+ if force or not already_running or not model_already_installed:
2037
+ compose_env = env
2038
+ anonymous_docker_config: Path | None = None
2039
+ if use_engine_image:
2040
+ compose_env, anonymous_docker_config = _anonymous_public_gar_docker_env(env, engine_image)
2041
+ compose_process_env = _compose_subprocess_env(compose_env)
2042
+ try:
2043
+ if use_engine_image:
2044
+ subprocess.run(
2045
+ runtime_compose_cmd("pull", CONTEXT_ENGINE_SERVICE),
2046
+ check=True,
2047
+ stdout=subprocess.DEVNULL,
2048
+ env=compose_process_env,
2049
+ )
2050
+ up_args = ("up", "-d", "--no-build", CONTEXT_ENGINE_SERVICE)
2051
+ else:
2052
+ subprocess.run(
2053
+ runtime_compose_cmd("build", CONTEXT_ENGINE_SERVICE),
2054
+ check=True,
2055
+ stdout=subprocess.DEVNULL,
2056
+ env=compose_process_env,
2057
+ )
2058
+ up_args = ("up", "-d", CONTEXT_ENGINE_SERVICE)
2059
+ subprocess.run(
2060
+ runtime_compose_cmd(*up_args),
2061
+ check=True,
2062
+ stdout=subprocess.DEVNULL,
2063
+ env=compose_process_env,
2064
+ )
2065
+ finally:
2066
+ if anonymous_docker_config is not None:
2067
+ shutil.rmtree(anonymous_docker_config, ignore_errors=True)
2032
2068
  status = "restarted" if already_running and force else "started"
2069
+ if not model_already_installed:
2070
+ model_status = "installed" if _docker_model_inspect_ok(model) else "compose_managed"
2033
2071
  else:
2034
2072
  status = "already_running"
2035
2073
 
@@ -2038,10 +2076,106 @@ def ensure_context_engine_runtime(*, force: bool = False) -> dict[str, str]:
2038
2076
  "service": CONTEXT_ENGINE_SERVICE,
2039
2077
  "container": CONTEXT_ENGINE_CONTAINER,
2040
2078
  "model": model,
2041
- "membrane_dir": str(source_dir),
2079
+ "model_status": model_status,
2042
2080
  "compose_profiles": profiles,
2081
+ **({"engine_image": engine_image} if use_engine_image else {"membrane_dir": str(source_dir)}),
2043
2082
  }
2044
2083
 
2084
+ def _compose_subprocess_env(env: dict[str, str]) -> dict[str, str]:
2085
+ merged = dict(os.environ)
2086
+ merged.update({str(key): str(value) for key, value in env.items()})
2087
+ return merged
2088
+
2089
+ def _docker_model_inspect_ok(model: str) -> bool:
2090
+ if not model:
2091
+ return False
2092
+ return _docker_command_ok(["docker", "model", "inspect", model])
2093
+
2094
+ def _anonymous_public_gar_docker_env(env: dict[str, str], image: str) -> tuple[dict[str, str], Path | None]:
2095
+ if not _is_public_gar_image(image):
2096
+ return env, None
2097
+
2098
+ registry_host = _docker_image_registry_host(image)
2099
+ config_dir = Path(tempfile.mkdtemp(prefix="mn-public-gar-docker-config-"))
2100
+ source_config = _docker_config_dir(env) / "config.json"
2101
+ config: dict[str, Any] = {}
2102
+ if source_config.exists():
2103
+ try:
2104
+ loaded = json.loads(source_config.read_text(encoding="utf-8"))
2105
+ if isinstance(loaded, dict):
2106
+ config = loaded
2107
+ except Exception:
2108
+ logger.debug("Could not read Docker config for public GAR pull; using anonymous config", exc_info=True)
2109
+
2110
+ config = _docker_config_without_public_gar_credentials(config, registry_host)
2111
+ (config_dir / "config.json").write_text(json.dumps(config, indent=2, sort_keys=True) + "\n", encoding="utf-8")
2112
+ _expose_docker_cli_plugins(source_config.parent, config_dir)
2113
+ _expose_docker_contexts(source_config.parent, config_dir)
2114
+
2115
+ next_env = dict(env)
2116
+ next_env["DOCKER_CONFIG"] = str(config_dir)
2117
+ return next_env, config_dir
2118
+
2119
+ def _expose_docker_cli_plugins(source_config_dir: Path, target_config_dir: Path) -> None:
2120
+ source_plugins = source_config_dir / "cli-plugins"
2121
+ if not source_plugins.is_dir():
2122
+ return
2123
+ target_plugins = target_config_dir / "cli-plugins"
2124
+ try:
2125
+ os.symlink(source_plugins, target_plugins, target_is_directory=True)
2126
+ except Exception:
2127
+ try:
2128
+ shutil.copytree(source_plugins, target_plugins, symlinks=True)
2129
+ except Exception:
2130
+ logger.debug("Could not expose Docker CLI plugins for anonymous GAR pull", exc_info=True)
2131
+
2132
+ def _expose_docker_contexts(source_config_dir: Path, target_config_dir: Path) -> None:
2133
+ source_contexts = source_config_dir / "contexts"
2134
+ if not source_contexts.is_dir():
2135
+ return
2136
+ target_contexts = target_config_dir / "contexts"
2137
+ try:
2138
+ os.symlink(source_contexts, target_contexts, target_is_directory=True)
2139
+ except Exception:
2140
+ try:
2141
+ shutil.copytree(source_contexts, target_contexts, symlinks=True)
2142
+ except Exception:
2143
+ logger.debug("Could not expose Docker contexts for anonymous GAR pull", exc_info=True)
2144
+
2145
+ def _is_public_gar_image(image: str) -> bool:
2146
+ text = str(image or "").strip()
2147
+ return PUBLIC_GAR_PROJECT_PATH in text and _docker_image_registry_host(text).endswith(".pkg.dev")
2148
+
2149
+ def _docker_image_registry_host(image: str) -> str:
2150
+ return str(image or "").split("/", 1)[0].strip().lower()
2151
+
2152
+ def _docker_config_dir(env: dict[str, str]) -> Path:
2153
+ configured = str(env.get("DOCKER_CONFIG") or os.environ.get("DOCKER_CONFIG") or "").strip()
2154
+ return Path(configured).expanduser() if configured else Path.home() / ".docker"
2155
+
2156
+ def _docker_config_without_public_gar_credentials(config: dict[str, Any], registry_host: str) -> dict[str, Any]:
2157
+ sanitized = dict(config)
2158
+ sanitized.pop("credsStore", None)
2159
+ for key in ("credHelpers", "auths"):
2160
+ value = sanitized.get(key)
2161
+ if not isinstance(value, dict):
2162
+ continue
2163
+ filtered = {
2164
+ str(registry): details
2165
+ for registry, details in value.items()
2166
+ if not _docker_registry_matches_public_gar(str(registry), registry_host)
2167
+ }
2168
+ if filtered:
2169
+ sanitized[key] = filtered
2170
+ else:
2171
+ sanitized.pop(key, None)
2172
+ return sanitized
2173
+
2174
+ def _docker_registry_matches_public_gar(registry: str, registry_host: str) -> bool:
2175
+ normalized = registry.strip().lower()
2176
+ normalized = normalized.removeprefix("https://").removeprefix("http://").split("/", 1)[0]
2177
+ return normalized == registry_host or normalized.endswith(".pkg.dev")
2178
+
2045
2179
  def _compose_profiles_with(value: object, required_profile: str) -> str:
2046
2180
  profiles: list[str] = []
2047
2181
  seen: set[str] = set()
@@ -2058,6 +2192,50 @@ def _compose_profiles_with(value: object, required_profile: str) -> str:
2058
2192
  profiles.append(required_profile)
2059
2193
  return ",".join(profiles)
2060
2194
 
2195
+ def _normalized_release_image_tag(value: object) -> str:
2196
+ tag = str(value or "").strip()
2197
+ if not tag:
2198
+ return ""
2199
+ return tag if tag.startswith("v") else f"v{tag}"
2200
+
2201
+ def _context_engine_release_image(env: dict[str, str]) -> str:
2202
+ explicit = str(
2203
+ os.getenv("MN_MEMBRANE_ENGINE_IMAGE")
2204
+ or env.get("MN_MEMBRANE_ENGINE_IMAGE")
2205
+ or os.getenv("MN_CONTEXT_ENGINE_IMAGE")
2206
+ or env.get("MN_CONTEXT_ENGINE_IMAGE")
2207
+ or os.getenv("ENGINE_IMAGE")
2208
+ or env.get("ENGINE_IMAGE")
2209
+ or ""
2210
+ ).strip()
2211
+ if explicit:
2212
+ return explicit
2213
+
2214
+ tag = _normalized_release_image_tag(
2215
+ os.getenv("MN_MEMBRANE_ENGINE_IMAGE_TAG")
2216
+ or env.get("MN_MEMBRANE_ENGINE_IMAGE_TAG")
2217
+ or os.getenv("MN_RUNTIME_MODULE_VERSION")
2218
+ or env.get("MN_RUNTIME_MODULE_VERSION")
2219
+ or os.getenv("MN_PACKAGE_VERSION")
2220
+ or env.get("MN_PACKAGE_VERSION")
2221
+ )
2222
+ if not tag:
2223
+ return ""
2224
+ repository = str(
2225
+ os.getenv("MN_MEMBRANE_ENGINE_IMAGE_REPOSITORY")
2226
+ or env.get("MN_MEMBRANE_ENGINE_IMAGE_REPOSITORY")
2227
+ or DEFAULT_MEMBRANE_ENGINE_IMAGE_REPOSITORY
2228
+ ).strip().rstrip("/")
2229
+ return f"{repository}:{tag}" if repository else ""
2230
+
2231
+ def _context_engine_image_mode_enabled(env: dict[str, str], image: str) -> bool:
2232
+ mode = str(os.getenv("MN_MEMBRANE_SOURCE_MODE") or env.get("MN_MEMBRANE_SOURCE_MODE") or "").strip().lower()
2233
+ if mode in {"source", "git", "checkout", "local"}:
2234
+ return False
2235
+ if mode in {"image", "docker", "gar", "release"}:
2236
+ return bool(image)
2237
+ return bool(image and image != "mirror-neuron-memory-engine:latest")
2238
+
2061
2239
  def _context_engine_git_url(env: dict[str, str]) -> str:
2062
2240
  explicit = str(os.getenv("MN_MEMBRANE_GIT_URL") or env.get("MN_MEMBRANE_GIT_URL") or "").strip()
2063
2241
  if explicit:
@@ -3658,7 +3836,6 @@ def _start_server(
3658
3836
  if token:
3659
3837
  _write_network_token(network_token)
3660
3838
  env = _runtime_base_env(compose_runtime)
3661
- persisted_join_profile_before_network = bool(compose_runtime and not ip and _persisted_join_profile(env))
3662
3839
  mode_override = docker_network_mode or os.getenv("MN_DOCKER_NETWORK_MODE", "").strip()
3663
3840
  requested_docker_mode = _docker_network_mode(
3664
3841
  mode_override or None,
@@ -15,6 +15,7 @@ runner = CliRunner()
15
15
  @pytest.fixture(autouse=True)
16
16
  def isolate_model_ownership(monkeypatch, tmp_path):
17
17
  monkeypatch.setenv("MN_MODEL_OWNERSHIP_PATH", str(tmp_path / "ownership.json"))
18
+ monkeypatch.setattr("mn_cli.libs.model_cmds._endpoint_responds", lambda: False)
18
19
 
19
20
 
20
21
  def _completed(command, returncode=0, stdout="", stderr=""):
@@ -128,6 +129,38 @@ def test_model_install_streams_pull_progress(mocker):
128
129
  assert pull_kwargs["capture_output"] is False
129
130
 
130
131
 
132
+ def test_model_install_retries_transient_pull_failure(mocker):
133
+ calls = []
134
+ pull_attempts = 0
135
+
136
+ def fake_run(command, **kwargs):
137
+ nonlocal pull_attempts
138
+ calls.append(command)
139
+ if command[:4] == ["docker", "model", "status", "--json"]:
140
+ return _completed(command, stdout=json.dumps({"running": True, "backends": {"llama.cpp": "Running"}}))
141
+ if command[:4] == ["docker", "model", "run", "--help"]:
142
+ return _completed(command, stdout="Options:\n")
143
+ if command == ["docker", "model", "pull", "ai/gemma4:E2B"]:
144
+ pull_attempts += 1
145
+ if pull_attempts == 1:
146
+ return _completed(command, returncode=1, stderr="writing blob: blob digest mismatch")
147
+ if command == ["docker", "model", "inspect", "ai/gemma4:E2B"]:
148
+ return _completed(command, returncode=1)
149
+ return _completed(command)
150
+
151
+ mocker.patch("subprocess.run", side_effect=fake_run)
152
+ mocker.patch(
153
+ "mn_sdk.model_runtime.detect_host_hardware",
154
+ return_value=HostHardwareProfile("darwin", "arm64", total_memory_gb=16, unified_memory_gb=16, has_apple_silicon=True),
155
+ )
156
+
157
+ result = runner.invoke(app, ["model", "install", "gemma4:e2b"])
158
+
159
+ assert result.exit_code == 0
160
+ assert calls.count(["docker", "model", "pull", "ai/gemma4:E2B"]) == 2
161
+ assert ["docker", "model", "run", "--detach", "ai/gemma4:E2B"] in calls
162
+
163
+
131
164
  def test_model_install_persists_manual_ownership_record(mocker):
132
165
  def fake_run(command, **kwargs):
133
166
  if command[:4] == ["docker", "model", "status", "--json"]:
@@ -239,6 +272,40 @@ def test_model_install_falls_back_to_dmr_rest_when_cli_plugin_missing(mocker):
239
272
  assert {"from": "ai/gemma4:E2B"} in payloads
240
273
 
241
274
 
275
+ def test_model_install_prefers_dmr_rest_pull_when_runner_api_reachable(mocker, monkeypatch):
276
+ calls = []
277
+ requests = []
278
+ monkeypatch.setattr("mn_cli.libs.model_cmds._endpoint_responds", lambda: True)
279
+
280
+ def fake_run(command, **kwargs):
281
+ calls.append(command)
282
+ if command[:4] == ["docker", "model", "status", "--json"]:
283
+ return _completed(command, stdout=json.dumps({"running": True, "backends": {"llama.cpp": "Running"}}))
284
+ if command[:4] == ["docker", "model", "run", "--help"]:
285
+ return _completed(command, stdout="Options:\n")
286
+ return _completed(command)
287
+
288
+ def fake_urlopen(request, timeout=0):
289
+ requests.append((request.full_url, request.get_method(), request.data, timeout))
290
+ return FakeResponse("{}")
291
+
292
+ mocker.patch("subprocess.run", side_effect=fake_run)
293
+ mocker.patch("urllib.request.urlopen", side_effect=fake_urlopen)
294
+ mocker.patch(
295
+ "mn_sdk.model_runtime.detect_host_hardware",
296
+ return_value=HostHardwareProfile("darwin", "arm64", total_memory_gb=16, unified_memory_gb=16, has_apple_silicon=True),
297
+ )
298
+
299
+ result = runner.invoke(app, ["model", "install", "gemma4:e2b"])
300
+
301
+ assert result.exit_code == 0
302
+ assert ["docker", "model", "pull", "ai/gemma4:E2B"] not in calls
303
+ assert ["docker", "model", "run", "--detach", "ai/gemma4:E2B"] in calls
304
+ assert any(url.endswith("/models/create") and method == "POST" for url, method, _data, _timeout in requests)
305
+ payloads = [json.loads(data.decode("utf-8")) for _url, _method, data, _timeout in requests if data]
306
+ assert {"from": "ai/gemma4:E2B"} in payloads
307
+
308
+
242
309
  def test_model_list_reads_dmr_rest_tags_when_cli_plugin_missing(mocker):
243
310
  def fake_run(command, **kwargs):
244
311
  if command[:3] == ["docker", "model", "--help"] or command[:3] == ["docker", "model", "list"]:
@@ -293,6 +360,8 @@ def test_model_install_failure_does_not_record_manual_ownership(mocker, tmp_path
293
360
  return _completed(command, stdout=json.dumps({"running": True, "backends": {"llama.cpp": "Running"}}))
294
361
  if command[:3] == ["docker", "model", "pull"]:
295
362
  return _completed(command, returncode=1, stderr="pull failed")
363
+ if command[:3] == ["docker", "model", "inspect"]:
364
+ return _completed(command, returncode=1)
296
365
  return _completed(command)
297
366
 
298
367
  mocker.patch("subprocess.run", side_effect=fake_run)
@@ -789,6 +789,27 @@ def test_runtime_ensure_context_engine_explains_first_launch(mocker):
789
789
  assert "First launch may download the context model" in stdout_text
790
790
  assert "Context engine" in result.stdout
791
791
  assert "hf.co/example/context-model" in result.stdout
792
+ assert "/tmp/Membrane" in result.stdout
793
+ mock_ensure.assert_called_once_with(force=False)
794
+
795
+
796
+ def test_runtime_ensure_context_engine_reports_release_image(mocker):
797
+ mock_ensure = mocker.patch(
798
+ "mn_cli.libs.sys_cmds.ensure_context_engine_runtime",
799
+ return_value={
800
+ "status": "started",
801
+ "service": "membrane-context-engine",
802
+ "model": "hf.co/example/context-model",
803
+ "engine_image": "us-central1-docker.pkg.dev/example/runtime/membrane-context-engine:v1.2.14",
804
+ },
805
+ )
806
+
807
+ result = runner.invoke(app, ["runtime", "ensure-context-engine"])
808
+
809
+ assert result.exit_code == 0
810
+ assert "Context engine" in result.stdout
811
+ assert "hf.co/example/context-model" in result.stdout
812
+ assert "membrane-context-engine:v1.2.14" in result.stdout
792
813
  mock_ensure.assert_called_once_with(force=False)
793
814
 
794
815
 
@@ -983,6 +1004,9 @@ def test_openshell_skill_dependency_context_injects_pinned_gar_install(tmp_path)
983
1004
  assert context != sandbox_dir
984
1005
  assert "mirrorneuron-websocket-stream-skill==1.2.7" in requirements
985
1006
  assert "https://us-central1-python.pkg.dev/mirrorneuron-public-packages/agent-skills/simple/" in requirements
1007
+ assert "--index-url\n" not in requirements
1008
+ assert "--index-url https://us-central1-python.pkg.dev/mirrorneuron-public-packages/agent-skills/simple/" in requirements
1009
+ assert "--extra-index-url https://pypi.org/simple" in requirements
986
1010
  assert "COPY __mn_skill_dependencies/requirements.txt" in dockerfile
987
1011
  assert "pip install --break-system-packages --no-cache-dir -r /tmp/mn-skill-dependencies/requirements.txt" in dockerfile
988
1012
 
@@ -384,6 +384,9 @@ def test_stage_skill_dependency_payloads_injects_pinned_gar_requirements_for_doc
384
384
  dockerfile = payloads["worker/docker_worker/Dockerfile"].decode()
385
385
  assert "mirrorneuron-rag-skill==1.2.7" in requirements
386
386
  assert "https://us-central1-python.pkg.dev/mirrorneuron-public-packages/agent-skills/simple/" in requirements
387
+ assert "--index-url\n" not in requirements
388
+ assert "--index-url https://us-central1-python.pkg.dev/mirrorneuron-public-packages/agent-skills/simple/" in requirements
389
+ assert "--extra-index-url https://pypi.org/simple" in requirements
387
390
  assert "COPY __mn_skill_dependencies/requirements.txt" in dockerfile
388
391
  assert "pip install --break-system-packages --no-cache-dir -r /tmp/mn-skill-dependencies/requirements.txt" in dockerfile
389
392
 
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import pytest
3
+ import shutil
3
4
  import subprocess
4
5
  import sys
5
6
  from io import StringIO
@@ -324,6 +325,9 @@ def test_deploy_compose_passes_host_shared_storage_to_core():
324
325
 
325
326
  assert "MN_HOST_SHARED_STORAGE_ROOT:" in compose_text
326
327
  assert "${MN_HOST_SHARED_STORAGE_ROOT:-${MN_SHARED_STORAGE_ROOT:-" in compose_text
328
+ assert "membrane-context-engine:" in compose_text
329
+ assert "context-engine-model:" in compose_text
330
+ assert "MN_CONTEXT_MODEL_ENDPOINT" in compose_text
327
331
 
328
332
  def test_runtime_blueprint_env_updates_ignores_legacy_host_mn_dir(tmp_path):
329
333
  legacy_home = tmp_path / "legacy-mn-home"
@@ -574,6 +578,7 @@ def test_ensure_context_engine_runtime_persists_profile_and_starts_compose(mocke
574
578
  server_cmds.RUNTIME_COMPOSE_FILE.write_text("services: {}\n", encoding="utf-8")
575
579
  mocker.patch("mn_cli.server_cmds._ensure_context_engine_source", return_value=membrane_dir)
576
580
  mocker.patch("mn_cli.server_cmds._ensure_docker_model_runner")
581
+ inspect_model = mocker.patch("mn_cli.server_cmds._docker_model_inspect_ok", side_effect=[False, True])
577
582
  mocker.patch("mn_cli.server_cmds._remove_non_mirror_neuron_container")
578
583
  mocker.patch("mn_cli.server_cmds._docker_container_running", return_value=False)
579
584
  run = mocker.patch("mn_cli.server_cmds.subprocess.run")
@@ -585,9 +590,99 @@ def test_ensure_context_engine_runtime_persists_profile_and_starts_compose(mocke
585
590
  assert env["MEMBRANE_DIR"] == str(membrane_dir)
586
591
  assert env["MN_CONTEXT_MODEL_RUNNER_MODEL"] == server_cmds.DEFAULT_CONTEXT_MODEL_RUNNER_MODEL
587
592
  assert result["status"] == "started"
593
+ assert result["model_status"] == "installed"
594
+ assert inspect_model.call_args_list[0].args[0] == server_cmds.DEFAULT_CONTEXT_MODEL_RUNNER_MODEL
588
595
  assert run.call_args_list[0].args[0] == runtime_compose_cmd("build", "membrane-context-engine")
589
596
  assert run.call_args_list[1].args[0] == runtime_compose_cmd("up", "-d", "membrane-context-engine")
590
597
 
598
+ def test_ensure_context_engine_runtime_uses_release_image_without_source_clone(mocker, monkeypatch):
599
+ monkeypatch.setenv("PATH", "/usr/local/bin:/usr/bin:/bin")
600
+ server_cmds.RUNTIME_COMPOSE_ENV.parent.mkdir(parents=True, exist_ok=True)
601
+ server_cmds.RUNTIME_COMPOSE_ENV.write_text(
602
+ "COMPOSE_PROJECT_NAME=mirror-neuron\n"
603
+ "COMPOSE_PROFILES=openshell\n"
604
+ "MN_RUNTIME_MODULE_VERSION=1.2.7\n"
605
+ "MEMBRANE_DIR=/private/membrane\n",
606
+ encoding="utf-8",
607
+ )
608
+ server_cmds.RUNTIME_COMPOSE_FILE.write_text("services: {}\n", encoding="utf-8")
609
+ ensure_source = mocker.patch("mn_cli.server_cmds._ensure_context_engine_source")
610
+ mocker.patch("mn_cli.server_cmds._ensure_docker_model_runner")
611
+ inspect_model = mocker.patch("mn_cli.server_cmds._docker_model_inspect_ok", return_value=True)
612
+ mocker.patch("mn_cli.server_cmds._remove_non_mirror_neuron_container")
613
+ mocker.patch("mn_cli.server_cmds._docker_container_running", return_value=False)
614
+ run = mocker.patch("mn_cli.server_cmds.subprocess.run")
615
+
616
+ result = server_cmds.ensure_context_engine_runtime()
617
+
618
+ env = server_cmds._read_env_file(server_cmds.RUNTIME_COMPOSE_ENV)
619
+ expected_image = (
620
+ "us-central1-docker.pkg.dev/mirrorneuron-public-packages/"
621
+ "mirrorneuron-runtime/membrane-context-engine:v1.2.7"
622
+ )
623
+ assert env["COMPOSE_PROFILES"] == "openshell,context"
624
+ assert env["ENGINE_IMAGE"] == expected_image
625
+ assert env["MN_MEMBRANE_ENGINE_IMAGE"] == expected_image
626
+ assert "MEMBRANE_DIR" not in env
627
+ assert result["status"] == "started"
628
+ assert result["model_status"] == "already_installed"
629
+ assert result["engine_image"] == expected_image
630
+ ensure_source.assert_not_called()
631
+ inspect_model.assert_called_once_with(server_cmds.DEFAULT_CONTEXT_MODEL_RUNNER_MODEL)
632
+ assert run.call_args_list[0].args[0] == runtime_compose_cmd("pull", "membrane-context-engine")
633
+ assert run.call_args_list[1].args[0] == runtime_compose_cmd("up", "-d", "--no-build", "membrane-context-engine")
634
+ assert run.call_args_list[0].kwargs["env"]["DOCKER_CONFIG"] != str(Path.home() / ".docker")
635
+ assert run.call_args_list[0].kwargs["env"]["PATH"] == "/usr/local/bin:/usr/bin:/bin"
636
+
637
+ def test_public_gar_docker_env_strips_gcloud_helpers(monkeypatch, tmp_path):
638
+ docker_config = tmp_path / "docker-config"
639
+ docker_config.mkdir()
640
+ docker_plugins = docker_config / "cli-plugins"
641
+ docker_plugins.mkdir()
642
+ (docker_plugins / "docker-compose").write_text("#!/bin/sh\n", encoding="utf-8")
643
+ docker_context = docker_config / "contexts" / "meta" / "desktop-linux"
644
+ docker_context.mkdir(parents=True)
645
+ (docker_context / "meta.json").write_text('{"Name":"desktop-linux"}\n', encoding="utf-8")
646
+ (docker_config / "config.json").write_text(
647
+ json.dumps(
648
+ {
649
+ "currentContext": "desktop-linux",
650
+ "auths": {
651
+ "https://us-central1-docker.pkg.dev": {"auth": "private"},
652
+ "ghcr.io": {"auth": "keep"},
653
+ },
654
+ "credHelpers": {
655
+ "us-central1-docker.pkg.dev": "gcloud",
656
+ "ghcr.io": "ghcr",
657
+ },
658
+ "credsStore": "desktop",
659
+ "proxies": {"default": {"httpProxy": "http://proxy.test"}},
660
+ }
661
+ ),
662
+ encoding="utf-8",
663
+ )
664
+ env = {"DOCKER_CONFIG": str(docker_config)}
665
+
666
+ next_env, temp_config_dir = server_cmds._anonymous_public_gar_docker_env(
667
+ env,
668
+ "us-central1-docker.pkg.dev/mirrorneuron-public-packages/mirrorneuron-runtime/membrane-context-engine:v1.2.8",
669
+ )
670
+
671
+ try:
672
+ assert temp_config_dir is not None
673
+ assert next_env["DOCKER_CONFIG"] == str(temp_config_dir)
674
+ assert next_env["DOCKER_CONFIG"] != env["DOCKER_CONFIG"]
675
+ sanitized = json.loads((temp_config_dir / "config.json").read_text(encoding="utf-8"))
676
+ assert "credsStore" not in sanitized
677
+ assert sanitized["credHelpers"] == {"ghcr.io": "ghcr"}
678
+ assert sanitized["auths"] == {"ghcr.io": {"auth": "keep"}}
679
+ assert sanitized["proxies"] == {"default": {"httpProxy": "http://proxy.test"}}
680
+ assert sanitized["currentContext"] == "desktop-linux"
681
+ assert (temp_config_dir / "cli-plugins" / "docker-compose").exists()
682
+ assert (temp_config_dir / "contexts" / "meta" / "desktop-linux" / "meta.json").exists()
683
+ finally:
684
+ shutil.rmtree(temp_config_dir, ignore_errors=True)
685
+
591
686
  def test_ensure_context_engine_runtime_skips_compose_when_already_running(mocker, tmp_path):
592
687
  membrane_dir = tmp_path / "Membrane"
593
688
  membrane_dir.mkdir()
@@ -602,6 +697,7 @@ def test_ensure_context_engine_runtime_skips_compose_when_already_running(mocker
602
697
  server_cmds.RUNTIME_COMPOSE_FILE.write_text("services: {}\n", encoding="utf-8")
603
698
  mocker.patch("mn_cli.server_cmds._ensure_context_engine_source", return_value=membrane_dir)
604
699
  mocker.patch("mn_cli.server_cmds._ensure_docker_model_runner")
700
+ inspect_model = mocker.patch("mn_cli.server_cmds._docker_model_inspect_ok", return_value=True)
605
701
  mocker.patch("mn_cli.server_cmds._remove_non_mirror_neuron_container")
606
702
  mocker.patch("mn_cli.server_cmds._docker_container_running", return_value=True)
607
703
  run = mocker.patch("mn_cli.server_cmds.subprocess.run")
@@ -610,8 +706,36 @@ def test_ensure_context_engine_runtime_skips_compose_when_already_running(mocker
610
706
 
611
707
  assert result["status"] == "already_running"
612
708
  assert result["model"] == "hf.co/acme/context"
709
+ assert result["model_status"] == "already_installed"
710
+ inspect_model.assert_called_once_with("hf.co/acme/context")
613
711
  run.assert_not_called()
614
712
 
713
+ def test_ensure_context_engine_runtime_reconciles_missing_model_with_compose(mocker, tmp_path):
714
+ membrane_dir = tmp_path / "Membrane"
715
+ membrane_dir.mkdir()
716
+ (membrane_dir / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8")
717
+ server_cmds.RUNTIME_COMPOSE_ENV.parent.mkdir(parents=True, exist_ok=True)
718
+ server_cmds.RUNTIME_COMPOSE_ENV.write_text(
719
+ "COMPOSE_PROJECT_NAME=mirror-neuron\n"
720
+ "COMPOSE_PROFILES=context\n"
721
+ "MN_CONTEXT_MODEL_RUNNER_MODEL=hf.co/acme/context\n",
722
+ encoding="utf-8",
723
+ )
724
+ server_cmds.RUNTIME_COMPOSE_FILE.write_text("services: {}\n", encoding="utf-8")
725
+ mocker.patch("mn_cli.server_cmds._ensure_context_engine_source", return_value=membrane_dir)
726
+ mocker.patch("mn_cli.server_cmds._ensure_docker_model_runner")
727
+ mocker.patch("mn_cli.server_cmds._docker_model_inspect_ok", side_effect=[False, True])
728
+ mocker.patch("mn_cli.server_cmds._remove_non_mirror_neuron_container")
729
+ mocker.patch("mn_cli.server_cmds._docker_container_running", return_value=True)
730
+ run = mocker.patch("mn_cli.server_cmds.subprocess.run")
731
+
732
+ result = server_cmds.ensure_context_engine_runtime()
733
+
734
+ assert result["status"] == "started"
735
+ assert result["model_status"] == "installed"
736
+ assert run.call_args_list[0].args[0] == runtime_compose_cmd("build", "membrane-context-engine")
737
+ assert run.call_args_list[1].args[0] == runtime_compose_cmd("up", "-d", "membrane-context-engine")
738
+
615
739
  def test_runtime_compose_cmd_includes_models_override():
616
740
  server_cmds.RUNTIME_COMPOSE_ENV.parent.mkdir(parents=True, exist_ok=True)
617
741
  server_cmds.RUNTIME_COMPOSE_ENV.write_text("COMPOSE_PROJECT_NAME=mirror-neuron\n", encoding="utf-8")