From 9032c5b517fd025e9eb851a71abf601cc9aa0b93 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 7 Aug 2026 11:25:29 +0800 Subject: [PATCH] fix(external-systems): preserve credentials on definition updates --- CHANGELOG.md | 5 + DESIGN.md | 4 +- PROGRESS.md | 3 +- RUN.md | 4 +- core/__init__.py | 2 +- core/external_systems/service.py | 135 +++++++++++++++++-------- core/tool_registry.py | 19 +++- tests/test_external_systems.py | 165 ++++++++++++++++++++++++++++--- tools/external_systems.py | 16 ++- 9 files changed, 290 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b92f23f..a73f79d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.63.1 — 2026-08-07 + +- 管理员调整外部系统定义时不再清除用户凭据:查询策略、提示和限额变化不中断连接;接口目标或认证配置变化后只暂停调用,用户在“外部”页面测试成功即可恢复。 +- 外部系统暂不可用时,助手现在能识别“需重新验证”“需更新凭据”等真实状态并提示处理,不再因为调用工具被隐藏而转去搜索本地文件或互联网。 + ## 0.63.0 — 2026-08-07 - 外部系统连接现在会跟踪系统定义版本:管理员修改接口地址或认证方式后,旧凭据不会被自动发送到新目标;普通策略调整也会明确提示重新验证。 diff --git a/DESIGN.md b/DESIGN.md index 6dddcca..44b8880 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 catalog;JWT `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//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 并同步删除该用户连接;用户自行断开只删除 connection,grant 保留。凭据使用带 key id 的 AES-GCM envelope,AAD 绑定 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 并同步删除该用户连接,用户自行断开只删除 connection,grant 保留。凭据使用带 key id 的 AES-GCM envelope,AAD 绑定 user、definition 和字段,旧 Fernet 密文只保留滚动读取入口;调用审计仅保存身份、operation、耗时、状态和响应字节,不保存凭据、请求体或完整响应。管理后台当前仍是唯一 definition 创建入口,未来用户私有定义复用同一模型进入 draft/review 流程。 **不选**:①zcbot 直连 Factory DB(绕过现有 RBAC/审计,只读仍可越权/拖垮主库);②固定几个查询模板(把 agent 降成菜单,无法利用 Factory 已有广泛 API);③直接复用 Factory `ichat` 自由 SQL 原型(字符串安全判断不构成边界,且使用默认 DB 凭据);④自动把相似问题生成并上线新代码工具(候选配方可自动生成,可执行能力仍需工具门控/人审)。 diff --git a/PROGRESS.md b/PROGRESS.md index 2513451..08b580e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 + 显式只读 POST,Factory 默认开放可信规格全部标准 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 diff --git a/RUN.md b/RUN.md index 9682c68..9f0582a 100644 --- a/RUN.md +++ b/RUN.md @@ -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 \ diff --git a/core/__init__.py b/core/__init__.py index 27157ef..119f4dd 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.63.0" +__version__ = "0.63.1" diff --git a/core/external_systems/service.py b/core/external_systems/service.py index 5f81fdd..819869c 100644 --- a/core/external_systems/service.py +++ b/core/external_systems/service.py @@ -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, diff --git a/core/tool_registry.py b/core/tool_registry.py index c5386a2..7045c39 100644 --- a/core/tool_registry.py +++ b/core/tool_registry.py @@ -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 diff --git a/tests/test_external_systems.py b/tests/test_external_systems.py index 480b6d5..a5b5c24 100644 --- a/tests/test_external_systems.py +++ b/tests/test_external_systems.py @@ -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 diff --git a/tools/external_systems.py b/tools/external_systems.py index 950aa28..367dfb6 100644 --- a/tools/external_systems.py +++ b/tools/external_systems.py @@ -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})