mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 11:33:14 +00:00
* wip(rebuild): core reliability spine
- remove PR-babysitting (ci_autofix + ci_monitor graph + webhook wiring)
- dispatch core: agent/dispatch.py with multitask_strategy=interrupt +
durability=sync + completion webhook; reroute all webhook + plan triggers;
drop the racy in-process lock + is_thread_active busy-check
- completion webhook: agent/completion.py + /webhooks/run-complete loopback
route for failure/timeout replies (idempotent)
Co-authored-by: open-swe[bot]
* feat(rebuild): async tools, reconcile, shared http timeouts, assembly tuning
Parallel batch on top of the reliability spine:
- async-ify all 24 tools (drop asyncio.run; requests->httpx); re-implement the
http_request/fetch_url SSRF + DNS-rebinding defense httpx-natively and harden
the IP check to 'not is_global' (+ IPv4-mapped unwrap)
- reconcile.py: stale pending-run sweep (threads.search -> per-thread runs.list
-> cancel_many), wired into the scheduler graph via task='reconcile'
- shared DEFAULT_HTTP_TIMEOUT (agent/utils/http.py) on every bare
httpx.AsyncClient() across utils/dashboard/webapp/middleware
- run budget: MODEL_CALL_RECURSION_LIMIT 5000->250
- fix stale OpenAI->Anthropic fallback id (claude-opus-4-5 -> 4-8)
- drop redundant custom repair middleware (deepagents auto-adds PatchToolCalls)
- confirm tool-result eviction + summarization auto-wired via backend
- slim system prompt ~8% (full harness-profile rewrite deferred)
Co-authored-by: open-swe[bot]
* feat(rebuild): harness-profile prompt + split webhooks out of webapp
- prompt.py: own the system prompt via a registered harness profile
(OPEN_SWE_SHARED_BASE, kept neutral so the read-only reviewer/analyzer that
share it stay safe), registered across all 4 providers; per-thread values
stay in construct_system_prompt. Assembled main-agent prompt ~6.8k -> ~3.1k
tokens (~55% smaller); de-duped PR/commit/suite/force-push guidance; dropped
ALL-CAPS markers.
- webapp.py 3325 -> 1890 LOC: moved 14 per-source handlers into
agent/webhooks/{linear,slack,github}.py; webapp re-exports them for the
routes + tests; moved handlers reach shared helpers via the webapp namespace
to preserve the test suite's monkeypatch targets.
Full suite: 1168 passing, lint clean.
Co-authored-by: open-swe[bot]
* Restore MODEL_CALL_RECURSION_LIMIT to 5000 for long-running tasks
Reverts the 250 cap from the run-budget change — long-running tasks legitimately
need many model calls. The notify_step_limit_reached safety net still fires if a
run does hit the cap, so runs end with a signal either way.
Co-authored-by: open-swe[bot]
* fix: address PR review (auth, SSRF, interrupted status, redirect headers)
- completion.py: drop `interrupted` from failure statuses — with
multitask_strategy=interrupt a follow-up ends the prior run as interrupted,
which is healthy, not a failure to report. [open-swe]
- /webhooks/run-complete: shared-secret auth — dispatch appends ?token= when
RUN_COMPLETE_WEBHOOK_SECRET is set; route verifies via hmac.compare_digest.
[corridor-security]
- SSRF: extract the URL validator to agent/utils/url_safety.py and apply it
before server-side image fetches in multimodal.fetch_image_block.
[corridor-security]
- http_request: preserve caller headers/extensions across redirect hops instead
of dropping them on the first hop. [open-swe]
Co-authored-by: open-swe[bot]
* chore: remove REBUILD_PLAN.md (planning doc, not needed in the repo)
Co-authored-by: open-swe[bot]
* fix: fail closed on run-complete webhook auth when secret unset
Corridor follow-up: verify_run_complete_token returns False (not True) when
RUN_COMPLETE_WEBHOOK_SECRET is unset, so the public route is never
unauthenticated. Logs a startup warning when the secret is absent, and dispatch
skips registering the webhook when there's no secret (no rejected callbacks).
Co-authored-by: open-swe[bot]
---------
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
312 lines
11 KiB
Python
312 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib
|
|
import socket as real_socket
|
|
import sys
|
|
import types
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
exa_py_stub = types.ModuleType("exa_py")
|
|
exa_py_stub.Exa = object
|
|
sys.modules.setdefault("exa_py", exa_py_stub)
|
|
|
|
importlib.import_module("agent.tools.fetch_url")
|
|
importlib.import_module("agent.tools.http_request")
|
|
fetch_url_tool = sys.modules["agent.tools.fetch_url"]
|
|
http_request_tool = sys.modules["agent.tools.http_request"]
|
|
# SSRF resolution now lives in the shared validator; patch DNS there.
|
|
url_safety = importlib.import_module("agent.utils.url_safety")
|
|
|
|
_NO_JSON = object()
|
|
|
|
|
|
def _addr_info(ip: str, port: int | None = None) -> tuple:
|
|
return (
|
|
real_socket.AF_INET,
|
|
real_socket.SOCK_STREAM,
|
|
6,
|
|
"",
|
|
(ip, port or 0),
|
|
)
|
|
|
|
|
|
class FakeResponse:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
status_code: int,
|
|
url: str,
|
|
headers: dict[str, str] | None = None,
|
|
text: str = "",
|
|
json_data: object = _NO_JSON,
|
|
) -> None:
|
|
self.status_code = status_code
|
|
self.url = url
|
|
self.headers = headers or {}
|
|
self.text = text
|
|
self._json_data = json_data
|
|
|
|
def json(self) -> object:
|
|
if self._json_data is _NO_JSON:
|
|
raise ValueError("response is not json")
|
|
return self._json_data
|
|
|
|
def raise_for_status(self) -> None:
|
|
if self.status_code >= 400:
|
|
raise httpx.HTTPStatusError(f"{self.status_code} error", request=None, response=None)
|
|
|
|
|
|
class FakeAsyncClient:
|
|
"""Records each request and replays programmed responses.
|
|
|
|
``responder(method, url, **kwargs)`` returns a ``FakeResponse``. The class is
|
|
installed in place of ``httpx.AsyncClient`` on the tool module under test.
|
|
"""
|
|
|
|
last_instance: FakeAsyncClient | None = None
|
|
|
|
def __init__(self, responder, *args: Any, **kwargs: Any) -> None:
|
|
self._responder = responder
|
|
self.calls: list[dict[str, Any]] = []
|
|
FakeAsyncClient.last_instance = self
|
|
|
|
async def __aenter__(self) -> FakeAsyncClient:
|
|
return self
|
|
|
|
async def __aexit__(self, *exc: Any) -> bool:
|
|
return False
|
|
|
|
async def request(self, method: str, url: str, **kwargs: Any) -> FakeResponse:
|
|
self.calls.append({"method": method, "url": url, **kwargs})
|
|
return self._responder(method, url, **kwargs)
|
|
|
|
|
|
def _install_client(monkeypatch, module, responder) -> type:
|
|
def factory(*args: Any, **kwargs: Any) -> FakeAsyncClient:
|
|
return FakeAsyncClient(responder, *args, **kwargs)
|
|
|
|
fake_httpx = types.SimpleNamespace(
|
|
AsyncClient=factory,
|
|
HTTPError=httpx.HTTPError,
|
|
TimeoutException=httpx.TimeoutException,
|
|
)
|
|
monkeypatch.setattr(module, "httpx", fake_httpx)
|
|
return factory
|
|
|
|
|
|
# --- _resolve_and_validate (pure IP gating) ----------------------------------
|
|
|
|
|
|
def test_resolve_and_validate_rejects_unsupported_scheme() -> None:
|
|
is_safe, reason, _, _ = http_request_tool._resolve_and_validate("ftp://example.com/x")
|
|
assert is_safe is False
|
|
assert "scheme" in reason.lower()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ip",
|
|
["127.0.0.1", "169.254.169.254", "10.0.0.5", "192.168.1.1"],
|
|
)
|
|
def test_resolve_and_validate_rejects_private_ranges(monkeypatch, ip: str) -> None:
|
|
monkeypatch.setattr(
|
|
url_safety.socket,
|
|
"getaddrinfo",
|
|
lambda host, port, *a, **k: [_addr_info(ip, port)],
|
|
)
|
|
is_safe, reason, hostname, _ = http_request_tool._resolve_and_validate("http://evil.test/")
|
|
assert is_safe is False
|
|
assert "blocked address" in reason
|
|
assert hostname == "evil.test"
|
|
|
|
|
|
def test_resolve_and_validate_accepts_public_ip(monkeypatch) -> None:
|
|
monkeypatch.setattr(
|
|
url_safety.socket,
|
|
"getaddrinfo",
|
|
lambda host, port, *a, **k: [_addr_info("93.184.216.34", port)],
|
|
)
|
|
is_safe, reason, hostname, addr_infos = http_request_tool._resolve_and_validate(
|
|
"https://example.com/path"
|
|
)
|
|
assert is_safe is True
|
|
assert reason == ""
|
|
assert hostname == "example.com"
|
|
assert addr_infos[0][4][0] == "93.184.216.34"
|
|
|
|
|
|
def test_pinned_url_rewrites_host_to_ip_keeping_path_and_port() -> None:
|
|
assert (
|
|
http_request_tool._pinned_url("https://example.com:8443/a/b?q=1", "93.184.216.34")
|
|
== "https://93.184.216.34:8443/a/b?q=1"
|
|
)
|
|
# IPv6 literal is bracketed
|
|
assert http_request_tool._pinned_url("http://h/x", "::1").startswith("http://[::1]/x")
|
|
|
|
|
|
# --- fetch_url ---------------------------------------------------------------
|
|
|
|
|
|
async def test_fetch_url_blocks_private_ip_without_issuing_a_request(monkeypatch) -> None:
|
|
def fail_responder(*args: Any, **kwargs: Any) -> FakeResponse:
|
|
raise AssertionError("request should not be issued for blocked URLs")
|
|
|
|
_install_client(monkeypatch, fetch_url_tool, fail_responder)
|
|
# Real DNS resolution of the metadata IP literal yields the private IP itself.
|
|
|
|
result = await fetch_url_tool.fetch_url(
|
|
"http://169.254.169.254/latest/meta-data/iam/security-credentials/"
|
|
)
|
|
|
|
assert result["status_code"] == 0
|
|
assert "Request blocked" in result["error"]
|
|
assert result["url"].startswith("http://169.254.169.254/")
|
|
|
|
|
|
async def test_fetch_url_blocks_redirects_to_private_ips(monkeypatch) -> None:
|
|
def fake_getaddrinfo(host, port, *args, **kwargs): # type: ignore[no-untyped-def]
|
|
ip = "93.184.216.34" if host == "example.com" else host
|
|
return [_addr_info(ip, port)]
|
|
|
|
monkeypatch.setattr(url_safety.socket, "getaddrinfo", fake_getaddrinfo)
|
|
|
|
def responder(method: str, url: str, **kwargs: Any) -> FakeResponse:
|
|
return FakeResponse(
|
|
status_code=302,
|
|
url=url,
|
|
headers={"Location": "http://169.254.169.254/latest/meta-data"},
|
|
)
|
|
|
|
_install_client(monkeypatch, fetch_url_tool, responder)
|
|
|
|
result = await fetch_url_tool.fetch_url("https://example.com/start")
|
|
|
|
# First hop targets the validated public IP, with Host preserved.
|
|
client = FakeAsyncClient.last_instance
|
|
assert client is not None
|
|
assert len(client.calls) == 1
|
|
first = client.calls[0]
|
|
assert urlparse(first["url"]).hostname == "93.184.216.34"
|
|
assert first["headers"]["Host"] == "example.com"
|
|
assert first["extensions"]["sni_hostname"] == "example.com"
|
|
# The redirect to a private IP was blocked before a second request was issued.
|
|
assert result["status_code"] == 0
|
|
assert result["url"] == "http://169.254.169.254/latest/meta-data"
|
|
assert "Request blocked" in result["error"]
|
|
|
|
|
|
# --- http_request ------------------------------------------------------------
|
|
|
|
|
|
async def test_http_request_pins_connection_to_validated_public_ip(monkeypatch) -> None:
|
|
"""Validation sees a public IP and the connection must target that exact IP.
|
|
|
|
A resolver that later flips to a private address cannot rebind because the
|
|
request URL is pinned to the validated IP (with Host + SNI preserved).
|
|
"""
|
|
hostname = "rebind.example.com"
|
|
public_addr = "93.184.216.34"
|
|
private_addr = "127.0.0.1"
|
|
|
|
call_count = {"n": 0}
|
|
|
|
def fake_getaddrinfo(host, port, *args, **kwargs): # type: ignore[no-untyped-def]
|
|
call_count["n"] += 1
|
|
ip = public_addr if call_count["n"] == 1 else private_addr
|
|
return [_addr_info(ip, port)]
|
|
|
|
monkeypatch.setattr(url_safety.socket, "getaddrinfo", fake_getaddrinfo)
|
|
|
|
def responder(method: str, url: str, **kwargs: Any) -> FakeResponse:
|
|
return FakeResponse(status_code=200, url=url, text="ok", json_data="ok")
|
|
|
|
_install_client(monkeypatch, http_request_tool, responder)
|
|
|
|
result = await http_request_tool.http_request(f"http://{hostname}/probe")
|
|
|
|
client = FakeAsyncClient.last_instance
|
|
assert client is not None
|
|
assert len(client.calls) == 1
|
|
call = client.calls[0]
|
|
assert urlparse(call["url"]).hostname == public_addr, (
|
|
f"connection must target pinned public IP, got {call['url']}"
|
|
)
|
|
assert call["headers"]["Host"] == hostname
|
|
assert call["extensions"]["sni_hostname"] == hostname
|
|
assert result["status_code"] == 200
|
|
|
|
|
|
async def test_http_request_blocks_when_only_private_ips(monkeypatch) -> None:
|
|
"""If the first resolution returns a private IP, no request is issued."""
|
|
hostname = "evil.example.com"
|
|
private_addr = "169.254.169.254"
|
|
|
|
monkeypatch.setattr(
|
|
url_safety.socket,
|
|
"getaddrinfo",
|
|
lambda host, port, *a, **k: [_addr_info(private_addr, port)],
|
|
)
|
|
|
|
def fail_responder(*args: Any, **kwargs: Any) -> FakeResponse:
|
|
raise AssertionError("request should not be issued for blocked URLs")
|
|
|
|
_install_client(monkeypatch, http_request_tool, fail_responder)
|
|
|
|
result = await http_request_tool.http_request(f"http://{hostname}/")
|
|
|
|
assert result["status_code"] == 0
|
|
assert "Request blocked" in result["content"]
|
|
|
|
|
|
async def test_http_request_downgrades_method_on_303(monkeypatch) -> None:
|
|
"""A 303 redirect must switch the follow-up request to GET and drop the body."""
|
|
|
|
def fake_getaddrinfo(host, port, *args, **kwargs): # type: ignore[no-untyped-def]
|
|
return [_addr_info("93.184.216.34", port)]
|
|
|
|
monkeypatch.setattr(url_safety.socket, "getaddrinfo", fake_getaddrinfo)
|
|
|
|
def responder(method: str, url: str, **kwargs: Any) -> FakeResponse:
|
|
if "start" in url:
|
|
return FakeResponse(
|
|
status_code=303,
|
|
url=url,
|
|
headers={"Location": "https://example.com/done"},
|
|
)
|
|
return FakeResponse(status_code=200, url=url, json_data={"ok": True})
|
|
|
|
_install_client(monkeypatch, http_request_tool, responder)
|
|
|
|
result = await http_request_tool.http_request(
|
|
"https://example.com/start", method="POST", data={"x": 1}
|
|
)
|
|
|
|
client = FakeAsyncClient.last_instance
|
|
assert client is not None
|
|
assert len(client.calls) == 2
|
|
assert client.calls[0]["method"] == "POST"
|
|
assert client.calls[1]["method"] == "GET"
|
|
assert "json" not in client.calls[1] and "content" not in client.calls[1]
|
|
assert result["status_code"] == 200
|
|
assert result["content"] == {"ok": True}
|
|
|
|
|
|
async def test_http_request_returns_timeout_result(monkeypatch) -> None:
|
|
def fake_getaddrinfo(host, port, *args, **kwargs): # type: ignore[no-untyped-def]
|
|
return [_addr_info("93.184.216.34", port)]
|
|
|
|
monkeypatch.setattr(url_safety.socket, "getaddrinfo", fake_getaddrinfo)
|
|
|
|
def responder(method: str, url: str, **kwargs: Any) -> FakeResponse:
|
|
raise httpx.TimeoutException("timed out")
|
|
|
|
_install_client(monkeypatch, http_request_tool, responder)
|
|
|
|
result = await http_request_tool.http_request("https://example.com/", timeout=7)
|
|
|
|
assert result["success"] is False
|
|
assert result["status_code"] == 0
|
|
assert "timed out after 7 seconds" in result["content"]
|