Files
mnote/rust/crates/mnote-web/src/routes/page_ai_pi.rs
T
Agent Board d47f6447fd fix: restore pi rust builtin tool surface
- expose Pi Rust built-in tools by permission mode instead of replacing them with MNote file tools
- update Pi Lab smoke coverage for native ls/read/bash usage
- document the overreplacement regression and verification evidence
2026-07-11 20:34:21 +08:00

9525 lines
350 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.
//! Pi-first Page AI Lab — MNote 托管的后端垂直切片。
//!
//! 该模块默认启用 MNote 托管的 Pi Rust Page AI 后端;OpenHub 仅保留为迁移期兼容 / admin 边界。
//! Pi 进程通过 RPC subprocess 托管,MNote bridge tools 在 Rust 后端按 allowed roots 执行权限校验。
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::knowledge_rag as knowledge_rag_agent_output;
use crate::routes::{ai_settings, knowledge_rag, local_folder_source};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use control_plane::{
AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput, ControlPlaneStore,
UpsertAiRuntimeRunInput,
};
use futures_util::stream::{self, StreamExt};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use std::convert::Infallible;
use std::fs::{self, OpenOptions};
use std::io::{Read as _, Write as _};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, Command};
use tokio::sync::{broadcast, oneshot, Mutex as AsyncMutex};
use tokio_stream::wrappers::BroadcastStream;
const PI_LAB_VERSION: &str = "0.1.0-pi-lab-spike";
const PI_LAB_PROVIDER: &str = "pi";
const PI_LAB_SCHEMA_STATUS: &str = "mnote.page_ai_pi.status.v1";
const PI_LAB_SCHEMA_RECEIPT: &str = "mnote.page_ai_pi.tool_receipt.v1";
const PI_LAB_SCHEMA_EVENT: &str = "mnote.page_ai_pi.event.v1";
const PI_LAB_SCHEMA_STATE: &str = "mnote.page_ai_pi.state.v1";
const PI_LAB_SCHEMA_COMPACT: &str = "mnote.page_ai_pi.compact.v1";
const PI_LAB_SCHEMA_QUEUE_CONFIG: &str = "mnote.page_ai_pi.queue_config.v1";
const PI_LAB_DEFAULT_MODEL_PROVIDER: &str = "omniroute";
const PI_LAB_SCHEMA_SESSION_TREE: &str = "mnote.page_ai_pi.session_tree.v1";
const PI_LAB_SCHEMA_FORK: &str = "mnote.page_ai_pi.fork.v1";
const PI_LAB_SCHEMA_ARTIFACT_DIFF: &str = "mnote.page_ai_pi.artifact_diff.v1";
const PI_LAB_DEFAULT_MODEL_ID: &str = "gpt-5.4-mini";
const PI_LAB_DEFAULT_OMNIROUTE_BASE_URL: &str = "http://127.0.0.1:20128/v1";
const HEADER_PI_LAB_BRIDGE_TOKEN: &str = "x-mnote-pi-lab-bridge-token";
const PI_LAB_SESSION_TTL_MS: u128 = 30 * 60 * 1000;
const PI_LAB_MAX_SESSIONS: usize = 16;
const PI_LAB_RATE_WINDOW_MS: u128 = 10_000;
const PI_LAB_MAX_STARTS_PER_WINDOW: usize = 4;
const PI_LAB_MAX_SENDS_PER_WINDOW: usize = 12;
const PI_LAB_MAX_TOOLS_PER_WINDOW: usize = 40;
const PI_LAB_RUNTIME_IMPL_RUST: &str = "pi-rust";
const PI_LAB_RUNTIME_IMPL_TS: &str = "pi-ts";
const PI_LAB_MANAGED_BUILTIN_TOOLS: [&str; 8] = [
"read",
"write",
"edit",
"bash",
"grep",
"find",
"ls",
"hashline_edit",
];
const PI_LAB_MCP_CACHE_FILE: &str = "mcp-cache.json";
const PI_LAB_MCP_BRIDGE_TIMEOUT_MS: u64 = 45_000;
const PI_LAB_MCP_BRIDGE_MAX_OUTPUT_BYTES: usize = 2 * 1024 * 1024;
pub const PI_LAB_PROFILE: &str = "pi_lab";
pub const PI_LAB_ACP_RUNTIME: &str = "pi";
static PI_LAB_SESSIONS: LazyLock<StdMutex<HashMap<String, PiLabSession>>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
static PI_LAB_PROCESSES: LazyLock<StdMutex<HashMap<String, PiLabProcessHandle>>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
static PI_LAB_RECEIPT_STORE: LazyLock<StdMutex<Vec<PiLabToolReceipt>>> =
LazyLock::new(|| StdMutex::new(Vec::new()));
static PI_LAB_RATE_LIMITS: LazyLock<StdMutex<HashMap<String, Vec<u128>>>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
static PI_LAB_APPROVALS: LazyLock<StdMutex<HashMap<String, PiLabPendingApproval>>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
static PI_LAB_UI_RESPONSES: LazyLock<StdMutex<HashMap<String, PiLabUiPendingResponse>>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
static PI_LAB_EVENT_TX: LazyLock<broadcast::Sender<Value>> = LazyLock::new(|| {
let (tx, _) = broadcast::channel(1024);
tx
});
static PI_LAB_PENDING_RPC_RESPONSES: LazyLock<AsyncMutex<HashMap<String, oneshot::Sender<Value>>>> =
LazyLock::new(|| AsyncMutex::new(HashMap::new()));
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PiLabSessionStatus {
Idle,
RuntimeRunning,
TurnRunning,
Aborted,
Error,
}
fn status_after_agent_end(status: &PiLabSessionStatus) -> PiLabSessionStatus {
match status {
PiLabSessionStatus::Aborted => PiLabSessionStatus::Aborted,
PiLabSessionStatus::Error => PiLabSessionStatus::Error,
PiLabSessionStatus::Idle
| PiLabSessionStatus::RuntimeRunning
| PiLabSessionStatus::TurnRunning => PiLabSessionStatus::RuntimeRunning,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabSession {
pub session_id: String,
pub mnote_user_id: String,
#[serde(skip_serializing, skip_deserializing)]
pub bridge_token: String,
pub status: PiLabSessionStatus,
pub provider_session_id: String,
pub pi_session_dir: String,
pub pi_session_file: Option<String>,
pub root_uri: Option<String>,
pub workspace_id: Option<String>,
pub page_path: Option<String>,
pub page_title: Option<String>,
pub model_provider: Option<String>,
pub model_id: Option<String>,
#[serde(default)]
pub thinking_level: Option<String>,
pub allowed_roots_snapshot: Option<Value>,
#[serde(default)]
pub runtime_policy_snapshot: Option<Value>,
pub runtime_pid: Option<u32>,
pub runtime_mode: String,
pub runtime_error: Option<String>,
pub created_at_ms: u128,
pub updated_at_ms: u128,
pub message_count: u64,
}
#[derive(Clone)]
struct PiLabProcessHandle {
stdin: Arc<AsyncMutex<ChildStdin>>,
child: Arc<AsyncMutex<Child>>,
_pid: Option<u32>,
}
#[derive(Debug, Clone)]
struct PiLabPendingApproval {
session_id: String,
approval_id: String,
tool_name: String,
params_hash: String,
confirmed: bool,
cancelled: bool,
expires_at_ms: u128,
}
#[derive(Debug, Clone)]
struct PiLabUiPendingResponse {
value: Option<Value>,
confirmed: Option<bool>,
cancelled: bool,
expires_at_ms: u128,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabToolReceipt {
pub schema: &'static str,
pub receipt_id: String,
pub mnote_user_id: String,
pub workspace_id: Option<String>,
pub root_uri: Option<String>,
pub page_path: Option<String>,
pub session_id: String,
pub tool_name: String,
pub normalized_file_path: Option<String>,
pub allowed: bool,
pub deny_reason: Option<String>,
pub diff_summary: Option<String>,
pub before_file_version: Option<String>,
pub after_file_version: Option<String>,
pub provider: &'static str,
pub provider_session_id: Option<String>,
pub model_provider: Option<String>,
pub model_id: Option<String>,
pub allowed_roots_snapshot: Option<Value>,
pub storage: String,
pub created_at_ms: u128,
}
#[derive(Debug, Clone)]
struct AllowedRoot {
root_uri: Option<String>,
root_path: PathBuf,
workspace_id: Option<String>,
permission: String,
source: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabBootstrapRequest {
pub prompt: Option<String>,
pub root_uri: Option<String>,
pub workspace_id: Option<String>,
pub page_path: Option<String>,
pub page_title: Option<String>,
pub model_provider: Option<String>,
pub model_id: Option<String>,
pub thinking_level: Option<String>,
pub permission_mode: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabStateRequest {
pub session_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabCompactRequest {
pub session_id: String,
pub custom_instructions: Option<String>,
pub reserve_tokens: Option<u64>,
pub keep_recent_tokens: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabQueueConfigRequest {
pub session_id: String,
pub steering_mode: Option<String>,
pub follow_up_mode: Option<String>,
pub auto_compaction: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabStartRequest {
pub session_id: Option<String>,
pub root_uri: Option<String>,
pub workspace_id: Option<String>,
pub page_path: Option<String>,
pub page_title: Option<String>,
pub model_provider: Option<String>,
pub model_id: Option<String>,
pub thinking_level: Option<String>,
pub permission_mode: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabSendRequest {
pub session_id: String,
pub message: String,
pub streaming_behavior: Option<String>,
pub root_uri: Option<String>,
pub workspace_id: Option<String>,
pub page_path: Option<String>,
pub page_title: Option<String>,
pub folder_path: Option<String>,
pub context_refs: Option<Vec<String>>,
pub selected_context: Option<Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabAbortRequest {
pub session_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabRenameSessionRequest {
pub title: String,
pub workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabUiResponseRequest {
pub session_id: String,
#[serde(alias = "requestId")]
pub id: String,
pub method: Option<String>,
pub value: Option<Value>,
pub confirmed: Option<bool>,
pub cancelled: Option<bool>,
#[serde(default)]
pub mnote_approval: Option<Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabUiRequestBridgeRequest {
pub session_id: String,
pub id: String,
pub method: String,
pub title: Option<String>,
pub message: Option<String>,
pub timeout_ms: Option<u64>,
#[serde(default)]
pub mnote_approval: Option<Value>,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabMcpBridgeRequest {
pub session_id: String,
pub server: String,
pub mode: String,
pub tool: Option<String>,
pub arguments: Option<Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabEventsQuery {
pub session_id: Option<String>,
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabToolCallRequest {
pub session_id: Option<String>,
pub tool_name: String,
#[serde(default)]
pub params: Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabListSessionsQuery {
pub workspace_id: Option<String>,
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub struct PiLabSessionPathParam {
pub session_id: String,
}
fn now_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0)
}
fn generate_id(prefix: &str) -> String {
format!("{prefix}_{:x}_{:x}", now_ms(), random_suffix())
}
fn generate_bridge_token() -> String {
let mut bytes = [0_u8; 24];
if let Ok(mut random) = fs::File::open("/dev/urandom") {
if random.read_exact(&mut bytes).is_ok() {
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
encoded.push_str(&format!("{byte:02x}"));
}
return format!("pi_bridge_{encoded}");
}
}
generate_id("pi_bridge_fallback")
}
fn random_suffix() -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
SystemTime::now().hash(&mut hasher);
std::thread::current().id().hash(&mut hasher);
hasher.finish()
}
fn canonical_json_string(value: &Value) -> String {
match value {
Value::Null => "null".into(),
Value::Bool(value) => value.to_string(),
Value::Number(value) => value.to_string(),
Value::String(value) => serde_json::to_string(value).unwrap_or_else(|_| "\"\"".into()),
Value::Array(items) => {
let body = items
.iter()
.map(canonical_json_string)
.collect::<Vec<_>>()
.join(",");
format!("[{body}]")
}
Value::Object(map) => {
let mut keys = map.keys().collect::<Vec<_>>();
keys.sort();
let body = keys
.into_iter()
.map(|key| {
let encoded_key = serde_json::to_string(key).unwrap_or_else(|_| "\"\"".into());
let encoded_value = canonical_json_string(&map[key]);
format!("{encoded_key}:{encoded_value}")
})
.collect::<Vec<_>>()
.join(",");
format!("{{{body}}}")
}
}
}
fn pi_lab_tool_params_hash(params: &Value) -> String {
let mut normalized = params.clone();
if let Value::Object(map) = &mut normalized {
map.remove("mnoteApproval");
map.remove("mnote_approval");
}
format!("{:x}", stable_hash(&canonical_json_string(&normalized)))
}
fn stable_hash(value: &str) -> u64 {
let mut hash = 5381_u32;
for unit in value.encode_utf16() {
hash = ((hash << 5).wrapping_add(hash)).wrapping_add(u32::from(unit));
}
u64::from(hash)
}
fn approval_key(session_id: &str, approval_id: &str) -> String {
format!("{session_id}:{approval_id}")
}
fn enabled(state: &AppState) -> bool {
state.config().enable_page_ai_pi_lab
}
fn ensure_enabled(state: &AppState) -> Result<(), WebError> {
if enabled(state) {
return Ok(());
}
Err(WebError::new(
StatusCode::NOT_FOUND,
"page_ai_pi_lab_disabled",
"Pi Lab 已被 MNOTE_PAGE_AI_PI_LAB=0 强制关闭",
))
}
fn hash_auth_identity(value: &str) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
value.hash(&mut hasher);
format!("auth:{:016x}", hasher.finish())
}
fn pi_actor_id(state: &AppState, context: &RequestContext) -> Option<String> {
crate::routes::gateway::current_actor_id(state, context).or_else(|| {
context
.auth
.authorization
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(hash_auth_identity)
})
}
fn ensure_authenticated(state: &AppState, context: &RequestContext) -> Result<String, WebError> {
if let Some(actor_id) = pi_actor_id(state, context) {
return Ok(actor_id);
}
if let Some(raw_token) = context
.auth
.cookie_header
.as_deref()
.and_then(|_| crate::routes::gateway::current_actor_id(state, context))
{
return Ok(raw_token);
}
if context.auth.cookie_header.is_some() {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"page_ai_pi_lab_invalid_session_cookie",
"Pi Lab 需要有效 MNote session cookie,不能用任意 Cookie 头访问",
)
.with_context(context));
}
Err(WebError::new(
StatusCode::UNAUTHORIZED,
"page_ai_pi_lab_unauthorized",
"Pi Lab 需要 MNote 登录态后访问",
)
.with_context(context))
}
fn ensure_session_owner(
state: &AppState,
context: &RequestContext,
session: &PiLabSession,
) -> Result<(), WebError> {
let actor_id = ensure_authenticated(state, context)?;
if session.mnote_user_id == actor_id {
return Ok(());
}
Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_session_owner_mismatch",
"Pi Lab session 不属于当前登录主体",
)
.with_context(context))
}
fn file_path_from_root_uri(root_uri: &str) -> Option<PathBuf> {
let value = root_uri.trim();
let path = value.strip_prefix("file://")?;
(!path.is_empty()).then(|| PathBuf::from(path))
}
fn active_allowed_roots(
state: &AppState,
context: &RequestContext,
) -> Result<Vec<AllowedRoot>, WebError> {
let actor_id = ensure_authenticated(state, context)?;
let mut roots: Vec<AllowedRoot> = state
.control_plane()
.list_directory_grants_for_actor(actor_id.trim())
.unwrap_or_default()
.into_iter()
.filter(|grant| grant.status.trim() == "active")
.filter_map(|grant| {
let root_path = if let Some(path) = file_path_from_root_uri(&grant.root_uri) {
path
} else if !grant.root_path.trim().is_empty() {
PathBuf::from(grant.root_path.trim())
} else {
return None;
};
Some(AllowedRoot {
root_uri: (!grant.root_uri.trim().is_empty()).then(|| grant.root_uri.clone()),
root_path,
workspace_id: grant
.workspace_id
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
permission: grant.permission,
source: grant.source,
})
})
.collect();
if roots.is_empty() {
roots.extend(dev_allowed_roots_from_env());
}
if roots.is_empty() {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_allowed_roots_empty",
"当前用户没有可用于 Pi Lab 的 allowed roots;请先通过 Turso/libSQL control-plane 授权目录,或仅在 dev 下设置 MNOTE_PI_LAB_ALLOWED_ROOTS",
)
.with_context(context));
}
Ok(roots)
}
fn dev_allowed_roots_from_env() -> Vec<AllowedRoot> {
let Some(raw) = std::env::var("MNOTE_PI_LAB_ALLOWED_ROOTS")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
else {
return Vec::new();
};
if let Ok(values) = serde_json::from_str::<Vec<String>>(&raw) {
return values
.into_iter()
.filter_map(|value| dev_allowed_root_from_string(&value))
.collect();
}
let delimiter = if raw.contains(';') { ';' } else { ':' };
raw.split(delimiter)
.filter_map(dev_allowed_root_from_string)
.collect()
}
fn dev_allowed_root_from_string(value: &str) -> Option<AllowedRoot> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let (root_uri, root_path) = if trimmed.starts_with("file://") {
(Some(trimmed.to_string()), file_path_from_root_uri(trimmed)?)
} else {
let root_path = PathBuf::from(trimmed);
let normalized = canonical_or_parent(&root_path);
(
Some(format!("file://{}", normalized.to_string_lossy())),
root_path,
)
};
Some(AllowedRoot {
root_uri,
root_path,
workspace_id: None,
permission: "write".into(),
source: "MNOTE_PI_LAB_ALLOWED_ROOTS".into(),
})
}
fn root_can_write(root: &AllowedRoot) -> bool {
let permission = root.permission.to_ascii_lowercase();
permission.contains("write") || permission == "owner"
}
fn canonical_or_parent(path: &Path) -> PathBuf {
if let Ok(canonical) = path.canonicalize() {
return canonical;
}
if let Some(parent) = path.parent() {
if let Ok(canonical_parent) = parent.canonicalize() {
return canonical_parent.join(path.file_name().unwrap_or_default());
}
}
path.to_path_buf()
}
fn path_is_inside(path: &Path, root: &Path) -> bool {
let canonical_path = canonical_or_parent(path);
let canonical_root = canonical_or_parent(root);
canonical_path.starts_with(canonical_root)
}
fn resolve_root_relative_path(
state: &AppState,
context: &RequestContext,
root_uri: &str,
relative_path: &str,
require_write: bool,
) -> Result<PathBuf, WebError> {
let allowed = active_allowed_roots(state, context)?;
let requested_root_path =
file_path_from_root_uri(root_uri).map(|path| canonical_or_parent(&path));
let matched = allowed.iter().find(|root| {
root.root_uri
.as_deref()
.is_some_and(|candidate| candidate == root_uri)
});
let root = matched.or_else(|| {
let requested_root_path = requested_root_path.as_ref()?;
allowed
.iter()
.find(|root| path_is_inside(requested_root_path, &root.root_path))
});
let Some(root) = root else {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_root_not_allowed",
"请求 rootUri 不在当前 allowed roots 内",
)
.with_context(context));
};
let base_root_path = if matched.is_some() {
canonical_or_parent(&root.root_path)
} else {
requested_root_path.unwrap_or_else(|| canonical_or_parent(&root.root_path))
};
if require_write && !root_can_write(root) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_root_readonly",
"请求 rootUri 只有只读权限,不能执行 patch",
)
.with_context(context));
}
let target = canonical_or_parent(&base_root_path.join(relative_path));
if !path_is_inside(&target, &root.root_path) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_path_escape",
"文件路径不能越过 allowed root",
)
.with_context(context));
}
Ok(target)
}
fn resolve_file_path(
state: &AppState,
context: &RequestContext,
params: &Value,
session: Option<&PiLabSession>,
require_write: bool,
) -> Result<(PathBuf, Option<String>, Option<String>), WebError> {
let root_uri = params
.get("rootUri")
.or_else(|| params.get("root_uri"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| session.and_then(|session| session.root_uri.clone()));
let path = params
.get("path")
.or_else(|| params.get("relativePath"))
.or_else(|| params.get("relative_path"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| WebError::bad_request_code("page_ai_pi_lab_path_required", "缺少 path"))?;
if let Some(root_uri) = root_uri.as_deref() {
let folder_path = params
.get("folderPath")
.or_else(|| params.get("folder_path"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let relative_path = if let Some(folder_path) = folder_path {
let normalized_path = path.replace('\\', "/").trim_start_matches('/').to_string();
let normalized_folder = folder_path
.replace('\\', "/")
.trim_matches('/')
.to_string();
if normalized_folder.is_empty()
|| Path::new(&normalized_path).is_absolute()
|| normalized_path == normalized_folder
|| normalized_path.starts_with(&format!("{normalized_folder}/"))
{
normalized_path
} else {
format!("{normalized_folder}/{normalized_path}")
}
} else {
path.to_string()
};
let target =
resolve_root_relative_path(state, context, root_uri, &relative_path, require_write)?;
return Ok((target, Some(root_uri.to_string()), Some(relative_path)));
}
let requested = PathBuf::from(path);
if !requested.is_absolute() {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_absolute_path_required",
"未提供 rootUri 时 path 必须是绝对路径",
)
.with_context(context));
}
let allowed = active_allowed_roots(state, context)?;
let target = canonical_or_parent(&requested);
let allowed_root = allowed
.iter()
.find(|root| path_is_inside(&target, &root.root_path))
.ok_or_else(|| {
WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_path_not_allowed",
"文件路径不在当前 allowed roots 内",
)
.with_context(context)
})?;
if require_write && !root_can_write(allowed_root) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_path_readonly",
"目标文件所在 allowed root 只有只读权限",
)
.with_context(context));
}
let relative = target
.strip_prefix(canonical_or_parent(&allowed_root.root_path))
.ok()
.map(|value| value.to_string_lossy().to_string());
Ok((target, allowed_root.root_uri.clone(), relative))
}
fn managed_session_dir(
context: &RequestContext,
root_uri: Option<&str>,
session_id: &str,
) -> Result<PathBuf, WebError> {
let actor = pi_lab_actor_segment(&context.auth.actor_id);
if let Some(root_path) = root_uri.and_then(file_path_from_root_uri) {
return Ok(root_path
.join(".mnote")
.join("ai")
.join("pi-sessions")
.join(actor)
.join(session_id));
}
Ok(std::env::temp_dir()
.join("mnote-web")
.join("pi-lab")
.join(actor)
.join(session_id))
}
fn pi_lab_actor_segment(actor_id: &str) -> String {
let actor = actor_id.trim().replace(['/', '\\', ':'], "_");
if actor.is_empty() {
"anonymous".into()
} else {
actor
}
}
fn is_pi_lab_warmup_session_id(session_id: &str) -> bool {
let value = session_id.trim();
value.starts_with("pi_lab_dev_warm_") || value.starts_with("pi_lab_warm_")
}
fn session_from_request(
state: &AppState,
context: &RequestContext,
request: &PiLabStartRequest,
) -> Result<PiLabSession, WebError> {
let mnote_user_id = ensure_authenticated(state, context)?;
let session_id = request
.session_id
.clone()
.unwrap_or_else(|| generate_id("pi_lab"));
let session_dir = managed_session_dir(context, request.root_uri.as_deref(), &session_id)?;
let runtime_policy = ai_settings::load_effective_ai_runtime_policy(
state,
&mnote_user_id,
request.workspace_id.as_deref(),
);
let resolved_model = runtime_policy
.resolve_requested_model(
request.model_provider.as_deref(),
request.model_id.as_deref(),
)
.map_err(|message| {
WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_model_not_allowed",
message,
)
.with_context(context)
})?;
let default_thinking = default_thinking_level();
let thinking_level = normalize_thinking_level(
request
.thinking_level
.as_deref()
.or(Some(default_thinking.as_str())),
)
.map_err(|message| {
WebError::new(
StatusCode::BAD_REQUEST,
"page_ai_pi_lab_invalid_thinking_level",
message,
)
.with_context(context)
})?;
let permission_mode =
normalize_permission_mode(request.permission_mode.as_deref()).map_err(|message| {
WebError::new(
StatusCode::BAD_REQUEST,
"page_ai_pi_lab_invalid_permission_mode",
message,
)
.with_context(context)
})?;
let mut runtime_policy_snapshot =
serde_json::to_value(&runtime_policy).unwrap_or_else(|_| json!({}));
if let Some(permission_mode) = permission_mode.as_deref() {
runtime_policy_snapshot =
refresh_runtime_policy_permission_mode_value(runtime_policy_snapshot, permission_mode);
}
let now = now_ms();
Ok(PiLabSession {
session_id: session_id.clone(),
mnote_user_id,
bridge_token: generate_bridge_token(),
status: PiLabSessionStatus::Idle,
provider_session_id: generate_id("pi_provider"),
pi_session_dir: session_dir.to_string_lossy().to_string(),
pi_session_file: None,
root_uri: request.root_uri.clone(),
workspace_id: request.workspace_id.clone(),
page_path: request.page_path.clone(),
page_title: request.page_title.clone(),
model_provider: Some(resolved_model.provider),
model_id: Some(resolved_model.model_id),
thinking_level: Some(thinking_level),
runtime_pid: None,
runtime_mode: runtime_mode(),
runtime_error: None,
allowed_roots_snapshot: None,
runtime_policy_snapshot: Some(runtime_policy_snapshot),
created_at_ms: now,
updated_at_ms: now,
message_count: 0,
})
}
fn runtime_mode() -> String {
std::env::var("MNOTE_PAGE_AI_PI_LAB_RUNTIME")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "rpc".into())
}
fn env_trimmed(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn pi_binary() -> String {
if let Some(explicit) = env_trimmed("MNOTE_PAGE_AI_PI_BIN") {
return explicit;
}
if pi_runtime_impl() == PI_LAB_RUNTIME_IMPL_TS {
return env_trimmed("MNOTE_PAGE_AI_PI_TS_BIN").unwrap_or_else(|| "pi".into());
}
env_trimmed("MNOTE_PAGE_AI_PI_RUST_BIN")
.or_else(first_existing_pi_rust_binary)
.unwrap_or_else(|| "pi-rust".into())
}
fn pi_runtime_impl() -> String {
let raw = env_trimmed("MNOTE_PAGE_AI_PI_IMPL")
.or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_RUNTIME_IMPL"))
.unwrap_or_else(|| PI_LAB_RUNTIME_IMPL_RUST.into());
match raw.trim().to_ascii_lowercase().as_str() {
"ts" | "typescript" | "pi-ts" | "legacy-ts" => PI_LAB_RUNTIME_IMPL_TS.into(),
"rust" | "rs" | "pi-rust" | "pi_agent_rust" | "pi-agent-rust" => {
PI_LAB_RUNTIME_IMPL_RUST.into()
}
_ => PI_LAB_RUNTIME_IMPL_RUST.into(),
}
}
fn first_existing_pi_rust_binary() -> Option<String> {
let mut candidates = Vec::new();
if let Some(home) = env_trimmed("HOME") {
candidates.push(PathBuf::from(&home).join(".local/share/mnote/pi-rust/bin/pi"));
candidates.push(PathBuf::from(&home).join(".cargo/bin/pi-rust"));
candidates.push(PathBuf::from(&home).join(".cargo/bin/pi_agent_rust"));
}
candidates.extend(path_candidates("pi-rust"));
candidates.extend(path_candidates("pi_agent_rust"));
candidates
.into_iter()
.find(|path| path.is_file())
.map(|path| path.to_string_lossy().to_string())
}
fn path_candidates(binary: &str) -> Vec<PathBuf> {
std::env::var_os("PATH")
.map(|path| {
std::env::split_paths(&path)
.map(|dir| dir.join(binary))
.collect()
})
.unwrap_or_default()
}
fn pi_runtime_binary_available(binary: &str) -> bool {
let path = Path::new(binary);
if path.is_absolute() || path.components().count() > 1 {
return path.is_file();
}
path_candidates(binary)
.into_iter()
.any(|candidate| candidate.is_file())
}
fn pi_runtime_install_hint(runtime_impl: &str, binary: &str) -> Option<String> {
if runtime_mode() == "mock" || pi_runtime_binary_available(binary) {
return None;
}
if runtime_impl == PI_LAB_RUNTIME_IMPL_TS {
return Some(format!(
"未找到 Pi TS runtime: {binary}。请设置 MNOTE_PAGE_AI_PI_TS_BIN 或 MNOTE_PAGE_AI_PI_BIN。"
));
}
Some(format!(
"未找到 Pi Rust runtime: {binary}。请安装到 ~/.local/share/mnote/pi-rust/bin/pi,或设置 MNOTE_PAGE_AI_PI_RUST_BIN / MNOTE_PAGE_AI_PI_BIN。"
))
}
fn pi_runtime_status_snapshot() -> (String, String, bool, Option<String>) {
let runtime_impl = pi_runtime_impl();
let binary = pi_binary();
let available = runtime_mode() == "mock" || pi_runtime_binary_available(&binary);
let install_hint = if available {
None
} else {
pi_runtime_install_hint(&runtime_impl, &binary)
};
(runtime_impl, binary, available, install_hint)
}
fn pi_lab_start_response(session: &PiLabSession, reused: bool) -> Value {
let (runtime_impl, runtime_binary, runtime_available, runtime_install_hint) =
pi_runtime_status_snapshot();
let pi_extension_sources = pi_lab_runtime_extension_sources(session);
let pi_extension_tool_names = if pi_extension_sources.is_empty() {
Vec::new()
} else {
pi_lab_pi_extension_tool_names(session)
};
json!({
"ok": true,
"schema": "mnote.page_ai_pi.start.v1",
"session": session,
"managedPiBuiltinTools": pi_lab_enabled_builtin_tools(session),
"mnoteToolOnly": false,
"configuredPiExtensionSources": pi_lab_configured_extension_sources(session),
"piExtensionSources": pi_extension_sources,
"piExtensionToolNames": pi_extension_tool_names,
"thinkingLevel": session.thinking_level,
"permissionMode": session_permission_mode(session),
"runtimeImplementation": runtime_impl,
"runtimeBinary": runtime_binary,
"runtimeAvailable": runtime_available,
"runtimeInstallHint": runtime_install_hint,
"runtimeReused": reused,
})
}
fn pi_mcp_extension_source() -> Option<String> {
let value = env_trimmed("MNOTE_PAGE_AI_PI_MCP_EXTENSION")?;
let normalized = value.to_ascii_lowercase();
if matches!(
normalized.as_str(),
"0" | "false"
| "off"
| "disabled"
| "none"
| "1"
| "true"
| "yes"
| "on"
| "builtin"
| "mnote"
) {
return None;
}
Some(value)
}
fn string_array_from_policy(session: &PiLabSession, key: &str) -> Vec<String> {
session
.runtime_policy_snapshot
.as_ref()
.and_then(|value| value.get(key))
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect()
}
fn pi_lab_enabled_extension_ids(session: &PiLabSession) -> Vec<String> {
string_array_from_policy(session, "enabledPiExtensions")
}
fn pi_lab_configured_extension_sources(session: &PiLabSession) -> Vec<String> {
let mut sources = string_array_from_policy(session, "enabledPiExtensionSources");
if pi_lab_mcp_enabled(session) {
sources.push(pi_mcp_extension_source().unwrap_or_else(|| "mnote:mcp".into()));
}
sources.sort();
sources.dedup();
sources
}
fn pi_lab_external_configured_extension_sources(session: &PiLabSession) -> Vec<String> {
let mcp_source = pi_mcp_extension_source();
let mut sources = pi_lab_configured_extension_sources(session)
.into_iter()
.filter_map(|source| {
if source.starts_with("mnote:") {
return None;
}
if pi_lab_resolve_bundled_pi_rust_official_source(&source).is_some() {
return None;
}
if Some(source.as_str()) == mcp_source.as_deref() {
if pi_lab_mcp_enabled(session) {
return mcp_source.clone();
}
return None;
}
Some(source)
})
.collect::<Vec<_>>();
sources.sort();
sources.dedup();
sources
}
fn pi_lab_external_extensions_enabled(session: &PiLabSession) -> bool {
if env_trimmed("MNOTE_PAGE_AI_PI_ALLOW_EXTERNAL_EXTENSIONS")
.as_deref()
.is_some_and(|value| matches!(value, "1" | "true" | "yes" | "on"))
{
return true;
}
session
.runtime_policy_snapshot
.as_ref()
.and_then(|policy| {
policy
.get("allowExternalPiExtensions")
.or_else(|| policy.get("externalPiExtensionsEnabled"))
})
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn pi_lab_external_extension_allowlist() -> Vec<String> {
env_trimmed("MNOTE_PAGE_AI_PI_EXTENSION_ALLOWLIST")
.or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_EXTENSIONS_ALLOWLIST"))
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect()
}
fn pi_lab_deconflict_configured_extension_sources(mut sources: Vec<String>) -> Vec<String> {
sources.sort();
sources.dedup();
// 官方 plan-mode 与官方 todo 都会注册 /todos。Pi Rust 当前按重复注册直接失败,
// 所以在进入 runtime 前做能力去重;默认 todo 可用,只有显式启用 plan-mode 时才让 plan-mode 接管。
let has_plan_mode = sources
.iter()
.any(|source| source == "pi-rust-official:plan-mode");
if has_plan_mode {
sources.retain(|source| source != "pi-rust-official:todo");
}
sources
}
fn pi_lab_official_extension_tool_name_map() -> [(&'static str, &'static [&'static str]); 4] {
[
("pi-rust-official:question", &["question"]),
("pi-rust-official:questionnaire", &["questionnaire"]),
("pi-rust-official:subagent", &["subagent"]),
("pi-rust-official:todo", &["todo"]),
]
}
fn pi_lab_runtime_extension_sources(session: &PiLabSession) -> Vec<String> {
let mut configured_sources = pi_lab_deconflict_configured_extension_sources(
pi_lab_configured_extension_sources(session),
);
let mut sources = configured_sources
.drain(..)
.into_iter()
.filter_map(|source| pi_lab_resolve_bundled_pi_rust_official_source(&source))
.collect::<Vec<_>>();
if !pi_lab_external_extensions_enabled(session) {
sources.sort();
sources.dedup();
return sources;
}
let configured = pi_lab_external_configured_extension_sources(session);
let allowlist = pi_lab_external_extension_allowlist();
sources.extend(configured.into_iter().filter(|source| {
allowlist.is_empty() || allowlist.iter().any(|allowed| allowed == source)
}));
sources.sort();
sources.dedup();
sources
}
fn pi_lab_pi_extension_tool_names(session: &PiLabSession) -> Vec<String> {
let mut names = string_array_from_policy(session, "piExtensionToolNames");
if pi_lab_mcp_enabled(session) {
names.push("mcp".into());
} else {
names.retain(|name| name != "mcp");
}
names.sort();
names.dedup();
names
}
fn pi_lab_runtime_pi_extension_tool_names(session: &PiLabSession) -> Vec<String> {
let mut names = if pi_lab_runtime_extension_sources(session).is_empty() {
Vec::new()
} else {
pi_lab_pi_extension_tool_names(session)
};
let active_sources = pi_lab_deconflict_configured_extension_sources(
pi_lab_configured_extension_sources(session),
);
for (source, tool_names) in pi_lab_official_extension_tool_name_map() {
if !active_sources.iter().any(|active| active == source) {
names.retain(|name| !tool_names.iter().any(|tool_name| tool_name == name));
}
}
names.sort();
names.dedup();
names
}
fn pi_lab_managed_builtin_tools() -> Vec<String> {
PI_LAB_MANAGED_BUILTIN_TOOLS
.iter()
.map(|tool| (*tool).to_string())
.collect()
}
fn pi_lab_permission_system_enabled(session: &PiLabSession) -> bool {
if !pi_lab_external_extensions_enabled(session) {
return false;
}
pi_lab_enabled_extension_ids(session)
.iter()
.any(|id| id == "pi-permission-system")
|| pi_lab_runtime_extension_sources(session)
.iter()
.any(|source| source == "npm:@gotgenes/pi-permission-system")
}
fn pi_lab_official_permission_gate_enabled(session: &PiLabSession) -> bool {
pi_lab_enabled_extension_ids(session)
.iter()
.any(|id| id == "pi-rust-official-permission-gate")
|| pi_lab_runtime_extension_sources(session)
.iter()
.any(|source| {
source.ends_with("packages/pi-mnote/extensions/pi-rust-official/permission-gate.ts")
})
}
fn pi_lab_enabled_builtin_tools(session: &PiLabSession) -> Vec<String> {
match session_permission_mode(session) {
Some("full_access") => pi_lab_managed_builtin_tools(),
Some("auto_edit") => [
"read",
"write",
"edit",
"grep",
"find",
"ls",
"hashline_edit",
]
.iter()
.map(|tool| (*tool).to_string())
.collect(),
Some("plan") => ["read", "grep", "find", "ls"]
.iter()
.map(|tool| (*tool).to_string())
.collect(),
_ if pi_lab_permission_system_enabled(session) => pi_lab_managed_builtin_tools(),
_ => Vec::new(),
}
}
fn default_model_provider() -> String {
env_trimmed("MNOTE_PAGE_AI_PI_DEFAULT_MODEL_PROVIDER")
.unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_PROVIDER.into())
}
fn default_model_id() -> String {
env_trimmed("MNOTE_PAGE_AI_PI_DEFAULT_MODEL")
.or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_DEFAULT_MODEL_ID"))
.unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_ID.into())
}
fn default_thinking_level() -> String {
env_trimmed("MNOTE_PAGE_AI_PI_THINKING").unwrap_or_else(|| "medium".into())
}
fn normalize_thinking_level(value: Option<&str>) -> Result<String, String> {
let raw = value
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("medium")
.to_ascii_lowercase();
match raw.as_str() {
"off" | "minimal" | "low" | "medium" | "high" | "xhigh" => Ok(raw),
_ => Err(format!(
"Pi thinking level 只能是 off/minimal/low/medium/high/xhigh,当前为 {raw}"
)),
}
}
fn normalize_permission_mode(value: Option<&str>) -> Result<Option<String>, String> {
let Some(raw) = value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_ascii_lowercase)
else {
return Ok(None);
};
match raw.as_str() {
"confirm" | "ask" | "auto_edit" | "plan" | "full_access" => Ok(Some(raw)),
_ => Err(format!(
"Pi permission mode 只能是 confirm/ask/auto_edit/plan/full_access,当前为 {raw}"
)),
}
}
fn normalize_queue_mode(value: &str, field: &str) -> Result<String, WebError> {
let raw = value.trim();
let normalized = match raw
.chars()
.filter(|ch| !matches!(ch, '-' | '_' | ' '))
.collect::<String>()
.to_ascii_lowercase()
.as_str()
{
"all" => "all".to_string(),
"oneatatime" => "one-at-a-time".to_string(),
_ => {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_invalid_queue_mode",
format!("{field} 的值无效: \"{value}\"。支持的值: \"all\"\"one-at-a-time\"",),
));
}
};
Ok(normalized)
}
fn permission_mode_tool_policy(mode: Option<&str>, tool_name: &str, base_policy: &str) -> String {
match mode {
Some("plan") => match tool_name {
"mnote.current_page.read"
| "mnote.selection.read"
| "mnote.allowed_roots.describe"
| "mnote.local_file.read"
| "mnote.knowledge_rag.status"
| "mnote.knowledge_rag.query"
| "mnote.knowledge_rag.section_context"
| "mnote.knowledge_rag.open_reference"
| "mnote.reference.open"
| "mnote.tool_receipt.write" => "allow".into(),
_ => "deny".into(),
},
Some("auto_edit") => match tool_name {
"mnote.local_file.read" | "mnote.local_file.patch" => "allow".into(),
"mnote.codex_rescue.request" => "ask".into(),
_ => base_policy.into(),
},
Some("full_access") => {
if tool_name == "mnote.codex_rescue.request" {
"ask".into()
} else {
"allow".into()
}
}
Some("confirm") | Some("ask") => match tool_name {
"mnote.local_file.read" | "mnote.local_file.patch" | "mnote.codex_rescue.request" => {
"ask".into()
}
_ => base_policy.into(),
},
_ => base_policy.into(),
}
}
fn session_permission_mode(session: &PiLabSession) -> Option<&str> {
session
.runtime_policy_snapshot
.as_ref()
.and_then(|value| value.get("permissionMode"))
.or_else(|| {
session
.runtime_policy_snapshot
.as_ref()
.and_then(|value| value.get("permission_mode"))
})
.and_then(Value::as_str)
}
fn refresh_runtime_policy_permission_mode_value(mut policy: Value, mode: &str) -> Value {
policy["permissionMode"] = json!(mode);
let tool_names = policy
.get("mnoteToolNames")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut policies = serde_json::Map::new();
for tool_name in tool_names.iter().filter_map(Value::as_str) {
policies.insert(
tool_name.to_string(),
json!(permission_mode_tool_policy(Some(mode), tool_name, "allow")),
);
}
if !policies.is_empty() {
policy["mnoteToolPolicies"] = Value::Object(policies);
}
policy
}
fn refresh_runtime_policy_permission_mode(session: &PiLabSession, mode: &str) -> Value {
refresh_runtime_policy_permission_mode_value(
session
.runtime_policy_snapshot
.clone()
.unwrap_or_else(|| json!({})),
mode,
)
}
fn pi_lab_effective_prompt_for_session(session: &PiLabSession, message: &str) -> String {
if session_permission_mode(session) != Some("plan") {
return message.to_string();
}
format!(
"当前是 MNote Pi 计划模式。\n\
要求:先输出可执行计划和风险点;不要实际写入、删除、移动、重命名文件;不要执行 bash/write/edit/rm/mv/cp/mkdir 或 mnote.local_file.patch 等会改变环境的工具。\n\
允许:只读分析、读取当前页、读取授权目录、查询 LightRAG/MCP、列出需要用户确认的下一步。\n\
如果必须修改文件,请明确说明需要用户切换到“自动编辑”或“完全访问”后再执行。\n\n\
用户原始请求:\n{message}"
)
}
fn pi_lab_command_message_for_session(
session: &PiLabSession,
message: &str,
input_context_prefix: &str,
) -> String {
if !message.starts_with("/skill:") {
let contextual_message = format!("{input_context_prefix}{message}");
return pi_lab_effective_prompt_for_session(session, &contextual_message);
}
let command_end = message.find(char::is_whitespace).unwrap_or(message.len());
let command = &message[..command_end];
let skill_args = message[command_end..].trim_start_matches(char::is_whitespace);
let contextual_args = format!("{input_context_prefix}{skill_args}");
let effective_args = pi_lab_effective_prompt_for_session(session, &contextual_args);
format!("{command}\n{effective_args}")
}
fn omniroute_base_url() -> String {
env_trimmed("MNOTE_PAGE_AI_PI_OMNIROUTE_BASE_URL")
.or_else(|| env_trimmed("OMNIROUTE_BASE_URL"))
.unwrap_or_else(|| PI_LAB_DEFAULT_OMNIROUTE_BASE_URL.into())
}
fn omniroute_base_url_is_local() -> bool {
let value = omniroute_base_url().to_ascii_lowercase();
value.starts_with("http://127.0.0.1:")
|| value.starts_with("http://localhost:")
|| value.starts_with("http://0.0.0.0:")
}
fn configured_omniroute_api_key() -> Option<String> {
env_trimmed("MNOTE_PAGE_AI_PI_OMNIROUTE_API_KEY")
.or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_OPENAI_API_KEY"))
.or_else(|| env_trimmed("OPENAI_API_KEY"))
}
fn omniroute_api_key() -> Option<String> {
configured_omniroute_api_key().or_else(|| {
if omniroute_base_url_is_local() {
Some("mnote-local-omniroute".into())
} else {
None
}
})
}
fn omniroute_models_url() -> String {
format!("{}/models", omniroute_base_url().trim_end_matches('/'))
}
fn omniroute_models_api_key() -> Option<String> {
env_trimmed("MNOTE_PAGE_AI_PI_OMNIROUTE_API_KEY").or_else(|| {
(!omniroute_base_url_is_local())
.then(|| {
env_trimmed("MNOTE_PAGE_AI_PI_OPENAI_API_KEY")
.or_else(|| env_trimmed("OPENAI_API_KEY"))
})
.flatten()
})
}
fn omniroute_model_tool_calling_capability(catalog: &Value, model_id: &str) -> Option<bool> {
catalog
.get("data")
.and_then(Value::as_array)
.into_iter()
.flatten()
.find(|model| model.get("id").and_then(Value::as_str) == Some(model_id))
.map(|model| {
model
.get("capabilities")
.and_then(|capabilities| capabilities.get("tool_calling"))
.and_then(Value::as_bool)
.unwrap_or(false)
})
}
async fn ensure_session_model_supports_tools(session: &PiLabSession) -> Result<bool, WebError> {
let provider = session
.model_provider
.as_deref()
.unwrap_or(PI_LAB_DEFAULT_MODEL_PROVIDER);
if provider != "omniroute" || session.runtime_mode == "mock" {
return Ok(true);
}
let model_id = session
.model_id
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or(PI_LAB_DEFAULT_MODEL_ID);
let models_url = omniroute_models_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.map_err(|error| {
WebError::bad_gateway_code(
"page_ai_pi_model_capabilities_unavailable",
format!("创建 OmniRoute 模型能力检查客户端失败: {error}"),
)
})?;
let mut request = client.get(&models_url);
if let Some(api_key) = omniroute_models_api_key() {
request = request.bearer_auth(api_key);
}
let response = request.send().await.map_err(|error| {
WebError::bad_gateway_code(
"page_ai_pi_model_capabilities_unavailable",
format!("无法读取 OmniRoute 模型能力: {error}"),
)
.with_details(json!({
"provider": provider,
"modelId": model_id,
"modelsUrl": models_url,
}))
})?;
let status = response.status();
if !status.is_success() {
return Err(WebError::bad_gateway_code(
"page_ai_pi_model_capabilities_unavailable",
format!("OmniRoute 模型能力接口返回 {status}"),
)
.with_details(json!({
"provider": provider,
"modelId": model_id,
"modelsUrl": models_url,
})));
}
let catalog = response.json::<Value>().await.map_err(|error| {
WebError::bad_gateway_code(
"page_ai_pi_model_capabilities_unavailable",
format!("解析 OmniRoute 模型能力失败: {error}"),
)
.with_details(json!({
"provider": provider,
"modelId": model_id,
"modelsUrl": models_url,
}))
})?;
match omniroute_model_tool_calling_capability(&catalog, model_id) {
Some(true) => Ok(true),
Some(false) => Err(WebError::new(
StatusCode::BAD_REQUEST,
"page_ai_pi_model_tools_unsupported",
format!(
"模型 omniroute/{model_id} 不支持工具调用;Page AI 已启用 skill、扩展、MCP 与文件工具,请选择支持 tool_calling 的模型"
),
)
.with_details(json!({
"provider": provider,
"modelId": model_id,
"requiredCapability": "tool_calling",
"recommendedModel": format!("{PI_LAB_DEFAULT_MODEL_PROVIDER}/{PI_LAB_DEFAULT_MODEL_ID}"),
}))),
None => Err(WebError::new(
StatusCode::BAD_REQUEST,
"page_ai_pi_model_tools_unsupported",
format!("OmniRoute 模型目录中未找到模型 {model_id},无法确认工具调用能力"),
)
.with_details(json!({
"provider": provider,
"modelId": model_id,
"requiredCapability": "tool_calling",
"modelsUrl": models_url,
}))),
}
}
/// 为 Pi 子进程生成每 session 受控的 models.json。
/// - 写入 `<pi_session_dir>/config/models.json`
/// - API key 仅以环境变量名引用(OPENAI_API_KEY),不写入明文
/// - 调用方应将 `PI_CODING_AGENT_DIR` 设为返回的 config 目录
fn ensure_session_models_config(
session: &PiLabSession,
supports_tools: bool,
) -> Result<PathBuf, WebError> {
let config_dir = PathBuf::from(&session.pi_session_dir).join("config");
fs::create_dir_all(&config_dir).map_err(|error| {
WebError::internal(format!("创建 Pi Lab session config 目录失败: {error}"))
})?;
let models_path = config_dir.join("models.json");
let model_provider = session
.model_provider
.as_deref()
.unwrap_or(PI_LAB_DEFAULT_MODEL_PROVIDER);
let model_id = session
.model_id
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or(PI_LAB_DEFAULT_MODEL_ID);
let mut omniroute_model_ids = vec![model_id.to_string()];
for candidate in ["freefirst", PI_LAB_DEFAULT_MODEL_ID] {
if !omniroute_model_ids.iter().any(|id| id == candidate) {
omniroute_model_ids.push(candidate.to_string());
}
}
let omniroute_models = omniroute_model_ids
.iter()
.map(|id| {
json!({
"id": id,
"name": format!("OmniRoute {}", id),
"input": ["text"],
"reasoning": false,
"contextWindow": 128000,
"maxTokens": 65536,
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
})
})
.collect::<Vec<_>>();
let models = match model_provider {
"omniroute" => json!({
"providers": {
"omniroute": {
"baseUrl": omniroute_base_url(),
"api": "openai-completions",
"apiKey": "OPENAI_API_KEY",
"authHeader": true,
"compat": {
"supportsDeveloperRole": false,
"supportsReasoningEffort": false,
"supportsTools": supports_tools,
"supportsUsageInStreaming": true
},
"models": omniroute_models
}
}
}),
_ => json!({
"providers": {
model_provider: {
"models": [
{
"id": model_id,
"name": format!("{} {}", model_provider, model_id),
"input": ["text"],
"reasoning": false,
"contextWindow": 128000,
"maxTokens": 65536
}
]
}
}
}),
};
let pretty = serde_json::to_string_pretty(&models)
.map_err(|error| WebError::internal(format!("序列化 Pi Lab models.json 失败: {error}")))?;
fs::write(&models_path, pretty.as_bytes())
.map_err(|error| WebError::internal(format!("写入 Pi Lab models.json 失败: {error}")))?;
Ok(config_dir)
}
fn ensure_session_mcp_config(
session: &PiLabSession,
config_dir: &Path,
) -> Result<Option<PathBuf>, WebError> {
if !pi_lab_mcp_enabled(session) {
return Ok(None);
}
let Some(servers) = session
.runtime_policy_snapshot
.as_ref()
.and_then(|value| value.get("mcpServers"))
.and_then(Value::as_object)
.filter(|servers| !servers.is_empty())
else {
return Ok(None);
};
fs::create_dir_all(config_dir)
.map_err(|error| WebError::internal(format!("创建 Pi Lab MCP config 目录失败: {error}")))?;
let mcp_path = config_dir.join("mcp.json");
let payload = json!({
"settings": {
"toolPrefix": "server",
"directTools": false,
"outputGuard": true,
"requestTimeoutMs": 30000
},
"mcpServers": servers,
});
let pretty = serde_json::to_vec_pretty(&payload)
.map_err(|error| WebError::internal(format!("序列化 Pi Lab MCP config 失败: {error}")))?;
fs::write(&mcp_path, pretty)
.map_err(|error| WebError::internal(format!("写入 Pi Lab MCP config 失败: {error}")))?;
Ok(Some(mcp_path))
}
fn pi_lab_mcp_strict_lazy_enabled() -> bool {
env_trimmed("MNOTE_PAGE_AI_PI_MCP_STRICT_LAZY")
.map(|value| {
!matches!(
value.to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "disabled" | "no"
)
})
.unwrap_or(true)
}
fn shared_mcp_cache_path(session: &PiLabSession) -> Option<PathBuf> {
if !pi_lab_mcp_enabled(session) {
return None;
}
let actor = pi_lab_actor_segment(&session.mnote_user_id);
let base = session
.root_uri
.as_deref()
.and_then(file_path_from_root_uri)
.map(|root| root.join(".mnote").join("ai").join("pi-mcp-cache"))
.unwrap_or_else(|| {
std::env::temp_dir()
.join("mnote-web")
.join("pi-lab")
.join("mcp-cache")
});
Some(base.join(actor).join(PI_LAB_MCP_CACHE_FILE))
}
fn hydrate_session_mcp_cache(
session: &PiLabSession,
config_dir: &Path,
) -> Result<Option<PathBuf>, WebError> {
let Some(shared_cache_path) = shared_mcp_cache_path(session) else {
return Ok(None);
};
let session_cache_path = config_dir.join(PI_LAB_MCP_CACHE_FILE);
if session_cache_path.exists() {
return Ok(Some(shared_cache_path));
}
if shared_cache_path.exists() {
fs::copy(&shared_cache_path, &session_cache_path).map_err(|error| {
WebError::internal(format!(
"复制 Pi Lab MCP metadata cache 失败: {} -> {}: {error}",
shared_cache_path.display(),
session_cache_path.display()
))
})?;
return Ok(Some(shared_cache_path));
}
if pi_lab_mcp_strict_lazy_enabled() {
fs::write(&session_cache_path, br#"{"version":1,"servers":{}}"#).map_err(|error| {
WebError::internal(format!(
"初始化 Pi Lab MCP metadata cache 失败: {}: {error}",
session_cache_path.display()
))
})?;
}
Ok(Some(shared_cache_path))
}
fn schedule_mcp_cache_sync(session_id: String, config_dir: PathBuf, shared_cache_path: PathBuf) {
tokio::spawn(async move {
let session_cache_path = config_dir.join(PI_LAB_MCP_CACHE_FILE);
let mut last_hash = 0_u64;
for _ in 0..360 {
tokio::time::sleep(Duration::from_secs(5)).await;
if let Ok(bytes) = tokio::fs::read(&session_cache_path).await {
let hash = stable_hash(&String::from_utf8_lossy(&bytes));
if hash != last_hash {
if let Some(parent) = shared_cache_path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let tmp_path = shared_cache_path
.with_extension(format!("json.{}.tmp", std::process::id()));
if tokio::fs::write(&tmp_path, &bytes).await.is_ok()
&& tokio::fs::rename(&tmp_path, &shared_cache_path)
.await
.is_ok()
{
last_hash = hash;
} else {
let _ = tokio::fs::remove_file(&tmp_path).await;
}
}
}
let running = PI_LAB_PROCESSES
.lock()
.map(|processes| processes.contains_key(&session_id))
.unwrap_or(false);
if !running {
break;
}
}
});
}
fn shell_glob_escape_path(path: &str) -> String {
path.replace('\\', "\\\\")
.replace('[', "\\[")
.replace(']', "\\]")
.replace('{', "\\{")
.replace('}', "\\}")
}
fn allowed_root_paths(session: &PiLabSession) -> Vec<PathBuf> {
session
.allowed_roots_snapshot
.as_ref()
.and_then(|value| value.get("roots"))
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|root| {
root.get("rootPath")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
})
.collect()
}
fn primary_allowed_root_path(session: &PiLabSession) -> Option<PathBuf> {
let allowed_paths = allowed_root_paths(session);
let requested = session
.root_uri
.as_deref()
.and_then(file_path_from_root_uri);
if let Some(requested) = requested {
let requested = canonical_or_parent(&requested);
if allowed_paths.iter().any(|root| {
let root = canonical_or_parent(root);
path_is_inside(&requested, &root) || requested == root
}) {
return Some(requested);
}
}
allowed_paths.into_iter().find(|path| path.exists())
}
fn ensure_session_pi_permission_config(
session: &PiLabSession,
config_dir: &Path,
) -> Result<Option<PathBuf>, WebError> {
if !pi_lab_permission_system_enabled(session) {
return Ok(None);
}
let permission_dir = config_dir.join("extensions").join("pi-permission-system");
fs::create_dir_all(&permission_dir).map_err(|error| {
WebError::internal(format!(
"创建 Pi permission-system config 目录失败: {error}"
))
})?;
let mode = session_permission_mode(session).unwrap_or("confirm");
let (
default_path_rule,
allowed_root_rule,
default_mcp_rule,
read_rule,
write_rule,
edit_rule,
bash_rule,
) = match mode {
"plan" => ("deny", "allow", "ask", "allow", "deny", "deny", "deny"),
"auto_edit" => ("ask", "allow", "allow", "allow", "allow", "allow", "ask"),
"full_access" => (
"allow", "allow", "allow", "allow", "allow", "allow", "allow",
),
_ => ("ask", "ask", "ask", "allow", "ask", "ask", "ask"),
};
let mut path_rules = serde_json::Map::new();
path_rules.insert("*".into(), json!(default_path_rule));
path_rules.insert("*.env".into(), json!("deny"));
path_rules.insert("*.env.*".into(), json!("deny"));
path_rules.insert("*.env.example".into(), json!(default_path_rule));
let mut external_rules = serde_json::Map::new();
external_rules.insert("*".into(), json!(default_path_rule));
if let Some(roots) = session
.allowed_roots_snapshot
.as_ref()
.and_then(|value| value.get("roots"))
.and_then(Value::as_array)
{
for root in roots {
if let Some(root_path) = root
.get("rootPath")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
let pattern = format!(
"{}/*",
shell_glob_escape_path(root_path.trim_end_matches('/'))
);
path_rules.insert(
shell_glob_escape_path(root_path.trim_end_matches('/')),
json!(allowed_root_rule),
);
path_rules.insert(pattern.clone(), json!(allowed_root_rule));
external_rules.insert(
shell_glob_escape_path(root_path.trim_end_matches('/')),
json!(allowed_root_rule),
);
external_rules.insert(pattern, json!(allowed_root_rule));
}
}
}
let mut permission_rules = serde_json::Map::new();
permission_rules.insert("*".into(), json!("allow"));
permission_rules.insert("path".into(), Value::Object(path_rules));
permission_rules.insert("external_directory".into(), Value::Object(external_rules));
permission_rules.insert("read".into(), json!(read_rule));
permission_rules.insert("write".into(), json!(write_rule));
permission_rules.insert("edit".into(), json!(edit_rule));
permission_rules.insert("hashline_edit".into(), json!(edit_rule));
permission_rules.insert(
"grep".into(),
json!({
"*": read_rule
}),
);
permission_rules.insert(
"find".into(),
json!({
"*": read_rule
}),
);
permission_rules.insert(
"ls".into(),
json!({
"*": read_rule
}),
);
permission_rules.insert(
"bash".into(),
json!({
"*": bash_rule,
"cat *": read_rule,
"head *": read_rule,
"tail *": read_rule,
"sed -n *": read_rule,
"rg *": read_rule,
"grep *": read_rule,
"ls *": read_rule,
"pwd": "allow",
"git status": read_rule,
"git diff *": read_rule,
"git ls-files *": read_rule,
"rm *": bash_rule,
"rm -r *": bash_rule,
"rm -rf *": bash_rule,
"mv *": bash_rule,
"cp *": bash_rule,
"mkdir *": bash_rule
}),
);
permission_rules.insert(
"mcp".into(),
json!({
"*": default_mcp_rule
}),
);
for tool in pi_lab_tool_definitions_for_session(session) {
permission_rules.insert(
tool.pi_name.to_string(),
json!(pi_lab_session_tool_policy(session, tool.mnote_name)),
);
}
let config = json!({
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-packages/main/packages/pi-permission-system/schemas/permissions.schema.json",
"debugLog": false,
"permissionReviewLog": true,
"permission": Value::Object(permission_rules)
});
let config_path = permission_dir.join("config.json");
let pretty = serde_json::to_vec_pretty(&config).map_err(|error| {
WebError::internal(format!("序列化 Pi permission-system config 失败: {error}"))
})?;
fs::write(&config_path, pretty).map_err(|error| {
WebError::internal(format!("写入 Pi permission-system config 失败: {error}"))
})?;
Ok(Some(config_path))
}
fn pi_lab_mcp_enabled(session: &PiLabSession) -> bool {
let has_mcp_servers = session
.runtime_policy_snapshot
.as_ref()
.and_then(|value| value.get("mcpServers"))
.and_then(Value::as_object)
.is_some_and(|servers| !servers.is_empty());
if !has_mcp_servers {
return false;
}
let bridge_enabled_by_policy = session
.runtime_policy_snapshot
.as_ref()
.and_then(|value| value.get("mcpBridge"))
.and_then(Value::as_str)
.is_some_and(|value| {
matches!(
value,
"pi-rust-sync-client" | "mnote-backend" | "pi-extension"
)
});
bridge_enabled_by_policy || pi_mcp_extension_source().is_some()
}
fn pi_lab_public_base_url() -> String {
std::env::var("MNOTE_WEB_PUBLIC_BIND")
.or_else(|_| std::env::var("MNOTE_WEB_BIND"))
.ok()
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty())
.map(|value| {
if value.starts_with("http://") || value.starts_with("https://") {
value
} else {
format!("http://{value}")
}
})
.unwrap_or_else(|| "http://127.0.0.1:3000".into())
}
#[derive(Debug, Clone)]
struct PiLabToolDefinition {
pi_name: &'static str,
mnote_name: &'static str,
label: &'static str,
description: &'static str,
}
fn pi_lab_tool_definitions() -> Vec<PiLabToolDefinition> {
vec![
PiLabToolDefinition {
pi_name: "mnote_current_page_read",
mnote_name: "mnote.current_page.read",
label: "MNote current page read",
description: "Read the current MNote page through MNote access scope.",
},
PiLabToolDefinition {
pi_name: "mnote_selection_read",
mnote_name: "mnote.selection.read",
label: "MNote selection read",
description: "Read the current MNote editor selection snapshot supplied by MNote.",
},
PiLabToolDefinition {
pi_name: "mnote_allowed_roots_describe",
mnote_name: "mnote.allowed_roots.describe",
label: "MNote allowed roots describe",
description: "Describe MNote allowed roots and disabled raw tools.",
},
PiLabToolDefinition {
pi_name: "mnote_local_file_read",
mnote_name: "mnote.local_file.read",
label: "MNote local file read",
description: "Read a file only through MNote allowed roots.",
},
PiLabToolDefinition {
pi_name: "mnote_local_file_patch",
mnote_name: "mnote.local_file.patch",
label: "MNote local file patch",
description: "Patch a file only through MNote allowed roots and watcher refresh.",
},
PiLabToolDefinition {
pi_name: "mnote_knowledge_rag_status",
mnote_name: "mnote.knowledge_rag.status",
label: "MNote LightRAG status",
description: "Check the MNote LightRAG knowledge provider status, indexed source registry, dashboard URL, and sync state. Use before knowledge-base questions when availability is uncertain. Params: optional workspaceId/rootUri; MNote fills the current Pi session context when omitted.",
},
PiLabToolDefinition {
pi_name: "mnote_knowledge_rag_query",
mnote_name: "mnote.knowledge_rag.query",
label: "MNote LightRAG query",
description: "Ask the MNote LightRAG knowledge library across indexed books, papers, Office files, PDFs, images, and attachments. Use this for knowledge-base questions and answers that require sources, citations, or evidence. For book or long-document questions, pass query, mode='naive' or 'mix', topK, chunkTopK, includeChunkContent=true, and includeDocumentStructureIndex=true. Use returned references/citations quotes as evidence; do not invent page numbers or hand-write /documents/mnote:// links.",
},
PiLabToolDefinition {
pi_name: "mnote_knowledge_rag_section_context",
mnote_name: "mnote.knowledge_rag.section_context",
label: "MNote LightRAG section context",
description: "Read bounded section blocks/chunks from a LightRAG sidecar using documentStructureIndex ranges returned by mnote_knowledge_rag_query. Use this for second-pass reading of large books or long documents when query references are not enough. Params include sourcePath/sourceId/lightRagDocId/filePath/sectionId, block or paragraph ordinal range, contextBefore/contextAfter, maxBlocks, maxChars.",
},
PiLabToolDefinition {
pi_name: "mnote_knowledge_rag_open_reference",
mnote_name: "mnote.knowledge_rag.open_reference",
label: "MNote LightRAG open reference",
description: "Convert a LightRAG reference, filePath, or chunkId returned by mnote_knowledge_rag_query into a MNote clickable local resource locator. Use when the user asks to open or verify a cited source.",
},
PiLabToolDefinition {
pi_name: "mnote_reference_open",
mnote_name: "mnote.reference.open",
label: "MNote reference open",
description: "Legacy alias for opening a citation/reference through MNote mapping. Prefer mnote_knowledge_rag_open_reference for LightRAG references.",
},
PiLabToolDefinition {
pi_name: "mnote_codex_rescue_request",
mnote_name: "mnote.codex_rescue.request",
label: "MNote Codex rescue",
description: "Ask local Codex to rescue hard MNote/Pi problems before escalating to the user. Use when tools, skills, MCP, LightRAG, environment, or local repo behavior looks broken and normal Pi troubleshooting is insufficient. This tool is admin-gated, approval-gated by default, runs codex exec with workspace-write sandbox and a timeout, and returns Codex's final answer plus stdout/stderr snippets. Provide issue, evidence/logs, attempted steps, and desired outcome. Call at most once per unresolved incident; if Codex cannot fix it, summarize the blocker to the user.",
},
PiLabToolDefinition {
pi_name: "mnote_tool_receipt_write",
mnote_name: "mnote.tool_receipt.write",
label: "MNote tool receipt write",
description: "Write a provider-neutral MNote tool receipt.",
},
]
}
fn pi_lab_tool_definitions_for_session(session: &PiLabSession) -> Vec<PiLabToolDefinition> {
let Some(policy_tools) = session
.runtime_policy_snapshot
.as_ref()
.and_then(|value| value.get("mnoteToolNames"))
.and_then(Value::as_array)
else {
return pi_lab_tool_definitions();
};
let allowed = policy_tools
.iter()
.filter_map(Value::as_str)
.collect::<std::collections::HashSet<_>>();
pi_lab_tool_definitions()
.into_iter()
.filter(|tool| allowed.contains(tool.mnote_name))
.collect()
}
fn pi_lab_extension_tool_names(session: &PiLabSession) -> Vec<String> {
pi_lab_tool_definitions_for_session(session)
.into_iter()
.map(|tool| tool.pi_name.to_string())
.collect()
}
fn pi_lab_enabled_skill_sources(session: &PiLabSession) -> Vec<String> {
session
.runtime_policy_snapshot
.as_ref()
.and_then(|value| value.get("enabledSkillSources"))
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect()
}
fn pi_lab_skill_cli_args(session: &PiLabSession) -> Vec<String> {
let skill_sources = pi_lab_enabled_skill_sources(session);
if skill_sources.is_empty() {
return vec!["--no-skills".into()];
}
skill_sources
.into_iter()
.flat_map(|source| ["--skill".to_string(), source])
.collect()
}
fn pi_lab_session_allows_mnote_tool(session: &PiLabSession, tool_name: &str) -> bool {
let Some(policy_tools) = session
.runtime_policy_snapshot
.as_ref()
.and_then(|value| value.get("mnoteToolNames"))
.and_then(Value::as_array)
else {
return true;
};
policy_tools
.iter()
.filter_map(Value::as_str)
.any(|name| name == tool_name)
}
fn pi_lab_session_tool_policy(session: &PiLabSession, tool_name: &str) -> String {
let base_policy = session
.runtime_policy_snapshot
.as_ref()
.and_then(|value| value.get("mnoteToolPolicies"))
.and_then(Value::as_object)
.and_then(|policies| policies.get(tool_name))
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| "allow".into());
permission_mode_tool_policy(session_permission_mode(session), tool_name, &base_policy)
}
fn pi_lab_extract_approval(params: &Value) -> Option<&serde_json::Map<String, Value>> {
params
.get("mnoteApproval")
.or_else(|| params.get("mnote_approval"))
.and_then(Value::as_object)
}
fn cleanup_expired_approvals() {
let now = now_ms();
if let Ok(mut approvals) = PI_LAB_APPROVALS.lock() {
approvals.retain(|_, approval| approval.expires_at_ms >= now);
}
if let Ok(mut responses) = PI_LAB_UI_RESPONSES.lock() {
responses.retain(|_, response| response.expires_at_ms >= now);
}
}
fn ui_response_key(session_id: &str, request_id: &str) -> String {
format!("{session_id}:{request_id}")
}
fn rpc_response_key(session_id: &str, rpc_id: &str) -> String {
format!("{session_id}:{rpc_id}")
}
fn record_pending_approval_from_event(session_id: &str, payload: &Value) {
if payload.get("type").and_then(Value::as_str) != Some("extension_ui_request")
|| payload.get("method").and_then(Value::as_str) != Some("confirm")
{
return;
}
let Some(approval) = payload.get("mnoteApproval").and_then(Value::as_object) else {
return;
};
let Some(approval_id) = approval
.get("approvalId")
.or_else(|| approval.get("approval_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
let Some(tool_name) = approval
.get("toolName")
.or_else(|| approval.get("tool_name"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
let Some(params_hash) = approval
.get("paramsHash")
.or_else(|| approval.get("params_hash"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
cleanup_expired_approvals();
if let Ok(mut approvals) = PI_LAB_APPROVALS.lock() {
approvals.insert(
approval_key(session_id, approval_id),
PiLabPendingApproval {
session_id: session_id.into(),
approval_id: approval_id.into(),
tool_name: tool_name.into(),
params_hash: params_hash.into(),
confirmed: false,
cancelled: false,
expires_at_ms: now_ms().saturating_add(5 * 60 * 1000),
},
);
}
}
fn update_pending_approval_response(
session_id: &str,
approval: Option<&Value>,
confirmed: bool,
cancelled: bool,
) {
let Some(approval) = approval.and_then(Value::as_object) else {
return;
};
let Some(approval_id) = approval
.get("approvalId")
.or_else(|| approval.get("approval_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
cleanup_expired_approvals();
if let Ok(mut approvals) = PI_LAB_APPROVALS.lock() {
if let Some(pending) = approvals.get_mut(&approval_key(session_id, approval_id)) {
pending.confirmed = confirmed;
pending.cancelled = cancelled;
}
}
}
fn confirm_pending_approval(session_id: &str, approval: Option<&Value>) {
update_pending_approval_response(session_id, approval, true, false);
}
fn cancel_pending_approval(session_id: &str, approval: Option<&Value>) {
update_pending_approval_response(session_id, approval, false, true);
}
fn pending_approval_response(session_id: &str, approval_id: &str) -> Option<(bool, bool)> {
cleanup_expired_approvals();
PI_LAB_APPROVALS
.lock()
.ok()
.and_then(|approvals| {
approvals
.get(&approval_key(session_id, approval_id))
.cloned()
})
.map(|pending| (pending.confirmed, pending.cancelled))
}
fn pi_lab_tool_approval_confirmed(
session: Option<&PiLabSession>,
params: &Value,
tool_name: &str,
allow_bridge_approval: bool,
) -> bool {
let Some(session) = session else {
return false;
};
if !allow_bridge_approval {
return false;
}
let Some(approval) = pi_lab_extract_approval(params) else {
return false;
};
let confirmed = approval
.get("confirmed")
.or_else(|| approval.get("approved"))
.and_then(Value::as_bool)
.unwrap_or(false);
if !confirmed {
return false;
}
if !approval
.get("toolName")
.or_else(|| approval.get("tool_name"))
.and_then(Value::as_str)
.map(|name| name == tool_name)
.unwrap_or(true)
{
return false;
}
let Some(approval_id) = approval
.get("approvalId")
.or_else(|| approval.get("approval_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return false;
};
let expected_params_hash = pi_lab_tool_params_hash(params);
cleanup_expired_approvals();
PI_LAB_APPROVALS
.lock()
.ok()
.and_then(|approvals| {
approvals
.get(&approval_key(&session.session_id, approval_id))
.cloned()
})
.is_some_and(|pending| {
pending.session_id == session.session_id
&& pending.approval_id == approval_id
&& pending.tool_name == tool_name
&& pending.params_hash == expected_params_hash
&& pending.confirmed
&& pending.expires_at_ms >= now_ms()
})
}
fn store_pending_ui_response(
session_id: &str,
request_id: &str,
value: Option<Value>,
confirmed: Option<bool>,
cancelled: bool,
) {
let key = ui_response_key(session_id, request_id);
if let Ok(mut responses) = PI_LAB_UI_RESPONSES.lock() {
responses.insert(
key,
PiLabUiPendingResponse {
value,
confirmed,
cancelled,
expires_at_ms: now_ms().saturating_add(120_000),
},
);
}
}
fn take_pending_ui_response(session_id: &str, request_id: &str) -> Option<PiLabUiPendingResponse> {
let key = ui_response_key(session_id, request_id);
PI_LAB_UI_RESPONSES
.lock()
.ok()
.and_then(|mut responses| responses.remove(&key))
}
fn mnote_pi_extension_path() -> PathBuf {
if let Some(path) = env_trimmed("MNOTE_PAGE_AI_PI_MNOTE_EXTENSION") {
return PathBuf::from(path);
}
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../packages/pi-mnote/extensions/mnote-bridge.ts")
}
fn mnote_pi_mcp_extension_path() -> PathBuf {
if let Some(path) = env_trimmed("MNOTE_PAGE_AI_PI_MCP_BUILTIN_EXTENSION") {
return PathBuf::from(path);
}
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../packages/pi-mnote/extensions/mnote-mcp/index.ts")
}
fn mnote_pi_mcp_client_path() -> PathBuf {
if let Some(path) = env_trimmed("MNOTE_PAGE_AI_PI_MCP_CLIENT") {
return PathBuf::from(path);
}
mnote_pi_mcp_extension_path()
.parent()
.map(|path| path.join("client.mjs"))
.unwrap_or_else(|| PathBuf::from("client.mjs"))
}
fn pi_rust_official_extension_path(name: &str) -> Option<PathBuf> {
let file_name = match name {
"question" => "question.ts",
"questionnaire" => "questionnaire.ts",
"todo" => "todo.ts",
"permission-gate" => "permission-gate.ts",
"plan-mode" => "plan-mode/index.ts",
"subagent" => "subagent/index.ts",
_ => return None,
};
let env_slug = name
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_uppercase()
} else {
'_'
}
})
.collect::<String>();
let env_name = format!("MNOTE_PAGE_AI_PI_OFFICIAL_{env_slug}_EXTENSION");
if let Some(path) = env_trimmed(&env_name) {
return Some(PathBuf::from(path));
}
Some(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../packages/pi-mnote/extensions/pi-rust-official")
.join(file_name),
)
}
fn pi_lab_resolve_bundled_pi_rust_official_source(source: &str) -> Option<String> {
let name = source.strip_prefix("pi-rust-official:")?.trim();
pi_rust_official_extension_path(name).map(|path| path.to_string_lossy().to_string())
}
fn pi_lab_official_extension_slug_from_source(source: &str) -> String {
source
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
fn copy_dir_recursive(source_dir: &Path, target_dir: &Path) -> Result<(), WebError> {
fs::create_dir_all(target_dir).map_err(|error| {
WebError::internal(format!(
"创建 Pi 扩展 staging 目录失败: {}: {error}",
target_dir.display()
))
})?;
for entry in fs::read_dir(source_dir).map_err(|error| {
WebError::internal(format!(
"读取 Pi 扩展目录失败: {}: {error}",
source_dir.display()
))
})? {
let entry = entry
.map_err(|error| WebError::internal(format!("读取 Pi 扩展目录项失败: {error}")))?;
let source_path = entry.path();
let target_path = target_dir.join(entry.file_name());
let file_type = entry.file_type().map_err(|error| {
WebError::internal(format!(
"读取 Pi 扩展目录项类型失败: {}: {error}",
source_path.display()
))
})?;
if file_type.is_dir() {
copy_dir_recursive(&source_path, &target_path)?;
} else if file_type.is_file() {
fs::copy(&source_path, &target_path).map_err(|error| {
WebError::internal(format!(
"复制 Pi 扩展文件失败: {} -> {}: {error}",
source_path.display(),
target_path.display()
))
})?;
}
}
Ok(())
}
fn stage_extension_file(
source_path: &Path,
stage_root: &Path,
slug: &str,
) -> Result<String, WebError> {
let file_name = source_path.file_name().ok_or_else(|| {
WebError::internal(format!("Pi 扩展路径缺少文件名: {}", source_path.display()))
})?;
let target_dir = stage_root.join(slug);
fs::create_dir_all(&target_dir).map_err(|error| {
WebError::internal(format!(
"创建 Pi 扩展 staging 目录失败: {}: {error}",
target_dir.display()
))
})?;
let target_path = target_dir.join(file_name);
fs::copy(source_path, &target_path).map_err(|error| {
WebError::internal(format!(
"复制 Pi 扩展文件失败: {} -> {}: {error}",
source_path.display(),
target_path.display()
))
})?;
Ok(target_path.to_string_lossy().to_string())
}
fn bind_mnote_bridge_context_path(extension_path: &str, context_path: &Path) -> Result<(), WebError> {
let target = Path::new(extension_path);
let source = fs::read_to_string(target).map_err(|error| {
WebError::internal(format!(
"读取 staged MNote Pi bridge 扩展失败: {}: {error}",
target.display()
))
})?;
let canonical_context_path = context_path
.canonicalize()
.unwrap_or_else(|_| context_path.to_path_buf());
let context_literal = serde_json::to_string(&canonical_context_path.to_string_lossy().to_string())
.map_err(|error| WebError::internal(format!("编码 Pi Rust MNote context 路径失败: {error}")))?;
let context_payload: Value = serde_json::from_slice(&fs::read(context_path).map_err(|error| {
WebError::internal(format!(
"读取 Pi Rust MNote context 失败: {}: {error}",
context_path.display()
))
})?)
.map_err(|error| WebError::internal(format!("解析 Pi Rust MNote context 失败: {error}")))?;
let context_snapshot_literal = serde_json::to_string(&context_payload)
.map_err(|error| WebError::internal(format!("编码 Pi Rust MNote context 快照失败: {error}")))?;
let context_file_needle = r#"const DEFAULT_CONTEXT_FILE = path.join(EXTENSION_DIR, "mnote-context.json");"#;
let context_snapshot_needle =
r#"const EMBEDDED_CONTEXT: Record<string, unknown> | undefined = undefined;"#;
if !source.contains(context_file_needle) || !source.contains(context_snapshot_needle) {
return Err(WebError::internal(
"staged MNote Pi bridge 扩展缺少 context 绑定点",
));
}
let next = source.replace(
context_file_needle,
&format!("const DEFAULT_CONTEXT_FILE = {context_literal};"),
)
.replace(
context_snapshot_needle,
&format!(
"const EMBEDDED_CONTEXT: Record<string, unknown> | undefined = {context_snapshot_literal};"
),
);
fs::write(target, next).map_err(|error| {
WebError::internal(format!(
"写入 staged MNote Pi bridge 扩展失败: {}: {error}",
target.display()
))
})?;
Ok(())
}
fn pi_mnote_context_path(session: &PiLabSession) -> PathBuf {
PathBuf::from(&session.pi_session_dir)
.join("config")
.join("runtime-extensions")
.join("mnote-bridge")
.join("mnote-context.json")
}
fn pi_mnote_context_payload(
session: &PiLabSession,
selected_context: Option<&Value>,
context_refs: Option<&[String]>,
) -> Value {
json!({
"schema": "mnote.pi.context.v1",
"runtimeImplementation": pi_runtime_impl(),
"sessionId": session.session_id,
"bridgeBaseUrl": pi_lab_public_base_url(),
"rootUri": session.root_uri,
"workspaceId": session.workspace_id,
"pagePath": session.page_path,
"pageTitle": session.page_title,
"modelProvider": session.model_provider,
"modelId": session.model_id,
"thinkingLevel": session.thinking_level,
"primaryRootPath": primary_allowed_root_path(session),
"allowedRoots": session.allowed_roots_snapshot,
"toolPolicies": mnote_pi_tool_policies(session),
"selectedContext": selected_context.cloned().unwrap_or(Value::Null),
"contextRefs": context_refs.unwrap_or(&[]),
"updatedAtMs": now_ms(),
})
}
fn write_pi_mnote_context_snapshot(
session: &PiLabSession,
selected_context: Option<&Value>,
context_refs: Option<&[String]>,
) -> Result<PathBuf, WebError> {
let context_path = pi_mnote_context_path(session);
if let Some(parent) = context_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
WebError::internal(format!("创建 Pi Rust MNote context 目录失败: {error}"))
})?;
}
let mut payload = pi_mnote_context_payload(session, selected_context, context_refs);
if let Some(object) = payload.as_object_mut() {
object.insert(
"bridgeToken".to_string(),
Value::String(session.bridge_token.clone()),
);
}
let bytes = serde_json::to_vec_pretty(&payload).map_err(|error| {
WebError::internal(format!("序列化 Pi Rust MNote context 失败: {error}"))
})?;
let temp_path = context_path.with_extension("json.tmp");
fs::write(&temp_path, bytes)
.map_err(|error| WebError::internal(format!("写入 Pi Rust MNote context 失败: {error}")))?;
#[cfg(unix)]
{
let mut permissions = fs::metadata(&temp_path)
.map_err(|error| {
WebError::internal(format!("读取 Pi Rust MNote context 权限失败: {error}"))
})?
.permissions();
permissions.set_mode(0o600);
fs::set_permissions(&temp_path, permissions).map_err(|error| {
WebError::internal(format!("收紧 Pi Rust MNote context 权限失败: {error}"))
})?;
}
fs::rename(&temp_path, &context_path)
.map_err(|error| WebError::internal(format!("提交 Pi Rust MNote context 失败: {error}")))?;
Ok(context_path)
}
fn pi_mnote_input_context_prefix(
session: &PiLabSession,
selected_context: Option<&Value>,
context_refs: Option<&[String]>,
) -> Result<String, WebError> {
let payload = pi_mnote_context_payload(session, selected_context, context_refs);
let bytes = serde_json::to_vec(&payload).map_err(|error| {
WebError::internal(format!("序列化 Pi Rust input context 失败: {error}"))
})?;
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
use std::fmt::Write as _;
write!(&mut encoded, "{byte:02x}").map_err(|error| {
WebError::internal(format!("编码 Pi Rust input context 失败: {error}"))
})?;
}
Ok(format!("[[MNOTE_PI_CONTEXT_V1:{encoded}]]\n"))
}
fn stage_extension_tree(
entry_path: &Path,
stage_root: &Path,
slug: &str,
) -> Result<String, WebError> {
let source_dir = entry_path.parent().ok_or_else(|| {
WebError::internal(format!("Pi 扩展入口缺少父目录: {}", entry_path.display()))
})?;
let file_name = entry_path.file_name().ok_or_else(|| {
WebError::internal(format!("Pi 扩展入口缺少文件名: {}", entry_path.display()))
})?;
let target_dir = stage_root.join(slug);
copy_dir_recursive(source_dir, &target_dir)?;
Ok(target_dir.join(file_name).to_string_lossy().to_string())
}
fn stage_pi_extension_source(source: &str, stage_root: &Path) -> Result<String, WebError> {
let source_path = PathBuf::from(source);
if !source_path.exists() {
return Ok(source.to_string());
}
let slug = pi_lab_official_extension_slug_from_source(source);
if source_path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == "index.ts" || name == "index.js")
{
return stage_extension_tree(&source_path, stage_root, &slug);
}
stage_extension_file(&source_path, stage_root, &slug)
}
fn stage_pi_lab_runtime_extension_sources(
session: &PiLabSession,
stage_root: &Path,
) -> Result<Vec<String>, WebError> {
let mut staged = Vec::new();
for source in pi_lab_runtime_extension_sources(session) {
staged.push(stage_pi_extension_source(&source, stage_root)?);
}
staged.sort();
staged.dedup();
Ok(staged)
}
fn stage_project_mcp_config_for_extension(
extension_source: Option<&str>,
mcp_config_path: &Option<PathBuf>,
) -> Result<(), WebError> {
let (Some(extension_source), Some(mcp_config_path)) = (extension_source, mcp_config_path)
else {
return Ok(());
};
let extension_dir = Path::new(extension_source).parent().ok_or_else(|| {
WebError::internal(format!("Pi MCP 扩展入口缺少父目录: {extension_source}"))
})?;
let project_mcp_dir = extension_dir.join(".pi");
fs::create_dir_all(&project_mcp_dir).map_err(|error| {
WebError::internal(format!(
"创建 Pi MCP project config 目录失败: {}: {error}",
project_mcp_dir.display()
))
})?;
fs::copy(mcp_config_path, project_mcp_dir.join("mcp.json")).map_err(|error| {
WebError::internal(format!(
"写入 Pi MCP project config 失败: {}: {error}",
project_mcp_dir.display()
))
})?;
Ok(())
}
fn mnote_pi_tool_manifest(session: &PiLabSession) -> Value {
Value::Array(
pi_lab_tool_definitions_for_session(session)
.into_iter()
.map(|tool| {
json!({
"piName": tool.pi_name,
"mnoteName": tool.mnote_name,
"label": tool.label,
"description": tool.description,
})
})
.collect(),
)
}
fn mnote_pi_tool_policies(session: &PiLabSession) -> Value {
let mut policies = serde_json::Map::new();
for tool in pi_lab_tool_definitions_for_session(session) {
policies.insert(
tool.mnote_name.to_string(),
json!(pi_lab_session_tool_policy(session, tool.mnote_name)),
);
}
Value::Object(policies)
}
fn publish_event(session_id: &str, kind: &str, payload: Value) {
let event = json!({
"schema": PI_LAB_SCHEMA_EVENT,
"sessionId": session_id,
"kind": kind,
"createdAtMs": now_ms(),
"payload": payload,
});
let _ = PI_LAB_EVENT_TX.send(event);
}
fn upsert_session(session: PiLabSession) {
if let Ok(mut sessions) = PI_LAB_SESSIONS.lock() {
sessions.insert(session.session_id.clone(), session);
}
}
fn update_session<F>(session_id: &str, f: F)
where
F: FnOnce(&mut PiLabSession),
{
if let Ok(mut sessions) = PI_LAB_SESSIONS.lock() {
if let Some(session) = sessions.get_mut(session_id) {
f(session);
session.updated_at_ms = now_ms();
}
}
}
fn get_session(session_id: &str) -> Option<PiLabSession> {
PI_LAB_SESSIONS
.lock()
.ok()
.and_then(|sessions| sessions.get(session_id).cloned())
}
fn session_has_process(session_id: &str) -> bool {
PI_LAB_PROCESSES
.lock()
.map(|processes| processes.contains_key(session_id))
.unwrap_or(false)
}
fn session_runtime_is_usable(session: &PiLabSession) -> bool {
matches!(
session.status,
PiLabSessionStatus::RuntimeRunning | PiLabSessionStatus::TurnRunning
) && (session.runtime_mode == "mock" || session_has_process(&session.session_id))
}
fn session_can_auto_resume(session: &PiLabSession) -> bool {
session.runtime_error.is_none() && session_runtime_is_usable(session)
}
fn session_runtime_config_matches(existing: &PiLabSession, requested: &PiLabSession) -> bool {
existing.model_provider == requested.model_provider
&& existing.model_id == requested.model_id
&& existing.thinking_level == requested.thinking_level
&& session_permission_mode(existing) == session_permission_mode(requested)
}
fn refresh_runtime_session_context(existing: &mut PiLabSession, requested: &PiLabSession) {
existing.root_uri = requested.root_uri.clone();
existing.workspace_id = requested.workspace_id.clone();
existing.page_path = requested.page_path.clone();
existing.page_title = requested.page_title.clone();
existing.allowed_roots_snapshot = requested.allowed_roots_snapshot.clone();
existing.runtime_policy_snapshot = requested.runtime_policy_snapshot.clone();
existing.runtime_mode = requested.runtime_mode.clone();
existing.runtime_error = None;
}
fn remove_session(session_id: &str) -> bool {
PI_LAB_SESSIONS
.lock()
.map(|mut sessions| sessions.remove(session_id).is_some())
.unwrap_or(false)
}
async fn kill_process_handle(handle: PiLabProcessHandle) {
let mut child = handle.child.lock().await;
let _ = child.kill().await;
let _ = child.wait().await;
}
async fn kill_session_process(session_id: &str) -> bool {
let handle = PI_LAB_PROCESSES
.lock()
.ok()
.and_then(|mut processes| processes.remove(session_id));
if let Some(handle) = handle {
kill_process_handle(handle).await;
true
} else {
false
}
}
fn cleanup_expired_sessions() {
let cutoff = now_ms().saturating_sub(PI_LAB_SESSION_TTL_MS);
let expired = PI_LAB_SESSIONS
.lock()
.map(|mut sessions| {
let mut expired = sessions
.iter()
.filter_map(|(session_id, session)| {
(session.updated_at_ms < cutoff
&& !matches!(
session.status,
PiLabSessionStatus::RuntimeRunning | PiLabSessionStatus::TurnRunning
))
.then(|| (session_id.clone(), session.pi_session_dir.clone()))
})
.collect::<Vec<_>>();
for (session_id, _) in &expired {
sessions.remove(session_id);
}
if sessions.len() > PI_LAB_MAX_SESSIONS {
let mut removable = sessions
.values()
.filter(|session| {
!matches!(
session.status,
PiLabSessionStatus::RuntimeRunning | PiLabSessionStatus::TurnRunning
)
})
.map(|session| {
(
session.updated_at_ms,
session.session_id.clone(),
session.pi_session_dir.clone(),
)
})
.collect::<Vec<_>>();
removable.sort_by_key(|(updated_at, _, _)| *updated_at);
for (_, session_id, session_dir) in removable
.into_iter()
.take(sessions.len().saturating_sub(PI_LAB_MAX_SESSIONS))
{
sessions.remove(&session_id);
expired.push((session_id, session_dir));
}
}
expired
})
.unwrap_or_default();
if !expired.is_empty() {
let mut handles = Vec::new();
if let Ok(mut processes) = PI_LAB_PROCESSES.lock() {
for (session_id, _) in &expired {
if let Some(handle) = processes.remove(session_id) {
handles.push(handle);
}
}
}
for handle in handles {
tokio::spawn(kill_process_handle(handle));
}
for (_, session_dir) in expired {
let path = PathBuf::from(session_dir);
if path
.components()
.any(|component| component.as_os_str() == "pi-sessions")
{
let _ = fs::remove_dir_all(path);
}
}
}
}
fn check_rate_limit(actor_id: &str, action: &str, limit: usize) -> Result<(), WebError> {
let now = now_ms();
let key = format!("{actor_id}:{action}");
if let Ok(mut buckets) = PI_LAB_RATE_LIMITS.lock() {
let bucket = buckets.entry(key).or_default();
bucket.retain(|timestamp| now.saturating_sub(*timestamp) <= PI_LAB_RATE_WINDOW_MS);
if bucket.len() >= limit {
return Err(WebError::new(
StatusCode::TOO_MANY_REQUESTS,
"page_ai_pi_lab_rate_limited",
format!("Pi Lab {action} 请求过于频繁,请稍后再试"),
));
}
bucket.push(now);
buckets.retain(|_, timestamps| !timestamps.is_empty());
}
Ok(())
}
fn pi_lab_start_rate_limit(state: &AppState) -> usize {
if state.config().allow_dev_fixtures {
24
} else {
PI_LAB_MAX_STARTS_PER_WINDOW
}
}
fn get_session_for_context(
state: &AppState,
context: &RequestContext,
session_id: &str,
) -> Result<PiLabSession, WebError> {
let session = get_session(session_id).ok_or_else(|| {
WebError::bad_request_code("page_ai_pi_lab_session_not_found", "Pi Lab session 不存在")
})?;
ensure_session_owner(state, context, &session)?;
Ok(session)
}
fn bridge_token_from_headers(headers: &HeaderMap) -> Option<&str> {
headers
.get(HEADER_PI_LAB_BRIDGE_TOKEN)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn get_session_for_bridge(headers: &HeaderMap, session_id: &str) -> Result<PiLabSession, WebError> {
let session = get_session(session_id).ok_or_else(|| {
WebError::bad_request_code("page_ai_pi_lab_session_not_found", "Pi Lab session 不存在")
})?;
if !matches!(
session.status,
PiLabSessionStatus::RuntimeRunning | PiLabSessionStatus::TurnRunning
) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_bridge_session_not_running",
"Pi Lab internal tool bridge 只允许运行中的 session 调用",
));
}
if bridge_token_from_headers(headers) == Some(session.bridge_token.as_str()) {
return Ok(session);
}
Err(WebError::new(
StatusCode::UNAUTHORIZED,
"page_ai_pi_lab_bridge_token_invalid",
"Pi Lab internal tool bridge token 无效",
))
}
fn context_for_session(mut context: RequestContext, session: &PiLabSession) -> RequestContext {
context.auth.actor_id = session.mnote_user_id.clone();
context.auth.actor_type = "user".into();
context
}
async fn start_runtime_for_session(
state: &AppState,
mut session: PiLabSession,
) -> Result<PiLabSession, WebError> {
fs::create_dir_all(&session.pi_session_dir)
.map_err(|error| WebError::internal(format!("创建 Pi Lab sessionDir 失败: {error}")))?;
let model_supports_tools = ensure_session_model_supports_tools(&session).await?;
let pi_config_dir = ensure_session_models_config(&session, model_supports_tools)?;
let mcp_config_path = ensure_session_mcp_config(&session, &pi_config_dir)?;
let shared_mcp_cache_path = hydrate_session_mcp_cache(&session, &pi_config_dir)?;
let permission_config_path = ensure_session_pi_permission_config(&session, &pi_config_dir)?;
let mnote_pi_extension_source_path = mnote_pi_extension_path();
if !mnote_pi_extension_source_path.exists() {
return Err(WebError::internal(format!(
"MNote Pi package extension 不存在: {}",
mnote_pi_extension_source_path.display()
)));
}
let extension_stage_dir = pi_config_dir.join("runtime-extensions");
let mnote_pi_extension_path = stage_extension_file(
&mnote_pi_extension_source_path,
&extension_stage_dir,
"mnote-bridge",
)?;
let mnote_context_path = write_pi_mnote_context_snapshot(&session, None, None)?;
bind_mnote_bridge_context_path(&mnote_pi_extension_path, &mnote_context_path)?;
let enabled_builtin_tools = pi_lab_enabled_builtin_tools(&session);
let configured_extension_sources = pi_lab_configured_extension_sources(&session);
let mut pi_extension_sources =
stage_pi_lab_runtime_extension_sources(&session, &extension_stage_dir)?;
let mut mcp_extension_runtime_path = None;
if pi_lab_mcp_enabled(&session) {
let mcp_extension_source = if let Some(custom_source) = pi_mcp_extension_source() {
let allowlist = pi_lab_external_extension_allowlist();
if !pi_lab_external_extensions_enabled(&session)
|| !allowlist.iter().any(|allowed| allowed == &custom_source)
{
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_mcp_extension_not_allowed",
"自定义 Pi MCP 扩展必须显式启用外部扩展,并加入 MNOTE_PAGE_AI_PI_EXTENSION_ALLOWLIST。",
));
}
PathBuf::from(custom_source)
} else {
mnote_pi_mcp_extension_path()
};
if !mcp_extension_source.exists() {
return Err(WebError::internal(format!(
"MNote Pi Rust MCP extension 不存在: {}",
mcp_extension_source.display()
)));
}
let staged_mcp_extension = stage_pi_extension_source(
&mcp_extension_source.to_string_lossy(),
&extension_stage_dir,
)?;
pi_extension_sources.push(staged_mcp_extension.clone());
mcp_extension_runtime_path = Some(staged_mcp_extension);
pi_extension_sources.sort();
pi_extension_sources.dedup();
}
stage_project_mcp_config_for_extension(
mcp_extension_runtime_path.as_deref(),
&mcp_config_path,
)?;
let bridge_base_url = pi_lab_public_base_url();
let pi_extension_tool_names = pi_lab_runtime_pi_extension_tool_names(&session);
let mut pi_tool_names = pi_lab_extension_tool_names(&session);
pi_tool_names.extend(pi_extension_tool_names.clone());
pi_tool_names.extend(enabled_builtin_tools.clone());
pi_tool_names.sort();
pi_tool_names.dedup();
if session.runtime_mode == "mock" {
session.status = PiLabSessionStatus::RuntimeRunning;
session.runtime_pid = None;
upsert_session(session.clone());
persist_upsert_run(state, &session)?;
// 扫描 pi_session_file
if session.pi_session_file.is_none() {
if let Some(pi_file) = resolve_pi_session_file(&session) {
session.pi_session_file = Some(pi_file);
upsert_session(session.clone());
let _ = persist_upsert_run(state, &session);
}
}
persist_append_event(
state,
&session,
"runtime_started",
&json!({
"mode": "mock",
"providerSessionId": session.provider_session_id,
"modelProvider": session.model_provider,
"modelId": session.model_id,
"runtimePolicy": session.runtime_policy_snapshot,
"mcpConfigPath": mcp_config_path.clone(),
"sharedMcpCachePath": shared_mcp_cache_path.clone(),
"permissionConfigPath": permission_config_path.clone(),
"configuredPiExtensionSources": configured_extension_sources.clone(),
"piExtensionSources": pi_extension_sources.clone(),
"piExtensionToolNames": pi_extension_tool_names.clone(),
"mnotePiToolManifest": mnote_pi_tool_manifest(&session),
"piToolNames": pi_tool_names.clone(),
"managedBuiltinTools": enabled_builtin_tools.clone(),
}),
)?;
publish_event(
&session.session_id,
"runtime_started",
json!({
"mode": "mock",
"providerSessionId": session.provider_session_id,
"modelProvider": session.model_provider,
"modelId": session.model_id,
"runtimePolicy": session.runtime_policy_snapshot,
"piCodingAgentDir": pi_config_dir,
"mnotePiExtension": mnote_pi_extension_path,
"mcpConfigPath": mcp_config_path,
"sharedMcpCachePath": shared_mcp_cache_path,
"permissionConfigPath": permission_config_path,
"configuredPiExtensionSources": configured_extension_sources,
"piExtensionSources": pi_extension_sources,
"piExtensionToolNames": pi_extension_tool_names,
"mnotePiToolManifest": mnote_pi_tool_manifest(&session),
"piToolNames": pi_tool_names,
"managedBuiltinTools": enabled_builtin_tools,
}),
);
return Ok(session);
}
let runtime_impl = pi_runtime_impl();
let binary = pi_binary();
if !pi_runtime_binary_available(&binary) {
let message = pi_runtime_install_hint(&runtime_impl, &binary)
.unwrap_or_else(|| format!("Pi runtime 不可用: {binary}"));
session.status = PiLabSessionStatus::Error;
session.runtime_error = Some(message.clone());
upsert_session(session.clone());
let _ = persist_upsert_run(state, &session);
return Err(
WebError::bad_gateway_code("page_ai_pi_lab_runtime_missing", message).with_details(
json!({
"runtimeImplementation": runtime_impl,
"runtimeBinary": binary,
}),
),
);
}
let mut command = Command::new(&binary);
command
.arg("--mode")
.arg("rpc")
.arg("--session-dir")
.arg(&session.pi_session_dir)
.arg("--no-approve")
.arg("--no-extensions")
.arg("--extension")
.arg(&mnote_pi_extension_path)
.arg("--tools")
.arg(pi_tool_names.join(","))
.arg("--no-prompt-templates")
.arg("--no-context-files")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(cwd) = primary_allowed_root_path(&session) {
command.current_dir(cwd);
}
for source in &pi_extension_sources {
command.arg("--extension").arg(source);
}
for arg in pi_lab_skill_cli_args(&session) {
command.arg(arg);
}
if let Some(provider) = session
.model_provider
.as_deref()
.filter(|value| !value.is_empty())
{
command
.arg("--provider")
.arg(provider)
.env("MNOTE_PI_MODEL_PROVIDER", provider);
}
if let Some(model) = session
.model_id
.as_deref()
.filter(|value| !value.is_empty())
{
command
.arg("--model")
.arg(model)
.env("MNOTE_PI_MODEL_ID", model);
}
if let Some(thinking) = session
.thinking_level
.as_deref()
.filter(|value| !value.is_empty())
{
command.arg("--thinking").arg(thinking);
}
command
.arg("--name")
.arg(format!("MNote Pi Lab {}", session.session_id));
// 生成每 session 受控的 models.json,设置 PI_CODING_AGENT_DIR
// API key 仅从环境读取,不写入日志或 command line
command.env(
"PI_CODING_AGENT_DIR",
pi_config_dir.to_string_lossy().to_string(),
);
let bridge_tools =
serde_json::to_string(&mnote_pi_tool_manifest(&session)).unwrap_or_else(|_| "[]".into());
let bridge_tool_policies =
serde_json::to_string(&mnote_pi_tool_policies(&session)).unwrap_or_else(|_| "{}".into());
command.env("PI_MNOTE_RUNTIME_IMPL", &runtime_impl);
command.env("PI_MNOTE_CONTEXT_FILE", &mnote_context_path);
command.env("PI_MNOTE_HTTP_BRIDGE_AVAILABLE", "0");
command.env("PI_MNOTE_BRIDGE_BASE_URL", &bridge_base_url);
command.env("PI_MNOTE_BRIDGE_SESSION_ID", &session.session_id);
command.env("PI_MNOTE_BRIDGE_TOOLS", &bridge_tools);
command.env("PI_MNOTE_BRIDGE_TOOL_POLICIES", &bridge_tool_policies);
command.env("PI_MNOTE_BRIDGE_TOKEN", &session.bridge_token);
command.env("MNOTE_PI_BRIDGE_BASE_URL", &bridge_base_url);
command.env("MNOTE_PI_BRIDGE_SESSION_ID", &session.session_id);
command.env("MNOTE_PI_BRIDGE_TOOLS", &bridge_tools);
command.env("MNOTE_PI_BRIDGE_TOOL_POLICIES", &bridge_tool_policies);
command.env("MNOTE_PI_BRIDGE_TOKEN", &session.bridge_token);
command.env("MNOTE_PI_LAB_BRIDGE_TOKEN", &session.bridge_token);
if let Some(permission_mode) = session_permission_mode(&session) {
command.env("MNOTE_PI_PERMISSION_MODE", permission_mode);
}
command.env("MNOTE_PI_RUNTIME_BINARY", &binary);
command.env("PI_HTTP_ALLOW_LOOPBACK", "1");
if pi_lab_mcp_enabled(&session) {
// Pi Rust 的同步 child_process API 还有独立的默认关闭开关。
// 仅受控 MCP 扩展启用时开放,后续仍经过扩展 exec capability/mediation。
command.env("PIJS_ALLOW_UNSAFE_SYNC_EXEC", "1");
}
command.env("OPENAI_BASE_URL", omniroute_base_url());
if let Some(key) = omniroute_api_key() {
command.env("OPENAI_API_KEY", key);
}
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
let message = format!("启动 {binary} --mode rpc 失败: {error}");
session.status = PiLabSessionStatus::Error;
session.runtime_error = Some(message.clone());
upsert_session(session.clone());
let _ = persist_upsert_run(state, &session);
return Err(
WebError::bad_gateway_code("page_ai_pi_lab_runtime_spawn_failed", message)
.with_details(json!({
"runtimeImplementation": runtime_impl,
"runtimeBinary": binary,
})),
);
}
};
let pid = child.id();
let stdin = child.stdin.take().ok_or_else(|| {
WebError::bad_gateway_code("page_ai_pi_lab_stdin_missing", "Pi RPC stdin 不可用")
})?;
let stdout = child.stdout.take().ok_or_else(|| {
WebError::bad_gateway_code("page_ai_pi_lab_stdout_missing", "Pi RPC stdout 不可用")
})?;
let stderr = child.stderr.take().ok_or_else(|| {
WebError::bad_gateway_code("page_ai_pi_lab_stderr_missing", "Pi RPC stderr 不可用")
})?;
let handle = PiLabProcessHandle {
stdin: Arc::new(AsyncMutex::new(stdin)),
child: Arc::new(AsyncMutex::new(child)),
_pid: pid,
};
if let Ok(mut processes) = PI_LAB_PROCESSES.lock() {
processes.insert(session.session_id.clone(), handle);
}
if let Some(shared_mcp_cache_path) = shared_mcp_cache_path.clone() {
schedule_mcp_cache_sync(
session.session_id.clone(),
pi_config_dir.clone(),
shared_mcp_cache_path,
);
}
session.status = PiLabSessionStatus::RuntimeRunning;
session.runtime_pid = pid;
session.runtime_error = None;
upsert_session(session.clone());
// 扫描 pi_session_file
if session.pi_session_file.is_none() {
if let Some(pi_file) = resolve_pi_session_file(&session) {
session.pi_session_file = Some(pi_file);
upsert_session(session.clone());
let _ = persist_upsert_run(state, &session);
}
}
publish_event(
&session.session_id,
"runtime_started",
json!({
"mode": "rpc",
"runtimeImplementation": runtime_impl,
"pid": pid,
"sessionDir": session.pi_session_dir,
"providerSessionId": session.provider_session_id,
"modelProvider": session.model_provider,
"modelId": session.model_id,
"thinkingLevel": session.thinking_level,
"omnirouteBaseUrl": omniroute_base_url(),
"piCodingAgentDir": pi_config_dir,
"mnotePiExtension": mnote_pi_extension_path,
"mcpConfigPath": mcp_config_path,
"permissionConfigPath": permission_config_path,
"configuredPiExtensionSources": configured_extension_sources,
"piExtensionSources": pi_extension_sources,
"piExtensionToolNames": pi_extension_tool_names,
"mnotePiToolManifest": mnote_pi_tool_manifest(&session),
"piToolNames": pi_tool_names,
"managedBuiltinTools": enabled_builtin_tools,
}),
);
let session_id = session.session_id.clone();
let cloned_state = state.clone();
let session_user_id = session.mnote_user_id.clone();
let session_workspace_id = session.workspace_id.clone();
let session_page_path = session.page_path.clone();
tokio::spawn(async move {
let cp: &dyn ControlPlaneStore = cloned_state.control_plane();
let run_id = pi_run_id(&session_id);
let persist_event = |event_type: &str, event_payload: &Value| {
let input = AppendAiRuntimeEventInput {
id: None,
user_id: session_user_id.clone(),
workspace_id: session_workspace_id.clone(),
document_id: session_page_path.clone(),
session_id: session_id.clone(),
run_id: run_id.clone(),
profile: PI_LAB_PROFILE.to_string(),
acp_runtime: PI_LAB_ACP_RUNTIME.to_string(),
event_type: event_type.to_string(),
payload_json: serde_json::to_string(event_payload).unwrap_or_else(|_| "{}".into()),
};
if let Err(e) = cp.append_ai_runtime_event(input) {
publish_event(
&session_id,
"runtime_persistence_error",
json!({
"error": e.to_string(),
"eventType": event_type,
}),
);
}
};
let mut lines = BufReader::new(stdout).lines();
loop {
match lines.next_line().await {
Ok(Some(line)) => {
let payload = serde_json::from_str::<Value>(&line)
.unwrap_or_else(|_| json!({"raw": line}));
if payload.get("type").and_then(Value::as_str) == Some("agent_end") {
update_session(&session_id, |session| {
session.status = status_after_agent_end(&session.status);
});
}
record_pending_approval_from_event(&session_id, &payload);
// Resolve pending RPC response if this line is a Pi RPC response
if payload.get("type").and_then(Value::as_str) == Some("response") {
if let Some(resp_id) = payload.get("id").and_then(Value::as_str) {
let resp_key = rpc_response_key(&session_id, resp_id);
let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await;
if let Some(tx) = pending.remove(&resp_key) {
let _ = tx.send(payload.clone());
}
}
}
persist_event("pi_rpc_event", &payload);
publish_event(&session_id, "pi_rpc_event", payload);
}
Ok(None) => {
let closed_during_turn = get_session(&session_id)
.map(|session| session.status == PiLabSessionStatus::TurnRunning)
.unwrap_or(false);
let close_payload = json!({
"pid": pid,
"duringTurn": closed_during_turn,
"message": if closed_during_turn {
"Pi Rust runtime exited before returning an assistant response"
} else {
"Pi Rust runtime stdout closed"
},
});
update_session(&session_id, |session| {
session.runtime_pid = None;
match session.status {
PiLabSessionStatus::TurnRunning => {
session.status = PiLabSessionStatus::Error;
session.runtime_error = Some(
"Pi Rust runtime exited before returning an assistant response"
.into(),
);
}
PiLabSessionStatus::Aborted | PiLabSessionStatus::Error => {}
PiLabSessionStatus::Idle | PiLabSessionStatus::RuntimeRunning => {
session.status = PiLabSessionStatus::Idle;
}
}
});
if let Ok(mut processes) = PI_LAB_PROCESSES.lock() {
if processes
.get(&session_id)
.is_some_and(|handle| handle._pid == pid)
{
processes.remove(&session_id);
}
}
persist_event("runtime_stdout_closed", &close_payload);
publish_event(&session_id, "runtime_stdout_closed", close_payload);
break;
}
Err(error) => {
update_session(&session_id, |session| {
session.status = PiLabSessionStatus::Error;
session.runtime_error = Some(error.to_string());
});
publish_event(
&session_id,
"runtime_stdout_error",
json!({"error": error.to_string()}),
);
persist_event("runtime_stdout_error", &json!({"error": error.to_string()}));
break;
}
}
}
});
let stderr_state = state.clone();
let stderr_session = session.clone();
let stderr_session_id = stderr_session.session_id.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
loop {
match lines.next_line().await {
Ok(Some(line)) => {
let payload = json!({"line": line});
if let Err(error) = persist_append_event(
&stderr_state,
&stderr_session,
"runtime_stderr_line",
&payload,
) {
publish_event(
&stderr_session_id,
"runtime_persistence_error",
json!({
"error": error.message(),
"eventType": "runtime_stderr_line",
}),
);
}
publish_event(&stderr_session_id, "runtime_stderr_line", payload);
}
Ok(None) => break,
Err(error) => {
let payload = json!({"error": error.to_string()});
if let Err(error) = persist_append_event(
&stderr_state,
&stderr_session,
"runtime_stderr_error",
&payload,
) {
publish_event(
&stderr_session_id,
"runtime_persistence_error",
json!({
"error": error.message(),
"eventType": "runtime_stderr_error",
}),
);
}
publish_event(&stderr_session_id, "runtime_stderr_error", payload);
break;
}
}
}
});
persist_upsert_run(state, &session)?;
// 扫描 pi_session_file
if session.pi_session_file.is_none() {
if let Some(pi_file) = resolve_pi_session_file(&session) {
session.pi_session_file = Some(pi_file);
upsert_session(session.clone());
let _ = persist_upsert_run(state, &session);
}
}
persist_append_event(
state,
&session,
"runtime_started",
&json!({
"mode": "rpc",
"runtimeImplementation": pi_runtime_impl(),
"pid": session.runtime_pid,
"providerSessionId": session.provider_session_id,
"modelProvider": session.model_provider,
"modelId": session.model_id,
"thinkingLevel": session.thinking_level,
"omnirouteBaseUrl": omniroute_base_url(),
"sessionDir": session.pi_session_dir,
"mnotePiExtension": mnote_pi_extension_path,
"mcpConfigPath": mcp_config_path,
"permissionConfigPath": permission_config_path,
"configuredPiExtensionSources": pi_lab_configured_extension_sources(&session),
"piExtensionSources": pi_extension_sources,
"piExtensionToolNames": pi_extension_tool_names,
"mnotePiToolManifest": mnote_pi_tool_manifest(&session),
"piToolNames": pi_tool_names,
"managedBuiltinTools": pi_lab_enabled_builtin_tools(&session),
}),
)?;
Ok(session)
}
async fn send_rpc_command(session_id: &str, command: Value) -> Result<(), WebError> {
let handle = PI_LAB_PROCESSES
.lock()
.ok()
.and_then(|processes| processes.get(session_id).cloned());
let Some(handle) = handle else {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_runtime_not_started",
"Pi runtime 未启动;请先调用 /api/page-ai/pi/start",
));
};
let mut stdin = handle.stdin.lock().await;
let line = serde_json::to_string(&command)
.map_err(|error| WebError::internal(format!("序列化 Pi RPC command 失败: {error}")))?;
stdin.write_all(line.as_bytes()).await.map_err(|error| {
WebError::bad_gateway_code("page_ai_pi_lab_rpc_write_failed", error.to_string())
})?;
stdin.write_all(b"\n").await.map_err(|error| {
WebError::bad_gateway_code("page_ai_pi_lab_rpc_write_failed", error.to_string())
})?;
stdin.flush().await.map_err(|error| {
WebError::bad_gateway_code("page_ai_pi_lab_rpc_flush_failed", error.to_string())
})?;
Ok(())
}
async fn send_rpc_command_wait(
session_id: &str,
command: Value,
timeout: Duration,
) -> Result<Option<Value>, WebError> {
let rpc_id = command
.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if rpc_id.is_empty() {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_missing_rpc_id",
"Pi RPC command 缺少 id 字段",
));
}
let key = rpc_response_key(session_id, &rpc_id);
let (tx, rx) = oneshot::channel();
// Register pending response before sending
{
let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await;
pending.insert(key.clone(), tx);
}
// Send command
send_rpc_command(session_id, command).await?;
// Wait for response with timeout
let result = tokio::time::timeout(timeout, rx).await;
// Clean up registry
{
let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await;
pending.remove(&key);
}
match result {
Ok(Ok(response)) => Ok(Some(response)),
Ok(Err(_)) | Err(_) => Ok(None),
}
}
fn rpc_response_success(response: Option<&Value>) -> bool {
response
.and_then(|value| value.get("success"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn rpc_response_error_message(response: Option<&Value>, fallback: &str) -> String {
response
.and_then(|value| value.get("error"))
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or(fallback)
.to_string()
}
fn apply_text_operations(current: &str, operations: &Value) -> Result<String, WebError> {
match operations {
Value::Array(ops) => {
let mut result = current.to_string();
for op in ops {
result = apply_single_operation(&result, op)?;
}
Ok(result)
}
Value::Object(_) => apply_single_operation(current, operations),
_ => Err(WebError::bad_request_code(
"page_ai_pi_lab_invalid_operations",
"operations 必须是对象或数组",
)),
}
}
fn apply_single_operation(current: &str, op: &Value) -> Result<String, WebError> {
let op_type = op.get("op").and_then(Value::as_str).ok_or_else(|| {
WebError::bad_request_code("page_ai_pi_lab_missing_op", "operation 缺少 op 字段")
})?;
match op_type {
"replace" => {
let old = op.get("old").and_then(Value::as_str).ok_or_else(|| {
WebError::bad_request_code("page_ai_pi_lab_missing_old", "replace 缺少 old")
})?;
let new = op.get("new").and_then(Value::as_str).unwrap_or("");
if !current.contains(old) {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_replace_not_found",
"replace 未找到匹配文本",
));
}
Ok(current.replacen(old, new, 1))
}
"append" => {
let content = op.get("content").and_then(Value::as_str).ok_or_else(|| {
WebError::bad_request_code("page_ai_pi_lab_missing_content", "append 缺少 content")
})?;
Ok(format!("{current}{content}"))
}
"prepend" => {
let content = op.get("content").and_then(Value::as_str).ok_or_else(|| {
WebError::bad_request_code("page_ai_pi_lab_missing_content", "prepend 缺少 content")
})?;
Ok(format!("{content}{current}"))
}
"delete" => {
let target = op.get("target").and_then(Value::as_str).ok_or_else(|| {
WebError::bad_request_code("page_ai_pi_lab_missing_target", "delete 缺少 target")
})?;
if !current.contains(target) {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_delete_not_found",
"delete 未找到匹配文本",
));
}
Ok(current.replacen(target, "", 1))
}
_ => Err(WebError::bad_request_code(
"page_ai_pi_lab_unknown_op",
format!("不支持的 operation: {op_type}"),
)),
}
}
fn file_version(path: &Path) -> Option<String> {
let bytes = fs::read(path).ok()?;
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hasher);
Some(format!("{:016x}", hasher.finish()))
}
fn write_receipt(
state: &AppState,
receipt: PiLabToolReceipt,
result_payload: &Value,
citation_count: usize,
) -> Value {
if let Ok(mut store) = PI_LAB_RECEIPT_STORE.lock() {
store.push(receipt.clone());
if store.len() > 1000 {
store.remove(0);
}
}
let receipt_payload = serde_json::to_string(&json!({
"receipt": receipt,
"result": result_payload,
}))
.unwrap_or_else(|_| "{}".into());
let event_result = state
.control_plane()
.append_ai_tool_event(AppendAiToolEventInput {
id: Some(receipt.receipt_id.clone()),
user_id: receipt.mnote_user_id.clone(),
workspace_id: receipt.workspace_id.clone(),
session_id: receipt.session_id.clone(),
run_id: (receipt.session_id != "standalone_tool_call")
.then(|| pi_run_id(&receipt.session_id)),
provider: receipt.provider.to_string(),
provider_session_id: receipt.provider_session_id.clone(),
tool_name: receipt.tool_name.clone(),
allowed: receipt.allowed,
deny_reason: receipt.deny_reason.clone(),
root_uri: receipt.root_uri.clone().unwrap_or_default(),
page_path: receipt.page_path.clone(),
normalized_file_path: receipt.normalized_file_path.clone(),
diff_summary: receipt.diff_summary.clone(),
citation_count: citation_count as i64,
before_file_version: receipt.before_file_version.clone(),
after_file_version: receipt.after_file_version.clone(),
payload_json: receipt_payload,
});
if let Ok(event) = event_result {
let patch_persisted = if receipt.tool_name == "mnote.local_file.patch" && receipt.allowed {
let relative_path = result_payload
.get("relativePath")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let root_uri = result_payload
.get("rootUri")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| receipt.root_uri.clone())
.unwrap_or_default();
state
.control_plane()
.append_ai_file_patch(AppendAiFilePatchInput {
id: None,
user_id: receipt.mnote_user_id.clone(),
workspace_id: receipt.workspace_id.clone(),
session_id: receipt.session_id.clone(),
run_id: (receipt.session_id != "standalone_tool_call")
.then(|| pi_run_id(&receipt.session_id)),
tool_event_id: event.id,
root_uri,
relative_path,
before_file_version: receipt.before_file_version.clone(),
after_file_version: receipt.after_file_version.clone(),
patch_summary_json: serde_json::to_string(&json!({
"diffSummary": receipt.diff_summary,
"oldSize": result_payload.get("oldSize"),
"newSize": result_payload.get("newSize"),
}))
.unwrap_or_else(|_| "{}".into()),
})
.is_ok()
} else {
false
};
return json!({
"receiptId": receipt.receipt_id,
"storage": "control_plane_turso_libsql_v1",
"persisted": true,
"patchPersisted": patch_persisted,
});
}
let mut adapter_path = std::env::temp_dir()
.join("mnote-web")
.join("pi-lab")
.join("tool-receipts.jsonl");
if let Some(root_path) = receipt
.root_uri
.as_deref()
.and_then(file_path_from_root_uri)
{
adapter_path = root_path
.join(".mnote")
.join("ai")
.join("pi-lab")
.join("tool-receipts.jsonl");
}
let persisted = (|| -> Result<(), std::io::Error> {
if let Some(parent) = adapter_path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&adapter_path)?;
let line = serde_json::to_string(&receipt).unwrap_or_else(|_| "{}".into());
writeln!(file, "{line}")?;
Ok(())
})()
.is_ok();
json!({
"receiptId": receipt.receipt_id,
"storage": "provider_neutral_jsonl_debug_fallback_v1",
"persisted": persisted,
"path": adapter_path,
"fallbackReason": "control-plane ai_tool_events write failed",
})
}
fn receipt_for(
context: &RequestContext,
session: Option<&PiLabSession>,
tool_name: &str,
normalized_file_path: Option<String>,
allowed: bool,
deny_reason: Option<String>,
diff_summary: Option<String>,
before_file_version: Option<String>,
after_file_version: Option<String>,
) -> PiLabToolReceipt {
PiLabToolReceipt {
schema: PI_LAB_SCHEMA_RECEIPT,
receipt_id: generate_id("pi_receipt"),
mnote_user_id: session
.map(|session| session.mnote_user_id.clone())
.unwrap_or_else(|| context.auth.actor_id.clone()),
workspace_id: session.and_then(|session| session.workspace_id.clone()),
root_uri: session.and_then(|session| session.root_uri.clone()),
page_path: session.and_then(|session| session.page_path.clone()),
session_id: session
.map(|session| session.session_id.clone())
.unwrap_or_else(|| "standalone_tool_call".into()),
tool_name: tool_name.to_string(),
normalized_file_path,
allowed,
deny_reason,
diff_summary,
before_file_version,
after_file_version,
provider: PI_LAB_PROVIDER,
provider_session_id: session.map(|session| session.provider_session_id.clone()),
model_provider: session.and_then(|session| session.model_provider.clone()),
model_id: session.and_then(|session| session.model_id.clone()),
allowed_roots_snapshot: session.and_then(|session| session.allowed_roots_snapshot.clone()),
storage: "control_plane_turso_libsql_v1".into(),
created_at_ms: now_ms(),
}
}
pub struct PiLabToolFacade {
state: AppState,
context: RequestContext,
session: Option<PiLabSession>,
}
fn string_param(params: &Value, key: &str) -> Option<String> {
params
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn u64_param(params: &Value, key: &str) -> Option<u64> {
params.get(key).and_then(|value| {
value
.as_u64()
.or_else(|| value.as_i64().and_then(|n| (n >= 0).then_some(n as u64)))
.or_else(|| {
value
.as_str()
.and_then(|text| text.trim().parse::<u64>().ok())
})
})
}
fn bool_param(params: &Value, key: &str) -> Option<bool> {
params.get(key).and_then(|value| {
value.as_bool().or_else(|| {
value
.as_str()
.and_then(|text| match text.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
})
})
})
}
fn truncate_text(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value.to_string();
}
let mut text = value.chars().take(max_chars).collect::<String>();
text.push_str("\n...[truncated]");
text
}
fn mnote_repo_root() -> PathBuf {
if let Ok(root) = std::env::var("MNOTE_REPO_ROOT") {
let root = PathBuf::from(root);
if root.exists() {
return root;
}
}
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
for ancestor in manifest_dir.ancestors() {
if ancestor.join("AGENTS.md").exists() && ancestor.join("rust").exists() {
return ancestor.to_path_buf();
}
}
std::env::current_dir().unwrap_or(manifest_dir)
}
fn pi_codex_extra_writable_dirs(repo_root: &Path) -> Vec<PathBuf> {
[
repo_root.join(".codex").join("skills"),
PathBuf::from("/home/lix/.codex/skills"),
PathBuf::from("/home/lix/.agents/skills"),
]
.into_iter()
.filter(|path| path.exists())
.collect()
}
impl PiLabToolFacade {
fn session_root_uri(&self, params: &Value) -> Option<String> {
params
.get("rootUri")
.or_else(|| params.get("root_uri"))
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
self.session
.as_ref()
.and_then(|session| session.root_uri.clone())
})
}
fn session_page_path(&self, params: &Value) -> Option<String> {
params
.get("pagePath")
.or_else(|| params.get("page_path"))
.or_else(|| params.get("path"))
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
self.session
.as_ref()
.and_then(|session| session.page_path.clone())
})
}
fn current_page_read(&self, params: Value) -> Result<Value, WebError> {
let root_uri = self.session_root_uri(&params).ok_or_else(|| {
WebError::bad_request_code("page_ai_pi_lab_root_uri_required", "读取当前页缺少 rootUri")
})?;
let page_path = self.session_page_path(&params).ok_or_else(|| {
WebError::bad_request_code(
"page_ai_pi_lab_page_path_required",
"读取当前页缺少 pagePath",
)
})?;
let target =
resolve_root_relative_path(&self.state, &self.context, &root_uri, &page_path, false)?;
let content = fs::read_to_string(&target).map_err(|error| {
WebError::bad_request_code(
"page_ai_pi_lab_current_page_read_failed",
format!("读取当前页失败: {error}"),
)
})?;
Ok(json!({
"rootUri": root_uri,
"pagePath": page_path,
"path": target,
"content": content,
"contentLength": content.len(),
"format": "markdown",
"fileVersion": file_version(&target),
}))
}
fn selection_read(&self, params: Value) -> Result<Value, WebError> {
let source = params
.get("selectionSource")
.or_else(|| params.get("selection_source"))
.and_then(Value::as_str)
.unwrap_or_default();
if source != "mnote_sidebar_host" {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_selection_snapshot_required",
"selection 必须由 MNote sidebar host 注入,不能由 provider 直接伪造",
));
}
let selection = params.get("selection").cloned().unwrap_or(Value::Null);
Ok(json!({
"selection": selection,
"selectionSource": "mnote_sidebar_host_snapshot",
"rootUri": self.session_root_uri(&params),
"pagePath": self.session_page_path(&params),
"note": "Pi Lab 只接受 MNote sidebar host 注入的 tiptap live selection snapshot",
}))
}
fn allowed_roots_describe(&self) -> Result<Value, WebError> {
let roots = active_allowed_roots(&self.state, &self.context)?;
let permission_mode = self
.session
.as_ref()
.and_then(|session| session_permission_mode(session));
let managed_builtin_tools = self
.session
.as_ref()
.map(pi_lab_enabled_builtin_tools)
.unwrap_or_default();
let denied_builtin_tools = pi_lab_managed_builtin_tools()
.into_iter()
.filter(|tool| !managed_builtin_tools.iter().any(|managed| managed == tool))
.collect::<Vec<_>>();
let permission_provider = if permission_mode == Some("full_access") {
"pi-rust-full-access-builtins"
} else if permission_mode == Some("auto_edit") {
"pi-rust-auto-edit-builtins"
} else if permission_mode == Some("plan") {
"pi-rust-readonly-builtins"
} else if managed_builtin_tools.is_empty() {
"mnote-bridge-rust-policy"
} else if self
.session
.as_ref()
.is_some_and(pi_lab_official_permission_gate_enabled)
{
"pi-rust-official:permission-gate"
} else {
"@gotgenes/pi-permission-system"
};
let permission_note = if permission_mode == Some("full_access") {
"Full access exposes Pi Rust built-in read/write/edit/hashline_edit/bash/grep/find/ls as first-class tools. MNote bridge tools only add current page, allowed roots, URL/reference, and knowledge context."
} else if permission_mode == Some("auto_edit") {
"Auto edit exposes Pi Rust built-in read/write/edit/hashline_edit/grep/find/ls. Bash remains reserved for full_access; MNote bridge tools only add current page, allowed roots, URL/reference, and knowledge context."
} else if permission_mode == Some("plan") {
"Plan mode exposes only Pi Rust read/grep/find/ls built-ins. Write/edit/hashline_edit/bash remain disabled until the user switches to auto_edit or full_access."
} else if managed_builtin_tools.is_empty() {
"Pi built-in read/write/edit/hashline_edit/bash/grep/find/ls are disabled by default for Pi Rust; MNote bridge tools enforce allowed roots in Rust."
} else if self
.session
.as_ref()
.is_some_and(pi_lab_official_permission_gate_enabled)
{
"Pi Rust official permission-gate only confirms dangerous bash commands; MNote keeps built-in file tools disabled unless a separate explicit permission-system bridge is enabled."
} else {
"Pi built-in read/write/edit/hashline_edit/bash/grep/find/ls are exposed through opt-in pi-permission-system path gates."
};
Ok(json!({
"allowedRoots": roots.iter().map(|root| json!({
"rootUri": root.root_uri,
"rootPath": root.root_path,
"workspaceId": root.workspace_id,
"permission": root.permission,
"source": root.source,
})).collect::<Vec<_>>(),
"deniedPiBuiltinTools": denied_builtin_tools,
"managedPiBuiltinTools": managed_builtin_tools,
"permissionProvider": permission_provider,
"note": permission_note,
"mnoteTools": [
"mnote.current_page.read",
"mnote.selection.read",
"mnote.allowed_roots.describe",
"mnote.local_file.read",
"mnote.local_file.patch",
"mnote.knowledge_rag.status",
"mnote.knowledge_rag.query",
"mnote.knowledge_rag.section_context",
"mnote.knowledge_rag.open_reference",
"mnote.reference.open",
"mnote.codex_rescue.request",
"mnote.tool_receipt.write"
],
}))
}
fn local_file_read(&self, params: Value) -> Result<Value, WebError> {
let (target, root_uri, relative_path) =
resolve_file_path(&self.state, &self.context, &params, self.session.as_ref(), false)?;
let content = fs::read_to_string(&target).map_err(|error| {
WebError::bad_request_code(
"page_ai_pi_lab_file_read_failed",
format!("读取文件失败: {error}"),
)
})?;
Ok(json!({
"rootUri": root_uri,
"relativePath": relative_path,
"path": target,
"content": content,
"contentLength": content.len(),
"fileVersion": file_version(&target),
}))
}
fn local_file_patch(&self, params: Value) -> Result<Value, WebError> {
let (target, root_uri, relative_path) =
resolve_file_path(&self.state, &self.context, &params, self.session.as_ref(), true)?;
let before_version = file_version(&target);
let current = fs::read_to_string(&target).unwrap_or_default();
let next = if let Some(content) = params.get("content").and_then(Value::as_str) {
content.to_string()
} else {
let operations = params.get("operations").ok_or_else(|| {
WebError::bad_request_code(
"page_ai_pi_lab_operations_required",
"patch 需要 content 或 operations",
)
})?;
apply_text_operations(&current, operations)?
};
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"page_ai_pi_lab_patch_parent_failed",
format!("创建父目录失败: {error}"),
)
})?;
}
fs::write(&target, next.as_bytes()).map_err(|error| {
WebError::bad_request_code(
"page_ai_pi_lab_patch_write_failed",
format!("写入文件失败: {error}"),
)
})?;
let after_version = file_version(&target);
let diff_summary = if current == next {
"no_changes".to_string()
} else {
format!("bytes_delta={}", next.len() as i64 - current.len() as i64)
};
Ok(json!({
"rootUri": root_uri,
"relativePath": relative_path,
"path": target,
"beforeFileVersion": before_version,
"afterFileVersion": after_version,
"oldSize": current.len(),
"newSize": next.len(),
"diffSummary": diff_summary,
"refresh": "mnote local-folder watcher / document-session external refresh",
"polling": false,
}))
}
async fn knowledge_rag_query(&self, params: Value) -> Result<Value, WebError> {
let query = params
.get("query")
.or_else(|| params.get("question"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("page_ai_pi_lab_rag_query_required", "缺少 query")
})?;
let root_uri = self.session_root_uri(&params).ok_or_else(|| {
WebError::bad_request_code("page_ai_pi_lab_root_uri_required", "RAG 查询缺少 rootUri")
})?;
let body = knowledge_rag::KnowledgeRagQueryRequest {
workspace_id: self
.session
.as_ref()
.and_then(|session| session.workspace_id.clone())
.or_else(|| {
params
.get("workspaceId")
.and_then(Value::as_str)
.map(str::to_string)
}),
root_uri,
question: Some(query.to_string()),
query: None,
mode: params
.get("mode")
.and_then(Value::as_str)
.map(str::to_string),
top_k: params
.get("topK")
.and_then(Value::as_u64)
.map(|value| value as u32),
chunk_top_k: params
.get("chunkTopK")
.and_then(Value::as_u64)
.map(|value| value as u32),
include_chunk_content: params.get("includeChunkContent").and_then(Value::as_bool),
source_paths: params.get("sourcePaths").and_then(|value| {
value.as_array().map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect::<Vec<_>>()
})
}),
include_document_structure_index: params
.get("includeDocumentStructureIndex")
.and_then(Value::as_bool),
knowledge_base_id: params
.get("knowledgeBaseId")
.and_then(Value::as_str)
.map(str::to_string),
provider_knowledge_base_id: params
.get("providerKnowledgeBaseId")
.and_then(Value::as_str)
.map(str::to_string),
};
let Json(payload) = knowledge_rag::query_rag(
State(self.state.clone()),
Extension(self.context.clone()),
Json(body),
)
.await?;
Ok(knowledge_rag_agent_output::compact_query_result_for_agent(
payload,
))
}
async fn knowledge_rag_status(&self, params: Value) -> Result<Value, WebError> {
let query = knowledge_rag::KnowledgeRagStatusQuery {
workspace_id: self
.session
.as_ref()
.and_then(|session| session.workspace_id.clone())
.or_else(|| {
params
.get("workspaceId")
.and_then(Value::as_str)
.map(str::to_string)
}),
root_uri: self.session_root_uri(&params),
};
let Json(payload) = knowledge_rag::status(
State(self.state.clone()),
Extension(self.context.clone()),
Query(query),
)
.await?;
Ok(knowledge_rag_agent_output::compact_status_result_for_agent(
payload,
))
}
async fn knowledge_rag_section_context(&self, params: Value) -> Result<Value, WebError> {
let root_uri = self.session_root_uri(&params).ok_or_else(|| {
WebError::bad_request_code(
"page_ai_pi_lab_root_uri_required",
"读取 RAG section context 缺少 rootUri",
)
})?;
let body = knowledge_rag::KnowledgeRagSectionContextRequest {
workspace_id: self
.session
.as_ref()
.and_then(|session| session.workspace_id.clone())
.or_else(|| {
params
.get("workspaceId")
.and_then(Value::as_str)
.map(str::to_string)
}),
root_uri,
source_path: string_param(&params, "sourcePath")
.or_else(|| string_param(&params, "source_path")),
source_id: string_param(&params, "sourceId")
.or_else(|| string_param(&params, "source_id")),
light_rag_doc_id: string_param(&params, "lightRagDocId")
.or_else(|| string_param(&params, "light_rag_doc_id")),
provider_knowledge_id: string_param(&params, "providerKnowledgeId")
.or_else(|| string_param(&params, "provider_knowledge_id")),
provider_knowledge_base_id: string_param(&params, "providerKnowledgeBaseId")
.or_else(|| string_param(&params, "provider_knowledge_base_id")),
chunk_id: string_param(&params, "chunkId")
.or_else(|| string_param(&params, "chunk_id")),
provider_chunk_id: string_param(&params, "providerChunkId")
.or_else(|| string_param(&params, "provider_chunk_id")),
file_path: string_param(&params, "filePath")
.or_else(|| string_param(&params, "file_path")),
section_id: string_param(&params, "sectionId")
.or_else(|| string_param(&params, "section_id")),
start_block_ordinal: u64_param(&params, "startBlockOrdinal")
.or_else(|| u64_param(&params, "start_block_ordinal")),
end_block_ordinal: u64_param(&params, "endBlockOrdinal")
.or_else(|| u64_param(&params, "end_block_ordinal")),
start_paragraph_ordinal: u64_param(&params, "startParagraphOrdinal")
.or_else(|| u64_param(&params, "start_paragraph_ordinal"))
.map(|value| value as u32),
end_paragraph_ordinal: u64_param(&params, "endParagraphOrdinal")
.or_else(|| u64_param(&params, "end_paragraph_ordinal"))
.map(|value| value as u32),
context_before: u64_param(&params, "contextBefore")
.or_else(|| u64_param(&params, "context_before")),
context_after: u64_param(&params, "contextAfter")
.or_else(|| u64_param(&params, "context_after")),
max_blocks: u64_param(&params, "maxBlocks")
.or_else(|| u64_param(&params, "max_blocks"))
.map(|value| value as usize),
max_chars: u64_param(&params, "maxChars")
.or_else(|| u64_param(&params, "max_chars"))
.map(|value| value as usize),
};
let Json(payload) = knowledge_rag::section_context(
State(self.state.clone()),
Extension(self.context.clone()),
Json(body),
)
.await?;
Ok(knowledge_rag_agent_output::compact_section_context_for_agent(payload))
}
async fn reference_open(&self, params: Value) -> Result<Value, WebError> {
let root_uri = self.session_root_uri(&params).ok_or_else(|| {
WebError::bad_request_code("page_ai_pi_lab_root_uri_required", "打开引用缺少 rootUri")
})?;
let body = knowledge_rag::KnowledgeRagOpenReferenceRequest {
workspace_id: self
.session
.as_ref()
.and_then(|session| session.workspace_id.clone())
.or_else(|| {
params
.get("workspaceId")
.and_then(Value::as_str)
.map(str::to_string)
}),
root_uri,
reference: params.get("reference").cloned(),
reference_id: params
.get("referenceId")
.or_else(|| params.get("reference_id"))
.and_then(Value::as_str)
.map(str::to_string),
file_path: params
.get("filePath")
.or_else(|| params.get("resourcePath"))
.or_else(|| params.get("path"))
.and_then(Value::as_str)
.map(str::to_string),
chunk_id: params
.get("chunkId")
.or_else(|| params.get("chunk_id"))
.and_then(Value::as_str)
.map(str::to_string),
};
let Json(payload) = knowledge_rag::open_reference(
State(self.state.clone()),
Extension(self.context.clone()),
Json(body),
)
.await?;
Ok(payload)
}
async fn codex_rescue_request(&self, params: Value) -> Result<Value, WebError> {
if !local_folder_source::is_local_access_policy_admin_context(&self.context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_codex_rescue_admin_required",
"Codex 自救只能由管理员授权的 Pi 会话调用",
)
.with_context(&self.context));
}
let issue = string_param(&params, "issue")
.or_else(|| string_param(&params, "problem"))
.or_else(|| string_param(&params, "question"))
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_pi_lab_codex_rescue_issue_required",
"Codex 自救需要提供 issue/problem/question",
)
})?;
let evidence = string_param(&params, "evidence")
.or_else(|| string_param(&params, "logs"))
.or_else(|| string_param(&params, "context"))
.unwrap_or_default();
let attempted = string_param(&params, "attempted")
.or_else(|| string_param(&params, "attemptedSteps"))
.unwrap_or_default();
let desired = string_param(&params, "desiredOutcome")
.or_else(|| string_param(&params, "goal"))
.unwrap_or_else(|| "修复根因;如果不能安全修复,说明阻塞原因和下一步".into());
let allow_writes = bool_param(&params, "allowWrites").unwrap_or(true);
let timeout_secs = u64_param(&params, "timeoutSeconds")
.unwrap_or(300)
.clamp(60, 900);
let rescue_id = generate_id("codex_rescue");
let repo_root = mnote_repo_root();
let output_dir = self
.session
.as_ref()
.map(|session| PathBuf::from(&session.pi_session_dir))
.unwrap_or_else(std::env::temp_dir)
.join("codex-rescue");
fs::create_dir_all(&output_dir)
.map_err(|error| WebError::internal(format!("创建 Codex 自救输出目录失败: {error}")))?;
let final_message_path = output_dir.join(format!("{rescue_id}-final.md"));
let sandbox = if allow_writes {
"workspace-write"
} else {
"read-only"
};
let root_uri = self
.session
.as_ref()
.and_then(|session| session.root_uri.clone());
let page_path = self
.session
.as_ref()
.and_then(|session| session.page_path.clone());
let prompt = format!(
r#"你是被 MNote Pi 调用的本机 Codex 自救代理。目标是在用户看到失败前,先尝试定位和修复 MNote/Pi 的疑难问题。
边界:
- 使用简体中文沟通和总结。
- 遵守仓库 AGENTS.md;不要删除、回滚或覆盖用户已有改动。
- 只做与本问题直接相关的最小修复;能验证就运行最相关验证。
- 当前运行在 codex execsandbox={sandbox}approval=never;如果需要 sudo、系统级权限、外部凭证或超出 sandbox 的写入,不要强行绕过,明确说明阻塞。
- 如果你完成了修复,最后说明修改文件、验证命令和结果。
- 如果不能修复,最后给出明确原因、用户需要介入的动作,以及 Pi 下一步应如何降级汇报。
MNote Pi 会话:
- actorId: {actor_id}
- sessionId: {session_id}
- workspaceId: {workspace_id}
- rootUri: {root_uri}
- pagePath: {page_path}
问题:
{issue}
期望结果:
{desired}
Pi 已尝试:
{attempted}
证据/日志/上下文:
{evidence}
"#,
sandbox = sandbox,
actor_id = self.context.auth.actor_id,
session_id = self
.session
.as_ref()
.map(|session| session.session_id.as_str())
.unwrap_or("standalone"),
workspace_id = self
.session
.as_ref()
.and_then(|session| session.workspace_id.as_deref())
.unwrap_or(""),
root_uri = root_uri.as_deref().unwrap_or(""),
page_path = page_path.as_deref().unwrap_or(""),
issue = truncate_text(&issue, 12_000),
desired = truncate_text(&desired, 4_000),
attempted = truncate_text(&attempted, 8_000),
evidence = truncate_text(&evidence, 20_000),
);
let codex_bin = std::env::var("MNOTE_CODEX_BIN").unwrap_or_else(|_| "codex".into());
let mut command = Command::new("timeout");
command
.arg("--kill-after=10s")
.arg(format!("{timeout_secs}s"))
.arg(&codex_bin)
.arg("--ask-for-approval")
.arg("never")
.arg("exec")
.arg("--sandbox")
.arg(sandbox)
.arg("--cd")
.arg(&repo_root)
.arg("--output-last-message")
.arg(&final_message_path)
.arg("-");
for extra_dir in pi_codex_extra_writable_dirs(&repo_root) {
if allow_writes {
command.arg("--add-dir").arg(extra_dir);
}
}
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env("MNOTE_PI_CODEX_RESCUE", "1")
.env("NO_COLOR", "1");
let mut child = command
.spawn()
.map_err(|error| WebError::internal(format!("启动 Codex 自救失败: {error}")))?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(prompt.as_bytes()).await.map_err(|error| {
WebError::internal(format!("写入 Codex 自救 prompt 失败: {error}"))
})?;
}
let output = child
.wait_with_output()
.await
.map_err(|error| WebError::internal(format!("等待 Codex 自救失败: {error}")))?;
let exit_code = output.status.code();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let final_message = fs::read_to_string(&final_message_path).unwrap_or_default();
let timed_out = exit_code == Some(124) || exit_code == Some(137);
let ok = output.status.success() && !final_message.trim().is_empty();
Ok(json!({
"schema": "mnote.page_ai_pi.codex_rescue_result.v1",
"ok": ok,
"rescueId": rescue_id,
"status": if ok { "completed" } else if timed_out { "timed_out" } else { "failed" },
"exitCode": exit_code,
"timedOut": timed_out,
"sandbox": sandbox,
"approvalPolicy": "never",
"repoRoot": repo_root,
"finalMessage": truncate_text(&final_message, 12_000),
"stdout": truncate_text(&stdout, 8_000),
"stderr": truncate_text(&stderr, 8_000),
"finalMessagePath": final_message_path,
"nextAction": if ok {
"Pi 应读取 finalMessage,总结 Codex 已修复内容、验证结果和仍需用户确认的事项。"
} else {
"Pi 应停止重复自救,向用户汇报 Codex 未能解决的原因、stdout/stderr 摘要和需要人工介入的动作。"
},
}))
}
}
async fn execute_tool(
state: AppState,
context: RequestContext,
session_id: Option<String>,
tool_name: String,
params: Value,
allow_bridge_approval: bool,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&state, &context)?;
check_rate_limit(&actor_id, "tool", PI_LAB_MAX_TOOLS_PER_WINDOW)?;
let session = if let Some(session_id) = session_id.as_deref() {
Some(get_session_for_context(&state, &context, session_id)?)
} else {
None
};
if let Some(session) = session.as_ref() {
if !pi_lab_session_allows_mnote_tool(session, &tool_name) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_tool_disabled",
format!("当前 AI 设置未启用 Pi 工具 {tool_name}"),
)
.with_context(&context));
}
}
let facade = PiLabToolFacade {
state,
context: context.clone(),
session: session.clone(),
};
let started = now_ms();
let tool_policy = session
.as_ref()
.map(|session| pi_lab_session_tool_policy(session, &tool_name))
.unwrap_or_else(|| "allow".into());
if tool_policy == "deny" {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_tool_denied_by_permission_mode",
format!("当前 Pi 模式禁止调用工具 {tool_name}"),
)
.with_context(&context));
}
let approval_required = tool_policy == "ask";
let approval_confirmed = !approval_required
|| pi_lab_tool_approval_confirmed(
session.as_ref(),
&params,
&tool_name,
allow_bridge_approval,
);
let result: Result<Value, WebError> = if approval_required && !approval_confirmed {
Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_tool_approval_required",
format!("Pi 工具 {tool_name} 需要用户审批"),
))
} else {
match tool_name.as_str() {
"mnote.current_page.read" => facade.current_page_read(params.clone()),
"mnote.selection.read" => facade.selection_read(params.clone()),
"mnote.allowed_roots.describe" => facade.allowed_roots_describe(),
"mnote.local_file.read" => facade.local_file_read(params.clone()),
"mnote.local_file.patch" => facade.local_file_patch(params.clone()),
"mnote.knowledge_rag.status" => facade.knowledge_rag_status(params.clone()).await,
"mnote.knowledge_rag.query" => facade.knowledge_rag_query(params.clone()).await,
"mnote.knowledge_rag.section_context" => {
facade.knowledge_rag_section_context(params.clone()).await
}
"mnote.knowledge_rag.open_reference" => facade.reference_open(params.clone()).await,
"mnote.reference.open" => facade.reference_open(params.clone()).await,
"mnote.codex_rescue.request" => facade.codex_rescue_request(params.clone()).await,
"mnote.tool_receipt.write" => Ok(json!({
"requestedReceipt": params,
"diffSummary": params.get("diffSummary").cloned().unwrap_or(Value::Null),
"citations": params.get("citations").cloned().unwrap_or_else(|| json!([])),
"sources": params.get("sources").cloned().unwrap_or_else(|| json!([])),
"references": params.get("references").cloned().unwrap_or_else(|| json!([])),
"storage": "control_plane_turso_libsql_v1",
"note": "Pi Lab 由 execute_tool 统一写入 control-plane receipt journal",
})),
_ => Err(WebError::bad_request_code(
"page_ai_pi_lab_unknown_tool",
format!("未知 Pi Lab tool: {tool_name}"),
)),
}
};
let normalized_file_path = params
.get("path")
.and_then(Value::as_str)
.map(str::to_string);
let (allowed, payload, deny_reason, diff_summary, before_file_version, after_file_version) =
match result {
Ok(payload) => (
true,
payload.clone(),
None,
payload
.get("diffSummary")
.and_then(Value::as_str)
.map(str::to_string),
payload
.get("beforeFileVersion")
.and_then(Value::as_str)
.map(str::to_string),
payload
.get("afterFileVersion")
.and_then(Value::as_str)
.map(str::to_string),
),
Err(error) => (
false,
json!({
"ok": false,
"code": error.code(),
"message": error.message(),
}),
Some(format!("{}: {}", error.code(), error.message())),
None,
None,
None,
),
};
let citation_count = payload
.get("citations")
.and_then(Value::as_array)
.map(|items| items.len())
.or_else(|| {
payload
.get("sources")
.and_then(Value::as_array)
.map(|items| items.len())
})
.or_else(|| {
payload
.get("references")
.and_then(Value::as_array)
.map(|items| items.len())
})
.unwrap_or(0);
let receipt = receipt_for(
&context,
session.as_ref(),
&tool_name,
normalized_file_path.clone(),
allowed,
deny_reason.clone(),
diff_summary.clone(),
before_file_version.clone(),
after_file_version.clone(),
);
let receipt_payload = write_receipt(&facade.state, receipt, &payload, citation_count);
let elapsed_ms = now_ms().saturating_sub(started) as u64;
if let Some(session) = session.as_ref() {
let tool_event_id = receipt_payload
.get("toolEventId")
.or_else(|| receipt_payload.get("tool_event_id"))
.and_then(Value::as_str)
.map(str::to_string);
if allowed && tool_name == "mnote.local_file.patch" {
let artifact = json!({
"schema": "mnote.page_ai_pi.artifact.file_patch.v1",
"sessionId": session.session_id,
"providerSessionId": session.provider_session_id,
"toolEventId": tool_event_id,
"rootUri": payload.get("rootUri").cloned().unwrap_or(Value::Null),
"relativePath": payload.get("relativePath").cloned().unwrap_or_else(|| {
normalized_file_path
.as_deref()
.map(|path| json!(path))
.unwrap_or(Value::Null)
}),
"beforeFileVersion": before_file_version,
"afterFileVersion": after_file_version,
"diffSummary": diff_summary,
"receipt": receipt_payload,
});
publish_event(&session.session_id, "artifact_file_patch", artifact);
}
publish_event(
&session.session_id,
"tool_call",
json!({
"toolName": tool_name,
"allowed": allowed,
"denyReason": deny_reason,
"normalizedFilePath": normalized_file_path,
"rootUri": payload.get("rootUri").cloned().unwrap_or(Value::Null),
"relativePath": payload.get("relativePath").cloned().unwrap_or(Value::Null),
"diffSummary": diff_summary,
"beforeFileVersion": before_file_version,
"afterFileVersion": after_file_version,
"citationCount": citation_count,
"receipt": receipt_payload,
"elapsedMs": elapsed_ms,
"approvalRequired": approval_required,
"approvalConfirmed": approval_confirmed,
"toolPolicy": tool_policy,
}),
);
}
Ok(Json(json!({
"ok": allowed,
"toolName": tool_name,
"result": payload,
"receipt": receipt_payload,
"elapsedMs": elapsed_ms,
"approvalRequired": approval_required,
"approvalConfirmed": approval_confirmed,
"toolPolicy": tool_policy,
})))
}
pub async fn shell(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Response, WebError> {
ensure_enabled(&state)?;
ensure_authenticated(&state, &context)?;
let html = r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MNote Pi Lab</title>
<style>
html,body{margin:0;height:100%;background:#f7f7f5;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
#pi-lab-root{height:100%}
</style>
</head>
<body>
<div id="pi-lab-root" data-page-ai-sidebar-content></div>
<script src="/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js"></script>
<script>
window.createSidebarPageAiPiLabRuntime && window.createSidebarPageAiPiLabRuntime({ standalone: true });
window.postMessage({ source: 'mnote-sidebar', type: 'mnote:pi-lab-show' }, location.origin);
</script>
</body>
</html>"#;
Ok(Html(html).into_response())
}
pub async fn status(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
if !enabled(&state) {
return Ok(Json(json!({
"ok": true,
"schema": PI_LAB_SCHEMA_STATUS,
"enabled": false,
"running": false,
"uiMode": "independent_mnote_native_drawer",
"reason": "Pi Lab disabled by config",
})));
}
cleanup_expired_sessions();
let actor_id = ensure_authenticated(&state, &context)?;
let sessions = PI_LAB_SESSIONS.lock().ok();
let current_session = sessions.as_ref().and_then(|sessions| {
sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.filter(|session| session_can_auto_resume(session))
.max_by_key(|session| session.updated_at_ms)
.cloned()
});
let warmup_session = sessions.as_ref().and_then(|sessions| {
sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| is_pi_lab_warmup_session_id(&session.session_id))
.filter(|session| session_can_auto_resume(session))
.max_by_key(|session| session.updated_at_ms)
.cloned()
});
let active_session_count = sessions
.as_ref()
.map(|sessions| {
sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.count()
})
.unwrap_or(0);
let owned_session_ids = sessions
.as_ref()
.map(|sessions| {
sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.map(|session| session.session_id.clone())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let owned_warmup_session_ids = sessions
.as_ref()
.map(|sessions| {
sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| is_pi_lab_warmup_session_id(&session.session_id))
.map(|session| session.session_id.clone())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let process_count = PI_LAB_PROCESSES
.lock()
.map(|processes| {
owned_session_ids
.iter()
.filter(|session_id| processes.contains_key(*session_id))
.count()
})
.unwrap_or(0);
let warmup_process_count = PI_LAB_PROCESSES
.lock()
.map(|processes| {
owned_warmup_session_ids
.iter()
.filter(|session_id| processes.contains_key(*session_id))
.count()
})
.unwrap_or(0);
let (runtime_impl, runtime_binary, runtime_available, runtime_install_hint) =
pi_runtime_status_snapshot();
let runtime_error = current_session
.as_ref()
.and_then(|session| session.runtime_error.clone());
Ok(Json(json!({
"ok": true,
"schema": PI_LAB_SCHEMA_STATUS,
"enabled": true,
"running": process_count > 0 || current_session.as_ref().is_some_and(|session| session.status == PiLabSessionStatus::RuntimeRunning || session.status == PiLabSessionStatus::TurnRunning),
"version": PI_LAB_VERSION,
"provider": PI_LAB_PROVIDER,
"runtimeMode": runtime_mode(),
"runtimeImplementation": runtime_impl,
"runtimeBinary": runtime_binary,
"runtimeAvailable": runtime_available,
"runtimeInstallHint": runtime_install_hint,
"runtimeError": runtime_error,
"defaultModelProvider": default_model_provider(),
"defaultModelId": default_model_id(),
"defaultThinkingLevel": default_thinking_level(),
"permissionMode": current_session.as_ref().and_then(|session| session_permission_mode(session)),
"omnirouteBaseUrl": omniroute_base_url(),
"piExtensions": current_session
.as_ref()
.and_then(|session| session.runtime_policy_snapshot.as_ref())
.and_then(|policy| policy.get("piExtensions").cloned())
.unwrap_or_else(|| json!({})),
"enabledPiExtensions": current_session
.as_ref()
.map(pi_lab_enabled_extension_ids)
.unwrap_or_default(),
"piExtensionToolNames": current_session
.as_ref()
.map(pi_lab_runtime_pi_extension_tool_names)
.unwrap_or_default(),
"configuredPiExtensionSources": current_session
.as_ref()
.map(pi_lab_configured_extension_sources)
.unwrap_or_default(),
"piExtensionSources": current_session
.as_ref()
.map(pi_lab_runtime_extension_sources)
.unwrap_or_default(),
"pid": current_session.as_ref().and_then(|session| session.runtime_pid),
"sessionId": current_session.as_ref().map(|session| session.session_id.clone()),
"providerSessionId": current_session.as_ref().map(|session| session.provider_session_id.clone()),
"session": current_session,
"activeSessionCount": active_session_count,
"processCount": process_count,
"warmupRunning": warmup_process_count > 0 || warmup_session.as_ref().is_some_and(|session| session.status == PiLabSessionStatus::RuntimeRunning || session.status == PiLabSessionStatus::TurnRunning),
"warmupSessionId": warmup_session.as_ref().map(|session| session.session_id.clone()),
"warmupStatus": warmup_session.as_ref().map(|session| session_status_to_string(&session.status)),
"warmupSessionCount": owned_warmup_session_ids.len(),
"warmupProcessCount": warmup_process_count,
"managedPiSessionDirPolicy": "<workspace>/.mnote/ai/pi-sessions/<actor>/<session>",
"managedPiBuiltinTools": current_session
.as_ref()
.map(pi_lab_enabled_builtin_tools)
.unwrap_or_default(),
"receiptStorage": "control_plane_turso_libsql_v1",
"receiptFallbackStorage": "provider_neutral_jsonl_debug_fallback_v1",
"uiMode": "independent_mnote_native_drawer",
})))
}
pub async fn bootstrap(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<PiLabBootstrapRequest>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
let actor_id = ensure_authenticated(&state, &context)?;
check_rate_limit(&actor_id, "start", pi_lab_start_rate_limit(&state))?;
let start_request = PiLabStartRequest {
session_id: None,
root_uri: request.root_uri,
workspace_id: request.workspace_id,
page_path: request.page_path,
page_title: request.page_title,
model_provider: request.model_provider,
model_id: request.model_id,
thinking_level: request.thinking_level,
permission_mode: request.permission_mode,
};
let requested = session_from_request(&state, &context, &start_request)?;
let mut session = requested;
session.allowed_roots_snapshot = snapshot_allowed_roots(&state, &context)?;
let session = start_runtime_for_session(&state, session).await?;
let mut response = json!({
"ok": true,
"schema": "mnote.page_ai_pi.bootstrap.v1",
"sessionId": session.session_id,
"providerSessionId": session.provider_session_id,
"runtimeMode": session.runtime_mode,
"pid": session.runtime_pid,
"sessionDir": session.pi_session_dir,
"toolCalls": [],
"citations": [],
"diffSummary": null,
"uiMode": "independent_mnote_native_drawer",
});
if let Some(prompt) = request
.prompt
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let send_response = send(
State(state),
Extension(context),
Json(PiLabSendRequest {
session_id: response["sessionId"]
.as_str()
.unwrap_or_default()
.to_string(),
message: prompt.to_string(),
streaming_behavior: None,
root_uri: start_request.root_uri.clone(),
workspace_id: start_request.workspace_id.clone(),
page_path: start_request.page_path.clone(),
page_title: start_request.page_title.clone(),
folder_path: None,
context_refs: None,
selected_context: None,
}),
)
.await?
.0;
response["send"] = send_response;
response["text"] =
json!("Pi Lab 已接收 prompt;真实输出请从 /api/page-ai/pi/events 读取。");
}
Ok(Json(response))
}
pub async fn start(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<PiLabStartRequest>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
let actor_id = ensure_authenticated(&state, &context)?;
check_rate_limit(&actor_id, "start", pi_lab_start_rate_limit(&state))?;
let requested_session = session_from_request(&state, &context, &request)?;
let mut requested_session = requested_session;
requested_session.allowed_roots_snapshot = snapshot_allowed_roots(&state, &context)?;
if let Some(existing_session_id) = request.session_id.as_deref() {
if let Some(mut existing_session) = get_session(existing_session_id) {
ensure_session_owner(&state, &context, &existing_session)?;
if session_runtime_is_usable(&existing_session)
&& session_runtime_config_matches(&existing_session, &requested_session)
{
refresh_runtime_session_context(&mut existing_session, &requested_session);
upsert_session(existing_session.clone());
return Ok(Json(pi_lab_start_response(&existing_session, true)));
}
}
}
if kill_session_process(&requested_session.session_id).await {
update_session(&requested_session.session_id, |session| {
session.status = PiLabSessionStatus::Aborted;
session.runtime_pid = None;
});
}
let session = start_runtime_for_session(&state, requested_session).await?;
Ok(Json(pi_lab_start_response(&session, false)))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabConfigureRequest {
pub session_id: String,
pub model_provider: Option<String>,
pub model_id: Option<String>,
pub thinking_level: Option<String>,
pub permission_mode: Option<String>,
}
/// POST /api/page-ai/pi/configure
/// 配置 Pi Lab 会话的参数(model、thinking level、permission mode)。
/// 只在 mock 模式下本地生效;real 模式会转发对应 RPC 命令。
pub async fn configure(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<PiLabConfigureRequest>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
ensure_authenticated(&state, &context)?;
let session = get_session_for_context(&state, &context, &request.session_id)?;
let mut applied = json!({});
let mut rpc_response_pending = false;
let mut rpc_failures: Vec<String> = Vec::new();
if request.model_provider.is_some() || request.model_id.is_some() {
let mut requested_session = session.clone();
requested_session.model_provider = request
.model_provider
.clone()
.or_else(|| session.model_provider.clone());
requested_session.model_id = request
.model_id
.clone()
.or_else(|| session.model_id.clone());
ensure_session_model_supports_tools(&requested_session).await?;
}
if session.runtime_mode != "mock" && session_runtime_is_usable(&session) {
if request.model_provider.is_some() || request.model_id.is_some() {
let provider = request
.model_provider
.clone()
.or_else(|| session.model_provider.clone())
.unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_PROVIDER.to_string());
let model_id = request
.model_id
.clone()
.or_else(|| session.model_id.clone())
.unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_ID.to_string());
let response = send_rpc_command_wait(
&request.session_id,
json!({
"id": generate_id("pi_rpc_set_model"),
"type": "set_model",
"provider": provider,
"modelId": model_id,
}),
Duration::from_secs(5),
)
.await?;
if rpc_response_success(response.as_ref()) {
applied["model"] = json!({
"provider": provider,
"modelId": model_id,
});
} else {
rpc_response_pending = true;
rpc_failures.push(rpc_response_error_message(
response.as_ref(),
"set_model timeout or no response",
));
}
}
if let Some(level) = &request.thinking_level {
let normalized = normalize_thinking_level(Some(level)).map_err(|msg| {
WebError::bad_request_code("page_ai_pi_lab_invalid_thinking_level", &msg)
})?;
let response = send_rpc_command_wait(
&request.session_id,
json!({
"id": generate_id("pi_rpc_set_thinking"),
"type": "set_thinking_level",
"level": normalized,
}),
Duration::from_secs(5),
)
.await?;
if rpc_response_success(response.as_ref()) {
applied["thinkingLevel"] = json!(normalized);
} else {
rpc_response_pending = true;
rpc_failures.push(rpc_response_error_message(
response.as_ref(),
"set_thinking_level timeout or no response",
));
}
}
}
if let Some(provider) = &request.model_provider {
update_session(&request.session_id, |session| {
session.model_provider = Some(provider.clone());
});
}
if let Some(model_id) = &request.model_id {
update_session(&request.session_id, |session| {
session.model_id = Some(model_id.clone());
});
}
if let Some(level) = &request.thinking_level {
let normalized = normalize_thinking_level(Some(level)).map_err(|msg| {
WebError::bad_request_code("page_ai_pi_lab_invalid_thinking_level", &msg)
})?;
update_session(&request.session_id, |session| {
session.thinking_level = Some(normalized);
});
}
if let Some(mode) = &request.permission_mode {
let normalized = normalize_permission_mode(Some(mode)).map_err(|msg| {
WebError::bad_request_code("page_ai_pi_lab_invalid_permission_mode", &msg)
})?;
if let Some(normalized) = normalized {
let runtime_policy = refresh_runtime_policy_permission_mode(&session, &normalized);
applied["permissionMode"] = json!(normalized);
update_session(&request.session_id, |session| {
session.runtime_policy_snapshot = Some(runtime_policy);
});
}
}
persist_append_event(
&state,
&session,
"runtime_configured",
&json!({
"modelProvider": request.model_provider,
"modelId": request.model_id,
"thinkingLevel": request.thinking_level,
"permissionMode": request.permission_mode,
}),
)?;
let current = get_session(&request.session_id).unwrap_or(session.clone());
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_pi.configure.v1",
"sessionId": request.session_id,
"providerSessionId": current.provider_session_id,
"runtimeMode": current.runtime_mode,
"stateSource": if current.runtime_mode == "mock" { "mock_runtime_snapshot" } else { "pi_rpc_with_response_tracking" },
"rpcResponsePending": rpc_response_pending,
"applied": applied,
"failures": rpc_failures,
"session": current,
})))
}
pub async fn send(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<PiLabSendRequest>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
ensure_authenticated(&state, &context)?;
let session = get_session_for_context(&state, &context, &request.session_id)?;
check_rate_limit(&session.mnote_user_id, "send", PI_LAB_MAX_SENDS_PER_WINDOW)?;
if request.message.trim().is_empty() {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_prompt_required",
"prompt 不能为空",
));
}
update_session(&request.session_id, |session| {
if request
.root_uri
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
session.root_uri = request.root_uri.clone();
}
if request
.workspace_id
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
session.workspace_id = request.workspace_id.clone();
}
if request
.page_path
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
session.page_path = request.page_path.clone();
}
if request
.page_title
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
session.page_title = request.page_title.clone();
}
session.status = PiLabSessionStatus::TurnRunning;
session.message_count += 1;
});
let command_session = get_session(&request.session_id).unwrap_or(session.clone());
write_pi_mnote_context_snapshot(
&command_session,
request.selected_context.as_ref(),
request.context_refs.as_deref(),
)?;
let input_context_prefix = pi_mnote_input_context_prefix(
&command_session,
request.selected_context.as_ref(),
request.context_refs.as_deref(),
)?;
let command_message = pi_lab_command_message_for_session(
&command_session,
&request.message,
&input_context_prefix,
);
let plan_mode_prompt_applied = session_permission_mode(&command_session) == Some("plan");
let mut command = json!({
"id": generate_id("pi_rpc"),
"type": "prompt",
"message": command_message,
"displayMessage": request.message,
"context": {
"rootUri": request.root_uri,
"workspaceId": request.workspace_id,
"pagePath": request.page_path,
"pageTitle": request.page_title,
"folderPath": request.folder_path,
"contextRefs": request.context_refs,
"selectedContext": request.selected_context,
},
});
if let Some(streaming_behavior) = request
.streaming_behavior
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
command["streamingBehavior"] = json!(streaming_behavior);
}
if command_session.runtime_mode == "mock" {
publish_event(
&command_session.session_id,
"pi_rpc_event",
json!({
"type": "message_update",
"assistantMessageEvent": {
"type": "text_delta",
"delta": "[Pi Lab mock] prompt accepted"
}
}),
);
publish_event(
&command_session.session_id,
"pi_rpc_event",
json!({
"type": "citation",
"source": "lightrag-mock",
"title": "LightRAG mock citation",
"url": "#lightrag-mock-citation",
}),
);
publish_event(
&command_session.session_id,
"pi_rpc_event",
json!({
"type": "diff",
"files": [command_session.page_path.clone().unwrap_or_else(|| "page.md".into())],
}),
);
update_session(&request.session_id, |session| {
session.status = PiLabSessionStatus::RuntimeRunning;
});
} else {
if let Err(error) = send_rpc_command(&request.session_id, command).await {
update_session(&request.session_id, |session| {
session.status = PiLabSessionStatus::Error;
session.runtime_error = Some(error.message().to_string());
});
return Err(error);
}
}
let current = get_session(&request.session_id).unwrap_or(command_session.clone());
persist_upsert_run(&state, &current)?;
persist_append_event(
&state,
&current,
"user_prompt",
&json!({"message": request.message}),
)?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_pi.send.v1",
"sessionId": request.session_id,
"providerSessionId": current.provider_session_id,
"accepted": true,
"eventStream": "/api/page-ai/pi/events",
"permissionMode": session_permission_mode(&current),
"planModePromptApplied": plan_mode_prompt_applied,
})))
}
pub async fn abort(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<PiLabAbortRequest>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
ensure_authenticated(&state, &context)?;
let session = get_session_for_context(&state, &context, &request.session_id)?;
if session.runtime_mode != "mock" {
let _ = send_rpc_command(&request.session_id, json!({"type": "abort"})).await;
let _ = kill_session_process(&request.session_id).await;
}
update_session(&request.session_id, |session| {
session.status = PiLabSessionStatus::Aborted;
});
let abort_payload = json!({
"schema": "mnote.page_ai_pi.abort.v1",
"sessionId": request.session_id,
"providerSessionId": session.provider_session_id,
"aborted": true,
"stopReason": "aborted",
"command": "abort",
});
publish_event(
&request.session_id,
"runtime_aborted",
abort_payload.clone(),
);
// 持久化 abort 状态到 DB
if let Some(current) = get_session(&request.session_id) {
if let Err(e) = persist_upsert_run(&state, &current) {
// abort 已执行,DB 写失败仅记录日志,不影响 abort 返回
publish_event(
&request.session_id,
"runtime_persistence_error",
json!({
"error": e.message(),
"context": "abort_persist",
}),
);
}
}
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_pi.abort.v1",
"sessionId": request.session_id,
"providerSessionId": session.provider_session_id,
"aborted": true,
"stopReason": "aborted",
"command": "abort",
})))
}
/// POST /api/page-ai/pi/state
/// 封装官方 Pi RPC `get_state`。
/// mock 返回完整假数据;real 发送 get_state RPC 并等待响应,超时降级。
pub async fn state(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<PiLabStateRequest>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
ensure_authenticated(&state, &context)?;
let session = get_session_for_context(&state, &context, &request.session_id)?;
if !session_runtime_is_usable(&session) {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_runtime_not_started",
"Pi runtime 未启动;请先调用 /api/page-ai/pi/start",
));
}
let is_running = matches!(
session.status,
PiLabSessionStatus::RuntimeRunning | PiLabSessionStatus::TurnRunning
);
let is_streaming = session.status == PiLabSessionStatus::TurnRunning;
if session.runtime_mode == "mock" {
return Ok(Json(json!({
"ok": true,
"schema": PI_LAB_SCHEMA_STATE,
"sessionId": session.session_id,
"providerSessionId": session.provider_session_id,
"stateSource": "mock_runtime_snapshot",
"rpcResponsePending": false,
"status": session.status,
"running": true,
"isStreaming": false,
"isCompacting": false,
"modelProvider": session.model_provider.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_PROVIDER.to_string()),
"modelId": session.model_id.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_ID.to_string()),
"thinkingLevel": session.thinking_level.clone().unwrap_or_else(default_thinking_level),
"contextUsage": {
"used": 0,
"limit": 0,
"ratio": 0.0,
},
"pendingMessageCount": 0,
"queuedMessages": [],
"autoCompactionEnabled": false,
"autoRetryEnabled": false,
})));
}
// Real runtime: send get_state RPC command and wait for response
let state_response = send_rpc_command_wait(
&request.session_id,
json!({
"id": generate_id("pi_rpc_state"),
"type": "get_state",
}),
Duration::from_secs(5),
)
.await?;
if let Some(response) = state_response {
if response
.get("success")
.and_then(Value::as_bool)
.unwrap_or(false)
{
if let Some(data) = response.get("data") {
let model_provider = data
.get("model")
.and_then(|m| m.get("provider"))
.and_then(Value::as_str)
.map(String::from);
let model_id = data
.get("model")
.and_then(|m| m.get("id"))
.and_then(Value::as_str)
.map(String::from);
let thinking_level = data
.get("thinkingLevel")
.and_then(Value::as_str)
.map(String::from);
let effective_thinking_level = session
.thinking_level
.clone()
.or(thinking_level)
.unwrap_or_else(default_thinking_level);
let steering_mode = data
.get("steeringMode")
.and_then(Value::as_str)
.map(String::from);
let follow_up_mode = data
.get("followUpMode")
.and_then(Value::as_str)
.map(String::from);
let auto_compaction_enabled = data
.get("autoCompactionEnabled")
.and_then(Value::as_bool)
.unwrap_or(false);
let auto_retry_enabled = data
.get("autoRetryEnabled")
.and_then(Value::as_bool)
.unwrap_or(false);
let pending_message_count = data
.get("pendingMessageCount")
.and_then(Value::as_u64)
.unwrap_or(0);
let rpc_is_streaming = data
.get("isStreaming")
.and_then(Value::as_bool)
.unwrap_or(false);
let rpc_is_compacting = data
.get("isCompacting")
.and_then(Value::as_bool)
.unwrap_or(false);
return Ok(Json(json!({
"ok": true,
"schema": PI_LAB_SCHEMA_STATE,
"sessionId": session.session_id,
"providerSessionId": session.provider_session_id,
"stateSource": "pi_rpc_get_state_response",
"rpcResponsePending": false,
"status": session.status,
"running": is_running,
"isStreaming": rpc_is_streaming,
"isCompacting": rpc_is_compacting,
"modelProvider": model_provider.unwrap_or_else(|| session.model_provider.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_PROVIDER.to_string())),
"modelId": model_id.unwrap_or_else(|| session.model_id.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_ID.to_string())),
"thinkingLevel": effective_thinking_level,
"steeringMode": steering_mode,
"followUpMode": follow_up_mode,
"contextUsage": {
"used": 0,
"limit": 0,
"ratio": 0.0,
},
"pendingMessageCount": pending_message_count,
"queuedMessages": [],
"autoCompactionEnabled": auto_compaction_enabled,
"autoRetryEnabled": auto_retry_enabled,
})));
}
}
}
// Fallback: degraded response when RPC times out or returns error
Ok(Json(json!({
"ok": true,
"schema": PI_LAB_SCHEMA_STATE,
"sessionId": session.session_id,
"providerSessionId": session.provider_session_id,
"stateSource": "mnote_session_snapshot_with_rpc_request_pending",
"rpcResponsePending": true,
"degradedReason": "Pi RPC get_state response not received within timeout or returned error",
"status": session.status,
"running": is_running,
"isStreaming": is_streaming,
"isCompacting": false,
"modelProvider": session.model_provider.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_PROVIDER.to_string()),
"modelId": session.model_id.clone().unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_ID.to_string()),
"thinkingLevel": session.thinking_level.clone().unwrap_or_else(default_thinking_level),
"contextUsage": {
"used": 0,
"limit": 0,
"ratio": 0.0,
},
"pendingMessageCount": if session.status == PiLabSessionStatus::TurnRunning { session.message_count as u64 } else { 0u64 },
"queuedMessages": [],
"autoCompactionEnabled": false,
"autoRetryEnabled": false,
})))
}
/// POST /api/page-ai/pi/compact
/// 封装官方 Pi RPC `compact`。
/// mock 返回稳定 compact summaryreal 发送 compact RPC 并等待响应,超时降级。
pub async fn compact(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<PiLabCompactRequest>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
ensure_authenticated(&state, &context)?;
let session = get_session_for_context(&state, &context, &request.session_id)?;
if !session_runtime_is_usable(&session) {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_runtime_not_started",
"Pi runtime 未启动;请先调用 /api/page-ai/pi/start",
));
}
let compact_response = if session.runtime_mode != "mock" {
let mut command = json!({
"id": generate_id("pi_rpc_compact"),
"type": "compact",
});
if let Some(instructions) = &request.custom_instructions {
command["customInstructions"] = json!(instructions);
}
if let Some(tokens) = request.reserve_tokens {
command["reserveTokens"] = json!(tokens);
}
if let Some(tokens) = request.keep_recent_tokens {
command["keepRecentTokens"] = json!(tokens);
}
send_rpc_command_wait(&request.session_id, command, Duration::from_secs(60)).await?
} else {
None
};
let mock_summary = format!(
"Mock compact: consolidated {} previous messages into a compacted summary",
session.message_count.max(1)
);
let mock_first_kept = generate_id("compact_entry");
// Try to extract real response data
let response_data: Option<Value> = compact_response.as_ref().and_then(|resp| {
if resp
.get("success")
.and_then(Value::as_bool)
.unwrap_or(false)
{
resp.get("data").cloned()
} else {
None
}
});
let (summary, first_kept_entry_id, tokens_before, compact_details, rpc_pending, state_source) =
if session.runtime_mode == "mock" {
(
mock_summary,
Some(mock_first_kept),
Some(session.message_count.max(1) * 100),
json!({
"customInstructions": request.custom_instructions,
"reserveTokens": request.reserve_tokens,
"keepRecentTokens": request.keep_recent_tokens,
}),
false,
"mock_runtime_snapshot",
)
} else if let Some(data) = response_data {
let s = data
.get("summary")
.and_then(Value::as_str)
.unwrap_or("Compaction completed")
.to_string();
let f = data
.get("firstKeptEntryId")
.and_then(Value::as_str)
.map(String::from);
let t = data.get("tokensBefore").and_then(Value::as_u64);
let d = data.get("details").cloned().unwrap_or(json!(null));
(s, f, t, d, false, "pi_rpc_compact_response")
} else {
(
"Compaction requested via Pi RPC".to_string(),
None,
None,
json!({
"customInstructions": request.custom_instructions,
"reserveTokens": request.reserve_tokens,
"keepRecentTokens": request.keep_recent_tokens,
}),
true,
"pi_rpc_request_pending",
)
};
persist_append_event(
&state,
&session,
"runtime_compacted",
&json!({
"summary": &summary,
"details": &compact_details,
}),
)?;
publish_event(
&request.session_id,
"runtime_compacted",
json!({
"summary": &summary,
"details": &compact_details,
}),
);
Ok(Json(json!({
"ok": true,
"schema": PI_LAB_SCHEMA_COMPACT,
"sessionId": session.session_id,
"providerSessionId": session.provider_session_id,
"stateSource": state_source,
"rpcResponsePending": rpc_pending,
"summary": summary,
"firstKeptEntryId": first_kept_entry_id,
"tokensBefore": tokens_before,
"details": compact_details,
})))
}
/// POST /api/page-ai/pi/queue-config
/// 封装官方 Pi RPC `set_steering_mode`/`set_follow_up_mode`/`set_auto_compaction`。
/// 只发送请求中出现的字段,不发送未提供的字段。
/// 每个 setter 等待对应 RPC response,失败则标记 failed。
pub async fn queue_config(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<PiLabQueueConfigRequest>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
ensure_authenticated(&state, &context)?;
let session = get_session_for_context(&state, &context, &request.session_id)?;
if !session_runtime_is_usable(&session) {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_runtime_not_started",
"Pi runtime 未启动;请先调用 /api/page-ai/pi/start",
));
}
let mut applied = json!({});
let mut payload_fields = json!({});
let mut has_failed = false;
if let Some(mode) = &request.steering_mode {
let normalized = normalize_queue_mode(mode, "steeringMode")?;
if session.runtime_mode != "mock" {
let resp = send_rpc_command_wait(
&request.session_id,
json!({
"id": generate_id("pi_rpc_steer_mode"),
"type": "set_steering_mode",
"mode": &normalized,
}),
Duration::from_secs(5),
)
.await?;
let ok = resp
.as_ref()
.and_then(|r| r.get("success"))
.and_then(Value::as_bool)
.unwrap_or(false);
if ok {
applied["steeringMode"] = json!(&normalized);
} else {
applied["steeringMode"] = json!({
"value": &normalized,
"failed": true,
"error": resp.as_ref()
.and_then(|r| r.get("error"))
.and_then(Value::as_str)
.unwrap_or("timeout or no response"),
});
has_failed = true;
}
} else {
applied["steeringMode"] = json!(&normalized);
}
payload_fields["steeringMode"] = json!(&normalized);
}
if let Some(mode) = &request.follow_up_mode {
let normalized = normalize_queue_mode(mode, "followUpMode")?;
if session.runtime_mode != "mock" {
let resp = send_rpc_command_wait(
&request.session_id,
json!({
"id": generate_id("pi_rpc_followup_mode"),
"type": "set_follow_up_mode",
"mode": &normalized,
}),
Duration::from_secs(5),
)
.await?;
let ok = resp
.as_ref()
.and_then(|r| r.get("success"))
.and_then(Value::as_bool)
.unwrap_or(false);
if ok {
applied["followUpMode"] = json!(&normalized);
} else {
applied["followUpMode"] = json!({
"value": &normalized,
"failed": true,
"error": resp.as_ref()
.and_then(|r| r.get("error"))
.and_then(Value::as_str)
.unwrap_or("timeout or no response"),
});
has_failed = true;
}
} else {
applied["followUpMode"] = json!(&normalized);
}
payload_fields["followUpMode"] = json!(&normalized);
}
if let Some(enabled) = request.auto_compaction {
if session.runtime_mode != "mock" {
let resp = send_rpc_command_wait(
&request.session_id,
json!({
"id": generate_id("pi_rpc_auto_compact"),
"type": "set_auto_compaction",
"enabled": enabled,
}),
Duration::from_secs(5),
)
.await?;
let ok = resp
.as_ref()
.and_then(|r| r.get("success"))
.and_then(Value::as_bool)
.unwrap_or(false);
if ok {
applied["autoCompaction"] = json!(enabled);
} else {
applied["autoCompaction"] = json!({
"value": enabled,
"failed": true,
"error": resp.as_ref()
.and_then(|r| r.get("error"))
.and_then(Value::as_str)
.unwrap_or("timeout or no response"),
});
has_failed = true;
}
} else {
applied["autoCompaction"] = json!(enabled);
}
payload_fields["autoCompaction"] = json!(enabled);
}
persist_append_event(
&state,
&session,
"runtime_queue_config_applied",
&payload_fields,
)?;
publish_event(
&request.session_id,
"runtime_queue_config_applied",
payload_fields,
);
Ok(Json(json!({
"ok": true,
"schema": PI_LAB_SCHEMA_QUEUE_CONFIG,
"sessionId": session.session_id,
"providerSessionId": session.provider_session_id,
"stateSource": if session.runtime_mode == "mock" { "mock_runtime_snapshot" } else { "pi_rpc_with_response_tracking" },
"rpcResponsePending": session.runtime_mode != "mock" && has_failed,
"applied": applied,
})))
}
pub async fn ui_response(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<PiLabUiResponseRequest>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
ensure_authenticated(&state, &context)?;
let session = get_session_for_context(&state, &context, &request.session_id)?;
if request.id.trim().is_empty() {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_ui_request_id_required",
"extension UI request id 不能为空",
));
}
let mut command = json!({
"type": "extension_ui_response",
"id": request.id,
});
if let Some(value) = request.value.clone() {
command["value"] = value;
}
if let Some(confirmed) = request.confirmed {
command["confirmed"] = json!(confirmed);
}
if request.cancelled.unwrap_or(false) {
command["cancelled"] = json!(true);
}
if let Some(approval) = request.mnote_approval.clone() {
command["mnoteApproval"] = approval;
}
if session.runtime_mode != "mock" {
send_rpc_command(&request.session_id, command.clone()).await?;
}
if request.confirmed == Some(true) && !request.cancelled.unwrap_or(false) {
confirm_pending_approval(&request.session_id, request.mnote_approval.as_ref());
} else if request.cancelled.unwrap_or(false) || request.confirmed == Some(false) {
cancel_pending_approval(&request.session_id, request.mnote_approval.as_ref());
}
store_pending_ui_response(
&request.session_id,
&request.id,
request.value.clone(),
request.confirmed,
request.cancelled.unwrap_or(false),
);
persist_append_event(
&state,
&session,
"extension_ui_response",
&json!({
"id": command.get("id").cloned().unwrap_or(Value::Null),
"method": request.method,
"value": command.get("value").cloned().unwrap_or(Value::Null),
"confirmed": command.get("confirmed").cloned().unwrap_or(Value::Null),
"cancelled": command.get("cancelled").cloned().unwrap_or(Value::Null),
"mnoteApproval": command.get("mnoteApproval").cloned().unwrap_or(Value::Null),
}),
)?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_pi.ui_response.v1",
"sessionId": request.session_id,
"providerSessionId": session.provider_session_id,
"requestId": command.get("id").cloned().unwrap_or(Value::Null),
})))
}
pub async fn ui_request_bridge(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
headers: HeaderMap,
Json(request): Json<PiLabUiRequestBridgeRequest>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
let session = get_session_for_bridge(&headers, &request.session_id)?;
let context = context_for_session(context, &session);
ensure_authenticated(&state, &context)?;
if request.id.trim().is_empty() {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_ui_request_id_required",
"extension UI request id 不能为空",
));
}
let mut payload = json!({
"type": "extension_ui_request",
"id": request.id.clone(),
"method": request.method.clone(),
"title": request.title.clone(),
"message": request.message.clone(),
});
for (key, value) in request.extra.iter() {
if !matches!(
key.as_str(),
"sessionId"
| "session_id"
| "id"
| "method"
| "title"
| "message"
| "timeoutMs"
| "timeout_ms"
| "mnoteApproval"
| "mnote_approval"
) {
payload[key] = value.clone();
}
}
if let Some(approval) = request.mnote_approval.clone() {
payload["mnoteApproval"] = approval;
}
record_pending_approval_from_event(&session.session_id, &payload);
persist_append_event(&state, &session, "pi_rpc_event", &payload)?;
publish_event(&session.session_id, "pi_rpc_event", payload);
let approval_id = request
.mnote_approval
.as_ref()
.and_then(Value::as_object)
.and_then(|approval| {
approval
.get("approvalId")
.or_else(|| approval.get("approval_id"))
.and_then(Value::as_str)
})
.map(str::to_string)
.unwrap_or_else(|| request.id.clone());
let timeout_ms = request.timeout_ms.unwrap_or(60_000).clamp(1_000, 120_000);
let started = now_ms();
while now_ms().saturating_sub(started) < u128::from(timeout_ms) {
if let Some(response) = take_pending_ui_response(&session.session_id, &request.id) {
return Ok(Json(json!({
"ok": true,
"confirmed": response.confirmed.unwrap_or(false),
"cancelled": response.cancelled,
"value": response.value.unwrap_or(Value::Null),
"requestId": request.id,
})));
}
if let Some((confirmed, cancelled)) =
pending_approval_response(&session.session_id, &approval_id)
{
if confirmed {
return Ok(Json(json!({
"ok": true,
"confirmed": true,
"requestId": request.id,
})));
}
if cancelled {
return Ok(Json(json!({
"ok": true,
"cancelled": true,
"requestId": request.id,
})));
}
}
tokio::time::sleep(Duration::from_millis(120)).await;
}
Ok(Json(json!({
"ok": false,
"cancelled": true,
"code": "page_ai_pi_lab_ui_request_timeout",
"message": "等待 MNote 审批超时",
"requestId": request.id,
})))
}
pub async fn events(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<PiLabEventsQuery>,
) -> Result<Sse<impl futures_util::Stream<Item = Result<SseEvent, Infallible>>>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
let user_id = ensure_authenticated(&state, &context)?;
let session_filter = if let Some(session_id) = query.session_id {
let session = get_session_for_context(&state, &context, &session_id)?;
Some(session.session_id)
} else {
None
};
let backlog = if let Some(session_id) = session_filter.as_deref() {
let run_id = pi_run_id(session_id);
let limit = query.limit.unwrap_or(200).min(1000);
state
.control_plane()
.list_ai_runtime_events(&user_id, &run_id, limit)
.map_err(|e| WebError::internal(format!("查询 Pi Lab SSE backlog 失败: {e}")))?
.into_iter()
.map(|event| {
let payload: Value = serde_json::from_str(&event.payload_json).unwrap_or(json!({}));
let event_name = event.event_type.clone();
let data = json!({
"schema": PI_LAB_SCHEMA_EVENT,
"sessionId": session_id,
"kind": event_name.clone(),
"createdAt": event.created_at,
"payload": payload,
"replayed": true,
});
Ok(SseEvent::default()
.event(event_name)
.id(event.id)
.data(data.to_string()))
})
.collect::<Vec<Result<SseEvent, Infallible>>>()
} else {
Vec::new()
};
let rx = PI_LAB_EVENT_TX.subscribe();
let hello = stream::once(async {
Ok(SseEvent::default().event("connected").data(
json!({
"schema": PI_LAB_SCHEMA_EVENT,
"kind": "connected",
"version": PI_LAB_VERSION,
})
.to_string(),
))
});
let replay = stream::iter(backlog);
let stream = BroadcastStream::new(rx).filter_map(move |event| {
let session_filter = session_filter.clone();
async move {
let Ok(value) = event else {
return None;
};
if let Some(filter) = session_filter.as_deref() {
if value.get("sessionId").and_then(Value::as_str) != Some(filter) {
return None;
}
}
let event_name = value
.get("kind")
.and_then(Value::as_str)
.unwrap_or("message");
Some(Ok(SseEvent::default()
.event(event_name)
.data(value.to_string())))
}
});
Ok(Sse::new(hello.chain(replay).chain(stream)).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(30))
.text("keepalive"),
))
}
pub async fn tool_call(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<PiLabToolCallRequest>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
execute_tool(
state,
context,
request.session_id,
request.tool_name,
request.params,
false,
)
.await
}
pub async fn tool_call_bridge(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
headers: HeaderMap,
Json(request): Json<PiLabToolCallRequest>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
let session_id = request.session_id.clone().ok_or_else(|| {
WebError::bad_request_code(
"page_ai_pi_lab_bridge_session_required",
"Pi Lab tool bridge 缺少 sessionId",
)
})?;
let session = get_session_for_bridge(&headers, &session_id)?;
let context = context_for_session(context, &session);
execute_tool(
state,
context,
Some(session_id),
request.tool_name,
request.params,
true,
)
.await
}
async fn execute_mcp_bridge_request(
session: &PiLabSession,
request: &PiLabMcpBridgeRequest,
) -> Result<Value, WebError> {
if !pi_lab_mcp_enabled(session) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_mcp_disabled",
"当前 Pi session 未启用 MCP bridge",
));
}
let server = request.server.trim();
if server.is_empty() {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_mcp_server_required",
"MCP bridge 缺少 server",
));
}
let mode = request.mode.trim().to_ascii_lowercase();
if !matches!(mode.as_str(), "list" | "status" | "call") {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_mcp_mode_invalid",
"MCP bridge mode 只支持 list/status/call",
));
}
let tool = request
.tool
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if mode == "call" && tool.is_none() {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_mcp_tool_required",
"MCP bridge mode=call 时必须提供 tool",
));
}
if request
.arguments
.as_ref()
.is_some_and(|value| !value.is_object())
{
return Err(WebError::bad_request_code(
"page_ai_pi_lab_mcp_arguments_invalid",
"MCP bridge arguments 必须是对象",
));
}
let server_config = session
.runtime_policy_snapshot
.as_ref()
.and_then(|value| value.get("mcpServers"))
.and_then(Value::as_object)
.and_then(|servers| servers.get(server))
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_pi_lab_mcp_server_not_configured",
format!("MCP server 未配置: {server}"),
)
})?;
if server_config
.get("disabled")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_mcp_server_disabled",
format!("MCP server 已禁用: {server}"),
));
}
let config_path = PathBuf::from(&session.pi_session_dir)
.join("config")
.join("mcp.json");
if !config_path.is_file() {
return Err(WebError::internal(format!(
"Pi session MCP config 不存在: {}",
config_path.display()
)));
}
let client_path = mnote_pi_mcp_client_path();
if !client_path.is_file() {
return Err(WebError::internal(format!(
"MNote MCP client 不存在: {}",
client_path.display()
)));
}
let payload = serde_json::to_vec(&json!({
"server": server,
"mode": mode,
"tool": tool,
"arguments": request.arguments.clone().unwrap_or_else(|| json!({})),
}))
.map_err(|error| WebError::internal(format!("序列化 MCP bridge 请求失败: {error}")))?;
let node_binary = env_trimmed("MNOTE_PAGE_AI_PI_MCP_NODE_BIN").unwrap_or_else(|| "node".into());
let mut command = Command::new(node_binary);
command
.arg(&client_path)
.arg("-")
.env("MNOTE_MCP_CONFIG_PATH", &config_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(parent) = client_path.parent() {
command.current_dir(parent);
}
let mut child = command
.spawn()
.map_err(|error| WebError::internal(format!("启动 MNote MCP client 失败: {error}")))?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| WebError::internal("MNote MCP client stdin 不可用".to_string()))?;
stdin
.write_all(&payload)
.await
.map_err(|error| WebError::internal(format!("写入 MCP bridge 请求失败: {error}")))?;
stdin
.shutdown()
.await
.map_err(|error| WebError::internal(format!("关闭 MCP bridge stdin 失败: {error}")))?;
drop(stdin);
let output = tokio::time::timeout(
Duration::from_millis(PI_LAB_MCP_BRIDGE_TIMEOUT_MS),
child.wait_with_output(),
)
.await
.map_err(|_| {
WebError::new(
StatusCode::GATEWAY_TIMEOUT,
"page_ai_pi_lab_mcp_timeout",
format!("MCP bridge 超时: {server}/{mode}"),
)
})?
.map_err(|error| WebError::internal(format!("等待 MNote MCP client 失败: {error}")))?;
if output.stdout.len() > PI_LAB_MCP_BRIDGE_MAX_OUTPUT_BYTES {
return Err(WebError::new(
StatusCode::BAD_GATEWAY,
"page_ai_pi_lab_mcp_output_too_large",
"MCP bridge 输出超过 2 MiB 限制",
));
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WebError::new(
StatusCode::BAD_GATEWAY,
"page_ai_pi_lab_mcp_client_failed",
format!(
"MNote MCP client 执行失败: {}",
stderr.trim().chars().take(2000).collect::<String>()
),
));
}
let stdout = String::from_utf8(output.stdout)
.map_err(|error| WebError::internal(format!("MCP bridge 输出不是 UTF-8: {error}")))?;
serde_json::from_str(stdout.trim())
.map_err(|error| WebError::internal(format!("解析 MCP bridge 输出失败: {error}")))
}
pub async fn mcp_call_bridge(
State(state): State<AppState>,
headers: HeaderMap,
Json(request): Json<PiLabMcpBridgeRequest>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
let session = get_session_for_bridge(&headers, &request.session_id)?;
check_rate_limit(&session.mnote_user_id, "mcp", PI_LAB_MAX_TOOLS_PER_WINDOW)?;
Ok(Json(execute_mcp_bridge_request(&session, &request).await?))
}
fn snapshot_allowed_roots(
state: &AppState,
context: &RequestContext,
) -> Result<Option<Value>, WebError> {
match active_allowed_roots(state, context) {
Ok(roots) => Ok(Some(json!({
"roots": roots.iter().map(|root| json!({
"rootUri": root.root_uri,
"rootPath": root.root_path,
"workspaceId": root.workspace_id,
"permission": root.permission,
"source": root.source,
})).collect::<Vec<_>>(),
"capturedAtMs": now_ms(),
}))),
Err(_) => Ok(None),
}
}
// ---------------------------------------------------------------------------
// DB persistence helpers
// ---------------------------------------------------------------------------
fn pi_run_id(session_id: &str) -> String {
format!("pi_run_{session_id}")
}
fn session_status_to_string(status: &PiLabSessionStatus) -> &'static str {
match status {
PiLabSessionStatus::Idle => "idle",
PiLabSessionStatus::RuntimeRunning => "runtime_running",
PiLabSessionStatus::TurnRunning => "turn_running",
PiLabSessionStatus::Aborted => "aborted",
PiLabSessionStatus::Error => "error",
}
}
// ── JSONL reader limits ──────────────────────────────────────────
const PI_LAB_JSONL_MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; // 10 MiB
const PI_LAB_JSONL_MAX_LINE_BYTES: u64 = 100 * 1024; // 100 KiB
const PI_LAB_JSONL_MAX_ENTRIES: usize = 5000;
const PI_LAB_JSONL_WINDOW_ENTRIES: usize = 2000;
fn build_run_runtime_json(session: &PiLabSession) -> String {
serde_json::to_string(&json!({
"providerSessionId": session.provider_session_id,
"piSessionDir": session.pi_session_dir,
"piSessionFile": session.pi_session_file,
"runtimeMode": session.runtime_mode,
"modelProvider": session.model_provider,
"modelId": session.model_id,
"thinkingLevel": session.thinking_level,
"messageCount": session.message_count,
"pagePath": session.page_path,
"pageTitle": session.page_title,
"rootUri": session.root_uri,
"workspaceId": session.workspace_id,
"allowedRootsSnapshot": session.allowed_roots_snapshot,
"runtimePolicy": session.runtime_policy_snapshot,
}))
.unwrap_or_default()
}
fn build_upsert_run_input(session: &PiLabSession) -> UpsertAiRuntimeRunInput {
UpsertAiRuntimeRunInput {
id: None,
user_id: session.mnote_user_id.clone(),
workspace_id: session.workspace_id.clone(),
document_id: session.page_path.clone(),
session_id: session.session_id.clone(),
run_id: pi_run_id(&session.session_id),
title: session.page_title.clone(),
profile: PI_LAB_PROFILE.to_string(),
acp_runtime: PI_LAB_ACP_RUNTIME.to_string(),
trace_id: None,
status: session_status_to_string(&session.status).to_string(),
runtime_json: build_run_runtime_json(session),
payload_json: "{}".to_string(),
}
}
fn persist_upsert_run(state: &AppState, session: &PiLabSession) -> Result<(), WebError> {
let input = build_upsert_run_input(session);
state
.control_plane()
.upsert_ai_runtime_run(input)
.map_err(|e| WebError::internal(format!("持久化 Pi Lab run 失败: {e}")))?;
Ok(())
}
fn build_append_event_input(
session: &PiLabSession,
event_type: &str,
payload_json: &str,
) -> AppendAiRuntimeEventInput {
AppendAiRuntimeEventInput {
id: None,
user_id: session.mnote_user_id.clone(),
workspace_id: session.workspace_id.clone(),
document_id: session.page_path.clone(),
session_id: session.session_id.clone(),
run_id: pi_run_id(&session.session_id),
profile: PI_LAB_PROFILE.to_string(),
acp_runtime: PI_LAB_ACP_RUNTIME.to_string(),
event_type: event_type.to_string(),
payload_json: payload_json.to_string(),
}
}
fn persist_append_event(
state: &AppState,
session: &PiLabSession,
event_type: &str,
payload: &Value,
) -> Result<(), WebError> {
let payload_json = serde_json::to_string(payload).unwrap_or_else(|_| "{}".into());
let input = build_append_event_input(session, event_type, &payload_json);
state
.control_plane()
.append_ai_runtime_event(input)
.map_err(|e| WebError::internal(format!("持久化 Pi Lab event 失败: {e}")))?;
Ok(())
}
// ── B2: 受控 Pi JSONL 读取 helper ──────────────────────────────────
/// 仅读取当前 session 自己的 pi_session_file。
/// 单行上限 PI_LAB_JSONL_MAX_LINE_BYTES,总大小上限 PI_LAB_JSONL_MAX_FILE_BYTESentry 上限 PI_LAB_JSONL_MAX_ENTRIES。
/// 返回解析后的 entry 列表。文件不存在或超出限制返回明确错误。
fn read_pi_session_jsonl(session: &PiLabSession) -> Result<Vec<Value>, WebError> {
let pi_session_file = session.pi_session_file.as_deref().ok_or_else(|| {
WebError::new(
StatusCode::NOT_FOUND,
"page_ai_pi_lab_no_pi_session_file",
"当前 Pi session 还没有 pi_session_file 记录;请先启动 runtime 后再查询",
)
})?;
let path = Path::new(pi_session_file);
// 安全验证:路径必须在 session pi_session_dir 内
let session_dir = Path::new(&session.pi_session_dir);
let canonical_path = canonical_or_parent(path);
let canonical_dir = canonical_or_parent(session_dir);
if !canonical_path.starts_with(&canonical_dir) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_jsonl_path_escape",
"JSONL 文件必须在 session 目录内",
));
}
if !path.exists() {
return Ok(Vec::new());
}
let metadata = fs::metadata(path)
.map_err(|e| WebError::internal(format!("读取 Pi JSONL metadata 失败: {e}")))?;
if metadata.len() > PI_LAB_JSONL_MAX_FILE_BYTES as u64 {
return Err(WebError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"page_ai_pi_lab_jsonl_too_large",
format!(
"Pi JSONL 文件过大 ({} bytes, 上限 {} bytes)",
metadata.len(),
PI_LAB_JSONL_MAX_FILE_BYTES
),
));
}
let raw = fs::read_to_string(path)
.map_err(|e| WebError::internal(format!("读取 Pi JSONL 失败: {e}")))?;
let mut entries: Vec<Value> = Vec::new();
for line in raw.lines() {
if line.trim().is_empty() {
continue;
}
if line.len() as u64 > PI_LAB_JSONL_MAX_LINE_BYTES {
return Err(WebError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"page_ai_pi_lab_jsonl_line_too_long",
format!(
"Pi JSONL 单行过长 ({} bytes, 上限 {} bytes)",
line.len(),
PI_LAB_JSONL_MAX_LINE_BYTES
),
));
}
match serde_json::from_str::<Value>(line) {
Ok(entry) => entries.push(entry),
Err(e) => {
// 跳过无法解析的行,但记录日志
entries.push(json!({
"parseError": format!("无法解析 JSONL 行: {e}"),
"rawPreview": truncate_text(line, 200),
}));
}
}
if entries.len() > PI_LAB_JSONL_MAX_ENTRIES {
return Err(WebError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"page_ai_pi_lab_jsonl_too_many_entries",
format!(
"Pi JSONL entry 过多 (>{}, 上限 {})",
PI_LAB_JSONL_MAX_ENTRIES, PI_LAB_JSONL_MAX_ENTRIES
),
));
}
}
Ok(entries)
}
fn pi_lab_entry_parent_id(entry: &Value) -> Option<&str> {
entry
.get("parent_id")
.or_else(|| entry.get("parentId"))
.or_else(|| entry.get("parentEntryId"))
.and_then(Value::as_str)
}
fn pi_lab_entry_message(entry: &Value) -> Option<&Value> {
entry
.get("message")
.filter(|message| message.is_object())
}
fn pi_lab_entry_role(entry: &Value) -> String {
entry
.get("role")
.and_then(Value::as_str)
.or_else(|| {
pi_lab_entry_message(entry)
.and_then(|message| message.get("role"))
.and_then(Value::as_str)
})
.unwrap_or("")
.to_string()
}
fn pi_lab_content_block_text(block: &Value) -> Option<String> {
block
.get("text")
.and_then(Value::as_str)
.or_else(|| block.get("content").and_then(Value::as_str))
.or_else(|| block.get("message").and_then(Value::as_str))
.map(str::to_string)
}
fn pi_lab_content_blocks_text(content: &Value, include_tool_result: bool) -> String {
if let Some(text) = content.as_str() {
return text.to_string();
}
let Some(items) = content.as_array() else {
return String::new();
};
items
.iter()
.filter_map(|item| {
let kind = item.get("type").and_then(Value::as_str).unwrap_or("");
if kind == "text"
|| kind == "input_text"
|| (include_tool_result
&& matches!(kind, "tool_result" | "output_text" | "text_delta"))
{
pi_lab_content_block_text(item)
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn pi_lab_entry_text(entry: &Value) -> String {
if let Some(text) = entry
.get("text")
.or_else(|| entry.get("content"))
.and_then(Value::as_str)
{
return text.to_string();
}
let Some(message) = pi_lab_entry_message(entry) else {
return entry
.get("message")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
};
let role = message.get("role").and_then(Value::as_str).unwrap_or("");
let include_tool_result = role == "toolResult" || role == "tool_result";
message
.get("content")
.map(|content| pi_lab_content_blocks_text(content, include_tool_result))
.unwrap_or_default()
}
fn pi_lab_message_content_text(message: &Value, include_tool_result: bool) -> String {
message
.get("content")
.map(|content| pi_lab_content_blocks_text(content, include_tool_result))
.unwrap_or_default()
}
fn pi_lab_entry_tool_calls(entry: &Value) -> Vec<Value> {
let Some(message) = pi_lab_entry_message(entry) else {
return Vec::new();
};
let Some(items) = message.get("content").and_then(Value::as_array) else {
return Vec::new();
};
items
.iter()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("toolCall"))
.map(|item| {
json!({
"id": item.get("id"),
"name": item.get("name"),
"arguments": item.get("arguments"),
})
})
.collect()
}
fn pi_lab_message_tool_calls(message: &Value) -> Vec<Value> {
let Some(items) = message.get("content").and_then(Value::as_array) else {
return Vec::new();
};
items
.iter()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("toolCall"))
.map(|item| {
json!({
"id": item.get("id"),
"toolCallId": item.get("toolCallId").or_else(|| item.get("tool_call_id")).or_else(|| item.get("id")),
"toolName": item.get("name").or_else(|| item.get("toolName")).or_else(|| item.get("tool_name")),
"name": item.get("name").or_else(|| item.get("toolName")).or_else(|| item.get("tool_name")),
"args": item.get("args").or_else(|| item.get("arguments")).cloned().unwrap_or_else(|| json!({})),
"status": "running",
})
})
.collect()
}
fn pi_lab_tool_result_call_id(message: &Value) -> String {
message
.get("toolCallId")
.or_else(|| message.get("tool_call_id"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
fn pi_lab_tool_result_name(message: &Value) -> String {
message
.get("toolName")
.or_else(|| message.get("tool_name"))
.and_then(Value::as_str)
.unwrap_or("tool")
.to_string()
}
fn pi_lab_entry_id(entry: &Value) -> String {
entry
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
fn pi_lab_entry_seq(entry: &Value) -> i64 {
entry
.get("seq")
.or_else(|| entry.get("entry_seq"))
.and_then(Value::as_i64)
.unwrap_or(0)
}
fn pi_lab_entry_created_at(entry: &Value) -> Value {
entry
.get("created_at")
.or_else(|| entry.get("timestamp"))
.or_else(|| entry.get("createdAt"))
.cloned()
.unwrap_or(Value::Null)
}
fn pi_lab_active_path_entries(entries: &[Value]) -> Vec<&Value> {
if entries.is_empty() {
return Vec::new();
}
let mut by_id: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for (index, entry) in entries.iter().enumerate() {
let id = pi_lab_entry_id(entry);
if !id.is_empty() {
by_id.insert(id, index);
}
}
let Some(leaf_id) = entries
.iter()
.max_by_key(|entry| pi_lab_entry_seq(entry))
.map(pi_lab_entry_id)
.filter(|id| !id.is_empty())
else {
return entries.iter().collect();
};
let mut path = Vec::new();
let mut visited = std::collections::HashSet::new();
let mut current = Some(leaf_id);
while let Some(id) = current {
if !visited.insert(id.clone()) {
break;
}
let Some(index) = by_id.get(&id).copied() else {
break;
};
let entry = &entries[index];
path.push(entry);
current = pi_lab_entry_parent_id(entry).map(str::to_string);
}
path.reverse();
if path.is_empty() || (path.len() <= 1 && entries.len() > 1) {
entries.iter().collect()
} else {
path
}
}
fn build_pi_replay_messages(entries: &[Value]) -> Vec<Value> {
let mut messages: Vec<Value> = Vec::new();
for entry in pi_lab_active_path_entries(entries) {
let Some(message) = pi_lab_entry_message(entry) else {
continue;
};
let role = message.get("role").and_then(Value::as_str).unwrap_or("");
let entry_id = pi_lab_entry_id(entry);
let base = json!({
"entryId": entry_id,
"parentId": pi_lab_entry_parent_id(entry),
"seq": pi_lab_entry_seq(entry),
"createdAt": pi_lab_entry_created_at(entry),
});
match role {
"user" => {
let text = pi_lab_message_content_text(message, false);
if !text.trim().is_empty() {
messages.push(json!({
"entryId": base["entryId"],
"parentId": base["parentId"],
"seq": base["seq"],
"createdAt": base["createdAt"],
"role": "user",
"text": text,
"meta": "",
}));
}
}
"assistant" => {
let text = pi_lab_message_content_text(message, false);
let tool_calls = pi_lab_message_tool_calls(message);
if !text.trim().is_empty() || !tool_calls.is_empty() {
messages.push(json!({
"entryId": base["entryId"],
"parentId": base["parentId"],
"seq": base["seq"],
"createdAt": base["createdAt"],
"role": "assistant",
"text": text,
"meta": pi_lab_entry_meta(entry),
"toolCalls": tool_calls,
}));
}
}
"toolResult" | "tool_result" => {
let text = pi_lab_message_content_text(message, true);
let tool_name = pi_lab_tool_result_name(message);
let tool_call_id = pi_lab_tool_result_call_id(message);
let is_error = message
.get("isError")
.or_else(|| message.get("is_error"))
.and_then(Value::as_bool)
.unwrap_or(false);
let tool_call = json!({
"id": tool_call_id,
"toolCallId": tool_call_id,
"toolName": tool_name,
"name": tool_name,
"status": if is_error { "error" } else { "done" },
"result": {
"content": message.get("content").cloned().unwrap_or_else(|| json!([])),
"details": message.get("details").cloned().unwrap_or(Value::Null),
},
"isError": is_error,
});
messages.push(json!({
"entryId": base["entryId"],
"parentId": base["parentId"],
"seq": base["seq"],
"createdAt": base["createdAt"],
"role": "assistant",
"text": "",
"meta": if text.trim().is_empty() { "" } else { "tool result" },
"toolCalls": [tool_call],
}));
}
"custom" => {
let display = message
.get("display")
.and_then(Value::as_bool)
.unwrap_or(false);
let text = message
.get("content")
.and_then(Value::as_str)
.unwrap_or_default();
if display && !text.trim().is_empty() {
messages.push(json!({
"entryId": base["entryId"],
"parentId": base["parentId"],
"seq": base["seq"],
"createdAt": base["createdAt"],
"role": "system",
"text": text,
"meta": message.get("customType").or_else(|| message.get("custom_type")).and_then(Value::as_str).unwrap_or("custom"),
}));
}
}
"bashExecution" | "bash_execution" => {
messages.push(json!({
"entryId": base["entryId"],
"parentId": base["parentId"],
"seq": base["seq"],
"createdAt": base["createdAt"],
"role": "assistant",
"text": "",
"meta": "bash execution",
"toolCalls": [{
"id": base["entryId"],
"toolCallId": base["entryId"],
"toolName": "bash",
"name": "bash",
"status": if message.get("exitCode").or_else(|| message.get("exit_code")).and_then(Value::as_i64).unwrap_or(0) == 0 { "done" } else { "error" },
"args": {"command": message.get("command").cloned().unwrap_or(Value::Null)},
"result": {"content": [{"type": "text", "text": message.get("output").and_then(Value::as_str).unwrap_or_default()}]},
}],
}));
}
_ => {}
}
}
messages
}
fn pi_lab_entry_meta(entry: &Value) -> String {
pi_lab_entry_message(entry)
.and_then(|message| message.get("stopReason"))
.and_then(Value::as_str)
.map(|reason| format!("stopReason={reason}"))
.unwrap_or_default()
}
/// 从解析后的 JSONL entry 列表中提取 entry tree、active leaf、compaction/branch 摘要。
fn build_pi_entry_tree(entries: &[Value]) -> Value {
let mut nodes = json!({});
let mut edges: Vec<Value> = Vec::new();
let mut compaction_summaries: Vec<Value> = Vec::new();
let mut branch_summaries: Vec<Value> = Vec::new();
let mut _active_leaf_id: Option<String> = None;
let mut max_seq: i64 = -1;
for entry in entries {
let entry_id = entry.get("id").and_then(Value::as_str).unwrap_or("");
let entry_type = entry
.get("type")
.and_then(Value::as_str)
.unwrap_or("unknown");
let parent_id = pi_lab_entry_parent_id(entry);
let role = pi_lab_entry_role(entry);
let seq = entry
.get("seq")
.or_else(|| entry.get("entry_seq"))
.and_then(Value::as_i64)
.unwrap_or(0);
if seq > max_seq {
max_seq = seq;
_active_leaf_id = (!entry_id.is_empty()).then(|| entry_id.to_string());
}
let preview = truncate_text(&pi_lab_entry_text(entry), 200);
nodes[entry_id] = json!({
"id": entry_id,
"type": entry_type,
"role": role,
"preview": preview,
"seq": seq,
"parentId": parent_id,
});
if let Some(pid) = parent_id {
if !pid.is_empty() {
edges.push(json!({
"from": pid,
"to": entry_id,
}));
}
}
if entry_type == "compaction" {
let summary = entry
.get("summary")
.or_else(|| entry.get("details"))
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_default();
compaction_summaries.push(json!({
"entryId": entry_id,
"summary": summary,
"tokensBefore": entry.get("tokensBefore").or_else(|| entry.get("tokens_before")),
"firstKeptEntryId": entry.get("firstKeptEntryId").or_else(|| entry.get("first_kept_entry_id")),
}));
}
if entry_type == "branch_summary" {
branch_summaries.push(json!({
"entryId": entry_id,
"summary": entry.get("summary").and_then(Value::as_str).unwrap_or(""),
"branchPointEntryId": entry.get("branchPointEntryId").or_else(|| entry.get("branch_point_entry_id")),
}));
}
}
json!({
"nodes": nodes,
"edges": edges,
})
}
// ── B1: 扫描并记录 pi_session_file ──────────────────────────────────
/// 从 session dir 中扫描 Pi JSONL session 文件并设置 pi_session_file。
/// Pi 的命名模式:YYYY-MM-DDTHH-MM-SS.sssZ_id.jsonl
fn resolve_pi_session_file(session: &PiLabSession) -> Option<String> {
let session_dir = Path::new(&session.pi_session_dir);
if !session_dir.exists() {
return None;
}
// Pi 创建 session 时可能在 session_dir 下创建子目录,或直接在 session_dir 中创建 .jsonl
// 先在 session_dir 中找 .jsonl 文件
let mut candidates: Vec<PathBuf> = Vec::new();
if let Ok(entries) = fs::read_dir(session_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
// 检查命名模式
if let Some(name) = path.file_stem().and_then(|n| n.to_str()) {
// Pi pattern: YYYY-MM-DDTHH-MM-SS.sssZ_id 或包含 timestamp
if name.contains('T') || name.len() > 20 {
candidates.push(path);
}
}
}
}
}
// 也检查 session_dir 的子目录(Pi 可能创建 session-index.sqlite 等,但 .jsonl 通常在 session_dir 下)
// 按修改时间排序,取最新的
candidates.sort_by_key(|p| fs::metadata(p).ok().and_then(|m| m.modified().ok()));
candidates
.last()
.and_then(|p| p.to_str().map(str::to_string))
}
// ── B3: GET /api/page-ai/pi/sessions/{sessionId}/tree ──────────────
#[derive(Debug, Deserialize)]
pub struct PiLabSessionTreeQuery {
pub window_entries: Option<usize>,
}
pub async fn session_tree(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
axum::extract::Path(path): axum::extract::Path<PiLabSessionPathParam>,
Query(query): Query<PiLabSessionTreeQuery>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
let user_id = ensure_authenticated(&state, &context)?;
// 先检查内存 session
let session = get_session(&path.session_id)
.or_else(|| {
// fallback: 从 control-plane 恢复 session 元数据
state
.control_plane()
.find_ai_runtime_run(&user_id, &pi_run_id(&path.session_id))
.ok()
.flatten()
.map(|run| {
let runtime: Value =
serde_json::from_str(&run.runtime_json).unwrap_or(json!({}));
PiLabSession {
session_id: run.session_id,
mnote_user_id: run.user_id,
bridge_token: String::new(),
status: PiLabSessionStatus::Idle,
provider_session_id: runtime
.get("providerSessionId")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
pi_session_dir: runtime
.get("piSessionDir")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
pi_session_file: runtime
.get("piSessionFile")
.and_then(Value::as_str)
.map(str::to_string),
root_uri: runtime
.get("rootUri")
.and_then(Value::as_str)
.map(str::to_string),
workspace_id: run.workspace_id,
page_path: run.document_id,
page_title: run.title,
model_provider: runtime
.get("modelProvider")
.and_then(Value::as_str)
.map(str::to_string),
model_id: runtime
.get("modelId")
.and_then(Value::as_str)
.map(str::to_string),
thinking_level: runtime
.get("thinkingLevel")
.and_then(Value::as_str)
.map(str::to_string),
allowed_roots_snapshot: runtime.get("allowedRootsSnapshot").cloned(),
runtime_policy_snapshot: runtime.get("runtimePolicy").cloned(),
runtime_pid: None,
runtime_mode: runtime
.get("runtimeMode")
.and_then(Value::as_str)
.unwrap_or("mock")
.to_string(),
runtime_error: None,
created_at_ms: 0,
updated_at_ms: 0,
message_count: 0,
}
})
})
.ok_or_else(|| {
WebError::new(
StatusCode::NOT_FOUND,
"page_ai_pi_lab_session_not_found",
"Pi Lab session 不存在或不属于当前用户",
)
})?;
// Verify ownership
if session.mnote_user_id != user_id {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_session_owner_mismatch",
"Pi Lab session 不属于当前登录主体",
));
}
// B2: Read JSONL
let entries = match read_pi_session_jsonl(&session) {
Ok(entries) => entries,
Err(e) => {
return Ok(Json(json!({
"ok": true,
"schema": PI_LAB_SCHEMA_SESSION_TREE,
"sessionId": session.session_id,
"providerSessionId": session.provider_session_id,
"piSessionFile": session.pi_session_file,
"status": session.status,
"entries": [],
"entryTree": json!({}),
"activeLeafId": Value::Null,
"compactionSummaries": [],
"branchSummaries": [],
"totalEntries": 0,
"degradedReason": format!("Failed to read Pi JSONL: {}", e.message()),
})));
}
};
let total_entries = entries.len();
let replay_messages = build_pi_replay_messages(&entries);
let window_size = query
.window_entries
.unwrap_or(PI_LAB_JSONL_WINDOW_ENTRIES)
.min(total_entries);
// Window entries (last N for preview, for memory safety)
let window_start = if window_size >= total_entries {
0
} else {
total_entries - window_size
};
let window_entries: Vec<Value> = entries[window_start..]
.iter()
.map(|entry| {
json!({
"id": entry.get("id"),
"parentId": pi_lab_entry_parent_id(entry).map(|parent| Value::String(parent.to_string())),
"type": entry.get("type").unwrap_or(&json!("unknown")),
"role": pi_lab_entry_role(entry),
"text": pi_lab_entry_text(entry),
"preview": truncate_text(&pi_lab_entry_text(entry), 200),
"meta": pi_lab_entry_meta(entry),
"toolCalls": pi_lab_entry_tool_calls(entry),
"seq": entry.get("seq").or_else(|| entry.get("entry_seq")),
"createdAt": entry.get("created_at").or_else(|| entry.get("timestamp")).or_else(|| entry.get("createdAt")),
})
})
.collect();
// Build tree from full entries
let entry_tree = build_pi_entry_tree(&entries);
// Extract summaries
let compaction_summaries: Vec<Value> = entries.iter()
.filter(|e| e.get("type").and_then(Value::as_str) == Some("compaction"))
.map(|e| json!({
"entryId": e.get("id"),
"summary": e.get("summary").or_else(|| e.get("details")).and_then(Value::as_str).unwrap_or(""),
"tokensBefore": e.get("tokensBefore").or_else(|| e.get("tokens_before")),
"firstKeptEntryId": e.get("firstKeptEntryId").or_else(|| e.get("first_kept_entry_id")),
}))
.collect();
let branch_summaries: Vec<Value> = entries.iter()
.filter(|e| e.get("type").and_then(Value::as_str) == Some("branch_summary"))
.map(|e| json!({
"entryId": e.get("id"),
"summary": e.get("summary").and_then(Value::as_str).unwrap_or(""),
"branchPointEntryId": e.get("branchPointEntryId").or_else(|| e.get("branch_point_entry_id")),
}))
.collect();
// Determine active leaf (last entry with max seq or last entry in window)
let active_leaf_id = entries
.iter()
.max_by_key(|e| {
e.get("seq")
.or_else(|| e.get("entry_seq"))
.and_then(Value::as_i64)
.unwrap_or(0)
})
.and_then(|e| e.get("id").and_then(Value::as_str).map(str::to_string));
Ok(Json(json!({
"ok": true,
"schema": PI_LAB_SCHEMA_SESSION_TREE,
"sessionId": session.session_id,
"providerSessionId": session.provider_session_id,
"piSessionFile": session.pi_session_file,
"status": session.status,
"entries": window_entries,
"messages": replay_messages,
"entryTree": entry_tree,
"activeLeafId": active_leaf_id,
"compactionSummaries": compaction_summaries,
"branchSummaries": branch_summaries,
"totalEntries": total_entries,
"windowStart": window_start,
"windowSize": window_size,
"degradedReason": Value::Null,
})))
}
// ── B5: POST /api/page-ai/pi/fork ──────────────────────────────────
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabForkRequest {
pub session_id: String,
pub entry_id: Option<String>,
}
pub async fn fork_pi_session(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<PiLabForkRequest>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
ensure_authenticated(&state, &context)?;
let source_session = get_session_for_context(&state, &context, &request.session_id)?;
let rpc_response_pending: bool;
let degraded_reason: Option<String>;
let new_session: PiLabSession;
if source_session.runtime_mode == "mock" {
// Mock fork: generate a stable mock fork session
let entry_id = request
.entry_id
.clone()
.unwrap_or_else(|| "mock_root_entry".to_string());
new_session = PiLabSession {
session_id: generate_id("pi_fork"),
mnote_user_id: source_session.mnote_user_id.clone(),
bridge_token: generate_bridge_token(),
status: PiLabSessionStatus::RuntimeRunning,
provider_session_id: generate_id("pi_provider_fork"),
pi_session_dir: source_session.pi_session_dir.clone(),
pi_session_file: source_session.pi_session_file.clone(),
root_uri: source_session.root_uri.clone(),
workspace_id: source_session.workspace_id.clone(),
page_path: source_session.page_path.clone(),
page_title: Some(format!(
"{} (fork)",
source_session.page_title.as_deref().unwrap_or("Pi Session")
)),
model_provider: source_session.model_provider.clone(),
model_id: source_session.model_id.clone(),
thinking_level: source_session.thinking_level.clone(),
allowed_roots_snapshot: source_session.allowed_roots_snapshot.clone(),
runtime_policy_snapshot: source_session.runtime_policy_snapshot.clone(),
runtime_pid: None,
runtime_mode: "mock".to_string(),
runtime_error: None,
created_at_ms: now_ms(),
updated_at_ms: now_ms(),
message_count: 0,
};
upsert_session(new_session.clone());
let _ = persist_upsert_run(&state, &new_session);
rpc_response_pending = false;
degraded_reason = None;
publish_event(
&new_session.session_id,
"runtime_started",
json!({
"mode": "mock_fork",
"sourceSessionId": source_session.session_id,
"forkEntryId": entry_id,
"providerSessionId": new_session.provider_session_id,
"sessionId": new_session.session_id,
}),
);
} else if session_runtime_is_usable(&source_session) {
// Real runtime: send fork RPC command
let fork_response = send_rpc_command_wait(
&request.session_id,
json!({
"id": generate_id("pi_rpc_fork"),
"type": "fork",
"entryId": request.entry_id,
}),
Duration::from_secs(10),
)
.await?;
if let Some(ref response) = fork_response {
if response
.get("success")
.and_then(Value::as_bool)
.unwrap_or(false)
{
if let Some(data) = response.get("data") {
let forked_session_id =
data.get("sessionId").and_then(Value::as_str).unwrap_or("");
new_session = PiLabSession {
session_id: generate_id("pi_fork"),
mnote_user_id: source_session.mnote_user_id.clone(),
bridge_token: generate_bridge_token(),
status: PiLabSessionStatus::RuntimeRunning,
provider_session_id: data
.get("providerSessionId")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
pi_session_dir: data
.get("sessionDir")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| source_session.pi_session_dir.clone()),
pi_session_file: None,
root_uri: source_session.root_uri.clone(),
workspace_id: source_session.workspace_id.clone(),
page_path: source_session.page_path.clone(),
page_title: Some(format!(
"{} (fork)",
source_session.page_title.as_deref().unwrap_or("Pi Session")
)),
model_provider: source_session.model_provider.clone(),
model_id: source_session.model_id.clone(),
thinking_level: source_session.thinking_level.clone(),
allowed_roots_snapshot: source_session.allowed_roots_snapshot.clone(),
runtime_policy_snapshot: source_session.runtime_policy_snapshot.clone(),
runtime_pid: None,
runtime_mode: source_session.runtime_mode.clone(),
runtime_error: None,
created_at_ms: now_ms(),
updated_at_ms: now_ms(),
message_count: 0,
};
upsert_session(new_session.clone());
let _ = persist_upsert_run(&state, &new_session);
rpc_response_pending = false;
degraded_reason = None;
publish_event(
&new_session.session_id,
"runtime_started",
json!({
"mode": "fork",
"sourceSessionId": source_session.session_id,
"forkedSessionId": forked_session_id,
"providerSessionId": new_session.provider_session_id,
"sessionId": new_session.session_id,
}),
);
} else {
return Err(WebError::bad_gateway_code(
"page_ai_pi_lab_fork_response_no_data",
"Pi fork RPC 返回成功但没有 data",
));
}
} else {
rpc_response_pending = true;
degraded_reason = Some(rpc_response_error_message(
fork_response.as_ref(),
"Pi fork RPC 返回错误",
));
// Return degraded response with source session info
return Ok(Json(json!({
"ok": true,
"schema": PI_LAB_SCHEMA_FORK,
"sourceSessionId": source_session.session_id,
"sessionId": Value::Null,
"session": Value::Null,
"rpcResponsePending": rpc_response_pending,
"degradedReason": degraded_reason,
})));
}
} else {
rpc_response_pending = true;
degraded_reason = Some("Pi fork RPC 超时或未收到响应".to_string());
return Ok(Json(json!({
"ok": true,
"schema": PI_LAB_SCHEMA_FORK,
"sourceSessionId": source_session.session_id,
"sessionId": Value::Null,
"session": Value::Null,
"rpcResponsePending": rpc_response_pending,
"degradedReason": degraded_reason,
})));
}
} else {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_runtime_not_started",
"源 session Pi runtime 未启动;请先调用 /api/page-ai/pi/start",
));
}
Ok(Json(json!({
"ok": true,
"schema": PI_LAB_SCHEMA_FORK,
"sourceSessionId": source_session.session_id,
"sessionId": new_session.session_id,
"session": new_session,
"rpcResponsePending": rpc_response_pending,
"degradedReason": degraded_reason,
})))
}
// ── D2: GET /api/page-ai/pi/artifacts/{toolEventId}/diff ──────────
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabArtifactDiffQuery {
pub session_id: Option<String>,
}
pub async fn artifact_diff(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
axum::extract::Path(tool_event_id): axum::extract::Path<String>,
Query(query): Query<PiLabArtifactDiffQuery>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
let user_id = ensure_authenticated(&state, &context)?;
let session_id = query.session_id.as_deref().ok_or_else(|| {
WebError::bad_request_code(
"page_ai_pi_lab_session_id_required",
"artifact diff 需要 sessionId 查询参数",
)
})?;
// Verify session ownership
let run = state
.control_plane()
.find_ai_runtime_run(user_id.trim(), &pi_run_id(session_id))
.map_err(|e| WebError::internal(format!("查询 session 详情失败: {e}")))?
.ok_or_else(|| {
WebError::new(
StatusCode::NOT_FOUND,
"page_ai_pi_lab_session_not_found",
"Pi Lab session 不存在或不属于当前用户",
)
})?;
if run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME {
return Err(WebError::new(
StatusCode::NOT_FOUND,
"page_ai_pi_lab_session_not_found",
"Pi Lab session 不存在或不属于当前用户",
));
}
// Query control-plane ai_file_patches by session_id, filter in-memory by tool_event_id
let patches = state
.control_plane()
.list_ai_file_patches(user_id.trim(), Some(session_id), 100)
.map_err(|e| WebError::internal(format!("查询 file patches 失败: {e}")))?;
let matching = patches
.into_iter()
.find(|p| p.tool_event_id == tool_event_id);
match matching {
Some(patch) => {
let patch_summary: Value =
serde_json::from_str(&patch.patch_summary_json).unwrap_or(json!({}));
Ok(Json(json!({
"ok": true,
"schema": PI_LAB_SCHEMA_ARTIFACT_DIFF,
"toolEventId": tool_event_id,
"sessionId": session_id,
"diffSummary": patch_summary.get("diffSummary").and_then(Value::as_str).unwrap_or(""),
"patchSummary": patch_summary,
"rootUri": patch.root_uri,
"relativePath": patch.relative_path,
"beforeFileVersion": patch.before_file_version,
"afterFileVersion": patch.after_file_version,
"raw": Value::Null,
})))
}
None => {
// 404: no matching patch found
Err(WebError::new(
StatusCode::NOT_FOUND,
"page_ai_pi_lab_artifact_not_found",
format!("toolEventId {tool_event_id} 没有对应的 file patch 记录"),
))
}
}
}
// ---------------------------------------------------------------------------
// Session history handlers
// ---------------------------------------------------------------------------
pub async fn list_sessions(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<PiLabListSessionsQuery>,
) -> Result<Json<Value>, WebError> {
if !enabled(&state) {
return Ok(Json(json!({"ok": true, "sessions": []})));
}
let user_id = ensure_authenticated(&state, &context)?;
let limit = query.limit.unwrap_or(20).min(100);
let runs = state
.control_plane()
.list_ai_runtime_runs(&user_id, query.workspace_id.as_deref(), None, None, limit)
.map_err(|e| WebError::internal(format!("查询 session 历史失败: {e}")))?;
// Filter to Pi Lab runs in-memory (the store list method doesn't support profile/acp_runtime filter)
let pi_runs: Vec<Value> = runs
.into_iter()
.filter(|r| r.profile == PI_LAB_PROFILE && r.acp_runtime == PI_LAB_ACP_RUNTIME)
.filter(|r| !is_pi_lab_warmup_session_id(&r.session_id))
.map(|r| {
let runtime: Value = serde_json::from_str(&r.runtime_json).unwrap_or(json!({}));
let preview = runtime
.get("pageTitle")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| r.title.clone())
.unwrap_or_default();
json!({
"sessionId": r.session_id,
"runId": r.run_id,
"profile": r.profile,
"acpRuntime": r.acp_runtime,
"status": r.status,
"title": r.title,
"preview": preview,
"messageCount": runtime.get("messageCount").unwrap_or(&json!(0)),
"pagePath": runtime.get("pagePath"),
"pageTitle": runtime.get("pageTitle"),
"modelProvider": runtime.get("modelProvider"),
"modelId": runtime.get("modelId"),
"createdAt": r.created_at,
"updatedAt": r.updated_at,
})
})
.collect();
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_pi.list_sessions.v1",
"sessions": pi_runs,
"count": pi_runs.len(),
})))
}
pub async fn get_session_history(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
axum::extract::Path(path): axum::extract::Path<PiLabSessionPathParam>,
) -> Result<Json<Value>, WebError> {
if !enabled(&state) {
return Err(WebError::new(
StatusCode::NOT_FOUND,
"page_ai_pi_lab_disabled",
"Pi Lab 已被 MNOTE_PAGE_AI_PI_LAB=0 强制关闭",
));
}
let user_id = ensure_authenticated(&state, &context)?;
let run_id = pi_run_id(&path.session_id);
let run = state
.control_plane()
.find_ai_runtime_run(&user_id, &run_id)
.map_err(|e| WebError::internal(format!("查询 session 详情失败: {e}")))?
.ok_or_else(|| {
WebError::new(
StatusCode::NOT_FOUND,
"page_ai_pi_lab_session_not_found",
"Pi Lab session 不存在或不属于当前用户",
)
})?;
if run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME {
return Err(WebError::new(
StatusCode::NOT_FOUND,
"page_ai_pi_lab_session_not_found",
"Pi Lab session 不存在或不属于当前用户",
));
}
let runtime: Value = serde_json::from_str(&run.runtime_json).unwrap_or(json!({}));
let payload: Value = serde_json::from_str(&run.payload_json).unwrap_or(json!({}));
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_pi.get_session.v1",
"session": {
"id": run.id,
"sessionId": run.session_id,
"runId": run.run_id,
"userId": run.user_id,
"workspaceId": run.workspace_id,
"documentId": run.document_id,
"title": run.title,
"profile": run.profile,
"acpRuntime": run.acp_runtime,
"status": run.status,
"traceId": run.trace_id,
"runtime": runtime,
"payload": payload,
"createdAt": run.created_at,
"updatedAt": run.updated_at,
}
})))
}
pub async fn get_session_events(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
axum::extract::Path(path): axum::extract::Path<PiLabSessionPathParam>,
Query(query): Query<PiLabListSessionsQuery>,
) -> Result<Json<Value>, WebError> {
if !enabled(&state) {
return Err(WebError::new(
StatusCode::NOT_FOUND,
"page_ai_pi_lab_disabled",
"Pi Lab 已被 MNOTE_PAGE_AI_PI_LAB=0 强制关闭",
));
}
let user_id = ensure_authenticated(&state, &context)?;
let run_id = pi_run_id(&path.session_id);
let limit = query.limit.unwrap_or(200).min(1000);
// Verify the run exists and belongs to the user
let run = state
.control_plane()
.find_ai_runtime_run(&user_id, &run_id)
.map_err(|e| WebError::internal(format!("查询 session 详情失败: {e}")))?
.ok_or_else(|| {
WebError::new(
StatusCode::NOT_FOUND,
"page_ai_pi_lab_session_not_found",
"Pi Lab session 不存在或不属于当前用户",
)
})?;
if run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME {
return Err(WebError::new(
StatusCode::NOT_FOUND,
"page_ai_pi_lab_session_not_found",
"Pi Lab session 不存在或不属于当前用户",
));
}
let events = state
.control_plane()
.list_ai_runtime_events(&user_id, &run_id, limit)
.map_err(|e| WebError::internal(format!("查询 session events 失败: {e}")))?;
let event_values: Vec<Value> = events
.into_iter()
.map(|e| {
let payload: Value = serde_json::from_str(&e.payload_json).unwrap_or(json!({}));
json!({
"id": e.id,
"eventType": e.event_type,
"profile": e.profile,
"acpRuntime": e.acp_runtime,
"payload": payload,
"createdAt": e.created_at,
})
})
.collect();
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_pi.get_session_events.v1",
"sessionId": path.session_id,
"runId": run_id,
"events": event_values,
"count": event_values.len(),
})))
}
pub async fn delete_session_history(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
axum::extract::Path(path): axum::extract::Path<PiLabSessionPathParam>,
Query(query): Query<PiLabListSessionsQuery>,
) -> Result<Json<Value>, WebError> {
if !enabled(&state) {
return Err(WebError::new(
StatusCode::NOT_FOUND,
"page_ai_pi_lab_disabled",
"Pi Lab 已被 MNOTE_PAGE_AI_PI_LAB=0 强制关闭",
));
}
let user_id = ensure_authenticated(&state, &context)?;
let run_id = pi_run_id(&path.session_id);
let run = state
.control_plane()
.find_ai_runtime_run(&user_id, &run_id)
.map_err(|e| WebError::internal(format!("查询 Pi Lab session 失败: {e}")))?
.ok_or_else(|| {
WebError::new(
StatusCode::NOT_FOUND,
"page_ai_pi_lab_session_not_found",
"Pi Lab session 不存在或不属于当前用户",
)
})?;
if run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME {
return Err(WebError::new(
StatusCode::NOT_FOUND,
"page_ai_pi_lab_session_not_found",
"Pi Lab session 不存在或不属于当前用户",
));
}
let deleted = state
.control_plane()
.delete_ai_runtime_session(&user_id, &path.session_id, query.workspace_id.as_deref())
.map_err(|e| WebError::internal(format!("删除 Pi Lab session 失败: {e}")))?;
let killed = kill_session_process(&path.session_id).await;
let removed = remove_session(&path.session_id);
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_pi.delete_session.v1",
"sessionId": path.session_id,
"deletedRuns": deleted,
"runtimeKilled": killed,
"memorySessionRemoved": removed,
})))
}
pub async fn clear_sessions(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<PiLabListSessionsQuery>,
) -> Result<Json<Value>, WebError> {
if !enabled(&state) {
return Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_pi.clear_sessions.v1",
"deletedRuns": 0,
"sessionIds": [],
})));
}
let user_id = ensure_authenticated(&state, &context)?;
let limit = query.limit.unwrap_or(500).min(1000);
let runs = state
.control_plane()
.list_ai_runtime_runs(&user_id, query.workspace_id.as_deref(), None, None, limit)
.map_err(|e| WebError::internal(format!("查询 Pi Lab session 历史失败: {e}")))?;
let mut seen = HashSet::new();
let mut session_ids = Vec::new();
for run in runs {
if run.profile == PI_LAB_PROFILE
&& run.acp_runtime == PI_LAB_ACP_RUNTIME
&& seen.insert(run.session_id.clone())
{
session_ids.push(run.session_id);
}
}
let mut deleted_runs = 0usize;
let mut killed_sessions = 0usize;
let mut memory_sessions_removed = 0usize;
for session_id in &session_ids {
deleted_runs += state
.control_plane()
.delete_ai_runtime_session(&user_id, session_id, query.workspace_id.as_deref())
.map_err(|e| WebError::internal(format!("清空 Pi Lab session 失败: {e}")))?;
if kill_session_process(session_id).await {
killed_sessions += 1;
}
if remove_session(session_id) {
memory_sessions_removed += 1;
}
}
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_pi.clear_sessions.v1",
"deletedRuns": deleted_runs,
"sessionIds": session_ids,
"killedSessions": killed_sessions,
"memorySessionsRemoved": memory_sessions_removed,
})))
}
pub async fn rename_session(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
axum::extract::Path(path): axum::extract::Path<PiLabSessionPathParam>,
Json(request): Json<PiLabRenameSessionRequest>,
) -> Result<Json<Value>, WebError> {
if !enabled(&state) {
return Err(WebError::new(
StatusCode::NOT_FOUND,
"page_ai_pi_lab_disabled",
"Pi Lab 已被 MNOTE_PAGE_AI_PI_LAB=0 强制关闭",
));
}
let user_id = ensure_authenticated(&state, &context)?;
let title = request.title.trim();
if title.is_empty() {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_session_title_required",
"session 标题不能为空",
));
}
let renamed = state
.control_plane()
.rename_ai_runtime_session(
&user_id,
&path.session_id,
request.workspace_id.as_deref(),
title,
)
.map_err(|e| WebError::internal(format!("重命名 Pi Lab session 失败: {e}")))?;
if renamed
.iter()
.all(|run| run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME)
{
return Err(WebError::new(
StatusCode::NOT_FOUND,
"page_ai_pi_lab_session_not_found",
"Pi Lab session 不存在或不属于当前用户",
));
}
update_session(&path.session_id, |session| {
session.page_title = Some(title.to_string());
});
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_pi.rename_session.v1",
"sessionId": path.session_id,
"title": title,
"updatedRuns": renamed.len(),
})))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use control_plane::{DirectoryGrantInput, UpsertAiPolicyInput, UpsertUserInput};
use tower::util::ServiceExt;
fn test_state() -> AppState {
AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "test".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: None,
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
})
}
fn test_app() -> axum::Router {
build_app(test_state())
}
fn temp_root(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("{name}-{}", now_ms()));
fs::create_dir_all(&root).expect("temp root");
root.canonicalize().expect("canonical temp root")
}
fn file_uri(path: &Path) -> String {
format!("file://{}", path.to_string_lossy())
}
fn grant_directory(state: &AppState, user_id: &str, root: &Path, permission: &str) -> String {
let root_uri = file_uri(root);
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some(user_id.into()),
email: Some(format!("{user_id}@example.com")),
username: user_id.into(),
display_name: user_id.into(),
role: None,
password_hash: None,
})
.expect("upsert user");
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("test_admin".into()),
email: Some("test-admin@example.com".into()),
username: "test_admin".into(),
display_name: "test_admin".into(),
role: Some("admin".into()),
password_hash: None,
})
.expect("upsert admin");
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: user_id.into(),
workspace_id: None,
root_uri: root_uri.clone(),
root_path: root.to_string_lossy().to_string(),
permission: permission.into(),
recursive: true,
capabilities: vec!["ai".into()],
source: "test".into(),
created_by: Some("test_admin".into()),
})
.expect("grant directory");
root_uri
}
fn upsert_ai_policy(state: &AppState, user_id: &str, model_policy: Value) {
state
.control_plane()
.upsert_ai_policy(UpsertAiPolicyInput {
id: None,
user_id: Some(user_id.into()),
workspace_id: None,
allowed_roots_json: "[]".into(),
model_policy_json: model_policy.to_string(),
quota_json: "{}".into(),
})
.expect("upsert ai policy");
}
async fn request_json(
app: axum::Router,
uri: &str,
actor_id: &str,
body: Value,
) -> (StatusCode, Value) {
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.header("x-mnote-actor-id", actor_id)
.header("x-mnote-actor-type", "user")
.body(Body::from(body.to_string()))
.expect("request"),
)
.await
.expect("response");
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
(status, payload)
}
async fn bridge_request_json(
app: axum::Router,
actor_id: &str,
bridge_token: &str,
body: Value,
) -> (StatusCode, Value) {
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/page-ai/pi/tool-call-bridge")
.header("content-type", "application/json")
.header("x-mnote-actor-id", actor_id)
.header("x-mnote-actor-type", "user")
.header(HEADER_PI_LAB_BRIDGE_TOKEN, bridge_token)
.body(Body::from(body.to_string()))
.expect("request"),
)
.await
.expect("response");
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
(status, payload)
}
fn confirm_test_approval(session_id: &str, approval: &Value) {
record_pending_approval_from_event(
session_id,
&json!({
"type": "extension_ui_request",
"method": "confirm",
"mnoteApproval": approval,
}),
);
confirm_pending_approval(session_id, Some(approval));
}
fn permission_mode_test_session(root: &Path, mode: &str) -> PiLabSession {
PiLabSession {
session_id: format!("pi_lab_permission_{mode}"),
mnote_user_id: "pi_permission_user".into(),
bridge_token: "bridge".into(),
status: PiLabSessionStatus::RuntimeRunning,
provider_session_id: format!("prov_permission_{mode}"),
pi_session_dir: root
.join(format!("session-{mode}"))
.to_string_lossy()
.to_string(),
pi_session_file: None,
root_uri: Some(file_uri(root)),
workspace_id: None,
page_path: Some("note.md".into()),
page_title: Some("Permission mode test".into()),
model_provider: Some("omniroute".into()),
model_id: Some("pi-fast".into()),
thinking_level: Some("medium".into()),
allowed_roots_snapshot: Some(json!({
"roots": [{
"rootUri": file_uri(root),
"rootPath": root.to_string_lossy(),
"permission": "write"
}]
})),
runtime_policy_snapshot: Some(json!({
"permissionMode": mode,
"allowExternalPiExtensions": true,
"enabledPiExtensions": ["pi-permission-system"],
"enabledPiExtensionSources": ["npm:@gotgenes/pi-permission-system"],
"mnoteToolNames": [
"mnote.current_page.read",
"mnote.selection.read",
"mnote.allowed_roots.describe",
"mnote.local_file.read",
"mnote.local_file.patch",
"mnote.knowledge_rag.status",
"mnote.knowledge_rag.query",
"mnote.knowledge_rag.section_context",
"mnote.knowledge_rag.open_reference",
"mnote.reference.open",
"mnote.codex_rescue.request",
"mnote.tool_receipt.write"
],
"mnoteToolPolicies": {
"mnote.current_page.read": "allow",
"mnote.selection.read": "allow",
"mnote.allowed_roots.describe": "allow",
"mnote.local_file.read": "ask",
"mnote.local_file.patch": "ask",
"mnote.knowledge_rag.status": "allow",
"mnote.knowledge_rag.query": "allow",
"mnote.knowledge_rag.section_context": "allow",
"mnote.knowledge_rag.open_reference": "allow",
"mnote.reference.open": "allow",
"mnote.codex_rescue.request": "ask",
"mnote.tool_receipt.write": "allow"
}
})),
runtime_pid: None,
runtime_mode: "mock".into(),
runtime_error: None,
created_at_ms: 1000,
updated_at_ms: 2000,
message_count: 0,
}
}
fn permission_config_for_mode(root: &Path, mode: &str) -> Value {
let session = permission_mode_test_session(root, mode);
let config_dir = root.join(format!("config-{mode}"));
let path = ensure_session_pi_permission_config(&session, &config_dir)
.expect("permission config")
.expect("permission-system enabled");
serde_json::from_slice(&fs::read(path).expect("permission config file")).unwrap()
}
#[test]
fn permission_modes_compile_distinct_runtime_policies() {
let root = temp_root("mnote-pi-permission-modes");
let root_rule = shell_glob_escape_path(root.to_string_lossy().trim_end_matches('/'));
let root_wildcard_rule = format!("{root_rule}/*");
let confirm = permission_config_for_mode(&root, "confirm");
assert_eq!(confirm["permission"]["path"]["*"], "ask");
assert_eq!(confirm["permission"]["path"][&root_rule], "ask");
assert_eq!(confirm["permission"]["read"], "allow");
assert_eq!(confirm["permission"]["write"], "ask");
assert_eq!(confirm["permission"]["edit"], "ask");
assert_eq!(confirm["permission"]["bash"]["*"], "ask");
assert_eq!(confirm["permission"]["bash"]["rm -rf *"], "ask");
assert_eq!(confirm["permission"]["mnote_local_file_read"], "ask");
assert_eq!(confirm["permission"]["mnote_local_file_patch"], "ask");
let auto_edit = permission_config_for_mode(&root, "auto_edit");
assert_eq!(auto_edit["permission"]["path"]["*"], "ask");
assert_eq!(auto_edit["permission"]["path"][&root_rule], "allow");
assert_eq!(
auto_edit["permission"]["path"][&root_wildcard_rule],
"allow"
);
assert_eq!(auto_edit["permission"]["read"], "allow");
assert_eq!(auto_edit["permission"]["write"], "allow");
assert_eq!(auto_edit["permission"]["edit"], "allow");
assert_eq!(auto_edit["permission"]["bash"]["*"], "ask");
assert_eq!(auto_edit["permission"]["bash"]["rm -rf *"], "ask");
assert_eq!(auto_edit["permission"]["mnote_local_file_read"], "allow");
assert_eq!(auto_edit["permission"]["mnote_local_file_patch"], "allow");
let plan = permission_config_for_mode(&root, "plan");
assert_eq!(plan["permission"]["path"]["*"], "deny");
assert_eq!(plan["permission"]["path"][&root_rule], "allow");
assert_eq!(plan["permission"]["path"][&root_wildcard_rule], "allow");
assert_eq!(plan["permission"]["read"], "allow");
assert_eq!(plan["permission"]["write"], "deny");
assert_eq!(plan["permission"]["edit"], "deny");
assert_eq!(plan["permission"]["bash"]["*"], "deny");
assert_eq!(plan["permission"]["bash"]["rm -rf *"], "deny");
assert_eq!(plan["permission"]["mnote_local_file_read"], "allow");
assert_eq!(plan["permission"]["mnote_local_file_patch"], "deny");
assert_eq!(plan["permission"]["mnote_codex_rescue_request"], "deny");
let full_access = permission_config_for_mode(&root, "full_access");
assert_eq!(full_access["permission"]["path"]["*"], "allow");
assert_eq!(full_access["permission"]["path"][&root_rule], "allow");
assert_eq!(full_access["permission"]["read"], "allow");
assert_eq!(full_access["permission"]["write"], "allow");
assert_eq!(full_access["permission"]["edit"], "allow");
assert_eq!(full_access["permission"]["bash"]["*"], "allow");
assert_eq!(full_access["permission"]["bash"]["rm -rf *"], "allow");
assert_eq!(full_access["permission"]["mnote_local_file_read"], "allow");
assert_eq!(full_access["permission"]["mnote_local_file_patch"], "allow");
assert_eq!(
full_access["permission"]["mnote_codex_rescue_request"],
"ask"
);
}
#[test]
fn plan_mode_wraps_prompt_with_readonly_instructions() {
let root = temp_root("mnote-pi-plan-mode-prompt");
let plan_session = permission_mode_test_session(&root, "plan");
let prompt =
pi_lab_effective_prompt_for_session(&plan_session, "请删除这个目录并修改 note.md");
assert!(prompt.contains("当前是 MNote Pi 计划模式"));
assert!(prompt.contains("不要实际写入、删除、移动、重命名文件"));
assert!(prompt.contains("不要执行 bash/write/edit/rm/mv/cp/mkdir"));
assert!(prompt.contains("用户原始请求"));
assert!(prompt.contains("请删除这个目录并修改 note.md"));
let full_access_session = permission_mode_test_session(&root, "full_access");
assert_eq!(
pi_lab_effective_prompt_for_session(&full_access_session, "请删除这个目录"),
"请删除这个目录"
);
}
#[test]
fn command_prompt_routes_hidden_context_through_extension_input_hook() {
let root = temp_root("mnote-pi-skill-prompt-context");
let full_access_session = permission_mode_test_session(&root, "full_access");
let context = "[[MNOTE_PI_CONTEXT_V1:7b7d]]\n";
let normal_prompt =
pi_lab_command_message_for_session(&full_access_session, "请只回复 ok", context);
assert_eq!(normal_prompt, format!("{context}请只回复 ok"));
assert!(!normal_prompt.contains("bridgeToken"));
let prompt = pi_lab_command_message_for_session(
&full_access_session,
"/skill:vpn 请返回 skill 中定义的端口",
context,
);
assert!(prompt.starts_with("/skill:vpn\n"));
assert!(prompt.contains(context));
assert!(!prompt.contains("bridgeToken"));
assert!(prompt.ends_with("请返回 skill 中定义的端口"));
let plan_session = permission_mode_test_session(&root, "plan");
let plan_prompt =
pi_lab_command_message_for_session(&plan_session, "/skill:vpn 检查代理配置", context);
assert!(plan_prompt.starts_with("/skill:vpn\n"));
assert!(plan_prompt.contains(context));
assert!(!plan_prompt.contains("bridgeToken"));
assert!(plan_prompt.contains("当前是 MNote Pi 计划模式"));
assert!(plan_prompt.contains("用户原始请求"));
assert!(plan_prompt.ends_with("检查代理配置"));
}
#[test]
fn pi_rust_jsonl_message_entries_preserve_tail_text_and_tools() {
let entries = vec![
json!({
"id": "u1",
"type": "message",
"seq": 1,
"message": {
"role": "user",
"content": "请查看当前页"
}
}),
json!({
"id": "a1",
"type": "message",
"seq": 2,
"parentId": "u1",
"message": {
"role": "assistant",
"content": [{
"type": "toolCall",
"id": "tool_1",
"name": "mnote_current_page_read",
"arguments": {"path": "note.md"}
}]
}
}),
json!({
"id": "t1",
"type": "message",
"seq": 3,
"parentId": "a1",
"message": {
"role": "toolResult",
"toolCallId": "tool_1",
"content": [{"type": "text", "text": "当前页内容"}]
}
}),
json!({
"id": "a2",
"type": "message",
"seq": 4,
"parentId": "t1",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "FINAL_TAIL_REPLY"}],
"stopReason": "stop"
}
}),
];
assert_eq!(pi_lab_entry_text(&entries[0]), "请查看当前页");
assert_eq!(pi_lab_entry_parent_id(&entries[1]), Some("u1"));
assert_eq!(pi_lab_entry_role(&entries[1]), "assistant");
let tool_calls = pi_lab_entry_tool_calls(&entries[1]);
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0]["name"], "mnote_current_page_read");
assert_eq!(pi_lab_entry_text(&entries[2]), "当前页内容");
assert_eq!(pi_lab_entry_text(&entries[3]), "FINAL_TAIL_REPLY");
assert_eq!(pi_lab_entry_meta(&entries[3]), "stopReason=stop");
let tree = build_pi_entry_tree(&entries);
assert_eq!(tree["nodes"]["a2"]["preview"], "FINAL_TAIL_REPLY");
assert_eq!(tree["edges"][2]["from"], "t1");
assert_eq!(tree["edges"][2]["to"], "a2");
let replay = build_pi_replay_messages(&entries);
assert_eq!(replay.len(), 4);
assert_eq!(replay[0]["role"], "user");
assert_eq!(replay[0]["text"], "请查看当前页");
assert_eq!(replay[1]["role"], "assistant");
assert_eq!(replay[1]["toolCalls"][0]["toolName"], "mnote_current_page_read");
assert_eq!(replay[2]["role"], "assistant");
assert_eq!(replay[2]["toolCalls"][0]["status"], "done");
assert_eq!(replay[2]["toolCalls"][0]["result"]["content"][0]["text"], "当前页内容");
assert_eq!(replay[3]["text"], "FINAL_TAIL_REPLY");
}
#[test]
fn pi_run_id_format() {
let rid = pi_run_id("pi_lab_abc123");
assert_eq!(rid, "pi_run_pi_lab_abc123");
}
#[test]
fn session_status_to_string_maps_all_variants() {
assert_eq!(session_status_to_string(&PiLabSessionStatus::Idle), "idle");
assert_eq!(
session_status_to_string(&PiLabSessionStatus::RuntimeRunning),
"runtime_running"
);
assert_eq!(
session_status_to_string(&PiLabSessionStatus::TurnRunning),
"turn_running"
);
assert_eq!(
session_status_to_string(&PiLabSessionStatus::Aborted),
"aborted"
);
assert_eq!(
session_status_to_string(&PiLabSessionStatus::Error),
"error"
);
}
#[test]
fn agent_end_does_not_revive_terminal_session_status() {
assert_eq!(
status_after_agent_end(&PiLabSessionStatus::Aborted),
PiLabSessionStatus::Aborted
);
assert_eq!(
status_after_agent_end(&PiLabSessionStatus::Error),
PiLabSessionStatus::Error
);
assert_eq!(
status_after_agent_end(&PiLabSessionStatus::TurnRunning),
PiLabSessionStatus::RuntimeRunning
);
}
#[test]
fn mnote_context_snapshot_keeps_bridge_token_out_of_prompt_payload() {
let root = temp_root("mnote-pi-context-bridge-token");
let session = permission_mode_test_session(&root, "full_access");
let prompt_payload = pi_mnote_context_payload(&session, None, None);
assert!(prompt_payload.get("bridgeToken").is_none());
assert_eq!(
prompt_payload["modelProvider"],
session
.model_provider
.clone()
.map(Value::String)
.unwrap_or(Value::Null)
);
assert_eq!(
prompt_payload["modelId"],
session
.model_id
.clone()
.map(Value::String)
.unwrap_or(Value::Null)
);
let context_path =
write_pi_mnote_context_snapshot(&session, None, None).expect("write context snapshot");
let persisted: Value =
serde_json::from_slice(&fs::read(&context_path).expect("read context snapshot"))
.expect("parse context snapshot");
assert_eq!(
persisted["bridgeToken"],
Value::String(session.bridge_token.clone())
);
assert_eq!(
persisted["sessionId"],
Value::String(session.session_id.clone())
);
assert_eq!(
persisted["bridgeBaseUrl"],
Value::String(pi_lab_public_base_url())
);
#[cfg(unix)]
{
let mode = fs::metadata(context_path)
.expect("context metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
}
#[test]
fn permission_modes_expose_pi_builtins_by_mode() {
let root = temp_root("mnote-pi-full-access-builtins");
let mut session = permission_mode_test_session(&root, "full_access");
session.runtime_policy_snapshot = Some(json!({
"permissionMode": "full_access",
"allowExternalPiExtensions": false,
"enabledPiExtensions": ["pi-rust-official-permission-gate"],
"enabledPiExtensionSources": ["pi-rust-official:permission-gate"],
}));
assert!(!pi_lab_permission_system_enabled(&session));
assert_eq!(
pi_lab_enabled_builtin_tools(&session),
pi_lab_managed_builtin_tools()
);
session.runtime_policy_snapshot = Some(json!({
"permissionMode": "auto_edit",
"allowExternalPiExtensions": false,
"enabledPiExtensions": ["pi-rust-official-permission-gate"],
"enabledPiExtensionSources": ["pi-rust-official:permission-gate"],
}));
assert_eq!(
pi_lab_enabled_builtin_tools(&session),
vec![
"read".to_string(),
"write".to_string(),
"edit".to_string(),
"grep".to_string(),
"find".to_string(),
"ls".to_string(),
"hashline_edit".to_string()
]
);
session.runtime_policy_snapshot = Some(json!({
"permissionMode": "plan",
"allowExternalPiExtensions": false,
"enabledPiExtensions": ["pi-rust-official-permission-gate"],
"enabledPiExtensionSources": ["pi-rust-official:permission-gate"],
}));
assert_eq!(
pi_lab_enabled_builtin_tools(&session),
vec![
"read".to_string(),
"grep".to_string(),
"find".to_string(),
"ls".to_string()
]
);
}
#[test]
fn omniroute_models_config_preserves_tools_and_stream_usage() {
let root = temp_root("mnote-pi-omniroute-models-config");
let session = PiLabSession {
session_id: "pi_lab_models_config".into(),
mnote_user_id: "user_test".into(),
bridge_token: "bridge".into(),
status: PiLabSessionStatus::Idle,
provider_session_id: "prov_models_config".into(),
pi_session_dir: root.join("session").to_string_lossy().to_string(),
pi_session_file: None,
root_uri: Some(file_uri(&root)),
workspace_id: Some("ws_test".into()),
page_path: Some("doc.md".into()),
page_title: Some("Test Page".into()),
model_provider: Some("omniroute".into()),
model_id: Some("freefirst".into()),
thinking_level: Some("off".into()),
allowed_roots_snapshot: None,
runtime_policy_snapshot: None,
runtime_pid: None,
runtime_mode: "rpc".into(),
runtime_error: None,
created_at_ms: 1000,
updated_at_ms: 2000,
message_count: 0,
};
let config_dir = ensure_session_models_config(&session, true).expect("models config");
let models: Value =
serde_json::from_slice(&fs::read(config_dir.join("models.json")).expect("models file"))
.expect("models json");
let compat = &models["providers"]["omniroute"]["compat"];
assert_eq!(compat["supportsDeveloperRole"], false);
assert_eq!(compat["supportsReasoningEffort"], false);
assert_eq!(compat["supportsTools"], true);
assert_eq!(compat["supportsUsageInStreaming"], true);
assert_eq!(
models["providers"]["omniroute"]["apiKey"], "OPENAI_API_KEY",
"Pi Rust resolves bare *_API_KEY env var names; shell-style $OPENAI_API_KEY is sent literally"
);
assert_eq!(
models["providers"]["omniroute"]["baseUrl"],
omniroute_base_url()
);
}
#[test]
fn omniroute_model_capability_requires_explicit_tool_calling() {
let catalog = json!({
"data": [
{
"id": "gpt-5.4-mini",
"capabilities": {
"tool_calling": true,
"reasoning": true
}
},
{
"id": "freefirst",
"capabilities": {
"reasoning": true
}
}
]
});
assert_eq!(
omniroute_model_tool_calling_capability(&catalog, "gpt-5.4-mini"),
Some(true)
);
assert_eq!(
omniroute_model_tool_calling_capability(&catalog, "freefirst"),
Some(false)
);
assert_eq!(
omniroute_model_tool_calling_capability(&catalog, "missing"),
None
);
}
#[test]
fn build_upsert_run_input_creates_correct_input() {
let session = PiLabSession {
session_id: "pi_lab_test123".into(),
mnote_user_id: "user_test".into(),
bridge_token: "bridge".into(),
status: PiLabSessionStatus::RuntimeRunning,
provider_session_id: "prov_123".into(),
pi_session_dir: "/tmp/pi-lab/test".into(),
pi_session_file: None,
root_uri: Some("file:///workspace".into()),
workspace_id: Some("ws_test".into()),
page_path: Some("doc.md".into()),
page_title: Some("Test Page".into()),
model_provider: Some("omniroute".into()),
model_id: Some("freefirst".into()),
thinking_level: Some("medium".into()),
allowed_roots_snapshot: None,
runtime_policy_snapshot: None,
runtime_pid: Some(12345),
runtime_mode: "mock".into(),
runtime_error: None,
created_at_ms: 1000,
updated_at_ms: 2000,
message_count: 5,
};
let input = build_upsert_run_input(&session);
assert_eq!(input.user_id, "user_test");
assert_eq!(input.run_id, "pi_run_pi_lab_test123");
assert_eq!(input.session_id, "pi_lab_test123");
assert_eq!(input.profile, "pi_lab");
assert_eq!(input.acp_runtime, "pi");
assert_eq!(input.status, "runtime_running");
assert_eq!(input.title.as_deref(), Some("Test Page"));
assert_eq!(input.workspace_id.as_deref(), Some("ws_test"));
assert_eq!(input.document_id.as_deref(), Some("doc.md"));
let runtime: Value = serde_json::from_str(&input.runtime_json).unwrap();
assert_eq!(runtime["messageCount"], 5);
assert_eq!(runtime["modelProvider"], "omniroute");
}
#[test]
fn build_append_event_input_creates_correct_input() {
let session = PiLabSession {
session_id: "pi_lab_test456".into(),
mnote_user_id: "user_test".into(),
bridge_token: "bridge".into(),
status: PiLabSessionStatus::RuntimeRunning,
provider_session_id: "prov_456".into(),
pi_session_dir: "/tmp/pi-lab/test".into(),
pi_session_file: None,
root_uri: None,
workspace_id: None,
page_path: None,
page_title: None,
model_provider: None,
model_id: None,
thinking_level: Some("medium".into()),
allowed_roots_snapshot: None,
runtime_policy_snapshot: None,
runtime_pid: None,
runtime_mode: "mock".into(),
runtime_error: None,
created_at_ms: 1000,
updated_at_ms: 2000,
message_count: 0,
};
let payload = json!({"type": "text_delta", "delta": "hello"});
let input = build_append_event_input(
&session,
"pi_rpc_event",
&serde_json::to_string(&payload).unwrap(),
);
assert_eq!(input.user_id, "user_test");
assert_eq!(input.session_id, "pi_lab_test456");
assert_eq!(input.run_id, "pi_run_pi_lab_test456");
assert_eq!(input.event_type, "pi_rpc_event");
let parsed: Value = serde_json::from_str(&input.payload_json).unwrap();
assert_eq!(parsed["delta"], "hello");
}
#[test]
fn tool_bridge_extension_does_not_duplicate_native_skill_loading() {
let root = temp_root("mnote-pi-bridge-no-skill-dup");
let skill_path = root.join("context7").join("SKILL.md");
fs::create_dir_all(skill_path.parent().unwrap()).expect("skill dir");
fs::write(
&skill_path,
"---\nname: context7\n---\n\n# Context7\n\nUse Context7 docs.\n",
)
.expect("skill file");
let session = PiLabSession {
session_id: "pi_lab_skill_no_dup".into(),
mnote_user_id: "user_test".into(),
bridge_token: "bridge".into(),
status: PiLabSessionStatus::RuntimeRunning,
provider_session_id: "prov_skill".into(),
pi_session_dir: root.join("session").to_string_lossy().to_string(),
pi_session_file: None,
root_uri: None,
workspace_id: None,
page_path: None,
page_title: None,
model_provider: None,
model_id: None,
thinking_level: Some("medium".into()),
allowed_roots_snapshot: None,
runtime_policy_snapshot: Some(json!({
"enabledSkillSources": [skill_path.to_string_lossy()]
})),
runtime_pid: None,
runtime_mode: "mock".into(),
runtime_error: None,
created_at_ms: 1000,
updated_at_ms: 2000,
message_count: 0,
};
let manifest = mnote_pi_tool_manifest(&session);
assert!(manifest
.as_array()
.unwrap()
.iter()
.any(|tool| tool["piName"] == "mnote_current_page_read"));
let extension_path = mnote_pi_extension_path();
let source = fs::read_to_string(extension_path).expect("extension source");
assert!(source.contains("pi.registerTool"));
assert!(!source.contains("SKILL_CONTEXTS"));
assert!(!source.contains("before_agent_start"));
assert!(!source.contains("MNote Pi Lab enabled skill context follows"));
assert!(!source.contains("Use Context7 docs."));
}
#[test]
fn skill_cli_args_use_native_skill_without_no_skills() {
let session = PiLabSession {
session_id: "pi_lab_skill_args".into(),
mnote_user_id: "user_test".into(),
bridge_token: "bridge".into(),
status: PiLabSessionStatus::RuntimeRunning,
provider_session_id: "prov_skill".into(),
pi_session_dir: "/tmp/pi-lab/skill-args".into(),
pi_session_file: None,
root_uri: None,
workspace_id: None,
page_path: None,
page_title: None,
model_provider: None,
model_id: None,
thinking_level: Some("medium".into()),
allowed_roots_snapshot: None,
runtime_policy_snapshot: Some(json!({
"enabledSkillSources": ["/tmp/context7/SKILL.md", "/tmp/vpn/SKILL.md"]
})),
runtime_pid: None,
runtime_mode: "mock".into(),
runtime_error: None,
created_at_ms: 1000,
updated_at_ms: 2000,
message_count: 0,
};
assert_eq!(
pi_lab_skill_cli_args(&session),
vec![
"--skill",
"/tmp/context7/SKILL.md",
"--skill",
"/tmp/vpn/SKILL.md"
]
);
let mut no_skill_session = session;
no_skill_session.runtime_policy_snapshot = None;
assert_eq!(
pi_lab_skill_cli_args(&no_skill_session),
vec!["--no-skills"]
);
}
#[tokio::test]
async fn list_sessions_filters_by_current_user_and_pi_profile() {
let app = test_app();
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/page-ai/pi/sessions")
.header("x-mnote-actor-id", "user_test_list")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["schema"], "mnote.page_ai_pi.list_sessions.v1");
// Should be an empty list since no Pi sessions exist for this user
assert!(payload["sessions"].is_array());
assert_eq!(payload["count"].as_u64().unwrap_or(0), 0);
}
#[tokio::test]
async fn start_uses_ai_settings_for_model_skills_mcp_and_tools() {
std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock");
std::env::set_var("MNOTE_PAGE_AI_PI_MCP_STRICT_LAZY", "1");
let state = test_state();
let actor_id = "pi_policy_user";
let root = temp_root("mnote-pi-policy-root");
let root_uri = grant_directory(&state, actor_id, &root, "write");
upsert_ai_policy(
&state,
actor_id,
json!({
"defaultModel": "omniroute/pi-fast",
"allowedModels": ["omniroute/pi-fast"],
"tools": {
"mnote.local_file.patch": "deny"
},
"skills": {
"context7": {
"name": "Context7",
"enabled": false,
"description": "disabled in test",
"source": "/tmp/context7-disabled/SKILL.md",
"riskLevel": "medium",
"requiredScopes": []
},
"vpn": {
"name": "VPN",
"enabled": true,
"description": "enabled in test",
"source": "/tmp/vpn-enabled/SKILL.md",
"riskLevel": "high",
"requiredScopes": []
}
},
"mcpServers": {
"context7": {
"name": "Context7",
"enabled": false,
"transport": "streamable-http",
"url": "https://mcp.context7.com/mcp",
"command": "",
"networkPolicy": "allow-all",
"secretRefs": [],
"facadeOnly": true,
"sandbox": true,
"description": "disabled in test",
"riskLevel": "medium",
"requiredScopes": []
},
"codegraph": {
"name": "CodeGraph",
"enabled": true,
"transport": "stdio",
"url": "",
"command": "codegraph serve --mcp",
"networkPolicy": "deny-all",
"secretRefs": [],
"facadeOnly": true,
"sandbox": true,
"description": "enabled in test",
"riskLevel": "medium",
"requiredScopes": []
}
}
}),
);
let app = build_app(state);
let (status, payload) = request_json(
app.clone(),
"/api/page-ai/pi/start",
actor_id,
json!({
"sessionId": "pi_lab_policy_test",
"rootUri": root_uri,
"modelProvider": "omniroute",
"modelId": "pi-fast",
"thinkingLevel": "high",
"permissionMode": "full_access"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(payload["mnoteToolOnly"], false);
assert_eq!(
payload["managedPiBuiltinTools"],
json!(pi_lab_managed_builtin_tools())
);
assert!(payload["configuredPiExtensionSources"]
.as_array()
.unwrap()
.contains(&json!("mnote:core")));
assert!(payload["piExtensionSources"]
.as_array()
.unwrap()
.iter()
.any(|source| {
source.as_str().is_some_and(|value| {
value.ends_with("packages/pi-mnote/extensions/pi-rust-official/question.ts")
})
}));
assert!(payload["piExtensionSources"]
.as_array()
.unwrap()
.iter()
.any(|source| {
source.as_str().is_some_and(|value| {
value
.ends_with("packages/pi-mnote/extensions/pi-rust-official/questionnaire.ts")
})
}));
assert_eq!(payload["session"]["modelProvider"], "omniroute");
assert_eq!(payload["session"]["modelId"], "pi-fast");
assert_eq!(payload["session"]["thinkingLevel"], "high");
assert_eq!(
payload["session"]["runtimePolicySnapshot"]["permissionMode"],
"full_access"
);
let policy = &payload["session"]["runtimePolicySnapshot"];
assert_eq!(policy["defaultModel"], "omniroute/pi-fast");
assert_eq!(
policy["allowedModels"],
json!(["omniroute/freefirst", "omniroute/gpt-5.4-mini", "omniroute/pi-fast"])
);
assert!(policy["enabledSkills"]
.as_array()
.unwrap()
.contains(&json!("vpn")));
assert!(!policy["enabledSkills"]
.as_array()
.unwrap()
.contains(&json!("context7")));
assert!(policy["enabledMcpServers"]
.as_array()
.unwrap()
.contains(&json!("codegraph")));
assert!(!policy["enabledMcpServers"]
.as_array()
.unwrap()
.contains(&json!("context7")));
assert_eq!(policy["nativeMcpSupported"], false);
assert_eq!(policy["mcpBridge"], "pi-rust-sync-client");
assert!(policy["enabledPiExtensions"]
.as_array()
.unwrap()
.contains(&json!("mnote-pi")));
assert!(policy["enabledPiExtensionSources"]
.as_array()
.unwrap()
.contains(&json!("mnote:core")));
assert_eq!(
policy["mcpServers"]["codegraph"]["command"],
json!("codegraph")
);
assert_eq!(
policy["mcpServers"]["codegraph"]["args"],
json!(["serve", "--mcp"])
);
assert!(policy["mcpServers"].get("context7").is_none());
assert!(!policy["mnoteToolNames"]
.as_array()
.unwrap()
.contains(&json!("mnote.local_file.patch")));
let mcp_config_path = PathBuf::from(
payload["session"]["piSessionDir"]
.as_str()
.expect("session dir"),
)
.join("config")
.join("mcp.json");
assert!(
mcp_config_path.exists(),
"built-in Pi Rust MCP extension should receive session mcp.json"
);
let mcp_config: Value =
serde_json::from_slice(&fs::read(&mcp_config_path).expect("read mcp config"))
.expect("parse mcp config");
assert_eq!(
mcp_config["mcpServers"]["codegraph"]["command"],
json!("codegraph")
);
let mcp_cache_path = PathBuf::from(
payload["session"]["piSessionDir"]
.as_str()
.expect("session dir"),
)
.join("config")
.join(PI_LAB_MCP_CACHE_FILE);
assert!(
mcp_cache_path.exists(),
"MCP-enabled sessions should initialize a session metadata cache"
);
let full_access_session = get_session("pi_lab_policy_test").expect("full access session");
assert!(shared_mcp_cache_path(&full_access_session).is_some());
let permission_config_path = PathBuf::from(
payload["session"]["piSessionDir"]
.as_str()
.expect("session dir"),
)
.join("config")
.join("extensions")
.join("pi-permission-system")
.join("config.json");
assert!(
!permission_config_path.exists(),
"permission-system config should not be generated unless external Pi extensions are explicitly enabled"
);
let full_access_tool_policies = mnote_pi_tool_policies(&full_access_session);
assert_eq!(
full_access_tool_policies["mnote.current_page.read"],
"allow"
);
assert_eq!(
full_access_tool_policies["mnote.codex_rescue.request"],
"ask"
);
let (tool_status, tool_payload) = request_json(
app.clone(),
"/api/page-ai/pi/tool-call",
actor_id,
json!({
"sessionId": "pi_lab_policy_test",
"toolName": "mnote.local_file.patch",
"params": {"rootUri": root_uri, "path": "note.md", "operations": []}
}),
)
.await;
assert_eq!(tool_status, StatusCode::FORBIDDEN);
assert_eq!(tool_payload["code"], "page_ai_pi_lab_tool_disabled");
let (denied_status, denied_payload) = request_json(
app,
"/api/page-ai/pi/start",
actor_id,
json!({
"sessionId": "pi_lab_policy_denied_model",
"modelProvider": "omniroute",
"modelId": "not-allowed"
}),
)
.await;
assert_eq!(denied_status, StatusCode::FORBIDDEN);
assert_eq!(denied_payload["code"], "page_ai_pi_lab_model_not_allowed");
}
#[test]
fn hydrate_session_mcp_cache_copies_shared_cache() {
let root = temp_root("mnote-pi-shared-mcp-cache-root");
let config_dir = root.join("session-config");
fs::create_dir_all(&config_dir).expect("config dir");
let session = PiLabSession {
session_id: "pi_lab_cache_copy".into(),
mnote_user_id: "user/cache".into(),
bridge_token: "bridge".into(),
status: PiLabSessionStatus::RuntimeRunning,
provider_session_id: "prov_cache".into(),
pi_session_dir: root.join("session").to_string_lossy().to_string(),
pi_session_file: None,
root_uri: Some(file_uri(&root)),
workspace_id: None,
page_path: None,
page_title: None,
model_provider: None,
model_id: None,
thinking_level: Some("medium".into()),
allowed_roots_snapshot: None,
runtime_policy_snapshot: Some(json!({
"mcpBridge": "pi-rust-sync-client",
"enabledMcpServers": ["codegraph"],
"mcpServers": {
"codegraph": {
"transport": "stdio",
"command": "codegraph",
"args": ["serve", "--mcp"],
"lifecycle": "lazy"
}
}
})),
runtime_pid: None,
runtime_mode: "mock".into(),
runtime_error: None,
created_at_ms: 1000,
updated_at_ms: 2000,
message_count: 0,
};
let shared = shared_mcp_cache_path(&session).expect("shared cache path");
fs::create_dir_all(shared.parent().expect("shared parent")).expect("shared parent");
fs::write(
&shared,
br#"{"version":1,"servers":{"codegraph":{"configHash":"hash","tools":[],"resources":[],"cachedAt":1}}}"#,
)
.expect("shared cache write");
let hydrated = hydrate_session_mcp_cache(&session, &config_dir)
.expect("hydrate")
.expect("shared path");
assert_eq!(hydrated, shared);
let session_cache =
fs::read_to_string(config_dir.join(PI_LAB_MCP_CACHE_FILE)).expect("session cache");
assert!(session_cache.contains("\"codegraph\""));
}
#[tokio::test]
async fn tool_facade_enforces_directory_grant_read_and_write_permissions() {
std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock");
let state = test_state();
let reader_id = "pi_reader_user";
let writer_id = "pi_writer_user";
let read_root = temp_root("mnote-pi-read-root");
let write_root = temp_root("mnote-pi-write-root");
let read_root_uri = grant_directory(&state, reader_id, &read_root, "read");
let write_root_uri = grant_directory(&state, writer_id, &write_root, "write");
fs::write(read_root.join("note.md"), "reader").expect("read file");
fs::write(write_root.join("note.md"), "writer").expect("write file");
let app = build_app(state);
let (reader_start_status, _) = request_json(
app.clone(),
"/api/page-ai/pi/start",
reader_id,
json!({"sessionId": "pi_lab_reader", "rootUri": read_root_uri}),
)
.await;
assert_eq!(reader_start_status, StatusCode::OK);
let (unapproved_read_status, unapproved_read_payload) = request_json(
app.clone(),
"/api/page-ai/pi/tool-call",
reader_id,
json!({
"sessionId": "pi_lab_reader",
"toolName": "mnote.local_file.read",
"params": {"rootUri": read_root_uri, "path": "note.md"}
}),
)
.await;
assert_eq!(unapproved_read_status, StatusCode::OK);
assert_eq!(unapproved_read_payload["ok"], false);
assert_eq!(
unapproved_read_payload["result"]["code"],
"page_ai_pi_lab_tool_approval_required"
);
assert_eq!(unapproved_read_payload["approvalRequired"], true);
assert_eq!(unapproved_read_payload["approvalConfirmed"], false);
let (forged_read_status, forged_read_payload) = request_json(
app.clone(),
"/api/page-ai/pi/tool-call",
reader_id,
json!({
"sessionId": "pi_lab_reader",
"toolName": "mnote.local_file.read",
"params": {
"rootUri": read_root_uri,
"path": "note.md",
"mnoteApproval": {"confirmed": true, "toolName": "mnote.local_file.read"}
}
}),
)
.await;
assert_eq!(forged_read_status, StatusCode::OK);
assert_eq!(forged_read_payload["ok"], false);
assert_eq!(
forged_read_payload["result"]["code"],
"page_ai_pi_lab_tool_approval_required"
);
let read_params = json!({"rootUri": read_root_uri, "path": "note.md"});
let read_approval = json!({
"approvalId": "approval_read_note",
"toolName": "mnote.local_file.read",
"paramsHash": pi_lab_tool_params_hash(&read_params),
"confirmed": true
});
confirm_test_approval("pi_lab_reader", &read_approval);
let reader_bridge_token = get_session("pi_lab_reader")
.expect("reader session")
.bridge_token;
let (read_status, read_payload) = bridge_request_json(
app.clone(),
reader_id,
&reader_bridge_token,
json!({
"sessionId": "pi_lab_reader",
"toolName": "mnote.local_file.read",
"params": {
"rootUri": read_root_uri,
"path": "note.md",
"mnoteApproval": read_approval
}
}),
)
.await;
assert_eq!(read_status, StatusCode::OK);
assert_eq!(read_payload["ok"], true);
assert_eq!(read_payload["result"]["content"], "reader");
let current_page_params = json!({"pagePath": "note.md"});
let current_page_approval = json!({
"approvalId": "approval_current_page_note",
"toolName": "mnote.current_page.read",
"paramsHash": pi_lab_tool_params_hash(&current_page_params),
"confirmed": true
});
confirm_test_approval("pi_lab_reader", &current_page_approval);
let (current_page_status, current_page_payload) = bridge_request_json(
app.clone(),
reader_id,
&reader_bridge_token,
json!({
"sessionId": "pi_lab_reader",
"toolName": "mnote.current_page.read",
"params": {
"pagePath": "note.md",
"mnoteApproval": current_page_approval
}
}),
)
.await;
assert_eq!(current_page_status, StatusCode::OK);
assert_eq!(current_page_payload["ok"], true);
assert_eq!(current_page_payload["result"]["rootUri"], read_root_uri);
assert_eq!(current_page_payload["result"]["content"], "reader");
let nested_read_root = read_root.join("nested-current-page-root");
fs::create_dir_all(&nested_read_root).expect("nested root");
fs::write(nested_read_root.join("child.md"), "nested reader").expect("nested reader file");
let nested_read_root_uri = format!("file://{}", nested_read_root.display());
let nested_current_page_params =
json!({"rootUri": nested_read_root_uri, "pagePath": "child.md"});
let nested_current_page_approval = json!({
"approvalId": "approval_nested_current_page_child",
"toolName": "mnote.current_page.read",
"paramsHash": pi_lab_tool_params_hash(&nested_current_page_params),
"confirmed": true
});
confirm_test_approval("pi_lab_reader", &nested_current_page_approval);
let (nested_current_page_status, nested_current_page_payload) = bridge_request_json(
app.clone(),
reader_id,
&reader_bridge_token,
json!({
"sessionId": "pi_lab_reader",
"toolName": "mnote.current_page.read",
"params": {
"rootUri": nested_read_root_uri,
"pagePath": "child.md",
"mnoteApproval": nested_current_page_approval
}
}),
)
.await;
assert_eq!(nested_current_page_status, StatusCode::OK);
assert_eq!(nested_current_page_payload["ok"], true);
assert_eq!(
nested_current_page_payload["result"]["rootUri"],
nested_read_root_uri
);
assert_eq!(
nested_current_page_payload["result"]["content"],
"nested reader"
);
let readonly_patch_params = json!({
"rootUri": read_root_uri,
"path": "note.md",
"operations": [{"op": "append", "content": " updated"}]
});
let readonly_patch_approval = json!({
"approvalId": "approval_readonly_patch",
"toolName": "mnote.local_file.patch",
"paramsHash": pi_lab_tool_params_hash(&readonly_patch_params),
"confirmed": true
});
confirm_test_approval("pi_lab_reader", &readonly_patch_approval);
let (readonly_patch_status, readonly_patch_payload) = bridge_request_json(
app.clone(),
reader_id,
&reader_bridge_token,
json!({
"sessionId": "pi_lab_reader",
"toolName": "mnote.local_file.patch",
"params": {
"rootUri": read_root_uri,
"path": "note.md",
"operations": [{"op": "append", "content": " updated"}],
"mnoteApproval": readonly_patch_approval
}
}),
)
.await;
assert_eq!(readonly_patch_status, StatusCode::OK);
assert_eq!(readonly_patch_payload["ok"], false);
assert_eq!(
readonly_patch_payload["result"]["code"],
"page_ai_pi_lab_root_readonly"
);
let (writer_start_status, _) = request_json(
app.clone(),
"/api/page-ai/pi/start",
writer_id,
json!({"sessionId": "pi_lab_writer", "rootUri": write_root_uri}),
)
.await;
assert_eq!(writer_start_status, StatusCode::OK);
let write_params = json!({
"rootUri": write_root_uri,
"path": "note.md",
"operations": [{"op": "append", "content": " updated"}]
});
let write_approval = json!({
"approvalId": "approval_write_note",
"toolName": "mnote.local_file.patch",
"paramsHash": pi_lab_tool_params_hash(&write_params),
"confirmed": true
});
confirm_test_approval("pi_lab_writer", &write_approval);
let writer_bridge_token = get_session("pi_lab_writer")
.expect("writer session")
.bridge_token;
let (write_status, write_payload) = bridge_request_json(
app,
writer_id,
&writer_bridge_token,
json!({
"sessionId": "pi_lab_writer",
"toolName": "mnote.local_file.patch",
"params": {
"rootUri": write_root_uri,
"path": "note.md",
"operations": [{"op": "append", "content": " updated"}],
"mnoteApproval": write_approval
}
}),
)
.await;
assert_eq!(write_status, StatusCode::OK);
assert_eq!(write_payload["ok"], true);
assert_eq!(
fs::read_to_string(write_root.join("note.md")).expect("patched file"),
"writer updated"
);
}
#[tokio::test]
async fn configure_permission_mode_refreshes_mnote_tool_policy() {
std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock");
let state = test_state();
let actor_id = "pi_configure_permission_user";
let root = temp_root("mnote-pi-configure-permission-root");
let root_uri = grant_directory(&state, actor_id, &root, "write");
fs::write(root.join("note.md"), "before").expect("write file");
let app = build_app(state);
let (start_status, _) = request_json(
app.clone(),
"/api/page-ai/pi/start",
actor_id,
json!({
"sessionId": "pi_lab_configure_permission",
"rootUri": root_uri,
"permissionMode": "confirm"
}),
)
.await;
assert_eq!(start_status, StatusCode::OK);
let (configure_status, configure_payload) = request_json(
app.clone(),
"/api/page-ai/pi/configure",
actor_id,
json!({
"sessionId": "pi_lab_configure_permission",
"permissionMode": "auto_edit"
}),
)
.await;
assert_eq!(configure_status, StatusCode::OK);
assert_eq!(configure_payload["applied"]["permissionMode"], "auto_edit");
assert_eq!(
configure_payload["session"]["runtimePolicySnapshot"]["permissionMode"],
"auto_edit"
);
let (tool_status, tool_payload) = request_json(
app,
"/api/page-ai/pi/tool-call",
actor_id,
json!({
"sessionId": "pi_lab_configure_permission",
"toolName": "mnote.local_file.patch",
"params": {
"rootUri": root_uri,
"path": "note.md",
"operations": [{"op": "replace", "old": "before", "new": "after"}]
}
}),
)
.await;
assert_eq!(tool_status, StatusCode::OK);
assert_eq!(tool_payload["ok"], true);
assert_eq!(
fs::read_to_string(root.join("note.md")).expect("read file"),
"after"
);
}
#[tokio::test]
async fn get_session_returns_not_found_for_nonexistent_session() {
let app = test_app();
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/page-ai/pi/sessions/nonexistent_session_id")
.header("x-mnote-actor-id", "user_test_get")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["code"], "page_ai_pi_lab_session_not_found");
}
#[tokio::test]
async fn get_session_events_returns_not_found_for_nonexistent_session() {
let app = test_app();
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/page-ai/pi/sessions/nonexistent_session_id/events")
.header("x-mnote-actor-id", "user_test_events")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["code"], "page_ai_pi_lab_session_not_found");
}
// ── A7: Pi RPC request-response correlation ──────────────────────
#[test]
fn rpc_response_key_joins_session_id_and_rpc_id() {
let key = rpc_response_key("session_abc", "pi_rpc_state_123");
assert_eq!(key, "session_abc:pi_rpc_state_123");
}
#[test]
fn rpc_response_key_handles_empty_ids() {
let key = rpc_response_key("", "");
assert_eq!(key, ":");
}
#[test]
fn queue_mode_normalization_matches_pi_rpc_contract() {
assert_eq!(normalize_queue_mode("all", "steeringMode").unwrap(), "all");
assert_eq!(
normalize_queue_mode("one-at-a-time", "followUpMode").unwrap(),
"one-at-a-time"
);
assert_eq!(
normalize_queue_mode("oneAtATime", "steeringMode").unwrap(),
"one-at-a-time"
);
assert_eq!(
normalize_queue_mode("one_at_a_time", "followUpMode").unwrap(),
"one-at-a-time"
);
assert!(normalize_queue_mode("skip", "steeringMode").is_err());
assert!(normalize_queue_mode("queue", "followUpMode").is_err());
assert!(normalize_queue_mode("parallel", "steeringMode").is_err());
assert!(normalize_queue_mode("single", "followUpMode").is_err());
}
#[tokio::test]
async fn pending_rpc_registry_insert_remove_and_resolve() {
let (tx, rx) = oneshot::channel();
let key = "test_sess:test_id".to_string();
// Insert
{
let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await;
pending.insert(key.clone(), tx);
assert!(pending.contains_key(&key));
}
// Resolve via registry lookup
let response = json!({"type": "response", "success": true, "data": {"key": "val"}});
{
let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await;
if let Some(sender) = pending.remove(&key) {
let _ = sender.send(response.clone());
}
assert!(!pending.contains_key(&key));
}
// Verify receiver got the message
let received = rx.await.expect("should receive via oneshot");
assert_eq!(received["type"], "response");
assert_eq!(received["success"], true);
assert_eq!(received["data"]["key"], "val");
}
#[tokio::test]
async fn pending_rpc_registry_cleanup_on_remove() {
let (tx, _rx) = oneshot::channel();
let key = "cleanup_test:id".to_string();
// Insert
{
let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await;
pending.insert(key.clone(), tx);
assert!(pending.contains_key(&key));
}
// Remove without sending
{
let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await;
pending.remove(&key);
assert!(!pending.contains_key(&key));
}
}
#[tokio::test]
async fn mock_state_handler_unchanged() {
let app = test_app();
let (status, body) = request_json(
app,
"/api/page-ai/pi/state",
"user_a7_mock_state",
json!({
"sessionId": "nonexistent_mock_state",
}),
)
.await;
assert_eq!(status, 400);
assert_eq!(body["code"], "page_ai_pi_lab_session_not_found");
}
}