From 806205b8b0866db9bd20a158beb8a2984eec2fba Mon Sep 17 00:00:00 2001 From: lanrtop Date: Tue, 22 Sep 2026 23:27:34 +0900 Subject: [PATCH] feat(cicd): add safe agent workflow projection Add preview-and-apply workflow commands with digest confirmation, project boundary checks, and MCP/Tauri projections. Sync onboarding capability packs and Codex skill guidance. Feature-Confirmed: true Rules-Applied: R01,R02,R06,R07,R12,R13 --- .agents/skills/lian-jing/SKILL.md | 1 + .blueprint/manifest.yaml | 2 + .blueprint/modules/cicd-workflow.md | 7 + .lian-jing/capability-packs.json | 2 +- docs/ai-context/project-context.yaml | 3 +- .../lian-jing-codex/.codex-plugin/plugin.json | 2 +- .../lian-jing-codex/skills/lian-jing/SKILL.md | 5 +- .../.agents/skills/lian-jing/SKILL.md | 1 + .../.lian-jing/capability-packs.json | 2 +- src-tauri/src/commands/cicd.rs | 535 +++++++++++++++--- src-tauri/src/commands/onboarding.rs | 4 +- src-tauri/src/lib.rs | 2 + src-tauri/src/mcp_tools.rs | 156 ++++- src-tauri/src/models.rs | 19 + src/lib/commands.ts | 37 ++ 15 files changed, 686 insertions(+), 92 deletions(-) diff --git a/.agents/skills/lian-jing/SKILL.md b/.agents/skills/lian-jing/SKILL.md index f52f5f6..ce173ff 100644 --- a/.agents/skills/lian-jing/SKILL.md +++ b/.agents/skills/lian-jing/SKILL.md @@ -15,5 +15,6 @@ description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管 4. 只有用户明确确认本次下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配客户端 bundled runtime 而修改项目版本或 lockfile。 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 保存。 如果 MCP 未出现,说明 Codex 项目配置尚未被加载、项目未被信任,或炼境未运行。说明该限制后继续完成不依赖 MCP 的本地工作;不得伪造诊断结果。 diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 543f53b..3acb0c9 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -173,6 +173,8 @@ modules: area: infra status: done progress: 100 + updated: 2026-09-22 + note: "Gitea workflow 已具备 Agent 安全投影:先只读预览,再以摘要锁定和显式确认写入;不接收凭证值,不触发 CI 或部署。" position: [0, 650] - id: auto-update diff --git a/.blueprint/modules/cicd-workflow.md b/.blueprint/modules/cicd-workflow.md index f09ef9e..d0ece70 100644 --- a/.blueprint/modules/cicd-workflow.md +++ b/.blueprint/modules/cicd-workflow.md @@ -57,3 +57,10 @@ CI/CD 配置管理和 GitHub Actions Workflow 文件生成。支持两种部署 查看 GitHub Actions 运行状态,支持手动触发。 > ⚠️ 已随 github-decommission(2026-07-02)移除,仅作历史记录;Gitea Actions 接入后按新事实源重建。 + +### ✅ T10. CI/CD 能力的 Agent 安全投影 +- status: done +- complexity: M +- files: src-tauri/src/commands/cicd.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/models.rs, src-tauri/src/lib.rs, src/lib/commands.ts, docs/ai-context/project-context.yaml, .agents/skills/lian-jing/SKILL.md, src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md, src-tauri/resources/onboarding/.lian-jing/capability-packs.json, .lian-jing/capability-packs.json, plugins/lian-jing-codex/.codex-plugin/plugin.json, plugins/lian-jing-codex/skills/lian-jing/SKILL.md +- acceptance: "agent_verify: [cargo test] plan_cicd_workflow(project_id, workflow_type) 返回目标文件、工具链来源、required_secrets、change 与 content_digest,且磁盘零写入;apply_cicd_workflow 在 confirmed=false、摘要不一致、未知项目、配置缺失、路径越界或工具链冲突时零写入并返回证据,只有 confirmed=true 且摘要匹配时写入预览对应的 `.gitea/workflows/` 文件;[MCP schema] 支持 pr_ci/deepseek_review/web_ssh/tauri_oss,不暴露密钥值、任意命令或远程执行参数;cargo test --lib、pnpm typecheck、git diff --check 通过,pnpm-lock.yaml 无改动。" +- decision_note: "保留 distribute_cicd 作为 pr_ci/deepseek_review 兼容入口;部署类 workflow 只开放 plan/apply,两阶段确认仅写项目文件,不触发 Gitea Actions、SSH、部署、回滚或 migration。" diff --git a/.lian-jing/capability-packs.json b/.lian-jing/capability-packs.json index b40de6a..9ed8bd2 100644 --- a/.lian-jing/capability-packs.json +++ b/.lian-jing/capability-packs.json @@ -3,7 +3,7 @@ "packs": [ { "pack_id": "base-onboarding", - "version": "1.3.0", + "version": "1.4.0", "supported_clients": ["codex", "claude"], "project_artifacts": [ "AGENTS.md", diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 9a38b82..3c692be 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -13,8 +13,9 @@ contracts: - "src-tauri/src/mcp_tools.rs" - "src-tauri/src/commands/delivery_status.rs" - "src-tauri/src/commands/toolchain.rs" + - "src-tauri/src/commands/cicd.rs" - "src-tauri/src/commands/gitea.rs" - doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功" + doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。" - label: 被管理项目身份上下文 paths: diff --git a/plugins/lian-jing-codex/.codex-plugin/plugin.json b/plugins/lian-jing-codex/.codex-plugin/plugin.json index edac06d..71c5b9b 100644 --- a/plugins/lian-jing-codex/.codex-plugin/plugin.json +++ b/plugins/lian-jing-codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "lian-jing-codex", - "version": "0.1.0", + "version": "0.2.0", "description": "Guide Codex through Lian Jing project context and existing MCP workflows.", "author": { "name": "Lian Jing" diff --git a/plugins/lian-jing-codex/skills/lian-jing/SKILL.md b/plugins/lian-jing-codex/skills/lian-jing/SKILL.md index 67d586a..20309a7 100644 --- a/plugins/lian-jing-codex/skills/lian-jing/SKILL.md +++ b/plugins/lian-jing-codex/skills/lian-jing/SKILL.md @@ -12,7 +12,8 @@ description: Inspect an already onboarded Lian Jing project, use its existing MC 3. 当项目已有 `lian-jing` MCP 时,可调用 `get_diagnostics`、`verify_onboarding`、`get_toolchain_status` 和 `get_delivery_status` 获取可观察信号;所有调用显式传入 `project_id`。 4. Node/pnpm 项目运行包管理命令前先读取工具链状态;`ready_managed` 时使用 `mise exec -- pnpm ...`。只有用户明确确认下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配 Codex bundled runtime 修改项目版本或 lockfile。 5. 合并 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)`;合并后再次读取交付状态。不得自行推断确认。 -6. 若缺少 canonical context、MCP 或项目登记,不得猜测 project_id、MCP URL、凭证或状态。说明缺失项,并建议通过炼境接入或 `inject_mcp` 修复。 -7. 炼境提供状态、凭证托管和受控入口;项目 Agent 仍负责代码判断、测试选择、提交分组与业务交付决策。不要把 CI 成功或部署信号表述为业务验收。 +6. 生成 CI workflow 时先调用 `plan_cicd_workflow(project_id, workflow_type)`,向用户展示目标文件、变更类型、`content_digest`、工具链来源与所需 secret 名称;只有用户明确确认这份摘要后,才以同一 digest 调用 `apply_cicd_workflow(..., confirmed=true)`。该流程只写项目 workflow,不配置 secret、不触发 CI 或部署;不得通过 MCP 传服务器地址、命令、路径或 secret 值,部署类配置先由用户在炼境 UI 保存。 +7. 若缺少 canonical context、MCP 或项目登记,不得猜测 project_id、MCP URL、凭证或状态。说明缺失项,并建议通过炼境接入或 `inject_mcp` 修复。 +8. 炼境提供状态、凭证托管和受控入口;项目 Agent 仍负责代码判断、测试选择、提交分组与业务交付决策。不要把 CI 成功或部署信号表述为业务验收。 本插件不包含 MCP server、MCP URL、项目 ID、凭证、部署命令或客户端项目 UI 映射;这些都属于项目接入与炼境控制面。 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 f52f5f6..ce173ff 100644 --- a/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md +++ b/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md @@ -15,5 +15,6 @@ description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管 4. 只有用户明确确认本次下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配客户端 bundled runtime 而修改项目版本或 lockfile。 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 保存。 如果 MCP 未出现,说明 Codex 项目配置尚未被加载、项目未被信任,或炼境未运行。说明该限制后继续完成不依赖 MCP 的本地工作;不得伪造诊断结果。 diff --git a/src-tauri/resources/onboarding/.lian-jing/capability-packs.json b/src-tauri/resources/onboarding/.lian-jing/capability-packs.json index b40de6a..9ed8bd2 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.3.0", + "version": "1.4.0", "supported_clients": ["codex", "claude"], "project_artifacts": [ "AGENTS.md", diff --git a/src-tauri/src/commands/cicd.rs b/src-tauri/src/commands/cicd.rs index 1b167d7..3907398 100644 --- a/src-tauri/src/commands/cicd.rs +++ b/src-tauri/src/commands/cicd.rs @@ -2,9 +2,10 @@ // 历史部署教程与踩坑指南见 docs/tauri-cicd-guide.md(原 GitHub Actions 方案) use crate::commands::toolchain::parse_toolchain_contract; use crate::db; -use crate::models::{CicdConfig, WorkflowResult}; -use rusqlite::params; -use std::path::Path; +use crate::models::{CicdConfig, WorkflowApplyResult, WorkflowPlan, WorkflowResult}; +use rusqlite::{params, Connection}; +use sha2::{Digest, Sha256}; +use std::path::{Component, Path, PathBuf}; use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Eq)] @@ -118,9 +119,7 @@ impl WorkflowToolchain { } } -#[tauri::command] -pub fn get_cicd_config(project_id: String) -> Result, String> { - let conn = db::pool().get().map_err(|e| e.to_string())?; +fn get_cicd_config_for(conn: &Connection, project_id: &str) -> Result, String> { let result = conn.query_row( "SELECT id, project_id, config_type, trigger_branch, build_command, node_version, server_host, server_user, deploy_path, start_command, oss_endpoint, created_at @@ -129,12 +128,291 @@ pub fn get_cicd_config(project_id: String) -> Result, String> CicdConfig::from_row, ); match result { - Ok(cfg) => Ok(Some(cfg)), + Ok(config) => Ok(Some(config)), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.to_string()), + Err(error) => Err(error.to_string()), } } +fn minimal_config(project_id: &str, workflow_type: &str) -> CicdConfig { + CicdConfig { + id: String::new(), + project_id: project_id.to_string(), + config_type: workflow_type.to_string(), + trigger_branch: "main".to_string(), + build_command: None, + node_version: None, + server_host: None, + server_user: None, + deploy_path: None, + start_command: None, + oss_endpoint: None, + created_at: None, + } +} + +fn config_for_workflow( + conn: &Connection, + project_id: &str, + workflow_type: &str, +) -> Result { + match workflow_type { + "pr_ci" | "deepseek_review" => Ok(get_cicd_config_for(conn, project_id)? + .filter(|config| config.config_type == workflow_type) + .unwrap_or_else(|| minimal_config(project_id, workflow_type))), + "web_ssh" | "tauri_oss" => { + let config = get_cicd_config_for(conn, project_id)?.ok_or_else(|| { + format!( + "项目 {project_id} 尚未保存 {workflow_type} CI/CD 配置;请先在炼境 UI 配置,Agent 不接收服务器地址、命令或密钥值" + ) + })?; + if config.config_type != workflow_type { + return Err(format!( + "项目 {project_id} 当前保存的 CI/CD 类型为 {},不能按 {workflow_type} 生成;请先由用户在炼境 UI 确认配置类型", + config.config_type + )); + } + Ok(config) + } + other => Err(format!( + "不支持的 workflow_type: {other}(可选: pr_ci / deepseek_review / web_ssh / tauri_oss)" + )), + } +} + +fn resolve_registered_project_root(conn: &Connection, project_id: &str) -> Result { + if project_id.trim().is_empty() { + return Err("project_id 不能为空".to_string()); + } + + let (win_path, wsl_path, platform): (Option, Option, Option) = conn + .query_row( + "SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1", + params![project_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .map_err(|_| format!("项目 {project_id} 不存在"))?; + + let configured_path = if platform.as_deref() == Some("wsl") { + let wsl = wsl_path.ok_or("WSL 项目未配置 wsl_path")?; + let distro: String = conn + .query_row( + "SELECT value FROM settings WHERE key = 'wsl_distro'", + [], + |row| row.get(0), + ) + .unwrap_or_else(|_| "Ubuntu".to_string()); + let inner = wsl.trim_start_matches('/').replace('/', "\\"); + format!("\\\\wsl.localhost\\{distro}\\{inner}") + } else { + win_path.ok_or("项目未配置本地路径")? + }; + + let root = PathBuf::from(&configured_path); + if !root.is_absolute() { + return Err(format!( + "项目路径不是绝对路径,拒绝生成 workflow:{configured_path}" + )); + } + let canonical = root + .canonicalize() + .map_err(|error| format!("项目路径不可访问 {configured_path}:{error}"))?; + if !canonical.is_dir() { + return Err(format!("项目路径不是目录:{}", canonical.display())); + } + Ok(canonical) +} + +fn workflow_target(root: &Path, filename: &str) -> Result { + let components = Path::new(filename).components().collect::>(); + if components.len() != 1 || !matches!(components[0], Component::Normal(_)) { + return Err(format!("非法 workflow 文件名,拒绝路径越界:{filename}")); + } + + let gitea_dir = root.join(".gitea"); + let workflows_dir = gitea_dir.join("workflows"); + let existing_parent = if workflows_dir.exists() { + workflows_dir.canonicalize() + } else if gitea_dir.exists() { + gitea_dir.canonicalize() + } else { + root.canonicalize() + } + .map_err(|error| format!("无法校验 workflow 目录边界:{error}"))?; + + if !existing_parent.starts_with(root) { + return Err(format!( + "workflow 目录越出项目根目录,拒绝写入:{}", + existing_parent.display() + )); + } + + let target = workflows_dir.join(filename); + if target.exists() { + let canonical_target = target + .canonicalize() + .map_err(|error| format!("无法校验 workflow 文件边界:{error}"))?; + if !canonical_target.starts_with(root) { + return Err(format!( + "workflow 文件越出项目根目录,拒绝写入:{}", + canonical_target.display() + )); + } + } + Ok(target) +} + +fn content_digest(content: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn build_workflow_for_type( + root: &Path, + config: &CicdConfig, + workflow_type: &str, +) -> Result<(String, String, Vec, Option), String> { + match workflow_type { + "web_ssh" => { + let toolchain = WorkflowToolchain::from_project(root, config)?; + let (content, filename, secrets) = build_web_ssh_workflow(config, &toolchain); + Ok((content, filename, secrets, Some(toolchain))) + } + "tauri_oss" => { + let toolchain = WorkflowToolchain::from_project(root, config)?; + let (content, filename, secrets) = build_tauri_oss_workflow(config, &toolchain); + Ok((content, filename, secrets, Some(toolchain))) + } + "pr_ci" => { + let toolchain = WorkflowToolchain::from_project(root, config)?; + let (content, filename, secrets) = build_pr_ci_workflow(config, &toolchain); + Ok((content, filename, secrets, Some(toolchain))) + } + "deepseek_review" => { + let (content, filename, secrets) = build_deepseek_review_workflow(config); + Ok((content, filename, secrets, None)) + } + other => Err(format!( + "不支持的 workflow_type: {other}(可选: pr_ci / deepseek_review / web_ssh / tauri_oss)" + )), + } +} + +pub fn plan_cicd_workflow_for( + conn: &Connection, + project_id: &str, + workflow_type: &str, +) -> Result { + let root = resolve_registered_project_root(conn, project_id)?; + let config = config_for_workflow(conn, project_id, workflow_type)?; + let (content, filename, required_secrets, toolchain) = + build_workflow_for_type(&root, &config, workflow_type)?; + let target = workflow_target(&root, &filename)?; + let change = match std::fs::read(&target) { + Ok(existing) if existing == content.as_bytes() => "noop", + Ok(_) => "update", + Err(error) if error.kind() == std::io::ErrorKind::NotFound => "create", + Err(error) => return Err(format!("读取现有 workflow 失败:{error}")), + } + .to_string(); + + Ok(WorkflowPlan { + project_id: project_id.to_string(), + workflow_type: workflow_type.to_string(), + content_digest: content_digest(&content), + content, + path: target.to_string_lossy().to_string(), + change, + required_secrets, + node_source: toolchain.as_ref().map(|value| value.node_source.clone()), + pnpm_source: toolchain.as_ref().map(|value| value.pnpm_source.clone()), + }) +} + +pub fn apply_cicd_workflow_for( + conn: &Connection, + project_id: &str, + workflow_type: &str, + expected_digest: &str, + confirmed: bool, +) -> Result { + if !confirmed { + return Err("写入 CI workflow 需要当前用户明确确认,并传 confirmed=true".to_string()); + } + if expected_digest.trim().is_empty() { + return Err("expected_digest 不能为空;请先调用 plan_cicd_workflow".to_string()); + } + + let mut plan = plan_cicd_workflow_for(conn, project_id, workflow_type)?; + if plan.content_digest != expected_digest { + return Err(format!( + "workflow 预览已变化,拒绝写入:expected_digest={expected_digest},actual_digest={}", + plan.content_digest + )); + } + + let root = resolve_registered_project_root(conn, project_id)?; + let filename = Path::new(&plan.path) + .file_name() + .and_then(|value| value.to_str()) + .ok_or("workflow 目标文件名无效")?; + let target = workflow_target(&root, filename)?; + let workflows_dir = target.parent().ok_or("workflow 目标目录无效")?; + std::fs::create_dir_all(workflows_dir) + .map_err(|error| format!("创建 workflow 目录失败:{error}"))?; + let canonical_dir = workflows_dir + .canonicalize() + .map_err(|error| format!("无法校验 workflow 目录:{error}"))?; + if !canonical_dir.starts_with(&root) { + return Err(format!( + "workflow 目录越出项目根目录,拒绝写入:{}", + canonical_dir.display() + )); + } + + let written = plan.change != "noop"; + if written { + std::fs::write(&target, plan.content.as_bytes()) + .map_err(|error| format!("写入 workflow 失败:{error}"))?; + } + plan.path = target.to_string_lossy().to_string(); + + Ok(WorkflowApplyResult { plan, written }) +} + +#[tauri::command] +pub fn plan_cicd_workflow( + project_id: String, + workflow_type: String, +) -> Result { + let conn = db::pool().get().map_err(|error| error.to_string())?; + plan_cicd_workflow_for(&conn, &project_id, &workflow_type) +} + +#[tauri::command] +pub fn apply_cicd_workflow( + project_id: String, + workflow_type: String, + expected_digest: String, + confirmed: bool, +) -> Result { + let conn = db::pool().get().map_err(|error| error.to_string())?; + apply_cicd_workflow_for( + &conn, + &project_id, + &workflow_type, + &expected_digest, + confirmed, + ) +} + +#[tauri::command] +pub fn get_cicd_config(project_id: String) -> Result, String> { + let conn = db::pool().get().map_err(|e| e.to_string())?; + get_cicd_config_for(&conn, &project_id) +} + #[tauri::command] pub fn save_cicd_config(input: CicdConfig) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; @@ -196,21 +474,14 @@ pub fn generate_workflow( .map_err(|e| e.to_string())?; let project_root = Path::new(&project_path); - let (content, filename, secrets) = match config.config_type.as_str() { - "tauri_oss" => { - let toolchain = WorkflowToolchain::from_project(project_root, &config)?; - build_tauri_oss_workflow(&config, &toolchain) - } - "pr_ci" => { - let toolchain = WorkflowToolchain::from_project(project_root, &config)?; - build_pr_ci_workflow(&config, &toolchain) - } - "deepseek_review" => build_deepseek_review_workflow(&config), - _ => { - let toolchain = WorkflowToolchain::from_project(project_root, &config)?; - build_web_ssh_workflow(&config, &toolchain) - } + let workflow_type = match config.config_type.as_str() { + "tauri_oss" => "tauri_oss", + "pr_ci" => "pr_ci", + "deepseek_review" => "deepseek_review", + _ => "web_ssh", }; + let (content, filename, secrets, _) = + build_workflow_for_type(project_root, &config, workflow_type)?; let workflows_dir = project_root.join(".gitea").join("workflows"); std::fs::create_dir_all(&workflows_dir).map_err(|e| e.to_string())?; @@ -230,70 +501,26 @@ pub fn distribute_workflow_for_project( project_id: &str, workflow_type: &str, ) -> Result { + if !matches!(workflow_type, "pr_ci" | "deepseek_review") { + return Err(format!( + "distribute_cicd 兼容入口仅支持 pr_ci / deepseek_review;{workflow_type} 请先调用 plan_cicd_workflow,再由用户确认摘要后调用 apply_cicd_workflow" + )); + } let conn = db::pool().get().map_err(|e| e.to_string())?; - - let (win_path, wsl_path, platform): (Option, Option, Option) = conn - .query_row( - "SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1", - params![project_id], - |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), - ) - .map_err(|_| format!("项目 {} 不存在", project_id))?; - - let project_path = if platform.as_deref() == Some("wsl") { - let wsl = wsl_path.ok_or("WSL 项目未配置 wsl_path")?; - let distro: String = conn - .query_row( - "SELECT value FROM settings WHERE key = 'wsl_distro'", - [], - |r| r.get(0), - ) - .unwrap_or_else(|_| "Ubuntu".to_string()); - let inner = wsl.trim_start_matches('/').replace('/', "\\"); - format!("\\\\wsl.localhost\\{}\\{}", distro, inner) - } else { - win_path.ok_or("项目未配置本地路径")? - }; - - // 最小 config,pr_ci 用 trigger_branch;deepseek_review 不使用 config 字段 - let minimal_cfg = crate::models::CicdConfig { - id: String::new(), - project_id: project_id.to_string(), - config_type: workflow_type.to_string(), - trigger_branch: "main".to_string(), - build_command: None, - node_version: None, - server_host: None, - server_user: None, - deploy_path: None, - start_command: None, - oss_endpoint: None, - created_at: None, - }; - - let (content, filename, secrets) = match workflow_type { - "pr_ci" => { - let toolchain = - WorkflowToolchain::from_project(Path::new(&project_path), &minimal_cfg)?; - build_pr_ci_workflow(&minimal_cfg, &toolchain) - } - "deepseek_review" => build_deepseek_review_workflow(&minimal_cfg), - other => { - return Err(format!( - "不支持的 workflow_type: {other}(可选: pr_ci / deepseek_review)" - )) - } - }; - - let workflows_dir = Path::new(&project_path).join(".gitea").join("workflows"); - std::fs::create_dir_all(&workflows_dir).map_err(|e| format!("创建目录失败: {e}"))?; - let file_path = workflows_dir.join(&filename); - std::fs::write(&file_path, &content).map_err(|e| format!("写入文件失败: {e}"))?; - - let secrets_hint = if secrets.is_empty() { + let plan = plan_cicd_workflow_for(&conn, project_id, workflow_type)?; + let digest = plan.content_digest.clone(); + let result = apply_cicd_workflow_for(&conn, project_id, workflow_type, &digest, true)?; + let filename = Path::new(&result.plan.path) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("workflow.yml"); + let secrets_hint = if result.plan.required_secrets.is_empty() { String::new() } else { - format!(",需要配置 secrets: {}", secrets.join(" / ")) + format!( + ",需要配置 secrets: {}", + result.plan.required_secrets.join(" / ") + ) }; Ok(format!( @@ -628,6 +855,44 @@ mod tests { use super::*; use std::path::PathBuf; + fn setup_cicd_schema(conn: &Connection) { + conn.execute_batch( + "CREATE TABLE project_workspaces ( + id TEXT PRIMARY KEY, + win_path TEXT, + wsl_path TEXT, + platform TEXT + ); + CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT + ); + CREATE TABLE cicd_configs ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL UNIQUE, + config_type TEXT NOT NULL, + trigger_branch TEXT NOT NULL, + build_command TEXT, + node_version TEXT, + server_host TEXT, + server_user TEXT, + deploy_path TEXT, + start_command TEXT, + oss_endpoint TEXT, + created_at TEXT + );", + ) + .unwrap(); + } + + fn register_project(conn: &Connection, project_id: &str, root: &Path) { + conn.execute( + "INSERT INTO project_workspaces (id, win_path, platform) VALUES (?1, ?2, 'windows')", + params![project_id, root.to_string_lossy()], + ) + .unwrap(); + } + fn cfg(config_type: &str) -> CicdConfig { CicdConfig { id: "t".into(), @@ -801,4 +1066,108 @@ mod tests { assert!(error.contains(".node-version=22.14.0")); std::fs::remove_dir_all(root).unwrap(); } + + #[test] + fn plan_is_read_only_and_apply_requires_confirmation_and_digest() { + let conn = Connection::open_in_memory().unwrap(); + setup_cicd_schema(&conn); + let root = temp_project("plan-apply"); + std::fs::write(root.join(".nvmrc"), "24.19.0\n").unwrap(); + std::fs::write( + root.join("package.json"), + r#"{"packageManager":"pnpm@9.15.0"}"#, + ) + .unwrap(); + + let unknown = plan_cicd_workflow_for(&conn, "missing", "pr_ci").unwrap_err(); + assert!(unknown.contains("项目 missing 不存在")); + assert!(!root.join(".gitea").exists()); + + register_project(&conn, "p1", &root); + + let plan = plan_cicd_workflow_for(&conn, "p1", "pr_ci").unwrap(); + let target = PathBuf::from(&plan.path); + assert_eq!(plan.change, "create"); + assert_eq!(plan.content_digest.len(), 64); + assert_eq!(plan.node_source.as_deref(), Some(".nvmrc")); + assert_eq!( + plan.pnpm_source.as_deref(), + Some("package.json#packageManager") + ); + assert!(!target.exists(), "预览不得写入 workflow"); + + let error = + apply_cicd_workflow_for(&conn, "p1", "pr_ci", &plan.content_digest, false).unwrap_err(); + assert!(error.contains("明确确认")); + assert!(!target.exists()); + + let error = apply_cicd_workflow_for(&conn, "p1", "pr_ci", "stale", true).unwrap_err(); + assert!(error.contains("预览已变化")); + assert!(!target.exists()); + + let applied = + apply_cicd_workflow_for(&conn, "p1", "pr_ci", &plan.content_digest, true).unwrap(); + assert!(applied.written); + assert_eq!(applied.plan.change, "create"); + assert_eq!(std::fs::read_to_string(&target).unwrap(), plan.content); + + let second_plan = plan_cicd_workflow_for(&conn, "p1", "pr_ci").unwrap(); + assert_eq!(second_plan.change, "noop"); + let second_apply = + apply_cicd_workflow_for(&conn, "p1", "pr_ci", &second_plan.content_digest, true) + .unwrap(); + assert!(!second_apply.written); + assert_eq!(second_apply.plan.change, "noop"); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn deploy_plan_requires_saved_matching_config() { + let conn = Connection::open_in_memory().unwrap(); + setup_cicd_schema(&conn); + let root = temp_project("deploy-config"); + register_project(&conn, "p1", &root); + + let missing = plan_cicd_workflow_for(&conn, "p1", "web_ssh").unwrap_err(); + assert!(missing.contains("尚未保存 web_ssh")); + assert!(!root.join(".gitea").exists()); + + conn.execute( + "INSERT INTO cicd_configs ( + id, project_id, config_type, trigger_branch, build_command, node_version + ) VALUES ('cfg-1', 'p1', 'tauri_oss', 'main', 'pnpm build', '20')", + [], + ) + .unwrap(); + let mismatch = plan_cicd_workflow_for(&conn, "p1", "web_ssh").unwrap_err(); + assert!(mismatch.contains("当前保存的 CI/CD 类型为 tauri_oss")); + assert!(!root.join(".gitea").exists()); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn workflow_target_rejects_path_escape() { + let root = temp_project("path-escape").canonicalize().unwrap(); + let error = workflow_target(&root, "../outside.yml").unwrap_err(); + assert!(error.contains("路径越界")); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn plan_conflict_fails_without_writing() { + let conn = Connection::open_in_memory().unwrap(); + setup_cicd_schema(&conn); + let root = temp_project("plan-conflict"); + std::fs::write(root.join(".nvmrc"), "24.19.0\n").unwrap(); + std::fs::write(root.join(".node-version"), "22.14.0\n").unwrap(); + register_project(&conn, "p1", &root); + + let error = plan_cicd_workflow_for(&conn, "p1", "pr_ci").unwrap_err(); + assert!(error.contains("拒绝生成 CI workflow")); + assert!(!root.join(".gitea").exists()); + + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 0dfe3d6..2e755f3 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.3.0"; +const BASE_ONBOARDING_PACK_VERSION: &str = "1.4.0"; const TOOLCHAIN_PACK_VERSION: &str = "1.0.0"; /// 单个模板的分发结果。 @@ -843,7 +843,7 @@ pub fn verify_onboarding( if yml_count > 0 { format!(".gitea/workflows/ 有 {yml_count} 个 workflow") } else { - "无 .gitea/workflows/*.yml——可调 distribute_cicd 分发".into() + "无 .gitea/workflows/*.yml——先调 plan_cicd_workflow 预览,用户确认摘要后再调 apply_cicd_workflow 写入".into() }, )); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dffa659..13d5c2e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -293,6 +293,8 @@ pub fn run() { get_cicd_config, save_cicd_config, generate_workflow, + plan_cicd_workflow, + apply_cicd_workflow, // blueprint get_blueprint, generate_blueprint_prompt, diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 13bfc72..32284af 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -257,9 +257,35 @@ pub fn tools_list_result() -> Value { "required": ["project_id"] } }, + { + "name": "plan_cicd_workflow", + "description": "只读预览项目 CI workflow:返回目标文件、create/update/noop、完整内容摘要 digest、所需 secret 名称与工具链来源;不写磁盘、不配置 secret、不触发 CI 或部署。部署类模板只读取炼境 UI 已保存的配置。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, + "workflow_type": { "type": "string", "enum": ["pr_ci", "deepseek_review", "web_ssh", "tauri_oss"], "description": "要预览的 workflow 类型" } + }, + "required": ["project_id", "workflow_type"] + } + }, + { + "name": "apply_cicd_workflow", + "description": "在当前用户明确确认 plan_cicd_workflow 的摘要后,仅写入该项目对应的 .gitea/workflows 文件。必须提交同一预览的 digest;配置或内容变化会拒绝写入。不会配置 secret、触发 CI、SSH、部署、回滚或 migration。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, + "workflow_type": { "type": "string", "enum": ["pr_ci", "deepseek_review", "web_ssh", "tauri_oss"], "description": "与预览相同的 workflow 类型" }, + "expected_digest": { "type": "string", "description": "用户确认时看到的 plan_cicd_workflow.content_digest" }, + "confirmed": { "type": "boolean", "description": "当前用户是否已明确确认该预览摘要与写入目标" } + }, + "required": ["project_id", "workflow_type", "expected_digest", "confirmed"] + } + }, { "name": "distribute_cicd", - "description": "向项目写入 CI 模板文件(.gitea/workflows/)。当项目缺少 CI 配置时调用,自动解析项目路径写入。支持 pr_ci(PR 质量检查)和 deepseek_review(AI 代码审查)两种类型。", + "description": "旧客户端兼容入口:直接写入 pr_ci 或 deepseek_review 模板。新 Agent 应使用 plan_cicd_workflow → 用户确认摘要 → apply_cicd_workflow;部署类模板不允许通过本入口写入。", "inputSchema": { "type": "object", "properties": { @@ -638,6 +664,62 @@ pub async fn tools_call(params: Option<&Value>) -> Value { let wt = args.get("workflow_type").and_then(|v| v.as_str()).unwrap_or(""); crate::commands::cicd::distribute_workflow_for_project(pid, wt) } + "plan_cicd_workflow" => { + let pid = args.get("project_id").and_then(|value| value.as_str()).unwrap_or(""); + let workflow_type = args + .get("workflow_type") + .and_then(|value| value.as_str()) + .unwrap_or(""); + if pid.is_empty() { + return Err("project_id 不能为空".to_string()); + } + if workflow_type.is_empty() { + return Err("workflow_type 不能为空".to_string()); + } + let conn = db::pool().get().map_err(|error| error.to_string())?; + let plan = crate::commands::cicd::plan_cicd_workflow_for( + &conn, + pid, + workflow_type, + )?; + serde_json::to_string_pretty(&plan).map_err(|error| error.to_string()) + } + "apply_cicd_workflow" => { + let pid = args.get("project_id").and_then(|value| value.as_str()).unwrap_or(""); + let workflow_type = args + .get("workflow_type") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let expected_digest = args + .get("expected_digest") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let confirmed = args + .get("confirmed") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + if pid.is_empty() { + return Err("project_id 不能为空".to_string()); + } + if workflow_type.is_empty() { + return Err("workflow_type 不能为空".to_string()); + } + if !confirmed { + return Err("写入 CI workflow 需要当前用户明确确认(confirmed=true)".to_string()); + } + if expected_digest.is_empty() { + return Err("expected_digest 不能为空;请先调用 plan_cicd_workflow".to_string()); + } + let conn = db::pool().get().map_err(|error| error.to_string())?; + let result = crate::commands::cicd::apply_cicd_workflow_for( + &conn, + pid, + workflow_type, + expected_digest, + confirmed, + )?; + serde_json::to_string_pretty(&result).map_err(|error| error.to_string()) + } "list_all_projects" => list_all_projects(), "scan_unregistered_projects" => scan_unregistered_projects(), "unregister_project" => { @@ -1658,6 +1740,14 @@ mod tests { "缺少 unregister_project" ); assert!(names.contains(&"setup_gitea_repo"), "缺少 setup_gitea_repo"); + assert!( + names.contains(&"plan_cicd_workflow"), + "缺少 plan_cicd_workflow" + ); + assert!( + names.contains(&"apply_cicd_workflow"), + "缺少 apply_cicd_workflow" + ); // 每个工具都有 description 和 inputSchema for tool in tools { let name = tool["name"].as_str().unwrap_or("?"); @@ -1738,6 +1828,70 @@ mod tests { .contains("project_id 不能为空")); } + #[test] + fn cicd_workflow_schemas_require_safe_confirmation_contract() { + let result = tools_list_result(); + let tools = result["tools"].as_array().unwrap(); + let plan = tools + .iter() + .find(|tool| tool["name"] == "plan_cicd_workflow") + .expect("缺少 plan_cicd_workflow"); + let apply = tools + .iter() + .find(|tool| tool["name"] == "apply_cicd_workflow") + .expect("缺少 apply_cicd_workflow"); + let required = apply["inputSchema"]["required"].as_array().unwrap(); + for field in [ + "project_id", + "workflow_type", + "expected_digest", + "confirmed", + ] { + assert!(required.iter().any(|value| value == field)); + } + for (name, tool) in [("plan", plan), ("apply", apply)] { + let properties = tool["inputSchema"]["properties"].as_object().unwrap(); + for forbidden in ["secret", "command", "path", "url", "host"] { + assert!( + !properties.contains_key(forbidden), + "{name} schema 不应接收 {forbidden}" + ); + } + } + } + + #[tokio::test] + async fn apply_cicd_workflow_rejects_missing_confirmation_before_db_io() { + let result = tools_call(Some(&json!({ + "name": "apply_cicd_workflow", + "arguments": { + "project_id": "project-1", + "workflow_type": "pr_ci", + "expected_digest": "0123456789abcdef" + } + }))) + .await; + assert_eq!(result["isError"], true); + assert!(result["content"][0]["text"] + .as_str() + .unwrap_or_default() + .contains("明确确认")); + } + + #[tokio::test] + async fn plan_cicd_workflow_requires_project_id_before_db_io() { + let result = tools_call(Some(&json!({ + "name": "plan_cicd_workflow", + "arguments": { "workflow_type": "pr_ci" } + }))) + .await; + assert_eq!(result["isError"], true); + assert!(result["content"][0]["text"] + .as_str() + .unwrap_or_default() + .contains("project_id 不能为空")); + } + #[test] fn prepare_toolchain_schema_requires_confirmation() { let result = tools_list_result(); diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index bb85fc7..8de3d9a 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -209,6 +209,25 @@ pub struct WorkflowResult { pub secrets: Vec, } +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct WorkflowPlan { + pub project_id: String, + pub workflow_type: String, + pub content: String, + pub path: String, + pub content_digest: String, + pub change: String, + pub required_secrets: Vec, + pub node_source: Option, + pub pnpm_source: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct WorkflowApplyResult { + pub plan: WorkflowPlan, + pub written: bool, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct GitRepo { pub id: String, diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 79b25a0..d83808f 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -763,6 +763,25 @@ export interface WorkflowResult { secrets: string[]; } +export type CicdWorkflowType = "pr_ci" | "deepseek_review" | "web_ssh" | "tauri_oss"; + +export interface WorkflowPlan { + project_id: string; + workflow_type: CicdWorkflowType; + content: string; + path: string; + content_digest: string; + change: "create" | "update" | "noop"; + required_secrets: string[]; + node_source?: string; + pnpm_source?: string; +} + +export interface WorkflowApplyResult { + plan: WorkflowPlan; + written: boolean; +} + export const getCicdConfig = (projectId: string) => invoke("get_cicd_config", { projectId }); @@ -773,6 +792,24 @@ export const saveCicdConfig = (input: CicdConfig) => export const generateWorkflow = (projectId: string, projectPath: string) => invoke("generate_workflow", { projectId, projectPath }); +/** 只读预览:不写文件、不触发 CI 或部署。 */ +export const planCicdWorkflow = (projectId: string, workflowType: CicdWorkflowType) => + invoke("plan_cicd_workflow", { projectId, workflowType }); + +/** 写入预览对应的 workflow;调用方必须传入用户确认过的摘要。 */ +export const applyCicdWorkflow = ( + projectId: string, + workflowType: CicdWorkflowType, + expectedDigest: string, + confirmed: boolean, +) => + invoke("apply_cicd_workflow", { + projectId, + workflowType, + expectedDigest, + confirmed, + }); + /** 根据节点数据构造 share link(前端完成,避免 Rust 侧 URL 编码依赖) */ export function buildShareLink(node: V2raynNode): string { const remarks = encodeURIComponent(node.remarks); -- 2.45.2