fix(external-systems): preserve credentials on definition updates

This commit is contained in:
caoqianming 2026-08-07 11:25:29 +08:00
parent 0e4bd0456a
commit 9032c5b517
9 changed files with 290 additions and 63 deletions

View File

@ -5,6 +5,11 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.63.1 — 2026-08-07
- 管理员调整外部系统定义时不再清除用户凭据:查询策略、提示和限额变化不中断连接;接口目标或认证配置变化后只暂停调用,用户在“外部”页面测试成功即可恢复。
- 外部系统暂不可用时,助手现在能识别“需重新验证”“需更新凭据”等真实状态并提示处理,不再因为调用工具被隐藏而转去搜索本地文件或互联网。
## 0.63.0 — 2026-08-07
- 外部系统连接现在会跟踪系统定义版本:管理员修改接口地址或认证方式后,旧凭据不会被自动发送到新目标;普通策略调整也会明确提示重新验证。

View File

@ -422,13 +422,13 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
- Swagger/OpenAPI 是接口契约事实源;Gitea 代码只补业务语义和排障,不覆盖契约。规格/代码内文本一律当不可信数据,不能改写 system/tool 约束。
- Swagger/OpenAPI JSON 不持久化入数据库或文件。连接器使用按 `external_system_id + definition_revision + credential digest + config digest` 隔离的进程内有界 `ExternalRuntimeCache`,统一复用 HTTP 连接池、短期认证 Header、原始 spec 与编译后的 operation catalogJWT `exp` 早 30 秒失效且单次 401 会清 Token 后重新登录一次,规格默认缓存 5 分钟LRU 淘汰活跃连接时延迟到 lease 结束再关闭。登录、规格获取、catalog 编译和时间上重叠的相同只读业务请求使用同步 single-flight失败不缓存业务响应不做跨请求 TTL 缓存顺序执行的相同查询仍访问上游。Swagger 2/OpenAPI 3 catalog 解析本地参数引用、请求体契约和 header/cookie 参数搜索与调用只消费归一化结果。spec、登录响应和业务响应均流式限长在完整 JSON 进入内存前执行硬边界。
**工具面**:不把数百个 Swagger operation 全展开为 JSON tool(工具列表膨胀+选择降准),只挂五个 host-side 元工具:`external_system_list`(已连系统 + 执行模式 + 管理员查询规划提示),`external_system_search`(按问题搜 operation 摘要、解析后的请求 body schema + 置顶管理员推荐入口),`external_system_call`(按 operation_id 调用),`external_system_result_read`(按 `result_ref` + JSON Pointer/分页/字段投影读取大响应),`external_system_result_export`(仅在用户要求保存/下载/交付时把完整快照导出到 `data/external/`)。仅当该 user 有 active 连接时注册,密钥不进 sandbox。搜索只展示当前模式实际可调用的 operation管理员在 definition JSONB 配置 `query_guidance``recommended_operation_ids`,前者是可信控制面的软路由策略,后者是无需关键词命中的机械发现入口。Factory 默认把 BI dataset list/exec 作为统计聚合入口,日志/明细用于逐条追溯Swagger 业务文本仍是不可信数据,非查询操作只响应用户明确意图。
**工具面**:不把数百个 Swagger operation 全展开为 JSON tool(工具列表膨胀+选择降准),只挂五个 host-side 元工具:`external_system_list`(已连系统、连接状态、执行模式 + 管理员查询规划提示),`external_system_search`(按问题搜 operation 摘要、解析后的请求 body schema + 置顶管理员推荐入口),`external_system_call`(按 operation_id 调用),`external_system_result_read`(按 `result_ref` + JSON Pointer/分页/字段投影读取大响应),`external_system_result_export`(仅在用户要求保存/下载/交付时把完整快照导出到 `data/external/`)。有任意连接即注册只读 `list`,使 agent 能解释 `needs_reverify|needs_credentials|invalid|disabled` 并提示用户处理;其余四个调用工具仅在有 active 且 revision 匹配的连接时注册,密钥不进 sandbox。搜索只展示当前模式实际可调用的 operation管理员在 definition JSONB 配置 `query_guidance``recommended_operation_ids`,前者是可信控制面的软路由策略,后者是无需关键词命中的机械发现入口。Factory 默认把 BI dataset list/exec 作为统计聚合入口,日志/明细用于逐条追溯Swagger 业务文本仍是不可信数据,非查询操作只响应用户明确意图。
**大响应**:`max_result_bytes` 是进入模型上下文的单次内联额度,不再用于切断原始 JSON超额响应完整写入 `.zcbot_cache/<task_id>/external_results/`,工具只返回合法结构化预览、`result_ref`、原始字节数和可继续读取的位置。reader 每次读取都重新校验当前 user 对原 external system 的 active 授权,并与 call 共享本轮 `max_total_result_bytes` 内联额度export 同样重验授权,并把查询 operation/参数/时间等 provenance 与完整响应一起持久化,导出文件不受缓存 TTL 影响。缓存固定 24h TTL、单响应 10 MiB、单 task 50 MiB、单 user 200 MiB,过期或超额时优先清理最旧缓存0.62.1 的 `.zcbot_external_results/` 在读取和容量核算上保留兼容窗口。超过响应安全上限的远端结果直接拒绝并要求缩小范围,不产生半截 JSON。这里把“上游响应安全边界”“完整结果保存”“模型上下文额度”“用户明确留存”拆成四层,既不丢数据,也不靠无限提高上下文额度解决大结果问题。
**明细扫描边界**:单次响应保留安全上限与模型内联额度,每次 agent run 另按外部系统累计内联返回量Factory connector 将 `page_size` 限在管理员上限,拒绝 `page=0` / `pageoff` 关闭分页。三者防模型通过连续翻日志自行做昂贵聚合,但不改变 Factory 对其他客户端的分页契约。达到边界后工具正向引导回 dataset/聚合接口、`result_ref` 分段读取或缩小查询范围。
**状态与 UI三实体**:`external_system_definitions` 保存可信目录、revision、治理元数据、查询提示、`query|upstream_managed` 执行模式和查询模式下只读 POST 的显式 `operation_id -> read|export` policy`external_system_grants` 只保存 selected 可见授权;`external_systems` 只保存用户连接、AAD 绑定密文、verified revision 和 `active|invalid|needs_reverify|needs_credentials` 状态。管理员撤权删除独立 grant 并同步删除该用户连接用户自行断开只删除 connectiongrant 保留。凭据使用带 key id 的 AES-GCM envelopeAAD 绑定 user、definition 和字段,旧 Fernet 密文只保留滚动读取入口调用审计仅保存身份、operation、耗时、状态和响应字节不保存凭据、请求体或完整响应。管理后台当前仍是唯一 definition 创建入口,未来用户私有定义复用同一模型进入 draft/review 流程。
**状态与 UI三实体**:`external_system_definitions` 保存可信目录、revision、治理元数据、查询提示、`query|upstream_managed` 执行模式和查询模式下只读 POST 的显式 `operation_id -> read|export` policy`external_system_grants` 只保存 selected 可见授权;`external_systems` 只保存用户连接、AAD 绑定密文、verified revision 和 `active|invalid|needs_reverify|needs_credentials` 状态。定义更新先对新旧配置做默认值补全后的语义比较:查询提示、推荐入口、执行策略和响应限额等运行配置变化让 active 连接原子跟随新 revision目标、登录、认证绑定或 TLS 变化保留密文但置 `needs_reverify`,在用户从“外部”页面主动测试前 agent 不得调用,测试成功后恢复 active管理员撤权删除独立 grant 并同步删除该用户连接用户自行断开只删除 connectiongrant 保留。凭据使用带 key id 的 AES-GCM envelopeAAD 绑定 user、definition 和字段,旧 Fernet 密文只保留滚动读取入口调用审计仅保存身份、operation、耗时、状态和响应字节不保存凭据、请求体或完整响应。管理后台当前仍是唯一 definition 创建入口,未来用户私有定义复用同一模型进入 draft/review 流程。
**不选**:①zcbot 直连 Factory DB(绕过现有 RBAC/审计,只读仍可越权/拖垮主库);②固定几个查询模板(把 agent 降成菜单,无法利用 Factory 已有广泛 API);③直接复用 Factory `ichat` 自由 SQL 原型(字符串安全判断不构成边界,且使用默认 DB 凭据);④自动把相似问题生成并上线新代码工具(候选配方可自动生成,可执行能力仍需工具门控/人审)。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-08-07(外部系统治理底座重构,bump 0.63.0)
最后更新:2026-08-07(外部连接定义更新保凭据 + agent 状态可见,bump 0.63.1)
---
@ -23,6 +23,7 @@
### 2026-08-07
- **08-07 / 0.63.1 / 外部定义更新保凭据 + agent 状态可见**:生产 task `96bef58e` 暴露旧版 Factory 定义补齐默认认证字段时被误判为认证绑定变化,用户密文被清空为 `needs_credentials`,而工具组整体隐藏又让模型转查本地与互联网。现对新旧配置先做默认值补全后的语义比较:查询提示、推荐入口、执行策略和响应限额变化由 active 连接原子跟随 revision目标、登录、认证绑定或 TLS 变化保留密文并置 `needs_reverify`,用户主动测试成功后恢复。只读 `external_system_list` 在任意连接状态下均可挂载并返回操作提示search/call/read/export 仍仅对 active 且 revision 匹配的连接开放。完整 512 项 unittest 全绿(17 skip),外部系统专项 46 项、无 DB 路由 23 项、外部模块 mypy、Python 编译、Ruff 致命规则与 diff 检查通过;无 schema、migration、依赖或 HTTP API 变化,只读排查生产 task未写生产 DB。
- **08-07 / 0.63.0 / 外部系统治理底座重构**:外部系统持久化拆为 definition、grant、connection 三实体0027 migration 搬运 selected 授权并去除连接表 provider/connector/config 重复列definition 增加 revision、owner/visibility、trust/review 与 egress policy 预留,配置变化按目标/认证绑定差异将连接置为 `needs_reverify` 或清密文进入 `needs_credentials`。执行边界拆为 `query|upstream_managed`:通用系统默认 GET/HEAD + 显式只读 POSTFactory 默认开放可信规格全部标准 method 并委托 Factory 按用户凭据鉴权Swagger 2/OpenAPI 3 先编译统一 catalog补本地参数 `$ref`、header/cookie、数组序列化与请求体基础校验。运行态缓存统一复用 HTTP 连接、短期 Token、spec 和 catalog登录/规格/catalog/并发相同只读查询使用 single-flight明确不做顺序业务查询结果缓存spec、登录和业务响应均流式硬限长。凭据升级为带 key id、user/definition/field AAD 的 AES-GCM envelope并保留旧 Fernet 滚动读取,新增无敏感载荷调用审计;完整 507 项 unittest 全绿(17 skip)0027 PostgreSQL DDL 定向编译、Alembic 单 head、外部模块 mypy、Ruff 致命规则和 JavaScript 语法检查通过,未配置或连接生产 DB。
### 2026-08-06

4
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`
最后更新:2026-08-05(外部系统支持通用 OpenAPI 配置与动态认证凭据)
最后更新:2026-08-07(外部系统定义更新保留凭据并按语义重验)
---
@ -150,7 +150,7 @@
- **未绑定成员发消息 → 回绑定指引**(不再静默):聊天优先布局下新员工第一动作就是打字,回调对未绑定成员的 text/图片/文件消息每条回一句"先去控制台绑定"(事件不回)。未绑定成员点菜单「工作台」则落在绑定提示页(不自动建号)。
- **channel 长会话上下文(微信/企业微信通用,0019)**:常驻会话不再无限膨胀。① **自动分段**——入站时距上次消息超过 `config.json``channel.session_gap_hours`(默 **6** 小时,设 `<=0` 关闭)→ 软重置:只把「最后一条 user 消息起」喂模型(保留上一轮做续聊锚点),之前的历史仍全留 DB,网页端照旧翻完整记录;② **手动新话题**——用户在微信/企业微信里直接发「新话题 / 新会话 / `/new` / 清空上下文」→ 硬重置,彻底从零(回执提示已归档)。两者都**不删任何消息**,只移动「喂给模型的窗口起点」`tasks.context_base_idx`。网页端「清空对话」(`POST /v1/tasks/{id}/clear`)仍整清并把 base 归 0。需 `main.py db upgrade head` 带上 `0019`
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。
- **OpenAPI 外部系统**:① `.env` 配置独立的 `ZCBOT_CREDENTIAL_MASTER_KEY`,可选 `ZCBOT_CREDENTIAL_KEY_ID` 标识当前密钥;轮换时把旧 key 以 JSON 对象放入 `ZCBOT_CREDENTIAL_PREVIOUS_KEYS`,待用户凭据完成重写后再移除。② 执行 `main.py db upgrade head`0027 会把既有 selected 授权迁入独立 grants不连接或清理业务库数据。③ admin 进入管理后台「外部系统」,选择 Factory MES preset 或通用 OpenAPI配置同源的可信 Base URL / Swagger URL、认证方式、执行模式、推荐入口和查询规划提示再选择“全部用户”或指定用户。Factory 默认“上游托管”:可信规格声明的全部标准 HTTP method 均可调用,由 Factory 按当前用户凭据最终鉴权通用系统默认“查询模式”GET/HEAD 默认可查POST 只有加入只读清单才开放。④ 普通用户点击左栏 **「外部」**,页面按定义动态显示所需凭据;定义目标或认证变化后必须重新填写凭据,其他策略变化需重新测试连接。通用类型支持“用户名密码换取 Token”“API Key”“Bearer Token”Swagger JSON 只在进程内按定义和用户有界缓存 5 分钟spec、登录和业务响应都在流式下载时限长普通用户和模型不能传任意 URL。
- **OpenAPI 外部系统**:① `.env` 配置独立的 `ZCBOT_CREDENTIAL_MASTER_KEY`,可选 `ZCBOT_CREDENTIAL_KEY_ID` 标识当前密钥;轮换时把旧 key 以 JSON 对象放入 `ZCBOT_CREDENTIAL_PREVIOUS_KEYS`,待用户凭据完成重写后再移除。② 执行 `main.py db upgrade head`0027 会把既有 selected 授权迁入独立 grants不连接或清理业务库数据。③ admin 进入管理后台「外部系统」,选择 Factory MES preset 或通用 OpenAPI配置同源的可信 Base URL / Swagger URL、认证方式、执行模式、推荐入口和查询规划提示再选择“全部用户”或指定用户。Factory 默认“上游托管”:可信规格声明的全部标准 HTTP method 均可调用,由 Factory 按当前用户凭据最终鉴权通用系统默认“查询模式”GET/HEAD 默认可查POST 只有加入只读清单才开放。④ 普通用户点击左栏 **「外部」**,页面按定义动态显示所需凭据;定义目标、登录、认证绑定或 TLS 变化后会保留加密凭据并暂停 agent 调用,用户点击“测试连接”成功后恢复,查询提示、执行策略和响应限额等运行配置变化不中断连接。通用类型支持“用户名密码换取 Token”“API Key”“Bearer Token”Swagger JSON 只在进程内按定义和用户有界缓存 5 分钟spec、登录和业务响应都在流式下载时限长普通用户和模型不能传任意 URL。
- **测试库(可选,`ZCBOT_TEST_DB_URL`)**:DB 级单测(`tests/test_usage_report.py` / `tests/test_scheduler.py` / `tests/test_web_routes_db.py`)**只认这个显式变量、绝不回退 `.env``ZCBOT_DB_URL`**——后者可能经隧道指向生产库,测试插入的到点 job 会被生产实例调度守护真跑一次(2026-07-23 实锤)。未设则这几组自动 skip。一键起库(docker,端口 5433 避开本地 5432):
```bash
docker run -d --name zcbot-test-pg -e POSTGRES_PASSWORD=zcbot_test \

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.63.0"
__version__ = "0.63.1"

View File

@ -2,8 +2,8 @@
from __future__ import annotations
from datetime import datetime, timezone
from collections.abc import Sequence
from datetime import datetime, timezone
from typing import Any, Optional
from urllib.parse import urlparse
from uuid import UUID
@ -30,6 +30,22 @@ class ExternalSystemError(RuntimeError):
pass
_REVERIFY_KEYS = frozenset(
{
"base_url",
"openapi_url",
"login_path",
"auth_type",
"username_field",
"password_field",
"token_field",
"auth_header_name",
"auth_header_template",
"verify_tls",
}
)
def _runtime_config(provider: str, data: dict[str, Any]) -> OpenApiConfig:
try:
return OpenApiConfig.from_mapping(merged_config(provider, data))
@ -57,6 +73,28 @@ def _normalized_config(provider: str, data: dict[str, Any]) -> dict[str, Any]:
}
def _classify_definition_config_change(
provider: str,
old_data: dict[str, Any],
new_data: dict[str, Any],
) -> tuple[dict[str, Any], dict[str, Any], frozenset[str], str]:
"""按运行语义比较配置,避免旧 JSON 缺省字段被误判成认证变化。"""
old_config = _normalized_config(provider, old_data)
new_config = _normalized_config(provider, new_data)
changed = frozenset(
key
for key in old_config.keys() | new_config.keys()
if old_config.get(key) != new_config.get(key)
)
if not changed:
impact = "none"
elif changed & _REVERIFY_KEYS:
impact = "reverify"
else:
impact = "runtime"
return old_config, new_config, changed, impact
def _definition_view(
row: ExternalSystemDefinition, *, include_config: bool
) -> dict[str, Any]:
@ -92,18 +130,29 @@ def _validate_visibility(visibility: str) -> str:
return mode
def _invalidate_connections_for_revision(
connections: Sequence[ExternalSystem], *, credential_binding_changed: bool
) -> None:
def _mark_connections_for_reverify(connections: Sequence[ExternalSystem]) -> None:
"""保留密文凭据,但在用户主动测试前阻止 agent 使用变更后的目标。"""
for connection in connections:
connection.last_verified_at = None
connection.last_error = "系统定义已更新,请重新验证连接"
if credential_binding_changed:
connection.credentials = {}
connection.credential_hint = "***"
connection.status = "needs_credentials"
connection.status = (
"needs_reverify" if connection.credentials else "needs_credentials"
)
def _advance_active_connections(
connections: Sequence[ExternalSystem], *, revision: int
) -> None:
"""仅运行策略变化不影响连接有效性active 连接原子跟随新 revision。"""
for connection in connections:
if connection.status != "active":
continue
if connection.credentials:
connection.verified_revision = revision
else:
connection.status = "needs_reverify"
connection.status = "needs_credentials"
connection.last_verified_at = None
connection.last_error = "连接凭据缺失,请重新填写凭据"
def _selected_user_ids(s: Any, definition_id: UUID) -> list[str]:
@ -315,23 +364,24 @@ def update_external_system_definition(
).scalar_one_or_none()
if row is None:
raise ExternalSystemError("external system definition not found")
old_config = row.config or {}
new_config = _normalized_config(row.provider, config)
config_changed = old_config != new_config
credential_binding_keys = {
"base_url",
"openapi_url",
"login_path",
"auth_type",
"username_field",
"password_field",
"token_field",
"auth_header_name",
"auth_header_template",
}
binding_changed = any(
old_config.get(key) != new_config.get(key)
for key in credential_binding_keys
_, new_config, _, impact = (
_classify_definition_config_change(
row.provider,
row.config or {},
config,
)
)
config_changed = impact != "none"
connections = (
s.execute(
select(ExternalSystem).where(
ExternalSystem.definition_id == definition_id
)
)
.scalars()
.all()
if config_changed
else []
)
row.name = name
row.config = new_config
@ -339,19 +389,10 @@ def update_external_system_definition(
row.visibility = _validate_visibility(visibility)
if config_changed:
row.revision += 1
connections = (
s.execute(
select(ExternalSystem).where(
ExternalSystem.definition_id == definition_id
)
)
.scalars()
.all()
)
_invalidate_connections_for_revision(
connections,
credential_binding_changed=binding_changed,
)
if impact == "runtime":
_advance_active_connections(connections, revision=row.revision)
else:
_mark_connections_for_reverify(connections)
if row.visibility == "selected":
_sync_selected_users(
s, row, selected_user_ids or [], granted_by=row.created_by
@ -771,6 +812,22 @@ def external_system_tools_available(user_id: UUID) -> bool:
return False
def external_system_status_available(user_id: UUID) -> bool:
"""只读状态工具不需要解密凭据;有连接即向 agent 暴露状态。"""
try:
with session_scope() as s:
return (
s.execute(
select(ExternalSystem.external_system_id)
.where(ExternalSystem.user_id == user_id)
.limit(1)
).scalar_one_or_none()
is not None
)
except Exception:
return False
def record_external_system_audit(
*,
user_id: UUID,

View File

@ -155,12 +155,14 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
MaterialsProjectGetEntriesTool(working_dir=ctx.working_dir_path, **base),
]
def _external_system_status() -> list:
return [ExternalSystemListTool(ctx.uid, **base)]
def _external_systems() -> list:
from core.external_systems.service import record_external_system_audit
result_budget: dict[str, int] = {}
return [
ExternalSystemListTool(ctx.uid, **base),
ExternalSystemSearchTool(ctx.uid, **base),
ExternalSystemCallTool(
ctx.uid,
@ -282,6 +284,11 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
# key 绝不进 run_python / 沙箱。
("document_search", _env_set("DOCUMENT_SEARCH_API_KEY"), _document_search),
("materials_project", _env_set("MP_API_KEY"), _materials_project),
(
"external_system_status",
lambda: _external_system_status_available(ctx.uid),
_external_system_status,
),
("external_systems", lambda: _external_systems_available(ctx.uid), _external_systems),
("load_skill", lambda: bool(ctx.skills.skills), _load_skill),
("skill_authoring", lambda: True, _skill_authoring),
@ -313,3 +320,13 @@ def _external_systems_available(user_id: UUID) -> bool:
return external_system_tools_available(user_id)
except Exception:
return False
def _external_system_status_available(user_id: UUID) -> bool:
"""连接失效时仍挂只读状态入口,让模型解释真实阻塞原因。"""
try:
from core.external_systems.service import external_system_status_available
return external_system_status_available(user_id)
except Exception:
return False

View File

@ -80,8 +80,8 @@ class ExternalCredentialCryptoTests(unittest.TestCase):
class ExternalConnectionRevisionTests(unittest.TestCase):
def test_non_binding_definition_change_keeps_credentials_for_reverify(self):
from core.external_systems.service import _invalidate_connections_for_revision
def test_sensitive_definition_change_keeps_credentials_for_reverify(self):
from core.external_systems.service import _mark_connections_for_reverify
connection = SimpleNamespace(
credentials={"token": "ciphertext"},
@ -90,30 +90,107 @@ class ExternalConnectionRevisionTests(unittest.TestCase):
last_verified_at="old",
last_error=None,
)
_invalidate_connections_for_revision(
[connection], credential_binding_changed=False
)
_mark_connections_for_reverify([connection])
self.assertEqual(connection.credentials, {"token": "ciphertext"})
self.assertEqual(connection.status, "needs_reverify")
self.assertIsNone(connection.last_verified_at)
def test_binding_definition_change_clears_credentials(self):
from core.external_systems.service import _invalidate_connections_for_revision
def test_missing_credentials_remains_needs_credentials(self):
from core.external_systems.service import _mark_connections_for_reverify
connection = SimpleNamespace(
credentials={"token": "ciphertext"},
credential_hint="ab***z",
credentials={},
credential_hint="***",
status="active",
last_verified_at="old",
last_error=None,
)
_invalidate_connections_for_revision(
[connection], credential_binding_changed=True
)
_mark_connections_for_reverify([connection])
self.assertEqual(connection.credentials, {})
self.assertEqual(connection.credential_hint, "***")
self.assertEqual(connection.status, "needs_credentials")
def test_legacy_missing_auth_defaults_are_semantically_unchanged(self):
from core.external_systems.service import _classify_definition_config_change
legacy = {
"base_url": "https://factory.invalid",
"openapi_url": "https://factory.invalid/swagger.json",
"login_path": "/api/auth/token/",
}
materialized = {
**legacy,
"auth_type": "password_jwt",
"username_field": "username",
"password_field": "password",
"token_field": "access",
"auth_header_name": "Authorization",
"auth_header_template": "Bearer {token}",
}
_, _, changed, impact = _classify_definition_config_change(
"factory_mes", legacy, materialized
)
self.assertEqual(changed, frozenset())
self.assertEqual(impact, "none")
def test_query_guidance_change_is_runtime_only(self):
from core.external_systems.service import _classify_definition_config_change
config = {
"base_url": "https://factory.invalid",
"openapi_url": "https://factory.invalid/swagger.json",
}
_, _, changed, impact = _classify_definition_config_change(
"factory_mes",
config,
{**config, "query_guidance": "先查数据集目录"},
)
self.assertEqual(changed, frozenset({"query_guidance"}))
self.assertEqual(impact, "runtime")
def test_target_change_requires_reverify_without_credential_reset(self):
from core.external_systems.service import _classify_definition_config_change
old = {
"base_url": "https://factory.invalid",
"openapi_url": "https://factory.invalid/swagger.json",
}
new = {
"base_url": "https://factory-new.invalid",
"openapi_url": "https://factory-new.invalid/swagger.json",
}
_, _, changed, impact = _classify_definition_config_change(
"factory_mes", old, new
)
self.assertEqual(
changed, frozenset({"base_url", "openapi_url"})
)
self.assertEqual(impact, "reverify")
def test_runtime_change_advances_only_active_connections(self):
from core.external_systems.service import _advance_active_connections
active = SimpleNamespace(
credentials={"token": "ciphertext"},
status="active",
verified_revision=1,
)
invalid = SimpleNamespace(
credentials={"token": "ciphertext"},
status="invalid",
verified_revision=1,
)
missing = SimpleNamespace(
credentials={},
status="active",
verified_revision=1,
last_verified_at="old",
last_error=None,
)
_advance_active_connections([active, invalid, missing], revision=2)
self.assertEqual(active.verified_revision, 2)
self.assertEqual(invalid.verified_revision, 1)
self.assertEqual(missing.status, "needs_credentials")
def _cfg(*, allowed=frozenset(), recommended=(), operation_mode="query"):
from core.external_systems.factory import FactoryMesConfig
@ -1055,6 +1132,58 @@ class FactoryOpenApiConnectorTests(unittest.TestCase):
class ExternalSystemToolSafetyTests(unittest.TestCase):
def test_status_tool_remains_registered_without_callable_connection(self):
from core.tool_registry import ToolContext, build_tools
uid = uuid.uuid4()
task_id = uuid.uuid4()
with (
tempfile.TemporaryDirectory() as tmp,
patch.dict(
os.environ,
{"DOCUMENT_SEARCH_API_KEY": "", "MP_API_KEY": ""},
clear=False,
),
patch(
"core.tool_registry._external_system_status_available",
return_value=True,
),
patch(
"core.tool_registry._external_systems_available",
return_value=False,
),
patch("core.tool_registry.smtp_configured", return_value=False),
patch("core.tool_registry.wechat_push_available", return_value=False),
patch("core.tool_registry.lfasr_configured", return_value=False),
patch("core.tool_registry.BochaConfig.load", return_value=None),
):
root = Path(tmp)
tools = build_tools(
ToolContext(
tool_base=root,
ur_path=root,
working_dir_path=root,
task_id=task_id,
uid=uid,
cfg={},
caps=SimpleNamespace(enable_run_python=False),
skills=SimpleNamespace(skills={}),
cancel_check=None,
scheduled_run=True,
deferred_actions=SimpleNamespace(),
ark_cfg=None,
img_provider="",
img_key="",
img_cfg=None,
img_provider_cfg=None,
video_variant="",
office_to_pdf_available=False,
)
)
self.assertIn("external_system_list", tools)
self.assertNotIn("external_system_search", tools)
self.assertNotIn("external_system_call", tools)
def test_tools_are_scoped_to_constructor_user(self):
from tools.external_systems import ExternalSystemListTool
@ -1066,12 +1195,20 @@ class ExternalSystemToolSafetyTests(unittest.TestCase):
"external_system_id": str(uuid.uuid4()),
"status": "active",
"username_masked": "me***r",
}
},
{
"external_system_id": str(uuid.uuid4()),
"status": "needs_reverify",
"username_masked": "me***r",
},
],
) as listed:
output = ExternalSystemListTool(uid).execute()
listed.assert_called_once_with(uid)
self.assertNotIn("password", output.lower())
payload = json.loads(output)
self.assertEqual(len(payload["systems"]), 2)
self.assertIn("测试连接", payload["systems"][1]["status_guidance"])
def test_search_returns_admin_guidance_with_recommended_operations(self):
from tools.external_systems import ExternalSystemSearchTool

View File

@ -45,8 +45,9 @@ def _row_and_client(user_id: UUID, raw_system_id: str):
class ExternalSystemListTool(Tool):
name = "external_system_list"
description = (
"列出当前用户已连接的外部系统。返回 system_id、执行模式、管理员配置的查询规划提示"
"和推荐 operationId使用外部系统前先调用并遵循对应提示。凭据永不返回。"
"列出当前用户已连接的外部系统及状态。返回 system_id、active/需重新验证/"
"需更新凭据等状态、执行模式和查询规划提示;使用外部系统前先调用。"
"连接不可用时应明确告知用户处理方式,不要改查互联网。凭据永不返回。"
)
parameters = {"type": "object", "properties": {}}
@ -55,8 +56,17 @@ class ExternalSystemListTool(Tool):
self.user_id = user_id
def execute(self, **kwargs) -> str:
systems = list_external_systems(self.user_id)
guidance = {
"active": "连接可用,可继续 search/call",
"needs_reverify": "系统定义已更新,需要用户在“外部”页面测试连接",
"needs_credentials": "连接凭据缺失,需要用户在“外部”页面重新填写凭据",
"invalid": "连接验证失败,需要用户在“外部”页面检查或更新凭据",
"disabled": "系统定义已停用,请联系管理员",
}
systems = [
x for x in list_external_systems(self.user_id) if x["status"] == "active"
{**system, "status_guidance": guidance.get(system["status"], "连接不可用")}
for system in systems
]
return _json({"systems": systems})