diff --git a/docs/api-reference.md b/docs/api-reference.md index 5248459..ea131f4 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -125,6 +125,10 @@ Trial.run() ├─ setup() — resolve config, create env object ├─ start() — spin up sandbox, upload task files, start services ├─ install_agent() — install agent binary, credentials, sandbox user + │ (sandbox user setup: create non-root user, prepare + │ small config/auth dirs, chown the workspace — no + │ recursive copy of /root tool trees; agent binaries + │ must live on shared prefixes like /usr/local/bin) ├─ for scene in scenes: │ └─ _run_scene(scene) │ ├─ setup /app/.outbox/ — (multi-role scenes only) diff --git a/docs/task-authoring.md b/docs/task-authoring.md index adfb641..9081987 100644 --- a/docs/task-authoring.md +++ b/docs/task-authoring.md @@ -50,6 +50,8 @@ env = { OPENAI_API_KEY = "${OPENAI_API_KEY}" } # host vars to injec **Built-in mock services** — if the Dockerfile references a service binary (`claw-gmail`, `claw-slack`, `claw-gcal`, `claw-gdoc`, `claw-gdrive`), BenchFlow starts it automatically. No `[services]` section needed. +**Install tooling to shared prefixes, not `/root`** — when a task image ships Node.js, Python tools, or agent binaries that the sandbox user must execute, install them to `/usr/local/bin`, `/usr/local/lib`, or `/opt`, not `/root/.nvm` or `/root/.local/bin`. `setup_sandbox_user()` creates the non-root user, prepares small config/auth dirs, and chowns the workspace — it does not clone `/root` into the sandbox home. Legacy images that already install tools under `/root` still work via a narrow symlink fallback, but shared prefixes are the supported path. Pre-creating the sandbox user in the Dockerfile is an optional speedup, not a requirement. + --- ## instruction.md diff --git a/src/benchflow/_agent_setup.py b/src/benchflow/_agent_setup.py index 0074d94..0c04a5a 100644 --- a/src/benchflow/_agent_setup.py +++ b/src/benchflow/_agent_setup.py @@ -30,6 +30,47 @@ logger = logging.getLogger(__name__) +def _skill_link_cmd(source: str, dest: str) -> str: + """Link a shared skills tree into an agent discovery path.""" + if source == dest: + return f"mkdir -p {shlex.quote(dest)}" + + parent = shlex.quote(str(Path(dest).parent)) + q_source = shlex.quote(source) + q_dest = shlex.quote(dest) + return ( + f"mkdir -p {parent} && " + f"rm -rf {q_dest} && " + f"ln -sfn {q_source} {q_dest}" + ) + + +async def _link_skill_paths(env, source: str, skill_paths: list[str], home: str, cwd: str) -> int: + """Link one shared skills tree into each configured discovery path.""" + parts = [] + for sp in skill_paths: + expanded = sp.replace("$HOME", home).replace("$WORKSPACE", cwd) + parts.append(_skill_link_cmd(source, expanded)) + if parts: + cmd = " && ".join(parts) + result = await env.exec(cmd, timeout_sec=15) + if result.return_code != 0: + stdout = (getattr(result, "stdout", "") or "").strip() + stderr = (getattr(result, "stderr", "") or "").strip() + details = [ + f"exit code {result.return_code}", + f"command: {cmd}", + ] + if stdout: + details.append(f"stdout: {stdout}") + if stderr: + details.append(f"stderr: {stderr}") + raise RuntimeError( + f"Failed to link skills from {source}: {'; '.join(details)}" + ) + return len(parts) + + async def install_agent(env, agent: str, trial_dir: Path) -> AgentConfig | None: """Install agent in sandbox and return its config.""" agent_base = agent.split()[0] @@ -86,6 +127,9 @@ async def deploy_skills( task: "Task", ) -> None: """Deploy and distribute skills into sandbox.""" + task_skills_dir = task.config.environment.skills_dir + effective_skills = task_skills_dir + # Runtime upload (fallback if not baked into Dockerfile) if skills_dir: dockerfile = task_path / "environment" / "Dockerfile" @@ -98,38 +142,24 @@ async def deploy_skills( if skills_path.is_dir(): logger.info(f"Deploying skills via runtime upload from {skills_path}") await env.upload_dir(skills_path, "/skills") - if agent_cfg and agent_cfg.skill_paths: - parts = [] - for sp in agent_cfg.skill_paths: - expanded = sp.replace("$HOME", "/root").replace( - "$WORKSPACE", "/app" - ) - parent = str(Path(expanded).parent) - parts.append( - f"mkdir -p '{parent}' && ln -sf /skills '{expanded}'" - ) - await env.exec(" && ".join(parts), timeout_sec=10) - logger.info("Skills deployed to /skills and symlinked") + logger.info("Skills deployed to /skills") + effective_skills = "/skills" else: logger.warning(f"Skills dir not found: {skills_path}") else: logger.info("Skills already injected via Dockerfile") # Distribute to agent-specific discovery paths - task_skills_dir = task.config.environment.skills_dir - effective_skills = "/skills" if skills_dir else task_skills_dir if effective_skills and agent_cfg and agent_cfg.skill_paths: home = f"/home/{sandbox_user}" if sandbox_user else "/root" - parts = [] - for sp in agent_cfg.skill_paths: - expanded = sp.replace("$HOME", home).replace("$WORKSPACE", agent_cwd) - q_expanded = shlex.quote(expanded) - q_skills = shlex.quote(effective_skills) - parts.append( - f"mkdir -p {q_expanded} && cp -r {q_skills}/. {q_expanded}/ 2>/dev/null" - ) - if parts: - await env.exec("; ".join(parts), timeout_sec=15) + count = await _link_skill_paths( + env, + effective_skills, + agent_cfg.skill_paths, + home, + agent_cwd, + ) + if count: logger.info( - f"Skills distributed to {len(parts)} paths for {agent_cfg.name}" + f"Skills distributed to {count} paths for {agent_cfg.name}" ) diff --git a/src/benchflow/_sandbox.py b/src/benchflow/_sandbox.py index 130a0c3..63fbcde 100644 --- a/src/benchflow/_sandbox.py +++ b/src/benchflow/_sandbox.py @@ -1,7 +1,7 @@ """Sandbox user setup, path lockdown, and verifier hardening. Owns the "agent runs as non-root" lifecycle: - - Creating the sandbox user and copying root's tooling into its home + - Creating the sandbox user and preparing minimal home state it needs - Building the privilege-drop wrapper (setpriv / su) for agent launch - Locking down solution/test paths so the sandbox user cannot read them - Hardening the environment before the verifier runs @@ -98,6 +98,20 @@ def build_priv_drop_cmd(agent_launch: str, sandbox_user: str) -> str: ) +def _legacy_root_tool_link_cmd(source: str, dest: str) -> str: + """Link legacy root-only tool dirs into the sandbox home when needed.""" + src = shlex.quote(source) + dst = shlex.quote(dest) + parent = shlex.quote(str(Path(dest).parent)) + return ( + f"if [ -e {src} ] && [ ! -L {dst} ]; then " + f"mkdir -p {parent} && " + f"rmdir {dst} 2>/dev/null || true; " + f"[ -e {dst} ] || ln -s {src} {dst}; " + "fi" + ) + + async def setup_sandbox_user( env, sandbox_user: str, workspace: str, *, timeout_sec: int = 120 ) -> str: @@ -107,18 +121,17 @@ async def setup_sandbox_user( f"Invalid sandbox_user: {sandbox_user!r} (must be alphanumeric)" ) logger.info(f"Setting up sandbox user: {sandbox_user}") + home = f"/home/{sandbox_user}" + home_dirs = sorted(d for d in get_sandbox_home_dirs() if d != ".local") await env.exec( f"id -u {sandbox_user} >/dev/null 2>&1 || " f"useradd -m -s /bin/bash {sandbox_user} && " - f"mkdir -p /home/{sandbox_user}/.local/bin && " - "if [ -d /root/.local/bin ]; then " - f"cp -aL /root/.local/bin/. /home/{sandbox_user}/.local/bin/ 2>/dev/null || true; fi && " - "if [ -d /root/.nvm ]; then " - f"cp -a /root/.nvm/. /home/{sandbox_user}/.nvm/ 2>/dev/null || true; fi && " - f"for d in {' '.join(sorted(get_sandbox_home_dirs()))}; do " - f"if [ -d /root/$d ]; then mkdir -p /home/{sandbox_user}/$d && " - f"cp -a /root/$d/. /home/{sandbox_user}/$d/ 2>/dev/null || true; fi; done && " - f"chown -R {sandbox_user}:{sandbox_user} /home/{sandbox_user} && " + f"{_legacy_root_tool_link_cmd('/root/.local/bin', f'{home}/.local/bin')} && " + f"{_legacy_root_tool_link_cmd('/root/.nvm', f'{home}/.nvm')} && " + f"for d in {' '.join(home_dirs)}; do " + f"if [ -d /root/$d ]; then mkdir -p {home}/$d && " + f"cp -a /root/$d/. {home}/$d/ 2>/dev/null || true; fi; done && " + f"chown -R {sandbox_user}:{sandbox_user} {home} && " f"chown -R {sandbox_user}:{sandbox_user} {shlex.quote(workspace)}", timeout_sec=timeout_sec, ) diff --git a/src/benchflow/agents/registry.py b/src/benchflow/agents/registry.py index 9b977de..ca24348 100644 --- a/src/benchflow/agents/registry.py +++ b/src/benchflow/agents/registry.py @@ -374,21 +374,18 @@ class AgentConfig: def get_sandbox_home_dirs() -> set[str]: - """Collect all dot-dirs under $HOME that sandbox user setup should copy. + """Collect user home config/auth dirs BenchFlow may materialize for the sandbox user. Derives from three sources across all registered agents: - - skill_paths: $HOME/.foo/... → ".foo" - credential_files: {home}/.foo/... → ".foo" + - subscription_auth.files: {home}/.foo/... → ".foo" - home_dirs: explicit extras (e.g. ".openclaw") - Always includes ".local" (pip scripts, etc.). + Skill paths are excluded: deploy_skills() now links those paths directly to a + shared skills tree instead of relying on sandbox-home copies. """ - dirs: set[str] = {".local"} + dirs: set[str] = set() for cfg in AGENTS.values(): - for sp in cfg.skill_paths: - if sp.startswith("$HOME/."): - dirname = sp.removeprefix("$HOME/").split("/")[0] - dirs.add(dirname) for cf in cfg.credential_files: # path uses {home}/.foo/... placeholder path = cf.path diff --git a/tests/test_agent_setup.py b/tests/test_agent_setup.py index ce00d6b..3b99ce6 100644 --- a/tests/test_agent_setup.py +++ b/tests/test_agent_setup.py @@ -1,13 +1,151 @@ +"""Tests for agent install and skill deployment setup helpers.""" + from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock import pytest -from benchflow._agent_setup import install_agent +from benchflow._agent_setup import deploy_skills, install_agent +from benchflow.agents.registry import AgentConfig from benchflow.models import AgentInstallError +def _make_task(skills_dir: str | None): + return SimpleNamespace( + config=SimpleNamespace( + environment=SimpleNamespace( + skills_dir=skills_dir, + ) + ) + ) + + +@pytest.mark.asyncio +async def test_deploy_skills_symlinks_agent_skill_paths_instead_of_copying(tmp_path): + env = MagicMock() + env.exec = AsyncMock(return_value=MagicMock(return_code=0, stdout="")) + env.upload_dir = AsyncMock() + agent_cfg = AgentConfig( + name="test-agent", + install_cmd="true", + launch_cmd="true", + skill_paths=["$HOME/.agents/skills", "$WORKSPACE/skills"], + ) + + await deploy_skills( + env=env, + task_path=tmp_path, + skills_dir=None, + agent_cfg=agent_cfg, + sandbox_user="agent", + agent_cwd="/app", + task=_make_task("/opt/benchflow/skills"), + ) + + env.upload_dir.assert_not_called() + env.exec.assert_awaited_once() + + cmd = env.exec.await_args.args[0] + assert "cp -r" not in cmd + assert " && " in cmd + assert ";" not in cmd + assert "ln -sfn /opt/benchflow/skills /home/agent/.agents/skills" in cmd + assert "ln -sfn /opt/benchflow/skills /app/skills" in cmd + + +@pytest.mark.asyncio +async def test_deploy_skills_uploads_runtime_skills_and_links_shared_tree(tmp_path): + env = MagicMock() + env.exec = AsyncMock(return_value=MagicMock(return_code=0, stdout="")) + env.upload_dir = AsyncMock() + agent_cfg = AgentConfig( + name="test-agent", + install_cmd="true", + launch_cmd="true", + skill_paths=["$HOME/.agents/skills", "$WORKSPACE/skills"], + ) + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + + await deploy_skills( + env=env, + task_path=tmp_path, + skills_dir=skills_dir, + agent_cfg=agent_cfg, + sandbox_user="agent", + agent_cwd="/workspace", + task=_make_task("/opt/benchflow/skills"), + ) + + env.upload_dir.assert_awaited_once_with(skills_dir, "/skills") + env.exec.assert_awaited_once() + + distributed_link_cmd = env.exec.await_args.args[0] + assert " && " in distributed_link_cmd + assert ";" not in distributed_link_cmd + assert "ln -sfn /skills /home/agent/.agents/skills" in distributed_link_cmd + assert "ln -sfn /skills /workspace/skills" in distributed_link_cmd + assert "/root/.agents/skills" not in distributed_link_cmd + assert "/app/skills" not in distributed_link_cmd + + +@pytest.mark.asyncio +async def test_deploy_skills_falls_back_when_local_skills_dir_is_missing(tmp_path): + env = MagicMock() + env.exec = AsyncMock(return_value=MagicMock(return_code=0, stdout="")) + env.upload_dir = AsyncMock() + agent_cfg = AgentConfig( + name="test-agent", + install_cmd="true", + launch_cmd="true", + skill_paths=["$HOME/.agents/skills", "$WORKSPACE/skills"], + ) + + await deploy_skills( + env=env, + task_path=tmp_path, + skills_dir=tmp_path / "missing-skills", + agent_cfg=agent_cfg, + sandbox_user="agent", + agent_cwd="/workspace", + task=_make_task("/opt/benchflow/skills"), + ) + + env.upload_dir.assert_not_called() + env.exec.assert_awaited_once() + + distributed_link_cmd = env.exec.await_args.args[0] + assert "ln -sfn /opt/benchflow/skills /home/agent/.agents/skills" in distributed_link_cmd + assert "ln -sfn /opt/benchflow/skills /workspace/skills" in distributed_link_cmd + assert "ln -sfn /skills /home/agent/.agents/skills" not in distributed_link_cmd + assert "ln -sfn /skills /workspace/skills" not in distributed_link_cmd + + +@pytest.mark.asyncio +async def test_deploy_skills_raises_when_skill_linking_fails(tmp_path): + env = MagicMock() + env.exec = AsyncMock(return_value=MagicMock(return_code=17, stdout="link failed")) + env.upload_dir = AsyncMock() + agent_cfg = AgentConfig( + name="test-agent", + install_cmd="true", + launch_cmd="true", + skill_paths=["$HOME/.agents/skills"], + ) + + with pytest.raises(RuntimeError, match="Failed to link skills"): + await deploy_skills( + env=env, + task_path=tmp_path, + skills_dir=None, + agent_cfg=agent_cfg, + sandbox_user="agent", + agent_cwd="/app", + task=_make_task("/opt/benchflow/skills"), + ) + + @pytest.mark.asyncio async def test_install_agent_writes_command_stdout_and_stderr_on_failure(tmp_path: Path): env = SimpleNamespace() diff --git a/tests/test_registry_invariants.py b/tests/test_registry_invariants.py index 5b708d3..868ffb3 100644 --- a/tests/test_registry_invariants.py +++ b/tests/test_registry_invariants.py @@ -76,6 +76,22 @@ def test_agent_collection_invariants(name, cfg): assert d.startswith("."), f"home_dirs entry {d!r} must start with '.'" +@pytest.mark.parametrize("name,cfg", AGENTS.items(), ids=list(AGENTS.keys())) +def test_agent_install_cmd_targets_shared_paths(name, cfg): + """Installed binaries must land in shared prefixes, not a root-only home. + + setup_sandbox_user() no longer recursively copies /root/.nvm or + /root/.local/bin into the sandbox home. If an install_cmd placed its + binary there, the sandbox user would silently lose access to the agent. + """ + forbidden_binary_prefixes = ("/root/.nvm/", "/root/.local/bin/", "$HOME/.nvm/") + for prefix in forbidden_binary_prefixes: + assert prefix not in cfg.install_cmd, ( + f"{name!r} install_cmd writes under {prefix!r}; use /usr/local/bin " + f"or another shared prefix so the sandbox user inherits the tool" + ) + + @pytest.mark.parametrize("name,cfg", AGENTS.items(), ids=list(AGENTS.keys())) def test_agent_credential_and_subscription_auth(name, cfg): """Optional credential_files and subscription_auth structures.""" diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 4c7f9f2..36a8c80 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -1,23 +1,21 @@ -"""Tests for sandbox user directory derivation from agent registry.""" +"""Tests for sandbox user config/auth directory derivation from agent registry.""" from benchflow.agents.registry import ( AGENTS, AgentConfig, + HostAuthFile, + SubscriptionAuth, get_sandbox_home_dirs, ) class TestSandboxDirs: - def test_dirs_derived_from_skill_paths(self): - """All $HOME skill_paths dirs from AGENTS registry are included.""" + def test_skill_only_dirs_not_included(self): + """Skill-only home dirs should not be copied during sandbox user setup.""" dirs = get_sandbox_home_dirs() - # claude-agent-acp has $HOME/.claude/skills - assert ".claude" in dirs - # gemini has $HOME/.gemini/skills - assert ".gemini" in dirs - # pi-acp has $HOME/.pi/agent/skills and $HOME/.agents/skills - assert ".pi" in dirs - assert ".agents" in dirs + + assert ".pi" not in dirs + assert ".agents" not in dirs def test_credential_file_dirs_included(self): """Dirs from credential_files paths are included.""" @@ -31,13 +29,25 @@ def test_home_dirs_included(self): # openclaw has home_dirs=[".openclaw"] assert ".openclaw" in dirs - def test_always_includes_local(self): - """.local is always in the dir list.""" + def test_does_not_include_legacy_local_tool_dir(self): + """.local is not included unless an agent registry path derives it.""" + dirs = get_sandbox_home_dirs() + assert ".local" not in dirs + + def test_only_includes_top_level_home_dirs(self): + """Derived entries stay at $HOME top-level, not nested tool subpaths.""" dirs = get_sandbox_home_dirs() - assert ".local" in dirs + assert ".local/bin" not in dirs - def test_new_agent_auto_included(self): - """Adding an agent with skill_paths=$HOME/.newagent/skills includes .newagent.""" + def test_dirs_represent_registry_backed_home_config_or_auth(self): + """Returned dirs are registry-derived user home config/auth roots.""" + dirs = get_sandbox_home_dirs() + assert {".claude", ".codex", ".gemini", ".openclaw"}.issubset(dirs) + assert ".agents" not in dirs + assert ".pi" not in dirs + + def test_new_agent_skill_path_not_auto_included(self): + """Skill-only home dirs should not become sandbox copy targets.""" AGENTS["_test_agent"] = AgentConfig( name="_test_agent", install_cmd="true", @@ -46,10 +56,33 @@ def test_new_agent_auto_included(self): ) try: dirs = get_sandbox_home_dirs() - assert ".newagent" in dirs + assert ".newagent" not in dirs finally: del AGENTS["_test_agent"] + def test_subscription_auth_file_dirs_included(self): + """Dirs from subscription_auth.files container paths are included.""" + AGENTS["_test_agent_subscription_auth"] = AgentConfig( + name="_test_agent_subscription_auth", + install_cmd="true", + launch_cmd="true", + subscription_auth=SubscriptionAuth( + replaces_env="TEST_API_KEY", + detect_file="~/.subauth/login.json", + files=[ + HostAuthFile( + "~/.subauth/login.json", + "{home}/.subauth/login.json", + ) + ], + ), + ) + try: + dirs = get_sandbox_home_dirs() + assert ".subauth" in dirs + finally: + del AGENTS["_test_agent_subscription_auth"] + def test_workspace_paths_excluded(self): """$WORKSPACE paths are not included (only $HOME paths).""" dirs = get_sandbox_home_dirs() diff --git a/tests/test_sandbox_setup.py b/tests/test_sandbox_setup.py new file mode 100644 index 0000000..f81b392 --- /dev/null +++ b/tests/test_sandbox_setup.py @@ -0,0 +1,106 @@ +"""Focused contract tests for setup_sandbox_user() shell command generation.""" + +import re +import shlex +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from benchflow._sandbox import setup_sandbox_user +from benchflow.agents.registry import get_sandbox_home_dirs + + +async def _run_setup_sandbox_user(*, sandbox_user: str = "agent", workspace: str = "/app"): + env = MagicMock() + env.exec = AsyncMock(return_value=MagicMock(stdout="", stderr="", exit_code=0)) + + await setup_sandbox_user(env, sandbox_user, workspace) + + env.exec.assert_awaited_once() + return env.exec.call_args.args[0], env.exec.call_args.kwargs + + +def _assert_conditional_legacy_symlink(cmd: str, *, source: str, dest: str) -> None: + """Legacy tool dirs should link only when a root-only install exists.""" + assert re.search( + rf"if \[ -e [\"']?{re.escape(source)}[\"']? \].*ln -s(?:f|[a-zA-Z-])* [\"']?{re.escape(source)}[\"']? [\"']?{re.escape(dest)}[\"']?.*fi", + cmd, + ), f"expected explicit symlink from {source} to {dest} in setup command: {cmd}" + + +def _get_copy_loop_dirs(cmd: str) -> list[str]: + """Extract the general home-dir copy loop payload from the shell command.""" + match = re.search(r"for d in (?P.*?); do", cmd) + assert match, f"expected general home-dir copy loop in setup command: {cmd}" + return match.group("dirs").split() + + +class TestSetupSandboxUser: + @pytest.mark.asyncio + async def test_setup_command_avoids_recursive_root_tool_copies(self): + """Heavy root-owned tool dirs should no longer be recursively copied.""" + cmd, kwargs = await _run_setup_sandbox_user() + + assert "cp -aL /root/.local/bin/." not in cmd + assert "cp -a /root/.nvm/." not in cmd + assert kwargs["timeout_sec"] == 120 + + @pytest.mark.asyncio + async def test_setup_command_still_creates_user_prepares_home_and_chowns_workspace(self): + """The non-copy setup contract still creates the user and grants access.""" + cmd, _ = await _run_setup_sandbox_user() + + assert "id -u agent >/dev/null 2>&1 || useradd -m -s /bin/bash agent" in cmd + assert "mkdir -p /home/agent/.local/bin" not in cmd + assert "chown -R agent:agent /home/agent" in cmd + assert f"chown -R agent:agent {shlex.quote('/app')}" in cmd + + @pytest.mark.asyncio + async def test_setup_command_keeps_heavy_root_tool_dirs_on_shared_paths(self): + """Legacy root-only tool dirs should use conditional symlinks, not duplication.""" + cmd, _ = await _run_setup_sandbox_user() + + _assert_conditional_legacy_symlink( + cmd, + source="/root/.local/bin", + dest="/home/agent/.local/bin", + ) + _assert_conditional_legacy_symlink( + cmd, source="/root/.nvm", dest="/home/agent/.nvm" + ) + assert "cp -aL /root/.local/bin/. /home/agent/.local/bin/" not in cmd + assert "cp -a /root/.nvm/. /home/agent/.nvm/" not in cmd + + @pytest.mark.asyncio + async def test_setup_command_copy_loop_excludes_local_dir(self): + """General home-dir copying should narrow to small config/auth dirs only.""" + cmd, _ = await _run_setup_sandbox_user() + + copy_loop_dirs = _get_copy_loop_dirs(cmd) + + assert copy_loop_dirs == sorted( + d for d in get_sandbox_home_dirs() if d != ".local" + ) + assert ".local" not in copy_loop_dirs + assert "mkdir -p /home/agent/$d" in cmd + assert "cp -a /root/$d/. /home/agent/$d/ 2>/dev/null || true" in cmd + + @pytest.mark.asyncio + async def test_setup_command_does_not_copy_heavy_tool_trees_into_home(self): + """BenchFlow-installed agents must not rely on sandbox-home tool copies. + + Agent binaries are placed in /usr/local/bin by the registered install_cmd + values, so setup_sandbox_user() must not bulk-copy heavyweight tool trees + (e.g. /root/.nvm, /root/.local/bin) to make them executable for the user. + """ + cmd, _ = await _run_setup_sandbox_user() + + for heavy_source in ("/root/.nvm", "/root/.local/bin"): + assert f"cp -a {heavy_source}/." not in cmd + assert f"cp -aL {heavy_source}/." not in cmd + + copy_loop_dirs = _get_copy_loop_dirs(cmd) + for heavy_dir in (".nvm", ".local"): + assert heavy_dir not in copy_loop_dirs, ( + f"sandbox copy loop must not include heavy tool dir {heavy_dir!r}" + )