files.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. import os
  2. import tempfile
  3. from fastapi import (
  4. APIRouter,
  5. BackgroundTasks,
  6. HTTPException,
  7. Request,
  8. UploadFile,
  9. status,
  10. )
  11. from fastapi.responses import FileResponse, JSONResponse
  12. from pathspec import PathSpec
  13. from pathspec.patterns import GitWildMatchPattern
  14. from openhands.core.logger import openhands_logger as logger
  15. from openhands.events.action import (
  16. FileReadAction,
  17. FileWriteAction,
  18. )
  19. from openhands.events.observation import (
  20. ErrorObservation,
  21. FileReadObservation,
  22. FileWriteObservation,
  23. )
  24. from openhands.runtime.base import Runtime, RuntimeUnavailableError
  25. from openhands.server.file_config import (
  26. FILES_TO_IGNORE,
  27. MAX_FILE_SIZE_MB,
  28. is_extension_allowed,
  29. sanitize_filename,
  30. )
  31. from openhands.utils.async_utils import call_sync_from_async
  32. app = APIRouter(prefix='/api')
  33. @app.get('/list-files')
  34. async def list_files(request: Request, path: str | None = None):
  35. """List files in the specified path.
  36. This function retrieves a list of files from the agent's runtime file store,
  37. excluding certain system and hidden files/directories.
  38. To list files:
  39. ```sh
  40. curl http://localhost:3000/api/list-files
  41. ```
  42. Args:
  43. request (Request): The incoming request object.
  44. path (str, optional): The path to list files from. Defaults to None.
  45. Returns:
  46. list: A list of file names in the specified path.
  47. Raises:
  48. HTTPException: If there's an error listing the files.
  49. """
  50. if not request.state.conversation.runtime:
  51. return JSONResponse(
  52. status_code=status.HTTP_404_NOT_FOUND,
  53. content={'error': 'Runtime not yet initialized'},
  54. )
  55. runtime: Runtime = request.state.conversation.runtime
  56. try:
  57. file_list = await call_sync_from_async(runtime.list_files, path)
  58. except RuntimeUnavailableError as e:
  59. logger.error(f'Error listing files: {e}', exc_info=True)
  60. return JSONResponse(
  61. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  62. content={'error': f'Error listing files: {e}'},
  63. )
  64. if path:
  65. file_list = [os.path.join(path, f) for f in file_list]
  66. file_list = [f for f in file_list if f not in FILES_TO_IGNORE]
  67. async def filter_for_gitignore(file_list, base_path):
  68. gitignore_path = os.path.join(base_path, '.gitignore')
  69. try:
  70. read_action = FileReadAction(gitignore_path)
  71. observation = await call_sync_from_async(runtime.run_action, read_action)
  72. spec = PathSpec.from_lines(
  73. GitWildMatchPattern, observation.content.splitlines()
  74. )
  75. except Exception as e:
  76. logger.warning(e)
  77. return file_list
  78. file_list = [entry for entry in file_list if not spec.match_file(entry)]
  79. return file_list
  80. try:
  81. file_list = await filter_for_gitignore(file_list, '')
  82. except RuntimeUnavailableError as e:
  83. logger.error(f'Error filtering files: {e}', exc_info=True)
  84. return JSONResponse(
  85. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  86. content={'error': f'Error filtering files: {e}'},
  87. )
  88. return file_list
  89. @app.get('/select-file')
  90. async def select_file(file: str, request: Request):
  91. """Retrieve the content of a specified file.
  92. To select a file:
  93. ```sh
  94. curl http://localhost:3000/api/select-file?file=<file_path>
  95. ```
  96. Args:
  97. file (str): The path of the file to be retrieved.
  98. Expect path to be absolute inside the runtime.
  99. request (Request): The incoming request object.
  100. Returns:
  101. dict: A dictionary containing the file content.
  102. Raises:
  103. HTTPException: If there's an error opening the file.
  104. """
  105. runtime: Runtime = request.state.conversation.runtime
  106. file = os.path.join(runtime.config.workspace_mount_path_in_sandbox, file)
  107. read_action = FileReadAction(file)
  108. try:
  109. observation = await call_sync_from_async(runtime.run_action, read_action)
  110. except RuntimeUnavailableError as e:
  111. logger.error(f'Error opening file {file}: {e}', exc_info=True)
  112. return JSONResponse(
  113. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  114. content={'error': f'Error opening file: {e}'},
  115. )
  116. if isinstance(observation, FileReadObservation):
  117. content = observation.content
  118. return {'code': content}
  119. elif isinstance(observation, ErrorObservation):
  120. logger.error(f'Error opening file {file}: {observation}', exc_info=False)
  121. return JSONResponse(
  122. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  123. content={'error': f'Error opening file: {observation}'},
  124. )
  125. @app.post('/upload-files')
  126. async def upload_file(request: Request, files: list[UploadFile]):
  127. """Upload a list of files to the workspace.
  128. To upload a files:
  129. ```sh
  130. curl -X POST -F "file=@<file_path1>" -F "file=@<file_path2>" http://localhost:3000/api/upload-files
  131. ```
  132. Args:
  133. request (Request): The incoming request object.
  134. files (list[UploadFile]): A list of files to be uploaded.
  135. Returns:
  136. dict: A message indicating the success of the upload operation.
  137. Raises:
  138. HTTPException: If there's an error saving the files.
  139. """
  140. try:
  141. uploaded_files = []
  142. skipped_files = []
  143. for file in files:
  144. safe_filename = sanitize_filename(file.filename)
  145. file_contents = await file.read()
  146. if (
  147. MAX_FILE_SIZE_MB > 0
  148. and len(file_contents) > MAX_FILE_SIZE_MB * 1024 * 1024
  149. ):
  150. skipped_files.append(
  151. {
  152. 'name': safe_filename,
  153. 'reason': f'Exceeds maximum size limit of {MAX_FILE_SIZE_MB}MB',
  154. }
  155. )
  156. continue
  157. if not is_extension_allowed(safe_filename):
  158. skipped_files.append(
  159. {'name': safe_filename, 'reason': 'File type not allowed'}
  160. )
  161. continue
  162. # copy the file to the runtime
  163. with tempfile.TemporaryDirectory() as tmp_dir:
  164. tmp_file_path = os.path.join(tmp_dir, safe_filename)
  165. with open(tmp_file_path, 'wb') as tmp_file:
  166. tmp_file.write(file_contents)
  167. tmp_file.flush()
  168. runtime: Runtime = request.state.conversation.runtime
  169. try:
  170. await call_sync_from_async(
  171. runtime.copy_to,
  172. tmp_file_path,
  173. runtime.config.workspace_mount_path_in_sandbox,
  174. )
  175. except RuntimeUnavailableError as e:
  176. logger.error(
  177. f'Error saving file {safe_filename}: {e}', exc_info=True
  178. )
  179. return JSONResponse(
  180. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  181. content={'error': f'Error saving file: {e}'},
  182. )
  183. uploaded_files.append(safe_filename)
  184. response_content = {
  185. 'message': 'File upload process completed',
  186. 'uploaded_files': uploaded_files,
  187. 'skipped_files': skipped_files,
  188. }
  189. if not uploaded_files and skipped_files:
  190. return JSONResponse(
  191. status_code=status.HTTP_400_BAD_REQUEST,
  192. content={
  193. **response_content,
  194. 'error': 'No files were uploaded successfully',
  195. },
  196. )
  197. return JSONResponse(status_code=status.HTTP_200_OK, content=response_content)
  198. except Exception as e:
  199. logger.error(f'Error during file upload: {e}', exc_info=True)
  200. return JSONResponse(
  201. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  202. content={
  203. 'error': f'Error during file upload: {str(e)}',
  204. 'uploaded_files': [],
  205. 'skipped_files': [],
  206. },
  207. )
  208. @app.post('/save-file')
  209. async def save_file(request: Request):
  210. """Save a file to the agent's runtime file store.
  211. This endpoint allows saving a file when the agent is in a paused, finished,
  212. or awaiting user input state. It checks the agent's state before proceeding
  213. with the file save operation.
  214. Args:
  215. request (Request): The incoming FastAPI request object.
  216. Returns:
  217. JSONResponse: A JSON response indicating the success of the operation.
  218. Raises:
  219. HTTPException:
  220. - 403 error if the agent is not in an allowed state for editing.
  221. - 400 error if the file path or content is missing.
  222. - 500 error if there's an unexpected error during the save operation.
  223. """
  224. try:
  225. # Extract file path and content from the request
  226. data = await request.json()
  227. file_path = data.get('filePath')
  228. content = data.get('content')
  229. # Validate the presence of required data
  230. if not file_path or content is None:
  231. raise HTTPException(status_code=400, detail='Missing filePath or content')
  232. # Save the file to the agent's runtime file store
  233. runtime: Runtime = request.state.conversation.runtime
  234. file_path = os.path.join(
  235. runtime.config.workspace_mount_path_in_sandbox, file_path
  236. )
  237. write_action = FileWriteAction(file_path, content)
  238. try:
  239. observation = await call_sync_from_async(runtime.run_action, write_action)
  240. except RuntimeUnavailableError as e:
  241. logger.error(f'Error saving file: {e}', exc_info=True)
  242. return JSONResponse(
  243. status_code=500,
  244. content={'error': f'Error saving file: {e}'},
  245. )
  246. if isinstance(observation, FileWriteObservation):
  247. return JSONResponse(
  248. status_code=200, content={'message': 'File saved successfully'}
  249. )
  250. elif isinstance(observation, ErrorObservation):
  251. return JSONResponse(
  252. status_code=500,
  253. content={'error': f'Failed to save file: {observation}'},
  254. )
  255. else:
  256. return JSONResponse(
  257. status_code=500,
  258. content={'error': f'Unexpected observation: {observation}'},
  259. )
  260. except Exception as e:
  261. # Log the error and return a 500 response
  262. logger.error(f'Error saving file: {e}', exc_info=True)
  263. raise HTTPException(status_code=500, detail=f'Error saving file: {e}')
  264. @app.get('/zip-directory')
  265. async def zip_current_workspace(request: Request, background_tasks: BackgroundTasks):
  266. try:
  267. logger.debug('Zipping workspace')
  268. runtime: Runtime = request.state.conversation.runtime
  269. path = runtime.config.workspace_mount_path_in_sandbox
  270. try:
  271. zip_file = await call_sync_from_async(runtime.copy_from, path)
  272. except RuntimeUnavailableError as e:
  273. logger.error(f'Error zipping workspace: {e}', exc_info=True)
  274. return JSONResponse(
  275. status_code=500,
  276. content={'error': f'Error zipping workspace: {e}'},
  277. )
  278. response = FileResponse(
  279. path=zip_file,
  280. filename='workspace.zip',
  281. media_type='application/x-zip-compressed',
  282. )
  283. # This will execute after the response is sent (So the file is not deleted before being sent)
  284. background_tasks.add_task(zip_file.unlink)
  285. return response
  286. except Exception as e:
  287. logger.error(f'Error zipping workspace: {e}', exc_info=True)
  288. raise HTTPException(
  289. status_code=500,
  290. detail='Failed to zip workspace',
  291. )