refactor(skill): compose generic box mounts in core

This commit is contained in:
huanghuoguoguo
2026-09-05 23:09:46 +08:00
parent c15f668126
commit a61b0f0068
22 changed files with 476 additions and 565 deletions
+14 -6
View File
@@ -173,10 +173,12 @@ Box is the optional sandbox subsystem used by native execution tools, stdio MCP
In this repo:
- `pkg/box/service.py` is the application-facing facade for exec, sessions, managed processes, skill CRUD, status, reconnects, quotas, mounts, and sandbox profiles.
- `pkg/box/service.py` is the application-facing facade for exec, sessions, managed processes, status, reconnects, quotas, generic mounts, and sandbox profiles.
- `pkg/box/connector.py` connects to the Box Runtime over stdio, Windows subprocess+WebSocket, or remote WebSocket.
- `pkg/provider/tools/loaders/native.py`, `mcp_stdio.py`, and skill loaders depend on Box availability.
- `pkg/skill/repository.py` is the thin async/Workspace adapter over the Plugin SDK's execution-independent `SkillStore`; it preserves the existing `data/box/skills` layout shared with Box for optional execution mounts.
- `pkg/provider/tools/loaders/native.py` is the Core orchestration seam: the
Skill loader supplies generic read-only mounts to Box execution. `mcp_stdio.py`
and execution-backed tools depend on Box availability.
- `pkg/skill/repository.py` is the thin async/Workspace adapter over the Plugin SDK's execution-independent `SkillStore`; `skills.root` owns its location independently of Box.
- `pkg/skill/manager.py` caches the Core repository catalog for progressive disclosure. Activation and read-only resource tools do not require Box; script execution and Workspace mutation still do.
Durable Box Workspace storage is shared across placement generations, but
@@ -188,11 +190,17 @@ retires stale processes and closes already-attached relays.
In `langbot-plugin-sdk`:
- `src/langbot_plugin/box/server.py` implements `lbp box` and the WebSocket endpoints on `:5410`.
- `src/langbot_plugin/box/runtime.py` owns sandbox sessions and managed processes.
- `src/langbot_plugin/box/runtime.py` owns sandbox sessions, generic read-only mounts, and managed processes.
- `backend.py`, `nsjail_backend.py`, and `e2b_backend.py` implement sandbox backends.
- `skill_store.py` manages skill packages from the Box side.
- `src/langbot_plugin/skill_store.py` is consumed by Core, not Box. Core turns
selected package roots into generic read-only mounts; Box does not understand
Skill names, metadata, revisions, files, or CRUD.
Important config keys live under `box:` in `src/langbot/templates/config.yaml`: `box.enabled`, `box.backend`, `box.runtime.endpoint`, and `box.local.*`. Start LangBot with `--standalone-box` when connecting to an externally launched Box runtime.
Skill storage uses `skills.root`. Box execution config lives under `box:`:
`box.enabled`, `box.backend`, `box.runtime.endpoint`, and `box.local.*`. The old
`box.local.skills_root` key is read only as an online-upgrade fallback and is
marked for removal in the next major version. Start LangBot with
`--standalone-box` when connecting to an externally launched Box runtime.
## HTTP API, Web UI, and MCP Server
+3 -1
View File
@@ -96,7 +96,9 @@ services:
# box.* and are forwarded to the Box runtime via INIT RPC.
- BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
- BOX__LOCAL__DEFAULT_WORKSPACE=default
- BOX__LOCAL__SKILLS_ROOT=skills
# TODO(next-major): default LANGBOT_SKILLS_ROOT to ${PWD}/data/skills
# after the historical Box storage path no longer needs zero-copy upgrades.
- SKILLS__ROOT=${LANGBOT_SKILLS_ROOT:-${LANGBOT_BOX_ROOT:-${PWD}/data/box}/skills}
- BOX__LOCAL__ALLOWED_MOUNT_ROOTS=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
- BOX__DOCKER__CPU_LIMIT_ENABLED=${LANGBOT_BOX_DOCKER_CPU_LIMIT_ENABLED:-true}
ports:
+8 -6
View File
@@ -214,8 +214,9 @@ spec:
# Deployment for LangBot Box (sandbox) runtime
#
# The Box runtime backs LangBot's sandbox tools (exec / read / write / edit /
# glob / grep), the `activate` skill tool, skill add/edit, and stdio-mode MCP
# servers. It is OPTIONAL: if you do not deploy it, set `BOX__ENABLED=false` on
# glob / grep), Skill script execution, and stdio-mode MCP servers. Skill
# activation, resources, and management remain Core-owned without Box. Box is
# OPTIONAL: if you do not deploy it, set `BOX__ENABLED=false` on
# the langbot Deployment (or `box.enabled: false` in config.yaml) so the
# dashboard renders cleanly with sandbox features disabled.
#
@@ -448,14 +449,15 @@ spec:
key: token
# box.local.* config — forwarded to the Box runtime via INIT RPC. The
# host_root MUST match the box-root hostPath mountPath below AND the box
# Deployment's box-root mountPath, so that skill package paths resolve
# identically on both sides and on the node's Docker daemon.
# Deployment's box-root mountPath, so generic package mount paths
# resolve identically on both sides and on the node's Docker daemon.
- name: BOX__LOCAL__HOST_ROOT
value: "/app/data/box"
- name: BOX__LOCAL__DEFAULT_WORKSPACE
value: "default"
- name: BOX__LOCAL__SKILLS_ROOT
value: "skills"
- name: SKILLS__ROOT
# TODO(next-major): use /app/data/skills after the legacy path window.
value: "/app/data/box/skills"
- name: BOX__LOCAL__ALLOWED_MOUNT_ROOTS
value: "/app/data/box"
volumeMounts:
+24 -21
View File
@@ -22,6 +22,7 @@
│ │ │ (shared 容器, 多 process) │
│ │ │ │
│ │ ├──> SkillToolLoader (activate 工具) │
│ │ │ └─ build_execution_mounts() │
│ │ │ │
│ │ ├──> SkillAuthoringToolLoader │
│ │ │ │
@@ -33,7 +34,7 @@
│ ├─ Workspace quota 检查 │
│ ├─ 输出截断 (head+tail) │
│ ├─ Session ID 模板解析 (resolve_box_session_id) │
│ ├─ 技能挂载组装 (build_skill_extra_mounts) │
│ ├─ 通用只读挂载接收 (read_only_mounts)
│ ├─ 重连循环 (_reconnect_loop, 指数退避) │
│ └─ BoxRuntimeConnector │
│ ├─ 心跳 loop (20s ping) │
@@ -59,10 +60,8 @@
│ NsjailBackend ──┘ (本地 CLI 或 fallback 到容器内 CLI) │
│ E2BBackend (云沙箱, 需要 E2B_API_KEY) │
│ │
BoxSkillStore
list / get / create / update / delete
│ ├─ scan_skill_directory / read_skill_file / write_skill_file │
│ └─ preview_skill_zip / install_skill_zip (zip 或 GitHub) │
Generic mount admission
allow-list + read-only + normalized target validation
│ │
│ aiohttp 单端口服务 (默认 :5410): │
│ /rpc/ws — Action RPC │
@@ -85,7 +84,7 @@
**核心设计原则**:
- Box Runtime 作为独立进程运行,通过 Action RPC 与 LangBot 主进程通信,两者复用 SDK 的 IO 层(Handler → Connection → Controller
- 一个 session_id 对应一个容器/沙箱实例。同一 session 内可并存多条 mount 与多个 managed process
- Skill / 默认 exec / MCP Server 共享同一个 session 容器(详见 [box-session-scope.md](./box-session-scope.md)
- Skill 仅是 Core 组装 mount 的业务来源;Box 与默认 exec / MCP Server 共享通用 session 和 mount 机制(详见 [box-session-scope.md](./box-session-scope.md)
---
@@ -93,7 +92,7 @@
### 2.1 BoxService (`pkg/box/service.py`, 722 行)
应用层门面,协调 Profile、安全校验、配额、连接、Skill 挂载与 Session 模板:
应用层门面,协调 Profile、安全校验、配额、连接、Core 生成的只读挂载与 Session 模板:
主要公开方法(按定义顺序):
@@ -105,7 +104,7 @@ BoxService
├─ available (property) 连接状态
├─ resolve_box_session_id(query) 从 pipeline 模板解析 session_id
├─ build_skill_extra_mounts(query) 组装 pipeline-bound skill 的挂载列表
├─ execute_tool(..., read_only_mounts=...) 接收 Core 组装的通用只读挂载
├─ execute_tool(parameters, query) Agent 调用 exec 时的入口
│ ├─ _apply_profile / build_spec
@@ -122,12 +121,6 @@ BoxService
├─ stop_managed_process(session_id, pid) 单独停止某个 managed process
├─ get_managed_process_websocket_url(...) 返回 WS attach URL
├─ list_skills() / get_skill(name) Skill 元数据
├─ create_skill / update_skill / delete_skill Skill CRUD
├─ scan_skill_directory(path) 扫描目录
├─ list_skill_files / read_skill_file / write_skill_file
├─ preview_skill_zip / install_skill_zip zip / GitHub 安装
├─ shutdown() / dispose() 清理:RPC SHUTDOWN + 进程终止
├─ get_status() / get_sessions() / get_recent_errors()
└─ get_system_guidance() LLM 系统提示
@@ -137,7 +130,7 @@ BoxService
**输出截断**: 默认 4000 字符上限,保留前 60% + 后 40%,中间插入 `[...truncated...]`
**Skill 挂载合并**: `execute_tool()` 调用时,`build_skill_extra_mounts(query)`把当前 pipeline-bound 的所有 skill 的 `package_root` 作为 `extra_mounts` 加入 BoxSpec,挂在 `/workspace/.skills/<name>`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径。
**Skill 挂载合并**: native loader 调用 `skill.build_execution_mounts()`把当前 pipeline-bound 的所有 skill 的 `package_root` 转成普通只读 mount,再通过 `BoxService.execute_tool(..., read_only_mounts=...)` 交给 Box,挂在 `/workspace/.skills/<name>`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径BoxService 和 Box Runtime 都不知道这些 mount 来自 Skill
### 2.2 BoxRuntimeConnector (`pkg/box/connector.py`, 357 行)
@@ -294,7 +287,7 @@ start_managed_process(session, spec):
单端口 aiohttp 服务(默认 5410),通过路径区分(commit `8c71ec5` 合并端口):
1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理所有 action,包括 `INIT` 配置注入、skill store 操作等
1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理 `INIT`、exec、session、managed-process 与状态等通用 action
2. **WS Relay** (`/v1/sessions/{id}/managed-process/ws``/v1/sessions/{id}/managed-process/{pid}/ws`): 双向桥接 WebSocket ↔ 指定 managed process stdin/stdout
stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process attachAction RPC 走 stdin/stdout。
@@ -303,7 +296,7 @@ stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process atta
`ActionRPCBoxClient` 封装 `Handler.call_action()` 调用:
- 25+ 方法对应 25+ 个 RPC actionexec / session / managed-process / skill / status / shutdown
- 方法对应 exec / session / managed-process / status / shutdown 等通用 RPC action,不暴露 Skill CRUD
- 错误还原: `_translate_action_error()` 通过字符串前缀匹配还原 SDK 侧异常类型
- `execute()` timeout = 300s,其他默认 15s
- `BoxRuntimeClient` 是 ABC,供后续可能的非 RPC 实现复用
@@ -344,7 +337,7 @@ stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process atta
### 3.7 SkillStore (`langbot_plugin.skill_store`)
Skill 包存储最初位于 Box Runtimeissue #2410 将通用实现抽到 Plugin SDK 顶层,Core 与 Box 使用同一套存储和 revision 语义:
Skill 包存储最初位于 Box Runtimeissue #2410 将通用实现抽到 Plugin SDK 顶层,Core 独占存储和 revision 语义:
```
SkillStore
@@ -359,7 +352,15 @@ SkillStore
└─ 支持 source_subdir / target_suffixcommit 1aa043f
```
`langbot_plugin.box.skill_store.BoxSkillStore` 仅保留为旧 Box 配置到通用 `SkillStore` 的兼容适配器,不再拥有存储实现。GitHub 安装路径由 Core HTTP 层下载归档,再交给 SkillRepository。Skill 文件继续存放于 `box.local.skills_root`(默认 `skills`,相对 `host_root`),执行时只读挂载到 `/workspace/.skills/`
GitHub 安装路径由 Core HTTP 层下载归档,再交给 SkillRepository。Skill 文件位于独立的 `skills.root`,执行时由 Core 组装成通用只读 `BoxMountSpec` 并挂载到 `/workspace/.skills/`。Box 的正常模型、客户端和 Runtime 不包含 `skill_name`、Skill CRUD、revision 或 `SKILL.md` 语义
滚动升级只保留一个隔离桥:`box/legacy_skill_compat.py` 让旧 Core 暂时调用新 Box,并把旧 `skill_name` 转为普通只读 mount。部署顺序必须先升级 Box、再升级 Core。该模块有 `TODO(next-major)`,下一大版本删除;正常架构不依赖它。
下一大版本的删除清单(当前均有 `TODO(next-major)`):
1. 删除 SDK `box/legacy_skill_compat.py` 及 Server 中唯一的注册/转换钩子。
2. 删除 Core 对 `box.local.skills_root` 的配置 fallback。
3. 新安装默认从历史 `./data/box/skills` 切到 `./data/skills`;届时 Box 部署只需只读访问 Core 明确下发的通用 artifact root。
### 3.8 Security (`box/security.py`, 52 行)
@@ -504,6 +505,9 @@ Box 额外做了 RPC SHUTDOWN 通知 Runtime 主动清理容器,比 Plugin 的
### config.yaml (重构后)
```yaml
skills:
root: './data/box/skills' # Core-ownedBox 关闭时仍可管理/读取
box:
enabled: true # 整个 Box 子系统的总开关。设为 false 时:
# - 不连接远程 Box runtime,不 fork 本地 stdio 子进程
@@ -522,7 +526,6 @@ box:
image: '' # 覆盖 profile 默认 image
host_root: './data/box' # 工作区挂载根,Docker 部署需绝对路径
default_workspace: '' # 默认 '<host_root>/default'
skills_root: 'skills' # Core 管理且与 Box 共享的 skill 包目录
allowed_mount_roots: # 默认 ['<host_root>']
- './data/box'
- '/tmp'
@@ -571,7 +574,7 @@ volumes:
| Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | 可编辑 |
| 仪表盘 Box 状态卡片 | 绿点 / "已连接" | 灰点 / "已禁用"(disabled) 或 红点 / "已断开"(failed) |
> Core 的 SkillRepository 是 `langbot_plugin.skill_store.SkillStore` 的异步 Workspace 适配层保持原 `data/box/skills/tenants/...` 布局,升级时无需移动已安装 Skill。Box 只在执行发生时消费同一份只读 package revision
> Core 的 SkillRepository 是 `langbot_plugin.skill_store.SkillStore` 的异步 Workspace 适配层。默认 `skills.root` 保持原 `data/box/skills/tenants/...` 布局,升级时无需移动已安装 Skill;旧 `box.local.skills_root` 仅作为在线升级 fallback,并将在下一大版本删除。Box 只消费 Core 下发的通用只读 mount
### Pipeline 配置 (templates/metadata/pipeline/ai.yaml)
+2 -2
View File
@@ -52,8 +52,8 @@
### S5. 挂载校验缺口 — Med-High
- **位置**: SDK `box/security.py` `_BLOCKED_HOST_PATHS_POSIX``box/backend.py``extra_mounts` 处理
- **现状**: ① SDK 黑名单仍不含 `/`(前缀匹配,`host_path="/"` 可通过,挂载整个宿主 fs);用户 home、`/usr``/opt``/tmp` 也未拦截。② `validate_sandbox_security` 只校验 `spec.host_path`**从不遍历 `spec.extra_mounts`**——LangBot 侧 `allowed_mount_roots` 也只校验 `host_path`。当前 `extra_mounts` 仅由 `build_skill_extra_mounts` 内部填充(agent 不可达),但缺乏纵深防御:一旦 S1 的无认证 RPC 被触达,extra_mounts 可挂任意宿主路径,两层都不拦
- **要求**: SDK 黑名单加入 `/`(或改白名单)`extra_mounts` 在 SDK 与 LangBot 两侧都纳入挂载校验
- **现状**: grant-enforced 模式已经由 Core 与 Runtime 双重校验通用只读 mount(绝对路径 allow-list、存在性、只读模式、规范化且位于 `/workspace` 下的目标);它不再有 Skill 特例。遗留风险仅在 admission-disabled 的低信任直连场景:通用 `extra_mounts` 仍未统一套用 grant-enforced 白名单
- **要求**: admission-disabled 的外部控制面也复用同一套通用 mount 校验;SDK 黑名单加入 `/`(或全面改白名单)。
### S6. 容器加固缺失 — Med
+1 -1
View File
@@ -18,7 +18,7 @@ has shipped the design largely as written:
| Docker / nsjail / E2B backends apply extra mounts | ✅ Shipped | Last gap closed by SDK commit `0fea9b1` (E2B) |
| `box-session-id-template` in `local-agent` pipeline config | ✅ Shipped | `templates/metadata/pipeline/ai.yaml`, default `{launcher_type}_{launcher_id}` |
| `BoxService.resolve_box_session_id(query)` | ✅ Shipped | `pkg/box/service.py:166` |
| `BoxService.build_skill_extra_mounts(query)` | ✅ Shipped | `pkg/box/service.py:189` |
| `skill.build_execution_mounts(ap, query)` | ✅ Shipped | Core composes read-only packages; Box receives generic mounts |
| Skill exec uses unified container + extra mounts | ✅ Shipped | `pkg/provider/tools/loaders/native.py` skill branch |
| MCP-in-Box uses shared persistent session, multi-process | ✅ Shipped (earlier than originally scoped) | SDK commit `529088e`, LangBot `mcp_stdio.py:_build_box_session_id` |
| `BoxManagedProcessSpec.process_id` + multi-process per session | ✅ Shipped | `BoxRuntime` keeps `managed_processes: dict[pid, _ManagedProcess]` |
+1 -1
View File
@@ -51,7 +51,7 @@
| BoxService workspace quota | 优秀 | 前置/后置配额检查、超额清理 |
| BoxService 输出截断 | 优秀 | 短/精确边界/长输出、独立 stderr |
| BoxService 可观测性 | 优秀 | 状态报告、error ring buffer、buffer 上限 |
| BoxService session 模板 | 良好 | `resolve_box_session_id` + `build_skill_extra_mounts` 在 service / native / mcp 三处都有覆盖 |
| BoxService session / mount contract | 良好 | `resolve_box_session_id` + generic `read_only_mounts`; Skill mount composition is covered in the Core loader |
| RPC client/server 协议 | 优秀 | execute/get_sessions/delete/create/conflict error |
| BoxRuntimeConnector | 良好 | local/remote 模式、Docker 平台、relay URL、心跳与重连回调 |
| BoxWorkspaceSession | 良好 | payload 构建、managed process 路径重写、stage host file |
+3 -4
View File
@@ -147,10 +147,9 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
- An explicit ``runtime.endpoint`` was configured
When this is True the Box runtime lives in a separate process with its
own filesystem view (container, pod sidecar, or remote host), so paths
it reports (e.g. skill ``package_root``) are NOT resolvable on the
LangBot side. When False, Box runs as a stdio child process that shares
LangBot's filesystem.
own filesystem view (container, pod sidecar, or remote host), so only
explicitly shared paths are usable on both sides. When False, Box runs
as a stdio child process that shares LangBot's filesystem.
"""
return bool(
self.configured_runtime_endpoint
+53 -211
View File
@@ -28,8 +28,10 @@ from langbot_plugin.box.errors import BoxAdmissionError, BoxError, BoxValidation
from langbot_plugin.box.models import (
BUILTIN_PROFILES,
BoxExecutionResult,
BoxHostMountMode,
BoxManagedProcessInfo,
BoxManagedProcessSpec,
BoxMountSpec,
BoxProfile,
BoxSpec,
)
@@ -169,7 +171,7 @@ class BoxService:
self._connector_error = 'Box runtime is disabled in config (box.enabled = false)'
self.ap.logger.info(
'Box runtime disabled by config; sandbox features (exec/read/write/edit, '
'skill add/edit, stdio MCP) will be unavailable.'
'stdio MCP, executable package scripts) will be unavailable.'
)
return
try:
@@ -275,10 +277,6 @@ class BoxService:
await self._purge_attachment_dirs()
self._available = True
self._connector_error = ''
skill_mgr = getattr(self.ap, 'skill_mgr', None)
reload_skills = getattr(skill_mgr, 'reload_skills', None)
if callable(reload_skills) and not self._cloud_managed:
await reload_skills()
self.ap.logger.info('Box runtime reconnected, sandbox features restored.')
return
except Exception as exc:
@@ -358,19 +356,17 @@ class BoxService:
"""Whether LangBot and the Box runtime share a filesystem view.
This is True only when Box runs as a local stdio child process of
LangBot (same container/host). In that case paths the Box runtime
reports — notably skill ``package_root`` — resolve identically on the
LangBot side, so LangBot may validate them against its own filesystem.
LangBot (same container/host). In that case host paths resolve
identically on both sides and Core may perform local filesystem work.
It is False for every separated deployment (Docker Compose, k8s
sidecar, ``--standalone-box``, or an explicit ``runtime.endpoint``),
where the Box runtime owns its own filesystem and LangBot must trust
the paths it reports rather than checking them locally.
where only explicitly shared and identically mounted roots can cross
the process boundary.
When Box is wired up with an injected client (tests, custom embeds)
there is no connector to introspect; we conservatively report False so
LangBot never wrongly drops Box-reported skills. An explicit override
can be set via ``_shares_filesystem_with_box`` (used by tests and any
there is no connector to introspect; we conservatively report False.
An explicit override can be set via ``_shares_filesystem_with_box`` (used by tests and any
embedder that knows the real topology).
"""
if self._shares_filesystem_with_box_override is not None:
@@ -483,11 +479,22 @@ class BoxService:
self,
context: TenantContext,
spec_payload: dict,
*,
trusted_read_only_mounts: list[dict] | None = None,
) -> dict:
"""Reject tenant-owned policy fields and apply the Cloud hard policy."""
payload = dict(spec_payload)
trusted_mounts = self._normalize_trusted_read_only_mounts(
trusted_read_only_mounts or []
)
if not self._cloud_managed:
if trusted_mounts:
if payload.get('extra_mounts'):
raise BoxValidationError(
'extra_mounts and trusted_read_only_mounts cannot both be supplied'
)
payload['extra_mounts'] = trusted_mounts
return payload
policy = self._admission_policy
if policy is None:
@@ -537,7 +544,7 @@ class BoxService:
'network': 'off',
'host_path': canonical_host_path,
'mount_path': '/workspace',
'extra_mounts': [],
'extra_mounts': trusted_mounts,
'persistent': True,
'timeout_sec': min(timeout, policy.max_timeout_sec),
'cpus': policy.cpus,
@@ -549,6 +556,26 @@ class BoxService:
)
return payload
def _normalize_trusted_read_only_mounts(self, mounts: list[dict]) -> list[dict]:
"""Validate Core-composed artifacts before crossing into Box."""
normalized: list[dict] = []
for raw_mount in mounts:
mount = BoxMountSpec.model_validate(raw_mount)
if mount.mode != BoxHostMountMode.READ_ONLY:
raise BoxAdmissionError('Core-composed additional mounts must be read-only')
host_path = os.path.realpath(mount.host_path)
if not os.path.isdir(host_path):
raise BoxAdmissionError('Core-composed read-only mount source is unavailable')
if not any(_is_path_under(host_path, root) for root in self.allowed_mount_roots):
raise BoxAdmissionError(
'Core-composed read-only mount source is outside allowed_mount_roots'
)
normalized.append(
mount.model_copy(update={'host_path': host_path}).model_dump(mode='json')
)
return normalized
def _reject_cloud_managed_process(self) -> None:
if self._cloud_managed:
raise BoxAdmissionError('Managed processes are disabled for Cloud sandboxes')
@@ -565,13 +592,18 @@ class BoxService:
query: pipeline_query.Query,
*,
skip_host_mount_validation: bool = False,
trusted_read_only_mounts: list[dict] | None = None,
) -> dict:
if not self._available:
raise BoxError(
'Box runtime is not available. Configure an available Box backend before using Box features.'
)
execution_context = await self._validated_execution_context(self._query_execution_context(query))
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
spec_payload = self._managed_policy_payload(
execution_context,
spec_payload,
trusted_read_only_mounts=trusted_read_only_mounts,
)
await self._require_validated_workspace_sandbox(execution_context)
if spec_payload.get('host_path') in (None, ''):
tenant_workspace = self._tenant_workspace(execution_context)
@@ -658,70 +690,12 @@ class BoxService:
variables.setdefault('global', 'global')
return template.format_map(collections.defaultdict(lambda: 'unknown', variables))
def build_skill_extra_mounts(self, query: pipeline_query.Query) -> list[dict]:
"""Build extra_mounts entries for all pipeline-bound skills.
This ensures that when a container is first created it already has
all skill packages mounted, regardless of which skill is currently
activated.
Path validation is filesystem-topology dependent. When LangBot and the
Box runtime share a filesystem (local stdio mode), a skill whose
``package_root`` is missing or no longer a directory is skipped with a
warning instead of being passed through to the backend. Without that
guard the three backends behave inconsistently on a stale mount: nsjail
refuses to start the sandbox (failing every exec in the session),
Docker silently auto-creates a root-owned empty directory on the host,
and E2B silently skips the upload — none of which surfaces an
actionable error.
When Box runs as a separate process (Docker Compose, k8s sidecar,
``--standalone-box``, or a remote ``runtime.endpoint``), the
``package_root`` reported by ``list_skills`` is the Box runtime's own
filesystem path and is NOT resolvable on the LangBot side. Validating
it locally would wrongly drop every skill, so LangBot trusts the path
and lets the Box runtime resolve it. The Box runtime only ever reports
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 []
from ..provider.tools.loaders import skill as skill_loader
validate_locally = self.shares_filesystem_with_box
visible_skills = skill_loader.get_visible_skills(self.ap, query)
mounts: list[dict] = []
for skill_name, skill_data in visible_skills.items():
package_root = str(skill_data.get('package_root', '') or '').strip()
if not package_root:
continue
if validate_locally and not os.path.isdir(package_root):
self.ap.logger.warning(
f'Skill "{skill_name}" package_root missing on filesystem '
f'({package_root}); skipping mount to prevent sandbox failures. '
f'The skill cache may be stale — consider reloading skills.'
)
continue
mounts.append(
{
'host_path': package_root,
'mount_path': f'/workspace/.skills/{skill_name}',
'mode': 'ro',
}
)
return mounts
async def execute_tool(
self,
parameters: dict,
query: pipeline_query.Query,
*,
skill_name: str | None = None,
read_only_mounts: list[dict] | None = None,
) -> dict:
"""Execute an agent-facing ``exec`` tool call.
@@ -729,8 +703,6 @@ class BoxService:
``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'):
@@ -740,11 +712,11 @@ class BoxService:
# Inject context the agent must not control
spec_payload.setdefault('session_id', self.resolve_box_session_id(query))
# Mount all pipeline-bound skills so they are available in the container
if 'extra_mounts' not in spec_payload:
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
return await self.execute_spec_payload(spec_payload, query)
return await self.execute_spec_payload(
spec_payload,
query,
trusted_read_only_mounts=read_only_mounts,
)
async def execute_in_context(
self,
@@ -1275,8 +1247,6 @@ class BoxService:
'timeout_sec': 120,
'session_id': self.resolve_box_session_id(query),
}
if 'extra_mounts' not in spec_payload:
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
try:
spec = self.build_spec(spec_payload)
result = await self.client.execute(spec)
@@ -1527,126 +1497,6 @@ class BoxService:
self._runtime_connector.get_relay_headers(action_context),
)
async def list_skills(self, context: TenantContext) -> list[dict]:
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_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_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_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,
action_context=self._action_context(execution_context),
)
async def delete_skill(self, context: TenantContext, name: str) -> None:
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_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,
name: str,
path: str = '.',
include_hidden: bool = False,
max_entries: int = 200,
) -> dict:
execution_context = await self._validated_skill_execution_context(context)
return await self.client.list_skill_files(
name,
path,
include_hidden,
max_entries,
action_context=self._action_context(execution_context),
)
async def read_skill_file(self, context: TenantContext, name: str, path: str) -> dict:
execution_context = await self._validated_skill_execution_context(context)
return await self.client.read_skill_file(
name,
path,
action_context=self._action_context(execution_context),
)
async def write_skill_file(self, context: TenantContext, name: str, path: str, content: str) -> dict:
execution_context = await self._validated_skill_execution_context(context)
return await self.client.write_skill_file(
name,
path,
content,
action_context=self._action_context(execution_context),
)
async def preview_skill_zip(
self,
context: TenantContext,
file_bytes: bytes,
filename: str,
source_subdir: str = '',
target_suffix: str = 'upload',
) -> list[dict]:
execution_context = await self._validated_skill_execution_context(context)
return await self.client.preview_skill_zip(
file_bytes,
filename,
source_subdir,
target_suffix,
action_context=self._action_context(execution_context),
)
async def install_skill_zip(
self,
context: TenantContext,
file_bytes: bytes,
filename: str,
source_paths: list[str] | None = None,
source_path: str = '',
source_subdir: str = '',
target_suffix: str = 'upload',
) -> list[dict]:
execution_context = await self._validated_skill_execution_context(context)
return await self.client.install_skill_zip(
file_bytes,
filename,
source_paths,
source_path,
source_subdir,
target_suffix,
action_context=self._action_context(execution_context),
)
def _serialize_result(self, result: BoxExecutionResult) -> dict:
stdout, stdout_truncated = self._truncate(result.stdout)
stderr, stderr_truncated = self._truncate(result.stderr)
@@ -1783,14 +1633,6 @@ class BoxService:
default_workspace = os.path.join(self.host_root, default_workspace)
return os.path.realpath(os.path.abspath(default_workspace))
def get_skills_root(self) -> str | None:
skills_root = str(self._local_config().get('skills_root', '') or 'skills').strip()
if not skills_root:
skills_root = 'skills'
if not os.path.isabs(skills_root) and self.host_root is not None:
skills_root = os.path.join(self.host_root, skills_root)
return os.path.realpath(os.path.abspath(skills_root))
def _load_enabled(self) -> bool:
"""Read ``box.enabled`` (top-level, not ``box.local.*``). Default True
— disabling is opt-in. Accepts bool, ``'true'``/``'false'`` strings,
+5 -183
View File
@@ -1,43 +1,27 @@
"""Reusable workspace/session helpers built on top of Box.
This module is the middle layer between the raw Box runtime primitives and
application-specific flows such as skills or MCP stdio.
This module is the middle layer between raw Box runtime primitives and
application-specific consumers.
It intentionally stays generic:
- path and virtualenv rewriting are workspace concerns
- Python project detection/bootstrap are workspace concerns
- session exec / managed-process helpers are workspace concerns
Higher layers add their own semantics on top, for example:
- skills choose a stable per-skill session id and use repeated exec
- MCP stdio chooses how to prepare dependencies and attaches to a managed process
Higher layers add their own semantics on top; BoxWorkspaceSession retains only
workspace, execution, and managed-process concepts.
"""
from __future__ import annotations
import os
import textwrap
from typing import Any
PYTHON_MANIFEST_FILES = (
'requirements.txt',
'pyproject.toml',
'setup.py',
'setup.cfg',
)
from ..utils.python_workspace import list_python_manifest_files
_VENV_DIRS = frozenset({'.venv', 'venv', 'env', '.env'})
_VENV_BIN_DIRS = frozenset({'bin', 'Scripts'})
def normalize_host_path(path: str | None) -> str:
if path is None:
return ''
stripped = str(path).strip()
if not stripped:
return ''
return os.path.realpath(os.path.abspath(stripped))
def rewrite_mounted_path(path: str, host_path: str | None, *, mount_path: str = '/workspace') -> str:
"""Translate a host path into the path visible inside the sandbox mount."""
if not host_path or not path:
@@ -98,13 +82,6 @@ def rewrite_venv_command(command: str, host_path: str | None, *, mount_path: str
return rewrite_mounted_path(normalized_command, host_path, mount_path=mount_path)
def list_python_manifest_files(host_path: str | None) -> list[str]:
normalized_root = normalize_host_path(host_path)
if not normalized_root:
return []
return [filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename))]
def classify_python_workspace(host_path: str | None) -> str | None:
"""Return the generic Python workspace shape, without app-specific policy."""
manifest_files = set(list_python_manifest_files(host_path))
@@ -117,161 +94,6 @@ def classify_python_workspace(host_path: str | None) -> str | None:
return None
def should_prepare_python_env(host_path: str | None) -> bool:
normalized_root = normalize_host_path(host_path)
if not normalized_root:
return False
if os.path.isdir(os.path.join(normalized_root, '.venv')):
return True
return bool(list_python_manifest_files(normalized_root))
def wrap_python_command_with_env(
command: str,
*,
mount_path: str = '/workspace',
state_path: str | None = None,
) -> str:
"""Wrap a command with a reusable sandbox-local Python env bootstrap.
``mount_path`` is always the source tree used for manifest hashing and
installation. ``state_path`` may point at a separate writable directory
for read-only source mounts; when omitted, legacy mutable-workspace behavior
stores the environment beside the source.
"""
writable_state_path = state_path or mount_path
bootstrap = textwrap.dedent(
f"""
set -e
_LB_VENV_DIR="{writable_state_path}/.venv"
_LB_META_DIR="{writable_state_path}/.langbot"
_LB_META_FILE="$_LB_META_DIR/python-env.json"
_LB_LOCK_DIR="$_LB_META_DIR/python-env.lock"
_LB_TMP_DIR="{writable_state_path}/.tmp"
_LB_PIP_CACHE_DIR="{writable_state_path}/.cache/pip"
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"
if [ -z "$_LB_SYSTEM_PYTHON" ]; then
echo "python3 or python is required to prepare the workspace Python environment" >&2
exit 127
fi
export TMPDIR="$_LB_TMP_DIR"
export TEMP="$_LB_TMP_DIR"
export TMP="$_LB_TMP_DIR"
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
_lb_python_meta() {{
"$_LB_SYSTEM_PYTHON" - <<'PY'
import hashlib
import json
import os
import sys
root = "{mount_path}"
max_manifest_bytes = 10 * 1024 * 1024
digest = hashlib.sha256()
manifest_files = []
for rel in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"):
path = os.path.join(root, rel)
if not os.path.isfile(path):
continue
if os.path.getsize(path) > max_manifest_bytes:
raise RuntimeError(
f"Python project manifest exceeds {{max_manifest_bytes}} bytes: {{rel}}"
)
manifest_files.append(rel)
with open(path, "rb") as handle:
digest.update(rel.encode("utf-8"))
digest.update(b"\\0")
while chunk := handle.read(1024 * 1024):
digest.update(chunk)
digest.update(b"\\0")
print(
json.dumps(
{{
"python_executable": sys.executable,
"python_version": list(sys.version_info[:3]),
"manifest_files": manifest_files,
"manifest_sha256": digest.hexdigest(),
}},
sort_keys=True,
)
)
PY
}}
_LB_CURRENT_META="$(_lb_python_meta)"
_LB_NEEDS_BOOTSTRAP=0
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
_LB_NEEDS_BOOTSTRAP=1
elif [ ! -f "$_LB_META_FILE" ]; then
_LB_NEEDS_BOOTSTRAP=1
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
_LB_NEEDS_BOOTSTRAP=1
fi
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
_LB_LOCK_WAIT=0
while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do
if [ "$_LB_LOCK_WAIT" -ge 120 ]; then
_LB_LOCK_OWNER="$(cat "$_LB_LOCK_DIR/pid" 2>/dev/null || true)"
if [ -n "$_LB_LOCK_OWNER" ] && kill -0 "$_LB_LOCK_OWNER" 2>/dev/null; then
echo "Timed out waiting for active Python environment lock: $_LB_LOCK_DIR" >&2
exit 1
fi
echo "Timed out waiting for Python environment lock, clearing stale lock: $_LB_LOCK_DIR" >&2
rm -rf "$_LB_LOCK_DIR" 2>/dev/null || true
if mkdir "$_LB_LOCK_DIR" 2>/dev/null; then
break
fi
echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2
exit 1
fi
sleep 1
_LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1))
done
printf '%s\\n' "$$" > "$_LB_LOCK_DIR/pid" 2>/dev/null || true
_lb_cleanup_lock() {{
rm -rf "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
}}
trap _lb_cleanup_lock EXIT INT TERM
_LB_CURRENT_META="$(_lb_python_meta)"
_LB_NEEDS_BOOTSTRAP=0
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
_LB_NEEDS_BOOTSTRAP=1
elif [ ! -f "$_LB_META_FILE" ]; then
_LB_NEEDS_BOOTSTRAP=1
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
_LB_NEEDS_BOOTSTRAP=1
fi
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
rm -rf "$_LB_VENV_DIR"
"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"
. "$_LB_VENV_DIR/bin/activate"
python -m pip install --upgrade pip setuptools wheel
if [ -f "{mount_path}/requirements.txt" ]; then
python -m pip install -r "{mount_path}/requirements.txt"
elif [ -f "{mount_path}/pyproject.toml" ] || [ -f "{mount_path}/setup.py" ] || [ -f "{mount_path}/setup.cfg" ]; then
python -m pip install "{mount_path}"
fi
printf '%s' "$_LB_CURRENT_META" > "$_LB_META_FILE"
fi
fi
export VIRTUAL_ENV="$_LB_VENV_DIR"
export PATH="$_LB_VENV_DIR/bin:$PATH"
{command}
"""
).strip()
return bootstrap + '\n'
class BoxWorkspaceSession:
@@ -18,12 +18,11 @@ from ....box.workspace import (
BoxWorkspaceSession,
classify_python_workspace,
infer_workspace_host_path,
normalize_host_path,
rewrite_mounted_path,
rewrite_venv_command,
unwrap_venv_path,
wrap_python_command_with_env,
)
from ....utils.python_workspace import normalize_host_path, wrap_python_command_with_env
if TYPE_CHECKING:
from .mcp import RuntimeMCPSession
@@ -329,20 +329,11 @@ 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}'
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.
if 'python_project' not in selected_skill:
python_project = skill_loader.should_prepare_skill_python_env(package_root)
if python_project:
parameters = dict(parameters)
@@ -358,12 +349,9 @@ class NativeToolLoader(loader.ToolLoader):
result = await self.ap.box_service.execute_tool(
parameters,
query,
skill_name=selected_skill_name,
read_only_mounts=skill_loader.build_execution_mounts(self.ap, query),
)
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_location(
@@ -386,8 +374,7 @@ class NativeToolLoader(loader.ToolLoader):
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.'
'Secure Core host file operations are unavailable on this platform.'
)
host_root = selected_skill.get('package_root')
workspace_anchor = None
@@ -426,11 +413,9 @@ class NativeToolLoader(loader.ToolLoader):
return selected_skill, relative
def _can_interpret_skill_host_paths(self) -> bool:
"""Require an explicitly proven shared Core/Runtime filesystem view."""
"""Return whether Core can use its no-follow host file primitives."""
return _SECURE_HOST_FILE_OPS_AVAILABLE and bool(
getattr(self.ap.box_service, 'shares_filesystem_with_box', False)
)
return _SECURE_HOST_FILE_OPS_AVAILABLE
def _should_use_box_workspace_files(self, selected_skill: dict | None) -> bool:
if selected_skill is not None:
@@ -1130,16 +1115,6 @@ else:
skill_repository = getattr(self.ap, 'skill_repository', None)
if skill_request is not None and skill_repository is not None:
selected_skill, relative = skill_request
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 await asyncio.to_thread(self._read_host_location, host_location, parameters)
except FileNotFoundError:
pass
try:
result = await skill_repository.read_skill_file(
self._execution_context(query),
@@ -1,10 +1,14 @@
from __future__ import annotations
import os
import re
import typing
from ....box import workspace as box_workspace
from ....api.http.context import ExecutionContext
from ....utils.python_workspace import (
should_prepare_python_env,
wrap_python_command_with_env,
)
if typing.TYPE_CHECKING:
from ....core import app
@@ -57,6 +61,30 @@ def get_visible_skill(ap: app.Application, query: pipeline_query.Query, skill_na
return get_visible_skills(ap, query).get(skill_name)
def build_execution_mounts(ap: app.Application, query: pipeline_query.Query) -> list[dict]:
"""Translate visible Core-owned packages into generic read-only mounts."""
mounts: list[dict] = []
for skill_name, skill_data in get_visible_skills(ap, query).items():
package_root = str(skill_data.get('package_root', '') or '').strip()
if not package_root:
continue
if not os.path.isdir(package_root):
ap.logger.warning(
f'Skill "{skill_name}" package_root missing on the Core filesystem '
f'({package_root}); skipping its execution mount. Reload the skill catalog.'
)
continue
mounts.append(
{
'host_path': package_root,
'mount_path': get_virtual_skill_mount_path(skill_name),
'mode': 'ro',
}
)
return mounts
def get_activated_skills(query: pipeline_query.Query) -> dict[str, dict]:
if query.variables is None:
return {}
@@ -198,7 +226,7 @@ def build_skill_session_id(skill_data: dict, query: pipeline_query.Query) -> str
def should_prepare_skill_python_env(package_root: str | None) -> bool:
return box_workspace.should_prepare_python_env(package_root)
return should_prepare_python_env(package_root)
def wrap_skill_command_with_python_env(
@@ -207,7 +235,7 @@ def wrap_skill_command_with_python_env(
mount_path: str = '/workspace',
state_path: str | None = None,
) -> str:
return box_workspace.wrap_python_command_with_env(
return wrap_python_command_with_env(
command,
mount_path=mount_path,
state_path=state_path,
+10 -3
View File
@@ -21,6 +21,7 @@ class SkillRepository:
self.ap = ap
config = getattr(getattr(ap, 'instance_config', None), 'data', {}) or {}
self._local_config = (config.get('box') or {}).get('local') or {}
self._skills_config = config.get('skills') or {}
self._store = SkillStore(self._skills_root())
self._lock = asyncio.Lock()
@@ -29,9 +30,15 @@ class SkillRepository:
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
def _skills_root(self) -> str:
configured = str(self._local_config.get('skills_root') or 'skills').strip()
if not os.path.isabs(configured):
configured = os.path.join(self._host_root(), configured)
configured = str(self._skills_config.get('root') or '').strip()
if not configured:
# Online-upgrade bridge for installations whose persisted config
# predates the standalone Skill domain.
# TODO(next-major): remove box.local.skills_root fallback.
legacy = str(self._local_config.get('skills_root') or '').strip()
configured = legacy or 'skills'
if not os.path.isabs(configured):
configured = os.path.join(self._host_root(), configured)
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
def _default_workspace(self) -> str:
+1 -1
View File
@@ -198,7 +198,7 @@ async def build_heartbeat_payload(
except Exception:
features['plugin_count'] = -1
# Skill count (from Box runtime via skill manager)
# Skill count (from the Core SkillRepository cache)
try:
skill_mgr = getattr(ap, 'skill_mgr', None)
if skill_mgr is not None:
+195
View File
@@ -0,0 +1,195 @@
"""Python project detection and sandbox-local environment bootstrap helpers."""
from __future__ import annotations
import os
import textwrap
PYTHON_MANIFEST_FILES = (
'requirements.txt',
'pyproject.toml',
'setup.py',
'setup.cfg',
)
def normalize_host_path(path: str | None) -> str:
if path is None:
return ''
stripped = str(path).strip()
if not stripped:
return ''
return os.path.realpath(os.path.abspath(stripped))
def list_python_manifest_files(host_path: str | None) -> list[str]:
normalized_root = normalize_host_path(host_path)
if not normalized_root:
return []
return [
filename
for filename in PYTHON_MANIFEST_FILES
if os.path.isfile(os.path.join(normalized_root, filename))
]
def should_prepare_python_env(host_path: str | None) -> bool:
normalized_root = normalize_host_path(host_path)
if not normalized_root:
return False
if os.path.isdir(os.path.join(normalized_root, '.venv')):
return True
return bool(list_python_manifest_files(normalized_root))
def wrap_python_command_with_env(
command: str,
*,
mount_path: str = '/workspace',
state_path: str | None = None,
) -> str:
"""Wrap a command with a reusable sandbox-local Python env bootstrap."""
writable_state_path = state_path or mount_path
bootstrap = textwrap.dedent(
f"""
set -e
_LB_VENV_DIR="{writable_state_path}/.venv"
_LB_META_DIR="{writable_state_path}/.langbot"
_LB_META_FILE="$_LB_META_DIR/python-env.json"
_LB_LOCK_DIR="$_LB_META_DIR/python-env.lock"
_LB_TMP_DIR="{writable_state_path}/.tmp"
_LB_PIP_CACHE_DIR="{writable_state_path}/.cache/pip"
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"
if [ -z "$_LB_SYSTEM_PYTHON" ]; then
echo "python3 or python is required to prepare the workspace Python environment" >&2
exit 127
fi
export TMPDIR="$_LB_TMP_DIR"
export TEMP="$_LB_TMP_DIR"
export TMP="$_LB_TMP_DIR"
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
_lb_python_meta() {{
"$_LB_SYSTEM_PYTHON" - <<'PY'
import hashlib
import json
import os
import sys
root = "{mount_path}"
max_manifest_bytes = 10 * 1024 * 1024
digest = hashlib.sha256()
manifest_files = []
for rel in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"):
path = os.path.join(root, rel)
if not os.path.isfile(path):
continue
if os.path.getsize(path) > max_manifest_bytes:
raise RuntimeError(
f"Python project manifest exceeds {{max_manifest_bytes}} bytes: {{rel}}"
)
manifest_files.append(rel)
with open(path, "rb") as handle:
digest.update(rel.encode("utf-8"))
digest.update(b"\0")
while chunk := handle.read(1024 * 1024):
digest.update(chunk)
digest.update(b"\0")
print(
json.dumps(
{{
"python_executable": sys.executable,
"python_version": list(sys.version_info[:3]),
"manifest_files": manifest_files,
"manifest_sha256": digest.hexdigest(),
}},
sort_keys=True,
)
)
PY
}}
_LB_CURRENT_META="$(_lb_python_meta)"
_LB_NEEDS_BOOTSTRAP=0
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
_LB_NEEDS_BOOTSTRAP=1
elif [ ! -f "$_LB_META_FILE" ]; then
_LB_NEEDS_BOOTSTRAP=1
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
_LB_NEEDS_BOOTSTRAP=1
fi
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
_LB_LOCK_WAIT=0
while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do
if [ "$_LB_LOCK_WAIT" -ge 120 ]; then
_LB_LOCK_OWNER="$(cat "$_LB_LOCK_DIR/pid" 2>/dev/null || true)"
if [ -n "$_LB_LOCK_OWNER" ] && kill -0 "$_LB_LOCK_OWNER" 2>/dev/null; then
echo "Timed out waiting for active Python environment lock: $_LB_LOCK_DIR" >&2
exit 1
fi
echo "Timed out waiting for Python environment lock, clearing stale lock: $_LB_LOCK_DIR" >&2
rm -rf "$_LB_LOCK_DIR" 2>/dev/null || true
if mkdir "$_LB_LOCK_DIR" 2>/dev/null; then
break
fi
echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2
exit 1
fi
sleep 1
_LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1))
done
printf '%s\n' "$$" > "$_LB_LOCK_DIR/pid" 2>/dev/null || true
_lb_cleanup_lock() {{
rm -rf "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
}}
trap _lb_cleanup_lock EXIT INT TERM
_LB_CURRENT_META="$(_lb_python_meta)"
_LB_NEEDS_BOOTSTRAP=0
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
_LB_NEEDS_BOOTSTRAP=1
elif [ ! -f "$_LB_META_FILE" ]; then
_LB_NEEDS_BOOTSTRAP=1
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
_LB_NEEDS_BOOTSTRAP=1
fi
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
rm -rf "$_LB_VENV_DIR"
"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"
. "$_LB_VENV_DIR/bin/activate"
python -m pip install --upgrade pip setuptools wheel
if [ -f "{mount_path}/requirements.txt" ]; then
python -m pip install -r "{mount_path}/requirements.txt"
elif [ -f "{mount_path}/pyproject.toml" ] || [ -f "{mount_path}/setup.py" ] || [ -f "{mount_path}/setup.cfg" ]; then
python -m pip install "{mount_path}"
fi
printf '%s' "$_LB_CURRENT_META" > "$_LB_META_FILE"
fi
fi
export VIRTUAL_ENV="$_LB_VENV_DIR"
export PATH="$_LB_VENV_DIR/bin:$PATH"
{command}
"""
).strip()
return bootstrap + '\n'
__all__ = [
'PYTHON_MANIFEST_FILES',
'list_python_manifest_files',
'normalize_host_path',
'should_prepare_python_env',
'wrap_python_command_with_env',
]
+7 -1
View File
@@ -323,6 +323,13 @@ monitoring:
# Prevent one large Workspace backlog from monopolizing PostgreSQL.
# Supports MONITORING__AUTO_CLEANUP__MAX_BATCHES_PER_TABLE_PER_RUN.
max_batches_per_table_per_run: 4
skills:
# Core-owned SkillStore. Skill discovery, activation, resources, CRUD and
# revisions remain available when Box is disabled.
# TODO(next-major): change the fresh-install default to './data/skills'
# after the online-upgrade window for the historical Box path closes.
root: './data/box/skills'
box:
# Master switch for the Box sandbox runtime. When false, LangBot does NOT
# attempt to connect to a remote Box runtime nor start a local stdio Box
@@ -378,7 +385,6 @@ box:
image: '' # Custom local sandbox image. Leave empty to use the profile default.
host_root: './data/box' # Base host directory for local workspace mounts. Docker deployments should override this with an absolute host path.
default_workspace: '' # Defaults to '<host_root>/default'. Relative paths are resolved under host_root.
skills_root: 'skills' # Core-owned skill repository shared with Box for optional execution mounts.
allowed_mount_roots: # Defaults to ['<host_root>'] when left empty.
- './data/box'
- '/tmp'
@@ -29,6 +29,9 @@ from langbot.pkg.cloud.entitlements import (
EntitlementSnapshot,
EntitlementUnavailableError,
)
from langbot.pkg.skill.manager import SkillManager
from langbot.pkg.skill.repository import SkillRepository
from langbot.pkg.provider.tools.loaders import skill as skill_loader
pytestmark = pytest.mark.integration
@@ -54,7 +57,7 @@ class _AdmissionBackend(BaseSandboxBackend):
'mount_isolation': True,
'network_isolation': True,
'hard_workspace_quota': True,
'hard_skill_storage_quota': True,
'hard_read_only_mount_quota': True,
'bounded_ephemeral_storage': True,
'inode_quota': True,
}
@@ -230,9 +233,18 @@ async def _stack(tmp_path):
deployment=SimpleNamespace(multi_workspace_enabled=True),
entitlement_resolver=EntitlementResolver('instance-a', entitlements),
workspace_service=workspace_service,
instance_config=SimpleNamespace(data={'box': box_config, 'system': {'limitation': {}}}),
instance_config=SimpleNamespace(
data={
'skills': {'root': str(shared_root / 'skills')},
'box': box_config,
'system': {'limitation': {}},
}
),
)
app.skill_repository = SkillRepository(app)
app.skill_mgr = SkillManager(app)
service = BoxService(app, client=client)
app.box_service = service
await service.initialize()
return service, runtime, backend, entitlements, server_task, client_task
@@ -312,7 +324,7 @@ async def test_two_workspaces_get_isolated_physical_sessions_and_paths(tmp_path)
@pytest.mark.asyncio
async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tmp_path):
async def test_cloud_core_skills_mount_generically_and_do_not_require_box_entitlement(tmp_path):
service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
first = _context('workspace-a')
second = _context('workspace-b')
@@ -324,32 +336,35 @@ async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tm
managed=False,
)
try:
private = await service.create_skill(
repository = service.ap.skill_repository
await repository.create_skill(
second,
{
'name': 'private',
'instructions': 'workspace-b secret',
},
)
own_skill = await service.create_skill(
own_skill = await repository.create_skill(
first,
{
'name': 'runner',
'instructions': 'Run scripts/main.py',
},
)
await service.write_skill_file(first, 'runner', 'scripts/main.py', "print('ok')")
await service.write_skill_file(first, 'runner', 'requirements.txt', 'requests==2.32.0\n')
refreshed_skill = await service.get_skill(first, 'runner')
await repository.write_skill_file(first, 'runner', 'scripts/main.py', "print('ok')")
await repository.write_skill_file(first, 'runner', 'requirements.txt', 'requests==2.32.0\n')
refreshed_skill = await repository.get_skill(first, 'runner')
assert refreshed_skill is not None
assert refreshed_skill['python_project'] is True
await service.ap.skill_mgr.reload_skills(first)
query = _query(first, 91)
await service.execute_tool(
{
'command': 'python /workspace/.skills/runner/scripts/main.py',
'workdir': '/workspace/.skills/runner',
},
_query(first, 91),
skill_name='runner',
query,
read_only_mounts=skill_loader.build_execution_mounts(service.ap, query),
)
mounted_spec = backend.started_specs[-1]
@@ -358,20 +373,14 @@ async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tm
assert mounted_spec.extra_mounts[0].mount_path == '/workspace/.skills/runner'
assert mounted_spec.extra_mounts[0].mode.value == 'ro'
with pytest.raises(BoxAdmissionError, match='Scanning arbitrary host'):
await service.scan_skill_directory(first, private['package_root'])
with pytest.raises(BoxAdmissionError, match='package_root is runtime-owned'):
await service.create_skill(
first,
{
'name': 'stolen',
'package_root': private['package_root'],
},
)
assert await service.get_skill(first, 'private') is None
assert await repository.get_skill(first, 'private') is None
await repository.create_skill(
ineligible,
{'name': 'docs-only', 'instructions': 'Read this without Box.'},
)
assert [skill['name'] for skill in await repository.list_skills(ineligible)] == ['docs-only']
with pytest.raises(EntitlementUnavailableError):
await service.list_skills(ineligible)
await service.execute_tool({'command': 'true'}, _query(ineligible, 92))
finally:
server_task.cancel()
client_task.cancel()
+20 -39
View File
@@ -41,6 +41,7 @@ from langbot_plugin.box.security import (
from langbot_plugin.entities.io.context import ActionContext
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.box.service import BoxService
from langbot.pkg.provider.tools.loaders import skill as skill_loader
_UTC = dt.timezone.utc
_CONTEXT = ExecutionContext(
@@ -301,7 +302,7 @@ class TestSharesFilesystemWithBox:
- stdio (local child process) shared filesystem True
- WebSocket (Docker / sidecar / --standalone-box / remote) separated False
This drives whether LangBot validates Box-reported skill paths locally.
This drives whether LangBot can safely perform local workspace operations.
Getting it wrong silently drops every skill in separated deployments.
"""
@@ -338,7 +339,7 @@ class TestSharesFilesystemWithBox:
def test_false_when_client_injected_without_connector(self):
# Injected client (no connector) → unknown topology → conservative False
# so LangBot never wrongly drops Box-reported skills.
# so LangBot does not assume a shared local filesystem.
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
assert service._runtime_connector is None
@@ -552,7 +553,6 @@ async def test_box_service_reconnect_restores_workspace_and_runs_cleanup(
monkeypatch: pytest.MonkeyPatch,
):
app = make_app(Mock())
app.skill_mgr = SimpleNamespace(reload_skills=AsyncMock())
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
connector = Mock()
connector.reconnect = AsyncMock()
@@ -565,16 +565,14 @@ async def test_box_service_reconnect_restores_workspace_and_runs_cleanup(
connector.reconnect.assert_awaited_once()
service._ensure_default_workspace.assert_called_once()
service._purge_attachment_dirs.assert_awaited_once()
app.skill_mgr.reload_skills.assert_awaited_once()
assert service.available is True
@pytest.mark.asyncio
async def test_cloud_box_service_reconnect_does_not_reload_unscoped_skills(
async def test_cloud_box_service_reconnect_restores_runtime_only(
monkeypatch: pytest.MonkeyPatch,
):
app = make_app(Mock())
app.skill_mgr = SimpleNamespace(reload_skills=AsyncMock())
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
service._cloud_managed = True
connector = Mock()
@@ -587,7 +585,6 @@ async def test_cloud_box_service_reconnect_does_not_reload_unscoped_skills(
connector.reconnect.assert_awaited_once()
service._verify_cloud_runtime.assert_awaited_once()
app.skill_mgr.reload_skills.assert_not_awaited()
assert service.available is True
@@ -1941,7 +1938,7 @@ def test_disconnect_callback_does_not_schedule_without_running_event_loop():
assert service._reconnecting is False
class TestBuildSkillExtraMounts:
class TestBuildSkillExecutionMounts:
"""Robustness of skill mount construction against a stale skill cache.
The three sandbox backends behave inconsistently when a skill's
@@ -1951,16 +1948,10 @@ class TestBuildSkillExtraMounts:
the backend never sees a bad mount.
"""
def _make_service(self, logger, skills, *, shares_filesystem=True):
def _make_app(self, logger, skills):
app = make_app(logger)
app.skill_mgr = SimpleNamespace(skills=skills, get_skills=Mock(return_value=skills))
client = Mock(spec=BoxRuntimeClient)
service = BoxService(app, client=client)
# Tests construct BoxService with an injected client (no connector), so
# set the topology explicitly. Most cases exercise the shared-fs (local
# stdio) path where local package_root validation applies.
service._shares_filesystem_with_box_override = shares_filesystem
return service
return app
def test_skips_skill_with_missing_package_root(self):
logger = Mock()
@@ -1969,10 +1960,10 @@ class TestBuildSkillExtraMounts:
'alive': {'name': 'alive', 'package_root': live_dir},
'ghost': {'name': 'ghost', 'package_root': '/nonexistent/path/should/never/exist'},
}
service = self._make_service(logger, skills)
app = self._make_app(logger, skills)
query = make_query()
mounts = service.build_skill_extra_mounts(query)
mounts = skill_loader.build_execution_mounts(app, query)
assert mounts == [
{
@@ -1987,27 +1978,19 @@ class TestBuildSkillExtraMounts:
for call in logger.warning.call_args_list
)
def test_trusts_box_paths_when_filesystem_not_shared(self):
"""In separated deployments (Docker Compose, k8s sidecar,
--standalone-box, remote endpoint) the Box runtime owns its own
filesystem. package_root values it reports are NOT resolvable on the
LangBot side, so LangBot must trust them rather than dropping every
skill via a local isdir() check."""
def test_rejects_missing_core_paths_when_filesystem_not_shared(self):
"""Core owns package paths even when Box is a separate process."""
logger = Mock()
skills = {
'a': {'name': 'a', 'package_root': '/box/skills/a'},
'b': {'name': 'b', 'package_root': '/box/skills/b'},
}
service = self._make_service(logger, skills, shares_filesystem=False)
app = self._make_app(logger, skills)
mounts = service.build_skill_extra_mounts(make_query())
mounts = skill_loader.build_execution_mounts(app, make_query())
assert mounts == [
{'host_path': '/box/skills/a', 'mount_path': '/workspace/.skills/a', 'mode': 'ro'},
{'host_path': '/box/skills/b', 'mount_path': '/workspace/.skills/b', 'mode': 'ro'},
]
# No skill is dropped, so no "missing" warning should be logged.
assert not any('package_root missing' in str(call.args[0]) for call in logger.warning.call_args_list)
assert mounts == []
assert len(logger.warning.call_args_list) == 2
def test_skips_skill_with_empty_package_root(self):
logger = Mock()
@@ -2015,25 +1998,23 @@ class TestBuildSkillExtraMounts:
'no_root': {'name': 'no_root', 'package_root': ''},
'whitespace': {'name': 'whitespace', 'package_root': ' '},
}
service = self._make_service(logger, skills)
app = self._make_app(logger, skills)
assert service.build_skill_extra_mounts(make_query()) == []
assert skill_loader.build_execution_mounts(app, make_query()) == []
def test_empty_package_root_skipped_even_when_not_shared(self):
"""An empty package_root is always invalid regardless of topology."""
logger = Mock()
skills = {'no_root': {'name': 'no_root', 'package_root': ''}}
service = self._make_service(logger, skills, shares_filesystem=False)
app = self._make_app(logger, skills)
assert service.build_skill_extra_mounts(make_query()) == []
assert skill_loader.build_execution_mounts(app, make_query()) == []
def test_returns_empty_when_no_skill_manager(self):
logger = Mock()
app = make_app(logger)
# no skill_mgr attribute
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
assert service.build_skill_extra_mounts(make_query()) == []
assert skill_loader.build_execution_mounts(app, make_query()) == []
# ── Attachment passthrough (inbound / outbound) ─────────────────────────────
+1 -1
View File
@@ -13,8 +13,8 @@ from langbot.pkg.box.workspace import (
classify_python_workspace,
infer_workspace_host_path,
rewrite_mounted_path,
wrap_python_command_with_env,
)
from langbot.pkg.utils.python_workspace import wrap_python_command_with_env
_CONTEXT = ExecutionContext(
+32 -25
View File
@@ -614,7 +614,7 @@ class TestNativeToolLoaderSkillPaths:
ap.skill_repository.read_skill_file.assert_awaited_once_with(_CONTEXT, 'demo', 'SKILL.md')
@pytest.mark.asyncio
async def test_external_runtime_rejects_skill_host_fallback_without_protocol_capability(self):
async def test_core_owned_skill_path_does_not_depend_on_runtime_topology(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
@@ -634,18 +634,20 @@ class TestNativeToolLoaderSkillPaths:
variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']},
)
with pytest.raises(ValueError, match='owned by the Box Runtime'):
await loader.invoke_tool(
'grep',
{
'path': '/workspace/.skills/demo',
'pattern': 'core-host-secret',
},
query,
)
result = await loader.invoke_tool(
'grep',
{
'path': '/workspace/.skills/demo',
'pattern': 'core-host-secret',
},
query,
)
assert result['ok'] is True
assert result['total'] == 1
@pytest.mark.asyncio
async def test_exec_in_activated_skill_mount_rewrites_command_and_refreshes(self):
async def test_exec_in_activated_skill_mount_rewrites_command_without_mutating_skill(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
@@ -656,11 +658,15 @@ class TestNativeToolLoaderSkillPaths:
default_workspace=tmpdir,
execute_tool=AsyncMock(return_value={'ok': True}),
)
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
skill_data = _make_skill_data(name='demo', package_root=tmpdir)
ap.skill_mgr = _make_skill_manager(
{'demo': skill_data},
refresh_skill_from_disk=Mock(),
)
loader = NativeToolLoader(ap)
query = _make_query(query_id='q1', launcher_type='person', launcher_id='123')
register_activated_skill(query, _make_skill_data(name='demo', package_root=tmpdir))
register_activated_skill(query, skill_data)
result = await loader.invoke_tool(
'exec',
@@ -675,8 +681,8 @@ class TestNativeToolLoaderSkillPaths:
tool_parameters = ap.box_service.execute_tool.await_args.args[0]
assert tool_parameters['command'] == 'python /workspace/.skills/demo/scripts/run.py'
assert tool_parameters['workdir'] == '/workspace/.skills/demo'
assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo'
ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with(_CONTEXT, 'demo')
assert 'skill_name' not in ap.box_service.execute_tool.await_args.kwargs
ap.skill_mgr.refresh_skill_from_disk.assert_not_called()
@pytest.mark.asyncio
async def test_external_runtime_python_skill_uses_trusted_metadata_and_writable_env(self):
@@ -689,17 +695,18 @@ class TestNativeToolLoaderSkillPaths:
shares_filesystem_with_box=False,
execute_tool=AsyncMock(return_value={'ok': True}),
)
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
skill_data = _make_skill_data(
name='demo',
package_root='/box-runtime/skills/tenants/workspace/demo',
python_project=True,
)
ap.skill_mgr = _make_skill_manager(
{'demo': skill_data},
refresh_skill_from_disk=Mock(),
)
loader = NativeToolLoader(ap)
query = _make_query(query_id='q-external', launcher_type='person', launcher_id='123')
register_activated_skill(
query,
_make_skill_data(
name='demo',
package_root='/box-runtime/skills/tenants/workspace/demo',
python_project=True,
),
)
register_activated_skill(query, skill_data)
result = await loader.invoke_tool(
'exec',
@@ -716,7 +723,7 @@ class TestNativeToolLoaderSkillPaths:
assert '_LB_VENV_DIR="/workspace/.skill-envs/demo/.venv"' in wrapped
assert 'root = "/workspace/.skills/demo"' in wrapped
assert '/box-runtime/skills/tenants/workspace/demo' not in wrapped
assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo'
assert 'skill_name' not in ap.box_service.execute_tool.await_args.kwargs
@pytest.mark.asyncio
async def test_write_requires_skill_activation(self):
+27 -1
View File
@@ -28,11 +28,11 @@ def _repository(tmp_path) -> SkillRepository:
),
instance_config=SimpleNamespace(
data={
'skills': {'root': str(tmp_path / 'skill-store')},
'box': {
'enabled': False,
'local': {
'host_root': str(tmp_path / 'box'),
'skills_root': 'skills',
},
}
}
@@ -41,6 +41,32 @@ def _repository(tmp_path) -> SkillRepository:
return SkillRepository(app)
def test_repository_prefers_standalone_skill_root(tmp_path):
repository = _repository(tmp_path)
assert repository._store.root == str((tmp_path / 'skill-store').resolve())
def test_repository_keeps_old_box_root_only_for_online_upgrade(tmp_path):
app = SimpleNamespace(
workspace_service=SimpleNamespace(get_execution_binding=_binding),
instance_config=SimpleNamespace(
data={
'box': {
'local': {
'host_root': str(tmp_path / 'box'),
'skills_root': 'legacy-skills',
}
}
}
),
)
repository = SkillRepository(app)
assert repository._store.root == str((tmp_path / 'box' / 'legacy-skills').resolve())
@pytest.mark.asyncio
async def test_repository_crud_and_reads_do_not_require_box(tmp_path):
repository = _repository(tmp_path)