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。 > 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.63.1 — 2026-08-07
- 管理员调整外部系统定义时不再清除用户凭据:查询策略、提示和限额变化不中断连接;接口目标或认证配置变化后只暂停调用,用户在“外部”页面测试成功即可恢复。
- 外部系统暂不可用时,助手现在能识别“需重新验证”“需更新凭据”等真实状态并提示处理,不再因为调用工具被隐藏而转去搜索本地文件或互联网。
## 0.63.0 — 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 是接口契约事实源;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/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。这里把“上游响应安全边界”“完整结果保存”“模型上下文额度”“用户明确留存”拆成四层,既不丢数据,也不靠无限提高上下文额度解决大结果问题。 **大响应**:`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` 分段读取或缩小查询范围。 **明细扫描边界**:单次响应保留安全上限与模型内联额度,每次 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 凭据);④自动把相似问题生成并上线新代码工具(候选配方可自动生成,可执行能力仍需工具门控/人审)。 **不选**:①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` > 配合 `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 ### 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。 - **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 ### 2026-08-06

4
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md` > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`
最后更新:2026-08-05(外部系统支持通用 OpenAPI 配置与动态认证凭据) 最后更新:2026-08-07(外部系统定义更新保留凭据并按语义重验)
--- ---
@ -150,7 +150,7 @@
- **未绑定成员发消息 → 回绑定指引**(不再静默):聊天优先布局下新员工第一动作就是打字,回调对未绑定成员的 text/图片/文件消息每条回一句"先去控制台绑定"(事件不回)。未绑定成员点菜单「工作台」则落在绑定提示页(不自动建号)。 - **未绑定成员发消息 → 回绑定指引**(不再静默):聊天优先布局下新员工第一动作就是打字,回调对未绑定成员的 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` - **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)。 - **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): - **测试库(可选,`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 ```bash
docker run -d --name zcbot-test-pg -e POSTGRES_PASSWORD=zcbot_test \ 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 返回、前端展示都引这里。 # 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 __future__ import annotations
from datetime import datetime, timezone
from collections.abc import Sequence from collections.abc import Sequence
from datetime import datetime, timezone
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import UUID from uuid import UUID
@ -30,6 +30,22 @@ class ExternalSystemError(RuntimeError):
pass 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: def _runtime_config(provider: str, data: dict[str, Any]) -> OpenApiConfig:
try: try:
return OpenApiConfig.from_mapping(merged_config(provider, data)) 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( def _definition_view(
row: ExternalSystemDefinition, *, include_config: bool row: ExternalSystemDefinition, *, include_config: bool
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -92,18 +130,29 @@ def _validate_visibility(visibility: str) -> str:
return mode return mode
def _invalidate_connections_for_revision( def _mark_connections_for_reverify(connections: Sequence[ExternalSystem]) -> None:
connections: Sequence[ExternalSystem], *, credential_binding_changed: bool """保留密文凭据,但在用户主动测试前阻止 agent 使用变更后的目标。"""
) -> None:
for connection in connections: for connection in connections:
connection.last_verified_at = None connection.last_verified_at = None
connection.last_error = "系统定义已更新,请重新验证连接" connection.last_error = "系统定义已更新,请重新验证连接"
if credential_binding_changed: connection.status = (
connection.credentials = {} "needs_reverify" if connection.credentials else "needs_credentials"
connection.credential_hint = "***" )
connection.status = "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: 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]: def _selected_user_ids(s: Any, definition_id: UUID) -> list[str]:
@ -315,23 +364,24 @@ def update_external_system_definition(
).scalar_one_or_none() ).scalar_one_or_none()
if row is None: if row is None:
raise ExternalSystemError("external system definition not found") raise ExternalSystemError("external system definition not found")
old_config = row.config or {} _, new_config, _, impact = (
new_config = _normalized_config(row.provider, config) _classify_definition_config_change(
config_changed = old_config != new_config row.provider,
credential_binding_keys = { row.config or {},
"base_url", config,
"openapi_url", )
"login_path", )
"auth_type", config_changed = impact != "none"
"username_field", connections = (
"password_field", s.execute(
"token_field", select(ExternalSystem).where(
"auth_header_name", ExternalSystem.definition_id == definition_id
"auth_header_template", )
} )
binding_changed = any( .scalars()
old_config.get(key) != new_config.get(key) .all()
for key in credential_binding_keys if config_changed
else []
) )
row.name = name row.name = name
row.config = new_config row.config = new_config
@ -339,19 +389,10 @@ def update_external_system_definition(
row.visibility = _validate_visibility(visibility) row.visibility = _validate_visibility(visibility)
if config_changed: if config_changed:
row.revision += 1 row.revision += 1
connections = ( if impact == "runtime":
s.execute( _advance_active_connections(connections, revision=row.revision)
select(ExternalSystem).where( else:
ExternalSystem.definition_id == definition_id _mark_connections_for_reverify(connections)
)
)
.scalars()
.all()
)
_invalidate_connections_for_revision(
connections,
credential_binding_changed=binding_changed,
)
if row.visibility == "selected": if row.visibility == "selected":
_sync_selected_users( _sync_selected_users(
s, row, selected_user_ids or [], granted_by=row.created_by 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 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( def record_external_system_audit(
*, *,
user_id: UUID, 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), MaterialsProjectGetEntriesTool(working_dir=ctx.working_dir_path, **base),
] ]
def _external_system_status() -> list:
return [ExternalSystemListTool(ctx.uid, **base)]
def _external_systems() -> list: def _external_systems() -> list:
from core.external_systems.service import record_external_system_audit from core.external_systems.service import record_external_system_audit
result_budget: dict[str, int] = {} result_budget: dict[str, int] = {}
return [ return [
ExternalSystemListTool(ctx.uid, **base),
ExternalSystemSearchTool(ctx.uid, **base), ExternalSystemSearchTool(ctx.uid, **base),
ExternalSystemCallTool( ExternalSystemCallTool(
ctx.uid, ctx.uid,
@ -282,6 +284,11 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
# key 绝不进 run_python / 沙箱。 # key 绝不进 run_python / 沙箱。
("document_search", _env_set("DOCUMENT_SEARCH_API_KEY"), _document_search), ("document_search", _env_set("DOCUMENT_SEARCH_API_KEY"), _document_search),
("materials_project", _env_set("MP_API_KEY"), _materials_project), ("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), ("external_systems", lambda: _external_systems_available(ctx.uid), _external_systems),
("load_skill", lambda: bool(ctx.skills.skills), _load_skill), ("load_skill", lambda: bool(ctx.skills.skills), _load_skill),
("skill_authoring", lambda: True, _skill_authoring), ("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) return external_system_tools_available(user_id)
except Exception: except Exception:
return False 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): class ExternalConnectionRevisionTests(unittest.TestCase):
def test_non_binding_definition_change_keeps_credentials_for_reverify(self): def test_sensitive_definition_change_keeps_credentials_for_reverify(self):
from core.external_systems.service import _invalidate_connections_for_revision from core.external_systems.service import _mark_connections_for_reverify
connection = SimpleNamespace( connection = SimpleNamespace(
credentials={"token": "ciphertext"}, credentials={"token": "ciphertext"},
@ -90,30 +90,107 @@ class ExternalConnectionRevisionTests(unittest.TestCase):
last_verified_at="old", last_verified_at="old",
last_error=None, last_error=None,
) )
_invalidate_connections_for_revision( _mark_connections_for_reverify([connection])
[connection], credential_binding_changed=False
)
self.assertEqual(connection.credentials, {"token": "ciphertext"}) self.assertEqual(connection.credentials, {"token": "ciphertext"})
self.assertEqual(connection.status, "needs_reverify") self.assertEqual(connection.status, "needs_reverify")
self.assertIsNone(connection.last_verified_at) self.assertIsNone(connection.last_verified_at)
def test_binding_definition_change_clears_credentials(self): def test_missing_credentials_remains_needs_credentials(self):
from core.external_systems.service import _invalidate_connections_for_revision from core.external_systems.service import _mark_connections_for_reverify
connection = SimpleNamespace( connection = SimpleNamespace(
credentials={"token": "ciphertext"}, credentials={},
credential_hint="ab***z", credential_hint="***",
status="active", status="active",
last_verified_at="old", last_verified_at="old",
last_error=None, last_error=None,
) )
_invalidate_connections_for_revision( _mark_connections_for_reverify([connection])
[connection], credential_binding_changed=True
)
self.assertEqual(connection.credentials, {}) self.assertEqual(connection.credentials, {})
self.assertEqual(connection.credential_hint, "***")
self.assertEqual(connection.status, "needs_credentials") 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"): def _cfg(*, allowed=frozenset(), recommended=(), operation_mode="query"):
from core.external_systems.factory import FactoryMesConfig from core.external_systems.factory import FactoryMesConfig
@ -1055,6 +1132,58 @@ class FactoryOpenApiConnectorTests(unittest.TestCase):
class ExternalSystemToolSafetyTests(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): def test_tools_are_scoped_to_constructor_user(self):
from tools.external_systems import ExternalSystemListTool from tools.external_systems import ExternalSystemListTool
@ -1066,12 +1195,20 @@ class ExternalSystemToolSafetyTests(unittest.TestCase):
"external_system_id": str(uuid.uuid4()), "external_system_id": str(uuid.uuid4()),
"status": "active", "status": "active",
"username_masked": "me***r", "username_masked": "me***r",
} },
{
"external_system_id": str(uuid.uuid4()),
"status": "needs_reverify",
"username_masked": "me***r",
},
], ],
) as listed: ) as listed:
output = ExternalSystemListTool(uid).execute() output = ExternalSystemListTool(uid).execute()
listed.assert_called_once_with(uid) listed.assert_called_once_with(uid)
self.assertNotIn("password", output.lower()) 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): def test_search_returns_admin_guidance_with_recommended_operations(self):
from tools.external_systems import ExternalSystemSearchTool 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): class ExternalSystemListTool(Tool):
name = "external_system_list" name = "external_system_list"
description = ( description = (
"列出当前用户已连接的外部系统。返回 system_id、执行模式、管理员配置的查询规划提示" "列出当前用户已连接的外部系统及状态。返回 system_id、active/需重新验证/"
"和推荐 operationId使用外部系统前先调用并遵循对应提示。凭据永不返回。" "需更新凭据等状态、执行模式和查询规划提示;使用外部系统前先调用。"
"连接不可用时应明确告知用户处理方式,不要改查互联网。凭据永不返回。"
) )
parameters = {"type": "object", "properties": {}} parameters = {"type": "object", "properties": {}}
@ -55,8 +56,17 @@ class ExternalSystemListTool(Tool):
self.user_id = user_id self.user_id = user_id
def execute(self, **kwargs) -> str: def execute(self, **kwargs) -> str:
systems = list_external_systems(self.user_id)
guidance = {
"active": "连接可用,可继续 search/call",
"needs_reverify": "系统定义已更新,需要用户在“外部”页面测试连接",
"needs_credentials": "连接凭据缺失,需要用户在“外部”页面重新填写凭据",
"invalid": "连接验证失败,需要用户在“外部”页面检查或更新凭据",
"disabled": "系统定义已停用,请联系管理员",
}
systems = [ 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}) return _json({"systems": systems})