mirrorneuron-cli 1.2.8__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.8/mirrorneuron_cli.egg-info → mirrorneuron_cli-1.2.15}/PKG-INFO +1 -1
  2. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15/mirrorneuron_cli.egg-info}/PKG-INFO +1 -1
  3. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/model_cmds.py +24 -3
  4. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/skill_dependencies.py +12 -1
  5. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/sys_cmds.py +11 -5
  6. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/server_cmds.py +129 -19
  7. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_model_cmds.py +35 -0
  8. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_run_cmds.py +24 -0
  9. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_run_helpers.py +3 -0
  10. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_server_cmds.py +92 -1
  11. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/.github/workflows/ci.yml +0 -0
  12. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/.github/workflows/release.yml +0 -0
  13. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/.gitignore +0 -0
  14. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/.python-version +0 -0
  15. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/AGENTS.md +0 -0
  16. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/LICENSE +0 -0
  17. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/README.md +0 -0
  18. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/RELEASE.md +0 -0
  19. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mirrorneuron_cli.egg-info/SOURCES.txt +0 -0
  20. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mirrorneuron_cli.egg-info/dependency_links.txt +0 -0
  21. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mirrorneuron_cli.egg-info/entry_points.txt +0 -0
  22. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mirrorneuron_cli.egg-info/requires.txt +0 -0
  23. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mirrorneuron_cli.egg-info/top_level.txt +0 -0
  24. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/__init__.py +0 -0
  25. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/banner.py +0 -0
  26. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/config.py +0 -0
  27. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/error_handler.py +0 -0
  28. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/__init__.py +0 -0
  29. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/artifacts.py +0 -0
  30. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/backup_cmds.py +0 -0
  31. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/blueprint_cmds.py +0 -0
  32. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/blueprint_models.py +0 -0
  33. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/blueprint_observability.py +0 -0
  34. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/blueprint_repository.py +0 -0
  35. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/blueprint_resources.py +0 -0
  36. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/bundles.py +0 -0
  37. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/deployment_cmds.py +0 -0
  38. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/event_relay.py +0 -0
  39. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/job_cmds.py +0 -0
  40. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/resource_cmds.py +0 -0
  41. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/run_cmds.py +0 -0
  42. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/run_logs.py +0 -0
  43. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/run_manifest.py +0 -0
  44. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/runtime_health.py +0 -0
  45. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/schedule_cmds.py +0 -0
  46. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/service_cmds.py +0 -0
  47. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/skill_runtime.py +0 -0
  48. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/ui.py +0 -0
  49. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/workflow_progress.py +0 -0
  50. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/libs/workflow_validation.py +0 -0
  51. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/logging_config.py +0 -0
  52. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/main.py +0 -0
  53. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/runtime_mode.py +0 -0
  54. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/runtime_state.py +0 -0
  55. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/schemas/workflow_manifest.schema.json +0 -0
  56. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/sdk_path.py +0 -0
  57. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/shared.py +0 -0
  58. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/terminal.py +0 -0
  59. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/mn_cli/update_cmds.py +0 -0
  60. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/pyproject.toml +0 -0
  61. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/scripts/check-release-artifacts.sh +0 -0
  62. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/scripts/make-release-zip.sh +0 -0
  63. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/scripts/validate-version-tag.sh +0 -0
  64. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/setup.cfg +0 -0
  65. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/conftest.py +0 -0
  66. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_backup_cmds.py +0 -0
  67. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_blueprint_cmds.py +0 -0
  68. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_blueprint_repository.py +0 -0
  69. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_blueprint_resources.py +0 -0
  70. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_deployment_cmds.py +0 -0
  71. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_docker_network_integration.py +0 -0
  72. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_job_cmds.py +0 -0
  73. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_main.py +0 -0
  74. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_repo_hygiene.py +0 -0
  75. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_resource_cmds.py +0 -0
  76. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_runtime_health.py +0 -0
  77. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_runtime_mode.py +0 -0
  78. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_runtime_state.py +0 -0
  79. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_schedule_cmds.py +0 -0
  80. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_service_cmds.py +0 -0
  81. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_shared.py +0 -0
  82. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_sys_cmds.py +0 -0
  83. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_terminal.py +0 -0
  84. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_ui.py +0 -0
  85. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_update_cmds.py +0 -0
  86. {mirrorneuron_cli-1.2.8 → mirrorneuron_cli-1.2.15}/tests/test_workflow_validation.py +0 -0
  87. {mirrorneuron_cli-1.2.8 → 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.8
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.8
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)
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,21 @@ 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
+
272
293
  def _docker_model_pull(target: str, *, attempts: int = 2) -> None:
273
294
  last_error: RuntimeError | None = None
274
295
  for attempt in range(1, attempts + 1):
@@ -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
@@ -116,6 +117,7 @@ DEFAULT_MEMBRANE_ENGINE_IMAGE_REPOSITORY = (
116
117
  "us-central1-docker.pkg.dev/mirrorneuron-public-packages/"
117
118
  "mirrorneuron-runtime/membrane-context-engine"
118
119
  )
120
+ PUBLIC_GAR_PROJECT_PATH = "/mirrorneuron-public-packages/"
119
121
  CONTEXT_ENGINE_SERVICE = "membrane-context-engine"
120
122
  CONTEXT_ENGINE_CONTAINER = "mirror-neuron-context-engine"
121
123
  CONTEXT_ENGINE_MODEL_CONTAINER = "mirror-neuron-context-engine-model"
@@ -2027,32 +2029,45 @@ def ensure_context_engine_runtime(*, force: bool = False) -> dict[str, str]:
2027
2029
  _remove_non_mirror_neuron_container(CONTEXT_ENGINE_CONTAINER)
2028
2030
  _remove_non_mirror_neuron_container(CONTEXT_ENGINE_MODEL_CONTAINER)
2029
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"
2030
2034
 
2031
2035
  already_running = _docker_container_running(CONTEXT_ENGINE_CONTAINER)
2032
- if force or not already_running:
2036
+ if force or not already_running or not model_already_installed:
2037
+ compose_env = env
2038
+ anonymous_docker_config: Path | None = None
2033
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)
2034
2059
  subprocess.run(
2035
- runtime_compose_cmd("pull", CONTEXT_ENGINE_SERVICE),
2036
- check=True,
2037
- stdout=subprocess.DEVNULL,
2038
- env=env,
2039
- )
2040
- up_args = ("up", "-d", "--no-build", CONTEXT_ENGINE_SERVICE)
2041
- else:
2042
- subprocess.run(
2043
- runtime_compose_cmd("build", CONTEXT_ENGINE_SERVICE),
2060
+ runtime_compose_cmd(*up_args),
2044
2061
  check=True,
2045
2062
  stdout=subprocess.DEVNULL,
2046
- env=env,
2063
+ env=compose_process_env,
2047
2064
  )
2048
- up_args = ("up", "-d", CONTEXT_ENGINE_SERVICE)
2049
- subprocess.run(
2050
- runtime_compose_cmd(*up_args),
2051
- check=True,
2052
- stdout=subprocess.DEVNULL,
2053
- env=env,
2054
- )
2065
+ finally:
2066
+ if anonymous_docker_config is not None:
2067
+ shutil.rmtree(anonymous_docker_config, ignore_errors=True)
2055
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"
2056
2071
  else:
2057
2072
  status = "already_running"
2058
2073
 
@@ -2061,10 +2076,106 @@ def ensure_context_engine_runtime(*, force: bool = False) -> dict[str, str]:
2061
2076
  "service": CONTEXT_ENGINE_SERVICE,
2062
2077
  "container": CONTEXT_ENGINE_CONTAINER,
2063
2078
  "model": model,
2079
+ "model_status": model_status,
2064
2080
  "compose_profiles": profiles,
2065
2081
  **({"engine_image": engine_image} if use_engine_image else {"membrane_dir": str(source_dir)}),
2066
2082
  }
2067
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
+
2068
2179
  def _compose_profiles_with(value: object, required_profile: str) -> str:
2069
2180
  profiles: list[str] = []
2070
2181
  seen: set[str] = set()
@@ -3725,7 +3836,6 @@ def _start_server(
3725
3836
  if token:
3726
3837
  _write_network_token(network_token)
3727
3838
  env = _runtime_base_env(compose_runtime)
3728
- persisted_join_profile_before_network = bool(compose_runtime and not ip and _persisted_join_profile(env))
3729
3839
  mode_override = docker_network_mode or os.getenv("MN_DOCKER_NETWORK_MODE", "").strip()
3730
3840
  requested_docker_mode = _docker_network_mode(
3731
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=""):
@@ -271,6 +272,40 @@ def test_model_install_falls_back_to_dmr_rest_when_cli_plugin_missing(mocker):
271
272
  assert {"from": "ai/gemma4:E2B"} in payloads
272
273
 
273
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
+
274
309
  def test_model_list_reads_dmr_rest_tags_when_cli_plugin_missing(mocker):
275
310
  def fake_run(command, **kwargs):
276
311
  if command[:3] == ["docker", "model", "--help"] or command[:3] == ["docker", "model", "list"]:
@@ -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,10 +590,13 @@ 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
 
591
- def test_ensure_context_engine_runtime_uses_release_image_without_source_clone(mocker):
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")
592
600
  server_cmds.RUNTIME_COMPOSE_ENV.parent.mkdir(parents=True, exist_ok=True)
593
601
  server_cmds.RUNTIME_COMPOSE_ENV.write_text(
594
602
  "COMPOSE_PROJECT_NAME=mirror-neuron\n"
@@ -600,6 +608,7 @@ def test_ensure_context_engine_runtime_uses_release_image_without_source_clone(m
600
608
  server_cmds.RUNTIME_COMPOSE_FILE.write_text("services: {}\n", encoding="utf-8")
601
609
  ensure_source = mocker.patch("mn_cli.server_cmds._ensure_context_engine_source")
602
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)
603
612
  mocker.patch("mn_cli.server_cmds._remove_non_mirror_neuron_container")
604
613
  mocker.patch("mn_cli.server_cmds._docker_container_running", return_value=False)
605
614
  run = mocker.patch("mn_cli.server_cmds.subprocess.run")
@@ -616,10 +625,63 @@ def test_ensure_context_engine_runtime_uses_release_image_without_source_clone(m
616
625
  assert env["MN_MEMBRANE_ENGINE_IMAGE"] == expected_image
617
626
  assert "MEMBRANE_DIR" not in env
618
627
  assert result["status"] == "started"
628
+ assert result["model_status"] == "already_installed"
619
629
  assert result["engine_image"] == expected_image
620
630
  ensure_source.assert_not_called()
631
+ inspect_model.assert_called_once_with(server_cmds.DEFAULT_CONTEXT_MODEL_RUNNER_MODEL)
621
632
  assert run.call_args_list[0].args[0] == runtime_compose_cmd("pull", "membrane-context-engine")
622
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)
623
685
 
624
686
  def test_ensure_context_engine_runtime_skips_compose_when_already_running(mocker, tmp_path):
625
687
  membrane_dir = tmp_path / "Membrane"
@@ -635,6 +697,7 @@ def test_ensure_context_engine_runtime_skips_compose_when_already_running(mocker
635
697
  server_cmds.RUNTIME_COMPOSE_FILE.write_text("services: {}\n", encoding="utf-8")
636
698
  mocker.patch("mn_cli.server_cmds._ensure_context_engine_source", return_value=membrane_dir)
637
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)
638
701
  mocker.patch("mn_cli.server_cmds._remove_non_mirror_neuron_container")
639
702
  mocker.patch("mn_cli.server_cmds._docker_container_running", return_value=True)
640
703
  run = mocker.patch("mn_cli.server_cmds.subprocess.run")
@@ -643,8 +706,36 @@ def test_ensure_context_engine_runtime_skips_compose_when_already_running(mocker
643
706
 
644
707
  assert result["status"] == "already_running"
645
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")
646
711
  run.assert_not_called()
647
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
+
648
739
  def test_runtime_compose_cmd_includes_models_override():
649
740
  server_cmds.RUNTIME_COMPOSE_ENV.parent.mkdir(parents=True, exist_ok=True)
650
741
  server_cmds.RUNTIME_COMPOSE_ENV.write_text("COMPOSE_PROJECT_NAME=mirror-neuron\n", encoding="utf-8")