Compare commits

..

12 Commits

Author SHA1 Message Date
huanghuoguoguo 2fe4b117a4 refactor(skill): move orchestration into core 2026-09-06 12:50:37 +08:00
huanghuoguoguo cba842fbee style: format core skill orchestration 2026-09-05 23:18:17 +08:00
huanghuoguoguo a61b0f0068 refactor(skill): compose generic box mounts in core 2026-09-05 23:09:46 +08:00
huanghuoguoguo c15f668126 feat(skill): use SDK store without box execution 2026-09-05 19:49:35 +08:00
Hyu ec63978ecf docs: replace legacy documentation shortlinks (#2510)
* docs: replace legacy documentation shortlinks

* style: format updated documentation URLs

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-05 01:19:28 +08:00
Hyu 1cfe87186c docs: update published documentation links (#2509)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-05 00:55:18 +08:00
leonoxo 9794df0933 feat(n8n-runner): support async response handling (#2487)
* feat(n8n-runner): support async response handling

* fix(n8n-runner): expose response handling in form

* fix(n8n-runner): preserve async response semantics

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-04 22:36:40 +08:00
Hyu a63808caa6 chore(release): prepare LangBot 4.10.10 (#2507)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-04 21:47:31 +08:00
mintya de3c0b00ad fix(bot): roll back inserted bot row when adapter fails to load (#2497)
create_bot inserts the Bot row first and only then instantiates the
adapter via platform_mgr.load_bot. When the adapter constructor raises
(e.g. KeyError on a missing credential key), the insert is already
committed and nothing removes the row: the HTTP layer returns 500 but
a permanently disabled orphan bot stays in the DB. Callers never
receive the bot uuid, so they cannot compensate by deleting it, and
load_bots_from_db skips enable=False bots, so the orphan is never
loaded or surfaced anywhere.

Wrap load_bot in try/except and delete the inserted row before
re-raising. Add a regression test asserting the DELETE is issued when
the adapter constructor fails.
2026-09-04 13:06:12 +08:00
mintya cb45807b12 fix(qqofficial): tolerate missing optional token in adapter config (#2496)
Since b55f073e the token field is optional in qqofficial.yaml ("the
current adapter implementation does not use it either, so it can be
safely left blank"), but the adapter constructor still reads it with
config['token']. Creating a bot via QR binding (which only returns
appid/secret) or with the token field left blank raises KeyError and
the API returns 500.

Read it with config.get('token', '') instead. The value is never used
by QQOfficialClient beyond being stored, so an empty string default is
safe.
2026-09-04 13:05:36 +08:00
Amir Fathi d942bfe19a fix(wecom): read media_id instead of media in send_message() (#2447)
WecomMessageConverter.yiri2target() always emits {'media_id': ...} for
image/voice/file parts (never 'media'), matching the correct usage
already in reply_message(). send_message(), the entry point plugins
use via PluginToRuntimeAction.SEND_MESSAGE, instead read
content['media'], which is never set, so any image/voice/file part
raises KeyError and aborts the send.

Refs #1687

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
2026-09-04 13:02:40 +08:00
Hyu b44b8f474d fix(cloud): provision login workspace just in time (#2505)
* fix(cloud): provision login workspace just in time

* fix(oauth): send callback URI during code exchange

* fix(oauth): preserve callback URI through browser exchange

* fix(oauth): negotiate redirect-bound codes

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-03 23:14:44 +08:00
114 changed files with 2320 additions and 1387 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
name: 漏洞反馈 name: 漏洞反馈
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://langbot.app/docs/zh/workshop/network-details
title: "[Bug]: " title: "[Bug]: "
labels: ["bug?"] labels: ["bug?"]
body: body:
@@ -22,7 +22,7 @@ body:
- type: textarea - type: textarea
attributes: attributes:
label: 异常情况 label: 异常情况
description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。** description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。**
validations: validations:
required: true required: true
- type: textarea - type: textarea
+1 -1
View File
@@ -1,5 +1,5 @@
name: Bug report name: Bug report
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://link.langbot.app/en/docs/network description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://langbot.app/docs/en/workshop/network-details
title: "[Bug]: " title: "[Bug]: "
labels: ["bug?"] labels: ["bug?"]
body: body:
+2 -2
View File
@@ -43,8 +43,8 @@ Run the narrowest useful test first, then broader checks when confidence is need
## Where to Look ## Where to Look
- Architecture map: `ARCHITECTURE.md`. - Architecture map: `ARCHITECTURE.md`.
- Dev environment guide: https://docs.langbot.app/zh/develop/dev-config. - Dev environment guide: https://langbot.app/docs/zh/develop/dev-config.
- Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime. - Plugin runtime / CLI / SDK debugging: https://langbot.app/docs/zh/develop/plugin-runtime.
- API-key auth: `docs/API_KEY_AUTH.md`. - API-key auth: `docs/API_KEY_AUTH.md`.
- Box deep-dive notes: `docs/review/box-architecture.md` and related files. - Box deep-dive notes: `docs/review/box-architecture.md` and related files.
- In-repo skills: `skills/` is the single source of truth for LangBot agent skills. - In-repo skills: `skills/` is the single source of truth for LangBot agent skills.
+16 -7
View File
@@ -169,14 +169,17 @@ The Plugin Runtime supports stdio and WebSocket control transports. Direct local
## Box Runtime and Skills ## Box Runtime and Skills
Box is the sandbox subsystem used by native agent tools, stdio MCP servers, skill authoring, and managed processes. Box is the optional sandbox subsystem used by native execution tools, stdio MCP servers, agent-side skill authoring, and managed processes. Skill storage and read-only access are Core responsibilities and remain available without Box.
In this repo: In this repo:
- `pkg/box/service.py` is the application-facing facade for exec, sessions, managed processes, skill CRUD, status, reconnects, quotas, mounts, and sandbox profiles. - `pkg/box/service.py` is the application-facing facade for exec, sessions, managed processes, status, reconnects, quotas, generic mounts, and sandbox profiles.
- `pkg/box/connector.py` connects to the Box Runtime over stdio, Windows subprocess+WebSocket, or remote WebSocket. - `pkg/box/connector.py` connects to the Box Runtime over stdio, Windows subprocess+WebSocket, or remote WebSocket.
- `pkg/provider/tools/loaders/native.py`, `mcp_stdio.py`, and skill loaders depend on Box availability. - `pkg/provider/tools/loaders/native.py` is the Core orchestration seam: the
- `pkg/skill/manager.py` loads skills from the Box runtime, falling back to local `data/skills` when needed. Skill loader supplies generic read-only mounts to Box execution. `mcp_stdio.py`
and execution-backed tools depend on Box availability.
- `pkg/skill/repository.py` is the thin async/Workspace adapter over the Plugin SDK's execution-independent `SkillStore`; `skills.root` owns its location independently of Box.
- `pkg/skill/manager.py` caches the Core repository catalog for progressive disclosure. Activation and read-only resource tools do not require Box; script execution and Workspace mutation still do.
Durable Box Workspace storage is shared across placement generations, but Durable Box Workspace storage is shared across placement generations, but
sandbox sessions and managed processes are generation-scoped. LangBot validates sandbox sessions and managed processes are generation-scoped. LangBot validates
@@ -187,11 +190,17 @@ retires stale processes and closes already-attached relays.
In `langbot-plugin-sdk`: In `langbot-plugin-sdk`:
- `src/langbot_plugin/box/server.py` implements `lbp box` and the WebSocket endpoints on `:5410`. - `src/langbot_plugin/box/server.py` implements `lbp box` and the WebSocket endpoints on `:5410`.
- `src/langbot_plugin/box/runtime.py` owns sandbox sessions and managed processes. - `src/langbot_plugin/box/runtime.py` owns sandbox sessions, generic read-only mounts, and managed processes.
- `backend.py`, `nsjail_backend.py`, and `e2b_backend.py` implement sandbox backends. - `backend.py`, `nsjail_backend.py`, and `e2b_backend.py` implement sandbox backends.
- `skill_store.py` manages skill packages from the Box side. - `src/langbot_plugin/skill_store.py` is consumed by Core, not Box. Core turns
selected package roots into generic read-only mounts; Box does not understand
Skill names, metadata, revisions, files, or CRUD.
Important config keys live under `box:` in `src/langbot/templates/config.yaml`: `box.enabled`, `box.backend`, `box.runtime.endpoint`, and `box.local.*`. Start LangBot with `--standalone-box` when connecting to an externally launched Box runtime. Skill storage uses `skills.root`. Box execution config lives under `box:`:
`box.enabled`, `box.backend`, `box.runtime.endpoint`, and `box.local.*`. The old
`box.local.skills_root` key is read only as an online-upgrade fallback and is
marked for removal in the next major version. Start LangBot with
`--standalone-box` when connecting to an externally launched Box runtime.
## HTTP API, Web UI, and MCP Server ## HTTP API, Web UI, and MCP Server
+6 -6
View File
@@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Website</a> <a href="https://langbot.app">Website</a>
<a href="https://link.langbot.app/en/docs/features">Features</a> <a href="https://langbot.app/docs/en/insight/features">Features</a>
<a href="https://link.langbot.app/en/docs/guide">Docs</a> <a href="https://langbot.app/docs/en/insight/guide">Docs</a>
<a href="https://link.langbot.app/en/docs/api">API</a> <a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://space.langbot.app/cloud">Cloud</a> <a href="https://space.langbot.app/cloud">Cloud</a>
<a href="https://space.langbot.app">Plugin Market</a> <a href="https://space.langbot.app">Plugin Market</a>
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a> <a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
@@ -49,7 +49,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
- **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required. - **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required.
- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling. - **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
[→ Learn more about all features](https://link.langbot.app/en/docs/features) [→ Learn more about all features](https://langbot.app/docs/en/insight/features)
📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). 📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -89,7 +89,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) **More options:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
--- ---
@@ -151,7 +151,7 @@ _Note: Public demo environment. Do not enter sensitive information._
| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ | | [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
[→ View all integrations](https://link.langbot.app/en/docs/features) [→ View all integrations](https://langbot.app/docs/en/insight/features)
--- ---
+6 -6
View File
@@ -21,9 +21,9 @@
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot) [![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
<a href="https://langbot.app">官网</a> <a href="https://langbot.app">官网</a>
<a href="https://link.langbot.app/zh/docs/features">特性</a> <a href="https://langbot.app/docs/zh/insight/features">特性</a>
<a href="https://link.langbot.app/zh/docs/guide">文档</a> <a href="https://langbot.app/docs/zh/insight/guide">文档</a>
<a href="https://link.langbot.app/zh/docs/api">API</a> <a href="https://langbot.app/docs/zh/tags/readme">API</a>
<a href="https://space.langbot.app/cloud">Cloud</a> <a href="https://space.langbot.app/cloud">Cloud</a>
<a href="https://space.langbot.app">扩展市场</a> <a href="https://space.langbot.app">扩展市场</a>
<a href="https://langbot.featurebase.app/roadmap">路线图</a> <a href="https://langbot.featurebase.app/roadmap">路线图</a>
@@ -49,7 +49,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。 - **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。 - **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features) [→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features)
📍 实践指南:[5 分钟部署多平台 AI 机器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。 📍 实践指南:[5 分钟部署多平台 AI 机器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
@@ -89,7 +89,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes) **更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手动部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [宝塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes)
--- ---
@@ -152,7 +152,7 @@ docker compose --profile all up -d
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ | | [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ | | [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features) [→ 查看完整集成列表](https://langbot.app/docs/zh/insight/features)
### TTS(语音合成) ### TTS(语音合成)
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Inicio</a> <a href="https://langbot.app">Inicio</a>
<a href="https://link.langbot.app/en/docs/features">Características</a> <a href="https://langbot.app/docs/en/insight/features">Características</a>
<a href="https://link.langbot.app/en/docs/guide">Documentación</a> <a href="https://langbot.app/docs/en/insight/guide">Documentación</a>
<a href="https://link.langbot.app/en/docs/api">API</a> <a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://space.langbot.app">Mercado de Plugins</a> <a href="https://space.langbot.app">Mercado de Plugins</a>
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a> <a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
@@ -48,7 +48,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
- **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML. - **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML.
- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones. - **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features) [→ Conocer más sobre todas las funcionalidades](https://langbot.app/docs/en/insight/features)
📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). 📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) **Más opciones:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
--- ---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ | | [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features) [→ Ver todas las integraciones](https://langbot.app/docs/en/insight/features)
--- ---
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Accueil</a> <a href="https://langbot.app">Accueil</a>
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a> <a href="https://langbot.app/docs/en/insight/features">Fonctionnalités</a>
<a href="https://link.langbot.app/en/docs/guide">Documentation</a> <a href="https://langbot.app/docs/en/insight/guide">Documentation</a>
<a href="https://link.langbot.app/en/docs/api">API</a> <a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://space.langbot.app">Marché des Plugins</a> <a href="https://space.langbot.app">Marché des Plugins</a>
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a> <a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
@@ -48,7 +48,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
- **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise. - **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise.
- **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions. - **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions.
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features) [→ En savoir plus sur toutes les fonctionnalités](https://langbot.app/docs/en/insight/features)
📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). 📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) **Plus d'options :** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manuel](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
--- ---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features) [→ Voir toutes les intégrations](https://langbot.app/docs/en/insight/features)
--- ---
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">ホーム</a> <a href="https://langbot.app">ホーム</a>
<a href="https://link.langbot.app/ja/docs/features">機能</a> <a href="https://langbot.app/docs/ja/insight/features">機能</a>
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a> <a href="https://langbot.app/docs/ja/insight/guide">ドキュメント</a>
<a href="https://link.langbot.app/ja/docs/api">API</a> <a href="https://langbot.app/docs/ja/tags/readme">API</a>
<a href="https://space.langbot.app">プラグインマーケット</a> <a href="https://space.langbot.app">プラグインマーケット</a>
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a> <a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
@@ -48,7 +48,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。 - **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。 - **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features) [→ すべての機能について詳しく見る](https://langbot.app/docs/ja/insight/features)
📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。 📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) **その他:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [手動デプロイ](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
--- ---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ | | [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
[→ すべての統合を表示](https://link.langbot.app/en/docs/features) [→ すべての統合を表示](https://langbot.app/docs/en/insight/features)
--- ---
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">홈</a> <a href="https://langbot.app">홈</a>
<a href="https://link.langbot.app/en/docs/features">기능</a> <a href="https://langbot.app/docs/en/insight/features">기능</a>
<a href="https://link.langbot.app/en/docs/guide">문서</a> <a href="https://langbot.app/docs/en/insight/guide">문서</a>
<a href="https://link.langbot.app/en/docs/api">API</a> <a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://space.langbot.app">플러그인 마켓</a> <a href="https://space.langbot.app">플러그인 마켓</a>
<a href="https://langbot.featurebase.app/roadmap">로드맵</a> <a href="https://langbot.featurebase.app/roadmap">로드맵</a>
@@ -48,7 +48,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요. - **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리. - **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features) [→ 모든 기능 자세히 보기](https://langbot.app/docs/en/insight/features)
📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). 📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) **더 많은 옵션:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [수동 배포](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
--- ---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ | | [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features) [→ 모든 통합 보기](https://langbot.app/docs/en/insight/features)
--- ---
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Главная</a> <a href="https://langbot.app">Главная</a>
<a href="https://link.langbot.app/en/docs/features">Возможности</a> <a href="https://langbot.app/docs/en/insight/features">Возможности</a>
<a href="https://link.langbot.app/en/docs/guide">Документация</a> <a href="https://langbot.app/docs/en/insight/guide">Документация</a>
<a href="https://link.langbot.app/en/docs/api">API</a> <a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://space.langbot.app">Магазин плагинов</a> <a href="https://space.langbot.app">Магазин плагинов</a>
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a> <a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
@@ -48,7 +48,7 @@ LangBot — это **платформа с открытым исходным к
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется. - **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений. - **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features) [→ Подробнее обо всех возможностях](https://langbot.app/docs/en/insight/features)
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). 📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) **Другие варианты:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Ручная установка](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
--- ---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features) [→ Смотреть все интеграции](https://langbot.app/docs/en/insight/features)
--- ---
+6 -6
View File
@@ -21,9 +21,9 @@
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot) [![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
<a href="https://langbot.app">官網</a> <a href="https://langbot.app">官網</a>
<a href="https://link.langbot.app/zh/docs/features">特性</a> <a href="https://langbot.app/docs/zh/insight/features">特性</a>
<a href="https://link.langbot.app/zh/docs/guide">文件</a> <a href="https://langbot.app/docs/zh/insight/guide">文件</a>
<a href="https://link.langbot.app/zh/docs/api">API</a> <a href="https://langbot.app/docs/zh/tags/readme">API</a>
<a href="https://space.langbot.app">外掛市場</a> <a href="https://space.langbot.app">外掛市場</a>
<a href="https://langbot.featurebase.app/roadmap">路線圖</a> <a href="https://langbot.featurebase.app/roadmap">路線圖</a>
@@ -50,7 +50,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。 - **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。 - **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features) [→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features)
📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。 📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
@@ -90,7 +90,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes) **更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手動部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [寶塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes)
--- ---
@@ -165,7 +165,7 @@ docker compose --profile all up -d
|-----------|------| |-----------|------|
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) | | 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features) [→ 查看完整整合列表](https://langbot.app/docs/zh/insight/features)
--- ---
+6 -6
View File
@@ -19,9 +19,9 @@
[![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Trang chủ</a> <a href="https://langbot.app">Trang chủ</a>
<a href="https://link.langbot.app/en/docs/features">Tính năng</a> <a href="https://langbot.app/docs/en/insight/features">Tính năng</a>
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a> <a href="https://langbot.app/docs/en/insight/guide">Tài liệu</a>
<a href="https://link.langbot.app/en/docs/api">API</a> <a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://space.langbot.app">Chợ Plugin</a> <a href="https://space.langbot.app">Chợ Plugin</a>
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a> <a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
@@ -48,7 +48,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
- **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML. - **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML.
- **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ. - **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ.
[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features) [→ Tìm hiểu thêm về tất cả tính năng](https://langbot.app/docs/en/insight/features)
📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/). 📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
@@ -88,7 +88,7 @@ docker compose --profile all up -d
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF)
**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes) **Thêm tùy chọn:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Thủ công](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
--- ---
@@ -149,7 +149,7 @@ docker compose --profile all up -d
| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ | | [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ | | [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features) [→ Xem tất cả tích hợp](https://langbot.app/docs/en/insight/features)
--- ---
+10 -4
View File
@@ -1,5 +1,5 @@
# Docker Compose configuration for LangBot # Docker Compose configuration for LangBot
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://docs.langbot.app # For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://langbot.app/docs
version: "3" version: "3"
services: services:
@@ -27,8 +27,8 @@ services:
# The Box sandbox runtime is optional. It is only started when you run # The Box sandbox runtime is optional. It is only started when you run
# ``docker compose --profile box up`` (or ``docker compose --profile all # ``docker compose --profile box up`` (or ``docker compose --profile all
# up``). With Box off, LangBot keeps the dashboard / skills list visible # up``). With Box off, LangBot keeps skill management, activation, and
# (read-only) but disables sandbox tools, skill add/edit and stdio MCP — # read-only resources available but disables execution tools and stdio MCP —
# set ``box.enabled: false`` in ``data/config.yaml`` (or # set ``box.enabled: false`` in ``data/config.yaml`` (or
# ``BOX__ENABLED=false`` in the langbot service env below) to match. # ``BOX__ENABLED=false`` in the langbot service env below) to match.
langbot_box: langbot_box:
@@ -73,6 +73,10 @@ services:
container_name: langbot container_name: langbot
volumes: volumes:
- ./data:/app/data - ./data:/app/data
# Core owns the SkillRepository even when the Box profile is disabled.
# Keep this path identical to langbot_box so optional execution can
# consume the same Workspace-scoped package revisions.
- ${LANGBOT_BOX_ROOT:-${PWD}/data/box}:${LANGBOT_BOX_ROOT:-${PWD}/data/box}
restart: on-failure restart: on-failure
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
@@ -92,7 +96,9 @@ services:
# box.* and are forwarded to the Box runtime via INIT RPC. # box.* and are forwarded to the Box runtime via INIT RPC.
- BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box} - BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
- BOX__LOCAL__DEFAULT_WORKSPACE=default - BOX__LOCAL__DEFAULT_WORKSPACE=default
- BOX__LOCAL__SKILLS_ROOT=skills # TODO(next-major): default LANGBOT_SKILLS_ROOT to ${PWD}/data/skills
# after the historical Box storage path no longer needs zero-copy upgrades.
- SKILLS__ROOT=${LANGBOT_SKILLS_ROOT:-${LANGBOT_BOX_ROOT:-${PWD}/data/box}/skills}
- BOX__LOCAL__ALLOWED_MOUNT_ROOTS=${LANGBOT_BOX_ROOT:-${PWD}/data/box} - BOX__LOCAL__ALLOWED_MOUNT_ROOTS=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
- BOX__DOCKER__CPU_LIMIT_ENABLED=${LANGBOT_BOX_DOCKER_CPU_LIMIT_ENABLED:-true} - BOX__DOCKER__CPU_LIMIT_ENABLED=${LANGBOT_BOX_DOCKER_CPU_LIMIT_ENABLED:-true}
ports: ports:
+9 -7
View File
@@ -1,7 +1,7 @@
# Kubernetes Deployment for LangBot # Kubernetes Deployment for LangBot
# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml # This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml
# #
# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes # Full deployment guide (zh/en/ja): https://langbot.app/docs -> Installation -> Kubernetes
# #
# Usage: # Usage:
# kubectl -n langbot create secret generic langbot-plugin-runtime-control \ # kubectl -n langbot create secret generic langbot-plugin-runtime-control \
@@ -214,8 +214,9 @@ spec:
# Deployment for LangBot Box (sandbox) runtime # Deployment for LangBot Box (sandbox) runtime
# #
# The Box runtime backs LangBot's sandbox tools (exec / read / write / edit / # The Box runtime backs LangBot's sandbox tools (exec / read / write / edit /
# glob / grep), the `activate` skill tool, skill add/edit, and stdio-mode MCP # glob / grep), Skill script execution, and stdio-mode MCP servers. Skill
# servers. It is OPTIONAL: if you do not deploy it, set `BOX__ENABLED=false` on # activation, resources, and management remain Core-owned without Box. Box is
# OPTIONAL: if you do not deploy it, set `BOX__ENABLED=false` on
# the langbot Deployment (or `box.enabled: false` in config.yaml) so the # the langbot Deployment (or `box.enabled: false` in config.yaml) so the
# dashboard renders cleanly with sandbox features disabled. # dashboard renders cleanly with sandbox features disabled.
# #
@@ -448,14 +449,15 @@ spec:
key: token key: token
# box.local.* config — forwarded to the Box runtime via INIT RPC. The # box.local.* config — forwarded to the Box runtime via INIT RPC. The
# host_root MUST match the box-root hostPath mountPath below AND the box # host_root MUST match the box-root hostPath mountPath below AND the box
# Deployment's box-root mountPath, so that skill package paths resolve # Deployment's box-root mountPath, so generic package mount paths
# identically on both sides and on the node's Docker daemon. # resolve identically on both sides and on the node's Docker daemon.
- name: BOX__LOCAL__HOST_ROOT - name: BOX__LOCAL__HOST_ROOT
value: "/app/data/box" value: "/app/data/box"
- name: BOX__LOCAL__DEFAULT_WORKSPACE - name: BOX__LOCAL__DEFAULT_WORKSPACE
value: "default" value: "default"
- name: BOX__LOCAL__SKILLS_ROOT - name: SKILLS__ROOT
value: "skills" # TODO(next-major): use /app/data/skills after the legacy path window.
value: "/app/data/box/skills"
- name: BOX__LOCAL__ALLOWED_MOUNT_ROOTS - name: BOX__LOCAL__ALLOWED_MOUNT_ROOTS
value: "/app/data/box" value: "/app/data/box"
volumeMounts: volumeMounts:
+2 -2
View File
@@ -218,8 +218,8 @@ metadata:
spec: spec:
categories: [popular, global] categories: [popular, global]
help_links: help_links:
zh: https://docs.langbot.app/zh/platforms/http-bot zh: https://langbot.app/docs/zh/platforms/http-bot
en: https://docs.langbot.app/en/platforms/http-bot en: https://langbot.app/docs/en/platforms/http-bot
config: config:
- { name: inbound_secret, type: string, required: true, default: "" } - { name: inbound_secret, type: string, required: true, default: "" }
- { name: callback_url, type: string, required: false, default: "" } - { name: callback_url, type: string, required: false, default: "" }
+1 -1
View File
@@ -243,7 +243,7 @@ For large datasets:
- SeekDB GitHub: https://github.com/oceanbase/seekdb - SeekDB GitHub: https://github.com/oceanbase/seekdb
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb - pyseekdb SDK: https://github.com/oceanbase/pyseekdb
- OceanBase Documentation: https://oceanbase.ai - OceanBase Documentation: https://oceanbase.ai
- LangBot Documentation: https://docs.langbot.app - LangBot Documentation: https://langbot.app/docs
## License ## License
+39 -38
View File
@@ -22,6 +22,7 @@
│ │ │ (shared 容器, 多 process) │ │ │ │ (shared 容器, 多 process) │
│ │ │ │ │ │ │ │
│ │ ├──> SkillToolLoader (activate 工具) │ │ │ ├──> SkillToolLoader (activate 工具) │
│ │ │ └─ build_execution_mounts() │
│ │ │ │ │ │ │ │
│ │ ├──> SkillAuthoringToolLoader │ │ │ ├──> SkillAuthoringToolLoader │
│ │ │ │ │ │ │ │
@@ -33,7 +34,7 @@
│ ├─ Workspace quota 检查 │ │ ├─ Workspace quota 检查 │
│ ├─ 输出截断 (head+tail) │ │ ├─ 输出截断 (head+tail) │
│ ├─ Session ID 模板解析 (resolve_box_session_id) │ │ ├─ Session ID 模板解析 (resolve_box_session_id) │
│ ├─ 技能挂载组装 (build_skill_extra_mounts) │ │ ├─ 通用只读挂载接收 (read_only_mounts)
│ ├─ 重连循环 (_reconnect_loop, 指数退避) │ │ ├─ 重连循环 (_reconnect_loop, 指数退避) │
│ └─ BoxRuntimeConnector │ │ └─ BoxRuntimeConnector │
│ ├─ 心跳 loop (20s ping) │ │ ├─ 心跳 loop (20s ping) │
@@ -41,7 +42,7 @@
│ │ Action RPC (stdio 或 WebSocket) │ │ │ Action RPC (stdio 或 WebSocket) │
│ │ │ │
│ SkillManager (skill_mgr) │ │ SkillManager (skill_mgr) │
│ └─ 从 Box runtime 拉取 skills, 不可用时回落 data/skills │ │ └─ 从 Core SkillRepository 加载 Workspace-scoped skills │
└──────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────┘
@@ -59,10 +60,8 @@
│ NsjailBackend ──┘ (本地 CLI 或 fallback 到容器内 CLI) │ │ NsjailBackend ──┘ (本地 CLI 或 fallback 到容器内 CLI) │
│ E2BBackend (云沙箱, 需要 E2B_API_KEY) │ │ E2BBackend (云沙箱, 需要 E2B_API_KEY) │
│ │ │ │
BoxSkillStore Generic mount admission
list / get / create / update / delete allow-list + read-only + normalized target validation
│ ├─ scan_skill_directory / read_skill_file / write_skill_file │
│ └─ preview_skill_zip / install_skill_zip (zip 或 GitHub) │
│ │ │ │
│ aiohttp 单端口服务 (默认 :5410): │ │ aiohttp 单端口服务 (默认 :5410): │
│ /rpc/ws — Action RPC │ │ /rpc/ws — Action RPC │
@@ -85,7 +84,7 @@
**核心设计原则**: **核心设计原则**:
- Box Runtime 作为独立进程运行,通过 Action RPC 与 LangBot 主进程通信,两者复用 SDK 的 IO 层(Handler → Connection → Controller - Box Runtime 作为独立进程运行,通过 Action RPC 与 LangBot 主进程通信,两者复用 SDK 的 IO 层(Handler → Connection → Controller
- 一个 session_id 对应一个容器/沙箱实例。同一 session 内可并存多条 mount 与多个 managed process - 一个 session_id 对应一个容器/沙箱实例。同一 session 内可并存多条 mount 与多个 managed process
- Skill / 默认 exec / MCP Server 共享同一个 session 容器(详见 [box-session-scope.md](./box-session-scope.md) - Skill 仅是 Core 组装 mount 的业务来源;Box 与默认 exec / MCP Server 共享通用 session 和 mount 机制(详见 [box-session-scope.md](./box-session-scope.md)
--- ---
@@ -93,7 +92,7 @@
### 2.1 BoxService (`pkg/box/service.py`, 722 行) ### 2.1 BoxService (`pkg/box/service.py`, 722 行)
应用层门面,协调 Profile、安全校验、配额、连接、Skill 挂载与 Session 模板: 应用层门面,协调 Profile、安全校验、配额、连接、Core 生成的只读挂载与 Session 模板:
主要公开方法(按定义顺序): 主要公开方法(按定义顺序):
@@ -105,7 +104,7 @@ BoxService
├─ available (property) 连接状态 ├─ available (property) 连接状态
├─ resolve_box_session_id(query) 从 pipeline 模板解析 session_id ├─ resolve_box_session_id(query) 从 pipeline 模板解析 session_id
├─ build_skill_extra_mounts(query) 组装 pipeline-bound skill 的挂载列表 ├─ execute_tool(..., read_only_mounts=...) 接收 Core 组装的通用只读挂载
├─ execute_tool(parameters, query) Agent 调用 exec 时的入口 ├─ execute_tool(parameters, query) Agent 调用 exec 时的入口
│ ├─ _apply_profile / build_spec │ ├─ _apply_profile / build_spec
@@ -122,12 +121,6 @@ BoxService
├─ stop_managed_process(session_id, pid) 单独停止某个 managed process ├─ stop_managed_process(session_id, pid) 单独停止某个 managed process
├─ get_managed_process_websocket_url(...) 返回 WS attach URL ├─ get_managed_process_websocket_url(...) 返回 WS attach URL
├─ list_skills() / get_skill(name) Skill 元数据
├─ create_skill / update_skill / delete_skill Skill CRUD
├─ scan_skill_directory(path) 扫描目录
├─ list_skill_files / read_skill_file / write_skill_file
├─ preview_skill_zip / install_skill_zip zip / GitHub 安装
├─ shutdown() / dispose() 清理:RPC SHUTDOWN + 进程终止 ├─ shutdown() / dispose() 清理:RPC SHUTDOWN + 进程终止
├─ get_status() / get_sessions() / get_recent_errors() ├─ get_status() / get_sessions() / get_recent_errors()
└─ get_system_guidance() LLM 系统提示 └─ get_system_guidance() LLM 系统提示
@@ -137,7 +130,7 @@ BoxService
**输出截断**: 默认 4000 字符上限,保留前 60% + 后 40%,中间插入 `[...truncated...]` **输出截断**: 默认 4000 字符上限,保留前 60% + 后 40%,中间插入 `[...truncated...]`
**Skill 挂载合并**: `execute_tool()` 调用时,`build_skill_extra_mounts(query)`把当前 pipeline-bound 的所有 skill 的 `package_root` 作为 `extra_mounts` 加入 BoxSpec,挂在 `/workspace/.skills/<name>`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径。 **Skill 挂载合并**: native loader 调用 `skill.build_execution_mounts()`把当前 pipeline-bound 的所有 skill 的 `package_root` 转成普通只读 mount,再通过 `BoxService.execute_tool(..., read_only_mounts=...)` 交给 Box,挂在 `/workspace/.skills/<name>`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径BoxService 和 Box Runtime 都不知道这些 mount 来自 Skill
### 2.2 BoxRuntimeConnector (`pkg/box/connector.py`, 357 行) ### 2.2 BoxRuntimeConnector (`pkg/box/connector.py`, 357 行)
@@ -171,11 +164,10 @@ BoxService
``` ```
SkillManager SkillManager
├─ initialize() 调用 reload_skills() ├─ initialize() 调用 reload_skills()
├─ reload_skills() Box runtime list_skills() ├─ reload_skills() 从 Core SkillRepository 加载
│ 不可用则回落 data/skills/ 扫描 ├─ refresh_skill_from_disk() 检查单 skill 的缓存状态
├─ refresh_skill_from_disk() 单 skill 重新加载
├─ get_skill_by_name(name) ├─ get_skill_by_name(name)
└─ get_managed_skills_root() 返回 Box 视角的 skills_root 路径 └─ build_skill_aware_prompt_addition() 生成渐进披露索引
``` ```
skill 元数据通过 `parse_frontmatter` 解析 `SKILL.md` 头部(`name` / `description` / `instructions`),不再做整体扫描的代价(典型 < 50 个)。 skill 元数据通过 `parse_frontmatter` 解析 `SKILL.md` 头部(`name` / `description` / `instructions`),不再做整体扫描的代价(典型 < 50 个)。
@@ -295,7 +287,7 @@ start_managed_process(session, spec):
单端口 aiohttp 服务(默认 5410),通过路径区分(commit `8c71ec5` 合并端口): 单端口 aiohttp 服务(默认 5410),通过路径区分(commit `8c71ec5` 合并端口):
1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理所有 action,包括 `INIT` 配置注入、skill store 操作等 1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理 `INIT`、exec、session、managed-process 与状态等通用 action
2. **WS Relay** (`/v1/sessions/{id}/managed-process/ws``/v1/sessions/{id}/managed-process/{pid}/ws`): 双向桥接 WebSocket ↔ 指定 managed process stdin/stdout 2. **WS Relay** (`/v1/sessions/{id}/managed-process/ws``/v1/sessions/{id}/managed-process/{pid}/ws`): 双向桥接 WebSocket ↔ 指定 managed process stdin/stdout
stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process attachAction RPC 走 stdin/stdout。 stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process attachAction RPC 走 stdin/stdout。
@@ -304,7 +296,7 @@ stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process atta
`ActionRPCBoxClient` 封装 `Handler.call_action()` 调用: `ActionRPCBoxClient` 封装 `Handler.call_action()` 调用:
- 25+ 方法对应 25+ 个 RPC actionexec / session / managed-process / skill / status / shutdown - 方法对应 exec / session / managed-process / status / shutdown 等通用 RPC action,不暴露 Skill CRUD
- 错误还原: `_translate_action_error()` 通过字符串前缀匹配还原 SDK 侧异常类型 - 错误还原: `_translate_action_error()` 通过字符串前缀匹配还原 SDK 侧异常类型
- `execute()` timeout = 300s,其他默认 15s - `execute()` timeout = 300s,其他默认 15s
- `BoxRuntimeClient` 是 ABC,供后续可能的非 RPC 实现复用 - `BoxRuntimeClient` 是 ABC,供后续可能的非 RPC 实现复用
@@ -343,23 +335,29 @@ stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process atta
`BoxSpec` 校验器: `workdir` 默认继承 `mount_path``host_path` 支持 POSIX 和 Windows 路径;设置 `host_path``workdir` 必须在 `mount_path` 下。 `BoxSpec` 校验器: `workdir` 默认继承 `mount_path``host_path` 支持 POSIX 和 Windows 路径;设置 `host_path``workdir` 必须在 `mount_path` 下。
### 3.7 BoxSkillStore (`box/skill_store.py`, 647 行) ### 3.7 SkillStore (`langbot_plugin.skill_store`)
新增模块(commit `4ab3502`),把 skill 持久化收归 Box runtime Skill 包存储最初位于 Box Runtimeissue #2410 将通用实现抽到 Plugin SDK 顶层,由 Core 独占存储和 revision 语义
``` ```
BoxSkillStore SkillStore
├─ list_skills() / get_skill(name) ├─ list_skills() / get_skill(name)
├─ create_skill(data) / update_skill(name, data) / delete_skill(name) ├─ create_skill(data) / update_skill(name, data) / delete_skill(name)
├─ scan_skill_directory(path) 扫描目录返回候选 skill 包列表 ├─ scan_skill_directory(path) 扫描目录返回候选 skill 包列表
├─ list_skill_files(name, path) 浏览 skill 内文件树 ├─ list_skill_resources(name, path, revision) 按 revision 浏览只读资源
├─ read_skill_file(name, path) / write_skill_file(name, path, content) ├─ read_skill_resource(name, path, revision) 按 revision 读取 UTF-8 资源
├─ read_skill_file(...) / write_skill_file(...) 管理侧文件接口
├─ preview_skill_zip(zip_bytes, ...) 不落盘预览 zip 内容 ├─ preview_skill_zip(zip_bytes, ...) 不落盘预览 zip 内容
└─ install_skill_zip(zip_bytes, ...) 解压、校验、复制到 skills_root └─ install_skill_zip(zip_bytes, ...) 解压、校验、复制到 skills_root
└─ 支持 source_subdir / target_suffixcommit 1aa043f └─ 支持 source_subdir / target_suffixcommit 1aa043f
``` ```
GitHub 安装路径HTTP 层(`api/http/service/skill.py`)先 `git clone` 拉取,再走 `install_skill_zip` 或 directory 路径。Skill 文件存放于 `box.local.skills_root`(默认 `skills`,相对 `host_root`),容器内对应 `/workspace/.skills/` GitHub 安装路径由 Core HTTP 层下载归档,再交给 SkillRepository。Skill 文件位于独立的 `skills.root`,执行时由 Core 组装成通用只读 `BoxMountSpec` 并挂载到 `/workspace/.skills/`。Box 的模型、客户端和 Runtime 不包含 `skill_name`、Skill CRUD、revision 或 `SKILL.md` 语义。Core 与 Box Runtime SDK 按同一发布单元同步升级;Box 不保留旧 Skill RPC,也会拒绝旧的 Skill-aware payload 字段
仍保留的兼容仅用于已有数据的在线升级,不用于混版本协议:
1. Core 暂时读取 `box.local.skills_root`,兼容尚未生成 `skills.root` 的持久化配置。
2. 新安装默认暂时沿用历史 `./data/box/skills`,避免升级时搬迁已安装 Skill;下一大版本切换到 `./data/skills`
### 3.8 Security (`box/security.py`, 52 行) ### 3.8 Security (`box/security.py`, 52 行)
@@ -463,7 +461,7 @@ BuildAppStage.run(ap)
├─ ap.tool_mgr = tool_mgr ├─ ap.tool_mgr = tool_mgr
├─ ... (platform, pipeline) ... ├─ ... (platform, pipeline) ...
├─ SkillManager.initialize() (从 Box runtime 加载 skill 列表) ├─ SkillManager.initialize() (从 Core SkillRepository 加载 skill 列表)
└─ ... (RAG, HTTP, plugins) ... └─ ... (RAG, HTTP, plugins) ...
``` ```
@@ -480,7 +478,7 @@ except Exception as e:
logger.warning(f"Box runtime unavailable: {e}") logger.warning(f"Box runtime unavailable: {e}")
``` ```
**静默降级**: Box 初始化失败不会阻止应用启动,仅导致 6 个 native tool、所有 Skill 工具和 MCP-in-Box 工具不暴露给 LLM。与 Plugin 的行为不同(Plugin 失败会抛异常)。 **静默降级**: Box 初始化失败不会阻止应用启动6 个 native tool、`register_skill` 和 MCP-in-Box 工具不暴露给 LLM`activate` 与 Skill 只读资源工具继续由 Core 提供。与 Plugin 的行为不同(Plugin 失败会抛异常)。
### 5.3 销毁流程 ### 5.3 销毁流程
@@ -504,13 +502,16 @@ Box 额外做了 RPC SHUTDOWN 通知 Runtime 主动清理容器,比 Plugin 的
### config.yaml (重构后) ### config.yaml (重构后)
```yaml ```yaml
skills:
root: './data/box/skills' # Core-ownedBox 关闭时仍可管理/读取
box: box:
enabled: true # 整个 Box 子系统的总开关。设为 false 时: enabled: true # 整个 Box 子系统的总开关。设为 false 时:
# - 不连接远程 Box runtime,不 fork 本地 stdio 子进程 # - 不连接远程 Box runtime,不 fork 本地 stdio 子进程
# - sandbox 工具 (exec/read/write/edit/glob/grep) 不暴露给 LLM # - sandbox 工具 (exec/read/write/edit/glob/grep) 不暴露给 LLM
# - skill 添加/编辑 / GitHub 安装 / 文件写入全部拒绝 # - Agent 从 sandbox 注册 skill 的能力不可用
# - stdio 模式的 MCP server 启动时报错(http/sse 模式不受影响) # - stdio 模式的 MCP server 启动时报错(http/sse 模式不受影响)
# - skill 列表/读取保持只读可用 # - skill 管理、激活和只读资源保持可用
# BOX__ENABLED 环境变量可覆盖(统一约定) # BOX__ENABLED 环境变量可覆盖(统一约定)
backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b' backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b'
# 由 box.backend / BOX__BACKEND 选择后端 # 由 box.backend / BOX__BACKEND 选择后端
@@ -522,7 +523,6 @@ box:
image: '' # 覆盖 profile 默认 image image: '' # 覆盖 profile 默认 image
host_root: './data/box' # 工作区挂载根,Docker 部署需绝对路径 host_root: './data/box' # 工作区挂载根,Docker 部署需绝对路径
default_workspace: '' # 默认 '<host_root>/default' default_workspace: '' # 默认 '<host_root>/default'
skills_root: 'skills' # Box 管理的 skill 包目录(相对 host_root
allowed_mount_roots: # 默认 ['<host_root>'] allowed_mount_roots: # 默认 ['<host_root>']
- './data/box' - './data/box'
- '/tmp' - '/tmp'
@@ -561,16 +561,17 @@ volumes:
| 消费方 | Box 可用 | Box 不可用(disabled 或 failed) | | 消费方 | Box 可用 | Box 不可用(disabled 或 failed) |
|---|---|---| |---|---|---|
| native exec/read/write/edit/glob/grep 工具 | 暴露给 LLM | **不暴露** | | native exec/read/write/edit/glob/grep 工具 | 暴露给 LLM | **不暴露** |
| `activate` / `register_skill` 工具 | 暴露给 LLM | **不暴露** | | `activate` / Skill resource 工具 | 暴露给 LLM | 暴露给 LLM |
| `register_skill` 工具 | 暴露给 LLM | **不暴露**;直接调用返回 `sandbox_unavailable` |
| stdio MCP server | 在 Box 内启动 | **`_init_stdio_python_server` 抛 RuntimeError** 拒绝;不退化到宿主 stdio | | stdio MCP server | 在 Box 内启动 | **`_init_stdio_python_server` 抛 RuntimeError** 拒绝;不退化到宿主 stdio |
| http/sse MCP server | 正常 | 正常(不依赖 Box) | | http/sse MCP server | 正常 | 正常(不依赖 Box) |
| Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Box runtime | 走 LangBot 本地 `data/skills/` 只读 fallback | | Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Core SkillRepository | 走 Core SkillRepository |
| Skill 创建/编辑/安装/写文件 | 走 Box runtime | **HTTP 400** + 明确错误信息(`_require_box_for_write`) | | Skill 创建/编辑/安装/写文件 | 走 Core SkillRepository | 走 Core SkillRepository |
| Pipeline AI 配置中 `box-session-id-template` | 正常生效 | **前端 banner** 提示字段无效 | | Pipeline AI 配置中 `box-session-id-template` | 正常生效 | **前端 banner** 提示字段无效 |
| Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | **前端禁用** + banner | | Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | 可编辑 |
| 仪表盘 Box 状态卡片 | 绿点 / "已连接" | 灰点 / "已禁用"(disabled) 或 红点 / "已断开"(failed) | | 仪表盘 Box 状态卡片 | 绿点 / "已连接" | 灰点 / "已禁用"(disabled) 或 红点 / "已断开"(failed) |
> 后端拒写的边界条件:如果 `ap.box_service` **完全没装**(老式 dev mode,没经过 BuildAppStage),`_require_box_for_write` 视作 no-op,保留 `data/skills/` 本地路径——以兼容历史测试与最小化设置。生产环境总会装 `ap.box_service`,因此该 fallback 不会被触发 > Core 的 SkillRepository 是 `langbot_plugin.skill_store.SkillStore` 的异步 Workspace 适配层。默认 `skills.root` 保持原 `data/box/skills/tenants/...` 布局,升级时无需移动已安装 Skill;旧 `box.local.skills_root` 仅作为在线升级 fallback,并将在下一大版本删除。Box 只消费 Core 下发的通用只读 mount
### Pipeline 配置 (templates/metadata/pipeline/ai.yaml) ### Pipeline 配置 (templates/metadata/pipeline/ai.yaml)
+2 -2
View File
@@ -52,8 +52,8 @@
### S5. 挂载校验缺口 — Med-High ### S5. 挂载校验缺口 — Med-High
- **位置**: SDK `box/security.py` `_BLOCKED_HOST_PATHS_POSIX``box/backend.py``extra_mounts` 处理 - **位置**: SDK `box/security.py` `_BLOCKED_HOST_PATHS_POSIX``box/backend.py``extra_mounts` 处理
- **现状**: ① SDK 黑名单仍不含 `/`(前缀匹配,`host_path="/"` 可通过,挂载整个宿主 fs);用户 home、`/usr``/opt``/tmp` 也未拦截。② `validate_sandbox_security` 只校验 `spec.host_path`**从不遍历 `spec.extra_mounts`**——LangBot 侧 `allowed_mount_roots` 也只校验 `host_path`。当前 `extra_mounts` 仅由 `build_skill_extra_mounts` 内部填充(agent 不可达),但缺乏纵深防御:一旦 S1 的无认证 RPC 被触达,extra_mounts 可挂任意宿主路径,两层都不拦 - **现状**: grant-enforced 模式已经由 Core 与 Runtime 双重校验通用只读 mount(绝对路径 allow-list、存在性、只读模式、规范化且位于 `/workspace` 下的目标);它不再有 Skill 特例。遗留风险仅在 admission-disabled 的低信任直连场景:通用 `extra_mounts` 仍未统一套用 grant-enforced 白名单
- **要求**: SDK 黑名单加入 `/`(或改白名单)`extra_mounts` 在 SDK 与 LangBot 两侧都纳入挂载校验 - **要求**: admission-disabled 的外部控制面也复用同一套通用 mount 校验;SDK 黑名单加入 `/`(或全面改白名单)。
### S6. 容器加固缺失 — Med ### S6. 容器加固缺失 — Med
+1 -1
View File
@@ -18,7 +18,7 @@ has shipped the design largely as written:
| Docker / nsjail / E2B backends apply extra mounts | ✅ Shipped | Last gap closed by SDK commit `0fea9b1` (E2B) | | Docker / nsjail / E2B backends apply extra mounts | ✅ Shipped | Last gap closed by SDK commit `0fea9b1` (E2B) |
| `box-session-id-template` in `local-agent` pipeline config | ✅ Shipped | `templates/metadata/pipeline/ai.yaml`, default `{launcher_type}_{launcher_id}` | | `box-session-id-template` in `local-agent` pipeline config | ✅ Shipped | `templates/metadata/pipeline/ai.yaml`, default `{launcher_type}_{launcher_id}` |
| `BoxService.resolve_box_session_id(query)` | ✅ Shipped | `pkg/box/service.py:166` | | `BoxService.resolve_box_session_id(query)` | ✅ Shipped | `pkg/box/service.py:166` |
| `BoxService.build_skill_extra_mounts(query)` | ✅ Shipped | `pkg/box/service.py:189` | | `skill.build_execution_mounts(ap, query)` | ✅ Shipped | Core composes read-only packages; Box receives generic mounts |
| Skill exec uses unified container + extra mounts | ✅ Shipped | `pkg/provider/tools/loaders/native.py` skill branch | | Skill exec uses unified container + extra mounts | ✅ Shipped | `pkg/provider/tools/loaders/native.py` skill branch |
| MCP-in-Box uses shared persistent session, multi-process | ✅ Shipped (earlier than originally scoped) | SDK commit `529088e`, LangBot `mcp_stdio.py:_build_box_session_id` | | MCP-in-Box uses shared persistent session, multi-process | ✅ Shipped (earlier than originally scoped) | SDK commit `529088e`, LangBot `mcp_stdio.py:_build_box_session_id` |
| `BoxManagedProcessSpec.process_id` + multi-process per session | ✅ Shipped | `BoxRuntime` keeps `managed_processes: dict[pid, _ManagedProcess]` | | `BoxManagedProcessSpec.process_id` + multi-process per session | ✅ Shipped | `BoxRuntime` keeps `managed_processes: dict[pid, _ManagedProcess]` |
+1 -1
View File
@@ -51,7 +51,7 @@
| BoxService workspace quota | 优秀 | 前置/后置配额检查、超额清理 | | BoxService workspace quota | 优秀 | 前置/后置配额检查、超额清理 |
| BoxService 输出截断 | 优秀 | 短/精确边界/长输出、独立 stderr | | BoxService 输出截断 | 优秀 | 短/精确边界/长输出、独立 stderr |
| BoxService 可观测性 | 优秀 | 状态报告、error ring buffer、buffer 上限 | | BoxService 可观测性 | 优秀 | 状态报告、error ring buffer、buffer 上限 |
| BoxService session 模板 | 良好 | `resolve_box_session_id` + `build_skill_extra_mounts` 在 service / native / mcp 三处都有覆盖 | | BoxService session / mount contract | 良好 | `resolve_box_session_id` + generic `read_only_mounts`; Skill mount composition is covered in the Core loader |
| RPC client/server 协议 | 优秀 | execute/get_sessions/delete/create/conflict error | | RPC client/server 协议 | 优秀 | execute/get_sessions/delete/create/conflict error |
| BoxRuntimeConnector | 良好 | local/remote 模式、Docker 平台、relay URL、心跳与重连回调 | | BoxRuntimeConnector | 良好 | local/remote 模式、Docker 平台、relay URL、心跳与重连回调 |
| BoxWorkspaceSession | 良好 | payload 构建、managed process 路径重写、stage host file | | BoxWorkspaceSession | 良好 | payload 构建、managed process 路径重写、stage host file |
+1 -1
View File
@@ -6,7 +6,7 @@ Minimal, dependency-light clients for the LangBot **HTTP Bot** platform adapter.
They show the whole loop: signing a request, pushing a message, and receiving They show the whole loop: signing a request, pushing a message, and receiving
multi-part replies on a callback endpoint. multi-part replies on a callback endpoint.
Full guide: [docs.langbot.app — HTTP Bot](https://docs.langbot.app/en/usage/platforms/http-bot). Full guide: [docs.langbot.app — HTTP Bot](https://langbot.app/docs/en/usage/platforms/http-bot).
Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json). Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json).
## Files ## Files
+1 -1
View File
@@ -6,7 +6,7 @@
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收 它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
1→M 的多段回复。 1→M 的多段回复。
完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。 完整指南:[docs.langbot.app —— HTTP Bot](https://langbot.app/docs/zh/usage/platforms/http-bot)。
机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。 机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。
## 文件清单 ## 文件清单
+1 -1
View File
@@ -6,7 +6,7 @@ A single self-contained HTML page that demos the LangBot **Page Bot**
(`web_page_bot`) embeddable chat widget — the one you drop onto any website with (`web_page_bot`) embeddable chat widget — the one you drop onto any website with
a single `<script>` tag. a single `<script>` tag.
Full guide: [docs.langbot.app — Page Bot](https://docs.langbot.app/en/usage/platforms/webpage). Full guide: [docs.langbot.app — Page Bot](https://langbot.app/docs/en/usage/platforms/webpage).
## Files ## Files
+1 -1
View File
@@ -6,7 +6,7 @@
(`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 `<script>` 标签就能放到任意 (`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 `<script>` 标签就能放到任意
网站上的那个组件。 网站上的那个组件。
完整指南:[docs.langbot.app —— 页面机器人](https://docs.langbot.app/zh/usage/platforms/webpage)。 完整指南:[docs.langbot.app —— 页面机器人](https://langbot.app/docs/zh/usage/platforms/webpage)。
## 文件清单 ## 文件清单
+3 -3
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.10.9" version = "4.10.10"
description = "Production-grade platform for building agentic IM bots" description = "Production-grade platform for building agentic IM bots"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"] license-files = ["LICENSE"]
@@ -70,7 +70,7 @@ dependencies = [
"langchain-text-splitters>=1.1.2", "langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0", "chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.5.6", "langbot-plugin==0.5.7",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0", "line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2", "matrix-nio>=0.25.2",
@@ -114,7 +114,7 @@ seekdb = [
[project.urls] [project.urls]
Homepage = "https://langbot.app" Homepage = "https://langbot.app"
Documentation = "https://docs.langbot.app" Documentation = "https://langbot.app/docs"
Repository = "https://github.com/langbot-app/LangBot" Repository = "https://github.com/langbot-app/LangBot"
[project.scripts] [project.scripts]
+1 -1
View File
@@ -48,7 +48,7 @@ tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
## Kubernetes ## Kubernetes
See `docker/kubernetes.yaml` and the deployment guide at See `docker/kubernetes.yaml` and the deployment guide at
https://docs.langbot.app. `docker/deploy-k8s-test.sh` is a test helper. https://langbot.app/docs. `docker/deploy-k8s-test.sh` is a test helper.
## config.yaml (generated at `data/config.yaml` on first run) ## config.yaml (generated at `data/config.yaml` on first run)
+1 -1
View File
@@ -16,7 +16,7 @@ asciiart = r"""
|___/ |___/
Open Source 开源地址: https://github.com/langbot-app/LangBot Open Source 开源地址: https://github.com/langbot-app/LangBot
📖 Documentation 文档地址: https://docs.langbot.app 📖 Documentation 文档地址: https://langbot.app/docs
""" """
@@ -2,9 +2,6 @@ from __future__ import annotations
import quart import quart
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError
from langbot_plugin.box.errors import BoxError
from ...authz import Permission from ...authz import Permission
from ...context import RequestContext from ...context import RequestContext
from .. import group from .. import group
@@ -24,12 +21,7 @@ class SkillsRouterGroup(group.RouterGroup):
async def list_skills(request_context: RequestContext) -> quart.Response: async def list_skills(request_context: RequestContext) -> quart.Response:
try: try:
skills = await self.ap.skill_service.list_skills(request_context) skills = await self.ap.skill_service.list_skills(request_context)
except EntitlementFeatureUnavailableError: except ValueError as exc:
# Plans without managed sandbox support have no runnable skills.
# Treat that capability absence as an empty collection so the
# shared UI can render normally instead of surfacing a 500.
return self.success(data={'skills': []})
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc)) return self.http_status(400, -1, str(exc))
return self.success(data={'skills': skills}) return self.success(data={'skills': skills})
@@ -47,7 +39,7 @@ class SkillsRouterGroup(group.RouterGroup):
try: try:
skill = await self.ap.skill_service.create_skill(request_context, data) skill = await self.ap.skill_service.create_skill(request_context, data)
return self.success(data={'skill': skill}) return self.success(data={'skill': skill})
except (ValueError, BoxError) as exc: except ValueError as exc:
return self.http_status(400, -1, str(exc)) return self.http_status(400, -1, str(exc))
@self.route( @self.route(
@@ -59,7 +51,7 @@ class SkillsRouterGroup(group.RouterGroup):
async def get_skill(skill_name: str, request_context: RequestContext) -> quart.Response: async def get_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
try: try:
skill = await self.ap.skill_service.get_skill(request_context, skill_name) skill = await self.ap.skill_service.get_skill(request_context, skill_name)
except (ValueError, BoxError) as exc: except ValueError as exc:
return self.http_status(400, -1, str(exc)) return self.http_status(400, -1, str(exc))
if not skill: if not skill:
return self.http_status(404, -1, 'Skill not found') return self.http_status(404, -1, 'Skill not found')
@@ -77,13 +69,13 @@ class SkillsRouterGroup(group.RouterGroup):
try: try:
skill = await self.ap.skill_service.update_skill(request_context, skill_name, data) skill = await self.ap.skill_service.update_skill(request_context, skill_name, data)
return self.success(data={'skill': skill}) return self.success(data={'skill': skill})
except (ValueError, BoxError) as exc: except ValueError as exc:
return self.http_status(400, -1, str(exc)) return self.http_status(400, -1, str(exc))
try: try:
await self.ap.skill_service.delete_skill(request_context, skill_name) await self.ap.skill_service.delete_skill(request_context, skill_name)
return self.success() return self.success()
except (ValueError, BoxError) as exc: except ValueError as exc:
return self.http_status(400, -1, str(exc)) return self.http_status(400, -1, str(exc))
@self.route( @self.route(
@@ -105,7 +97,7 @@ class SkillsRouterGroup(group.RouterGroup):
include_hidden=include_hidden, include_hidden=include_hidden,
) )
return self.success(data=result) return self.success(data=result)
except (ValueError, BoxError) as exc: except ValueError as exc:
return self.http_status(400, -1, str(exc)) return self.http_status(400, -1, str(exc))
@self.route( @self.route(
@@ -118,7 +110,7 @@ class SkillsRouterGroup(group.RouterGroup):
try: try:
result = await self.ap.skill_service.read_skill_file(request_context, skill_name, path) result = await self.ap.skill_service.read_skill_file(request_context, skill_name, path)
return self.success(data=result) return self.success(data=result)
except (ValueError, BoxError) as exc: except ValueError as exc:
return self.http_status(400, -1, str(exc)) return self.http_status(400, -1, str(exc))
@self.route( @self.route(
@@ -136,7 +128,7 @@ class SkillsRouterGroup(group.RouterGroup):
try: try:
result = await self.ap.skill_service.write_skill_file(request_context, skill_name, path, content) result = await self.ap.skill_service.write_skill_file(request_context, skill_name, path, content)
return self.success(data=result) return self.success(data=result)
except (ValueError, BoxError) as exc: except ValueError as exc:
return self.http_status(400, -1, str(exc)) return self.http_status(400, -1, str(exc))
@self.route( @self.route(
@@ -170,7 +162,7 @@ class SkillsRouterGroup(group.RouterGroup):
try: try:
skill = await self.ap.skill_service.install_from_github(request_context, data) skill = await self.ap.skill_service.install_from_github(request_context, data)
return self.success(data={'skills': skill}) return self.success(data={'skills': skill})
except (ValueError, BoxError) as exc: except ValueError as exc:
return self.http_status(400, -1, str(exc)) return self.http_status(400, -1, str(exc))
except Exception: except Exception:
raise raise
@@ -194,7 +186,7 @@ class SkillsRouterGroup(group.RouterGroup):
try: try:
preview = await self.ap.skill_service.preview_install_from_github(request_context, data) preview = await self.ap.skill_service.preview_install_from_github(request_context, data)
return self.success(data={'skills': preview}) return self.success(data={'skills': preview})
except (ValueError, BoxError) as exc: except ValueError as exc:
return self.http_status(400, -1, str(exc)) return self.http_status(400, -1, str(exc))
except Exception: except Exception:
raise raise
@@ -219,7 +211,7 @@ class SkillsRouterGroup(group.RouterGroup):
source_paths=form.getlist('source_paths'), source_paths=form.getlist('source_paths'),
) )
return self.success(data={'skills': skill}) return self.success(data={'skills': skill})
except (ValueError, BoxError) as exc: except ValueError as exc:
return self.http_status(400, -1, str(exc)) return self.http_status(400, -1, str(exc))
except Exception: except Exception:
raise raise
@@ -242,7 +234,7 @@ class SkillsRouterGroup(group.RouterGroup):
filename=file.filename or '', filename=file.filename or '',
) )
return self.success(data={'skills': preview}) return self.success(data={'skills': preview})
except (ValueError, BoxError) as exc: except ValueError as exc:
return self.http_status(400, -1, str(exc)) return self.http_status(400, -1, str(exc))
except Exception: except Exception:
raise raise
@@ -261,5 +253,5 @@ class SkillsRouterGroup(group.RouterGroup):
try: try:
result = await self.ap.skill_service.scan_directory_async(request_context, path) result = await self.ap.skill_service.scan_directory_async(request_context, path)
return self.success(data=result) return self.success(data=result)
except (ValueError, BoxError) as exc: except ValueError as exc:
return self.http_status(400, -1, str(exc)) return self.http_status(400, -1, str(exc))
@@ -9,7 +9,6 @@ from .. import group
from .....entity.errors import account as account_errors from .....entity.errors import account as account_errors
from ...context import RequestContext from ...context import RequestContext
from .....cloud.launch import SpaceLaunchError from .....cloud.launch import SpaceLaunchError
from .....workspace.errors import WorkspaceNotFoundError
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
@@ -187,6 +186,9 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json json_data = await quart.request.json
code = json_data.get('code') code = json_data.get('code')
state = json_data.get('state') state = json_data.get('state')
redirect_uri = json_data.get('redirect_uri') or (
quart.request.url_root.rstrip('/') + '/auth/space/callback'
)
launch_assertion = json_data.get('launch_assertion') launch_assertion = json_data.get('launch_assertion')
workspace_uuid = json_data.get('workspace_uuid') workspace_uuid = json_data.get('workspace_uuid')
@@ -200,8 +202,11 @@ class UserRouterGroup(group.RouterGroup):
return self.fail(1, 'Missing authorization code') return self.fail(1, 'Missing authorization code')
if not state: if not state:
return self.fail(1, 'Missing state parameter') return self.fail(1, 'Missing state parameter')
if not str(code).startswith('v4_'):
return self.fail(1, 'Unsupported Space OAuth code contract')
try: try:
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=False)
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login') consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
# Exchange code for tokens # Exchange code for tokens
launch_workspace_uuid = consumed_state.launch_workspace_uuid launch_workspace_uuid = consumed_state.launch_workspace_uuid
@@ -219,24 +224,36 @@ class UserRouterGroup(group.RouterGroup):
code, code,
workspace_uuids, workspace_uuids,
workspace_created_ats, workspace_created_ats,
redirect_uri=redirect_uri,
) )
access_token = token_data.get('access_token') access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token') refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0) expires_in = token_data.get('expires_in', 0)
cloud_workspace_uuid = token_data.get('cloud_workspace_uuid')
if not access_token: if not access_token:
return self.fail(1, 'Failed to get access token from Space') return self.fail(1, 'Failed to get access token from Space')
# Authenticate and create/update local user cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
if cloud_mode and launch_workspace_uuid and launch_workspace_uuid != cloud_workspace_uuid:
return self.fail(1, 'Space OAuth Workspace binding mismatch')
target_workspace_uuid = launch_workspace_uuid or cloud_workspace_uuid
if cloud_mode:
if not target_workspace_uuid:
return self.fail(1, 'Space OAuth response is missing the Cloud Workspace binding')
await self.ap.directory_projection_service.reconcile_workspaces((target_workspace_uuid,))
# Authenticate only after the signed, exact Workspace delta has
# established the Account and membership runtime shadow rows.
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user( jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
access_token, refresh_token, expires_in access_token, refresh_token, expires_in
) )
if launch_workspace_uuid: if target_workspace_uuid:
try: try:
access = await self.ap.workspace_collaboration_service.resolve_account_workspace( access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
user_obj.uuid, user_obj.uuid,
launch_workspace_uuid, target_workspace_uuid,
) )
except Exception: except Exception:
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace') self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
@@ -368,12 +385,17 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json json_data = await quart.request.json
code = json_data.get('code') code = json_data.get('code')
state = json_data.get('state') state = json_data.get('state')
redirect_uri = json_data.get('redirect_uri') or (
quart.request.url_root.rstrip('/') + '/auth/space/callback?mode=bind'
)
if not code: if not code:
return self.http_status(400, -1, 'Missing authorization code') return self.http_status(400, -1, 'Missing authorization code')
if not state: if not state:
return self.http_status(400, -1, 'Missing state parameter') return self.http_status(400, -1, 'Missing state parameter')
if not str(code).startswith('v4_'):
return self.http_status(400, -1, 'Unsupported Space OAuth code contract')
try: try:
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind') user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
@@ -386,7 +408,10 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Only local accounts can bind to Space') return self.http_status(400, -1, 'Only local accounts can bind to Space')
try: try:
updated_user = await self.ap.user_service.bind_space_account(user_obj.user, code) redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=True)
updated_user = await self.ap.user_service.bind_space_account(
user_obj.user, code, redirect_uri=redirect_uri
)
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user) jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
return self.success( return self.success(
data={ data={
@@ -429,49 +454,18 @@ class UserRouterGroup(group.RouterGroup):
} }
) )
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
projection_service = self.ap.directory_projection_service projection_service = self.ap.directory_projection_service
access = None if projection_service is None:
# A first Cloud launch creates the personal Workspace immediately raise SpaceLaunchError('Cloud directory projection is unavailable')
# before redirecting here. Pull a bounded number of signed event await projection_service.reconcile_workspaces((launch['workspace_uuid'],))
# pages until both the Account and its target Workspace membership account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
# are visible instead of rejecting during the background-sync window.
for attempt in range(4):
if account is not None:
self.ap.user_service._require_active_account(account)
try:
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
account.uuid,
launch['workspace_uuid'],
)
break
except WorkspaceNotFoundError:
if projection_service is None:
raise
elif projection_service is None:
break
if attempt == 3:
break
await projection_service.sync_once()
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
if access is None and projection_service is not None:
# The target event may be deeper than the bounded incremental
# page budget. One authoritative signed snapshot catches this
# process up without turning the callback into unbounded polling.
await projection_service.refresh_snapshot()
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
if account is not None:
self.ap.user_service._require_active_account(account)
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
account.uuid,
launch['workspace_uuid'],
)
if account is None: if account is None:
raise SpaceLaunchError('Launch Account is not projected into Core') raise SpaceLaunchError('Launch Account is not projected into Core')
if access is None: # pragma: no cover - bounded loop resolves or raises. self.ap.user_service._require_active_account(account)
raise SpaceLaunchError('Launch Workspace is not projected into Core') access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
account.uuid,
launch['workspace_uuid'],
)
token = await self.ap.user_service.generate_jwt_token(account) token = await self.ap.user_service.generate_jwt_token(account)
return self.success( return self.success(
data={ data={
+10 -1
View File
@@ -137,7 +137,16 @@ class BotService:
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True) bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
await self.ap.platform_mgr.load_bot(context, bot) try:
await self.ap.platform_mgr.load_bot(context, bot)
except Exception:
# The bot row was already inserted above; without this rollback a
# failing adapter constructor (e.g. a missing optional credential
# key) would leave a permanently disabled orphan bot in the DB.
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_data['uuid'])
)
raise
return bot_data['uuid'] return bot_data['uuid']
+37 -69
View File
@@ -25,6 +25,7 @@ _PUBLIC_SKILL_FIELDS = (
'description', 'description',
'instructions', 'instructions',
'package_root', 'package_root',
'revision',
'created_at', 'created_at',
'updated_at', 'updated_at',
) )
@@ -53,38 +54,11 @@ class SkillService:
def __init__(self, ap: app.Application) -> None: def __init__(self, ap: app.Application) -> None:
self.ap = ap self.ap = ap
def _box_service(self): def _repository(self):
box_service = getattr(self.ap, 'box_service', None) repository = getattr(self.ap, 'skill_repository', None)
if box_service is not None and getattr(box_service, 'available', False): if repository is None:
return box_service raise ValueError('Skill repository is not initialised')
return None return repository
def _require_box(self, action: str):
"""Return the Box service or raise if it is not available.
Box is the only source of truth for skills. Every read and write
operation goes through it there is no local-filesystem fallback.
"""
box_service = self._box_service()
if box_service is not None:
return box_service
ap_box = getattr(self.ap, 'box_service', None)
if ap_box is None:
reason = 'not initialised'
elif not getattr(ap_box, 'enabled', True):
reason = 'disabled in config (box.enabled = false)'
else:
connector_error = getattr(ap_box, '_connector_error', '') or 'currently unavailable'
reason = f'unavailable: {connector_error}'
raise ValueError(
f'{action} requires the Box runtime, which is {reason}. '
f'Enable Box in config.yaml (box.enabled = true) and ensure the '
f'runtime is reachable before retrying.'
)
def _require_box_for_write(self, action: str) -> None:
"""Backwards-compatible alias preserved for clarity at call sites."""
self._require_box(action)
async def _execution_context(self, context: TenantContext) -> ExecutionContext: async def _execution_context(self, context: TenantContext) -> ExecutionContext:
workspace_uuid = require_workspace_uuid(context) workspace_uuid = require_workspace_uuid(context)
@@ -113,20 +87,11 @@ class SkillService:
async def list_skills(self, context: TenantContext) -> list[dict]: async def list_skills(self, context: TenantContext) -> list[dict]:
execution_context = await self._execution_context(context) execution_context = await self._execution_context(context)
# When Box is unavailable, surface an empty list rather than raising — return [self._serialize_skill(skill) for skill in await self._repository().list_skills(execution_context)]
# the skills page should render cleanly, and the UI separately renders
# a "Box disabled / unavailable" banner via useBoxStatus.
box_service = self._box_service()
if box_service is None:
return []
return [self._serialize_skill(skill) for skill in await box_service.list_skills(execution_context)]
async def get_skill(self, context: TenantContext, skill_name: str) -> Optional[dict]: async def get_skill(self, context: TenantContext, skill_name: str) -> Optional[dict]:
execution_context = await self._execution_context(context) execution_context = await self._execution_context(context)
box_service = self._box_service() skill = await self._repository().get_skill(execution_context, skill_name, snapshot=True)
if box_service is None:
return None
skill = await box_service.get_skill(execution_context, skill_name)
return self._serialize_skill(skill) if skill else None return self._serialize_skill(skill) if skill else None
async def get_skill_by_name(self, context: TenantContext, name: str) -> Optional[dict]: async def get_skill_by_name(self, context: TenantContext, name: str) -> Optional[dict]:
@@ -134,22 +99,25 @@ class SkillService:
async def create_skill(self, context: TenantContext, data: dict) -> dict: async def create_skill(self, context: TenantContext, data: dict) -> dict:
execution_context = await self._execution_context(context) execution_context = await self._execution_context(context)
box_service = self._require_box('Creating a skill') created = await self._repository().create_skill(execution_context, data)
created = await box_service.create_skill(execution_context, data) await self._reload_skills(execution_context)
return self._serialize_skill(created)
async def import_skill_directory(self, context: TenantContext, path: str, data: dict) -> dict:
execution_context = await self._execution_context(context)
created = await self._repository().import_skill_directory(execution_context, path, data)
await self._reload_skills(execution_context) await self._reload_skills(execution_context)
return self._serialize_skill(created) return self._serialize_skill(created)
async def update_skill(self, context: TenantContext, skill_name: str, data: dict) -> dict: async def update_skill(self, context: TenantContext, skill_name: str, data: dict) -> dict:
execution_context = await self._execution_context(context) execution_context = await self._execution_context(context)
box_service = self._require_box('Editing a skill') updated = await self._repository().update_skill(execution_context, skill_name, data)
updated = await box_service.update_skill(execution_context, skill_name, data)
await self._reload_skills(execution_context) await self._reload_skills(execution_context)
return self._serialize_skill(updated) return self._serialize_skill(updated)
async def delete_skill(self, context: TenantContext, skill_name: str) -> bool: async def delete_skill(self, context: TenantContext, skill_name: str) -> bool:
execution_context = await self._execution_context(context) execution_context = await self._execution_context(context)
box_service = self._require_box('Deleting a skill') await self._repository().delete_skill(execution_context, skill_name)
await box_service.delete_skill(execution_context, skill_name)
await self._reload_skills(execution_context) await self._reload_skills(execution_context)
return True return True
@@ -162,24 +130,27 @@ class SkillService:
max_entries: int = 200, max_entries: int = 200,
) -> dict: ) -> dict:
execution_context = await self._execution_context(context) execution_context = await self._execution_context(context)
box_service = self._require_box('Browsing skill files') return await self._repository().list_skill_files(
return await box_service.list_skill_files(execution_context, skill_name, path, include_hidden, max_entries) execution_context,
skill_name,
path,
include_hidden,
max_entries,
)
async def read_skill_file(self, context: TenantContext, skill_name: str, path: str) -> dict: async def read_skill_file(self, context: TenantContext, skill_name: str, path: str) -> dict:
execution_context = await self._execution_context(context) execution_context = await self._execution_context(context)
box_service = self._require_box('Reading a skill file') return await self._repository().read_skill_file(execution_context, skill_name, path)
return await box_service.read_skill_file(execution_context, skill_name, path)
async def write_skill_file(self, context: TenantContext, skill_name: str, path: str, content: str) -> dict: async def write_skill_file(self, context: TenantContext, skill_name: str, path: str, content: str) -> dict:
execution_context = await self._execution_context(context) execution_context = await self._execution_context(context)
box_service = self._require_box('Editing skill files') result = await self._repository().write_skill_file(execution_context, skill_name, path, content)
result = await box_service.write_skill_file(execution_context, skill_name, path, content)
await self._reload_skills(execution_context) await self._reload_skills(execution_context)
return result return result
async def install_from_github(self, context: TenantContext, data: dict) -> list[dict]: async def install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
execution_context = await self._execution_context(context) execution_context = await self._execution_context(context)
box_service = self._require_box('Installing a skill from GitHub') repository = self._repository()
owner = str(data['owner']).strip() owner = str(data['owner']).strip()
repo = str(data['repo']).strip() repo = str(data['repo']).strip()
release_tag = str(data.get('release_tag', '')).strip() release_tag = str(data.get('release_tag', '')).strip()
@@ -198,7 +169,7 @@ class SkillService:
zip_bytes = await self._download_github_asset(asset_url) zip_bytes = await self._download_github_asset(asset_url)
filename = f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip' filename = f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip'
installed = await box_service.install_skill_zip( installed = await repository.install_skill_zip(
execution_context, execution_context,
zip_bytes, zip_bytes,
filename, filename,
@@ -211,7 +182,7 @@ class SkillService:
async def preview_install_from_github(self, context: TenantContext, data: dict) -> list[dict]: async def preview_install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
execution_context = await self._execution_context(context) execution_context = await self._execution_context(context)
box_service = self._require_box('Previewing a skill from GitHub') repository = self._repository()
owner = str(data['owner']).strip() owner = str(data['owner']).strip()
repo = str(data['repo']).strip() repo = str(data['repo']).strip()
release_tag = str(data.get('release_tag', '')).strip() release_tag = str(data.get('release_tag', '')).strip()
@@ -228,7 +199,7 @@ class SkillService:
source_subdir = str(data.get('source_subdir', '') or '').strip() source_subdir = str(data.get('source_subdir', '') or '').strip()
zip_bytes = await self._download_github_asset(asset_url) zip_bytes = await self._download_github_asset(asset_url)
return await box_service.preview_skill_zip( return await repository.preview_skill_zip(
execution_context, execution_context,
zip_bytes, zip_bytes,
f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip', f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip',
@@ -245,8 +216,7 @@ class SkillService:
source_path: str = '', source_path: str = '',
) -> list[dict]: ) -> list[dict]:
execution_context = await self._execution_context(context) execution_context = await self._execution_context(context)
box_service = self._require_box('Installing a skill from upload') installed = await self._repository().install_skill_zip(
installed = await box_service.install_skill_zip(
execution_context, execution_context,
file_bytes, file_bytes,
filename, filename,
@@ -264,8 +234,7 @@ class SkillService:
filename: str, filename: str,
) -> list[dict]: ) -> list[dict]:
execution_context = await self._execution_context(context) execution_context = await self._execution_context(context)
box_service = self._require_box('Previewing a skill upload') return await self._repository().preview_skill_zip(execution_context, file_bytes, filename)
return await box_service.preview_skill_zip(execution_context, file_bytes, filename)
async def _install_github_skill_md( async def _install_github_skill_md(
self, self,
@@ -276,14 +245,14 @@ class SkillService:
repo: str, repo: str,
data: dict, data: dict,
) -> list[dict]: ) -> list[dict]:
box_service = self._require_box('Installing a skill from GitHub') repository = self._repository()
zip_bytes, filename, _package_name = await self._download_github_skill_directory_as_zip( zip_bytes, filename, _package_name = await self._download_github_skill_directory_as_zip(
asset_url, asset_url,
owner=owner, owner=owner,
repo=repo, repo=repo,
) )
installed = await box_service.install_skill_zip( installed = await repository.install_skill_zip(
context, context,
zip_bytes, zip_bytes,
filename, filename,
@@ -302,13 +271,13 @@ class SkillService:
owner: str, owner: str,
repo: str, repo: str,
) -> list[dict]: ) -> list[dict]:
box_service = self._require_box('Previewing a skill from GitHub') repository = self._repository()
zip_bytes, _filename, package_name = await self._download_github_skill_directory_as_zip( zip_bytes, _filename, package_name = await self._download_github_skill_directory_as_zip(
asset_url, asset_url,
owner=owner, owner=owner,
repo=repo, repo=repo,
) )
return await box_service.preview_skill_zip(context, zip_bytes, f'{package_name}.zip', target_suffix='') return await repository.preview_skill_zip(context, zip_bytes, f'{package_name}.zip', target_suffix='')
async def reload_skills(self, context: TenantContext) -> list[dict]: async def reload_skills(self, context: TenantContext) -> list[dict]:
execution_context = await self._execution_context(context) execution_context = await self._execution_context(context)
@@ -317,8 +286,7 @@ class SkillService:
async def scan_directory_async(self, context: TenantContext, path: str) -> dict: async def scan_directory_async(self, context: TenantContext, path: str) -> dict:
execution_context = await self._execution_context(context) execution_context = await self._execution_context(context)
box_service = self._require_box('Scanning a skill directory') return await self._repository().scan_skill_directory(execution_context, path)
return await box_service.scan_skill_directory(execution_context, path)
async def _reload_skills(self, context: TenantContext) -> None: async def _reload_skills(self, context: TenantContext) -> None:
skill_mgr = getattr(self.ap, 'skill_mgr', None) skill_mgr = getattr(self.ap, 'skill_mgr', None)
+4 -1
View File
@@ -119,7 +119,7 @@ class SpaceService:
space_config = self._get_space_config() space_config = self._get_space_config()
authorize_url = space_config['oauth_authorize_url'] authorize_url = space_config['oauth_authorize_url']
params = {'redirect_uri': redirect_uri} params = {'redirect_uri': redirect_uri, 'code_contract': 'redirect-v1'}
if state: if state:
params['state'] = state params['state'] = state
return f'{authorize_url}?{urlencode(params)}' return f'{authorize_url}?{urlencode(params)}'
@@ -129,6 +129,8 @@ class SpaceService:
code: str, code: str,
workspace_uuids: list[str] | None = None, workspace_uuids: list[str] | None = None,
workspace_created_ats: dict[str, int] | None = None, workspace_created_ats: dict[str, int] | None = None,
*,
redirect_uri: str = '',
) -> typing.Dict: ) -> typing.Dict:
"""Exchange OAuth authorization code for tokens""" """Exchange OAuth authorization code for tokens"""
from langbot.pkg.utils import constants from langbot.pkg.utils import constants
@@ -141,6 +143,7 @@ class SpaceService:
f'{space_url}/api/v1/accounts/oauth/token', f'{space_url}/api/v1/accounts/oauth/token',
json={ json={
'code': code, 'code': code,
'redirect_uri': redirect_uri,
'instance_id': constants.instance_id, 'instance_id': constants.instance_id,
# Sending an explicit empty list tells new Space servers not to # Sending an explicit empty list tells new Space servers not to
# synthesize a legacy instance-derived Workspace binding. # synthesize a legacy instance-derived Workspace binding.
+3 -2
View File
@@ -774,7 +774,7 @@ class UserService:
f'email:{normalized_email}', f'email:{normalized_email}',
) )
async def bind_space_account(self, user_email: str, code: str) -> user.User: async def bind_space_account(self, user_email: str, code: str, *, redirect_uri: str = '') -> user.User:
"""Bind Space account to existing local account""" """Bind Space account to existing local account"""
local_account = await self.get_user_by_email(user_email) local_account = await self.get_user_by_email(user_email)
if local_account is None: if local_account is None:
@@ -794,12 +794,13 @@ class UserService:
code, code,
[binding.workspace_uuid], [binding.workspace_uuid],
{binding.workspace_uuid: created_ts}, {binding.workspace_uuid: created_ts},
redirect_uri=redirect_uri,
) )
else: else:
# Compatibility for early/bootstrap call sites that have not wired # Compatibility for early/bootstrap call sites that have not wired
# WorkspaceService yet; old Space servers still derive the legacy # WorkspaceService yet; old Space servers still derive the legacy
# Workspace identity from instance_id when the field is omitted. # Workspace identity from instance_id when the field is omitted.
token_data = await self.ap.space_service.exchange_oauth_code(code) token_data = await self.ap.space_service.exchange_oauth_code(code, redirect_uri=redirect_uri)
access_token = token_data.get('access_token') access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token') refresh_token = token_data.get('refresh_token')
expires_in = token_data.get('expires_in', 0) expires_in = token_data.get('expires_in', 0)
+3 -4
View File
@@ -147,10 +147,9 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
- An explicit ``runtime.endpoint`` was configured - An explicit ``runtime.endpoint`` was configured
When this is True the Box runtime lives in a separate process with its When this is True the Box runtime lives in a separate process with its
own filesystem view (container, pod sidecar, or remote host), so paths own filesystem view (container, pod sidecar, or remote host), so only
it reports (e.g. skill ``package_root``) are NOT resolvable on the explicitly shared paths are usable on both sides. When False, Box runs
LangBot side. When False, Box runs as a stdio child process that shares as a stdio child process that shares LangBot's filesystem.
LangBot's filesystem.
""" """
return bool( return bool(
self.configured_runtime_endpoint self.configured_runtime_endpoint
+45 -211
View File
@@ -28,8 +28,10 @@ from langbot_plugin.box.errors import BoxAdmissionError, BoxError, BoxValidation
from langbot_plugin.box.models import ( from langbot_plugin.box.models import (
BUILTIN_PROFILES, BUILTIN_PROFILES,
BoxExecutionResult, BoxExecutionResult,
BoxHostMountMode,
BoxManagedProcessInfo, BoxManagedProcessInfo,
BoxManagedProcessSpec, BoxManagedProcessSpec,
BoxMountSpec,
BoxProfile, BoxProfile,
BoxSpec, BoxSpec,
) )
@@ -169,7 +171,7 @@ class BoxService:
self._connector_error = 'Box runtime is disabled in config (box.enabled = false)' self._connector_error = 'Box runtime is disabled in config (box.enabled = false)'
self.ap.logger.info( self.ap.logger.info(
'Box runtime disabled by config; sandbox features (exec/read/write/edit, ' 'Box runtime disabled by config; sandbox features (exec/read/write/edit, '
'skill add/edit, stdio MCP) will be unavailable.' 'stdio MCP, executable package scripts) will be unavailable.'
) )
return return
try: try:
@@ -275,10 +277,6 @@ class BoxService:
await self._purge_attachment_dirs() await self._purge_attachment_dirs()
self._available = True self._available = True
self._connector_error = '' self._connector_error = ''
skill_mgr = getattr(self.ap, 'skill_mgr', None)
reload_skills = getattr(skill_mgr, 'reload_skills', None)
if callable(reload_skills) and not self._cloud_managed:
await reload_skills()
self.ap.logger.info('Box runtime reconnected, sandbox features restored.') self.ap.logger.info('Box runtime reconnected, sandbox features restored.')
return return
except Exception as exc: except Exception as exc:
@@ -358,19 +356,17 @@ class BoxService:
"""Whether LangBot and the Box runtime share a filesystem view. """Whether LangBot and the Box runtime share a filesystem view.
This is True only when Box runs as a local stdio child process of This is True only when Box runs as a local stdio child process of
LangBot (same container/host). In that case paths the Box runtime LangBot (same container/host). In that case host paths resolve
reports notably skill ``package_root`` resolve identically on the identically on both sides and Core may perform local filesystem work.
LangBot side, so LangBot may validate them against its own filesystem.
It is False for every separated deployment (Docker Compose, k8s It is False for every separated deployment (Docker Compose, k8s
sidecar, ``--standalone-box``, or an explicit ``runtime.endpoint``), sidecar, ``--standalone-box``, or an explicit ``runtime.endpoint``),
where the Box runtime owns its own filesystem and LangBot must trust where only explicitly shared and identically mounted roots can cross
the paths it reports rather than checking them locally. the process boundary.
When Box is wired up with an injected client (tests, custom embeds) When Box is wired up with an injected client (tests, custom embeds)
there is no connector to introspect; we conservatively report False so there is no connector to introspect; we conservatively report False.
LangBot never wrongly drops Box-reported skills. An explicit override An explicit override can be set via ``_shares_filesystem_with_box`` (used by tests and any
can be set via ``_shares_filesystem_with_box`` (used by tests and any
embedder that knows the real topology). embedder that knows the real topology).
""" """
if self._shares_filesystem_with_box_override is not None: if self._shares_filesystem_with_box_override is not None:
@@ -483,11 +479,18 @@ class BoxService:
self, self,
context: TenantContext, context: TenantContext,
spec_payload: dict, spec_payload: dict,
*,
trusted_read_only_mounts: list[dict] | None = None,
) -> dict: ) -> dict:
"""Reject tenant-owned policy fields and apply the Cloud hard policy.""" """Reject tenant-owned policy fields and apply the Cloud hard policy."""
payload = dict(spec_payload) payload = dict(spec_payload)
trusted_mounts = self._normalize_trusted_read_only_mounts(trusted_read_only_mounts or [])
if not self._cloud_managed: if not self._cloud_managed:
if trusted_mounts:
if payload.get('extra_mounts'):
raise BoxValidationError('extra_mounts and trusted_read_only_mounts cannot both be supplied')
payload['extra_mounts'] = trusted_mounts
return payload return payload
policy = self._admission_policy policy = self._admission_policy
if policy is None: if policy is None:
@@ -537,7 +540,7 @@ class BoxService:
'network': 'off', 'network': 'off',
'host_path': canonical_host_path, 'host_path': canonical_host_path,
'mount_path': '/workspace', 'mount_path': '/workspace',
'extra_mounts': [], 'extra_mounts': trusted_mounts,
'persistent': True, 'persistent': True,
'timeout_sec': min(timeout, policy.max_timeout_sec), 'timeout_sec': min(timeout, policy.max_timeout_sec),
'cpus': policy.cpus, 'cpus': policy.cpus,
@@ -549,6 +552,22 @@ class BoxService:
) )
return payload return payload
def _normalize_trusted_read_only_mounts(self, mounts: list[dict]) -> list[dict]:
"""Validate Core-composed artifacts before crossing into Box."""
normalized: list[dict] = []
for raw_mount in mounts:
mount = BoxMountSpec.model_validate(raw_mount)
if mount.mode != BoxHostMountMode.READ_ONLY:
raise BoxAdmissionError('Core-composed additional mounts must be read-only')
host_path = os.path.realpath(mount.host_path)
if not os.path.isdir(host_path):
raise BoxAdmissionError('Core-composed read-only mount source is unavailable')
if not any(_is_path_under(host_path, root) for root in self.allowed_mount_roots):
raise BoxAdmissionError('Core-composed read-only mount source is outside allowed_mount_roots')
normalized.append(mount.model_copy(update={'host_path': host_path}).model_dump(mode='json'))
return normalized
def _reject_cloud_managed_process(self) -> None: def _reject_cloud_managed_process(self) -> None:
if self._cloud_managed: if self._cloud_managed:
raise BoxAdmissionError('Managed processes are disabled for Cloud sandboxes') raise BoxAdmissionError('Managed processes are disabled for Cloud sandboxes')
@@ -565,13 +584,18 @@ class BoxService:
query: pipeline_query.Query, query: pipeline_query.Query,
*, *,
skip_host_mount_validation: bool = False, skip_host_mount_validation: bool = False,
trusted_read_only_mounts: list[dict] | None = None,
) -> dict: ) -> dict:
if not self._available: if not self._available:
raise BoxError( raise BoxError(
'Box runtime is not available. Configure an available Box backend before using Box features.' 'Box runtime is not available. Configure an available Box backend before using Box features.'
) )
execution_context = await self._validated_execution_context(self._query_execution_context(query)) execution_context = await self._validated_execution_context(self._query_execution_context(query))
spec_payload = self._managed_policy_payload(execution_context, spec_payload) spec_payload = self._managed_policy_payload(
execution_context,
spec_payload,
trusted_read_only_mounts=trusted_read_only_mounts,
)
await self._require_validated_workspace_sandbox(execution_context) await self._require_validated_workspace_sandbox(execution_context)
if spec_payload.get('host_path') in (None, ''): if spec_payload.get('host_path') in (None, ''):
tenant_workspace = self._tenant_workspace(execution_context) tenant_workspace = self._tenant_workspace(execution_context)
@@ -658,70 +682,12 @@ class BoxService:
variables.setdefault('global', 'global') variables.setdefault('global', 'global')
return template.format_map(collections.defaultdict(lambda: 'unknown', variables)) return template.format_map(collections.defaultdict(lambda: 'unknown', variables))
def build_skill_extra_mounts(self, query: pipeline_query.Query) -> list[dict]:
"""Build extra_mounts entries for all pipeline-bound skills.
This ensures that when a container is first created it already has
all skill packages mounted, regardless of which skill is currently
activated.
Path validation is filesystem-topology dependent. When LangBot and the
Box runtime share a filesystem (local stdio mode), a skill whose
``package_root`` is missing or no longer a directory is skipped with a
warning instead of being passed through to the backend. Without that
guard the three backends behave inconsistently on a stale mount: nsjail
refuses to start the sandbox (failing every exec in the session),
Docker silently auto-creates a root-owned empty directory on the host,
and E2B silently skips the upload none of which surfaces an
actionable error.
When Box runs as a separate process (Docker Compose, k8s sidecar,
``--standalone-box``, or a remote ``runtime.endpoint``), the
``package_root`` reported by ``list_skills`` is the Box runtime's own
filesystem path and is NOT resolvable on the LangBot side. Validating
it locally would wrongly drop every skill, so LangBot trusts the path
and lets the Box runtime resolve it. The Box runtime only ever reports
skills it discovered on its own filesystem, so the path is valid there
by construction.
"""
if self._cloud_managed:
return []
skill_mgr = getattr(self.ap, 'skill_mgr', None)
if skill_mgr is None:
return []
from ..provider.tools.loaders import skill as skill_loader
validate_locally = self.shares_filesystem_with_box
visible_skills = skill_loader.get_visible_skills(self.ap, query)
mounts: list[dict] = []
for skill_name, skill_data in visible_skills.items():
package_root = str(skill_data.get('package_root', '') or '').strip()
if not package_root:
continue
if validate_locally and not os.path.isdir(package_root):
self.ap.logger.warning(
f'Skill "{skill_name}" package_root missing on filesystem '
f'({package_root}); skipping mount to prevent sandbox failures. '
f'The skill cache may be stale — consider reloading skills.'
)
continue
mounts.append(
{
'host_path': package_root,
'mount_path': f'/workspace/.skills/{skill_name}',
'mode': 'rw',
}
)
return mounts
async def execute_tool( async def execute_tool(
self, self,
parameters: dict, parameters: dict,
query: pipeline_query.Query, query: pipeline_query.Query,
*, *,
skill_name: str | None = None, read_only_mounts: list[dict] | None = None,
) -> dict: ) -> dict:
"""Execute an agent-facing ``exec`` tool call. """Execute an agent-facing ``exec`` tool call.
@@ -729,8 +695,6 @@ class BoxService:
``BoxSpec.cmd`` field and injects the session id from the query. ``BoxSpec.cmd`` field and injects the session id from the query.
""" """
spec_payload: dict = {'cmd': parameters['command']} spec_payload: dict = {'cmd': parameters['command']}
if skill_name is not None:
spec_payload['skill_name'] = skill_name
# Pass through allowed agent-facing fields # Pass through allowed agent-facing fields
for key in ('workdir', 'timeout_sec', 'env'): for key in ('workdir', 'timeout_sec', 'env'):
@@ -740,11 +704,11 @@ class BoxService:
# Inject context the agent must not control # Inject context the agent must not control
spec_payload.setdefault('session_id', self.resolve_box_session_id(query)) spec_payload.setdefault('session_id', self.resolve_box_session_id(query))
# Mount all pipeline-bound skills so they are available in the container return await self.execute_spec_payload(
if 'extra_mounts' not in spec_payload: spec_payload,
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query) query,
trusted_read_only_mounts=read_only_mounts,
return await self.execute_spec_payload(spec_payload, query) )
async def execute_in_context( async def execute_in_context(
self, self,
@@ -1275,8 +1239,6 @@ class BoxService:
'timeout_sec': 120, 'timeout_sec': 120,
'session_id': self.resolve_box_session_id(query), 'session_id': self.resolve_box_session_id(query),
} }
if 'extra_mounts' not in spec_payload:
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
try: try:
spec = self.build_spec(spec_payload) spec = self.build_spec(spec_payload)
result = await self.client.execute(spec) result = await self.client.execute(spec)
@@ -1527,126 +1489,6 @@ class BoxService:
self._runtime_connector.get_relay_headers(action_context), self._runtime_connector.get_relay_headers(action_context),
) )
async def list_skills(self, context: TenantContext) -> list[dict]:
execution_context = await self._validated_skill_execution_context(context)
return await self.client.list_skills(action_context=self._action_context(execution_context))
async def get_skill(self, context: TenantContext, name: str) -> dict | None:
execution_context = await self._validated_skill_execution_context(context)
return await self.client.get_skill(name, action_context=self._action_context(execution_context))
async def create_skill(self, context: TenantContext, skill: dict) -> dict:
execution_context = await self._validated_skill_execution_context(context)
payload = dict(skill)
payload.pop('workspace_uuid', None)
if self._cloud_managed and str(payload.get('package_root', '') or '').strip():
raise BoxAdmissionError('Cloud skill package_root is runtime-owned')
if self._cloud_managed:
payload.pop('package_root', None)
return await self.client.create_skill(payload, action_context=self._action_context(execution_context))
async def update_skill(self, context: TenantContext, name: str, skill: dict) -> dict:
execution_context = await self._validated_skill_execution_context(context)
payload = dict(skill)
payload.pop('workspace_uuid', None)
if self._cloud_managed:
# The runtime already owns the package path for an existing skill.
# A serialized read response may contain it, but it is never an
# authority-bearing update field in shared Cloud mode.
payload.pop('package_root', None)
return await self.client.update_skill(
name,
payload,
action_context=self._action_context(execution_context),
)
async def delete_skill(self, context: TenantContext, name: str) -> None:
execution_context = await self._validated_skill_execution_context(context)
await self.client.delete_skill(name, action_context=self._action_context(execution_context))
async def scan_skill_directory(self, context: TenantContext, path: str) -> dict:
execution_context = await self._validated_skill_execution_context(context)
if self._cloud_managed:
raise BoxAdmissionError('Scanning arbitrary host skill directories is disabled in Cloud')
return await self.client.scan_skill_directory(path, action_context=self._action_context(execution_context))
async def _validated_skill_execution_context(self, context: TenantContext) -> ExecutionContext:
execution_context = await self._validated_execution_context(context)
await self._require_validated_workspace_sandbox(execution_context)
return execution_context
async def list_skill_files(
self,
context: TenantContext,
name: str,
path: str = '.',
include_hidden: bool = False,
max_entries: int = 200,
) -> dict:
execution_context = await self._validated_skill_execution_context(context)
return await self.client.list_skill_files(
name,
path,
include_hidden,
max_entries,
action_context=self._action_context(execution_context),
)
async def read_skill_file(self, context: TenantContext, name: str, path: str) -> dict:
execution_context = await self._validated_skill_execution_context(context)
return await self.client.read_skill_file(
name,
path,
action_context=self._action_context(execution_context),
)
async def write_skill_file(self, context: TenantContext, name: str, path: str, content: str) -> dict:
execution_context = await self._validated_skill_execution_context(context)
return await self.client.write_skill_file(
name,
path,
content,
action_context=self._action_context(execution_context),
)
async def preview_skill_zip(
self,
context: TenantContext,
file_bytes: bytes,
filename: str,
source_subdir: str = '',
target_suffix: str = 'upload',
) -> list[dict]:
execution_context = await self._validated_skill_execution_context(context)
return await self.client.preview_skill_zip(
file_bytes,
filename,
source_subdir,
target_suffix,
action_context=self._action_context(execution_context),
)
async def install_skill_zip(
self,
context: TenantContext,
file_bytes: bytes,
filename: str,
source_paths: list[str] | None = None,
source_path: str = '',
source_subdir: str = '',
target_suffix: str = 'upload',
) -> list[dict]:
execution_context = await self._validated_skill_execution_context(context)
return await self.client.install_skill_zip(
file_bytes,
filename,
source_paths,
source_path,
source_subdir,
target_suffix,
action_context=self._action_context(execution_context),
)
def _serialize_result(self, result: BoxExecutionResult) -> dict: def _serialize_result(self, result: BoxExecutionResult) -> dict:
stdout, stdout_truncated = self._truncate(result.stdout) stdout, stdout_truncated = self._truncate(result.stdout)
stderr, stderr_truncated = self._truncate(result.stderr) stderr, stderr_truncated = self._truncate(result.stderr)
@@ -1783,14 +1625,6 @@ class BoxService:
default_workspace = os.path.join(self.host_root, default_workspace) default_workspace = os.path.join(self.host_root, default_workspace)
return os.path.realpath(os.path.abspath(default_workspace)) return os.path.realpath(os.path.abspath(default_workspace))
def get_skills_root(self) -> str | None:
skills_root = str(self._local_config().get('skills_root', '') or 'skills').strip()
if not skills_root:
skills_root = 'skills'
if not os.path.isabs(skills_root) and self.host_root is not None:
skills_root = os.path.join(self.host_root, skills_root)
return os.path.realpath(os.path.abspath(skills_root))
def _load_enabled(self) -> bool: def _load_enabled(self) -> bool:
"""Read ``box.enabled`` (top-level, not ``box.local.*``). Default True """Read ``box.enabled`` (top-level, not ``box.local.*``). Default True
disabling is opt-in. Accepts bool, ``'true'``/``'false'`` strings, disabling is opt-in. Accepts bool, ``'true'``/``'false'`` strings,
+6 -185
View File
@@ -1,43 +1,28 @@
"""Reusable workspace/session helpers built on top of Box. """Reusable workspace/session helpers built on top of Box.
This module is the middle layer between the raw Box runtime primitives and This module is the middle layer between raw Box runtime primitives and
application-specific flows such as skills or MCP stdio. application-specific consumers.
It intentionally stays generic: It intentionally stays generic:
- path and virtualenv rewriting are workspace concerns - path and virtualenv rewriting are workspace concerns
- Python project detection/bootstrap are workspace concerns - Python project detection/bootstrap are workspace concerns
- session exec / managed-process helpers are workspace concerns - session exec / managed-process helpers are workspace concerns
Higher layers add their own semantics on top, for example: Higher layers add their own semantics on top; BoxWorkspaceSession retains only
- skills choose a stable per-skill session id and use repeated exec workspace, execution, and managed-process concepts.
- MCP stdio chooses how to prepare dependencies and attaches to a managed process
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import textwrap
from typing import Any from typing import Any
PYTHON_MANIFEST_FILES = ( from ..utils.python_workspace import list_python_manifest_files
'requirements.txt',
'pyproject.toml',
'setup.py',
'setup.cfg',
)
_VENV_DIRS = frozenset({'.venv', 'venv', 'env', '.env'}) _VENV_DIRS = frozenset({'.venv', 'venv', 'env', '.env'})
_VENV_BIN_DIRS = frozenset({'bin', 'Scripts'}) _VENV_BIN_DIRS = frozenset({'bin', 'Scripts'})
def normalize_host_path(path: str | None) -> str:
if path is None:
return ''
stripped = str(path).strip()
if not stripped:
return ''
return os.path.realpath(os.path.abspath(stripped))
def rewrite_mounted_path(path: str, host_path: str | None, *, mount_path: str = '/workspace') -> str: def rewrite_mounted_path(path: str, host_path: str | None, *, mount_path: str = '/workspace') -> str:
"""Translate a host path into the path visible inside the sandbox mount.""" """Translate a host path into the path visible inside the sandbox mount."""
if not host_path or not path: if not host_path or not path:
@@ -98,13 +83,6 @@ def rewrite_venv_command(command: str, host_path: str | None, *, mount_path: str
return rewrite_mounted_path(normalized_command, host_path, mount_path=mount_path) return rewrite_mounted_path(normalized_command, host_path, mount_path=mount_path)
def list_python_manifest_files(host_path: str | None) -> list[str]:
normalized_root = normalize_host_path(host_path)
if not normalized_root:
return []
return [filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename))]
def classify_python_workspace(host_path: str | None) -> str | None: def classify_python_workspace(host_path: str | None) -> str | None:
"""Return the generic Python workspace shape, without app-specific policy.""" """Return the generic Python workspace shape, without app-specific policy."""
manifest_files = set(list_python_manifest_files(host_path)) manifest_files = set(list_python_manifest_files(host_path))
@@ -117,163 +95,6 @@ def classify_python_workspace(host_path: str | None) -> str | None:
return None return None
def should_prepare_python_env(host_path: str | None) -> bool:
normalized_root = normalize_host_path(host_path)
if not normalized_root:
return False
if os.path.isdir(os.path.join(normalized_root, '.venv')):
return True
return bool(list_python_manifest_files(normalized_root))
def wrap_python_command_with_env(
command: str,
*,
mount_path: str = '/workspace',
state_path: str | None = None,
) -> str:
"""Wrap a command with a reusable sandbox-local Python env bootstrap.
``mount_path`` is always the source tree used for manifest hashing and
installation. ``state_path`` may point at a separate writable directory
for read-only source mounts; when omitted, legacy mutable-workspace behavior
stores the environment beside the source.
"""
writable_state_path = state_path or mount_path
bootstrap = textwrap.dedent(
f"""
set -e
_LB_VENV_DIR="{writable_state_path}/.venv"
_LB_META_DIR="{writable_state_path}/.langbot"
_LB_META_FILE="$_LB_META_DIR/python-env.json"
_LB_LOCK_DIR="$_LB_META_DIR/python-env.lock"
_LB_TMP_DIR="{writable_state_path}/.tmp"
_LB_PIP_CACHE_DIR="{writable_state_path}/.cache/pip"
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"
if [ -z "$_LB_SYSTEM_PYTHON" ]; then
echo "python3 or python is required to prepare the workspace Python environment" >&2
exit 127
fi
export TMPDIR="$_LB_TMP_DIR"
export TEMP="$_LB_TMP_DIR"
export TMP="$_LB_TMP_DIR"
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
_lb_python_meta() {{
"$_LB_SYSTEM_PYTHON" - <<'PY'
import hashlib
import json
import os
import sys
root = "{mount_path}"
max_manifest_bytes = 10 * 1024 * 1024
digest = hashlib.sha256()
manifest_files = []
for rel in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"):
path = os.path.join(root, rel)
if not os.path.isfile(path):
continue
if os.path.getsize(path) > max_manifest_bytes:
raise RuntimeError(
f"Python project manifest exceeds {{max_manifest_bytes}} bytes: {{rel}}"
)
manifest_files.append(rel)
with open(path, "rb") as handle:
digest.update(rel.encode("utf-8"))
digest.update(b"\\0")
while chunk := handle.read(1024 * 1024):
digest.update(chunk)
digest.update(b"\\0")
print(
json.dumps(
{{
"python_executable": sys.executable,
"python_version": list(sys.version_info[:3]),
"manifest_files": manifest_files,
"manifest_sha256": digest.hexdigest(),
}},
sort_keys=True,
)
)
PY
}}
_LB_CURRENT_META="$(_lb_python_meta)"
_LB_NEEDS_BOOTSTRAP=0
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
_LB_NEEDS_BOOTSTRAP=1
elif [ ! -f "$_LB_META_FILE" ]; then
_LB_NEEDS_BOOTSTRAP=1
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
_LB_NEEDS_BOOTSTRAP=1
fi
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
_LB_LOCK_WAIT=0
while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do
if [ "$_LB_LOCK_WAIT" -ge 120 ]; then
_LB_LOCK_OWNER="$(cat "$_LB_LOCK_DIR/pid" 2>/dev/null || true)"
if [ -n "$_LB_LOCK_OWNER" ] && kill -0 "$_LB_LOCK_OWNER" 2>/dev/null; then
echo "Timed out waiting for active Python environment lock: $_LB_LOCK_DIR" >&2
exit 1
fi
echo "Timed out waiting for Python environment lock, clearing stale lock: $_LB_LOCK_DIR" >&2
rm -rf "$_LB_LOCK_DIR" 2>/dev/null || true
if mkdir "$_LB_LOCK_DIR" 2>/dev/null; then
break
fi
echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2
exit 1
fi
sleep 1
_LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1))
done
printf '%s\\n' "$$" > "$_LB_LOCK_DIR/pid" 2>/dev/null || true
_lb_cleanup_lock() {{
rm -rf "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
}}
trap _lb_cleanup_lock EXIT INT TERM
_LB_CURRENT_META="$(_lb_python_meta)"
_LB_NEEDS_BOOTSTRAP=0
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
_LB_NEEDS_BOOTSTRAP=1
elif [ ! -f "$_LB_META_FILE" ]; then
_LB_NEEDS_BOOTSTRAP=1
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
_LB_NEEDS_BOOTSTRAP=1
fi
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
rm -rf "$_LB_VENV_DIR"
"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"
. "$_LB_VENV_DIR/bin/activate"
python -m pip install --upgrade pip setuptools wheel
if [ -f "{mount_path}/requirements.txt" ]; then
python -m pip install -r "{mount_path}/requirements.txt"
elif [ -f "{mount_path}/pyproject.toml" ] || [ -f "{mount_path}/setup.py" ] || [ -f "{mount_path}/setup.cfg" ]; then
python -m pip install "{mount_path}"
fi
printf '%s' "$_LB_CURRENT_META" > "$_LB_META_FILE"
fi
fi
export VIRTUAL_ENV="$_LB_VENV_DIR"
export PATH="$_LB_VENV_DIR/bin:$PATH"
{command}
"""
).strip()
return bootstrap + '\n'
class BoxWorkspaceSession: class BoxWorkspaceSession:
"""High-level handle for one reusable workspace-backed Box session. """High-level handle for one reusable workspace-backed Box session.
+84 -1
View File
@@ -173,6 +173,77 @@ class DirectoryProjectionService:
async with self._sync_lock: async with self._sync_lock:
await self._sync_once() await self._sync_once()
async def reconcile_workspaces(self, workspace_uuids: Iterable[str]) -> None:
"""Synchronously project an exact Workspace set without moving the event cursor."""
requested = tuple(sorted({str(value).strip() for value in workspace_uuids if str(value).strip()}))
if not requested:
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation requires a Workspace')
if len(requested) > self.event_limit:
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation exceeds the batch limit')
async with self._sync_lock:
delta = await self.provider.fetch_workspaces(self.instance_uuid, requested)
await self._apply_targeted_delta(delta, requested)
async def _apply_targeted_delta(
self,
delta: DirectoryDelta,
requested_workspace_uuids: tuple[str, ...],
) -> None:
if not isinstance(delta, DirectoryDelta):
raise DirectoryProjectionUnavailableError('Directory provider returned an invalid delta')
workspace_count, membership_count = self._validate_batch_capacity(
delta.workspaces,
full_snapshot=False,
)
delta = DirectoryDelta.model_validate(delta.model_dump())
if delta.instance_uuid != self.instance_uuid:
raise DirectoryProjectionUnavailableError('Directory delta targets another LangBot instance')
requested = set(requested_workspace_uuids)
if set(delta.requested_workspace_uuids) != requested:
raise DirectoryProjectionUnavailableError('Directory delta does not match the requested Workspaces')
if {workspace.uuid for workspace in delta.workspaces} != requested:
raise DirectoryProjectionUnavailableError('Directory delta omitted a requested Workspace')
directory_uow = getattr(self.ap.persistence_mgr, 'directory_projection_uow', None)
if not callable(directory_uow):
raise DirectoryProjectionUnavailableError('Directory projection persistence scope is unavailable')
async with directory_uow(self.instance_uuid) as uow:
session = uow.session
state = await session.scalar(
sqlalchemy.select(DirectoryProjectionState)
.where(DirectoryProjectionState.instance_uuid == self.instance_uuid)
.with_for_update()
)
if state is None:
raise DirectoryProjectionUnavailableError('Directory projection is not initialized')
snapshot = DirectorySnapshot(
instance_uuid=self.instance_uuid,
cursor=state.cursor,
generated_at=delta.generated_at,
workspaces=delta.workspaces,
)
accounts_by_uuid = await self._apply_accounts(session, snapshot, preserve_existing=True)
await self._apply_workspaces(session, snapshot, accounts_by_uuid=accounts_by_uuid)
active_workspace_count = await self._enforce_active_workspace_capacity(session)
await session.flush()
await self._update_entitlement_workspace_activity(
snapshot.workspaces,
requested_workspace_uuids=requested,
)
self._publish_runtime_execution_projection(
snapshot.workspaces,
affected_workspace_uuids=requested,
)
self._request_model_catalog_sync()
self._record_batch_cardinality(
active_workspaces=active_workspace_count,
workspaces=workspace_count,
memberships=membership_count,
)
async def _sync_once(self) -> None: async def _sync_once(self) -> None:
cursor = self._consumer_cursor cursor = self._consumer_cursor
if cursor is None: if cursor is None:
@@ -723,7 +794,13 @@ class DirectoryProjectionService:
for row in inbox_rows: for row in inbox_rows:
row.applied_at = now row.applied_at = now
async def _apply_accounts(self, session: Any, snapshot: DirectorySnapshot) -> dict[str, User]: async def _apply_accounts(
self,
session: Any,
snapshot: DirectorySnapshot,
*,
preserve_existing: bool = False,
) -> dict[str, User]:
selected: dict[str, DirectoryMember] = {} selected: dict[str, DirectoryMember] = {}
emails: dict[str, str] = {} emails: dict[str, str] = {}
for workspace in snapshot.workspaces: for workspace in snapshot.workspaces:
@@ -788,6 +865,12 @@ class DirectoryProjectionService:
continue continue
if account.source != AccountSource.CLOUD_PROJECTION.value: if account.source != AccountSource.CLOUD_PROJECTION.value:
raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account') raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account')
if preserve_existing:
# A targeted Workspace fetch has no independently monotonic
# Account revision. It may create a missing runtime shadow, but
# ordered event/snapshot projection remains the only updater of
# existing Account identity and status fields.
continue
if account.projection_revision > snapshot.cursor: if account.projection_revision > snapshot.cursor:
raise DirectoryProjectionUnavailableError('Directory account revision rolled back') raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
projected_account = self._account_projection(member) projected_account = self._account_projection(member)
+4 -2
View File
@@ -43,6 +43,7 @@ from . import entities as core_entities
from ..rag.knowledge import kbmgr as rag_mgr from ..rag.knowledge import kbmgr as rag_mgr
from ..rag.service import RAGRuntimeService from ..rag.service import RAGRuntimeService
from ..vector import mgr as vectordb_mgr from ..vector import mgr as vectordb_mgr
from ..skill import repository as skill_repository
from ..telemetry import telemetry as telemetry_module from ..telemetry import telemetry as telemetry_module
from ..survey import manager as survey_module from ..survey import manager as survey_module
from ..skill import manager as skill_mgr from ..skill import manager as skill_mgr
@@ -84,6 +85,7 @@ class Application:
# TODO move to pipeline # TODO move to pipeline
tool_mgr: llm_tool_mgr.ToolManager = None tool_mgr: llm_tool_mgr.ToolManager = None
box_service: box_service_module.BoxService = None box_service: box_service_module.BoxService = None
skill_repository: skill_repository.SkillRepository = None
# ======= Config manager ======= # ======= Config manager =======
@@ -635,9 +637,9 @@ class Application:
frontend_path = paths.get_frontend_path() frontend_path = paths.get_frontend_path()
if not os.path.exists(frontend_path): if not os.path.exists(frontend_path):
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://docs.langbot.app/zh') self.logger.warning('WebUI 文件缺失,请根据文档部署:https://langbot.app/docs/zh')
self.logger.warning( self.logger.warning(
'WebUI files are missing, please deploy according to the documentation: https://docs.langbot.app/en' 'WebUI files are missing, please deploy according to the documentation: https://langbot.app/docs/en'
) )
return return
+2
View File
@@ -26,6 +26,7 @@ from ...api.http.service import knowledge as knowledge_service
from ...api.http.service import mcp as mcp_service from ...api.http.service import mcp as mcp_service
from ...api.http.service import apikey as apikey_service from ...api.http.service import apikey as apikey_service
from ...api.http.service import webhook as webhook_service from ...api.http.service import webhook as webhook_service
from ...skill import repository as skill_repository
from ...api.http.service import monitoring as monitoring_service from ...api.http.service import monitoring as monitoring_service
from ...api.http.service import skill as skill_service from ...api.http.service import skill as skill_service
from ...skill import manager as skill_mgr from ...skill import manager as skill_mgr
@@ -127,6 +128,7 @@ class BuildAppStage(stage.BootingStage):
webhook_service_inst = webhook_service.WebhookService(ap) webhook_service_inst = webhook_service.WebhookService(ap)
ap.webhook_service = webhook_service_inst ap.webhook_service = webhook_service_inst
ap.skill_repository = skill_repository.SkillRepository(ap)
skill_service_inst = skill_service.SkillService(ap) skill_service_inst = skill_service.SkillService(ap)
ap.skill_service = skill_service_inst ap.skill_service = skill_service_inst
+4 -2
View File
@@ -328,6 +328,8 @@ class PreProcessor(stage.PipelineStage):
# relied on this injection; without it the LLM never discovers # relied on this injection; without it the LLM never discovers
# the skills are there and just calls native tools instead. # the skills are there and just calls native tools instead.
if selected_runner == 'local-agent' and self.ap.skill_mgr: if selected_runner == 'local-agent' and self.ap.skill_mgr:
available_tool_names = {tool.name for tool in query.use_funcs}
query.variables['_skill_execution_available'] = 'exec' in available_tool_names
skill_execution_context = get_query_execution_context(query) skill_execution_context = get_query_execution_context(query)
await self.ap.skill_mgr.ensure_loaded(skill_execution_context) await self.ap.skill_mgr.ensure_loaded(skill_execution_context)
pipeline_data = await self.ap.pipeline_service.get_pipeline( pipeline_data = await self.ap.pipeline_service.get_pipeline(
@@ -349,7 +351,7 @@ class PreProcessor(stage.PipelineStage):
skill_execution_context, skill_execution_context,
bound_skills=bound_skills, bound_skills=bound_skills,
) )
if skill_addition: if skill_addition and 'activate' in available_tool_names:
self._append_to_system_prompt(query.prompt.messages, skill_addition) self._append_to_system_prompt(query.prompt.messages, skill_addition)
self.ap.logger.debug( self.ap.logger.debug(
f'Skill index injected into system prompt: ' f'Skill index injected into system prompt: '
@@ -357,7 +359,7 @@ class PreProcessor(stage.PipelineStage):
f'bound_skills={bound_skills or "all"} ' f'bound_skills={bound_skills or "all"} '
f'loaded_skills={len(self.ap.skill_mgr.get_skills(skill_execution_context))}' f'loaded_skills={len(self.ap.skill_mgr.get_skills(skill_execution_context))}'
) )
else: elif 'activate' in available_tool_names:
self.ap.logger.debug( self.ap.logger.debug(
f'No skills available for prompt injection: ' f'No skills available for prompt injection: '
f'pipeline={query.pipeline_uuid} ' f'pipeline={query.pipeline_uuid} '
@@ -15,9 +15,9 @@ spec:
categories: categories:
- protocol - protocol
help_links: help_links:
zh: https://link.langbot.app/zh/platforms/aiocqhttp zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat
en: https://link.langbot.app/en/platforms/aiocqhttp en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat
ja: https://link.langbot.app/ja/platforms/aiocqhttp ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat
config: config:
- name: host - name: host
label: label:
@@ -15,9 +15,9 @@ spec:
categories: categories:
- china - china
help_links: help_links:
zh: https://link.langbot.app/zh/platforms/dingtalk zh: https://langbot.app/docs/zh/usage/platforms/dingtalk
en: https://link.langbot.app/en/platforms/dingtalk en: https://langbot.app/docs/en/usage/platforms/dingtalk
ja: https://link.langbot.app/ja/platforms/dingtalk ja: https://langbot.app/docs/ja/usage/platforms/dingtalk
config: config:
- name: one-click-create - name: one-click-create
label: label:
@@ -24,9 +24,9 @@ spec:
- popular - popular
- global - global
help_links: help_links:
zh: https://link.langbot.app/zh/platforms/discord zh: https://langbot.app/docs/zh/usage/platforms/discord
en: https://link.langbot.app/en/platforms/discord en: https://langbot.app/docs/en/usage/platforms/discord
ja: https://link.langbot.app/ja/platforms/discord ja: https://langbot.app/docs/ja/usage/platforms/discord
config: config:
- name: client_id - name: client_id
label: label:
@@ -18,9 +18,9 @@ spec:
- popular - popular
- global - global
help_links: help_links:
zh: https://docs.langbot.app/zh/platforms/http-bot zh: https://langbot.app/docs/zh/platforms/http-bot
en: https://docs.langbot.app/en/platforms/http-bot en: https://langbot.app/docs/en/platforms/http-bot
ja: https://docs.langbot.app/ja/platforms/http-bot ja: https://langbot.app/docs/ja/platforms/http-bot
config: config:
- name: webhook_url - name: webhook_url
label: label:
+3 -3
View File
@@ -15,9 +15,9 @@ spec:
categories: categories:
- china - china
help_links: help_links:
zh: https://link.langbot.app/zh/platforms/kook zh: https://langbot.app/docs/zh/usage/platforms/kook
en: https://link.langbot.app/en/platforms/kook en: https://langbot.app/docs/en/usage/platforms/kook
ja: https://link.langbot.app/ja/platforms/kook ja: https://langbot.app/docs/ja/usage/platforms/kook
config: config:
- name: token - name: token
label: label:
+3 -3
View File
@@ -19,9 +19,9 @@ spec:
- china - china
- global - global
help_links: help_links:
zh: https://link.langbot.app/zh/platforms/lark zh: https://langbot.app/docs/zh/usage/platforms/lark
en: https://link.langbot.app/en/platforms/lark en: https://langbot.app/docs/en/usage/platforms/lark
ja: https://link.langbot.app/ja/platforms/lark ja: https://langbot.app/docs/ja/usage/platforms/lark
config: config:
- name: domain - name: domain
label: label:
+3 -3
View File
@@ -22,9 +22,9 @@ spec:
categories: categories:
- global - global
help_links: help_links:
zh: https://link.langbot.app/zh/platforms/line zh: https://langbot.app/docs/zh/usage/platforms/line
en: https://link.langbot.app/en/platforms/line en: https://langbot.app/docs/en/usage/platforms/line
ja: https://link.langbot.app/ja/platforms/line ja: https://langbot.app/docs/ja/usage/platforms/line
config: config:
- name: webhook_url - name: webhook_url
label: label:
@@ -15,9 +15,9 @@ spec:
categories: categories:
- china - china
help_links: help_links:
zh: https://link.langbot.app/zh/platforms/officialaccount zh: https://langbot.app/docs/zh/usage/platforms/wxoa
en: https://link.langbot.app/en/platforms/officialaccount en: https://langbot.app/docs/en/usage/platforms/wxoa
ja: https://link.langbot.app/ja/platforms/officialaccount ja: https://langbot.app/docs/ja/usage/platforms/wxoa
config: config:
- name: webhook_url - name: webhook_url
label: label:
@@ -16,9 +16,9 @@ spec:
- popular - popular
- china - china
help_links: help_links:
zh: https://link.langbot.app/zh/platforms/openclaw_weixin zh: https://langbot.app/docs/zh/usage/platforms/wechat/weixin
en: https://link.langbot.app/en/platforms/openclaw_weixin en: https://langbot.app/docs/en/usage/platforms/readme
ja: https://link.langbot.app/ja/platforms/openclaw_weixin ja: https://langbot.app/docs/ja/usage/platforms/readme
config: config:
- name: base_url - name: base_url
label: label:
@@ -205,7 +205,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
bot = QQOfficialClient( bot = QQOfficialClient(
app_id=config['appid'], app_id=config['appid'],
secret=config['secret'], secret=config['secret'],
token=config['token'], token=config.get('token', ''),
logger=logger, logger=logger,
unified_mode=enable_webhook, unified_mode=enable_webhook,
) )
@@ -15,9 +15,9 @@ spec:
categories: categories:
- china - china
help_links: help_links:
zh: https://link.langbot.app/zh/platforms/qqofficial zh: https://langbot.app/docs/zh/usage/platforms/qq/official_webhook
en: https://link.langbot.app/en/platforms/qqofficial en: https://langbot.app/docs/en/usage/platforms/qq/official_webhook
ja: https://link.langbot.app/ja/platforms/qqofficial ja: https://langbot.app/docs/ja/usage/platforms/qq/official_webhook
config: config:
- name: __system.outbound_ips - name: __system.outbound_ips
label: label:
+3 -3
View File
@@ -21,9 +21,9 @@ spec:
categories: categories:
- protocol - protocol
help_links: help_links:
zh: https://link.langbot.app/zh/platforms/satori zh: https://langbot.app/docs/zh/usage/platforms/readme
en: https://link.langbot.app/en/platforms/satori en: https://langbot.app/docs/en/usage/platforms/readme
ja: https://link.langbot.app/ja/platforms/satori ja: https://langbot.app/docs/ja/usage/platforms/readme
config: config:
- name: platform - name: platform
label: label:
+3 -3
View File
@@ -24,9 +24,9 @@ spec:
- popular - popular
- global - global
help_links: help_links:
zh: https://link.langbot.app/zh/platforms/slack zh: https://langbot.app/docs/zh/usage/platforms/slack
en: https://link.langbot.app/en/platforms/slack en: https://langbot.app/docs/en/usage/platforms/slack
ja: https://link.langbot.app/ja/platforms/slack ja: https://langbot.app/docs/ja/usage/platforms/slack
config: config:
- name: webhook_url - name: webhook_url
label: label:
@@ -24,9 +24,9 @@ spec:
- popular - popular
- global - global
help_links: help_links:
zh: https://link.langbot.app/zh/platforms/telegram zh: https://langbot.app/docs/zh/usage/platforms/telegram
en: https://link.langbot.app/en/platforms/telegram en: https://langbot.app/docs/en/usage/platforms/telegram
ja: https://link.langbot.app/ja/platforms/telegram ja: https://langbot.app/docs/ja/usage/platforms/telegram
config: config:
- name: token - name: token
label: label:
@@ -15,9 +15,9 @@ spec:
categories: categories:
- china - china
help_links: help_links:
zh: https://link.langbot.app/zh/platforms/wechatpad zh: https://langbot.app/docs/zh/usage/platforms/wechat/wechatpad
en: https://link.langbot.app/en/platforms/wechatpad en: https://langbot.app/docs/en/usage/platforms/readme
ja: https://link.langbot.app/ja/platforms/wechatpad ja: https://langbot.app/docs/ja/usage/platforms/readme
config: config:
- name: wechatpad_url - name: wechatpad_url
label: label:
+3 -3
View File
@@ -274,11 +274,11 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if content['type'] == 'text': if content['type'] == 'text':
await self.bot.send_private_msg(user_id, agent_id, content['content']) await self.bot.send_private_msg(user_id, agent_id, content['content'])
if content['type'] == 'image': if content['type'] == 'image':
await self.bot.send_image(user_id, agent_id, content['media']) await self.bot.send_image(user_id, agent_id, content['media_id'])
if content['type'] == 'voice': if content['type'] == 'voice':
await self.bot.send_voice(user_id, agent_id, content['media']) await self.bot.send_voice(user_id, agent_id, content['media_id'])
if content['type'] == 'file': if content['type'] == 'file':
await self.bot.send_file(user_id, agent_id, content['media']) await self.bot.send_file(user_id, agent_id, content['media_id'])
def register_listener( def register_listener(
self, self,
+3 -3
View File
@@ -16,9 +16,9 @@ spec:
- popular - popular
- china - china
help_links: help_links:
zh: https://link.langbot.app/zh/platforms/wecom zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecom
en: https://link.langbot.app/en/platforms/wecom en: https://langbot.app/docs/en/usage/platforms/wecom/wecom
ja: https://link.langbot.app/ja/platforms/wecom ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecom
config: config:
- name: webhook_url - name: webhook_url
label: label:
@@ -15,9 +15,9 @@ spec:
categories: categories:
- china - china
help_links: help_links:
zh: https://link.langbot.app/zh/platforms/wecombot zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecombot
en: https://link.langbot.app/en/platforms/wecombot en: https://langbot.app/docs/en/usage/platforms/wecom/wecombot
ja: https://link.langbot.app/ja/platforms/wecombot ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecombot
config: config:
- name: one-click-create - name: one-click-create
label: label:
@@ -15,9 +15,9 @@ spec:
categories: categories:
- china - china
help_links: help_links:
zh: https://link.langbot.app/zh/platforms/wecomcs zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecomcs
en: https://link.langbot.app/en/platforms/wecomcs en: https://langbot.app/docs/en/usage/platforms/wecom/wecomcs
ja: https://link.langbot.app/ja/platforms/wecomcs ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecomcs
config: config:
- name: webhook_url - name: webhook_url
label: label:
+13 -1
View File
@@ -39,6 +39,9 @@ class N8nServiceAPIRunner(runner.RequestRunner):
# 获取输出键名,默认为response # 获取输出键名,默认为response
self.output_key = self.pipeline_config['ai']['n8n-service-api'].get('output-key', 'response') self.output_key = self.pipeline_config['ai']['n8n-service-api'].get('output-key', 'response')
self.response_handling = self.pipeline_config['ai']['n8n-service-api'].get('response-handling', 'reply')
if self.response_handling not in {'reply', 'ignore'}:
raise ValueError(f'Invalid n8n response-handling: {self.response_handling}')
# 获取认证类型,默认为none # 获取认证类型,默认为none
self.auth_type = self.pipeline_config['ai']['n8n-service-api'].get('auth-type', 'none') self.auth_type = self.pipeline_config['ai']['n8n-service-api'].get('auth-type', 'none')
@@ -262,7 +265,11 @@ class N8nServiceAPIRunner(runner.RequestRunner):
async with session.post( async with session.post(
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
) as response: ) as response:
if response.status != 200: if self.response_handling == 'ignore':
status_ok = 200 <= response.status < 300
else:
status_ok = response.status == 200
if not status_ok:
error_text = ( error_text = (
await httpclient.read_limited( await httpclient.read_limited(
response, response,
@@ -272,6 +279,11 @@ class N8nServiceAPIRunner(runner.RequestRunner):
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}') self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}') raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
if self.response_handling == 'ignore':
response.release()
self.ap.logger.debug('n8n async webhook accepted; response body ignored')
return
async for chunk in self._process_response(response): async for chunk in self._process_response(response):
if is_stream: if is_stream:
yield chunk yield chunk
@@ -18,12 +18,11 @@ from ....box.workspace import (
BoxWorkspaceSession, BoxWorkspaceSession,
classify_python_workspace, classify_python_workspace,
infer_workspace_host_path, infer_workspace_host_path,
normalize_host_path,
rewrite_mounted_path, rewrite_mounted_path,
rewrite_venv_command, rewrite_venv_command,
unwrap_venv_path, unwrap_venv_path,
wrap_python_command_with_env,
) )
from ....utils.python_workspace import normalize_host_path, wrap_python_command_with_env
if TYPE_CHECKING: if TYPE_CHECKING:
from .mcp import RuntimeMCPSession from .mcp import RuntimeMCPSession
@@ -32,7 +32,7 @@ EDIT_TOOL_NAME = 'edit'
GLOB_TOOL_NAME = 'glob' GLOB_TOOL_NAME = 'glob'
GREP_TOOL_NAME = 'grep' GREP_TOOL_NAME = 'grep'
_ALL_TOOL_NAMES = {EXEC_TOOL_NAME, READ_TOOL_NAME, WRITE_TOOL_NAME, EDIT_TOOL_NAME, GLOB_TOOL_NAME, GREP_TOOL_NAME} SANDBOX_TOOL_NAMES = {EXEC_TOOL_NAME, READ_TOOL_NAME, WRITE_TOOL_NAME, EDIT_TOOL_NAME, GLOB_TOOL_NAME, GREP_TOOL_NAME}
# Skip these dirs during grep walk to avoid noise # Skip these dirs during grep walk to avoid noise
_SKIP_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', '.tox', 'dist', 'build'} _SKIP_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', '.tox', 'dist', 'build'}
@@ -260,7 +260,11 @@ class NativeToolLoader(loader.ToolLoader):
return list(self._tools) return list(self._tools)
async def has_tool(self, name: str) -> bool: async def has_tool(self, name: str) -> bool:
return name in _ALL_TOOL_NAMES and await self._is_sandbox_available() return name in SANDBOX_TOOL_NAMES and await self._is_sandbox_available()
@staticmethod
def recognizes_tool(name: str) -> bool:
return name in SANDBOX_TOOL_NAMES
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query): async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query):
require_sandbox = getattr( require_sandbox = getattr(
@@ -325,20 +329,11 @@ class NativeToolLoader(loader.ToolLoader):
if not package_root: if not package_root:
raise ValueError(f'Activated skill "{selected_skill_name}" has no package_root.') raise ValueError(f'Activated skill "{selected_skill_name}" has no package_root.')
# Pass only the logical name across the authenticated Core→Runtime
# boundary. In Cloud mode the shared Box Runtime resolves the
# Workspace-scoped package root and constructs the read-only mount;
# Core host paths are never accepted as mount authority.
# Wrap command with Python venv bootstrap if the skill has a Python project. # Wrap command with Python venv bootstrap if the skill has a Python project.
# The venv is created inside the skill's mount path. # The venv is created inside the skill's mount path.
skill_mount = f'/workspace/.skills/{selected_skill_name}' skill_mount = f'/workspace/.skills/{selected_skill_name}'
python_project = selected_skill.get('python_project') is True python_project = selected_skill.get('python_project') is True
if 'python_project' not in selected_skill and bool( if 'python_project' not in selected_skill:
getattr(self.ap.box_service, 'shares_filesystem_with_box', False)
):
# Backward compatibility for a same-process OSS Runtime that
# predates trusted Box metadata. Never probe a path reported by
# an external Runtime from the Core filesystem.
python_project = skill_loader.should_prepare_skill_python_env(package_root) python_project = skill_loader.should_prepare_skill_python_env(package_root)
if python_project: if python_project:
parameters = dict(parameters) parameters = dict(parameters)
@@ -354,12 +349,9 @@ class NativeToolLoader(loader.ToolLoader):
result = await self.ap.box_service.execute_tool( result = await self.ap.box_service.execute_tool(
parameters, parameters,
query, query,
skill_name=selected_skill_name, read_only_mounts=skill_loader.build_execution_mounts(self.ap, query),
) )
result = self._normalize_exec_result(result) result = self._normalize_exec_result(result)
if selected_skill is not None:
self._refresh_skill_from_disk(query, selected_skill)
return result return result
def _resolve_host_location( def _resolve_host_location(
@@ -381,10 +373,7 @@ class NativeToolLoader(loader.ToolLoader):
box_service = self.ap.box_service box_service = self.ap.box_service
if selected_skill is not None: if selected_skill is not None:
if not self._can_interpret_skill_host_paths(): if not self._can_interpret_skill_host_paths():
raise ValueError( raise ValueError('Secure Core host file operations are unavailable on this platform.')
'Skill package paths are owned by the Box Runtime; '
'this operation requires a Runtime skill-file API.'
)
host_root = selected_skill.get('package_root') host_root = selected_skill.get('package_root')
workspace_anchor = None workspace_anchor = None
else: else:
@@ -422,11 +411,9 @@ class NativeToolLoader(loader.ToolLoader):
return selected_skill, relative return selected_skill, relative
def _can_interpret_skill_host_paths(self) -> bool: def _can_interpret_skill_host_paths(self) -> bool:
"""Require an explicitly proven shared Core/Runtime filesystem view.""" """Return whether Core can use its no-follow host file primitives."""
return _SECURE_HOST_FILE_OPS_AVAILABLE and bool( return _SECURE_HOST_FILE_OPS_AVAILABLE
getattr(self.ap.box_service, 'shares_filesystem_with_box', False)
)
def _should_use_box_workspace_files(self, selected_skill: dict | None) -> bool: def _should_use_box_workspace_files(self, selected_skill: dict | None) -> bool:
if selected_skill is not None: if selected_skill is not None:
@@ -1123,20 +1110,11 @@ else:
include_visible=True, include_visible=True,
include_activated=True, include_activated=True,
) )
if skill_request is not None and hasattr(self.ap.box_service, 'read_skill_file'): skill_repository = getattr(self.ap, 'skill_repository', None)
if skill_request is not None and skill_repository is not None:
selected_skill, relative = skill_request selected_skill, relative = skill_request
if self._can_interpret_skill_host_paths():
host_location = self._resolve_skill_host_location(selected_skill, relative)
else:
host_location = None
if host_location is not None:
try:
return await asyncio.to_thread(self._read_host_location, host_location, parameters)
except FileNotFoundError:
pass
try: try:
result = await self.ap.box_service.read_skill_file( result = await skill_repository.read_skill_file(
self._execution_context(query), self._execution_context(query),
selected_skill['name'], selected_skill['name'],
relative, relative,
@@ -1144,7 +1122,7 @@ else:
return self._build_read_result_from_text(str(result.get('content', '')), parameters) return self._build_read_result_from_text(str(result.get('content', '')), parameters)
except Exception: except Exception:
try: try:
result = await self.ap.box_service.list_skill_files( result = await skill_repository.list_skill_files(
self._execution_context(query), self._execution_context(query),
selected_skill['name'], selected_skill['name'],
relative, relative,
@@ -1178,12 +1156,13 @@ else:
include_visible=False, include_visible=False,
include_activated=True, include_activated=True,
) )
if skill_request is not None and hasattr(self.ap.box_service, 'write_skill_file'): skill_repository = getattr(self.ap, 'skill_repository', None)
if skill_request is not None and skill_repository is not None:
if encoding != 'text': if encoding != 'text':
return {'ok': False, 'error': 'base64 writes to skill packages are not supported.'} return {'ok': False, 'error': 'base64 writes to skill packages are not supported.'}
selected_skill, relative = skill_request selected_skill, relative = skill_request
execution_context = self._execution_context(query) execution_context = self._execution_context(query)
await self.ap.box_service.write_skill_file(execution_context, selected_skill['name'], relative, content) await skill_repository.write_skill_file(execution_context, selected_skill['name'], relative, content)
await self.ap.skill_mgr.reload_skills(execution_context) await self.ap.skill_mgr.reload_skills(execution_context)
return {'ok': True, 'path': path} return {'ok': True, 'path': path}
@@ -1216,14 +1195,10 @@ else:
include_visible=False, include_visible=False,
include_activated=True, include_activated=True,
) )
if ( if skill_request is not None and getattr(self.ap, 'skill_repository', None) is not None:
skill_request is not None
and hasattr(self.ap.box_service, 'read_skill_file')
and hasattr(self.ap.box_service, 'write_skill_file')
):
selected_skill, relative = skill_request selected_skill, relative = skill_request
try: try:
result = await self.ap.box_service.read_skill_file( result = await self.ap.skill_repository.read_skill_file(
self._execution_context(query), self._execution_context(query),
selected_skill['name'], selected_skill['name'],
relative, relative,
@@ -1238,7 +1213,7 @@ else:
return {'ok': False, 'error': f'old_string matches {count} locations; provide a more unique string.'} return {'ok': False, 'error': f'old_string matches {count} locations; provide a more unique string.'}
new_content = content.replace(old_string, new_string, 1) new_content = content.replace(old_string, new_string, 1)
execution_context = self._execution_context(query) execution_context = self._execution_context(query)
await self.ap.box_service.write_skill_file( await self.ap.skill_repository.write_skill_file(
execution_context, execution_context,
selected_skill['name'], selected_skill['name'],
relative, relative,
@@ -1,10 +1,14 @@
from __future__ import annotations from __future__ import annotations
import os
import re import re
import typing import typing
from ....box import workspace as box_workspace
from ....api.http.context import ExecutionContext from ....api.http.context import ExecutionContext
from ....utils.python_workspace import (
should_prepare_python_env,
wrap_python_command_with_env,
)
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from ....core import app from ....core import app
@@ -57,6 +61,30 @@ def get_visible_skill(ap: app.Application, query: pipeline_query.Query, skill_na
return get_visible_skills(ap, query).get(skill_name) return get_visible_skills(ap, query).get(skill_name)
def build_execution_mounts(ap: app.Application, query: pipeline_query.Query) -> list[dict]:
"""Translate visible Core-owned packages into generic read-only mounts."""
mounts: list[dict] = []
for skill_name, skill_data in get_visible_skills(ap, query).items():
package_root = str(skill_data.get('package_root', '') or '').strip()
if not package_root:
continue
if not os.path.isdir(package_root):
ap.logger.warning(
f'Skill "{skill_name}" package_root missing on the Core filesystem '
f'({package_root}); skipping its execution mount. Reload the skill catalog.'
)
continue
mounts.append(
{
'host_path': package_root,
'mount_path': get_virtual_skill_mount_path(skill_name),
'mode': 'ro',
}
)
return mounts
def get_activated_skills(query: pipeline_query.Query) -> dict[str, dict]: def get_activated_skills(query: pipeline_query.Query) -> dict[str, dict]:
if query.variables is None: if query.variables is None:
return {} return {}
@@ -198,7 +226,7 @@ def build_skill_session_id(skill_data: dict, query: pipeline_query.Query) -> str
def should_prepare_skill_python_env(package_root: str | None) -> bool: def should_prepare_skill_python_env(package_root: str | None) -> bool:
return box_workspace.should_prepare_python_env(package_root) return should_prepare_python_env(package_root)
def wrap_skill_command_with_python_env( def wrap_skill_command_with_python_env(
@@ -207,7 +235,7 @@ def wrap_skill_command_with_python_env(
mount_path: str = '/workspace', mount_path: str = '/workspace',
state_path: str | None = None, state_path: str | None = None,
) -> str: ) -> str:
return box_workspace.wrap_python_command_with_env( return wrap_python_command_with_env(
command, command,
mount_path=mount_path, mount_path=mount_path,
state_path=state_path, state_path=state_path,
@@ -6,7 +6,6 @@ import typing
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from .. import loader from .. import loader
from .availability import is_box_backend_available
from ....api.http.context import ExecutionContext from ....api.http.context import ExecutionContext
# Align with Claude Code's Skill tool design: # Align with Claude Code's Skill tool design:
@@ -15,12 +14,23 @@ from ....api.http.context import ExecutionContext
# - This protects KV Cache and follows industry standard # - This protects KV Cache and follows industry standard
ACTIVATE_SKILL_TOOL_NAME = 'activate' ACTIVATE_SKILL_TOOL_NAME = 'activate'
LIST_SKILL_RESOURCES_TOOL_NAME = 'list_skill_resources'
READ_SKILL_RESOURCE_TOOL_NAME = 'read_skill_resource'
REGISTER_SKILL_TOOL_NAME = 'register_skill' REGISTER_SKILL_TOOL_NAME = 'register_skill'
SKILL_TOOL_NAMES = { READ_ONLY_SKILL_TOOL_NAMES = {
ACTIVATE_SKILL_TOOL_NAME, ACTIVATE_SKILL_TOOL_NAME,
LIST_SKILL_RESOURCES_TOOL_NAME,
READ_SKILL_RESOURCE_TOOL_NAME,
}
SANDBOX_SKILL_TOOL_NAMES = {
REGISTER_SKILL_TOOL_NAME, REGISTER_SKILL_TOOL_NAME,
} }
SKILL_TOOL_NAMES = READ_ONLY_SKILL_TOOL_NAMES | SANDBOX_SKILL_TOOL_NAMES
_SKILL_EXECUTION_AVAILABLE_KEY = '_skill_execution_available'
_SKILL_RESOURCE_BYTES_READ_KEY = '_skill_resource_bytes_read'
_MAX_SKILL_RESOURCE_FILE_BYTES = 256 * 1024
_MAX_SKILL_RESOURCE_RUN_BYTES = 1024 * 1024
class SkillToolLoader(loader.ToolLoader): class SkillToolLoader(loader.ToolLoader):
@@ -28,62 +38,73 @@ class SkillToolLoader(loader.ToolLoader):
def __init__(self, ap): def __init__(self, ap):
super().__init__(ap) super().__init__(ap)
self._tools: list[resource_tool.LLMTool] = [] self._read_only_tools: list[resource_tool.LLMTool] = []
self._sandbox_available: bool = False self._sandbox_tools: list[resource_tool.LLMTool] = []
async def initialize(self): async def initialize(self):
# Check if sandbox backend is available (same check as native tools) if self._is_available():
self._sandbox_available = await self._check_sandbox_available() self._read_only_tools = [
if self._sandbox_available:
self._tools = [
self._build_activate_skill_tool(), self._build_activate_skill_tool(),
self._build_register_skill_tool(), self._build_list_skill_resources_tool(),
self._build_read_skill_resource_tool(),
] ]
self._sandbox_tools = [self._build_register_skill_tool()]
else: else:
self.ap.logger.info( self.ap.logger.info('Skill tools are unavailable because the Core SkillRepository is not initialized.')
'Skill tools (activate/register_skill) are NOT available. '
'No sandbox backend (Docker/nsjail/E2B) is ready. '
'Trusted local development may explicitly select box.backend=host.'
)
async def _check_sandbox_available(self) -> bool: async def get_tools(
"""Check if the box backend is truly available (not just the runtime).""" self,
return await is_box_backend_available(self.ap) bound_plugins: list[str] | None = None,
*,
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]: sandbox_available: bool | None = None,
if not await self._is_available(): ) -> list[resource_tool.LLMTool]:
if not self._is_available():
return [] return []
if not self._tools: if not self._read_only_tools:
self._tools = [ await self.initialize()
self._build_activate_skill_tool(), tools = list(self._read_only_tools)
self._build_register_skill_tool(), if sandbox_available:
] tools.extend(self._sandbox_tools)
return list(self._tools) return tools
async def has_tool(self, name: str) -> bool: async def get_tool(self, name: str, *, sandbox_available: bool | None = None):
return await self._is_available() and name in SKILL_TOOL_NAMES for tool in await self.get_tools(sandbox_available=sandbox_available):
if tool.name == name:
return tool
return None
async def _is_available(self) -> bool: async def has_tool(self, name: str, *, sandbox_available: bool | None = None) -> bool:
"""Check if skill tools should be available. if not self._is_available() or name not in SKILL_TOOL_NAMES:
Skill tools require both a skill manager and a sandbox backend.
"""
if not self._has_skill_manager():
return False return False
self._sandbox_available = await self._check_sandbox_available() return name in READ_ONLY_SKILL_TOOL_NAMES or bool(sandbox_available)
return self._sandbox_available
@staticmethod
def is_sandbox_tool(name: str) -> bool:
return name in SANDBOX_SKILL_TOOL_NAMES
@staticmethod
def recognizes_tool(name: str) -> bool:
return name in SKILL_TOOL_NAMES
def _is_available(self) -> bool:
return self._has_skill_manager() and getattr(self.ap, 'skill_repository', None) is not None
async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any: async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any:
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: if name == ACTIVATE_SKILL_TOOL_NAME:
return await self._invoke_activate_skill(parameters, query) 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: 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) return await self._invoke_register_skill(parameters, query)
raise ValueError(f'Unknown skill tool: {name}') raise ValueError(f'Unknown skill tool: {name}')
@@ -108,6 +129,27 @@ class SkillToolLoader(loader.ToolLoader):
def _has_skill_manager(self) -> bool: def _has_skill_manager(self) -> bool:
return getattr(self.ap, 'skill_mgr', None) is not None return getattr(self.ap, 'skill_mgr', None) is not None
@staticmethod
def _sandbox_unavailable_result(name: str) -> dict:
return {
'ok': False,
'code': 'sandbox_unavailable',
'tool': name,
'message': 'This operation requires Box execution, but Box is not configured or available.',
}
async def _execution_available(self, query) -> bool:
variables = getattr(query, 'variables', None)
if isinstance(variables, dict) and _SKILL_EXECUTION_AVAILABLE_KEY in variables:
return bool(variables[_SKILL_EXECUTION_AVAILABLE_KEY])
checker = getattr(getattr(self.ap, 'box_service', None), 'is_workspace_sandbox_available', None)
if not callable(checker):
return False
try:
return bool(await checker(self._execution_context(query)))
except Exception:
return False
async def _invoke_activate_skill(self, parameters: dict, query) -> typing.Any: async def _invoke_activate_skill(self, parameters: dict, query) -> typing.Any:
"""Activate a skill and return SKILL.md content via Tool Result.""" """Activate a skill and return SKILL.md content via Tool Result."""
skill_name = str(parameters.get('skill_name', '') or '').strip() skill_name = str(parameters.get('skill_name', '') or '').strip()
@@ -116,42 +158,111 @@ class SkillToolLoader(loader.ToolLoader):
from . import skill as skill_loader from . import skill as skill_loader
skill_data = skill_loader.get_visible_skill(self.ap, query, skill_name) visible_skill = skill_loader.get_visible_skill(self.ap, query, skill_name)
if skill_data is None: if visible_skill is None:
visible_skills = skill_loader.get_visible_skills(self.ap, query) visible_skills = skill_loader.get_visible_skills(self.ap, query)
available_names = ', '.join(sorted(visible_skills.keys())) or 'none' available_names = ', '.join(sorted(visible_skills.keys())) or 'none'
raise ValueError(f'Skill "{skill_name}" not found. Available skills: {available_names}') raise ValueError(f'Skill "{skill_name}" not found. Available skills: {available_names}')
# Register activated skill for sandbox mount path resolution skill_data = await self.ap.skill_repository.get_skill(
self._execution_context(query),
skill_name,
snapshot=True,
)
if skill_data is None:
raise ValueError(f'Skill "{skill_name}" is no longer available; reload the skill catalog.')
skill_loader.register_activated_skill(query, skill_data) skill_loader.register_activated_skill(query, skill_data)
# Return SKILL.md content as Tool Result (injects into context)
instructions = skill_data.get('instructions', '') instructions = skill_data.get('instructions', '')
package_root = skill_data.get('package_root', '') revision = str(skill_data.get('revision', '') or '')
mount_path = skill_loader.get_virtual_skill_mount_path(skill_name) execution_available = await self._execution_available(query)
mount_path = skill_loader.get_virtual_skill_mount_path(skill_name) if execution_available else None
# Build Tool Result content
result_content = f'<command-message>The "{skill_name}" skill is activated</command-message>\n' result_content = f'<command-message>The "{skill_name}" skill is activated</command-message>\n'
result_content += '<skill-activation>\n' result_content += '<skill-activation>\n'
result_content += f'<skill-name>{skill_name}</skill-name>\n' result_content += f'<skill-name>{skill_name}</skill-name>\n'
result_content += f'<mount-path>{mount_path}</mount-path>\n' result_content += f'<revision>{revision}</revision>\n'
result_content += f'<package-root>{package_root}</package-root>\n' result_content += '<resources-readable>true</resources-readable>\n'
result_content += f'<execution-available>{str(execution_available).lower()}</execution-available>\n'
result_content += f'\n## Instructions\n{instructions}\n' result_content += f'\n## Instructions\n{instructions}\n'
result_content += '\n## Runtime Context\n' result_content += '\n## Runtime Context\n'
result_content += f'The skill package is mounted at {mount_path}. Use the standard tools to interact with it:\n' result_content += '- Use `list_skill_resources` and `read_skill_resource` for read-only package resources.\n'
result_content += f'- Use `read` to inspect files under {mount_path}\n' if execution_available:
result_content += f'- Use `exec` with workdir set to {mount_path} to run commands in that package\n' result_content += (
result_content += '- Use `write` and `edit` on that path when the instructions require updating files\n' f'- Box execution is available; executable package files will be mounted at {mount_path}.\n'
)
else:
result_content += (
'- Box execution is unavailable. Do not attempt to run scripts or modify Workspace files.\n'
)
result_content += '</skill-activation>\n' result_content += '</skill-activation>\n'
return { return {
'activated': True, 'activated': True,
'skill_name': skill_name, 'skill_name': skill_name,
'mount_path': mount_path, 'mount_path': mount_path,
'revision': revision,
'capabilities': {
'instructions_readable': True,
'resources_readable': True,
'execution_available': execution_available,
},
'activated_skill_names': skill_loader.get_activated_skill_names(query), 'activated_skill_names': skill_loader.get_activated_skill_names(query),
'content': result_content, 'content': result_content,
} }
@staticmethod
def _activated_skill(parameters: dict, query) -> dict:
from . import skill as skill_loader
skill_name = str(parameters.get('skill_name', '') or '').strip()
if not skill_name:
raise ValueError('skill_name is required')
skill_data = skill_loader.get_activated_skill(query, skill_name)
if skill_data is None:
raise ValueError(f'Skill "{skill_name}" must be activated before its resources can be read.')
requested_revision = str(parameters.get('revision', '') or '').strip()
activated_revision = str(skill_data.get('revision', '') or '').strip()
if requested_revision and requested_revision != activated_revision:
raise ValueError('revision must match the activated skill revision')
return skill_data
async def _invoke_list_skill_resources(self, parameters: dict, query) -> dict:
skill_data = self._activated_skill(parameters, query)
return await self.ap.skill_repository.list_skill_resources(
self._execution_context(query),
skill_data['name'],
str(parameters.get('path', '.') or '.'),
expected_revision=skill_data.get('revision'),
)
async def _invoke_read_skill_resource(self, parameters: dict, query) -> dict:
skill_data = self._activated_skill(parameters, query)
path = str(parameters.get('path', '') or '').strip()
if not path:
raise ValueError('path is required')
result = await self.ap.skill_repository.read_skill_resource(
self._execution_context(query),
skill_data['name'],
path,
expected_revision=skill_data.get('revision'),
)
content = str(result.get('content', ''))
size = len(content.encode('utf-8'))
if size > _MAX_SKILL_RESOURCE_FILE_BYTES:
raise ValueError('Skill resource exceeds the per-file read limit')
variables = getattr(query, 'variables', None)
if not isinstance(variables, dict):
variables = {}
query.variables = variables
total = int(variables.get(_SKILL_RESOURCE_BYTES_READ_KEY, 0) or 0) + size
if total > _MAX_SKILL_RESOURCE_RUN_BYTES:
raise ValueError('Skill resource reads exceed the per-run limit')
variables[_SKILL_RESOURCE_BYTES_READ_KEY] = total
result['size'] = size
return result
async def _invoke_register_skill(self, parameters: dict, query) -> typing.Any: async def _invoke_register_skill(self, parameters: dict, query) -> typing.Any:
"""Register a skill from sandbox directory to data/skills/.""" """Register a skill from sandbox directory to data/skills/."""
sandbox_path = str(parameters.get('path', '') or '').strip() sandbox_path = str(parameters.get('path', '') or '').strip()
@@ -176,14 +287,14 @@ class SkillToolLoader(loader.ToolLoader):
raise ValueError('skill name is required') raise ValueError('skill name is required')
# Create the skill # Create the skill
created = await skill_service.create_skill( created = await skill_service.import_skill_directory(
execution_context, execution_context,
host_path,
{ {
'name': skill_name, 'name': skill_name,
'display_name': str(parameters.get('display_name') or scanned.get('display_name', '')).strip(), 'display_name': str(parameters.get('display_name') or scanned.get('display_name', '')).strip(),
'description': str(parameters.get('description') or scanned.get('description', '')).strip(), 'description': str(parameters.get('description') or scanned.get('description', '')).strip(),
'instructions': str(parameters.get('instructions') or scanned.get('instructions', '')), 'instructions': str(parameters.get('instructions') or scanned.get('instructions', '')),
'package_root': host_path,
}, },
) )
@@ -249,6 +360,42 @@ class SkillToolLoader(loader.ToolLoader):
func=lambda parameters: parameters, func=lambda parameters: parameters,
) )
def _build_list_skill_resources_tool(self) -> resource_tool.LLMTool:
return resource_tool.LLMTool(
name=LIST_SKILL_RESOURCES_TOOL_NAME,
human_desc='List activated skill resources',
description='List read-only files in an activated skill package without starting a sandbox.',
parameters={
'type': 'object',
'properties': {
'skill_name': {'type': 'string', 'description': 'The activated skill name.'},
'path': {'type': 'string', 'description': 'Relative directory path. Defaults to the package root.'},
'revision': {'type': 'string', 'description': 'Optional revision returned by activate.'},
},
'required': ['skill_name'],
'additionalProperties': False,
},
func=lambda parameters: parameters,
)
def _build_read_skill_resource_tool(self) -> resource_tool.LLMTool:
return resource_tool.LLMTool(
name=READ_SKILL_RESOURCE_TOOL_NAME,
human_desc='Read an activated skill resource',
description='Read a UTF-8 text resource from an activated skill package without starting a sandbox.',
parameters={
'type': 'object',
'properties': {
'skill_name': {'type': 'string', 'description': 'The activated skill name.'},
'path': {'type': 'string', 'description': 'File path relative to the skill package root.'},
'revision': {'type': 'string', 'description': 'Optional revision returned by activate.'},
},
'required': ['skill_name', 'path'],
'additionalProperties': False,
},
func=lambda parameters: parameters,
)
def _build_register_skill_tool(self) -> resource_tool.LLMTool: def _build_register_skill_tool(self) -> resource_tool.LLMTool:
return resource_tool.LLMTool( return resource_tool.LLMTool(
name=REGISTER_SKILL_TOOL_NAME, name=REGISTER_SKILL_TOOL_NAME,
+31 -9
View File
@@ -66,6 +66,15 @@ class ToolManager:
except Exception: except Exception:
return False return False
@staticmethod
def _sandbox_unavailable_result(name: str) -> dict[str, typing.Any]:
return {
'ok': False,
'code': 'sandbox_unavailable',
'tool': name,
'message': 'This operation requires Box execution, but Box is not configured or available.',
}
async def initialize(self): async def initialize(self):
from langbot.pkg.utils import importutil from langbot.pkg.utils import importutil
from langbot.pkg.provider.tools import loaders from langbot.pkg.provider.tools import loaders
@@ -102,8 +111,8 @@ class ToolManager:
sandbox_available = await self._workspace_sandbox_available(context) sandbox_available = await self._workspace_sandbox_available(context)
if sandbox_available: if sandbox_available:
all_functions.extend(await self.native_tool_loader.get_tools()) all_functions.extend(await self.native_tool_loader.get_tools())
if include_skill_authoring and sandbox_available: if include_skill_authoring:
all_functions.extend(await self.skill_tool_loader.get_tools()) all_functions.extend(await self.skill_tool_loader.get_tools(sandbox_available=sandbox_available))
all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins)) all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))
all_functions.extend( all_functions.extend(
await self.mcp_tool_loader.get_tools( await self.mcp_tool_loader.get_tools(
@@ -142,8 +151,12 @@ class ToolManager:
sandbox_available = await self._workspace_sandbox_available(context) sandbox_available = await self._workspace_sandbox_available(context)
if sandbox_available: if sandbox_available:
append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools()) append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools())
if include_skill_authoring and sandbox_available: if include_skill_authoring:
append_tools('skill', 'LangBot', await self.skill_tool_loader.get_tools()) append_tools(
'skill',
'LangBot',
await self.skill_tool_loader.get_tools(sandbox_available=sandbox_available),
)
catalog.extend(await self.plugin_tool_loader.get_tool_catalog(bound_plugins)) catalog.extend(await self.plugin_tool_loader.get_tool_catalog(bound_plugins))
if self.mcp_tool_loader: if self.mcp_tool_loader:
@@ -168,10 +181,9 @@ class ToolManager:
tool = await active_loader.get_tool(name) tool = await active_loader.get_tool(name)
if tool: if tool:
return tool return tool
if sandbox_available: tool = await self.skill_tool_loader.get_tool(name, sandbox_available=sandbox_available)
tool = await self.skill_tool_loader.get_tool(name) if tool:
if tool: return tool
return tool
return await self.mcp_tool_loader.get_tool(context, name) return await self.mcp_tool_loader.get_tool(context, name)
@@ -310,7 +322,10 @@ class ToolManager:
query=query, query=query,
invoke=lambda: self.mcp_tool_loader.invoke_tool(name, parameters, query), invoke=lambda: self.mcp_tool_loader.invoke_tool(name, parameters, query),
) )
if sandbox_available and await self.skill_tool_loader.has_tool(name): if await self.skill_tool_loader.has_tool(name, sandbox_available=sandbox_available):
variables = getattr(query, 'variables', None)
if isinstance(variables, dict):
variables['_skill_execution_available'] = sandbox_available
telemetry_features.increment(query, 'tool_calls', 'skill') telemetry_features.increment(query, 'tool_calls', 'skill')
return await self._invoke_tool_with_monitoring( return await self._invoke_tool_with_monitoring(
source='skill', source='skill',
@@ -319,6 +334,13 @@ class ToolManager:
query=query, query=query,
invoke=lambda: self.skill_tool_loader.invoke_tool(name, parameters, query), invoke=lambda: self.skill_tool_loader.invoke_tool(name, parameters, query),
) )
recognizes_native = getattr(self.native_tool_loader, 'recognizes_tool', None)
is_sandbox_skill_tool = getattr(self.skill_tool_loader, 'is_sandbox_tool', None)
if not sandbox_available and (
(callable(recognizes_native) and recognizes_native(name) is True)
or (callable(is_sandbox_skill_tool) and is_sandbox_skill_tool(name) is True)
):
return self._sandbox_unavailable_result(name)
raise ToolNotFoundError(name) raise ToolNotFoundError(name)
async def shutdown(self): async def shutdown(self):
+3 -1
View File
@@ -1,3 +1,5 @@
from .manager import SkillManager from .manager import SkillManager
from .repository import SkillRepository
__all__ = ['SkillManager']
__all__ = ['SkillManager', 'SkillRepository']
+6 -21
View File
@@ -1,14 +1,12 @@
from __future__ import annotations from __future__ import annotations
import os
from ..api.http.context import ExecutionContext from ..api.http.context import ExecutionContext
from ..api.http.service.tenant import TenantContext, require_workspace_uuid from ..api.http.service.tenant import TenantContext, require_workspace_uuid
from ..core import app from ..core import app
class SkillManager: class SkillManager:
"""Workspace-scoped in-memory view of Box-managed skill packages.""" """Workspace-scoped in-memory view of Core-managed skill packages."""
ap: app.Application ap: app.Application
@@ -63,33 +61,20 @@ class SkillManager:
self._skills_by_scope.pop(existing_key, None) self._skills_by_scope.pop(existing_key, None)
self._skills_by_scope[key] = {} self._skills_by_scope[key] = {}
box_service = getattr(self.ap, 'box_service', None) repository = getattr(self.ap, 'skill_repository', None)
if box_service is None or not getattr(box_service, 'available', False): if repository is None:
self.ap.logger.info( self.ap.logger.info('Skill repository unavailable; skill cache will remain empty.')
f'Box runtime unavailable; skill cache is empty for Workspace {execution_context.workspace_uuid}.'
)
return return
validate_locally = bool(getattr(box_service, 'shares_filesystem_with_box', False))
try: try:
dropped = 0
skills: dict[str, dict] = {} skills: dict[str, dict] = {}
for skill_data in await box_service.list_skills(execution_context): for skill_data in await repository.list_skills(execution_context):
skill_name = skill_data.get('name') skill_name = skill_data.get('name')
if not skill_name: if not skill_name:
continue continue
package_root = str(skill_data.get('package_root', '') or '').strip()
if validate_locally and package_root and not os.path.isdir(package_root):
self.ap.logger.warning(
f'Skill "{skill_name}" reported by Box runtime but package_root '
f'missing on LangBot filesystem ({package_root}); dropping from cache.'
)
dropped += 1
continue
skills[skill_name] = skill_data skills[skill_name] = skill_data
self._skills_by_scope[key] = skills self._skills_by_scope[key] = skills
suffix = f' ({dropped} dropped due to missing package_root)' if dropped else '' self.ap.logger.info(f'Loaded {len(skills)} skills for Workspace {execution_context.workspace_uuid}')
self.ap.logger.info(f'Loaded {len(skills)} skills for Workspace {execution_context.workspace_uuid}{suffix}')
except Exception as exc: except Exception as exc:
self.ap.logger.warning(f'Failed to load skills for Workspace {execution_context.workspace_uuid}: {exc}') self.ap.logger.warning(f'Failed to load skills for Workspace {execution_context.workspace_uuid}: {exc}')
+207
View File
@@ -0,0 +1,207 @@
from __future__ import annotations
import asyncio
import os
import weakref
from langbot_plugin.skill_store import (
SkillRevisionMismatchError,
SkillStore,
skill_namespace,
)
from ..api.http.context import ExecutionContext
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
from ..utils.bounded_executor import blocking_work_scope, run_blocking_atomic
class SkillRepository:
"""Async, Workspace-scoped adapter around the SDK SkillStore."""
def __init__(self, ap) -> None:
self.ap = ap
config = getattr(getattr(ap, 'instance_config', None), 'data', {}) or {}
self._local_config = (config.get('box') or {}).get('local') or {}
self._skills_config = config.get('skills') or {}
self._store = SkillStore(self._skills_root())
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
def _workspace_lock(self, namespace: str) -> asyncio.Lock:
lock = self._locks.get(namespace)
if lock is None:
lock = asyncio.Lock()
self._locks[namespace] = lock
return lock
def _host_root(self) -> str:
configured = str(self._local_config.get('host_root') or './data/box').strip()
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
def _skills_root(self) -> str:
configured = str(self._skills_config.get('root') or '').strip()
if not configured:
# Online-upgrade bridge for installations whose persisted config
# predates the standalone Skill domain.
# TODO(next-major): remove box.local.skills_root fallback.
legacy = str(self._local_config.get('skills_root') or '').strip()
configured = legacy or 'skills'
if not os.path.isabs(configured):
configured = os.path.join(self._host_root(), configured)
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
def _default_workspace(self) -> str:
configured = str(self._local_config.get('default_workspace') or '').strip()
if not configured:
configured = os.path.join(self._host_root(), 'default')
elif not os.path.isabs(configured):
configured = os.path.join(self._host_root(), configured)
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
@staticmethod
def _execution_context(context: TenantContext) -> ExecutionContext:
workspace_uuid = require_workspace_uuid(context)
instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
generation = getattr(context, 'placement_generation', None)
if not instance_uuid:
raise ValueError('Skill operations require an explicit instance UUID')
if isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
raise ValueError('Skill operations require a positive placement generation')
return ExecutionContext(
instance_uuid=instance_uuid,
workspace_uuid=workspace_uuid,
placement_generation=generation,
bot_uuid=getattr(context, 'bot_uuid', None),
pipeline_uuid=getattr(context, 'pipeline_uuid', None),
query_uuid=getattr(context, 'query_uuid', None),
entitlement_revision=getattr(context, 'entitlement_revision', 0),
)
@classmethod
def _namespace(cls, context: TenantContext) -> str:
execution_context = cls._execution_context(context)
return skill_namespace(
execution_context.instance_uuid,
execution_context.workspace_uuid,
)
async def _validated_execution_context(self, context: TenantContext) -> ExecutionContext:
execution_context = self._execution_context(context)
binding = await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
if (
binding.instance_uuid != execution_context.instance_uuid
or str(getattr(binding, 'workspace_uuid', '') or '') != execution_context.workspace_uuid
or getattr(binding, 'placement_generation', None) != execution_context.placement_generation
):
raise ValueError('Skill execution context belongs to a stale Workspace placement')
return execution_context
def _workspace_root(self, namespace: str) -> str:
return os.path.join(self._default_workspace(), 'tenants', namespace)
async def _call(self, context: TenantContext, method_name: str, *args, **kwargs):
execution_context = await self._validated_execution_context(context)
namespace = self._namespace(execution_context)
def invoke():
method = getattr(self._store.scoped(namespace), method_name)
return method(*args, **kwargs)
async with self._workspace_lock(namespace):
with blocking_work_scope(f'skill:{namespace}'):
return await run_blocking_atomic(invoke)
async def list_skills(self, context: TenantContext) -> list[dict]:
return await self._call(context, 'list_skills')
async def get_skill(self, context: TenantContext, name: str, *, snapshot: bool = False) -> dict | None:
return await self._call(context, 'get_skill_snapshot' if snapshot else 'get_skill', name)
async def create_skill(self, context: TenantContext, skill: dict) -> dict:
return await self._call(context, 'create_skill', skill)
async def import_skill_directory(self, context: TenantContext, path: str, skill: dict) -> dict:
namespace = self._namespace(context)
return await self._call(
context,
'import_skill_directory',
path,
skill,
source_root=self._workspace_root(namespace),
)
async def update_skill(self, context: TenantContext, name: str, skill: dict) -> dict:
return await self._call(context, 'update_skill', name, skill)
async def delete_skill(self, context: TenantContext, name: str) -> None:
await self._call(context, 'delete_skill', name)
async def scan_skill_directory(self, context: TenantContext, path: str) -> dict:
namespace = self._namespace(context)
return await self._call(
context,
'scan_import_directory',
path,
source_root=self._workspace_root(namespace),
)
async def list_skill_files(
self,
context: TenantContext,
name: str,
path: str = '.',
include_hidden: bool = False,
max_entries: int = 200,
) -> dict:
return await self._call(context, 'list_skill_files', name, path, include_hidden, max_entries)
async def read_skill_file(self, context: TenantContext, name: str, path: str) -> dict:
return await self._call(context, 'read_skill_file', name, path)
async def list_skill_resources(
self,
context: TenantContext,
name: str,
path: str = '.',
*,
expected_revision: str | None = None,
) -> dict:
return await self._call(
context,
'list_skill_resources',
name,
path,
False,
200,
expected_revision=expected_revision,
)
async def read_skill_resource(
self,
context: TenantContext,
name: str,
path: str,
*,
expected_revision: str | None = None,
) -> dict:
return await self._call(
context,
'read_skill_resource',
name,
path,
expected_revision=expected_revision,
)
async def write_skill_file(self, context: TenantContext, name: str, path: str, content: str) -> dict:
return await self._call(context, 'write_skill_file', name, path, content)
async def preview_skill_zip(self, context: TenantContext, file_bytes: bytes, filename: str, **kwargs) -> list[dict]:
return await self._call(context, 'preview_zip_upload', file_bytes=file_bytes, filename=filename, **kwargs)
async def install_skill_zip(self, context: TenantContext, file_bytes: bytes, filename: str, **kwargs) -> list[dict]:
return await self._call(context, 'install_zip_upload', file_bytes=file_bytes, filename=filename, **kwargs)
__all__ = ['SkillRepository', 'SkillRevisionMismatchError']
+1 -1
View File
@@ -198,7 +198,7 @@ async def build_heartbeat_payload(
except Exception: except Exception:
features['plugin_count'] = -1 features['plugin_count'] = -1
# Skill count (from Box runtime via skill manager) # Skill count (from the Core SkillRepository cache)
try: try:
skill_mgr = getattr(ap, 'skill_mgr', None) skill_mgr = getattr(ap, 'skill_mgr', None)
if skill_mgr is not None: if skill_mgr is not None:
+191
View File
@@ -0,0 +1,191 @@
"""Python project detection and sandbox-local environment bootstrap helpers."""
from __future__ import annotations
import os
import textwrap
PYTHON_MANIFEST_FILES = (
'requirements.txt',
'pyproject.toml',
'setup.py',
'setup.cfg',
)
def normalize_host_path(path: str | None) -> str:
if path is None:
return ''
stripped = str(path).strip()
if not stripped:
return ''
return os.path.realpath(os.path.abspath(stripped))
def list_python_manifest_files(host_path: str | None) -> list[str]:
normalized_root = normalize_host_path(host_path)
if not normalized_root:
return []
return [filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename))]
def should_prepare_python_env(host_path: str | None) -> bool:
normalized_root = normalize_host_path(host_path)
if not normalized_root:
return False
if os.path.isdir(os.path.join(normalized_root, '.venv')):
return True
return bool(list_python_manifest_files(normalized_root))
def wrap_python_command_with_env(
command: str,
*,
mount_path: str = '/workspace',
state_path: str | None = None,
) -> str:
"""Wrap a command with a reusable sandbox-local Python env bootstrap."""
writable_state_path = state_path or mount_path
bootstrap = textwrap.dedent(
f"""
set -e
_LB_VENV_DIR="{writable_state_path}/.venv"
_LB_META_DIR="{writable_state_path}/.langbot"
_LB_META_FILE="$_LB_META_DIR/python-env.json"
_LB_LOCK_DIR="$_LB_META_DIR/python-env.lock"
_LB_TMP_DIR="{writable_state_path}/.tmp"
_LB_PIP_CACHE_DIR="{writable_state_path}/.cache/pip"
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"
if [ -z "$_LB_SYSTEM_PYTHON" ]; then
echo "python3 or python is required to prepare the workspace Python environment" >&2
exit 127
fi
export TMPDIR="$_LB_TMP_DIR"
export TEMP="$_LB_TMP_DIR"
export TMP="$_LB_TMP_DIR"
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
_lb_python_meta() {{
"$_LB_SYSTEM_PYTHON" - <<'PY'
import hashlib
import json
import os
import sys
root = "{mount_path}"
max_manifest_bytes = 10 * 1024 * 1024
digest = hashlib.sha256()
manifest_files = []
for rel in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"):
path = os.path.join(root, rel)
if not os.path.isfile(path):
continue
if os.path.getsize(path) > max_manifest_bytes:
raise RuntimeError(
f"Python project manifest exceeds {{max_manifest_bytes}} bytes: {{rel}}"
)
manifest_files.append(rel)
with open(path, "rb") as handle:
digest.update(rel.encode("utf-8"))
digest.update(b"\0")
while chunk := handle.read(1024 * 1024):
digest.update(chunk)
digest.update(b"\0")
print(
json.dumps(
{{
"python_executable": sys.executable,
"python_version": list(sys.version_info[:3]),
"manifest_files": manifest_files,
"manifest_sha256": digest.hexdigest(),
}},
sort_keys=True,
)
)
PY
}}
_LB_CURRENT_META="$(_lb_python_meta)"
_LB_NEEDS_BOOTSTRAP=0
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
_LB_NEEDS_BOOTSTRAP=1
elif [ ! -f "$_LB_META_FILE" ]; then
_LB_NEEDS_BOOTSTRAP=1
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
_LB_NEEDS_BOOTSTRAP=1
fi
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
_LB_LOCK_WAIT=0
while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do
if [ "$_LB_LOCK_WAIT" -ge 120 ]; then
_LB_LOCK_OWNER="$(cat "$_LB_LOCK_DIR/pid" 2>/dev/null || true)"
if [ -n "$_LB_LOCK_OWNER" ] && kill -0 "$_LB_LOCK_OWNER" 2>/dev/null; then
echo "Timed out waiting for active Python environment lock: $_LB_LOCK_DIR" >&2
exit 1
fi
echo "Timed out waiting for Python environment lock, clearing stale lock: $_LB_LOCK_DIR" >&2
rm -rf "$_LB_LOCK_DIR" 2>/dev/null || true
if mkdir "$_LB_LOCK_DIR" 2>/dev/null; then
break
fi
echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2
exit 1
fi
sleep 1
_LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1))
done
printf '%s\n' "$$" > "$_LB_LOCK_DIR/pid" 2>/dev/null || true
_lb_cleanup_lock() {{
rm -rf "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
}}
trap _lb_cleanup_lock EXIT INT TERM
_LB_CURRENT_META="$(_lb_python_meta)"
_LB_NEEDS_BOOTSTRAP=0
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
_LB_NEEDS_BOOTSTRAP=1
elif [ ! -f "$_LB_META_FILE" ]; then
_LB_NEEDS_BOOTSTRAP=1
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
_LB_NEEDS_BOOTSTRAP=1
fi
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
rm -rf "$_LB_VENV_DIR"
"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"
. "$_LB_VENV_DIR/bin/activate"
python -m pip install --upgrade pip setuptools wheel
if [ -f "{mount_path}/requirements.txt" ]; then
python -m pip install -r "{mount_path}/requirements.txt"
elif [ -f "{mount_path}/pyproject.toml" ] || [ -f "{mount_path}/setup.py" ] || [ -f "{mount_path}/setup.cfg" ]; then
python -m pip install "{mount_path}"
fi
printf '%s' "$_LB_CURRENT_META" > "$_LB_META_FILE"
fi
fi
export VIRTUAL_ENV="$_LB_VENV_DIR"
export PATH="$_LB_VENV_DIR/bin:$PATH"
{command}
"""
).strip()
return bootstrap + '\n'
__all__ = [
'PYTHON_MANIFEST_FILES',
'list_python_manifest_files',
'normalize_host_path',
'should_prepare_python_env',
'wrap_python_command_with_env',
]
+1 -1
View File
@@ -83,7 +83,7 @@ class VersionManager:
try: try:
if await self.is_new_version_available(): if await self.is_new_version_available():
return ( return (
'New version available. Update guide: https://link.langbot.app/en/docs/update', 'New version available. Update guide: https://langbot.app/docs/en/deploy/update',
logging.INFO, logging.INFO,
) )
except Exception as e: except Exception as e:
@@ -269,7 +269,7 @@ class InvitationDeliveryService:
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;"> <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
<tr> <tr>
<td style="padding:0 4px 20px;"> <td style="padding:0 4px 20px;">
<img src="https://docs.langbot.app/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;"> <img src="https://langbot.app/docs/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span> <span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
</td> </td>
</tr> </tr>
+11 -5
View File
@@ -323,13 +323,20 @@ monitoring:
# Prevent one large Workspace backlog from monopolizing PostgreSQL. # Prevent one large Workspace backlog from monopolizing PostgreSQL.
# Supports MONITORING__AUTO_CLEANUP__MAX_BATCHES_PER_TABLE_PER_RUN. # Supports MONITORING__AUTO_CLEANUP__MAX_BATCHES_PER_TABLE_PER_RUN.
max_batches_per_table_per_run: 4 max_batches_per_table_per_run: 4
skills:
# Core-owned SkillStore. Skill discovery, activation, resources, CRUD and
# revisions remain available when Box is disabled.
# TODO(next-major): change the fresh-install default to './data/skills'
# after the online-upgrade window for the historical Box path closes.
root: './data/box/skills'
box: box:
# Master switch for the Box sandbox runtime. When false, LangBot does NOT # Master switch for the Box sandbox runtime. When false, LangBot does NOT
# attempt to connect to a remote Box runtime nor start a local stdio Box # attempt to connect to a remote Box runtime nor start a local stdio Box
# subprocess. Disabling Box also disables every feature that depends on it: # subprocess. Disabling Box disables execution-backed features: native
# the native sandbox tools (exec/read/write/edit/glob/grep), the activate # sandbox tools (exec/read/write/edit/glob/grep), agent-side skill
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still # registration, and stdio-mode MCP servers. Skill management, activation,
# be listed read-only and http/sse MCP servers continue to work. # and read-only package resources remain available without Box.
enabled: true enabled: true
# 'host' runs commands directly as the Box Runtime user without sandbox # 'host' runs commands directly as the Box Runtime user without sandbox
# isolation. It is never auto-selected and is only for trusted local # isolation. It is never auto-selected and is only for trusted local
@@ -378,7 +385,6 @@ box:
image: '' # Custom local sandbox image. Leave empty to use the profile default. image: '' # Custom local sandbox image. Leave empty to use the profile default.
host_root: './data/box' # Base host directory for local workspace mounts. Docker deployments should override this with an absolute host path. host_root: './data/box' # Base host directory for local workspace mounts. Docker deployments should override this with an absolute host path.
default_workspace: '' # Defaults to '<host_root>/default'. Relative paths are resolved under host_root. default_workspace: '' # Defaults to '<host_root>/default'. Relative paths are resolved under host_root.
skills_root: 'skills' # Box-owned skill package directory. Relative paths are resolved under host_root.
allowed_mount_roots: # Defaults to ['<host_root>'] when left empty. allowed_mount_roots: # Defaults to ['<host_root>'] when left empty.
- './data/box' - './data/box'
- '/tmp' - '/tmp'
@@ -80,7 +80,8 @@
"header-name": "", "header-name": "",
"header-value": "", "header-value": "",
"timeout": 120, "timeout": 120,
"output-key": "response" "output-key": "response",
"response-handling": "reply"
}, },
"langflow-api": { "langflow-api": {
"base-url": "http://localhost:7860", "base-url": "http://localhost:7860",
@@ -475,6 +475,25 @@ stages:
type: string type: string
required: false required: false
default: 'response' default: 'response'
- name: response-handling
label:
en_US: Webhook Response Handling
zh_Hans: Webhook 响应处理方式
description:
en_US: Choose whether LangBot forwards the n8n webhook response to the chat user. Ignore mode requires the n8n Webhook node to use Respond Immediately.
zh_Hans: 选择是否将 n8n Webhook 响应转发给聊天用户。忽略模式要求 n8n Webhook 节点使用“立即响应”。
type: select
required: false
default: 'reply'
options:
- name: reply
label:
en_US: Forward as chat reply
zh_Hans: 转发为聊天回复
- name: ignore
label:
en_US: Ignore response body (asynchronous workflow)
zh_Hans: 忽略响应正文(异步工作流)
- name: coze-api - name: coze-api
label: label:
en_US: coze API en_US: coze API
@@ -1,4 +1,4 @@
"""Skills API behavior when a workspace plan has no managed sandbox.""" """Skills API behavior is independent from managed sandbox entitlement."""
from __future__ import annotations from __future__ import annotations
@@ -9,10 +9,7 @@ import pytest
import quart import quart
from langbot.pkg.api.http.controller.groups.skills import SkillsRouterGroup from langbot.pkg.api.http.controller.groups.skills import SkillsRouterGroup
from langbot.pkg.cloud.entitlements import ( from langbot.pkg.cloud.entitlements import EntitlementUnavailableError
EntitlementFeatureUnavailableError,
EntitlementUnavailableError,
)
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111' WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
@@ -32,10 +29,7 @@ async def skills_api():
application.user_service.get_authenticated_account = AsyncMock(return_value=account) application.user_service.get_authenticated_account = AsyncMock(return_value=account)
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access) application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
application.skill_service.list_skills = AsyncMock( application.skill_service.list_skills = AsyncMock(
side_effect=EntitlementFeatureUnavailableError( return_value=[{'name': 'docs-only', 'description': 'No execution required'}]
'managed_sandbox',
entitlement_revision=1,
)
) )
quart_app = quart.Quart(__name__) quart_app = quart.Quart(__name__)
@@ -45,7 +39,7 @@ async def skills_api():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_skills_is_empty_when_plan_has_no_managed_sandbox(skills_api): async def test_list_skills_remains_available_without_managed_sandbox(skills_api):
application, client = skills_api application, client = skills_api
response = await client.get( response = await client.get(
'/api/v1/skills', '/api/v1/skills',
@@ -57,7 +51,7 @@ async def test_list_skills_is_empty_when_plan_has_no_managed_sandbox(skills_api)
assert response.status_code == 200 assert response.status_code == 200
payload = await response.get_json() payload = await response.get_json()
assert payload['data'] == {'skills': []} assert payload['data'] == {'skills': [{'name': 'docs-only', 'description': 'No execution required'}]}
application.skill_service.list_skills.assert_awaited_once() application.skill_service.list_skills.assert_awaited_once()
+156 -71
View File
@@ -11,7 +11,6 @@ import pytest
import quart import quart
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@@ -71,7 +70,6 @@ async def space_oauth_api():
application.space_service.get_oauth_authorize_url = Mock( application.space_service.get_oauth_authorize_url = Mock(
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}' side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
) )
application.space_service.exchange_oauth_code = AsyncMock( application.space_service.exchange_oauth_code = AsyncMock(
return_value={ return_value={
'access_token': 'space-access-token', 'access_token': 'space-access-token',
@@ -129,7 +127,7 @@ async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oau
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cloud_login_entry_starts_stateful_space_oauth(space_oauth_api): async def test_cloud_login_entry_uses_normal_stateful_oauth(space_oauth_api):
application, client = space_oauth_api application, client = space_oauth_api
application.deployment.mode = 'cloud' application.deployment.mode = 'cloud'
@@ -272,10 +270,14 @@ async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api):
async def test_login_callback_requires_and_consumes_server_state(space_oauth_api): async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
application, client = space_oauth_api application, client = space_oauth_api
missing = await client.post('/api/v1/user/space/callback', json={'code': 'oauth-code'}) missing = await client.post('/api/v1/user/space/callback', json={'code': 'v4_oauth-code'})
response = await client.post( response = await client.post(
'/api/v1/user/space/callback', '/api/v1/user/space/callback',
json={'code': 'oauth-code', 'state': 'opaque-login-state'}, json={
'code': 'v4_oauth-code',
'state': 'opaque-login-state',
'redirect_uri': 'https://oss.example/auth/space/callback',
},
) )
assert (await missing.get_json())['code'] == 1 assert (await missing.get_json())['code'] == 1
@@ -283,12 +285,146 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
assert (await response.get_json())['data']['token'] == 'space-login-token' assert (await response.get_json())['data']['token'] == 'space-login-token'
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login') application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
application.space_service.exchange_oauth_code.assert_awaited_once_with( application.space_service.exchange_oauth_code.assert_awaited_once_with(
'oauth-code', 'v4_oauth-code',
[WORKSPACE_UUID], [WORKSPACE_UUID],
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())}, {WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
redirect_uri='https://oss.example/auth/space/callback',
) )
@pytest.mark.asyncio
async def test_login_callback_rejects_downgraded_legacy_code(space_oauth_api):
application, client = space_oauth_api
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v2_legacy-code', 'state': 'opaque-login-state'},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['code'] == 1
assert 'code contract' in payload['msg']
application.space_service.exchange_oauth_code.assert_not_awaited()
@pytest.mark.asyncio
async def test_cloud_login_callback_reconciles_authorized_workspace_before_local_authentication(space_oauth_api):
application, client = space_oauth_api
application.deployment.mode = 'cloud'
calls: list[str] = []
application.directory_projection_service = SimpleNamespace(
reconcile_workspaces=AsyncMock(side_effect=lambda _workspace_uuids: calls.append('reconcile'))
)
application.space_service.exchange_oauth_code.return_value = {
'access_token': 'space-access-token',
'refresh_token': 'space-refresh-token',
'expires_in': 3600,
'cloud_workspace_uuid': WORKSPACE_UUID,
}
authenticated_account = application.user_service.authenticate_space_user.return_value[1]
async def authenticate(*_args):
calls.append('authenticate')
return 'space-login-token', authenticated_account
application.user_service.authenticate_space_user.side_effect = authenticate
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
)
assert response.status_code == 200
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
assert calls == ['reconcile', 'authenticate']
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
@pytest.mark.asyncio
async def test_cloud_login_callback_fails_closed_without_workspace_binding(space_oauth_api):
application, client = space_oauth_api
application.deployment.mode = 'cloud'
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['code'] == 1
assert 'Cloud Workspace binding' in payload['msg']
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
application.user_service.authenticate_space_user.assert_not_awaited()
@pytest.mark.asyncio
async def test_cloud_login_callback_requires_code_binding_for_launch_state(space_oauth_api):
application, client = space_oauth_api
application.deployment.mode = 'cloud'
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
launch_workspace_uuid=WORKSPACE_UUID
)
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['code'] == 1
assert 'Workspace binding' in payload['msg']
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
application.user_service.authenticate_space_user.assert_not_awaited()
@pytest.mark.asyncio
async def test_cloud_login_callback_rejects_conflicting_state_and_code_workspace_bindings(space_oauth_api):
application, client = space_oauth_api
application.deployment.mode = 'cloud'
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
launch_workspace_uuid=WORKSPACE_UUID
)
application.space_service.exchange_oauth_code.return_value = {
'access_token': 'space-access-token',
'refresh_token': 'space-refresh-token',
'expires_in': 3600,
'cloud_workspace_uuid': 'workspace-from-another-flow',
}
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['code'] == 1
assert 'Workspace binding' in payload['msg']
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
application.user_service.authenticate_space_user.assert_not_awaited()
@pytest.mark.asyncio
async def test_oss_login_callback_does_not_request_cloud_reconciliation(space_oauth_api):
application, client = space_oauth_api
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
)
assert response.status_code == 200
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api): async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
application, client = space_oauth_api application, client = space_oauth_api
@@ -299,7 +435,7 @@ async def test_login_callback_launch_state_selects_asserted_workspace(space_oaut
response = await client.post( response = await client.post(
'/api/v1/user/space/callback', '/api/v1/user/space/callback',
json={'code': 'oauth-code', 'state': 'opaque-login-state'}, json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -398,18 +534,22 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_
rejected = await client.post( rejected = await client.post(
'/api/v1/user/bind-space', '/api/v1/user/bind-space',
json={'code': 'attacker-code', 'state': 'jwt.must-not-be-used'}, json={'code': 'v4_attacker-code', 'state': 'jwt.must-not-be-used'},
) )
response = await client.post( response = await client.post(
'/api/v1/user/bind-space', '/api/v1/user/bind-space',
json={'code': 'oauth-code', 'state': 'opaque-bind-state'}, json={'code': 'v4_oauth-code', 'state': 'opaque-bind-state'},
) )
assert rejected.status_code == 401 assert rejected.status_code == 401
assert response.status_code == 200 assert response.status_code == 200
assert (await response.get_json())['data']['token'] == 'rotated-account-token' assert (await response.get_json())['data']['token'] == 'rotated-account-token'
application.user_service.verify_jwt_token.assert_not_awaited() application.user_service.verify_jwt_token.assert_not_awaited()
application.user_service.bind_space_account.assert_awaited_once_with('owner@example.com', 'oauth-code') application.user_service.bind_space_account.assert_awaited_once_with(
'owner@example.com',
'v4_oauth-code',
redirect_uri='http://localhost/auth/space/callback?mode=bind',
)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -417,6 +557,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
application, client = space_oauth_api application, client = space_oauth_api
application.user_service.consume_space_oauth_state.reset_mock() application.user_service.consume_space_oauth_state.reset_mock()
application.space_service.exchange_oauth_code.reset_mock() application.space_service.exchange_oauth_code.reset_mock()
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
response = await client.post( response = await client.post(
'/api/v1/user/space/callback', '/api/v1/user/space/callback',
@@ -440,7 +581,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_direct_launch_refreshes_new_workspace_projection_before_rejecting_account(space_oauth_api): async def test_direct_launch_reconciles_exact_workspace_before_resolving_access(space_oauth_api):
application, client = space_oauth_api application, client = space_oauth_api
projected_account = SimpleNamespace( projected_account = SimpleNamespace(
uuid='account-a', uuid='account-a',
@@ -448,8 +589,8 @@ async def test_direct_launch_refreshes_new_workspace_projection_before_rejecting
account_type='space', account_type='space',
status='active', status='active',
) )
application.user_service.get_user_by_uuid = AsyncMock(side_effect=[None, None, projected_account]) application.user_service.get_user_by_uuid = AsyncMock(return_value=projected_account)
application.directory_projection_service = SimpleNamespace(sync_once=AsyncMock()) application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
response = await client.post( response = await client.post(
'/api/v1/user/space/callback', '/api/v1/user/space/callback',
@@ -461,61 +602,5 @@ async def test_direct_launch_refreshes_new_workspace_projection_before_rejecting
assert response.status_code == 200 assert response.status_code == 200
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
assert application.directory_projection_service.sync_once.await_count == 2 application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
assert application.user_service.get_user_by_uuid.await_count == 3 application.user_service.get_user_by_uuid.assert_awaited_once_with('account-a')
@pytest.mark.asyncio
async def test_direct_launch_refreshes_projection_when_account_exists_before_workspace(space_oauth_api):
application, client = space_oauth_api
projected_access = application.workspace_collaboration_service.resolve_account_workspace.return_value
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
side_effect=[WorkspaceNotFoundError('Workspace not found'), projected_access]
)
application.directory_projection_service = SimpleNamespace(sync_once=AsyncMock())
response = await client.post(
'/api/v1/user/space/callback',
json={
'workspace_uuid': WORKSPACE_UUID,
'launch_assertion': 'signed-launch-token',
},
)
assert response.status_code == 200
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
application.directory_projection_service.sync_once.assert_awaited_once_with()
assert application.workspace_collaboration_service.resolve_account_workspace.await_count == 2
@pytest.mark.asyncio
async def test_direct_launch_falls_back_to_snapshot_when_event_backlog_exceeds_page_budget(space_oauth_api):
application, client = space_oauth_api
projected_access = application.workspace_collaboration_service.resolve_account_workspace.return_value
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
side_effect=[
WorkspaceNotFoundError('Workspace not found'),
WorkspaceNotFoundError('Workspace not found'),
WorkspaceNotFoundError('Workspace not found'),
WorkspaceNotFoundError('Workspace not found'),
projected_access,
]
)
application.directory_projection_service = SimpleNamespace(
sync_once=AsyncMock(),
refresh_snapshot=AsyncMock(),
)
response = await client.post(
'/api/v1/user/space/callback',
json={
'workspace_uuid': WORKSPACE_UUID,
'launch_assertion': 'signed-launch-token',
},
)
assert response.status_code == 200
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
assert application.directory_projection_service.sync_once.await_count == 3
application.directory_projection_service.refresh_snapshot.assert_awaited_once_with()
assert application.workspace_collaboration_service.resolve_account_workspace.await_count == 5
@@ -29,6 +29,9 @@ from langbot.pkg.cloud.entitlements import (
EntitlementSnapshot, EntitlementSnapshot,
EntitlementUnavailableError, EntitlementUnavailableError,
) )
from langbot.pkg.skill.manager import SkillManager
from langbot.pkg.skill.repository import SkillRepository
from langbot.pkg.provider.tools.loaders import skill as skill_loader
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@@ -54,7 +57,7 @@ class _AdmissionBackend(BaseSandboxBackend):
'mount_isolation': True, 'mount_isolation': True,
'network_isolation': True, 'network_isolation': True,
'hard_workspace_quota': True, 'hard_workspace_quota': True,
'hard_skill_storage_quota': True, 'hard_read_only_mount_quota': True,
'bounded_ephemeral_storage': True, 'bounded_ephemeral_storage': True,
'inode_quota': True, 'inode_quota': True,
} }
@@ -230,9 +233,18 @@ async def _stack(tmp_path):
deployment=SimpleNamespace(multi_workspace_enabled=True), deployment=SimpleNamespace(multi_workspace_enabled=True),
entitlement_resolver=EntitlementResolver('instance-a', entitlements), entitlement_resolver=EntitlementResolver('instance-a', entitlements),
workspace_service=workspace_service, workspace_service=workspace_service,
instance_config=SimpleNamespace(data={'box': box_config, 'system': {'limitation': {}}}), instance_config=SimpleNamespace(
data={
'skills': {'root': str(shared_root / 'skills')},
'box': box_config,
'system': {'limitation': {}},
}
),
) )
app.skill_repository = SkillRepository(app)
app.skill_mgr = SkillManager(app)
service = BoxService(app, client=client) service = BoxService(app, client=client)
app.box_service = service
await service.initialize() await service.initialize()
return service, runtime, backend, entitlements, server_task, client_task return service, runtime, backend, entitlements, server_task, client_task
@@ -312,7 +324,7 @@ async def test_two_workspaces_get_isolated_physical_sessions_and_paths(tmp_path)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tmp_path): async def test_cloud_core_skills_mount_generically_and_do_not_require_box_entitlement(tmp_path):
service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path) service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
first = _context('workspace-a') first = _context('workspace-a')
second = _context('workspace-b') second = _context('workspace-b')
@@ -324,32 +336,35 @@ async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tm
managed=False, managed=False,
) )
try: try:
private = await service.create_skill( repository = service.ap.skill_repository
await repository.create_skill(
second, second,
{ {
'name': 'private', 'name': 'private',
'instructions': 'workspace-b secret', 'instructions': 'workspace-b secret',
}, },
) )
own_skill = await service.create_skill( own_skill = await repository.create_skill(
first, first,
{ {
'name': 'runner', 'name': 'runner',
'instructions': 'Run scripts/main.py', 'instructions': 'Run scripts/main.py',
}, },
) )
await service.write_skill_file(first, 'runner', 'scripts/main.py', "print('ok')") await repository.write_skill_file(first, 'runner', 'scripts/main.py', "print('ok')")
await service.write_skill_file(first, 'runner', 'requirements.txt', 'requests==2.32.0\n') await repository.write_skill_file(first, 'runner', 'requirements.txt', 'requests==2.32.0\n')
refreshed_skill = await service.get_skill(first, 'runner') refreshed_skill = await repository.get_skill(first, 'runner')
assert refreshed_skill is not None assert refreshed_skill is not None
assert refreshed_skill['python_project'] is True assert refreshed_skill['python_project'] is True
await service.ap.skill_mgr.reload_skills(first)
query = _query(first, 91)
await service.execute_tool( await service.execute_tool(
{ {
'command': 'python /workspace/.skills/runner/scripts/main.py', 'command': 'python /workspace/.skills/runner/scripts/main.py',
'workdir': '/workspace/.skills/runner', 'workdir': '/workspace/.skills/runner',
}, },
_query(first, 91), query,
skill_name='runner', read_only_mounts=skill_loader.build_execution_mounts(service.ap, query),
) )
mounted_spec = backend.started_specs[-1] mounted_spec = backend.started_specs[-1]
@@ -358,20 +373,14 @@ async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tm
assert mounted_spec.extra_mounts[0].mount_path == '/workspace/.skills/runner' assert mounted_spec.extra_mounts[0].mount_path == '/workspace/.skills/runner'
assert mounted_spec.extra_mounts[0].mode.value == 'ro' assert mounted_spec.extra_mounts[0].mode.value == 'ro'
with pytest.raises(BoxAdmissionError, match='Scanning arbitrary host'): assert await repository.get_skill(first, 'private') is None
await service.scan_skill_directory(first, private['package_root']) await repository.create_skill(
with pytest.raises(BoxAdmissionError, match='package_root is runtime-owned'): ineligible,
await service.create_skill( {'name': 'docs-only', 'instructions': 'Read this without Box.'},
first, )
{ assert [skill['name'] for skill in await repository.list_skills(ineligible)] == ['docs-only']
'name': 'stolen',
'package_root': private['package_root'],
},
)
assert await service.get_skill(first, 'private') is None
with pytest.raises(EntitlementUnavailableError): with pytest.raises(EntitlementUnavailableError):
await service.list_skills(ineligible) await service.execute_tool({'command': 'true'}, _query(ineligible, 92))
finally: finally:
server_task.cancel() server_task.cancel()
client_task.cancel() client_task.cancel()
@@ -12,6 +12,7 @@ import pytest
from unittest.mock import AsyncMock, MagicMock, Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
from types import SimpleNamespace from types import SimpleNamespace
import json import json
import sqlalchemy
import uuid import uuid
from langbot.pkg.api.http.service.bot import BotService from langbot.pkg.api.http.service.bot import BotService
@@ -449,10 +450,58 @@ class TestBotServiceCreateBot:
insert_statement = ap.persistence_mgr.execute_async.await_args_list[1].args[0] insert_statement = ap.persistence_mgr.execute_async.await_args_list[1].args[0]
insert_values = insert_statement.compile().params insert_values = insert_statement.compile().params
assert insert_values['workspace_uuid'] == WORKSPACE_UUID assert insert_values['workspace_uuid'] == WORKSPACE_UUID
assert insert_values['use_pipeline_uuid'] == 'default-pipeline-uuid'
assert insert_values['use_pipeline_name'] == 'Default Pipeline'
assert bot_uuid is not None # Verify UUID was returned assert bot_uuid is not None # Verify UUID was returned
async def test_create_bot_rolls_back_insert_when_load_bot_fails(self):
"""Deletes the inserted row when the adapter fails to load.
Regression: a failing adapter constructor (e.g. KeyError on a missing
optional credential key) used to leave a permanently disabled orphan
bot in the DB the insert was already committed and the HTTP layer
surfaced a 500 without any cleanup.
"""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_bots': -1}}}
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.load_bot = AsyncMock(side_effect=KeyError('token'))
pipeline_result = Mock()
pipeline_result.first = Mock(return_value=None)
bot_result = Mock()
bot_result.first = Mock(return_value=_create_mock_bot())
executed_statements = []
async def mock_execute(query):
executed_statements.append(query)
if len(executed_statements) <= 2:
return pipeline_result # 1: limitation bots query, 2: pipeline query
if len(executed_statements) == 3:
return Mock() # insert
return bot_result # get_bot after insert
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid', 'name': 'New Bot'})
service = BotService(ap)
# Execute & Verify: the adapter error propagates
with pytest.raises(KeyError, match='token'):
await service.create_bot(
WORKSPACE_UUID, {'name': 'New Bot', 'adapter': 'telegram', 'adapter_config': {}}
)
# And the inserted row is rolled back via a DELETE on the new uuid
# (no limitation query runs because max_bots=-1)
assert len(executed_statements) == 4 # pipeline select, insert, bot select, delete
delete_statement = executed_statements[-1]
assert isinstance(delete_statement, sqlalchemy.sql.dml.Delete)
compiled = delete_statement.compile()
assert compiled.params['uuid_1'] is not None
class TestBotServiceUpdateBot: class TestBotServiceUpdateBot:
"""Tests for update_bot method.""" """Tests for update_bot method."""
@@ -95,7 +95,9 @@ class TestSpaceServiceGetOAuthAuthorizeUrl:
result = service.get_oauth_authorize_url('http://localhost/callback') result = service.get_oauth_authorize_url('http://localhost/callback')
# Verify # Verify
assert parse_qs(urlsplit(result).query)['redirect_uri'] == ['http://localhost/callback'] query = parse_qs(urlsplit(result).query)
assert query['redirect_uri'] == ['http://localhost/callback']
assert query['code_contract'] == ['redirect-v1']
assert 'https://space.langbot.app/auth/authorize' in result assert 'https://space.langbot.app/auth/authorize' in result
def test_get_oauth_authorize_url_with_state(self): def test_get_oauth_authorize_url_with_state(self):
@@ -578,12 +580,14 @@ class TestSpaceServiceExchangeOAuthCode:
'auth_code', 'auth_code',
['workspace-1'], ['workspace-1'],
{'workspace-1': 1_700_000_000}, {'workspace-1': 1_700_000_000},
redirect_uri='https://oss.example/auth/space/callback',
) )
# Verify # Verify
assert result['access_token'] == 'new_access_token' assert result['access_token'] == 'new_access_token'
assert mock_session_obj.post.call_args.kwargs['json'] == { assert mock_session_obj.post.call_args.kwargs['json'] == {
'code': 'auth_code', 'code': 'auth_code',
'redirect_uri': 'https://oss.example/auth/space/callback',
'instance_id': constants.instance_id, 'instance_id': constants.instance_id,
'workspace_uuids': ['workspace-1'], 'workspace_uuids': ['workspace-1'],
'workspace_created_ats': {'workspace-1': 1_700_000_000}, 'workspace_created_ats': {'workspace-1': 1_700_000_000},
@@ -846,10 +850,7 @@ class TestSpaceServiceGetModelSelection:
if response_shape == 'models-envelope': if response_shape == 'models-envelope':
data = {'models': models} data = {'models': models}
elif response_shape == 'availability-wrapper': elif response_shape == 'availability-wrapper':
data = [ data = [{'model': model, 'latency_ms': index + 10, 'http_code': 200} for index, model in enumerate(models)]
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
for index, model in enumerate(models)
]
else: else:
data = models data = models
payload = {'code': 0, 'data': data} payload = {'code': 0, 'data': data}
+21 -40
View File
@@ -41,6 +41,7 @@ from langbot_plugin.box.security import (
from langbot_plugin.entities.io.context import ActionContext from langbot_plugin.entities.io.context import ActionContext
from langbot.pkg.api.http.context import ExecutionContext from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.box.service import BoxService from langbot.pkg.box.service import BoxService
from langbot.pkg.provider.tools.loaders import skill as skill_loader
_UTC = dt.timezone.utc _UTC = dt.timezone.utc
_CONTEXT = ExecutionContext( _CONTEXT = ExecutionContext(
@@ -301,7 +302,7 @@ class TestSharesFilesystemWithBox:
- stdio (local child process) shared filesystem True - stdio (local child process) shared filesystem True
- WebSocket (Docker / sidecar / --standalone-box / remote) separated False - WebSocket (Docker / sidecar / --standalone-box / remote) separated False
This drives whether LangBot validates Box-reported skill paths locally. This drives whether LangBot can safely perform local workspace operations.
Getting it wrong silently drops every skill in separated deployments. Getting it wrong silently drops every skill in separated deployments.
""" """
@@ -338,7 +339,7 @@ class TestSharesFilesystemWithBox:
def test_false_when_client_injected_without_connector(self): def test_false_when_client_injected_without_connector(self):
# Injected client (no connector) → unknown topology → conservative False # Injected client (no connector) → unknown topology → conservative False
# so LangBot never wrongly drops Box-reported skills. # so LangBot does not assume a shared local filesystem.
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
assert service._runtime_connector is None assert service._runtime_connector is None
@@ -552,7 +553,6 @@ async def test_box_service_reconnect_restores_workspace_and_runs_cleanup(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
): ):
app = make_app(Mock()) app = make_app(Mock())
app.skill_mgr = SimpleNamespace(reload_skills=AsyncMock())
service = BoxService(app, client=Mock(spec=BoxRuntimeClient)) service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
connector = Mock() connector = Mock()
connector.reconnect = AsyncMock() connector.reconnect = AsyncMock()
@@ -565,16 +565,14 @@ async def test_box_service_reconnect_restores_workspace_and_runs_cleanup(
connector.reconnect.assert_awaited_once() connector.reconnect.assert_awaited_once()
service._ensure_default_workspace.assert_called_once() service._ensure_default_workspace.assert_called_once()
service._purge_attachment_dirs.assert_awaited_once() service._purge_attachment_dirs.assert_awaited_once()
app.skill_mgr.reload_skills.assert_awaited_once()
assert service.available is True assert service.available is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cloud_box_service_reconnect_does_not_reload_unscoped_skills( async def test_cloud_box_service_reconnect_restores_runtime_only(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
): ):
app = make_app(Mock()) app = make_app(Mock())
app.skill_mgr = SimpleNamespace(reload_skills=AsyncMock())
service = BoxService(app, client=Mock(spec=BoxRuntimeClient)) service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
service._cloud_managed = True service._cloud_managed = True
connector = Mock() connector = Mock()
@@ -587,7 +585,6 @@ async def test_cloud_box_service_reconnect_does_not_reload_unscoped_skills(
connector.reconnect.assert_awaited_once() connector.reconnect.assert_awaited_once()
service._verify_cloud_runtime.assert_awaited_once() service._verify_cloud_runtime.assert_awaited_once()
app.skill_mgr.reload_skills.assert_not_awaited()
assert service.available is True assert service.available is True
@@ -1941,7 +1938,7 @@ def test_disconnect_callback_does_not_schedule_without_running_event_loop():
assert service._reconnecting is False assert service._reconnecting is False
class TestBuildSkillExtraMounts: class TestBuildSkillExecutionMounts:
"""Robustness of skill mount construction against a stale skill cache. """Robustness of skill mount construction against a stale skill cache.
The three sandbox backends behave inconsistently when a skill's The three sandbox backends behave inconsistently when a skill's
@@ -1951,16 +1948,10 @@ class TestBuildSkillExtraMounts:
the backend never sees a bad mount. the backend never sees a bad mount.
""" """
def _make_service(self, logger, skills, *, shares_filesystem=True): def _make_app(self, logger, skills):
app = make_app(logger) app = make_app(logger)
app.skill_mgr = SimpleNamespace(skills=skills, get_skills=Mock(return_value=skills)) app.skill_mgr = SimpleNamespace(skills=skills, get_skills=Mock(return_value=skills))
client = Mock(spec=BoxRuntimeClient) return app
service = BoxService(app, client=client)
# Tests construct BoxService with an injected client (no connector), so
# set the topology explicitly. Most cases exercise the shared-fs (local
# stdio) path where local package_root validation applies.
service._shares_filesystem_with_box_override = shares_filesystem
return service
def test_skips_skill_with_missing_package_root(self): def test_skips_skill_with_missing_package_root(self):
logger = Mock() logger = Mock()
@@ -1969,16 +1960,16 @@ class TestBuildSkillExtraMounts:
'alive': {'name': 'alive', 'package_root': live_dir}, 'alive': {'name': 'alive', 'package_root': live_dir},
'ghost': {'name': 'ghost', 'package_root': '/nonexistent/path/should/never/exist'}, 'ghost': {'name': 'ghost', 'package_root': '/nonexistent/path/should/never/exist'},
} }
service = self._make_service(logger, skills) app = self._make_app(logger, skills)
query = make_query() query = make_query()
mounts = service.build_skill_extra_mounts(query) mounts = skill_loader.build_execution_mounts(app, query)
assert mounts == [ assert mounts == [
{ {
'host_path': live_dir, 'host_path': live_dir,
'mount_path': '/workspace/.skills/alive', 'mount_path': '/workspace/.skills/alive',
'mode': 'rw', 'mode': 'ro',
} }
] ]
# Warning logged so operators can see what was dropped # Warning logged so operators can see what was dropped
@@ -1987,27 +1978,19 @@ class TestBuildSkillExtraMounts:
for call in logger.warning.call_args_list for call in logger.warning.call_args_list
) )
def test_trusts_box_paths_when_filesystem_not_shared(self): def test_rejects_missing_core_paths_when_filesystem_not_shared(self):
"""In separated deployments (Docker Compose, k8s sidecar, """Core owns package paths even when Box is a separate process."""
--standalone-box, remote endpoint) the Box runtime owns its own
filesystem. package_root values it reports are NOT resolvable on the
LangBot side, so LangBot must trust them rather than dropping every
skill via a local isdir() check."""
logger = Mock() logger = Mock()
skills = { skills = {
'a': {'name': 'a', 'package_root': '/box/skills/a'}, 'a': {'name': 'a', 'package_root': '/box/skills/a'},
'b': {'name': 'b', 'package_root': '/box/skills/b'}, 'b': {'name': 'b', 'package_root': '/box/skills/b'},
} }
service = self._make_service(logger, skills, shares_filesystem=False) app = self._make_app(logger, skills)
mounts = service.build_skill_extra_mounts(make_query()) mounts = skill_loader.build_execution_mounts(app, make_query())
assert mounts == [ assert mounts == []
{'host_path': '/box/skills/a', 'mount_path': '/workspace/.skills/a', 'mode': 'rw'}, assert len(logger.warning.call_args_list) == 2
{'host_path': '/box/skills/b', 'mount_path': '/workspace/.skills/b', 'mode': 'rw'},
]
# No skill is dropped, so no "missing" warning should be logged.
assert not any('package_root missing' in str(call.args[0]) for call in logger.warning.call_args_list)
def test_skips_skill_with_empty_package_root(self): def test_skips_skill_with_empty_package_root(self):
logger = Mock() logger = Mock()
@@ -2015,25 +1998,23 @@ class TestBuildSkillExtraMounts:
'no_root': {'name': 'no_root', 'package_root': ''}, 'no_root': {'name': 'no_root', 'package_root': ''},
'whitespace': {'name': 'whitespace', 'package_root': ' '}, 'whitespace': {'name': 'whitespace', 'package_root': ' '},
} }
service = self._make_service(logger, skills) app = self._make_app(logger, skills)
assert service.build_skill_extra_mounts(make_query()) == [] assert skill_loader.build_execution_mounts(app, make_query()) == []
def test_empty_package_root_skipped_even_when_not_shared(self): def test_empty_package_root_skipped_even_when_not_shared(self):
"""An empty package_root is always invalid regardless of topology.""" """An empty package_root is always invalid regardless of topology."""
logger = Mock() logger = Mock()
skills = {'no_root': {'name': 'no_root', 'package_root': ''}} skills = {'no_root': {'name': 'no_root', 'package_root': ''}}
service = self._make_service(logger, skills, shares_filesystem=False) app = self._make_app(logger, skills)
assert service.build_skill_extra_mounts(make_query()) == [] assert skill_loader.build_execution_mounts(app, make_query()) == []
def test_returns_empty_when_no_skill_manager(self): def test_returns_empty_when_no_skill_manager(self):
logger = Mock() logger = Mock()
app = make_app(logger) app = make_app(logger)
# no skill_mgr attribute # no skill_mgr attribute
service = BoxService(app, client=Mock(spec=BoxRuntimeClient)) assert skill_loader.build_execution_mounts(app, make_query()) == []
assert service.build_skill_extra_mounts(make_query()) == []
# ── Attachment passthrough (inbound / outbound) ───────────────────────────── # ── Attachment passthrough (inbound / outbound) ─────────────────────────────
+1 -1
View File
@@ -13,8 +13,8 @@ from langbot.pkg.box.workspace import (
classify_python_workspace, classify_python_workspace,
infer_workspace_host_path, infer_workspace_host_path,
rewrite_mounted_path, rewrite_mounted_path,
wrap_python_command_with_env,
) )
from langbot.pkg.utils.python_workspace import wrap_python_command_with_env
_CONTEXT = ExecutionContext( _CONTEXT = ExecutionContext(
@@ -4,7 +4,7 @@ import asyncio
import datetime import datetime
import logging import logging
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import Mock from unittest.mock import AsyncMock, Mock
import pytest import pytest
import sqlalchemy import sqlalchemy
@@ -215,6 +215,88 @@ async def test_directory_delta_requests_model_catalog_sync_after_commit(projecti
request_sync.assert_called_once_with() request_sync.assert_called_once_with()
async def test_targeted_reconciliation_projects_new_workspace_without_advancing_event_cursor(projection_context):
application, session_factory = projection_context
provider = _Provider(
[_snapshot(7, workspaces=[])],
deltas=[_delta(workspaces=[_workspace(revision=8, name='JIT Workspace')])],
)
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
await service.initialize()
await service.reconcile_workspaces((WORKSPACE_UUID,))
async with session_factory() as session:
account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID))
workspace = await session.get(Workspace, WORKSPACE_UUID)
membership = await session.scalar(
sqlalchemy.select(WorkspaceMembership).where(
WorkspaceMembership.workspace_uuid == WORKSPACE_UUID,
WorkspaceMembership.account_uuid == ACCOUNT_UUID,
)
)
state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
assert account is not None
assert workspace is not None and workspace.name == 'JIT Workspace'
assert membership is not None and membership.status == 'active'
assert state is not None and state.cursor == 7
assert provider.delta_calls == 1
assert provider.after_cursors == []
async def test_targeted_reconciliation_preserves_existing_account_until_ordered_event_projection(projection_context):
application, session_factory = projection_context
targeted_workspace = _workspace(revision=8, name='Renamed Workspace').model_copy(
update={
'members': [
_member(revision=8).model_copy(update={'display_name': 'Changed Account Name'})
]
}
)
provider = _Provider(
[_snapshot(7)],
deltas=[_delta(workspaces=[targeted_workspace])],
)
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
await service.initialize()
await service.reconcile_workspaces((WORKSPACE_UUID,))
async with session_factory() as session:
account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID))
workspace = await session.get(Workspace, WORKSPACE_UUID)
state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
assert account is not None and account.user == 'Workspace Owner'
assert account.projection_revision == 7
assert workspace is not None and workspace.name == 'Renamed Workspace'
assert state is not None and state.cursor == 7
async def test_targeted_reconciliation_only_updates_requested_workspace_side_effects(projection_context):
application, _session_factory = projection_context
provider = _Provider(
[_snapshot(7, workspaces=[])],
deltas=[_delta(workspaces=[_workspace(revision=8, name='JIT Workspace')])],
)
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
await service.initialize()
service._reconcile_entitlement_snapshot_set = AsyncMock()
service._update_entitlement_workspace_activity = AsyncMock()
service._publish_runtime_execution_projection = Mock()
await service.reconcile_workspaces((WORKSPACE_UUID,))
service._reconcile_entitlement_snapshot_set.assert_not_awaited()
service._update_entitlement_workspace_activity.assert_awaited_once()
assert service._update_entitlement_workspace_activity.await_args.kwargs == {
'requested_workspace_uuids': {WORKSPACE_UUID},
}
service._publish_runtime_execution_projection.assert_called_once()
assert service._publish_runtime_execution_projection.call_args.kwargs == {
'affected_workspace_uuids': {WORKSPACE_UUID},
}
async def test_initial_snapshot_projects_core_owned_rows(projection_context): async def test_initial_snapshot_projects_core_owned_rows(projection_context):
application, session_factory = projection_context application, session_factory = projection_context
reconcile_execution_projection = Mock() reconcile_execution_projection = Mock()
+88 -1
View File
@@ -55,7 +55,7 @@ finally:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner: def make_runner(output_key: str = 'response', response_handling: str = 'reply') -> N8nServiceAPIRunner:
ap = Mock() ap = Mock()
ap.logger = Mock() ap.logger = Mock()
pipeline_config = { pipeline_config = {
@@ -63,6 +63,7 @@ def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner:
'n8n-service-api': { 'n8n-service-api': {
'webhook-url': 'http://test-n8n/webhook', 'webhook-url': 'http://test-n8n/webhook',
'output-key': output_key, 'output-key': output_key,
'response-handling': response_handling,
'auth-type': 'none', 'auth-type': 'none',
} }
} }
@@ -287,6 +288,7 @@ def make_http_session_mock(response_bytes: bytes, status: int = 200):
"""Mock httpclient.get_session() returning a session whose post() yields response_bytes.""" """Mock httpclient.get_session() returning a session whose post() yields response_bytes."""
mock_response = make_mock_response([response_bytes], status=status) mock_response = make_mock_response([response_bytes], status=status)
mock_response.status = status mock_response.status = status
mock_response.headers = {}
mock_cm = AsyncMock() mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_response) mock_cm.__aenter__ = AsyncMock(return_value=mock_response)
@@ -314,6 +316,91 @@ async def test_call_webhook_nonstream_adapter_plain_json():
assert results[0].content == 'result text' assert results[0].content == 'result text'
@pytest.mark.asyncio
@pytest.mark.parametrize('status', [200, 201, 202, 204])
@pytest.mark.parametrize(
'response_body',
[
b'{"message":"Workflow was started"}',
b'{"response":"must not be forwarded"}',
b'plain acknowledgement',
],
)
async def test_call_webhook_ignore_response_body(response_body: bytes, status: int):
"""Ignore mode accepts any HTTP 2xx response without emitting chat output."""
runner = make_runner(response_handling='ignore')
query = make_query(is_stream=False)
http_session = make_http_session_mock(response_body, status=status)
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
results = []
async for message in runner._call_webhook(query):
results.append(message)
assert results == []
@pytest.mark.asyncio
async def test_call_webhook_ignore_releases_without_reading_response_body():
"""Ignore mode returns after the success status without waiting for the body."""
runner = make_runner(response_handling='ignore')
query = make_query(is_stream=False)
mock_response = make_mock_response([], status=202)
mock_response.headers = {}
mock_response.release = Mock()
async def fail_if_read(_size):
raise AssertionError('ignore mode must not read the response body')
yield b''
mock_response.content.iter_chunked = fail_if_read
mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_response)
mock_cm.__aexit__ = AsyncMock(return_value=False)
mock_session = Mock()
mock_session.post = Mock(return_value=mock_cm)
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=mock_session):
results = [message async for message in runner._call_webhook(query)]
assert results == []
mock_response.release.assert_called_once_with()
@pytest.mark.asyncio
@pytest.mark.parametrize('status', [201, 202, 204])
async def test_call_webhook_reply_mode_preserves_http_200_contract(status: int):
"""Reply mode remains backward compatible and rejects non-200 statuses."""
runner = make_runner(response_handling='reply')
query = make_query(is_stream=False)
http_session = make_http_session_mock(b'', status=status)
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
with pytest.raises(N8nAPIError, match=f'n8n webhook call failed: {status}'):
async for _ in runner._call_webhook(query):
pass
@pytest.mark.asyncio
async def test_call_webhook_ignore_mode_preserves_http_error():
"""Ignore mode must not swallow a failed n8n webhook response."""
runner = make_runner(response_handling='ignore')
query = make_query(is_stream=False)
http_session = make_http_session_mock(b'{"error":"unavailable"}', status=500)
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
with pytest.raises(N8nAPIError, match='n8n webhook call exception'):
async for _ in runner._call_webhook(query):
pass
@pytest.mark.asyncio
async def test_invalid_response_handling_is_rejected():
"""Configuration errors should fail fast instead of silently changing reply behavior."""
with pytest.raises(ValueError, match='Invalid n8n response-handling'):
make_runner(response_handling='unexpected')
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_call_webhook_stream_adapter_stream_format(): async def test_call_webhook_stream_adapter_stream_format():
"""Stream adapter + stream format → MessageChunks, last is_final.""" """Stream adapter + stream format → MessageChunks, last is_final."""
@@ -0,0 +1,59 @@
"""Tests for WecomAdapter.send_message content-key handling."""
import pytest
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform.sources.wecom import WecomAdapter
class StubWecomClient:
def __init__(self):
self.calls = []
async def get_media_id(self, msg):
return 'MEDIA_ID_123'
async def send_private_msg(self, user_id, agent_id, text):
self.calls.append(('text', user_id, agent_id, text))
async def send_image(self, user_id, agent_id, media_id):
self.calls.append(('image', user_id, agent_id, media_id))
async def send_voice(self, user_id, agent_id, media_id):
self.calls.append(('voice', user_id, agent_id, media_id))
async def send_file(self, user_id, agent_id, media_id):
self.calls.append(('file', user_id, agent_id, media_id))
def _make_adapter():
adapter = WecomAdapter.model_construct(bot=StubWecomClient())
return adapter
@pytest.mark.asyncio
@pytest.mark.parametrize(
('part', 'expected_type'),
[
(platform_message.Image(url='https://example.com/x.jpg'), 'image'),
(platform_message.Voice(url='https://example.com/x.amr'), 'voice'),
(platform_message.File(url='https://example.com/x.pdf', name='x.pdf'), 'file'),
],
)
async def test_send_message_dispatches_media_by_id(part, expected_type):
adapter = _make_adapter()
chain = platform_message.MessageChain([part])
await adapter.send_message('person', 'USER1|1000001', chain)
assert adapter.bot.calls == [(expected_type, 'USER1', 1000001, 'MEDIA_ID_123')]
@pytest.mark.asyncio
async def test_send_message_text_still_works():
adapter = _make_adapter()
chain = platform_message.MessageChain([platform_message.Plain(text='hello')])
await adapter.send_message('person', 'USER1|1000001', chain)
assert adapter.bot.calls == [('text', 'USER1', 1000001, 'hello')]
+152 -115
View File
@@ -67,15 +67,10 @@ def _make_skill_data(
class TestSkillManagerCache: class TestSkillManagerCache:
"""The Box runtime is the only source of truth — SkillManager just holds """SkillManager caches the Core-owned SkillRepository catalog."""
an in-memory cache populated by ``reload_skills``. There is no local
filesystem reader anymore."""
def test_refresh_skill_from_disk_reports_cache_presence(self): def test_refresh_skill_from_disk_reports_cache_presence(self):
"""Box is the only source of truth for skill content. refresh_skill_from_disk """Disk mutations are reflected by an explicit repository reload."""
now just reports whether the skill is still in the in-memory cache
the actual content refresh is driven by SkillService awaiting
``reload_skills`` after every Box mutation."""
from langbot.pkg.skill.manager import SkillManager from langbot.pkg.skill.manager import SkillManager
ap = _make_ap() ap = _make_ap()
@@ -92,67 +87,26 @@ class TestSkillManagerCache:
assert mgr.refresh_skill_from_disk(_CONTEXT, '') is False assert mgr.refresh_skill_from_disk(_CONTEXT, '') is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reload_skills_drops_box_skills_with_missing_package_root(self): async def test_reload_skills_uses_repository_when_box_is_disabled(self):
"""When LangBot shares a filesystem with Box (local stdio mode) and Box
reports a skill whose package_root is gone from that shared filesystem,
the cache must drop it instead of keeping a stale entry that would later
produce a bad mount."""
from langbot.pkg.skill.manager import SkillManager from langbot.pkg.skill.manager import SkillManager
with tempfile.TemporaryDirectory() as live_dir: repository = SimpleNamespace(
ghost_dir = os.path.join(live_dir, '_does_not_exist')
box_service = SimpleNamespace(
available=True,
shares_filesystem_with_box=True,
list_skills=AsyncMock(
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,
list_skills=AsyncMock( list_skills=AsyncMock(
return_value=[ return_value=[
_make_skill_data(name='alpha', package_root='/box/skills/alpha'), _make_skill_data(name='alpha', package_root='/skills/alpha'),
_make_skill_data(name='beta', package_root='/box/skills/beta'), _make_skill_data(name='beta', package_root='/skills/beta'),
] ]
), ),
) )
ap = _make_ap() ap = _make_ap()
ap.box_service = box_service ap.box_service = SimpleNamespace(available=False, enabled=False)
ap.skill_repository = repository
mgr = SkillManager(ap) mgr = SkillManager(ap)
await mgr.reload_skills(_CONTEXT) await mgr.reload_skills(_CONTEXT)
assert sorted(mgr.get_skills(_CONTEXT)) == ['alpha', 'beta'] assert sorted(mgr.get_skills(_CONTEXT)) == ['alpha', 'beta']
# No skill dropped → no "package_root missing" warning. repository.list_skills.assert_awaited_once_with(_CONTEXT)
warning_messages = [str(call.args[0]) for call in ap.logger.warning.call_args_list]
assert not any('package_root missing' in msg for msg in warning_messages)
class TestSkillActivationHelper: class TestSkillActivationHelper:
@@ -322,7 +276,7 @@ class TestSkillPathHelpers:
class TestSkillToolLoader: class TestSkillToolLoader:
"""The skill tool surface is now just ``activate`` + ``register_skill``. """Skill activation and resources are independent from sandbox execution.
The legacy CRUD authoring tools (create/list/get/update/delete/ The legacy CRUD authoring tools (create/list/get/update/delete/
import_skill_from_directory/reload_skills) were removed; skill CRUD is import_skill_from_directory/reload_skills) were removed; skill CRUD is
@@ -338,8 +292,11 @@ class TestSkillToolLoader:
from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILLS_KEY from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILLS_KEY
skill = _make_skill_data(name='demo', package_root='/data/skills/demo', instructions='Step 1') skill = _make_skill_data(name='demo', package_root='/data/skills/demo', instructions='Step 1')
skill['revision'] = 'sha256:demo'
ap = _make_ap() ap = _make_ap()
ap.skill_mgr = _make_skill_manager({'demo': skill}) ap.skill_mgr = _make_skill_manager({'demo': skill})
ap.skill_repository = SimpleNamespace(get_skill=AsyncMock(return_value=skill))
ap.box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
loader = SkillToolLoader(ap) loader = SkillToolLoader(ap)
query = _make_query() query = _make_query()
@@ -348,9 +305,13 @@ class TestSkillToolLoader:
assert result['activated'] is True assert result['activated'] is True
assert result['skill_name'] == 'demo' assert result['skill_name'] == 'demo'
assert result['mount_path'] == '/workspace/.skills/demo' assert result['mount_path'] is None
assert result['revision'] == 'sha256:demo'
assert result['capabilities']['resources_readable'] is True
assert result['capabilities']['execution_available'] is False
assert result['activated_skill_names'] == ['demo'] assert result['activated_skill_names'] == ['demo']
assert 'Step 1' in result['content'] assert 'Step 1' in result['content']
assert '<package-root>' not in result['content']
assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'demo'} assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'demo'}
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -384,7 +345,11 @@ class TestSkillToolLoader:
os.makedirs(repo_dir) os.makedirs(repo_dir)
ap = _make_ap() ap = _make_ap()
ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True) ap.box_service = SimpleNamespace(
default_workspace=tmpdir,
available=True,
require_workspace_sandbox=AsyncMock(return_value=_CONTEXT),
)
ap.skill_service = SimpleNamespace( ap.skill_service = SimpleNamespace(
scan_directory_async=AsyncMock( scan_directory_async=AsyncMock(
return_value={ return_value={
@@ -394,7 +359,7 @@ class TestSkillToolLoader:
'instructions': 'Do work', 'instructions': 'Do work',
} }
), ),
create_skill=AsyncMock( import_skill_directory=AsyncMock(
return_value=_make_skill_data(name='cloned-skill', package_root=os.path.realpath(repo_dir)) return_value=_make_skill_data(name='cloned-skill', package_root=os.path.realpath(repo_dir))
), ),
) )
@@ -407,14 +372,14 @@ class TestSkillToolLoader:
) )
ap.skill_service.scan_directory_async.assert_awaited_once_with(_CONTEXT, os.path.realpath(repo_dir)) ap.skill_service.scan_directory_async.assert_awaited_once_with(_CONTEXT, os.path.realpath(repo_dir))
ap.skill_service.create_skill.assert_awaited_once_with( ap.skill_service.import_skill_directory.assert_awaited_once_with(
_CONTEXT, _CONTEXT,
os.path.realpath(repo_dir),
{ {
'name': 'cloned-skill', 'name': 'cloned-skill',
'display_name': 'Cloned Skill', 'display_name': 'Cloned Skill',
'description': 'Imported from clone', 'description': 'Imported from clone',
'instructions': 'Do work', 'instructions': 'Do work',
'package_root': os.path.realpath(repo_dir),
}, },
) )
assert result['registered'] is True assert result['registered'] is True
@@ -430,8 +395,15 @@ class TestSkillToolLoader:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
ap = _make_ap() ap = _make_ap()
ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True) ap.box_service = SimpleNamespace(
ap.skill_service = SimpleNamespace(scan_directory_async=AsyncMock(), create_skill=AsyncMock()) default_workspace=tmpdir,
available=True,
require_workspace_sandbox=AsyncMock(return_value=_CONTEXT),
)
ap.skill_service = SimpleNamespace(
scan_directory_async=AsyncMock(),
import_skill_directory=AsyncMock(),
)
loader = SkillToolLoader(ap) loader = SkillToolLoader(ap)
@@ -451,7 +423,11 @@ class TestSkillToolLoader:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
ap = _make_ap() # no skill_service attribute ap = _make_ap() # no skill_service attribute
ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True) ap.box_service = SimpleNamespace(
default_workspace=tmpdir,
available=True,
require_workspace_sandbox=AsyncMock(return_value=_CONTEXT),
)
loader = SkillToolLoader(ap) loader = SkillToolLoader(ap)
@@ -463,21 +439,22 @@ class TestSkillToolLoader:
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tools_hidden_when_sandbox_backend_unavailable(self): async def test_read_only_tools_remain_when_sandbox_unavailable(self):
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
ap = _make_ap() ap = _make_ap()
ap.skill_mgr = SimpleNamespace(skills={}) ap.skill_mgr = SimpleNamespace(skills={})
ap.box_service = SimpleNamespace( ap.skill_repository = SimpleNamespace()
available=True,
get_backend_status=AsyncMock(return_value={'backend': {'available': False}}),
)
loader = SkillToolLoader(ap) loader = SkillToolLoader(ap)
await loader.initialize() await loader.initialize()
assert await loader.get_tools() == [] assert sorted(tool.name for tool in await loader.get_tools(sandbox_available=False)) == [
assert await loader.has_tool('activate') is False 'activate',
'list_skill_resources',
'read_skill_resource',
]
assert await loader.has_tool('activate') is True
assert await loader.has_tool('register_skill') is False assert await loader.has_tool('register_skill') is False
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -486,38 +463,89 @@ class TestSkillToolLoader:
ap = _make_ap() ap = _make_ap()
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo')}) ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo')})
ap.box_service = SimpleNamespace( ap.skill_repository = SimpleNamespace()
available=True,
get_backend_status=AsyncMock(return_value={'backend': {'available': True}}),
)
loader = SkillToolLoader(ap) loader = SkillToolLoader(ap)
await loader.initialize() await loader.initialize()
tools = await loader.get_tools() tools = await loader.get_tools(sandbox_available=True)
assert sorted(tool.name for tool in tools) == ['activate', 'register_skill'] assert sorted(tool.name for tool in tools) == [
'activate',
'list_skill_resources',
'read_skill_resource',
'register_skill',
]
assert await loader.has_tool('activate') is True assert await loader.has_tool('activate') is True
assert await loader.has_tool('register_skill') is True assert await loader.has_tool('register_skill', sandbox_available=True) is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tools_reappear_after_box_backend_recovers(self): async def test_register_skill_appears_after_sandbox_recovers(self):
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
ap = _make_ap() ap = _make_ap()
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')}) ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')})
ap.box_service = SimpleNamespace( ap.skill_repository = SimpleNamespace()
available=False,
get_backend_status=AsyncMock(return_value={'backend': {'available': True}}),
)
loader = SkillToolLoader(ap) loader = SkillToolLoader(ap)
await loader.initialize() await loader.initialize()
assert await loader.get_tools() == [] assert 'register_skill' not in {tool.name for tool in await loader.get_tools(sandbox_available=False)}
assert 'register_skill' in {tool.name for tool in await loader.get_tools(sandbox_available=True)}
ap.box_service.available = True @pytest.mark.asyncio
async def test_resources_require_activation_and_use_pinned_revision(self):
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
assert sorted(tool.name for tool in await loader.get_tools()) == ['activate', 'register_skill'] skill = _make_skill_data(name='demo', instructions='Read references')
skill['revision'] = 'sha256:demo'
ap = _make_ap()
ap.skill_mgr = _make_skill_manager({'demo': skill})
ap.skill_repository = SimpleNamespace(
list_skill_resources=AsyncMock(
return_value={'entries': [{'path': 'references/a.md'}], 'revision': 'sha256:demo'}
),
read_skill_resource=AsyncMock(
return_value={
'path': 'references/a.md',
'content': 'reference text',
'revision': 'sha256:demo',
'mime_type': 'text/markdown',
}
),
)
loader = SkillToolLoader(ap)
query = _make_query()
with pytest.raises(ValueError, match='must be activated'):
await loader.invoke_tool(
'read_skill_resource',
{'skill_name': 'demo', 'path': 'references/a.md'},
query,
)
register_activated_skill(query, skill)
listed = await loader.invoke_tool('list_skill_resources', {'skill_name': 'demo'}, query)
read = await loader.invoke_tool(
'read_skill_resource',
{'skill_name': 'demo', 'path': 'references/a.md', 'revision': 'sha256:demo'},
query,
)
assert listed['entries'][0]['path'] == 'references/a.md'
assert read['content'] == 'reference text'
ap.skill_repository.list_skill_resources.assert_awaited_once_with(
_CONTEXT,
'demo',
'.',
expected_revision='sha256:demo',
)
ap.skill_repository.read_skill_resource.assert_awaited_once_with(
_CONTEXT,
'demo',
'references/a.md',
expected_revision='sha256:demo',
)
class TestNativeToolLoaderSkillPaths: class TestNativeToolLoaderSkillPaths:
@@ -551,7 +579,7 @@ class TestNativeToolLoaderSkillPaths:
assert result['truncated'] is False assert result['truncated'] is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_external_runtime_read_never_interprets_package_root_on_core_host(self): async def test_external_runtime_read_uses_core_skill_repository(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
@@ -563,7 +591,9 @@ class TestNativeToolLoaderSkillPaths:
ap.box_service = SimpleNamespace( ap.box_service = SimpleNamespace(
available=True, available=True,
shares_filesystem_with_box=False, shares_filesystem_with_box=False,
read_skill_file=AsyncMock(return_value={'content': 'runtime-owned-content'}), )
ap.skill_repository = SimpleNamespace(
read_skill_file=AsyncMock(return_value={'content': 'repository-content'})
) )
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)}) ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
loader = NativeToolLoader(ap) loader = NativeToolLoader(ap)
@@ -579,12 +609,12 @@ class TestNativeToolLoaderSkillPaths:
) )
assert result['ok'] is True assert result['ok'] is True
assert result['content'] == 'runtime-owned-content' assert result['content'] == 'repository-content'
assert 'core-host-secret' not in repr(result) assert 'core-host-secret' not in repr(result)
ap.box_service.read_skill_file.assert_awaited_once_with(_CONTEXT, 'demo', 'SKILL.md') ap.skill_repository.read_skill_file.assert_awaited_once_with(_CONTEXT, 'demo', 'SKILL.md')
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_external_runtime_rejects_skill_host_fallback_without_protocol_capability(self): async def test_core_owned_skill_path_does_not_depend_on_runtime_topology(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
@@ -604,18 +634,20 @@ class TestNativeToolLoaderSkillPaths:
variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}, variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']},
) )
with pytest.raises(ValueError, match='owned by the Box Runtime'): result = await loader.invoke_tool(
await loader.invoke_tool( 'grep',
'grep', {
{ 'path': '/workspace/.skills/demo',
'path': '/workspace/.skills/demo', 'pattern': 'core-host-secret',
'pattern': 'core-host-secret', },
}, query,
query, )
)
assert result['ok'] is True
assert result['total'] == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_exec_in_activated_skill_mount_rewrites_command_and_refreshes(self): async def test_exec_in_activated_skill_mount_rewrites_command_without_mutating_skill(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
@@ -626,11 +658,15 @@ class TestNativeToolLoaderSkillPaths:
default_workspace=tmpdir, default_workspace=tmpdir,
execute_tool=AsyncMock(return_value={'ok': True}), execute_tool=AsyncMock(return_value={'ok': True}),
) )
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock()) skill_data = _make_skill_data(name='demo', package_root=tmpdir)
ap.skill_mgr = _make_skill_manager(
{'demo': skill_data},
refresh_skill_from_disk=Mock(),
)
loader = NativeToolLoader(ap) loader = NativeToolLoader(ap)
query = _make_query(query_id='q1', launcher_type='person', launcher_id='123') query = _make_query(query_id='q1', launcher_type='person', launcher_id='123')
register_activated_skill(query, _make_skill_data(name='demo', package_root=tmpdir)) register_activated_skill(query, skill_data)
result = await loader.invoke_tool( result = await loader.invoke_tool(
'exec', 'exec',
@@ -645,8 +681,8 @@ class TestNativeToolLoaderSkillPaths:
tool_parameters = ap.box_service.execute_tool.await_args.args[0] tool_parameters = ap.box_service.execute_tool.await_args.args[0]
assert tool_parameters['command'] == 'python /workspace/.skills/demo/scripts/run.py' assert tool_parameters['command'] == 'python /workspace/.skills/demo/scripts/run.py'
assert tool_parameters['workdir'] == '/workspace/.skills/demo' assert tool_parameters['workdir'] == '/workspace/.skills/demo'
assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo' assert 'skill_name' not in ap.box_service.execute_tool.await_args.kwargs
ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with(_CONTEXT, 'demo') ap.skill_mgr.refresh_skill_from_disk.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_external_runtime_python_skill_uses_trusted_metadata_and_writable_env(self): async def test_external_runtime_python_skill_uses_trusted_metadata_and_writable_env(self):
@@ -659,17 +695,18 @@ class TestNativeToolLoaderSkillPaths:
shares_filesystem_with_box=False, shares_filesystem_with_box=False,
execute_tool=AsyncMock(return_value={'ok': True}), execute_tool=AsyncMock(return_value={'ok': True}),
) )
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock()) skill_data = _make_skill_data(
name='demo',
package_root='/box-runtime/skills/tenants/workspace/demo',
python_project=True,
)
ap.skill_mgr = _make_skill_manager(
{'demo': skill_data},
refresh_skill_from_disk=Mock(),
)
loader = NativeToolLoader(ap) loader = NativeToolLoader(ap)
query = _make_query(query_id='q-external', launcher_type='person', launcher_id='123') query = _make_query(query_id='q-external', launcher_type='person', launcher_id='123')
register_activated_skill( register_activated_skill(query, skill_data)
query,
_make_skill_data(
name='demo',
package_root='/box-runtime/skills/tenants/workspace/demo',
python_project=True,
),
)
result = await loader.invoke_tool( result = await loader.invoke_tool(
'exec', 'exec',
@@ -686,7 +723,7 @@ class TestNativeToolLoaderSkillPaths:
assert '_LB_VENV_DIR="/workspace/.skill-envs/demo/.venv"' in wrapped assert '_LB_VENV_DIR="/workspace/.skill-envs/demo/.venv"' in wrapped
assert 'root = "/workspace/.skills/demo"' in wrapped assert 'root = "/workspace/.skills/demo"' in wrapped
assert '/box-runtime/skills/tenants/workspace/demo' not in wrapped assert '/box-runtime/skills/tenants/workspace/demo' not in wrapped
assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo' assert 'skill_name' not in ap.box_service.execute_tool.await_args.kwargs
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_write_requires_skill_activation(self): async def test_write_requires_skill_activation(self):
@@ -55,10 +55,20 @@ class StubLoader:
for tool in self._tools for tool in self._tools
] ]
async def has_tool(self, *args) -> bool: async def get_tool(self, name: str, **_kwargs):
return next((tool for tool in self._tools if tool.name == name), None)
async def has_tool(self, *args, **_kwargs) -> bool:
name = args[-1] name = args[-1]
return any(tool.name == name for tool in self._tools) return any(tool.name == name for tool in self._tools)
def recognizes_tool(self, name: str) -> bool:
return any(tool.name == name for tool in self._tools)
@staticmethod
def is_sandbox_tool(name: str) -> bool:
return name == 'register_skill'
async def invoke_tool(self, name: str, parameters: dict, query): async def invoke_tool(self, name: str, parameters: dict, query):
return self._invoke_result(name, parameters, query) if callable(self._invoke_result) else self._invoke_result return self._invoke_result(name, parameters, query) if callable(self._invoke_result) else self._invoke_result
@@ -145,7 +155,7 @@ async def test_tool_manager_routes_native_tool_calls():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tool_manager_hides_sandbox_and_skill_tools_without_workspace_entitlement(): async def test_tool_manager_keeps_read_only_skill_tools_without_workspace_entitlement():
box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False)) box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
manager = ToolManager(SimpleNamespace(box_service=box_service)) manager = ToolManager(SimpleNamespace(box_service=box_service))
manager.native_tool_loader = StubLoader([make_tool('exec')]) manager.native_tool_loader = StubLoader([make_tool('exec')])
@@ -156,8 +166,8 @@ async def test_tool_manager_hides_sandbox_and_skill_tools_without_workspace_enti
tools = await manager.get_all_tools(_CONTEXT, include_skill_authoring=True) tools = await manager.get_all_tools(_CONTEXT, include_skill_authoring=True)
catalog = await manager.get_tool_catalog(_CONTEXT, include_skill_authoring=True) catalog = await manager.get_tool_catalog(_CONTEXT, include_skill_authoring=True)
assert [tool.name for tool in tools] == ['plugin_tool', 'mcp_tool'] assert [tool.name for tool in tools] == ['activate', 'plugin_tool', 'mcp_tool']
assert [item['name'] for item in catalog] == ['plugin_tool', 'mcp_tool'] assert [item['name'] for item in catalog] == ['activate', 'plugin_tool', 'mcp_tool']
assert box_service.is_workspace_sandbox_available.await_count == 2 assert box_service.is_workspace_sandbox_available.await_count == 2
@@ -176,9 +186,10 @@ async def test_tool_manager_rechecks_workspace_entitlement_before_native_invocat
query_uuid=None, query_uuid=None,
) )
with pytest.raises(Exception, match='exec'): result = await manager.execute_func_call('exec', {'command': 'pwd'}, query=query)
await manager.execute_func_call('exec', {'command': 'pwd'}, query=query)
assert result['code'] == 'sandbox_unavailable'
assert result['tool'] == 'exec'
box_service.is_workspace_sandbox_available.assert_awaited_once_with(_CONTEXT) box_service.is_workspace_sandbox_available.assert_awaited_once_with(_CONTEXT)
+17
View File
@@ -190,6 +190,7 @@ async def test_preproc_injects_skill_index_into_system_prompt():
preproc_module, entities_module = _import_preproc_modules() preproc_module, entities_module = _import_preproc_modules()
app = _make_app(skill_service=SimpleNamespace()) app = _make_app(skill_service=SimpleNamespace())
app.tool_mgr.get_all_tools.return_value = [SimpleNamespace(name='activate')]
addendum = '\n\nAvailable Skills:\n- demo (demo): Demo skill.\n\nCall activate ...' addendum = '\n\nAvailable Skills:\n- demo (demo): Demo skill.\n\nCall activate ...'
app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value=addendum) app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value=addendum)
@@ -204,6 +205,22 @@ async def test_preproc_injects_skill_index_into_system_prompt():
head = query.prompt.messages[0] head = query.prompt.messages[0]
assert head.role == 'system' assert head.role == 'system'
assert head.content.endswith(addendum) assert head.content.endswith(addendum)
assert query.variables['_skill_execution_available'] is False
@pytest.mark.asyncio
async def test_preproc_does_not_advertise_activation_when_tool_is_filtered_out():
preproc_module, entities_module = _import_preproc_modules()
app = _make_app(skill_service=SimpleNamespace())
addendum = '\n\nAvailable Skills:\n- demo (demo): Demo skill.\n\nCall activate ...'
app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value=addendum)
query = _make_query()
result = await stage_process_capture(preproc_module, app, query)
assert result.result_type == entities_module.ResultType.CONTINUE
assert addendum not in query.prompt.messages[0].content
@pytest.mark.asyncio @pytest.mark.asyncio
+209
View File
@@ -0,0 +1,209 @@
from types import SimpleNamespace
import pytest
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.skill.repository import SkillRepository, SkillRevisionMismatchError
_CONTEXT = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
async def _binding(workspace_uuid, *, expected_generation):
return SimpleNamespace(
instance_uuid=_CONTEXT.instance_uuid,
workspace_uuid=workspace_uuid,
placement_generation=expected_generation,
)
def _repository(tmp_path) -> SkillRepository:
app = SimpleNamespace(
workspace_service=SimpleNamespace(
get_execution_binding=_binding,
),
instance_config=SimpleNamespace(
data={
'skills': {'root': str(tmp_path / 'skill-store')},
'box': {
'enabled': False,
'local': {
'host_root': str(tmp_path / 'box'),
},
},
}
),
)
return SkillRepository(app)
def test_repository_prefers_standalone_skill_root(tmp_path):
repository = _repository(tmp_path)
assert repository._store.root == str((tmp_path / 'skill-store').resolve())
def test_repository_locks_are_workspace_scoped(tmp_path):
repository = _repository(tmp_path)
first = repository._workspace_lock('workspace-a')
assert repository._workspace_lock('workspace-a') is first
assert repository._workspace_lock('workspace-b') is not first
def test_repository_keeps_old_box_root_only_for_online_upgrade(tmp_path):
app = SimpleNamespace(
workspace_service=SimpleNamespace(get_execution_binding=_binding),
instance_config=SimpleNamespace(
data={
'box': {
'local': {
'host_root': str(tmp_path / 'box'),
'skills_root': 'legacy-skills',
}
}
}
),
)
repository = SkillRepository(app)
assert repository._store.root == str((tmp_path / 'box' / 'legacy-skills').resolve())
@pytest.mark.asyncio
async def test_repository_crud_and_reads_do_not_require_box(tmp_path):
repository = _repository(tmp_path)
await repository.create_skill(
_CONTEXT,
{
'name': 'docs-only',
'display_name': 'Docs only',
'description': 'Read-only guidance',
'instructions': 'Read references/guide.md.',
},
)
await repository.write_skill_file(
_CONTEXT,
'docs-only',
'references/guide.md',
'# Guide\n\nNo execution needed.',
)
skill = await repository.get_skill(_CONTEXT, 'docs-only', snapshot=True)
assert skill is not None
assert skill['revision'].startswith('stat-v1:')
assert [item['name'] for item in await repository.list_skills(_CONTEXT)] == ['docs-only']
listed = await repository.list_skill_resources(
_CONTEXT,
'docs-only',
'references',
expected_revision=skill['revision'],
)
assert listed['entries'][0]['path'] == 'references/guide.md'
assert listed['entries'][0]['mime_type'] == 'text/markdown'
resource = await repository.read_skill_resource(
_CONTEXT,
'docs-only',
'references/guide.md',
expected_revision=skill['revision'],
)
assert resource['content'].startswith('# Guide')
assert resource['revision'] == skill['revision']
@pytest.mark.asyncio
async def test_repository_rejects_traversal_and_stale_revision(tmp_path):
repository = _repository(tmp_path)
await repository.create_skill(
_CONTEXT,
{'name': 'safe', 'description': 'Safe', 'instructions': 'Use the reference.'},
)
await repository.write_skill_file(_CONTEXT, 'safe', 'reference.md', 'first')
skill = await repository.get_skill(_CONTEXT, 'safe', snapshot=True)
assert skill is not None
with pytest.raises(ValueError, match='stay within'):
await repository.read_skill_resource(_CONTEXT, 'safe', '../secret.txt')
await repository.write_skill_file(_CONTEXT, 'safe', 'reference.md', 'second')
with pytest.raises(SkillRevisionMismatchError, match='reactivate'):
await repository.read_skill_resource(
_CONTEXT,
'safe',
'reference.md',
expected_revision=skill['revision'],
)
@pytest.mark.asyncio
async def test_repository_scopes_skills_by_workspace(tmp_path):
repository = _repository(tmp_path)
other_context = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-b',
placement_generation=1,
)
await repository.create_skill(_CONTEXT, {'name': 'private', 'instructions': 'A'})
assert [skill['name'] for skill in await repository.list_skills(_CONTEXT)] == ['private']
assert await repository.list_skills(other_context) == []
@pytest.mark.asyncio
async def test_repository_rejects_stale_workspace_placement(tmp_path):
repository = _repository(tmp_path)
async def stale_binding(workspace_uuid, *, expected_generation):
return SimpleNamespace(
instance_uuid=_CONTEXT.instance_uuid,
workspace_uuid=workspace_uuid,
placement_generation=expected_generation + 1,
)
repository.ap.workspace_service.get_execution_binding = stale_binding
with pytest.raises(ValueError, match='stale Workspace placement'):
await repository.list_skills(_CONTEXT)
@pytest.mark.asyncio
async def test_repository_imports_only_from_the_fenced_workspace(tmp_path):
repository = _repository(tmp_path)
namespace = repository._namespace(_CONTEXT)
source = tmp_path / 'box' / 'default' / 'tenants' / namespace / 'draft'
source.mkdir(parents=True)
(source / 'SKILL.md').write_text(
'---\nname: draft\ndescription: Draft skill\n---\n\nFollow the guide.',
encoding='utf-8',
)
(source / 'guide.md').write_text('Imported resource', encoding='utf-8')
scanned = await repository.scan_skill_directory(_CONTEXT, str(source))
imported = await repository.import_skill_directory(
_CONTEXT,
str(source),
{
'name': scanned['name'],
'display_name': scanned['display_name'],
'description': scanned['description'],
'instructions': scanned['instructions'],
},
)
assert imported['name'] == 'draft'
resource = await repository.read_skill_file(_CONTEXT, 'draft', 'guide.md')
assert resource['content'] == 'Imported resource'
outside = tmp_path / 'outside'
outside.mkdir()
(outside / 'SKILL.md').write_text('Outside', encoding='utf-8')
with pytest.raises(ValueError, match='trusted source root'):
await repository.scan_skill_directory(_CONTEXT, str(outside))
+46 -75
View File
@@ -23,102 +23,73 @@ def _workspace_service():
) )
class TestRequireBoxForWrite: class TestSkillRepositoryBoundary:
"""Box is the only source of truth for skills — there is no local """Skill management and reads remain available without Box execution."""
filesystem fallback. Every write and (most) read methods refuse cleanly
when the Box runtime is disabled, unreachable, or simply not installed."""
def _ap_with_disabled_box(self): @staticmethod
def _ap_with_repository():
repository = SimpleNamespace(
list_skills=AsyncMock(return_value=[{'name': 'x', 'instructions': 'Do work'}]),
get_skill=AsyncMock(return_value={'name': 'x', 'instructions': 'Do work', 'revision': 'sha256:x'}),
create_skill=AsyncMock(return_value={'name': 'x', 'instructions': 'Do work'}),
update_skill=AsyncMock(return_value={'name': 'x', 'instructions': 'Updated'}),
delete_skill=AsyncMock(),
read_skill_file=AsyncMock(return_value={'path': 'a.txt', 'content': 'hello'}),
write_skill_file=AsyncMock(return_value={'path': 'a.txt'}),
)
return SimpleNamespace( return SimpleNamespace(
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()), skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
workspace_service=_workspace_service(), workspace_service=_workspace_service(),
box_service=SimpleNamespace( box_service=SimpleNamespace(available=False, enabled=False),
available=False, skill_repository=repository,
enabled=False,
_connector_error='Box runtime is disabled in config (box.enabled = false)',
),
)
def _ap_with_failed_box(self):
return SimpleNamespace(
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
workspace_service=_workspace_service(),
box_service=SimpleNamespace(
available=False,
enabled=True,
_connector_error='docker daemon not running',
),
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_skill_refused_when_box_disabled(self): async def test_list_and_read_work_when_box_disabled(self):
service = SkillService(self._ap_with_disabled_box()) ap = self._ap_with_repository()
with pytest.raises(ValueError, match='disabled in config'): service = SkillService(ap)
await service.create_skill(_CONTEXT, {'name': 'x'})
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 @pytest.mark.asyncio
async def test_create_skill_refused_when_box_failed(self): async def test_create_update_and_write_work_when_box_disabled(self):
service = SkillService(self._ap_with_failed_box()) ap = self._ap_with_repository()
with pytest.raises(ValueError, match='docker daemon not running'): service = SkillService(ap)
await service.create_skill(_CONTEXT, {'name': 'x'})
await service.create_skill(_CONTEXT, {'name': 'x'})
await service.update_skill(_CONTEXT, 'x', {'instructions': 'Updated'})
await service.write_skill_file(_CONTEXT, 'x', 'a.txt', 'hello')
ap.skill_repository.create_skill.assert_awaited_once_with(_CONTEXT, {'name': 'x'})
ap.skill_repository.update_skill.assert_awaited_once_with(_CONTEXT, 'x', {'instructions': 'Updated'})
ap.skill_repository.write_skill_file.assert_awaited_once_with(_CONTEXT, 'x', 'a.txt', 'hello')
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_skill_refused_when_box_disabled(self): async def test_get_skill_returns_repository_revision(self):
service = SkillService(self._ap_with_disabled_box()) ap = self._ap_with_repository()
with pytest.raises(ValueError, match='Editing a skill requires the Box runtime'): service = SkillService(ap)
await service.update_skill(_CONTEXT, 'x', {})
skill = await service.get_skill(_CONTEXT, 'x')
assert skill['revision'] == 'sha256:x'
ap.skill_repository.get_skill.assert_awaited_once_with(_CONTEXT, 'x', snapshot=True)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_write_skill_file_refused_when_box_disabled(self): async def test_missing_repository_is_explicit(self):
service = SkillService(self._ap_with_disabled_box())
with pytest.raises(ValueError, match='Editing skill files requires the Box runtime'):
await service.write_skill_file(_CONTEXT, 'x', 'a.txt', 'hi')
@pytest.mark.asyncio
async def test_install_from_github_refused_when_box_disabled(self):
service = SkillService(self._ap_with_disabled_box())
with pytest.raises(ValueError, match='Installing a skill from GitHub'):
await service.install_from_github(
_CONTEXT,
{'owner': 'o', 'repo': 'r', 'asset_url': 'https://example/x.zip'},
)
@pytest.mark.asyncio
async def test_install_from_zip_upload_refused_when_box_disabled(self):
service = SkillService(self._ap_with_disabled_box())
with pytest.raises(ValueError, match='Installing a skill from upload'):
await service.install_from_zip_upload(
_CONTEXT,
file_bytes=b'',
filename='x.zip',
)
@pytest.mark.asyncio
async def test_create_skill_refused_when_box_service_missing_entirely(self):
"""No ap.box_service attribute at all (truly minimal setup):
Box is the only source of truth, so creation must still refuse."""
service = SkillService( service = SkillService(
SimpleNamespace( SimpleNamespace(
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()), skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
workspace_service=_workspace_service(), workspace_service=_workspace_service(),
) )
) )
with pytest.raises(ValueError, match='not initialised'): with pytest.raises(ValueError, match='repository is not initialised'):
await service.create_skill(_CONTEXT, {'name': 'x'}) await service.create_skill(_CONTEXT, {'name': 'x'})
@pytest.mark.asyncio
async def test_list_skills_returns_empty_when_box_unavailable(self):
"""list_skills should render an empty surface (not crash) so the
skills page can show a banner instead of a broken state."""
service = SkillService(self._ap_with_disabled_box())
assert await service.list_skills(_CONTEXT) == []
@pytest.mark.asyncio
async def test_read_skill_file_refused_when_box_unavailable(self):
service = SkillService(self._ap_with_disabled_box())
with pytest.raises(ValueError, match='Reading a skill file'):
await service.read_skill_file(_CONTEXT, 'x', 'a.txt')
class TestGithubSkillArchiveLimits: class TestGithubSkillArchiveLimits:
@staticmethod @staticmethod
@@ -113,7 +113,7 @@ async def test_invitation_email_uses_quiet_brand_lockup_and_compact_fallback_lin
html = service._html("RockChinQ's Workspace", link) html = service._html("RockChinQ's Workspace", link)
assert 'https://docs.langbot.app/langbot-logo.png' in html assert 'https://langbot.app/docs/langbot-logo.png' in html
assert '>LangBot<' in html assert '>LangBot<' in html
assert 'Workspace invitation' in html assert 'Workspace invitation' in html
assert 'Open invitation link' in html assert 'Open invitation link' in html
Generated
+5 -5
View File
@@ -2008,7 +2008,7 @@ wheels = [
[[package]] [[package]]
name = "langbot" name = "langbot"
version = "4.10.9" version = "4.10.10"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiocqhttp" }, { name = "aiocqhttp" },
@@ -2129,7 +2129,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" }, { name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" }, { name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" }, { name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.5.6" }, { name = "langbot-plugin", specifier = "==0.5.7" },
{ name = "langchain", specifier = ">=1.3.9" }, { name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2196,7 +2196,7 @@ dev = [
[[package]] [[package]]
name = "langbot-plugin" name = "langbot-plugin"
version = "0.5.6" version = "0.5.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
@@ -2217,9 +2217,9 @@ dependencies = [
{ name = "watchdog" }, { name = "watchdog" },
{ name = "websockets" }, { name = "websockets" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/0b/1b/0c2e1f457abedf7ce052f47ad193937322b5f25f4e09e35d92bb5bd0346f/langbot_plugin-0.5.6.tar.gz", hash = "sha256:b7d6bb170ceffead6929e8d95ac388dd9a90a6d971ec4fcdaf7f7b46e894fa9e", size = 475814, upload-time = "2026-08-31T16:04:51.604Z" } sdist = { url = "https://files.pythonhosted.org/packages/d2/7d/b024770f1f52c9dc71ddcab79fc07dfb6147ce8e645f0fed170d758e49cb/langbot_plugin-0.5.7.tar.gz", hash = "sha256:faecd566b7ff57dc5f3a5b1be01e2165d25924031c0a65a829c83b51c65255ee", size = 480635, upload-time = "2026-09-04T13:39:22.505Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/40/1bb5d3562f66c88ac45b3b5b6ee77e9f8a6943599aea95731ea4a4e8b005/langbot_plugin-0.5.6-py3-none-any.whl", hash = "sha256:8f35a07be667abeb84147c4299d7afcc394125c73455fc74d9fcc887eae3a7d4", size = 306108, upload-time = "2026-08-31T16:04:50.427Z" }, { url = "https://files.pythonhosted.org/packages/cd/25/416745039cacace6a0ca3f719a2eff41dc74cdb30ef7ffaec1de0142bd2e/langbot_plugin-0.5.7-py3-none-any.whl", hash = "sha256:b1a20bcb6a2d482019eafbfe0ac628c106b8e915c7afe89df057b4d8e2015f05", size = 310463, upload-time = "2026-09-04T13:39:21.18Z" },
] ]
[[package]] [[package]]
+1 -1
View File
@@ -1,6 +1,6 @@
# Debug LangBot Frontend # Debug LangBot Frontend
Please refer to the [Development Guide](https://link.langbot.app/en/docs/dev-config) for more information. Please refer to the [Development Guide](https://langbot.app/docs/en/develop/dev-config) for more information.
## Tests ## Tests
+15 -3
View File
@@ -43,17 +43,24 @@ const pendingSpaceOAuthLogins = new Map<
function getOrCreateSpaceOAuthLoginPromise( function getOrCreateSpaceOAuthLoginPromise(
authCode: string, authCode: string,
state: string, state: string,
redirectUri: string,
workspaceUuid?: string, workspaceUuid?: string,
launchAssertion?: string, launchAssertion?: string,
): Promise<SpaceOAuthLoginResult> { ): Promise<SpaceOAuthLoginResult> {
const requestKey = `${authCode}:${state}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`; const requestKey = `${authCode}:${state}:${redirectUri}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`;
const pendingRequest = pendingSpaceOAuthLogins.get(requestKey); const pendingRequest = pendingSpaceOAuthLogins.get(requestKey);
if (pendingRequest) { if (pendingRequest) {
return pendingRequest; return pendingRequest;
} }
const requestPromise = httpClient const requestPromise = httpClient
.exchangeSpaceOAuthCode(authCode, state, workspaceUuid, launchAssertion) .exchangeSpaceOAuthCode(
authCode,
state,
redirectUri,
workspaceUuid,
launchAssertion,
)
.finally(() => { .finally(() => {
pendingSpaceOAuthLogins.delete(requestKey); pendingSpaceOAuthLogins.delete(requestKey);
}); });
@@ -95,6 +102,7 @@ function SpaceOAuthCallbackContent() {
const response = await getOrCreateSpaceOAuthLoginPromise( const response = await getOrCreateSpaceOAuthLoginPromise(
authCode, authCode,
state, state,
`${window.location.origin}/auth/space/callback`,
workspaceUuid, workspaceUuid,
launchAssertion, launchAssertion,
); );
@@ -195,7 +203,11 @@ function SpaceOAuthCallbackContent() {
async (authCode: string, state: string) => { async (authCode: string, state: string) => {
setIsProcessing(true); setIsProcessing(true);
try { try {
const response = await httpClient.bindSpaceAccount(authCode, state); const response = await httpClient.bindSpaceAccount(
authCode,
state,
`${window.location.origin}/auth/space/callback?mode=bind`,
);
if (!isMountedRef.current) { if (!isMountedRef.current) {
return; return;
} }
@@ -0,0 +1,22 @@
const COMMON_N8N_CONFIG_FIELDS = new Set([
'webhook-url',
'auth-type',
'timeout',
'output-key',
'response-handling',
]);
export function shouldShowN8nConfigField(
fieldName: string,
authType: string,
): boolean {
if (COMMON_N8N_CONFIG_FIELDS.has(fieldName)) {
return true;
}
return (
(authType === 'basic' && fieldName.startsWith('basic-')) ||
(authType === 'jwt' && fieldName.startsWith('jwt-')) ||
(authType === 'header' && fieldName.startsWith('header-'))
);
}
@@ -13,6 +13,7 @@ import {
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues'; import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
import { shouldShowN8nConfigField } from '@/app/home/components/dynamic-form/N8nAuthFieldVisibility';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
/** /**
@@ -181,29 +182,9 @@ export default function N8nAuthFormComponent({
}, [form, itemConfigList]); }, [form, itemConfigList]);
// 根据认证类型过滤表单项 // 根据认证类型过滤表单项
const filteredConfigList = itemConfigList.filter((config) => { const filteredConfigList = itemConfigList.filter((config) =>
// 始终显示webhook-url、auth-type、timeout和output-key shouldShowN8nConfigField(config.name, authType),
if ( );
['webhook-url', 'auth-type', 'timeout', 'output-key'].includes(
config.name,
)
) {
return true;
}
// 根据认证类型显示相应的表单项
if (authType === 'basic' && config.name.startsWith('basic-')) {
return true;
}
if (authType === 'jwt' && config.name.startsWith('jwt-')) {
return true;
}
if (authType === 'header' && config.name.startsWith('header-')) {
return true;
}
return false;
});
return ( return (
<Form {...form}> <Form {...form}>
@@ -2195,12 +2195,12 @@ export default function HomeSidebar({
localStorage.getItem('langbot_language'); localStorage.getItem('langbot_language');
if (language === 'zh-Hans' || language === 'zh-Hant') { if (language === 'zh-Hans' || language === 'zh-Hant') {
window.open( window.open(
'https://link.langbot.app/zh/docs/guide', 'https://langbot.app/docs/zh/insight/guide',
'_blank', '_blank',
); );
} else { } else {
window.open( window.open(
'https://link.langbot.app/en/docs/guide', 'https://langbot.app/docs/en/insight/guide',
'_blank', '_blank',
); );
} }
@@ -49,9 +49,9 @@ export const sidebarConfigList = [
route: '/home/bots', route: '/home/bots',
description: t('bots.description'), description: t('bots.description'),
helpLink: { helpLink: {
en_US: 'https://link.langbot.app/en/docs/platforms', en_US: 'https://langbot.app/docs/en/usage/platforms/readme',
zh_Hans: 'https://link.langbot.app/zh/docs/platforms', zh_Hans: 'https://langbot.app/docs/zh/usage/platforms/readme',
ja_JP: 'https://link.langbot.app/ja/docs/platforms', ja_JP: 'https://langbot.app/docs/ja/usage/platforms/readme',
}, },
section: 'home', section: 'home',
}), }),
@@ -62,9 +62,9 @@ export const sidebarConfigList = [
route: '/home/pipelines', route: '/home/pipelines',
description: t('pipelines.description'), description: t('pipelines.description'),
helpLink: { helpLink: {
en_US: 'https://link.langbot.app/en/docs/pipelines', en_US: 'https://langbot.app/docs/en/usage/pipelines/readme',
zh_Hans: 'https://link.langbot.app/zh/docs/pipelines', zh_Hans: 'https://langbot.app/docs/zh/usage/pipelines/readme',
ja_JP: 'https://link.langbot.app/ja/docs/pipelines', ja_JP: 'https://langbot.app/docs/ja/usage/pipelines/readme',
}, },
section: 'home', section: 'home',
}), }),
@@ -75,9 +75,9 @@ export const sidebarConfigList = [
route: '/home/knowledge', route: '/home/knowledge',
description: t('knowledge.description'), description: t('knowledge.description'),
helpLink: { helpLink: {
en_US: 'https://link.langbot.app/en/docs/knowledge', en_US: 'https://langbot.app/docs/en/usage/knowledge/readme',
zh_Hans: 'https://link.langbot.app/zh/docs/knowledge', zh_Hans: 'https://langbot.app/docs/zh/usage/knowledge/readme',
ja_JP: 'https://link.langbot.app/ja/docs/knowledge', ja_JP: 'https://langbot.app/docs/ja/usage/knowledge/readme',
}, },
section: 'home', section: 'home',
}), }),
@@ -89,9 +89,9 @@ export const sidebarConfigList = [
route: '/home/extensions', route: '/home/extensions',
description: t('plugins.description'), description: t('plugins.description'),
helpLink: { helpLink: {
en_US: 'https://docs.langbot.app/en/plugin/plugin-intro', en_US: 'https://langbot.app/docs/en/plugin/plugin-intro',
zh_Hans: 'https://docs.langbot.app/zh/plugin/plugin-intro', zh_Hans: 'https://langbot.app/docs/zh/plugin/plugin-intro',
ja_JP: 'https://docs.langbot.app/ja/plugin/plugin-intro', ja_JP: 'https://langbot.app/docs/ja/plugin/plugin-intro',
}, },
section: 'extensions', section: 'extensions',
}), }),
@@ -102,9 +102,9 @@ export const sidebarConfigList = [
route: '/home/add-extension', route: '/home/add-extension',
description: t('plugins.description'), description: t('plugins.description'),
helpLink: { helpLink: {
en_US: 'https://docs.langbot.app/en/plugin/plugin-intro', en_US: 'https://langbot.app/docs/en/plugin/plugin-intro',
zh_Hans: 'https://docs.langbot.app/zh/plugin/plugin-intro', zh_Hans: 'https://langbot.app/docs/zh/plugin/plugin-intro',
ja_JP: 'https://docs.langbot.app/ja/plugin/plugin-intro', ja_JP: 'https://langbot.app/docs/ja/plugin/plugin-intro',
}, },
section: 'extensions', section: 'extensions',
}), }),
@@ -34,11 +34,11 @@ export default function NewVersionDialog({
const getUpdateDocsUrl = () => { const getUpdateDocsUrl = () => {
const language = i18n.language; const language = i18n.language;
if (language === 'zh-Hans' || language === 'zh-Hant') { if (language === 'zh-Hans' || language === 'zh-Hant') {
return 'https://link.langbot.app/zh/docs/update'; return 'https://langbot.app/docs/zh/deploy/update';
} else if (language === 'ja-JP') { } else if (language === 'ja-JP') {
return 'https://link.langbot.app/ja/docs/update'; return 'https://langbot.app/docs/ja/deploy/update';
} else { } else {
return 'https://link.langbot.app/en/docs/update'; return 'https://langbot.app/docs/en/deploy/update';
} }
}; };

Some files were not shown because too many files have changed in this diff Show More