diff --git a/.changeset/connect-envs-on-reconnect.md b/.changeset/connect-envs-on-reconnect.md new file mode 100644 index 0000000000..7755a822c7 --- /dev/null +++ b/.changeset/connect-envs-on-reconnect.md @@ -0,0 +1,6 @@ +--- +'e2b': minor +'@e2b/python-sdk': minor +--- + +Add support for passing environment variables when reconnecting to a sandbox via `Sandbox.connect()`. diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index d3c42e3ee2..9d450ef59b 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -1747,6 +1747,8 @@ export interface components { step?: string; }; ConnectSandbox: { + /** @description Environment variables to set in the sandbox on reconnect. Merged into the sandbox environment and applied to processes started after resume. */ + envVars?: components["schemas"]["EnvVars"]; /** * Format: int32 * @description Timeout in seconds from the current time after which the sandbox should expire diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 6fc03addb7..52e100e0ca 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -207,6 +207,11 @@ export type SandboxConnectOpts = ConnectionOpts & { * @default 300_000 // 5 minutes */ timeoutMs?: number + /** + * Custom environment variables to set in the sandbox on reconnect. + * These are merged into the sandbox environment and applied to processes started after resume. + */ + envs?: Record } /** @@ -852,6 +857,7 @@ export class SandboxApi { }, body: { timeout: timeoutToSeconds(timeoutMs), + envVars: opts?.envs, }, signal: config.getSignal(opts?.requestTimeoutMs), }) diff --git a/packages/js-sdk/tests/sandbox/configPropagation.test.ts b/packages/js-sdk/tests/sandbox/configPropagation.test.ts index ef5ba8e188..dc805ccad9 100644 --- a/packages/js-sdk/tests/sandbox/configPropagation.test.ts +++ b/packages/js-sdk/tests/sandbox/configPropagation.test.ts @@ -57,3 +57,43 @@ describe('Sandbox API config propagation', () => { assert.equal(opts?.debug, baseConfig.debug) }) }) + +describe('Sandbox connect envs propagation', () => { + const mockConnectResult = { + sandboxId: 'sbx-test', + sandboxDomain: 'sandbox.e2b.dev', + envdVersion: '0.2.4', + envdAccessToken: 'tok', + trafficAccessToken: 'tok', + } + + afterEach(() => { + vi.restoreAllMocks() + }) + + test('forwards envs to connectSandbox when provided', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const connectSpy = vi + .spyOn(SandboxApi as any, 'connectSandbox') + .mockResolvedValue(mockConnectResult) + const sandbox = createSandbox() + + await sandbox.connect({ envs: { MY_KEY: 'my_value' } }) + + const opts = connectSpy.mock.calls[0][1] + assert.deepEqual(opts?.envs, { MY_KEY: 'my_value' }) + }) + + test('does not include envs in opts when not provided', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const connectSpy = vi + .spyOn(SandboxApi as any, 'connectSandbox') + .mockResolvedValue(mockConnectResult) + const sandbox = createSandbox() + + await sandbox.connect() + + const opts = connectSpy.mock.calls[0][1] + assert.isUndefined(opts?.envs) + }) +}) diff --git a/packages/python-sdk/e2b/api/client/models/connect_sandbox.py b/packages/python-sdk/e2b/api/client/models/connect_sandbox.py index de7a8f8c19..2ad8e4e141 100644 --- a/packages/python-sdk/e2b/api/client/models/connect_sandbox.py +++ b/packages/python-sdk/e2b/api/client/models/connect_sandbox.py @@ -1,9 +1,11 @@ from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, TypeVar, Union from attrs import define as _attrs_define from attrs import field as _attrs_field +from ..types import UNSET, Unset + T = TypeVar("T", bound="ConnectSandbox") @@ -12,14 +14,18 @@ class ConnectSandbox: """ Attributes: timeout (int): Timeout in seconds from the current time after which the sandbox should expire + env_vars (Union[Unset, Any]): Environment variables to set in the sandbox on reconnect. """ timeout: int + env_vars: Union[Unset, Any] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: timeout = self.timeout + env_vars = self.env_vars + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -27,6 +33,8 @@ def to_dict(self) -> dict[str, Any]: "timeout": timeout, } ) + if env_vars is not UNSET: + field_dict["envVars"] = env_vars return field_dict @@ -35,8 +43,11 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: d = dict(src_dict) timeout = d.pop("timeout") + env_vars = d.pop("envVars", UNSET) + connect_sandbox = cls( timeout=timeout, + env_vars=env_vars, ) connect_sandbox.additional_properties = d diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 3dd7044ea7..fb8809cfd5 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -249,6 +249,7 @@ async def create( async def connect( self, timeout: Optional[int] = None, + envs: Optional[Dict[str, str]] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -259,6 +260,8 @@ async def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param envs: Custom environment variables to set in the sandbox on reconnect. + Merged into the sandbox environment and applied to processes started after resume. :return: A running sandbox instance @example @@ -277,6 +280,7 @@ async def connect( async def connect( sandbox_id: str, timeout: Optional[int] = None, + envs: Optional[Dict[str, str]] = None, **opts: Unpack[ApiParams], ) -> "AsyncSandbox": """ @@ -288,6 +292,8 @@ async def connect( :param sandbox_id: Sandbox ID :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param envs: Custom environment variables to set in the sandbox on reconnect. + Merged into the sandbox environment and applied to processes started after resume. :return: A running sandbox instance @example @@ -305,6 +311,7 @@ async def connect( async def connect( self, timeout: Optional[int] = None, + envs: Optional[Dict[str, str]] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -315,6 +322,8 @@ async def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param envs: Custom environment variables to set in the sandbox on reconnect. + Merged into the sandbox environment and applied to processes started after resume. :return: A running sandbox instance @example @@ -329,6 +338,7 @@ async def connect( await SandboxApi._cls_connect( sandbox_id=self.sandbox_id, timeout=timeout, + envs=envs, **self.connection_config.get_api_params(**opts), ) @@ -853,11 +863,13 @@ async def _cls_connect_sandbox( cls, sandbox_id: str, timeout: Optional[int] = None, + envs: Optional[Dict[str, str]] = None, **opts: Unpack[ApiParams], ) -> Self: sandbox = await SandboxApi._cls_connect( sandbox_id=sandbox_id, timeout=timeout, + envs=envs, **opts, ) diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index 8946b038fa..201fa60319 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -378,6 +378,7 @@ async def _cls_connect( cls, sandbox_id: str, timeout: Optional[int] = None, + envs: Optional[Dict[str, str]] = None, **opts: Unpack[ApiParams], ) -> Sandbox: timeout = timeout or SandboxBase.default_sandbox_timeout @@ -395,7 +396,10 @@ async def _cls_connect( res = await post_sandboxes_sandbox_id_connect.asyncio_detailed( sandbox_id, client=api_client, - body=ConnectSandbox(timeout=timeout), + body=ConnectSandbox( + timeout=timeout, + env_vars=envs if envs is not None else UNSET, + ), ) if res.status_code == 404: diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 43f3a858c5..ac35ba1358 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -247,6 +247,7 @@ def create( def connect( self, timeout: Optional[int] = None, + envs: Optional[Dict[str, str]] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -257,6 +258,8 @@ def connect( :param timeout: Timeout for the sandbox in **seconds** For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param envs: Custom environment variables to set in the sandbox on reconnect. + Merged into the sandbox environment and applied to processes started after resume. :return: A running sandbox instance @example @@ -276,6 +279,7 @@ def connect( def connect( sandbox_id: str, timeout: Optional[int] = None, + envs: Optional[Dict[str, str]] = None, **opts: Unpack[ApiParams], ) -> "Sandbox": """ @@ -287,6 +291,8 @@ def connect( :param sandbox_id: Sandbox ID :param timeout: Timeout for the sandbox in **seconds**. For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param envs: Custom environment variables to set in the sandbox on reconnect. + Merged into the sandbox environment and applied to processes started after resume. :return: A running sandbox instance @example @@ -304,6 +310,7 @@ def connect( def connect( self, timeout: Optional[int] = None, + envs: Optional[Dict[str, str]] = None, **opts: Unpack[ApiParams], ) -> Self: """ @@ -314,6 +321,8 @@ def connect( :param timeout: Timeout for the sandbox in **seconds**. For running sandboxes, the timeout will update only if the new timeout is longer than the existing one. + :param envs: Custom environment variables to set in the sandbox on reconnect. + Merged into the sandbox environment and applied to processes started after resume. :return: A running sandbox instance @example @@ -328,6 +337,7 @@ def connect( SandboxApi._cls_connect( sandbox_id=self.sandbox_id, timeout=timeout, + envs=envs, **self.connection_config.get_api_params(**opts), ) @@ -848,11 +858,13 @@ def _cls_connect_sandbox( cls, sandbox_id: str, timeout: Optional[int] = None, + envs: Optional[Dict[str, str]] = None, **opts: Unpack[ApiParams], ) -> Self: sandbox = SandboxApi._cls_connect( sandbox_id=sandbox_id, timeout=timeout, + envs=envs, **opts, ) diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index 4cad1247dc..44312795bf 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -287,6 +287,7 @@ def _cls_connect( cls, sandbox_id: str, timeout: Optional[int] = None, + envs: Optional[Dict[str, str]] = None, **opts: Unpack[ApiParams], ) -> Sandbox: timeout = timeout or SandboxBase.default_sandbox_timeout @@ -303,7 +304,10 @@ def _cls_connect( res = post_sandboxes_sandbox_id_connect.sync_detailed( sandbox_id, client=api_client, - body=ConnectSandbox(timeout=timeout), + body=ConnectSandbox( + timeout=timeout, + env_vars=envs if envs is not None else UNSET, + ), ) if res.status_code == 404: diff --git a/packages/python-sdk/tests/async/sandbox_async/test_config_propagation.py b/packages/python-sdk/tests/async/sandbox_async/test_config_propagation.py index 0e547ad0c9..fbd47738c0 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_config_propagation.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_config_propagation.py @@ -67,6 +67,58 @@ async def test_pause_passes_connection_config_without_overrides(monkeypatch): assert mock_pause.call_args.kwargs["headers"]["X-Test"] == BASE_HEADERS["X-Test"] +@pytest.mark.skip_debug() +async def test_connect_forwards_envs(monkeypatch): + mock_connect = AsyncMock(return_value=None) + monkeypatch.setattr(sandbox_async_main.SandboxApi, "_cls_connect", mock_connect) + + sandbox = create_sandbox(monkeypatch) + await sandbox.connect(envs={"MY_KEY": "my_value"}) + + mock_connect.assert_awaited_once() + assert mock_connect.call_args.kwargs["envs"] == {"MY_KEY": "my_value"} + + +@pytest.mark.skip_debug() +async def test_connect_envs_is_none_when_not_provided(monkeypatch): + mock_connect = AsyncMock(return_value=None) + monkeypatch.setattr(sandbox_async_main.SandboxApi, "_cls_connect", mock_connect) + + sandbox = create_sandbox(monkeypatch) + await sandbox.connect() + + mock_connect.assert_awaited_once() + assert mock_connect.call_args.kwargs.get("envs") is None + + +@pytest.mark.skip_debug() +async def test_classmethod_connect_forwards_envs_without_polluting_opts(monkeypatch): + # Regression: AsyncSandbox.connect(id, envs=...) previously passed envs into + # **opts, which then reached ConnectionConfig(**opts) and raised TypeError. + mock_cls_connect = AsyncMock(return_value=SimpleNamespace( + sandbox_id="sbx-test", + domain="sandbox.e2b.dev", + envd_version="0.2.4", + envd_access_token="tok", + traffic_access_token="tok", + )) + monkeypatch.setattr(sandbox_async_main.SandboxApi, "_cls_connect", mock_cls_connect) + monkeypatch.setattr( + sandbox_async_main, "get_transport", lambda *_args, **_kwargs: SimpleNamespace(pool=object()) + ) + monkeypatch.setattr(sandbox_async_main.httpx, "AsyncClient", lambda *args, **kwargs: object()) + monkeypatch.setattr(sandbox_async_main, "Filesystem", lambda *args, **kwargs: object()) + monkeypatch.setattr(sandbox_async_main, "Commands", lambda *args, **kwargs: object()) + monkeypatch.setattr(sandbox_async_main, "Pty", lambda *args, **kwargs: object()) + monkeypatch.setattr(sandbox_async_main, "Git", lambda *args, **kwargs: object()) + + # This must not raise TypeError: ConnectionConfig() got unexpected keyword 'envs' + await sandbox_async_main.AsyncSandbox.connect("sbx-test", envs={"MY_KEY": "my_value"}, api_key="test-key") + + mock_cls_connect.assert_awaited_once() + assert mock_cls_connect.call_args.kwargs["envs"] == {"MY_KEY": "my_value"} + + @pytest.mark.skip_debug() async def test_pause_applies_overrides(monkeypatch): mock_pause = AsyncMock(return_value="sbx-test") diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_config_propagation.py b/packages/python-sdk/tests/sync/sandbox_sync/test_config_propagation.py index 8874ad53ca..f5ae4b5561 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_config_propagation.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_config_propagation.py @@ -65,6 +65,58 @@ def test_pause_passes_connection_config_without_overrides(monkeypatch): assert mock_pause.call_args.kwargs["headers"]["X-Test"] == BASE_HEADERS["X-Test"] +@pytest.mark.skip_debug() +def test_connect_forwards_envs(monkeypatch): + mock_connect = Mock(return_value=None) + monkeypatch.setattr(sandbox_sync_main.SandboxApi, "_cls_connect", mock_connect) + + sandbox = create_sandbox(monkeypatch) + sandbox.connect(envs={"MY_KEY": "my_value"}) + + mock_connect.assert_called_once() + assert mock_connect.call_args.kwargs["envs"] == {"MY_KEY": "my_value"} + + +@pytest.mark.skip_debug() +def test_connect_envs_is_none_when_not_provided(monkeypatch): + mock_connect = Mock(return_value=None) + monkeypatch.setattr(sandbox_sync_main.SandboxApi, "_cls_connect", mock_connect) + + sandbox = create_sandbox(monkeypatch) + sandbox.connect() + + mock_connect.assert_called_once() + assert mock_connect.call_args.kwargs.get("envs") is None + + +@pytest.mark.skip_debug() +def test_classmethod_connect_forwards_envs_without_polluting_opts(monkeypatch): + # Regression: Sandbox.connect(id, envs=...) previously passed envs into + # **opts, which then reached ConnectionConfig(**opts) and raised TypeError. + mock_cls_connect = Mock(return_value=SimpleNamespace( + sandbox_id="sbx-test", + domain="sandbox.e2b.dev", + envd_version="0.2.4", + envd_access_token="tok", + traffic_access_token="tok", + )) + monkeypatch.setattr(sandbox_sync_main.SandboxApi, "_cls_connect", mock_cls_connect) + monkeypatch.setattr( + sandbox_sync_main, "get_transport", lambda *_args, **_kwargs: SimpleNamespace(pool=object()) + ) + monkeypatch.setattr(sandbox_sync_main.httpx, "Client", lambda *args, **kwargs: object()) + monkeypatch.setattr(sandbox_sync_main, "Filesystem", lambda *args, **kwargs: object()) + monkeypatch.setattr(sandbox_sync_main, "Commands", lambda *args, **kwargs: object()) + monkeypatch.setattr(sandbox_sync_main, "Pty", lambda *args, **kwargs: object()) + monkeypatch.setattr(sandbox_sync_main, "Git", lambda *args, **kwargs: object()) + + # This must not raise TypeError: ConnectionConfig() got unexpected keyword 'envs' + sandbox_sync_main.Sandbox.connect("sbx-test", envs={"MY_KEY": "my_value"}, api_key="test-key") + + mock_cls_connect.assert_called_once() + assert mock_cls_connect.call_args.kwargs["envs"] == {"MY_KEY": "my_value"} + + @pytest.mark.skip_debug() def test_pause_applies_overrides(monkeypatch): mock_pause = Mock(return_value="sbx-test") diff --git a/spec/openapi.yml b/spec/openapi.yml index 87084f64b9..40abc5a47a 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -660,6 +660,9 @@ components: type: integer format: int32 minimum: 0 + envVars: + $ref: '#/components/schemas/EnvVars' + description: Environment variables to set in the sandbox on reconnect. Merged into the sandbox environment and applied to processes started after resume. TeamMetric: description: Team metric with timestamp