diff --git a/backend/Dockerfile b/backend/Dockerfile index e3bbbfc95..919e49015 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -47,6 +47,7 @@ COPY . . RUN useradd --create-home clawith && \ mkdir -p /data/agents && \ chmod +x /app/entrypoint.sh && \ + chmod u+s /usr/bin/bwrap && \ chown -R clawith:clawith /app /data # Note: USER is removed to allow entrypoint.sh to fix permissions of mounted volumes diff --git a/backend/app/config.py b/backend/app/config.py index 4c2dbb458..e6066f6ce 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -171,6 +171,8 @@ class Settings(BaseSettings): FEISHU_REDIRECT_URI: str = "" PUBLIC_BASE_URL: str = "" HTTP_PROXY: str = "" + HTTPS_PROXY: str = "" + NO_PROXY: str = "" # CORS CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173"] @@ -192,6 +194,9 @@ class Settings(BaseSettings): SANDBOX_ALLOW_UNSAFE_FALLBACK_WHEN_BWRAP_MISSING: bool = _default_allow_unsafe_bwrap_fallback() SANDBOX_DEFAULT_TIMEOUT: int = 30 SANDBOX_MAX_TIMEOUT: int = 60 + SANDBOX_HTTP_PROXY: str = "" + SANDBOX_HTTPS_PROXY: str = "" + SANDBOX_NO_PROXY: str = "" @field_validator( "LANGGRAPH_CHECKPOINT_DATABASE_URL", @@ -256,4 +261,7 @@ def get_sandbox_config() -> SandboxConfig: allow_unsafe_fallback_when_bwrap_missing=settings.SANDBOX_ALLOW_UNSAFE_FALLBACK_WHEN_BWRAP_MISSING, default_timeout=settings.SANDBOX_DEFAULT_TIMEOUT, max_timeout=settings.SANDBOX_MAX_TIMEOUT, + http_proxy=settings.SANDBOX_HTTP_PROXY or settings.HTTP_PROXY or None, + https_proxy=settings.SANDBOX_HTTPS_PROXY or settings.HTTPS_PROXY or None, + no_proxy=settings.SANDBOX_NO_PROXY or settings.NO_PROXY or None, ) diff --git a/backend/app/services/sandbox/config.py b/backend/app/services/sandbox/config.py index 6430142d7..54da3c788 100644 --- a/backend/app/services/sandbox/config.py +++ b/backend/app/services/sandbox/config.py @@ -38,6 +38,11 @@ class SandboxConfig(BaseModel): default_timeout: int = Field(default=30, ge=1, le=3600) max_timeout: int = Field(default=60, ge=1, le=3600) + # Proxy options + http_proxy: Optional[str] = None + https_proxy: Optional[str] = None + no_proxy: Optional[str] = None + # Language mapping for API sandboxes # Maps our internal language names to API-specific language IDs language_mapping: dict[str, str] = Field(default_factory=lambda: { @@ -113,5 +118,8 @@ def get_value(key: str, default=None, encrypt: bool = False): ), default_timeout=get_value("default_timeout", 30), max_timeout=get_value("max_timeout", 60), + http_proxy=get_value("http_proxy", None), + https_proxy=get_value("https_proxy", None), + no_proxy=get_value("no_proxy", None), ) return result diff --git a/backend/app/services/sandbox/local/docker_backend.py b/backend/app/services/sandbox/local/docker_backend.py index 6d5fd64f6..6dea0a8bb 100644 --- a/backend/app/services/sandbox/local/docker_backend.py +++ b/backend/app/services/sandbox/local/docker_backend.py @@ -1,7 +1,7 @@ """Local docker-based sandbox backend.""" +import os import time -from pathlib import Path from app.services.sandbox.base import BaseSandboxBackend, ExecutionResult, SandboxCapabilities from app.services.sandbox.config import SandboxConfig @@ -109,6 +109,18 @@ async def execute( "HOME": "/root", "PYTHONDONTWRITEBYTECODE": "1", } + http_proxy = self.config.http_proxy or os.environ.get("http_proxy") or os.environ.get("HTTP_PROXY") + https_proxy = self.config.https_proxy or os.environ.get("https_proxy") or os.environ.get("HTTPS_PROXY") + no_proxy = self.config.no_proxy or os.environ.get("no_proxy") or os.environ.get("NO_PROXY") + if http_proxy: + env["http_proxy"] = http_proxy + env["HTTP_PROXY"] = http_proxy + if https_proxy: + env["https_proxy"] = https_proxy + env["HTTPS_PROXY"] = https_proxy + if no_proxy: + env["no_proxy"] = no_proxy + env["NO_PROXY"] = no_proxy # Build docker run command if language == "python": @@ -179,7 +191,7 @@ async def execute( except Exception as e: duration_ms = int((time.time() - start_time) * 1000) error_msg = str(e) - logger.exception(f"[Docker] Execution error") + logger.exception("[Docker] Execution error") # Handle timeout specifically if "timeout" in error_msg.lower(): diff --git a/backend/app/services/sandbox/local/subprocess_backend.py b/backend/app/services/sandbox/local/subprocess_backend.py index fd738cf23..1b434cecd 100644 --- a/backend/app/services/sandbox/local/subprocess_backend.py +++ b/backend/app/services/sandbox/local/subprocess_backend.py @@ -144,6 +144,18 @@ def _build_safe_env(self, work_path: Path) -> dict[str, str]: "PIP_CACHE_DIR": str(workspace_tmp / "pip-cache"), "PIP_DISABLE_PIP_VERSION_CHECK": "1", } + http_proxy = self.config.http_proxy or os.environ.get("http_proxy") or os.environ.get("HTTP_PROXY") + https_proxy = self.config.https_proxy or os.environ.get("https_proxy") or os.environ.get("HTTPS_PROXY") + no_proxy = self.config.no_proxy or os.environ.get("no_proxy") or os.environ.get("NO_PROXY") + if http_proxy: + env["http_proxy"] = http_proxy + env["HTTP_PROXY"] = http_proxy + if https_proxy: + env["https_proxy"] = https_proxy + env["HTTPS_PROXY"] = https_proxy + if no_proxy: + env["no_proxy"] = no_proxy + env["NO_PROXY"] = no_proxy return env def _bind_if_exists(self, host_path: str, guest_path: str | None = None, *, read_only: bool = True) -> list[str]: @@ -288,11 +300,10 @@ def _build_bwrap_command(self, command: list[str], work_path: Path, venv_path: P bwrap, "--die-with-parent", "--new-session", - "--unshare-user", "--unshare-ipc", "--unshare-pid", "--unshare-uts", - "--unshare-cgroup", + "--unshare-cgroup-try", *base_binds, "--bind", "/data/agents/.uv-cache", "/uv-cache", "--bind", str(work_path), "/workspace", @@ -312,8 +323,19 @@ def _build_bwrap_command(self, command: list[str], work_path: Path, venv_path: P "--setenv", "PIP_CACHE_DIR", "/workspace/.tmp/pip-cache", "--setenv", "PIP_DISABLE_PIP_VERSION_CHECK", "1", "--setenv", "UV_CACHE_DIR", "/uv-cache", - "--chdir", "/workspace", ] + http_proxy = self.config.http_proxy or os.environ.get("http_proxy") or os.environ.get("HTTP_PROXY") + https_proxy = self.config.https_proxy or os.environ.get("https_proxy") or os.environ.get("HTTPS_PROXY") + no_proxy = self.config.no_proxy or os.environ.get("no_proxy") or os.environ.get("NO_PROXY") + if http_proxy: + cmd.extend(["--setenv", "http_proxy", http_proxy, "--setenv", "HTTP_PROXY", http_proxy]) + if https_proxy: + cmd.extend(["--setenv", "https_proxy", https_proxy, "--setenv", "HTTPS_PROXY", https_proxy]) + if no_proxy: + cmd.extend(["--setenv", "no_proxy", no_proxy, "--setenv", "NO_PROXY", no_proxy]) + + cmd.append("--chdir") + cmd.append("/workspace") if not self.config.allow_network: cmd.append("--unshare-net") cmd.extend(command) diff --git a/backend/tests/test_sandbox_subprocess_backend.py b/backend/tests/test_sandbox_subprocess_backend.py index 260b9ac0a..8ef6ad915 100644 --- a/backend/tests/test_sandbox_subprocess_backend.py +++ b/backend/tests/test_sandbox_subprocess_backend.py @@ -73,3 +73,53 @@ async def fake_create(*_args, **_kwargs): await backend._ensure_workspace_venv(tmp_path / ".venv") assert terminated == [456] + + +def test_subprocess_backend_proxy_env_propagation(tmp_path: Path) -> None: + config = SandboxConfig( + http_proxy="http://127.0.0.1:8080", + https_proxy="http://127.0.0.1:8081", + no_proxy="localhost,127.0.0.1", + ) + backend = SubprocessBackend(config) + env = backend._build_safe_env(tmp_path) + + assert env.get("http_proxy") == "http://127.0.0.1:8080" + assert env.get("HTTP_PROXY") == "http://127.0.0.1:8080" + assert env.get("https_proxy") == "http://127.0.0.1:8081" + assert env.get("HTTPS_PROXY") == "http://127.0.0.1:8081" + assert env.get("no_proxy") == "localhost,127.0.0.1" + assert env.get("NO_PROXY") == "localhost,127.0.0.1" + + +def test_subprocess_backend_proxy_bwrap_command(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr("shutil.which", lambda cmd: "/usr/bin/bwrap" if cmd == "bwrap" else None) + config = SandboxConfig( + http_proxy="http://proxy.example.com:8080", + https_proxy="http://proxy.example.com:8443", + ) + backend = SubprocessBackend(config) + cmd = backend._build_bwrap_command(["python3", "-c", "print(1)"], tmp_path, tmp_path / ".venv") + + assert cmd is not None + assert "--unshare-cgroup-try" in cmd + assert "--unshare-cgroup" not in cmd + assert "--unshare-user" not in cmd + assert "--setenv" in cmd + idx_http = cmd.index("http_proxy") + assert cmd[idx_http + 1] == "http://proxy.example.com:8080" + idx_https = cmd.index("https_proxy") + assert cmd[idx_https + 1] == "http://proxy.example.com:8443" + + +def test_sandbox_config_proxy_parsing() -> None: + data = { + "http_proxy": "http://10.0.0.1:3128", + "https_proxy": "http://10.0.0.1:3128", + "no_proxy": ".local,10.0.0.0/8", + } + config = SandboxConfig.from_dict(data) + assert config.http_proxy == "http://10.0.0.1:3128" + assert config.https_proxy == "http://10.0.0.1:3128" + assert config.no_proxy == ".local,10.0.0.0/8" +