Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f3dd708
fix(backend/copilot): fix tool output file reading between E2B and host
majdyz Apr 2, 2026
66afca6
fix(backend/copilot): address review feedback - size limits, promptin…
majdyz Apr 2, 2026
263cd0e
fix(backend/copilot): add bridging to Read tool, size limits, prompti…
majdyz Apr 2, 2026
b5b754d
fix(backend/copilot): return sandbox path from bridge, inform model o…
majdyz Apr 2, 2026
0e567df
fix(backend/copilot): add concrete tool examples to file copy prompting
majdyz Apr 2, 2026
3a49086
fix(backend/copilot): use resolved path for bridging, explicit return…
majdyz Apr 2, 2026
2cb65f5
fix(backend/copilot): use working_dir in prompt examples instead of h…
majdyz Apr 2, 2026
015e0d5
fix(backend/copilot): remove type: ignore from conftest, use named fi…
majdyz Apr 2, 2026
dd34b0d
fix(backend): lower bridge shell threshold and add collision-free san…
majdyz Apr 2, 2026
03e5d37
test(backend/copilot): add E2E test screenshots for PR #12646 round 1
majdyz Apr 2, 2026
dd228de
fix(backend/copilot): preserve binary files when bridging to E2B sandbox
majdyz Apr 2, 2026
19ea753
fix(backend/copilot): address review feedback on _bridge_to_sandbox
majdyz Apr 2, 2026
79c5a10
fix(backend/copilot): add missing security test for tool-outputs path…
majdyz Apr 2, 2026
2d04584
fix(backend/copilot): correct outdated E2B bridge threshold in system…
majdyz Apr 2, 2026
0b4acd7
Merge branch 'dev' of github.com:Significant-Gravitas/AutoGPT into fi…
majdyz Apr 2, 2026
13fcc62
Merge branch 'dev' of github.com:Significant-Gravitas/AutoGPT into fi…
majdyz Apr 2, 2026
82887a2
fix(backend/copilot): address reviewer feedback on E2B bridge API sur…
majdyz Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions autogpt_platform/backend/backend/copilot/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@ def is_allowed_local_path(path: str, sdk_cwd: str | None = None) -> bool:

Allowed:
- Files under *sdk_cwd* (``/tmp/copilot-<session>/``)
- Files under ``~/.claude/projects/<encoded-cwd>/<uuid>/tool-results/...``.
- Files under ``~/.claude/projects/<encoded-cwd>/<uuid>/tool-results/...``
or ``tool-outputs/...``.
The SDK nests tool-results under a conversation UUID directory;
the UUID segment is validated with ``_UUID_RE``.
"""
Expand All @@ -174,17 +175,20 @@ def is_allowed_local_path(path: str, sdk_cwd: str | None = None) -> bool:
# Defence-in-depth: ensure project_dir didn't escape the base.
if not project_dir.startswith(SDK_PROJECTS_DIR + os.sep):
return False
# Only allow: <encoded-cwd>/<uuid>/tool-results/<file>
# Only allow: <encoded-cwd>/<uuid>/<tool-dir>/<file>
# The SDK always creates a conversation UUID directory between
# the project dir and tool-results/.
# the project dir and the tool directory.
# Accept both "tool-results" (SDK's persisted outputs) and
# "tool-outputs" (the model sometimes confuses workspace paths
# with filesystem paths and generates this variant).
if resolved.startswith(project_dir + os.sep):
relative = resolved[len(project_dir) + 1 :]
parts = relative.split(os.sep)
# Require exactly: [<uuid>, "tool-results", <file>, ...]
# Require exactly: [<uuid>, "tool-results"|"tool-outputs", <file>, ...]
if (
len(parts) >= 3
and _UUID_RE.match(parts[0])
and parts[1] == "tool-results"
and parts[1] in ("tool-results", "tool-outputs")
):
return True

Expand Down
17 changes: 16 additions & 1 deletion autogpt_platform/backend/backend/copilot/context_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,21 @@ def test_is_allowed_local_path_tool_results_with_uuid():
_current_project_dir.set("")


def test_is_allowed_local_path_tool_outputs_with_uuid():
"""Files under <encoded-cwd>/<uuid>/tool-outputs/ are also allowed."""
encoded = "test-encoded-dir"
conv_uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
path = os.path.join(
SDK_PROJECTS_DIR, encoded, conv_uuid, "tool-outputs", "output.json"
)

_current_project_dir.set(encoded)
try:
assert is_allowed_local_path(path, sdk_cwd=None)
finally:
_current_project_dir.set("")


def test_is_allowed_local_path_tool_results_without_uuid_rejected():
"""Direct <encoded-cwd>/tool-results/ (no UUID) is rejected."""
encoded = "test-encoded-dir"
Expand All @@ -159,7 +174,7 @@ def test_is_allowed_local_path_sibling_of_tool_results_is_rejected():


def test_is_allowed_local_path_valid_uuid_wrong_segment_name_rejected():
"""A valid UUID dir but non-'tool-results' second segment is rejected."""
"""A valid UUID dir but non-'tool-results'/'tool-outputs' second segment is rejected."""
encoded = "test-encoded-dir"
uuid_str = "12345678-1234-5678-9abc-def012345678"
path = os.path.join(
Expand Down
30 changes: 25 additions & 5 deletions autogpt_platform/backend/backend/copilot/prompting.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@
- Image: `![chart](workspace://file_id#image/png)`
- Video: `![recording](workspace://file_id#video/mp4)`

### Handling binary/image data in tool outputs — CRITICAL
When a tool output contains base64-encoded binary data (images, PDFs, etc.):
1. **NEVER** try to inline or render the base64 content in your response.
2. **Save** the data to workspace using `write_workspace_file` (pass the base64 data URI as content).
3. **Show** the result via the workspace download URL in Markdown: `![image](workspace://file_id#image/png)`.

### Passing large data between tools — CRITICAL
When tool outputs produce large text that you need to feed into another tool:
- **NEVER** copy-paste the full text into the next tool call argument.
- **Save** the output to a file (workspace or local), then use `@@agptfile:` references.
- This avoids token limits and ensures data integrity.

### File references — @@agptfile:
Pass large file content to tools by reference: `@@agptfile:<uri>[<start>-<end>]`
- `workspace://<file_id>` or `workspace:///<path>` — workspace files
Expand Down Expand Up @@ -138,6 +150,11 @@
# E2B-only notes — E2B has full internet access so gh CLI works there.
# Not shown in local (bubblewrap) mode: --unshare-net blocks all network.
_E2B_TOOL_NOTES = """
### SDK tool-result files in E2B
When you `Read` an SDK tool-result file, it is automatically copied into the
sandbox at `/tmp/<filename>` (or `/home/user/<filename>` for files >5 MB)
so `bash_exec` can access it for further processing.

### GitHub CLI (`gh`) and git
- If the user has connected their GitHub account, both `gh` and `git` are
pre-authenticated — use them directly without any manual login step.
Expand Down Expand Up @@ -211,11 +228,14 @@ def _build_storage_supplement(

### SDK tool-result files
When tool outputs are large, the SDK truncates them and saves the full output to
a local file under `~/.claude/projects/.../tool-results/`. To read these files,
always use `Read` (NOT `bash_exec`, NOT `read_workspace_file`).
These files are on the host filesystem — `bash_exec` runs in the sandbox and
CANNOT access them. `read_workspace_file` reads from cloud workspace storage,
where SDK tool-results are NOT stored.
a local file under `~/.claude/projects/.../tool-results/` (or `tool-outputs/`).
To read these files, use `Read` — it reads from the host filesystem.

### Large tool outputs saved to workspace
When a tool output contains `<tool-output-truncated workspace_path="...">`, the
full output is in workspace storage (NOT on the local filesystem). To access it:
- Use `read_workspace_file(path="...", offset=..., length=50000)` for reading sections.
- To process in the sandbox, use `read_workspace_file(path="...", save_to_path="/home/user/file.json")` first, then use `bash_exec` on the local copy.
{_SHARED_TOOL_NOTES}{extra_notes}"""


Expand Down
13 changes: 13 additions & 0 deletions autogpt_platform/backend/backend/copilot/sdk/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,23 @@
from uuid import uuid4

import pytest
import pytest_asyncio

from backend.util import json


@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def server(): # type: ignore[override]
"""No-op server stub — SDK tests don't need the full backend."""
return None


@pytest_asyncio.fixture(scope="session", loop_scope="session", autouse=True)
async def graph_cleanup(): # type: ignore[override]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
"""No-op graph cleanup stub."""
yield


@pytest.fixture()
def mock_chat_config():
"""Mock ChatConfig so compact_transcript tests skip real config lookup."""
Expand Down
75 changes: 73 additions & 2 deletions autogpt_platform/backend/backend/copilot/sdk/e2b_file_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,16 @@ async def _handle_read_file(args: dict[str, Any]) -> dict[str, Any]:
if not file_path:
return _mcp("file_path is required", error=True)

# SDK-internal paths (tool-results, ephemeral working dir) stay on the host.
# SDK-internal paths (tool-results/tool-outputs, ephemeral working dir)
# stay on the host. When E2B is active, also copy the file into the
# sandbox so bash_exec can access it for further processing.
if _is_allowed_local(file_path):
return _read_local(file_path, offset, limit)
result = _read_local(file_path, offset, limit)
if not result.get("isError"):
sandbox = _get_sandbox()
if sandbox is not None:
await _bridge_to_sandbox(sandbox, file_path, offset, limit)
return result

result = _get_sandbox_and_path(file_path)
if isinstance(result, dict):
Expand Down Expand Up @@ -302,6 +309,70 @@ async def _handle_grep(args: dict[str, Any]) -> dict[str, Any]:
return _mcp(output if output else "No matches found.")


# Bridging: copy SDK-internal files into E2B sandbox

# Files larger than this are written to /home/user/ via sandbox.files.write()
# instead of /tmp/ via shell base64, to avoid shell argument length limits
# and E2B command timeouts.
_BRIDGE_SHELL_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
# Files larger than this are skipped entirely to avoid excessive transfer times.
_BRIDGE_SKIP_BYTES = 50 * 1024 * 1024 # 50 MB


async def _bridge_to_sandbox(
sandbox: Any, file_path: str, offset: int, limit: int
) -> None:
"""Best-effort copy of a host-side SDK file into the E2B sandbox.

When the model reads an SDK-internal file (e.g. tool-results), it often
wants to process the data with bash. Copying the file into the sandbox
under a stable name lets ``bash_exec`` access it without extra steps.

Only copies when offset=0 and limit is large enough to indicate the model
wants the full file. Errors are logged but never propagated.

Size handling:
- <= 5 MB: written to ``/tmp/<basename>`` via shell base64 (``_sandbox_write``).
- 5-50 MB: written to ``/home/user/<basename>`` via ``sandbox.files.write()``
to avoid shell argument length limits.
- > 50 MB: skipped entirely with a warning.
"""
if offset != 0 or limit < 2000:
return
basename = os.path.basename(file_path)
try:
expanded = os.path.realpath(os.path.expanduser(file_path))
file_size = os.path.getsize(expanded)
if file_size > _BRIDGE_SKIP_BYTES:
logger.warning(
"[E2B] Skipping bridge for large file (%d bytes): %s",
file_size,
basename,
)
return
with open(expanded, "rb") as fh:
content = fh.read()
if file_size <= _BRIDGE_SHELL_MAX_BYTES:
sandbox_path = f"/tmp/{basename}"
await _sandbox_write(
sandbox, sandbox_path, content.decode("utf-8", errors="replace")
)
else:
sandbox_path = f"/home/user/{basename}"
await sandbox.files.write(
sandbox_path, content.decode("utf-8", errors="replace")
)
logger.info(
"[E2B] Bridged SDK file to sandbox: %s -> %s", basename, sandbox_path
)
except Exception:
logger.debug(
"[E2B] Failed to bridge SDK file to sandbox: %s",
basename,
exc_info=True,
)


# Local read (for SDK-internal paths)


Expand Down
132 changes: 129 additions & 3 deletions autogpt_platform/backend/backend/copilot/sdk/e2b_file_tools_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
from backend.copilot.context import E2B_WORKDIR, SDK_PROJECTS_DIR, _current_project_dir

from .e2b_file_tools import (
_BRIDGE_SHELL_MAX_BYTES,
_BRIDGE_SKIP_BYTES,
_bridge_to_sandbox,
_check_sandbox_symlink_escape,
_read_local,
_sandbox_write,
Expand Down Expand Up @@ -91,9 +94,9 @@ def test_tmp_prefix_collision_blocked(self):
# ---------------------------------------------------------------------------
# _read_local — host filesystem reads with allowlist enforcement
#
# In E2B mode, _read_local only allows tool-results paths (via
# is_allowed_local_path without sdk_cwd). Regular files live on the
# sandbox, not the host.
# In E2B mode, _read_local only allows tool-results/tool-outputs paths
# (via is_allowed_local_path without sdk_cwd). Regular files live on
# the sandbox, not the host.
# ---------------------------------------------------------------------------


Expand Down Expand Up @@ -127,6 +130,25 @@ def test_read_tool_results_file(self):
_current_project_dir.reset(token)
os.unlink(filepath)

def test_read_tool_outputs_file(self):
"""Reading a tool-outputs file should also succeed."""
encoded = "-tmp-copilot-e2b-test-read-outputs"
tool_outputs_dir = os.path.join(
SDK_PROJECTS_DIR, encoded, self._CONV_UUID, "tool-outputs"
)
os.makedirs(tool_outputs_dir, exist_ok=True)
filepath = os.path.join(tool_outputs_dir, "sdk-abc123.json")
with open(filepath, "w") as f:
f.write('{"data": "test"}\n')
token = _current_project_dir.set(encoded)
try:
result = _read_local(filepath, offset=0, limit=2000)
assert result["isError"] is False
assert "test" in result["content"][0]["text"]
finally:
_current_project_dir.reset(token)
shutil.rmtree(os.path.join(SDK_PROJECTS_DIR, encoded), ignore_errors=True)

def test_read_disallowed_path_blocked(self):
"""Reading /etc/passwd should be blocked by the allowlist."""
result = _read_local("/etc/passwd", offset=0, limit=10)
Expand Down Expand Up @@ -335,3 +357,107 @@ async def test_tmp_write_preserves_content_with_special_chars(self):
encoded_in_cmd = call_args.split("echo ")[1].split(" |")[0].strip("'")
decoded = base64.b64decode(encoded_in_cmd).decode()
assert decoded == content


# ---------------------------------------------------------------------------
# _bridge_to_sandbox — copy SDK-internal files into E2B sandbox
# ---------------------------------------------------------------------------


def _make_bridge_sandbox() -> SimpleNamespace:
"""Build a sandbox mock suitable for _bridge_to_sandbox tests."""
run_result = SimpleNamespace(stdout="", stderr="", exit_code=0)
commands = SimpleNamespace(run=AsyncMock(return_value=run_result))
files = SimpleNamespace(write=AsyncMock())
return SimpleNamespace(commands=commands, files=files)


class TestBridgeToSandbox:
@pytest.mark.asyncio
async def test_happy_path_small_file(self, tmp_path):
"""A small file is bridged to /tmp/<basename> via _sandbox_write."""
f = tmp_path / "result.json"
f.write_text('{"ok": true}')
sandbox = _make_bridge_sandbox()

await _bridge_to_sandbox(sandbox, str(f), offset=0, limit=2000)

sandbox.commands.run.assert_called_once()
cmd = sandbox.commands.run.call_args[0][0]
assert "result.json" in cmd
sandbox.files.write.assert_not_called()

@pytest.mark.asyncio
async def test_skip_when_offset_nonzero(self, tmp_path):
"""Bridging is skipped when offset != 0 (partial read)."""
f = tmp_path / "data.txt"
f.write_text("content")
sandbox = _make_bridge_sandbox()

await _bridge_to_sandbox(sandbox, str(f), offset=10, limit=2000)

sandbox.commands.run.assert_not_called()
sandbox.files.write.assert_not_called()

@pytest.mark.asyncio
async def test_skip_when_limit_too_small(self, tmp_path):
"""Bridging is skipped when limit < 2000 (partial read)."""
f = tmp_path / "data.txt"
f.write_text("content")
sandbox = _make_bridge_sandbox()

await _bridge_to_sandbox(sandbox, str(f), offset=0, limit=100)

sandbox.commands.run.assert_not_called()
sandbox.files.write.assert_not_called()

@pytest.mark.asyncio
async def test_nonexistent_file_does_not_raise(self, tmp_path):
"""Bridging a non-existent file logs but does not propagate errors."""
sandbox = _make_bridge_sandbox()

await _bridge_to_sandbox(
sandbox, str(tmp_path / "ghost.txt"), offset=0, limit=2000
)

sandbox.commands.run.assert_not_called()
sandbox.files.write.assert_not_called()

@pytest.mark.asyncio
async def test_sandbox_write_failure_does_not_raise(self, tmp_path):
"""If sandbox write fails, the error is swallowed (best-effort)."""
f = tmp_path / "data.txt"
f.write_text("content")
sandbox = _make_bridge_sandbox()
sandbox.commands.run.side_effect = RuntimeError("E2B timeout")

await _bridge_to_sandbox(sandbox, str(f), offset=0, limit=2000)

@pytest.mark.asyncio
async def test_large_file_uses_files_api(self, tmp_path):
"""Files > 5 MB but <= 50 MB are written to /home/user/ via files.write."""
f = tmp_path / "big.json"
f.write_bytes(b"x" * (_BRIDGE_SHELL_MAX_BYTES + 1))
sandbox = _make_bridge_sandbox()

await _bridge_to_sandbox(sandbox, str(f), offset=0, limit=2000)

sandbox.files.write.assert_called_once()
call_args = sandbox.files.write.call_args[0]
assert call_args[0] == "/home/user/big.json"
sandbox.commands.run.assert_not_called()

@pytest.mark.asyncio
async def test_very_large_file_skipped(self, tmp_path):
"""Files > 50 MB are skipped entirely."""
f = tmp_path / "huge.bin"
# Create a sparse file to avoid actually writing 50 MB
with open(f, "wb") as fh:
fh.seek(_BRIDGE_SKIP_BYTES + 1)
fh.write(b"\0")
sandbox = _make_bridge_sandbox()

await _bridge_to_sandbox(sandbox, str(f), offset=0, limit=2000)

sandbox.commands.run.assert_not_called()
sandbox.files.write.assert_not_called()
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ def test_read_tool_results_allowed():


def test_read_claude_projects_settings_json_denied():
"""SDK-internal artifacts like settings.json are NOT accessible — only tool-results/ is."""
"""SDK-internal artifacts like settings.json are NOT accessible — only tool-results/tool-outputs is."""
home = os.path.expanduser("~")
path = f"{home}/.claude/projects/-tmp-copilot-abc123/settings.json"
token = _current_project_dir.set("-tmp-copilot-abc123")
Expand Down
Loading
Loading