Files
mnote/rust/crates/mnote-web/src/routes/ai_settings.rs
T

3444 lines
120 KiB
Rust
Raw Normal View History

2026-07-04 21:47:42 +08:00
//! Provider-neutral AI settings API — effective config, access scopes, and
//! admin-scoped access-scope queries.
//!
//! These endpoints consume MNote's control-plane as the single source of truth:
//! - `directory_grants` → `allowedRoots` (filtered by status, never from `ai_policies`)
//! - `ai_policies` → only `model_policy_json` / `quota_json`
//!
//! Routes are registered by the caller in `routes/mod.rs`.
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source;
use axum::extract::{Extension, Path, Query, State};
use axum::http::StatusCode;
use axum::Json;
use control_plane::AppendAuditInput;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
// ─── Response types ─────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EffectiveAiSettings {
pub default_model: String,
pub providers: Vec<ProviderEntry>,
pub models: Vec<ModelEntry>,
pub allowed_roots: Vec<AccessScopeEntry>,
pub model_policy: Value,
pub quota: Value,
pub tool_catalog: Vec<ToolCatalogEntry>,
pub skills: Vec<SkillConfig>,
pub mcp_servers: Vec<McpServerConfig>,
2026-07-10 10:54:34 +08:00
pub pi_extensions: Vec<PiExtensionConfig>,
2026-07-04 21:47:42 +08:00
pub lightrag_provider: LightRagProviderRef,
pub access_policy_links: AccessPolicyLinks,
/// Always `"directory_grants"` — declares that allowed-roots come from
/// directory_grants, NOT from `ai_policies.allowed_roots_json`.
pub source_of_truth: &'static str,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderEntry {
pub id: String,
pub name: String,
pub enabled: bool,
pub default_model: String,
pub base_url: String,
pub secret_ref: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelEntry {
pub provider: &'static str,
pub id: String,
pub name: String,
pub enabled: bool,
pub is_default: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolCatalogEntry {
pub name: &'static str,
pub description: &'static str,
/// One of `"allow"`, `"ask"`, `"deny"`.
pub default_policy: &'static str,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LightRagProviderRef {
pub provider: &'static str,
pub description: &'static str,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccessPolicyLinks {
pub admin: &'static str,
pub user: &'static str,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccessScopeEntry {
pub id: String,
pub user_id: String,
pub workspace_id: Option<String>,
pub root_uri: String,
pub root_path: String,
pub permission: String,
pub recursive: bool,
pub capabilities: Value,
pub source: String,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccessScopesResponse {
pub allowed_roots: Vec<AccessScopeEntry>,
pub source_of_truth: &'static str,
}
// ─── Query parameters ───────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccessScopesQuery {
pub workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReceiptQuery {
pub session_id: Option<String>,
pub user_id: Option<String>,
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserAiPolicyBody {
#[serde(default)]
pub default_model: Option<String>,
#[serde(default)]
pub allowed_models: Option<Vec<String>>,
#[serde(default)]
pub tools: Option<HashMap<String, String>>,
#[serde(default)]
pub skills: Option<HashMap<String, bool>>,
#[serde(default)]
pub mcp_servers: Option<HashMap<String, bool>>,
2026-07-10 10:54:34 +08:00
#[serde(default)]
pub pi_extensions: Option<HashMap<String, bool>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryAccessRequestBody {
#[serde(default)]
pub root_path: String,
#[serde(default)]
pub root_uri: String,
#[serde(default)]
pub permission: String,
#[serde(default)]
pub note: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryAccessDecisionBody {
#[serde(default)]
pub reason: String,
2026-07-04 21:47:42 +08:00
}
// ─── Admin body types ───────────────────────────────────────────────────
/// Admin PUT request body for AI policy.
/// All fields are optional — only provided fields are merged into the
/// existing policy. `allowed_roots` is never accepted from the client
/// (source of truth is `directory_grants`).
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpsertAiPolicyBody {
#[serde(default)]
pub default_model: Option<String>,
#[serde(default)]
pub allowed_models: Option<Vec<String>>,
#[serde(default)]
pub workspace_id: Option<String>,
/// Per-provider configuration. Provider `secretRef` must use
/// `env://` or `secret://` references — raw API keys are rejected.
#[serde(default)]
pub providers: Option<HashMap<String, ProviderConfig>>,
#[serde(default)]
pub build_plan_task: Option<BuildPlanTaskConfig>,
/// Tool-policy overrides. Keys are MNote tool names
/// (e.g. `mnote.local_file.read`), values are `"allow"`, `"ask"`,
/// or `"deny"`.
#[serde(default)]
pub tools: Option<HashMap<String, String>>,
/// Skills registry: name → enabled/disabled.
#[serde(default)]
pub skills: Option<HashMap<String, SkillConfig>>,
/// MCP server registry.
#[serde(default)]
pub mcp_servers: Option<HashMap<String, McpServerConfig>>,
2026-07-10 10:54:34 +08:00
/// Pi package/extension registry.
#[serde(default)]
pub pi_extensions: Option<HashMap<String, PiExtensionConfig>>,
2026-07-04 21:47:42 +08:00
/// Optional quota override (merged into existing).
#[serde(default)]
pub quota: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderConfig {
pub name: String,
pub enabled: bool,
/// Must be an `env://` or `secret://` reference — raw keys are rejected.
pub secret_ref: String,
pub default_model: String,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub allowed_models: Vec<String>,
#[serde(default)]
pub default_build_model: String,
#[serde(default)]
pub default_plan_model: String,
#[serde(default)]
pub default_task_model: String,
#[serde(default)]
pub failover_chains: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildPlanTaskConfig {
#[serde(default)]
pub default: Option<String>,
#[serde(default)]
pub failover: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillConfig {
pub name: String,
pub enabled: bool,
#[serde(default)]
pub description: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub risk_level: String,
#[serde(default)]
pub required_scopes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpServerConfig {
pub name: String,
pub enabled: bool,
#[serde(default)]
pub url: String,
#[serde(default = "default_mcp_transport")]
pub transport: String,
#[serde(default)]
pub command: String,
#[serde(default = "default_mcp_network_policy")]
pub network_policy: String,
#[serde(default)]
pub secret_refs: Vec<String>,
/// Must be `true` — only facade proxy mode is accepted.
pub facade_only: bool,
/// Must be `true` — sandboxed execution is required.
pub sandbox: bool,
#[serde(default)]
pub description: String,
#[serde(default)]
pub risk_level: String,
#[serde(default)]
pub required_scopes: Vec<String>,
}
2026-07-10 10:54:34 +08:00
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiExtensionConfig {
pub name: String,
pub enabled: bool,
#[serde(default)]
pub description: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub tool_names: Vec<String>,
#[serde(default)]
pub risk_level: String,
#[serde(default)]
pub required_scopes: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AiRuntimeResolvedModel {
pub provider: String,
pub model_id: String,
pub model_ref: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EffectiveAiRuntimePolicy {
pub default_model: String,
pub allowed_models: Vec<String>,
pub enabled_skills: Vec<String>,
pub enabled_skill_sources: Vec<String>,
pub enabled_mcp_servers: Vec<String>,
pub enabled_pi_extensions: Vec<String>,
pub enabled_pi_extension_sources: Vec<String>,
pub pi_extension_tool_names: Vec<String>,
pub pi_extensions: HashMap<String, PiExtensionConfig>,
pub mcp_servers: Value,
pub mcp_bridge: String,
pub mnote_tool_names: Vec<String>,
pub mnote_tool_policies: HashMap<String, String>,
pub native_mcp_supported: bool,
}
impl EffectiveAiRuntimePolicy {
pub(crate) fn resolve_requested_model(
&self,
requested_provider: Option<&str>,
requested_model_id: Option<&str>,
) -> Result<AiRuntimeResolvedModel, String> {
let default_ref = normalize_model_ref(None, &self.default_model)
.unwrap_or_else(|| normalize_model_ref(None, DEFAULT_PI_MODEL).expect("default model"));
let default_provider = default_ref
.split_once('/')
.map(|(provider, _)| provider.to_string())
.unwrap_or_else(|| "omniroute".into());
let requested_model_id = requested_model_id
.map(str::trim)
.filter(|value| !value.is_empty());
let requested_provider = requested_provider
.map(str::trim)
.filter(|value| !value.is_empty());
let model_ref = if let Some(model_id) = requested_model_id {
let normalized =
normalize_model_ref(requested_provider.or(Some(&default_provider)), model_id)
.ok_or_else(|| "请求模型为空".to_string())?;
if let (Some(provider), Some((model_provider, _))) =
(requested_provider, normalized.split_once('/'))
{
if provider != model_provider {
return Err(format!(
"请求模型 {normalized} 与 provider {provider} 不一致"
));
}
}
normalized
} else {
default_ref
};
let allowed = self
.allowed_models
.iter()
.filter_map(|value| normalize_model_ref(Some(&default_provider), value))
.any(|value| value == model_ref);
if !allowed {
return Err(format!("模型 {model_ref} 不在当前 AI 设置允许范围内"));
}
let (provider, model_id) = model_ref
.split_once('/')
.map(|(provider, model_id)| (provider.to_string(), model_id.to_string()))
.unwrap_or_else(|| ("omniroute".into(), model_ref.clone()));
Ok(AiRuntimeResolvedModel {
provider,
model_id,
model_ref,
})
}
}
2026-07-04 21:47:42 +08:00
// ─── Admin response types ──────────────────────────────────────────────
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAiSettingsResponse {
pub ok: bool,
pub schema: &'static str,
pub default_model: String,
pub allowed_models: Vec<String>,
pub providers: HashMap<String, ProviderConfig>,
pub build_plan_task: Option<BuildPlanTaskConfig>,
pub tools: HashMap<String, String>,
pub skills: HashMap<String, SkillConfig>,
pub mcp_servers: HashMap<String, McpServerConfig>,
2026-07-10 10:54:34 +08:00
pub pi_extensions: HashMap<String, PiExtensionConfig>,
2026-07-04 21:47:42 +08:00
pub quota: Value,
pub revision: i64,
pub updated_at: String,
pub tool_catalog: Vec<ToolCatalogEntry>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminAiSettingsUpsertResponse {
pub ok: bool,
pub schema: &'static str,
pub revision: i64,
pub updated_at: String,
}
// ─── Handler implementations ─────────────────────────────────────────────
/// `GET /api/ai-settings/effective`
///
/// Returns the effective AI configuration for the current authenticated user:
/// model policy + fallback defaults, quota, MNote-owned tool catalog,
/// LightRAG provider reference, and access-policy management links.
///
pub async fn effective_settings(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<AccessScopesQuery>,
) -> Result<Json<EffectiveAiSettings>, WebError> {
let actor_id = ensure_authenticated(&context)?;
let allowed_roots =
load_active_directory_grants(&state, &actor_id, query.workspace_id.as_deref())?;
let (model_policy, quota) =
load_effective_model_policy_and_quota(&state, &actor_id, query.workspace_id.as_deref());
let default_model = effective_default_model(&model_policy);
let models = effective_models(&model_policy, &default_model);
let provider_configs = policy_map::<ProviderConfig>(&model_policy, "providers");
let providers = if provider_configs.is_empty() {
vec![ProviderEntry {
id: "omniroute".into(),
name: "Omniroute".into(),
enabled: true,
default_model: default_model.clone(),
base_url: String::new(),
secret_ref: "env://OMNIROUTE_API_KEY".into(),
}]
} else {
provider_configs
.into_iter()
.map(|(id, provider)| ProviderEntry {
id,
name: provider.name,
enabled: provider.enabled,
default_model: provider.default_model,
base_url: provider.base_url,
secret_ref: provider.secret_ref,
})
.collect()
};
let tool_policies = policy_string_map(&model_policy, "tools");
let skills = effective_skill_registry(&model_policy)
.into_values()
.filter(|skill| skill.enabled)
.collect();
let mcp_servers = effective_mcp_registry(&model_policy)
.into_values()
.filter(|server| server.enabled && server.facade_only && server.sandbox)
.collect();
2026-07-10 10:54:34 +08:00
let pi_extensions = effective_pi_extension_registry(&model_policy)
.into_values()
.filter(|extension| extension.enabled)
.collect();
2026-07-04 21:47:42 +08:00
Ok(Json(EffectiveAiSettings {
default_model: default_model.clone(),
providers,
models,
allowed_roots,
model_policy,
quota,
tool_catalog: effective_tool_catalog(&tool_policies),
skills,
mcp_servers,
2026-07-10 10:54:34 +08:00
pi_extensions,
2026-07-04 21:47:42 +08:00
lightrag_provider: LightRagProviderRef {
provider: LIGHTRAG_PROVIDER,
description: LIGHTRAG_PROVIDER_DESCRIPTION,
},
access_policy_links: AccessPolicyLinks {
admin: ADMIN_ACCESS_POLICY_PATH,
user: USER_ACCESS_POLICY_PATH,
},
source_of_truth: SOURCE_OF_TRUTH,
}))
}
/// `GET /api/ai-settings/access-scopes`
///
/// Returns the current user's allowed roots derived from `directory_grants`.
/// Only `active` grants are included.
/// Optional `workspaceId` query parameter narrows to a single workspace.
pub async fn user_access_scopes(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<AccessScopesQuery>,
) -> Result<Json<AccessScopesResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
let allowed_roots =
load_active_directory_grants(&state, &actor_id, query.workspace_id.as_deref())?;
Ok(Json(AccessScopesResponse {
allowed_roots,
source_of_truth: SOURCE_OF_TRUTH,
}))
}
/// `GET /api/ai-admin/access-scopes`
///
/// Admin-only variant. Validates that the current actor has admin
/// privileges via `is_local_access_policy_admin_context` before
/// returning directory grants.
pub async fn admin_access_scopes(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<AccessScopesQuery>,
) -> Result<Json<AccessScopesResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
if !local_folder_source::is_local_access_policy_admin_context(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_access_forbidden",
"需要管理员权限才能访问 ai-admin 端点",
)
.with_context(&context));
}
let allowed_roots =
load_active_directory_grants(&state, &actor_id, query.workspace_id.as_deref())?;
Ok(Json(AccessScopesResponse {
allowed_roots,
source_of_truth: SOURCE_OF_TRUTH,
}))
}
pub async fn user_receipts(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<ReceiptQuery>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
load_receipts(&state, &actor_id, query.session_id.as_deref(), query.limit)
}
pub async fn admin_receipts(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<ReceiptQuery>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
if !local_folder_source::is_local_access_policy_admin_context(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_access_forbidden",
"需要管理员权限才能访问 ai-admin 端点",
)
.with_context(&context));
}
let user_id = query
.user_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(&actor_id);
load_receipts(&state, user_id, query.session_id.as_deref(), query.limit)
}
2026-07-10 10:54:34 +08:00
pub async fn user_directory_access_requests(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
let requests = list_directory_access_requests(&state, Some(&actor_id))?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.settings.directory-access-requests.v1",
"requests": requests,
})))
}
pub async fn create_directory_access_request(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<DirectoryAccessRequestBody>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
let root_path = body.root_path.trim();
let root_uri = body.root_uri.trim();
if root_path.is_empty() && root_uri.is_empty() {
return Err(WebError::bad_request_code(
"directory_access_request_root_required",
"申请目录权限需要提供目录路径",
)
.with_context(&context));
}
let permission = normalize_directory_request_permission(&body.permission);
let request_id = format!("dirreq_{}", uuid::Uuid::new_v4().simple());
let metadata = json!({
"requestId": request_id,
"userId": actor_id,
"rootPath": root_path,
"rootUri": root_uri,
"permission": permission,
"note": body.note.trim(),
"status": "pending",
});
state
.control_plane()
.append_audit(AppendAuditInput {
actor_user_id: Some(actor_id.clone()),
action: "directory_access.requested".into(),
target_kind: "directory_access_request".into(),
target_id: Some(request_id.clone()),
metadata_json: metadata.to_string(),
})
.map_err(|error| WebError::internal(format!("目录权限申请写入失败: {error}")))?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.settings.directory-access-request.created.v1",
"request": metadata,
})))
}
pub async fn admin_directory_access_requests(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
ensure_admin(&context)?;
let requests = list_directory_access_requests(&state, None)?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.admin.settings.directory-access-requests.v1",
"requests": requests,
})))
}
pub async fn approve_directory_access_request(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(request_id): Path<String>,
Json(_body): Json<DirectoryAccessDecisionBody>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
let request = find_pending_directory_access_request(&state, &request_id)?;
let user_id = request
.get("userId")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string();
let root_path = request
.get("rootPath")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string();
let root_uri = request
.get("rootUri")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string();
let permission = request
.get("permission")
.and_then(Value::as_str)
.map(normalize_directory_request_permission)
.unwrap_or_else(|| "read".into());
let (_, Json(created)) = local_folder_source::create_local_access_grant(
State(state.clone()),
Extension(context.clone()),
Json(local_folder_source::LocalAccessGrantRequest {
id: String::new(),
user_id,
root_uri,
root_path,
permission,
recursive: true,
capabilities: Vec::new(),
}),
)
.await?;
let grant_id = created
.pointer("/grant/id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let metadata = json!({
"requestId": request_id,
"status": "approved",
"grantId": grant_id,
"decidedBy": actor_id,
});
state
.control_plane()
.append_audit(AppendAuditInput {
actor_user_id: Some(actor_id),
action: "directory_access.approved".into(),
target_kind: "directory_access_request".into(),
target_id: Some(request_id),
metadata_json: metadata.to_string(),
})
.map_err(|error| WebError::internal(format!("目录权限审批记录写入失败: {error}")))?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.admin.settings.directory-access-request.approved.v1",
"request": metadata,
"grant": created.get("grant").cloned().unwrap_or(Value::Null),
})))
}
pub async fn reject_directory_access_request(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(request_id): Path<String>,
Json(body): Json<DirectoryAccessDecisionBody>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
let _ = find_pending_directory_access_request(&state, &request_id)?;
let metadata = json!({
"requestId": request_id,
"status": "rejected",
"reason": body.reason.trim(),
"decidedBy": actor_id,
});
state
.control_plane()
.append_audit(AppendAuditInput {
actor_user_id: Some(actor_id),
action: "directory_access.rejected".into(),
target_kind: "directory_access_request".into(),
target_id: Some(request_id),
metadata_json: metadata.to_string(),
})
.map_err(|error| WebError::internal(format!("目录权限拒绝记录写入失败: {error}")))?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.admin.settings.directory-access-request.rejected.v1",
"request": metadata,
})))
}
2026-07-04 21:47:42 +08:00
fn load_receipts(
state: &AppState,
user_id: &str,
session_id: Option<&str>,
limit: Option<usize>,
) -> Result<Json<Value>, WebError> {
let limit = limit.unwrap_or(50).clamp(1, 200);
let receipts = state
.control_plane()
.list_ai_tool_events(user_id, session_id, limit)
.map_err(|error| WebError::internal(format!("查询 AI receipts 失败: {error}")))?;
let patches = state
.control_plane()
.list_ai_file_patches(user_id, session_id, limit)
.map_err(|error| WebError::internal(format!("查询 AI file patches 失败: {error}")))?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.ai.receipts.v1",
"userId": user_id,
"receipts": receipts,
"patches": patches,
"receiptCount": receipts.len(),
"patchCount": patches.len(),
"storage": "control_plane_turso_libsql_v1",
})))
}
2026-07-10 10:54:34 +08:00
fn normalize_directory_request_permission(value: &str) -> String {
if value.trim() == "write" {
"write".into()
} else {
"read".into()
}
}
fn list_directory_access_requests(
state: &AppState,
user_filter: Option<&str>,
) -> Result<Vec<Value>, WebError> {
let rows = state
.control_plane()
.list_audit_log(1000)
.map_err(|error| WebError::internal(format!("目录权限申请读取失败: {error}")))?;
let mut requests: HashMap<String, Value> = HashMap::new();
for row in rows.iter().rev() {
if row.target_kind.as_str() != "directory_access_request" {
continue;
}
let Some(request_id) = row
.target_id
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
else {
continue;
};
let metadata =
serde_json::from_str::<Value>(&row.metadata_json).unwrap_or_else(|_| json!({}));
match row.action.as_str() {
"directory_access.requested" => {
let mut value = metadata;
value["requestId"] = json!(request_id);
value["status"] = json!("pending");
value["createdAt"] = json!(row.created_at.clone());
requests.insert(request_id.to_string(), value);
}
"directory_access.approved" | "directory_access.rejected" => {
if let Some(value) = requests.get_mut(request_id) {
let status = metadata.get("status").and_then(Value::as_str).unwrap_or(
if row.action.ends_with("approved") {
"approved"
} else {
"rejected"
},
);
value["status"] = json!(status);
value["decidedAt"] = json!(row.created_at.clone());
if let Some(decided_by) = metadata.get("decidedBy").and_then(Value::as_str) {
value["decidedBy"] = json!(decided_by);
}
if let Some(reason) = metadata.get("reason").and_then(Value::as_str) {
value["reason"] = json!(reason);
}
if let Some(grant_id) = metadata.get("grantId").and_then(Value::as_str) {
value["grantId"] = json!(grant_id);
}
}
}
_ => {}
}
}
let mut values = requests
.into_values()
.filter(|request| {
user_filter
.map(|user_id| {
request.get("userId").and_then(Value::as_str).map(str::trim) == Some(user_id)
})
.unwrap_or(true)
})
.collect::<Vec<_>>();
values.sort_by(|left, right| {
right
.get("createdAt")
.and_then(Value::as_str)
.cmp(&left.get("createdAt").and_then(Value::as_str))
});
Ok(values)
}
fn find_pending_directory_access_request(
state: &AppState,
request_id: &str,
) -> Result<Value, WebError> {
let request_id = request_id.trim();
let request = list_directory_access_requests(state, None)?
.into_iter()
.find(|request| {
request
.get("requestId")
.and_then(Value::as_str)
.map(str::trim)
== Some(request_id)
})
.ok_or_else(|| {
WebError::new(
StatusCode::NOT_FOUND,
"directory_access_request_not_found",
"目录权限申请不存在",
)
})?;
if request.get("status").and_then(Value::as_str) != Some("pending") {
return Err(WebError::new(
StatusCode::CONFLICT,
"directory_access_request_not_pending",
"目录权限申请已处理",
));
}
Ok(request)
}
2026-07-04 21:47:42 +08:00
// ─── Admin handler implementations ──────────────────────────────────────
/// `GET /api/ai-admin/settings`
///
/// Returns the effective admin AI policy, including provider registry,
/// model list, build-plan-task config, tool-policy overrides, skills
/// registry, MCP server registry, quota, and revision info.
///
/// Admin auth required. Projects `model_policy_json` from the
/// control-plane `ai_policies` table (single source of truth).
pub async fn admin_get_settings(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Json<AdminAiSettingsResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
let global_owner = global_policy_owner_id(&actor_id);
let policy = state
.control_plane()
.get_ai_policy(&global_owner, None)
.map_err(|error| WebError::internal(format!("读取 AI policy 失败: {error}")))?;
let model_policy = policy
.as_ref()
.and_then(|record| serde_json::from_str(&record.model_policy_json).ok())
.unwrap_or_else(|| json!({}));
let quota = policy
.as_ref()
.and_then(|record| serde_json::from_str(&record.quota_json).ok())
.unwrap_or_else(|| json!({}));
let response = project_admin_settings(&model_policy, &quota, policy.as_ref());
Ok(Json(response))
}
/// `PUT /api/ai-admin/settings`
///
/// Upserts the AI policy document. The client provides the desired
/// policy sections (providers, models, tools, skills, MCP, build-plan,
/// quota). `allowed_roots` is never accepted from the client — the
/// source of truth is `directory_grants`.
///
/// Validation rules:
/// - Provider `secretRef` MUST start with `env://` or `secret://`
/// (raw API keys rejected).
/// - MCP servers MUST set `facadeOnly: true` and `sandbox: true`.
///
/// On success, appends an audit log entry and returns the new revision.
pub async fn admin_put_settings(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<UpsertAiPolicyBody>,
) -> Result<Json<AdminAiSettingsUpsertResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
let global_owner = global_policy_owner_id(&actor_id);
// ── Validate provider secretRefs ──────────────────────────────────
if let Some(ref providers) = body.providers {
for (provider_id, config) in providers {
validate_provider_secret_ref(&config.secret_ref).map_err(|msg| {
WebError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"ai_admin_invalid_secret_ref",
format!("Provider \"{provider_id}\": {msg}"),
)
.with_context(&context)
})?;
}
}
// ── Validate MCP server configs ───────────────────────────────────
if let Some(ref servers) = body.mcp_servers {
for (server_id, config) in servers {
validate_mcp_server_config(config).map_err(|msg| {
WebError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"ai_admin_invalid_mcp_config",
format!("MCP server \"{server_id}\": {msg}"),
)
.with_context(&context)
})?;
}
}
2026-07-10 10:54:34 +08:00
if let Some(ref extensions) = body.pi_extensions {
for (extension_id, config) in extensions {
validate_pi_extension_config(extension_id, config).map_err(|msg| {
WebError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"ai_admin_invalid_pi_extension_config",
format!("Pi extension \"{extension_id}\": {msg}"),
)
.with_context(&context)
})?;
}
}
2026-07-04 21:47:42 +08:00
// ── Load existing policy, merge inputs ────────────────────────────
let workspace_id = body.workspace_id.as_deref();
let existing = state
.control_plane()
.get_ai_policy(&global_owner, workspace_id)
.map_err(|e| WebError::internal(format!("读取 AI policy 失败: {e}")))?;
let (merged_model_policy_json, merged_quota_json) =
merge_policy_with_existing(existing.as_ref(), &body);
// ── Upsert ────────────────────────────────────────────────────────
let upsert_input = control_plane::UpsertAiPolicyInput {
id: None,
user_id: Some(global_owner),
workspace_id: workspace_id.map(String::from),
allowed_roots_json: existing
.as_ref()
.map(|record| record.allowed_roots_json.clone())
.unwrap_or_else(|| "[]".into()),
model_policy_json: merged_model_policy_json,
quota_json: merged_quota_json,
};
let result = state
.control_plane()
.upsert_ai_policy(upsert_input)
.map_err(|e| WebError::internal(format!("更新 AI policy 失败: {e}")))?;
// ── Append audit ──────────────────────────────────────────────────
let audit_metadata = serde_json::json!({
"revision": result.revision,
"workspaceId": workspace_id,
"updatedFields": describe_updated_fields(&body),
});
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor_id),
action: "admin.upsert_ai_policy".into(),
target_kind: "ai_policy".into(),
target_id: Some(result.id.clone()),
metadata_json: audit_metadata.to_string(),
});
Ok(Json(AdminAiSettingsUpsertResponse {
ok: true,
schema: "mnote.admin.ai.settings.upsert.v1",
revision: result.revision,
updated_at: result.updated_at,
}))
}
pub async fn admin_list_users(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
ensure_admin(&context)?;
let users = state
.control_plane()
.list_users(500)
.map_err(|error| WebError::internal(format!("读取用户列表失败: {error}")))?;
let users = users
.into_iter()
.map(|user| {
json!({
"id": user.id,
"email": user.email,
"username": user.username,
"displayName": user.display_name,
"role": if is_configured_admin_user(&user.id) { "admin" } else { user.role.as_str() },
"status": user.status,
"createdAt": user.created_at,
"updatedAt": user.updated_at,
})
})
.collect::<Vec<_>>();
Ok(Json(json!({
"ok": true,
"schema": "mnote.admin.ai.users.v1",
"users": users,
})))
}
pub async fn admin_get_user_settings(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(user_id): Path<String>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_known_user(&state, &user_id)?;
let global_owner = global_policy_owner_id(&actor_id);
let global_policy = load_policy_value(&state, &global_owner, None);
let user_policy = if user_id == global_owner {
json!({})
} else {
load_policy_value(&state, &user_id, None)
};
2026-07-10 10:54:34 +08:00
let allowed_roots = load_active_directory_grants(&state, &user_id, None)?;
let mut projected =
project_user_settings(&user_id, &global_owner, &global_policy, &user_policy);
projected["allowedRoots"] = json!(allowed_roots);
Ok(Json(projected))
2026-07-04 21:47:42 +08:00
}
pub async fn admin_put_user_settings(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(user_id): Path<String>,
Json(body): Json<UserAiPolicyBody>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_known_user(&state, &user_id)?;
let global_owner = global_policy_owner_id(&actor_id);
if user_id == global_owner {
return Err(WebError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"ai_admin_global_owner_override_forbidden",
"全局策略管理员请在模型、工具或技能/MCP 页面修改默认策略",
)
.with_context(&context));
}
let global_policy = load_policy_value(&state, &global_owner, None);
validate_user_policy_body(&body, &global_policy).map_err(|message| {
WebError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"ai_admin_invalid_user_policy",
message,
)
.with_context(&context)
})?;
let updated_fields = describe_user_updated_fields(&body);
let existing = state
.control_plane()
.get_ai_policy(&user_id, None)
.map_err(|error| WebError::internal(format!("读取用户 AI policy 失败: {error}")))?;
let mut policy = existing
.as_ref()
.and_then(|record| serde_json::from_str::<Value>(&record.model_policy_json).ok())
.unwrap_or_else(|| json!({}));
if let Some(value) = body.default_model {
policy["defaultModel"] = json!(value);
}
if let Some(value) = body.allowed_models {
policy["allowedModels"] = json!(value);
}
if let Some(value) = body.tools {
policy["tools"] = json!(value);
}
if let Some(value) = body.skills {
policy["skillOverrides"] = json!(value);
}
if let Some(value) = body.mcp_servers {
policy["mcpOverrides"] = json!(value);
}
2026-07-10 10:54:34 +08:00
if let Some(value) = body.pi_extensions {
policy["piExtensionOverrides"] = json!(value);
}
2026-07-04 21:47:42 +08:00
let result = state
.control_plane()
.upsert_ai_policy(control_plane::UpsertAiPolicyInput {
id: None,
user_id: Some(user_id.clone()),
workspace_id: None,
allowed_roots_json: existing
.as_ref()
.map(|record| record.allowed_roots_json.clone())
.unwrap_or_else(|| "[]".into()),
model_policy_json: serde_json::to_string(&policy).unwrap_or_else(|_| "{}".into()),
quota_json: existing
.as_ref()
.map(|record| record.quota_json.clone())
.unwrap_or_else(|| "{}".into()),
})
.map_err(|error| WebError::internal(format!("保存用户 AI policy 失败: {error}")))?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor_id),
action: "admin.upsert_user_ai_policy".into(),
target_kind: "user_ai_policy".into(),
target_id: Some(user_id.clone()),
metadata_json: json!({
"revision": result.revision,
"updatedFields": updated_fields,
})
.to_string(),
});
Ok(Json(json!({
"ok": true,
"schema": "mnote.admin.ai.user-settings.upsert.v1",
"userId": user_id,
"revision": result.revision,
"updatedAt": result.updated_at,
})))
}
// ─── Constants ──────────────────────────────────────────────────────────
const DEFAULT_PI_MODEL: &str = "omniroute/gpt-5.4-mini";
const FREEFIRST_PI_MODEL: &str = "omniroute/freefirst";
2026-07-04 21:47:42 +08:00
const LIGHTRAG_PROVIDER: &str = "lightrag";
const LIGHTRAG_PROVIDER_DESCRIPTION: &str = "MNote 知识库默认提供者(LightRAG";
const SOURCE_OF_TRUTH: &str = "directory_grants";
2026-07-10 10:54:34 +08:00
const ADMIN_ACCESS_POLICY_PATH: &str = "/admin/ai#ai-admin-access";
const USER_ACCESS_POLICY_PATH: &str = "/user/ai#ai-admin-access";
2026-07-04 21:47:42 +08:00
fn default_mcp_transport() -> String {
"stdio".into()
}
fn default_mcp_network_policy() -> String {
"deny-all".into()
}
/// Canonical MNote-owned tool catalog for local-first AI editing.
const MNOTE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
ToolCatalogEntry {
name: "mnote.current_page.read",
description: "读取当前 MNote 页面",
default_policy: "allow",
},
ToolCatalogEntry {
name: "mnote.selection.read",
description: "读取当前编辑器选区",
default_policy: "allow",
},
ToolCatalogEntry {
name: "mnote.allowed_roots.describe",
description: "描述当前目录授权范围",
default_policy: "allow",
},
ToolCatalogEntry {
name: "mnote.local_file.read",
description: "读取授权目录内的本地文件",
default_policy: "ask",
},
ToolCatalogEntry {
name: "mnote.local_file.patch",
description: "修改授权目录内的本地文件",
default_policy: "ask",
},
2026-07-10 10:54:34 +08:00
ToolCatalogEntry {
name: "mnote.knowledge_rag.status",
description: "查看 MNote LightRAG 知识库状态",
default_policy: "allow",
},
2026-07-04 21:47:42 +08:00
ToolCatalogEntry {
name: "mnote.knowledge_rag.query",
description: "通过 MNote LightRAG facade 查询知识库",
default_policy: "allow",
},
2026-07-10 10:54:34 +08:00
ToolCatalogEntry {
name: "mnote.knowledge_rag.section_context",
description: "读取知识库长文档章节上下文",
default_policy: "allow",
},
ToolCatalogEntry {
name: "mnote.knowledge_rag.open_reference",
description: "打开知识库引用来源",
default_policy: "allow",
},
2026-07-04 21:47:42 +08:00
ToolCatalogEntry {
name: "mnote.reference.open",
description: "打开知识库引用来源",
default_policy: "allow",
},
2026-07-10 10:54:34 +08:00
ToolCatalogEntry {
name: "mnote.codex_rescue.request",
description: "请求本机 Codex 进行疑难问题自救",
default_policy: "ask",
},
2026-07-04 21:47:42 +08:00
ToolCatalogEntry {
name: "mnote.tool_receipt.write",
description: "记录工具调用回执",
default_policy: "allow",
},
];
fn skill_config(
name: &str,
description: &str,
source: &str,
risk_level: &str,
required_scopes: &[&str],
) -> SkillConfig {
SkillConfig {
name: name.into(),
enabled: true,
description: description.into(),
source: source.into(),
risk_level: risk_level.into(),
2026-07-10 10:54:34 +08:00
required_scopes: required_scopes
.iter()
.map(|scope| (*scope).into())
.collect(),
}
}
fn pi_extension_config(
name: &str,
description: &str,
source: &str,
tool_names: &[&str],
risk_level: &str,
required_scopes: &[&str],
) -> PiExtensionConfig {
PiExtensionConfig {
name: name.into(),
enabled: true,
description: description.into(),
source: source.into(),
tool_names: tool_names.iter().map(|value| (*value).into()).collect(),
risk_level: risk_level.into(),
required_scopes: required_scopes
.iter()
.map(|scope| (*scope).into())
.collect(),
2026-07-04 21:47:42 +08:00
}
}
fn mcp_server_config(
name: &str,
description: &str,
transport: &str,
command: &str,
url: &str,
network_policy: &str,
secret_refs: &[&str],
risk_level: &str,
required_scopes: &[&str],
) -> McpServerConfig {
McpServerConfig {
name: name.into(),
enabled: true,
url: url.into(),
transport: transport.into(),
command: command.into(),
network_policy: network_policy.into(),
secret_refs: secret_refs.iter().map(|value| (*value).into()).collect(),
facade_only: true,
sandbox: true,
description: description.into(),
risk_level: risk_level.into(),
2026-07-10 10:54:34 +08:00
required_scopes: required_scopes
.iter()
.map(|scope| (*scope).into())
.collect(),
2026-07-04 21:47:42 +08:00
}
}
fn default_skill_registry() -> HashMap<String, SkillConfig> {
let mut skills = HashMap::new();
skills.insert(
"vpn".into(),
skill_config(
"VPN",
"通过 MNote facade 协助诊断代理、出海访问和本机网络路由问题。",
"/home/lix/.codex/skills/vpn/SKILL.md",
"high",
&["network:diagnose", "admin:network"],
),
);
skills.insert(
"chrome-bridge".into(),
skill_config(
"Chrome Bridge",
"通过受控浏览器桥接执行页面验证、截图和 DOM/网络诊断。",
"mcp://chrome-bridge",
"high",
&["browser:automation", "qa:browser"],
),
);
skills.insert(
"context7".into(),
skill_config(
"Context7",
"查询最新官方库文档、API 参数和发布说明。",
"/home/lix/.codex/skills/context7/SKILL.md",
"medium",
&["network:docs"],
),
);
skills.insert(
"searxng".into(),
skill_config(
"SearXNG Search",
"通过本地 SearXNG MCP 做通用网页检索并保留引用。",
"mcp://searxng",
"medium",
&["network:search"],
),
);
skills.insert(
"global-search".into(),
skill_config(
"Global Search",
"聚合本机/网页搜索线索,适合研究型查询入口。",
"/home/lix/.hermes/profiles/lite/skills/global-search/SKILL.md",
"medium",
&["network:search"],
),
);
skills.insert(
"mempalace".into(),
skill_config(
"MemPalace",
"读取共享记忆与历史决策事实层,默认通过 MCP facade 受控访问。",
"mcp://mempalace",
"medium",
&["memory:read"],
),
);
skills.insert(
"codegraph".into(),
skill_config(
"CodeGraph",
"读取项目代码图、符号和调用关系,适合开发者工作区。",
"mcp://codegraph",
"medium",
&["workspace:code-read"],
),
);
skills
}
2026-07-10 10:54:34 +08:00
fn default_pi_extension_registry() -> HashMap<String, PiExtensionConfig> {
let mut extensions = HashMap::new();
extensions.insert(
"mnote-pi".into(),
pi_extension_config(
"MNote Pi",
"MNote 官方 Pi package,提供当前页、授权目录、LightRAG 引用和 Codex 自救工具的受控桥接。",
"mnote:core",
&[],
"high",
&["mnote:tool-bridge", "knowledge-rag:query"],
),
);
extensions.insert(
"pi-rust-official-permission-gate".into(),
pi_extension_config(
"Pi Rust Official Permission Gate",
"Pi Rust 官方索引 permission-gate 扩展的本地镜像,用于对危险 bash 命令做确认拦截。",
"pi-rust-official:permission-gate",
&[],
"high",
&["tool:policy"],
),
);
extensions.insert(
"pi-rust-official-todo".into(),
pi_extension_config(
"Pi Rust Official Todo",
"Pi Rust 官方索引 todo 扩展的本地镜像,提供轻量任务列表工具。",
"pi-rust-official:todo",
&["todo"],
"medium",
&["workflow:todo"],
),
);
extensions.insert(
"pi-rust-official-question".into(),
pi_extension_config(
"Pi Rust Official Question",
"Pi Rust 官方索引 question 扩展的本地镜像,用官方 question 工具向用户提问并选择答案。",
"pi-rust-official:question",
&["question"],
"low",
&["ui:ask"],
),
);
extensions.insert(
"pi-rust-official-questionnaire".into(),
pi_extension_config(
"Pi Rust Official Questionnaire",
"Pi Rust 官方索引 questionnaire 扩展的本地镜像,用官方 questionnaire 工具发起多问题澄清。",
"pi-rust-official:questionnaire",
&["questionnaire"],
"low",
&["ui:ask"],
),
);
extensions.insert(
"pi-rust-official-plan-mode".into(),
pi_extension_config(
"Pi Rust Official Plan Mode",
"Pi Rust 官方索引 plan-mode 扩展的本地镜像。默认禁用:它会注册 /todos,与官方 todo 扩展不能同时加载;MNote 默认使用后端 plan permission wrapper。",
"pi-rust-official:plan-mode",
&[],
"medium",
&["workflow:plan-review"],
),
);
if let Some(extension) = extensions.get_mut("pi-rust-official-plan-mode") {
extension.enabled = false;
}
extensions.insert(
"pi-rust-official-subagent".into(),
pi_extension_config(
"Pi Rust Official Subagent",
"Pi Rust 官方索引 subagent 扩展的本地镜像,提供隔离子代理委派工具。",
"pi-rust-official:subagent",
&["subagent"],
"high",
&["agent:delegate"],
),
);
extensions
}
2026-07-04 21:47:42 +08:00
fn default_mcp_server_registry() -> HashMap<String, McpServerConfig> {
let mut servers = HashMap::new();
servers.insert(
"chrome-bridge".into(),
mcp_server_config(
"Chrome Bridge",
"本机 Chromium/Chrome 桥接,用于浏览器 QA、截图与网络请求核验。",
"stdio",
"node /home/lix/apps/codex-chrome-bridge/mcp/chrome-bridge-mcp.mjs",
"",
"allow-local",
&[],
"high",
&["browser:automation", "qa:browser"],
),
);
servers.insert(
"context7".into(),
mcp_server_config(
"Context7",
"官方文档检索 MCP,密钥只允许通过 env://CONTEXT7_API_KEY 引用。",
"streamable-http",
"",
"https://mcp.context7.com/mcp",
"allow-all",
&["env://CONTEXT7_API_KEY"],
"medium",
&["network:docs"],
),
);
servers.insert(
"searxng".into(),
mcp_server_config(
"SearXNG",
"本地 SearXNG 检索 MCP,默认只允许访问本地聚合服务。",
"stdio",
"node /home/lix/.agent-infra/searxng/codex-searxng-mcp.cjs",
"",
"allow-local",
&[],
"medium",
&["network:search"],
),
);
servers.insert(
"mempalace".into(),
mcp_server_config(
"MemPalace",
"共享记忆事实层 MCP,默认只开放 facade/sandbox 后的受控记忆访问。",
"stdio",
"/home/lix/.local/share/uv/tools/mempalace/bin/python -m mempalace.mcp_server --palace /home/lix/.mempalace/palace",
"",
"deny-all",
&[],
"medium",
&["memory:read"],
),
);
servers.insert(
"codegraph".into(),
mcp_server_config(
"CodeGraph",
"代码图 MCP,默认用于只读符号、调用链和影响面查询。",
"stdio",
"codegraph serve --mcp",
"",
"deny-all",
&[],
"medium",
&["workspace:code-read"],
),
);
servers
}
fn effective_skill_registry(model_policy: &Value) -> HashMap<String, SkillConfig> {
let mut skills = default_skill_registry();
let configured = policy_map::<SkillConfig>(model_policy, "skills");
for (id, config) in configured {
skills.insert(id, config);
}
skills
}
fn effective_mcp_registry(model_policy: &Value) -> HashMap<String, McpServerConfig> {
let mut servers = default_mcp_server_registry();
let configured = policy_map::<McpServerConfig>(model_policy, "mcpServers");
for (id, config) in configured {
servers.insert(id, config);
}
servers
}
2026-07-10 10:54:34 +08:00
fn effective_pi_extension_registry(model_policy: &Value) -> HashMap<String, PiExtensionConfig> {
let mut extensions = default_pi_extension_registry();
let configured = policy_map::<PiExtensionConfig>(model_policy, "piExtensions");
for (id, config) in configured {
if is_retired_pi_extension_id(&id) {
continue;
}
extensions.insert(id, config);
}
extensions
}
fn is_retired_pi_extension_id(id: &str) -> bool {
matches!(
id,
"rpiv-ask-user-question"
| "d3ara1n-pi-ask-user"
| "pi-ask-user"
| "mnote-pi-ask-user"
| "pi-mcp-adapter"
| "pi-permission-system"
| "rpiv-todo"
| "plannotator"
| "pi-subagents"
| "pi-codex-goal"
)
}
2026-07-04 21:47:42 +08:00
// ─── Internal helpers ───────────────────────────────────────────────────
/// Rejects unauthenticated / anonymous requests and returns the actor id.
fn ensure_authenticated(context: &RequestContext) -> Result<String, WebError> {
let actor_id = context.auth.actor_id.trim();
let actor_type = context.auth.actor_type.trim();
if actor_id.is_empty()
|| actor_id == "anonymous"
|| actor_type.is_empty()
|| actor_type == "anonymous"
{
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"ai_settings_unauthorized",
"需要 MNote 登录态才能访问 AI 设置",
)
.with_context(context));
}
Ok(actor_id.to_string())
}
/// Reads `ai_policies` for model_policy and quota only.
/// Returns empty objects when no policy exists or on error.
/// Never reads `allowed_roots_json`.
fn load_model_policy_and_quota(
state: &AppState,
actor_id: &str,
workspace_id: Option<&str>,
) -> (Value, Value) {
let policy = match state.control_plane().get_ai_policy(actor_id, workspace_id) {
Ok(Some(p)) => p,
_ => return (json!({}), json!({})),
};
let model_policy = serde_json::from_str(&policy.model_policy_json).unwrap_or(json!({}));
let quota = serde_json::from_str(&policy.quota_json).unwrap_or(json!({}));
(model_policy, quota)
}
fn load_effective_model_policy_and_quota(
state: &AppState,
actor_id: &str,
workspace_id: Option<&str>,
) -> (Value, Value) {
let global_owner = global_policy_owner_id(actor_id);
let (global_policy, global_quota) = load_model_policy_and_quota(state, &global_owner, None);
if actor_id == global_owner && workspace_id.is_none() {
return (global_policy, global_quota);
}
let (user_policy, user_quota) = load_model_policy_and_quota(state, actor_id, workspace_id);
(
merge_effective_user_policy(&global_policy, &user_policy),
merge_json_objects(&global_quota, &user_quota),
)
}
fn global_policy_owner_id(fallback_actor_id: &str) -> String {
std::env::var("MNOTE_ADMIN_USER_IDS")
.ok()
.and_then(|value| {
value
.split(',')
.map(str::trim)
.find(|value| !value.is_empty())
.map(str::to_string)
})
.unwrap_or_else(|| fallback_actor_id.to_string())
}
fn is_configured_admin_user(user_id: &str) -> bool {
std::env::var("MNOTE_ADMIN_USER_IDS")
.ok()
.map(|value| {
value
.split(',')
.map(str::trim)
.any(|value| !value.is_empty() && value == user_id)
})
.unwrap_or(false)
}
fn load_policy_value(state: &AppState, actor_id: &str, workspace_id: Option<&str>) -> Value {
load_model_policy_and_quota(state, actor_id, workspace_id).0
}
fn merge_json_objects(base: &Value, override_value: &Value) -> Value {
let mut merged = base.clone();
if !merged.is_object() {
merged = json!({});
}
if let Some(values) = override_value.as_object() {
for (key, value) in values {
merged[key] = value.clone();
}
}
merged
}
fn merge_effective_user_policy(global_policy: &Value, user_policy: &Value) -> Value {
let mut merged = global_policy.clone();
if !merged.is_object() {
merged = json!({});
}
for key in ["allowedModels", "defaultModel", "buildPlanTask"] {
if let Some(value) = user_policy.get(key) {
merged[key] = value.clone();
}
}
if let Some(overrides) = user_policy.get("tools").and_then(Value::as_object) {
let target = merged
.as_object_mut()
.expect("effective policy object")
.entry("tools")
.or_insert_with(|| json!({}));
if !target.is_object() {
*target = json!({});
}
for (name, value) in overrides {
target[name] = value.clone();
}
}
if let Some(overrides) = user_policy.get("skillOverrides").and_then(Value::as_object) {
let target = merged
.as_object_mut()
.expect("effective policy object")
.entry("skills")
.or_insert_with(|| serde_json::to_value(default_skill_registry()).unwrap_or(json!({})));
if !target.is_object() {
*target = serde_json::to_value(default_skill_registry()).unwrap_or(json!({}));
}
if let Some(skills) = target.as_object_mut() {
2026-07-10 10:54:34 +08:00
let defaults = default_skill_registry();
2026-07-04 21:47:42 +08:00
for (name, enabled) in overrides {
2026-07-10 10:54:34 +08:00
if !skills.contains_key(name) {
if let Some(default_skill) = defaults.get(name) {
skills.insert(
name.clone(),
serde_json::to_value(default_skill).unwrap_or(json!({})),
);
}
}
2026-07-04 21:47:42 +08:00
if let Some(skill) = skills.get_mut(name) {
skill["enabled"] = json!(enabled.as_bool().unwrap_or(false));
}
}
}
}
if let Some(overrides) = user_policy.get("mcpOverrides").and_then(Value::as_object) {
let target = merged
.as_object_mut()
.expect("effective policy object")
.entry("mcpServers")
2026-07-10 10:54:34 +08:00
.or_insert_with(|| {
serde_json::to_value(default_mcp_server_registry()).unwrap_or(json!({}))
});
2026-07-04 21:47:42 +08:00
if !target.is_object() {
*target = serde_json::to_value(default_mcp_server_registry()).unwrap_or(json!({}));
}
if let Some(servers) = target.as_object_mut() {
2026-07-10 10:54:34 +08:00
let defaults = default_mcp_server_registry();
2026-07-04 21:47:42 +08:00
for (name, enabled) in overrides {
2026-07-10 10:54:34 +08:00
if !servers.contains_key(name) {
if let Some(default_server) = defaults.get(name) {
servers.insert(
name.clone(),
serde_json::to_value(default_server).unwrap_or(json!({})),
);
}
}
2026-07-04 21:47:42 +08:00
if let Some(server) = servers.get_mut(name) {
server["enabled"] = json!(enabled.as_bool().unwrap_or(false));
}
}
}
}
2026-07-10 10:54:34 +08:00
if let Some(overrides) = user_policy
.get("piExtensionOverrides")
.and_then(Value::as_object)
{
let target = merged
.as_object_mut()
.expect("effective policy object")
.entry("piExtensions")
.or_insert_with(|| {
serde_json::to_value(default_pi_extension_registry()).unwrap_or(json!({}))
});
if !target.is_object() {
*target = serde_json::to_value(default_pi_extension_registry()).unwrap_or(json!({}));
}
if let Some(extensions) = target.as_object_mut() {
let defaults = default_pi_extension_registry();
for (name, enabled) in overrides {
if !extensions.contains_key(name) {
if let Some(default_extension) = defaults.get(name) {
extensions.insert(
name.clone(),
serde_json::to_value(default_extension).unwrap_or(json!({})),
);
}
}
if let Some(extension) = extensions.get_mut(name) {
extension["enabled"] = json!(enabled.as_bool().unwrap_or(false));
}
}
}
}
2026-07-04 21:47:42 +08:00
merged
}
fn effective_default_model(model_policy: &Value) -> String {
model_policy
.get("defaultModel")
.or_else(|| model_policy.get("default_model"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_PI_MODEL)
.to_string()
}
fn effective_models(model_policy: &Value, default_model: &str) -> Vec<ModelEntry> {
let mut model_ids = model_policy
.get("allowedModels")
.or_else(|| model_policy.get("allowed_models"))
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
if !model_ids.iter().any(|value| value == default_model) {
model_ids.insert(0, default_model.to_string());
}
model_ids
.into_iter()
.map(|id| {
let name = id.clone();
ModelEntry {
provider: "omniroute",
is_default: id == default_model,
id,
name,
enabled: true,
}
})
.collect()
}
2026-07-10 10:54:34 +08:00
fn normalize_model_ref(default_provider: Option<&str>, value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
if let Some((provider, model_id)) = trimmed.split_once('/') {
let provider = provider.trim();
let model_id = model_id.trim();
if provider.is_empty() || model_id.is_empty() {
return None;
}
return Some(format!("{provider}/{model_id}"));
}
let provider = default_provider
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("omniroute");
Some(format!("{provider}/{trimmed}"))
}
fn pi_mcp_extension_bridge_enabled() -> bool {
std::env::var("MNOTE_PAGE_AI_PI_MCP_EXTENSION")
.ok()
.map(|value| value.trim().to_ascii_lowercase())
.map(|value| !matches!(value.as_str(), "0" | "false" | "off" | "disabled" | "none"))
.unwrap_or(true)
}
pub(crate) fn load_effective_ai_runtime_policy(
state: &AppState,
actor_id: &str,
workspace_id: Option<&str>,
) -> EffectiveAiRuntimePolicy {
let (model_policy, _) = load_effective_model_policy_and_quota(state, actor_id, workspace_id);
let default_model = effective_default_model(&model_policy);
let default_provider = normalize_model_ref(None, &default_model)
.and_then(|value| {
value
.split_once('/')
.map(|(provider, _)| provider.to_string())
})
.unwrap_or_else(|| "omniroute".into());
let mut allowed_models = effective_models(&model_policy, &default_model)
.into_iter()
.filter_map(|entry| normalize_model_ref(Some(&default_provider), &entry.id))
.collect::<Vec<_>>();
2026-07-11 21:07:32 +08:00
if !allowed_models
.iter()
.any(|model| model == FREEFIRST_PI_MODEL)
{
allowed_models.push(FREEFIRST_PI_MODEL.to_string());
}
if !allowed_models.iter().any(|model| model == DEFAULT_PI_MODEL) {
allowed_models.push(DEFAULT_PI_MODEL.to_string());
}
2026-07-10 10:54:34 +08:00
allowed_models.sort();
allowed_models.dedup();
let skill_registry = effective_skill_registry(&model_policy);
let mut enabled_skills = skill_registry
.iter()
.filter_map(|(id, skill)| skill.enabled.then_some(id.clone()))
.collect::<Vec<_>>();
enabled_skills.sort();
let mut enabled_skill_sources = skill_registry
.into_iter()
.filter_map(|(_, skill)| {
let source = skill.source.trim();
(skill.enabled && !source.is_empty() && !source.starts_with("mcp://"))
.then(|| source.to_string())
})
.collect::<Vec<_>>();
enabled_skill_sources.sort();
enabled_skill_sources.dedup();
let mcp_registry = effective_mcp_registry(&model_policy);
let mcp_bridge_enabled = pi_mcp_extension_bridge_enabled();
let mut enabled_mcp_servers = mcp_registry
.iter()
.filter_map(|(id, server)| {
(mcp_bridge_enabled && server.enabled && server.facade_only && server.sandbox)
.then_some(id.clone())
})
.collect::<Vec<_>>();
enabled_mcp_servers.sort();
let mcp_servers = if mcp_bridge_enabled {
Value::Object(
mcp_registry
.into_iter()
.filter_map(|(id, server)| {
mcp_server_runtime_config(&server).map(|config| (id, config))
})
.collect(),
)
} else {
json!({})
};
let pi_extension_registry = effective_pi_extension_registry(&model_policy);
let mut enabled_pi_extensions = pi_extension_registry
.iter()
.filter_map(|(id, extension)| extension.enabled.then_some(id.clone()))
.collect::<Vec<_>>();
enabled_pi_extensions.sort();
let mut enabled_pi_extension_sources = pi_extension_registry
.values()
.filter_map(|extension| {
let source = extension.source.trim();
(extension.enabled && !source.is_empty()).then(|| source.to_string())
})
.collect::<Vec<_>>();
enabled_pi_extension_sources.sort();
enabled_pi_extension_sources.dedup();
let mut pi_extension_tool_names = pi_extension_registry
.values()
.filter(|extension| extension.enabled)
.flat_map(|extension| extension.tool_names.iter())
.map(|tool_name| tool_name.trim())
.filter(|tool_name| !tool_name.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
pi_extension_tool_names.sort();
pi_extension_tool_names.dedup();
let tool_policies = policy_string_map(&model_policy, "tools");
let effective_tools = effective_tool_catalog(&tool_policies);
let mut mnote_tool_names = effective_tools
.iter()
.filter_map(|tool| (tool.default_policy != "deny").then_some(tool.name.to_string()))
.collect::<Vec<_>>();
mnote_tool_names.sort();
let mnote_tool_policies = effective_tools
.iter()
.map(|tool| (tool.name.to_string(), tool.default_policy.to_string()))
.collect::<HashMap<_, _>>();
EffectiveAiRuntimePolicy {
default_model: normalize_model_ref(None, &default_model).unwrap_or(default_model),
allowed_models,
enabled_skills,
enabled_skill_sources,
enabled_mcp_servers,
enabled_pi_extensions,
enabled_pi_extension_sources,
pi_extension_tool_names,
pi_extensions: pi_extension_registry,
mcp_bridge: if mcp_servers
.as_object()
.is_some_and(|servers| !servers.is_empty())
{
"pi-rust-sync-client".into()
} else {
"disabled".into()
},
mcp_servers,
mnote_tool_names,
mnote_tool_policies,
native_mcp_supported: false,
}
}
fn mcp_server_runtime_config(server: &McpServerConfig) -> Option<Value> {
if !(server.enabled && server.facade_only && server.sandbox) {
return None;
}
let mut config = serde_json::Map::new();
config.insert("transport".into(), json!(server.transport));
config.insert("lifecycle".into(), json!("lazy"));
config.insert("requestTimeoutMs".into(), json!(30_000));
match server.transport.as_str() {
"stdio" => {
let command_parts = split_command_parts(&server.command);
let command = command_parts.first()?.clone();
let args = command_parts.into_iter().skip(1).collect::<Vec<_>>();
config.insert("command".into(), json!(command));
if !args.is_empty() {
config.insert("args".into(), json!(args));
}
}
"sse" | "streamable-http" => {
if server.url.trim().is_empty() {
return None;
}
config.insert("url".into(), json!(server.url.trim()));
let env_refs = env_secret_refs(&server.secret_refs);
if !env_refs.is_empty() {
let mut headers = serde_json::Map::new();
headers.insert(
"Authorization".into(),
json!(format!("Bearer ${{{}}}", env_refs[0])),
);
config.insert("headers".into(), Value::Object(headers));
}
}
_ => return None,
}
let env_refs = env_secret_refs(&server.secret_refs);
if !env_refs.is_empty() {
config.insert(
"env".into(),
Value::Object(
env_refs
.into_iter()
.map(|name| {
let value = format!("${{{name}}}");
(name, json!(value))
})
.collect(),
),
);
}
Some(Value::Object(config))
}
fn env_secret_refs(secret_refs: &[String]) -> Vec<String> {
secret_refs
.iter()
.filter_map(|value| value.trim().strip_prefix("env://").map(str::trim))
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect()
}
fn split_command_parts(command: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
let mut escaped = false;
for ch in command.chars() {
if escaped {
current.push(ch);
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if let Some(active_quote) = quote {
if ch == active_quote {
quote = None;
} else {
current.push(ch);
}
continue;
}
if ch == '"' || ch == '\'' {
quote = Some(ch);
} else if ch.is_whitespace() {
if !current.is_empty() {
parts.push(std::mem::take(&mut current));
}
} else {
current.push(ch);
}
}
if !current.is_empty() {
parts.push(current);
}
parts
}
2026-07-04 21:47:42 +08:00
fn policy_map<T>(model_policy: &Value, key: &str) -> HashMap<String, T>
where
T: for<'de> Deserialize<'de>,
{
model_policy
.get(key)
.and_then(|value| serde_json::from_value(value.clone()).ok())
.unwrap_or_default()
}
fn policy_string_map(model_policy: &Value, key: &str) -> HashMap<String, String> {
model_policy
.get(key)
.and_then(Value::as_object)
.map(|values| {
values
.iter()
.filter_map(|(name, value)| {
value
.as_str()
.map(|policy| (name.clone(), policy.to_string()))
})
.collect()
})
.unwrap_or_default()
}
fn effective_tool_catalog(policies: &HashMap<String, String>) -> Vec<ToolCatalogEntry> {
MNOTE_TOOL_CATALOG
.iter()
.cloned()
.map(|mut entry| {
if let Some(policy) = policies.get(entry.name) {
entry.default_policy = match policy.as_str() {
"allow" => "allow",
"ask" => "ask",
_ => "deny",
};
}
entry
})
.collect()
}
/// Loads `directory_grants` for the given actor, filters to active entries,
/// and maps to API-friendly `AccessScopeEntry` values.
fn load_active_directory_grants(
state: &AppState,
actor_id: &str,
workspace_id: Option<&str>,
) -> Result<Vec<AccessScopeEntry>, WebError> {
let grants = state
.control_plane()
.list_directory_grants_for_actor(actor_id)
.map_err(|e| WebError::internal(format!("读取目录授权失败: {e}")))?;
Ok(filter_directory_grants_to_access_scopes(
grants,
workspace_id,
))
}
/// Pure function: filters `DirectoryGrantRecord` items to active entries,
/// optionally narrowed by workspace, and maps to `AccessScopeEntry`.
fn filter_directory_grants_to_access_scopes(
grants: Vec<control_plane::DirectoryGrantRecord>,
workspace_id: Option<&str>,
) -> Vec<AccessScopeEntry> {
grants
.into_iter()
.filter(|g| g.status.trim() == "active")
.filter(|g| workspace_id.map_or(true, |ws| g.workspace_id.as_deref() == Some(ws)))
.map(|g| {
let capabilities = serde_json::from_str(&g.capabilities_json).unwrap_or(json!({}));
AccessScopeEntry {
id: g.id,
user_id: g.user_id,
workspace_id: g.workspace_id,
root_uri: g.root_uri,
root_path: g.root_path,
permission: g.permission,
recursive: g.recursive,
capabilities,
source: g.source,
status: g.status,
created_at: g.created_at,
updated_at: g.updated_at,
}
})
.collect()
}
// ─── Admin helpers ──────────────────────────────────────────────────────
/// Ensures admin auth. Reuses the existing admin check from
/// `local_folder_source::is_local_access_policy_admin_context`.
fn ensure_admin(context: &RequestContext) -> Result<(), WebError> {
if !local_folder_source::is_local_access_policy_admin_context(context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_access_forbidden",
"需要管理员权限",
)
.with_context(context));
}
Ok(())
}
fn ensure_known_user(state: &AppState, user_id: &str) -> Result<(), WebError> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Err(WebError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"ai_admin_invalid_user",
"用户 ID 不能为空",
));
}
let exists = state
.control_plane()
.list_users(500)
.map_err(|error| WebError::internal(format!("读取用户列表失败: {error}")))?
.into_iter()
.any(|user| user.id == user_id);
if !exists {
return Err(WebError::new(
StatusCode::NOT_FOUND,
"ai_admin_user_not_found",
"用户不存在",
));
}
Ok(())
}
fn validate_user_policy_body(body: &UserAiPolicyBody, global_policy: &Value) -> Result<(), String> {
let global_models = global_policy
.get("allowedModels")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect::<std::collections::HashSet<_>>();
if let Some(models) = &body.allowed_models {
for model in models {
if !global_models.contains(model.trim()) {
return Err(format!("模型 {model} 不在管理员允许范围内"));
}
}
}
if let Some(default_model) = &body.default_model {
let allowed = body
.allowed_models
.as_ref()
.map(|models| models.iter().any(|model| model == default_model))
.unwrap_or_else(|| global_models.contains(default_model.as_str()));
if !allowed {
return Err("默认模型必须属于该用户允许的模型".into());
}
}
let global_tools = policy_string_map(global_policy, "tools");
if let Some(tools) = &body.tools {
for (name, action) in tools {
if !matches!(action.as_str(), "allow" | "ask" | "deny") {
return Err(format!("工具 {name} 的策略非法"));
}
let global_action = global_tools
.get(name)
.map(String::as_str)
.or_else(|| {
MNOTE_TOOL_CATALOG
.iter()
.find(|tool| tool.name == name)
.map(|tool| tool.default_policy)
})
.unwrap_or("deny");
if tool_policy_rank(action) > tool_policy_rank(global_action) {
return Err(format!("工具 {name} 不能高于全局策略 {global_action}"));
}
}
}
let global_skills = effective_skill_registry(global_policy);
if let Some(skills) = &body.skills {
for (name, enabled) in skills {
if *enabled
&& !global_skills
.get(name)
.map(|skill| skill.enabled)
.unwrap_or(false)
{
return Err(format!("Skill {name} 未被管理员全局启用"));
}
}
}
let global_mcp = effective_mcp_registry(global_policy);
if let Some(servers) = &body.mcp_servers {
for (name, enabled) in servers {
if *enabled
&& !global_mcp
.get(name)
.map(|server| server.enabled && server.facade_only && server.sandbox)
.unwrap_or(false)
{
return Err(format!("MCP {name} 未被管理员全局启用"));
}
}
}
2026-07-10 10:54:34 +08:00
let global_pi_extensions = effective_pi_extension_registry(global_policy);
if let Some(extensions) = &body.pi_extensions {
for (name, enabled) in extensions {
if *enabled
&& !global_pi_extensions
.get(name)
.map(|extension| extension.enabled)
.unwrap_or(false)
{
return Err(format!("Pi 扩展 {name} 未被管理员全局启用"));
}
}
}
2026-07-04 21:47:42 +08:00
Ok(())
}
fn tool_policy_rank(action: &str) -> u8 {
match action {
"allow" => 2,
"ask" => 1,
_ => 0,
}
}
fn project_user_settings(
user_id: &str,
global_owner: &str,
global_policy: &Value,
user_policy: &Value,
) -> Value {
let effective = merge_effective_user_policy(global_policy, user_policy);
let global_models = effective_models(global_policy, &effective_default_model(global_policy));
let allowed_models = effective_models(&effective, &effective_default_model(&effective));
let global_tools = policy_string_map(global_policy, "tools");
let user_tools = policy_string_map(user_policy, "tools");
let skills = effective_skill_registry(global_policy)
.into_iter()
.map(|(id, skill)| {
let override_value = user_policy
.get("skillOverrides")
.and_then(|value| value.get(&id))
.and_then(Value::as_bool);
json!({
"id": id,
"name": skill.name,
"globallyEnabled": skill.enabled,
"enabled": override_value.unwrap_or(skill.enabled),
"hasOverride": override_value.is_some(),
})
})
.collect::<Vec<_>>();
let mcp_servers = effective_mcp_registry(global_policy)
.into_iter()
.map(|(id, server)| {
let override_value = user_policy
.get("mcpOverrides")
.and_then(|value| value.get(&id))
.and_then(Value::as_bool);
json!({
"id": id,
"name": server.name,
"globallyEnabled": server.enabled,
"enabled": override_value.unwrap_or(server.enabled),
"hasOverride": override_value.is_some(),
"transport": server.transport,
"facadeOnly": server.facade_only,
"sandbox": server.sandbox,
})
})
.collect::<Vec<_>>();
2026-07-10 10:54:34 +08:00
let pi_extensions = effective_pi_extension_registry(global_policy)
.into_iter()
.map(|(id, extension)| {
let override_value = user_policy
.get("piExtensionOverrides")
.and_then(|value| value.get(&id))
.and_then(Value::as_bool);
json!({
"id": id,
"name": extension.name,
"description": extension.description,
"source": extension.source,
"toolNames": extension.tool_names,
"riskLevel": extension.risk_level,
"requiredScopes": extension.required_scopes,
"globallyEnabled": extension.enabled,
"enabled": override_value.unwrap_or(extension.enabled),
"hasOverride": override_value.is_some(),
})
})
.collect::<Vec<_>>();
2026-07-04 21:47:42 +08:00
let tools = MNOTE_TOOL_CATALOG
.iter()
.map(|tool| {
let global_action = global_tools
.get(tool.name)
.map(String::as_str)
.unwrap_or(tool.default_policy);
let user_action = user_tools.get(tool.name);
json!({
"name": tool.name,
"description": tool.description,
"globalAction": global_action,
"action": user_action.map(String::as_str).unwrap_or(global_action),
"hasOverride": user_action.is_some(),
})
})
.collect::<Vec<_>>();
json!({
"ok": true,
"schema": "mnote.admin.ai.user-settings.v1",
"userId": user_id,
"globalPolicyOwner": global_owner,
"isGlobalPolicyOwner": user_id == global_owner,
"defaultModel": effective_default_model(&effective),
"catalogModels": global_models,
"allowedModels": allowed_models,
"tools": tools,
"skills": skills,
"mcpServers": mcp_servers,
2026-07-10 10:54:34 +08:00
"piExtensions": pi_extensions,
2026-07-04 21:47:42 +08:00
})
}
fn describe_user_updated_fields(body: &UserAiPolicyBody) -> Vec<&'static str> {
let mut fields = Vec::new();
if body.default_model.is_some() {
fields.push("defaultModel");
}
if body.allowed_models.is_some() {
fields.push("allowedModels");
}
if body.tools.is_some() {
fields.push("tools");
}
if body.skills.is_some() {
fields.push("skillOverrides");
}
if body.mcp_servers.is_some() {
fields.push("mcpOverrides");
}
2026-07-10 10:54:34 +08:00
if body.pi_extensions.is_some() {
fields.push("piExtensionOverrides");
}
2026-07-04 21:47:42 +08:00
fields
}
/// Validates that a provider `secretRef` is an `env://` or `secret://`
/// reference. Raw API keys or empty strings are rejected.
fn validate_provider_secret_ref(secret_ref: &str) -> Result<(), String> {
let trimmed = secret_ref.trim();
if trimmed.is_empty() {
return Err("secretRef 不能为空".into());
}
if trimmed.starts_with("env://") || trimmed.starts_with("secret://") {
Ok(())
} else {
Err("secretRef 必须是 env:// 或 secret:// 引用,不允许直接传 API Key".into())
}
}
/// Validates that an MCP server config has `facadeOnly: true` and
/// `sandbox: true`.
fn validate_mcp_server_config(config: &McpServerConfig) -> Result<(), String> {
if !config.facade_only {
return Err("MCP 服务器必须启用 facadeOnly 模式".into());
}
if !config.sandbox {
return Err("MCP 服务器必须启用 sandbox".into());
}
if !matches!(
config.transport.as_str(),
"stdio" | "sse" | "streamable-http"
) {
return Err("MCP transport 只允许 stdio、sse 或 streamable-http".into());
}
if config.transport == "stdio" && config.command.trim().is_empty() {
return Err("stdio MCP 必须配置 command".into());
}
if config.transport != "stdio" && config.url.trim().is_empty() {
return Err("网络 MCP 必须配置 URL".into());
}
if !matches!(
config.network_policy.as_str(),
"deny-all" | "allow-local" | "allow-all"
) {
return Err("MCP networkPolicy 非法".into());
}
for secret_ref in &config.secret_refs {
validate_provider_secret_ref(secret_ref)?;
}
Ok(())
}
2026-07-10 10:54:34 +08:00
fn validate_pi_extension_config(id: &str, config: &PiExtensionConfig) -> Result<(), String> {
let normalized_id = id.trim();
let source = config.source.trim();
if normalized_id.is_empty() {
return Err("扩展 ID 不能为空".into());
}
if normalized_id == "pi-landstrip" || source.contains("pi-landstrip") {
return Err("OS 沙盒扩展 pi-landstrip 不在默认接入范围内".into());
}
if source.is_empty()
|| !(source.starts_with("npm:")
|| source.starts_with("mnote:")
|| source.starts_with("pi-rust-official:"))
{
return Err(
"Pi 扩展来源必须使用 npm:package、mnote: 内置扩展或 pi-rust-official: 官方扩展形式"
.into(),
);
}
if config.name.trim().is_empty() {
return Err("扩展名称不能为空".into());
}
if !matches!(config.risk_level.as_str(), "low" | "medium" | "high") {
return Err("riskLevel 只允许 low、medium 或 high".into());
}
Ok(())
}
2026-07-04 21:47:42 +08:00
/// Merges the client-provided body into the existing policy (if any).
/// Returns `(model_policy_json, quota_json)` strings suitable for
/// `UpsertAiPolicyInput`.
///
/// `allowed_roots_json` is never touched — it stays at its persisted
/// value (or empty if no existing policy).
fn merge_policy_with_existing(
existing: Option<&control_plane::AiPolicyRecord>,
body: &UpsertAiPolicyBody,
) -> (String, String) {
let mut model_policy = existing
.and_then(|rec| serde_json::from_str::<Value>(&rec.model_policy_json).ok())
.unwrap_or_else(|| json!({}));
// ── Merge scalar fields ─────────────────────────────────────────
if let Some(ref val) = body.default_model {
model_policy["defaultModel"] = json!(val);
}
if let Some(ref val) = body.allowed_models {
model_policy["allowedModels"] = json!(val);
}
if let Some(ref val) = body.build_plan_task {
model_policy["buildPlanTask"] = serde_json::to_value(val).unwrap_or(json!({}));
}
// ── Merge providers map ─────────────────────────────────────────
if let Some(ref providers) = body.providers {
let current = model_policy
.get("providers")
.and_then(|v| v.as_object())
.map(|m| m.clone())
.unwrap_or_default();
let mut merged = serde_json::Map::new();
for (k, v) in current {
merged.insert(k, v.clone());
}
for (k, v) in providers {
let val = serde_json::to_value(v).unwrap_or(json!({}));
merged.insert(k.clone(), val);
}
model_policy["providers"] = Value::Object(merged);
}
// ── Merge tools overrides map ───────────────────────────────────
if let Some(ref tools) = body.tools {
let current = model_policy
.get("tools")
.and_then(|v| v.as_object())
.map(|m| m.clone())
.unwrap_or_default();
let mut merged = serde_json::Map::new();
for (k, v) in current {
merged.insert(k, v.clone());
}
for (k, v) in tools {
merged.insert(k.clone(), json!(v));
}
model_policy["tools"] = Value::Object(merged);
}
2026-07-10 10:54:34 +08:00
// ── Replace skills registry ─────────────────────────────────────
//
// The admin settings UI edits this registry as the desired full
// state. Re-merging deleted rows back from the persisted policy
// makes "删除" a no-op, so presence of this section means replace.
2026-07-04 21:47:42 +08:00
if let Some(ref skills) = body.skills {
let mut merged = serde_json::Map::new();
for (k, v) in skills {
let val = serde_json::to_value(v).unwrap_or(json!({}));
merged.insert(k.clone(), val);
}
model_policy["skills"] = Value::Object(merged);
}
2026-07-10 10:54:34 +08:00
// ── Replace MCP servers map ─────────────────────────────────────
//
// Same contract as skills: admin sends the full editable registry,
// including deletions.
2026-07-04 21:47:42 +08:00
if let Some(ref servers) = body.mcp_servers {
let mut merged = serde_json::Map::new();
for (k, v) in servers {
let val = serde_json::to_value(v).unwrap_or(json!({}));
merged.insert(k.clone(), val);
}
model_policy["mcpServers"] = Value::Object(merged);
}
2026-07-10 10:54:34 +08:00
if let Some(ref extensions) = body.pi_extensions {
let mut merged = serde_json::Map::new();
for (k, v) in extensions {
let val = serde_json::to_value(v).unwrap_or(json!({}));
merged.insert(k.clone(), val);
}
model_policy["piExtensions"] = Value::Object(merged);
}
2026-07-04 21:47:42 +08:00
let model_policy_json = serde_json::to_string(&model_policy).unwrap_or_else(|_| "{}".into());
// ── Merge quota ─────────────────────────────────────────────────
let quota_json = if let Some(ref quota) = body.quota {
let mut existing_quota = existing
.and_then(|rec| serde_json::from_str::<Value>(&rec.quota_json).ok())
.unwrap_or_else(|| json!({}));
if let Some(obj) = quota.as_object() {
for (k, v) in obj {
existing_quota[k] = v.clone();
}
} else {
existing_quota = quota.clone();
}
serde_json::to_string(&existing_quota).unwrap_or_else(|_| "{}".into())
} else {
existing
.map(|rec| rec.quota_json.clone())
.unwrap_or_else(|| "{}".into())
};
(model_policy_json, quota_json)
}
/// Project the admin-facing effective settings view from the raw
/// model_policy JSON.
fn project_admin_settings(
model_policy: &Value,
quota: &Value,
record: Option<&control_plane::AiPolicyRecord>,
) -> AdminAiSettingsResponse {
let default_model = effective_default_model(model_policy);
let allowed_models: Vec<String> = model_policy
.get("allowedModels")
.or_else(|| model_policy.get("allowed_models"))
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_string)
.collect();
let mut providers: HashMap<String, ProviderConfig> = model_policy
.get("providers")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
if providers.is_empty() {
providers.insert(
"omniroute".into(),
ProviderConfig {
name: "Omniroute".into(),
enabled: true,
secret_ref: "env://OMNIROUTE_API_KEY".into(),
default_model: default_model.clone(),
base_url: String::new(),
allowed_models: vec![default_model.clone()],
default_build_model: default_model.clone(),
default_plan_model: default_model.clone(),
default_task_model: default_model.clone(),
failover_chains: vec![],
},
);
}
let build_plan_task = model_policy
.get("buildPlanTask")
.and_then(|v| serde_json::from_value(v.clone()).ok());
let tools = model_policy
.get("tools")
.and_then(|v| {
v.as_object().map(|obj| {
obj.iter()
.map(|(k, v)| (k.clone(), v.as_str().unwrap_or("deny").to_string()))
.collect::<HashMap<_, _>>()
})
})
.unwrap_or_default();
let skills = effective_skill_registry(model_policy);
let mcp_servers = effective_mcp_registry(model_policy);
2026-07-10 10:54:34 +08:00
let pi_extensions = effective_pi_extension_registry(model_policy);
2026-07-04 21:47:42 +08:00
let revision = record.map(|value| value.revision).unwrap_or(0);
let updated_at = record
.map(|value| value.updated_at.clone())
.unwrap_or_default();
let tool_catalog = effective_tool_catalog(&tools);
AdminAiSettingsResponse {
ok: true,
schema: "mnote.admin.ai.settings.v1",
default_model,
allowed_models,
providers,
build_plan_task,
tools,
skills,
mcp_servers,
2026-07-10 10:54:34 +08:00
pi_extensions,
2026-07-04 21:47:42 +08:00
quota: quota.clone(),
revision,
updated_at,
tool_catalog,
}
}
/// Returns a concise list of field names that were provided in the body,
/// for audit-log metadata.
fn describe_updated_fields(body: &UpsertAiPolicyBody) -> Vec<&'static str> {
let mut fields: Vec<&'static str> = Vec::new();
if body.default_model.is_some() {
fields.push("defaultModel");
}
if body.allowed_models.is_some() {
fields.push("allowedModels");
}
if body.providers.is_some() {
fields.push("providers");
}
if body.build_plan_task.is_some() {
fields.push("buildPlanTask");
}
if body.tools.is_some() {
fields.push("tools");
}
if body.skills.is_some() {
fields.push("skills");
}
if body.mcp_servers.is_some() {
fields.push("mcpServers");
}
2026-07-10 10:54:34 +08:00
if body.pi_extensions.is_some() {
fields.push("piExtensions");
}
2026-07-04 21:47:42 +08:00
if body.quota.is_some() {
fields.push("quota");
}
fields
}
// ─── Tests ──────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use control_plane::DirectoryGrantRecord;
fn make_grant(id: &str, workspace_id: Option<&str>, status: &str) -> DirectoryGrantRecord {
DirectoryGrantRecord {
id: id.into(),
user_id: "user_test".into(),
workspace_id: workspace_id.map(Into::into),
root_uri: format!("file:///mnt/test/{id}"),
root_path: format!("/mnt/test/{id}"),
permission: "read".into(),
recursive: false,
capabilities_json: "[]".into(),
source: "access_policy".into(),
status: status.into(),
created_by: None,
created_at: "2026-07-04T00:00:00Z".into(),
updated_at: "2026-07-04T00:00:00Z".into(),
revision: 1,
}
}
#[test]
fn filter_grants_revoked_are_excluded() {
let grants = vec![
make_grant("g1", Some("ws_a"), "active"),
make_grant("g2", Some("ws_a"), "revoked"),
];
let result = filter_directory_grants_to_access_scopes(grants, None);
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, "g1");
}
#[test]
fn filter_grants_workspace_filter() {
let grants = vec![
make_grant("g1", Some("ws_a"), "active"),
make_grant("g2", Some("ws_b"), "active"),
make_grant("g3", None, "active"),
];
let result = filter_directory_grants_to_access_scopes(grants, Some("ws_a"));
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, "g1");
}
#[test]
fn filter_grants_no_workspace_returns_all_active() {
let grants = vec![
make_grant("g1", Some("ws_a"), "active"),
make_grant("g2", None, "active"),
];
let result = filter_directory_grants_to_access_scopes(grants, None);
assert_eq!(result.len(), 2);
}
#[test]
fn effective_response_includes_directory_grant_roots() {
let response = EffectiveAiSettings {
default_model: "test/model".into(),
providers: vec![],
models: vec![],
allowed_roots: vec![AccessScopeEntry {
id: "grant_1".into(),
user_id: "user_1".into(),
workspace_id: None,
root_uri: "file:///tmp/test".into(),
root_path: "/tmp/test".into(),
permission: "read".into(),
recursive: true,
capabilities: json!([]),
source: "test".into(),
status: "active".into(),
created_at: "2026-07-04T00:00:00Z".into(),
updated_at: "2026-07-04T00:00:00Z".into(),
}],
model_policy: json!({"allowedModels": []}),
quota: json!({}),
tool_catalog: vec![],
skills: vec![],
mcp_servers: vec![],
2026-07-10 10:54:34 +08:00
pi_extensions: vec![],
2026-07-04 21:47:42 +08:00
lightrag_provider: LightRagProviderRef {
provider: "test",
description: "test provider",
},
access_policy_links: AccessPolicyLinks {
admin: "/admin",
user: "/user",
},
source_of_truth: "directory_grants",
};
let value = serde_json::to_value(&response).expect("serialize");
assert_eq!(value["allowedRoots"][0]["id"], "grant_1");
assert_eq!(value["sourceOfTruth"], "directory_grants");
}
#[test]
fn model_policy_can_select_default_without_affecting_roots() {
let policy = json!({
"defaultModel": "omniroute/freefirst",
"allowedModels": ["omniroute/freefirst", "omniroute/fast"]
});
assert_eq!(effective_default_model(&policy), "omniroute/freefirst");
let models = effective_models(&policy, "omniroute/freefirst");
assert_eq!(models.len(), 2);
assert!(models[0].is_default);
}
// ─── Admin body validation ────────────────────────────────────────────
#[test]
fn validate_provider_secret_ref_env_passes() {
assert!(validate_provider_secret_ref("env://OPENAI_API_KEY").is_ok());
}
#[test]
fn validate_provider_secret_ref_secret_passes() {
assert!(validate_provider_secret_ref("secret://my-vault-key").is_ok());
}
#[test]
fn validate_provider_secret_ref_empty_fails() {
assert!(validate_provider_secret_ref("").is_err());
}
#[test]
fn validate_provider_secret_ref_raw_key_fails() {
assert!(validate_provider_secret_ref("sk-abc123def456").is_err());
assert!(validate_provider_secret_ref("my-api-key").is_err());
}
#[test]
fn validate_mcp_config_valid() {
let config = McpServerConfig {
name: "test".into(),
enabled: true,
url: "http://localhost:9999".into(),
transport: "sse".into(),
command: String::new(),
network_policy: "allow-local".into(),
secret_refs: vec![],
facade_only: true,
sandbox: true,
description: String::new(),
risk_level: String::new(),
required_scopes: vec![],
};
assert!(validate_mcp_server_config(&config).is_ok());
}
#[test]
fn validate_mcp_config_no_facade_fails() {
let config = McpServerConfig {
name: "test".into(),
enabled: true,
url: "http://localhost:9999".into(),
transport: "sse".into(),
command: String::new(),
network_policy: "allow-local".into(),
secret_refs: vec![],
facade_only: false,
sandbox: true,
description: String::new(),
risk_level: String::new(),
required_scopes: vec![],
};
assert!(validate_mcp_server_config(&config).is_err());
}
#[test]
fn validate_mcp_config_no_sandbox_fails() {
let config = McpServerConfig {
name: "test".into(),
enabled: true,
url: "http://localhost:9999".into(),
transport: "sse".into(),
command: String::new(),
network_policy: "allow-local".into(),
secret_refs: vec![],
facade_only: true,
sandbox: false,
description: String::new(),
risk_level: String::new(),
required_scopes: vec![],
};
assert!(validate_mcp_server_config(&config).is_err());
}
// ─── Admin merge helpers ──────────────────────────────────────────────
#[test]
fn merge_policy_with_existing_creates_from_empty() {
let body = UpsertAiPolicyBody {
default_model: Some("omniroute/freefirst".into()),
allowed_models: Some(vec!["omniroute/freefirst".into(), "omniroute/fast".into()]),
workspace_id: None,
providers: None,
build_plan_task: None,
tools: None,
skills: None,
mcp_servers: None,
2026-07-10 10:54:34 +08:00
pi_extensions: None,
2026-07-04 21:47:42 +08:00
quota: None,
};
let (model_json, quota_json) = merge_policy_with_existing(None, &body);
let parsed: Value = serde_json::from_str(&model_json).unwrap();
assert_eq!(parsed["defaultModel"], "omniroute/freefirst");
assert_eq!(parsed["allowedModels"][0], "omniroute/freefirst");
assert_eq!(quota_json, "{}");
}
#[test]
fn merge_policy_with_existing_preserves_other_fields() {
let existing_value = json!({
"defaultModel": "old/model",
"allowedModels": ["old/model"],
"providers": {
"keep": {"name": "Keep", "enabled": true, "secretRef": "env://KEEP", "defaultModel": "old/model"}
}
});
let existing_rec = control_plane::AiPolicyRecord {
id: "dummy".into(),
user_id: Some("u1".into()),
workspace_id: None,
allowed_roots_json: "[]".into(),
model_policy_json: serde_json::to_string(&existing_value).unwrap(),
quota_json: r#"{"tokens":1000}"#.into(),
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
revision: 1,
};
let body = UpsertAiPolicyBody {
default_model: Some("new/model".into()),
allowed_models: Some(vec!["new/model".into()]),
workspace_id: None,
providers: None, // don't touch existing providers
build_plan_task: None,
tools: None,
skills: None,
mcp_servers: None,
2026-07-10 10:54:34 +08:00
pi_extensions: None,
2026-07-04 21:47:42 +08:00
quota: Some(json!({"tokens": 2000})),
};
let (model_json, quota_json) = merge_policy_with_existing(Some(&existing_rec), &body);
let parsed: Value = serde_json::from_str(&model_json).unwrap();
assert_eq!(parsed["defaultModel"], "new/model");
// Existing providers preserved
assert_eq!(parsed["providers"]["keep"]["name"], "Keep");
let quota_parsed: Value = serde_json::from_str(&quota_json).unwrap();
assert_eq!(quota_parsed["tokens"], 2000);
}
#[test]
fn merge_policy_tools_override() {
let body = UpsertAiPolicyBody {
default_model: None,
allowed_models: None,
workspace_id: None,
providers: None,
build_plan_task: None,
tools: Some([("mnote.local_file.read".into(), "deny".into())].into()),
skills: None,
mcp_servers: None,
2026-07-10 10:54:34 +08:00
pi_extensions: None,
2026-07-04 21:47:42 +08:00
quota: None,
};
let (model_json, _) = merge_policy_with_existing(None, &body);
let parsed: Value = serde_json::from_str(&model_json).unwrap();
assert_eq!(parsed["tools"]["mnote.local_file.read"], "deny");
}
#[test]
fn merge_policy_skills_and_mcp() {
let body = UpsertAiPolicyBody {
default_model: None,
allowed_models: None,
workspace_id: None,
providers: None,
build_plan_task: None,
tools: None,
skills: Some(
[(
"img-skill".into(),
SkillConfig {
name: "Image Gen".into(),
enabled: true,
description: "Generate images".into(),
source: "test".into(),
risk_level: "low".into(),
required_scopes: vec![],
},
)]
.into(),
),
mcp_servers: Some(
[(
"fs-mcp".into(),
McpServerConfig {
name: "File System".into(),
enabled: true,
url: "http://mcp:9090".into(),
transport: "sse".into(),
command: String::new(),
network_policy: "allow-local".into(),
secret_refs: vec![],
facade_only: true,
sandbox: true,
description: "Test MCP".into(),
risk_level: "medium".into(),
required_scopes: vec![],
},
)]
.into(),
),
2026-07-10 10:54:34 +08:00
pi_extensions: Some(
[(
"pi-rust-official-todo".into(),
PiExtensionConfig {
name: "Pi Rust Official Todo".into(),
enabled: true,
description: "Todo tools".into(),
source: "pi-rust-official:todo".into(),
tool_names: vec!["todo".into()],
risk_level: "medium".into(),
required_scopes: vec!["workflow:todo".into()],
},
)]
.into(),
),
2026-07-04 21:47:42 +08:00
quota: None,
};
let (model_json, _) = merge_policy_with_existing(None, &body);
let parsed: Value = serde_json::from_str(&model_json).unwrap();
assert_eq!(parsed["skills"]["img-skill"]["name"], "Image Gen");
assert_eq!(parsed["skills"]["img-skill"]["enabled"], true);
assert_eq!(parsed["mcpServers"]["fs-mcp"]["url"], "http://mcp:9090");
assert_eq!(parsed["mcpServers"]["fs-mcp"]["facadeOnly"], true);
assert_eq!(parsed["mcpServers"]["fs-mcp"]["sandbox"], true);
2026-07-10 10:54:34 +08:00
assert_eq!(
parsed["piExtensions"]["pi-rust-official-todo"]["source"],
"pi-rust-official:todo"
);
}
#[test]
fn merge_policy_skills_and_mcp_replace_existing_registries() {
let existing = control_plane::AiPolicyRecord {
id: "policy_1".into(),
user_id: Some("admin".into()),
workspace_id: None,
allowed_roots_json: "[]".into(),
model_policy_json: json!({
"skills": {
"stale-skill": {
"name": "Stale Skill",
"enabled": true,
"description": "",
"source": "",
"riskLevel": "low",
"requiredScopes": []
},
"keep-skill": {
"name": "Keep Skill",
"enabled": false,
"description": "",
"source": "/tmp/keep/SKILL.md",
"riskLevel": "low",
"requiredScopes": []
}
},
"mcpServers": {
"stale-mcp": {
"name": "Stale MCP",
"enabled": true,
"url": "",
"transport": "stdio",
"command": "old-mcp",
"networkPolicy": "allow-local",
"secretRefs": [],
"facadeOnly": true,
"sandbox": true,
"description": "",
"riskLevel": "medium",
"requiredScopes": []
}
}
})
.to_string(),
quota_json: "{}".into(),
created_at: String::new(),
updated_at: String::new(),
revision: 1,
};
let body = UpsertAiPolicyBody {
default_model: None,
allowed_models: None,
workspace_id: None,
providers: None,
build_plan_task: None,
tools: None,
skills: Some(
[(
"keep-skill".into(),
SkillConfig {
name: "Keep Skill".into(),
enabled: true,
description: String::new(),
source: "/tmp/keep/SKILL.md".into(),
risk_level: "low".into(),
required_scopes: vec![],
},
)]
.into(),
),
mcp_servers: Some(HashMap::new()),
pi_extensions: Some(HashMap::new()),
quota: None,
};
let (model_json, _) = merge_policy_with_existing(Some(&existing), &body);
let parsed: Value = serde_json::from_str(&model_json).unwrap();
assert!(parsed["skills"].get("stale-skill").is_none());
assert_eq!(parsed["skills"]["keep-skill"]["enabled"], true);
assert!(parsed["mcpServers"].as_object().unwrap().is_empty());
assert!(parsed["piExtensions"].as_object().unwrap().is_empty());
2026-07-04 21:47:42 +08:00
}
// ─── Admin projection ────────────────────────────────────────────────
#[test]
fn project_admin_settings_empty_policy_yields_defaults() {
let response = project_admin_settings(&json!({}), &json!({}), None);
assert_eq!(response.default_model, DEFAULT_PI_MODEL);
assert!(response.providers.contains_key("omniroute"));
assert!(response.tools.is_empty());
assert!(response.skills.contains_key("vpn"));
assert!(response.skills.contains_key("chrome-bridge"));
assert!(response.skills.contains_key("context7"));
assert!(response.skills.contains_key("searxng"));
assert!(response.skills.contains_key("global-search"));
assert!(response.skills.contains_key("mempalace"));
assert!(response.skills.contains_key("codegraph"));
assert!(response.mcp_servers.contains_key("chrome-bridge"));
assert!(response.mcp_servers.contains_key("context7"));
assert!(response.mcp_servers.contains_key("searxng"));
assert!(response.mcp_servers.contains_key("mempalace"));
assert!(response.mcp_servers.contains_key("codegraph"));
2026-07-10 10:54:34 +08:00
assert!(response.pi_extensions.contains_key("mnote-pi"));
assert!(!response.pi_extensions.contains_key("pi-mcp-adapter"));
assert!(!response.pi_extensions.contains_key("pi-permission-system"));
assert!(!response.pi_extensions.contains_key("rpiv-todo"));
assert!(!response
.pi_extensions
.contains_key("rpiv-ask-user-question"));
assert!(!response.pi_extensions.contains_key("d3ara1n-pi-ask-user"));
assert!(response
.pi_extensions
.contains_key("pi-rust-official-question"));
assert!(response
.pi_extensions
.contains_key("pi-rust-official-questionnaire"));
assert!(!response.pi_extensions.contains_key("mnote-pi-ask-user"));
assert!(!response.pi_extensions.contains_key("pi-ask-user"));
assert!(response.pi_extensions.contains_key("pi-rust-official-todo"));
assert!(response
.pi_extensions
.contains_key("pi-rust-official-permission-gate"));
assert!(response
.pi_extensions
.contains_key("pi-rust-official-plan-mode"));
assert!(response
.pi_extensions
.contains_key("pi-rust-official-subagent"));
assert!(!response.pi_extensions.contains_key("plannotator"));
assert!(!response.pi_extensions.contains_key("pi-subagents"));
assert!(!response.pi_extensions.contains_key("pi-codex-goal"));
assert!(!response.pi_extensions.contains_key("pi-landstrip"));
2026-07-04 21:47:42 +08:00
}
#[test]
fn project_admin_settings_with_full_policy() {
let policy = json!({
"defaultModel": "omniroute/premium",
"allowedModels": ["omniroute/premium", "omniroute/fast"],
"providers": {
"omniroute": {
"name": "Omniroute",
"enabled": true,
"secretRef": "env://OMNIROUTE_KEY",
"defaultModel": "omniroute/premium"
}
},
"buildPlanTask": {
"default": "omniroute/premium",
"failover": ["omniroute/fast"]
},
"tools": {
"mnote.local_file.patch": "deny"
},
"skills": {
"img-skill": {
"name": "Image Gen",
"enabled": true
}
},
"mcpServers": {
"fs-mcp": {
"name": "File System",
"enabled": true,
"url": "http://mcp:9090",
"facadeOnly": true,
"sandbox": true
}
2026-07-10 10:54:34 +08:00
},
"piExtensions": {
"pi-rust-official-todo": {
"name": "Pi Rust Official Todo",
"enabled": true,
"source": "pi-rust-official:todo",
"toolNames": ["todo"]
}
2026-07-04 21:47:42 +08:00
}
});
let quota = json!({"tokens": 5000});
let response = project_admin_settings(&policy, &quota, None);
assert_eq!(response.default_model, "omniroute/premium");
assert_eq!(response.allowed_models.len(), 2);
assert!(response.providers.contains_key("omniroute"));
assert_eq!(
response.tools.get("mnote.local_file.patch").unwrap(),
"deny"
);
assert!(response.skills.contains_key("img-skill"));
assert!(response.mcp_servers.contains_key("fs-mcp"));
2026-07-10 10:54:34 +08:00
assert!(response.pi_extensions.contains_key("pi-rust-official-todo"));
2026-07-04 21:47:42 +08:00
assert_eq!(response.quota["tokens"], 5000);
}
#[test]
fn describe_updated_fields_empty() {
let body = UpsertAiPolicyBody {
default_model: None,
allowed_models: None,
workspace_id: None,
providers: None,
build_plan_task: None,
tools: None,
skills: None,
mcp_servers: None,
2026-07-10 10:54:34 +08:00
pi_extensions: None,
2026-07-04 21:47:42 +08:00
quota: None,
};
let fields = describe_updated_fields(&body);
assert!(fields.is_empty());
}
#[test]
fn describe_updated_fields_all() {
let body = UpsertAiPolicyBody {
default_model: Some("m".into()),
allowed_models: Some(vec![]),
workspace_id: None,
providers: Some(HashMap::new()),
build_plan_task: Some(BuildPlanTaskConfig {
default: Some("m".into()),
failover: vec![],
}),
tools: Some(HashMap::new()),
skills: Some(HashMap::new()),
mcp_servers: Some(HashMap::new()),
2026-07-10 10:54:34 +08:00
pi_extensions: Some(HashMap::new()),
2026-07-04 21:47:42 +08:00
quota: Some(json!({})),
};
let fields = describe_updated_fields(&body);
2026-07-10 10:54:34 +08:00
assert_eq!(fields.len(), 9);
2026-07-04 21:47:42 +08:00
}
#[test]
fn user_policy_merges_models_and_disables_skill_and_mcp() {
let global = json!({
"defaultModel": "omniroute/freefirst",
"allowedModels": ["omniroute/freefirst", "omniroute/fast"],
"skills": {
"search": {"name": "Search", "enabled": true}
},
"mcpServers": {
"docs": {"name": "Docs", "enabled": true, "facadeOnly": true, "sandbox": true}
2026-07-10 10:54:34 +08:00
},
"piExtensions": {
"pi-rust-official-subagent": {"name": "Pi Rust Official Subagent", "enabled": true}
2026-07-04 21:47:42 +08:00
}
});
let user = json!({
"defaultModel": "omniroute/fast",
"allowedModels": ["omniroute/fast"],
2026-07-10 10:54:34 +08:00
"skillOverrides": {"search": false, "context7": false},
"mcpOverrides": {"docs": false, "context7": false},
"piExtensionOverrides": {"pi-rust-official-subagent": false, "pi-rust-official-todo": false}
2026-07-04 21:47:42 +08:00
});
let merged = merge_effective_user_policy(&global, &user);
assert_eq!(merged["defaultModel"], "omniroute/fast");
assert_eq!(merged["allowedModels"], json!(["omniroute/fast"]));
assert_eq!(merged["skills"]["search"]["enabled"], false);
2026-07-10 10:54:34 +08:00
assert_eq!(merged["skills"]["context7"]["enabled"], false);
2026-07-04 21:47:42 +08:00
assert_eq!(merged["mcpServers"]["docs"]["enabled"], false);
2026-07-10 10:54:34 +08:00
assert_eq!(merged["mcpServers"]["context7"]["enabled"], false);
assert_eq!(
merged["piExtensions"]["pi-rust-official-subagent"]["enabled"],
false
);
assert_eq!(
merged["piExtensions"]["pi-rust-official-todo"]["enabled"],
false
);
2026-07-04 21:47:42 +08:00
}
#[test]
fn user_policy_rejects_privilege_escalation() {
let global = json!({
"allowedModels": ["omniroute/freefirst"],
"tools": {"mnote.local_file.patch": "ask"},
"skills": {"search": {"name": "Search", "enabled": false}},
"mcpServers": {"docs": {"name": "Docs", "enabled": false, "facadeOnly": true, "sandbox": true}}
});
let body = UserAiPolicyBody {
default_model: Some("omniroute/premium".into()),
allowed_models: Some(vec!["omniroute/premium".into()]),
tools: Some([("mnote.local_file.patch".into(), "allow".into())].into()),
skills: Some([("search".into(), true)].into()),
mcp_servers: Some([("docs".into(), true)].into()),
2026-07-10 10:54:34 +08:00
pi_extensions: Some([("pi-rust-official-subagent".into(), true)].into()),
2026-07-04 21:47:42 +08:00
};
assert!(validate_user_policy_body(&body, &global).is_err());
}
#[test]
fn user_tool_policy_can_only_reduce_global_permission() {
let global = json!({
"allowedModels": ["omniroute/freefirst"],
"tools": {"mnote.local_file.patch": "ask"}
});
let body = UserAiPolicyBody {
default_model: Some("omniroute/freefirst".into()),
allowed_models: Some(vec!["omniroute/freefirst".into()]),
tools: Some([("mnote.local_file.patch".into(), "deny".into())].into()),
skills: None,
mcp_servers: None,
2026-07-10 10:54:34 +08:00
pi_extensions: None,
2026-07-04 21:47:42 +08:00
};
assert!(validate_user_policy_body(&body, &global).is_ok());
}
}