| import os
|
| import tempfile
|
|
|
| from fastapi import (
|
| APIRouter,
|
| BackgroundTasks,
|
| HTTPException,
|
| Request,
|
| UploadFile,
|
| status,
|
| )
|
| from fastapi.responses import FileResponse, JSONResponse
|
| from pathspec import PathSpec
|
| from pathspec.patterns import GitWildMatchPattern
|
|
|
| from openhands.core.exceptions import AgentRuntimeUnavailableError
|
| from openhands.core.logger import openhands_logger as logger
|
| from openhands.events.action import (
|
| FileReadAction,
|
| FileWriteAction,
|
| )
|
| from openhands.events.observation import (
|
| ErrorObservation,
|
| FileReadObservation,
|
| FileWriteObservation,
|
| )
|
| from openhands.runtime.base import Runtime
|
| from openhands.server.file_config import (
|
| FILES_TO_IGNORE,
|
| MAX_FILE_SIZE_MB,
|
| is_extension_allowed,
|
| sanitize_filename,
|
| )
|
| from openhands.utils.async_utils import call_sync_from_async
|
|
|
| app = APIRouter(prefix='/api/conversations/{conversation_id}')
|
|
|
|
|
| @app.get('/list-files')
|
| async def list_files(request: Request, conversation_id: str, path: str | None = None):
|
| """List files in the specified path.
|
|
|
| This function retrieves a list of files from the agent's runtime file store,
|
| excluding certain system and hidden files/directories.
|
|
|
| To list files:
|
| ```sh
|
| curl http://localhost:3000/api/conversations/{conversation_id}/list-files
|
| ```
|
|
|
| Args:
|
| request (Request): The incoming request object.
|
| path (str, optional): The path to list files from. Defaults to None.
|
|
|
| Returns:
|
| list: A list of file names in the specified path.
|
|
|
| Raises:
|
| HTTPException: If there's an error listing the files.
|
| """
|
| if not request.state.conversation.runtime:
|
| return JSONResponse(
|
| status_code=status.HTTP_404_NOT_FOUND,
|
| content={'error': 'Runtime not yet initialized'},
|
| )
|
|
|
| runtime: Runtime = request.state.conversation.runtime
|
| try:
|
| file_list = await call_sync_from_async(runtime.list_files, path)
|
| except AgentRuntimeUnavailableError as e:
|
| logger.error(f'Error listing files: {e}')
|
| return JSONResponse(
|
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| content={'error': f'Error listing files: {e}'},
|
| )
|
| if path:
|
| file_list = [os.path.join(path, f) for f in file_list]
|
|
|
| file_list = [f for f in file_list if f not in FILES_TO_IGNORE]
|
|
|
| async def filter_for_gitignore(file_list, base_path):
|
| gitignore_path = os.path.join(base_path, '.gitignore')
|
| try:
|
| read_action = FileReadAction(gitignore_path)
|
| observation = await call_sync_from_async(runtime.run_action, read_action)
|
| spec = PathSpec.from_lines(
|
| GitWildMatchPattern, observation.content.splitlines()
|
| )
|
| except Exception as e:
|
| logger.warning(e)
|
| return file_list
|
| file_list = [entry for entry in file_list if not spec.match_file(entry)]
|
| return file_list
|
|
|
| try:
|
| file_list = await filter_for_gitignore(file_list, '')
|
| except AgentRuntimeUnavailableError as e:
|
| logger.error(f'Error filtering files: {e}')
|
| return JSONResponse(
|
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| content={'error': f'Error filtering files: {e}'},
|
| )
|
|
|
| return file_list
|
|
|
|
|
| @app.get('/select-file')
|
| async def select_file(file: str, request: Request):
|
| """Retrieve the content of a specified file.
|
|
|
| To select a file:
|
| ```sh
|
| curl http://localhost:3000/api/conversations/{conversation_id}select-file?file=<file_path>
|
| ```
|
|
|
| Args:
|
| file (str): The path of the file to be retrieved.
|
| Expect path to be absolute inside the runtime.
|
| request (Request): The incoming request object.
|
|
|
| Returns:
|
| dict: A dictionary containing the file content.
|
|
|
| Raises:
|
| HTTPException: If there's an error opening the file.
|
| """
|
| runtime: Runtime = request.state.conversation.runtime
|
|
|
| file = os.path.join(runtime.config.workspace_mount_path_in_sandbox, file)
|
| read_action = FileReadAction(file)
|
| try:
|
| observation = await call_sync_from_async(runtime.run_action, read_action)
|
| except AgentRuntimeUnavailableError as e:
|
| logger.error(f'Error opening file {file}: {e}')
|
| return JSONResponse(
|
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| content={'error': f'Error opening file: {e}'},
|
| )
|
|
|
| if isinstance(observation, FileReadObservation):
|
| content = observation.content
|
| return {'code': content}
|
| elif isinstance(observation, ErrorObservation):
|
| logger.error(f'Error opening file {file}: {observation}')
|
| return JSONResponse(
|
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| content={'error': f'Error opening file: {observation}'},
|
| )
|
|
|
|
|
| @app.post('/upload-files')
|
| async def upload_file(request: Request, conversation_id: str, files: list[UploadFile]):
|
| """Upload a list of files to the workspace.
|
|
|
| To upload a files:
|
| ```sh
|
| curl -X POST -F "file=@<file_path1>" -F "file=@<file_path2>" http://localhost:3000/api/conversations/{conversation_id}/upload-files
|
| ```
|
|
|
| Args:
|
| request (Request): The incoming request object.
|
| files (list[UploadFile]): A list of files to be uploaded.
|
|
|
| Returns:
|
| dict: A message indicating the success of the upload operation.
|
|
|
| Raises:
|
| HTTPException: If there's an error saving the files.
|
| """
|
| try:
|
| uploaded_files = []
|
| skipped_files = []
|
| for file in files:
|
| safe_filename = sanitize_filename(file.filename)
|
| file_contents = await file.read()
|
|
|
| if (
|
| MAX_FILE_SIZE_MB > 0
|
| and len(file_contents) > MAX_FILE_SIZE_MB * 1024 * 1024
|
| ):
|
| skipped_files.append(
|
| {
|
| 'name': safe_filename,
|
| 'reason': f'Exceeds maximum size limit of {MAX_FILE_SIZE_MB}MB',
|
| }
|
| )
|
| continue
|
|
|
| if not is_extension_allowed(safe_filename):
|
| skipped_files.append(
|
| {'name': safe_filename, 'reason': 'File type not allowed'}
|
| )
|
| continue
|
|
|
|
|
| with tempfile.TemporaryDirectory() as tmp_dir:
|
| tmp_file_path = os.path.join(tmp_dir, safe_filename)
|
| with open(tmp_file_path, 'wb') as tmp_file:
|
| tmp_file.write(file_contents)
|
| tmp_file.flush()
|
|
|
| runtime: Runtime = request.state.conversation.runtime
|
| try:
|
| await call_sync_from_async(
|
| runtime.copy_to,
|
| tmp_file_path,
|
| runtime.config.workspace_mount_path_in_sandbox,
|
| )
|
| except AgentRuntimeUnavailableError as e:
|
| logger.error(f'Error saving file {safe_filename}: {e}')
|
| return JSONResponse(
|
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| content={'error': f'Error saving file: {e}'},
|
| )
|
| uploaded_files.append(safe_filename)
|
|
|
| response_content = {
|
| 'message': 'File upload process completed',
|
| 'uploaded_files': uploaded_files,
|
| 'skipped_files': skipped_files,
|
| }
|
|
|
| if not uploaded_files and skipped_files:
|
| return JSONResponse(
|
| status_code=status.HTTP_400_BAD_REQUEST,
|
| content={
|
| **response_content,
|
| 'error': 'No files were uploaded successfully',
|
| },
|
| )
|
|
|
| return JSONResponse(status_code=status.HTTP_200_OK, content=response_content)
|
|
|
| except Exception as e:
|
| logger.error(f'Error during file upload: {e}')
|
| return JSONResponse(
|
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| content={
|
| 'error': f'Error during file upload: {str(e)}',
|
| 'uploaded_files': [],
|
| 'skipped_files': [],
|
| },
|
| )
|
|
|
|
|
| @app.post('/save-file')
|
| async def save_file(request: Request):
|
| """Save a file to the agent's runtime file store.
|
|
|
| This endpoint allows saving a file when the agent is in a paused, finished,
|
| or awaiting user input state. It checks the agent's state before proceeding
|
| with the file save operation.
|
|
|
| Args:
|
| request (Request): The incoming FastAPI request object.
|
|
|
| Returns:
|
| JSONResponse: A JSON response indicating the success of the operation.
|
|
|
| Raises:
|
| HTTPException:
|
| - 403 error if the agent is not in an allowed state for editing.
|
| - 400 error if the file path or content is missing.
|
| - 500 error if there's an unexpected error during the save operation.
|
| """
|
| try:
|
|
|
| data = await request.json()
|
| file_path = data.get('filePath')
|
| content = data.get('content')
|
|
|
|
|
| if not file_path or content is None:
|
| raise HTTPException(status_code=400, detail='Missing filePath or content')
|
|
|
|
|
| runtime: Runtime = request.state.conversation.runtime
|
| file_path = os.path.join(
|
| runtime.config.workspace_mount_path_in_sandbox, file_path
|
| )
|
| write_action = FileWriteAction(file_path, content)
|
| try:
|
| observation = await call_sync_from_async(runtime.run_action, write_action)
|
| except AgentRuntimeUnavailableError as e:
|
| logger.error(f'Error saving file: {e}')
|
| return JSONResponse(
|
| status_code=500,
|
| content={'error': f'Error saving file: {e}'},
|
| )
|
|
|
| if isinstance(observation, FileWriteObservation):
|
| return JSONResponse(
|
| status_code=200, content={'message': 'File saved successfully'}
|
| )
|
| elif isinstance(observation, ErrorObservation):
|
| return JSONResponse(
|
| status_code=500,
|
| content={'error': f'Failed to save file: {observation}'},
|
| )
|
| else:
|
| return JSONResponse(
|
| status_code=500,
|
| content={'error': f'Unexpected observation: {observation}'},
|
| )
|
| except Exception as e:
|
|
|
| logger.error(f'Error saving file: {e}')
|
| raise HTTPException(status_code=500, detail=f'Error saving file: {e}')
|
|
|
|
|
| @app.get('/zip-directory')
|
| async def zip_current_workspace(
|
| request: Request, conversation_id: str, background_tasks: BackgroundTasks
|
| ):
|
| try:
|
| logger.debug('Zipping workspace')
|
| runtime: Runtime = request.state.conversation.runtime
|
| path = runtime.config.workspace_mount_path_in_sandbox
|
| try:
|
| zip_file = await call_sync_from_async(runtime.copy_from, path)
|
| except AgentRuntimeUnavailableError as e:
|
| logger.error(f'Error zipping workspace: {e}')
|
| return JSONResponse(
|
| status_code=500,
|
| content={'error': f'Error zipping workspace: {e}'},
|
| )
|
| response = FileResponse(
|
| path=zip_file,
|
| filename='workspace.zip',
|
| media_type='application/x-zip-compressed',
|
| )
|
|
|
|
|
| background_tasks.add_task(zip_file.unlink)
|
|
|
| return response
|
| except Exception as e:
|
| logger.error(f'Error zipping workspace: {e}')
|
| raise HTTPException(
|
| status_code=500,
|
| detail='Failed to zip workspace',
|
| )
|
|
|