snapcontext 4.2.0__tar.gz → 4.3.0__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 (51) hide show
  1. {snapcontext-4.2.0/snapcontext.egg-info → snapcontext-4.3.0}/PKG-INFO +58 -2
  2. {snapcontext-4.2.0 → snapcontext-4.3.0}/README.md +57 -1
  3. {snapcontext-4.2.0 → snapcontext-4.3.0}/agentes.py +6 -0
  4. {snapcontext-4.2.0 → snapcontext-4.3.0}/pyproject.toml +2 -2
  5. {snapcontext-4.2.0 → snapcontext-4.3.0/snapcontext.egg-info}/PKG-INFO +58 -2
  6. {snapcontext-4.2.0 → snapcontext-4.3.0}/snapcontext.egg-info/SOURCES.txt +1 -0
  7. {snapcontext-4.2.0 → snapcontext-4.3.0}/snapcontext.py +212 -10
  8. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_auto_017.py +1 -1
  9. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_diagnostico_310.py +1 -1
  10. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_diff_editor_210.py +1 -1
  11. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_editor_ast_220.py +1 -1
  12. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_editor_propio_200.py +1 -1
  13. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_exe_150.py +1 -1
  14. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_jetbrains_170.py +1 -1
  15. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_mcp_014.py +1 -1
  16. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_mcp_140.py +1 -1
  17. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_memoria_015.py +1 -1
  18. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_memoria_300.py +1 -1
  19. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_permisos_013.py +1 -1
  20. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_plan_012.py +1 -1
  21. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_plan_mcp_230.py +1 -1
  22. snapcontext-4.3.0/tests/test_sandbox_430.py +249 -0
  23. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_v010.py +1 -1
  24. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_validacion_130.py +1 -1
  25. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_vscode_016.py +1 -1
  26. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_web_160.py +1 -1
  27. {snapcontext-4.2.0 → snapcontext-4.3.0}/LICENSE +0 -0
  28. {snapcontext-4.2.0 → snapcontext-4.3.0}/orquestador.py +0 -0
  29. {snapcontext-4.2.0 → snapcontext-4.3.0}/setup.cfg +0 -0
  30. {snapcontext-4.2.0 → snapcontext-4.3.0}/snapcontext.egg-info/__pycache__/top_level.cpython-313.pyc +0 -0
  31. {snapcontext-4.2.0 → snapcontext-4.3.0}/snapcontext.egg-info/dependency_links.txt +0 -0
  32. {snapcontext-4.2.0 → snapcontext-4.3.0}/snapcontext.egg-info/entry_points.txt +0 -0
  33. {snapcontext-4.2.0 → snapcontext-4.3.0}/snapcontext.egg-info/requires.txt +0 -0
  34. {snapcontext-4.2.0 → snapcontext-4.3.0}/snapcontext.egg-info/top_level.txt +0 -0
  35. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_agentes.py +0 -0
  36. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_api_360.py +0 -0
  37. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_asesor_350.py +0 -0
  38. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_bienvenida_311.py +0 -0
  39. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_deteccion.py +0 -0
  40. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_editor_330.py +0 -0
  41. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_editor_410.py +0 -0
  42. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_embeddings_110.py +0 -0
  43. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_orquestador.py +0 -0
  44. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_plugins_400.py +0 -0
  45. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_seguridad_rendimiento_420.py +0 -0
  46. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_validacion_sintaxis_340.py +0 -0
  47. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_vscode_ts_320.py +0 -0
  48. {snapcontext-4.2.0 → snapcontext-4.3.0}/tests/test_web_120.py +0 -0
  49. {snapcontext-4.2.0 → snapcontext-4.3.0}/web/__init__.py +0 -0
  50. {snapcontext-4.2.0 → snapcontext-4.3.0}/web/app.py +0 -0
  51. {snapcontext-4.2.0 → snapcontext-4.3.0}/web/static/index.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snapcontext
3
- Version: 4.2.0
3
+ Version: 4.3.0
4
4
  Summary: SnapContext: Asistente de IA con contexto automático para desarrollo
5
5
  Author-email: SnapContext Contributors <tu@email.com>
6
6
  License-Expression: MIT
@@ -45,7 +45,7 @@ Dynamic: license-file
45
45
 
46
46
  # SnapContext
47
47
 
48
- ![v4.2.0](https://img.shields.io/badge/version-4.2.0-blue.svg)
48
+ ![v4.3.0](https://img.shields.io/badge/version-4.3.0-blue.svg)
49
49
  [![PyPI](https://badge.fury.io/py/snapcontext.svg)](https://pypi.org/project/snapcontext/)
50
50
  [![CI](https://img.shields.io/github/actions/workflow/status/NicolasBruna24/snapcontext/ci.yml?branch=main&label=tests)](https://github.com/NicolasBruña24/snapcontext/actions)
51
51
  ![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)
@@ -85,6 +85,62 @@ pip install aider-chat # ediciones de código
85
85
  snapcontext --init # asistente inicial + API key
86
86
  ```
87
87
 
88
+ ## 🐳 Sandboxing con Docker (v4.3.0)
89
+
90
+ Ejecuta los comandos de SnapContext (bucle de pruebas, `execute_command`,
91
+ pasos `ejecutar` del planificador, plugins…) dentro de un **contenedor Docker
92
+ aislado**. Ideal cuando no se confía en el código o necesitas dependencias
93
+ específicas.
94
+
95
+ ### Uso básico
96
+
97
+ ```bash
98
+ # Todo el trabajo de comandos dentro del contenedor
99
+ snapcontext "arreglar el checkout" --test-loop --sandbox
100
+
101
+ # Con imagen personalizada y comando de preparación
102
+ snapcontext "añadir tests" --test-loop --sandbox \
103
+ --sandbox-imagen python:3.11-slim \
104
+ --sandbox-comando "pip install pytest"
105
+
106
+ # En el planificador: pasos "ejecutar" y MCP con comandos usan el sandbox;
107
+ # "editar" y "consultar" siguen en el host.
108
+ snapcontext "migrar a pytest" --plan --sandbox
109
+
110
+ # Imagen por defecto sin flag:
111
+ export SNAPCONTEXT_SANDBOX_IMAGE=ubuntu:22.04 # PowerShell: $env:SNAPCONTEXT_SANDBOX_IMAGE="..."
112
+ ```
113
+
114
+ ### Cómo funciona
115
+
116
+ - **Detección**: `_docker_disponible()` comprueba `docker --version` (binario)
117
+ y `docker info` (daemon activo).
118
+ - **Ejecución**: cada comando se lanza como
119
+ `docker run --rm -v "<proyecto>:/workspace" -w /workspace -e GEMINI_API_KEY … <imagen> sh -c "<comando>"`.
120
+ - **Montaje**: el directorio del proyecto se monta en `/workspace`.
121
+ - **Variables de entorno**: las claves (`*_API_KEY`, `OLLAMA_URL`, …) se pasan
122
+ al contenedor automáticamente.
123
+ - **Preparación**: `--sandbox-comando` antepone un comando de setup
124
+ (`apt update && apt install -y …`) antes del comando principal.
125
+
126
+ ### Qué corre dentro y fuera del sandbox
127
+
128
+ | Dentro del contenedor | Fuera (host) |
129
+ | --- | --- |
130
+ | Bucle de pruebas (`--test-loop`) | Herramientas de solo lectura (`grep`, `read_file`, `list_files`, `ast`) |
131
+ | MCP `execute_command` (+ background) | Pasos `editar` y `consultar` del planificador |
132
+ | Pasos `ejecutar` del planificador | Selección de contexto, chat con la IA |
133
+ | Plugins y herramientas de usuario | |
134
+
135
+ ### Errores y logs
136
+
137
+ - Cada comando sandboxeado registra `ℹ [sandbox] Ejecutando en contenedor: …`.
138
+ - Si un comando falla dentro del contenedor se muestra su salida y código como
139
+ siempre.
140
+ - Si Docker no está disponible y usaste `--sandbox` explícitamente,
141
+ SnapContext falla con un mensaje claro; sin el flag nunca cambia nada
142
+ (compatibilidad total).
143
+
88
144
  ## 🧰 Instalación y onboarding sin fricción (v3.1.x)
89
145
 
90
146
  Objetivo: instalar SnapContext y empezar a usarlo en menos de 5 minutos.
@@ -1,6 +1,6 @@
1
1
  # SnapContext
2
2
 
3
- ![v4.2.0](https://img.shields.io/badge/version-4.2.0-blue.svg)
3
+ ![v4.3.0](https://img.shields.io/badge/version-4.3.0-blue.svg)
4
4
  [![PyPI](https://badge.fury.io/py/snapcontext.svg)](https://pypi.org/project/snapcontext/)
5
5
  [![CI](https://img.shields.io/github/actions/workflow/status/NicolasBruna24/snapcontext/ci.yml?branch=main&label=tests)](https://github.com/NicolasBruña24/snapcontext/actions)
6
6
  ![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)
@@ -40,6 +40,62 @@ pip install aider-chat # ediciones de código
40
40
  snapcontext --init # asistente inicial + API key
41
41
  ```
42
42
 
43
+ ## 🐳 Sandboxing con Docker (v4.3.0)
44
+
45
+ Ejecuta los comandos de SnapContext (bucle de pruebas, `execute_command`,
46
+ pasos `ejecutar` del planificador, plugins…) dentro de un **contenedor Docker
47
+ aislado**. Ideal cuando no se confía en el código o necesitas dependencias
48
+ específicas.
49
+
50
+ ### Uso básico
51
+
52
+ ```bash
53
+ # Todo el trabajo de comandos dentro del contenedor
54
+ snapcontext "arreglar el checkout" --test-loop --sandbox
55
+
56
+ # Con imagen personalizada y comando de preparación
57
+ snapcontext "añadir tests" --test-loop --sandbox \
58
+ --sandbox-imagen python:3.11-slim \
59
+ --sandbox-comando "pip install pytest"
60
+
61
+ # En el planificador: pasos "ejecutar" y MCP con comandos usan el sandbox;
62
+ # "editar" y "consultar" siguen en el host.
63
+ snapcontext "migrar a pytest" --plan --sandbox
64
+
65
+ # Imagen por defecto sin flag:
66
+ export SNAPCONTEXT_SANDBOX_IMAGE=ubuntu:22.04 # PowerShell: $env:SNAPCONTEXT_SANDBOX_IMAGE="..."
67
+ ```
68
+
69
+ ### Cómo funciona
70
+
71
+ - **Detección**: `_docker_disponible()` comprueba `docker --version` (binario)
72
+ y `docker info` (daemon activo).
73
+ - **Ejecución**: cada comando se lanza como
74
+ `docker run --rm -v "<proyecto>:/workspace" -w /workspace -e GEMINI_API_KEY … <imagen> sh -c "<comando>"`.
75
+ - **Montaje**: el directorio del proyecto se monta en `/workspace`.
76
+ - **Variables de entorno**: las claves (`*_API_KEY`, `OLLAMA_URL`, …) se pasan
77
+ al contenedor automáticamente.
78
+ - **Preparación**: `--sandbox-comando` antepone un comando de setup
79
+ (`apt update && apt install -y …`) antes del comando principal.
80
+
81
+ ### Qué corre dentro y fuera del sandbox
82
+
83
+ | Dentro del contenedor | Fuera (host) |
84
+ | --- | --- |
85
+ | Bucle de pruebas (`--test-loop`) | Herramientas de solo lectura (`grep`, `read_file`, `list_files`, `ast`) |
86
+ | MCP `execute_command` (+ background) | Pasos `editar` y `consultar` del planificador |
87
+ | Pasos `ejecutar` del planificador | Selección de contexto, chat con la IA |
88
+ | Plugins y herramientas de usuario | |
89
+
90
+ ### Errores y logs
91
+
92
+ - Cada comando sandboxeado registra `ℹ [sandbox] Ejecutando en contenedor: …`.
93
+ - Si un comando falla dentro del contenedor se muestra su salida y código como
94
+ siempre.
95
+ - Si Docker no está disponible y usaste `--sandbox` explícitamente,
96
+ SnapContext falla con un mensaje claro; sin el flag nunca cambia nada
97
+ (compatibilidad total).
98
+
43
99
  ## 🧰 Instalación y onboarding sin fricción (v3.1.x)
44
100
 
45
101
  Objetivo: instalar SnapContext y empezar a usarlo en menos de 5 minutos.
@@ -702,6 +702,12 @@ class AgenteTester:
702
702
  import snapcontext as sc
703
703
 
704
704
  sc.depurar(f"[AgenteTester] Ejecutando pruebas: {' '.join(comando)}")
705
+ # v4.3.0: con --sandbox activo las pruebas corren en el contenedor.
706
+ if sc.sandbox_activo():
707
+ codigo, stdout, stderr = sc._ejecutar_pruebas_argv(
708
+ comando, directorio)
709
+ return subprocess.CompletedProcess(
710
+ comando, codigo, stdout=stdout, stderr=stderr)
705
711
  return subprocess.run(
706
712
  comando, cwd=directorio, capture_output=True, text=True
707
713
  )
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
  # Nombre verificado disponible en PyPI (18/08/2026): pypi.org/pypi/snapcontext → 404.
7
7
  # Alternativas si algún día estuviera ocupado: "snapcontext-cli" o "snapcontext-tool".
8
8
  name = "snapcontext"
9
- version = "4.2.0"
9
+ version = "4.3.0"
10
10
  description = "SnapContext: Asistente de IA con contexto automático para desarrollo"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.9"
@@ -89,4 +89,4 @@ packages = ["web"]
89
89
 
90
90
  [tool.setuptools.package-data]
91
91
  # El estático de la interfaz web debe viajar dentro del paquete instalado.
92
- "web" = ["static/index.html"]
92
+ "web" = ["static/index.html"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snapcontext
3
- Version: 4.2.0
3
+ Version: 4.3.0
4
4
  Summary: SnapContext: Asistente de IA con contexto automático para desarrollo
5
5
  Author-email: SnapContext Contributors <tu@email.com>
6
6
  License-Expression: MIT
@@ -45,7 +45,7 @@ Dynamic: license-file
45
45
 
46
46
  # SnapContext
47
47
 
48
- ![v4.2.0](https://img.shields.io/badge/version-4.2.0-blue.svg)
48
+ ![v4.3.0](https://img.shields.io/badge/version-4.3.0-blue.svg)
49
49
  [![PyPI](https://badge.fury.io/py/snapcontext.svg)](https://pypi.org/project/snapcontext/)
50
50
  [![CI](https://img.shields.io/github/actions/workflow/status/NicolasBruna24/snapcontext/ci.yml?branch=main&label=tests)](https://github.com/NicolasBruña24/snapcontext/actions)
51
51
  ![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)
@@ -85,6 +85,62 @@ pip install aider-chat # ediciones de código
85
85
  snapcontext --init # asistente inicial + API key
86
86
  ```
87
87
 
88
+ ## 🐳 Sandboxing con Docker (v4.3.0)
89
+
90
+ Ejecuta los comandos de SnapContext (bucle de pruebas, `execute_command`,
91
+ pasos `ejecutar` del planificador, plugins…) dentro de un **contenedor Docker
92
+ aislado**. Ideal cuando no se confía en el código o necesitas dependencias
93
+ específicas.
94
+
95
+ ### Uso básico
96
+
97
+ ```bash
98
+ # Todo el trabajo de comandos dentro del contenedor
99
+ snapcontext "arreglar el checkout" --test-loop --sandbox
100
+
101
+ # Con imagen personalizada y comando de preparación
102
+ snapcontext "añadir tests" --test-loop --sandbox \
103
+ --sandbox-imagen python:3.11-slim \
104
+ --sandbox-comando "pip install pytest"
105
+
106
+ # En el planificador: pasos "ejecutar" y MCP con comandos usan el sandbox;
107
+ # "editar" y "consultar" siguen en el host.
108
+ snapcontext "migrar a pytest" --plan --sandbox
109
+
110
+ # Imagen por defecto sin flag:
111
+ export SNAPCONTEXT_SANDBOX_IMAGE=ubuntu:22.04 # PowerShell: $env:SNAPCONTEXT_SANDBOX_IMAGE="..."
112
+ ```
113
+
114
+ ### Cómo funciona
115
+
116
+ - **Detección**: `_docker_disponible()` comprueba `docker --version` (binario)
117
+ y `docker info` (daemon activo).
118
+ - **Ejecución**: cada comando se lanza como
119
+ `docker run --rm -v "<proyecto>:/workspace" -w /workspace -e GEMINI_API_KEY … <imagen> sh -c "<comando>"`.
120
+ - **Montaje**: el directorio del proyecto se monta en `/workspace`.
121
+ - **Variables de entorno**: las claves (`*_API_KEY`, `OLLAMA_URL`, …) se pasan
122
+ al contenedor automáticamente.
123
+ - **Preparación**: `--sandbox-comando` antepone un comando de setup
124
+ (`apt update && apt install -y …`) antes del comando principal.
125
+
126
+ ### Qué corre dentro y fuera del sandbox
127
+
128
+ | Dentro del contenedor | Fuera (host) |
129
+ | --- | --- |
130
+ | Bucle de pruebas (`--test-loop`) | Herramientas de solo lectura (`grep`, `read_file`, `list_files`, `ast`) |
131
+ | MCP `execute_command` (+ background) | Pasos `editar` y `consultar` del planificador |
132
+ | Pasos `ejecutar` del planificador | Selección de contexto, chat con la IA |
133
+ | Plugins y herramientas de usuario | |
134
+
135
+ ### Errores y logs
136
+
137
+ - Cada comando sandboxeado registra `ℹ [sandbox] Ejecutando en contenedor: …`.
138
+ - Si un comando falla dentro del contenedor se muestra su salida y código como
139
+ siempre.
140
+ - Si Docker no está disponible y usaste `--sandbox` explícitamente,
141
+ SnapContext falla con un mensaje claro; sin el flag nunca cambia nada
142
+ (compatibilidad total).
143
+
88
144
  ## 🧰 Instalación y onboarding sin fricción (v3.1.x)
89
145
 
90
146
  Objetivo: instalar SnapContext y empezar a usarlo en menos de 5 minutos.
@@ -35,6 +35,7 @@ tests/test_permisos_013.py
35
35
  tests/test_plan_012.py
36
36
  tests/test_plan_mcp_230.py
37
37
  tests/test_plugins_400.py
38
+ tests/test_sandbox_430.py
38
39
  tests/test_seguridad_rendimiento_420.py
39
40
  tests/test_v010.py
40
41
  tests/test_validacion_130.py
@@ -39,6 +39,7 @@ Open-source y pensado para ser fácil de extender (ver ejecutar_bucle_test).
39
39
 
40
40
  import argparse
41
41
  import ast
42
+ import contextlib
42
43
  import difflib
43
44
  import fnmatch
44
45
  import json
@@ -111,7 +112,7 @@ except ImportError: # pragma: no cover
111
112
  import concurrent.futures
112
113
  from concurrent.futures import ThreadPoolExecutor
113
114
 
114
- VERSION = "4.2.0"
115
+ VERSION = "4.3.0"
115
116
 
116
117
  # v3.1.0 — Claves de API reconocidas para el modo por defecto (offline).
117
118
  CLAVES_API_CONOCIDAS = (
@@ -2532,7 +2533,9 @@ def ejecutar_bucle_test(consulta: str, archivos: List[str], directorio: str,
2532
2533
  """
2533
2534
  if not comando_test:
2534
2535
  raise RuntimeError("El comando de pruebas está vacío (--comando-test).")
2535
- if shutil.which(comando_test[0]) is None:
2536
+ # v4.3.0: en sandbox el binario vive dentro del contenedor; la comprobación
2537
+ # de PATH del host no aplica.
2538
+ if not _SANDBOX_ACTIVO and shutil.which(comando_test[0]) is None:
2536
2539
  raise RuntimeError(
2537
2540
  f"No se encontró el comando de pruebas '{comando_test[0]}'. "
2538
2541
  "Ajusta --comando-test."
@@ -2556,9 +2559,16 @@ def ejecutar_bucle_test(consulta: str, archivos: List[str], directorio: str,
2556
2559
 
2557
2560
  solicitud = " ".join(comando_test)
2558
2561
  info(f"Ejecutando pruebas: {solicitud}")
2559
- resultado = subprocess.run(
2560
- comando_test, cwd=directorio, capture_output=True, text=True
2561
- )
2562
+ # v4.3.0: con --sandbox las pruebas corren dentro del contenedor.
2563
+ if _SANDBOX_ACTIVO:
2564
+ codigo, stdout, stderr = _ejecutar_pruebas_argv(
2565
+ comando_test, directorio)
2566
+ resultado = subprocess.CompletedProcess(
2567
+ comando_test, codigo, stdout=stdout, stderr=stderr)
2568
+ else:
2569
+ resultado = subprocess.run(
2570
+ comando_test, cwd=directorio, capture_output=True, text=True
2571
+ )
2562
2572
  if resultado.returncode == 0:
2563
2573
  exito(f"¡Pruebas superadas en la iteración {iteracion}!")
2564
2574
  return True
@@ -3213,6 +3223,11 @@ def _ejecutar_comando(comando: str, directorio: str = ".",
3213
3223
  raiz = Path(directorio).expanduser()
3214
3224
  if not raiz.is_dir():
3215
3225
  return (-1, "", f"El directorio no existe: {raiz}")
3226
+ # v4.3.0: con --sandbox activo el comando corre dentro del contenedor.
3227
+ if _SANDBOX_ACTIVO:
3228
+ info(f"[sandbox] Ejecutando en contenedor: {comando}")
3229
+ comando = _envolver_sandbox(comando, str(raiz))
3230
+ raiz = Path.cwd() # docker se lanza desde el host; el mount ya es absoluto
3216
3231
  try:
3217
3232
  proc = subprocess.run(
3218
3233
  comando,
@@ -3231,6 +3246,155 @@ def _ejecutar_comando(comando: str, directorio: str = ".",
3231
3246
  except OSError as exc:
3232
3247
  return (-1, "", f"Error ejecutando '{comando}': {exc}")
3233
3248
 
3249
+
3250
+ # ---------------------------------------------------------------------------
3251
+ # 🐳 Sandboxing con Docker (v4.3.0)
3252
+ # ---------------------------------------------------------------------------
3253
+ # Imagen por defecto del sandbox (ligera, con Python y herramientas comunes).
3254
+ # Puede sobrescribirse con --sandbox-imagen o SNAPCONTEXT_SANDBOX_IMAGE.
3255
+ SANDBOX_IMAGEN_DEFECTO = "python:3.11-slim"
3256
+ SANDBOX_DIR_TRABAJO = "/workspace"
3257
+ # Nombres/patrones de variables de entorno que se pasan al contenedor.
3258
+ _SANDBOX_VARS_CLAVE = ("GEMINI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY",
3259
+ "DEEPSEEK_API_KEY", "GROQ_API_KEY", "OLLAMA_URL",
3260
+ "SNAPCONTEXT_SANDBOX_IMAGE")
3261
+
3262
+ # Estado global del sandbox (lo activa _activar_sandbox desde main()).
3263
+ _SANDBOX_ACTIVO: bool = False
3264
+ _SANDBOX_IMAGEN: str = SANDBOX_IMAGEN_DEFECTO
3265
+ _SANDBOX_COMANDO_PREP: Optional[str] = None
3266
+
3267
+
3268
+ def _docker_disponible() -> bool:
3269
+ """Comprueba que Docker está instalado Y que el daemon está en ejecución.
3270
+
3271
+ - ``docker --version`` existe en el PATH.
3272
+ - ``docker info`` responde sin error (daemon activo).
3273
+
3274
+ Nunca lanza excepciones; devuelve ``False`` ante cualquier problema.
3275
+ """
3276
+ if shutil.which("docker") is None:
3277
+ return False
3278
+ try:
3279
+ proc = subprocess.run(
3280
+ ["docker", "info"], capture_output=True, text=True,
3281
+ timeout=30, creationflags=(
3282
+ subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0),
3283
+ )
3284
+ return proc.returncode == 0
3285
+ except (OSError, subprocess.TimeoutExpired):
3286
+ return False
3287
+
3288
+
3289
+ def _sandbox_imagen_resuelta(explicita: Optional[str] = None) -> str:
3290
+ """Resuelve la imagen del sandbox: flag > variable de entorno > defecto."""
3291
+ return (explicita or os.environ.get("SNAPCONTEXT_SANDBOX_IMAGE", "").strip()
3292
+ or SANDBOX_IMAGEN_DEFECTO)
3293
+
3294
+
3295
+ def _activar_sandbox(imagen: Optional[str] = None,
3296
+ comando_prep: Optional[str] = None,
3297
+ estricto: bool = True) -> bool:
3298
+ """Activa el sandbox global si Docker está disponible.
3299
+
3300
+ Args:
3301
+ imagen: imagen Docker (--sandbox-imagen o env por defecto).
3302
+ comando_prep: comando de preparación previo (--sandbox-comando).
3303
+ estricto: si es ``True`` (``--sandbox`` explícito) y Docker no está
3304
+ disponible lanza ``RuntimeError``; si es ``False`` solo avisa y
3305
+ continúa sin sandbox.
3306
+
3307
+ Devuelve ``True`` si el sandbox quedó activo.
3308
+ """
3309
+ global _SANDBOX_ACTIVO, _SANDBOX_IMAGEN, _SANDBOX_COMANDO_PREP
3310
+ if not _docker_disponible():
3311
+ mensaje = (
3312
+ "--sandbox solicitado pero Docker no está disponible "
3313
+ "(¿instalado? ¿el daemon está en ejecución?). "
3314
+ "Instala Docker Desktop o inicia el servicio 'docker'."
3315
+ )
3316
+ _SANDBOX_ACTIVO = False
3317
+ if estricto:
3318
+ raise RuntimeError(mensaje)
3319
+ aviso(mensaje + "\n → Se continúa SIN sandbox.")
3320
+ return False
3321
+ _SANDBOX_ACTIVO = True
3322
+ _SANDBOX_IMAGEN = _sandbox_imagen_resuelta(imagen)
3323
+ _SANDBOX_COMANDO_PREP = (comando_prep or "").strip() or None
3324
+ exito(f"🐳 Sandbox activo (imagen: {_SANDBOX_IMAGEN}, "
3325
+ f"directorio montado en {SANDBOX_DIR_TRABAJO}).")
3326
+ return True
3327
+
3328
+
3329
+ def _desactivar_sandbox() -> None:
3330
+ """Desactiva el sandbox global (vuelve al comportamiento normal)."""
3331
+ global _SANDBOX_ACTIVO, _SANDBOX_COMANDO_PREP
3332
+ _SANDBOX_ACTIVO = False
3333
+ _SANDBOX_COMANDO_PREP = None
3334
+
3335
+
3336
+ def sandbox_activo() -> bool:
3337
+ """Indica si el sandbox Docker está activo."""
3338
+ return _SANDBOX_ACTIVO
3339
+
3340
+
3341
+ def _envolver_sandbox(comando: str, directorio: str = ".") -> str:
3342
+ """Envuelve ``comando`` en un ``docker run`` dentro del sandbox.
3343
+
3344
+ Genera algo como::
3345
+
3346
+ docker run --rm -v "<dir>:/workspace" -w /workspace \
3347
+ -e GEMINI_API_KEY ... <imagen> sh -c "<comando>"
3348
+
3349
+ Si hay comando de preparación (--sandbox-comando), se antepone con
3350
+ ``&&``. Sin sandbox activo devuelve ``comando`` tal cual.
3351
+ """
3352
+ if not _SANDBOX_ACTIVO:
3353
+ return comando
3354
+ raiz = Path(directorio).expanduser().resolve()
3355
+ partes = ["docker", "run", "--rm",
3356
+ "-v", f"{raiz}:{SANDBOX_DIR_TRABAJO}",
3357
+ "-w", SANDBOX_DIR_TRABAJO]
3358
+ # Pasar las variables de entorno relevantes del host al contenedor.
3359
+ vistas = set()
3360
+ for nombre in list(os.environ):
3361
+ incluir = nombre in _SANDBOX_VARS_CLAVE or nombre.endswith("_API_KEY")
3362
+ if incluir and nombre not in vistas:
3363
+ vistas.add(nombre)
3364
+ partes.extend(["-e", nombre])
3365
+ partes.append(_SANDBOX_IMAGEN)
3366
+ if _SANDBOX_COMANDO_PREP:
3367
+ comando = f"{_SANDBOX_COMANDO_PREP} && ({comando})"
3368
+ partes.extend(["sh", "-c", comando])
3369
+ return shlex.join(partes)
3370
+
3371
+
3372
+ def _ejecutar_pruebas_argv(comando: List[str], directorio: str) -> tuple:
3373
+ """Ejecuta un comando de pruebas (lista argv) respetando el sandbox.
3374
+
3375
+ Devuelve ``(codigo_retorno, stdout, stderr)`` como :func:`_ejecutar_comando`.
3376
+ """
3377
+ if not comando:
3378
+ return (-1, "", "El comando de pruebas está vacío.")
3379
+ return _ejecutar_comando(" ".join(comando), directorio, timeout=1800)
3380
+
3381
+
3382
+ @contextlib.contextmanager
3383
+ def _sandbox_pausado():
3384
+ """Desactiva temporalmente el sandbox (herramientas de solo lectura).
3385
+
3386
+ Las herramientas MCP que no modifican el sistema (grep, git_status,
3387
+ git_diff...) se ejecutan fuera del contenedor para mayor velocidad.
3388
+ """
3389
+ global _SANDBOX_ACTIVO
3390
+ previo = _SANDBOX_ACTIVO
3391
+ _SANDBOX_ACTIVO = False
3392
+ try:
3393
+ yield
3394
+ finally:
3395
+ _SANDBOX_ACTIVO = previo
3396
+
3397
+
3234
3398
  # --- Procesos en segundo plano para execute_command (v2.3.0) -----------------
3235
3399
  _PROCESOS_FONDO: dict = {} # pid → estado (para ejecución en background)
3236
3400
 
@@ -3245,6 +3409,11 @@ def _lanzar_proceso_fondo(comando: str, directorio: str = ".",
3245
3409
  raiz = Path(directorio).expanduser()
3246
3410
  if not raiz.is_dir():
3247
3411
  return {"ok": False, "error": f"El directorio no existe: {raiz}"}
3412
+ # v4.3.0: los procesos en segundo plano también respetan --sandbox.
3413
+ if _SANDBOX_ACTIVO:
3414
+ info(f"[sandbox] Ejecutando en contenedor (background): {comando}")
3415
+ comando = _envolver_sandbox(comando, str(raiz))
3416
+ raiz = Path.cwd()
3248
3417
  try:
3249
3418
  if capture_output:
3250
3419
  proc = subprocess.Popen(
@@ -6292,7 +6461,11 @@ def _tool_grep(patron: str, directorio: str = ".",
6292
6461
  comando = f'grep -rn -i -m 5 "{patron}" .'
6293
6462
  else:
6294
6463
  comando = f'findstr /s /n /i "{patron}" *.py *.dart *.js *.ts *.go *.rs'
6295
- codigo, stdout, stderr = _ejecutar_comando(comando, directorio, timeout=60)
6464
+ codigo, stdout, stderr = (None, None, None)
6465
+ # v4.3.0: grep es de solo lectura → corre fuera del sandbox.
6466
+ with _sandbox_pausado():
6467
+ codigo, stdout, stderr = _ejecutar_comando(comando, directorio,
6468
+ timeout=60)
6296
6469
  lineas = [l for l in (stdout or "").splitlines() if l.strip()]
6297
6470
  return {"ok": codigo == 0 or bool(lineas),
6298
6471
  "buscador": herramienta, "total": len(lineas),
@@ -6381,8 +6554,9 @@ def _tool_git_status(directorio: str = ".") -> dict:
6381
6554
  return {"ok": False, "error": f"'{directorio}' no es un repositorio git"}
6382
6555
  _, rama, _ = _ejecutar_comando("git rev-parse --abbrev-ref HEAD",
6383
6556
  directorio, timeout=15)
6384
- codigo, salida, _ = _ejecutar_comando("git status --porcelain",
6385
- directorio, timeout=30)
6557
+ with _sandbox_pausado(): # v4.3.0: solo lectura → fuera del sandbox
6558
+ codigo, salida, _ = _ejecutar_comando("git status --porcelain",
6559
+ directorio, timeout=30)
6386
6560
  modificados = [l.strip() for l in (salida or "").splitlines() if l.strip()]
6387
6561
  return {"ok": codigo == 0, "rama": (rama or "").strip(),
6388
6562
  "cambios": modificados, "total_cambios": len(modificados)}
@@ -6396,7 +6570,9 @@ def _tool_git_diff(directorio: str = ".", archivo: Optional[str] = None,
6396
6570
  comando = "git diff HEAD"
6397
6571
  if archivo:
6398
6572
  comando += f' -- "{archivo}"'
6399
- codigo, salida, stderr = _ejecutar_comando(comando, directorio, timeout=60)
6573
+ with _sandbox_pausado(): # v4.3.0: solo lectura → fuera del sandbox
6574
+ codigo, salida, stderr = _ejecutar_comando(comando, directorio,
6575
+ timeout=60)
6400
6576
  lineas = (salida or "").splitlines()
6401
6577
  return {"ok": codigo == 0, "archivo": archivo,
6402
6578
  "total_lineas": len(lineas),
@@ -7580,7 +7756,8 @@ CATEGORIAS_AYUDA = (
7580
7756
  ("--git-commit", "--no-git-commit", "--branch")),
7581
7757
  ("Planificador y bucles",
7582
7758
  ("--paralelo", "--max-intentos", "--test-loop", "--server-loop",
7583
- "--manual-loop", "--comando-test", "--dispositivo", "--url-defecto",
7759
+ "--manual-loop", "--comando-test", "--sandbox", "--sandbox-imagen",
7760
+ "--sandbox-comando", "--dispositivo", "--url-defecto",
7584
7761
  "--max-iteraciones")),
7585
7762
  ("Otros",
7586
7763
  ("consulta", "--directorio", "--aider-opciones", "--depurar",
@@ -8517,6 +8694,23 @@ def crear_parser() -> argparse.ArgumentParser:
8517
8694
  "--comando-test", default=COMANDO_TEST_DEFECTO,
8518
8695
  help='Comando de pruebas del bucle (por defecto: "flutter test").',
8519
8696
  )
8697
+ # v4.3.0: sandbox opcional con Docker.
8698
+ parser.add_argument(
8699
+ "--sandbox", action="store_true",
8700
+ help="(v4.3.0) Ejecuta comandos y pruebas dentro de un contenedor "
8701
+ "Docker aislado (monta el proyecto en /workspace). Si Docker no "
8702
+ "está disponible, falla con error claro.",
8703
+ )
8704
+ parser.add_argument(
8705
+ "--sandbox-imagen", dest="sandbox_imagen", default=None,
8706
+ help="Imagen Docker del sandbox (por defecto: python:3.11-slim o "
8707
+ "SNAPCONTEXT_SANDBOX_IMAGE).",
8708
+ )
8709
+ parser.add_argument(
8710
+ "--sandbox-comando", dest="sandbox_comando", default=None,
8711
+ help='Comando de preparación dentro del contenedor antes del comando '
8712
+ 'principal (ej.: "apt update && apt install -y make").',
8713
+ )
8520
8714
  parser.add_argument(
8521
8715
  "--max-iteraciones", type=int, default=MAX_ITERACIONES_TEST_DEFECTO,
8522
8716
  help="Máximo de iteraciones del bucle de pruebas.",
@@ -9497,6 +9691,14 @@ def main(argv: Optional[List[str]] = None) -> int:
9497
9691
  # para todos los modos (chat, planificador, ...).
9498
9692
  global CONFIRMAR_ACCIONES
9499
9693
  CONFIRMAR_ACCIONES = getattr(args, "confirmar", True)
9694
+ # v4.3.0: activar el sandbox Docker si se pidió --sandbox. Es lo
9695
+ # primero para que TODO comando posterior ya nazca en el contenedor
9696
+ # (bucle de pruebas, MCP execute_command, pasos "ejecutar" del plan).
9697
+ if getattr(args, "sandbox", False):
9698
+ _activar_sandbox(
9699
+ imagen=getattr(args, "sandbox_imagen", None),
9700
+ comando_prep=getattr(args, "sandbox_comando", None),
9701
+ estricto=True)
9500
9702
  # v3.1.1: --bienvenida explícito ejecuta el tutorial y marca el
9501
9703
  # primer uso como completado (por si quiere volver a verlo).
9502
9704
  if getattr(args, "bienvenida", False):
@@ -183,7 +183,7 @@ class TestFlagsAutoCli(unittest.TestCase):
183
183
  self.assertTrue(args.plan)
184
184
 
185
185
  def test_version_es_1_2_0(self):
186
- self.assertEqual(sc.VERSION, "4.2.0")
186
+ self.assertEqual(sc.VERSION, "4.3.0")
187
187
 
188
188
 
189
189
  if __name__ == "__main__":
@@ -35,7 +35,7 @@ class TestFlagsCli310(unittest.TestCase):
35
35
  ).bienvenida)
36
36
 
37
37
  def test_version_es_3_1_0(self):
38
- self.assertEqual(sc.VERSION, "4.2.0")
38
+ self.assertEqual(sc.VERSION, "4.3.0")
39
39
 
40
40
 
41
41
  class TestApiKeyDetection(unittest.TestCase):
@@ -98,7 +98,7 @@ class TestFlagsEdicion(unittest.TestCase):
98
98
  self.assertEqual(parser.parse_args(["--modo-edicion", "sobrescribir", "c"]).modo_edicion, "sobrescribir")
99
99
 
100
100
  def test_version_es_2_1_0(self):
101
- self.assertEqual(sc.VERSION, "4.2.0")
101
+ self.assertEqual(sc.VERSION, "4.3.0")
102
102
 
103
103
 
104
104
  if __name__ == "__main__":
@@ -174,7 +174,7 @@ class TestFlagsEdicionAST(unittest.TestCase):
174
174
  self.assertEqual(args2.modo_edicion, "auto")
175
175
 
176
176
  def test_version_es_2_2_0(self):
177
- self.assertEqual(sc.VERSION, "4.2.0")
177
+ self.assertEqual(sc.VERSION, "4.3.0")
178
178
 
179
179
 
180
180
  if __name__ == "__main__":
@@ -86,7 +86,7 @@ class TestFlagsEditor(unittest.TestCase):
86
86
  self.assertIsInstance(orch.agente_editor_propio, AgenteEditorPropio)
87
87
 
88
88
  def test_version_es_2_0_0(self):
89
- self.assertEqual(sc.VERSION, "4.2.0")
89
+ self.assertEqual(sc.VERSION, "4.3.0")
90
90
 
91
91
 
92
92
  if __name__ == "__main__":
@@ -14,7 +14,7 @@ import snapcontext as sc # noqa: E402
14
14
 
15
15
  class TestVersion(unittest.TestCase):
16
16
  def test_version_150_coherente(self):
17
- self.assertEqual(sc.VERSION, "4.2.0")
17
+ self.assertEqual(sc.VERSION, "4.3.0")
18
18
 
19
19
 
20
20
  class TestEstaticoWeb(unittest.TestCase):
@@ -17,7 +17,7 @@ import snapcontext as sc # noqa: E402
17
17
 
18
18
  class TestVersion(unittest.TestCase):
19
19
  def test_version_170_coherente(self):
20
- self.assertEqual(sc.VERSION, "4.2.0")
20
+ self.assertEqual(sc.VERSION, "4.3.0")
21
21
 
22
22
 
23
23
  class TestEstructuraJetBrains(unittest.TestCase):
@@ -217,7 +217,7 @@ class TestFormatoYAutoContexto(BaseMCP):
217
217
 
218
218
  class TestVersionMCPCli(BaseMCP):
219
219
  def test_version_es_1_2_0(self):
220
- self.assertEqual(sc.VERSION, "4.2.0")
220
+ self.assertEqual(sc.VERSION, "4.3.0")
221
221
 
222
222
 
223
223
  if __name__ == "__main__":
@@ -248,7 +248,7 @@ class TestChatComandosGrafo(unittest.TestCase):
248
248
 
249
249
  class TestVersion(unittest.TestCase):
250
250
  def test_version_140_coherente(self):
251
- self.assertEqual(sc.VERSION, "4.2.0")
251
+ self.assertEqual(sc.VERSION, "4.3.0")
252
252
 
253
253
 
254
254
  if __name__ == "__main__":
@@ -179,7 +179,7 @@ class TestFlagsMemoriaCli(BaseMemoria):
179
179
  ["--init-claude"]).init_claude)
180
180
 
181
181
  def test_version_es_1_2_0(self):
182
- self.assertEqual(sc.VERSION, "4.2.0")
182
+ self.assertEqual(sc.VERSION, "4.3.0")
183
183
 
184
184
 
185
185
  if __name__ == "__main__":
@@ -312,7 +312,7 @@ class TestFlagsCLI(unittest.TestCase):
312
312
  self.assertEqual(args.daemon_intervalo, 24)
313
313
 
314
314
  def test_version_300(self):
315
- self.assertEqual(sc.VERSION, "4.2.0")
315
+ self.assertEqual(sc.VERSION, "4.3.0")
316
316
 
317
317
 
318
318
  if __name__ == "__main__":
@@ -182,7 +182,7 @@ class TestFlagsConfirmarCLI(unittest.TestCase):
182
182
  self.assertFalse(self._parse(["--chat", "--no-confirmar"]).confirmar)
183
183
 
184
184
  def test_version_es_1_2_0(self):
185
- self.assertEqual(sc.VERSION, "4.2.0")
185
+ self.assertEqual(sc.VERSION, "4.3.0")
186
186
 
187
187
 
188
188
  if __name__ == "__main__":
@@ -269,7 +269,7 @@ class TestFlagsPlanCLI(unittest.TestCase):
269
269
  self.assertIsNone(self._parse(["--plan", "x"]).branch)
270
270
 
271
271
  def test_version_es_1_2_0(self):
272
- self.assertEqual(sc.VERSION, "4.2.0")
272
+ self.assertEqual(sc.VERSION, "4.3.0")
273
273
 
274
274
 
275
275
  if __name__ == "__main__":
@@ -210,7 +210,7 @@ class TestEjecutarToolExecuteCommand(unittest.TestCase):
210
210
 
211
211
  class TestVersionYFlags(unittest.TestCase):
212
212
  def test_version_230(self):
213
- self.assertEqual(sc.VERSION, "4.2.0")
213
+ self.assertEqual(sc.VERSION, "4.3.0")
214
214
 
215
215
  def test_flag_paralelo(self):
216
216
  args = sc.crear_parser().parse_args(["--plan", "t", "--paralelo", "3"])
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Tests de la v4.3.0: sandbox opcional con Docker."""
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from pathlib import Path
11
+ from unittest import mock
12
+
13
+ RAIZ = Path(__file__).resolve().parent.parent
14
+ sys.path.insert(0, str(RAIZ))
15
+
16
+ import snapcontext as sc # noqa: E402
17
+
18
+
19
+ def _args_base(**extra) -> argparse.Namespace:
20
+ base = {"consulta": None, "depurar": False, "auto": False,
21
+ "confirmar": True, "modelo": None, "test_loop": False,
22
+ "aider_opciones": "", "comando_test": "pytest"}
23
+ base.update(extra or {})
24
+ return argparse.Namespace(**base)
25
+
26
+
27
+ class TestDeteccionDocker(unittest.TestCase):
28
+ """Detección de Docker: presencia, ausencia y daemon parado."""
29
+
30
+ def test_sin_binario_en_path_no_disponible(self):
31
+ with mock.patch.object(sc.shutil, "which", return_value=None):
32
+ self.assertFalse(sc._docker_disponible())
33
+
34
+ def test_docker_info_ok_disponible(self):
35
+ proc = mock.Mock(returncode=0)
36
+ with mock.patch.object(sc.shutil, "which", return_value="docker"), \
37
+ mock.patch.object(sc.subprocess, "run", return_value=proc):
38
+ self.assertTrue(sc._docker_disponible())
39
+
40
+ def test_docker_daemon_parado_no_disponible(self):
41
+ proc = mock.Mock(returncode=1)
42
+ with mock.patch.object(sc.shutil, "which", return_value="docker"), \
43
+ mock.patch.object(sc.subprocess, "run", return_value=proc):
44
+ self.assertFalse(sc._docker_disponible())
45
+
46
+ def test_error_al_llamar_docker_tolerado(self):
47
+ with mock.patch.object(sc.shutil, "which", return_value="docker"), \
48
+ mock.patch.object(sc.subprocess, "run",
49
+ side_effect=OSError("boom")):
50
+ self.assertFalse(sc._docker_disponible())
51
+
52
+
53
+ class TestActivacionSandbox(unittest.TestCase):
54
+ def setUp(self):
55
+ sc._desactivar_sandbox()
56
+
57
+ def tearDown(self):
58
+ sc._desactivar_sandbox()
59
+
60
+ def test_activacion_estricta_falla_sin_docker(self):
61
+ with mock.patch.object(sc, "_docker_disponible", return_value=False):
62
+ with self.assertRaises(RuntimeError):
63
+ sc._activar_sandbox(estricto=True)
64
+ self.assertFalse(sc.sandbox_activo())
65
+
66
+ def test_activacion_no_estricta_avisa_y_continua(self):
67
+ with mock.patch.object(sc, "_docker_disponible", return_value=False), \
68
+ mock.patch.object(sc, "aviso") as fake_aviso:
69
+ self.assertFalse(sc._activar_sandbox(estricto=False))
70
+ fake_aviso.assert_called_once()
71
+ self.assertFalse(sc.sandbox_activo())
72
+
73
+ def test_activacion_con_docker(self):
74
+ with mock.patch.object(sc, "_docker_disponible", return_value=True):
75
+ self.assertTrue(sc._activar_sandbox(imagen="ubuntu:22.04",
76
+ comando_prep="apt update"))
77
+ self.assertTrue(sc.sandbox_activo())
78
+ self.assertEqual(sc._SANDBOX_IMAGEN, "ubuntu:22.04")
79
+ self.assertEqual(sc._SANDBOX_COMANDO_PREP, "apt update")
80
+
81
+ def test_imagen_por_variable_de_entorno(self):
82
+ env = {"SNAPCONTEXT_SANDBOX_IMAGE": "python:3.12"}
83
+ with mock.patch.dict(os.environ, env, clear=False):
84
+ self.assertEqual(sc._sandbox_imagen_resuelta(), "python:3.12")
85
+ # El flag explícito tiene prioridad sobre la variable.
86
+ self.assertEqual(sc._sandbox_imagen_resuelta("alpine"),
87
+ "alpine")
88
+
89
+ def test_flag_cli_parseado(self):
90
+ args = sc.crear_parser().parse_args(
91
+ ["--sandbox", "--sandbox-imagen", "ubuntu:22.04",
92
+ "--sandbox-comando", "apt update", "consulta"])
93
+ self.assertTrue(args.sandbox)
94
+ self.assertEqual(args.sandbox_imagen, "ubuntu:22.04")
95
+ self.assertEqual(args.sandbox_comando, "apt update")
96
+ # Sin flags → False (compatibilidad).
97
+ args2 = sc.crear_parser().parse_args(["consulta"])
98
+ self.assertFalse(args2.sandbox)
99
+
100
+
101
+ class TestEnvolverSandbox(unittest.TestCase):
102
+ def setUp(self):
103
+ sc._desactivar_sandbox()
104
+ self.dir_tmp = tempfile.mkdtemp()
105
+
106
+ def tearDown(self):
107
+ sc._desactivar_sandbox()
108
+
109
+ def test_sin_sandbox_devuelve_comando_intacto(self):
110
+ self.assertEqual(sc._envolver_sandbox("pytest", "."), "pytest")
111
+
112
+ def test_envuelve_con_mount_y_workdir(self):
113
+ sc._SANDBOX_ACTIVO = True
114
+ sc._SANDBOX_IMAGEN = "python:3.11-slim"
115
+ sc._SANDBOX_COMANDO_PREP = None
116
+ envuelto = sc._envolver_sandbox("pytest -q", self.dir_tmp)
117
+ self.assertIn("docker run --rm", envuelto)
118
+ self.assertIn("-w /workspace", envuelto)
119
+ self.assertIn("python:3.11-slim", envuelto)
120
+ self.assertIn("pytest -q", envuelto)
121
+
122
+ def test_comando_prep_antepuesto(self):
123
+ sc._SANDBOX_ACTIVO = True
124
+ sc._SANDBOX_IMAGEN = "python:3.11-slim"
125
+ sc._SANDBOX_COMANDO_PREP = "pip install pytest"
126
+ envuelto = sc._envolver_sandbox("pytest", self.dir_tmp)
127
+ self.assertIn("pip install pytest && (pytest)", envuelto)
128
+
129
+
130
+ class TestEjecucionEnSandbox(unittest.TestCase):
131
+ def setUp(self):
132
+ sc._desactivar_sandbox()
133
+
134
+ def tearDown(self):
135
+ sc._desactivar_sandbox()
136
+
137
+ def _activar(self):
138
+ sc._SANDBOX_ACTIVO = True
139
+ sc._SANDBOX_IMAGEN = "python:3.11-slim"
140
+ sc._SANDBOX_COMANDO_PREP = None
141
+
142
+ def test_ejecutar_comando_usa_docker_run(self):
143
+ self._activar()
144
+ with mock.patch.object(sc.subprocess, "run") as fake_run:
145
+ fake_run.return_value = mock.Mock(returncode=0, stdout="hola",
146
+ stderr="")
147
+ codigo, stdout, _ = sc._ejecutar_comando("echo hola", ".")
148
+ self.assertEqual(codigo, 0)
149
+ self.assertIn("hola", stdout)
150
+ comando_lanzado = fake_run.call_args[0][0]
151
+ self.assertIn("docker run --rm", comando_lanzado)
152
+ self.assertIn("echo hola", comando_lanzado)
153
+
154
+ def test_ejecutar_comando_error_se_propaga_igual(self):
155
+ self._activar()
156
+ with mock.patch.object(sc.subprocess, "run") as fake_run:
157
+ fake_run.return_value = mock.Mock(returncode=5, stdout="",
158
+ stderr="boom")
159
+ codigo, _, stderr = sc._ejecutar_comando("exit 5", ".")
160
+ self.assertEqual(codigo, 5)
161
+ self.assertIn("boom", stderr)
162
+
163
+ def test_ejecutar_pruebas_argv_en_sandbox(self):
164
+ self._activar()
165
+ with mock.patch.object(sc.subprocess, "run") as fake_run:
166
+ fake_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
167
+ codigo, _, _ = sc._ejecutar_pruebas_argv(["pytest", "-q"], ".")
168
+ self.assertEqual(codigo, 0)
169
+ self.assertIn("pytest -q", fake_run.call_args[0][0])
170
+
171
+ def test_mcp_execute_command_en_sandbox(self):
172
+ self._activar()
173
+ with mock.patch.object(sc.subprocess, "run") as fake_run:
174
+ fake_run.return_value = mock.Mock(returncode=0, stdout="ok",
175
+ stderr="")
176
+ res = sc._tool_execute_command("echo ok", ".")
177
+ self.assertTrue(res["ok"])
178
+ self.assertIn("docker run --rm", fake_run.call_args[0][0])
179
+
180
+ def test_herramientas_lectura_no_usan_docker(self):
181
+ # grep es de solo lectura: no pasa por docker (usa su propia función).
182
+ self._activar()
183
+ tmp = tempfile.mkdtemp()
184
+ (Path(tmp) / "a.py").write_text("def hola():\n pass\n",
185
+ encoding="utf-8")
186
+ with mock.patch.object(sc.subprocess, "run") as fake_run:
187
+ fake_run.return_value = mock.Mock(returncode=0, stdout="a.py:1:x",
188
+ stderr="")
189
+ res = sc._tool_grep("hola", tmp)
190
+ # Solo lectura → se ejecuta en el host, SIN envolver en docker.
191
+ lanzado = fake_run.call_args[0][0]
192
+ self.assertNotIn("docker", str(lanzado))
193
+ self.assertTrue(res["ok"])
194
+
195
+
196
+ class TestIntegracionPlanYBucle(unittest.TestCase):
197
+ def setUp(self):
198
+ sc._desactivar_sandbox()
199
+ self.dir_tmp = tempfile.mkdtemp()
200
+ # Comando real no destructivo según plataforma.
201
+ self.comando = ("cmd /c echo hola"
202
+ if os.name == "nt" else "echo hola")
203
+
204
+ def tearDown(self):
205
+ sc._desactivar_sandbox()
206
+
207
+ def _activar_mock_docker(self):
208
+ sc._SANDBOX_ACTIVO = True
209
+ sc._SANDBOX_IMAGEN = "python:3.11-slim"
210
+ sc._SANDBOX_COMANDO_PREP = None
211
+
212
+ def test_paso_plan_ejecutar_en_sandbox(self):
213
+ self._activar_mock_docker()
214
+ with mock.patch.object(sc, "_confirmar_accion", return_value=True), \
215
+ mock.patch.object(sc.subprocess, "run") as fake_run:
216
+ fake_run.return_value = mock.Mock(returncode=0, stdout="hola",
217
+ stderr="")
218
+ ok, detalle = sc._ejecutar_paso_plan(
219
+ {"accion": "ejecutar", "descripcion": "paso",
220
+ "comando": self.comando},
221
+ _args_base(), self.dir_tmp)
222
+ self.assertTrue(ok)
223
+ self.assertIn("docker run --rm", fake_run.call_args[0][0])
224
+
225
+ def test_bucle_test_saltara_check_host_en_sandbox(self):
226
+ # Con sandbox activo NO se exige el binario en el PATH del host.
227
+ self._activar_mock_docker()
228
+ with mock.patch.object(sc.shutil, "which",
229
+ return_value=None) as fake_which, \
230
+ mock.patch.object(sc, "ejecutar_aider"), \
231
+ mock.patch.object(sc, "_ejecutar_pruebas_argv",
232
+ return_value=(0, "ok", "")):
233
+ self.assertTrue(sc.ejecutar_bucle_test(
234
+ "tarea", ["a.py"], ".", "", ["binario-inexistente"], 1))
235
+ fake_which.assert_not_called()
236
+
237
+ def test_agente_tester_en_sandbox(self):
238
+ from agentes import AgenteTester
239
+ self._activar_mock_docker()
240
+ with mock.patch.object(sc.subprocess, "run") as fake_run:
241
+ fake_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
242
+ res = AgenteTester().ejecutar_pruebas(["pytest"], ".")
243
+ self.assertEqual(res.returncode, 0)
244
+ self.assertIn("docker run --rm", fake_run.call_args[0][0])
245
+
246
+
247
+ if __name__ == "__main__":
248
+ unittest.main()
249
+
@@ -168,7 +168,7 @@ class TestFlagsCLI(unittest.TestCase):
168
168
  self.assertTrue(args.vista_previa)
169
169
 
170
170
  def test_version_es_1_2_0(self):
171
- self.assertEqual(sc.VERSION, "4.2.0")
171
+ self.assertEqual(sc.VERSION, "4.3.0")
172
172
 
173
173
 
174
174
  class TestComandosAgenteChat(unittest.TestCase):
@@ -126,7 +126,7 @@ class TestIniciarProyecto(unittest.TestCase):
126
126
  class TestVersion(unittest.TestCase):
127
127
  def test_version_130_coherente(self):
128
128
  import snapcontext as sc
129
- self.assertEqual(sc.VERSION, "4.2.0")
129
+ self.assertEqual(sc.VERSION, "4.3.0")
130
130
 
131
131
 
132
132
  if __name__ == "__main__":
@@ -91,7 +91,7 @@ class TestEstructuraVsCode(unittest.TestCase):
91
91
 
92
92
  class TestVersionCli(unittest.TestCase):
93
93
  def test_version_es_1_2_0(self):
94
- self.assertEqual(sc.VERSION, "4.2.0")
94
+ self.assertEqual(sc.VERSION, "4.3.0")
95
95
 
96
96
 
97
97
  if __name__ == "__main__":
@@ -17,7 +17,7 @@ INDEX = RAIZ / "web" / "static" / "index.html"
17
17
 
18
18
  class TestVersion(unittest.TestCase):
19
19
  def test_version_160_coherente(self):
20
- self.assertEqual(sc.VERSION, "4.2.0")
20
+ self.assertEqual(sc.VERSION, "4.3.0")
21
21
 
22
22
 
23
23
  class TestFrontendV160(unittest.TestCase):
File without changes
File without changes
File without changes
File without changes
File without changes