feat(cicd): 增加 Agent 安全工作流投影 #17

Merged
lanrtop merged 1 commits from feat/cicd-workflow-t10 into master 2026-09-23 00:04:30 +08:00
15 changed files with 686 additions and 92 deletions

View File

@ -15,5 +15,6 @@ description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管
4. 只有用户明确确认本次下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配客户端 bundled runtime 而修改项目版本或 lockfile。 4. 只有用户明确确认本次下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配客户端 bundled runtime 而修改项目版本或 lockfile。
5. 交付、Gitea、CI、蓝图等平台状态优先通过 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)`;合并后再次读取交付状态。不得自行推断确认。 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 的本地工作;不得伪造诊断结果。 如果 MCP 未出现,说明 Codex 项目配置尚未被加载、项目未被信任,或炼境未运行。说明该限制后继续完成不依赖 MCP 的本地工作;不得伪造诊断结果。

View File

@ -173,6 +173,8 @@ modules:
area: infra area: infra
status: done status: done
progress: 100 progress: 100
updated: 2026-09-22
note: "Gitea workflow 已具备 Agent 安全投影:先只读预览,再以摘要锁定和显式确认写入;不接收凭证值,不触发 CI 或部署。"
position: [0, 650] position: [0, 650]
- id: auto-update - id: auto-update

View File

@ -57,3 +57,10 @@ CI/CD 配置管理和 GitHub Actions Workflow 文件生成。支持两种部署
查看 GitHub Actions 运行状态,支持手动触发。 查看 GitHub Actions 运行状态,支持手动触发。
> ⚠️ 已随 github-decommission(2026-07-02)移除,仅作历史记录;Gitea 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。"

View File

@ -3,7 +3,7 @@
"packs": [ "packs": [
{ {
"pack_id": "base-onboarding", "pack_id": "base-onboarding",
"version": "1.3.0", "version": "1.4.0",
"supported_clients": ["codex", "claude"], "supported_clients": ["codex", "claude"],
"project_artifacts": [ "project_artifacts": [
"AGENTS.md", "AGENTS.md",

View File

@ -13,8 +13,9 @@ contracts:
- "src-tauri/src/mcp_tools.rs" - "src-tauri/src/mcp_tools.rs"
- "src-tauri/src/commands/delivery_status.rs" - "src-tauri/src/commands/delivery_status.rs"
- "src-tauri/src/commands/toolchain.rs" - "src-tauri/src/commands/toolchain.rs"
- "src-tauri/src/commands/cicd.rs"
- "src-tauri/src/commands/gitea.rs" - "src-tauri/src/commands/gitea.rs"
doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功" doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。"
- label: 被管理项目身份上下文 - label: 被管理项目身份上下文
paths: paths:

View File

@ -1,6 +1,6 @@
{ {
"name": "lian-jing-codex", "name": "lian-jing-codex",
"version": "0.1.0", "version": "0.2.0",
"description": "Guide Codex through Lian Jing project context and existing MCP workflows.", "description": "Guide Codex through Lian Jing project context and existing MCP workflows.",
"author": { "author": {
"name": "Lian Jing" "name": "Lian Jing"

View File

@ -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`。 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。 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)`;合并后再次读取交付状态。不得自行推断确认。 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` 修复。 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. 炼境提供状态、凭证托管和受控入口;项目 Agent 仍负责代码判断、测试选择、提交分组与业务交付决策。不要把 CI 成功或部署信号表述为业务验收。 7. 若缺少 canonical context、MCP 或项目登记,不得猜测 project_id、MCP URL、凭证或状态。说明缺失项,并建议通过炼境接入或 `inject_mcp` 修复。
8. 炼境提供状态、凭证托管和受控入口;项目 Agent 仍负责代码判断、测试选择、提交分组与业务交付决策。不要把 CI 成功或部署信号表述为业务验收。
本插件不包含 MCP server、MCP URL、项目 ID、凭证、部署命令或客户端项目 UI 映射;这些都属于项目接入与炼境控制面。 本插件不包含 MCP server、MCP URL、项目 ID、凭证、部署命令或客户端项目 UI 映射;这些都属于项目接入与炼境控制面。

View File

@ -15,5 +15,6 @@ description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管
4. 只有用户明确确认本次下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配客户端 bundled runtime 而修改项目版本或 lockfile。 4. 只有用户明确确认本次下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配客户端 bundled runtime 而修改项目版本或 lockfile。
5. 交付、Gitea、CI、蓝图等平台状态优先通过 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)`;合并后再次读取交付状态。不得自行推断确认。 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 的本地工作;不得伪造诊断结果。 如果 MCP 未出现,说明 Codex 项目配置尚未被加载、项目未被信任,或炼境未运行。说明该限制后继续完成不依赖 MCP 的本地工作;不得伪造诊断结果。

View File

@ -3,7 +3,7 @@
"packs": [ "packs": [
{ {
"pack_id": "base-onboarding", "pack_id": "base-onboarding",
"version": "1.3.0", "version": "1.4.0",
"supported_clients": ["codex", "claude"], "supported_clients": ["codex", "claude"],
"project_artifacts": [ "project_artifacts": [
"AGENTS.md", "AGENTS.md",

View File

@ -2,9 +2,10 @@
// 历史部署教程与踩坑指南见 docs/tauri-cicd-guide.md(原 GitHub Actions 方案) // 历史部署教程与踩坑指南见 docs/tauri-cicd-guide.md(原 GitHub Actions 方案)
use crate::commands::toolchain::parse_toolchain_contract; use crate::commands::toolchain::parse_toolchain_contract;
use crate::db; use crate::db;
use crate::models::{CicdConfig, WorkflowResult}; use crate::models::{CicdConfig, WorkflowApplyResult, WorkflowPlan, WorkflowResult};
use rusqlite::params; use rusqlite::{params, Connection};
use std::path::Path; use sha2::{Digest, Sha256};
use std::path::{Component, Path, PathBuf};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@ -118,9 +119,7 @@ impl WorkflowToolchain {
} }
} }
#[tauri::command] fn get_cicd_config_for(conn: &Connection, project_id: &str) -> Result<Option<CicdConfig>, String> {
pub fn get_cicd_config(project_id: String) -> Result<Option<CicdConfig>, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let result = conn.query_row( let result = conn.query_row(
"SELECT id, project_id, config_type, trigger_branch, build_command, node_version, "SELECT id, project_id, config_type, trigger_branch, build_command, node_version,
server_host, server_user, deploy_path, start_command, oss_endpoint, created_at 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<Option<CicdConfig>, String>
CicdConfig::from_row, CicdConfig::from_row,
); );
match result { match result {
Ok(cfg) => Ok(Some(cfg)), Ok(config) => Ok(Some(config)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), 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<CicdConfig, String> {
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<PathBuf, String> {
if project_id.trim().is_empty() {
return Err("project_id 不能为空".to_string());
}
let (win_path, wsl_path, platform): (Option<String>, Option<String>, Option<String>) = 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<PathBuf, String> {
let components = Path::new(filename).components().collect::<Vec<_>>();
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<String>, Option<WorkflowToolchain>), 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<WorkflowPlan, String> {
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<WorkflowApplyResult, String> {
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<WorkflowPlan, String> {
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<WorkflowApplyResult, String> {
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<Option<CicdConfig>, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
get_cicd_config_for(&conn, &project_id)
}
#[tauri::command] #[tauri::command]
pub fn save_cicd_config(input: CicdConfig) -> Result<CicdConfig, String> { pub fn save_cicd_config(input: CicdConfig) -> Result<CicdConfig, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?; 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())?; .map_err(|e| e.to_string())?;
let project_root = Path::new(&project_path); let project_root = Path::new(&project_path);
let (content, filename, secrets) = match config.config_type.as_str() { let workflow_type = match config.config_type.as_str() {
"tauri_oss" => { "tauri_oss" => "tauri_oss",
let toolchain = WorkflowToolchain::from_project(project_root, &config)?; "pr_ci" => "pr_ci",
build_tauri_oss_workflow(&config, &toolchain) "deepseek_review" => "deepseek_review",
} _ => "web_ssh",
"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 (content, filename, secrets, _) =
build_workflow_for_type(project_root, &config, workflow_type)?;
let workflows_dir = project_root.join(".gitea").join("workflows"); let workflows_dir = project_root.join(".gitea").join("workflows");
std::fs::create_dir_all(&workflows_dir).map_err(|e| e.to_string())?; 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, project_id: &str,
workflow_type: &str, workflow_type: &str,
) -> Result<String, String> { ) -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?; if !matches!(workflow_type, "pr_ci" | "deepseek_review") {
let (win_path, wsl_path, platform): (Option<String>, Option<String>, Option<String>) = 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!( return Err(format!(
"不支持的 workflow_type: {other}(可选: pr_ci / deepseek_review)" "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 plan = plan_cicd_workflow_for(&conn, project_id, workflow_type)?;
let workflows_dir = Path::new(&project_path).join(".gitea").join("workflows"); let digest = plan.content_digest.clone();
std::fs::create_dir_all(&workflows_dir).map_err(|e| format!("创建目录失败: {e}"))?; let result = apply_cicd_workflow_for(&conn, project_id, workflow_type, &digest, true)?;
let file_path = workflows_dir.join(&filename); let filename = Path::new(&result.plan.path)
std::fs::write(&file_path, &content).map_err(|e| format!("写入文件失败: {e}"))?; .file_name()
.and_then(|value| value.to_str())
let secrets_hint = if secrets.is_empty() { .unwrap_or("workflow.yml");
let secrets_hint = if result.plan.required_secrets.is_empty() {
String::new() String::new()
} else { } else {
format!(",需要配置 secrets: {}", secrets.join(" / ")) format!(
",需要配置 secrets: {}",
result.plan.required_secrets.join(" / ")
)
}; };
Ok(format!( Ok(format!(
@ -628,6 +855,44 @@ mod tests {
use super::*; use super::*;
use std::path::PathBuf; 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 { fn cfg(config_type: &str) -> CicdConfig {
CicdConfig { CicdConfig {
id: "t".into(), id: "t".into(),
@ -801,4 +1066,108 @@ mod tests {
assert!(error.contains(".node-version=22.14.0")); assert!(error.contains(".node-version=22.14.0"));
std::fs::remove_dir_all(root).unwrap(); 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();
}
} }

View File

@ -26,7 +26,7 @@ const TMPL_CAPABILITY_PACKS: &str =
const TMPL_MISE: &str = include_str!("../../resources/onboarding/mise.toml.tmpl"); const TMPL_MISE: &str = include_str!("../../resources/onboarding/mise.toml.tmpl");
const PROJECT_CONTEXT_SCHEMA_VERSION: u64 = 1; const PROJECT_CONTEXT_SCHEMA_VERSION: u64 = 1;
const CAPABILITY_PACK_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"; const TOOLCHAIN_PACK_VERSION: &str = "1.0.0";
/// 单个模板的分发结果。 /// 单个模板的分发结果。
@ -843,7 +843,7 @@ pub fn verify_onboarding(
if yml_count > 0 { if yml_count > 0 {
format!(".gitea/workflows/ 有 {yml_count} 个 workflow") format!(".gitea/workflows/ 有 {yml_count} 个 workflow")
} else { } else {
"无 .gitea/workflows/*.yml——可调 distribute_cicd 分发".into() "无 .gitea/workflows/*.yml——先调 plan_cicd_workflow 预览,用户确认摘要后再调 apply_cicd_workflow 写入".into()
}, },
)); ));

View File

@ -293,6 +293,8 @@ pub fn run() {
get_cicd_config, get_cicd_config,
save_cicd_config, save_cicd_config,
generate_workflow, generate_workflow,
plan_cicd_workflow,
apply_cicd_workflow,
// blueprint // blueprint
get_blueprint, get_blueprint,
generate_blueprint_prompt, generate_blueprint_prompt,

View File

@ -257,9 +257,35 @@ pub fn tools_list_result() -> Value {
"required": ["project_id"] "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", "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": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "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(""); let wt = args.get("workflow_type").and_then(|v| v.as_str()).unwrap_or("");
crate::commands::cicd::distribute_workflow_for_project(pid, wt) 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(), "list_all_projects" => list_all_projects(),
"scan_unregistered_projects" => scan_unregistered_projects(), "scan_unregistered_projects" => scan_unregistered_projects(),
"unregister_project" => { "unregister_project" => {
@ -1658,6 +1740,14 @@ mod tests {
"缺少 unregister_project" "缺少 unregister_project"
); );
assert!(names.contains(&"setup_gitea_repo"), "缺少 setup_gitea_repo"); 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 // 每个工具都有 description 和 inputSchema
for tool in tools { for tool in tools {
let name = tool["name"].as_str().unwrap_or("?"); let name = tool["name"].as_str().unwrap_or("?");
@ -1738,6 +1828,70 @@ mod tests {
.contains("project_id 不能为空")); .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] #[test]
fn prepare_toolchain_schema_requires_confirmation() { fn prepare_toolchain_schema_requires_confirmation() {
let result = tools_list_result(); let result = tools_list_result();

View File

@ -209,6 +209,25 @@ pub struct WorkflowResult {
pub secrets: Vec<String>, pub secrets: Vec<String>,
} }
#[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<String>,
pub node_source: Option<String>,
pub pnpm_source: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct WorkflowApplyResult {
pub plan: WorkflowPlan,
pub written: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GitRepo { pub struct GitRepo {
pub id: String, pub id: String,

View File

@ -763,6 +763,25 @@ export interface WorkflowResult {
secrets: string[]; 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) => export const getCicdConfig = (projectId: string) =>
invoke<CicdConfig | null>("get_cicd_config", { projectId }); invoke<CicdConfig | null>("get_cicd_config", { projectId });
@ -773,6 +792,24 @@ export const saveCicdConfig = (input: CicdConfig) =>
export const generateWorkflow = (projectId: string, projectPath: string) => export const generateWorkflow = (projectId: string, projectPath: string) =>
invoke<WorkflowResult>("generate_workflow", { projectId, projectPath }); invoke<WorkflowResult>("generate_workflow", { projectId, projectPath });
/** 只读预览:不写文件、不触发 CI 或部署。 */
export const planCicdWorkflow = (projectId: string, workflowType: CicdWorkflowType) =>
invoke<WorkflowPlan>("plan_cicd_workflow", { projectId, workflowType });
/** 写入预览对应的 workflow;调用方必须传入用户确认过的摘要。 */
export const applyCicdWorkflow = (
projectId: string,
workflowType: CicdWorkflowType,
expectedDigest: string,
confirmed: boolean,
) =>
invoke<WorkflowApplyResult>("apply_cicd_workflow", {
projectId,
workflowType,
expectedDigest,
confirmed,
});
/** 根据节点数据构造 share link(前端完成,避免 Rust 侧 URL 编码依赖) */ /** 根据节点数据构造 share link(前端完成,避免 Rust 侧 URL 编码依赖) */
export function buildShareLink(node: V2raynNode): string { export function buildShareLink(node: V2raynNode): string {
const remarks = encodeURIComponent(node.remarks); const remarks = encodeURIComponent(node.remarks);