Skip to content

Commit 1f958e8

Browse files
feat(box): add session workspace quota enforcement and SDK quota metadata
1 parent 4ab0d6c commit 1f958e8

2 files changed

Lines changed: 178 additions & 8 deletions

File tree

src/langbot/pkg/box/service.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
_INT_ADAPTER = pydantic.TypeAdapter(int)
2525
_UTC = _dt.timezone.utc
2626
_MAX_RECENT_ERRORS = 50
27+
_MIB = 1024 * 1024
2728

2829

2930
def _is_path_under(path: str, root: str) -> bool:
@@ -54,6 +55,7 @@ def __init__(
5455
self.allowed_host_mount_roots = self._load_allowed_host_mount_roots()
5556
self.default_host_workspace = self._load_default_host_workspace()
5657
self.profile = self._load_profile()
58+
self.workspace_quota_mb = self._load_workspace_quota_mb()
5759
self._recent_errors: collections.deque[dict] = collections.deque(maxlen=_MAX_RECENT_ERRORS)
5860
self._shutdown_task = None
5961
self._available = False
@@ -93,11 +95,22 @@ async def execute_spec_payload(
9395
f'query_id={query.query_id} '
9496
f'spec={json.dumps(self._summarize_spec(spec), ensure_ascii=False)}'
9597
)
98+
try:
99+
self._enforce_workspace_quota(spec, phase='before execution')
100+
except BoxError as exc:
101+
self._record_error(exc, query)
102+
raise
96103
try:
97104
result = await self.client.execute(spec)
98105
except BoxError as exc:
99106
self._record_error(exc, query)
100107
raise
108+
try:
109+
self._enforce_workspace_quota(spec, phase='after execution')
110+
except BoxError as exc:
111+
await self._cleanup_exceeded_session(spec)
112+
self._record_error(exc, query)
113+
raise
101114
self.ap.logger.info(
102115
'LangBot Box result: '
103116
f'query_id={query.query_id} '
@@ -141,6 +154,8 @@ def build_spec(self, spec_payload: dict, skip_host_mount_validation: bool = Fals
141154
spec_payload.setdefault('env', {})
142155
if spec_payload.get('host_path') in (None, '') and self.default_host_workspace is not None:
143156
spec_payload['host_path'] = self.default_host_workspace
157+
if spec_payload.get('workspace_quota_mb') in (None, '') and self.workspace_quota_mb is not None:
158+
spec_payload['workspace_quota_mb'] = self.workspace_quota_mb
144159

145160
self._apply_profile(spec_payload)
146161

@@ -241,6 +256,7 @@ def _summarize_spec(self, spec: BoxSpec) -> dict:
241256
'memory_mb': spec.memory_mb,
242257
'pids_limit': spec.pids_limit,
243258
'read_only_rootfs': spec.read_only_rootfs,
259+
'workspace_quota_mb': spec.workspace_quota_mb,
244260
'env_keys': sorted(spec.env.keys()),
245261
'cmd': cmd,
246262
}
@@ -292,6 +308,18 @@ def _load_default_host_workspace(self) -> str | None:
292308
default_host_workspace = os.path.join(self.shared_host_root, 'default')
293309
return os.path.realpath(os.path.abspath(default_host_workspace))
294310

311+
def _load_workspace_quota_mb(self) -> int | None:
312+
raw_value = _get_box_config(self.ap).get('workspace_quota_mb')
313+
if raw_value in (None, ''):
314+
return None
315+
try:
316+
value = _INT_ADAPTER.validate_python(raw_value)
317+
except pydantic.ValidationError as exc:
318+
raise BoxValidationError('workspace_quota_mb must be an integer greater than or equal to 0') from exc
319+
if value < 0:
320+
raise BoxValidationError('workspace_quota_mb must be greater than or equal to 0')
321+
return value
322+
295323
def _ensure_default_host_workspace(self):
296324
if self.default_host_workspace is None:
297325
return
@@ -356,6 +384,7 @@ def _apply_profile(self, params: dict):
356384
'memory_mb',
357385
'pids_limit',
358386
'read_only_rootfs',
387+
'workspace_quota_mb',
359388
)
360389

361390
for field in _PROFILE_FIELDS:
@@ -376,6 +405,58 @@ def _apply_profile(self, params: dict):
376405
if normalized_timeout > profile.max_timeout_sec:
377406
params['timeout_sec'] = profile.max_timeout_sec
378407

408+
def _get_workspace_size_bytes(self, root: str) -> int:
409+
total = 0
410+
411+
def _walk(path: str):
412+
nonlocal total
413+
try:
414+
with os.scandir(path) as entries:
415+
for entry in entries:
416+
try:
417+
if entry.is_symlink():
418+
total += entry.stat(follow_symlinks=False).st_size
419+
continue
420+
if entry.is_dir(follow_symlinks=False):
421+
_walk(entry.path)
422+
continue
423+
total += entry.stat(follow_symlinks=False).st_size
424+
except FileNotFoundError:
425+
continue
426+
except FileNotFoundError:
427+
return
428+
429+
_walk(root)
430+
return total
431+
432+
def _enforce_workspace_quota(self, spec: BoxSpec, *, phase: str) -> None:
433+
if spec.host_path is None or spec.workspace_quota_mb <= 0:
434+
return
435+
436+
host_path = os.path.realpath(spec.host_path)
437+
if not os.path.isdir(host_path):
438+
return
439+
440+
used_bytes = self._get_workspace_size_bytes(host_path)
441+
limit_bytes = spec.workspace_quota_mb * _MIB
442+
if used_bytes <= limit_bytes:
443+
return
444+
445+
raise BoxValidationError(
446+
f'workspace quota exceeded {phase}: '
447+
f'used={used_bytes} bytes limit={limit_bytes} bytes '
448+
f'host_path={host_path} session_id={spec.session_id}'
449+
)
450+
451+
async def _cleanup_exceeded_session(self, spec: BoxSpec) -> None:
452+
try:
453+
await self.client.delete_session(spec.session_id)
454+
except Exception as exc:
455+
self.ap.logger.warning(
456+
'Failed to clean up Box session after workspace quota was exceeded: '
457+
f'session_id={spec.session_id} error={exc}'
458+
)
459+
379460
# ── Observability ─────────────────────────────────────────────────
380461

381462
def _record_error(self, exc: Exception, query: pipeline_query.Query):

tests/unit_tests/box/test_box_service.py

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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
433462
async 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

Comments
 (0)