mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-08 14:56:03 +00:00
feat(box): SaaS guard to force a single global sandbox scope
Add system.limitation.force_box_session_id_template: when non-empty it
overrides every pipeline's box-session-id-template at resolve time, pinning
all queries to one shared sandbox (e.g. {global}). This is the authoritative,
unbypassable guard — it runs on every exec call, so editing the pipeline
config via API cannot escape it. The web UI locks the Sandbox Scope selector
via a combined box_scope_editable flag (box available AND not forced).
This commit is contained in:
@@ -221,13 +221,25 @@ class BoxService:
|
||||
return self._serialize_result(result)
|
||||
|
||||
def resolve_box_session_id(self, query: pipeline_query.Query) -> str:
|
||||
"""Resolve the Box session_id from the pipeline's template and query variables."""
|
||||
template = (
|
||||
(query.pipeline_config or {})
|
||||
.get('ai', {})
|
||||
.get('local-agent', {})
|
||||
.get('box-session-id-template', '{launcher_type}_{launcher_id}')
|
||||
)
|
||||
"""Resolve the Box session_id from the pipeline's template and query variables.
|
||||
|
||||
When ``system.limitation.force_box_session_id_template`` is set to a
|
||||
non-empty value, that template overrides whatever the pipeline
|
||||
configured. This is the authoritative SaaS guard: it runs on every
|
||||
``exec`` call, so a tenant cannot escape a single shared sandbox even
|
||||
by editing the pipeline config directly through the API (which only
|
||||
gates the web UI).
|
||||
"""
|
||||
forced_template = self._forced_box_session_id_template()
|
||||
if forced_template:
|
||||
template = forced_template
|
||||
else:
|
||||
template = (
|
||||
(query.pipeline_config or {})
|
||||
.get('ai', {})
|
||||
.get('local-agent', {})
|
||||
.get('box-session-id-template', '{launcher_type}_{launcher_id}')
|
||||
)
|
||||
variables = dict(query.variables or {})
|
||||
launcher_type = getattr(query, 'launcher_type', None)
|
||||
if hasattr(launcher_type, 'value'):
|
||||
@@ -606,6 +618,20 @@ class BoxService:
|
||||
raw = str(self._local_config().get('image', '') or '').strip()
|
||||
return raw or None
|
||||
|
||||
def _forced_box_session_id_template(self) -> str:
|
||||
"""Return the SaaS-forced sandbox-scope template, or '' when unset.
|
||||
|
||||
Read from ``system.limitation.force_box_session_id_template``. A
|
||||
non-empty value pins every pipeline to a single sandbox scope
|
||||
(e.g. ``'{global}'``) and cannot be overridden per-pipeline.
|
||||
"""
|
||||
limitation = (
|
||||
(self.ap.instance_config.data or {}).get('system', {}).get('limitation', {})
|
||||
if getattr(self.ap, 'instance_config', None) is not None
|
||||
else {}
|
||||
)
|
||||
return str(limitation.get('force_box_session_id_template', '') or '').strip()
|
||||
|
||||
def _load_workspace_quota_mb(self) -> int | None:
|
||||
raw_value = self._local_config().get('workspace_quota_mb')
|
||||
if raw_value in (None, ''):
|
||||
|
||||
@@ -25,6 +25,12 @@ system:
|
||||
max_bots: -1
|
||||
max_pipelines: -1
|
||||
max_extensions: -1
|
||||
# When set to a non-empty string, every pipeline is forced to use this
|
||||
# Box sandbox-scope template regardless of its own configuration, and
|
||||
# the per-pipeline "Sandbox Scope" selector is locked in the web UI.
|
||||
# Used by SaaS deployments to confine a tenant to a single shared
|
||||
# sandbox (set to '{global}'). Empty string = no restriction.
|
||||
force_box_session_id_template: ''
|
||||
task_retention:
|
||||
# Keep at most this many completed async task records in memory
|
||||
completed_limit: 200
|
||||
|
||||
@@ -152,21 +152,22 @@ stages:
|
||||
es_ES: Determina cómo se comparten los entornos sandbox entre mensajes.
|
||||
ru_RU: Определяет, как песочницы используются совместно между сообщениями.
|
||||
disable_if:
|
||||
field: __system.box_available
|
||||
field: __system.box_scope_editable
|
||||
operator: eq
|
||||
value: false
|
||||
disabled_tooltip:
|
||||
en_US: >-
|
||||
Box sandbox is disabled or unavailable. Enable it in config.yaml
|
||||
(box.enabled = true) and ensure the runtime is reachable to change
|
||||
this setting.
|
||||
zh_Hans: Box 沙箱已禁用或不可用。请在配置中启用(box.enabled = true)并确认运行时连接正常,才能修改此项。
|
||||
zh_Hant: Box 沙箱已停用或無法使用。請在設定中啟用(box.enabled = true)並確認執行時連線正常,才能修改此項。
|
||||
ja_JP: Box サンドボックスが無効または利用できません。設定で有効化(box.enabled = true)し、ランタイムが接続できることを確認してから変更してください。
|
||||
vi_VN: Sandbox Box đã tắt hoặc không khả dụng. Hãy bật trong cấu hình (box.enabled = true) và đảm bảo runtime hoạt động để chỉnh sửa.
|
||||
th_TH: Sandbox Box ถูกปิดใช้งานหรือไม่พร้อมใช้งาน กรุณาเปิดใช้งานในการตั้งค่า (box.enabled = true) และตรวจสอบว่ารันไทม์เชื่อมต่อปกติก่อนปรับค่า
|
||||
es_ES: El sandbox de Box está desactivado o no disponible. Actívelo en la configuración (box.enabled = true) y asegúrese de que el runtime esté conectado para modificar este ajuste.
|
||||
ru_RU: Песочница Box отключена или недоступна. Включите её в конфигурации (box.enabled = true) и убедитесь, что среда выполнения работает, чтобы изменить эту настройку.
|
||||
Sandbox scope can't be changed: either the Box sandbox is disabled
|
||||
or unavailable (enable it in config.yaml with box.enabled = true and
|
||||
ensure the runtime is reachable), or this deployment pins all
|
||||
pipelines to a fixed scope.
|
||||
zh_Hans: "无法修改沙箱作用域:Box 沙箱已禁用或不可用(请在配置中启用 box.enabled = true 并确认运行时连接正常),或本部署已将所有流水线固定为统一作用域。"
|
||||
zh_Hant: "無法修改沙箱作用域:Box 沙箱已停用或無法使用(請在設定中啟用 box.enabled = true 並確認執行時連線正常),或本部署已將所有流水線固定為統一作用域。"
|
||||
ja_JP: "サンドボックススコープを変更できません:Box サンドボックスが無効/利用不可(設定で box.enabled = true にしてランタイム接続を確認)、またはこのデプロイがすべてのパイプラインを固定スコープに制限しています。"
|
||||
vi_VN: "Không thể thay đổi phạm vi sandbox:Box sandbox bị tắt hoặc không khả dụng (bật box.enabled = true và đảm bảo runtime hoạt động), hoặc bản triển khai này cố định mọi pipeline về một phạm vi."
|
||||
th_TH: "ไม่สามารถเปลี่ยนขอบเขต Sandbox:Box sandbox ถูกปิดหรือไม่พร้อมใช้งาน (เปิด box.enabled = true และตรวจสอบรันไทม์) หรือการ deploy นี้ล็อกทุก pipeline ไว้ที่ขอบเขตเดียว"
|
||||
es_ES: "No se puede cambiar el alcance del sandbox: el sandbox de Box está desactivado o no disponible (actívelo con box.enabled = true y verifique el runtime), o este despliegue fija todas las pipelines a un alcance único."
|
||||
ru_RU: "Невозможно изменить область песочницы: песочница Box отключена или недоступна (включите box.enabled = true и проверьте среду выполнения), либо это развёртывание фиксирует единую область для всех конвейеров."
|
||||
type: select
|
||||
required: false
|
||||
default: "{launcher_type}_{launcher_id}"
|
||||
|
||||
@@ -153,6 +153,7 @@ def make_app(
|
||||
host_root: str = '',
|
||||
workspace_quota_mb: int | None = None,
|
||||
enabled: bool = True,
|
||||
force_box_session_id_template: str = '',
|
||||
):
|
||||
box_config = {
|
||||
'enabled': enabled,
|
||||
@@ -171,7 +172,12 @@ def make_app(
|
||||
|
||||
return SimpleNamespace(
|
||||
logger=logger,
|
||||
instance_config=SimpleNamespace(data={'box': box_config}),
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'box': box_config,
|
||||
'system': {'limitation': {'force_box_session_id_template': force_box_session_id_template}},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -362,6 +368,69 @@ async def test_box_service_session_id_falls_back_to_query_id_for_synthetic_queri
|
||||
assert backend.start_calls == ['query_7']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_service_forced_global_scope_overrides_pipeline_template():
|
||||
"""SaaS guard: a non-empty ``force_box_session_id_template`` pins every
|
||||
query to one shared sandbox regardless of the pipeline's own scope."""
|
||||
logger = Mock()
|
||||
backend = FakeBackend(logger)
|
||||
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
|
||||
service = BoxService(
|
||||
make_app(logger, force_box_session_id_template='{global}'),
|
||||
client=_InProcessBoxRuntimeClient(logger, runtime),
|
||||
)
|
||||
await service.initialize()
|
||||
|
||||
# Two distinct callers that would otherwise get separate sandboxes.
|
||||
q1 = pipeline_query.Query.model_construct(query_id=1, launcher_type='group', launcher_id='room-1')
|
||||
q2 = pipeline_query.Query.model_construct(query_id=2, launcher_type='person', launcher_id='alice')
|
||||
|
||||
r1 = await service.execute_tool({'command': 'pwd'}, q1)
|
||||
r2 = await service.execute_tool({'command': 'pwd'}, q2)
|
||||
|
||||
assert r1['session_id'] == 'global'
|
||||
assert r2['session_id'] == 'global'
|
||||
# Only one sandbox was ever started — the shared global one.
|
||||
assert backend.start_calls == ['global']
|
||||
|
||||
|
||||
def test_box_service_forced_template_ignores_pipeline_config():
|
||||
"""The forced template wins even when the pipeline explicitly sets a
|
||||
per-user scope — proving the override is not bypassable via pipeline config."""
|
||||
logger = Mock()
|
||||
service = BoxService(
|
||||
make_app(logger, force_box_session_id_template='{global}'),
|
||||
client=Mock(spec=BoxRuntimeClient),
|
||||
)
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id=7,
|
||||
launcher_type='person',
|
||||
launcher_id='test_user',
|
||||
sender_id='test_user',
|
||||
pipeline_config={'ai': {'local-agent': {'box-session-id-template': '{launcher_type}_{launcher_id}_{sender_id}'}}},
|
||||
)
|
||||
|
||||
assert service.resolve_box_session_id(query) == 'global'
|
||||
|
||||
|
||||
def test_box_service_empty_forced_template_respects_pipeline_config():
|
||||
"""An empty/whitespace forced template is a no-op: the pipeline's own
|
||||
scope template is honoured (default non-SaaS behaviour)."""
|
||||
logger = Mock()
|
||||
service = BoxService(
|
||||
make_app(logger, force_box_session_id_template=' '),
|
||||
client=Mock(spec=BoxRuntimeClient),
|
||||
)
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id=7,
|
||||
launcher_type='group',
|
||||
launcher_id='room-1',
|
||||
pipeline_config={'ai': {'local-agent': {'box-session-id-template': '{launcher_type}_{launcher_id}'}}},
|
||||
)
|
||||
|
||||
assert service.resolve_box_session_id(query) == 'group_room-1'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_service_fails_closed_when_backend_unavailable():
|
||||
logger = Mock()
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';
|
||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -420,9 +421,20 @@ export default function PipelineFormComponent({
|
||||
// opt-in via ``disable_if`` + ``disabled_tooltip`` rather than every page
|
||||
// hard-coding a banner. Field-level gating keeps unrelated fields
|
||||
// untouched.
|
||||
//
|
||||
// ``box_scope_editable`` folds the two reasons the Sandbox Scope selector
|
||||
// can be locked into a single flag the yaml ``disable_if`` consumes:
|
||||
// 1. Box sandbox is unavailable, or
|
||||
// 2. the deployment pins all pipelines to a fixed scope via
|
||||
// ``system.limitation.force_box_session_id_template`` (SaaS).
|
||||
const boxScopeForced =
|
||||
!!systemInfo.limitation?.force_box_session_id_template;
|
||||
const stageSystemContext =
|
||||
stage.name === 'local-agent'
|
||||
? { box_available: boxAvailable }
|
||||
? {
|
||||
box_available: boxAvailable,
|
||||
box_scope_editable: boxAvailable && !boxScopeForced,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
|
||||
@@ -325,6 +325,10 @@ export interface SystemLimitation {
|
||||
max_bots: number;
|
||||
max_pipelines: number;
|
||||
max_extensions: number;
|
||||
/** When non-empty, every pipeline is forced to this Box sandbox-scope
|
||||
* template (e.g. ``{global}``) and the per-pipeline "Sandbox Scope"
|
||||
* selector is locked. Used by SaaS deployments. Empty = no restriction. */
|
||||
force_box_session_id_template?: string;
|
||||
}
|
||||
|
||||
export interface WizardProgress {
|
||||
|
||||
Reference in New Issue
Block a user