diff --git a/src/uipath_langchain/agent/advanced/agent.py b/src/uipath_langchain/agent/advanced/agent.py index 4baaf1f0b..d823e8b3d 100644 --- a/src/uipath_langchain/agent/advanced/agent.py +++ b/src/uipath_langchain/agent/advanced/agent.py @@ -35,12 +35,16 @@ def create_advanced_agent( backend: BackendProtocol | BackendFactory | None = None, response_format: ResponseFormat[Any] | None = None, memory: Sequence[str] = (), + skills: Sequence[str] | None = None, ) -> CompiledStateGraph[Any, Any, Any, Any]: """Create a deepagents agent with planning, filesystem, and sub-agent tools. ``memory`` is a list of file paths loaded via deepagents' ``MemoryMiddleware``: each is read from ``backend`` and injected into the system prompt every turn, and the model maintains them with ``edit_file``. Empty disables the middleware. + + ``skills`` is a list of skill source paths for deepagents' ``SkillsMiddleware``; + ``None`` or empty disables it (mirroring ``_create_deep_agent``'s contract). """ return _create_deep_agent( model=model, @@ -50,6 +54,7 @@ def create_advanced_agent( backend=backend, response_format=response_format, memory=list(memory) or None, + skills=list(skills) if skills else None, ) @@ -62,6 +67,7 @@ def create_advanced_agent_graph( input_schema: type[BaseModel] | None, output_schema: type[BaseModel], build_user_message: Callable[[dict[str, Any]], str], + skills: Sequence[str] | None = None, ) -> StateGraph[Any, Any, Any, Any]: """Wrap the advanced agent in a parent graph that maps typed I/O to/from messages. @@ -82,6 +88,7 @@ def create_advanced_agent_graph( backend=backend, response_format=response_format, memory=memory_sources, + skills=skills, ) wrapper_state = create_state_with_input(input_schema) @@ -128,6 +135,7 @@ def create_conversational_advanced_agent_graph( tools: Sequence[BaseTool], system_prompt: str, backend: BackendProtocol | BackendFactory | None, + skills: Sequence[str] | None = None, ) -> StateGraph[Any, Any, Any, Any]: """Wrap the advanced agent in a parent graph that speaks the conversational contract. @@ -150,6 +158,7 @@ def create_conversational_advanced_agent_graph( system_prompt=system_prompt, backend=backend, memory=memory_sources, + skills=skills, ) class ConversationalAdvancedAgentOutput(BaseModel): diff --git a/src/uipath_langchain/agent/tools/internal_tools/__init__.py b/src/uipath_langchain/agent/tools/internal_tools/__init__.py index 5298b109f..a8f2dcfda 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/__init__.py +++ b/src/uipath_langchain/agent/tools/internal_tools/__init__.py @@ -1,5 +1,6 @@ """Internal Tool creation and management for LowCode agents.""" from .internal_tool_factory import create_internal_tool +from .uipath_cli_tool import create_uipath_cli_tool -__all__ = ["create_internal_tool"] +__all__ = ["create_internal_tool", "create_uipath_cli_tool"] diff --git a/src/uipath_langchain/agent/tools/internal_tools/uipath_cli_tool.py b/src/uipath_langchain/agent/tools/internal_tools/uipath_cli_tool.py new file mode 100644 index 000000000..b51bbe8af --- /dev/null +++ b/src/uipath_langchain/agent/tools/internal_tools/uipath_cli_tool.py @@ -0,0 +1,273 @@ +"""Sandboxed ``uip`` CLI runner injected into advanced agents. + +Unlike the resource-selected internal tools built by ``create_internal_tool``, this +tool is injected programmatically by the agent graph builder when UiPath skills are +active — the agent invokes it to run the ``uip`` commands a skill prescribes. It runs +exactly one ``uip`` invocation per call. + +""" + +import asyncio +import logging +import os +import shlex +import shutil +from pathlib import Path +from typing import Any + +from langchain_core.tools import StructuredTool +from pydantic import BaseModel, Field, field_validator +from uipath.eval.mocks import mockable +from uipath.runtime import Workspace + +from uipath_langchain.agent.tools.structured_tool_with_argument_properties import ( + StructuredToolWithArgumentProperties, +) + +logger = logging.getLogger("uipath") + +_TOOL_NAME = "uipath_cli" + +_TOOL_DESCRIPTION = ( + "Run a single UiPath `uip` command in the agent workspace; returns " + "exit_code/stdout/stderr. Only the uip/uipath binaries, one command per call; " + "destructive subcommands and shell chaining are refused. A negative exit_code " + "means the command was refused before it ran and stderr explains why. Use " + "`subdir` to target a scaffolded project folder." +) + +_ALLOWED_BINARIES: tuple[str, ...] = ("uip", "uipath") + +# Distinct from any real process exit status: normal exits are 0-255 and +# signal-terminated processes surface as -1..-64, so a value well below -255 +# unambiguously marks a command rejected before it ever ran. +_REJECTED_EXIT_CODE = -1000 + +_COMMAND_TIMEOUT_SECONDS = 600 + +_SHELL_OPERATORS: frozenset[str] = frozenset({"&&", "||", ";", "|"}) + +# Adding this list for the first iteration of the tool, but we may not need it in the future. +_DESTRUCTIVE_SUBCOMMANDS: frozenset[str] = frozenset( + {"delete", "remove", "destroy", "uninstall", "purge"} +) + + +def _parse_uip_command(command: str) -> list[str]: + """Validate a single ``uip`` command and return its argv (binary excluded). + + Args: + command: The command to run, e.g. ``"pack"`` or ``"solution publish"``. + An explicit ``uip``/``uipath`` prefix is tolerated and stripped. + + Returns: + The argument tokens to pass after the resolved binary. + + Raises: + ValueError: If the command is empty, chains multiple commands via a shell + operator, or invokes a destructive subcommand. + """ + try: + lexer = shlex.shlex(command, posix=True) + lexer.whitespace_split = True + lexer.commenters = "" + lexer.escape = "" + tokens = list(lexer) + except ValueError as exc: + raise ValueError(f"Could not parse command: {exc}") from exc + + if not tokens: + raise ValueError("No command provided.") + + if tokens[0] in _ALLOWED_BINARIES: + tokens = tokens[1:] + if not tokens: + raise ValueError("No `uip` subcommand provided.") + + # One pass rejects both shell operators and destructive subcommands. The operator + # check is defense-in-depth: execution uses ``create_subprocess_exec`` (never a + # shell), so chaining cannot happen regardless — this just returns a clear error + # instead of handing a nonsense token to ``uip``. + for token in tokens: + if token in _SHELL_OPERATORS: + raise ValueError( + f"Shell operator '{token}' is not allowed; run one command at a time." + ) + if token.lower() in _DESTRUCTIVE_SUBCOMMANDS: + raise ValueError(f"Destructive subcommand '{token}' is not allowed.") + + return tokens + + +class UiPathCliInput(BaseModel): + """Input schema for the ``uipath_cli`` tool.""" + + command: str = Field( + description=( + "A single `uip` command without the binary prefix, e.g. `pack`, " + "`solution publish`. One command only; no shell operators." + ), + examples=["pack", "solution publish", "init my-agent"], + ) + subdir: str = Field( + default="", + description="Workspace-relative directory to run in; defaults to the root.", + ) + + @field_validator("command") + @classmethod + def _validate_command(cls, value: str) -> str: + """Reject unsafe commands before execution (see ``_parse_uip_command``).""" + _parse_uip_command(value) + return value + + +class UiPathCliOutput(BaseModel): + """Output schema for the ``uipath_cli`` tool.""" + + command: str = Field(description="The argv actually executed (or attempted).") + exit_code: int = Field( + description=( + f"Process exit code, or {_REJECTED_EXIT_CODE} when the command was " + "rejected before running (see stderr for the reason)." + ) + ) + stdout: str = Field(description="Captured standard output.") + stderr: str = Field(description="Captured standard error, or the failure reason.") + + +def _rejected(command: str, reason: str) -> dict[str, Any]: + """Build a 'rejected before running' result carrying the sentinel and reason.""" + logger.warning("uipath_cli rejected command %r: %s", command, reason) + return UiPathCliOutput( + command=command, + exit_code=_REJECTED_EXIT_CODE, + stdout="", + stderr=reason, + ).model_dump() + + +def _resolve_run_dir_within_workspace(workspace_root: Path, subdir: str) -> Path | None: + """Resolve ``subdir`` under the workspace, or ``None`` if it escapes. + + ``.resolve()`` collapses ``..`` and follows symlinks, so absolute paths, parent + traversal, and symlink escapes are all rejected. + """ + run_dir = (workspace_root / subdir).resolve() + if run_dir == workspace_root or workspace_root in run_dir.parents: + return run_dir + return None + + +async def _run_uip_subprocess( + binary: str, args: list[str], run_dir: Path, echoed: str +) -> dict[str, Any]: + """Run the resolved ``uip`` command in ``run_dir`` and map it to output.""" + logger.info("uipath_cli running %s (cwd=%s)", echoed, run_dir) + try: + proc = await asyncio.create_subprocess_exec( + binary, + *args, + cwd=run_dir, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except OSError as exc: + return _rejected(echoed, str(exc)) + + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=_COMMAND_TIMEOUT_SECONDS + ) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return _rejected( + echoed, f"Command timed out after {_COMMAND_TIMEOUT_SECONDS}s." + ) + + logger.info("uipath_cli command %s exited with code %s", echoed, proc.returncode) + exit_code = proc.returncode if proc.returncode is not None else _REJECTED_EXIT_CODE + return UiPathCliOutput( + command=echoed, + exit_code=exit_code, + stdout=stdout.decode(errors="replace"), + stderr=stderr.decode(errors="replace"), + ).model_dump() + + +def create_uipath_cli_tool(workspace: Workspace) -> StructuredTool: + """Create the sandboxed ``uipath_cli`` tool bound to ``workspace``. + + The returned tool runs one ``uip`` command per call inside the workspace + (optionally under ``subdir``) and returns a :class:`UiPathCliOutput` dict. It is + injected by the agent graph builder rather than selected as a resource, and + enforces that only the ``uip``/``uipath`` binaries run, one command at a time, + excluding destructive subcommands. + + Args: + workspace: The agent's workspace; commands run within its directory. Must be a + real on-disk workspace (inject only for a ``FilesystemBackend``). + + Returns: + A ``StructuredTool`` named ``uipath_cli``. + """ + + async def run_uipath_command(command: str, subdir: str = "") -> dict[str, Any]: + # Parse again here to obtain the argv we exec (and to stay correct if this + # coroutine is invoked directly, bypassing the model validator). Pure and + # deterministic, so it stays outside the mockable body below. + try: + args = _parse_uip_command(command) + except ValueError as exc: + return _rejected(command.strip() or "uip", str(exc)) + + @mockable( + name=_TOOL_NAME, + description=_TOOL_DESCRIPTION, + input_schema=UiPathCliInput.model_json_schema(), + output_schema=UiPathCliOutput.model_json_schema(), + example_calls=[], # Examples cannot be provided for internal tools + ) + async def _execute(**_tool_kwargs: Any) -> dict[str, Any]: + # Wrapped by @mockable so eval runs get a deterministic result without a + # real binary/workspace — hence binary resolution and the workspace guard + # live here, above the subprocess, so the mock intercepts before them. + binary = shutil.which("uip") or shutil.which("uipath") + if binary is None: + return _rejected( + shlex.join(["uip", *args]), + "No `uip` or `uipath` binary found on PATH.", + ) + echoed = shlex.join([os.path.basename(binary), *args]) + + run_dir = _resolve_run_dir_within_workspace( + workspace.path.resolve(), subdir + ) + if run_dir is None: + return _rejected( + echoed, f"Invalid subdir '{subdir}': escapes the workspace." + ) + + return await _run_uip_subprocess(binary, args, run_dir, echoed) + + return await _execute(command=command, subdir=subdir) + + return StructuredToolWithArgumentProperties( + name=_TOOL_NAME, + description=_TOOL_DESCRIPTION, + args_schema=UiPathCliInput, + coroutine=run_uipath_command, + output_type=UiPathCliOutput, + argument_properties={}, + metadata={ + "tool_type": _TOOL_NAME, + "display_name": _TOOL_NAME, + "args_schema": UiPathCliInput, + "output_schema": UiPathCliOutput, + }, + # Validator errors become a recoverable ToolMessage in all modes (the repo + # only auto-wraps tool errors in conversational mode). + handle_validation_error=lambda e: str(e), + ) diff --git a/tests/agent/advanced/test_create_advanced_agent.py b/tests/agent/advanced/test_create_advanced_agent.py index c574a7abe..a97553a04 100644 --- a/tests/agent/advanced/test_create_advanced_agent.py +++ b/tests/agent/advanced/test_create_advanced_agent.py @@ -69,3 +69,27 @@ def test_advanced_agent_converts_sequences_to_lists( _, kwargs = mock_upstream.call_args assert isinstance(kwargs["tools"], list) assert isinstance(kwargs["subagents"], list) + + def test_advanced_agent_forwards_skills(self, mock_model: MagicMock) -> None: + """Non-empty skills are forwarded to the upstream builder as a list.""" + with patch( + "uipath_langchain.agent.advanced.agent._create_deep_agent" + ) as mock_upstream: + mock_upstream.return_value = MagicMock(spec=CompiledStateGraph) + create_advanced_agent( + mock_model, system_prompt="test", skills=("/skills/",) + ) + _, kwargs = mock_upstream.call_args + assert kwargs["skills"] == ["/skills/"] + + def test_advanced_agent_empty_skills_becomes_none( + self, mock_model: MagicMock + ) -> None: + """An empty skills sequence collapses to None (disables the middleware).""" + with patch( + "uipath_langchain.agent.advanced.agent._create_deep_agent" + ) as mock_upstream: + mock_upstream.return_value = MagicMock(spec=CompiledStateGraph) + create_advanced_agent(mock_model, system_prompt="test") + _, kwargs = mock_upstream.call_args + assert kwargs["skills"] is None diff --git a/tests/agent/tools/internal_tools/test_uipath_cli_tool.py b/tests/agent/tools/internal_tools/test_uipath_cli_tool.py new file mode 100644 index 000000000..9a9184b29 --- /dev/null +++ b/tests/agent/tools/internal_tools/test_uipath_cli_tool.py @@ -0,0 +1,208 @@ +"""Tests for the sandboxed ``uipath_cli`` internal tool.""" + +import asyncio +import shutil + +import pytest +from pydantic import ValidationError +from uipath.runtime import Workspace + +from uipath_langchain.agent.tools.internal_tools.uipath_cli_tool import ( + _REJECTED_EXIT_CODE, + UiPathCliInput, + _parse_uip_command, + create_uipath_cli_tool, +) + +_TOOL_MODULE = "uipath_langchain.agent.tools.internal_tools.uipath_cli_tool" + +# --- _parse_uip_command: the security boundary ----------------------------- + + +@pytest.mark.parametrize( + "command,expected", + [ + ("pack", ["pack"]), + ("solution publish", ["solution", "publish"]), + ("init my-agent", ["init", "my-agent"]), + # explicit binary prefix is tolerated and stripped + ("uip pack", ["pack"]), + ("uipath solution pack", ["solution", "pack"]), + ], +) +def test_parse_accepts_valid_commands(command: str, expected: list[str]) -> None: + assert _parse_uip_command(command) == expected + + +@pytest.mark.parametrize("command", ["", " ", "uip", "uipath"]) +def test_parse_rejects_empty_or_binary_only(command: str) -> None: + with pytest.raises(ValueError): + _parse_uip_command(command) + + +@pytest.mark.parametrize( + "command", ["pack && rm -rf /", "pack | grep x", "a ; b", "a || b"] +) +def test_parse_rejects_shell_operators(command: str) -> None: + with pytest.raises(ValueError, match="Shell operator"): + _parse_uip_command(command) + + +@pytest.mark.parametrize( + "command", ["solution delete x", "assets REMOVE y", "destroy", "purge all"] +) +def test_parse_rejects_destructive_subcommands(command: str) -> None: + with pytest.raises(ValueError, match="Destructive subcommand"): + _parse_uip_command(command) + + +# --- UiPathCliInput / tool-level validation -------------------------------- + + +def test_input_model_rejects_bad_command() -> None: + with pytest.raises(ValidationError): + UiPathCliInput(command="solution delete x") + + +def test_input_model_accepts_good_command() -> None: + assert UiPathCliInput(command="pack").command == "pack" + + +async def test_tool_returns_recoverable_error_on_bad_command(tmp_path) -> None: + tool = create_uipath_cli_tool(Workspace(tmp_path)) + msg = await tool.ainvoke( + { + "name": "uipath_cli", + "args": {"command": "solution delete x"}, + "id": "1", + "type": "tool_call", + } + ) + assert msg.status == "error" + assert "Destructive subcommand" in msg.content + + +# --- execution mapping (mocked subprocess) --------------------------------- + + +class _FakeProc: + def __init__(self, returncode: int, stdout: bytes, stderr: bytes) -> None: + self.returncode = returncode + self._stdout = stdout + self._stderr = stderr + self.killed = False + + async def communicate(self) -> tuple[bytes, bytes]: + return self._stdout, self._stderr + + def kill(self) -> None: + self.killed = True + + async def wait(self) -> int: + return self.returncode + + +@pytest.fixture +def fake_uip(monkeypatch: pytest.MonkeyPatch) -> None: + """Make binary resolution deterministic as ``uip``.""" + monkeypatch.setattr( + shutil, "which", lambda name: "/usr/bin/uip" if name == "uip" else None + ) + + +@pytest.mark.parametrize("returncode", [0, 1]) +async def test_run_maps_process_result( + tmp_path, monkeypatch: pytest.MonkeyPatch, fake_uip: None, returncode: int +) -> None: + async def fake_exec(*args, **kwargs): # noqa: ANN002, ANN003 + return _FakeProc(returncode, b"out\n", b"err\n") + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) + tool = create_uipath_cli_tool(Workspace(tmp_path)) + + assert tool.coroutine is not None + result = await tool.coroutine("pack") + + assert result == { + "command": "uip pack", + "exit_code": returncode, + "stdout": "out\n", + "stderr": "err\n", + } + + +async def test_run_returns_failure_on_oserror( + tmp_path, monkeypatch: pytest.MonkeyPatch, fake_uip: None +) -> None: + async def boom(*args, **kwargs): # noqa: ANN002, ANN003 + raise FileNotFoundError("no such binary") + + monkeypatch.setattr(asyncio, "create_subprocess_exec", boom) + tool = create_uipath_cli_tool(Workspace(tmp_path)) + + assert tool.coroutine is not None + result = await tool.coroutine("pack") + + assert result["exit_code"] == _REJECTED_EXIT_CODE + assert "no such binary" in result["stderr"] + + +async def test_run_times_out_and_kills( + tmp_path, monkeypatch: pytest.MonkeyPatch, fake_uip: None +) -> None: + proc = _FakeProc(0, b"", b"") + + async def hang() -> tuple[bytes, bytes]: + await asyncio.sleep(3600) + return b"", b"" + + proc.communicate = hang # type: ignore[method-assign] + + async def fake_exec(*args, **kwargs): # noqa: ANN002, ANN003 + return proc + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) + monkeypatch.setattr(f"{_TOOL_MODULE}._COMMAND_TIMEOUT_SECONDS", 0.01) + tool = create_uipath_cli_tool(Workspace(tmp_path)) + + assert tool.coroutine is not None + result = await tool.coroutine("pack") + + assert result["exit_code"] == _REJECTED_EXIT_CODE + assert "timed out" in result["stderr"] + assert proc.killed is True + + +@pytest.mark.parametrize("subdir", ["../outside", "../../etc", "/tmp"]) +async def test_run_rejects_subdir_escape( + tmp_path, monkeypatch: pytest.MonkeyPatch, fake_uip: None, subdir: str +) -> None: + async def fake_exec(*args, **kwargs): # noqa: ANN002, ANN003 + raise AssertionError("Subprocess must not start for an escaping subdir.") + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) + tool = create_uipath_cli_tool(Workspace(tmp_path)) + + assert tool.coroutine is not None + result = await tool.coroutine("pack", subdir=subdir) + + assert result["exit_code"] == _REJECTED_EXIT_CODE + assert "escapes the workspace" in result["stderr"] + + +async def test_run_rejects_when_no_binary( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(shutil, "which", lambda name: None) + + async def fake_exec(*args, **kwargs): # noqa: ANN002, ANN003 + raise AssertionError("Subprocess must not start when no binary is found.") + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) + tool = create_uipath_cli_tool(Workspace(tmp_path)) + + assert tool.coroutine is not None + result = await tool.coroutine("pack") + + assert result["exit_code"] == _REJECTED_EXIT_CODE + assert "binary" in result["stderr"]