From 6305e13dc66c1e6a063884d08e6ca967d21ebdaf Mon Sep 17 00:00:00 2001 From: Aran Yogesh Date: Tue, 7 Apr 2026 16:41:48 -0700 Subject: [PATCH] feat: authenticate git operations via sandbox proxy instead of credential files [closes: OPE-20] (#1070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: authenticate git operations via sandbox proxy instead of credential files * feat: authenticate git operations via sandbox proxy instead of credential files * feat: authenticate git operations via sandbox proxy instead of credential files * removing logger.info * formatting and linting * fix: resolve lint errors in server.py (imports, unused vars, undefined names) * feat: use opaque proxy headers for GitHub auth in sandbox * linting formatting and test changes * linting * Delete .claude directory * Delete tests/evals directory * fix: address PR review — guard missing tokens, quote shell paths, add proxy auth tests * fix: restore authorship, branch_name support, and installation token for PR creation * linitng * fix: move installation token fetch before commit, clean up dead proxy validation code * fix: drop github_token arg from sandbox creation, use generic create_sandbox factory with langsmith-only proxy config * fix: use _get_langsmith_api_key() for prod key fallback, warn when API key missing for proxy config * linting * linting * fix: resolve merge conflicts with main, adopt deepagents v0.5.0a4 LangSmithSandbox --- agent/integrations/langsmith.py | 63 ++++++++++- agent/middleware/open_pr.py | 4 +- agent/server.py | 92 +++++++--------- agent/tools/commit_and_open_pr.py | 18 ++-- agent/utils/github.py | 58 +--------- pyproject.toml | 2 +- tests/test_github_security.py | 19 ---- tests/test_proxy_auth.py | 172 ++++++++++++++++++++++++++++++ uv.lock | 8 +- 9 files changed, 293 insertions(+), 143 deletions(-) create mode 100644 tests/test_proxy_auth.py diff --git a/agent/integrations/langsmith.py b/agent/integrations/langsmith.py index 602c827a..9324be2a 100644 --- a/agent/integrations/langsmith.py +++ b/agent/integrations/langsmith.py @@ -2,16 +2,21 @@ from __future__ import annotations +import base64 import contextlib +import logging import os import time from abc import ABC, abstractmethod from typing import Any +import httpx from deepagents.backends import LangSmithSandbox from deepagents.backends.protocol import SandboxBackendProtocol from langsmith.sandbox import SandboxClient, SandboxTemplate +logger = logging.getLogger(__name__) + def _get_langsmith_api_key() -> str | None: """Get LangSmith API key from environment. @@ -29,14 +34,62 @@ def _get_sandbox_template_config() -> tuple[str | None, str | None]: return template_name, template_image +def _configure_github_proxy(sandbox_name: str, github_token: str) -> None: + """Configure sandbox proxy to inject GitHub auth for all github.com requests. + + Uses the LangSmith proxy-config API to set up header injection so that + git operations (clone, pull, push) authenticate via the proxy rather than + writing credentials to disk in the sandbox. + + Args: + 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() + 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") + url = f"{langsmith_endpoint}/v2/sandboxes/boxes/{sandbox_name}" + basic_auth = base64.b64encode(f"x-access-token:{github_token}".encode()).decode() + payload = { + "proxy_config": { + "rules": [ + { + "name": "github", + "match_hosts": ["github.com", "*.github.com"], + "headers": [ + { + "name": "Authorization", + "type": "opaque", + "value": f"Basic {basic_auth}", + } + ], + } + ] + } + } + with httpx.Client() as client: + response = client.patch( + url, + json=payload, + headers={"X-API-Key": api_key}, + ) + response.raise_for_status() + logger.info("Configured GitHub proxy for sandbox %s", sandbox_name) + + def create_langsmith_sandbox( sandbox_id: str | None = None, + github_token: str | None = None, ) -> SandboxBackendProtocol: """Create or connect to a LangSmith sandbox without automatic cleanup. Args: sandbox_id: Optional existing sandbox ID to connect to. If None, creates a new sandbox. + github_token: Optional GitHub token. Used to configure proxy auth on + new sandboxes. Ignored when connecting to an existing sandbox. Returns: SandboxBackendProtocol instance @@ -51,6 +104,10 @@ def create_langsmith_sandbox( template_image=template_image, ) _update_thread_sandbox_metadata(backend.id) + + if sandbox_id is None and github_token: + _configure_github_proxy(backend.id, github_token) + return backend @@ -108,7 +165,7 @@ class SandboxProvider(ABC): raise NotImplementedError -DEFAULT_TEMPLATE_NAME = "open-swe" +DEFAULT_TEMPLATE_NAME = "open-swe-new" DEFAULT_TEMPLATE_IMAGE = "python:3" @@ -118,9 +175,9 @@ class LangSmithProvider(SandboxProvider): def __init__(self, api_key: str | None = None) -> None: from langsmith import sandbox - self._api_key = api_key or os.environ.get("LANGSMITH_API_KEY") + self._api_key = api_key or _get_langsmith_api_key() if not self._api_key: - msg = "LANGSMITH_API_KEY environment variable not set" + msg = "LANGSMITH_API_KEY (or LANGSMITH_API_KEY_PROD) not set" raise ValueError(msg) self._client: SandboxClient = sandbox.SandboxClient(api_key=self._api_key) diff --git a/agent/middleware/open_pr.py b/agent/middleware/open_pr.py index b02a8509..12d9325e 100644 --- a/agent/middleware/open_pr.py +++ b/agent/middleware/open_pr.py @@ -160,9 +160,7 @@ async def open_pr_if_needed( await asyncio.to_thread(git_add_all, sandbox_backend, repo_dir) await asyncio.to_thread(git_commit, sandbox_backend, repo_dir, commit_message) - await asyncio.to_thread( - git_push, sandbox_backend, repo_dir, target_branch, installation_token - ) + await asyncio.to_thread(git_push, sandbox_backend, repo_dir, target_branch) base_branch = await get_github_default_branch(repo_owner, repo_name, installation_token) logger.info("Using base branch: %s", base_branch) diff --git a/agent/server.py b/agent/server.py index 52a0a787..95ca0505 100644 --- a/agent/server.py +++ b/agent/server.py @@ -27,6 +27,7 @@ from deepagents import create_deep_agent from deepagents.backends.protocol import SandboxBackendProtocol from langsmith.sandbox import SandboxClientError +from .integrations.langsmith import _configure_github_proxy from .middleware import ( ToolErrorMiddleware, check_message_queue_before_model, @@ -57,6 +58,7 @@ from .tools import ( web_search, ) from .utils.auth import resolve_github_token +from .utils.github_app import get_github_app_installation_token from .utils.model import make_model from .utils.sandbox import create_sandbox @@ -68,32 +70,28 @@ SANDBOX_POLL_INTERVAL = 1.0 from .utils.agents_md import read_agents_md_in_sandbox from .utils.github import ( - _CRED_FILE_PATH, - cleanup_git_credentials, - git_current_branch, git_has_uncommitted_changes, - git_pull_branch, is_valid_git_repo, remove_directory, - setup_git_credentials, ) from .utils.sandbox_paths import aresolve_repo_dir, aresolve_sandbox_work_dir from .utils.sandbox_state import SANDBOX_BACKENDS, get_sandbox_id_from_metadata -async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915 +async def _clone_or_pull_repo_in_sandbox( sandbox_backend: SandboxBackendProtocol, owner: str, repo: str, - github_token: str | None = None, ) -> str: """Clone a GitHub repo into the sandbox, or pull if it already exists. + Authentication is handled by the sandbox proxy (configured at sandbox creation + time), so no token is needed here. + Args: sandbox_backend: The sandbox backend to execute commands in owner: GitHub repo owner repo: GitHub repo name - github_token: GitHub access token (from agent auth or env var) Returns: Path to the cloned/updated repo directory @@ -101,18 +99,9 @@ async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915 logger.info("_clone_or_pull_repo_in_sandbox called for %s/%s", owner, repo) loop = asyncio.get_event_loop() - token = github_token - if not token: - msg = "No GitHub token provided" - logger.error(msg) - raise ValueError(msg) - work_dir = await aresolve_sandbox_work_dir(sandbox_backend) repo_dir = await aresolve_repo_dir(sandbox_backend, repo) clean_url = f"https://github.com/{owner}/{repo}.git" - cred_helper = shlex.quote(f"store --file={_CRED_FILE_PATH}") - safe_repo_dir = shlex.quote(repo_dir) - safe_clean_url = shlex.quote(clean_url) logger.info("Resolved sandbox work dir to %s", work_dir) @@ -143,21 +132,10 @@ async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915 logger.info("Repo is clean, pulling latest changes from %s/%s", owner, repo) try: - current_branch = await loop.run_in_executor( - None, git_current_branch, sandbox_backend, repo_dir - ) - if not current_branch: - msg = f"Failed to determine current branch for repo at {repo_dir}" - logger.error(msg) - raise RuntimeError(msg) - pull_result = await loop.run_in_executor( None, - git_pull_branch, - sandbox_backend, - repo_dir, - current_branch, - token, + sandbox_backend.execute, + f"cd {shlex.quote(repo_dir)} && git pull origin $(git rev-parse --abbrev-ref HEAD)", ) logger.debug("Git pull result: exit_code=%s", pull_result.exit_code) if pull_result.exit_code != 0: @@ -174,19 +152,16 @@ async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915 return repo_dir logger.info("Cloning repo %s/%s to %s", owner, repo, repo_dir) - await loop.run_in_executor(None, setup_git_credentials, sandbox_backend, token) try: result = await loop.run_in_executor( None, sandbox_backend.execute, - f"git -c credential.helper={cred_helper} clone {safe_clean_url} {safe_repo_dir}", + f"git clone {shlex.quote(clean_url)} {shlex.quote(repo_dir)}", ) logger.debug("Git clone result: exit_code=%s", result.exit_code) except Exception: logger.exception("Failed to execute git clone") raise - finally: - await loop.run_in_executor(None, cleanup_git_credentials, sandbox_backend) if result.exit_code != 0: msg = f"Failed to clone repo {owner}/{repo}: {result.output}" @@ -197,17 +172,35 @@ async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915 return repo_dir +async def _create_sandbox_with_proxy() -> SandboxBackendProtocol: + """Create a new sandbox with GitHub proxy auth configured. + + Uses create_sandbox (generic factory) so non-langsmith providers still work. + For langsmith sandboxes, configures the proxy with the installation token. + """ + sandbox_backend = await asyncio.to_thread(create_sandbox) + + sandbox_type = os.getenv("SANDBOX_TYPE", "langsmith") + if sandbox_type == "langsmith": + installation_token = await get_github_app_installation_token() + if not installation_token: + msg = "Cannot configure proxy: GitHub App installation token is unavailable" + logger.error(msg) + raise ValueError(msg) + await asyncio.to_thread(_configure_github_proxy, sandbox_backend.id, installation_token) + + return sandbox_backend + + async def _recreate_sandbox( thread_id: str, repo_owner: str, repo_name: str, - *, - github_token: str | None, ) -> tuple[SandboxBackendProtocol, str]: """Recreate a sandbox and clone the repo after a connection failure. Clears the stale cache entry, sets the SANDBOX_CREATING sentinel, - creates a fresh sandbox, and clones the repo. + creates a fresh sandbox (with proxy auth configured), and clones the repo. """ SANDBOX_BACKENDS.pop(thread_id, None) await client.threads.update( @@ -215,10 +208,8 @@ async def _recreate_sandbox( metadata={"sandbox_id": SANDBOX_CREATING}, ) try: - sandbox_backend = await asyncio.to_thread(create_sandbox) - repo_dir = await _clone_or_pull_repo_in_sandbox( - sandbox_backend, repo_owner, repo_name, github_token - ) + sandbox_backend = await _create_sandbox_with_proxy() + repo_dir = await _clone_or_pull_repo_in_sandbox(sandbox_backend, repo_owner, repo_name) except Exception: logger.exception("Failed to recreate sandbox after connection failure") await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": None}) @@ -296,7 +287,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 logger.info("Pulling latest changes for repo %s/%s", repo_owner, repo_name) try: repo_dir = await _clone_or_pull_repo_in_sandbox( - sandbox_backend, repo_owner, repo_name, github_token + sandbox_backend, repo_owner, repo_name ) except SandboxClientError: logger.warning( @@ -304,7 +295,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 thread_id, ) sandbox_backend, repo_dir = await _recreate_sandbox( - thread_id, repo_owner, repo_name, github_token=github_token + thread_id, repo_owner, repo_name ) except Exception: logger.exception("Failed to pull repo in cached sandbox") @@ -315,15 +306,14 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": SANDBOX_CREATING}) try: - # Create sandbox without context manager cleanup (sandbox persists) - sandbox_backend = await asyncio.to_thread(create_sandbox) + sandbox_backend = await _create_sandbox_with_proxy() logger.info("Sandbox created: %s", sandbox_backend.id) repo_dir = None if repo_owner and repo_name: logger.info("Cloning repo %s/%s into sandbox", repo_owner, repo_name) repo_dir = await _clone_or_pull_repo_in_sandbox( - sandbox_backend, repo_owner, repo_name, github_token + sandbox_backend, repo_owner, repo_name ) logger.info("Repo cloned to %s", repo_dir) @@ -342,19 +332,19 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 else: logger.info("Connecting to existing sandbox %s", sandbox_id) try: - # Connect to existing sandbox without context manager cleanup + # Connect to existing sandbox (no proxy reconfiguration needed) sandbox_backend = await asyncio.to_thread(create_sandbox, sandbox_id) logger.info("Connected to existing sandbox %s", sandbox_id) except Exception: logger.warning("Failed to connect to existing sandbox %s, creating new one", sandbox_id) - # Reset sandbox_id and create a new sandbox + # Reset sandbox_id and create a new sandbox with proxy auth configured await client.threads.update( thread_id=thread_id, metadata={"sandbox_id": SANDBOX_CREATING}, ) try: - sandbox_backend = await asyncio.to_thread(create_sandbox) + sandbox_backend = await _create_sandbox_with_proxy() logger.info("New sandbox created: %s", sandbox_backend.id) except Exception: logger.exception("Failed to create replacement sandbox") @@ -368,7 +358,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 logger.info("Pulling latest changes for repo %s/%s", repo_owner, repo_name) try: repo_dir = await _clone_or_pull_repo_in_sandbox( - sandbox_backend, repo_owner, repo_name, github_token + sandbox_backend, repo_owner, repo_name ) except SandboxClientError: logger.warning( @@ -376,7 +366,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 thread_id, ) sandbox_backend, repo_dir = await _recreate_sandbox( - thread_id, repo_owner, repo_name, github_token=github_token + thread_id, repo_owner, repo_name ) except Exception: logger.exception("Failed to pull repo in existing sandbox") diff --git a/agent/tools/commit_and_open_pr.py b/agent/tools/commit_and_open_pr.py index 7d5f3452..b4922c91 100644 --- a/agent/tools/commit_and_open_pr.py +++ b/agent/tools/commit_and_open_pr.py @@ -151,14 +151,6 @@ def commit_and_open_pr( if not (has_uncommitted_changes or has_unpushed_commits): return {"success": False, "error": "No changes detected", "pr_url": None} - installation_token = asyncio.run(get_github_app_installation_token()) - if not installation_token: - return { - "success": False, - "error": "Failed to get GitHub App installation token", - "pr_url": None, - } - metadata = config.get("metadata", {}) branch_name = metadata.get("branch_name") current_branch = git_current_branch(sandbox_backend, repo_dir) @@ -198,7 +190,15 @@ def commit_and_open_pr( "pr_url": None, } - push_result = git_push(sandbox_backend, repo_dir, target_branch, installation_token) + installation_token = asyncio.run(get_github_app_installation_token()) + if not installation_token: + return { + "success": False, + "error": "Failed to get GitHub App installation token", + "pr_url": None, + } + + push_result = git_push(sandbox_backend, repo_dir, target_branch) if push_result.exit_code != 0: return { "success": False, diff --git a/agent/utils/github.py b/agent/utils/github.py index acd3cf59..fec0c5c3 100644 --- a/agent/utils/github.py +++ b/agent/utils/github.py @@ -122,66 +122,18 @@ def git_get_remote_url(sandbox_backend: SandboxBackendProtocol, repo_dir: str) - return result.output.strip() -_CRED_FILE_PATH = "/tmp/.git-credentials" - - -def setup_git_credentials(sandbox_backend: SandboxBackendProtocol, github_token: str) -> None: - """Write GitHub credentials to a temporary file using the sandbox write API. - - The write API sends content in the HTTP body (not via a shell command), - so the token never appears in shell history or process listings. - """ - sandbox_backend.write(_CRED_FILE_PATH, f"https://git:{github_token}@github.com\n") - sandbox_backend.execute(f"chmod 600 {_CRED_FILE_PATH}") - - -def cleanup_git_credentials(sandbox_backend: SandboxBackendProtocol) -> None: - """Remove the temporary credentials file.""" - sandbox_backend.execute(f"rm -f {_CRED_FILE_PATH}") - - -def _git_with_credentials( - sandbox_backend: SandboxBackendProtocol, - repo_dir: str, - command: str, -) -> ExecuteResponse: - """Run a git command using the temporary credential file.""" - cred_helper = shlex.quote(f"store --file={_CRED_FILE_PATH}") - return _run_git(sandbox_backend, repo_dir, f"git -c credential.helper={cred_helper} {command}") - - def git_push( sandbox_backend: SandboxBackendProtocol, repo_dir: str, branch: str, - github_token: str | None = None, ) -> ExecuteResponse: - """Push the branch to origin, using a token if needed.""" - safe_branch = shlex.quote(branch) - if not github_token: - return _run_git(sandbox_backend, repo_dir, f"git push origin {safe_branch}") - setup_git_credentials(sandbox_backend, github_token) - try: - return _git_with_credentials(sandbox_backend, repo_dir, f"push origin {safe_branch}") - finally: - cleanup_git_credentials(sandbox_backend) + """Push the branch to origin. - -def git_pull_branch( - sandbox_backend: SandboxBackendProtocol, - repo_dir: str, - branch: str, - github_token: str | None = None, -) -> ExecuteResponse: - """Pull a specific branch from origin, using a token if needed.""" + Authentication is handled by the sandbox proxy (configured at sandbox creation + time via the LangSmith proxy-config API), so no token is needed here. + """ safe_branch = shlex.quote(branch) - if not github_token: - return _run_git(sandbox_backend, repo_dir, f"git pull origin {safe_branch}") - setup_git_credentials(sandbox_backend, github_token) - try: - return _git_with_credentials(sandbox_backend, repo_dir, f"pull origin {safe_branch}") - finally: - cleanup_git_credentials(sandbox_backend) + return _run_git(sandbox_backend, repo_dir, f"git push origin {safe_branch}") async def create_github_pr( diff --git a/pyproject.toml b/pyproject.toml index 648bc8bf..c6c3c30a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "markdownify>=1.2.2", "langchain-anthropic>1.1.0", "langgraph-cli[inmem]>=0.4.12", - "langsmith>=0.7.1", + "langsmith>=0.7.20", "langchain-openai==1.1.10", "langchain-daytona>=0.0.3", "langchain-modal>=0.0.2", diff --git a/tests/test_github_security.py b/tests/test_github_security.py index c89334f7..f4585efa 100644 --- a/tests/test_github_security.py +++ b/tests/test_github_security.py @@ -27,22 +27,3 @@ def test_git_checkout_existing_branch_quotes_repo_dir_and_branch() -> None: github.git_checkout_existing_branch(sandbox, repo_dir, branch) assert sandbox.commands == [f"cd {shlex.quote(repo_dir)} && git checkout {shlex.quote(branch)}"] - - -def test_git_pull_branch_quotes_repo_dir_and_branch_when_using_credentials() -> None: - sandbox = FakeSandboxBackend() - repo_dir = "/tmp/repo; curl attacker" - branch = "main; curl attacker" - - github.git_pull_branch(sandbox, repo_dir, branch, github_token="secret-token") - - assert sandbox.writes == [(github._CRED_FILE_PATH, "https://git:secret-token@github.com\n")] - assert sandbox.commands == [ - f"chmod 600 {github._CRED_FILE_PATH}", - ( - f"cd {shlex.quote(repo_dir)} && git -c " - f"credential.helper={shlex.quote(f'store --file={github._CRED_FILE_PATH}')}" - f" pull origin {shlex.quote(branch)}" - ), - f"rm -f {github._CRED_FILE_PATH}", - ] diff --git a/tests/test_proxy_auth.py b/tests/test_proxy_auth.py new file mode 100644 index 00000000..4ab212d1 --- /dev/null +++ b/tests/test_proxy_auth.py @@ -0,0 +1,172 @@ +"""Tests for GitHub proxy auth configuration.""" + +from __future__ import annotations + +import base64 +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from agent.integrations.langsmith import _configure_github_proxy + + +class TestConfigureGithubProxy: + """Tests for _configure_github_proxy payload shape and error handling.""" + + def test_sends_correct_payload_shape(self) -> None: + """Verify the PATCH request uses opaque headers with correct structure.""" + token = "ghs_testtoken123" + expected_basic = base64.b64encode(f"x-access-token:{token}".encode()).decode() + + with ( + patch("agent.integrations.langsmith.httpx.Client") as mock_client_cls, + patch.dict("os.environ", {"LANGSMITH_API_KEY": "ls-api-key"}), + ): + 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-abc123", token) + + mock_client.patch.assert_called_once() + call_kwargs = mock_client.patch.call_args + payload = call_kwargs.kwargs["json"] + + # Verify proxy_config structure + assert "proxy_config" in payload + rules = payload["proxy_config"]["rules"] + assert len(rules) == 1 + + rule = rules[0] + assert rule["name"] == "github" + assert rule["match_hosts"] == ["github.com", "*.github.com"] + + headers = rule["headers"] + assert len(headers) == 1 + assert headers[0]["name"] == "Authorization" + assert headers[0]["type"] == "opaque" + assert headers[0]["value"] == f"Basic {expected_basic}" + + def test_sends_to_correct_url(self) -> None: + """Verify the PATCH hits the right endpoint.""" + with ( + patch("agent.integrations.langsmith.httpx.Client") as mock_client_cls, + patch.dict( + "os.environ", + { + "LANGSMITH_ENDPOINT": "https://test.api.smith.langchain.com", + "LANGSMITH_API_KEY": "api-key", + }, + ), + ): + 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-xyz", "token") + + url = mock_client.patch.call_args.args[0] + assert url == "https://test.api.smith.langchain.com/v2/sandboxes/boxes/sandbox-xyz" + + def test_sends_api_key_header(self) -> None: + """Verify the PATCH includes the LangSmith API key.""" + with ( + patch("agent.integrations.langsmith.httpx.Client") as mock_client_cls, + patch.dict("os.environ", {"LANGSMITH_API_KEY": "my-api-key"}), + ): + 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") + + headers = mock_client.patch.call_args.kwargs["headers"] + assert headers == {"X-API-Key": "my-api-key"} + + def test_raises_on_http_error(self) -> None: + """Verify HTTP errors propagate.""" + with ( + patch("agent.integrations.langsmith.httpx.Client") as mock_client_cls, + patch.dict("os.environ", {"LANGSMITH_API_KEY": "api-key"}), + ): + mock_client = MagicMock() + mock_client.patch.side_effect = httpx.HTTPStatusError( + "Server error", request=MagicMock(), response=MagicMock(status_code=500) + ) + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + + with pytest.raises(httpx.HTTPStatusError): + _configure_github_proxy("sandbox-abc", "token") + + +class TestCreateSandboxWithProxy: + """Tests for _create_sandbox_with_proxy token source selection.""" + + @pytest.mark.asyncio + async def test_uses_installation_token_for_langsmith(self) -> None: + """Installation token should be used for proxy auth on langsmith sandboxes.""" + with ( + patch( + "agent.server.get_github_app_installation_token", + new_callable=AsyncMock, + return_value="ghs_install", + ), + patch("agent.server.create_sandbox") as mock_create, + patch("agent.server._configure_github_proxy") as mock_proxy, + patch.dict("os.environ", {"SANDBOX_TYPE": "langsmith", "LANGSMITH_API_KEY": "ls-key"}), + ): + mock_create.return_value = MagicMock(id="sandbox-123") + + from agent.server import _create_sandbox_with_proxy + + await _create_sandbox_with_proxy() + + mock_create.assert_called_once_with() + mock_proxy.assert_called_once_with("sandbox-123", "ghs_install") + + @pytest.mark.asyncio + async def test_skips_proxy_for_non_langsmith(self) -> None: + """Non-langsmith sandboxes should skip proxy configuration.""" + with ( + patch("agent.server.create_sandbox") as mock_create, + patch("agent.server._configure_github_proxy") as mock_proxy, + patch.dict("os.environ", {"SANDBOX_TYPE": "daytona"}), + ): + mock_create.return_value = MagicMock(id="sandbox-456") + + from agent.server import _create_sandbox_with_proxy + + await _create_sandbox_with_proxy() + + mock_create.assert_called_once_with() + mock_proxy.assert_not_called() + + @pytest.mark.asyncio + async def test_raises_when_no_installation_token_for_langsmith(self) -> None: + """Should raise ValueError when installation token is unavailable for langsmith.""" + with ( + patch("agent.server.create_sandbox") as mock_create, + patch( + "agent.server.get_github_app_installation_token", + new_callable=AsyncMock, + return_value=None, + ), + patch.dict("os.environ", {"SANDBOX_TYPE": "langsmith"}), + ): + mock_create.return_value = MagicMock(id="sandbox-789") + + from agent.server import _create_sandbox_with_proxy + + with pytest.raises(ValueError, match="installation token is unavailable"): + await _create_sandbox_with_proxy() diff --git a/uv.lock b/uv.lock index 2403c735..8f4c0da3 100644 --- a/uv.lock +++ b/uv.lock @@ -1505,7 +1505,7 @@ wheels = [ [[package]] name = "langsmith" -version = "0.7.1" +version = "0.7.25" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -1518,9 +1518,9 @@ dependencies = [ { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/48/3151de6df96e0977b8d319b03905e29db0df6929a85df1d922a030b7e68d/langsmith-0.7.1.tar.gz", hash = "sha256:e3fec2f97f7c5192f192f4873d6a076b8c6469768022323dded07087d8cb70a4", size = 984367, upload-time = "2026-02-10T01:55:24.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/d7/21ffae5ccdc3c9b8de283e8f8bf48a92039681df0d39f15133d8ff8965bd/langsmith-0.7.25.tar.gz", hash = "sha256:d17da71f156ca69eafd28ac9627c8e0e93170260ec37cd27cedc83205a067598", size = 1145410, upload-time = "2026-04-03T13:11:42.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/87/6f2b008a456b4f5fd0fb1509bb7e1e9368c1a0c9641a535f224a9ddc10f3/langsmith-0.7.1-py3-none-any.whl", hash = "sha256:92cfa54253d35417184c297ad25bfd921d95f15d60a1ca75f14d4e7acd152a29", size = 322515, upload-time = "2026-02-10T01:55:22.531Z" }, + { url = "https://files.pythonhosted.org/packages/29/13/67889d41baf7dbaf13ffd0b334a0f284e107fad1cc8782a1abb1e56e5eeb/langsmith-0.7.25-py3-none-any.whl", hash = "sha256:55ecc24c547f6c79b5a684ff8685c669eec34e52fcac5d2c0af7d613aef5a632", size = 359417, upload-time = "2026-04-03T13:11:40.729Z" }, ] [package.optional-dependencies] @@ -1826,7 +1826,7 @@ requires-dist = [ { name = "langgraph", specifier = ">=1.0.8" }, { name = "langgraph-cli", extras = ["inmem"], specifier = ">=0.4.12" }, { name = "langgraph-sdk", specifier = ">=0.1.0" }, - { name = "langsmith", specifier = ">=0.7.1" }, + { name = "langsmith", specifier = ">=0.7.20" }, { name = "markdownify", specifier = ">=1.2.2" }, { name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" }, { name = "pyjwt", specifier = ">=2.12.0" },