mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-14 06:30:57 +00:00
fix(runtime): restore standalone runtime compatibility
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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: 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.
|
- 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 wire one shared secret to each host/runtime pair. An empty external-runtime secret fails startup instead of silently exposing an unauthenticated socket.
|
- 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
|
### Dashboard WebSocket sessions are tenant runtime objects
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ The `all` / `box` profile starts three services:
|
|||||||
- `langbot_box` — Box sandbox runtime (`:5410`). Uses the host Docker socket to
|
- `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
|
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}`).
|
must be identical** (`BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}`).
|
||||||
Its RPC and managed-process relay require a shared
|
OSS allows its RPC and managed-process relay to run without a token when both
|
||||||
`LANGBOT_BOX_CONTROL_TOKEN` (at least 32 non-whitespace characters) in both
|
sides leave `LANGBOT_BOX_CONTROL_TOKEN` unset. For an exposed endpoint, set
|
||||||
the LangBot and Box containers. Generate it once with `openssl rand -hex 32`;
|
the same value of at least 32 non-whitespace characters in both the LangBot
|
||||||
never put it in `box.runtime.endpoint` or commit it to config.
|
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
|
A Compose deployment may optionally set
|
||||||
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` on both `langbot` and
|
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` on both `langbot` and
|
||||||
|
|||||||
@@ -398,7 +398,16 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
# Get debug URL from config
|
# Get debug URL from config
|
||||||
plugin_config = self.ap.instance_config.data.get('plugin', {})
|
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(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
|
|||||||
@@ -367,6 +367,8 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
def _ensure_control_token(self, *, allow_generate: bool) -> str:
|
def _ensure_control_token(self, *, allow_generate: bool) -> str:
|
||||||
if not self._control_token and allow_generate:
|
if not self._control_token and allow_generate:
|
||||||
self._control_token = secrets.token_urlsafe(48)
|
self._control_token = secrets.token_urlsafe(48)
|
||||||
|
if not self._control_token:
|
||||||
|
return ''
|
||||||
try:
|
try:
|
||||||
self._control_token = validate_control_token(self._control_token)
|
self._control_token = validate_control_token(self._control_token)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -376,19 +378,19 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
return self._control_token
|
return self._control_token
|
||||||
|
|
||||||
def get_control_headers(self) -> dict[str, str]:
|
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)
|
self._ensure_control_token(allow_generate=False)
|
||||||
return {
|
headers = {BOX_INSTANCE_HEADER: self._trusted_instance_uuid}
|
||||||
BOX_CONTROL_TOKEN_HEADER: self._control_token,
|
if self._control_token:
|
||||||
BOX_INSTANCE_HEADER: self._trusted_instance_uuid,
|
headers[BOX_CONTROL_TOKEN_HEADER] = self._control_token
|
||||||
}
|
return headers
|
||||||
|
|
||||||
def get_relay_headers(
|
def get_relay_headers(
|
||||||
self,
|
self,
|
||||||
action_context: ActionContext,
|
action_context: ActionContext,
|
||||||
) -> dict[str, str]:
|
) -> 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()
|
context = ActionContext.model_validate(action_context).without_installation()
|
||||||
if context.instance_uuid != self._trusted_instance_uuid:
|
if context.instance_uuid != self._trusted_instance_uuid:
|
||||||
|
|||||||
@@ -1962,11 +1962,16 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
|
|
||||||
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
|
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
|
||||||
"""Get debug information including debug key and WS URL"""
|
"""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(
|
result = await self.call_action(
|
||||||
LangBotToRuntimeAction.GET_DEBUG_INFO,
|
LangBotToRuntimeAction.GET_DEBUG_INFO,
|
||||||
{},
|
{},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
action_context=execution_context,
|
action_context=action_context,
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -328,8 +328,9 @@ box:
|
|||||||
enabled: true
|
enabled: true
|
||||||
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
|
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
|
||||||
runtime:
|
runtime:
|
||||||
# External WebSocket runtimes also require LANGBOT_BOX_CONTROL_TOKEN in
|
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
|
||||||
# both LangBot and Box. Keep the shared secret out of this config file.
|
# 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.
|
endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime.
|
||||||
limits:
|
limits:
|
||||||
max_sessions: 64
|
max_sessions: 64
|
||||||
|
|||||||
@@ -235,13 +235,24 @@ async def test_debug_key_requires_resource_manage_permission(plugin_security_api
|
|||||||
assert operator_denied.status_code == 403
|
assert operator_denied.status_code == 403
|
||||||
assert allowed.status_code == 200
|
assert allowed.status_code == 200
|
||||||
assert (await allowed.get_json())['data'] == {
|
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',
|
'plugin_debug_key': 'runtime-debug-secret',
|
||||||
'expires_at': '2026-08-04T12:00:00Z',
|
'expires_at': '2026-08-04T12:00:00Z',
|
||||||
}
|
}
|
||||||
application.plugin_connector.get_debug_info.assert_awaited_once()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_viewer_cannot_read_plugin_runtime_logs(plugin_security_api):
|
async def test_viewer_cannot_read_plugin_runtime_logs(plugin_security_api):
|
||||||
application, client, _ = plugin_security_api
|
application, client, _ = plugin_security_api
|
||||||
|
|||||||
@@ -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)
|
monkeypatch.delenv(BOX_CONTROL_TOKEN_ENV, raising=False)
|
||||||
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
|
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):
|
with pytest.raises(BoxRuntimeUnavailableError, match=BOX_CONTROL_TOKEN_ENV):
|
||||||
connector.get_control_headers()
|
connector.get_control_headers()
|
||||||
|
|
||||||
|
|||||||
@@ -444,3 +444,23 @@ async def test_host_to_runtime_action_carries_trusted_connector_context():
|
|||||||
'runtime_id': 'runtime-a',
|
'runtime_id': 'runtime-a',
|
||||||
}
|
}
|
||||||
assert request.get('context') is None
|
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,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user