From 91f63de361994827727659dd4d5fab3bb841ca8e Mon Sep 17 00:00:00 2001 From: Aran Yogesh Date: Thu, 9 Apr 2026 14:59:49 -0700 Subject: [PATCH] fix: refresh GitHub proxy token on sandbox reuse to prevent git auth failures (#1178) * fix: refresh GitHub proxy token on sandbox reuse to prevent git auth failures * fix: refresh GitHub proxy token on sandbox reuse to prevent git auth failures * function name change --- agent/server.py | 23 +++++++- tests/test_proxy_auth.py | 118 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/agent/server.py b/agent/server.py index 95ca0505..82710da0 100644 --- a/agent/server.py +++ b/agent/server.py @@ -192,6 +192,24 @@ async def _create_sandbox_with_proxy() -> SandboxBackendProtocol: return sandbox_backend +async def _refresh_github_proxy( + sandbox_backend: SandboxBackendProtocol, +) -> None: + """Refresh GitHub proxy credentials for reused LangSmith sandboxes.""" + if os.getenv("SANDBOX_TYPE", "langsmith") != "langsmith": + return + + installation_token = await get_github_app_installation_token() + if not installation_token: + logger.warning( + "Skipping GitHub proxy refresh for sandbox %s: installation token unavailable", + sandbox_backend.id, + ) + return + + await asyncio.to_thread(_configure_github_proxy, sandbox_backend.id, installation_token) + + async def _recreate_sandbox( thread_id: str, repo_owner: str, @@ -283,6 +301,8 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 metadata = get_config().get("metadata", {}) repo_dir = metadata.get("repo_dir") + await _refresh_github_proxy(sandbox_backend) + if repo_owner and repo_name: logger.info("Pulling latest changes for repo %s/%s", repo_owner, repo_name) try: @@ -332,7 +352,6 @@ 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) sandbox_backend = await asyncio.to_thread(create_sandbox, sandbox_id) logger.info("Connected to existing sandbox %s", sandbox_id) except Exception: @@ -351,6 +370,8 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": None}) raise + await _refresh_github_proxy(sandbox_backend) + metadata = get_config().get("metadata", {}) repo_dir = metadata.get("repo_dir") diff --git a/tests/test_proxy_auth.py b/tests/test_proxy_auth.py index 4ab212d1..8ada47f0 100644 --- a/tests/test_proxy_auth.py +++ b/tests/test_proxy_auth.py @@ -170,3 +170,121 @@ class TestCreateSandboxWithProxy: with pytest.raises(ValueError, match="installation token is unavailable"): await _create_sandbox_with_proxy() + + +class _DummyAgent: + def with_config(self, config): + return self + + +class TestRefreshProxyOnSandboxReuse: + """Tests for refreshing GitHub proxy auth on sandbox reuse.""" + + @staticmethod + def _execution_config() -> dict: + return { + "configurable": { + "__is_for_execution__": True, + "thread_id": "thread-123", + "repo": {"owner": "langchain-ai", "name": "open-swe"}, + }, + "metadata": {}, + } + + @pytest.mark.asyncio + async def test_refreshes_proxy_for_cached_langsmith_sandbox(self) -> None: + """Cached sandboxes should get a fresh proxy token before git operations.""" + config = self._execution_config() + mock_sandbox = MagicMock(id="sandbox-cached") + + with ( + patch("agent.server.get_config", return_value=config), + patch( + "agent.server.resolve_github_token", + new_callable=AsyncMock, + return_value=("ghp", "enc"), + ), + patch( + "agent.server.get_sandbox_id_from_metadata", + new_callable=AsyncMock, + return_value="sandbox-cached", + ), + patch( + "agent.server.get_github_app_installation_token", + new_callable=AsyncMock, + return_value="ghs_fresh", + ), + patch("agent.server._configure_github_proxy") as mock_proxy, + patch( + "agent.server._clone_or_pull_repo_in_sandbox", + new_callable=AsyncMock, + return_value="/workspace/open-swe", + ), + patch( + "agent.server.read_agents_md_in_sandbox", + new_callable=AsyncMock, + return_value="", + ), + patch("agent.server.make_model", return_value=MagicMock()), + patch("agent.server.construct_system_prompt", return_value="prompt"), + patch("agent.server.create_deep_agent", return_value=_DummyAgent()), + patch.dict( + "agent.server.SANDBOX_BACKENDS", + {"thread-123": mock_sandbox}, + clear=True, + ), + patch.dict("os.environ", {"SANDBOX_TYPE": "langsmith"}), + ): + from agent.server import get_agent + + await get_agent(config) + + mock_proxy.assert_called_once_with("sandbox-cached", "ghs_fresh") + + @pytest.mark.asyncio + async def test_refreshes_proxy_when_reconnecting_to_existing_langsmith_sandbox(self) -> None: + """Reconnected sandboxes should also get a fresh proxy token.""" + config = self._execution_config() + mock_sandbox = MagicMock(id="sandbox-existing") + + with ( + patch("agent.server.get_config", return_value=config), + patch( + "agent.server.resolve_github_token", + new_callable=AsyncMock, + return_value=("ghp", "enc"), + ), + patch( + "agent.server.get_sandbox_id_from_metadata", + new_callable=AsyncMock, + return_value="sandbox-existing", + ), + patch("agent.server.create_sandbox", return_value=mock_sandbox) as mock_create, + patch( + "agent.server.get_github_app_installation_token", + new_callable=AsyncMock, + return_value="ghs_fresh", + ), + patch("agent.server._configure_github_proxy") as mock_proxy, + patch( + "agent.server._clone_or_pull_repo_in_sandbox", + new_callable=AsyncMock, + return_value="/workspace/open-swe", + ), + patch( + "agent.server.read_agents_md_in_sandbox", + new_callable=AsyncMock, + return_value="", + ), + patch("agent.server.make_model", return_value=MagicMock()), + patch("agent.server.construct_system_prompt", return_value="prompt"), + patch("agent.server.create_deep_agent", return_value=_DummyAgent()), + patch.dict("agent.server.SANDBOX_BACKENDS", {}, clear=True), + patch.dict("os.environ", {"SANDBOX_TYPE": "langsmith"}), + ): + from agent.server import get_agent + + await get_agent(config) + + mock_create.assert_called_once_with("sandbox-existing") + mock_proxy.assert_called_once_with("sandbox-existing", "ghs_fresh")