From f5d47c818022ffb6204804773fae48175738a6d0 Mon Sep 17 00:00:00 2001 From: Ionut Mihalache Date: Fri, 24 Jul 2026 14:29:49 +0300 Subject: [PATCH 1/2] feat(tools): add internal HTTP request tool Add an HTTP_REQUEST internal tool that issues outbound HTTP requests to a caller-provided URL. Arguments (url/method/headers/params/body/timeout) come from the resource input schema and may be static, agent-input-bound, or LLM-inferred via the shared argument_properties mechanism. Requests resolving to private, loopback, link-local, or cloud-metadata addresses are rejected to guard against SSRF, including on redirect hops. Wire the tool into the internal tool factory, add the INVALID_INPUT_ARGUMENT error code, and bump the uipath dependency to >=2.13.16. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Hw5JP9xHn5atKPrASKmDVq --- pyproject.toml | 4 +- .../agent/exceptions/exceptions.py | 1 + .../tools/internal_tools/http_request_tool.py | 296 ++++++++++++++++++ .../internal_tools/internal_tool_factory.py | 5 + .../internal_tools/test_http_request_tool.py | 253 +++++++++++++++ uv.lock | 12 +- 6 files changed, 563 insertions(+), 8 deletions(-) create mode 100644 src/uipath_langchain/agent/tools/internal_tools/http_request_tool.py create mode 100644 tests/agent/tools/internal_tools/test_http_request_tool.py diff --git a/pyproject.toml b/pyproject.toml index ac43b3b36..c0752c89f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "uipath-langchain" -version = "0.14.16" +version = "0.14.17" description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath>=2.13.7, <2.14.0", + "uipath>=2.13.16, <2.14.0", "uipath-core>=0.5.29, <0.6.0", "uipath-platform>=0.2.12, <0.3.0", "uipath-runtime>=0.12.5, <0.13.0", diff --git a/src/uipath_langchain/agent/exceptions/exceptions.py b/src/uipath_langchain/agent/exceptions/exceptions.py index 65ebbaf81..5ac66e3d7 100644 --- a/src/uipath_langchain/agent/exceptions/exceptions.py +++ b/src/uipath_langchain/agent/exceptions/exceptions.py @@ -50,6 +50,7 @@ class AgentRuntimeErrorCode(str, Enum): INVALID_ATTACHMENT_ID = "INVALID_ATTACHMENT_ID" OUTPUT_VALIDATION_ERROR = "OUTPUT_VALIDATION_ERROR" INVALID_STATIC_ARGUMENT = "INVALID_STATIC_ARGUMENT" + INVALID_INPUT_ARGUMENT = "INVALID_INPUT_ARGUMENT" FILE_ERROR = "FILE_ERROR" diff --git a/src/uipath_langchain/agent/tools/internal_tools/http_request_tool.py b/src/uipath_langchain/agent/tools/internal_tools/http_request_tool.py new file mode 100644 index 000000000..47246651b --- /dev/null +++ b/src/uipath_langchain/agent/tools/internal_tools/http_request_tool.py @@ -0,0 +1,296 @@ +"""Internal HTTP request tool. + +Issues an outbound HTTP request to a caller-provided URL. The tool's arguments +(``url``/``method``/``headers``/``params``/``body``/``timeout``) come from the +resource's input schema authored in the agent definition, mirroring the +analyze-files tool; each may be pinned to a static value, bound to an agent +input, or left for the LLM to infer through the generic ``argument_properties`` +mechanism shared by all structured tools. + +Requests that resolve to private, loopback, link-local, or cloud-metadata +addresses are rejected to guard against SSRF; the check runs on every request, +including redirect hops. +""" + +import asyncio +import ipaddress +import re +import socket +from typing import Any +from urllib.parse import urlparse + +import httpx +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import StructuredTool +from uipath._utils._ssl_context import get_httpx_client_kwargs +from uipath.agent.models.agent import AgentInternalToolResourceConfig +from uipath.eval.mocks import mockable +from uipath.runtime.errors import UiPathErrorCategory + +from uipath_langchain.agent.exceptions import ( + AgentRuntimeError, + AgentRuntimeErrorCode, +) +from uipath_langchain.agent.react.jsonschema_pydantic_converter import ( + create_model, + create_output_model, +) +from uipath_langchain.agent.tools.structured_tool_with_argument_properties import ( + StructuredToolWithArgumentProperties, +) +from uipath_langchain.agent.tools.utils import sanitize_tool_name + +# HTTP methods the http-request tool supports. +HTTP_REQUEST_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] + +# Default per-request timeout (seconds) applied when neither the configured +# argument properties nor the LLM provide one. +HTTP_REQUEST_DEFAULT_TIMEOUT_SECONDS = 30.0 + +# Fixed output schema for the http-request tool. +HTTP_REQUEST_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "statusCode": { + "type": "integer", + "description": "HTTP status code of the response.", + }, + "headers": { + "type": "object", + "additionalProperties": {"type": "string"}, + "description": "Response headers.", + }, + "body": { + "type": "string", + "description": "Response body as text.", + }, + }, + "required": ["statusCode", "headers", "body"], +} + +# Maximum number of redirects to follow. Each hop is re-validated by the SSRF +# guard, so this only bounds how long a redirect chain may run. +_MAX_REDIRECTS = 5 + +# Matches a leading URI scheme (e.g. ``https://``, ``ftp://``). Used to detect +# whether the caller supplied a scheme at all. +_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://") + + +def _normalize_url(url: str) -> str: + """Default to ``https`` when the caller omits a scheme. + + ``"google.com"`` becomes ``"https://google.com"``; a scheme-relative + ``"//google.com"`` becomes ``"https://google.com"``. A URL that already + carries an explicit scheme is returned unchanged — including non-HTTP(S) + schemes, which the SSRF guard then rejects. + """ + url = url.strip() + if url.startswith("//"): + return f"https:{url}" + if not _SCHEME_RE.match(url): + return f"https://{url}" + return url + + +def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + """Whether an IP is non-public (private, loopback, link-local, ...). + + Link-local covers the cloud-metadata address ``169.254.169.254``. + """ + return ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_multicast + or ip.is_reserved + or ip.is_unspecified + ) + + +def _blocked_host_error(detail: str) -> AgentRuntimeError: + return AgentRuntimeError( + code=AgentRuntimeErrorCode.HTTP_ERROR, + title="Request was blocked", + detail=detail, + category=UiPathErrorCategory.USER, + ) + + +async def _assert_public_url(url: str) -> None: + """Reject a URL whose host is missing, non-HTTP(S), or internal. + + Resolves the host and fails if *any* resolved address is non-public, so a + hostname that maps to an internal IP cannot slip through. + """ + parsed = urlparse(url) + + if parsed.scheme not in ("http", "https"): + raise _blocked_host_error( + f"Unsupported URL scheme {parsed.scheme!r}; only http and https are allowed." + ) + + host = parsed.hostname + if not host: + raise _blocked_host_error(f"URL {url!r} has no host.") + + # A bare IP literal in the URL still needs checking. + try: + literal_ip = ipaddress.ip_address(host) + except ValueError: + literal_ip = None + if literal_ip is not None and _is_blocked_ip(literal_ip): + raise _blocked_host_error(f"Host {host!r} resolves to a non-public address.") + + try: + infos = await asyncio.get_running_loop().getaddrinfo( + host, parsed.port, type=socket.SOCK_STREAM + ) + except socket.gaierror as e: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.HTTP_ERROR, + title="Host could not be resolved", + detail=f"Could not resolve host {host!r}: {e}", + category=UiPathErrorCategory.USER, + ) from e + + for info in infos: + ip = ipaddress.ip_address(info[4][0]) + if _is_blocked_ip(ip): + raise _blocked_host_error( + f"Host {host!r} resolves to non-public address {ip}." + ) + + +async def _validate_request_hook(request: httpx.Request) -> None: + """httpx request hook that runs the SSRF guard on every hop.""" + await _assert_public_url(str(request.url)) + + +def create_http_request_tool( + resource: AgentInternalToolResourceConfig, llm: BaseChatModel +) -> StructuredTool: + """Create the http-request internal tool from resource configuration. + + ``llm`` is accepted for signature parity with the other internal tool + factories but is unused: the tool issues a plain HTTP request. + """ + tool_name = sanitize_tool_name(resource.name) + input_model = create_model(resource.input_schema) + output_model = create_output_model(HTTP_REQUEST_OUTPUT_SCHEMA, resource.name) + + @mockable( + name=resource.name, + description=resource.description, + input_schema=input_model.model_json_schema(), + output_schema=output_model.model_json_schema(), + example_calls=[], # Examples cannot be provided for internal tools + ) + async def http_request_tool_fn(**kwargs: Any) -> dict[str, Any]: + url = kwargs.get("url") + if not url: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.INVALID_INPUT_ARGUMENT, + title="Missing required argument", + detail="Argument 'url' is required.", + category=UiPathErrorCategory.USER, + ) + # Default to https when no scheme is given (e.g. "google.com"). + url = _normalize_url(url) + + method = (kwargs.get("method") or "GET").upper() + if method not in HTTP_REQUEST_METHODS: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.INVALID_INPUT_ARGUMENT, + title="Unsupported HTTP method", + detail=( + f"Unsupported HTTP method {method!r}; expected one of " + f"{', '.join(HTTP_REQUEST_METHODS)}." + ), + category=UiPathErrorCategory.USER, + ) + + headers = kwargs.get("headers") or None + params = kwargs.get("params") or None + + timeout = kwargs.get("timeout") + if timeout is None: + timeout = HTTP_REQUEST_DEFAULT_TIMEOUT_SECONDS + + # A dict/list body is sent as JSON; anything else is sent as raw content. + request_kwargs: dict[str, Any] = {} + body = kwargs.get("body") + if body is not None: + if isinstance(body, (dict, list)): + request_kwargs["json"] = body + elif isinstance(body, (str, bytes)): + request_kwargs["content"] = body + else: + request_kwargs["content"] = str(body) + + # Build from get_httpx_client_kwargs() (enforced SSL/proxy config), but + # drop the merged UiPath platform headers so internal licensing context + # is never leaked to an arbitrary third-party host. + client_kwargs = get_httpx_client_kwargs() + client_kwargs.pop("headers", None) + + try: + async with httpx.AsyncClient( + event_hooks={"request": [_validate_request_hook]}, + max_redirects=_MAX_REDIRECTS, + **client_kwargs, + ) as client: + response = await client.request( + method, + url, + headers=headers, + params=params, + timeout=timeout, + **request_kwargs, + ) + except AgentRuntimeError: + raise + except httpx.TimeoutException as e: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.HTTP_ERROR, + title="HTTP request timed out", + detail=f"Request to {url!r} timed out after {timeout}s: {e}", + category=UiPathErrorCategory.USER, + ) from e + except httpx.HTTPError as e: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.HTTP_ERROR, + title="HTTP request failed", + detail=f"Request to {url!r} failed: {e}", + category=UiPathErrorCategory.USER, + ) from e + + # Non-2xx responses are returned to the agent rather than raised, so it + # can react to the status code. + return { + "statusCode": response.status_code, + "headers": {k: v for k, v in response.headers.items()}, + "body": response.text, + } + + # Import here to avoid circular dependency + from uipath_langchain.agent.wrappers import get_job_attachment_wrapper + + job_attachment_wrapper = get_job_attachment_wrapper(output_type=output_model) + + tool = StructuredToolWithArgumentProperties( + name=tool_name, + description=resource.description, + args_schema=input_model, + coroutine=http_request_tool_fn, + output_type=output_model, + argument_properties=resource.argument_properties, + metadata={ + "tool_type": resource.type.lower(), + "display_name": tool_name, + "args_schema": input_model, + "output_schema": output_model, + }, + ) + tool.set_tool_wrappers(awrapper=job_attachment_wrapper) + return tool diff --git a/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py b/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py index 5a8410675..19891077c 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py +++ b/src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py @@ -6,6 +6,9 @@ Supported Internal Tools: - ANALYZE_FILES: Tool for analyzing file contents and extracting information + - DEEP_RAG: Tool for deep retrieval-augmented generation over documents + - BATCH_TRANSFORM: Tool for batch transformation of document data + - HTTP_REQUEST: Tool for issuing outbound HTTP requests to a given URL Example: >>> from uipath.agent.models.agent import AgentInternalToolResourceConfig @@ -29,6 +32,7 @@ from .analyze_files_tool import create_analyze_file_tool from .batch_transform_tool import create_batch_transform_tool from .deeprag_tool import create_deeprag_tool +from .http_request_tool import create_http_request_tool _INTERNAL_TOOL_HANDLERS: dict[ AgentInternalToolType, @@ -37,6 +41,7 @@ AgentInternalToolType.ANALYZE_FILES: create_analyze_file_tool, AgentInternalToolType.DEEP_RAG: create_deeprag_tool, AgentInternalToolType.BATCH_TRANSFORM: create_batch_transform_tool, + AgentInternalToolType.HTTP_REQUEST: create_http_request_tool, } diff --git a/tests/agent/tools/internal_tools/test_http_request_tool.py b/tests/agent/tools/internal_tools/test_http_request_tool.py new file mode 100644 index 000000000..0745f1933 --- /dev/null +++ b/tests/agent/tools/internal_tools/test_http_request_tool.py @@ -0,0 +1,253 @@ +"""Tests for http_request_tool.py module.""" + +import json +from unittest.mock import AsyncMock, patch + +import pytest +from uipath.agent.models.agent import ( + AgentInternalHttpRequestToolProperties, + AgentInternalToolResourceConfig, +) + +from uipath_langchain.agent.exceptions import AgentRuntimeError +from uipath_langchain.agent.tools.internal_tools import http_request_tool as H +from uipath_langchain.agent.tools.internal_tools.http_request_tool import ( + create_http_request_tool, +) + +# Patch mockable to a passthrough for every test in this module, matching the +# convention in the sibling internal-tool tests. +pytestmark = pytest.mark.usefixtures("_passthrough_mockable") + + +@pytest.fixture +def _passthrough_mockable(): + with patch( + "uipath_langchain.agent.tools.internal_tools.http_request_tool.mockable", + lambda **kwargs: lambda f: f, + ): + yield + + +@pytest.fixture +def mock_llm(): + return AsyncMock() + + +@pytest.fixture +def resource_config(): + # The input schema is authored in the agent definition (like analyze-files); + # the tool reads these fields from kwargs rather than injecting them. + input_schema = { + "type": "object", + "properties": { + "url": {"type": "string"}, + "method": {"type": "string"}, + "headers": { + "type": "object", + "additionalProperties": {"type": "string"}, + }, + "params": { + "type": "object", + "additionalProperties": {"type": "string"}, + }, + "body": { + "anyOf": [ + {"type": "string"}, + {"type": "object", "additionalProperties": True}, + ] + }, + "timeout": {"type": "number"}, + }, + "required": ["url"], + } + return AgentInternalToolResourceConfig( + name="http_request", + description="Make an HTTP request", + input_schema=input_schema, + output_schema={}, + properties=AgentInternalHttpRequestToolProperties(), + ) + + +class TestCreateHttpRequestTool: + def test_tool_creation_and_schema(self, resource_config, mock_llm): + """Tool is created with the canonical input fields and fixed output fields.""" + tool = create_http_request_tool(resource_config, mock_llm) + + assert tool.name == "http_request" + assert tool.description == "Make an HTTP request" + + input_fields = set(tool.args_schema.model_fields.keys()) + assert {"url", "method", "headers", "params", "body", "timeout"} <= input_fields + + output_fields = set(tool.output_type.model_fields.keys()) + assert output_fields == {"statusCode", "headers", "body"} + + @patch( + "uipath_langchain.agent.tools.internal_tools.http_request_tool._assert_public_url", + new_callable=AsyncMock, + ) + async def test_get_request_happy_path( + self, _mock_guard, httpx_mock, resource_config, mock_llm + ): + """A GET request returns statusCode/headers/body; method is normalized.""" + httpx_mock.add_response( + url="https://api.example.com/items?page=1", + method="GET", + status_code=200, + headers={"Content-Type": "application/json"}, + text='{"ok": true}', + ) + + tool = create_http_request_tool(resource_config, mock_llm) + result = await tool.coroutine( + url="https://api.example.com/items", + method="get", # lowercase -> normalized to GET + params={"page": "1"}, + ) + + assert result["statusCode"] == 200 + assert result["headers"]["content-type"] == "application/json" + assert result["body"] == '{"ok": true}' + + @patch( + "uipath_langchain.agent.tools.internal_tools.http_request_tool._assert_public_url", + new_callable=AsyncMock, + ) + async def test_dict_body_is_sent_as_json( + self, _mock_guard, httpx_mock, resource_config, mock_llm + ): + """An object body is serialized as JSON.""" + httpx_mock.add_response( + url="https://api.example.com/create", + method="POST", + status_code=201, + text="created", + ) + + tool = create_http_request_tool(resource_config, mock_llm) + result = await tool.coroutine( + url="https://api.example.com/create", + method="POST", + headers={"X-Trace": "abc"}, + body={"name": "widget", "qty": 3}, + ) + + assert result["statusCode"] == 201 + request = httpx_mock.get_requests()[0] + assert request.headers["x-trace"] == "abc" + assert request.headers["content-type"] == "application/json" + assert json.loads(request.read()) == {"name": "widget", "qty": 3} + + @patch( + "uipath_langchain.agent.tools.internal_tools.http_request_tool._assert_public_url", + new_callable=AsyncMock, + ) + async def test_string_body_is_sent_raw( + self, _mock_guard, httpx_mock, resource_config, mock_llm + ): + """A string body is sent as-is.""" + httpx_mock.add_response(method="PUT", status_code=200, text="ok") + + tool = create_http_request_tool(resource_config, mock_llm) + await tool.coroutine( + url="https://api.example.com/raw", + method="PUT", + body="raw-payload", + ) + + assert httpx_mock.get_requests()[0].read() == b"raw-payload" + + @patch( + "uipath_langchain.agent.tools.internal_tools.http_request_tool._assert_public_url", + new_callable=AsyncMock, + ) + async def test_non_2xx_is_returned_not_raised( + self, _mock_guard, httpx_mock, resource_config, mock_llm + ): + """A 4xx/5xx response is returned to the agent rather than raised.""" + httpx_mock.add_response(status_code=404, text="not found") + + tool = create_http_request_tool(resource_config, mock_llm) + result = await tool.coroutine(url="https://api.example.com/missing") + + assert result["statusCode"] == 404 + assert result["body"] == "not found" + + @patch( + "uipath_langchain.agent.tools.internal_tools.http_request_tool._assert_public_url", + new_callable=AsyncMock, + ) + async def test_schemeless_url_defaults_to_https( + self, _mock_guard, httpx_mock, resource_config, mock_llm + ): + """A URL without a scheme (e.g. 'google.com') is requested over https.""" + httpx_mock.add_response( + url="https://google.com", method="GET", status_code=200, text="ok" + ) + + tool = create_http_request_tool(resource_config, mock_llm) + result = await tool.coroutine(url="google.com") + + assert result["statusCode"] == 200 + assert str(httpx_mock.get_requests()[0].url) == "https://google.com" + + async def test_missing_url_raises(self, resource_config, mock_llm): + tool = create_http_request_tool(resource_config, mock_llm) + with pytest.raises(AgentRuntimeError, match="Argument 'url' is required"): + await tool.coroutine() + + async def test_invalid_method_raises(self, resource_config, mock_llm): + tool = create_http_request_tool(resource_config, mock_llm) + with pytest.raises(AgentRuntimeError, match="Unsupported HTTP method"): + await tool.coroutine(url="https://api.example.com/x", method="FETCH") + + async def test_ssrf_blocks_internal_host(self, resource_config, mock_llm): + """A request to an internal address is blocked before it is issued.""" + tool = create_http_request_tool(resource_config, mock_llm) + with pytest.raises(AgentRuntimeError): + await tool.coroutine(url="http://127.0.0.1/admin") + + +class TestSsrfGuard: + """Direct tests of the SSRF guard (also runs on every redirect hop).""" + + @pytest.mark.parametrize( + "url", + [ + "http://127.0.0.1/x", + "http://localhost:8080/", + "http://10.0.0.5/", + "http://169.254.169.254/latest/meta-data/", + "http://metadata.google.internal/", + "ftp://example.com/file", + ], + ) + async def test_blocked_urls(self, url): + with pytest.raises(AgentRuntimeError): + await H._assert_public_url(url) + + async def test_public_url_allowed(self): + # Resolves a real public host; should not raise. + await H._assert_public_url("https://example.com/") + + +class TestNormalizeUrl: + """Scheme defaulting: bare hosts become https, explicit schemes untouched.""" + + @pytest.mark.parametrize( + "raw,expected", + [ + ("google.com", "https://google.com"), + ("google.com/search?q=1", "https://google.com/search?q=1"), + (" google.com ", "https://google.com"), + ("//google.com", "https://google.com"), + ("http://google.com", "http://google.com"), + ("https://google.com", "https://google.com"), + # An explicit non-HTTP scheme is preserved (SSRF guard rejects it later). + ("ftp://host/file", "ftp://host/file"), + ], + ) + def test_normalize_url(self, raw, expected): + assert H._normalize_url(raw) == expected diff --git a/uv.lock b/uv.lock index a90039156..8f9c5a1c5 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-07-20T22:41:41.618612Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -4453,7 +4453,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.7" +version = "2.13.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -4477,9 +4477,9 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/76/f57747721f7e6386bf277f34e4ccc5d34f1e58a8c31080a4ed449df8b614/uipath-2.13.7.tar.gz", hash = "sha256:379af967a9a302b3521938030ae41bf53c21a34a68aa6354416caf2b76cecb2f", size = 4500489, upload-time = "2026-07-08T15:57:51.3Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/99/17036d803ea11745af4c2cc1d6c8c6ec1b2d3d454eec842f220930a48f4e/uipath-2.13.16.tar.gz", hash = "sha256:d8977972ab29c7eac22fcfa044186701ba2961d746fd38b06f43c665d8f8cf5f", size = 4527477, upload-time = "2026-07-24T08:09:45.569Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/1b/9c4f095cb4e87eaded00ea9eb022a4ea51f663b961124e6dccb27bd7fc82/uipath-2.13.7-py3-none-any.whl", hash = "sha256:145881ae320b9d936252766f1cb575ed40601181b1f313b5816fc43653d92ad5", size = 418876, upload-time = "2026-07-08T15:57:49.254Z" }, + { url = "https://files.pythonhosted.org/packages/4e/08/aec381f035d00d2c0e1bde410a1d79c9c61d697004d1a849d2be373e3486/uipath-2.13.16-py3-none-any.whl", hash = "sha256:99909c8dcbbb0c2b08710f2d55c55b6b54a4473be6df3c4daab8442e12a4b860", size = 431522, upload-time = "2026-07-24T08:09:43.486Z" }, ] [[package]] @@ -4498,7 +4498,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.14.16" +version = "0.14.17" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -4578,7 +4578,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.6.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "rdflib", specifier = ">=7.0.0,<8.0.0" }, - { name = "uipath", specifier = ">=2.13.7,<2.14.0" }, + { name = "uipath", specifier = ">=2.13.16,<2.14.0" }, { name = "uipath-core", specifier = ">=0.5.29,<0.6.0" }, { name = "uipath-langchain-client", extras = ["all"], marker = "extra == 'all'", specifier = ">=1.17.1,<1.18.0" }, { name = "uipath-langchain-client", extras = ["anthropic"], marker = "extra == 'anthropic'", specifier = ">=1.17.1,<1.18.0" }, From 70bf9c0edb1da714606663bfb7528cc3fc180cde Mon Sep 17 00:00:00 2001 From: Ionut Mihalache Date: Fri, 24 Jul 2026 14:37:41 +0300 Subject: [PATCH 2/2] feat(http-tool): modify parameters and headers input format --- .../tools/internal_tools/http_request_tool.py | 100 +++++- .../internal_tools/test_http_request_tool.py | 289 ++++++++++-------- 2 files changed, 253 insertions(+), 136 deletions(-) diff --git a/src/uipath_langchain/agent/tools/internal_tools/http_request_tool.py b/src/uipath_langchain/agent/tools/internal_tools/http_request_tool.py index 47246651b..2035bff47 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/http_request_tool.py +++ b/src/uipath_langchain/agent/tools/internal_tools/http_request_tool.py @@ -14,6 +14,7 @@ import asyncio import ipaddress +import json import re import socket from typing import Any @@ -22,6 +23,7 @@ import httpx from langchain_core.language_models import BaseChatModel from langchain_core.tools import StructuredTool +from pydantic import BaseModel from uipath._utils._ssl_context import get_httpx_client_kwargs from uipath.agent.models.agent import AgentInternalToolResourceConfig from uipath.eval.mocks import mockable @@ -93,6 +95,71 @@ def _normalize_url(url: str) -> str: return url +def _has_header(headers: dict[str, str] | None, name: str) -> bool: + """Case-insensitive check for whether a header is already present.""" + if not headers: + return False + lowered = name.lower() + return any(key.lower() == lowered for key in headers) + + +def _is_json_object_body(text: str) -> bool: + """Whether a string body is a JSON object or array (not a bare scalar). + + Restricting to object/array avoids mislabeling plain-text bodies that happen + to be valid JSON scalars (e.g. ``"123"`` or ``"true"``) as JSON. + """ + try: + parsed = json.loads(text) + except ValueError: + return False + return isinstance(parsed, (dict, list)) + + +def _pairs_to_dict(pairs: list[Any]) -> dict[str, Any]: + """Fold a list of ``{name, value}`` items into a dict. + + Headers and query params are modeled as arrays of name/value pairs rather + than an open map, so the generated tool schema stays compatible with + providers that reject ``additionalProperties`` (notably Gemini, which would + otherwise see a property-less object). Each item may be a dict or a pydantic + model (the validated form); later duplicate names win. + """ + result: dict[str, Any] = {} + for item in pairs: + if isinstance(item, BaseModel): + item = item.model_dump() + if isinstance(item, dict) and item.get("name") is not None: + result[str(item["name"])] = item.get("value") + return result + + +def _stringify_mapping(mapping: Any) -> dict[str, str] | None: + """Coerce a header/param argument to a ``dict[str, str]`` for httpx. + + Accepts the validated forms the argument can take — a list of + ``{name, value}`` pairs (the canonical, provider-portable shape), a pydantic + model, or a plain dict — and returns string-valued entries (httpx headers + must be strings; query params are cleanest as strings). Booleans render as + lowercase ``true``/``false``. Returns ``None`` for an empty/missing value. + """ + if isinstance(mapping, BaseModel): + mapping = mapping.model_dump() + if isinstance(mapping, list): + mapping = _pairs_to_dict(mapping) + if not mapping or not isinstance(mapping, dict): + return None + result: dict[str, str] = {} + for key, value in mapping.items(): + if isinstance(value, bool): + result[str(key)] = "true" if value else "false" + elif isinstance(value, str): + result[str(key)] = value + else: + result[str(key)] = str(value) + return result or None + + def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: """Whether an IP is non-public (private, loopback, link-local, ...). @@ -210,21 +277,48 @@ async def http_request_tool_fn(**kwargs: Any) -> dict[str, Any]: category=UiPathErrorCategory.USER, ) - headers = kwargs.get("headers") or None - params = kwargs.get("params") or None + headers = _stringify_mapping(kwargs.get("headers")) + params = _stringify_mapping(kwargs.get("params")) timeout = kwargs.get("timeout") if timeout is None: timeout = HTTP_REQUEST_DEFAULT_TIMEOUT_SECONDS + elif ( + isinstance(timeout, bool) + or not isinstance(timeout, (int, float)) + or timeout <= 0 + ): + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.INVALID_INPUT_ARGUMENT, + title="Invalid timeout", + detail=( + "Argument 'timeout' must be a positive number of seconds; " + f"got {timeout!r}." + ), + category=UiPathErrorCategory.USER, + ) # A dict/list body is sent as JSON; anything else is sent as raw content. + # A validated object body arrives as a pydantic model, not a dict. request_kwargs: dict[str, Any] = {} body = kwargs.get("body") + if isinstance(body, BaseModel): + body = body.model_dump() if body is not None: if isinstance(body, (dict, list)): request_kwargs["json"] = body elif isinstance(body, (str, bytes)): request_kwargs["content"] = body + # A JSON object/array string is very likely meant as JSON, but + # httpx does not set Content-Type for raw content. Default it + # when the caller did not set one, so callers aren't forced to + # remember the header. + if ( + isinstance(body, str) + and _is_json_object_body(body) + and not _has_header(headers, "content-type") + ): + headers = {**(headers or {}), "Content-Type": "application/json"} else: request_kwargs["content"] = str(body) @@ -269,7 +363,7 @@ async def http_request_tool_fn(**kwargs: Any) -> dict[str, Any]: # can react to the status code. return { "statusCode": response.status_code, - "headers": {k: v for k, v in response.headers.items()}, + "headers": dict(response.headers), "body": response.text, } diff --git a/tests/agent/tools/internal_tools/test_http_request_tool.py b/tests/agent/tools/internal_tools/test_http_request_tool.py index 0745f1933..f3e8ecd1a 100644 --- a/tests/agent/tools/internal_tools/test_http_request_tool.py +++ b/tests/agent/tools/internal_tools/test_http_request_tool.py @@ -1,19 +1,32 @@ -"""Tests for http_request_tool.py module.""" +"""Tests for http_request_tool.py module. + +These tests exercise the tool only through its public surface (the created +tool's ``coroutine``/``ainvoke``), never its module-private helpers. Requests +target a literal public IP (``8.8.8.8``) so the real SSRF guard runs and passes +without any DNS lookup — no need to mock internal functions. +""" -import json from unittest.mock import AsyncMock, patch import pytest +from pydantic import BaseModel from uipath.agent.models.agent import ( AgentInternalHttpRequestToolProperties, AgentInternalToolResourceConfig, ) from uipath_langchain.agent.exceptions import AgentRuntimeError -from uipath_langchain.agent.tools.internal_tools import http_request_tool as H from uipath_langchain.agent.tools.internal_tools.http_request_tool import ( create_http_request_tool, ) +from uipath_langchain.agent.tools.structured_tool_with_argument_properties import ( + StructuredToolWithArgumentProperties, +) + +# A literal public IP: the SSRF guard treats it as public, and getaddrinfo on a +# numeric host resolves locally, so tests need no network and no patching. +PUBLIC_HOST = "8.8.8.8" +BASE_URL = f"https://{PUBLIC_HOST}" # Patch mockable to a passthrough for every test in this module, matching the # convention in the sibling internal-tool tests. @@ -38,25 +51,28 @@ def mock_llm(): def resource_config(): # The input schema is authored in the agent definition (like analyze-files); # the tool reads these fields from kwargs rather than injecting them. + # Headers/params are arrays of name/value pairs (not open maps) so the + # generated tool schema stays compatible with providers that reject + # additionalProperties (e.g. Gemini); body is a plain string. + pair_list = { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "value": {"type": "string"}, + }, + "required": ["name", "value"], + }, + } input_schema = { "type": "object", "properties": { "url": {"type": "string"}, "method": {"type": "string"}, - "headers": { - "type": "object", - "additionalProperties": {"type": "string"}, - }, - "params": { - "type": "object", - "additionalProperties": {"type": "string"}, - }, - "body": { - "anyOf": [ - {"type": "string"}, - {"type": "object", "additionalProperties": True}, - ] - }, + "headers": pair_list, + "params": pair_list, + "body": {"type": "string"}, "timeout": {"type": "number"}, }, "required": ["url"], @@ -70,184 +86,191 @@ def resource_config(): ) +@pytest.fixture +def tool(resource_config, mock_llm): + return create_http_request_tool(resource_config, mock_llm) + + class TestCreateHttpRequestTool: - def test_tool_creation_and_schema(self, resource_config, mock_llm): + def test_tool_creation_and_schema(self, tool): """Tool is created with the canonical input fields and fixed output fields.""" - tool = create_http_request_tool(resource_config, mock_llm) - + assert isinstance(tool, StructuredToolWithArgumentProperties) assert tool.name == "http_request" assert tool.description == "Make an HTTP request" - input_fields = set(tool.args_schema.model_fields.keys()) + args_schema = tool.args_schema + assert isinstance(args_schema, type) and issubclass(args_schema, BaseModel) + input_fields = set(args_schema.model_fields.keys()) assert {"url", "method", "headers", "params", "body", "timeout"} <= input_fields output_fields = set(tool.output_type.model_fields.keys()) assert output_fields == {"statusCode", "headers", "body"} - @patch( - "uipath_langchain.agent.tools.internal_tools.http_request_tool._assert_public_url", - new_callable=AsyncMock, - ) - async def test_get_request_happy_path( - self, _mock_guard, httpx_mock, resource_config, mock_llm - ): + async def test_get_request_happy_path(self, tool, httpx_mock): """A GET request returns statusCode/headers/body; method is normalized.""" httpx_mock.add_response( - url="https://api.example.com/items?page=1", + url=f"{BASE_URL}/items?page=1", method="GET", status_code=200, headers={"Content-Type": "application/json"}, text='{"ok": true}', ) - tool = create_http_request_tool(resource_config, mock_llm) - result = await tool.coroutine( - url="https://api.example.com/items", - method="get", # lowercase -> normalized to GET - params={"page": "1"}, + result = await tool.ainvoke( + { + "url": f"{BASE_URL}/items", + "method": "get", # lowercase -> normalized to GET + "params": [{"name": "page", "value": "1"}], + } ) assert result["statusCode"] == 200 assert result["headers"]["content-type"] == "application/json" assert result["body"] == '{"ok": true}' - @patch( - "uipath_langchain.agent.tools.internal_tools.http_request_tool._assert_public_url", - new_callable=AsyncMock, - ) - async def test_dict_body_is_sent_as_json( - self, _mock_guard, httpx_mock, resource_config, mock_llm - ): - """An object body is serialized as JSON.""" + async def test_json_string_body_gets_default_content_type(self, tool, httpx_mock): + """A JSON object/array string body is sent verbatim, with an auto Content-Type.""" httpx_mock.add_response( - url="https://api.example.com/create", - method="POST", - status_code=201, - text="created", + url=f"{BASE_URL}/create", method="POST", status_code=201, text="created" ) - tool = create_http_request_tool(resource_config, mock_llm) - result = await tool.coroutine( - url="https://api.example.com/create", - method="POST", - headers={"X-Trace": "abc"}, - body={"name": "widget", "qty": 3}, + result = await tool.ainvoke( + { + "url": f"{BASE_URL}/create", + "method": "POST", + "body": '{"name": "widget", "qty": 3}', # no Content-Type header set + } ) assert result["statusCode"] == 201 request = httpx_mock.get_requests()[0] - assert request.headers["x-trace"] == "abc" assert request.headers["content-type"] == "application/json" - assert json.loads(request.read()) == {"name": "widget", "qty": 3} + assert request.read() == b'{"name": "widget", "qty": 3}' # not re-serialized + + async def test_explicit_content_type_is_not_overridden(self, tool, httpx_mock): + """A caller-set Content-Type wins over the JSON default.""" + httpx_mock.add_response(method="POST", status_code=200, text="ok") + + await tool.ainvoke( + { + "url": f"{BASE_URL}/create", + "method": "POST", + "headers": [{"name": "content-type", "value": "text/plain"}], + "body": '{"a": 1}', + } + ) - @patch( - "uipath_langchain.agent.tools.internal_tools.http_request_tool._assert_public_url", - new_callable=AsyncMock, - ) - async def test_string_body_is_sent_raw( - self, _mock_guard, httpx_mock, resource_config, mock_llm + assert httpx_mock.get_requests()[0].headers["content-type"] == "text/plain" + + async def test_non_json_string_body_has_no_default_content_type( + self, tool, httpx_mock ): - """A string body is sent as-is.""" + """A non-JSON string body is sent as-is with no Content-Type inferred.""" httpx_mock.add_response(method="PUT", status_code=200, text="ok") - tool = create_http_request_tool(resource_config, mock_llm) - await tool.coroutine( - url="https://api.example.com/raw", - method="PUT", - body="raw-payload", + await tool.ainvoke( + {"url": f"{BASE_URL}/raw", "method": "PUT", "body": "raw-payload"} ) - assert httpx_mock.get_requests()[0].read() == b"raw-payload" + request = httpx_mock.get_requests()[0] + assert request.read() == b"raw-payload" + assert "content-type" not in request.headers + + async def test_scalar_json_body_is_not_labeled_json(self, tool, httpx_mock): + """A bare JSON scalar string (e.g. '123') is not treated as a JSON body.""" + httpx_mock.add_response(method="POST", status_code=200, text="ok") - @patch( - "uipath_langchain.agent.tools.internal_tools.http_request_tool._assert_public_url", - new_callable=AsyncMock, - ) - async def test_non_2xx_is_returned_not_raised( - self, _mock_guard, httpx_mock, resource_config, mock_llm + await tool.ainvoke({"url": f"{BASE_URL}/x", "method": "POST", "body": "123"}) + + assert "content-type" not in httpx_mock.get_requests()[0].headers + + async def test_header_and_param_pairs_are_folded_into_request( + self, tool, httpx_mock ): + """Name/value pair lists are folded into httpx headers and query params. + + Pairs go through args-schema validation (arriving as pydantic models, as + in the agent graph), exercising the list-of-pairs -> dict conversion. + """ + httpx_mock.add_response(method="GET", status_code=200, text="ok") + + result = await tool.ainvoke( + { + "url": f"{BASE_URL}/x", + "params": [ + {"name": "a", "value": "3"}, + {"name": "b", "value": "5"}, + ], + "headers": [{"name": "X-Count", "value": "7"}], + } + ) + + assert result["statusCode"] == 200 + request = httpx_mock.get_requests()[0] + assert dict(request.url.params) == {"a": "3", "b": "5"} + assert request.headers["x-count"] == "7" + + async def test_non_2xx_is_returned_not_raised(self, tool, httpx_mock): """A 4xx/5xx response is returned to the agent rather than raised.""" httpx_mock.add_response(status_code=404, text="not found") - tool = create_http_request_tool(resource_config, mock_llm) - result = await tool.coroutine(url="https://api.example.com/missing") + result = await tool.ainvoke({"url": f"{BASE_URL}/missing"}) assert result["statusCode"] == 404 assert result["body"] == "not found" - @patch( - "uipath_langchain.agent.tools.internal_tools.http_request_tool._assert_public_url", - new_callable=AsyncMock, - ) - async def test_schemeless_url_defaults_to_https( - self, _mock_guard, httpx_mock, resource_config, mock_llm - ): - """A URL without a scheme (e.g. 'google.com') is requested over https.""" - httpx_mock.add_response( - url="https://google.com", method="GET", status_code=200, text="ok" - ) + async def test_schemeless_url_defaults_to_https(self, tool, httpx_mock): + """A URL without a scheme is requested over https.""" + httpx_mock.add_response(url=BASE_URL, method="GET", status_code=200, text="ok") - tool = create_http_request_tool(resource_config, mock_llm) - result = await tool.coroutine(url="google.com") + result = await tool.ainvoke({"url": PUBLIC_HOST}) # no scheme assert result["statusCode"] == 200 - assert str(httpx_mock.get_requests()[0].url) == "https://google.com" - - async def test_missing_url_raises(self, resource_config, mock_llm): - tool = create_http_request_tool(resource_config, mock_llm) + assert str(httpx_mock.get_requests()[0].url) == BASE_URL + + async def test_missing_url_raises(self, mock_llm): + """The tool's own guard rejects a missing url. + + Uses a schema that does not mark ``url`` required, so args validation + passes and the tool's defensive check is what raises (a schema that + requires ``url`` would be rejected earlier, by validation). + """ + resource = AgentInternalToolResourceConfig( + name="http_request", + description="Make an HTTP request", + input_schema={"type": "object", "properties": {"url": {"type": "string"}}}, + output_schema={}, + properties=AgentInternalHttpRequestToolProperties(), + ) + tool = create_http_request_tool(resource, mock_llm) with pytest.raises(AgentRuntimeError, match="Argument 'url' is required"): - await tool.coroutine() + await tool.ainvoke({}) - async def test_invalid_method_raises(self, resource_config, mock_llm): - tool = create_http_request_tool(resource_config, mock_llm) + async def test_invalid_method_raises(self, tool): with pytest.raises(AgentRuntimeError, match="Unsupported HTTP method"): - await tool.coroutine(url="https://api.example.com/x", method="FETCH") + await tool.ainvoke({"url": f"{BASE_URL}/x", "method": "FETCH"}) - async def test_ssrf_blocks_internal_host(self, resource_config, mock_llm): - """A request to an internal address is blocked before it is issued.""" - tool = create_http_request_tool(resource_config, mock_llm) - with pytest.raises(AgentRuntimeError): - await tool.coroutine(url="http://127.0.0.1/admin") - - -class TestSsrfGuard: - """Direct tests of the SSRF guard (also runs on every redirect hop).""" + @pytest.mark.parametrize("bad_timeout", [-1, 0, -0.5]) + async def test_non_positive_timeout_raises(self, tool, bad_timeout): + with pytest.raises(AgentRuntimeError, match="must be a positive number"): + await tool.ainvoke({"url": f"{BASE_URL}/x", "timeout": bad_timeout}) @pytest.mark.parametrize( "url", [ - "http://127.0.0.1/x", + "http://127.0.0.1/admin", "http://localhost:8080/", "http://10.0.0.5/", "http://169.254.169.254/latest/meta-data/", "http://metadata.google.internal/", - "ftp://example.com/file", + f"ftp://{PUBLIC_HOST}/file", ], ) - async def test_blocked_urls(self, url): - with pytest.raises(AgentRuntimeError): - await H._assert_public_url(url) - - async def test_public_url_allowed(self): - # Resolves a real public host; should not raise. - await H._assert_public_url("https://example.com/") - + async def test_ssrf_blocked_targets_are_rejected(self, tool, url): + """Internal/metadata/non-http targets are rejected before any request. -class TestNormalizeUrl: - """Scheme defaulting: bare hosts become https, explicit schemes untouched.""" - - @pytest.mark.parametrize( - "raw,expected", - [ - ("google.com", "https://google.com"), - ("google.com/search?q=1", "https://google.com/search?q=1"), - (" google.com ", "https://google.com"), - ("//google.com", "https://google.com"), - ("http://google.com", "http://google.com"), - ("https://google.com", "https://google.com"), - # An explicit non-HTTP scheme is preserved (SSRF guard rejects it later). - ("ftp://host/file", "ftp://host/file"), - ], - ) - def test_normalize_url(self, raw, expected): - assert H._normalize_url(raw) == expected + No response is registered because the request never leaves the SSRF + guard. + """ + with pytest.raises(AgentRuntimeError): + await tool.ainvoke({"url": url})