diff --git a/run_agent.py b/run_agent.py index a3da633bbab17..1435e704a4ce4 100644 --- a/run_agent.py +++ b/run_agent.py @@ -8223,6 +8223,7 @@ def _dispatch_delegate_task(self, function_args: dict) -> str: tasks=_strip_model_hidden_task_fields(function_args.get("tasks")), max_iterations=function_args.get("max_iterations"), role=function_args.get("role"), + sandbox=function_args.get("sandbox"), background=(not _is_subagent), action=function_args.get("action"), subagent_id=function_args.get("subagent_id"), diff --git a/tests/tools/test_delegate_sandbox_isolation.py b/tests/tools/test_delegate_sandbox_isolation.py new file mode 100644 index 0000000000000..459edee810594 --- /dev/null +++ b/tests/tools/test_delegate_sandbox_isolation.py @@ -0,0 +1,334 @@ +"""Per-subagent terminal sandbox isolation (delegate_task(sandbox=True)). + +Feature issue #4271: parallel subagents share the parent's single terminal +backend — concurrent `cd`, env mutations, and writes to the same path +collide. This pins the opt-in fix: + +* `delegate_task(sandbox=True)` (top-level or per-task) gives each sandboxed + child its own container via `register_task_env_overrides()`; the child's + task_id no longer collapses to the parent's container key. +* Non-sandboxed children keep the documented shared-parent-container + contract (alias registration unchanged). +* local/ssh/vercel_sandbox backends cannot isolate: `sandbox=True` fails + loudly (tool_error at entry, ValueError at spawn) instead of silently + degrading to the shared sandbox. +* Overrides are cleared at child teardown so the registry cannot leak. +""" + +import json +import threading +from unittest.mock import MagicMock, patch + +import pytest + +from tools import delegate_tool, terminal_tool +from tools.delegate_tool import ( + DELEGATE_TASK_SCHEMA, + _clear_child_sandbox, + _register_child_sandbox_overrides, + _seed_child_terminal_env, + delegate_task, +) + +_IMAGE = "nikolaik/python-nodejs:python3.11-nodejs20" + + +def _make_mock_parent(depth=0): + """Mock parent agent with the fields delegate_task expects.""" + parent = MagicMock() + parent.base_url = "https://openrouter.ai/api/v1" + parent.api_key = "***" + parent.provider = "openrouter" + parent.api_mode = "chat_completions" + parent.model = "anthropic/claude-sonnet-4" + parent.platform = "cli" + parent.providers_allowed = None + parent.providers_ignored = None + parent.providers_order = None + parent.provider_sort = None + parent._session_db = None + parent._delegate_depth = depth + parent._active_children = [] + parent._active_children_lock = threading.Lock() + parent._print_fn = None + parent.tool_progress_callback = None + parent.thinking_callback = None + return parent + + +@pytest.fixture(autouse=True) +def _clean_state(monkeypatch): + """Reset the module-level registries and pin the config→env bridge.""" + before_overrides = dict(terminal_tool._task_env_overrides) + terminal_tool._task_env_overrides.clear() + with terminal_tool._container_alias_lock: + before_aliases = dict(terminal_tool._container_aliases) + terminal_tool._container_aliases.clear() + with terminal_tool._session_cwd_lock: + before_cwd = dict(terminal_tool._session_cwd) + terminal_tool._session_cwd.clear() + # The config→env bridge is one-shot; mark it done so tests control env vars. + monkeypatch.setattr(terminal_tool, "_terminal_config_bridge_attempted", True) + yield + terminal_tool._task_env_overrides.clear() + terminal_tool._task_env_overrides.update(before_overrides) + with terminal_tool._container_alias_lock: + terminal_tool._container_aliases.clear() + terminal_tool._container_aliases.update(before_aliases) + with terminal_tool._session_cwd_lock: + terminal_tool._session_cwd.clear() + terminal_tool._session_cwd.update(before_cwd) + + +def _set_backend(monkeypatch, env_type: str, image: str = _IMAGE): + monkeypatch.setenv("TERMINAL_ENV", env_type) + monkeypatch.setenv(f"TERMINAL_{env_type.upper()}_IMAGE", image) + + +class TestSandboxSchema: + """The model-facing schema exposes the opt-in flag.""" + + def test_top_level_sandbox_property(self): + props = DELEGATE_TASK_SCHEMA["parameters"]["properties"] + assert "sandbox" in props + assert props["sandbox"]["type"] == "boolean" + + def test_per_task_sandbox_property(self): + items = DELEGATE_TASK_SCHEMA["parameters"]["properties"]["tasks"]["items"] + props = items["properties"] + assert "sandbox" in props + assert props["sandbox"]["type"] == "boolean" + + +class TestRegisterChildSandboxOverrides: + """_register_child_sandbox_overrides registers isolation triggers.""" + + def test_docker_registers_env_type_and_image(self, monkeypatch): + _set_backend(monkeypatch, "docker", _IMAGE) + _register_child_sandbox_overrides("subagent-1") + assert terminal_tool._task_env_overrides["subagent-1"] == { + "env_type": "docker", + "docker_image": _IMAGE, + } + + def test_modal_registers_modal_image(self, monkeypatch): + _set_backend(monkeypatch, "modal", "hermes-modal") + _register_child_sandbox_overrides("subagent-1") + assert terminal_tool._task_env_overrides["subagent-1"] == { + "env_type": "modal", + "modal_image": "hermes-modal", + } + + def test_daytona_registers_daytona_image(self, monkeypatch): + _set_backend(monkeypatch, "daytona", "hermes-daytona") + _register_child_sandbox_overrides("subagent-1") + assert terminal_tool._task_env_overrides["subagent-1"] == { + "env_type": "daytona", + "daytona_image": "hermes-daytona", + } + + def test_singularity_registers_singularity_image(self, monkeypatch): + _set_backend(monkeypatch, "singularity", "docker://hermes-singularity") + _register_child_sandbox_overrides("subagent-1") + assert terminal_tool._task_env_overrides["subagent-1"] == { + "env_type": "singularity", + "singularity_image": "docker://hermes-singularity", + } + + def test_local_backend_raises(self, monkeypatch): + _set_backend(monkeypatch, "local") + with pytest.raises(ValueError, match="container terminal backend"): + _register_child_sandbox_overrides("subagent-1") + + def test_ssh_backend_raises(self, monkeypatch): + _set_backend(monkeypatch, "ssh") + with pytest.raises(ValueError, match="container terminal backend"): + _register_child_sandbox_overrides("subagent-1") + + def test_vercel_sandbox_raises(self, monkeypatch): + _set_backend(monkeypatch, "vercel_sandbox") + with pytest.raises(ValueError, match="container terminal backend"): + _register_child_sandbox_overrides("subagent-1") + + def test_override_triggers_isolation_keying(self, monkeypatch): + """A registered override makes _resolve_container_task_id return the + child's own task_id (its own container) instead of the parent key.""" + _set_backend(monkeypatch, "docker", _IMAGE) + _register_child_sandbox_overrides("subagent-1") + assert terminal_tool._resolve_container_task_id("subagent-1") == "subagent-1" + assert terminal_tool.resolve_task_overrides("subagent-1")["docker_image"] == _IMAGE + + def test_parent_task_image_wins_over_process_config(self, monkeypatch): + """A per-task image registered on the parent session (RL rollouts, + ACP workspaces) is inherited by the sandboxed child.""" + _set_backend(monkeypatch, "docker", _IMAGE) + terminal_tool.register_task_env_overrides( + "tui:sess-a", {"docker_image": "custom/parent-image:latest"} + ) + _register_child_sandbox_overrides("subagent-1", "tui:sess-a") + assert terminal_tool._task_env_overrides["subagent-1"]["docker_image"] == ( + "custom/parent-image:latest" + ) + + def test_no_parent_task_falls_back_to_process_config(self, monkeypatch): + _set_backend(monkeypatch, "docker", _IMAGE) + _register_child_sandbox_overrides("subagent-1", None) + assert terminal_tool._task_env_overrides["subagent-1"]["docker_image"] == _IMAGE + + +class TestSeedChildTerminalEnv: + """Spawn wiring: sandbox children get overrides, others get the alias.""" + + def test_shared_path_registers_alias_and_seeds_cwd(self, monkeypatch): + calls = {"alias": 0, "sandbox": 0} + monkeypatch.setattr( + terminal_tool, "register_container_alias", + lambda *a, **k: calls.__setitem__("alias", calls["alias"] + 1), + ) + monkeypatch.setattr( + terminal_tool, "get_session_cwd", lambda task_id: "/parent/dir", + ) + monkeypatch.setattr( + terminal_tool, "record_session_cwd", lambda *a, **k: None, + ) + monkeypatch.setattr( + delegate_tool, "_register_child_sandbox_overrides", + lambda *a, **k: calls.__setitem__("sandbox", calls["sandbox"] + 1), + ) + + _seed_child_terminal_env("subagent-1", "tui:sess-a", sandbox=False) + + assert calls["alias"] == 1 + assert calls["sandbox"] == 0 + assert terminal_tool.get_session_cwd("subagent-1") == "/parent/dir" + + def test_sandbox_path_registers_overrides_not_alias(self, monkeypatch): + calls = {"alias": 0, "sandbox": 0} + monkeypatch.setattr( + terminal_tool, "register_container_alias", + lambda *a, **k: calls.__setitem__("alias", calls["alias"] + 1), + ) + monkeypatch.setattr( + terminal_tool, "get_session_cwd", lambda task_id: None, + ) + monkeypatch.setattr( + terminal_tool, "record_session_cwd", lambda *a, **k: None, + ) + monkeypatch.setattr( + delegate_tool, "_register_child_sandbox_overrides", + lambda *a, **k: calls.__setitem__("sandbox", calls["sandbox"] + 1), + ) + + _seed_child_terminal_env("subagent-1", "tui:sess-a", sandbox=True) + + assert calls["sandbox"] == 1 + assert calls["alias"] == 0 + + +class TestDelegateTaskEntryValidation: + """sandbox=True fails fast on backends that cannot isolate.""" + + def test_local_backend_tool_error(self, monkeypatch): + _set_backend(monkeypatch, "local") + parent = _make_mock_parent() + result = json.loads(delegate_task(goal="do the thing", sandbox=True, parent_agent=parent)) + assert "requires a container terminal backend" in result["error"] + assert "local" in result["error"] + + def test_string_false_per_task_is_not_sandboxed(self, monkeypatch): + """A model-emitted string 'false' must not coerce to True (bool('false') + would); the shared truthy parser is the single owner of coercion.""" + _set_backend(monkeypatch, "local") + parent = _make_mock_parent() + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.run_conversation.return_value = { + "final_response": "ok", + "completed": True, + "api_calls": 1, + } + MockAgent.return_value = mock_child + result = json.loads( + delegate_task( + tasks=[ + {"goal": "First task with a longer self-contained goal", "sandbox": "false"}, + {"goal": "Second task with a longer self-contained goal"}, + ], + parent_agent=parent, + ) + ) + # sandbox="false" is not a sandbox request -> entry validation must + # NOT reject on backend; the children run normally. + assert "error" not in result + + def test_ssh_backend_tool_error(self, monkeypatch): + _set_backend(monkeypatch, "ssh") + parent = _make_mock_parent() + result = json.loads(delegate_task(goal="do the thing", sandbox=True, parent_agent=parent)) + assert "requires a container terminal backend" in result["error"] + + def test_per_task_sandbox_checked_too(self, monkeypatch): + _set_backend(monkeypatch, "local") + parent = _make_mock_parent() + result = json.loads( + delegate_task( + tasks=[{"goal": "a"}, {"goal": "b", "sandbox": True}], + parent_agent=parent, + ) + ) + assert "requires a container terminal backend" in result["error"] + + def test_docker_backend_passes_validation(self, monkeypatch): + """Validation passes on docker; the child runs via the mocked AIAgent.""" + _set_backend(monkeypatch, "docker", _IMAGE) + parent = _make_mock_parent() + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.run_conversation.return_value = { + "final_response": "ok", + "completed": True, + "api_calls": 1, + } + MockAgent.return_value = mock_child + result = json.loads(delegate_task(goal="sandboxed", sandbox=True, parent_agent=parent)) + assert "error" not in result + + +class TestClearChildSandbox: + def test_clears_overrides_and_alias(self, monkeypatch): + _set_backend(monkeypatch, "docker", _IMAGE) + _register_child_sandbox_overrides("subagent-1") + terminal_tool.register_container_alias("subagent-1", "tui:sess-a") + assert "subagent-1" in terminal_tool._task_env_overrides + + _clear_child_sandbox("subagent-1") + + assert "subagent-1" not in terminal_tool._task_env_overrides + assert "subagent-1" not in terminal_tool._container_aliases + + def test_noop_on_empty_task_id(self): + _clear_child_sandbox(None) + _clear_child_sandbox("") + + +class TestDispatchForwarding: + """sandbox reaches delegate_task through the live model dispatch path.""" + + def test_dispatch_forwards_sandbox(self): + import run_agent + + captured = {} + + def fake_delegate_task(**kwargs): + captured.update(kwargs) + return "{}" + + parent = _make_mock_parent() + with patch("tools.delegate_tool.delegate_task", fake_delegate_task): + run_agent.AIAgent._dispatch_delegate_task( + parent, + {"goal": "test", "sandbox": True, "tasks": [{"goal": "n", "sandbox": False}]}, + ) + + assert captured["sandbox"] is True + assert captured["tasks"][0]["sandbox"] is False diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 1141ed46d74e2..7d29642321225 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -57,6 +57,15 @@ ] ) +# Terminal backends that support per-subagent sandbox isolation via +# register_task_env_overrides(). Mirrors terminal_tool's +# _ISOLATION_OVERRIDE_KEYS coverage: each of these backends has a per-task +# image key, so a task_id with registered overrides resolves to its own +# container instead of collapsing to the parent's. local/ssh/vercel_sandbox +# cannot provide per-task sandboxes — sandbox=True on those backends fails +# loudly rather than silently degrading to the shared sandbox (issue #4271). +_SANDBOXABLE_BACKENDS = frozenset({"docker", "singularity", "modal", "daytona"}) + # --------------------------------------------------------------------------- # Subagent approval callbacks @@ -2298,12 +2307,135 @@ def _apply_summary_budget(results: List[Dict[str, Any]], parent_agent) -> None: ) +def _normalize_sandbox(value: Any, default: bool) -> bool: + """Coerce a sandbox flag from any caller surface to a bool. + + Single owner of sandbox truthiness: ``None`` falls back to *default* + (top-level default False; per-task default is the normalized top-level + value), string values use the shared truthy parser so a model-emitted + ``"false"`` behaves correctly. + """ + return is_truthy_value(value, default=default) + + +def _register_child_sandbox_overrides( + child_task_id: str, + parent_task_id: Optional[str] = None, +) -> None: + """Give *child_task_id* its own terminal sandbox (per-subagent isolation). + + Registers env overrides (active backend + its configured image) so the + child's task_id no longer collapses to the parent's container key — its + first terminal/file/execute_code call creates a fresh container instead + of joining the parent's (issue #4271). The child gets a clean filesystem + and env: no shared ``/workspace``, no inherited ``cd``s, no packages + installed by a sibling bleeding across parallel workstreams. The fresh + container reuses the parent's image (a per-task image registered on the + parent session wins over the process-level config), so the environment is + identical — just isolated. + + Raises ``ValueError`` when the active backend cannot provide per-task + sandboxes (local/ssh/vercel_sandbox). Callers must fail loudly rather + than silently degrade to the shared sandbox: an explicit ``sandbox=True`` + that lands in the parent's filesystem is a false isolation guarantee. + + The override is removed at child teardown via ``clear_task_env_overrides`` + (see ``_clear_child_sandbox``), and the container itself is removed by the + child's own ``close()`` → ``cleanup_vm`` path. + """ + from tools.terminal_tool import ( + _get_env_config, + register_task_env_overrides, + resolve_task_overrides, + ) + + config = _get_env_config() + env_type = config.get("env_type", "local") + if env_type not in _SANDBOXABLE_BACKENDS: + raise ValueError( + "sandbox isolation requires a container terminal backend " + f"(docker, singularity, modal, daytona); current backend is {env_type!r}. " + "Set terminal.backend in config.yaml to a container backend." + ) + # A per-task image registered on the PARENT session (RL/benchmark + # rollouts, ACP workspace sessions) must win over the process-level + # config: the child's fresh container should mirror the parent's actual + # environment, not the default image. + parent_overrides = resolve_task_overrides(parent_task_id) if parent_task_id else {} + overrides: Dict[str, Any] = {"env_type": env_type} + image_key = f"{env_type}_image" + image = parent_overrides.get(image_key) or config.get(image_key) + if image: + overrides[image_key] = image + register_task_env_overrides(child_task_id, overrides) + + +def _clear_child_sandbox(child_task_id: Optional[str]) -> None: + """Drop a sandboxed child's env overrides after the delegation ends. + + Best-effort: the registry must not leak per-task override entries (and + the child's container key must stop resolving after the child is gone). + A missing/empty task id is a no-op. + """ + if not child_task_id: + return + try: + from tools.terminal_tool import clear_task_env_overrides + + clear_task_env_overrides(child_task_id) + except Exception: + logger.debug("Failed to clear child sandbox overrides for %s", child_task_id, exc_info=True) + + +def _seed_child_terminal_env( + child_task_id: str, + parent_task_id: Optional[str], + sandbox: bool, +) -> None: + """Wire a child's task_id into the terminal backend at spawn. + + Shared path (default): seed the child's session-cwd record from the + parent's and register a container alias so the child resolves to the + PARENT's container (one bash, one /workspace, one set of installed + packages). Seeding the cwd preserves the parent's starting directory + while keeping the child's subsequent ``cd``s isolated in its own record + (a child's cd no longer bleeds back into the parent). + + Sandbox path: register per-task env overrides so the child's task_id + resolves to its OWN fresh container (issue #4271). Raises ``ValueError`` + on backends that cannot isolate — the spawn site must propagate it, not + swallow it: an explicit ``sandbox=True`` that silently lands in the + parent's filesystem is a false isolation guarantee. + + The seeded cwd is not a stale record: the fresh container's working + directory is derived from it (``get_session_cwd(task_id)`` feeds the + container's ``-w``), so the record and the container state agree by + construction. + """ + from tools.terminal_tool import ( + get_session_cwd, + record_session_cwd, + register_container_alias, + ) + + record_session_cwd(child_task_id, get_session_cwd(parent_task_id)) + if not sandbox: + # Per-session container isolation (docker + container_persistent: + # false) keys containers by session task_id. The child must share + # the PARENT's container — register the alias so the child's + # task_id resolves to the parent's container key. + register_container_alias(child_task_id, parent_task_id) + return + _register_child_sandbox_overrides(child_task_id, parent_task_id) + + def _run_single_child( task_index: int, goal: str, child=None, parent_agent=None, *, + sandbox: Optional[bool] = None, owner_session_id: Optional[str] = None, owner_transport: Any = None, owner_session_record: Any = None, @@ -2498,6 +2630,9 @@ def _attach_worktree(entry_dict: Dict[str, Any]) -> None: logger.debug("worktree finalize failed: %s", e) entry_dict["worktree"] = dict(_worktree_info) + # Set before the try below so the finally block can safely reference it + # even when the heartbeat thread fails to start before assignment. + child_task_id: Optional[str] = None try: _heartbeat_thread.start() if child_progress_cb: @@ -2514,27 +2649,18 @@ def _attach_worktree(entry_dict: Dict[str, Any]) -> None: child_task_id = _subagent_id or f"subagent-{task_index}-{_uuid.uuid4().hex[:8]}" parent_task_id = getattr(parent_agent, "_current_task_id", None) - # Seed the child's session-cwd record from the parent's (cwd rearch): - # children share the parent's container, and today they inherit the - # parent's live env.cwd implicitly. Seeding at spawn preserves that - # starting directory while keeping the child's subsequent `cd`s - # isolated in its own record (a child's cd no longer bleeds back into - # the parent once readers flip to the record store). - try: - from tools.terminal_tool import ( - get_session_cwd, - record_session_cwd, - register_container_alias, - ) - - record_session_cwd(child_task_id, get_session_cwd(parent_task_id)) - # Per-session container isolation (docker + container_persistent: - # false) keys containers by session task_id. The child must share - # the PARENT's container — register the alias so the child's - # task_id resolves to the parent's container key. - register_container_alias(child_task_id, parent_task_id) - except Exception as e: - logger.debug("Child cwd seed failed: %s", e) + if sandbox: + # Per-subagent sandbox isolation (issue #4271). Loud path: an + # explicit sandbox request must never silently degrade to the + # shared parent sandbox — a failure here surfaces in the child's + # result entry instead of being swallowed by the best-effort + # seeding guard below. + _seed_child_terminal_env(child_task_id, parent_task_id, True) + else: + try: + _seed_child_terminal_env(child_task_id, parent_task_id, False) + except Exception as e: + logger.debug("Child cwd seed failed: %s", e) # Opt-in worktree isolation (delegation.worktree_isolation, inspired # by Muse Code's --subagent-worktree-isolation): give this child its @@ -2576,7 +2702,6 @@ def _attach_worktree(entry_dict: Dict[str, Any]) -> None: from tools.subagent_worktree import build_worktree_context_note goal = goal + build_worktree_context_note(_worktree_info) - wall_start = time.time() parent_reads_snapshot = ( list(file_state.known_reads(parent_task_id)) if parent_task_id else [] @@ -3176,6 +3301,13 @@ def _run_with_thread_capture(): except Exception: logger.debug("Failed to close child agent after delegation") + # Per-subagent sandbox: drop the child's env overrides so the + # registry doesn't leak entries and the child's container key stops + # resolving after teardown (issue #4271). The container itself is + # removed by the child's close() → cleanup_vm path above. + if sandbox: + _clear_child_sandbox(child_task_id) + # The AIAgent turn boundary normally closes the child scope itself. This # fallback covers failures before that boundary starts, but must not pop # a scope while a timed-out child worker is still unwinding. @@ -3436,6 +3568,7 @@ def delegate_task( action: Optional[str] = None, subagent_id: Optional[str] = None, message: Optional[str] = None, + sandbox: Optional[bool] = None, parent_agent=None, ) -> str: """ @@ -3487,6 +3620,11 @@ def delegate_task( # Normalise the top-level role once; per-task overrides re-normalise. top_role = _normalize_role(role) + # Per-subagent terminal sandbox isolation (issue #4271). Per-task + # `sandbox` beats the top-level value; both default to False (children + # share the parent's sandbox, the documented contract). + sandbox = _normalize_sandbox(sandbox, False) + # Background (async) delegation now applies to BOTH single tasks and # batches. A batch is dispatched as ONE async unit: the whole fan-out runs # on the daemon executor, joins on every child (see _execute_and_aggregate @@ -3564,6 +3702,8 @@ def delegate_task( single_task: Dict[str, Any] = {"goal": goal, "context": context, "role": top_role} if output_schema is not None: single_task["output_schema"] = output_schema + if sandbox: + single_task["sandbox"] = True task_list = [single_task] else: return tool_error("Provide either 'goal' (single task) or 'tasks' (batch).") @@ -3580,6 +3720,22 @@ def delegate_task( if not task.get("goal", "").strip(): return tool_error(f"Task {i} is missing a 'goal'.") + # Per-subagent sandbox isolation requires a container terminal backend. + # Fail fast BEFORE spawning any child: sandbox=True on local/ssh/vercel + # cannot be honored, and silently ignoring it would hand the caller a + # false isolation guarantee (shared filesystem/env blast radius). + if sandbox or any(_normalize_sandbox(t.get("sandbox"), False) for t in task_list): + from tools.terminal_tool import _get_env_config as _terminal_env_config + + _env_type = _terminal_env_config().get("env_type", "local") + if _env_type not in _SANDBOXABLE_BACKENDS: + return tool_error( + "delegate_task(sandbox=True) requires a container terminal " + f"backend (docker, singularity, modal, daytona); current " + f"backend is {_env_type!r}. Set terminal.backend in " + "config.yaml to a container backend." + ) + # Batch-only quality gate: catch malformed fan-outs (placeholder goals, # unexpanded multi-word template markers, 1-task batches) before any # child is spawned. The single-`goal` form is deliberately exempt — @@ -3727,6 +3883,7 @@ def _execute_and_aggregate(*, honor_parent_interrupt: bool = True) -> dict: _t["goal"], child, parent_agent, + sandbox=_normalize_sandbox(_t.get("sandbox"), sandbox), owner_session_id=_origin_ui_session_id or None, owner_transport=_origin_owner_transport, owner_session_record=_origin_owner_session_record, @@ -3752,6 +3909,7 @@ def _execute_and_aggregate(*, honor_parent_interrupt: bool = True) -> dict: goal=t["goal"], child=child, parent_agent=parent_agent, + sandbox=_normalize_sandbox(t.get("sandbox"), sandbox), owner_session_id=_origin_ui_session_id or None, owner_transport=_origin_owner_transport, owner_session_record=_origin_owner_session_record, @@ -4597,6 +4755,19 @@ def _build_dynamic_schema_overrides() -> dict: "require only fields you will actually read." ), }, + "sandbox": { + "type": "boolean", + "description": ( + "Per-task override of the top-level sandbox " + "flag: run THIS subagent in its own isolated " + "terminal sandbox (fresh container, isolated " + "filesystem/env) instead of sharing the " + "parent's. Requires a container terminal " + "backend (docker, singularity, modal, " + "daytona); errors on local/ssh. Default " + "false." + ), + }, }, "required": ["goal"], }, @@ -4662,6 +4833,23 @@ def _build_dynamic_schema_overrides() -> dict: "and return early results\")." ), }, + "sandbox": { + "type": "boolean", + "description": ( + "Optional per-subagent terminal sandbox isolation. When " + "true, each subagent gets its OWN fresh container sandbox " + "(isolated filesystem, environment, and working directory) " + "instead of sharing the parent's terminal backend — " + "parallel workstreams can no longer clobber each other's " + "files, env mutations, or installed packages. The fresh " + "container uses the same configured image as the parent, " + "so the environment is identical, just isolated. Requires " + "a container terminal backend (docker, singularity, modal, " + "daytona); errors on local/ssh. Per-task sandbox in a " + "batch overrides this top-level value. Default false " + "(children share the parent's sandbox)." + ), + }, }, "required": [], }, @@ -4726,6 +4914,7 @@ def _strip_model_hidden_task_fields(tasks: Any) -> Any: action=args.get("action"), subagent_id=args.get("subagent_id"), message=args.get("message"), + sandbox=args.get("sandbox"), parent_agent=kw.get("parent_agent"), ), check_fn=check_delegate_requirements, diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 6bf1376e65509..21e4d640e1b4e 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -305,6 +305,8 @@ Edge cases worth knowing: Parallel subagents spawned via `delegate_task(tasks=[...])` share this one container — concurrent `cd`, env mutations, and writes to the same path will collide. If a subagent needs an isolated sandbox, it must register a per-task image override via `register_task_env_overrides()`, which RL and benchmark environments (TerminalBench2, HermesSweEnv, etc.) do automatically for their per-task Docker images. +**Per-subagent sandbox isolation (`delegate_task(sandbox=True)`):** when parallel workstreams must not touch each other's filesystem or env, pass `sandbox=True` to `delegate_task` (top-level, or per-task inside `tasks=[...]`). Each sandboxed subagent gets its OWN fresh container — isolated filesystem, environment, and working directory — using the same configured image as the parent, so the environment is identical, just separate. The container is created on the subagent's first terminal/file/`execute_code` call and removed when the subagent finishes. Requires a container terminal backend (`docker`, `singularity`, `modal`, `daytona`); on `local`/`ssh` the call fails loudly rather than silently sharing the parent's process. Default is `false` — children share the parent's sandbox, preserving the long-lived-container contract above. + **Security hardening:** - `--cap-drop ALL` with only `DAC_OVERRIDE`, `CHOWN`, `FOWNER` added back - `--security-opt no-new-privileges`