@@ -211,75 +211,214 @@ def tear_down_observer(observer: Observer, _: AdkWebServer):
211211 ** extra_fast_api_args ,
212212 )
213213
214+ agents_base_path = (Path .cwd () / agents_dir ).resolve ()
215+
216+ def _get_app_root (app_name : str ) -> Path :
217+ if app_name in ("" , "." , ".." ):
218+ raise ValueError (f"Invalid app name: { app_name !r} " )
219+ if Path (app_name ).name != app_name or "\\ " in app_name :
220+ raise ValueError (f"Invalid app name: { app_name !r} " )
221+ app_root = (agents_base_path / app_name ).resolve ()
222+ if not app_root .is_relative_to (agents_base_path ):
223+ raise ValueError (f"Invalid app name: { app_name !r} " )
224+ return app_root
225+
226+ def _normalize_relative_path (path : str ) -> str :
227+ return path .replace ("\\ " , "/" ).lstrip ("/" )
228+
229+ def _has_parent_reference (path : str ) -> bool :
230+ return any (part == ".." for part in path .split ("/" ))
231+
232+ def _parse_upload_filename (filename : Optional [str ]) -> tuple [str , str ]:
233+ if not filename :
234+ raise ValueError ("Upload filename is missing." )
235+ filename = _normalize_relative_path (filename )
236+ if "/" not in filename :
237+ raise ValueError (f"Invalid upload filename: { filename !r} " )
238+ app_name , rel_path = filename .split ("/" , 1 )
239+ if not app_name or not rel_path :
240+ raise ValueError (f"Invalid upload filename: { filename !r} " )
241+ if rel_path .startswith ("/" ):
242+ raise ValueError (f"Absolute upload path rejected: { filename !r} " )
243+ if _has_parent_reference (rel_path ):
244+ raise ValueError (f"Path traversal rejected: { filename !r} " )
245+ return app_name , rel_path
246+
247+ def _parse_file_path (file_path : str ) -> str :
248+ file_path = _normalize_relative_path (file_path )
249+ if not file_path :
250+ raise ValueError ("file_path is missing." )
251+ if file_path .startswith ("/" ):
252+ raise ValueError (f"Absolute file_path rejected: { file_path !r} " )
253+ if _has_parent_reference (file_path ):
254+ raise ValueError (f"Path traversal rejected: { file_path !r} " )
255+ return file_path
256+
257+ def _resolve_under_dir (root_dir : Path , rel_path : str ) -> Path :
258+ file_path = root_dir / rel_path
259+ resolved_root_dir = root_dir .resolve ()
260+ resolved_file_path = file_path .resolve ()
261+ if not resolved_file_path .is_relative_to (resolved_root_dir ):
262+ raise ValueError (f"Path escapes root_dir: { rel_path !r} " )
263+ return file_path
264+
265+ def _get_tmp_agent_root (app_root : Path , app_name : str ) -> Path :
266+ tmp_agent_root = app_root / "tmp" / app_name
267+ resolved_tmp_agent_root = tmp_agent_root .resolve ()
268+ if not resolved_tmp_agent_root .is_relative_to (app_root ):
269+ raise ValueError (f"Invalid tmp path for app: { app_name !r} " )
270+ return tmp_agent_root
271+
272+ def copy_dir_contents (source_dir : Path , dest_dir : Path ) -> None :
273+ dest_dir .mkdir (parents = True , exist_ok = True )
274+ for source_path in source_dir .iterdir ():
275+ if source_path .name == "tmp" :
276+ continue
277+
278+ dest_path = dest_dir / source_path .name
279+ if source_path .is_dir ():
280+ if dest_path .exists () and dest_path .is_file ():
281+ dest_path .unlink ()
282+ shutil .copytree (source_path , dest_path , dirs_exist_ok = True )
283+ elif source_path .is_file ():
284+ if dest_path .exists () and dest_path .is_dir ():
285+ shutil .rmtree (dest_path )
286+ shutil .copy2 (source_path , dest_path )
287+
288+ def cleanup_tmp (app_name : str ) -> bool :
289+ try :
290+ app_root = _get_app_root (app_name )
291+ except ValueError as exc :
292+ logger .exception ("Error in cleanup_tmp: %s" , exc )
293+ return False
294+
295+ try :
296+ tmp_agent_root = _get_tmp_agent_root (app_root , app_name )
297+ except ValueError as exc :
298+ logger .exception ("Error in cleanup_tmp: %s" , exc )
299+ return False
300+
301+ try :
302+ shutil .rmtree (tmp_agent_root )
303+ except FileNotFoundError :
304+ pass
305+ except OSError as exc :
306+ logger .exception ("Error deleting tmp agent root: %s" , exc )
307+ return False
308+
309+ tmp_dir = app_root / "tmp"
310+ resolved_tmp_dir = tmp_dir .resolve ()
311+ if not resolved_tmp_dir .is_relative_to (app_root ):
312+ logger .error (
313+ "Refusing to delete tmp outside app_root: %s" , resolved_tmp_dir
314+ )
315+ return False
316+
317+ try :
318+ tmp_dir .rmdir ()
319+ except OSError :
320+ pass
321+
322+ return True
323+
324+ def ensure_tmp_exists (app_name : str ) -> bool :
325+ try :
326+ app_root = _get_app_root (app_name )
327+ except ValueError as exc :
328+ logger .exception ("Error in ensure_tmp_exists: %s" , exc )
329+ return False
330+
331+ if not app_root .is_dir ():
332+ return False
333+
334+ try :
335+ tmp_agent_root = _get_tmp_agent_root (app_root , app_name )
336+ except ValueError as exc :
337+ logger .exception ("Error in ensure_tmp_exists: %s" , exc )
338+ return False
339+
340+ if tmp_agent_root .exists ():
341+ return True
342+
343+ try :
344+ tmp_agent_root .mkdir (parents = True , exist_ok = True )
345+ copy_dir_contents (app_root , tmp_agent_root )
346+ except OSError as exc :
347+ logger .exception ("Error in ensure_tmp_exists: %s" , exc )
348+ return False
349+
350+ return True
351+
214352 @app .post ("/builder/save" , response_model_exclude_none = True )
215353 async def builder_build (
216354 files : list [UploadFile ], tmp : Optional [bool ] = False
217355 ) -> bool :
218- base_path = Path .cwd () / agents_dir
219- for file in files :
220- if not file .filename :
221- logger .exception ("Agent name is missing in the input files" )
222- return False
223- agent_name , filename = file .filename .split ("/" )
224- agent_dir = os .path .join (base_path , agent_name )
225- try :
226- # File name format: {app_name}/{agent_name}.yaml
227- if tmp :
228- agent_dir = os .path .join (agent_dir , "tmp/" + agent_name )
229- os .makedirs (agent_dir , exist_ok = True )
230- file_path = os .path .join (agent_dir , filename )
231- with open (file_path , "wb" ) as buffer :
356+ try :
357+ if tmp :
358+ app_names = set ()
359+ uploads = []
360+ for file in files :
361+ app_name , rel_path = _parse_upload_filename (file .filename )
362+ app_names .add (app_name )
363+ uploads .append ((rel_path , file ))
364+
365+ if len (app_names ) != 1 :
366+ logger .error (
367+ "Exactly one app name is required, found: %s" , sorted (app_names )
368+ )
369+ return False
370+
371+ app_name = next (iter (app_names ))
372+ app_root = _get_app_root (app_name )
373+ tmp_agent_root = _get_tmp_agent_root (app_root , app_name )
374+ tmp_agent_root .mkdir (parents = True , exist_ok = True )
375+
376+ for rel_path , file in uploads :
377+ destination_path = _resolve_under_dir (tmp_agent_root , rel_path )
378+ destination_path .parent .mkdir (parents = True , exist_ok = True )
379+ with destination_path .open ("wb" ) as buffer :
232380 shutil .copyfileobj (file .file , buffer )
233381
234- else :
235- source_dir = os . path . join ( agent_dir , "tmp/" + agent_name )
236- destination_dir = agent_dir
237- for item in os . listdir ( source_dir ):
238- source_item = os . path . join ( source_dir , item )
239- destination_item = os . path . join ( destination_dir , item )
240- if os . path . isdir ( source_item ):
241- shutil . copytree ( source_item , destination_item , dirs_exist_ok = True )
242- # Check if the item is a file
243- elif os . path . isfile ( source_item ) :
244- shutil . copy2 ( source_item , destination_item )
245- except Exception as e :
246- logger . exception ( "Error in builder_build: %s" , e )
382+ return True
383+
384+ app_names = set ()
385+ uploads = []
386+ for file in files :
387+ app_name , rel_path = _parse_upload_filename ( file . filename )
388+ app_names . add ( app_name )
389+ uploads . append (( rel_path , file ) )
390+
391+ if len ( app_names ) != 1 :
392+ logger . error (
393+ "Exactly one app name is required, found: %s" , sorted ( app_names )
394+ )
247395 return False
248396
249- return True
397+ app_name = next (iter (app_names ))
398+ app_root = _get_app_root (app_name )
399+ app_root .mkdir (parents = True , exist_ok = True )
400+
401+ tmp_agent_root = _get_tmp_agent_root (app_root , app_name )
402+ if tmp_agent_root .is_dir ():
403+ copy_dir_contents (tmp_agent_root , app_root )
404+
405+ for rel_path , file in uploads :
406+ destination_path = _resolve_under_dir (app_root , rel_path )
407+ destination_path .parent .mkdir (parents = True , exist_ok = True )
408+ with destination_path .open ("wb" ) as buffer :
409+ shutil .copyfileobj (file .file , buffer )
410+
411+ return cleanup_tmp (app_name )
412+ except ValueError as exc :
413+ logger .exception ("Error in builder_build: %s" , exc )
414+ return False
415+ except OSError as exc :
416+ logger .exception ("Error in builder_build: %s" , exc )
417+ return False
250418
251419 @app .post ("/builder/app/{app_name}/cancel" , response_model_exclude_none = True )
252420 async def builder_cancel (app_name : str ) -> bool :
253- base_path = Path .cwd () / agents_dir
254- agent_dir = os .path .join (base_path , app_name )
255- destination_dir = os .path .join (agent_dir , "tmp/" + app_name )
256- source_dir = agent_dir
257- source_items = set (os .listdir (source_dir ))
258- try :
259- for item in os .listdir (destination_dir ):
260- if item in source_items :
261- continue
262- # If it doesn't exist in the source, delete it from the destination
263- item_path = os .path .join (destination_dir , item )
264- if os .path .isdir (item_path ):
265- shutil .rmtree (item_path )
266- elif os .path .isfile (item_path ):
267- os .remove (item_path )
268-
269- for item in os .listdir (source_dir ):
270- source_item = os .path .join (source_dir , item )
271- destination_item = os .path .join (destination_dir , item )
272- if item == "tmp" and os .path .isdir (source_item ):
273- continue
274- if os .path .isdir (source_item ):
275- shutil .copytree (source_item , destination_item , dirs_exist_ok = True )
276- # Check if the item is a file
277- elif os .path .isfile (source_item ):
278- shutil .copy2 (source_item , destination_item )
279- except Exception as e :
280- logger .exception ("Error in builder_build: %s" , e )
281- return False
282- return True
421+ return cleanup_tmp (app_name )
283422
284423 @app .get (
285424 "/builder/app/{app_name}" ,
@@ -291,34 +430,42 @@ async def get_agent_builder(
291430 file_path : Optional [str ] = None ,
292431 tmp : Optional [bool ] = False ,
293432 ):
294- base_path = Path .cwd () / agents_dir
295- agent_dir = base_path / app_name
433+ try :
434+ app_root = _get_app_root (app_name )
435+ except ValueError as exc :
436+ logger .exception ("Error in get_agent_builder: %s" , exc )
437+ return ""
438+
439+ agent_dir = app_root
296440 if tmp :
297- agent_dir = agent_dir / "tmp"
298- agent_dir = agent_dir / app_name
299- if not file_path :
300- file_name = "root_agent.yaml"
301- root_file_path = agent_dir / file_name
302- if not root_file_path .is_file ():
441+ if not ensure_tmp_exists (app_name ):
303442 return ""
304- else :
305- return FileResponse (
306- path = root_file_path ,
307- media_type = "application/x-yaml" ,
308- filename = "${app_name}.yaml" ,
309- headers = {"Cache-Control" : "no-store" },
310- )
443+ agent_dir = app_root / "tmp" / app_name
444+
445+ if not file_path :
446+ rel_path = "root_agent.yaml"
311447 else :
312- agent_file_path = agent_dir / file_path
313- if not agent_file_path .is_file ():
448+ try :
449+ rel_path = _parse_file_path (file_path )
450+ except ValueError as exc :
451+ logger .exception ("Error in get_agent_builder: %s" , exc )
314452 return ""
315- else :
316- return FileResponse (
317- path = agent_file_path ,
318- media_type = "application/x-yaml" ,
319- filename = file_path ,
320- headers = {"Cache-Control" : "no-store" },
321- )
453+
454+ try :
455+ agent_file_path = _resolve_under_dir (agent_dir , rel_path )
456+ except ValueError as exc :
457+ logger .exception ("Error in get_agent_builder: %s" , exc )
458+ return ""
459+
460+ if not agent_file_path .is_file ():
461+ return ""
462+
463+ return FileResponse (
464+ path = agent_file_path ,
465+ media_type = "application/x-yaml" ,
466+ filename = file_path or f"{ app_name } .yaml" ,
467+ headers = {"Cache-Control" : "no-store" },
468+ )
322469
323470 if a2a :
324471 from a2a .server .apps import A2AStarletteApplication
0 commit comments