mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 23:13:15 +00:00
Revert "feat: authenticate git operations via sandbox proxy instead of creden…" (#1170)
This reverts commit 6305e13dc6.
This commit is contained in:
parent
c5ba4e2e93
commit
9aba4d0545
9 changed files with 144 additions and 294 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
8
uv.lock
generated
8
uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue