diff --git a/.blueprint/modules/project-dev-variables.md b/.blueprint/modules/project-dev-variables.md index 2faddbc..a10608f 100644 --- a/.blueprint/modules/project-dev-variables.md +++ b/.blueprint/modules/project-dev-variables.md @@ -186,3 +186,26 @@ progress: 91 - acceptance: "agent_verify: [真实 scoped MCP/cargo test] 合成项目登记→同一本体填写→再次查询已配置→读取→撤销后读取失败,并确认目录没有新增 .env 或明文配置;cargo test、pnpm typecheck、git diff --check exit 0。human_verify: 真实桌面 UI 与真实 Agent 客户端跨会话便利性另报未验证,不冒充 V7 完成。" - human_verify: pending(真实桌面 UI 和真实 Agent 客户端跨会话复用尚未终验;V7 保持 blocked) - evidence: 真实本机 scoped MCP HTTP 合成链路逐步断言登记、待填写、受保护存储填写、复查已配置、项目 bearer 读取、撤销后拒绝;数据库重开复用测试通过。`cargo test --locked` 227 passed/4 ignored,`mise exec -- pnpm typecheck` 与 `git diff --check` exit 0;未新增 `.env` 或明文配置文件。 + +## 任务拆解:UI 终验反馈与 agent 可发现性(2026-09-30) + +> 来源:用户对 V7 的 human_verify 否决理由——「修改时空白,应像修改配置一样呈现旧值」「撤销无确认框」(后者为 bug,已由 `f74fe11` 修复并加回归测试);以及 agent 侧可发现性缺口:接入包 skill 未提示「需要测试账号时先查炼境」,agent 会继续向人索要。 + +### ✅ V12 — 修改时显式查看原值(人用投影) +- status: done +- complexity: M +- files: src-tauri/src/commands/dev_variables.rs, src-tauri/src/lib.rs, src/lib/commands.ts, src/components/dashboard/ProjectDevVariablesPanel.tsx, docs/ai-context/project-context.yaml +- depends: V10 +- acceptance: "agent_verify: [cargo test] reveal(已配置测试账户) → 账号与密码原值;reveal(标量) → 原值;未配置 → 错误;MCP tools/list 不含任何 reveal 工具。human_verify: 点「修改」→ 账号预填可见、密码预填但默认遮罩,点「显示」可见;保存后生效。" +- verification: "2026-09-30:cargo test reveal_returns_original_value_by_kind_only_for_humans 覆盖测试账户/标量取回、未配置与撤销后报错、MCP tools/list 不含 reveal;全量 cargo test --locked 228 passed / 5 ignored,pnpm typecheck exit 0,vitest 8 passed。" +- human_verify: pending(第二次 UI 终验) +- decision: 值仅经本机 Tauri command 在人显式点「修改」时取回,沿用 API Key R2 的显式 reveal 模式;不进 MCP,agent 取值仍只走已认证的 read_project_test_account。密码默认遮罩防截图/旁观泄露。 + +### ✅ V13 — 接入包 skill 提示 agent 先查炼境测试账户 +- status: done +- complexity: S +- files: src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md, src-tauri/src/commands/onboarding.rs +- depends: V9 +- acceptance: "agent_verify: [cargo test onboarding] base-onboarding 版本升级后旧版本项目报告 version_mismatch、新模板含测试账户指引。human_verify: 合并并重建后对 Enterprise apply_onboarding_pack,agent 需要登录时先查炼境而非向人索要。" +- verification: "2026-09-30:SKILL.md 新增第 8 步(先 list,已配置单项读取,否则登记并请用户在 UI 填写;禁止对话索要明文、禁止登记生产、禁止写入 Git 跟踪文件、认证被拒如实报告);BASE_ONBOARDING_PACK_VERSION 与模板 capability-packs.json 同步升至 1.5.0(仅升常量时 2 个 onboarding 测试失败,已同步修正);cargo test --locked 全绿。" +- human_verify: pending(分发到 Enterprise 后观察 agent 行为) diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 0be6d05..6cd13c0 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -31,7 +31,7 @@ contracts: paths: - "src-tauri/src/commands/**/*.rs" - "src/lib/commands.ts" - doc: "新增 command 时前后端必须同步。Gitea/API Key 普通 DTO 不含原始凭证;API Key 仅本机 UI 显式 reveal,get_project_resource_status 仅返回非秘密状态;项目开发需求列表 DTO 仅含名称、类型、目标系统、环境、用途、configured 与时间。本机 UI 对 test_account 一次提交账号/密码到同一受保护引用,允许修改或撤销但无常态 reveal;旧 scalar 保持兼容。旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。" + doc: "新增 command 时前后端必须同步。Gitea/API Key 普通 DTO 不含原始凭证;API Key 仅本机 UI 显式 reveal,get_project_resource_status 仅返回非秘密状态;项目开发需求列表 DTO 仅含名称、类型、目标系统、环境、用途、configured 与时间。本机 UI 对 test_account 一次提交账号/密码到同一受保护引用,允许修改或撤销;列表常态不含值,仅人显式点「修改」时经 reveal_project_dev_variable 取回原值(密码默认遮罩),该 command 不进 MCP;旧 scalar 保持兼容。旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。" architecture_docs: - "AGENTS.md" diff --git a/docs/tasks/2026-09-30-dev-variables-materialize.md b/docs/tasks/2026-09-30-dev-variables-materialize.md index 1d40e19..5dcdba5 100644 --- a/docs/tasks/2026-09-30-dev-variables-materialize.md +++ b/docs/tasks/2026-09-30-dev-variables-materialize.md @@ -36,13 +36,13 @@ - 返回值、错误文本、日志均不含账号或密码。 - MCP 投影走 scoped 路由,属写工具。客户端审批策略(参见 V7 blocker)按正常审批处理,不标注为只读来绕过。人的投影:`ProjectDevVariablesPanel` 可选加「写入工作区」按钮,属于 human_verify 范围,非 v1 必需。 -## 任务卡草案(合入 dev-variables 模块后编号接续 V11) +## 任务卡草案(合入 dev-variables 模块后编号接续 V13;V12/V13 已被 UI 终验反馈占用) -- **V12 落地本体**(M)— files: `src-tauri/src/commands/dev_variables.rs`, `src/lib/commands.ts`;depends: project-dev-variables 合入 master +- **V14 落地本体**(M)— files: `src-tauri/src/commands/dev_variables.rs`, `src/lib/commands.ts`;depends: project-dev-variables 合入 master - acceptance: `agent_verify: [cargo test] 临时 git 仓库 + 模拟受保护存储:目标被忽略 → 仅两变量行写入、其他行保留、二次调用字节不变;撤销后调用 → 两行被删;目标未被忽略 / 越出工作区 / worktree 属另一仓库 / 账户未填写 → 拒绝且文件字节不变;返回与错误序列化不含账号密码。` -- **V13 MCP 投影**(M)— files: `src-tauri/src/mcp_tools.rs`;depends: V12 +- **V15 MCP 投影**(M)— files: `src-tauri/src/mcp_tools.rs`;depends: V14 - acceptance: `agent_verify: [cargo test] tools/list 含 materialize_project_test_account,schema 要求 project_id;跨项目参数被 scope 拒绝;无认证调用可写入但响应只含变量名;仍无任何批量导出或 get_secret 工具。` -- **V14 Enterprise worktree 试点**(M)— files: `.blueprint/modules/project-dev-variables.md`, `docs/ai-context/project-context.yaml`, `docs/ai-context/agent-button-system.md`;depends: V13 +- **V16 Enterprise worktree 试点**(M)— files: `.blueprint/modules/project-dev-variables.md`, `docs/ai-context/project-context.yaml`, `docs/ai-context/agent-button-system.md`;depends: V15 - acceptance: `agent_verify: [CLI] Enterprise 新建隔离 worktree(无 .env.agent)→ [MCP] materialize_project_test_account(enterprise, <账户名>, ".env.agent", "AGENT_USERNAME", "AGENT_PASSWORD", worktree) → [CLI] 起本地 API 后 pnpm --silent --filter @enterprise-system/api agent:token exit 0 且 stdout 非空;git status 显示 .env.agent 未被跟踪;会话记录不含账号值。` - `human_verify: 在炼境登记一次后,agent 在新 worktree 中无需向人索要账号。` - 度量:人工传递账号次数(目标 0)、新 worktree 就绪到首次 agent:token 成功的耗时。未达收益按「管家退出」规则撤销。 diff --git a/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md b/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md index 3c43f24..84770e6 100644 --- a/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md +++ b/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md @@ -16,6 +16,7 @@ description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管 5. 交付、Gitea、CI、蓝图等平台状态优先通过 MCP 查询,不凭静态文档猜测。 6. 合并 PR 前调用 `get_delivery_status(project_id)`,向用户展示 PR 编号、完整 head SHA、CI 与 review。只有当前用户明确确认该 PR 与 SHA 后,才调用 `merge_pull_request(project_id, pr_number, expected_head_sha, confirmed=true)`;合并后再次读取交付状态。不得自行推断确认。 7. 生成 CI workflow 时先调用 `plan_cicd_workflow(project_id, workflow_type)`,向用户展示目标文件、变更类型、`content_digest`、工具链来源与所需 secret 名称;只有用户明确确认这份摘要后,才以同一 digest 调用 `apply_cicd_workflow(..., confirmed=true)`。该流程只写项目 workflow,不配置 secret、不触发 CI 或部署;不得通过 MCP 传服务器地址、命令、路径或 secret 值,部署类配置先由用户在炼境 UI 保存。 +8. 需要测试账号或开发变量(登录自验、沙箱 key 等)时,先调用 `list_project_dev_variables` 查找:已配置则用 `read_project_test_account` / `read_project_dev_variable` 单项读取;没有则用 `register_project_test_account` 登记名称、目标系统、非生产环境与用途,并请用户在炼境项目 UI「开发变量」中填写。不得在对话中向用户索要明文密码,不得登记生产账号,不得把读到的值写入受 Git 跟踪的文件。读取因缺少项目认证被拒时(当前仅 Codex 经 helper 验证可读),如实报告并改用项目原有配置方式,不得绕过认证。 如果 MCP 未出现,按以下顺序降级,不猜测状态: diff --git a/src-tauri/resources/onboarding/.lian-jing/capability-packs.json b/src-tauri/resources/onboarding/.lian-jing/capability-packs.json index 9ed8bd2..f3e1d44 100644 --- a/src-tauri/resources/onboarding/.lian-jing/capability-packs.json +++ b/src-tauri/resources/onboarding/.lian-jing/capability-packs.json @@ -3,7 +3,7 @@ "packs": [ { "pack_id": "base-onboarding", - "version": "1.4.0", + "version": "1.5.0", "supported_clients": ["codex", "claude"], "project_artifacts": [ "AGENTS.md", diff --git a/src-tauri/src/commands/dev_variables.rs b/src-tauri/src/commands/dev_variables.rs index 8640fd3..2046d2f 100644 --- a/src-tauri/src/commands/dev_variables.rs +++ b/src-tauri/src/commands/dev_variables.rs @@ -24,6 +24,14 @@ pub struct TestAccountValue { pub password: String, } +/// 人在 UI 显式「修改」时取回的原值;仅本机 Tauri 投影使用,MCP 不公开。 +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum DevVariableReveal { + Scalar { value: String }, + TestAccount { username: String, password: String }, +} + const SCALAR_KIND: &str = "scalar"; const TEST_ACCOUNT_KIND: &str = "test_account"; @@ -238,6 +246,35 @@ pub fn read_test_account( Ok(account) } +/// 按登记类型取回原值,复用 agent 读取同一本体,只是投影给人。 +pub fn reveal( + conn: &Connection, + store: &impl CredentialStore, + project_id: &str, + name: &str, +) -> Result { + require_project(conn, project_id)?; + let name = normalized_name(name)?; + let kind: String = conn + .query_row( + "SELECT kind FROM project_dev_variables WHERE project_id=?1 AND name=?2", + params![project_id, name], + |row| row.get(0), + ) + .map_err(|_| "开发变量未登记".to_string())?; + if kind == TEST_ACCOUNT_KIND { + let account = read_test_account(conn, store, project_id, &name)?; + Ok(DevVariableReveal::TestAccount { + username: account.username, + password: account.password, + }) + } else { + Ok(DevVariableReveal::Scalar { + value: read(conn, store, project_id, &name)?, + }) + } +} + fn read_value( conn: &Connection, store: &impl CredentialStore, @@ -444,6 +481,23 @@ pub fn save_project_test_account( ) } +/// 仅供本机 UI 在用户显式点「修改」时调用;MCP 不公开此 command。 +#[tauri::command] +pub fn reveal_project_dev_variable( + project_id: String, + name: String, +) -> Result { + let conn = crate::db::pool() + .get() + .map_err(|_| "数据库不可用".to_string())?; + reveal( + &conn, + &crate::credential_store::WindowsCredentialStore, + &project_id, + &name, + ) +} + #[tauri::command] pub fn revoke_project_dev_variable(project_id: String, name: String) -> Result<(), String> { let conn = crate::db::pool() @@ -507,6 +561,34 @@ mod tests { assert!(read(&conn, &store, "p1", "E2E_PASSWORD").is_err()); } + #[test] + fn reveal_returns_original_value_by_kind_only_for_humans() { + let conn = fixture(); + let store = MemoryCredentialStore::new(); + register_test_account(&conn, "p1", "AGENT_ADMIN", "api", "local", "自验").unwrap(); + register(&conn, "p1", "SANDBOX_KEY", "沙箱").unwrap(); + assert!(reveal(&conn, &store, "p1", "AGENT_ADMIN").is_err()); + assert!(reveal(&conn, &store, "p1", "MISSING").is_err()); + + save_test_account(&conn, &store, "p1", "AGENT_ADMIN", "synthetic-user", "synthetic-pass") + .unwrap(); + save(&conn, &store, "p1", "SANDBOX_KEY", "synthetic-key").unwrap(); + assert_eq!( + serde_json::to_value(reveal(&conn, &store, "p1", "agent_admin").unwrap()).unwrap(), + serde_json::json!({"kind": "test_account", "username": "synthetic-user", "password": "synthetic-pass"}) + ); + assert_eq!( + serde_json::to_value(reveal(&conn, &store, "p1", "SANDBOX_KEY").unwrap()).unwrap(), + serde_json::json!({"kind": "scalar", "value": "synthetic-key"}) + ); + + revoke(&conn, &store, "p1", "AGENT_ADMIN").unwrap(); + assert!(reveal(&conn, &store, "p1", "AGENT_ADMIN").is_err()); + // 人用投影不得进入 agent 通道。 + let tools = crate::mcp_tools::tools_list_result().to_string(); + assert!(!tools.contains("reveal")); + } + #[test] fn test_account_is_one_idempotent_request_and_one_protected_value() { let conn = fixture(); diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index a2a7356..5255cd9 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -26,7 +26,7 @@ const TMPL_CAPABILITY_PACKS: &str = const TMPL_MISE: &str = include_str!("../../resources/onboarding/mise.toml.tmpl"); const PROJECT_CONTEXT_SCHEMA_VERSION: u64 = 1; const CAPABILITY_PACK_SCHEMA_VERSION: u64 = 1; -const BASE_ONBOARDING_PACK_VERSION: &str = "1.4.0"; +const BASE_ONBOARDING_PACK_VERSION: &str = "1.5.0"; const TOOLCHAIN_PACK_VERSION: &str = "1.0.0"; /// 单个模板的分发结果。 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a91480..d9dba65 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -406,6 +406,7 @@ pub fn run() { commands::dev_variables::list_project_dev_variables, commands::dev_variables::save_project_dev_variable, commands::dev_variables::save_project_test_account, + commands::dev_variables::reveal_project_dev_variable, commands::dev_variables::revoke_project_dev_variable, // host apps commands::host_apps::get_host_apps, diff --git a/src/components/dashboard/ProjectDevVariablesPanel.tsx b/src/components/dashboard/ProjectDevVariablesPanel.tsx index 71898b5..b52dcac 100644 --- a/src/components/dashboard/ProjectDevVariablesPanel.tsx +++ b/src/components/dashboard/ProjectDevVariablesPanel.tsx @@ -2,9 +2,11 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { listProjectDevVariables, + revealProjectDevVariable, saveProjectDevVariable, saveProjectTestAccount, revokeProjectDevVariable, + type ProjectDevVariableStatus, } from "../../lib/commands"; import { confirm } from "@tauri-apps/plugin-dialog"; import { useUIStore } from "../../store/ui"; @@ -24,6 +26,8 @@ export function ProjectDevVariablesPanel({ projectId, projectName, onClose }: Pr const [draftUsername, setDraftUsername] = useState(""); const [draftPassword, setDraftPassword] = useState(""); const [saving, setSaving] = useState(false); + const [showSecret, setShowSecret] = useState(false); + const [loadingName, setLoadingName] = useState(null); const { data: variables = [], isPending, error } = useQuery({ queryKey: ["projectDevVariables", projectId], queryFn: () => listProjectDevVariables(projectId), @@ -33,7 +37,35 @@ export function ProjectDevVariablesPanel({ projectId, projectName, onClose }: Pr setDraftValue(""); setDraftUsername(""); setDraftPassword(""); + setShowSecret(false); }; + // 已配置项进入修改时预填原值(密码默认遮罩);取回失败则提示并以空白表单继续。 + const startEdit = async (item: ProjectDevVariableStatus) => { + clearDraft(); + if (item.configured) { + setLoadingName(item.name); + try { + const original = await revealProjectDevVariable(projectId, item.name); + if (original.kind === "test_account") { + setDraftUsername(original.username); + setDraftPassword(original.password); + } else { + setDraftValue(original.value); + } + } catch (cause) { + showToast(`读取原值失败:${cause}`); + } finally { + setLoadingName(null); + } + } + setEditingName(item.name); + }; + const secretToggle = ( + + ); const handleSave = async (name: string, kind: "scalar" | "test_account") => { if (saving || (kind === "test_account" ? !draftUsername || !draftPassword : !draftValue)) return; setSaving(true); @@ -106,26 +138,32 @@ export function ProjectDevVariablesPanel({ projectId, projectName, onClose }: Pr aria-label={`${item.name} 测试账号`} className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" /> - setDraftPassword(event.target.value)} - placeholder="测试密码" - aria-label={`${item.name} 测试密码`} - className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" - /> +
+ setDraftPassword(event.target.value)} + placeholder="测试密码" + aria-label={`${item.name} 测试密码`} + className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" + /> + {secretToggle} +
) : ( - setDraftValue(event.target.value)} - placeholder="填写开发值" - className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" - /> +
+ setDraftValue(event.target.value)} + placeholder="填写开发值" + className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" + /> + {secretToggle} +
)}
+ {item.configured && (