mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 12:40:59 +00:00
feat(tenancy): harden shared cloud runtime boundaries
This commit is contained in:
@@ -197,6 +197,23 @@ class ModelManager:
|
||||
self.llm_model_dict = {}
|
||||
self.embedding_model_dict = {}
|
||||
self.rerank_model_dict = {}
|
||||
|
||||
list_bindings = getattr(self.ap.workspace_service, 'list_active_execution_bindings', None)
|
||||
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
|
||||
cloud_runtime = getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
|
||||
if cloud_runtime:
|
||||
if not callable(list_bindings) or not callable(tenant_uow):
|
||||
raise RuntimeError('Cloud model loading requires explicit instance discovery and tenant UoWs')
|
||||
for binding in await list_bindings():
|
||||
context = self._context_from_binding(
|
||||
binding,
|
||||
trigger_principal=PrincipalContext(principal_type=PrincipalType.SYSTEM),
|
||||
)
|
||||
async with tenant_uow(binding.workspace_uuid):
|
||||
await self._load_workspace_models(context)
|
||||
return
|
||||
|
||||
# Compatibility path for isolated manager tests and older embedders.
|
||||
contexts: dict[str, ExecutionContext] = {}
|
||||
|
||||
async def context_for(workspace_uuid: str | None) -> ExecutionContext:
|
||||
@@ -263,6 +280,61 @@ class ModelManager:
|
||||
except Exception as exc:
|
||||
self.ap.logger.error(f'Failed to load model {model_entity.uuid}: {exc}\n{traceback.format_exc()}')
|
||||
|
||||
async def _load_workspace_models(self, context: ExecutionContext) -> None:
|
||||
"""Load one Workspace while its tenant transaction is active."""
|
||||
|
||||
providers_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.workspace_uuid == context.workspace_uuid
|
||||
)
|
||||
)
|
||||
for provider_entity in providers_result.all():
|
||||
try:
|
||||
runtime_provider = await self._build_provider(context, provider_entity)
|
||||
self.provider_dict[self._cache_key(context, provider_entity.uuid)] = runtime_provider
|
||||
except provider_errors.RequesterNotFoundError as exc:
|
||||
self.ap.logger.warning(
|
||||
f'Requester {exc.requester_name} not found, skipping provider {provider_entity.uuid}'
|
||||
)
|
||||
except Exception as exc:
|
||||
self.ap.logger.error(f'Failed to load provider {provider_entity.uuid}: {exc}\n{traceback.format_exc()}')
|
||||
|
||||
await self._load_workspace_model_kind(
|
||||
context,
|
||||
persistence_model.LLMModel,
|
||||
self.llm_model_dict,
|
||||
self._build_llm_model,
|
||||
)
|
||||
await self._load_workspace_model_kind(
|
||||
context,
|
||||
persistence_model.EmbeddingModel,
|
||||
self.embedding_model_dict,
|
||||
self._build_embedding_model,
|
||||
)
|
||||
await self._load_workspace_model_kind(
|
||||
context,
|
||||
persistence_model.RerankModel,
|
||||
self.rerank_model_dict,
|
||||
self._build_rerank_model,
|
||||
)
|
||||
|
||||
async def _load_workspace_model_kind(self, context, entity_type, cache: dict, builder) -> None:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(entity_type).where(entity_type.workspace_uuid == context.workspace_uuid)
|
||||
)
|
||||
for model_entity in result.all():
|
||||
try:
|
||||
provider = self.provider_dict.get(self._cache_key(context, model_entity.provider_uuid))
|
||||
if provider is None:
|
||||
self.ap.logger.warning(
|
||||
f'Provider {model_entity.provider_uuid} not found for model {model_entity.uuid}'
|
||||
)
|
||||
continue
|
||||
runtime_model = builder(context, model_entity, provider)
|
||||
cache[self._cache_key(context, model_entity.uuid)] = runtime_model
|
||||
except Exception as exc:
|
||||
self.ap.logger.error(f'Failed to load model {model_entity.uuid}: {exc}\n{traceback.format_exc()}')
|
||||
|
||||
async def sync_new_models_from_space(self, context: ExecutionContext) -> None:
|
||||
"""Sync legacy Space models for the explicitly selected OSS Workspace."""
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
req_messages.append(
|
||||
provider_message.Message(
|
||||
role='system',
|
||||
content=self.ap.box_service.get_system_guidance(query.query_id),
|
||||
content=self.ap.box_service.get_system_guidance(query),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from pydantic import AnyUrl
|
||||
|
||||
from .. import loader
|
||||
from ....core import app
|
||||
from ....core.task_boundary import create_detached_task, run_in_workspace_uow
|
||||
from ....api.http.context import ExecutionContext
|
||||
from ....api.http.service.tenant import TenantContext, require_workspace_uuid
|
||||
from ....workspace.errors import WorkspaceError, WorkspaceInvariantError
|
||||
@@ -1497,11 +1498,41 @@ class MCPLoader(loader.ToolLoader):
|
||||
|
||||
self.sessions = {}
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer))
|
||||
servers = result.all()
|
||||
server_configs: list[tuple[typing.Any, typing.Any, dict]] = []
|
||||
list_bindings = getattr(self.ap.workspace_service, 'list_active_execution_bindings', None)
|
||||
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
|
||||
cloud_runtime = getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
|
||||
if cloud_runtime:
|
||||
if not callable(list_bindings) or not callable(tenant_uow):
|
||||
raise RuntimeError('Cloud MCP loading requires explicit instance discovery and tenant UoWs')
|
||||
for binding in await list_bindings():
|
||||
async with tenant_uow(binding.workspace_uuid):
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer)
|
||||
.where(persistence_mcp.MCPServer.workspace_uuid == binding.workspace_uuid)
|
||||
.order_by(persistence_mcp.MCPServer.uuid)
|
||||
)
|
||||
for server in result.all():
|
||||
server_configs.append(
|
||||
(
|
||||
binding,
|
||||
server,
|
||||
self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server),
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Compatibility path for isolated loader tests and older embedders.
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer))
|
||||
for server in result.all():
|
||||
server_configs.append(
|
||||
(
|
||||
None,
|
||||
server,
|
||||
self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server),
|
||||
)
|
||||
)
|
||||
|
||||
for server in servers:
|
||||
config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
|
||||
for binding, server, config in server_configs:
|
||||
if config.get('mode') == 'stdio' and not stdio_mcp_enabled(self.ap):
|
||||
self.ap.logger.info(
|
||||
f'Skipping disabled stdio MCP server {server.uuid}; '
|
||||
@@ -1509,7 +1540,8 @@ class MCPLoader(loader.ToolLoader):
|
||||
)
|
||||
continue
|
||||
try:
|
||||
binding = await self.ap.workspace_service.get_execution_binding(server.workspace_uuid)
|
||||
if binding is None:
|
||||
binding = await self.ap.workspace_service.get_execution_binding(server.workspace_uuid)
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid=binding.instance_uuid,
|
||||
workspace_uuid=binding.workspace_uuid,
|
||||
@@ -1521,7 +1553,10 @@ class MCPLoader(loader.ToolLoader):
|
||||
)
|
||||
continue
|
||||
|
||||
task = asyncio.create_task(self.host_mcp_server(execution_context, config))
|
||||
task = create_detached_task(
|
||||
self.host_mcp_server(execution_context, config),
|
||||
after_commit_manager=getattr(self.ap, 'persistence_mgr', None),
|
||||
)
|
||||
self._hosted_mcp_tasks.append(task)
|
||||
|
||||
@staticmethod
|
||||
@@ -1542,7 +1577,12 @@ class MCPLoader(loader.ToolLoader):
|
||||
return [session for key, session in self.sessions.items() if key[:3] == scope_key]
|
||||
|
||||
async def host_mcp_server(self, context: TenantContext, server_config: dict):
|
||||
execution_context = await self._assert_execution_active(context)
|
||||
requested_context = _execution_context_from_tenant(context)
|
||||
execution_context = await run_in_workspace_uow(
|
||||
self.ap,
|
||||
requested_context.workspace_uuid,
|
||||
lambda: self._assert_execution_active(requested_context),
|
||||
)
|
||||
configured_workspace = str(server_config.get('workspace_uuid') or '').strip()
|
||||
if configured_workspace and configured_workspace != execution_context.workspace_uuid:
|
||||
raise ValueError('MCP server configuration belongs to another Workspace')
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import stat
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
@@ -34,6 +41,158 @@ _GREP_MAX_MATCHES = 200
|
||||
_GREP_MAX_FILES = 5000
|
||||
_GREP_MAX_LINE_CHARS = 500
|
||||
|
||||
_DIRECTORY_OPEN_FLAGS = (
|
||||
os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
|
||||
)
|
||||
_FILE_OPEN_FLAGS = getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NONBLOCK', 0)
|
||||
_SECURE_HOST_FILE_OPS_AVAILABLE = bool(
|
||||
getattr(os, 'O_NOFOLLOW', 0)
|
||||
and os.open in os.supports_dir_fd
|
||||
and os.stat in os.supports_dir_fd
|
||||
and os.mkdir in os.supports_dir_fd
|
||||
and os.listdir in os.supports_fd
|
||||
and os.scandir in os.supports_fd
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _HostLocation:
|
||||
root: str
|
||||
relative_parts: tuple[str, ...]
|
||||
selected_skill: dict | None
|
||||
workspace_anchor: str | None = None
|
||||
|
||||
|
||||
def _unsafe_host_path(path: str, exc: BaseException | None = None) -> ValueError:
|
||||
error = ValueError(f'Path escapes the workspace boundary or contains a symbolic link: {path}')
|
||||
if exc is not None:
|
||||
error.__cause__ = exc
|
||||
return error
|
||||
|
||||
|
||||
def _relative_workspace_parts(path: str) -> tuple[str, ...]:
|
||||
normalized = posixpath.normpath(str(path or '/workspace').strip() or '/workspace')
|
||||
if normalized == '/workspace':
|
||||
return ()
|
||||
if not normalized.startswith('/workspace/'):
|
||||
raise ValueError('Path escapes the workspace boundary.')
|
||||
|
||||
parts = tuple(part for part in normalized.removeprefix('/workspace/').split('/') if part)
|
||||
if any(part in {'.', '..'} or '\x00' in part for part in parts):
|
||||
raise ValueError('Path escapes the workspace boundary.')
|
||||
return parts
|
||||
|
||||
|
||||
def _is_symlink_at(parent_fd: int, name: str) -> bool:
|
||||
try:
|
||||
return stat.S_ISLNK(os.stat(name, dir_fd=parent_fd, follow_symlinks=False).st_mode)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def _open_directory_at(parent_fd: int, name: str, *, create: bool) -> int:
|
||||
if create:
|
||||
try:
|
||||
os.mkdir(name, mode=0o777, dir_fd=parent_fd)
|
||||
except FileExistsError:
|
||||
pass
|
||||
|
||||
try:
|
||||
directory_fd = os.open(name, _DIRECTORY_OPEN_FLAGS, dir_fd=parent_fd)
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.ELOOP or _is_symlink_at(parent_fd, name):
|
||||
raise _unsafe_host_path(name, exc)
|
||||
raise
|
||||
if not stat.S_ISDIR(os.fstat(directory_fd).st_mode):
|
||||
os.close(directory_fd)
|
||||
raise NotADirectoryError(name)
|
||||
return directory_fd
|
||||
|
||||
|
||||
def _open_directory_parts(root_fd: int, parts: tuple[str, ...], *, create: bool) -> int:
|
||||
current_fd = os.dup(root_fd)
|
||||
try:
|
||||
for part in parts:
|
||||
next_fd = _open_directory_at(current_fd, part, create=create)
|
||||
os.close(current_fd)
|
||||
current_fd = next_fd
|
||||
return current_fd
|
||||
except BaseException:
|
||||
os.close(current_fd)
|
||||
raise
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _open_host_root(location: _HostLocation, *, create: bool) -> Iterator[int]:
|
||||
"""Open and pin the tenant root before resolving tenant-controlled names."""
|
||||
|
||||
if location.workspace_anchor is None:
|
||||
root_path = os.path.realpath(location.root)
|
||||
try:
|
||||
root_fd = os.open(root_path, _DIRECTORY_OPEN_FLAGS)
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.ELOOP:
|
||||
raise _unsafe_host_path(location.root, exc)
|
||||
raise
|
||||
else:
|
||||
anchor_path = os.path.abspath(location.workspace_anchor)
|
||||
root_path = os.path.abspath(location.root)
|
||||
try:
|
||||
if os.path.commonpath((anchor_path, root_path)) != anchor_path:
|
||||
raise _unsafe_host_path(location.root)
|
||||
except ValueError as exc:
|
||||
raise _unsafe_host_path(location.root, exc)
|
||||
|
||||
anchor_real_path = os.path.realpath(anchor_path)
|
||||
try:
|
||||
anchor_fd = os.open(anchor_real_path, _DIRECTORY_OPEN_FLAGS)
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.ELOOP:
|
||||
raise _unsafe_host_path(location.workspace_anchor, exc)
|
||||
raise
|
||||
try:
|
||||
root_relative = os.path.relpath(root_path, anchor_path)
|
||||
root_parts = () if root_relative == '.' else tuple(root_relative.split(os.sep))
|
||||
root_fd = _open_directory_parts(anchor_fd, root_parts, create=create)
|
||||
finally:
|
||||
os.close(anchor_fd)
|
||||
|
||||
try:
|
||||
if not stat.S_ISDIR(os.fstat(root_fd).st_mode):
|
||||
raise _unsafe_host_path(location.root)
|
||||
yield root_fd
|
||||
finally:
|
||||
os.close(root_fd)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _open_location_fd(
|
||||
root_fd: int,
|
||||
relative_parts: tuple[str, ...],
|
||||
flags: int,
|
||||
*,
|
||||
create_parents: bool = False,
|
||||
mode: int = 0o666,
|
||||
) -> Iterator[int]:
|
||||
if not relative_parts:
|
||||
target_fd = os.dup(root_fd)
|
||||
else:
|
||||
parent_fd = _open_directory_parts(root_fd, relative_parts[:-1], create=create_parents)
|
||||
try:
|
||||
try:
|
||||
target_fd = os.open(relative_parts[-1], flags | _FILE_OPEN_FLAGS, mode, dir_fd=parent_fd)
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.ELOOP or _is_symlink_at(parent_fd, relative_parts[-1]):
|
||||
raise _unsafe_host_path(relative_parts[-1], exc)
|
||||
raise
|
||||
finally:
|
||||
os.close(parent_fd)
|
||||
|
||||
try:
|
||||
yield target_fd
|
||||
finally:
|
||||
os.close(target_fd)
|
||||
|
||||
|
||||
class NativeToolLoader(loader.ToolLoader):
|
||||
def __init__(self, ap):
|
||||
@@ -59,6 +218,9 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
|
||||
@staticmethod
|
||||
def _execution_context(query: pipeline_query.Query) -> ExecutionContext:
|
||||
attached_context = getattr(query, '_execution_context', None)
|
||||
if isinstance(attached_context, ExecutionContext):
|
||||
return attached_context
|
||||
return ExecutionContext(
|
||||
instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
|
||||
workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
|
||||
@@ -66,6 +228,7 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
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),
|
||||
)
|
||||
|
||||
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
||||
@@ -86,6 +249,13 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
return name in _ALL_TOOL_NAMES and await self._is_sandbox_available()
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query):
|
||||
require_sandbox = getattr(
|
||||
getattr(self.ap, 'box_service', None),
|
||||
'require_workspace_sandbox',
|
||||
None,
|
||||
)
|
||||
if callable(require_sandbox):
|
||||
await require_sandbox(self._execution_context(query))
|
||||
if name == EXEC_TOOL_NAME:
|
||||
self.ap.logger.info(
|
||||
'exec tool invoked: '
|
||||
@@ -111,6 +281,7 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
async def _invoke_exec(self, parameters: dict, query: pipeline_query.Query) -> dict:
|
||||
command = str(parameters['command'])
|
||||
workdir = str(parameters.get('workdir', '/workspace') or '/workspace')
|
||||
selected_skill_name: str | None = None
|
||||
|
||||
# Validate that skill references target activated skills.
|
||||
selected_skill, _ = skill_loader.resolve_virtual_skill_path(
|
||||
@@ -140,31 +311,51 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
if not package_root:
|
||||
raise ValueError(f'Activated skill "{selected_skill_name}" has no package_root.')
|
||||
|
||||
# Pass only the logical name across the authenticated Core→Runtime
|
||||
# boundary. In Cloud mode the shared Box Runtime resolves the
|
||||
# Workspace-scoped package root and constructs the read-only mount;
|
||||
# Core host paths are never accepted as mount authority.
|
||||
# Wrap command with Python venv bootstrap if the skill has a Python project.
|
||||
# The venv is created inside the skill's mount path.
|
||||
skill_mount = f'/workspace/.skills/{selected_skill_name}'
|
||||
if skill_loader.should_prepare_skill_python_env(package_root):
|
||||
python_project = selected_skill.get('python_project') is True
|
||||
if 'python_project' not in selected_skill and bool(
|
||||
getattr(self.ap.box_service, 'shares_filesystem_with_box', False)
|
||||
):
|
||||
# Backward compatibility for a same-process OSS Runtime that
|
||||
# predates trusted Box metadata. Never probe a path reported by
|
||||
# an external Runtime from the Core filesystem.
|
||||
python_project = skill_loader.should_prepare_skill_python_env(package_root)
|
||||
if python_project:
|
||||
parameters = dict(parameters)
|
||||
parameters['command'] = skill_loader.wrap_skill_command_with_python_env(command, mount_path=skill_mount)
|
||||
parameters['command'] = skill_loader.wrap_skill_command_with_python_env(
|
||||
command,
|
||||
mount_path=skill_mount,
|
||||
state_path=f'/workspace/.skill-envs/{selected_skill_name}',
|
||||
)
|
||||
|
||||
# All exec calls (with or without skills) go through the same container
|
||||
# via execute_tool. Skills are mounted at /workspace/.skills/{name}/
|
||||
# via extra_mounts built by BoxService.
|
||||
result = await self.ap.box_service.execute_tool(parameters, query)
|
||||
result = await self.ap.box_service.execute_tool(
|
||||
parameters,
|
||||
query,
|
||||
skill_name=selected_skill_name,
|
||||
)
|
||||
result = self._normalize_exec_result(result)
|
||||
|
||||
if selected_skill is not None:
|
||||
self._refresh_skill_from_disk(query, selected_skill)
|
||||
return result
|
||||
|
||||
def _resolve_host_path(
|
||||
def _resolve_host_location(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
sandbox_path: str,
|
||||
*,
|
||||
include_visible: bool,
|
||||
include_activated: bool,
|
||||
) -> tuple[str, dict | None]:
|
||||
) -> _HostLocation:
|
||||
selected_skill, rewritten_path = skill_loader.resolve_virtual_skill_path(
|
||||
self.ap,
|
||||
query,
|
||||
@@ -174,26 +365,26 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
)
|
||||
|
||||
box_service = self.ap.box_service
|
||||
host_root = (
|
||||
selected_skill.get('package_root')
|
||||
if selected_skill is not None
|
||||
else box_service._tenant_workspace(self._execution_context(query))
|
||||
)
|
||||
if selected_skill is not None:
|
||||
if not self._can_interpret_skill_host_paths():
|
||||
raise ValueError(
|
||||
'Skill package paths are owned by the Box Runtime; '
|
||||
'this operation requires a Runtime skill-file API.'
|
||||
)
|
||||
host_root = selected_skill.get('package_root')
|
||||
workspace_anchor = None
|
||||
else:
|
||||
host_root = box_service._tenant_workspace(self._execution_context(query))
|
||||
workspace_anchor = getattr(box_service, 'default_workspace', None)
|
||||
if not host_root:
|
||||
raise ValueError('No host workspace configured for file operations.')
|
||||
|
||||
mount_path = '/workspace'
|
||||
if not rewritten_path.startswith(mount_path):
|
||||
raise ValueError(f'Path must be under {mount_path}.')
|
||||
|
||||
relative = rewritten_path[len(mount_path) :].lstrip('/')
|
||||
host_path = os.path.realpath(os.path.join(host_root, relative))
|
||||
host_root = os.path.realpath(host_root)
|
||||
|
||||
if not (host_path == host_root or host_path.startswith(host_root + os.sep)):
|
||||
raise ValueError('Path escapes the workspace boundary.')
|
||||
|
||||
return host_path, selected_skill
|
||||
return _HostLocation(
|
||||
root=str(host_root),
|
||||
relative_parts=_relative_workspace_parts(rewritten_path),
|
||||
selected_skill=selected_skill,
|
||||
workspace_anchor=str(workspace_anchor) if workspace_anchor else None,
|
||||
)
|
||||
|
||||
def _resolve_skill_relative_path(
|
||||
self,
|
||||
@@ -213,21 +404,259 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
if selected_skill is None:
|
||||
return None
|
||||
|
||||
mount_path = '/workspace'
|
||||
if not rewritten_path.startswith(mount_path):
|
||||
raise ValueError(f'Path must be under {mount_path}.')
|
||||
relative = rewritten_path[len(mount_path) :].lstrip('/') or '.'
|
||||
relative = '/'.join(_relative_workspace_parts(rewritten_path)) or '.'
|
||||
return selected_skill, relative
|
||||
|
||||
def _can_interpret_skill_host_paths(self) -> bool:
|
||||
"""Require an explicitly proven shared Core/Runtime filesystem view."""
|
||||
|
||||
return _SECURE_HOST_FILE_OPS_AVAILABLE and bool(
|
||||
getattr(self.ap.box_service, 'shares_filesystem_with_box', False)
|
||||
)
|
||||
|
||||
def _should_use_box_workspace_files(self, selected_skill: dict | None) -> bool:
|
||||
if selected_skill is not None:
|
||||
return False
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is None or not hasattr(box_service, 'execute_tool'):
|
||||
return False
|
||||
if not _SECURE_HOST_FILE_OPS_AVAILABLE:
|
||||
# Preserve the OSS API on platforms without openat/O_NOFOLLOW by
|
||||
# running inside the tenant-scoped Box mount, never via a racy
|
||||
# host-path fallback.
|
||||
return True
|
||||
default_workspace = getattr(box_service, 'default_workspace', None)
|
||||
return bool(default_workspace and not os.path.isdir(os.path.realpath(default_workspace)))
|
||||
|
||||
def _read_host_location(self, location: _HostLocation, parameters: dict) -> dict:
|
||||
with _open_host_root(location, create=False) as root_fd:
|
||||
with _open_location_fd(root_fd, location.relative_parts, os.O_RDONLY) as target_fd:
|
||||
metadata = os.fstat(target_fd)
|
||||
if stat.S_ISDIR(metadata.st_mode):
|
||||
return self._build_directory_result(os.listdir(target_fd))
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise ValueError('Path must reference a regular file or directory.')
|
||||
return self._read_text_file_preview(target_fd, parameters, metadata=metadata)
|
||||
|
||||
def _write_host_location(self, location: _HostLocation, content: str, parameters: dict) -> None:
|
||||
if not location.relative_parts:
|
||||
raise ValueError('Path must reference a file under /workspace.')
|
||||
|
||||
encoding, mode = self._write_options(parameters)
|
||||
if encoding == 'base64':
|
||||
try:
|
||||
payload = base64.b64decode(content, validate=True)
|
||||
except Exception as exc:
|
||||
raise ValueError(f'invalid base64 content: {exc}') from exc
|
||||
else:
|
||||
payload = content.encode('utf-8')
|
||||
|
||||
flags = os.O_WRONLY | os.O_CREAT
|
||||
if mode == 'append':
|
||||
flags |= os.O_APPEND
|
||||
with _open_host_root(location, create=True) as root_fd:
|
||||
with _open_location_fd(
|
||||
root_fd,
|
||||
location.relative_parts,
|
||||
flags,
|
||||
create_parents=True,
|
||||
) as target_fd:
|
||||
if not stat.S_ISREG(os.fstat(target_fd).st_mode):
|
||||
raise ValueError('Path must reference a regular file.')
|
||||
if mode != 'append':
|
||||
os.ftruncate(target_fd, 0)
|
||||
os.lseek(target_fd, 0, os.SEEK_SET)
|
||||
self._write_all(target_fd, payload)
|
||||
|
||||
def _edit_host_location(
|
||||
self,
|
||||
location: _HostLocation,
|
||||
old_string: str,
|
||||
new_string: str,
|
||||
) -> tuple[bool, str | None]:
|
||||
if not location.relative_parts:
|
||||
raise ValueError('Path must reference a file under /workspace.')
|
||||
|
||||
with _open_host_root(location, create=False) as root_fd:
|
||||
with _open_location_fd(root_fd, location.relative_parts, os.O_RDWR) as target_fd:
|
||||
if not stat.S_ISREG(os.fstat(target_fd).st_mode):
|
||||
return False, 'File not found.'
|
||||
with os.fdopen(os.dup(target_fd), 'r', encoding='utf-8', errors='replace') as file_obj:
|
||||
content = file_obj.read()
|
||||
count = content.count(old_string)
|
||||
if count == 0:
|
||||
return False, 'old_string not found in file.'
|
||||
if count > 1:
|
||||
return False, f'old_string matches {count} locations; provide a more unique string.'
|
||||
|
||||
payload = content.replace(old_string, new_string, 1).encode('utf-8')
|
||||
os.ftruncate(target_fd, 0)
|
||||
os.lseek(target_fd, 0, os.SEEK_SET)
|
||||
self._write_all(target_fd, payload)
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
def _write_all(file_fd: int, payload: bytes) -> None:
|
||||
view = memoryview(payload)
|
||||
while view:
|
||||
written = os.write(file_fd, view)
|
||||
if written <= 0:
|
||||
raise OSError('Could not write the complete workspace file.')
|
||||
view = view[written:]
|
||||
|
||||
@staticmethod
|
||||
def _rglob_matches(relative_path: str, pattern: str) -> bool:
|
||||
candidates = {pattern}
|
||||
pending = [pattern]
|
||||
while pending:
|
||||
candidate = pending.pop()
|
||||
marker = candidate.find('**/')
|
||||
while marker >= 0:
|
||||
without_recursive_segment = candidate[:marker] + candidate[marker + 3 :]
|
||||
if without_recursive_segment not in candidates:
|
||||
candidates.add(without_recursive_segment)
|
||||
pending.append(without_recursive_segment)
|
||||
marker = candidate.find('**/', marker + 3)
|
||||
return any(candidate and PurePosixPath(relative_path).match(candidate) for candidate in candidates)
|
||||
|
||||
def _glob_host_location(self, location: _HostLocation, pattern: str, sandbox_base: str) -> dict:
|
||||
hits: list[tuple[str, float]] = []
|
||||
|
||||
def walk(directory_fd: int, prefix: str) -> None:
|
||||
with os.scandir(directory_fd) as entries:
|
||||
for entry in entries:
|
||||
name = entry.name
|
||||
if name in _SKIP_DIRS:
|
||||
continue
|
||||
try:
|
||||
child_fd = os.open(name, os.O_RDONLY | _FILE_OPEN_FLAGS, dir_fd=directory_fd)
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
metadata = os.fstat(child_fd)
|
||||
relative = f'{prefix}/{name}' if prefix else name
|
||||
if self._rglob_matches(relative, pattern):
|
||||
hits.append((relative, metadata.st_mtime))
|
||||
if stat.S_ISDIR(metadata.st_mode):
|
||||
walk(child_fd, relative)
|
||||
finally:
|
||||
os.close(child_fd)
|
||||
|
||||
with _open_host_root(location, create=False) as root_fd:
|
||||
with _open_location_fd(root_fd, location.relative_parts, os.O_RDONLY) as target_fd:
|
||||
if not stat.S_ISDIR(os.fstat(target_fd).st_mode):
|
||||
return {'ok': False, 'error': f'Path is not a directory: {sandbox_base}'}
|
||||
walk(target_fd, '')
|
||||
|
||||
hits.sort(key=lambda item: item[1], reverse=True)
|
||||
total = len(hits)
|
||||
sandbox_paths: list[str] = []
|
||||
output_bytes = 0
|
||||
truncated_by_bytes = False
|
||||
for relative, _mtime in hits[:_GLOB_MAX_MATCHES]:
|
||||
sandbox_path = self._sandbox_child_path(sandbox_base, relative)
|
||||
entry_bytes = len(sandbox_path.encode('utf-8')) + (1 if sandbox_paths else 0)
|
||||
if output_bytes + entry_bytes > _DEFAULT_TOOL_RESULT_MAX_BYTES:
|
||||
truncated_by_bytes = True
|
||||
break
|
||||
sandbox_paths.append(sandbox_path)
|
||||
output_bytes += entry_bytes
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'matches': sandbox_paths,
|
||||
'preview': '\n'.join(sandbox_paths),
|
||||
'total': total,
|
||||
'truncated': total > len(sandbox_paths) or truncated_by_bytes,
|
||||
'truncated_by': 'bytes' if truncated_by_bytes else ('matches' if total > len(sandbox_paths) else None),
|
||||
}
|
||||
|
||||
def _grep_host_location(
|
||||
self,
|
||||
location: _HostLocation,
|
||||
regex,
|
||||
include: str | None,
|
||||
sandbox_base: str,
|
||||
) -> dict:
|
||||
matches: list[dict] = []
|
||||
output_bytes = 0
|
||||
truncated_by: str | None = None
|
||||
files_seen = 0
|
||||
|
||||
def grep_file(file_fd: int, sandbox_path: str) -> bool:
|
||||
nonlocal output_bytes, truncated_by
|
||||
with os.fdopen(os.dup(file_fd), 'r', encoding='utf-8', errors='ignore') as handle:
|
||||
for lineno, line in enumerate(handle, 1):
|
||||
if not regex.search(line):
|
||||
continue
|
||||
content, line_truncated = self._truncate_grep_line(line.rstrip())
|
||||
entry = {'file': sandbox_path, 'line': lineno, 'content': content}
|
||||
entry_bytes = len(json.dumps(entry, ensure_ascii=False).encode('utf-8')) + 1
|
||||
if output_bytes + entry_bytes > _DEFAULT_TOOL_RESULT_MAX_BYTES:
|
||||
truncated_by = 'bytes'
|
||||
return True
|
||||
if line_truncated and truncated_by is None:
|
||||
truncated_by = 'line'
|
||||
matches.append(entry)
|
||||
output_bytes += entry_bytes
|
||||
if len(matches) >= _GREP_MAX_MATCHES:
|
||||
truncated_by = truncated_by or 'matches'
|
||||
return True
|
||||
return False
|
||||
|
||||
def walk(directory_fd: int, prefix: str) -> bool:
|
||||
nonlocal files_seen
|
||||
with os.scandir(directory_fd) as entries:
|
||||
for entry in entries:
|
||||
name = entry.name
|
||||
if name in _SKIP_DIRS:
|
||||
continue
|
||||
try:
|
||||
child_fd = os.open(name, os.O_RDONLY | _FILE_OPEN_FLAGS, dir_fd=directory_fd)
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
metadata = os.fstat(child_fd)
|
||||
relative = f'{prefix}/{name}' if prefix else name
|
||||
if stat.S_ISDIR(metadata.st_mode):
|
||||
if walk(child_fd, relative):
|
||||
return True
|
||||
continue
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
continue
|
||||
if include and not self._rglob_matches(relative, include):
|
||||
continue
|
||||
files_seen += 1
|
||||
if grep_file(child_fd, self._sandbox_child_path(sandbox_base, relative)):
|
||||
return True
|
||||
if files_seen >= _GREP_MAX_FILES:
|
||||
return True
|
||||
finally:
|
||||
os.close(child_fd)
|
||||
return False
|
||||
|
||||
with _open_host_root(location, create=False) as root_fd:
|
||||
with _open_location_fd(root_fd, location.relative_parts, os.O_RDONLY) as target_fd:
|
||||
metadata = os.fstat(target_fd)
|
||||
if stat.S_ISREG(metadata.st_mode):
|
||||
grep_file(target_fd, sandbox_base)
|
||||
elif stat.S_ISDIR(metadata.st_mode):
|
||||
walk(target_fd, '')
|
||||
else:
|
||||
return {'ok': False, 'error': f'Path not found: {sandbox_base}'}
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'matches': matches,
|
||||
'total': len(matches),
|
||||
'truncated': truncated_by is not None,
|
||||
'truncated_by': truncated_by,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _sandbox_child_path(base: str, relative: str) -> str:
|
||||
return f'{str(base).rstrip("/")}/{relative}'
|
||||
|
||||
async def _run_workspace_file_script(self, script: str, query: pipeline_query.Query) -> dict:
|
||||
result = await self.ap.box_service.execute_tool(
|
||||
{
|
||||
@@ -531,11 +960,15 @@ else:
|
||||
)
|
||||
if skill_request is not None and hasattr(self.ap.box_service, 'read_skill_file'):
|
||||
selected_skill, relative = skill_request
|
||||
host_path = self._resolve_skill_host_path(selected_skill, relative)
|
||||
if host_path and os.path.exists(host_path):
|
||||
if os.path.isdir(host_path):
|
||||
return self._build_directory_result(os.listdir(host_path))
|
||||
return self._read_text_file_preview(host_path, parameters)
|
||||
if self._can_interpret_skill_host_paths():
|
||||
host_location = self._resolve_skill_host_location(selected_skill, relative)
|
||||
else:
|
||||
host_location = None
|
||||
if host_location is not None:
|
||||
try:
|
||||
return self._read_host_location(host_location, parameters)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
try:
|
||||
result = await self.ap.box_service.read_skill_file(
|
||||
@@ -556,20 +989,18 @@ else:
|
||||
except Exception as exc:
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
host_path, selected_skill = self._resolve_host_path(
|
||||
host_location = self._resolve_host_location(
|
||||
query,
|
||||
path,
|
||||
include_visible=True,
|
||||
include_activated=True,
|
||||
)
|
||||
if self._should_use_box_workspace_files(selected_skill):
|
||||
if self._should_use_box_workspace_files(host_location.selected_skill):
|
||||
return await self._read_workspace_via_box(path, parameters, query)
|
||||
if not os.path.exists(host_path):
|
||||
try:
|
||||
return self._read_host_location(host_location, parameters)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return {'ok': False, 'error': f'File not found: {path}'}
|
||||
if os.path.isdir(host_path):
|
||||
entries = os.listdir(host_path)
|
||||
return self._build_directory_result(entries)
|
||||
return self._read_text_file_preview(host_path, parameters)
|
||||
|
||||
async def _invoke_write(self, parameters: dict, query: pipeline_query.Query) -> dict:
|
||||
path = parameters['path']
|
||||
@@ -591,20 +1022,19 @@ else:
|
||||
await self.ap.skill_mgr.reload_skills(execution_context)
|
||||
return {'ok': True, 'path': path}
|
||||
|
||||
host_path, selected_skill = self._resolve_host_path(
|
||||
host_location = self._resolve_host_location(
|
||||
query,
|
||||
path,
|
||||
include_visible=False,
|
||||
include_activated=True,
|
||||
)
|
||||
if self._should_use_box_workspace_files(selected_skill):
|
||||
if self._should_use_box_workspace_files(host_location.selected_skill):
|
||||
return await self._write_workspace_via_box(path, content, parameters, query)
|
||||
os.makedirs(os.path.dirname(host_path), exist_ok=True)
|
||||
try:
|
||||
self._write_host_file(host_path, content, parameters)
|
||||
self._write_host_location(host_location, content, parameters)
|
||||
except ValueError as exc:
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
self._refresh_skill_from_disk(query, selected_skill)
|
||||
self._refresh_skill_from_disk(query, host_location.selected_skill)
|
||||
return {'ok': True, 'path': path}
|
||||
|
||||
async def _invoke_edit(self, parameters: dict, query: pipeline_query.Query) -> dict:
|
||||
@@ -652,27 +1082,21 @@ else:
|
||||
await self.ap.skill_mgr.reload_skills(execution_context)
|
||||
return {'ok': True, 'path': path}
|
||||
|
||||
host_path, selected_skill = self._resolve_host_path(
|
||||
host_location = self._resolve_host_location(
|
||||
query,
|
||||
path,
|
||||
include_visible=False,
|
||||
include_activated=True,
|
||||
)
|
||||
if self._should_use_box_workspace_files(selected_skill):
|
||||
if self._should_use_box_workspace_files(host_location.selected_skill):
|
||||
return await self._edit_workspace_via_box(path, old_string, new_string, query)
|
||||
if not os.path.isfile(host_path):
|
||||
try:
|
||||
changed, error = self._edit_host_location(host_location, old_string, new_string)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return {'ok': False, 'error': f'File not found: {path}'}
|
||||
with open(host_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
content = f.read()
|
||||
count = content.count(old_string)
|
||||
if count == 0:
|
||||
return {'ok': False, 'error': 'old_string not found in file.'}
|
||||
if count > 1:
|
||||
return {'ok': False, 'error': f'old_string matches {count} locations; provide a more unique string.'}
|
||||
new_content = content.replace(old_string, new_string, 1)
|
||||
with open(host_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
self._refresh_skill_from_disk(query, selected_skill)
|
||||
if not changed:
|
||||
return {'ok': False, 'error': error or f'File not found: {path}'}
|
||||
self._refresh_skill_from_disk(query, host_location.selected_skill)
|
||||
return {'ok': True, 'path': path}
|
||||
|
||||
def _refresh_skill_from_disk(self, query: pipeline_query.Query, selected_skill: dict | None) -> None:
|
||||
@@ -931,55 +1355,19 @@ else:
|
||||
path = str(parameters.get('path', '/workspace') or '/workspace')
|
||||
self.ap.logger.info(f'glob tool invoked: query_id={query.query_id} pattern={pattern} path={path}')
|
||||
|
||||
host_path, selected_skill = self._resolve_host_path(
|
||||
host_location = self._resolve_host_location(
|
||||
query,
|
||||
path,
|
||||
include_visible=True,
|
||||
include_activated=True,
|
||||
)
|
||||
if self._should_use_box_workspace_files(selected_skill):
|
||||
if self._should_use_box_workspace_files(host_location.selected_skill):
|
||||
return await self._glob_workspace_via_box(path, pattern, query)
|
||||
|
||||
if not os.path.isdir(host_path):
|
||||
try:
|
||||
return self._glob_host_location(host_location, pattern, path)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return {'ok': False, 'error': f'Path is not a directory: {path}'}
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
base = Path(host_path)
|
||||
hits = list(base.rglob(pattern))
|
||||
|
||||
# Filter out skipped directories
|
||||
hits = [h for h in hits if not any(skip in h.parts for skip in _SKIP_DIRS)]
|
||||
|
||||
# Sort by mtime, newest first
|
||||
hits.sort(key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True)
|
||||
|
||||
total = len(hits)
|
||||
shown = hits[:_GLOB_MAX_MATCHES]
|
||||
|
||||
# Convert back to sandbox paths
|
||||
sandbox_paths = []
|
||||
output_bytes = 0
|
||||
truncated_by_bytes = False
|
||||
for h in shown:
|
||||
rel = os.path.relpath(str(h), host_path)
|
||||
sandbox_path = os.path.join(path, rel)
|
||||
entry_bytes = len(sandbox_path.encode('utf-8')) + (1 if sandbox_paths else 0)
|
||||
if output_bytes + entry_bytes > _DEFAULT_TOOL_RESULT_MAX_BYTES:
|
||||
truncated_by_bytes = True
|
||||
break
|
||||
sandbox_paths.append(sandbox_path)
|
||||
output_bytes += entry_bytes
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'matches': sandbox_paths,
|
||||
'preview': '\n'.join(sandbox_paths),
|
||||
'total': total,
|
||||
'truncated': total > len(sandbox_paths) or truncated_by_bytes,
|
||||
'truncated_by': 'bytes' if truncated_by_bytes else ('matches' if total > len(sandbox_paths) else None),
|
||||
}
|
||||
|
||||
async def _invoke_grep(self, parameters: dict, query: pipeline_query.Query) -> dict:
|
||||
pattern = parameters['pattern']
|
||||
path = str(parameters.get('path', '/workspace') or '/workspace')
|
||||
@@ -987,99 +1375,36 @@ else:
|
||||
self.ap.logger.info(f'grep tool invoked: query_id={query.query_id} pattern={pattern} path={path}')
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
regex = re.compile(pattern)
|
||||
except re.error as e:
|
||||
return {'ok': False, 'error': f'Invalid regex: {e}'}
|
||||
|
||||
host_path, selected_skill = self._resolve_host_path(
|
||||
host_location = self._resolve_host_location(
|
||||
query,
|
||||
path,
|
||||
include_visible=True,
|
||||
include_activated=True,
|
||||
)
|
||||
if self._should_use_box_workspace_files(selected_skill):
|
||||
if self._should_use_box_workspace_files(host_location.selected_skill):
|
||||
return await self._grep_workspace_via_box(path, pattern, include, query)
|
||||
|
||||
if not os.path.exists(host_path):
|
||||
try:
|
||||
return self._grep_host_location(host_location, regex, include, path)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return {'ok': False, 'error': f'Path not found: {path}'}
|
||||
|
||||
base = Path(host_path)
|
||||
|
||||
if base.is_file():
|
||||
files = [base]
|
||||
else:
|
||||
files = self._grep_walk(base, include)
|
||||
|
||||
matches = []
|
||||
output_bytes = 0
|
||||
truncated_by = None
|
||||
for fp in files:
|
||||
try:
|
||||
handle = fp.open('r', encoding='utf-8', errors='ignore')
|
||||
except OSError:
|
||||
continue
|
||||
with handle:
|
||||
for lineno, line in enumerate(handle, 1):
|
||||
if regex.search(line):
|
||||
rel = os.path.relpath(str(fp), host_path)
|
||||
sandbox_path = os.path.join(path, rel)
|
||||
content, line_truncated = self._truncate_grep_line(line.rstrip())
|
||||
entry = {
|
||||
'file': sandbox_path,
|
||||
'line': lineno,
|
||||
'content': content,
|
||||
}
|
||||
entry_bytes = len(json.dumps(entry, ensure_ascii=False).encode('utf-8')) + 1
|
||||
if output_bytes + entry_bytes > _DEFAULT_TOOL_RESULT_MAX_BYTES:
|
||||
truncated_by = 'bytes'
|
||||
break
|
||||
if line_truncated and truncated_by is None:
|
||||
truncated_by = 'line'
|
||||
matches.append(entry)
|
||||
output_bytes += entry_bytes
|
||||
if len(matches) >= _GREP_MAX_MATCHES:
|
||||
truncated_by = truncated_by or 'matches'
|
||||
break
|
||||
if truncated_by == 'bytes' or len(matches) >= _GREP_MAX_MATCHES:
|
||||
break
|
||||
if truncated_by == 'bytes' or len(matches) >= _GREP_MAX_MATCHES:
|
||||
break
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'matches': matches,
|
||||
'total': len(matches),
|
||||
'truncated': truncated_by is not None,
|
||||
'truncated_by': truncated_by,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _grep_walk(root, include: str | None) -> list:
|
||||
"""Walk dir tree for grep, skipping junk dirs."""
|
||||
results = []
|
||||
for item in root.rglob(include or '*'):
|
||||
if any(skip in item.parts for skip in _SKIP_DIRS):
|
||||
continue
|
||||
if item.is_file():
|
||||
results.append(item)
|
||||
if len(results) >= _GREP_MAX_FILES:
|
||||
break
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _resolve_skill_host_path(selected_skill: dict, relative: str) -> str | None:
|
||||
def _resolve_skill_host_location(selected_skill: dict, relative: str) -> _HostLocation | None:
|
||||
package_root = str(selected_skill.get('package_root', '') or '').strip()
|
||||
if not package_root:
|
||||
return None
|
||||
|
||||
host_root = os.path.realpath(package_root)
|
||||
host_path = os.path.realpath(os.path.join(host_root, relative))
|
||||
if not (host_path == host_root or host_path.startswith(host_root + os.sep)):
|
||||
raise ValueError('Path escapes the skill package boundary.')
|
||||
return host_path
|
||||
relative_path = '/workspace' if relative in {'', '.'} else f'/workspace/{relative}'
|
||||
return _HostLocation(
|
||||
root=package_root,
|
||||
relative_parts=_relative_workspace_parts(relative_path),
|
||||
selected_skill=selected_skill,
|
||||
)
|
||||
|
||||
def _normalize_exec_result(self, result: dict) -> dict:
|
||||
normalized = dict(result)
|
||||
@@ -1119,9 +1444,9 @@ else:
|
||||
'truncated_by': 'bytes' if truncated else None,
|
||||
}
|
||||
|
||||
def _read_text_file_preview(self, host_path: str, parameters: dict) -> dict:
|
||||
def _read_text_file_preview(self, file_fd: int, parameters: dict, *, metadata: os.stat_result) -> dict:
|
||||
if self._read_encoding(parameters) == 'base64':
|
||||
return self._read_binary_file_chunk(host_path, parameters)
|
||||
return self._read_binary_file_chunk(file_fd, parameters, metadata=metadata)
|
||||
|
||||
offset = self._positive_int(parameters.get('offset'), default=1)
|
||||
max_lines = self._positive_int(
|
||||
@@ -1141,7 +1466,7 @@ else:
|
||||
truncated_by: str | None = None
|
||||
next_offset: int | None = None
|
||||
|
||||
with open(host_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
with os.fdopen(os.dup(file_fd), 'r', encoding='utf-8', errors='replace') as f:
|
||||
for line_number, line in enumerate(f, 1):
|
||||
if line_number < offset:
|
||||
continue
|
||||
@@ -1182,15 +1507,15 @@ else:
|
||||
'max_bytes': max_bytes,
|
||||
}
|
||||
|
||||
def _read_binary_file_chunk(self, host_path: str, parameters: dict) -> dict:
|
||||
def _read_binary_file_chunk(self, file_fd: int, parameters: dict, *, metadata: os.stat_result) -> dict:
|
||||
byte_offset = self._non_negative_int(parameters.get('byte_offset'), default=0)
|
||||
max_bytes = self._positive_int(
|
||||
parameters.get('max_bytes'),
|
||||
default=_DEFAULT_TOOL_RESULT_MAX_BYTES,
|
||||
max_value=_DEFAULT_TOOL_RESULT_MAX_BYTES,
|
||||
)
|
||||
size_bytes = os.path.getsize(host_path)
|
||||
with open(host_path, 'rb') as f:
|
||||
size_bytes = metadata.st_size
|
||||
with os.fdopen(os.dup(file_fd), 'rb') as f:
|
||||
f.seek(byte_offset)
|
||||
data = f.read(max_bytes + 1)
|
||||
chunk = data[:max_bytes]
|
||||
@@ -1207,19 +1532,6 @@ else:
|
||||
'max_bytes': max_bytes,
|
||||
}
|
||||
|
||||
def _write_host_file(self, host_path: str, content: str, parameters: dict) -> None:
|
||||
encoding, mode = self._write_options(parameters)
|
||||
if encoding == 'base64':
|
||||
try:
|
||||
data = base64.b64decode(content, validate=True)
|
||||
except Exception as exc:
|
||||
raise ValueError(f'invalid base64 content: {exc}') from exc
|
||||
with open(host_path, 'ab' if mode == 'append' else 'wb') as f:
|
||||
f.write(data)
|
||||
return
|
||||
with open(host_path, 'a' if mode == 'append' else 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
@staticmethod
|
||||
def _read_encoding(parameters: dict) -> str:
|
||||
return 'base64' if parameters.get('encoding') == 'base64' else 'text'
|
||||
|
||||
@@ -201,5 +201,14 @@ def should_prepare_skill_python_env(package_root: str | None) -> bool:
|
||||
return box_workspace.should_prepare_python_env(package_root)
|
||||
|
||||
|
||||
def wrap_skill_command_with_python_env(command: str, *, mount_path: str = '/workspace') -> str:
|
||||
return box_workspace.wrap_python_command_with_env(command, mount_path=mount_path).rstrip()
|
||||
def wrap_skill_command_with_python_env(
|
||||
command: str,
|
||||
*,
|
||||
mount_path: str = '/workspace',
|
||||
state_path: str | None = None,
|
||||
) -> str:
|
||||
return box_workspace.wrap_python_command_with_env(
|
||||
command,
|
||||
mount_path=mount_path,
|
||||
state_path=state_path,
|
||||
).rstrip()
|
||||
|
||||
@@ -73,12 +73,34 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
return self._sandbox_available
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any:
|
||||
require_sandbox = getattr(
|
||||
getattr(self.ap, 'box_service', None),
|
||||
'require_workspace_sandbox',
|
||||
None,
|
||||
)
|
||||
if callable(require_sandbox):
|
||||
await require_sandbox(self._execution_context(query))
|
||||
if name == ACTIVATE_SKILL_TOOL_NAME:
|
||||
return await self._invoke_activate_skill(parameters, query)
|
||||
if name == REGISTER_SKILL_TOOL_NAME:
|
||||
return await self._invoke_register_skill(parameters, query)
|
||||
raise ValueError(f'Unknown skill tool: {name}')
|
||||
|
||||
@staticmethod
|
||||
def _execution_context(query) -> ExecutionContext:
|
||||
attached_context = getattr(query, '_execution_context', None)
|
||||
if isinstance(attached_context, ExecutionContext):
|
||||
return attached_context
|
||||
return ExecutionContext(
|
||||
instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
|
||||
workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
|
||||
placement_generation=getattr(query, 'placement_generation', 0) or 0,
|
||||
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),
|
||||
)
|
||||
|
||||
async def shutdown(self):
|
||||
pass
|
||||
|
||||
@@ -136,7 +158,8 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
raise ValueError('path is required')
|
||||
|
||||
# Resolve sandbox path to host path
|
||||
host_path = self._resolve_workspace_directory(sandbox_path)
|
||||
execution_context = self._execution_context(query)
|
||||
host_path = self._resolve_workspace_directory(sandbox_path, execution_context)
|
||||
|
||||
# Get or create skill service
|
||||
skill_service = getattr(self.ap, 'skill_service', None)
|
||||
@@ -144,14 +167,6 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
raise ValueError('Skill service not available')
|
||||
|
||||
# Scan and register the skill
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
|
||||
workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
|
||||
placement_generation=getattr(query, 'placement_generation', 0) or 0,
|
||||
bot_uuid=getattr(query, 'bot_uuid', None),
|
||||
pipeline_uuid=getattr(query, 'pipeline_uuid', None),
|
||||
query_uuid=getattr(query, 'query_uuid', None),
|
||||
)
|
||||
scanned = await skill_service.scan_directory_async(execution_context, host_path)
|
||||
|
||||
# Override name if provided
|
||||
@@ -178,10 +193,19 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
'skill': created,
|
||||
}
|
||||
|
||||
def _resolve_workspace_directory(self, sandbox_path: str) -> str:
|
||||
def _resolve_workspace_directory(
|
||||
self,
|
||||
sandbox_path: str,
|
||||
execution_context: ExecutionContext,
|
||||
) -> str:
|
||||
"""Resolve sandbox path to host filesystem path."""
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
workspace_root = getattr(box_service, 'default_workspace', None)
|
||||
tenant_workspace = getattr(box_service, '_tenant_workspace', None)
|
||||
workspace_root = (
|
||||
tenant_workspace(execution_context)
|
||||
if callable(tenant_workspace)
|
||||
else getattr(box_service, 'default_workspace', None)
|
||||
)
|
||||
if not workspace_root:
|
||||
raise ValueError('No default workspace configured')
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import time
|
||||
import inspect
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
@@ -35,6 +36,36 @@ class ToolManager:
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
|
||||
async def _bind_plugin_workspace(self, context: TenantContext) -> None:
|
||||
"""Select the tenant before any plugin catalog lookup.
|
||||
|
||||
Tool discovery happens before invocation, so relying on ``call_tool``
|
||||
to bind the Workspace is too late and can expose another task's
|
||||
catalog in a shared Runtime.
|
||||
"""
|
||||
|
||||
connector = getattr(self.ap, 'plugin_connector', None)
|
||||
require_context = getattr(connector, 'require_workspace_context', None)
|
||||
if require_context is None:
|
||||
return
|
||||
result = require_context(context)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
async def _workspace_sandbox_available(self, context: TenantContext) -> bool:
|
||||
"""Resolve the Workspace capability before exposing sandbox tools."""
|
||||
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
checker = getattr(box_service, 'is_workspace_sandbox_available', None)
|
||||
if not callable(checker):
|
||||
# Compatibility for OSS embedders and isolated manager tests. The
|
||||
# BoxService execution path remains the final authority.
|
||||
return True
|
||||
try:
|
||||
return bool(await checker(context))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def initialize(self):
|
||||
from langbot.pkg.utils import importutil
|
||||
from langbot.pkg.provider.tools import loaders
|
||||
@@ -65,10 +96,13 @@ class ToolManager:
|
||||
include_skill_authoring: bool = False,
|
||||
include_mcp_resource_tools: bool = True,
|
||||
) -> list[resource_tool.LLMTool]:
|
||||
await self._bind_plugin_workspace(context)
|
||||
all_functions: list[resource_tool.LLMTool] = []
|
||||
|
||||
all_functions.extend(await self.native_tool_loader.get_tools())
|
||||
if include_skill_authoring:
|
||||
sandbox_available = await self._workspace_sandbox_available(context)
|
||||
if sandbox_available:
|
||||
all_functions.extend(await self.native_tool_loader.get_tools())
|
||||
if include_skill_authoring and sandbox_available:
|
||||
all_functions.extend(await self.skill_tool_loader.get_tools())
|
||||
all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))
|
||||
all_functions.extend(
|
||||
@@ -89,6 +123,7 @@ class ToolManager:
|
||||
include_skill_authoring: bool = False,
|
||||
include_mcp_resource_tools: bool = False,
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
await self._bind_plugin_workspace(context)
|
||||
catalog: list[dict[str, typing.Any]] = []
|
||||
|
||||
def append_tools(source: str, source_name: str, tools: list[resource_tool.LLMTool]) -> None:
|
||||
@@ -104,8 +139,10 @@ class ToolManager:
|
||||
}
|
||||
)
|
||||
|
||||
append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools())
|
||||
if include_skill_authoring:
|
||||
sandbox_available = await self._workspace_sandbox_available(context)
|
||||
if sandbox_available:
|
||||
append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools())
|
||||
if include_skill_authoring and sandbox_available:
|
||||
append_tools('skill', 'LangBot', await self.skill_tool_loader.get_tools())
|
||||
catalog.extend(await self.plugin_tool_loader.get_tool_catalog(bound_plugins))
|
||||
|
||||
@@ -121,14 +158,20 @@ class ToolManager:
|
||||
|
||||
async def get_tool_by_name(self, context: TenantContext, name: str) -> tool_loader.ToolLookupResult | None:
|
||||
"""Get tool by name from any active loader."""
|
||||
for active_loader in (
|
||||
self.native_tool_loader,
|
||||
self.plugin_tool_loader,
|
||||
self.skill_tool_loader,
|
||||
):
|
||||
await self._bind_plugin_workspace(context)
|
||||
sandbox_available = await self._workspace_sandbox_available(context)
|
||||
if sandbox_available:
|
||||
tool = await self.native_tool_loader.get_tool(name)
|
||||
if tool:
|
||||
return tool
|
||||
for active_loader in (self.plugin_tool_loader,):
|
||||
tool = await active_loader.get_tool(name)
|
||||
if tool:
|
||||
return tool
|
||||
if sandbox_available:
|
||||
tool = await self.skill_tool_loader.get_tool(name)
|
||||
if tool:
|
||||
return tool
|
||||
|
||||
return await self.mcp_tool_loader.get_tool(context, name)
|
||||
|
||||
@@ -237,7 +280,10 @@ class ToolManager:
|
||||
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
from langbot.pkg.telemetry import features as telemetry_features
|
||||
|
||||
if await self.native_tool_loader.has_tool(name):
|
||||
execution_context = get_query_execution_context(query)
|
||||
await self._bind_plugin_workspace(execution_context)
|
||||
sandbox_available = await self._workspace_sandbox_available(execution_context)
|
||||
if sandbox_available and await self.native_tool_loader.has_tool(name):
|
||||
telemetry_features.increment(query, 'tool_calls', 'native')
|
||||
return await self._invoke_tool_with_monitoring(
|
||||
source='native',
|
||||
@@ -255,7 +301,6 @@ class ToolManager:
|
||||
query=query,
|
||||
invoke=lambda: self.plugin_tool_loader.invoke_tool(name, parameters, query),
|
||||
)
|
||||
execution_context = get_query_execution_context(query)
|
||||
if await self.mcp_tool_loader.has_tool(execution_context, name):
|
||||
telemetry_features.increment(query, 'tool_calls', 'mcp')
|
||||
return await self._invoke_tool_with_monitoring(
|
||||
@@ -265,7 +310,7 @@ class ToolManager:
|
||||
query=query,
|
||||
invoke=lambda: self.mcp_tool_loader.invoke_tool(name, parameters, query),
|
||||
)
|
||||
if await self.skill_tool_loader.has_tool(name):
|
||||
if sandbox_available and await self.skill_tool_loader.has_tool(name):
|
||||
telemetry_features.increment(query, 'tool_calls', 'skill')
|
||||
return await self._invoke_tool_with_monitoring(
|
||||
source='skill',
|
||||
|
||||
Reference in New Issue
Block a user