advance 1-8 post-mvp execution batches

This commit is contained in:
lix-2026
2026-05-21 23:53:39 +08:00
parent 3ebcbff728
commit fdb20300e9
67 changed files with 4378 additions and 275 deletions
+8 -4
View File
@@ -106,15 +106,19 @@ impl RequestContext {
}
pub fn apply_response_headers(&self, headers: &mut HeaderMap) {
self.apply_trace_response_headers(headers);
if self.auth.actor_id.trim() != "anonymous" && !self.auth.actor_id.trim().is_empty() {
append_cookie(headers, COOKIE_ACTOR_ID, self.auth.actor_id.trim());
append_cookie(headers, COOKIE_ACTOR_TYPE, self.auth.actor_type.trim());
}
}
pub fn apply_trace_response_headers(&self, headers: &mut HeaderMap) {
insert_header(headers, HEADER_REQUEST_ID, &self.trace.request_id);
insert_header(headers, HEADER_TRACE_ID, &self.trace.trace_id);
if let Some(workspace_id) = &self.workspace.workspace_id {
insert_header(headers, HEADER_WORKSPACE_ID, workspace_id);
}
if self.auth.actor_id.trim() != "anonymous" && !self.auth.actor_id.trim().is_empty() {
append_cookie(headers, COOKIE_ACTOR_ID, self.auth.actor_id.trim());
append_cookie(headers, COOKIE_ACTOR_TYPE, self.auth.actor_type.trim());
}
}
}
@@ -276,19 +276,5 @@ fn sanitize_local_artifact_file_name(value: &str) -> String {
}
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
)
.with_context(context));
}
if input.dry_run.is_none() {
return Err(WebError::bad_request_code(
"mnote_tool_dry_run_required",
"写入型 mnote Hermes tool 必须显式携带 dryRun",
)
.with_context(context));
}
Ok(())
crate::hermes_tools::ensure_write_authorized(context, input)
}
@@ -693,29 +693,7 @@ pub(crate) fn ensure_write_contract(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<(), WebError> {
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
)
.with_context(context));
}
if input.dry_run.is_none() {
return Err(WebError::bad_request_code(
"mnote_tool_dry_run_required",
"写入型 mnote Hermes tool 必须显式携带 dryRun",
)
.with_context(context));
}
if input.ai_access_scope_is_read_only() {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_write_forbidden",
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool",
)
.with_context(context));
}
Ok(())
crate::hermes_tools::ensure_write_authorized(context, input)
}
fn ensure_leaf_block(
@@ -375,6 +375,8 @@ fn page_get_tool() -> Value {
"description": "读取当前页面 Page Aggregate 摘要",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["page.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "actorId"],
@@ -5,6 +5,8 @@ pub mod manifest;
pub mod page;
pub mod resource;
use crate::context::RequestContext;
use crate::error::WebError;
use serde::Deserialize;
use serde_json::Value;
@@ -145,4 +147,163 @@ impl ToolCallInput {
})
.unwrap_or(false)
}
pub fn command_context_bridge(&self) -> Option<CommandContextBridge> {
let ctx = self.args.as_ref().and_then(|args| {
args.get("commandContext")
.or_else(|| args.get("command_context"))
})?;
let workspace_readonly = ctx
.get("workspace.readonly")
.or_else(|| ctx.get("workspaceReadonly"))
.or_else(|| ctx.get("workspace_readonly"))
.and_then(Value::as_bool)
.unwrap_or(false);
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);
Some(CommandContextBridge {
workspace_readonly,
ai_can_write,
})
}
}
/// CommandContext 桥接信息,用于将 `core-protocol` 的 command context 引入 hermes_tools 写入守卫。
///
/// 当此桥接可用时,`ensure_write_authorized` 除检查 `ToolCallInput` 自带的
/// `aiAccessScope.permissionLevel` 外,额外检查 `ai_can_write` 和 `workspace_readonly`。
///
/// 设计意图:`CommandContext.ai.canWrite` / `workspace.readonly` 是从 tree/workspace/editor
/// 状态推导的写权限口径,与 `args.aiAccessScope.permissionLevel`AI 客户端声明的权限)是
/// 两个独立的信息源。桥接合并两者,任一拒绝则阻止写入。
#[derive(Debug, Clone, Copy)]
pub struct CommandContextBridge {
pub workspace_readonly: bool,
pub ai_can_write: bool,
}
/// 统一的 hermes_tools 写入守卫。检查:
///
/// - `idempotencyKey` 必须存在
/// - `dryRun` 必须显式携带
/// - `aiAccessScope.permissionLevel` 不是只读(来自 ToolCallInput
/// - 如果提供了 `bridge` 且 `ai_can_write == false`,拒绝
/// - 如果提供了 `bridge` 且 `workspace_readonly == true`,拒绝
///
/// 拒绝响应可解释(包含具体原因),不静默成功,不 panic。
pub fn ensure_write_authorized(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
)
.with_context(context));
}
if input.dry_run.is_none() {
return Err(WebError::bad_request_code(
"mnote_tool_dry_run_required",
"写入型 mnote Hermes tool 必须显式携带 dryRun",
)
.with_context(context));
}
if input.ai_access_scope_is_read_only() {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_write_forbidden",
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool",
)
.with_context(context));
}
if let Some(bridge) = input.command_context_bridge() {
if bridge.workspace_readonly {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_tool_workspace_readonly",
"当前 workspace 是只读权限,禁止执行写入型 mnote tool",
)
.with_context(context));
}
if !bridge.ai_can_write {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_tool_ai_write_forbidden",
"CommandContext 标记 AI 写入未授权,禁止执行写入型 mnote tool",
)
.with_context(context));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{HeaderMap, Method};
use serde_json::json;
fn context() -> RequestContext {
RequestContext::from_http_parts(
&Method::POST,
&"/api/hermes/tools".parse().expect("uri"),
&HeaderMap::new(),
)
}
fn write_input(args: Value) -> ToolCallInput {
ToolCallInput {
tool_name: "mnote.page.save".into(),
workspace_id: Some("ws_1".into()),
document_id: Some("doc_1".into()),
source_kind: Some("local_folder".into()),
root_uri: Some("file:///tmp/mnote".into()),
actor_id: Some("user_1".into()),
profile: None,
session_id: Some("sess_1".into()),
run_id: Some("run_1".into()),
tool_call_id: Some("tool_1".into()),
trace_id: Some("trace_1".into()),
idempotency_key: Some("idem_1".into()),
dry_run: Some(false),
capability_scope: None,
args: Some(args),
}
}
#[test]
fn ensure_write_authorized_rejects_command_context_ai_cannot_write() {
let error = ensure_write_authorized(
&context(),
&write_input(json!({
"commandContext": {
"ai.canWrite": false,
"workspace.readonly": false
}
})),
)
.expect_err("ai.canWrite=false should reject write tools");
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(
&context(),
&write_input(json!({
"commandContext": {
"ai.canWrite": true,
"workspace.readonly": true
}
})),
)
.expect_err("workspace.readonly=true should reject write tools");
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
assert_eq!(error.code(), "mnote_tool_workspace_readonly");
}
}
+1 -23
View File
@@ -72,29 +72,7 @@ pub async fn page_get(
}
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
)
.with_context(context));
}
if input.dry_run.is_none() {
return Err(WebError::bad_request_code(
"mnote_tool_dry_run_required",
"写入型 mnote Hermes tool 必须显式携带 dryRun",
)
.with_context(context));
}
if input.ai_access_scope_is_read_only() {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_write_forbidden",
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool",
)
.with_context(context));
}
Ok(())
crate::hermes_tools::ensure_write_authorized(context, input)
}
pub async fn page_save(
@@ -285,29 +285,7 @@ fn ensure_resource_write_contract(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<(), WebError> {
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入型 mnote resource tool 必须携带 idempotencyKey",
)
.with_context(context));
}
if input.dry_run.is_none() {
return Err(WebError::bad_request_code(
"mnote_tool_dry_run_required",
"写入型 mnote resource tool 必须显式携带 dryRun",
)
.with_context(context));
}
if input.ai_access_scope_is_read_only() {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_tool_ai_scope_write_forbidden",
"当前 AI scope 是只读权限,禁止执行写入型 mnote resource tool",
)
.with_context(context));
}
Ok(())
crate::hermes_tools::ensure_write_authorized(context, input)
}
fn local_root_uri_for_resource(input: &ToolCallInput) -> Option<String> {
@@ -9,6 +9,10 @@ pub async fn inject_request_context(mut request: Request, next: Next) -> Respons
request.extensions_mut().insert(context.clone());
let mut response = next.run(request).await;
context.apply_response_headers(response.headers_mut());
if context.trace.path == "/api/auth" {
context.apply_trace_response_headers(response.headers_mut());
} else {
context.apply_response_headers(response.headers_mut());
}
response
}
+117 -7
View File
@@ -41,6 +41,8 @@ const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
const COOKIE_MNOTE_WEB_DEV_SESSION: &str = "mnote_web_dev_session";
const COOKIE_MNOTE_ACTOR_ID: &str = "mnote_actor_id";
const COOKIE_MNOTE_ACTOR_TYPE: &str = "mnote_actor_type";
const COOKIE_MNOTE_ACTOR_EMAIL: &str = "mnote_actor_email";
const COOKIE_MNOTE_ACTOR_NAME: &str = "mnote_actor_name";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -121,9 +123,12 @@ pub async fn auth_api(
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
}
if action == "auth:signOut" {
return Ok(build_sign_out_response(&context));
}
let convex_response = run_convex_auth_action(&state, &context, &payload).await?;
Ok(build_auth_proxy_response(&convex_response, &context))
Ok(build_auth_proxy_response(&convex_response, &context, &payload))
}
pub async fn auth_entry(
@@ -1625,6 +1630,7 @@ async fn run_convex_auth_action(
fn build_auth_proxy_response(
convex_response: &serde_json::Value,
context: &RequestContext,
request_payload: &serde_json::Value,
) -> Response {
if convex_response
.get("status")
@@ -1639,7 +1645,7 @@ fn build_auth_proxy_response(
*response.status_mut() = StatusCode::BAD_REQUEST;
clear_auth_cookies(response.headers_mut());
stamp_gateway_headers(response.headers_mut(), false);
context.apply_response_headers(response.headers_mut());
apply_trace_response_headers(context, response.headers_mut());
return response;
}
@@ -1679,14 +1685,46 @@ fn build_auth_proxy_response(
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_ID, &actor_id);
set_literal_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_TYPE, "user");
}
if let Some(email) = resolve_mnote_actor_email(&value, tokens, request_payload) {
set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_EMAIL, &email);
}
if let Some(name) = resolve_mnote_actor_name(&value, tokens, request_payload) {
set_encoded_cookie(response.headers_mut(), COOKIE_MNOTE_ACTOR_NAME, &name);
}
expire_cookie(response.headers_mut(), COOKIE_MNOTE_WEB_DEV_SESSION);
}
}
stamp_gateway_headers(response.headers_mut(), false);
context.apply_response_headers(response.headers_mut());
apply_trace_response_headers(context, response.headers_mut());
response
}
fn build_sign_out_response(context: &RequestContext) -> Response {
let mut response = axum::Json(json!({ "ok": true, "signedOut": true })).into_response();
clear_auth_cookies(response.headers_mut());
stamp_gateway_headers(response.headers_mut(), false);
apply_trace_response_headers(context, response.headers_mut());
response
}
fn apply_trace_response_headers(context: &RequestContext, headers: &mut axum::http::HeaderMap) {
insert_response_header(headers, "x-request-id", &context.trace.request_id);
insert_response_header(headers, "x-trace-id", &context.trace.trace_id);
if let Some(workspace_id) = &context.workspace.workspace_id {
insert_response_header(headers, "x-mnote-workspace-id", workspace_id);
}
}
fn insert_response_header(headers: &mut axum::http::HeaderMap, name: &str, value: &str) {
let Ok(name) = HeaderName::from_lowercase(name.as_bytes()) else {
return;
};
let Ok(value) = HeaderValue::from_str(value) else {
return;
};
headers.insert(name, value);
}
fn resolve_mnote_actor_id(
value: &serde_json::Value,
tokens: &serde_json::Value,
@@ -1735,6 +1773,69 @@ fn extract_actor_id_from_jwt(token: &str) -> Option<String> {
})
}
fn resolve_mnote_actor_email(
value: &serde_json::Value,
tokens: &serde_json::Value,
request_payload: &serde_json::Value,
) -> Option<String> {
[
"/email",
"/user/email",
"/profile/email",
"/args/params/email",
]
.iter()
.find_map(|pointer| non_empty_json_string(value.pointer(pointer)))
.or_else(|| {
tokens
.get("token")
.and_then(Value::as_str)
.and_then(extract_email_from_jwt)
})
.or_else(|| non_empty_json_string(request_payload.pointer("/args/params/email")))
}
fn resolve_mnote_actor_name(
value: &serde_json::Value,
tokens: &serde_json::Value,
request_payload: &serde_json::Value,
) -> Option<String> {
["/name", "/user/name", "/profile/name", "/args/params/name"]
.iter()
.find_map(|pointer| non_empty_json_string(value.pointer(pointer)))
.or_else(|| {
tokens
.get("token")
.and_then(Value::as_str)
.and_then(extract_name_from_jwt)
})
.or_else(|| non_empty_json_string(request_payload.pointer("/args/params/name")))
}
fn extract_email_from_jwt(token: &str) -> Option<String> {
jwt_string_claim(token, &["email", "preferred_username"])
}
fn extract_name_from_jwt(token: &str) -> Option<String> {
jwt_string_claim(token, &["name", "username"])
}
fn jwt_string_claim(token: &str, keys: &[&str]) -> Option<String> {
let payload_segment = token.split('.').nth(1)?;
let decoded = URL_SAFE_NO_PAD.decode(payload_segment.as_bytes()).ok()?;
let payload: Value = serde_json::from_slice(&decoded).ok()?;
keys.iter()
.find_map(|key| non_empty_json_string(payload.get(key)))
}
fn non_empty_json_string(value: Option<&serde_json::Value>) -> Option<String> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn set_literal_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, value: &str) {
let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
if let Ok(value) = HeaderValue::from_str(&cookie) {
@@ -1742,6 +1843,11 @@ fn set_literal_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, v
}
}
fn set_encoded_cookie(headers: &mut axum::http::HeaderMap, name: &'static str, value: &str) {
let encoded = URL_SAFE_NO_PAD.encode(value.as_bytes());
set_literal_cookie(headers, name, &encoded);
}
fn set_auth_cookie_from_value(
headers: &mut axum::http::HeaderMap,
name: &'static str,
@@ -1762,6 +1868,8 @@ fn clear_auth_cookies(headers: &mut axum::http::HeaderMap) {
expire_cookie(headers, COOKIE_MNOTE_WEB_DEV_SESSION);
expire_cookie(headers, COOKIE_MNOTE_ACTOR_ID);
expire_cookie(headers, COOKIE_MNOTE_ACTOR_TYPE);
expire_cookie(headers, COOKIE_MNOTE_ACTOR_EMAIL);
expire_cookie(headers, COOKIE_MNOTE_ACTOR_NAME);
}
fn expire_cookie(headers: &mut axum::http::HeaderMap, name: &'static str) {
@@ -2623,9 +2731,9 @@ mod tests {
assert!(html.contains("local_folder"));
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
assert!(html.contains(r#"data-mnote-root-uri="file://"#));
assert!(html.contains(r#"data-testid="mnote-workspace-empty-state""#));
assert!(html.contains("当前还没有可显示的本地工作区"));
assert!(html.contains(r#"data-testid="mnote-empty-create-page""#));
assert!(!html.contains(r#"data-testid="mnote-workspace-empty-state""#));
assert!(!html.contains("当前还没有可显示的本地工作区"));
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
assert!(html.contains(r#""transport":"disabled""#));
let _ = std::fs::remove_dir_all(&base);
@@ -2797,7 +2905,9 @@ mod tests {
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains(r#"data-mnote-shell="auth""#));
assert!(html.contains("邮箱登录"));
assert!(html.contains("账号登录"));
assert!(html.contains("邮箱或用户名"));
assert!(!html.contains(r#"<span>"用户名"</span>"#));
assert!(html.contains("测试账号快速登录"));
}
@@ -29,6 +29,7 @@ const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner";
const ACP_RUNTIME_RUN_MUTATION: &str = "aiSessions:upsertRuntimeRun";
const ACP_RUNTIME_EVENT_MUTATION: &str = "aiSessions:appendRuntimeEvent";
const ACP_ABORT_NOTIFICATION_TIMEOUT_MS: u64 = 2_500;
const LOCAL_SHARE_GRANTS_JSON: &str = "/mnt/Data1T/Mnote_data/control-plane/share-grants.json";
const ENV_LOCAL_SHARE_GRANTS_FILE: &str = "MNOTE_SHARE_GRANTS_FILE";
@@ -90,6 +91,7 @@ struct AcpActiveRun {
manager: Arc<crate::acp_session_manager::AcpSessionManager>,
mnote_session_id: String,
acp_session_id: String,
event_tx: broadcast::Sender<crate::acp_bridge::SseEvent>,
}
#[derive(Debug, Clone)]
@@ -1484,6 +1486,7 @@ async fn acp_stream_events(
manager: Arc::clone(&mgr),
mnote_session_id: mnote_session_id.clone(),
acp_session_id,
event_tx: event_tx.clone(),
},
);
@@ -1567,6 +1570,12 @@ async fn acp_stream_events(
{
Ok(result) => {
info!("ACP prompt completed: stop_reason={:?}", result.stop_reason);
let prompt_cancelled =
matches!(result.stop_reason, crate::acp_types::StopReason::Cancelled);
let runtime_aborted = matches!(
runtime_status_for_run(&run_id_owned).as_deref(),
Some("aborting" | "aborted")
);
let agent_audit = if audit_payload
.get("sourceKind")
.and_then(Value::as_str)
@@ -1590,7 +1599,11 @@ async fn acp_stream_events(
Value::Null
};
let _ = event_tx_prompt.send(crate::acp_bridge::SseEvent {
event: "run.completed".into(),
event: if prompt_cancelled || runtime_aborted {
"run.aborted".into()
} else {
"run.completed".into()
},
data: json!({
"stopReason": format!("{:?}", result.stop_reason),
"agentAudit": agent_audit,
@@ -1627,15 +1640,16 @@ async fn acp_stream_events(
});
}
}
let was_aborted = matches!(
runtime_status_for_run(&run_id_owned).as_deref(),
Some("aborting" | "aborted")
);
mgr_clone.close().await;
ACP_ACTIVE_RUNS
.lock()
.expect("acp active runs")
.remove(&run_id_owned);
if !matches!(
runtime_status_for_run(&run_id_owned).as_deref(),
Some("aborting" | "aborted")
) {
if !was_aborted {
update_runtime_by_run_id(&run_id_owned, "completed", Some("acp.prompt.done"), None);
}
});
@@ -1763,11 +1777,37 @@ pub async fn abort_run(
})),
));
};
active.manager.cancel().await.map_err(|error| {
WebError::bad_gateway_code("acp_abort_failed", format!("ACP abort failed: {error}"))
.with_context(&context)
})?;
let cancel_result = tokio::time::timeout(
Duration::from_millis(ACP_ABORT_NOTIFICATION_TIMEOUT_MS),
active.manager.cancel(),
)
.await;
let cancel_status = match cancel_result {
Ok(Ok(())) => json!({"ok": true}),
Ok(Err(error)) => {
warn!(error = ?error, run_id = %run_id, "ACP abort notification failed; marking run aborted");
json!({"ok": false, "error": error.to_string()})
}
Err(_) => {
warn!(
run_id = %run_id,
timeout_ms = ACP_ABORT_NOTIFICATION_TIMEOUT_MS,
"ACP abort notification timed out; marking run aborted"
);
json!({"ok": false, "error": "abort notification timed out"})
}
};
update_runtime_by_run_id(&run_id, "aborted", Some("abort.completed"), None);
let _ = active.event_tx.send(crate::acp_bridge::SseEvent {
event: "run.aborted".into(),
data: json!({
"reason": payload
.get("reason")
.and_then(Value::as_str)
.unwrap_or("client_abort"),
"cancel": cancel_status.clone(),
}),
});
return Ok((
StatusCode::OK,
stamp_client_headers(),
@@ -1778,6 +1818,7 @@ pub async fn abort_run(
"acpSessionId": active.acp_session_id,
"status": "aborted",
"runtime": runtime_state_for_run(&run_id).unwrap_or(Value::Null),
"cancel": cancel_status,
"events": [
{"event": "abort.started", "runId": run_id},
{"event": "abort.completed", "runId": run_id}
@@ -306,6 +306,23 @@ pub(crate) async fn execute_mnote_tool_call(
);
return Err(error);
}
if let Err(error) = ensure_tool_capability_scope(&context, &input) {
audit_push(json!({
"phase": "failed",
"traceId": trace_id,
"sessionId": input.session_id,
"runId": input.run_id,
"toolCallId": tool_call_id,
"toolName": input.tool_name,
"workspaceId": workspace_id,
"documentId": document_id,
"actorId": input.actor_id,
"status": error.status().as_u16(),
"message": error.message(),
"capabilityScope": input.capability_scope
}));
return Err(error);
}
if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) {
info!(
trace_id = %trace_id,
@@ -449,6 +466,74 @@ pub(crate) async fn execute_mnote_tool_call(
Ok(response_body)
}
fn ensure_tool_capability_scope(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<(), WebError> {
let required = required_capability_scope(&input.tool_name);
if required.is_empty()
|| declared_capability_scope_covers(input.capability_scope.as_ref(), &required)
{
return Ok(());
}
Err(WebError::new(
StatusCode::FORBIDDEN,
"mnote_tool_capability_scope_forbidden",
"调用方声明的 capabilityScope 未覆盖目标 mnote tool 所需能力",
)
.with_context(context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools"))
}
fn required_capability_scope(tool_name: &str) -> Vec<String> {
manifest::manifest()
.get("tools")
.and_then(Value::as_array)
.into_iter()
.flatten()
.find(|tool| tool.get("name").and_then(Value::as_str) == Some(tool_name))
.and_then(|tool| tool.get("capabilityScope").and_then(Value::as_array))
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(normalize_capability_scope)
.filter(|value| !value.is_empty())
.collect()
}
fn declared_capability_scope_covers(declared: Option<&Vec<String>>, required: &[String]) -> bool {
let Some(declared) = declared else {
// 兼容旧调用方:缺省 capabilityScope 不改变既有执行路径。
return true;
};
let declared = declared
.iter()
.map(|value| normalize_capability_scope(value))
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
required.iter().all(|scope| {
declared
.iter()
.any(|candidate| capability_scope_satisfies(candidate, scope))
})
}
fn capability_scope_satisfies(candidate: &str, required: &str) -> bool {
if candidate == required {
return true;
}
required
.strip_suffix(".read")
.map(|prefix| format!("{prefix}.write"))
.as_deref()
== Some(candidate)
}
fn normalize_capability_scope(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn is_read_tool(tool_name: &str) -> bool {
matches!(
tool_name,
@@ -964,6 +1049,46 @@ mod tests {
.collect()
}
#[tokio::test]
async fn hermes_tools_call_rejects_declared_scope_that_does_not_cover_tool() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(
json!({
"toolName": "mnote.page.save",
"workspaceId": "ws_demo",
"documentId": "doc_1",
"sessionId": "sess_scope",
"runId": "run_scope",
"toolCallId": "call_scope",
"traceId": "trace_scope",
"actorId": "user_1",
"capabilityScope": ["page.read"],
"dryRun": true,
"idempotencyKey": "scope-mismatch",
"args": {"content": "不会写入"}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("mnote_tool_capability_scope_forbidden")
);
}
#[tokio::test]
async fn hermes_tools_manifest_returns_first_batch_tools() {
let response = app()
@@ -3,37 +3,65 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::{
decode_local_id_segment, ensure_local_workspace_read_access,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, local_workspace_id_from_root_uri,
};
use crate::routes::snapshot_support::ProjectionSnapshot;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue};
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
use axum::response::sse::{Event as SseEvent, Sse};
use futures_util::stream;
use futures_util::StreamExt;
use serde::Deserialize;
use serde_json::{json, Value};
use std::convert::Infallible;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::path::PathBuf;
use std::pin::Pin;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::broadcast::error::RecvError;
type BoxedEventStream = Pin<Box<dyn futures_util::Stream<Item = Result<SseEvent, Infallible>> + Send>>;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalFolderEventsQuery {
pub root_uri: String,
pub document_id: Option<String>,
pub tree_live: Option<bool>,
}
pub async fn local_folder_events(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalFolderEventsQuery>,
) -> Result<
(
HeaderMap,
Sse<impl futures_util::Stream<Item = Result<SseEvent, Infallible>>>,
),
WebError,
> {
) -> Result<(HeaderMap, Sse<BoxedEventStream>), WebError> {
let canonical_root = ensure_local_workspace_read_access(&context, &query.root_uri)
.map_err(|error| error.with_context(&context))?;
let (mut headers, stream): (HeaderMap, BoxedEventStream) = if query.tree_live.unwrap_or(false) {
build_tree_live_stream(state, context, canonical_root, query.root_uri).await?
} else {
build_document_events_stream(state, context, canonical_root, &query).await?
};
// Apply keepalive via the same type-erased stream path
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-local-folder-events-owner") {
headers.insert(name, HeaderValue::from_static("rust-web"));
}
Ok((headers, Sse::new(stream)))
}
/// Build the original document-level external-edit event stream (`ready` / `change`).
async fn build_document_events_stream(
state: AppState,
context: RequestContext,
canonical_root: PathBuf,
query: &LocalFolderEventsQuery,
) -> Result<(HeaderMap, BoxedEventStream), WebError> {
let document_relative_path = query
.document_id
.as_deref()
@@ -80,26 +108,129 @@ pub async fn local_folder_events(
}
}
},
)
.boxed();
Ok((HeaderMap::new(), stream))
}
/// Build the tree live stream: emits `snapshot` (initial) and `resync` (on watcher change)
/// with full sidebar + file tree projections.
///
/// Reuses `LocalFolderWatcherRegistry` — no second watcher created.
/// No data is written to Convex command log.
async fn build_tree_live_stream(
state: AppState,
context: RequestContext,
canonical_root: PathBuf,
root_uri: String,
) -> Result<(HeaderMap, BoxedEventStream), WebError> {
let subscription = state
.local_folder_watcher_registry()
.subscribe(&canonical_root)
.map_err(|error| WebError::internal(error).with_context(&context))?;
let workspace_id = local_workspace_id_from_root_uri(&root_uri)
.map_err(|error| error.with_context(&context))?;
// Build initial snapshot
let sidebar_snapshot = load_local_folder_page_tree_snapshot(&root_uri)
.map_err(|error| error.with_context(&context))?;
let file_tree_snapshot = load_local_folder_file_tree_snapshot(&root_uri)
.map_err(|error| error.with_context(&context))?;
let revision = local_folder_watch_revision(&root_uri)
.map_err(|error| error.with_context(&context))?;
let initial_payload = build_tree_snapshot_payload(
&root_uri,
&workspace_id,
&revision.revision,
"snapshot",
&sidebar_snapshot,
&file_tree_snapshot,
);
let mut headers = HeaderMap::new();
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-local-folder-events-owner") {
headers.insert(name, HeaderValue::from_static("rust-web"));
}
let stream = stream::unfold(
(Some(initial_payload), subscription, root_uri, workspace_id),
|(payload, mut subscription, root_uri, workspace_id)| async move {
if let Some(payload) = payload {
return Some((
Ok(stream_event("snapshot", &payload)),
(None, subscription, root_uri, workspace_id),
));
}
Ok((
headers,
Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("keepalive"),
),
loop {
match subscription.receiver.recv().await {
Ok(_watcher_payload) => {
// Rebuild full snapshot on any filesystem change
if let Some(resync_payload) =
rebuild_tree_resync_payload(&root_uri, &workspace_id)
{
return Some((
Ok(stream_event("resync", &resync_payload)),
(None, subscription, root_uri, workspace_id),
));
}
// Snapshot load failed — continue waiting for next change
continue;
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => return None,
}
}
},
)
.boxed();
Ok((HeaderMap::new(), stream))
}
fn rebuild_tree_resync_payload(root_uri: &str, workspace_id: &str) -> Option<Value> {
let revision = local_folder_watch_revision(root_uri).ok()?;
let sidebar_snapshot = load_local_folder_page_tree_snapshot(root_uri).ok()?;
let file_tree_snapshot = load_local_folder_file_tree_snapshot(root_uri).ok()?;
Some(build_tree_snapshot_payload(
root_uri,
workspace_id,
&revision.revision,
"resync",
&sidebar_snapshot,
&file_tree_snapshot,
))
}
fn build_tree_snapshot_payload(
root_uri: &str,
workspace_id: &str,
revision: &str,
kind: &str,
sidebar_snapshot: &ProjectionSnapshot,
file_tree_snapshot: &ProjectionSnapshot,
) -> Value {
let dataset = json!({
"kernel_sidebar_projection": sidebar_snapshot.projection,
"kernelSidebarProjection": sidebar_snapshot.projection,
"kernel_file_tree_projection": file_tree_snapshot.projection,
"kernelFileTreeProjection": file_tree_snapshot.projection,
});
json!({
"kind": kind,
"revision": revision,
"stream": "workspace",
"projection": "sidebar_tree",
"scope": "workspace",
"sourceKind": "local_folder",
"rootUri": root_uri,
"workspaceId": workspace_id,
"data": {
"dataset": dataset,
"tree": sidebar_snapshot.projection,
},
})
}
fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<String> {
let trimmed = document_id.trim();
let encoded = trimmed.strip_prefix("local-md:")?;
@@ -132,6 +263,101 @@ fn stream_event(event_name: &str, payload: &Value) -> SseEvent {
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{build_app, AppConfig, AppState};
use crate::routes::local_folder_source::initialize_local_workspace_for_actor;
use axum::body::Body;
use axum::http::Request;
use tower::util::ServiceExt;
fn test_root(name: &str) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!(
"mnote-local-folder-events-{name}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0)
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create temp root");
root
}
fn app_with_local_workspace(root: &std::path::Path) -> axum::Router {
let root_uri = format!("file://{}", root.display());
initialize_local_workspace_for_actor("dev-user", &root_uri)
.expect("init local workspace");
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: false,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
/// Build a root_uri query-parameter-safe by percent-encoding (no external crate).
fn encoded_root_uri(raw: &str) -> String {
raw.replace('%', "%25")
.replace(':', "%3A")
.replace('/', "%2F")
}
#[tokio::test]
async fn tree_live_headers_include_mnote_web_owner() {
let root = test_root("tree-live-headers");
std::fs::write(root.join("test.md"), "# Test\n").expect("write test");
let root_uri = format!("file://{}", root.display());
let encoded = encoded_root_uri(&root_uri);
let app = app_with_local_workspace(&root);
let response = app
.oneshot(
Request::builder()
.uri(format!(
"/api/local-folder/events?rootUri={encoded}&treeLive=true"
))
.header("x-mnote-actor-id", "dev-user")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), 200);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|v| v.to_str().ok()),
Some("mnote-web"),
"response should have x-mnote-web-owner header"
);
assert_eq!(
response
.headers()
.get("x-mnote-local-folder-events-owner")
.and_then(|v| v.to_str().ok()),
Some("rust-web"),
"response should have x-mnote-local-folder-events-owner header"
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn local_markdown_document_id_maps_to_relative_path() {
@@ -140,4 +366,52 @@ mod tests {
Some("docs/README.md")
);
}
#[test]
fn build_tree_snapshot_payload_has_required_fields() {
let sidebar_projection = json!({
"projection": "page_tree",
"sourceKind": "local_folder",
"rootUri": "file:///test",
"watchRevision": "abc123",
"items": [],
});
let file_tree_projection = json!({
"projection": "file_tree",
"sourceKind": "local_folder",
"rootUri": "file:///test",
"watchRevision": "abc123",
"items": [],
});
let sidebar = ProjectionSnapshot {
dataset: json!({}),
projection: sidebar_projection,
};
let file_tree = ProjectionSnapshot {
dataset: json!({}),
projection: file_tree_projection,
};
let payload = build_tree_snapshot_payload(
"file:///test",
"local:test_workspace",
"rev_1",
"snapshot",
&sidebar,
&file_tree,
);
assert_eq!(payload["kind"], "snapshot");
assert_eq!(payload["stream"], "workspace");
assert_eq!(payload["projection"], "sidebar_tree");
assert_eq!(payload["sourceKind"], "local_folder");
assert_eq!(payload["rootUri"], "file:///test");
assert_eq!(payload["workspaceId"], "local:test_workspace");
assert_eq!(payload["revision"], "rev_1");
assert!(payload["data"]["dataset"]["kernel_sidebar_projection"].is_object());
assert!(payload["data"]["dataset"]["kernel_file_tree_projection"].is_object());
assert!(payload["data"]["dataset"]["kernelSidebarProjection"].is_object());
assert!(payload["data"]["dataset"]["kernelFileTreeProjection"].is_object());
assert!(payload["data"]["tree"].is_object());
}
}
+62 -2
View File
@@ -4,9 +4,13 @@ use axum::extract::{Extension, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde::Serialize;
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
const COOKIE_ACTOR_EMAIL: &str = "mnote_actor_email";
const COOKIE_ACTOR_NAME: &str = "mnote_actor_name";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -63,6 +67,18 @@ pub async fn refresh_session(
fn build_session_response(state: &AppState, context: RequestContext) -> SessionResponse {
let actor_id = context.auth.actor_id.trim();
let has_forwarded_actor = !actor_id.is_empty() && actor_id != "anonymous";
let actor_email = context
.auth
.cookie_header
.as_deref()
.and_then(|cookies| encoded_cookie_value(cookies, COOKIE_ACTOR_EMAIL))
.or_else(|| jwt_cookie_claim(&context, &["email", "preferred_username"]));
let actor_name = context
.auth
.cookie_header
.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 {
@@ -79,12 +95,12 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
owner: "mnote-web",
user_id,
email: if has_forwarded_actor {
String::new()
actor_email.unwrap_or_default()
} else {
state.config().dev_user_email.clone()
},
name: if has_forwarded_actor {
actor_id.to_string()
actor_name.unwrap_or_else(|| actor_id.to_string())
} else {
state.config().dev_user_name.clone()
},
@@ -99,6 +115,50 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
}
}
fn jwt_cookie_claim(context: &RequestContext, keys: &[&str]) -> Option<String> {
let token = context
.auth
.cookie_header
.as_deref()
.and_then(|cookies| raw_cookie_value(cookies, COOKIE_CONVEX_AUTH_JWT))?;
let payload_segment = token.split('.').nth(1)?;
let decoded = URL_SAFE_NO_PAD.decode(payload_segment.as_bytes()).ok()?;
let payload: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
keys.iter().find_map(|key| {
payload
.get(key)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn encoded_cookie_value(cookie_header: &str, name: &str) -> Option<String> {
let encoded = raw_cookie_value(cookie_header, name)?;
let decoded = URL_SAFE_NO_PAD.decode(encoded.as_bytes()).ok()?;
String::from_utf8(decoded)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn raw_cookie_value(cookie_header: &str, name: &str) -> Option<String> {
cookie_header.split(';').find_map(|part| {
let (cookie_name, cookie_value) = part.trim().split_once('=')?;
if cookie_name.trim() == name {
let value = cookie_value.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
} else {
None
}
})
}
fn owner_json<T>(payload: Json<T>) -> Response
where
T: Serialize,
+65 -34
View File
@@ -17,7 +17,7 @@ pub fn AuthPage() -> impl IntoView {
</a>
<section class="mnote-auth-panel" aria-labelledby="mnote-auth-title">
<div class="mnote-auth-heading">
<h1 id="mnote-auth-title">"邮箱登录"</h1>
<h1 id="mnote-auth-title">"账号登录"</h1>
<p>"登录后进入你的工作区"</p>
</div>
<form class="mnote-auth-form" method="post" action="/api/auth" data-auth-mode="convex-password">
@@ -25,24 +25,14 @@ pub fn AuthPage() -> impl IntoView {
<input type="hidden" name="provider" value="password" />
<input type="hidden" name="flow" value="signIn" data-auth-flow />
<label class="mnote-auth-field">
<span>"邮箱"</span>
<span data-auth-account-label>"邮箱或用户名"</span>
<input
id="email"
name="email"
type="email"
autocomplete="email"
placeholder="请输入邮箱"
required
/>
</label>
<label class="mnote-auth-field mnote-auth-username" hidden>
<span>"用户名"</span>
<input
id="username"
name="username"
id="account"
name="account"
type="text"
autocomplete="username"
placeholder="用于显示的用户名"
placeholder="请输入邮箱或用户名"
required
/>
</label>
<label class="mnote-auth-field">
@@ -85,8 +75,8 @@ const AUTH_SCRIPT: &str = r#"
if (!root) return;
var form = root.querySelector('form[data-auth-mode="convex-password"]');
var flowInput = root.querySelector('[data-auth-flow]');
var usernameField = root.querySelector('.mnote-auth-username');
var usernameInput = root.querySelector('#username');
var accountInput = root.querySelector('#account');
var accountLabel = root.querySelector('[data-auth-account-label]');
var title = root.querySelector('#mnote-auth-title');
var subtitle = root.querySelector('.mnote-auth-heading p');
var submit = root.querySelector('[data-auth-submit]');
@@ -110,19 +100,54 @@ const AUTH_SCRIPT: &str = r#"
var isSignUp = nextFlow === 'signUp';
flowInput.value = nextFlow;
switcher.dataset.flow = nextFlow;
if (title) title.textContent = isSignUp ? '' : '';
if (title) title.textContent = isSignUp ? '' : '';
if (subtitle) subtitle.textContent = isSignUp ? '' : '';
submit.textContent = isSignUp ? '' : '';
switcher.textContent = isSignUp ? '' : '';
if (usernameField) usernameField.hidden = !isSignUp;
if (usernameInput) usernameInput.required = isSignUp;
if (accountLabel) accountLabel.textContent = isSignUp ? '' : '';
if (accountInput) {
accountInput.placeholder = isSignUp ? '' : '';
accountInput.autocomplete = isSignUp ? 'email' : 'username';
}
quickLogin.hidden = isSignUp;
var passwordInput = root.querySelector('#password');
if (passwordInput) passwordInput.autocomplete = isSignUp ? 'new-password' : 'current-password';
setMessage('', '');
}
function buildPayload(flow, email, password, username) {
function accountMap() {
try {
return JSON.parse(window.localStorage.getItem('mnote.auth.accountEmailByName') || '{}') || {};
} catch (_) {
return {};
}
}
function rememberAccount(email, name) {
var normalizedEmail = String(email || '').trim();
var normalizedName = String(name || '').trim();
if (!normalizedEmail || !normalizedName || normalizedName.indexOf('@') !== -1) return;
try {
var map = accountMap();
map[normalizedName.toLowerCase()] = normalizedEmail;
window.localStorage.setItem('mnote.auth.accountEmailByName', JSON.stringify(map));
} catch (_) {}
}
function resolveAccountEmail(account) {
var normalized = String(account || '').trim();
if (normalized.indexOf('@') !== -1) return normalized;
var mapped = accountMap()[normalized.toLowerCase()];
return String(mapped || normalized).trim();
}
function defaultNameFromEmail(email) {
return String(email || '').split('@')[0].trim();
}
function buildPayload(flow, account, password) {
var email = flow === 'signIn' ? resolveAccountEmail(account) : String(account || '').trim();
var name = defaultNameFromEmail(email);
var payload = {
action: 'auth:signIn',
args: {
@@ -134,23 +159,25 @@ const AUTH_SCRIPT: &str = r#"
}
}
};
if (flow === 'signUp' && username) {
payload.args.params.name = username;
if (name) {
payload.args.params.name = name;
}
return payload;
}
async function requestAuth(flow, email, password, username) {
async function requestAuth(flow, account, password) {
var response = await fetch('/api/auth', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify(buildPayload(flow, email, password, username))
body: JSON.stringify(buildPayload(flow, account, password))
});
var data = await response.json().catch(function () { return {}; });
if (!response.ok || data.error) {
throw new Error(data.error || '');
}
var params = buildPayload(flow, account, password).args.params;
rememberAccount(params.email, params.name);
return data;
}
@@ -160,18 +187,21 @@ const AUTH_SCRIPT: &str = r#"
form.addEventListener('submit', async function (event) {
event.preventDefault();
var email = String((root.querySelector('#email') || {}).value || '').trim();
var account = String((accountInput || {}).value || '').trim();
var password = String((root.querySelector('#password') || {}).value || '');
var username = String((usernameInput || {}).value || '').trim();
var flow = flowInput.value === 'signUp' ? 'signUp' : 'signIn';
if (!email || !password || (flow === 'signUp' && !username)) {
if (!account || !password) {
setMessage('', 'error');
return;
}
if (flow === 'signUp' && account.indexOf('@') === -1) {
setMessage('', 'error');
return;
}
setBusy(true);
setMessage(flow === 'signUp' ? '...' : '...', 'info');
try {
await requestAuth(flow, email, password, username);
await requestAuth(flow, account, password);
setMessage('...', 'success');
window.location.assign('/');
} catch (error) {
@@ -189,15 +219,15 @@ const AUTH_SCRIPT: &str = r#"
return;
}
var emailInput = root.querySelector('#email');
var passwordInput = root.querySelector('#password');
if (emailInput) emailInput.value = email;
if (accountInput) accountInput.value = email;
if (passwordInput) passwordInput.value = password;
setBusy(true);
setMessage('...', 'info');
try {
await requestAuth('signIn', email, password, username);
await requestAuth('signIn', email, password);
rememberAccount(email, username);
setMessage('...', 'success');
window.location.assign('/');
return;
@@ -206,7 +236,8 @@ const AUTH_SCRIPT: &str = r#"
}
try {
await requestAuth('signUp', email, password, username);
await requestAuth('signUp', email, password);
rememberAccount(email, username);
setMessage('...', 'success');
window.location.assign('/');
} catch (error) {
+1 -24
View File
@@ -61,30 +61,7 @@ pub fn HomePage(
</main>
}.into_any()
} else {
view! {
<section class="mnote-workspace-empty-state" data-testid="mnote-workspace-empty-state">
<h1>"暂无页面"</h1>
<p>"当前还没有可显示的本地工作区。"</p>
<button
type="button"
class="mnote-empty-create-page"
data-testid="mnote-create-default-local-workspace"
data-mnote-action="create-local-workspace"
>"创建我的空间"</button>
<button
type="button"
class="mnote-empty-create-page"
data-testid="mnote-open-local-folder-empty"
data-mnote-action="open-local-folder"
>"打开本地文件夹"</button>
<button
type="button"
class="mnote-empty-create-page"
data-testid="mnote-empty-create-page"
data-mnote-action="create-local-workspace"
>"新建页面"</button>
</section>
}.into_any()
view! {}.into_any()
}}
</PageLayout>
}
+144 -4
View File
@@ -1214,6 +1214,110 @@ const SIDEBAR_TREE_JS: &str = r##"
trigger.closest('[data-testid="wolai-workspace-identity"]')?.appendChild(menu);
}
function closeAccountMenu() {
var existing = document.querySelector('[data-testid="mnote-account-menu"]');
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
document.querySelectorAll('[data-testid="mnote-account-menu-trigger"]').forEach(function(trigger) {
if (trigger instanceof HTMLElement) trigger.setAttribute('aria-expanded', 'false');
});
}
function accountMenuFallbackSession() {
var body = document.body instanceof HTMLElement ? document.body : null;
var actorId = body ? String(body.getAttribute('data-mnote-actor-id') || '').trim() : '';
return {
ok: true,
name: actorId || '',
email: '',
userId: actorId || 'anonymous',
actorType: actorId && actorId !== 'anonymous' ? 'user' : 'anonymous',
authMode: 'browser'
};
}
function renderAccountInfo(menu, session) {
var nameNode = menu.querySelector('[data-account-info="name"]');
var emailNode = menu.querySelector('[data-account-info="email"]');
var idNode = menu.querySelector('[data-account-info="user-id"]');
var typeNode = menu.querySelector('[data-account-info="actor-type"]');
if (nameNode) nameNode.textContent = session.name || session.userId || '';
if (emailNode) emailNode.textContent = session.email || '';
if (idNode) idNode.textContent = session.userId || 'anonymous';
if (typeNode) typeNode.textContent = session.actorType || session.authMode || 'unknown';
}
async function loadAccountInfo(menu) {
try {
var response = await fetch('/api/auth/session', {
method: 'GET',
headers: { 'accept': 'application/json' },
credentials: 'include'
});
var payload = await response.json().catch(function() { return null; });
if (!response.ok || !payload) throw new Error('session_failed_' + response.status);
renderAccountInfo(menu, payload);
} catch (_) {
renderAccountInfo(menu, accountMenuFallbackSession());
}
}
async function signOutAccount(trigger) {
setCommandPending(trigger, true);
try {
var response = await fetch('/api/auth', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ action: 'auth:signOut', args: {} })
});
var payload = await response.json().catch(function() { return {}; });
payload = payload || {};
if (!response.ok || payload.error) {
throw new Error(payload.error || '退');
}
window.location.assign('/auth');
} catch (error) {
var menu = document.querySelector('[data-testid="mnote-account-menu"]');
var errorNode = menu ? menu.querySelector('[data-account-error]') : null;
if (errorNode instanceof HTMLElement) {
errorNode.textContent = error && error.message ? error.message : '退';
errorNode.hidden = false;
}
setCommandPending(trigger, false);
}
}
function openAccountMenu(trigger) {
closeAccountMenu();
if (!(trigger instanceof HTMLElement)) return;
trigger.setAttribute('aria-expanded', 'true');
var menu = document.createElement('div');
menu.className = 'mnote-workspace-source-menu mnote-account-menu';
menu.setAttribute('data-testid', 'mnote-account-menu');
menu.setAttribute('role', 'menu');
menu.innerHTML =
'<div class="mnote-account-menu__profile" role="group" aria-label="个人信息">' +
'<div class="mnote-account-menu__title"></div>' +
'<div class="mnote-account-menu__name" data-account-info="name">...</div>' +
'<div class="mnote-account-menu__row"><span></span><strong data-account-info="email">...</strong></div>' +
'<div class="mnote-account-menu__row"><span> ID</span><strong data-account-info="user-id">...</strong></div>' +
'<div class="mnote-account-menu__row"><span></span><strong data-account-info="actor-type">...</strong></div>' +
'</div>' +
'<div class="mnote-account-menu__separator" aria-hidden="true"></div>' +
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__logout" data-testid="mnote-account-sign-out" role="menuitem">退</button>' +
'<div class="mnote-account-menu__error" data-account-error hidden></div>';
var signOutButton = menu.querySelector('[data-testid="mnote-account-sign-out"]');
if (signOutButton) {
signOutButton.addEventListener('click', function(event) {
event.preventDefault();
void signOutAccount(signOutButton);
});
}
var host = trigger.closest('[data-testid="wolai-sidebar-quick-actions"]') || trigger.parentElement;
if (host) host.appendChild(menu);
void loadAccountInfo(menu);
}
autoOpenRecentLocalRootOnHome();
function setCommandPending(trigger, pending) {
@@ -1825,7 +1929,7 @@ const SIDEBAR_TREE_JS: &str = r##"
});
var activeId = currentDocumentId();
var activeRowId = currentFileTreeActiveRowId();
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">' + (rows.length ? renderPageRows('', groupRowsByParent(rows), activeId, 0) : '<li class="tree-empty" data-rust-rendered-row="page-empty"></li>') + '</ul>';
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">' + (rows.length ? renderPageRows('', groupRowsByParent(rows), activeId, 0) : '') + '</ul>';
return true;
}
@@ -1934,7 +2038,7 @@ const SIDEBAR_TREE_JS: &str = r##"
var rows = projectionItems(projection);
var activeId = currentDocumentId();
var activeRowId = currentFileTreeActiveRowId();
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">' + (rows.length ? renderFileRows('', groupRowsByParent(rows), activeId, activeRowId) : '<li class="tree-empty" data-rust-rendered-row="filetree-empty"></li>') + '</ul>';
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">' + (rows.length ? renderFileRows('', groupRowsByParent(rows), activeId, activeRowId) : '') + '</ul>';
return true;
}
@@ -2006,6 +2110,9 @@ const SIDEBAR_TREE_JS: &str = r##"
};
var poll = async function() {
if (document.hidden) return;
// If tree live SSE transport is active for local_folder, skip polling (fallback)
var treeTransport = document.documentElement.getAttribute('data-mnote-tree-live-transport') || '';
if (treeTransport === 'local-folder-events') return;
var url = new URL('/api/tree/local-folder-watch', window.location.origin);
url.searchParams.set('rootUri', rootUri);
var response = await fetch(url.toString(), { headers: { accept: 'application/json' } });
@@ -8864,6 +8971,17 @@ const SIDEBAR_TREE_JS: &str = r##"
var sourceMenu = closestAction(e.target, '[data-testid="mnote-workspace-source-menu"]');
if (!sourceMenu) closeWorkspaceSourceMenu();
var accountMenuTrigger = closestAction(e.target, '[data-mnote-action="open-account-menu"]');
if (accountMenuTrigger) {
e.preventDefault();
var existingAccountMenu = document.querySelector('[data-testid="mnote-account-menu"]');
if (existingAccountMenu) closeAccountMenu();
else openAccountMenu(accountMenuTrigger);
return;
}
var accountMenu = closestAction(e.target, '[data-testid="mnote-account-menu"]');
if (!accountMenu) closeAccountMenu();
var trashTrigger = closestAction(e.target, '[data-mnote-action="open-trash-modal"]');
if (trashTrigger) {
e.preventDefault();
@@ -9706,6 +9824,18 @@ const TREE_LIVE_CONTROLLER_JS: &str = r##"
var params = new URLSearchParams(window.location.search);
var sourceKind = (params.get('sourceKind') || '').trim();
if (sourceKind === 'local_folder') {
applyTransport('local-folder-events');
applyStatus('connecting');
var localRootUri = (params.get('rootUri') || '').trim()
|| (document.body instanceof HTMLElement ? (document.body.getAttribute('data-mnote-root-uri') || '').trim() : '');
if (localRootUri && 'EventSource' in window) {
var url = new URL('/api/local-folder/events', window.location.origin);
url.searchParams.set('rootUri', localRootUri);
url.searchParams.set('treeLive', 'true');
startWithSse(bootstrap, '', url);
return;
}
// No rootUri or EventSource unavailable — mark static and let polling fallback handle it
applyTransport('local-folder-static');
applyStatus('static');
return;
@@ -9841,7 +9971,7 @@ pub fn PageLayout(
<span class="wolai-sidebar-chevron" aria-hidden="true">""</span>
</button>
</div>
<nav class="mnote-sidebar-nav wolai-quick-actions" aria-label="快捷操作">
<nav class="mnote-sidebar-nav wolai-quick-actions" aria-label="快捷操作" data-testid="wolai-sidebar-quick-actions">
<a href="/search" class:active={current_nav == "search"} title="搜索" aria-label="搜索" data-mnote-action="open-search-modal"><span class="material-symbols-outlined nav-icon" data-icon="search" aria-hidden="true"></span></a>
<a href="/graph" title="关系图" aria-label="关系图"><span class="material-symbols-outlined nav-icon" data-icon="account_tree" aria-hidden="true"></span></a>
<a href="/actions" title="快捷动作" aria-label="快捷动作"><span class="material-symbols-outlined nav-icon" data-icon="bolt" aria-hidden="true"></span></a>
@@ -9863,7 +9993,17 @@ pub fn PageLayout(
} else {
view! {}.into_any()
}}
<a href="/more" title="更多" aria-label="更多"><span class="material-symbols-outlined nav-icon" data-icon="more_horiz" aria-hidden="true"></span></a>
<button
type="button"
title="更多"
aria-label="更多"
data-testid="mnote-account-menu-trigger"
data-mnote-action="open-account-menu"
aria-haspopup="menu"
aria-expanded="false"
>
<span class="material-symbols-outlined nav-icon" data-icon="more_horiz" aria-hidden="true"></span>
</button>
</nav>
<div class="wolai-sidebar-body" inner_html={sidebar_sections_html}></div>
<script id="__MNOTE_TREE_LIVE_BOOTSTRAP__" type="application/json" inner_html={tree_live_bootstrap}></script>
+70 -18
View File
@@ -338,12 +338,81 @@ a:hover {
color: #1D4ED8;
}
.mnote-account-menu {
inset-inline-start: auto;
right: 8px;
top: calc(100% - 10px);
width: 232px;
}
.mnote-account-menu__profile {
padding: 8px;
}
.mnote-account-menu__title {
color: var(--wolai-text-secondary);
font-size: 12px;
line-height: 1.35;
}
.mnote-account-menu__name {
margin-top: 5px;
color: var(--wolai-text-primary);
font-size: 14px;
font-weight: 600;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-account-menu__row {
display: grid;
grid-template-columns: 58px minmax(0, 1fr);
gap: 8px;
margin-top: 6px;
color: var(--wolai-text-secondary);
font-size: 12px;
line-height: 1.35;
}
.mnote-account-menu__row strong {
min-width: 0;
color: var(--wolai-text-primary);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-account-menu__separator {
height: 1px;
margin: 4px 0;
background: var(--wolai-border);
}
.mnote-account-menu__logout {
color: #C2410C;
}
.mnote-account-menu__logout:hover {
background: #FFF7ED;
}
.mnote-account-menu__error {
padding: 6px 8px 2px;
color: #B91C1C;
font-size: 12px;
line-height: 1.35;
}
.wolai-quick-actions {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
padding: 8px 12px 18px;
position: relative;
}
.wolai-quick-actions a,
@@ -403,22 +472,6 @@ a:hover {
color: #555;
}
.mnote-empty-create-page {
margin-top: 14px;
height: 34px;
padding: 0 14px;
border: 1px solid var(--wolai-border);
border-radius: 6px;
background: #fff;
color: #333;
font-size: 14px;
cursor: pointer;
}
.mnote-empty-create-page:hover {
background: var(--wolai-bg-hover);
}
.wolai-page-row {
min-height: 38px;
display: flex;
@@ -1519,8 +1572,7 @@ body {
padding: 0;
}
.sidebar-tree .tree-empty,
.wolai-sidebar-empty {
.sidebar-tree .tree-empty {
padding: 6px 12px;
color: var(--atelier-text-soft);
font-size: 13px;
@@ -129,9 +129,6 @@ pub fn render_initial_filetree_html(input: &FileTreeInitialRenderInput) -> Strin
r#"<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">"#,
);
if input.rows.is_empty() {
html.push_str(
r#"<li class="tree-empty" data-rust-rendered-row="filetree-empty">暂无文件或页面</li>"#,
);
html.push_str("</ul>");
return html;
}
@@ -161,9 +161,6 @@ pub fn render_initial_page_tree_html(input: &PageTreeInitialRenderInput) -> Stri
let mut html =
String::from(r#"<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">"#);
if input.rows.is_empty() {
html.push_str(
r#"<li class="tree-empty" data-rust-rendered-row="page-empty">暂无页面</li>"#,
);
html.push_str("</ul>");
return html;
}
+4 -4
View File
@@ -391,7 +391,8 @@ mod tests {
build_workspace_shell_projection(&dataset, "ws_demo", None, "开发用户 的工作区");
let html = render_workspace_shell_sidebar_html(&projection, None, None);
assert!(html.contains("data-testid=\"wolai-sidebar-empty-state\""));
assert!(!html.contains("data-testid=\"wolai-sidebar-empty-state\""));
assert!(!html.contains("暂无页面"));
}
#[test]
@@ -471,15 +472,14 @@ pub fn render_workspace_shell_sidebar_html(
)
})
.unwrap_or_else(|| {
r#"<div class="wolai-sidebar-empty" data-testid="wolai-sidebar-filetree-empty-state">暂无文件或页面</div>"#.to_string()
String::new()
});
let empty_state = r#"<div class="wolai-sidebar-empty" data-testid="wolai-sidebar-empty-state">暂无页面</div>"#;
let my_pages = if !tree_html.is_empty() {
tree_html
} else if !projected_my_pages.is_empty() {
projected_my_pages
} else {
empty_state.to_string()
String::new()
};
let bottom_entries = projection
.bottom_entries