fix(sandbox): fail E2B bootstrap safely (#4325)

* fix(sandbox): fail E2B bootstrap safely

* test(sandbox): cover E2B bootstrap cleanup

* docs(backend): document E2B bootstrap failure

* fix(sandbox): discard E2B bootstrap failures

* test(sandbox): cover E2B reconnect bootstrap failures

* docs(backend): clarify E2B bootstrap recovery

* fix: preserve E2B bootstrap errors

* fix: reject falsey E2B bootstrap errors
This commit is contained in:
luo jiyin 2026-07-21 22:22:20 +08:00 committed by GitHub
parent 2bb22643ad
commit da3feb3863
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 126 additions and 34 deletions

View File

@ -462,7 +462,7 @@ Scheduled-task runtime note:
- `browser_automation/` - Agentic browser control (stateful `navigate → observe → click/type` loop) via Playwright, distinct from the read-only `web_fetch`/`web_capture` tools. Tools: `browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_get_text`, `browser_back`, `browser_screenshot`, `browser_close` (config `group: browser`). A process-local `BrowserSessionManager` owns one private, loop-affine Playwright event-loop thread (same pattern as the BoxLite provider) so a per-thread browser session survives across turns regardless of the caller's loop (Gateway / TUI / test). Each action returns a fresh page snapshot whose interactive elements are addressed by a stable numeric `[ref]` index (stamped as `data-df-ref` during snapshot), so the model acts on what it just observed instead of holding stale handles or guessing selectors. URLs are SSRF-screened via the shared `validate_public_http_url` (opt-out `allow_private_addresses` only for intentional internal targets). CDP attachment cannot install the request guard on an existing Chrome context, so `cdp_url` fails closed unless the operator explicitly sets `allow_unguarded_cdp: true` for a trusted local browser. Browser REST/Live access also requires an exact non-NULL thread owner, rather than the general legacy shared-thread policy, because retained pages may contain authenticated state. Session admission is a hard `max_sessions` cap: pinned Live/operation sessions are never evicted, and a new thread is rejected when no unpinned session can be closed; one Live viewer owns a session at a time. Optional dependency: `cd backend && uv sync --extra browser && uv run playwright install chromium`; `scripts/detect_uv_extras.py` preserves the extra when `config.yaml` enables `browser_navigate`, and Gateway startup fails fast if configured browser control cannot import Playwright. Tests: `tests/test_browser_automation.py` (mocked tools + a real-Chromium integration test guarded by `importorskip`); `tests/manual_browser_live_check.py` is a manual DeepSeek-driven end-to-end check (not collected by pytest). - `browser_automation/` - Agentic browser control (stateful `navigate → observe → click/type` loop) via Playwright, distinct from the read-only `web_fetch`/`web_capture` tools. Tools: `browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_get_text`, `browser_back`, `browser_screenshot`, `browser_close` (config `group: browser`). A process-local `BrowserSessionManager` owns one private, loop-affine Playwright event-loop thread (same pattern as the BoxLite provider) so a per-thread browser session survives across turns regardless of the caller's loop (Gateway / TUI / test). Each action returns a fresh page snapshot whose interactive elements are addressed by a stable numeric `[ref]` index (stamped as `data-df-ref` during snapshot), so the model acts on what it just observed instead of holding stale handles or guessing selectors. URLs are SSRF-screened via the shared `validate_public_http_url` (opt-out `allow_private_addresses` only for intentional internal targets). CDP attachment cannot install the request guard on an existing Chrome context, so `cdp_url` fails closed unless the operator explicitly sets `allow_unguarded_cdp: true` for a trusted local browser. Browser REST/Live access also requires an exact non-NULL thread owner, rather than the general legacy shared-thread policy, because retained pages may contain authenticated state. Session admission is a hard `max_sessions` cap: pinned Live/operation sessions are never evicted, and a new thread is rejected when no unpinned session can be closed; one Live viewer owns a session at a time. Optional dependency: `cd backend && uv sync --extra browser && uv run playwright install chromium`; `scripts/detect_uv_extras.py` preserves the extra when `config.yaml` enables `browser_navigate`, and Gateway startup fails fast if configured browser control cannot import Playwright. Tests: `tests/test_browser_automation.py` (mocked tools + a real-Chromium integration test guarded by `importorskip`); `tests/manual_browser_live_check.py` is a manual DeepSeek-driven end-to-end check (not collected by pytest).
Live UI input dispatch is kept independent from JPEG capture: non-move actions start a rate-limited background refresh loop, so pointer, wheel, or keyboard input stays responsive while continuous gestures still produce frames throughout the interaction. Live UI input dispatch is kept independent from JPEG capture: non-move actions start a rate-limited background refresh loop, so pointer, wheel, or keyboard input stays responsive while continuous gestures still produce frames throughout the interaction.
Additional providers also live here (`boxlite`, `brave`, `browserless`, `crawl4ai`, `ddg_search`, `e2b_sandbox`, `exa`, `fastcrw`, `groundroute`, `infoquest`, `searxng`, `serper`); see each subpackage for specifics. Additional providers also live here (`boxlite`, `brave`, `browserless`, `crawl4ai`, `ddg_search`, `e2b_sandbox`, `exa`, `fastcrw`, `groundroute`, `infoquest`, `searxng`, `serper`); see each subpackage for specifics. E2B bootstrap is required. If it fails, the provider kills and closes the unusable remote sandbox. New sandbox creation raises an error. Warm-pool reclaim and remote discovery discard the sandbox and continue acquisition. E2B mounts remain optional.
**ACP agent tools**: **ACP agent tools**:
- `invoke_acp_agent` - Invokes external ACP-compatible agents from `config.yaml` - `invoke_acp_agent` - Invokes external ACP-compatible agents from `config.yaml`

View File

@ -299,10 +299,8 @@ class E2BSandboxProvider(SandboxProvider):
return None return None
self._refresh_remote_timeout(client) self._refresh_remote_timeout(client)
try: if self._bootstrap_or_discard(client, target_id) is not None:
self._bootstrap_sandbox_paths(client) return None
except Exception as e:
logger.debug("bootstrap on warm-pool reclaim failed: %s", e)
self._register_connected_sandbox(target_id, client, thread_id=thread_id, user_id=user_id) self._register_connected_sandbox(target_id, client, thread_id=thread_id, user_id=user_id)
logger.info( logger.info(
"Reclaimed warm-pool e2b sandbox %s for user/thread %s/%s", "Reclaimed warm-pool e2b sandbox %s for user/thread %s/%s",
@ -415,10 +413,8 @@ class E2BSandboxProvider(SandboxProvider):
return None return None
self._refresh_remote_timeout(client) self._refresh_remote_timeout(client)
try: if self._bootstrap_or_discard(client, target_id) is not None:
self._bootstrap_sandbox_paths(client) return None
except Exception as e:
logger.debug("bootstrap on remote discovery failed: %s", e)
self._register_connected_sandbox(target_id, client, thread_id=thread_id, user_id=user_id) self._register_connected_sandbox(target_id, client, thread_id=thread_id, user_id=user_id)
logger.info( logger.info(
"Discovered remote e2b sandbox %s for user/thread %s/%s (seed=%s)", "Discovered remote e2b sandbox %s for user/thread %s/%s (seed=%s)",
@ -475,14 +471,9 @@ class E2BSandboxProvider(SandboxProvider):
# use the same /mnt/user-data prefix as LocalSandbox / AioSandbox — fail # use the same /mnt/user-data prefix as LocalSandbox / AioSandbox — fail
# with PermissionError because /mnt is owned by root in the e2b # with PermissionError because /mnt is owned by root in the e2b
# template. See the path-mapping note in :class:`E2BSandbox`. # template. See the path-mapping note in :class:`E2BSandbox`.
try: error = self._bootstrap_or_discard(client, sandbox_id)
self._bootstrap_sandbox_paths(client) if error is not None:
except Exception as e: raise RuntimeError(f"Failed to bootstrap e2b sandbox {sandbox_id}") from error
logger.warning(
"Failed to bootstrap virtual paths in e2b sandbox %s: %s",
sandbox_id,
e,
)
# One-shot mount uploads. e2b has no host bind-mount, so we copy # One-shot mount uploads. e2b has no host bind-mount, so we copy
# files from ``host_path`` into ``container_path`` at sandbox start. # files from ``host_path`` into ``container_path`` at sandbox start.
@ -612,6 +603,18 @@ class E2BSandboxProvider(SandboxProvider):
logger.debug("e2b client close raised: %s", e) logger.debug("e2b client close raised: %s", e)
return return
def _bootstrap_or_discard(self, client: E2BClientSandbox, sandbox_id: str) -> Exception | None:
"""Bootstrap a sandbox or return its error after cleanup."""
try:
self._bootstrap_sandbox_paths(client)
except Exception as e:
logger.exception("Failed to bootstrap e2b sandbox %s. Discarding the unusable sandbox.", sandbox_id)
if error := self._kill_client(client):
logger.warning("Failed to kill e2b sandbox %s after bootstrap failure: %s", sandbox_id, error)
self._safe_close_client(client)
return e
return None
def _bootstrap_sandbox_paths(self, client: E2BClientSandbox) -> None: def _bootstrap_sandbox_paths(self, client: E2BClientSandbox) -> None:
"""Materialise DeerFlow's virtual path layout inside the e2b VM. """Materialise DeerFlow's virtual path layout inside the e2b VM.
@ -637,11 +640,9 @@ class E2BSandboxProvider(SandboxProvider):
writes through the symlink target succeed. writes through the symlink target succeed.
The e2b code-interpreter template puts ``user`` in the ``sudo`` group The e2b code-interpreter template puts ``user`` in the ``sudo`` group
with passwordless sudo, so the ``sudo`` calls below succeed without with passwordless sudo. Custom templates must provide equivalent
interactive prompts. If the customer template removes that, the permissions. Bootstrap failure makes the sandbox unusable. The
commands fail loudly here and we fall back to silently relying on the provider discards it instead of returning a partially functional VM.
path remap inside ``E2BSandbox`` agent shell commands will still
fail, but the read/write/list APIs continue to work.
""" """
# Use the configured ``home_dir`` so a custom template can move HOME. # Use the configured ``home_dir`` so a custom template can move HOME.
home_dir = self._config["home_dir"].rstrip("/") or "/home/user" home_dir = self._config["home_dir"].rstrip("/") or "/home/user"
@ -669,21 +670,13 @@ class E2BSandboxProvider(SandboxProvider):
try: try:
result = client.commands.run(bootstrap_script) result = client.commands.run(bootstrap_script)
except Exception as e: except Exception as e:
logger.warning( raise RuntimeError("e2b bootstrap script raised") from e
"e2b bootstrap script raised: %s (agent shell commands using /mnt/user-data may fail until the VM is recycled)",
e,
)
return
stdout = getattr(result, "stdout", "") or "" stdout = getattr(result, "stdout", "") or ""
stderr = getattr(result, "stderr", "") or "" stderr = getattr(result, "stderr", "") or ""
exit_code = getattr(result, "exit_code", 0) exit_code = getattr(result, "exit_code", 0)
if exit_code not in (0, None) or "BOOTSTRAP_OK" not in stdout: if exit_code not in (0, None) or "BOOTSTRAP_OK" not in stdout:
logger.warning( raise RuntimeError(f"e2b bootstrap script failed with exit code {exit_code}; stderr={stderr.strip()}")
"e2b bootstrap script exited with code=%s; stderr=%s",
exit_code,
stderr.strip(),
)
def _apply_mounts(self, client: E2BClientSandbox) -> None: def _apply_mounts(self, client: E2BClientSandbox) -> None:
mounts = self._config.get("mounts") or [] mounts = self._config.get("mounts") or []

View File

@ -489,6 +489,29 @@ def test_reclaim_warm_pool_sandbox_handles_reconnect_exception(monkeypatch):
assert "sb-broken" not in p._warm_pool assert "sb-broken" not in p._warm_pool
def test_acquire_discards_warm_sandbox_when_bootstrap_fails(monkeypatch):
p = _make_provider()
fake_cls = _install_fake_sdk(monkeypatch, p)
warm_client = FakeClient(
sandbox_id="sb-warm",
commands=FakeCommandsAPI(
[
SimpleNamespace(stdout="ok", stderr="", exit_code=0),
SimpleNamespace(stdout="", stderr="permission denied", exit_code=1),
]
),
)
fresh_client = FakeClient(sandbox_id="sb-fresh")
fake_cls.connect_factory = lambda _sid, **_kw: warm_client
fake_cls.create_factory = lambda **_kw: fresh_client
p._warm_pool["sb-warm"] = (p._stable_seed("t1", "u1"), 12345.0)
assert p.acquire("t1", user_id="u1") == "sb-fresh"
assert warm_client.killed is True
assert warm_client.closed is True
assert p.get("sb-warm") is None
def test_reclaim_warm_pool_sandbox_returns_none_on_seed_mismatch(monkeypatch): def test_reclaim_warm_pool_sandbox_returns_none_on_seed_mismatch(monkeypatch):
p = _make_provider() p = _make_provider()
_install_fake_sdk(monkeypatch, p) _install_fake_sdk(monkeypatch, p)
@ -566,6 +589,27 @@ def test_discover_remote_sandbox_skips_dead_candidate(monkeypatch):
assert client.closed is True assert client.closed is True
def test_discover_remote_sandbox_discards_candidate_when_bootstrap_fails(monkeypatch):
p = _make_provider()
fake_cls = _install_fake_sdk(monkeypatch, p)
fake_cls.list_return = [_info("sb-broken", "u1", "t1")]
client = FakeClient(
sandbox_id="sb-broken",
commands=FakeCommandsAPI(
[
SimpleNamespace(stdout="ok", stderr="", exit_code=0),
SimpleNamespace(stdout="", stderr="permission denied", exit_code=1),
]
),
)
fake_cls.connect_factory = lambda _sid, **_kw: client
assert p._discover_remote_sandbox("t1", user_id="u1") is None
assert client.killed is True
assert client.closed is True
assert ("u1", "t1") not in p._thread_sandboxes
def test_kill_client_returns_exception_without_raising(): def test_kill_client_returns_exception_without_raising():
p = _make_provider() p = _make_provider()
client = FakeClient() client = FakeClient()
@ -645,14 +689,69 @@ def test_bootstrap_sandbox_paths_emits_expected_script():
assert f"/home/user/{sub}" in script assert f"/home/user/{sub}" in script
def test_bootstrap_sandbox_paths_swallows_command_failure(): def test_bootstrap_sandbox_paths_raises_on_command_failure():
p = _make_provider() p = _make_provider()
def boom(_cmd: str) -> Any: def boom(_cmd: str) -> Any:
raise RuntimeError("sudo not allowed") raise RuntimeError("sudo not allowed")
client = FakeClient(commands=FakeCommandsAPI([boom])) client = FakeClient(commands=FakeCommandsAPI([boom]))
p._bootstrap_sandbox_paths(client) with pytest.raises(RuntimeError, match="bootstrap script raised"):
p._bootstrap_sandbox_paths(client)
def test_acquire_cleans_up_and_fails_when_bootstrap_fails(monkeypatch):
provider = _make_provider()
fake_cls = _install_fake_sdk(monkeypatch, provider)
client = FakeClient(
sandbox_id="bootstrap-failure",
commands=FakeCommandsAPI([SimpleNamespace(stdout="", stderr="permission denied", exit_code=1)]),
)
fake_cls.create_factory = lambda **kwargs: client
with pytest.raises(RuntimeError, match="bootstrap") as error:
provider.acquire("thread-1", user_id="user-1")
assert error.value.__cause__ is not None
assert "permission denied" in str(error.value.__cause__)
assert client.killed is True
assert client.closed is True
assert provider.get("bootstrap-failure") is None
def test_acquire_rejects_falsey_bootstrap_error(monkeypatch):
class FalseyError(RuntimeError):
def __bool__(self) -> bool:
return False
provider = _make_provider()
fake_cls = _install_fake_sdk(monkeypatch, provider)
client = FakeClient(sandbox_id="bootstrap-failure")
error = FalseyError("bootstrap failed")
fake_cls.create_factory = lambda **kwargs: client
provider._bootstrap_or_discard = MagicMock(return_value=error)
with pytest.raises(RuntimeError, match="bootstrap") as caught:
provider.acquire("thread-1", user_id="user-1")
assert caught.value.__cause__ is error
assert provider.get("bootstrap-failure") is None
def test_acquire_closes_client_when_bootstrap_cleanup_kill_fails(monkeypatch):
provider = _make_provider()
fake_cls = _install_fake_sdk(monkeypatch, provider)
client = FakeClient(
sandbox_id="bootstrap-failure",
commands=FakeCommandsAPI([SimpleNamespace(stdout="", stderr="permission denied", exit_code=1)]),
)
client.kill = MagicMock(side_effect=RuntimeError("sandbox already gone"))
fake_cls.create_factory = lambda **kwargs: client
with pytest.raises(RuntimeError, match="bootstrap"):
provider.acquire("thread-1", user_id="user-1")
assert client.closed is True
def test_release_unknown_sandbox_id_is_noop(): def test_release_unknown_sandbox_id_is_noop():