From 9aba4d05456faabd35037eaa403d6bead831495c Mon Sep 17 00:00:00 2001 From: Aran Yogesh Date: Tue, 7 Apr 2026 18:45:33 -0700 Subject: [PATCH] =?UTF-8?q?Revert=20"feat:=20authenticate=20git=20operatio?= =?UTF-8?q?ns=20via=20sandbox=20proxy=20instead=20of=20creden=E2=80=A6"=20?= =?UTF-8?q?(#1170)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 6305e13dc66c1e6a063884d08e6ca967d21ebdaf. --- 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 | 60 +++++++++-- pyproject.toml | 2 +- tests/test_github_security.py | 19 ++++ tests/test_proxy_auth.py | 172 ------------------------------ uv.lock | 8 +- 9 files changed, 144 insertions(+), 294 deletions(-) delete mode 100644 tests/test_proxy_auth.py diff --git a/agent/integrations/langsmith.py b/agent/integrations/langsmith.py index 9324be2a..602c827a 100644 --- a/agent/integrations/langsmith.py +++ b/agent/integrations/langsmith.py @@ -2,21 +2,16 @@ 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. @@ -34,62 +29,14 @@ 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 @@ -104,10 +51,6 @@ 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 @@ -165,7 +108,7 @@ class SandboxProvider(ABC): raise NotImplementedError -DEFAULT_TEMPLATE_NAME = "open-swe-new" +DEFAULT_TEMPLATE_NAME = "open-swe" DEFAULT_TEMPLATE_IMAGE = "python:3" @@ -175,9 +118,9 @@ 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 os.environ.get("LANGSMITH_API_KEY") if not self._api_key: - msg = "LANGSMITH_API_KEY (or LANGSMITH_API_KEY_PROD) not set" + msg = "LANGSMITH_API_KEY environment variable 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 12d9325e..b02a8509 100644 --- a/agent/middleware/open_pr.py +++ b/agent/middleware/open_pr.py @@ -160,7 +160,9 @@ 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) + await asyncio.to_thread( + git_push, sandbox_backend, repo_dir, target_branch, installation_token + ) 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 95ca0505..52a0a787 100644 --- a/agent/server.py +++ b/agent/server.py @@ -27,7 +27,6 @@ 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, @@ -58,7 +57,6 @@ 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 @@ -70,28 +68,32 @@ 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( +async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915 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 @@ -99,9 +101,18 @@ async def _clone_or_pull_repo_in_sandbox( 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) @@ -132,10 +143,21 @@ async def _clone_or_pull_repo_in_sandbox( 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, - sandbox_backend.execute, - f"cd {shlex.quote(repo_dir)} && git pull origin $(git rev-parse --abbrev-ref HEAD)", + git_pull_branch, + sandbox_backend, + repo_dir, + current_branch, + token, ) logger.debug("Git pull result: exit_code=%s", pull_result.exit_code) if pull_result.exit_code != 0: @@ -152,16 +174,19 @@ async def _clone_or_pull_repo_in_sandbox( 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 clone {shlex.quote(clean_url)} {shlex.quote(repo_dir)}", + f"git -c credential.helper={cred_helper} clone {safe_clean_url} {safe_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}" @@ -172,35 +197,17 @@ async def _clone_or_pull_repo_in_sandbox( 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 (with proxy auth configured), and clones the repo. + creates a fresh sandbox, and clones the repo. """ SANDBOX_BACKENDS.pop(thread_id, None) await client.threads.update( @@ -208,8 +215,10 @@ async def _recreate_sandbox( metadata={"sandbox_id": SANDBOX_CREATING}, ) try: - sandbox_backend = await _create_sandbox_with_proxy() - repo_dir = await _clone_or_pull_repo_in_sandbox(sandbox_backend, repo_owner, repo_name) + 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 + ) except Exception: logger.exception("Failed to recreate sandbox after connection failure") await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": None}) @@ -287,7 +296,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 + sandbox_backend, repo_owner, repo_name, github_token ) except SandboxClientError: logger.warning( @@ -295,7 +304,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 + thread_id, repo_owner, repo_name, github_token=github_token ) except Exception: logger.exception("Failed to pull repo in cached sandbox") @@ -306,14 +315,15 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": SANDBOX_CREATING}) try: - sandbox_backend = await _create_sandbox_with_proxy() + # Create sandbox without context manager cleanup (sandbox persists) + sandbox_backend = await asyncio.to_thread(create_sandbox) 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 + sandbox_backend, repo_owner, repo_name, github_token ) logger.info("Repo cloned to %s", repo_dir) @@ -332,19 +342,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 (no proxy reconfiguration needed) + # Connect to existing sandbox without context manager cleanup 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 with proxy auth configured + # Reset sandbox_id and create a new sandbox await client.threads.update( thread_id=thread_id, metadata={"sandbox_id": SANDBOX_CREATING}, ) try: - sandbox_backend = await _create_sandbox_with_proxy() + sandbox_backend = await asyncio.to_thread(create_sandbox) logger.info("New sandbox created: %s", sandbox_backend.id) except Exception: logger.exception("Failed to create replacement sandbox") @@ -358,7 +368,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 + sandbox_backend, repo_owner, repo_name, github_token ) except SandboxClientError: logger.warning( @@ -366,7 +376,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 + thread_id, repo_owner, repo_name, github_token=github_token ) 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 b4922c91..7d5f3452 100644 --- a/agent/tools/commit_and_open_pr.py +++ b/agent/tools/commit_and_open_pr.py @@ -151,6 +151,14 @@ 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) @@ -190,15 +198,7 @@ def commit_and_open_pr( "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, - } - - push_result = git_push(sandbox_backend, repo_dir, target_branch) + push_result = git_push(sandbox_backend, repo_dir, target_branch, installation_token) if push_result.exit_code != 0: return { "success": False, diff --git a/agent/utils/github.py b/agent/utils/github.py index fec0c5c3..acd3cf59 100644 --- a/agent/utils/github.py +++ b/agent/utils/github.py @@ -122,18 +122,66 @@ 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. - - Authentication is handled by the sandbox proxy (configured at sandbox creation - time via the LangSmith proxy-config API), so no token is needed here. - """ + """Push the branch to origin, using a token if needed.""" safe_branch = shlex.quote(branch) - return _run_git(sandbox_backend, repo_dir, f"git push origin {safe_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) + + +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.""" + 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) async def create_github_pr( diff --git a/pyproject.toml b/pyproject.toml index c6c3c30a..648bc8bf 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.20", + "langsmith>=0.7.1", "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 f4585efa..c89334f7 100644 --- a/tests/test_github_security.py +++ b/tests/test_github_security.py @@ -27,3 +27,22 @@ 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 deleted file mode 100644 index 4ab212d1..00000000 --- a/tests/test_proxy_auth.py +++ /dev/null @@ -1,172 +0,0 @@ -"""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 8f4c0da3..2403c735 100644 --- a/uv.lock +++ b/uv.lock @@ -1505,7 +1505,7 @@ wheels = [ [[package]] name = "langsmith" -version = "0.7.25" +version = "0.7.1" 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/7e/d7/21ffae5ccdc3c9b8de283e8f8bf48a92039681df0d39f15133d8ff8965bd/langsmith-0.7.25.tar.gz", hash = "sha256:d17da71f156ca69eafd28ac9627c8e0e93170260ec37cd27cedc83205a067598", size = 1145410, upload-time = "2026-04-03T13:11:42.36Z" } +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" } wheels = [ - { 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" }, + { 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" }, ] [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.20" }, + { name = "langsmith", specifier = ">=0.7.1" }, { name = "markdownify", specifier = ">=1.2.2" }, { name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" }, { name = "pyjwt", specifier = ">=2.12.0" },