Files
DevFlow/docs/02-架构设计/secret下沉与provider注入方案-2026-06-16.md

22 KiB
Raw Blame History

secret 下沉与 provider 注入方案

背景AiNode 自审闭环②③④⑤已实施(commit c10adaf + 741b0b9)provider 配置注入阻塞⑥端到端联调。 根因api_key 明文经 run_workflow config 注入 NodeContext.config 违背 FR-S1 mask 设计(前端 / LLM tool_call schema 可拿明文 key)。 secret 解析(ensure_resolved_key / resolve_provider_secret)位于 src-tauri/src/commands/ai/secret.rs(app crate)df-nodes 不可依赖 src-tauri(单向 app→df-nodes)。 范围:仅设计,不改任何 .rs/.ts/.vue 代码。


一、现状核验(file:line 证据)

1.1 secret.rs 函数清单(src-tauri/src/commands/ai/secret.rs)

函数 位置 签名 核心逻辑
get_provider_secret secret.rs:88 fn(id: &str) -> Option<String> keyring(devflow-ai-provider/)读密钥,空/无→None(secret.rs:88-94)
resolve_provider_secret secret.rs:97 fn(record: &AiProviderRecord) -> String DB.api_key 非空→直接返回(兼容未迁移);否则 keyring 取,空→unwrap_or_default() 空串(secret.rs:97-102)
ensure_resolved_key secret.rs:179 fn(provider_name: &str, resolved: &str) -> Result<(), String> 空串/纯空白→Err「未读取到密钥」非空 Ok(secret.rs:179-188)
set_provider_secret secret.rs:105 fn(id, key) -> anyhow::Result<()> keyring 写(secret.rs:105-108)
delete_provider_secret secret.rs:111 fn(id) -> anyhow::Result<()> keyring 删(secret.rs:111-114)
migrate_secrets_to_keyring secret.rs:117 async fn(repo: &AiProviderRepo) -> Result<usize> 启动一次性DB 明文→keyring→DB 置空;失败累计 failcount 达 3 升级 warn(secret.rs:117-156)
build_provider_for secret.rs:164 fn(record) -> Result<Box<dyn LlmProvider>, String> 三步打包 resolve→ensure→df_ai::build_provider(secret.rs:164-175)
keyring entry secret.rs:83-85 entry_for(id) Entry::new("devflow-ai-provider", id),服务名常量 KEYRING_SERVICE(secret.rs:18)
failcount sidecar secret.rs:26-81 read/write/record/clear <cwd>/.devflow-keyring-failcountprovider_id=count 跨启动持久化告警计数

依赖:use df_storage::crud::AiProviderRepo; use df_storage::models::AiProviderRecord; use keyring::Entry;(secret.rs:14-16)。

1.2 调用点清单(下沉影响面)

调用点 位置 调的函数
lib.rs 启动迁移 src-tauri/src/lib.rs:28 migrate_secrets_to_keyring(&app_state.ai_providers)
commands/ai/commands.rs ai_list_providers commands.rs:683 get_provider_secret(list mask)
commands/ai/commands.rs ai_save_provider commands.rs:724, 737, 740 set_provider_secret(新/改写)、get_provider_secret(未迁移检测)、set_provider_secret(即时迁移)
commands/ai/commands.rs ai_delete_provider commands.rs:818 delete_provider_secret
commands/ai/agentic.rs 主对话循环 agentic.rs:126, 128 resolve_provider_secret + ensure_resolved_key(内联三步B-17 复用 resolved key)
commands/ai/title.rs 标题生成 title.rs:62 build_provider_for
commands/ai/knowledge_inject.rs 知识提炼 knowledge_inject.rs:35, 327 build_provider_for(两处embed + 提炼)
commands/idea.rs 灵感评分 idea.rs:272 build_provider_for
commands/project.rs 项目导入 project.rs:438, 598 build_provider_for(两处)

影响面9 文件、~12 调用点,全在 src-tauri/commands/。下沉后这些调用点改 import 路径(crate::commands::ai::secret::df_storage::secret::)即可,函数签名不变,行为零变化。

1.3 ai_providers 表 schema(df-storage)

models.rs(crates/df-storage/src/models.rs:147-159)

AiProviderRecord { id, name, provider_type, api_key, base_url,
                   default_model, models: Option<String>, is_default,
                   config: Option<String>, created_at, updated_at }

建表 SQL(crates/df-storage/src/migrations.rs:488-500V9)

api_key TEXT NOT NULL,   -- 列定义无 default、无加密、无 env 引用语法

CRUD(crates/df-storage/src/crud.rs:1113-1140)impl_repo! 宏生成 AiProviderRepoinsert/update/from_row(crud.rs:1034-1048)list_all/get_by_id(宏内置)。

api_key 存储形态结论

  • 不是 mask(列定义无 sk-**** 语法)不是 env 引用(无 $ENV_VAR 解析)不是加密(明文 TEXT)。
  • 真相FR-S1 后 DB api_key 列恒空(迁移后/新建均空,secret.rs:1-8 模块文档明确),真实密钥唯一源 = OS keyring(service=devflow-ai-provider, username=provider_id)。
  • 消费读路径:resolve_provider_secret 先看 DB(兼容未迁移老库明文)→空则 keyring(secret.rs:97-102)。
  • 前端只拿到 maskai_list_providers(commands.rs:679-686) 用 mask_api_key(commands.rs:663-671,首尾各 4 字符 + 中间 ••••)返回,前端 Settings.vue:458 编辑不回填 apiKey。

1.4 依赖链(df-nodes / df-storage Cargo.toml)

df-nodes(crates/df-nodes/Cargo.toml:6-17)已依赖:

  • df-core, df-execute, df-workflow, df-ai, df-storage, serde, tokio, async-trait, anyhow, tracing。
  • 关键df-nodes 已依赖 df-storage + df-ai,可直接调 AiProviderRepo + df_ai::build_provider,无需新增 crate 依赖。

df-storage(crates/df-storage/Cargo.toml:6-13)依赖df-core, serde, serde_json, anyhow, tokio, rusqlite, tracing。不含 keyring

keyring crate:声明在 workspace 根 Cargo.toml:42(features=windows-native, apple-native),仅 src-tauri 引用。下沉到 df-storage 需在 df-storage 的 Cargo.tomlkeyring = { workspace = true }(workspace 已定义path 无需重声明)。

1.5 AiNode 现状(ai_node.rs)

  • AiNodeArc<Database>(ai_node.rs:119-128),注册时注入(state.rs:260-263)。
  • parse_params(ai_node.rs:38-112)从 ctx.configbase_url / api_key(明文P0 风险点),空 key 早失败(ai_node.rs:54-56,对齐 ensure_resolved_key)。
  • execute(ai_node.rs:135-138)直接 df_ai::build_provider(protocol, base_url, api_key, default_model)
  • schema required: ["base_url","api_key"](ai_node.rs:214)api_key 字段对 LLM tool_call 可见。
  • AiSelfReviewNode 同构(ai_node.rs:330-358)。

1.6 run_workflow config 注入链

  • IPC run_workflow(name, dag, config, task_id, target_status)(workflow.rs:67-76)config: serde_json::Value 由前端构造传入。
  • executor.run(&runtime_dag, config)(workflow.rs:245)→ 透传到 NodeContext.config(node.rs:13-26),节点 execute 直接读。
  • 前端目前不构造 ai/ai_self_review 节点 config:模板由后端 template_for(workflow.rs:84-92task_workflow_templates.rs)提供,节点 config 来自模板定义 + 前端 run_workflow 传入的 config 合并/覆盖。
  • config 经 IPC(明文) + NodeContext.config(明文) 两层暴露,前端 Vue devtools / LLM tool_call schema 均可拿到明文 api_key。

二、下沉方案对比(A/B/C)

方案 A完整下沉 secret.rs → df-storage(新建 crates/df-storage/src/secret.rs)

做什么:把 secret.rs 全部逻辑(get/set/delete/resolve/ensure/migrate/build_provider_for/failcount)整体 move 到 crates/df-storage/src/secret.rsdf-storage 加 keyring 依赖src-tauri 改 use df_storage::secret::*; 转发。

维度 评估
影响面 9 文件 12 调用点改 import 路径df-storage Cargo.toml +1 依赖df-nodes Cargo.toml 已有 df-storage 依赖(零改动)src-tauri/Cargo.toml keyring 可保留(workspace 引用)或移除(改由 df-storage 传递)
工作量 move 文件 + 改 12 调用点 import + df-storage 加 keyring feat + cargo check。约 1-2h
风险 keyring feature 传递df-storage 加 keyring = { workspace = true }feat=windows-native, apple-native 随 workspace 注入Linux 构建(df-storage 是否跑测)需补 linux-native。df-storage 单测若启 keyring 会触发 OS keyring 副作用(单测应 cfg-gate)。build_provider_for 依赖 df_ai::build_provider——df-storage 不依赖 df-ai,下沉 build_provider_for 会打破单向依赖(df-storage→df-ai 反向df-ai 不依赖 df-storage 但 df-storage 反引 df-ai 形成潜在循环)。需把 build_provider_for 留在 src-tauri(不下沉),只下沉 resolve/ensure/get/set/migrate
收益 df-nodes 可直接 df_storage::secret::resolve_provider_secret,最干净

致命点build_provider_for(secret.rs:164)调 df_ai::build_provider,下沉 df-storage 会引 df-storage→df-ai。需拆分下沉边界(见方案 B 的边界划分)。

方案 Bdf-storage 加薄 secret 查询方法(推荐) ★

做什么

  1. 下沉纯密钥解析逻辑到 crates/df-storage/src/secret.rsget/set/delete_provider_secretresolve_provider_secretensure_resolved_keymigrate_secrets_to_keyring、failcount helper。不含 build_provider_for(因依赖 df-ai)。
  2. df-storage 加 keyring = { workspace = true } 依赖。
  3. src-tauri secret.rs 保留 build_provider_for 作为转发壳:内部调 df_storage::secret::resolve_provider_secret + ensure_resolved_key + df_ai::build_provider(src-tauri 已依赖 df-ai无循环)。
  4. df-nodes AiNode/AiSelfReviewNode 调 df_storage::secret::resolve_provider_secret(&record) + ensure_resolved_key 自己拼 df_ai::build_provider(df-nodes 已依赖 df-ai + df-storage)。
维度 评估
影响面 df-storage 新增 1 文件 + Cargo.toml +1 依赖src-tauri secret.rs 瘦身为转发壳(保留 build_provider_for)12 调用点 import 改为 df_storage::secret::(build_provider_for 仍从 src-tauri 调6 调用点不变)df-nodes AiNode 改读 provider 配置(见第三章)
工作量 move 纯密钥逻辑(约 100 行)+ df-storage Cargo.toml + 改 import + AiNode 注入链。约 2-3h
风险 keyring feature 同 Adf-storage 单测需 mock keyring 或 cfg-gate。无循环依赖df-storage 不引 df-aibuild_provider_for 留 src-tauri。最稳
收益 边界清晰密钥解析下沉、provider 构造留 app 层df-nodes 获密钥解析能力FR-S1 mask 链路后端闭环

方案 Cprovider_id 注入 config + df-nodes 直查 ai_providers.api_key(df-nodes 自己解析)

做什么config 注 provider_idAiNode 用 AiProviderRepo.get_by_id 查 record自己实现 keyring 解析(在 df-nodes 里复制一份 resolve 逻辑)。

维度 评估
影响面 df-nodes 复制一份 keyring 解析(逻辑重复,违反 DRY);或 df-nodes 也下沉 secret(等同方案 B 但把解析放 df-nodes 而非 df-storage)
工作量 中,但重复实现
风险 DRY 破坏:两份 keyring 解析逻辑(secret.rs 一份 + df-nodes 一份),未来迁移逻辑/常量(service name devflow-ai-provider)变更需同步两处易漏。FAIL_THRESHOLD/failcount 等告警逻辑更难复制
收益 无(被 B 完全覆盖)

推荐:方案 B

理由:

  1. 无循环依赖:纯密钥解析(无 df_ai 依赖)下沉 df-storagebuild_provider_for 留 src-tauri(app 层既依赖 df-storage 又依赖 df-ai天然适合拼装)。
  2. DRYkeyring 解析逻辑唯一源在 df-storagesrc-tauri 与 df-nodes 共用。
  3. df-nodes 零新依赖df-nodes Cargo.toml 已含 df-storage + df-ai方案 B 下沉后直接可调,不改 crate 依赖。
  4. FR-S1 闭环config 只注 provider_id,明文 api_key 不进 IPC / NodeContext.config前端 / LLM tool_call schema 拿不到明文,与现有 ai_list_providers mask 设计一致。
  5. 最小破坏12 调用点中 6 个调 build_provider_for(src-tauri 保留import 不变)6 个调 resolve/ensure/get/set/import 改路径,行为零变化。

三、run_workflow → AiNode provider 注入链设计(方案 B)

3.1 config 注入什么

注入(明文安全)

{
  "provider_id": "<uuid>",        // 必填AiNode 据此查 ai_providers 表
  "prompt": "...",                // 必填(可上游覆盖)
  "system_prompt": "...",         // 可选
  "model": "glm-4-flash",         // 可选,留空用 record.default_model
  "temperature": 0.3,             // 可选
  "max_tokens": 1024,             // 可选
  "task_id": "..."                // 可选(自审闭环用)
}

不注入(api_key/base_url/protocol 经下沉层解析,不进 config)

  • api_key — 明文,违背 FR-S1
  • base_url — 从 record 读(record.base_url)config 注入是冗余 + 泄漏
  • protocol — 从 record.provider_type 映射(openai_compat / anthropic)config 注入冗余

3.2 api_key 如何经 df-storage 层解析不进 config

AiNode.execute 内部链路(伪代码)

let provider_id = ctx.config["provider_id"]?;          // 仅 id 进 config
let repo = AiProviderRepo::new(&self.db);
let record = repo.get_by_id(provider_id).await?;       // 查 ai_providers 行
let api_key = df_storage::secret::resolve_provider_secret(&record);  // DB 优先→keyring
df_storage::secret::ensure_resolved_key(&record.name, &api_key)?;    // 空 key 早失败
let provider = df_ai::build_provider(
    &record.provider_type, &record.base_url, &api_key, &model_or_default);

api_key 全程在 AiNode 进程内存,不出 IPC / 不进 NodeContext.config

  • IPC run_workflow 收到的 config 只有 provider_id(非密)。
  • NodeContext.config 只含 provider_idLLM tool_call schema 看不到 api_key。
  • api_key 经 df_storage::secret::resolve_provider_secret 在 AiNode 内存解析,直达 df_ai::build_provider

3.3 run_workflow 如何拿到 provider 配置注入 config

前端 → run_workflow config 注入路径(三选一)

路径 1(推荐):前端只传 provider_id,其余从 DB 默认 provider

  • 前端 ai_list_providers 拿到 provider 列表(mask无明文)。
  • 触发 run_workflow 时 config 注 {provider_id: <default or chosen id>}(或留空AiNode 内部取 is_default=true 首条)。
  • base_url/protocol/model 全由 AiNode 从 record 读,前端不构造。
  • 最安全前端全程不接触明文config 最小化。

路径 2run_workflow IPC 加 provider_id 顶层参数

  • run_workflow(name, dag, config, task_id, target_status, provider_id: Option<String>)
  • 后端 workflow.rs:245 前把 provider_id 注入 config 注入到 ai/ai_self_review 节点 config。
  • 显式更安全,但改 IPC 签名(向后兼容Option 缺省 None)。

路径 3模板内嵌 provider_id 占位

  • task_workflow_templates.rs 的 ai 节点 config 写 {"provider_id": "{{default}}"}executor 注入时替换为 DB 默认 provider id。
  • 需 executor 支持 placeholder 替换,改动大。

推荐路径 1:最小改动,前端 config 注 provider_id(或留空走默认)AiNode 内部解析。run_workflow 签名不变(向后兼容)。

3.4 兼容老 config(base_url/api_key 明文)迁移

  • AiNode parse_params 兼容窗口:有 provider_id → 走下沉解析路径;无 provider_id → 走老 base_url/api_key 明文路径(对齐现有 ai_node.rs:42-56兼容期保留)。
  • 老路径打 deprecation warn引导 config 迁移到 provider_id。
  • 模板(task_workflow_templates.rs)切到 provider_id 后,老路径仅 demo dag(ProjectDetail.vue demoDag)用,逐步淘汰。
  • schema required["base_url","api_key"] 改为 ["provider_id"](明文路径保留但不再 required)。

四、影响面 + 风险 + 实施步骤

4.1 影响面

模块 改动
crates/df-storage/src/secret.rs 新建move 纯密钥逻辑(get/set/delete/resolve/ensure/migrate/failcount)
crates/df-storage/Cargo.toml +keyring = { workspace = true }
crates/df-storage/src/lib.rs pub mod secret;
src-tauri/src/commands/ai/secret.rs 瘦身为转发壳:pub use df_storage::secret::*; + 保留 build_provider_for(调 df_ai)
crates/df-nodes/src/ai_node.rs parse_params 改读 provider_idexecute 加 repo.get_by_id + resolve + ensure + build_providerschema required 改
src-tauri/src/lib.rs:28 df_storage::secret::migrate_secrets_to_keyring(改 import)
commands/ai/commands.rs(3 处) import 改 df_storage::secret::(行为不变)
commands/ai/agentic.rs(2 处) import 改(build_provider_for 仍 src-tauriresolve/ensure 改 df-storage)
commands/ai/title.rs / knowledge_inject.rs(3 处) / idea.rs(1 处) / project.rs(2 处) build_provider_for 调用点不变(src-tauri 保留)
crates/df-nodes/src/task_workflow_templates.rs ai/ai_self_review 节点 config 改注 provider_id(替代 base_url/api_key)
前端 api/workflow.ts(触发 run_workflow 处) config 注 provider_id(替代 base_url/api_key 明文)

4.2 风险

风险 等级 缓解
keyring feature 传递df-storage 加 keyring 后 Linux 无 native feat workspace Cargo.toml:42 已含 windows-native, apple-nativeCI 若有 Linux 跑 df-storage 单测需补 linux-native 或 cfg-gate keyring 单测
df-storage 单测触发 OS keyring 副作用 keyring 相关单测加 #[cfg(not(test))] 或 mock trait 抽象migrate 测试用 in_memory db + mock keyring
老配置兼容期明文路径残留 parse_params 双路径兼容(provider_id 优先)deprecation warn 引导迁移
build_provider_for 下沉边界划错引循环依赖 严格df-storage 只下沉纯密钥逻辑build_provider_for 留 src-tauri(依赖 df-ai),方案 B 明确划界
run_workflow config 注入 provider_id 后,模板节点缺 provider_id 致 AiNode 报错 AiNode 内部 provider_id 留空时取 DB is_default=true 首条(对齐 idea.rs:265-279 build_default_provider 模式)
前端 demoDag(ProjectDetail.vue)仍注明文 base_url/api_key 老路径兼容,逐步切模板 provider_id

4.3 实施步骤(可并发标注)

阶段 1下沉(串行,基础)

  • S1df-storage 加 keyring 依赖 + 新建 crates/df-storage/src/secret.rs(move 纯密钥逻辑,删除 build_provider_for)
  • S2src-tauri secret.rs 改转发壳(pub use df_storage::secret::*; + 保留 build_provider_for 调 df_ai)
  • S3cargo check -p df-storage && -p src-tauri 验证下沉零行为变化

阶段 2调用点 import 切换(可并发S3 完成后)

  • 并行 P1lib.rs:28 / commands.rs(3 处) / agentic.rs(2 处) 改 import(纯路径替换,行为零变)
  • 并行 P2build_provider_for 调用点(title/knowledge_inject/idea/project6 处)无需改(src-tauri 保留)
  • 合并:cargo check 全绿

阶段 3AiNode 注入链改造(串行,阶段 2 完成后)

  • S4ai_node.rs parse_params 改 provider_id 路径(双路径兼容)
  • S5ai_node.rs executeAiProviderRepo::get_by_id + resolve + ensure + build_provider
  • S6AiSelfReviewNode 同构改造
  • S7schema required 改 ["provider_id"]

阶段 4模板 + 前端(可并发,阶段 3 完成后)

  • 并行 P3task_workflow_templates.rs ai/ai_self_review 节点 config 改 provider_id
  • 并行 P4前端 api/workflow.ts / TaskDetail.vue 触发 run_workflow 处 config 注 provider_id

阶段 5验证(串行)

  • S8cargo test -p df-storage(keyring 单测 cfg-gate) / -p df-nodes(parse_params 双路径) / -p src-tauri
  • S9端到端联调 AiNode 自审闭环⑥(模板触发 → AiNode 查 provider → LLM 调用 → 产出落 task.output_json)

并发点:阶段 2(P1+P2)、阶段 4(P3+P4) 可分别并发;阶段 1/3/5 串行(前后依赖)。


五、关键 file:line 索引

关注点 位置
secret.rs 全貌 src-tauri/src/commands/ai/secret.rs:1-228
resolve_provider_secret secret.rs:97-102
ensure_resolved_key secret.rs:179-188
build_provider_for(留 src-tauri) secret.rs:164-175
keyring 服务名常量 secret.rs:18(devflow-ai-provider)
AiProviderRecord schema crates/df-storage/src/models.rs:147-159
ai_providers 建表 SQL crates/df-storage/src/migrations.rs:488-500(V9)
AiProviderRepo CRUD crates/df-storage/src/crud.rs:1113-1140
df-nodes Cargo.toml(已含 df-storage+df-ai) crates/df-nodes/Cargo.toml:6-17
df-storage Cargo.toml(缺 keyring) crates/df-storage/Cargo.toml:6-13
workspace keyring 声明 Cargo.toml:42
AiNode parse_params(明文 api_key 风险点) crates/df-nodes/src/ai_node.rs:38-112
AiNode execute build_provider ai_node.rs:135-138
AiSelfReviewNode execute ai_node.rs:330-358
run_workflow config 注入 src-tauri/src/commands/workflow.rs:67-76,245
NodeContext.config crates/df-workflow/src/node.rs:13-26
build_registry AiNode 工厂 src-tauri/src/state.rs:260-263
ai_list_providers mask src-tauri/src/commands/ai/commands.rs:675-688
启动迁移 src-tauri/src/lib.rs:27-31
12 调用点 见 1.2 表

六、决策记录(规格)

  • 方案选型:方案 B(df-storage 加薄 secret 查询方法)。理由:无循环依赖(build_provider_for 留 src-tauri)、DRY(keyring 解析唯一源)、df-nodes 零新依赖、FR-S1 闭环。
  • config 注入:仅 provider_id(非密)base_url/protocol/model 经 AiNode 从 record 读api_key 经 df_storage::secret::resolve_provider_secret 解析,全程不进 IPC / NodeContext.config。
  • run_workflow config 来源:路径 1(前端只传 provider_idAiNode 内部取默认/查表)run_workflow 签名不变(向后兼容)。
  • 兼容窗口parse_params 双路径(provider_id 优先,老 base_url/api_key 明文路径保留 + deprecation warn)。