Harden auth/vault path sanitization and clean WeKnora docs

This commit is contained in:
Agent Board
2026-07-28 17:04:27 +08:00
parent 2deaf59f7b
commit 26ff1a9c9a
190 changed files with 13454 additions and 4987 deletions
+22
View File
@@ -41,6 +41,8 @@ pub struct AppConfig {
pub dev_user_id: String,
pub dev_user_name: String,
pub dev_user_email: String,
/// "dev" | "prod" — 影响前端显示与 env 片段中的 base URL。
pub environment: String,
}
impl AppConfig {
@@ -93,10 +95,29 @@ impl AppConfig {
.ok()
.or_else(|| read_env_or_dotenv("DEV_USER_EMAIL"))
.unwrap_or_else(|| "dev@mnote.local".into()),
environment: detect_environment(),
}
}
}
/// 推断运行环境:显式 MNOTE_WEB_ENV > 端口匹配(3000=dev, 3003=prod> 默认 "dev"。
fn detect_environment() -> String {
if let Ok(v) = env::var("MNOTE_WEB_ENV") {
let t = v.trim().to_lowercase();
if !t.is_empty() {
return t;
}
}
let port = env::var("MNOTE_WEB_BIND")
.or_else(|_| env::var("MNOTE_WEB_PUBLIC_BIND"))
.ok()
.and_then(|addr| addr.rsplit(':').next()?.parse::<u16>().ok());
match port {
Some(3003) => "prod".into(),
_ => "dev".into(),
}
}
fn env_bool(key: &str, default: bool) -> bool {
env::var(key)
.ok()
@@ -392,6 +413,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}
}
+70 -2
View File
@@ -29,14 +29,79 @@ pub struct TraceContext {
pub path: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[derive(Clone, PartialEq, Eq)]
pub struct AuthContext {
/// 原始 Authorization;禁止序列化/Debug 明文以免 token 进日志或错误响应。
pub authorization: Option<String>,
pub cookie_header: Option<String>,
pub actor_id: String,
pub actor_type: String,
pub session_id: Option<String>,
/// `anonymous` | `session` | `pat` | `extension`7-76
pub auth_method: String,
/// PAT scopessession 为空表示不受 PAT 白名单限制。
pub scopes: Vec<String>,
/// PAT jti(若适用)
pub pat_jti: Option<String>,
}
impl std::fmt::Debug for AuthContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthContext")
.field(
"authorization",
&self
.authorization
.as_ref()
.map(|_| "<redacted>")
.unwrap_or("None"),
)
.field(
"cookie_header",
&self
.cookie_header
.as_ref()
.map(|_| "<redacted>")
.unwrap_or("None"),
)
.field("actor_id", &self.actor_id)
.field("actor_type", &self.actor_type)
.field(
"session_id",
&self
.session_id
.as_ref()
.map(|_| "<redacted>")
.unwrap_or("None"),
)
.field("auth_method", &self.auth_method)
.field("scopes", &self.scopes)
.field(
"pat_jti",
&self
.pat_jti
.as_ref()
.map(|_| "<redacted>")
.unwrap_or("None"),
)
.finish()
}
}
impl Serialize for AuthContext {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
// 不序列化 sessionId / token:避免进入错误响应 / 日志 / 诊断载荷。
let mut state = serializer.serialize_struct("AuthContext", 4)?;
state.serialize_field("actorId", &self.actor_id)?;
state.serialize_field("actorType", &self.actor_type)?;
state.serialize_field("authMethod", &self.auth_method)?;
state.serialize_field("scopes", &self.scopes)?;
state.end()
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
@@ -88,6 +153,9 @@ impl RequestContext {
.or_else(|| cookie_value(headers, COOKIE_ACTOR_TYPE))
.unwrap_or_else(|| "anonymous".into()),
session_id: header_value(headers, HEADER_SESSION_ID),
auth_method: "anonymous".into(),
scopes: Vec::new(),
pat_jti: None,
},
workspace: WorkspaceContext {
workspace_id: header_value(headers, HEADER_WORKSPACE_ID),
@@ -297,6 +297,26 @@ impl BufferStore {
}
}
/// 本地文件 restore 后清除已打开 Markdown buffer 的 deleted 标记。
pub fn clear_local_folder_markdown_deleted(
&self,
workspace_id: &str,
root_uri: &str,
relative_path: &str,
document_id: &str,
) -> Option<DocumentBuffer> {
let path =
build_local_folder_workspace_path(workspace_id, root_uri, relative_path, document_id);
let key = BufferKey::from_workspace_path(&path);
let mut inner = self.inner.write().expect("BufferStore lock");
if let Some(buf) = inner.buffers.get_mut(&key) {
buf.clear_deleted();
Some(buf.clone())
} else {
None
}
}
/// 获取或创建 buffer 时设置 file_version 和 base_content_hash(从 aggregate 加载后调用)。
pub fn init_buffer(
&self,
@@ -738,5 +758,15 @@ mod tests {
.expect("buffer should be marked deleted");
assert_eq!(deleted.dirty_state, DocBufferDirtyState::Deleted);
let restored = store
.clear_local_folder_markdown_deleted(
"local:test",
root_uri,
"docs/Delete.md",
"local-md:docs~2FDelete.md",
)
.expect("buffer should clear deleted after restore");
assert_eq!(restored.dirty_state, DocBufferDirtyState::Clean);
}
}
+145 -72
View File
@@ -137,54 +137,7 @@ impl EditorRuntimeActor {
format!("文档 {document_id} 尚未加载"),
)
})?;
let operations = match command {
EditorCommand::ReplaceBlock(cmd) => {
let block = state
.document
.blocks
.iter()
.find(|b| b.block_id == cmd.block_id);
vec![DeltaOperation::ReplaceBlock {
block_id: cmd.block_id.clone(),
text: block_text_from_block(block),
block_type: block.map(|b| block_type_name(&b.block_type)),
}]
}
EditorCommand::InsertBlockAfter(cmd) => {
let new_block = state
.document
.blocks
.iter()
.find(|b| b.block_id == cmd.block.block_id);
vec![DeltaOperation::InsertBlockAfter {
anchor_block_id: cmd.after_block_id.clone(),
block_id: cmd.block.block_id.clone(),
text: block_text_from_block(new_block),
block_type: new_block.map(|b| block_type_name(&b.block_type)),
}]
}
EditorCommand::DeleteBlock(cmd) => {
vec![DeltaOperation::DeleteBlock {
block_id: cmd.block_id.clone(),
}]
}
EditorCommand::MoveBlock(cmd) => {
let anchor = cmd.after_block_id.clone().unwrap_or_default();
vec![DeltaOperation::MoveBlock {
block_id: cmd.block_id.clone(),
anchor_block_id: anchor,
}]
}
_ => vec![],
};
Ok(BlockDelta {
document_id: document_id.to_string(),
revision: state.revision,
conflict_detection_key: state.conflict_detection_key.clone(),
operations,
})
build_block_delta_from_state(state, document_id, command)
}
pub fn new() -> Self {
Self {
@@ -257,33 +210,64 @@ impl EditorRuntimeActor {
)
})?;
let changed_blocks = extract_changed_blocks(&state.document, &command);
apply_command_locked(state, document_id, command, command_name)
}
apply_editor_command_to_document(&mut state.document, command.clone()).map_err(
|error| WebError::bad_request_code("mnote_editor_command_failed", format!("{error:?}")),
)?;
/// 单次写锁内完成 load_or_init + apply + legacy export + BlockDelta。
/// 避免 is_loaded / apply / legacy_content_for_save 分锁导致的并发交错。
pub fn apply_command_and_export(
&self,
document_id: &str,
workspace_id: Option<&str>,
page_aggregate: &Value,
command: EditorCommand,
command_name: &str,
) -> Result<(Value, Option<Value>), WebError> {
let mut documents = self
.documents
.write()
.map_err(|error| WebError::internal(format!("EditorRuntimeActor 锁失败:{error}")))?;
state.revision += 1;
state.conflict_detection_key = format!(
"{}:{}:{}",
document_id,
state.revision,
state.last_applied_at.elapsed().as_micros()
);
state.last_applied_at = Instant::now();
if !documents.contains_key(document_id) {
let content = page_aggregate
.pointer("/body/content")
.cloned()
.unwrap_or_else(|| json!([]));
let revision = page_aggregate
.pointer("/body/revision")
.and_then(|value| value.as_u64())
.unwrap_or(1);
let conflict_detection_key = page_aggregate
.pointer("/body/conflictDetectionKey")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let document = editor_document_from_legacy_content(document_id, &content);
documents.insert(
document_id.to_string(),
EditorDocumentState {
document_id: document_id.to_string(),
workspace_id: workspace_id.map(ToString::to_string),
document,
revision,
conflict_detection_key,
last_applied_at: Instant::now(),
},
);
}
Ok(ApplyResult {
ok: true,
command: command_name.to_string(),
document_id: Some(document_id.to_string()),
workspace_id: state.workspace_id.clone(),
new_revision: state.revision,
conflict_detection_key: state.conflict_detection_key.clone(),
changed_blocks,
warnings: vec![],
blocked: false,
risk: "low".to_string(),
})
let state = documents.get_mut(document_id).ok_or_else(|| {
WebError::internal(format!(
"EditorRuntimeActor 文档 {document_id} 初始化后仍不可用"
))
})?;
let _apply = apply_command_locked(state, document_id, command.clone(), command_name)?;
let content = legacy_content_from_editor_document(&state.document);
let delta = build_block_delta_from_state(state, document_id, &command)
.ok()
.and_then(|bd| serde_json::to_value(bd).ok());
Ok((content, delta))
}
/// 从内存态生成 legacy content(用于构建 Convex save payload)。
@@ -325,6 +309,95 @@ impl EditorRuntimeActor {
}
}
fn apply_command_locked(
state: &mut EditorDocumentState,
document_id: &str,
command: EditorCommand,
command_name: &str,
) -> Result<ApplyResult, WebError> {
let changed_blocks = extract_changed_blocks(&state.document, &command);
apply_editor_command_to_document(&mut state.document, command.clone()).map_err(|error| {
WebError::bad_request_code("mnote_editor_command_failed", format!("{error:?}"))
})?;
state.revision += 1;
state.conflict_detection_key = format!(
"{}:{}:{}",
document_id,
state.revision,
state.last_applied_at.elapsed().as_micros()
);
state.last_applied_at = Instant::now();
Ok(ApplyResult {
ok: true,
command: command_name.to_string(),
document_id: Some(document_id.to_string()),
workspace_id: state.workspace_id.clone(),
new_revision: state.revision,
conflict_detection_key: state.conflict_detection_key.clone(),
changed_blocks,
warnings: vec![],
blocked: false,
risk: "low".to_string(),
})
}
fn build_block_delta_from_state(
state: &EditorDocumentState,
document_id: &str,
command: &EditorCommand,
) -> Result<BlockDelta, WebError> {
let operations = match command {
EditorCommand::ReplaceBlock(cmd) => {
let block = state
.document
.blocks
.iter()
.find(|b| b.block_id == cmd.block_id);
vec![DeltaOperation::ReplaceBlock {
block_id: cmd.block_id.clone(),
text: block_text_from_block(block),
block_type: block.map(|b| block_type_name(&b.block_type)),
}]
}
EditorCommand::InsertBlockAfter(cmd) => {
let new_block = state
.document
.blocks
.iter()
.find(|b| b.block_id == cmd.block.block_id);
vec![DeltaOperation::InsertBlockAfter {
anchor_block_id: cmd.after_block_id.clone(),
block_id: cmd.block.block_id.clone(),
text: block_text_from_block(new_block),
block_type: new_block.map(|b| block_type_name(&b.block_type)),
}]
}
EditorCommand::DeleteBlock(cmd) => {
vec![DeltaOperation::DeleteBlock {
block_id: cmd.block_id.clone(),
}]
}
EditorCommand::MoveBlock(cmd) => {
let anchor = cmd.after_block_id.clone().unwrap_or_default();
vec![DeltaOperation::MoveBlock {
block_id: cmd.block_id.clone(),
anchor_block_id: anchor,
}]
}
_ => vec![],
};
Ok(BlockDelta {
document_id: document_id.to_string(),
revision: state.revision,
conflict_detection_key: state.conflict_detection_key.clone(),
operations,
})
}
fn extract_changed_blocks(
document: &EditorBlockDocument,
command: &EditorCommand,
@@ -1,4 +1,5 @@
use crate::context::RequestContext;
use crate::routes::api_access_token::{bearer_mnpat1, verify_pat_token};
use crate::routes::vault_extension_token::{bearer_mnext1, verify_extension_token};
use axum::extract::Request;
use axum::middleware::Next;
@@ -8,23 +9,56 @@ pub async fn inject_request_context(mut request: Request, next: Next) -> Respons
let mut context =
RequestContext::from_http_parts(request.method(), request.uri(), request.headers());
// 12-3 E2: Authorization Bearer mnext1.* → actor (when cookie/header actor is anonymous)
if context.auth.actor_id.trim() == "anonymous" || context.auth.actor_id.trim().is_empty() {
// 7-76Bearer mnpat1.* 优先于 cookiePAT 请求忽略 cookie 并权)。
if let Some(token) = bearer_mnpat1(context.auth.authorization.as_deref()) {
if let Ok(verified) = verify_pat_token(token) {
if let Some(actor) = crate::context::stable_actor_id(&verified.subject_user_id) {
context.auth.actor_id = actor;
context.auth.actor_type = if verified.claims.principal_kind == "ai_service" {
"ai_service".into()
} else {
"user".into()
};
context.auth.auth_method = "pat".into();
context.auth.scopes = verified.claims.scope.clone();
context.auth.pat_jti = Some(verified.claims.jti.clone());
context.auth.session_id = Some(format!("pat:{}", verified.claims.jti));
// 清除 cookie 头语义:后续 current_actor_id 见 auth_method=pat 短路
context.auth.cookie_header = None;
}
}
}
// 12-3 E2: Authorization Bearer mnext1.* → actor(仅当尚未 PAT / 仍 anonymous
if context.auth.auth_method != "pat"
&& (context.auth.actor_id.trim() == "anonymous" || context.auth.actor_id.trim().is_empty())
{
if let Some(token) = bearer_mnext1(context.auth.authorization.as_deref()) {
if let Ok(claims) = verify_extension_token(token) {
context.auth.actor_id = claims.actor;
if context.auth.actor_type.trim().is_empty()
|| context.auth.actor_type.trim() == "anonymous"
{
context.auth.actor_type = "user".into();
}
if context.auth.session_id.is_none() {
context.auth.session_id = Some(format!("ext:{}", claims.jti));
if let Some(actor) = crate::context::stable_actor_id(&claims.actor) {
context.auth.actor_id = actor;
if context.auth.actor_type.trim().is_empty()
|| context.auth.actor_type.trim() == "anonymous"
{
context.auth.actor_type = "user".into();
}
context.auth.auth_method = "extension".into();
if context.auth.session_id.is_none() {
context.auth.session_id = Some(format!("ext:{}", claims.jti));
}
}
}
}
}
// cookie actor 已存在时标记 session 方法(真正 session 校验仍在 current_actor_id
if context.auth.auth_method == "anonymous"
&& context.auth.actor_id.trim() != "anonymous"
&& !context.auth.actor_id.trim().is_empty()
{
context.auth.auth_method = "session".into();
}
request.extensions_mut().insert(context.clone());
let mut response = next.run(request).await;
@@ -9,7 +9,10 @@ use bridge_runtime::{
};
use serde_json::{json, Value};
use std::fs;
use std::path::PathBuf;
use std::io::Write;
use std::path::Path;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
pub async fn create_summary(
state: &AppState,
@@ -79,7 +82,8 @@ async fn create_artifact_node(
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
.with_context(context)
})?;
ensure_local_workspace_access(context, &root_uri)
// 使用 ensure 返回的规范化路径,避免权限检查与 I/O 路径不一致。
let root_path = ensure_local_workspace_access(context, &root_uri)
.map_err(|error| error.with_context(context))?;
if input.dry_run.unwrap_or(false) {
return Ok(json!({
@@ -93,7 +97,6 @@ async fn create_artifact_node(
"diff": [{"op": "create_artifact", "artifactType": node_type}]
}));
}
let root_path = parse_local_root_path(&root_uri)?;
let artifact_dir = root_path.join(".mnote").join("artifacts");
fs::create_dir_all(&artifact_dir).map_err(|error| {
WebError::bad_request_code(
@@ -105,10 +108,12 @@ async fn create_artifact_node(
)
.with_context(context)
})?;
let artifact_path = artifact_dir.join(format!(
"{}.json",
sanitize_local_artifact_file_name(&artifact_document_id)
));
let artifact_file_name =
format!("{}.json", sanitize_local_artifact_file_name(&artifact_document_id));
let artifact_path = artifact_dir.join(&artifact_file_name);
let created_at = OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into());
let artifact_value = json!({
"schema": "mnote.local_artifact.v1",
"artifactType": node_type,
@@ -116,16 +121,14 @@ async fn create_artifact_node(
"documentId": document_id,
"workspaceId": workspace_id,
"content": content,
"createdAt": context.trace.trace_id,
"createdAt": created_at,
"traceId": context.trace.trace_id,
});
fs::write(
&artifact_path,
serde_json::to_string_pretty(&artifact_value).map_err(|error| {
WebError::internal(format!("本地 artifact 序列化失败: {error}"))
.with_context(context)
})?,
)
.map_err(|error| {
let artifact_body = serde_json::to_string_pretty(&artifact_value).map_err(|error| {
WebError::internal(format!("本地 artifact 序列化失败: {error}")).with_context(context)
})?;
// tmp + fsync + rename,降低并发写半截文件 / 丢失更新风险(对齐 vault atomic_write)。
atomic_write_string(&artifact_path, &artifact_body).map_err(|error| {
WebError::bad_request_code(
"local_artifact_write_failed",
format!(
@@ -135,6 +138,8 @@ async fn create_artifact_node(
)
.with_context(context)
})?;
// 响应只返回相对路径,避免泄露服务器绝对路径。
let relative_artifact_path = format!(".mnote/artifacts/{artifact_file_name}");
return Ok(json!({
"dryRun": false,
"commandName": "tree.node.create",
@@ -147,7 +152,7 @@ async fn create_artifact_node(
"result": {
"ok": true,
"source": "local_folder",
"artifactPath": artifact_path,
"artifactPath": relative_artifact_path,
"artifactDocumentId": artifact_document_id,
}
}));
@@ -248,21 +253,6 @@ async fn create_artifact_node(
}))
}
fn parse_local_root_path(root_uri: &str) -> Result<PathBuf, WebError> {
let root_path = if let Some(stripped) = root_uri.trim().strip_prefix("file://") {
stripped.trim()
} else {
root_uri.trim()
};
if root_path.is_empty() {
return Err(WebError::bad_request_code(
"local_folder_root_required",
"缺少本地文件夹 rootUri",
));
}
Ok(PathBuf::from(root_path))
}
fn sanitize_local_artifact_file_name(value: &str) -> String {
value
.chars()
@@ -275,6 +265,21 @@ fn sanitize_local_artifact_file_name(value: &str) -> String {
.to_string()
}
/// 本地 artifact 原子写:先写同目录临时文件,sync 后 rename 覆盖目标。
fn atomic_write_string(path: &Path, content: &str) -> Result<(), std::io::Error> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("json.tmp");
{
let mut file = fs::File::create(&tmp)?;
file.write_all(content.as_bytes())?;
let _ = file.sync_all();
}
fs::rename(&tmp, path)?;
Ok(())
}
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
crate::mnote_agent_tools::ensure_write_authorized(context, input)
}
@@ -323,6 +323,8 @@ pub async fn block_move_after(
&anchor_block_id,
"move_after_anchor",
)?;
// move_after 对复杂/不可编辑块走 soft-block200 + blocked),便于 agent 规划;
// 与 replace/delete 的硬失败不同,避免把「暂不支持」误报成工具调用错误。
let same_parent = block.get("parentBlockId") == anchor.get("parentBlockId");
let leaf = block
.get("children")
@@ -612,12 +614,21 @@ pub async fn doc_apply_block_ops(
&anchor_block_id,
"move_after_anchor",
)?;
if block.get("parentBlockId") != anchor.get("parentBlockId")
// 与单个 block_move_after 一致:仅 paragraph/heading/todo/task 可移动。
let movable_type = block
.get("type")
.and_then(Value::as_str)
.map(|block_type| {
matches!(block_type, "paragraph" | "heading" | "todo" | "task")
})
.unwrap_or(false);
if !movable_type
|| block.get("parentBlockId") != anchor.get("parentBlockId")
|| block_id == anchor_block_id
{
return Err(WebError::bad_request_code(
"mnote_block_unsupported",
"move_after 批量快路径第一阶段仅支持同父级普通叶子块",
"move_after 批量快路径第一阶段仅支持同父级普通叶子块paragraph/heading/todo/task",
)
.with_context(context));
}
@@ -988,15 +999,27 @@ fn value_label(value: &Value) -> Option<String> {
.map(ToOwned::to_owned)
}
/// 将 revision 规范化为 u64 JSON;无法解析时保留原值,避免静默变成 null 绕过冲突检测。
fn revision_number_value(value: Value) -> Option<Value> {
if value.is_null() {
return None;
}
if let Some(number) = value.as_u64() {
return Some(json!(number));
}
value
.as_str()
.map(str::trim)
.and_then(|value| value.parse::<u64>().ok())
.map(|number| json!(number))
if let Some(number) = value.as_i64() {
if number >= 0 {
return Some(json!(number as u64));
}
}
if let Some(raw) = value.as_str().map(str::trim).filter(|v| !v.is_empty()) {
if let Ok(number) = raw.parse::<u64>() {
return Some(json!(number));
}
// 非数值字符串:原样保留,由下游冲突检测处理,不丢 revision。
return Some(json!(raw));
}
Some(value)
}
fn same_parent_blocks(blocks: &[Value], block: &Value) -> Vec<Value> {
@@ -1063,30 +1086,15 @@ fn compute_next_content_via_actor(
})?;
let workspace_id = input.effective_workspace_id();
// 确保 actor 已加载此文档
if !state.editor_actor.is_loaded(&document_id) {
state
.editor_actor
.load_or_init(&document_id, workspace_id.as_deref(), aggregate)?;
}
// 单次写锁内完成 load + apply + export,避免并发交错损坏内存态。
let (content, delta) = state.editor_actor.apply_command_and_export(
&document_id,
workspace_id.as_deref(),
aggregate,
command.clone(),
command_name,
)?;
// 在内存中 apply
let _apply_result =
state
.editor_actor
.apply_command(&document_id, command.clone(), command_name)?;
// 从 actor 获取 legacy content(用于 Convex save 的 payload
let content = state.editor_actor.legacy_content_for_save(&document_id)?;
// 构建 BlockDeltaPhase B)并序列化为 JSON
let delta = state
.editor_actor
.build_block_delta(&document_id, command)
.ok()
.and_then(|bd| serde_json::to_value(bd).ok());
// Phase C:将 block.delta 推送到 broadcast 广播(SSE 事件 stream
if let Some(ref delta_json) = delta {
state.editor_actor.try_push_block_delta(delta_json);
}
@@ -1238,7 +1246,17 @@ pub(crate) async fn execute_page_body_save_from_aggregate(
}))
}
/// 递归抽取 content 文本的最大深度,防止恶意/异常嵌套导致栈溢出。
const CONTENT_TO_TEXT_MAX_DEPTH: usize = 32;
fn content_to_text(value: &Value) -> String {
content_to_text_limited(value, CONTENT_TO_TEXT_MAX_DEPTH)
}
fn content_to_text_limited(value: &Value, depth: usize) -> String {
if depth == 0 {
return String::new();
}
if let Some(text) = value.as_str() {
return text.to_string();
}
@@ -1246,18 +1264,18 @@ fn content_to_text(value: &Value) -> String {
return text.to_string();
}
if let Some(payload) = value.get("payload") {
return content_to_text(payload);
return content_to_text_limited(payload, depth - 1);
}
if let Some(content_nodes) = value.get("contentNodes") {
return content_to_text(content_nodes);
return content_to_text_limited(content_nodes, depth - 1);
}
if let Some(content) = value.get("content") {
return content_to_text(content);
return content_to_text_limited(content, depth - 1);
}
if let Some(items) = value.as_array() {
return items
.iter()
.map(content_to_text)
.map(|item| content_to_text_limited(item, depth - 1))
.collect::<Vec<_>>()
.join("");
}
@@ -1271,8 +1289,11 @@ fn ensure_allowed_target(
block_id: &str,
op: &str,
) -> Result<(), WebError> {
let allowed = allowed_target_block_ids(input, operation);
if allowed.is_empty() || allowed.contains(block_id) {
// None = 未声明 selection 限制(整页写权限路径);Some = 显式限制(含空数组 fail-closed)。
let Some(allowed) = allowed_target_block_scope(input, operation) else {
return Ok(());
};
if allowed.contains(block_id) {
return Ok(());
}
Err(WebError::bad_request_code(
@@ -1282,9 +1303,17 @@ fn ensure_allowed_target(
.with_context(context))
}
fn allowed_target_block_ids(input: &ToolCallInput, operation: &Value) -> HashSet<String> {
/// 解析 `allowedTargetBlockIds` 作用域。
/// - `None`input/operation 均未提供该字段 → 不施加 selection 限制
/// - `Some(set)`:至少一处显式提供(含空数组)→ fail-closed,仅集合内 block 可写
fn allowed_target_block_scope(
input: &ToolCallInput,
operation: &Value,
) -> Option<HashSet<String>> {
let mut scope_declared = false;
let mut allowed = HashSet::new();
if let Some(Value::Array(values)) = input.arg_value("allowedTargetBlockIds") {
scope_declared = true;
for value in values {
if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) {
allowed.insert(id.to_string());
@@ -1295,13 +1324,18 @@ fn allowed_target_block_ids(input: &ToolCallInput, operation: &Value) -> HashSet
.get("allowedTargetBlockIds")
.and_then(Value::as_array)
{
scope_declared = true;
for value in values {
if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) {
allowed.insert(id.to_string());
}
}
}
allowed
if scope_declared {
Some(allowed)
} else {
None
}
}
fn block_to_ai_content(
@@ -1550,4 +1584,21 @@ mod tests {
assert_eq!(content_to_text(&value), "agent 插件插入段");
}
#[test]
fn content_to_text_stops_at_max_depth() {
// 构造远超 CONTENT_TO_TEXT_MAX_DEPTH 的 payload 链,验证不 panic 且深度耗尽后返回空。
let mut nested = json!({"text": "deep-leaf"});
for _ in 0..(CONTENT_TO_TEXT_MAX_DEPTH + 8) {
nested = json!({ "payload": nested });
}
assert_eq!(content_to_text(&nested), "");
// 深度内仍可读到文本。
let mut shallow = json!({"text": "ok"});
for _ in 0..3 {
shallow = json!({ "payload": shallow });
}
assert_eq!(content_to_text(&shallow), "ok");
}
}
@@ -524,20 +524,39 @@ pub(crate) fn ensure_ai_scope_resource_allowed(
let Some(scope) = input.arg_value("aiAccessScope") else {
return Ok(());
};
let allowed = scope
// 与 resource ensure_resource_scope_allowed 对齐:
// - 未声明 allowedResourceIds → 不按资源 id 收紧
// - 声明了但非数组 / 空数组 → fail-closed
// - 声明了非空数组 → 仅白名单放行
let raw = scope
.get("allowedResourceIds")
.or_else(|| scope.get("allowed_resource_ids"))
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<HashSet<_>>()
})
.unwrap_or_default();
if allowed.is_empty() || allowed.contains(document_id) {
.or_else(|| scope.get("allowed_resource_ids"));
let Some(raw) = raw else {
return Ok(());
};
let Some(values) = raw.as_array() else {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_invalid",
"aiAccessScope.allowedResourceIds 必须是字符串数组",
)
.with_context(context));
};
let allowed = values
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<HashSet<_>>();
if allowed.is_empty() {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_empty",
"aiAccessScope.allowedResourceIds 为空,拒绝读取任何资源",
)
.with_context(context));
}
if allowed.contains(document_id) {
return Ok(());
}
Err(WebError::new(
@@ -575,6 +594,19 @@ fn local_root_uri_for_tool(input: &ToolCallInput) -> Option<String> {
})
}
/// full_content 创建场景:仅文档缺失可当空文档;鉴权/越权/网络等错误必须传播。
fn is_document_missing_for_full_content(error: &WebError) -> bool {
matches!(
error.code(),
"local_markdown_not_found"
| "page_not_found"
| "document_not_found"
| "mnote_resource_page_not_found"
| "local_file_unavailable"
| "local_file_not_file"
) || error.status() == StatusCode::NOT_FOUND
}
pub(crate) fn block_projection_blocks(aggregate: &Value) -> Vec<Value> {
aggregate
.pointer("/body/blockDocument/blocks")
@@ -804,15 +836,18 @@ fn extract_block_comment(line: &str) -> (Option<String>, Option<(String, String,
(None, None)
}
/// 提取 `[mnote-raw-block:ID]` 标记
/// 提取 `[mnote-raw-block:ID]` 标记
/// 前缀长度为 17`[mnote-raw-block:`);`]` 必须在 marker 之后,避免 panic / 截断首字符。
fn extract_raw_block_marker(line: &str) -> Option<String> {
let start = line.find("[mnote-raw-block:");
let end = line.find(']');
if let (Some(start), Some(end)) = (start, end) {
let id = &line[start + 18..end];
return Some(id.to_string());
const MARKER: &str = "[mnote-raw-block:";
let start = line.find(MARKER)?;
let id_start = start + MARKER.len();
let end = line[id_start..].find(']')? + id_start;
let id = line[id_start..end].trim();
if id.is_empty() {
return None;
}
None
Some(id.to_string())
}
/// 从行前缀推断块类型
@@ -1140,6 +1175,9 @@ fn blocks_to_content(
"page_xml" | "xml" => blocks_to_page_xml(blocks, document_id, aggregate),
"text" | "plain" => blocks_to_text(blocks, include_ids),
"markdown" | "md" => blocks_to_markdown(blocks, include_ids),
// 默认 format=jsoncontent 返回 blocks 的 JSON 文本,与 format 字段一致;
// 结构化数组仍在响应的 blocks 字段中。
"json" => serde_json::to_string(blocks).unwrap_or_else(|_| "[]".into()),
_ => blocks_to_markdown(blocks, include_ids),
}
}
@@ -1291,6 +1329,45 @@ mod tests {
assert!(result.unwrap_err().contains("无法匹配"));
}
#[test]
fn test_search_replace_not_found_utf8_long_needle_no_panic() {
// 多字节中文 needle 超过 60 字符时,错误消息截断不得 panic。
// 使用与原文差异极大的 needle,避免 Level3 fuzzy30% 容限)误匹配。
let needle: String = format!("UNIQUE_NEEDLE_{}", "".repeat(80));
let result = search_replace("完全不同的短文内容ABC", &needle, "x");
let err = result.expect_err("应无法匹配");
assert!(err.contains("无法匹配"), "err={err}");
assert!(err.contains("..."), "err={err}");
// 截断后总长合理(前缀 60 字 + "..." + 引号包装)
assert!(err.chars().count() < 120, "err len={}", err.chars().count());
}
#[test]
fn test_truncate_for_error_char_safe() {
assert_eq!(truncate_for_error("abc", 10), "abc");
assert_eq!(truncate_for_error("abcdefghij", 5), "abcde...");
let s: String = "".repeat(70);
let t = truncate_for_error(&s, 60);
assert!(t.ends_with("..."));
assert_eq!(t.chars().count(), 63); // 60 + "..."
}
#[test]
fn test_fuzzy_match_rejects_pattern_longer_than_text() {
let long: String = "".repeat(80);
assert!(!fuzzy_match("短文", &long, 0.3));
assert!(fuzzy_match("第一段内容差不多", "第一段内容差不", 0.3));
}
#[test]
fn test_search_replace_fuzzy_paragraph_no_unwrap_panic() {
// Level3:段落 fuzzy 命中后必须安全替换,不依赖 find().unwrap()。
let text = "引言。\n\n第一段内容差不多。\n\n结尾。";
let result = search_replace(text, "第一段内容差不", "替换段").expect("fuzzy 应命中");
assert!(result.contains("替换段"), "result={result}");
assert!(!result.contains("第一段内容差不多"), "result={result}");
}
#[test]
fn test_search_replace_full_content() {
// 全文替换:search 等于全文
@@ -1335,6 +1412,30 @@ mod tests {
assert!(md.contains("<!-- block:rsc_1:resource -->"));
}
#[test]
fn test_extract_raw_block_marker_keeps_full_id() {
assert_eq!(
extract_raw_block_marker("[mnote-raw-block:rsc_1] <!-- block:rsc_1:resource -->")
.as_deref(),
Some("rsc_1")
);
// 前缀 `]` 不得影响 marker 后的 id 截取
assert_eq!(
extract_raw_block_marker("prefix] [mnote-raw-block:abc_9] tail").as_deref(),
Some("abc_9")
);
assert_eq!(extract_raw_block_marker("no marker here"), None);
}
#[test]
fn test_blocks_to_content_json_format() {
let blocks = json!([{"blockId": "b1", "text": "x", "type": "paragraph"}]);
let blocks: Vec<Value> = blocks.as_array().unwrap().clone();
let content = blocks_to_content("json", &blocks, true, "doc_1", &json!({}));
let parsed: Value = serde_json::from_str(&content).expect("json content");
assert_eq!(parsed[0]["blockId"], "b1");
}
#[test]
fn test_build_page_content_full_content_replaces_old_text_blocks_but_keeps_complex_blocks() {
let original_content = json!([
@@ -1489,23 +1590,43 @@ pub async fn doc_markdown_edit(
let workspace_id = input.effective_workspace_id();
let source_kind = input.effective_source_kind();
let root_uri = input.effective_root_uri();
let is_local_file = document_id.starts_with('/') || document_id.starts_with("./");
let is_local_file = document_id.starts_with('/')
|| document_id.starts_with("./")
|| document_id.starts_with("file://");
let is_local_workspace =
source_kind.as_deref() == Some("local_folder") && root_uri.as_deref().is_some();
crate::mnote_agent_tools::block::ensure_write_contract(context, input)?;
// 本地绝对/相对路径写:强制 rootUri + 路径守卫(防穿越)
let local_file_path = if is_local_file {
let tool_root = local_root_uri_for_tool(input).ok_or_else(|| {
WebError::new(
StatusCode::FORBIDDEN,
"ai_scope_root_uri_required",
"本地文件写入需要授权 rootUri",
)
.with_context(context)
})?;
Some(
crate::routes::ensure_local_path_write_access(context, &tool_root, &document_id)
.map_err(|error| error.with_context(context))?,
)
} else {
None
};
// 1. 读取当前文档内容(markdown 形式)
let (current_md, source) = if is_local_file {
let (current_md, source) = if let Some(ref path) = local_file_path {
use std::fs;
// full_content 模式时允许文件不存在(创建新文件)
let has_full = input.arg_value("full_content").is_some();
let content = match fs::read_to_string(&document_id) {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) if has_full => String::new(), // 创建模式:空内容
Err(error) => {
return Err(WebError::bad_request_code(
"mnote_tool_bad_request",
format!("无法读取本地文件 {document_id}: {error}"),
format!("无法读取本地文件 {}: {error}", path.display()),
)
.with_context(context));
}
@@ -1629,23 +1750,24 @@ pub async fn doc_markdown_edit(
};
// 5. 写回(本地文件直接 fs::writeConvex 文档通过 block ops apply
let apply_result = if is_local_file {
let apply_result = if let Some(ref path) = local_file_path {
if input.dry_run.unwrap_or(false) {
json!({"written": false, "dryRun": true, "path": document_id.clone()})
json!({"written": false, "dryRun": true, "path": path.display().to_string()})
} else {
use std::fs;
fs::write(&document_id, &md).map_err(|error| {
fs::write(path, &md).map_err(|error| {
WebError::bad_request_code(
"mnote_tool_bad_request",
format!("无法写入本地文件 {document_id}: {error}"),
format!("无法写入本地文件 {}: {error}", path.display()),
)
.with_context(context)
})?;
json!({"written": true, "path": document_id.clone()})
json!({"written": true, "path": path.display().to_string()})
}
} else {
// 7-27: 在线写回以最终 markdown 为真源,直接生成 block content
// 与 /api/documents/save 共用同一个 RuntimeCommandEnvelopeWire 路径
// full_content 仅对「文档不存在」类错误放行为空文档;鉴权/网络等错误 fail-closed
let (aggregate, blocks, original_content) =
match aggregate_value(state, context, input).await {
Ok(agg) => {
@@ -1653,8 +1775,10 @@ pub async fn doc_markdown_edit(
let original_content = crate::mnote_agent_tools::block::current_body_content(&agg);
(agg, blocks, original_content)
}
Err(_) if use_full_content.is_some() => {
// 空文档 + full_content:跳过读取
Err(error)
if use_full_content.is_some()
&& is_document_missing_for_full_content(&error) =>
{
(Value::Null, vec![], json!([]))
}
Err(e) => return Err(e),
@@ -1817,20 +1941,27 @@ fn search_replace(text: &str, search: &str, replace: &str) -> Result<String, Str
// Level 3: 按段落 fuzzy30% 字符差异容限)
for para in text.split("\n\n") {
if fuzzy_match(para, search, 0.3) {
let idx = text.find(para).unwrap();
// para 来自 split,正常必是 text 子串;不用 unwrap,避免极端 Unicode 规范化下 panic。
let Some(idx) = text.find(para) else {
continue;
};
let replaced = format!("{}{}{}", &text[..idx], replace, &text[idx + para.len()..]);
return Ok(replaced);
}
}
// Level 4: 失败
Err(format!(
"无法匹配 \"{}\"",
if search.len() > 60 {
format!("{}...", &search[..60])
} else {
search.to_string()
}
))
// Level 4: 失败(按字符截断,避免 UTF-8 多字节边界 panic
Err(format!("无法匹配 \"{}\"", truncate_for_error(search, 60)))
}
/// 按 Unicode 标量截断用于错误消息;不在 UTF-8 字节中间切开。
fn truncate_for_error(s: &str, max_chars: usize) -> String {
let mut it = s.chars();
let head: String = it.by_ref().take(max_chars).collect();
if it.next().is_some() {
format!("{head}...")
} else {
head
}
}
fn search_replace_exact_or_normalized(text: &str, search: &str, replace: &str) -> Option<String> {
@@ -1896,9 +2027,17 @@ fn normalize_search_char(ch: char) -> Option<char> {
fn fuzzy_match(text: &str, pattern: &str, max_diff_ratio: f64) -> bool {
let text_chars: Vec<char> = text.chars().collect();
let pat_chars: Vec<char> = pattern.chars().collect();
if pat_chars.is_empty() {
return false;
}
// pattern 比 text 长时无法在 text 上形成等长窗口;旧逻辑用 min 缩短窗口,
// 会把 max_dist 相对长 needle 放大到「几乎任意短段落都命中」。
if pat_chars.len() > text_chars.len() {
return false;
}
let max_dist = (pat_chars.len() as f64 * max_diff_ratio).ceil() as usize;
// 简单的滑动窗口匹配
for window in text_chars.windows(pat_chars.len().min(text_chars.len())) {
// 等长滑动窗口:只比较完整 pattern 窗口
for window in text_chars.windows(pat_chars.len()) {
let dist = window
.iter()
.zip(pat_chars.iter())
@@ -62,6 +62,8 @@ pub async fn index_refresh(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
// 索引刷新会写本地索引文件,只读 scope / ai_can_write=false 必须拒绝。
ensure_write_authorized(context, input)?;
let workspace_id = effective_workspace_id(context, input);
let root_uri = required_root_uri(
context,
@@ -70,7 +72,7 @@ pub async fn index_refresh(
"本地索引刷新缺少 rootUri",
)?;
let root_path =
routes::ensure_local_workspace_read_access_with_state(state, context, &root_uri)
routes::ensure_local_workspace_write_access_with_state(state, context, &root_uri)
.map_err(|error| error.with_context(context))?;
let effective_settings = routes::effective_local_index_settings_for_root(
state.control_plane(),
@@ -14,18 +14,29 @@ pub async fn status(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let args = input.args.clone().unwrap_or_else(|| json!({}));
// status 与 inject_identity 一致:context / 顶层绑定优先,不采用 args 自报身份。
let body = KnowledgeRagStatusQuery {
workspace_id: input.effective_workspace_id().or_else(|| {
args.get("workspaceId")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}),
root_uri: input.effective_root_uri().or_else(|| {
args.get("rootUri")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}),
workspace_id: context
.workspace
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
input
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}),
root_uri: input
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
};
let Json(payload) = crate::routes::knowledge_rag::status(
State(state.clone()),
@@ -42,12 +53,12 @@ pub async fn search(
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
ensure_weknora_scope(&args, input, context)?;
inject_identity_args(&mut args, input);
ensure_knowledge_tool_scope(&args, input, context)?;
inject_identity_args(&mut args, input, context);
let body = serde_json::from_value::<KnowledgeRagSearchRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_weknora_search_payload_invalid",
format!("WeKnora 检索参数无效: {error}"),
"mnote_knowledge_search_payload_invalid",
format!("知识库检索参数无效: {error}"),
)
.with_context(context)
})?;
@@ -66,7 +77,7 @@ pub async fn query(
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
inject_identity_args(&mut args, input);
inject_identity_args(&mut args, input, context);
let body = serde_json::from_value::<KnowledgeRagQueryRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_knowledge_rag_query_payload_invalid",
@@ -89,8 +100,8 @@ pub async fn open_reference(
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
ensure_weknora_scope(&args, input, context)?;
inject_identity_args(&mut args, input);
ensure_knowledge_tool_scope(&args, input, context)?;
inject_identity_args(&mut args, input, context);
let body =
serde_json::from_value::<KnowledgeRagOpenReferenceRequest>(args).map_err(|error| {
WebError::bad_request_code(
@@ -114,7 +125,7 @@ pub async fn section_context(
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
inject_identity_args(&mut args, input);
inject_identity_args(&mut args, input, context);
let body =
serde_json::from_value::<KnowledgeRagSectionContextRequest>(args).map_err(|error| {
WebError::bad_request_code(
@@ -138,16 +149,16 @@ pub async fn list_sources(
input: &ToolCallInput,
) -> Result<Value, WebError> {
let args = input.args.clone().unwrap_or_else(|| json!({}));
ensure_weknora_scope(&args, input, context)?;
ensure_knowledge_tool_scope(&args, input, context)?;
let payload = status(state, context, input).await?;
Ok(json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": "mnote.weknora.sources_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"schema": "mnote.knowledge.sources_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"providerConfig": payload.get("providerConfig").cloned().unwrap_or(Value::Null),
"registry": payload.get("registry").cloned().unwrap_or(Value::Null),
"documents": payload.get("documents").cloned().unwrap_or(Value::Null),
"locatorPolicy": "WeKnora provider ids are returned separately; filenames and chunk ids are not local paths.",
"locatorPolicy": "Provider ids are returned separately; filenames and chunk ids are not local paths.",
}))
}
@@ -157,7 +168,7 @@ pub async fn get_source_status(
input: &ToolCallInput,
) -> Result<Value, WebError> {
let args = input.args.clone().unwrap_or_else(|| json!({}));
ensure_weknora_scope(&args, input, context)?;
ensure_knowledge_tool_scope(&args, input, context)?;
let payload = list_sources(state, context, input).await?;
let requested_source = args
.get("sourcePath")
@@ -186,28 +197,50 @@ pub async fn get_source_status(
.collect::<Vec<_>>();
Ok(json!({
"ok": true,
"schema": "mnote.weknora.source_status_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"schema": "mnote.knowledge.source_status_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"source": if matched.len() == 1 { matched[0].clone() } else { Value::Null },
"sources": matched,
"requestedSource": requested_source,
}))
}
fn inject_identity_args(args: &mut Value, input: &ToolCallInput) {
if args.get("workspaceId").is_none() {
if let Some(workspace_id) = input.effective_workspace_id() {
args["workspaceId"] = json!(workspace_id);
}
/// 身份字段 fail-closed 注入:
/// 1) `RequestContext` 头/会话 workspace
/// 2) `ToolCallInput` **顶层** workspaceId/rootUri(宿主绑定)
/// 绝不采用 `args` 里的用户自报身份(防跨 workspace 劫持)。
fn inject_identity_args(args: &mut Value, input: &ToolCallInput, context: &RequestContext) {
let workspace_id = context
.workspace
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
input
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
});
if let Some(workspace_id) = workspace_id {
args["workspaceId"] = json!(workspace_id);
}
if args.get("rootUri").is_none() {
if let Some(root_uri) = input.effective_root_uri() {
args["rootUri"] = json!(root_uri);
}
// rootUri 不在 RequestContext;仅信任 tool 顶层绑定,不读 args。
if let Some(root_uri) = input
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
args["rootUri"] = json!(root_uri);
}
}
fn ensure_weknora_scope(
fn ensure_knowledge_tool_scope(
args: &Value,
input: &ToolCallInput,
context: &RequestContext,
@@ -229,8 +262,8 @@ fn ensure_weknora_scope(
return Ok(());
}
Err(WebError::bad_request_code(
"mnote_weknora_scope_required",
"WeKnora tool 调用必须包含 rootUri 以及 scope/allowlist/allowedRoots/aiAccessScope/sourcePaths 之一",
"mnote_knowledge_scope_required",
"knowledge tool 调用必须包含 rootUri 以及 scope/allowlist/allowedRoots/aiAccessScope/sourcePaths 之一",
)
.with_context(context))
}
@@ -297,7 +330,7 @@ pub(crate) fn compact_query_result_for_agent(payload: Value) -> Value {
json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": "mnote.knowledge_rag.agent_query_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"answerGuidance": "Final answers should answer the user in plain text. Do not copy citationMarkdown, citationUrl, /documents, mnote://, or search-engine wrapped local links into the answer. MNote UI will render citations[] / references[].citationMarkdown after the answer as clickable source locators. Treat rawMetadata.keywords and entity/relation counts only as query analysis, not proof that a source contains those words. Use references[].quote/contentDiagnostics as evidence. For book-like or skip-KG documents, call the tool with sourcePaths and includeDocumentStructureIndex=true; MNote fixes their effective retrieval mode to naive because they intentionally do not build KG. If documentStructureIndex is present, use it as a section map and call mnote.knowledge_rag.section_context with the section range when you need bounded chapter text for second-pass reading; do not cite the map itself unless the same claim appears in references[].quote or section_context text. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox. If contentDiagnostics.ocrTextExposed is false, say OCR text was not exposed in the returned reference instead of claiming OCR succeeded.",
"references": references,
"referenceCount": payload_references.len(),
@@ -643,7 +676,7 @@ pub(crate) fn compact_section_context_for_agent(payload: Value) -> Value {
json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": payload.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.section_context.v1")),
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"sourceId": payload.get("sourceId").cloned().unwrap_or(Value::Null),
"sourceRootRelativePath": payload.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
"lightRagDocId": payload.get("lightRagDocId").cloned().unwrap_or(Value::Null),
@@ -669,7 +702,7 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
.unwrap_or_else(|| quote_diagnostics(&quote));
json!({
"schema": reference.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.reference.v1")),
"provider": reference.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"provider": reference.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"citationId": reference.get("citationId").cloned().unwrap_or(Value::Null),
"citationLabel": reference.get("citationLabel").cloned().unwrap_or(Value::Null),
"filePath": reference.get("filePath").cloned().unwrap_or(Value::Null),
@@ -693,7 +726,7 @@ fn compact_citation_for_agent(citation: &Value) -> Value {
.unwrap_or_default();
json!({
"schema": citation.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.citation.v1")),
"provider": citation.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
"provider": citation.get("provider").cloned().unwrap_or_else(|| json!("lightrag_legacy")),
"citationId": citation.get("citationId").cloned().unwrap_or(Value::Null),
"citationLabel": citation.get("citationLabel").cloned().unwrap_or(Value::Null),
"sourceId": citation.get("sourceId").cloned().unwrap_or(Value::Null),
@@ -1010,4 +1043,79 @@ mod tests {
< 60_000
);
}
#[test]
fn inject_identity_args_prefers_request_context_over_user_args() {
use crate::context::{
AuthContext, RequestContext, SourceContext, TraceContext, WorkspaceContext,
};
use crate::mnote_agent_tools::ToolCallInput;
let context = RequestContext {
trace: TraceContext {
request_id: "req".into(),
trace_id: "tr".into(),
method: "POST".into(),
path: "/api".into(),
},
auth: AuthContext {
authorization: None,
cookie_header: None,
actor_id: "user_1".into(),
actor_type: "user".into(),
session_id: None,
auth_method: "session".into(),
scopes: Vec::new(),
pat_jti: None,
},
workspace: WorkspaceContext {
workspace_id: Some("ws-from-header".into()),
tenant_id: None,
deployment_id: None,
project_id: None,
},
source: SourceContext {
channel: "http".into(),
client: "test".into(),
idempotency_key: None,
},
};
let input = ToolCallInput {
tool_name: "mnote.knowledge_rag.query".into(),
workspace_id: Some("ws-from-top".into()),
document_id: None,
source_kind: None,
root_uri: Some("file:///bound-root".into()),
actor_id: None,
profile: None,
session_id: None,
run_id: None,
tool_call_id: None,
trace_id: None,
idempotency_key: None,
dry_run: None,
capability_scope: None,
args: Some(json!({
"workspaceId": "ws-attacker",
"rootUri": "file:///etc",
"query": "x",
})),
};
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
super::inject_identity_args(&mut args, &input, &context);
assert_eq!(args["workspaceId"].as_str(), Some("ws-from-header"));
assert_eq!(args["rootUri"].as_str(), Some("file:///bound-root"));
// 无 header 时用顶层,仍忽略 args
let context_no_ws = RequestContext {
workspace: WorkspaceContext {
workspace_id: None,
..context.workspace.clone()
},
..context.clone()
};
let mut args2 = input.args.clone().unwrap_or_else(|| json!({}));
super::inject_identity_args(&mut args2, &input, &context_no_ws);
assert_eq!(args2["workspaceId"].as_str(), Some("ws-from-top"));
assert_eq!(args2["rootUri"].as_str(), Some("file:///bound-root"));
}
}
@@ -355,7 +355,7 @@ fn knowledge_rag_status_tool() -> Value {
}
json!({
"name": "mnote.knowledge_rag.status",
"description": "查看当前知识库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 LightRAGWeKnora/RAGFlow 仅作为 env 显式切换的备用 / 调试路径。",
"description": "查看当前知识库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 LightRAGKnowledge RAG/RAGFlow 仅作为 env 显式切换的备用 / 调试路径。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -405,7 +405,7 @@ fn knowledge_rag_query_tool() -> Value {
}
json!({
"name": "mnote.knowledge_rag.query",
"description": "向当前知识库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 LightRAGWeKnora/RAGFlow 仅 env 备用。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
"description": "向当前知识库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 LightRAGKnowledge RAG/RAGFlow 仅 env 备用。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -2096,13 +2096,16 @@ fn onlyoffice_tool(
{ "required": ["bridgeSessionId"] }
]);
}
// 清空幻灯片 / 删除页等会不可逆丢内容,必须标 destructive,避免 AI 当普通写操作。
let destructive =
!readonly && (name.contains("delete") || name.contains("clear_slide"));
json!({
"name": name,
"description": description,
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": capability_scope.into_iter().collect::<Vec<_>>(),
"status": "available",
"annotations": tool_annotations(readonly, false, readonly, false),
"annotations": tool_annotations(readonly, destructive, readonly, destructive),
"inputSchema": input_schema
})
}
@@ -141,15 +141,17 @@ impl ToolCallInput {
}
pub fn ai_access_scope_is_read_only(&self) -> bool {
match self.ai_access_permission_level() {
Some(level) => is_read_only_permission_level(&level),
// 缺 scope / permissionLevel 时不再默认“可写”;由 ensure_write_authorized 统一 fail-closed。
None => false,
}
}
/// 是否显式声明了可写 permissionLevel(缺省不算可写)。
pub fn ai_access_scope_explicitly_allows_write(&self) -> bool {
self.ai_access_permission_level()
.map(|level| {
let normalized = level.trim().to_ascii_lowercase();
normalized == "read"
|| normalized == "readonly"
|| normalized == "read_only"
|| normalized == "shared_read"
|| (normalized.contains("read") && !normalized.contains("write"))
})
.map(|level| !is_read_only_permission_level(&level))
.unwrap_or(false)
}
@@ -164,12 +166,13 @@ impl ToolCallInput {
.or_else(|| ctx.get("workspace_readonly"))
.and_then(Value::as_bool)
.unwrap_or(false);
// 与 core-protocol CommandContext 一致:未显式声明时默认不可写(fail-closed
let ai_can_write = ctx
.get("ai.canWrite")
.or_else(|| ctx.get("aiCanWrite"))
.or_else(|| ctx.get("ai_can_write"))
.and_then(Value::as_bool)
.unwrap_or(true);
.unwrap_or(false);
Some(CommandContextBridge {
workspace_readonly,
ai_can_write,
@@ -191,13 +194,23 @@ pub struct CommandContextBridge {
pub ai_can_write: bool,
}
fn is_read_only_permission_level(level: &str) -> bool {
let normalized = level.trim().to_ascii_lowercase();
normalized == "read"
|| normalized == "readonly"
|| normalized == "read_only"
|| normalized == "shared_read"
|| (normalized.contains("read") && !normalized.contains("write"))
}
/// 统一的 agent tools 写入守卫。检查:
///
/// - `idempotencyKey` 必须存在
/// - `dryRun` 必须显式携带
/// - `aiAccessScope.permissionLevel` 不是只读(来自 ToolCallInput
/// - 如果提供了 `bridge` `ai_can_write == false`拒绝
/// - 如果提供了 `bridge` 且 `workspace_readonly == true`,拒绝
/// - 若声明了只读 `aiAccessScope.permissionLevel`,拒绝
/// - 提供了 `commandContext``workspace.readonly` / `ai.canWrite` 任一拒绝则拒绝
/// - **fail-closed**:必须至少有一方显式授予写权限
/// `aiAccessScope` 可写 permissionLevel,或 `commandContext.ai.canWrite=true`
///
/// 拒绝响应可解释(包含具体原因),不静默成功,不 panic。
pub fn ensure_write_authorized(
@@ -218,7 +231,12 @@ pub fn ensure_write_authorized(
)
.with_context(context));
}
if input.ai_access_scope_is_read_only() {
// 显式只读 scope 优先拒绝(含 shared_read
if input
.ai_access_permission_level()
.as_deref()
.is_some_and(is_read_only_permission_level)
{
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_write_forbidden",
@@ -226,7 +244,9 @@ pub fn ensure_write_authorized(
)
.with_context(context));
}
if let Some(bridge) = input.command_context_bridge() {
let bridge = input.command_context_bridge();
if let Some(bridge) = bridge {
if bridge.workspace_readonly {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
@@ -244,6 +264,20 @@ pub fn ensure_write_authorized(
.with_context(context));
}
}
let scope_allows_write = input.ai_access_scope_explicitly_allows_write();
let bridge_allows_write = bridge
.map(|b| b.ai_can_write && !b.workspace_readonly)
.unwrap_or(false);
// 缺 aiAccessScope 且缺 commandContext(或两者都未授予写)→ fail-closed
if !scope_allows_write && !bridge_allows_write {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_tool_write_authorization_required",
"写入型 mnote agent tool 必须携带可写 aiAccessScope.permissionLevel 或 commandContext.ai.canWrite=true",
)
.with_context(context));
}
Ok(())
}
@@ -298,6 +332,23 @@ mod tests {
assert_eq!(error.code(), "mnote_tool_ai_write_forbidden");
}
#[test]
fn ensure_write_authorized_rejects_missing_ai_can_write_default_false() {
// commandContext 存在但未声明 ai.canWrite → fail-closed 拒绝写
let error = ensure_write_authorized(
&context(),
&write_input(json!({
"commandContext": {
"workspace.readonly": false
}
})),
)
.expect_err("missing ai.canWrite should default false and reject");
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
assert_eq!(error.code(), "mnote_tool_ai_write_forbidden");
}
#[test]
fn ensure_write_authorized_rejects_command_context_readonly_workspace() {
let error = ensure_write_authorized(
@@ -314,4 +365,39 @@ mod tests {
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
assert_eq!(error.code(), "mnote_tool_workspace_readonly");
}
#[test]
fn ensure_write_authorized_rejects_missing_command_context_and_scope() {
// 无 aiAccessScope、无 commandContext → fail-closed,禁止写
let error = ensure_write_authorized(&context(), &write_input(json!({})))
.expect_err("missing write authorization should reject");
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
assert_eq!(error.code(), "mnote_tool_write_authorization_required");
}
#[test]
fn ensure_write_authorized_accepts_explicit_write_scope_without_command_context() {
ensure_write_authorized(
&context(),
&write_input(json!({
"aiAccessScope": { "permissionLevel": "read_write" }
})),
)
.expect("explicit write scope should authorize without commandContext");
}
#[test]
fn ensure_write_authorized_accepts_command_context_write_without_scope() {
ensure_write_authorized(
&context(),
&write_input(json!({
"commandContext": {
"ai.canWrite": true,
"workspace.readonly": false
}
})),
)
.expect("commandContext ai.canWrite=true should authorize without aiAccessScope");
}
}
@@ -311,6 +311,9 @@ pub async fn document_insert_html(
input: &ToolCallInput,
) -> Result<Value, WebError> {
let html = required_string(context, input, "html")?;
// AI / bridge 传入的 HTML 可能含 script / on* / javascript: 等载荷;
// OnlyOffice 虽不完全执行脚本,仍会落盘或外链,必须先净化。
let html = sanitize_insert_html(&html);
run_write_action(
context,
input,
@@ -320,6 +323,234 @@ pub async fn document_insert_html(
.await
}
/// 轻量 HTML 净化:剥危险标签与事件处理器,保留基础排版标签。
/// 不引入 ammonia / regex 依赖;面向 OnlyOffice insert_html 的最小防御。
fn sanitize_insert_html(html: &str) -> String {
let without_blocks = strip_dangerous_html_blocks(html);
strip_dangerous_html_attrs(&without_blocks)
}
const DANGEROUS_BLOCK_TAGS: &[&str] = &[
"script", "style", "iframe", "object", "embed", "link", "meta", "base", "form",
];
fn strip_dangerous_html_blocks(html: &str) -> String {
let lower = html.to_ascii_lowercase();
let bytes = html.as_bytes();
let lower_bytes = lower.as_bytes();
let mut out = String::with_capacity(html.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'<' {
// 拷贝到下一个 '<' 或末尾
let mut j = i + 1;
while j < bytes.len() && bytes[j] != b'<' {
j += 1;
}
out.push_str(&html[i..j]);
i = j;
continue;
}
// 解析标签名
let after_lt = i + 1;
let is_close = after_lt < bytes.len() && bytes[after_lt] == b'/';
let name_start = if is_close { after_lt + 1 } else { after_lt };
// 跳过空白
let mut name_pos = name_start;
while name_pos < lower_bytes.len() && lower_bytes[name_pos].is_ascii_whitespace() {
name_pos += 1;
}
let mut name_end = name_pos;
while name_end < lower_bytes.len()
&& (lower_bytes[name_end].is_ascii_alphanumeric() || lower_bytes[name_end] == b'-' || lower_bytes[name_end] == b':')
{
name_end += 1;
}
let tag = std::str::from_utf8(&lower_bytes[name_pos..name_end]).unwrap_or("");
let is_dangerous = DANGEROUS_BLOCK_TAGS.iter().any(|t| *t == tag);
// 找到本标签结束 '>'(粗处理引号内 > 较少见,足够防御)
let mut tag_end = name_end;
while tag_end < bytes.len() && bytes[tag_end] != b'>' {
tag_end += 1;
}
if tag_end >= bytes.len() {
// 残缺标签:丢弃剩余
break;
}
let self_close = tag_end > i && bytes[tag_end - 1] == b'/';
if !is_dangerous {
out.push_str(&html[i..=tag_end]);
i = tag_end + 1;
continue;
}
// 危险开标签:跳过到匹配闭合(或自闭合)
i = tag_end + 1;
if is_close || self_close {
continue;
}
// 找 </tag ...>
let close_pat = format!("</{tag}");
let close_bytes = close_pat.as_bytes();
while i < lower_bytes.len() {
if lower_bytes[i..].starts_with(close_bytes) {
// 前进到 '>'
let mut k = i + close_bytes.len();
while k < bytes.len() && bytes[k] != b'>' {
k += 1;
}
i = if k < bytes.len() { k + 1 } else { bytes.len() };
break;
}
i += 1;
}
}
out
}
fn strip_dangerous_html_attrs(html: &str) -> String {
let bytes = html.as_bytes();
let mut out = String::with_capacity(html.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'<' {
let mut j = i + 1;
while j < bytes.len() && bytes[j] != b'<' {
j += 1;
}
out.push_str(&html[i..j]);
i = j;
continue;
}
// 标签起点
out.push('<');
i += 1;
// 保留 / 与 tag name + 空白分隔
while i < bytes.len() && bytes[i] != b'>' {
// 尝试读一个属性
// 跳过空白
if bytes[i].is_ascii_whitespace() {
// 窥探下一个属性名
let mut k = i;
while k < bytes.len() && bytes[k].is_ascii_whitespace() {
k += 1;
}
if k >= bytes.len() || bytes[k] == b'>' || bytes[k] == b'/' {
// 空白直接写入到 > 或 /
out.push(bytes[i] as char);
i += 1;
continue;
}
// 读属性名
let name_start = k;
let mut name_end = k;
while name_end < bytes.len()
&& (bytes[name_end].is_ascii_alphanumeric()
|| bytes[name_end] == b'-'
|| bytes[name_end] == b':'
|| bytes[name_end] == b'_')
{
name_end += 1;
}
if name_end == name_start {
out.push(bytes[i] as char);
i += 1;
continue;
}
let attr_name = html[name_start..name_end].to_ascii_lowercase();
// 找 = 与值
let mut p = name_end;
while p < bytes.len() && bytes[p].is_ascii_whitespace() {
p += 1;
}
let mut value = String::new();
let value_end;
if p < bytes.len() && bytes[p] == b'=' {
p += 1;
while p < bytes.len() && bytes[p].is_ascii_whitespace() {
p += 1;
}
if p < bytes.len() && (bytes[p] == b'"' || bytes[p] == b'\'') {
let quote = bytes[p];
p += 1;
let vstart = p;
while p < bytes.len() && bytes[p] != quote {
p += 1;
}
value = html[vstart..p].to_string();
if p < bytes.len() {
p += 1; // closing quote
}
value_end = p;
} else {
let vstart = p;
while p < bytes.len()
&& !bytes[p].is_ascii_whitespace()
&& bytes[p] != b'>'
&& bytes[p] != b'/'
{
p += 1;
}
value = html[vstart..p].to_string();
value_end = p;
}
} else {
value_end = name_end;
}
let drop_attr = should_drop_html_attr(&attr_name, &value);
if drop_attr {
i = value_end;
continue;
}
// 保留:从原空白起点到 value_end
out.push_str(&html[i..value_end]);
i = value_end;
continue;
}
out.push(bytes[i] as char);
i += 1;
}
if i < bytes.len() && bytes[i] == b'>' {
out.push('>');
i += 1;
}
}
out
}
fn should_drop_html_attr(name: &str, value: &str) -> bool {
if name.starts_with("on") && name.len() > 2 {
return true;
}
let url_attrs = [
"href",
"src",
"xlink:href",
"action",
"formaction",
"poster",
"data",
"background",
"dynsrc",
"lowsrc",
];
if !url_attrs.iter().any(|a| *a == name) {
return false;
}
// 折叠空白后判断危险 scheme,防 `java script:` 类绕过的弱变体
let compact: String = value
.chars()
.filter(|c| !c.is_whitespace() && *c != '\0')
.collect::<String>()
.to_ascii_lowercase();
compact.starts_with("javascript:")
|| compact.starts_with("vbscript:")
|| compact.starts_with("data:text/html")
}
pub async fn sheet_set_value(
context: &RequestContext,
input: &ToolCallInput,
@@ -1019,7 +1250,16 @@ fn arg_f64(input: &ToolCallInput, key: &'static str) -> Option<f64> {
_ => None,
})
.and_then(|value| value.as_f64())
.filter(|value| value.is_finite() && *value >= 0.0)
.filter(|value| {
if !value.is_finite() {
return false;
}
// 位置/偏移可负(向左/上);尺寸/字号/线宽必须非负。
match key {
"xOffsetMm" | "yOffsetMm" | "xMm" | "yMm" => true,
_ => *value >= 0.0,
}
})
}
fn arg_bool(input: &ToolCallInput, key: &'static str) -> Option<bool> {
@@ -1128,3 +1368,81 @@ fn bridge_run_error(context: &RequestContext, error: BridgeRunError) -> WebError
}
.with_context(context)
}
#[cfg(test)]
mod tests {
use super::{arg_f64, sanitize_insert_html, should_drop_html_attr};
use crate::mnote_agent_tools::ToolCallInput;
use serde_json::json;
fn input_with_args(args: serde_json::Value) -> ToolCallInput {
ToolCallInput {
tool_name: "mnote.onlyoffice.test".into(),
workspace_id: None,
document_id: None,
source_kind: None,
root_uri: None,
actor_id: None,
profile: None,
session_id: None,
run_id: None,
tool_call_id: None,
trace_id: None,
idempotency_key: None,
dry_run: None,
capability_scope: None,
args: Some(args),
}
}
#[test]
fn arg_f64_allows_negative_offsets_but_rejects_negative_sizes() {
let offsets = input_with_args(json!({
"xOffsetMm": -12.5,
"yMm": -3.0,
"widthMm": -1.0,
"fontSize": -8.0,
"strokeWidthMm": 1.5
}));
assert_eq!(arg_f64(&offsets, "xOffsetMm"), Some(-12.5));
assert_eq!(arg_f64(&offsets, "yMm"), Some(-3.0));
assert_eq!(arg_f64(&offsets, "widthMm"), None);
assert_eq!(arg_f64(&offsets, "fontSize"), None);
assert_eq!(arg_f64(&offsets, "strokeWidthMm"), Some(1.5));
}
#[test]
fn sanitize_strips_script_and_event_handlers() {
let dirty = r#"<p onclick="alert(1)">hi</p><script>evil()</script><b>ok</b>"#;
let clean = sanitize_insert_html(dirty);
assert!(!clean.to_ascii_lowercase().contains("<script"));
assert!(!clean.to_ascii_lowercase().contains("onclick"));
assert!(clean.contains("<b>ok</b>") || clean.contains("ok"));
assert!(clean.contains("hi"));
}
#[test]
fn sanitize_strips_javascript_href() {
let dirty = r#"<a href="javascript:alert(1)">x</a><a href="https://ok.example">y</a>"#;
let clean = sanitize_insert_html(dirty);
assert!(!clean.to_ascii_lowercase().contains("javascript:"));
assert!(clean.contains("https://ok.example"));
}
#[test]
fn sanitize_strips_iframe_and_data_html() {
let dirty = r#"<iframe src="https://evil"></iframe><img src="data:text/html;base64,xx">"#;
let clean = sanitize_insert_html(dirty);
assert!(!clean.to_ascii_lowercase().contains("<iframe"));
assert!(!clean.to_ascii_lowercase().contains("data:text/html"));
}
#[test]
fn should_drop_on_and_js_schemes() {
assert!(should_drop_html_attr("onclick", "x"));
assert!(should_drop_html_attr("href", "javascript:alert(1)"));
assert!(should_drop_html_attr("href", " java\tscript:alert(1)"));
assert!(!should_drop_html_attr("href", "https://example.com"));
assert!(!should_drop_html_attr("class", "foo"));
}
}
@@ -360,13 +360,17 @@ async fn resolve_page_save_content(
.with_context(context)
})?;
let workspace_id = input.effective_workspace_id();
// 与 page_get 一致:local_folder 等非默认源必须带上 source_kind/root_uri
// 否则 append/prepend 会读到错误 workspace 的当前正文。
let source_kind = input.effective_source_kind();
let root_uri = input.effective_root_uri();
let aggregate = build_page_aggregate_snapshot(
state,
context,
&document_id,
workspace_id.as_deref(),
None,
None,
source_kind.as_deref(),
root_uri.as_deref(),
)
.await?;
let aggregate_value = serde_json::to_value(&aggregate)
@@ -394,6 +394,15 @@ impl ResourceToolTarget {
})?;
let root = crate::routes::ensure_local_workspace_access(context, &root_uri)
.map_err(|error| error.with_context(context))?;
// 与 resolve_write_path 一致:canonicalize 根,并逐段解析已存在路径上的符号链接,
// 防止 `subdir -> /etc` 后再 create `subdir/x` 逃出授权目录。
let root = root.canonicalize().map_err(|error| {
WebError::bad_request_code(
"mnote_resource_unavailable",
format!("无法访问授权根目录: {error}"),
)
.with_context(context)
})?;
let relative = self.relative_path();
let relative_path = Path::new(&relative);
if relative_path.is_absolute()
@@ -407,15 +416,41 @@ impl ResourceToolTarget {
)
.with_context(context));
}
let path = root.join(relative_path);
if !path.starts_with(&root) {
let mut cursor = root.clone();
let components: Vec<_> = relative_path.components().collect();
for (index, component) in components.iter().enumerate() {
cursor = cursor.join(component);
if !cursor.exists() {
// 从本段起路径尚未落盘:直接拼完剩余段(无 symlink 可解析),仍须 starts_with(root)。
for rest in components.iter().skip(index + 1) {
cursor = cursor.join(rest);
}
break;
}
let canonical = cursor.canonicalize().map_err(|error| {
WebError::bad_request_code(
"mnote_resource_unavailable",
format!("无法解析资源路径: {error}"),
)
.with_context(context)
})?;
if !canonical.starts_with(&root) {
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
cursor = canonical;
}
if !cursor.starts_with(&root) {
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
Ok(path)
Ok(cursor)
}
fn relative_path(&self) -> String {
@@ -436,24 +471,40 @@ fn ensure_resource_scope_allowed(
let Some(scope) = input.arg_value("aiAccessScope") else {
return Ok(());
};
let allowed = scope
// 与 block allowedTargetBlockIds 一致:
// - 未声明 allowedResourceIds → 不按资源 id 收紧(仍受 rootUri / 写授权约束)
// - 声明了但非数组 / 空数组 → fail-closed
// - 声明了非空数组 → 仅白名单放行
let raw = scope
.get("allowedResourceIds")
.or_else(|| scope.get("allowed_resource_ids"))
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<HashSet<_>>()
})
.unwrap_or_default();
if allowed.is_empty()
|| allowed.contains(&target.resource_id)
|| allowed.contains(&target.object_identity)
{
.or_else(|| scope.get("allowed_resource_ids"));
let Some(raw) = raw else {
return Ok(());
};
let Some(values) = raw.as_array() else {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_resource_ai_scope_invalid",
"aiAccessScope.allowedResourceIds 必须是字符串数组",
)
.with_context(context));
};
let allowed = values
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<HashSet<_>>();
if allowed.is_empty() {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_resource_ai_scope_empty",
"aiAccessScope.allowedResourceIds 为空,拒绝访问任何资源",
)
.with_context(context));
}
if allowed.contains(&target.resource_id) || allowed.contains(&target.object_identity) {
return Ok(());
}
Err(WebError::new(
@@ -979,14 +1030,22 @@ fn ensure_mindmap_revision_precondition(
return Ok(());
};
let current = file_revision(path);
if revision_label(&expected).as_deref() != revision_label(&current).as_deref() {
return Err(WebError::bad_request_code(
let expected_label = revision_label(&expected);
let current_label = revision_label(&current);
// 双方均无法解析时不得 None==None 静默放行(fail-closed)。
match (expected_label.as_deref(), current_label.as_deref()) {
(Some(exp), Some(cur)) if exp == cur => Ok(()),
(Some(_), Some(_)) => Err(WebError::bad_request_code(
"mnote_resource_revision_conflict",
"mindmap resource revision 已变化,请重新读取后再写入",
)
.with_context(context));
.with_context(context)),
_ => Err(WebError::bad_request_code(
"mnote_resource_revision_unparseable",
"mindmap resource revision 无法解析,拒绝写入",
)
.with_context(context)),
}
Ok(())
}
fn revision_label(value: &Value) -> Option<String> {
@@ -1063,8 +1122,33 @@ fn office_mime_type(path: &Path) -> &'static str {
}
fn office_text_preview(path: &Path) -> Value {
match fs::read_to_string(path) {
Ok(text) => json!(text.chars().take(4000).collect::<String>()),
Err(_) => Value::Null,
// Office Open XML 是 ZIP 二进制,read_to_string 必失败;不要整文件读入内存。
// 文本摘要请走 onlyoffice document.export;此处仅对可能的纯文本做有限预览。
let ext = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if matches!(
ext.as_str(),
"docx" | "xlsx" | "pptx" | "doc" | "xls" | "ppt" | "odt" | "ods" | "odp"
) {
return Value::Null;
}
const MAX_PREVIEW_BYTES: usize = 16 * 1024;
let Ok(mut file) = fs::File::open(path) else {
return Value::Null;
};
use std::io::Read;
let mut buf = vec![0_u8; MAX_PREVIEW_BYTES];
let Ok(n) = file.read(&mut buf) else {
return Value::Null;
};
buf.truncate(n);
// 含明显 NUL 则当二进制,避免把 ZIP/乱码当摘要
if buf.iter().any(|b| *b == 0) {
return Value::Null;
}
let text = String::from_utf8_lossy(&buf);
json!(text.chars().take(4000).collect::<String>())
}
@@ -1,12 +1,8 @@
use crate::error::WebError;
use control_plane::UserRecord;
use reqwest::header::{HeaderMap, HeaderValue};
use serde_json::{json, Value};
use std::env;
use std::time::Duration;
//! Provider identity sync (legacy).
//!
//! 外部知识库身份同步已移除。本模块保留 API 兼容外壳,始终返回 skipped。
const DEFAULT_WEKNORA_ENDPOINT: &str = "http://127.0.0.1:8080/api/v1";
const PROVIDER_IDENTITY_SYNC_TIMEOUT_MS: u64 = 1_500;
use control_plane::UserRecord;
#[derive(Debug, Clone)]
pub struct ProviderIdentitySyncResult {
@@ -16,143 +12,16 @@ pub struct ProviderIdentitySyncResult {
pub provider_user_id: Option<String>,
}
fn env_flag(key: &str, default: bool) -> bool {
env::var(key)
.ok()
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(default)
}
fn clean_url(value: String) -> Option<String> {
let trimmed = value.trim().trim_end_matches('/').to_string();
(!trimmed.is_empty()).then_some(trimmed)
}
fn weknora_endpoint() -> String {
env::var("MNOTE_WEKNORA_ENDPOINT")
.or_else(|_| env::var("WEKNORA_ENDPOINT"))
.ok()
.and_then(clean_url)
.unwrap_or_else(|| DEFAULT_WEKNORA_ENDPOINT.to_string())
}
fn join_url(base_url: &str, path: &str) -> String {
format!(
"{}/{}",
base_url.trim_end_matches('/'),
path.trim_start_matches('/')
)
}
fn internal_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
if let Ok(secret) = env::var("MNOTE_PROVIDER_IDENTITY_SYNC_SECRET")
.or_else(|_| env::var("MNOTE_INTERNAL_API_SECRET"))
.or_else(|_| env::var("INTERNAL_API_SECRET"))
{
if let Ok(value) = HeaderValue::from_str(secret.trim()) {
headers.insert("x-internal-token", value);
}
}
headers
}
fn fallback_email(user: &UserRecord) -> String {
user.email
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("{}@mnote.local", user.username))
}
fn sync_disabled() -> bool {
!env_flag("MNOTE_PROVIDER_IDENTITY_SYNC", true)
}
/// 历史入口:注册/改密时同步外部知识库身份。已退役,固定 no-op。
pub async fn sync_provider_identities(
user: &UserRecord,
password: &str,
_password: &str,
) -> Vec<ProviderIdentitySyncResult> {
if sync_disabled() {
return vec![ProviderIdentitySyncResult {
provider: "all",
ok: true,
message: "provider identity sync disabled".to_string(),
provider_user_id: None,
}];
}
let sync_weknora = env_flag("MNOTE_PROVIDER_IDENTITY_SYNC_WEKNORA", true);
if sync_weknora {
vec![sync_weknora_identity(user, password).await]
} else {
vec![ProviderIdentitySyncResult {
provider: "all",
ok: true,
message: "provider identity sync disabled".to_string(),
provider_user_id: None,
}]
}
}
async fn post_json(url: String, body: Value) -> Result<Value, WebError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(PROVIDER_IDENTITY_SYNC_TIMEOUT_MS))
.build()
.map_err(|error| WebError::internal(format!("provider identity sync client: {error}")))?;
let response = client
.post(url.clone())
.headers(internal_headers())
.json(&body)
.send()
.await
.map_err(|error| {
WebError::bad_gateway_code(
"provider_identity_sync_unreachable",
format!("{url}: {error}"),
)
})?;
let status = response.status();
let payload = response.json::<Value>().await.unwrap_or_else(|_| json!({}));
if !status.is_success() {
return Err(WebError::bad_gateway_code(
"provider_identity_sync_failed",
format!("{url} returned {status}: {payload}"),
));
}
Ok(payload)
}
async fn sync_weknora_identity(user: &UserRecord, password: &str) -> ProviderIdentitySyncResult {
let payload = json!({
"mnote_user_id": user.id,
"username": user.username,
"email": fallback_email(user),
"password": password,
"role": "contributor",
"is_active": user.status == "active",
});
match post_json(
join_url(&weknora_endpoint(), "/internal/mnote/users/provision"),
payload,
)
.await
{
Ok(payload) => ProviderIdentitySyncResult {
provider: "weknora",
ok: true,
message: "synced".to_string(),
provider_user_id: payload
.pointer("/user/id")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
},
Err(error) => ProviderIdentitySyncResult {
provider: "weknora",
ok: false,
message: error.message().to_string(),
provider_user_id: None,
},
}
let _ = user;
vec![ProviderIdentitySyncResult {
provider: "none",
ok: true,
message: "External knowledge provider identity sync removed; no sync configured".into(),
provider_user_id: None,
}]
}
+181 -30
View File
@@ -593,8 +593,14 @@ pub async fn create_directory_access_request(
Json(body): Json<DirectoryAccessRequestBody>,
) -> Result<Json<Value>, WebError> {
let actor_id = ensure_authenticated(&context)?;
let root_path = body.root_path.trim();
let root_uri = body.root_uri.trim();
let root_path = sanitize_directory_access_root(body.root_path.trim()).map_err(|message| {
WebError::bad_request_code("directory_access_request_invalid_path", message)
.with_context(&context)
})?;
let root_uri = sanitize_directory_access_root(body.root_uri.trim()).map_err(|message| {
WebError::bad_request_code("directory_access_request_invalid_uri", message)
.with_context(&context)
})?;
if root_path.is_empty() && root_uri.is_empty() {
return Err(WebError::bad_request_code(
"directory_access_request_root_required",
@@ -659,23 +665,34 @@ pub async fn approve_directory_access_request(
.unwrap_or_default()
.trim()
.to_string();
let root_path = request
.get("rootPath")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string();
let root_uri = request
.get("rootUri")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string();
let root_path = sanitize_directory_access_root(
request
.get("rootPath")
.and_then(Value::as_str)
.unwrap_or_default(),
)
.map_err(|message| {
WebError::bad_request_code("directory_access_request_invalid_path", message)
})?;
let root_uri = sanitize_directory_access_root(
request
.get("rootUri")
.and_then(Value::as_str)
.unwrap_or_default(),
)
.map_err(|message| {
WebError::bad_request_code("directory_access_request_invalid_uri", message)
})?;
let permission = request
.get("permission")
.and_then(Value::as_str)
.map(normalize_directory_request_permission)
.unwrap_or_else(|| "read".into());
// 默认递归;若申请明确写了 recursive=false 则尊重(兼容旧审计无该字段)
let recursive = request
.get("recursive")
.and_then(Value::as_bool)
.unwrap_or(true);
let (_, Json(created)) = local_folder_source::create_local_access_grant(
State(state.clone()),
Extension(context.clone()),
@@ -685,7 +702,7 @@ pub async fn approve_directory_access_request(
root_uri,
root_path,
permission,
recursive: true,
recursive,
capabilities: Vec::new(),
}),
)
@@ -786,18 +803,53 @@ fn normalize_directory_request_permission(value: &str) -> String {
}
}
/// 拒绝路径穿越与空段;允许绝对/相对路径与 file:// URI(仅做字面安全检查)。
fn sanitize_directory_access_root(raw: &str) -> Result<String, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(String::new());
}
if trimmed.contains('\0') {
return Err("目录路径包含非法字符".into());
}
// file:// 或普通路径统一按 path component 检查 `..`
let path_part = trimmed
.strip_prefix("file://")
.or_else(|| trimmed.strip_prefix("file:"))
.unwrap_or(trimmed);
let path = std::path::Path::new(path_part);
for component in path.components() {
match component {
std::path::Component::ParentDir => {
return Err("目录路径不能包含 ..".into());
}
std::path::Component::CurDir => continue,
_ => {}
}
}
// 拒绝 Windows 风格盘符以外的可疑 `..` 编码残留
if path_part.split(['/', '\\']).any(|seg| seg == "..") {
return Err("目录路径不能包含 ..".into());
}
Ok(trimmed.to_string())
}
fn list_directory_access_requests(
state: &AppState,
user_filter: Option<&str>,
) -> Result<Vec<Value>, WebError> {
// list_audit_log 按 created_at DESC;用两遍处理保证与返回顺序无关:
// 先收 requested,再叠 approved/rejected,避免决策事件找不到条目而永久 pending。
const AUDIT_LOG_FETCH_LIMIT: usize = 5_000;
let rows = state
.control_plane()
.list_audit_log(1000)
.list_audit_log(AUDIT_LOG_FETCH_LIMIT)
.map_err(|error| WebError::internal(format!("目录权限申请读取失败: {error}")))?;
let mut requests: HashMap<String, Value> = HashMap::new();
for row in rows.iter().rev() {
let mut apply_row = |row: &control_plane::AuditLogRecord, phase: u8| {
if row.target_kind.as_str() != "directory_access_request" {
continue;
return;
}
let Some(request_id) = row
.target_id
@@ -805,19 +857,35 @@ fn list_directory_access_requests(
.map(str::trim)
.filter(|v| !v.is_empty())
else {
continue;
return;
};
let metadata =
serde_json::from_str::<Value>(&row.metadata_json).unwrap_or_else(|_| json!({}));
match row.action.as_str() {
"directory_access.requested" => {
match (phase, row.action.as_str()) {
(0, "directory_access.requested") => {
let mut value = metadata;
value["requestId"] = json!(request_id);
value["status"] = json!("pending");
value["createdAt"] = json!(row.created_at.clone());
requests.insert(request_id.to_string(), value);
// 同 id 多次申请:保留最早 createdAt(若已有条目则不覆盖 createdAt)
requests
.entry(request_id.to_string())
.and_modify(|existing| {
if existing.get("createdAt").and_then(Value::as_str).is_none() {
existing["createdAt"] = json!(row.created_at.clone());
}
// 若尚无决策,刷新申请元数据
if existing.get("status").and_then(Value::as_str) == Some("pending") {
for key in ["rootPath", "rootUri", "permission", "note", "userId"] {
if let Some(v) = value.get(key) {
existing[key] = v.clone();
}
}
}
})
.or_insert(value);
}
"directory_access.approved" | "directory_access.rejected" => {
(1, "directory_access.approved") | (1, "directory_access.rejected") => {
if let Some(value) = requests.get_mut(request_id) {
let status = metadata.get("status").and_then(Value::as_str).unwrap_or(
if row.action.ends_with("approved") {
@@ -837,11 +905,32 @@ fn list_directory_access_requests(
if let Some(grant_id) = metadata.get("grantId").and_then(Value::as_str) {
value["grantId"] = json!(grant_id);
}
} else {
// 决策日志早于申请窗口之外时,仍以决策元数据建档,避免幽灵 pending
let mut value = metadata;
value["requestId"] = json!(request_id);
if value.get("status").and_then(Value::as_str).is_none() {
value["status"] = json!(if row.action.ends_with("approved") {
"approved"
} else {
"rejected"
});
}
value["decidedAt"] = json!(row.created_at.clone());
requests.insert(request_id.to_string(), value);
}
}
_ => {}
}
};
for row in &rows {
apply_row(row, 0);
}
for row in &rows {
apply_row(row, 1);
}
let mut values = requests
.into_values()
.filter(|request| {
@@ -1050,12 +1139,29 @@ pub async fn admin_list_users(
let users = users
.into_iter()
.map(|user| {
// 7-76mnote-e2e / role=ai_service 视为 AI 主体;普通用户不可冒充。
let principal_kind = if user.role == "ai_service"
|| user.id == "mnote-e2e"
|| user.username == "mnote-e2e"
{
"ai_service"
} else {
"human"
};
let display_role = if principal_kind == "ai_service" {
"ai_service"
} else if is_admin_user_for_display(&user.id, &user.role) {
"admin"
} else {
user.role.as_str()
};
json!({
"id": user.id,
"email": user.email,
"username": user.username,
"displayName": user.display_name,
"role": if is_admin_user_for_display(&user.id, &user.role) { "admin" } else { user.role.as_str() },
"role": display_role,
"principalKind": principal_kind,
"status": user.status,
"createdAt": user.created_at,
"updatedAt": user.updated_at,
@@ -2294,9 +2400,11 @@ fn ensure_known_user(state: &AppState, user_id: &str) -> Result<(), WebError> {
"用户 ID 不能为空",
));
}
// 控制面尚无 get_user;提高 limit 降低误报,仍是 O(n) 列表扫描。
const USER_LOOKUP_LIMIT: usize = 5_000;
let exists = state
.control_plane()
.list_users(500)
.list_users(USER_LOOKUP_LIMIT)
.map_err(|error| WebError::internal(format!("读取用户列表失败: {error}")))?
.into_iter()
.any(|user| user.id == user_id);
@@ -2311,26 +2419,39 @@ fn ensure_known_user(state: &AppState, user_id: &str) -> Result<(), WebError> {
}
fn validate_user_policy_body(body: &UserAiPolicyBody, global_policy: &Value) -> Result<(), String> {
let global_models = global_policy
// 与 runtime 一致:两边都 canonical,避免 freefirst-fast ↔ freefirst 误拒。
let global_models: std::collections::HashSet<String> = global_policy
.get("allowedModels")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect::<std::collections::HashSet<_>>();
.filter_map(|value| canonical_ai_model_ref(None, value))
.collect();
if let Some(models) = &body.allowed_models {
for model in models {
if !global_models.contains(model.trim()) {
let Some(canonical) = canonical_ai_model_ref(None, model.trim()) else {
return Err(format!("模型 {model} 非法"));
};
if !global_models.contains(&canonical) {
return Err(format!("模型 {model} 不在管理员允许范围内"));
}
}
}
if let Some(default_model) = &body.default_model {
let Some(default_canonical) = canonical_ai_model_ref(None, default_model.trim()) else {
return Err("默认模型非法".into());
};
let allowed = body
.allowed_models
.as_ref()
.map(|models| models.iter().any(|model| model == default_model))
.unwrap_or_else(|| global_models.contains(default_model.as_str()));
.map(|models| {
models.iter().any(|model| {
canonical_ai_model_ref(None, model.trim()).as_deref()
== Some(default_canonical.as_str())
})
})
.unwrap_or_else(|| global_models.contains(&default_canonical));
if !allowed {
return Err("默认模型必须属于该用户允许的模型".into());
}
@@ -3609,6 +3730,36 @@ mod tests {
assert!(validate_user_policy_body(&body, &global).is_ok());
}
#[test]
fn user_policy_accepts_canonical_model_aliases() {
// 全局存 freefirst-fast,用户侧用 canonical freefirst 应通过
let global = json!({
"allowedModels": ["omniroute/freefirst-fast"],
"tools": {}
});
let body = UserAiPolicyBody {
default_model: Some("omniroute/freefirst".into()),
allowed_models: Some(vec!["omniroute/freefirst".into()]),
tools: None,
skills: None,
mcp_servers: None,
pi_extensions: None,
};
assert!(validate_user_policy_body(&body, &global).is_ok());
}
#[test]
fn sanitize_directory_access_root_rejects_traversal() {
assert!(sanitize_directory_access_root("../../etc/passwd").is_err());
assert!(sanitize_directory_access_root("/tmp/../etc").is_err());
assert!(sanitize_directory_access_root("file:///tmp/../secret").is_err());
assert_eq!(
sanitize_directory_access_root("/home/user/notes").unwrap(),
"/home/user/notes"
);
assert_eq!(sanitize_directory_access_root("").unwrap(), "");
}
#[test]
fn admin_user_display_role_includes_access_policy_admins() {
let _guard = crate::test_support::agent_env_lock()
File diff suppressed because it is too large Load Diff
+29 -7
View File
@@ -53,6 +53,16 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Val
)
}
/// Cap bridge overview page size to avoid unbounded fan-out / memory pressure.
const BRIDGE_WORKSPACE_LIMIT_MAX: u32 = 200;
const BRIDGE_WORKSPACE_LIMIT_DEFAULT: u32 = 50;
fn clamped_bridge_limit(limit: Option<u32>) -> u32 {
limit
.unwrap_or(BRIDGE_WORKSPACE_LIMIT_DEFAULT)
.clamp(1, BRIDGE_WORKSPACE_LIMIT_MAX)
}
fn workspace_query_payload(
workspace_id: &str,
query: &BridgeWorkspaceQuery,
@@ -61,7 +71,7 @@ fn workspace_query_payload(
name: "bridge.workspace.overview".into(),
payload: json!({
"workspaceId": workspace_id,
"limit": query.limit.unwrap_or(50),
"limit": clamped_bridge_limit(query.limit),
"cursor": query.cursor,
"commandStatus": query.command_status,
"eventStatus": query.event_status,
@@ -82,14 +92,27 @@ async fn execute_bridge_query(
execute_runtime_query_via_legacy_cloud(config, context, Some(workspace_id), query).await
}
fn require_resolved_workspace_id(
context: &RequestContext,
query_workspace_id: Option<&str>,
) -> Result<String, WebError> {
resolve_effective_workspace_id(context, query_workspace_id, true)?.ok_or_else(|| {
WebError::bad_request_code(
"workspace_required",
"缺少 workspaceId,请在 query 或请求头中提供有效工作区",
)
.with_context(context)
.with_header("x-error-phase", "workspace_resolve")
})
}
pub async fn workspace(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<BridgeWorkspaceQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
require_resolved_workspace_id(&context, query.workspace_id.as_deref())?;
let result = execute_bridge_query(
state.config(),
&context,
@@ -106,8 +129,7 @@ pub async fn request(
Query(query): Query<BridgeRequestQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
require_resolved_workspace_id(&context, query.workspace_id.as_deref())?;
let result = execute_bridge_query(
state.config(),
&context,
@@ -131,8 +153,7 @@ pub async fn trace(
Query(query): Query<BridgeTraceQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
require_resolved_workspace_id(&context, query.workspace_id.as_deref())?;
let result = execute_bridge_query(
state.config(),
&context,
@@ -177,6 +198,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -83,6 +83,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -122,6 +123,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
.oneshot(
Request::builder()
@@ -178,6 +180,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
.oneshot(
Request::builder()
@@ -246,6 +249,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
.oneshot(
Request::builder()
@@ -532,6 +532,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -605,6 +605,10 @@ pub async fn meta(
Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentMetaQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_NOTES_READ,
)?;
let result = load_document_meta_result(&state, &context, query).await?;
Ok(ok_response(&context, result))
}
@@ -614,6 +618,10 @@ pub async fn content(
Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentContentQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_NOTES_READ,
)?;
let result = load_document_content_result(&state, &context, query).await?;
Ok(ok_response(&context, result))
}
@@ -691,6 +699,10 @@ pub async fn page_body_write(
Extension(context): Extension<RequestContext>,
Json(body): Json<core_protocol::PageBodyWriteRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_NOTES_WRITE,
)?;
let document_id = body.document_id.trim();
if document_id.is_empty() {
return Err(
@@ -724,6 +736,10 @@ pub async fn save(
Extension(context): Extension<RequestContext>,
Json(body): Json<DocumentSaveRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_NOTES_WRITE,
)?;
let document_id = body.document_id.trim();
if document_id.is_empty() {
return Err(
@@ -977,6 +993,10 @@ pub async fn title(
Extension(context): Extension<RequestContext>,
Json(body): Json<DocumentTitleRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_NOTES_WRITE,
)?;
let document_id = body.document_id.trim();
if document_id.is_empty() {
return Err(
@@ -1280,6 +1300,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -1913,6 +1913,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -398,6 +398,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
}
+95 -22
View File
@@ -128,7 +128,8 @@ pub async fn auth_api(
if action != "auth:signIn" && action != "auth:signOut" {
return Err(WebError::bad_request_code(
"auth_action_unsupported",
"Rust gateway 当前仅支持账号登录、注册与登出动作。",
// 注册走 auth:signIn 的 create-if-missing 路径;独立 auth:signUp 未开放。
"Rust gateway 当前仅支持账号登录(auth:signIn)与登出(auth:signOut)。",
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
@@ -306,6 +307,14 @@ fn ai_management_response(
} else {
"user-ai-admin"
};
let ui_runtime_src = {
let base = "/api/mnote-browser-runtime/mnote-ui-runtime.js";
if let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() {
format!("{base}?devHot={cache_buster}")
} else {
base.to_string()
}
};
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
@@ -314,6 +323,7 @@ fn ai_management_response(
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{}</title>
<style>{}</style>
<script src="{}"></script>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="{}" data-mnote-actor-id="{}">
{}
@@ -321,6 +331,7 @@ fn ai_management_response(
</html>"#,
title,
crate::ssr::MNOTE_CSS,
ui_runtime_src,
shell,
escape_html(context.auth.actor_id.as_str()),
content
@@ -1105,14 +1116,32 @@ fn parent_scope_from_relative_path(relative_path: &str) -> &str {
.unwrap_or("")
}
/// 导航 scope 路径:词法拒 `..` / 绝对路径,避免把穿越串写进 query(下游仍会再校验)。
fn sanitize_navigation_relative_path(relative_path: &str) -> &str {
let trimmed = relative_path.trim();
if trimmed.is_empty() || trimmed == "." {
return "";
}
let path = std::path::Path::new(trimmed);
if path.is_absolute()
|| path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return "";
}
trimmed
}
fn navigation_folder_href(root_uri: &str, relative_path: &str) -> String {
let mut href = format!(
"/?sourceKind=local_folder&rootUri={}&treeView=filetree",
query_escape(root_uri)
);
if !relative_path.trim().is_empty() {
let scope = sanitize_navigation_relative_path(relative_path);
if !scope.is_empty() {
href.push_str("&fileTreeScope=");
href.push_str(&query_escape(relative_path));
href.push_str(&query_escape(scope));
}
href
}
@@ -1123,9 +1152,10 @@ fn navigation_document_href(root_uri: &str, file_tree_scope: &str, document_id:
query_escape(document_id),
query_escape(root_uri)
);
if !file_tree_scope.trim().is_empty() {
let scope = sanitize_navigation_relative_path(file_tree_scope);
if !scope.is_empty() {
href.push_str("&fileTreeScope=");
href.push_str(&query_escape(file_tree_scope));
href.push_str(&query_escape(scope));
}
href
}
@@ -1246,8 +1276,7 @@ pub async fn vault_entry(
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))?;
// 单次调用同时完成权限校验与路径解析,避免重复 FS 操作。
let workspace_root =
ensure_local_workspace_read_access_with_state(&state, &context, root_uri)
.map_err(|error| error.with_context(&context))?;
@@ -1419,6 +1448,7 @@ fn render_vault_workbench_html(workspace_id: &str, root_uri: &str, bootstrap: &V
<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>
<button type="button" data-vault-repair-ai-folders data-testid="vault-repair-ai-folders" hidden title="将分享副本分组重写为 用户名/原分组"> AI </button>
<label class="mnote-vault-insert-cipher" title="在当前焦点字段光标处插入 [Key]" hidden aria-hidden="true">
<span class="mnote-vault-sr-only"></span>
<select data-vault-insert-cipher data-testid="vault-insert-cipher" aria-label="插入密文">
@@ -1750,13 +1780,13 @@ fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
}}
return Promise.reject(new Error('resource_kind_unsupported'));
}}
root.addEventListener('click', function(event) {{
root.addEventListener('click', async function(event) {{
var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null;
if (!button || button.disabled) return;
var action = button.getAttribute('data-trash-action');
var documentId = button.getAttribute('data-document-id') || '';
if (action === 'empty-documents') {{
if (!window.confirm('')) return;
if (!(await window.mnote.confirm(''))) return;
button.disabled = true;
fetch('/api/documents/empty-trash', {{
method: 'POST',
@@ -1770,14 +1800,14 @@ fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
if (count) count.textContent = '0';
setStatus('', false);
}});
}}).catch(function(error) {{
}}).catch(async function(error) {{
button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true);
}});
return;
}}
if (action === 'empty-resources') {{
if (!window.confirm('')) return;
if (!(await window.mnote.confirm(''))) return;
button.disabled = true;
Promise.all([
postJson('/api/media/empty-trash', {{ workspaceId: workspaceId }}),
@@ -1788,7 +1818,7 @@ fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
var count = root.querySelector('[data-trash-resource-count]');
if (count) count.textContent = '0';
setStatus('', false);
}}).catch(function(error) {{
}}).catch(async function(error) {{
button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true);
}});
@@ -1799,21 +1829,21 @@ fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String {
var kind = button.getAttribute('data-resource-kind') || '';
var resourceDocumentId = button.getAttribute('data-document-id') || '';
if (!resourceId) return;
if (action === 'resource-purge' && !window.confirm('')) return;
if (action === 'resource-purge' && !(await window.mnote.confirm(''))) return;
button.disabled = true;
runResourceAction(kind, action === 'resource-restore' ? 'restore' : 'purge', resourceId, resourceDocumentId).then(function() {{
var row = button.closest('[data-trash-row]');
if (row) row.remove();
decrement('[data-trash-resource-count]');
setStatus(action === 'resource-restore' ? '' : '', false);
}}).catch(function(error) {{
}}).catch(async function(error) {{
button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true);
}});
return;
}}
if (!documentId) return;
if (action === 'purge' && !window.confirm('')) return;
if (action === 'purge' && !(await window.mnote.confirm(''))) return;
button.disabled = true;
fetch('/api/tree/commands', {{
method: 'POST',
@@ -1973,7 +2003,7 @@ fn render_local_trash_workbench_html(
return false;
}});
}}
root.addEventListener('click', function(event) {{
root.addEventListener('click', async function(event) {{
var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null;
if (!button || button.disabled) return;
var action = button.getAttribute('data-trash-action');
@@ -1981,7 +2011,7 @@ fn render_local_trash_workbench_html(
var documentId = button.getAttribute('data-document-id') || entryId;
var kind = button.getAttribute('data-resource-kind') || '';
if (action === 'local-restore' || action === 'local-purge') {{
if (action === 'local-purge' && !window.confirm('')) return;
if (action === 'local-purge' && !(await window.mnote.confirm(''))) return;
button.disabled = true;
postJson('/api/tree/commands', {{
action: action === 'local-restore' ? 'restore' : 'purge',
@@ -2003,7 +2033,7 @@ fn render_local_trash_workbench_html(
return refresh();
}}).then(function() {{
setStatus(action === 'local-restore' ? '' : '', false);
}}).catch(function(error) {{
}}).catch(async function(error) {{
button.disabled = false;
setStatus(error && error.message ? error.message : String(error), true);
}});
@@ -2014,7 +2044,7 @@ fn render_local_trash_workbench_html(
: '[data-trash-row="local"]:not([data-resource-kind="markdown"]):not([data-resource-kind="markdown_bundle"])';
var rows = Array.prototype.slice.call(root.querySelectorAll(selector));
if (rows.length === 0) return;
if (!window.confirm('')) return;
if (!(await window.mnote.confirm(''))) return;
button.disabled = true;
rows.reduce(function(chain, row) {{
return chain.then(function() {{
@@ -2461,6 +2491,14 @@ pub(crate) fn current_actor_display_name(
}
pub(crate) fn current_actor_id(state: &AppState, context: &RequestContext) -> Option<String> {
// 7-76PAT 鉴权优先,忽略 cookie 叠加。
if context.auth.auth_method == "pat" {
let actor_id = context.auth.actor_id.trim();
if actor_id.is_empty() || actor_id == "anonymous" {
return None;
}
return Some(actor_id.to_string());
}
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) {
@@ -2476,6 +2514,14 @@ pub(crate) fn current_actor_id(state: &AppState, context: &RequestContext) -> Op
}
pub(crate) fn current_actor_type(state: &AppState, context: &RequestContext) -> String {
if context.auth.auth_method == "pat" {
let t = context.auth.actor_type.trim();
return if t.is_empty() {
"user".to_string()
} else {
t.to_string()
};
}
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
let token_hash = session_token_hash(&raw_token);
if matches!(
@@ -2489,6 +2535,10 @@ pub(crate) fn current_actor_type(state: &AppState, context: &RequestContext) ->
}
pub(crate) fn current_actor_is_local_admin(state: &AppState, context: &RequestContext) -> bool {
// PAT 永不隐式 admin(即使 subject 是 admin 用户,也只按 scopes 授权)。
if context.auth.auth_method == "pat" {
return false;
}
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) {
@@ -2573,14 +2623,27 @@ async fn handle_control_plane_auth_action(
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| email.split('@').next().unwrap_or("user"));
// 7-76mnote-e2e 注册为 AI 主体(ai_service),非普通测试人设。
let role = if username == "mnote-e2e"
|| email.eq_ignore_ascii_case("mnote.e2e@example.com")
{
Some("ai_service".to_string())
} else {
None
};
let display_name = if role.as_deref() == Some("ai_service") {
"MNote AI".to_string()
} else {
username.to_string()
};
let user = state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some(username.to_string()),
email: Some(email.to_string()),
username: username.to_string(),
display_name: username.to_string(),
role: None,
display_name,
role,
password_hash: None,
})
.map_err(|error| control_plane_auth_error(context, error))?;
@@ -3007,6 +3070,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -3529,6 +3593,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
app_state
.control_plane()
@@ -3596,6 +3661,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
app_state
.control_plane()
@@ -3903,6 +3969,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
state
.control_plane()
@@ -4543,7 +4610,13 @@ mod tests {
assert!(html.contains("账号登录"));
assert!(html.contains("邮箱或用户名"));
assert!(!html.contains(r#"<span>"用户名"</span>"#));
assert!(html.contains("测试账号快速登录"));
// 7-76 P0:开发对标生产,登录页不得再渲染测试快速登录或测试密码 DOM。
assert!(!html.contains("测试账号快速登录"));
assert!(!html.contains("data-auth-test-login"));
assert!(!html.contains("data-test-password"));
assert!(!html.contains("MnoteE2E123!"));
assert!(html.contains("data-auth-submit"));
assert!(html.contains(r#"data-testid="mnote-auth-page""#));
}
#[tokio::test]
@@ -308,6 +308,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -514,6 +514,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
File diff suppressed because it is too large Load Diff
+35 -4
View File
@@ -46,8 +46,19 @@ pub(crate) struct OcrFrontmatter {
pub(crate) status: String,
}
fn reject_path_escape(value: &str) -> bool {
value
.replace('\\', "/")
.split('/')
.any(|seg| seg == ".." || seg == ".")
|| value.contains('\0')
}
pub(crate) fn is_local_ocr_sidecar_relative_path(relative_path: &str) -> bool {
let normalized = relative_path.trim().replace('\\', "/");
if normalized.is_empty() || reject_path_escape(&normalized) {
return false;
}
let path = Path::new(&normalized);
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
return false;
@@ -79,11 +90,24 @@ pub(crate) fn parse_ocr_frontmatter(markdown: &str) -> Option<OcrFrontmatter> {
if fields.get("mnote_ocr_version").map(String::as_str) != Some("1") {
return None;
}
let owner_document = fields.get("owner_document")?.to_string();
let source_path = fields.get("source_path")?.to_string();
let source_root_relative_path = fields.get("source_root_relative_path")?.to_string();
// Frontmatter 路径字段不得含 `..` / `.` 段,避免后续 join 逃逸工作区。
for candidate in [
owner_document.as_str(),
source_path.as_str(),
source_root_relative_path.as_str(),
] {
if reject_path_escape(candidate) {
return None;
}
}
Some(OcrFrontmatter {
provider: fields.get("provider")?.to_string(),
owner_document: fields.get("owner_document")?.to_string(),
source_path: fields.get("source_path")?.to_string(),
source_root_relative_path: fields.get("source_root_relative_path")?.to_string(),
owner_document,
source_path,
source_root_relative_path,
source_size: fields.get("source_size")?.parse().ok()?,
source_mtime_ms: fields.get("source_mtime_ms")?.parse().ok()?,
status: fields.get("status")?.to_string(),
@@ -153,8 +177,15 @@ mod tests {
assert!(!is_local_ocr_sidecar_relative_path(
"docs/Page.assets/photo.png"
));
assert!(!is_local_ocr_sidecar_relative_path(
"docs/../Page.ocr/photo.png.ocr.md"
));
let markdown = "---\nmnote_ocr_version: 1\nprovider: mock\nowner_document: ../Page.md\nsource_path: ./Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\n---\n\nOCR body\n";
// 含路径逃逸段的 frontmatter 必须拒绝解析。
let bad = "---\nmnote_ocr_version: 1\nprovider: mock\nowner_document: ../Page.md\nsource_path: Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\n---\n\nOCR body\n";
assert!(parse_ocr_frontmatter(bad).is_none());
let markdown = "---\nmnote_ocr_version: 1\nprovider: mock\nowner_document: Page.md\nsource_path: Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\n---\n\nOCR body\n";
let parsed = parse_ocr_frontmatter(markdown).expect("frontmatter");
assert_eq!(parsed.provider, "mock");
assert_eq!(
@@ -17,7 +17,6 @@ use core_protocol::{
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[cfg(test)]
use std::collections::BTreeSet;
use std::fs;
use std::path::{Component, Path, PathBuf};
@@ -1457,6 +1456,7 @@ fn rebuild_local_search_index_with_settings(
) -> Result<LocalSearchIndex, WebError> {
let mut documents = Vec::new();
let mut resources = Vec::new();
let mut visited = BTreeSet::new();
for include_path in &settings.include_paths {
let base_path = if include_path == "." {
root_path.to_path_buf()
@@ -1464,7 +1464,13 @@ fn rebuild_local_search_index_with_settings(
root_path.join(include_path)
};
if base_path.exists() {
collect_markdown_documents(root_path, &base_path, &mut documents, &mut resources)?;
collect_markdown_documents(
root_path,
&base_path,
&mut documents,
&mut resources,
&mut visited,
)?;
}
}
documents.sort_by(|left, right| left.path.cmp(&right.path));
@@ -1964,7 +1970,28 @@ fn collect_markdown_documents(
current: &Path,
documents: &mut Vec<LocalSearchDocument>,
resources: &mut Vec<LocalSearchResource>,
visited: &mut BTreeSet<PathBuf>,
) -> Result<(), WebError> {
// 规范化后必须仍在 root 内;失败则跳过(含断链 / 权限)。
let root_canonical = match root_path.canonicalize() {
Ok(path) => path,
Err(_) => root_path.to_path_buf(),
};
let current_key = match current.canonicalize() {
Ok(path) => path,
Err(_) => current.to_path_buf(),
};
if !current_key.starts_with(&root_canonical) && current != root_path {
// 词法路径可能尚未 canonicalize 到 root;仅当 current 已规范且越界时拒绝。
if current.canonicalize().is_ok() {
return Ok(());
}
}
if !visited.insert(current_key) {
// 目录环(含硬链接/重复 include)直接跳过,避免无限递归。
return Ok(());
}
let entries = match fs::read_dir(current) {
Ok(entries) => entries,
Err(error) => {
@@ -1995,8 +2022,12 @@ fn collect_markdown_documents(
format!("无法读取本地搜索索引文件状态 {}: {error}", path.display()),
)
})?;
// 不跟随符号链接,防止索引逃逸到 root 之外或形成符号链接环。
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
collect_markdown_documents(root_path, &path, documents, resources)?;
collect_markdown_documents(root_path, &path, documents, resources, visited)?;
continue;
}
if !file_type.is_file() {
@@ -502,6 +502,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -3,7 +3,7 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::gateway::default_workspace_name_for_context;
use crate::routes::web_shell::{
build_page_aggregate_snapshot, load_file_tree_html, load_sidebar_tree_html,
build_page_aggregate_snapshot, escape_script_json, load_file_tree_html, load_sidebar_tree_html,
load_workspace_shell_projection, render_local_file_tree_html, render_local_sidebar_tree_html,
};
use crate::ssr::pages::mindmap::MindmapPage;
@@ -32,14 +32,26 @@ pub async fn mindmap_object_shell(
Query(query): Query<MindmapShellQuery>,
) -> Result<Response, WebError> {
let default_workspace_name = default_workspace_name_for_context(&state, &context);
let source_kind = query.source_kind.as_deref();
// 与路由分支一致:未显式提供时不伪称 local_folder(避免 is_local_folder=false 但 JSON 写 local_folder
let resolved_source_kind = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("")
.to_string();
let source_kind = resolved_source_kind.as_str();
let root_uri = query.root_uri.as_deref();
let aggregate = build_page_aggregate_snapshot(
&state,
&context,
&doc_id,
query.workspace_id.as_deref(),
source_kind,
if source_kind.is_empty() {
None
} else {
Some(source_kind)
},
root_uri,
)
.await
@@ -53,7 +65,7 @@ pub async fn mindmap_object_shell(
.map(|value| value.head.title.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "思维导图".to_string());
let is_local_folder = source_kind.map(str::trim) == Some("local_folder");
let is_local_folder = source_kind == "local_folder";
let (workspace_name, sidebar_tree_html, workspace_sidebar_html) = if is_local_folder {
let root_uri = root_uri.unwrap_or_default();
let sidebar_tree_html =
@@ -150,7 +162,7 @@ pub async fn mindmap_object_shell(
"documentId": doc_id,
"mindmapId": mindmap_id
},
"sourceKind": source_kind.unwrap_or("local_folder"),
"sourceKind": source_kind,
"rootUri": root_uri.unwrap_or(""),
"revision": serde_json::Value::Null,
"conflictDetectionKey": serde_json::Value::Null,
@@ -167,7 +179,7 @@ pub async fn mindmap_object_shell(
"shell": "mindmap",
"documentId": doc_id,
"mindmapId": mindmap_id,
"sourceKind": source_kind.unwrap_or("local_folder"),
"sourceKind": source_kind,
"rootUri": root_uri.unwrap_or(""),
"projection": {
"schema": "mnote.mindmap.simple_mind_map_scene.v1",
@@ -217,7 +229,7 @@ pub async fn mindmap_object_shell(
crate::ssr::MNOTE_CSS,
escape_html(&doc_id),
escape_html(&mindmap_id),
escape_html(source_kind.unwrap_or("local_folder")),
escape_html(source_kind),
escape_html(root_uri.unwrap_or("")),
body_content,
escape_script_json(&editor_bootstrap_json),
@@ -246,10 +258,6 @@ fn escape_html(value: &str) -> String {
.replace('"', "&quot;")
}
fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
}
fn render_mindmap_standalone_bootstrap_script() -> String {
r#"<script type="module">
(() => {
@@ -407,6 +415,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -449,6 +458,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
+237 -27
View File
@@ -171,13 +171,39 @@ fn agent_profile_home() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(".mnote-agent"))
}
fn agent_profile_config_path(profile: &str) -> PathBuf {
let home = agent_profile_home();
/// profile 段只允许单层安全名,拒绝 `/`、`\`、`..`,防止拼到 profiles/ 外。
fn sanitize_agent_profile_segment(profile: &str) -> Option<&str> {
let profile = profile.trim();
if profile.is_empty() || profile == "default" {
return home.join("config.yaml");
return None;
}
let candidate = home.join("profiles").join(profile);
if profile.contains('/')
|| profile.contains('\\')
|| profile.contains('\0')
|| profile == "."
|| profile == ".."
|| profile
.split(['/', '\\'])
.any(|seg| seg.is_empty() || seg == "." || seg == "..")
{
return None;
}
// 仅允许常见 profile 标识字符,避免奇怪路径段。
if !profile
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return None;
}
Some(profile)
}
fn agent_profile_config_path(profile: &str) -> PathBuf {
let home = agent_profile_home();
let Some(safe) = sanitize_agent_profile_segment(profile) else {
return home.join("config.yaml");
};
let candidate = home.join("profiles").join(safe);
if candidate.exists() {
candidate.join("config.yaml")
} else {
@@ -388,6 +414,13 @@ pub async fn mnote_call(
Extension(context): Extension<RequestContext>,
Json(input): Json<ToolCallInput>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
// 7-76:外部 AI 经 tools 调用时按读写工具要求 notes scope。
let required = if is_read_tool(&input.tool_name) {
crate::routes::api_access_token::SCOPE_NOTES_READ
} else {
crate::routes::api_access_token::SCOPE_NOTES_WRITE
};
crate::routes::api_access_token::ensure_scope(&context, required)?;
let response_body = execute_mnote_tool_call(&state, &context, input).await?;
Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body)))
}
@@ -588,15 +621,6 @@ pub(crate) async fn execute_mnote_tool_call(
.with_context(&context))
}
"mnote.knowledge_rag.status" => knowledge_rag::status(&state, &context, &input).await,
"mnote.weknora.search"
| "mnote.weknora.list_sources"
| "mnote.weknora.get_source_status"
| "mnote.weknora.open_reference" => Err(WebError::new(
StatusCode::GONE,
"mnote_weknora_tools_retired",
"WeKnora 专用工具已从默认 agent manifest 移除;请改用 provider-neutral mnote.knowledge_rag.*,或显式启动 legacy WeKnora provider 调试。",
)
.with_context(&context)),
"mnote.knowledge_rag.query" => knowledge_rag::query(&state, &context, &input).await,
"mnote.knowledge_rag.section_context" => {
knowledge_rag::section_context(&state, &context, &input).await
@@ -905,8 +929,11 @@ fn required_capability_scope(tool_name: &str) -> Vec<String> {
}
fn declared_capability_scope_covers(declared: Option<&Vec<String>>, required: &[String]) -> bool {
// required 为空时由 ensure_tool_capability_scope 短路。
let Some(declared) = declared else {
// 兼容旧调用方:缺省 capabilityScope 不改变既有执行路径。
// 兼容旧调用方:完全未声明 capabilityScope 不改变既有执行路径。
// 写工具仍由 shared_read / aiAccessScope / commandContext 等合同 fail-closed。
// 注意:显式声明 `[]` 与“未声明”语义不同——空数组表示调用方主动声明无能力,必须拒绝。
return true;
};
let declared = declared
@@ -914,6 +941,10 @@ fn declared_capability_scope_covers(declared: Option<&Vec<String>>, required: &[
.map(|value| normalize_capability_scope(value))
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
// 显式空 capabilityScope → fail-closed(禁止 None 与 [] 混同为“放行”)。
if declared.is_empty() {
return false;
}
required.iter().all(|scope| {
declared
.iter()
@@ -1311,6 +1342,7 @@ fn stamp_tool_headers() -> HeaderMap {
#[cfg(test)]
mod tests {
use super::{agent_profile_config_path, sanitize_agent_profile_segment};
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
@@ -1325,6 +1357,37 @@ mod tests {
crate::test_support::agent_env_lock()
}
#[test]
fn agent_profile_segment_rejects_path_traversal() {
assert!(sanitize_agent_profile_segment("ok-profile").is_some());
assert!(sanitize_agent_profile_segment("../etc").is_none());
assert!(sanitize_agent_profile_segment("a/b").is_none());
assert!(sanitize_agent_profile_segment("..").is_none());
assert!(sanitize_agent_profile_segment("default").is_none());
// 危险段回落到 default config.yaml,路径中不得含攻击串
let path = agent_profile_config_path("../../../etc/passwd");
let s = path.to_string_lossy();
assert!(!s.contains("etc/passwd"), "{s}");
assert!(s.ends_with("config.yaml"), "{s}");
}
#[test]
fn declared_capability_scope_empty_vec_is_fail_closed() {
use super::declared_capability_scope_covers;
let required = vec!["page.write".to_string()];
// 未声明:兼容旧路径
assert!(declared_capability_scope_covers(None, &required));
// 显式空:拒绝
let empty: Vec<String> = vec![];
assert!(!declared_capability_scope_covers(Some(&empty), &required));
// 显式覆盖:通过
let ok = vec!["page.write".to_string()];
assert!(declared_capability_scope_covers(Some(&ok), &required));
// 仅 read 不覆盖 write
let read_only = vec!["page.read".to_string()];
assert!(!declared_capability_scope_covers(Some(&read_only), &required));
}
fn app() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
@@ -1389,6 +1452,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -1439,6 +1503,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -2333,6 +2398,10 @@ mod tests {
"dryRun": true,
"capabilityScope": ["office.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"address": "A1",
"value": "after"
}
@@ -2476,6 +2545,10 @@ mod tests {
"dryRun": true,
"capabilityScope": ["office.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"onlyofficeSessionId": bridge_session_id,
"address": "A1",
"value": "after"
@@ -4951,6 +5024,10 @@ mod tests {
"dryRun": true,
"capabilityScope": ["page.write", "block.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"command": "block_replace",
"blockId": "heading_1",
"content": "替换标题"
@@ -4990,6 +5067,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"anchorBlockId": "heading_1",
"revision": 7,
@@ -5041,6 +5122,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"content": "替换后的章节",
"revision": 7,
@@ -5089,6 +5174,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"anchorBlockId": "heading_1",
"content": "新增段落",
"revision": 7,
@@ -5137,6 +5226,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "p_1",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
@@ -5184,6 +5277,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"anchorBlockId": "p_2",
"revision": 7,
@@ -5228,6 +5325,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"content": "越界替换",
"revision": 7,
@@ -5248,6 +5349,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"anchorBlockId": "heading_1",
"content": "越界插入",
"revision": 7,
@@ -5268,6 +5373,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "p_1",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
@@ -5287,6 +5396,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"anchorBlockId": "p_2",
"revision": 7,
@@ -5394,6 +5507,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": block_id,
"anchorBlockId": "p_anchor",
"revision": 7,
@@ -5445,6 +5562,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"anchorBlockId": "heading_1",
"blocks": ["新增第一段", {"type": "todo", "content": "新增待办"}],
"revision": 7,
@@ -5507,6 +5628,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"anchorBlockId": "heading_1",
"blocks": blocks,
"revision": 7,
@@ -5552,6 +5677,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"content": "不应写入"
}
@@ -5591,6 +5720,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"blockId": "heading_1",
"content": "不应写入",
"revision": 7,
@@ -5636,6 +5769,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{
"op": "replace",
"blockId": "p_2",
@@ -5678,6 +5815,10 @@ mod tests {
"dryRun": false,
"capabilityScope": ["block.write", "page.write"],
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"operations": [{
@@ -6016,7 +6157,11 @@ mod tests {
"args": {
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "本地 page.save 写入"}]}
]
],
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:README.md"]
}
}
})
.to_string(),
@@ -6097,6 +6242,10 @@ mod tests {
"wideLayout": true,
"showHeadingNumbers": true,
"hideTitleHeader": false
},
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:README.md"]
}
}
})
@@ -6158,7 +6307,11 @@ mod tests {
"traceId": "trace_1",
"idempotencyKey": "idem_save_1",
"dryRun": true,
"args": {"content": [{"type":"paragraph","content":[{"type":"text","text":"AI 写入"}]}]}
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"content": [{"type":"paragraph","content":[{"type":"text","text":"AI 写入"}]}]}
})
.to_string(),
))
@@ -6238,8 +6391,12 @@ mod tests {
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).expect("create temp root");
let path = root.join("page.md");
fs::write(&path, "第一段\n\n第二段\n").expect("write markdown");
fs::write(&root.join("page.md"), "第一段\n\n第二段\n").expect("write markdown");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_1", &root_uri,
)
.expect("initialize workspace");
let response = app()
.oneshot(
@@ -6248,11 +6405,15 @@ mod tests {
.uri("/api/mnote/tools/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "mnote.doc.markdown_edit",
"workspaceId": "ws_demo",
"documentId": path.to_string_lossy(),
"workspaceId": "local-ws-dry-run",
"documentId": "local-md:page.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"actorId": "user_1",
"sessionId": "sess_1",
"runId": "run_1",
"toolCallId": "call_1",
@@ -6260,6 +6421,10 @@ mod tests {
"idempotencyKey": "idem_markdown_local_1",
"dryRun": true,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:page.md"]
},
"operations": [{"search": "第二段", "replace": "测试123"}]
}
})
@@ -6277,10 +6442,9 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["audit"]["effect"], "dry_run");
assert_eq!(payload["result"]["operationsApplied"], 1);
assert_eq!(payload["result"]["applyResult"]["written"], false);
assert_eq!(payload["result"]["applyResult"]["dryRun"], true);
// dry-run 不得改盘
assert_eq!(
fs::read_to_string(&path).expect("read markdown"),
fs::read_to_string(root.join("page.md")).expect("read markdown"),
"第一段\n\n第二段\n"
);
let _ = fs::remove_dir_all(&root);
@@ -6475,6 +6639,10 @@ mod tests {
"idempotencyKey": "idem_markdown_mapping_empty_2",
"dryRun": false,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{"search": "第二段 <!-- block:p_2 -->", "replace": "测试123"}]
}
})
@@ -6519,6 +6687,10 @@ mod tests {
"idempotencyKey": "idem_markdown_full_content_online_2",
"dryRun": false,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"full_content": "章节一\n\n第一段已改\n\n第二段已改"
}
})
@@ -6562,6 +6734,10 @@ mod tests {
"idempotencyKey": "idem_markdown_normalized_1",
"dryRun": true,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{"search": "第二 段", "replace": "测试123"}]
}
})
@@ -6612,6 +6788,10 @@ mod tests {
"idempotencyKey": "idem_markdown_precondition",
"dryRun": false,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{"search": "第二段", "replace": "测试123"}]
}
})
@@ -6676,7 +6856,11 @@ mod tests {
"idempotencyKey": "idem_local_folder_md",
"dryRun": false,
"args": {
"operations": [{"search": "第一段", "replace": "第一段已由 AI 修改"}]
"operations": [{"search": "第一段", "replace": "第一段已由 AI 修改"}],
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:README.md"]
}
}
})
.to_string(),
@@ -6730,6 +6914,10 @@ mod tests {
"idempotencyKey": "idem_markdown_noop",
"dryRun": false,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [{"search": "不存在的段落", "replace": "测试123"}]
}
})
@@ -6771,6 +6959,10 @@ mod tests {
"idempotencyKey": "idem_markdown_scope",
"dryRun": true,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"allowedTargetBlockIds": ["p_2"],
"operations": [{"search": "第一段", "replace": "不应越权修改"}]
}
@@ -6813,6 +7005,10 @@ mod tests {
"idempotencyKey": "idem_markdown_same_block_1",
"dryRun": true,
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"operations": [
{"search": "", "replace": "2"},
{"search": "2段", "replace": "2段落"}
@@ -6861,7 +7057,11 @@ mod tests {
"traceId": "trace_1",
"idempotencyKey": "idem_options_1",
"dryRun": true,
"args": {"options": {"wideLayout": true, "pageFont": "serif"}}
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"options": {"wideLayout": true, "pageFont": "serif"}}
})
.to_string(),
))
@@ -6904,7 +7104,11 @@ mod tests {
"traceId": "trace_1",
"idempotencyKey": "idem_summary_1",
"dryRun": true,
"args": {"summary": "摘要内容"}
"args": {
"aiAccessScope": {
"permissionLevel": "read_write"
},
"summary": "摘要内容"}
})
.to_string(),
))
@@ -6958,7 +7162,13 @@ mod tests {
"traceId": "trace_artifact_local",
"idempotencyKey": "idem_artifact_local",
"dryRun": false,
"args": {"summary": "本地摘要"}
"args": {
"summary": "本地摘要",
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["local-md:README.md"]
}
}
})
.to_string(),
))
+57 -3
View File
@@ -1,5 +1,7 @@
mod ai_settings;
pub(crate) mod api_access_token;
mod bridge;
pub(crate) mod local_agent_install;
pub(crate) mod command_support;
mod compat;
pub(crate) mod dev_hot;
@@ -49,9 +51,10 @@ mod ws;
pub(crate) use gateway::current_actor_id;
pub(crate) use local_folder_source::{
control_plane_status_display, decode_local_id_segment, ensure_local_path_read_access,
ensure_local_workspace_access, ensure_local_workspace_read_access_with_state,
ensure_local_workspace_write_access_with_state, local_markdown_conflict_detection_key,
local_workspace_id_from_root_uri, update_local_markdown_title, write_local_markdown_page_body,
ensure_local_path_write_access, ensure_local_workspace_access,
ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state,
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
update_local_markdown_title, write_local_markdown_page_body,
};
#[cfg(test)]
pub(crate) use local_search_index::write_local_index_settings;
@@ -379,6 +382,26 @@ pub fn build_router(state: AppState) -> Router {
"/api/vault/items/{id}/session",
put(vault::put_item_session),
)
.route(
"/api/vault/ai/token",
post(vault::issue_agent_vault_token),
)
.route(
"/api/vault/ai/token/install",
post(vault::install_agent_vault_token),
)
.route(
"/api/vault/ai/token/uninstall",
post(vault::uninstall_agent_vault_token),
)
.route(
"/api/vault/ai/token/local",
get(vault::list_local_agent_vault_tokens),
)
.route(
"/api/vault/ai/token/local/{subject}",
get(vault::detail_local_agent_vault_token),
)
.route(
"/api/vault/extension/token",
post(vault::issue_extension_token),
@@ -388,6 +411,10 @@ pub fn build_router(state: AppState) -> Router {
post(vault::revoke_extension_token),
)
.route("/api/vault/ai/list", get(vault::list_ai))
.route(
"/api/vault/ai/repair-folders",
post(vault::repair_ai_folders),
)
.route("/api/vault/ai/items/{id}", get(vault::get_ai_item))
.route(
"/api/vault/ai/items/{id}/resolve",
@@ -546,6 +573,32 @@ pub fn build_router(state: AppState) -> Router {
)
.route("/api/ai-settings/receipts", get(ai_settings::user_receipts))
.route("/api/ai-admin/receipts", get(ai_settings::admin_receipts))
// 7-76 Web PAT 管理
.route(
"/api/ai-tokens",
get(api_access_token::list_tokens).post(api_access_token::create_token),
)
.route(
"/api/ai-tokens/{id}/reveal",
post(api_access_token::reveal_token),
)
.route(
"/api/ai-tokens/{id}/revoke",
post(api_access_token::revoke_token),
)
.route(
"/api/ai-tokens/{id}",
axum::routing::patch(api_access_token::rename_token)
.delete(api_access_token::delete_token),
)
.route(
"/api/ai-tokens/{id}/install",
post(api_access_token::install_token),
)
.route(
"/api/ai-tokens/{id}/uninstall",
post(api_access_token::uninstall_token),
)
.route(
"/api/ai-admin/settings",
get(ai_settings::admin_get_settings).put(ai_settings::admin_put_settings),
@@ -854,6 +907,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -227,18 +227,20 @@ fn ensure_navigation_recent_target_exists(
}
fn canonical_navigation_target(root_path: &Path, relative_path: &str) -> Result<PathBuf, WebError> {
let relative_path = normalize_navigation_relative_path(Some(relative_path))?
// root 与 target 都 canonicalize,避免 symlink / 未规范化 root 导致 starts_with 误判。
let canonical_root = root_path.canonicalize().unwrap_or_else(|_| root_path.to_path_buf());
let joined = normalize_navigation_relative_path(Some(relative_path))?
.as_deref()
.map(Path::new)
.map(|path| root_path.join(path))
.unwrap_or_else(|| root_path.to_path_buf());
let canonical_target = relative_path.canonicalize().map_err(|error| {
.map(|path| canonical_root.join(path))
.unwrap_or_else(|| canonical_root.clone());
let canonical_target = joined.canonicalize().map_err(|error| {
WebError::bad_request_code(
"navigation_recent_target_unavailable",
format!("最近访问目标不可用: {error}"),
)
})?;
if !canonical_target.starts_with(root_path) {
if !canonical_target.starts_with(&canonical_root) {
return Err(WebError::bad_request_code(
"navigation_recent_root_escape",
"最近访问路径不能越过授权目录",
@@ -379,6 +381,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})
}
+167 -21
View File
@@ -3,8 +3,8 @@ use crate::app::AppConfig;
use crate::app::AppState;
use crate::error::WebError;
use adapter_onlyoffice::{
prepare_callback, prepare_proxy_request, sign_config, OnlyOfficeCallbackPreparationInput,
OnlyOfficeProxyPreparationInput,
extract_callback_jwt, prepare_callback, prepare_proxy_request, sign_config,
verify_callback_jwt, OnlyOfficeCallbackPreparationInput, OnlyOfficeProxyPreparationInput,
};
use axum::body::{Body, Bytes};
use axum::extract::{Path, Query, State};
@@ -1025,24 +1025,106 @@ fn resolve_onlyoffice_local_file_path(
"本地文件路径不能越过 root",
));
}
let target = canonical_root
.join(requested)
.canonicalize()
.map_err(|error| {
WebError::bad_request_code(
let joined = canonical_root.join(requested);
// 拒绝目标本身为 symlink,降低 canonicalize 与 open 之间的 TOCTOU 替换窗口。
match fs::symlink_metadata(&joined) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_symlink_forbidden",
"本地文件路径拒绝符号链接目标",
));
}
Ok(meta) if !meta.is_file() => {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_not_file",
"本地文件路径必须指向普通文件",
));
}
Ok(_) => {}
Err(error) => {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_not_found",
format!("找不到本地文件: {error}"),
)
})?;
));
}
}
let target = joined.canonicalize().map_err(|error| {
WebError::bad_request_code(
"onlyoffice_local_file_not_found",
format!("找不到本地文件: {error}"),
)
})?;
if !target.starts_with(&canonical_root) || !target.is_file() {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_root_escape",
"本地文件路径不能越过 root",
));
}
// 二次确认 canonicalize 后路径仍不是 symlink(竞态窗口内被替换)。
if let Ok(meta) = fs::symlink_metadata(&target) {
if meta.file_type().is_symlink() {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_symlink_forbidden",
"本地文件路径拒绝符号链接目标",
));
}
}
Ok(target)
}
/// 读取本地 OnlyOffice 文件:resolve 后再以 symlink_metadata 校验并 open,缩小 TOCTOU 窗口。
fn read_onlyoffice_local_file_bytes(target: &FsPath) -> Result<Vec<u8>, WebError> {
if let Ok(meta) = fs::symlink_metadata(target) {
if meta.file_type().is_symlink() {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_symlink_forbidden",
"本地文件路径拒绝符号链接目标",
));
}
}
fs::read(target).map_err(|error| {
WebError::bad_request_code(
"onlyoffice_local_file_read_failed",
format!("无法读取本地文件: {error}"),
)
})
}
/// 写回本地 OnlyOffice 文件:写前拒绝 symlink,避免 TOCTOU 导向 root 外。
fn write_onlyoffice_local_file_bytes(target: &FsPath, bytes: &[u8]) -> Result<(), WebError> {
if let Ok(meta) = fs::symlink_metadata(target) {
if meta.file_type().is_symlink() {
return Err(WebError::bad_request_code(
"onlyoffice_local_file_symlink_forbidden",
"本地文件路径拒绝符号链接目标",
));
}
}
fs::write(target, bytes).map_err(|error| {
WebError::bad_request_code(
"onlyoffice_local_callback_write_failed",
format!("写回本地 Office 文件失败: {error}"),
)
})
}
/// 当配置了 ONLYOFFICE_JWT_SECRET 时,强制校验回调 JWT;未配置时与 sign 一致放行。
fn enforce_onlyoffice_callback_jwt(
body: &Value,
authorization: Option<&str>,
) -> Result<(), WebError> {
let secret = env_or_dotenv("ONLYOFFICE_JWT_SECRET").unwrap_or_default();
let token = extract_callback_jwt(body, authorization);
verify_callback_jwt(token.as_deref(), &secret).map_err(|error| {
WebError::new(
StatusCode::UNAUTHORIZED,
"onlyoffice_callback_jwt_invalid",
error,
)
})?;
Ok(())
}
fn onlyoffice_content_type_for_path(path: &FsPath) -> HeaderValue {
let extension = path
.extension()
@@ -1111,12 +1193,7 @@ fn proxy_local_folder_file_open(
let mut response = if *method == Method::HEAD {
Response::new(Body::empty())
} else {
let bytes = fs::read(&target).map_err(|error| {
WebError::bad_request_code(
"onlyoffice_local_file_read_failed",
format!("无法读取本地文件: {error}"),
)
})?;
let bytes = read_onlyoffice_local_file_bytes(&target)?;
Response::new(Body::from(bytes))
};
response.headers_mut().insert(
@@ -1297,12 +1374,7 @@ async fn local_folder_onlyoffice_callback(
)
})?;
let bytes = download_onlyoffice_callback_body(download_url).await?;
fs::write(&target, &bytes).map_err(|error| {
WebError::bad_request_code(
"onlyoffice_local_callback_write_failed",
format!("写回本地 Office 文件失败: {error}"),
)
})?;
write_onlyoffice_local_file_bytes(&target, &bytes)?;
Ok(onlyoffice_callback_success(json!({
"localWrite": true,
"bytes": bytes.len(),
@@ -1312,9 +1384,16 @@ async fn local_folder_onlyoffice_callback(
pub async fn callback(
State(state): State<AppState>,
uri: Uri,
headers: HeaderMap,
Query(query): Query<OnlyOfficeCallbackQuery>,
Json(body): Json<Value>,
) -> Response {
let authorization = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok());
if let Err(error) = enforce_onlyoffice_callback_jwt(&body, authorization) {
return onlyoffice_callback_failure(error);
}
let status = body
.get("status")
.and_then(Value::as_i64)
@@ -2008,6 +2087,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})
}
@@ -2040,6 +2120,7 @@ mod tests {
"/api/onlyoffice/callback?assetId=asset_1"
.parse::<Uri>()
.expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: None,
@@ -2083,6 +2164,7 @@ mod tests {
)
.parse::<Uri>()
.expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
@@ -2149,6 +2231,7 @@ mod tests {
)
.parse::<Uri>()
.expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
@@ -2210,6 +2293,7 @@ mod tests {
)
.parse::<Uri>()
.expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
@@ -2277,6 +2361,7 @@ mod tests {
)
.parse::<Uri>()
.expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
@@ -2350,6 +2435,7 @@ mod tests {
)
.parse::<Uri>()
.expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
@@ -2379,6 +2465,7 @@ mod tests {
"/api/onlyoffice/callback?assetId=asset_1&userId=user_1"
.parse::<Uri>()
.expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: Some("user_1".into()),
@@ -2534,4 +2621,63 @@ mod tests {
assert!(html.contains("window.__MNOTE_ONLYOFFICE_REQUEST_EDIT_RIGHTS__"));
assert!(html.contains("window.location.replace(editHref);"));
}
#[tokio::test]
async fn onlyoffice_callback_rejects_invalid_jwt_when_secret_configured() {
std::env::set_var("ONLYOFFICE_JWT_SECRET", "test-onlyoffice-jwt-secret");
let response = callback(
State(test_state(None)),
"/api/onlyoffice/callback?assetId=asset_1"
.parse::<Uri>()
.expect("uri"),
HeaderMap::new(),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: None,
session_id: None,
token: None,
root_uri: None,
path: None,
}),
Json(json!({
"status": 2,
"url": "http://127.0.0.1:8082/cache/files/out.docx",
"token": "not.a.valid.jwt"
})),
)
.await;
std::env::remove_var("ONLYOFFICE_JWT_SECRET");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["error"], 1);
assert_eq!(payload["code"], "onlyoffice_callback_jwt_invalid");
}
#[test]
fn resolve_onlyoffice_local_file_path_rejects_symlink_target() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-symlink-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("Page")).expect("create page");
let real = root.join("Page").join("real.docx");
fs::write(&real, b"real").expect("write real");
let link = root.join("Page").join("link.docx");
#[cfg(unix)]
{
std::os::unix::fs::symlink(&real, &link).expect("symlink");
let err = resolve_onlyoffice_local_file_path(
&format!("file://{}", root.display()),
"Page/link.docx",
)
.expect_err("symlink should fail");
assert_eq!(err.code(), "onlyoffice_local_file_symlink_forbidden");
}
let _ = fs::remove_dir_all(&root);
}
}
@@ -1345,16 +1345,18 @@ fn render_plugin_index(
async function loop() {{
while (!stopped) {{
// 每次迭代用 let 限定 command,避免 var 提升导致 catch 误报上一轮成功命令
let command = null;
try {{
var query = new URLSearchParams({{
const query = new URLSearchParams({{
sessionId: sessionId,
token: bridgeToken,
timeoutMs: "25000"
}});
var response = await fetch(apiBase + "/api/onlyoffice/bridge/commands/next?" + query.toString());
const response = await fetch(apiBase + "/api/onlyoffice/bridge/commands/next?" + query.toString());
if (response.status === 204) continue;
var command = await response.json();
var result = await executeCommand(command);
command = await response.json();
const result = await executeCommand(command);
await postJson("/api/onlyoffice/bridge/results", {{
sessionId: sessionId,
token: bridgeToken,
@@ -1902,8 +1904,32 @@ fn ensure_session_token(session_id: &str, token: Option<&str>) -> Result<(), Res
}
}
/// 将字符串序列化为可安全嵌入 `<script>` 的 JSON 字面量。
/// `serde_json` 只做 JS 字符串转义,不会打断 HTML 解析器对 `</script>` 的识别,
/// 因此必须额外把 `</script`(大小写不敏感)写成 `<\\/script`。
fn json_string(input: &str) -> String {
serde_json::to_string(input).unwrap_or_else(|_| "\"\"".into())
let encoded = serde_json::to_string(input).unwrap_or_else(|_| "\"\"".into());
escape_script_json_literal(&encoded)
}
fn escape_script_json_literal(value: &str) -> String {
let lower = value.to_ascii_lowercase();
let needle = b"</script";
let mut out = String::with_capacity(value.len());
let bytes = value.as_bytes();
let lower_bytes = lower.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + needle.len() <= lower_bytes.len() && &lower_bytes[i..i + needle.len()] == needle {
out.push_str("<\\/script");
i += needle.len();
continue;
}
let ch = value[i..].chars().next().expect("valid utf-8 offset");
out.push(ch);
i += ch.len_utf8();
}
out
}
fn now_millis() -> u128 {
@@ -1993,6 +2019,56 @@ mod tests {
assert!(html.contains(r#"pageOrigin: "http://127.0.0.1:3001""#));
}
#[test]
fn json_string_escapes_script_close_tag_case_insensitive() {
// 与 web_shell::escape_script_json 一致:匹配大小写不敏感,输出统一为 <\/script
let escaped = r#"<\/script>"#;
assert_eq!(
json_string(r#"</script><img onerror=alert(1)>"#),
format!(r#""{escaped}<img onerror=alert(1)>""#)
);
assert_eq!(
json_string(r#"</SCRIPT>alert(1)"#),
format!(r#""{escaped}alert(1)""#)
);
assert_eq!(
json_string(r#"</ScRiPt>x"#),
format!(r#""{escaped}x""#)
);
assert_eq!(json_string(r#"ok-value"#), r#""ok-value""#);
}
#[tokio::test]
async fn bridge_plugin_index_escapes_script_breaking_payloads() {
let response = plugin_index(Query(BridgePluginIndexQuery {
session_id: Some(r#"x</script><script>alert(1)</script>"#.into()),
api_base: Some("http://127.0.0.1:3000".into()),
token: Some("token-safe".into()),
document_id: Some(r#"doc</ScRiPt>x"#.into()),
asset_id: None,
file_type: None,
doc_key: None,
page_origin: None,
}))
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
// 原始 `</script>` 不得出现在注入字面量位置(允许外层 HTML 的正常闭合标签)
assert!(!html.contains(r#"var sessionId = "x</script>"#));
assert!(!html.contains(r#"documentId: "doc</ScRiPt>"#));
assert!(!html.contains(r#"documentId: "doc</script>"#));
// 大小写变体统一转义为 <\/script(小写),打断 HTML 解析器
assert!(html.contains(r#"var sessionId = "x<\/script><script>alert(1)<\/script>";"#));
assert!(html.contains(r#"documentId: "doc<\/script>x""#));
// 每次迭代用 let command,避免 var 提升残留
assert!(html.contains("let command = null;"));
assert!(!html.contains("var command = await response.json()"));
}
#[tokio::test]
async fn bridge_plugin_index_exposes_second_batch_recipe_actions() {
let response = plugin_index(Query(BridgePluginIndexQuery {
@@ -27,7 +27,8 @@ use std::fs::{self, OpenOptions};
use std::io::{Read as _, Write as _};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use sha2::{Digest, Sha256};
use std::process::Stdio;
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -379,18 +380,28 @@ fn generate_id(prefix: &str) -> String {
format!("{prefix}_{:x}_{:x}", now_ms(), random_suffix())
}
fn generate_bridge_token() -> String {
let mut bytes = [0_u8; 24];
if let Ok(mut random) = fs::File::open("/dev/urandom") {
if random.read_exact(&mut bytes).is_ok() {
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
encoded.push_str(&format!("{byte:02x}"));
fn fill_secure_random(buf: &mut [u8]) -> bool {
// bridge token / 安全凭证:只接受 CSPRNG。Linux/Unix 用 /dev/urandom。
#[cfg(unix)]
{
if let Ok(mut random) = fs::File::open("/dev/urandom") {
if random.read_exact(buf).is_ok() {
return true;
}
return format!("pi_bridge_{encoded}");
}
}
generate_id("pi_bridge_fallback")
false
}
fn generate_bridge_token() -> String {
let mut bytes = [0_u8; 32];
// 两次尝试 urandom;仍失败则 panic,禁止发出可预测 bridge token。
if !fill_secure_random(&mut bytes) && !fill_secure_random(&mut bytes) {
panic!(
"generate_bridge_token: 无法从 CSPRNG 读取随机字节,拒绝发出可预测 bridge token"
);
}
format!("pi_bridge_{}", hex::encode(bytes))
}
fn random_suffix() -> u64 {
@@ -438,15 +449,17 @@ fn pi_lab_tool_params_hash(params: &Value) -> String {
map.remove("mnoteApproval");
map.remove("mnote_approval");
}
format!("{:x}", stable_hash(&canonical_json_string(&normalized)))
// approval 参数匹配必须抗碰撞;DJB2/32-bit 不够。使用 SHA-256 hex。
let digest = Sha256::digest(canonical_json_string(&normalized).as_bytes());
hex::encode(digest)
}
fn stable_hash(value: &str) -> u64 {
let mut hash = 5381_u32;
for unit in value.encode_utf16() {
hash = ((hash << 5).wrapping_add(hash)).wrapping_add(u32::from(unit));
}
u64::from(hash)
// 仅用于 MCP cache 变更检测等非安全场景;取 SHA-256 前 8 字节作 64-bit 指纹。
let digest = Sha256::digest(value.as_bytes());
let mut bytes = [0_u8; 8];
bytes.copy_from_slice(&digest[..8]);
u64::from_be_bytes(bytes)
}
fn approval_key(session_id: &str, approval_id: &str) -> String {
@@ -634,22 +647,63 @@ fn root_can_write(root: &AllowedRoot) -> bool {
permission.contains("write") || permission == "owner"
}
fn canonical_or_parent(path: &Path) -> PathBuf {
if let Ok(canonical) = path.canonicalize() {
return canonical;
}
if let Some(parent) = path.parent() {
if let Ok(canonical_parent) = parent.canonicalize() {
return canonical_parent.join(path.file_name().unwrap_or_default());
/// 词法归一化路径分量:解析 `.` / `..`,避免未 canonicalize 时 `starts_with` 被 `..` 绕过。
fn lexically_normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(prefix) => out.push(prefix.as_os_str()),
Component::RootDir => out.push(Component::RootDir.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
match out.components().next_back() {
Some(Component::Normal(_)) => {
out.pop();
}
Some(Component::RootDir) | Some(Component::Prefix(_)) => {
// 已在文件系统根,忽略继续上溯
}
Some(Component::ParentDir) | Some(Component::CurDir) | None => {
// 相对路径上溢:保留 `..`,后续 path_is_inside 会拒绝
out.push("..");
}
}
}
Component::Normal(part) => out.push(part),
}
}
path.to_path_buf()
out
}
fn path_contains_parent_dir(path: &Path) -> bool {
path.components()
.any(|component| matches!(component, Component::ParentDir))
}
fn canonical_or_parent(path: &Path) -> PathBuf {
// 先词法归一化,确保即便 canonicalize 失败也不会把含 `..` 的原始路径交给 starts_with。
let normalized = lexically_normalize(path);
if let Ok(canonical) = normalized.canonicalize() {
return canonical;
}
if let Some(parent) = normalized.parent() {
if !path_contains_parent_dir(parent) {
if let Ok(canonical_parent) = parent.canonicalize() {
return canonical_parent.join(normalized.file_name().unwrap_or_default());
}
}
}
normalized
}
fn path_is_inside(path: &Path, root: &Path) -> bool {
let canonical_path = canonical_or_parent(path);
let canonical_root = canonical_or_parent(root);
canonical_path.starts_with(canonical_root)
let canonical_path = lexically_normalize(&canonical_or_parent(path));
let canonical_root = lexically_normalize(&canonical_or_parent(root));
// 任一端仍含未解析的 `..` → 拒绝(fail-closed
if path_contains_parent_dir(&canonical_path) || path_contains_parent_dir(&canonical_root) {
return false;
}
canonical_path.starts_with(&canonical_root)
}
fn resolve_root_relative_path(
@@ -813,28 +867,71 @@ fn managed_session_dir(
session_id: &str,
) -> Result<PathBuf, WebError> {
let actor = pi_lab_actor_segment(&context.auth.actor_id);
// session_id 来自客户端;必须消毒,禁止 `..` / 路径分隔符跳出 pi-sessions 目录。
let session_seg = pi_lab_path_segment(session_id, "session");
if let Some(root_path) = root_uri.and_then(file_path_from_root_uri) {
return Ok(root_path
let dir = root_path
.join(".mnote")
.join("ai")
.join("pi-sessions")
.join(actor)
.join(session_id));
.join(&actor)
.join(&session_seg);
// 防御:归一化后必须仍落在 pi-sessions/<actor>/ 下
let base = root_path
.join(".mnote")
.join("ai")
.join("pi-sessions")
.join(&actor);
let normalized = lexically_normalize(&dir);
if path_contains_parent_dir(&normalized) || !normalized.starts_with(&base) {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_invalid_session_id",
"sessionId 非法,禁止路径穿越",
));
}
return Ok(dir);
}
Ok(std::env::temp_dir()
let dir = std::env::temp_dir()
.join("mnote-web")
.join("pi-lab")
.join(actor)
.join(session_id))
.join(&actor)
.join(&session_seg);
let base = std::env::temp_dir()
.join("mnote-web")
.join("pi-lab")
.join(&actor);
let normalized = lexically_normalize(&dir);
if path_contains_parent_dir(&normalized) || !normalized.starts_with(&base) {
return Err(WebError::bad_request_code(
"page_ai_pi_lab_invalid_session_id",
"sessionId 非法,禁止路径穿越",
));
}
Ok(dir)
}
fn pi_lab_path_segment(raw: &str, fallback: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return fallback.to_string();
}
// 只保留安全文件名字符;拒绝 `..` 与路径分隔。
let mut out = String::with_capacity(trimmed.len().min(128));
for ch in trimmed.chars().take(128) {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() || out == "." || out == ".." || out.contains("..") {
return fallback.to_string();
}
out
}
fn pi_lab_actor_segment(actor_id: &str) -> String {
let actor = actor_id.trim().replace(['/', '\\', ':'], "_");
if actor.is_empty() {
"anonymous".into()
} else {
actor
}
pi_lab_path_segment(actor_id, "anonymous")
}
fn is_pi_lab_warmup_session_id(session_id: &str) -> bool {
@@ -1569,12 +1666,20 @@ fn permission_mode_tool_policy(mode: Option<&str>, tool_name: &str, base_policy:
}
}
fn is_vault_tool_name(tool_name: &str) -> bool {
matches!(
tool_name,
"mnote.vault.resolve"
| "mnote.vault.login"
| "mnote.vault.session"
| "mnote.vault.list"
| "mnote.vault.get"
) || tool_name.starts_with("mnote.vault.")
}
/// 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"
{
if !is_vault_tool_name(tool_name) {
return payload.clone();
}
let mut safe = payload.clone();
@@ -1597,6 +1702,24 @@ fn redact_vault_tool_payload(tool_name: &str, payload: &Value) -> Value {
safe
}
/// vault 工具的 receipt 元字段(diff/version 摘要)可能间接含密文路径或值片段。
fn redact_vault_receipt_meta(
tool_name: &str,
diff_summary: Option<String>,
before_file_version: Option<String>,
after_file_version: Option<String>,
) -> (Option<String>, Option<String>, Option<String>) {
if !is_vault_tool_name(tool_name) {
return (diff_summary, before_file_version, after_file_version);
}
(
diff_summary.map(|_| "[redacted-vault-meta]".into()),
// file_version 通常是内容哈希;vault 工具侧仍 fail-closed 不落盘自由文本摘要
before_file_version.map(|_| "[redacted]".into()),
after_file_version.map(|_| "[redacted]".into()),
)
}
fn session_permission_mode(session: &PiLabSession) -> Option<&str> {
session
.runtime_policy_snapshot
@@ -2045,7 +2168,11 @@ fn schedule_mcp_cache_sync(session_id: String, config_dir: PathBuf, shared_cache
}
fn shell_glob_escape_path(path: &str) -> String {
// 路径写入 permission JSON 的 path 规则时,必须把 glob 元字符转义,
// 否则目录名含 `*`/`?` 会被当成通配,扩大到 sibling 路径。
path.replace('\\', "\\\\")
.replace('*', "\\*")
.replace('?', "\\?")
.replace('[', "\\[")
.replace(']', "\\]")
.replace('{', "\\{")
@@ -2463,7 +2590,8 @@ fn pi_lab_session_tool_policy(session: &PiLabSession, tool_name: &str) -> String
.and_then(|policies| policies.get(tool_name))
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| "allow".into());
// 策略缺失时 fail-closed:禁止默认 allow 绕过权限。
.unwrap_or_else(|| "deny".into());
permission_mode_tool_policy(session_permission_mode(session), tool_name, &base_policy)
}
@@ -2616,12 +2744,13 @@ fn pi_lab_tool_approval_confirmed(
if !confirmed {
return false;
}
// fail-closedapproval 未带 toolName 时不得放行(避免跨工具复用 approval)
if !approval
.get("toolName")
.or_else(|| approval.get("tool_name"))
.and_then(Value::as_str)
.map(|name| name == tool_name)
.unwrap_or(true)
.unwrap_or(false)
{
return false;
}
@@ -3324,16 +3453,48 @@ fn cleanup_expired_sessions() {
}
for (_, session_dir) in expired {
let path = PathBuf::from(session_dir);
if path
.components()
.any(|component| component.as_os_str() == "pi-sessions")
{
let _ = fs::remove_dir_all(path);
if is_safe_pi_session_dir_to_delete(&path) {
let _ = fs::remove_dir_all(&path);
}
}
}
}
/// 过期会话目录删除守卫:词法归一化后不得含 `..`,且路径段必须匹配
/// `…/.mnote/ai/pi-sessions/…` 或 `…/mnote-web/pi-lab/…`;若路径已存在则再对
/// canonicalize 结果复检,防止 `pi-sessions/../../etc` 类穿越。
fn is_safe_pi_session_dir_to_delete(path: &Path) -> bool {
fn looks_like_managed_session_dir(p: &Path) -> bool {
let comps: Vec<_> = p
.components()
.filter_map(|c| c.as_os_str().to_str().map(str::to_string))
.collect();
// workspace 托管:`.mnote/ai/pi-sessions`
if comps.windows(3).any(|w| {
w[0] == ".mnote" && w[1] == "ai" && w[2] == "pi-sessions"
}) {
return true;
}
// 无 root_uri 时的临时目录:`mnote-web/pi-lab`
comps
.windows(2)
.any(|w| w[0] == "mnote-web" && w[1] == "pi-lab")
}
let normalized = lexically_normalize(path);
if path_contains_parent_dir(&normalized) || !looks_like_managed_session_dir(&normalized) {
return false;
}
match fs::canonicalize(path) {
Ok(canon) => {
let cnorm = lexically_normalize(&canon);
!path_contains_parent_dir(&cnorm) && looks_like_managed_session_dir(&cnorm)
}
// 目录不存在:无需删除;canonicalize 失败(权限等)→ fail-closed 不删
Err(_) => false,
}
}
fn check_rate_limit(actor_id: &str, action: &str, limit: usize) -> Result<(), WebError> {
let now = now_ms();
let key = format!("{actor_id}:{action}");
@@ -4012,8 +4173,12 @@ async fn send_rpc_command_wait(
pending.insert(key.clone(), tx);
}
// Send command
send_rpc_command(session_id, command).await?;
// Send command;失败时必须清理 pending,避免 map 泄漏
if let Err(error) = send_rpc_command(session_id, command).await {
let mut pending = PI_LAB_PENDING_RPC_RESPONSES.lock().await;
pending.remove(&key);
return Err(error);
}
// Wait for response with timeout
let result = tokio::time::timeout(timeout, rx).await;
@@ -4429,11 +4594,10 @@ fn apply_single_operation(current: &str, op: &Value) -> Result<String, WebError>
}
fn file_version(path: &Path) -> Option<String> {
// 跨进程稳定:DefaultHasher 每进程随机种子,会破坏持久化 fileVersion 可比性。
let bytes = fs::read(path).ok()?;
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hasher);
Some(format!("{:016x}", hasher.finish()))
let digest = Sha256::digest(&bytes);
Some(hex::encode(digest))
}
fn write_receipt(
@@ -4668,14 +4832,17 @@ fn mnote_repo_root() -> PathBuf {
}
fn pi_codex_extra_writable_dirs(repo_root: &Path) -> Vec<PathBuf> {
[
repo_root.join(".codex").join("skills"),
PathBuf::from("/home/lix/.codex/skills"),
PathBuf::from("/home/lix/.agents/skills"),
]
.into_iter()
.filter(|path| path.exists())
.collect()
// 禁止硬编码开发者家目录;仅允许:仓库内 skills、当前用户 HOME/XDG 下的 skills。
let mut dirs = vec![repo_root.join(".codex").join("skills")];
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
dirs.push(home.join(".codex").join("skills"));
dirs.push(home.join(".agents").join("skills"));
dirs.push(home.join(".grok").join("skills"));
}
if let Some(xdg) = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from) {
dirs.push(xdg.join("mnote").join("skills"));
}
dirs.into_iter().filter(|path| path.exists()).collect()
}
impl PiLabToolFacade {
@@ -5297,7 +5464,8 @@ impl PiLabToolFacade {
let desired = string_param(&params, "desiredOutcome")
.or_else(|| string_param(&params, "goal"))
.unwrap_or_else(|| "修复根因;如果不能安全修复,说明阻塞原因和下一步".into());
let allow_writes = bool_param(&params, "allowWrites").unwrap_or(true);
// 最小权限:未显式 allowWrites=true 时只读 sandbox,避免自救默认可写工作区
let allow_writes = bool_param(&params, "allowWrites").unwrap_or(false);
let timeout_secs = u64_param(&params, "timeoutSeconds")
.unwrap_or(300)
.clamp(60, 900);
@@ -5474,10 +5642,11 @@ async fn execute_tool(
session: session.clone(),
};
let started = now_ms();
// 无会话上下文时 fail-closed:禁止默认 allow。
let tool_policy = session
.as_ref()
.map(|session| pi_lab_session_tool_policy(session, &tool_name))
.unwrap_or_else(|| "allow".into());
.unwrap_or_else(|| "deny".into());
if tool_policy == "deny" {
return Err(WebError::new(
StatusCode::FORBIDDEN,
@@ -5589,6 +5758,13 @@ async fn execute_tool(
.map(|items| items.len())
})
.unwrap_or(0);
// vault 工具:receipt / artifact / tool_call 元数据一并脱敏,避免 move 后仍用明文。
let (diff_summary, before_file_version, after_file_version) = redact_vault_receipt_meta(
&tool_name,
diff_summary,
before_file_version,
after_file_version,
);
let receipt = receipt_for(
&context,
session.as_ref(),
@@ -5714,75 +5890,60 @@ pub async fn status(
}
cleanup_expired_sessions();
let actor_id = ensure_authenticated(&state, &context)?;
let sessions = PI_LAB_SESSIONS.lock().ok();
let current_session = sessions.as_ref().and_then(|sessions| {
sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.filter(|session| session_can_auto_resume(session))
.max_by_key(|session| session.updated_at_ms)
.cloned()
});
let warmup_session = sessions.as_ref().and_then(|sessions| {
sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| is_pi_lab_warmup_session_id(&session.session_id))
.filter(|session| session_can_auto_resume(session))
.max_by_key(|session| session.updated_at_ms)
.cloned()
});
// 锁中毒 = 共享状态可能已损坏;禁止 .ok() 静默空态(fail-open)。
let sessions = PI_LAB_SESSIONS.lock().map_err(|_| {
WebError::internal("PI_LAB_SESSIONS 锁中毒,共享状态可能已损坏")
.with_context(&context)
})?;
let current_session = sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.filter(|session| session_can_auto_resume(session))
.max_by_key(|session| session.updated_at_ms)
.cloned();
let warmup_session = sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| is_pi_lab_warmup_session_id(&session.session_id))
.filter(|session| session_can_auto_resume(session))
.max_by_key(|session| session.updated_at_ms)
.cloned();
let active_session_count = sessions
.as_ref()
.map(|sessions| {
sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.count()
})
.unwrap_or(0);
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.count();
let owned_session_ids = sessions
.as_ref()
.map(|sessions| {
sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.map(|session| session.session_id.clone())
.collect::<Vec<_>>()
})
.unwrap_or_default();
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| !is_pi_lab_warmup_session_id(&session.session_id))
.map(|session| session.session_id.clone())
.collect::<Vec<_>>();
let owned_warmup_session_ids = sessions
.as_ref()
.map(|sessions| {
sessions
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| is_pi_lab_warmup_session_id(&session.session_id))
.map(|session| session.session_id.clone())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let process_count = PI_LAB_PROCESSES
.values()
.filter(|session| session.mnote_user_id == actor_id)
.filter(|session| is_pi_lab_warmup_session_id(&session.session_id))
.map(|session| session.session_id.clone())
.collect::<Vec<_>>();
// 在同一把进程锁下统计,避免两次独立加锁导致状态窗口不一致。
let (process_count, warmup_process_count) = PI_LAB_PROCESSES
.lock()
.map(|processes| {
owned_session_ids
let process_count = owned_session_ids
.iter()
.filter(|session_id| processes.contains_key(*session_id))
.count()
})
.unwrap_or(0);
let warmup_process_count = PI_LAB_PROCESSES
.lock()
.map(|processes| {
owned_warmup_session_ids
.count();
let warmup_process_count = owned_warmup_session_ids
.iter()
.filter(|session_id| processes.contains_key(*session_id))
.count()
.count();
(process_count, warmup_process_count)
})
.unwrap_or(0);
.map_err(|_| {
WebError::internal("PI_LAB_PROCESSES 锁中毒,共享状态可能已损坏")
.with_context(&context)
})?;
let (runtime_impl, runtime_binary, runtime_available, runtime_install_hint) =
pi_runtime_status_snapshot();
let runtime_error = current_session
@@ -5962,12 +6123,13 @@ pub async fn start(
}
}
}
if kill_session_process(&requested_session.session_id).await {
update_session(&requested_session.session_id, |session| {
session.status = PiLabSessionStatus::Aborted;
session.runtime_pid = None;
});
}
// 无论 kill 是否找到进程,都先把会话标为 Aborted,避免旧 runtime 状态残留后
// 与 start_runtime_for_session 并发。
let _ = kill_session_process(&requested_session.session_id).await;
update_session(&requested_session.session_id, |session| {
session.status = PiLabSessionStatus::Aborted;
session.runtime_pid = None;
});
let session = start_runtime_for_session(&state, requested_session).await?;
Ok(Json(pi_lab_start_response(&session, false)))
}
@@ -7471,7 +7633,8 @@ fn build_run_runtime_json(session: &PiLabSession) -> String {
"allowedRootsSnapshot": session.allowed_roots_snapshot,
"runtimePolicy": session.runtime_policy_snapshot,
}))
.unwrap_or_default()
// 必须是合法 JSON:空串会让下游 `from_str` 失败;失败时回退 `{}`。
.unwrap_or_else(|_| "{}".to_string())
}
fn build_upsert_run_input(session: &PiLabSession) -> UpsertAiRuntimeRunInput {
@@ -7550,11 +7713,9 @@ fn read_pi_session_jsonl(session: &PiLabSession) -> Result<Vec<Value>, WebError>
let path = Path::new(pi_session_file);
// 安全验证:路径必须在 session pi_session_dir 内
// 安全验证:路径必须在 session pi_session_dir 内path_is_inside 含词法归一化 + `..` fail-closed
let session_dir = Path::new(&session.pi_session_dir);
let canonical_path = canonical_or_parent(path);
let canonical_dir = canonical_or_parent(session_dir);
if !canonical_path.starts_with(&canonical_dir) {
if !path_is_inside(path, session_dir) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"page_ai_pi_lab_jsonl_path_escape",
@@ -8908,7 +9069,8 @@ pub async fn rename_session(
update_session(&path.session_id, |session| {
session.page_title = Some(title.to_string());
});
let mut pi_rpc_response = Value::Null;
// 不向客户端回传原始 Pi RPC 响应(可能含 runtime 内部状态);仅暴露是否成功。
let mut pi_rpc_ok = false;
if let Some(session) = get_session(&path.session_id) {
if session.runtime_mode != "mock" && session_runtime_is_usable(&session) {
if let Some(response) = send_rpc_command_wait(
@@ -8922,7 +9084,10 @@ pub async fn rename_session(
)
.await?
{
pi_rpc_response = response;
pi_rpc_ok = response
.get("ok")
.and_then(Value::as_bool)
.unwrap_or(true);
}
}
}
@@ -8931,7 +9096,7 @@ pub async fn rename_session(
"schema": "mnote.page_ai_pi.rename_session.v1",
"sessionId": path.session_id,
"title": title,
"piRpcResponse": pi_rpc_response,
"piRpcOk": pi_rpc_ok,
"updatedRuns": renamed.len(),
})))
}
@@ -8945,6 +9110,108 @@ mod tests {
use control_plane::{DirectoryGrantInput, UpsertAiPolicyInput, UpsertUserInput};
use tower::util::ServiceExt;
#[test]
fn lexically_normalize_collapses_parent_dirs() {
let path = PathBuf::from("/root/workspace/../../etc/passwd");
let normalized = lexically_normalize(&path);
assert_eq!(normalized, PathBuf::from("/etc/passwd"));
assert!(!path_contains_parent_dir(&normalized));
}
#[test]
fn path_is_inside_rejects_parent_dir_escape_without_existing_target() {
let root = temp_root("pi-path-inside-root");
// 目标目录不存在时 canonicalize 会失败;旧实现仅靠 starts_with 词法匹配会放行。
let escape = root.join("../../etc/passwd_should_not_escape");
assert!(
!path_is_inside(&escape, &root),
"含 .. 且未 canonicalize 的路径不得判定为 inside root"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn path_is_inside_allows_nested_file_under_root() {
let root = temp_root("pi-path-inside-ok");
let nested = root.join("docs").join("note.md");
fs::create_dir_all(nested.parent().unwrap()).expect("mkdir");
fs::write(&nested, "ok").expect("write");
assert!(path_is_inside(&nested, &root));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn build_run_runtime_json_is_valid_object_json() {
let session = PiLabSession {
session_id: "sess-1".into(),
mnote_user_id: "u1".into(),
bridge_token: "pi_bridge_test".into(),
status: PiLabSessionStatus::Idle,
provider_session_id: "prov-1".into(),
pi_session_dir: "/tmp/pi".into(),
pi_session_file: Some("/tmp/pi/session.jsonl".into()),
root_uri: Some("file:///tmp".into()),
workspace_id: Some("ws".into()),
page_path: Some("a.md".into()),
page_title: Some("A".into()),
model_provider: Some("openai".into()),
model_id: Some("gpt".into()),
thinking_level: None,
allowed_roots_snapshot: None,
runtime_policy_snapshot: None,
runtime_pid: None,
runtime_mode: "lab".into(),
runtime_error: None,
created_at_ms: 1,
updated_at_ms: 1,
message_count: 0,
};
let raw = build_run_runtime_json(&session);
assert!(!raw.is_empty(), "不得回退空串");
let parsed: Value = serde_json::from_str(&raw).expect("必须是合法 JSON");
assert!(parsed.is_object());
assert_eq!(parsed["providerSessionId"], "prov-1");
assert_eq!(parsed["workspaceId"], "ws");
// session_id 不进 runtime_json 字段集合(仅 run 元数据)
assert!(parsed.get("sessionId").is_none());
}
#[test]
fn generate_bridge_token_is_unpredictable_hex() {
let a = generate_bridge_token();
let b = generate_bridge_token();
assert!(a.starts_with("pi_bridge_"));
assert_ne!(a, b);
// 32 字节 → 64 hex
assert_eq!(a.len(), "pi_bridge_".len() + 64);
}
#[test]
fn shell_glob_escape_path_escapes_wildcards() {
let escaped = shell_glob_escape_path("/home/user/project*/docs?");
assert_eq!(escaped, r"/home/user/project\*/docs\?");
// 已有的 bracket/brace 转义仍保留
assert_eq!(
shell_glob_escape_path(r"/tmp/a[b]{c}"),
r"/tmp/a\[b\]\{c\}"
);
// 反斜杠先加倍,避免二次解释
assert_eq!(shell_glob_escape_path(r"a\b*"), r"a\\b\*");
}
#[test]
fn pi_lab_tool_params_hash_is_sha256_hex_and_stable() {
let params = json!({"path": "a.md", "mnoteApproval": {"id": "x"}});
let hash = pi_lab_tool_params_hash(&params);
assert_eq!(hash.len(), 64);
// mnoteApproval 被剥离后应与无 approval 的 params 一致
let without = json!({"path": "a.md"});
assert_eq!(hash, pi_lab_tool_params_hash(&without));
// 不同参数不得碰撞(强哈希)
let other = json!({"path": "b.md"});
assert_ne!(hash, pi_lab_tool_params_hash(&other));
}
fn test_state() -> AppState {
AppState::new(AppConfig {
service_name: "mnote-web".into(),
@@ -8965,6 +9232,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
})
}
@@ -83,12 +83,25 @@ pub async fn block_edit_workflow(
)
.with_context(&context));
}
let actor_id =
if context.auth.actor_id.trim().is_empty() || context.auth.actor_id == "anonymous" {
state.config().dev_user_id.clone()
// Fail-closed: unauthenticated actors cannot write via the page-ai workflow.
// Dev fixtures may use dev_user_id only when explicitly enabled.
let actor_id = {
let raw = context.auth.actor_id.trim();
if raw.is_empty() || raw == "anonymous" {
if state.config().allow_dev_fixtures {
state.config().dev_user_id.clone()
} else {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"page_ai_workflow_auth_required",
"页面 AI 工作流需要先登录",
)
.with_context(&context));
}
} else {
context.auth.actor_id.clone()
};
}
};
let allowed_target_block_ids = ai_context
.get("allowedTargetBlockIds")
.and_then(Value::as_array)
@@ -103,7 +116,17 @@ pub async fn block_edit_workflow(
})
.unwrap_or_default();
let mut markdown_args = json!({
"operations": markdown_operations.clone()
"operations": markdown_operations.clone(),
// 写入守卫 fail-closed:快路径也必须显式授予写权限。
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": [document_id.clone()],
"allowedTargetBlockIds": allowed_target_block_ids.clone(),
},
"commandContext": {
"ai.canWrite": true,
"workspace.readonly": false
}
});
if !allowed_target_block_ids.is_empty() {
if let Value::Object(map) = &mut markdown_args {
@@ -172,13 +195,29 @@ struct MarkdownEditPlan {
summary: Option<String>,
}
/// 模型输出可能把 JSON 再包一层 `choices[0].message.content` 字符串;限制解包深度防栈溢出。
const MODEL_JSON_UNWRAP_MAX_DEPTH: u8 = 4;
fn extract_markdown_plan_from_model_text(text: &str) -> Result<MarkdownEditPlan, WebError> {
extract_markdown_plan_from_model_text_depth(text, 0)
}
fn extract_markdown_plan_from_model_text_depth(
text: &str,
depth: u8,
) -> Result<MarkdownEditPlan, WebError> {
if depth > MODEL_JSON_UNWRAP_MAX_DEPTH {
return Err(WebError::bad_request_code(
"page_ai_workflow_model_output_too_nested",
"模型输出嵌套过深,拒绝解析",
));
}
let parsed = parse_model_json(text)?;
if let Some(content) = parsed
.pointer("/choices/0/message/content")
.and_then(Value::as_str)
{
return extract_markdown_plan_from_model_text(content);
return extract_markdown_plan_from_model_text_depth(content, depth + 1);
}
let summary = parsed
.get("summary")
@@ -237,12 +276,26 @@ fn extract_markdown_plan_from_model_text(text: &str) -> Result<MarkdownEditPlan,
#[allow(dead_code)]
fn extract_operations_from_model_text(text: &str) -> Result<Vec<Value>, WebError> {
extract_operations_from_model_text_depth(text, 0)
}
#[allow(dead_code)]
fn extract_operations_from_model_text_depth(
text: &str,
depth: u8,
) -> Result<Vec<Value>, WebError> {
if depth > MODEL_JSON_UNWRAP_MAX_DEPTH {
return Err(WebError::bad_request_code(
"page_ai_workflow_model_output_too_nested",
"模型输出嵌套过深,拒绝解析",
));
}
let parsed = parse_model_json(text)?;
if let Some(content) = parsed
.pointer("/choices/0/message/content")
.and_then(Value::as_str)
{
return extract_operations_from_model_text(content);
return extract_operations_from_model_text_depth(content, depth + 1);
}
if let Some(operations) = parsed.get("operations").and_then(Value::as_array) {
return Ok(operations.clone());
@@ -385,7 +438,13 @@ async fn call_block_edit_model(
.with_context(context)
})?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
let text = response.text().await.map_err(|error| {
WebError::bad_gateway_code(
"page_ai_workflow_model_body_read_failed",
format!("页面 AI workflow 模型响应体读取失败: {error}"),
)
.with_context(context)
})?;
if !status.is_success() {
return Err(WebError::bad_gateway_code(
"page_ai_workflow_model_failed",
@@ -545,14 +604,50 @@ fn agent_profile_home() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(".mnote-agent"))
}
/// profile 名仅允许安全文件名字符,拒绝 `..` / 路径分隔,防止 profiles join 穿越。
fn sanitize_workflow_profile_name(profile: &str) -> String {
let trimmed = profile.trim();
if trimmed.is_empty() {
return "default".to_string();
}
let mut out = String::with_capacity(trimmed.len().min(64));
for ch in trimmed.chars().take(64) {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() || out == "." || out == ".." || out.contains("..") {
return "default".to_string();
}
out
}
fn profile_config_path(profile: &str) -> PathBuf {
let home = agent_profile_home();
let profile = profile.trim();
if profile.is_empty() || profile == "default" {
return home.join("config.yaml");
}
let candidate = home.join("profiles").join(profile);
if candidate.exists() {
let safe = sanitize_workflow_profile_name(profile);
if safe == "default" {
return home.join("config.yaml");
}
let profiles_root = home.join("profiles");
let candidate = profiles_root.join(&safe);
// 防御:消毒后仍须落在 profiles 目录内(不解析 symlink,仅词法检查)。
if candidate
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return home.join("config.yaml");
}
// 禁止跟随 profiles 下指向外部的 symlinkexists/is_dir 会跟随)。
if candidate.is_symlink() {
return home.join("config.yaml");
}
if candidate.is_dir() {
candidate.join("config.yaml")
} else {
home.join("config.yaml")
@@ -673,6 +768,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -978,4 +1074,56 @@ mod tests {
std::env::remove_var("MNOTE_AGENT_HOME");
let _ = fs::remove_dir_all(&agent_home);
}
#[test]
fn profile_config_path_rejects_traversal() {
use super::{profile_config_path, sanitize_workflow_profile_name};
use std::path::Component;
assert_eq!(sanitize_workflow_profile_name("../../etc"), "default");
assert_eq!(sanitize_workflow_profile_name("mnoteai"), "mnoteai");
assert_eq!(sanitize_workflow_profile_name("a/b"), "a_b");
let path = profile_config_path("../../etc/passwd");
assert!(
path.components().all(|c| !matches!(c, Component::ParentDir)),
"profile path must not contain ParentDir: {path:?}"
);
// 危险 profile 回落到 home/config.yaml,不得 join 原始 ../../
assert!(
path.ends_with("config.yaml"),
"expected config.yaml fallback, got {path:?}"
);
let path2 = profile_config_path("mnoteai");
// may or may not exist; must be under profiles/mnoteai or home config
assert!(
path2.components().all(|c| !matches!(c, Component::ParentDir))
);
}
#[test]
fn extract_markdown_plan_rejects_deeply_nested_content() {
use super::extract_markdown_plan_from_model_text;
// 5 层 choices.content 嵌套 → 超过 MODEL_JSON_UNWRAP_MAX_DEPTH(4)
let mut nested = r#"{"operations":[{"search":"a","replace":"b"}]}"#.to_string();
for _ in 0..5 {
nested = format!(
r#"{{"choices":[{{"message":{{"content":{}}}}}]}}"#,
serde_json::to_string(&nested).expect("escape")
);
}
let err = match extract_markdown_plan_from_model_text(&nested) {
Ok(_) => panic!("must reject deeply nested model JSON"),
Err(e) => e,
};
let msg = err.message();
assert!(
msg.contains("嵌套")
|| msg.contains("too_nested")
|| format!("{err:?}").contains("too_nested")
|| format!("{err:?}").contains("嵌套"),
"unexpected err: {err:?}"
);
}
}
@@ -103,10 +103,16 @@ fn stamp_headers(headers: &mut HeaderMap) {
}
}
async fn current_user_id(state: &AppState, context: &RequestContext) -> String {
/// Resolve the acting user for trash mutations.
/// Fail-closed: unauthenticated callers only fall back to `dev_user_id` when
/// `allow_dev_fixtures` is enabled (local/dev). Production must present a real actor.
async fn current_user_id(
state: &AppState,
context: &RequestContext,
) -> Result<String, WebError> {
let user_id = context.auth.actor_id.trim();
if !user_id.is_empty() && user_id != "anonymous" {
return user_id.to_string();
return Ok(user_id.to_string());
}
let has_auth_cookie = context
@@ -135,13 +141,23 @@ async fn current_user_id(state: &AppState, context: &RequestContext) -> String {
.map(str::trim)
.filter(|value| !value.is_empty())
{
return user_id.to_string();
return Ok(user_id.to_string());
}
}
}
}
state.config().dev_user_id.clone()
if state.config().allow_dev_fixtures {
return Ok(state.config().dev_user_id.clone());
}
Err(WebError::new(
StatusCode::UNAUTHORIZED,
"resource_trash_auth_required",
"资源回收站操作需要先登录",
)
.with_context(context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"))
}
fn require_id<'a>(
@@ -521,7 +537,7 @@ pub async fn media_batch(
Extension(context): Extension<RequestContext>,
Json(body): Json<MediaBatchRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await;
let user_id = current_user_id(&state, &context).await?;
let action = body.action.trim();
if action != "restore" && action != "delete" && action != "rename" && action != "move" {
return Err(WebError::bad_request_code(
@@ -600,6 +616,15 @@ pub async fn media_batch(
document_id.as_deref(),
payload,
);
// 先执行 tree runtime command;成功后再做 legacy media store patch
// 避免 patch 已生效而 tree 失败时调用方仍看到成功(与 restore/delete 一致)。
execute_runtime_command_via_legacy_cloud_with_artifacts(
&state,
&context,
workspace_id.as_deref(),
command,
)
.await?;
if action == "rename" {
let new_name = require_id(
&context,
@@ -646,25 +671,6 @@ pub async fn media_batch(
)
.await?;
}
let command_result = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state,
&context,
workspace_id.as_deref(),
command,
)
.await;
if action == "rename" || action == "move" {
if let Err(error) = command_result {
tracing::warn!(
error = %error.message(),
asset_id = %asset_id,
action = %action,
"tree.resource artifact command 失败,已保留兼容 patch 结果"
);
}
} else {
command_result?;
}
updated += 1;
}
@@ -695,7 +701,7 @@ pub async fn media_purge(
Extension(context): Extension<RequestContext>,
Json(body): Json<MediaPurgeRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await;
let user_id = current_user_id(&state, &context).await?;
let asset_id = require_id(&context, &body.asset_id, "assetId")?;
let asset = fetch_media_asset_meta(&state, &context, &user_id, asset_id).await?;
let (workspace_id, document_id) = read_asset_workspace_and_document(&asset);
@@ -727,7 +733,7 @@ pub async fn media_empty_trash(
Extension(context): Extension<RequestContext>,
Json(body): Json<WorkspaceTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await;
let user_id = current_user_id(&state, &context).await?;
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
let result = execute_retired_mutation_by_name(
state.config(),
@@ -775,7 +781,7 @@ pub async fn mindmap_delete(
Path((doc_id, mindmap_id)): Path<(String, String)>,
Query(query): Query<MindmapLocalQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await;
let user_id = current_user_id(&state, &context).await?;
let doc_id = require_id(&context, &doc_id, "docId")?;
let mindmap_id = require_id(&context, &mindmap_id, "mindmapId")?;
if query.source_kind.as_deref() == Some("local_folder") {
@@ -845,7 +851,7 @@ pub async fn mindmap_trash_action(
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
}
};
let user_id = current_user_id(&state, &context).await;
let user_id = current_user_id(&state, &context).await?;
let doc_id = require_id(&context, &doc_id, "docId")?;
let mindmap_id = require_id(&context, &mindmap_id, "mindmapId")?;
let local_source_kind = body
@@ -971,7 +977,7 @@ pub async fn table_create(
Extension(context): Extension<RequestContext>,
Json(body): Json<TableCreateRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await;
let user_id = current_user_id(&state, &context).await?;
let document_id = require_id(&context, &body.document_id, "documentId")?;
let workspace_id = fetch_document_workspace_id(&state, &context, document_id)
.await
@@ -1048,7 +1054,7 @@ async fn table_action(
function_name: &'static str,
error_phase: &'static str,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await;
let user_id = current_user_id(&state, &context).await?;
let table_id = require_id(&context, &table_id, "tableId")?;
let table_meta = fetch_table_meta(&state, &context, &user_id, table_id).await;
let workspace_id = table_meta
@@ -1106,7 +1112,7 @@ pub async fn table_empty_trash(
Extension(context): Extension<RequestContext>,
Json(body): Json<WorkspaceTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await;
let user_id = current_user_id(&state, &context).await?;
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
let result = execute_retired_mutation_by_name(
state.config(),
@@ -1198,6 +1204,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
+4 -5
View File
@@ -6,7 +6,7 @@ use crate::routes::query_support::{
execute_runtime_query_against_data, execute_runtime_query_via_legacy_cloud,
resolve_effective_workspace_id,
};
use crate::routes::web_shell::load_sidebar_tree_html;
use crate::routes::web_shell::{escape_script_json, load_sidebar_tree_html};
use crate::routes::{local_folder_source, local_search_index};
use crate::ssr::pages::search::SearchPage;
use axum::extract::{Extension, Query, State};
@@ -734,10 +734,6 @@ fn render_initial_results_html(results: Option<&Vec<Value>>) -> String {
format!(r#"<div class="search-result-list">{items}</div>"#)
}
fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
@@ -786,6 +782,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -916,6 +913,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}));
let response = app
.oneshot(
@@ -1527,6 +1525,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
state
.control_plane()
+41 -27
View File
@@ -103,37 +103,44 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
.as_deref()
.and_then(|cookies| encoded_cookie_value(cookies, COOKIE_ACTOR_NAME))
.or_else(|| jwt_cookie_claim(&context, &["name", "username"]));
let user_id = if has_forwarded_actor {
actor_id.to_string()
} else {
state.config().dev_user_id.clone()
};
let actor_type = if has_forwarded_actor {
effective_actor_type_for_user(actor_id, &context.auth.actor_type)
} else {
"devFallback".to_string()
};
// Fail-closed: only fall back to dev identity when allow_dev_fixtures is on.
// Production unauthenticated callers get anonymous (not a privileged dev user).
if !has_forwarded_actor {
if state.config().allow_dev_fixtures {
return SessionResponse {
ok: true,
owner: "mnote-web",
user_id: state.config().dev_user_id.clone(),
email: state.config().dev_user_email.clone(),
name: state.config().dev_user_name.clone(),
actor_type: "devFallback".to_string(),
auth_mode: "devFallback",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
};
}
return SessionResponse {
ok: true,
owner: "mnote-web",
user_id: "anonymous".to_string(),
email: String::new(),
name: "anonymous".to_string(),
actor_type: "anonymous".to_string(),
auth_mode: "anonymous",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
};
}
SessionResponse {
ok: true,
owner: "mnote-web",
user_id,
email: if has_forwarded_actor {
actor_email.unwrap_or_default()
} else {
state.config().dev_user_email.clone()
},
name: if has_forwarded_actor {
actor_name.unwrap_or_else(|| actor_id.to_string())
} else {
state.config().dev_user_name.clone()
},
actor_type,
auth_mode: if has_forwarded_actor {
"forwardedActor"
} else {
"devFallback"
},
user_id: actor_id.to_string(),
email: actor_email.unwrap_or_default(),
name: actor_name.unwrap_or_else(|| actor_id.to_string()),
actor_type: effective_actor_type_for_user(actor_id, &context.auth.actor_type),
auth_mode: "forwardedActor",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
}
@@ -152,6 +159,9 @@ fn effective_actor_type_for_user(user_id: &str, stored_role: &str) -> String {
}
}
/// 仅用于 **展示** email/name 补全;**绝不**作为 user_id / 鉴权真源。
/// 无签名校验:任意客户端可伪造 JWT payload 中的展示字段。
/// 身份仍以 control-plane session / header actor 为准(见上方 get_session 分支)。
fn jwt_cookie_claim(context: &RequestContext, keys: &[&str]) -> Option<String> {
let token = context
.auth
@@ -239,6 +249,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -341,6 +352,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
state
.control_plane()
@@ -410,6 +422,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
state
.control_plane()
@@ -490,6 +503,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
state
.control_plane()
@@ -157,13 +157,21 @@ pub async fn delete_shortcut(
)
.with_context(&context)
})?;
let _ = state.control_plane().append_audit(AppendAuditInput {
if let Err(error) = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor_id),
action: "sidebar.shortcut.removed".to_string(),
target_kind: "sidebar_shortcut".to_string(),
target_id: Some(shortcut_id.clone()),
metadata_json: "{}".to_string(),
});
}) {
// 审计失败不回滚业务删除,但必须可观测(避免静默 fail-open)。
tracing::warn!(
target: "mnote_web::sidebar_shortcuts",
error = %error,
shortcut_id = %shortcut_id,
"sidebar shortcut delete audit append failed"
);
}
Ok(Json(json!({
"ok": true,
"owner": "mnote-web",
@@ -256,7 +264,7 @@ fn append_shortcut_audit(
action: &str,
shortcut: &SidebarShortcutRecord,
) {
let _ = state.control_plane().append_audit(AppendAuditInput {
if let Err(error) = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor_id.to_string()),
action: action.to_string(),
target_kind: "sidebar_shortcut".to_string(),
@@ -267,7 +275,15 @@ fn append_shortcut_audit(
"targetId": shortcut.target_id,
})
.to_string(),
});
}) {
tracing::warn!(
target: "mnote_web::sidebar_shortcuts",
error = %error,
action = %action,
shortcut_id = %shortcut.id,
"sidebar shortcut audit append failed"
);
}
}
trait EmptyStringExt {
+70 -9
View File
@@ -53,6 +53,21 @@ async fn events_with_stream_delta(
let state_for_stream = state.clone();
let context_for_stream = context.clone();
let query_for_stream = query.clone();
let subscription_workspace = query
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| {
context
.workspace
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
});
let stream = stream::unfold(
Some(StreamPollState {
app_state: state_for_stream,
@@ -64,6 +79,7 @@ async fn events_with_stream_delta(
initial_emitted: false,
block_delta_rx,
stream_delta_rx,
subscription_workspace,
}),
move |state| async move {
let mut state = state?;
@@ -76,11 +92,17 @@ async fn events_with_stream_delta(
));
}
// Check block.delta broadcast first
// Check block.delta broadcast first(按订阅 workspace 过滤,防跨工作区泄露)
if let Some(ref mut rx) = state.block_delta_rx {
match rx.try_recv() {
Ok(payload) => {
return Some((Ok(stream_event("block.delta", &payload)), Some(state)));
if delta_matches_workspace(
&payload,
state.subscription_workspace.as_deref(),
) {
return Some((Ok(stream_event("block.delta", &payload)), Some(state)));
}
// 非本工作区:丢弃并继续同一 tick 的后续检查
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
@@ -94,12 +116,17 @@ async fn events_with_stream_delta(
if let Some(ref mut rx) = state.stream_delta_rx {
match rx.try_recv() {
Ok(payload) => {
let hint = build_stream_push_delta_hint(
if delta_matches_workspace(
&payload,
&state.context.trace.request_id,
&state.context.trace.trace_id,
);
return Some((Ok(stream_event("delta", &hint)), Some(state)));
state.subscription_workspace.as_deref(),
) {
let hint = build_stream_push_delta_hint(
&payload,
&state.context.trace.request_id,
&state.context.trace.trace_id,
);
return Some((Ok(stream_event("delta", &hint)), Some(state)));
}
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
@@ -184,11 +211,19 @@ async fn events_with_stream_delta(
state.polls += 1;
sleep(Duration::from_millis(poll_ms)).await;
// Check block.delta after poll sleep
// Check block.delta after poll sleep(同样按 workspace 过滤)
if let Some(ref mut rx) = state.block_delta_rx {
match rx.try_recv() {
Ok(payload) => {
return Some((Ok(stream_event("block.delta", &payload)), Some(state)));
if delta_matches_workspace(
&payload,
state.subscription_workspace.as_deref(),
) {
return Some((
Ok(stream_event("block.delta", &payload)),
Some(state),
));
}
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
@@ -305,6 +340,31 @@ struct StreamPollState {
#[allow(dead_code)]
block_delta_rx: Option<tokio::sync::broadcast::Receiver<Value>>,
stream_delta_rx: Option<tokio::sync::broadcast::Receiver<Value>>,
/// 订阅工作区;broadcast 推送仅转发匹配项,避免跨工作区泄露。
subscription_workspace: Option<String>,
}
/// 仅转发与当前订阅 workspace 一致的 delta(与 ws.rs 同策略)。
fn delta_matches_workspace(delta: &Value, subscription_workspace: Option<&str>) -> bool {
let Some(expected) = subscription_workspace else {
// 无订阅 workspace 时不推送带 workspace 的全局 delta(保守)
return delta
.get("workspaceId")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.is_none();
};
match delta
.get("workspaceId")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
{
Some(delta_ws) => delta_ws == expected,
// 无 workspace 标记的 delta 不转发(避免跨租户噪声)
None => false,
}
}
fn live_poll_query(query: &StreamSnapshotQuery) -> StreamSnapshotQuery {
@@ -368,6 +428,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -624,9 +624,17 @@ pub async fn load_stream_overview(
context: &RequestContext,
query: &StreamSnapshotQuery,
) -> Result<(String, Value), WebError> {
// resolve_effective_workspace_id(require=true) 在缺失时已返回 Err,不再用 expect panic。
let effective_workspace_id =
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?.ok_or_else(
|| {
WebError::bad_request_code(
"workspace_required",
"缺少 workspaceId,请在 query 或请求头中提供有效工作区",
)
.with_context(context)
},
)?;
let overview = execute_runtime_query_via_legacy_cloud(
config,
context,
@@ -643,8 +651,15 @@ pub async fn load_stream_snapshot(
query: &StreamSnapshotQuery,
) -> Result<Value, WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?.ok_or_else(
|| {
WebError::bad_request_code(
"workspace_required",
"缺少 workspaceId,请在 query 或请求头中提供有效工作区",
)
.with_context(context)
},
)?;
let scope = resolve_stream_scope(query);
let snapshot = match scope {
@@ -653,8 +668,13 @@ pub async fn load_stream_snapshot(
.await?
}
StreamSnapshotScope::Subtree => {
let root_node_id =
normalize_root_node_id(query).expect("subtree scope 已确保 rootNodeId 存在");
let root_node_id = normalize_root_node_id(query).ok_or_else(|| {
WebError::bad_request_code(
"stream_root_node_required",
"subtree 流需要 rootNodeId",
)
.with_context(context)
})?;
let dataset = load_sidebar_dataset(config, context, &effective_workspace_id).await?;
let tree = execute_kernel_query(
context,
+109 -29
View File
@@ -263,10 +263,23 @@ fn escape_html(input: &str) -> String {
}
fn escape_inline_json(input: &str) -> String {
input
.replace('&', "\\u0026")
.replace('<', "\\u003c")
.replace('>', "\\u003e")
// 嵌入 HTML/script 时除 <>& 外,还需处理 U+2028/U+2029 与控制字符,
// 避免破坏 JSON 解析或形成 XSS 面。
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'&' => out.push_str("\\u0026"),
'<' => out.push_str("\\u003c"),
'>' => out.push_str("\\u003e"),
'\u{2028}' => out.push_str("\\u2028"),
'\u{2029}' => out.push_str("\\u2029"),
c if c.is_control() && c != '\n' && c != '\r' && c != '\t' => {
out.push_str(&format!("\\u{:04x}", c as u32));
}
c => out.push(c),
}
}
out
}
fn normalize_title(value: Option<String>) -> String {
@@ -308,22 +321,42 @@ fn normalize_bool_flag(value: Option<&str>, default: bool) -> bool {
}
}
fn normalize_tree_source_kind(raw: Option<&str>) -> &'static str {
match raw.map(str::trim).unwrap_or("") {
"local_folder" | "local-folder" | "local" => "local_folder",
"convex_workspace" | "convex" | "" => "convex_workspace",
// 未知 kind 一律回落到 convex_workspace,避免客户端自报 kind 与 root_uri 脱钩
_ => "convex_workspace",
}
}
fn build_workspace_source_wire(
context: &RequestContext,
workspace_id: &str,
envelope_context: &TreeCommandEnvelopeContext,
) -> bridge_runtime::RuntimeSourceWire {
let source_kind = envelope_context
.source_kind
.clone()
.unwrap_or_else(|| "convex_workspace".into());
let root_uri = envelope_context.root_uri.clone().or_else(|| {
if source_kind == "convex_workspace" {
Some(format!("convex://workspace/{workspace_id}"))
} else {
None
// 不信任请求体自报的任意 source_kind:白名单归一化后再与 root_uri 对齐。
let source_kind = normalize_tree_source_kind(envelope_context.source_kind.as_deref()).to_string();
let root_uri = match source_kind.as_str() {
"local_folder" => {
// local_folder 必须带已由上游鉴权的 root_uri;无则不回落到 convex 假 URI
envelope_context
.root_uri
.as_ref()
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty() && !u.contains(".."))
}
});
_ => Some(
envelope_context
.root_uri
.clone()
.filter(|u| {
let t = u.trim();
!t.is_empty() && (t.starts_with("convex://") || t.starts_with("workspace:"))
})
.unwrap_or_else(|| format!("convex://workspace/{workspace_id}")),
),
};
let capabilities =
if envelope_context.source_capabilities.is_empty() && source_kind == "convex_workspace" {
vec![
@@ -895,19 +928,6 @@ fn build_tree_shell_renderer_input(
}
}
fn override_actor_context(context: &RequestContext, actor_id: Option<&str>) -> RequestContext {
let mut next = context.clone();
let actor_id = actor_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
if let Some(actor_id) = actor_id {
next.auth.actor_id = actor_id;
next.auth.actor_type = "user".into();
}
next
}
fn generate_tree_document_id() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -1836,7 +1856,10 @@ pub async fn tree_shell(
Extension(context): Extension<RequestContext>,
Query(query): Query<TreeShellQuery>,
) -> Result<Response, WebError> {
let effective_context = override_actor_context(&context, query.actor_id.as_deref());
// 安全:忽略 query.actor_id,禁止用查询参数冒充任意用户。
// actor 仅来自 RequestContextheader/cookie/session/extension token)。
let _ignored_query_actor_id = query.actor_id.as_deref();
let effective_context = context.clone();
let mode = normalize_tree_mode(query.mode.as_deref());
let allow_root_pick = normalize_bool_flag(query.allow_root_pick.as_deref(), false);
let exclude_ids = parse_exclude_ids(query.exclude_ids.as_deref());
@@ -2120,8 +2143,9 @@ fn create_command_wire(
items,
title: _,
} => {
let fallback_document_id = ensure_non_empty(&document_id, "documentId", context)?;
// 仅当 items 为空时要求顶层 documentId;已有 items 时用 items.documentId。
let copy_items = if items.is_empty() {
let fallback_document_id = ensure_non_empty(&document_id, "documentId", context)?;
vec![json!({
"documentId": fallback_document_id,
"recursive": true,
@@ -2366,6 +2390,25 @@ fn apply_local_file_operation_participants(
);
}
}
"restore" => {
// restore 后资源回到 resource 路径;若仅有 previousResource 也尝试清除。
let restore_relative = next_relative_path
.as_deref()
.or(previous_relative_path.as_deref());
let restore_document_id = next_document_id
.as_deref()
.or(previous_document_id.as_deref());
if let (Some(relative_path), Some(document_id)) =
(restore_relative, restore_document_id)
{
let _ = buffer_store.clear_local_folder_markdown_deleted(
workspace_id,
root_uri,
relative_path,
document_id,
);
}
}
_ => {}
}
}
@@ -2375,6 +2418,11 @@ pub async fn tree_command(
Extension(context): Extension<RequestContext>,
body: String,
) -> Result<(StatusCode, Json<Value>), WebError> {
// 7-76PAT 写树需要 tree.write(读投影走其它 GET,不经此入口)。
crate::routes::api_access_token::ensure_scope(
&context,
crate::routes::api_access_token::SCOPE_TREE_WRITE,
)?;
let raw_request: TreeCommandEnvelope = serde_json::from_str(&body).map_err(|error| {
WebError::bad_request_code(
"tree_command_invalid_json",
@@ -2735,6 +2783,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
.layer(axum::middleware::from_fn(inject_test_actor))
}
@@ -2817,6 +2866,35 @@ mod tests {
assert!(mindmap_row.selected, "mindmap 对象页应保持 asset row 选中");
}
#[tokio::test]
async fn tree_shell_ignores_query_actor_id_impersonation() {
// 攻击面:?actorId= 不得覆盖 middleware 注入的 actorx-mnote-actor-id=user_test)。
let response = app()
.oneshot(
Request::builder()
.uri("/tree?workspaceId=ws_demo&rootNodeId=page_root&actorId=attacker_admin&channel=test-shell")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
// 嵌入的 actor 必须是测试中间件身份,而非 query 冒充值
assert!(
html.contains("\"actorId\":\"user_test\"") || html.contains("user_test"),
"tree shell 应使用认证 actor,而非 query.actorId"
);
assert!(
!html.contains("attacker_admin"),
"query.actorId 不得写入 shell 身份上下文"
);
}
#[tokio::test]
async fn tree_shell_returns_interactive_html_document() {
let response = app()
@@ -3080,6 +3158,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] {
state
@@ -3825,6 +3904,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
for (user_id, role) in [("shujuan", None), ("liaibo", Some("admin"))] {
state
+532 -18
View File
@@ -18,9 +18,10 @@ use crate::routes::vault_store::{
use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use control_plane::AppendAuditInput;
use serde::Deserialize;
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
@@ -531,6 +532,25 @@ pub async fn list(
let root_uri = require_root_uri(query.root_uri.as_deref())?;
let root = resolve_read_root(&state, &context, root_uri).await?;
let status = parse_status(query.status.as_deref())?;
let (is_ai_vault, ai_actor) = vault_role_meta(&root);
// AI 密码本:打开列表时自动把旧分享副本分组修成 `{分享者}/{源分组}`
// (含 from-liaibo、无前缀的 个人/系统;仅识别到一个分享者时会统一归到其名下)
let folder_repair = if is_ai_vault {
vault_store::repair_ai_shared_folder_paths(
&root,
|source_actor| {
let p = local_folder_source::managed_default_workspace_root_for_actor(source_actor);
if p.exists() {
Some(p)
} else {
None
}
},
)
.ok()
} else {
None
};
let (index, items) = vault_store::list_credentials(&root, status)?;
let book = vault_store::load_cipher_book(&root).ok();
let book_ref = book.as_ref();
@@ -538,20 +558,31 @@ pub async fn list(
.iter()
.map(|e| vault_store::project_list_entry_with_cipher(e, book_ref))
.collect();
let (is_ai_vault, ai_actor) = vault_role_meta(&root);
Ok(ok_response(
&context,
json!({
"schema": "mnote.vault.list.v1",
"status": status.as_str(),
"revision": index.revision,
"updatedAt": index.updated_at,
"items": projected,
"isAiVault": is_ai_vault,
"aiVaultActorId": ai_actor,
"vaultRole": if is_ai_vault { "ai" } else { "user" },
}),
))
let mut body = json!({
"schema": "mnote.vault.list.v1",
"status": status.as_str(),
"revision": index.revision,
"updatedAt": index.updated_at,
"items": projected,
"isAiVault": is_ai_vault,
"aiVaultActorId": ai_actor,
"vaultRole": if is_ai_vault { "ai" } else { "user" },
});
if let Some(outcome) = folder_repair {
if let Some(obj) = body.as_object_mut() {
obj.insert(
"folderRepair".into(),
json!({
"scanned": outcome.scanned,
"updated": outcome.updated,
"skipped": outcome.skipped,
"failed": outcome.failed,
"changes": outcome.changes,
}),
);
}
}
Ok(ok_response(&context, body))
}
/// GET /api/vault/items/{id}
@@ -1216,6 +1247,7 @@ pub async fn resolve_item(
}
/// GET /api/vault/ai/list — multi-agent list of AI password book (no rootUri).
/// 打开列表时自动修复旧分享副本分组:`{分享者}/{源分组}`。
pub async fn list_ai(
Extension(context): Extension<RequestContext>,
Query(query): Query<VaultRootQuery>,
@@ -1223,10 +1255,91 @@ pub async fn list_ai(
require_vault_enabled()?;
require_authenticated(&context)?;
let status = parse_status(query.status.as_deref())?;
let result = list_ai_vault_items(status)?;
// 尽力修复旧数据分组(失败不阻断列表)
let repair = repair_ai_folder_paths_best_effort();
let mut result = list_ai_vault_items(status)?;
if let Some(outcome) = repair {
if let Some(obj) = result.as_object_mut() {
obj.insert(
"folderRepair".into(),
json!({
"scanned": outcome.scanned,
"updated": outcome.updated,
"skipped": outcome.skipped,
"failed": outcome.failed,
"changes": outcome.changes,
}),
);
}
}
Ok(ok_response(&context, result))
}
/// POST /api/vault/ai/repair-folders — 显式批量修复 AI 密码本分享副本分组。
/// body 可选:`{ "defaultSourceActor": "liaibo" }`(全库仅剩一人分享时也可自动推断)。
pub async fn repair_ai_folders(
Extension(context): Extension<RequestContext>,
body: Option<Json<Value>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
let actor = require_authenticated(&context)?;
let root = ensure_ai_vault_workspace()?;
let default_actor = body
.as_ref()
.and_then(|Json(v)| v.get("defaultSourceActor").or_else(|| v.get("default_source_actor")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty());
let outcome = vault_store::repair_ai_shared_folder_paths_with_default(
&root,
|source_actor| {
let p = local_folder_source::managed_default_workspace_root_for_actor(source_actor);
if p.exists() {
Some(p)
} else {
None
}
},
default_actor,
)?;
let _ = vault_store::append_vault_audit(
&root,
"repair_ai_folders",
&actor,
"",
Some(&format!("updated={}", outcome.updated)),
Some(context.trace.request_id.as_str()),
true,
);
Ok(ok_response(
&context,
json!({
"schema": "mnote.vault.repairAiFolders.v1",
"scanned": outcome.scanned,
"updated": outcome.updated,
"skipped": outcome.skipped,
"failed": outcome.failed,
"changes": outcome.changes,
}),
))
}
fn repair_ai_folder_paths_best_effort() -> Option<vault_store::RepairAiFolderPathsOutcome> {
let root = ensure_ai_vault_workspace().ok()?;
vault_store::repair_ai_shared_folder_paths(
&root,
|source_actor| {
let p = local_folder_source::managed_default_workspace_root_for_actor(source_actor);
if p.exists() {
Some(p)
} else {
None
}
},
)
.ok()
}
/// GET /api/vault/ai/items/{id}
pub async fn get_ai_item(
Extension(context): Extension<RequestContext>,
@@ -1493,6 +1606,388 @@ fn parse_session_cookies(value: Option<&Value>) -> Vec<VaultSessionCookie> {
.collect()
}
/// 归一化 agent vault token scopes 为 CLI 短名(`list/get/resolve/...`)。
///
/// 接受 `vault.list` 等前缀写法;空输入 → 默认读密三件套。
pub(crate) fn normalize_agent_vault_scopes(input: Vec<String>) -> Vec<String> {
let mapped: BTreeSet<String> = input
.into_iter()
.map(|s| {
let t = s.trim();
match t {
"vault.list" => "list".into(),
"vault.get" => "get".into(),
"vault.resolve" => "resolve".into(),
"vault.login" => "login".into(),
"vault.session" => "session".into(),
other => other.to_string(),
}
})
.filter(|s| !s.is_empty())
.collect();
if mapped.is_empty() {
vec!["list".into(), "get".into(), "resolve".into()]
} else {
mapped.into_iter().collect()
}
}
/// POST /api/vault/ai/token — 签发 agent vault token`mnv1.*`)。
///
/// 7-76:用户本人或 admin 代签;与 Web PAT / 扩展 token 分职。
pub async fn issue_agent_vault_token(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
if crate::routes::api_access_token::is_pat_auth(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_issue_via_pat_forbidden",
"不能用 Web PAT 签发 vault token;请使用浏览器会话",
)
.with_context(&context));
}
let actor = crate::routes::gateway::current_actor_id(&state, &context).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"签发 vault token 需要登录会话",
)
.with_context(&context)
})?;
let is_admin = crate::routes::gateway::current_actor_is_local_admin(&state, &context);
let map = body.as_object();
let subject = map
.and_then(|m| m.get("subjectUserId").or_else(|| m.get("subject_user_id")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(actor.as_str())
.to_string();
if subject != actor && !is_admin {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_delegate_forbidden",
"只有管理员可以代用户签发 vault token",
)
.with_context(&context));
}
let ttl_secs = map
.and_then(|m| m.get("ttlSecs").or_else(|| m.get("ttl_secs")))
.and_then(Value::as_u64)
.or_else(|| {
map.and_then(|m| m.get("ttlHours").or_else(|| m.get("ttl_hours")))
.and_then(Value::as_u64)
.map(|h| h.saturating_mul(3600))
});
let scopes_raw: Vec<String> = map
.and_then(|m| m.get("scopes").or_else(|| m.get("scope")))
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_str)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.filter(|v: &Vec<String>| !v.is_empty())
.unwrap_or_default();
let scopes = normalize_agent_vault_scopes(scopes_raw);
let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect();
let key = mnote_vault_core::load_or_create_hmac_key(&mnote_vault_core::default_hmac_key_path())
.map_err(WebError::from)?;
let issued = mnote_vault_core::issue_token(
&key,
&format!("user:{subject}"),
&subject,
&scope_refs,
ttl_secs,
None,
)
.map_err(WebError::from)?;
let auto_install = map
.and_then(|m| m.get("install").or_else(|| m.get("autoInstall")))
.and_then(Value::as_bool)
.unwrap_or(true);
let mut install_result = None;
if auto_install {
match crate::routes::local_agent_install::install_vault_token_with_meta(
state.config(),
&subject,
&issued.token,
&issued.claims.jti,
&scopes,
Some(&subject),
) {
Ok(v) => install_result = Some(v),
Err(e) => {
// 签发成功但安装失败:仍返回 token,附 error
install_result = Some(json!({
"ok": false,
"installed": false,
"error": e.message(),
"code": e.code(),
}));
}
}
}
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor.clone()),
action: if subject == actor {
"vault_token.issue".into()
} else {
"vault_token.issue.delegated".into()
},
target_kind: "vault_token".into(),
target_id: Some(issued.claims.jti.clone()),
metadata_json: json!({
"subjectUserId": subject,
"scopes": scopes,
"delegated": subject != actor,
"autoInstall": auto_install,
"installed": install_result
.as_ref()
.and_then(|v| v.get("installed"))
.and_then(|v| v.as_bool())
.unwrap_or(false),
})
.to_string(),
});
Ok(ok_response(
&context,
json!({
"token": issued.token,
"jti": issued.claims.jti,
"actor": issued.claims.actor,
"sub": issued.claims.sub,
"scope": issued.claims.scope,
"exp": issued.claims.exp,
"aud": issued.claims.aud,
"install": install_result,
"warning": if auto_install {
"明文仅此一次完整返回;已尝试一键写入本机 vault-tokens 与 agent-env。"
} else {
"明文仅此一次完整返回;请点击「配置」写入本机,或勿提交仓库。"
},
}),
))
}
/// POST /api/vault/ai/token/install — 一键把 vault token 写到本机
pub async fn install_agent_vault_token(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
if crate::routes::api_access_token::is_pat_auth(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_install_via_pat_forbidden",
"不能用 Web PAT 安装 vault token;请使用浏览器会话",
)
.with_context(&context));
}
let actor = crate::routes::gateway::current_actor_id(&state, &context).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"安装 vault token 需要登录会话",
)
.with_context(&context)
})?;
let map = body.as_object();
let token = map
.and_then(|m| m.get("token"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("vault_token_required", "需要 token 明文(签发后仅一次)")
})?;
let subject = map
.and_then(|m| m.get("subjectUserId").or_else(|| m.get("subject")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(actor.as_str())
.to_string();
// 本机配置按 subject 隔离;非 admin 只能写自己的 vault-tokens/<self>.token
let is_admin = crate::routes::gateway::current_actor_is_local_admin(&state, &context);
if subject != actor && !is_admin {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_install_forbidden",
"只能配置自己的 vault 本机 token",
)
.with_context(&context));
}
let jti = map
.and_then(|m| m.get("jti"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let result = crate::routes::local_agent_install::install_vault_token(
state.config(),
&subject,
token,
&jti,
)?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor),
action: "vault_token.install".into(),
target_kind: "vault_token".into(),
target_id: Some(jti),
metadata_json: json!({
"subjectUserId": subject,
"environment": result.get("environment"),
"path": result.get("path"),
})
.to_string(),
});
Ok(ok_response(&context, result))
}
/// POST /api/vault/ai/token/uninstall — 删除本机 vault token 文件并清理 agent-env 指针
pub async fn uninstall_agent_vault_token(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
if crate::routes::api_access_token::is_pat_auth(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_uninstall_via_pat_forbidden",
"不能用 Web PAT 取消 vault 配置;请使用浏览器会话",
)
.with_context(&context));
}
let actor = crate::routes::gateway::current_actor_id(&state, &context).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"取消 vault 配置需要登录会话",
)
.with_context(&context)
})?;
let map = body.as_object();
let subject = map
.and_then(|m| m.get("subjectUserId").or_else(|| m.get("subject")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(actor.as_str())
.to_string();
// 非 admin 只能取消自己的本机 vault 配置,避免交叉删他人 token
let is_admin = crate::routes::gateway::current_actor_is_local_admin(&state, &context);
if subject != actor && !is_admin {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_uninstall_forbidden",
"只能取消自己的 vault 本机配置",
)
.with_context(&context));
}
let result =
crate::routes::local_agent_install::uninstall_vault_token(state.config(), &subject)?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor),
action: "vault_token.uninstall".into(),
target_kind: "vault_token".into(),
target_id: Some(subject.clone()),
metadata_json: json!({
"subjectUserId": subject,
"environment": result.get("environment"),
})
.to_string(),
});
Ok(ok_response(&context, result))
}
/// GET /api/vault/ai/token/local — 本机已配置的 vault token 列表
///
/// 默认只返回**当前登录用户**的本机配置,避免 liaibo / mnote-e2e 交叉。
/// admin 可传 `?all=1` 查看本机全部 subject。
pub async fn list_local_agent_vault_tokens(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
axum::extract::Query(query): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
let actor = crate::routes::gateway::current_actor_id(&state, &context).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"需要登录会话",
)
.with_context(&context)
})?;
let is_admin = crate::routes::gateway::current_actor_is_local_admin(&state, &context);
let want_all = query
.get("all")
.map(|v| v == "1" || v == "true")
.unwrap_or(false);
let list_all = is_admin && want_all;
Ok(ok_response(
&context,
crate::routes::local_agent_install::list_local_vault_installs(
state.config(),
&actor,
list_all,
),
))
}
/// GET /api/vault/ai/token/local/{subject} — 详情:本机 token 明文 + 配置方法
pub async fn detail_local_agent_vault_token(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(subject): Path<String>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
if crate::routes::api_access_token::is_pat_auth(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_detail_via_pat_forbidden",
"不能用 Web PAT 查看 vault 本机详情;请使用浏览器会话",
)
.with_context(&context));
}
let actor = crate::routes::gateway::current_actor_id(&state, &context).ok_or_else(|| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_auth_required",
"需要登录会话",
)
.with_context(&context)
})?;
let is_admin = crate::routes::gateway::current_actor_is_local_admin(&state, &context);
if subject != actor && !is_admin {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_token_detail_forbidden",
"只能查看自己的 vault 本机配置",
)
.with_context(&context));
}
let detail =
crate::routes::local_agent_install::vault_token_detail(state.config(), &subject)?;
Ok(ok_response(&context, detail))
}
/// POST /api/vault/extension/token — issue E2 human token (mnext1.*) after session login.
pub async fn issue_extension_token(
Extension(context): Extension<RequestContext>,
@@ -1671,14 +2166,16 @@ pub async fn share_to_ai(
));
}
// 相对分组覆盖(可选)。省略时使用源条目 folderPath。
// AI 侧最终路径由 compose_ai_shared_folder_path 生成:`{分享者}/{原分组…}`。
// 不再默认写成扁平的 `from-{actor}`(会丢掉用户原有「系统/个人」层级)。
let folder_override = map
.get("folderPath")
.or_else(|| map.get("folder_path"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.or_else(|| Some(format!("from-{}", actor)));
.map(|s| s.to_string());
// Default true: copy missing [Key] fragments into AI cipher-book so templates resolve.
// Existing non-empty target keys are never overwritten.
@@ -2016,6 +2513,23 @@ mod resolve_strategy_tests {
/// Serialize tests that touch the shared AI vault path.
static AI_VAULT_TEST_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn normalize_agent_vault_scopes_defaults_and_strips_vault_prefix() {
assert_eq!(
normalize_agent_vault_scopes(vec![]),
vec!["list", "get", "resolve"]
);
assert_eq!(
normalize_agent_vault_scopes(vec![
"vault.list".into(),
"vault.resolve".into(),
"get".into(),
"vault.login".into(),
]),
vec!["get", "list", "login", "resolve"]
);
}
#[test]
fn resolve_rejects_invalid_field_before_io() {
let err =
@@ -112,10 +112,11 @@ pub fn load_or_create_hmac_key(path: &Path) -> Result<Vec<u8>, WebError> {
)
})?;
let hex = raw.trim();
if hex.len() < 32 {
// 与生成侧 32 字节/64 hex 一致,拒绝弱 key。
if hex.len() < 64 {
return Err(WebError::bad_request_code(
"vault_ext_hmac_key_invalid",
"extension HMAC key 过短",
"extension HMAC key 过短(需要至少 32 字节 / 64 hex)",
));
}
return hex::decode(hex).map_err(|e| {
@@ -308,6 +309,8 @@ pub fn verify_extension_token(token: &str) -> Result<ExtensionTokenClaims, WebEr
Ok(claims)
}
/// 目前仅 unit test 使用;extension 路由后续接 scope 校验时可去掉 cfg。
#[cfg(test)]
pub fn require_scope(claims: &ExtensionTokenClaims, need: &str) -> Result<(), WebError> {
let set: BTreeSet<&str> = claims.scope.iter().map(|s| s.as_str()).collect();
if set.contains(need) || set.contains("*") {
+57 -20
View File
@@ -9,21 +9,26 @@ 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).
/// `rel` should use `/` separators. Collapses `.` / empty segments and resolves
/// `..` with a stack (cannot climb above workspace root). This closes
/// `.mnote/./vault/...` and `.mnote/vault/../vault/...` classification gaps.
pub fn normalize_workspace_relative_path(rel: &str) -> String {
let mut value = rel.trim().replace('\\', "/");
while value.starts_with("./") {
value = value[2..].to_string();
let value = rel.trim().replace('\\', "/");
let mut parts: Vec<&str> = Vec::new();
for segment in value.split('/') {
if segment.is_empty() || segment == "." {
continue;
}
if segment == ".." {
if !parts.is_empty() {
parts.pop();
}
// Climbing above workspace root is discarded (fail closed at root).
continue;
}
parts.push(segment);
}
value = value.trim_start_matches('/').to_string();
while value.contains("//") {
value = value.replace("//", "/");
}
if value.ends_with('/') && value != "/" {
value.pop();
}
value
parts.join("/")
}
/// Returns true when `rel` is `.mnote/vault` or any path under it.
@@ -32,13 +37,6 @@ pub fn is_vault_sensitive_relative_path(rel: &str) -> bool {
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/")
}
@@ -101,6 +99,23 @@ mod tests {
assert!(deny_if_vault_sensitive_relative_path("notes/a.md").is_ok());
}
#[test]
fn detects_dot_segment_and_parent_resolved_vault_paths() {
assert!(is_vault_sensitive_relative_path(
".mnote/./vault/entries/x.md"
));
assert!(is_vault_sensitive_relative_path(
".mnote/vault/../vault/cipher-book.json"
));
assert!(is_vault_sensitive_relative_path(
"foo/../.mnote/vault/secret.json"
));
// After resolving out of vault, no longer sensitive.
assert!(!is_vault_sensitive_relative_path(
".mnote/vault/../trash/a.md"
));
}
#[test]
fn denies_cipher_book_and_index_under_vault() {
assert!(is_vault_sensitive_relative_path(
@@ -116,4 +131,26 @@ mod tests {
.expect_err("must deny cipher-book via general file surface");
assert_eq!(err.code(), "vault_path_denied");
}
#[test]
fn normalize_collapses_dot_and_parent_segments() {
assert_eq!(
normalize_workspace_relative_path(".mnote/./vault/secret.json"),
".mnote/vault/secret.json"
);
assert_eq!(
normalize_workspace_relative_path(".mnote/vault/../vault/a"),
".mnote/vault/a"
);
assert_eq!(
normalize_workspace_relative_path("foo/../../.mnote/vault/x"),
".mnote/vault/x"
);
assert!(is_vault_sensitive_relative_path(
".mnote/./vault/secret.json"
));
assert!(is_vault_sensitive_relative_path(
".mnote/vault/../vault/a"
));
}
}
+307 -29
View File
@@ -493,31 +493,57 @@ pub fn ensure_vault_directories(workspace_root: &Path) -> Result<PathBuf, WebErr
Ok(root)
}
/// 将 session 路径段消毒为安全文件名,拒绝 `..` / 分隔符 / 控制字符。
fn sanitize_session_path_segment(raw: &str, fallback: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return fallback.to_string();
}
let mut out = String::with_capacity(trimmed.len().min(128));
for ch in trimmed.chars().take(128) {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() || out == "." || out == ".." || out.contains("..") {
return fallback.to_string();
}
out
}
/// Normalize account id for session file name (`primary` when empty).
pub fn normalize_session_account_id(account_id: Option<&str>) -> String {
let raw = account_id.map(str::trim).unwrap_or("");
if raw.is_empty() || raw.eq_ignore_ascii_case("primary") {
SESSION_ACCOUNT_PRIMARY.to_string()
} else if raw.contains('/') || raw.contains('\\') || raw.contains("..") {
SESSION_ACCOUNT_PRIMARY.to_string()
} else {
raw.to_string()
return SESSION_ACCOUNT_PRIMARY.to_string();
}
if raw.contains('/') || raw.contains('\\') || raw.contains("..") {
return SESSION_ACCOUNT_PRIMARY.to_string();
}
sanitize_session_path_segment(raw, SESSION_ACCOUNT_PRIMARY)
}
/// `sessions/{credId}/` under vault root.
/// 公共相对路径 API;内部读写走 `*_abs`,保留供外部/脚本使用。
#[allow(dead_code)]
pub fn session_dir_rel(credential_id: &str) -> String {
format!("sessions/{credential_id}")
let cred = sanitize_session_path_segment(credential_id, "invalid");
format!("sessions/{cred}")
}
/// `sessions/{credId}/{accountId}.json`
#[allow(dead_code)]
pub fn session_file_rel(credential_id: &str, account_id: Option<&str>) -> String {
let cred = sanitize_session_path_segment(credential_id, "invalid");
let acc = normalize_session_account_id(account_id);
format!("sessions/{credential_id}/{acc}.json")
format!("sessions/{cred}/{acc}.json")
}
fn session_dir_abs(vault: &Path, credential_id: &str) -> PathBuf {
vault.join("sessions").join(credential_id)
let cred = sanitize_session_path_segment(credential_id, "invalid");
vault.join("sessions").join(cred)
}
fn session_file_abs(vault: &Path, credential_id: &str, account_id: Option<&str>) -> PathBuf {
@@ -577,6 +603,19 @@ fn atomic_write_session_string(path: &Path, content: &str) -> Result<(), WebErro
format!("无法写入登录态: {error}"),
)
})?;
// 会话含 cookie/tokenrename 前 fsync,降低崩溃半写风险。
f.sync_all().map_err(|error| {
WebError::bad_request_code(
"vault_session_write_failed",
format!("无法同步登录态到磁盘: {error}"),
)
})?;
// 尽量限制为属主读写(Unix);Windows 上 set_permissions 语义不同,忽略失败。
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600));
}
}
fs::rename(&tmp, path).map_err(|error| {
WebError::bad_request_code(
@@ -584,6 +623,11 @@ fn atomic_write_session_string(path: &Path, content: &str) -> Result<(), WebErro
format!("无法提交登录态: {error}"),
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}
Ok(())
}
@@ -607,6 +651,8 @@ fn read_session_file_at(path: &Path) -> Result<Option<VaultSessionFile>, WebErro
}
/// Read one session file (no frontmatter fallback).
/// 公共包装;内部路径用 `read_session_file_at`。
#[allow(dead_code)]
pub fn read_session_file(
workspace_root: &Path,
credential_id: &str,
@@ -1670,6 +1716,210 @@ pub fn normalize_folder_path(raw: Option<&str>) -> Option<String> {
}
}
/// Sanitize one folder segment (username / actor id) for use in `folderPath`.
pub fn sanitize_folder_segment(raw: &str) -> Option<String> {
let s = raw.trim();
if s.is_empty() || s == "." || s == ".." {
return None;
}
let cleaned: String = s
.chars()
.map(|c| match c {
'/' | '\\' => '_',
c if c.is_control() => '_',
c => c,
})
.collect();
let cleaned = cleaned.trim_matches('_').trim();
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
None
} else {
Some(cleaned.to_string())
}
}
fn strip_ai_share_namespace(path: &str, actor_ns: &str) -> Option<String> {
let path = path.trim().trim_matches('/');
if path.is_empty() {
return None;
}
let from_ns = format!("from-{actor_ns}");
if path == actor_ns || path == from_ns {
return None;
}
if let Some(rest) = path
.strip_prefix(&format!("{actor_ns}/"))
.or_else(|| path.strip_prefix(&format!("{from_ns}/")))
{
return normalize_folder_path(Some(rest));
}
Some(path.to_string())
}
/// Compose AI 密码本分组路径:`{sourceUser}/{原用户分组…}`。
pub fn compose_ai_shared_folder_path(
source_actor_id: &str,
relative: Option<&str>,
) -> Option<String> {
let Some(ns) = sanitize_folder_segment(source_actor_id) else {
return normalize_folder_path(relative);
};
let relative = normalize_folder_path(relative)
.and_then(|p| strip_ai_share_namespace(&p, &ns));
match relative {
Some(r) if !r.is_empty() => Some(format!("{ns}/{r}")),
_ => Some(ns),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RepairAiFolderPathChange {
pub id: String,
pub title: String,
pub source_actor_id: String,
pub from: Option<String>,
pub to: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RepairAiFolderPathsOutcome {
pub scanned: usize,
pub updated: usize,
pub skipped: usize,
pub failed: usize,
pub changes: Vec<RepairAiFolderPathChange>,
}
pub fn normalize_share_actor_id(raw: &str) -> Option<String> {
let s = raw.trim();
let s = s
.strip_prefix("user:")
.or_else(|| s.strip_prefix("user/"))
.unwrap_or(s)
.trim();
sanitize_folder_segment(s)
}
pub fn parse_from_actor_folder_prefix(folder_path: Option<&str>) -> Option<String> {
let p = normalize_folder_path(folder_path)?;
let rest = p.strip_prefix("from-")?;
let actor = rest.split('/').next().unwrap_or("").trim();
sanitize_folder_segment(actor)
}
/// 批量修复 AI 密码本分享副本分组为 `{分享者}/{源分组}`。
pub fn repair_ai_shared_folder_paths(
ai_workspace: &Path,
resolve_source_workspace: impl FnMut(&str) -> Option<PathBuf>,
) -> Result<RepairAiFolderPathsOutcome, WebError> {
repair_ai_shared_folder_paths_with_default(ai_workspace, resolve_source_workspace, None)
}
pub fn repair_ai_shared_folder_paths_with_default(
ai_workspace: &Path,
mut resolve_source_workspace: impl FnMut(&str) -> Option<PathBuf>,
default_source_actor: Option<&str>,
) -> Result<RepairAiFolderPathsOutcome, WebError> {
let (_index, entries) = list_credentials(ai_workspace, VaultItemStatus::Active)?;
let mut scanned = 0usize;
let mut updated = 0usize;
let mut skipped = 0usize;
let mut failed = 0usize;
let mut changes = Vec::new();
let mut known_actors: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
if let Some(d) = default_source_actor.and_then(normalize_share_actor_id) {
known_actors.insert(d);
}
let mut records: Vec<VaultCredentialRecord> = Vec::with_capacity(entries.len());
for entry in &entries {
match get_credential(ai_workspace, &entry.id) {
Ok(r) => {
if let Some(origin) = r.shared_from.as_ref() {
if let Some(a) = normalize_share_actor_id(&origin.source_actor_id) {
known_actors.insert(a);
}
}
if let Some(a) = parse_from_actor_folder_prefix(r.folder_path.as_deref()) {
known_actors.insert(a);
}
records.push(r);
}
Err(_) => {
scanned += 1;
failed += 1;
}
}
}
let sole_actor = if known_actors.len() == 1 {
known_actors.iter().next().cloned()
} else {
default_source_actor.and_then(normalize_share_actor_id)
};
for mut record in records {
scanned += 1;
let source_id_hint = record
.shared_from
.as_ref()
.map(|o| o.source_id.clone())
.unwrap_or_default();
let source_actor = record
.shared_from
.as_ref()
.and_then(|o| normalize_share_actor_id(&o.source_actor_id))
.or_else(|| parse_from_actor_folder_prefix(record.folder_path.as_deref()))
.or_else(|| sole_actor.clone());
let Some(source_actor) = source_actor else {
skipped += 1;
continue;
};
let relative_from_source = if !source_id_hint.is_empty() {
resolve_source_workspace(&source_actor)
.and_then(|src_root| get_credential(&src_root, &source_id_hint).ok())
.and_then(|src| src.folder_path)
} else {
None
};
let relative = relative_from_source.or_else(|| {
let p = record.folder_path.as_deref()?;
strip_ai_share_namespace(p, &source_actor)
});
let new_path = compose_ai_shared_folder_path(&source_actor, relative.as_deref());
if record.folder_path == new_path {
skipped += 1;
continue;
}
let from = record.folder_path.clone();
record.folder_path = new_path.clone();
record.updated_at = now_rfc3339();
record.revision = record.revision.saturating_add(1);
if write_record_with_index(ai_workspace, &record).is_err() {
failed += 1;
continue;
}
updated += 1;
changes.push(RepairAiFolderPathChange {
id: record.id.clone(),
title: record.title.clone(),
source_actor_id: source_actor,
from,
to: new_path,
});
}
Ok(RepairAiFolderPathsOutcome {
scanned,
updated,
skipped,
failed,
changes,
})
}
/// Validate cipher-book key: `[A-Za-z0-9_]+`, 1..=32 chars (matches `[Key]` in passwords).
pub fn validate_cipher_key(key: &str) -> Result<(), WebError> {
let key = key.trim();
@@ -1954,11 +2204,32 @@ fn yaml_escape(value: &str) -> String {
if value.is_empty() {
return "\"\"".to_string();
}
if value.chars().any(|c| {
// 双引号标量必须转义控制字符,否则按行解析 frontmatter 时多行值会被截断。
let needs_quote = value.chars().any(|c| {
c.is_whitespace()
|| matches!(c, ':' | '#' | '"' | '\'' | '{' | '}' | '[' | ']' | ',' | '&' | '*' | '!' | '|' | '>' | '%' | '@' | '`')
}) {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
|| c.is_control()
|| matches!(
c,
':' | '#' | '"' | '\'' | '{' | '}' | '[' | ']' | ',' | '&' | '*' | '!' | '|' | '>'
| '%' | '@' | '`'
)
});
if needs_quote {
let mut escaped = String::with_capacity(value.len() + 8);
for ch in value.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
c if c.is_control() => {
escaped.push_str(&format!("\\u{:04x}", c as u32));
}
c => escaped.push(c),
}
}
format!("\"{escaped}\"")
} else {
value.to_string()
}
@@ -3035,11 +3306,11 @@ fn apply_source_fields_to_ai_copy(
target.secrets = source.secrets.clone();
target.fields = source.fields.clone();
target.notes_markdown = source.notes_markdown.clone();
// AI 副本分组必须带分享者命名空间;禁止把用户原 folder_path 原样写回。
target.folder_path = folder_path_override
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.or_else(|| source.folder_path.clone())
.or_else(|| target.folder_path.clone());
target.tags = merge_ai_shared_tags(source.tags.clone(), extra_tags);
// Keep AI-side login session always; seed playbook only if missing.
@@ -3150,7 +3421,8 @@ pub fn sync_shared_ai_copy(
/// When `sync_cipher_keys` is true, missing target cipher fragments are copied
/// from the source book (existing non-empty target keys are never overwritten).
/// If source already has `shared_to_ai` and the target still exists, **updates** that copy.
/// Tags get `ai-shared` if missing. Optional `folder_path_override` (e.g. `from-user`).
/// Tags get `ai-shared` if missing.
/// `folder_path_override`:可选相对分组;AI 侧路径 = `{sourceUser}/{relative}`。
pub fn share_credential_to_workspace(
source_workspace: &Path,
target_workspace: &Path,
@@ -3169,6 +3441,12 @@ pub fn share_credential_to_workspace(
));
}
let relative = folder_path_override
.map(str::trim)
.filter(|s| !s.is_empty())
.or_else(|| source.folder_path.as_deref());
let ai_folder = compose_ai_shared_folder_path(source_actor_id, relative);
// Re-share / sync existing linked copy when still present.
if let Some(link) = source.shared_to_ai.clone() {
if !link.target_id.is_empty()
@@ -3181,7 +3459,7 @@ pub fn share_credential_to_workspace(
&link.target_id,
source_actor_id,
target_actor_id,
folder_path_override,
ai_folder.as_deref(),
sync_cipher_keys,
);
}
@@ -3201,11 +3479,7 @@ pub fn share_credential_to_workspace(
}
let tags = merge_ai_shared_tags(source.tags.clone(), extra_tags);
let folder = folder_path_override
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.or_else(|| source.folder_path.clone());
let folder = ai_folder;
let mut item = create_credential(
target_workspace,
VaultCreateInput {
@@ -3293,6 +3567,8 @@ pub fn maybe_sync_after_source_update(
if link.target_id.is_empty() {
return Ok(None);
}
let ai_folder =
compose_ai_shared_folder_path(source_actor_id, source.folder_path.as_deref());
match sync_shared_ai_copy(
source_workspace,
source,
@@ -3300,7 +3576,7 @@ pub fn maybe_sync_after_source_update(
&link.target_id,
source_actor_id,
&link.target_actor_id,
None,
ai_folder.as_deref(),
sync_cipher_keys,
) {
Ok(outcome) => Ok(Some(outcome)),
@@ -4228,6 +4504,8 @@ pub fn project_list_entry(entry: &VaultIndexEntry) -> Value {
///
/// Does **not** rewrite credential markdown with cookie secrets.
/// AI multi-agent session write remains in `mnote-vault-core` (12-2).
/// 兼容封装 → `put_login_session_for_account`;生产路由走后者,本函数主要给测试。
#[cfg(test)]
pub fn put_login_session(
workspace_root: &Path,
id: &str,
@@ -4750,7 +5028,7 @@ mod tests {
&src,
&dst,
&created.id,
Some("from-user"),
None,
&[],
true,
"user-a",
@@ -4761,7 +5039,7 @@ mod tests {
assert_ne!(shared.id, created.id);
assert_eq!(shared.password.as_deref(), Some("Li@[A]s3cret"));
assert_eq!(shared.apikey.as_deref(), Some("sk-test"));
assert_eq!(shared.folder_path.as_deref(), Some("from-user"));
assert_eq!(shared.folder_path.as_deref(), Some("user-a/ai/keys"));
assert!(shared.tags.iter().any(|t| t == "ai-shared"));
assert!(shared.tags.iter().any(|t| t == "prod"));
assert!(shared.shared_from.is_some());
@@ -4788,7 +5066,7 @@ mod tests {
&src,
&dst,
&created.id,
Some("from-user"),
None,
&[],
true,
"user-a",
@@ -4824,7 +5102,7 @@ mod tests {
&src,
&dst,
&created.id,
Some("from-user"),
None,
&[],
true,
"user-a",
@@ -4892,7 +5170,7 @@ mod tests {
&src,
&dst,
&created.id,
Some("from-user"),
None,
&[],
true,
"user-a",
@@ -4944,7 +5222,7 @@ mod tests {
&src,
&dst,
&created.id,
Some("from-user"),
None,
&[],
true,
"user-a",
@@ -4984,7 +5262,7 @@ mod tests {
&src,
&dst,
&created.id,
Some("from-user"),
None,
&[],
true,
"user-a",
@@ -5437,7 +5715,7 @@ mod tests {
&src,
&dst,
&created.id,
Some("from-user"),
None,
&[],
false,
"user-a",
@@ -186,10 +186,46 @@ fn map_http_error(status: u16, body: &str) -> WebError {
fn client_token() -> Result<Option<String>, WebError> {
match read_token_from_env_or_file() {
Ok(t) => Ok(Some(t)),
Err(_) => Ok(None),
Err(e) => {
// UDS-only 必须有 tokenauto 模式允许后续失败后回落 local core。
if transport_mode() == TransportMode::UdsOnly {
return Err(WebError::service_unavailable_code(
"vaultd_token_unavailable",
format!("无法读取 vaultd client token: {e}"),
));
}
Ok(None)
}
}
}
/// vaultd 路径段 id:拒 CRLF / 路径穿越 / 查询注入。
fn sanitize_vaultd_item_id(id: &str) -> Result<String, WebError> {
let id = id.trim();
if id.is_empty() || id.len() > 128 {
return Err(WebError::bad_request_code(
"vault_item_id_invalid",
"vault item id 无效",
));
}
if !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(WebError::bad_request_code(
"vault_item_id_invalid",
"vault item id 含非法字符",
));
}
if id.contains("..") {
return Err(WebError::bad_request_code(
"vault_item_id_invalid",
"vault item id 不得含 ..",
));
}
Ok(id.to_string())
}
fn with_transport<F, G>(via_uds: F, via_local: G) -> Result<Value, WebError>
where
F: FnOnce(Option<&str>) -> Result<Value, WebError>,
@@ -241,6 +277,7 @@ pub fn list_ai_vault_items(status: VaultItemStatus) -> Result<Value, WebError> {
}
pub fn get_ai_vault_item(id: &str) -> Result<Value, WebError> {
let id = sanitize_vaultd_item_id(id)?;
with_transport(
|token| {
let path = format!("/v1/items/{id}");
@@ -252,7 +289,7 @@ pub fn get_ai_vault_item(id: &str) -> Result<Value, WebError> {
WebError::internal(format!("vaultd get JSON 无效: {e}"))
})
},
|| vault::get_ai_vault_item(id),
|| vault::get_ai_vault_item(&id),
)
}
@@ -264,7 +301,7 @@ pub fn resolve_ai_vault_secret(
account_id: Option<&str>,
secret_id: Option<&str>,
) -> Result<Value, WebError> {
let id_owned = id.to_string();
let id_owned = sanitize_vaultd_item_id(id)?;
let field_owned = field.to_string();
let account = account_id.map(str::to_string);
let secret = secret_id.map(str::to_string);
@@ -305,7 +342,7 @@ pub fn login_ai_vault_credential(
actor: &str,
request_id: Option<&str>,
) -> Result<Value, WebError> {
let id_owned = id.to_string();
let id_owned = sanitize_vaultd_item_id(id)?;
with_transport(
|token| {
let path = format!("/v1/items/{id_owned}/login");
@@ -332,7 +369,7 @@ pub fn put_ai_vault_session(
actor: &str,
request_id: Option<&str>,
) -> Result<Value, WebError> {
let id_owned = id.to_string();
let id_owned = sanitize_vaultd_item_id(id)?;
let cookie = cookie_header.to_string();
let expires = expires_at.map(str::to_string);
let source_owned = source.to_string();
@@ -388,4 +425,16 @@ mod tests {
let _ = list_ai_vault_items(VaultItemStatus::Active);
std::env::remove_var("MNOTE_VAULT_PI_TRANSPORT");
}
#[test]
fn sanitize_vaultd_item_id_rejects_path_injection() {
assert!(sanitize_vaultd_item_id("../x").is_err());
assert!(sanitize_vaultd_item_id("a/b").is_err());
assert!(sanitize_vaultd_item_id("a?b").is_err());
assert!(sanitize_vaultd_item_id("a\nb").is_err());
assert_eq!(
sanitize_vaultd_item_id("item-1_ok.x").unwrap(),
"item-1_ok.x"
);
}
}
+71 -3
View File
@@ -423,11 +423,22 @@ fn local_markdown_parent_scope_from_document_id(document_id: &str) -> String {
}
fn local_markdown_relative_path_from_document_id(document_id: &str) -> String {
document_id
let relative = document_id
.trim()
.strip_prefix("local-md:")
.unwrap_or_default()
.replace("~2F", "/")
.replace("~2F", "/");
// 解码后词法拒 `..` / 绝对路径,避免导航 recent 等路径写入携带穿越串。
let path = std::path::Path::new(relative.as_str());
if relative.is_empty()
|| path.is_absolute()
|| path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return String::new();
}
relative
}
fn record_navigation_recent_page(
@@ -3240,8 +3251,28 @@ pub(crate) fn escape_html(value: &str) -> String {
.replace('"', "&quot;")
}
/// 将 JSON 嵌入 `<script type="application/json">` 时防止提前闭合标签。
/// HTML 标签名大小写不敏感,必须匹配 `</script` 的任意大小写变体。
pub(crate) fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
let lower = value.to_ascii_lowercase();
let needle = b"</script";
let mut out = String::with_capacity(value.len());
let bytes = value.as_bytes();
let lower_bytes = lower.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + needle.len() <= lower_bytes.len() && &lower_bytes[i..i + needle.len()] == needle {
out.push_str("<\\/script");
i += needle.len();
// 保留原串中 `</script` 之后紧跟的字符(如 `>`、空白),不吞掉
continue;
}
// 按 UTF-8 字符推进,避免切断多字节序列
let ch = value[i..].chars().next().expect("valid utf-8 offset");
out.push(ch);
i += ch.len_utf8();
}
out
}
pub(crate) async fn load_workspace_shell_projection(
@@ -3627,6 +3658,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
.layer(axum::middleware::from_fn(inject_test_actor))
}
@@ -3671,6 +3703,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -3772,6 +3805,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -3914,6 +3948,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
state
.control_plane()
@@ -4682,6 +4717,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
init_local_workspace(&root, "owner_user");
grant_local_workspace_read_access(&state, "user_test", &root_uri, &root);
@@ -4750,6 +4786,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
init_local_workspace(&root, "owner_user");
grant_local_workspace_read_access(&state, "user_test", &root_uri, &root);
@@ -4908,6 +4945,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
});
grant_local_workspace_read_access(&state, "user_test", &root_uri, &root);
let workspace_id =
@@ -5463,6 +5501,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
};
let headers = HeaderMap::new();
let context = RequestContext::from_http_parts(
@@ -5624,4 +5663,33 @@ mod tests {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("--mnote-mindmap-block-max-width"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("currentPageWidthPreferences().mindmap"));
}
#[test]
fn escape_script_json_is_case_insensitive_for_script_close() {
use super::escape_script_json;
// 输出串中字面为 `<` + `\` + `/script`HTML 脚本闭合防护)
let escaped_close = r#"<\/script>"#;
assert_eq!(
escape_script_json(r#"{"t":"</script>"}"#),
format!(r#"{{"t":"{escaped_close}"}}"#)
);
assert_eq!(
escape_script_json(r#"{"t":"</SCRIPT>"}"#),
format!(r#"{{"t":"{escaped_close}"}}"#)
);
assert_eq!(
escape_script_json(r#"{"t":"</ScRiPt> alert(1)"}"#),
format!(r#"{{"t":"{escaped_close} alert(1)"}}"#)
);
// 正常 JSON 不受影响
assert_eq!(
escape_script_json(r#"{"ok":true,"n":1}"#),
r#"{"ok":true,"n":1}"#
);
// 多字节 UTF-8 不截断
assert_eq!(
escape_script_json(r#"{"t":"中文</Script>尾"}"#),
format!(r#"{{"t":"中文{escaped_close}尾"}}"#)
);
}
}
+59 -8
View File
@@ -32,17 +32,43 @@ async fn handle_socket(
query: StreamSnapshotQuery,
payload: Value,
) {
let mut stream_delta_rx = state.stream_delta_tx.subscribe();
let subscription_workspace = query
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| {
context
.workspace
.workspace_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
});
let mut stream_delta_rx = Some(state.stream_delta_tx.subscribe());
let _ = socket.send(serialize_snapshot_message(&payload)).await;
loop {
tokio::select! {
biased;
// 优先处理 broadcast 推送的变更通知
delta_result = stream_delta_rx.recv() => {
// 优先处理 broadcast 推送的变更通知channel 关闭后不再 recv,避免 busy spin
delta_result = async {
match stream_delta_rx.as_mut() {
Some(rx) => Some(rx.recv().await),
None => {
std::future::pending::<()>().await;
None
}
}
} => {
match delta_result {
Ok(delta) => {
Some(Ok(delta)) => {
if !delta_matches_workspace(&delta, subscription_workspace.as_deref()) {
continue;
}
if socket
.send(serialize_delta_message(
&delta,
@@ -55,7 +81,7 @@ async fn handle_socket(
break;
}
}
Err(RecvError::Lagged(n)) => {
Some(Err(RecvError::Lagged(n))) => {
// Lagged: 发送 resync 提示让客户端重新加载
let lagged_hint = json!({
"kind": "resync_hint",
@@ -66,9 +92,11 @@ async fn handle_socket(
});
let _ = socket.send(Message::Text(lagged_hint.to_string().into())).await;
}
Err(RecvError::Closed) => {
// Broadcast channel closed, WS stays open for client-initiated resync
Some(Err(RecvError::Closed)) => {
// Broadcast 已关闭:丢弃订阅,避免紧忙循环;WS 仍可处理客户端 resync
stream_delta_rx = None;
}
None => {}
}
}
@@ -87,7 +115,7 @@ async fn handle_socket(
break;
}
// 重订阅 broadcast(可能丢掉了中间的变更)
stream_delta_rx = state.stream_delta_tx.subscribe();
stream_delta_rx = Some(state.stream_delta_tx.subscribe());
}
Err(error) => {
let err_payload = json!({
@@ -129,6 +157,29 @@ async fn handle_socket(
}
}
/// 仅转发与当前订阅 workspace 一致的 delta,避免跨工作区泄露。
fn delta_matches_workspace(delta: &Value, subscription_workspace: Option<&str>) -> bool {
let Some(expected) = subscription_workspace else {
// 无订阅 workspace 时不推送带 workspace 的全局 delta(保守)
return delta
.get("workspaceId")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.is_none();
};
match delta
.get("workspaceId")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
{
Some(delta_ws) => delta_ws == expected,
// 无 workspace 标记的 delta 不转发(避免跨租户噪声)
None => false,
}
}
fn serialize_snapshot_message(payload: &Value) -> Message {
Message::Text(payload.to_string().into())
}
+2 -1
View File
@@ -7,7 +7,8 @@ use leptos::prelude::*;
pub fn AdminAccessPolicyPanel(
#[prop(optional)] workspace_name: Option<String>,
#[prop(optional)] share_grants_path: Option<String>,
#[prop(optional, default = true)] is_admin: bool,
// 默认非管理员,避免未显式传入时以 admin 模式渲染(最小权限)。
#[prop(optional, default = false)] is_admin: bool,
#[prop(optional, default = true)] boot_script: bool,
) -> impl IntoView {
let workspace_name = workspace_name
+846 -1
View File
@@ -803,6 +803,231 @@ details.mnote-ai-admin-provider-card[open] > summary.mnote-ai-admin-provider-car
.mnote-ai-admin-subsection-card-body {
padding: 14px 16px 16px;
}
/* 7-76 token 表单 / 密钥卡片(PAT + vault mnv1 */
.mnote-ai-admin-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
margin: 0;
}
.mnote-ai-admin-field > span {
color: rgba(0,0,0,0.65);
font-size: 12px;
font-weight: 500;
line-height: 1.4;
}
.mnote-ai-admin-field input[type="text"],
.mnote-ai-admin-field input[type="number"],
.mnote-ai-admin-field select {
width: 100%;
box-sizing: border-box;
height: 32px;
padding: 4px 11px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #ffffff;
color: rgba(0,0,0,0.88);
font-size: 14px;
line-height: 1.5;
}
.mnote-ai-admin-field input:focus,
.mnote-ai-admin-field select:focus {
outline: none;
border-color: #4096ff;
box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
}
.mnote-ai-admin-token-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px 14px;
align-items: end;
margin-bottom: 12px;
}
.mnote-ai-admin-token-form--actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-top: 4px;
}
.mnote-ai-admin-scope-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px 12px;
padding: 10px 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fafafa;
}
.mnote-ai-admin-scope-item {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
color: rgba(0,0,0,0.78);
font-size: 13px;
cursor: pointer;
user-select: none;
}
.mnote-ai-admin-scope-item input {
width: 14px;
height: 14px;
margin: 0;
accent-color: #1677ff;
}
.mnote-ai-admin-scope-item small {
color: rgba(0,0,0,0.45);
font-size: 11px;
}
.mnote-ai-admin-help-list {
margin: 8px 0 0;
padding-left: 18px;
color: rgba(0,0,0,0.55);
font-size: 12px;
line-height: 1.6;
}
.mnote-ai-admin-help-list code {
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
font-size: 11px;
background: #f5f5f5;
padding: 1px 4px;
border-radius: 3px;
}
.mnote-ai-admin-code {
max-height: 280px;
margin-top: 10px;
overflow: auto;
border: 1px solid #f0f0f0;
border-radius: 6px;
background: #fafafa;
padding: 12px;
color: rgba(0,0,0,0.72);
font: 12px/1.55 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.mnote-ai-admin-token-card {
margin-top: 14px;
border: 1px solid #91caff;
border-radius: 8px;
background: #e6f4ff;
padding: 12px 14px;
}
.mnote-ai-admin-token-card[hidden] {
display: none !important;
}
.mnote-ai-admin-token-card--error {
border-color: #ffa39e;
background: #fff1f0;
}
.mnote-ai-admin-token-card-title {
margin: 0 0 6px;
color: rgba(0,0,0,0.88);
font-size: 13px;
font-weight: 600;
}
.mnote-ai-admin-token-card-warn {
margin: 0 0 10px;
color: #d46b08;
font-size: 12px;
line-height: 1.5;
}
.mnote-ai-admin-token-card-value {
margin: 0;
padding: 10px 12px;
border: 1px solid #91caff;
border-radius: 6px;
background: #ffffff;
color: rgba(0,0,0,0.88);
font: 12px/1.55 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-all;
}
.mnote-ai-admin-token-card-meta {
margin: 10px 0 0;
color: rgba(0,0,0,0.55);
font-size: 12px;
line-height: 1.5;
}
.mnote-ai-admin-token-card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.mnote-ai-admin-token-card details {
margin-top: 10px;
}
.mnote-ai-admin-token-card summary {
cursor: pointer;
color: rgba(0,0,0,0.55);
font-size: 12px;
}
.mnote-ai-admin-modal-backdrop {
position: fixed;
inset: 0;
z-index: 12000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(15, 23, 42, 0.45);
}
.mnote-ai-admin-modal-backdrop[hidden] {
display: none !important;
}
.mnote-ai-admin-modal {
width: min(560px, 100%);
max-height: min(80vh, 720px);
overflow: auto;
border-radius: 12px;
background: #fff;
box-shadow: 0 18px 50px rgba(0,0,0,0.22);
padding: 16px 18px 14px;
}
.mnote-ai-admin-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.mnote-ai-admin-modal-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: rgba(0,0,0,0.88);
}
.mnote-ai-admin-modal-label {
margin: 10px 0 4px;
font-size: 12px;
color: #6b7280;
}
.mnote-ai-admin-modal-pre {
margin: 0;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
font: 12px/1.5 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
white-space: pre-wrap;
word-break: break-all;
}
.mnote-ai-admin-modal-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
justify-content: flex-end;
}
.mnote-ai-admin-chip-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-width: 240px;
}
.mnote-ai-admin-access-scope-row {
display: flex;
justify-content: space-between;
@@ -1062,6 +1287,35 @@ const AI_ADMIN_SCRIPT: &str = r#"
.replace(/'/g, '&#39;');
}
async function copyText(text, okLabel) {
var value = String(text == null ? '' : text);
if (!value) throw new Error('');
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(value);
} else {
var ta = document.createElement('textarea');
ta.value = value;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
return okLabel || '';
}
function flashButton(btn, label) {
if (!btn) return;
var prev = btn.getAttribute('data-flash-prev') || btn.textContent;
btn.setAttribute('data-flash-prev', prev);
btn.textContent = label;
window.setTimeout(function () {
btn.textContent = btn.getAttribute('data-flash-prev') || prev;
}, 1400);
}
async function requestJson(url, options) {
var response = await fetch(url, {
credentials: 'include',
@@ -2923,6 +3177,395 @@ const AI_ADMIN_SCRIPT: &str = r#"
}
}
// ── 7-76 API 访问令牌 / vault token(列表 + 详情弹窗)──
var patListBody = root.querySelector('[data-ai-pat-list]');
var vaultIssuedList = root.querySelector('[data-ai-vault-issued-list]');
var patFormStatus = root.querySelector('[data-ai-pat-form-status]');
var vaultFormStatus = root.querySelector('[data-ai-vault-form-status]');
var tokenModal = root.querySelector('[data-ai-token-modal]');
var tokenModalTitle = root.querySelector('[data-ai-token-modal-title]');
var tokenModalToken = root.querySelector('[data-ai-token-modal-token]');
var tokenModalHow = root.querySelector('[data-ai-token-modal-how]');
var tokenModalError = root.querySelector('[data-ai-token-modal-error]');
var lastDetailToken = '';
var patRuntime = { environment: 'dev', baseUrl: 'http://127.0.0.1:3000', envFile: '~/.config/mnote/agent-env/users/<subject>/dev.env' };
// 设置页独立壳也加载 mnote-ui-runtime;此处再兜底避免 mnote 未就绪
function uiConfirm(message, options) {
if (window.mnote && typeof window.mnote.confirm === 'function') {
return window.mnote.confirm(message, options);
}
return Promise.resolve(window.confirm(message));
}
function uiAlert(message, options) {
if (window.mnote && typeof window.mnote.alert === 'function') {
return window.mnote.alert(message, options);
}
window.alert(message);
return Promise.resolve();
}
function selectedPatScopes() {
return Array.prototype.slice.call(root.querySelectorAll('[data-ai-pat-scope]:checked'))
.map(function (el) { return el.value; })
.filter(Boolean);
}
function envLabel(env) {
var e = String(env || 'dev');
if (e === 'prod') return '';
if (e === 'dev') return '';
return e;
}
function setFormStatus(el, text, isError) {
if (!el) return;
el.textContent = text || '';
el.style.color = isError ? '#b91c1c' : '#6b7280';
}
function renderScopeChips(scopes) {
var list = scopes || [];
if (!list.length) return '<span class="mnote-ai-admin-tag">-</span>';
return '<div class="mnote-ai-admin-chip-row">' + list.map(function (s) {
return '<span class="mnote-ai-admin-tag">' + escapeHtml(s) + '</span>';
}).join('') + '</div>';
}
function statusTag(item) {
if (item.revokedAt) return '<span class="mnote-ai-admin-tag mnote-ai-admin-tag--red"></span>';
var installed = !!(item.installed || (item.localInstall && item.localInstall.installed));
var active = !!(item.active || (item.localInstall && item.localInstall.active));
if (installed) {
var label = active ? '·' : '';
return '<span class="mnote-ai-admin-tag" style="background:#e8f3ff;color:#1d4ed8;">' + label + '</span>';
}
return '<span class="mnote-ai-admin-tag" style="background:#f3f4f6;color:#6b7280;"></span>';
}
function openTokenModal(opts) {
if (!tokenModal) {
console.warn('token modal missing');
return;
}
var title = (opts && opts.title) || '';
var token = (opts && opts.token) || '';
var how = (opts && opts.howTo) || '';
var err = (opts && opts.error) || '';
lastDetailToken = token;
if (tokenModalTitle) tokenModalTitle.textContent = title;
if (tokenModalToken) tokenModalToken.textContent = token || (err ? '' : '');
if (tokenModalHow) tokenModalHow.textContent = how || '';
if (tokenModalError) {
tokenModalError.textContent = err || '';
tokenModalError.hidden = !err;
}
tokenModal.hidden = false;
}
function closeTokenModal() {
if (tokenModal) tokenModal.hidden = true;
lastDetailToken = '';
if (tokenModalToken) tokenModalToken.textContent = '';
if (tokenModalHow) tokenModalHow.textContent = '';
if (tokenModalError) {
tokenModalError.textContent = '';
tokenModalError.hidden = true;
}
}
function renderPatList(items) {
if (!patListBody) return;
if (!items || !items.length) {
patListBody.innerHTML = '<tr><td colspan="6" class="mnote-ai-admin-empty"></td></tr>';
return;
}
patListBody.innerHTML = items.map(function (item) {
var installed = !!(item.installed || (item.localInstall && item.localInstall.installed));
var id = item.id || '';
var confBtn = '';
if (!item.revokedAt) {
confBtn = installed
? '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small" data-ai-pat-uninstall="' + escapeHtml(id) + '"></button> '
: '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small mnote-ai-admin-btn--primary" data-ai-pat-install="' + escapeHtml(id) + '"></button> ';
}
var detailBtn = '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small" data-ai-pat-detail-btn="' + escapeHtml(id) + '" data-ai-pat-detail-name="' + escapeHtml(item.name || '') + '"></button> ';
var revokeBtn = item.revokedAt ? ''
: '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small mnote-ai-admin-btn--danger" data-ai-pat-revoke="' + escapeHtml(id) + '"></button> ';
var deleteBtn = '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small mnote-ai-admin-btn--danger" data-ai-pat-delete="' + escapeHtml(id) + '"></button>';
return '<tr>'
+ '<td>' + escapeHtml(item.name || '') + '</td>'
+ '<td>' + escapeHtml(item.subjectUserId || '') + '</td>'
+ '<td><code>' + escapeHtml(item.tokenPrefix || '') + '</code></td>'
+ '<td>' + renderScopeChips(item.scopes || []) + '</td>'
+ '<td>' + statusTag(item) + '</td>'
+ '<td>' + confBtn + detailBtn + revokeBtn + deleteBtn + '</td>'
+ '</tr>';
}).join('');
}
async function loadPatList() {
if (!patListBody) return;
// 即使用户面 isAdmin=true 也不默认 all:避免 liaibo/mnote-e2e 列表交叉。
// 仅 /admin/ai 且显式 pageConfig.listAllTokens 时才拉全量(代签运维)。
var listAll = isAdmin && pageConfig.listAllTokens === true;
var q = listAll ? '?all=1' : '';
var payload = await requestJson('/api/ai-tokens' + q, { method: 'GET' });
if (payload && payload.runtime) patRuntime = payload.runtime;
var hint = root.querySelector('[data-ai-pat-runtime]');
if (hint && patRuntime) {
var active = patRuntime.activePatSubject ? (' · active:' + patRuntime.activePatSubject) : '';
hint.textContent = envLabel(patRuntime.environment) + ' · ' + (patRuntime.baseUrl || '') + active;
}
renderPatList(payload && payload.items);
}
function renderVaultLocalList(items) {
if (!vaultIssuedList) return;
if (!items || !items.length) {
vaultIssuedList.innerHTML = '<tr><td colspan="6" class="mnote-ai-admin-empty"></td></tr>';
return;
}
vaultIssuedList.innerHTML = items.map(function (item) {
var subject = item.subject || '';
var confBtn = item.installed
? '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small" data-ai-vault-uninstall="' + escapeHtml(subject) + '"></button> '
: '';
var detailBtn = '<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small" data-ai-vault-detail="' + escapeHtml(subject) + '"></button>';
return '<tr>'
+ '<td>' + escapeHtml(item.name || subject) + '</td>'
+ '<td>' + escapeHtml(subject) + '</td>'
+ '<td><code>' + escapeHtml(item.tokenPrefix || '') + '</code></td>'
+ '<td>' + renderScopeChips(item.scopes || []) + '</td>'
+ '<td>' + statusTag({ installed: item.installed }) + '</td>'
+ '<td>' + confBtn + detailBtn + '</td>'
+ '</tr>';
}).join('');
}
async function loadVaultLocalList() {
if (!vaultIssuedList) return;
try {
// 默认只列当前登录主体;admin 运维全量需 pageConfig.listAllTokens
var listAll = isAdmin && pageConfig.listAllTokens === true;
var q = listAll ? '?all=1' : '';
var payload = await requestJson('/api/vault/ai/token/local' + q, { method: 'GET' });
var data = (payload && payload.result) || payload || {};
if (data.runtime) patRuntime = Object.assign({}, patRuntime, data.runtime);
var hint = root.querySelector('[data-ai-vault-runtime]');
if (hint) {
var active = patRuntime.activeVaultSubject ? (' · active:' + patRuntime.activeVaultSubject) : '';
hint.textContent = envLabel(patRuntime.environment) + ' · ' + (patRuntime.baseUrl || '') + active;
}
renderVaultLocalList(data.items);
} catch (err) {
vaultIssuedList.innerHTML = '<tr><td colspan="6">' + escapeHtml(err.message || '') + '</td></tr>';
}
}
var createPatBtn = root.querySelector('[data-ai-pat-create]');
if (createPatBtn) {
createPatBtn.addEventListener('click', async function () {
setFormStatus(patFormStatus, '');
try {
var nameEl = root.querySelector('[data-ai-pat-name]');
var subjectEl = root.querySelector('[data-ai-pat-subject]');
var ttlEl = root.querySelector('[data-ai-pat-ttl]');
var scopes = selectedPatScopes();
if (!scopes.length) throw new Error(' scope');
var body = {
name: (nameEl && nameEl.value.trim()) || 'default',
scopes: scopes,
ttlDays: ttlEl && ttlEl.value !== '' ? Number(ttlEl.value) : 90
};
if (subjectEl && subjectEl.value.trim()) body.subjectUserId = subjectEl.value.trim();
var payload = await requestJson('/api/ai-tokens', {
method: 'POST',
body: JSON.stringify(body)
});
var rec = payload.record || {};
if (rec.id) {
try {
await requestJson('/api/ai-tokens/' + encodeURIComponent(rec.id) + '/install', { method: 'POST', body: '{}' });
} catch (_) {}
}
await loadPatList();
setFormStatus(patFormStatus, '', false);
} catch (err) {
setFormStatus(patFormStatus, err.message || String(err), true);
}
});
}
var refreshPatBtn = root.querySelector('[data-ai-pat-refresh]');
if (refreshPatBtn) {
refreshPatBtn.addEventListener('click', function () {
loadPatList().catch(function (err) {
if (patListBody) patListBody.innerHTML = '<tr><td colspan="6">' + escapeHtml(err.message || '') + '</td></tr>';
});
});
}
if (tokenModal) {
tokenModal.addEventListener('click', function (ev) {
var t = ev.target;
if (!(t instanceof HTMLElement)) return;
if (t.getAttribute('data-ai-token-modal-close') != null || t === tokenModal) {
closeTokenModal();
}
});
}
var modalCopyToken = root.querySelector('[data-ai-token-modal-copy-token]');
if (modalCopyToken) {
modalCopyToken.addEventListener('click', function () {
copyText(lastDetailToken || (tokenModalToken && tokenModalToken.textContent) || '', '')
.then(function (msg) { flashButton(modalCopyToken, msg); })
.catch(function (err) { flashButton(modalCopyToken, err.message || ''); });
});
}
var modalCopyHow = root.querySelector('[data-ai-token-modal-copy-how]');
if (modalCopyHow) {
modalCopyHow.addEventListener('click', function () {
copyText((tokenModalHow && tokenModalHow.textContent) || '', '')
.then(function (msg) { flashButton(modalCopyHow, msg); })
.catch(function (err) { flashButton(modalCopyHow, err.message || ''); });
});
}
if (patListBody) {
patListBody.addEventListener('click', async function (ev) {
var t = ev.target;
if (!(t instanceof HTMLElement)) return;
var detailId = t.getAttribute('data-ai-pat-detail-btn');
var detailName = t.getAttribute('data-ai-pat-detail-name') || '';
var revokeId = t.getAttribute('data-ai-pat-revoke');
var deleteId = t.getAttribute('data-ai-pat-delete');
var installId = t.getAttribute('data-ai-pat-install');
var uninstallId = t.getAttribute('data-ai-pat-uninstall');
try {
if (detailId) {
try {
var revealed = await requestJson('/api/ai-tokens/' + encodeURIComponent(detailId) + '/reveal', { method: 'POST', body: '{}' });
openTokenModal({
title: (detailName || 'API ') + ' · ',
token: revealed.token || '',
howTo: revealed.howTo || ''
});
} catch (err) {
openTokenModal({
title: (detailName || 'API ') + ' · ',
token: '',
howTo: '',
error: err.message || String(err)
});
}
return;
}
if (revokeId) {
if (!(await uiConfirm('', { tone: 'danger' }))) return;
await requestJson('/api/ai-tokens/' + encodeURIComponent(revokeId) + '/revoke', { method: 'POST', body: '{}' });
await loadPatList();
}
if (deleteId) {
if (!(await uiConfirm('', { tone: 'danger' }))) return;
await requestJson('/api/ai-tokens/' + encodeURIComponent(deleteId), { method: 'DELETE' });
await loadPatList();
}
if (installId) {
await requestJson('/api/ai-tokens/' + encodeURIComponent(installId) + '/install', { method: 'POST', body: '{}' });
await loadPatList();
}
if (uninstallId) {
await requestJson('/api/ai-tokens/' + encodeURIComponent(uninstallId) + '/uninstall', { method: 'POST', body: '{}' });
await loadPatList();
}
} catch (err) {
setFormStatus(patFormStatus, err.message || String(err), true);
}
});
}
var vaultTokenBtn = root.querySelector('[data-ai-vault-token-create]');
if (vaultTokenBtn) {
vaultTokenBtn.addEventListener('click', async function () {
setFormStatus(vaultFormStatus, '');
try {
var subjectEl = root.querySelector('[data-ai-vault-token-subject]');
var ttlEl = root.querySelector('[data-ai-vault-token-ttl]');
var presetEl = root.querySelector('[data-ai-vault-token-preset]');
var body = { install: true };
if (subjectEl && subjectEl.value.trim()) body.subjectUserId = subjectEl.value.trim();
if (ttlEl && ttlEl.value !== '') body.ttlHours = Number(ttlEl.value);
var preset = presetEl ? presetEl.value : 'read';
if (preset === 'read_login') {
body.scopes = ['list', 'get', 'resolve', 'login', 'session'];
} else {
body.scopes = ['list', 'get', 'resolve'];
}
var payload = await requestJson('/api/vault/ai/token', {
method: 'POST',
body: JSON.stringify(body)
});
var result = payload && (payload.result || payload);
var installInfo = (result && result.install) || {};
await loadVaultLocalList();
if (installInfo.ok === false) {
setFormStatus(vaultFormStatus, installInfo.error || '', true);
} else {
setFormStatus(vaultFormStatus, '', false);
}
} catch (err) {
setFormStatus(vaultFormStatus, err.message || String(err), true);
}
});
}
if (vaultIssuedList) {
vaultIssuedList.addEventListener('click', async function (ev) {
var t = ev.target;
if (!(t instanceof HTMLElement)) return;
var subject = t.getAttribute('data-ai-vault-uninstall');
var detailSubject = t.getAttribute('data-ai-vault-detail');
if (detailSubject) {
try {
var payload = await requestJson('/api/vault/ai/token/local/' + encodeURIComponent(detailSubject), { method: 'GET' });
var data = (payload && payload.result) || payload || {};
openTokenModal({
title: (data.name || detailSubject) + ' · Vault ',
token: data.token || '',
howTo: data.howTo || ''
});
} catch (err) {
openTokenModal({
title: detailSubject + ' · Vault ',
token: '',
howTo: '',
error: err.message || String(err)
});
}
return;
}
if (!subject) return;
if (!(await uiConfirm(' token ', { tone: 'danger' }))) return;
try {
await requestJson('/api/vault/ai/token/uninstall', {
method: 'POST',
body: JSON.stringify({ subjectUserId: subject })
});
await loadVaultLocalList();
setFormStatus(vaultFormStatus, '');
} catch (err) {
setFormStatus(vaultFormStatus, err.message || String(err), true);
}
});
}
var vaultLocalRefresh = root.querySelector('[data-ai-vault-local-refresh]');
if (vaultLocalRefresh) {
vaultLocalRefresh.addEventListener('click', function () { loadVaultLocalList(); });
}
loadVaultLocalList();
// ── 启动 ──
loadEffective().catch(function (err) {
if (effectiveJson) setText(effectiveJson, { error: err.message || '' });
@@ -2940,6 +3583,9 @@ const AI_ADMIN_SCRIPT: &str = r#"
// 静默处理,各个容器已显示错误状态
console.warn('loadConfig :', err && err.message);
});
loadPatList().catch(function (err) {
if (patListBody) patListBody.innerHTML = '<tr><td colspan="7">' + escapeHtml(err.message || '') + '</td></tr>';
});
if (isAdmin) {
loadUsers().catch(function (err) {
if (userSettingsEditor) userSettingsEditor.innerHTML = '<div class="mnote-ai-admin-empty">' + escapeHtml(err.message || '') + '</div>';
@@ -2977,8 +3623,10 @@ pub fn AiManagementPage(
"个人设置 — 查看 AI 配置、当前目录权限并提交申请"
};
// listAllTokens:仅管理员页默认 true(代签运维看全量);用户页 false,避免配置交叉
let config_json = format!(
r#"{{"isAdmin":{},"workspaceName":"{}"}}"#,
r#"{{"isAdmin":{},"listAllTokens":{},"workspaceName":"{}"}}"#,
if is_admin { "true" } else { "false" },
if is_admin { "true" } else { "false" },
workspace_name.replace('"', "\\\"")
);
@@ -3025,6 +3673,8 @@ pub fn AiManagementPage(
<a href="#ai-admin-mcp" data-ai-admin-nav data-nav-icon="service">"MCP"</a>
<a href="#ai-admin-pi-extensions" data-ai-admin-nav data-nav-icon="tools">"Pi 扩展"</a>
<a href="#ai-admin-access" data-ai-admin-nav data-nav-icon="folder">"目录权限"</a>
<a href="#ai-admin-api-tokens" data-ai-admin-nav data-nav-icon="tools">"API 访问令牌"</a>
<a href="#ai-admin-vault-ai" data-ai-admin-nav data-nav-icon="db">"密码箱 AI 访问"</a>
<a href="#ai-admin-knowledge" data-ai-admin-nav data-nav-icon="db">"知识库"</a>
{if is_admin {
view! { <a href="#ai-admin-channels" data-ai-admin-nav data-nav-icon="channel">"渠道管理"</a> }.into_any()
@@ -3309,6 +3959,181 @@ pub fn AiManagementPage(
</div>
</section>
<section id="ai-admin-api-tokens" class="mnote-ai-admin-section mnote-ai-admin-panel" data-ai-admin-panel hidden data-testid="mnote-ai-admin-api-tokens">
<div class="mnote-ai-admin-section-header">
<div>
<h2>"API 访问令牌"</h2>
<p class="mnote-ai-admin-section-desc">
"Web PATmnpat1.*)访问笔记 API · 与密码箱 mnv1 分职"
</p>
</div>
</div>
<div class="mnote-ai-admin-section-body">
<div class="mnote-ai-admin-token-form">
<label class="mnote-ai-admin-field">
<span>"名称"</span>
<input type="text" data-ai-pat-name placeholder="paseo-agent" />
</label>
<label class="mnote-ai-admin-field">
<span>"有效天数(-1 不过期)"</span>
<input type="number" data-ai-pat-ttl value="90" min="-1" />
</label>
{if is_admin {
view! {
<label class="mnote-ai-admin-field">
<span>"代签主体(可选)"</span>
<input type="text" data-ai-pat-subject placeholder="默认自己" />
</label>
}.into_any()
} else {
view! {}.into_any()
}}
</div>
<div class="mnote-ai-admin-field" style="margin-bottom:10px;">
<span>"Scope"</span>
<div class="mnote-ai-admin-scope-grid" data-ai-pat-scope-grid>
<label class="mnote-ai-admin-scope-item">
<input type="checkbox" data-ai-pat-scope value="notes.read" checked />
<span>"notes.read"</span>
</label>
<label class="mnote-ai-admin-scope-item">
<input type="checkbox" data-ai-pat-scope value="notes.write" checked />
<span>"notes.write"</span>
</label>
<label class="mnote-ai-admin-scope-item">
<input type="checkbox" data-ai-pat-scope value="tree.read" checked />
<span>"tree.read"</span>
</label>
<label class="mnote-ai-admin-scope-item">
<input type="checkbox" data-ai-pat-scope value="tree.write" checked />
<span>"tree.write"</span>
</label>
<label class="mnote-ai-admin-scope-item">
<input type="checkbox" data-ai-pat-scope value="ai.settings.read" />
<span>"ai.settings.read"</span>
</label>
<label class="mnote-ai-admin-scope-item">
<input type="checkbox" data-ai-pat-scope value="ai.usage.read" />
<span>"ai.usage.read"</span>
</label>
</div>
</div>
<div class="mnote-ai-admin-token-form--actions" style="align-items:center;gap:12px;">
<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--primary" data-ai-pat-create>"创建并配置"</button>
<span data-ai-pat-form-status style="font-size:12px;color:#6b7280;"></span>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin:18px 0 8px;">
<div>
<strong style="font-size:13px;">"已签发"</strong>
<span data-ai-pat-runtime style="font-size:12px;color:#8B8782;margin-left:8px;"></span>
</div>
<button type="button" class="mnote-ai-admin-btn" data-ai-pat-refresh>"刷新"</button>
</div>
<div class="mnote-ai-admin-table-wrap">
<table class="mnote-ai-admin-table">
<thead>
<tr>
<th>"名称"</th>
<th>"主体"</th>
<th>"前缀"</th>
<th>"Scope"</th>
<th>"状态"</th>
<th>"操作"</th>
</tr>
</thead>
<tbody data-ai-pat-list>
<tr><td colspan="6" class="mnote-ai-admin-skeleton">"加载…"</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section id="ai-admin-vault-ai" class="mnote-ai-admin-section mnote-ai-admin-panel" data-ai-admin-panel hidden data-testid="mnote-ai-admin-vault-ai">
<div class="mnote-ai-admin-section-header">
<div>
<h2>"密码箱 AI 访问"</h2>
<p class="mnote-ai-admin-section-desc">
"Vault tokenmnv1.*)本机 agent 读密 · 与 Web PAT 分职"
</p>
</div>
</div>
<div class="mnote-ai-admin-section-body">
<div class="mnote-ai-admin-token-form">
{if is_admin {
view! {
<label class="mnote-ai-admin-field">
<span>"代签主体(可选)"</span>
<input type="text" data-ai-vault-token-subject placeholder="默认自己;代签填用户 id" />
</label>
}.into_any()
} else {
view! {}.into_any()
}}
<label class="mnote-ai-admin-field">
<span>"TTL 小时(空=不过期)"</span>
<input type="number" data-ai-vault-token-ttl placeholder="168" min="1" />
</label>
<label class="mnote-ai-admin-field">
<span>"用途"</span>
<select data-ai-vault-token-preset>
<option value="read">"读密 list/get/resolve"</option>
<option value="read_login">"读密 + login/session"</option>
</select>
</label>
</div>
<div class="mnote-ai-admin-token-form--actions" style="align-items:center;gap:12px;">
<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--primary" data-ai-vault-token-create>"签发并配置"</button>
<span data-ai-vault-form-status style="font-size:12px;color:#6b7280;"></span>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin:18px 0 8px;">
<div>
<strong style="font-size:13px;">"已配置"</strong>
<span data-ai-vault-runtime style="font-size:12px;color:#8B8782;margin-left:8px;"></span>
</div>
<button type="button" class="mnote-ai-admin-btn" data-ai-vault-local-refresh>"刷新"</button>
</div>
<div class="mnote-ai-admin-table-wrap">
<table class="mnote-ai-admin-table">
<thead>
<tr>
<th>"名称"</th>
<th>"主体"</th>
<th>"前缀"</th>
<th>"Scope"</th>
<th>"状态"</th>
<th>"操作"</th>
</tr>
</thead>
<tbody data-ai-vault-issued-list>
<tr><td colspan="6" class="mnote-ai-admin-skeleton">"加载…"</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<div class="mnote-ai-admin-modal-backdrop" data-ai-token-modal hidden>
<div class="mnote-ai-admin-modal" role="dialog" aria-modal="true">
<div class="mnote-ai-admin-modal-head">
<h3 class="mnote-ai-admin-modal-title" data-ai-token-modal-title>"详情"</h3>
<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--small" data-ai-token-modal-close>"关闭"</button>
</div>
<p class="mnote-ai-admin-modal-label">"Token / API Key"</p>
<pre class="mnote-ai-admin-modal-pre" data-ai-token-modal-token></pre>
<p class="mnote-ai-admin-modal-label">"配置方法"</p>
<pre class="mnote-ai-admin-modal-pre" data-ai-token-modal-how></pre>
<p data-ai-token-modal-error hidden style="margin:10px 0 0;color:#b91c1c;font-size:12px;"></p>
<div class="mnote-ai-admin-modal-actions">
<button type="button" class="mnote-ai-admin-btn" data-ai-token-modal-copy-how>"复制配置方法"</button>
<button type="button" class="mnote-ai-admin-btn mnote-ai-admin-btn--primary" data-ai-token-modal-copy-token>"复制 token"</button>
<button type="button" class="mnote-ai-admin-btn" data-ai-token-modal-close>"关闭"</button>
</div>
</div>
</div>
<section id="ai-admin-knowledge" class="mnote-ai-admin-section mnote-ai-admin-panel" data-ai-admin-panel hidden data-testid="mnote-ai-admin-knowledge">
<div class="mnote-ai-admin-section-header">
<div>
@@ -3437,6 +4262,24 @@ mod tests {
assert!(html.contains("Pi 扩展包"));
assert!(html.contains("工具权限"));
assert!(html.contains("MNote 设置"));
assert!(html.contains("API 访问令牌"));
assert!(html.contains("密码箱 AI 访问"));
assert!(html.contains("data-testid=\"mnote-ai-admin-api-tokens\""));
assert!(html.contains("data-testid=\"mnote-ai-admin-vault-ai\""));
assert!(html.contains("data-ai-pat-scope"));
assert!(html.contains("data-ai-pat-create"));
assert!(html.contains("data-ai-token-modal"));
assert!(html.contains("data-ai-pat-runtime"));
assert!(html.contains("data-ai-vault-token-create"));
assert!(html.contains("data-ai-vault-issued-list"));
assert!(html.contains("data-ai-vault-runtime"));
assert!(AI_ADMIN_STYLE.contains(".mnote-ai-admin-field"));
assert!(AI_ADMIN_SCRIPT.contains("selectedPatScopes"));
assert!(AI_ADMIN_SCRIPT.contains("loadVaultLocalList"));
assert!(AI_ADMIN_SCRIPT.contains("data-ai-pat-install"));
assert!(AI_ADMIN_SCRIPT.contains("body.scopes = ['list', 'get', 'resolve']"));
assert!(AI_ADMIN_SCRIPT.contains("install: true"));
assert!(AI_ADMIN_SCRIPT.contains("openTokenModal"));
}
#[test]
@@ -3452,7 +4295,9 @@ mod tests {
assert!(html.contains("目录权限"));
// 内嵌脚本包含 action 字符串;用户态由 isAdmin=false 禁用编辑。
assert!(html.contains(r#""isAdmin":false"#));
assert!(html.contains(r#""listAllTokens":false"#));
assert!(AI_ADMIN_SCRIPT.contains("var disabledAttr = isAdmin ? '' : ' disabled'"));
assert!(AI_ADMIN_SCRIPT.contains("listAllTokens"));
}
#[test]
+4 -52
View File
@@ -1,11 +1,10 @@
//! MNOTE 登录页面组件
//!
//! 7-76 P0:开发对标生产。登录页仅标准账号密码登录/注册,
//! 不再渲染「测试账号快速登录」或任何测试密码 DOM。
use leptos::prelude::*;
const TEST_ACCOUNT_EMAIL: &str = "mnote.e2e@example.com";
const TEST_ACCOUNT_PASSWORD: &str = "MnoteE2E123!";
const TEST_ACCOUNT_NAME: &str = "mnote-e2e";
/// MNOTE 登录页面
#[component]
pub fn AuthPage() -> impl IntoView {
@@ -71,16 +70,6 @@ pub fn AuthPage() -> impl IntoView {
</label>
<p class="mnote-auth-message" role="status" aria-live="polite" data-auth-message></p>
<button class="mnote-auth-submit" type="submit" data-auth-submit>"登录"</button>
<button
class="mnote-auth-quick-login"
type="button"
data-auth-test-login
data-test-email=TEST_ACCOUNT_EMAIL
data-test-password=TEST_ACCOUNT_PASSWORD
data-test-name=TEST_ACCOUNT_NAME
>
"测试账号快速登录"
</button>
</form>
<button class="mnote-auth-switch" type="button" data-auth-switch data-flow="signIn">
"没有账号?注册"
@@ -107,10 +96,9 @@ const AUTH_SCRIPT: &str = r#"
var title = root.querySelector('#mnote-auth-title');
var subtitle = root.querySelector('.mnote-auth-heading p');
var submit = root.querySelector('[data-auth-submit]');
var quickLogin = root.querySelector('[data-auth-test-login]');
var switcher = root.querySelector('[data-auth-switch]');
var message = root.querySelector('[data-auth-message]');
if (!form || !flowInput || !submit || !quickLogin || !switcher || !message) return;
if (!form || !flowInput || !submit || !switcher || !message) return;
function setMessage(text, type) {
message.textContent = text || '';
@@ -119,7 +107,6 @@ const AUTH_SCRIPT: &str = r#"
function setBusy(isBusy) {
submit.disabled = !!isBusy;
quickLogin.disabled = !!isBusy;
switcher.disabled = !!isBusy;
}
@@ -146,7 +133,6 @@ const AUTH_SCRIPT: &str = r#"
if (usernameInput) {
usernameInput.required = isSignUp;
}
quickLogin.hidden = isSignUp;
var passwordInput = root.querySelector('#password');
if (passwordInput) passwordInput.autocomplete = isSignUp ? 'new-password' : 'current-password';
setMessage('', '');
@@ -218,40 +204,6 @@ const AUTH_SCRIPT: &str = r#"
}
});
quickLogin.addEventListener('click', async function () {
var email = String(quickLogin.dataset.testEmail || '').trim();
var password = String(quickLogin.dataset.testPassword || '');
var username = String(quickLogin.dataset.testName || '').trim();
if (!email || !password || !username) {
setMessage('', 'error');
return;
}
var passwordInput = root.querySelector('#password');
if (accountInput) accountInput.value = email;
if (passwordInput) passwordInput.value = password;
setBusy(true);
setMessage('...', 'info');
try {
await requestAuth('signIn', { account: email, password: password });
setMessage('...', 'success');
window.location.assign('/');
return;
} catch (_signInError) {
setMessage('...', 'info');
}
try {
await requestAuth('signUp', { email: email, username: username, password: password });
setMessage('...', 'success');
window.location.assign('/');
} catch (error) {
setMessage(error && error.message ? error.message : '', 'error');
setBusy(false);
}
});
setFlow('signIn');
})();
"#;
@@ -609,8 +609,11 @@ mod tests {
#[test]
fn mnote_ui_runtime_exposes_toast_api() {
assert!(MNOTE_UI_RUNTIME_JS.contains("window.mnote.toast"));
assert!(MNOTE_UI_RUNTIME_JS.contains("window.mnote.confirm"));
assert!(MNOTE_UI_RUNTIME_JS.contains("window.mnote.alert"));
assert!(MNOTE_UI_RUNTIME_JS.contains("data-mnote-toast-region"));
assert!(MNOTE_UI_RUNTIME_JS.contains("mnote:toast"));
assert!(MNOTE_UI_RUNTIME_JS.contains("mnote-dialog-backdrop"));
}
#[test]
+5
View File
@@ -14,6 +14,7 @@
/// - `styles/pages/home.css`, `shells.css`, `auth.css`: 页面级样式
/// - `styles/components/main.css`: 核心组件样式(编辑器、侧栏、树、附件等)
/// - `styles/components/toast.css`: 全局 toast 与 portal 反馈样式
/// - `styles/components/dialog.css`: 全局居中 confirm / alert
/// - `styles/components/search.css`: 搜索弹窗组件
/// - `styles/components/page-ai.css`: Page AI 仪表盘组件
/// - `styles/components/ui-debug.css`: UI Debug 组件矩阵
@@ -33,6 +34,8 @@ pub const MNOTE_CSS: &str = concat!(
"\n",
include_str!("styles/components/toast.css"),
"\n",
include_str!("styles/components/dialog.css"),
"\n",
include_str!("styles/components/search.css"),
"\n",
include_str!("styles/components/page-ai.css"),
@@ -153,6 +156,8 @@ mod tests {
assert!(MNOTE_CSS.contains("--wolai-state-focus-ring"));
assert!(MNOTE_CSS.contains(".mnote-toast-region"));
assert!(MNOTE_CSS.contains(".mnote-toast--success"));
assert!(MNOTE_CSS.contains(".mnote-dialog-backdrop"));
assert!(MNOTE_CSS.contains(".mnote-dialog__btn--primary"));
}
#[test]
@@ -0,0 +1,113 @@
/* 全局居中确认 / 提示(替代浏览器 confirm / alert */
.mnote-dialog-backdrop {
position: fixed;
inset: 0;
z-index: var(--wolai-z-modal, 1400);
display: flex;
align-items: center;
justify-content: center;
padding: var(--wolai-spacing-lg, 20px);
background: rgba(15, 23, 42, 0.32);
backdrop-filter: blur(2px);
}
.mnote-dialog-backdrop[hidden] {
display: none !important;
}
.mnote-dialog {
width: min(400px, 100%);
border-radius: var(--wolai-radius-lg, 10px);
border: 1px solid var(--wolai-border, rgba(31, 35, 40, 0.12));
background: var(--wolai-bg, #ffffff);
box-shadow: var(--wolai-shadow-overlay, 0 20px 48px rgba(15, 23, 42, 0.22));
color: var(--wolai-text-primary, #1f2328);
overflow: hidden;
animation: mnote-dialog-in 140ms ease-out;
}
@keyframes mnote-dialog-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
.mnote-dialog__body {
padding: 18px 18px 8px;
}
.mnote-dialog__title {
margin: 0 0 8px;
font: var(--mnote-font-weight-semibold, 600) var(--mnote-font-size-md, 14px) /
var(--mnote-line-height-normal, 1.4) var(--wolai-font-sans, system-ui, sans-serif);
color: var(--wolai-text-primary, #1f2328);
}
.mnote-dialog__message {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font: var(--mnote-font-weight-regular, 400) var(--mnote-font-size-sm, 13px) /
1.55 var(--wolai-font-sans, system-ui, sans-serif);
color: var(--wolai-text-secondary, #4b5563);
}
.mnote-dialog__actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px 14px;
}
.mnote-dialog__btn {
min-width: 72px;
min-height: 32px;
border-radius: var(--wolai-radius-md, 6px);
border: 1px solid var(--wolai-border, rgba(31, 35, 40, 0.12));
padding: 6px 14px;
background: var(--wolai-bg, #ffffff);
color: var(--wolai-text-primary, #1f2328);
font: var(--mnote-font-weight-medium, 500) var(--mnote-font-size-sm, 13px) /
1.2 var(--wolai-font-sans, system-ui, sans-serif);
cursor: pointer;
}
.mnote-dialog__btn:hover {
background: var(--wolai-bg-hover, #f6f7f9);
}
.mnote-dialog__btn:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.35);
outline-offset: 1px;
}
.mnote-dialog__btn--primary {
border-color: var(--wolai-primary, #2563eb);
background: var(--wolai-primary, #2563eb);
color: #ffffff;
}
.mnote-dialog__btn--primary:hover {
background: var(--wolai-primary-hover, #1d4ed8);
}
.mnote-dialog__btn--danger {
border-color: #dc2626;
background: #dc2626;
color: #ffffff;
}
.mnote-dialog__btn--danger:hover {
background: #b91c1c;
}
.mnote-dialog--danger .mnote-dialog__title {
color: #991b1b;
}
@@ -2699,14 +2699,14 @@
overflow: hidden;
}
.mnote-weknora-kb-page {
.mnote-krag-kb-page {
gap: 0;
padding: 0;
border-radius: 10px;
background: #F6F7F9;
}
.mnote-weknora-kb-page-header {
.mnote-krag-kb-page-header {
display: flex;
align-items: center;
justify-content: space-between;
@@ -2716,7 +2716,7 @@
background: #FFFFFF;
}
.mnote-weknora-kb-page-title {
.mnote-krag-kb-page-title {
display: flex;
min-width: 0;
flex-direction: row;
@@ -2724,7 +2724,7 @@
align-items: baseline;
}
.mnote-weknora-kb-page-eyebrow {
.mnote-krag-kb-page-eyebrow {
display: none;
color: #2F7D4A;
font: 11px/16px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
@@ -2732,25 +2732,25 @@
text-transform: uppercase;
}
.mnote-weknora-kb-page-title strong {
.mnote-krag-kb-page-title strong {
color: #111827;
font-size: 17px;
line-height: 22px;
}
.mnote-weknora-kb-page-title span:last-child {
.mnote-krag-kb-page-title span:last-child {
color: #6B7280;
font-size: 12px;
line-height: 18px;
}
.mnote-weknora-kb-page-actions {
.mnote-krag-kb-page-actions {
display: flex;
gap: 8px;
align-items: center;
}
.mnote-weknora-kb-page-actions button {
.mnote-krag-kb-page-actions button {
display: inline-flex;
width: 32px;
height: 32px;
@@ -2763,12 +2763,12 @@
cursor: pointer;
}
.mnote-weknora-kb-page-actions button:hover:not(:disabled) {
.mnote-krag-kb-page-actions button:hover:not(:disabled) {
background: #F3F4F6;
color: #111827;
}
.mnote-weknora-kb-page-status {
.mnote-krag-kb-page-status {
padding: 6px 16px;
border-bottom: 1px solid #E5E7EB;
background: #F7FBF8;
@@ -2777,24 +2777,24 @@
line-height: 18px;
}
.mnote-weknora-kb-page-body {
.mnote-krag-kb-page-body {
display: grid;
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
min-height: min(710px, calc(100vh - 122px));
max-height: calc(100vh - 96px);
}
.mnote-weknora-kb-page-body[data-kb-list-hidden="true"] {
.mnote-krag-kb-page-body[data-kb-list-hidden="true"] {
grid-template-columns: minmax(0, 1fr);
}
.mnote-weknora-kb-list-pane,
.mnote-weknora-kb-detail-pane {
.mnote-krag-kb-list-pane,
.mnote-krag-kb-detail-pane {
min-height: 0;
overflow: auto;
}
.mnote-weknora-kb-list-pane {
.mnote-krag-kb-list-pane {
display: flex;
flex-direction: column;
gap: 12px;
@@ -2803,31 +2803,31 @@
background: #FFFFFF;
}
.mnote-weknora-kb-list-pane[hidden] {
.mnote-krag-kb-list-pane[hidden] {
display: none;
}
.mnote-weknora-kb-list-head {
.mnote-krag-kb-list-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.mnote-weknora-kb-list-head div {
.mnote-krag-kb-list-head div {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.mnote-weknora-kb-list-head strong {
.mnote-krag-kb-list-head strong {
color: #111827;
font-size: 14px;
line-height: 20px;
}
.mnote-weknora-kb-list-head span {
.mnote-krag-kb-list-head span {
min-width: 22px;
border-radius: 999px;
background: #EEF2FF;
@@ -2837,10 +2837,10 @@
text-align: center;
}
.mnote-weknora-kb-list-head button,
.mnote-krag-kb-list-head button,
.mnote-knowledge-rag-kb-manager button,
.mnote-weknora-doc-toolbar button,
.mnote-weknora-doc-actions button {
.mnote-krag-doc-toolbar button,
.mnote-krag-doc-actions button {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -2856,46 +2856,46 @@
padding: 0 10px;
}
.mnote-weknora-kb-list-head button {
.mnote-krag-kb-list-head button {
width: 30px;
padding: 0;
}
.mnote-weknora-kb-list-head button:hover,
.mnote-krag-kb-list-head button:hover,
.mnote-knowledge-rag-kb-manager button:hover:not(:disabled),
.mnote-weknora-doc-toolbar button:hover,
.mnote-weknora-doc-actions button:hover {
.mnote-krag-doc-toolbar button:hover,
.mnote-krag-doc-actions button:hover {
background: #F3F4F6;
color: #111827;
}
.mnote-knowledge-rag-kb-manager button:disabled,
.mnote-weknora-doc-toolbar button:disabled,
.mnote-weknora-doc-actions button:disabled {
.mnote-krag-doc-toolbar button:disabled,
.mnote-krag-doc-actions button:disabled {
opacity: .48;
cursor: not-allowed;
}
.mnote-weknora-doc-toolbar button.mnote-knowledge-rag-primary-action,
.mnote-weknora-doc-actions button.mnote-knowledge-rag-primary-action {
.mnote-krag-doc-toolbar button.mnote-knowledge-rag-primary-action,
.mnote-krag-doc-actions button.mnote-knowledge-rag-primary-action {
border-color: #2F7D4A;
background: #2F7D4A;
color: #FFFFFF;
}
.mnote-weknora-doc-toolbar button.mnote-knowledge-rag-primary-action:hover,
.mnote-weknora-doc-actions button.mnote-knowledge-rag-primary-action:hover {
.mnote-krag-doc-toolbar button.mnote-knowledge-rag-primary-action:hover,
.mnote-krag-doc-actions button.mnote-knowledge-rag-primary-action:hover {
background: #24683C;
color: #FFFFFF;
}
.mnote-weknora-kb-card-wrap {
.mnote-krag-kb-card-wrap {
display: flex;
flex-direction: column;
gap: 8px;
}
.mnote-weknora-kb-card {
.mnote-krag-kb-card {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
gap: 8px;
@@ -2910,13 +2910,13 @@
text-align: left;
}
.mnote-weknora-kb-card.is-active {
.mnote-krag-kb-card.is-active {
border-color: #2F7D4A;
background: #F0F9F4;
box-shadow: inset 3px 0 0 #2F7D4A;
}
.mnote-weknora-kb-card-star {
.mnote-krag-kb-card-star {
display: inline-flex;
width: 28px;
height: 28px;
@@ -2927,21 +2927,21 @@
color: #3730A3;
}
.mnote-weknora-kb-card-main {
.mnote-krag-kb-card-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.mnote-weknora-kb-card-main strong,
.mnote-weknora-kb-card-main em {
.mnote-krag-kb-card-main strong,
.mnote-krag-kb-card-main em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-weknora-kb-card-main strong {
.mnote-krag-kb-card-main strong {
color: #111827;
font-size: 13px;
font-style: normal;
@@ -2949,21 +2949,21 @@
line-height: 19px;
}
.mnote-weknora-kb-card-main em {
.mnote-krag-kb-card-main em {
color: #6B7280;
font-size: 11px;
font-style: normal;
line-height: 16px;
}
.mnote-weknora-kb-card-main span {
.mnote-krag-kb-card-main span {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.mnote-weknora-kb-card-main i {
.mnote-krag-kb-card-main i {
display: inline-flex;
align-items: center;
gap: 3px;
@@ -2973,16 +2973,16 @@
line-height: 16px;
}
.mnote-weknora-kb-card-main i.is-processing {
.mnote-krag-kb-card-main i.is-processing {
color: #B45309;
}
.mnote-weknora-kb-card-main .material-symbols-outlined {
.mnote-krag-kb-card-main .material-symbols-outlined {
font-size: 14px;
}
.mnote-weknora-kb-card-status,
.mnote-weknora-kb-type-pill {
.mnote-krag-kb-card-status,
.mnote-krag-kb-type-pill {
display: inline-flex;
align-items: center;
gap: 4px;
@@ -2995,7 +2995,7 @@
white-space: nowrap;
}
.mnote-weknora-kb-create-card {
.mnote-krag-kb-create-card {
display: flex;
flex-direction: column;
gap: 8px;
@@ -3005,11 +3005,11 @@
background: #F9FAFB;
}
.mnote-weknora-kb-create-card[hidden] {
.mnote-krag-kb-create-card[hidden] {
display: none;
}
.mnote-weknora-kb-create-title {
.mnote-krag-kb-create-title {
display: flex;
align-items: center;
gap: 6px;
@@ -3018,7 +3018,7 @@
line-height: 19px;
}
.mnote-weknora-kb-detail-pane {
.mnote-krag-kb-detail-pane {
display: flex;
flex-direction: column;
gap: 10px;
@@ -3026,7 +3026,7 @@
padding: 10px 14px 14px;
}
.mnote-weknora-kb-detail-hero {
.mnote-krag-kb-detail-hero {
order: 1;
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -3038,22 +3038,22 @@
background: #FFFFFF;
}
.mnote-weknora-kb-detail-hero[hidden] {
.mnote-krag-kb-detail-hero[hidden] {
display: none;
}
.mnote-weknora-kb-detail-hero select[hidden] {
.mnote-krag-kb-detail-hero select[hidden] {
display: none;
}
.mnote-weknora-kb-detail-hero h2 {
.mnote-krag-kb-detail-hero h2 {
margin: 0 0 2px;
color: #111827;
font-size: 16px;
line-height: 22px;
}
.mnote-weknora-kb-breadcrumb {
.mnote-krag-kb-breadcrumb {
display: none;
align-items: center;
gap: 7px;
@@ -3063,7 +3063,7 @@
line-height: 18px;
}
.mnote-weknora-kb-breadcrumb strong {
.mnote-krag-kb-breadcrumb strong {
min-width: 0;
overflow: hidden;
color: #111827;
@@ -3072,14 +3072,14 @@
white-space: nowrap;
}
.mnote-weknora-kb-detail-hero p {
.mnote-krag-kb-detail-hero p {
margin: 0;
color: #6B7280;
font-size: 12px;
line-height: 18px;
}
.mnote-weknora-kb-type-pill {
.mnote-krag-kb-type-pill {
display: none;
}
@@ -3323,15 +3323,15 @@
background: #F3F4F6;
}
.mnote-weknora-doc-toolbar,
.mnote-weknora-doc-actions {
.mnote-krag-doc-toolbar,
.mnote-krag-doc-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.mnote-weknora-doc-layout {
.mnote-krag-doc-layout {
display: flex;
min-height: 0;
flex: 1 1 auto;
@@ -3339,7 +3339,7 @@
gap: 10px;
}
.mnote-weknora-doc-sidebar {
.mnote-krag-doc-sidebar {
display: flex;
flex-direction: column;
gap: 10px;
@@ -3350,38 +3350,38 @@
background: #FFFFFF;
}
.mnote-weknora-doc-sidebar[hidden] {
.mnote-krag-doc-sidebar[hidden] {
display: none;
}
.mnote-weknora-doc-sidebar-head {
.mnote-krag-doc-sidebar-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.mnote-weknora-doc-sidebar-head strong {
.mnote-krag-doc-sidebar-head strong {
color: #111827;
font-size: 12px;
line-height: 18px;
}
.mnote-weknora-doc-sidebar-head span {
.mnote-krag-doc-sidebar-head span {
color: #9CA3AF;
font-size: 10px;
line-height: 14px;
}
.mnote-weknora-doc-source-tags,
.mnote-weknora-doc-tags {
.mnote-krag-doc-source-tags,
.mnote-krag-doc-tags {
display: flex;
flex-direction: column;
gap: 6px;
}
.mnote-weknora-doc-source-tags button,
.mnote-weknora-doc-tags span {
.mnote-krag-doc-source-tags button,
.mnote-krag-doc-tags span {
display: inline-flex;
align-items: center;
gap: 7px;
@@ -3396,21 +3396,21 @@
text-align: left;
}
.mnote-weknora-doc-source-tags button {
.mnote-krag-doc-source-tags button {
cursor: pointer;
}
.mnote-weknora-doc-source-tags button[data-active="true"] {
.mnote-krag-doc-source-tags button[data-active="true"] {
border-color: #A7D8B8;
background: #F0F9F4;
color: #166534;
}
.mnote-weknora-doc-source-tags .material-symbols-outlined {
.mnote-krag-doc-source-tags .material-symbols-outlined {
font-size: 16px;
}
.mnote-weknora-doc-main {
.mnote-krag-doc-main {
display: flex;
flex: 1 1 auto;
min-height: 0;
@@ -3419,7 +3419,7 @@
gap: 10px;
}
.mnote-weknora-doc-toolbar {
.mnote-krag-doc-toolbar {
position: sticky;
top: 0;
z-index: 1;
@@ -3427,7 +3427,7 @@
background: #F6F7F9;
}
.mnote-weknora-view-switch {
.mnote-krag-view-switch {
display: inline-flex;
align-items: center;
border: 1px solid #E5E7EB;
@@ -3436,7 +3436,7 @@
padding: 2px;
}
.mnote-weknora-view-switch button {
.mnote-krag-view-switch button {
width: 28px;
min-height: 26px;
border: 0;
@@ -3446,12 +3446,12 @@
padding: 0;
}
.mnote-weknora-view-switch button.is-active {
.mnote-krag-view-switch button.is-active {
background: #EAF7EA;
color: #166534;
}
.mnote-weknora-doc-search {
.mnote-krag-doc-search {
display: flex;
flex: 1 1 260px;
min-width: 220px;
@@ -3464,7 +3464,7 @@
background: #FFFFFF;
}
.mnote-weknora-doc-search input {
.mnote-krag-doc-search input {
min-width: 0;
flex: 1;
border: 0;
@@ -3474,7 +3474,7 @@
font-size: 12px;
}
.mnote-weknora-doc-table {
.mnote-krag-doc-table {
flex: 1 1 auto;
min-height: 440px;
overflow: auto;
@@ -3483,14 +3483,14 @@
background: #FFFFFF;
}
.mnote-weknora-doc-table[data-weknora-document-view="grid"] {
.mnote-krag-doc-table[data-krag-document-view="grid"] {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
padding: 10px;
}
.mnote-weknora-doc-table::before {
.mnote-krag-doc-table::before {
display: grid;
grid-template-columns: minmax(0, 1fr) 132px;
gap: 8px;
@@ -3504,11 +3504,11 @@
white-space: pre;
}
.mnote-weknora-doc-table[data-weknora-document-view="grid"]::before {
.mnote-krag-doc-table[data-krag-document-view="grid"]::before {
display: none;
}
.mnote-weknora-placeholder-panel {
.mnote-krag-placeholder-panel {
display: flex;
min-height: 220px;
flex-direction: column;
@@ -3521,13 +3521,13 @@
color: #6B7280;
}
.mnote-weknora-placeholder-panel strong {
.mnote-krag-placeholder-panel strong {
color: #111827;
font-size: 16px;
line-height: 24px;
}
.mnote-weknora-placeholder-panel span {
.mnote-krag-placeholder-panel span {
max-width: 560px;
font-size: 12px;
line-height: 19px;
@@ -3591,7 +3591,7 @@
line-height: 18px;
}
.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card {
.mnote-knowledge-rag-meta .mnote-krag-kb-summary-card {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -3600,7 +3600,7 @@
overflow: hidden;
}
.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card div {
.mnote-knowledge-rag-meta .mnote-krag-kb-summary-card div {
border: 0;
border-right: 1px solid #E5E7EB;
border-radius: 0;
@@ -3608,7 +3608,7 @@
padding: 10px 12px;
}
.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card div:last-child {
.mnote-knowledge-rag-meta .mnote-krag-kb-summary-card div:last-child {
border-right: 0;
}
@@ -3753,14 +3753,14 @@
white-space: nowrap;
}
.mnote-knowledge-rag-source-main .mnote-weknora-source-aux {
.mnote-knowledge-rag-source-main .mnote-krag-source-aux {
display: none;
flex-wrap: wrap;
gap: 6px;
font-family: inherit;
}
.mnote-weknora-source-aux i {
.mnote-krag-source-aux i {
display: inline-flex;
max-width: 180px;
overflow: hidden;
@@ -3839,18 +3839,18 @@
}
@media (max-width: 900px) {
.mnote-weknora-kb-page-body,
.mnote-weknora-kb-detail-hero,
.mnote-weknora-doc-layout {
.mnote-krag-kb-page-body,
.mnote-krag-kb-detail-hero,
.mnote-krag-doc-layout {
grid-template-columns: 1fr;
}
.mnote-weknora-kb-list-pane {
.mnote-krag-kb-list-pane {
border-right: 0;
border-bottom: 1px solid #E5E7EB;
}
.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card {
.mnote-knowledge-rag-meta .mnote-krag-kb-summary-card {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@@ -1034,9 +1034,18 @@
height: 28px;
}
.mnote-vault-urls-view .mnote-vault-field-value a {
.mnote-vault-urls-view .mnote-vault-field-value a,
.mnote-vault-field-value a.mnote-vault-url-link,
a.mnote-vault-url-link {
color: #2383e2;
text-decoration: underline;
text-underline-offset: 2px;
word-break: break-all;
cursor: pointer;
}
a.mnote-vault-url-link:hover {
color: #0b6bcb;
}
.mnote-vault-slot-group {
@@ -29,7 +29,8 @@ pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str,
rows.iter()
.map(|row| {
let test_id = match row.row_kind.as_str() {
"document" => protocol::TEST_ID_FILETREE_DOC_ROW,
// markdown 与 document 同属文档行(command_document_id 已同等对待)
"document" | "markdown" => protocol::TEST_ID_FILETREE_DOC_ROW,
"index" => protocol::TEST_ID_FILETREE_INDEX_ROW,
_ => protocol::TEST_ID_FILETREE_ASSET_ROW,
};
@@ -49,7 +50,7 @@ fn escape_html(input: &str) -> String {
fn row_test_id(row_kind: &str) -> &'static str {
match row_kind {
"document" => protocol::TEST_ID_FILETREE_DOC_ROW,
"document" | "markdown" => protocol::TEST_ID_FILETREE_DOC_ROW,
"index" => protocol::TEST_ID_FILETREE_INDEX_ROW,
_ => protocol::TEST_ID_FILETREE_ASSET_ROW,
}
@@ -223,22 +223,28 @@ impl FileTreeRuntimeState {
copy,
} => {
let mut state = self.clone();
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
let row_ids = row_ids
.into_iter()
.filter(|row_id| !row_id.is_empty())
.collect::<Vec<_>>();
if row_ids.is_empty() {
// 无有效行:清拖拽态后返回
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
}
let target = target_row_id
.as_deref()
.and_then(|row_id| resolve_open_target(env, row_id));
let Some(target) = target else {
// 目标无效:保留拖拽态,允许用户继续尝试
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
};
// 目标有效后再清拖拽态
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
transition(
state,
[
@@ -257,18 +263,22 @@ impl FileTreeRuntimeState {
file_count,
} => {
let mut state = self.clone();
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
if file_count == 0 {
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
}
let target = target_row_id
.as_deref()
.and_then(|row_id| resolve_open_target(env, row_id));
let Some(target) = target else {
// 目标无效:保留拖拽态
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
};
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
transition(
state,
[
@@ -214,8 +214,9 @@ impl PageTreeRuntimeState {
)],
),
PageTreeRuntimeAction::DispatchRename { node_id, title } => {
let node_id = node_id.trim().to_string();
let title = title.trim().to_string();
if node_id.trim().is_empty() || title.is_empty() {
if node_id.is_empty() || title.is_empty() {
return transition(self.clone(), []);
}
transition(
@@ -51,11 +51,21 @@ fn render_picker_row(
row: &PickerRenderRow,
children_by_parent: &BTreeMap<Option<String>, Vec<PickerRenderRow>>,
) {
// aria-expanded 仅出现在可展开节点;叶子节点省略,符合 WAI-ARIA。
let expanded_attr = if row.expandable {
if row.expanded {
r#" aria-expanded="true""#
} else {
r#" aria-expanded="false""#
}
} else {
""
};
html.push_str(&format!(
r#"<li class="tree-node" data-node-id="{node_id}"><button type="button" class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="{node_id}" data-shell-mode="picker" data-focused="{active}" data-rust-action="pick" tabindex="{tabindex}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">{title}</span></span></button>"#,
r#"<li class="tree-node" data-node-id="{node_id}"><button type="button" class="tree-row" role="treeitem" aria-level="{aria_level}"{expanded_attr} data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="{node_id}" data-shell-mode="picker" data-focused="{active}" data-rust-action="pick" tabindex="{tabindex}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">{title}</span></span></button>"#,
node_id = escape_html(&row.node_id),
aria_level = row.depth + 1,
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
expanded_attr = expanded_attr,
active = row.active,
tabindex = if row.active { "0" } else { "-1" },
title = escape_html(&row.title),