feat(dev-variables): 项目开发变量与测试账户补给(V1–V11) #23
498
src-tauri/src/commands/dev_variables.rs
Normal file
498
src-tauri/src/commands/dev_variables.rs
Normal file
@ -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<String, String> {
|
||||||
|
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<Option<String>, 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<Vec<DevVariableStatus>, 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<String>>(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<String, String> {
|
||||||
|
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<String> = 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<String> = 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<String> = statement
|
||||||
|
.query_map([project_id], |row| row.get(0))
|
||||||
|
.map_err(|_| "清理项目开发变量失败".to_string())?
|
||||||
|
.collect::<Result<_, _>>()
|
||||||
|
.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<Vec<DevVariableStatus>, 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ pub mod cloud_db_databases;
|
|||||||
pub mod cloud_products;
|
pub mod cloud_products;
|
||||||
pub mod commit_metrics;
|
pub mod commit_metrics;
|
||||||
pub mod delivery_status;
|
pub mod delivery_status;
|
||||||
|
pub mod dev_variables;
|
||||||
pub mod devtools;
|
pub mod devtools;
|
||||||
pub mod docker_apps;
|
pub mod docker_apps;
|
||||||
pub mod git_ops;
|
pub mod git_ops;
|
||||||
|
|||||||
@ -325,6 +325,12 @@ pub fn delete_project(id: String, delete_local: Option<bool>) -> Result<(), Stri
|
|||||||
)
|
)
|
||||||
.unwrap_or((None, None, None));
|
.unwrap_or((None, None, None));
|
||||||
|
|
||||||
|
crate::commands::dev_variables::purge_project(
|
||||||
|
&conn,
|
||||||
|
&crate::credential_store::WindowsCredentialStore,
|
||||||
|
&id,
|
||||||
|
)?;
|
||||||
|
|
||||||
// 清理炼境 MCP 配置(在删除 project_workspaces 记录之前,需查项目路径)
|
// 清理炼境 MCP 配置(在删除 project_workspaces 记录之前,需查项目路径)
|
||||||
if let Err(e) = mcp_inject::remove_project_mcp(&id) {
|
if let Err(e) = mcp_inject::remove_project_mcp(&id) {
|
||||||
eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e);
|
eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e);
|
||||||
|
|||||||
@ -54,6 +54,75 @@ pub trait CredentialStore {
|
|||||||
fn delete(&self, reference: &str) -> Result<(), String>;
|
fn delete(&self, reference: &str) -> Result<(), String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 项目认证材料只用于 MCP 客户端的本机 HTTP 认证;digest 可写入项目配置,token 不可。
|
||||||
|
pub fn mcp_auth_digest(project_id: &str) -> Result<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<u8>) -> Result<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum LegacyCredentialKind {
|
pub enum LegacyCredentialKind {
|
||||||
GiteaAccessToken,
|
GiteaAccessToken,
|
||||||
@ -486,7 +555,9 @@ impl CredentialStore for MemoryCredentialStore {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
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;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
@ -523,6 +594,25 @@ mod tests {
|
|||||||
assert!(!error.contains("do-not-print-this"));
|
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]
|
#[test]
|
||||||
fn legacy_migration_verifies_then_switches_reference_and_clears_old_value() {
|
fn legacy_migration_verifies_then_switches_reference_and_clears_old_value() {
|
||||||
let conn = migration_db();
|
let conn = migration_db();
|
||||||
@ -690,8 +780,20 @@ mod tests {
|
|||||||
|
|
||||||
#[cfg(all(test, windows))]
|
#[cfg(all(test, windows))]
|
||||||
mod windows_tests {
|
mod windows_tests {
|
||||||
use super::{windows_store, CredentialStore, WindowsCredentialStore};
|
use super::{
|
||||||
use std::process::Command;
|
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";
|
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]
|
#[test]
|
||||||
#[ignore]
|
#[ignore]
|
||||||
fn credential_child_probe() {
|
fn credential_child_probe() {
|
||||||
|
|||||||
@ -32,7 +32,7 @@ pub(crate) fn try_pool() -> Option<&'static DbPool> {
|
|||||||
POOL.get()
|
POOL.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn migrate(conn: &rusqlite::Connection) {
|
pub(crate) fn migrate(conn: &rusqlite::Connection) {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"
|
"
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
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",
|
"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)]
|
#[cfg(test)]
|
||||||
@ -1144,6 +1156,23 @@ mod tests {
|
|||||||
migrate(&conn);
|
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<String> = 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]
|
#[test]
|
||||||
fn sync_state_touch_then_read() {
|
fn sync_state_touch_then_read() {
|
||||||
let conn = conn_with_schema();
|
let conn = conn_with_schema();
|
||||||
|
|||||||
@ -8,6 +8,16 @@ mod mcp_webhook;
|
|||||||
mod models;
|
mod models;
|
||||||
use git2;
|
use git2;
|
||||||
|
|
||||||
|
/// Codex 的 http_headers_helper 入口;只被本机同一可执行文件的专用参数调用。
|
||||||
|
pub fn mcp_client_auth_headers_json(project_digest: &str) -> Result<String, String> {
|
||||||
|
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 标志)。
|
/// 创建不弹 CMD 窗口的子进程(Windows 专用 CREATE_NO_WINDOW 标志)。
|
||||||
/// 所有后台静默命令都应通过此函数创建,避免在 Windows 上频繁闪出黑框。
|
/// 所有后台静默命令都应通过此函数创建,避免在 Windows 上频繁闪出黑框。
|
||||||
pub(crate) fn silent_cmd(program: &str) -> std::process::Command {
|
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::edit_api_key,
|
||||||
commands::api_keys::delete_api_key,
|
commands::api_keys::delete_api_key,
|
||||||
commands::api_keys::reveal_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
|
// host apps
|
||||||
commands::host_apps::get_host_apps,
|
commands::host_apps::get_host_apps,
|
||||||
commands::host_apps::add_host_app,
|
commands::host_apps::add_host_app,
|
||||||
|
|||||||
@ -2,5 +2,22 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
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()
|
dev_manager_tauri_lib::run()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
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!(
|
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) {
|
let current = match std::fs::read_to_string(path) {
|
||||||
Ok(value) => value,
|
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> {
|
pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
|
||||||
let port = crate::mcp_server::read_port();
|
let port = crate::mcp_server::read_port();
|
||||||
let server_url = crate::mcp_server::scoped_mcp_url(port, project_id);
|
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)?;
|
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 path = settings_path(project_id)?;
|
||||||
let mut settings = read_json(&path)?;
|
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> {
|
pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
|
||||||
remove_codex_mcp_config(&codex_config_path(project_id)?)?;
|
remove_codex_mcp_config(&codex_config_path(project_id)?)?;
|
||||||
let path = settings_path(project_id)?;
|
let path = settings_path(project_id)?;
|
||||||
if !path.exists() {
|
if path.exists() {
|
||||||
return Ok(());
|
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)?;
|
crate::credential_store::delete_mcp_auth_token_for_project(
|
||||||
|
&crate::credential_store::WindowsCredentialStore,
|
||||||
if let Some(servers) = settings
|
project_id,
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 key + 清旧 key + liangjing.json 瘦身)
|
/// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 key + 清旧 key + liangjing.json 瘦身)
|
||||||
@ -627,14 +645,21 @@ mod tests {
|
|||||||
let path = base.join(".codex/config.toml");
|
let path = base.join(".codex/config.toml");
|
||||||
|
|
||||||
std::fs::write(&path, "model = \"gpt-6\"\n").unwrap();
|
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();
|
let configured = std::fs::read_to_string(&path).unwrap();
|
||||||
assert!(configured.contains("model = \"gpt-6\""));
|
assert!(configured.contains("model = \"gpt-6\""));
|
||||||
assert!(configured.contains("[mcp_servers.lian-jing]"));
|
assert!(configured.contains("[mcp_servers.lian-jing]"));
|
||||||
assert!(configured.contains("url = \"http://127.0.0.1:27190/mcp\""));
|
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")
|
upsert_codex_mcp_config(
|
||||||
.unwrap();
|
&path,
|
||||||
|
"http://127.0.0.1:27190/mcp/projects/project%20one",
|
||||||
|
helper,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let migrated = std::fs::read_to_string(&path).unwrap();
|
let migrated = std::fs::read_to_string(&path).unwrap();
|
||||||
assert!(migrated.contains("model = \"gpt-6\""));
|
assert!(migrated.contains("model = \"gpt-6\""));
|
||||||
assert!(migrated.contains("url = \"http://127.0.0.1:27190/mcp/projects/project%20one\""));
|
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();
|
std::fs::write(&path, manual).unwrap();
|
||||||
assert!(upsert_codex_mcp_config(
|
assert!(upsert_codex_mcp_config(
|
||||||
&path,
|
&path,
|
||||||
"http://127.0.0.1:27190/mcp/projects/project%20one"
|
"http://127.0.0.1:27190/mcp/projects/project%20one",
|
||||||
|
helper,
|
||||||
)
|
)
|
||||||
.is_err());
|
.is_err());
|
||||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), manual);
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), manual);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::{header::AUTHORIZATION, HeaderMap, StatusCode},
|
||||||
response::{
|
response::{
|
||||||
sse::{Event, KeepAlive, Sse},
|
sse::{Event, KeepAlive, Sse},
|
||||||
IntoResponse, Response,
|
IntoResponse, Response,
|
||||||
@ -30,6 +30,7 @@ struct SessionEntry {
|
|||||||
type Sessions = Arc<Mutex<HashMap<String, SessionEntry>>>;
|
type Sessions = Arc<Mutex<HashMap<String, SessionEntry>>>;
|
||||||
|
|
||||||
const MCP_LOOPBACK_HOST: &str = "127.0.0.1";
|
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 不得当作成功。";
|
const MCP_SERVER_INSTRUCTIONS: &str = "先读取 get_diagnostics、verify_onboarding 或项目状态,再执行后续操作。项目级工具必须使用当前项目的 canonical project_id,不得跨项目猜测或改写身份。写操作仅在用户明确确认目标与影响后调用;状态 unavailable/unknown 不得当作成功。";
|
||||||
static INITIALIZED_PROJECT_CLIENTS: OnceLock<RwLock<HashSet<String>>> = OnceLock::new();
|
static INITIALIZED_PROJECT_CLIENTS: OnceLock<RwLock<HashSet<String>>> = OnceLock::new();
|
||||||
|
|
||||||
@ -195,6 +196,60 @@ struct SessionQuery {
|
|||||||
session_id: String,
|
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 处理逻辑 ─────────────────────────────────────────────────────────
|
// ── 共享 RPC 处理逻辑 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn process_rpc(req: &RpcRequest, project_scope: Option<&str>) -> Result<Value, String> {
|
async fn process_rpc(req: &RpcRequest, project_scope: Option<&str>) -> Result<Value, String> {
|
||||||
@ -234,7 +289,17 @@ async fn process_rpc(req: &RpcRequest, project_scope: Option<&str>) -> Result<Va
|
|||||||
|
|
||||||
// ── Streamable HTTP(POST)─────────────────────────────────────────────────────
|
// ── Streamable HTTP(POST)─────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn handle_mcp(Json(req): Json<RpcRequest>) -> Response {
|
async fn handle_mcp(headers: HeaderMap, Json(req): Json<RpcRequest>) -> Response {
|
||||||
|
if authorize_sensitive_request(
|
||||||
|
&req,
|
||||||
|
None,
|
||||||
|
&headers,
|
||||||
|
&crate::credential_store::WindowsCredentialStore,
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
|
}
|
||||||
if req.id.is_none() {
|
if req.id.is_none() {
|
||||||
return StatusCode::ACCEPTED.into_response();
|
return StatusCode::ACCEPTED.into_response();
|
||||||
}
|
}
|
||||||
@ -251,8 +316,19 @@ async fn handle_mcp(Json(req): Json<RpcRequest>) -> Response {
|
|||||||
|
|
||||||
async fn handle_scoped_mcp(
|
async fn handle_scoped_mcp(
|
||||||
Path(project_id): Path<String>,
|
Path(project_id): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
Json(req): Json<RpcRequest>,
|
Json(req): Json<RpcRequest>,
|
||||||
) -> Response {
|
) -> 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() {
|
if req.id.is_none() {
|
||||||
return StatusCode::ACCEPTED.into_response();
|
return StatusCode::ACCEPTED.into_response();
|
||||||
}
|
}
|
||||||
@ -318,6 +394,7 @@ async fn handle_scoped_sse_connect(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::credential_store::{get_or_create_mcp_auth_token, MemoryCredentialStore};
|
||||||
|
|
||||||
fn rpc(method: &str) -> RpcRequest {
|
fn rpc(method: &str) -> RpcRequest {
|
||||||
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 必须可用
|
/// 单一 lian-jing 服务器的握手路径:initialize / tools/list 必须可用
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn process_rpc_handshake() {
|
async fn process_rpc_handshake() {
|
||||||
@ -445,6 +567,189 @@ mod tests {
|
|||||||
server.abort();
|
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::<Value>()
|
||||||
|
.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::<Value>()
|
||||||
|
.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::<Value>()
|
||||||
|
.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::<Value>()
|
||||||
|
.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::<Value>()
|
||||||
|
.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::<Value>()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(after_revoke["result"]["isError"], true);
|
||||||
|
assert!(!after_revoke.to_string().contains("qa-only-http-value"));
|
||||||
|
server.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn scoped_mcp_create_pull_request_uses_synthetic_credential() {
|
async fn scoped_mcp_create_pull_request_uses_synthetic_credential() {
|
||||||
@ -818,8 +1123,20 @@ mod tests {
|
|||||||
async fn handle_sse_message(
|
async fn handle_sse_message(
|
||||||
State(sessions): State<Sessions>,
|
State(sessions): State<Sessions>,
|
||||||
Query(query): Query<SessionQuery>,
|
Query(query): Query<SessionQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
Json(req): Json<RpcRequest>,
|
Json(req): Json<RpcRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
|
if authorize_sensitive_request(
|
||||||
|
&req,
|
||||||
|
None,
|
||||||
|
&headers,
|
||||||
|
&crate::credential_store::WindowsCredentialStore,
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
if req.id.is_none() {
|
if req.id.is_none() {
|
||||||
return StatusCode::ACCEPTED.into_response();
|
return StatusCode::ACCEPTED.into_response();
|
||||||
}
|
}
|
||||||
@ -857,8 +1174,19 @@ async fn handle_scoped_sse_message(
|
|||||||
Path(project_id): Path<String>,
|
Path(project_id): Path<String>,
|
||||||
State(sessions): State<Sessions>,
|
State(sessions): State<Sessions>,
|
||||||
Query(query): Query<SessionQuery>,
|
Query(query): Query<SessionQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
Json(req): Json<RpcRequest>,
|
Json(req): Json<RpcRequest>,
|
||||||
) -> Response {
|
) -> 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() {
|
if req.id.is_none() {
|
||||||
return StatusCode::ACCEPTED.into_response();
|
return StatusCode::ACCEPTED.into_response();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -412,6 +412,33 @@ pub fn tools_list_result_for_project(project_id: &str) -> Value {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
tools.retain(is_project_scoped_tool);
|
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 {
|
for tool in tools {
|
||||||
let Some(schema) = tool.get_mut("inputSchema").and_then(Value::as_object_mut) else {
|
let Some(schema) = tool.get_mut("inputSchema").and_then(Value::as_object_mut) else {
|
||||||
continue;
|
continue;
|
||||||
@ -600,6 +627,16 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
|
|||||||
.and_then(|p| p.get("arguments"))
|
.and_then(|p| p.get("arguments"))
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or(json!({}));
|
.unwrap_or(json!({}));
|
||||||
|
if project_scope.is_none()
|
||||||
|
&& matches!(
|
||||||
|
tool_name.as_str(),
|
||||||
|
"register_project_dev_variable"
|
||||||
|
| "list_project_dev_variables"
|
||||||
|
| "read_project_dev_variable"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return scope_violation("开发变量仅允许项目级 MCP 连接");
|
||||||
|
}
|
||||||
if let Some(project_id) = project_scope.as_deref() {
|
if let Some(project_id) = project_scope.as_deref() {
|
||||||
if let Err(detail) = bind_project_scope(&tool_name, &mut args, project_id) {
|
if let Err(detail) = bind_project_scope(&tool_name, &mut args, project_id) {
|
||||||
return scope_violation(detail);
|
return scope_violation(detail);
|
||||||
@ -609,6 +646,35 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
|
|||||||
let scope_for_call = project_scope.clone();
|
let scope_for_call = project_scope.clone();
|
||||||
|
|
||||||
let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() {
|
let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() {
|
||||||
|
"register_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 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() {
|
"get_diagnostics" => match scope_for_call.as_deref() {
|
||||||
Some(project_id) => get_scoped_diagnostics(project_id),
|
Some(project_id) => get_scoped_diagnostics(project_id),
|
||||||
None => get_diagnostics(),
|
None => get_diagnostics(),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user