mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 08:25:21 +08:00
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:
+41
-7
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -214,8 +214,9 @@ spec:
|
||||
# Deployment for LangBot Box (sandbox) runtime
|
||||
#
|
||||
# The Box runtime backs LangBot's sandbox tools (exec / read / write / edit /
|
||||
# glob / grep), the `activate` skill tool, skill add/edit, and stdio-mode MCP
|
||||
# servers. It is OPTIONAL: if you do not deploy it, set `BOX__ENABLED=false` on
|
||||
# glob / grep), Skill script execution, and stdio-mode MCP servers. Skill
|
||||
# activation, resources, and management remain Core-owned without Box. Box is
|
||||
# OPTIONAL: if you do not deploy it, set `BOX__ENABLED=false` on
|
||||
# the langbot Deployment (or `box.enabled: false` in config.yaml) so the
|
||||
# dashboard renders cleanly with sandbox features disabled.
|
||||
#
|
||||
@@ -448,14 +449,15 @@ spec:
|
||||
key: token
|
||||
# box.local.* config — forwarded to the Box runtime via INIT RPC. The
|
||||
# host_root MUST match the box-root hostPath mountPath below AND the box
|
||||
# Deployment's box-root mountPath, so that skill package paths resolve
|
||||
# identically on both sides and on the node's Docker daemon.
|
||||
# Deployment's box-root mountPath, so generic package mount paths
|
||||
# resolve identically on both sides and on the node's Docker daemon.
|
||||
- name: BOX__LOCAL__HOST_ROOT
|
||||
value: "/app/data/box"
|
||||
- name: BOX__LOCAL__DEFAULT_WORKSPACE
|
||||
value: "default"
|
||||
- name: BOX__LOCAL__SKILLS_ROOT
|
||||
value: "skills"
|
||||
- name: SKILLS__ROOT
|
||||
# TODO(next-major): use /app/data/skills after the legacy path window.
|
||||
value: "/app/data/box/skills"
|
||||
- name: BOX__LOCAL__ALLOWED_MOUNT_ROOTS
|
||||
value: "/app/data/box"
|
||||
volumeMounts:
|
||||
|
||||
@@ -24,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
|
||||
+18
-49
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 =======
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from .manager import SkillManager
|
||||
from .repository import SkillRepository
|
||||
|
||||
__all__ = ['SkillManager']
|
||||
|
||||
__all__ = ['SkillManager', 'SkillRepository']
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -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:
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'バックエンド',
|
||||
|
||||
@@ -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: 'Бэкенд',
|
||||
|
||||
@@ -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: 'แบ็กเอนด์',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '后端',
|
||||
|
||||
@@ -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: '後端',
|
||||
|
||||
Reference in New Issue
Block a user