完善共享授权与本地同步闭环

实现独立 share-grants 控制面,区分产品分享授权与本机目录授权。

收紧 shared AI session 的资源范围,并补充 shared cache、pending change 与冲突报告 sidecar。

更新当前架构与执行 checklist,记录 P4 已完成项和剩余 browser smoke。
This commit is contained in:
lix-2026
2026-05-19 11:11:08 +08:00
parent fc4a47e597
commit a1b28a8e38
7 changed files with 1889 additions and 48 deletions
+648 -27
View File
@@ -29,6 +29,8 @@ 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 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";
static HERMES_RUNTIME_REGISTRY: LazyLock<Mutex<HashMap<String, HermesRuntimeState>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
@@ -104,6 +106,58 @@ struct LocalAgentAuditSnapshot {
files: BTreeMap<String, LocalAgentAuditFileSnapshot>,
}
#[derive(Debug, Clone)]
struct LocalShareGrant {
share_id: String,
owner_id: Option<String>,
target_user_id: Option<String>,
root_uri: Option<String>,
permission: String,
capabilities: HashSet<String>,
allowed_resource_ids: Vec<String>,
revoked: bool,
}
impl LocalShareGrant {
fn permission_level(&self) -> &'static str {
if local_share_permission_allows_write(&self.permission, &self.capabilities) {
"shared_write"
} else {
"shared_read"
}
}
fn share_context(&self) -> Value {
json!({
"shareId": self.share_id,
"ownerId": self.owner_id,
"targetUserId": self.target_user_id,
"rootUri": self.root_uri,
"permission": self.permission,
"capabilities": self.capabilities.iter().cloned().collect::<Vec<_>>()
})
}
fn allowed_file_paths(&self) -> Vec<String> {
let Some(root_uri) = self.root_uri.as_deref() else {
return Vec::new();
};
let Some(root_path) = file_root_uri_to_permission_path(root_uri) else {
return Vec::new();
};
self.allowed_resource_ids
.iter()
.filter_map(|resource_id| local_resource_id_to_relative_path(resource_id))
.map(|relative_path| {
FsPath::new(&root_path)
.join(relative_path)
.to_string_lossy()
.to_string()
})
.collect()
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateSessionRequest {
@@ -380,18 +434,34 @@ pub async fn create_session(
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
})?;
crate::routes::ensure_local_workspace_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
let share_id = payload
.share_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let permission_level = payload
.permission_level
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
let share_grant = if let Some(share_id) = share_id {
let grant = load_local_share_grant_for_actor(
share_id,
Some(&context.auth.actor_id),
Some(root_uri),
)
.map_err(|error| error.with_context(&context))?;
Some(grant)
} else {
crate::routes::ensure_local_workspace_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
None
};
let permission_level = share_grant
.as_ref()
.map(LocalShareGrant::permission_level)
.or_else(|| {
payload
.permission_level
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
})
.unwrap_or(if share_id.is_some() {
"shared_read"
} else {
@@ -424,6 +494,8 @@ pub async fn create_session(
"visibility": visibility,
"permissionLevel": permission_level,
"shareId": share_id,
"shareContext": share_grant.as_ref().map(LocalShareGrant::share_context).unwrap_or(Value::Null),
"allowedResourceIds": share_grant.as_ref().map(|grant| grant.allowed_resource_ids.clone()).unwrap_or_else(|| vec![document_id.to_string()]),
"title": payload.title.as_deref().unwrap_or("当前页问答"),
"profile": profile,
"userId": session_store_user_id,
@@ -442,6 +514,8 @@ pub async fn create_session(
"sourceKind": "local_folder",
"permissionLevel": permission_level,
"shareId": share_id,
"shareContext": share_grant.as_ref().map(LocalShareGrant::share_context).unwrap_or(Value::Null),
"allowedResourceIds": share_grant.as_ref().map(|grant| grant.allowed_resource_ids.clone()).unwrap_or_default(),
"actorId": context.auth.actor_id,
"userId": session_store_user_id,
"traceId": trace_id,
@@ -2828,6 +2902,9 @@ fn merge_acp_runtime_env(
}
fn local_ai_payload_permission_level(payload: &Value) -> &'static str {
if let Some(grant) = local_share_grant_for_payload(payload).ok().flatten() {
return grant.permission_level();
}
let raw = payload
.get("permissionLevel")
.or_else(|| payload.get("permission_level"))
@@ -2835,7 +2912,11 @@ fn local_ai_payload_permission_level(payload: &Value) -> &'static str {
.map(str::trim)
.unwrap_or("read_write")
.to_ascii_lowercase();
if raw == "read" || raw == "readonly" || raw == "read_only" || raw == "shared_read" {
if raw == "shared_read" {
"shared_read"
} else if raw == "shared_write" {
"shared_write"
} else if raw == "read" || raw == "readonly" || raw == "read_only" {
"read_only"
} else {
"read_write"
@@ -2843,7 +2924,261 @@ fn local_ai_payload_permission_level(payload: &Value) -> &'static str {
}
fn local_ai_payload_is_read_only(payload: &Value) -> bool {
local_ai_payload_permission_level(payload) == "read_only"
matches!(
local_ai_payload_permission_level(payload),
"read_only" | "shared_read"
)
}
fn local_share_grants_path() -> PathBuf {
env::var(ENV_LOCAL_SHARE_GRANTS_FILE)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(LOCAL_SHARE_GRANTS_JSON))
}
fn local_resource_id_to_relative_path(resource_id: &str) -> Option<String> {
let trimmed = resource_id.trim();
let encoded = trimmed.strip_prefix("local-md:")?;
let bytes = encoded.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'~' {
if index + 2 >= bytes.len() {
return None;
}
let hex = &encoded[index + 1..index + 3];
let byte = u8::from_str_radix(hex, 16).ok()?;
decoded.push(byte);
index += 3;
} else {
decoded.push(bytes[index]);
index += 1;
}
}
String::from_utf8(decoded).ok()
}
fn local_share_permission_allows_write(permission: &str, capabilities: &HashSet<String>) -> bool {
let permission = permission.trim().to_ascii_lowercase();
let has_ai = capabilities
.iter()
.any(|capability| capability.trim().eq_ignore_ascii_case("ai"));
(permission == "write" || permission == "shared_write") && has_ai
}
fn local_share_grant_for_payload(payload: &Value) -> Result<Option<LocalShareGrant>, WebError> {
let share_id = payload
.get("shareId")
.or_else(|| payload.get("share_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let Some(share_id) = share_id else {
return Ok(None);
};
Ok(Some(load_local_share_grant_for_actor(
share_id,
payload.get("actorId").and_then(Value::as_str),
payload.get("rootUri").and_then(Value::as_str),
)?))
}
fn load_local_share_grant_for_actor(
share_id: &str,
actor_id: Option<&str>,
root_uri: Option<&str>,
) -> Result<LocalShareGrant, WebError> {
let path = local_share_grants_path();
let content = fs::read_to_string(&path).map_err(|error| {
WebError::new(
StatusCode::FORBIDDEN,
"local_share_grant_required",
format!("共享 AI 会话需要 canonical share grant: {error}"),
)
})?;
let payload: Value = serde_json::from_str(&content).map_err(|error| {
WebError::bad_request_code(
"local_share_grants_invalid",
format!("share-grants.json 格式非法: {error}"),
)
})?;
let grants = if let Some(array) = payload.as_array() {
array
} else {
payload
.get("grants")
.and_then(Value::as_array)
.ok_or_else(|| {
WebError::bad_request_code(
"local_share_grants_invalid",
"share-grants.json 缺少 grants 数组",
)
})?
};
let Some(raw_grant) = grants.iter().find(|grant| {
grant
.get("shareId")
.or_else(|| grant.get("share_id"))
.and_then(Value::as_str)
.map(str::trim)
== Some(share_id)
}) else {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_share_grant_not_found",
"共享 AI 会话使用的 shareId 不存在",
));
};
let grant = parse_local_share_grant(raw_grant)?;
if grant.revoked {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_share_grant_revoked",
"共享 AI 会话使用的 share grant 已撤销",
));
}
if let (Some(target), Some(actor_id)) = (grant.target_user_id.as_deref(), actor_id) {
if target.trim() != actor_id.trim() {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_share_grant_actor_mismatch",
"当前用户不是 share grant 的 target 用户",
));
}
}
if let (Some(grant_root), Some(root_uri)) = (grant.root_uri.as_deref(), root_uri) {
if grant_root.trim() != root_uri.trim() {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_share_grant_root_mismatch",
"请求 rootUri 与 canonical share grant 不一致",
));
}
}
if !grant
.capabilities
.iter()
.any(|capability| capability.trim().eq_ignore_ascii_case("ai"))
{
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_share_grant_ai_capability_required",
"共享 AI 会话需要 share grant 包含 ai capability",
));
}
Ok(grant)
}
fn parse_local_share_grant(value: &Value) -> Result<LocalShareGrant, WebError> {
let share_id = value
.get("shareId")
.or_else(|| value.get("share_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_share_grant_invalid", "share grant 缺少 shareId")
})?
.to_string();
let owner_id = value
.get("ownerId")
.or_else(|| value.get("ownerUserId"))
.or_else(|| value.get("owner_id"))
.or_else(|| value.get("owner_user_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let target_user_id = value
.get("targetUserId")
.or_else(|| value.get("target_user_id"))
.or_else(|| value.get("targetId"))
.or_else(|| value.get("target_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let root_uri = value
.get("rootUri")
.or_else(|| value.get("root_uri"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let permission = value
.get("permission")
.or_else(|| value.get("permissionLevel"))
.or_else(|| value.get("permission_level"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("read")
.to_ascii_lowercase();
let capabilities = value
.get("capabilities")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase())
.collect::<HashSet<_>>()
})
.unwrap_or_default();
let allowed_resource_ids = value
.get("allowedResourceIds")
.or_else(|| value.get("allowed_resource_ids"))
.or_else(|| value.get("resourceIds"))
.or_else(|| value.get("resource_ids"))
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let revoked = value
.get("revoked")
.and_then(Value::as_bool)
.unwrap_or(false)
|| value
.get("active")
.and_then(Value::as_bool)
.map(|active| !active)
.unwrap_or(false)
|| value
.get("revokedAt")
.or_else(|| value.get("revoked_at"))
.and_then(Value::as_str)
.map(str::trim)
.map(|revoked_at| !revoked_at.is_empty())
.unwrap_or(false)
|| value
.get("status")
.and_then(Value::as_str)
.map(str::trim)
.map(|status| status.eq_ignore_ascii_case("revoked"))
.unwrap_or(false);
Ok(LocalShareGrant {
share_id,
owner_id,
target_user_id,
root_uri,
permission,
capabilities,
allowed_resource_ids,
revoked,
})
}
fn acp_allowed_roots_env_for_payload(payload: &Value) -> Option<HashMap<String, String>> {
@@ -2885,21 +3220,44 @@ fn acp_allowed_roots_env_for_payload(payload: &Value) -> Option<HashMap<String,
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("anonymous");
let permission_level = local_ai_payload_permission_level(payload);
let access_scope = json!({
let share_grant = local_share_grant_for_payload(payload).ok().flatten();
let permission_level = share_grant
.as_ref()
.map(LocalShareGrant::permission_level)
.unwrap_or_else(|| local_ai_payload_permission_level(payload));
let allowed_resource_ids = share_grant
.as_ref()
.map(|grant| grant.allowed_resource_ids.clone())
.filter(|ids| !ids.is_empty())
.unwrap_or_else(|| vec![document_id.to_string()]);
let allowed_file_paths = share_grant
.as_ref()
.map(LocalShareGrant::allowed_file_paths)
.unwrap_or_default();
let mut access_scope = json!({
"userId": actor_id,
"workspaceId": workspace_id,
"sessionId": session_id,
"sourceKind": "local_folder",
"permissionLevel": permission_level,
"allowedRoots": [root_uri],
"allowedFilePaths": [],
"allowedResourceIds": [document_id]
"allowedFilePaths": allowed_file_paths,
"allowedResourceIds": allowed_resource_ids
});
if let Some(grant) = share_grant.as_ref() {
access_scope["shareContext"] = grant.share_context();
}
let mut env = HashMap::new();
env.insert(
"MNOTE_AI_ALLOWED_ROOTS_JSON".into(),
serde_json::to_string(&vec![root_path.clone()]).ok()?,
serde_json::to_string(
&share_grant
.as_ref()
.map(LocalShareGrant::allowed_file_paths)
.filter(|paths| !paths.is_empty())
.unwrap_or_else(|| vec![root_path.clone()]),
)
.ok()?,
);
env.insert("MNOTE_AI_WORKSPACE_ROOT".into(), root_path);
env.insert(
@@ -3329,6 +3687,11 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
.get("actorType")
.and_then(Value::as_str)
.unwrap_or(&context.auth.actor_type);
let share_grant = if is_local_source {
local_share_grant_for_payload(&payload)?
} else {
None
};
let local_file_reference = if is_local_source {
root_uri.map(|root_uri| {
json!({
@@ -3347,17 +3710,33 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
};
let local_ai_access_scope = if is_local_source {
root_uri.map(|root_uri| {
let permission_level = local_ai_payload_permission_level(&payload);
json!({
let permission_level = share_grant
.as_ref()
.map(LocalShareGrant::permission_level)
.unwrap_or_else(|| local_ai_payload_permission_level(&payload));
let allowed_resource_ids = share_grant
.as_ref()
.map(|grant| grant.allowed_resource_ids.clone())
.filter(|ids| !ids.is_empty())
.unwrap_or_else(|| vec![document_id.to_string()]);
let allowed_file_paths = share_grant
.as_ref()
.map(LocalShareGrant::allowed_file_paths)
.unwrap_or_default();
let mut scope = json!({
"userId": actor_id,
"workspaceId": workspace_id_string,
"sessionId": session_id,
"sourceKind": "local_folder",
"permissionLevel": permission_level,
"allowedRoots": [root_uri],
"allowedFilePaths": [],
"allowedResourceIds": [document_id]
})
"allowedFilePaths": allowed_file_paths,
"allowedResourceIds": allowed_resource_ids
});
if let Some(grant) = share_grant.as_ref() {
scope["shareContext"] = grant.share_context();
}
scope
})
} else {
None
@@ -3727,6 +4106,16 @@ fn acp_runtime_run_store_args(
})
}
fn local_session_share_id(payload: &Value) -> Option<String> {
payload
.get("shareId")
.or_else(|| payload.get("share_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn runtime_store_user_id(context: &RequestContext, payload: &Value) -> String {
payload
.get("actorId")
@@ -4320,8 +4709,18 @@ async fn persist_acp_runtime_run(
WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri")
.with_context(context)
})?;
crate::routes::ensure_local_workspace_access(context, root_uri)
let share_id = local_session_share_id(payload);
if let Some(share_id) = share_id.as_deref() {
load_local_share_grant_for_actor(
share_id,
payload.get("actorId").and_then(Value::as_str),
Some(root_uri),
)
.map_err(|error| error.with_context(context))?;
} else {
crate::routes::ensure_local_workspace_access(context, root_uri)
.map_err(|error| error.with_context(context))?;
}
let mut event = args.clone();
if let Value::Object(map) = &mut event {
map.insert("eventType".into(), Value::String("run.started".into()));
@@ -4330,12 +4729,17 @@ async fn persist_acp_runtime_run(
Value::String("local_ai_session_jsonl".into()),
);
}
append_local_ai_session_event(root_uri, &registration.session_id, &event, None)
.map_err(|error| error.with_context(context))?;
append_local_ai_session_event(
root_uri,
&registration.session_id,
&event,
share_id.as_deref(),
)
.map_err(|error| error.with_context(context))?;
return Ok(json!({
"ok": true,
"persistence": "local_ai_session_jsonl",
"sessionStorage": "local_private",
"sessionStorage": if share_id.is_some() { "local_shared" } else { "local_private" },
"sessionId": registration.session_id,
"runId": run_id
}));
@@ -4428,8 +4832,18 @@ async fn persist_acp_runtime_event(
WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri")
.with_context(context)
})?;
crate::routes::ensure_local_workspace_access(context, root_uri)
let share_id = local_session_share_id(run_payload);
if let Some(share_id) = share_id.as_deref() {
load_local_share_grant_for_actor(
share_id,
run_payload.get("actorId").and_then(Value::as_str),
Some(root_uri),
)
.map_err(|error| error.with_context(context))?;
} else {
crate::routes::ensure_local_workspace_access(context, root_uri)
.map_err(|error| error.with_context(context))?;
}
let mut event = args.clone();
if let Value::Object(map) = &mut event {
map.insert(
@@ -4437,12 +4851,17 @@ async fn persist_acp_runtime_event(
Value::String("local_ai_session_jsonl".into()),
);
}
append_local_ai_session_event(root_uri, &registration.session_id, &event, None)
.map_err(|error| error.with_context(context))?;
append_local_ai_session_event(
root_uri,
&registration.session_id,
&event,
share_id.as_deref(),
)
.map_err(|error| error.with_context(context))?;
return Ok(json!({
"ok": true,
"persistence": "local_ai_session_jsonl",
"sessionStorage": "local_private",
"sessionStorage": if share_id.is_some() { "local_shared" } else { "local_private" },
"sessionId": registration.session_id,
"runId": run_id
}));
@@ -5672,18 +6091,47 @@ mod tests {
#[tokio::test]
async fn hermes_client_local_shared_write_session_writes_shared_jsonl_with_audit() {
let _guard = env_lock().lock().expect("env lock");
let root = std::env::temp_dir().join(format!(
"mnote-local-ai-session-shared-{}",
std::process::id()
));
let config_root = std::env::temp_dir().join(format!(
"mnote-local-ai-session-shared-config-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&config_root);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::create_dir_all(&config_root).expect("config root");
fs::write(
root.join(".mnote").join("workspace.json"),
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#,
)
.expect("manifest");
let root_uri = format!("file://{}", root.display());
let share_grants_file = config_root.join("share-grants.json");
fs::write(
&share_grants_file,
json!({
"grants": [{
"id": "share_grant_1",
"shareId": "share_1",
"ownerUserId": "user_1",
"targetUserId": "user_1",
"rootUri": root_uri,
"documentId": "local-md:README.md",
"allowedResourceIds": ["local-md:README.md"],
"permission": "write",
"capabilities": ["ai", "share"],
"createdAt": "1",
"active": true
}]
})
.to_string(),
)
.expect("share grants");
std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file);
let response = app()
.oneshot(
@@ -5727,24 +6175,57 @@ mod tests {
assert!(jsonl.contains("\"eventType\":\"session.created\""));
assert!(jsonl.contains("\"eventType\":\"audit.shared_write\""));
assert!(jsonl.contains("\"shareId\":\"share_1\""));
assert!(jsonl.contains("\"allowedResourceIds\":[\"local-md:README.md\"]"));
assert!(jsonl.contains("\"shareContext\""));
std::env::remove_var("MNOTE_SHARE_GRANTS_FILE");
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&config_root);
}
#[tokio::test]
async fn hermes_client_local_shared_read_session_write_is_forbidden() {
let _guard = env_lock().lock().expect("env lock");
let root = std::env::temp_dir().join(format!(
"mnote-local-ai-session-shared-read-{}",
std::process::id()
));
let config_root = std::env::temp_dir().join(format!(
"mnote-local-ai-session-shared-read-config-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&config_root);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::create_dir_all(&config_root).expect("config root");
fs::write(
root.join(".mnote").join("workspace.json"),
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#,
)
.expect("manifest");
let root_uri = format!("file://{}", root.display());
let share_grants_file = config_root.join("share-grants.json");
fs::write(
&share_grants_file,
json!({
"grants": [{
"id": "share_grant_read_1",
"shareId": "share_1",
"ownerUserId": "user_1",
"targetUserId": "user_1",
"rootUri": root_uri,
"documentId": "local-md:README.md",
"allowedResourceIds": ["local-md:README.md"],
"permission": "read",
"capabilities": ["ai", "share"],
"createdAt": "1",
"active": true
}]
})
.to_string(),
)
.expect("share grants");
std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file);
let response = app()
.oneshot(
@@ -5775,7 +6256,9 @@ mod tests {
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert!(!root.join("ai-sessions").join("shared").exists());
std::env::remove_var("MNOTE_SHARE_GRANTS_FILE");
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&config_root);
}
#[tokio::test]
@@ -7082,6 +7565,74 @@ mod tests {
assert!(!instructions.contains("\"pageText\""));
}
#[test]
fn hermes_client_run_body_shared_scope_uses_share_grant_resources() {
let _guard = env_lock().lock().expect("env lock");
let config_root = std::env::temp_dir().join(format!(
"mnote-shared-run-body-config-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&config_root);
fs::create_dir_all(&config_root).expect("config root");
let share_grants_file = config_root.join("share-grants.json");
fs::write(
&share_grants_file,
json!({
"grants": [{
"id": "share_grant_scope_1",
"shareId": "share_scope_1",
"ownerUserId": "owner_1",
"targetUserId": "target_1",
"rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space",
"documentId": "local-md:docs~2FShared.md",
"allowedResourceIds": ["local-md:docs~2FShared.md"],
"permission": "write",
"capabilities": ["ai", "share"],
"createdAt": "1",
"active": true
}]
})
.to_string(),
)
.expect("share grants");
std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file);
let mut headers = HeaderMap::new();
headers.insert("x-mnote-actor-id", "target_1".parse().unwrap());
headers.insert("x-mnote-actor-type", "user".parse().unwrap());
let context = RequestContext::from_http_parts(
&axum::http::Method::POST,
&"/api/hermes/client/runs".parse().expect("uri"),
&headers,
);
let body = build_run_upstream_body(
&context,
json!({
"workspaceId": "local-workspace-shared",
"documentId": "local-md:docs~2FShared.md",
"sessionId": "sess_shared_scope_1",
"sourceKind": "local_folder",
"rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space",
"shareId": "share_scope_1",
"actorId": "target_1",
"message": "编辑共享页面",
"traceId": "trace_shared_scope_1"
}),
)
.expect("body");
let instructions = body["instructions"].as_str().expect("instructions");
assert!(instructions.contains("\"permissionLevel\":\"shared_write\""));
assert!(instructions.contains("\"shareContext\""));
assert!(instructions.contains("\"shareId\":\"share_scope_1\""));
assert!(instructions.contains("\"allowedResourceIds\":[\"local-md:docs~2FShared.md\"]"));
assert!(instructions.contains(
"\"allowedFilePaths\":[\"/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space/docs/Shared.md\"]"
));
std::env::remove_var("MNOTE_SHARE_GRANTS_FILE");
let _ = fs::remove_dir_all(&config_root);
}
#[test]
fn acp_runtime_env_limits_local_source_to_allowed_root() {
let payload = json!({
@@ -7122,6 +7673,76 @@ mod tests {
);
}
#[test]
fn acp_runtime_env_limits_shared_scope_to_granted_files() {
let _guard = env_lock().lock().expect("env lock");
let config_root = std::env::temp_dir().join(format!(
"mnote-shared-acp-env-config-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&config_root);
fs::create_dir_all(&config_root).expect("config root");
let share_grants_file = config_root.join("share-grants.json");
fs::write(
&share_grants_file,
json!({
"grants": [{
"id": "share_grant_env_1",
"shareId": "share_env_1",
"ownerUserId": "owner_1",
"targetUserId": "target_1",
"rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space",
"allowedResourceIds": ["local-md:README.md"],
"permission": "read",
"capabilities": ["ai", "share"],
"createdAt": "1",
"active": true
}]
})
.to_string(),
)
.expect("share grants");
std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file);
let payload = json!({
"workspaceId": "local-workspace-shared",
"documentId": "local-md:README.md",
"sessionId": "sess_shared_env_1",
"sourceKind": "local_folder",
"rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space",
"shareId": "share_env_1",
"actorId": "target_1"
});
let env = acp_allowed_roots_env_for_payload(&payload).expect("env");
let allowed_roots = serde_json::from_str::<Vec<String>>(
env.get("MNOTE_AI_ALLOWED_ROOTS_JSON")
.expect("allowed roots"),
)
.expect("allowed roots json");
assert_eq!(
allowed_roots,
vec!["/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space/README.md"]
);
assert_eq!(
env.get("MNOTE_AI_WORKSPACE_ROOT").map(String::as_str),
Some("/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space")
);
let scope = serde_json::from_str::<serde_json::Value>(
env.get("MNOTE_AI_ACCESS_SCOPE_JSON").expect("scope"),
)
.expect("scope json");
assert_eq!(scope["permissionLevel"], "shared_read");
assert_eq!(scope["shareContext"]["shareId"], "share_env_1");
assert_eq!(scope["allowedResourceIds"][0], "local-md:README.md");
assert_eq!(
scope["allowedFilePaths"][0],
"/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space/README.md"
);
std::env::remove_var("MNOTE_SHARE_GRANTS_FILE");
let _ = fs::remove_dir_all(&config_root);
}
#[test]
fn hermes_client_run_actor_falls_back_to_dev_user_for_cookie_auth() {
let state = test_state();