|
|
|
@@ -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();
|
|
|
|
|