From 824254411d0cc54cb7b8863bc0232849b92d3978 Mon Sep 17 00:00:00 2001 From: jiawen7777 Date: Mon, 31 Aug 2026 11:09:48 +0000 Subject: [PATCH] fix(sdk): carry API key on execd requests per use_server_proxy declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-tenant servers authenticate the proxy route too, but execd adapters sent no API key — Sandbox.connect() surfaced the 401 as a 30s timeout. Attach the key only when the client declared proxy mode; in direct mode it must never travel into the untrusted sandbox. Fixes #1686 --- .../opensandbox/adapters/command_adapter.py | 10 +- .../opensandbox/adapters/egress_adapter.py | 6 +- .../adapters/filesystem_adapter.py | 11 +- .../opensandbox/adapters/health_adapter.py | 10 +- .../opensandbox/adapters/isolated_adapter.py | 6 +- .../adapters/isolated_filesystem_adapter.py | 6 +- .../opensandbox/adapters/metrics_adapter.py | 9 +- .../src/opensandbox/models/sandboxes.py | 29 ++- .../sync/adapters/command_adapter.py | 6 +- .../sync/adapters/egress_adapter.py | 6 +- .../sync/adapters/filesystem_adapter.py | 6 +- .../sync/adapters/health_adapter.py | 6 +- .../sync/adapters/isolated_adapter.py | 6 +- .../adapters/isolated_filesystem_adapter.py | 6 +- .../sync/adapters/metrics_adapter.py | 6 +- .../tests/test_execd_proxy_auth_headers.py | 190 ++++++++++++++++++ 16 files changed, 237 insertions(+), 82 deletions(-) create mode 100644 sdks/sandbox/python/tests/test_execd_proxy_auth_headers.py diff --git a/sdks/sandbox/python/src/opensandbox/adapters/command_adapter.py b/sdks/sandbox/python/src/opensandbox/adapters/command_adapter.py index ba54a380e..a35ec283f 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/command_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/command_adapter.py @@ -152,13 +152,7 @@ def __init__( timeout_seconds = self.connection_config.request_timeout.total_seconds() timeout = httpx.Timeout(timeout_seconds) - headers = { - "User-Agent": self.connection_config.user_agent, - **self.connection_config.headers, - **self.execd_endpoint.headers, - } - - # Execd API does not require authentication + headers = self.execd_endpoint.build_request_headers(self.connection_config) self._client = Client( base_url=base_url, timeout=timeout, @@ -194,7 +188,7 @@ def __init__( ) async def _get_client(self): - """Return the client for execd API (no auth required).""" + """Return the client for execd API.""" return self._client def _get_execd_url(self, path: str) -> str: diff --git a/sdks/sandbox/python/src/opensandbox/adapters/egress_adapter.py b/sdks/sandbox/python/src/opensandbox/adapters/egress_adapter.py index 95b553d9a..96a542092 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/egress_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/egress_adapter.py @@ -112,11 +112,7 @@ def __init__( base_url = f"{self.connection_config.protocol}://{self.endpoint.endpoint}" timeout_seconds = self.connection_config.request_timeout.total_seconds() timeout = httpx.Timeout(timeout_seconds) - headers = { - "User-Agent": self.connection_config.user_agent, - **self.connection_config.headers, - **self.endpoint.headers, - } + headers = self.endpoint.build_request_headers(self.connection_config) self._client = Client( base_url=base_url, diff --git a/sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py b/sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py index e0a72f9ce..0414d4c22 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py @@ -108,11 +108,7 @@ def __init__( base_url = self._get_execd_base_url() timeout_seconds = self.connection_config.request_timeout.total_seconds() timeout = httpx.Timeout(timeout_seconds) - headers = { - "User-Agent": self.connection_config.user_agent, - **self.connection_config.headers, - **self.execd_endpoint.headers, - } + headers = self.execd_endpoint.build_request_headers(self.connection_config) self._httpx_client = httpx.AsyncClient( base_url=base_url, @@ -121,7 +117,6 @@ def __init__( transport=self.connection_config.transport, ) - # Execd API does not require authentication self._client = Client( base_url=base_url, timeout=timeout, @@ -133,11 +128,11 @@ def _get_execd_base_url(self) -> str: return f"{protocol}://{self.execd_endpoint.endpoint}" async def _get_httpx_client(self) -> httpx.AsyncClient: - """Return adapter-owned httpx client for execd (no auth required).""" + """Return adapter-owned httpx client for execd.""" return self._httpx_client async def _get_client(self): - """Return the client for execd API (no auth required).""" + """Return the client for execd API.""" return self._client def _get_execd_url(self, path: str) -> str: diff --git a/sdks/sandbox/python/src/opensandbox/adapters/health_adapter.py b/sdks/sandbox/python/src/opensandbox/adapters/health_adapter.py index e76118c38..3a002f7d4 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/health_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/health_adapter.py @@ -61,13 +61,7 @@ def __init__( timeout_seconds = self.connection_config.request_timeout.total_seconds() timeout = httpx.Timeout(timeout_seconds) - headers = { - "User-Agent": self.connection_config.user_agent, - **self.connection_config.headers, - **self.execd_endpoint.headers, - } - - # Execd API does not require authentication + headers = self.execd_endpoint.build_request_headers(self.connection_config) self._client = Client( base_url=base_url, timeout=timeout, @@ -82,7 +76,7 @@ def __init__( self._client.set_async_httpx_client(self._httpx_client) async def _get_client(self): - """Return the client for execd API (no auth required).""" + """Return the client for execd API.""" return self._client async def ping(self, sandbox_id: str) -> bool: diff --git a/sdks/sandbox/python/src/opensandbox/adapters/isolated_adapter.py b/sdks/sandbox/python/src/opensandbox/adapters/isolated_adapter.py index 99638ee12..89b025934 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/isolated_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/isolated_adapter.py @@ -237,11 +237,7 @@ def __init__( timeout_seconds = self.connection_config.request_timeout.total_seconds() timeout = httpx.Timeout(timeout_seconds) - headers = { - "User-Agent": self.connection_config.user_agent, - **self.connection_config.headers, - **self.execd_endpoint.headers, - } + headers = self.execd_endpoint.build_request_headers(self.connection_config) self._httpx_client = httpx.AsyncClient( base_url=base_url, diff --git a/sdks/sandbox/python/src/opensandbox/adapters/isolated_filesystem_adapter.py b/sdks/sandbox/python/src/opensandbox/adapters/isolated_filesystem_adapter.py index e0372a136..823407896 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/isolated_filesystem_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/isolated_filesystem_adapter.py @@ -81,11 +81,7 @@ def __init__( base_url = f"{protocol}://{self.execd_endpoint.endpoint}" timeout_seconds = self.connection_config.request_timeout.total_seconds() timeout = httpx.Timeout(timeout_seconds) - headers = { - "User-Agent": self.connection_config.user_agent, - **self.connection_config.headers, - **self.execd_endpoint.headers, - } + headers = self.execd_endpoint.build_request_headers(self.connection_config) self._httpx_client = httpx.AsyncClient( base_url=base_url, diff --git a/sdks/sandbox/python/src/opensandbox/adapters/metrics_adapter.py b/sdks/sandbox/python/src/opensandbox/adapters/metrics_adapter.py index 0cb99a2fb..2223e4844 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/metrics_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/metrics_adapter.py @@ -70,13 +70,8 @@ def __init__( timeout_seconds = self.connection_config.request_timeout.total_seconds() timeout = httpx.Timeout(timeout_seconds) - headers = { - "User-Agent": self.connection_config.user_agent, - **self.connection_config.headers, - **self.execd_endpoint.headers, - } + headers = self.execd_endpoint.build_request_headers(self.connection_config) - # Execd API does not require authentication self._client = Client( base_url=base_url, timeout=timeout, @@ -91,7 +86,7 @@ def __init__( self._client.set_async_httpx_client(self._httpx_client) async def _get_client(self): - """Return the client for execd API (no auth required).""" + """Return the client for execd API.""" return self._client async def get_metrics(self, sandbox_id: str) -> SandboxMetrics: diff --git a/sdks/sandbox/python/src/opensandbox/models/sandboxes.py b/sdks/sandbox/python/src/opensandbox/models/sandboxes.py index 975d457c6..9eee13fe2 100644 --- a/sdks/sandbox/python/src/opensandbox/models/sandboxes.py +++ b/sdks/sandbox/python/src/opensandbox/models/sandboxes.py @@ -21,10 +21,14 @@ import re from datetime import datetime -from typing import Literal +from typing import TYPE_CHECKING, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +if TYPE_CHECKING: + from opensandbox.config import ConnectionConfig + from opensandbox.config.connection_sync import ConnectionConfigSync + class SandboxImageAuth(BaseModel): """ @@ -784,6 +788,29 @@ class SandboxEndpoint(BaseModel): description="Headers that must be included on every request targeting this endpoint (e.g. when the server requires them for routing or auth). Empty if not required.", ) + def build_request_headers( + self, + connection_config: "ConnectionConfig | ConnectionConfigSync", + ) -> dict[str, str]: + """ + Default headers for execd-plane requests to this endpoint. + + The API key is attached only when the client declared server-proxy + mode (``ConnectionConfig.use_server_proxy``): such requests pass the + server's auth gate. In direct mode execd performs no auth and the + key must never travel into the untrusted sandbox. + """ + headers = { + "User-Agent": connection_config.user_agent, + **connection_config.headers, + **self.headers, + } + if connection_config.use_server_proxy: + api_key = connection_config.get_api_key() + if api_key: + headers["OPEN-SANDBOX-API-KEY"] = api_key + return headers + class PaginationInfo(BaseModel): """ diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/command_adapter.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/command_adapter.py index e47d79a79..64a5841ea 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/adapters/command_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/command_adapter.py @@ -140,11 +140,7 @@ def __init__( timeout_seconds = self.connection_config.request_timeout.total_seconds() timeout = httpx.Timeout(timeout_seconds) - headers = { - "User-Agent": self.connection_config.user_agent, - **self.connection_config.headers, - **self.execd_endpoint.headers, - } + headers = self.execd_endpoint.build_request_headers(self.connection_config) self._client = Client(base_url=base_url, timeout=timeout) diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/egress_adapter.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/egress_adapter.py index e61ac4aa6..66b0e4a64 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/adapters/egress_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/egress_adapter.py @@ -112,11 +112,7 @@ def __init__( base_url = f"{self.connection_config.protocol}://{self.endpoint.endpoint}" timeout_seconds = self.connection_config.request_timeout.total_seconds() timeout = httpx.Timeout(timeout_seconds) - headers = { - "User-Agent": self.connection_config.user_agent, - **self.connection_config.headers, - **self.endpoint.headers, - } + headers = self.endpoint.build_request_headers(self.connection_config) self._client = Client( base_url=base_url, diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py index 565691664..4de29a80a 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py @@ -90,11 +90,7 @@ def __init__( base_url = self._get_execd_base_url() timeout_seconds = self.connection_config.request_timeout.total_seconds() timeout = httpx.Timeout(timeout_seconds) - headers = { - "User-Agent": self.connection_config.user_agent, - **self.connection_config.headers, - **self.execd_endpoint.headers, - } + headers = self.execd_endpoint.build_request_headers(self.connection_config) self._httpx_client = httpx.Client( base_url=base_url, diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/health_adapter.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/health_adapter.py index 9195e253a..b9c025db3 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/adapters/health_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/health_adapter.py @@ -39,11 +39,7 @@ def __init__( base_url = f"{self.connection_config.protocol}://{self.execd_endpoint.endpoint}" timeout = httpx.Timeout(self.connection_config.request_timeout.total_seconds()) - headers = { - "User-Agent": self.connection_config.user_agent, - **self.connection_config.headers, - **self.execd_endpoint.headers, - } + headers = self.execd_endpoint.build_request_headers(self.connection_config) self._client = Client(base_url=base_url, timeout=timeout) self._httpx_client = httpx.Client( diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/isolated_adapter.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/isolated_adapter.py index 0e9ac1284..81703560a 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/adapters/isolated_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/isolated_adapter.py @@ -179,11 +179,7 @@ def __init__( timeout_seconds = self.connection_config.request_timeout.total_seconds() timeout = httpx.Timeout(timeout_seconds) - headers = { - "User-Agent": self.connection_config.user_agent, - **self.connection_config.headers, - **self.execd_endpoint.headers, - } + headers = self.execd_endpoint.build_request_headers(self.connection_config) self._httpx_client = httpx.Client( base_url=base_url, diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/isolated_filesystem_adapter.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/isolated_filesystem_adapter.py index 504e9b92c..673b76c2e 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/adapters/isolated_filesystem_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/isolated_filesystem_adapter.py @@ -81,11 +81,7 @@ def __init__( base_url = f"{protocol}://{self.execd_endpoint.endpoint}" timeout_seconds = self.connection_config.request_timeout.total_seconds() timeout = httpx.Timeout(timeout_seconds) - headers = { - "User-Agent": self.connection_config.user_agent, - **self.connection_config.headers, - **self.execd_endpoint.headers, - } + headers = self.execd_endpoint.build_request_headers(self.connection_config) self._httpx_client = httpx.Client( base_url=base_url, diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/metrics_adapter.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/metrics_adapter.py index 3161c693c..1863cfcc5 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/adapters/metrics_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/metrics_adapter.py @@ -48,11 +48,7 @@ def __init__( base_url = f"{self.connection_config.protocol}://{self.execd_endpoint.endpoint}" timeout = httpx.Timeout(self.connection_config.request_timeout.total_seconds()) - headers = { - "User-Agent": self.connection_config.user_agent, - **self.connection_config.headers, - **self.execd_endpoint.headers, - } + headers = self.execd_endpoint.build_request_headers(self.connection_config) self._client = Client(base_url=base_url, timeout=timeout) self._httpx_client = httpx.Client( diff --git a/sdks/sandbox/python/tests/test_execd_proxy_auth_headers.py b/sdks/sandbox/python/tests/test_execd_proxy_auth_headers.py new file mode 100644 index 000000000..2e31d7d0f --- /dev/null +++ b/sdks/sandbox/python/tests/test_execd_proxy_auth_headers.py @@ -0,0 +1,190 @@ +# +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Execd-plane request headers follow the use_server_proxy declaration. + +The declaration is the only signal: proxy URL shapes are deployment-derived +and cannot be guessed. When the client declared server-proxy mode the +request passes the server's auth gate and must carry the API key; in direct +mode the key would travel straight into the untrusted sandbox and must +never be attached. +""" + +from __future__ import annotations + +import httpx +import pytest + +from opensandbox.adapters.filesystem_adapter import FilesystemAdapter +from opensandbox.config import ConnectionConfig +from opensandbox.config.connection_sync import ConnectionConfigSync +from opensandbox.models.filesystem import WriteEntry +from opensandbox.models.sandboxes import SandboxEndpoint +from opensandbox.sync.adapters.filesystem_adapter import FilesystemAdapterSync + +# Realistic shapes: proxy base comes from the server's configured eip and may +# carry a path prefix; direct is a pod address. +PROXY_FORM_ENDPOINT = "gateway.example.com/opensandbox/sandboxes/sbx-123/proxy/18080" +DIRECT_ENDPOINT = "10.42.1.7:18080" + + +class _CaptureAsyncTransport(httpx.AsyncBaseTransport): + def __init__(self) -> None: + self.request: httpx.Request | None = None + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + self.request = request + await request.aread() + return httpx.Response(200, request=request, content=b"{}") + + +class _CaptureSyncTransport(httpx.BaseTransport): + def __init__(self) -> None: + self.request: httpx.Request | None = None + + def handle_request(self, request: httpx.Request) -> httpx.Response: + self.request = request + request.read() + return httpx.Response(200, request=request, content=b"{}") + + +def _headers(request: httpx.Request | None) -> dict[str, str]: + assert request is not None + return {k.lower(): v for k, v in request.headers.items()} + + +@pytest.mark.asyncio +async def test_async_execd_request_carries_api_key_when_proxy_declared() -> None: + transport = _CaptureAsyncTransport() + adapter = FilesystemAdapter( + ConnectionConfig( + api_key="tenant-key-1", + protocol="https", + transport=transport, + use_server_proxy=True, + ), + SandboxEndpoint(endpoint=PROXY_FORM_ENDPOINT), + ) + + await adapter.write_files([WriteEntry(path="/tmp/a.txt", data="hello")]) + + assert _headers(transport.request)["open-sandbox-api-key"] == "tenant-key-1" + + await adapter._httpx_client.aclose() + + +@pytest.mark.asyncio +async def test_async_execd_declaration_gates_key_regardless_of_endpoint_shape() -> None: + transport = _CaptureAsyncTransport() + adapter = FilesystemAdapter( + ConnectionConfig( + api_key="tenant-key-1", + protocol="http", + transport=transport, + # The declaration is the only gate: the client said proxy mode, + # so the key is attached even though the endpoint string has no + # recognizable proxy-route shape (deployments control their URLs). + use_server_proxy=True, + ), + SandboxEndpoint(endpoint=DIRECT_ENDPOINT), + ) + + await adapter.write_files([WriteEntry(path="/tmp/a.txt", data="hello")]) + + assert _headers(transport.request)["open-sandbox-api-key"] == "tenant-key-1" + + await adapter._httpx_client.aclose() + + +@pytest.mark.asyncio +async def test_async_execd_request_never_carries_api_key_in_direct_mode() -> None: + transport = _CaptureAsyncTransport() + adapter = FilesystemAdapter( + ConnectionConfig( + api_key="tenant-key-1", + protocol="http", + transport=transport, + use_server_proxy=False, + ), + SandboxEndpoint(endpoint=DIRECT_ENDPOINT), + ) + + await adapter.write_files([WriteEntry(path="/tmp/a.txt", data="hello")]) + + # The key must not travel straight into the untrusted sandbox. + assert "open-sandbox-api-key" not in _headers(transport.request) + + await adapter._httpx_client.aclose() + + +@pytest.mark.asyncio +async def test_async_execd_request_without_api_key_omits_header( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("OPEN_SANDBOX_API_KEY", raising=False) + transport = _CaptureAsyncTransport() + adapter = FilesystemAdapter( + ConnectionConfig( + api_key=None, + protocol="https", + transport=transport, + use_server_proxy=True, + ), + SandboxEndpoint(endpoint=PROXY_FORM_ENDPOINT), + ) + + await adapter.write_files([WriteEntry(path="/tmp/a.txt", data="hello")]) + + assert "open-sandbox-api-key" not in _headers(transport.request) + + await adapter._httpx_client.aclose() + + +def test_sync_execd_request_carries_api_key_when_proxy_declared() -> None: + transport = _CaptureSyncTransport() + adapter = FilesystemAdapterSync( + ConnectionConfigSync( + api_key="tenant-key-1", + protocol="https", + transport=transport, + use_server_proxy=True, + ), + SandboxEndpoint(endpoint=PROXY_FORM_ENDPOINT), + ) + + adapter.write_files([WriteEntry(path="/tmp/a.txt", data="hello")]) + + assert _headers(transport.request)["open-sandbox-api-key"] == "tenant-key-1" + + adapter._httpx_client.close() + + +def test_sync_execd_request_never_carries_api_key_in_direct_mode() -> None: + transport = _CaptureSyncTransport() + adapter = FilesystemAdapterSync( + ConnectionConfigSync( + api_key="tenant-key-1", + protocol="http", + transport=transport, + use_server_proxy=False, + ), + SandboxEndpoint(endpoint=DIRECT_ENDPOINT), + ) + + adapter.write_files([WriteEntry(path="/tmp/a.txt", data="hello")]) + + assert "open-sandbox-api-key" not in _headers(transport.request) + + adapter._httpx_client.close()