open-swe/tests/sandbox/test_langsmith_sandbox_config.py
Adam Moussa a2794118ff
feat: optional separate LangSmith key/endpoint for sandboxes (#1760)
* feat: optional separate LangSmith key/endpoint for sandboxes

Adds optional SANDBOX_LANGSMITH_API_KEY / SANDBOX_LANGSMITH_ENDPOINT env
overrides so sandboxes can run against a different LangSmith workspace than
the one used for tracing and other API calls. Both fall back to the existing
LANGSMITH_API_KEY / LANGSMITH_ENDPOINT resolution, so default behavior is
unchanged.

Applied to sandbox create/connect/delete, the GitHub proxy config, and repo
snapshot builds.

* feat: name langsmith sandboxes openswe-<b32(thread id)>

New sandboxes get a deterministic, thread-traceable name derived from the
LangGraph thread id (UUID base32-encoded lowercase, no padding), e.g.
openswe-ci2fm6asgrlhqerukz4bencwpa. Falls back to an unset name when no thread
id is present. Reconnect/delete still key off the server-assigned sandbox id.

* fix: pass sandbox base URL (root + /v2/sandboxes) to langsmith SDK clients

The SDK's api_endpoint is the sandbox base, not the API root — its methods
append /boxes, /snapshots, etc. Passing the bare root sent calls to
<root>/boxes instead of <root>/v2/sandboxes/boxes. Add _get_sandbox_api_endpoint
for the SDK clients (async client, provider, snapshot SandboxClient) while the
proxy-config PATCH keeps using the root.

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
(cherry picked from commit e826864dce0e56cda7decbc48254b1e13eef07e2)

Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
2026-07-17 16:22:38 -04:00

217 lines
7 KiB
Python

"""Tests for LangSmith sandbox env-var configuration parsing."""
import base64
import uuid
from unittest.mock import MagicMock, patch
import pytest
from agent.integrations.langsmith import (
DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS,
DEFAULT_SANDBOX_IDLE_TTL_SECONDS,
DEFAULT_SANDBOX_MEM_BYTES,
DEFAULT_SANDBOX_VCPUS,
DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES,
LangSmithProvider,
_get_sandbox_api_endpoint,
_get_sandbox_create_extra_fields,
_get_sandbox_snapshot_config,
_install_create_extra_fields,
_release_sandbox_name,
_sandbox_name_for_thread,
)
def test_sandbox_api_endpoint_appends_v2_sandboxes() -> None:
with patch.dict("os.environ", {"LANGSMITH_ENDPOINT": "https://eu.smith.langchain.com"}):
assert _get_sandbox_api_endpoint() == "https://eu.smith.langchain.com/v2/sandboxes"
def test_sandbox_api_endpoint_no_double_suffix() -> None:
with patch.dict(
"os.environ",
{"SANDBOX_LANGSMITH_ENDPOINT": "https://x.smith.langchain.com/v2/sandboxes"},
):
assert _get_sandbox_api_endpoint() == "https://x.smith.langchain.com/v2/sandboxes"
def test_sandbox_name_for_thread_encodes_uuid() -> None:
thread_id = "12345678-1234-5678-1234-567812345678"
name = _sandbox_name_for_thread(thread_id)
assert name is not None
prefix, _, encoded = name.partition("-")
assert prefix == "openswe"
assert encoded == encoded.lower()
assert "=" not in encoded and "-" not in encoded
# Round-trips back to the original UUID.
padded = encoded.upper() + "=" * (-len(encoded) % 8)
assert uuid.UUID(bytes=base64.b32decode(padded)) == uuid.UUID(thread_id)
def test_sandbox_name_for_thread_none_or_invalid() -> None:
assert _sandbox_name_for_thread(None) is None
assert _sandbox_name_for_thread("not-a-uuid") is None
def test_release_sandbox_name_deletes_stale_box() -> None:
client = MagicMock()
_release_sandbox_name(client, "openswe-abc")
client.delete_sandbox.assert_called_once_with("openswe-abc")
def test_release_sandbox_name_swallows_missing_and_skips_none() -> None:
client = MagicMock()
client.delete_sandbox.side_effect = RuntimeError("not found")
_release_sandbox_name(client, "openswe-abc") # must not raise
client.delete_sandbox.reset_mock(side_effect=True)
_release_sandbox_name(client, None)
client.delete_sandbox.assert_not_called()
def test_defaults_when_env_unset() -> None:
with patch.dict(
"os.environ",
{"DEFAULT_SANDBOX_SNAPSHOT_ID": "snap-1"},
clear=True,
):
snapshot_id, fs, vcpus, mem, idle, delete_after = _get_sandbox_snapshot_config()
assert snapshot_id == "snap-1"
assert fs == DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES
assert vcpus == DEFAULT_SANDBOX_VCPUS
assert mem == DEFAULT_SANDBOX_MEM_BYTES
assert idle == DEFAULT_SANDBOX_IDLE_TTL_SECONDS
assert delete_after == DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS
def test_overrides_from_env() -> None:
with patch.dict(
"os.environ",
{
"DEFAULT_SANDBOX_SNAPSHOT_ID": "snap-2",
"DEFAULT_SANDBOX_IDLE_TTL_SECONDS": "120",
"DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS": "3600",
},
clear=True,
):
_, _, _, _, idle, delete_after = _get_sandbox_snapshot_config()
assert idle == 120
assert delete_after == 3600
def test_zero_disables_ttls() -> None:
with patch.dict(
"os.environ",
{
"DEFAULT_SANDBOX_SNAPSHOT_ID": "snap-3",
"DEFAULT_SANDBOX_IDLE_TTL_SECONDS": "0",
"DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS": "0",
},
clear=True,
):
_, _, _, _, idle, delete_after = _get_sandbox_snapshot_config()
assert idle == 0
assert delete_after == 0
def test_validate_startup_rejects_non_integer_ttl() -> None:
with patch.dict(
"os.environ",
{
"DEFAULT_SANDBOX_SNAPSHOT_ID": "snap-4",
"DEFAULT_SANDBOX_IDLE_TTL_SECONDS": "not-a-number",
},
clear=True,
):
with pytest.raises(ValueError, match="DEFAULT_SANDBOX_IDLE_TTL_SECONDS"):
LangSmithProvider.validate_startup_config()
def test_validate_startup_rejects_negative_ttl() -> None:
with patch.dict(
"os.environ",
{
"DEFAULT_SANDBOX_SNAPSHOT_ID": "snap-5",
"DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS": "-1",
},
clear=True,
):
with pytest.raises(ValueError, match=">= 0"):
LangSmithProvider.validate_startup_config()
def test_validate_startup_accepts_valid_config() -> None:
with patch.dict(
"os.environ",
{
"DEFAULT_SANDBOX_SNAPSHOT_ID": "snap-6",
"DEFAULT_SANDBOX_IDLE_TTL_SECONDS": "1800",
"DEFAULT_SANDBOX_DELETE_AFTER_STOP_SECONDS": "86400",
},
clear=True,
):
LangSmithProvider.validate_startup_config()
def test_extra_fields_unset_is_empty() -> None:
with patch.dict("os.environ", {}, clear=True):
assert _get_sandbox_create_extra_fields() == {}
with patch.dict("os.environ", {"SANDBOX_CREATE_EXTRA_JSON": " "}, clear=True):
assert _get_sandbox_create_extra_fields() == {}
def test_extra_fields_parsed() -> None:
with patch.dict(
"os.environ",
{"SANDBOX_CREATE_EXTRA_JSON": '{"_internal_runtime": "v2"}'},
clear=True,
):
assert _get_sandbox_create_extra_fields() == {"_internal_runtime": "v2"}
def test_extra_fields_rejects_invalid_json() -> None:
with patch.dict("os.environ", {"SANDBOX_CREATE_EXTRA_JSON": "{not json"}, clear=True):
with pytest.raises(ValueError, match="valid JSON"):
_get_sandbox_create_extra_fields()
def test_extra_fields_rejects_non_object() -> None:
with patch.dict("os.environ", {"SANDBOX_CREATE_EXTRA_JSON": "[1, 2]"}, clear=True):
with pytest.raises(ValueError, match="JSON object"):
_get_sandbox_create_extra_fields()
def test_install_create_extra_fields_merges_only_boxes_post() -> None:
calls: list[tuple[str, dict]] = []
class _FakeHttp:
def post(self, url, **kwargs): # noqa: ANN001, ANN003
calls.append((url, kwargs.get("json")))
return "ok"
class _FakeClient:
def __init__(self) -> None:
self._http = _FakeHttp()
client = _FakeClient()
_install_create_extra_fields(client, {"_internal_runtime": "v2"})
client._http.post("https://api/v2/sandboxes/boxes", json={"snapshot_id": "s"})
client._http.post("https://api/v2/sandboxes/boxes/abc/start", json={"foo": "bar"})
assert calls[0][1] == {"snapshot_id": "s", "_internal_runtime": "v2"}
assert calls[1][1] == {"foo": "bar"}
def test_install_create_extra_fields_noop_when_empty() -> None:
class _FakeHttp:
def __init__(self) -> None:
self.post = "sentinel"
class _FakeClient:
def __init__(self) -> None:
self._http = _FakeHttp()
client = _FakeClient()
_install_create_extra_fields(client, {})
assert client._http.post == "sentinel"