diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 02b59f1ea..a4bcb7eee 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index cf74c6711..d1ecb1bdc 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -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 diff --git a/docker/kubernetes.yaml b/docker/kubernetes.yaml index 189641a96..1241ee1ad 100644 --- a/docker/kubernetes.yaml +++ b/docker/kubernetes.yaml @@ -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: diff --git a/src/langbot/pkg/box/connector.py b/src/langbot/pkg/box/connector.py index c1771e2f6..90e317b2c 100644 --- a/src/langbot/pkg/box/connector.py +++ b/src/langbot/pkg/box/connector.py @@ -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) diff --git a/tests/unit_tests/box/conftest.py b/tests/unit_tests/box/conftest.py new file mode 100644 index 000000000..9ab0df23f --- /dev/null +++ b/tests/unit_tests/box/conftest.py @@ -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) diff --git a/tests/unit_tests/box/test_box_connector.py b/tests/unit_tests/box/test_box_connector.py index 0949801c6..1fec77a92 100644 --- a/tests/unit_tests/box/test_box_connector.py +++ b/tests/unit_tests/box/test_box_connector.py @@ -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):