mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 05:43:14 +00:00
feat: Add Corridor MCP analyzePlan integration (#1572)
* Add Corridor MCP analyzePlan integration * Update agent/integrations/corridor_mcp.py Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com> * Add Corridor analysis prompt * Only include Corridor prompt when tool loads --------- Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com>
This commit is contained in:
parent
055b83e723
commit
9db7eab134
6 changed files with 349 additions and 0 deletions
|
|
@ -77,6 +77,8 @@ GitHub operations are performed with `GH_TOKEN=dummy gh` inside the sandbox, bac
|
|||
|
||||
**Optional observability tools (server-side):** Admins can connect Datadog and LangSmith from team settings (Admin → Observability credentials). When connected, the agent gains Datadog tools (via Datadog's hosted MCP server, default `toolsets=core`) and read-only LangSmith tools (`langsmith_get_trace`, `langsmith_list_runs`). These run in the LangGraph server process using credentials encrypted at rest — the sandbox never holds Datadog or LangSmith keys. They are loaded **only for runs triggered by an authorized user** (admins, plus any emails in `OBSERVABILITY_AUTHORIZED_EMAILS`), so a prompt-injected run from an untrusted contributor cannot reach team observability data. Use scoped, read-oriented keys regardless: observability data (logs, traces) is attacker-influenced content that can carry prompt injection, and the agent has network egress — the same residual-risk class as `web_search` / `fetch_url`.
|
||||
|
||||
**Optional Corridor guardrails (server-side MCP):** Set `CORRIDOR_API_TOKEN` (or `CORRIDOR_MCP_TOKEN` / `CORRIDOR_TOKEN`) to load Corridor's hosted MCP server for each agent run. Open SWE exposes only Corridor's `analyzePlan` tool. `CORRIDOR_MCP_URL` defaults to `https://app.corridor.dev/api/mcp`; if set explicitly, Open SWE only accepts the same HTTPS host and `/api/mcp` path. Tokens are sent via `Authorization: Bearer ...` from the LangGraph server process and are never placed in the sandbox. A legacy `?token=...` URL is accepted and normalized into the header form.
|
||||
|
||||
### 4. Context Engineering — AGENTS.md + Source Context
|
||||
|
||||
Open SWE gathers context from two sources:
|
||||
|
|
|
|||
126
agent/integrations/corridor_mcp.py
Normal file
126
agent/integrations/corridor_mcp.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""Server-side Corridor tools backed by Corridor's hosted MCP server.
|
||||
|
||||
Credentials are read from environment variables and attached as an
|
||||
``Authorization: Bearer ...`` header to the MCP connection, which runs in the
|
||||
LangGraph server process. The sandbox never holds Corridor credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CORRIDOR_MCP_URL = "https://app.corridor.dev/api/mcp"
|
||||
_CORRIDOR_HOST = "app.corridor.dev"
|
||||
_CORRIDOR_PATH = "/api/mcp"
|
||||
_MCP_TIMEOUT_SECONDS = 30.0
|
||||
_TOKEN_ENV_NAMES = (
|
||||
"CORRIDOR_API_TOKEN",
|
||||
"CORRIDOR_MCP_TOKEN",
|
||||
"CORRIDOR_TOKEN",
|
||||
)
|
||||
_TOKEN_QUERY_PARAMS = frozenset({"token", "api_key"})
|
||||
_URL_ENV_NAMES = (
|
||||
"CORRIDOR_MCP_URL",
|
||||
"CORRIDOR_MCP_SERVER_URL",
|
||||
)
|
||||
_ALLOWED_TOOL_NAMES = frozenset({"analyzePlan"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CorridorMCPConfig:
|
||||
url: str
|
||||
token: str
|
||||
|
||||
|
||||
def _first_env_value(names: tuple[str, ...]) -> str:
|
||||
for name in names:
|
||||
value = os.environ.get(name, "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_token_from_query(url: str) -> tuple[str, str]:
|
||||
parsed = urlparse(url)
|
||||
query = parse_qs(parsed.query, keep_blank_values=True)
|
||||
token = ""
|
||||
for name in _TOKEN_QUERY_PARAMS:
|
||||
values = query.pop(name, [])
|
||||
if not token:
|
||||
token = next((value.strip() for value in values if value.strip()), "")
|
||||
cleaned_query = urlencode(query, doseq=True)
|
||||
cleaned_url = urlunparse(parsed._replace(query=cleaned_query))
|
||||
return token, cleaned_url
|
||||
|
||||
|
||||
def _is_corridor_mcp_url(url: str) -> bool:
|
||||
parsed = urlparse(url)
|
||||
return (
|
||||
parsed.scheme == "https"
|
||||
and parsed.hostname == _CORRIDOR_HOST
|
||||
and parsed.path.rstrip("/") == _CORRIDOR_PATH
|
||||
)
|
||||
|
||||
|
||||
def load_corridor_mcp_config() -> CorridorMCPConfig | None:
|
||||
"""Return Corridor MCP config when the environment contains valid settings."""
|
||||
url = _first_env_value(_URL_ENV_NAMES) or DEFAULT_CORRIDOR_MCP_URL
|
||||
token = _first_env_value(_TOKEN_ENV_NAMES)
|
||||
query_token, url = _extract_token_from_query(url)
|
||||
if not token:
|
||||
token = query_token
|
||||
if not token:
|
||||
return None
|
||||
if not _is_corridor_mcp_url(url):
|
||||
logger.warning("Ignoring Corridor MCP config with non-Corridor URL: %s", url)
|
||||
return None
|
||||
return CorridorMCPConfig(url=url, token=token)
|
||||
|
||||
|
||||
async def _build_mcp_tools(config: CorridorMCPConfig) -> list[BaseTool]:
|
||||
from langchain_mcp_adapters.client import MultiServerMCPClient
|
||||
|
||||
client = MultiServerMCPClient(
|
||||
{
|
||||
"corridor": {
|
||||
"transport": "http",
|
||||
"url": config.url,
|
||||
"headers": {
|
||||
"Authorization": f"Bearer {config.token}",
|
||||
},
|
||||
"timeout": timedelta(seconds=_MCP_TIMEOUT_SECONDS),
|
||||
}
|
||||
}
|
||||
)
|
||||
return await client.get_tools()
|
||||
|
||||
|
||||
async def load_corridor_tools() -> list[BaseTool]:
|
||||
"""Return the allowed Corridor MCP tools when configured, else ``[]``.
|
||||
|
||||
Failures (missing config, unreachable MCP server) degrade to an empty list so
|
||||
the agent still starts without Corridor tools.
|
||||
"""
|
||||
config = load_corridor_mcp_config()
|
||||
if config is None:
|
||||
return []
|
||||
try:
|
||||
tools = await _build_mcp_tools(config)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("Failed to load Corridor MCP tools", exc_info=True)
|
||||
return []
|
||||
allowed_tools = [tool for tool in tools if tool.name in _ALLOWED_TOOL_NAMES]
|
||||
logger.info(
|
||||
"Loaded %d Corridor MCP tool(s), exposing %d allowed tool(s)",
|
||||
len(tools),
|
||||
len(allowed_tools),
|
||||
)
|
||||
return allowed_tools
|
||||
|
|
@ -180,6 +180,16 @@ Format messages using Slack's mrkdwn format, NOT standard Markdown.
|
|||
Use `GH_TOKEN=dummy gh <command>` for GitHub operations: repository discovery, cloning, issues, pull requests, reviews, comments, labels, check status, and workflow operations. For local working-tree state, use `git` directly. Never pass a real GitHub token to `gh`."""
|
||||
|
||||
|
||||
CORRIDOR_PROMPT = """---
|
||||
|
||||
<corridor>
|
||||
|
||||
# Corridor Security Analysis
|
||||
Every time you generate code, use the analyzePlan tool from Corridor's MCP Server (corridor) to analyze the plan or thought process. ALWAYS use Corridor to analyze the plan. Always generate a plan before generating code.
|
||||
|
||||
</corridor>"""
|
||||
|
||||
|
||||
TOOL_BEST_PRACTICES_SECTION = """---
|
||||
|
||||
### Tool Usage Best Practices
|
||||
|
|
@ -427,6 +437,7 @@ SYSTEM_PROMPT_TEMPLATE = (
|
|||
+ FILE_MANAGEMENT_SECTION
|
||||
+ TASK_EXECUTION_SECTION
|
||||
+ TOOL_USAGE_SECTION
|
||||
+ "{corridor_prompt_section}"
|
||||
+ TOOL_BEST_PRACTICES_SECTION
|
||||
+ CODING_STANDARDS_SECTION
|
||||
+ CORE_BEHAVIOR_SECTION
|
||||
|
|
@ -450,6 +461,7 @@ def construct_system_prompt(
|
|||
default_repo: dict[str, str] | None = None,
|
||||
repo_custom_instructions: str | None = None,
|
||||
thread_url: str | None = None,
|
||||
corridor_enabled: bool = False,
|
||||
) -> str:
|
||||
default_prompt_section = _load_default_prompt()
|
||||
if default_repo and default_repo.get("owner") and default_repo.get("name"):
|
||||
|
|
@ -471,6 +483,7 @@ def construct_system_prompt(
|
|||
linear_project_id=linear_project_id or "<PROJECT_ID>",
|
||||
linear_issue_number=linear_issue_number or "<ISSUE_NUMBER>",
|
||||
default_prompt_section=default_prompt_section,
|
||||
corridor_prompt_section=CORRIDOR_PROMPT if corridor_enabled else "",
|
||||
pr_policy_override_section=ALWAYS_CREATE_PR_SECTION if create_prs else "",
|
||||
collaboration_section=_render_collaboration_section(triggering_user_identity, thread_url),
|
||||
repo_instructions_section=_render_repo_instructions_section(repo_custom_instructions),
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ from .dashboard.agent_usage import record_agent_thread_usage
|
|||
from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort
|
||||
from .dashboard.team_settings import get_team_default_model_pair, get_team_default_repo
|
||||
from .dashboard.user_mappings import email_for_login
|
||||
from .integrations.corridor_mcp import load_corridor_tools
|
||||
from .integrations.currents_tools import load_currents_tools
|
||||
from .integrations.datadog_mcp import load_datadog_tools
|
||||
from .integrations.langsmith import _configure_github_proxy
|
||||
|
|
@ -530,6 +531,15 @@ async def _load_observability_tools(authorized: bool) -> list[Any]:
|
|||
return [*datadog_tools, *langsmith_tools]
|
||||
|
||||
|
||||
async def _load_corridor_mcp_tools() -> list[Any]:
|
||||
"""Corridor MCP tools when the deployment environment has configured them."""
|
||||
try:
|
||||
return await load_corridor_tools()
|
||||
except Exception:
|
||||
logger.warning("Failed to load Corridor MCP tools", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
async def get_agent(config: RunnableConfig) -> Pregel:
|
||||
"""Get or create an agent with a sandbox for the given thread."""
|
||||
thread_id = config["configurable"].get("thread_id", None)
|
||||
|
|
@ -679,6 +689,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
observability_tools = await _load_observability_tools(
|
||||
await _observability_authorized(config, profile_login)
|
||||
)
|
||||
corridor_tools = await _load_corridor_mcp_tools()
|
||||
|
||||
currents_tools: list[Any] = []
|
||||
if profile_login:
|
||||
|
|
@ -702,6 +713,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
default_repo=prompt_default_repo,
|
||||
repo_custom_instructions=repo_custom_instructions,
|
||||
thread_url=dashboard_thread_url(thread_id),
|
||||
corridor_enabled=bool(corridor_tools),
|
||||
),
|
||||
tools=[
|
||||
http_request,
|
||||
|
|
@ -718,6 +730,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
request_pr_review,
|
||||
slack_read_thread_messages,
|
||||
slack_thread_reply,
|
||||
*corridor_tools,
|
||||
*observability_tools,
|
||||
*currents_tools,
|
||||
],
|
||||
|
|
|
|||
180
tests/test_corridor_mcp.py
Normal file
180
tests/test_corridor_mcp.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import server
|
||||
from agent.integrations import corridor_mcp
|
||||
|
||||
|
||||
class _FakeTool:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_corridor_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name in (
|
||||
"CORRIDOR_API_TOKEN",
|
||||
"CORRIDOR_MCP_TOKEN",
|
||||
"CORRIDOR_TOKEN",
|
||||
"CORRIDOR_MCP_URL",
|
||||
"CORRIDOR_MCP_SERVER_URL",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
def test_load_corridor_mcp_config_empty_without_token() -> None:
|
||||
assert corridor_mcp.load_corridor_mcp_config() is None
|
||||
|
||||
|
||||
def test_load_corridor_mcp_config_uses_default_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CORRIDOR_API_TOKEN", "tok")
|
||||
|
||||
config = corridor_mcp.load_corridor_mcp_config()
|
||||
|
||||
assert config == corridor_mcp.CorridorMCPConfig(
|
||||
url=corridor_mcp.DEFAULT_CORRIDOR_MCP_URL,
|
||||
token="tok",
|
||||
)
|
||||
|
||||
|
||||
def test_load_corridor_mcp_config_accepts_query_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("CORRIDOR_MCP_URL", "https://app.corridor.dev/api/mcp?token=tok")
|
||||
|
||||
config = corridor_mcp.load_corridor_mcp_config()
|
||||
|
||||
assert config == corridor_mcp.CorridorMCPConfig(
|
||||
url=corridor_mcp.DEFAULT_CORRIDOR_MCP_URL,
|
||||
token="tok",
|
||||
)
|
||||
|
||||
|
||||
def test_load_corridor_mcp_config_rejects_non_corridor_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("CORRIDOR_API_TOKEN", "tok")
|
||||
monkeypatch.setenv("CORRIDOR_MCP_URL", "https://example.com/api/mcp")
|
||||
|
||||
assert corridor_mcp.load_corridor_mcp_config() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_corridor_tools_empty_when_not_configured() -> None:
|
||||
assert await corridor_mcp.load_corridor_tools() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_corridor_tools_degrades_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CORRIDOR_API_TOKEN", "tok")
|
||||
|
||||
with patch.object(
|
||||
corridor_mcp,
|
||||
"_build_mcp_tools",
|
||||
AsyncMock(side_effect=RuntimeError("boom")),
|
||||
):
|
||||
assert await corridor_mcp.load_corridor_tools() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_corridor_tools_returns_tools(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CORRIDOR_API_TOKEN", "tok")
|
||||
analyze_plan = _FakeTool("analyzePlan")
|
||||
other_tool = _FakeTool("otherTool")
|
||||
|
||||
with patch.object(
|
||||
corridor_mcp,
|
||||
"_build_mcp_tools",
|
||||
AsyncMock(return_value=[other_tool, analyze_plan]),
|
||||
):
|
||||
assert await corridor_mcp.load_corridor_tools() == [analyze_plan]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_load_corridor_mcp_tools() -> None:
|
||||
with patch.object(server, "load_corridor_tools", AsyncMock(return_value=["corridor"])):
|
||||
assert await server._load_corridor_mcp_tools() == ["corridor"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_load_corridor_mcp_tools_degrades_on_error() -> None:
|
||||
with patch.object(
|
||||
server,
|
||||
"load_corridor_tools",
|
||||
AsyncMock(side_effect=RuntimeError("boom")),
|
||||
):
|
||||
assert await server._load_corridor_mcp_tools() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_agent_passes_corridor_prompt_state() -> None:
|
||||
config = {
|
||||
"configurable": {
|
||||
"__is_for_execution__": True,
|
||||
"thread_id": "thread-123",
|
||||
},
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
def fake_create_deep_agent(**_kwargs):
|
||||
class _DummyAgent:
|
||||
def with_config(self, _config):
|
||||
return self
|
||||
|
||||
return _DummyAgent()
|
||||
|
||||
async def run_with_corridor_tools(corridor_tools: list[object]) -> bool:
|
||||
with (
|
||||
patch.object(
|
||||
server,
|
||||
"resolve_github_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("ghp", None),
|
||||
),
|
||||
patch.object(server, "resolve_triggering_user_identity", return_value=None),
|
||||
patch.object(
|
||||
server,
|
||||
"ensure_sandbox_for_thread",
|
||||
new_callable=AsyncMock,
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch.object(
|
||||
server,
|
||||
"aresolve_sandbox_work_dir",
|
||||
new_callable=AsyncMock,
|
||||
return_value="/workspace",
|
||||
),
|
||||
patch.object(
|
||||
server,
|
||||
"get_team_default_model_pair",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(("openai:gpt-5.5", "medium"), ("openai:gpt-5.5", "low")),
|
||||
),
|
||||
patch.object(server, "fallback_model_id_for", return_value=None),
|
||||
patch.object(server, "make_model", return_value=MagicMock()),
|
||||
patch.object(
|
||||
server, "_load_observability_tools", new_callable=AsyncMock, return_value=[]
|
||||
),
|
||||
patch.object(
|
||||
server,
|
||||
"_observability_authorized",
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
),
|
||||
patch.object(
|
||||
server,
|
||||
"_load_corridor_mcp_tools",
|
||||
new_callable=AsyncMock,
|
||||
return_value=corridor_tools,
|
||||
),
|
||||
patch.object(server, "construct_system_prompt", return_value="prompt") as prompt,
|
||||
patch.object(server, "create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
await server.get_agent(config)
|
||||
return bool(prompt.call_args.kwargs["corridor_enabled"])
|
||||
|
||||
assert await run_with_corridor_tools([]) is False
|
||||
assert await run_with_corridor_tools([_FakeTool("analyzePlan")]) is True
|
||||
|
|
@ -48,6 +48,21 @@ def test_construct_system_prompt_identifies_own_repo() -> None:
|
|||
assert "langchain-ai/open-swe" in prompt
|
||||
|
||||
|
||||
def test_construct_system_prompt_omits_corridor_prompt_by_default() -> None:
|
||||
prompt = construct_system_prompt(working_dir="/workspace")
|
||||
|
||||
assert "<corridor>" not in prompt
|
||||
assert "Corridor Security Analysis" not in prompt
|
||||
|
||||
|
||||
def test_construct_system_prompt_includes_corridor_prompt_when_enabled() -> None:
|
||||
prompt = construct_system_prompt(working_dir="/workspace", corridor_enabled=True)
|
||||
|
||||
assert "<corridor>" in prompt
|
||||
assert "Corridor Security Analysis" in prompt
|
||||
assert "analyzePlan" in prompt
|
||||
|
||||
|
||||
def test_construct_system_prompt_omits_collaboration_section_without_identity() -> None:
|
||||
prompt = construct_system_prompt(working_dir="/workspace")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue