diff --git a/containers/runtime/config.sh b/containers/runtime/config.sh index 99d2eb66cc..3b10f52c1a 100644 --- a/containers/runtime/config.sh +++ b/containers/runtime/config.sh @@ -5,3 +5,6 @@ DOCKER_IMAGE=runtime # These variables will be appended by the runtime_build.py script # DOCKER_IMAGE_TAG= # DOCKER_IMAGE_SOURCE_TAG= + +DOCKER_IMAGE_TAG=oh_v0.59.0_image_nikolaik_s_python-nodejs_tag_python3.12-nodejs22 +DOCKER_IMAGE_SOURCE_TAG=oh_v0.59.0_cwpsf0pego28lacp_p73ruf86qxiulkou diff --git a/enterprise/server/auth/token_manager.py b/enterprise/server/auth/token_manager.py index 7033dab1d8..9e0eba0364 100644 --- a/enterprise/server/auth/token_manager.py +++ b/enterprise/server/auth/token_manager.py @@ -293,11 +293,12 @@ class TokenManager: refresh_token_expires_at: int, ) -> dict[str, str | int] | None: current_time = int(time.time()) - # expire access_token ten minutes before actual expiration + # expire access_token four hours before actual expiration + # This ensures tokens are refreshed on resume to have at least 4 hours validity access_expired = ( False if access_token_expires_at == 0 - else access_token_expires_at < current_time + 600 + else access_token_expires_at < current_time + 14400 ) refresh_expired = ( False diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index 119b3c2172..1474eec023 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -4,6 +4,7 @@ import copy import json import os import random +import shlex import shutil import string import tempfile @@ -447,8 +448,12 @@ class Runtime(FileEditRuntimeMixin): ) openhands_workspace_branch = f'openhands-workspace-{random_str}' + repo_path = self.workspace_root / dir_name + quoted_repo_path = shlex.quote(str(repo_path)) + quoted_remote_repo_url = shlex.quote(remote_repo_url) + # Clone repository command - clone_command = f'git clone {remote_repo_url} {dir_name}' + clone_command = f'git clone {quoted_remote_repo_url} {quoted_repo_path}' # Checkout to appropriate branch checkout_command = ( @@ -461,11 +466,35 @@ class Runtime(FileEditRuntimeMixin): await call_sync_from_async(self.run_action, clone_action) cd_checkout_action = CmdRunAction( - command=f'cd {dir_name} && {checkout_command}' + command=f'cd {quoted_repo_path} && {checkout_command}' ) action = cd_checkout_action self.log('info', f'Cloning repo: {selected_repository}') await call_sync_from_async(self.run_action, action) + + if remote_repo_url: + set_remote_action = CmdRunAction( + command=( + f'cd {quoted_repo_path} && ' + f'git remote set-url origin {quoted_remote_repo_url}' + ) + ) + obs = await call_sync_from_async(self.run_action, set_remote_action) + if isinstance(obs, CmdOutputObservation) and obs.exit_code == 0: + self.log( + 'info', + f'Set git remote origin to authenticated URL for {selected_repository}', + ) + else: + self.log( + 'warning', + ( + 'Failed to set git remote origin while ensuring fresh token ' + f'for {selected_repository}: ' + f'{obs.content if isinstance(obs, CmdOutputObservation) else "unknown error"}' + ), + ) + return dir_name def maybe_run_setup_script(self): diff --git a/tests/unit/runtime/test_runtime_git_tokens.py b/tests/unit/runtime/test_runtime_git_tokens.py index e1c4bb0613..4b4d27650b 100644 --- a/tests/unit/runtime/test_runtime_git_tokens.py +++ b/tests/unit/runtime/test_runtime_git_tokens.py @@ -8,7 +8,11 @@ from openhands.core.config import OpenHandsConfig from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig from openhands.events.action import Action from openhands.events.action.commands import CmdRunAction -from openhands.events.observation import NullObservation, Observation +from openhands.events.observation import ( + CmdOutputObservation, + NullObservation, + Observation, +) from openhands.events.stream import EventStream from openhands.integrations.provider import ProviderHandler, ProviderToken, ProviderType from openhands.integrations.service_types import AuthenticationError, Repository @@ -73,6 +77,36 @@ class MockRuntime(Runtime): def run_action(self, action: Action) -> Observation: self.run_action_calls.append(action) + # Return a mock git remote URL for git remote get-url commands + # Use an OLD token to simulate token refresh scenario + if ( + isinstance(action, CmdRunAction) + and 'git remote get-url origin' in action.command + ): + # Extract provider from previous clone command + if len(self.run_action_calls) > 0: + clone_cmd = ( + self.run_action_calls[0].command if self.run_action_calls else '' + ) + if 'github.com' in clone_cmd: + mock_url = 'https://old_github_token@github.com/owner/repo.git' + elif 'gitlab.com' in clone_cmd: + mock_url = ( + 'https://oauth2:old_gitlab_token@gitlab.com/owner/repo.git' + ) + else: + mock_url = 'https://github.com/owner/repo.git' + return CmdOutputObservation( + content=mock_url, command_id=-1, command='', exit_code=0 + ) + # Return success for git remote set-url commands + if ( + isinstance(action, CmdRunAction) + and 'git remote set-url origin' in action.command + ): + return CmdOutputObservation( + content='', command_id=-1, command='', exit_code=0 + ) return NullObservation(content='') def call_tool_mcp(self, action): @@ -330,22 +364,29 @@ async def test_clone_or_init_repo_github_with_token(temp_dir, monkeypatch): result = await runtime.clone_or_init_repo(git_provider_tokens, 'owner/repo', None) - # Verify that git clone and checkout were called as separate commands - assert len(runtime.run_action_calls) == 2 + # Verify that git clone, checkout, and git remote URL update were called + assert len(runtime.run_action_calls) == 3 # clone, checkout, set-url assert isinstance(runtime.run_action_calls[0], CmdRunAction) assert isinstance(runtime.run_action_calls[1], CmdRunAction) + assert isinstance(runtime.run_action_calls[2], CmdRunAction) # Check that the first command is the git clone with the correct URL format with token clone_cmd = runtime.run_action_calls[0].command - assert ( - f'git clone https://{github_token}@github.com/owner/repo.git repo' in clone_cmd - ) + assert f'https://{github_token}@github.com/owner/repo.git' in clone_cmd + expected_repo_path = str(runtime.workspace_root / 'repo') + assert expected_repo_path in clone_cmd # Check that the second command is the checkout checkout_cmd = runtime.run_action_calls[1].command - assert 'cd repo' in checkout_cmd + assert f'cd {expected_repo_path}' in checkout_cmd assert 'git checkout -b openhands-workspace-' in checkout_cmd + # Check that the third command sets the remote URL immediately after clone + set_url_cmd = runtime.run_action_calls[2].command + assert f'cd {expected_repo_path}' in set_url_cmd + assert 'git remote set-url origin' in set_url_cmd + assert github_token in set_url_cmd + assert result == 'repo' @@ -363,20 +404,28 @@ async def test_clone_or_init_repo_github_no_token(temp_dir, monkeypatch): mock_repo_and_patch(monkeypatch, provider=ProviderType.GITHUB) result = await runtime.clone_or_init_repo(None, 'owner/repo', None) - # Verify that git clone and checkout were called as separate commands - assert len(runtime.run_action_calls) == 2 + # Verify that git clone, checkout, and remote update were called + assert len(runtime.run_action_calls) == 3 # clone, checkout, set-url assert isinstance(runtime.run_action_calls[0], CmdRunAction) assert isinstance(runtime.run_action_calls[1], CmdRunAction) + assert isinstance(runtime.run_action_calls[2], CmdRunAction) # Check that the first command is the git clone with the correct URL format without token clone_cmd = runtime.run_action_calls[0].command - assert 'git clone https://github.com/owner/repo.git repo' in clone_cmd + expected_repo_path = str(runtime.workspace_root / 'repo') + assert 'git clone https://github.com/owner/repo.git' in clone_cmd + assert expected_repo_path in clone_cmd # Check that the second command is the checkout checkout_cmd = runtime.run_action_calls[1].command - assert 'cd repo' in checkout_cmd + assert f'cd {expected_repo_path}' in checkout_cmd assert 'git checkout -b openhands-workspace-' in checkout_cmd + # Check that the third command sets the remote URL after clone + set_url_cmd = runtime.run_action_calls[2].command + assert f'cd {expected_repo_path}' in set_url_cmd + assert 'git remote set-url origin' in set_url_cmd + assert result == 'repo' @@ -403,23 +452,29 @@ async def test_clone_or_init_repo_gitlab_with_token(temp_dir, monkeypatch): result = await runtime.clone_or_init_repo(git_provider_tokens, 'owner/repo', None) - # Verify that git clone and checkout were called as separate commands - assert len(runtime.run_action_calls) == 2 + # Verify that git clone, checkout, and git remote URL update were called + assert len(runtime.run_action_calls) == 3 # clone, checkout, set-url assert isinstance(runtime.run_action_calls[0], CmdRunAction) assert isinstance(runtime.run_action_calls[1], CmdRunAction) + assert isinstance(runtime.run_action_calls[2], CmdRunAction) # Check that the first command is the git clone with the correct URL format with token clone_cmd = runtime.run_action_calls[0].command - assert ( - f'git clone https://oauth2:{gitlab_token}@gitlab.com/owner/repo.git repo' - in clone_cmd - ) + expected_repo_path = str(runtime.workspace_root / 'repo') + assert f'https://oauth2:{gitlab_token}@gitlab.com/owner/repo.git' in clone_cmd + assert expected_repo_path in clone_cmd # Check that the second command is the checkout checkout_cmd = runtime.run_action_calls[1].command - assert 'cd repo' in checkout_cmd + assert f'cd {expected_repo_path}' in checkout_cmd assert 'git checkout -b openhands-workspace-' in checkout_cmd + # Check that the third command sets the remote URL immediately after clone + set_url_cmd = runtime.run_action_calls[2].command + assert f'cd {expected_repo_path}' in set_url_cmd + assert 'git remote set-url origin' in set_url_cmd + assert gitlab_token in set_url_cmd + assert result == 'repo' @@ -437,18 +492,24 @@ async def test_clone_or_init_repo_with_branch(temp_dir, monkeypatch): mock_repo_and_patch(monkeypatch, provider=ProviderType.GITHUB) result = await runtime.clone_or_init_repo(None, 'owner/repo', 'feature-branch') - # Verify that git clone and checkout were called as separate commands - assert len(runtime.run_action_calls) == 2 + # Verify that git clone, checkout, and remote update were called + assert len(runtime.run_action_calls) == 3 # clone, checkout, set-url assert isinstance(runtime.run_action_calls[0], CmdRunAction) assert isinstance(runtime.run_action_calls[1], CmdRunAction) + assert isinstance(runtime.run_action_calls[2], CmdRunAction) # Check that the first command is the git clone clone_cmd = runtime.run_action_calls[0].command + expected_repo_path = str(runtime.workspace_root / 'repo') + assert 'git clone https://github.com/owner/repo.git' in clone_cmd + assert expected_repo_path in clone_cmd # Check that the second command contains the correct branch checkout checkout_cmd = runtime.run_action_calls[1].command - assert 'git clone https://github.com/owner/repo.git repo' in clone_cmd - assert 'cd repo' in checkout_cmd + assert f'cd {expected_repo_path}' in checkout_cmd assert 'git checkout feature-branch' in checkout_cmd + set_url_cmd = runtime.run_action_calls[2].command + assert f'cd {expected_repo_path}' in set_url_cmd + assert 'git remote set-url origin' in set_url_cmd assert 'git checkout -b' not in checkout_cmd # Should not create a new branch assert result == 'repo' diff --git a/tests/unit/server/routes/test_secrets_api.py b/tests/unit/server/routes/test_secrets_api.py index 36bddbb6b0..0f5bae19e9 100644 --- a/tests/unit/server/routes/test_secrets_api.py +++ b/tests/unit/server/routes/test_secrets_api.py @@ -14,7 +14,9 @@ from openhands.integrations.provider import ( ProviderToken, ProviderType, ) -from openhands.server.routes.secrets import app as secrets_app +from openhands.server.routes.secrets import ( + app as secrets_app, +) from openhands.storage import get_file_store from openhands.storage.data_models.user_secrets import UserSecrets from openhands.storage.secrets.file_secrets_store import FileSecretsStore