mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-26 14:26:06 +00:00
feat(tenancy): harden shared cloud runtime boundaries
This commit is contained in:
+379
-74
@@ -5,8 +5,10 @@ import collections
|
||||
import contextlib
|
||||
import datetime as _dt
|
||||
import enum
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pydantic
|
||||
@@ -14,11 +16,14 @@ import pydantic
|
||||
from langbot_plugin.box.client import BoxRuntimeClient
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
from langbot_plugin.box.tenancy import box_namespace
|
||||
from langbot_plugin.box.security import BOX_SHARED_WORKSPACE_PROBE_PREFIX
|
||||
from .admission import SandboxAdmissionController, require_cloud_admission_policy
|
||||
from .connector import BoxRuntimeConnector, _get_box_config
|
||||
from . import secure_fs
|
||||
from ..telemetry import features as telemetry_features
|
||||
from ..api.http.context import ExecutionContext
|
||||
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
|
||||
from langbot_plugin.box.errors import BoxError, BoxValidationError
|
||||
from langbot_plugin.box.errors import BoxAdmissionError, BoxError, BoxValidationError
|
||||
from langbot_plugin.box.models import (
|
||||
BUILTIN_PROFILES,
|
||||
BoxExecutionResult,
|
||||
@@ -52,6 +57,7 @@ class BoxService:
|
||||
output_limit_chars: int = 4000,
|
||||
):
|
||||
self.ap = ap
|
||||
self._cloud_managed = bool(getattr(getattr(ap, 'deployment', None), 'multi_workspace_enabled', False))
|
||||
self._enabled = self._load_enabled()
|
||||
self._runtime_connector: BoxRuntimeConnector | None = None
|
||||
if client is None:
|
||||
@@ -68,6 +74,18 @@ class BoxService:
|
||||
self.profile = self._load_profile()
|
||||
self.custom_image = self._load_custom_image()
|
||||
self.workspace_quota_mb = self._load_workspace_quota_mb()
|
||||
self._admission_policy = (
|
||||
require_cloud_admission_policy(_get_box_config(ap).get('admission')) if self._cloud_managed else None
|
||||
)
|
||||
self._admission = (
|
||||
SandboxAdmissionController(
|
||||
ap,
|
||||
self.client,
|
||||
policy=self._admission_policy,
|
||||
)
|
||||
if self._cloud_managed and self._admission_policy is not None
|
||||
else None
|
||||
)
|
||||
self._recent_errors: collections.deque[dict] = collections.deque(maxlen=_MAX_RECENT_ERRORS)
|
||||
self._shutdown_task = None
|
||||
self._reconnect_task: asyncio.Task | None = None
|
||||
@@ -88,6 +106,10 @@ class BoxService:
|
||||
``available = False`` to consumers, but distinguished in get_status."""
|
||||
return self._enabled
|
||||
|
||||
@property
|
||||
def managed_admission_required(self) -> bool:
|
||||
return self._cloud_managed
|
||||
|
||||
async def initialize(self):
|
||||
if not self._enabled:
|
||||
# Disabled by config: do NOT connect to a remote runtime, do NOT
|
||||
@@ -106,19 +128,24 @@ class BoxService:
|
||||
else:
|
||||
await self.client.initialize()
|
||||
self._ensure_default_workspace()
|
||||
await self._verify_cloud_runtime()
|
||||
self._available = True
|
||||
self._connector_error = ''
|
||||
self.ap.logger.info(
|
||||
f'LangBot Box runtime initialized: profile={self.profile.name} '
|
||||
f'default_workspace={self.default_workspace or "(none)"}'
|
||||
)
|
||||
await self._purge_attachment_dirs()
|
||||
# Cloud query directories use globally opaque query UUIDs. Never
|
||||
# sweep all tenants when a future replica joins the same logical
|
||||
# instance; that could delete another replica's in-flight files.
|
||||
if not self._cloud_managed:
|
||||
await self._purge_attachment_dirs()
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(f'LangBot Box runtime unavailable, sandbox features disabled: {exc}')
|
||||
self._available = False
|
||||
self._connector_error = str(exc)
|
||||
if self._runtime_connector is not None:
|
||||
await self._on_runtime_disconnect(self._runtime_connector)
|
||||
if self._cloud_managed:
|
||||
raise
|
||||
|
||||
async def _on_runtime_disconnect(self, connector: BoxRuntimeConnector) -> None:
|
||||
"""Called by the connector when the Box runtime connection drops.
|
||||
@@ -146,9 +173,9 @@ class BoxService:
|
||||
self.ap.logger.info(f'Attempting to reconnect to Box runtime in {delay}s...')
|
||||
await asyncio.sleep(delay)
|
||||
try:
|
||||
await connector.reconnect()
|
||||
self._ensure_default_workspace()
|
||||
await self._purge_attachment_dirs()
|
||||
connector.dispose()
|
||||
await connector.initialize()
|
||||
await self._verify_cloud_runtime()
|
||||
self._available = True
|
||||
self._connector_error = ''
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
@@ -165,6 +192,83 @@ class BoxService:
|
||||
self._reconnecting = False
|
||||
self._reconnect_task = None
|
||||
|
||||
async def _verify_cloud_runtime(self) -> None:
|
||||
if not self._cloud_managed:
|
||||
return
|
||||
self._ensure_cloud_shared_workspace()
|
||||
await self._challenge_cloud_shared_workspace()
|
||||
backend_info = await self.client.get_backend_info()
|
||||
if (
|
||||
not isinstance(backend_info, dict)
|
||||
or backend_info.get('name') != 'nsjail'
|
||||
or backend_info.get('available') is not True
|
||||
):
|
||||
raise BoxValidationError('Cloud Box nsjail isolation readiness failed')
|
||||
|
||||
async def _challenge_cloud_shared_workspace(self) -> None:
|
||||
"""Prove Core and Box Runtime see the same durable filesystem.
|
||||
|
||||
Equal configured path strings are not evidence of a shared container
|
||||
volume. Core creates one high-entropy, no-follow marker under its
|
||||
canonical root and the authenticated Runtime host-control action reads
|
||||
that basename only. Any mismatch fails Cloud startup/reconnect closed.
|
||||
"""
|
||||
|
||||
if self.default_workspace is None:
|
||||
raise BoxValidationError('Cloud Box shared default_workspace is unavailable')
|
||||
|
||||
marker_name = f'{BOX_SHARED_WORKSPACE_PROBE_PREFIX}{secrets.token_hex(16)}'
|
||||
marker_payload = secrets.token_bytes(64)
|
||||
expected_digest = hashlib.sha256(marker_payload).hexdigest()
|
||||
directory_flags = os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0)
|
||||
nofollow = getattr(os, 'O_NOFOLLOW', 0)
|
||||
root_fd: int | None = None
|
||||
marker_fd: int | None = None
|
||||
marker_created = False
|
||||
try:
|
||||
root_fd = os.open(self.default_workspace, directory_flags | nofollow)
|
||||
marker_fd = os.open(
|
||||
marker_name,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | nofollow,
|
||||
0o600,
|
||||
dir_fd=root_fd,
|
||||
)
|
||||
marker_created = True
|
||||
remaining = memoryview(marker_payload)
|
||||
while remaining:
|
||||
written = os.write(marker_fd, remaining)
|
||||
if written <= 0:
|
||||
raise BoxValidationError('Failed to write Cloud Box shared-volume probe')
|
||||
remaining = remaining[written:]
|
||||
os.fsync(marker_fd)
|
||||
os.close(marker_fd)
|
||||
marker_fd = None
|
||||
|
||||
result = await self.client.verify_shared_workspace(marker_name)
|
||||
if (
|
||||
not isinstance(result, dict)
|
||||
or result.get('marker_name') != marker_name
|
||||
or result.get('size') != len(marker_payload)
|
||||
or not secrets.compare_digest(str(result.get('sha256') or ''), expected_digest)
|
||||
):
|
||||
raise BoxValidationError(
|
||||
'Cloud Box Core and Runtime do not share the configured durable Workspace volume'
|
||||
)
|
||||
except BoxValidationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise BoxValidationError('Cloud Box shared durable Workspace volume verification failed') from exc
|
||||
finally:
|
||||
if marker_fd is not None:
|
||||
os.close(marker_fd)
|
||||
if root_fd is not None:
|
||||
if marker_created:
|
||||
try:
|
||||
os.unlink(marker_name, dir_fd=root_fd)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
os.close(root_fd)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._available
|
||||
@@ -211,10 +315,14 @@ class BoxService:
|
||||
bot_uuid=getattr(context, 'bot_uuid', None),
|
||||
pipeline_uuid=getattr(context, 'pipeline_uuid', None),
|
||||
query_uuid=getattr(context, 'query_uuid', None),
|
||||
entitlement_revision=getattr(context, 'entitlement_revision', 0),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _query_execution_context(cls, query: pipeline_query.Query) -> ExecutionContext:
|
||||
attached_context = getattr(query, '_execution_context', None)
|
||||
if isinstance(attached_context, ExecutionContext):
|
||||
return cls._execution_context(attached_context)
|
||||
return cls._execution_context(
|
||||
ExecutionContext(
|
||||
instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
|
||||
@@ -223,6 +331,7 @@ class BoxService:
|
||||
bot_uuid=getattr(query, 'bot_uuid', None),
|
||||
pipeline_uuid=getattr(query, 'pipeline_uuid', None),
|
||||
query_uuid=getattr(query, 'query_uuid', None),
|
||||
entitlement_revision=getattr(query, 'entitlement_revision', 0),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -252,6 +361,116 @@ class BoxService:
|
||||
raise BoxValidationError('Box execution context belongs to a stale Workspace placement')
|
||||
return execution_context
|
||||
|
||||
async def require_workspace_sandbox(self, context: TenantContext) -> ExecutionContext:
|
||||
"""Fence a Workspace and install its short-lived Cloud admission grant.
|
||||
|
||||
OSS keeps the existing singleton behavior and does not require a
|
||||
Control Plane entitlement. Cloud always resolves a fresh generic
|
||||
entitlement before a sandbox-visible operation.
|
||||
"""
|
||||
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
await self._require_validated_workspace_sandbox(execution_context)
|
||||
return execution_context
|
||||
|
||||
async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
|
||||
if not self._available:
|
||||
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
|
||||
if self._cloud_managed:
|
||||
if self._admission is None:
|
||||
raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
|
||||
await self._admission.require(execution_context)
|
||||
|
||||
async def is_workspace_sandbox_available(self, context: TenantContext) -> bool:
|
||||
"""Return tenant-specific availability for UI and tool discovery.
|
||||
|
||||
This method deliberately catches entitlement failures so callers can
|
||||
hide tools without leaking plan details. Direct execution APIs use
|
||||
:meth:`require_workspace_sandbox` and retain an explicit failure.
|
||||
"""
|
||||
|
||||
if not self._available:
|
||||
return False
|
||||
try:
|
||||
await self.require_workspace_sandbox(context)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _managed_policy_payload(
|
||||
self,
|
||||
context: TenantContext,
|
||||
spec_payload: dict,
|
||||
) -> dict:
|
||||
"""Reject tenant-owned policy fields and apply the Cloud hard policy."""
|
||||
|
||||
payload = dict(spec_payload)
|
||||
if not self._cloud_managed:
|
||||
return payload
|
||||
policy = self._admission_policy
|
||||
if policy is None:
|
||||
raise BoxAdmissionError('Cloud Box sandbox admission policy is unavailable')
|
||||
|
||||
forged_fields = {
|
||||
'plan',
|
||||
'subscription',
|
||||
'managed_sandbox',
|
||||
'entitlement',
|
||||
'entitlement_revision',
|
||||
'max_sessions',
|
||||
'max_managed_processes',
|
||||
'backend',
|
||||
}
|
||||
submitted_forged_fields = sorted(forged_fields.intersection(payload))
|
||||
if submitted_forged_fields:
|
||||
raise BoxAdmissionError(
|
||||
'Managed sandbox policy fields are host-controlled: ' + ', '.join(submitted_forged_fields)
|
||||
)
|
||||
|
||||
submitted_session_id = str(payload.get('session_id', '') or '').strip()
|
||||
if submitted_session_id and submitted_session_id != policy.logical_session_id:
|
||||
raise BoxAdmissionError('Managed sandbox session_id is runtime-owned')
|
||||
submitted_network = str(getattr(payload.get('network'), 'value', payload.get('network', 'off')) or 'off')
|
||||
if submitted_network != 'off':
|
||||
raise BoxAdmissionError('Managed sandbox network access is disabled')
|
||||
if payload.get('extra_mounts'):
|
||||
raise BoxAdmissionError('Managed sandbox additional host mounts are disabled')
|
||||
submitted_mount_path = str(payload.get('mount_path', '/workspace') or '/workspace')
|
||||
if submitted_mount_path != '/workspace':
|
||||
raise BoxAdmissionError('Managed sandbox mount_path is runtime-owned')
|
||||
|
||||
canonical_host_path = self._tenant_workspace(context)
|
||||
if canonical_host_path is None:
|
||||
raise BoxAdmissionError('Managed sandbox Workspace path is unavailable')
|
||||
submitted_host_path = str(payload.get('host_path', '') or '').strip()
|
||||
if submitted_host_path and os.path.realpath(submitted_host_path) != os.path.realpath(canonical_host_path):
|
||||
raise BoxAdmissionError('Managed sandbox host_path is runtime-owned')
|
||||
|
||||
timeout = payload.get('timeout_sec', policy.max_timeout_sec)
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, int):
|
||||
raise BoxValidationError('timeout_sec must be an integer')
|
||||
payload.update(
|
||||
{
|
||||
'session_id': policy.logical_session_id,
|
||||
'network': 'off',
|
||||
'host_path': canonical_host_path,
|
||||
'mount_path': '/workspace',
|
||||
'extra_mounts': [],
|
||||
'persistent': True,
|
||||
'timeout_sec': min(timeout, policy.max_timeout_sec),
|
||||
'cpus': policy.cpus,
|
||||
'memory_mb': policy.memory_mb,
|
||||
'pids_limit': policy.pids_limit,
|
||||
'read_only_rootfs': policy.read_only_rootfs,
|
||||
'workspace_quota_mb': policy.workspace_quota_mb,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
def _reject_cloud_managed_process(self) -> None:
|
||||
if self._cloud_managed:
|
||||
raise BoxAdmissionError('Managed processes are disabled for Cloud sandboxes')
|
||||
|
||||
def _tenant_workspace(self, context: TenantContext) -> str | None:
|
||||
if self.default_workspace is None:
|
||||
return None
|
||||
@@ -268,7 +487,8 @@ class BoxService:
|
||||
if not self._available:
|
||||
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
|
||||
execution_context = await self._validated_execution_context(self._query_execution_context(query))
|
||||
spec_payload = dict(spec_payload)
|
||||
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
|
||||
await self._require_validated_workspace_sandbox(execution_context)
|
||||
if spec_payload.get('host_path') in (None, ''):
|
||||
tenant_workspace = self._tenant_workspace(execution_context)
|
||||
if tenant_workspace is not None:
|
||||
@@ -295,6 +515,11 @@ class BoxService:
|
||||
spec,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
# A placement may be cut over while a long-running sandbox call is
|
||||
# in flight. Never accept a result produced by the superseded
|
||||
# generation. Runtime-side generation fencing prevents new work;
|
||||
# this second Core check closes the response race.
|
||||
await self._validated_execution_context(execution_context)
|
||||
except BoxError as exc:
|
||||
self._record_error(exc, query)
|
||||
raise
|
||||
@@ -322,6 +547,8 @@ class BoxService:
|
||||
by editing the pipeline config directly through the API (which only
|
||||
gates the web UI).
|
||||
"""
|
||||
if self._cloud_managed:
|
||||
return 'global'
|
||||
forced_template = self._forced_box_session_id_template()
|
||||
if forced_template:
|
||||
template = forced_template
|
||||
@@ -373,6 +600,8 @@ class BoxService:
|
||||
skills it discovered on its own filesystem, so the path is valid there
|
||||
by construction.
|
||||
"""
|
||||
if self._cloud_managed:
|
||||
return []
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
return []
|
||||
@@ -403,13 +632,21 @@ class BoxService:
|
||||
)
|
||||
return mounts
|
||||
|
||||
async def execute_tool(self, parameters: dict, query: pipeline_query.Query) -> dict:
|
||||
async def execute_tool(
|
||||
self,
|
||||
parameters: dict,
|
||||
query: pipeline_query.Query,
|
||||
*,
|
||||
skill_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Execute an agent-facing ``exec`` tool call.
|
||||
|
||||
Translates the agent-facing ``command`` field to the internal
|
||||
``BoxSpec.cmd`` field and injects the session id from the query.
|
||||
"""
|
||||
spec_payload: dict = {'cmd': parameters['command']}
|
||||
if skill_name is not None:
|
||||
spec_payload['skill_name'] = skill_name
|
||||
|
||||
# Pass through allowed agent-facing fields
|
||||
for key in ('workdir', 'timeout_sec', 'env'):
|
||||
@@ -435,7 +672,8 @@ class BoxService:
|
||||
"""Execute trusted internal Box work inside one Workspace namespace."""
|
||||
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
payload = dict(spec_payload)
|
||||
payload = self._managed_policy_payload(execution_context, spec_payload)
|
||||
await self._require_validated_workspace_sandbox(execution_context)
|
||||
if payload.get('host_path') in (None, ''):
|
||||
tenant_workspace = self._tenant_workspace(execution_context)
|
||||
if tenant_workspace is not None:
|
||||
@@ -443,7 +681,9 @@ class BoxService:
|
||||
if self.shares_filesystem_with_box:
|
||||
os.makedirs(tenant_workspace, exist_ok=True)
|
||||
spec = self.build_spec(payload, skip_host_mount_validation=skip_host_mount_validation)
|
||||
return await self.client.execute(spec, action_context=self._action_context(execution_context))
|
||||
result = await self.client.execute(spec, action_context=self._action_context(execution_context))
|
||||
await self._validated_execution_context(execution_context)
|
||||
return result
|
||||
|
||||
# ── Attachment passthrough (inbound / outbound) ──────────────────
|
||||
#
|
||||
@@ -473,10 +713,22 @@ class BoxService:
|
||||
# Hard cap on a single attachment. The HTTP upload endpoints already cap
|
||||
# uploads at 10MiB; keep parity.
|
||||
_ATTACHMENT_MAX_BYTES = 10 * _MIB
|
||||
_ATTACHMENT_MAX_FILES = 20
|
||||
_ATTACHMENT_MAX_TOTAL_BYTES = 50 * _MIB
|
||||
# Conservative cap for the exec FALLBACK path only (ARG_MAX / stdout
|
||||
# truncation). The host-filesystem path has no such limit.
|
||||
_EXEC_FALLBACK_MAX_BYTES = 256 * 1024
|
||||
|
||||
def _attachment_query_key(self, query: pipeline_query.Query) -> str:
|
||||
query_uuid = str(getattr(query, 'query_uuid', '') or '').strip()
|
||||
if query_uuid:
|
||||
if query_uuid in {'.', '..'} or '/' in query_uuid or '\\' in query_uuid or '\x00' in query_uuid:
|
||||
raise BoxValidationError('Query attachment identity is invalid')
|
||||
return query_uuid
|
||||
if self._cloud_managed:
|
||||
raise BoxValidationError('Cloud attachment transfer requires query_uuid')
|
||||
return str(query.query_id)
|
||||
|
||||
def _host_query_dir(self, subdir: str, query: pipeline_query.Query) -> str | None:
|
||||
"""Host path for ``/workspace/<subdir>/<query_id>`` when LangBot can
|
||||
access the bind-mounted workspace directly, else ``None``.
|
||||
@@ -488,9 +740,9 @@ class BoxService:
|
||||
E2B and remote runtimes, where we must fall back to the exec channel.
|
||||
"""
|
||||
root = self._tenant_workspace(self._query_execution_context(query))
|
||||
if not root or not os.path.isdir(root):
|
||||
if not root or not os.path.isdir(root) or os.path.islink(root):
|
||||
return None
|
||||
return os.path.join(root, subdir, str(query.query_id))
|
||||
return os.path.join(root, subdir, self._attachment_query_key(query))
|
||||
|
||||
async def _purge_attachment_dirs(self) -> None:
|
||||
"""Remove leftover inbox/outbox directories on startup.
|
||||
@@ -509,14 +761,12 @@ class BoxService:
|
||||
if not root or not os.path.isdir(root):
|
||||
return
|
||||
|
||||
import shutil
|
||||
|
||||
host_survivors: list[str] = []
|
||||
|
||||
def _host_purge() -> list[str]:
|
||||
candidates = [
|
||||
os.path.join(root, self.INBOX_SUBDIR),
|
||||
os.path.join(root, self.OUTBOX_SUBDIR),
|
||||
candidates: list[tuple[str, str]] = [
|
||||
(root, self.INBOX_SUBDIR),
|
||||
(root, self.OUTBOX_SUBDIR),
|
||||
]
|
||||
tenants_root = os.path.join(root, 'tenants')
|
||||
if os.path.isdir(tenants_root):
|
||||
@@ -526,17 +776,18 @@ class BoxService:
|
||||
continue
|
||||
candidates.extend(
|
||||
[
|
||||
os.path.join(tenant_entry.path, self.INBOX_SUBDIR),
|
||||
os.path.join(tenant_entry.path, self.OUTBOX_SUBDIR),
|
||||
(tenant_entry.path, self.INBOX_SUBDIR),
|
||||
(tenant_entry.path, self.OUTBOX_SUBDIR),
|
||||
]
|
||||
)
|
||||
survivors: list[str] = []
|
||||
for path in candidates:
|
||||
if not os.path.isdir(path):
|
||||
continue
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
if os.path.exists(path):
|
||||
survivors.append(path)
|
||||
for candidate_root, subdir in candidates:
|
||||
path = os.path.join(candidate_root, subdir)
|
||||
try:
|
||||
secure_fs.purge_subdirectory(candidate_root, subdir)
|
||||
except OSError:
|
||||
if os.path.lexists(path):
|
||||
survivors.append(path)
|
||||
return survivors
|
||||
|
||||
try:
|
||||
@@ -651,14 +902,15 @@ class BoxService:
|
||||
(the webchat session uses small sequential ids) never inherits stale
|
||||
files from an earlier turn.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(host_dir, ignore_errors=True)
|
||||
os.makedirs(host_dir, exist_ok=True)
|
||||
query_key = os.path.basename(host_dir)
|
||||
subdir = os.path.basename(os.path.dirname(host_dir))
|
||||
tenant_root = os.path.dirname(os.path.dirname(host_dir))
|
||||
try:
|
||||
secure_fs.write_files(tenant_root, subdir, query_key, files)
|
||||
except secure_fs.UnsafeWorkspacePathError as exc:
|
||||
raise BoxValidationError('Sandbox attachment path contains an unsafe symbolic link') from exc
|
||||
written: list[str] = []
|
||||
for name, data in files:
|
||||
with open(os.path.join(host_dir, name), 'wb') as fh:
|
||||
fh.write(data)
|
||||
for name, _data in files:
|
||||
written.append(f'{target_mount_dir}/{name}')
|
||||
return written
|
||||
|
||||
@@ -729,6 +981,8 @@ class BoxService:
|
||||
"""
|
||||
if not self._available:
|
||||
return []
|
||||
if self._cloud_managed:
|
||||
await self.require_workspace_sandbox(self._query_execution_context(query))
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
@@ -777,7 +1031,8 @@ class BoxService:
|
||||
if not pending:
|
||||
return []
|
||||
|
||||
target_dir = f'{self.INBOX_MOUNT_DIR}/{query.query_id}'
|
||||
query_key = self._attachment_query_key(query)
|
||||
target_dir = f'{self.INBOX_MOUNT_DIR}/{query_key}'
|
||||
written = await self._write_files_into_sandbox(query, self.INBOX_SUBDIR, target_dir, pending)
|
||||
written_basenames = {os.path.basename(p) for p in written}
|
||||
|
||||
@@ -805,6 +1060,8 @@ class BoxService:
|
||||
"""
|
||||
if not self._available:
|
||||
return []
|
||||
if self._cloud_managed:
|
||||
await self.require_workspace_sandbox(self._query_execution_context(query))
|
||||
|
||||
host_dir = self._host_query_dir(self.OUTBOX_SUBDIR, query)
|
||||
if host_dir is not None:
|
||||
@@ -828,22 +1085,21 @@ class BoxService:
|
||||
"""Read outbox files straight off the bind-mounted host directory."""
|
||||
import base64 as _b64
|
||||
|
||||
entries: list[dict] = []
|
||||
if not os.path.isdir(host_dir):
|
||||
return entries
|
||||
for root, _dirs, names in os.walk(host_dir):
|
||||
for name in sorted(names):
|
||||
path = os.path.join(root, name)
|
||||
try:
|
||||
if os.path.getsize(path) > self._ATTACHMENT_MAX_BYTES:
|
||||
continue
|
||||
with open(path, 'rb') as fh:
|
||||
data = fh.read()
|
||||
except OSError:
|
||||
continue
|
||||
rel = os.path.relpath(path, host_dir)
|
||||
entries.append({'name': rel, 'b64': _b64.b64encode(data).decode('ascii')})
|
||||
return entries
|
||||
query_key = os.path.basename(host_dir)
|
||||
subdir = os.path.basename(os.path.dirname(host_dir))
|
||||
tenant_root = os.path.dirname(os.path.dirname(host_dir))
|
||||
try:
|
||||
files = secure_fs.read_regular_files(
|
||||
tenant_root,
|
||||
subdir,
|
||||
query_key,
|
||||
max_file_bytes=self._ATTACHMENT_MAX_BYTES,
|
||||
max_files=self._ATTACHMENT_MAX_FILES,
|
||||
max_total_bytes=self._ATTACHMENT_MAX_TOTAL_BYTES,
|
||||
)
|
||||
except secure_fs.UnsafeWorkspacePathError as exc:
|
||||
raise BoxValidationError('Sandbox outbox contains an unsafe symbolic link') from exc
|
||||
return [{'name': name, 'b64': _b64.b64encode(data).decode('ascii')} for name, data in files]
|
||||
|
||||
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
|
||||
"""Fallback: read the outbox over the exec channel (E2B / remote).
|
||||
@@ -853,7 +1109,7 @@ class BoxService:
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
target_dir = f'{self.OUTBOX_MOUNT_DIR}/{query.query_id}'
|
||||
target_dir = f'{self.OUTBOX_MOUNT_DIR}/{self._attachment_query_key(query)}'
|
||||
max_bytes = self._EXEC_FALLBACK_MAX_BYTES
|
||||
script = (
|
||||
'import base64, json, os\n'
|
||||
@@ -898,16 +1154,19 @@ class BoxService:
|
||||
container's root can remove its own files. Best-effort: never raise
|
||||
into the pipeline.
|
||||
"""
|
||||
target_dir = f'{self.OUTBOX_MOUNT_DIR}/{query.query_id}'
|
||||
target_dir = f'{self.OUTBOX_MOUNT_DIR}/{self._attachment_query_key(query)}'
|
||||
|
||||
if host_dir is not None:
|
||||
import shutil
|
||||
|
||||
def _clear() -> bool:
|
||||
shutil.rmtree(host_dir, ignore_errors=True)
|
||||
survived = os.path.exists(host_dir) and bool(os.listdir(host_dir))
|
||||
os.makedirs(host_dir, exist_ok=True)
|
||||
return survived
|
||||
query_key = os.path.basename(host_dir)
|
||||
subdir = os.path.basename(os.path.dirname(host_dir))
|
||||
tenant_root = os.path.dirname(os.path.dirname(host_dir))
|
||||
try:
|
||||
secure_fs.reset_directory(tenant_root, subdir, query_key)
|
||||
return False
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
survived = await asyncio.to_thread(_clear)
|
||||
if not survived:
|
||||
@@ -977,9 +1236,9 @@ class BoxService:
|
||||
self._runtime_connector.dispose()
|
||||
|
||||
async def get_sessions(self, context: TenantContext) -> list[dict]:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
if not self._available:
|
||||
return []
|
||||
execution_context = await self.require_workspace_sandbox(context)
|
||||
try:
|
||||
return await self.client.get_sessions(action_context=self._action_context(execution_context))
|
||||
except Exception:
|
||||
@@ -1017,7 +1276,8 @@ class BoxService:
|
||||
skip_host_mount_validation: bool = False,
|
||||
) -> dict:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
spec_payload = dict(spec_payload)
|
||||
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
|
||||
await self._require_validated_workspace_sandbox(execution_context)
|
||||
if spec_payload.get('host_path') in (None, ''):
|
||||
tenant_workspace = self._tenant_workspace(execution_context)
|
||||
if tenant_workspace is not None:
|
||||
@@ -1033,6 +1293,7 @@ class BoxService:
|
||||
session_id: str,
|
||||
process_payload: dict,
|
||||
) -> BoxManagedProcessInfo:
|
||||
self._reject_cloud_managed_process()
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
process_spec = BoxManagedProcessSpec.model_validate(process_payload)
|
||||
return await self.client.start_managed_process(
|
||||
@@ -1047,6 +1308,7 @@ class BoxService:
|
||||
session_id: str,
|
||||
process_id: str = 'default',
|
||||
) -> BoxManagedProcessInfo:
|
||||
self._reject_cloud_managed_process()
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
return await self.client.get_managed_process(
|
||||
session_id,
|
||||
@@ -1060,6 +1322,7 @@ class BoxService:
|
||||
session_id: str,
|
||||
process_id: str = 'default',
|
||||
) -> None:
|
||||
self._reject_cloud_managed_process()
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
return await self.client.stop_managed_process(
|
||||
session_id,
|
||||
@@ -1101,6 +1364,7 @@ class BoxService:
|
||||
execution context used by the action RPC that created the process.
|
||||
"""
|
||||
|
||||
self._reject_cloud_managed_process()
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
if self._runtime_connector is None:
|
||||
raise BoxValidationError(
|
||||
@@ -1117,23 +1381,32 @@ class BoxService:
|
||||
)
|
||||
|
||||
async def list_skills(self, context: TenantContext) -> list[dict]:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.list_skills(action_context=self._action_context(execution_context))
|
||||
|
||||
async def get_skill(self, context: TenantContext, name: str) -> dict | None:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.get_skill(name, action_context=self._action_context(execution_context))
|
||||
|
||||
async def create_skill(self, context: TenantContext, skill: dict) -> dict:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
payload = dict(skill)
|
||||
payload.pop('workspace_uuid', None)
|
||||
if self._cloud_managed and str(payload.get('package_root', '') or '').strip():
|
||||
raise BoxAdmissionError('Cloud skill package_root is runtime-owned')
|
||||
if self._cloud_managed:
|
||||
payload.pop('package_root', None)
|
||||
return await self.client.create_skill(payload, action_context=self._action_context(execution_context))
|
||||
|
||||
async def update_skill(self, context: TenantContext, name: str, skill: dict) -> dict:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
payload = dict(skill)
|
||||
payload.pop('workspace_uuid', None)
|
||||
if self._cloud_managed:
|
||||
# The runtime already owns the package path for an existing skill.
|
||||
# A serialized read response may contain it, but it is never an
|
||||
# authority-bearing update field in shared Cloud mode.
|
||||
payload.pop('package_root', None)
|
||||
return await self.client.update_skill(
|
||||
name,
|
||||
payload,
|
||||
@@ -1141,13 +1414,20 @@ class BoxService:
|
||||
)
|
||||
|
||||
async def delete_skill(self, context: TenantContext, name: str) -> None:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
await self.client.delete_skill(name, action_context=self._action_context(execution_context))
|
||||
|
||||
async def scan_skill_directory(self, context: TenantContext, path: str) -> dict:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
if self._cloud_managed:
|
||||
raise BoxAdmissionError('Scanning arbitrary host skill directories is disabled in Cloud')
|
||||
return await self.client.scan_skill_directory(path, action_context=self._action_context(execution_context))
|
||||
|
||||
async def _validated_skill_execution_context(self, context: TenantContext) -> ExecutionContext:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
await self._require_validated_workspace_sandbox(execution_context)
|
||||
return execution_context
|
||||
|
||||
async def list_skill_files(
|
||||
self,
|
||||
context: TenantContext,
|
||||
@@ -1156,7 +1436,7 @@ class BoxService:
|
||||
include_hidden: bool = False,
|
||||
max_entries: int = 200,
|
||||
) -> dict:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.list_skill_files(
|
||||
name,
|
||||
path,
|
||||
@@ -1166,7 +1446,7 @@ class BoxService:
|
||||
)
|
||||
|
||||
async def read_skill_file(self, context: TenantContext, name: str, path: str) -> dict:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.read_skill_file(
|
||||
name,
|
||||
path,
|
||||
@@ -1174,7 +1454,7 @@ class BoxService:
|
||||
)
|
||||
|
||||
async def write_skill_file(self, context: TenantContext, name: str, path: str, content: str) -> dict:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.write_skill_file(
|
||||
name,
|
||||
path,
|
||||
@@ -1190,7 +1470,7 @@ class BoxService:
|
||||
source_subdir: str = '',
|
||||
target_suffix: str = 'upload',
|
||||
) -> list[dict]:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.preview_skill_zip(
|
||||
file_bytes,
|
||||
filename,
|
||||
@@ -1209,7 +1489,7 @@ class BoxService:
|
||||
source_subdir: str = '',
|
||||
target_suffix: str = 'upload',
|
||||
) -> list[dict]:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.install_skill_zip(
|
||||
file_bytes,
|
||||
filename,
|
||||
@@ -1430,6 +1710,20 @@ class BoxService:
|
||||
allowed_roots = ', '.join(self.allowed_mount_roots)
|
||||
raise BoxValidationError(f'box.local.default_workspace is outside allowed_mount_roots: {allowed_roots}')
|
||||
|
||||
def _ensure_cloud_shared_workspace(self) -> None:
|
||||
"""Require the Core-side view of the Cloud Box durable volume."""
|
||||
|
||||
if self.default_workspace is None:
|
||||
raise BoxValidationError('Cloud Box requires box.local.default_workspace')
|
||||
if not os.path.isabs(self.default_workspace) or not os.path.isdir(self.default_workspace):
|
||||
raise BoxValidationError('Cloud Box shared default_workspace must be an existing absolute directory')
|
||||
if not os.access(self.default_workspace, os.R_OK | os.W_OK | os.X_OK):
|
||||
raise BoxValidationError('Cloud Box shared default_workspace must be writable by LangBot Core')
|
||||
if not self.allowed_mount_roots or not any(
|
||||
_is_path_under(self.default_workspace, allowed_root) for allowed_root in self.allowed_mount_roots
|
||||
):
|
||||
raise BoxValidationError('Cloud Box shared default_workspace is outside allowed_mount_roots')
|
||||
|
||||
def _validate_host_mount(self, spec: BoxSpec):
|
||||
if spec.host_path is None:
|
||||
return
|
||||
@@ -1572,13 +1866,13 @@ class BoxService:
|
||||
and error.get('workspace_uuid') == execution_context.workspace_uuid
|
||||
]
|
||||
|
||||
def get_system_guidance(self, query_id=None) -> str:
|
||||
def get_system_guidance(self, query: pipeline_query.Query | int | str | None = None) -> str:
|
||||
"""Return LLM system-prompt guidance for the exec tool.
|
||||
|
||||
All execution-specific prompt text is kept here so that callers
|
||||
(e.g. LocalAgentRunner) stay free of box domain knowledge.
|
||||
|
||||
``query_id`` is the current turn's pipeline query id. When provided,
|
||||
``query`` is the current turn's pipeline query. When provided,
|
||||
the guidance ALWAYS advertises the per-query outbox path so the agent
|
||||
knows how to deliver generated files back to the user — even on turns
|
||||
where the user sent no inbound attachment (e.g. "generate a QR code"),
|
||||
@@ -1599,8 +1893,17 @@ class BoxService:
|
||||
'modify local files in the working directory, use exec with /workspace paths directly; do not ask the '
|
||||
'user for directory parameters unless they explicitly need a different directory.'
|
||||
)
|
||||
if query_id is not None:
|
||||
outbox_dir = f'{self.OUTBOX_MOUNT_DIR}/{query_id}'
|
||||
if query is not None:
|
||||
if not isinstance(query, (int, str)):
|
||||
query_key = self._attachment_query_key(query)
|
||||
else:
|
||||
# Backwards compatibility for OSS callers/tests that passed
|
||||
# the old process-local integer identity. Cloud callers must
|
||||
# pass the full Query so an opaque UUID is always advertised.
|
||||
if self._cloud_managed:
|
||||
raise BoxValidationError('Cloud outbox guidance requires a pipeline Query')
|
||||
query_key = str(query)
|
||||
outbox_dir = f'{self.OUTBOX_MOUNT_DIR}/{query_key}'
|
||||
guidance += (
|
||||
f' If you produce any file (image, audio, document, etc.) that should be sent back to the user, '
|
||||
f'write it into {outbox_dir}/ (create the directory if needed). Every file placed there will be '
|
||||
@@ -1618,6 +1921,8 @@ class BoxService:
|
||||
|
||||
async def get_status(self, context: TenantContext) -> dict:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
if self._cloud_managed and self._available:
|
||||
await self._require_validated_workspace_sandbox(execution_context)
|
||||
action_context = self._action_context(execution_context)
|
||||
recent_error_count = len(self.get_recent_errors(execution_context))
|
||||
if not self._available:
|
||||
|
||||
Reference in New Issue
Block a user