coder-eval 0.8.2__py3-none-any.whl
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.
- coder_eval/.gitattributes +2 -0
- coder_eval/__init__.py +3 -0
- coder_eval/agent.py +302 -0
- coder_eval/agents/__init__.py +41 -0
- coder_eval/agents/_logging.py +89 -0
- coder_eval/agents/antigravity_agent.py +811 -0
- coder_eval/agents/claude_code_agent.py +1701 -0
- coder_eval/agents/codex_agent.py +2055 -0
- coder_eval/agents/noop_agent.py +100 -0
- coder_eval/agents/registry.py +163 -0
- coder_eval/agents/watchdog.py +116 -0
- coder_eval/analysis.py +88 -0
- coder_eval/cli/__init__.py +87 -0
- coder_eval/cli/aggregate_command.py +153 -0
- coder_eval/cli/console.py +7 -0
- coder_eval/cli/evaluate_command.py +164 -0
- coder_eval/cli/plan_command.py +152 -0
- coder_eval/cli/report_command.py +121 -0
- coder_eval/cli/run_command.py +686 -0
- coder_eval/cli/run_helpers.py +137 -0
- coder_eval/cli/run_task_internal_command.py +210 -0
- coder_eval/cli/utils.py +37 -0
- coder_eval/config.py +212 -0
- coder_eval/criteria/__init__.py +163 -0
- coder_eval/criteria/_classification_aggregate.py +106 -0
- coder_eval/criteria/agent_judge.py +425 -0
- coder_eval/criteria/base.py +248 -0
- coder_eval/criteria/classification_match.py +107 -0
- coder_eval/criteria/command_executed.py +181 -0
- coder_eval/criteria/commands_efficiency.py +73 -0
- coder_eval/criteria/file_check.py +119 -0
- coder_eval/criteria/file_contains.py +85 -0
- coder_eval/criteria/file_exists.py +45 -0
- coder_eval/criteria/file_matches_regex.py +86 -0
- coder_eval/criteria/json_check.py +184 -0
- coder_eval/criteria/llm_judge.py +260 -0
- coder_eval/criteria/reference_comparison.py +130 -0
- coder_eval/criteria/run_command.py +220 -0
- coder_eval/criteria/skill_triggered.py +111 -0
- coder_eval/criteria/uipath_eval.py +223 -0
- coder_eval/errors/__init__.py +26 -0
- coder_eval/errors/agent.py +26 -0
- coder_eval/errors/budget.py +28 -0
- coder_eval/errors/categories.py +205 -0
- coder_eval/errors/categorization.py +240 -0
- coder_eval/errors/executor.py +124 -0
- coder_eval/errors/judge.py +12 -0
- coder_eval/errors/retry.py +211 -0
- coder_eval/errors/timeout.py +63 -0
- coder_eval/evaluation/__init__.py +10 -0
- coder_eval/evaluation/checker.py +260 -0
- coder_eval/evaluation/judge_anthropic.py +55 -0
- coder_eval/evaluation/judge_bedrock.py +114 -0
- coder_eval/evaluation/judge_context.py +497 -0
- coder_eval/evaluation/judge_models.py +53 -0
- coder_eval/evaluation/judge_persistence.py +291 -0
- coder_eval/evaluation/judge_usage.py +50 -0
- coder_eval/evaluation/sub_agent.py +231 -0
- coder_eval/evaluation/summaries.py +48 -0
- coder_eval/evaluation/verdict_tool.py +213 -0
- coder_eval/formatting.py +191 -0
- coder_eval/isolation/__init__.py +15 -0
- coder_eval/isolation/docker_runner.py +1253 -0
- coder_eval/logging_config.py +399 -0
- coder_eval/models/__init__.py +337 -0
- coder_eval/models/agent_config.py +373 -0
- coder_eval/models/container_paths.py +26 -0
- coder_eval/models/criteria.py +942 -0
- coder_eval/models/enums.py +121 -0
- coder_eval/models/experiment.py +314 -0
- coder_eval/models/judge.py +90 -0
- coder_eval/models/judge_defaults.py +11 -0
- coder_eval/models/limits.py +89 -0
- coder_eval/models/merge_strategy.py +132 -0
- coder_eval/models/mutations.py +95 -0
- coder_eval/models/results.py +787 -0
- coder_eval/models/routing.py +160 -0
- coder_eval/models/sandbox.py +371 -0
- coder_eval/models/tasks.py +631 -0
- coder_eval/models/telemetry.py +454 -0
- coder_eval/models/templates.py +108 -0
- coder_eval/orchestration/__init__.py +13 -0
- coder_eval/orchestration/batch.py +690 -0
- coder_eval/orchestration/config.py +111 -0
- coder_eval/orchestration/config_merge.py +431 -0
- coder_eval/orchestration/evaluation.py +94 -0
- coder_eval/orchestration/experiment.py +837 -0
- coder_eval/orchestration/overrides.py +206 -0
- coder_eval/orchestration/task_loader.py +437 -0
- coder_eval/orchestrator.py +2078 -0
- coder_eval/path_utils.py +79 -0
- coder_eval/plugins.py +81 -0
- coder_eval/pricing.py +169 -0
- coder_eval/py.typed +0 -0
- coder_eval/reports.py +1066 -0
- coder_eval/reports_experiment.py +761 -0
- coder_eval/reports_html.py +1659 -0
- coder_eval/reports_stats.py +250 -0
- coder_eval/resources/__init__.py +137 -0
- coder_eval/resources/default_experiment.yaml +85 -0
- coder_eval/resources/default_ignore_patterns.yaml +60 -0
- coder_eval/resources/tags.yaml +55 -0
- coder_eval/sandbox.py +1127 -0
- coder_eval/scoring/__init__.py +11 -0
- coder_eval/scoring/ast_similarity.py +38 -0
- coder_eval/scoring/complexity.py +115 -0
- coder_eval/scoring/quality.py +154 -0
- coder_eval/scoring/signature_similarity.py +57 -0
- coder_eval/scoring/similarity.py +103 -0
- coder_eval/scoring/token_similarity.py +44 -0
- coder_eval/simulation/__init__.py +27 -0
- coder_eval/simulation/termination.py +88 -0
- coder_eval/simulation/user_simulator.py +324 -0
- coder_eval/streaming/__init__.py +44 -0
- coder_eval/streaming/callbacks.py +57 -0
- coder_eval/streaming/collector.py +193 -0
- coder_eval/streaming/events.py +198 -0
- coder_eval/streaming/renderers.py +248 -0
- coder_eval/streaming/wire.py +115 -0
- coder_eval/telemetry.py +407 -0
- coder_eval/utils.py +517 -0
- coder_eval-0.8.2.dist-info/METADATA +242 -0
- coder_eval-0.8.2.dist-info/RECORD +127 -0
- coder_eval-0.8.2.dist-info/WHEEL +4 -0
- coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
- coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
- coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
coder_eval/sandbox.py
ADDED
|
@@ -0,0 +1,1127 @@
|
|
|
1
|
+
"""Sandbox manager for isolated execution environments."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .models import (
|
|
13
|
+
RepoSource,
|
|
14
|
+
SandboxConfig,
|
|
15
|
+
StarterFilesSource,
|
|
16
|
+
TemplateDirSource,
|
|
17
|
+
)
|
|
18
|
+
from .resources import get_ignore_patterns, should_ignore_path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Module logger (inherits from coder_eval logger)
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Top-level entries excluded from Sandbox.capture_to (docker WORKDIR-alignment).
|
|
26
|
+
# The WORKDIR can be HOME or overlap framework mounts, so skip credentials and
|
|
27
|
+
# sandbox-created bulk. `.claude` is the RW lean copy of ~/.claude (carries
|
|
28
|
+
# .credentials.json). The rest are sandbox infra the standard artifacts path
|
|
29
|
+
# also drops. Matched by basename at every level (shutil.ignore_patterns).
|
|
30
|
+
_WORKSPACE_CAPTURE_IGNORE = (".claude", ".venv", ".npm-prefix", "node_modules")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _grant_read_traverse(root: Path) -> None:
|
|
34
|
+
"""Recursively apply ``chmod a+rX`` semantics under ``root``.
|
|
35
|
+
|
|
36
|
+
Preserved artifacts produced inside a root-owned ``driver:docker`` container
|
|
37
|
+
land on the host bind-mount owned by root, with mkdtemp's 0700 sandbox root.
|
|
38
|
+
The host user (a different uid) can't traverse that, so the blob upload and
|
|
39
|
+
any ``ls`` see an empty dir. This grants group+other read everywhere and
|
|
40
|
+
group+other execute only where the owner already has it (dirs, exec files),
|
|
41
|
+
matching ``a+rX``. Symlinks are skipped: their mode is ignored on Linux and
|
|
42
|
+
``chmod`` would alter the target instead.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def _add_bits(path: str) -> None:
|
|
46
|
+
try:
|
|
47
|
+
if os.path.islink(path):
|
|
48
|
+
return
|
|
49
|
+
mode = os.stat(path).st_mode
|
|
50
|
+
new = mode | 0o044 # r for group + other
|
|
51
|
+
if mode & 0o100: # owner-executable -> dir or exec file: add g+x, o+x
|
|
52
|
+
new |= 0o011
|
|
53
|
+
if new != mode:
|
|
54
|
+
os.chmod(path, new)
|
|
55
|
+
except OSError as e:
|
|
56
|
+
logger.debug("grant_read_traverse: could not chmod %s: %s", path, e)
|
|
57
|
+
|
|
58
|
+
_add_bits(str(root))
|
|
59
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
60
|
+
for name in (*dirnames, *filenames):
|
|
61
|
+
_add_bits(os.path.join(dirpath, name))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Sandbox:
|
|
65
|
+
"""Manages sandboxed execution environments for agent tasks.
|
|
66
|
+
|
|
67
|
+
Supports multiple drivers (tempdir, docker) and provides isolated
|
|
68
|
+
environments with virtual environments and resource limits.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
REMEDIATE_HOME_PLUGINS_ENV = "CODER_EVAL_REMEDIATE_HOME_PLUGINS"
|
|
72
|
+
"""Env-var flag gating destructive ``$HOME/node_modules/@uipath`` cleanup."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, config: SandboxConfig, task_id: str, task_dir: Path | None = None):
|
|
75
|
+
"""Initialize the sandbox.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
config: Sandbox configuration
|
|
79
|
+
task_id: Unique identifier for this task (used in paths)
|
|
80
|
+
task_dir: Directory containing the task YAML file (exposed as TASK_DIR env var in run_command)
|
|
81
|
+
"""
|
|
82
|
+
self.config = config
|
|
83
|
+
self.task_id = task_id
|
|
84
|
+
self.task_dir = task_dir
|
|
85
|
+
self.sandbox_dir: Path | None = None
|
|
86
|
+
self.venv_dir: Path | None = None
|
|
87
|
+
self._cleanup_on_exit = True
|
|
88
|
+
self.installed_tool_versions: dict[str, str] = {}
|
|
89
|
+
self._command_base_path: str | None = None
|
|
90
|
+
# Cached canonical `node_modules/@uipath`; pins UiPath CLI plugin discovery
|
|
91
|
+
# via PLUGIN_TOOLS_DIR to bypass CWD-walk contamination.
|
|
92
|
+
self._plugin_tools_dir: str | None = None
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def _venv_scripts_dir(self) -> Path | None:
|
|
96
|
+
"""Return the platform-appropriate scripts directory inside the venv."""
|
|
97
|
+
if self.venv_dir is None:
|
|
98
|
+
return None
|
|
99
|
+
return self.venv_dir / ("Scripts" if os.name == "nt" else "bin")
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def is_persistent(self) -> bool:
|
|
103
|
+
"""Whether this sandbox was created with a persistent target directory.
|
|
104
|
+
|
|
105
|
+
When True, the sandbox was created in a specific target directory (typically
|
|
106
|
+
the artifacts directory) and will not be deleted on cleanup().
|
|
107
|
+
"""
|
|
108
|
+
return not self._cleanup_on_exit
|
|
109
|
+
|
|
110
|
+
def setup(self, target_dir: Path | None = None) -> Path:
|
|
111
|
+
"""Set up the sandbox environment.
|
|
112
|
+
|
|
113
|
+
The sandbox is a plain temporary directory on the host -- there is no
|
|
114
|
+
container or cgroup isolation. Only command-level timeouts are enforced;
|
|
115
|
+
memory and disk limits in :class:`ResourceLimits` are not enforced.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
target_dir: If provided, use this directory instead of creating a temp dir.
|
|
119
|
+
The directory will NOT be deleted on cleanup (persistent mode).
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Path to the sandbox directory
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ValueError: If driver is not supported
|
|
126
|
+
RuntimeError: If setup fails
|
|
127
|
+
"""
|
|
128
|
+
if self.config.driver == "tempdir":
|
|
129
|
+
return self._setup_tempdir(target_dir=target_dir)
|
|
130
|
+
if self.config.driver == "docker":
|
|
131
|
+
# Docker isolation is dispatched at the orchestrator-entry boundary
|
|
132
|
+
# (coder_eval.isolation.docker_runner). Inside the container, the
|
|
133
|
+
# task is re-run with driver=tempdir, so this branch is never
|
|
134
|
+
# reached on a correctly routed call.
|
|
135
|
+
raise RuntimeError(
|
|
136
|
+
"Sandbox.setup() called with driver='docker' -- Docker tasks must be "
|
|
137
|
+
+ "dispatched via DockerRunner from the host. This indicates a routing bug."
|
|
138
|
+
)
|
|
139
|
+
raise ValueError(f"Unsupported sandbox driver: {self.config.driver}")
|
|
140
|
+
|
|
141
|
+
def _setup_tempdir(self, target_dir: Path | None = None) -> Path:
|
|
142
|
+
"""Set up a sandbox directory.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
target_dir: If provided, use this directory instead of creating a temp dir.
|
|
146
|
+
Sets _cleanup_on_exit=False so cleanup() preserves the directory.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Path to the sandbox directory
|
|
150
|
+
"""
|
|
151
|
+
if target_dir is not None:
|
|
152
|
+
# Persistent mode: work directly in the target directory
|
|
153
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
self.sandbox_dir = target_dir
|
|
155
|
+
self._cleanup_on_exit = False
|
|
156
|
+
else:
|
|
157
|
+
# Default: create a temporary directory. Dataset row tasks have IDs like
|
|
158
|
+
# "parent/row" -- flatten path separators so they don't become subdirectories
|
|
159
|
+
# under /tmp (mkdtemp does not auto-create parent dirs).
|
|
160
|
+
safe_task_id = self.task_id.replace("/", "_").replace("\\", "_")
|
|
161
|
+
# On Windows root off the home dir, not the user temp tree: the agent's Git Bash
|
|
162
|
+
# mounts /tmp onto the base temp dir while Python's mkdtemp honors %TEMP% (a CI-set
|
|
163
|
+
# subdir), so a temp-rooted sandbox gets a divergent /tmp twin the grader never reads.
|
|
164
|
+
# POSIX has one namespace (dir=None keeps the system temp, unchanged for driver:docker).
|
|
165
|
+
self.sandbox_dir = Path(
|
|
166
|
+
tempfile.mkdtemp(prefix=f"coder_eval_{safe_task_id}_", dir=Path.home() if os.name == "nt" else None)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
# Setup template content (repo, directory, or inline files)
|
|
171
|
+
self._setup_template()
|
|
172
|
+
|
|
173
|
+
# Mark mock binaries executable so the agent's PATH can shadow real CLIs
|
|
174
|
+
self._prepare_mock_path_dirs()
|
|
175
|
+
|
|
176
|
+
# Set up Python virtual environment (only if python config is provided)
|
|
177
|
+
if self.config.python:
|
|
178
|
+
self._setup_virtualenv()
|
|
179
|
+
|
|
180
|
+
# Install required packages
|
|
181
|
+
if self.config.python.env_packages:
|
|
182
|
+
self._install_packages()
|
|
183
|
+
|
|
184
|
+
# Install Node.js packages
|
|
185
|
+
if self.config.node and self.config.node.env_packages:
|
|
186
|
+
self._install_node_packages()
|
|
187
|
+
|
|
188
|
+
# MST-9674: report (without remediating) parent-dir node_modules
|
|
189
|
+
# contamination that could perturb Node module resolution.
|
|
190
|
+
self._check_parent_node_modules_contamination()
|
|
191
|
+
# Opt-in destructive cleanup of $HOME/node_modules/@uipath (eval-host-only).
|
|
192
|
+
self._maybe_remediate_home_plugins_pollution()
|
|
193
|
+
# Cache canonical @uipath dir for PLUGIN_TOOLS_DIR pin; no-op if `uip` absent.
|
|
194
|
+
self._refresh_plugin_tools_dir()
|
|
195
|
+
except Exception:
|
|
196
|
+
# Clean up on failure -- but ONLY a temp dir we created ourselves.
|
|
197
|
+
# For a caller-supplied target_dir (DIRECT_WRITE persistent mode) we
|
|
198
|
+
# must not rmtree it: it may be a pre-existing artifacts dir, and the
|
|
199
|
+
# mode's contract is to never clear it. A self-created tempdir always
|
|
200
|
+
# has _cleanup_on_exit=True at this point; target_dir flips it False.
|
|
201
|
+
if self._cleanup_on_exit:
|
|
202
|
+
shutil.rmtree(self.sandbox_dir, ignore_errors=True)
|
|
203
|
+
self.sandbox_dir = None
|
|
204
|
+
raise
|
|
205
|
+
|
|
206
|
+
return self.sandbox_dir
|
|
207
|
+
|
|
208
|
+
def _apply_repo_source(self, source: RepoSource) -> None:
|
|
209
|
+
"""Clone a git repository into the sandbox.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
source: Repository source configuration
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
RuntimeError: If git clone or checkout fails
|
|
216
|
+
|
|
217
|
+
Note:
|
|
218
|
+
Repos are cloned into sandbox_dir/repo/ subdirectory.
|
|
219
|
+
RepoSource should always be first (git clone requires empty target).
|
|
220
|
+
"""
|
|
221
|
+
assert self.sandbox_dir is not None, "Sandbox directory not initialized"
|
|
222
|
+
|
|
223
|
+
repo_dir = self.sandbox_dir / "repo"
|
|
224
|
+
cmd = ["git", "clone", source.url, str(repo_dir)]
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
subprocess.run(cmd, check=True, capture_output=True, text=True, encoding="utf-8", timeout=60)
|
|
228
|
+
|
|
229
|
+
# Checkout specific commit if specified
|
|
230
|
+
if source.commit:
|
|
231
|
+
subprocess.run(
|
|
232
|
+
["git", "checkout", source.commit],
|
|
233
|
+
cwd=repo_dir,
|
|
234
|
+
check=True,
|
|
235
|
+
capture_output=True,
|
|
236
|
+
text=True,
|
|
237
|
+
encoding="utf-8",
|
|
238
|
+
timeout=30,
|
|
239
|
+
)
|
|
240
|
+
except subprocess.CalledProcessError as e:
|
|
241
|
+
raise RuntimeError(f"Failed to clone repository: {e.stderr}") from e
|
|
242
|
+
|
|
243
|
+
def _resolve_within_sandbox(self, rel: str, *, field: str) -> Path:
|
|
244
|
+
"""Join ``rel`` with the sandbox root, resolve, and reject if it escapes.
|
|
245
|
+
|
|
246
|
+
Used by every code path that consumes a sandbox-relative path supplied by
|
|
247
|
+
a task author (``template_dir.mount_point``, ``starter_files`` paths,
|
|
248
|
+
``mock_path_dirs`` entries). The result is allowed to equal the sandbox
|
|
249
|
+
root (e.g. an empty ``mount_point``); anything that resolves outside
|
|
250
|
+
raises ``RuntimeError`` with the originating ``field`` named so the task
|
|
251
|
+
author can locate the bad entry in their YAML.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
rel: The user-supplied path. May be a relative path, an absolute
|
|
255
|
+
path (joining discards the sandbox prefix), or a string with
|
|
256
|
+
``..`` segments.
|
|
257
|
+
field: Human-readable label of the YAML field being checked,
|
|
258
|
+
surfaced in the error message.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Absolute, resolved path that is guaranteed to be inside the sandbox.
|
|
262
|
+
"""
|
|
263
|
+
assert self.sandbox_dir is not None, "Sandbox directory not initialized"
|
|
264
|
+
sandbox_root = self.sandbox_dir.resolve()
|
|
265
|
+
candidate = (self.sandbox_dir / rel).resolve()
|
|
266
|
+
if candidate != sandbox_root and sandbox_root not in candidate.parents:
|
|
267
|
+
raise RuntimeError(f"{field} escapes sandbox: {rel!r} -> {candidate}")
|
|
268
|
+
return candidate
|
|
269
|
+
|
|
270
|
+
def _setup_template(self) -> None:
|
|
271
|
+
"""Setup template files/directory in sandbox.
|
|
272
|
+
|
|
273
|
+
Applies template sources sequentially in order. Later sources can overwrite
|
|
274
|
+
files from earlier sources (last-wins conflict resolution).
|
|
275
|
+
"""
|
|
276
|
+
sources = self.config.template_sources or []
|
|
277
|
+
|
|
278
|
+
for source in sources:
|
|
279
|
+
if isinstance(source, RepoSource):
|
|
280
|
+
self._apply_repo_source(source)
|
|
281
|
+
elif isinstance(source, TemplateDirSource):
|
|
282
|
+
self._apply_template_dir_source(source)
|
|
283
|
+
elif isinstance(source, StarterFilesSource):
|
|
284
|
+
self._apply_starter_files_source(source)
|
|
285
|
+
|
|
286
|
+
def _apply_template_dir_source(self, source: TemplateDirSource) -> None:
|
|
287
|
+
"""Copy template directory contents to sandbox with overwrite tracking.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
source: Template directory source configuration
|
|
291
|
+
|
|
292
|
+
Raises:
|
|
293
|
+
RuntimeError: If template directory doesn't exist or is not a directory
|
|
294
|
+
"""
|
|
295
|
+
assert self.sandbox_dir is not None, "Sandbox directory not initialized"
|
|
296
|
+
|
|
297
|
+
template_path = Path(source.path)
|
|
298
|
+
logger = logging.getLogger(__name__)
|
|
299
|
+
|
|
300
|
+
if not template_path.exists():
|
|
301
|
+
raise RuntimeError(f"Template directory not found: {template_path}")
|
|
302
|
+
|
|
303
|
+
if not template_path.is_dir():
|
|
304
|
+
raise RuntimeError(f"Template path is not a directory: {template_path}")
|
|
305
|
+
|
|
306
|
+
mount_root = self._resolve_within_sandbox(source.mount_point, field="Template mount_point")
|
|
307
|
+
mount_root.mkdir(parents=True, exist_ok=True)
|
|
308
|
+
|
|
309
|
+
# Track overwrites for logging
|
|
310
|
+
overwrites: set[str] = set()
|
|
311
|
+
|
|
312
|
+
# Copy contents with ignore patterns
|
|
313
|
+
for item in template_path.rglob("*"):
|
|
314
|
+
# Calculate relative path
|
|
315
|
+
rel_path = item.relative_to(template_path)
|
|
316
|
+
# Match ignore patterns against the template-relative path only —
|
|
317
|
+
# checking the absolute path would let an ancestor directory named
|
|
318
|
+
# `dist`, `build`, `env`, `venv`, or `node_modules` filter out the
|
|
319
|
+
# entire template (e.g. if the repo is cloned under ~/build/…).
|
|
320
|
+
if self._should_ignore_template_file(rel_path) and not self._matches_template_include_pattern(
|
|
321
|
+
rel_path, source.include_patterns
|
|
322
|
+
):
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
dest_path = mount_root / rel_path
|
|
326
|
+
|
|
327
|
+
# is_symlink() must come first — is_dir() / is_file() follow
|
|
328
|
+
# symlinks, so a `tools/node_modules/fil-compiler -> ../fil`
|
|
329
|
+
# link would look like a directory and we'd create an empty
|
|
330
|
+
# dir at the destination, breaking npm workspace resolution.
|
|
331
|
+
if item.is_symlink():
|
|
332
|
+
# `is_symlink()` before `exists()` because `exists()` follows
|
|
333
|
+
# the link; a *broken* symlink at dest is still an overwrite
|
|
334
|
+
# we need to clear.
|
|
335
|
+
if dest_path.is_symlink() or dest_path.exists():
|
|
336
|
+
# Only a real directory needs rmtree; symlinks-to-dir,
|
|
337
|
+
# symlinks-to-file, and regular files all clear with
|
|
338
|
+
# unlink() (which removes the link, not its target).
|
|
339
|
+
if dest_path.is_dir() and not dest_path.is_symlink():
|
|
340
|
+
shutil.rmtree(dest_path)
|
|
341
|
+
else:
|
|
342
|
+
dest_path.unlink()
|
|
343
|
+
overwrites.add(str(rel_path))
|
|
344
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
345
|
+
# `item.is_dir()` follows the symlink, so it tells us
|
|
346
|
+
# whether the target is a directory. On Windows
|
|
347
|
+
# `os.symlink` needs `target_is_directory=True` for
|
|
348
|
+
# directory targets — without it Windows creates a
|
|
349
|
+
# file-symlink that can't be traversed. POSIX ignores
|
|
350
|
+
# the flag.
|
|
351
|
+
#
|
|
352
|
+
# We preserve `os.readlink(item)` verbatim — both
|
|
353
|
+
# relative (npm workspaces, e.g. `node_modules/foo
|
|
354
|
+
# -> ../foo`) and absolute targets. Absolute targets
|
|
355
|
+
# remain live links into the host filesystem inside
|
|
356
|
+
# the sandbox; template authors are trusted infra
|
|
357
|
+
# (see `templates/` in this repo), so this is the
|
|
358
|
+
# intended behavior, not a defense boundary.
|
|
359
|
+
os.symlink(
|
|
360
|
+
os.readlink(item),
|
|
361
|
+
dest_path,
|
|
362
|
+
target_is_directory=item.is_dir(),
|
|
363
|
+
)
|
|
364
|
+
elif item.is_dir():
|
|
365
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
366
|
+
elif item.is_file():
|
|
367
|
+
# Track overwrites
|
|
368
|
+
if dest_path.exists():
|
|
369
|
+
overwrites.add(str(rel_path))
|
|
370
|
+
|
|
371
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
372
|
+
shutil.copy2(item, dest_path)
|
|
373
|
+
|
|
374
|
+
# Enhanced logging based on overwrite count
|
|
375
|
+
if overwrites:
|
|
376
|
+
if len(overwrites) <= 5:
|
|
377
|
+
logger.debug(f"Overwrote {len(overwrites)} files from {source.path}: {', '.join(sorted(overwrites))}")
|
|
378
|
+
else:
|
|
379
|
+
logger.debug(f"Overwrote {len(overwrites)} files from {source.path}")
|
|
380
|
+
|
|
381
|
+
def _prepare_mock_path_dirs(self) -> None:
|
|
382
|
+
"""Apply +x to plain files in each ``mock_path_dirs`` entry.
|
|
383
|
+
|
|
384
|
+
Resolves each configured directory against the sandbox root and, for every
|
|
385
|
+
plain file directly under it, ORs in the user/group/other execute bits.
|
|
386
|
+
Required on NTFS and after copies that drop the +x bit; a no-op when the
|
|
387
|
+
bit is already set. Missing entries and non-files (e.g. fixture
|
|
388
|
+
subdirectories) are skipped silently. PATH wiring happens in the agent --
|
|
389
|
+
this method only owns the filesystem side.
|
|
390
|
+
"""
|
|
391
|
+
assert self.sandbox_dir is not None, "Sandbox directory not initialized"
|
|
392
|
+
|
|
393
|
+
for dir_path in self.resolved_mock_path_dirs:
|
|
394
|
+
for entry in dir_path.iterdir():
|
|
395
|
+
if entry.is_file():
|
|
396
|
+
entry.chmod(entry.stat().st_mode | 0o111)
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def resolved_mock_path_dirs(self) -> list[Path]:
|
|
400
|
+
"""Absolute paths of configured mock dirs that exist on disk.
|
|
401
|
+
|
|
402
|
+
Returned in the order they appear in ``SandboxConfig.mock_path_dirs``;
|
|
403
|
+
non-existent and non-directory entries are filtered out so the caller
|
|
404
|
+
can pass the result straight to PATH-prepend logic.
|
|
405
|
+
|
|
406
|
+
Entries that resolve outside the sandbox root are rejected with a
|
|
407
|
+
RuntimeError -- a typo like ``"../mocks"`` would otherwise let
|
|
408
|
+
``_prepare_mock_path_dirs`` chmod +x files on the host filesystem.
|
|
409
|
+
Mirrors the ``mount_point`` containment check in
|
|
410
|
+
:meth:`_apply_template_dir_source`.
|
|
411
|
+
"""
|
|
412
|
+
if self.sandbox_dir is None or not self.config.mock_path_dirs:
|
|
413
|
+
return []
|
|
414
|
+
resolved: list[Path] = []
|
|
415
|
+
for rel in self.config.mock_path_dirs:
|
|
416
|
+
candidate = self._resolve_within_sandbox(rel, field="mock_path_dirs entry")
|
|
417
|
+
if candidate.is_dir():
|
|
418
|
+
resolved.append(candidate)
|
|
419
|
+
return resolved
|
|
420
|
+
|
|
421
|
+
def _apply_starter_files_source(self, source: StarterFilesSource) -> None:
|
|
422
|
+
"""Create inline starter files in sandbox with overwrite tracking.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
source: Starter files source configuration
|
|
426
|
+
|
|
427
|
+
Raises:
|
|
428
|
+
RuntimeError: If file path escapes sandbox (path traversal)
|
|
429
|
+
"""
|
|
430
|
+
assert self.sandbox_dir is not None, "Sandbox directory not initialized"
|
|
431
|
+
|
|
432
|
+
logger = logging.getLogger(__name__)
|
|
433
|
+
overwrites: set[str] = set()
|
|
434
|
+
|
|
435
|
+
for starter_file in source.files:
|
|
436
|
+
# Reject path traversal before any filesystem write; the helper allows
|
|
437
|
+
# the resolved path to equal sandbox_root, which is harmless for files
|
|
438
|
+
# because subsequent mkdir/write_text would fail on an empty path anyway.
|
|
439
|
+
file_path = self._resolve_within_sandbox(starter_file.path, field="starter_files path")
|
|
440
|
+
|
|
441
|
+
# Track overwrites
|
|
442
|
+
if file_path.exists():
|
|
443
|
+
overwrites.add(starter_file.path)
|
|
444
|
+
|
|
445
|
+
# Create parent directories
|
|
446
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
447
|
+
|
|
448
|
+
# Write file content
|
|
449
|
+
file_path.write_text(starter_file.content, encoding="utf-8")
|
|
450
|
+
|
|
451
|
+
# Enhanced logging based on overwrite count
|
|
452
|
+
if overwrites:
|
|
453
|
+
if len(overwrites) <= 5:
|
|
454
|
+
logger.debug(f"Overwrote {len(overwrites)} files from starter_files: {', '.join(sorted(overwrites))}")
|
|
455
|
+
else:
|
|
456
|
+
logger.debug(f"Overwrote {len(overwrites)} files from starter_files")
|
|
457
|
+
|
|
458
|
+
def _should_ignore_template_file(self, path: Path) -> bool:
|
|
459
|
+
"""Check if template file/directory should be ignored."""
|
|
460
|
+
patterns = get_ignore_patterns(self.config.ignore_patterns)
|
|
461
|
+
return should_ignore_path(path, patterns)
|
|
462
|
+
|
|
463
|
+
def _matches_template_include_pattern(self, rel_path: Path, include_patterns: list[str]) -> bool:
|
|
464
|
+
"""Return whether a template-relative path is explicitly re-included.
|
|
465
|
+
|
|
466
|
+
Patterns are matched with :func:`fnmatch.fnmatchcase` against the
|
|
467
|
+
forward-slash form of ``rel_path``. Note that ``*`` does NOT stop at
|
|
468
|
+
``/`` (unlike gitignore), so ``tools/*/dist`` will also match
|
|
469
|
+
``tools/a/b/dist``. This is permissive by design: include patterns can
|
|
470
|
+
only re-include paths the default ignore list rejected, so widening is
|
|
471
|
+
safer than narrowing. A leading ``./`` on the pattern is stripped.
|
|
472
|
+
"""
|
|
473
|
+
if not include_patterns:
|
|
474
|
+
return False
|
|
475
|
+
normalized_path = rel_path.as_posix()
|
|
476
|
+
for pattern in include_patterns:
|
|
477
|
+
normalized_pattern = pattern.replace("\\", "/").removeprefix("./")
|
|
478
|
+
if fnmatch.fnmatchcase(normalized_path, normalized_pattern):
|
|
479
|
+
return True
|
|
480
|
+
return False
|
|
481
|
+
|
|
482
|
+
def _setup_virtualenv(self) -> None:
|
|
483
|
+
"""Create a Python virtual environment in the sandbox."""
|
|
484
|
+
if not self.sandbox_dir:
|
|
485
|
+
raise RuntimeError("Sandbox directory not initialized")
|
|
486
|
+
|
|
487
|
+
self.venv_dir = self.sandbox_dir / ".venv"
|
|
488
|
+
|
|
489
|
+
# Use uv to create virtual environment (faster than venv)
|
|
490
|
+
try:
|
|
491
|
+
# Check if uv is available
|
|
492
|
+
subprocess.run(["uv", "--version"], check=True, capture_output=True, timeout=5)
|
|
493
|
+
# Use uv to create venv
|
|
494
|
+
cmd = ["uv", "venv", str(self.venv_dir)]
|
|
495
|
+
subprocess.run(cmd, check=True, capture_output=True, text=True, encoding="utf-8", timeout=60)
|
|
496
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
497
|
+
# Fallback to standard venv if uv is not available
|
|
498
|
+
import venv
|
|
499
|
+
|
|
500
|
+
venv.create(self.venv_dir, with_pip=True)
|
|
501
|
+
|
|
502
|
+
def _install_packages(self) -> None:
|
|
503
|
+
"""Install required Python packages in the virtual environment."""
|
|
504
|
+
if not self.config.python or not self.config.python.env_packages or not self.venv_dir:
|
|
505
|
+
return
|
|
506
|
+
|
|
507
|
+
# Get path to pip in the virtual environment
|
|
508
|
+
scripts_dir = self._venv_scripts_dir
|
|
509
|
+
assert scripts_dir is not None # guaranteed by venv_dir guard above
|
|
510
|
+
pip_path = scripts_dir / "pip"
|
|
511
|
+
|
|
512
|
+
# Try uv first, fall back to pip
|
|
513
|
+
try:
|
|
514
|
+
subprocess.run(["uv", "--version"], check=True, capture_output=True, timeout=5)
|
|
515
|
+
# Use uv pip for faster installation
|
|
516
|
+
cmd = ["uv", "pip", "install", *self.config.python.env_packages]
|
|
517
|
+
env = os.environ.copy()
|
|
518
|
+
env["VIRTUAL_ENV"] = str(self.venv_dir)
|
|
519
|
+
env["PATH"] = f"{scripts_dir}{os.pathsep}{env['PATH']}"
|
|
520
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
521
|
+
# Fallback to regular pip
|
|
522
|
+
cmd = [str(pip_path), "install", *self.config.python.env_packages]
|
|
523
|
+
env = None
|
|
524
|
+
|
|
525
|
+
try:
|
|
526
|
+
subprocess.run(cmd, check=True, capture_output=True, text=True, encoding="utf-8", timeout=300, env=env)
|
|
527
|
+
except subprocess.CalledProcessError as e:
|
|
528
|
+
raise RuntimeError(f"Failed to install packages: {e.stderr}") from e
|
|
529
|
+
|
|
530
|
+
def _install_node_packages(self) -> None:
|
|
531
|
+
"""Install npm packages locally in the sandbox directory."""
|
|
532
|
+
if not self.config.node or not self.config.node.env_packages or not self.sandbox_dir:
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
packages = self.config.node.env_packages
|
|
536
|
+
|
|
537
|
+
# Try bun first, fall back to npm
|
|
538
|
+
try:
|
|
539
|
+
subprocess.run(["bun", "--version"], check=True, capture_output=True, timeout=5)
|
|
540
|
+
cmd = ["bun", "add", *packages]
|
|
541
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
542
|
+
cmd = ["npm", "install", *packages]
|
|
543
|
+
|
|
544
|
+
try:
|
|
545
|
+
subprocess.run(
|
|
546
|
+
cmd,
|
|
547
|
+
check=True,
|
|
548
|
+
capture_output=True,
|
|
549
|
+
text=True,
|
|
550
|
+
encoding="utf-8",
|
|
551
|
+
timeout=300,
|
|
552
|
+
cwd=self.sandbox_dir,
|
|
553
|
+
)
|
|
554
|
+
except subprocess.CalledProcessError as e:
|
|
555
|
+
raise RuntimeError(f"Failed to install node packages: {e.stderr}") from e
|
|
556
|
+
|
|
557
|
+
# Capture installed versions
|
|
558
|
+
self._capture_node_tool_versions()
|
|
559
|
+
|
|
560
|
+
def _capture_node_tool_versions(self) -> None:
|
|
561
|
+
"""Capture installed versions for explicitly requested npm packages only."""
|
|
562
|
+
if not self.sandbox_dir or not self.config.node:
|
|
563
|
+
return
|
|
564
|
+
|
|
565
|
+
node_modules = self.sandbox_dir / "node_modules"
|
|
566
|
+
if not node_modules.exists():
|
|
567
|
+
return
|
|
568
|
+
|
|
569
|
+
# Extract package names from specifiers (strip version: "@uipath/cli@0.1.5" -> "@uipath/cli")
|
|
570
|
+
requested_names: set[str] = set()
|
|
571
|
+
for spec in self.config.node.env_packages:
|
|
572
|
+
if spec.startswith("@"):
|
|
573
|
+
# Scoped: "@scope/pkg@version" -> "@scope/pkg"
|
|
574
|
+
requested_names.add("@" + spec[1:].split("@", 1)[0])
|
|
575
|
+
else:
|
|
576
|
+
# Unscoped: "pkg@version" -> "pkg"
|
|
577
|
+
requested_names.add(spec.split("@", 1)[0])
|
|
578
|
+
|
|
579
|
+
# Read package.json for each requested package
|
|
580
|
+
for name in requested_names:
|
|
581
|
+
if name.startswith("@") and "/" in name:
|
|
582
|
+
# Scoped: @scope/pkg -> node_modules/@scope/pkg
|
|
583
|
+
scope, pkg = name.split("/", 1)
|
|
584
|
+
pkg_dir = node_modules / scope / pkg
|
|
585
|
+
else:
|
|
586
|
+
pkg_dir = node_modules / name
|
|
587
|
+
|
|
588
|
+
pkg_json = pkg_dir / "package.json"
|
|
589
|
+
if pkg_json.exists():
|
|
590
|
+
try:
|
|
591
|
+
data = json.loads(pkg_json.read_text(encoding="utf-8"))
|
|
592
|
+
version = data.get("version", "unknown")
|
|
593
|
+
self.installed_tool_versions[name] = version
|
|
594
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
595
|
+
logger.debug(
|
|
596
|
+
"Failed to read or parse package.json for %s at %s: %s",
|
|
597
|
+
name,
|
|
598
|
+
pkg_json,
|
|
599
|
+
exc,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
def set_command_base_path(self, path: str | None) -> None:
|
|
603
|
+
"""Set the parent PATH used by sandbox command checks.
|
|
604
|
+
|
|
605
|
+
The orchestrator uses this to align success-criteria commands with the
|
|
606
|
+
PATH passed to the agent SDK. Sandbox-local venv and node bin entries
|
|
607
|
+
are still prepended by ``run_command``.
|
|
608
|
+
|
|
609
|
+
Also re-derives the canonical ``PLUGIN_TOOLS_DIR`` (MST-9795): the
|
|
610
|
+
resolved ``uip`` binary depends on PATH, and the path-aligned criterion
|
|
611
|
+
is the canonical lookup. Failures are swallowed — the env var simply
|
|
612
|
+
stays unset and the CLI falls back to its walk-based discovery.
|
|
613
|
+
|
|
614
|
+
Passing ``None`` clears the agent-aligned PATH prefix and re-derives
|
|
615
|
+
``PLUGIN_TOOLS_DIR`` from ``os.environ['PATH']`` alone. The new pin
|
|
616
|
+
may differ from the previous one if the parent PATH resolves ``uip``
|
|
617
|
+
to a different install — by design, since dropping the agent
|
|
618
|
+
alignment means the criterion subprocess should now match the parent
|
|
619
|
+
environment.
|
|
620
|
+
"""
|
|
621
|
+
self._command_base_path = path or None
|
|
622
|
+
self._refresh_plugin_tools_dir()
|
|
623
|
+
|
|
624
|
+
@property
|
|
625
|
+
def command_base_path(self) -> str | None:
|
|
626
|
+
"""Read-only view of the configured base PATH (or ``None`` when unset).
|
|
627
|
+
|
|
628
|
+
Tests can observe orchestrator-set overrides without touching the
|
|
629
|
+
underlying private slot. Mutate via :meth:`set_command_base_path`.
|
|
630
|
+
"""
|
|
631
|
+
return self._command_base_path
|
|
632
|
+
|
|
633
|
+
@property
|
|
634
|
+
def plugin_tools_dir(self) -> str | None:
|
|
635
|
+
"""Canonical ``node_modules/@uipath`` derived from the resolved ``uip``.
|
|
636
|
+
|
|
637
|
+
Populated by :meth:`_refresh_plugin_tools_dir` after the agent's PATH
|
|
638
|
+
is captured. When non-None, ``_build_run_command_env`` exports it as
|
|
639
|
+
``PLUGIN_TOOLS_DIR`` so the UiPath CLI pins plugin discovery instead
|
|
640
|
+
of walking up from CWD — eliminating MST-9795's host-pollution
|
|
641
|
+
asymmetry between authoring-time and criterion-time validation.
|
|
642
|
+
|
|
643
|
+
Returns ``None`` when ``uip`` is not on PATH or the resolved binary
|
|
644
|
+
does not live inside a recognizable ``node_modules/@uipath`` tree
|
|
645
|
+
(e.g. development monorepo runs).
|
|
646
|
+
"""
|
|
647
|
+
return self._plugin_tools_dir
|
|
648
|
+
|
|
649
|
+
@property
|
|
650
|
+
def uip_search_path(self) -> str:
|
|
651
|
+
"""The PATH used to resolve ``uip`` — agent-aligned prefix + process PATH.
|
|
652
|
+
|
|
653
|
+
The same PATH ``run_command`` subprocesses and the agent SDK env see,
|
|
654
|
+
so a binary resolved against it is the one task commands actually
|
|
655
|
+
executed.
|
|
656
|
+
"""
|
|
657
|
+
search_path = os.environ.get("PATH", "")
|
|
658
|
+
if self._command_base_path:
|
|
659
|
+
search_path = f"{self._command_base_path}{os.pathsep}{search_path}"
|
|
660
|
+
return search_path
|
|
661
|
+
|
|
662
|
+
def refresh_plugin_tools_dir(self) -> None:
|
|
663
|
+
"""Re-derive :attr:`plugin_tools_dir` for the current PATH.
|
|
664
|
+
|
|
665
|
+
Public hook for callers that need post-task state: the UiPath CLI
|
|
666
|
+
auto-installs/upgrades its tool plugins on first use, so a pin derived
|
|
667
|
+
at setup time can be stale (or ``None``) by the time the task ends.
|
|
668
|
+
"""
|
|
669
|
+
self._refresh_plugin_tools_dir()
|
|
670
|
+
|
|
671
|
+
def _refresh_plugin_tools_dir(self) -> None:
|
|
672
|
+
"""Resolve the canonical ``node_modules/@uipath`` for the current PATH.
|
|
673
|
+
|
|
674
|
+
Delegates to :func:`coder_eval.utils.resolve_uipath_plugin_dir` against
|
|
675
|
+
``uip_search_path`` (``command_base_path + os.environ['PATH']`` — the
|
|
676
|
+
same PATH ``run_command`` and the agent SDK will see), then stores the
|
|
677
|
+
result as a string on ``self._plugin_tools_dir`` (or ``None`` if no
|
|
678
|
+
usable ``uip`` is on PATH). Idempotent across calls; safe to call from
|
|
679
|
+
both ``setup`` (initial value when no command_base_path yet) and
|
|
680
|
+
``set_command_base_path`` (re-derive after PATH alignment).
|
|
681
|
+
"""
|
|
682
|
+
from .utils import resolve_uipath_plugin_dir
|
|
683
|
+
|
|
684
|
+
resolved = resolve_uipath_plugin_dir(self.uip_search_path)
|
|
685
|
+
self._plugin_tools_dir = str(resolved) if resolved is not None else None
|
|
686
|
+
|
|
687
|
+
def _maybe_remediate_home_plugins_pollution(self) -> Path | None:
|
|
688
|
+
"""Optionally delete ``$HOME/node_modules/@uipath`` before the task runs.
|
|
689
|
+
|
|
690
|
+
Gated on the ``CODER_EVAL_REMEDIATE_HOME_PLUGINS`` env var being a
|
|
691
|
+
truthy string (``"1"``/``"true"``/``"yes"``, case-insensitive). Off
|
|
692
|
+
by default — silent deletion of a user-owned directory is
|
|
693
|
+
destructive and a generic eval framework should not own that
|
|
694
|
+
decision. Operators of dedicated eval runners (Azure) flip the flag
|
|
695
|
+
on at host-bring-up time because every task there is poisoned by
|
|
696
|
+
sibling tasks leaking installs into ``$HOME`` (MST-9674 / MST-9795).
|
|
697
|
+
|
|
698
|
+
Returns the deleted directory on success, ``None`` when no action
|
|
699
|
+
was taken (flag off, dir absent, or under-test ``$HOME`` mismatch).
|
|
700
|
+
|
|
701
|
+
TOCTOU: there is a small window between ``Path.resolve(strict=True)``
|
|
702
|
+
and ``shutil.rmtree`` during which the target could be replaced.
|
|
703
|
+
Combined defenses: (a) the resolved-anchor check rejects HOME=/,
|
|
704
|
+
(b) ``resolved_home in resolved_target.parents`` confines deletion
|
|
705
|
+
under HOME, (c) the operator opt-in gates the entire path. Failures
|
|
706
|
+
in ``rmtree`` are silenced (``ignore_errors=True``) and surfaced via
|
|
707
|
+
a residual-presence warning rather than a raise.
|
|
708
|
+
"""
|
|
709
|
+
flag = os.environ.get(self.REMEDIATE_HOME_PLUGINS_ENV, "").strip().lower()
|
|
710
|
+
if flag not in {"1", "true", "yes"}:
|
|
711
|
+
return None
|
|
712
|
+
home = os.environ.get("HOME") or os.environ.get("USERPROFILE")
|
|
713
|
+
if not home:
|
|
714
|
+
return None
|
|
715
|
+
target = Path(home) / "node_modules" / "@uipath"
|
|
716
|
+
if not target.is_dir():
|
|
717
|
+
return None
|
|
718
|
+
# Refuse to touch anything outside the configured HOME — if HOME
|
|
719
|
+
# somehow points at root or a system dir, bail out loudly rather
|
|
720
|
+
# than rm-rf'ing it. The check is belt-and-suspenders: the path
|
|
721
|
+
# construction above already anchors at $HOME.
|
|
722
|
+
try:
|
|
723
|
+
resolved_target = target.resolve(strict=True)
|
|
724
|
+
resolved_home = Path(home).resolve(strict=True)
|
|
725
|
+
except (OSError, RuntimeError) as exc:
|
|
726
|
+
logger.warning(
|
|
727
|
+
"Cannot resolve %s for remediation: %s",
|
|
728
|
+
target,
|
|
729
|
+
exc,
|
|
730
|
+
)
|
|
731
|
+
return None
|
|
732
|
+
if resolved_home == Path(resolved_home.anchor):
|
|
733
|
+
logger.warning(
|
|
734
|
+
"Refusing to remediate %s: HOME=%s resolves to the filesystem root",
|
|
735
|
+
target,
|
|
736
|
+
resolved_home,
|
|
737
|
+
)
|
|
738
|
+
return None
|
|
739
|
+
if resolved_home not in resolved_target.parents:
|
|
740
|
+
logger.warning(
|
|
741
|
+
"Refusing to remediate %s: resolved target %s is not under HOME %s",
|
|
742
|
+
target,
|
|
743
|
+
resolved_target,
|
|
744
|
+
resolved_home,
|
|
745
|
+
)
|
|
746
|
+
return None
|
|
747
|
+
logger.warning(
|
|
748
|
+
"MST-9795 remediation: removing host-pollution dir %s (gated on %s; sibling tasks leaked installs)",
|
|
749
|
+
resolved_target,
|
|
750
|
+
self.REMEDIATE_HOME_PLUGINS_ENV,
|
|
751
|
+
)
|
|
752
|
+
shutil.rmtree(resolved_target, ignore_errors=True)
|
|
753
|
+
if resolved_target.exists():
|
|
754
|
+
logger.warning(
|
|
755
|
+
"MST-9795 remediation: %s still present after rmtree (partial delete; check fs busy/locked files)",
|
|
756
|
+
resolved_target,
|
|
757
|
+
)
|
|
758
|
+
return resolved_target
|
|
759
|
+
|
|
760
|
+
def _build_run_command_env(self) -> dict[str, str]:
|
|
761
|
+
"""Build the environment for ``run_command``.
|
|
762
|
+
|
|
763
|
+
Each layer is independent — none breaks if another is absent:
|
|
764
|
+
|
|
765
|
+
1. Inherit parent env (so agent tools / credentials remain reachable).
|
|
766
|
+
2. (MST-9265) If the orchestrator has captured the agent's SDK PATH
|
|
767
|
+
via :meth:`set_command_base_path`, **prepend** it ahead of the
|
|
768
|
+
host PATH (not replace) — the agent's PATH only needs to win
|
|
769
|
+
the lookup race for its bundled toolchain, but system binaries
|
|
770
|
+
(``python``, ``node``, ``/usr/bin/*``) must remain reachable to
|
|
771
|
+
criteria. Prepend semantics also stay symmetric with the venv /
|
|
772
|
+
node_bin prepends below.
|
|
773
|
+
3. Activate the sandbox virtualenv (if present). First-hit-wins:
|
|
774
|
+
if the agent's PATH already contains the venv scripts dir
|
|
775
|
+
(likely, since the agent inherits this process's env), this
|
|
776
|
+
prepend duplicates the entry. Harmless on every OS we target;
|
|
777
|
+
left explicit so the order stays independent of what the agent
|
|
778
|
+
SDK happens to inject.
|
|
779
|
+
4. Prepend ``<sandbox>/node_modules/.bin`` to PATH (if present).
|
|
780
|
+
5. (MST-9674) Pin ``NODE_PATH=""`` so Node's fallback search paths
|
|
781
|
+
cannot pick up contaminated parent-dir installs. Note: this
|
|
782
|
+
does NOT disable parent-walking from cwd — that is hard-wired
|
|
783
|
+
in Node — but it eliminates ``NODE_PATH``-mediated leaks.
|
|
784
|
+
6. (MST-9674) Pin ``NPM_CONFIG_PREFIX`` to a sandbox-scoped
|
|
785
|
+
directory so any ``npm install`` / ``bun add`` from inside the
|
|
786
|
+
sandbox writes into the sandbox, not into
|
|
787
|
+
``$HOME/node_modules`` where concurrent sandboxes would shadow
|
|
788
|
+
each other.
|
|
789
|
+
7. Expose ``TASK_DIR`` for criterion scripts.
|
|
790
|
+
"""
|
|
791
|
+
assert self.sandbox_dir is not None
|
|
792
|
+
env = os.environ.copy()
|
|
793
|
+
if self._command_base_path:
|
|
794
|
+
env["PATH"] = f"{self._command_base_path}{os.pathsep}{env['PATH']}"
|
|
795
|
+
if self.venv_dir:
|
|
796
|
+
env["VIRTUAL_ENV"] = str(self.venv_dir)
|
|
797
|
+
env["PATH"] = f"{self._venv_scripts_dir}{os.pathsep}{env['PATH']}"
|
|
798
|
+
node_bin = self.sandbox_dir / "node_modules" / ".bin"
|
|
799
|
+
if node_bin.exists():
|
|
800
|
+
env["PATH"] = f"{node_bin}{os.pathsep}{env['PATH']}"
|
|
801
|
+
# MST-9674: keep Node and npm resolution sandbox-local so concurrent
|
|
802
|
+
# tasks cannot poison each other through shared parent-dir node_modules.
|
|
803
|
+
env["NODE_PATH"] = ""
|
|
804
|
+
env["NPM_CONFIG_PREFIX"] = str(self.sandbox_dir / ".npm-prefix")
|
|
805
|
+
# Pin UiPath CLI plugin discovery so the criterion subprocess uses the
|
|
806
|
+
# same @uipath tools the agent authored against (defers to an external pin).
|
|
807
|
+
if self._plugin_tools_dir and "PLUGIN_TOOLS_DIR" not in env:
|
|
808
|
+
env["PLUGIN_TOOLS_DIR"] = self._plugin_tools_dir
|
|
809
|
+
if self.task_dir:
|
|
810
|
+
env["TASK_DIR"] = str(self.task_dir)
|
|
811
|
+
return env
|
|
812
|
+
|
|
813
|
+
def _check_parent_node_modules_contamination(self) -> list[Path]:
|
|
814
|
+
"""Walk up from ``sandbox_dir`` and report any ancestor that has a
|
|
815
|
+
populated ``node_modules/`` directory.
|
|
816
|
+
|
|
817
|
+
Concurrent tasks (or anything else on the host that runs
|
|
818
|
+
``cd <ancestor> && npm install ... --save``) drop packages into
|
|
819
|
+
shared parent dirs. Node's parent-walking module resolver finds
|
|
820
|
+
those before the sandbox-local install, which is the proximate
|
|
821
|
+
cause of MST-9674's ``unknown command 'run'`` failure — but the
|
|
822
|
+
failure mode is generic to Node module resolution, not specific
|
|
823
|
+
to any one npm scope. The check therefore stays
|
|
824
|
+
scope-agnostic: ``coder_eval`` is a generic evaluation framework
|
|
825
|
+
and should not single out one ecosystem's namespace. Operators
|
|
826
|
+
read the logged entry list to decide whether the contamination
|
|
827
|
+
actually matters for their agent's toolchain.
|
|
828
|
+
|
|
829
|
+
This is a *detection-only* helper. It returns the list of
|
|
830
|
+
ancestor ``node_modules`` dirs found and logs a single warning
|
|
831
|
+
per dir. Auto-remediation is intentionally avoided — those dirs
|
|
832
|
+
may legitimately belong to the user and silently deleting them
|
|
833
|
+
would be destructive.
|
|
834
|
+
"""
|
|
835
|
+
if self.sandbox_dir is None:
|
|
836
|
+
return []
|
|
837
|
+
offenders: list[Path] = []
|
|
838
|
+
seen: set[Path] = set()
|
|
839
|
+
# Walk strictly upward; do not include the sandbox itself (its
|
|
840
|
+
# node_modules is intentional).
|
|
841
|
+
for parent in self.sandbox_dir.resolve().parents:
|
|
842
|
+
if parent in seen:
|
|
843
|
+
continue
|
|
844
|
+
seen.add(parent)
|
|
845
|
+
node_modules_dir = parent / "node_modules"
|
|
846
|
+
if not node_modules_dir.is_dir():
|
|
847
|
+
continue
|
|
848
|
+
try:
|
|
849
|
+
# Skip dot-entries (``.bin``, ``.cache``, …) — they are
|
|
850
|
+
# package-manager bookkeeping, not installed packages
|
|
851
|
+
# that would shadow a sandbox-local install.
|
|
852
|
+
entries = sorted(p.name for p in node_modules_dir.iterdir() if not p.name.startswith("."))
|
|
853
|
+
except OSError:
|
|
854
|
+
# Permission denied / race-with-delete — skip silently.
|
|
855
|
+
continue
|
|
856
|
+
if not entries:
|
|
857
|
+
continue
|
|
858
|
+
offenders.append(node_modules_dir)
|
|
859
|
+
sample = ", ".join(entries[:5])
|
|
860
|
+
more = f" (+{len(entries) - 5} more)" if len(entries) > 5 else ""
|
|
861
|
+
logger.warning(
|
|
862
|
+
"Parent-dir node_modules contamination detected at %s — "
|
|
863
|
+
+ "Node's parent-walking resolver may pick this up before the "
|
|
864
|
+
+ "sandbox-local install (see MST-9674). Contents: %s%s",
|
|
865
|
+
node_modules_dir,
|
|
866
|
+
sample,
|
|
867
|
+
more,
|
|
868
|
+
)
|
|
869
|
+
return offenders
|
|
870
|
+
|
|
871
|
+
def run_command(self, command: str, timeout: float | int | None = None) -> tuple[int, str, str]:
|
|
872
|
+
"""Run a command in the sandbox environment.
|
|
873
|
+
|
|
874
|
+
Args:
|
|
875
|
+
command: Command to execute
|
|
876
|
+
timeout: Timeout in seconds (uses config default if not specified)
|
|
877
|
+
|
|
878
|
+
Returns:
|
|
879
|
+
Tuple of (exit_code, stdout, stderr)
|
|
880
|
+
|
|
881
|
+
Raises:
|
|
882
|
+
RuntimeError: If sandbox is not set up
|
|
883
|
+
subprocess.TimeoutExpired: If command times out
|
|
884
|
+
"""
|
|
885
|
+
if not self.sandbox_dir:
|
|
886
|
+
raise RuntimeError("Sandbox not set up. Call setup() first.")
|
|
887
|
+
|
|
888
|
+
# Use timeout from argument or config
|
|
889
|
+
if timeout is None:
|
|
890
|
+
timeout = self.config.limits.timeout
|
|
891
|
+
|
|
892
|
+
env = self._build_run_command_env()
|
|
893
|
+
|
|
894
|
+
try:
|
|
895
|
+
# Shell execution is intentional for sandbox - allows pipes, redirects, and complex commands.
|
|
896
|
+
# Decode stdout/stderr as UTF-8 with replacement on bad bytes so an agent that emits
|
|
897
|
+
# non-UTF-8 output (e.g. raw binary, locale-encoded compiler errors on Windows) does not
|
|
898
|
+
# kill the run with UnicodeDecodeError. Downstream callers (e.g. json_check) only need
|
|
899
|
+
# JSON-parseable strings; a replacement char is preferable to a crash.
|
|
900
|
+
result = subprocess.run(
|
|
901
|
+
command,
|
|
902
|
+
shell=True, # nosec B602 - Required for sandbox command execution
|
|
903
|
+
cwd=self.sandbox_dir,
|
|
904
|
+
env=env,
|
|
905
|
+
capture_output=True,
|
|
906
|
+
text=True,
|
|
907
|
+
encoding="utf-8",
|
|
908
|
+
errors="replace",
|
|
909
|
+
timeout=timeout,
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
# Log command completion
|
|
913
|
+
logger.debug(f"Command '{command}' exited with code {result.returncode}")
|
|
914
|
+
|
|
915
|
+
# Log stdout if non-empty (pre-strip for cleaner code)
|
|
916
|
+
stdout_content = result.stdout.strip()
|
|
917
|
+
if stdout_content:
|
|
918
|
+
logger.debug(f"STDOUT:\n---\n{stdout_content}\n---")
|
|
919
|
+
|
|
920
|
+
# Log stderr if non-empty
|
|
921
|
+
stderr_content = result.stderr.strip()
|
|
922
|
+
if stderr_content:
|
|
923
|
+
logger.debug(f"STDERR:\n---\n{stderr_content}\n---")
|
|
924
|
+
|
|
925
|
+
return result.returncode, result.stdout, result.stderr
|
|
926
|
+
|
|
927
|
+
except subprocess.TimeoutExpired:
|
|
928
|
+
error_msg = f"Command '{command}' timed out after {timeout} seconds"
|
|
929
|
+
logger.warning(error_msg)
|
|
930
|
+
return -1, "", error_msg
|
|
931
|
+
|
|
932
|
+
# NOTE: get_file_content, file_exists, and list_files intentionally do NOT validate
|
|
933
|
+
# path traversal. The sandbox is a trusted execution environment where the agent
|
|
934
|
+
# needs filesystem access beyond the sandbox root (e.g., reading installed packages,
|
|
935
|
+
# system headers). Path traversal protection is handled at the agent permission level.
|
|
936
|
+
|
|
937
|
+
def get_file_content(self, path: str) -> str:
|
|
938
|
+
"""Read the content of a file in the sandbox.
|
|
939
|
+
|
|
940
|
+
Args:
|
|
941
|
+
path: Relative path to the file
|
|
942
|
+
|
|
943
|
+
Returns:
|
|
944
|
+
File content as string
|
|
945
|
+
|
|
946
|
+
Raises:
|
|
947
|
+
RuntimeError: If sandbox is not set up
|
|
948
|
+
FileNotFoundError: If file doesn't exist
|
|
949
|
+
"""
|
|
950
|
+
if not self.sandbox_dir:
|
|
951
|
+
raise RuntimeError("Sandbox not set up")
|
|
952
|
+
|
|
953
|
+
file_path = self.sandbox_dir / path
|
|
954
|
+
return file_path.read_text(encoding="utf-8")
|
|
955
|
+
|
|
956
|
+
def file_exists(self, path: str) -> bool:
|
|
957
|
+
"""Check if a file exists in the sandbox.
|
|
958
|
+
|
|
959
|
+
Args:
|
|
960
|
+
path: Relative path to the file
|
|
961
|
+
|
|
962
|
+
Returns:
|
|
963
|
+
True if file exists, False otherwise
|
|
964
|
+
"""
|
|
965
|
+
if not self.sandbox_dir:
|
|
966
|
+
return False
|
|
967
|
+
|
|
968
|
+
return (self.sandbox_dir / path).exists()
|
|
969
|
+
|
|
970
|
+
def list_files(self, path: str = ".") -> list[str]:
|
|
971
|
+
"""List files in a directory within the sandbox.
|
|
972
|
+
|
|
973
|
+
Args:
|
|
974
|
+
path: Relative path to directory (default: root)
|
|
975
|
+
|
|
976
|
+
Returns:
|
|
977
|
+
List of file paths relative to sandbox root
|
|
978
|
+
"""
|
|
979
|
+
if not self.sandbox_dir:
|
|
980
|
+
return []
|
|
981
|
+
|
|
982
|
+
target_dir = self.sandbox_dir / path
|
|
983
|
+
if not target_dir.exists() or not target_dir.is_dir():
|
|
984
|
+
return []
|
|
985
|
+
|
|
986
|
+
files = []
|
|
987
|
+
for item in target_dir.rglob("*"):
|
|
988
|
+
if item.is_file():
|
|
989
|
+
rel_path = item.relative_to(self.sandbox_dir)
|
|
990
|
+
files.append(str(rel_path))
|
|
991
|
+
|
|
992
|
+
return sorted(files)
|
|
993
|
+
|
|
994
|
+
def grant_read_access(self) -> None:
|
|
995
|
+
"""Apply ``chmod a+rX`` across the sandbox tree (in place).
|
|
996
|
+
|
|
997
|
+
For DIRECT_WRITE preservation the sandbox already lives in the
|
|
998
|
+
artifacts dir, so ``preserve_to`` (which would otherwise grant this)
|
|
999
|
+
never runs. A root-owned docker container leaves the tree at 0700, so
|
|
1000
|
+
the host user can't traverse it; granting group+other read/traverse
|
|
1001
|
+
keeps the artifacts visible across the uid boundary. No-op-ish on the
|
|
1002
|
+
host path, where the sandbox is already owner-readable.
|
|
1003
|
+
"""
|
|
1004
|
+
if self.sandbox_dir is not None and self.sandbox_dir.exists():
|
|
1005
|
+
_grant_read_traverse(self.sandbox_dir)
|
|
1006
|
+
|
|
1007
|
+
def preserve_to(self, artifact_dir: Path) -> Path:
|
|
1008
|
+
"""Preserve sandbox contents to an artifact directory.
|
|
1009
|
+
|
|
1010
|
+
Uses ``shutil.move``: an atomic rename when source and destination
|
|
1011
|
+
share a filesystem, copy+remove otherwise. Either way the source side
|
|
1012
|
+
is gone the moment this method returns (no second-pass rmtree).
|
|
1013
|
+
|
|
1014
|
+
Args:
|
|
1015
|
+
artifact_dir: Directory to move sandbox contents into.
|
|
1016
|
+
|
|
1017
|
+
Returns:
|
|
1018
|
+
Path to the preserved sandbox.
|
|
1019
|
+
|
|
1020
|
+
Raises:
|
|
1021
|
+
RuntimeError: If sandbox is not set up.
|
|
1022
|
+
"""
|
|
1023
|
+
if not self.sandbox_dir:
|
|
1024
|
+
raise RuntimeError("Sandbox not set up")
|
|
1025
|
+
|
|
1026
|
+
# task_id may contain "/" (dataset row tasks); ensure the parent exists.
|
|
1027
|
+
preserve_path = artifact_dir / self.task_id
|
|
1028
|
+
preserve_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1029
|
+
|
|
1030
|
+
# Guard against self-referential move (sandbox already at target).
|
|
1031
|
+
if self.sandbox_dir.resolve() == preserve_path.resolve():
|
|
1032
|
+
return preserve_path
|
|
1033
|
+
|
|
1034
|
+
if preserve_path.exists():
|
|
1035
|
+
shutil.rmtree(preserve_path)
|
|
1036
|
+
|
|
1037
|
+
old_sandbox_dir = self.sandbox_dir
|
|
1038
|
+
shutil.move(str(old_sandbox_dir), str(preserve_path))
|
|
1039
|
+
|
|
1040
|
+
# mkdtemp creates the sandbox root at 0700. Under driver:docker the
|
|
1041
|
+
# container runs as root, so the preserved tree lands on the host
|
|
1042
|
+
# bind-mount owned by root with that 0700 top dir -- the host user
|
|
1043
|
+
# (a different uid) then can't traverse it, so the blob upload and any
|
|
1044
|
+
# `ls` see an empty dir and silently skip the artifacts. Grant a+rX on
|
|
1045
|
+
# the preserved tree so artifacts are readable across the uid boundary.
|
|
1046
|
+
# No-op-ish on the host path, where the sandbox is already owner-readable.
|
|
1047
|
+
_grant_read_traverse(preserve_path)
|
|
1048
|
+
|
|
1049
|
+
# Sandbox now lives at the artifact path -- redirect pointers so that a
|
|
1050
|
+
# subsequent cleanup() is a no-op. Venv absolute paths inside the venv
|
|
1051
|
+
# are not rewritten (same behaviour as the prior copy-based code).
|
|
1052
|
+
self.sandbox_dir = preserve_path
|
|
1053
|
+
if self.venv_dir is not None:
|
|
1054
|
+
try:
|
|
1055
|
+
rel = self.venv_dir.relative_to(old_sandbox_dir)
|
|
1056
|
+
self.venv_dir = preserve_path / rel
|
|
1057
|
+
except ValueError:
|
|
1058
|
+
# Defensive: venv_dir is currently always created under
|
|
1059
|
+
# sandbox_dir (see _setup_virtualenv), so relative_to should
|
|
1060
|
+
# always succeed. If a future code path places it elsewhere,
|
|
1061
|
+
# leave the pointer untouched -- the move did not relocate it.
|
|
1062
|
+
pass
|
|
1063
|
+
self._cleanup_on_exit = False
|
|
1064
|
+
return preserve_path
|
|
1065
|
+
|
|
1066
|
+
def capture_to(self, artifact_dir: Path) -> Path:
|
|
1067
|
+
"""Copy an in-place workspace out to ``artifact_dir/<task_id>`` (docker WORKDIR mode).
|
|
1068
|
+
|
|
1069
|
+
Sibling to :meth:`preserve_to`, but COPIES instead of ``shutil.move``: the
|
|
1070
|
+
sandbox here is the container's own WORKDIR (e.g. ``/root``), which is
|
|
1071
|
+
discarded with ``--rm``, and the orchestrator's own cwd may sit under it --
|
|
1072
|
+
so a copy is safe and non-destructive. ``symlinks=True`` +
|
|
1073
|
+
``ignore_dangling_symlinks=True`` makes a dangling symlink a no-op rather
|
|
1074
|
+
than a failure (the exact breakage the old ``cp -a "$PWD/." "/root/"``
|
|
1075
|
+
reconciliation prelude hit). Grants cross-uid read on the COPY, since that
|
|
1076
|
+
is the artifact the host reads (mirrors preserve_to's grant on its dest).
|
|
1077
|
+
|
|
1078
|
+
Because the WORKDIR can be HOME (``/root``) or otherwise overlap
|
|
1079
|
+
framework mounts, we exclude framework/sensitive entries via
|
|
1080
|
+
:data:`_WORKSPACE_CAPTURE_IGNORE` -- most importantly ``.claude`` (the
|
|
1081
|
+
RW lean copy of the host ``~/.claude`` carries ``.credentials.json``;
|
|
1082
|
+
without this a ``/root`` WORKDIR would leak it into artifacts), plus
|
|
1083
|
+
``.venv``/``node_modules``/``.npm-prefix`` (sandbox-created bulk, already
|
|
1084
|
+
stripped by the standard artifacts path's post-run cleanup).
|
|
1085
|
+
|
|
1086
|
+
Returns the destination path; unlike preserve_to it does NOT repoint
|
|
1087
|
+
``self.sandbox_dir`` -- the workspace persists in-container and is reaped
|
|
1088
|
+
with the container, and ``_cleanup_on_exit`` is already False (run-in-place).
|
|
1089
|
+
"""
|
|
1090
|
+
if not self.sandbox_dir:
|
|
1091
|
+
raise RuntimeError("Sandbox not set up")
|
|
1092
|
+
|
|
1093
|
+
# task_id may contain "/" (dataset row tasks); ensure the parent exists.
|
|
1094
|
+
preserve_path = artifact_dir / self.task_id
|
|
1095
|
+
preserve_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1096
|
+
|
|
1097
|
+
# Guard against a self-referential copy (workspace already at target).
|
|
1098
|
+
if self.sandbox_dir.resolve() == preserve_path.resolve():
|
|
1099
|
+
_grant_read_traverse(preserve_path)
|
|
1100
|
+
return preserve_path
|
|
1101
|
+
|
|
1102
|
+
if preserve_path.exists():
|
|
1103
|
+
shutil.rmtree(preserve_path)
|
|
1104
|
+
shutil.copytree(
|
|
1105
|
+
self.sandbox_dir,
|
|
1106
|
+
preserve_path,
|
|
1107
|
+
symlinks=True,
|
|
1108
|
+
ignore_dangling_symlinks=True,
|
|
1109
|
+
ignore=shutil.ignore_patterns(*_WORKSPACE_CAPTURE_IGNORE),
|
|
1110
|
+
)
|
|
1111
|
+
_grant_read_traverse(preserve_path)
|
|
1112
|
+
return preserve_path
|
|
1113
|
+
|
|
1114
|
+
def cleanup(self, preserve: bool = False) -> None:
|
|
1115
|
+
"""Clean up the sandbox environment.
|
|
1116
|
+
|
|
1117
|
+
Args:
|
|
1118
|
+
preserve: If True, skip cleanup (caller should use preserve_to() explicitly)
|
|
1119
|
+
|
|
1120
|
+
Note:
|
|
1121
|
+
If you want to preserve the sandbox, call preserve_to() before cleanup().
|
|
1122
|
+
The preserve parameter just skips deletion for manual inspection.
|
|
1123
|
+
"""
|
|
1124
|
+
if self.sandbox_dir and self.sandbox_dir.exists() and self._cleanup_on_exit and not preserve:
|
|
1125
|
+
shutil.rmtree(self.sandbox_dir)
|
|
1126
|
+
self.sandbox_dir = None
|
|
1127
|
+
self.venv_dir = None
|