Merge pull request #2512 from langbot-app/feat/2410-sdk-skill-store

feat(skill): decouple read-only skills from Box execution
This commit is contained in:
huanghuoguoguo
2026-10-17 10:13:26 +08:00
committed by GitHub
63 changed files with 2414 additions and 1675 deletions
+41 -7
View File
@@ -190,14 +190,17 @@ The Pipeline wrapper never scans a Box. Binding ends with the run; the reusable
remains subject to Runtime idle expiry. Persistent workspace files survive expiry,
but processes and container-local state do not.
Box is the sandbox subsystem used by native agent tools, stdio MCP servers, skill authoring, and managed processes.
Box is the optional sandbox subsystem used by native execution tools, stdio MCP servers, agent-side skill authoring, and managed processes. Skill storage and read-only access are Core responsibilities and remain available without Box.
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/manager.py` loads skills from the Box runtime, falling back to local `data/skills` when needed.
- `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. Core's registry points to immutable content-addressed publications, and every update carries `base_revision` so concurrent writers fail instead of overwriting each other.
- `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
sandbox sessions and managed processes are generation-scoped. LangBot validates
@@ -208,11 +211,42 @@ 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. Activation
pins the first revision for a run in Host-owned run ledger metadata; instructions,
resource reads, execution mounts, and revision-scoped dependency state use that
exact publication. A new run refreshes the Workspace catalog and resolves the
current revision, ignoring old conversation activation state. Lost in-memory
pins in an active authorized run are reloaded from its ledger. After a Core
process restart, expired run sessions cannot call tools: the Host rejects them
rather than silently resolving a new version. This does not add automatic run resumption. Core mounts only activated and authorized Skills. Published
trees are never agent write targets: authoring happens in Workspace drafts and
an explicit registration/update atomically publishes the next revision. Core
turns those package roots and content digests 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 execution paths include the publication digest (`/opt/langbot-artifacts/<digest>`).
Docker/Podman mount a read-only cache when creating the Box and atomically add
verified packages before execution; E2B seals and verifies a private staging copy
before publishing a stable projection. nsjail applies the requested mounts in a
separate namespace for each execution. Activating a Skill after acquiring a Box
therefore does not recreate the session or stop its managed processes. Ordinary
host mount changes still fail explicitly on fixed-mount backends.
A shared Box remains a Workspace filesystem sharing boundary: its commands can
see previously materialized packages. Run pins prevent revision switching; they
are not access isolation between mutually untrusted programs in the same Box.
Use package-relative script paths or the path returned by activation. Core tools
translate legacy `/workspace/.skills/<name>` command paths to the run's digest
path, but do not rewrite paths embedded inside scripts. Published packages remain
read-only; use writable drafts and the existing per-run outbox for output.
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
+9 -3
View File
@@ -27,8 +27,8 @@ services:
# The Box sandbox runtime is optional. It is only started when you run
# ``docker compose --profile box up`` (or ``docker compose --profile all
# up``). With Box off, LangBot keeps the dashboard / skills list visible
# (read-only) but disables sandbox tools, skill add/edit and stdio MCP —
# up``). With Box off, LangBot keeps skill management, activation, and
# read-only resources available but disables execution tools and stdio MCP —
# set ``box.enabled: false`` in ``data/config.yaml`` (or
# ``BOX__ENABLED=false`` in the langbot service env below) to match.
langbot_box:
@@ -73,6 +73,10 @@ services:
container_name: langbot
volumes:
- ./data:/app/data
# Core owns the SkillRepository even when the Box profile is disabled.
# Keep this path identical to langbot_box so optional execution can
# consume the same Workspace-scoped package revisions.
- ${LANGBOT_BOX_ROOT:-${PWD}/data/box}:${LANGBOT_BOX_ROOT:-${PWD}/data/box}
restart: on-failure
environment:
- TZ=Asia/Shanghai
@@ -92,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:
+41 -37
View File
@@ -24,6 +24,7 @@
│ │ │ (shared 容器, 多 process) │
│ │ │ │
│ │ ├──> SkillToolLoader (activate 工具) │
│ │ │ └─ build_execution_mounts() │
│ │ │ │
│ │ ├──> SkillAuthoringToolLoader │
│ │ │ │
@@ -35,7 +36,7 @@
│ ├─ Workspace quota 检查 │
│ ├─ 输出截断 (head+tail) │
│ ├─ Host scope 哈希 (resolve_box_session_id) │
│ ├─ 技能挂载组装 (build_skill_extra_mounts) │
│ ├─ 通用只读挂载接收 (read_only_mounts) │
│ ├─ 重连循环 (_reconnect_loop, 指数退避) │
│ └─ BoxRuntimeConnector │
│ ├─ 心跳 loop (20s ping) │
@@ -43,7 +44,7 @@
│ │ Action RPC (stdio 或 WebSocket) │
│ │
│ SkillManager (skill_mgr) │
│ └─ 从 Box runtime 拉取 skills, 不可用时回落 data/skills │
│ └─ 从 Core SkillRepository 加载 Workspace-scoped skills │
└──────────────────────────────────────────────────────────────────┘
│
▼
@@ -61,10 +62,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 │
@@ -89,6 +88,7 @@
- 一个 session_id 对应一个容器/沙箱实例。同一 session 内可并存多条 mount 与多个 managed process
- Runner 无权指定 session scope。SDK/Python `call_tool` 与 scoped MCP bridge 都发出同一个 `PluginToRuntimeAction.CALL_TOOL`,最终由 Host 的 ToolManager 执行,并使用当前 run 保存的同一个 execution Query
- Box 内托管的 stdio MCP server 使用独立的长期 `mcp-shared` session;它不是 Runner 本次事件的 sandbox 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))
---
@@ -96,7 +96,7 @@
### 2.1 BoxService (`pkg/box/service.py`, 722 行)
应用层门面,协调 Profile、安全校验、配额、连接、Skill 挂载与 Host scope 哈希:
应用层门面,协调 Profile、安全校验、配额、连接、Host scope 哈希与 Core 生成的只读挂载:
主要公开方法(按定义顺序):
@@ -108,7 +108,7 @@ BoxService
├─ available (property) 连接状态
│
├─ resolve_box_session_id(query) 哈希 Host 私有 scope,生成固定长度 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
@@ -125,12 +125,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 系统提示
@@ -142,7 +136,7 @@ BoxService
**Session 所有权**: `resolve_box_session_id(query)` 只接受 Host 已确定的私有 scope 或 Query launcher/session identity,并输出 `lb-box-` + 64 位小写 SHA-256 十六进制摘要(固定 71 个 ASCII 字符)。哈希输入是 canonical JSON,包含 instance、workspace、bot、platform adapter、target type/id 与 thread;原始用户、群组、conversation 或 event id 不会出现在 Box session id 中。相同 Host scope 稳定复用,不同 target/thread/workspace/bot/adapter/instance 相互隔离;缺少可用 identity 时 fail closed。Pipeline、Agent 或 Runner 配置都不能覆盖该规则。
**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 行)
@@ -176,11 +170,10 @@ BoxService
```
SkillManager
├─ initialize() 调用 reload_skills()
├─ reload_skills() 先从 Box runtime list_skills(),
│ 不可用则回落 data/skills/ 扫描
├─ refresh_skill_from_disk() 单 skill 重新加载
├─ reload_skills() 从 Core SkillRepository 加载
├─ refresh_skill_from_disk() 检查单 skill 的缓存状态
├─ get_skill_by_name(name)
└─ get_managed_skills_root() 返回 Box 视角的 skills_root 路径
└─ build_skill_aware_prompt_addition() 生成渐进披露索引
```
skill 元数据通过 `parse_frontmatter` 解析 `SKILL.md` 头部(`name` / `description` / `instructions`),不再做整体扫描的代价(典型 < 50 个)。
@@ -300,7 +293,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 attach;Action RPC 走 stdin/stdout。
@@ -309,7 +302,7 @@ stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process atta
`ActionRPCBoxClient` 封装 `Handler.call_action()` 调用:
- 25+ 方法对应 25+ 个 RPC action(exec / 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 实现复用
@@ -348,23 +341,29 @@ stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process atta
`BoxSpec` 校验器: `workdir` 默认继承 `mount_path`;`host_path` 支持 POSIX 和 Windows 路径;设置 `host_path` 时 `workdir` 必须在 `mount_path` 下。
### 3.7 BoxSkillStore (`box/skill_store.py`, 647 行)
### 3.7 SkillStore (`langbot_plugin.skill_store`)
新增模块(commit `4ab3502`),把 skill 持久化收归 Box runtime:
Skill 包存储最初位于 Box Runtime;issue #2410 将通用实现抽到 Plugin SDK 顶层,由 Core 独占存储和 revision 语义:
```
BoxSkillStore
SkillStore
├─ list_skills() / get_skill(name)
├─ create_skill(data) / update_skill(name, data) / delete_skill(name)
├─ scan_skill_directory(path) 扫描目录返回候选 skill 包列表
├─ list_skill_files(name, path) 浏览 skill 内文件树
├─ read_skill_file(name, path) / write_skill_file(name, path, content)
├─ list_skill_resources(name, path, revision) 按 revision 浏览只读资源
├─ read_skill_resource(name, path, revision) 按 revision 读取 UTF-8 资源
├─ read_skill_file(...) / write_skill_file(...) 管理侧文件接口
├─ preview_skill_zip(zip_bytes, ...) 不落盘预览 zip 内容
└─ install_skill_zip(zip_bytes, ...) 解压、校验、复制到 skills_root
└─ 支持 source_subdir / target_suffix(commit 1aa043f)
```
GitHub 安装路径:HTTP 层(`api/http/service/skill.py`)先 `git clone` 拉取,再走 `install_skill_zip` 或 directory 路径。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` 语义。Core 与 Box Runtime SDK 按同一发布单元同步升级;Box 不保留旧 Skill RPC,也会拒绝旧的 Skill-aware payload 字段。
仍保留的兼容仅用于已有数据的在线升级,不用于混版本协议:
1. Core 暂时读取 `box.local.skills_root`,兼容尚未生成 `skills.root` 的持久化配置。
2. 新安装默认暂时沿用历史 `./data/box/skills`,避免升级时搬迁已安装 Skill;下一大版本切换到 `./data/skills`。
### 3.8 Security (`box/security.py`, 52 行)
@@ -472,7 +471,7 @@ BuildAppStage.run(ap)
├─ ap.tool_mgr = tool_mgr
│
├─ ... (platform, pipeline) ...
├─ SkillManager.initialize() (从 Box runtime 加载 skill 列表)
├─ SkillManager.initialize() (从 Core SkillRepository 加载 skill 列表)
└─ ... (RAG, HTTP, plugins) ...
```
@@ -489,7 +488,7 @@ except Exception as e:
logger.warning(f"Box runtime unavailable: {e}")
```
**静默降级**: Box 初始化失败不会阻止应用启动,仅导致 6 个 native tool、所有 Skill 工具和 MCP-in-Box 工具不暴露给 LLM。与 Plugin 的行为不同(Plugin 失败会抛异常)。
**静默降级**: Box 初始化失败不会阻止应用启动。6 个 native tool、`register_skill` 和 MCP-in-Box 工具不暴露给 LLM;`activate` 与 Skill 只读资源工具继续由 Core 提供。与 Plugin 的行为不同(Plugin 失败会抛异常)。
### 5.3 销毁流程
@@ -513,13 +512,16 @@ Box 额外做了 RPC SHUTDOWN 通知 Runtime 主动清理容器,比 Plugin 的
### config.yaml (重构后)
```yaml
skills:
root: './data/box/skills' # Core-owned;Box 关闭时仍可管理/读取
box:
enabled: true # 整个 Box 子系统的总开关。设为 false 时:
# - 不连接远程 Box runtime,不 fork 本地 stdio 子进程
# - sandbox 工具 (exec/read/write/edit/glob/grep) 不暴露给 LLM
# - skill 添加/编辑 / GitHub 安装 / 文件写入全部拒绝
# - Agent 从 sandbox 注册 skill 的能力不可用
# - stdio 模式的 MCP server 启动时报错(http/sse 模式不受影响)
# - skill 列表/读取保持只读可用
# - skill 管理、激活和只读资源保持可用
# BOX__ENABLED 环境变量可覆盖(统一约定)
backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b'
# 由 box.backend / BOX__BACKEND 选择后端
@@ -531,7 +533,6 @@ box:
image: '' # 覆盖 profile 默认 image
host_root: './data/box' # 工作区挂载根,Docker 部署需绝对路径
default_workspace: '' # 默认 '<host_root>/default'
skills_root: 'skills' # Box 管理的 skill 包目录(相对 host_root)
allowed_mount_roots: # 默认 ['<host_root>']
- './data/box'
- '/tmp'
@@ -570,15 +571,18 @@ volumes:
| 消费方 | Box 可用 | Box 不可用(disabled 或 failed) |
|---|---|---|
| native exec/read/write/edit/glob/grep 工具 | 暴露给 LLM | **不暴露** |
| `activate` / `register_skill` 工具 | 暴露给 LLM | **不暴露** |
| `activate` / Skill resource 工具 | 暴露给 LLM | 暴露给 LLM |
| `register_skill` 工具 | 暴露给 LLM | **不暴露**;直接调用返回 `sandbox_unavailable` |
| stdio MCP server | 在 Box 内启动 | **`_init_stdio_python_server` 抛 RuntimeError** 拒绝;不退化到宿主 stdio |
| http/sse MCP server | 正常 | 正常(不依赖 Box) |
| Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Box runtime | 走 LangBot 本地 `data/skills/` 只读 fallback |
| Skill 创建/编辑/安装/写文件 | 走 Box runtime | **HTTP 400** + 明确错误信息(`_require_box_for_write`) |
| Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | **前端禁用** + banner |
| Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Core SkillRepository | 走 Core SkillRepository |
| Skill 创建/编辑/安装/写文件 | 走 Core SkillRepository | 走 Core SkillRepository |
| Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | 可编辑 |
| 仪表盘 Box 状态卡片 | 绿点 / "已连接" | 灰点 / "已禁用"(disabled) 或 红点 / "已断开"(failed) |
> 后端拒写的边界条件:如果 `ap.box_service` **完全没装**(老式 dev mode,没经过 BuildAppStage),`_require_box_for_write` 视作 no-op,保留 `data/skills/` 本地路径——以兼容历史测试与最小化设置。生产环境总会装 `ap.box_service`,因此该 fallback 不会被触发。
> Core 的 SkillRepository 是 `langbot_plugin.skill_store.SkillStore` 的异步 Workspace 适配层。默认 `skills.root` 保持原 `data/box/skills/tenants/...` 布局,升级时无需移动已安装 Skill;旧 `box.local.skills_root` 仅作为在线升级 fallback,并将在下一大版本删除。Box 只消费 Core 下发的通用只读 mount。
自定义 `skills.root` 若位于 Box 的 `allowed_mount_roots` 之外,Skill 仍可管理和读取,但脚本执行的只读挂载会被拒绝。执行这些 Skill 时,需将该路径加入允许的挂载根;分离部署还需让 Core 和 Box Runtime 在相同路径看见这份目录。
### Session scope
+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
View File
@@ -53,6 +53,7 @@
| BoxService 输出截断 | 优秀 | 短/精确边界/长输出、独立 stderr |
| BoxService 可观测性 | 优秀 | 状态报告、error ring buffer、buffer 上限 |
| BoxService Host-owned session | 优秀 | 覆盖 `lb-box-<sha256>` 固定格式、原始 identity 不泄露、同 scope 稳定、不同 conversation/scope 隔离、缺 identity fail closed |
| BoxService mount contract | 良好 | 通用 `read_only_mounts`;Skill mount 由 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 |
+1 -1
View File
@@ -71,7 +71,7 @@ dependencies = [
"langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.7.12",
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@dc3d98efd4f02565a4d56999f91152393542fd73",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
+8 -15
View File
@@ -1805,7 +1805,7 @@
},
{
"id": "sandbox-skill-authoring-e2e",
"title": "Local Agent creates, registers, activates, and uses a sandbox skill",
"title": "Local Agent publishes and executes an immutable Skill revision",
"mode": "agent-browser",
"area": "sandbox",
"type": "regression",
@@ -1817,9 +1817,7 @@
"skills",
"local-agent",
"tools",
"e2b",
"nsjail",
"host"
"nsjail"
],
"automation": "",
"setup_automation": [],
@@ -1833,7 +1831,7 @@
},
{
"id": "sandbox-skill-authoring-edit-existing-e2e",
"title": "Local Agent modifies an activated sandbox skill package",
"title": "Local Agent publishes a new Skill revision without changing an active run",
"mode": "agent-browser",
"area": "sandbox",
"type": "regression",
@@ -1845,17 +1843,12 @@
"skills",
"local-agent",
"tools",
"edit"
],
"automation": "scripts/e2e/pipeline-debug-chat.mjs",
"setup_automation": [
"node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env",
"node:scripts/e2e/reset-sandbox-skill.mjs --name=lb-skill-edit-regression"
],
"setup_provides_env": [
"LANGBOT_LOCAL_AGENT_PIPELINE_URL",
"LANGBOT_LOCAL_AGENT_PIPELINE_NAME"
"edit",
"nsjail"
],
"automation": "",
"setup_automation": [],
"setup_provides_env": [],
"evidence_required": [
"ui",
"backend_log",
@@ -1,5 +1,5 @@
id: sandbox-skill-authoring-e2e
title: "Local Agent creates, registers, activates, and uses a sandbox skill"
title: "Local Agent publishes and executes an immutable Skill revision"
mode: agent-browser
area: sandbox
type: regression
@@ -11,45 +11,32 @@ tags:
- skills
- local-agent
- tools
- e2b
- nsjail
- host
skills:
- langbot-env-setup
- langbot-testing
env:
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
- LANGBOT_LOCAL_AGENT_PIPELINE_URL
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
env_any:
- LANGBOT_LOCAL_AGENT_PIPELINE_URL|LANGBOT_LOCAL_AGENT_PIPELINE_NAME
preconditions:
- "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test."
- "LangBot is started with the Box backend intended for this run, such as e2b, nsjail, or explicit host development mode."
- "The selected model route supports tool/function calling strongly enough to invoke sandbox tools."
- "The selected Local Agent uses a function-calling model and an available Box backend."
steps:
- "Start LangBot with the target sandbox backend and confirm the Box status UI or LANGBOT_BACKEND_URL /api/v1/box/status reports the expected backend."
- "Open the target Local Agent pipeline in Debug Chat with a function-calling model."
- "Run the canonical prompt pattern in references/sandbox-skill-authoring.md with a unique skill name."
- "Capture the visible final response, backend logs, and the registered skill-store files."
- "Open the target Local Agent in Debug Chat."
- "Follow Publish and execute in references/sandbox-skill-authoring.md with a unique Skill name."
- "Record the activated revision and the script's independently expected output."
- "Attempt a write under the published package, then read back the original contents."
checks:
- "UI: Debug Chat final assistant response contains E2E_OK:<skill-name>."
- "Logs: The model called exec, register_skill, activate, then exec again from the activated skill path."
- "Logs: The selected backend name is the expected one, such as e2b, nsjail, or host."
- "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md."
- "Box status: recent_error_count is 0 after the run."
- "UI and logs show successful draft creation, registration, activation and script execution."
- "Script output matches the deterministic expected value."
- "Published-package mutation is rejected; outputs remain in the run outbox or writable draft."
- "The expected rejected write is distinguishable from unexpected execution failures."
evidence_required:
- ui
- backend_log
- api_diagnostic
- filesystem
diagnostics:
- "If Debug Chat fails before tool execution, first validate the model provider and function-calling support."
- "If /workspace/.skills/<skill> is missing only on E2B, check extra mount sync behavior."
- "If nsjail starts but every command fails before execve, check nsjail CLI compatibility and chroot mount targets."
- "API or raw provider probes are diagnostic only; they do not make this UI case pass by themselves."
troubleshooting:
- sandbox-native-tools-unavailable
- e2b-extra-mount-sync-missing
- box-session-conflict-logical-metadata
- nsjail-cli-compatibility
- socks-proxy-without-socksio
- "Check Box availability and backend selection before interpreting a missing tool."
- "The runner may acquire Box before activation; a new package must not recreate the session."
@@ -1,5 +1,5 @@
id: sandbox-skill-authoring-edit-existing-e2e
title: "Local Agent modifies an activated sandbox skill package"
title: "Local Agent publishes a new Skill revision without changing an active run"
mode: agent-browser
area: sandbox
type: regression
@@ -12,66 +12,35 @@ tags:
- local-agent
- tools
- edit
- nsjail
skills:
- langbot-env-setup
- langbot-testing
env:
- LANGBOT_REPO
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
- LANGBOT_LOCAL_AGENT_PIPELINE_URL
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
automation: scripts/e2e/pipeline-debug-chat.mjs
automation_env:
- LANGBOT_REPO
- LANGBOT_FRONTEND_URL
- LANGBOT_BACKEND_URL
- LANGBOT_BROWSER_PROFILE
- LANGBOT_CHROMIUM_EXECUTABLE
- LANGBOT_LOCAL_AGENT_PIPELINE_URL
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
automation_pipeline_url_env: LANGBOT_LOCAL_AGENT_PIPELINE_URL
automation_pipeline_name_env: LANGBOT_LOCAL_AGENT_PIPELINE_NAME
automation_expected_text: "E2E_OK:lb-skill-edit-regression:SANDBOX_SKILL_MODIFIED_OK"
automation_prompts_json: '[{"prompt":"Skill name is lb-skill-edit-regression. Use exactly one exec tool call with workdir=/workspace. In that single command, remove old /workspace/lb-skill-edit-regression, create SKILL.md, scripts/use.py, and data/input.json. The JSON must contain numbers [1,2,3,4], factors [2,3,4], and marker CREATE_MARKER_EDIT_REGRESSION. The Python script must read data/input.json relative to __file__ and print exactly SANDBOX_SKILL_CREATE_OK sum=10 product=24 marker=CREATE_MARKER_EDIT_REGRESSION. Run python3 /workspace/lb-skill-edit-regression/scripts/use.py in the same command. After the exec succeeds, final answer exactly CREATE_OK:lb-skill-edit-regression.","expected_text":"CREATE_OK:lb-skill-edit-regression","response_timeout_ms":"180000"},{"prompt":"Continue with the same skill name lb-skill-edit-regression. Strictly call register_skill with path=/workspace/lb-skill-edit-regression and name=lb-skill-edit-regression, then call activate with skill_name=lb-skill-edit-regression. Do not call exec, read, write, edit, grep, or ls in this step. After both tools succeed, final answer exactly REGISTER_ACTIVATE_OK:lb-skill-edit-regression.","expected_text":"REGISTER_ACTIVATE_OK:lb-skill-edit-regression","response_timeout_ms":"180000"},{"prompt":"Continue with the same activated skill lb-skill-edit-regression. Use exactly one exec tool call with workdir=/workspace/.skills/lb-skill-edit-regression. In that single exec command, run exactly this shell logic: set -e; overwrite SKILL.md with a heading and UPDATED_MARKER_EDIT_REGRESSION; overwrite data/input.json with numbers [5,6], factors [7,8], and marker UPDATED_MARKER_EDIT_REGRESSION; overwrite scripts/use.py so it reads data/input.json relative to __file__ and prints exactly SANDBOX_SKILL_MODIFIED_OK sum=11 product=56 marker=UPDATED_MARKER_EDIT_REGRESSION; run python3 scripts/use.py. Do not write exit(1). Do not call find, ls, read, grep as separate tools. If the single exec exits successfully and prints SANDBOX_SKILL_MODIFIED_OK, do not call more tools. Final answer exactly E2E_OK:lb-skill-edit-regression:SANDBOX_SKILL_MODIFIED_OK.","expected_text":"E2E_OK:lb-skill-edit-regression:SANDBOX_SKILL_MODIFIED_OK","response_timeout_ms":"240000"}]'
setup_automation:
- "node:scripts/e2e/ensure-local-agent-pipeline.mjs --write-env"
- "node:scripts/e2e/reset-sandbox-skill.mjs --name=lb-skill-edit-regression"
setup_provides_env:
- LANGBOT_LOCAL_AGENT_PIPELINE_URL
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
automation_filesystem_checks_json: '[{"path":"${LANGBOT_REPO}/data/box/skills/lb-skill-edit-regression/SKILL.md","contains":"UPDATED_MARKER_EDIT_REGRESSION"},{"path":"${LANGBOT_REPO}/data/box/skills/lb-skill-edit-regression/data/input.json","json_equals":{"numbers":[5,6],"factors":[7,8],"marker":"UPDATED_MARKER_EDIT_REGRESSION"}},{"path":"${LANGBOT_REPO}/data/box/skills/lb-skill-edit-regression/scripts/use.py","contains":["SANDBOX_SKILL_MODIFIED_OK"],"not_contains":["SANDBOX_SKILL_CREATE_OK","exit(1)"]},{"argv":["python3","scripts/use.py"],"cwd":"${LANGBOT_REPO}/data/box/skills/lb-skill-edit-regression","stdout_contains":"SANDBOX_SKILL_MODIFIED_OK sum=11 product=56 marker=UPDATED_MARKER_EDIT_REGRESSION","exit_code":0}]'
automation_stream_output: "0"
automation_reset_debug_chat: "1"
automation_response_timeout_ms: "420000"
env_any:
- LANGBOT_LOCAL_AGENT_PIPELINE_URL|LANGBOT_LOCAL_AGENT_PIPELINE_NAME
preconditions:
- "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test."
- "LangBot is started with the sandbox backend intended for this run, such as e2b or nsjail."
- "The selected model route supports tool/function calling strongly enough to invoke sandbox tools."
- "The selected Local Agent uses a function-calling model and an available Box backend."
- "A unique test Skill v1 has a deterministic script and reference output."
steps:
- "Start LangBot with the target sandbox backend and confirm native sandbox tools are available."
- "Open the target Local Agent pipeline in Debug Chat with a function-calling model."
- "Run the automation prompt or the equivalent prompt pattern from references/sandbox-skill-authoring.md."
- "Capture the visible final response, backend logs, and the registered skill-store files."
- "Follow Update an existing Skill in references/sandbox-skill-authoring.md."
- "Keep one run active on v1; edit a writable draft and publish v2 using base_revision=v1."
- "Read and execute again in the existing run, then activate and execute in a new run in the same conversation."
- "Attempt a conflicting update using the stale base_revision and verify rejection."
- "Follow Shared Box and recovery to check retained temporary state and a managed process."
checks:
- "UI: A Bot message, not only the user prompt, contains E2E_OK:lb-skill-edit-regression:SANDBOX_SKILL_MODIFIED_OK."
- "Logs: The model called exec, register_skill, activate, then a second exec whose workdir is /workspace/.skills/lb-skill-edit-regression."
- "Logs: The second exec stdout contains SANDBOX_SKILL_MODIFIED_OK."
- "Automation: Filesystem checks pass for the registered skill-store files under LANGBOT_REPO/data/box/skills/lb-skill-edit-regression."
- "Skill store: SKILL.md and data/input.json contain UPDATED_MARKER_EDIT_REGRESSION."
- "Skill store: scripts/use.py is the modified script and prints SANDBOX_SKILL_MODIFIED_OK sum=11 product=56 marker=UPDATED_MARKER_EDIT_REGRESSION."
- "The existing run continues to read and execute v1; the new run reads and executes v2."
- "Published revisions remain immutable, and stale publication is rejected."
- "Alternating activated revisions do not recreate the shared Box or stop the managed process."
- "An unbound Skill is not restored into a subsequent run."
evidence_required:
- ui
- backend_log
- api_diagnostic
- filesystem
diagnostics:
- "If the model stops after activation and only reruns the original script, treat this as a failed edit-existing E2E even if create/register/activate succeeded."
- "If the expected marker appears only in the user prompt, treat the run as failed and inspect automation-result.json assistant counts."
- "If the browser opens /login or console shows 401, refresh the authenticated browser profile before diagnosing the runner."
troubleshooting:
- sandbox-native-tools-unavailable
- e2b-extra-mount-sync-missing
- box-session-conflict-logical-metadata
- nsjail-cli-compatibility
- socks-proxy-without-socksio
- "An output marker in the user prompt alone is not execution evidence."
- "Do not edit files in the published path; publish a writable draft explicitly."
- "Expired run tool sessions must fail explicitly, never silently select the latest Skill."
@@ -1,139 +1,60 @@
# Sandbox Skill Authoring
# Skill publication and execution acceptance
## Goal
Verify that Local Agent can use sandbox tools to create, register, activate, and use a LangBot skill package through the same path a user would exercise in Debug Chat.
This flow applies to Docker, nsjail, E2B, and the explicit host development backend. Host runs commands directly as the Box Runtime user and must never be treated as sandbox-isolation coverage. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
Verify through Debug Chat that instructions and text resources work without Box,
and that executable packages use the revision activated by the current run.
API and direct backend checks diagnose failures; they do not establish UI acceptance.
## Preconditions
1. Read `../.env` and use `LANGBOT_FRONTEND_URL` and `LANGBOT_BACKEND_URL`.
2. Start LangBot with the intended backend:
- `BOX_BACKEND=e2b` when validating E2B.
- `BOX_BACKEND=nsjail` when validating nsjail.
- `BOX_BACKEND=local` or `docker` when validating local container fallback.
- `BOX_BACKEND=host` only when validating explicit, trusted local direct execution.
3. Confirm `/api/v1/box/status` reports `available: true` and the expected backend name.
4. Confirm Debug Chat uses a model with function-calling ability.
5. Confirm backend logs say native sandbox tools are available.
Use the frontend/backend and Local Agent pipeline selected in the machine's
`skills/.env.local`. The model must support tool calling. Do not record credentials.
Use Docker/Podman, nsjail, or a configured E2B service. The runner may acquire an
empty Box before activation; adding a revision must preserve that Box. Host mode
provides no sandbox isolation evidence.
Do not store sandbox provider keys, JWTs, OAuth tokens, or localStorage values in the case or notes.
## Read-only path
## Debug Chat Prompt Pattern
Install a unique Skill with instructions and a UTF-8 reference, then disable Box.
Ask the Agent to activate it and read the reference. Check the reported revision,
reference contents, and absence of sandbox creation. Execution requests must fail
clearly; activation must not claim that files are already mounted.
Use a unique skill name per run, for example:
## Publish and execute
```text
lb-sandbox-agent-e2e-<timestamp>
```
1. With Box enabled, create a draft under `/workspace/skill-drafts/<name>`.
Include `SKILL.md`, `scripts/use.py`, and `data/input.json`. Resolve resource
files relative to `__file__`; the script should print a deterministic marker.
2. Call `register_skill` with the draft path, then `activate`. Record revision v1.
3. Execute the script under the `mount_path` returned by activation. Write results to the
run's outbox or draft directory, never the published package.
4. Attempt to overwrite a published file. The operation must fail and its contents
must remain unchanged. Check UI results, tool logs, and the published package.
Send a prompt that requires the model to do all of the following:
## Update an existing Skill
1. Use `exec` to create a multi-file skill under `/workspace/<skill-name>`.
2. Include at least:
- `SKILL.md`
- `scripts/use.py`
- `data/input.json`
3. In the same first `exec`, run the script and verify a deterministic marker such as:
Copy v1 to a separate writable draft. Modify the draft and publish with
`base_revision=v1`; record v2. The existing run must continue reading/executing v1,
including when `activate` is called again. A new run in the same conversation must
activate v2. A stale `base_revision` update must fail. Removing the Skill from the
new run's authorization must not break startup or restore the old activation.
Verify a second UI edit/save adopts the revision returned by the first save.
```text
SANDBOX_COMPLEX_SKILL_OK sum=10 product=24
```
## Shared Box and recovery
4. Call `register_skill` with `path=/workspace/<skill-name>`.
5. Call `activate` with `skill_name=<skill-name>`.
6. Call `exec` with `workdir=/workspace/.skills/<skill-name>` and run:
With nsjail, alternate two authorized runs using v1 and v2 in the same Box. Check
each script's independent version marker, retained `/tmp` state, and a running
managed process. Neither activation nor a changed mount set may recreate the Box.
Docker/Podman and E2B must likewise add v2 at its digest path while v1 and the
existing Box remain usable. On fixed-mount backends, changing ordinary host mounts must fail without
removing the session. Programs sharing a Box can see its cached packages; test
version consistency without claiming that the shared filesystem isolates runs.
```bash
python3 scripts/use.py && echo SANDBOX_ACTIVATED_WRITEBACK_OK > activated_writeback.txt && cat activated_writeback.txt
```
Pins are Host-owned run ledger data, not conversation state. An active run whose
in-memory pin is reloaded must use its recorded revision, even after publication
or deletion of the current catalog entry. A missing recorded package must fail.
A Core process restart expires tool sessions; calls from those sessions must be
rejected. Do not report this as automatic run recovery.
7. Require the final answer to contain only an explicit success marker:
```text
E2E_OK:<skill-name>
```
Keep the test script robust to working-directory changes. Prefer resolving data paths from `__file__`:
```python
from pathlib import Path
data_path = Path(__file__).resolve().parent.parent / "data" / "input.json"
```
## Success Criteria
The UI should show an assistant final response containing `E2E_OK:<skill-name>`.
Backend logs should show:
- `exec tool invoked`
- `register_skill`
- `activate`
- a second `exec` whose workdir is `/workspace/.skills/<skill-name>`
- `backend=e2b`, `backend=nsjail`, `backend=host`, or the expected local backend
After the run, verify the skill store through the UI or API:
- Skill root lists `SKILL.md`, `scripts`, `data`, and `activated_writeback.txt`.
- `scripts/use.py` is readable.
- `data/input.json` is readable.
- `activated_writeback.txt` contains `SANDBOX_ACTIVATED_WRITEBACK_OK`.
- `/api/v1/box/errors` is empty.
## Existing Skill Edit Variant
The base `sandbox-skill-authoring-e2e` case only proves create, register,
activate, and use. To prove that an already activated skill can be modified,
run `sandbox-skill-authoring-edit-existing-e2e` or use its prompt pattern.
The edit variant must include these additional checks:
- The second `exec` uses `workdir=/workspace/.skills/<skill-name>`.
- The second `exec` overwrites `SKILL.md`, `data/input.json`, and
`scripts/use.py` under the activated skill path.
- The modified script prints a deterministic marker such as:
```text
SANDBOX_SKILL_MODIFIED_OK sum=11 product=56 marker=<updated-marker>
```
- `grep -q <updated-marker> SKILL.md scripts/use.py data/input.json` succeeds
in the same activated-path command.
- Filesystem evidence under the Box-managed skill store shows the updated
marker, not only the original create marker.
If the model stops after activation and only reruns the original script, treat
that run as a failed edit-existing E2E even when create/register/activate
succeeded.
## Diagnostic Checks
Use these only after the model-driven Debug Chat flow fails:
- `/api/v1/box/status` to confirm backend selection and recent errors.
- `/api/v1/box/sessions` to check leaked or conflicting sessions.
- Direct backend probes to separate provider credentials from LangBot integration.
- Filesystem inspection under the configured Box-managed skill store.
For E2B raw HTTP diagnostics, include a valid template id such as `base`; a missing template can produce schema validation errors that are unrelated to authentication.
## Known Pitfalls
- When Box is available, skills may be owned by the Box runtime and stored in Box-managed skill storage. Do not assume `data/skills` is the active source of truth.
- Public E2B does not provide local bind mounts. Main workspace and activated skill extra mounts must be synchronized into and back out of the E2B sandbox.
- Session metadata should keep LangBot logical paths such as `/workspace`; storing provider-internal paths can make later requests look incompatible.
- nsjail versions differ. Some expose only `--disable_clone_new*` flags and use `--bindmount` instead of `--rw_bind`.
- On WSL, cgroup v2 may exist but not be writable. The backend should warn and fall back to rlimits rather than fail the sandbox.
- The host backend does not honor sandbox image, network, rootfs, process, or
resource isolation. Use a disposable workspace and low-privilege account.
- If `ALL_PROXY` uses a SOCKS URL and `socksio` is not installed, some Python HTTP clients can fail during startup. Prefer consistent HTTP proxy variables unless SOCKS support is installed.
## Related Troubleshooting
- `sandbox-native-tools-unavailable`
- `e2b-extra-mount-sync-missing`
- `box-session-conflict-logical-metadata`
- `nsjail-cli-compatibility`
- `socks-proxy-without-socksio`
Capture the visible assistant results, relevant tool logs, revisions, and file
contents. Public E2B credentials and deployment topology are separate prerequisites;
mocked or local backend results do not establish E2B service acceptance.
+7 -13
View File
@@ -47,9 +47,6 @@ from ...telemetry.execution import close_trace as close_execution_trace
from ...telemetry.execution import record as record_execution
ACTIVATED_SKILL_NAMES_STATE_KEY = 'host.activated_skills'
class AgentRunOrchestrator:
"""Coordinate one Runner execution.
@@ -176,24 +173,21 @@ class AgentRunOrchestrator:
resources=resources,
)
authorized_skill_names = [
str(skill['skill_name']) for skill in resources.get('skills', []) if skill.get('skill_name')
]
# Every invocation owns a new run; conversation state is not a recovery snapshot.
prepare_execution_query(execution_query, event, authorized_skill_names)
execution_query.variables.pop(skill_loader.ACTIVATED_SKILLS_KEY, None)
session_query_id = None
if adapter_context:
if execution_query is not None:
skill_loader.restore_activated_skills_from_state(
self.ap,
execution_query,
context.get('state', {}),
)
session_query_id = adapter_context.get('query_id')
if execution_query is not None or session_query_id is not None:
context['context']['available_apis']['prompt_get'] = True
if 'params' in adapter_context:
context['adapter']['extra']['params'] = adapter_context['params']
authorized_skill_names = [
str(skill['skill_name']) for skill in resources.get('skills', []) if skill.get('skill_name')
]
prepare_execution_query(execution_query, event, authorized_skill_names)
context['variables'] = {
key: value
for key, value in (execution_query.variables or {}).items()
@@ -106,6 +106,8 @@ class AgentResourceBuilder:
descriptor,
runner_config,
)
if getattr(self.ap, 'skill_mgr', None) is not None:
await self.ap.skill_mgr.reload_skills(execution_context)
skills = self._build_skills_from_binding(
execution_context,
resource_policy,
@@ -377,6 +377,33 @@ class RunLedgerStore:
inflight_hub.notify_run(run_id, event_type)
return self._event_to_dict(row)
async def pin_skill_revision(self, run_id: str, workspace_id: str, name: str, revision: str) -> str:
"""Pin a Host-owned revision without overwriting concurrent run metadata."""
async with self._session_factory() as session:
run = await self._get_run_row(session, run_id)
if run is None or run.workspace_id != workspace_id or run.status in TERMINAL_STATUSES:
raise ValueError('Skill activation requires an active run in the current Workspace')
original = run.metadata_json
metadata = _json_loads(original, {})
bindings = metadata.setdefault('activated_skills', {})
if name in bindings:
return str(bindings[name])
bindings[name] = revision
result = await session.execute(
sqlalchemy.update(AgentRun)
.where(
AgentRun.run_id == run_id,
AgentRun.metadata_json == original,
AgentRun.status.notin_(TERMINAL_STATUSES),
)
.values(metadata_json=_json_dumps(metadata))
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
raise ValueError('Concurrent run update; retry Skill activation')
await session.commit()
return revision
async def finalize_run(
self,
*,
@@ -2,12 +2,10 @@ from __future__ import annotations
import quart
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError
from langbot_plugin.box.errors import BoxError
from ...authz import Permission
from ...context import RequestContext
from .....operation_trace import service as settings_service
from .....skill.repository import SkillRevisionConflictError
from .. import group
@@ -25,12 +23,7 @@ class SkillsRouterGroup(group.RouterGroup):
async def list_skills(request_context: RequestContext) -> quart.Response:
try:
skills = await self.ap.skill_service.list_skills(request_context)
except EntitlementFeatureUnavailableError:
# Plans without managed sandbox support have no runnable skills.
# Treat that capability absence as an empty collection so the
# shared UI can render normally instead of surfacing a 500.
return self.success(data={'skills': []})
except (ValueError, BoxError) as exc:
except ValueError as exc:
return self.http_status(400, -1, str(exc))
return self.success(data={'skills': skills})
@@ -48,7 +41,7 @@ class SkillsRouterGroup(group.RouterGroup):
try:
skill = await self.ap.skill_service.create_skill(request_context, data)
return self.success(data={'skill': skill})
except (ValueError, BoxError) as exc:
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
@@ -60,7 +53,7 @@ class SkillsRouterGroup(group.RouterGroup):
async def get_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
try:
skill = await self.ap.skill_service.get_skill(request_context, skill_name)
except (ValueError, BoxError) as exc:
except ValueError as exc:
return self.http_status(400, -1, str(exc))
if not skill:
return self.http_status(404, -1, 'Skill not found')
@@ -80,11 +73,13 @@ class SkillsRouterGroup(group.RouterGroup):
data = await quart.request.json
try:
previous = await self.ap.skill_service.get_skill(request_context, skill_name)
except (ValueError, BoxError):
except ValueError:
previous = None
try:
skill = await self.ap.skill_service.update_skill(request_context, skill_name, data)
except (ValueError, BoxError) as exc:
except SkillRevisionConflictError as exc:
return self.http_status(409, -1, str(exc))
except ValueError as exc:
return self.http_status(400, -1, str(exc))
changes = settings_service.changed_fields(
@@ -101,7 +96,7 @@ class SkillsRouterGroup(group.RouterGroup):
try:
await self.ap.skill_service.delete_skill(request_context, skill_name)
return self.success()
except (ValueError, BoxError) as exc:
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
@@ -123,7 +118,9 @@ class SkillsRouterGroup(group.RouterGroup):
include_hidden=include_hidden,
)
return self.success(data=result)
except (ValueError, BoxError) as exc:
except SkillRevisionConflictError as exc:
return self.http_status(409, -1, str(exc))
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
@@ -136,7 +133,7 @@ class SkillsRouterGroup(group.RouterGroup):
try:
result = await self.ap.skill_service.read_skill_file(request_context, skill_name, path)
return self.success(data=result)
except (ValueError, BoxError) as exc:
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
@@ -148,13 +145,20 @@ class SkillsRouterGroup(group.RouterGroup):
async def write_skill_file(skill_name: str, path: str, request_context: RequestContext) -> quart.Response:
data = await quart.request.json
content = data.get('content', '')
base_revision = str(data.get('base_revision', '') or '').strip() or None
if content is None:
return self.http_status(400, -1, 'Missing required field: content')
try:
result = await self.ap.skill_service.write_skill_file(request_context, skill_name, path, content)
result = await self.ap.skill_service.write_skill_file(
request_context,
skill_name,
path,
content,
base_revision=base_revision,
)
return self.success(data=result)
except (ValueError, BoxError) as exc:
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
@@ -191,7 +195,7 @@ class SkillsRouterGroup(group.RouterGroup):
try:
skill = await self.ap.skill_service.install_from_github(request_context, data)
return self.success(data={'skills': skill})
except (ValueError, BoxError) as exc:
except ValueError as exc:
return self.http_status(400, -1, str(exc))
except Exception:
raise
@@ -215,7 +219,7 @@ class SkillsRouterGroup(group.RouterGroup):
try:
preview = await self.ap.skill_service.preview_install_from_github(request_context, data)
return self.success(data={'skills': preview})
except (ValueError, BoxError) as exc:
except ValueError as exc:
return self.http_status(400, -1, str(exc))
except Exception:
raise
@@ -243,7 +247,7 @@ class SkillsRouterGroup(group.RouterGroup):
source_paths=form.getlist('source_paths'),
)
return self.success(data={'skills': skill})
except (ValueError, BoxError) as exc:
except ValueError as exc:
return self.http_status(400, -1, str(exc))
except Exception:
raise
@@ -266,7 +270,7 @@ class SkillsRouterGroup(group.RouterGroup):
filename=file.filename or '',
)
return self.success(data={'skills': preview})
except (ValueError, BoxError) as exc:
except ValueError as exc:
return self.http_status(400, -1, str(exc))
except Exception:
raise
@@ -285,5 +289,5 @@ class SkillsRouterGroup(group.RouterGroup):
try:
result = await self.ap.skill_service.scan_directory_async(request_context, path)
return self.success(data=result)
except (ValueError, BoxError) as exc:
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@@ -148,6 +148,10 @@ class MaintenanceService:
scoped_storage_path = Path('data/storage') / self.ap.storage_mgr.scoped_prefix(context)
roots = [('storage', scoped_storage_path)]
skill_repository = getattr(self.ap, 'skill_repository', None)
if skill_repository is not None:
roots.append(('skills', Path(skill_repository.storage_root(context))))
sections = await asyncio.to_thread(self._collect_sections, roots)
runtime_processes = await self._runtime_storage_processes(
context,
+71 -78
View File
@@ -11,7 +11,6 @@ from typing import Optional
from urllib.parse import quote, unquote, urlparse
import httpx
from langbot_plugin.entities.io.errors import ActionCallTimeoutError
from ....core import app
from ....skill.utils import parse_frontmatter
@@ -26,6 +25,7 @@ _PUBLIC_SKILL_FIELDS = (
'description',
'instructions',
'package_root',
'revision',
'created_at',
'updated_at',
)
@@ -54,38 +54,11 @@ class SkillService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
def _box_service(self):
box_service = getattr(self.ap, 'box_service', None)
if box_service is not None and getattr(box_service, 'available', False):
return box_service
return None
def _require_box(self, action: str):
"""Return the Box service or raise if it is not available.
Box is the only source of truth for skills. Every read and write
operation goes through it — there is no local-filesystem fallback.
"""
box_service = self._box_service()
if box_service is not None:
return box_service
ap_box = getattr(self.ap, 'box_service', None)
if ap_box is None:
reason = 'not initialised'
elif not getattr(ap_box, 'enabled', True):
reason = 'disabled in config (box.enabled = false)'
else:
connector_error = getattr(ap_box, '_connector_error', '') or 'currently unavailable'
reason = f'unavailable: {connector_error}'
raise ValueError(
f'{action} requires the Box runtime, which is {reason}. '
f'Enable Box in config.yaml (box.enabled = true) and ensure the '
f'runtime is reachable before retrying.'
)
def _require_box_for_write(self, action: str) -> None:
"""Backwards-compatible alias preserved for clarity at call sites."""
self._require_box(action)
def _repository(self):
repository = getattr(self.ap, 'skill_repository', None)
if repository is None:
raise ValueError('Skill repository is not initialised')
return repository
async def _execution_context(self, context: TenantContext) -> ExecutionContext:
workspace_uuid = require_workspace_uuid(context)
@@ -114,27 +87,11 @@ class SkillService:
async def list_skills(self, context: TenantContext) -> list[dict]:
execution_context = await self._execution_context(context)
# When Box is unavailable, surface an empty list rather than raising —
# the skills page should render cleanly, and the UI separately renders
# a "Box disabled / unavailable" banner via useBoxStatus.
box_service = self._box_service()
if box_service is None:
return []
try:
skills = await box_service.list_skills(execution_context)
except ActionCallTimeoutError as exc:
logger = getattr(self.ap, 'logger', None)
if logger is not None:
logger.warning(f'Box skill listing timed out: {exc}')
return []
return [self._serialize_skill(skill) for skill in skills]
return [self._serialize_skill(skill) for skill in await self._repository().list_skills(execution_context)]
async def get_skill(self, context: TenantContext, skill_name: str) -> Optional[dict]:
execution_context = await self._execution_context(context)
box_service = self._box_service()
if box_service is None:
return None
skill = await box_service.get_skill(execution_context, skill_name)
skill = await self._repository().get_skill(execution_context, skill_name, snapshot=True)
return self._serialize_skill(skill) if skill else None
async def get_skill_by_name(self, context: TenantContext, name: str) -> Optional[dict]:
@@ -142,22 +99,44 @@ class SkillService:
async def create_skill(self, context: TenantContext, data: dict) -> dict:
execution_context = await self._execution_context(context)
box_service = self._require_box('Creating a skill')
created = await box_service.create_skill(execution_context, data)
created = await self._repository().create_skill(execution_context, data)
await self._reload_skills(execution_context)
return self._serialize_skill(created)
async def import_skill_directory(
self,
context: TenantContext,
path: str,
data: dict,
*,
base_revision: str | None = None,
) -> dict:
execution_context = await self._execution_context(context)
created = await self._repository().import_skill_directory(
execution_context,
path,
data,
base_revision=base_revision,
)
await self._reload_skills(execution_context)
return self._serialize_skill(created)
async def update_skill(self, context: TenantContext, skill_name: str, data: dict) -> dict:
execution_context = await self._execution_context(context)
box_service = self._require_box('Editing a skill')
updated = await box_service.update_skill(execution_context, skill_name, data)
payload = dict(data)
base_revision = str(payload.pop('base_revision', '') or '').strip() or None
updated = await self._repository().update_skill(
execution_context,
skill_name,
payload,
base_revision=base_revision,
)
await self._reload_skills(execution_context)
return self._serialize_skill(updated)
async def delete_skill(self, context: TenantContext, skill_name: str) -> bool:
execution_context = await self._execution_context(context)
box_service = self._require_box('Deleting a skill')
await box_service.delete_skill(execution_context, skill_name)
await self._repository().delete_skill(execution_context, skill_name)
await self._reload_skills(execution_context)
return True
@@ -170,24 +149,41 @@ class SkillService:
max_entries: int = 200,
) -> dict:
execution_context = await self._execution_context(context)
box_service = self._require_box('Browsing skill files')
return await box_service.list_skill_files(execution_context, skill_name, path, include_hidden, max_entries)
return await self._repository().list_skill_files(
execution_context,
skill_name,
path,
include_hidden,
max_entries,
)
async def read_skill_file(self, context: TenantContext, skill_name: str, path: str) -> dict:
execution_context = await self._execution_context(context)
box_service = self._require_box('Reading a skill file')
return await box_service.read_skill_file(execution_context, skill_name, path)
return await self._repository().read_skill_file(execution_context, skill_name, path)
async def write_skill_file(self, context: TenantContext, skill_name: str, path: str, content: str) -> dict:
async def write_skill_file(
self,
context: TenantContext,
skill_name: str,
path: str,
content: str,
*,
base_revision: str | None,
) -> dict:
execution_context = await self._execution_context(context)
box_service = self._require_box('Editing skill files')
result = await box_service.write_skill_file(execution_context, skill_name, path, content)
result = await self._repository().write_skill_file(
execution_context,
skill_name,
path,
content,
base_revision=base_revision,
)
await self._reload_skills(execution_context)
return result
async def install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
execution_context = await self._execution_context(context)
box_service = self._require_box('Installing a skill from GitHub')
repository = self._repository()
owner = str(data['owner']).strip()
repo = str(data['repo']).strip()
release_tag = str(data.get('release_tag', '')).strip()
@@ -206,7 +202,7 @@ class SkillService:
zip_bytes = await self._download_github_asset(asset_url)
filename = f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip'
installed = await box_service.install_skill_zip(
installed = await repository.install_skill_zip(
execution_context,
zip_bytes,
filename,
@@ -219,7 +215,7 @@ class SkillService:
async def preview_install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
execution_context = await self._execution_context(context)
box_service = self._require_box('Previewing a skill from GitHub')
repository = self._repository()
owner = str(data['owner']).strip()
repo = str(data['repo']).strip()
release_tag = str(data.get('release_tag', '')).strip()
@@ -236,7 +232,7 @@ class SkillService:
source_subdir = str(data.get('source_subdir', '') or '').strip()
zip_bytes = await self._download_github_asset(asset_url)
return await box_service.preview_skill_zip(
return await repository.preview_skill_zip(
execution_context,
zip_bytes,
f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip',
@@ -253,8 +249,7 @@ class SkillService:
source_path: str = '',
) -> list[dict]:
execution_context = await self._execution_context(context)
box_service = self._require_box('Installing a skill from upload')
installed = await box_service.install_skill_zip(
installed = await self._repository().install_skill_zip(
execution_context,
file_bytes,
filename,
@@ -272,8 +267,7 @@ class SkillService:
filename: str,
) -> list[dict]:
execution_context = await self._execution_context(context)
box_service = self._require_box('Previewing a skill upload')
return await box_service.preview_skill_zip(execution_context, file_bytes, filename)
return await self._repository().preview_skill_zip(execution_context, file_bytes, filename)
async def _install_github_skill_md(
self,
@@ -284,14 +278,14 @@ class SkillService:
repo: str,
data: dict,
) -> list[dict]:
box_service = self._require_box('Installing a skill from GitHub')
repository = self._repository()
zip_bytes, filename, _package_name = await self._download_github_skill_directory_as_zip(
asset_url,
owner=owner,
repo=repo,
)
installed = await box_service.install_skill_zip(
installed = await repository.install_skill_zip(
context,
zip_bytes,
filename,
@@ -310,13 +304,13 @@ class SkillService:
owner: str,
repo: str,
) -> list[dict]:
box_service = self._require_box('Previewing a skill from GitHub')
repository = self._repository()
zip_bytes, _filename, package_name = await self._download_github_skill_directory_as_zip(
asset_url,
owner=owner,
repo=repo,
)
return await box_service.preview_skill_zip(context, zip_bytes, f'{package_name}.zip', target_suffix='')
return await repository.preview_skill_zip(context, zip_bytes, f'{package_name}.zip', target_suffix='')
async def reload_skills(self, context: TenantContext) -> list[dict]:
execution_context = await self._execution_context(context)
@@ -325,8 +319,7 @@ class SkillService:
async def scan_directory_async(self, context: TenantContext, path: str) -> dict:
execution_context = await self._execution_context(context)
box_service = self._require_box('Scanning a skill directory')
return await box_service.scan_skill_directory(execution_context, path)
return await self._repository().scan_skill_directory(execution_context, path)
async def _reload_skills(self, context: TenantContext) -> None:
skill_mgr = getattr(self.ap, 'skill_mgr', None)
+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
+10 -5
View File
@@ -24,6 +24,7 @@ class RunBoxBinding:
exported: dict = field(default_factory=dict)
submitted: set = field(default_factory=set)
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
read_only_mounts: list[dict] = field(default_factory=list)
def prepare_input_files(query, agent_input):
@@ -152,9 +153,10 @@ class RunnerBoxService:
if set(request.options) - {'image'}:
raise BoxValidationError('Only the Box image can be specified; resource limits are Host-owned')
spec = {'session_id': session_id, **request.options}
if query is not None:
spec['extra_mounts'] = self.box.build_skill_extra_mounts(query)
result = await self.box.create_session(context, spec)
from ..provider.tools.loaders.skill import build_execution_mounts
mounts = build_execution_mounts(self.box.ap, query) if query is not None else []
result = await self.box.create_session(context, spec, trusted_read_only_mounts=mounts)
return self.public_session(result)
async def bind(self, context, query, run_id, box_id):
@@ -174,8 +176,11 @@ class RunnerBoxService:
return self.binding_info(current)
fields = ('image', 'network', 'cpus', 'memory_mb', 'pids_limit', 'read_only_rootfs', 'persistent')
spec = {key: item[key] for key in fields if key in item}
spec['extra_mounts'] = self.box.build_skill_extra_mounts(query)
binding = RunBoxBinding(run_id, box_id, spec, run_id)
from ..provider.tools.loaders.skill import build_execution_mounts
binding = RunBoxBinding(
run_id, box_id, spec, run_id, read_only_mounts=build_execution_mounts(self.box.ap, query)
)
object.__setattr__(query, '_box_binding', binding)
return self.binding_info(binding)
+56 -220
View File
@@ -27,8 +27,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,
)
@@ -168,7 +170,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:
@@ -274,17 +276,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:
binding = await self.ap.workspace_service.get_execution_binding()
await reload_skills(
ExecutionContext(
instance_uuid=binding.instance_uuid,
workspace_uuid=binding.workspace_uuid,
placement_generation=binding.placement_generation,
)
)
self.ap.logger.info('Box runtime reconnected, sandbox features restored.')
return
except Exception as exc:
@@ -364,19 +355,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:
@@ -489,11 +478,18 @@ 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:
@@ -543,7 +539,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,
@@ -555,6 +551,22 @@ 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')
@@ -576,9 +588,13 @@ class BoxService:
query: pipeline_query.Query,
*,
skip_host_mount_validation: bool = False,
trusted_read_only_mounts: list[dict] | None = None,
) -> dict:
result = await self._execute_spec_payload_result(
spec_payload, query, skip_host_mount_validation=skip_host_mount_validation
spec_payload,
query,
skip_host_mount_validation=skip_host_mount_validation,
trusted_read_only_mounts=trusted_read_only_mounts,
)
return self._serialize_result(result)
@@ -588,6 +604,7 @@ class BoxService:
query: pipeline_query.Query,
*,
skip_host_mount_validation: bool = False,
trusted_read_only_mounts: list[dict] | None = None,
) -> BoxExecutionResult:
"""Execute within the bound Workspace and preserve the raw result for file transfers."""
if not self._available:
@@ -597,11 +614,17 @@ class BoxService:
from .runner import binding_for
binding = binding_for(query)
if trusted_read_only_mounts is None:
trusted_read_only_mounts = binding.read_only_mounts
if spec_payload.get('session_id') not in (None, '', binding.session_id):
raise BoxValidationError('session_id must match the bound Box')
spec_payload = {**binding.spec, **spec_payload, 'session_id': binding.session_id}
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)
@@ -656,70 +679,12 @@ class BoxService:
return binding_for(query).session_id
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': 'rw',
}
)
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.
@@ -727,8 +692,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'):
@@ -738,11 +701,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,
@@ -1281,8 +1244,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)
result = await self._execute_spec_payload_result(spec_payload, query)
if not result.ok:
if 'Sandbox output attachment exceeds the export size limit' in (result.stderr or ''):
@@ -1447,9 +1408,12 @@ class BoxService:
spec_payload: dict,
*,
skip_host_mount_validation: bool = False,
trusted_read_only_mounts: list[dict] | None = None,
) -> dict:
execution_context = await self._validated_execution_context(context)
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)
@@ -1553,126 +1517,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)
@@ -1809,14 +1653,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,
+6 -185
View File
@@ -1,43 +1,28 @@
"""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 +83,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,163 +95,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:
"""High-level handle for one reusable workspace-backed Box session.
+2
View File
@@ -47,6 +47,7 @@ from . import entities as core_entities
from ..rag.knowledge import kbmgr as rag_mgr
from ..rag.service import RAGRuntimeService
from ..vector import mgr as vectordb_mgr
from ..skill import repository as skill_repository
from ..telemetry import telemetry as telemetry_module
from ..survey import manager as survey_module
from ..skill import manager as skill_mgr
@@ -91,6 +92,7 @@ class Application:
# TODO move to pipeline
tool_mgr: llm_tool_mgr.ToolManager = None
box_service: box_service_module.BoxService = None
skill_repository: skill_repository.SkillRepository = None
# ======= Config manager =======
+2
View File
@@ -28,6 +28,7 @@ from ...api.http.service import knowledge as knowledge_service
from ...api.http.service import mcp as mcp_service
from ...api.http.service import apikey as apikey_service
from ...api.http.service import webhook as webhook_service
from ...skill import repository as skill_repository
from ...api.http.service import monitoring as monitoring_service
from ...operation_trace import service as operation_trace_service
from ...api.http.service import skill as skill_service
@@ -137,6 +138,7 @@ class BuildAppStage(stage.BootingStage):
webhook_service_inst = webhook_service.WebhookService(ap)
ap.webhook_service = webhook_service_inst
ap.skill_repository = skill_repository.SkillRepository(ap)
skill_service_inst = skill_service.SkillService(ap)
ap.skill_service = skill_service_inst
@@ -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
@@ -33,7 +33,7 @@ EDIT_TOOL_NAME = 'edit'
GLOB_TOOL_NAME = 'glob'
GREP_TOOL_NAME = 'grep'
_ALL_TOOL_NAMES = {EXEC_TOOL_NAME, READ_TOOL_NAME, WRITE_TOOL_NAME, EDIT_TOOL_NAME, GLOB_TOOL_NAME, GREP_TOOL_NAME}
SANDBOX_TOOL_NAMES = {EXEC_TOOL_NAME, READ_TOOL_NAME, WRITE_TOOL_NAME, EDIT_TOOL_NAME, GLOB_TOOL_NAME, GREP_TOOL_NAME}
# Skip these dirs during grep walk to avoid noise
_SKIP_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', '.tox', 'dist', 'build'}
@@ -261,7 +261,11 @@ class NativeToolLoader(loader.ToolLoader):
return list(self._tools)
async def has_tool(self, name: str) -> bool:
return name in _ALL_TOOL_NAMES and await self._is_sandbox_available()
return name in SANDBOX_TOOL_NAMES and await self._is_sandbox_available()
@staticmethod
def recognizes_tool(name: str) -> bool:
return name in SANDBOX_TOOL_NAMES
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query):
from ....box.runner import binding_for
@@ -309,7 +313,7 @@ class NativeToolLoader(loader.ToolLoader):
include_visible=False,
include_activated=True,
)
referenced_skill_names = skill_loader.find_referenced_skill_names(command)
referenced_skill_names = skill_loader.find_referenced_skill_names(command, query)
if selected_skill is None and referenced_skill_names:
if len(referenced_skill_names) > 1:
@@ -329,41 +333,35 @@ 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}'
# Dependency state stays writable and separate from the published package.
skill_mount = skill_loader.get_virtual_skill_mount_path(selected_skill_name, selected_skill['revision'])
legacy_mount = skill_loader.get_virtual_skill_mount_path(selected_skill_name)
command = regex.sub(regex.escape(legacy_mount) + r'(?![A-Za-z0-9_-])', skill_mount, command)
if workdir == legacy_mount or workdir.startswith(legacy_mount + '/'):
workdir = skill_mount + workdir[len(legacy_mount) :]
parameters = {**parameters, 'command': command, 'workdir': workdir}
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:
revision_key = str(selected_skill.get('revision', '') or '').removeprefix('sha256:')
if not revision_key:
raise ValueError(f'Activated skill "{selected_skill_name}" has no pinned revision.')
parameters = dict(parameters)
parameters['command'] = skill_loader.wrap_skill_command_with_python_env(
command,
mount_path=skill_mount,
state_path=f'/workspace/.skill-envs/{selected_skill_name}',
state_path=f'/workspace/.skill-envs/{selected_skill_name}/{revision_key}',
)
# 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.
# Package paths contain the pinned digest, so no shared alias changes underneath another run.
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(
@@ -389,10 +387,7 @@ class NativeToolLoader(loader.ToolLoader):
box_service = self.ap.box_service
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.'
)
raise ValueError('Secure Core host file operations are unavailable on this platform.')
host_root = selected_skill.get('package_root')
workspace_anchor = None
else:
@@ -430,11 +425,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:
@@ -1136,34 +1129,27 @@ else:
skill_request = self._resolve_skill_relative_path(
query,
path,
include_visible=True,
include_visible=False,
include_activated=True,
)
if skill_request is not None and hasattr(self.ap.box_service, 'read_skill_file'):
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 self.ap.box_service.read_skill_file(
result = await skill_repository.read_skill_resource(
self._execution_context(query),
selected_skill['name'],
relative,
expected_revision=selected_skill.get('revision'),
)
return self._build_read_result_from_text(str(result.get('content', '')), parameters)
except Exception:
try:
result = await self.ap.box_service.list_skill_files(
result = await skill_repository.list_skill_resources(
self._execution_context(query),
selected_skill['name'],
relative,
expected_revision=selected_skill.get('revision'),
)
entries = [entry['name'] for entry in result.get('entries', [])]
return self._build_directory_result(entries)
@@ -1173,7 +1159,7 @@ else:
host_location = self._resolve_host_location(
query,
path,
include_visible=True,
include_visible=False,
include_activated=True,
)
if host_location is None:
@@ -1187,21 +1173,22 @@ else:
path = parameters['path']
content = parameters['content']
self.ap.logger.info(f'write tool invoked: query_id={query.query_id} path={path} length={len(content)}')
encoding, _mode = self._write_options(parameters)
self._write_options(parameters)
skill_request = self._resolve_skill_relative_path(
query,
path,
include_visible=False,
include_activated=True,
)
if skill_request is not None and hasattr(self.ap.box_service, 'write_skill_file'):
if encoding != 'text':
return {'ok': False, 'error': 'base64 writes to skill packages are not supported.'}
selected_skill, relative = skill_request
execution_context = self._execution_context(query)
await self.ap.box_service.write_skill_file(execution_context, selected_skill['name'], relative, content)
await self.ap.skill_mgr.reload_skills(execution_context)
return {'ok': True, 'path': path}
if skill_request is not None:
return {
'ok': False,
'error': (
'Published Skill revisions are immutable. Copy the package to a writable '
'directory under /workspace/skill-drafts, edit it there, then call register_skill '
'with the activated revision as base_revision.'
),
}
host_location = self._resolve_host_location(
query,
@@ -1232,36 +1219,15 @@ else:
include_visible=False,
include_activated=True,
)
if (
skill_request is not None
and hasattr(self.ap.box_service, 'read_skill_file')
and hasattr(self.ap.box_service, 'write_skill_file')
):
selected_skill, relative = skill_request
try:
result = await self.ap.box_service.read_skill_file(
self._execution_context(query),
selected_skill['name'],
relative,
)
except Exception:
return {'ok': False, 'error': f'File not found: {path}'}
content = result.get('content', '')
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)
execution_context = self._execution_context(query)
await self.ap.box_service.write_skill_file(
execution_context,
selected_skill['name'],
relative,
new_content,
)
await self.ap.skill_mgr.reload_skills(execution_context)
return {'ok': True, 'path': path}
if skill_request is not None:
return {
'ok': False,
'error': (
'Published Skill revisions are immutable. Copy the package to a writable '
'directory under /workspace/skill-drafts, edit it there, then call register_skill '
'with the activated revision as base_revision.'
),
}
host_location = self._resolve_host_location(
query,
@@ -1561,7 +1527,7 @@ else:
host_location = self._resolve_host_location(
query,
path,
include_visible=True,
include_visible=False,
include_activated=True,
)
if host_location is None:
@@ -1583,7 +1549,7 @@ else:
host_location = self._resolve_host_location(
query,
path,
include_visible=True,
include_visible=False,
include_activated=True,
)
if host_location is None:
+92 -81
View File
@@ -1,10 +1,16 @@
from __future__ import annotations
import os
import re
import typing
from ....box import workspace as box_workspace
from langbot_plugin.box.models import ARTIFACT_MOUNT_ROOT
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
@@ -17,7 +23,9 @@ SKILL_MOUNT_PREFIX = '/workspace/.skills'
_SKILL_MOUNT_PATTERN = re.compile(r'/workspace/\.skills/([A-Za-z0-9_-]+)')
def get_virtual_skill_mount_path(skill_name: str) -> str:
def get_virtual_skill_mount_path(skill_name: str, revision: str | None = None) -> str:
if revision is not None:
return f'{ARTIFACT_MOUNT_ROOT}/{revision.removeprefix("sha256:")}'
return f'{SKILL_MOUNT_PREFIX}/{skill_name}'
@@ -70,6 +78,37 @@ def register_created_skill_visibility(query: pipeline_query.Query, skill_name: s
bound_skills.append(skill_name)
def build_execution_mounts(ap: app.Application, query: pipeline_query.Query) -> list[dict]:
"""Mount only immutable revisions pinned by this run's activations."""
mounts: list[dict] = []
for skill_name, skill_data in get_activated_skills(query).items():
package_root = str(skill_data.get('package_root', '') or '').strip()
manifest_path = str(skill_data.get('manifest_path', '') or '').strip()
revision = str(skill_data.get('revision', '') or '').strip()
if not package_root:
raise ValueError(f'Activated skill "{skill_name}" has no immutable package root.')
if not revision:
raise ValueError(f'Activated skill "{skill_name}" has no pinned revision.')
if not os.path.isdir(package_root):
raise ValueError(
f'Activated skill "{skill_name}" pinned revision {revision} is unavailable; '
'the run cannot be recovered safely.'
)
if not manifest_path or not os.path.isfile(manifest_path):
raise ValueError(f'Activated skill "{skill_name}" pinned revision {revision} has no publication manifest.')
mounts.append(
{
'host_path': package_root,
'mount_path': get_virtual_skill_mount_path(skill_name, revision),
'mode': 'ro',
'content_digest': revision,
'manifest_path': manifest_path,
}
)
return mounts
def get_activated_skills(query: pipeline_query.Query) -> dict[str, dict]:
if query.variables is None:
return {}
@@ -84,14 +123,18 @@ def get_activated_skill(query: pipeline_query.Query, skill_name: str) -> dict |
return get_activated_skills(query).get(skill_name)
def register_activated_skill(query: pipeline_query.Query, skill_data: dict) -> None:
def register_activated_skill(query: pipeline_query.Query, skill_data: dict) -> dict:
if query.variables is None:
query.variables = {}
activated = query.variables.setdefault(ACTIVATED_SKILLS_KEY, {})
skill_name = str(skill_data.get('name', '') or '').strip()
if skill_name and skill_name not in activated:
activated[skill_name] = skill_data
revision = str(skill_data.get('revision', '') or '').strip()
if not skill_name or not revision:
raise ValueError('Activated Skills require a name and immutable revision.')
if skill_name not in activated:
activated[skill_name] = dict(skill_data)
return activated[skill_name]
def normalize_skill_names(value: typing.Any) -> list[str]:
@@ -112,89 +155,37 @@ def get_activated_skill_names(query: pipeline_query.Query) -> list[str]:
return normalize_skill_names(list(get_activated_skills(query).keys()))
def restore_activated_skills(
ap: app.Application,
query: pipeline_query.Query,
skill_names: typing.Any,
) -> list[str]:
"""Restore caller-provided names from the current visible skill set."""
restored: list[str] = []
for skill_name in normalize_skill_names(skill_names):
skill_data = get_visible_skill(ap, query, skill_name)
if skill_data is None:
continue
register_activated_skill(query, skill_data)
restored.append(skill_name)
return restored
async def get_persisted_skill_revision(ap: app.Application, query: pipeline_query.Query, name: str) -> str | None:
"""Read a pin only for the currently authorized Host run."""
session = getattr(query, '_agent_run_session', None)
if not isinstance(session, dict):
return None
from ....agent.runner.run_ledger_store import RunLedgerStore, TERMINAL_STATUSES
def restore_activated_skills_from_state(
ap: app.Application,
query: pipeline_query.Query,
state: dict[str, dict[str, typing.Any]],
) -> list[str]:
"""Restore persisted activated skill names into Query variables.
The state value stores names only. Full skill metadata is rebuilt from the
current pipeline-visible skill cache so removed or unbound skills remain
unavailable to native exec/write/edit.
"""
conversation_state = state.get('conversation', {}) if isinstance(state, dict) else {}
skill_names = normalize_skill_names(conversation_state.get(ACTIVATED_SKILL_NAMES_STATE_KEY))
return restore_activated_skills(ap, query, skill_names)
context = getattr(query, '_execution_context', None)
workspace_id = str(getattr(context, 'workspace_uuid', None) or getattr(query, 'workspace_uuid', '') or '')
run = await RunLedgerStore(ap.persistence_mgr.get_db_engine()).get_run(str(session['run_id']))
if run is None or run['workspace_id'] != workspace_id or run['status'] in TERMINAL_STATUSES:
raise ValueError('Skill recovery requires an active run in the current Workspace')
return (run.get('metadata') or {}).get('activated_skills', {}).get(name)
async def persist_activated_skill(
ap: app.Application,
query: pipeline_query.Query,
skill_name: str,
) -> None:
"""Persist activated skill names into host-owned conversation state.
``activate`` runs host-side. This writes the run's current activated skill
names to the conversation-scope ``host.activated_skills`` snapshot so a later
run can restore them via ``restore_activated_skills_from_state``. Host writes
here and a runner ``state.updated`` to the same key follow last-write-wins.
Best-effort: a persistence failure must not fail the activation itself. No-op
when the call is not inside an authorized agent run, or when conversation
state is unavailable (state disabled / scope not enabled / no conversation).
"""
revision: str,
) -> str:
"""Persist one pin in the run ledger, never in Runner-writable conversation state."""
session = getattr(query, '_agent_run_session', None)
if not isinstance(session, dict):
return
return revision
from ....agent.runner.run_ledger_store import RunLedgerStore
authorization = session.get('authorization')
if not isinstance(authorization, dict):
return
state_context = authorization.get('state_context')
if not isinstance(state_context, dict):
return
scope_keys = state_context.get('scope_keys')
conversation_scope_key = scope_keys.get('conversation') if isinstance(scope_keys, dict) else None
if not conversation_scope_key:
return
try:
from ....agent.runner.persistent_state_store import get_persistent_state_store
store = get_persistent_state_store(ap.persistence_mgr.get_db_engine())
await store.state_set(
scope_key=conversation_scope_key,
state_key=ACTIVATED_SKILL_NAMES_STATE_KEY,
value=get_activated_skill_names(query),
runner_id=str(session.get('runner_id', '') or ''),
binding_identity=str(state_context.get('binding_identity', 'unknown') or 'unknown'),
scope='conversation',
context=state_context,
logger=getattr(ap, 'logger', None),
)
except Exception as e: # noqa: BLE001 - persistence is best-effort, must not break activation
logger = getattr(ap, 'logger', None)
if logger is not None:
logger.warning(f'Failed to persist activated skill "{skill_name}": {e}')
context = getattr(query, '_execution_context', None)
workspace_id = str(getattr(context, 'workspace_uuid', None) or getattr(query, 'workspace_uuid', '') or '')
store = RunLedgerStore(ap.persistence_mgr.get_db_engine())
return await store.pin_skill_revision(str(session['run_id']), workspace_id, skill_name, revision)
def parse_skill_mount_path(sandbox_path: str) -> tuple[str | None, str]:
@@ -224,6 +215,12 @@ def resolve_virtual_skill_path(
include_visible: bool,
include_activated: bool,
) -> tuple[dict | None, str]:
if sandbox_path == ARTIFACT_MOUNT_ROOT or sandbox_path.startswith(ARTIFACT_MOUNT_ROOT + '/'):
for skill in get_activated_skills(query).values():
root = get_virtual_skill_mount_path(skill['name'], skill['revision'])
if sandbox_path == root or sandbox_path.startswith(root + '/'):
return skill, '/workspace' + sandbox_path[len(root) :]
raise ValueError('Artifact path does not belong to a Skill activated by this run')
skill_name, rewritten_path = parse_skill_mount_path(sandbox_path)
if skill_name is None:
return None, rewritten_path
@@ -246,7 +243,7 @@ def resolve_virtual_skill_path(
)
def find_referenced_skill_names(text: str) -> list[str]:
def find_referenced_skill_names(text: str, query=None) -> list[str]:
if not text:
return []
@@ -254,6 +251,20 @@ def find_referenced_skill_names(text: str) -> list[str]:
for match in _SKILL_MOUNT_PATTERN.findall(text):
if match not in seen:
seen.append(match)
if query is not None:
for path in re.findall(re.escape(ARTIFACT_MOUNT_ROOT) + r'/[a-f0-9]{64}', text):
selected = next(
(
name
for name, skill in get_activated_skills(query).items()
if get_virtual_skill_mount_path(name, skill['revision']) == path
),
None,
)
if selected is None:
raise ValueError('Artifact command references a revision not activated by this run')
if selected not in seen:
seen.append(selected)
return seen
@@ -275,7 +286,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(
@@ -284,7 +295,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,
@@ -6,7 +6,6 @@ import typing
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from .. import loader
from .availability import is_box_backend_available
from ....api.http.context import ExecutionContext
# Align with Claude Code's Skill tool design:
@@ -15,12 +14,23 @@ from ....api.http.context import ExecutionContext
# - This protects KV Cache and follows industry standard
ACTIVATE_SKILL_TOOL_NAME = 'activate'
LIST_SKILL_RESOURCES_TOOL_NAME = 'list_skill_resources'
READ_SKILL_RESOURCE_TOOL_NAME = 'read_skill_resource'
REGISTER_SKILL_TOOL_NAME = 'register_skill'
SKILL_TOOL_NAMES = {
READ_ONLY_SKILL_TOOL_NAMES = {
ACTIVATE_SKILL_TOOL_NAME,
LIST_SKILL_RESOURCES_TOOL_NAME,
READ_SKILL_RESOURCE_TOOL_NAME,
}
SANDBOX_SKILL_TOOL_NAMES = {
REGISTER_SKILL_TOOL_NAME,
}
SKILL_TOOL_NAMES = READ_ONLY_SKILL_TOOL_NAMES | SANDBOX_SKILL_TOOL_NAMES
_SKILL_EXECUTION_AVAILABLE_KEY = '_skill_execution_available'
_SKILL_RESOURCE_BYTES_READ_KEY = '_skill_resource_bytes_read'
_MAX_SKILL_RESOURCE_FILE_BYTES = 256 * 1024
_MAX_SKILL_RESOURCE_RUN_BYTES = 1024 * 1024
class SkillToolLoader(loader.ToolLoader):
@@ -28,77 +38,77 @@ class SkillToolLoader(loader.ToolLoader):
def __init__(self, ap):
super().__init__(ap)
self._tools: list[resource_tool.LLMTool] = []
self._sandbox_available: bool = False
self._read_only_tools: list[resource_tool.LLMTool] = []
self._sandbox_tools: list[resource_tool.LLMTool] = []
async def initialize(self):
# Check if sandbox backend is available (same check as native tools)
self._sandbox_available = await self._check_sandbox_available()
if self._sandbox_available:
self._tools = self._build_skill_tools()
if self._is_available():
self._read_only_tools = [
self._build_activate_skill_tool(),
self._build_list_skill_resources_tool(),
self._build_read_skill_resource_tool(),
]
self._sandbox_tools = [self._build_register_skill_tool()]
else:
self.ap.logger.info(
'Skill tools (activate/register_skill) are NOT available. '
'No sandbox backend (Docker/nsjail/E2B) is ready. '
'Trusted local development may explicitly select box.backend=host.'
)
self.ap.logger.info('Skill tools are unavailable because the Core SkillRepository is not initialized.')
async def _check_sandbox_available(self) -> bool:
"""Check if the box backend is truly available (not just the runtime)."""
return await is_box_backend_available(self.ap)
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
if not await self._is_available():
async def get_tools(
self,
bound_plugins: list[str] | None = None,
*,
sandbox_available: bool | None = None,
) -> list[resource_tool.LLMTool]:
if not self._is_available():
return []
if not self._tools:
self._tools = self._build_skill_tools()
return list(self._tools)
def _build_skill_tools(self) -> list[resource_tool.LLMTool]:
tools = [self._build_activate_skill_tool()]
if self._skill_registration_available():
tools.append(self._build_register_skill_tool())
else:
self.ap.logger.info(
'register_skill is NOT available: Cloud sandboxes never scan arbitrary '
'host skill directories. Install skills through the skill store instead.'
)
if not self._read_only_tools:
await self.initialize()
tools = list(self._read_only_tools)
if sandbox_available and self._skill_registration_available():
tools.extend(self._sandbox_tools)
return tools
async def get_tool(self, name: str, *, sandbox_available: bool | None = None):
for tool in await self.get_tools(sandbox_available=sandbox_available):
if tool.name == name:
return tool
return None
def _skill_registration_available(self) -> bool:
"""Host skill scanning is disabled for Cloud-managed sandboxes."""
return not bool(getattr(getattr(self.ap, 'box_service', None), 'managed_admission_required', False))
box_service = getattr(self.ap, 'box_service', None)
return not bool(getattr(box_service, 'managed_admission_required', False))
async def has_tool(self, name: str) -> bool:
if not await self._is_available() or name not in SKILL_TOOL_NAMES:
async def has_tool(self, name: str, *, sandbox_available: bool | None = None) -> bool:
if not self._is_available() or name not in SKILL_TOOL_NAMES:
return False
if name == REGISTER_SKILL_TOOL_NAME:
return self._skill_registration_available()
return True
return name in READ_ONLY_SKILL_TOOL_NAMES or (bool(sandbox_available) and self._skill_registration_available())
async def _is_available(self) -> bool:
"""Check if skill tools should be available.
@staticmethod
def is_sandbox_tool(name: str) -> bool:
return name in SANDBOX_SKILL_TOOL_NAMES
Skill tools require both a skill manager and a sandbox backend.
"""
if not self._has_skill_manager():
return False
self._sandbox_available = await self._check_sandbox_available()
return self._sandbox_available
@staticmethod
def recognizes_tool(name: str) -> bool:
return name in SKILL_TOOL_NAMES
def _is_available(self) -> bool:
return self._has_skill_manager() and getattr(self.ap, 'skill_repository', None) is not None
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 == LIST_SKILL_RESOURCES_TOOL_NAME:
return await self._invoke_list_skill_resources(parameters, query)
if name == READ_SKILL_RESOURCE_TOOL_NAME:
return await self._invoke_read_skill_resource(parameters, query)
if name == REGISTER_SKILL_TOOL_NAME:
require_sandbox = getattr(
getattr(self.ap, 'box_service', None),
'require_workspace_sandbox',
None,
)
if not callable(require_sandbox):
return self._sandbox_unavailable_result(name)
await require_sandbox(self._execution_context(query))
return await self._invoke_register_skill(parameters, query)
raise ValueError(f'Unknown skill tool: {name}')
@@ -123,6 +133,27 @@ class SkillToolLoader(loader.ToolLoader):
def _has_skill_manager(self) -> bool:
return getattr(self.ap, 'skill_mgr', None) is not None
@staticmethod
def _sandbox_unavailable_result(name: str) -> dict:
return {
'ok': False,
'code': 'sandbox_unavailable',
'tool': name,
'message': 'This operation requires Box execution, but Box is not configured or available.',
}
async def _execution_available(self, query) -> bool:
variables = getattr(query, 'variables', None)
if isinstance(variables, dict) and _SKILL_EXECUTION_AVAILABLE_KEY in variables:
return bool(variables[_SKILL_EXECUTION_AVAILABLE_KEY])
checker = getattr(getattr(self.ap, 'box_service', None), 'is_workspace_sandbox_available', None)
if not callable(checker):
return False
try:
return bool(await checker(self._execution_context(query)))
except Exception:
return False
async def _invoke_activate_skill(self, parameters: dict, query) -> typing.Any:
"""Activate a skill and return SKILL.md content via Tool Result."""
skill_name = str(parameters.get('skill_name', '') or '').strip()
@@ -131,43 +162,117 @@ class SkillToolLoader(loader.ToolLoader):
from . import skill as skill_loader
skill_data = skill_loader.get_visible_skill(self.ap, query, skill_name)
bound_names = skill_loader.get_bound_skill_names(query)
if bound_names is not None and skill_name not in bound_names:
raise ValueError(f'Skill "{skill_name}" is not authorized for this run')
skill_data = skill_loader.get_activated_skill(query, skill_name)
if skill_data is None:
visible_skills = skill_loader.get_visible_skills(self.ap, query)
available_names = ', '.join(sorted(visible_skills.keys())) or 'none'
raise ValueError(f'Skill "{skill_name}" not found. Available skills: {available_names}')
pinned_revision = await skill_loader.get_persisted_skill_revision(self.ap, query, skill_name)
if pinned_revision is None and skill_loader.get_visible_skill(self.ap, query, skill_name) is None:
raise ValueError(f'Skill "{skill_name}" not found in the authorized catalog')
skill_data = await self.ap.skill_repository.get_skill(
self._execution_context(query), skill_name, snapshot=True, revision=pinned_revision
)
if skill_data is None:
raise ValueError(f'Skill "{skill_name}" revision is unavailable')
# Register activated skill for sandbox mount path resolution
skill_loader.register_activated_skill(query, skill_data)
await skill_loader.persist_activated_skill(self.ap, query, skill_name)
# Persist before exposing the activation; a failed write must not produce an unrecorded pin.
pinned_revision = await skill_loader.persist_activated_skill(self.ap, query, skill_name, skill_data['revision'])
if pinned_revision != skill_data['revision']:
skill_data = await self.ap.skill_repository.get_skill(
self._execution_context(query), skill_name, snapshot=True, revision=pinned_revision
)
if skill_data is None:
raise ValueError('The run-pinned Skill revision is unavailable')
skill_data = skill_loader.register_activated_skill(query, skill_data)
# Return SKILL.md content as Tool Result (injects into context)
instructions = skill_data.get('instructions', '')
package_root = skill_data.get('package_root', '')
mount_path = skill_loader.get_virtual_skill_mount_path(skill_name)
revision = str(skill_data.get('revision', '') or '')
execution_available = await self._execution_available(query)
mount_path = skill_loader.get_virtual_skill_mount_path(skill_name, revision) if execution_available else None
# Build Tool Result content
result_content = f'<command-message>The "{skill_name}" skill is activated</command-message>\n'
result_content += '<skill-activation>\n'
result_content += f'<skill-name>{skill_name}</skill-name>\n'
result_content += f'<mount-path>{mount_path}</mount-path>\n'
result_content += f'<package-root>{package_root}</package-root>\n'
result_content += f'<revision>{revision}</revision>\n'
result_content += '<resources-readable>true</resources-readable>\n'
result_content += f'<execution-available>{str(execution_available).lower()}</execution-available>\n'
result_content += f'\n## Instructions\n{instructions}\n'
result_content += '\n## Runtime Context\n'
result_content += f'The skill package is mounted at {mount_path}. Use the standard tools to interact with it:\n'
result_content += f'- Use `read` to inspect files under {mount_path}\n'
result_content += f'- Use `exec` with workdir set to {mount_path} to run commands in that package\n'
result_content += '- Use `write` and `edit` on that path when the instructions require updating files\n'
result_content += '- Use `list_skill_resources` and `read_skill_resource` for read-only package resources.\n'
if execution_available:
result_content += f'- Package files will be prepared read-only at {mount_path} before execution.\n'
else:
result_content += (
'- Box execution is unavailable. Do not attempt to run scripts or modify Workspace files.\n'
)
result_content += '</skill-activation>\n'
return {
'activated': True,
'skill_name': skill_name,
'mount_path': mount_path,
'revision': revision,
'capabilities': {
'instructions_readable': True,
'resources_readable': True,
'execution_available': execution_available,
},
'activated_skill_names': skill_loader.get_activated_skill_names(query),
'content': result_content,
}
@staticmethod
def _activated_skill(parameters: dict, query) -> dict:
from . import skill as skill_loader
skill_name = str(parameters.get('skill_name', '') or '').strip()
if not skill_name:
raise ValueError('skill_name is required')
skill_data = skill_loader.get_activated_skill(query, skill_name)
if skill_data is None:
raise ValueError(f'Skill "{skill_name}" must be activated before its resources can be read.')
requested_revision = str(parameters.get('revision', '') or '').strip()
activated_revision = str(skill_data.get('revision', '') or '').strip()
if requested_revision and requested_revision != activated_revision:
raise ValueError('revision must match the activated skill revision')
return skill_data
async def _invoke_list_skill_resources(self, parameters: dict, query) -> dict:
skill_data = self._activated_skill(parameters, query)
return await self.ap.skill_repository.list_skill_resources(
self._execution_context(query),
skill_data['name'],
str(parameters.get('path', '.') or '.'),
expected_revision=skill_data.get('revision'),
)
async def _invoke_read_skill_resource(self, parameters: dict, query) -> dict:
skill_data = self._activated_skill(parameters, query)
path = str(parameters.get('path', '') or '').strip()
if not path:
raise ValueError('path is required')
result = await self.ap.skill_repository.read_skill_resource(
self._execution_context(query),
skill_data['name'],
path,
expected_revision=skill_data.get('revision'),
)
content = str(result.get('content', ''))
size = len(content.encode('utf-8'))
if size > _MAX_SKILL_RESOURCE_FILE_BYTES:
raise ValueError('Skill resource exceeds the per-file read limit')
variables = getattr(query, 'variables', None)
if not isinstance(variables, dict):
variables = {}
query.variables = variables
total = int(variables.get(_SKILL_RESOURCE_BYTES_READ_KEY, 0) or 0) + size
if total > _MAX_SKILL_RESOURCE_RUN_BYTES:
raise ValueError('Skill resource reads exceed the per-run limit')
variables[_SKILL_RESOURCE_BYTES_READ_KEY] = total
result['size'] = size
return result
async def _invoke_register_skill(self, parameters: dict, query) -> typing.Any:
"""Register a skill from sandbox directory to data/skills/."""
from . import skill as skill_loader
@@ -193,16 +298,17 @@ class SkillToolLoader(loader.ToolLoader):
if not skill_name:
raise ValueError('skill name is required')
# Create the skill
created = await skill_service.create_skill(
base_revision = str(parameters.get('base_revision', '') or '').strip() or None
published = await skill_service.import_skill_directory(
execution_context,
host_path,
{
'name': skill_name,
'display_name': str(parameters.get('display_name') or scanned.get('display_name', '')).strip(),
'description': str(parameters.get('description') or scanned.get('description', '')).strip(),
'instructions': str(parameters.get('instructions') or scanned.get('instructions', '')),
'package_root': host_path,
},
base_revision=base_revision,
)
skill_loader.register_created_skill_visibility(query, skill_name)
@@ -210,7 +316,8 @@ class SkillToolLoader(loader.ToolLoader):
'registered': True,
'skill_name': skill_name,
'source_path': sandbox_path,
'skill': created,
'revision': published.get('revision'),
'skill': published,
}
def _resolve_workspace_directory(
@@ -268,14 +375,51 @@ class SkillToolLoader(loader.ToolLoader):
func=lambda parameters: parameters,
)
def _build_list_skill_resources_tool(self) -> resource_tool.LLMTool:
return resource_tool.LLMTool(
name=LIST_SKILL_RESOURCES_TOOL_NAME,
human_desc='List activated skill resources',
description='List read-only files in an activated skill package without starting a sandbox.',
parameters={
'type': 'object',
'properties': {
'skill_name': {'type': 'string', 'description': 'The activated skill name.'},
'path': {'type': 'string', 'description': 'Relative directory path. Defaults to the package root.'},
'revision': {'type': 'string', 'description': 'Optional revision returned by activate.'},
},
'required': ['skill_name'],
'additionalProperties': False,
},
func=lambda parameters: parameters,
)
def _build_read_skill_resource_tool(self) -> resource_tool.LLMTool:
return resource_tool.LLMTool(
name=READ_SKILL_RESOURCE_TOOL_NAME,
human_desc='Read an activated skill resource',
description='Read a UTF-8 text resource from an activated skill package without starting a sandbox.',
parameters={
'type': 'object',
'properties': {
'skill_name': {'type': 'string', 'description': 'The activated skill name.'},
'path': {'type': 'string', 'description': 'File path relative to the skill package root.'},
'revision': {'type': 'string', 'description': 'Optional revision returned by activate.'},
},
'required': ['skill_name', 'path'],
'additionalProperties': False,
},
func=lambda parameters: parameters,
)
def _build_register_skill_tool(self) -> resource_tool.LLMTool:
return resource_tool.LLMTool(
name=REGISTER_SKILL_TOOL_NAME,
human_desc='Register a skill from sandbox',
description=(
"Register a skill package from a directory under /workspace into LangBot's skill store. "
'Use this after creating or preparing a skill in the sandbox with exec/read/write/edit. '
"Publish a skill draft from a directory under /workspace into LangBot's skill store. "
'Use this after creating or preparing the draft with exec/read/write/edit. '
'The directory must contain a SKILL.md file. '
'Updating an existing Skill requires its current base_revision; conflicting updates are rejected. '
'After registration, the skill can be activated with the activate tool.'
),
parameters={
@@ -301,6 +445,10 @@ class SkillToolLoader(loader.ToolLoader):
'type': 'string',
'description': 'Optional instructions override.',
},
'base_revision': {
'type': 'string',
'description': 'Required current revision when publishing an update; omit for a new Skill.',
},
},
'required': ['path'],
'additionalProperties': False,
+38 -12
View File
@@ -77,6 +77,15 @@ class ToolManager:
except Exception:
return False
@staticmethod
def _sandbox_unavailable_result(name: str) -> dict[str, typing.Any]:
return {
'ok': False,
'code': 'sandbox_unavailable',
'tool': name,
'message': 'This operation requires Box execution, but Box is not configured or available.',
}
async def initialize(self):
from langbot.pkg.utils import importutil
from langbot.pkg.provider.tools import loaders
@@ -113,8 +122,8 @@ class ToolManager:
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())
if include_skill_authoring:
all_functions.extend(await self.skill_tool_loader.get_tools(sandbox_available=sandbox_available))
all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))
all_functions.extend(
await self.mcp_tool_loader.get_tools(
@@ -153,8 +162,12 @@ class ToolManager:
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())
if include_skill_authoring:
append_tools(
'skill',
'LangBot',
await self.skill_tool_loader.get_tools(sandbox_available=sandbox_available),
)
catalog.extend(await self.plugin_tool_loader.get_tool_catalog(bound_plugins))
if self.mcp_tool_loader:
@@ -296,10 +309,9 @@ class ToolManager:
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
tool = await self.skill_tool_loader.get_tool(name, sandbox_available=sandbox_available)
if tool:
return tool
return await self.mcp_tool_loader.get_tool(context, name)
@@ -363,7 +375,8 @@ class ToolManager:
if source in {'builtin', 'native'}:
return await self.native_tool_loader.get_tool(name)
if source == 'skill':
return await self.skill_tool_loader.get_tool(name)
sandbox_available = await self._workspace_sandbox_available(context)
return await self.skill_tool_loader.get_tool(name, sandbox_available=sandbox_available)
if source == 'plugin':
if not source_id:
return None
@@ -569,11 +582,11 @@ class ToolManager:
telemetry_source = 'native'
exists = await loader.has_tool(name)
elif source == 'skill':
if not sandbox_available:
if not sandbox_available and self.skill_tool_loader.is_sandbox_tool(name):
raise ToolNotFoundError(name)
loader = self.skill_tool_loader
telemetry_source = 'skill'
exists = await loader.has_tool(name)
exists = await loader.has_tool(name, sandbox_available=sandbox_available)
elif source == 'plugin' and source_id:
loader = self.plugin_tool_loader
telemetry_source = 'plugin'
@@ -594,6 +607,9 @@ class ToolManager:
if not exists:
raise ToolNotFoundError(name)
if source == 'skill' and isinstance(getattr(query, 'variables', None), dict):
query.variables['_skill_execution_available'] = sandbox_available
async def invoke_selected_tool() -> typing.Any:
if source == 'mcp':
return await loader.invoke_tool(
@@ -641,7 +657,10 @@ class ToolManager:
query=query,
invoke=lambda: self.mcp_tool_loader.invoke_tool(name, parameters, query),
)
if sandbox_available and await self.skill_tool_loader.has_tool(name):
if await self.skill_tool_loader.has_tool(name, sandbox_available=sandbox_available):
variables = getattr(query, 'variables', None)
if isinstance(variables, dict):
variables['_skill_execution_available'] = sandbox_available
return await self._invoke_tool_with_monitoring(
source='skill',
name=name,
@@ -649,6 +668,13 @@ class ToolManager:
query=query,
invoke=lambda: self.skill_tool_loader.invoke_tool(name, parameters, query),
)
recognizes_native = getattr(self.native_tool_loader, 'recognizes_tool', None)
is_sandbox_skill_tool = getattr(self.skill_tool_loader, 'is_sandbox_tool', None)
if not sandbox_available and (
(callable(recognizes_native) and recognizes_native(name) is True)
or (callable(is_sandbox_skill_tool) and is_sandbox_skill_tool(name) is True)
):
return self._sandbox_unavailable_result(name)
raise ToolNotFoundError(name)
async def shutdown(self):
+3 -1
View File
@@ -1,3 +1,5 @@
from .manager import SkillManager
from .repository import SkillRepository
__all__ = ['SkillManager']
__all__ = ['SkillManager', 'SkillRepository']
-44
View File
@@ -1,44 +0,0 @@
from __future__ import annotations
import typing
from ..provider.tools.loaders import skill as skill_loader
from ..api.http.context import ExecutionContext
if typing.TYPE_CHECKING:
from ..core import app
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
# Skill activation is now handled through Tool Call mechanism (activate tool).
# This file is kept for potential future extensions but the text marker
# detection mechanism has been removed.
def register_activated_skill(
ap: app.Application,
query: pipeline_query.Query,
skill_name: str,
) -> bool:
"""Register an activated skill for sandbox mount path resolution.
This is called by the activate tool when a skill is activated via Tool Call.
"""
skill_mgr = getattr(ap, 'skill_mgr', None)
if skill_mgr is None:
return False
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),
)
skill_data = skill_mgr.get_skill_by_name(execution_context, skill_name)
if skill_data is None:
return False
skill_loader.register_activated_skill(query, skill_data)
return True
+7 -23
View File
@@ -1,14 +1,12 @@
from __future__ import annotations
import os
from ..api.http.context import ExecutionContext
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
from ..core import app
class SkillManager:
"""Workspace-scoped in-memory view of Box-managed skill packages."""
"""Workspace-scoped in-memory view of Core-managed skill packages."""
ap: app.Application
@@ -61,37 +59,23 @@ class SkillManager:
for existing_key in tuple(self._skills_by_scope):
if existing_key[:2] == key[:2] and existing_key != key:
self._skills_by_scope.pop(existing_key, None)
self._skills_by_scope[key] = {}
box_service = getattr(self.ap, 'box_service', None)
if box_service is None or not getattr(box_service, 'available', False):
self.ap.logger.info(
f'Box runtime unavailable; skill cache is empty for Workspace {execution_context.workspace_uuid}.'
)
return
repository = getattr(self.ap, 'skill_repository', None)
if repository is None:
raise RuntimeError('Skill repository unavailable')
validate_locally = bool(getattr(box_service, 'shares_filesystem_with_box', False))
try:
dropped = 0
skills: dict[str, dict] = {}
for skill_data in await box_service.list_skills(execution_context):
for skill_data in await repository.list_skills(execution_context):
skill_name = skill_data.get('name')
if not skill_name:
continue
package_root = str(skill_data.get('package_root', '') or '').strip()
if validate_locally and package_root and not os.path.isdir(package_root):
self.ap.logger.warning(
f'Skill "{skill_name}" reported by Box runtime but package_root '
f'missing on LangBot filesystem ({package_root}); dropping from cache.'
)
dropped += 1
continue
skills[skill_name] = skill_data
self._skills_by_scope[key] = skills
suffix = f' ({dropped} dropped due to missing package_root)' if dropped else ''
self.ap.logger.info(f'Loaded {len(skills)} skills for Workspace {execution_context.workspace_uuid}{suffix}')
self.ap.logger.info(f'Loaded {len(skills)} skills for Workspace {execution_context.workspace_uuid}')
except Exception as exc:
self.ap.logger.warning(f'Failed to load skills for Workspace {execution_context.workspace_uuid}: {exc}')
raise
async def ensure_loaded(self, context: TenantContext) -> None:
key = self._scope_key(context)
+262
View File
@@ -0,0 +1,262 @@
from __future__ import annotations
import asyncio
import os
import weakref
from langbot_plugin.skill_store import (
SkillRevisionConflictError,
SkillRevisionMismatchError,
SkillRevisionNotFoundError,
SkillStore,
skill_namespace,
)
from ..api.http.context import ExecutionContext
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
from ..utils.bounded_executor import blocking_work_scope, run_blocking_atomic
class SkillRepository:
"""Async, Workspace-scoped adapter around the SDK SkillStore."""
def __init__(self, ap) -> None:
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._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
def _workspace_lock(self, namespace: str) -> asyncio.Lock:
lock = self._locks.get(namespace)
if lock is None:
lock = asyncio.Lock()
self._locks[namespace] = lock
return lock
def _host_root(self) -> str:
configured = str(self._local_config.get('host_root') or './data/box').strip()
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
def _skills_root(self) -> str:
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:
configured = str(self._local_config.get('default_workspace') or '').strip()
if not configured:
configured = os.path.join(self._host_root(), 'default')
elif not os.path.isabs(configured):
configured = os.path.join(self._host_root(), configured)
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
@staticmethod
def _execution_context(context: TenantContext) -> ExecutionContext:
workspace_uuid = require_workspace_uuid(context)
instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
generation = getattr(context, 'placement_generation', None)
if not instance_uuid:
raise ValueError('Skill operations require an explicit instance UUID')
if isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
raise ValueError('Skill operations require a positive placement generation')
return ExecutionContext(
instance_uuid=instance_uuid,
workspace_uuid=workspace_uuid,
placement_generation=generation,
bot_uuid=getattr(context, 'bot_uuid', None),
pipeline_uuid=getattr(context, 'pipeline_uuid', None),
query_uuid=getattr(context, 'query_uuid', None),
entitlement_revision=getattr(context, 'entitlement_revision', 0),
)
@classmethod
def _namespace(cls, context: TenantContext) -> str:
execution_context = cls._execution_context(context)
return skill_namespace(
execution_context.instance_uuid,
execution_context.workspace_uuid,
)
async def _validated_execution_context(self, context: TenantContext) -> ExecutionContext:
execution_context = self._execution_context(context)
binding = await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
if (
binding.instance_uuid != execution_context.instance_uuid
or str(getattr(binding, 'workspace_uuid', '') or '') != execution_context.workspace_uuid
or getattr(binding, 'placement_generation', None) != execution_context.placement_generation
):
raise ValueError('Skill execution context belongs to a stale Workspace placement')
return execution_context
def _workspace_root(self, namespace: str) -> str:
return os.path.join(self._default_workspace(), 'tenants', namespace)
def storage_root(self, context: TenantContext) -> str:
return self._store.scoped(self._namespace(context)).root
async def _call(self, context: TenantContext, method_name: str, *args, **kwargs):
execution_context = await self._validated_execution_context(context)
namespace = self._namespace(execution_context)
def invoke():
method = getattr(self._store.scoped(namespace), method_name)
return method(*args, **kwargs)
async with self._workspace_lock(namespace):
with blocking_work_scope(f'skill:{namespace}'):
return await run_blocking_atomic(invoke)
async def list_skills(self, context: TenantContext) -> list[dict]:
return await self._call(context, 'list_skills')
async def get_skill(
self,
context: TenantContext,
name: str,
*,
snapshot: bool = False,
revision: str | None = None,
) -> dict | None:
if revision is not None:
return await self._call(context, 'get_skill_snapshot', name, revision)
return await self._call(context, 'get_skill_snapshot' if snapshot else 'get_skill', name)
async def create_skill(self, context: TenantContext, skill: dict) -> dict:
return await self._call(context, 'create_skill', skill)
async def import_skill_directory(
self,
context: TenantContext,
path: str,
skill: dict,
*,
base_revision: str | None = None,
) -> dict:
namespace = self._namespace(context)
return await self._call(
context,
'import_skill_directory',
path,
skill,
source_root=self._workspace_root(namespace),
base_revision=base_revision,
)
async def update_skill(
self,
context: TenantContext,
name: str,
skill: dict,
*,
base_revision: str | None,
) -> dict:
return await self._call(
context,
'update_skill',
name,
skill,
base_revision=base_revision,
)
async def delete_skill(self, context: TenantContext, name: str) -> None:
await self._call(context, 'delete_skill', name)
async def scan_skill_directory(self, context: TenantContext, path: str) -> dict:
namespace = self._namespace(context)
return await self._call(
context,
'scan_import_directory',
path,
source_root=self._workspace_root(namespace),
)
async def list_skill_files(
self,
context: TenantContext,
name: str,
path: str = '.',
include_hidden: bool = False,
max_entries: int = 200,
) -> dict:
return await self._call(context, 'list_skill_files', name, path, include_hidden, max_entries)
async def read_skill_file(self, context: TenantContext, name: str, path: str) -> dict:
return await self._call(context, 'read_skill_file', name, path)
async def list_skill_resources(
self,
context: TenantContext,
name: str,
path: str = '.',
*,
expected_revision: str | None = None,
) -> dict:
return await self._call(
context,
'list_skill_resources',
name,
path,
False,
200,
expected_revision=expected_revision,
)
async def read_skill_resource(
self,
context: TenantContext,
name: str,
path: str,
*,
expected_revision: str | None = None,
) -> dict:
return await self._call(
context,
'read_skill_resource',
name,
path,
expected_revision=expected_revision,
)
async def write_skill_file(
self,
context: TenantContext,
name: str,
path: str,
content: str,
*,
base_revision: str | None,
) -> dict:
return await self._call(
context,
'write_skill_file',
name,
path,
content,
base_revision=base_revision,
)
async def preview_skill_zip(self, context: TenantContext, file_bytes: bytes, filename: str, **kwargs) -> list[dict]:
return await self._call(context, 'preview_zip_upload', file_bytes=file_bytes, filename=filename, **kwargs)
async def install_skill_zip(self, context: TenantContext, file_bytes: bytes, filename: str, **kwargs) -> list[dict]:
return await self._call(context, 'install_zip_upload', file_bytes=file_bytes, filename=filename, **kwargs)
__all__ = [
'SkillRepository',
'SkillRevisionConflictError',
'SkillRevisionMismatchError',
'SkillRevisionNotFoundError',
]
+1 -1
View File
@@ -269,7 +269,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:
+191
View File
@@ -0,0 +1,191 @@
"""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',
]
+11 -5
View File
@@ -357,13 +357,20 @@ 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
# subprocess. Disabling Box also disables every feature that depends on it:
# the native sandbox tools (exec/read/write/edit/glob/grep), the activate
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
# be listed read-only and http/sse MCP servers continue to work.
# subprocess. Disabling Box disables execution-backed features: native
# sandbox tools (exec/read/write/edit/glob/grep), agent-side skill
# registration, and stdio-mode MCP servers. Skill management, activation,
# and read-only package resources remain available without Box.
enabled: true
# 'host' runs commands directly as the Box Runtime user without sandbox
# isolation. It is never auto-selected and is only for trusted local
@@ -412,7 +419,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' # Box-owned skill package directory. Relative paths are resolved under host_root.
allowed_mount_roots: # Defaults to ['<host_root>'] when left empty.
- './data/box'
- '/tmp'
@@ -1,4 +1,4 @@
"""Skills API behavior when a workspace plan has no managed sandbox."""
"""Skills API behavior is independent from managed sandbox entitlement."""
from __future__ import annotations
@@ -9,10 +9,7 @@ import pytest
import quart
from langbot.pkg.api.http.controller.groups.skills import SkillsRouterGroup
from langbot.pkg.cloud.entitlements import (
EntitlementFeatureUnavailableError,
EntitlementUnavailableError,
)
from langbot.pkg.cloud.entitlements import EntitlementUnavailableError
pytestmark = pytest.mark.integration
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
@@ -33,10 +30,7 @@ async def skills_api():
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
application.skill_service.list_skills = AsyncMock(
side_effect=EntitlementFeatureUnavailableError(
'managed_sandbox',
entitlement_revision=1,
)
return_value=[{'name': 'docs-only', 'description': 'No execution required'}]
)
quart_app = quart.Quart(__name__)
@@ -46,7 +40,7 @@ async def skills_api():
@pytest.mark.asyncio
async def test_list_skills_is_empty_when_plan_has_no_managed_sandbox(skills_api):
async def test_list_skills_remains_available_without_managed_sandbox(skills_api):
application, client = skills_api
response = await client.get(
'/api/v1/skills',
@@ -58,7 +52,7 @@ async def test_list_skills_is_empty_when_plan_has_no_managed_sandbox(skills_api)
assert response.status_code == 200
payload = await response.get_json()
assert payload['data'] == {'skills': []}
assert payload['data'] == {'skills': [{'name': 'docs-only', 'description': 'No execution required'}]}
application.skill_service.list_skills.assert_awaited_once()
@@ -30,6 +30,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
@@ -37,6 +40,7 @@ _UTC = dt.timezone.utc
class _AdmissionBackend(BaseSandboxBackend):
execution_scoped_mounts = True
name = 'nsjail'
def __init__(self, logger):
@@ -56,7 +60,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,
}
@@ -246,9 +250,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
@@ -329,7 +342,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')
@@ -341,54 +354,97 @@ 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')
script_revision = await repository.write_skill_file(
first,
'runner',
'scripts/main.py',
"print('ok')",
base_revision=own_skill['revision'],
)
await repository.write_skill_file(
first,
'runner',
'requirements.txt',
'requests==2.32.0\n',
base_revision=script_revision['revision'],
)
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 = await _bound_query(service, first, 91)
skill_loader.register_activated_skill(query, refreshed_skill)
await service.execute_tool(
{
'command': 'python /workspace/.skills/runner/scripts/main.py',
'workdir': '/workspace/.skills/runner',
},
await _bound_query(service, first, 91),
skill_name='runner',
query,
read_only_mounts=skill_loader.build_execution_mounts(service.ap, query),
)
mounted_spec = backend.started_specs[-1]
mounted_spec = backend.executed_specs[-1]
assert len(mounted_spec.extra_mounts) == 1
assert mounted_spec.extra_mounts[0].host_path == own_skill['package_root']
assert mounted_spec.extra_mounts[0].mount_path == '/workspace/.skills/runner'
assert mounted_spec.extra_mounts[0].host_path == refreshed_skill['package_root']
assert mounted_spec.extra_mounts[0].mount_path == '/opt/langbot-artifacts/' + refreshed_skill['revision'][7:]
assert mounted_spec.extra_mounts[0].mode.value == 'ro'
assert mounted_spec.extra_mounts[0].content_digest == refreshed_skill['revision']
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 RunnerBoxService(service).acquire(ineligible, {'reuse_key': 'global'}, _query(ineligible, 92))
finally:
server_task.cancel()
client_task.cancel()
await runtime.shutdown()
@pytest.mark.asyncio
async def test_activated_skill_mount_changes_preserve_shared_box_session(tmp_path):
service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
context = _context('workspace-a')
entitlements.snapshots[context.workspace_uuid] = _snapshot(context.workspace_uuid)
try:
first_query = await _bound_query(service, context, 101)
await service.execute_tool({'command': 'echo before activation'}, first_query)
assert len(backend.started_specs) == 1
skill = await service.ap.skill_repository.create_skill(
context, {'name': 'demo', 'instructions': 'Run a script'}
)
second_query = await _bound_query(service, context, 102)
skill_loader.register_activated_skill(second_query, skill)
await service.execute_tool(
{'command': 'echo with skill'},
second_query,
read_only_mounts=skill_loader.build_execution_mounts(service.ap, second_query),
)
assert len(backend.started_specs) == 1
assert backend.stopped_sessions == []
await service.execute_tool({'command': 'echo first query again'}, first_query)
assert len(backend.started_specs) == 1
assert backend.stopped_sessions == []
finally:
server_task.cancel()
client_task.cancel()
@@ -144,6 +144,7 @@ class FakeApplication:
get_knowledge_base_by_uuid=AsyncMock(return_value=FakeKnowledgeBase('kb_001'))
)
self.skill_mgr = types.SimpleNamespace(
reload_skills=AsyncMock(),
get_skills=lambda context: {
'demo': {
'name': 'demo',
@@ -155,7 +156,7 @@ class FakeApplication:
'display_name': 'Hidden Skill',
'description': 'Not bound to this pipeline.',
},
}
},
)
@@ -1038,7 +1039,7 @@ class TestQueryEntrySessionQueryId:
@pytest.mark.asyncio
async def test_no_query_id_for_pure_event_first_flow(self, clean_agent_state):
"""Pure event-first flow has query_id=None in session."""
"""Pure event-first flow ignores the previous run's conversation pins."""
from langbot.pkg.agent.runner.host_models import (
AgentEventEnvelope,
AgentBinding,
@@ -1049,6 +1050,8 @@ class TestQueryEntrySessionQueryId:
)
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store
from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILL_NAMES_STATE_KEY, ACTIVATED_SKILLS_KEY
db_engine = clean_agent_state
descriptor = make_descriptor()
@@ -1061,6 +1064,10 @@ class TestQueryEntrySessionQueryId:
]
)
ap = FakeApplication(plugin_connector, db_engine)
revision = 'sha256:' + '1' * 64
ap.skill_repository = types.SimpleNamespace(
get_skill=AsyncMock(return_value={'name': 'demo', 'revision': revision})
)
async def build_resource_context(execution_query):
from langbot.pkg.provider.tools.loaders.mcp import (
@@ -1116,10 +1123,21 @@ class TestQueryEntrySessionQueryId:
'mcp-resource-agent-read-enabled': True,
},
resource_policy=ResourcePolicy(),
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
state_policy=StatePolicy(enable_state=True, state_scopes=['conversation']),
delivery_policy=DeliveryPolicy(enable_streaming=True, enable_reply=True),
)
success, error = await get_persistent_state_store(db_engine).apply_update_from_event(
event.model_copy(update={'workspace_id': TEST_CONTEXT.workspace_uuid}),
binding,
descriptor,
'conversation',
ACTIVATED_SKILL_NAMES_STATE_KEY,
[{'name': 'demo', 'revision': revision}],
None,
)
assert success is True and error is None
messages = [
message
async for message in orchestrator.run(
@@ -1145,6 +1163,8 @@ class TestQueryEntrySessionQueryId:
assert execution_query.message_event.type == event.event_type
assert '_host_box_scope' not in execution_query.variables
assert execution_query.variables['_pipeline_bound_skills'] == ['demo', 'hidden']
assert ACTIVATED_SKILLS_KEY not in execution_query.variables
ap.skill_repository.get_skill.assert_not_awaited()
assert execution_query.variables['_pipeline_mcp_resource_attachments'][0]['server_uuid'] == 'srv-1'
assert execution_query.variables['_pipeline_mcp_resource_agent_read_enabled'] is True
assert 'MCP resource context selected by LangBot host:' in plugin_connector.contexts[0]['input']['text']
@@ -1375,8 +1395,9 @@ class TestQueryEntryAdapterHostCapabilities:
assert snapshot['conversation']['external.test_key'] == 'test_value'
@pytest.mark.asyncio
async def test_run_from_query_restores_activated_skills_from_state(self, clean_agent_state):
"""Persisted activated skill names are restored into the next Query run."""
@pytest.mark.parametrize('legacy_state', [False, True])
async def test_new_query_does_not_restore_conversation_skill_pins(self, clean_agent_state, legacy_state):
"""New runs ignore both revision and legacy name-only conversation pins."""
from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store
from langbot.pkg.provider.tools.loaders.skill import (
ACTIVATED_SKILL_NAMES_STATE_KEY,
@@ -1394,6 +1415,10 @@ class TestQueryEntryAdapterHostCapabilities:
]
)
ap = FakeApplication(plugin_connector, db_engine)
revision = 'sha256:' + '1' * 64
ap.skill_repository = types.SimpleNamespace(
get_skill=AsyncMock(return_value={'name': 'demo', 'revision': revision})
)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
query = make_query()
@@ -1407,7 +1432,7 @@ class TestQueryEntryAdapterHostCapabilities:
descriptor,
'conversation',
ACTIVATED_SKILL_NAMES_STATE_KEY,
['demo'],
['demo'] if legacy_state else [{'name': 'demo', 'revision': revision}],
None,
)
assert success is True
@@ -1416,7 +1441,8 @@ class TestQueryEntryAdapterHostCapabilities:
messages = [message async for message in orchestrator.run_from_query(query)]
assert len(messages) == 1
assert query.variables[ACTIVATED_SKILLS_KEY]['demo']['name'] == 'demo'
assert ACTIVATED_SKILLS_KEY not in query.variables
ap.skill_repository.get_skill.assert_not_awaited()
@pytest.mark.asyncio
async def test_event_log_and_transcript_written(self, clean_agent_state):
@@ -499,3 +499,91 @@ async def test_agent_run_pages_include_old_bindings_without_leaking_other_agents
page, cursor, more, total = await store.list_runs(agent_id='one', workspace_id='w', before_id=cursor, limit=2)
assert [run['run_id'] for run in page] == ['old']
assert not more and total == 3
@pytest.mark.asyncio
async def test_skill_pins_belong_to_run_and_survive_store_restart(store, db_engine):
for run_id in ('old', 'new'):
await store.create_run(
run_id=run_id,
event_id=run_id,
binding_id='binding',
runner_id='runner',
workspace_id='ws',
conversation_id='same',
status='running',
)
assert await store.pin_skill_revision('old', 'ws', 'demo', 'v1') == 'v1'
restarted = RunLedgerStore(db_engine)
assert await restarted.pin_skill_revision('old', 'ws', 'demo', 'v2') == 'v1'
assert await restarted.pin_skill_revision('new', 'ws', 'demo', 'v2') == 'v2'
assert (await restarted.get_run('old'))['metadata']['activated_skills'] == {'demo': 'v1'}
with pytest.raises(ValueError, match='current Workspace'):
await restarted.pin_skill_revision('old', 'other', 'demo', 'v2')
await store.finalize_run(run_id='old', status='completed')
with pytest.raises(ValueError, match='active run'):
await restarted.pin_skill_revision('old', 'ws', 'demo', 'v2')
@pytest.mark.asyncio
async def test_activation_uses_new_revision_only_for_new_run(store, db_engine, tmp_path):
from types import SimpleNamespace
from unittest.mock import Mock
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.skill.repository import SkillRepository
from langbot.pkg.skill.manager import SkillManager
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
context = ExecutionContext(instance_uuid='instance', workspace_uuid='ws', placement_generation=1)
async def binding(workspace_uuid, **kwargs):
return context
app = SimpleNamespace(
logger=Mock(),
instance_config=SimpleNamespace(data={'skills': {'root': str(tmp_path / 'skills')}}),
workspace_service=SimpleNamespace(get_execution_binding=binding),
persistence_mgr=SimpleNamespace(get_db_engine=lambda: db_engine),
)
app.skill_repository = SkillRepository(app)
app.skill_mgr = SkillManager(app)
loader = SkillToolLoader(app)
first = await app.skill_repository.create_skill(context, {'name': 'demo', 'instructions': 'version one'})
await app.skill_mgr.reload_skills(context)
def query(run_id):
return SimpleNamespace(
instance_uuid='instance',
workspace_uuid='ws',
placement_generation=1,
variables={'_pipeline_bound_skills': ['demo']},
_execution_context=context,
_agent_run_session={'run_id': run_id},
)
for name in ('old', 'new'):
await store.create_run(
run_id=name,
event_id=name,
binding_id='binding',
runner_id='runner',
workspace_id='ws',
conversation_id='same',
status='running',
)
old = query('old')
assert 'version one' in (await loader._invoke_activate_skill({'skill_name': 'demo'}, old))['content']
second = await app.skill_repository.update_skill(
context, 'demo', {'instructions': 'version two'}, base_revision=first['revision']
)
await app.skill_mgr.reload_skills(context)
assert 'version two' in (await loader._invoke_activate_skill({'skill_name': 'demo'}, query('new')))['content']
assert 'version one' in (await loader._invoke_activate_skill({'skill_name': 'demo'}, old))['content']
await app.skill_repository.delete_skill(context, 'demo')
await app.skill_mgr.reload_skills(context)
assert 'version one' in (await loader._invoke_activate_skill({'skill_name': 'demo'}, query('old')))['content']
assert (await store.get_run('new'))['metadata']['activated_skills']['demo'] == second['revision']
revoked = query('old')
revoked.variables['_pipeline_bound_skills'] = []
with pytest.raises(ValueError, match='not authorized'):
await loader._invoke_activate_skill({'skill_name': 'demo'}, revoked)
@@ -383,6 +383,7 @@ class TestMaintenanceServiceGetStorageAnalysis:
handler=plugin_handler,
),
box_service=box_service,
skill_repository=SimpleNamespace(storage_root=Mock(return_value='/core/skills/tenants/test')),
)
service = MaintenanceService(ap)
service._path_size = Mock(return_value=10)
@@ -394,12 +395,17 @@ class TestMaintenanceServiceGetStorageAnalysis:
result = await service.get_storage_analysis(TEST_CONTEXT)
assert result['total_size_bytes'] == 115
assert result['total_size_bytes'] == 125
assert (
next(section for section in result['sections'] if section['key'] == 'skills')['path']
== '/core/skills/tenants/test'
)
assert result['processes'][1]['directories'][0]['key'] == 'artifacts'
assert result['processes'][2]['directories'][1]['key'] == 'mcp'
assert result['processes'][2]['managed_processes'] == 2
plugin_handler.get_storage_analysis.assert_awaited_once_with()
box_service.get_storage_analysis.assert_awaited_once_with(TEST_CONTEXT)
ap.skill_repository.storage_root.assert_called_once_with(TEST_CONTEXT)
async def test_get_storage_analysis_postgresql(self):
"""Handles PostgreSQL database type."""
+94 -64
View File
@@ -42,6 +42,7 @@ 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.box.runner import RunBoxBinding
from langbot.pkg.provider.tools.loaders import skill as skill_loader
_UTC = dt.timezone.utc
_CONTEXT = ExecutionContext(
@@ -307,7 +308,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.
"""
@@ -344,7 +345,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
@@ -572,7 +573,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()
@@ -585,16 +585,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_with(_CONTEXT)
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()
@@ -607,7 +605,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
@@ -1809,99 +1806,104 @@ def test_disconnect_callback_does_not_schedule_without_running_event_loop():
assert service._reconnecting is False
class TestBuildSkillExtraMounts:
"""Robustness of skill mount construction against a stale skill cache.
class TestBuildSkillExecutionMounts:
"""Execution materializes only revisions pinned by this run."""
The three sandbox backends behave inconsistently when a skill's
package_root no longer exists on disk (nsjail aborts the whole sandbox
start, Docker silently auto-creates a root-owned empty directory, E2B
silently skips). Mount construction must filter these out up front so
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):
def test_mounts_only_activated_revision(self):
logger = Mock()
with tempfile.TemporaryDirectory() as live_dir:
manifest_path = os.path.join(live_dir, 'manifest.json')
with open(manifest_path, 'w', encoding='utf-8') as file:
file.write('{}')
skills = {
'alive': {'name': 'alive', 'package_root': live_dir},
'ghost': {'name': 'ghost', 'package_root': '/nonexistent/path/should/never/exist'},
'alive': {
'name': 'alive',
'package_root': live_dir,
'manifest_path': manifest_path,
'revision': 'sha256:' + '1' * 64,
},
'visible-not-activated': {
'name': 'visible-not-activated',
'package_root': live_dir,
'manifest_path': manifest_path,
'revision': 'sha256:' + '2' * 64,
},
}
service = self._make_service(logger, skills)
app = self._make_app(logger, skills)
query = make_query()
skill_loader.register_activated_skill(query, skills['alive'])
mounts = service.build_skill_extra_mounts(query)
mounts = skill_loader.build_execution_mounts(app, query)
assert mounts == [
{
'host_path': live_dir,
'mount_path': '/workspace/.skills/alive',
'mode': 'rw',
'mount_path': '/opt/langbot-artifacts/' + '1' * 64,
'mode': 'ro',
'content_digest': 'sha256:' + '1' * 64,
'manifest_path': manifest_path,
}
]
# Warning logged so operators can see what was dropped
assert any(
'ghost' in str(call.args[0]) and 'package_root missing' in str(call.args[0])
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_missing_pinned_revision_fails_instead_of_being_skipped(self):
logger = Mock()
skills = {
'a': {'name': 'a', 'package_root': '/box/skills/a'},
'b': {'name': 'b', 'package_root': '/box/skills/b'},
'a': {
'name': 'a',
'package_root': '/box/skills/a',
'manifest_path': '/box/skills/manifest.json',
'revision': 'sha256:' + '1' * 64,
}
}
service = self._make_service(logger, skills, shares_filesystem=False)
app = self._make_app(logger, skills)
query = make_query()
skill_loader.register_activated_skill(query, skills['a'])
mounts = service.build_skill_extra_mounts(make_query())
with pytest.raises(ValueError, match='cannot be recovered safely'):
skill_loader.build_execution_mounts(app, query)
assert mounts == [
{'host_path': '/box/skills/a', 'mount_path': '/workspace/.skills/a', 'mode': 'rw'},
{'host_path': '/box/skills/b', 'mount_path': '/workspace/.skills/b', 'mode': 'rw'},
]
# 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)
def test_skips_skill_with_empty_package_root(self):
def test_rejects_activated_skill_with_empty_package_root(self):
logger = Mock()
skills = {
'no_root': {'name': 'no_root', 'package_root': ''},
'whitespace': {'name': 'whitespace', 'package_root': ' '},
'no_root': {
'name': 'no_root',
'package_root': '',
'manifest_path': '',
'revision': 'sha256:' + '1' * 64,
}
}
service = self._make_service(logger, skills)
app = self._make_app(logger, skills)
query = make_query()
skill_loader.register_activated_skill(query, skills['no_root'])
assert service.build_skill_extra_mounts(make_query()) == []
with pytest.raises(ValueError, match='no immutable package root'):
skill_loader.build_execution_mounts(app, 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)
skills = {
'no_root': {
'name': 'no_root',
'package_root': '',
'manifest_path': '',
'revision': 'sha256:' + '1' * 64,
}
}
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) ─────────────────────────────
@@ -2394,3 +2396,31 @@ async def test_execution_cannot_override_runner_bound_session():
await service.execute_spec_payload({'cmd': 'true', 'session_id': 'other-box'}, make_query())
assert backend.start_calls == []
@pytest.mark.asyncio
async def test_execute_tool_preserves_trusted_read_only_mounts(tmp_path):
logger = Mock()
backend = FakeBackend(logger)
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
service = BoxService(
make_app(logger, allowed_mount_roots=[str(tmp_path)]),
client=_InProcessBoxRuntimeClient(logger, runtime),
)
package = tmp_path / 'published'
package.mkdir()
await service.initialize()
try:
result = await service.execute_tool(
{'command': 'cat /workspace/.skills/demo/SKILL.md'},
make_query(),
read_only_mounts=[{'host_path': str(package), 'mount_path': '/workspace/.skills/demo', 'mode': 'ro'}],
)
assert result['exit_code'] == 0
assert len(backend.start_specs) == 1
mount = backend.start_specs[0].extra_mounts[0]
assert mount.host_path == str(package)
assert mount.mount_path == '/workspace/.skills/demo'
assert mount.mode == BoxHostMountMode.READ_ONLY
finally:
await service.shutdown()
+4 -4
View File
@@ -12,11 +12,12 @@ from langbot.pkg.box.runner import RunnerBoxService, RunBoxBinding, exported_mes
def service():
sessions = {}
async def create(context, spec):
async def create(context, spec, **kwargs):
sessions.setdefault(spec['session_id'], spec)
return sessions[spec['session_id']]
box = SimpleNamespace(
ap=SimpleNamespace(),
enabled=True,
managed_admission_required=False,
_ATTACHMENT_MAX_TOTAL_BYTES=1000000,
@@ -24,7 +25,6 @@ def service():
require_workspace_sandbox=AsyncMock(side_effect=lambda context: context),
_action_context=lambda context: context,
client=SimpleNamespace(get_sessions=AsyncMock(side_effect=lambda **kw: list(sessions.values()))),
build_skill_extra_mounts=lambda query: [],
)
return RunnerBoxService(box)
@@ -37,7 +37,7 @@ async def test_acquire_reuses_key_and_bind_is_explicit_and_run_scoped():
api.acquire('workspace', {'reuse_key': 'global'}),
)
assert first == second
q1, q2 = SimpleNamespace(), SimpleNamespace()
q1, q2 = SimpleNamespace(variables={}), SimpleNamespace(variables={})
a = await api.bind('workspace', q1, 'run-a', first['id'])
b = await api.bind('workspace', q2, 'run-b', first['id'])
assert a['box_id'] == b['box_id']
@@ -54,7 +54,7 @@ async def test_concurrent_binding_cannot_switch_boxes():
api = service()
a = await api.acquire('ws', {'reuse_key': 'a'})
b = await api.acquire('ws', {'reuse_key': 'b'})
q = SimpleNamespace()
q = SimpleNamespace(variables={})
results = await asyncio.gather(
api.bind('ws', q, 'run', a['id']), api.bind('ws', q, 'run', b['id']), return_exceptions=True
)
+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(
@@ -1630,7 +1630,7 @@ class TestAgentRunProxyActions:
app.box_service.execute_tool.assert_awaited_once_with(
{'command': 'pwd'},
query,
skill_name=None,
read_only_mounts=[],
)
@pytest.mark.asyncio
+386 -228
View File
@@ -68,21 +68,18 @@ def _make_skill_data(
'description': kwargs.pop('description', f'Description of {name}'),
'instructions': instructions,
'package_root': package_root,
'manifest_path': kwargs.pop('manifest_path', ''),
'entry_file': entry_file,
'revision': kwargs.pop('revision', 'sha256:' + '1' * 64),
**kwargs,
}
class TestSkillManagerCache:
"""The Box runtime is the only source of truth — SkillManager just holds
an in-memory cache populated by ``reload_skills``. There is no local
filesystem reader anymore."""
"""SkillManager caches the Core-owned SkillRepository catalog."""
def test_refresh_skill_from_disk_reports_cache_presence(self):
"""Box is the only source of truth for skill content. refresh_skill_from_disk
now just reports whether the skill is still in the in-memory cache —
the actual content refresh is driven by SkillService awaiting
``reload_skills`` after every Box mutation."""
"""Disk mutations are reflected by an explicit repository reload."""
from langbot.pkg.skill.manager import SkillManager
ap = _make_ap()
@@ -99,119 +96,26 @@ class TestSkillManagerCache:
assert mgr.refresh_skill_from_disk(_CONTEXT, '') is False
@pytest.mark.asyncio
async def test_reload_skills_drops_box_skills_with_missing_package_root(self):
"""When LangBot shares a filesystem with Box (local stdio mode) and Box
reports a skill whose package_root is gone from that shared filesystem,
the cache must drop it instead of keeping a stale entry that would later
produce a bad mount."""
async def test_reload_skills_uses_repository_when_box_is_disabled(self):
from langbot.pkg.skill.manager import SkillManager
with tempfile.TemporaryDirectory() as live_dir:
ghost_dir = os.path.join(live_dir, '_does_not_exist')
box_service = SimpleNamespace(
available=True,
shares_filesystem_with_box=True,
list_skills=AsyncMock(
return_value=[
_make_skill_data(name='alive', package_root=live_dir),
_make_skill_data(name='ghost', package_root=ghost_dir),
]
),
)
ap = _make_ap()
ap.box_service = box_service
mgr = SkillManager(ap)
await mgr.reload_skills(_CONTEXT)
assert list(mgr.get_skills(_CONTEXT)) == ['alive']
# Warning fired with the dropped skill name so operators can see it.
warning_messages = [str(call.args[0]) for call in ap.logger.warning.call_args_list]
assert any('ghost' in msg and 'package_root missing' in msg for msg in warning_messages)
@pytest.mark.asyncio
async def test_reload_skills_trusts_box_paths_when_filesystem_not_shared(self):
"""In separated deployments (Docker Compose, k8s sidecar,
--standalone-box, remote endpoint) the package_root reported by Box
lives on the Box runtime's filesystem and is not resolvable on the
LangBot side. The cache must keep every Box-reported skill rather than
dropping them all via a local isdir() check."""
from langbot.pkg.skill.manager import SkillManager
box_service = SimpleNamespace(
available=True,
shares_filesystem_with_box=False,
repository = SimpleNamespace(
list_skills=AsyncMock(
return_value=[
_make_skill_data(name='alpha', package_root='/box/skills/alpha'),
_make_skill_data(name='beta', package_root='/box/skills/beta'),
_make_skill_data(name='alpha', package_root='/skills/alpha'),
_make_skill_data(name='beta', package_root='/skills/beta'),
]
),
)
ap = _make_ap()
ap.box_service = box_service
ap.box_service = SimpleNamespace(available=False, enabled=False)
ap.skill_repository = repository
mgr = SkillManager(ap)
await mgr.reload_skills(_CONTEXT)
assert sorted(mgr.get_skills(_CONTEXT)) == ['alpha', 'beta']
# No skill dropped → no "package_root missing" warning.
warning_messages = [str(call.args[0]) for call in ap.logger.warning.call_args_list]
assert not any('package_root missing' in msg for msg in warning_messages)
class TestSkillActivationHelper:
"""Skill activation is now Tool-Call based.
The legacy text-marker mechanism (``[ACTIVATE_SKILL: x]`` detection,
``build_activation_prompt_for_skills``, ``remove_activation_marker``,
``prepare_skill_activation``) has been removed. Activation now goes
through ``skill.activation.register_activated_skill``, invoked by the
``activate`` Tool Call.
"""
def test_register_activated_skill_records_known_skill(self):
from langbot.pkg.skill.activation import register_activated_skill
from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILLS_KEY
from langbot.pkg.skill.manager import SkillManager
ap = _make_ap()
mgr = SkillManager(ap)
mgr._skills_by_scope[mgr._scope_key(_CONTEXT)] = {
'primary': _make_skill_data(name='primary', instructions='Primary instructions'),
}
ap.skill_mgr = mgr
query = _make_query()
assert register_activated_skill(ap, query, 'primary') is True
assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'primary'}
assert query.variables[ACTIVATED_SKILLS_KEY]['primary']['name'] == 'primary'
def test_register_activated_skill_rejects_unknown_skill(self):
from langbot.pkg.skill.activation import register_activated_skill
from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILLS_KEY
from langbot.pkg.skill.manager import SkillManager
ap = _make_ap()
mgr = SkillManager(ap)
mgr._skills_by_scope[mgr._scope_key(_CONTEXT)] = {'primary': _make_skill_data(name='primary')}
ap.skill_mgr = mgr
query = _make_query()
assert register_activated_skill(ap, query, 'missing') is False
assert ACTIVATED_SKILLS_KEY not in query.variables
def test_register_activated_skill_without_skill_manager_returns_false(self):
from langbot.pkg.skill.activation import register_activated_skill
ap = _make_ap() # no skill_mgr attribute
query = _make_query()
assert register_activated_skill(ap, query, 'primary') is False
repository.list_skills.assert_awaited_once_with(_CONTEXT)
class TestSkillPathHelpers:
@@ -231,28 +135,16 @@ class TestSkillPathHelpers:
assert list(result.keys()) == ['visible']
def test_restore_activated_skills_uses_caller_provided_names_and_visibility(self):
from langbot.pkg.provider.tools.loaders.skill import (
ACTIVATED_SKILLS_KEY,
PIPELINE_BOUND_SKILLS_KEY,
get_activated_skill_names,
restore_activated_skills,
)
def test_activation_keeps_first_revision_and_rejects_unversioned_data(self):
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
ap = _make_ap()
ap.skill_mgr = _make_skill_manager(
{
'visible': _make_skill_data(name='visible'),
'hidden': _make_skill_data(name='hidden'),
}
)
query = _make_query(variables={PIPELINE_BOUND_SKILLS_KEY: ['visible']})
restored = restore_activated_skills(ap, query, ['visible', 'hidden', 'visible', ''])
assert restored == ['visible']
assert list(query.variables[ACTIVATED_SKILLS_KEY].keys()) == ['visible']
assert get_activated_skill_names(query) == ['visible']
query = _make_query()
first = _make_skill_data(name='visible', revision='sha256:' + '1' * 64)
second = _make_skill_data(name='visible', revision='sha256:' + '2' * 64)
assert register_activated_skill(query, first)['revision'] == first['revision']
assert register_activated_skill(query, second)['revision'] == first['revision']
with pytest.raises(ValueError, match='immutable revision'):
register_activated_skill(query, {'name': 'unversioned'})
def test_resolve_virtual_skill_path_allows_visible_skill_reads(self):
from langbot.pkg.provider.tools.loaders.skill import (
@@ -329,13 +221,40 @@ class TestSkillPathHelpers:
class TestSkillToolLoader:
"""The skill tool surface is now just ``activate`` + ``register_skill``.
"""Skill activation and resources are independent from sandbox execution.
The legacy CRUD authoring tools (create/list/get/update/delete/
import_skill_from_directory/reload_skills) were removed; skill CRUD is
handled by SkillService via the HTTP API / web UI instead.
"""
@pytest.mark.asyncio
async def test_frozen_skill_source_runs_activation_without_box(self):
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
from langbot.pkg.provider.tools.toolmgr import ToolManager
skill = _make_skill_data(name='demo', instructions='Read this first')
ap = _make_ap()
ap.skill_mgr = _make_skill_manager({'demo': skill})
ap.skill_repository = SimpleNamespace(get_skill=AsyncMock(return_value=skill))
ap.box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
manager = ToolManager(ap)
manager.skill_tool_loader = SkillToolLoader(ap)
query = _make_query(_execution_context=_CONTEXT)
source_ref = {'source': 'skill', 'source_id': None}
result = await manager.execute_func_call('activate', {'skill_name': 'demo'}, query, source_ref)
assert result['activated'] is True
assert result['revision'] == skill['revision']
assert result['capabilities']['execution_available'] is False
assert query.variables['_skill_execution_available'] is False
assert await manager.get_tool_by_source(_CONTEXT, 'activate', source_ref) is not None
assert await manager.get_tool_by_source(_CONTEXT, 'register_skill', source_ref) is None
ap.box_service.is_workspace_sandbox_available.return_value = True
assert await manager.get_tool_by_source(_CONTEXT, 'register_skill', source_ref) is not None
@pytest.mark.asyncio
async def test_activate_returns_instructions_and_registers_skill(self):
from langbot.pkg.provider.tools.loaders.skill_authoring import (
@@ -345,8 +264,11 @@ class TestSkillToolLoader:
from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILLS_KEY
skill = _make_skill_data(name='demo', package_root='/data/skills/demo', instructions='Step 1')
skill['revision'] = 'sha256:demo'
ap = _make_ap()
ap.skill_mgr = _make_skill_manager({'demo': skill})
ap.skill_repository = SimpleNamespace(get_skill=AsyncMock(return_value=skill))
ap.box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
loader = SkillToolLoader(ap)
query = _make_query()
@@ -355,11 +277,46 @@ class TestSkillToolLoader:
assert result['activated'] is True
assert result['skill_name'] == 'demo'
assert result['mount_path'] == '/workspace/.skills/demo'
assert result['mount_path'] is None
assert result['revision'] == 'sha256:demo'
assert result['capabilities']['resources_readable'] is True
assert result['capabilities']['execution_available'] is False
assert result['activated_skill_names'] == ['demo']
assert 'Step 1' in result['content']
assert '<package-root>' not in result['content']
assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'demo'}
@pytest.mark.asyncio
async def test_reactivation_keeps_first_revision_and_instructions(self):
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
v1 = _make_skill_data(
name='demo',
instructions='version one',
revision='sha256:' + '1' * 64,
)
v2 = _make_skill_data(
name='demo',
instructions='version two',
revision='sha256:' + '2' * 64,
)
ap = _make_ap()
ap.skill_mgr = _make_skill_manager({'demo': v1})
ap.skill_repository = SimpleNamespace(
get_skill=AsyncMock(side_effect=[v1, v2]),
)
ap.box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
loader = SkillToolLoader(ap)
query = _make_query()
first = await loader.invoke_tool('activate', {'skill_name': 'demo'}, query)
second = await loader.invoke_tool('activate', {'skill_name': 'demo'}, query)
assert first['revision'] == v1['revision']
assert second['revision'] == v1['revision']
assert 'version one' in second['content']
assert 'version two' not in second['content']
@pytest.mark.asyncio
async def test_activate_unknown_skill_raises(self):
from langbot.pkg.provider.tools.loaders.skill_authoring import (
@@ -391,7 +348,11 @@ class TestSkillToolLoader:
os.makedirs(repo_dir)
ap = _make_ap()
ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True)
ap.box_service = SimpleNamespace(
default_workspace=tmpdir,
available=True,
require_workspace_sandbox=AsyncMock(return_value=_CONTEXT),
)
ap.skill_service = SimpleNamespace(
scan_directory_async=AsyncMock(
return_value={
@@ -401,7 +362,7 @@ class TestSkillToolLoader:
'instructions': 'Do work',
}
),
create_skill=AsyncMock(
import_skill_directory=AsyncMock(
return_value=_make_skill_data(name='cloned-skill', package_root=os.path.realpath(repo_dir))
),
)
@@ -414,20 +375,64 @@ class TestSkillToolLoader:
)
ap.skill_service.scan_directory_async.assert_awaited_once_with(_CONTEXT, os.path.realpath(repo_dir))
ap.skill_service.create_skill.assert_awaited_once_with(
ap.skill_service.import_skill_directory.assert_awaited_once_with(
_CONTEXT,
os.path.realpath(repo_dir),
{
'name': 'cloned-skill',
'display_name': 'Cloned Skill',
'description': 'Imported from clone',
'instructions': 'Do work',
'package_root': os.path.realpath(repo_dir),
},
base_revision=None,
)
assert result['registered'] is True
assert result['skill_name'] == 'cloned-skill'
assert result['source_path'] == '/workspace/repo'
@pytest.mark.asyncio
async def test_register_skill_forwards_base_revision_for_update(self):
from langbot.pkg.provider.tools.loaders.skill_authoring import (
REGISTER_SKILL_TOOL_NAME,
SkillToolLoader,
)
with tempfile.TemporaryDirectory() as tmpdir:
draft = os.path.join(tmpdir, 'skill-drafts', 'demo')
os.makedirs(draft)
revision = 'sha256:' + '3' * 64
ap = _make_ap()
ap.box_service = SimpleNamespace(
default_workspace=tmpdir,
available=True,
require_workspace_sandbox=AsyncMock(return_value=_CONTEXT),
)
ap.skill_service = SimpleNamespace(
scan_directory_async=AsyncMock(
return_value={
'name': 'demo',
'display_name': 'Demo',
'description': 'Updated',
'instructions': 'v2',
}
),
import_skill_directory=AsyncMock(
return_value=_make_skill_data(name='demo', revision='sha256:' + '4' * 64)
),
)
result = await SkillToolLoader(ap).invoke_tool(
REGISTER_SKILL_TOOL_NAME,
{
'path': '/workspace/skill-drafts/demo',
'base_revision': revision,
},
_make_query(),
)
assert result['revision'] == 'sha256:' + '4' * 64
assert ap.skill_service.import_skill_directory.await_args.kwargs['base_revision'] == revision
@pytest.mark.asyncio
async def test_register_skill_rejects_workspace_escape(self):
from langbot.pkg.provider.tools.loaders.skill_authoring import (
@@ -437,8 +442,15 @@ class TestSkillToolLoader:
with tempfile.TemporaryDirectory() as tmpdir:
ap = _make_ap()
ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True)
ap.skill_service = SimpleNamespace(scan_directory_async=AsyncMock(), create_skill=AsyncMock())
ap.box_service = SimpleNamespace(
default_workspace=tmpdir,
available=True,
require_workspace_sandbox=AsyncMock(return_value=_CONTEXT),
)
ap.skill_service = SimpleNamespace(
scan_directory_async=AsyncMock(),
import_skill_directory=AsyncMock(),
)
loader = SkillToolLoader(ap)
@@ -458,7 +470,11 @@ class TestSkillToolLoader:
with tempfile.TemporaryDirectory() as tmpdir:
ap = _make_ap() # no skill_service attribute
ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True)
ap.box_service = SimpleNamespace(
default_workspace=tmpdir,
available=True,
require_workspace_sandbox=AsyncMock(return_value=_CONTEXT),
)
loader = SkillToolLoader(ap)
@@ -470,21 +486,22 @@ class TestSkillToolLoader:
)
@pytest.mark.asyncio
async def test_tools_hidden_when_sandbox_backend_unavailable(self):
async def test_read_only_tools_remain_when_sandbox_unavailable(self):
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
ap = _make_ap()
ap.skill_mgr = SimpleNamespace(skills={})
ap.box_service = SimpleNamespace(
available=True,
get_backend_status=AsyncMock(return_value={'backend': {'available': False}}),
)
ap.skill_repository = SimpleNamespace()
loader = SkillToolLoader(ap)
await loader.initialize()
assert await loader.get_tools() == []
assert await loader.has_tool('activate') is False
assert sorted(tool.name for tool in await loader.get_tools(sandbox_available=False)) == [
'activate',
'list_skill_resources',
'read_skill_resource',
]
assert await loader.has_tool('activate') is True
assert await loader.has_tool('register_skill') is False
@pytest.mark.asyncio
@@ -493,45 +510,96 @@ class TestSkillToolLoader:
ap = _make_ap()
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo')})
ap.box_service = SimpleNamespace(
available=True,
get_backend_status=AsyncMock(return_value={'backend': {'available': True}}),
)
ap.skill_repository = SimpleNamespace()
loader = SkillToolLoader(ap)
await loader.initialize()
tools = await loader.get_tools()
tools = await loader.get_tools(sandbox_available=True)
assert sorted(tool.name for tool in tools) == ['activate', 'register_skill']
assert sorted(tool.name for tool in tools) == [
'activate',
'list_skill_resources',
'read_skill_resource',
'register_skill',
]
assert await loader.has_tool('activate') is True
assert await loader.has_tool('register_skill') is True
assert await loader.has_tool('register_skill', sandbox_available=True) is True
@pytest.mark.asyncio
async def test_tools_reappear_after_box_backend_recovers(self):
async def test_register_skill_appears_after_sandbox_recovers(self):
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
ap = _make_ap()
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')})
ap.box_service = SimpleNamespace(
available=False,
get_backend_status=AsyncMock(return_value={'backend': {'available': True}}),
)
ap.skill_repository = SimpleNamespace()
loader = SkillToolLoader(ap)
await loader.initialize()
assert await loader.get_tools() == []
assert 'register_skill' not in {tool.name for tool in await loader.get_tools(sandbox_available=False)}
assert 'register_skill' in {tool.name for tool in await loader.get_tools(sandbox_available=True)}
ap.box_service.available = True
@pytest.mark.asyncio
async def test_resources_require_activation_and_use_pinned_revision(self):
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
assert sorted(tool.name for tool in await loader.get_tools()) == ['activate', 'register_skill']
skill = _make_skill_data(name='demo', instructions='Read references')
skill['revision'] = 'sha256:demo'
ap = _make_ap()
ap.skill_mgr = _make_skill_manager({'demo': skill})
ap.skill_repository = SimpleNamespace(
list_skill_resources=AsyncMock(
return_value={'entries': [{'path': 'references/a.md'}], 'revision': 'sha256:demo'}
),
read_skill_resource=AsyncMock(
return_value={
'path': 'references/a.md',
'content': 'reference text',
'revision': 'sha256:demo',
'mime_type': 'text/markdown',
}
),
)
loader = SkillToolLoader(ap)
query = _make_query()
with pytest.raises(ValueError, match='must be activated'):
await loader.invoke_tool(
'read_skill_resource',
{'skill_name': 'demo', 'path': 'references/a.md'},
query,
)
register_activated_skill(query, skill)
listed = await loader.invoke_tool('list_skill_resources', {'skill_name': 'demo'}, query)
read = await loader.invoke_tool(
'read_skill_resource',
{'skill_name': 'demo', 'path': 'references/a.md', 'revision': 'sha256:demo'},
query,
)
assert listed['entries'][0]['path'] == 'references/a.md'
assert read['content'] == 'reference text'
ap.skill_repository.list_skill_resources.assert_awaited_once_with(
_CONTEXT,
'demo',
'.',
expected_revision='sha256:demo',
)
ap.skill_repository.read_skill_resource.assert_awaited_once_with(
_CONTEXT,
'demo',
'references/a.md',
expected_revision='sha256:demo',
)
class TestNativeToolLoaderSkillPaths:
@pytest.mark.asyncio
async def test_read_visible_skill_file(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY, register_activated_skill
with tempfile.TemporaryDirectory() as tmpdir:
skill_md = os.path.join(tmpdir, 'SKILL.md')
@@ -547,20 +615,22 @@ class TestNativeToolLoaderSkillPaths:
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
loader = NativeToolLoader(ap)
query = _make_bound_query(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
register_activated_skill(query, ap.skill_mgr.skills['demo'])
if not loader._can_interpret_skill_host_paths():
# Windows lacks the secure descriptor-relative host file operations.
with pytest.raises(ValueError, match='owned by the Box Runtime'):
with pytest.raises(ValueError, match='Secure Core host file operations'):
await loader.invoke_tool(
'read',
{'path': '/workspace/.skills/demo/SKILL.md'},
_make_bound_query(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}),
query,
)
return
result = await loader.invoke_tool(
'read',
{'path': '/workspace/.skills/demo/SKILL.md'},
_make_bound_query(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}),
query,
)
assert result['ok'] is True
@@ -568,9 +638,9 @@ class TestNativeToolLoaderSkillPaths:
assert result['truncated'] is False
@pytest.mark.asyncio
async def test_external_runtime_read_never_interprets_package_root_on_core_host(self):
async def test_external_runtime_read_uses_core_skill_repository(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY, register_activated_skill
with tempfile.TemporaryDirectory() as tmpdir:
with open(os.path.join(tmpdir, 'SKILL.md'), 'w', encoding='utf-8') as file_obj:
@@ -580,7 +650,9 @@ class TestNativeToolLoaderSkillPaths:
ap.box_service = SimpleNamespace(
available=True,
shares_filesystem_with_box=False,
read_skill_file=AsyncMock(return_value={'content': 'runtime-owned-content'}),
)
ap.skill_repository = SimpleNamespace(
read_skill_resource=AsyncMock(return_value={'content': 'repository-content'})
)
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
loader = NativeToolLoader(ap)
@@ -588,6 +660,7 @@ class TestNativeToolLoaderSkillPaths:
query_id='q-external-read',
variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']},
)
register_activated_skill(query, ap.skill_mgr.skills['demo'])
result = await loader.invoke_tool(
'read',
@@ -596,14 +669,19 @@ class TestNativeToolLoaderSkillPaths:
)
assert result['ok'] is True
assert result['content'] == 'runtime-owned-content'
assert result['content'] == 'repository-content'
assert 'core-host-secret' not in repr(result)
ap.box_service.read_skill_file.assert_awaited_once_with(_CONTEXT, 'demo', 'SKILL.md')
ap.skill_repository.read_skill_resource.assert_awaited_once_with(
_CONTEXT,
'demo',
'SKILL.md',
expected_revision='sha256:' + '1' * 64,
)
@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
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY, register_activated_skill
with tempfile.TemporaryDirectory() as tmpdir:
with open(os.path.join(tmpdir, 'secret.txt'), 'w', encoding='utf-8') as file_obj:
@@ -620,34 +698,48 @@ class TestNativeToolLoaderSkillPaths:
query_id='q-external-no-protocol',
variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']},
)
register_activated_skill(query, ap.skill_mgr.skills['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
with tempfile.TemporaryDirectory() as tmpdir:
manifest_path = os.path.join(tmpdir, 'manifest.json')
with open(manifest_path, 'w', encoding='utf-8') as file:
file.write('{}')
ap = _make_ap()
ap.box_service = SimpleNamespace(
available=True,
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,
manifest_path=manifest_path,
)
ap.skill_mgr = _make_skill_manager(
{'demo': skill_data},
refresh_skill_from_disk=Mock(),
)
loader = NativeToolLoader(ap)
query = _make_bound_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',
@@ -660,50 +752,56 @@ class TestNativeToolLoaderSkillPaths:
assert result['ok'] is True
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 tool_parameters['command'] == f'python /opt/langbot-artifacts/{"1" * 64}/scripts/run.py'
assert tool_parameters['workdir'] == '/opt/langbot-artifacts/' + '1' * 64
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):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
ap = _make_ap()
ap.box_service = SimpleNamespace(
available=True,
shares_filesystem_with_box=False,
execute_tool=AsyncMock(return_value={'ok': True}),
)
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
loader = NativeToolLoader(ap)
query = _make_bound_query(query_id='q-external', launcher_type='person', launcher_id='123')
register_activated_skill(
query,
_make_skill_data(
with tempfile.TemporaryDirectory() as tmpdir:
manifest_path = os.path.join(tmpdir, 'manifest.json')
with open(manifest_path, 'w', encoding='utf-8') as file:
file.write('{}')
ap = _make_ap()
ap.box_service = SimpleNamespace(
available=True,
shares_filesystem_with_box=False,
execute_tool=AsyncMock(return_value={'ok': True}),
)
skill_data = _make_skill_data(
name='demo',
package_root='/box-runtime/skills/tenants/workspace/demo',
package_root=tmpdir,
manifest_path=manifest_path,
python_project=True,
),
)
)
ap.skill_mgr = _make_skill_manager(
{'demo': skill_data},
refresh_skill_from_disk=Mock(),
)
loader = NativeToolLoader(ap)
query = _make_bound_query(query_id='q-external', launcher_type='person', launcher_id='123')
register_activated_skill(query, skill_data)
result = await loader.invoke_tool(
'exec',
{
'command': 'python /workspace/.skills/demo/scripts/run.py',
'workdir': '/workspace/.skills/demo',
},
query,
)
result = await loader.invoke_tool(
'exec',
{
'command': 'python /workspace/.skills/demo/scripts/run.py',
'workdir': '/workspace/.skills/demo',
},
query,
)
assert result['ok'] is True
tool_parameters = ap.box_service.execute_tool.await_args.args[0]
wrapped = tool_parameters['command']
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 f'_LB_VENV_DIR="/workspace/.skill-envs/demo/{"1" * 64}/.venv"' in wrapped
assert f'root = "/opt/langbot-artifacts/{"1" * 64}"' in wrapped
assert tmpdir not in wrapped
assert 'skill_name' not in ap.box_service.execute_tool.await_args.kwargs
@pytest.mark.asyncio
async def test_write_requires_skill_activation(self):
@@ -725,6 +823,36 @@ class TestNativeToolLoaderSkillPaths:
query,
)
@pytest.mark.asyncio
@pytest.mark.parametrize('tool_name', ['write', 'edit'])
async def test_published_revision_rejects_direct_mutation(self, tool_name):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
with tempfile.TemporaryDirectory() as tmpdir:
ap = _make_ap()
ap.box_service = SimpleNamespace(available=True, default_workspace=tmpdir)
skill = _make_skill_data(name='demo', package_root=tmpdir)
ap.skill_mgr = _make_skill_manager({'demo': skill})
loader = NativeToolLoader(ap)
query = _make_bound_query(query_id='immutable')
register_activated_skill(query, skill)
parameters = (
{'path': '/workspace/.skills/demo/notes.txt', 'content': 'hi'}
if tool_name == 'write'
else {
'path': '/workspace/.skills/demo/notes.txt',
'old_string': 'old',
'new_string': 'new',
}
)
result = await loader.invoke_tool(tool_name, parameters, query)
assert result['ok'] is False
assert 'immutable' in result['error']
assert '/workspace/skill-drafts' in result['error']
@pytest.mark.asyncio
async def test_native_skill_tools_require_runner_box_binding():
@@ -738,21 +866,51 @@ async def test_native_skill_tools_require_runner_box_binding():
await loader.invoke_tool('exec', {'command': 'true'}, _make_query())
ap.box_service.execute_tool.assert_not_awaited()
@pytest.mark.asyncio
async def test_register_skill_hidden_when_cloud_scanning_is_disabled(self):
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
ap = _make_ap()
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo')})
ap.box_service = SimpleNamespace(
available=True,
managed_admission_required=True,
get_backend_status=AsyncMock(return_value={'backend': {'available': True}}),
)
@pytest.mark.asyncio
async def test_register_skill_hidden_when_cloud_scanning_is_disabled():
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
loader = SkillToolLoader(ap)
await loader.initialize()
ap = _make_ap()
ap.skill_repository = SimpleNamespace()
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo')})
ap.box_service = SimpleNamespace(
available=True,
managed_admission_required=True,
get_backend_status=AsyncMock(return_value={'backend': {'available': True}}),
)
assert [tool.name for tool in await loader.get_tools()] == ['activate']
assert await loader.has_tool('register_skill') is False
assert await loader.has_tool('activate') is True
loader = SkillToolLoader(ap)
await loader.initialize()
assert {tool.name for tool in await loader.get_tools(sandbox_available=True)} == {
'activate',
'list_skill_resources',
'read_skill_resource',
}
assert await loader.has_tool('register_skill', sandbox_available=True) is False
assert await loader.has_tool('activate') is True
@pytest.mark.asyncio
async def test_catalog_refresh_does_not_hide_skills_from_another_run():
import asyncio
from langbot.pkg.skill.manager import SkillManager
app = _make_ap()
entered, release = asyncio.Event(), asyncio.Event()
async def read_catalog(context):
entered.set()
await release.wait()
return [_make_skill_data(name='new')]
app.skill_repository = SimpleNamespace(list_skills=read_catalog)
manager = SkillManager(app)
manager._skills_by_scope[manager._scope_key(_CONTEXT)] = {'old': _make_skill_data(name='old')}
refresh = asyncio.create_task(manager.reload_skills(_CONTEXT))
await entered.wait()
assert set(manager.get_skills(_CONTEXT)) == {'old'}
release.set()
await refresh
assert set(manager.get_skills(_CONTEXT)) == {'new'}
@@ -56,10 +56,20 @@ class StubLoader:
for tool in self._tools
]
async def has_tool(self, *args) -> bool:
async def get_tool(self, name: str, **_kwargs):
return next((tool for tool in self._tools if tool.name == name), None)
async def has_tool(self, *args, **_kwargs) -> bool:
name = args[-1]
return any(tool.name == name for tool in self._tools)
def recognizes_tool(self, name: str) -> bool:
return any(tool.name == name for tool in self._tools)
@staticmethod
def is_sandbox_tool(name: str) -> bool:
return name == 'register_skill'
async def invoke_tool(self, name: str, parameters: dict, query):
return self._invoke_result(name, parameters, query) if callable(self._invoke_result) else self._invoke_result
@@ -146,7 +156,7 @@ async def test_tool_manager_routes_native_tool_calls():
@pytest.mark.asyncio
async def test_tool_manager_hides_sandbox_and_skill_tools_without_workspace_entitlement():
async def test_tool_manager_keeps_read_only_skill_tools_without_workspace_entitlement():
box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
manager = ToolManager(SimpleNamespace(box_service=box_service))
manager.native_tool_loader = StubLoader([make_tool('exec')])
@@ -157,8 +167,8 @@ async def test_tool_manager_hides_sandbox_and_skill_tools_without_workspace_enti
tools = await manager.get_all_tools(_CONTEXT, include_skill_authoring=True)
catalog = await manager.get_tool_catalog(_CONTEXT, include_skill_authoring=True)
assert [tool.name for tool in tools] == ['plugin_tool', 'mcp_tool']
assert [item['name'] for item in catalog] == ['plugin_tool', 'mcp_tool']
assert [tool.name for tool in tools] == ['activate', 'plugin_tool', 'mcp_tool']
assert [item['name'] for item in catalog] == ['activate', 'plugin_tool', 'mcp_tool']
assert box_service.is_workspace_sandbox_available.await_count == 2
@@ -177,9 +187,10 @@ async def test_tool_manager_rechecks_workspace_entitlement_before_native_invocat
query_uuid=None,
)
with pytest.raises(Exception, match='exec'):
await manager.execute_func_call('exec', {'command': 'pwd'}, query=query)
result = await manager.execute_func_call('exec', {'command': 'pwd'}, query=query)
assert result['code'] == 'sandbox_unavailable'
assert result['tool'] == 'exec'
box_service.is_workspace_sandbox_available.assert_awaited_once_with(_CONTEXT)
+299
View File
@@ -0,0 +1,299 @@
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.skill.repository import SkillRepository, SkillRevisionConflictError
_CONTEXT = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
async def _binding(workspace_uuid, *, expected_generation):
return SimpleNamespace(
instance_uuid=_CONTEXT.instance_uuid,
workspace_uuid=workspace_uuid,
placement_generation=expected_generation,
)
def _repository(tmp_path) -> SkillRepository:
app = SimpleNamespace(
workspace_service=SimpleNamespace(
get_execution_binding=_binding,
),
instance_config=SimpleNamespace(
data={
'skills': {'root': str(tmp_path / 'skill-store')},
'box': {
'enabled': False,
'local': {
'host_root': str(tmp_path / 'box'),
},
},
}
),
)
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_locks_are_workspace_scoped(tmp_path):
repository = _repository(tmp_path)
first = repository._workspace_lock('workspace-a')
assert repository._workspace_lock('workspace-a') is first
assert repository._workspace_lock('workspace-b') is not first
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)
created = await repository.create_skill(
_CONTEXT,
{
'name': 'docs-only',
'display_name': 'Docs only',
'description': 'Read-only guidance',
'instructions': 'Read references/guide.md.',
},
)
await repository.write_skill_file(
_CONTEXT,
'docs-only',
'references/guide.md',
'# Guide\n\nNo execution needed.',
base_revision=created['revision'],
)
skill = await repository.get_skill(_CONTEXT, 'docs-only', snapshot=True)
assert skill is not None
assert skill['revision'].startswith('sha256:')
assert [item['name'] for item in await repository.list_skills(_CONTEXT)] == ['docs-only']
listed = await repository.list_skill_resources(
_CONTEXT,
'docs-only',
'references',
expected_revision=skill['revision'],
)
assert listed['entries'][0]['path'] == 'references/guide.md'
assert listed['entries'][0]['mime_type'] == 'text/markdown'
resource = await repository.read_skill_resource(
_CONTEXT,
'docs-only',
'references/guide.md',
expected_revision=skill['revision'],
)
assert resource['content'].startswith('# Guide')
assert resource['revision'] == skill['revision']
@pytest.mark.asyncio
async def test_repository_rejects_traversal_and_conflicting_update(tmp_path):
repository = _repository(tmp_path)
created = await repository.create_skill(
_CONTEXT,
{'name': 'safe', 'description': 'Safe', 'instructions': 'Use the reference.'},
)
first = await repository.write_skill_file(
_CONTEXT,
'safe',
'reference.md',
'first',
base_revision=created['revision'],
)
skill = await repository.get_skill(_CONTEXT, 'safe', snapshot=True)
assert skill is not None
with pytest.raises(ValueError, match='stay within'):
await repository.read_skill_resource(_CONTEXT, 'safe', '../secret.txt')
second = await repository.write_skill_file(
_CONTEXT,
'safe',
'reference.md',
'second',
base_revision=first['revision'],
)
pinned = await repository.read_skill_resource(
_CONTEXT,
'safe',
'reference.md',
expected_revision=skill['revision'],
)
assert pinned['content'] == 'first'
with pytest.raises(SkillRevisionConflictError, match='changed since'):
await repository.update_skill(
_CONTEXT,
'safe',
{'instructions': 'stale'},
base_revision=first['revision'],
)
assert second['revision'] != first['revision']
@pytest.mark.asyncio
async def test_repository_scopes_skills_by_workspace(tmp_path):
repository = _repository(tmp_path)
other_context = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-b',
placement_generation=1,
)
await repository.create_skill(_CONTEXT, {'name': 'private', 'instructions': 'A'})
assert [skill['name'] for skill in await repository.list_skills(_CONTEXT)] == ['private']
assert await repository.list_skills(other_context) == []
@pytest.mark.asyncio
async def test_repository_rejects_stale_workspace_placement(tmp_path):
repository = _repository(tmp_path)
async def stale_binding(workspace_uuid, *, expected_generation):
return SimpleNamespace(
instance_uuid=_CONTEXT.instance_uuid,
workspace_uuid=workspace_uuid,
placement_generation=expected_generation + 1,
)
repository.ap.workspace_service.get_execution_binding = stale_binding
with pytest.raises(ValueError, match='stale Workspace placement'):
await repository.list_skills(_CONTEXT)
@pytest.mark.asyncio
async def test_repository_imports_only_from_the_fenced_workspace(tmp_path):
repository = _repository(tmp_path)
namespace = repository._namespace(_CONTEXT)
source = tmp_path / 'box' / 'default' / 'tenants' / namespace / 'draft'
source.mkdir(parents=True)
(source / 'SKILL.md').write_text(
'---\nname: draft\ndescription: Draft skill\n---\n\nFollow the guide.',
encoding='utf-8',
)
(source / 'guide.md').write_text('Imported resource', encoding='utf-8')
scanned = await repository.scan_skill_directory(_CONTEXT, str(source))
imported = await repository.import_skill_directory(
_CONTEXT,
str(source),
{
'name': scanned['name'],
'display_name': scanned['display_name'],
'description': scanned['description'],
'instructions': scanned['instructions'],
},
)
assert imported['name'] == 'draft'
resource = await repository.read_skill_file(_CONTEXT, 'draft', 'guide.md')
assert resource['content'] == 'Imported resource'
outside = tmp_path / 'outside'
outside.mkdir()
(outside / 'SKILL.md').write_text('Outside', encoding='utf-8')
with pytest.raises(ValueError, match='trusted source root'):
await repository.scan_skill_directory(_CONTEXT, str(outside))
@pytest.mark.asyncio
async def test_existing_run_keeps_v1_while_new_run_resolves_v2(tmp_path):
from langbot.pkg.provider.tools.loaders import skill as skill_loader
repository = _repository(tmp_path)
created = await repository.create_skill(
_CONTEXT,
{'name': 'runner', 'instructions': 'Run scripts/main.py'},
)
v1_write = await repository.write_skill_file(
_CONTEXT,
'runner',
'scripts/main.py',
"print('v1')",
base_revision=created['revision'],
)
v1 = await repository.get_skill(_CONTEXT, 'runner', snapshot=True)
old_run = SimpleNamespace(variables={})
skill_loader.register_activated_skill(old_run, v1)
await repository.write_skill_file(
_CONTEXT,
'runner',
'scripts/main.py',
"print('v2')",
base_revision=v1_write['revision'],
)
v2 = await repository.get_skill(_CONTEXT, 'runner', snapshot=True)
new_run = SimpleNamespace(variables={})
skill_loader.register_activated_skill(new_run, v2)
old_resource = await repository.read_skill_resource(
_CONTEXT,
'runner',
'scripts/main.py',
expected_revision=v1['revision'],
)
new_resource = await repository.read_skill_resource(
_CONTEXT,
'runner',
'scripts/main.py',
expected_revision=v2['revision'],
)
app = SimpleNamespace(logger=Mock())
old_mount = skill_loader.build_execution_mounts(app, old_run)[0]
new_mount = skill_loader.build_execution_mounts(app, new_run)[0]
assert old_resource['content'] == "print('v1')"
assert new_resource['content'] == "print('v2')"
assert old_mount['host_path'] == v1['package_root']
assert new_mount['host_path'] == v2['package_root']
assert old_mount['content_digest'] == v1['revision']
assert new_mount['content_digest'] == v2['revision']
@pytest.mark.asyncio
async def test_deleted_skill_can_restore_exact_recoverable_run_revision(tmp_path):
repository = _repository(tmp_path)
published = await repository.create_skill(
_CONTEXT,
{'name': 'recoverable', 'instructions': 'Pinned instructions'},
)
await repository.delete_skill(_CONTEXT, 'recoverable')
restored = await repository.get_skill(_CONTEXT, 'recoverable', snapshot=True, revision=published['revision'])
assert restored['revision'] == published['revision']
assert restored['instructions'] == 'Pinned instructions'
+67 -94
View File
@@ -5,7 +5,6 @@ import zipfile
import httpx
import pytest
from langbot_plugin.entities.io.errors import ActionCallTimeoutError
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.api.http.service.skill import SkillService
@@ -24,120 +23,94 @@ def _workspace_service():
)
class TestRequireBoxForWrite:
"""Box is the only source of truth for skills — there is no local
filesystem fallback. Every write and (most) read methods refuse cleanly
when the Box runtime is disabled, unreachable, or simply not installed."""
class TestSkillRepositoryBoundary:
"""Skill management and reads remain available without Box execution."""
def _ap_with_disabled_box(self):
@staticmethod
def _ap_with_repository():
repository = SimpleNamespace(
list_skills=AsyncMock(return_value=[{'name': 'x', 'instructions': 'Do work'}]),
get_skill=AsyncMock(return_value={'name': 'x', 'instructions': 'Do work', 'revision': 'sha256:x'}),
create_skill=AsyncMock(return_value={'name': 'x', 'instructions': 'Do work', 'revision': 'sha256:v1'}),
update_skill=AsyncMock(return_value={'name': 'x', 'instructions': 'Updated', 'revision': 'sha256:v2'}),
delete_skill=AsyncMock(),
read_skill_file=AsyncMock(return_value={'path': 'a.txt', 'content': 'hello'}),
write_skill_file=AsyncMock(return_value={'path': 'a.txt', 'revision': 'sha256:v3'}),
)
return SimpleNamespace(
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
workspace_service=_workspace_service(),
box_service=SimpleNamespace(
available=False,
enabled=False,
_connector_error='Box runtime is disabled in config (box.enabled = false)',
),
)
def _ap_with_failed_box(self):
return SimpleNamespace(
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
workspace_service=_workspace_service(),
box_service=SimpleNamespace(
available=False,
enabled=True,
_connector_error='docker daemon not running',
),
box_service=SimpleNamespace(available=False, enabled=False),
skill_repository=repository,
)
@pytest.mark.asyncio
async def test_create_skill_refused_when_box_disabled(self):
service = SkillService(self._ap_with_disabled_box())
with pytest.raises(ValueError, match='disabled in config'):
await service.create_skill(_CONTEXT, {'name': 'x'})
async def test_list_and_read_work_when_box_disabled(self):
ap = self._ap_with_repository()
service = SkillService(ap)
assert await service.list_skills(_CONTEXT) == [{'name': 'x', 'instructions': 'Do work'}]
assert await service.read_skill_file(_CONTEXT, 'x', 'a.txt') == {
'path': 'a.txt',
'content': 'hello',
}
ap.skill_repository.read_skill_file.assert_awaited_once_with(_CONTEXT, 'x', 'a.txt')
@pytest.mark.asyncio
async def test_create_skill_refused_when_box_failed(self):
service = SkillService(self._ap_with_failed_box())
with pytest.raises(ValueError, match='docker daemon not running'):
await service.create_skill(_CONTEXT, {'name': 'x'})
async def test_create_update_and_write_work_when_box_disabled(self):
ap = self._ap_with_repository()
service = SkillService(ap)
await service.create_skill(_CONTEXT, {'name': 'x'})
await service.update_skill(
_CONTEXT,
'x',
{'instructions': 'Updated', 'base_revision': 'sha256:v1'},
)
await service.write_skill_file(
_CONTEXT,
'x',
'a.txt',
'hello',
base_revision='sha256:v2',
)
ap.skill_repository.create_skill.assert_awaited_once_with(_CONTEXT, {'name': 'x'})
ap.skill_repository.update_skill.assert_awaited_once_with(
_CONTEXT,
'x',
{'instructions': 'Updated'},
base_revision='sha256:v1',
)
ap.skill_repository.write_skill_file.assert_awaited_once_with(
_CONTEXT,
'x',
'a.txt',
'hello',
base_revision='sha256:v2',
)
@pytest.mark.asyncio
async def test_update_skill_refused_when_box_disabled(self):
service = SkillService(self._ap_with_disabled_box())
with pytest.raises(ValueError, match='Editing a skill requires the Box runtime'):
await service.update_skill(_CONTEXT, 'x', {})
async def test_get_skill_returns_repository_revision(self):
ap = self._ap_with_repository()
service = SkillService(ap)
skill = await service.get_skill(_CONTEXT, 'x')
assert skill['revision'] == 'sha256:x'
ap.skill_repository.get_skill.assert_awaited_once_with(_CONTEXT, 'x', snapshot=True)
@pytest.mark.asyncio
async def test_write_skill_file_refused_when_box_disabled(self):
service = SkillService(self._ap_with_disabled_box())
with pytest.raises(ValueError, match='Editing skill files requires the Box runtime'):
await service.write_skill_file(_CONTEXT, 'x', 'a.txt', 'hi')
@pytest.mark.asyncio
async def test_install_from_github_refused_when_box_disabled(self):
service = SkillService(self._ap_with_disabled_box())
with pytest.raises(ValueError, match='Installing a skill from GitHub'):
await service.install_from_github(
_CONTEXT,
{'owner': 'o', 'repo': 'r', 'asset_url': 'https://example/x.zip'},
)
@pytest.mark.asyncio
async def test_install_from_zip_upload_refused_when_box_disabled(self):
service = SkillService(self._ap_with_disabled_box())
with pytest.raises(ValueError, match='Installing a skill from upload'):
await service.install_from_zip_upload(
_CONTEXT,
file_bytes=b'',
filename='x.zip',
)
@pytest.mark.asyncio
async def test_create_skill_refused_when_box_service_missing_entirely(self):
"""No ap.box_service attribute at all (truly minimal setup):
Box is the only source of truth, so creation must still refuse."""
async def test_missing_repository_is_explicit(self):
service = SkillService(
SimpleNamespace(
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
workspace_service=_workspace_service(),
)
)
with pytest.raises(ValueError, match='not initialised'):
with pytest.raises(ValueError, match='repository is not initialised'):
await service.create_skill(_CONTEXT, {'name': 'x'})
@pytest.mark.asyncio
async def test_list_skills_returns_empty_when_box_unavailable(self):
"""list_skills should render an empty surface (not crash) so the
skills page can show a banner instead of a broken state."""
service = SkillService(self._ap_with_disabled_box())
assert await service.list_skills(_CONTEXT) == []
@pytest.mark.asyncio
async def test_list_skills_returns_empty_when_box_action_times_out(self):
"""A transient Box list failure should not break unrelated extension surfaces."""
box_service = SimpleNamespace(
available=True,
list_skills=AsyncMock(side_effect=ActionCallTimeoutError('Action box_list_skills call timed out')),
)
service = SkillService(
SimpleNamespace(
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
workspace_service=_workspace_service(),
box_service=box_service,
)
)
assert await service.list_skills(_CONTEXT) == []
@pytest.mark.asyncio
async def test_read_skill_file_refused_when_box_unavailable(self):
service = SkillService(self._ap_with_disabled_box())
with pytest.raises(ValueError, match='Reading a skill file'):
await service.read_skill_file(_CONTEXT, 'x', 'a.txt')
class TestGithubSkillArchiveLimits:
@staticmethod
def _service() -> SkillService:
Generated
+2 -6
View File
@@ -2185,7 +2185,7 @@ requires-dist = [
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
{ name = "langbot-plugin", specifier = "==0.7.12" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=dc3d98efd4f02565a4d56999f91152393542fd73" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2256,7 +2256,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.7.12"
source = { registry = "https://pypi.org/simple" }
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=dc3d98efd4f02565a4d56999f91152393542fd73#dc3d98efd4f02565a4d56999f91152393542fd73" }
dependencies = [
{ name = "aiofiles" },
{ name = "aiohttp" },
@@ -2276,10 +2276,6 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/4c/0286247a6caa24a21328fe5dae32b295635143cb863d9eb879384bf613ce/langbot_plugin-0.7.12.tar.gz", hash = "sha256:36a661bfe25233a5c4d74e86568df6831e9055e1b6c88bfad8dee7c2e207766e", size = 697786, upload-time = "2026-10-15T12:01:17.213Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/34/2e40056497e60411e851b30320087e1385865b6357185a05e40a75ef81ca/langbot_plugin-0.7.12-py3-none-any.whl", hash = "sha256:b0bbd6bbfedf15ed48377c3904c73af62c4ee88a676b06649b470fc1bad9416a", size = 448016, upload-time = "2026-10-15T12:01:15.542Z" },
]
[[package]]
name = "langchain"
@@ -32,8 +32,6 @@ import {
import { Plugin } from '@/app/infra/entities/plugin';
import { MCPServer, Skill } from '@/app/infra/entities/api';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
function PluginThumbnail({ author, name }: { author: string; name: string }) {
@@ -81,11 +79,6 @@ export default function PipelineExtension({
pipelineId: string;
}) {
const { t } = useTranslation();
const {
available: boxAvailable,
hint: boxHint,
reason: boxReason,
} = useBoxStatus();
const [loading, setLoading] = useState(true);
const [enableAllPlugins, setEnableAllPlugins] = useState(true);
const [enableAllMCPServers, setEnableAllMCPServers] = useState(true);
@@ -583,13 +576,9 @@ export default function PipelineExtension({
id="enable-all-skills"
checked={enableAllSkills}
onCheckedChange={handleToggleEnableAllSkills}
disabled={!boxAvailable}
/>
</div>
</div>
{!boxAvailable && (
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
)}
<div className="space-y-2">
{enableAllSkills ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
@@ -627,7 +616,6 @@ export default function PipelineExtension({
variant="ghost"
size="icon"
onClick={() => handleRemoveSkill(skill.name)}
disabled={!boxAvailable}
>
<X className="h-4 w-4" />
</Button>
@@ -641,7 +629,7 @@ export default function PipelineExtension({
onClick={handleOpenSkillDialog}
variant="outline"
className="w-full"
disabled={enableAllSkills || !boxAvailable}
disabled={enableAllSkills}
>
<Plus className="mr-2 h-4 w-4" />
{t('pipelines.extensions.addSkill')}
+2 -31
View File
@@ -1,4 +1,3 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@@ -22,8 +21,6 @@ import {
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { httpClient } from '@/app/infra/http/HttpClient';
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { Sparkles, Trash2 } from 'lucide-react';
import { useCurrentWorkspace } from '@/app/infra/http';
@@ -37,13 +34,6 @@ export default function SkillDetailContent({ id }: { id: string }) {
const { refreshSkills, skills, setDetailEntityName } = useSidebarData();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const skill = skills.find((item) => item.id === id);
const {
loading: boxLoading,
available: boxAvailable,
hint: boxHint,
reason: boxReason,
} = useBoxStatus();
useEffect(() => {
if (isCreateMode) {
setDetailEntityName(t('skills.createSkill'));
@@ -79,8 +69,6 @@ export default function SkillDetailContent({ id }: { id: string }) {
}
}
if (boxLoading) return <EntityLoadState />;
if (isCreateMode) {
return (
<div className="flex h-full flex-col">
@@ -97,23 +85,12 @@ export default function SkillDetailContent({ id }: { id: string }) {
</div>
</div>
{canManage && (
<Button
type="submit"
form="skill-form"
className="shrink-0"
disabled={!boxAvailable}
>
<Button type="submit" form="skill-form" className="shrink-0">
{t('common.save')}
</Button>
)}
</div>
{!boxAvailable && (
<div className="pb-4 shrink-0">
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
</div>
)}
<div className="min-h-0 flex-1">
<fieldset className="contents" disabled={!canManage}>
<SkillForm
@@ -188,18 +165,12 @@ export default function SkillDetailContent({ id }: { id: string }) {
type="submit"
form="skill-form"
className="shrink-0"
disabled={!boxAvailable}
disabled={!canManage}
>
{t('common.save')}
</Button>
</div>
{!boxAvailable && (
<div className="pb-4 shrink-0">
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
</div>
)}
<div className="min-h-0 flex-1">
<fieldset className="contents" disabled={!canManage}>
<SkillForm
@@ -825,7 +825,11 @@ export default function SkillForm({
try {
if (initSkillName) {
const resp = await httpClient.updateSkill(initSkillName, baseSkillData);
const resp = await httpClient.updateSkill(initSkillName, {
...baseSkillData,
base_revision: skill.revision || '',
});
setSkill(resp.skill);
toast.success(t('skills.saveSuccess'));
onSkillUpdated(resp.skill.name);
} else {
+1 -18
View File
@@ -5,8 +5,6 @@ import { Button } from '@/components/ui/button';
import SkillDetailContent from '@/app/home/skills/SkillDetailContent';
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { useCurrentWorkspace } from '@/app/infra/http';
export default function SkillsPage() {
@@ -21,12 +19,6 @@ export default function SkillsPage() {
const { refreshSkills } = useSidebarData();
const isCreateView = actionParam === 'create';
const {
available: boxAvailable,
hint: boxHint,
reason: boxReason,
} = useBoxStatus();
useEffect(() => {
if (!detailId && !isCreateView) {
navigate('/home/add-extension', { replace: true });
@@ -65,20 +57,11 @@ export default function SkillsPage() {
<Button variant="outline" onClick={handleCancel}>
{t('common.cancel')}
</Button>
<Button
type="submit"
form="skill-form"
disabled={!boxAvailable || !canManage}
>
<Button type="submit" form="skill-form" disabled={!canManage}>
{t('common.save')}
</Button>
</div>
</div>
{!boxAvailable && (
<div className="pb-4 shrink-0">
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
</div>
)}
<div className="min-h-0 flex-1">
<fieldset className="contents" disabled={!canManage}>
<SkillForm
+1
View File
@@ -938,6 +938,7 @@ export interface Skill {
description: string;
instructions?: string;
package_root?: string;
revision?: string;
is_builtin?: boolean;
created_at?: string;
updated_at?: string;
+3 -1
View File
@@ -2475,7 +2475,7 @@ export class BackendClient extends BaseHttpClient {
public updateSkill(
name: string,
skill: Partial<Skill>,
skill: Partial<Skill> & { base_revision: string },
): Promise<ApiRespSkill> {
return this.put(`/api/v1/skills/${name}`, skill);
}
@@ -2539,6 +2539,7 @@ export class BackendClient extends BaseHttpClient {
skillName: string,
filePath: string,
content: string,
baseRevision: string,
): Promise<{
skill: { name: string };
path: string;
@@ -2546,6 +2547,7 @@ export class BackendClient extends BaseHttpClient {
}> {
return this.put(`/api/v1/skills/${skillName}/files/${filePath}`, {
content,
base_revision: baseRevision,
});
}
}
+3 -3
View File
@@ -2263,7 +2263,7 @@ const enUS = {
mcpToolsScopeTooltip:
'Only tools from MCP servers currently allowed in Extensions are shown here.',
skillToolsScopeTooltip:
'Skill tools are available when LangBot skill service and the Box sandbox backend are ready. They let the agent activate or register skills.',
'Skill activation and read-only resources work without Box. Registering a skill from the agent workspace still requires the Box sandbox.',
selectTools: 'Select tools',
resourcesTitle: 'Resources',
resourcesDescription:
@@ -3129,9 +3129,9 @@ const enUS = {
statusDetail: 'Status',
pluginDisabled: 'Plugin system is disabled',
boxDisabled:
'Box sandbox is disabled in config — sandbox tools, skill add/edit, and stdio MCP are unavailable',
'Box sandbox is disabled in config — execution tools and stdio MCP are unavailable',
boxUnavailable:
'Box sandbox is unavailable — sandbox tools, skill add/edit, and stdio MCP are unavailable',
'Box sandbox is unavailable — execution tools and stdio MCP are unavailable',
boxRequiredHint:
'This feature requires the Box runtime. Enable it in config (box.enabled = true) and ensure the runtime is healthy.',
boxBackend: 'Backend',
+3 -3
View File
@@ -2297,7 +2297,7 @@ const esES = {
mcpToolsScopeTooltip:
'Aquí solo se muestran herramientas de servidores MCP permitidos actualmente en Extensiones.',
skillToolsScopeTooltip:
'Las herramientas de skill aparecen cuando el servicio de skills de LangBot y el backend de sandbox Box están disponibles. Permiten al agente activar o registrar skills.',
'La activación y los recursos de solo lectura funcionan sin Box. Registrar un skill desde el espacio del Agent aún requiere el sandbox Box.',
selectTools: 'Seleccionar herramientas',
resourcesTitle: 'Recursos',
resourcesDescription:
@@ -3182,9 +3182,9 @@ const esES = {
statusDetail: 'Estado',
pluginDisabled: 'El sistema de plugins está desactivado',
boxDisabled:
'El sandbox de Box está desactivado en la configuración — herramientas de sandbox, alta/edición de skills y MCP stdio no están disponibles',
'El sandbox de Box está desactivado en la configuración — las herramientas de ejecución y MCP stdio no están disponibles',
boxUnavailable:
'El sandbox de Box no está disponible — herramientas de sandbox, alta/edición de skills y MCP stdio no están disponibles',
'El sandbox de Box no está disponible — las herramientas de ejecución y MCP stdio no están disponibles',
boxRequiredHint:
'Esta función requiere el runtime de Box. Actívelo en la configuración (box.enabled = true) y asegúrese de que el runtime está conectado.',
boxBackend: 'Backend',
+3 -3
View File
@@ -2272,7 +2272,7 @@ const jaJP = {
mcpToolsScopeTooltip:
'拡張機能で現在許可されている MCP サーバーのツールだけが表示されます。',
skillToolsScopeTooltip:
'スキルツールは LangBot のスキルサービスと Box サンドボックスバックエンドが利用可能なときに表示され、Agent がスキルを有効化または登録できるようにします。',
'スキルの有効化と読み取り専用リソースには Box は不要です。Agent ワークスペースからの登録には Box サンドボックスが必要です。',
selectTools: 'ツールを選択',
resourcesTitle: 'リソース',
resourcesDescription:
@@ -3143,9 +3143,9 @@ const jaJP = {
statusDetail: 'ステータス',
pluginDisabled: 'プラグインシステムが無効です',
boxDisabled:
'Box サンドボックスは設定で無効化されています — サンドボックスツール / スキルの追加・編集 / stdio MCP は利用できません',
'Box サンドボックスは設定で無効化されています — 実行ツールと stdio MCP は利用できません',
boxUnavailable:
'Box サンドボックスは利用できません — サンドボックスツール / スキルの追加・編集 / stdio MCP は利用できません',
'Box サンドボックスは利用できません — 実行ツールと stdio MCP は利用できません',
boxRequiredHint:
'この機能には Box ランタイムが必要です。設定で有効化(box.enabled = true)し、ランタイムが正常に接続できることを確認してください。',
boxBackend: 'バックエンド',
+3 -3
View File
@@ -2287,7 +2287,7 @@ const ruRU = {
mcpToolsScopeTooltip:
'Здесь показаны только инструменты MCP-серверов, разрешённых сейчас в Расширениях.',
skillToolsScopeTooltip:
'Инструменты навыков доступны, когда сервис навыков LangBot и backend песочницы Box готовы. Они позволяют агенту активировать или регистрировать навыки.',
'Активация навыков и ресурсы только для чтения работают без Box. Регистрация из рабочей области Agent по-прежнему требует песочницу Box.',
selectTools: 'Выбрать инструменты',
resourcesTitle: 'Ресурсы',
resourcesDescription:
@@ -3151,9 +3151,9 @@ const ruRU = {
statusDetail: 'Статус',
pluginDisabled: 'Система плагинов отключена',
boxDisabled:
'Песочница Box отключена в конфигурации — инструменты песочницы, добавление/редактирование навыков и stdio MCP недоступны',
'Песочница Box отключена в конфигурации — инструменты выполнения и stdio MCP недоступны',
boxUnavailable:
'Песочница Box недоступна — инструменты песочницы, добавление/редактирование навыков и stdio MCP недоступны',
'Песочница Box недоступна — инструменты выполнения и stdio MCP недоступны',
boxRequiredHint:
'Для этой функции требуется среда Box. Включите её в конфигурации (box.enabled = true) и убедитесь, что соединение работает.',
boxBackend: 'Бэкенд',
+3 -3
View File
@@ -2216,7 +2216,7 @@ const thTH = {
mcpToolsScopeTooltip:
'ที่นี่จะแสดงเฉพาะเครื่องมือจากเซิร์ฟเวอร์ MCP ที่อนุญาตอยู่ในส่วนขยาย',
skillToolsScopeTooltip:
'เครื่องมือสกิลจะแสดงเมื่อบริการสกิลของ LangBot และแบ็กเอนด์แซนด์บ็อกซ์ Box พร้อมใช้งาน เพื่อให้ Agent เปิดใช้หรือลงทะเบียนสกิลได้',
'การเปิดใช้สกิลและทรัพยากรแบบอ่านอย่างเดียวทำงานได้โดยไม่ต้องใช้ Box ส่วนการลงทะเบียนจาก workspace ของ Agent ยังต้องใช้ sandbox Box',
selectTools: 'เลือกเครื่องมือ',
resourcesTitle: 'ทรัพยากร',
resourcesDescription: 'เลือกทรัพยากร MCP และคลังความรู้สำหรับ Runner นี้',
@@ -3070,9 +3070,9 @@ const thTH = {
statusDetail: 'สถานะ',
pluginDisabled: 'ระบบปลั๊กอินถูกปิดใช้งาน',
boxDisabled:
'Sandbox Box ถูกปิดใช้งานในการตั้งค่า — เครื่องมือ sandbox, การเพิ่ม/แก้ไข skill และ stdio MCP ใช้งานไม่ได้',
'Sandbox Box ถูกปิดใช้งานในการตั้งค่า — เครื่องมือเรียกใช้และ stdio MCP ใช้งานไม่ได้',
boxUnavailable:
'Sandbox Box ไม่พร้อมใช้งาน — เครื่องมือ sandbox, การเพิ่ม/แก้ไข skill และ stdio MCP ใช้งานไม่ได้',
'Sandbox Box ไม่พร้อมใช้งาน — เครื่องมือเรียกใช้และ stdio MCP ใช้งานไม่ได้',
boxRequiredHint:
'ฟีเจอร์นี้ต้องใช้ Box runtime กรุณาเปิดใช้งานในการตั้งค่า (box.enabled = true) และตรวจสอบว่าการเชื่อมต่อปกติ',
boxBackend: 'แบ็กเอนด์',
+3 -3
View File
@@ -2249,7 +2249,7 @@ const viVN = {
mcpToolsScopeTooltip:
'Tại đây chỉ hiển thị công cụ từ máy chủ MCP hiện được cho phép trong Tiện ích mở rộng.',
skillToolsScopeTooltip:
'Công cụ kỹ năng khả dụng khi dịch vụ kỹ năng LangBot và backend sandbox Box đã sẵn sàng. Chúng cho phép Agent kích hoạt hoặc đăng ký kỹ năng.',
'Kích hoạt kỹ năng và tài nguyên chỉ đọc hoạt động không cần Box. Đăng ký từ workspace của Agent vẫn cần sandbox Box.',
selectTools: 'Chọn công cụ',
resourcesTitle: 'Tài nguyên',
resourcesDescription:
@@ -3110,9 +3110,9 @@ const viVN = {
statusDetail: 'Trạng thái',
pluginDisabled: 'Hệ thống plugin đã tắt',
boxDisabled:
'Sandbox Box đã tắt trong cấu hình — công cụ sandbox, thêm/chỉnh sửa skill và stdio MCP đều không khả dụng',
'Sandbox Box đã tắt trong cấu hình — công cụ thực thi và stdio MCP không khả dụng',
boxUnavailable:
'Sandbox Box không khả dụng — công cụ sandbox, thêm/chỉnh sửa skill và stdio MCP đều không khả dụng',
'Sandbox Box không khả dụng — công cụ thực thi và stdio MCP không khả dụng',
boxRequiredHint:
'Tính năng này cần Box runtime. Hãy bật trong cấu hình (box.enabled = true) và đảm bảo runtime đang hoạt động.',
boxBackend: 'Backend',
+3 -5
View File
@@ -2135,7 +2135,7 @@ const zhHans = {
mcpToolsScopeTooltip:
'这里仅展示扩展集成当前允许的 MCP 服务器提供的工具。',
skillToolsScopeTooltip:
'技能工具会在 LangBot 技能服务和 Box 沙箱后端可用时出现,用于让 Agent 激活或注册技能。',
'技能激活和只读资源无需 Box;Agent 从工作区注册技能仍需要 Box 沙箱。',
selectTools: '选择工具',
resourcesTitle: '资源',
resourcesDescription: '选择当前运行器可以读取的 MCP 资源和知识库。',
@@ -2956,10 +2956,8 @@ const zhHans = {
disabled: '已禁用',
statusDetail: '状态',
pluginDisabled: '插件系统已禁用',
boxDisabled:
'Box 沙箱已在配置中禁用——沙箱工具、技能添加/编辑与 stdio MCP 均不可用',
boxUnavailable:
'Box 沙箱不可用——沙箱工具、技能添加/编辑与 stdio MCP 均不可用',
boxDisabled: 'Box 沙箱已在配置中禁用——执行工具与 stdio MCP 不可用',
boxUnavailable: 'Box 沙箱不可用——执行工具与 stdio MCP 不可用',
boxRequiredHint:
'此功能依赖 Box 运行时。请在配置中启用(box.enabled = true)并确认运行时连接正常。',
boxBackend: '后端',
+3 -5
View File
@@ -2132,7 +2132,7 @@ const zhHant = {
mcpToolsScopeTooltip:
'這裡僅展示擴展集成目前允許的 MCP 伺服器提供的工具。',
skillToolsScopeTooltip:
'技能工具會在 LangBot 技能服務和 Box 沙箱後端可用時出現,用於讓 Agent 啟用或註冊技能。',
'技能啟用和唯讀資源不需要 Box;Agent 從工作區註冊技能仍需要 Box 沙箱。',
selectTools: '選擇工具',
resourcesTitle: '資源',
resourcesDescription: '選擇此內建 Agent 可以讀取的 MCP 資源和知識庫。',
@@ -2957,10 +2957,8 @@ const zhHant = {
disabled: '已停用',
statusDetail: '狀態',
pluginDisabled: '外掛系統已停用',
boxDisabled:
'Box 沙箱已在設定中停用——沙箱工具、技能新增/編輯與 stdio MCP 均無法使用',
boxUnavailable:
'Box 沙箱無法使用——沙箱工具、技能新增/編輯與 stdio MCP 均無法使用',
boxDisabled: 'Box 沙箱已在設定中停用——執行工具與 stdio MCP 無法使用',
boxUnavailable: 'Box 沙箱無法使用——執行工具與 stdio MCP 無法使用',
boxRequiredHint:
'此功能需要 Box 執行時。請在設定中啟用(box.enabled = true)並確認執行時連線正常。',
boxBackend: '後端',