| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341 |
- 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.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, RuntimeUnavailableError
- 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')
- @app.get('/list-files')
- async def list_files(request: Request, 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/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 RuntimeUnavailableError as e:
- logger.error(f'Error listing files: {e}', exc_info=True)
- 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 RuntimeUnavailableError as e:
- logger.error(f'Error filtering files: {e}', exc_info=True)
- 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/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 RuntimeUnavailableError as e:
- logger.error(f'Error opening file {file}: {e}', exc_info=True)
- 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}', exc_info=False)
- 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, 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/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
- # copy the file to the runtime
- 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 RuntimeUnavailableError as e:
- logger.error(
- f'Error saving file {safe_filename}: {e}', exc_info=True
- )
- 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}', exc_info=True)
- 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:
- # Extract file path and content from the request
- data = await request.json()
- file_path = data.get('filePath')
- content = data.get('content')
- # Validate the presence of required data
- if not file_path or content is None:
- raise HTTPException(status_code=400, detail='Missing filePath or content')
- # Save the file to the agent's runtime file store
- 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 RuntimeUnavailableError as e:
- logger.error(f'Error saving file: {e}', exc_info=True)
- 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:
- # Log the error and return a 500 response
- logger.error(f'Error saving file: {e}', exc_info=True)
- raise HTTPException(status_code=500, detail=f'Error saving file: {e}')
- @app.get('/zip-directory')
- async def zip_current_workspace(request: Request, 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 RuntimeUnavailableError as e:
- logger.error(f'Error zipping workspace: {e}', exc_info=True)
- 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',
- )
- # This will execute after the response is sent (So the file is not deleted before being sent)
- background_tasks.add_task(zip_file.unlink)
- return response
- except Exception as e:
- logger.error(f'Error zipping workspace: {e}', exc_info=True)
- raise HTTPException(
- status_code=500,
- detail='Failed to zip workspace',
- )
|