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

3483 lines
122 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, &quota, policy.as_ref());
Ok(Json(response))
}
/// `PUT /api/ai-admin/settings`
///
/// Upserts the AI policy document. The client provides the desired
/// policy sections (providers, models, tools, skills, MCP, build-plan,
/// quota). `allowed_roots` is never accepted from the client — the
/// source of truth is `directory_grants`.
///
/// Validation rules:
/// - Provider `secretRef` MUST start with `env://` or `secret://`
/// (raw API keys rejected).
/// - MCP servers MUST set `facadeOnly: true` and `sandbox: true`.
///
/// On success, appends an audit log entry and returns the new revision.
pub async fn admin_put_settings(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<UpsertAiPolicyBody>,
) -> Result<Json<AdminAiSettingsUpsertResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
let global_owner = global_policy_owner_id(&actor_id);
// ── Validate provider secretRefs ──────────────────────────────────
if let Some(ref providers) = body.providers {
for (provider_id, config) in providers {
validate_provider_secret_ref(&config.secret_ref).map_err(|msg| {
WebError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"ai_admin_invalid_secret_ref",
format!("Provider \"{provider_id}\": {msg}"),
)
.with_context(&context)
})?;
}
}
// ── Validate MCP server configs ───────────────────────────────────
if let Some(ref servers) = body.mcp_servers {
for (server_id, config) in servers {
validate_mcp_server_config(config).map_err(|msg| {
WebError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"ai_admin_invalid_mcp_config",
format!("MCP server \"{server_id}\": {msg}"),
)
.with_context(&context)
})?;
}
}
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(&quota_json).unwrap();
assert_eq!(quota_parsed["tokens"], 2000);
}
#[test]
fn merge_policy_tools_override() {
let body = UpsertAiPolicyBody {
default_model: None,
allowed_models: None,
workspace_id: None,
providers: None,
build_plan_task: None,
tools: Some([("mnote.local_file.read".into(), "deny".into())].into()),
skills: None,
mcp_servers: None,
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, &quota, None);
assert_eq!(response.default_model, "omniroute/premium");
assert_eq!(response.allowed_models.len(), 2);
assert!(response.providers.contains_key("omniroute"));
assert_eq!(
response.tools.get("mnote.local_file.patch").unwrap(),
"deny"
);
assert!(response.skills.contains_key("img-skill"));
assert!(response.mcp_servers.contains_key("fs-mcp"));
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());
}
}