diff --git a/src-tauri/src/commands/dev_variables.rs b/src-tauri/src/commands/dev_variables.rs new file mode 100644 index 0000000..1353d3d --- /dev/null +++ b/src-tauri/src/commands/dev_variables.rs @@ -0,0 +1,498 @@ +//! 项目级开发变量本体。SQLite 只保存元数据和随机引用;值仅驻留受保护存储。 +use rusqlite::{params, Connection}; +use serde::Serialize; + +use crate::credential_store::{ + delete_references, put_verified, restore_references, CredentialStore, +}; + +#[derive(Debug, Clone, Serialize)] +pub struct DevVariableStatus { + pub project_id: String, + pub name: String, + pub purpose: String, + pub configured: bool, + pub updated_at: String, +} + +fn normalized_name(name: &str) -> Result { + let name = name.trim().to_ascii_uppercase(); + if name.is_empty() + || name.len() > 64 + || !name.bytes().next().unwrap().is_ascii_alphabetic() + || !name + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err("开发变量名须为 1–64 位 ASCII 字母、数字或下划线,且以字母开头".into()); + } + Ok(name) +} + +fn require_project(conn: &Connection, project_id: &str) -> Result<(), String> { + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM project_workspaces WHERE id=?1)", + [project_id], + |row| row.get(0), + ) + .map_err(|_| "项目状态不可用".to_string())?; + if !exists { + return Err("项目未登记".into()); + } + Ok(()) +} + +fn reference(conn: &Connection, project_id: &str, name: &str) -> Result, String> { + conn.query_row( + "SELECT credential_ref FROM project_dev_variables WHERE project_id=?1 AND name=?2", + params![project_id, name], + |row| row.get(0), + ) + .map_err(|_| "开发变量未登记".to_string()) +} + +pub fn register( + conn: &Connection, + project_id: &str, + name: &str, + purpose: &str, +) -> Result<(), String> { + require_project(conn, project_id)?; + let name = normalized_name(name)?; + let purpose = purpose.trim(); + if purpose.is_empty() || purpose.chars().count() > 240 || purpose.chars().any(char::is_control) + { + return Err("用途说明须为 1–240 字且不含控制字符".into()); + } + conn.execute( + "INSERT OR IGNORE INTO project_dev_variables (project_id,name,purpose) VALUES (?1,?2,?3)", + params![project_id, name, purpose], + ) + .map_err(|_| "登记开发变量失败".to_string())?; + Ok(()) +} + +pub fn list( + conn: &Connection, + store: &impl CredentialStore, + project_id: &str, +) -> Result, String> { + require_project(conn, project_id)?; + let mut statement = conn + .prepare( + "SELECT name,purpose,credential_ref,updated_at FROM project_dev_variables + WHERE project_id=?1 ORDER BY name", + ) + .map_err(|_| "查询开发变量失败".to_string())?; + let rows = statement + .query_map([project_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, String>(3)?, + )) + }) + .map_err(|_| "查询开发变量失败".to_string())?; + let mut statuses = Vec::new(); + for row in rows { + let (name, purpose, reference, updated_at) = + row.map_err(|_| "查询开发变量失败".to_string())?; + let configured = match reference { + Some(reference) => { + store + .get(&reference) + .map_err(|_| "开发变量状态 needs_repair:受保护存储不可用".to_string())? + .ok_or_else(|| "开发变量状态 needs_repair:凭证引用缺失".to_string())? + .len() + > 0 + } + None => false, + }; + statuses.push(DevVariableStatus { + project_id: project_id.to_string(), + name, + purpose, + configured, + updated_at, + }); + } + Ok(statuses) +} + +pub fn read( + conn: &Connection, + store: &impl CredentialStore, + project_id: &str, + name: &str, +) -> Result { + require_project(conn, project_id)?; + let name = normalized_name(name)?; + let reference = + reference(conn, project_id, &name)?.ok_or_else(|| "开发变量尚未由用户填写".to_string())?; + let bytes = store + .get(&reference) + .map_err(|_| "开发变量状态 needs_repair:受保护存储不可用".to_string())? + .ok_or_else(|| "开发变量状态 needs_repair:凭证引用缺失".to_string())?; + String::from_utf8(bytes).map_err(|_| "开发变量状态 needs_repair:值编码无效".to_string()) +} + +pub fn save( + conn: &Connection, + store: &impl CredentialStore, + project_id: &str, + name: &str, + value: &str, +) -> Result<(), String> { + require_project(conn, project_id)?; + let name = normalized_name(name)?; + if value.is_empty() || value.len() > 2560 { + return Err("开发变量值长度须为 1–2560 字节".into()); + } + let old_reference = reference(conn, project_id, &name)?; + let new_reference = format!("dev-variable/{}", uuid::Uuid::new_v4()); + put_verified(store, &new_reference, value.as_bytes())?; + let result = (|| { + let tx = conn + .unchecked_transaction() + .map_err(|_| "保存开发变量失败".to_string())?; + let changed = tx + .execute( + "UPDATE project_dev_variables SET credential_ref=?1, updated_at=datetime('now') + WHERE project_id=?2 AND name=?3 AND credential_ref IS ?4", + params![new_reference, project_id, name, old_reference], + ) + .map_err(|_| "保存开发变量失败".to_string())?; + if changed != 1 { + return Err("开发变量状态已变化,请重试".into()); + } + let old_refs: Vec = old_reference.clone().into_iter().collect(); + let snapshots = delete_references(store, &old_refs)?; + if tx.commit().is_err() { + restore_references(store, &snapshots)?; + return Err("保存开发变量失败:数据库提交失败".into()); + } + Ok(()) + })(); + if result.is_err() && store.delete(&new_reference).is_err() { + return Err("开发变量状态 needs_repair:新凭证清理失败".into()); + } + result +} + +pub fn revoke( + conn: &Connection, + store: &impl CredentialStore, + project_id: &str, + name: &str, +) -> Result<(), String> { + require_project(conn, project_id)?; + let name = normalized_name(name)?; + let old_reference = reference(conn, project_id, &name)?; + let tx = conn + .unchecked_transaction() + .map_err(|_| "撤销开发变量失败".to_string())?; + let changed = tx + .execute( + "UPDATE project_dev_variables SET credential_ref=NULL, updated_at=datetime('now') + WHERE project_id=?1 AND name=?2 AND credential_ref IS ?3", + params![project_id, name, old_reference], + ) + .map_err(|_| "撤销开发变量失败".to_string())?; + if changed != 1 { + return Err("开发变量状态已变化,请重试".into()); + } + let old_refs: Vec = old_reference.into_iter().collect(); + let snapshots = delete_references(store, &old_refs)?; + if tx.commit().is_err() { + restore_references(store, &snapshots)?; + return Err("撤销开发变量失败:数据库提交失败".into()); + } + Ok(()) +} + +/// 项目注销前清理所有开发变量引用;删除失败则保留数据库项目记录以便重试。 +pub fn purge_project( + conn: &Connection, + store: &impl CredentialStore, + project_id: &str, +) -> Result<(), String> { + let mut statement = conn + .prepare("SELECT credential_ref FROM project_dev_variables WHERE project_id=?1 AND credential_ref IS NOT NULL") + .map_err(|_| "清理项目开发变量失败".to_string())?; + let references: Vec = statement + .query_map([project_id], |row| row.get(0)) + .map_err(|_| "清理项目开发变量失败".to_string())? + .collect::>() + .map_err(|_| "清理项目开发变量失败".to_string())?; + drop(statement); + let tx = conn + .unchecked_transaction() + .map_err(|_| "清理项目开发变量失败".to_string())?; + tx.execute( + "DELETE FROM project_dev_variables WHERE project_id=?1", + [project_id], + ) + .map_err(|_| "清理项目开发变量失败".to_string())?; + let snapshots = delete_references(store, &references)?; + if tx.commit().is_err() { + restore_references(store, &snapshots)?; + return Err("清理项目开发变量失败:数据库提交失败".into()); + } + Ok(()) +} + +#[tauri::command] +pub fn list_project_dev_variables(project_id: String) -> Result, String> { + let conn = crate::db::pool() + .get() + .map_err(|_| "数据库不可用".to_string())?; + list( + &conn, + &crate::credential_store::WindowsCredentialStore, + &project_id, + ) +} + +#[tauri::command] +pub fn save_project_dev_variable( + project_id: String, + name: String, + value: String, +) -> Result<(), String> { + let conn = crate::db::pool() + .get() + .map_err(|_| "数据库不可用".to_string())?; + save( + &conn, + &crate::credential_store::WindowsCredentialStore, + &project_id, + &name, + &value, + ) +} + +#[tauri::command] +pub fn revoke_project_dev_variable(project_id: String, name: String) -> Result<(), String> { + let conn = crate::db::pool() + .get() + .map_err(|_| "数据库不可用".to_string())?; + revoke( + &conn, + &crate::credential_store::WindowsCredentialStore, + &project_id, + &name, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::credential_store::{CredentialStore, MemoryCredentialStore}; + + fn fixture() -> Connection { + let conn = crate::db::conn_with_schema(); + conn.execute( + "INSERT INTO project_profiles (id,name) VALUES ('profile-1','QA')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id,profile_id) VALUES ('p1','profile-1')", + [], + ) + .unwrap(); + conn + } + + #[test] + fn register_save_overwrite_rebuild_read_and_revoke_without_list_leak() { + let conn = fixture(); + let store = MemoryCredentialStore::new(); + register(&conn, "p1", "e2e_password", "仅用于合成验收").unwrap(); + assert!(!list(&conn, &store, "p1").unwrap()[0].configured); + assert!(read(&conn, &store, "p1", "E2E_PASSWORD").is_err()); + save(&conn, &store, "p1", "E2E_PASSWORD", "qa-only-old-value").unwrap(); + let old_reference: String = conn + .query_row( + "SELECT credential_ref FROM project_dev_variables WHERE project_id='p1'", + [], + |row| row.get(0), + ) + .unwrap(); + save(&conn, &store, "p1", "E2E_PASSWORD", "qa-only-new-value").unwrap(); + assert!(store.get(&old_reference).unwrap().is_none()); + assert_eq!( + read(&conn, &store, "p1", "e2e_password").unwrap(), + "qa-only-new-value" + ); + let serialized = serde_json::to_string(&list(&conn, &store, "p1").unwrap()).unwrap(); + assert!(serialized.contains("\"configured\":true")); + assert!(!serialized.contains("qa-only-old-value")); + assert!(!serialized.contains("qa-only-new-value")); + revoke(&conn, &store, "p1", "E2E_PASSWORD").unwrap(); + assert!(!list(&conn, &store, "p1").unwrap()[0].configured); + assert!(read(&conn, &store, "p1", "E2E_PASSWORD").is_err()); + } + + #[test] + fn failed_write_or_delete_preserves_old_value_and_reference() { + let conn = fixture(); + register(&conn, "p1", "E2E_PASSWORD", "合成验收").unwrap(); + let failing_write = MemoryCredentialStore::failing(); + assert!(save( + &conn, + &failing_write, + "p1", + "E2E_PASSWORD", + "qa-only-new-value" + ) + .is_err()); + assert!(!list(&conn, &MemoryCredentialStore::new(), "p1").unwrap()[0].configured); + + let old_ref = "dev-variable/synthetic-old"; + let failing_delete = MemoryCredentialStore::failing_delete_for(old_ref); + failing_delete.put(old_ref, b"qa-only-old-value").unwrap(); + conn.execute( + "UPDATE project_dev_variables SET credential_ref=?1 WHERE project_id='p1' AND name='E2E_PASSWORD'", + [old_ref], + ) + .unwrap(); + assert!(save( + &conn, + &failing_delete, + "p1", + "E2E_PASSWORD", + "qa-only-new-value" + ) + .is_err()); + assert_eq!( + read(&conn, &failing_delete, "p1", "E2E_PASSWORD").unwrap(), + "qa-only-old-value" + ); + assert!(revoke(&conn, &failing_delete, "p1", "E2E_PASSWORD").is_err()); + assert_eq!( + read(&conn, &failing_delete, "p1", "E2E_PASSWORD").unwrap(), + "qa-only-old-value" + ); + let raw_ref: String = conn + .query_row( + "SELECT credential_ref FROM project_dev_variables WHERE project_id='p1'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(raw_ref, old_ref); + } + + #[test] + fn rejects_unknown_project_and_malformed_name() { + let conn = fixture(); + assert!(register(&conn, "missing", "E2E_PASSWORD", "用途").is_err()); + assert!(register(&conn, "p1", "../SECRET", "用途").is_err()); + assert!(register(&conn, "p1", "E2E_PASSWORD", "").is_err()); + } + + #[test] + fn project_purge_deletes_values_and_refuses_failed_store_delete() { + let conn = fixture(); + let store = MemoryCredentialStore::new(); + register(&conn, "p1", "E2E_PASSWORD", "合成验收").unwrap(); + save(&conn, &store, "p1", "E2E_PASSWORD", "qa-only-purge-value").unwrap(); + purge_project(&conn, &store, "p1").unwrap(); + assert!(list(&conn, &store, "p1").unwrap().is_empty()); + assert!(!store.contains_value(b"qa-only-purge-value")); + + register(&conn, "p1", "E2E_PASSWORD", "合成验收").unwrap(); + let reference = "dev-variable/qa-only-failed-delete"; + let failing = MemoryCredentialStore::failing_delete_for(reference); + failing.put(reference, b"qa-only-keep-value").unwrap(); + conn.execute( + "UPDATE project_dev_variables SET credential_ref=?1 WHERE project_id='p1'", + [reference], + ) + .unwrap(); + assert!(purge_project(&conn, &failing, "p1").is_err()); + assert_eq!( + read(&conn, &failing, "p1", "E2E_PASSWORD").unwrap(), + "qa-only-keep-value" + ); + } + + #[test] + fn metadata_and_protected_value_survive_database_reopen() { + let path = + std::env::temp_dir().join(format!("lianjing-dev-variable-{}.db", uuid::Uuid::new_v4())); + let store = MemoryCredentialStore::new(); + { + let conn = Connection::open(&path).unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + crate::db::migrate(&conn); + conn.execute( + "INSERT INTO project_profiles (id,name) VALUES ('profile-1','QA')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id,profile_id) VALUES ('p1','profile-1')", + [], + ) + .unwrap(); + register(&conn, "p1", "E2E_PASSWORD", "合成验收").unwrap(); + save(&conn, &store, "p1", "E2E_PASSWORD", "qa-only-reopen-value").unwrap(); + } + { + let conn = Connection::open(&path).unwrap(); + crate::db::migrate(&conn); + assert_eq!( + read(&conn, &store, "p1", "E2E_PASSWORD").unwrap(), + "qa-only-reopen-value" + ); + revoke(&conn, &store, "p1", "E2E_PASSWORD").unwrap(); + assert!(read(&conn, &store, "p1", "E2E_PASSWORD").is_err()); + } + std::fs::remove_file(path).unwrap(); + } + + #[cfg(windows)] + #[test] + fn tauri_commands_use_same_state_and_never_return_value_in_list() { + crate::db::init_test_pool(); + let project_id = format!("qa-only-dev-var-{}", uuid::Uuid::new_v4()); + let profile_id = format!("qa-only-profile-{}", uuid::Uuid::new_v4()); + { + let conn = crate::db::pool().get().unwrap(); + conn.execute( + "INSERT INTO project_profiles (id,name) VALUES (?1,'QA')", + [&profile_id], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id,profile_id) VALUES (?1,?2)", + params![project_id, profile_id], + ) + .unwrap(); + register(&conn, &project_id, "E2E_PASSWORD", "合成验收").unwrap(); + } + assert!(!list_project_dev_variables(project_id.clone()).unwrap()[0].configured); + save_project_dev_variable( + project_id.clone(), + "E2E_PASSWORD".into(), + "qa-only-command-value".into(), + ) + .unwrap(); + let statuses = list_project_dev_variables(project_id.clone()).unwrap(); + assert!(statuses[0].configured); + assert!(!serde_json::to_string(&statuses) + .unwrap() + .contains("qa-only-command-value")); + revoke_project_dev_variable(project_id.clone(), "E2E_PASSWORD".into()).unwrap(); + assert!(!list_project_dev_variables(project_id.clone()).unwrap()[0].configured); + let conn = crate::db::pool().get().unwrap(); + conn.execute("DELETE FROM project_workspaces WHERE id=?1", [&project_id]) + .unwrap(); + conn.execute("DELETE FROM project_profiles WHERE id=?1", [&profile_id]) + .unwrap(); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1810507..cdf11b5 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ pub mod cloud_db_databases; pub mod cloud_products; pub mod commit_metrics; pub mod delivery_status; +pub mod dev_variables; pub mod devtools; pub mod docker_apps; pub mod git_ops; diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs index 99ce7c6..8764da2 100644 --- a/src-tauri/src/commands/projects.rs +++ b/src-tauri/src/commands/projects.rs @@ -325,6 +325,12 @@ pub fn delete_project(id: String, delete_local: Option) -> Result<(), Stri ) .unwrap_or((None, None, None)); + crate::commands::dev_variables::purge_project( + &conn, + &crate::credential_store::WindowsCredentialStore, + &id, + )?; + // 清理炼境 MCP 配置(在删除 project_workspaces 记录之前,需查项目路径) if let Err(e) = mcp_inject::remove_project_mcp(&id) { eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e); diff --git a/src-tauri/src/credential_store.rs b/src-tauri/src/credential_store.rs index a3963e4..a46e428 100644 --- a/src-tauri/src/credential_store.rs +++ b/src-tauri/src/credential_store.rs @@ -54,6 +54,75 @@ pub trait CredentialStore { fn delete(&self, reference: &str) -> Result<(), String>; } +/// 项目认证材料只用于 MCP 客户端的本机 HTTP 认证;digest 可写入项目配置,token 不可。 +pub fn mcp_auth_digest(project_id: &str) -> Result { + if project_id.trim().is_empty() { + return Err("项目 ID 不能为空".into()); + } + use sha2::Digest; + let digest = sha2::Sha256::digest(project_id.as_bytes()); + Ok(digest.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +fn mcp_auth_reference_for_digest(digest: &str) -> Result { + if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("MCP 认证引用格式无效".into()); + } + Ok(format!("mcp-client-auth/{digest}")) +} + +fn decode_mcp_auth_token(bytes: Vec) -> Result { + let token = String::from_utf8(bytes).map_err(|_| "MCP 认证材料 needs_repair".to_string())?; + if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("MCP 认证材料 needs_repair".into()); + } + Ok(token) +} + +pub fn get_or_create_mcp_auth_token( + store: &impl CredentialStore, + project_id: &str, +) -> Result { + let digest = mcp_auth_digest(project_id)?; + let reference = mcp_auth_reference_for_digest(&digest)?; + if let Some(bytes) = store + .get(&reference) + .map_err(|_| "MCP 认证材料 needs_repair:受保护存储不可用".to_string())? + { + return decode_mcp_auth_token(bytes); + } + let token = format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ); + put_verified(store, &reference, token.as_bytes())?; + Ok(token) +} + +pub fn read_mcp_auth_token_for_digest( + store: &impl CredentialStore, + digest: &str, +) -> Result { + let reference = mcp_auth_reference_for_digest(digest)?; + let bytes = store + .get(&reference) + .map_err(|_| "MCP 认证材料 needs_repair:受保护存储不可用".to_string())? + .ok_or_else(|| "MCP 认证材料未配置".to_string())?; + decode_mcp_auth_token(bytes) +} + +pub fn delete_mcp_auth_token_for_project( + store: &impl CredentialStore, + project_id: &str, +) -> Result<(), String> { + let digest = mcp_auth_digest(project_id)?; + let reference = mcp_auth_reference_for_digest(&digest)?; + store + .delete(&reference) + .map_err(|_| "MCP 认证材料 needs_repair:删除失败".to_string()) +} + #[derive(Debug, Clone, Copy)] pub enum LegacyCredentialKind { GiteaAccessToken, @@ -486,7 +555,9 @@ impl CredentialStore for MemoryCredentialStore { #[cfg(test)] mod tests { use super::{ - get_or_migrate_legacy, CredentialStore, LegacyCredentialKind, MemoryCredentialStore, + get_or_create_mcp_auth_token, get_or_migrate_legacy, mcp_auth_digest, + read_mcp_auth_token_for_digest, CredentialStore, LegacyCredentialKind, + MemoryCredentialStore, }; use rusqlite::Connection; @@ -523,6 +594,25 @@ mod tests { assert!(!error.contains("do-not-print-this")); } + #[test] + fn mcp_auth_token_is_stable_per_project_and_never_in_reference() { + let store = MemoryCredentialStore::new(); + let p1 = get_or_create_mcp_auth_token(&store, "p1").unwrap(); + let p2 = get_or_create_mcp_auth_token(&store, "p2").unwrap(); + let digest = mcp_auth_digest("p1").unwrap(); + assert_eq!(p1.len(), 64); + assert_ne!(p1, p2); + assert_eq!(get_or_create_mcp_auth_token(&store, "p1").unwrap(), p1); + assert_eq!(read_mcp_auth_token_for_digest(&store, &digest).unwrap(), p1); + assert!(!digest.contains(&p1)); + assert!(read_mcp_auth_token_for_digest(&store, "invalid").is_err()); + assert!( + get_or_create_mcp_auth_token(&MemoryCredentialStore::failing(), "p1") + .unwrap_err() + .contains("受保护存储") + ); + } + #[test] fn legacy_migration_verifies_then_switches_reference_and_clears_old_value() { let conn = migration_db(); @@ -690,8 +780,20 @@ mod tests { #[cfg(all(test, windows))] mod windows_tests { - use super::{windows_store, CredentialStore, WindowsCredentialStore}; - use std::process::Command; + use super::{ + get_or_create_mcp_auth_token, mcp_auth_digest, mcp_auth_reference_for_digest, + windows_store, CredentialStore, WindowsCredentialStore, + }; + use std::{ + io::{Read, Write}, + net::TcpListener, + process::Command, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, + }, + time::{Duration, Instant}, + }; const CHILD_ENV: &str = "DEV_MANAGER_CREDENTIAL_STORE_CHILD_TEST"; @@ -722,6 +824,115 @@ mod windows_tests { ); } + #[test] + #[ignore = "先运行 cargo build 构建同分支的真实可执行文件"] + fn mcp_auth_header_helper_reads_windows_store_and_fails_closed() { + let project_id = format!("qa-only-mcp-auth-{}", uuid::Uuid::new_v4()); + let digest = mcp_auth_digest(&project_id).unwrap(); + let store = WindowsCredentialStore; + let token = get_or_create_mcp_auth_token(&store, &project_id).unwrap(); + let executable = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/debug/dev-manager-tauri.exe"); + let configured = Command::new(&executable) + .args(["--lianjing-mcp-header", &digest]) + .output(); + let missing_digest = mcp_auth_digest("qa-only-missing-project").unwrap(); + let missing = Command::new(&executable) + .args(["--lianjing-mcp-header", &missing_digest]) + .output(); + let cleanup = store.delete(&mcp_auth_reference_for_digest(&digest).unwrap()); + assert!(cleanup.is_ok(), "测试认证材料清理失败"); + let configured = configured.expect("无法启动真实 helper 可执行文件"); + assert!(configured.status.success()); + let headers: serde_json::Value = serde_json::from_slice(&configured.stdout).unwrap(); + let expected = format!("Bearer {token}"); + assert!(headers["Authorization"].as_str() == Some(expected.as_str())); + let missing = missing.expect("无法启动真实 helper 可执行文件"); + assert!(!missing.status.success()); + assert!(missing.stdout.is_empty()); + } + + #[test] + #[ignore = "需已登录 Codex CLI,先 cargo build;只使用随机合成认证材料"] + fn codex_client_reads_windows_helper_header_across_sessions() { + let project_id = format!("qa-only-codex-auth-{}", uuid::Uuid::new_v4()); + let digest = mcp_auth_digest(&project_id).unwrap(); + let reference = mcp_auth_reference_for_digest(&digest).unwrap(); + let store = WindowsCredentialStore; + let token = get_or_create_mcp_auth_token(&store, &project_id).unwrap(); + let executable = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target/debug/dev-manager-tauri.exe"); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = listener.local_addr().unwrap().port(); + let observed = Arc::new(AtomicUsize::new(0)); + let stop = Arc::new(AtomicBool::new(false)); + let observed_for_server = Arc::clone(&observed); + let stop_for_server = Arc::clone(&stop); + let server = std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(120); + while !stop_for_server.load(Ordering::SeqCst) && Instant::now() < deadline { + match listener.accept() { + Ok((mut stream, _)) => { + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut bytes = [0u8; 8192]; + let count = stream.read(&mut bytes).unwrap_or(0); + let request = String::from_utf8_lossy(&bytes[..count]); + let expected = format!("authorization: bearer {token}"); + if request.to_ascii_lowercase().contains(&expected) { + observed_for_server.fetch_add(1, Ordering::SeqCst); + } + let _ = stream.write_all(b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(20)); + } + Err(_) => break, + } + } + }); + let helper = format!( + "\"{}\" --lianjing-mcp-header {digest}", + executable.display() + ); + let url_config = format!("mcp_servers.qa_only_auth.url=\"http://127.0.0.1:{port}/mcp\""); + let helper_config = format!( + "mcp_servers.qa_only_auth.http_headers_helper={}", + serde_json::to_string(&helper).unwrap() + ); + let mut client_statuses = Vec::new(); + for _ in 0..2 { + let status = Command::new("codex") + .args([ + "exec", + "--skip-git-repo-check", + "--sandbox", + "read-only", + "-c", + &url_config, + "-c", + &helper_config, + "Reply OK.", + ]) + .output(); + client_statuses.push(status.map(|result| result.status)); + } + stop.store(true, Ordering::SeqCst); + server.join().unwrap(); + let cleanup = store.delete(&reference); + assert!(cleanup.is_ok(), "测试认证材料清理失败"); + assert!( + client_statuses.iter().all(Result::is_ok), + "Codex CLI 未能启动" + ); + assert!( + observed.load(Ordering::SeqCst) >= 2, + "两次 Codex 会话未分别通过真实 helper 发送认证头" + ); + } + #[test] #[ignore] fn credential_child_probe() { diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 1a16e16..a8e7927 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -32,7 +32,7 @@ pub(crate) fn try_pool() -> Option<&'static DbPool> { POOL.get() } -fn migrate(conn: &rusqlite::Connection) { +pub(crate) fn migrate(conn: &rusqlite::Connection) { conn.execute_batch( " CREATE TABLE IF NOT EXISTS projects ( @@ -1057,6 +1057,18 @@ fn migrate(conn: &rusqlite::Connection) { "ALTER TABLE gitea_repos ADD COLUMN webhook_secret_ref TEXT", [], ); + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS project_dev_variables ( + project_id TEXT NOT NULL REFERENCES project_workspaces(id) ON DELETE CASCADE, + name TEXT NOT NULL, + purpose TEXT NOT NULL, + credential_ref TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (project_id, name) + );", + ) + .expect("migrate project dev variables"); } #[cfg(test)] @@ -1144,6 +1156,23 @@ mod tests { migrate(&conn); } + #[test] + fn dev_variable_migration_adds_metadata_table_to_existing_database() { + let conn = conn_with_schema(); + conn.execute("DROP TABLE project_dev_variables", []) + .unwrap(); + migrate(&conn); + let columns: Vec = conn + .prepare("PRAGMA table_info(project_dev_variables)") + .unwrap() + .query_map([], |row| row.get(1)) + .unwrap() + .map(Result::unwrap) + .collect(); + assert!(columns.contains(&"credential_ref".to_string())); + assert!(!columns.contains(&"value".to_string())); + } + #[test] fn sync_state_touch_then_read() { let conn = conn_with_schema(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 64b7750..38e764d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,16 @@ mod mcp_webhook; mod models; use git2; +/// Codex 的 http_headers_helper 入口;只被本机同一可执行文件的专用参数调用。 +pub fn mcp_client_auth_headers_json(project_digest: &str) -> Result { + let token = credential_store::read_mcp_auth_token_for_digest( + &credential_store::WindowsCredentialStore, + project_digest, + )?; + serde_json::to_string(&serde_json::json!({ "Authorization": format!("Bearer {token}") })) + .map_err(|_| "MCP 认证头生成失败".to_string()) +} + /// 创建不弹 CMD 窗口的子进程(Windows 专用 CREATE_NO_WINDOW 标志)。 /// 所有后台静默命令都应通过此函数创建,避免在 Windows 上频繁闪出黑框。 pub(crate) fn silent_cmd(program: &str) -> std::process::Command { @@ -392,6 +402,10 @@ pub fn run() { commands::api_keys::edit_api_key, commands::api_keys::delete_api_key, commands::api_keys::reveal_api_key, + // project development variables (human filling; no reveal command) + commands::dev_variables::list_project_dev_variables, + commands::dev_variables::save_project_dev_variable, + commands::dev_variables::revoke_project_dev_variable, // host apps commands::host_apps::get_host_apps, commands::host_apps::add_host_app, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 47cb2f8..5f7e72c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,22 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + let mut args = std::env::args(); + let _ = args.next(); + if args.next().as_deref() == Some("--lianjing-mcp-header") { + let Some(digest) = args.next() else { + std::process::exit(1); + }; + if args.next().is_some() { + std::process::exit(1); + } + match dev_manager_tauri_lib::mcp_client_auth_headers_json(&digest) { + Ok(headers) => { + println!("{headers}"); + return; + } + Err(_) => std::process::exit(1), + } + } dev_manager_tauri_lib::run() } diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index ca37f0f..433563b 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -375,9 +375,15 @@ fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> { std::fs::write(path, content).map_err(|e| format!("写入 settings.json 失败: {}", e)) } -fn upsert_codex_mcp_config(path: &PathBuf, server_url: &str) -> Result<(), String> { +fn upsert_codex_mcp_config( + path: &PathBuf, + server_url: &str, + header_helper: &str, +) -> Result<(), String> { + let helper_literal = serde_json::to_string(header_helper) + .map_err(|_| "MCP helper 配置序列化失败".to_string())?; let block = format!( - "{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{server_url}\"\n{CODEX_MCP_MANAGED_END}\n" + "{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{server_url}\"\nhttp_headers_helper = {helper_literal}\n{CODEX_MCP_MANAGED_END}\n" ); let current = match std::fs::read_to_string(path) { Ok(value) => value, @@ -460,8 +466,19 @@ fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> { pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { let port = crate::mcp_server::read_port(); let server_url = crate::mcp_server::scoped_mcp_url(port, project_id); + let digest = crate::credential_store::mcp_auth_digest(project_id)?; + crate::credential_store::get_or_create_mcp_auth_token( + &crate::credential_store::WindowsCredentialStore, + project_id, + )?; + let executable = std::env::current_exe() + .map_err(|_| "无法定位炼境可执行文件,拒绝写入 MCP 认证配置".to_string())?; + let header_helper = format!( + "\"{}\" --lianjing-mcp-header {digest}", + executable.display() + ); write_canonical_context(project_id)?; - upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url)?; + upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url, &header_helper)?; let path = settings_path(project_id)?; let mut settings = read_json(&path)?; @@ -501,20 +518,21 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { pub fn remove_project_mcp(project_id: &str) -> Result<(), String> { remove_codex_mcp_config(&codex_config_path(project_id)?)?; let path = settings_path(project_id)?; - if !path.exists() { - return Ok(()); + if path.exists() { + let mut settings = read_json(&path)?; + if let Some(servers) = settings + .get_mut("mcpServers") + .and_then(|v| v.as_object_mut()) + { + servers.remove(MCP_KEY); + servers.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX)); + } + write_json(&path, &settings)?; } - let mut settings = read_json(&path)?; - - if let Some(servers) = settings - .get_mut("mcpServers") - .and_then(|v| v.as_object_mut()) - { - servers.remove(MCP_KEY); - servers.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX)); - } - - write_json(&path, &settings) + crate::credential_store::delete_mcp_auth_token_for_project( + &crate::credential_store::WindowsCredentialStore, + project_id, + ) } /// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 key + 清旧 key + liangjing.json 瘦身) @@ -627,14 +645,21 @@ mod tests { let path = base.join(".codex/config.toml"); std::fs::write(&path, "model = \"gpt-6\"\n").unwrap(); - upsert_codex_mcp_config(&path, "http://127.0.0.1:27190/mcp").unwrap(); + let helper = "\"C:/Lianjing.exe\" --lianjing-mcp-header qa-only-digest"; + upsert_codex_mcp_config(&path, "http://127.0.0.1:27190/mcp", helper).unwrap(); let configured = std::fs::read_to_string(&path).unwrap(); assert!(configured.contains("model = \"gpt-6\"")); assert!(configured.contains("[mcp_servers.lian-jing]")); assert!(configured.contains("url = \"http://127.0.0.1:27190/mcp\"")); + assert!(configured.contains("http_headers_helper")); + assert!(!configured.contains("qa-only-synthetic-mcp-token")); - upsert_codex_mcp_config(&path, "http://127.0.0.1:27190/mcp/projects/project%20one") - .unwrap(); + upsert_codex_mcp_config( + &path, + "http://127.0.0.1:27190/mcp/projects/project%20one", + helper, + ) + .unwrap(); let migrated = std::fs::read_to_string(&path).unwrap(); assert!(migrated.contains("model = \"gpt-6\"")); assert!(migrated.contains("url = \"http://127.0.0.1:27190/mcp/projects/project%20one\"")); @@ -650,7 +675,8 @@ mod tests { std::fs::write(&path, manual).unwrap(); assert!(upsert_codex_mcp_config( &path, - "http://127.0.0.1:27190/mcp/projects/project%20one" + "http://127.0.0.1:27190/mcp/projects/project%20one", + helper, ) .is_err()); assert_eq!(std::fs::read_to_string(&path).unwrap(), manual); diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 8e54914..5c888c8 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -1,6 +1,6 @@ use axum::{ extract::{Path, Query, State}, - http::StatusCode, + http::{header::AUTHORIZATION, HeaderMap, StatusCode}, response::{ sse::{Event, KeepAlive, Sse}, IntoResponse, Response, @@ -30,6 +30,7 @@ struct SessionEntry { type Sessions = Arc>>; const MCP_LOOPBACK_HOST: &str = "127.0.0.1"; +const DEV_VARIABLE_READ_TOOL: &str = "read_project_dev_variable"; const MCP_SERVER_INSTRUCTIONS: &str = "先读取 get_diagnostics、verify_onboarding 或项目状态,再执行后续操作。项目级工具必须使用当前项目的 canonical project_id,不得跨项目猜测或改写身份。写操作仅在用户明确确认目标与影响后调用;状态 unavailable/unknown 不得当作成功。"; static INITIALIZED_PROJECT_CLIENTS: OnceLock>> = OnceLock::new(); @@ -195,6 +196,60 @@ struct SessionQuery { session_id: String, } +/// 仅敏感单项读取要求项目级认证;旧的非敏感 MCP 工具保持原有契约。 +fn authorize_sensitive_request( + req: &RpcRequest, + project_scope: Option<&str>, + headers: &HeaderMap, + store: &impl crate::credential_store::CredentialStore, +) -> Result<(), &'static str> { + if req.method != "tools/call" + || req + .params + .as_ref() + .and_then(|params| params.get("name")) + .and_then(Value::as_str) + != Some(DEV_VARIABLE_READ_TOOL) + { + return Ok(()); + } + let project_id = project_scope.ok_or("敏感工具仅允许项目级 MCP 连接")?; + let requested_project = req + .params + .as_ref() + .and_then(|params| params.get("arguments")) + .and_then(|args| args.get("project_id")) + .and_then(Value::as_str); + if requested_project.is_some_and(|requested| requested != project_id) { + return Err("项目作用域不匹配"); + } + let mut auth_values = headers.get_all(AUTHORIZATION).iter(); + let header = auth_values + .next() + .and_then(|header| header.to_str().ok()) + .ok_or("缺少 MCP 客户端认证")?; + if auth_values.next().is_some() { + return Err("MCP 客户端认证格式无效"); + } + let provided = header + .strip_prefix("Bearer ") + .ok_or("MCP 客户端认证格式无效")?; + let digest = + crate::credential_store::mcp_auth_digest(project_id).map_err(|_| "MCP 项目认证不可用")?; + let expected = crate::credential_store::read_mcp_auth_token_for_digest(store, &digest) + .map_err(|_| "MCP 项目认证不可用")?; + let equal = provided.len() == expected.len() + && provided + .bytes() + .zip(expected.bytes()) + .fold(0u8, |difference, (left, right)| difference | (left ^ right)) + == 0; + if !equal { + return Err("MCP 客户端认证失败"); + } + Ok(()) +} + // ── 共享 RPC 处理逻辑 ───────────────────────────────────────────────────────── async fn process_rpc(req: &RpcRequest, project_scope: Option<&str>) -> Result { @@ -234,7 +289,17 @@ async fn process_rpc(req: &RpcRequest, project_scope: Option<&str>) -> Result) -> Response { +async fn handle_mcp(headers: HeaderMap, Json(req): Json) -> Response { + if authorize_sensitive_request( + &req, + None, + &headers, + &crate::credential_store::WindowsCredentialStore, + ) + .is_err() + { + return StatusCode::FORBIDDEN.into_response(); + } if req.id.is_none() { return StatusCode::ACCEPTED.into_response(); } @@ -251,8 +316,19 @@ async fn handle_mcp(Json(req): Json) -> Response { async fn handle_scoped_mcp( Path(project_id): Path, + headers: HeaderMap, Json(req): Json, ) -> Response { + if authorize_sensitive_request( + &req, + Some(&project_id), + &headers, + &crate::credential_store::WindowsCredentialStore, + ) + .is_err() + { + return StatusCode::FORBIDDEN.into_response(); + } if req.id.is_none() { return StatusCode::ACCEPTED.into_response(); } @@ -318,6 +394,7 @@ async fn handle_scoped_sse_connect( #[cfg(test)] mod tests { use super::*; + use crate::credential_store::{get_or_create_mcp_auth_token, MemoryCredentialStore}; fn rpc(method: &str) -> RpcRequest { RpcRequest { @@ -328,6 +405,51 @@ mod tests { } } + #[test] + fn sensitive_read_requires_matching_project_bearer_on_both_transports() { + let store = MemoryCredentialStore::new(); + let p1 = get_or_create_mcp_auth_token(&store, "p1").unwrap(); + let _p2 = get_or_create_mcp_auth_token(&store, "p2").unwrap(); + let req = RpcRequest { + jsonrpc: "2.0".into(), + method: "tools/call".into(), + id: Some(json!(1)), + params: Some(json!({ + "name": DEV_VARIABLE_READ_TOOL, + "arguments": {"project_id": "p1", "name": "E2E_PASSWORD"} + })), + }; + let mut headers = HeaderMap::new(); + assert!(authorize_sensitive_request(&req, Some("p1"), &headers, &store).is_err()); + headers.insert(AUTHORIZATION, "Bearer wrong".parse().unwrap()); + assert!(authorize_sensitive_request(&req, Some("p1"), &headers, &store).is_err()); + headers.insert(AUTHORIZATION, format!("Bearer {p1}").parse().unwrap()); + assert!(authorize_sensitive_request(&req, Some("p1"), &headers, &store).is_ok()); + assert!(authorize_sensitive_request(&req, Some("p2"), &headers, &store).is_err()); + assert!(authorize_sensitive_request(&req, None, &headers, &store).is_err()); + + let mut implicit_project = req; + implicit_project.params.as_mut().unwrap()["arguments"] = json!({"name": "E2E_PASSWORD"}); + assert!( + authorize_sensitive_request(&implicit_project, Some("p1"), &headers, &store).is_ok() + ); + assert!(authorize_sensitive_request( + &rpc("initialize"), + Some("p1"), + &HeaderMap::new(), + &store + ) + .is_ok()); + let normal = RpcRequest { + method: "tools/call".into(), + params: Some(json!({"name": "get_diagnostics", "arguments": {}})), + ..rpc("tools/call") + }; + assert!( + authorize_sensitive_request(&normal, Some("p1"), &HeaderMap::new(), &store).is_ok() + ); + } + /// 单一 lian-jing 服务器的握手路径:initialize / tools/list 必须可用 #[tokio::test] async fn process_rpc_handshake() { @@ -445,6 +567,189 @@ mod tests { server.abort(); } + #[cfg(windows)] + #[tokio::test(flavor = "multi_thread")] + async fn scoped_dev_variable_http_pilot_denies_missing_wrong_cross_and_global_auth() { + struct Cleanup { + ids: Vec<(String, String)>, + } + impl Drop for Cleanup { + fn drop(&mut self) { + let store = crate::credential_store::WindowsCredentialStore; + for (project_id, profile_id) in &self.ids { + if let Ok(conn) = db::pool().get() { + let _ = crate::commands::dev_variables::revoke( + &conn, + &store, + project_id, + "E2E_PASSWORD", + ); + let _ = conn + .execute("DELETE FROM project_workspaces WHERE id=?1", [project_id]); + let _ = + conn.execute("DELETE FROM project_profiles WHERE id=?1", [profile_id]); + } + let _ = crate::credential_store::delete_mcp_auth_token_for_project( + &store, project_id, + ); + } + } + } + + db::init_test_pool(); + let p1 = format!("qa-only-dev-mcp-{}", uuid::Uuid::new_v4()); + let p2 = format!("qa-only-dev-mcp-{}", uuid::Uuid::new_v4()); + let profile1 = format!("{p1}-profile"); + let profile2 = format!("{p2}-profile"); + let _cleanup = Cleanup { + ids: vec![ + (p1.clone(), profile1.clone()), + (p2.clone(), profile2.clone()), + ], + }; + { + let conn = db::pool().get().unwrap(); + for (project_id, profile_id) in [(&p1, &profile1), (&p2, &profile2)] { + conn.execute( + "INSERT INTO project_profiles (id,name) VALUES (?1,'QA')", + [profile_id], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id,profile_id) VALUES (?1,?2)", + rusqlite::params![project_id, profile_id], + ) + .unwrap(); + } + } + let store = crate::credential_store::WindowsCredentialStore; + let token = crate::credential_store::get_or_create_mcp_auth_token(&store, &p1).unwrap(); + let _other_token = + crate::credential_store::get_or_create_mcp_auth_token(&store, &p2).unwrap(); + let listener = tokio::net::TcpListener::bind(mcp_bind_addr(0)) + .await + .unwrap(); + let port = listener.local_addr().unwrap().port(); + let sessions: Sessions = Arc::new(Mutex::new(HashMap::new())); + let server = tokio::spawn(async move { + axum::serve(listener, mcp_router(sessions)).await.unwrap(); + }); + let p1_url = format!("http://127.0.0.1:{port}/mcp/projects/{p1}"); + let p2_url = format!("http://127.0.0.1:{port}/mcp/projects/{p2}"); + let global_url = format!("http://127.0.0.1:{port}/mcp"); + let call_body = |name: &str, arguments: Value| { + json!({ + "jsonrpc":"2.0","id":1,"method":"tools/call", + "params":{"name":name,"arguments":arguments} + }) + }; + let scoped_catalog = ureq::post(&p1_url) + .send_json(json!({"jsonrpc":"2.0","id":1,"method":"tools/list"})) + .unwrap() + .into_json::() + .unwrap(); + let tool_names: Vec<&str> = scoped_catalog["result"]["tools"] + .as_array() + .unwrap() + .iter() + .filter_map(|tool| tool["name"].as_str()) + .collect(); + assert!(tool_names.contains(&"read_project_dev_variable")); + assert!(!tool_names + .iter() + .any(|name| name.contains("get_secret") || name.contains("export_env"))); + let global_catalog = ureq::post(&global_url) + .send_json(json!({"jsonrpc":"2.0","id":1,"method":"tools/list"})) + .unwrap() + .into_json::() + .unwrap(); + assert!(!global_catalog + .to_string() + .contains("read_project_dev_variable")); + let register = ureq::post(&p1_url) + .send_json(call_body( + "register_project_dev_variable", + json!({"name":"E2E_PASSWORD","purpose":"合成验收"}), + )) + .unwrap() + .into_json::() + .unwrap(); + assert!(register["result"]["isError"].is_null()); + let pending = ureq::post(&p1_url) + .send_json(call_body("list_project_dev_variables", json!({}))) + .unwrap() + .into_json::() + .unwrap(); + assert!(pending["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("\"configured\":false")); + { + let conn = db::pool().get().unwrap(); + crate::commands::dev_variables::save( + &conn, + &store, + &p1, + "E2E_PASSWORD", + "qa-only-http-value", + ) + .unwrap(); + } + let read_call = call_body("read_project_dev_variable", json!({"name":"E2E_PASSWORD"})); + assert!(matches!( + ureq::post(&p1_url).send_json(read_call.clone()), + Err(ureq::Error::Status(403, _)) + )); + assert!(matches!( + ureq::post(&p1_url) + .set("Authorization", "Bearer wrong") + .send_json(read_call.clone()), + Err(ureq::Error::Status(403, _)) + )); + assert!(matches!( + ureq::post(&p2_url) + .set("Authorization", &format!("Bearer {token}")) + .send_json(read_call.clone()), + Err(ureq::Error::Status(403, _)) + )); + let cross_argument = call_body( + "read_project_dev_variable", + json!({"project_id":p2,"name":"E2E_PASSWORD"}), + ); + assert!(matches!( + ureq::post(&p1_url) + .set("Authorization", &format!("Bearer {token}")) + .send_json(cross_argument), + Err(ureq::Error::Status(403, _)) + )); + assert!(matches!( + ureq::post(&global_url) + .set("Authorization", &format!("Bearer {token}")) + .send_json(read_call.clone()), + Err(ureq::Error::Status(403, _)) + )); + let read = ureq::post(&p1_url) + .set("Authorization", &format!("Bearer {token}")) + .send_json(read_call.clone()) + .unwrap() + .into_json::() + .unwrap(); + assert_eq!(read["result"]["content"][0]["text"], "qa-only-http-value"); + { + let conn = db::pool().get().unwrap(); + crate::commands::dev_variables::revoke(&conn, &store, &p1, "E2E_PASSWORD").unwrap(); + } + let after_revoke = ureq::post(&p1_url) + .set("Authorization", &format!("Bearer {token}")) + .send_json(read_call) + .unwrap() + .into_json::() + .unwrap(); + assert_eq!(after_revoke["result"]["isError"], true); + assert!(!after_revoke.to_string().contains("qa-only-http-value")); + server.abort(); + } + #[cfg(windows)] #[tokio::test(flavor = "multi_thread")] async fn scoped_mcp_create_pull_request_uses_synthetic_credential() { @@ -818,8 +1123,20 @@ mod tests { async fn handle_sse_message( State(sessions): State, Query(query): Query, + headers: HeaderMap, Json(req): Json, ) -> Response { + if authorize_sensitive_request( + &req, + None, + &headers, + &crate::credential_store::WindowsCredentialStore, + ) + .is_err() + { + return StatusCode::FORBIDDEN.into_response(); + } + if req.id.is_none() { return StatusCode::ACCEPTED.into_response(); } @@ -857,8 +1174,19 @@ async fn handle_scoped_sse_message( Path(project_id): Path, State(sessions): State, Query(query): Query, + headers: HeaderMap, Json(req): Json, ) -> Response { + if authorize_sensitive_request( + &req, + Some(&project_id), + &headers, + &crate::credential_store::WindowsCredentialStore, + ) + .is_err() + { + return StatusCode::FORBIDDEN.into_response(); + } if req.id.is_none() { return StatusCode::ACCEPTED.into_response(); } diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 7ad38ee..fe54e1e 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -412,6 +412,33 @@ pub fn tools_list_result_for_project(project_id: &str) -> Value { return result; }; tools.retain(is_project_scoped_tool); + tools.extend([ + json!({ + "name": "register_project_dev_variable", + "description": "登记本项目开发阶段所需变量的名称和用途;不提交值。请让用户在炼境项目 UI 填写非生产、低权限、可重置的值。", + "inputSchema": {"type":"object","properties":{ + "project_id":{"type":"string"}, + "name":{"type":"string","description":"变量名,例如 E2E_PASSWORD"}, + "purpose":{"type":"string","description":"不含秘密的用途说明"} + },"required":["name","purpose"]}, + "annotations":{"readOnlyHint":false,"destructiveHint":false} + }), + json!({ + "name": "list_project_dev_variables", + "description": "查询本项目开发变量的待填写/已配置状态,不返回值或凭证引用。", + "inputSchema": {"type":"object","properties":{"project_id":{"type":"string"}},"required":[]}, + "annotations":{"readOnlyHint":true} + }), + json!({ + "name": "read_project_dev_variable", + "description": "经本机认证后单项读取本项目已由用户填写的开发变量。返回值会进入 Agent 会话;仅用于非生产、低权限、可重置的开发数据。", + "inputSchema": {"type":"object","properties":{ + "project_id":{"type":"string"}, + "name":{"type":"string"} + },"required":["name"]}, + "annotations":{"readOnlyHint":true} + }), + ]); for tool in tools { let Some(schema) = tool.get_mut("inputSchema").and_then(Value::as_object_mut) else { continue; @@ -600,6 +627,16 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option, project_scope: Option { + let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or(""); + let name = args.get("name").and_then(Value::as_str).unwrap_or(""); + let purpose = args.get("purpose").and_then(Value::as_str).unwrap_or(""); + let conn = db::pool().get().map_err(|_| "数据库不可用".to_string())?; + crate::commands::dev_variables::register(&conn, project_id, name, purpose)?; + Ok("已登记开发变量需求;等待用户在炼境项目 UI 填写".to_string()) + } + "list_project_dev_variables" => { + let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or(""); + let conn = db::pool().get().map_err(|_| "数据库不可用".to_string())?; + let statuses = crate::commands::dev_variables::list( + &conn, + &crate::credential_store::WindowsCredentialStore, + project_id, + )?; + serde_json::to_string(&statuses).map_err(|_| "序列化开发变量状态失败".to_string()) + } + "read_project_dev_variable" => { + let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or(""); + let name = args.get("name").and_then(Value::as_str).unwrap_or(""); + let conn = db::pool().get().map_err(|_| "数据库不可用".to_string())?; + crate::commands::dev_variables::read( + &conn, + &crate::credential_store::WindowsCredentialStore, + project_id, + name, + ) + } "get_diagnostics" => match scope_for_call.as_deref() { Some(project_id) => get_scoped_diagnostics(project_id), None => get_diagnostics(),