chore: land tree view-state, vault, Pi module split, and repo hygiene

Persist PageTree expand state via control-plane view-state and align
chevron/DOM with restored expansion; keep Sidex-style shallow page-tree
scan and drop the unused recursive scanner that only added cargo noise.

Add password vault workbench routes/runtime/skill/CLI, split page_ai_pi
into a module package, and retire Hermes/ACP/OpenHub recycle + root
harness evidence from the index while gitignoring recycle and local
diag dumps.

Archive superseded design/bugs docs under old/, point architecture at
ARCHITECTURE.md, and refresh smokes for Pi S1–S7, vault, and editor
regressions so the working tree can stay clean.
This commit is contained in:
Agent Board
2026-07-21 05:13:05 +08:00
parent 6f9c7d3b58
commit b798f628ee
264 changed files with 17480 additions and 17314 deletions
+169 -17
View File
@@ -510,16 +510,17 @@ pub async fn user_access_scopes(
/// `GET /api/ai-admin/access-scopes`
///
/// Admin-only variant. Validates that the current actor has admin
/// privileges via `is_local_access_policy_admin_context` before
/// returning directory grants.
/// privileges from the active control-plane session before
/// returning **all** directory grants (not only the admin actor's own).
/// Source of truth matches `/api/admin/access-policy` → `list_directory_grants()`.
pub async fn admin_access_scopes(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<AccessScopesQuery>,
) -> Result<Json<AccessScopesResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
let _actor_id = ensure_authenticated(&context)?;
if !local_folder_source::is_local_access_policy_admin_context(&context) {
if !crate::routes::gateway::current_actor_is_local_admin(&state, &context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_access_forbidden",
@@ -528,8 +529,13 @@ pub async fn admin_access_scopes(
.with_context(&context));
}
// S3: admin SoT is the full grant table, not actor-scoped grants.
let grants = state
.control_plane()
.list_directory_grants()
.map_err(|e| WebError::internal(format!("读取目录授权失败: {e}")))?;
let allowed_roots =
load_active_directory_grants(&state, &actor_id, query.workspace_id.as_deref())?;
filter_directory_grants_to_access_scopes(grants, query.workspace_id.as_deref());
Ok(Json(AccessScopesResponse {
allowed_roots,
source_of_truth: SOURCE_OF_TRUTH,
@@ -551,7 +557,7 @@ pub async fn admin_receipts(
Query(query): Query<ReceiptQuery>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
if !local_folder_source::is_local_access_policy_admin_context(&context) {
if !crate::routes::gateway::current_actor_is_local_admin(&state, &context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_access_forbidden",
@@ -629,7 +635,7 @@ pub async fn admin_directory_access_requests(
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let requests = list_directory_access_requests(&state, None)?;
Ok(Json(json!({
"ok": true,
@@ -645,7 +651,7 @@ pub async fn approve_directory_access_request(
Json(_body): Json<DirectoryAccessDecisionBody>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let request = find_pending_directory_access_request(&state, &request_id)?;
let user_id = request
.get("userId")
@@ -720,7 +726,7 @@ pub async fn reject_directory_access_request(
Json(body): Json<DirectoryAccessDecisionBody>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let _ = find_pending_directory_access_request(&state, &request_id)?;
let metadata = json!({
"requestId": request_id,
@@ -901,7 +907,7 @@ pub async fn admin_get_settings(
Extension(context): Extension<RequestContext>,
) -> Result<Json<AdminAiSettingsResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let global_owner = global_policy_owner_id(&actor_id);
let policy = state
@@ -939,7 +945,7 @@ pub async fn admin_put_settings(
Json(body): Json<UpsertAiPolicyBody>,
) -> Result<Json<AdminAiSettingsUpsertResponse>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let global_owner = global_policy_owner_id(&actor_id);
// ── Validate provider secretRefs ──────────────────────────────────
@@ -1036,7 +1042,7 @@ pub async fn admin_list_users(
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
let users = state
.control_plane()
.list_users(500)
@@ -1049,7 +1055,7 @@ pub async fn admin_list_users(
"email": user.email,
"username": user.username,
"displayName": user.display_name,
"role": if is_configured_admin_user(&user.id) { "admin" } else { user.role.as_str() },
"role": if is_admin_user_for_display(&user.id, &user.role) { "admin" } else { user.role.as_str() },
"status": user.status,
"createdAt": user.created_at,
"updatedAt": user.updated_at,
@@ -1069,7 +1075,7 @@ pub async fn admin_get_user_settings(
Path(user_id): Path<String>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
ensure_known_user(&state, &user_id)?;
let global_owner = global_policy_owner_id(&actor_id);
let global_policy = load_policy_value(&state, &global_owner, None);
@@ -1092,7 +1098,7 @@ pub async fn admin_put_user_settings(
Json(body): Json<UserAiPolicyBody>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
ensure_admin(&context)?;
ensure_admin(&state, &context)?;
ensure_known_user(&state, &user_id)?;
let global_owner = global_policy_owner_id(&actor_id);
if user_id == global_owner {
@@ -1567,12 +1573,30 @@ fn default_mcp_server_registry() -> HashMap<String, McpServerConfig> {
servers
}
fn policy_removed_default_ids(model_policy: &Value, key: &str) -> std::collections::HashSet<String> {
model_policy
.get(key)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(|value| value.trim().to_string()))
.filter(|value| !value.is_empty())
.collect()
})
.unwrap_or_default()
}
fn effective_skill_registry(model_policy: &Value) -> HashMap<String, SkillConfig> {
let mut skills = default_skill_registry();
let configured = policy_map::<SkillConfig>(model_policy, "skills");
for (id, config) in configured {
skills.insert(id, config);
}
// S2: omit built-in defaults the admin explicitly deleted (tombstones).
for id in policy_removed_default_ids(model_policy, "removedDefaultSkills") {
skills.remove(&id);
}
skills
}
@@ -1582,6 +1606,9 @@ fn effective_mcp_registry(model_policy: &Value) -> HashMap<String, McpServerConf
for (id, config) in configured {
servers.insert(id, config);
}
for id in policy_removed_default_ids(model_policy, "removedDefaultMcpServers") {
servers.remove(&id);
}
servers
}
@@ -1594,6 +1621,9 @@ fn effective_pi_extension_registry(model_policy: &Value) -> HashMap<String, PiEx
}
extensions.insert(id, config);
}
for id in policy_removed_default_ids(model_policy, "removedDefaultPiExtensions") {
extensions.remove(&id);
}
extensions
}
@@ -1693,6 +1723,11 @@ fn is_configured_admin_user(user_id: &str) -> bool {
.unwrap_or(false)
}
fn is_admin_user_for_display(user_id: &str, stored_role: &str) -> bool {
local_folder_source::is_local_access_policy_admin_actor(user_id, stored_role)
|| is_configured_admin_user(user_id)
}
fn load_policy_value(state: &AppState, actor_id: &str, workspace_id: Option<&str>) -> Value {
load_model_policy_and_quota(state, actor_id, workspace_id).0
}
@@ -2238,8 +2273,8 @@ fn filter_directory_grants_to_access_scopes(
/// Ensures admin auth. Reuses the existing admin check from
/// `local_folder_source::is_local_access_policy_admin_context`.
fn ensure_admin(context: &RequestContext) -> Result<(), WebError> {
if !local_folder_source::is_local_access_policy_admin_context(context) {
fn ensure_admin(state: &AppState, context: &RequestContext) -> Result<(), WebError> {
if !crate::routes::gateway::current_actor_is_local_admin(state, context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_access_forbidden",
@@ -2634,13 +2669,22 @@ fn merge_policy_with_existing(
// The admin settings UI edits this registry as the desired full
// state. Re-merging deleted rows back from the persisted policy
// makes "删除" a no-op, so presence of this section means replace.
//
// S2: also write tombstones for built-in defaults omitted from the
// payload, so effective_*_registry does not rehydrate ghosts.
if let Some(ref skills) = body.skills {
let mut merged = serde_json::Map::new();
for (k, v) in skills {
let val = serde_json::to_value(v).unwrap_or(json!({}));
merged.insert(k.clone(), val);
}
let removed_defaults: Vec<String> = default_skill_registry()
.keys()
.filter(|id| !merged.contains_key(id.as_str()))
.cloned()
.collect();
model_policy["skills"] = Value::Object(merged);
model_policy["removedDefaultSkills"] = json!(removed_defaults);
}
// ── Replace MCP servers map ─────────────────────────────────────
@@ -2653,7 +2697,13 @@ fn merge_policy_with_existing(
let val = serde_json::to_value(v).unwrap_or(json!({}));
merged.insert(k.clone(), val);
}
let removed_defaults: Vec<String> = default_mcp_server_registry()
.keys()
.filter(|id| !merged.contains_key(id.as_str()))
.cloned()
.collect();
model_policy["mcpServers"] = Value::Object(merged);
model_policy["removedDefaultMcpServers"] = json!(removed_defaults);
}
if let Some(ref extensions) = body.pi_extensions {
@@ -2662,7 +2712,13 @@ fn merge_policy_with_existing(
let val = serde_json::to_value(v).unwrap_or(json!({}));
merged.insert(k.clone(), val);
}
let removed_defaults: Vec<String> = default_pi_extension_registry()
.keys()
.filter(|id| !merged.contains_key(id.as_str()))
.cloned()
.collect();
model_policy["piExtensions"] = Value::Object(merged);
model_policy["removedDefaultPiExtensions"] = json!(removed_defaults);
}
let model_policy_json = serde_json::to_string(&model_policy).unwrap_or_else(|_| "{}".into());
@@ -3253,6 +3309,79 @@ mod tests {
assert_eq!(parsed["skills"]["keep-skill"]["enabled"], true);
assert!(parsed["mcpServers"].as_object().unwrap().is_empty());
assert!(parsed["piExtensions"].as_object().unwrap().is_empty());
// S2: omitting a built-in default from the replace payload tombs it.
let removed_skills = parsed["removedDefaultSkills"]
.as_array()
.expect("removedDefaultSkills");
assert!(
removed_skills.iter().any(|v| v.as_str() == Some("vpn")),
"vpn default skill should be tombstoned when omitted: {parsed}"
);
let removed_mcp = parsed["removedDefaultMcpServers"]
.as_array()
.expect("removedDefaultMcpServers");
assert!(
removed_mcp
.iter()
.any(|v| v.as_str() == Some("context7")),
"context7 default MCP should be tombstoned when omitted: {parsed}"
);
}
/// S2: deleted default skills/MCP must not reappear via effective registries.
#[test]
fn effective_registries_honor_removed_default_tombstones() {
let policy = json!({
"skills": {
"custom-only": {
"name": "Custom Only",
"enabled": true,
"description": "",
"source": "/tmp/custom/SKILL.md",
"riskLevel": "low",
"requiredScopes": []
}
},
"removedDefaultSkills": ["vpn", "chrome-bridge"],
"mcpServers": {
"custom-mcp": {
"name": "Custom MCP",
"enabled": true,
"url": "",
"transport": "stdio",
"command": "custom-mcp",
"networkPolicy": "deny-all",
"secretRefs": [],
"facadeOnly": true,
"sandbox": true,
"description": "",
"riskLevel": "medium",
"requiredScopes": []
}
},
"removedDefaultMcpServers": ["context7", "codegraph"]
});
let skills = effective_skill_registry(&policy);
assert!(skills.contains_key("custom-only"));
assert!(
!skills.contains_key("vpn"),
"tombstoned default skill must not rehydrate"
);
assert!(!skills.contains_key("chrome-bridge"));
// Untombstoned defaults still present.
assert!(skills.contains_key("context7") || skills.contains_key("searxng"));
let mcps = effective_mcp_registry(&policy);
assert!(mcps.contains_key("custom-mcp"));
assert!(!mcps.contains_key("context7"));
assert!(!mcps.contains_key("codegraph"));
// Admin projection must match effective (no ghost defaults).
let admin = project_admin_settings(&policy, &json!({}), None);
assert!(!admin.skills.contains_key("vpn"));
assert!(admin.skills.contains_key("custom-only"));
assert!(!admin.mcp_servers.contains_key("context7"));
assert!(admin.mcp_servers.contains_key("custom-mcp"));
}
// ─── Admin projection ────────────────────────────────────────────────
@@ -3479,4 +3608,27 @@ mod tests {
};
assert!(validate_user_policy_body(&body, &global).is_ok());
}
#[test]
fn admin_user_display_role_includes_access_policy_admins() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
let policy_root = std::env::temp_dir().join(format!(
"mnote-ai-settings-display-admin-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&policy_root);
std::fs::create_dir_all(&policy_root).expect("create policy root");
let policy_file = policy_root.join("access-policy.json");
std::fs::write(&policy_file, r#"{"admins":["liaibo"],"grants":[]}"#)
.expect("write access policy");
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
assert!(is_admin_user_for_display("liaibo", "user"));
assert!(!is_admin_user_for_display("shujuan", "user"));
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
let _ = std::fs::remove_dir_all(&policy_root);
}
}
+551 -21
View File
@@ -4,9 +4,10 @@ use crate::error::WebError;
use crate::provider_identity_sync::sync_provider_identities;
use crate::routes::local_folder_source::{
create_default_local_workspace_for_actor, ensure_local_workspace_read_access_with_state,
is_local_access_policy_admin_context, load_local_folder_file_tree_children_snapshot,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_scope_snapshot,
load_local_folder_page_tree_snapshot, load_local_trash_entries,
is_local_access_policy_admin_actor, is_local_access_policy_admin_context,
load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot,
load_local_folder_page_tree_scope_snapshot_with_reveal,
load_local_folder_page_tree_snapshot_with_reveal, load_local_trash_entries,
};
use crate::routes::snapshot_support::load_sidebar_dataset;
use crate::routes::web_shell::{
@@ -15,8 +16,8 @@ use crate::routes::web_shell::{
load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
render_document_title_controller_script, render_editor_island_adapter_script,
render_editor_runtime_preload_links, render_local_file_tree_html,
render_local_file_tree_html_scoped, render_local_sidebar_tree_html,
render_local_sidebar_tree_html_from_snapshot,
render_local_file_tree_html_scoped, render_local_file_tree_pending_shell_html,
render_local_sidebar_tree_html, render_local_sidebar_tree_html_from_snapshot,
};
use crate::transport::legacy_cloud_guard::execute_retired_mutation_by_name;
use crate::workspace_shell::{
@@ -188,7 +189,7 @@ pub async fn admin_access_policy_entry(
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
if !is_local_access_policy_admin_context(&context) {
if !current_actor_is_local_admin(&state, &context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_access_policy_admin_required",
@@ -240,7 +241,7 @@ pub async fn admin_ai_entry(
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
if !is_local_access_policy_admin_context(&context) {
if !current_actor_is_local_admin(&state, &context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_required",
@@ -267,7 +268,7 @@ pub async fn settings_entry(
ai_management_response(
&state,
&context,
is_local_access_policy_admin_context(&context),
current_actor_is_local_admin(&state, &context),
)
}
@@ -410,10 +411,15 @@ pub async fn root_entry(
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?;
// Reveal only the active page path; keep SSR PageTree shallow (Sidex-aligned).
let page_tree_snapshot = if let Some(scope) = file_tree_scope {
load_local_folder_page_tree_scope_snapshot(root_uri, scope)?
load_local_folder_page_tree_scope_snapshot_with_reveal(
root_uri,
scope,
requested_page_id.as_deref(),
)?
} else {
load_local_folder_page_tree_snapshot(root_uri)?
load_local_folder_page_tree_snapshot_with_reveal(root_uri, requested_page_id.as_deref())?
};
let workspace_id = page_tree_snapshot
.dataset
@@ -452,12 +458,19 @@ pub async fn root_entry(
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let file_tree_html = render_local_file_tree_html_scoped(
root_uri,
selected_active_page_id.as_deref(),
restore_focus_row_id,
file_tree_scope,
)?;
// Shell-first: when landing on PageTree (default), do not block home SSR on FileTree scan.
// treeView=filetree (or restore focus into file rows) still needs synchronous FileTree HTML.
let needs_sync_file_tree = requests_filetree_first || restore_focus_row_id.is_some();
let file_tree_html = if needs_sync_file_tree {
render_local_file_tree_html_scoped(
root_uri,
selected_active_page_id.as_deref(),
restore_focus_row_id,
file_tree_scope,
)?
} else {
render_local_file_tree_pending_shell_html()
};
(
workspace_id,
workspace_projection,
@@ -480,7 +493,8 @@ pub async fn root_entry(
WebError::internal("默认本地工作区初始化未返回 rootUri").with_context(&context)
})?
.to_string();
let snapshot = load_local_folder_page_tree_snapshot(&root_uri)?;
let snapshot =
load_local_folder_page_tree_snapshot_with_reveal(&root_uri, requested_page_id.as_deref())?;
let workspace_id = snapshot
.dataset
.get("workspace")
@@ -511,8 +525,12 @@ pub async fn root_entry(
&snapshot,
selected_active_page_id.as_deref(),
);
let file_tree_html =
render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref(), None)?;
// Local-first landing also prefers shell-first FileTree (hydrate after paint).
let file_tree_html = if requests_filetree_first {
render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref(), None)?
} else {
render_local_file_tree_pending_shell_html()
};
(
workspace_id,
workspace_projection,
@@ -593,7 +611,11 @@ pub async fn root_entry(
.active_page_title
.clone()
.unwrap_or_default();
let show_admin_access_policy = is_local_access_policy_admin_context(&context);
let breadcrumb_html = crate::workspace_shell::render_page_breadcrumb_html(
&workspace_projection,
Some(active_page_id.as_str()),
);
let show_admin_access_policy = current_actor_is_local_admin(&state, &context);
let navigation_notice_html = render_navigation_guard_notice(&query);
let navigation_html = if active_page_id.trim().is_empty() {
if active_source_kind.as_deref() == Some("local_folder") {
@@ -618,6 +640,10 @@ pub async fn root_entry(
} else {
None
};
let vault_nav_href = vault_nav_href_for_context(
active_source_kind.as_deref(),
active_root_uri.as_deref(),
);
let render_workspace_entry = || {
crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::home::HomePage
@@ -625,11 +651,13 @@ pub async fn root_entry(
workspace_name={workspace_name.clone()}
workspace_id={workspace_id.clone()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
breadcrumb_html={breadcrumb_html.clone()}
active_page_id={active_page_id.clone()}
active_page_title={active_page_title.clone()}
navigation_html={navigation_html.clone().unwrap_or_default()}
show_admin_access_policy={show_admin_access_policy}
enable_tree_live={active_source_kind.as_deref() == Some("local_folder")}
vault_nav_href={vault_nav_href.clone()}
/>
})
};
@@ -708,6 +736,7 @@ document.body.appendChild(s);
page_subtree_json={page_subtree_json}
show_admin_access_policy={show_admin_access_policy}
enable_tree_live={true}
vault_nav_href={vault_nav_href.clone()}
/>
});
let body_extra = format!(
@@ -1113,6 +1142,304 @@ fn query_escape(value: &str) -> String {
.collect()
}
/// SSR vault nav href with sourceKind+rootUri so first click keeps local_folder context.
pub(crate) fn vault_nav_href_for_context(source_kind: Option<&str>, root_uri: Option<&str>) -> String {
let root_uri = root_uri.map(str::trim).filter(|value| !value.is_empty());
let source_kind = source_kind
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| root_uri.map(|_| "local_folder"));
match (source_kind, root_uri) {
(Some(source_kind), Some(root_uri)) => format!(
"/vault?sourceKind={}&rootUri={}",
query_escape(source_kind),
query_escape(root_uri)
),
(Some(source_kind), None) => {
format!("/vault?sourceKind={}", query_escape(source_kind))
}
(None, Some(root_uri)) => format!(
"/vault?sourceKind=local_folder&rootUri={}",
query_escape(root_uri)
),
(None, None) => "/vault".to_string(),
}
}
/// GET /files → permanent product surface is /vault (left-rail password vault).
pub async fn files_redirect_to_vault(
Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> {
let mut location = String::from("/vault");
let mut parts: Vec<String> = Vec::new();
if let Some(root_uri) = query
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
parts.push(format!("rootUri={}", query_escape(root_uri)));
}
if let Some(source_kind) = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
parts.push(format!("sourceKind={}", query_escape(source_kind)));
} else if !parts.is_empty() {
parts.push("sourceKind=local_folder".to_string());
}
if let Some(workspace_id) = query
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
parts.push(format!("workspaceId={}", query_escape(workspace_id)));
}
if !parts.is_empty() {
location.push('?');
location.push_str(&parts.join("&"));
}
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, location)
.body(Body::empty())
.map_err(|error| WebError::internal(format!("/files 跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
Ok(response)
}
/// GET /vault — dedicated password vault workbench (local_folder only for P0).
pub async fn vault_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> {
if !crate::routes::vault::vault_feature_enabled() {
return Err(crate::routes::vault::vault_disabled_error().with_context(&context));
}
if !has_real_auth_context(&state, &context) {
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/auth")
.body(Body::empty())
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
let source_kind = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let root_uri = query
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
// Product path: local_folder workspace with rootUri.
if source_kind == Some("local_folder") || root_uri.is_some() {
let root_uri = root_uri.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
let workspace_root =
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
// Best-effort ensure vault dirs so first paint has a writable layout.
let _ = crate::routes::vault_store::ensure_vault_directories(&workspace_root);
let workspace_id =
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default();
let file_tree_html = render_local_file_tree_pending_shell_html();
let mut workspace_dataset = json!({
"active_workspace_id": workspace_id,
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
"documents": [],
});
attach_sidebar_shortcuts_to_dataset(
&state,
&context,
&workspace_id,
&mut workspace_dataset,
);
let workspace_projection =
build_workspace_shell_projection(&workspace_dataset, &workspace_id, None, "我的空间");
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
Some(file_tree_html.as_str()),
None,
None,
);
let bootstrap = crate::routes::vault::bootstrap_list_json(
&workspace_root,
crate::routes::vault_store::VaultItemStatus::Active,
)
.unwrap_or_else(|_| {
json!({
"schema": "mnote.vault.list.v1",
"status": "active",
"revision": 0,
"updatedAt": "",
"items": [],
})
});
let vault_workbench_html =
render_vault_workbench_html(&workspace_id, root_uri, &bootstrap);
let vault_nav_href = vault_nav_href_for_context(Some("local_folder"), Some(root_uri));
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::layout::PageLayout
current_nav="vault"
sidebar_tree_html={sidebar_tree_html.clone()}
workspace_name={"我的空间".to_string()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
topbar_title={"密码箱".to_string()}
enable_tree_live={false}
vault_nav_href={vault_nav_href.clone()}
>
<div inner_html={vault_workbench_html}></div>
</crate::ssr::pages::layout::PageLayout>
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>密码箱</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-source-kind="local_folder" data-mnote-root-uri="{}" data-mnote-page="vault">
{}
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
escape_html(root_uri),
content,
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
context.apply_response_headers(response.headers_mut());
return Ok(response);
}
// Bare /vault without rootUri: friendly local-first shell (no cloud workspace bootstrap).
// Avoid resolve_root_workspace_id → retired Convex ensureDefaultWorkspace → 503.
let default_workspace_name = default_workspace_name_for_context(&state, &context);
let workspace_id = normalize_optional_id(query.workspace_id.as_deref())
.or_else(|| normalize_optional_id(context.workspace.workspace_id.as_deref()))
.map(ToOwned::to_owned)
.unwrap_or_else(|| "local-folder".to_string());
let mut workspace_dataset = json!({
"active_workspace_id": workspace_id,
"workspaces": [{ "id": workspace_id, "name": default_workspace_name }],
"documents": [],
});
attach_sidebar_shortcuts_to_dataset(
&state,
&context,
&workspace_id,
&mut workspace_dataset,
);
let workspace_projection = build_workspace_shell_projection(
&workspace_dataset,
&workspace_id,
None,
&default_workspace_name,
);
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(""),
Some(""),
None,
None,
);
let empty_bootstrap = json!({
"schema": "mnote.vault.list.v1",
"status": "active",
"revision": 0,
"updatedAt": "",
"items": [],
"needsLocalFolder": true,
});
let vault_workbench_html =
render_vault_workbench_html(&workspace_id, "", &empty_bootstrap);
let workspace_name = workspace_projection.workspace_name.clone();
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::layout::PageLayout
current_nav="vault"
sidebar_tree_html={String::new()}
workspace_name={workspace_name.clone()}
workspace_sidebar_html={workspace_sidebar_html.clone()}
topbar_title={"密码箱".to_string()}
enable_tree_live={false}
>
<div inner_html={vault_workbench_html}></div>
</crate::ssr::pages::layout::PageLayout>
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>密码箱</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-page="vault" data-mnote-vault-needs-folder="1">
{}
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
content,
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
context.apply_response_headers(response.headers_mut());
Ok(response)
}
fn render_vault_workbench_html(workspace_id: &str, root_uri: &str, bootstrap: &Value) -> String {
let bootstrap_raw = bootstrap.to_string();
let bootstrap_json = escape_script_json(&bootstrap_raw);
let runtime_src = crate::routes::web_shell::mnote_browser_runtime_src("vault-workbench-runtime.js");
format!(
r#"<section class="mnote-vault-workbench" data-testid="mnote-vault-workbench" data-workspace-id="{workspace_id}" data-root-uri="{root_uri_esc}" data-status="active">
<header class="mnote-vault-header">
<div class="mnote-vault-header-main">
<h1>密码箱</h1>
<p class="mnote-vault-status" data-vault-status role="status" aria-live="polite"></p>
</div>
<div class="mnote-vault-header-actions">
<button type="button" data-vault-create data-testid="vault-create">新建</button>
<button type="button" data-vault-cipher-book data-testid="vault-cipher-book">密文簿</button>
<input type="search" data-vault-search data-testid="vault-search" placeholder="搜索标题、用户名、标签、分组…" aria-label="搜索密码条目" />
<div class="mnote-vault-tabs" role="tablist">
<button type="button" role="tab" data-vault-tab="active" class="is-active" aria-selected="true">在用</button>
<button type="button" role="tab" data-vault-tab="deleted" aria-selected="false">已删除</button>
</div>
</div>
</header>
<div class="mnote-vault-body">
<aside class="mnote-vault-list" data-vault-list data-testid="vault-list" aria-label="密码条目列表"></aside>
<main class="mnote-vault-detail" data-vault-detail data-testid="vault-detail" aria-label="条目详情"></main>
</div>
<script type="application/json" id="__MNOTE_VAULT_BOOTSTRAP__">{bootstrap_json}</script>
<script src="{runtime_src}" defer></script>
</section>"#,
workspace_id = escape_html(workspace_id),
root_uri_esc = escape_html(root_uri),
bootstrap_json = bootstrap_json,
runtime_src = runtime_src,
)
}
pub async fn trash_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -1147,7 +1474,7 @@ pub async fn trash_entry(
let workspace_id =
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default();
let file_tree_html = render_local_file_tree_html(root_uri, None, None).unwrap_or_default();
let file_tree_html = render_local_file_tree_pending_shell_html();
let mut workspace_dataset = json!({
"active_workspace_id": workspace_id,
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
@@ -2155,6 +2482,16 @@ pub(crate) fn current_actor_type(state: &AppState, context: &RequestContext) ->
context.auth.actor_type.trim().to_string()
}
pub(crate) fn current_actor_is_local_admin(state: &AppState, context: &RequestContext) -> bool {
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
let token_hash = session_token_hash(&raw_token);
if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) {
return is_local_access_policy_admin_actor(&resolved.user.id, &resolved.user.role);
}
}
is_local_access_policy_admin_context(context)
}
fn normalize_optional_id(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
@@ -3232,6 +3569,104 @@ mod tests {
assert!(!html.contains("开发用户 的空间"));
}
#[tokio::test]
async fn control_plane_user_session_overrides_stale_admin_actor_cookies_for_settings() {
use control_plane::{session_token_hash, CreateSessionInput};
let app_state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
});
app_state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("normal-user-session".into()),
email: Some("normal@example.com".into()),
username: "normal-user-session".into(),
display_name: "普通用户".into(),
role: Some("user".into()),
password_hash: None,
})
.expect("upsert control-plane user");
app_state
.control_plane()
.create_session(CreateSessionInput {
id: None,
user_id: "normal-user-session".into(),
token_hash: session_token_hash("normal-session-token"),
user_agent: None,
ip_hash: None,
expires_at: None,
})
.expect("create control-plane session");
let app = build_app(app_state);
let stale_admin_cookie =
"mnote_session=normal-session-token; mnote_actor_id=stale-admin; mnote_actor_type=admin";
let settings_response = app
.clone()
.oneshot(
Request::builder()
.uri("/settings")
.header("cookie", stale_admin_cookie)
.body(Body::empty())
.expect("request"),
)
.await
.expect("settings response");
assert_eq!(settings_response.status(), StatusCode::OK);
let body = to_bytes(settings_response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("data-ai-admin-role=\"user\""));
assert!(!html.contains("data-ai-admin-role=\"admin\""));
assert!(!html.contains("href=\"#ai-admin-users\""));
let admin_page_response = app
.clone()
.oneshot(
Request::builder()
.uri("/admin/ai")
.header("cookie", stale_admin_cookie)
.body(Body::empty())
.expect("request"),
)
.await
.expect("admin page response");
assert_eq!(admin_page_response.status(), StatusCode::FORBIDDEN);
let admin_api_response = app
.oneshot(
Request::builder()
.uri("/api/ai-admin/users")
.header("cookie", stale_admin_cookie)
.body(Body::empty())
.expect("request"),
)
.await
.expect("admin api response");
assert_eq!(admin_api_response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn admin_access_policy_entry_requires_admin_actor() {
let user_response = app_with_config("http://127.0.0.1:3100".into(), false)
@@ -3780,10 +4215,105 @@ mod tests {
assert!(!html.contains("当前还没有可显示的本地工作区"));
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
assert!(html.contains(r#""transport":"tree-live-ws""#));
// Vault left-rail should carry local_folder context in SSR href (no full refresh to bare /vault).
// Leptos HTML-escapes `&` as `&amp;` in attribute values — match fragments, not full attribute.
let vault_href_ok = html.contains(r#"data-testid="mnote-nav-vault""#)
&& html.contains("/vault?sourceKind=local_folder")
&& html.contains("rootUri=");
assert!(
vault_href_ok,
"vault nav SSR href must include sourceKind+rootUri: {}",
html.lines()
.find(|line| line.contains("mnote-nav-vault"))
.unwrap_or("(no vault nav line)")
);
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
async fn bare_vault_entry_returns_friendly_200_without_convex_bootstrap() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/vault")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(
response.status(),
StatusCode::OK,
"bare /vault must not 503 via retired cloud workspace bootstrap"
);
assert_ne!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("convex_retired")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(
html.contains(r#"data-mnote-page="vault""#)
|| html.contains(r#"data-testid="mnote-vault-workbench""#)
|| html.contains("密码箱"),
"bare vault should render password vault shell: {}",
&html[..html.len().min(500)]
);
assert!(
html.contains(r#"data-mnote-vault-needs-folder="1""#)
|| html.contains("needsLocalFolder"),
"bare vault should signal needs local folder"
);
}
#[tokio::test]
async fn root_entry_local_folder_accepts_gzip_encoding() {
let root = temp_root("mnote-root-local-folder-gzip");
std::fs::write(root.join("README.md"), "# Gzip\n").expect("write md");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_real",
&root_uri,
)
.expect("init");
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri(format!("/?sourceKind=local_folder&rootUri={root_uri}"))
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.header(header::ACCEPT_ENCODING, "gzip, br")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
let encoding = response
.headers()
.get(header::CONTENT_ENCODING)
.and_then(|value| value.to_str().ok())
.unwrap_or("");
// CompressionLayer may skip tiny bodies; accept either gzip/br or uncompressed OK.
assert!(
encoding.is_empty() || encoding.contains("gzip") || encoding.contains("br"),
"unexpected content-encoding: {encoding}"
);
}
#[tokio::test]
async fn auth_entry_returns_gateway_fallback_shell_when_compat_disabled() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
+18 -2
View File
@@ -4,7 +4,9 @@ use crate::error::WebError;
use crate::routes::local_folder_source::{
ensure_local_workspace_read_access_with_state, load_local_folder_file_tree_children_snapshot,
load_local_folder_file_tree_snapshot, load_local_folder_file_tree_snapshot_with_reveal,
load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
load_local_folder_page_tree_scope_snapshot,
load_local_folder_page_tree_scope_snapshot_with_reveal,
load_local_folder_page_tree_snapshot, load_local_folder_page_tree_snapshot_with_reveal,
};
use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{
@@ -121,7 +123,21 @@ async fn project_projection(
load_local_folder_file_tree_snapshot(&root_uri)
}
} else if let Some(parent_relative_path) = parent_relative_path.as_deref() {
load_local_folder_page_tree_scope_snapshot(&root_uri, parent_relative_path)
// Children fetch stays shallow; only reveal when root_node_id is present.
if root_node_id.is_some() {
load_local_folder_page_tree_scope_snapshot_with_reveal(
&root_uri,
parent_relative_path,
root_node_id.as_deref(),
)
} else {
load_local_folder_page_tree_scope_snapshot(&root_uri, parent_relative_path)
}
} else if root_node_id.is_some() {
load_local_folder_page_tree_snapshot_with_reveal(
&root_uri,
root_node_id.as_deref(),
)
} else {
load_local_folder_page_tree_snapshot(&root_uri)
}
File diff suppressed because it is too large Load Diff
@@ -197,7 +197,21 @@ fn parse_markdown_attachment_link_with_paths(
{
return None;
}
let target_path = std::path::Path::new(target);
if page_reference_from_target(target, label).is_some()
&& !attachment_paths.contains(target)
&& !is_local_markdown_asset_href(target)
{
return None;
}
let target_without_fragment = target
.split_once('#')
.map(|(path, _)| path)
.unwrap_or(target);
let target_path_part = target_without_fragment
.split_once('?')
.map(|(path, _)| path)
.unwrap_or(target_without_fragment);
let target_path = std::path::Path::new(target_path_part);
let extension = target_path.extension().and_then(|value| value.to_str())?;
if (extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown"))
&& !attachment_paths.contains(target)
@@ -320,7 +334,40 @@ fn extract_html_attr<'a>(fragment: &'a str, name: &str) -> Option<(&'a str, usiz
fn should_collect_attachment_href(raw_href: &str) -> bool {
let trimmed = raw_href.trim();
!trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("mailto:")
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("mailto:") {
return false;
}
page_reference_from_target(trimmed, "").is_none() || is_local_markdown_asset_href(trimmed)
}
fn is_local_markdown_asset_href(raw_href: &str) -> bool {
let trimmed = raw_href
.trim()
.strip_prefix('<')
.and_then(|value| value.strip_suffix('>'))
.unwrap_or(raw_href.trim());
let without_fragment = trimmed
.split_once('#')
.map(|(path, _)| path)
.unwrap_or(trimmed);
let without_query = without_fragment
.split_once('?')
.map(|(path, _)| path)
.unwrap_or(without_fragment);
let normalized = without_query
.strip_prefix("./")
.unwrap_or(without_query)
.replace('\\', "/");
let extension = Path::new(&normalized)
.extension()
.and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase());
if !matches!(extension.as_deref(), Some("md" | "markdown")) {
return false;
}
normalized
.split('/')
.any(|segment| segment == ".assets" || segment.ends_with(".assets"))
}
fn build_attachment_ref(
@@ -686,6 +733,9 @@ fn append_ast_paragraph<'a>(
blocks.push(MarkdownBlock::PageReference { title, source_path });
return;
}
if append_paragraph_attachment_sequence(node, blocks, attachment_paths) {
return;
}
if let Some((name, source_path, remaining)) =
paragraph_leading_attachment_media(node, attachment_paths)
{
@@ -716,6 +766,9 @@ fn append_ast_list_item<'a>(
if children.next().is_none()
&& matches!(paragraph.data.borrow().value, NodeValue::Paragraph)
{
if append_paragraph_attachment_sequence(paragraph, blocks, attachment_paths) {
return;
}
if let Some((title, source_path)) = paragraph_page_reference(paragraph) {
blocks.push(MarkdownBlock::PageReference { title, source_path });
return;
@@ -724,13 +777,49 @@ fn append_ast_list_item<'a>(
}
}
let mut content = Vec::new();
let mut emitted_child_blocks = false;
for child in node.children() {
match child.data.borrow().value.clone() {
NodeValue::Paragraph => content.extend(collect_inline_children(child)),
_ => append_ast_block(child, blocks, attachment_paths),
NodeValue::Paragraph => {
let mut paragraph_blocks = Vec::new();
if append_paragraph_attachment_sequence(
child,
&mut paragraph_blocks,
attachment_paths,
) {
if !content.is_empty() {
push_list_item_content_block(blocks, ordered, is_task, checked, content);
content = Vec::new();
}
blocks.extend(paragraph_blocks);
emitted_child_blocks = true;
} else {
content.extend(collect_inline_children(child));
}
}
_ => {
if !content.is_empty() {
push_list_item_content_block(blocks, ordered, is_task, checked, content);
content = Vec::new();
}
append_ast_block(child, blocks, attachment_paths);
emitted_child_blocks = true;
}
}
}
if !content.is_empty() || !emitted_child_blocks {
push_list_item_content_block(blocks, ordered, is_task, checked, content);
}
}
fn push_list_item_content_block(
blocks: &mut Vec<MarkdownBlock>,
ordered: bool,
is_task: bool,
checked: bool,
content: Vec<MarkdownInline>,
) {
if is_task {
blocks.push(MarkdownBlock::Todo { checked, content });
} else if ordered {
@@ -876,6 +965,39 @@ fn paragraph_attachment_media<'a>(
link_attachment_media(first, attachment_paths)
}
fn append_paragraph_attachment_sequence<'a>(
node: &'a AstNode<'a>,
blocks: &mut Vec<MarkdownBlock>,
attachment_paths: &BTreeSet<String>,
) -> bool {
let mut parsed = Vec::<MarkdownBlock>::new();
for child in node.children() {
match &child.data.borrow().value {
NodeValue::SoftBreak | NodeValue::LineBreak => continue,
NodeValue::Text(text) if text.as_ref().trim().is_empty() => continue,
NodeValue::Image(link) => parsed.push(MarkdownBlock::Image {
alt: collect_plain_text(child).trim().to_string(),
source_path: link.url.clone(),
}),
NodeValue::Link(_) => {
if let Some((name, source_path)) = link_attachment_media(child, attachment_paths) {
parsed.push(MarkdownBlock::Media { name, source_path });
} else if let Some((title, source_path)) = link_page_reference(child) {
parsed.push(MarkdownBlock::PageReference { title, source_path });
} else {
return false;
}
}
_ => return false,
}
}
if parsed.is_empty() {
return false;
}
blocks.extend(parsed);
true
}
fn paragraph_page_reference<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
let mut children = node.children();
let first = children.next()?;
@@ -982,6 +1104,9 @@ fn link_page_reference<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
fn page_reference_from_target(target: &str, label: &str) -> Option<(String, String)> {
let target = normalized_markdown_link_target(target)?;
if let Some(source_path) = local_markdown_relative_path_from_documents_target(target) {
return Some(page_reference_title_and_path(&source_path, label));
}
if target.is_empty()
|| target.starts_with("http://")
|| target.starts_with("https://")
@@ -1004,20 +1129,59 @@ fn page_reference_from_target(target: &str, label: &str) -> Option<(String, Stri
if !matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") {
return None;
}
let fallback_title = std::path::Path::new(path_part)
Some(page_reference_title_and_path(path_part, label))
}
fn page_reference_title_and_path(source_path: &str, label: &str) -> (String, String) {
let fallback_title = std::path::Path::new(source_path)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("页面")
.trim();
let title = label.trim();
Some((
(
if title.is_empty() {
fallback_title.to_string()
} else {
title.to_string()
},
path_part.to_string(),
))
source_path.to_string(),
)
}
fn local_markdown_relative_path_from_documents_target(target: &str) -> Option<String> {
let normalized = target.trim();
let path_and_query = if normalized.starts_with("/documents/") {
normalized
} else if let Some((_, rest)) = normalized.split_once("://") {
let slash = rest.find('/')?;
&rest[slash..]
} else {
return None;
};
let path_part = path_and_query
.split_once('#')
.map(|(path, _)| path)
.unwrap_or(path_and_query)
.split_once('?')
.map(|(path, _)| path)
.unwrap_or(path_and_query);
let segment = path_part.strip_prefix("/documents/")?;
let decoded_segment = percent_decode_lossy(segment);
let local_id = decoded_segment.strip_prefix("local-md:")?;
let relative_path = percent_decode_lossy(&local_id.replace('~', "%"))
.trim_start_matches('/')
.to_string();
if relative_path.is_empty() {
return None;
}
let extension = std::path::Path::new(&relative_path)
.extension()
.and_then(|value| value.to_str())?;
if !matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") {
return None;
}
Some(relative_path)
}
fn normalized_markdown_link_target(target: &str) -> Option<&str> {
@@ -1363,6 +1527,28 @@ mod tests {
assert_eq!(first["props"]["alt"].as_str(), Some("示例图片"));
}
#[test]
fn markdown_consecutive_attachment_links_parse_as_blocks() {
let blocks = markdown_to_blocks(
"[身份证](.assets/身份证_李爱波.pdf)\n[本科证书](.assets/本科证书证明.pdf)\n![照片](.assets/photo.png)\n",
);
let items = blocks.as_array().expect("blocks array");
assert_eq!(items.len(), 3);
assert_eq!(items[0]["type"].as_str(), Some("media"));
assert_eq!(
items[0]["props"]["sourcePath"].as_str(),
Some(".assets/身份证_李爱波.pdf")
);
assert_eq!(items[1]["type"].as_str(), Some("media"));
assert_eq!(
items[1]["props"]["sourcePath"].as_str(),
Some(".assets/本科证书证明.pdf")
);
assert_eq!(items[2]["type"].as_str(), Some("image"));
assert_eq!(items[2]["props"]["src"].as_str(), Some(".assets/photo.png"));
}
#[test]
fn markdown_mindmap_link_parses_generated_mindmap_json_as_mindmap_block() {
let blocks = markdown_to_blocks("[思维导图](mindmap-123456.json)\n");
@@ -1411,6 +1597,46 @@ mod tests {
assert_eq!(first["props"]["sourcePath"].as_str(), Some("知识/知识.md"));
}
#[test]
fn markdown_runtime_local_documents_url_parses_as_page_reference_block() {
let blocks = markdown_to_blocks(
"[爱斯特完结项目](/documents/local-md:liaibo~E7~9A~84~E4~B8~AA~E4~BA~BA~E7~A9~BA~E9~97~B4~2F~E9~A1~B9~E7~9B~AE~2F~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE.md?sourceKind=local_folder&rootUri=file%3A%2F%2F%2Fmnt%2FData1T%2FMnote_data%2Fusers%2Fliaibo%2Fworkspaces%2Fmy-space&treeView=filetree)\n",
);
let first = blocks
.as_array()
.and_then(|items| items.first())
.expect("first block");
assert_eq!(first["type"].as_str(), Some("page_reference"));
assert_eq!(first["props"]["title"].as_str(), Some("爱斯特完结项目"));
assert_eq!(
first["props"]["sourcePath"].as_str(),
Some("liaibo的个人空间/项目/完结项目/爱斯特完结项目/爱斯特完结项目.md")
);
}
#[test]
fn markdown_runtime_local_documents_url_is_not_attachment_ref() {
let root = std::env::temp_dir().join(format!(
"mnote-page-ref-attachment-filter-{}",
std::process::id()
));
let owner_dir = root.join("liaibo的个人空间/项目/完结项目");
std::fs::create_dir_all(&owner_dir).expect("create owner dir");
let owner = owner_dir.join("完结项目.md");
let refs = parse_markdown_attachment_refs(
"[爱斯特完结项目](/documents/local-md:liaibo~E7~9A~84~E4~B8~AA~E4~BA~BA~E7~A9~BA~E9~97~B4~2F~E9~A1~B9~E7~9B~AE~2F~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE.md?sourceKind=local_folder&rootUri=file%3A%2F%2F%2Fmnt%2FData1T%2FMnote_data%2Fusers%2Fliaibo%2Fworkspaces%2Fmy-space&treeView=filetree)\n",
&owner.display().to_string(),
&format!("file://{}", root.display()),
);
assert!(
refs.is_empty(),
"内部页面链接不能进入 attachmentRefs: {refs:?}"
);
}
#[test]
fn markdown_attachment_refs_parse_standard_href_variants() {
let root = std::env::temp_dir().join(format!(
+68
View File
@@ -42,6 +42,9 @@ mod tree;
mod tree_view_state;
mod ui_debug;
pub(crate) mod ui_preferences;
mod vault;
mod vault_path;
mod vault_store;
pub(crate) mod web_shell;
mod ws;
@@ -75,6 +78,8 @@ pub fn build_router(state: AppState) -> Router {
.route("/health", get(health::health))
.route("/", get(gateway::root_entry))
.route("/trash", get(gateway::trash_entry))
.route("/vault", get(gateway::vault_entry))
.route("/files", get(gateway::files_redirect_to_vault))
.route("/favicon.ico", get(gateway::favicon))
.route("/settings", get(gateway::settings_entry))
.route(
@@ -370,6 +375,64 @@ pub fn build_router(state: AppState) -> Router {
"/api/mnote-browser-runtime/document-editor-adapter-runtime.js",
get(web_shell::document_editor_adapter_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/vault-workbench-runtime.js",
get(web_shell::vault_workbench_runtime_asset),
)
.route("/api/vault/ensure", post(vault::ensure))
.route("/api/vault/reindex", post(vault::reindex))
.route("/api/vault/list", get(vault::list))
.route("/api/vault/items", post(vault::create_item))
.route(
"/api/vault/items/{id}",
get(vault::get_item)
.patch(vault::update_item)
.delete(vault::delete_item),
)
.route(
"/api/vault/items/{id}/restore",
post(vault::restore_item),
)
.route("/api/vault/items/{id}/purge", post(vault::purge_item))
.route(
"/api/vault/items/{id}/reveal",
post(vault::reveal_item),
)
.route(
"/api/vault/items/{id}/resolve",
post(vault::resolve_item),
)
.route(
"/api/vault/items/{id}/share-to-ai",
post(vault::share_to_ai),
)
.route(
"/api/vault/items/{id}/unshare-from-ai",
post(vault::unshare_from_ai),
)
.route("/api/vault/ai/list", get(vault::list_ai))
.route("/api/vault/ai/items/{id}", get(vault::get_ai_item))
.route(
"/api/vault/ai/items/{id}/resolve",
post(vault::resolve_ai_item),
)
.route(
"/api/vault/ai/items/{id}/login",
post(vault::login_ai_item),
)
.route(
"/api/vault/ai/items/{id}/session",
post(vault::put_ai_session),
)
.route("/api/vault/cipher-book", get(vault::list_cipher_book))
.route(
"/api/vault/cipher-book/{key}",
put(vault::put_cipher_key).delete(vault::delete_cipher_key),
)
.route(
"/api/vault/cipher-book/{key}/reveal",
post(vault::reveal_cipher_key),
)
.route("/api/search/documents", post(search::documents))
.route(
"/api/search/local-index/refresh",
@@ -859,6 +922,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/documents/buffer-state/dirty",
post(documents::mark_buffer_dirty),
)
.route(
"/api/documents/buffer-state/dirty:0",
post(documents::mark_buffer_dirty),
)
.route("/api/documents/purge", post(documents::purge))
.route("/api/documents/empty-trash", post(documents::empty_trash))
.route("/api/documents/title", post(documents::title))
@@ -1624,6 +1691,7 @@ mod tests {
"/api/mnote-browser-runtime/document-slash-position-runtime.js",
"/api/mnote-browser-runtime/document-tiptap-conversion-runtime.js",
"/api/mnote-browser-runtime/document-editor-adapter-runtime.js",
"/api/mnote-browser-runtime/vault-workbench-runtime.js",
] {
let response = app(false)
.oneshot(
@@ -0,0 +1,43 @@
//! Pi Page AI 常量与 schema 名(产品口径,非 spike)。
pub(super) const PI_LAB_VERSION: &str = "1.0.0";
pub(super) const PI_LAB_PROVIDER: &str = "pi";
pub(super) const PI_LAB_SCHEMA_STATUS: &str = "mnote.page_ai_pi.status.v1";
pub(super) const PI_LAB_SCHEMA_RECEIPT: &str = "mnote.page_ai_pi.tool_receipt.v1";
pub(super) const PI_LAB_SCHEMA_EVENT: &str = "mnote.page_ai_pi.event.v1";
pub(super) const PI_LAB_SCHEMA_STATE: &str = "mnote.page_ai_pi.state.v1";
pub(super) const PI_LAB_SCHEMA_COMPACT: &str = "mnote.page_ai_pi.compact.v1";
pub(super) const PI_LAB_SCHEMA_QUEUE_CONFIG: &str = "mnote.page_ai_pi.queue_config.v1";
pub(super) const PI_LAB_SCHEMA_RPC_COMMAND: &str = "mnote.page_ai_pi.rpc_command.v1";
pub(super) const PI_LAB_SCHEMA_DIAGNOSTICS: &str = "mnote.page_ai_pi.diagnostics.v1";
pub(super) const PI_LAB_DEFAULT_MODEL_PROVIDER: &str = "omniroute";
pub(super) const PI_LAB_SCHEMA_SESSION_TREE: &str = "mnote.page_ai_pi.session_tree.v1";
pub(super) const PI_LAB_SCHEMA_FORK: &str = "mnote.page_ai_pi.fork.v1";
pub(super) const PI_LAB_SCHEMA_ARTIFACT_DIFF: &str = "mnote.page_ai_pi.artifact_diff.v1";
pub(super) const PI_LAB_DEFAULT_MODEL_ID: &str = "gpt-5.4-mini";
pub(super) const PI_LAB_DEFAULT_OMNIROUTE_BASE_URL: &str = "http://127.0.0.1:20128/v1";
pub(super) const HEADER_PI_LAB_BRIDGE_TOKEN: &str = "x-mnote-pi-lab-bridge-token";
pub(super) const PI_LAB_SESSION_TTL_MS: u128 = 30 * 60 * 1000;
pub(super) const PI_LAB_MAX_SESSIONS: usize = 16;
pub(super) const PI_LAB_RATE_WINDOW_MS: u128 = 10_000;
pub(super) const PI_LAB_MAX_STARTS_PER_WINDOW: usize = 4;
pub(super) const PI_LAB_MAX_SENDS_PER_WINDOW: usize = 12;
pub(super) const PI_LAB_MAX_TOOLS_PER_WINDOW: usize = 40;
pub(super) const PI_LAB_RUNTIME_IMPL_RUST: &str = "pi-rust";
pub(super) const PI_LAB_MANAGED_BUILTIN_TOOLS: [&str; 8] = [
"read",
"write",
"edit",
"bash",
"grep",
"find",
"ls",
"hashline_edit",
];
pub(super) const PI_LAB_MCP_CACHE_FILE: &str = "mcp-cache.json";
pub(super) const PI_LAB_MCP_BRIDGE_TIMEOUT_MS: u64 = 45_000;
pub(super) const PI_LAB_MCP_BRIDGE_MAX_OUTPUT_BYTES: usize = 2 * 1024 * 1024;
pub(super) 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";
@@ -0,0 +1,12 @@
//! Pi Rust Page AI route module.
//!
//! - [`constants`]:版本 / schema / profile 常量
//! - [`runtime`]HTTP handlers、RPC、session 热缓存与桥接
//!
//! 会话元数据权威源:Turso/libSQL control-plane。
//! 进程内 `HashMap` 仅为热缓存;重启后由 `hydrate_session_from_control_plane` 恢复元数据。
mod constants;
mod runtime;
pub use runtime::*;
@@ -1,7 +1,8 @@
//! Pi-first Page AI Lab — MNote 托管的后端垂直切片
//! Pi Rust Page AI — MNote 托管的生产 Page AI 后端。
//!
//! 该模块默认启用 MNote 托管的 Pi Rust Page AI 后端OpenHub 与 Pi TS 已退役到 recycle 边界。
//! 默认启用`MNOTE_PAGE_AI_PI_LAB`OpenHub 与 Pi TS 已退役到 recycle 边界。
//! Pi 进程通过 RPC subprocess 托管,MNote bridge tools 在 Rust 后端按 allowed roots 执行权限校验。
//! 会话元数据 / run / tool event 持久化到 Turso/libSQL control-plane;进程内表仅作热缓存。
use crate::app::AppState;
use crate::context::RequestContext;
@@ -35,52 +36,13 @@ use tokio::process::{Child, ChildStdin, Command};
use tokio::sync::{broadcast, oneshot, Mutex as AsyncMutex};
use tokio_stream::wrappers::BroadcastStream;
const PI_LAB_VERSION: &str = "0.1.0-pi-lab-spike";
const PI_LAB_PROVIDER: &str = "pi";
const PI_LAB_SCHEMA_STATUS: &str = "mnote.page_ai_pi.status.v1";
const PI_LAB_SCHEMA_RECEIPT: &str = "mnote.page_ai_pi.tool_receipt.v1";
const PI_LAB_SCHEMA_EVENT: &str = "mnote.page_ai_pi.event.v1";
const PI_LAB_SCHEMA_STATE: &str = "mnote.page_ai_pi.state.v1";
const PI_LAB_SCHEMA_COMPACT: &str = "mnote.page_ai_pi.compact.v1";
const PI_LAB_SCHEMA_QUEUE_CONFIG: &str = "mnote.page_ai_pi.queue_config.v1";
const PI_LAB_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";
const PI_LAB_SCHEMA_ARTIFACT_DIFF: &str = "mnote.page_ai_pi.artifact_diff.v1";
const PI_LAB_DEFAULT_MODEL_ID: &str = "gpt-5.4-mini";
const PI_LAB_DEFAULT_OMNIROUTE_BASE_URL: &str = "http://127.0.0.1:20128/v1";
const HEADER_PI_LAB_BRIDGE_TOKEN: &str = "x-mnote-pi-lab-bridge-token";
const PI_LAB_SESSION_TTL_MS: u128 = 30 * 60 * 1000;
const PI_LAB_MAX_SESSIONS: usize = 16;
const PI_LAB_RATE_WINDOW_MS: u128 = 10_000;
const PI_LAB_MAX_STARTS_PER_WINDOW: usize = 4;
const PI_LAB_MAX_SENDS_PER_WINDOW: usize = 12;
const PI_LAB_MAX_TOOLS_PER_WINDOW: usize = 40;
const PI_LAB_RUNTIME_IMPL_RUST: &str = "pi-rust";
const PI_LAB_MANAGED_BUILTIN_TOOLS: [&str; 8] = [
"read",
"write",
"edit",
"bash",
"grep",
"find",
"ls",
"hashline_edit",
];
const PI_LAB_MCP_CACHE_FILE: &str = "mcp-cache.json";
const PI_LAB_MCP_BRIDGE_TIMEOUT_MS: u64 = 45_000;
const PI_LAB_MCP_BRIDGE_MAX_OUTPUT_BYTES: usize = 2 * 1024 * 1024;
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";
use super::constants::*;
static PI_LAB_SESSIONS: LazyLock<StdMutex<HashMap<String, PiLabSession>>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
static PI_LAB_PROCESSES: LazyLock<StdMutex<HashMap<String, PiLabProcessHandle>>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
/// 热缓存;权威查询走 control-plane `list_ai_tool_events` / `list_ai_file_patches`。
static PI_LAB_RECEIPT_STORE: LazyLock<StdMutex<Vec<PiLabToolReceipt>>> =
LazyLock::new(|| StdMutex::new(Vec::new()));
static PI_LAB_RATE_LIMITS: LazyLock<StdMutex<HashMap<String, Vec<u128>>>> =
@@ -297,6 +259,9 @@ pub struct PiLabSendRequest {
pub folder_path: Option<String>,
pub context_refs: Option<Vec<String>>,
pub selected_context: Option<Value>,
/// S7: optional agent target package (mnote.agent_target_package.v1) from Page AI host.
#[serde(default)]
pub target_package: Option<Value>,
}
#[derive(Debug, Deserialize)]
@@ -787,6 +752,8 @@ fn resolve_file_path(
} else {
path.to_string()
};
crate::routes::vault_path::deny_if_vault_sensitive_relative_path(&relative_path)
.map_err(|error| error.with_context(context))?;
let target =
resolve_root_relative_path(state, context, root_uri, &relative_path, require_write)?;
return Ok((target, Some(root_uri.to_string()), Some(relative_path)));
@@ -800,6 +767,18 @@ fn resolve_file_path(
)
.with_context(context));
}
// Absolute-path tools: deny any path whose workspace-relative form hits vault.
let normalized_abs = path.replace('\\', "/");
if let Some(idx) = normalized_abs.find("/.mnote/vault") {
let tail = &normalized_abs[idx + 1..]; // drop leading '/'
crate::routes::vault_path::deny_if_vault_sensitive_relative_path(tail)
.map_err(|error| error.with_context(context))?;
} else if crate::routes::vault_path::is_vault_sensitive_relative_path(&normalized_abs) {
return Err(crate::routes::vault_path::vault_path_denied_error(
"密码箱路径不能通过通用文件接口访问;请使用 /vault 或 /api/vault/*",
)
.with_context(context));
}
let allowed = active_allowed_roots(state, context)?;
let target = canonical_or_parent(&requested);
let allowed_root = allowed
@@ -1545,11 +1524,19 @@ fn permission_mode_tool_policy(mode: Option<&str>, tool_name: &str, base_policy:
| "mnote.knowledge_rag.section_context"
| "mnote.knowledge_rag.open_reference"
| "mnote.reference.open"
| "mnote.vault.list"
| "mnote.vault.get"
| "mnote.tool_receipt.write" => "allow".into(),
// plan: no secret resolve/login
_ => "deny".into(),
},
Some("auto_edit") => match tool_name {
"mnote.local_file.read" | "mnote.local_file.patch" => "allow".into(),
"mnote.vault.list"
| "mnote.vault.get"
| "mnote.vault.resolve"
| "mnote.vault.login"
| "mnote.vault.session" => "allow".into(),
"mnote.codex_rescue.request" => "ask".into(),
_ => base_policy.into(),
},
@@ -1564,12 +1551,52 @@ fn permission_mode_tool_policy(mode: Option<&str>, tool_name: &str, base_policy:
"mnote.local_file.read" | "mnote.local_file.patch" | "mnote.codex_rescue.request" => {
"ask".into()
}
"mnote.vault.list"
| "mnote.vault.get"
| "mnote.vault.resolve"
| "mnote.vault.login"
| "mnote.vault.session" => "allow".into(),
_ => base_policy.into(),
},
_ => match tool_name {
"mnote.vault.list"
| "mnote.vault.get"
| "mnote.vault.resolve"
| "mnote.vault.login"
| "mnote.vault.session" => "allow".into(),
_ => base_policy.into(),
},
_ => base_policy.into(),
}
}
/// Strip secret values before control-plane receipt persistence.
fn redact_vault_tool_payload(tool_name: &str, payload: &Value) -> Value {
if tool_name != "mnote.vault.resolve"
&& tool_name != "mnote.vault.login"
&& tool_name != "mnote.vault.session"
{
return payload.clone();
}
let mut safe = payload.clone();
if let Some(obj) = safe.as_object_mut() {
if obj.contains_key("value") {
obj.insert("value".into(), Value::String("[redacted]".into()));
}
if obj.contains_key("cookieHeader") {
obj.insert("cookieHeader".into(), Value::String("[redacted]".into()));
}
if let Some(secret) = obj.get_mut("secret") {
if let Some(s) = secret.as_object_mut() {
if s.contains_key("value") {
s.insert("value".into(), Value::String("[redacted]".into()));
}
}
}
obj.insert("receiptRedacted".into(), Value::Bool(true));
}
safe
}
fn session_permission_mode(session: &PiLabSession) -> Option<&str> {
session
.runtime_policy_snapshot
@@ -2321,6 +2348,36 @@ fn pi_lab_tool_definitions() -> Vec<PiLabToolDefinition> {
label: "MNote Codex rescue",
description: "Ask local Codex to rescue hard MNote/Pi problems before escalating to the user. Use when tools, skills, MCP, LightRAG, environment, or local repo behavior looks broken and normal Pi troubleshooting is insufficient. This tool is admin-gated, approval-gated by default, runs codex exec with workspace-write sandbox and a timeout, and returns Codex's final answer plus stdout/stderr snippets. Provide issue, evidence/logs, attempted steps, and desired outcome. Call at most once per unresolved incident; if Codex cannot fix it, summarize the blocker to the user.",
},
PiLabToolDefinition {
pi_name: "mnote_vault_list",
mnote_name: "mnote.vault.list",
label: "MNote AI vault list",
description: "List credentials in the shared AI password book (L0 metadata only, no secret plaintext). Multi-agent single credential pool. Optional status=active|deleted. Do NOT use generic file tools on .mnote/vault.",
},
PiLabToolDefinition {
pi_name: "mnote_vault_get",
mnote_name: "mnote.vault.get",
label: "MNote AI vault get",
description: "Get one AI password-book item by id (secrets masked). Params: id. Use mnote.vault.resolve to obtain password/apikey/token plaintext for automation.",
},
PiLabToolDefinition {
pi_name: "mnote_vault_resolve",
mnote_name: "mnote.vault.resolve",
label: "MNote AI vault resolve",
description: "Resolve a secret field from the shared AI password book with cipher-book expansion. Params: id, field=password|apikey|token. Prefer mnote.vault.login for site login (session reuse). Do not paste value into chat.",
},
PiLabToolDefinition {
pi_name: "mnote_vault_login",
mnote_name: "mnote.vault.login",
label: "MNote AI vault login",
description: "ONE-SHOT multi-agent login for AI password book. Params: id, optional forceRefresh. Reuses saved session if fresh; otherwise api_first password login and saves session. If Cloudflare/captcha: returns human_required — human uses chrome-bridge/Paseo browser then mnote.vault.session. Prefer this over list+resolve+browser for logins. Do not paste cookieHeader/password into chat.",
},
PiLabToolDefinition {
pi_name: "mnote_vault_session",
mnote_name: "mnote.vault.session",
label: "MNote AI vault session write-back",
description: "Write browser-captured cookies into AI password book after human Cloudflare/captcha login. Params: id, cookieHeader, optional expiresAt, source=human_bridge|browser. Next mnote.vault.login will reuse them.",
},
PiLabToolDefinition {
pi_name: "mnote_tool_receipt_write",
mnote_name: "mnote.tool_receipt.write",
@@ -3028,6 +3085,96 @@ fn publish_event(session_id: &str, kind: &str, payload: Value) {
let _ = PI_LAB_EVENT_TX.send(event);
}
fn status_from_persisted(status: &str) -> PiLabSessionStatus {
match status {
"turn_running" => PiLabSessionStatus::Idle,
"runtime_running" => PiLabSessionStatus::Idle,
"aborted" => PiLabSessionStatus::Aborted,
"error" => PiLabSessionStatus::Error,
_ => PiLabSessionStatus::Idle,
}
}
/// 从 control-plane 恢复会话元数据到热缓存(不自动拉起子进程)。
fn hydrate_session_from_control_plane(
state: &AppState,
user_id: &str,
session_id: &str,
) -> Result<Option<PiLabSession>, WebError> {
if session_id.trim().is_empty() || is_pi_lab_warmup_session_id(session_id) {
return Ok(None);
}
if get_session(session_id).is_some() {
return Ok(get_session(session_id));
}
let run_id = pi_run_id(session_id);
let Some(run) = state
.control_plane()
.find_ai_runtime_run(user_id, &run_id)
.map_err(|e| WebError::internal(format!("hydrate Pi session 失败: {e}")))?
else {
return Ok(None);
};
if run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME {
return Ok(None);
}
if run.user_id != user_id {
return Ok(None);
}
let runtime: Value = serde_json::from_str(&run.runtime_json).unwrap_or(json!({}));
let str_field = |key: &str| -> Option<String> {
runtime
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
};
let now = now_ms();
let session = PiLabSession {
session_id: run.session_id.clone(),
mnote_user_id: run.user_id.clone(),
bridge_token: generate_bridge_token(),
status: status_from_persisted(&run.status),
provider_session_id: str_field("providerSessionId")
.unwrap_or_else(|| run.session_id.clone()),
pi_session_dir: str_field("piSessionDir").unwrap_or_default(),
pi_session_file: str_field("piSessionFile"),
root_uri: str_field("rootUri"),
workspace_id: run
.workspace_id
.clone()
.or_else(|| str_field("workspaceId")),
page_path: run.document_id.clone().or_else(|| str_field("pagePath")),
page_title: run.title.clone().or_else(|| str_field("pageTitle")),
model_provider: str_field("modelProvider"),
model_id: str_field("modelId"),
thinking_level: str_field("thinkingLevel"),
allowed_roots_snapshot: runtime.get("allowedRootsSnapshot").cloned(),
runtime_policy_snapshot: runtime.get("runtimePolicy").cloned(),
runtime_pid: None,
runtime_mode: str_field("runtimeMode").unwrap_or_else(|| "real".to_string()),
runtime_error: None,
created_at_ms: now,
updated_at_ms: now,
message_count: runtime
.get("messageCount")
.and_then(Value::as_u64)
.unwrap_or(0),
};
if session.pi_session_dir.trim().is_empty() {
return Ok(None);
}
upsert_session(session.clone());
let _ = persist_append_event(
state,
&session,
"session_hydrated",
&json!({"source": "control_plane", "runId": run.run_id}),
);
Ok(Some(session))
}
fn upsert_session(session: PiLabSession) {
if let Ok(mut sessions) = PI_LAB_SESSIONS.lock() {
sessions.insert(session.session_id.clone(), session);
@@ -3219,11 +3366,25 @@ fn get_session_for_context(
context: &RequestContext,
session_id: &str,
) -> Result<PiLabSession, WebError> {
let session = get_session(session_id).ok_or_else(|| {
WebError::bad_request_code("page_ai_pi_lab_session_not_found", "Pi Lab session 不存在")
})?;
ensure_session_owner(state, context, &session)?;
Ok(session)
if let Some(session) = get_session(session_id) {
ensure_session_owner(state, context, &session)?;
return Ok(session);
}
// S6: process restart / cold cache — hydrate metadata from control-plane
// so send/events/status can resume the same sessionId without a full start.
let actor_id = context.auth.actor_id.trim();
if !actor_id.is_empty() && actor_id != "anonymous" {
if let Ok(Some(session)) =
hydrate_session_from_control_plane(state, actor_id, session_id)
{
ensure_session_owner(state, context, &session)?;
return Ok(session);
}
}
Err(WebError::bad_request_code(
"page_ai_pi_lab_session_not_found",
"Pi Lab session 不存在",
))
}
fn bridge_token_from_headers(headers: &HeaderMap) -> Option<&str> {
@@ -4668,12 +4829,153 @@ impl PiLabToolFacade {
"mnote.knowledge_rag.section_context",
"mnote.knowledge_rag.open_reference",
"mnote.reference.open",
"mnote.vault.list",
"mnote.vault.get",
"mnote.vault.resolve",
"mnote.vault.login",
"mnote.vault.session",
"mnote.codex_rescue.request",
"mnote.tool_receipt.write"
],
}))
}
fn vault_list(&self, params: Value) -> Result<Value, WebError> {
let status = params
.get("status")
.and_then(Value::as_str)
.and_then(crate::routes::vault_store::VaultItemStatus::parse)
.unwrap_or(crate::routes::vault_store::VaultItemStatus::Active);
crate::routes::vault::list_ai_vault_items(status)
}
fn vault_get(&self, params: Value) -> Result<Value, WebError> {
let id = string_param(&params, "id")
.or_else(|| string_param(&params, "credentialId"))
.ok_or_else(|| {
WebError::bad_request_code("page_ai_vault_id_required", "mnote.vault.get 需要 id")
})?;
crate::routes::vault::get_ai_vault_item(&id)
}
fn vault_resolve(&self, params: Value) -> Result<Value, WebError> {
let id = string_param(&params, "id")
.or_else(|| string_param(&params, "credentialId"))
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_vault_id_required",
"mnote.vault.resolve 需要 id",
)
})?;
let field = string_param(&params, "field").ok_or_else(|| {
WebError::bad_request_code(
"page_ai_vault_field_required",
"mnote.vault.resolve 需要 field=password|apikey|token",
)
})?;
let actor = {
let id = self.context.auth.actor_id.trim();
if id.is_empty() {
"anonymous".to_string()
} else {
id.to_string()
}
};
if actor == "anonymous" {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"密码箱 resolve 需要登录会话",
));
}
crate::routes::vault::resolve_ai_vault_secret(
&id,
&field,
&actor,
Some(self.context.trace.request_id.as_str()),
)
}
fn vault_login(&self, params: Value) -> Result<Value, WebError> {
let id = string_param(&params, "id")
.or_else(|| string_param(&params, "credentialId"))
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_vault_id_required",
"mnote.vault.login 需要 id",
)
})?;
let force = bool_param(&params, "forceRefresh")
.or_else(|| bool_param(&params, "force_refresh"))
.unwrap_or(false);
let actor = {
let id = self.context.auth.actor_id.trim();
if id.is_empty() {
"anonymous".to_string()
} else {
id.to_string()
}
};
if actor == "anonymous" {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"密码箱 login 需要登录会话",
));
}
crate::routes::vault::login_ai_vault_credential(
&id,
force,
&actor,
Some(self.context.trace.request_id.as_str()),
)
}
fn vault_session(&self, params: Value) -> Result<Value, WebError> {
let id = string_param(&params, "id")
.or_else(|| string_param(&params, "credentialId"))
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_vault_id_required",
"mnote.vault.session 需要 id",
)
})?;
let cookie = string_param(&params, "cookieHeader")
.or_else(|| string_param(&params, "cookie_header"))
.ok_or_else(|| {
WebError::bad_request_code(
"page_ai_vault_cookie_required",
"mnote.vault.session 需要 cookieHeader",
)
})?;
let expires = string_param(&params, "expiresAt")
.or_else(|| string_param(&params, "expires_at"));
let source = string_param(&params, "source").unwrap_or_else(|| "human_bridge".into());
let actor = {
let id = self.context.auth.actor_id.trim();
if id.is_empty() {
"anonymous".to_string()
} else {
id.to_string()
}
};
if actor == "anonymous" {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"密码箱 session 需要登录会话",
));
}
crate::routes::vault::put_ai_vault_session(
&id,
&cookie,
expires.as_deref(),
&source,
&actor,
Some(self.context.trace.request_id.as_str()),
)
}
fn local_file_read(&self, params: Value) -> Result<Value, WebError> {
let (target, root_uri, relative_path) = resolve_file_path(
&self.state,
@@ -5205,6 +5507,11 @@ async fn execute_tool(
}
"mnote.knowledge_rag.open_reference" => facade.reference_open(params.clone()).await,
"mnote.reference.open" => facade.reference_open(params.clone()).await,
"mnote.vault.list" => facade.vault_list(params.clone()),
"mnote.vault.get" => facade.vault_get(params.clone()),
"mnote.vault.resolve" => facade.vault_resolve(params.clone()),
"mnote.vault.login" => facade.vault_login(params.clone()),
"mnote.vault.session" => facade.vault_session(params.clone()),
"mnote.codex_rescue.request" => facade.codex_rescue_request(params.clone()).await,
"mnote.tool_receipt.write" => Ok(json!({
"requestedReceipt": params,
@@ -5286,7 +5593,14 @@ async fn execute_tool(
before_file_version.clone(),
after_file_version.clone(),
);
let receipt_payload = write_receipt(&facade.state, receipt, &payload, citation_count);
// Never persist vault secret values into control-plane receipts / journals.
let receipt_safe_payload = redact_vault_tool_payload(&tool_name, &payload);
let receipt_payload = write_receipt(
&facade.state,
receipt,
&receipt_safe_payload,
citation_count,
);
let elapsed_ms = now_ms().saturating_sub(started) as u64;
if let Some(session) = session.as_ref() {
let tool_event_id = receipt_payload
@@ -5467,6 +5781,17 @@ pub async fn status(
let runtime_error = current_session
.as_ref()
.and_then(|session| session.runtime_error.clone());
// S1: status defaults must match effective AI policy (management surface truth),
// not only env hardcodes. Frontend checkStatus previously overwrote effective
// defaults with these fields.
let effective_policy =
ai_settings::load_effective_ai_runtime_policy(&state, &actor_id, None);
let (status_default_provider, status_default_model_id) = match effective_policy
.resolve_requested_model(None, None)
{
Ok(resolved) => (resolved.provider, resolved.model_id),
Err(_) => (default_model_provider(), default_model_id()),
};
Ok(Json(json!({
"ok": true,
"schema": PI_LAB_SCHEMA_STATUS,
@@ -5480,8 +5805,9 @@ pub async fn status(
"runtimeAvailable": runtime_available,
"runtimeInstallHint": runtime_install_hint,
"runtimeError": runtime_error,
"defaultModelProvider": default_model_provider(),
"defaultModelId": default_model_id(),
"defaultModelProvider": status_default_provider,
"defaultModelId": status_default_model_id,
"defaultModel": format!("{status_default_provider}/{status_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),
@@ -5590,6 +5916,7 @@ pub async fn bootstrap(
folder_path: None,
context_refs: None,
selected_context: None,
target_package: None,
}),
)
.await?
@@ -5614,6 +5941,9 @@ pub async fn start(
let mut requested_session = requested_session;
requested_session.allowed_roots_snapshot = snapshot_allowed_roots(&state, &context)?;
if let Some(existing_session_id) = request.session_id.as_deref() {
if get_session(existing_session_id).is_none() {
let _ = hydrate_session_from_control_plane(&state, &actor_id, existing_session_id)?;
}
if let Some(mut existing_session) = get_session(existing_session_id) {
ensure_session_owner(&state, &context, &existing_session)?;
if session_runtime_is_usable(&existing_session)
@@ -5888,6 +6218,8 @@ pub async fn send(
"folderPath": request.folder_path,
"contextRefs": request.context_refs,
"selectedContext": request.selected_context,
// S7: host-built agent target package (optional; client also enforces dirty gate).
"targetPackage": request.target_package,
},
});
if let Some(images) = request.images.clone().filter(|images| !images.is_empty()) {
@@ -9463,7 +9795,7 @@ mod tests {
#[test]
fn pi_rust_start_keeps_official_prompt_templates_and_context_files_enabled() {
let source = include_str!("page_ai_pi.rs");
let source = include_str!("runtime.rs");
assert!(!source.contains(".arg(\"--no-prompt-templates\")"));
assert!(!source.contains(".arg(\"--no-context-files\")"));
}
@@ -9836,6 +10168,129 @@ mod tests {
assert_eq!(denied_payload["code"], "page_ai_pi_lab_model_not_allowed");
}
/// S1: admin/user AI policy defaultModel must apply on the next Pi start when
/// the client does not pass modelProvider/modelId (management surface is truth).
#[tokio::test]
async fn start_without_model_uses_effective_default_and_picks_up_policy_change() {
std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock");
let state = test_state();
let actor_id = "pi_default_model_s1_user";
let root = temp_root("mnote-pi-default-model-s1-root");
let root_uri = grant_directory(&state, actor_id, &root, "write");
upsert_ai_policy(
&state,
actor_id,
json!({
"defaultModel": "omniroute/pi-fast",
"allowedModels": ["omniroute/pi-fast", "omniroute/pi-reason"]
}),
);
let app = build_app(state.clone());
let (status_a, payload_a) = request_json(
app.clone(),
"/api/page-ai/pi/start",
actor_id,
json!({
"sessionId": "pi_lab_s1_default_a",
"rootUri": root_uri,
}),
)
.await;
assert_eq!(status_a, StatusCode::OK, "start A: {payload_a}");
assert_eq!(payload_a["session"]["modelProvider"], "omniroute");
assert_eq!(payload_a["session"]["modelId"], "pi-fast");
assert_eq!(
payload_a["session"]["runtimePolicySnapshot"]["defaultModel"],
"omniroute/pi-fast"
);
// Simulate management-surface defaultModel change (same control-plane policy).
upsert_ai_policy(
&state,
actor_id,
json!({
"defaultModel": "omniroute/pi-reason",
"allowedModels": ["omniroute/pi-fast", "omniroute/pi-reason"]
}),
);
let (status_b, payload_b) = request_json(
app,
"/api/page-ai/pi/start",
actor_id,
json!({
"sessionId": "pi_lab_s1_default_b",
"rootUri": root_uri,
}),
)
.await;
assert_eq!(status_b, StatusCode::OK, "start B: {payload_b}");
assert_eq!(payload_b["session"]["modelProvider"], "omniroute");
assert_eq!(
payload_b["session"]["modelId"],
"pi-reason",
"next start without explicit model must pick up new defaultModel"
);
assert_eq!(
payload_b["session"]["runtimePolicySnapshot"]["defaultModel"],
"omniroute/pi-reason"
);
}
/// S1: /status defaultModel* must mirror effective policy, not only env hardcodes.
#[tokio::test]
async fn status_default_model_matches_effective_ai_policy() {
std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock");
// Env hardcode must not win over policy when policy is present.
std::env::set_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL", "env-stale-model");
std::env::set_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL_PROVIDER", "omniroute");
let state = test_state();
let actor_id = "pi_status_default_s1_user";
// Policy FK requires the user row first (grant_directory also upserts user).
let _root = grant_directory(
&state,
actor_id,
&temp_root("mnote-pi-status-default-s1-root"),
"read",
);
upsert_ai_policy(
&state,
actor_id,
json!({
"defaultModel": "omniroute/pi-reason",
"allowedModels": ["omniroute/pi-reason", "omniroute/pi-fast"]
}),
);
let app = build_app(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/page-ai/pi/status")
.header("x-mnote-actor-id", actor_id)
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["defaultModelProvider"], "omniroute");
assert_eq!(
payload["defaultModelId"], "pi-reason",
"status must not report env-stale default when policy exists: {payload}"
);
assert_eq!(payload["defaultModel"], "omniroute/pi-reason");
std::env::remove_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL");
std::env::remove_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL_PROVIDER");
}
#[test]
fn hydrate_session_mcp_cache_copies_shared_cache() {
let root = temp_root("mnote-pi-shared-mcp-cache-root");
+78 -9
View File
@@ -421,6 +421,41 @@ fn collect_expanded_ids(projection: &Value) -> BTreeSet<String> {
.unwrap_or_default()
}
/// Map a page-tree row's storage relative path to the directory scope used for lazy expand.
/// - Nested bundle `Folder/Folder.md` → `Folder`
/// - Sibling page `Folder.md` (+ `Folder/`) → `Folder`
/// - Page-group / directory row → itself
pub(crate) fn page_tree_expand_relative_path(relative_path: &str) -> String {
let rp = relative_path
.trim()
.trim_matches('/')
.replace('\\', "/");
if rp.is_empty() {
return String::new();
}
let lower = rp.to_ascii_lowercase();
if !lower.ends_with(".md") {
return rp;
}
let stem = &rp[..rp.len().saturating_sub(3)];
if let Some((parent, file)) = rp.rsplit_once('/') {
let file_stem = file
.strip_suffix(".md")
.or_else(|| file.strip_suffix(".MD"))
.or_else(|| file.strip_suffix(".Md"))
.unwrap_or(file);
let parent_name = parent.rsplit_once('/').map(|(_, name)| name).unwrap_or(parent);
if parent_name == file_stem {
// Nested bundle: markdown lives inside a same-named folder.
return parent.to_string();
}
// Sibling page under a parent directory.
return stem.to_string();
}
// Root-level sibling page.
stem.to_string()
}
pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeRenderRow> {
let mut rows: Vec<PageTreeRenderRow> = projection
.get("items")
@@ -441,6 +476,47 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
if row_kind != "document" {
return None;
}
let expandable = item
.get("expandable")
.and_then(Value::as_bool)
.unwrap_or_else(|| {
item.get("childCount")
.and_then(Value::as_u64)
.map(|count| count > 0)
.unwrap_or(false)
});
let relative_path = item
.get("expandRelativePath")
.or_else(|| item.get("relativePath"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
item.get("resourceMeta")
.and_then(|meta| meta.get("workspacePath"))
.and_then(|path| path.get("relativePath"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.or_else(|| {
item.get("resourceMeta")
.and_then(|meta| meta.pointer("/extra/source/relativePath"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
});
let expand_relative_path = if expandable {
relative_path
.as_deref()
.map(page_tree_expand_relative_path)
.filter(|value| !value.is_empty())
} else {
None
};
Some(PageTreeRenderRow {
node_id: node_id.to_string(),
parent_node_id: item
@@ -458,15 +534,7 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
.unwrap_or("无标题")
.to_string(),
depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32,
expandable: item
.get("expandable")
.and_then(Value::as_bool)
.unwrap_or_else(|| {
item.get("childCount")
.and_then(Value::as_u64)
.map(|count| count > 0)
.unwrap_or(false)
}),
expandable,
expanded: item
.get("expandedByDefault")
.and_then(Value::as_bool)
@@ -480,6 +548,7 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
.filter(|value| !value.is_empty())
.is_some()
|| !node_id.starts_with("local-dir:"),
expand_relative_path,
})
})
.collect()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
//! Shared vault path deny predicate for general file surfaces.
//!
//! Any workspace-relative path under `.mnote/vault` must be rejected on
//! open/stat/resource/local_file surfaces. Vault content is only reachable
//! via `/api/vault/*`.
use crate::error::WebError;
use axum::http::StatusCode;
/// Workspace-root-relative path that points at the password vault system space.
///
/// `rel` should use `/` separators. Leading `./` is stripped. Parent segments
/// (`..`) are rejected before classification (callers usually already do this).
pub fn normalize_workspace_relative_path(rel: &str) -> String {
let mut value = rel.trim().replace('\\', "/");
while value.starts_with("./") {
value = value[2..].to_string();
}
value = value.trim_start_matches('/').to_string();
while value.contains("//") {
value = value.replace("//", "/");
}
if value.ends_with('/') && value != "/" {
value.pop();
}
value
}
/// Returns true when `rel` is `.mnote/vault` or any path under it.
pub fn is_vault_sensitive_relative_path(rel: &str) -> bool {
let n = normalize_workspace_relative_path(rel);
if n.is_empty() {
return false;
}
if n
.split('/')
.any(|segment| segment == ".." || segment.is_empty())
{
// Escape / empty segments are not vault matches; callers reject escape.
return false;
}
n == ".mnote/vault" || n.starts_with(".mnote/vault/")
}
/// 403 with stable code for general file surfaces that hit vault paths.
pub fn vault_path_denied_error(message: impl Into<String>) -> WebError {
WebError::new(
StatusCode::FORBIDDEN,
"vault_path_denied",
message.into(),
)
}
/// Reject vault-relative paths before reading or writing general local files.
pub fn deny_if_vault_sensitive_relative_path(rel: &str) -> Result<(), WebError> {
if is_vault_sensitive_relative_path(rel) {
return Err(vault_path_denied_error(
"密码箱路径不能通过通用文件接口访问;请使用 /vault 或 /api/vault/*",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_vault_root_and_children() {
assert!(is_vault_sensitive_relative_path(".mnote/vault"));
assert!(is_vault_sensitive_relative_path(".mnote/vault/"));
assert!(is_vault_sensitive_relative_path(".mnote/vault/entries/x.md"));
assert!(is_vault_sensitive_relative_path(
"./.mnote/vault/entries/x.md"
));
assert!(is_vault_sensitive_relative_path(
".mnote\\vault\\entries\\x.md"
));
assert!(is_vault_sensitive_relative_path(
".mnote/vault/trash/entries/x.md"
));
}
#[test]
fn allows_non_vault_paths() {
assert!(!is_vault_sensitive_relative_path(""));
assert!(!is_vault_sensitive_relative_path("notes/a.md"));
assert!(!is_vault_sensitive_relative_path(".mnote/trash/a.md"));
assert!(!is_vault_sensitive_relative_path(".mnote/index/search-index.json"));
assert!(!is_vault_sensitive_relative_path("mnote/vault/x.md"));
assert!(!is_vault_sensitive_relative_path(".mnote/vault-backup/x.md"));
assert!(!is_vault_sensitive_relative_path("个人/密码/a.md"));
}
#[test]
fn deny_helper_returns_stable_code() {
let err = deny_if_vault_sensitive_relative_path(".mnote/vault/entries/x.md")
.expect_err("must deny");
assert_eq!(err.code(), "vault_path_denied");
assert_eq!(err.status(), StatusCode::FORBIDDEN);
assert!(deny_if_vault_sensitive_relative_path("notes/a.md").is_ok());
}
}
File diff suppressed because it is too large Load Diff
+57 -10
View File
@@ -11,8 +11,9 @@ use crate::routes::gateway::default_workspace_name_for_context;
use crate::routes::local_folder_source::{
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
load_local_folder_file_tree_children_snapshot_with_reveal,
load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_scope_snapshot,
load_local_folder_page_tree_snapshot, resolve_local_markdown_page_aggregate,
load_local_folder_file_tree_snapshot_with_reveal,
load_local_folder_page_tree_scope_snapshot_with_reveal,
load_local_folder_page_tree_snapshot_with_reveal, resolve_local_markdown_page_aggregate,
};
use crate::routes::query_support::execute_runtime_query_against_data;
use crate::routes::snapshot_support::{
@@ -21,12 +22,12 @@ use crate::routes::snapshot_support::{
use crate::routes::tree::{collect_filetree_render_rows, collect_page_tree_render_rows};
use crate::ssr::pages::document::DocumentPage;
use crate::tree_shell::filetree_renderer::{
render_initial_filetree_html, FileTreeInitialRenderInput,
render_filetree_pending_shell_html, render_initial_filetree_html, FileTreeInitialRenderInput,
};
use crate::tree_shell::page_renderer::{render_initial_page_tree_html, PageTreeInitialRenderInput};
use crate::workspace_shell::{
apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html,
WorkspaceShellProjection,
apply_active_page, build_workspace_shell_projection, render_page_breadcrumb_html,
render_workspace_shell_sidebar_html, WorkspaceShellProjection,
};
use axum::body::Body;
use axum::extract::{Extension, Path, Query, State};
@@ -261,6 +262,7 @@ pub async fn document_page_shell(
file_tree_scope,
);
let workspace_name = workspace_projection.workspace_name.clone();
let breadcrumb_html = render_page_breadcrumb_html(&workspace_projection, Some(&document_id));
let page_subtree_json =
serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string());
let page_options_json = serde_json::to_string(&aggregate.layout.page_options)
@@ -300,6 +302,10 @@ pub async fn document_page_shell(
);
let pi_lab_loader_script =
render_page_ai_pi_lab_loader_script(state.config().enable_page_ai_pi_lab);
let vault_nav_href = crate::routes::gateway::vault_nav_href_for_context(
primary_source_kind,
primary_root_uri,
);
let body_content = crate::ssr::render_view(leptos::view! {
<DocumentPage
title={title.to_string()}
@@ -308,6 +314,7 @@ pub async fn document_page_shell(
sidebar_tree_html={sidebar_tree_html}
workspace_name={workspace_name}
workspace_sidebar_html={workspace_sidebar_html}
breadcrumb_html={breadcrumb_html}
page_subtree_json={page_subtree_json}
page_options_json={page_options_json}
secondary_title={secondary_aggregate.as_ref().map(|aggregate| aggregate.head.title.clone()).unwrap_or_default()}
@@ -318,6 +325,7 @@ pub async fn document_page_shell(
primary_hide_title_header={aggregate.layout.page_options.hide_title_header}
secondary_hide_title_header={secondary_aggregate.as_ref().map(|aggregate| aggregate.layout.page_options.hide_title_header).unwrap_or(secondary_source_kind == Some("local_folder"))}
show_admin_access_policy={is_local_access_policy_admin_context(&context)}
vault_nav_href={vault_nav_href}
/>
});
let hermes_settings_config_script = render_hermes_settings_config_script();
@@ -766,6 +774,12 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
let saving = false;
const saveTitle = async () => {
if (input.readOnly || input.getAttribute('data-document-user-readonly-mode') === 'true') {
input.value = readLastSavedTitle();
autosize(input);
setStatus(input, 'saved');
return;
}
const title = input.value.trim() || '';
const currentTarget = resolveTitleTarget(input);
autosize(input);
@@ -820,6 +834,12 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
};
input.addEventListener('input', () => {
if (input.readOnly || input.getAttribute('data-document-user-readonly-mode') === 'true') {
input.value = readLastSavedTitle();
autosize(input);
setStatus(input, 'saved');
return;
}
autosize(input);
setStatus(input, (input.value.trim() || '') === readLastSavedTitle() ? 'saved' : 'dirty');
});
@@ -3103,6 +3123,20 @@ pub async fn document_editor_adapter_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn vault_workbench_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/vault-workbench-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn leptos_tiptap_manifest() -> Response {
let manifest = json!({
"entryAssetPath": "mnote-leptos-tiptap-spike-island.js",
@@ -3603,9 +3637,13 @@ pub(crate) fn render_local_sidebar_tree_html_scoped(
.map(str::trim)
.filter(|value| !value.is_empty())
{
load_local_folder_page_tree_scope_snapshot(root_uri, scope)?
load_local_folder_page_tree_scope_snapshot_with_reveal(
root_uri,
scope,
active_document_id,
)?
} else {
load_local_folder_page_tree_snapshot(root_uri)?
load_local_folder_page_tree_snapshot_with_reveal(root_uri, active_document_id)?
};
Ok(render_local_sidebar_tree_html_from_snapshot(
&snapshot,
@@ -3633,6 +3671,12 @@ pub(crate) fn render_local_file_tree_html(
render_local_file_tree_html_scoped(root_uri, active_document_id, active_row_id, None)
}
/// Fast path for home SSR: keep FileTree shell structure without scanning the folder.
/// Browser hydrates rows after first paint when `data-filetree-ssr="pending"`.
pub(crate) fn render_local_file_tree_pending_shell_html() -> String {
render_filetree_pending_shell_html()
}
pub(crate) fn render_local_file_tree_html_scoped(
root_uri: &str,
active_document_id: Option<&str>,
@@ -5320,8 +5364,10 @@ mod tests {
));
assert!(session_runtime.contains("new EventSource(url.toString())"));
assert!(session_runtime.contains("localFolderEventRegistry"));
assert!(session_runtime
.contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')"));
assert!(session_runtime.contains("if (!response.ok) {"));
assert!(session_runtime.contains(
"if (session.sourceKind === 'local_folder' && (response.status === 404 || errorCode === 'local_markdown_not_found'))"
));
assert!(session_runtime.contains("shouldSuppressLocalFolderSelfChange"));
assert!(session_runtime.contains("kind.includes('Modify(Name')"));
assert!(session_runtime.contains("targetSession.views.size === 0"));
@@ -5707,7 +5753,8 @@ mod tests {
assert!(runtime.contains("legacyInlineContentToTiptap"));
assert!(runtime.contains("legacyStylesToTiptapMarks"));
assert!(runtime.contains("legacyMarkArrayToTiptapMarks"));
assert!(runtime.contains("firstNonEmptyText(block?.props?.sourcePath"));
assert!(runtime.contains("const sourcePath = firstNonEmptyText("));
assert!(runtime.contains("block?.props?.sourcePath"));
assert!(runtime.contains("marks.push({ type: 'bold' })"));
assert!(runtime.contains("marks.push({ type: 'italic' })"));
assert!(runtime.contains("marks.push({ type: 'underline' })"));