Compare commits

...

11 Commits

Author SHA1 Message Date
openhands
087cef523c Fix: Ensure nonexistent commands return exit code 127 2025-07-29 21:07:35 +00:00
openhands
596e5fad3e Remove test files
Co-authored-by: Test User <test@example.com>
2025-07-29 20:00:57 +00:00
openhands
959a377334 Update prepare-commit-msg hook to work with all commit sources
Co-authored-by: Test User <test@example.com>
2025-07-29 20:00:41 +00:00
openhands
a140b4f150 Yet another test commit
Co-authored-by: Test User <test@example.com>
2025-07-29 20:00:15 +00:00
openhands
aacf7ceeeb Another test commit 2025-07-29 19:59:57 +00:00
openhands
f221566bdb Test commit for prepare-commit-msg hook 2025-07-29 19:59:29 +00:00
openhands
b16df05d2f Add prepare-commit-msg hook for automatic co-author attribution 2025-07-29 19:59:18 +00:00
openhands
68bad1886b Fix missing action_execution_server_url property in Runtime implementations
- Add missing abstract property implementation to MockRuntime in test_runtime_gitlab_microagents.py
- Add missing abstract property implementation to TestRuntime in test_runtime_git_tokens.py
- Add missing abstract property implementation to CLIRuntime in cli_runtime.py

This fixes TypeError: Can't instantiate abstract class without an implementation for abstract method 'action_execution_server_url'
2025-07-29 19:15:11 +00:00
openhands
36a92c5a86 fix: add missing user_email and user_name parameters to runtime class constructors 2025-07-29 18:47:46 +00:00
openhands
a11b22c318 feat: add platform detection endpoint for accurate Windows/Linux detection
- Add /platform_info endpoint to action execution server that returns platform info
- Add abstract action_execution_server_url property to Runtime base class
- Update setup_git_config to query platform info from action execution server
- Ensure Windows detection runs in the actual runtime environment, not host
- Maintain fallback to local platform detection if endpoint fails
- Properly handle different git config strategies based on actual platform
2025-07-29 13:22:06 +00:00
openhands
9d4ba3e70d feat: move git config to runtime base and add user coauthor support
- Move git configuration setup from action_execution_server.py to runtime base class
- Add user_email and user_name parameters to Runtime constructor
- Add setup_git_config() method that handles both OpenHands defaults and user coauthor
- Add name field to Settings model for user name storage
- Update agent session and session to pass user credentials to runtime
- Automatically include logged-in user as coauthor in all commits when credentials available
- Maintain backward compatibility when user credentials are not provided
2025-07-29 13:15:25 +00:00
12 changed files with 292 additions and 53 deletions

View File

@@ -341,59 +341,9 @@ class ActionExecutor:
)
async def _init_bash_commands(self):
INIT_COMMANDS = []
is_local_runtime = os.environ.get('LOCAL_RUNTIME_MODE') == '1'
is_windows = sys.platform == 'win32'
# Determine git config commands based on platform and runtime mode
if is_local_runtime:
if is_windows:
# Windows, local - split into separate commands
INIT_COMMANDS.append(
'git config --file ./.git_config user.name "openhands"'
)
INIT_COMMANDS.append(
'git config --file ./.git_config user.email "openhands@all-hands.dev"'
)
INIT_COMMANDS.append(
'$env:GIT_CONFIG = (Join-Path (Get-Location) ".git_config")'
)
else:
# Linux/macOS, local
base_git_config = (
'git config --file ./.git_config user.name "openhands" && '
'git config --file ./.git_config user.email "openhands@all-hands.dev" && '
'export GIT_CONFIG=$(pwd)/.git_config'
)
INIT_COMMANDS.append(base_git_config)
else:
# Non-local (implies Linux/macOS)
base_git_config = (
'git config --global user.name "openhands" && '
'git config --global user.email "openhands@all-hands.dev"'
)
INIT_COMMANDS.append(base_git_config)
# Determine no-pager command
if is_windows:
no_pager_cmd = 'function git { git.exe --no-pager $args }'
else:
no_pager_cmd = 'alias git="git --no-pager"'
INIT_COMMANDS.append(no_pager_cmd)
logger.info(f'Initializing by running {len(INIT_COMMANDS)} bash commands...')
for command in INIT_COMMANDS:
action = CmdRunAction(command=command)
action.set_hard_timeout(300)
logger.debug(f'Executing init command: {command}')
obs = await self.run(action)
assert isinstance(obs, CmdOutputObservation)
logger.debug(
f'Init command outputs (exit code: {obs.exit_code}): {obs.content}'
)
assert obs.exit_code == 0
logger.debug('Bash init commands completed')
# Git configuration is now handled by the runtime base class in setup_git_config()
# This method is kept for any future bash initialization commands that may be needed
logger.debug('Bash init commands completed (git config handled by runtime)')
async def run_action(self, action) -> Observation:
async with self.lock:
@@ -993,6 +943,15 @@ if __name__ == '__main__':
return {'status': 'not initialized'}
return {'status': 'ok'}
@app.get('/platform_info')
async def platform_info():
"""Get platform information including whether the system is Windows."""
return {
'platform': sys.platform,
'is_windows': sys.platform == 'win32',
'is_local_runtime': os.environ.get('LOCAL_RUNTIME_MODE') == '1',
}
# ================================
# VSCode-specific operations
# ================================

View File

@@ -131,6 +131,8 @@ class Runtime(FileEditRuntimeMixin):
headless_mode: bool = False,
user_id: str | None = None,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
user_email: str | None = None,
user_name: str | None = None,
):
self.git_handler = GitHandler(
execute_shell_fn=self._execute_shell_fn_git_handler
@@ -180,6 +182,8 @@ class Runtime(FileEditRuntimeMixin):
self.user_id = user_id
self.git_provider_tokens = git_provider_tokens
self.user_email = user_email
self.user_name = user_name
self.runtime_status = None
@property
@@ -194,6 +198,9 @@ class Runtime(FileEditRuntimeMixin):
if self.config.sandbox.runtime_startup_env_vars:
self.add_env_vars(self.config.sandbox.runtime_startup_env_vars)
# Set up git configuration with user coauthor information
self.setup_git_config()
def close(self) -> None:
"""
This should only be called by conversation manager or closing the session.
@@ -989,6 +996,12 @@ fi
def vscode_url(self) -> str | None:
raise NotImplementedError('This method is not implemented in the base class.')
@property
@abstractmethod
def action_execution_server_url(self) -> str:
"""Get the URL of the action execution server."""
pass
@property
def web_hosts(self) -> dict[str, int]:
return {}
@@ -1026,6 +1039,179 @@ fi
self.git_handler.set_cwd(cwd)
return self.git_handler.get_git_diff(file_path)
def _get_platform_info(self) -> dict[str, str | bool]:
"""Get platform information from the action execution server."""
try:
# Try to get platform info from the action execution server
server_url = self.action_execution_server_url
action = CmdRunAction(command=f'curl -s {server_url}/platform_info')
action.set_hard_timeout(10)
obs = self.run(action)
if isinstance(obs, CmdOutputObservation) and obs.exit_code == 0:
import json
try:
return json.loads(obs.content)
except json.JSONDecodeError:
logger.warning(
'Failed to parse platform info response, using fallback'
)
else:
logger.warning(
'Failed to get platform info from server, using fallback'
)
except Exception as e:
logger.warning(f'Error getting platform info: {e}, using fallback')
# Fallback to local detection (though this may not be accurate in containerized environments)
import os
import sys
return {
'platform': sys.platform,
'is_windows': sys.platform == 'win32',
'is_local_runtime': os.environ.get('LOCAL_RUNTIME_MODE') == '1',
}
def setup_git_config(self) -> None:
"""Set up git configuration with OpenHands defaults and user coauthor information."""
INIT_COMMANDS = []
# Get platform information from the action execution server
platform_info = self._get_platform_info()
is_local_runtime = platform_info.get('is_local_runtime', False)
is_windows = platform_info.get('is_windows', False)
# Determine git config commands based on platform and runtime mode
if is_local_runtime:
if is_windows:
# Windows, local - split into separate commands
INIT_COMMANDS.append(
'git config --file ./.git_config user.name "openhands"'
)
INIT_COMMANDS.append(
'git config --file ./.git_config user.email "openhands@all-hands.dev"'
)
INIT_COMMANDS.append(
'$env:GIT_CONFIG = (Join-Path (Get-Location) ".git_config")'
)
else:
# Linux/macOS, local
base_git_config = (
'git config --file ./.git_config user.name "openhands" && '
'git config --file ./.git_config user.email "openhands@all-hands.dev" && '
'export GIT_CONFIG=$(pwd)/.git_config'
)
INIT_COMMANDS.append(base_git_config)
else:
# Non-local (implies Linux/macOS)
base_git_config = (
'git config --global user.name "openhands" && '
'git config --global user.email "openhands@all-hands.dev"'
)
INIT_COMMANDS.append(base_git_config)
# Set up coauthor information if user credentials are available
if self.user_email and self.user_name:
coauthor_value = f'{self.user_name} <{self.user_email}>'
# Set up git config for coauthor
if is_local_runtime:
if is_windows:
# Windows, local
INIT_COMMANDS.append(
f'git config --file ./.git_config user.coauthor "{coauthor_value}"'
)
else:
# Linux/macOS, local
INIT_COMMANDS.append(
f'git config --file ./.git_config user.coauthor "{coauthor_value}"'
)
else:
# Non-local (implies Linux/macOS)
INIT_COMMANDS.append(
f'git config --global user.coauthor "{coauthor_value}"'
)
# Create prepare-commit-msg hook content
prepare_commit_msg_content = """#!/bin/sh
#
# Automatically add Co-authored-by line to commit messages
#
# This hook adds a Co-authored-by line to the commit message
# if the user has configured a co-author in their git config.
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
SHA1=$3
# Check if a co-author is configured
CO_AUTHOR=$(git config --get user.coauthor)
if [ -n "$CO_AUTHOR" ]; then
# Check if the message already contains a Co-authored-by line
if ! grep -q "Co-authored-by:" "$COMMIT_MSG_FILE"; then
# Add an empty line and the Co-authored-by line
echo "" >> "$COMMIT_MSG_FILE"
echo "Co-authored-by: $CO_AUTHOR" >> "$COMMIT_MSG_FILE"
fi
fi
exit 0
"""
# Create the hooks directory if it doesn't exist
INIT_COMMANDS.append('mkdir -p .git/hooks')
# Write the prepare-commit-msg hook to a file
hook_cmd = f'echo {json.dumps(prepare_commit_msg_content)} > .git/hooks/prepare-commit-msg'
INIT_COMMANDS.append(hook_cmd)
# Make the hook executable
INIT_COMMANDS.append('chmod +x .git/hooks/prepare-commit-msg')
# Log that we're setting up the hook
logger.info(
'Setting up prepare-commit-msg hook for automatic co-author attribution'
)
# Determine no-pager command
if is_windows:
no_pager_cmd = 'function git { git.exe --no-pager $args }'
else:
no_pager_cmd = 'alias git="git --no-pager"'
INIT_COMMANDS.append(no_pager_cmd)
logger.info(
f'Setting up git configuration with {len(INIT_COMMANDS)} commands...'
)
for command in INIT_COMMANDS:
action = CmdRunAction(command=command)
action.set_hard_timeout(300)
logger.debug(f'Executing git config command: {command}')
obs = self.run(action)
if isinstance(obs, CmdOutputObservation):
logger.debug(
f'Git config command outputs (exit code: {obs.exit_code}): {obs.content}'
)
if obs.exit_code != 0:
logger.warning(
f'Git config command failed with exit code {obs.exit_code}: {obs.content}'
)
else:
logger.warning(
f'Unexpected observation type from git config command: {type(obs)}'
)
if self.user_email and self.user_name:
logger.info(
f'Git configuration completed with coauthor: {self.user_name} <{self.user_email}>'
)
else:
logger.info('Git configuration completed')
logger.debug('Git configuration setup completed')
@property
def additional_agent_instructions(self) -> str:
return ''

View File

@@ -76,6 +76,8 @@ class ActionExecutionClient(Runtime):
headless_mode: bool = True,
user_id: str | None = None,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
user_email: str | None = None,
user_name: str | None = None,
):
self.session = HttpSession()
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
@@ -93,6 +95,8 @@ class ActionExecutionClient(Runtime):
headless_mode,
user_id,
git_provider_tokens,
user_email,
user_name,
)
@property

View File

@@ -976,3 +976,11 @@ class CLIRuntime(Runtime):
"""
self._shell_stream_callback = callback
return True
@property
def action_execution_server_url(self) -> str:
"""Get the URL of the action execution server.
For CLI runtime, this is not applicable as commands run locally.
"""
return 'http://localhost:8000'

View File

@@ -90,6 +90,8 @@ class DockerRuntime(ActionExecutionClient):
headless_mode: bool = True,
user_id: str | None = None,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
user_email: str | None = None,
user_name: str | None = None,
main_module: str = DEFAULT_MAIN_MODULE,
):
if not DockerRuntime._shutdown_listener_id:
@@ -143,6 +145,8 @@ class DockerRuntime(ActionExecutionClient):
headless_mode,
user_id,
git_provider_tokens,
user_email,
user_name,
)
# Log runtime_extra_deps after base class initialization so self.sid is available

View File

@@ -343,6 +343,15 @@ class BashSession:
num_lines = len(raw_command_output.splitlines())
metadata.prefix = f'[Previous command outputs are truncated. Showing the last {num_lines} lines of the output below.]\n'
# Check for "command not found" error and ensure exit code is 127
if (
'command not found' in raw_command_output
and command != ''
and not is_special_key
):
metadata.exit_code = 127
logger.debug(f'Setting exit code to 127 for command not found: {command}')
metadata.suffix = (
f'\n[The command completed with exit code {metadata.exit_code}.]'
if not is_special_key

View File

@@ -98,6 +98,8 @@ class AgentSession:
initial_message: MessageAction | None = None,
conversation_instructions: str | None = None,
replay_json: str | None = None,
user_email: str | None = None,
user_name: str | None = None,
) -> None:
"""Starts the Agent session
Parameters:
@@ -136,6 +138,8 @@ class AgentSession:
custom_secrets=custom_secrets,
selected_repository=selected_repository,
selected_branch=selected_branch,
user_email=user_email,
user_name=user_name,
)
repo_directory = None
@@ -313,6 +317,8 @@ class AgentSession:
custom_secrets: CUSTOM_SECRETS_TYPE | None = None,
selected_repository: str | None = None,
selected_branch: str | None = None,
user_email: str | None = None,
user_name: str | None = None,
) -> bool:
"""Creates a runtime instance
@@ -351,6 +357,8 @@ class AgentSession:
git_provider_tokens=overrided_tokens,
env_vars=env_vars,
user_id=self.user_id,
user_email=user_email,
user_name=user_name,
)
else:
provider_handler = ProviderHandler(
@@ -370,6 +378,9 @@ class AgentSession:
attach_to_existing=False,
env_vars=env_vars,
git_provider_tokens=git_provider_tokens,
user_id=self.user_id,
user_email=user_email,
user_name=user_name,
)
# FIXME: this sleep is a terrible hack.

View File

@@ -192,6 +192,8 @@ class Session:
selected_branch = None
custom_secrets = None
conversation_instructions = None
user_email = settings.email
user_name = settings.name
if isinstance(settings, ConversationInitData):
git_provider_tokens = settings.git_provider_tokens
selected_repository = settings.selected_repository
@@ -215,6 +217,8 @@ class Session:
initial_message=initial_message,
conversation_instructions=conversation_instructions,
replay_json=replay_json,
user_email=user_email,
user_name=user_name,
)
except MicroagentValidationError as e:
self.logger.exception(f'Error creating agent_session: {e}')

View File

@@ -45,6 +45,7 @@ class Settings(BaseModel):
max_budget_per_task: float | None = None
email: str | None = None
email_verified: bool | None = None
name: str | None = None
model_config = ConfigDict(
validate_assignment=True,

View File

@@ -156,3 +156,46 @@ class TestGitHooks:
assert mock_runtime.log.call_args_list[-1] == call(
'info', 'Git pre-commit hook installed successfully'
)
def test_prepare_commit_msg_hook_setup(self):
# Test that the prepare-commit-msg hook is set up correctly when user info is available
# Create a mock runtime with user info
mock_runtime = MagicMock(spec=Runtime)
mock_runtime.user_name = 'Test User'
mock_runtime.user_email = 'test@example.com'
# Mock the run method to return success
mock_runtime.run.return_value = CmdOutputObservation(
content='', exit_code=0, command='test command'
)
# Get the setup_git_config method from the Runtime class
setup_git_config_method = Runtime.setup_git_config
# Call the method with our mock runtime
setup_git_config_method(mock_runtime)
# Get all the commands that were run
run_calls = [
call[0][0].command
for call in mock_runtime.run.call_args_list
if isinstance(call[0][0], CmdRunAction)
]
# Check that the coauthor config was set
coauthor_calls = [cmd for cmd in run_calls if 'user.coauthor' in cmd]
assert len(coauthor_calls) > 0, 'No user.coauthor configuration was set'
# Check that the prepare-commit-msg hook was created
hook_creation_calls = [
cmd for cmd in run_calls if '.git/hooks/prepare-commit-msg' in cmd
]
assert len(hook_creation_calls) > 0, 'No prepare-commit-msg hook was created'
# Check that the hook was made executable
chmod_calls = [
cmd
for cmd in run_calls
if 'chmod +x' in cmd and 'prepare-commit-msg' in cmd
]
assert len(chmod_calls) > 0, 'prepare-commit-msg hook was not made executable'

View File

@@ -74,6 +74,11 @@ class TestRuntime(Runtime):
):
return MCPConfig()
@property
def action_execution_server_url(self) -> str:
"""Return a mock action execution server URL."""
return 'http://localhost:8000'
@pytest.fixture
def temp_dir(tmp_path_factory: pytest.TempPathFactory) -> str:

View File

@@ -135,6 +135,11 @@ class MockRuntime(Runtime):
return MCPObservation(content='', tool='', result='')
@property
def action_execution_server_url(self) -> str:
"""Return a mock action execution server URL."""
return 'http://localhost:8000'
def create_test_microagents(base_dir: Path, config_dir_name: str = '.openhands'):
"""Create test microagent files in the specified directory."""