3483 lines
122 KiB
Rust
3483 lines
122 KiB
Rust
//! 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>,
|
||
pub pi_extensions: Vec<PiExtensionConfig>,
|
||
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>>,
|
||
#[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,
|
||
}
|
||
|
||
// ─── 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>>,
|
||
/// Pi package/extension registry.
|
||
#[serde(default)]
|
||
pub pi_extensions: Option<HashMap<String, PiExtensionConfig>>,
|
||
/// 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>,
|
||
}
|
||
|
||
#[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 pi_runtime: Value,
|
||
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 = 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<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>,
|
||
pub pi_extensions: HashMap<String, PiExtensionConfig>,
|
||
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();
|
||
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<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)
|
||
}
|
||
|
||
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,
|
||
})))
|
||
}
|
||
|
||
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",
|
||
})))
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// ─── 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, "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<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)
|
||
})?;
|
||
}
|
||
}
|
||
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<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)
|
||
};
|
||
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<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);
|
||
}
|
||
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<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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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"
|
||
)
|
||
}
|
||
|
||
// ─── 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() {
|
||
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<ModelEntry> {
|
||
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::<Vec<_>>();
|
||
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<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 canonical_ai_model_ref(default_provider: Option<&str>, value: &str) -> Option<String> {
|
||
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::<Vec<_>>();
|
||
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::<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,
|
||
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<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
|
||
}
|
||
|
||
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} 未被管理员全局启用"));
|
||
}
|
||
}
|
||
}
|
||
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::<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<_>>();
|
||
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<_>>();
|
||
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,
|
||
"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::<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);
|
||
}
|
||
|
||
// ── 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::<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);
|
||
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::<Vec<_>>();
|
||
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());
|
||
}
|
||
}
|