Merge pull request #304 from trycua/fix/returncode

[Computer] Add CommandResult dataclass (#295) for run_command with returncode
This commit is contained in:
ddupont
2025-06-25 12:22:10 -04:00
committed by GitHub
14 changed files with 266 additions and 50 deletions
+2 -1
View File
@@ -249,7 +249,8 @@ For complete examples, see [computer_examples.py](./examples/computer_examples.p
```python
# Shell Actions
await computer.interface.run_command(cmd) # Run shell command
result = await computer.interface.run_command(cmd) # Run shell command
# result.stdout, result.stderr, result.returncode
# Mouse Actions
await computer.interface.left_click(x, y) # Left click at coordinates
@@ -50,8 +50,8 @@ class BashTool(BaseBashTool, BaseAnthropicTool):
try:
async with asyncio.timeout(self._timeout):
stdout, stderr = await self.computer.interface.run_command(command)
return CLIResult(output=stdout or "", error=stderr or "")
result = await self.computer.interface.run_command(command)
return CLIResult(output=result.stdout or "", error=result.stderr or "")
except asyncio.TimeoutError as e:
raise ToolError(f"Command timed out after {self._timeout} seconds") from e
except Exception as e:
@@ -95,13 +95,13 @@ class EditTool(BaseEditTool, BaseAnthropicTool):
result = await self.computer.interface.run_command(
f'[ -e "{str(path)}" ] && echo "exists" || echo "not exists"'
)
exists = result[0].strip() == "exists"
exists = result.stdout.strip() == "exists"
if exists:
result = await self.computer.interface.run_command(
f'[ -d "{str(path)}" ] && echo "dir" || echo "file"'
)
is_dir = result[0].strip() == "dir"
is_dir = result.stdout.strip() == "dir"
else:
is_dir = False
@@ -126,7 +126,7 @@ class EditTool(BaseEditTool, BaseAnthropicTool):
result = await self.computer.interface.run_command(
f'[ -d "{str(path)}" ] && echo "dir" || echo "file"'
)
is_dir = result[0].strip() == "dir"
is_dir = result.stdout.strip() == "dir"
if is_dir:
if view_range:
@@ -136,7 +136,7 @@ class EditTool(BaseEditTool, BaseAnthropicTool):
# List directory contents using ls
result = await self.computer.interface.run_command(f'ls -la "{str(path)}"')
contents = result[0]
contents = result.stdout
if contents:
stdout = f"Here's the files and directories in {path}:\n{contents}\n"
else:
@@ -272,9 +272,9 @@ class EditTool(BaseEditTool, BaseAnthropicTool):
"""Read the content of a file using cat command."""
try:
result = await self.computer.interface.run_command(f'cat "{str(path)}"')
if result[1]: # If there's stderr output
raise ToolError(f"Error reading file: {result[1]}")
return result[0]
if result.stderr: # If there's stderr output
raise ToolError(f"Error reading file: {result.stderr}")
return result.stdout
except Exception as e:
raise ToolError(f"Failed to read {path}: {str(e)}")
@@ -291,8 +291,8 @@ class EditTool(BaseEditTool, BaseAnthropicTool):
{content}
EOFCUA"""
result = await self.computer.interface.run_command(cmd)
if result[1]: # If there's stderr output
raise ToolError(f"Error writing file: {result[1]}")
if result.stderr: # If there's stderr output
raise ToolError(f"Error writing file: {result.stderr}")
except Exception as e:
raise ToolError(f"Failed to write to {path}: {str(e)}")
+1 -1
View File
@@ -19,7 +19,7 @@ dependencies = [
"pydantic>=2.6.4",
"rich>=13.7.1",
"python-dotenv>=1.0.1",
"cua-computer>=0.2.0,<0.3.0",
"cua-computer>=0.3.0,<0.4.0",
"cua-core>=0.1.0,<0.2.0",
"certifi>=2024.2.2"
]
+26 -4
View File
@@ -3,8 +3,7 @@
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any, Tuple, List
from ..logger import Logger, LogLevel
from .models import MouseButton
from .models import MouseButton, CommandResult
class BaseComputerInterface(ABC):
"""Base class for computer control interfaces."""
@@ -234,8 +233,31 @@ class BaseComputerInterface(ABC):
pass
@abstractmethod
async def run_command(self, command: str) -> Tuple[str, str]:
"""Run shell command."""
async def run_command(self, command: str) -> CommandResult:
"""Run shell command and return structured result.
Executes a shell command using subprocess.run with shell=True and check=False.
The command is run in the target environment and captures both stdout and stderr.
Args:
command (str): The shell command to execute
Returns:
CommandResult: A structured result containing:
- stdout (str): Standard output from the command
- stderr (str): Standard error from the command
- returncode (int): Exit code from the command (0 indicates success)
Raises:
RuntimeError: If the command execution fails at the system level
Example:
result = await interface.run_command("ls -la")
if result.returncode == 0:
print(f"Output: {result.stdout}")
else:
print(f"Error: {result.stderr}, Exit code: {result.returncode}")
"""
pass
# Accessibility Actions
+7 -4
View File
@@ -9,8 +9,7 @@ import websockets
from ..logger import Logger, LogLevel
from .base import BaseComputerInterface
from ..utils import decode_base64_image, encode_base64_image, bytes_to_image, draw_box, resize_image
from .models import Key, KeyType, MouseButton
from .models import Key, KeyType, MouseButton, CommandResult
class LinuxComputerInterface(BaseComputerInterface):
"""Interface for Linux."""
@@ -616,11 +615,15 @@ class LinuxComputerInterface(BaseComputerInterface):
if not result.get("success", False):
raise RuntimeError(result.get("error", "Failed to delete directory"))
async def run_command(self, command: str) -> Tuple[str, str]:
async def run_command(self, command: str) -> CommandResult:
result = await self._send_command("run_command", {"command": command})
if not result.get("success", False):
raise RuntimeError(result.get("error", "Failed to run command"))
return result.get("stdout", ""), result.get("stderr", "")
return CommandResult(
stdout=result.get("stdout", ""),
stderr=result.get("stderr", ""),
returncode=result.get("return_code", 0)
)
# Accessibility Actions
async def get_accessibility_tree(self) -> Dict[str, Any]:
+7 -4
View File
@@ -9,8 +9,7 @@ import websockets
from ..logger import Logger, LogLevel
from .base import BaseComputerInterface
from ..utils import decode_base64_image, encode_base64_image, bytes_to_image, draw_box, resize_image
from .models import Key, KeyType, MouseButton
from .models import Key, KeyType, MouseButton, CommandResult
class MacOSComputerInterface(BaseComputerInterface):
"""Interface for macOS."""
@@ -623,11 +622,15 @@ class MacOSComputerInterface(BaseComputerInterface):
if not result.get("success", False):
raise RuntimeError(result.get("error", "Failed to delete directory"))
async def run_command(self, command: str) -> Tuple[str, str]:
async def run_command(self, command: str) -> CommandResult:
result = await self._send_command("run_command", {"command": command})
if not result.get("success", False):
raise RuntimeError(result.get("error", "Failed to run command"))
return result.get("stdout", ""), result.get("stderr", "")
return CommandResult(
stdout=result.get("stdout", ""),
stderr=result.get("stderr", ""),
returncode=result.get("return_code", 0)
)
# Accessibility Actions
async def get_accessibility_tree(self) -> Dict[str, Any]:
@@ -1,5 +1,17 @@
from enum import Enum
from typing import Dict, List, Any, TypedDict, Union, Literal
from dataclasses import dataclass
@dataclass
class CommandResult:
stdout: str
stderr: str
returncode: int
def __init__(self, stdout: str, stderr: str, returncode: int):
self.stdout = stdout
self.stderr = stderr
self.returncode = returncode
# Navigation key literals
NavigationKey = Literal['pagedown', 'pageup', 'home', 'end', 'left', 'right', 'up', 'down']
+7 -4
View File
@@ -9,8 +9,7 @@ import websockets
from ..logger import Logger, LogLevel
from .base import BaseComputerInterface
from ..utils import decode_base64_image, encode_base64_image, bytes_to_image, draw_box, resize_image
from .models import Key, KeyType, MouseButton
from .models import Key, KeyType, MouseButton, CommandResult
class WindowsComputerInterface(BaseComputerInterface):
"""Interface for Windows."""
@@ -615,11 +614,15 @@ class WindowsComputerInterface(BaseComputerInterface):
if not result.get("success", False):
raise RuntimeError(result.get("error", "Failed to delete directory"))
async def run_command(self, command: str) -> Tuple[str, str]:
async def run_command(self, command: str) -> CommandResult:
result = await self._send_command("run_command", {"command": command})
if not result.get("success", False):
raise RuntimeError(result.get("error", "Failed to run command"))
return result.get("stdout", ""), result.get("stderr", "")
return CommandResult(
stdout=result.get("stdout", ""),
stderr=result.get("stderr", ""),
returncode=result.get("return_code", 0)
)
# Accessibility Actions
async def get_accessibility_tree(self) -> Dict[str, Any]:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "pdm.backend"
[project]
name = "cua-computer"
version = "0.2.0"
version = "0.3.0"
description = "Computer-Use Interface (CUI) framework powering Cua"
readme = "README.md"
authors = [
+9 -9
View File
@@ -28,24 +28,24 @@ for path in pythonpath.split(":"):
sys.path.insert(0, path) # Insert at beginning to prioritize
print(f"Added to sys.path: {path}")
from computer.computer import Computer
from computer import Computer, VMProviderType
@pytest.fixture(scope="session")
async def computer():
"""Shared Computer instance for all test cases."""
# # Create a remote Linux computer with C/ua
# computer = Computer(
# os_type="linux",
# api_key=os.getenv("CUA_API_KEY"),
# name=str(os.getenv("CUA_CONTAINER_NAME")),
# provider_type=VMProviderType.CLOUD,
# )
# Create a remote Linux computer with C/ua
computer = Computer(
os_type="linux",
api_key=os.getenv("CUA_API_KEY"),
name=str(os.getenv("CUA_CONTAINER_NAME")),
provider_type=VMProviderType.CLOUD,
)
# Create a local macOS computer with C/ua
# computer = Computer()
# Connect to host computer
computer = Computer(use_host_computer_server=True)
# computer = Computer(use_host_computer_server=True)
try:
await computer.run()
+86
View File
@@ -0,0 +1,86 @@
"""
Shell Command Tests (Bash)
Tests for the run_command method of the Computer interface using bash commands.
Required environment variables:
- CUA_API_KEY: API key for C/ua cloud provider
- CUA_CONTAINER_NAME: Name of the container to use
"""
import os
import asyncio
import pytest
from pathlib import Path
import sys
import traceback
# Load environment variables from .env file
project_root = Path(__file__).parent.parent
env_file = project_root / ".env"
print(f"Loading environment from: {env_file}")
from dotenv import load_dotenv
load_dotenv(env_file)
# Add paths to sys.path if needed
pythonpath = os.environ.get("PYTHONPATH", "")
for path in pythonpath.split(":"):
if path and path not in sys.path:
sys.path.insert(0, path) # Insert at beginning to prioritize
print(f"Added to sys.path: {path}")
from computer import Computer, VMProviderType
@pytest.fixture(scope="session")
async def computer():
"""Shared Computer instance for all test cases."""
# Create a remote Linux computer with C/ua
computer = Computer(
os_type="linux",
api_key=os.getenv("CUA_API_KEY"),
name=str(os.getenv("CUA_CONTAINER_NAME")),
provider_type=VMProviderType.CLOUD,
)
try:
await computer.run()
yield computer
finally:
await computer.disconnect()
# Sample test cases
@pytest.mark.asyncio(loop_scope="session")
async def test_bash_echo_command(computer):
"""Test basic echo command with bash."""
result = await computer.interface.run_command("echo 'Hello World'")
assert result.stdout.strip() == "Hello World"
assert result.stderr == ""
assert result.returncode == 0
@pytest.mark.asyncio(loop_scope="session")
async def test_bash_ls_command(computer):
"""Test ls command to list directory contents."""
result = await computer.interface.run_command("ls -la /tmp")
assert result.returncode == 0
assert result.stderr == ""
assert "total" in result.stdout # ls -la typically starts with "total"
assert "." in result.stdout # Current directory entry
assert ".." in result.stdout # Parent directory entry
@pytest.mark.asyncio(loop_scope="session")
async def test_bash_command_with_error(computer):
"""Test command that produces an error."""
result = await computer.interface.run_command("ls /nonexistent_directory_12345")
assert result.returncode != 0
assert result.stdout == ""
assert "No such file or directory" in result.stderr or "cannot access" in result.stderr
if __name__ == "__main__":
# Run tests directly
pytest.main([__file__, "-v"])
+87
View File
@@ -0,0 +1,87 @@
"""
Shell Command Tests (CMD)
Tests for the run_command method of the Computer interface using cmd.exe commands.
Required environment variables:
- CUA_API_KEY: API key for C/ua cloud provider
- CUA_CONTAINER_NAME: Name of the container to use
"""
import os
import asyncio
import pytest
from pathlib import Path
import sys
import traceback
# Load environment variables from .env file
project_root = Path(__file__).parent.parent
env_file = project_root / ".env"
print(f"Loading environment from: {env_file}")
from dotenv import load_dotenv
load_dotenv(env_file)
# Add paths to sys.path if needed
pythonpath = os.environ.get("PYTHONPATH", "")
for path in pythonpath.split(":"):
if path and path not in sys.path:
sys.path.insert(0, path) # Insert at beginning to prioritize
print(f"Added to sys.path: {path}")
from computer import Computer, VMProviderType
@pytest.fixture(scope="session")
async def computer():
"""Shared Computer instance for all test cases."""
# Create a remote Windows computer with C/ua
computer = Computer(
os_type="windows",
api_key=os.getenv("CUA_API_KEY"),
name=str(os.getenv("CUA_CONTAINER_NAME")),
provider_type=VMProviderType.CLOUD,
)
try:
await computer.run()
yield computer
finally:
await computer.disconnect()
# Sample test cases
@pytest.mark.asyncio(loop_scope="session")
async def test_cmd_echo_command(computer):
"""Test basic echo command with cmd.exe."""
result = await computer.interface.run_command("echo Hello World")
assert result.stdout.strip() == "Hello World"
assert result.stderr == ""
assert result.returncode == 0
@pytest.mark.asyncio(loop_scope="session")
async def test_cmd_dir_command(computer):
"""Test dir command to list directory contents."""
result = await computer.interface.run_command("dir C:\\")
assert result.returncode == 0
assert result.stderr == ""
assert "Directory of C:\\" in result.stdout
assert "bytes" in result.stdout.lower() # dir typically shows file sizes
@pytest.mark.asyncio(loop_scope="session")
async def test_cmd_command_with_error(computer):
"""Test command that produces an error."""
result = await computer.interface.run_command("dir C:\\nonexistent_directory_12345")
assert result.returncode != 0
assert result.stdout == ""
assert ("File Not Found" in result.stderr or
"cannot find the path" in result.stderr or
"The system cannot find" in result.stderr)
if __name__ == "__main__":
# Run tests directly
pytest.main([__file__, "-v"])
+10 -11
View File
@@ -29,24 +29,23 @@ for path in pythonpath.split(":"):
sys.path.insert(0, path) # Insert at beginning to prioritize
print(f"Added to sys.path: {path}")
from computer.computer import Computer
from computer.providers.base import VMProviderType
from computer import Computer, VMProviderType
from computer.helpers import sandboxed, set_default_computer
@pytest.fixture(scope="session")
async def computer():
"""Shared Computer instance for all test cases."""
# # Create a remote Linux computer with C/ua
# computer = Computer(
# os_type="linux",
# api_key=os.getenv("CUA_API_KEY"),
# name=str(os.getenv("CUA_CONTAINER_NAME")),
# provider_type=VMProviderType.CLOUD,
# )
# Create a remote Linux computer with C/ua
computer = Computer(
os_type="linux",
api_key=os.getenv("CUA_API_KEY"),
name=str(os.getenv("CUA_CONTAINER_NAME")),
provider_type=VMProviderType.CLOUD,
)
# Create a local macOS computer with C/ua
computer = Computer()
# # Create a local macOS computer with C/ua
# computer = Computer()
try:
await computer.run()