diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index a4ef0dd3..65844de0 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -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, ''): diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 753b59d8..fc24f921 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -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 diff --git a/src/langbot/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml index 16b21069..00c041c7 100644 --- a/src/langbot/templates/metadata/pipeline/ai.yaml +++ b/src/langbot/templates/metadata/pipeline/ai.yaml @@ -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}" diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index c7fe7c61..4e947653 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -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() diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index de192298..fc0a6812 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -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 ( diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 44edd872..aa1e2c8b 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -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 {