fix: expose pi rust advanced diagnostics

This commit is contained in:
Agent Board
2026-07-12 06:45:58 +08:00
parent 1418f1b1e0
commit 031a08288b
5 changed files with 662 additions and 0 deletions
@@ -0,0 +1,22 @@
# Page AI Pi Rust P2 Advanced Runtime Diagnostics Checklist
## 背景
P1 已恢复 Pi Rust 官方主链能力。P2 聚焦非对话主链但会影响稳定性和排障效率的高级能力:运行时 policy 透传、effective policy 可见性、官方诊断/admin 命令的受控接入。
## 产品取舍
- [x] 支持 `--extension-policy` / `--repair-policy` 透传;MNote 仍保留自身 permission mode 与 allowlist,但诊断面暴露最终 effective advanced runtime。
- [x] 支持 `--session-durability``--request-timeout``--max-tool-iterations``--hide-cwd-in-prompt`;来源为 AI policy 的 `piRuntime` / `advancedRuntime`env 作为兜底。
- [x] provider 默认仍按 MNote 宿主策略走 OmniRoute,不把默认 provider 当 bug;后续如要“官方 Pi Rust 完整 provider passthrough”,应作为单独高级 provider passthrough 项。
- [x] 官方 `doctor/context-preview/list/info/search/update-index` 接入 MNote 诊断 API`update-index` 标记 `mutatesCache=true`,不放到普通对话链路。
- [x] 不开放 `install/remove/update/migrate/config` 这类会修改扩展安装或会话结构的命令;诊断 API 不接受裸 argv 透传。
## 验收
- [x] `cargo fmt --manifest-path rust/Cargo.toml -p mnote-web`
- [x] `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_pi -- --nocapture`32 passed
- [x] `node scripts/task-pi-lab-static-smoke.js`259 checks passed
- [x] 真实 Chromium Pi Lab smoke 仍能完成 built-in full access 删除验证:`/tmp/mnote-pi-full-access-builtin-tools-1783809738370/result.json`
- [x] `codegraph sync . && codegraph status .`Index is up to date
- [x] 提交 git
@@ -305,6 +305,7 @@ pub(crate) struct AiRuntimeResolvedModel {
pub(crate) struct EffectiveAiRuntimePolicy {
pub default_model: String,
pub allowed_models: Vec<String>,
pub pi_runtime: Value,
pub enabled_skills: Vec<String>,
pub enabled_skill_sources: Vec<String>,
pub enabled_mcp_servers: Vec<String>,
@@ -1997,6 +1998,11 @@ pub(crate) fn load_effective_ai_runtime_policy(
EffectiveAiRuntimePolicy {
default_model: normalize_model_ref(None, &default_model).unwrap_or(default_model),
allowed_models,
pi_runtime: model_policy
.get("piRuntime")
.or_else(|| model_policy.get("advancedRuntime"))
.cloned()
.unwrap_or_else(|| json!({})),
enabled_skills,
enabled_skill_sources,
enabled_mcp_servers,
+1
View File
@@ -605,6 +605,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/page-ai/pi/state", post(page_ai_pi::state))
.route("/api/page-ai/pi/compact", post(page_ai_pi::compact))
.route("/api/page-ai/pi/rpc-command", post(page_ai_pi::rpc_command))
.route("/api/page-ai/pi/diagnostics", post(page_ai_pi::diagnostics))
.route(
"/api/page-ai/pi/queue-config",
post(page_ai_pi::queue_config),
@@ -44,6 +44,7 @@ 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_SCHEMA_RPC_COMMAND: &str = "mnote.page_ai_pi.rpc_command.v1";
const PI_LAB_SCHEMA_DIAGNOSTICS: &str = "mnote.page_ai_pi.diagnostics.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";
@@ -73,6 +74,7 @@ const PI_LAB_MANAGED_BUILTIN_TOOLS: [&str; 8] = [
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;
const PI_LAB_DIAGNOSTICS_MAX_OUTPUT_BYTES: usize = 512 * 1024;
pub const PI_LAB_PROFILE: &str = "pi_lab";
pub const PI_LAB_ACP_RUNTIME: &str = "pi";
@@ -171,6 +173,30 @@ struct PiLabUiPendingResponse {
expires_at_ms: u128,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct PiLabAdvancedRuntimeConfig {
extension_policy: Option<String>,
repair_policy: Option<String>,
session_durability: Option<String>,
request_timeout_secs: Option<u64>,
max_tool_iterations: Option<u64>,
hide_cwd_in_prompt: bool,
}
impl PiLabAdvancedRuntimeConfig {
fn empty() -> Self {
Self {
extension_policy: None,
repair_policy: None,
session_durability: None,
request_timeout_secs: None,
max_tool_iterations: None,
hide_cwd_in_prompt: false,
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabToolReceipt {
@@ -291,6 +317,19 @@ pub struct PiLabRpcCommandRequest {
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabDiagnosticsRequest {
pub command: String,
#[serde(default)]
pub params: Value,
#[serde(default)]
pub args: Vec<String>,
pub timeout_ms: Option<u64>,
pub root_uri: Option<String>,
pub workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PiLabRenameSessionRequest {
@@ -884,6 +923,7 @@ fn session_from_request(
runtime_policy_snapshot =
refresh_runtime_policy_permission_mode_value(runtime_policy_snapshot, permission_mode);
}
runtime_policy_snapshot = refresh_runtime_policy_advanced_config_value(runtime_policy_snapshot);
let now = now_ms();
Ok(PiLabSession {
session_id: session_id.clone(),
@@ -1032,6 +1072,7 @@ fn pi_lab_start_response(session: &PiLabSession, reused: bool) -> Value {
"piExtensionToolNames": pi_extension_tool_names,
"thinkingLevel": session.thinking_level,
"permissionMode": session_permission_mode(session),
"advancedRuntime": pi_lab_effective_advanced_runtime_config(session),
"runtimeImplementation": runtime_impl,
"runtimeBinary": runtime_binary,
"runtimeAvailable": runtime_available,
@@ -1040,6 +1081,164 @@ fn pi_lab_start_response(session: &PiLabSession, reused: bool) -> Value {
})
}
fn policy_string_value(policy: Option<&Value>, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| policy.and_then(|value| value.get(*key)))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn policy_bool_value(policy: Option<&Value>, keys: &[&str]) -> Option<bool> {
keys.iter()
.find_map(|key| policy.and_then(|value| value.get(*key)))
.and_then(Value::as_bool)
}
fn policy_u64_value(policy: Option<&Value>, keys: &[&str]) -> Option<u64> {
keys.iter()
.find_map(|key| policy.and_then(|value| value.get(*key)))
.and_then(|value| {
value.as_u64().or_else(|| {
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|value| value.parse::<u64>().ok())
})
})
}
fn truthy_config_value(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
fn normalize_one_of(value: &str, allowed: &[&str]) -> Option<String> {
let normalized = value.trim().to_ascii_lowercase();
allowed
.iter()
.any(|allowed| *allowed == normalized)
.then_some(normalized)
}
fn pi_lab_advanced_runtime_policy(session: &PiLabSession) -> Option<&Value> {
session.runtime_policy_snapshot.as_ref().and_then(|policy| {
policy
.get("piRuntime")
.or_else(|| policy.get("advancedRuntime"))
})
}
fn pi_lab_effective_advanced_runtime_config(session: &PiLabSession) -> PiLabAdvancedRuntimeConfig {
let policy = pi_lab_advanced_runtime_policy(session);
let extension_policy = policy_string_value(policy, &["extensionPolicy", "extension_policy"])
.or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_EXTENSION_POLICY"))
.and_then(|value| normalize_one_of(&value, &["safe", "balanced", "permissive", "standard"]))
.map(|value| {
if value == "standard" {
"balanced".to_string()
} else {
value
}
});
let repair_policy = policy_string_value(policy, &["repairPolicy", "repair_policy"])
.or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_REPAIR_POLICY"))
.and_then(|value| {
normalize_one_of(&value, &["off", "suggest", "auto-safe", "auto-strict"])
});
let session_durability =
policy_string_value(policy, &["sessionDurability", "session_durability"])
.or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_SESSION_DURABILITY"))
.and_then(|value| normalize_one_of(&value, &["strict", "balanced", "throughput"]));
let request_timeout_secs =
policy_u64_value(policy, &["requestTimeoutSecs", "request_timeout_secs"])
.or_else(|| {
env_trimmed("MNOTE_PAGE_AI_PI_REQUEST_TIMEOUT_SECS")
.and_then(|value| value.parse::<u64>().ok())
})
.filter(|value| *value <= 86_400);
let max_tool_iterations =
policy_u64_value(policy, &["maxToolIterations", "max_tool_iterations"])
.or_else(|| {
env_trimmed("MNOTE_PAGE_AI_PI_MAX_TOOL_ITERATIONS")
.and_then(|value| value.parse::<u64>().ok())
})
.filter(|value| (1..=1000).contains(value));
let hide_cwd_in_prompt = policy_bool_value(policy, &["hideCwdInPrompt", "hide_cwd_in_prompt"])
.or_else(|| {
env_trimmed("MNOTE_PAGE_AI_PI_HIDE_CWD_IN_PROMPT")
.map(|value| truthy_config_value(&value))
})
.unwrap_or(false);
PiLabAdvancedRuntimeConfig {
extension_policy,
repair_policy,
session_durability,
request_timeout_secs,
max_tool_iterations,
hide_cwd_in_prompt,
}
}
fn refresh_runtime_policy_advanced_config_value(mut policy: Value) -> Value {
let session = PiLabSession {
session_id: String::new(),
mnote_user_id: String::new(),
bridge_token: String::new(),
status: PiLabSessionStatus::Idle,
provider_session_id: String::new(),
pi_session_dir: String::new(),
pi_session_file: None,
root_uri: None,
workspace_id: None,
page_path: None,
page_title: None,
model_provider: None,
model_id: None,
thinking_level: None,
allowed_roots_snapshot: None,
runtime_policy_snapshot: Some(policy.clone()),
runtime_pid: None,
runtime_mode: String::new(),
runtime_error: None,
created_at_ms: 0,
updated_at_ms: 0,
message_count: 0,
};
let config = pi_lab_effective_advanced_runtime_config(&session);
policy["piRuntime"] = serde_json::to_value(config).unwrap_or_else(|_| json!({}));
policy
}
fn append_pi_lab_advanced_runtime_cli_args(
command: &mut Command,
config: &PiLabAdvancedRuntimeConfig,
) {
if let Some(value) = config.extension_policy.as_deref() {
command.arg("--extension-policy").arg(value);
}
if let Some(value) = config.repair_policy.as_deref() {
command.arg("--repair-policy").arg(value);
}
if let Some(value) = config.session_durability.as_deref() {
command.arg("--session-durability").arg(value);
}
if let Some(value) = config.request_timeout_secs {
command.arg("--request-timeout").arg(value.to_string());
}
if let Some(value) = config.max_tool_iterations {
command.arg("--max-tool-iterations").arg(value.to_string());
}
if config.hide_cwd_in_prompt {
command.arg("--hide-cwd-in-prompt");
}
}
fn pi_mcp_extension_source() -> Option<String> {
let value = env_trimmed("MNOTE_PAGE_AI_PI_MCP_EXTENSION")?;
let normalized = value.to_ascii_lowercase();
@@ -3143,6 +3342,7 @@ async fn start_runtime_for_session(
)?;
let bridge_base_url = pi_lab_public_base_url();
let pi_extension_tool_names = pi_lab_runtime_pi_extension_tool_names(&session);
let advanced_runtime_config = pi_lab_effective_advanced_runtime_config(&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());
@@ -3174,6 +3374,7 @@ async fn start_runtime_for_session(
"mcpConfigPath": mcp_config_path.clone(),
"sharedMcpCachePath": shared_mcp_cache_path.clone(),
"permissionConfigPath": permission_config_path.clone(),
"advancedRuntime": advanced_runtime_config.clone(),
"configuredPiExtensionSources": configured_extension_sources.clone(),
"piExtensionSources": pi_extension_sources.clone(),
"piExtensionToolNames": pi_extension_tool_names.clone(),
@@ -3196,6 +3397,7 @@ async fn start_runtime_for_session(
"mcpConfigPath": mcp_config_path,
"sharedMcpCachePath": shared_mcp_cache_path,
"permissionConfigPath": permission_config_path,
"advancedRuntime": advanced_runtime_config,
"configuredPiExtensionSources": configured_extension_sources,
"piExtensionSources": pi_extension_sources,
"piExtensionToolNames": pi_extension_tool_names,
@@ -3248,6 +3450,7 @@ async fn start_runtime_for_session(
for arg in pi_lab_skill_cli_args(&session) {
command.arg(arg);
}
append_pi_lab_advanced_runtime_cli_args(&mut command, &advanced_runtime_config);
if let Some(provider) = session
.model_provider
.as_deref()
@@ -3391,6 +3594,7 @@ async fn start_runtime_for_session(
"mnotePiExtension": mnote_pi_extension_path,
"mcpConfigPath": mcp_config_path,
"permissionConfigPath": permission_config_path,
"advancedRuntime": advanced_runtime_config,
"configuredPiExtensionSources": configured_extension_sources,
"piExtensionSources": pi_extension_sources,
"piExtensionToolNames": pi_extension_tool_names,
@@ -3751,6 +3955,262 @@ fn build_pi_rpc_command(command_type: &str, params: Value) -> Result<Value, WebE
Ok(command)
}
fn diagnostics_string_param(params: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| params.get(*key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn diagnostics_u64_param(params: &Value, keys: &[&str]) -> Option<u64> {
keys.iter()
.find_map(|key| params.get(*key))
.and_then(|value| {
value.as_u64().or_else(|| {
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|value| value.parse::<u64>().ok())
})
})
}
fn diagnostics_string_array_param(params: &Value, keys: &[&str]) -> Vec<String> {
keys.iter()
.find_map(|key| params.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 truncate_diagnostics_output(bytes: &[u8]) -> (String, bool) {
let truncated = bytes.len() > PI_LAB_DIAGNOSTICS_MAX_OUTPUT_BYTES;
let output = if truncated {
&bytes[..PI_LAB_DIAGNOSTICS_MAX_OUTPUT_BYTES]
} else {
bytes
};
(String::from_utf8_lossy(output).to_string(), truncated)
}
fn safe_relative_or_absolute_path(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.contains('\0') {
return None;
}
let path = Path::new(trimmed);
if path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return None;
}
Some(trimmed.to_string())
}
fn build_pi_diagnostics_args(
request: &PiLabDiagnosticsRequest,
) -> Result<(Vec<String>, bool), WebError> {
if !request.args.is_empty() {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_diagnostics_raw_args_not_allowed",
"Pi diagnostics 不接受裸 args 透传;请使用 params 白名单字段",
));
}
let command = request.command.trim().to_ascii_lowercase();
let params = &request.params;
let mut args = Vec::new();
let mut mutates_cache = false;
match command.as_str() {
"doctor" => {
args.push("doctor".to_string());
let format =
diagnostics_string_param(params, &["format"]).unwrap_or_else(|| "json".into());
let normalized_format = normalize_one_of(&format, &["text", "json", "markdown"])
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_pi_lab_invalid_diagnostics_format",
"doctor format 只能是 text/json/markdown",
)
})?;
args.push("--format".into());
args.push(normalized_format);
if let Some(policy) =
diagnostics_string_param(params, &["policy", "extensionPolicy", "extension_policy"])
{
let policy =
normalize_one_of(&policy, &["safe", "balanced", "permissive", "standard"])
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_pi_lab_invalid_diagnostics_policy",
"doctor policy 只能是 safe/balanced/permissive/standard",
)
})?;
args.push("--policy".into());
args.push(policy);
}
if let Some(only) = diagnostics_string_param(params, &["only"]) {
let only = normalize_one_of(
&only,
&[
"config",
"dirs",
"auth",
"shell",
"sessions",
"swarm",
"extensions",
],
)
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_pi_lab_invalid_diagnostics_only",
"doctor only 只能是 config/dirs/auth/shell/sessions/swarm/extensions",
)
})?;
args.push("--only".into());
args.push(only);
}
if let Some(path) =
diagnostics_string_param(params, &["path", "extensionPath", "extension_path"])
{
let path = safe_relative_or_absolute_path(&path).ok_or_else(|| {
WebError::bad_request_code(
"page_ai_pi_lab_invalid_diagnostics_path",
"diagnostics path 不能为空、不能包含 NUL 或 ..",
)
})?;
args.push(path);
}
}
"context-preview" | "contextpreview" => {
args.push("context-preview".to_string());
let format =
diagnostics_string_param(params, &["format"]).unwrap_or_else(|| "json".into());
let normalized_format =
normalize_one_of(&format, &["text", "json"]).ok_or_else(|| {
WebError::bad_request_code(
"page_ai_pi_lab_invalid_diagnostics_format",
"context-preview format 只能是 text/json",
)
})?;
args.push("--format".into());
args.push(normalized_format);
if let Some(bead) = diagnostics_string_param(params, &["bead"]) {
args.push("--bead".into());
args.push(bead);
}
for changed_path in
diagnostics_string_array_param(params, &["changedPaths", "changed_paths"])
{
let changed_path =
safe_relative_or_absolute_path(&changed_path).ok_or_else(|| {
WebError::bad_request_code(
"page_ai_pi_lab_invalid_diagnostics_path",
"changedPath 不能为空、不能包含 NUL 或 ..",
)
})?;
args.push("--changed-path".into());
args.push(changed_path);
}
if let Some(failing_command) =
diagnostics_string_param(params, &["failingCommand", "failing_command"])
{
args.push("--failing-command".into());
args.push(failing_command);
}
if let Some(max_items) = diagnostics_u64_param(params, &["maxItems", "max_items"]) {
if !(1..=100).contains(&max_items) {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_invalid_diagnostics_limit",
"context-preview maxItems 必须在 1..=100",
));
}
args.push("--max-items".into());
args.push(max_items.to_string());
}
if let Some(max_bytes) = diagnostics_u64_param(params, &["maxBytes", "max_bytes"]) {
if !(1024..=1_048_576).contains(&max_bytes) {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_invalid_diagnostics_limit",
"context-preview maxBytes 必须在 1024..=1048576",
));
}
args.push("--max-bytes".into());
args.push(max_bytes.to_string());
}
let query = diagnostics_string_array_param(params, &["query", "queries"]);
args.extend(query);
}
"list" => {
args.push("list".to_string());
}
"info" => {
args.push("info".to_string());
let name = diagnostics_string_param(params, &["name", "id"]).ok_or_else(|| {
WebError::bad_request_code(
"page_ai_pi_lab_diagnostics_name_required",
"info 需要 name 或 id",
)
})?;
args.push(name);
}
"search" => {
args.push("search".to_string());
let query = diagnostics_string_param(params, &["query", "q"]).ok_or_else(|| {
WebError::bad_request_code(
"page_ai_pi_lab_diagnostics_query_required",
"search 需要 query",
)
})?;
if let Some(tag) = diagnostics_string_param(params, &["tag"]) {
args.push("--tag".into());
args.push(tag);
}
if let Some(sort) = diagnostics_string_param(params, &["sort"]) {
let sort = normalize_one_of(&sort, &["relevance", "name"]).ok_or_else(|| {
WebError::bad_request_code(
"page_ai_pi_lab_invalid_diagnostics_sort",
"search sort 只能是 relevance/name",
)
})?;
args.push("--sort".into());
args.push(sort);
}
if let Some(limit) = diagnostics_u64_param(params, &["limit"]) {
if !(1..=100).contains(&limit) {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_invalid_diagnostics_limit",
"search limit 必须在 1..=100",
));
}
args.push("--limit".into());
args.push(limit.to_string());
}
args.push(query);
}
"update-index" | "updateindex" => {
args.push("update-index".to_string());
mutates_cache = true;
}
_ => {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_diagnostics_command_not_allowed",
"Pi diagnostics 只允许 doctor/context-preview/list/info/search/update-index",
));
}
}
Ok((args, mutates_cache))
}
fn apply_text_operations(current: &str, operations: &Value) -> Result<String, WebError> {
match operations {
Value::Array(ops) => {
@@ -5034,6 +5494,7 @@ pub async fn status(
"defaultModelId": default_model_id(),
"defaultThinkingLevel": default_thinking_level(),
"permissionMode": current_session.as_ref().and_then(|session| session_permission_mode(session)),
"advancedRuntime": current_session.as_ref().map(pi_lab_effective_advanced_runtime_config).unwrap_or_else(PiLabAdvancedRuntimeConfig::empty),
"omnirouteBaseUrl": omniroute_base_url(),
"piExtensions": current_session
.as_ref()
@@ -5613,6 +6074,82 @@ pub async fn rpc_command(
})))
}
pub async fn diagnostics(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<PiLabDiagnosticsRequest>,
) -> Result<Json<Value>, WebError> {
ensure_enabled(&state)?;
cleanup_expired_sessions();
ensure_authenticated(&state, &context)?;
let (args, mutates_cache) = build_pi_diagnostics_args(&request)?;
let binary = pi_binary();
if !pi_runtime_binary_available(&binary) {
let runtime_impl = pi_runtime_impl();
let message = pi_runtime_install_hint(&runtime_impl, &binary)
.unwrap_or_else(|| format!("Pi runtime 不可用: {binary}"));
return Err(
WebError::bad_gateway_code("page_ai_pi_lab_runtime_missing", message).with_details(
json!({
"runtimeImplementation": runtime_impl,
"runtimeBinary": binary,
}),
),
);
}
let cwd = active_allowed_roots(&state, &context)?
.into_iter()
.find(|root| {
request
.root_uri
.as_deref()
.is_none_or(|root_uri| root.root_uri.as_deref() == Some(root_uri))
&& request
.workspace_id
.as_deref()
.is_none_or(|workspace_id| root.workspace_id.as_deref() == Some(workspace_id))
})
.map(|root| root.root_path)
.or_else(|| std::env::current_dir().ok());
let timeout_ms = request.timeout_ms.unwrap_or(15_000).clamp(1_000, 120_000);
let mut command = Command::new(&binary);
command
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(cwd) = cwd.as_deref() {
command.current_dir(cwd);
}
let output = tokio::time::timeout(Duration::from_millis(timeout_ms), command.output())
.await
.map_err(|_| {
WebError::new(
StatusCode::GATEWAY_TIMEOUT,
"page_ai_pi_lab_diagnostics_timeout",
format!("Pi diagnostics 超时: {}", request.command.trim()),
)
})?
.map_err(|error| WebError::internal(format!("执行 Pi diagnostics 失败: {error}")))?;
let (stdout, stdout_truncated) = truncate_diagnostics_output(&output.stdout);
let (stderr, stderr_truncated) = truncate_diagnostics_output(&output.stderr);
Ok(Json(json!({
"ok": output.status.success(),
"schema": PI_LAB_SCHEMA_DIAGNOSTICS,
"command": args.first().cloned().unwrap_or_else(|| request.command.trim().to_string()),
"args": args,
"mutatesCache": mutates_cache,
"runtimeBinary": binary,
"cwd": cwd.map(|path| path.to_string_lossy().to_string()),
"statusCode": output.status.code(),
"stdout": stdout,
"stderr": stderr,
"stdoutTruncated": stdout_truncated,
"stderrTruncated": stderr_truncated,
"timeoutMs": timeout_ms,
})))
}
/// POST /api/page-ai/pi/state
/// 封装官方 Pi RPC `get_state`。
/// mock 返回完整假数据;real 发送 get_state RPC 并等待响应,超时降级。
@@ -8941,6 +9478,96 @@ mod tests {
assert!(!source.contains(".arg(\"--no-context-files\")"));
}
#[test]
fn advanced_runtime_config_accepts_policy_and_env_fallbacks() {
std::env::set_var("MNOTE_PAGE_AI_PI_REPAIR_POLICY", "auto-safe");
let root = temp_root("mnote-pi-advanced-runtime");
let mut session = permission_mode_test_session(&root, "full_access");
session.runtime_policy_snapshot = Some(json!({
"piRuntime": {
"extensionPolicy": "permissive",
"sessionDurability": "throughput",
"requestTimeoutSecs": 120,
"maxToolIterations": 80,
"hideCwdInPrompt": true
}
}));
let config = pi_lab_effective_advanced_runtime_config(&session);
assert_eq!(config.extension_policy.as_deref(), Some("permissive"));
assert_eq!(config.repair_policy.as_deref(), Some("auto-safe"));
assert_eq!(config.session_durability.as_deref(), Some("throughput"));
assert_eq!(config.request_timeout_secs, Some(120));
assert_eq!(config.max_tool_iterations, Some(80));
assert!(config.hide_cwd_in_prompt);
std::env::remove_var("MNOTE_PAGE_AI_PI_REPAIR_POLICY");
}
#[test]
fn diagnostics_builder_allows_only_safe_official_commands() {
let doctor = PiLabDiagnosticsRequest {
command: "doctor".into(),
params: json!({"only": "sessions", "policy": "balanced"}),
args: Vec::new(),
timeout_ms: None,
root_uri: None,
workspace_id: None,
};
let (args, mutates_cache) = build_pi_diagnostics_args(&doctor).expect("doctor args");
assert_eq!(
args,
vec!["doctor", "--format", "json", "--policy", "balanced", "--only", "sessions"]
);
assert!(!mutates_cache);
let search = PiLabDiagnosticsRequest {
command: "search".into(),
params: json!({"query": "git", "sort": "name", "limit": 10}),
args: Vec::new(),
timeout_ms: None,
root_uri: None,
workspace_id: None,
};
let (args, _) = build_pi_diagnostics_args(&search).expect("search args");
assert_eq!(
args,
vec!["search", "--sort", "name", "--limit", "10", "git"]
);
let update_index = PiLabDiagnosticsRequest {
command: "updateIndex".into(),
params: json!({}),
args: Vec::new(),
timeout_ms: None,
root_uri: None,
workspace_id: None,
};
let (args, mutates_cache) =
build_pi_diagnostics_args(&update_index).expect("update-index args");
assert_eq!(args, vec!["update-index"]);
assert!(mutates_cache);
let denied = PiLabDiagnosticsRequest {
command: "install".into(),
params: json!({"source": "npm:unsafe"}),
args: Vec::new(),
timeout_ms: None,
root_uri: None,
workspace_id: None,
};
assert!(build_pi_diagnostics_args(&denied).is_err());
let raw_args_denied = PiLabDiagnosticsRequest {
command: "doctor".into(),
params: json!({}),
args: vec!["--fix".into()],
timeout_ms: None,
root_uri: None,
workspace_id: None,
};
assert!(build_pi_diagnostics_args(&raw_args_denied).is_err());
}
#[tokio::test]
async fn list_sessions_filters_by_current_user_and_pi_profile() {
let app = test_app();
+6
View File
@@ -206,6 +206,11 @@ const checks = [
['route starts Pi with MNote tool allowlist', route.includes('--tools') && route.includes('mnoteToolNames') && route.includes('pi_lab_extension_tool_names')],
['route keeps Pi Rust official prompt templates and @file context enabled', !route.includes('.arg("--no-prompt-templates")') && !route.includes('.arg("--no-context-files")')],
['route exposes controlled Pi Rust RPC command wrapper', route.includes('PI_LAB_SCHEMA_RPC_COMMAND') && route.includes('build_pi_rpc_command') && route.includes('abort_bash') && route.includes('get_available_models')],
['route exposes controlled Pi Rust diagnostics wrapper', route.includes('PI_LAB_SCHEMA_DIAGNOSTICS') && route.includes('pub async fn diagnostics') && route.includes('build_pi_diagnostics_args') && routesMod.includes('/api/page-ai/pi/diagnostics')],
['route only allows read-only or cache-only Pi diagnostics commands', route.includes('doctor/context-preview/list/info/search/update-index') && route.includes('mutatesCache') && route.includes('page_ai_pi_lab_diagnostics_command_not_allowed')],
['route rejects raw Pi diagnostics args passthrough', route.includes('page_ai_pi_lab_diagnostics_raw_args_not_allowed') && route.includes('不接受裸 args 透传')],
['route passes official Pi Rust advanced runtime flags', route.includes('--extension-policy') && route.includes('--repair-policy') && route.includes('--session-durability') && route.includes('--request-timeout') && route.includes('--max-tool-iterations') && route.includes('--hide-cwd-in-prompt')],
['route exposes effective Pi advanced runtime config', route.includes('advancedRuntime') && route.includes('pi_lab_effective_advanced_runtime_config') && route.includes('MNOTE_PAGE_AI_PI_EXTENSION_POLICY') && route.includes('MNOTE_PAGE_AI_PI_REPAIR_POLICY')],
['route maps mid-stream send to official steer/follow_up commands', route.includes('Some("steer") => "steer"') && route.includes('Some("follow-up") | Some("followUp") | Some("follow_up") => "follow_up"')],
['route sends Pi RPC images payload without MNote file substitution', route.includes('pub images: Option<Vec<Value>>') && route.includes('command["images"] = json!(images)')],
['route abort uses official RPC without killing runtime process', route.includes('json!({"type": "abort"})') && !route.includes('let _ = kill_session_process(&request.session_id).await;')],
@@ -258,6 +263,7 @@ const checks = [
['mod.rs mounts pi start route', routesMod.includes('/api/page-ai/pi/start')],
['mod.rs mounts pi send route', routesMod.includes('/api/page-ai/pi/send')],
['mod.rs mounts pi abort route', routesMod.includes('/api/page-ai/pi/abort')],
['mod.rs mounts pi diagnostics route', routesMod.includes('/api/page-ai/pi/diagnostics')],
['mod.rs mounts pi events route', routesMod.includes('/api/page-ai/pi/events')],
['mod.rs mounts pi session history delete and clear routes', routesMod.includes('get(page_ai_pi::list_sessions).delete(page_ai_pi::clear_sessions)') && routesMod.includes('.delete(page_ai_pi::delete_session_history)')],
['mod.rs mounts pi tool-call route', routesMod.includes('/api/page-ai/pi/tool-call')],