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