diff --git a/bugs/07-ai/done/7-64-page-ai-pi-rust-p2-advanced-runtime-diagnostics-checklist-v1.md b/bugs/07-ai/done/7-64-page-ai-pi-rust-p2-advanced-runtime-diagnostics-checklist-v1.md new file mode 100644 index 00000000..e9533e82 --- /dev/null +++ b/bugs/07-ai/done/7-64-page-ai-pi-rust-p2-advanced-runtime-diagnostics-checklist-v1.md @@ -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 diff --git a/rust/crates/mnote-web/src/routes/ai_settings.rs b/rust/crates/mnote-web/src/routes/ai_settings.rs index 342079bc..ba3ee4e0 100644 --- a/rust/crates/mnote-web/src/routes/ai_settings.rs +++ b/rust/crates/mnote-web/src/routes/ai_settings.rs @@ -305,6 +305,7 @@ pub(crate) struct AiRuntimeResolvedModel { pub(crate) struct EffectiveAiRuntimePolicy { pub default_model: String, pub allowed_models: Vec, + pub pi_runtime: Value, pub enabled_skills: Vec, pub enabled_skill_sources: Vec, pub enabled_mcp_servers: Vec, @@ -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, diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 6bdc7d3c..e9c2a86b 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -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), diff --git a/rust/crates/mnote-web/src/routes/page_ai_pi.rs b/rust/crates/mnote-web/src/routes/page_ai_pi.rs index 7f1b517f..331a9268 100644 --- a/rust/crates/mnote-web/src/routes/page_ai_pi.rs +++ b/rust/crates/mnote-web/src/routes/page_ai_pi.rs @@ -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, + repair_policy: Option, + session_durability: Option, + request_timeout_secs: Option, + max_tool_iterations: Option, + 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, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiLabDiagnosticsRequest { + pub command: String, + #[serde(default)] + pub params: Value, + #[serde(default)] + pub args: Vec, + pub timeout_ms: Option, + pub root_uri: Option, + pub workspace_id: Option, +} + #[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 { + 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 { + 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 { + 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::().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 { + 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::().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::().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 { 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 Option { + 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 { + 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::().ok()) + }) + }) +} + +fn diagnostics_string_array_param(params: &Value, keys: &[&str]) -> Vec { + 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 { + 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, 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 { 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, + Extension(context): Extension, + Json(request): Json, +) -> Result, 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(); diff --git a/scripts/task-pi-lab-static-smoke.js b/scripts/task-pi-lab-static-smoke.js index ce4cc781..d2c472f8 100644 --- a/scripts/task-pi-lab-static-smoke.js +++ b/scripts/task-pi-lab-static-smoke.js @@ -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>') && 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')],