//! 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, pub models: Vec, pub allowed_roots: Vec, pub model_policy: Value, pub quota: Value, pub tool_catalog: Vec, pub skills: Vec, pub mcp_servers: Vec, pub pi_extensions: Vec, 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, 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, pub source_of_truth: &'static str, } // ─── Query parameters ─────────────────────────────────────────────────── #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AccessScopesQuery { pub workspace_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReceiptQuery { pub session_id: Option, pub user_id: Option, pub limit: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UserAiPolicyBody { #[serde(default)] pub default_model: Option, #[serde(default)] pub allowed_models: Option>, #[serde(default)] pub tools: Option>, #[serde(default)] pub skills: Option>, #[serde(default)] pub mcp_servers: Option>, #[serde(default)] pub pi_extensions: Option>, } #[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, } // ─── 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, #[serde(default)] pub allowed_models: Option>, #[serde(default)] pub workspace_id: Option, /// Per-provider configuration. Provider `secretRef` must use /// `env://` or `secret://` references — raw API keys are rejected. #[serde(default)] pub providers: Option>, #[serde(default)] pub build_plan_task: Option, /// 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>, /// Skills registry: name → enabled/disabled. #[serde(default)] pub skills: Option>, /// MCP server registry. #[serde(default)] pub mcp_servers: Option>, /// Pi package/extension registry. #[serde(default)] pub pi_extensions: Option>, /// Optional quota override (merged into existing). #[serde(default)] pub quota: Option, } #[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, #[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, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BuildPlanTaskConfig { #[serde(default)] pub default: Option, #[serde(default)] pub failover: Vec, } #[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, } #[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, /// 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, } #[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, #[serde(default)] pub risk_level: String, #[serde(default)] pub required_scopes: Vec, } #[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, pub pi_runtime: Value, pub enabled_skills: Vec, pub enabled_skill_sources: Vec, pub enabled_mcp_servers: Vec, pub enabled_pi_extensions: Vec, pub enabled_pi_extension_sources: Vec, pub pi_extension_tool_names: Vec, pub pi_extensions: HashMap, pub mcp_servers: Value, pub mcp_bridge: String, pub mnote_tool_names: Vec, pub mnote_tool_policies: HashMap, pub native_mcp_supported: bool, } impl EffectiveAiRuntimePolicy { pub(crate) fn resolve_requested_model( &self, requested_provider: Option<&str>, requested_model_id: Option<&str>, ) -> Result { let default_ref = canonical_ai_model_ref(None, &self.default_model).unwrap_or_else(|| { canonical_ai_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 = canonical_ai_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| canonical_ai_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, }) } } // ─── 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, pub providers: HashMap, pub build_plan_task: Option, pub tools: HashMap, pub skills: HashMap, pub mcp_servers: HashMap, pub pi_extensions: HashMap, pub quota: Value, pub revision: i64, pub updated_at: String, pub tool_catalog: Vec, } #[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, Extension(context): Extension, Query(query): Query, ) -> Result, 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::(&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(); let pi_extensions = effective_pi_extension_registry(&model_policy) .into_values() .filter(|extension| extension.enabled) .collect(); 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, pi_extensions, 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, Extension(context): Extension, Query(query): Query, ) -> Result, 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, Extension(context): Extension, Query(query): Query, ) -> Result, 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, Extension(context): Extension, Query(query): Query, ) -> Result, 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, Extension(context): Extension, Query(query): Query, ) -> Result, 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) } pub async fn user_directory_access_requests( State(state): State, Extension(context): Extension, ) -> Result, 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, Extension(context): Extension, Json(body): Json, ) -> Result, 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, Extension(context): Extension, ) -> Result, 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, Extension(context): Extension, Path(request_id): Path, Json(_body): Json, ) -> Result, 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, Extension(context): Extension, Path(request_id): Path, Json(body): Json, ) -> Result, 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, }))) } fn load_receipts( state: &AppState, user_id: &str, session_id: Option<&str>, limit: Option, ) -> Result, 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", }))) } 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, WebError> { let rows = state .control_plane() .list_audit_log(1000) .map_err(|error| WebError::internal(format!("目录权限申请读取失败: {error}")))?; let mut requests: HashMap = 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::(&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::>(); 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 { 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) } // ─── 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, Extension(context): Extension, ) -> Result, 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, "a, 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, Extension(context): Extension, Json(body): Json, ) -> Result, 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) })?; } } 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) })?; } } // ── 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, Extension(context): Extension, ) -> Result, 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::>(); Ok(Json(json!({ "ok": true, "schema": "mnote.admin.ai.users.v1", "users": users, }))) } pub async fn admin_get_user_settings( State(state): State, Extension(context): Extension, Path(user_id): Path, ) -> Result, 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) }; 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)) } pub async fn admin_put_user_settings( State(state): State, Extension(context): Extension, Path(user_id): Path, Json(body): Json, ) -> Result, 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::(&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); } if let Some(value) = body.pi_extensions { policy["piExtensionOverrides"] = json!(value); } 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"; const RETIRED_FREEFIRST_FAST_MODEL: &str = "omniroute/freefirst-fast"; const LIGHTRAG_PROVIDER: &str = "lightrag"; const LIGHTRAG_PROVIDER_DESCRIPTION: &str = "MNote 知识库默认提供者(LightRAG)"; const SOURCE_OF_TRUTH: &str = "directory_grants"; const ADMIN_ACCESS_POLICY_PATH: &str = "/admin/ai#ai-admin-access"; const USER_ACCESS_POLICY_PATH: &str = "/user/ai#ai-admin-access"; 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", }, ToolCatalogEntry { name: "mnote.knowledge_rag.status", description: "查看 MNote LightRAG 知识库状态", default_policy: "allow", }, ToolCatalogEntry { name: "mnote.knowledge_rag.query", description: "通过 MNote LightRAG facade 查询知识库", default_policy: "allow", }, ToolCatalogEntry { name: "mnote.knowledge_rag.section_context", description: "读取知识库长文档章节上下文", default_policy: "allow", }, ToolCatalogEntry { name: "mnote.knowledge_rag.open_reference", description: "打开知识库引用来源", default_policy: "allow", }, ToolCatalogEntry { name: "mnote.reference.open", description: "打开知识库引用来源", default_policy: "allow", }, ToolCatalogEntry { name: "mnote.codex_rescue.request", description: "请求本机 Codex 进行疑难问题自救", default_policy: "ask", }, 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(), 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(), } } 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(), required_scopes: required_scopes .iter() .map(|scope| (*scope).into()) .collect(), } } fn default_skill_registry() -> HashMap { 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 } fn default_pi_extension_registry() -> HashMap { 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 } fn default_mcp_server_registry() -> HashMap { 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 { let mut skills = default_skill_registry(); let configured = policy_map::(model_policy, "skills"); for (id, config) in configured { skills.insert(id, config); } skills } fn effective_mcp_registry(model_policy: &Value) -> HashMap { let mut servers = default_mcp_server_registry(); let configured = policy_map::(model_policy, "mcpServers"); for (id, config) in configured { servers.insert(id, config); } servers } fn effective_pi_extension_registry(model_policy: &Value) -> HashMap { let mut extensions = default_pi_extension_registry(); let configured = policy_map::(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" ) } // ─── Internal helpers ─────────────────────────────────────────────────── /// Rejects unauthenticated / anonymous requests and returns the actor id. fn ensure_authenticated(context: &RequestContext) -> Result { 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() { let defaults = default_skill_registry(); for (name, enabled) in overrides { 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!({})), ); } } 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") .or_insert_with(|| { serde_json::to_value(default_mcp_server_registry()).unwrap_or(json!({})) }); if !target.is_object() { *target = serde_json::to_value(default_mcp_server_registry()).unwrap_or(json!({})); } if let Some(servers) = target.as_object_mut() { let defaults = default_mcp_server_registry(); for (name, enabled) in overrides { 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!({})), ); } } if let Some(server) = servers.get_mut(name) { server["enabled"] = json!(enabled.as_bool().unwrap_or(false)); } } } } 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)); } } } } merged } fn effective_default_model(model_policy: &Value) -> String { let configured = 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(); canonical_ai_model_ref(None, &configured).unwrap_or(configured) } fn effective_models(model_policy: &Value, default_model: &str) -> Vec { let default_model = canonical_ai_model_ref(None, default_model).unwrap_or_else(|| DEFAULT_PI_MODEL.to_string()); 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) .filter_map(|value| canonical_ai_model_ref(None, value)) .collect::>(); if !model_ids.iter().any(|value| value == &default_model) { model_ids.insert(0, default_model.clone()); } let mut deduped = Vec::with_capacity(model_ids.len()); for model_id in model_ids { if !deduped.iter().any(|value| value == &model_id) { deduped.push(model_id); } } deduped .into_iter() .map(|id| { let name = id.clone(); ModelEntry { provider: "omniroute", is_default: id == default_model, id, name, enabled: true, } }) .collect() } fn normalize_model_ref(default_provider: Option<&str>, value: &str) -> Option { 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 canonical_ai_model_ref(default_provider: Option<&str>, value: &str) -> Option { let normalized = normalize_model_ref(default_provider, value)?; if normalized == RETIRED_FREEFIRST_FAST_MODEL { return Some(FREEFIRST_PI_MODEL.to_string()); } Some(normalized) } 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::>(); 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()); } 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::>(); 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::>(); 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::>(); 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::>(); 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::>(); 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::>(); 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::>(); mnote_tool_names.sort(); let mnote_tool_policies = effective_tools .iter() .map(|tool| (tool.name.to_string(), tool.default_policy.to_string())) .collect::>(); EffectiveAiRuntimePolicy { default_model: normalize_model_ref(None, &default_model).unwrap_or(default_model), allowed_models, pi_runtime: model_policy .get("piRuntime") .or_else(|| model_policy.get("advancedRuntime")) .cloned() .unwrap_or_else(|| json!({})), 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 { 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::>(); 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 { 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 { let mut parts = Vec::new(); let mut current = String::new(); let mut quote: Option = 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 } fn policy_map(model_policy: &Value, key: &str) -> HashMap 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 { 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) -> Vec { 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, 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, workspace_id: Option<&str>, ) -> Vec { 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::>(); 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} 未被管理员全局启用")); } } } 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} 未被管理员全局启用")); } } } 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::>(); 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::>(); 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::>(); 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::>(); 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, "piExtensions": pi_extensions, }) } 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"); } if body.pi_extensions.is_some() { fields.push("piExtensionOverrides"); } 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(()) } 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(()) } /// 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::(&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); } // ── 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. 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); } // ── Replace MCP servers map ───────────────────────────────────── // // Same contract as skills: admin sends the full editable registry, // including deletions. 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); } 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); } 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::(&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 = 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 = 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::>() }) }) .unwrap_or_default(); let skills = effective_skill_registry(model_policy); let mcp_servers = effective_mcp_registry(model_policy); let pi_extensions = effective_pi_extension_registry(model_policy); 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, pi_extensions, 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"); } if body.pi_extensions.is_some() { fields.push("piExtensions"); } 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![], pi_extensions: vec![], 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); } #[test] fn effective_models_canonicalize_retired_freefirst_fast_alias() { let policy = json!({ "defaultModel": "omniroute/freefirst-fast", "allowedModels": ["omniroute/freefirst-fast", "omniroute/freefirst", "omniroute/gpt-5.4-mini"] }); assert_eq!(effective_default_model(&policy), "omniroute/freefirst"); let models = effective_models(&policy, "omniroute/freefirst-fast"); let ids = models .iter() .map(|model| model.id.as_str()) .collect::>(); assert_eq!(ids, vec!["omniroute/freefirst", "omniroute/gpt-5.4-mini"]); 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, pi_extensions: None, 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, pi_extensions: None, 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("a_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, pi_extensions: None, 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(), ), 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(), ), 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); 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()); } // ─── 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")); 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")); } #[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 } }, "piExtensions": { "pi-rust-official-todo": { "name": "Pi Rust Official Todo", "enabled": true, "source": "pi-rust-official:todo", "toolNames": ["todo"] } } }); let quota = json!({"tokens": 5000}); let response = project_admin_settings(&policy, "a, 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")); assert!(response.pi_extensions.contains_key("pi-rust-official-todo")); 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, pi_extensions: None, 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()), pi_extensions: Some(HashMap::new()), quota: Some(json!({})), }; let fields = describe_updated_fields(&body); assert_eq!(fields.len(), 9); } #[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} }, "piExtensions": { "pi-rust-official-subagent": {"name": "Pi Rust Official Subagent", "enabled": true} } }); let user = json!({ "defaultModel": "omniroute/fast", "allowedModels": ["omniroute/fast"], "skillOverrides": {"search": false, "context7": false}, "mcpOverrides": {"docs": false, "context7": false}, "piExtensionOverrides": {"pi-rust-official-subagent": false, "pi-rust-official-todo": false} }); 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); assert_eq!(merged["skills"]["context7"]["enabled"], false); assert_eq!(merged["mcpServers"]["docs"]["enabled"], false); 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 ); } #[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()), pi_extensions: Some([("pi-rust-official-subagent".into(), true)].into()), }; 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, pi_extensions: None, }; assert!(validate_user_policy_body(&body, &global).is_ok()); } }