完善共享授权与本地同步闭环
实现独立 share-grants 控制面,区分产品分享授权与本机目录授权。 收紧 shared AI session 的资源范围,并补充 shared cache、pending change 与冲突报告 sidecar。 更新当前架构与执行 checklist,记录 P4 已完成项和剩余 browser smoke。
This commit is contained in:
@@ -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, ®istration.session_id, &event, None)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
append_local_ai_session_event(
|
||||
root_uri,
|
||||
®istration.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, ®istration.session_id, &event, None)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
append_local_ai_session_event(
|
||||
root_uri,
|
||||
®istration.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();
|
||||
|
||||
Reference in New Issue
Block a user