refactor(skill): compose generic box mounts in core

This commit is contained in:
huanghuoguoguo
2026-09-05 23:09:46 +08:00
parent c15f668126
commit a61b0f0068
22 changed files with 476 additions and 565 deletions
@@ -29,6 +29,9 @@ from langbot.pkg.cloud.entitlements import (
EntitlementSnapshot,
EntitlementUnavailableError,
)
from langbot.pkg.skill.manager import SkillManager
from langbot.pkg.skill.repository import SkillRepository
from langbot.pkg.provider.tools.loaders import skill as skill_loader
pytestmark = pytest.mark.integration
@@ -54,7 +57,7 @@ class _AdmissionBackend(BaseSandboxBackend):
'mount_isolation': True,
'network_isolation': True,
'hard_workspace_quota': True,
'hard_skill_storage_quota': True,
'hard_read_only_mount_quota': True,
'bounded_ephemeral_storage': True,
'inode_quota': True,
}
@@ -230,9 +233,18 @@ async def _stack(tmp_path):
deployment=SimpleNamespace(multi_workspace_enabled=True),
entitlement_resolver=EntitlementResolver('instance-a', entitlements),
workspace_service=workspace_service,
instance_config=SimpleNamespace(data={'box': box_config, 'system': {'limitation': {}}}),
instance_config=SimpleNamespace(
data={
'skills': {'root': str(shared_root / 'skills')},
'box': box_config,
'system': {'limitation': {}},
}
),
)
app.skill_repository = SkillRepository(app)
app.skill_mgr = SkillManager(app)
service = BoxService(app, client=client)
app.box_service = service
await service.initialize()
return service, runtime, backend, entitlements, server_task, client_task
@@ -312,7 +324,7 @@ async def test_two_workspaces_get_isolated_physical_sessions_and_paths(tmp_path)
@pytest.mark.asyncio
async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tmp_path):
async def test_cloud_core_skills_mount_generically_and_do_not_require_box_entitlement(tmp_path):
service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
first = _context('workspace-a')
second = _context('workspace-b')
@@ -324,32 +336,35 @@ async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tm
managed=False,
)
try:
private = await service.create_skill(
repository = service.ap.skill_repository
await repository.create_skill(
second,
{
'name': 'private',
'instructions': 'workspace-b secret',
},
)
own_skill = await service.create_skill(
own_skill = await repository.create_skill(
first,
{
'name': 'runner',
'instructions': 'Run scripts/main.py',
},
)
await service.write_skill_file(first, 'runner', 'scripts/main.py', "print('ok')")
await service.write_skill_file(first, 'runner', 'requirements.txt', 'requests==2.32.0\n')
refreshed_skill = await service.get_skill(first, 'runner')
await repository.write_skill_file(first, 'runner', 'scripts/main.py', "print('ok')")
await repository.write_skill_file(first, 'runner', 'requirements.txt', 'requests==2.32.0\n')
refreshed_skill = await repository.get_skill(first, 'runner')
assert refreshed_skill is not None
assert refreshed_skill['python_project'] is True
await service.ap.skill_mgr.reload_skills(first)
query = _query(first, 91)
await service.execute_tool(
{
'command': 'python /workspace/.skills/runner/scripts/main.py',
'workdir': '/workspace/.skills/runner',
},
_query(first, 91),
skill_name='runner',
query,
read_only_mounts=skill_loader.build_execution_mounts(service.ap, query),
)
mounted_spec = backend.started_specs[-1]
@@ -358,20 +373,14 @@ async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tm
assert mounted_spec.extra_mounts[0].mount_path == '/workspace/.skills/runner'
assert mounted_spec.extra_mounts[0].mode.value == 'ro'
with pytest.raises(BoxAdmissionError, match='Scanning arbitrary host'):
await service.scan_skill_directory(first, private['package_root'])
with pytest.raises(BoxAdmissionError, match='package_root is runtime-owned'):
await service.create_skill(
first,
{
'name': 'stolen',
'package_root': private['package_root'],
},
)
assert await service.get_skill(first, 'private') is None
assert await repository.get_skill(first, 'private') is None
await repository.create_skill(
ineligible,
{'name': 'docs-only', 'instructions': 'Read this without Box.'},
)
assert [skill['name'] for skill in await repository.list_skills(ineligible)] == ['docs-only']
with pytest.raises(EntitlementUnavailableError):
await service.list_skills(ineligible)
await service.execute_tool({'command': 'true'}, _query(ineligible, 92))
finally:
server_task.cancel()
client_task.cancel()
+20 -39
View File
@@ -41,6 +41,7 @@ from langbot_plugin.box.security import (
from langbot_plugin.entities.io.context import ActionContext
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.box.service import BoxService
from langbot.pkg.provider.tools.loaders import skill as skill_loader
_UTC = dt.timezone.utc
_CONTEXT = ExecutionContext(
@@ -301,7 +302,7 @@ class TestSharesFilesystemWithBox:
- stdio (local child process) → shared filesystem → True
- WebSocket (Docker / sidecar / --standalone-box / remote) → separated → False
This drives whether LangBot validates Box-reported skill paths locally.
This drives whether LangBot can safely perform local workspace operations.
Getting it wrong silently drops every skill in separated deployments.
"""
@@ -338,7 +339,7 @@ class TestSharesFilesystemWithBox:
def test_false_when_client_injected_without_connector(self):
# Injected client (no connector) → unknown topology → conservative False
# so LangBot never wrongly drops Box-reported skills.
# so LangBot does not assume a shared local filesystem.
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
assert service._runtime_connector is None
@@ -552,7 +553,6 @@ async def test_box_service_reconnect_restores_workspace_and_runs_cleanup(
monkeypatch: pytest.MonkeyPatch,
):
app = make_app(Mock())
app.skill_mgr = SimpleNamespace(reload_skills=AsyncMock())
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
connector = Mock()
connector.reconnect = AsyncMock()
@@ -565,16 +565,14 @@ async def test_box_service_reconnect_restores_workspace_and_runs_cleanup(
connector.reconnect.assert_awaited_once()
service._ensure_default_workspace.assert_called_once()
service._purge_attachment_dirs.assert_awaited_once()
app.skill_mgr.reload_skills.assert_awaited_once()
assert service.available is True
@pytest.mark.asyncio
async def test_cloud_box_service_reconnect_does_not_reload_unscoped_skills(
async def test_cloud_box_service_reconnect_restores_runtime_only(
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()
@@ -587,7 +585,6 @@ async def test_cloud_box_service_reconnect_does_not_reload_unscoped_skills(
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
@@ -1941,7 +1938,7 @@ def test_disconnect_callback_does_not_schedule_without_running_event_loop():
assert service._reconnecting is False
class TestBuildSkillExtraMounts:
class TestBuildSkillExecutionMounts:
"""Robustness of skill mount construction against a stale skill cache.
The three sandbox backends behave inconsistently when a skill's
@@ -1951,16 +1948,10 @@ class TestBuildSkillExtraMounts:
the backend never sees a bad mount.
"""
def _make_service(self, logger, skills, *, shares_filesystem=True):
def _make_app(self, logger, skills):
app = make_app(logger)
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
# set the topology explicitly. Most cases exercise the shared-fs (local
# stdio) path where local package_root validation applies.
service._shares_filesystem_with_box_override = shares_filesystem
return service
return app
def test_skips_skill_with_missing_package_root(self):
logger = Mock()
@@ -1969,10 +1960,10 @@ class TestBuildSkillExtraMounts:
'alive': {'name': 'alive', 'package_root': live_dir},
'ghost': {'name': 'ghost', 'package_root': '/nonexistent/path/should/never/exist'},
}
service = self._make_service(logger, skills)
app = self._make_app(logger, skills)
query = make_query()
mounts = service.build_skill_extra_mounts(query)
mounts = skill_loader.build_execution_mounts(app, query)
assert mounts == [
{
@@ -1987,27 +1978,19 @@ class TestBuildSkillExtraMounts:
for call in logger.warning.call_args_list
)
def test_trusts_box_paths_when_filesystem_not_shared(self):
"""In separated deployments (Docker Compose, k8s sidecar,
--standalone-box, remote endpoint) the Box runtime owns its own
filesystem. package_root values it reports are NOT resolvable on the
LangBot side, so LangBot must trust them rather than dropping every
skill via a local isdir() check."""
def test_rejects_missing_core_paths_when_filesystem_not_shared(self):
"""Core owns package paths even when Box is a separate process."""
logger = Mock()
skills = {
'a': {'name': 'a', 'package_root': '/box/skills/a'},
'b': {'name': 'b', 'package_root': '/box/skills/b'},
}
service = self._make_service(logger, skills, shares_filesystem=False)
app = self._make_app(logger, skills)
mounts = service.build_skill_extra_mounts(make_query())
mounts = skill_loader.build_execution_mounts(app, make_query())
assert mounts == [
{'host_path': '/box/skills/a', 'mount_path': '/workspace/.skills/a', 'mode': 'ro'},
{'host_path': '/box/skills/b', 'mount_path': '/workspace/.skills/b', 'mode': 'ro'},
]
# No skill is dropped, so no "missing" warning should be logged.
assert not any('package_root missing' in str(call.args[0]) for call in logger.warning.call_args_list)
assert mounts == []
assert len(logger.warning.call_args_list) == 2
def test_skips_skill_with_empty_package_root(self):
logger = Mock()
@@ -2015,25 +1998,23 @@ class TestBuildSkillExtraMounts:
'no_root': {'name': 'no_root', 'package_root': ''},
'whitespace': {'name': 'whitespace', 'package_root': ' '},
}
service = self._make_service(logger, skills)
app = self._make_app(logger, skills)
assert service.build_skill_extra_mounts(make_query()) == []
assert skill_loader.build_execution_mounts(app, make_query()) == []
def test_empty_package_root_skipped_even_when_not_shared(self):
"""An empty package_root is always invalid regardless of topology."""
logger = Mock()
skills = {'no_root': {'name': 'no_root', 'package_root': ''}}
service = self._make_service(logger, skills, shares_filesystem=False)
app = self._make_app(logger, skills)
assert service.build_skill_extra_mounts(make_query()) == []
assert skill_loader.build_execution_mounts(app, make_query()) == []
def test_returns_empty_when_no_skill_manager(self):
logger = Mock()
app = make_app(logger)
# no skill_mgr attribute
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
assert service.build_skill_extra_mounts(make_query()) == []
assert skill_loader.build_execution_mounts(app, make_query()) == []
# ── Attachment passthrough (inbound / outbound) ─────────────────────────────
+1 -1
View File
@@ -13,8 +13,8 @@ from langbot.pkg.box.workspace import (
classify_python_workspace,
infer_workspace_host_path,
rewrite_mounted_path,
wrap_python_command_with_env,
)
from langbot.pkg.utils.python_workspace import wrap_python_command_with_env
_CONTEXT = ExecutionContext(
+32 -25
View File
@@ -614,7 +614,7 @@ class TestNativeToolLoaderSkillPaths:
ap.skill_repository.read_skill_file.assert_awaited_once_with(_CONTEXT, 'demo', 'SKILL.md')
@pytest.mark.asyncio
async def test_external_runtime_rejects_skill_host_fallback_without_protocol_capability(self):
async def test_core_owned_skill_path_does_not_depend_on_runtime_topology(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
@@ -634,18 +634,20 @@ class TestNativeToolLoaderSkillPaths:
variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']},
)
with pytest.raises(ValueError, match='owned by the Box Runtime'):
await loader.invoke_tool(
'grep',
{
'path': '/workspace/.skills/demo',
'pattern': 'core-host-secret',
},
query,
)
result = await loader.invoke_tool(
'grep',
{
'path': '/workspace/.skills/demo',
'pattern': 'core-host-secret',
},
query,
)
assert result['ok'] is True
assert result['total'] == 1
@pytest.mark.asyncio
async def test_exec_in_activated_skill_mount_rewrites_command_and_refreshes(self):
async def test_exec_in_activated_skill_mount_rewrites_command_without_mutating_skill(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
@@ -656,11 +658,15 @@ class TestNativeToolLoaderSkillPaths:
default_workspace=tmpdir,
execute_tool=AsyncMock(return_value={'ok': True}),
)
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
skill_data = _make_skill_data(name='demo', package_root=tmpdir)
ap.skill_mgr = _make_skill_manager(
{'demo': skill_data},
refresh_skill_from_disk=Mock(),
)
loader = NativeToolLoader(ap)
query = _make_query(query_id='q1', launcher_type='person', launcher_id='123')
register_activated_skill(query, _make_skill_data(name='demo', package_root=tmpdir))
register_activated_skill(query, skill_data)
result = await loader.invoke_tool(
'exec',
@@ -675,8 +681,8 @@ class TestNativeToolLoaderSkillPaths:
tool_parameters = ap.box_service.execute_tool.await_args.args[0]
assert tool_parameters['command'] == 'python /workspace/.skills/demo/scripts/run.py'
assert tool_parameters['workdir'] == '/workspace/.skills/demo'
assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo'
ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with(_CONTEXT, 'demo')
assert 'skill_name' not in ap.box_service.execute_tool.await_args.kwargs
ap.skill_mgr.refresh_skill_from_disk.assert_not_called()
@pytest.mark.asyncio
async def test_external_runtime_python_skill_uses_trusted_metadata_and_writable_env(self):
@@ -689,17 +695,18 @@ class TestNativeToolLoaderSkillPaths:
shares_filesystem_with_box=False,
execute_tool=AsyncMock(return_value={'ok': True}),
)
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
skill_data = _make_skill_data(
name='demo',
package_root='/box-runtime/skills/tenants/workspace/demo',
python_project=True,
)
ap.skill_mgr = _make_skill_manager(
{'demo': skill_data},
refresh_skill_from_disk=Mock(),
)
loader = NativeToolLoader(ap)
query = _make_query(query_id='q-external', launcher_type='person', launcher_id='123')
register_activated_skill(
query,
_make_skill_data(
name='demo',
package_root='/box-runtime/skills/tenants/workspace/demo',
python_project=True,
),
)
register_activated_skill(query, skill_data)
result = await loader.invoke_tool(
'exec',
@@ -716,7 +723,7 @@ class TestNativeToolLoaderSkillPaths:
assert '_LB_VENV_DIR="/workspace/.skill-envs/demo/.venv"' in wrapped
assert 'root = "/workspace/.skills/demo"' in wrapped
assert '/box-runtime/skills/tenants/workspace/demo' not in wrapped
assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo'
assert 'skill_name' not in ap.box_service.execute_tool.await_args.kwargs
@pytest.mark.asyncio
async def test_write_requires_skill_activation(self):
+27 -1
View File
@@ -28,11 +28,11 @@ def _repository(tmp_path) -> SkillRepository:
),
instance_config=SimpleNamespace(
data={
'skills': {'root': str(tmp_path / 'skill-store')},
'box': {
'enabled': False,
'local': {
'host_root': str(tmp_path / 'box'),
'skills_root': 'skills',
},
}
}
@@ -41,6 +41,32 @@ def _repository(tmp_path) -> SkillRepository:
return SkillRepository(app)
def test_repository_prefers_standalone_skill_root(tmp_path):
repository = _repository(tmp_path)
assert repository._store.root == str((tmp_path / 'skill-store').resolve())
def test_repository_keeps_old_box_root_only_for_online_upgrade(tmp_path):
app = SimpleNamespace(
workspace_service=SimpleNamespace(get_execution_binding=_binding),
instance_config=SimpleNamespace(
data={
'box': {
'local': {
'host_root': str(tmp_path / 'box'),
'skills_root': 'legacy-skills',
}
}
}
),
)
repository = SkillRepository(app)
assert repository._store.root == str((tmp_path / 'box' / 'legacy-skills').resolve())
@pytest.mark.asyncio
async def test_repository_crud_and_reads_do_not_require_box(tmp_path):
repository = _repository(tmp_path)