From a2794118ff6b8ffe9463888fd609b47a83c5cfd6 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 17 Jul 2026 16:22:38 -0400 Subject: [PATCH] feat: optional separate LangSmith key/endpoint for sandboxes (#1760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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- 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 /boxes instead of /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] (cherry picked from commit e826864dce0e56cda7decbc48254b1e13eef07e2) Co-authored-by: Ramon Nogueira --- agent/dashboard/repo_snapshots.py | 6 +- agent/integrations/langsmith.py | 101 ++++++++++++++++-- docs/CUSTOMIZATION.md | 2 + .../sandbox/test_langsmith_sandbox_config.py | 54 +++++++++- tests/sandbox/test_proxy_auth.py | 29 +++++ 5 files changed, 180 insertions(+), 12 deletions(-) diff --git a/agent/dashboard/repo_snapshots.py b/agent/dashboard/repo_snapshots.py index e35329f0..e2afb401 100644 --- a/agent/dashboard/repo_snapshots.py +++ b/agent/dashboard/repo_snapshots.py @@ -342,9 +342,9 @@ def _build_snapshot_sync(record: dict[str, Any], snapshot_name: str) -> tuple[st """ from langsmith.sandbox import SandboxClient - from agent.integrations.langsmith import _get_langsmith_api_key + from agent.integrations.langsmith import _get_sandbox_api_endpoint, _get_sandbox_api_key - api_key = _get_langsmith_api_key() + api_key = _get_sandbox_api_key() if not api_key: raise RuntimeError("LANGSMITH_API_KEY is not configured") @@ -356,7 +356,7 @@ def _build_snapshot_sync(record: dict[str, Any], snapshot_name: str) -> tuple[st timeout = int( os.environ.get("REPO_SNAPSHOT_BUILD_TIMEOUT_SECONDS", DEFAULT_BUILD_TIMEOUT_SECONDS) ) - client = SandboxClient(api_key=api_key) + client = SandboxClient(api_key=api_key, api_endpoint=_get_sandbox_api_endpoint()) try: with tempfile.TemporaryDirectory(prefix="openswe-snapshot-") as context_dir: dockerfile_path = Path(context_dir) / "Dockerfile" diff --git a/agent/integrations/langsmith.py b/agent/integrations/langsmith.py index 4c41240f..4e045954 100644 --- a/agent/integrations/langsmith.py +++ b/agent/integrations/langsmith.py @@ -8,6 +8,7 @@ import json import logging import os import time +import uuid from abc import ABC, abstractmethod from concurrent.futures import ThreadPoolExecutor from concurrent.futures import TimeoutError as FuturesTimeout @@ -45,6 +46,69 @@ def _get_langsmith_api_key() -> str | None: return os.environ.get("LANGSMITH_API_KEY") or os.environ.get("LANGSMITH_API_KEY_PROD") +def _get_sandbox_api_key() -> str | None: + """LangSmith API key for sandbox operations. + + ``SANDBOX_LANGSMITH_API_KEY`` lets sandboxes run against a different + LangSmith workspace than the one used for tracing/other API calls; falls + back to the standard key. + """ + return os.environ.get("SANDBOX_LANGSMITH_API_KEY") or _get_langsmith_api_key() + + +def _get_sandbox_endpoint() -> str: + """LangSmith API **root** for sandbox operations. + + Overridable via ``SANDBOX_LANGSMITH_ENDPOINT`` to pair with + ``SANDBOX_LANGSMITH_API_KEY``; falls back to ``LANGSMITH_ENDPOINT``. This is + the bare root (e.g. ``https://api.smith.langchain.com``) used to build the + proxy-config URL; the SDK clients take :func:`_get_sandbox_api_endpoint`. + """ + return ( + os.environ.get("SANDBOX_LANGSMITH_ENDPOINT") + or os.environ.get("LANGSMITH_ENDPOINT") + or "https://api.smith.langchain.com" + ) + + +def _get_sandbox_api_endpoint() -> str: + """Sandbox API base URL for the langsmith SDK clients. + + The SDK's ``api_endpoint`` is the sandbox base (root + ``/v2/sandboxes``), + not the API root, and its methods append ``/boxes``, ``/snapshots``, etc. + """ + root = _get_sandbox_endpoint().rstrip("/") + suffix = "/v2/sandboxes" + return root if root.endswith(suffix) else f"{root}{suffix}" + + +def _current_thread_id() -> str | None: + """The LangGraph thread id for the active run, if any.""" + try: + from langgraph.config import get_config + + return get_config().get("configurable", {}).get("thread_id") + except Exception: + return None + + +def _sandbox_name_for_thread(thread_id: str | None) -> str | None: + """Deterministic, thread-traceable sandbox name: ``openswe-``. + + The thread id (a UUID) is base32-encoded lowercase without padding so the + name is a compact, hyphen-free token that maps back to the thread. Returns + None when the thread id is missing or not a UUID, leaving the name unset. + """ + if not thread_id: + return None + try: + raw = uuid.UUID(thread_id).bytes + except ValueError: + return None + encoded = base64.b32encode(raw).decode("ascii").rstrip("=").lower() + return f"openswe-{encoded}" + + def _parse_optional_int(name: str, default: int) -> int: raw = os.environ.get(name) if not raw: @@ -172,6 +236,22 @@ def _is_retryable_proxy_config_error(exc: BaseException) -> bool: return isinstance(exc, httpx.TransportError) +def _release_sandbox_name(client: SandboxClient, name: str | None) -> None: + """Best-effort delete of any existing sandbox holding ``name``. + + Sandbox names are unique in LangSmith and thread-deterministic, so the only + box that can hold this name is this thread's own — typically a dead one + (idle-stopped past its TTL) we're recreating. Provisioning is serialized per + thread, so this never races a live box. Without this, recreate would 409. + """ + if not name: + return + try: + client.delete_sandbox(name) + except Exception as exc: # noqa: BLE001 - name is free if nothing to delete + logger.debug("No pre-existing sandbox %s to release (%s)", name, type(exc).__name__) + + def _configure_github_proxy(sandbox_name: str, github_token: str) -> None: """Configure sandbox proxy to inject GitHub auth for GitHub traffic. @@ -183,11 +263,11 @@ def _configure_github_proxy(sandbox_name: str, github_token: str) -> None: sandbox_name: The sandbox name/ID returned by the LangSmith API. github_token: GitHub token to inject as Authorization header. """ - api_key = _get_langsmith_api_key() + api_key = _get_sandbox_api_key() if not api_key: logger.warning("No LangSmith API key found, skipping GitHub proxy configuration") return - langsmith_endpoint = os.environ.get("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com") + langsmith_endpoint = _get_sandbox_endpoint() url = f"{langsmith_endpoint}/v2/sandboxes/boxes/{sandbox_name}" payload = {"proxy_config": {"rules": _github_proxy_rules(github_token)}} with httpx.Client(timeout=PROXY_CONFIG_TIMEOUT_SECONDS) as client: @@ -249,7 +329,7 @@ def create_langsmith_sandbox( Returns: SandboxBackendProtocol instance """ - api_key = _get_langsmith_api_key() + api_key = _get_sandbox_api_key() ( default_snapshot_id, fs_capacity_bytes, @@ -265,6 +345,7 @@ def create_langsmith_sandbox( backend = provider.get_or_create( sandbox_id=sandbox_id, snapshot_id=effective_snapshot_id, + name=_sandbox_name_for_thread(_current_thread_id()), fs_capacity_bytes=fs_capacity_bytes, vcpus=vcpus, mem_bytes=mem_bytes, @@ -284,11 +365,9 @@ def _update_thread_sandbox_metadata(sandbox_id: str) -> None: try: import asyncio - from langgraph.config import get_config from langgraph_sdk import get_client - config = get_config() - thread_id = config.get("configurable", {}).get("thread_id") + thread_id = _current_thread_id() if not thread_id: return client = get_client() @@ -454,11 +533,14 @@ class LangSmithProvider(SandboxProvider): def __init__(self, api_key: str | None = None) -> None: from langsmith import sandbox - self._api_key = api_key or _get_langsmith_api_key() + self._api_key = api_key or _get_sandbox_api_key() + self._api_endpoint = _get_sandbox_api_endpoint() if not self._api_key: msg = "LANGSMITH_API_KEY (or LANGSMITH_API_KEY_PROD) not set" raise ValueError(msg) - self._client: SandboxClient = sandbox.SandboxClient(api_key=self._api_key) + self._client: SandboxClient = sandbox.SandboxClient( + api_key=self._api_key, api_endpoint=self._api_endpoint + ) @classmethod def validate_startup_config(cls) -> None: @@ -499,6 +581,7 @@ class LangSmithProvider(SandboxProvider): sandbox_id: str | None = None, timeout: int = 180, snapshot_id: str | None = None, + name: str | None = None, fs_capacity_bytes: int | None = None, vcpus: int | None = None, mem_bytes: int | None = None, @@ -523,10 +606,12 @@ class LangSmithProvider(SandboxProvider): raise ValueError(msg) _install_create_extra_fields(self._client, _get_sandbox_create_extra_fields()) + _release_sandbox_name(self._client, name) try: sandbox = self._client.create_sandbox( snapshot_id=snapshot_id, + name=name, fs_capacity_bytes=fs_capacity_bytes, vcpus=vcpus, mem_bytes=mem_bytes, diff --git a/docs/CUSTOMIZATION.md b/docs/CUSTOMIZATION.md index b6165937..71ff4fcd 100644 --- a/docs/CUSTOMIZATION.md +++ b/docs/CUSTOMIZATION.md @@ -69,6 +69,8 @@ Set the `SANDBOX_TYPE` environment variable to switch providers. Each provider h > **Warning**: `local` runs commands directly on your host with no sandboxing. Only use for local development with human-in-the-loop enabled. +For `langsmith`, sandboxes default to the same LangSmith credentials as tracing. To run sandboxes against a **different** LangSmith workspace, set `SANDBOX_LANGSMITH_API_KEY` (falls back to `LANGSMITH_API_KEY` / `LANGSMITH_API_KEY_PROD`) and optionally `SANDBOX_LANGSMITH_ENDPOINT` (falls back to `LANGSMITH_ENDPOINT`). These apply to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds — the `DEFAULT_SANDBOX_SNAPSHOT_ID` must exist in whichever workspace these credentials point at. + ### Adding a new sandbox provider 1. **Create an integration file** at `agent/integrations/my_provider.py` with a factory function matching this signature: diff --git a/tests/sandbox/test_langsmith_sandbox_config.py b/tests/sandbox/test_langsmith_sandbox_config.py index a7a97ae4..eb361311 100644 --- a/tests/sandbox/test_langsmith_sandbox_config.py +++ b/tests/sandbox/test_langsmith_sandbox_config.py @@ -1,6 +1,8 @@ """Tests for LangSmith sandbox env-var configuration parsing.""" -from unittest.mock import patch +import base64 +import uuid +from unittest.mock import MagicMock, patch import pytest @@ -11,12 +13,62 @@ from agent.integrations.langsmith import ( 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", diff --git a/tests/sandbox/test_proxy_auth.py b/tests/sandbox/test_proxy_auth.py index a00d7ca0..0c257324 100644 --- a/tests/sandbox/test_proxy_auth.py +++ b/tests/sandbox/test_proxy_auth.py @@ -125,6 +125,35 @@ class TestConfigureGithubProxy: headers = mock_client.patch.call_args.kwargs["headers"] assert headers == {"X-API-Key": "my-api-key"} + def test_sandbox_overrides_take_precedence(self) -> None: + """SANDBOX_LANGSMITH_* override the shared key/endpoint for the proxy call.""" + with ( + patch("agent.integrations.langsmith.httpx.Client") as mock_client_cls, + patch.dict( + "os.environ", + { + "LANGSMITH_API_KEY": "shared-key", + "LANGSMITH_ENDPOINT": "https://shared.smith.langchain.com", + "SANDBOX_LANGSMITH_API_KEY": "sandbox-key", + "SANDBOX_LANGSMITH_ENDPOINT": "https://sandbox.smith.langchain.com", + }, + ), + ): + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_client.patch.return_value = mock_response + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + + _configure_github_proxy("sandbox-abc", "token") + + assert ( + mock_client.patch.call_args.args[0] + == "https://sandbox.smith.langchain.com/v2/sandboxes/boxes/sandbox-abc" + ) + assert mock_client.patch.call_args.kwargs["headers"] == {"X-API-Key": "sandbox-key"} + def test_retries_transient_http_error(self) -> None: """Transient proxy API errors should be retried on the same sandbox.""" request = httpx.Request(