Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 4 additions & 9 deletions backend/app/services/sandbox_file_syncer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@
block the attachment upload flow.
"""

import asyncio
import logging
import os
from typing import Optional

import httpx

from shared.utils.attachment_block import sanitize_attachment_filename

logger = logging.getLogger(__name__)

# Configuration
Expand All @@ -39,13 +40,7 @@ def _sanitize_filename(filename: str) -> str:
Returns:
Sanitized filename safe for use in file paths
"""
# Get basename to remove any directory components
safe_name = os.path.basename(filename or "attachment")
# Replace path separators that might have been encoded
safe_name = safe_name.replace("/", "_").replace("\\", "_")
# Remove control characters
safe_name = safe_name.replace("\n", "").replace("\r", "")
return safe_name if safe_name else "attachment"
return sanitize_attachment_filename(filename, fallback="attachment")
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def build_sandbox_attachment_path(task_id: int, subtask_id: int, filename: str) -> str:
Expand Down Expand Up @@ -282,7 +277,7 @@ async def sync_attachment_to_sandbox_background(
) -> None:
"""Sync attachment to sandbox in background.

This function is designed to be called from asyncio.create_task()
This function is designed to be scheduled as an asynchronous task.
and handles all exceptions internally.
Comment on lines +280 to 281

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the updated docstring sentence.

Line 280 ends the sentence before and handles all exceptions internally on Line 281. Remove the period after task, or split the text into two complete sentences.

Proposed fix
-    This function is designed to be scheduled as an asynchronous task.
-    and handles all exceptions internally.
+    This function is designed to be scheduled as an asynchronous task
+    and handles all exceptions internally.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
This function is designed to be scheduled as an asynchronous task.
and handles all exceptions internally.
This function is designed to be scheduled as an asynchronous task
and handles all exceptions internally.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/sandbox_file_syncer.py` around lines 280 - 281, Update
the docstring sentence describing the asynchronous task so “and handles all
exceptions internally” remains grammatically connected: remove the period after
“task” or split the text into two complete sentences.


Args:
Expand Down
67 changes: 44 additions & 23 deletions backend/init_data/skills/sandbox/download_attachment_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
import json
import logging
import os
import re
import time
from typing import Optional
from urllib.parse import urlsplit, urlunsplit

from langchain_core.callbacks import CallbackManagerForToolRun
from pydantic import BaseModel, Field
Expand All @@ -21,6 +23,28 @@

# Default API base URL for attachment downloads
DEFAULT_API_BASE_URL = "http://backend:8000"
_ATTACHMENT_DOWNLOAD_PATH = re.compile(
r"^/api/attachments/(?P<attachment_id>\d+)/download/?$"
)


def _build_download_url(attachment_url: str, api_base_url: str) -> str:
"""Build a URL that accepts the task token available to sandbox tools."""
relative_url = (
attachment_url if attachment_url.startswith("/") else f"/{attachment_url}"
)
parsed = urlsplit(
attachment_url
if attachment_url.startswith(("http://", "https://"))
else relative_url
)
match = _ATTACHMENT_DOWNLOAD_PATH.fullmatch(parsed.path)
if not match:
raise ValueError("Only Wegent attachment download URLs are supported")

backend = urlsplit(api_base_url.rstrip("/"))
executor_path = f"/api/attachments/{match.group('attachment_id')}/executor-download"
return urlunsplit((backend.scheme, backend.netloc, executor_path, "", ""))


class SandboxDownloadAttachmentInput(BaseModel):
Expand Down Expand Up @@ -61,7 +85,7 @@ class SandboxDownloadAttachmentTool(BaseSandboxTool):
"""Tool for downloading files from Wegent Backend to E2B sandbox.

This tool downloads files from Wegent's attachment storage to the
sandbox environment via the /api/attachments/{id}/download endpoint.
sandbox environment via the task-token attachment endpoint.
"""

name: str = "download_attachment"
Expand Down Expand Up @@ -132,8 +156,8 @@ async def _arun(
effective_timeout = timeout_seconds or self.default_download_timeout

logger.info(
f"[SandboxDownloadAttachmentTool] Downloading: {attachment_url} -> {save_path}, "
f"timeout={effective_timeout}s"
"[SandboxDownloadAttachmentTool] Downloading attachment: "
f"save_path={save_path}, timeout={effective_timeout}s"
)

# Emit status update via WebSocket if available
Expand All @@ -159,7 +183,7 @@ async def _arun(

# Get or create sandbox
logger.info(
f"[SandboxDownloadAttachmentTool] Getting or creating sandbox..."
"[SandboxDownloadAttachmentTool] Getting or creating sandbox..."
)
sandbox, error = await sandbox_manager.get_or_create_sandbox(
shell_type=self.default_shell_type,
Expand Down Expand Up @@ -202,17 +226,10 @@ async def _arun(
)
api_base_url = api_base_url.rstrip("/")

# Build full download URL
# attachment_url can be relative (e.g., /api/attachments/123/download) or full URL
if attachment_url.startswith("http://") or attachment_url.startswith(
"https://"
):
download_url = attachment_url
else:
# Ensure attachment_url starts with /
if not attachment_url.startswith("/"):
attachment_url = f"/{attachment_url}"
download_url = f"{api_base_url}{attachment_url}"
# The attachment block exposes the browser download URL. Translate
# that exact Wegent route to the executor route because sandbox tools
# authenticate with a task token rather than a browser login token.
download_url = _build_download_url(attachment_url, api_base_url)

# Get auth token
auth_token = self.auth_token
Expand All @@ -226,23 +243,27 @@ async def _arun(
await self._emit_tool_status("failed", error_msg)
return result

# Build curl command to download file
# Keep credentials and user-provided paths out of the command string.
# E2B passes these values directly as process environment variables.
curl_cmd = (
f"curl -s -f -L "
f'-H "Authorization: Bearer {auth_token}" '
f'-o "{save_path}" '
f'"{download_url}"'
"curl --silent --show-error --fail --location "
'--header "Authorization: Bearer $WEGENT_ATTACHMENT_TOKEN" '
'--output "$WEGENT_ATTACHMENT_SAVE_PATH" '
'"$WEGENT_ATTACHMENT_DOWNLOAD_URL"'
)

logger.info(
f"[SandboxDownloadAttachmentTool] Executing download via curl from {download_url}"
)
logger.info("[SandboxDownloadAttachmentTool] Executing attachment download")

# Execute curl command
result_obj = await sandbox.commands.run(
cmd=curl_cmd,
cwd="/home/user",
timeout=effective_timeout,
envs={
"WEGENT_ATTACHMENT_TOKEN": auth_token,
"WEGENT_ATTACHMENT_SAVE_PATH": save_path,
"WEGENT_ATTACHMENT_DOWNLOAD_URL": download_url,
},
)

execution_time = time.time() - start_time
Expand Down
1 change: 1 addition & 0 deletions backend/init_data/skills/sandbox/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def _prepare_base_params(
"timeout": config.get("timeout", 7200),
"auth_token": context.auth_token, # For skill downloads in sandbox
"skill_identity_token": context.skill_identity_token,
"load_skill_tool": context.load_skill_tool,
}

@property
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# SPDX-FileCopyrightText: 2026 Weibo, Inc.
#
# SPDX-License-Identifier: Apache-2.0

"""Tests for task-token attachment downloads in the sandbox skill."""

import json
from types import SimpleNamespace

import pytest

from init_data.skills.sandbox.download_attachment_tool import (
SandboxDownloadAttachmentTool,
_build_download_url,
)


def test_build_download_url_uses_executor_endpoint_for_attachment_url() -> None:
assert (
_build_download_url("/api/attachments/123/download", "http://backend:8000")
== "http://backend:8000/api/attachments/123/executor-download"
)
assert (
_build_download_url(
"https://wegent.example/api/attachments/123/download?download=1",
"http://backend:8000",
)
== "http://backend:8000/api/attachments/123/executor-download"
)


def test_build_download_url_rejects_non_attachment_url() -> None:
with pytest.raises(ValueError, match="Only Wegent attachment download URLs"):
_build_download_url(
"https://files.example/report.csv",
"http://backend:8000",
)


@pytest.mark.asyncio
async def test_download_uses_environment_for_credentials_and_paths(monkeypatch) -> None:
calls: list[dict] = []

class FakeFiles:
async def make_dir(self, path: str) -> None:
return None

async def get_info(self, path: str) -> SimpleNamespace:
return SimpleNamespace(size=4)

class FakeCommands:
async def run(self, **kwargs) -> SimpleNamespace:
calls.append(kwargs)
return SimpleNamespace(exit_code=0, stderr="")

sandbox = SimpleNamespace(
sandbox_id="sandbox-1",
files=FakeFiles(),
commands=FakeCommands(),
)

class FakeManager:
async def get_or_create_sandbox(self, **kwargs):
return sandbox, None

monkeypatch.setattr(
SandboxDownloadAttachmentTool,
"_get_sandbox_manager",
lambda self: FakeManager(),
)
tool = SandboxDownloadAttachmentTool(
task_id=1,
subtask_id=2,
user_id=3,
user_name="alice",
auth_token="task-token",
api_base_url="http://backend:8000",
)

result = json.loads(
await tool._arun(
attachment_url="/api/attachments/123/download",
save_path="/home/user/report.csv",
)
)

assert result["success"] is True
assert "task-token" not in calls[0]["cmd"]
assert "/home/user/report.csv" not in calls[0]["cmd"]
assert calls[0]["envs"] == {
"WEGENT_ATTACHMENT_TOKEN": "task-token",
"WEGENT_ATTACHMENT_SAVE_PATH": "/home/user/report.csv",
"WEGENT_ATTACHMENT_DOWNLOAD_URL": (
"http://backend:8000/api/attachments/123/executor-download"
),
}
Comment on lines +18 to +96

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add type annotations to the new Python test code.

The new helpers and test functions omit parameter or return annotations.

  • backend/tests/init_data/skills/sandbox/test_download_attachment_tool.py#L18-L96: Annotate test fixtures, helper parameters, and return values.
  • chat_shell/tests/test_sandbox_attachment_sync.py#L19-L209: Annotate helper methods, fixture parameters, and async test return values.
  • chat_shell/tests/test_sandbox_skill_identity.py#L53-L78: Annotate monkeypatch and the async test return value.
  • executor_manager/tests/services/test_sandbox_manager.py#L618-L754: Annotate fixture parameters and async test return values.
  • executor_manager/tests/services/test_sandbox_skill_sync.py#L20-L135: Annotate helper parameters, handler callbacks, and async test return values.

As per coding guidelines, “Python code must follow PEP 8, Black with 88-column formatting, isort, and type hints.”

🧰 Tools
🪛 ast-grep (0.45.1)

[warning] 19-19: Do not make http calls without encryption
Context: "http://backend:8000"
Note: [CWE-319] Cleartext Transmission of Sensitive Information.

(requests-http)


[warning] 20-20: Do not make http calls without encryption
Context: "http://backend:8000/api/attachments/123/executor-download"
Note: [CWE-319] Cleartext Transmission of Sensitive Information.

(requests-http)


[warning] 25-25: Do not make http calls without encryption
Context: "http://backend:8000"
Note: [CWE-319] Cleartext Transmission of Sensitive Information.

(requests-http)


[warning] 27-27: Do not make http calls without encryption
Context: "http://backend:8000/api/attachments/123/executor-download"
Note: [CWE-319] Cleartext Transmission of Sensitive Information.

(requests-http)


[warning] 35-35: Do not make http calls without encryption
Context: "http://backend:8000"
Note: [CWE-319] Cleartext Transmission of Sensitive Information.

(requests-http)


[warning] 76-76: Do not make http calls without encryption
Context: "http://backend:8000"
Note: [CWE-319] Cleartext Transmission of Sensitive Information.

(requests-http)


[warning] 93-93: Do not make http calls without encryption
Context: "http://backend:8000/api/attachments/123/executor-download"
Note: [CWE-319] Cleartext Transmission of Sensitive Information.

(requests-http)

🪛 Ruff (0.16.1)

[error] 76-76: Possible hardcoded password assigned to argument: "auth_token"

(S106)

📍 Affects 5 files
  • backend/tests/init_data/skills/sandbox/test_download_attachment_tool.py#L18-L96 (this comment)
  • chat_shell/tests/test_sandbox_attachment_sync.py#L19-L209
  • chat_shell/tests/test_sandbox_skill_identity.py#L53-L78
  • executor_manager/tests/services/test_sandbox_manager.py#L618-L754
  • executor_manager/tests/services/test_sandbox_skill_sync.py#L20-L135
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/init_data/skills/sandbox/test_download_attachment_tool.py`
around lines 18 - 96, Add type hints throughout the affected test code: in
backend/tests/init_data/skills/sandbox/test_download_attachment_tool.py:18-96,
annotate helper and test parameters and return values, including fixtures; in
chat_shell/tests/test_sandbox_attachment_sync.py:19-209, annotate helper
methods, fixture parameters, and async test returns; in
chat_shell/tests/test_sandbox_skill_identity.py:53-78, annotate monkeypatch and
the async test return; in
executor_manager/tests/services/test_sandbox_manager.py:618-754, annotate
fixture parameters and async test returns; and in
executor_manager/tests/services/test_sandbox_skill_sync.py:20-135, annotate
helper parameters, handler callbacks, and async test returns. Preserve existing
behavior and format the annotations with the project’s typing and style
conventions.

Source: Coding guidelines

18 changes: 18 additions & 0 deletions backend/tests/init_data/skills/sandbox/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,21 @@ def test_prepare_base_params_includes_skill_identity_token():
params = provider._prepare_base_params(context, {})

assert params["skill_identity_token"] == "skill-jwt"


def test_prepare_base_params_includes_load_skill_tool():
"""Sandbox provider should expose active Skill state to sandbox creation."""
provider = SandboxToolProvider()
load_skill_tool = object()
context = SkillToolContext(
task_id=1,
subtask_id=2,
user_id=3,
db_session=None,
ws_emitter=None,
load_skill_tool=load_skill_tool,
)

params = provider._prepare_base_params(context, {})

assert params["load_skill_tool"] is load_skill_tool
Comment on lines +30 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a return annotation to the new test.

test_prepare_base_params_includes_load_skill_tool is new and omits -> None.

Proposed fix
-def test_prepare_base_params_includes_load_skill_tool():
+def test_prepare_base_params_includes_load_skill_tool() -> None:

As per coding guidelines, Python code must follow PEP 8, Black with 88-column formatting, isort, and type hints.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_prepare_base_params_includes_load_skill_tool():
"""Sandbox provider should expose active Skill state to sandbox creation."""
provider = SandboxToolProvider()
load_skill_tool = object()
context = SkillToolContext(
task_id=1,
subtask_id=2,
user_id=3,
db_session=None,
ws_emitter=None,
load_skill_tool=load_skill_tool,
)
params = provider._prepare_base_params(context, {})
assert params["load_skill_tool"] is load_skill_tool
def test_prepare_base_params_includes_load_skill_tool() -> None:
"""Sandbox provider should expose active Skill state to sandbox creation."""
provider = SandboxToolProvider()
load_skill_tool = object()
context = SkillToolContext(
task_id=1,
subtask_id=2,
user_id=3,
db_session=None,
ws_emitter=None,
load_skill_tool=load_skill_tool,
)
params = provider._prepare_base_params(context, {})
assert params["load_skill_tool"] is load_skill_tool
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/init_data/skills/sandbox/test_provider.py` around lines 30 -
45, Add the explicit None return annotation to
test_prepare_base_params_includes_load_skill_tool while leaving its existing
test logic unchanged.

Source: Coding guidelines

6 changes: 1 addition & 5 deletions backend/tests/services/test_sandbox_file_syncer.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,8 @@ def test_filename_with_path(self):

def test_filename_with_backslash(self):
"""Test filename with backslash is sanitized."""
# On Linux, os.path.basename doesn't recognize Windows paths
# So backslashes are replaced with underscores
result = _sanitize_filename("C:\\Windows\\System32\\file.txt")
# Result should not contain backslashes
assert "\\" not in result
assert "/" not in result
assert result == "file.txt"

def test_filename_with_control_chars(self):
"""Test filename with control characters is sanitized."""
Expand Down
10 changes: 10 additions & 0 deletions chat_shell/chat_shell/services/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,16 @@ async def _process_chat(
)
context_metrics_tracker: ContextMetricsTracker | None = None

# The prompt advertises attachment paths inside the task sandbox.
# Materialize those files before the model can call sandbox tools so
# a newly-created sandbox cannot race the upload-time best-effort sync.
from chat_shell.services.sandbox_attachment_sync import (
sync_chat_attachments_to_sandbox,
)

add_span_event("syncing_sandbox_attachments")
await sync_chat_attachments_to_sandbox(request)

# Prepare all context resources in parallel
add_span_event("preparing_context")
t0 = time.perf_counter()
Expand Down
Loading
Loading