diff --git a/bugs/07-ai/done/7-63-page-ai-pi-rust-official-p1-alignment-checklist-v1.md b/bugs/07-ai/done/7-63-page-ai-pi-rust-official-p1-alignment-checklist-v1.md new file mode 100644 index 00000000..083f4d30 --- /dev/null +++ b/bugs/07-ai/done/7-63-page-ai-pi-rust-official-p1-alignment-checklist-v1.md @@ -0,0 +1,30 @@ +# 7-63 Page AI Pi Rust Official P1 Alignment Checklist + +## 背景 + +Pi Rust built-in tools 已恢复为一等工具面,但 MNote Pi Lab 仍有一组官方能力只接了外壳、被 MNote 自造路径替代,或没有 UI/API 入口。该缺陷以 Pi Rust 官方 RPC/CLI 语义为准,不再把 MNote 当成 Pi 能力替代层;MNote 只负责展示、授权目录、URL/引用与宿主上下文。 + +## Checklist + +- [x] 官方 RPC 管理面:接入受控 `rpc-command` endpoint,覆盖 `get_messages`、`get_available_models`、`new_session`、`switch_session`、`set_session_name`、`export_html`、`abort_bash`。 +- [x] 中途发送:`steer` / `follow_up` 使用官方 RPC command,普通 prompt 保留为默认 `prompt`。 +- [x] Abort 语义:普通 abort 只发送官方 `abort`,不直接 kill runtime;新建会话/切换模式等需要结束旧进程的路径另走明确 stop/kill。 +- [x] Bash 中止:full access 下长 bash 暴露 `abort_bash` UI/API。 +- [x] Prompt templates:移除默认 `--no-prompt-templates`,无配置时允许官方 `.pi/prompts` / `~/.pi/agent/prompts` 发现。 +- [x] `@file` / context files:移除默认 `--no-context-files`,让 Pi Rust 官方 `@file` 与资源扩展处理真实文件地址;MNote hidden context 仅作为页面/selection/allowed roots 附加上下文。 +- [x] 图片/附件:前端接入文件选择并按 Pi RPC `images` base64 payload 传给 `prompt`/`steer`/`follow_up`。 +- [x] 测试:补静态 smoke 覆盖启动参数、RPC command allowlist、图片 payload、abort 不 kill;补浏览器 smoke 覆盖附件入口、RPC command wrapper 和官方 built-in tools 不回退。 +- [x] 验证:`cargo test -p mnote-web page_ai_pi`、相关 `scripts/task-pi-lab-*.js`、真实 Chromium smoke。 +- [x] 完成后移动到 `bugs/07-ai/done/` 并记录提交与验证证据。 + +## 验证证据 + +- `cargo test -p mnote-web page_ai_pi -- --nocapture`:30 passed。 +- `node scripts/task-pi-lab-static-smoke.js`:253 checks passed。 +- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 PLAYWRIGHT_CHROMIUM_EXECUTABLE=/usr/bin/chromium-browser node scripts/task-pi-lab-full-access-builtin-delete-smoke.js`:通过,结果 `/tmp/mnote-pi-full-access-builtin-tools-1783775113182/result.json`。 + +## 非目标 + +- 不在本轮开放任意外部 Pi extension/package install。 +- 不改变 MNote allowed roots / permission mode 的宿主授权边界。 +- 不把 OpenHub/LightRAG/MCP 的产品策略混入官方 Pi Rust RPC 对齐。 diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js index 4247bc5a..028da99f 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js @@ -28,6 +28,7 @@ START: '/api/page-ai/pi/start', SEND: '/api/page-ai/pi/send', ABORT: '/api/page-ai/pi/abort', + RPC_COMMAND: '/api/page-ai/pi/rpc-command', UI_RESPONSE: '/api/page-ai/pi/ui-response', EVENTS: '/api/page-ai/pi/events', BOOTSTRAP: '/api/page-ai/pi/bootstrap', @@ -97,6 +98,7 @@ modelOptions: [], history: [], pendingQueue: { steering: [], followUp: [] }, + pendingImages: [], viewingHistorySessionId: null, contextSelection: { currentPage: false, @@ -1647,6 +1649,10 @@ window.location.assign('/user/ai#ai-admin-access'); return; } + if (action === 'attach-file' || action === 'camera') { + pickPiPromptImages(action === 'camera'); + return; + } if (action === 'send-steer') { sendCurrentInput({ streamingBehavior: 'steer' }); return; @@ -1655,6 +1661,14 @@ sendCurrentInput({ streamingBehavior: 'followUp' }); return; } + if (action === 'abort-bash') { + callPiRpcCommand('abort_bash', {}, 5000).then(function () { + showPiToast('已请求中止 bash', 'info'); + }).catch(function (error) { + showPiToast(error && error.message ? error.message : 'bash 中止失败', 'warning'); + }); + return; + } if (action === 'plan-mode') { var input = piLabPanelEl && piLabPanelEl.querySelector('[data-page-ai-pi-lab-input]'); var current = input ? input.value.trim() : ''; @@ -1943,12 +1957,13 @@ '
' + '
权限与附件
' + '' + - '' + - '' + + '' + + '' + '
' + '
执行中发送
' + '' + '' + + '' + '' + '' + '
' + @@ -3386,6 +3401,71 @@ }).catch(function () {}); } + function callPiRpcCommand(type, params, timeoutMs) { + if (!piLabState.sessionId) return Promise.reject(new Error('Pi 会话尚未就绪')); + return fetch(API.RPC_COMMAND, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: piLabState.sessionId, + type: type, + params: params || {}, + timeoutMs: timeoutMs || 10000, + }), + }).then(function (r) { + return responseJsonOrError(r, 'Pi RPC command failed'); + }); + } + + function readFileAsPiImage(file) { + return new Promise(function (resolve, reject) { + if (!file || !/^image\//i.test(file.type || '')) { + reject(new Error('只支持图片附件;普通文件请在输入中使用 @文件路径')); + return; + } + var reader = new FileReader(); + reader.onload = function () { + var dataUrl = String(reader.result || ''); + var comma = dataUrl.indexOf(','); + if (comma < 0) { + reject(new Error('图片读取失败')); + return; + } + resolve({ + type: 'image', + source: { + type: 'base64', + mediaType: file.type || 'image/png', + data: dataUrl.slice(comma + 1), + }, + }); + }; + reader.onerror = function () { reject(new Error('图片读取失败')); }; + reader.readAsDataURL(file); + }); + } + + function pickPiPromptImages(useCamera) { + var input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.multiple = !useCamera; + if (useCamera) input.setAttribute('capture', 'environment'); + input.addEventListener('change', function () { + var files = Array.prototype.slice.call(input.files || []); + if (!files.length) return; + Promise.all(files.map(readFileAsPiImage)).then(function (images) { + piLabState.pendingImages = (piLabState.pendingImages || []).concat(images); + showPiToast('已添加 ' + images.length + ' 张图片', 'success'); + updateButtons(); + }).catch(function (error) { + showPiToast(error && error.message ? error.message : '附件读取失败', 'warning'); + }); + }); + input.click(); + } + function responseJsonOrError(response, fallbackMessage) { return response.json().catch(function () { return {}; }).then(function (payload) { @@ -3947,6 +4027,10 @@ selectedContext: contextSelection, }; if (streamingBehavior) body.streamingBehavior = streamingBehavior; + if (piLabState.pendingImages && piLabState.pendingImages.length) { + body.images = piLabState.pendingImages; + piLabState.pendingImages = []; + } return fetch(API.SEND, { method: 'POST', credentials: 'same-origin', diff --git a/rust/crates/mnote-web/src/routes/ai_settings.rs b/rust/crates/mnote-web/src/routes/ai_settings.rs index f594d814..342079bc 100644 --- a/rust/crates/mnote-web/src/routes/ai_settings.rs +++ b/rust/crates/mnote-web/src/routes/ai_settings.rs @@ -1905,7 +1905,10 @@ pub(crate) fn load_effective_ai_runtime_policy( .into_iter() .filter_map(|entry| normalize_model_ref(Some(&default_provider), &entry.id)) .collect::>(); - if !allowed_models.iter().any(|model| model == FREEFIRST_PI_MODEL) { + if !allowed_models + .iter() + .any(|model| model == FREEFIRST_PI_MODEL) + { allowed_models.push(FREEFIRST_PI_MODEL.to_string()); } if !allowed_models.iter().any(|model| model == DEFAULT_PI_MODEL) { diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 7e508527..6bdc7d3c 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -604,6 +604,7 @@ pub fn build_router(state: AppState) -> Router { .route("/api/page-ai/pi/configure", post(page_ai_pi::configure)) .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/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 297d747c..7f1b517f 100644 --- a/rust/crates/mnote-web/src/routes/page_ai_pi.rs +++ b/rust/crates/mnote-web/src/routes/page_ai_pi.rs @@ -43,6 +43,7 @@ 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_SCHEMA_RPC_COMMAND: &str = "mnote.page_ai_pi.rpc_command.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"; @@ -263,6 +264,7 @@ pub struct PiLabSendRequest { pub session_id: String, pub message: String, pub streaming_behavior: Option, + pub images: Option>, pub root_uri: Option, pub workspace_id: Option, pub page_path: Option, @@ -278,6 +280,17 @@ pub struct PiLabAbortRequest { pub session_id: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiLabRpcCommandRequest { + pub session_id: String, + #[serde(rename = "type", alias = "command")] + pub command_type: String, + #[serde(default)] + pub params: Value, + pub timeout_ms: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabRenameSessionRequest { @@ -723,10 +736,7 @@ fn resolve_file_path( .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(); + 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 @@ -2568,7 +2578,10 @@ fn stage_extension_file( Ok(target_path.to_string_lossy().to_string()) } -fn bind_mnote_bridge_context_path(extension_path: &str, context_path: &Path) -> Result<(), WebError> { +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!( @@ -2579,18 +2592,23 @@ fn bind_mnote_bridge_context_path(extension_path: &str, context_path: &Path) -> 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_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 | undefined = undefined;"#; if !source.contains(context_file_needle) || !source.contains(context_snapshot_needle) { @@ -3218,8 +3236,6 @@ async fn start_runtime_for_session( .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()); @@ -3675,6 +3691,66 @@ fn rpc_response_error_message(response: Option<&Value>, fallback: &str) -> Strin .to_string() } +fn normalize_pi_rpc_command_type(value: &str) -> String { + match value.trim() { + "follow-up" | "followUp" | "queue-follow-up" | "queueFollowUp" => "follow_up", + "get-state" | "getState" => "get_state", + "get-messages" | "getMessages" => "get_messages", + "get-available-models" | "getAvailableModels" => "get_available_models", + "set-model" | "setModel" => "set_model", + "set-thinking-level" | "setThinkingLevel" => "set_thinking_level", + "set-steering-mode" | "setSteeringMode" => "set_steering_mode", + "set-follow-up-mode" | "setFollowUpMode" => "set_follow_up_mode", + "set-auto-compaction" | "setAutoCompaction" => "set_auto_compaction", + "new-session" | "newSession" => "new_session", + "switch-session" | "switchSession" => "switch_session", + "set-session-name" | "setSessionName" => "set_session_name", + "export-html" | "exportHtml" => "export_html", + "abort-bash" | "abortBash" => "abort_bash", + other => other, + } + .to_string() +} + +fn pi_lab_allowed_rpc_commands() -> HashSet<&'static str> { + [ + "get_state", + "get_messages", + "get_available_models", + "new_session", + "switch_session", + "set_session_name", + "export_html", + "abort_bash", + "cycle_model", + ] + .into_iter() + .collect() +} + +fn build_pi_rpc_command(command_type: &str, params: Value) -> Result { + let command_type = normalize_pi_rpc_command_type(command_type); + if !pi_lab_allowed_rpc_commands().contains(command_type.as_str()) { + return Err(WebError::bad_request_code( + "page_ai_pi_lab_rpc_command_not_allowed", + format!("Pi RPC command 不允许通过 MNote wrapper 调用: {command_type}"), + )); + } + let mut command = match params { + Value::Object(map) => Value::Object(map), + Value::Null => json!({}), + _ => { + return Err(WebError::bad_request_code( + "page_ai_pi_lab_rpc_command_params_invalid", + "Pi RPC command params 必须是 object", + )); + } + }; + command["id"] = json!(generate_id("pi_rpc_command")); + command["type"] = json!(command_type); + Ok(command) +} + fn apply_text_operations(current: &str, operations: &Value) -> Result { match operations { Value::Array(ops) => { @@ -4149,8 +4225,13 @@ impl PiLabToolFacade { } fn local_file_read(&self, params: Value) -> Result { - let (target, root_uri, relative_path) = - resolve_file_path(&self.state, &self.context, ¶ms, self.session.as_ref(), false)?; + let (target, root_uri, relative_path) = resolve_file_path( + &self.state, + &self.context, + ¶ms, + 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", @@ -4168,8 +4249,13 @@ impl PiLabToolFacade { } fn local_file_patch(&self, params: Value) -> Result { - let (target, root_uri, relative_path) = - resolve_file_path(&self.state, &self.context, ¶ms, self.session.as_ref(), true)?; + let (target, root_uri, relative_path) = resolve_file_path( + &self.state, + &self.context, + ¶ms, + 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) { @@ -5045,6 +5131,7 @@ pub async fn bootstrap( .to_string(), message: prompt.to_string(), streaming_behavior: None, + images: None, root_uri: start_request.root_uri.clone(), workspace_id: start_request.workspace_id.clone(), page_path: start_request.page_path.clone(), @@ -5315,6 +5402,22 @@ pub async fn send( request.selected_context.as_ref(), request.context_refs.as_deref(), )?; + let command_type = match request + .streaming_behavior + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some("steer") => "steer", + Some("follow-up") | Some("followUp") | Some("follow_up") => "follow_up", + Some(value) => { + return Err(WebError::bad_request_code( + "page_ai_pi_lab_invalid_streaming_behavior", + format!("streamingBehavior 只能是 steer/follow-up,当前为 {value}"), + )); + } + None => "prompt", + }; let command_message = pi_lab_command_message_for_session( &command_session, &request.message, @@ -5323,7 +5426,7 @@ pub async fn send( let plan_mode_prompt_applied = session_permission_mode(&command_session) == Some("plan"); let mut command = json!({ "id": generate_id("pi_rpc"), - "type": "prompt", + "type": command_type, "message": command_message, "displayMessage": request.message, "context": { @@ -5336,13 +5439,8 @@ pub async fn send( "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 let Some(images) = request.images.clone().filter(|images| !images.is_empty()) { + command["images"] = json!(images); } if command_session.runtime_mode == "mock" { publish_event( @@ -5418,7 +5516,6 @@ pub async fn abort( 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; @@ -5462,6 +5559,60 @@ pub async fn abort( }))) } +pub async fn rpc_command( + State(state): State, + Extension(context): Extension, + Json(request): Json, +) -> Result, 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 command = build_pi_rpc_command(&request.command_type, request.params)?; + let command_type = command + .get("type") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + if session.runtime_mode == "mock" { + return Ok(Json(json!({ + "ok": true, + "schema": PI_LAB_SCHEMA_RPC_COMMAND, + "sessionId": request.session_id, + "providerSessionId": session.provider_session_id, + "command": command_type, + "stateSource": "mock_runtime_snapshot", + "rpcResponsePending": false, + "response": { + "type": "response", + "command": command_type, + "success": true, + "data": Value::Null, + }, + }))); + } + let timeout = Duration::from_millis(request.timeout_ms.unwrap_or(10_000).clamp(1_000, 120_000)); + let response = send_rpc_command_wait(&request.session_id, command, timeout).await?; + Ok(Json(json!({ + "ok": response.as_ref().is_some_and(|value| { + value.get("success").and_then(Value::as_bool).unwrap_or(false) + }), + "schema": PI_LAB_SCHEMA_RPC_COMMAND, + "sessionId": request.session_id, + "providerSessionId": session.provider_session_id, + "command": command_type, + "stateSource": "pi_rpc_command_response", + "rpcResponsePending": response.is_none(), + "response": response, + }))) +} + /// POST /api/page-ai/pi/state /// 封装官方 Pi RPC `get_state`。 /// mock 返回完整假数据;real 发送 get_state RPC 并等待响应,超时降级。 @@ -6616,9 +6767,7 @@ fn pi_lab_entry_parent_id(entry: &Value) -> Option<&str> { } fn pi_lab_entry_message(entry: &Value) -> Option<&Value> { - entry - .get("message") - .filter(|message| message.is_object()) + entry.get("message").filter(|message| message.is_object()) } fn pi_lab_entry_role(entry: &Value) -> String { @@ -7893,11 +8042,30 @@ pub async fn rename_session( update_session(&path.session_id, |session| { session.page_title = Some(title.to_string()); }); + let mut pi_rpc_response = Value::Null; + if let Some(session) = get_session(&path.session_id) { + if session.runtime_mode != "mock" && session_runtime_is_usable(&session) { + if let Some(response) = send_rpc_command_wait( + &path.session_id, + json!({ + "id": generate_id("pi_rpc_set_session_name"), + "type": "set_session_name", + "name": title, + }), + Duration::from_secs(5), + ) + .await? + { + pi_rpc_response = response; + } + } + } Ok(Json(json!({ "ok": true, "schema": "mnote.page_ai_pi.rename_session.v1", "sessionId": path.session_id, "title": title, + "piRpcResponse": pi_rpc_response, "updatedRuns": renamed.len(), }))) } @@ -8331,10 +8499,16 @@ mod tests { 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[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[2]["toolCalls"][0]["result"]["content"][0]["text"], + "当前页内容" + ); assert_eq!(replay[3]["text"], "FINAL_TAIL_REPLY"); } @@ -8743,6 +8917,30 @@ mod tests { ); } + #[test] + fn pi_rpc_command_wrapper_allows_only_official_control_commands() { + let command = build_pi_rpc_command( + "getAvailableModels", + json!({ + "ignoredClientField": true + }), + ) + .expect("allowed command"); + assert_eq!(command["type"], "get_available_models"); + assert!(command["id"] + .as_str() + .unwrap_or("") + .starts_with("pi_rpc_command")); + assert!(build_pi_rpc_command("bash", json!({"command": "pwd"})).is_err()); + } + + #[test] + fn pi_rust_start_keeps_official_prompt_templates_and_context_files_enabled() { + let source = include_str!("page_ai_pi.rs"); + assert!(!source.contains(".arg(\"--no-prompt-templates\")")); + assert!(!source.contains(".arg(\"--no-context-files\")")); + } + #[tokio::test] async fn list_sessions_filters_by_current_user_and_pi_profile() { let app = test_app(); @@ -8893,7 +9091,11 @@ mod tests { assert_eq!(policy["defaultModel"], "omniroute/pi-fast"); assert_eq!( policy["allowedModels"], - json!(["omniroute/freefirst", "omniroute/gpt-5.4-mini", "omniroute/pi-fast"]) + json!([ + "omniroute/freefirst", + "omniroute/gpt-5.4-mini", + "omniroute/pi-fast" + ]) ); assert!(policy["enabledSkills"] .as_array() diff --git a/scripts/task-pi-lab-full-access-builtin-delete-smoke.js b/scripts/task-pi-lab-full-access-builtin-delete-smoke.js index d5a652a0..1893946d 100644 --- a/scripts/task-pi-lab-full-access-builtin-delete-smoke.js +++ b/scripts/task-pi-lab-full-access-builtin-delete-smoke.js @@ -272,6 +272,20 @@ async function main() { ); await openPiUi(page); + result.checks.attachmentButtonsEnabled = await page.locator('[data-page-ai-pi-lab-menu-action="attach-file"]:not([disabled])').count() === 1 + && await page.locator('[data-page-ai-pi-lab-menu-action="camera"]:not([disabled])').count() === 1; + const rpcState = await requestJson(page, "/api/page-ai/pi/rpc-command", { + method: "POST", + data: { + sessionId: session.sessionId, + type: "get_state", + params: {}, + timeoutMs: 10000, + }, + }); + result.checks.rpcCommandGetStateOk = rpcState.ok === true && rpcState.command === "get_state"; + assert.equal(result.checks.attachmentButtonsEnabled, true, "Pi RPC 图片附件入口应该可用"); + assert.equal(result.checks.rpcCommandGetStateOk, true, "受控 Pi RPC command wrapper 应能调用官方 get_state"); await page.screenshot({ path: path.join(OUT, "01-full-access-started.png"), fullPage: false }); result.screenshots.started = path.join(OUT, "01-full-access-started.png"); diff --git a/scripts/task-pi-lab-static-smoke.js b/scripts/task-pi-lab-static-smoke.js index 6ae01eef..ce4cc781 100644 --- a/scripts/task-pi-lab-static-smoke.js +++ b/scripts/task-pi-lab-static-smoke.js @@ -204,6 +204,11 @@ const checks = [ ['MNote Pi bridge allows native tools when context file proves Pi Rust', mnotePiExtension.includes('isPiRustNativeRuntime') && mnotePiExtension.includes('Boolean(CONTEXT_FILE)')], ['route starts Pi with explicit MNote extension bridge', route.includes('--extension') && route.includes('mnotePiExtension')], ['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 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;')], ['route passes MNote tool manifest to extension env', route.includes('MNOTE_PI_BRIDGE_TOOLS') && route.includes('PI_MNOTE_BRIDGE_TOOLS') && route.includes('mnote_pi_tool_manifest')], ['route writes dynamic Pi Rust MNote context file', route.includes('mnote.pi.context.v1') && route.includes('PI_MNOTE_CONTEXT_FILE') && route.includes('write_pi_mnote_context_snapshot')], ['route binds staged MNote bridge to absolute context path and private embedded snapshot', route.includes('bind_mnote_bridge_context_path') && route.includes('DEFAULT_CONTEXT_FILE') && route.includes('EMBEDDED_CONTEXT')], @@ -217,6 +222,8 @@ const checks = [ ['dev:hot warmup defaults to no real model prompt', devHot.includes('MNOTE_PAGE_AI_PI_WARMUP_SEND') && devHot.includes('?? "0"')], ['runtime opens Pi drawer by prestarting current page session', showPiLabBody.includes('startRuntime().then') && showPiLabBody.includes('syncRuntimeState()')], ['runtime send path auto-starts Pi Rust instead of dead-end not-ready toast', runtime.includes('Pi Rust 正在启动,启动后自动发送') && runtime.includes('piLabStartPromise') && !runtime.includes('Pi 会话尚未就绪,请重新打开 Pi 面板或稍后重试')], + ['runtime enables Pi RPC image attachments', runtime.includes('pendingImages') && runtime.includes('readFileAsPiImage') && runtime.includes('body.images = piLabState.pendingImages') && !runtime.includes('Pi RPC 附件上下文尚未接入')], + ['runtime exposes official abort_bash command', runtime.includes("RPC_COMMAND: '/api/page-ai/pi/rpc-command'") && runtime.includes("callPiRpcCommand('abort_bash'")], ['runtime keeps selected model before start/configure', runtime.includes('ensurePiToolCapableModel') && !runtime.includes('isPiToolUnsupportedModel') && runtime.includes('gpt-5.4-mini') && runtime.includes('freefirst')], ['route applies TTL and rate limits', route.includes('PI_LAB_SESSION_TTL_MS') && route.includes('PI_LAB_RATE_LIMITS') && route.includes('check_rate_limit')], ['route cleans expired session dirs and rate buckets', route.includes('fs::remove_dir_all') && route.includes('buckets.retain')],