diff --git a/docs/multi-tenant/implementation-decisions.md b/docs/multi-tenant/implementation-decisions.md index a74c13f32..bdbe93fbf 100644 --- a/docs/multi-tenant/implementation-decisions.md +++ b/docs/multi-tenant/implementation-decisions.md @@ -103,11 +103,11 @@ This log records implementation choices made while delivering the Workspace arch - Decision: New Core JWTs require `iss=langbot-core`, an audience derived from the immutable instance UUID, and an expiry. Legacy community tokens are accepted only when they have the historical issuer, carry no audience, and the active policy is the OSS singleton policy. - Reason: A token issued by one instance must not authenticate against another instance that happens to share a secret, and a compatibility decoder must not become an alternate path around the SaaS trust boundary. -### Runtime control transports authenticate before protocol dispatch +### Runtime control transports support opt-in shared-secret authentication -- Decision: External Plugin Runtime and Box WebSocket control channels require independent strong shared secrets in handshake headers. Locally managed child processes receive ephemeral secrets through their environment; secrets are not placed in URLs, process arguments, request payloads, or logs. Box additionally binds the first authenticated control channel to one trusted instance. Plugin Runtime debug and control credentials remain separate. -- Reason: Workspace context inside an RPC payload is not trustworthy until the transport peer itself is authenticated. Separating control and debug credentials also limits accidental privilege reuse. -- Deployment consequence: Docker Compose and Kubernetes wire one shared secret to each host/runtime pair. An empty external-runtime secret fails startup instead of silently exposing an unauthenticated socket. +- Decision: OSS external Plugin Runtime and Box WebSocket control channels preserve tokenless standalone compatibility when the corresponding control token is unset. When a Runtime configures a token, it validates the independent shared secret in the handshake before protocol dispatch. Locally managed child processes still receive ephemeral secrets through their environment; secrets are not placed in URLs, process arguments, request payloads, or logs. Box additionally pins the first control channel to one declared instance identity. Plugin Runtime debug and control credentials remain separate. +- Reason: Local OSS development must remain backward compatible, while exposed or shared Runtime endpoints can opt into transport authentication. Separating control and debug credentials also limits accidental privilege reuse. +- Deployment consequence: Docker Compose and Kubernetes should wire one strong shared secret to each host/runtime pair. Both sides must use the same value for protection to be effective; a Runtime configured with a token rejects clients that omit it or send a different value. ### Dashboard WebSocket sessions are tenant runtime objects diff --git a/skills/skills/langbot-deploy/SKILL.md b/skills/skills/langbot-deploy/SKILL.md index 9115b3cd4..e03182e01 100644 --- a/skills/skills/langbot-deploy/SKILL.md +++ b/skills/skills/langbot-deploy/SKILL.md @@ -27,10 +27,11 @@ The `all` / `box` profile starts three services: - `langbot_box` — Box sandbox runtime (`:5410`). Uses the host Docker socket to spawn sandbox containers, so the **Box root host path and in-container path must be identical** (`BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}`). - Its RPC and managed-process relay require a shared - `LANGBOT_BOX_CONTROL_TOKEN` (at least 32 non-whitespace characters) in both - the LangBot and Box containers. Generate it once with `openssl rand -hex 32`; - never put it in `box.runtime.endpoint` or commit it to config. + OSS allows its RPC and managed-process relay to run without a token when both + sides leave `LANGBOT_BOX_CONTROL_TOKEN` unset. For an exposed endpoint, set + the same value of at least 32 non-whitespace characters in both the LangBot + and Box containers. Generate it once with `openssl rand -hex 32`; never put + it in `box.runtime.endpoint` or commit it to config. A Compose deployment may optionally set `LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` on both `langbot` and diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index 069fcb027..77d710693 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -398,7 +398,16 @@ class PluginsRouterGroup(group.RouterGroup): # Get debug URL from config plugin_config = self.ap.instance_config.data.get('plugin', {}) - debug_url = plugin_config.get('display_plugin_debug_url', 'http://localhost:5401') + debug_url = plugin_config.get( + 'display_plugin_debug_url', + 'ws://localhost:5401/plugin/debug/ws', + ) + parsed_debug_url = urlparse(debug_url) + if parsed_debug_url.scheme in {'http', 'https'}: + debug_url = parsed_debug_url._replace( + scheme='wss' if parsed_debug_url.scheme == 'https' else 'ws', + path=parsed_debug_url.path or '/plugin/debug/ws', + ).geturl() return self.success( data={ diff --git a/src/langbot/pkg/box/connector.py b/src/langbot/pkg/box/connector.py index 2ef990d0c..df3a0d7fa 100644 --- a/src/langbot/pkg/box/connector.py +++ b/src/langbot/pkg/box/connector.py @@ -367,6 +367,8 @@ class BoxRuntimeConnector(ManagedRuntimeConnector): def _ensure_control_token(self, *, allow_generate: bool) -> str: if not self._control_token and allow_generate: self._control_token = secrets.token_urlsafe(48) + if not self._control_token: + return '' try: self._control_token = validate_control_token(self._control_token) except ValueError as exc: @@ -376,19 +378,19 @@ class BoxRuntimeConnector(ManagedRuntimeConnector): return self._control_token def get_control_headers(self) -> dict[str, str]: - """Headers for the instance-authenticated RPC control handshake.""" + """Return instance-scoped RPC headers and the optional shared secret.""" self._ensure_control_token(allow_generate=False) - return { - BOX_CONTROL_TOKEN_HEADER: self._control_token, - BOX_INSTANCE_HEADER: self._trusted_instance_uuid, - } + headers = {BOX_INSTANCE_HEADER: self._trusted_instance_uuid} + if self._control_token: + headers[BOX_CONTROL_TOKEN_HEADER] = self._control_token + return headers def get_relay_headers( self, action_context: ActionContext, ) -> dict[str, str]: - """Return authenticated, placement-scoped relay handshake headers.""" + """Return instance- and placement-scoped relay handshake headers.""" context = ActionContext.model_validate(action_context).without_installation() if context.instance_uuid != self._trusted_instance_uuid: diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py index d642b10ea..e7029f625 100644 --- a/src/langbot/pkg/plugin/handler.py +++ b/src/langbot/pkg/plugin/handler.py @@ -1962,11 +1962,16 @@ class RuntimeConnectionHandler(handler.Handler): async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]: """Get debug information including debug key and WS URL""" + action_context = ActionContext( + instance_uuid=execution_context.instance_uuid, + workspace_uuid=execution_context.workspace_uuid, + placement_generation=execution_context.placement_generation, + ) result = await self.call_action( LangBotToRuntimeAction.GET_DEBUG_INFO, {}, timeout=10, - action_context=execution_context, + action_context=action_context, ) return result diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index a9d25086f..c21c9b320 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -328,8 +328,9 @@ box: enabled: true backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND. runtime: - # External WebSocket runtimes also require LANGBOT_BOX_CONTROL_TOKEN in - # both LangBot and Box. Keep the shared secret out of this config file. + # LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket + # runtimes. To protect an exposed endpoint, set the same strong secret + # in both LangBot and Box. Keep it out of this config file. endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime. limits: max_sessions: 64 diff --git a/tests/integration/api/test_plugins_security.py b/tests/integration/api/test_plugins_security.py index b42c25356..5f79f716a 100644 --- a/tests/integration/api/test_plugins_security.py +++ b/tests/integration/api/test_plugins_security.py @@ -235,13 +235,24 @@ async def test_debug_key_requires_resource_manage_permission(plugin_security_api assert operator_denied.status_code == 403 assert allowed.status_code == 200 assert (await allowed.get_json())['data'] == { - 'debug_url': 'http://localhost:5401', + 'debug_url': 'ws://localhost:5401/plugin/debug/ws', 'plugin_debug_key': 'runtime-debug-secret', 'expires_at': '2026-08-04T12:00:00Z', } application.plugin_connector.get_debug_info.assert_awaited_once() +@pytest.mark.asyncio +async def test_debug_info_uses_websocket_endpoint_for_legacy_config(plugin_security_api): + application, client, _ = plugin_security_api + application.instance_config.data['plugin'].pop('display_plugin_debug_url') + + response = await client.get('/api/v1/plugins/debug-info', headers=_headers('manager-token')) + + assert response.status_code == 200 + assert (await response.get_json())['data']['debug_url'] == 'ws://localhost:5401/plugin/debug/ws' + + @pytest.mark.asyncio async def test_viewer_cannot_read_plugin_runtime_logs(plugin_security_api): application, client, _ = plugin_security_api diff --git a/tests/unit_tests/box/test_box_connector.py b/tests/unit_tests/box/test_box_connector.py index 367b2e362..4d769f244 100644 --- a/tests/unit_tests/box/test_box_connector.py +++ b/tests/unit_tests/box/test_box_connector.py @@ -306,10 +306,19 @@ def test_box_runtime_connector_rejects_relay_context_from_other_instance( ) -def test_external_box_runtime_fails_closed_without_control_token(monkeypatch: pytest.MonkeyPatch): +def test_external_box_runtime_control_headers_are_tokenless_when_secret_is_unset( + monkeypatch: pytest.MonkeyPatch, +): monkeypatch.delenv(BOX_CONTROL_TOKEN_ENV, raising=False) connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410')) + assert connector.get_control_headers() == {BOX_INSTANCE_HEADER: 'instance-a'} + + +def test_external_box_runtime_rejects_invalid_configured_control_token(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(BOX_CONTROL_TOKEN_ENV, 'too-short') + connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410')) + with pytest.raises(BoxRuntimeUnavailableError, match=BOX_CONTROL_TOKEN_ENV): connector.get_control_headers() diff --git a/tests/unit_tests/plugin/test_handler_tenancy.py b/tests/unit_tests/plugin/test_handler_tenancy.py index 43856b9cf..371123eb4 100644 --- a/tests/unit_tests/plugin/test_handler_tenancy.py +++ b/tests/unit_tests/plugin/test_handler_tenancy.py @@ -444,3 +444,23 @@ async def test_host_to_runtime_action_carries_trusted_connector_context(): 'runtime_id': 'runtime-a', } assert request.get('context') is None + + +@pytest.mark.asyncio +async def test_get_debug_info_converts_execution_context_to_sdk_action_context(): + runtime_handler, _app, _installation_context = make_handler() + runtime_handler.call_action = AsyncMock(return_value={'plugin_debug_key': 'debug-key'}) + execution_context = ExecutionContext( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=7, + ) + + result = await runtime_handler.get_debug_info(execution_context) + + assert result == {'plugin_debug_key': 'debug-key'} + assert runtime_handler.call_action.await_args.kwargs['action_context'] == ActionContext( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=7, + )