feat(box): configurable sandbox scope and unified skill containers

Replace the per-message session_id with a template-based system
configurable per pipeline via 'Sandbox Scope' in the local-agent panel.
Default scope is per-chat ({launcher_type}_{launcher_id}).

Unify skill exec into the same container as default exec — skills are
mounted at /workspace/.skills/{name}/ via extra_mounts instead of
getting separate containers. All pipeline-bound skills are injected
at container creation time.

- Add box-session-id-template to pipeline metadata (select, 4 options, 8 languages)
- Add resolve_box_session_id() and build_skill_extra_mounts() to BoxService
- Rewrite native.py skill exec path to use execute_tool with shared session
- Update tests for new session_id format
- Add design doc: docs/review/box-session-scope.md
This commit is contained in:
Junyan Qin
2026-04-18 22:11:28 +08:00
committed by WangCham
parent ec00e49ef1
commit 7e50063731
5 changed files with 466 additions and 44 deletions
+22 -11
View File
@@ -125,7 +125,18 @@ class FakeBackend(BaseSandboxBackend):
def make_query(query_id: int = 42) -> pipeline_query.Query:
return pipeline_query.Query.model_construct(query_id=query_id)
return pipeline_query.Query.model_construct(
query_id=query_id,
launcher_type='person',
launcher_id='test_user',
sender_id='test_user',
variables={
'launcher_type': 'person',
'launcher_id': 'test_user',
'sender_id': 'test_user',
'query_id': str(query_id),
},
)
def make_app(
@@ -146,9 +157,7 @@ def make_app(
return SimpleNamespace(
logger=logger,
instance_config=SimpleNamespace(
data={'box': box_config}
),
instance_config=SimpleNamespace(data={'box': box_config}),
)
@@ -241,9 +250,9 @@ async def test_box_service_defaults_session_id_from_query():
result = await service.execute_tool({'command': 'pwd'}, make_query(7))
assert result['session_id'] == '7'
assert result['session_id'] == 'person_test_user'
assert result['ok'] is True
assert backend.start_calls == ['7']
assert backend.start_calls == ['person_test_user']
@pytest.mark.asyncio
@@ -297,8 +306,8 @@ async def test_box_service_uses_default_host_workspace_when_host_path_omitted(tm
result = await service.execute_tool({'command': 'pwd'}, make_query(15))
assert result['ok'] is True
assert backend.start_calls == ['15']
assert backend.exec_calls == [('15', 'pwd')]
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)
@@ -733,8 +742,8 @@ async def test_box_service_rejects_and_cleans_up_when_execution_exceeds_workspac
with pytest.raises(BoxValidationError, match='workspace quota exceeded after execution'):
await service.execute_tool({'command': 'generate-output'}, make_query(45))
assert backend.start_calls == ['45']
assert backend.stop_calls == ['45']
assert backend.start_calls == ['person_test_user']
assert backend.stop_calls == ['person_test_user']
@pytest.mark.asyncio
@@ -748,7 +757,9 @@ async def test_profile_offline_readonly_locks_read_only_rootfs():
)
await service.initialize()
await service.execute_spec_payload({'cmd': 'echo hi', 'read_only_rootfs': False, 'session_id': '41'}, make_query(41))
await service.execute_spec_payload(
{'cmd': 'echo hi', 'read_only_rootfs': False, 'session_id': '41'}, make_query(41)
)
spec = backend.start_specs[0]
assert spec.read_only_rootfs is True