mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 12:40:59 +00:00
feat(tenancy): add Workspace multi-tenant foundation (#2353)
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
@@ -15,6 +16,7 @@ from langbot_plugin.box.backend import BaseSandboxBackend
|
||||
from langbot_plugin.box.client import BoxRuntimeClient, ActionRPCBoxClient
|
||||
from langbot_plugin.box.errors import (
|
||||
BoxBackendUnavailableError,
|
||||
BoxError,
|
||||
BoxSessionConflictError,
|
||||
BoxSessionNotFoundError,
|
||||
BoxValidationError,
|
||||
@@ -30,9 +32,27 @@ from langbot_plugin.box.models import (
|
||||
BoxSpec,
|
||||
)
|
||||
from langbot_plugin.box.runtime import BoxRuntime
|
||||
from langbot_plugin.box.security import (
|
||||
BOX_CONTROL_TOKEN_HEADER,
|
||||
BOX_INSTANCE_HEADER,
|
||||
BOX_PLACEMENT_GENERATION_HEADER,
|
||||
BOX_WORKSPACE_HEADER,
|
||||
)
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.box.service import BoxService
|
||||
|
||||
_UTC = dt.timezone.utc
|
||||
_CONTEXT = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
_ACTION_CONTEXT = ActionContext(
|
||||
instance_uuid=_CONTEXT.instance_uuid,
|
||||
workspace_uuid=_CONTEXT.workspace_uuid,
|
||||
placement_generation=_CONTEXT.placement_generation,
|
||||
)
|
||||
|
||||
|
||||
class _InProcessBoxRuntimeClient(BoxRuntimeClient):
|
||||
@@ -44,42 +64,63 @@ class _InProcessBoxRuntimeClient(BoxRuntimeClient):
|
||||
async def initialize(self):
|
||||
await self._runtime.initialize()
|
||||
|
||||
async def execute(self, spec):
|
||||
async def execute(self, spec, *, action_context=None):
|
||||
return await self._runtime.execute(spec)
|
||||
|
||||
async def shutdown(self):
|
||||
await self._runtime.shutdown()
|
||||
|
||||
async def get_status(self):
|
||||
async def get_status(self, *, action_context=None):
|
||||
return await self._runtime.get_status()
|
||||
|
||||
async def get_sessions(self):
|
||||
async def get_sessions(self, *, action_context=None):
|
||||
return self._runtime.get_sessions()
|
||||
|
||||
async def get_backend_info(self):
|
||||
return await self._runtime.get_backend_info()
|
||||
|
||||
async def delete_session(self, session_id):
|
||||
async def delete_session(self, session_id, *, action_context=None):
|
||||
await self._runtime.delete_session(session_id)
|
||||
|
||||
async def create_session(self, spec):
|
||||
async def create_session(self, spec, *, action_context=None):
|
||||
return await self._runtime.create_session(spec)
|
||||
|
||||
async def start_managed_process(self, session_id: str, spec: BoxManagedProcessSpec):
|
||||
async def start_managed_process(
|
||||
self,
|
||||
session_id: str,
|
||||
spec: BoxManagedProcessSpec,
|
||||
*,
|
||||
action_context=None,
|
||||
):
|
||||
return await self._runtime.start_managed_process(session_id, spec)
|
||||
|
||||
async def get_managed_process(self, session_id: str, process_id: str = 'default'):
|
||||
async def get_managed_process(
|
||||
self,
|
||||
session_id: str,
|
||||
process_id: str = 'default',
|
||||
*,
|
||||
action_context=None,
|
||||
):
|
||||
return self._runtime.get_managed_process(session_id, process_id)
|
||||
|
||||
async def stop_managed_process(self, session_id: str, process_id: str = 'default'):
|
||||
async def stop_managed_process(
|
||||
self,
|
||||
session_id: str,
|
||||
process_id: str = 'default',
|
||||
*,
|
||||
action_context=None,
|
||||
):
|
||||
await self._runtime.stop_managed_process(session_id, process_id)
|
||||
|
||||
async def get_session(self, session_id: str):
|
||||
async def get_session(self, session_id: str, *, action_context=None):
|
||||
return self._runtime.get_session(session_id)
|
||||
|
||||
async def init(self, config: dict) -> None:
|
||||
self._runtime.init(config)
|
||||
|
||||
async def verify_shared_workspace(self, marker_name: str) -> dict:
|
||||
return self._runtime.verify_shared_workspace(marker_name)
|
||||
|
||||
|
||||
class FakeBackend(BaseSandboxBackend):
|
||||
def __init__(self, logger: Mock, available: bool = True):
|
||||
@@ -134,6 +175,12 @@ class FakeBackend(BaseSandboxBackend):
|
||||
def make_query(query_id: int = 42) -> pipeline_query.Query:
|
||||
return pipeline_query.Query.model_construct(
|
||||
query_id=query_id,
|
||||
query_uuid=f'query-{query_id}',
|
||||
instance_uuid=_CONTEXT.instance_uuid,
|
||||
workspace_uuid=_CONTEXT.workspace_uuid,
|
||||
placement_generation=_CONTEXT.placement_generation,
|
||||
bot_uuid='bot-a',
|
||||
pipeline_uuid='pipeline-a',
|
||||
launcher_type='person',
|
||||
launcher_id='test_user',
|
||||
sender_id='test_user',
|
||||
@@ -170,8 +217,19 @@ def make_app(
|
||||
if workspace_quota_mb is not None:
|
||||
box_config['local']['workspace_quota_mb'] = workspace_quota_mb
|
||||
|
||||
workspace_service = SimpleNamespace(
|
||||
instance_uuid=_CONTEXT.instance_uuid,
|
||||
get_execution_binding=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
instance_uuid=_CONTEXT.instance_uuid,
|
||||
workspace_uuid=_CONTEXT.workspace_uuid,
|
||||
placement_generation=_CONTEXT.placement_generation,
|
||||
)
|
||||
),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
logger=logger,
|
||||
workspace_service=workspace_service,
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'box': box_config,
|
||||
@@ -196,6 +254,46 @@ async def test_box_service_without_explicit_client_initializes_internal_connecto
|
||||
connector.initialize.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_initialize_validation_failure_closes_connector_and_cancels_reconnect(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
logger = Mock()
|
||||
app = make_app(logger)
|
||||
app.deployment = SimpleNamespace(multi_workspace_enabled=True)
|
||||
app.instance_config.data['box'].update(
|
||||
{
|
||||
'backend': 'nsjail',
|
||||
'admission': {'required': True, 'workspace_quota_mb': 32},
|
||||
}
|
||||
)
|
||||
connector = Mock()
|
||||
connector.client = Mock(spec=BoxRuntimeClient)
|
||||
connector.initialize = AsyncMock()
|
||||
connector.aclose = AsyncMock()
|
||||
connector.runtime_disconnect_callback = Mock()
|
||||
monkeypatch.setattr('langbot.pkg.box.service.BoxRuntimeConnector', Mock(return_value=connector))
|
||||
service = BoxService(app)
|
||||
service._ensure_default_workspace = Mock()
|
||||
readiness_error = BoxValidationError('Cloud Box nsjail isolation readiness failed')
|
||||
service._verify_cloud_runtime = AsyncMock(side_effect=readiness_error)
|
||||
reconnect_task = asyncio.create_task(asyncio.Event().wait())
|
||||
service._reconnect_task = reconnect_task
|
||||
service._reconnecting = True
|
||||
|
||||
with pytest.raises(BoxValidationError) as exc_info:
|
||||
await service.initialize()
|
||||
|
||||
assert exc_info.value is readiness_error
|
||||
assert service.available is False
|
||||
assert service._closing is True
|
||||
assert service._reconnecting is False
|
||||
assert service._reconnect_task is None
|
||||
assert reconnect_task.cancelled()
|
||||
assert connector.runtime_disconnect_callback is None
|
||||
connector.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
class TestSharesFilesystemWithBox:
|
||||
"""``shares_filesystem_with_box`` must reflect the real LangBot<->Box
|
||||
filesystem topology, which is derived from the connector transport:
|
||||
@@ -268,6 +366,43 @@ def test_separated_box_runtime_does_not_create_default_workspace_in_langbot(tmp_
|
||||
assert not (host_root / 'default').exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_initialize_fails_when_core_and_runtime_volumes_are_separated(tmp_path):
|
||||
logger = Mock()
|
||||
core_root = tmp_path / 'core-box'
|
||||
runtime_root = tmp_path / 'runtime-box'
|
||||
(core_root / 'default').mkdir(parents=True)
|
||||
runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300)
|
||||
runtime.init(
|
||||
{
|
||||
'local': {
|
||||
'host_root': str(runtime_root),
|
||||
'default_workspace': 'default',
|
||||
'allowed_mount_roots': [str(runtime_root)],
|
||||
}
|
||||
}
|
||||
)
|
||||
app = make_app(logger, host_root=str(core_root))
|
||||
app.deployment = SimpleNamespace(multi_workspace_enabled=True)
|
||||
app.instance_config.data['box'].update(
|
||||
{
|
||||
'backend': 'nsjail',
|
||||
'admission': {'required': True, 'workspace_quota_mb': 32},
|
||||
}
|
||||
)
|
||||
service = BoxService(
|
||||
app,
|
||||
client=_InProcessBoxRuntimeClient(logger, runtime),
|
||||
)
|
||||
|
||||
with pytest.raises(BoxValidationError, match='shared durable Workspace volume'):
|
||||
await service.initialize()
|
||||
|
||||
assert service.available is False
|
||||
assert list((core_root / 'default').glob('.langbot-box-volume-probe-*')) == []
|
||||
await runtime.shutdown()
|
||||
|
||||
|
||||
def test_separated_box_runtime_allows_box_owned_missing_host_path(tmp_path):
|
||||
logger = Mock()
|
||||
runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300)
|
||||
@@ -289,12 +424,77 @@ async def test_box_service_get_sessions_delegates_to_client():
|
||||
service = BoxService(make_app(Mock()), client=client)
|
||||
service._available = True
|
||||
|
||||
sessions = await service.get_sessions()
|
||||
sessions = await service.get_sessions(_CONTEXT)
|
||||
|
||||
assert sessions == [{'session_id': 'test-session'}]
|
||||
client.get_sessions.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_service_relay_connection_is_binding_checked_and_scoped():
|
||||
app = make_app(Mock())
|
||||
client = Mock()
|
||||
client.get_managed_process_websocket_url = Mock(
|
||||
return_value='ws://box/v1/sessions/physical/managed-process/server-a/ws'
|
||||
)
|
||||
connector = Mock()
|
||||
connector.ws_relay_base_url = 'http://box:5410'
|
||||
connector.get_relay_headers = Mock(
|
||||
return_value={
|
||||
BOX_CONTROL_TOKEN_HEADER: 'secret',
|
||||
BOX_INSTANCE_HEADER: _CONTEXT.instance_uuid,
|
||||
BOX_WORKSPACE_HEADER: _CONTEXT.workspace_uuid,
|
||||
BOX_PLACEMENT_GENERATION_HEADER: '1',
|
||||
}
|
||||
)
|
||||
service = BoxService(app, client=client)
|
||||
service._runtime_connector = connector
|
||||
|
||||
url, headers = await service.get_managed_process_websocket_connection(
|
||||
_CONTEXT,
|
||||
'mcp-shared',
|
||||
'server-a',
|
||||
)
|
||||
|
||||
assert url == 'ws://box/v1/sessions/physical/managed-process/server-a/ws'
|
||||
assert headers[BOX_WORKSPACE_HEADER] == _CONTEXT.workspace_uuid
|
||||
assert headers[BOX_PLACEMENT_GENERATION_HEADER] == '1'
|
||||
app.workspace_service.get_execution_binding.assert_awaited_once_with(
|
||||
_CONTEXT.workspace_uuid,
|
||||
expected_generation=_CONTEXT.placement_generation,
|
||||
)
|
||||
action_context = connector.get_relay_headers.call_args.args[0]
|
||||
assert action_context == _ACTION_CONTEXT
|
||||
client.get_managed_process_websocket_url.assert_called_once_with(
|
||||
'mcp-shared',
|
||||
'http://box:5410',
|
||||
'server-a',
|
||||
action_context=_ACTION_CONTEXT,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_service_relay_connection_rejects_stale_binding():
|
||||
app = make_app(Mock())
|
||||
app.workspace_service.get_execution_binding.return_value = SimpleNamespace(
|
||||
instance_uuid=_CONTEXT.instance_uuid,
|
||||
workspace_uuid=_CONTEXT.workspace_uuid,
|
||||
placement_generation=2,
|
||||
)
|
||||
client = Mock()
|
||||
service = BoxService(app, client=client)
|
||||
service._runtime_connector = Mock()
|
||||
|
||||
with pytest.raises(BoxValidationError, match='stale Workspace placement'):
|
||||
await service.get_managed_process_websocket_connection(
|
||||
_CONTEXT,
|
||||
'mcp-shared',
|
||||
'server-a',
|
||||
)
|
||||
|
||||
service._runtime_connector.get_relay_headers.assert_not_called()
|
||||
|
||||
|
||||
def test_box_service_dispose_delegates_to_internal_connector(monkeypatch: pytest.MonkeyPatch):
|
||||
connector = Mock()
|
||||
connector.client = Mock()
|
||||
@@ -369,6 +569,28 @@ async def test_box_service_reconnect_restores_workspace_and_runs_cleanup(
|
||||
assert service.available is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_box_service_reconnect_does_not_reload_unscoped_skills(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
app = make_app(Mock())
|
||||
app.skill_mgr = SimpleNamespace(reload_skills=AsyncMock())
|
||||
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
|
||||
service._cloud_managed = True
|
||||
connector = Mock()
|
||||
connector.reconnect = AsyncMock()
|
||||
service._ensure_default_workspace = Mock()
|
||||
service._verify_cloud_runtime = AsyncMock()
|
||||
monkeypatch.setattr('langbot.pkg.box.service.asyncio.sleep', AsyncMock())
|
||||
|
||||
await service._reconnect_loop(connector)
|
||||
|
||||
connector.reconnect.assert_awaited_once()
|
||||
service._verify_cloud_runtime.assert_awaited_once()
|
||||
app.skill_mgr.reload_skills.assert_not_awaited()
|
||||
assert service.available is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_runtime_reuses_request_session():
|
||||
logger = Mock()
|
||||
@@ -409,7 +631,14 @@ async def test_box_service_session_id_uses_query_attributes_without_variables():
|
||||
service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||
await service.initialize()
|
||||
|
||||
query = pipeline_query.Query.model_construct(query_id=7, launcher_type='group', launcher_id='room-1')
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id=7,
|
||||
instance_uuid=_CONTEXT.instance_uuid,
|
||||
workspace_uuid=_CONTEXT.workspace_uuid,
|
||||
placement_generation=_CONTEXT.placement_generation,
|
||||
launcher_type='group',
|
||||
launcher_id='room-1',
|
||||
)
|
||||
result = await service.execute_tool({'command': 'pwd'}, query)
|
||||
|
||||
assert result['session_id'] == 'group_room-1'
|
||||
@@ -425,7 +654,12 @@ async def test_box_service_session_id_falls_back_to_query_id_for_synthetic_queri
|
||||
service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||
await service.initialize()
|
||||
|
||||
query = pipeline_query.Query.model_construct(query_id=7)
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id=7,
|
||||
instance_uuid=_CONTEXT.instance_uuid,
|
||||
workspace_uuid=_CONTEXT.workspace_uuid,
|
||||
placement_generation=_CONTEXT.placement_generation,
|
||||
)
|
||||
result = await service.execute_tool({'command': 'pwd'}, query)
|
||||
|
||||
assert result['session_id'] == 'query_7'
|
||||
@@ -447,8 +681,22 @@ async def test_box_service_forced_global_scope_overrides_pipeline_template():
|
||||
await service.initialize()
|
||||
|
||||
# Two distinct callers that would otherwise get separate sandboxes.
|
||||
q1 = pipeline_query.Query.model_construct(query_id=1, launcher_type='group', launcher_id='room-1')
|
||||
q2 = pipeline_query.Query.model_construct(query_id=2, launcher_type='person', launcher_id='alice')
|
||||
q1 = pipeline_query.Query.model_construct(
|
||||
query_id=1,
|
||||
instance_uuid=_CONTEXT.instance_uuid,
|
||||
workspace_uuid=_CONTEXT.workspace_uuid,
|
||||
placement_generation=_CONTEXT.placement_generation,
|
||||
launcher_type='group',
|
||||
launcher_id='room-1',
|
||||
)
|
||||
q2 = pipeline_query.Query.model_construct(
|
||||
query_id=2,
|
||||
instance_uuid=_CONTEXT.instance_uuid,
|
||||
workspace_uuid=_CONTEXT.workspace_uuid,
|
||||
placement_generation=_CONTEXT.placement_generation,
|
||||
launcher_type='person',
|
||||
launcher_id='alice',
|
||||
)
|
||||
|
||||
r1 = await service.execute_tool({'command': 'pwd'}, q1)
|
||||
r2 = await service.execute_tool({'command': 'pwd'}, q2)
|
||||
@@ -551,7 +799,7 @@ async def test_box_service_uses_default_workspace_when_host_path_omitted(tmp_pat
|
||||
assert result['ok'] is True
|
||||
assert backend.start_calls == ['person_test_user']
|
||||
assert backend.exec_calls == [('person_test_user', 'pwd')]
|
||||
assert backend.start_specs[0].host_path == os.path.realpath(host_dir)
|
||||
assert backend.start_specs[0].host_path == service._tenant_workspace(_CONTEXT)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -994,11 +1242,16 @@ async def test_box_service_rejects_execution_when_workspace_already_exceeds_quot
|
||||
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
|
||||
host_dir = tmp_path / 'quota-workspace'
|
||||
host_dir.mkdir()
|
||||
(host_dir / 'already-too-large.bin').write_bytes(b'x' * (2 * 1024 * 1024))
|
||||
app = make_app(logger, [str(tmp_path)], workspace_quota_mb=1)
|
||||
app.instance_config.data['box']['local']['default_workspace'] = str(host_dir)
|
||||
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||
|
||||
tenant_host_dir = service._tenant_workspace(_CONTEXT)
|
||||
assert tenant_host_dir is not None
|
||||
os.makedirs(tenant_host_dir, exist_ok=True)
|
||||
with open(os.path.join(tenant_host_dir, 'already-too-large.bin'), 'wb') as handle:
|
||||
handle.write(b'x' * (2 * 1024 * 1024))
|
||||
|
||||
await service.initialize()
|
||||
|
||||
with pytest.raises(BoxValidationError, match='workspace quota exceeded before execution'):
|
||||
@@ -1027,6 +1280,45 @@ async def test_box_service_rejects_and_cleans_up_when_execution_exceeds_workspac
|
||||
assert backend.stop_calls == ['person_test_user']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_service_rejects_workspace_inode_bomb_before_execution(tmp_path):
|
||||
logger = Mock()
|
||||
backend = FakeBackend(logger)
|
||||
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
|
||||
host_dir = tmp_path / 'quota-workspace-entries'
|
||||
host_dir.mkdir()
|
||||
app = make_app(logger, [str(tmp_path)], workspace_quota_mb=1)
|
||||
app.instance_config.data['box']['local']['default_workspace'] = str(host_dir)
|
||||
app.instance_config.data['box']['limits'] = {'max_workspace_entries': 2}
|
||||
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||
|
||||
tenant_host_dir = service._tenant_workspace(_CONTEXT)
|
||||
assert tenant_host_dir is not None
|
||||
os.makedirs(tenant_host_dir, exist_ok=True)
|
||||
for index in range(3):
|
||||
pathlib.Path(tenant_host_dir, f'tiny-{index}').write_bytes(b'x')
|
||||
|
||||
await service.initialize()
|
||||
|
||||
with pytest.raises(BoxValidationError, match='workspace entry limit exceeded before execution'):
|
||||
await service.execute_tool({'command': 'echo hi'}, make_query(46))
|
||||
|
||||
assert backend.start_calls == []
|
||||
|
||||
|
||||
def test_box_service_workspace_entry_limit_is_hard_clamped():
|
||||
app = make_app(Mock())
|
||||
app.instance_config.data['box']['limits'] = {'max_workspace_entries': 10_000_000}
|
||||
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
|
||||
assert service._max_workspace_entries() == 1_000_000
|
||||
|
||||
app.instance_config.data['box']['limits']['max_workspace_entries'] = 0
|
||||
assert service._max_workspace_entries() == 1
|
||||
|
||||
app.instance_config.data['box']['limits']['max_workspace_entries'] = 'invalid'
|
||||
assert service._max_workspace_entries() == 100_000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_profile_offline_readonly_locks_read_only_rootfs():
|
||||
"""offline_readonly locks read_only_rootfs so it cannot be overridden."""
|
||||
@@ -1137,7 +1429,7 @@ async def test_service_records_errors_on_failure():
|
||||
with pytest.raises(Exception):
|
||||
await service.execute_tool({'command': 'echo hello'}, make_query(50))
|
||||
|
||||
errors = service.get_recent_errors()
|
||||
errors = service.get_recent_errors(_CONTEXT)
|
||||
assert len(errors) == 1
|
||||
assert errors[0]['type'] == 'BoxBackendUnavailableError'
|
||||
assert errors[0]['query_id'] == '50'
|
||||
@@ -1156,7 +1448,7 @@ async def test_service_error_ring_buffer_capped():
|
||||
with pytest.raises(Exception):
|
||||
await service.execute_tool({'command': 'fail'}, make_query(100 + i))
|
||||
|
||||
errors = service.get_recent_errors()
|
||||
errors = service.get_recent_errors(_CONTEXT)
|
||||
assert len(errors) == 50
|
||||
# Oldest should have been evicted, newest kept
|
||||
assert errors[0]['query_id'] == '110'
|
||||
@@ -1171,7 +1463,7 @@ async def test_service_get_status_aggregates_runtime_and_profile():
|
||||
service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime))
|
||||
await service.initialize()
|
||||
|
||||
status = await service.get_status()
|
||||
status = await service.get_status(_CONTEXT)
|
||||
assert status['profile'] == 'default'
|
||||
assert status['backend']['name'] == 'fake'
|
||||
assert status['backend']['available'] is True
|
||||
@@ -1215,7 +1507,12 @@ async def _make_rpc_pair(runtime: BoxRuntime):
|
||||
|
||||
client_conn, server_conn = _make_queue_connection_pair()
|
||||
|
||||
server_handler = BoxServerHandler(server_conn, runtime)
|
||||
server_handler = BoxServerHandler(
|
||||
server_conn,
|
||||
runtime,
|
||||
host_control_authenticated=True,
|
||||
trusted_instance_uuid=_CONTEXT.instance_uuid,
|
||||
)
|
||||
server_task = asyncio.create_task(server_handler.run())
|
||||
|
||||
client_handler = Handler.__new__(Handler)
|
||||
@@ -1239,7 +1536,7 @@ async def test_rpc_client_execute():
|
||||
client, server_task, client_task = await _make_rpc_pair(runtime)
|
||||
try:
|
||||
spec = BoxSpec.model_validate({'cmd': 'echo remote', 'session_id': 'r-1'})
|
||||
result = await client.execute(spec)
|
||||
result = await client.execute(spec, action_context=_ACTION_CONTEXT)
|
||||
|
||||
assert result.session_id == 'r-1'
|
||||
assert result.status == BoxExecutionStatus.COMPLETED
|
||||
@@ -1261,9 +1558,9 @@ async def test_rpc_client_get_sessions():
|
||||
client, server_task, client_task = await _make_rpc_pair(runtime)
|
||||
try:
|
||||
spec = BoxSpec.model_validate({'cmd': 'echo hi', 'session_id': 'r-2'})
|
||||
await client.execute(spec)
|
||||
await client.execute(spec, action_context=_ACTION_CONTEXT)
|
||||
|
||||
sessions = await client.get_sessions()
|
||||
sessions = await client.get_sessions(action_context=_ACTION_CONTEXT)
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0]['session_id'] == 'r-2'
|
||||
finally:
|
||||
@@ -1272,6 +1569,34 @@ async def test_rpc_client_get_sessions():
|
||||
await runtime.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rpc_generation_advance_retires_old_session_and_rejects_old_context():
|
||||
logger = Mock()
|
||||
backend = FakeBackend(logger)
|
||||
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
|
||||
await runtime.initialize()
|
||||
second_context = _ACTION_CONTEXT.model_copy(update={'placement_generation': 2})
|
||||
|
||||
client, server_task, client_task = await _make_rpc_pair(runtime)
|
||||
try:
|
||||
spec = BoxSpec.model_validate({'cmd': 'echo generation', 'session_id': 'shared'})
|
||||
await client.execute(spec, action_context=_ACTION_CONTEXT)
|
||||
await client.execute(spec, action_context=second_context)
|
||||
|
||||
assert len(backend.start_calls) == 2
|
||||
assert backend.start_calls[0] != backend.start_calls[1]
|
||||
assert backend.stop_calls == [backend.start_calls[0]]
|
||||
assert [session['session_id'] for session in await client.get_sessions(action_context=second_context)] == [
|
||||
'shared'
|
||||
]
|
||||
with pytest.raises(BoxError, match='Stale Box placement generation'):
|
||||
await client.execute(spec, action_context=_ACTION_CONTEXT)
|
||||
finally:
|
||||
server_task.cancel()
|
||||
client_task.cancel()
|
||||
await runtime.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rpc_client_get_status():
|
||||
logger = Mock()
|
||||
@@ -1281,7 +1606,7 @@ async def test_rpc_client_get_status():
|
||||
|
||||
client, server_task, client_task = await _make_rpc_pair(runtime)
|
||||
try:
|
||||
status = await client.get_status()
|
||||
status = await client.get_status(action_context=_ACTION_CONTEXT)
|
||||
|
||||
assert 'backend' in status
|
||||
assert 'active_sessions' in status
|
||||
@@ -1323,11 +1648,11 @@ async def test_rpc_client_delete_session():
|
||||
client, server_task, client_task = await _make_rpc_pair(runtime)
|
||||
try:
|
||||
spec = BoxSpec.model_validate({'cmd': 'echo hi', 'session_id': 'r-del-1'})
|
||||
await client.execute(spec)
|
||||
await client.execute(spec, action_context=_ACTION_CONTEXT)
|
||||
|
||||
await client.delete_session('r-del-1')
|
||||
await client.delete_session('r-del-1', action_context=_ACTION_CONTEXT)
|
||||
|
||||
sessions = await client.get_sessions()
|
||||
sessions = await client.get_sessions(action_context=_ACTION_CONTEXT)
|
||||
assert len(sessions) == 0
|
||||
finally:
|
||||
server_task.cancel()
|
||||
@@ -1345,7 +1670,7 @@ async def test_rpc_client_delete_session_raises_not_found():
|
||||
client, server_task, client_task = await _make_rpc_pair(runtime)
|
||||
try:
|
||||
with pytest.raises(BoxSessionNotFoundError):
|
||||
await client.delete_session('nonexistent')
|
||||
await client.delete_session('nonexistent', action_context=_ACTION_CONTEXT)
|
||||
finally:
|
||||
server_task.cancel()
|
||||
client_task.cancel()
|
||||
@@ -1362,11 +1687,11 @@ async def test_rpc_client_create_session():
|
||||
client, server_task, client_task = await _make_rpc_pair(runtime)
|
||||
try:
|
||||
spec = BoxSpec.model_validate({'cmd': 'placeholder', 'session_id': 'r-create-1'})
|
||||
info = await client.create_session(spec)
|
||||
info = await client.create_session(spec, action_context=_ACTION_CONTEXT)
|
||||
assert info['session_id'] == 'r-create-1'
|
||||
assert info['backend_name'] == 'fake'
|
||||
|
||||
sessions = await client.get_sessions()
|
||||
sessions = await client.get_sessions(action_context=_ACTION_CONTEXT)
|
||||
assert len(sessions) == 1
|
||||
finally:
|
||||
server_task.cancel()
|
||||
@@ -1384,11 +1709,11 @@ async def test_rpc_client_exec_raises_conflict_error():
|
||||
client, server_task, client_task = await _make_rpc_pair(runtime)
|
||||
try:
|
||||
spec1 = BoxSpec.model_validate({'cmd': 'echo first', 'session_id': 'r-conflict-1', 'network': 'off'})
|
||||
await client.execute(spec1)
|
||||
await client.execute(spec1, action_context=_ACTION_CONTEXT)
|
||||
|
||||
spec2 = BoxSpec.model_validate({'cmd': 'echo second', 'session_id': 'r-conflict-1', 'network': 'on'})
|
||||
with pytest.raises(BoxSessionConflictError):
|
||||
await client.execute(spec2)
|
||||
await client.execute(spec2, action_context=_ACTION_CONTEXT)
|
||||
finally:
|
||||
server_task.cancel()
|
||||
client_task.cancel()
|
||||
@@ -1487,7 +1812,7 @@ class TestBoxDisabledByConfig:
|
||||
service = BoxService(make_app(logger, enabled=False), client=Mock(spec=BoxRuntimeClient))
|
||||
await service.initialize()
|
||||
|
||||
status = await service.get_status()
|
||||
status = await service.get_status(_CONTEXT)
|
||||
|
||||
assert status['available'] is False
|
||||
assert status['enabled'] is False
|
||||
@@ -1502,7 +1827,7 @@ class TestBoxDisabledByConfig:
|
||||
|
||||
await service.initialize()
|
||||
|
||||
status = await service.get_status()
|
||||
status = await service.get_status(_CONTEXT)
|
||||
assert status['available'] is False
|
||||
assert status['enabled'] is True
|
||||
assert 'docker daemon' in status['connector_error']
|
||||
@@ -1526,7 +1851,7 @@ class TestBoxDisabledByConfig:
|
||||
service = BoxService(make_app(logger, enabled=True), client=client)
|
||||
await service.initialize()
|
||||
|
||||
status = await service.get_status()
|
||||
status = await service.get_status(_CONTEXT)
|
||||
assert status['available'] is False
|
||||
assert status['enabled'] is True
|
||||
# The detailed backend object is preserved for the dialog
|
||||
@@ -1547,7 +1872,7 @@ class TestBoxDisabledByConfig:
|
||||
service = BoxService(make_app(logger, enabled=True), client=client)
|
||||
await service.initialize()
|
||||
|
||||
status = await service.get_status()
|
||||
status = await service.get_status(_CONTEXT)
|
||||
assert status['available'] is True
|
||||
assert status['backend'] == {'name': 'docker', 'available': True}
|
||||
# No spurious connector_error overlay when everything is healthy
|
||||
@@ -1565,6 +1890,57 @@ class TestBoxDisabledByConfig:
|
||||
assert service._reconnecting is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_callback_does_not_schedule_on_closing_event_loop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
|
||||
closed_loop = Mock()
|
||||
closed_loop.is_closed.return_value = True
|
||||
monkeypatch.setattr('langbot.pkg.box.service.asyncio.get_running_loop', Mock(return_value=closed_loop))
|
||||
|
||||
await service._on_runtime_disconnect(connector=Mock())
|
||||
|
||||
closed_loop.create_task.assert_not_called()
|
||||
assert service._reconnect_task is None
|
||||
assert service._reconnecting is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_callback_closes_reconnect_coroutine_when_task_creation_races_with_loop_close(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
|
||||
loop = Mock()
|
||||
loop.is_closed.return_value = False
|
||||
loop.create_task.side_effect = RuntimeError('event loop is closed')
|
||||
monkeypatch.setattr('langbot.pkg.box.service.asyncio.get_running_loop', Mock(return_value=loop))
|
||||
|
||||
async def reconnect():
|
||||
await asyncio.Event().wait()
|
||||
|
||||
reconnect_coroutine = reconnect()
|
||||
service._reconnect_loop = Mock(return_value=reconnect_coroutine)
|
||||
|
||||
await service._on_runtime_disconnect(connector=Mock())
|
||||
|
||||
loop.create_task.assert_called_once_with(reconnect_coroutine)
|
||||
assert reconnect_coroutine.cr_frame is None
|
||||
assert service._reconnect_task is None
|
||||
assert service._reconnecting is False
|
||||
|
||||
|
||||
def test_disconnect_callback_does_not_schedule_without_running_event_loop():
|
||||
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
|
||||
callback = service._on_runtime_disconnect(connector=Mock())
|
||||
|
||||
with pytest.raises(StopIteration):
|
||||
callback.send(None)
|
||||
|
||||
assert service._reconnect_task is None
|
||||
assert service._reconnecting is False
|
||||
|
||||
|
||||
class TestBuildSkillExtraMounts:
|
||||
"""Robustness of skill mount construction against a stale skill cache.
|
||||
|
||||
@@ -1577,7 +1953,7 @@ class TestBuildSkillExtraMounts:
|
||||
|
||||
def _make_service(self, logger, skills, *, shares_filesystem=True):
|
||||
app = make_app(logger)
|
||||
app.skill_mgr = SimpleNamespace(skills=skills)
|
||||
app.skill_mgr = SimpleNamespace(skills=skills, get_skills=Mock(return_value=skills))
|
||||
client = Mock(spec=BoxRuntimeClient)
|
||||
service = BoxService(app, client=client)
|
||||
# Tests construct BoxService with an injected client (no connector), so
|
||||
@@ -1714,6 +2090,17 @@ class TestAttachmentHelpers:
|
||||
component = SimpleNamespace(base64=None, url=None, path=None)
|
||||
assert await BoxService._component_to_bytes(component) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_component_to_bytes_rejects_oversized_base64(self, monkeypatch):
|
||||
monkeypatch.setattr(BoxService, '_ATTACHMENT_MAX_BYTES', 4)
|
||||
component = SimpleNamespace(
|
||||
base64='data:application/octet-stream;base64,' + ('A' * 12),
|
||||
url=None,
|
||||
path=None,
|
||||
)
|
||||
|
||||
assert await BoxService._component_to_bytes(component) is None
|
||||
|
||||
|
||||
class TestInboundOutboundRoundTrip:
|
||||
def _service(self) -> BoxService:
|
||||
@@ -1745,7 +2132,7 @@ class TestInboundOutboundRoundTrip:
|
||||
assert '/workspace/inbox/' in parameters['command']
|
||||
return {
|
||||
'ok': True,
|
||||
'stdout': '["/workspace/inbox/42/image_1.png"]',
|
||||
'stdout': '["/workspace/inbox/query-42/image_1.png"]',
|
||||
'stderr': '',
|
||||
}
|
||||
|
||||
@@ -1755,7 +2142,7 @@ class TestInboundOutboundRoundTrip:
|
||||
assert len(descriptors) == 1
|
||||
d = descriptors[0]
|
||||
assert d['type'] == 'Image'
|
||||
assert d['path'] == '/workspace/inbox/42/image_1.png'
|
||||
assert d['path'] == '/workspace/inbox/query-42/image_1.png'
|
||||
assert d['size'] == len(img_bytes)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1778,7 +2165,7 @@ class TestInboundOutboundRoundTrip:
|
||||
|
||||
async def fake_execute_tool(parameters, q):
|
||||
calls.append(parameters['command'])
|
||||
if 'os.walk' in parameters['command']:
|
||||
if 'os.scandir' in parameters['command']:
|
||||
return {
|
||||
'ok': True,
|
||||
'stdout': '[{"name": "out.png", "b64": "QUJD"}]',
|
||||
@@ -1808,7 +2195,7 @@ class TestInboundOutboundRoundTrip:
|
||||
|
||||
async def fake_execute_tool(parameters, q):
|
||||
calls.append(parameters['command'])
|
||||
if 'os.walk' in parameters['command']:
|
||||
if 'os.scandir' in parameters['command']:
|
||||
return {'ok': True, 'stdout': '[]', 'stderr': ''}
|
||||
return {'ok': True, 'stdout': '', 'stderr': ''}
|
||||
|
||||
@@ -1835,14 +2222,17 @@ class TestAttachmentHostPath:
|
||||
"""
|
||||
|
||||
def _service_with_workspace(self, tmp_path):
|
||||
ws = str(tmp_path / 'box' / 'default')
|
||||
os.makedirs(ws, exist_ok=True)
|
||||
default_workspace = str(tmp_path / 'box' / 'default')
|
||||
os.makedirs(default_workspace, exist_ok=True)
|
||||
app = make_app(Mock(), allowed_mount_roots=[str(tmp_path)], host_root=str(tmp_path / 'box'))
|
||||
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
|
||||
service._available = True
|
||||
# Force the default_workspace to our tmp dir so _host_query_dir resolves.
|
||||
service.default_workspace = ws
|
||||
return service, ws
|
||||
service.default_workspace = default_workspace
|
||||
tenant_workspace = service._tenant_workspace(_CONTEXT)
|
||||
assert tenant_workspace is not None
|
||||
os.makedirs(tenant_workspace, exist_ok=True)
|
||||
return service, tenant_workspace
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_writes_to_host_no_exec(self, tmp_path):
|
||||
@@ -1865,9 +2255,9 @@ class TestAttachmentHostPath:
|
||||
assert d['type'] == 'Image'
|
||||
assert d['size'] == len(big)
|
||||
# File actually landed on the host workspace.
|
||||
host_file = os.path.join(ws, 'inbox', str(query.query_id), d['name'])
|
||||
host_file = os.path.join(ws, 'inbox', str(query.query_uuid), d['name'])
|
||||
assert os.path.isfile(host_file)
|
||||
assert open(host_file, 'rb').read() == big
|
||||
assert pathlib.Path(host_file).read_bytes() == big
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_host_clears_stale_query_dir(self, tmp_path):
|
||||
@@ -1877,9 +2267,9 @@ class TestAttachmentHostPath:
|
||||
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
# Seed a stale file under the same query_id (simulates webchat id reuse).
|
||||
stale_dir = os.path.join(ws, 'inbox', '42')
|
||||
stale_dir = os.path.join(ws, 'inbox', 'query-42')
|
||||
os.makedirs(stale_dir, exist_ok=True)
|
||||
open(os.path.join(stale_dir, 'image_1.png'), 'wb').write(b'STALE-OLD-IMAGE')
|
||||
pathlib.Path(stale_dir, 'image_1.png').write_bytes(b'STALE-OLD-IMAGE')
|
||||
|
||||
new = b'\x89PNG\r\n\x1a\n NEW'
|
||||
b64 = 'data:image/png;base64,' + base64.b64encode(new).decode()
|
||||
@@ -1889,20 +2279,50 @@ class TestAttachmentHostPath:
|
||||
descriptors = await service.materialize_inbound_attachments(query)
|
||||
# The new write recreated the dir; the stale file is gone, new bytes present.
|
||||
host_file = os.path.join(stale_dir, descriptors[0]['name'])
|
||||
assert open(host_file, 'rb').read() == new
|
||||
host_bytes = pathlib.Path(host_file).read_bytes()
|
||||
assert host_bytes == new
|
||||
# No leftover content from the stale image.
|
||||
assert b'STALE-OLD-IMAGE' not in open(host_file, 'rb').read()
|
||||
assert b'STALE-OLD-IMAGE' not in host_bytes
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inbound_host_replaces_query_symlink_without_touching_other_workspace(self, tmp_path):
|
||||
import base64
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
other_workspace = tmp_path / 'other-workspace'
|
||||
other_workspace.mkdir()
|
||||
protected = other_workspace / 'protected.txt'
|
||||
protected.write_bytes(b'workspace-b-secret')
|
||||
inbox = os.path.join(ws, 'inbox')
|
||||
os.makedirs(inbox, exist_ok=True)
|
||||
os.symlink(other_workspace, os.path.join(inbox, 'query-42'))
|
||||
|
||||
query = make_query()
|
||||
payload = b'workspace-a-input'
|
||||
query.message_chain = platform_message.MessageChain(
|
||||
[platform_message.File(name='input.bin', base64=base64.b64encode(payload).decode())]
|
||||
)
|
||||
service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path'))
|
||||
|
||||
descriptors = await service.materialize_inbound_attachments(query)
|
||||
|
||||
assert descriptors[0]['path'] == '/workspace/inbox/query-42/input.bin'
|
||||
assert protected.read_bytes() == b'workspace-b-secret'
|
||||
assert not os.path.islink(os.path.join(inbox, 'query-42'))
|
||||
assert pathlib.Path(inbox, 'query-42', 'input.bin').read_bytes() == payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbound_reads_host_and_clears(self, tmp_path):
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
query = make_query()
|
||||
outbox = os.path.join(ws, 'outbox', str(query.query_id))
|
||||
outbox = os.path.join(ws, 'outbox', str(query.query_uuid))
|
||||
os.makedirs(outbox, exist_ok=True)
|
||||
# A large file that would be truncated on the exec/stdout path:
|
||||
big_png = b'\x89PNG\r\n\x1a\n' + b'y' * (400 * 1024)
|
||||
open(os.path.join(outbox, 'result.png'), 'wb').write(big_png)
|
||||
open(os.path.join(outbox, 'notes.txt'), 'wb').write(b'hello')
|
||||
pathlib.Path(outbox, 'result.png').write_bytes(big_png)
|
||||
pathlib.Path(outbox, 'notes.txt').write_bytes(b'hello')
|
||||
|
||||
service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path'))
|
||||
attachments = await service.collect_outbound_attachments(query)
|
||||
@@ -1917,20 +2337,76 @@ class TestAttachmentHostPath:
|
||||
# Outbox cleared after collection.
|
||||
assert os.listdir(outbox) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbound_host_never_follows_query_or_file_symlinks(self, tmp_path):
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
query = make_query()
|
||||
other_workspace = tmp_path / 'other-workspace'
|
||||
other_workspace.mkdir()
|
||||
secret = other_workspace / 'secret.txt'
|
||||
secret.write_bytes(b'workspace-b-secret')
|
||||
outbox_root = os.path.join(ws, 'outbox')
|
||||
os.makedirs(outbox_root, exist_ok=True)
|
||||
|
||||
# A hostile query-directory replacement is rejected rather than read.
|
||||
query_dir = os.path.join(outbox_root, str(query.query_uuid))
|
||||
os.symlink(other_workspace, query_dir)
|
||||
with pytest.raises(BoxValidationError, match='symbolic link'):
|
||||
await service.collect_outbound_attachments(query)
|
||||
assert secret.read_bytes() == b'workspace-b-secret'
|
||||
|
||||
os.unlink(query_dir)
|
||||
os.makedirs(query_dir)
|
||||
os.symlink(secret, os.path.join(query_dir, 'leak.txt'))
|
||||
service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path'))
|
||||
assert await service.collect_outbound_attachments(query) == []
|
||||
assert secret.read_bytes() == b'workspace-b-secret'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbound_host_fails_closed_on_inode_bomb(self, tmp_path):
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
query = make_query()
|
||||
outbox = os.path.join(ws, 'outbox', str(query.query_uuid))
|
||||
os.makedirs(outbox, exist_ok=True)
|
||||
harmless_target = tmp_path / 'harmless-target'
|
||||
harmless_target.write_bytes(b'x')
|
||||
# Symlinks do not count toward the 20 returned files, so this proves
|
||||
# traversal itself has a bounded entry budget.
|
||||
for index in range(513):
|
||||
os.symlink(harmless_target, os.path.join(outbox, f'entry-{index}'))
|
||||
|
||||
with pytest.raises(BoxValidationError, match='symbolic link'):
|
||||
await service.collect_outbound_attachments(query)
|
||||
assert harmless_target.read_bytes() == b'x'
|
||||
|
||||
def test_host_attachment_directories_use_query_uuid_not_process_local_id(self, tmp_path):
|
||||
service, _ws = self._service_with_workspace(tmp_path)
|
||||
first = make_query(query_id=7)
|
||||
second = make_query(query_id=7)
|
||||
object.__setattr__(first, 'query_uuid', 'replica-a-query')
|
||||
object.__setattr__(second, 'query_uuid', 'replica-b-query')
|
||||
|
||||
first_path = service._host_query_dir(service.OUTBOX_SUBDIR, first)
|
||||
second_path = service._host_query_dir(service.OUTBOX_SUBDIR, second)
|
||||
|
||||
assert first_path is not None and first_path.endswith('/outbox/replica-a-query')
|
||||
assert second_path is not None and second_path.endswith('/outbox/replica-b-query')
|
||||
assert first_path != second_path
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbound_empty_clears_stale_host_dir(self, tmp_path):
|
||||
# Reusing a query_id (counter resets on restart) must not re-send files
|
||||
# a previous run left in the outbox: an empty collection still clears it.
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
query = make_query()
|
||||
outbox = os.path.join(ws, 'outbox', str(query.query_id))
|
||||
outbox = os.path.join(ws, 'outbox', str(query.query_uuid))
|
||||
os.makedirs(outbox, exist_ok=True)
|
||||
# Stale file from a prior turn; the agent produced nothing this turn —
|
||||
# but _read_outbox_host would still pick it up, so collection must drop
|
||||
# it and then wipe the dir. Simulate "nothing produced this turn" by
|
||||
# treating any present file as stale and asserting it is not re-sent
|
||||
# across a second, genuinely-empty collection.
|
||||
open(os.path.join(outbox, 'stale.png'), 'wb').write(b'\x89PNG\r\n\x1a\n old')
|
||||
pathlib.Path(outbox, 'stale.png').write_bytes(b'\x89PNG\r\n\x1a\n old')
|
||||
service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path'))
|
||||
|
||||
# First collection drains + clears the dir.
|
||||
@@ -1952,7 +2428,7 @@ class TestAttachmentHostPath:
|
||||
for sub in ('inbox', 'outbox'):
|
||||
d = os.path.join(ws, sub, '0')
|
||||
os.makedirs(d, exist_ok=True)
|
||||
open(os.path.join(d, 'leftover.bin'), 'wb').write(b'from a previous process')
|
||||
pathlib.Path(d, 'leftover.bin').write_bytes(b'from a previous process')
|
||||
service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used for host-owned files'))
|
||||
|
||||
await service._purge_attachment_dirs()
|
||||
@@ -1963,37 +2439,37 @@ class TestAttachmentHostPath:
|
||||
assert os.path.isdir(ws)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purge_attachment_dirs_falls_back_to_exec_for_root_owned(self, tmp_path, monkeypatch):
|
||||
# When the host delete cannot remove a dir (root-owned container output),
|
||||
# purge must fall back to deleting from inside the sandbox via exec.
|
||||
async def test_purge_attachment_dirs_never_uses_unscoped_exec_for_root_owned(self, tmp_path, monkeypatch):
|
||||
# Startup has no trusted Workspace context. If host deletion cannot
|
||||
# remove root-owned output, cleanup must fail closed instead of issuing
|
||||
# an unscoped Box exec that could cross a tenant boundary.
|
||||
service, ws = self._service_with_workspace(tmp_path)
|
||||
outbox = os.path.join(ws, 'outbox')
|
||||
os.makedirs(os.path.join(outbox, '0'), exist_ok=True)
|
||||
|
||||
# Simulate a host delete that cannot remove the root-owned outbox.
|
||||
import shutil as _shutil
|
||||
from langbot.pkg.box import secure_fs
|
||||
|
||||
real_rmtree = _shutil.rmtree
|
||||
real_purge = secure_fs.purge_subdirectory
|
||||
|
||||
def fake_rmtree(path, *a, **k):
|
||||
if os.path.abspath(path) == os.path.abspath(outbox):
|
||||
return # "permission denied" — silently leaves the dir
|
||||
return real_rmtree(path, *a, **k)
|
||||
def fake_purge(root, subdir):
|
||||
if os.path.abspath(os.path.join(root, subdir)) == os.path.abspath(outbox):
|
||||
raise PermissionError('root-owned')
|
||||
return real_purge(root, subdir)
|
||||
|
||||
monkeypatch.setattr(_shutil, 'rmtree', fake_rmtree)
|
||||
monkeypatch.setattr(secure_fs, 'purge_subdirectory', fake_purge)
|
||||
|
||||
executed = {}
|
||||
spec_obj = object()
|
||||
service.build_spec = Mock(return_value=spec_obj)
|
||||
service.client.execute = AsyncMock(side_effect=lambda s: executed.setdefault('spec', s))
|
||||
service.build_spec = Mock()
|
||||
service.client.execute = AsyncMock()
|
||||
|
||||
await service._purge_attachment_dirs()
|
||||
|
||||
# build_spec was asked to rm the surviving outbox via exec.
|
||||
cmd = service.build_spec.call_args.args[0]['cmd']
|
||||
assert 'rm -rf' in cmd and '/workspace/outbox' in cmd
|
||||
assert '/workspace/inbox' not in cmd # inbox was host-deletable
|
||||
service.client.execute.assert_awaited_once_with(spec_obj)
|
||||
assert os.path.isdir(outbox)
|
||||
service.build_spec.assert_not_called()
|
||||
service.client.execute.assert_not_awaited()
|
||||
assert any(
|
||||
'no trusted Workspace context' in str(call.args[0]) for call in service.ap.logger.warning.call_args_list
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purge_attachment_dirs_noop_without_workspace(self):
|
||||
|
||||
Reference in New Issue
Block a user