mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-07 01:57:15 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fe4b117a4 | |||
| cba842fbee | |||
| a61b0f0068 | |||
| c15f668126 |
+16
-7
@@ -169,14 +169,17 @@ The Plugin Runtime supports stdio and WebSocket control transports. Direct local
|
|||||||
|
|
||||||
## Box Runtime and Skills
|
## Box Runtime and Skills
|
||||||
|
|
||||||
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:
|
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/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/provider/tools/loaders/native.py` is the Core orchestration seam: the
|
||||||
- `pkg/skill/manager.py` loads skills from the Box runtime, falling back to local `data/skills` when needed.
|
Skill loader supplies generic read-only mounts to Box execution. `mcp_stdio.py`
|
||||||
|
and execution-backed tools depend on Box availability.
|
||||||
|
- `pkg/skill/repository.py` is the thin async/Workspace adapter over the Plugin SDK's execution-independent `SkillStore`; `skills.root` owns its location independently of Box.
|
||||||
|
- `pkg/skill/manager.py` caches the Core repository catalog for progressive disclosure. Activation and read-only resource tools do not require Box; script execution and Workspace mutation still do.
|
||||||
|
|
||||||
Durable Box Workspace storage is shared across placement generations, but
|
Durable Box Workspace storage is shared across placement generations, but
|
||||||
sandbox sessions and managed processes are generation-scoped. LangBot validates
|
sandbox sessions and managed processes are generation-scoped. LangBot validates
|
||||||
@@ -187,11 +190,17 @@ retires stale processes and closes already-attached relays.
|
|||||||
In `langbot-plugin-sdk`:
|
In `langbot-plugin-sdk`:
|
||||||
|
|
||||||
- `src/langbot_plugin/box/server.py` implements `lbp box` and the WebSocket endpoints on `:5410`.
|
- `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.
|
- `backend.py`, `nsjail_backend.py`, and `e2b_backend.py` implement sandbox backends.
|
||||||
- `skill_store.py` manages skill packages from the Box side.
|
- `src/langbot_plugin/skill_store.py` is consumed by Core, not Box. Core turns
|
||||||
|
selected package roots into generic read-only mounts; Box does not understand
|
||||||
|
Skill names, metadata, revisions, files, or CRUD.
|
||||||
|
|
||||||
Important config keys live under `box:` in `src/langbot/templates/config.yaml`: `box.enabled`, `box.backend`, `box.runtime.endpoint`, and `box.local.*`. Start LangBot with `--standalone-box` when connecting to an externally launched Box runtime.
|
Skill storage uses `skills.root`. Box execution config lives under `box:`:
|
||||||
|
`box.enabled`, `box.backend`, `box.runtime.endpoint`, and `box.local.*`. The old
|
||||||
|
`box.local.skills_root` key is read only as an online-upgrade fallback and is
|
||||||
|
marked for removal in the next major version. Start LangBot with
|
||||||
|
`--standalone-box` when connecting to an externally launched Box runtime.
|
||||||
|
|
||||||
## HTTP API, Web UI, and MCP Server
|
## 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
|
# The Box sandbox runtime is optional. It is only started when you run
|
||||||
# ``docker compose --profile box up`` (or ``docker compose --profile all
|
# ``docker compose --profile box up`` (or ``docker compose --profile all
|
||||||
# up``). With Box off, LangBot keeps the dashboard / skills list visible
|
# up``). With Box off, LangBot keeps skill management, activation, and
|
||||||
# (read-only) but disables sandbox tools, skill add/edit and stdio MCP —
|
# read-only resources available but disables execution tools and stdio MCP —
|
||||||
# set ``box.enabled: false`` in ``data/config.yaml`` (or
|
# set ``box.enabled: false`` in ``data/config.yaml`` (or
|
||||||
# ``BOX__ENABLED=false`` in the langbot service env below) to match.
|
# ``BOX__ENABLED=false`` in the langbot service env below) to match.
|
||||||
langbot_box:
|
langbot_box:
|
||||||
@@ -73,6 +73,10 @@ services:
|
|||||||
container_name: langbot
|
container_name: langbot
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./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
|
restart: on-failure
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
@@ -92,7 +96,9 @@ services:
|
|||||||
# box.* and are forwarded to the Box runtime via INIT RPC.
|
# box.* and are forwarded to the Box runtime via INIT RPC.
|
||||||
- BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
- BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||||
- BOX__LOCAL__DEFAULT_WORKSPACE=default
|
- 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__LOCAL__ALLOWED_MOUNT_ROOTS=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||||
- BOX__DOCKER__CPU_LIMIT_ENABLED=${LANGBOT_BOX_DOCKER_CPU_LIMIT_ENABLED:-true}
|
- BOX__DOCKER__CPU_LIMIT_ENABLED=${LANGBOT_BOX_DOCKER_CPU_LIMIT_ENABLED:-true}
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -214,8 +214,9 @@ spec:
|
|||||||
# Deployment for LangBot Box (sandbox) runtime
|
# Deployment for LangBot Box (sandbox) runtime
|
||||||
#
|
#
|
||||||
# The Box runtime backs LangBot's sandbox tools (exec / read / write / edit /
|
# 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
|
# glob / grep), Skill script execution, and stdio-mode MCP servers. Skill
|
||||||
# servers. It is OPTIONAL: if you do not deploy it, set `BOX__ENABLED=false` on
|
# 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
|
# the langbot Deployment (or `box.enabled: false` in config.yaml) so the
|
||||||
# dashboard renders cleanly with sandbox features disabled.
|
# dashboard renders cleanly with sandbox features disabled.
|
||||||
#
|
#
|
||||||
@@ -448,14 +449,15 @@ spec:
|
|||||||
key: token
|
key: token
|
||||||
# box.local.* config — forwarded to the Box runtime via INIT RPC. The
|
# 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
|
# host_root MUST match the box-root hostPath mountPath below AND the box
|
||||||
# Deployment's box-root mountPath, so that skill package paths resolve
|
# Deployment's box-root mountPath, so generic package mount paths
|
||||||
# identically on both sides and on the node's Docker daemon.
|
# resolve identically on both sides and on the node's Docker daemon.
|
||||||
- name: BOX__LOCAL__HOST_ROOT
|
- name: BOX__LOCAL__HOST_ROOT
|
||||||
value: "/app/data/box"
|
value: "/app/data/box"
|
||||||
- name: BOX__LOCAL__DEFAULT_WORKSPACE
|
- name: BOX__LOCAL__DEFAULT_WORKSPACE
|
||||||
value: "default"
|
value: "default"
|
||||||
- name: BOX__LOCAL__SKILLS_ROOT
|
- name: SKILLS__ROOT
|
||||||
value: "skills"
|
# TODO(next-major): use /app/data/skills after the legacy path window.
|
||||||
|
value: "/app/data/box/skills"
|
||||||
- name: BOX__LOCAL__ALLOWED_MOUNT_ROOTS
|
- name: BOX__LOCAL__ALLOWED_MOUNT_ROOTS
|
||||||
value: "/app/data/box"
|
value: "/app/data/box"
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
│ │ │ (shared 容器, 多 process) │
|
│ │ │ (shared 容器, 多 process) │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ ├──> SkillToolLoader (activate 工具) │
|
│ │ ├──> SkillToolLoader (activate 工具) │
|
||||||
|
│ │ │ └─ build_execution_mounts() │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ ├──> SkillAuthoringToolLoader │
|
│ │ ├──> SkillAuthoringToolLoader │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
│ ├─ Workspace quota 检查 │
|
│ ├─ Workspace quota 检查 │
|
||||||
│ ├─ 输出截断 (head+tail) │
|
│ ├─ 输出截断 (head+tail) │
|
||||||
│ ├─ Session ID 模板解析 (resolve_box_session_id) │
|
│ ├─ Session ID 模板解析 (resolve_box_session_id) │
|
||||||
│ ├─ 技能挂载组装 (build_skill_extra_mounts) │
|
│ ├─ 通用只读挂载接收 (read_only_mounts) │
|
||||||
│ ├─ 重连循环 (_reconnect_loop, 指数退避) │
|
│ ├─ 重连循环 (_reconnect_loop, 指数退避) │
|
||||||
│ └─ BoxRuntimeConnector │
|
│ └─ BoxRuntimeConnector │
|
||||||
│ ├─ 心跳 loop (20s ping) │
|
│ ├─ 心跳 loop (20s ping) │
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
│ │ Action RPC (stdio 或 WebSocket) │
|
│ │ Action RPC (stdio 或 WebSocket) │
|
||||||
│ │
|
│ │
|
||||||
│ SkillManager (skill_mgr) │
|
│ SkillManager (skill_mgr) │
|
||||||
│ └─ 从 Box runtime 拉取 skills, 不可用时回落 data/skills │
|
│ └─ 从 Core SkillRepository 加载 Workspace-scoped skills │
|
||||||
└──────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
@@ -59,10 +60,8 @@
|
|||||||
│ NsjailBackend ──┘ (本地 CLI 或 fallback 到容器内 CLI) │
|
│ NsjailBackend ──┘ (本地 CLI 或 fallback 到容器内 CLI) │
|
||||||
│ E2BBackend (云沙箱, 需要 E2B_API_KEY) │
|
│ E2BBackend (云沙箱, 需要 E2B_API_KEY) │
|
||||||
│ │
|
│ │
|
||||||
│ BoxSkillStore │
|
│ Generic mount admission │
|
||||||
│ ├─ list / get / create / update / delete │
|
│ └─ allow-list + read-only + normalized target validation │
|
||||||
│ ├─ scan_skill_directory / read_skill_file / write_skill_file │
|
|
||||||
│ └─ preview_skill_zip / install_skill_zip (zip 或 GitHub) │
|
|
||||||
│ │
|
│ │
|
||||||
│ aiohttp 单端口服务 (默认 :5410): │
|
│ aiohttp 单端口服务 (默认 :5410): │
|
||||||
│ /rpc/ws — Action RPC │
|
│ /rpc/ws — Action RPC │
|
||||||
@@ -85,7 +84,7 @@
|
|||||||
**核心设计原则**:
|
**核心设计原则**:
|
||||||
- Box Runtime 作为独立进程运行,通过 Action RPC 与 LangBot 主进程通信,两者复用 SDK 的 IO 层(Handler → Connection → Controller)
|
- Box Runtime 作为独立进程运行,通过 Action RPC 与 LangBot 主进程通信,两者复用 SDK 的 IO 层(Handler → Connection → Controller)
|
||||||
- 一个 session_id 对应一个容器/沙箱实例。同一 session 内可并存多条 mount 与多个 managed process
|
- 一个 session_id 对应一个容器/沙箱实例。同一 session 内可并存多条 mount 与多个 managed process
|
||||||
- Skill / 默认 exec / MCP Server 共享同一个 session 容器(详见 [box-session-scope.md](./box-session-scope.md))
|
- Skill 仅是 Core 组装 mount 的业务来源;Box 与默认 exec / MCP Server 只共享通用 session 和 mount 机制(详见 [box-session-scope.md](./box-session-scope.md))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -93,7 +92,7 @@
|
|||||||
|
|
||||||
### 2.1 BoxService (`pkg/box/service.py`, 722 行)
|
### 2.1 BoxService (`pkg/box/service.py`, 722 行)
|
||||||
|
|
||||||
应用层门面,协调 Profile、安全校验、配额、连接、Skill 挂载与 Session 模板:
|
应用层门面,协调 Profile、安全校验、配额、连接、Core 生成的只读挂载与 Session 模板:
|
||||||
|
|
||||||
主要公开方法(按定义顺序):
|
主要公开方法(按定义顺序):
|
||||||
|
|
||||||
@@ -105,7 +104,7 @@ BoxService
|
|||||||
├─ available (property) 连接状态
|
├─ available (property) 连接状态
|
||||||
│
|
│
|
||||||
├─ resolve_box_session_id(query) 从 pipeline 模板解析 session_id
|
├─ resolve_box_session_id(query) 从 pipeline 模板解析 session_id
|
||||||
├─ build_skill_extra_mounts(query) 组装 pipeline-bound skill 的挂载列表
|
├─ execute_tool(..., read_only_mounts=...) 接收 Core 组装的通用只读挂载
|
||||||
│
|
│
|
||||||
├─ execute_tool(parameters, query) Agent 调用 exec 时的入口
|
├─ execute_tool(parameters, query) Agent 调用 exec 时的入口
|
||||||
│ ├─ _apply_profile / build_spec
|
│ ├─ _apply_profile / build_spec
|
||||||
@@ -122,12 +121,6 @@ BoxService
|
|||||||
├─ stop_managed_process(session_id, pid) 单独停止某个 managed process
|
├─ stop_managed_process(session_id, pid) 单独停止某个 managed process
|
||||||
├─ get_managed_process_websocket_url(...) 返回 WS attach URL
|
├─ 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 + 进程终止
|
├─ shutdown() / dispose() 清理:RPC SHUTDOWN + 进程终止
|
||||||
├─ get_status() / get_sessions() / get_recent_errors()
|
├─ get_status() / get_sessions() / get_recent_errors()
|
||||||
└─ get_system_guidance() LLM 系统提示
|
└─ get_system_guidance() LLM 系统提示
|
||||||
@@ -137,7 +130,7 @@ BoxService
|
|||||||
|
|
||||||
**输出截断**: 默认 4000 字符上限,保留前 60% + 后 40%,中间插入 `[...truncated...]`。
|
**输出截断**: 默认 4000 字符上限,保留前 60% + 后 40%,中间插入 `[...truncated...]`。
|
||||||
|
|
||||||
**Skill 挂载合并**: `execute_tool()` 调用时,`build_skill_extra_mounts(query)` 会把当前 pipeline-bound 的所有 skill 的 `package_root` 作为 `extra_mounts` 加入 BoxSpec,挂在 `/workspace/.skills/<name>`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径。
|
**Skill 挂载合并**: native loader 调用 `skill.build_execution_mounts()`,把当前 pipeline-bound 的所有 skill 的 `package_root` 转成普通只读 mount,再通过 `BoxService.execute_tool(..., read_only_mounts=...)` 交给 Box,挂在 `/workspace/.skills/<name>`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径;BoxService 和 Box Runtime 都不知道这些 mount 来自 Skill。
|
||||||
|
|
||||||
### 2.2 BoxRuntimeConnector (`pkg/box/connector.py`, 357 行)
|
### 2.2 BoxRuntimeConnector (`pkg/box/connector.py`, 357 行)
|
||||||
|
|
||||||
@@ -171,11 +164,10 @@ BoxService
|
|||||||
```
|
```
|
||||||
SkillManager
|
SkillManager
|
||||||
├─ initialize() 调用 reload_skills()
|
├─ initialize() 调用 reload_skills()
|
||||||
├─ reload_skills() 先从 Box runtime list_skills(),
|
├─ reload_skills() 从 Core SkillRepository 加载
|
||||||
│ 不可用则回落 data/skills/ 扫描
|
├─ refresh_skill_from_disk() 检查单 skill 的缓存状态
|
||||||
├─ refresh_skill_from_disk() 单 skill 重新加载
|
|
||||||
├─ get_skill_by_name(name)
|
├─ 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 个)。
|
skill 元数据通过 `parse_frontmatter` 解析 `SKILL.md` 头部(`name` / `description` / `instructions`),不再做整体扫描的代价(典型 < 50 个)。
|
||||||
@@ -295,7 +287,7 @@ start_managed_process(session, spec):
|
|||||||
|
|
||||||
单端口 aiohttp 服务(默认 5410),通过路径区分(commit `8c71ec5` 合并端口):
|
单端口 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
|
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。
|
stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process attach;Action RPC 走 stdin/stdout。
|
||||||
@@ -304,7 +296,7 @@ stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process atta
|
|||||||
|
|
||||||
`ActionRPCBoxClient` 封装 `Handler.call_action()` 调用:
|
`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 侧异常类型
|
- 错误还原: `_translate_action_error()` 通过字符串前缀匹配还原 SDK 侧异常类型
|
||||||
- `execute()` timeout = 300s,其他默认 15s
|
- `execute()` timeout = 300s,其他默认 15s
|
||||||
- `BoxRuntimeClient` 是 ABC,供后续可能的非 RPC 实现复用
|
- `BoxRuntimeClient` 是 ABC,供后续可能的非 RPC 实现复用
|
||||||
@@ -343,23 +335,29 @@ stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process atta
|
|||||||
|
|
||||||
`BoxSpec` 校验器: `workdir` 默认继承 `mount_path`;`host_path` 支持 POSIX 和 Windows 路径;设置 `host_path` 时 `workdir` 必须在 `mount_path` 下。
|
`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)
|
├─ list_skills() / get_skill(name)
|
||||||
├─ create_skill(data) / update_skill(name, data) / delete_skill(name)
|
├─ create_skill(data) / update_skill(name, data) / delete_skill(name)
|
||||||
├─ scan_skill_directory(path) 扫描目录返回候选 skill 包列表
|
├─ scan_skill_directory(path) 扫描目录返回候选 skill 包列表
|
||||||
├─ list_skill_files(name, path) 浏览 skill 内文件树
|
├─ list_skill_resources(name, path, revision) 按 revision 浏览只读资源
|
||||||
├─ read_skill_file(name, path) / write_skill_file(name, path, content)
|
├─ read_skill_resource(name, path, revision) 按 revision 读取 UTF-8 资源
|
||||||
|
├─ read_skill_file(...) / write_skill_file(...) 管理侧文件接口
|
||||||
├─ preview_skill_zip(zip_bytes, ...) 不落盘预览 zip 内容
|
├─ preview_skill_zip(zip_bytes, ...) 不落盘预览 zip 内容
|
||||||
└─ install_skill_zip(zip_bytes, ...) 解压、校验、复制到 skills_root
|
└─ install_skill_zip(zip_bytes, ...) 解压、校验、复制到 skills_root
|
||||||
└─ 支持 source_subdir / target_suffix(commit 1aa043f)
|
└─ 支持 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 行)
|
### 3.8 Security (`box/security.py`, 52 行)
|
||||||
|
|
||||||
@@ -463,7 +461,7 @@ BuildAppStage.run(ap)
|
|||||||
├─ ap.tool_mgr = tool_mgr
|
├─ ap.tool_mgr = tool_mgr
|
||||||
│
|
│
|
||||||
├─ ... (platform, pipeline) ...
|
├─ ... (platform, pipeline) ...
|
||||||
├─ SkillManager.initialize() (从 Box runtime 加载 skill 列表)
|
├─ SkillManager.initialize() (从 Core SkillRepository 加载 skill 列表)
|
||||||
└─ ... (RAG, HTTP, plugins) ...
|
└─ ... (RAG, HTTP, plugins) ...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -480,7 +478,7 @@ except Exception as e:
|
|||||||
logger.warning(f"Box runtime unavailable: {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 销毁流程
|
### 5.3 销毁流程
|
||||||
|
|
||||||
@@ -504,13 +502,16 @@ Box 额外做了 RPC SHUTDOWN 通知 Runtime 主动清理容器,比 Plugin 的
|
|||||||
### config.yaml (重构后)
|
### config.yaml (重构后)
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
skills:
|
||||||
|
root: './data/box/skills' # Core-owned;Box 关闭时仍可管理/读取
|
||||||
|
|
||||||
box:
|
box:
|
||||||
enabled: true # 整个 Box 子系统的总开关。设为 false 时:
|
enabled: true # 整个 Box 子系统的总开关。设为 false 时:
|
||||||
# - 不连接远程 Box runtime,不 fork 本地 stdio 子进程
|
# - 不连接远程 Box runtime,不 fork 本地 stdio 子进程
|
||||||
# - sandbox 工具 (exec/read/write/edit/glob/grep) 不暴露给 LLM
|
# - sandbox 工具 (exec/read/write/edit/glob/grep) 不暴露给 LLM
|
||||||
# - skill 添加/编辑 / GitHub 安装 / 文件写入全部拒绝
|
# - Agent 从 sandbox 注册 skill 的能力不可用
|
||||||
# - stdio 模式的 MCP server 启动时报错(http/sse 模式不受影响)
|
# - stdio 模式的 MCP server 启动时报错(http/sse 模式不受影响)
|
||||||
# - skill 列表/读取保持只读可用
|
# - skill 管理、激活和只读资源保持可用
|
||||||
# BOX__ENABLED 环境变量可覆盖(统一约定)
|
# BOX__ENABLED 环境变量可覆盖(统一约定)
|
||||||
backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b'
|
backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b'
|
||||||
# 由 box.backend / BOX__BACKEND 选择后端
|
# 由 box.backend / BOX__BACKEND 选择后端
|
||||||
@@ -522,7 +523,6 @@ box:
|
|||||||
image: '' # 覆盖 profile 默认 image
|
image: '' # 覆盖 profile 默认 image
|
||||||
host_root: './data/box' # 工作区挂载根,Docker 部署需绝对路径
|
host_root: './data/box' # 工作区挂载根,Docker 部署需绝对路径
|
||||||
default_workspace: '' # 默认 '<host_root>/default'
|
default_workspace: '' # 默认 '<host_root>/default'
|
||||||
skills_root: 'skills' # Box 管理的 skill 包目录(相对 host_root)
|
|
||||||
allowed_mount_roots: # 默认 ['<host_root>']
|
allowed_mount_roots: # 默认 ['<host_root>']
|
||||||
- './data/box'
|
- './data/box'
|
||||||
- '/tmp'
|
- '/tmp'
|
||||||
@@ -561,16 +561,17 @@ volumes:
|
|||||||
| 消费方 | Box 可用 | Box 不可用(disabled 或 failed) |
|
| 消费方 | Box 可用 | Box 不可用(disabled 或 failed) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| native exec/read/write/edit/glob/grep 工具 | 暴露给 LLM | **不暴露** |
|
| 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 |
|
| stdio MCP server | 在 Box 内启动 | **`_init_stdio_python_server` 抛 RuntimeError** 拒绝;不退化到宿主 stdio |
|
||||||
| http/sse MCP server | 正常 | 正常(不依赖 Box) |
|
| http/sse MCP server | 正常 | 正常(不依赖 Box) |
|
||||||
| Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Box runtime | 走 LangBot 本地 `data/skills/` 只读 fallback |
|
| Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Core SkillRepository | 走 Core SkillRepository |
|
||||||
| Skill 创建/编辑/安装/写文件 | 走 Box runtime | **HTTP 400** + 明确错误信息(`_require_box_for_write`) |
|
| Skill 创建/编辑/安装/写文件 | 走 Core SkillRepository | 走 Core SkillRepository |
|
||||||
| Pipeline AI 配置中 `box-session-id-template` | 正常生效 | **前端 banner** 提示字段无效 |
|
| Pipeline AI 配置中 `box-session-id-template` | 正常生效 | **前端 banner** 提示字段无效 |
|
||||||
| Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | **前端禁用** + banner |
|
| Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | 可编辑 |
|
||||||
| 仪表盘 Box 状态卡片 | 绿点 / "已连接" | 灰点 / "已禁用"(disabled) 或 红点 / "已断开"(failed) |
|
| 仪表盘 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。
|
||||||
|
|
||||||
### Pipeline 配置 (templates/metadata/pipeline/ai.yaml)
|
### Pipeline 配置 (templates/metadata/pipeline/ai.yaml)
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,8 @@
|
|||||||
### S5. 挂载校验缺口 — Med-High
|
### S5. 挂载校验缺口 — Med-High
|
||||||
|
|
||||||
- **位置**: SDK `box/security.py` `_BLOCKED_HOST_PATHS_POSIX`;`box/backend.py` 的 `extra_mounts` 处理
|
- **位置**: 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 可挂任意宿主路径,两层都不拦。
|
- **现状**: grant-enforced 模式已经由 Core 与 Runtime 双重校验通用只读 mount(绝对路径 allow-list、存在性、只读模式、规范化且位于 `/workspace` 下的目标);它不再有 Skill 特例。遗留风险仅在 admission-disabled 的低信任直连场景:通用 `extra_mounts` 仍未统一套用 grant-enforced 白名单。
|
||||||
- **要求**: SDK 黑名单加入 `/`(或改白名单);`extra_mounts` 在 SDK 与 LangBot 两侧都纳入挂载校验。
|
- **要求**: admission-disabled 的外部控制面也复用同一套通用 mount 校验;SDK 黑名单加入 `/`(或全面改白名单)。
|
||||||
|
|
||||||
### S6. 容器加固缺失 — Med
|
### S6. 容器加固缺失 — Med
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ has shipped the design largely as written:
|
|||||||
| Docker / nsjail / E2B backends apply extra mounts | ✅ Shipped | Last gap closed by SDK commit `0fea9b1` (E2B) |
|
| Docker / nsjail / E2B backends apply extra mounts | ✅ Shipped | Last gap closed by SDK commit `0fea9b1` (E2B) |
|
||||||
| `box-session-id-template` in `local-agent` pipeline config | ✅ Shipped | `templates/metadata/pipeline/ai.yaml`, default `{launcher_type}_{launcher_id}` |
|
| `box-session-id-template` in `local-agent` pipeline config | ✅ Shipped | `templates/metadata/pipeline/ai.yaml`, default `{launcher_type}_{launcher_id}` |
|
||||||
| `BoxService.resolve_box_session_id(query)` | ✅ Shipped | `pkg/box/service.py:166` |
|
| `BoxService.resolve_box_session_id(query)` | ✅ Shipped | `pkg/box/service.py:166` |
|
||||||
| `BoxService.build_skill_extra_mounts(query)` | ✅ Shipped | `pkg/box/service.py:189` |
|
| `skill.build_execution_mounts(ap, query)` | ✅ Shipped | Core composes read-only packages; Box receives generic mounts |
|
||||||
| Skill exec uses unified container + extra mounts | ✅ Shipped | `pkg/provider/tools/loaders/native.py` skill branch |
|
| Skill exec uses unified container + extra mounts | ✅ Shipped | `pkg/provider/tools/loaders/native.py` skill branch |
|
||||||
| MCP-in-Box uses shared persistent session, multi-process | ✅ Shipped (earlier than originally scoped) | SDK commit `529088e`, LangBot `mcp_stdio.py:_build_box_session_id` |
|
| MCP-in-Box uses shared persistent session, multi-process | ✅ Shipped (earlier than originally scoped) | SDK commit `529088e`, LangBot `mcp_stdio.py:_build_box_session_id` |
|
||||||
| `BoxManagedProcessSpec.process_id` + multi-process per session | ✅ Shipped | `BoxRuntime` keeps `managed_processes: dict[pid, _ManagedProcess]` |
|
| `BoxManagedProcessSpec.process_id` + multi-process per session | ✅ Shipped | `BoxRuntime` keeps `managed_processes: dict[pid, _ManagedProcess]` |
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
| BoxService workspace quota | 优秀 | 前置/后置配额检查、超额清理 |
|
| BoxService workspace quota | 优秀 | 前置/后置配额检查、超额清理 |
|
||||||
| BoxService 输出截断 | 优秀 | 短/精确边界/长输出、独立 stderr |
|
| BoxService 输出截断 | 优秀 | 短/精确边界/长输出、独立 stderr |
|
||||||
| BoxService 可观测性 | 优秀 | 状态报告、error ring buffer、buffer 上限 |
|
| BoxService 可观测性 | 优秀 | 状态报告、error ring buffer、buffer 上限 |
|
||||||
| BoxService session 模板 | 良好 | `resolve_box_session_id` + `build_skill_extra_mounts` 在 service / native / mcp 三处都有覆盖 |
|
| BoxService session / mount contract | 良好 | `resolve_box_session_id` + generic `read_only_mounts`; Skill mount composition is covered in the Core loader |
|
||||||
| RPC client/server 协议 | 优秀 | execute/get_sessions/delete/create/conflict error |
|
| RPC client/server 协议 | 优秀 | execute/get_sessions/delete/create/conflict error |
|
||||||
| BoxRuntimeConnector | 良好 | local/remote 模式、Docker 平台、relay URL、心跳与重连回调 |
|
| BoxRuntimeConnector | 良好 | local/remote 模式、Docker 平台、relay URL、心跳与重连回调 |
|
||||||
| BoxWorkspaceSession | 良好 | payload 构建、managed process 路径重写、stage host file |
|
| BoxWorkspaceSession | 良好 | payload 构建、managed process 路径重写、stage host file |
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import quart
|
import quart
|
||||||
|
|
||||||
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError
|
|
||||||
from langbot_plugin.box.errors import BoxError
|
|
||||||
|
|
||||||
from ...authz import Permission
|
from ...authz import Permission
|
||||||
from ...context import RequestContext
|
from ...context import RequestContext
|
||||||
from .. import group
|
from .. import group
|
||||||
@@ -24,12 +21,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
|||||||
async def list_skills(request_context: RequestContext) -> quart.Response:
|
async def list_skills(request_context: RequestContext) -> quart.Response:
|
||||||
try:
|
try:
|
||||||
skills = await self.ap.skill_service.list_skills(request_context)
|
skills = await self.ap.skill_service.list_skills(request_context)
|
||||||
except EntitlementFeatureUnavailableError:
|
except ValueError as exc:
|
||||||
# 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:
|
|
||||||
return self.http_status(400, -1, str(exc))
|
return self.http_status(400, -1, str(exc))
|
||||||
return self.success(data={'skills': skills})
|
return self.success(data={'skills': skills})
|
||||||
|
|
||||||
@@ -47,7 +39,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
|||||||
try:
|
try:
|
||||||
skill = await self.ap.skill_service.create_skill(request_context, data)
|
skill = await self.ap.skill_service.create_skill(request_context, data)
|
||||||
return self.success(data={'skill': skill})
|
return self.success(data={'skill': skill})
|
||||||
except (ValueError, BoxError) as exc:
|
except ValueError as exc:
|
||||||
return self.http_status(400, -1, str(exc))
|
return self.http_status(400, -1, str(exc))
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
@@ -59,7 +51,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
|||||||
async def get_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
|
async def get_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
|
||||||
try:
|
try:
|
||||||
skill = await self.ap.skill_service.get_skill(request_context, skill_name)
|
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))
|
return self.http_status(400, -1, str(exc))
|
||||||
if not skill:
|
if not skill:
|
||||||
return self.http_status(404, -1, 'Skill not found')
|
return self.http_status(404, -1, 'Skill not found')
|
||||||
@@ -77,13 +69,13 @@ class SkillsRouterGroup(group.RouterGroup):
|
|||||||
try:
|
try:
|
||||||
skill = await self.ap.skill_service.update_skill(request_context, skill_name, data)
|
skill = await self.ap.skill_service.update_skill(request_context, skill_name, data)
|
||||||
return self.success(data={'skill': skill})
|
return self.success(data={'skill': skill})
|
||||||
except (ValueError, BoxError) as exc:
|
except ValueError as exc:
|
||||||
return self.http_status(400, -1, str(exc))
|
return self.http_status(400, -1, str(exc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.ap.skill_service.delete_skill(request_context, skill_name)
|
await self.ap.skill_service.delete_skill(request_context, skill_name)
|
||||||
return self.success()
|
return self.success()
|
||||||
except (ValueError, BoxError) as exc:
|
except ValueError as exc:
|
||||||
return self.http_status(400, -1, str(exc))
|
return self.http_status(400, -1, str(exc))
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
@@ -105,7 +97,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
|||||||
include_hidden=include_hidden,
|
include_hidden=include_hidden,
|
||||||
)
|
)
|
||||||
return self.success(data=result)
|
return self.success(data=result)
|
||||||
except (ValueError, BoxError) as exc:
|
except ValueError as exc:
|
||||||
return self.http_status(400, -1, str(exc))
|
return self.http_status(400, -1, str(exc))
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
@@ -118,7 +110,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
|||||||
try:
|
try:
|
||||||
result = await self.ap.skill_service.read_skill_file(request_context, skill_name, path)
|
result = await self.ap.skill_service.read_skill_file(request_context, skill_name, path)
|
||||||
return self.success(data=result)
|
return self.success(data=result)
|
||||||
except (ValueError, BoxError) as exc:
|
except ValueError as exc:
|
||||||
return self.http_status(400, -1, str(exc))
|
return self.http_status(400, -1, str(exc))
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
@@ -136,7 +128,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
|||||||
try:
|
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)
|
||||||
return self.success(data=result)
|
return self.success(data=result)
|
||||||
except (ValueError, BoxError) as exc:
|
except ValueError as exc:
|
||||||
return self.http_status(400, -1, str(exc))
|
return self.http_status(400, -1, str(exc))
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
@@ -170,7 +162,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
|||||||
try:
|
try:
|
||||||
skill = await self.ap.skill_service.install_from_github(request_context, data)
|
skill = await self.ap.skill_service.install_from_github(request_context, data)
|
||||||
return self.success(data={'skills': skill})
|
return self.success(data={'skills': skill})
|
||||||
except (ValueError, BoxError) as exc:
|
except ValueError as exc:
|
||||||
return self.http_status(400, -1, str(exc))
|
return self.http_status(400, -1, str(exc))
|
||||||
except Exception:
|
except Exception:
|
||||||
raise
|
raise
|
||||||
@@ -194,7 +186,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
|||||||
try:
|
try:
|
||||||
preview = await self.ap.skill_service.preview_install_from_github(request_context, data)
|
preview = await self.ap.skill_service.preview_install_from_github(request_context, data)
|
||||||
return self.success(data={'skills': preview})
|
return self.success(data={'skills': preview})
|
||||||
except (ValueError, BoxError) as exc:
|
except ValueError as exc:
|
||||||
return self.http_status(400, -1, str(exc))
|
return self.http_status(400, -1, str(exc))
|
||||||
except Exception:
|
except Exception:
|
||||||
raise
|
raise
|
||||||
@@ -219,7 +211,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
|||||||
source_paths=form.getlist('source_paths'),
|
source_paths=form.getlist('source_paths'),
|
||||||
)
|
)
|
||||||
return self.success(data={'skills': skill})
|
return self.success(data={'skills': skill})
|
||||||
except (ValueError, BoxError) as exc:
|
except ValueError as exc:
|
||||||
return self.http_status(400, -1, str(exc))
|
return self.http_status(400, -1, str(exc))
|
||||||
except Exception:
|
except Exception:
|
||||||
raise
|
raise
|
||||||
@@ -242,7 +234,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
|||||||
filename=file.filename or '',
|
filename=file.filename or '',
|
||||||
)
|
)
|
||||||
return self.success(data={'skills': preview})
|
return self.success(data={'skills': preview})
|
||||||
except (ValueError, BoxError) as exc:
|
except ValueError as exc:
|
||||||
return self.http_status(400, -1, str(exc))
|
return self.http_status(400, -1, str(exc))
|
||||||
except Exception:
|
except Exception:
|
||||||
raise
|
raise
|
||||||
@@ -261,5 +253,5 @@ class SkillsRouterGroup(group.RouterGroup):
|
|||||||
try:
|
try:
|
||||||
result = await self.ap.skill_service.scan_directory_async(request_context, path)
|
result = await self.ap.skill_service.scan_directory_async(request_context, path)
|
||||||
return self.success(data=result)
|
return self.success(data=result)
|
||||||
except (ValueError, BoxError) as exc:
|
except ValueError as exc:
|
||||||
return self.http_status(400, -1, str(exc))
|
return self.http_status(400, -1, str(exc))
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ _PUBLIC_SKILL_FIELDS = (
|
|||||||
'description',
|
'description',
|
||||||
'instructions',
|
'instructions',
|
||||||
'package_root',
|
'package_root',
|
||||||
|
'revision',
|
||||||
'created_at',
|
'created_at',
|
||||||
'updated_at',
|
'updated_at',
|
||||||
)
|
)
|
||||||
@@ -53,38 +54,11 @@ class SkillService:
|
|||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
|
||||||
def _box_service(self):
|
def _repository(self):
|
||||||
box_service = getattr(self.ap, 'box_service', None)
|
repository = getattr(self.ap, 'skill_repository', None)
|
||||||
if box_service is not None and getattr(box_service, 'available', False):
|
if repository is None:
|
||||||
return box_service
|
raise ValueError('Skill repository is not initialised')
|
||||||
return None
|
return repository
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
async def _execution_context(self, context: TenantContext) -> ExecutionContext:
|
async def _execution_context(self, context: TenantContext) -> ExecutionContext:
|
||||||
workspace_uuid = require_workspace_uuid(context)
|
workspace_uuid = require_workspace_uuid(context)
|
||||||
@@ -113,20 +87,11 @@ class SkillService:
|
|||||||
|
|
||||||
async def list_skills(self, context: TenantContext) -> list[dict]:
|
async def list_skills(self, context: TenantContext) -> list[dict]:
|
||||||
execution_context = await self._execution_context(context)
|
execution_context = await self._execution_context(context)
|
||||||
# When Box is unavailable, surface an empty list rather than raising —
|
return [self._serialize_skill(skill) for skill in await self._repository().list_skills(execution_context)]
|
||||||
# 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 []
|
|
||||||
return [self._serialize_skill(skill) for skill in await box_service.list_skills(execution_context)]
|
|
||||||
|
|
||||||
async def get_skill(self, context: TenantContext, skill_name: str) -> Optional[dict]:
|
async def get_skill(self, context: TenantContext, skill_name: str) -> Optional[dict]:
|
||||||
execution_context = await self._execution_context(context)
|
execution_context = await self._execution_context(context)
|
||||||
box_service = self._box_service()
|
skill = await self._repository().get_skill(execution_context, skill_name, snapshot=True)
|
||||||
if box_service is None:
|
|
||||||
return None
|
|
||||||
skill = await box_service.get_skill(execution_context, skill_name)
|
|
||||||
return self._serialize_skill(skill) if skill else None
|
return self._serialize_skill(skill) if skill else None
|
||||||
|
|
||||||
async def get_skill_by_name(self, context: TenantContext, name: str) -> Optional[dict]:
|
async def get_skill_by_name(self, context: TenantContext, name: str) -> Optional[dict]:
|
||||||
@@ -134,22 +99,25 @@ class SkillService:
|
|||||||
|
|
||||||
async def create_skill(self, context: TenantContext, data: dict) -> dict:
|
async def create_skill(self, context: TenantContext, data: dict) -> dict:
|
||||||
execution_context = await self._execution_context(context)
|
execution_context = await self._execution_context(context)
|
||||||
box_service = self._require_box('Creating a skill')
|
created = await self._repository().create_skill(execution_context, data)
|
||||||
created = await box_service.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) -> dict:
|
||||||
|
execution_context = await self._execution_context(context)
|
||||||
|
created = await self._repository().import_skill_directory(execution_context, path, data)
|
||||||
await self._reload_skills(execution_context)
|
await self._reload_skills(execution_context)
|
||||||
return self._serialize_skill(created)
|
return self._serialize_skill(created)
|
||||||
|
|
||||||
async def update_skill(self, context: TenantContext, skill_name: str, data: dict) -> dict:
|
async def update_skill(self, context: TenantContext, skill_name: str, data: dict) -> dict:
|
||||||
execution_context = await self._execution_context(context)
|
execution_context = await self._execution_context(context)
|
||||||
box_service = self._require_box('Editing a skill')
|
updated = await self._repository().update_skill(execution_context, skill_name, data)
|
||||||
updated = await box_service.update_skill(execution_context, skill_name, data)
|
|
||||||
await self._reload_skills(execution_context)
|
await self._reload_skills(execution_context)
|
||||||
return self._serialize_skill(updated)
|
return self._serialize_skill(updated)
|
||||||
|
|
||||||
async def delete_skill(self, context: TenantContext, skill_name: str) -> bool:
|
async def delete_skill(self, context: TenantContext, skill_name: str) -> bool:
|
||||||
execution_context = await self._execution_context(context)
|
execution_context = await self._execution_context(context)
|
||||||
box_service = self._require_box('Deleting a skill')
|
await self._repository().delete_skill(execution_context, skill_name)
|
||||||
await box_service.delete_skill(execution_context, skill_name)
|
|
||||||
await self._reload_skills(execution_context)
|
await self._reload_skills(execution_context)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -162,24 +130,27 @@ class SkillService:
|
|||||||
max_entries: int = 200,
|
max_entries: int = 200,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
execution_context = await self._execution_context(context)
|
execution_context = await self._execution_context(context)
|
||||||
box_service = self._require_box('Browsing skill files')
|
return await self._repository().list_skill_files(
|
||||||
return await box_service.list_skill_files(execution_context, skill_name, path, include_hidden, max_entries)
|
execution_context,
|
||||||
|
skill_name,
|
||||||
|
path,
|
||||||
|
include_hidden,
|
||||||
|
max_entries,
|
||||||
|
)
|
||||||
|
|
||||||
async def read_skill_file(self, context: TenantContext, skill_name: str, path: str) -> dict:
|
async def read_skill_file(self, context: TenantContext, skill_name: str, path: str) -> dict:
|
||||||
execution_context = await self._execution_context(context)
|
execution_context = await self._execution_context(context)
|
||||||
box_service = self._require_box('Reading a skill file')
|
return await self._repository().read_skill_file(execution_context, skill_name, path)
|
||||||
return await box_service.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) -> dict:
|
||||||
execution_context = await self._execution_context(context)
|
execution_context = await self._execution_context(context)
|
||||||
box_service = self._require_box('Editing skill files')
|
result = await self._repository().write_skill_file(execution_context, skill_name, path, content)
|
||||||
result = await box_service.write_skill_file(execution_context, skill_name, path, content)
|
|
||||||
await self._reload_skills(execution_context)
|
await self._reload_skills(execution_context)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
|
async def install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
|
||||||
execution_context = await self._execution_context(context)
|
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()
|
owner = str(data['owner']).strip()
|
||||||
repo = str(data['repo']).strip()
|
repo = str(data['repo']).strip()
|
||||||
release_tag = str(data.get('release_tag', '')).strip()
|
release_tag = str(data.get('release_tag', '')).strip()
|
||||||
@@ -198,7 +169,7 @@ class SkillService:
|
|||||||
|
|
||||||
zip_bytes = await self._download_github_asset(asset_url)
|
zip_bytes = await self._download_github_asset(asset_url)
|
||||||
filename = f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip'
|
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,
|
execution_context,
|
||||||
zip_bytes,
|
zip_bytes,
|
||||||
filename,
|
filename,
|
||||||
@@ -211,7 +182,7 @@ class SkillService:
|
|||||||
|
|
||||||
async def preview_install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
|
async def preview_install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
|
||||||
execution_context = await self._execution_context(context)
|
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()
|
owner = str(data['owner']).strip()
|
||||||
repo = str(data['repo']).strip()
|
repo = str(data['repo']).strip()
|
||||||
release_tag = str(data.get('release_tag', '')).strip()
|
release_tag = str(data.get('release_tag', '')).strip()
|
||||||
@@ -228,7 +199,7 @@ class SkillService:
|
|||||||
source_subdir = str(data.get('source_subdir', '') or '').strip()
|
source_subdir = str(data.get('source_subdir', '') or '').strip()
|
||||||
|
|
||||||
zip_bytes = await self._download_github_asset(asset_url)
|
zip_bytes = await self._download_github_asset(asset_url)
|
||||||
return await box_service.preview_skill_zip(
|
return await repository.preview_skill_zip(
|
||||||
execution_context,
|
execution_context,
|
||||||
zip_bytes,
|
zip_bytes,
|
||||||
f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip',
|
f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip',
|
||||||
@@ -245,8 +216,7 @@ class SkillService:
|
|||||||
source_path: str = '',
|
source_path: str = '',
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
execution_context = await self._execution_context(context)
|
execution_context = await self._execution_context(context)
|
||||||
box_service = self._require_box('Installing a skill from upload')
|
installed = await self._repository().install_skill_zip(
|
||||||
installed = await box_service.install_skill_zip(
|
|
||||||
execution_context,
|
execution_context,
|
||||||
file_bytes,
|
file_bytes,
|
||||||
filename,
|
filename,
|
||||||
@@ -264,8 +234,7 @@ class SkillService:
|
|||||||
filename: str,
|
filename: str,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
execution_context = await self._execution_context(context)
|
execution_context = await self._execution_context(context)
|
||||||
box_service = self._require_box('Previewing a skill upload')
|
return await self._repository().preview_skill_zip(execution_context, file_bytes, filename)
|
||||||
return await box_service.preview_skill_zip(execution_context, file_bytes, filename)
|
|
||||||
|
|
||||||
async def _install_github_skill_md(
|
async def _install_github_skill_md(
|
||||||
self,
|
self,
|
||||||
@@ -276,14 +245,14 @@ class SkillService:
|
|||||||
repo: str,
|
repo: str,
|
||||||
data: dict,
|
data: dict,
|
||||||
) -> list[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(
|
zip_bytes, filename, _package_name = await self._download_github_skill_directory_as_zip(
|
||||||
asset_url,
|
asset_url,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
repo=repo,
|
repo=repo,
|
||||||
)
|
)
|
||||||
|
|
||||||
installed = await box_service.install_skill_zip(
|
installed = await repository.install_skill_zip(
|
||||||
context,
|
context,
|
||||||
zip_bytes,
|
zip_bytes,
|
||||||
filename,
|
filename,
|
||||||
@@ -302,13 +271,13 @@ class SkillService:
|
|||||||
owner: str,
|
owner: str,
|
||||||
repo: str,
|
repo: str,
|
||||||
) -> list[dict]:
|
) -> 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(
|
zip_bytes, _filename, package_name = await self._download_github_skill_directory_as_zip(
|
||||||
asset_url,
|
asset_url,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
repo=repo,
|
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]:
|
async def reload_skills(self, context: TenantContext) -> list[dict]:
|
||||||
execution_context = await self._execution_context(context)
|
execution_context = await self._execution_context(context)
|
||||||
@@ -317,8 +286,7 @@ class SkillService:
|
|||||||
|
|
||||||
async def scan_directory_async(self, context: TenantContext, path: str) -> dict:
|
async def scan_directory_async(self, context: TenantContext, path: str) -> dict:
|
||||||
execution_context = await self._execution_context(context)
|
execution_context = await self._execution_context(context)
|
||||||
box_service = self._require_box('Scanning a skill directory')
|
return await self._repository().scan_skill_directory(execution_context, path)
|
||||||
return await box_service.scan_skill_directory(execution_context, path)
|
|
||||||
|
|
||||||
async def _reload_skills(self, context: TenantContext) -> None:
|
async def _reload_skills(self, context: TenantContext) -> None:
|
||||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||||
|
|||||||
@@ -147,10 +147,9 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
- An explicit ``runtime.endpoint`` was configured
|
- An explicit ``runtime.endpoint`` was configured
|
||||||
|
|
||||||
When this is True the Box runtime lives in a separate process with its
|
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
|
own filesystem view (container, pod sidecar, or remote host), so only
|
||||||
it reports (e.g. skill ``package_root``) are NOT resolvable on the
|
explicitly shared paths are usable on both sides. When False, Box runs
|
||||||
LangBot side. When False, Box runs as a stdio child process that shares
|
as a stdio child process that shares LangBot's filesystem.
|
||||||
LangBot's filesystem.
|
|
||||||
"""
|
"""
|
||||||
return bool(
|
return bool(
|
||||||
self.configured_runtime_endpoint
|
self.configured_runtime_endpoint
|
||||||
|
|||||||
+45
-211
@@ -28,8 +28,10 @@ from langbot_plugin.box.errors import BoxAdmissionError, BoxError, BoxValidation
|
|||||||
from langbot_plugin.box.models import (
|
from langbot_plugin.box.models import (
|
||||||
BUILTIN_PROFILES,
|
BUILTIN_PROFILES,
|
||||||
BoxExecutionResult,
|
BoxExecutionResult,
|
||||||
|
BoxHostMountMode,
|
||||||
BoxManagedProcessInfo,
|
BoxManagedProcessInfo,
|
||||||
BoxManagedProcessSpec,
|
BoxManagedProcessSpec,
|
||||||
|
BoxMountSpec,
|
||||||
BoxProfile,
|
BoxProfile,
|
||||||
BoxSpec,
|
BoxSpec,
|
||||||
)
|
)
|
||||||
@@ -169,7 +171,7 @@ class BoxService:
|
|||||||
self._connector_error = 'Box runtime is disabled in config (box.enabled = false)'
|
self._connector_error = 'Box runtime is disabled in config (box.enabled = false)'
|
||||||
self.ap.logger.info(
|
self.ap.logger.info(
|
||||||
'Box runtime disabled by config; sandbox features (exec/read/write/edit, '
|
'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
|
return
|
||||||
try:
|
try:
|
||||||
@@ -275,10 +277,6 @@ class BoxService:
|
|||||||
await self._purge_attachment_dirs()
|
await self._purge_attachment_dirs()
|
||||||
self._available = True
|
self._available = True
|
||||||
self._connector_error = ''
|
self._connector_error = ''
|
||||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
|
||||||
reload_skills = getattr(skill_mgr, 'reload_skills', None)
|
|
||||||
if callable(reload_skills) and not self._cloud_managed:
|
|
||||||
await reload_skills()
|
|
||||||
self.ap.logger.info('Box runtime reconnected, sandbox features restored.')
|
self.ap.logger.info('Box runtime reconnected, sandbox features restored.')
|
||||||
return
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -358,19 +356,17 @@ class BoxService:
|
|||||||
"""Whether LangBot and the Box runtime share a filesystem view.
|
"""Whether LangBot and the Box runtime share a filesystem view.
|
||||||
|
|
||||||
This is True only when Box runs as a local stdio child process of
|
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
|
LangBot (same container/host). In that case host paths resolve
|
||||||
reports — notably skill ``package_root`` — resolve identically on the
|
identically on both sides and Core may perform local filesystem work.
|
||||||
LangBot side, so LangBot may validate them against its own filesystem.
|
|
||||||
|
|
||||||
It is False for every separated deployment (Docker Compose, k8s
|
It is False for every separated deployment (Docker Compose, k8s
|
||||||
sidecar, ``--standalone-box``, or an explicit ``runtime.endpoint``),
|
sidecar, ``--standalone-box``, or an explicit ``runtime.endpoint``),
|
||||||
where the Box runtime owns its own filesystem and LangBot must trust
|
where only explicitly shared and identically mounted roots can cross
|
||||||
the paths it reports rather than checking them locally.
|
the process boundary.
|
||||||
|
|
||||||
When Box is wired up with an injected client (tests, custom embeds)
|
When Box is wired up with an injected client (tests, custom embeds)
|
||||||
there is no connector to introspect; we conservatively report False so
|
there is no connector to introspect; we conservatively report False.
|
||||||
LangBot never wrongly drops Box-reported skills. An explicit override
|
An explicit override can be set via ``_shares_filesystem_with_box`` (used by tests and any
|
||||||
can be set via ``_shares_filesystem_with_box`` (used by tests and any
|
|
||||||
embedder that knows the real topology).
|
embedder that knows the real topology).
|
||||||
"""
|
"""
|
||||||
if self._shares_filesystem_with_box_override is not None:
|
if self._shares_filesystem_with_box_override is not None:
|
||||||
@@ -483,11 +479,18 @@ class BoxService:
|
|||||||
self,
|
self,
|
||||||
context: TenantContext,
|
context: TenantContext,
|
||||||
spec_payload: dict,
|
spec_payload: dict,
|
||||||
|
*,
|
||||||
|
trusted_read_only_mounts: list[dict] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Reject tenant-owned policy fields and apply the Cloud hard policy."""
|
"""Reject tenant-owned policy fields and apply the Cloud hard policy."""
|
||||||
|
|
||||||
payload = dict(spec_payload)
|
payload = dict(spec_payload)
|
||||||
|
trusted_mounts = self._normalize_trusted_read_only_mounts(trusted_read_only_mounts or [])
|
||||||
if not self._cloud_managed:
|
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
|
return payload
|
||||||
policy = self._admission_policy
|
policy = self._admission_policy
|
||||||
if policy is None:
|
if policy is None:
|
||||||
@@ -537,7 +540,7 @@ class BoxService:
|
|||||||
'network': 'off',
|
'network': 'off',
|
||||||
'host_path': canonical_host_path,
|
'host_path': canonical_host_path,
|
||||||
'mount_path': '/workspace',
|
'mount_path': '/workspace',
|
||||||
'extra_mounts': [],
|
'extra_mounts': trusted_mounts,
|
||||||
'persistent': True,
|
'persistent': True,
|
||||||
'timeout_sec': min(timeout, policy.max_timeout_sec),
|
'timeout_sec': min(timeout, policy.max_timeout_sec),
|
||||||
'cpus': policy.cpus,
|
'cpus': policy.cpus,
|
||||||
@@ -549,6 +552,22 @@ class BoxService:
|
|||||||
)
|
)
|
||||||
return payload
|
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:
|
def _reject_cloud_managed_process(self) -> None:
|
||||||
if self._cloud_managed:
|
if self._cloud_managed:
|
||||||
raise BoxAdmissionError('Managed processes are disabled for Cloud sandboxes')
|
raise BoxAdmissionError('Managed processes are disabled for Cloud sandboxes')
|
||||||
@@ -565,13 +584,18 @@ class BoxService:
|
|||||||
query: pipeline_query.Query,
|
query: pipeline_query.Query,
|
||||||
*,
|
*,
|
||||||
skip_host_mount_validation: bool = False,
|
skip_host_mount_validation: bool = False,
|
||||||
|
trusted_read_only_mounts: list[dict] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if not self._available:
|
if not self._available:
|
||||||
raise BoxError(
|
raise BoxError(
|
||||||
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||||
)
|
)
|
||||||
execution_context = await self._validated_execution_context(self._query_execution_context(query))
|
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)
|
await self._require_validated_workspace_sandbox(execution_context)
|
||||||
if spec_payload.get('host_path') in (None, ''):
|
if spec_payload.get('host_path') in (None, ''):
|
||||||
tenant_workspace = self._tenant_workspace(execution_context)
|
tenant_workspace = self._tenant_workspace(execution_context)
|
||||||
@@ -658,70 +682,12 @@ class BoxService:
|
|||||||
variables.setdefault('global', 'global')
|
variables.setdefault('global', 'global')
|
||||||
return template.format_map(collections.defaultdict(lambda: 'unknown', variables))
|
return template.format_map(collections.defaultdict(lambda: 'unknown', variables))
|
||||||
|
|
||||||
def build_skill_extra_mounts(self, query: pipeline_query.Query) -> list[dict]:
|
|
||||||
"""Build extra_mounts entries for all pipeline-bound skills.
|
|
||||||
|
|
||||||
This ensures that when a container is first created it already has
|
|
||||||
all skill packages mounted, regardless of which skill is currently
|
|
||||||
activated.
|
|
||||||
|
|
||||||
Path validation is filesystem-topology dependent. When LangBot and the
|
|
||||||
Box runtime share a filesystem (local stdio mode), a skill whose
|
|
||||||
``package_root`` is missing or no longer a directory is skipped with a
|
|
||||||
warning instead of being passed through to the backend. Without that
|
|
||||||
guard the three backends behave inconsistently on a stale mount: nsjail
|
|
||||||
refuses to start the sandbox (failing every exec in the session),
|
|
||||||
Docker silently auto-creates a root-owned empty directory on the host,
|
|
||||||
and E2B silently skips the upload — none of which surfaces an
|
|
||||||
actionable error.
|
|
||||||
|
|
||||||
When Box runs as a separate process (Docker Compose, k8s sidecar,
|
|
||||||
``--standalone-box``, or a remote ``runtime.endpoint``), the
|
|
||||||
``package_root`` reported by ``list_skills`` is the Box runtime's own
|
|
||||||
filesystem path and is NOT resolvable on the LangBot side. Validating
|
|
||||||
it locally would wrongly drop every skill, so LangBot trusts the path
|
|
||||||
and lets the Box runtime resolve it. The Box runtime only ever reports
|
|
||||||
skills it discovered on its own filesystem, so the path is valid there
|
|
||||||
by construction.
|
|
||||||
"""
|
|
||||||
if self._cloud_managed:
|
|
||||||
return []
|
|
||||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
|
||||||
if skill_mgr is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
from ..provider.tools.loaders import skill as skill_loader
|
|
||||||
|
|
||||||
validate_locally = self.shares_filesystem_with_box
|
|
||||||
|
|
||||||
visible_skills = skill_loader.get_visible_skills(self.ap, query)
|
|
||||||
mounts: list[dict] = []
|
|
||||||
for skill_name, skill_data in visible_skills.items():
|
|
||||||
package_root = str(skill_data.get('package_root', '') or '').strip()
|
|
||||||
if not package_root:
|
|
||||||
continue
|
|
||||||
if validate_locally and not os.path.isdir(package_root):
|
|
||||||
self.ap.logger.warning(
|
|
||||||
f'Skill "{skill_name}" package_root missing on filesystem '
|
|
||||||
f'({package_root}); skipping mount to prevent sandbox failures. '
|
|
||||||
f'The skill cache may be stale — consider reloading skills.'
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
mounts.append(
|
|
||||||
{
|
|
||||||
'host_path': package_root,
|
|
||||||
'mount_path': f'/workspace/.skills/{skill_name}',
|
|
||||||
'mode': 'rw',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return mounts
|
|
||||||
|
|
||||||
async def execute_tool(
|
async def execute_tool(
|
||||||
self,
|
self,
|
||||||
parameters: dict,
|
parameters: dict,
|
||||||
query: pipeline_query.Query,
|
query: pipeline_query.Query,
|
||||||
*,
|
*,
|
||||||
skill_name: str | None = None,
|
read_only_mounts: list[dict] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Execute an agent-facing ``exec`` tool call.
|
"""Execute an agent-facing ``exec`` tool call.
|
||||||
|
|
||||||
@@ -729,8 +695,6 @@ class BoxService:
|
|||||||
``BoxSpec.cmd`` field and injects the session id from the query.
|
``BoxSpec.cmd`` field and injects the session id from the query.
|
||||||
"""
|
"""
|
||||||
spec_payload: dict = {'cmd': parameters['command']}
|
spec_payload: dict = {'cmd': parameters['command']}
|
||||||
if skill_name is not None:
|
|
||||||
spec_payload['skill_name'] = skill_name
|
|
||||||
|
|
||||||
# Pass through allowed agent-facing fields
|
# Pass through allowed agent-facing fields
|
||||||
for key in ('workdir', 'timeout_sec', 'env'):
|
for key in ('workdir', 'timeout_sec', 'env'):
|
||||||
@@ -740,11 +704,11 @@ class BoxService:
|
|||||||
# Inject context the agent must not control
|
# Inject context the agent must not control
|
||||||
spec_payload.setdefault('session_id', self.resolve_box_session_id(query))
|
spec_payload.setdefault('session_id', self.resolve_box_session_id(query))
|
||||||
|
|
||||||
# Mount all pipeline-bound skills so they are available in the container
|
return await self.execute_spec_payload(
|
||||||
if 'extra_mounts' not in spec_payload:
|
spec_payload,
|
||||||
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
|
query,
|
||||||
|
trusted_read_only_mounts=read_only_mounts,
|
||||||
return await self.execute_spec_payload(spec_payload, query)
|
)
|
||||||
|
|
||||||
async def execute_in_context(
|
async def execute_in_context(
|
||||||
self,
|
self,
|
||||||
@@ -1275,8 +1239,6 @@ class BoxService:
|
|||||||
'timeout_sec': 120,
|
'timeout_sec': 120,
|
||||||
'session_id': self.resolve_box_session_id(query),
|
'session_id': self.resolve_box_session_id(query),
|
||||||
}
|
}
|
||||||
if 'extra_mounts' not in spec_payload:
|
|
||||||
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
|
|
||||||
try:
|
try:
|
||||||
spec = self.build_spec(spec_payload)
|
spec = self.build_spec(spec_payload)
|
||||||
result = await self.client.execute(spec)
|
result = await self.client.execute(spec)
|
||||||
@@ -1527,126 +1489,6 @@ class BoxService:
|
|||||||
self._runtime_connector.get_relay_headers(action_context),
|
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:
|
def _serialize_result(self, result: BoxExecutionResult) -> dict:
|
||||||
stdout, stdout_truncated = self._truncate(result.stdout)
|
stdout, stdout_truncated = self._truncate(result.stdout)
|
||||||
stderr, stderr_truncated = self._truncate(result.stderr)
|
stderr, stderr_truncated = self._truncate(result.stderr)
|
||||||
@@ -1783,14 +1625,6 @@ class BoxService:
|
|||||||
default_workspace = os.path.join(self.host_root, default_workspace)
|
default_workspace = os.path.join(self.host_root, default_workspace)
|
||||||
return os.path.realpath(os.path.abspath(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:
|
def _load_enabled(self) -> bool:
|
||||||
"""Read ``box.enabled`` (top-level, not ``box.local.*``). Default True
|
"""Read ``box.enabled`` (top-level, not ``box.local.*``). Default True
|
||||||
— disabling is opt-in. Accepts bool, ``'true'``/``'false'`` strings,
|
— disabling is opt-in. Accepts bool, ``'true'``/``'false'`` strings,
|
||||||
|
|||||||
@@ -1,43 +1,28 @@
|
|||||||
"""Reusable workspace/session helpers built on top of Box.
|
"""Reusable workspace/session helpers built on top of Box.
|
||||||
|
|
||||||
This module is the middle layer between the raw Box runtime primitives and
|
This module is the middle layer between raw Box runtime primitives and
|
||||||
application-specific flows such as skills or MCP stdio.
|
application-specific consumers.
|
||||||
|
|
||||||
It intentionally stays generic:
|
It intentionally stays generic:
|
||||||
- path and virtualenv rewriting are workspace concerns
|
- path and virtualenv rewriting are workspace concerns
|
||||||
- Python project detection/bootstrap are workspace concerns
|
- Python project detection/bootstrap are workspace concerns
|
||||||
- session exec / managed-process helpers are workspace concerns
|
- session exec / managed-process helpers are workspace concerns
|
||||||
|
|
||||||
Higher layers add their own semantics on top, for example:
|
Higher layers add their own semantics on top; BoxWorkspaceSession retains only
|
||||||
- skills choose a stable per-skill session id and use repeated exec
|
workspace, execution, and managed-process concepts.
|
||||||
- MCP stdio chooses how to prepare dependencies and attaches to a managed process
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import textwrap
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
PYTHON_MANIFEST_FILES = (
|
from ..utils.python_workspace import list_python_manifest_files
|
||||||
'requirements.txt',
|
|
||||||
'pyproject.toml',
|
|
||||||
'setup.py',
|
|
||||||
'setup.cfg',
|
|
||||||
)
|
|
||||||
_VENV_DIRS = frozenset({'.venv', 'venv', 'env', '.env'})
|
_VENV_DIRS = frozenset({'.venv', 'venv', 'env', '.env'})
|
||||||
_VENV_BIN_DIRS = frozenset({'bin', 'Scripts'})
|
_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:
|
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."""
|
"""Translate a host path into the path visible inside the sandbox mount."""
|
||||||
if not host_path or not path:
|
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)
|
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:
|
def classify_python_workspace(host_path: str | None) -> str | None:
|
||||||
"""Return the generic Python workspace shape, without app-specific policy."""
|
"""Return the generic Python workspace shape, without app-specific policy."""
|
||||||
manifest_files = set(list_python_manifest_files(host_path))
|
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
|
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:
|
class BoxWorkspaceSession:
|
||||||
"""High-level handle for one reusable workspace-backed Box session.
|
"""High-level handle for one reusable workspace-backed Box session.
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from . import entities as core_entities
|
|||||||
from ..rag.knowledge import kbmgr as rag_mgr
|
from ..rag.knowledge import kbmgr as rag_mgr
|
||||||
from ..rag.service import RAGRuntimeService
|
from ..rag.service import RAGRuntimeService
|
||||||
from ..vector import mgr as vectordb_mgr
|
from ..vector import mgr as vectordb_mgr
|
||||||
|
from ..skill import repository as skill_repository
|
||||||
from ..telemetry import telemetry as telemetry_module
|
from ..telemetry import telemetry as telemetry_module
|
||||||
from ..survey import manager as survey_module
|
from ..survey import manager as survey_module
|
||||||
from ..skill import manager as skill_mgr
|
from ..skill import manager as skill_mgr
|
||||||
@@ -84,6 +85,7 @@ class Application:
|
|||||||
# TODO move to pipeline
|
# TODO move to pipeline
|
||||||
tool_mgr: llm_tool_mgr.ToolManager = None
|
tool_mgr: llm_tool_mgr.ToolManager = None
|
||||||
box_service: box_service_module.BoxService = None
|
box_service: box_service_module.BoxService = None
|
||||||
|
skill_repository: skill_repository.SkillRepository = None
|
||||||
|
|
||||||
# ======= Config manager =======
|
# ======= Config manager =======
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from ...api.http.service import knowledge as knowledge_service
|
|||||||
from ...api.http.service import mcp as mcp_service
|
from ...api.http.service import mcp as mcp_service
|
||||||
from ...api.http.service import apikey as apikey_service
|
from ...api.http.service import apikey as apikey_service
|
||||||
from ...api.http.service import webhook as webhook_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 ...api.http.service import monitoring as monitoring_service
|
||||||
from ...api.http.service import skill as skill_service
|
from ...api.http.service import skill as skill_service
|
||||||
from ...skill import manager as skill_mgr
|
from ...skill import manager as skill_mgr
|
||||||
@@ -127,6 +128,7 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
webhook_service_inst = webhook_service.WebhookService(ap)
|
webhook_service_inst = webhook_service.WebhookService(ap)
|
||||||
ap.webhook_service = webhook_service_inst
|
ap.webhook_service = webhook_service_inst
|
||||||
|
|
||||||
|
ap.skill_repository = skill_repository.SkillRepository(ap)
|
||||||
skill_service_inst = skill_service.SkillService(ap)
|
skill_service_inst = skill_service.SkillService(ap)
|
||||||
ap.skill_service = skill_service_inst
|
ap.skill_service = skill_service_inst
|
||||||
|
|
||||||
|
|||||||
@@ -328,6 +328,8 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
# relied on this injection; without it the LLM never discovers
|
# relied on this injection; without it the LLM never discovers
|
||||||
# the skills are there and just calls native tools instead.
|
# the skills are there and just calls native tools instead.
|
||||||
if selected_runner == 'local-agent' and self.ap.skill_mgr:
|
if selected_runner == 'local-agent' and self.ap.skill_mgr:
|
||||||
|
available_tool_names = {tool.name for tool in query.use_funcs}
|
||||||
|
query.variables['_skill_execution_available'] = 'exec' in available_tool_names
|
||||||
skill_execution_context = get_query_execution_context(query)
|
skill_execution_context = get_query_execution_context(query)
|
||||||
await self.ap.skill_mgr.ensure_loaded(skill_execution_context)
|
await self.ap.skill_mgr.ensure_loaded(skill_execution_context)
|
||||||
pipeline_data = await self.ap.pipeline_service.get_pipeline(
|
pipeline_data = await self.ap.pipeline_service.get_pipeline(
|
||||||
@@ -349,7 +351,7 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
skill_execution_context,
|
skill_execution_context,
|
||||||
bound_skills=bound_skills,
|
bound_skills=bound_skills,
|
||||||
)
|
)
|
||||||
if skill_addition:
|
if skill_addition and 'activate' in available_tool_names:
|
||||||
self._append_to_system_prompt(query.prompt.messages, skill_addition)
|
self._append_to_system_prompt(query.prompt.messages, skill_addition)
|
||||||
self.ap.logger.debug(
|
self.ap.logger.debug(
|
||||||
f'Skill index injected into system prompt: '
|
f'Skill index injected into system prompt: '
|
||||||
@@ -357,7 +359,7 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
f'bound_skills={bound_skills or "all"} '
|
f'bound_skills={bound_skills or "all"} '
|
||||||
f'loaded_skills={len(self.ap.skill_mgr.get_skills(skill_execution_context))}'
|
f'loaded_skills={len(self.ap.skill_mgr.get_skills(skill_execution_context))}'
|
||||||
)
|
)
|
||||||
else:
|
elif 'activate' in available_tool_names:
|
||||||
self.ap.logger.debug(
|
self.ap.logger.debug(
|
||||||
f'No skills available for prompt injection: '
|
f'No skills available for prompt injection: '
|
||||||
f'pipeline={query.pipeline_uuid} '
|
f'pipeline={query.pipeline_uuid} '
|
||||||
|
|||||||
@@ -18,12 +18,11 @@ from ....box.workspace import (
|
|||||||
BoxWorkspaceSession,
|
BoxWorkspaceSession,
|
||||||
classify_python_workspace,
|
classify_python_workspace,
|
||||||
infer_workspace_host_path,
|
infer_workspace_host_path,
|
||||||
normalize_host_path,
|
|
||||||
rewrite_mounted_path,
|
rewrite_mounted_path,
|
||||||
rewrite_venv_command,
|
rewrite_venv_command,
|
||||||
unwrap_venv_path,
|
unwrap_venv_path,
|
||||||
wrap_python_command_with_env,
|
|
||||||
)
|
)
|
||||||
|
from ....utils.python_workspace import normalize_host_path, wrap_python_command_with_env
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .mcp import RuntimeMCPSession
|
from .mcp import RuntimeMCPSession
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ EDIT_TOOL_NAME = 'edit'
|
|||||||
GLOB_TOOL_NAME = 'glob'
|
GLOB_TOOL_NAME = 'glob'
|
||||||
GREP_TOOL_NAME = 'grep'
|
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 these dirs during grep walk to avoid noise
|
||||||
_SKIP_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', '.tox', 'dist', 'build'}
|
_SKIP_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', '.tox', 'dist', 'build'}
|
||||||
@@ -260,7 +260,11 @@ class NativeToolLoader(loader.ToolLoader):
|
|||||||
return list(self._tools)
|
return list(self._tools)
|
||||||
|
|
||||||
async def has_tool(self, name: str) -> bool:
|
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):
|
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query):
|
||||||
require_sandbox = getattr(
|
require_sandbox = getattr(
|
||||||
@@ -325,20 +329,11 @@ class NativeToolLoader(loader.ToolLoader):
|
|||||||
if not package_root:
|
if not package_root:
|
||||||
raise ValueError(f'Activated skill "{selected_skill_name}" has no 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.
|
# Wrap command with Python venv bootstrap if the skill has a Python project.
|
||||||
# The venv is created inside the skill's mount path.
|
# The venv is created inside the skill's mount path.
|
||||||
skill_mount = f'/workspace/.skills/{selected_skill_name}'
|
skill_mount = f'/workspace/.skills/{selected_skill_name}'
|
||||||
python_project = selected_skill.get('python_project') is True
|
python_project = selected_skill.get('python_project') is True
|
||||||
if 'python_project' not in selected_skill and bool(
|
if 'python_project' not in selected_skill:
|
||||||
getattr(self.ap.box_service, 'shares_filesystem_with_box', False)
|
|
||||||
):
|
|
||||||
# Backward compatibility for a same-process OSS Runtime that
|
|
||||||
# predates trusted Box metadata. Never probe a path reported by
|
|
||||||
# an external Runtime from the Core filesystem.
|
|
||||||
python_project = skill_loader.should_prepare_skill_python_env(package_root)
|
python_project = skill_loader.should_prepare_skill_python_env(package_root)
|
||||||
if python_project:
|
if python_project:
|
||||||
parameters = dict(parameters)
|
parameters = dict(parameters)
|
||||||
@@ -354,12 +349,9 @@ class NativeToolLoader(loader.ToolLoader):
|
|||||||
result = await self.ap.box_service.execute_tool(
|
result = await self.ap.box_service.execute_tool(
|
||||||
parameters,
|
parameters,
|
||||||
query,
|
query,
|
||||||
skill_name=selected_skill_name,
|
read_only_mounts=skill_loader.build_execution_mounts(self.ap, query),
|
||||||
)
|
)
|
||||||
result = self._normalize_exec_result(result)
|
result = self._normalize_exec_result(result)
|
||||||
|
|
||||||
if selected_skill is not None:
|
|
||||||
self._refresh_skill_from_disk(query, selected_skill)
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _resolve_host_location(
|
def _resolve_host_location(
|
||||||
@@ -381,10 +373,7 @@ class NativeToolLoader(loader.ToolLoader):
|
|||||||
box_service = self.ap.box_service
|
box_service = self.ap.box_service
|
||||||
if selected_skill is not None:
|
if selected_skill is not None:
|
||||||
if not self._can_interpret_skill_host_paths():
|
if not self._can_interpret_skill_host_paths():
|
||||||
raise ValueError(
|
raise ValueError('Secure Core host file operations are unavailable on this platform.')
|
||||||
'Skill package paths are owned by the Box Runtime; '
|
|
||||||
'this operation requires a Runtime skill-file API.'
|
|
||||||
)
|
|
||||||
host_root = selected_skill.get('package_root')
|
host_root = selected_skill.get('package_root')
|
||||||
workspace_anchor = None
|
workspace_anchor = None
|
||||||
else:
|
else:
|
||||||
@@ -422,11 +411,9 @@ class NativeToolLoader(loader.ToolLoader):
|
|||||||
return selected_skill, relative
|
return selected_skill, relative
|
||||||
|
|
||||||
def _can_interpret_skill_host_paths(self) -> bool:
|
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(
|
return _SECURE_HOST_FILE_OPS_AVAILABLE
|
||||||
getattr(self.ap.box_service, 'shares_filesystem_with_box', False)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _should_use_box_workspace_files(self, selected_skill: dict | None) -> bool:
|
def _should_use_box_workspace_files(self, selected_skill: dict | None) -> bool:
|
||||||
if selected_skill is not None:
|
if selected_skill is not None:
|
||||||
@@ -1123,20 +1110,11 @@ else:
|
|||||||
include_visible=True,
|
include_visible=True,
|
||||||
include_activated=True,
|
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
|
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:
|
try:
|
||||||
return await asyncio.to_thread(self._read_host_location, host_location, parameters)
|
result = await skill_repository.read_skill_file(
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self.ap.box_service.read_skill_file(
|
|
||||||
self._execution_context(query),
|
self._execution_context(query),
|
||||||
selected_skill['name'],
|
selected_skill['name'],
|
||||||
relative,
|
relative,
|
||||||
@@ -1144,7 +1122,7 @@ else:
|
|||||||
return self._build_read_result_from_text(str(result.get('content', '')), parameters)
|
return self._build_read_result_from_text(str(result.get('content', '')), parameters)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
result = await self.ap.box_service.list_skill_files(
|
result = await skill_repository.list_skill_files(
|
||||||
self._execution_context(query),
|
self._execution_context(query),
|
||||||
selected_skill['name'],
|
selected_skill['name'],
|
||||||
relative,
|
relative,
|
||||||
@@ -1178,12 +1156,13 @@ else:
|
|||||||
include_visible=False,
|
include_visible=False,
|
||||||
include_activated=True,
|
include_activated=True,
|
||||||
)
|
)
|
||||||
if skill_request is not None and hasattr(self.ap.box_service, 'write_skill_file'):
|
skill_repository = getattr(self.ap, 'skill_repository', None)
|
||||||
|
if skill_request is not None and skill_repository is not None:
|
||||||
if encoding != 'text':
|
if encoding != 'text':
|
||||||
return {'ok': False, 'error': 'base64 writes to skill packages are not supported.'}
|
return {'ok': False, 'error': 'base64 writes to skill packages are not supported.'}
|
||||||
selected_skill, relative = skill_request
|
selected_skill, relative = skill_request
|
||||||
execution_context = self._execution_context(query)
|
execution_context = self._execution_context(query)
|
||||||
await self.ap.box_service.write_skill_file(execution_context, selected_skill['name'], relative, content)
|
await skill_repository.write_skill_file(execution_context, selected_skill['name'], relative, content)
|
||||||
await self.ap.skill_mgr.reload_skills(execution_context)
|
await self.ap.skill_mgr.reload_skills(execution_context)
|
||||||
return {'ok': True, 'path': path}
|
return {'ok': True, 'path': path}
|
||||||
|
|
||||||
@@ -1216,14 +1195,10 @@ else:
|
|||||||
include_visible=False,
|
include_visible=False,
|
||||||
include_activated=True,
|
include_activated=True,
|
||||||
)
|
)
|
||||||
if (
|
if skill_request is not None and getattr(self.ap, 'skill_repository', None) is not None:
|
||||||
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
|
selected_skill, relative = skill_request
|
||||||
try:
|
try:
|
||||||
result = await self.ap.box_service.read_skill_file(
|
result = await self.ap.skill_repository.read_skill_file(
|
||||||
self._execution_context(query),
|
self._execution_context(query),
|
||||||
selected_skill['name'],
|
selected_skill['name'],
|
||||||
relative,
|
relative,
|
||||||
@@ -1238,7 +1213,7 @@ else:
|
|||||||
return {'ok': False, 'error': f'old_string matches {count} locations; provide a more unique string.'}
|
return {'ok': False, 'error': f'old_string matches {count} locations; provide a more unique string.'}
|
||||||
new_content = content.replace(old_string, new_string, 1)
|
new_content = content.replace(old_string, new_string, 1)
|
||||||
execution_context = self._execution_context(query)
|
execution_context = self._execution_context(query)
|
||||||
await self.ap.box_service.write_skill_file(
|
await self.ap.skill_repository.write_skill_file(
|
||||||
execution_context,
|
execution_context,
|
||||||
selected_skill['name'],
|
selected_skill['name'],
|
||||||
relative,
|
relative,
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from ....box import workspace as box_workspace
|
|
||||||
from ....api.http.context import ExecutionContext
|
from ....api.http.context import ExecutionContext
|
||||||
|
from ....utils.python_workspace import (
|
||||||
|
should_prepare_python_env,
|
||||||
|
wrap_python_command_with_env,
|
||||||
|
)
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from ....core import app
|
from ....core import app
|
||||||
@@ -57,6 +61,30 @@ def get_visible_skill(ap: app.Application, query: pipeline_query.Query, skill_na
|
|||||||
return get_visible_skills(ap, query).get(skill_name)
|
return get_visible_skills(ap, query).get(skill_name)
|
||||||
|
|
||||||
|
|
||||||
|
def build_execution_mounts(ap: app.Application, query: pipeline_query.Query) -> list[dict]:
|
||||||
|
"""Translate visible Core-owned packages into generic read-only mounts."""
|
||||||
|
|
||||||
|
mounts: list[dict] = []
|
||||||
|
for skill_name, skill_data in get_visible_skills(ap, query).items():
|
||||||
|
package_root = str(skill_data.get('package_root', '') or '').strip()
|
||||||
|
if not package_root:
|
||||||
|
continue
|
||||||
|
if not os.path.isdir(package_root):
|
||||||
|
ap.logger.warning(
|
||||||
|
f'Skill "{skill_name}" package_root missing on the Core filesystem '
|
||||||
|
f'({package_root}); skipping its execution mount. Reload the skill catalog.'
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
mounts.append(
|
||||||
|
{
|
||||||
|
'host_path': package_root,
|
||||||
|
'mount_path': get_virtual_skill_mount_path(skill_name),
|
||||||
|
'mode': 'ro',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return mounts
|
||||||
|
|
||||||
|
|
||||||
def get_activated_skills(query: pipeline_query.Query) -> dict[str, dict]:
|
def get_activated_skills(query: pipeline_query.Query) -> dict[str, dict]:
|
||||||
if query.variables is None:
|
if query.variables is None:
|
||||||
return {}
|
return {}
|
||||||
@@ -198,7 +226,7 @@ def build_skill_session_id(skill_data: dict, query: pipeline_query.Query) -> str
|
|||||||
|
|
||||||
|
|
||||||
def should_prepare_skill_python_env(package_root: str | None) -> bool:
|
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(
|
def wrap_skill_command_with_python_env(
|
||||||
@@ -207,7 +235,7 @@ def wrap_skill_command_with_python_env(
|
|||||||
mount_path: str = '/workspace',
|
mount_path: str = '/workspace',
|
||||||
state_path: str | None = None,
|
state_path: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
return box_workspace.wrap_python_command_with_env(
|
return wrap_python_command_with_env(
|
||||||
command,
|
command,
|
||||||
mount_path=mount_path,
|
mount_path=mount_path,
|
||||||
state_path=state_path,
|
state_path=state_path,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import typing
|
|||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||||
|
|
||||||
from .. import loader
|
from .. import loader
|
||||||
from .availability import is_box_backend_available
|
|
||||||
from ....api.http.context import ExecutionContext
|
from ....api.http.context import ExecutionContext
|
||||||
|
|
||||||
# Align with Claude Code's Skill tool design:
|
# 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
|
# - This protects KV Cache and follows industry standard
|
||||||
|
|
||||||
ACTIVATE_SKILL_TOOL_NAME = 'activate'
|
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'
|
REGISTER_SKILL_TOOL_NAME = 'register_skill'
|
||||||
|
|
||||||
SKILL_TOOL_NAMES = {
|
READ_ONLY_SKILL_TOOL_NAMES = {
|
||||||
ACTIVATE_SKILL_TOOL_NAME,
|
ACTIVATE_SKILL_TOOL_NAME,
|
||||||
|
LIST_SKILL_RESOURCES_TOOL_NAME,
|
||||||
|
READ_SKILL_RESOURCE_TOOL_NAME,
|
||||||
|
}
|
||||||
|
SANDBOX_SKILL_TOOL_NAMES = {
|
||||||
REGISTER_SKILL_TOOL_NAME,
|
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):
|
class SkillToolLoader(loader.ToolLoader):
|
||||||
@@ -28,62 +38,73 @@ class SkillToolLoader(loader.ToolLoader):
|
|||||||
|
|
||||||
def __init__(self, ap):
|
def __init__(self, ap):
|
||||||
super().__init__(ap)
|
super().__init__(ap)
|
||||||
self._tools: list[resource_tool.LLMTool] = []
|
self._read_only_tools: list[resource_tool.LLMTool] = []
|
||||||
self._sandbox_available: bool = False
|
self._sandbox_tools: list[resource_tool.LLMTool] = []
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
# Check if sandbox backend is available (same check as native tools)
|
if self._is_available():
|
||||||
self._sandbox_available = await self._check_sandbox_available()
|
self._read_only_tools = [
|
||||||
if self._sandbox_available:
|
|
||||||
self._tools = [
|
|
||||||
self._build_activate_skill_tool(),
|
self._build_activate_skill_tool(),
|
||||||
self._build_register_skill_tool(),
|
self._build_list_skill_resources_tool(),
|
||||||
|
self._build_read_skill_resource_tool(),
|
||||||
]
|
]
|
||||||
|
self._sandbox_tools = [self._build_register_skill_tool()]
|
||||||
else:
|
else:
|
||||||
self.ap.logger.info(
|
self.ap.logger.info('Skill tools are unavailable because the Core SkillRepository is not initialized.')
|
||||||
'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.'
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _check_sandbox_available(self) -> bool:
|
async def get_tools(
|
||||||
"""Check if the box backend is truly available (not just the runtime)."""
|
self,
|
||||||
return await is_box_backend_available(self.ap)
|
bound_plugins: list[str] | None = None,
|
||||||
|
*,
|
||||||
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
sandbox_available: bool | None = None,
|
||||||
if not await self._is_available():
|
) -> list[resource_tool.LLMTool]:
|
||||||
|
if not self._is_available():
|
||||||
return []
|
return []
|
||||||
if not self._tools:
|
if not self._read_only_tools:
|
||||||
self._tools = [
|
await self.initialize()
|
||||||
self._build_activate_skill_tool(),
|
tools = list(self._read_only_tools)
|
||||||
self._build_register_skill_tool(),
|
if sandbox_available:
|
||||||
]
|
tools.extend(self._sandbox_tools)
|
||||||
return list(self._tools)
|
return tools
|
||||||
|
|
||||||
async def has_tool(self, name: str) -> bool:
|
async def get_tool(self, name: str, *, sandbox_available: bool | None = None):
|
||||||
return await self._is_available() and name in SKILL_TOOL_NAMES
|
for tool in await self.get_tools(sandbox_available=sandbox_available):
|
||||||
|
if tool.name == name:
|
||||||
|
return tool
|
||||||
|
return None
|
||||||
|
|
||||||
async def _is_available(self) -> bool:
|
async def has_tool(self, name: str, *, sandbox_available: bool | None = None) -> bool:
|
||||||
"""Check if skill tools should be available.
|
if not self._is_available() or name not in SKILL_TOOL_NAMES:
|
||||||
|
|
||||||
Skill tools require both a skill manager and a sandbox backend.
|
|
||||||
"""
|
|
||||||
if not self._has_skill_manager():
|
|
||||||
return False
|
return False
|
||||||
self._sandbox_available = await self._check_sandbox_available()
|
return name in READ_ONLY_SKILL_TOOL_NAMES or bool(sandbox_available)
|
||||||
return self._sandbox_available
|
|
||||||
|
@staticmethod
|
||||||
|
def is_sandbox_tool(name: str) -> bool:
|
||||||
|
return name in SANDBOX_SKILL_TOOL_NAMES
|
||||||
|
|
||||||
|
@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:
|
async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any:
|
||||||
|
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(
|
require_sandbox = getattr(
|
||||||
getattr(self.ap, 'box_service', None),
|
getattr(self.ap, 'box_service', None),
|
||||||
'require_workspace_sandbox',
|
'require_workspace_sandbox',
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if callable(require_sandbox):
|
if not callable(require_sandbox):
|
||||||
|
return self._sandbox_unavailable_result(name)
|
||||||
await require_sandbox(self._execution_context(query))
|
await require_sandbox(self._execution_context(query))
|
||||||
if name == ACTIVATE_SKILL_TOOL_NAME:
|
|
||||||
return await self._invoke_activate_skill(parameters, query)
|
|
||||||
if name == REGISTER_SKILL_TOOL_NAME:
|
|
||||||
return await self._invoke_register_skill(parameters, query)
|
return await self._invoke_register_skill(parameters, query)
|
||||||
raise ValueError(f'Unknown skill tool: {name}')
|
raise ValueError(f'Unknown skill tool: {name}')
|
||||||
|
|
||||||
@@ -108,6 +129,27 @@ class SkillToolLoader(loader.ToolLoader):
|
|||||||
def _has_skill_manager(self) -> bool:
|
def _has_skill_manager(self) -> bool:
|
||||||
return getattr(self.ap, 'skill_mgr', None) is not None
|
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:
|
async def _invoke_activate_skill(self, parameters: dict, query) -> typing.Any:
|
||||||
"""Activate a skill and return SKILL.md content via Tool Result."""
|
"""Activate a skill and return SKILL.md content via Tool Result."""
|
||||||
skill_name = str(parameters.get('skill_name', '') or '').strip()
|
skill_name = str(parameters.get('skill_name', '') or '').strip()
|
||||||
@@ -116,42 +158,111 @@ class SkillToolLoader(loader.ToolLoader):
|
|||||||
|
|
||||||
from . import skill as skill_loader
|
from . import skill as skill_loader
|
||||||
|
|
||||||
skill_data = skill_loader.get_visible_skill(self.ap, query, skill_name)
|
visible_skill = skill_loader.get_visible_skill(self.ap, query, skill_name)
|
||||||
if skill_data is None:
|
if visible_skill is None:
|
||||||
visible_skills = skill_loader.get_visible_skills(self.ap, query)
|
visible_skills = skill_loader.get_visible_skills(self.ap, query)
|
||||||
available_names = ', '.join(sorted(visible_skills.keys())) or 'none'
|
available_names = ', '.join(sorted(visible_skills.keys())) or 'none'
|
||||||
raise ValueError(f'Skill "{skill_name}" not found. Available skills: {available_names}')
|
raise ValueError(f'Skill "{skill_name}" not found. Available skills: {available_names}')
|
||||||
|
|
||||||
# Register activated skill for sandbox mount path resolution
|
skill_data = await self.ap.skill_repository.get_skill(
|
||||||
|
self._execution_context(query),
|
||||||
|
skill_name,
|
||||||
|
snapshot=True,
|
||||||
|
)
|
||||||
|
if skill_data is None:
|
||||||
|
raise ValueError(f'Skill "{skill_name}" is no longer available; reload the skill catalog.')
|
||||||
|
|
||||||
skill_loader.register_activated_skill(query, 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', '')
|
instructions = skill_data.get('instructions', '')
|
||||||
package_root = skill_data.get('package_root', '')
|
revision = str(skill_data.get('revision', '') or '')
|
||||||
mount_path = skill_loader.get_virtual_skill_mount_path(skill_name)
|
execution_available = await self._execution_available(query)
|
||||||
|
mount_path = skill_loader.get_virtual_skill_mount_path(skill_name) 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 = f'<command-message>The "{skill_name}" skill is activated</command-message>\n'
|
||||||
result_content += '<skill-activation>\n'
|
result_content += '<skill-activation>\n'
|
||||||
result_content += f'<skill-name>{skill_name}</skill-name>\n'
|
result_content += f'<skill-name>{skill_name}</skill-name>\n'
|
||||||
result_content += f'<mount-path>{mount_path}</mount-path>\n'
|
result_content += f'<revision>{revision}</revision>\n'
|
||||||
result_content += f'<package-root>{package_root}</package-root>\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 += f'\n## Instructions\n{instructions}\n'
|
||||||
result_content += '\n## Runtime Context\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 += '- Use `list_skill_resources` and `read_skill_resource` for read-only package resources.\n'
|
||||||
result_content += f'- Use `read` to inspect files under {mount_path}\n'
|
if execution_available:
|
||||||
result_content += f'- Use `exec` with workdir set to {mount_path} to run commands in that package\n'
|
result_content += (
|
||||||
result_content += '- Use `write` and `edit` on that path when the instructions require updating files\n'
|
f'- Box execution is available; executable package files will be mounted at {mount_path}.\n'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result_content += (
|
||||||
|
'- Box execution is unavailable. Do not attempt to run scripts or modify Workspace files.\n'
|
||||||
|
)
|
||||||
result_content += '</skill-activation>\n'
|
result_content += '</skill-activation>\n'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'activated': True,
|
'activated': True,
|
||||||
'skill_name': skill_name,
|
'skill_name': skill_name,
|
||||||
'mount_path': mount_path,
|
'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),
|
'activated_skill_names': skill_loader.get_activated_skill_names(query),
|
||||||
'content': result_content,
|
'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:
|
async def _invoke_register_skill(self, parameters: dict, query) -> typing.Any:
|
||||||
"""Register a skill from sandbox directory to data/skills/."""
|
"""Register a skill from sandbox directory to data/skills/."""
|
||||||
sandbox_path = str(parameters.get('path', '') or '').strip()
|
sandbox_path = str(parameters.get('path', '') or '').strip()
|
||||||
@@ -176,14 +287,14 @@ class SkillToolLoader(loader.ToolLoader):
|
|||||||
raise ValueError('skill name is required')
|
raise ValueError('skill name is required')
|
||||||
|
|
||||||
# Create the skill
|
# Create the skill
|
||||||
created = await skill_service.create_skill(
|
created = await skill_service.import_skill_directory(
|
||||||
execution_context,
|
execution_context,
|
||||||
|
host_path,
|
||||||
{
|
{
|
||||||
'name': skill_name,
|
'name': skill_name,
|
||||||
'display_name': str(parameters.get('display_name') or scanned.get('display_name', '')).strip(),
|
'display_name': str(parameters.get('display_name') or scanned.get('display_name', '')).strip(),
|
||||||
'description': str(parameters.get('description') or scanned.get('description', '')).strip(),
|
'description': str(parameters.get('description') or scanned.get('description', '')).strip(),
|
||||||
'instructions': str(parameters.get('instructions') or scanned.get('instructions', '')),
|
'instructions': str(parameters.get('instructions') or scanned.get('instructions', '')),
|
||||||
'package_root': host_path,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -249,6 +360,42 @@ class SkillToolLoader(loader.ToolLoader):
|
|||||||
func=lambda parameters: parameters,
|
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:
|
def _build_register_skill_tool(self) -> resource_tool.LLMTool:
|
||||||
return resource_tool.LLMTool(
|
return resource_tool.LLMTool(
|
||||||
name=REGISTER_SKILL_TOOL_NAME,
|
name=REGISTER_SKILL_TOOL_NAME,
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ class ToolManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
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):
|
async def initialize(self):
|
||||||
from langbot.pkg.utils import importutil
|
from langbot.pkg.utils import importutil
|
||||||
from langbot.pkg.provider.tools import loaders
|
from langbot.pkg.provider.tools import loaders
|
||||||
@@ -102,8 +111,8 @@ class ToolManager:
|
|||||||
sandbox_available = await self._workspace_sandbox_available(context)
|
sandbox_available = await self._workspace_sandbox_available(context)
|
||||||
if sandbox_available:
|
if sandbox_available:
|
||||||
all_functions.extend(await self.native_tool_loader.get_tools())
|
all_functions.extend(await self.native_tool_loader.get_tools())
|
||||||
if include_skill_authoring and sandbox_available:
|
if include_skill_authoring:
|
||||||
all_functions.extend(await self.skill_tool_loader.get_tools())
|
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.plugin_tool_loader.get_tools(bound_plugins))
|
||||||
all_functions.extend(
|
all_functions.extend(
|
||||||
await self.mcp_tool_loader.get_tools(
|
await self.mcp_tool_loader.get_tools(
|
||||||
@@ -142,8 +151,12 @@ class ToolManager:
|
|||||||
sandbox_available = await self._workspace_sandbox_available(context)
|
sandbox_available = await self._workspace_sandbox_available(context)
|
||||||
if sandbox_available:
|
if sandbox_available:
|
||||||
append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools())
|
append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools())
|
||||||
if include_skill_authoring and sandbox_available:
|
if include_skill_authoring:
|
||||||
append_tools('skill', 'LangBot', await self.skill_tool_loader.get_tools())
|
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))
|
catalog.extend(await self.plugin_tool_loader.get_tool_catalog(bound_plugins))
|
||||||
|
|
||||||
if self.mcp_tool_loader:
|
if self.mcp_tool_loader:
|
||||||
@@ -168,8 +181,7 @@ class ToolManager:
|
|||||||
tool = await active_loader.get_tool(name)
|
tool = await active_loader.get_tool(name)
|
||||||
if tool:
|
if tool:
|
||||||
return tool
|
return tool
|
||||||
if sandbox_available:
|
tool = await self.skill_tool_loader.get_tool(name, sandbox_available=sandbox_available)
|
||||||
tool = await self.skill_tool_loader.get_tool(name)
|
|
||||||
if tool:
|
if tool:
|
||||||
return tool
|
return tool
|
||||||
|
|
||||||
@@ -310,7 +322,10 @@ class ToolManager:
|
|||||||
query=query,
|
query=query,
|
||||||
invoke=lambda: self.mcp_tool_loader.invoke_tool(name, parameters, 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
|
||||||
telemetry_features.increment(query, 'tool_calls', 'skill')
|
telemetry_features.increment(query, 'tool_calls', 'skill')
|
||||||
return await self._invoke_tool_with_monitoring(
|
return await self._invoke_tool_with_monitoring(
|
||||||
source='skill',
|
source='skill',
|
||||||
@@ -319,6 +334,13 @@ class ToolManager:
|
|||||||
query=query,
|
query=query,
|
||||||
invoke=lambda: self.skill_tool_loader.invoke_tool(name, parameters, 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)
|
raise ToolNotFoundError(name)
|
||||||
|
|
||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
from .manager import SkillManager
|
from .manager import SkillManager
|
||||||
|
from .repository import SkillRepository
|
||||||
|
|
||||||
__all__ = ['SkillManager']
|
|
||||||
|
__all__ = ['SkillManager', 'SkillRepository']
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from ..api.http.context import ExecutionContext
|
from ..api.http.context import ExecutionContext
|
||||||
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
|
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
|
||||||
from ..core import app
|
from ..core import app
|
||||||
|
|
||||||
|
|
||||||
class SkillManager:
|
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
|
ap: app.Application
|
||||||
|
|
||||||
@@ -63,33 +61,20 @@ class SkillManager:
|
|||||||
self._skills_by_scope.pop(existing_key, None)
|
self._skills_by_scope.pop(existing_key, None)
|
||||||
self._skills_by_scope[key] = {}
|
self._skills_by_scope[key] = {}
|
||||||
|
|
||||||
box_service = getattr(self.ap, 'box_service', None)
|
repository = getattr(self.ap, 'skill_repository', None)
|
||||||
if box_service is None or not getattr(box_service, 'available', False):
|
if repository is None:
|
||||||
self.ap.logger.info(
|
self.ap.logger.info('Skill repository unavailable; skill cache will remain empty.')
|
||||||
f'Box runtime unavailable; skill cache is empty for Workspace {execution_context.workspace_uuid}.'
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
validate_locally = bool(getattr(box_service, 'shares_filesystem_with_box', False))
|
|
||||||
try:
|
try:
|
||||||
dropped = 0
|
|
||||||
skills: dict[str, dict] = {}
|
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')
|
skill_name = skill_data.get('name')
|
||||||
if not skill_name:
|
if not skill_name:
|
||||||
continue
|
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
|
skills[skill_name] = skill_data
|
||||||
self._skills_by_scope[key] = skills
|
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}')
|
||||||
self.ap.logger.info(f'Loaded {len(skills)} skills for Workspace {execution_context.workspace_uuid}{suffix}')
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.ap.logger.warning(f'Failed to load skills for Workspace {execution_context.workspace_uuid}: {exc}')
|
self.ap.logger.warning(f'Failed to load skills for Workspace {execution_context.workspace_uuid}: {exc}')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import weakref
|
||||||
|
|
||||||
|
from langbot_plugin.skill_store import (
|
||||||
|
SkillRevisionMismatchError,
|
||||||
|
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)
|
||||||
|
|
||||||
|
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) -> dict | None:
|
||||||
|
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) -> dict:
|
||||||
|
namespace = self._namespace(context)
|
||||||
|
return await self._call(
|
||||||
|
context,
|
||||||
|
'import_skill_directory',
|
||||||
|
path,
|
||||||
|
skill,
|
||||||
|
source_root=self._workspace_root(namespace),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_skill(self, context: TenantContext, name: str, skill: dict) -> dict:
|
||||||
|
return await self._call(context, 'update_skill', name, skill)
|
||||||
|
|
||||||
|
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) -> dict:
|
||||||
|
return await self._call(context, 'write_skill_file', name, path, content)
|
||||||
|
|
||||||
|
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', 'SkillRevisionMismatchError']
|
||||||
@@ -198,7 +198,7 @@ async def build_heartbeat_payload(
|
|||||||
except Exception:
|
except Exception:
|
||||||
features['plugin_count'] = -1
|
features['plugin_count'] = -1
|
||||||
|
|
||||||
# Skill count (from Box runtime via skill manager)
|
# Skill count (from the Core SkillRepository cache)
|
||||||
try:
|
try:
|
||||||
skill_mgr = getattr(ap, 'skill_mgr', None)
|
skill_mgr = getattr(ap, 'skill_mgr', None)
|
||||||
if skill_mgr is not 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',
|
||||||
|
]
|
||||||
@@ -323,13 +323,20 @@ monitoring:
|
|||||||
# Prevent one large Workspace backlog from monopolizing PostgreSQL.
|
# Prevent one large Workspace backlog from monopolizing PostgreSQL.
|
||||||
# Supports MONITORING__AUTO_CLEANUP__MAX_BATCHES_PER_TABLE_PER_RUN.
|
# Supports MONITORING__AUTO_CLEANUP__MAX_BATCHES_PER_TABLE_PER_RUN.
|
||||||
max_batches_per_table_per_run: 4
|
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:
|
box:
|
||||||
# Master switch for the Box sandbox runtime. When false, LangBot does NOT
|
# 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
|
# 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:
|
# subprocess. Disabling Box disables execution-backed features: native
|
||||||
# the native sandbox tools (exec/read/write/edit/glob/grep), the activate
|
# sandbox tools (exec/read/write/edit/glob/grep), agent-side skill
|
||||||
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
|
# registration, and stdio-mode MCP servers. Skill management, activation,
|
||||||
# be listed read-only and http/sse MCP servers continue to work.
|
# and read-only package resources remain available without Box.
|
||||||
enabled: true
|
enabled: true
|
||||||
# 'host' runs commands directly as the Box Runtime user without sandbox
|
# 'host' runs commands directly as the Box Runtime user without sandbox
|
||||||
# isolation. It is never auto-selected and is only for trusted local
|
# isolation. It is never auto-selected and is only for trusted local
|
||||||
@@ -378,7 +385,6 @@ box:
|
|||||||
image: '' # Custom local sandbox image. Leave empty to use the profile default.
|
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.
|
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.
|
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.
|
allowed_mount_roots: # Defaults to ['<host_root>'] when left empty.
|
||||||
- './data/box'
|
- './data/box'
|
||||||
- '/tmp'
|
- '/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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,10 +9,7 @@ import pytest
|
|||||||
import quart
|
import quart
|
||||||
|
|
||||||
from langbot.pkg.api.http.controller.groups.skills import SkillsRouterGroup
|
from langbot.pkg.api.http.controller.groups.skills import SkillsRouterGroup
|
||||||
from langbot.pkg.cloud.entitlements import (
|
from langbot.pkg.cloud.entitlements import EntitlementUnavailableError
|
||||||
EntitlementFeatureUnavailableError,
|
|
||||||
EntitlementUnavailableError,
|
|
||||||
)
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
|
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
|
||||||
@@ -32,10 +29,7 @@ async def skills_api():
|
|||||||
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
||||||
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
|
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
|
||||||
application.skill_service.list_skills = AsyncMock(
|
application.skill_service.list_skills = AsyncMock(
|
||||||
side_effect=EntitlementFeatureUnavailableError(
|
return_value=[{'name': 'docs-only', 'description': 'No execution required'}]
|
||||||
'managed_sandbox',
|
|
||||||
entitlement_revision=1,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
quart_app = quart.Quart(__name__)
|
quart_app = quart.Quart(__name__)
|
||||||
@@ -45,7 +39,7 @@ async def skills_api():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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
|
application, client = skills_api
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
'/api/v1/skills',
|
'/api/v1/skills',
|
||||||
@@ -57,7 +51,7 @@ async def test_list_skills_is_empty_when_plan_has_no_managed_sandbox(skills_api)
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
payload = await response.get_json()
|
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()
|
application.skill_service.list_skills.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ from langbot.pkg.cloud.entitlements import (
|
|||||||
EntitlementSnapshot,
|
EntitlementSnapshot,
|
||||||
EntitlementUnavailableError,
|
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
|
pytestmark = pytest.mark.integration
|
||||||
@@ -54,7 +57,7 @@ class _AdmissionBackend(BaseSandboxBackend):
|
|||||||
'mount_isolation': True,
|
'mount_isolation': True,
|
||||||
'network_isolation': True,
|
'network_isolation': True,
|
||||||
'hard_workspace_quota': True,
|
'hard_workspace_quota': True,
|
||||||
'hard_skill_storage_quota': True,
|
'hard_read_only_mount_quota': True,
|
||||||
'bounded_ephemeral_storage': True,
|
'bounded_ephemeral_storage': True,
|
||||||
'inode_quota': True,
|
'inode_quota': True,
|
||||||
}
|
}
|
||||||
@@ -230,9 +233,18 @@ async def _stack(tmp_path):
|
|||||||
deployment=SimpleNamespace(multi_workspace_enabled=True),
|
deployment=SimpleNamespace(multi_workspace_enabled=True),
|
||||||
entitlement_resolver=EntitlementResolver('instance-a', entitlements),
|
entitlement_resolver=EntitlementResolver('instance-a', entitlements),
|
||||||
workspace_service=workspace_service,
|
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)
|
service = BoxService(app, client=client)
|
||||||
|
app.box_service = service
|
||||||
await service.initialize()
|
await service.initialize()
|
||||||
return service, runtime, backend, entitlements, server_task, client_task
|
return service, runtime, backend, entitlements, server_task, client_task
|
||||||
|
|
||||||
@@ -312,7 +324,7 @@ async def test_two_workspaces_get_isolated_physical_sessions_and_paths(tmp_path)
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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)
|
service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
|
||||||
first = _context('workspace-a')
|
first = _context('workspace-a')
|
||||||
second = _context('workspace-b')
|
second = _context('workspace-b')
|
||||||
@@ -324,32 +336,35 @@ async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tm
|
|||||||
managed=False,
|
managed=False,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
private = await service.create_skill(
|
repository = service.ap.skill_repository
|
||||||
|
await repository.create_skill(
|
||||||
second,
|
second,
|
||||||
{
|
{
|
||||||
'name': 'private',
|
'name': 'private',
|
||||||
'instructions': 'workspace-b secret',
|
'instructions': 'workspace-b secret',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
own_skill = await service.create_skill(
|
own_skill = await repository.create_skill(
|
||||||
first,
|
first,
|
||||||
{
|
{
|
||||||
'name': 'runner',
|
'name': 'runner',
|
||||||
'instructions': 'Run scripts/main.py',
|
'instructions': 'Run scripts/main.py',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await service.write_skill_file(first, 'runner', 'scripts/main.py', "print('ok')")
|
await repository.write_skill_file(first, 'runner', 'scripts/main.py', "print('ok')")
|
||||||
await service.write_skill_file(first, 'runner', 'requirements.txt', 'requests==2.32.0\n')
|
await repository.write_skill_file(first, 'runner', 'requirements.txt', 'requests==2.32.0\n')
|
||||||
refreshed_skill = await service.get_skill(first, 'runner')
|
refreshed_skill = await repository.get_skill(first, 'runner')
|
||||||
assert refreshed_skill is not None
|
assert refreshed_skill is not None
|
||||||
assert refreshed_skill['python_project'] is True
|
assert refreshed_skill['python_project'] is True
|
||||||
|
await service.ap.skill_mgr.reload_skills(first)
|
||||||
|
query = _query(first, 91)
|
||||||
await service.execute_tool(
|
await service.execute_tool(
|
||||||
{
|
{
|
||||||
'command': 'python /workspace/.skills/runner/scripts/main.py',
|
'command': 'python /workspace/.skills/runner/scripts/main.py',
|
||||||
'workdir': '/workspace/.skills/runner',
|
'workdir': '/workspace/.skills/runner',
|
||||||
},
|
},
|
||||||
_query(first, 91),
|
query,
|
||||||
skill_name='runner',
|
read_only_mounts=skill_loader.build_execution_mounts(service.ap, query),
|
||||||
)
|
)
|
||||||
|
|
||||||
mounted_spec = backend.started_specs[-1]
|
mounted_spec = backend.started_specs[-1]
|
||||||
@@ -358,20 +373,14 @@ async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tm
|
|||||||
assert mounted_spec.extra_mounts[0].mount_path == '/workspace/.skills/runner'
|
assert mounted_spec.extra_mounts[0].mount_path == '/workspace/.skills/runner'
|
||||||
assert mounted_spec.extra_mounts[0].mode.value == 'ro'
|
assert mounted_spec.extra_mounts[0].mode.value == 'ro'
|
||||||
|
|
||||||
with pytest.raises(BoxAdmissionError, match='Scanning arbitrary host'):
|
assert await repository.get_skill(first, 'private') is None
|
||||||
await service.scan_skill_directory(first, private['package_root'])
|
await repository.create_skill(
|
||||||
with pytest.raises(BoxAdmissionError, match='package_root is runtime-owned'):
|
ineligible,
|
||||||
await service.create_skill(
|
{'name': 'docs-only', 'instructions': 'Read this without Box.'},
|
||||||
first,
|
|
||||||
{
|
|
||||||
'name': 'stolen',
|
|
||||||
'package_root': private['package_root'],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
assert [skill['name'] for skill in await repository.list_skills(ineligible)] == ['docs-only']
|
||||||
assert await service.get_skill(first, 'private') is None
|
|
||||||
with pytest.raises(EntitlementUnavailableError):
|
with pytest.raises(EntitlementUnavailableError):
|
||||||
await service.list_skills(ineligible)
|
await service.execute_tool({'command': 'true'}, _query(ineligible, 92))
|
||||||
finally:
|
finally:
|
||||||
server_task.cancel()
|
server_task.cancel()
|
||||||
client_task.cancel()
|
client_task.cancel()
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from langbot_plugin.box.security import (
|
|||||||
from langbot_plugin.entities.io.context import ActionContext
|
from langbot_plugin.entities.io.context import ActionContext
|
||||||
from langbot.pkg.api.http.context import ExecutionContext
|
from langbot.pkg.api.http.context import ExecutionContext
|
||||||
from langbot.pkg.box.service import BoxService
|
from langbot.pkg.box.service import BoxService
|
||||||
|
from langbot.pkg.provider.tools.loaders import skill as skill_loader
|
||||||
|
|
||||||
_UTC = dt.timezone.utc
|
_UTC = dt.timezone.utc
|
||||||
_CONTEXT = ExecutionContext(
|
_CONTEXT = ExecutionContext(
|
||||||
@@ -301,7 +302,7 @@ class TestSharesFilesystemWithBox:
|
|||||||
- stdio (local child process) → shared filesystem → True
|
- stdio (local child process) → shared filesystem → True
|
||||||
- WebSocket (Docker / sidecar / --standalone-box / remote) → separated → False
|
- 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.
|
Getting it wrong silently drops every skill in separated deployments.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -338,7 +339,7 @@ class TestSharesFilesystemWithBox:
|
|||||||
|
|
||||||
def test_false_when_client_injected_without_connector(self):
|
def test_false_when_client_injected_without_connector(self):
|
||||||
# Injected client (no connector) → unknown topology → conservative False
|
# 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))
|
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
|
||||||
|
|
||||||
assert service._runtime_connector is None
|
assert service._runtime_connector is None
|
||||||
@@ -552,7 +553,6 @@ async def test_box_service_reconnect_restores_workspace_and_runs_cleanup(
|
|||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
):
|
):
|
||||||
app = make_app(Mock())
|
app = make_app(Mock())
|
||||||
app.skill_mgr = SimpleNamespace(reload_skills=AsyncMock())
|
|
||||||
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
|
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
|
||||||
connector = Mock()
|
connector = Mock()
|
||||||
connector.reconnect = AsyncMock()
|
connector.reconnect = AsyncMock()
|
||||||
@@ -565,16 +565,14 @@ async def test_box_service_reconnect_restores_workspace_and_runs_cleanup(
|
|||||||
connector.reconnect.assert_awaited_once()
|
connector.reconnect.assert_awaited_once()
|
||||||
service._ensure_default_workspace.assert_called_once()
|
service._ensure_default_workspace.assert_called_once()
|
||||||
service._purge_attachment_dirs.assert_awaited_once()
|
service._purge_attachment_dirs.assert_awaited_once()
|
||||||
app.skill_mgr.reload_skills.assert_awaited_once()
|
|
||||||
assert service.available is True
|
assert service.available is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
):
|
):
|
||||||
app = make_app(Mock())
|
app = make_app(Mock())
|
||||||
app.skill_mgr = SimpleNamespace(reload_skills=AsyncMock())
|
|
||||||
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
|
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
|
||||||
service._cloud_managed = True
|
service._cloud_managed = True
|
||||||
connector = Mock()
|
connector = Mock()
|
||||||
@@ -587,7 +585,6 @@ async def test_cloud_box_service_reconnect_does_not_reload_unscoped_skills(
|
|||||||
|
|
||||||
connector.reconnect.assert_awaited_once()
|
connector.reconnect.assert_awaited_once()
|
||||||
service._verify_cloud_runtime.assert_awaited_once()
|
service._verify_cloud_runtime.assert_awaited_once()
|
||||||
app.skill_mgr.reload_skills.assert_not_awaited()
|
|
||||||
assert service.available is True
|
assert service.available is True
|
||||||
|
|
||||||
|
|
||||||
@@ -1941,7 +1938,7 @@ def test_disconnect_callback_does_not_schedule_without_running_event_loop():
|
|||||||
assert service._reconnecting is False
|
assert service._reconnecting is False
|
||||||
|
|
||||||
|
|
||||||
class TestBuildSkillExtraMounts:
|
class TestBuildSkillExecutionMounts:
|
||||||
"""Robustness of skill mount construction against a stale skill cache.
|
"""Robustness of skill mount construction against a stale skill cache.
|
||||||
|
|
||||||
The three sandbox backends behave inconsistently when a skill's
|
The three sandbox backends behave inconsistently when a skill's
|
||||||
@@ -1951,16 +1948,10 @@ class TestBuildSkillExtraMounts:
|
|||||||
the backend never sees a bad mount.
|
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 = make_app(logger)
|
||||||
app.skill_mgr = SimpleNamespace(skills=skills, get_skills=Mock(return_value=skills))
|
app.skill_mgr = SimpleNamespace(skills=skills, get_skills=Mock(return_value=skills))
|
||||||
client = Mock(spec=BoxRuntimeClient)
|
return app
|
||||||
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
|
|
||||||
|
|
||||||
def test_skips_skill_with_missing_package_root(self):
|
def test_skips_skill_with_missing_package_root(self):
|
||||||
logger = Mock()
|
logger = Mock()
|
||||||
@@ -1969,16 +1960,16 @@ class TestBuildSkillExtraMounts:
|
|||||||
'alive': {'name': 'alive', 'package_root': live_dir},
|
'alive': {'name': 'alive', 'package_root': live_dir},
|
||||||
'ghost': {'name': 'ghost', 'package_root': '/nonexistent/path/should/never/exist'},
|
'ghost': {'name': 'ghost', 'package_root': '/nonexistent/path/should/never/exist'},
|
||||||
}
|
}
|
||||||
service = self._make_service(logger, skills)
|
app = self._make_app(logger, skills)
|
||||||
query = make_query()
|
query = make_query()
|
||||||
|
|
||||||
mounts = service.build_skill_extra_mounts(query)
|
mounts = skill_loader.build_execution_mounts(app, query)
|
||||||
|
|
||||||
assert mounts == [
|
assert mounts == [
|
||||||
{
|
{
|
||||||
'host_path': live_dir,
|
'host_path': live_dir,
|
||||||
'mount_path': '/workspace/.skills/alive',
|
'mount_path': '/workspace/.skills/alive',
|
||||||
'mode': 'rw',
|
'mode': 'ro',
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
# Warning logged so operators can see what was dropped
|
# Warning logged so operators can see what was dropped
|
||||||
@@ -1987,27 +1978,19 @@ class TestBuildSkillExtraMounts:
|
|||||||
for call in logger.warning.call_args_list
|
for call in logger.warning.call_args_list
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_trusts_box_paths_when_filesystem_not_shared(self):
|
def test_rejects_missing_core_paths_when_filesystem_not_shared(self):
|
||||||
"""In separated deployments (Docker Compose, k8s sidecar,
|
"""Core owns package paths even when Box is a separate process."""
|
||||||
--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."""
|
|
||||||
logger = Mock()
|
logger = Mock()
|
||||||
skills = {
|
skills = {
|
||||||
'a': {'name': 'a', 'package_root': '/box/skills/a'},
|
'a': {'name': 'a', 'package_root': '/box/skills/a'},
|
||||||
'b': {'name': 'b', 'package_root': '/box/skills/b'},
|
'b': {'name': 'b', 'package_root': '/box/skills/b'},
|
||||||
}
|
}
|
||||||
service = self._make_service(logger, skills, shares_filesystem=False)
|
app = self._make_app(logger, skills)
|
||||||
|
|
||||||
mounts = service.build_skill_extra_mounts(make_query())
|
mounts = skill_loader.build_execution_mounts(app, make_query())
|
||||||
|
|
||||||
assert mounts == [
|
assert mounts == []
|
||||||
{'host_path': '/box/skills/a', 'mount_path': '/workspace/.skills/a', 'mode': 'rw'},
|
assert len(logger.warning.call_args_list) == 2
|
||||||
{'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_skips_skill_with_empty_package_root(self):
|
||||||
logger = Mock()
|
logger = Mock()
|
||||||
@@ -2015,25 +1998,23 @@ class TestBuildSkillExtraMounts:
|
|||||||
'no_root': {'name': 'no_root', 'package_root': ''},
|
'no_root': {'name': 'no_root', 'package_root': ''},
|
||||||
'whitespace': {'name': 'whitespace', 'package_root': ' '},
|
'whitespace': {'name': 'whitespace', 'package_root': ' '},
|
||||||
}
|
}
|
||||||
service = self._make_service(logger, skills)
|
app = self._make_app(logger, skills)
|
||||||
|
|
||||||
assert service.build_skill_extra_mounts(make_query()) == []
|
assert skill_loader.build_execution_mounts(app, make_query()) == []
|
||||||
|
|
||||||
def test_empty_package_root_skipped_even_when_not_shared(self):
|
def test_empty_package_root_skipped_even_when_not_shared(self):
|
||||||
"""An empty package_root is always invalid regardless of topology."""
|
"""An empty package_root is always invalid regardless of topology."""
|
||||||
logger = Mock()
|
logger = Mock()
|
||||||
skills = {'no_root': {'name': 'no_root', 'package_root': ''}}
|
skills = {'no_root': {'name': 'no_root', 'package_root': ''}}
|
||||||
service = self._make_service(logger, skills, shares_filesystem=False)
|
app = self._make_app(logger, skills)
|
||||||
|
|
||||||
assert service.build_skill_extra_mounts(make_query()) == []
|
assert skill_loader.build_execution_mounts(app, make_query()) == []
|
||||||
|
|
||||||
def test_returns_empty_when_no_skill_manager(self):
|
def test_returns_empty_when_no_skill_manager(self):
|
||||||
logger = Mock()
|
logger = Mock()
|
||||||
app = make_app(logger)
|
app = make_app(logger)
|
||||||
# no skill_mgr attribute
|
# no skill_mgr attribute
|
||||||
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
|
assert skill_loader.build_execution_mounts(app, make_query()) == []
|
||||||
|
|
||||||
assert service.build_skill_extra_mounts(make_query()) == []
|
|
||||||
|
|
||||||
|
|
||||||
# ── Attachment passthrough (inbound / outbound) ─────────────────────────────
|
# ── Attachment passthrough (inbound / outbound) ─────────────────────────────
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ from langbot.pkg.box.workspace import (
|
|||||||
classify_python_workspace,
|
classify_python_workspace,
|
||||||
infer_workspace_host_path,
|
infer_workspace_host_path,
|
||||||
rewrite_mounted_path,
|
rewrite_mounted_path,
|
||||||
wrap_python_command_with_env,
|
|
||||||
)
|
)
|
||||||
|
from langbot.pkg.utils.python_workspace import wrap_python_command_with_env
|
||||||
|
|
||||||
|
|
||||||
_CONTEXT = ExecutionContext(
|
_CONTEXT = ExecutionContext(
|
||||||
|
|||||||
@@ -67,15 +67,10 @@ def _make_skill_data(
|
|||||||
|
|
||||||
|
|
||||||
class TestSkillManagerCache:
|
class TestSkillManagerCache:
|
||||||
"""The Box runtime is the only source of truth — SkillManager just holds
|
"""SkillManager caches the Core-owned SkillRepository catalog."""
|
||||||
an in-memory cache populated by ``reload_skills``. There is no local
|
|
||||||
filesystem reader anymore."""
|
|
||||||
|
|
||||||
def test_refresh_skill_from_disk_reports_cache_presence(self):
|
def test_refresh_skill_from_disk_reports_cache_presence(self):
|
||||||
"""Box is the only source of truth for skill content. refresh_skill_from_disk
|
"""Disk mutations are reflected by an explicit repository reload."""
|
||||||
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."""
|
|
||||||
from langbot.pkg.skill.manager import SkillManager
|
from langbot.pkg.skill.manager import SkillManager
|
||||||
|
|
||||||
ap = _make_ap()
|
ap = _make_ap()
|
||||||
@@ -92,67 +87,26 @@ class TestSkillManagerCache:
|
|||||||
assert mgr.refresh_skill_from_disk(_CONTEXT, '') is False
|
assert mgr.refresh_skill_from_disk(_CONTEXT, '') is False
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reload_skills_drops_box_skills_with_missing_package_root(self):
|
async def test_reload_skills_uses_repository_when_box_is_disabled(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."""
|
|
||||||
from langbot.pkg.skill.manager import SkillManager
|
from langbot.pkg.skill.manager import SkillManager
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as live_dir:
|
repository = SimpleNamespace(
|
||||||
ghost_dir = os.path.join(live_dir, '_does_not_exist')
|
|
||||||
box_service = SimpleNamespace(
|
|
||||||
available=True,
|
|
||||||
shares_filesystem_with_box=True,
|
|
||||||
list_skills=AsyncMock(
|
list_skills=AsyncMock(
|
||||||
return_value=[
|
return_value=[
|
||||||
_make_skill_data(name='alive', package_root=live_dir),
|
_make_skill_data(name='alpha', package_root='/skills/alpha'),
|
||||||
_make_skill_data(name='ghost', package_root=ghost_dir),
|
_make_skill_data(name='beta', package_root='/skills/beta'),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
ap = _make_ap()
|
ap = _make_ap()
|
||||||
ap.box_service = box_service
|
ap.box_service = SimpleNamespace(available=False, enabled=False)
|
||||||
mgr = SkillManager(ap)
|
ap.skill_repository = repository
|
||||||
|
|
||||||
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,
|
|
||||||
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'),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
ap = _make_ap()
|
|
||||||
ap.box_service = box_service
|
|
||||||
mgr = SkillManager(ap)
|
mgr = SkillManager(ap)
|
||||||
|
|
||||||
await mgr.reload_skills(_CONTEXT)
|
await mgr.reload_skills(_CONTEXT)
|
||||||
|
|
||||||
assert sorted(mgr.get_skills(_CONTEXT)) == ['alpha', 'beta']
|
assert sorted(mgr.get_skills(_CONTEXT)) == ['alpha', 'beta']
|
||||||
# No skill dropped → no "package_root missing" warning.
|
repository.list_skills.assert_awaited_once_with(_CONTEXT)
|
||||||
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:
|
class TestSkillActivationHelper:
|
||||||
@@ -322,7 +276,7 @@ class TestSkillPathHelpers:
|
|||||||
|
|
||||||
|
|
||||||
class TestSkillToolLoader:
|
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/
|
The legacy CRUD authoring tools (create/list/get/update/delete/
|
||||||
import_skill_from_directory/reload_skills) were removed; skill CRUD is
|
import_skill_from_directory/reload_skills) were removed; skill CRUD is
|
||||||
@@ -338,8 +292,11 @@ class TestSkillToolLoader:
|
|||||||
from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILLS_KEY
|
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 = _make_skill_data(name='demo', package_root='/data/skills/demo', instructions='Step 1')
|
||||||
|
skill['revision'] = 'sha256:demo'
|
||||||
ap = _make_ap()
|
ap = _make_ap()
|
||||||
ap.skill_mgr = _make_skill_manager({'demo': skill})
|
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)
|
loader = SkillToolLoader(ap)
|
||||||
query = _make_query()
|
query = _make_query()
|
||||||
@@ -348,9 +305,13 @@ class TestSkillToolLoader:
|
|||||||
|
|
||||||
assert result['activated'] is True
|
assert result['activated'] is True
|
||||||
assert result['skill_name'] == 'demo'
|
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 result['activated_skill_names'] == ['demo']
|
||||||
assert 'Step 1' in result['content']
|
assert 'Step 1' in result['content']
|
||||||
|
assert '<package-root>' not in result['content']
|
||||||
assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'demo'}
|
assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'demo'}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -384,7 +345,11 @@ class TestSkillToolLoader:
|
|||||||
os.makedirs(repo_dir)
|
os.makedirs(repo_dir)
|
||||||
|
|
||||||
ap = _make_ap()
|
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(
|
ap.skill_service = SimpleNamespace(
|
||||||
scan_directory_async=AsyncMock(
|
scan_directory_async=AsyncMock(
|
||||||
return_value={
|
return_value={
|
||||||
@@ -394,7 +359,7 @@ class TestSkillToolLoader:
|
|||||||
'instructions': 'Do work',
|
'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))
|
return_value=_make_skill_data(name='cloned-skill', package_root=os.path.realpath(repo_dir))
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -407,14 +372,14 @@ class TestSkillToolLoader:
|
|||||||
)
|
)
|
||||||
|
|
||||||
ap.skill_service.scan_directory_async.assert_awaited_once_with(_CONTEXT, os.path.realpath(repo_dir))
|
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,
|
_CONTEXT,
|
||||||
|
os.path.realpath(repo_dir),
|
||||||
{
|
{
|
||||||
'name': 'cloned-skill',
|
'name': 'cloned-skill',
|
||||||
'display_name': 'Cloned Skill',
|
'display_name': 'Cloned Skill',
|
||||||
'description': 'Imported from clone',
|
'description': 'Imported from clone',
|
||||||
'instructions': 'Do work',
|
'instructions': 'Do work',
|
||||||
'package_root': os.path.realpath(repo_dir),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert result['registered'] is True
|
assert result['registered'] is True
|
||||||
@@ -430,8 +395,15 @@ class TestSkillToolLoader:
|
|||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
ap = _make_ap()
|
ap = _make_ap()
|
||||||
ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True)
|
ap.box_service = SimpleNamespace(
|
||||||
ap.skill_service = SimpleNamespace(scan_directory_async=AsyncMock(), create_skill=AsyncMock())
|
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)
|
loader = SkillToolLoader(ap)
|
||||||
|
|
||||||
@@ -451,7 +423,11 @@ class TestSkillToolLoader:
|
|||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
ap = _make_ap() # no skill_service attribute
|
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)
|
loader = SkillToolLoader(ap)
|
||||||
|
|
||||||
@@ -463,21 +439,22 @@ class TestSkillToolLoader:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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
|
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
|
||||||
|
|
||||||
ap = _make_ap()
|
ap = _make_ap()
|
||||||
ap.skill_mgr = SimpleNamespace(skills={})
|
ap.skill_mgr = SimpleNamespace(skills={})
|
||||||
ap.box_service = SimpleNamespace(
|
ap.skill_repository = SimpleNamespace()
|
||||||
available=True,
|
|
||||||
get_backend_status=AsyncMock(return_value={'backend': {'available': False}}),
|
|
||||||
)
|
|
||||||
|
|
||||||
loader = SkillToolLoader(ap)
|
loader = SkillToolLoader(ap)
|
||||||
await loader.initialize()
|
await loader.initialize()
|
||||||
|
|
||||||
assert await loader.get_tools() == []
|
assert sorted(tool.name for tool in await loader.get_tools(sandbox_available=False)) == [
|
||||||
assert await loader.has_tool('activate') is 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
|
assert await loader.has_tool('register_skill') is False
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -486,38 +463,89 @@ class TestSkillToolLoader:
|
|||||||
|
|
||||||
ap = _make_ap()
|
ap = _make_ap()
|
||||||
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo')})
|
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo')})
|
||||||
ap.box_service = SimpleNamespace(
|
ap.skill_repository = SimpleNamespace()
|
||||||
available=True,
|
|
||||||
get_backend_status=AsyncMock(return_value={'backend': {'available': True}}),
|
|
||||||
)
|
|
||||||
|
|
||||||
loader = SkillToolLoader(ap)
|
loader = SkillToolLoader(ap)
|
||||||
await loader.initialize()
|
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('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
|
@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
|
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
|
||||||
|
|
||||||
ap = _make_ap()
|
ap = _make_ap()
|
||||||
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')})
|
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')})
|
||||||
ap.box_service = SimpleNamespace(
|
ap.skill_repository = SimpleNamespace()
|
||||||
available=False,
|
|
||||||
get_backend_status=AsyncMock(return_value={'backend': {'available': True}}),
|
|
||||||
)
|
|
||||||
|
|
||||||
loader = SkillToolLoader(ap)
|
loader = SkillToolLoader(ap)
|
||||||
await loader.initialize()
|
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:
|
class TestNativeToolLoaderSkillPaths:
|
||||||
@@ -551,7 +579,7 @@ class TestNativeToolLoaderSkillPaths:
|
|||||||
assert result['truncated'] is False
|
assert result['truncated'] is False
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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.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
|
||||||
|
|
||||||
@@ -563,7 +591,9 @@ class TestNativeToolLoaderSkillPaths:
|
|||||||
ap.box_service = SimpleNamespace(
|
ap.box_service = SimpleNamespace(
|
||||||
available=True,
|
available=True,
|
||||||
shares_filesystem_with_box=False,
|
shares_filesystem_with_box=False,
|
||||||
read_skill_file=AsyncMock(return_value={'content': 'runtime-owned-content'}),
|
)
|
||||||
|
ap.skill_repository = SimpleNamespace(
|
||||||
|
read_skill_file=AsyncMock(return_value={'content': 'repository-content'})
|
||||||
)
|
)
|
||||||
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
|
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
|
||||||
loader = NativeToolLoader(ap)
|
loader = NativeToolLoader(ap)
|
||||||
@@ -579,12 +609,12 @@ class TestNativeToolLoaderSkillPaths:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result['ok'] is True
|
assert result['ok'] is True
|
||||||
assert result['content'] == 'runtime-owned-content'
|
assert result['content'] == 'repository-content'
|
||||||
assert 'core-host-secret' not in repr(result)
|
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_file.assert_awaited_once_with(_CONTEXT, 'demo', 'SKILL.md')
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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.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
|
||||||
|
|
||||||
@@ -604,8 +634,7 @@ class TestNativeToolLoaderSkillPaths:
|
|||||||
variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']},
|
variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']},
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='owned by the Box Runtime'):
|
result = await loader.invoke_tool(
|
||||||
await loader.invoke_tool(
|
|
||||||
'grep',
|
'grep',
|
||||||
{
|
{
|
||||||
'path': '/workspace/.skills/demo',
|
'path': '/workspace/.skills/demo',
|
||||||
@@ -614,8 +643,11 @@ class TestNativeToolLoaderSkillPaths:
|
|||||||
query,
|
query,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert result['ok'] is True
|
||||||
|
assert result['total'] == 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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.native import NativeToolLoader
|
||||||
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
|
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
|
||||||
|
|
||||||
@@ -626,11 +658,15 @@ class TestNativeToolLoaderSkillPaths:
|
|||||||
default_workspace=tmpdir,
|
default_workspace=tmpdir,
|
||||||
execute_tool=AsyncMock(return_value={'ok': True}),
|
execute_tool=AsyncMock(return_value={'ok': True}),
|
||||||
)
|
)
|
||||||
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
|
skill_data = _make_skill_data(name='demo', package_root=tmpdir)
|
||||||
|
ap.skill_mgr = _make_skill_manager(
|
||||||
|
{'demo': skill_data},
|
||||||
|
refresh_skill_from_disk=Mock(),
|
||||||
|
)
|
||||||
loader = NativeToolLoader(ap)
|
loader = NativeToolLoader(ap)
|
||||||
|
|
||||||
query = _make_query(query_id='q1', launcher_type='person', launcher_id='123')
|
query = _make_query(query_id='q1', launcher_type='person', launcher_id='123')
|
||||||
register_activated_skill(query, _make_skill_data(name='demo', package_root=tmpdir))
|
register_activated_skill(query, skill_data)
|
||||||
|
|
||||||
result = await loader.invoke_tool(
|
result = await loader.invoke_tool(
|
||||||
'exec',
|
'exec',
|
||||||
@@ -645,8 +681,8 @@ class TestNativeToolLoaderSkillPaths:
|
|||||||
tool_parameters = ap.box_service.execute_tool.await_args.args[0]
|
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['command'] == 'python /workspace/.skills/demo/scripts/run.py'
|
||||||
assert tool_parameters['workdir'] == '/workspace/.skills/demo'
|
assert tool_parameters['workdir'] == '/workspace/.skills/demo'
|
||||||
assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo'
|
assert 'skill_name' not in ap.box_service.execute_tool.await_args.kwargs
|
||||||
ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with(_CONTEXT, 'demo')
|
ap.skill_mgr.refresh_skill_from_disk.assert_not_called()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_external_runtime_python_skill_uses_trusted_metadata_and_writable_env(self):
|
async def test_external_runtime_python_skill_uses_trusted_metadata_and_writable_env(self):
|
||||||
@@ -659,17 +695,18 @@ class TestNativeToolLoaderSkillPaths:
|
|||||||
shares_filesystem_with_box=False,
|
shares_filesystem_with_box=False,
|
||||||
execute_tool=AsyncMock(return_value={'ok': True}),
|
execute_tool=AsyncMock(return_value={'ok': True}),
|
||||||
)
|
)
|
||||||
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
|
skill_data = _make_skill_data(
|
||||||
loader = NativeToolLoader(ap)
|
|
||||||
query = _make_query(query_id='q-external', launcher_type='person', launcher_id='123')
|
|
||||||
register_activated_skill(
|
|
||||||
query,
|
|
||||||
_make_skill_data(
|
|
||||||
name='demo',
|
name='demo',
|
||||||
package_root='/box-runtime/skills/tenants/workspace/demo',
|
package_root='/box-runtime/skills/tenants/workspace/demo',
|
||||||
python_project=True,
|
python_project=True,
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
ap.skill_mgr = _make_skill_manager(
|
||||||
|
{'demo': skill_data},
|
||||||
|
refresh_skill_from_disk=Mock(),
|
||||||
|
)
|
||||||
|
loader = NativeToolLoader(ap)
|
||||||
|
query = _make_query(query_id='q-external', launcher_type='person', launcher_id='123')
|
||||||
|
register_activated_skill(query, skill_data)
|
||||||
|
|
||||||
result = await loader.invoke_tool(
|
result = await loader.invoke_tool(
|
||||||
'exec',
|
'exec',
|
||||||
@@ -686,7 +723,7 @@ class TestNativeToolLoaderSkillPaths:
|
|||||||
assert '_LB_VENV_DIR="/workspace/.skill-envs/demo/.venv"' in wrapped
|
assert '_LB_VENV_DIR="/workspace/.skill-envs/demo/.venv"' in wrapped
|
||||||
assert 'root = "/workspace/.skills/demo"' in wrapped
|
assert 'root = "/workspace/.skills/demo"' in wrapped
|
||||||
assert '/box-runtime/skills/tenants/workspace/demo' not in wrapped
|
assert '/box-runtime/skills/tenants/workspace/demo' not in wrapped
|
||||||
assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo'
|
assert 'skill_name' not in ap.box_service.execute_tool.await_args.kwargs
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_write_requires_skill_activation(self):
|
async def test_write_requires_skill_activation(self):
|
||||||
|
|||||||
@@ -55,10 +55,20 @@ class StubLoader:
|
|||||||
for tool in self._tools
|
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]
|
name = args[-1]
|
||||||
return any(tool.name == name for tool in self._tools)
|
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):
|
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
|
return self._invoke_result(name, parameters, query) if callable(self._invoke_result) else self._invoke_result
|
||||||
|
|
||||||
@@ -145,7 +155,7 @@ async def test_tool_manager_routes_native_tool_calls():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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))
|
box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
|
||||||
manager = ToolManager(SimpleNamespace(box_service=box_service))
|
manager = ToolManager(SimpleNamespace(box_service=box_service))
|
||||||
manager.native_tool_loader = StubLoader([make_tool('exec')])
|
manager.native_tool_loader = StubLoader([make_tool('exec')])
|
||||||
@@ -156,8 +166,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)
|
tools = await manager.get_all_tools(_CONTEXT, include_skill_authoring=True)
|
||||||
catalog = await manager.get_tool_catalog(_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 [tool.name for tool in tools] == ['activate', 'plugin_tool', 'mcp_tool']
|
||||||
assert [item['name'] for item in catalog] == ['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
|
assert box_service.is_workspace_sandbox_available.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
@@ -176,9 +186,10 @@ async def test_tool_manager_rechecks_workspace_entitlement_before_native_invocat
|
|||||||
query_uuid=None,
|
query_uuid=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(Exception, match='exec'):
|
result = await manager.execute_func_call('exec', {'command': 'pwd'}, query=query)
|
||||||
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)
|
box_service.is_workspace_sandbox_available.assert_awaited_once_with(_CONTEXT)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ async def test_preproc_injects_skill_index_into_system_prompt():
|
|||||||
preproc_module, entities_module = _import_preproc_modules()
|
preproc_module, entities_module = _import_preproc_modules()
|
||||||
|
|
||||||
app = _make_app(skill_service=SimpleNamespace())
|
app = _make_app(skill_service=SimpleNamespace())
|
||||||
|
app.tool_mgr.get_all_tools.return_value = [SimpleNamespace(name='activate')]
|
||||||
addendum = '\n\nAvailable Skills:\n- demo (demo): Demo skill.\n\nCall activate ...'
|
addendum = '\n\nAvailable Skills:\n- demo (demo): Demo skill.\n\nCall activate ...'
|
||||||
app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value=addendum)
|
app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value=addendum)
|
||||||
|
|
||||||
@@ -204,6 +205,22 @@ async def test_preproc_injects_skill_index_into_system_prompt():
|
|||||||
head = query.prompt.messages[0]
|
head = query.prompt.messages[0]
|
||||||
assert head.role == 'system'
|
assert head.role == 'system'
|
||||||
assert head.content.endswith(addendum)
|
assert head.content.endswith(addendum)
|
||||||
|
assert query.variables['_skill_execution_available'] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preproc_does_not_advertise_activation_when_tool_is_filtered_out():
|
||||||
|
preproc_module, entities_module = _import_preproc_modules()
|
||||||
|
|
||||||
|
app = _make_app(skill_service=SimpleNamespace())
|
||||||
|
addendum = '\n\nAvailable Skills:\n- demo (demo): Demo skill.\n\nCall activate ...'
|
||||||
|
app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value=addendum)
|
||||||
|
|
||||||
|
query = _make_query()
|
||||||
|
result = await stage_process_capture(preproc_module, app, query)
|
||||||
|
|
||||||
|
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||||
|
assert addendum not in query.prompt.messages[0].content
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from langbot.pkg.api.http.context import ExecutionContext
|
||||||
|
from langbot.pkg.skill.repository import SkillRepository, SkillRevisionMismatchError
|
||||||
|
|
||||||
|
|
||||||
|
_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)
|
||||||
|
|
||||||
|
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.',
|
||||||
|
)
|
||||||
|
|
||||||
|
skill = await repository.get_skill(_CONTEXT, 'docs-only', snapshot=True)
|
||||||
|
assert skill is not None
|
||||||
|
assert skill['revision'].startswith('stat-v1:')
|
||||||
|
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_stale_revision(tmp_path):
|
||||||
|
repository = _repository(tmp_path)
|
||||||
|
await repository.create_skill(
|
||||||
|
_CONTEXT,
|
||||||
|
{'name': 'safe', 'description': 'Safe', 'instructions': 'Use the reference.'},
|
||||||
|
)
|
||||||
|
await repository.write_skill_file(_CONTEXT, 'safe', 'reference.md', 'first')
|
||||||
|
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')
|
||||||
|
|
||||||
|
await repository.write_skill_file(_CONTEXT, 'safe', 'reference.md', 'second')
|
||||||
|
with pytest.raises(SkillRevisionMismatchError, match='reactivate'):
|
||||||
|
await repository.read_skill_resource(
|
||||||
|
_CONTEXT,
|
||||||
|
'safe',
|
||||||
|
'reference.md',
|
||||||
|
expected_revision=skill['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))
|
||||||
@@ -23,102 +23,73 @@ def _workspace_service():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestRequireBoxForWrite:
|
class TestSkillRepositoryBoundary:
|
||||||
"""Box is the only source of truth for skills — there is no local
|
"""Skill management and reads remain available without Box execution."""
|
||||||
filesystem fallback. Every write and (most) read methods refuse cleanly
|
|
||||||
when the Box runtime is disabled, unreachable, or simply not installed."""
|
|
||||||
|
|
||||||
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'}),
|
||||||
|
update_skill=AsyncMock(return_value={'name': 'x', 'instructions': 'Updated'}),
|
||||||
|
delete_skill=AsyncMock(),
|
||||||
|
read_skill_file=AsyncMock(return_value={'path': 'a.txt', 'content': 'hello'}),
|
||||||
|
write_skill_file=AsyncMock(return_value={'path': 'a.txt'}),
|
||||||
|
)
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
|
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
|
||||||
workspace_service=_workspace_service(),
|
workspace_service=_workspace_service(),
|
||||||
box_service=SimpleNamespace(
|
box_service=SimpleNamespace(available=False, enabled=False),
|
||||||
available=False,
|
skill_repository=repository,
|
||||||
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',
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_skill_refused_when_box_disabled(self):
|
async def test_list_and_read_work_when_box_disabled(self):
|
||||||
service = SkillService(self._ap_with_disabled_box())
|
ap = self._ap_with_repository()
|
||||||
with pytest.raises(ValueError, match='disabled in config'):
|
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_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.create_skill(_CONTEXT, {'name': 'x'})
|
||||||
|
await service.update_skill(_CONTEXT, 'x', {'instructions': 'Updated'})
|
||||||
|
await service.write_skill_file(_CONTEXT, 'x', 'a.txt', 'hello')
|
||||||
|
|
||||||
|
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'})
|
||||||
|
ap.skill_repository.write_skill_file.assert_awaited_once_with(_CONTEXT, 'x', 'a.txt', 'hello')
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_skill_refused_when_box_failed(self):
|
async def test_get_skill_returns_repository_revision(self):
|
||||||
service = SkillService(self._ap_with_failed_box())
|
ap = self._ap_with_repository()
|
||||||
with pytest.raises(ValueError, match='docker daemon not running'):
|
service = SkillService(ap)
|
||||||
await service.create_skill(_CONTEXT, {'name': 'x'})
|
|
||||||
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_update_skill_refused_when_box_disabled(self):
|
async def test_missing_repository_is_explicit(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', {})
|
|
||||||
|
|
||||||
@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."""
|
|
||||||
service = SkillService(
|
service = SkillService(
|
||||||
SimpleNamespace(
|
SimpleNamespace(
|
||||||
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
|
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
|
||||||
workspace_service=_workspace_service(),
|
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'})
|
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_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:
|
class TestGithubSkillArchiveLimits:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ import {
|
|||||||
import { Plugin } from '@/app/infra/entities/plugin';
|
import { Plugin } from '@/app/infra/entities/plugin';
|
||||||
import { MCPServer, Skill } from '@/app/infra/entities/api';
|
import { MCPServer, Skill } from '@/app/infra/entities/api';
|
||||||
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
||||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
|
||||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
|
||||||
|
|
||||||
function InfoTooltip({ label }: { label: string }) {
|
function InfoTooltip({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -52,11 +50,6 @@ export default function PipelineExtension({
|
|||||||
pipelineId: string;
|
pipelineId: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
|
||||||
available: boxAvailable,
|
|
||||||
hint: boxHint,
|
|
||||||
reason: boxReason,
|
|
||||||
} = useBoxStatus();
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [enableAllPlugins, setEnableAllPlugins] = useState(true);
|
const [enableAllPlugins, setEnableAllPlugins] = useState(true);
|
||||||
const [enableAllMCPServers, setEnableAllMCPServers] = useState(true);
|
const [enableAllMCPServers, setEnableAllMCPServers] = useState(true);
|
||||||
@@ -558,13 +551,9 @@ export default function PipelineExtension({
|
|||||||
id="enable-all-skills"
|
id="enable-all-skills"
|
||||||
checked={enableAllSkills}
|
checked={enableAllSkills}
|
||||||
onCheckedChange={handleToggleEnableAllSkills}
|
onCheckedChange={handleToggleEnableAllSkills}
|
||||||
disabled={!boxAvailable}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!boxAvailable && (
|
|
||||||
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
|
|
||||||
)}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{enableAllSkills ? (
|
{enableAllSkills ? (
|
||||||
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
|
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
|
||||||
@@ -602,7 +591,6 @@ export default function PipelineExtension({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleRemoveSkill(skill.name)}
|
onClick={() => handleRemoveSkill(skill.name)}
|
||||||
disabled={!boxAvailable}
|
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -616,7 +604,7 @@ export default function PipelineExtension({
|
|||||||
onClick={handleOpenSkillDialog}
|
onClick={handleOpenSkillDialog}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={enableAllSkills || !boxAvailable}
|
disabled={enableAllSkills}
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
{t('pipelines.extensions.addSkill')}
|
{t('pipelines.extensions.addSkill')}
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ import {
|
|||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
|
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 { Sparkles, Trash2 } from 'lucide-react';
|
||||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||||
|
|
||||||
@@ -36,12 +34,6 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
|||||||
const { refreshSkills, skills, setDetailEntityName } = useSidebarData();
|
const { refreshSkills, skills, setDetailEntityName } = useSidebarData();
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const skill = skills.find((item) => item.id === id);
|
const skill = skills.find((item) => item.id === id);
|
||||||
const {
|
|
||||||
available: boxAvailable,
|
|
||||||
hint: boxHint,
|
|
||||||
reason: boxReason,
|
|
||||||
} = useBoxStatus();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreateMode) {
|
if (isCreateMode) {
|
||||||
setDetailEntityName(t('skills.createSkill'));
|
setDetailEntityName(t('skills.createSkill'));
|
||||||
@@ -93,23 +85,12 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Button
|
<Button type="submit" form="skill-form" className="shrink-0">
|
||||||
type="submit"
|
|
||||||
form="skill-form"
|
|
||||||
className="shrink-0"
|
|
||||||
disabled={!boxAvailable}
|
|
||||||
>
|
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!boxAvailable && (
|
|
||||||
<div className="pb-4 shrink-0">
|
|
||||||
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<fieldset className="contents" disabled={!canManage}>
|
<fieldset className="contents" disabled={!canManage}>
|
||||||
<SkillForm
|
<SkillForm
|
||||||
@@ -184,18 +165,12 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
|||||||
type="submit"
|
type="submit"
|
||||||
form="skill-form"
|
form="skill-form"
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
disabled={!boxAvailable}
|
disabled={!canManage}
|
||||||
>
|
>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!boxAvailable && (
|
|
||||||
<div className="pb-4 shrink-0">
|
|
||||||
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<fieldset className="contents" disabled={!canManage}>
|
<fieldset className="contents" disabled={!canManage}>
|
||||||
<SkillForm
|
<SkillForm
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { Button } from '@/components/ui/button';
|
|||||||
import SkillDetailContent from '@/app/home/skills/SkillDetailContent';
|
import SkillDetailContent from '@/app/home/skills/SkillDetailContent';
|
||||||
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
|
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
|
||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
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';
|
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||||
|
|
||||||
export default function SkillsPage() {
|
export default function SkillsPage() {
|
||||||
@@ -21,12 +19,6 @@ export default function SkillsPage() {
|
|||||||
const { refreshSkills } = useSidebarData();
|
const { refreshSkills } = useSidebarData();
|
||||||
|
|
||||||
const isCreateView = actionParam === 'create';
|
const isCreateView = actionParam === 'create';
|
||||||
const {
|
|
||||||
available: boxAvailable,
|
|
||||||
hint: boxHint,
|
|
||||||
reason: boxReason,
|
|
||||||
} = useBoxStatus();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detailId && !isCreateView) {
|
if (!detailId && !isCreateView) {
|
||||||
navigate('/home/add-extension', { replace: true });
|
navigate('/home/add-extension', { replace: true });
|
||||||
@@ -65,20 +57,11 @@ export default function SkillsPage() {
|
|||||||
<Button variant="outline" onClick={handleCancel}>
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="submit" form="skill-form" disabled={!canManage}>
|
||||||
type="submit"
|
|
||||||
form="skill-form"
|
|
||||||
disabled={!boxAvailable || !canManage}
|
|
||||||
>
|
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!boxAvailable && (
|
|
||||||
<div className="pb-4 shrink-0">
|
|
||||||
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<fieldset className="contents" disabled={!canManage}>
|
<fieldset className="contents" disabled={!canManage}>
|
||||||
<SkillForm
|
<SkillForm
|
||||||
|
|||||||
@@ -1021,7 +1021,7 @@ const enUS = {
|
|||||||
mcpToolsScopeTooltip:
|
mcpToolsScopeTooltip:
|
||||||
'Only tools from MCP servers currently allowed in Extensions are shown here.',
|
'Only tools from MCP servers currently allowed in Extensions are shown here.',
|
||||||
skillToolsScopeTooltip:
|
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',
|
selectTools: 'Select tools',
|
||||||
resourcesTitle: 'Resources',
|
resourcesTitle: 'Resources',
|
||||||
resourcesDescription:
|
resourcesDescription:
|
||||||
@@ -1630,9 +1630,9 @@ const enUS = {
|
|||||||
statusDetail: 'Status',
|
statusDetail: 'Status',
|
||||||
pluginDisabled: 'Plugin system is disabled',
|
pluginDisabled: 'Plugin system is disabled',
|
||||||
boxDisabled:
|
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:
|
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:
|
boxRequiredHint:
|
||||||
'This feature requires the Box runtime. Enable it in config (box.enabled = true) and ensure the runtime is healthy.',
|
'This feature requires the Box runtime. Enable it in config (box.enabled = true) and ensure the runtime is healthy.',
|
||||||
boxBackend: 'Backend',
|
boxBackend: 'Backend',
|
||||||
|
|||||||
@@ -1044,7 +1044,7 @@ const esES = {
|
|||||||
mcpToolsScopeTooltip:
|
mcpToolsScopeTooltip:
|
||||||
'Aquí solo se muestran herramientas de servidores MCP permitidos actualmente en Extensiones.',
|
'Aquí solo se muestran herramientas de servidores MCP permitidos actualmente en Extensiones.',
|
||||||
skillToolsScopeTooltip:
|
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',
|
selectTools: 'Seleccionar herramientas',
|
||||||
resourcesTitle: 'Recursos',
|
resourcesTitle: 'Recursos',
|
||||||
resourcesDescription:
|
resourcesDescription:
|
||||||
@@ -1587,9 +1587,9 @@ const esES = {
|
|||||||
statusDetail: 'Estado',
|
statusDetail: 'Estado',
|
||||||
pluginDisabled: 'El sistema de plugins está desactivado',
|
pluginDisabled: 'El sistema de plugins está desactivado',
|
||||||
boxDisabled:
|
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:
|
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:
|
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.',
|
'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',
|
boxBackend: 'Backend',
|
||||||
|
|||||||
@@ -1026,7 +1026,7 @@ const jaJP = {
|
|||||||
mcpToolsScopeTooltip:
|
mcpToolsScopeTooltip:
|
||||||
'拡張機能で現在許可されている MCP サーバーのツールだけが表示されます。',
|
'拡張機能で現在許可されている MCP サーバーのツールだけが表示されます。',
|
||||||
skillToolsScopeTooltip:
|
skillToolsScopeTooltip:
|
||||||
'スキルツールは LangBot のスキルサービスと Box サンドボックスバックエンドが利用可能なときに表示され、Agent がスキルを有効化または登録できるようにします。',
|
'スキルの有効化と読み取り専用リソースには Box は不要です。Agent ワークスペースからの登録には Box サンドボックスが必要です。',
|
||||||
selectTools: 'ツールを選択',
|
selectTools: 'ツールを選択',
|
||||||
resourcesTitle: 'リソース',
|
resourcesTitle: 'リソース',
|
||||||
resourcesDescription:
|
resourcesDescription:
|
||||||
@@ -1638,9 +1638,9 @@ const jaJP = {
|
|||||||
statusDetail: 'ステータス',
|
statusDetail: 'ステータス',
|
||||||
pluginDisabled: 'プラグインシステムが無効です',
|
pluginDisabled: 'プラグインシステムが無効です',
|
||||||
boxDisabled:
|
boxDisabled:
|
||||||
'Box サンドボックスは設定で無効化されています — サンドボックスツール / スキルの追加・編集 / stdio MCP は利用できません',
|
'Box サンドボックスは設定で無効化されています — 実行ツールと stdio MCP は利用できません',
|
||||||
boxUnavailable:
|
boxUnavailable:
|
||||||
'Box サンドボックスは利用できません — サンドボックスツール / スキルの追加・編集 / stdio MCP は利用できません',
|
'Box サンドボックスは利用できません — 実行ツールと stdio MCP は利用できません',
|
||||||
boxRequiredHint:
|
boxRequiredHint:
|
||||||
'この機能には Box ランタイムが必要です。設定で有効化(box.enabled = true)し、ランタイムが正常に接続できることを確認してください。',
|
'この機能には Box ランタイムが必要です。設定で有効化(box.enabled = true)し、ランタイムが正常に接続できることを確認してください。',
|
||||||
boxBackend: 'バックエンド',
|
boxBackend: 'バックエンド',
|
||||||
|
|||||||
@@ -1034,7 +1034,7 @@ const ruRU = {
|
|||||||
mcpToolsScopeTooltip:
|
mcpToolsScopeTooltip:
|
||||||
'Здесь показаны только инструменты MCP-серверов, разрешённых сейчас в Расширениях.',
|
'Здесь показаны только инструменты MCP-серверов, разрешённых сейчас в Расширениях.',
|
||||||
skillToolsScopeTooltip:
|
skillToolsScopeTooltip:
|
||||||
'Инструменты навыков доступны, когда сервис навыков LangBot и backend песочницы Box готовы. Они позволяют агенту активировать или регистрировать навыки.',
|
'Активация навыков и ресурсы только для чтения работают без Box. Регистрация из рабочей области Agent по-прежнему требует песочницу Box.',
|
||||||
selectTools: 'Выбрать инструменты',
|
selectTools: 'Выбрать инструменты',
|
||||||
resourcesTitle: 'Ресурсы',
|
resourcesTitle: 'Ресурсы',
|
||||||
resourcesDescription:
|
resourcesDescription:
|
||||||
@@ -1560,9 +1560,9 @@ const ruRU = {
|
|||||||
statusDetail: 'Статус',
|
statusDetail: 'Статус',
|
||||||
pluginDisabled: 'Система плагинов отключена',
|
pluginDisabled: 'Система плагинов отключена',
|
||||||
boxDisabled:
|
boxDisabled:
|
||||||
'Песочница Box отключена в конфигурации — инструменты песочницы, добавление/редактирование навыков и stdio MCP недоступны',
|
'Песочница Box отключена в конфигурации — инструменты выполнения и stdio MCP недоступны',
|
||||||
boxUnavailable:
|
boxUnavailable:
|
||||||
'Песочница Box недоступна — инструменты песочницы, добавление/редактирование навыков и stdio MCP недоступны',
|
'Песочница Box недоступна — инструменты выполнения и stdio MCP недоступны',
|
||||||
boxRequiredHint:
|
boxRequiredHint:
|
||||||
'Для этой функции требуется среда Box. Включите её в конфигурации (box.enabled = true) и убедитесь, что соединение работает.',
|
'Для этой функции требуется среда Box. Включите её в конфигурации (box.enabled = true) и убедитесь, что соединение работает.',
|
||||||
boxBackend: 'Бэкенд',
|
boxBackend: 'Бэкенд',
|
||||||
|
|||||||
@@ -1009,7 +1009,7 @@ const thTH = {
|
|||||||
mcpToolsScopeTooltip:
|
mcpToolsScopeTooltip:
|
||||||
'ที่นี่จะแสดงเฉพาะเครื่องมือจากเซิร์ฟเวอร์ MCP ที่อนุญาตอยู่ในส่วนขยาย',
|
'ที่นี่จะแสดงเฉพาะเครื่องมือจากเซิร์ฟเวอร์ MCP ที่อนุญาตอยู่ในส่วนขยาย',
|
||||||
skillToolsScopeTooltip:
|
skillToolsScopeTooltip:
|
||||||
'เครื่องมือสกิลจะแสดงเมื่อบริการสกิลของ LangBot และแบ็กเอนด์แซนด์บ็อกซ์ Box พร้อมใช้งาน เพื่อให้ Agent เปิดใช้หรือลงทะเบียนสกิลได้',
|
'การเปิดใช้สกิลและทรัพยากรแบบอ่านอย่างเดียวทำงานได้โดยไม่ต้องใช้ Box ส่วนการลงทะเบียนจาก workspace ของ Agent ยังต้องใช้ sandbox Box',
|
||||||
selectTools: 'เลือกเครื่องมือ',
|
selectTools: 'เลือกเครื่องมือ',
|
||||||
resourcesTitle: 'ทรัพยากร',
|
resourcesTitle: 'ทรัพยากร',
|
||||||
resourcesDescription:
|
resourcesDescription:
|
||||||
@@ -1529,9 +1529,9 @@ const thTH = {
|
|||||||
statusDetail: 'สถานะ',
|
statusDetail: 'สถานะ',
|
||||||
pluginDisabled: 'ระบบปลั๊กอินถูกปิดใช้งาน',
|
pluginDisabled: 'ระบบปลั๊กอินถูกปิดใช้งาน',
|
||||||
boxDisabled:
|
boxDisabled:
|
||||||
'Sandbox Box ถูกปิดใช้งานในการตั้งค่า — เครื่องมือ sandbox, การเพิ่ม/แก้ไข skill และ stdio MCP ใช้งานไม่ได้',
|
'Sandbox Box ถูกปิดใช้งานในการตั้งค่า — เครื่องมือเรียกใช้และ stdio MCP ใช้งานไม่ได้',
|
||||||
boxUnavailable:
|
boxUnavailable:
|
||||||
'Sandbox Box ไม่พร้อมใช้งาน — เครื่องมือ sandbox, การเพิ่ม/แก้ไข skill และ stdio MCP ใช้งานไม่ได้',
|
'Sandbox Box ไม่พร้อมใช้งาน — เครื่องมือเรียกใช้และ stdio MCP ใช้งานไม่ได้',
|
||||||
boxRequiredHint:
|
boxRequiredHint:
|
||||||
'ฟีเจอร์นี้ต้องใช้ Box runtime กรุณาเปิดใช้งานในการตั้งค่า (box.enabled = true) และตรวจสอบว่าการเชื่อมต่อปกติ',
|
'ฟีเจอร์นี้ต้องใช้ Box runtime กรุณาเปิดใช้งานในการตั้งค่า (box.enabled = true) และตรวจสอบว่าการเชื่อมต่อปกติ',
|
||||||
boxBackend: 'แบ็กเอนด์',
|
boxBackend: 'แบ็กเอนด์',
|
||||||
|
|||||||
@@ -1026,7 +1026,7 @@ const viVN = {
|
|||||||
mcpToolsScopeTooltip:
|
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.',
|
'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:
|
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ụ',
|
selectTools: 'Chọn công cụ',
|
||||||
resourcesTitle: 'Tài nguyên',
|
resourcesTitle: 'Tài nguyên',
|
||||||
resourcesDescription:
|
resourcesDescription:
|
||||||
@@ -1553,9 +1553,9 @@ const viVN = {
|
|||||||
statusDetail: 'Trạng thái',
|
statusDetail: 'Trạng thái',
|
||||||
pluginDisabled: 'Hệ thống plugin đã tắt',
|
pluginDisabled: 'Hệ thống plugin đã tắt',
|
||||||
boxDisabled:
|
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:
|
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:
|
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.',
|
'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',
|
boxBackend: 'Backend',
|
||||||
|
|||||||
@@ -978,7 +978,7 @@ const zhHans = {
|
|||||||
mcpToolsScopeTooltip:
|
mcpToolsScopeTooltip:
|
||||||
'这里仅展示扩展集成当前允许的 MCP 服务器提供的工具。',
|
'这里仅展示扩展集成当前允许的 MCP 服务器提供的工具。',
|
||||||
skillToolsScopeTooltip:
|
skillToolsScopeTooltip:
|
||||||
'技能工具会在 LangBot 技能服务和 Box 沙箱后端可用时出现,用于让 Agent 激活或注册技能。',
|
'技能激活和只读资源无需 Box;Agent 从工作区注册技能仍需要 Box 沙箱。',
|
||||||
selectTools: '选择工具',
|
selectTools: '选择工具',
|
||||||
resourcesTitle: '资源',
|
resourcesTitle: '资源',
|
||||||
resourcesDescription: '选择此内置 Agent 可以读取的 MCP 资源和知识库。',
|
resourcesDescription: '选择此内置 Agent 可以读取的 MCP 资源和知识库。',
|
||||||
@@ -1558,10 +1558,8 @@ const zhHans = {
|
|||||||
disabled: '已禁用',
|
disabled: '已禁用',
|
||||||
statusDetail: '状态',
|
statusDetail: '状态',
|
||||||
pluginDisabled: '插件系统已禁用',
|
pluginDisabled: '插件系统已禁用',
|
||||||
boxDisabled:
|
boxDisabled: 'Box 沙箱已在配置中禁用——执行工具与 stdio MCP 不可用',
|
||||||
'Box 沙箱已在配置中禁用——沙箱工具、技能添加/编辑与 stdio MCP 均不可用',
|
boxUnavailable: 'Box 沙箱不可用——执行工具与 stdio MCP 不可用',
|
||||||
boxUnavailable:
|
|
||||||
'Box 沙箱不可用——沙箱工具、技能添加/编辑与 stdio MCP 均不可用',
|
|
||||||
boxRequiredHint:
|
boxRequiredHint:
|
||||||
'此功能依赖 Box 运行时。请在配置中启用(box.enabled = true)并确认运行时连接正常。',
|
'此功能依赖 Box 运行时。请在配置中启用(box.enabled = true)并确认运行时连接正常。',
|
||||||
boxBackend: '后端',
|
boxBackend: '后端',
|
||||||
|
|||||||
@@ -979,7 +979,7 @@ const zhHant = {
|
|||||||
mcpToolsScopeTooltip:
|
mcpToolsScopeTooltip:
|
||||||
'這裡僅展示擴展集成目前允許的 MCP 伺服器提供的工具。',
|
'這裡僅展示擴展集成目前允許的 MCP 伺服器提供的工具。',
|
||||||
skillToolsScopeTooltip:
|
skillToolsScopeTooltip:
|
||||||
'技能工具會在 LangBot 技能服務和 Box 沙箱後端可用時出現,用於讓 Agent 啟用或註冊技能。',
|
'技能啟用和唯讀資源不需要 Box;Agent 從工作區註冊技能仍需要 Box 沙箱。',
|
||||||
selectTools: '選擇工具',
|
selectTools: '選擇工具',
|
||||||
resourcesTitle: '資源',
|
resourcesTitle: '資源',
|
||||||
resourcesDescription: '選擇此內建 Agent 可以讀取的 MCP 資源和知識庫。',
|
resourcesDescription: '選擇此內建 Agent 可以讀取的 MCP 資源和知識庫。',
|
||||||
@@ -1481,10 +1481,8 @@ const zhHant = {
|
|||||||
disabled: '已停用',
|
disabled: '已停用',
|
||||||
statusDetail: '狀態',
|
statusDetail: '狀態',
|
||||||
pluginDisabled: '外掛系統已停用',
|
pluginDisabled: '外掛系統已停用',
|
||||||
boxDisabled:
|
boxDisabled: 'Box 沙箱已在設定中停用——執行工具與 stdio MCP 無法使用',
|
||||||
'Box 沙箱已在設定中停用——沙箱工具、技能新增/編輯與 stdio MCP 均無法使用',
|
boxUnavailable: 'Box 沙箱無法使用——執行工具與 stdio MCP 無法使用',
|
||||||
boxUnavailable:
|
|
||||||
'Box 沙箱無法使用——沙箱工具、技能新增/編輯與 stdio MCP 均無法使用',
|
|
||||||
boxRequiredHint:
|
boxRequiredHint:
|
||||||
'此功能需要 Box 執行時。請在設定中啟用(box.enabled = true)並確認執行時連線正常。',
|
'此功能需要 Box 執行時。請在設定中啟用(box.enabled = true)並確認執行時連線正常。',
|
||||||
boxBackend: '後端',
|
boxBackend: '後端',
|
||||||
|
|||||||
Reference in New Issue
Block a user