fix: use project_dir consistently for workspace.working_dir, setup.sh, and git hooks (#13329)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Xingyao Wang
2026-03-11 15:26:34 +08:00
committed by GitHub
parent db40eb1e94
commit 53bb82fe2e
5 changed files with 201 additions and 40 deletions

View File

@@ -49,6 +49,7 @@ from openhands.app_server.app_conversation.app_conversation_service import (
)
from openhands.app_server.app_conversation.app_conversation_service_base import (
AppConversationServiceBase,
get_project_dir,
)
from openhands.app_server.app_conversation.app_conversation_start_task_service import (
AppConversationStartTaskService,
@@ -540,10 +541,13 @@ async def get_conversation_skills(
# Prefer the shared loader to avoid duplication; otherwise return empty list.
all_skills: list = []
if isinstance(app_conversation_service, AppConversationServiceBase):
project_dir = get_project_dir(
sandbox_spec.working_dir, conversation.selected_repository
)
all_skills = await app_conversation_service.load_and_merge_all_skills(
sandbox,
conversation.selected_repository,
sandbox_spec.working_dir,
project_dir,
agent_server_url,
)

View File

@@ -47,6 +47,40 @@ PRE_COMMIT_HOOK = '.git/hooks/pre-commit'
PRE_COMMIT_LOCAL = '.git/hooks/pre-commit.local'
def get_project_dir(
working_dir: str,
selected_repository: str | None = None,
) -> str:
"""Get the project root directory for a conversation.
When a repository is selected, the project root is the cloned repo directory
at {working_dir}/{repo_name}. This is the directory that contains the
`.openhands/` configuration (setup.sh, pre-commit.sh, skills/, etc.).
Without a repository, the project root is the working_dir itself.
This must be used consistently for ALL features that depend on the project root:
- workspace.working_dir (terminal CWD, file editor root, etc.)
- .openhands/setup.sh execution
- .openhands/pre-commit.sh (git hooks setup)
- .openhands/skills/ (project skills)
- PLAN.md path
Args:
working_dir: Base working directory path in the sandbox
(e.g., '/workspace/project' from sandbox_spec)
selected_repository: Repository name (e.g., 'OpenHands/software-agent-sdk')
If provided, the repo name is appended to working_dir.
Returns:
The project root directory path.
"""
if selected_repository:
repo_name = selected_repository.split('/')[-1]
return f'{working_dir}/{repo_name}'
return working_dir
@dataclass
class AppConversationServiceBase(AppConversationService, ABC):
"""App Conversation service which adds git specific functionality.
@@ -61,7 +95,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
self,
sandbox: SandboxInfo,
selected_repository: str | None,
working_dir: str,
project_dir: str,
agent_server_url: str,
) -> list[Skill]:
"""Load skills from all sources via the agent-server.
@@ -77,7 +111,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
Args:
sandbox: SandboxInfo containing exposed URLs and agent-server URL
selected_repository: Repository name or None
working_dir: Working directory path
project_dir: Project root directory (resolved via get_project_dir).
agent_server_url: Agent-server URL (required)
Returns:
@@ -96,12 +130,6 @@ class AppConversationServiceBase(AppConversationService, ABC):
# Build sandbox config (exposed URLs)
sandbox_config = build_sandbox_config(sandbox)
# Determine project directory for project skills
project_dir = working_dir
if selected_repository:
repo_name = selected_repository.split('/')[-1]
project_dir = f'{working_dir}/{repo_name}'
# Single API call to agent-server for ALL skills
all_skills = await load_skills_from_agent_server(
agent_server_url=agent_server_url,
@@ -180,24 +208,25 @@ class AppConversationServiceBase(AppConversationService, ABC):
agent: Agent,
remote_workspace: AsyncRemoteWorkspace,
selected_repository: str | None,
working_dir: str,
project_dir: str,
):
"""Load all skills and update agent with them.
Args:
agent: The agent to update
remote_workspace: AsyncRemoteWorkspace for loading repo skills
selected_repository: Repository name or None
working_dir: Working directory path
selected_repository: Repository name or None (used for org config)
project_dir: Project root directory (already resolved via get_project_dir).
Returns:
Updated agent with skills loaded into context
"""
# Load and merge all skills
# Extract agent_server_url from remote_workspace host
agent_server_url = remote_workspace.host
all_skills = await self.load_and_merge_all_skills(
sandbox, selected_repository, working_dir, agent_server_url
sandbox,
selected_repository,
project_dir,
agent_server_url,
)
# Update agent with skills
@@ -216,20 +245,27 @@ class AppConversationServiceBase(AppConversationService, ABC):
yield task
await self.clone_or_init_git_repo(task, workspace)
# Compute the project root — the cloned repo directory when a repo is
# selected, or the sandbox working_dir otherwise. This must be used
# for all .openhands/ features (setup.sh, pre-commit.sh, skills).
project_dir = get_project_dir(
workspace.working_dir, task.request.selected_repository
)
task.status = AppConversationStartTaskStatus.RUNNING_SETUP_SCRIPT
yield task
await self.maybe_run_setup_script(workspace)
await self.maybe_run_setup_script(workspace, project_dir)
task.status = AppConversationStartTaskStatus.SETTING_UP_GIT_HOOKS
yield task
await self.maybe_setup_git_hooks(workspace)
await self.maybe_setup_git_hooks(workspace, project_dir)
task.status = AppConversationStartTaskStatus.SETTING_UP_SKILLS
yield task
await self.load_and_merge_all_skills(
sandbox,
task.request.selected_repository,
workspace.working_dir,
project_dir,
agent_server_url,
)
@@ -334,26 +370,35 @@ class AppConversationServiceBase(AppConversationService, ABC):
async def maybe_run_setup_script(
self,
workspace: AsyncRemoteWorkspace,
project_dir: str,
):
"""Run .openhands/setup.sh if it exists in the workspace or repository."""
setup_script = workspace.working_dir + '/.openhands/setup.sh'
"""Run .openhands/setup.sh if it exists in the project root.
Args:
workspace: Remote workspace for command execution.
project_dir: Project root directory (repo root when a repo is selected).
"""
setup_script = project_dir + '/.openhands/setup.sh'
await workspace.execute_command(
f'chmod +x {setup_script} && source {setup_script}', timeout=600
f'chmod +x {setup_script} && source {setup_script}',
cwd=project_dir,
timeout=600,
)
# TODO: Does this need to be done?
# Add the action to the event stream as an ENVIRONMENT event
# source = EventSource.ENVIRONMENT
# self.event_stream.add_event(action, source)
async def maybe_setup_git_hooks(
self,
workspace: AsyncRemoteWorkspace,
project_dir: str,
):
"""Set up git hooks if .openhands/pre-commit.sh exists in the workspace or repository."""
"""Set up git hooks if .openhands/pre-commit.sh exists in the project root.
Args:
workspace: Remote workspace for command execution.
project_dir: Project root directory (repo root when a repo is selected).
"""
command = 'mkdir -p .git/hooks && chmod +x .openhands/pre-commit.sh'
result = await workspace.execute_command(command, workspace.working_dir)
result = await workspace.execute_command(command, project_dir)
if result.exit_code:
return
@@ -369,9 +414,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
f'mv {PRE_COMMIT_HOOK} {PRE_COMMIT_LOCAL} &&'
f'chmod +x {PRE_COMMIT_LOCAL}'
)
result = await workspace.execute_command(
command, workspace.working_dir
)
result = await workspace.execute_command(command, project_dir)
if result.exit_code != 0:
_logger.error(
f'Failed to preserve existing pre-commit hook: {result.stderr}',

View File

@@ -41,6 +41,7 @@ from openhands.app_server.app_conversation.app_conversation_service import (
)
from openhands.app_server.app_conversation.app_conversation_service_base import (
AppConversationServiceBase,
get_project_dir,
)
from openhands.app_server.app_conversation.app_conversation_start_task_service import (
AppConversationStartTaskService,
@@ -1227,7 +1228,12 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
5. Passing plugins to the agent server for remote plugin loading
"""
user = await self.user_context.get_user_info()
workspace = LocalWorkspace(working_dir=working_dir)
# Compute the project root — this is the repo directory when a repo is
# selected, or the sandbox working_dir otherwise. All tools, hooks,
# setup scripts, and plan paths must use this consistently.
project_dir = get_project_dir(working_dir, selected_repository)
workspace = LocalWorkspace(working_dir=project_dir)
# Set up secrets for all git providers
secrets = await self._setup_secrets_for_git_providers(user)
@@ -1244,7 +1250,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
user.condenser_max_size,
secrets=secrets,
git_provider=git_provider,
working_dir=working_dir,
working_dir=project_dir,
)
# Finalize and return the conversation request
@@ -1258,7 +1264,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
sandbox,
remote_workspace,
selected_repository,
working_dir,
project_dir,
plugins=plugins,
)