@@ -133,18 +133,21 @@ def make_app(
133133 allowed_host_mount_roots : list [str ] | None = None ,
134134 profile : str = 'default' ,
135135 shared_host_root : str = '' ,
136+ workspace_quota_mb : int | None = None ,
136137):
138+ box_config = {
139+ 'profile' : profile ,
140+ 'shared_host_root' : shared_host_root ,
141+ 'allowed_host_mount_roots' : allowed_host_mount_roots or [],
142+ 'default_host_workspace' : '' ,
143+ }
144+ if workspace_quota_mb is not None :
145+ box_config ['workspace_quota_mb' ] = workspace_quota_mb
146+
137147 return SimpleNamespace (
138148 logger = logger ,
139149 instance_config = SimpleNamespace (
140- data = {
141- 'box' : {
142- 'profile' : profile ,
143- 'shared_host_root' : shared_host_root ,
144- 'allowed_host_mount_roots' : allowed_host_mount_roots or [],
145- 'default_host_workspace' : '' ,
146- }
147- }
150+ data = {'box' : box_config }
148151 ),
149152 )
150153
@@ -429,6 +432,32 @@ async def exec(self, session: BoxSessionInfo, spec: BoxSpec) -> BoxExecutionResu
429432 )
430433
431434
435+ class FakeBackendWritingFiles (FakeBackend ):
436+ """Fake backend that writes files into the mounted host workspace during exec."""
437+
438+ def __init__ (self , logger : Mock , files_to_write : list [tuple [str , int ]]):
439+ super ().__init__ (logger )
440+ self ._files_to_write = files_to_write
441+
442+ async def exec (self , session : BoxSessionInfo , spec : BoxSpec ) -> BoxExecutionResult :
443+ self .exec_calls .append ((session .session_id , spec .cmd ))
444+ if session .host_path :
445+ for relative_path , size in self ._files_to_write :
446+ host_path = os .path .join (session .host_path , relative_path )
447+ os .makedirs (os .path .dirname (host_path ), exist_ok = True )
448+ with open (host_path , 'wb' ) as f :
449+ f .write (b'x' * size )
450+ return BoxExecutionResult (
451+ session_id = session .session_id ,
452+ backend_name = self .name ,
453+ status = BoxExecutionStatus .COMPLETED ,
454+ exit_code = 0 ,
455+ stdout = 'wrote files' ,
456+ stderr = '' ,
457+ duration_ms = 5 ,
458+ )
459+
460+
432461@pytest .mark .asyncio
433462async def test_truncate_short_output_unchanged ():
434463 logger = Mock ()
@@ -648,6 +677,64 @@ async def test_profile_default_applies_resource_limits():
648677 assert spec .memory_mb == profile .memory_mb
649678 assert spec .pids_limit == profile .pids_limit
650679 assert spec .read_only_rootfs == profile .read_only_rootfs
680+ assert spec .workspace_quota_mb == profile .workspace_quota_mb
681+
682+
683+ @pytest .mark .asyncio
684+ async def test_box_service_applies_workspace_quota_from_config (tmp_path ):
685+ logger = Mock ()
686+ backend = FakeBackend (logger )
687+ runtime = BoxRuntime (logger = logger , backends = [backend ], session_ttl_sec = 300 )
688+ host_dir = tmp_path / 'default-workspace'
689+ host_dir .mkdir ()
690+ app = make_app (logger , [str (tmp_path )], workspace_quota_mb = 32 )
691+ app .instance_config .data ['box' ]['default_host_workspace' ] = str (host_dir )
692+ service = BoxService (app , client = _InProcessBoxRuntimeClient (logger , runtime ))
693+
694+ await service .initialize ()
695+ await service .execute_tool ({'command' : 'echo hi' }, make_query (43 ))
696+
697+ assert backend .start_specs [0 ].workspace_quota_mb == 32
698+
699+
700+ @pytest .mark .asyncio
701+ async def test_box_service_rejects_execution_when_workspace_already_exceeds_quota (tmp_path ):
702+ logger = Mock ()
703+ backend = FakeBackend (logger )
704+ runtime = BoxRuntime (logger = logger , backends = [backend ], session_ttl_sec = 300 )
705+ host_dir = tmp_path / 'quota-workspace'
706+ host_dir .mkdir ()
707+ (host_dir / 'already-too-large.bin' ).write_bytes (b'x' * (2 * 1024 * 1024 ))
708+ app = make_app (logger , [str (tmp_path )], workspace_quota_mb = 1 )
709+ app .instance_config .data ['box' ]['default_host_workspace' ] = str (host_dir )
710+ service = BoxService (app , client = _InProcessBoxRuntimeClient (logger , runtime ))
711+
712+ await service .initialize ()
713+
714+ with pytest .raises (BoxValidationError , match = 'workspace quota exceeded before execution' ):
715+ await service .execute_tool ({'command' : 'echo hi' }, make_query (44 ))
716+
717+ assert backend .start_calls == []
718+
719+
720+ @pytest .mark .asyncio
721+ async def test_box_service_rejects_and_cleans_up_when_execution_exceeds_workspace_quota (tmp_path ):
722+ logger = Mock ()
723+ backend = FakeBackendWritingFiles (logger , files_to_write = [('output.bin' , 2 * 1024 * 1024 )])
724+ runtime = BoxRuntime (logger = logger , backends = [backend ], session_ttl_sec = 300 )
725+ host_dir = tmp_path / 'quota-workspace-post'
726+ host_dir .mkdir ()
727+ app = make_app (logger , [str (tmp_path )], workspace_quota_mb = 1 )
728+ app .instance_config .data ['box' ]['default_host_workspace' ] = str (host_dir )
729+ service = BoxService (app , client = _InProcessBoxRuntimeClient (logger , runtime ))
730+
731+ await service .initialize ()
732+
733+ with pytest .raises (BoxValidationError , match = 'workspace quota exceeded after execution' ):
734+ await service .execute_tool ({'command' : 'generate-output' }, make_query (45 ))
735+
736+ assert backend .start_calls == ['45' ]
737+ assert backend .stop_calls == ['45' ]
651738
652739
653740@pytest .mark .asyncio
@@ -695,6 +782,8 @@ def test_box_spec_validates_resource_limits():
695782 BoxSpec .model_validate ({'cmd' : 'echo' , 'session_id' : 's1' , 'memory_mb' : 10 })
696783 with pytest .raises (Exception ):
697784 BoxSpec .model_validate ({'cmd' : 'echo' , 'session_id' : 's1' , 'pids_limit' : 0 })
785+ with pytest .raises (Exception ):
786+ BoxSpec .model_validate ({'cmd' : 'echo' , 'session_id' : 's1' , 'workspace_quota_mb' : - 1 })
698787
699788
700789# ── Observability tests ───────────────────────────────────────────────
0 commit comments