fix(box): persist runtime ownership across instance restarts (#2860)

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
RockChinQ
2026-11-04 13:50:52 +08:00
committed by GitHub
co-authored by multica-agent
parent 0a962769b9
commit 80775f03f2
6 changed files with 71 additions and 2 deletions
+10
View File
@@ -204,6 +204,16 @@ In this repo:
- `pkg/box/service.py` is the application-facing facade for exec, sessions, managed processes, status, reconnects, quotas, generic mounts, and sandbox profiles.
- [Outbox export failures](dev-docs/BOX_OUTBOX_EXPORT.md) documents bounded reads, permission fallback, and cleanup after Runner acceptance.
- `pkg/box/connector.py` connects to the Box Runtime over stdio, Windows subprocess+WebSocket, or remote WebSocket.
- Core-launched Box subprocesses receive a stable `LANGBOT_BOX_INSTANCE_ID`:
explicit `box.instance_id`, then the environment override, then Core's instance
UUID. Standalone Box identity is fixed before RPC initialization; configure it
at launch with `--instance-id` or `LANGBOT_BOX_INSTANCE_ID`, or persist its
generated identity with `LANGBOT_BOX_DATA_DIR`. Compose and Kubernetes keep
this directory under the shared Box root. Each deployment needs its own root
and identity. The runtime holds an identity lock, so Kubernetes uses Recreate
rather than overlapping two owners during rollout. INIT cannot reassign Box
ownership. The SDK implementation requires the instance-scoped cleanup change;
the pinned SDK version is unchanged here.
- `pkg/provider/tools/loaders/native.py` is the Core orchestration seam: the
Skill loader supplies generic read-only mounts to Box execution. `mcp_stdio.py`
and execution-backed tools depend on Box availability.
+2
View File
@@ -63,6 +63,8 @@ services:
restart: on-failure
environment:
- TZ=Asia/Shanghai
- LANGBOT_BOX_DATA_DIR=${LANGBOT_BOX_ROOT:-${PWD}/data/box}/runtime
- LANGBOT_BOX_INSTANCE_ID=${LANGBOT_BOX_INSTANCE_ID:-}
# Optional shared control-plane secret used to authenticate both the RPC
# socket and managed-process relay. Leave unset on both OSS services, or
# generate one with ``openssl rand -hex 32`` and set the same value on
+4
View File
@@ -270,6 +270,8 @@ metadata:
app: langbot-box
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: langbot-box
@@ -298,6 +300,8 @@ spec:
name: box-rpc
protocol: TCP
env:
- name: LANGBOT_BOX_DATA_DIR
value: /app/data/box/runtime
- name: TZ
valueFrom:
configMapKeyRef:
+10
View File
@@ -243,6 +243,11 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
env = os.environ.copy()
env[BOX_CONTROL_TOKEN_ENV] = self._control_token
env[BOX_TRUSTED_INSTANCE_ENV] = self._trusted_instance_uuid
env['LANGBOT_BOX_INSTANCE_ID'] = str(
self._filtered_box_config.get('instance_id')
or env.get('LANGBOT_BOX_INSTANCE_ID')
or self._trusted_instance_uuid
)
if self._filtered_box_config:
env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config)
@@ -281,6 +286,11 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
env = os.environ.copy()
env[BOX_CONTROL_TOKEN_ENV] = self._control_token
env[BOX_TRUSTED_INSTANCE_ENV] = self._trusted_instance_uuid
env['LANGBOT_BOX_INSTANCE_ID'] = str(
self._filtered_box_config.get('instance_id')
or env.get('LANGBOT_BOX_INSTANCE_ID')
or self._trusted_instance_uuid
)
if self._filtered_box_config:
env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config)
+7
View File
@@ -0,0 +1,7 @@
import pytest
@pytest.fixture(autouse=True)
def isolated_box_identity(monkeypatch, tmp_path):
monkeypatch.setenv('LANGBOT_BOX_DATA_DIR', str(tmp_path / 'runtime-data'))
monkeypatch.delenv('LANGBOT_BOX_INSTANCE_ID', raising=False)
+38 -2
View File
@@ -332,10 +332,27 @@ def test_external_box_runtime_rejects_invalid_configured_control_token(monkeypat
connector.get_control_headers()
@pytest.mark.parametrize(
('core_id', 'configured_id', 'environment_id', 'expected_id'),
[
('instance-a', None, None, 'instance-a'),
('instance_12345678-abcd', None, None, 'instance_12345678-abcd'),
('instance-a', None, 'env-id', 'env-id'),
('instance-a', 'config-id', 'env-id', 'config-id'),
],
)
async def test_local_stdio_injects_generated_token_and_trusted_instance(
monkeypatch: pytest.MonkeyPatch,
core_id,
configured_id,
environment_id,
expected_id,
):
monkeypatch.delenv(BOX_CONTROL_TOKEN_ENV, raising=False)
if environment_id:
monkeypatch.setenv('LANGBOT_BOX_INSTANCE_ID', environment_id)
else:
monkeypatch.delenv('LANGBOT_BOX_INSTANCE_ID', raising=False)
captured = {}
class FakeStdioClientController:
@@ -350,7 +367,11 @@ async def test_local_stdio_injects_generated_token_and_trusted_instance(
'langbot_plugin.runtime.io.controllers.stdio.client.StdioClientController',
FakeStdioClientController,
)
connector = BoxRuntimeConnector(make_app(Mock()))
app = make_app(Mock())
app.workspace_service.instance_uuid = core_id
if configured_id:
app.instance_config.data['box']['instance_id'] = configured_id
connector = BoxRuntimeConnector(app)
def fake_callback(_transport_name, connected, _connect_error, _generation):
async def callback(_connection):
@@ -363,7 +384,22 @@ async def test_local_stdio_injects_generated_token_and_trusted_instance(
await connector._start_local_stdio()
assert len(captured['env'][BOX_CONTROL_TOKEN_ENV]) >= 32
assert captured['env'][BOX_TRUSTED_INSTANCE_ENV] == 'instance-a'
assert captured['env'][BOX_TRUSTED_INSTANCE_ENV] == core_id
assert captured['env']['LANGBOT_BOX_INSTANCE_ID'] == expected_id
async def test_windows_subprocess_receives_stable_identity(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv('LANGBOT_BOX_INSTANCE_ID', raising=False)
process = Mock(wait=AsyncMock(return_value=0))
spawn = AsyncMock(return_value=process)
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
connector = BoxRuntimeConnector(make_app(Mock()))
monkeypatch.setattr(connector, '_connect_ws', AsyncMock())
await connector._start_subprocess_then_ws()
await connector.runtime_subprocess_task
assert spawn.await_args.kwargs['env']['LANGBOT_BOX_INSTANCE_ID'] == 'instance-a'
async def test_websocket_controller_receives_control_headers(monkeypatch: pytest.MonkeyPatch):