Compare commits

...

35 Commits

Author SHA1 Message Date
TyperBody a40051daf1 feat(auth): add TOTP (RFC 6238) second factor with recovery codes
- store the per-Account shared secret encrypted at rest (Fernet keyed off
  the instance JWT secret via HKDF); never persist it in plaintext
- store recovery codes only as salted PBKDF2-HMAC-SHA256 digests
- add TotpService covering enrol / verify / disable and recovery-code use
- expose the login second-factor challenge (code `totp_required`) and the
  recovery-code path in the auth / reset flows
- add the `totp_credentials` migration (0025, revises 0024_passkey_credentials)
- web: TOTP challenge step on login, TOTP / recovery-code methods on
  reset-password, TotpEnrollDialog in account settings, i18n for all locales
2026-09-13 00:01:54 +08:00
huanghuoguoguo d26d0635c5 fix(vector): correct SeekDB adapter semantics (#2536) 2026-09-12 19:40:30 +08:00
彼方 58cde8c022 Merge pull request #2534 from langbot-app/fix/i18n-passkey-keys
fix(i18n): complete passkey keys across all locale files
2026-09-12 16:47:27 +08:00
彼方 9eb8683997 Merge pull request #2530 from langbot-app/feat/passkey-login
feat(auth): support passkey (webauthn) login and credential management
2026-09-12 16:00:10 +08:00
BiFangKNT 19526e1400 test(api): define explicit fixtures for passkey integration tests 2026-09-12 15:51:56 +08:00
BiFangKNT dfde9578c1 fix(ci): fix ruff lint errors and postgres legacy migration table exclusion 2026-09-12 15:35:47 +08:00
Hyu ec5b8cc8a8 docs(space): sync Runner usage recommendation contract (#2533)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-12 13:37:14 +08:00
Hyu 137bb4fdb3 fix(ci): recover immutable release PyPI builds (#2532)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-12 12:50:37 +08:00
Hyu 273b8839b9 chore(release): prepare LangBot 4.10.11 (#2531)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-12 12:33:26 +08:00
BiFangKNT 9db6650274 style(tests): Remove unused time import from test file 2026-09-12 12:26:51 +08:00
BiFangKNT b594cf23e4 feat(auth): add webauthn authentication support 2026-09-12 12:17:26 +08:00
Hyu 45d77c3926 fix(plugin): preserve explicit nested installation scope (#2528)
* fix(plugin): preserve explicit nested installation scope

* fix(deps): pin released RAG runtime SDK 0.5.8

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-11 17:41:33 +08:00
Hyu 1ea9cd3f6f fix(pipelines): show the actual sandbox scope restriction (#2527)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-11 14:42:38 +08:00
Hyu ff6ad6adc2 fix(monitoring): restore Cloud messages and bot-scoped sessions (#2526)
* fix(monitoring): restore Cloud message persistence and bot-scoped sessions

* fix(migrations): support partial monitoring schemas and align regression fixtures

* test(migrations): complete raw bot session fixture values

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-11 14:34:37 +08:00
huige66631 ce6b647fe7 feat(mcp): detect OAuth-protected remote MCP servers (#2363)
* feat(mcp): surface OAuth-required server tests

* fix(mcp): show connection failure details in status cards

---------

Co-authored-by: RockChinQ <rockchinq@gmail.com>
2026-09-09 16:13:33 +08:00
WODE25500 485113ae43 fix: default Langflow tweaks to empty object when unset (#2519)
Co-authored-by: WODE25500 <318555974+WODE25500@users.noreply.github.com>
2026-09-09 15:46:26 +08:00
Tynwink 1ba3c1ec72 Merge pull request #2520 from langbot-app/feat/api-key-system-context
为 lbctl 增加 API Key 发现与管理能力
当前为 lbctl 提供的能力通过 system.py -> SYSTEM_CAPABILITY_OPERATIONS 维护,后续可通过统一的接口暴露能力,避免频繁维护常量。
2026-09-09 14:47:00 +08:00
Tynwink 3a82aa5fcc Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-09-09 14:39:49 +08:00
WODE25500 fc1c998434 fix: Matrix relogin UnboundLocalError (#2516)
Co-authored-by: WODE25500 <318555974+WODE25500@users.noreply.github.com>
2026-09-09 13:51:42 +08:00
Tynwink2000 d90253cc77 feat(api): advertise provider and model management 2026-09-09 11:18:01 +08:00
Tynwink2000 d8b3dad212 feat(api): support explicit provider secret projection 2026-09-09 10:38:16 +08:00
Tynwink2000 8281eb18c9 feat(api): advertise plugin and skill management 2026-09-09 00:12:40 +08:00
Tynwink2000 4eea3419e8 feat(api): advertise knowledge and MCP management 2026-09-08 23:07:43 +08:00
Tynwink2000 814740ea68 feat(api): advertise managed read operations 2026-09-08 18:53:55 +08:00
Tynwink2000 e6e8258545 feat(api): advertise extension operation capabilities 2026-09-08 17:43:11 +08:00
Tynwink2000 1a69747a06 feat(api): expose task status to api keys 2026-09-08 14:06:13 +08:00
Tynwink2000 1fa5e2f755 feat(api): add system capabilities endpoint 2026-09-08 13:03:02 +08:00
fishzjp 267232c24f fix(security): harden password recovery with usable eight-character codes (#2477)
Use eight securely random recovery-code characters with concurrency-safe online throttling. Preserve existing keys and verify recovery through browser and real SQLite integration tests.

Co-authored-by: zhangjinpeng@mail.tuchong.com <zhangjinpeng@mail.tuchong.com>
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-07 15:31:54 +00:00
QuasarRyan d6443b10bc feat(platform): add Mattermost platform adapter (#2515) 2026-09-07 22:46:47 +08:00
Tynwink 0577689da4 Merge pull request #2517 from langbot-app/feat/api-key-system-context
feat(api): add system context endpoint for lbctl
2026-09-07 14:51:31 +08:00
Tynwink2000 bc32eb3ca0 feat(api): add system context endpoint for lbctl 2026-09-07 14:22:27 +08:00
Hyu 0f216a0d4d feat(provider): support Codex subscriptions with ChatGPT sign-in (#2513)
* feat(provider): support Codex subscriptions with ChatGPT sign-in

* style: format Codex live integration test

* fix(provider): preserve Codex identity in temporary model tests

* fix(web): portal provider selector without dialog overflow

* fix(web): allow native scrolling in provider dropdown

* fix(provider): surface safe Codex quota and upstream errors

* fix(web): provide reliable Codex copy feedback in dialogs

* feat(provider): confirm cascade deletion from edit dialog

* fix(persistence): discard connections after failed commit

* fix(web): polish provider loading and confirmation motion

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-06 23:31:02 +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
196 changed files with 15410 additions and 1497 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
name: 漏洞反馈
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://langbot.app/docs/zh/workshop/network-details
title: "[Bug]: "
labels: ["bug?"]
body:
@@ -22,7 +22,7 @@ body:
- type: textarea
attributes:
label: 异常情况
description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。**
description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。**
validations:
required: true
- type: textarea
+1 -1
View File
@@ -1,5 +1,5 @@
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]: "
labels: ["bug?"]
body:
+35 -4
View File
@@ -2,6 +2,11 @@ name: Build and Publish to PyPI
on:
workflow_dispatch:
inputs:
source_ref:
description: 'Existing release tag to publish (for example v4.10.11)'
required: true
type: string
release:
types: [published]
@@ -11,13 +16,39 @@ jobs:
permissions:
contents: read
id-token: write # Required for trusted publishing to PyPI
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ inputs.source_ref || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Validate release source and version
env:
RELEASE_TAG: ${{ inputs.source_ref || github.event.release.tag_name }}
run: |
python3 - <<'PY'
import os
import re
import subprocess
import tomllib
from pathlib import Path
tag = os.environ['RELEASE_TAG']
if not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', tag):
raise SystemExit('source_ref must be an existing release tag: vX.Y.Z')
def revision(ref):
return subprocess.check_output(['git', 'rev-parse', '--verify', ref], text=True).strip()
if revision('HEAD') != revision(f'refs/tags/{tag}^{{}}'):
raise SystemExit('Checked-out commit does not match the release tag')
version = tomllib.loads(Path('pyproject.toml').read_text())['project']['version']
if version != tag[1:]:
raise SystemExit(f'Package version {version} does not match tag {tag}')
print(f'Validated {tag} at {revision("HEAD")} (package {version})')
PY
- name: Set up Node.js
uses: actions/setup-node@v4
with:
@@ -26,9 +57,9 @@ jobs:
- name: Build frontend
run: |
cd web
npm install -g pnpm
pnpm install
pnpm build
# Match the archive/Docker npm path; npm ci rejects older tags' stale npm lockfiles.
npm install --include=optional
npm run build
mkdir -p ../src/langbot/web/dist
cp -r dist ../src/langbot/web/
+6
View File
@@ -10,12 +10,16 @@ on:
- 'src/langbot/pkg/persistence/**'
- 'src/langbot/pkg/entity/persistence/**'
- 'tests/integration/persistence/**'
- 'tests/unit_tests/api/service/test_monitoring_sessions.py'
- '.github/workflows/test-migrations.yml'
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'src/langbot/pkg/persistence/**'
- 'src/langbot/pkg/entity/persistence/**'
- 'tests/integration/persistence/**'
- 'tests/unit_tests/api/service/test_monitoring_sessions.py'
- '.github/workflows/test-migrations.yml'
jobs:
test-migrations-sqlite:
@@ -80,6 +84,8 @@ jobs:
run: >-
uv run pytest
tests/integration/persistence/test_migrations_postgres.py
tests/integration/persistence/test_monitoring_postgres.py
tests/unit_tests/api/service/test_monitoring_sessions.py::test_postgres_upgrade_rls_and_concurrent_bot_counts
tests/integration/persistence/test_pgvector_postgres.py
tests/integration/persistence/test_release_migration_postgres.py
tests/integration/persistence/test_plugin_identity_migration.py
+3
View File
@@ -57,3 +57,6 @@ testsdk/
# Next.js build cache (legacy)
web/.next/
web/.pnpm-home
.tmp
Caddyfile
+2 -2
View File
@@ -43,8 +43,8 @@ Run the narrowest useful test first, then broader checks when confidence is need
## Where to Look
- Architecture map: `ARCHITECTURE.md`.
- Dev environment guide: https://docs.langbot.app/zh/develop/dev-config.
- Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime.
- Dev environment guide: https://langbot.app/docs/zh/develop/dev-config.
- Plugin runtime / CLI / SDK debugging: https://langbot.app/docs/zh/develop/plugin-runtime.
- API-key auth: `docs/API_KEY_AUTH.md`.
- 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.
+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)
<a href="https://langbot.app">Website</a>
<a href="https://link.langbot.app/en/docs/features">Features</a>
<a href="https://link.langbot.app/en/docs/guide">Docs</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://langbot.app/docs/en/insight/features">Features</a>
<a href="https://langbot.app/docs/en/insight/guide">Docs</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">Plugin Market</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.
- **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/).
@@ -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 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 | ✅ |
| [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)
<a href="https://langbot.app">官网</a>
<a href="https://link.langbot.app/zh/docs/features">特性</a>
<a href="https://link.langbot.app/zh/docs/guide">文档</a>
<a href="https://link.langbot.app/zh/docs/api">API</a>
<a href="https://langbot.app/docs/zh/insight/features">特性</a>
<a href="https://langbot.app/docs/zh/insight/guide">文档</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">扩展市场</a>
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
@@ -49,7 +49,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
- **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/)。
@@ -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 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) | 智能体平台 | ✅ |
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
[→ 查看完整集成列表](https://langbot.app/docs/zh/insight/features)
### 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)
<a href="https://langbot.app">Inicio</a>
<a href="https://link.langbot.app/en/docs/features">Características</a>
<a href="https://link.langbot.app/en/docs/guide">Documentación</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://langbot.app/docs/en/insight/features">Características</a>
<a href="https://langbot.app/docs/en/insight/guide">Documentación</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://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.
- **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/).
@@ -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 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 | ✅ |
| [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)
<a href="https://langbot.app">Accueil</a>
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a>
<a href="https://link.langbot.app/en/docs/guide">Documentation</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://langbot.app/docs/en/insight/features">Fonctionnalités</a>
<a href="https://langbot.app/docs/en/insight/guide">Documentation</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://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.
- **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/).
@@ -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 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 | ✅ |
| [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)
<a href="https://langbot.app">ホーム</a>
<a href="https://link.langbot.app/ja/docs/features">機能</a>
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a>
<a href="https://link.langbot.app/ja/docs/api">API</a>
<a href="https://langbot.app/docs/ja/insight/features">機能</a>
<a href="https://langbot.app/docs/ja/insight/guide">ドキュメント</a>
<a href="https://langbot.app/docs/ja/tags/readme">API</a>
<a href="https://space.langbot.app">プラグインマーケット</a>
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
@@ -48,7 +48,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
- **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/)。
@@ -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 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) | ゲートウェイ | ✅ |
| [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)
<a href="https://langbot.app">홈</a>
<a href="https://link.langbot.app/en/docs/features">기능</a>
<a href="https://link.langbot.app/en/docs/guide">문서</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://langbot.app/docs/en/insight/features">기능</a>
<a href="https://langbot.app/docs/en/insight/guide">문서</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://space.langbot.app">플러그인 마켓</a>
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
@@ -48,7 +48,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. 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/).
@@ -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 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) | 게이트웨이 | ✅ |
| [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)
<a href="https://langbot.app">Главная</a>
<a href="https://link.langbot.app/en/docs/features">Возможности</a>
<a href="https://link.langbot.app/en/docs/guide">Документация</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://langbot.app/docs/en/insight/features">Возможности</a>
<a href="https://langbot.app/docs/en/insight/guide">Документация</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://space.langbot.app">Магазин плагинов</a>
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
@@ -48,7 +48,7 @@ LangBot — это **платформа с открытым исходным к
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование 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/).
@@ -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 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 | ✅ |
| [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)
<a href="https://langbot.app">官網</a>
<a href="https://link.langbot.app/zh/docs/features">特性</a>
<a href="https://link.langbot.app/zh/docs/guide">文件</a>
<a href="https://link.langbot.app/zh/docs/api">API</a>
<a href="https://langbot.app/docs/zh/insight/features">特性</a>
<a href="https://langbot.app/docs/zh/insight/guide">文件</a>
<a href="https://langbot.app/docs/zh/tags/readme">API</a>
<a href="https://space.langbot.app">外掛市場</a>
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
@@ -50,7 +50,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
- **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/)。
@@ -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 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://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)
<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://link.langbot.app/en/docs/guide">Tài liệu</a>
<a href="https://link.langbot.app/en/docs/api">API</a>
<a href="https://langbot.app/docs/en/insight/features">Tính năng</a>
<a href="https://langbot.app/docs/en/insight/guide">Tài liệu</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://space.langbot.app">Chợ Plugin</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.
- **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/).
@@ -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 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 | ✅ |
| [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)
---
+1 -1
View File
@@ -1,5 +1,5 @@
# 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"
services:
+1 -1
View File
@@ -1,7 +1,7 @@
# Kubernetes Deployment for LangBot
# 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:
# kubectl -n langbot create secret generic langbot-plugin-runtime-control \
+17
View File
@@ -88,6 +88,23 @@ Each endpoint accepts **either**:
1. **User Token** (via `Authorization: Bearer <user_jwt_token>`) - for web UI and authenticated users
2. **API Key** (via `X-API-Key` or `Authorization: Bearer <api_key>`) - for external services
### Inspecting API Key Identity
`GET /api/v1/system/context` validates an API key (user JWT not accepted) and returns its bound identity without requiring resource permissions:
```json
{
"code": 0,
"msg": "ok",
"data": {
"instance_uuid": "...",
"workspace_uuid": "...",
"api_key_id": "...",
"permissions": ["..."]
}
}
```
## Example: Model Management
### List All LLM Models
+65
View File
@@ -0,0 +1,65 @@
# ChatGPT / Codex subscription
LangBot's **OpenAI Codex** model provider uses **Sign in with ChatGPT** and the account's Codex entitlement. It is separate from the existing OpenAI API-key provider: subscribing to ChatGPT does not supply an OpenAI Platform API key, and API-key billing is unchanged.
## Connect an account
1. Open **Models**, choose **Add Provider**, and select **OpenAI Codex**.
2. Enter a provider name and choose **Save and sign in**. This saves the provider before authorization, so an interrupted login can be retried from its settings.
3. Open the OpenAI authorization link and enter the one-time code displayed in LangBot. Sign in on OpenAI's site, not in LangBot.
4. If OpenAI asks you to enable device-code authorization, enable it in your ChatGPT account's security settings, or contact your workspace administrator.
5. Keep the LangBot dialog open until it confirms the connection, then finish the form.
6. Use the existing **Scan models** or **Add model** controls, test the model, and select it in a pipeline as usual. Only LLM models are supported by this provider.
The device-code flow also works when LangBot runs remotely or in Docker: the browser does not need to reach a localhost OAuth callback on the server. Serve the LangBot management panel over HTTPS when accessing it remotely.
The account's model catalog is authoritative. A model listed elsewhere or entered manually is not a guarantee that this account has access. Scan errors are reported rather than replaced with a fabricated available-model list.
## Reconnect and disconnect
Open the provider's existing settings to sign in again or disconnect. LangBot refreshes expiring access tokens automatically. A revoked or invalid refresh grant requires another sign-in; transient network failures are not proof that the grant was revoked.
**Disconnect** removes this provider's locally stored authorization. It does not log the account out of other applications or revoke the account globally. Canceling a pending sign-in is separate from disconnecting an existing account. Removing a provider also removes its authorization; the normal rule that models must be removed first still applies.
A saved provider can remain disconnected. Scanning or invoking it then returns a sign-in-required error; LangBot does not silently switch to paid API-key billing.
## Usage and deployment boundary
Calls consume the connected account's included Codex usage and remain subject to OpenAI's plan limits, model availability, workspace policies, and terms. Token counts recorded by LangBot are request usage, not a measurement of remaining subscription quota or an OpenAI invoice.
Use this integration for your own authorized account and trusted workflows. Third-party sign-in support is not permission to pool accounts, resell subscription quota, or redistribute one subscription as a shared API service. For a public or commercial multi-user service, use the appropriate OpenAI API or separately authorized enterprise arrangement. The provider remains a Workspace resource in LangBot: consider who can invoke its models before connecting a personal account.
## Credential handling and API surface
- OAuth credentials are stored server-side separately from provider API keys. Provider and model reads do not supply OAuth access, refresh, or ID tokens.
- Authorization uses a fixed OpenAI origin. The Codex provider does not accept a custom base URL or manually supplied API keys.
- Authentication controls require an authenticated LangBot browser user with `provider_secret.manage` in the selected Workspace. Pending attempts are scoped to the Workspace, provider, and initiating user.
- Browser storage must not contain OAuth tokens. Treat the server database and its backups as sensitive application data.
- MCP and LangBot API keys do not expose the browser-only OAuth controls. Agents may inspect configured providers and models with the existing tools, but a human connects the subscription in the management panel.
The provider-scoped authentication routes are under `/api/v1/provider/providers/{uuid}/codex`:
| Method | Suffix | Purpose |
| --- | --- | --- |
| GET | `/status` | Read local connection state without returning credentials |
| POST | `/device` | Start device authorization |
| POST | `/device/poll` | Poll the initiating user's authorization attempt |
| DELETE | `/device/{authorization_id}` | Cancel only that pending attempt |
| DELETE | `/auth` | Remove local authorization |
Use the returned polling interval and expiration time. An expired attempt must be restarted. These routes are not a general-purpose subscription-to-API gateway.
## References
- [OpenAI Codex authentication](https://developers.openai.com/codex/auth): ChatGPT versus API-key access and device-code login.
- [Hermes Agent providers](https://hermes-agent.nousresearch.com/docs/integrations/providers/): subscription device authentication and refresh recovery.
- [OpenClaw OpenAI provider](https://docs.openclaw.ai/providers/openai): subscription and API-key route distinctions.
- [New API](https://github.com/QuantumNous/new-api): reference for Codex protocol compatibility; its gateway/account-pooling product model is not adopted here.
## 中文快速说明
在「模型」中添加提供商,选择 **OpenAI Codex**,填写名称并点击「保存并登录」。打开 OpenAI 授权页面,输入 LangBot 显示的一次性验证码,完成授权后回到原对话框。随后照常扫描或添加模型、测试模型,并在流水线中选择它。
无需填写 API Key,也无需为远程服务器配置 localhost 回调。登录中断后可以从该提供商的设置中重试;断开连接只删除 LangBot 中保存的授权。调用消耗所登录账号的 Codex 额度,受账号实际权限和 OpenAI 限制约束,不会自动转用按量付费的 OpenAI API。
此功能用于自己的授权账号及可信工作流,不应将个人订阅作为面向多个用户转售或共享的 API 服务。提供商仍是 LangBot 工作空间内的资源,连接个人账号前请确认模型的使用范围。
+2 -2
View File
@@ -218,8 +218,8 @@ metadata:
spec:
categories: [popular, global]
help_links:
zh: https://docs.langbot.app/zh/platforms/http-bot
en: https://docs.langbot.app/en/platforms/http-bot
zh: https://langbot.app/docs/zh/platforms/http-bot
en: https://langbot.app/docs/en/platforms/http-bot
config:
- { name: inbound_secret, type: string, required: true, 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
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb
- OceanBase Documentation: https://oceanbase.ai
- LangBot Documentation: https://docs.langbot.app
- LangBot Documentation: https://langbot.app/docs
## License
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+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
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).
## Files
+1 -1
View File
@@ -6,7 +6,7 @@
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
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)。
## 文件清单
+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
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
+1 -1
View File
@@ -6,7 +6,7 @@
(`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)。
## 文件清单
+6 -4
View File
@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.10.10"
version = "4.10.11"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -70,7 +70,7 @@ dependencies = [
"langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.5.7",
"langbot-plugin==0.5.8",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
@@ -81,6 +81,7 @@ dependencies = [
"botocore>=1.42.39",
"litellm>=1.0.0",
"valkey-glide>=2.4.1,<3.0.0; sys_platform != 'win32'", # No Windows wheels are published
"webauthn>=3.0.0",
]
keywords = [
"bot",
@@ -109,12 +110,13 @@ classifiers = [
[project.optional-dependencies]
seekdb = [
"pyseekdb==1.1.0.post3",
"pyseekdb==1.4.0.post1",
"pylibseekdb==1.4.0; sys_platform == 'linux' or (sys_platform == 'darwin' and platform_machine == 'arm64')",
]
[project.urls]
Homepage = "https://langbot.app"
Documentation = "https://docs.langbot.app"
Documentation = "https://langbot.app/docs"
Repository = "https://github.com/langbot-app/LangBot"
[project.scripts]
+1 -1
View File
@@ -48,7 +48,7 @@ tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
## Kubernetes
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)
+34
View File
@@ -43,6 +43,8 @@ Two kinds of key are accepted:
Invalid, revoked, or expired keys get `401 Unauthorized`. A valid key whose
scopes do not authorize a tool gets `403 Forbidden`.
To inspect key identity and permissions, call `GET /api/v1/system/context` with the API key.
## Client configuration
```json
@@ -86,6 +88,38 @@ already have a default pipeline.
4. Use `list_*` tools to discover, then `get_*` / `create_*` / `update_*` /
`delete_*` as needed.
## ChatGPT / Codex subscription providers
`list_model_providers` can return the `openai-codex` requester. Its OAuth
credentials are server-only and are not provider API keys. Never ask a user
to paste ChatGPT access tokens, refresh tokens, or a Codex auth cache into an
MCP tool or model configuration.
A human connects or disconnects the subscription through **Models → provider
settings** in the LangBot web UI. The provider-scoped `/codex/*` authentication
routes deliberately require a browser-user session and are not exposed as MCP
tools or authorized by a LangBot API key. Once connected, models are managed
and selected through the normal provider/model workflow. A disconnected
provider must be reauthorized; do not silently replace it with API-key billing.
See [ChatGPT / Codex subscription](../../../docs/CODEX_SUBSCRIPTION.md) for setup,
usage limits, and the personal-account versus shared-service boundary.
## Provider deletion
The curated MCP surface currently lists providers but has no provider-deletion
tool. In the web UI, **Edit Provider → Delete** asks for confirmation before
removing that provider and all its LLM, embedding, and rerank models. This is
irreversible; never interpret a request to edit a provider as authorization to
delete it.
The equivalent HTTP operation is
`DELETE /api/v1/provider/providers/{uuid}?cascade=true`, requiring
`resource.manage` in the authenticated Workspace. Omitting `cascade` preserves
the existing refusal to delete providers that still have models. Cloud-managed
providers remain protected. Cascade deletion removes stored Codex authorization
state as well; it is not the same operation as disconnecting an account.
## Implementation & maintenance (for LangBot developers)
- Server: `src/langbot/pkg/api/mcp/server.py` (FastMCP). Tools call the service
+34 -1
View File
@@ -25,7 +25,10 @@ CLI uses. Create one in your Space account (Profile → Personal Access Tokens),
then send it as a Bearer token:
```
Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`.
Authorization: Bearer <your-pat>
```
Requests without a valid PAT get `401 Unauthorized`.
## Client configuration
@@ -66,6 +69,36 @@ All tools are read-only.
state (available, unprobed, unavailable), then Space recommendation. Each
item includes `availability.up`, `last_probed_at`, latency, and HTTP status.
## Runner usage recommendations
Use `search_plugins` with `runner_usage: "agent"` for Agent, pipeline, and
setup-wizard recommendations, or `runner_usage: "event"` for event processors.
The component kind remains `Runner`. Only these two exact values are accepted;
omit the optional field to preserve unfiltered browsing.
```json
{"query":"", "runner_usage":"agent", "page":1, "page_size":100}
```
Plugin results include `latest_version` and `runner_usages: string[]`, the
explicit union of usages in that latest installable version. Only recommend a
plugin when this array explicitly contains the target usage. Missing, empty,
malformed, or unknown usages must never mean agent-compatible. Event-only
plugins must never enter Agent recommendations. Empty filtered results are
valid while legacy packages await corrected releases; never remove the filter
to fill a recommendation list.
REST callers use `runner_usage` on both
`POST /api/v1/marketplace/extensions/search` and the compatibility
`POST /api/v1/marketplace/plugins/search`; preserve it during fallback. Add
`"type_filter":"plugin", "component_filter":"Runner"` on the unified endpoint.
Usage is ANDed with other filters before pagination and `total`; MCP/Skill items
do not match. Invalid REST values return HTTP 400.
Open the same filter in the webpage:
`https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=agent`
(or `runner_usage=event`). Switch All / Agent / Event in the Runner usage row.
## Implementation & maintenance (for Space developers)
- Server: `internal/controller/mcp/server.go` (official Go MCP SDK
+1 -1
View File
@@ -16,7 +16,7 @@ asciiart = r"""
|___/
⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot
📖 Documentation 文档地址: https://docs.langbot.app
📖 Documentation 文档地址: https://langbot.app/docs
"""
@@ -15,6 +15,7 @@ from ....workspace.collaboration import MembershipPermissionError, WorkspaceColl
from ....workspace.errors import WorkspaceNotFoundError
from ....cloud.entitlements import EntitlementUnavailableError
from ....core.errors import TaskCapacityError
from ....provider.modelmgr.codex_errors import CodexProviderError
from ..authz import (
AuthenticationDeniedError,
AuthorizationError,
@@ -247,6 +248,8 @@ class RouterGroup(abc.ABC):
return await f(*args, **kwargs)
except Exception as e: # 自动 500
if isinstance(e, CodexProviderError):
return self.http_status(e.status_code, e.error_code, str(e))
if isinstance(e, AuthorizationError):
return self.http_status(e.status_code, e.error_code, str(e))
if isinstance(e, WorkspaceNotFoundError):
@@ -5,6 +5,7 @@ import quart
from ...authz import Permission
from ...context import RequestContext
from ...service.monitoring_traffic import get_traffic_series
from .. import group
@@ -377,6 +378,14 @@ class MonitoringRouterGroup(group.RouterGroup):
return self.success(
data={
'traffic': await get_traffic_series(
self.ap,
request_context,
bot_ids=bot_ids or None,
pipeline_ids=pipeline_ids or None,
start_time=start_time,
end_time=end_time,
),
'overview': overview,
'messages': messages,
'llmCalls': llm_calls,
@@ -405,6 +414,7 @@ class MonitoringRouterGroup(group.RouterGroup):
session_id,
start_time=start_time,
end_time=end_time,
bot_id=quart.request.args.get('botId'),
)
# Always return success with the analysis data
@@ -3,6 +3,7 @@ import quart
from ....authz import Permission, has_permission
from ....context import RequestContext
from ... import group
from .query import resolve_include_secret
@group.group_class('models/llm', '/api/v1/provider/models/llm')
@@ -16,7 +17,12 @@ class LLMModelsRouterGroup(group.RouterGroup):
)
async def _(request_context: RequestContext) -> str:
provider_uuid = quart.request.args.get('provider_uuid')
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
if provider_uuid:
models = await self.ap.llm_model_service.get_llm_models_by_provider(
request_context,
@@ -53,10 +59,16 @@ class LLMModelsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
model = await self.ap.llm_model_service.get_llm_model(
request_context,
model_uuid,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
include_secret=include_secret,
)
if model is None:
return self.http_status(404, -1, 'model not found')
@@ -111,7 +123,12 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
)
async def _(request_context: RequestContext) -> str:
provider_uuid = quart.request.args.get('provider_uuid')
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
if provider_uuid:
models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
request_context,
@@ -148,10 +165,16 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
model = await self.ap.embedding_models_service.get_embedding_model(
request_context,
model_uuid,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
include_secret=include_secret,
)
if model is None:
return self.http_status(404, -1, 'model not found')
@@ -208,7 +231,12 @@ class RerankModelsRouterGroup(group.RouterGroup):
)
async def _(request_context: RequestContext) -> str:
provider_uuid = quart.request.args.get('provider_uuid')
include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
if provider_uuid:
models = await self.ap.rerank_models_service.get_rerank_models_by_provider(
request_context,
@@ -245,10 +273,16 @@ class RerankModelsRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(model_uuid: str, request_context: RequestContext) -> str:
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
model = await self.ap.rerank_models_service.get_rerank_model(
request_context,
model_uuid,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
include_secret=include_secret,
)
if model is None:
return self.http_status(404, -1, 'model not found')
@@ -3,11 +3,86 @@ import quart
from ....authz import Permission, has_permission
from ....context import RequestContext
from ... import group
from .query import resolve_include_secret
@group.group_class('models/providers', '/api/v1/provider/providers')
class ModelProvidersRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
# Subscription authorization is an interactive, browser-user-only surface.
@self.route(
'/<provider_uuid>/codex/status',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_status(provider_uuid: str, request_context: RequestContext):
try:
return self.success(
data=await self.ap.provider_service.codex_auth.status(request_context, provider_uuid)
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'/<provider_uuid>/codex/device',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_device(provider_uuid: str, request_context: RequestContext):
try:
return self.success(
data=await self.ap.provider_service.codex_auth.start(request_context, provider_uuid)
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'/<provider_uuid>/codex/device/poll',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_poll(provider_uuid: str, request_context: RequestContext):
body = await quart.request.get_json()
if not isinstance(body, dict):
return self.http_status(400, -1, 'JSON object required')
try:
return self.success(
data=await self.ap.provider_service.codex_auth.poll(
request_context, provider_uuid, body.get('authorization_id')
)
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'/<provider_uuid>/codex/auth',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_disconnect(provider_uuid: str, request_context: RequestContext):
try:
await self.ap.provider_service.codex_auth.disconnect(request_context, provider_uuid)
return self.success()
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'/<provider_uuid>/codex/device/<authorization_id>',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_cancel(provider_uuid: str, authorization_id: str, request_context: RequestContext):
try:
await self.ap.provider_service.codex_auth.cancel(request_context, provider_uuid, authorization_id)
return self.success()
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'',
methods=['GET'],
@@ -15,9 +90,15 @@ class ModelProvidersRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
providers = await self.ap.provider_service.get_providers(
request_context,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
include_secret=include_secret,
)
for provider in providers:
counts = await self.ap.provider_service.get_provider_model_counts(request_context, provider['uuid'])
@@ -47,10 +128,16 @@ class ModelProvidersRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(provider_uuid: str, request_context: RequestContext) -> str:
include_secret, error = resolve_include_secret(
quart.request.args.get('include_secret'),
permitted=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
)
if error:
return self.http_status(400, -1, error)
provider = await self.ap.provider_service.get_provider(
request_context,
provider_uuid,
include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
include_secret=include_secret,
)
if provider is None:
return self.http_status(404, -1, 'provider not found')
@@ -82,7 +169,15 @@ class ModelProvidersRouterGroup(group.RouterGroup):
)
async def _(provider_uuid: str, request_context: RequestContext) -> str:
try:
await self.ap.provider_service.delete_provider(request_context, provider_uuid)
cascade_values = quart.request.args.getlist('cascade')
if cascade_values:
if len(cascade_values) != 1 or cascade_values[0] not in ('true', 'false'):
return self.http_status(400, -1, 'cascade must be a single true or false value')
await self.ap.provider_service.delete_provider(
request_context, provider_uuid, cascade=cascade_values[0] == 'true'
)
else:
await self.ap.provider_service.delete_provider(request_context, provider_uuid)
return self.success()
except ValueError as e:
return self.http_status(400, -1, str(e))
@@ -0,0 +1,15 @@
from __future__ import annotations
def resolve_include_secret(raw_value: str | None, *, permitted: bool) -> tuple[bool, str | None]:
"""Resolve the optional secret projection query parameter."""
if raw_value is None:
return permitted, None
value = raw_value.strip().lower()
if value == 'false':
return False, None
if value == 'true':
return permitted, None
return False, 'include_secret must be either true or false'
@@ -7,14 +7,116 @@ from .. import group
from .....utils import constants
from .....entity.persistence.metadata import WorkspaceMetadata
from ...authz import Permission
from ...context import RequestContext
from ...context import PrincipalType, RequestContext
from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
from .....workspace.invitation_delivery import InvitationDeliveryService
SYSTEM_CAPABILITY_OPERATIONS = (
'bot.list',
'bot.get',
'bot.create',
'bot.update',
'bot.delete',
'pipeline.list',
'pipeline.get',
'pipeline.create',
'pipeline.update',
'pipeline.delete',
'pipeline.copy',
'task.list',
'task.get',
'knowledge_base.list',
'knowledge_base.get',
'knowledge_base.create',
'knowledge_base.update',
'knowledge_base.delete',
'knowledge_base.file.list',
'knowledge_base.file.store',
'knowledge_base.file.delete',
'knowledge_base.retrieve',
'file.document.upload',
'plugin.install.github',
'plugin.install.marketplace',
'plugin.install.local',
'plugin.upgrade',
'plugin.get',
'plugin.list',
'plugin.config.get',
'plugin.config.update',
'plugin.logs',
'plugin.delete',
'provider.list',
'provider.get',
'provider.create',
'provider.update',
'provider.delete',
'provider.scan_models',
'model.llm.list',
'model.llm.get',
'model.llm.create',
'model.llm.update',
'model.llm.delete',
'model.llm.test',
'model.embedding.list',
'model.embedding.get',
'model.embedding.create',
'model.embedding.update',
'model.embedding.delete',
'model.embedding.test',
'model.rerank.list',
'model.rerank.get',
'model.rerank.create',
'model.rerank.update',
'model.rerank.delete',
'model.rerank.test',
'skill.list',
'skill.get',
'skill.create',
'skill.update',
'skill.delete',
'skill.files.list',
'skill.files.read',
'skill.files.write',
'skill.preview',
'skill.install.github',
'skill.install.upload',
'mcp_server.list',
'mcp_server.get',
'mcp_server.create',
'mcp_server.update',
'mcp_server.delete',
'mcp_server.resources',
'mcp_server.resource_templates',
'mcp_server.resource_read',
'mcp_server.logs',
'mcp_server.test',
)
@group.group_class('system', '/api/v1/system')
class SystemRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/context', methods=['GET'], auth_type=group.AuthType.API_KEY)
async def _(request_context: RequestContext) -> str:
return self.success(
data={
'instance_uuid': request_context.instance_uuid,
'workspace_uuid': request_context.workspace_uuid,
'api_key_id': request_context.principal.api_key_uuid,
'permissions': sorted(request_context.workspace.permissions),
}
)
@self.route('/capabilities', methods=['GET'], auth_type=group.AuthType.API_KEY)
async def _() -> str:
return self.success(
data={
'schema_version': 1,
'operations': {operation: {'supported': True} for operation in SYSTEM_CAPABILITY_OPERATIONS},
}
)
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str:
# Read wizard_status and wizard_progress from metadata table
@@ -223,7 +325,7 @@ class SystemRouterGroup(group.RouterGroup):
@self.route(
'/tasks',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
@@ -242,18 +344,23 @@ class SystemRouterGroup(group.RouterGroup):
instance_uuid=request_context.instance_uuid,
workspace_uuid=request_context.workspace_uuid,
placement_generation=request_context.placement_generation,
public=request_context.principal.principal_type == PrincipalType.API_KEY,
)
)
@self.route(
'/tasks/<task_id>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(task_id: str, request_context: RequestContext) -> str:
try:
task_index = int(task_id)
except (TypeError, ValueError):
return self.http_status(404, 404, 'Task not found')
task = self.ap.task_mgr.get_task_by_id(
int(task_id),
task_index,
instance_uuid=request_context.instance_uuid,
workspace_uuid=request_context.workspace_uuid,
placement_generation=request_context.placement_generation,
@@ -262,6 +369,8 @@ class SystemRouterGroup(group.RouterGroup):
if task is None:
return self.http_status(404, 404, 'Task not found')
if request_context.principal.principal_type == PrincipalType.API_KEY:
return self.success(data=task.to_public_dict())
return self.success(data=task.to_dict())
@self.route(
@@ -1,7 +1,19 @@
"""Account, authentication, passkey and TOTP HTTP routes.
Exposes the unauthenticated login/recovery surface as well as the authenticated
account-management, passkey (WebAuthn) and TOTP second-factor endpoints under
``/api/v1/user``.
"""
from __future__ import annotations
import quart
import argon2
import asyncio
import datetime
import hmac
import time
import typing
import uuid
from urllib.parse import parse_qs, urlsplit
@@ -10,11 +22,42 @@ from .....entity.errors import account as account_errors
from ...context import RequestContext
from .....cloud.launch import SpaceLaunchError
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
from ...service.totp import TotpAlreadyEnabledError, TotpInvalidCodeError, TotpNotEnabledError
# Fixed-window admission quota for the unauthenticated reset-password endpoint (#2392).
# The admission check and slot bump share ONE synchronous critical section with no await
# points, so concurrent bursts within a single event loop cannot slip past accounting.
# Every admitted attempt consumes quota (regardless of success), which throttles both the
# legacy 24-bit keyspace exhaustion and brute-force on modern high-entropy keys.
# NOTE: this state is process-local; multi-worker deployments need a shared limiter upstream.
_MAX_RESET_ATTEMPTS_PER_WINDOW = 5
_RESET_WINDOW_SECONDS = 15 * 60
_reset_password_state: dict = {'window_started_at': 0.0, 'attempts': 0}
def _admit_reset_attempt(now: float) -> bool:
"""Atomically reserve one reset-password admission slot.
Must stay await-free: running to completion without suspension makes the
check-and-increment atomic under the single-threaded event loop.
"""
st = _reset_password_state
if now - st['window_started_at'] >= _RESET_WINDOW_SECONDS:
st['window_started_at'] = now
st['attempts'] = 0
if st['attempts'] >= _MAX_RESET_ATTEMPTS_PER_WINDOW:
return False
st['attempts'] += 1
return True
@group.group_class('user', '/api/v1/user')
class UserRouterGroup(group.RouterGroup):
"""``/api/v1/user`` routes for accounts, auth, passkeys and TOTP."""
def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str:
"""Validate a Space OAuth redirect URI against the expected callback shape."""
parsed = urlsplit(redirect_uri)
if (
parsed.scheme not in {'http', 'https'}
@@ -35,11 +78,38 @@ class UserRouterGroup(group.RouterGroup):
return redirect_uri
def _extract_origin_and_rp_id(
self,
json_data: dict[str, typing.Any] | None = None,
) -> tuple[str, str]:
"""Resolve the WebAuthn origin and relying-party ID for a request."""
origin = ''
if json_data and isinstance(json_data, dict):
origin = json_data.get('origin', '')
if not origin:
origin = quart.request.headers.get('Origin', '')
if not origin:
origin = quart.request.headers.get('Referer', '')
if not origin:
origin = quart.request.url_root.rstrip('/')
parsed = urlsplit(origin)
rp_id = parsed.hostname or 'localhost'
if parsed.scheme and parsed.netloc:
clean_origin = f'{parsed.scheme}://{parsed.netloc}'
else:
clean_origin = origin.rstrip('/')
return clean_origin, rp_id
async def initialize(self) -> None:
"""Register every ``/api/v1/user`` route on this router group."""
@self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Report initialization state, or create the first account (POST)."""
if quart.request.method == 'GET':
return self.success(data={'initialized': await self.ap.user_service.is_initialized()})
initialized = await self.ap.user_service.is_initialized()
return self.success(data={'initialized': initialized})
if await self.ap.user_service.is_initialized():
return self.fail(1, 'System already initialized')
@@ -60,31 +130,70 @@ class UserRouterGroup(group.RouterGroup):
@self.route('/auth', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
return self.http_status(403, 'password_login_disabled', 'Password login is disabled on LangBot Cloud')
"""Authenticate a local Account, requiring a TOTP factor when enabled."""
deployment = getattr(self.ap, 'deployment', None)
if getattr(deployment, 'mode', 'oss') == 'cloud':
return self.http_status(
403,
'password_login_disabled',
'Password login is disabled on LangBot Cloud',
)
json_data = await quart.request.json
user_email = json_data['user']
try:
token = await self.ap.user_service.authenticate(json_data['user'], json_data['password'])
token = await self.ap.user_service.authenticate(user_email, json_data['password'])
except argon2.exceptions.VerifyMismatchError:
return self.fail(1, 'Invalid username or password')
except ValueError as e:
return self.fail(1, str(e))
# Second factor: an enabled TOTP credential makes the password alone
# insufficient. The client retries the same request with a code.
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is not None and await self.ap.totp_service.is_enabled(user_obj.uuid):
totp_code = json_data.get('totp_code')
recovery_code = json_data.get('recovery_code')
verified = False
if totp_code:
verified = await self.ap.totp_service.verify_for_account(
user_obj.uuid,
str(totp_code),
)
elif recovery_code:
verified = await self.ap.totp_service.redeem_recovery_code(
user_obj.uuid,
str(recovery_code),
)
if not verified:
return self.http_status(401, 'totp_required', 'TOTP verification required')
return self.success(data={'token': token})
@self.route('/check-token', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(account) -> str:
"""Issue a fresh user token for an already-authenticated Account."""
token = await self.ap.user_service.generate_jwt_token(account)
return self.success(data={'token': token})
@self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Reset a password using the recovery key, TOTP, or a recovery code."""
# Admit (or reject) BEFORE touching the body or any service call (#2392):
# rejecting requests never reach the slow path, and quota accounting happens
# synchronously at entry, closing the post-await race of burst requests.
if not _admit_reset_attempt(time.monotonic()):
return self.http_status(429, -1, 'Too many attempts, try again later')
json_data = await quart.request.json
user_email = json_data['user']
recovery_key = json_data['recovery_key']
# Recovery accepts either the instance recovery key, or (for accounts
# that enrolled one) a TOTP code or a one-time TOTP recovery code.
recovery_key = json_data.get('recovery_key')
totp_code = json_data.get('totp_code')
recovery_code = json_data.get('recovery_code')
new_password = json_data['new_password']
# hard sleep 3s for security
@@ -98,8 +207,39 @@ class UserRouterGroup(group.RouterGroup):
if user_obj is None:
return self.http_status(400, -1, 'User not found')
if recovery_key != self.ap.instance_config.data['system']['recovery_key']:
return self.http_status(403, -1, 'Invalid recovery key')
if totp_code or recovery_code:
if not await self.ap.totp_service.is_enabled(user_obj.uuid):
return self.http_status(
403,
'totp_not_enabled',
'TOTP is not enabled for this account',
)
if totp_code:
authorized = await self.ap.totp_service.verify_for_account(
user_obj.uuid,
str(totp_code),
)
else:
authorized = await self.ap.totp_service.redeem_recovery_code(
user_obj.uuid,
str(recovery_code),
)
if not authorized:
return self.http_status(403, 'totp_invalid_code', 'Invalid TOTP code')
else:
stored_key = self.ap.instance_config.data['system']['recovery_key']
try:
key_matches = (
isinstance(recovery_key, str)
and isinstance(stored_key, str)
and hmac.compare_digest(recovery_key.encode(), stored_key.encode())
)
except UnicodeEncodeError:
# JSON can contain lone surrogates, which are not valid UTF-8.
key_matches = False
if not key_matches:
return self.http_status(403, -1, 'Invalid recovery key')
await self.ap.user_service.reset_password(user_email, new_password)
@@ -107,6 +247,7 @@ class UserRouterGroup(group.RouterGroup):
@self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Change the current Account password after verifying the old one."""
# Check if password change is allowed
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
@@ -120,7 +261,11 @@ class UserRouterGroup(group.RouterGroup):
new_password = json_data['new_password']
try:
await self.ap.user_service.change_password(user_email, current_password, new_password)
await self.ap.user_service.change_password(
user_email,
current_password,
new_password,
)
except argon2.exceptions.VerifyMismatchError:
return self.http_status(400, -1, 'Current password is incorrect')
except ValueError as e:
@@ -144,7 +289,8 @@ class UserRouterGroup(group.RouterGroup):
redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False)
launch_workspace_uuid = quart.request.args.get('launch_workspace_uuid')
if launch_workspace_uuid:
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
deployment = getattr(self.ap, 'deployment', None)
if not getattr(deployment, 'multi_workspace_enabled', False):
return self.fail(1, 'Space launch requires Cloud mode')
try:
uuid.UUID(launch_workspace_uuid)
@@ -161,7 +307,11 @@ class UserRouterGroup(group.RouterGroup):
except ValueError as e:
return self.fail(1, str(e))
@self.route('/space/bind-authorize-url', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
@self.route(
'/space/bind-authorize-url',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
)
async def _(request_context: RequestContext) -> str:
"""Issue an account-bound, one-time Space OAuth redirect."""
redirect_uri = quart.request.args.get('redirect_uri', '')
@@ -207,19 +357,24 @@ class UserRouterGroup(group.RouterGroup):
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
launch_workspace_uuid = consumed_state.launch_workspace_uuid
workspace_uuids = [launch_workspace_uuid] if launch_workspace_uuid else []
workspace_created_ats: dict[str, int] = {}
if not workspace_uuids and getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud':
deployment = getattr(self.ap, 'deployment', None)
if not workspace_uuids and getattr(deployment, 'mode', 'oss') != 'cloud':
binding = await self.ap.workspace_service.get_execution_binding()
workspace_uuids = [binding.workspace_uuid]
workspace_created_at = binding.workspace_created_at
if workspace_created_at is not None:
if workspace_created_at.tzinfo is None:
workspace_created_at = workspace_created_at.replace(tzinfo=datetime.UTC)
workspace_created_ats[binding.workspace_uuid] = int(workspace_created_at.timestamp())
created_at_epoch = int(workspace_created_at.timestamp())
workspace_created_ats[binding.workspace_uuid] = created_at_epoch
token_data = await self.ap.space_service.exchange_oauth_code(
code,
workspace_uuids,
@@ -234,14 +389,20 @@ class UserRouterGroup(group.RouterGroup):
if not access_token:
return self.fail(1, 'Failed to get access token from Space')
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:
deployment = getattr(self.ap, 'deployment', None)
cloud_mode = getattr(deployment, 'mode', 'oss') == 'cloud'
launch_mismatch = launch_workspace_uuid != cloud_workspace_uuid
if cloud_mode and launch_workspace_uuid and launch_mismatch:
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,))
return self.fail(
1,
'Space OAuth response is missing the Cloud Workspace binding',
)
projection_service = self.ap.directory_projection_service
await projection_service.reconcile_workspaces((target_workspace_uuid,))
# Authenticate only after the signed, exact Workspace delta has
# established the Account and membership runtime shadow rows.
@@ -251,12 +412,15 @@ class UserRouterGroup(group.RouterGroup):
if target_workspace_uuid:
try:
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
collab_service = self.ap.workspace_collaboration_service
access = await collab_service.resolve_account_workspace(
user_obj.uuid,
target_workspace_uuid,
)
except Exception:
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
self.ap.logger.warning(
'Rejected Space OAuth launch for unauthorized Workspace',
)
return self.fail(1, 'Space OAuth failed')
return self.success(
data={
@@ -291,6 +455,7 @@ class UserRouterGroup(group.RouterGroup):
'user': account.user,
'account_type': account.account_type,
'has_password': bool(account.password and account.password.strip()),
'totp_enabled': await self.ap.totp_service.is_enabled(account.uuid),
}
)
@@ -341,6 +506,9 @@ class UserRouterGroup(group.RouterGroup):
capabilities['password_login_enabled'] = False
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
capabilities['invitation_registration_enabled'] = not cloud_mode
capabilities['passkey_login_enabled'] = True
capabilities['passkey_supported'] = True
capabilities['totp_supported'] = True
return self.success(data={'initialized': True, **capabilities})
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
@@ -431,6 +599,356 @@ class UserRouterGroup(group.RouterGroup):
except Exception:
raise
@self.route(
'/passkey/register/options',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
)
async def _(user_email: str) -> str:
"""Generate WebAuthn registration options for current account."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
json_data = (await quart.request.json) or {}
origin, rp_id = self._extract_origin_and_rp_id(json_data)
try:
user_service = self.ap.user_service
reg_options = user_service.generate_passkey_registration_options
options, challenge_token = await reg_options(
account_uuid=user_obj.uuid,
rp_id=rp_id,
origin=origin,
rp_name='LangBot',
)
return self.success(data={'options': options, 'challenge_token': challenge_token})
except Exception as e:
return self.fail(1, str(e))
@self.route(
'/passkey/register/verify',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
)
async def _(user_email: str) -> str:
"""Verify WebAuthn registration response and save credential."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
json_data = await quart.request.json
challenge_token = json_data.get('challenge_token')
credential = json_data.get('credential') or json_data.get('response')
name = json_data.get('name')
if not challenge_token or not credential:
return self.fail(1, 'Missing challenge_token or credential')
try:
cred = await self.ap.user_service.verify_and_save_passkey_registration(
challenge_token=challenge_token,
credential_data=credential,
name=name,
)
return self.success(
data={
'uuid': cred.uuid,
'name': cred.name,
'created_at': cred.created_at.isoformat() if cred.created_at else None,
}
)
except Exception as e:
return self.fail(1, str(e))
@self.route('/passkey/auth/options', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Generate WebAuthn authentication options for passkey login."""
json_data = (await quart.request.json) or {}
email = json_data.get('email')
origin, rp_id = self._extract_origin_and_rp_id(json_data)
try:
user_service = self.ap.user_service
auth_options = user_service.generate_passkey_authentication_options
options, challenge_token = await auth_options(
rp_id=rp_id,
origin=origin,
email=email,
)
return self.success(data={'options': options, 'challenge_token': challenge_token})
except Exception as e:
return self.fail(1, str(e))
@self.route('/passkey/auth/verify', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Verify WebAuthn authentication response and log in."""
json_data = await quart.request.json
challenge_token = json_data.get('challenge_token')
credential = json_data.get('credential') or json_data.get('response')
if not challenge_token or not credential:
return self.fail(1, 'Missing challenge_token or credential')
try:
token, user_obj = await self.ap.user_service.verify_passkey_authentication(
challenge_token=challenge_token,
credential_data=credential,
)
return self.success(
data={
'token': token,
'user': user_obj.user,
}
)
except Exception as e:
return self.fail(1, str(e))
@self.route('/passkeys', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""List registered passkeys for the current user."""
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
passkeys = await self.ap.user_service.get_user_passkeys(user_obj.uuid)
return self.success(
data=[
{
'uuid': pk.uuid,
'name': pk.name,
'aaguid': pk.aaguid,
'transports': pk.transports,
'backed_up': pk.backed_up,
'created_at': pk.created_at.isoformat() if pk.created_at else None,
'last_used_at': pk.last_used_at.isoformat() if pk.last_used_at else None,
}
for pk in passkeys
]
)
@self.route(
'/passkey/<passkey_uuid>',
methods=['PATCH'],
auth_type=group.AuthType.USER_TOKEN,
)
async def _(user_email: str, passkey_uuid: str) -> str:
"""Rename a registered passkey."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
json_data = await quart.request.json
name = (json_data.get('name') or '').strip()
if not name:
return self.fail(1, 'Passkey name cannot be empty')
updated = await self.ap.user_service.rename_user_passkey(
account_uuid=user_obj.uuid,
passkey_uuid=passkey_uuid,
new_name=name,
)
if not updated:
return self.http_status(404, -1, 'Passkey not found')
return self.success(data={'uuid': updated.uuid, 'name': updated.name})
@self.route(
'/passkey/<passkey_uuid>',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN,
)
async def _(user_email: str, passkey_uuid: str) -> str:
"""Delete/revoke a registered passkey."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
deleted = await self.ap.user_service.delete_user_passkey(
account_uuid=user_obj.uuid,
passkey_uuid=passkey_uuid,
)
if not deleted:
return self.http_status(404, -1, 'Passkey not found')
return self.success()
@self.route('/totp/check', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Report whether TOTP is enabled for a given Account (unauthenticated).
Used by the password-recovery page to decide whether the TOTP and
recovery-code verification methods are selectable. Only the boolean
capability is disclosed; no account details leak.
"""
if not await self.ap.user_service.is_initialized():
return self.http_status(400, -1, 'System not initialized')
json_data = await quart.request.json
user_email = json_data.get('user')
if not isinstance(user_email, str) or not user_email:
return self.fail(1, 'User is required')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
enabled = user_obj is not None and await self.ap.totp_service.is_enabled(user_obj.uuid)
return self.success(data={'totp_enabled': enabled})
@self.route('/totp/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Report whether the current Account has TOTP enabled."""
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
return self.success(
data={
'enabled': await self.ap.totp_service.is_enabled(user_obj.uuid),
'remaining_recovery_codes': await self.ap.totp_service.remaining_recovery_codes(
user_obj.uuid,
),
}
)
@self.route('/totp/enroll', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Start TOTP enrolment and return the QR payload plus recovery codes.
The secret is not enforced until ``/totp/enroll/verify`` confirms the
authenticator app can produce a valid code.
"""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
try:
enrollment, recovery_codes = await self.ap.totp_service.begin_enrollment(user_obj)
except TotpAlreadyEnabledError as e:
return self.http_status(409, e.code, str(e))
return self.success(
data={
'secret': enrollment.secret,
'otpauth_uri': enrollment.otpauth_uri,
'qr_svg': self.ap.totp_service.build_qr_svg(enrollment.otpauth_uri),
'recovery_codes': recovery_codes,
}
)
@self.route('/totp/enroll/verify', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Confirm enrolment with the first code from the authenticator app."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
json_data = await quart.request.json
code = json_data.get('code')
if not code:
return self.fail(1, 'Verification code is required')
try:
await self.ap.totp_service.confirm_enrollment(user_obj.uuid, str(code))
except TotpNotEnabledError as e:
return self.http_status(400, e.code, str(e))
except TotpAlreadyEnabledError as e:
return self.http_status(409, e.code, str(e))
except TotpInvalidCodeError as e:
return self.http_status(400, e.code, str(e))
return self.success(data={'enabled': True})
@self.route('/totp/recovery-codes', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Regenerate one-time recovery codes after proving a valid TOTP code."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
json_data = await quart.request.json
code = json_data.get('code')
if not code:
return self.fail(1, 'Verification code is required')
if not await self.ap.totp_service.verify_for_account(user_obj.uuid, str(code)):
return self.http_status(400, TotpInvalidCodeError.code, 'Invalid verification code')
try:
_, recovery_codes = await self.ap.totp_service.regenerate_recovery_codes(
user_obj.uuid,
)
except TotpNotEnabledError as e:
return self.http_status(400, e.code, str(e))
return self.success(data={'recovery_codes': recovery_codes})
@self.route('/totp/disable', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Disable TOTP for the current Account after a valid code check."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
json_data = await quart.request.json
code = json_data.get('code')
if not code:
return self.fail(1, 'Verification code is required')
try:
await self.ap.totp_service.disable(user_obj.uuid, str(code))
except TotpNotEnabledError as e:
return self.http_status(400, e.code, str(e))
except TotpInvalidCodeError as e:
return self.http_status(400, e.code, str(e))
return self.success(data={'enabled': False})
async def _handle_space_direct_launch(
self,
launch_assertion: str,
+16 -9
View File
@@ -446,15 +446,19 @@ class MCPService:
persisted_session = runtime_mcp_session
async def _refresh_and_report() -> None:
needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
if needs_start:
await persisted_session.start()
else:
try:
await persisted_session.refresh()
except Exception:
try:
needs_start = (
persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
)
if needs_start:
await persisted_session.start()
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
else:
try:
await persisted_session.refresh()
except Exception:
await persisted_session.start()
finally:
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
coroutine = _refresh_and_report()
else:
@@ -471,8 +475,11 @@ class MCPService:
async def _run_and_cleanup() -> None:
try:
await test_session.start()
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
finally:
# start() raises for a failed connection. Preserve the
# terminal runtime state so the UI can render actionable
# failure phases such as OAuth-required.
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
try:
await test_session.shutdown()
except Exception as exc:
+64 -19
View File
@@ -29,6 +29,19 @@ _DEFAULT_CLEANUP_BATCHES_PER_TABLE = 4
_HARD_MAX_CLEANUP_BATCHES_PER_TABLE = 100
def _normalize_user_id(value: str | int | None) -> str | None:
"""Convert numeric platform IDs before binding a VARCHAR with asyncpg.
Opaque string IDs (including whitespace and leading zeros) and missing
IDs must remain unchanged. Do not silently stringify unsupported objects.
"""
if value is None or isinstance(value, str):
return value
if isinstance(value, int) and not isinstance(value, bool):
return str(value)
raise TypeError('user_id must be a string, integer, or None')
def _workspace_transaction(method):
"""Run an explicit service entrypoint in one Workspace transaction."""
@@ -281,19 +294,21 @@ class MonitoringService:
for _batch_number in range(max_batches):
async def delete_batch() -> tuple[int, int]:
key_columns = list(model_cls.__table__.primary_key.columns)
select_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(pk_column)
sqlalchemy.select(*key_columns)
.where(model_cls.workspace_uuid == workspace_uuid, ts_column < cutoff)
.limit(batch_size)
)
pk_values = list(select_result.scalars().all())
pk_values = [tuple(row) for row in select_result.all()]
if not pk_values:
return 0, 0
delete_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(model_cls).where(
model_cls.workspace_uuid == workspace_uuid,
pk_column.in_(pk_values),
sqlalchemy.tuple_(*key_columns).in_(pk_values),
ts_column < cutoff,
)
)
return len(pk_values), int(delete_result.rowcount or 0)
@@ -415,7 +430,7 @@ class MonitoringService:
status: str = 'success',
level: str = 'info',
platform: str | None = None,
user_id: str | None = None,
user_id: str | int | None = None,
user_name: str | None = None,
runner_name: str | None = None,
variables: str | None = None,
@@ -437,7 +452,7 @@ class MonitoringService:
'status': status,
'level': level,
'platform': platform,
'user_id': user_id,
'user_id': _normalize_user_id(user_id),
'user_name': user_name,
'runner_name': runner_name,
'variables': variables,
@@ -610,7 +625,7 @@ class MonitoringService:
pipeline_id: str,
pipeline_name: str,
platform: str | None = None,
user_id: str | None = None,
user_id: str | int | None = None,
user_name: str | None = None,
) -> None:
"""Record a new session"""
@@ -622,17 +637,29 @@ class MonitoringService:
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
'message_count': 0,
'message_count': 1,
'start_time': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'is_active': True,
'platform': platform,
'user_id': user_id,
'user_id': _normalize_user_id(user_id),
'user_name': user_name,
}
model = persistence_monitoring.MonitoringSession
dialect = self.ap.persistence_mgr.get_db_engine().dialect.name
insert = postgresql_dialect.insert if dialect == 'postgresql' else sqlite_dialect.insert
statement = insert(model).values(session_data)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringSession).values(session_data)
statement.on_conflict_do_update(
index_elements=['workspace_uuid', 'bot_id', 'session_id'],
set_={
'message_count': model.message_count + 1,
'last_activity': statement.excluded.last_activity,
'pipeline_id': statement.excluded.pipeline_id,
'pipeline_name': statement.excluded.pipeline_name,
},
)
)
@_workspace_transaction
@@ -642,6 +669,7 @@ class MonitoringService:
session_id: str,
pipeline_id: str | None = None,
pipeline_name: str | None = None,
bot_id: str | None = None,
) -> bool:
"""Update session last activity time and increment message count.
@@ -651,6 +679,9 @@ class MonitoringService:
True if session was found and updated, False if session doesn't exist.
"""
workspace_uuid = self._require_write_context(context)
bot_id = bot_id if bot_id is not None else context.bot_uuid
if not bot_id:
raise ValueError('Session activity requires a bot_id')
update_values = {
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'message_count': persistence_monitoring.MonitoringSession.message_count + 1,
@@ -667,6 +698,7 @@ class MonitoringService:
.where(
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringSession.session_id == session_id,
persistence_monitoring.MonitoringSession.bot_id == bot_id,
)
.values(update_values)
)
@@ -769,13 +801,13 @@ class MonitoringService:
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
session_conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
session_conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
if end_time:
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time)
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time)
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time)
session_conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
session_conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
# Total messages
message_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id))
@@ -1272,9 +1304,9 @@ class MonitoringService:
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
if user_query and user_query.strip():
user_pattern = f'%{user_query.strip()}%'
conditions.append(
@@ -1376,6 +1408,7 @@ class MonitoringService:
session_id: str,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
bot_id: str | None = None,
) -> dict:
"""Get bounded session details with full statistics computed in SQL."""
workspace_uuid = require_workspace_uuid(context)
@@ -1385,8 +1418,13 @@ class MonitoringService:
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringSession.session_id == session_id,
)
session_result = await self.ap.persistence_mgr.execute_async(session_query)
session_row = session_result.first()
if bot_id is not None:
session_query = session_query.where(persistence_monitoring.MonitoringSession.bot_id == bot_id)
session_result = await self.ap.persistence_mgr.execute_async(session_query.limit(2))
session_rows = session_result.all()
if len(session_rows) > 1:
return {'session_id': session_id, 'found': False, 'ambiguous': True}
session_row = session_rows[0] if session_rows else None
if not session_row:
return {
@@ -1395,6 +1433,7 @@ class MonitoringService:
}
session = session_row[0] if isinstance(session_row, tuple) else session_row
bot_id = session.bot_id
message_stats_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(
@@ -1422,6 +1461,7 @@ class MonitoringService:
).where(
persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringMessage.session_id == session_id,
persistence_monitoring.MonitoringMessage.bot_id == bot_id,
)
)
message_stats = message_stats_result.one()
@@ -1460,6 +1500,7 @@ class MonitoringService:
).where(
persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringLLMCall.session_id == session_id,
persistence_monitoring.MonitoringLLMCall.bot_id == bot_id,
)
)
llm_stats = llm_stats_result.one()
@@ -1486,12 +1527,14 @@ class MonitoringService:
).where(
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
persistence_monitoring.MonitoringToolCall.bot_id == bot_id,
)
)
tool_stats = tool_stats_result.one()
tool_conditions = [
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
persistence_monitoring.MonitoringToolCall.bot_id == bot_id,
]
if start_time is not None:
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
@@ -1520,6 +1563,7 @@ class MonitoringService:
.where(
persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringError.session_id == session_id,
persistence_monitoring.MonitoringError.bot_id == bot_id,
)
.order_by(persistence_monitoring.MonitoringError.timestamp.desc())
.limit(detail_limit + 1)
@@ -2004,9 +2048,9 @@ class MonitoringService:
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
conditions.append(persistence_monitoring.MonitoringSession.last_activity <= end_time)
query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by(
persistence_monitoring.MonitoringSession.last_activity.desc()
@@ -2040,6 +2084,7 @@ class MonitoringService:
# ========== Feedback Methods ==========
@_workspace_transaction
async def record_feedback(
self,
context: ExecutionContext,
@@ -2054,7 +2099,7 @@ class MonitoringService:
session_id: str | None = None,
message_id: str | None = None,
stream_id: str | None = None,
user_id: str | None = None,
user_id: str | int | None = None,
platform: str | None = None,
) -> str | None:
"""Record user feedback (like/dislike) from AI Bot conversation.
@@ -2110,7 +2155,7 @@ class MonitoringService:
'session_id': session_id,
'message_id': message_id,
'stream_id': stream_id,
'user_id': user_id,
'user_id': _normalize_user_id(user_id),
'platform': platform,
}
dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
@@ -0,0 +1,83 @@
"""Bounded traffic aggregation, independent of record-list pagination."""
from __future__ import annotations
import datetime
import typing
import sqlalchemy
from ....entity.persistence.monitoring import MonitoringLLMCall, MonitoringMessage
from .tenant import TenantContext, require_workspace_uuid
if typing.TYPE_CHECKING:
from ....core.app import Application
MAX_TRAFFIC_POINTS = 1000
async def get_traffic_series(
ap: Application,
context: TenantContext,
*,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> dict:
"""Count all matching records in UTC buckets, returning at most 1000 points."""
workspace_uuid = require_workspace_uuid(context)
bucket = 'hour' if start_time and end_time and end_time - start_time <= datetime.timedelta(days=7) else 'day'
step = datetime.timedelta(hours=1) if bucket == 'hour' else datetime.timedelta(days=1)
postgres = ap.persistence_mgr.get_db_engine().dialect.name == 'postgresql'
points: dict[datetime.datetime, dict[str, int]] = {}
truncated = False
for model, field in ((MonitoringMessage, 'messages'), (MonitoringLLMCall, 'llm_calls')):
timestamp = model.timestamp
if postgres:
time_bucket = sqlalchemy.func.date_trunc(bucket, timestamp)
else:
pattern = '%Y-%m-%dT%H:00:00' if bucket == 'hour' else '%Y-%m-%dT00:00:00'
time_bucket = sqlalchemy.func.strftime(pattern, timestamp)
conditions = [model.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(model.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(model.pipeline_id.in_(pipeline_ids))
if start_time is not None:
conditions.append(timestamp >= start_time)
if end_time is not None:
conditions.append(timestamp <= end_time)
statement = (
sqlalchemy.select(time_bucket.label('bucket'), sqlalchemy.func.count(model.id).label('count'))
.where(*conditions)
.group_by(time_bucket)
.order_by(time_bucket)
.limit(MAX_TRAFFIC_POINTS + 1)
)
result = await ap.persistence_mgr.execute_async(statement)
rows = result.all()
truncated = truncated or len(rows) > MAX_TRAFFIC_POINTS
for timestamp_value, count in rows[:MAX_TRAFFIC_POINTS]:
key = (
datetime.datetime.fromisoformat(timestamp_value)
if isinstance(timestamp_value, str)
else timestamp_value
)
points.setdefault(key, {'messages': 0, 'llm_calls': 0})[field] = int(count)
def floor(value: datetime.datetime) -> datetime.datetime:
return value.replace(minute=0, second=0, microsecond=0, **({'hour': 0} if bucket == 'day' else {}))
first = floor(start_time) if start_time is not None else min(points, default=None)
last = floor(end_time) if end_time is not None else max(points, default=None)
series = []
if first is not None and last is not None:
cursor = first
while cursor <= last and len(series) < MAX_TRAFFIC_POINTS:
series.append(
{'timestamp': cursor.isoformat() + 'Z', **points.get(cursor, {'messages': 0, 'llm_calls': 0})}
)
cursor += step
truncated = truncated or cursor <= last
return {'bucket': bucket, 'points': series, 'truncated': truncated}
+129 -50
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import uuid
import traceback
@@ -7,8 +8,10 @@ import sqlalchemy
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
from ....core import app
from ....core.task_boundary import create_detached_task
from ....entity.persistence import model as persistence_model
from ....workspace.errors import WorkspaceNotFoundError
from ....provider.modelmgr.codex_auth import CodexAuth, REQUESTER as CODEX_REQUESTER, validate_config
from .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders
from .tenant import TenantContext, require_workspace_uuid, scope_statement
@@ -20,6 +23,8 @@ class ModelProviderService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
self.codex_auth = CodexAuth(ap)
self._deletion_tasks: set[asyncio.Task[None]] = set()
def _is_cloud_runtime(self) -> bool:
mode = getattr(self.ap.persistence_mgr, 'mode', None)
@@ -116,14 +121,30 @@ class ModelProviderService:
provider_data = provider_data.copy()
if self._system_requester_is_reserved(provider_data.get('requester')):
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
validate_config(provider_data)
provider_data['uuid'] = str(uuid.uuid4())
provider_data['workspace_uuid'] = require_workspace_uuid(context)
provider_data['api_keys'] = self._normalize_api_keys(
restore_secret_placeholders(provider_data.get('api_keys'), sensitive=True)
)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
)
if provider_data.get('requester') == CODEX_REQUESTER:
async with self.ap.persistence_mgr.tenant_uow(provider_data['workspace_uuid']):
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.CodexCredential).values(
workspace_uuid=provider_data['workspace_uuid'],
provider_uuid=provider_data['uuid'],
payload={},
version=0,
lease_until=0,
)
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
)
# load to runtime
runtime_provider = await self.ap.model_mgr.load_provider(context, provider_data)
@@ -138,6 +159,17 @@ class ModelProviderService:
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
provider_data.pop('uuid', None)
provider_data.pop('workspace_uuid', None)
if {'requester', 'base_url', 'api_keys'} & provider_data.keys():
current = await self.get_provider(context, provider_uuid, include_secret=True)
if current is None:
raise WorkspaceNotFoundError('Provider not found')
if CODEX_REQUESTER in (current.get('requester'), provider_data.get('requester')):
if provider_data.get('requester', current.get('requester')) != current.get('requester'):
raise ValueError('Create a separate provider to change the ChatGPT authentication type')
merged = {**current, **provider_data}
validate_config(merged)
provider_data['base_url'] = merged['base_url']
provider_data['api_keys'] = []
if 'api_keys' in provider_data:
submitted_keys = provider_data.get('api_keys')
if contains_secret_placeholder(submitted_keys, sensitive=True):
@@ -163,60 +195,107 @@ class ModelProviderService:
raise WorkspaceNotFoundError('Provider not found')
await self.ap.model_mgr.reload_provider(context, provider_uuid)
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
"""Delete a provider (only if no models reference it)"""
await self._assert_provider_mutable(context, provider_uuid)
async def delete_provider(self, context: TenantContext, provider_uuid: str, cascade: bool = False) -> None:
"""Delete a provider, optionally deleting all its Workspace-scoped models."""
workspace_uuid = require_workspace_uuid(context)
# Check if any models use this provider
llm_result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_model.LLMModel).where(
persistence_model.LLMModel.provider_uuid == provider_uuid
),
persistence_model.LLMModel,
workspace_uuid,
)
persistence = self.ap.persistence_mgr
model_types = (
(persistence_model.LLMModel, 'LLM', 'remove_llm_model'),
(persistence_model.EmbeddingModel, 'Embedding', 'remove_embedding_model'),
(persistence_model.RerankModel, 'Rerank', 'remove_rerank_model'),
)
if llm_result.first() is not None:
raise ValueError('Cannot delete provider: LLM models still reference it')
embedding_result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_model.EmbeddingModel).where(
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
),
persistence_model.EmbeddingModel,
workspace_uuid,
deleted_models: list[tuple[str, list[str]]] = []
async with persistence.tenant_uow(workspace_uuid):
# Check ownership before touching children. Lock the provider on PostgreSQL
# so concurrent model inserts cannot race the reference check/deletion.
provider_result = await persistence.execute_async(
scope_statement(
sqlalchemy.select(persistence_model.ModelProvider.requester)
.where(persistence_model.ModelProvider.uuid == provider_uuid)
.with_for_update(),
persistence_model.ModelProvider,
workspace_uuid,
)
)
)
if embedding_result.first() is not None:
raise ValueError('Cannot delete provider: Embedding models still reference it')
provider = provider_result.first()
if provider is None:
raise WorkspaceNotFoundError('Provider not found')
if self._system_requester_is_reserved(provider.requester):
raise ValueError('LangBot Models is managed by Cloud and cannot be modified')
rerank_result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_model.RerankModel).where(
persistence_model.RerankModel.provider_uuid == provider_uuid
),
persistence_model.RerankModel,
workspace_uuid,
for model_type, label, remover in model_types:
result = await persistence.execute_async(
scope_statement(
sqlalchemy.select(model_type.uuid).where(model_type.provider_uuid == provider_uuid),
model_type,
workspace_uuid,
)
)
model_uuids = list(result.scalars())
if model_uuids and not cascade:
raise ValueError(f'Cannot delete provider: {label} models still reference it')
if model_uuids:
# Model services have no pipeline/KB deletion side effects: they
# delete the scoped row and evict its runtime cache. Defer eviction
# here rather than calling those services before our commit.
await persistence.execute_async(
scope_statement(
sqlalchemy.delete(model_type).where(model_type.provider_uuid == provider_uuid),
model_type,
workspace_uuid,
)
)
deleted_models.append((remover, model_uuids))
# Explicit cleanup also works on legacy SQLite connections without FK
# enforcement; never load or serialize the private credential payload.
await persistence.execute_async(
scope_statement(
sqlalchemy.delete(persistence_model.CodexCredential).where(
persistence_model.CodexCredential.provider_uuid == provider_uuid
),
persistence_model.CodexCredential,
workspace_uuid,
)
)
)
if rerank_result.first() is not None:
raise ValueError('Cannot delete provider: Rerank models still reference it')
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.delete(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
),
persistence_model.ModelProvider,
workspace_uuid,
result = await persistence.execute_async(
scope_statement(
sqlalchemy.delete(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid
),
persistence_model.ModelProvider,
workspace_uuid,
)
)
)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Provider not found')
if result.rowcount == 0:
raise WorkspaceNotFoundError('Provider not found')
await self.ap.model_mgr.remove_provider(context, provider_uuid)
async def remove_runtime() -> None:
async with persistence.tenant_scope(workspace_uuid):
for remover, model_uuids in deleted_models:
for model_uuid in model_uuids:
await getattr(self.ap.model_mgr, remover)(context, model_uuid)
# This also closes the requester's HTTP client; models go first.
await self.ap.model_mgr.remove_provider(context, provider_uuid)
if persistence.current_session() is None:
await remove_runtime()
else:
# A nested UoW has not committed yet. Reuse the rollback-cancelled gate
# and detached context boundary instead of evicting uncommitted data.
task = create_detached_task(
remove_runtime(),
after_commit_manager=persistence,
workspace_uuid=workspace_uuid,
)
self._deletion_tasks.add(task)
def completed(task: asyncio.Task[None]) -> None:
self._deletion_tasks.discard(task)
if not task.cancelled() and task.exception() is not None:
self.ap.logger.error('Failed to remove deleted provider runtime', exc_info=task.exception())
task.add_done_callback(completed)
async def get_provider_model_counts(self, context: TenantContext, provider_uuid: str) -> dict:
"""Get count of models using this provider"""
+478
View File
@@ -0,0 +1,478 @@
"""Second-factor TOTP (RFC 6238) enrolment, verification and recovery.
This service backs the optional TOTP second factor for LangBot Accounts:
* the shared secret is encrypted at rest with a Fernet key derived from the
instance JWT secret via HKDF, and is never persisted in plaintext;
* recovery codes are stored only as salted PBKDF2-HMAC-SHA256 digests;
* the plaintext secret and recovery codes leave the server exactly once, in the
enrolment response.
"""
from __future__ import annotations
import asyncio
import base64
import dataclasses
import datetime
import hashlib
import hmac
import json
import logging
import secrets
import struct
import time
import typing
import uuid
import sqlalchemy
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ....entity.persistence import totp
from ....entity.persistence import user
if typing.TYPE_CHECKING:
from ....core.app import Application
_logger = logging.getLogger(__name__)
# RFC 6238 parameters. Six digits and a 30 second step are what every common
# authenticator app (Google Authenticator, Authy, 1Password, ...) defaults to.
_TOTP_DIGITS = 6
_TOTP_STEP_SECONDS = 30
# Accept one step of clock skew in either direction, which tolerates small
# device clock drift without materially widening the brute-force window.
_TOTP_WINDOW_STEPS = 1
_RECOVERY_CODE_COUNT = 10
# 10 groups drawn from 32 symbols provide 50 bits of entropy per recovery code.
_RECOVERY_CODE_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
_RECOVERY_CODE_LENGTH = 10
# Recovery codes are stored only as salted PBKDF2-HMAC-SHA256 digests. The work
# factor is intentionally high: guessing is already infeasible against 50 bits of
# entropy, and the slow KDF keeps a dumped database from being attacked cheaply.
# Hashing runs off the event loop, so this is a latency cost paid only at
# enrolment / regeneration / redemption.
_RECOVERY_CODE_KDF_ITERATIONS = 300_000
class TotpAlreadyEnabledError(ValueError):
"""Raised when enrolling an Account that already has TOTP enabled."""
code = 'totp_already_enabled'
class TotpNotEnabledError(ValueError):
"""Raised when an operation requires an enabled TOTP credential."""
code = 'totp_not_enabled'
class TotpInvalidCodeError(ValueError):
"""Raised when a supplied TOTP or recovery code fails verification."""
code = 'totp_invalid_code'
@dataclasses.dataclass(frozen=True, slots=True)
class TotpEnrollment:
"""Result of starting (or restarting) TOTP enrolment for an Account."""
secret: str
otpauth_uri: str
class TotpService:
"""Second-factor TOTP enrolment, verification and recovery for Accounts.
Nothing usable is persisted in plaintext:
* The shared TOTP secret is encrypted at rest with a Fernet key derived from
the instance JWT secret via HKDF, so a leaked database file alone does not
expose live secrets (the attacker additionally needs ``config.yaml``).
* Recovery codes are stored only as salted PBKDF2-HMAC-SHA256 digests and are
consumed one at a time.
* The plaintext secret / recovery codes leave the server exactly once, in the
enrolment response, and are never stored or logged server-side.
"""
ap: Application
def __init__(self, ap: Application) -> None:
self.ap = ap
# -- storage helpers -------------------------------------------------
def _session_factory(self) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(self.ap.persistence_mgr.get_db_engine(), expire_on_commit=False)
def _encryption_key(self) -> bytes:
"""Derive a stable 32-byte Fernet key from the instance JWT secret.
HKDF-SHA256 with a fixed domain-separation salt keeps the key stable
across restarts and distinct from the JWT signing secret. The key
material is NOT stored in the database, so a leaked ``langbot.db`` alone
cannot decrypt the TOTP secrets.
"""
secret = ''
try:
secret = self.ap.instance_config.data['system']['jwt']['secret'] or ''
except (KeyError, TypeError):
secret = ''
if not secret:
# Defence in depth: a missing JWT secret must not silently produce a
# well-known encryption key. This should never happen because
# GenKeysStage seeds it, but failing closed is safer than encrypting
# with a predictable key. The caller maps this to an invalid-code
# failure, so no plaintext is ever persisted.
raise TotpInvalidCodeError('Instance JWT secret unavailable')
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
# HKDF enforces the label internally, so include it as `info`.
derived = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=b'langbot-totp-v1',
info=b'langbot-totp-secret-encryption',
).derive(secret.encode('utf-8'))
return base64.urlsafe_b64encode(derived)
def _encrypt_secret(self, secret: str) -> str:
from cryptography.fernet import Fernet
return Fernet(self._encryption_key()).encrypt(secret.encode('utf-8')).decode('ascii')
def _decrypt_secret(self, token: str) -> str:
from cryptography.fernet import Fernet, InvalidToken
try:
return Fernet(self._encryption_key()).decrypt(token.encode('ascii')).decode('utf-8')
except (InvalidToken, ValueError) as exc:
raise TotpInvalidCodeError('Stored TOTP secret cannot be decrypted') from exc
# -- RFC 6238 primitives ---------------------------------------------
@staticmethod
def generate_secret() -> str:
"""Return a fresh base32 secret (160 bits, the RFC 4226 recommendation)."""
return base64.b32encode(secrets.token_bytes(20)).decode('ascii').rstrip('=')
@staticmethod
def _hotp(secret: str, counter: int) -> str:
padding = '=' * (-len(secret) % 8)
key = base64.b32decode(secret.upper() + padding)
msg = struct.pack('>Q', counter)
digest = hmac.new(key, msg, hashlib.sha1).digest()
offset = digest[-1] & 0x0F
binary = struct.unpack('>I', digest[offset : offset + 4])[0] & 0x7FFFFFFF
return str(binary % (10**_TOTP_DIGITS)).zfill(_TOTP_DIGITS)
@classmethod
def generate_code(cls, secret: str, at: float | None = None) -> str:
"""Return the TOTP code for ``secret`` at the given (or current) time."""
counter = int((at if at is not None else time.time()) // _TOTP_STEP_SECONDS)
return cls._hotp(secret, counter)
@classmethod
def verify_code(cls, secret: str, code: str, at: float | None = None) -> bool:
"""Constant-time check of a user-supplied code within the skew window."""
candidate = (code or '').strip().replace(' ', '')
if not candidate.isdigit() or len(candidate) != _TOTP_DIGITS:
return False
now = at if at is not None else time.time()
counter = int(now // _TOTP_STEP_SECONDS)
for offset in range(-_TOTP_WINDOW_STEPS, _TOTP_WINDOW_STEPS + 1):
expected = cls._hotp(secret, counter + offset)
if hmac.compare_digest(expected, candidate):
return True
return False
@staticmethod
def build_otpauth_uri(secret: str, account_name: str, issuer: str = 'LangBot') -> str:
"""Build the otpauth:// URI an authenticator app scans from the QR code."""
from urllib.parse import quote, urlencode
label = quote(f'{issuer}:{account_name}')
params = urlencode(
{
'secret': secret,
'issuer': issuer,
'algorithm': 'SHA1',
'digits': _TOTP_DIGITS,
'period': _TOTP_STEP_SECONDS,
}
)
return f'otpauth://totp/{label}?{params}'
@staticmethod
def build_qr_svg(otpauth_uri: str) -> str:
"""Render the otpauth URI to an inline SVG QR code.
SVG keeps the response text-only so the frontend can drop it straight
into a dialog without byte-encoding a PNG data URL.
"""
import qrcode
import qrcode.image.svg
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=10,
border=2,
image_factory=qrcode.image.svg.SvgPathImage,
)
qr.add_data(otpauth_uri)
qr.make(fit=True)
image = qr.make_image()
import io
buffer = io.BytesIO()
image.save(buffer)
return buffer.getvalue().decode('utf-8')
# -- recovery codes ---------------------------------------------------
@staticmethod
def _normalise_recovery_code(code: str) -> str:
return (code or '').strip().upper().replace('-', '').replace(' ', '')
@classmethod
def _hash_recovery_code(cls, code: str, *, salt: bytes | None = None) -> str:
"""Return a self-describing PBKDF2-HMAC-SHA256 digest of a recovery code.
The format is ``pbkdf2_sha256$<iterations>$<salt_hex>$<digest_hex>`` so the
work factor is stored alongside the digest and can be raised later
without invalidating existing codes. Salted and slow, so a database dump
does not allow offline brute-forcing of recovery codes.
"""
if salt is None:
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac(
'sha256',
cls._normalise_recovery_code(code).encode('utf-8'),
salt,
_RECOVERY_CODE_KDF_ITERATIONS,
)
return f'pbkdf2_sha256${_RECOVERY_CODE_KDF_ITERATIONS}${salt.hex()}${digest.hex()}'
@staticmethod
def _split_recovery_digest(stored: str) -> tuple[int, bytes, bytes] | None:
parts = (stored or '').split('$')
if len(parts) != 4 or parts[0] != 'pbkdf2_sha256':
return None
try:
iterations = int(parts[1])
salt = bytes.fromhex(parts[2])
digest = bytes.fromhex(parts[3])
except ValueError:
return None
return iterations, salt, digest
@staticmethod
def _random_recovery_code() -> str:
"""Return one random recovery code from the unambiguous alphabet."""
alphabet = _RECOVERY_CODE_ALPHABET
return ''.join(secrets.choice(alphabet) for _ in range(_RECOVERY_CODE_LENGTH))
@classmethod
async def generate_recovery_codes(cls) -> tuple[list[str], list[str]]:
"""Return ``(plaintext_codes, hashed_codes)`` for one enrolment."""
plaintext: list[str] = []
hashed: list[str] = []
for _ in range(_RECOVERY_CODE_COUNT):
code = cls._random_recovery_code()
plaintext.append(code)
# Offload the expensive KDF so 10 codes do not stall the event loop.
hashed.append(await asyncio.to_thread(cls._hash_recovery_code, code))
return plaintext, hashed
# -- persistence ------------------------------------------------------
@staticmethod
def _credential_statement(account_uuid: str) -> typing.Any:
"""Build the SELECT that loads an Account's TOTP credential row."""
entity = totp.TotpCredential
return sqlalchemy.select(entity).where(entity.account_uuid == account_uuid)
async def get_credential(self, account_uuid: str) -> totp.TotpCredential | None:
"""Load the (single) TOTP credential row for an Account, if any."""
statement = self._credential_statement(account_uuid)
async with self._session_factory()() as session:
return await session.scalar(statement)
async def is_enabled(self, account_uuid: str) -> bool:
"""Return whether the Account has a confirmed, enabled TOTP credential."""
credential = await self.get_credential(account_uuid)
return bool(credential and credential.enabled)
async def begin_enrollment(self, account: user.User) -> tuple[TotpEnrollment, list[str]]:
"""Create or replace a pending TOTP secret and return recovery codes.
A previous *enabled* credential is left untouched until the new secret
is confirmed, so a failed re-enrolment cannot lock the account out.
"""
secret = self.generate_secret()
uri = self.build_otpauth_uri(secret, account_name=account.user)
plaintext_codes, hashed_codes = await self.generate_recovery_codes()
async with self._session_factory()() as session:
async with session.begin():
credential = await session.scalar(self._credential_statement(account.uuid))
if credential is None:
credential = totp.TotpCredential(
uuid=str(uuid.uuid4()),
account_uuid=account.uuid,
secret_encrypted=self._encrypt_secret(secret),
account_name=account.user,
enabled=False,
recovery_codes=json.dumps(hashed_codes),
)
session.add(credential)
elif not credential.enabled:
credential.secret_encrypted = self._encrypt_secret(secret)
credential.account_name = account.user
credential.recovery_codes = json.dumps(hashed_codes)
else:
raise TotpAlreadyEnabledError('TOTP is already enabled for this account')
await session.flush()
return TotpEnrollment(secret=secret, otpauth_uri=uri), plaintext_codes
async def confirm_enrollment(self, account_uuid: str, code: str) -> None:
"""Verify the first code and flip the credential to enabled."""
credential = await self.get_credential(account_uuid)
if credential is None:
raise TotpNotEnabledError('No pending TOTP enrolment found')
if credential.enabled:
raise TotpAlreadyEnabledError('TOTP is already enabled for this account')
try:
code_matches = self.verify_code(self._decrypt_secret(credential.secret_encrypted), code)
except TotpInvalidCodeError:
code_matches = False
if not code_matches:
raise TotpInvalidCodeError('Invalid verification code')
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(self._credential_statement(account_uuid))
if record is None:
raise TotpNotEnabledError('No pending TOTP enrolment found')
record.enabled = True
record.last_used_at = datetime.datetime.now()
async def verify_for_account(self, account_uuid: str, code: str) -> bool:
"""Validate a live TOTP code for an enabled credential."""
credential = await self.get_credential(account_uuid)
if credential is None or not credential.enabled:
return False
try:
secret = self._decrypt_secret(credential.secret_encrypted)
except TotpInvalidCodeError:
return False
if not self.verify_code(secret, code):
return False
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(self._credential_statement(account_uuid))
if record is not None:
record.last_used_at = datetime.datetime.now()
return True
@classmethod
def _match_recovery_code(cls, code: str, hashed_codes: list[str]) -> int:
"""Return the index of the matching digest, or -1. Constant-time per entry."""
candidate = cls._normalise_recovery_code(code)
for index, stored in enumerate(hashed_codes):
parsed = cls._split_recovery_digest(stored)
if parsed is None:
continue
iterations, salt, expected = parsed
digest = hashlib.pbkdf2_hmac('sha256', candidate.encode('utf-8'), salt, iterations)
if hmac.compare_digest(digest, expected):
return index
return -1
async def redeem_recovery_code(self, account_uuid: str, code: str) -> bool:
"""Consume a one-time recovery code for password reset fallback."""
credential = await self.get_credential(account_uuid)
if credential is None:
return False
hashed_codes: list[str] = []
if credential.recovery_codes:
try:
parsed = json.loads(credential.recovery_codes)
if isinstance(parsed, list):
hashed_codes = [str(item) for item in parsed]
except (ValueError, TypeError):
hashed_codes = []
# Recomputing PBKDF2 for up to 10 salted digests is CPU-bound; keep it
# off the event loop so a recovery attempt cannot stall other requests.
matched_index = await asyncio.to_thread(self._match_recovery_code, code or '', hashed_codes)
if matched_index < 0:
return False
remaining = hashed_codes[:matched_index] + hashed_codes[matched_index + 1 :]
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(self._credential_statement(account_uuid))
if record is not None:
record.recovery_codes = json.dumps(remaining)
record.last_used_at = datetime.datetime.now()
return True
async def regenerate_recovery_codes(self, account_uuid: str) -> tuple[None, list[str]]:
"""Replace the recovery codes for an enabled credential.
The caller is responsible for proving possession of a valid TOTP code
first; this method only swaps the stored digests for a fresh set and
returns the plaintext codes for one-time display.
"""
credential = await self.get_credential(account_uuid)
if credential is None or not credential.enabled:
raise TotpNotEnabledError('TOTP is not enabled for this account')
plaintext_codes, hashed_codes = await self.generate_recovery_codes()
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(self._credential_statement(account_uuid))
if record is None:
raise TotpNotEnabledError('TOTP is not enabled for this account')
record.recovery_codes = json.dumps(hashed_codes)
record.updated_at = datetime.datetime.now()
return None, plaintext_codes
async def disable(self, account_uuid: str, code: str) -> bool:
"""Remove TOTP after the caller proves possession of a valid factor."""
credential = await self.get_credential(account_uuid)
if credential is None or not credential.enabled:
raise TotpNotEnabledError('TOTP is not enabled for this account')
try:
secret = self._decrypt_secret(credential.secret_encrypted)
code_matches = self.verify_code(secret, code)
except TotpInvalidCodeError:
code_matches = False
if not code_matches:
raise TotpInvalidCodeError('Invalid verification code')
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(self._credential_statement(account_uuid))
if record is not None:
await session.delete(record)
return True
async def remaining_recovery_codes(self, account_uuid: str) -> int:
"""Return how many unused recovery codes remain for the Account."""
credential = await self.get_credential(account_uuid)
if credential is None or not credential.recovery_codes:
return 0
try:
parsed = json.loads(credential.recovery_codes)
except (ValueError, TypeError):
return 0
return len(parsed) if isinstance(parsed, list) else 0
+333
View File
@@ -4,6 +4,7 @@ import sqlalchemy
import argon2
import jwt
import datetime
import json
import typing
import asyncio
import dataclasses
@@ -12,10 +13,19 @@ import hashlib
import secrets
import time
import uuid
import webauthn
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ....entity.persistence import user
from ....entity.persistence import passkey
from ....entity.persistence.workspace import MembershipRole, MembershipStatus, WorkspaceMembership
from ....utils import constants
from ....entity.errors import account as account_errors
@@ -29,6 +39,9 @@ if typing.TYPE_CHECKING:
_SPACE_OAUTH_STATE_MAX_ENTRIES = 4096
_SPACE_OAUTH_STATE_HEAP_COMPACT_FLOOR = 64
_SPACE_OAUTH_STATE_HEAP_MAX_MULTIPLIER = 4
_PASSKEY_CHALLENGE_MAX_ENTRIES = 4096
_PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR = 64
_PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER = 4
class AccountExistsLoginRequiredError(ValueError):
@@ -54,6 +67,17 @@ class SpaceOAuthStateConsumption:
launch_workspace_uuid: str | None = None
@dataclasses.dataclass(frozen=True, slots=True)
class PasskeyChallengeData:
challenge: bytes
purpose: typing.Literal['register', 'auth']
rp_id: str
origin: str
expires_at: float
account_uuid: str | None = None
user_email: str | None = None
class UserService:
ap: Application
_create_user_lock: asyncio.Lock
@@ -65,6 +89,9 @@ class UserService:
self._space_oauth_state_lock = asyncio.Lock()
self._space_oauth_states: dict[str, tuple[str, str | None, float, str | None]] = {}
self._space_oauth_state_expiry_heap: list[tuple[float, str]] = []
self._passkey_challenge_lock = asyncio.Lock()
self._passkey_challenges: dict[str, PasskeyChallengeData] = {}
self._passkey_challenge_expiry_heap: list[tuple[float, str]] = []
@staticmethod
def _space_oauth_state_digest(state: str) -> str:
@@ -850,3 +877,309 @@ class UserService:
await self._update_space_provider_for_account(local_account, api_key)
return await self.get_user_by_email(space_email)
def _prune_passkey_challenges(self, now: float) -> None:
while self._passkey_challenge_expiry_heap:
expires_at, token = self._passkey_challenge_expiry_heap[0]
entry = self._passkey_challenges.get(token)
if entry is None or entry.expires_at != expires_at:
heapq.heappop(self._passkey_challenge_expiry_heap)
continue
if expires_at > now:
break
heapq.heappop(self._passkey_challenge_expiry_heap)
self._passkey_challenges.pop(token, None)
max_heap_entries = max(
_PASSKEY_CHALLENGE_HEAP_COMPACT_FLOOR,
len(self._passkey_challenges) * _PASSKEY_CHALLENGE_HEAP_MAX_MULTIPLIER,
)
if len(self._passkey_challenge_expiry_heap) > max_heap_entries:
self._passkey_challenge_expiry_heap[:] = [
(entry.expires_at, token) for token, entry in self._passkey_challenges.items()
]
heapq.heapify(self._passkey_challenge_expiry_heap)
async def issue_passkey_challenge(
self,
purpose: typing.Literal['register', 'auth'],
rp_id: str,
origin: str,
*,
account_uuid: str | None = None,
user_email: str | None = None,
ttl_seconds: int = 300,
) -> tuple[str, bytes]:
now = time.monotonic()
challenge_bytes = secrets.token_bytes(32)
challenge_token = secrets.token_urlsafe(32)
expires_at = now + ttl_seconds
async with self._passkey_challenge_lock:
self._prune_passkey_challenges(now)
while len(self._passkey_challenges) >= _PASSKEY_CHALLENGE_MAX_ENTRIES:
if not self._passkey_challenge_expiry_heap:
break
_, oldest_token = heapq.heappop(self._passkey_challenge_expiry_heap)
self._passkey_challenges.pop(oldest_token, None)
self._passkey_challenges[challenge_token] = PasskeyChallengeData(
challenge=challenge_bytes,
purpose=purpose,
rp_id=rp_id,
origin=origin,
expires_at=expires_at,
account_uuid=account_uuid,
user_email=user_email,
)
heapq.heappush(self._passkey_challenge_expiry_heap, (expires_at, challenge_token))
return challenge_token, challenge_bytes
async def consume_passkey_challenge(
self,
challenge_token: str,
purpose: typing.Literal['register', 'auth'],
) -> PasskeyChallengeData:
now = time.monotonic()
async with self._passkey_challenge_lock:
self._prune_passkey_challenges(now)
data = self._passkey_challenges.pop(challenge_token, None)
if data is None or data.expires_at < now:
raise ValueError('Invalid or expired passkey challenge')
if data.purpose != purpose:
raise ValueError('Passkey challenge purpose mismatch')
return data
async def get_user_passkeys(self, account_uuid: str) -> list[passkey.PasskeyCredential]:
statement = (
sqlalchemy.select(passkey.PasskeyCredential)
.where(passkey.PasskeyCredential.account_uuid == account_uuid)
.order_by(passkey.PasskeyCredential.created_at.desc())
)
async with self._session_factory()() as session:
result = await session.scalars(statement)
return list(result.all())
async def get_passkey_by_credential_id(self, credential_id: str) -> passkey.PasskeyCredential | None:
statement = sqlalchemy.select(passkey.PasskeyCredential).where(
passkey.PasskeyCredential.credential_id == credential_id
)
async with self._session_factory()() as session:
return await session.scalar(statement)
async def get_passkey_by_uuid(self, passkey_uuid: str) -> passkey.PasskeyCredential | None:
statement = sqlalchemy.select(passkey.PasskeyCredential).where(passkey.PasskeyCredential.uuid == passkey_uuid)
async with self._session_factory()() as session:
return await session.scalar(statement)
async def generate_passkey_registration_options(
self,
account_uuid: str,
rp_id: str,
origin: str,
rp_name: str = 'LangBot',
) -> tuple[dict[str, typing.Any], str]:
account = await self.get_user_by_uuid(account_uuid)
if account is None:
raise ValueError('User not found')
self._require_active_account(account)
challenge_token, challenge_bytes = await self.issue_passkey_challenge(
purpose='register',
rp_id=rp_id,
origin=origin,
account_uuid=account_uuid,
user_email=account.user,
)
existing_passkeys = await self.get_user_passkeys(account_uuid)
exclude_credentials = [
PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in existing_passkeys
]
options = webauthn.generate_registration_options(
rp_id=rp_id,
rp_name=rp_name,
user_name=account.user,
user_id=account.uuid.encode('utf-8'),
user_display_name=account.user,
challenge=challenge_bytes,
exclude_credentials=exclude_credentials or None,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
),
)
options_dict = json.loads(webauthn.options_to_json(options))
return options_dict, challenge_token
async def verify_and_save_passkey_registration(
self,
challenge_token: str,
credential_data: dict[str, typing.Any] | str,
name: str | None = None,
) -> passkey.PasskeyCredential:
challenge_data = await self.consume_passkey_challenge(challenge_token, 'register')
if not challenge_data.account_uuid:
raise ValueError('Registration challenge must be bound to an account')
verification = webauthn.verify_registration_response(
credential=credential_data,
expected_challenge=challenge_data.challenge,
expected_rp_id=challenge_data.rp_id,
expected_origin=challenge_data.origin,
require_user_verification=False,
)
cred_id_str = bytes_to_base64url(verification.credential_id)
pub_key_str = bytes_to_base64url(verification.credential_public_key)
transports = None
if isinstance(credential_data, dict):
resp = credential_data.get('response', {})
if isinstance(resp, dict) and 'transports' in resp:
t_list = resp.get('transports')
if isinstance(t_list, list):
transports = ','.join(str(x) for x in t_list)
credential_name = (name or '').strip()
if not credential_name:
credential_name = f'Passkey ({datetime.datetime.now().strftime("%Y-%m-%d %H:%M")})'
record = passkey.PasskeyCredential(
uuid=str(uuid.uuid4()),
account_uuid=challenge_data.account_uuid,
name=credential_name,
credential_id=cred_id_str,
public_key=pub_key_str,
sign_count=verification.sign_count,
aaguid=verification.aaguid,
transports=transports,
backed_up=verification.credential_backed_up,
)
async with self._session_factory()() as session:
async with session.begin():
session.add(record)
await session.flush()
await session.refresh(record)
return record
async def generate_passkey_authentication_options(
self,
rp_id: str,
origin: str,
email: str | None = None,
) -> tuple[dict[str, typing.Any], str]:
challenge_token, challenge_bytes = await self.issue_passkey_challenge(
purpose='auth',
rp_id=rp_id,
origin=origin,
user_email=email,
)
allow_credentials: list[PublicKeyCredentialDescriptor] | None = None
if email:
user_obj = await self.get_user_by_email(email)
if user_obj:
user_passkeys = await self.get_user_passkeys(user_obj.uuid)
if user_passkeys:
allow_credentials = [
PublicKeyCredentialDescriptor(id=base64url_to_bytes(pk.credential_id)) for pk in user_passkeys
]
options = webauthn.generate_authentication_options(
rp_id=rp_id,
challenge=challenge_bytes,
allow_credentials=allow_credentials or None,
user_verification=UserVerificationRequirement.PREFERRED,
)
options_dict = json.loads(webauthn.options_to_json(options))
return options_dict, challenge_token
async def verify_passkey_authentication(
self,
challenge_token: str,
credential_data: dict[str, typing.Any] | str,
) -> tuple[str, user.User]:
challenge_data = await self.consume_passkey_challenge(challenge_token, 'auth')
raw_id = credential_data.get('id') if isinstance(credential_data, dict) else None
if not raw_id:
raise ValueError('Missing credential id')
stored_credential = await self.get_passkey_by_credential_id(raw_id)
if stored_credential is None:
raise ValueError('Passkey credential not recognized')
user_obj = await self.get_user_by_uuid(stored_credential.account_uuid)
if user_obj is None:
raise ValueError('Associated user not found')
self._require_active_account(user_obj)
verification = webauthn.verify_authentication_response(
credential=credential_data,
expected_challenge=challenge_data.challenge,
expected_rp_id=challenge_data.rp_id,
expected_origin=challenge_data.origin,
credential_public_key=base64url_to_bytes(stored_credential.public_key),
credential_current_sign_count=stored_credential.sign_count,
require_user_verification=False,
)
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(
sqlalchemy.select(passkey.PasskeyCredential).where(
passkey.PasskeyCredential.id == stored_credential.id
)
)
if record:
record.sign_count = verification.new_sign_count
record.last_used_at = datetime.datetime.now()
record.backed_up = verification.credential_backed_up
token = await self.generate_jwt_token(user_obj)
return token, user_obj
async def rename_user_passkey(
self,
account_uuid: str,
passkey_uuid: str,
new_name: str,
) -> passkey.PasskeyCredential | None:
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(
sqlalchemy.select(passkey.PasskeyCredential).where(
passkey.PasskeyCredential.uuid == passkey_uuid,
passkey.PasskeyCredential.account_uuid == account_uuid,
)
)
if record is None:
return None
record.name = new_name
await session.flush()
await session.refresh(record)
return record
async def delete_user_passkey(
self,
account_uuid: str,
passkey_uuid: str,
) -> bool:
async with self._session_factory()() as session:
async with session.begin():
record = await session.scalar(
sqlalchemy.select(passkey.PasskeyCredential).where(
passkey.PasskeyCredential.uuid == passkey_uuid,
passkey.PasskeyCredential.account_uuid == account_uuid,
)
)
if record is None:
return False
await session.delete(record)
return True
+5 -2
View File
@@ -34,6 +34,7 @@ from ..api.http.service import apikey as apikey_service
from ..api.http.service import webhook as webhook_service
from ..api.http.service import monitoring as monitoring_service
from ..api.http.service import skill as skill_service
from ..api.http.service import totp as totp_service
from ..api.http.service import maintenance as maintenance_service
from ..discover import engine as discover_engine
from ..storage import mgr as storagemgr
@@ -161,6 +162,8 @@ class Application:
user_service: user_service.UserService = None
totp_service: totp_service.TotpService = None
space_service: space_service.SpaceService = None
llm_model_service: model_service.LLMModelsService = None
@@ -635,9 +638,9 @@ class Application:
frontend_path = paths.get_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(
'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
+4
View File
@@ -28,6 +28,7 @@ from ...api.http.service import apikey as apikey_service
from ...api.http.service import webhook as webhook_service
from ...api.http.service import monitoring as monitoring_service
from ...api.http.service import skill as skill_service
from ...api.http.service import totp as totp_service
from ...skill import manager as skill_mgr
from ...api.http.service import maintenance as maintenance_service
from ...discover import engine as discover_engine
@@ -198,6 +199,9 @@ class BuildAppStage(stage.BootingStage):
user_service_inst = user_service.UserService(ap)
ap.user_service = user_service_inst
totp_service_inst = totp_service.TotpService(ap)
ap.totp_service = totp_service_inst
async def resolve_singleton_execution_context() -> ExecutionContext:
if workspace_policy.multi_workspace_enabled:
raise WorkspaceRequiredError('Cloud runtime work requires an explicit Workspace context')
+20 -1
View File
@@ -1,9 +1,18 @@
from __future__ import annotations
import logging
import secrets
from .. import stage, app
# This stage runs before SetupLoggerStage, so ap.logger is still None here;
# the module logger falls back to the stderr lastResort handler.
_logger = logging.getLogger(__name__)
# 32 symbols without 0/O or 1/I; eight independent draws provide 40 random bits.
_RECOVERY_KEY_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
_RECOVERY_KEY_LENGTH = 8
@stage.stage_class('GenKeysStage')
class GenKeysStage(stage.BootingStage):
@@ -20,5 +29,15 @@ class GenKeysStage(stage.BootingStage):
ap.instance_config.data['system']['recovery_key'] = ''
if not ap.instance_config.data['system']['recovery_key']:
ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper()
# Keep recovery practical to type. Security also requires the reset
# endpoint's concurrency-safe quota (five admissions per 15 minutes).
ap.instance_config.data['system']['recovery_key'] = ''.join(
secrets.choice(_RECOVERY_KEY_ALPHABET) for _ in range(_RECOVERY_KEY_LENGTH)
)
await ap.instance_config.dump_config()
elif len(ap.instance_config.data['system']['recovery_key']) < _RECOVERY_KEY_LENGTH:
_logger.warning(
'Low-entropy legacy recovery key detected (length < 8); '
'regenerate system.recovery_key in the configuration file '
'with a strong random value (#2392)'
)
+49 -12
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import json
import typing
import datetime
import time
@@ -197,6 +198,41 @@ class TaskWrapper:
},
}
def to_public_dict(self) -> dict:
"""Return the stable task projection exposed to API-key callers."""
if self.task.cancelled():
status = 'cancelled'
error = {'type': 'task_cancelled', 'message': 'Task was cancelled'}
result = None
elif not self.task.done():
status = 'running'
error = None
result = None
else:
exception = self.assume_exception()
if exception is not None:
status = 'failed'
error = {'type': 'task_failed', 'message': 'Task execution failed'}
result = None
else:
status = 'succeeded'
error = None
result = self.assume_result()
try:
json.dumps(result)
except (TypeError, ValueError):
result = None
return {
'id': self.id,
'task_type': self.task_type,
'kind': self.kind,
'status': status,
'error': error,
'result': result,
'created_at': self.created_at,
}
def cancel(self):
self.task.cancel()
@@ -325,19 +361,20 @@ class AsyncTaskManager:
instance_uuid: str | None = None,
workspace_uuid: str | None = None,
placement_generation: int | None = None,
public: bool = False,
) -> dict:
return {
'tasks': [
t.to_dict()
for t in self.tasks
if (type is None or t.task_type == type)
and (kind is None or t.kind == kind)
and (instance_uuid is None or t.instance_uuid == instance_uuid)
and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
and (placement_generation is None or t.placement_generation == placement_generation)
],
'id_index': TaskWrapper._id_index,
}
tasks = [
t.to_public_dict() if public else t.to_dict()
for t in self.tasks
if (type is None or t.task_type == type)
and (kind is None or t.kind == kind)
and (instance_uuid is None or t.instance_uuid == instance_uuid)
and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
and (placement_generation is None or t.placement_generation == placement_generation)
]
if public:
return {'tasks': tasks}
return {'tasks': tasks, 'id_index': TaskWrapper._id_index}
def get_stats(self) -> dict:
completed = sum(1 for t in self.tasks if t.task.done())
@@ -33,6 +33,28 @@ class ModelProvider(Base):
)
class CodexCredential(Base):
"""Server-only OAuth state. Never joined into provider/model serialization."""
__tablename__ = 'codex_credentials'
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
workspace_uuid = sqlalchemy.Column(sqlalchemy.String(36), nullable=False)
payload = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=dict)
version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
lease_owner = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
lease_until = sqlalchemy.Column(sqlalchemy.Float, nullable=False, default=0)
__table_args__ = (
sqlalchemy.ForeignKeyConstraint(
['workspace_uuid', 'provider_uuid'],
['model_providers.workspace_uuid', 'model_providers.uuid'],
name='fk_codex_credentials_workspace_provider',
ondelete='CASCADE',
),
sqlalchemy.Index('ix_codex_credentials_workspace', 'workspace_uuid'),
)
class LLMModel(Base):
"""LLM model"""
@@ -111,8 +111,8 @@ class MonitoringSession(Base):
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
primary_key=True,
)
bot_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, index=True)
session_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
@@ -0,0 +1,38 @@
from __future__ import annotations
import uuid as uuid_lib
import sqlalchemy
from .base import Base
class PasskeyCredential(Base):
__tablename__ = 'passkey_credentials'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
uuid = sqlalchemy.Column(
sqlalchemy.String(36),
nullable=False,
default=lambda: str(uuid_lib.uuid4()),
)
account_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
credential_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
public_key = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
sign_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
aaguid = sqlalchemy.Column(sqlalchemy.String(64), nullable=True)
transports = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
backed_up = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
__table_args__ = (
sqlalchemy.Index('uq_passkey_credentials_uuid', 'uuid', unique=True),
sqlalchemy.Index('uq_passkey_credentials_cred_id', 'credential_id', unique=True),
sqlalchemy.Index('ix_passkey_credentials_account', 'account_uuid'),
)
@@ -0,0 +1,60 @@
"""Persistence entity for per-Account TOTP (RFC 6238) second factors."""
from __future__ import annotations
import uuid as uuid_lib
import sqlalchemy
from .base import Base
class TotpCredential(Base):
"""Per-Account TOTP (RFC 6238) second factor and its recovery codes.
A single row is kept per Account. The shared secret is stored encrypted
(``secret_encrypted``, Fernet keyed off the instance JWT secret via HKDF)
rather than in plaintext, and remains unenforced until the owner confirms
possession by submitting a valid code (``enabled``). Recovery codes are
stored only as salted PBKDF2-HMAC-SHA256 digests, so a database leak does
not hand out account recovery. No plaintext secret or recovery code is ever
persisted; both leave the server exactly once, in the enrolment response.
"""
__tablename__ = 'totp_credentials'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
uuid = sqlalchemy.Column(
sqlalchemy.String(36),
nullable=False,
default=lambda: str(uuid_lib.uuid4()),
)
account_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
)
# Fernet-encrypted base32 secret; never exposed to the client after enrol.
secret_encrypted = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
# Issuer label shown inside the authenticator app (e.g. the account email).
account_name = sqlalchemy.Column(sqlalchemy.String(320), nullable=False)
enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, server_default='0')
# JSON-encoded list of salted PBKDF2 hashes for the one-time recovery codes.
recovery_codes = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
created_at = sqlalchemy.Column(
sqlalchemy.DateTime,
nullable=False,
server_default=sqlalchemy.func.now(),
)
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,
nullable=False,
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
__table_args__ = (
sqlalchemy.Index('uq_totp_credentials_uuid', 'uuid', unique=True),
sqlalchemy.Index('uq_totp_credentials_account', 'account_uuid', unique=True),
)
@@ -0,0 +1,48 @@
"""Add isolated server-only Codex credentials and tenant RLS.
Revision ID: 0022_codex_credentials
Revises: 0021_merge_reasoning_config
"""
from alembic import op
import sqlalchemy as sa
revision = '0022_codex_credentials'
down_revision = '0021_merge_reasoning_config'
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# Fresh startup creates ORM metadata before running Alembic.
if 'codex_credentials' not in sa.inspect(conn).get_table_names():
op.create_table(
'codex_credentials',
sa.Column('provider_uuid', sa.String(255), primary_key=True),
sa.Column('workspace_uuid', sa.String(36), nullable=False),
sa.Column('payload', sa.JSON(), nullable=False),
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('lease_owner', sa.String(64), nullable=True),
sa.Column('lease_until', sa.Float(), nullable=False),
sa.ForeignKeyConstraint(
['workspace_uuid', 'provider_uuid'],
['model_providers.workspace_uuid', 'model_providers.uuid'],
name='fk_codex_credentials_workspace_provider',
ondelete='CASCADE',
),
)
op.create_index('ix_codex_credentials_workspace', 'codex_credentials', ['workspace_uuid'])
if conn.dialect.name == 'postgresql':
op.execute('ALTER TABLE codex_credentials ENABLE ROW LEVEL SECURITY')
op.execute('ALTER TABLE codex_credentials FORCE ROW LEVEL SECURITY')
op.execute('DROP POLICY IF EXISTS langbot_workspace_isolation ON codex_credentials')
expression = "workspace_uuid::text = NULLIF(current_setting('langbot.workspace_uuid', true), '')"
op.execute(
f'CREATE POLICY langbot_workspace_isolation ON codex_credentials '
f'FOR ALL USING ({expression}) WITH CHECK ({expression})'
)
def downgrade() -> None:
op.drop_table('codex_credentials')
@@ -0,0 +1,104 @@
"""Scope monitoring sessions by bot without changing runtime session IDs.
Revision ID: 0023_bot_scoped_sessions
Revises: 0022_codex_credentials
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql, sqlite
revision = '0023_bot_scoped_sessions'
down_revision = '0022_codex_credentials'
branch_labels = None
depends_on = None
_TABLE = 'monitoring_sessions'
_KEY = ['workspace_uuid', 'bot_id', 'session_id']
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if _TABLE not in inspector.get_table_names():
return
pk = inspector.get_pk_constraint(_TABLE)
if pk['constrained_columns'] == _KEY:
return
# PostgreSQL alters in place, retaining indexes, grants, policies and RLS.
# SQLite batch reflection retains all existing indexes and foreign keys.
with op.batch_alter_table(_TABLE, naming_convention={'pk': 'pk_%(table_name)s'}) as batch:
batch.drop_constraint(pk['name'] or f'pk_{_TABLE}', type_='primary')
batch.create_primary_key(f'pk_{_TABLE}', _KEY)
metadata = sa.MetaData()
sessions = sa.Table(_TABLE, metadata, autoload_with=conn)
messages = sa.Table('monitoring_messages', metadata, autoload_with=conn)
m = messages.c
collisions = (
sa.select(m.workspace_uuid, m.session_id)
.group_by(m.workspace_uuid, m.session_id)
.having(sa.func.count(sa.distinct(m.bot_id)) > 1)
.subquery()
)
partition = [m.workspace_uuid, m.bot_id, m.session_id]
# Repair only demonstrable collisions. Retention may have removed earlier
# evidence; these summaries describe surviving messages, never invented text.
ranked = (
sa.select(
*[m[name] for name in _KEY],
m.bot_name,
m.pipeline_id,
m.pipeline_name,
m.platform,
m.user_id,
m.user_name,
sa.func.sum(sa.case((sa.or_(m.role == 'user', m.role.is_(None)), 1), else_=0))
.over(partition_by=partition)
.label('message_count'),
sa.func.min(m.timestamp).over(partition_by=partition).label('start_time'),
sa.func.max(m.timestamp).over(partition_by=partition).label('last_activity'),
sa.func.row_number().over(partition_by=partition, order_by=[m.timestamp.desc(), m.id.desc()]).label('rank'),
)
.join(
collisions,
sa.and_(m.workspace_uuid == collisions.c.workspace_uuid, m.session_id == collisions.c.session_id),
)
.subquery()
)
columns = _KEY + [
'bot_name',
'pipeline_id',
'pipeline_name',
'platform',
'user_id',
'user_name',
'message_count',
'start_time',
'last_activity',
'is_active',
]
select = sa.select(*[ranked.c[name] for name in columns[:-1]], sa.literal(True)).where(ranked.c.rank == 1)
insert = postgresql.insert if conn.dialect.name == 'postgresql' else sqlite.insert
statement = insert(sessions).from_select(columns, select)
conn.execute(
statement.on_conflict_do_update(
index_elements=_KEY,
set_={name: statement.excluded[name] for name in columns if name not in _KEY and name != 'is_active'},
)
)
def downgrade() -> None:
conn = op.get_bind()
if _TABLE not in sa.inspect(conn).get_table_names():
return
collisions = conn.execute(
sa.text('SELECT 1 FROM monitoring_sessions GROUP BY workspace_uuid, session_id HAVING COUNT(*) > 1 LIMIT 1')
).first()
if collisions:
raise RuntimeError('Cannot downgrade bot-scoped sessions without losing colliding bot records')
pk = sa.inspect(conn).get_pk_constraint(_TABLE)
with op.batch_alter_table(_TABLE, naming_convention={'pk': 'pk_%(table_name)s'}) as batch:
batch.drop_constraint(pk['name'] or f'pk_{_TABLE}', type_='primary')
batch.create_primary_key(f'pk_{_TABLE}', ['workspace_uuid', 'session_id'])
@@ -0,0 +1,54 @@
"""add passkey credentials table
Revision ID: 0024_passkey_credentials
Revises: 0023_bot_scoped_sessions
Create Date: 2026-09-12
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '0024_passkey_credentials'
down_revision = '0023_bot_scoped_sessions'
branch_labels = None
depends_on = None
_TABLE_NAME = 'passkey_credentials'
def upgrade() -> None:
conn = op.get_bind()
existing_tables = set(sa.inspect(conn).get_table_names())
if _TABLE_NAME not in existing_tables:
op.create_table(
_TABLE_NAME,
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('uuid', sa.String(36), nullable=False),
sa.Column(
'account_uuid',
sa.String(36),
sa.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('credential_id', sa.String(255), nullable=False),
sa.Column('public_key', sa.Text(), nullable=False),
sa.Column('sign_count', sa.Integer(), nullable=False, server_default='0'),
sa.Column('aaguid', sa.String(64), nullable=True),
sa.Column('transports', sa.String(255), nullable=True),
sa.Column('backed_up', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
)
op.create_index('uq_passkey_credentials_uuid', _TABLE_NAME, ['uuid'], unique=True)
op.create_index('uq_passkey_credentials_cred_id', _TABLE_NAME, ['credential_id'], unique=True)
op.create_index('ix_passkey_credentials_account', _TABLE_NAME, ['account_uuid'], unique=False)
def downgrade() -> None:
op.drop_index('ix_passkey_credentials_account', table_name=_TABLE_NAME)
op.drop_index('uq_passkey_credentials_cred_id', table_name=_TABLE_NAME)
op.drop_index('uq_passkey_credentials_uuid', table_name=_TABLE_NAME)
op.drop_table(_TABLE_NAME)
@@ -0,0 +1,50 @@
"""add totp credentials table
Revision ID: 0025_totp_credentials
Revises: 0024_passkey_credentials
Create Date: 2026-09-12
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '0025_totp_credentials'
down_revision = '0024_passkey_credentials'
branch_labels = None
depends_on = None
_TABLE_NAME = 'totp_credentials'
def upgrade() -> None:
conn = op.get_bind()
existing_tables = set(sa.inspect(conn).get_table_names())
if _TABLE_NAME not in existing_tables:
op.create_table(
_TABLE_NAME,
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('uuid', sa.String(36), nullable=False),
sa.Column(
'account_uuid',
sa.String(36),
sa.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
),
sa.Column('secret_encrypted', sa.Text(), nullable=False),
sa.Column('account_name', sa.String(320), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('recovery_codes', sa.Text(), nullable=True),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
op.create_index('uq_totp_credentials_uuid', _TABLE_NAME, ['uuid'], unique=True)
op.create_index('uq_totp_credentials_account', _TABLE_NAME, ['account_uuid'], unique=True)
def downgrade() -> None:
op.drop_index('uq_totp_credentials_account', table_name=_TABLE_NAME)
op.drop_index('uq_totp_credentials_uuid', table_name=_TABLE_NAME)
op.drop_table(_TABLE_NAME)
+2
View File
@@ -62,6 +62,8 @@ _ALEMBIC_TENANT_TABLES = {
'binary_storages',
'mcp_servers',
'model_providers',
'codex_credentials',
'passkey_credentials',
'llm_models',
'embedding_models',
'rerank_models',
+35 -1
View File
@@ -51,6 +51,7 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
'binary_storages': 'workspace_uuid',
'mcp_servers': 'workspace_uuid',
'model_providers': 'workspace_uuid',
'codex_credentials': 'workspace_uuid',
'llm_models': 'workspace_uuid',
'embedding_models': 'workspace_uuid',
'rerank_models': 'workspace_uuid',
@@ -206,6 +207,8 @@ _SYNC_PROXY_CAPABILITY: contextvars.ContextVar[_ScopedSessionGuardState | None]
_ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
'coalesce': sqlalchemy.sql.functions.coalesce,
'count': sqlalchemy.sql.functions.count,
'min': sqlalchemy.sql.functions.min,
'max': sqlalchemy.sql.functions.max,
'now': sqlalchemy.sql.functions.now,
'sum': sqlalchemy.sql.functions.sum,
}
@@ -852,7 +855,30 @@ class TenantScopedAsyncSession(sqlalchemy_asyncio.AsyncSession):
self._require_owner_task()
self._enter_internal_access()
try:
await transaction.commit()
# Retain the actual connection before COMMIT: after a failed SQLite
# COMMIT the logical transaction is inactive, but the DBAPI writer
# can still hold PENDING/RESERVED locks. Session.close()/rollback()
# alone can then return that poisoned connection to the pool.
connection = await super().connection()
try:
await transaction.commit()
except BaseException as exc:
cleanup = asyncio.create_task(connection.invalidate())
# Invalidation does not access the task-owned Session. Shield
# physical cleanup, including against repeated cancellation,
# before the owner closes the Session and releases its scope.
while not cleanup.done():
try:
await asyncio.shield(cleanup)
except asyncio.CancelledError:
continue
except BaseException:
break
try:
cleanup.result()
except BaseException as cleanup_error:
exc.add_note(f'Failed to invalidate transaction connection: {cleanup_error!r}')
raise
finally:
self._exit_internal_access()
@@ -1369,6 +1395,7 @@ class TenantUnitOfWork:
state.mark_rollback_only(exc_value)
rollback_only = state.rollback_only
committed = False
transaction_error: BaseException | None = None
try:
if exc_type is None and not rollback_only:
await typing.cast(TenantScopedAsyncSession, session)._commit_owned_transaction(
@@ -1381,6 +1408,9 @@ class TenantUnitOfWork:
_UOW_SESSION_CONTROL_CAPABILITY,
transaction,
)
except BaseException as exc:
transaction_error = exc
raise
finally:
try:
if self._active_transaction is not None and self._context_token is not None:
@@ -1388,6 +1418,10 @@ class TenantUnitOfWork:
await typing.cast(TenantScopedAsyncSession, session)._close_owned_session(
_UOW_SESSION_CONTROL_CAPABILITY
)
except BaseException as cleanup_error:
if transaction_error is None:
raise
transaction_error.add_note(f'Failed to close transaction Session: {cleanup_error!r}')
finally:
if self._database_operation_token is not None:
_DATABASE_OPERATION_TRANSACTION.reset(self._database_operation_token)
@@ -79,6 +79,7 @@ class MonitoringHelper:
session_updated = await ap.monitoring_service.update_session_activity(
get_query_execution_context(query),
session_id,
bot_id=bot_id,
pipeline_id=pipeline_id,
pipeline_name=pipeline_name,
)
@@ -15,9 +15,9 @@ spec:
categories:
- protocol
help_links:
zh: https://link.langbot.app/zh/platforms/aiocqhttp
en: https://link.langbot.app/en/platforms/aiocqhttp
ja: https://link.langbot.app/ja/platforms/aiocqhttp
zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat
en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat
ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat
config:
- name: host
label:
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/dingtalk
en: https://link.langbot.app/en/platforms/dingtalk
ja: https://link.langbot.app/ja/platforms/dingtalk
zh: https://langbot.app/docs/zh/usage/platforms/dingtalk
en: https://langbot.app/docs/en/usage/platforms/dingtalk
ja: https://langbot.app/docs/ja/usage/platforms/dingtalk
config:
- name: one-click-create
label:
@@ -24,9 +24,9 @@ spec:
- popular
- global
help_links:
zh: https://link.langbot.app/zh/platforms/discord
en: https://link.langbot.app/en/platforms/discord
ja: https://link.langbot.app/ja/platforms/discord
zh: https://langbot.app/docs/zh/usage/platforms/discord
en: https://langbot.app/docs/en/usage/platforms/discord
ja: https://langbot.app/docs/ja/usage/platforms/discord
config:
- name: client_id
label:
@@ -18,9 +18,9 @@ spec:
- popular
- global
help_links:
zh: https://docs.langbot.app/zh/platforms/http-bot
en: https://docs.langbot.app/en/platforms/http-bot
ja: https://docs.langbot.app/ja/platforms/http-bot
zh: https://langbot.app/docs/zh/platforms/http-bot
en: https://langbot.app/docs/en/platforms/http-bot
ja: https://langbot.app/docs/ja/platforms/http-bot
config:
- name: webhook_url
label:
+3 -3
View File
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/kook
en: https://link.langbot.app/en/platforms/kook
ja: https://link.langbot.app/ja/platforms/kook
zh: https://langbot.app/docs/zh/usage/platforms/kook
en: https://langbot.app/docs/en/usage/platforms/kook
ja: https://langbot.app/docs/ja/usage/platforms/kook
config:
- name: token
label:
+3 -3
View File
@@ -19,9 +19,9 @@ spec:
- china
- global
help_links:
zh: https://link.langbot.app/zh/platforms/lark
en: https://link.langbot.app/en/platforms/lark
ja: https://link.langbot.app/ja/platforms/lark
zh: https://langbot.app/docs/zh/usage/platforms/lark
en: https://langbot.app/docs/en/usage/platforms/lark
ja: https://langbot.app/docs/ja/usage/platforms/lark
config:
- name: domain
label:
+3 -3
View File
@@ -22,9 +22,9 @@ spec:
categories:
- global
help_links:
zh: https://link.langbot.app/zh/platforms/line
en: https://link.langbot.app/en/platforms/line
ja: https://link.langbot.app/ja/platforms/line
zh: https://langbot.app/docs/zh/usage/platforms/line
en: https://langbot.app/docs/en/usage/platforms/line
ja: https://langbot.app/docs/ja/usage/platforms/line
config:
- name: webhook_url
label:
+2 -2
View File
@@ -682,8 +682,8 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)')
continue
# Use configured logout command, fallback to deriving from login command
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
# Use configured logout command, fallback to deriving from login command
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...')
# Cancel existing tasks
@@ -0,0 +1,375 @@
from __future__ import annotations
import asyncio
import json
import re
import typing
from urllib.parse import urlsplit, urlunsplit
import aiohttp
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
_MATTERMOST_MAX_POST_LENGTH = 16_383
_MENTION_BOUNDARY = r'(?<![\w.-])@{username}(?![\w.-])'
def _normalize_server_url(server_url: str) -> str:
"""Return a validated Mattermost server URL without a trailing slash."""
url = server_url.strip().rstrip('/')
parsed = urlsplit(url)
if parsed.scheme not in {'http', 'https'} or not parsed.netloc:
raise ValueError('Mattermost server_url must be an absolute HTTP(S) URL')
return url
def _websocket_url(server_url: str) -> str:
parsed = urlsplit(server_url)
scheme = 'wss' if parsed.scheme == 'https' else 'ws'
return urlunsplit((scheme, parsed.netloc, f'{parsed.path}/api/v4/websocket', '', ''))
class MattermostMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
"""Translate Mattermost post text to and from LangBot message chains."""
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain) -> str:
parts: list[str] = []
for component in message_chain:
if isinstance(component, platform_message.Plain):
parts.append(component.text)
elif isinstance(component, platform_message.Image) and component.url:
# Mattermost renders image URLs in Markdown messages.
parts.append(component.url)
elif isinstance(component, platform_message.File) and component.url:
parts.append(component.url)
return ''.join(parts)
@staticmethod
async def target2yiri(post: dict, bot_username: str) -> platform_message.MessageChain:
text = str(post.get('message') or '')
components: list[typing.Any] = [
platform_message.Source(
id=str(post.get('id') or ''),
time=float(post.get('create_at') or 0) / 1000,
)
]
if bot_username:
mention_pattern = re.compile(_MENTION_BOUNDARY.format(username=re.escape(bot_username)), re.IGNORECASE)
if mention_pattern.search(text):
components.append(platform_message.At(target=bot_username))
text = mention_pattern.sub('', text).strip()
if text:
components.append(platform_message.Plain(text=text))
return platform_message.MessageChain(components)
class MattermostEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod
async def yiri2target(event: platform_events.MessageEvent) -> dict:
return event.source_platform_object
@staticmethod
async def target2yiri(
post: dict,
channel: dict,
sender_name: str,
bot_username: str,
) -> platform_events.MessageEvent:
message_chain = await MattermostMessageConverter.target2yiri(post, bot_username)
timestamp = float(post.get('create_at') or 0) / 1000
sender_id = str(post.get('user_id') or '')
channel_type = channel.get('type')
if channel_type == 'D':
return platform_events.FriendMessage(
sender=platform_entities.Friend(id=sender_id, nickname=sender_name or sender_id, remark=''),
message_chain=message_chain,
time=timestamp,
source_platform_object={'post': post, 'channel': channel},
)
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=sender_id,
member_name=sender_name or sender_id,
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=str(post.get('channel_id') or ''),
name=str(channel.get('display_name') or channel.get('name') or post.get('channel_id') or ''),
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=message_chain,
time=timestamp,
source_platform_object={'post': post, 'channel': channel},
)
class MattermostAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""Mattermost Bot Account adapter using the v4 REST and WebSocket APIs."""
server_url: str = ''
access_token: str = ''
session: aiohttp.ClientSession | None = None
listeners: dict[typing.Type[platform_events.Event], typing.Callable] = {}
channel_cache: dict[str, dict] = {}
stream_post_ids: dict[str, str] = {}
bot_username: str = ''
_running: bool = False
message_converter: MattermostMessageConverter = MattermostMessageConverter()
event_converter: MattermostEventConverter = MattermostEventConverter()
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
server_url = _normalize_server_url(str(config.get('server_url') or ''))
access_token = str(config.get('access_token') or '').strip()
if not access_token:
raise ValueError('Mattermost adapter requires an access_token')
super().__init__(
config=config,
logger=logger,
server_url=server_url,
access_token=access_token,
bot_account_id='',
session=None,
listeners={},
channel_cache={},
stream_post_ids={},
bot_username='',
_running=False,
)
async def _get_session(self) -> aiohttp.ClientSession:
if self.session is None or self.session.closed:
self.session = aiohttp.ClientSession(
headers={'Authorization': f'Bearer {self.access_token}'},
raise_for_status=False,
)
return self.session
async def _api_request(
self,
method: str,
path: str,
*,
payload: dict | None = None,
) -> dict:
session = await self._get_session()
async with session.request(method, f'{self.server_url}/api/v4{path}', json=payload) as response:
raw_body = await response.text()
if response.status >= 400:
# Mattermost returns a useful JSON error, but never include request headers/tokens in errors.
try:
error = json.loads(raw_body).get('message', raw_body)
except json.JSONDecodeError:
error = raw_body
raise RuntimeError(f'Mattermost API {method} {path} failed ({response.status}): {error}')
if not raw_body:
return {}
return json.loads(raw_body)
async def _load_bot_identity(self) -> None:
user = await self._api_request('GET', '/users/me')
self.bot_account_id = str(user.get('id') or '')
self.bot_username = str(user.get('username') or '')
if not self.bot_account_id:
raise RuntimeError('Mattermost API did not return a bot user ID')
async def _get_channel(self, channel_id: str) -> dict:
if channel_id not in self.channel_cache:
self.channel_cache[channel_id] = await self._api_request('GET', f'/channels/{channel_id}')
return self.channel_cache[channel_id]
async def _post_message(self, channel_id: str, text: str, root_id: str = '') -> dict:
if not text:
return {}
if len(text) > _MATTERMOST_MAX_POST_LENGTH:
raise ValueError(f'Mattermost messages cannot exceed {_MATTERMOST_MAX_POST_LENGTH} characters')
payload = {'channel_id': channel_id, 'message': text}
if root_id:
payload['root_id'] = root_id
return await self._api_request('POST', '/posts', payload=payload)
async def _get_direct_channel_id(self, user_id: str) -> str:
if not self.bot_account_id:
await self._load_bot_identity()
channel = await self._api_request(
'POST',
'/channels/direct',
payload={'user_ids': [self.bot_account_id, user_id]},
)
channel_id = str(channel.get('id') or '')
if not channel_id:
raise RuntimeError('Mattermost did not return a direct-message channel ID')
self.channel_cache[channel_id] = channel
return channel_id
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
if target_type not in {'person', 'group'}:
raise ValueError("Mattermost target_type must be 'person' or 'group'")
text = await self.message_converter.yiri2target(message)
channel_id = str(target_id)
if target_type == 'person':
channel_id = await self._get_direct_channel_id(channel_id)
await self._post_message(channel_id, text)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
source = await self.event_converter.yiri2target(message_source)
post = source['post']
text = await self.message_converter.yiri2target(message)
# A message received inside a Mattermost thread must remain in that thread. When
# quote_origin is requested, make the response a reply to the source root post.
root_id = str(post.get('root_id') or '')
if quote_origin and not root_id:
root_id = str(post.get('id') or '')
await self._post_message(str(post['channel_id']), text, root_id)
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
source = await self.event_converter.yiri2target(event)
post = source['post']
root_id = str(post.get('root_id') or post.get('id') or '')
created = await self._post_message(str(post['channel_id']), 'Thinking…', root_id)
if created.get('id'):
self.stream_post_ids[str(message_id)] = str(created['id'])
return True
return False
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
):
response_id = str(bot_message.resp_message_id)
text = await self.message_converter.yiri2target(message)
if not text:
return
post_id = self.stream_post_ids.get(response_id)
if post_id:
await self._api_request('PUT', f'/posts/{post_id}', payload={'id': post_id, 'message': text})
else:
source = await self.event_converter.yiri2target(message_source)
post = source['post']
root_id = str(post.get('root_id') or '')
if quote_origin and not root_id:
root_id = str(post.get('id') or '')
created = await self._post_message(str(post['channel_id']), text, root_id)
post_id = str(created.get('id') or '')
if post_id:
self.stream_post_ids[response_id] = post_id
if is_final and getattr(bot_message, 'tool_calls', None) is None:
self.stream_post_ids.pop(response_id, None)
async def is_stream_output_supported(self) -> bool:
return bool(self.config.get('enable_stream_reply', True))
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
],
):
self.listeners.pop(event_type, None)
async def _dispatch_post(self, payload: dict) -> None:
data = payload.get('data') or {}
try:
post = json.loads(data.get('post') or '{}')
except (TypeError, json.JSONDecodeError):
await self.logger.error('Mattermost received a posted event with an invalid post payload')
return
if not post or str(post.get('user_id') or '') == self.bot_account_id:
return
channel_id = str(post.get('channel_id') or '')
if not channel_id:
return
try:
channel = await self._get_channel(channel_id)
event = await self.event_converter.target2yiri(
post,
channel,
str(data.get('sender_name') or post.get('user_id') or ''),
self.bot_username,
)
callback = self.listeners.get(type(event))
if callback:
result = callback(event, self)
if asyncio.iscoroutine(result):
await result
except Exception as exc:
await self.logger.error(f'Error handling Mattermost post: {exc}')
async def _run_websocket_once(self) -> None:
session = await self._get_session()
async with session.ws_connect(_websocket_url(self.server_url), heartbeat=30) as websocket:
await websocket.send_json(
{
'seq': 1,
'action': 'authentication_challenge',
'data': {'token': self.access_token},
}
)
async for message in websocket:
if message.type == aiohttp.WSMsgType.TEXT:
try:
payload = json.loads(message.data)
except json.JSONDecodeError:
continue
if payload.get('event') == 'posted':
await self._dispatch_post(payload)
elif message.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR}:
break
async def run_async(self):
self._running = True
await self._load_bot_identity()
await self.logger.info(f'Mattermost bot connected: @{self.bot_username} ({self.bot_account_id})')
retry_delay = 1
while self._running:
try:
await self._run_websocket_once()
retry_delay = 1
except asyncio.CancelledError:
raise
except Exception as exc:
if self._running:
await self.logger.error(f'Mattermost WebSocket disconnected: {exc}')
await asyncio.sleep(retry_delay)
retry_delay = min(retry_delay * 2, 30)
async def kill(self) -> bool:
self._running = False
if self.session and not self.session.closed:
await self.session.close()
return True
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="Artwork" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 140"><defs><style>.cls-1{fill:#1e325c;fill-rule:evenodd;}</style></defs><path class="cls-1" d="M111.11,13.36l.74,14.86c12.04,13.3,16.8,32.15,10.81,49.86-8.95,26.44-38.46,40.33-65.92,31.04-27.46-9.29-42.45-38.26-33.5-64.7,6.01-17.77,21.32-29.87,39.05-33.07L71.87.03C41.99-.77,13.8,17.77,3.72,47.55c-12.4,36.6,7.24,76.33,43.85,88.73,36.6,12.4,76.33-7.24,88.73-43.85,10.07-29.74-1-61.55-25.14-79.07h-.03Z"/><path class="cls-1" d="M93.95,57.21l-.51-20.77-.41-11.95-.28-10.35s.07-4.99-.11-6.16c-.03-.25-.11-.44-.21-.62,0-.03-.02-.05-.03-.07,0-.02-.03-.05-.03-.07-.2-.33-.49-.59-.89-.72s-.8-.1-1.17.05h-.02s-.08.03-.13.07c-.16.08-.34.2-.51.36-.85.82-3.84,4.83-3.84,4.83l-6.5,8.06-7.59,9.25-13.02,16.19s-5.98,7.46-4.65,16.64c1.31,9.18,8.15,13.65,13.43,15.44,5.29,1.79,13.43,2.38,20.05-4.11,6.62-6.49,6.4-16.04,6.4-16.04l.02-.02Z"/></svg>

After

Width:  |  Height:  |  Size: 938 B

@@ -0,0 +1,75 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: mattermost
label:
en_US: Mattermost
zh_Hans: Mattermost
zh_Hant: Mattermost
ja_JP: Mattermost
th_TH: Mattermost
vi_VN: Mattermost
es_ES: Mattermost
icon: mattermost.svg
description:
en_US: Mattermost Bot Account adapter using the v4 REST and WebSocket APIs. Add me to the teams and channels where you want me to interact. Please use a browser or desktop application to do this.
zh_Hans: 使用 Mattermost v4 REST API 与 WebSocket 的 Bot Account 适配器。请将我添加到您想要我互动的团队与频道。请使用浏览器或桌面应用进行操作。
zh_Hant: 使用 Mattermost v4 REST API 與 WebSocket 的 Bot Account 介面卡。請將我加入您希望我互動的團隊與頻道。請使用瀏覽器或桌面應用程式操作。
ja_JP: Mattermost v4 REST API と WebSocket を使用する Bot Account アダプター。利用させたいチームとチャンネルに私を追加してください。ブラウザまたはデスクトップアプリで操作してください。
th_TH: อะแดปเตอร์ Bot Account ของ Mattermost ผ่าน v4 REST API และ WebSocket โปรดเพิ่มฉันไปยังทีมและช่องที่คุณต้องการให้ฉันโต้ตอบ โปรดดำเนินการผ่านเบราว์เซอร์หรือแอปเดสก์ท็อป
vi_VN: Bộ điều hợp Bot Account Mattermost sử dụng REST API v4 và WebSocket. Hãy thêm tôi vào các nhóm và kênh mà bạn muốn tôi tương tác. Vui lòng thao tác bằng trình duyệt hoặc ứng dụng máy tính để bàn.
es_ES: Adaptador de Bot Account de Mattermost mediante REST API v4 y WebSocket. Añádeme a los equipos y canales en los que quieras que interactúe. Hazlo desde un navegador o la aplicación de escritorio.
spec:
categories:
- global
- popular
config:
- name: server_url
label:
en_US: Mattermost Server URL
zh_Hans: Mattermost 服务器地址
zh_Hant: 位址伺服器 Mattermost
ja_JP: Mattermost サーバー URL
th_TH: URL เซิร์ฟเวอร์ Mattermost
vi_VN: URL máy chủ Mattermost
es_ES: URL del servidor Mattermost
description:
en_US: The base URL of the Mattermost server, for example https://mattermost.example.com
zh_Hans: Mattermost 服务器基础地址,例如 https://mattermost.example.com
type: string
required: true
default: ""
- name: access_token
label:
en_US: Bot Access Token
zh_Hans: Bot 访问令牌
zh_Hant: Bot 存取權杖
ja_JP: Bot アクセストークン
th_TH: โทเค็นการเข้าถึงของบอต
vi_VN: Mã truy cập Bot
es_ES: Token de acceso del bot
description:
en_US: The personal access token generated for the Mattermost Bot Account
zh_Hans: 为 Mattermost Bot Account 生成的个人访问令牌
type: string
required: true
default: ""
- name: enable_stream_reply
label:
en_US: Enable Stream Reply
zh_Hans: 启用流式回复
zh_Hant: 啟用串流回覆
ja_JP: ストリーミング返信を有効化
th_TH: เปิดใช้งานการตอบกลับแบบสตรีม
vi_VN: Bật phản hồi luồng
es_ES: Activar respuesta en streaming
description:
en_US: Update a Mattermost post while LangBot generates a response
zh_Hans: 在 LangBot 生成回复时持续更新同一条 Mattermost 消息
type: boolean
required: false
default: true
execution:
python:
path: ./mattermost.py
attr: MattermostAdapter
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/officialaccount
en: https://link.langbot.app/en/platforms/officialaccount
ja: https://link.langbot.app/ja/platforms/officialaccount
zh: https://langbot.app/docs/zh/usage/platforms/wxoa
en: https://langbot.app/docs/en/usage/platforms/wxoa
ja: https://langbot.app/docs/ja/usage/platforms/wxoa
config:
- name: webhook_url
label:
@@ -16,9 +16,9 @@ spec:
- popular
- china
help_links:
zh: https://link.langbot.app/zh/platforms/openclaw_weixin
en: https://link.langbot.app/en/platforms/openclaw_weixin
ja: https://link.langbot.app/ja/platforms/openclaw_weixin
zh: https://langbot.app/docs/zh/usage/platforms/wechat/weixin
en: https://langbot.app/docs/en/usage/platforms/readme
ja: https://langbot.app/docs/ja/usage/platforms/readme
config:
- name: base_url
label:
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/qqofficial
en: https://link.langbot.app/en/platforms/qqofficial
ja: https://link.langbot.app/ja/platforms/qqofficial
zh: https://langbot.app/docs/zh/usage/platforms/qq/official_webhook
en: https://langbot.app/docs/en/usage/platforms/qq/official_webhook
ja: https://langbot.app/docs/ja/usage/platforms/qq/official_webhook
config:
- name: __system.outbound_ips
label:
+3 -3
View File
@@ -21,9 +21,9 @@ spec:
categories:
- protocol
help_links:
zh: https://link.langbot.app/zh/platforms/satori
en: https://link.langbot.app/en/platforms/satori
ja: https://link.langbot.app/ja/platforms/satori
zh: https://langbot.app/docs/zh/usage/platforms/readme
en: https://langbot.app/docs/en/usage/platforms/readme
ja: https://langbot.app/docs/ja/usage/platforms/readme
config:
- name: platform
label:
+3 -3
View File
@@ -24,9 +24,9 @@ spec:
- popular
- global
help_links:
zh: https://link.langbot.app/zh/platforms/slack
en: https://link.langbot.app/en/platforms/slack
ja: https://link.langbot.app/ja/platforms/slack
zh: https://langbot.app/docs/zh/usage/platforms/slack
en: https://langbot.app/docs/en/usage/platforms/slack
ja: https://langbot.app/docs/ja/usage/platforms/slack
config:
- name: webhook_url
label:
@@ -24,9 +24,9 @@ spec:
- popular
- global
help_links:
zh: https://link.langbot.app/zh/platforms/telegram
en: https://link.langbot.app/en/platforms/telegram
ja: https://link.langbot.app/ja/platforms/telegram
zh: https://langbot.app/docs/zh/usage/platforms/telegram
en: https://langbot.app/docs/en/usage/platforms/telegram
ja: https://langbot.app/docs/ja/usage/platforms/telegram
config:
- name: token
label:
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/wechatpad
en: https://link.langbot.app/en/platforms/wechatpad
ja: https://link.langbot.app/ja/platforms/wechatpad
zh: https://langbot.app/docs/zh/usage/platforms/wechat/wechatpad
en: https://langbot.app/docs/en/usage/platforms/readme
ja: https://langbot.app/docs/ja/usage/platforms/readme
config:
- name: wechatpad_url
label:
+3 -3
View File
@@ -16,9 +16,9 @@ spec:
- popular
- china
help_links:
zh: https://link.langbot.app/zh/platforms/wecom
en: https://link.langbot.app/en/platforms/wecom
ja: https://link.langbot.app/ja/platforms/wecom
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecom
en: https://langbot.app/docs/en/usage/platforms/wecom/wecom
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecom
config:
- name: webhook_url
label:
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/wecombot
en: https://link.langbot.app/en/platforms/wecombot
ja: https://link.langbot.app/ja/platforms/wecombot
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecombot
en: https://langbot.app/docs/en/usage/platforms/wecom/wecombot
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecombot
config:
- name: one-click-create
label:
@@ -15,9 +15,9 @@ spec:
categories:
- china
help_links:
zh: https://link.langbot.app/zh/platforms/wecomcs
en: https://link.langbot.app/en/platforms/wecomcs
ja: https://link.langbot.app/ja/platforms/wecomcs
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecomcs
en: https://langbot.app/docs/en/usage/platforms/wecom/wecomcs
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecomcs
config:
- name: webhook_url
label:
+8 -6
View File
@@ -48,6 +48,7 @@ from ..utils import constants
_DEFAULT_BINARY_STORAGE_VALUE_BYTES = 10 * 1024 * 1024
_HARD_MAX_BINARY_STORAGE_VALUE_BYTES = 64 * 1024 * 1024
_UNSET_INSTALLATION_SCOPE = object()
def _binary_storage_value_limit(ap: Any) -> int:
@@ -479,7 +480,6 @@ class RuntimeConnectionHandler(handler.Handler):
self._outbound_installation_context: contextvars.ContextVar[InstallationBinding | None] = (
contextvars.ContextVar(
f'{self.__class__.__name__}_{id(self)}_outbound_installation',
default=None,
)
)
self._installation_bindings: dict[
@@ -1631,13 +1631,15 @@ class RuntimeConnectionHandler(handler.Handler):
) -> InstallationBinding | ActionContext | None:
if action_context is not None:
return super().resolve_outbound_action_context(action_context)
inbound_context = self.current_action_context
if inbound_context is not None:
return inbound_context
return self._outbound_installation_context.get()
# An explicit scope targets the nested call, not its inbound caller.
# None deliberately clears the context for runtime-scoped actions.
scoped_context = self._outbound_installation_context.get(_UNSET_INSTALLATION_SCOPE)
if scoped_context is not _UNSET_INSTALLATION_SCOPE:
return typing.cast(InstallationBinding | None, scoped_context)
return self.current_action_context
def require_outbound_installation_context(self) -> InstallationBinding:
binding = self._outbound_installation_context.get()
binding = self._outbound_installation_context.get(None)
if not isinstance(binding, InstallationBinding):
raise ValueError('Host plugin action requires an InstallationBinding scope')
return binding
@@ -0,0 +1,420 @@
"""ChatGPT device auth with server-only credentials and cross-process refresh leases.
Network I/O never holds a DB transaction. A persisted CAS lease serializes refresh
and poll; cancel fences device exchanges but waits for existing-token refreshes.
"""
from __future__ import annotations
import asyncio
import base64
import json
import math
import secrets
import time
from contextlib import asynccontextmanager
from datetime import datetime, timezone
import httpx
import sqlalchemy as sa
from ...entity.persistence.model import CodexCredential, ModelProvider
from ...api.http.context import PrincipalType, RequestContext
from ...api.http.authz import Permission, has_permission
from ...api.http.service.tenant import require_workspace_uuid
from ...workspace.errors import WorkspaceNotFoundError
REQUESTER = 'openai-codex'
BASE_URL = 'https://chatgpt.com/backend-api/codex'
ISSUER = 'https://auth.openai.com'
CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
LOGIN_REQUIRED = 'ChatGPT sign-in required. Open this provider and sign in again.'
LEASE_SECONDS = 90
def validate_config(data: dict) -> None:
if data.get('requester') != REQUESTER:
return
if data.get('base_url') not in (None, '', BASE_URL):
raise ValueError('Codex uses the fixed ChatGPT endpoint; custom base URLs are not supported')
if data.get('api_keys') not in (None, [], ''):
raise ValueError('Codex uses ChatGPT sign-in, not API keys')
data['base_url'] = BASE_URL
data['api_keys'] = []
def _claims(token: str) -> dict:
"""Read routing metadata, NOT trusted LangBot identity, from issuer tokens."""
try:
part = token.split('.')[1]
value = json.loads(base64.urlsafe_b64decode(part + '=' * (-len(part) % 4)))
return value if isinstance(value, dict) else {}
except (ValueError, IndexError, TypeError):
return {}
def _tokens(data: dict, previous: dict | None = None) -> dict:
previous = previous or {}
access = data.get('access_token')
refresh = data.get('refresh_token') or previous.get('refresh_token')
account = None
for token in (access, data.get('id_token')):
namespace = _claims(token or '').get('https://api.openai.com/auth', {})
if isinstance(namespace, dict) and isinstance(namespace.get('chatgpt_account_id'), str):
account = namespace['chatgpt_account_id']
break
account = account or previous.get('account_id')
try:
expires_at = (
time.time() + float(data['expires_in'])
if data.get('expires_in') is not None
else float(_claims(access or '').get('exp', 0))
)
except (TypeError, ValueError):
expires_at = 0
if (
not all(isinstance(v, str) and v for v in (access, refresh, account))
or not math.isfinite(expires_at)
or expires_at <= time.time()
):
raise ValueError('ChatGPT returned an incomplete authorization. Please sign in again.')
return {
'access_token': access,
'refresh_token': refresh,
'account_id': account,
'expires_at': expires_at,
'connection_id': previous.get('connection_id') or secrets.token_urlsafe(24),
}
class CodexAuth:
def __init__(self, ap):
self.ap = ap
def _where(self, workspace: str, provider: str):
return (CodexCredential.workspace_uuid == workspace, CodexCredential.provider_uuid == provider)
async def _execute(self, statement):
# SQLAlchemy/driver/serialization errors may embed the entire secret payload.
try:
return await self.ap.persistence_mgr.execute_async(statement)
except Exception:
raise ValueError('ChatGPT credential storage failed. Please retry.') from None
async def _read(self, workspace: str, provider: str) -> dict | None:
result = await self._execute(sa.select(CodexCredential).where(*self._where(workspace, provider)))
try:
row = result.first()
return dict(row._mapping) if row is not None else None
except Exception:
raise ValueError('ChatGPT credential storage failed. Please retry.') from None
async def _provider(self, context, provider: str, *, user: bool = False) -> str:
workspace = require_workspace_uuid(context)
if user and (
not isinstance(context, RequestContext)
or context.principal.principal_type != PrincipalType.ACCOUNT
or not context.account_uuid
or not has_permission(context, Permission.PROVIDER_SECRET_MANAGE)
):
raise ValueError('ChatGPT authorization requires an authorized workspace user')
result = await self._execute(
sa.select(ModelProvider.requester).where(
ModelProvider.workspace_uuid == workspace, ModelProvider.uuid == provider
)
)
kind = result.scalar()
if kind is None:
raise WorkspaceNotFoundError('Provider not found')
if kind != REQUESTER:
raise ValueError('This provider does not use ChatGPT sign-in')
return workspace
@asynccontextmanager
async def _lease(self, workspace: str, provider: str, *, refresh: bool = False):
owner = ('refresh:' if refresh else 'device:') + secrets.token_urlsafe(32)
deadline = time.monotonic() + 65
while True:
now = time.time()
result = await self._execute(
sa.update(CodexCredential)
.where(
*self._where(workspace, provider),
sa.or_(CodexCredential.lease_owner.is_(None), CodexCredential.lease_until < now),
)
.values(lease_owner=owner, lease_until=now + LEASE_SECONDS)
)
if result.rowcount == 1:
break
if await self._read(workspace, provider) is None:
raise ValueError(LOGIN_REQUIRED)
if time.monotonic() >= deadline:
raise ValueError('ChatGPT authorization is busy. Please retry shortly.')
await asyncio.sleep(0.1)
try:
yield owner
finally:
await self._execute(
sa.update(CodexCredential)
.where(*self._where(workspace, provider), CodexCredential.lease_owner == owner)
.values(lease_owner=None, lease_until=0)
)
async def _save(self, workspace: str, provider: str, owner: str, payload: dict) -> None:
result = await self._execute(
sa.update(CodexCredential)
.where(
*self._where(workspace, provider),
CodexCredential.lease_owner == owner,
CodexCredential.lease_until > time.time(),
)
.values(payload=payload, version=CodexCredential.version + 1)
)
if result.rowcount != 1:
raise ValueError('ChatGPT authorization was cancelled or replaced. Please retry.')
async def _post(self, path: str, *, data=None, json_body=None) -> httpx.Response:
try:
async with httpx.AsyncClient(timeout=20, follow_redirects=False) as client:
return await asyncio.wait_for(
client.post(
ISSUER + path,
data=data,
json=json_body,
headers={'Accept': 'application/json', 'User-Agent': 'LangBot'},
),
25,
)
except (httpx.HTTPError, TimeoutError):
raise ValueError('ChatGPT authorization network error. Please retry.') from None
@staticmethod
def _json(response: httpx.Response) -> dict:
try:
value = response.json()
if not isinstance(value, dict):
raise ValueError
return value
except ValueError:
raise ValueError('ChatGPT returned an invalid authorization response') from None
async def status(self, context, provider: str) -> dict:
workspace = await self._provider(context, provider, user=True)
row = await self._read(workspace, provider)
payload = row['payload'] if row else {}
tokens = payload.get('tokens')
connected = bool(tokens and not payload.get('invalid'))
return {
'status': 'connected' if connected else 'expired' if payload.get('invalid') else 'disconnected',
'connected': connected,
'expires_at': tokens.get('expires_at') if tokens else None,
}
async def start(self, context, provider: str) -> dict:
workspace = await self._provider(context, provider, user=True)
async with self._lease(workspace, provider) as owner:
response = await self._post('/api/accounts/deviceauth/usercode', json_body={'client_id': CLIENT_ID})
if response.status_code != 200:
raise ValueError('Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.')
data = self._json(response)
try:
code = data.get('user_code') or data['usercode']
device = data['device_auth_id']
interval = max(5, min(60, int(data.get('interval') or 5)))
if not isinstance(code, str) or not isinstance(device, str) or not code or not device:
raise ValueError
except (KeyError, ValueError, TypeError):
raise ValueError('ChatGPT returned an invalid device code') from None
now = time.time()
try:
expiry = data.get('expires_at')
if expiry is None:
expiry = now + float(data.get('expires_in', 900))
try:
expires_at = float(expiry)
except ValueError:
parsed = datetime.fromisoformat(expiry.replace('Z', '+00:00'))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
expires_at = parsed.timestamp()
if not math.isfinite(expires_at) or expires_at <= now:
raise ValueError
expires_at = min(now + 900, expires_at)
except (ValueError, TypeError):
raise ValueError('ChatGPT returned an invalid device code expiry') from None
pending = {
'authorization_id': secrets.token_urlsafe(32),
'user_code': code,
'device_auth_id': device,
'account_uuid': context.account_uuid,
'interval': interval,
'expires_at': expires_at,
'next_poll_at': now + interval,
}
row = await self._read(workspace, provider)
payload = dict(row['payload'])
payload['pending'] = pending
await self._save(workspace, provider, owner, payload)
return {k: pending[k] for k in ('authorization_id', 'user_code', 'interval', 'expires_at')} | {
'verification_uri': ISSUER + '/codex/device'
}
@staticmethod
def _attempt(payload: dict, context, authorization_id: str) -> dict | None:
pending = payload.get('pending')
if not pending or pending.get('authorization_id') != authorization_id:
return None
if pending.get('account_uuid') != context.account_uuid:
raise WorkspaceNotFoundError('Authorization not found')
return pending
async def poll(self, context, provider: str, authorization_id: str) -> dict:
workspace = await self._provider(context, provider, user=True)
if not isinstance(authorization_id, str) or not authorization_id:
raise ValueError('authorization_id is required')
async with self._lease(workspace, provider) as owner:
row = await self._read(workspace, provider)
payload = dict(row['payload'])
pending = self._attempt(payload, context, authorization_id)
if pending is None:
completed = payload.get('completed', {})
if (
completed.get('authorization_id') == authorization_id
and completed.get('account_uuid') == context.account_uuid
):
return {'status': 'connected'}
return {'status': 'expired'}
now = time.time()
if pending['expires_at'] <= now or pending.get('consumed'):
payload.pop('pending', None)
await self._save(workspace, provider, owner, payload)
return {'status': 'expired'}
if pending['next_poll_at'] > now:
return {'status': 'pending', 'interval': pending['interval']}
pending['next_poll_at'] = now + pending['interval']
await self._save(workspace, provider, owner, payload)
response = await self._post(
'/api/accounts/deviceauth/token',
json_body={'device_auth_id': pending['device_auth_id'], 'user_code': pending['user_code']},
)
if response.status_code in (403, 404, 429):
if response.status_code == 429:
pending['interval'] = min(60, pending['interval'] + 5)
pending['next_poll_at'] = time.time() + pending['interval']
await self._save(workspace, provider, owner, payload)
return {'status': 'pending', 'interval': pending['interval']}
if response.status_code != 200:
payload.pop('pending', None)
await self._save(workspace, provider, owner, payload)
raise ValueError('ChatGPT device authorization failed. Please start again.')
data = self._json(response)
if not data.get('authorization_code') or not data.get('code_verifier'):
payload.pop('pending', None)
await self._save(workspace, provider, owner, payload)
raise ValueError('ChatGPT returned an incomplete device authorization')
# Keep an attempt tombstone so cancel can preempt exchange, but never replay a code.
pending['consumed'] = True
await self._save(workspace, provider, owner, payload)
response = await self._post(
'/oauth/token',
data={
'grant_type': 'authorization_code',
'client_id': CLIENT_ID,
'code': data['authorization_code'],
'code_verifier': data['code_verifier'],
'redirect_uri': ISSUER + '/deviceauth/callback',
},
)
if response.status_code != 200:
raise ValueError('ChatGPT token exchange failed. Please start sign-in again.')
tokens = _tokens(self._json(response))
await self._save(
workspace,
provider,
owner,
{
'tokens': tokens,
'completed': {'authorization_id': authorization_id, 'account_uuid': context.account_uuid},
},
)
return {'status': 'connected'}
async def disconnect(self, context, provider: str) -> None:
workspace = await self._provider(context, provider, user=True)
await self._execute(
sa.update(CodexCredential)
.where(*self._where(workspace, provider))
.values(payload={}, lease_owner=None, lease_until=0, version=CodexCredential.version + 1)
)
async def cancel(self, context, provider: str, authorization_id: str) -> None:
workspace = await self._provider(context, provider, user=True)
deadline = time.monotonic() + 65
while time.monotonic() < deadline:
row = await self._read(workspace, provider)
if row is None:
return
old = row['payload']
if self._attempt(old, context, authorization_id) is None:
return
lease_owner = row['lease_owner']
if lease_owner and lease_owner.startswith('refresh:') and row['lease_until'] > time.time():
# A rotated refresh token must be committed before removing the attempt.
await asyncio.sleep(0.1)
continue
payload = dict(old)
payload.pop('pending', None)
result = await self._execute(
sa.update(CodexCredential)
.where(
*self._where(workspace, provider),
CodexCredential.version == row['version'],
# Lease acquisition does not change version; fence that race too.
CodexCredential.lease_owner == lease_owner,
)
.values(payload=payload, lease_owner=None, lease_until=0, version=CodexCredential.version + 1)
)
if result.rowcount == 1:
return
raise ValueError('Authorization changed concurrently. Please retry cancellation.')
async def access(self, context, provider: str, *, rejected_token: str | None = None) -> dict:
workspace = await self._provider(context, provider)
row = await self._read(workspace, provider)
payload = row['payload'] if row else {}
tokens = payload.get('tokens')
if not tokens or payload.get('invalid'):
raise ValueError(LOGIN_REQUIRED)
if tokens['expires_at'] > time.time() + 120 and tokens['access_token'] != rejected_token:
return tokens
async with self._lease(workspace, provider, refresh=True) as owner:
row = await self._read(workspace, provider)
payload = dict(row['payload'])
tokens = payload.get('tokens')
if not tokens or payload.get('invalid'):
raise ValueError(LOGIN_REQUIRED)
if tokens['expires_at'] > time.time() + 120 and tokens['access_token'] != rejected_token:
return tokens
response = await self._post(
'/oauth/token',
data={'grant_type': 'refresh_token', 'client_id': CLIENT_ID, 'refresh_token': tokens['refresh_token']},
)
error = self._json(response).get('error') if response.status_code in (400, 401, 403) else None
error_code = error.get('code') if isinstance(error, dict) else error
if error_code in (
'invalid_grant',
'refresh_token_reused',
'refresh_token_expired',
'refresh_token_revoked',
):
payload['invalid'] = True
payload.pop('tokens', None)
payload.pop('completed', None)
await self._save(workspace, provider, owner, payload)
raise ValueError(LOGIN_REQUIRED)
if response.status_code != 200:
raise ValueError('ChatGPT token refresh temporarily failed. Please retry.')
refreshed = _tokens(self._json(response), tokens)
payload['tokens'] = refreshed
await self._save(workspace, provider, owner, payload)
return refreshed
@@ -0,0 +1,10 @@
"""Explicitly safe Codex failures; never construct messages from upstream bodies."""
class CodexProviderError(ValueError):
"""A known provider failure safe to expose at the HTTP boundary."""
def __init__(self, message: str, status_code: int = 502, error_code: str = 'codex_upstream_failure'):
super().__init__(message)
self.status_code = status_code
self.error_code = error_code
+31 -5
View File
@@ -18,7 +18,7 @@ from ...discover import engine
from ...entity.errors import provider as provider_errors
from ...entity.persistence import model as persistence_model
from ...workspace.entities import WorkspaceExecutionBinding
from ...workspace.errors import WorkspaceError, WorkspaceInvariantError
from ...workspace.errors import WorkspaceError, WorkspaceInvariantError, WorkspaceNotFoundError
from . import requester, token
@@ -638,10 +638,32 @@ class ModelManager:
) -> requester.RuntimeLLMModel:
execution_context = await self.resolve_execution_context(context)
provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
runtime_provider = await self._build_provider(
execution_context,
persistence_model.ModelProvider(**provider_info),
)
provider_uuid = model_info.get('provider_uuid') or provider_info.get('uuid')
inline_codex = provider_info.get('requester') == 'openai-codex'
provider_entity = persistence_model.ModelProvider(**provider_info)
if provider_uuid:
if provider_info.get('uuid') and provider_info['uuid'] != provider_uuid:
raise ValueError('Conflicting provider identities')
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.workspace_uuid == execution_context.workspace_uuid,
persistence_model.ModelProvider.uuid == provider_uuid,
)
)
saved_provider = result.first()
if saved_provider is None:
if inline_codex or model_info.get('provider_uuid'):
raise WorkspaceNotFoundError('Provider not found')
else:
saved_provider = self._coerce_provider(saved_provider, execution_context)
if saved_provider.requester == 'openai-codex':
# OAuth identity and transport configuration are server-owned.
provider_entity = saved_provider
elif inline_codex:
raise ValueError('This provider does not use ChatGPT sign-in')
elif inline_codex:
raise WorkspaceNotFoundError('Provider not found')
runtime_provider = await self._build_provider(execution_context, provider_entity)
model_entity = persistence_model.LLMModel(
workspace_uuid=execution_context.workspace_uuid,
uuid=model_info.get('uuid', ''),
@@ -723,6 +745,10 @@ class ModelManager:
'requester_name': provider_entity.requester,
}
if provider_entity.requester == 'openai-codex':
config['provider_uuid'] = provider_entity.uuid
config['workspace_uuid'] = context.workspace_uuid
if litellm_provider:
from .requesters import litellmchat
@@ -0,0 +1,423 @@
"""Native ChatGPT Codex Responses/SSE requester (never Chat Completions)."""
from __future__ import annotations
import asyncio
import json
import secrets
import time
from collections import OrderedDict
import httpx
import langbot
import langbot_plugin.api.entities.builtin.provider.message as pm
from .. import requester, reasoning
from ..codex_auth import BASE_URL, CodexAuth, LOGIN_REQUIRED
from ..codex_errors import CodexProviderError
async def sse_events(response):
"""Decode SSE records, including CRLF, comments, and multiline data."""
data = []
size = 0
async for line in response.aiter_lines():
if not line:
if data:
text = '\n'.join(data)
if text == '[DONE]':
return
try:
event = json.loads(text)
if not isinstance(event, dict):
raise ValueError
except ValueError:
raise ValueError('Codex returned an invalid stream event') from None
yield event
data, size = [], 0
elif line.startswith('data:'):
value = line[5:]
if value.startswith(' '):
value = value[1:]
size += len(value)
if size > 4 * 1024 * 1024:
raise ValueError('Codex stream event exceeds the size limit')
data.append(value)
# SSE requires the blank separator; unterminated records cannot prove completion.
def _content(message):
content = message.content
if isinstance(content, str):
return [{'type': 'output_text' if message.role == 'assistant' else 'input_text', 'text': content}]
result = []
for part in content or []:
if part.type == 'text':
result.append(
{'type': 'output_text' if message.role == 'assistant' else 'input_text', 'text': part.text or ''}
)
elif part.type == 'image_url' and part.image_url is not None:
result.append({'type': 'input_image', 'image_url': part.image_url.url})
elif part.type == 'image_base64' and part.image_base64:
value = part.image_base64
result.append(
{
'type': 'input_image',
'image_url': value if value.startswith('data:') else 'data:image/png;base64,' + value,
}
)
else:
raise ValueError('Codex supports text and images only; this message contains unsupported content')
return result
def _tool(item):
try:
return pm.ToolCall(
id=item['call_id'],
type='function',
function=pm.FunctionCall(name=item['name'], arguments=item.get('arguments') or ''),
)
except (KeyError, ValueError, TypeError):
raise ValueError('Codex returned an invalid function call') from None
def _usage(response):
usage = response.get('usage') or {}
return {
'prompt_tokens': usage.get('input_tokens', 0),
'completion_tokens': usage.get('output_tokens', 0),
'total_tokens': usage.get('total_tokens', usage.get('input_tokens', 0) + usage.get('output_tokens', 0)),
'prompt_tokens_details': usage.get('input_tokens_details', {}),
'completion_tokens_details': usage.get('output_tokens_details', {}),
}
class CodexRequester(requester.ProviderAPIRequester):
async def initialize(self):
self.auth = CodexAuth(self.ap)
self.workspace = self.requester_cfg['workspace_uuid']
self.provider = self.requester_cfg['provider_uuid']
# Opaque replay data stays server-side; handles are scoped to the same query,
# model and OAuth connection. No token or encrypted reasoning enters messages.
self._replay = OrderedDict()
async def aclose(self):
self._replay.clear()
def get_reasoning_capabilities(self, model):
return {
'supported': True,
'levels': ['provider_default', 'low', 'medium', 'high', 'xhigh'],
'source': 'provider',
}
@staticmethod
def _headers(tokens, *, stream=False):
return {
'Authorization': 'Bearer ' + tokens['access_token'],
'ChatGPT-Account-ID': tokens['account_id'],
'User-Agent': 'LangBot/' + langbot.__version__,
'originator': 'langbot',
'OpenAI-Beta': 'responses=experimental',
'Accept': 'text/event-stream' if stream else 'application/json',
}
@staticmethod
def _http_error(status):
if status == 401:
# Upstream authentication is not LangBot authentication: HTTP401 would
# make the browser discard its own valid user session.
return CodexProviderError(LOGIN_REQUIRED, 400, 'codex_reauthentication_required')
if status == 429:
return CodexProviderError(
'ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.',
429,
'codex_rate_limited',
)
if status == 403:
return CodexProviderError(
'ChatGPT denied this request. Check subscription and workspace permissions.',
403,
'codex_access_denied',
)
if status == 400:
return CodexProviderError(
'ChatGPT rejected the model or request. Check the selected model and request settings.',
400,
'codex_invalid_request',
)
return CodexProviderError('ChatGPT Codex upstream request failed. Please retry later.')
async def _response_error(self, response):
# Inspect only a bounded 429 error record and an allowlisted machine code.
# Never expose upstream prose, reset metadata, headers or credentials.
if response.status_code == 429:
payload = bytearray()
async for chunk in response.aiter_bytes():
if len(payload) + len(chunk) > 8192:
return self._http_error(429)
payload.extend(chunk)
try:
data = json.loads(payload)
error = data.get('error') if isinstance(data, dict) else None
if isinstance(error, dict) and (
error.get('type') == 'usage_limit_reached' or error.get('code') == 'usage_limit_reached'
):
return CodexProviderError(
'ChatGPT subscription usage limit reached. Please retry later or check your plan.',
429,
'codex_usage_limit_reached',
)
except (ValueError, UnicodeError):
pass
return self._http_error(response.status_code)
def _scope(self, query, model, tokens):
return (
id(query),
getattr(query, 'query_id', None),
model.model_entity.name,
tokens.get('connection_id'),
tokens['account_id'],
)
def _body(self, query, model, messages, funcs, extra_args, tokens):
args = {**(model.model_entity.extra_args or {}), **(extra_args or {})}
# Never permit credentials, transport overrides, store/history or arbitrary
# SDK kwargs to be smuggled through model advanced parameters.
allowed = {'reasoning', 'text', 'parallel_tool_calls', 'tool_choice'}
unknown = set(args) - allowed
if unknown:
raise ValueError('Unsupported Codex advanced parameters: ' + ', '.join(sorted(unknown)))
instructions = []
items = []
scope = self._scope(query, model, tokens)
for message in messages:
if message.role in ('system', 'developer'):
instructions.append('\n'.join(p['text'] for p in _content(message) if 'text' in p))
continue
if message.role == 'tool':
if not message.tool_call_id:
raise ValueError('Codex tool results require a tool_call_id')
output = (
message.content
if isinstance(message.content, str)
else json.dumps([p.model_dump(exclude_none=True) for p in message.content or []])
)
items.append({'type': 'function_call_output', 'call_id': message.tool_call_id, 'output': output or ''})
continue
if message.role not in ('assistant', 'user'):
raise ValueError('Unsupported Codex message role')
handle = (message.provider_specific_fields or {}).get('codex_replay_id')
cached = self._replay.get(handle) if isinstance(handle, str) else None
if query is not None and cached and cached[0] == scope and cached[1] > time.time():
items.extend(cached[2])
continue
content = _content(message)
if content:
items.append({'type': 'message', 'role': message.role, 'content': content})
for call in message.tool_calls or []:
items.append(
{
'type': 'function_call',
'call_id': call.id,
'name': call.function.name,
'arguments': call.function.arguments,
}
)
body = {
**args,
'model': model.model_entity.name,
'instructions': '\n\n'.join(instructions),
'input': items,
'store': False,
'stream': True,
'include': ['reasoning.encrypted_content'],
}
level = reasoning.normalize_reasoning_config(getattr(model.model_entity, 'reasoning_config', None))['level']
if level != 'provider_default':
reasoning.validate_reasoning_capabilities(
{'level': level}, self.get_reasoning_capabilities(model), model.model_entity.name
)
body['reasoning'] = {'effort': level, 'summary': 'auto'}
if funcs:
body['tools'] = [
{
'type': 'function',
'name': f.name,
'description': f.description,
'parameters': f.parameters,
'strict': False,
}
for f in funcs
]
return body
async def _events(self, query, model, messages, funcs, extra_args):
tokens = await self.auth.access(self.workspace, self.provider)
try:
async with asyncio.timeout(300), httpx.AsyncClient(timeout=120, follow_redirects=False) as client:
for attempt in range(2):
body = self._body(query, model, messages, funcs, extra_args, tokens)
async with client.stream(
'POST', BASE_URL + '/responses', json=body, headers=self._headers(tokens, stream=True)
) as response:
if response.status_code == 401 and attempt == 0:
tokens = await self.auth.access(
self.workspace, self.provider, rejected_token=tokens['access_token']
)
continue
if response.status_code != 200:
raise await self._response_error(response)
async for event in sse_events(response):
yield event, tokens
return
except (httpx.HTTPError, TimeoutError):
raise ValueError('ChatGPT Codex network error or timeout. Please retry.') from None
async def _chunks(self, query, model, messages, funcs, extra_args, remove_think, usage_out):
text = ''
seen_calls = set()
output_items = {}
response_id = None
async for event, tokens in self._events(query, model, messages, funcs, extra_args):
kind = event.get('type')
response = event.get('response') or {}
response_id = response.get('id') or response_id
if kind in ('error', 'response.failed', 'response.incomplete'):
raise CodexProviderError('ChatGPT Codex response failed or was incomplete. Please retry.')
if kind == 'response.output_text.delta':
delta = event.get('delta', '')
text += delta
yield pm.MessageChunk(role='assistant', content=delta, resp_message_id=response_id)
elif kind in ('response.reasoning_summary_text.delta', 'response.reasoning_text.delta'):
if not remove_think:
yield pm.MessageChunk(
role='assistant',
content='',
provider_specific_fields={'reasoning_content': event.get('delta', '')},
)
elif kind == 'response.output_item.done':
item = event.get('item') or {}
output_items[event.get('output_index', len(output_items))] = item
if item.get('type') == 'function_call' and item.get('call_id') not in seen_calls:
seen_calls.add(item.get('call_id'))
yield pm.MessageChunk(role='assistant', content='', tool_calls=[_tool(item)])
elif kind in ('response.completed', 'response.done'):
if response.get('status') not in (None, 'completed'):
raise CodexProviderError('ChatGPT Codex response was not completed')
output = response.get('output') or [output_items[k] for k in sorted(output_items)]
for item in output:
if item.get('type') == 'function_call' and item.get('call_id') not in seen_calls:
seen_calls.add(item.get('call_id'))
yield pm.MessageChunk(role='assistant', content='', tool_calls=[_tool(item)])
# Some servers send only the terminal output, without text deltas.
final_text = ''.join(
p.get('text', '')
for item in output
if item.get('type') == 'message'
for p in item.get('content', [])
if p.get('type') == 'output_text'
)
if not text and final_text:
text = final_text
yield pm.MessageChunk(role='assistant', content=text, resp_message_id=response_id)
usage_out.update(_usage(response))
if query is not None:
if query.variables is None:
query.variables = {}
query.variables[requester.STREAM_USAGE_QUERY_VARIABLE] = dict(usage_out)
fields = None
if query is not None and output:
handle = secrets.token_urlsafe(24)
self._replay[handle] = (self._scope(query, model, tokens), time.time() + 3600, output)
while len(self._replay) > 64:
self._replay.popitem(last=False)
fields = {'codex_replay_id': handle}
yield pm.MessageChunk(
role='assistant',
content='',
all_content=text,
is_final=True,
resp_message_id=response_id,
provider_specific_fields=fields,
)
return
raise CodexProviderError('ChatGPT Codex stream ended before completion. Please retry.')
async def invoke_llm_stream(self, query, model, messages, funcs=None, extra_args=None, remove_think=False):
async for chunk in self._chunks(query, model, messages, funcs, extra_args, remove_think, {}):
yield chunk
async def invoke_llm(self, query, model, messages, funcs=None, extra_args=None, remove_think=False):
usage = {}
text = ''
calls = []
fields = {}
response_id = None
async for chunk in self._chunks(query, model, messages, funcs, extra_args, remove_think, usage):
text += chunk.content or ''
calls.extend(chunk.tool_calls or [])
response_id = chunk.resp_message_id or response_id
for key, value in (chunk.provider_specific_fields or {}).items():
fields[key] = fields.get(key, '') + value if key == 'reasoning_content' else value
return pm.Message(
role='assistant',
content=text,
tool_calls=calls or None,
resp_message_id=response_id,
provider_specific_fields=fields or None,
), usage
async def scan_models(self, api_key=None):
tokens = await self.auth.access(self.workspace, self.provider)
try:
async with asyncio.timeout(90), httpx.AsyncClient(timeout=30, follow_redirects=False) as client:
for attempt in range(2):
response = await client.get(
BASE_URL + '/models',
params={'client_version': langbot.__version__},
headers=self._headers(tokens),
)
if response.status_code == 401 and attempt == 0:
tokens = await self.auth.access(
self.workspace, self.provider, rejected_token=tokens['access_token']
)
continue
if response.status_code != 200:
raise await self._response_error(response)
data = response.json()
if not isinstance(data, dict) or not isinstance(data.get('models'), list):
raise ValueError('ChatGPT returned an invalid model catalog')
result = {}
for item in data['models']:
name = item.get('slug') or item.get('id')
if not isinstance(name, str) or not name or item.get('visibility') == 'hide':
continue
modalities = item.get('input_modalities') or ['text']
abilities = ['func_call']
if 'image' in modalities:
abilities.append('vision')
if item.get('supported_reasoning_levels'):
abilities.append('reasoning')
result[name] = {
'id': name,
'name': name,
'type': 'llm',
'abilities': abilities,
'display_name': item.get('display_name'),
'description': item.get('description'),
'context_length': item.get('context_window'),
'input_modalities': modalities,
'output_modalities': ['text'],
'owned_by': 'openai',
}
return {'models': list(result.values()), 'debug': None}
except (httpx.HTTPError, TimeoutError):
raise ValueError('ChatGPT model discovery network error. Please retry.') from None
except (ValueError, TypeError, KeyError, AttributeError) as exc:
# Never echo upstream response bodies (which may contain credentials).
if isinstance(exc, ValueError) and str(exc).startswith(('ChatGPT', 'Codex')):
raise
raise ValueError('ChatGPT returned an invalid model catalog') from None
@@ -0,0 +1,27 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: openai-codex
label:
en_US: OpenAI Codex
zh_Hans: OpenAI Codex
ja_JP: OpenAI Codex
icon: openai.svg
spec:
config:
- name: base_url
label:
en_US: ChatGPT endpoint
zh_Hans: ChatGPT 服务地址
ja_JP: ChatGPT エンドポイント
type: string
required: false
default: https://chatgpt.com/backend-api/codex
alias: "openai codex ChatGPT subscription OAuth 订阅"
support_type:
- llm
provider_category: manufacturer
execution:
python:
path: ./codex.py
attr: CodexRequester
@@ -90,7 +90,7 @@ class LangflowAPIRunner(runner.RequestRunner):
}
# 如果配置中有tweaks,则添加到负载中
tweaks = json.loads(self.pipeline_config['ai']['langflow-api'].get('tweaks'))
tweaks = json.loads(self.pipeline_config['ai']['langflow-api'].get('tweaks') or '{}')
if tweaks:
payload['tweaks'] = tweaks
+13 -1
View File
@@ -39,6 +39,9 @@ class N8nServiceAPIRunner(runner.RequestRunner):
# 获取输出键名,默认为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
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(
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
) 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 = (
await httpclient.read_limited(
response,
@@ -272,6 +279,11 @@ class N8nServiceAPIRunner(runner.RequestRunner):
self.ap.logger.error(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):
if is_stream:
yield chunk
+56 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import dataclasses
import enum
import json
import math
@@ -206,6 +207,13 @@ class MCPSessionStatus(enum.Enum):
ERROR = 'error'
@dataclasses.dataclass(frozen=True)
class MCPOAuthChallenge:
"""Bearer challenge metadata returned by an OAuth-protected MCP server."""
resource_metadata_url: str | None
class _TransportReconnect(Exception):
"""Internal signal: the Box stdio WS transport dropped but the managed
process is still alive. Triggers a lightweight transport reconnect that
@@ -265,6 +273,7 @@ class RuntimeMCPSession:
_ready_event: asyncio.Event
error_message: str | None = None
_public_error_code: str = 'runtime_error'
error_phase: MCPSessionErrorPhase | None = None
@@ -510,6 +519,13 @@ class RuntimeMCPSession:
await self._init_streamable_http_server()
return
except Exception as e:
if self._extract_oauth_challenge(e) is not None:
self.error_phase = MCPSessionErrorPhase.OAUTH_REQUIRED
self.ap.logger.info(
f'MCP server {self.server_name}: remote server requires OAuth authorization; '
'not falling back to SSE'
)
raise
if not self._should_fallback_to_sse(e):
self.ap.logger.info(
f'MCP server {self.server_name}: Streamable HTTP transport failed '
@@ -630,6 +646,7 @@ class RuntimeMCPSession:
except Exception as e:
self.status = MCPSessionStatus.ERROR
self.error_message = str(e)
self._public_error_code = self._classify_public_error(e)
self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}')
# Do NOT set _ready_event here — let _lifecycle_loop_with_retry
# handle retries first. It will set the event when all retries
@@ -752,6 +769,11 @@ class RuntimeMCPSession:
except Exception as e:
if self._shutdown_event.is_set():
return # Shutdown requested, don't retry
if self.error_phase == MCPSessionErrorPhase.OAUTH_REQUIRED:
self.retry_count = attempt + 1
self.status = MCPSessionStatus.ERROR
self._ready_event.set()
return
if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE:
box_service = getattr(self.ap, 'box_service', None)
if box_service is not None and getattr(box_service, 'enabled', True):
@@ -832,6 +854,39 @@ class RuntimeMCPSession:
else:
yield exc
@staticmethod
def _classify_public_error(exc: BaseException) -> str:
"""Expose a safe category without transport URLs, headers, or arguments."""
for leaf in RuntimeMCPSession._iter_exception_leaves(exc):
if isinstance(leaf, httpx.HTTPStatusError):
return f'http_{leaf.response.status_code}'
if isinstance(leaf, (httpx.TimeoutException, TimeoutError)):
return 'connection_timeout'
if isinstance(leaf, httpx.ConnectError):
return 'connection_unreachable'
return 'runtime_error'
@staticmethod
def _extract_oauth_challenge(exc: BaseException) -> MCPOAuthChallenge | None:
"""Extract an OAuth Bearer challenge from a remote MCP connection failure."""
for leaf in RuntimeMCPSession._iter_exception_leaves(exc):
if not isinstance(leaf, httpx.HTTPStatusError) or leaf.response.status_code != 401:
continue
for header in leaf.response.headers.get_list('www-authenticate'):
bearer_match = re.search(r'(?:^|,)\s*Bearer(?:\s|,|$)', header, flags=re.IGNORECASE)
if bearer_match is None:
continue
metadata_match = re.search(
r'(?:^|,)\s*resource_metadata\s*=\s*(?:"([^"]+)"|([^,\s]+))',
header[bearer_match.end() :],
flags=re.IGNORECASE,
)
if metadata_match is None:
continue
resource_metadata_url = metadata_match.group(1) or metadata_match.group(2)
return MCPOAuthChallenge(resource_metadata_url=resource_metadata_url)
return None
@staticmethod
def _should_fallback_to_sse(exc: BaseException) -> bool:
"""Whether a Streamable HTTP failure matches legacy-SSE fallback.
@@ -1374,7 +1429,7 @@ class RuntimeMCPSession:
# environment values. Detailed diagnostics belong in AUDIT_VIEW
# logs; resource-list responses expose only a stable status.
'error_message': 'MCP runtime failed' if self.error_message else None,
'error_code': 'runtime_error' if self.error_message else None,
'error_code': self._public_error_code if self.error_message else None,
'error_phase': self.error_phase.value if self.error_phase else None,
'retry_count': self.retry_count,
'tool_count': len(self.get_tools()),
@@ -52,6 +52,7 @@ class MCPSessionErrorPhase(enum.Enum):
MCP_INIT = 'mcp_init'
RUNTIME = 'runtime'
TOOL_CALL = 'tool_call'
OAUTH_REQUIRED = 'oauth_required'
# Stdio MCP refused because Box is disabled in config or currently
# unavailable. Not transient — retries would be pointless. The frontend
# uses this phase to render a localized actionable message instead of
+1 -1
View File
@@ -83,7 +83,7 @@ class VersionManager:
try:
if await self.is_new_version_available():
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,
)
except Exception as e:
+25 -26
View File
@@ -101,18 +101,6 @@ class SeekDBVectorDatabase(VectorDatabase):
self._collection_configs: Dict[str, HNSWConfiguration] = {}
self._runtime_cache_limit = runtime_cache_limit(ap)
self._escape_table = str.maketrans(
{
'\x00': '',
'\\': '\\\\',
"'": "''", # Standard SQL escaping (OceanBase NO_BACKSLASH_ESCAPES)
'"': '\\"',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
}
)
async def close(self) -> None:
self._collections.clear()
self._collection_configs.clear()
@@ -192,16 +180,22 @@ class SeekDBVectorDatabase(VectorDatabase):
return coll
def _clean_metadata(self, meta: Dict[str, Any]) -> Dict[str, Any]:
"""SeekDB metadata doesn't support \\ and ", insert will error 3104"""
return {
k: v.translate(self._escape_table)
if isinstance(v, str)
else v
if v is None or isinstance(v, (int, float, bool))
else str(v)
for k, v in meta.items()
if v is not None
}
"""Keep supported scalar metadata values without altering strings."""
return {k: v if isinstance(v, (str, int, float, bool)) else str(v) for k, v in meta.items() if v is not None}
@staticmethod
def _relevance_scores_to_distances(results: Dict[str, Any]) -> None:
"""Convert SeekDB hybrid relevance scores to lower-is-better distances."""
distances = results.get('distances')
if not isinstance(distances, list):
return
results['distances'] = [
[1.0 - float(score) if isinstance(score, (int, float)) else score for score in batch]
if isinstance(batch, list)
else batch
for batch in distances
]
async def get_or_create_collection(self, collection: str):
"""Get or create collection (without vector size - will use default)."""
@@ -236,10 +230,10 @@ class SeekDBVectorDatabase(VectorDatabase):
kwargs: Dict[str, Any] = dict(ids=ids, embeddings=embeddings_list, metadatas=cleaned_metadatas)
if documents is not None:
kwargs['documents'] = [doc.translate(self._escape_table) for doc in documents]
await asyncio.to_thread(coll.add, **kwargs)
kwargs['documents'] = documents
await asyncio.to_thread(coll.upsert, **kwargs)
self.ap.logger.info(f"Added {len(ids)} embeddings to SeekDB collection '{collection}'")
self.ap.logger.info(f"Upserted {len(ids)} embeddings into SeekDB collection '{collection}'")
async def search(
self,
@@ -287,7 +281,8 @@ class SeekDBVectorDatabase(VectorDatabase):
# Route by search type.
# pyseekdb's query() always requires embeddings, so full-text and
# hybrid modes use hybrid_search() which supports text-only queries
# and returns the same nested-list format with distances.
# and returns relevance scores in the nested ``distances`` field.
returns_relevance_scores = False
if search_type == SearchType.FULL_TEXT:
if not query_text:
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
@@ -309,6 +304,7 @@ class SeekDBVectorDatabase(VectorDatabase):
n_results=k,
include=['documents', 'metadatas'],
)
returns_relevance_scores = True
elif search_type == SearchType.HYBRID:
if not query_text:
@@ -352,6 +348,7 @@ class SeekDBVectorDatabase(VectorDatabase):
n_results=k,
include=['documents', 'metadatas'],
)
returns_relevance_scores = True
self.ap.logger.info(
f"SeekDB hybrid search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
)
@@ -363,6 +360,8 @@ class SeekDBVectorDatabase(VectorDatabase):
results = await asyncio.to_thread(coll.query, **query_kwargs)
results = self._json_safe(results)
if returns_relevance_scores:
self._relevance_scores_to_distances(results)
self.ap.logger.info(
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
)
@@ -269,7 +269,7 @@ class InvitationDeliveryService:
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
<tr>
<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>
</td>
</tr>
@@ -80,7 +80,8 @@
"header-name": "",
"header-value": "",
"timeout": 120,
"output-key": "response"
"output-key": "response",
"response-handling": "reply"
},
"langflow-api": {
"base-url": "http://localhost:7860",
+54 -12
View File
@@ -143,18 +143,41 @@ stages:
operator: eq
value: false
disabled_tooltip:
en_US: >-
Sandbox scope can't be changed: either the Box sandbox is disabled
or unavailable (enable it in config.yaml with box.enabled = true and
ensure the runtime is reachable), or this deployment pins all
pipelines to a fixed scope.
zh_Hans: "无法修改沙箱作用域:Box 沙箱已禁用或不可用(请在配置中启用 box.enabled = true 并确认运行时连接正常),或本部署已将所有流水线固定为统一作用域。"
zh_Hant: "無法修改沙箱作用域:Box 沙箱已停用或無法使用(請在設定中啟用 box.enabled = true 並確認執行時連線正常),或本部署已將所有流水線固定為統一作用域。"
ja_JP: "サンドボックススコープを変更できません:Box サンドボックスが無効/利用不可(設定で box.enabled = true にしてランタイム接続を確認)、またはこのデプロイがすべてのパイプラインを固定スコープに制限しています。"
vi_VN: "Không thể thay đổi phạm vi sandboxBox sandbox bị tắt hoặc không khả dụng (bật box.enabled = true và đảm bảo runtime hoạt động), hoặc bản triển khai này cố định mọi pipeline về một phạm vi."
th_TH: "ไม่สามารถเปลี่ยนขอบเขต Sandbox:Box sandbox ถูกปิดหรือไม่พร้อมใช้งาน (เปิด box.enabled = true และตรวจสอบรันไทม์) หรือการ deploy นี้ล็อกทุก pipeline ไว้ที่ขอบเขตเดียว"
es_ES: "No se puede cambiar el alcance del sandbox: el sandbox de Box está desactivado o no disponible (actívelo con box.enabled = true y verifique el runtime), o este despliegue fija todas las pipelines a un alcance único."
ru_RU: "Невозможно изменить область песочницы: песочница Box отключена или недоступна (включите box.enabled = true и проверьте среду выполнения), либо это развёртывание фиксирует единую область для всех конвейеров."
en_US: "Sandbox is unavailable. Enable Box and check its connection before changing the scope."
zh_Hans: "沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。"
zh_Hant: "沙箱未啟用,請啟用 Box 並確認連線正常後再修改作用域。"
ja_JP: "サンドボックスは利用できません。Box を有効にし、接続を確認してからスコープを変更してください。"
vi_VN: "Sandbox không khả dụng. Hãy bật Box và kiểm tra kết nối trước khi thay đổi phạm vi."
th_TH: "Sandbox ไม่พร้อมใช้งาน โปรดเปิดใช้งาน Box และตรวจสอบการเชื่อมต่อก่อนเปลี่ยนขอบเขต"
es_ES: "El sandbox no está disponible. Active Box y compruebe su conexión antes de cambiar el alcance."
ru_RU: "Песочница недоступна. Включите Box и проверьте подключение, прежде чем менять область."
disabled_tooltip_overrides:
- when:
field: __system.box_scope_forced_global
operator: eq
value: true
tooltip:
en_US: "A global sandbox is enforced; the scope cannot be changed."
zh_Hans: "已强制使用全局沙箱,无法修改作用域。"
zh_Hant: "已強制使用全域沙箱,無法修改作用域。"
ja_JP: "グローバルサンドボックスの使用が強制されているため、スコープを変更できません。"
vi_VN: "Bắt buộc sử dụng sandbox toàn cục; không thể thay đổi phạm vi."
th_TH: "ระบบบังคับใช้ Sandbox ส่วนกลาง จึงไม่สามารถเปลี่ยนขอบเขตได้"
es_ES: "Se impone un sandbox global; no se puede cambiar el alcance."
ru_RU: "Принудительно используется глобальная песочница; изменить область нельзя."
- when:
field: __system.box_scope_forced
operator: eq
value: true
tooltip:
en_US: "A fixed sandbox scope is enforced; the scope cannot be changed."
zh_Hans: "已强制使用固定沙箱作用域,无法修改作用域。"
zh_Hant: "已強制使用固定沙箱作用域,無法修改作用域。"
ja_JP: "固定のサンドボックススコープが強制されているため、スコープを変更できません。"
vi_VN: "Phạm vi sandbox đã được cố định bắt buộc; không thể thay đổi phạm vi."
th_TH: "ระบบบังคับใช้ขอบเขต Sandbox แบบตายตัว จึงไม่สามารถเปลี่ยนขอบเขตได้"
es_ES: "Se impone un alcance fijo del sandbox; no se puede cambiar el alcance."
ru_RU: "Принудительно задана фиксированная область песочницы; изменить её нельзя."
type: select
required: false
default: "{launcher_type}_{launcher_id}"
@@ -475,6 +498,25 @@ stages:
type: string
required: false
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
label:
en_US: coze API
+106
View File
@@ -0,0 +1,106 @@
"""Exercise Codex provider wiring through a real LangBot process.
The default run does not contact OpenAI. Set LANGBOT_TEST_CODEX_DEVICE_AUTH=1
to also exercise live device start/pending/cancel, without account sign-in.
OAuth exchange and inference behavior are covered by deterministic tests.
"""
from __future__ import annotations
import os
import time
import pytest
pytestmark = pytest.mark.e2e
def test_codex_provider_disconnected_journey(e2e_client):
credentials = {'user': 'codex-e2e@example.com', 'password': 'codex-local-test-password'}
initialized = e2e_client.post('/api/v1/user/init', json=credentials)
assert initialized.status_code == 200, initialized.text
authenticated = e2e_client.post('/api/v1/user/auth', json=credentials)
assert authenticated.status_code == 200, authenticated.text
headers = {'Authorization': f'Bearer {authenticated.json()["data"]["token"]}'}
bootstrap = e2e_client.get('/api/v1/workspaces/bootstrap', headers=headers)
assert bootstrap.status_code == 200, bootstrap.text
headers['X-Workspace-Id'] = bootstrap.json()['data']['workspaces'][0]['workspace']['uuid']
requesters = e2e_client.get('/api/v1/provider/requesters?type=llm', headers=headers)
assert requesters.status_code == 200, requesters.text
codex = next(item for item in requesters.json()['data']['requesters'] if item['name'] == 'openai-codex')
assert codex['spec']['support_type'] == ['llm']
icon = e2e_client.get('/api/v1/provider/requesters/openai-codex/icon')
assert icon.status_code == 200
assert 'image/' in icon.headers['content-type']
base = '/api/v1/provider/providers'
created = e2e_client.post(
base,
headers=headers,
json={'name': 'Codex E2E', 'requester': 'openai-codex', 'base_url': '', 'api_keys': []},
)
assert created.status_code == 200, created.text
provider_path = f'{base}/{created.json()["data"]["uuid"]}'
try:
provider = e2e_client.get(provider_path, headers=headers)
assert provider.status_code == 200, provider.text
data = provider.json()['data']['provider']
assert data['requester'] == 'openai-codex'
assert data['api_keys'] == []
assert data['base_url'] == 'https://chatgpt.com/backend-api/codex'
assert not {'access_token', 'refresh_token', 'id_token'} & data.keys()
status = e2e_client.get(f'{provider_path}/codex/status', headers=headers)
assert status.status_code == 200, status.text
assert status.json()['data']['connected'] is False
assert status.json()['data']['status'] == 'disconnected'
anonymous = e2e_client.post(f'{provider_path}/codex/device', json={})
assert anonymous.status_code == 401
invalid = e2e_client.put(provider_path, headers=headers, json={'base_url': 'https://example.com'})
assert invalid.status_code == 400, invalid.text
invalid_key = e2e_client.put(provider_path, headers=headers, json={'api_keys': ['not-a-codex-key']})
assert invalid_key.status_code == 400, invalid_key.text
scanned = e2e_client.get(f'{provider_path}/scan-models?type=llm', headers=headers)
assert scanned.status_code == 400, scanned.text
assert 'sign in' in scanned.json()['msg'].lower()
renamed = e2e_client.put(provider_path, headers=headers, json={'name': 'Codex renamed'})
assert renamed.status_code == 200, renamed.text
reread = e2e_client.get(provider_path, headers=headers)
assert reread.json()['data']['provider']['name'] == 'Codex renamed'
disconnected = e2e_client.delete(f'{provider_path}/codex/auth', headers=headers)
assert disconnected.status_code == 200, disconnected.text
# Opt-in smoke contacts real OpenAI device endpoints, but never completes
# account sign-in or prints the one-time code/device credentials.
if os.environ.get('LANGBOT_TEST_CODEX_DEVICE_AUTH') == '1':
started = e2e_client.post(f'{provider_path}/codex/device', headers=headers, json={})
assert started.status_code == 200, started.json().get('msg', 'Device start failed')
attempt = started.json()['data']
assert attempt['verification_uri'] == 'https://auth.openai.com/codex/device'
assert isinstance(attempt['user_code'], str) and attempt['user_code']
assert 0 < attempt['expires_at'] - time.time() <= 900
assert not {'access_token', 'refresh_token', 'device_auth_id'} & attempt.keys()
time.sleep(attempt['interval'])
pending = e2e_client.post(
f'{provider_path}/codex/device/poll',
headers=headers,
json={'authorization_id': attempt['authorization_id']},
)
assert pending.status_code == 200
assert pending.json()['data']['status'] == 'pending'
canceled = e2e_client.delete(f'{provider_path}/codex/device/{attempt["authorization_id"]}', headers=headers)
assert canceled.status_code == 200
expired = e2e_client.post(
f'{provider_path}/codex/device/poll',
headers=headers,
json={'authorization_id': attempt['authorization_id']},
)
assert expired.json()['data']['status'] == 'expired'
finally:
deleted = e2e_client.delete(provider_path, headers=headers)
assert deleted.status_code == 200, deleted.text
assert e2e_client.get(provider_path, headers=headers).status_code == 404
+1 -1
View File
@@ -69,7 +69,7 @@ class LangBotProcess:
# Use coverage.py to collect coverage data
# Set COVERAGE_PROCESS_START to enable coverage in subprocess
self._coverage_file = self.work_dir / '.coverage.e2e'
env['COVERAGE_PROCESS_START'] = str(self.project_root / '.coveragerc')
env['COVERAGE_PROCESS_START'] = str(self.work_dir / '.coveragerc')
env['COVERAGE_FILE'] = str(self._coverage_file)
# Create .coveragerc for subprocess
+11 -4
View File
@@ -9,7 +9,7 @@ Run: uv run pytest tests/integration/api/test_monitoring.py -q
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from unittest.mock import MagicMock, AsyncMock, Mock, patch
from types import SimpleNamespace
from tests.factories import FakeApp
@@ -280,13 +280,20 @@ class TestMonitoringAllDataEndpoint:
@pytest.mark.asyncio
async def test_get_all_data_success(self, quart_test_client):
"""GET /api/v1/monitoring/data returns all data."""
response = await quart_test_client.get(
'/api/v1/monitoring/data', headers={'Authorization': 'Bearer test_token'}
)
traffic = {'series': [], 'truncated': False}
with patch(
'langbot.pkg.api.http.controller.groups.monitoring.get_traffic_series',
new=AsyncMock(return_value=traffic),
) as get_traffic:
response = await quart_test_client.get(
'/api/v1/monitoring/data', headers={'Authorization': 'Bearer test_token'}
)
get_traffic.assert_awaited_once()
assert response.status_code == 200
data = await response.get_json()
assert 'overview' in data['data']
assert data['data']['traffic'] == traffic
@pytest.mark.usefixtures('mock_circular_import_chain')
@@ -0,0 +1,85 @@
from __future__ import annotations
import logging
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from quart import Quart
from langbot.pkg.api.http.controller.groups import user as user_module
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
from langbot.pkg.api.http.service.user import UserService
from langbot.pkg.core.stages.genkeys import GenKeysStage
from langbot.pkg.persistence.mgr import PersistenceManager
from langbot.pkg.utils import constants
from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService
from langbot.pkg.workspace.service import WorkspaceService
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
async def test_generated_recovery_code_resets_real_sqlite_account(tmp_path, monkeypatch):
"""Exercise generation, reset, and old/new password login without mocked user services."""
monkeypatch.setattr(constants, 'instance_id', 'recovery-journey')
monkeypatch.setattr(user_module, '_reset_password_state', {'window_started_at': 0.0, 'attempts': 0})
monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=AsyncMock()))
application = SimpleNamespace(
logger=logging.getLogger('recovery-password-journey'),
instance_config=SimpleNamespace(
data={
'database': {'use': 'sqlite', 'sqlite': {'path': str(tmp_path / 'recovery.db')}},
'system': {
'jwt': {'secret': 'recovery-journey-test-secret-only', 'expire': 3600},
'recovery_key': '',
},
},
dump_config=AsyncMock(),
),
)
await GenKeysStage().run(application)
key = application.instance_config.data['system']['recovery_key']
assert len(key) == 8
assert set(key) <= set('23456789ABCDEFGHJKLMNPQRSTUVWXYZ')
persistence = PersistenceManager(application)
application.persistence_mgr = persistence
try:
await persistence.initialize()
application.workspace_service = WorkspaceService(application, instance_uuid='recovery-journey')
application.workspace_collaboration_service = WorkspaceCollaborationService(
application, application.workspace_service
)
application.user_service = UserService(application)
quart_app = Quart(__name__)
await UserRouterGroup(application, quart_app).initialize()
client = quart_app.test_client()
initial = await client.post(
'/api/v1/user/init', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'}
)
assert initial.status_code == 200
assert (await initial.get_json())['code'] == 0
payload = {'user': 'owner@example.com', 'recovery_key': 'WRONG', 'new_password': 'RecoveredPass1!'}
wrong = await client.post('/api/v1/user/reset-password', json=payload)
assert wrong.status_code == 403
unchanged = await client.post(
'/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'}
)
assert (await unchanged.get_json())['code'] == 0
reset = await client.post('/api/v1/user/reset-password', json={**payload, 'recovery_key': key})
assert reset.status_code == 200
assert (await reset.get_json())['code'] == 0
old_login = await client.post(
'/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'}
)
assert (await old_login.get_json())['code'] != 0
new_login = await client.post(
'/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'RecoveredPass1!'}
)
new_data = await new_login.get_json()
assert new_data['code'] == 0
assert new_data['data']['token']
finally:
await persistence.get_db_engine().dispose()
+6
View File
@@ -310,6 +310,8 @@ class TestUserInitEndpoint:
'invitation_registration_enabled': True,
'password_login_enabled': True,
'space_login_enabled': False,
'passkey_login_enabled': True,
'passkey_supported': True,
}
fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
fake_api_app.user_service.get_first_user.assert_not_awaited()
@@ -334,6 +336,8 @@ class TestUserInitEndpoint:
'invitation_registration_enabled': False,
'password_login_enabled': False,
'space_login_enabled': True,
'passkey_login_enabled': True,
'passkey_supported': True,
}
@pytest.mark.asyncio
@@ -355,6 +359,8 @@ class TestUserInitEndpoint:
'invitation_registration_enabled': True,
'password_login_enabled': False,
'space_login_enabled': True,
'passkey_login_enabled': True,
'passkey_supported': True,
}
@pytest.mark.asyncio

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