收口 MNote P0 P1 P2 审查尾项

- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目
- 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线
- 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径

验证:
- cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1
- cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1
- git diff --check
- git diff --cached --check
- codegraph index . --force && codegraph status .
- codegraph sync . && codegraph status .
This commit is contained in:
lix-2026
2026-06-01 09:29:12 +08:00
parent 49a0545148
commit 1882db7681
143 changed files with 29810 additions and 3228 deletions
+680 -12
View File
@@ -1,5 +1,6 @@
//! SQLite-backed control-plane store.
use std::collections::BTreeMap;
use std::sync::Mutex;
use rusqlite::{params, Connection, OptionalExtension};
@@ -7,15 +8,17 @@ use rusqlite::{params, Connection, OptionalExtension};
use crate::error::ControlPlaneError;
use crate::migrations;
use crate::model::{
password_hash_v1, share_token_hash_v1, AiPolicyRecord, AiRuntimeEventRecord,
AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord,
AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput,
CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup,
DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, OutboxEventRecord,
ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord,
UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertNavigationRecentInput,
UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput,
UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
password_hash_v1, share_token_hash_v1, AiAgentProfileAccessRecord, AiAgentProfileGrantRecord,
AiAgentProfileRecord, AiExternalConversationBindingRecord, AiPolicyRecord,
AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput,
AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput,
CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput,
DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput,
OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord,
SyncStateRecord, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput,
UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UserRecord,
UserUiPreferenceRecord, WorkspaceRecord,
};
use crate::store::ControlPlaneStore;
@@ -214,6 +217,66 @@ fn option_to_stored_text(value: Option<String>) -> String {
.unwrap_or_default()
}
fn sanitize_profile_component(value: &str) -> String {
let mut sanitized = value
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
while sanitized.contains("--") {
sanitized = sanitized.replace("--", "-");
}
sanitized = sanitized.trim_matches('-').to_string();
if sanitized.is_empty() {
"user".to_string()
} else {
sanitized
}
}
fn row_to_ai_agent_profile(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiAgentProfileRecord> {
let owner_user_id: String = row.get(3)?;
Ok(AiAgentProfileRecord {
id: row.get(0)?,
agent_id: row.get(1)?,
profile_kind: row.get(2)?,
owner_user_id: empty_string_to_option(owner_user_id),
base_profile_name: row.get(4)?,
isolated_profile_name: row.get(5)?,
display_name: row.get(6)?,
status: row.get(7)?,
created_at: row.get(8)?,
updated_at: row.get(9)?,
revision: row.get(10)?,
})
}
fn row_to_ai_agent_profile_access(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<AiAgentProfileAccessRecord> {
Ok(AiAgentProfileAccessRecord {
profile: row_to_ai_agent_profile(row)?,
grant: AiAgentProfileGrantRecord {
id: row.get(11)?,
profile_id: row.get(12)?,
user_id: empty_string_to_option(row.get(13)?),
role: row.get(14)?,
can_run: row.get::<_, i64>(15)? != 0,
can_manage_skills: row.get::<_, i64>(16)? != 0,
can_manage_config: row.get::<_, i64>(17)? != 0,
created_at: row.get(18)?,
updated_at: row.get(19)?,
revision: row.get(20)?,
},
})
}
fn row_to_user_ui_preference(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserUiPreferenceRecord> {
Ok(UserUiPreferenceRecord {
id: row.get(0)?,
@@ -281,6 +344,19 @@ fn navigation_recent_target_key(
}
}
fn validate_ai_external_conversation_status(status: &str) -> Result<(), ControlPlaneError> {
match status {
"active"
| "local_deleted"
| "remote_deleted"
| "remote_delete_failed"
| "remote_missing" => Ok(()),
_ => Err(ControlPlaneError::InvalidInput(
"external conversation status 不合法".to_string(),
)),
}
}
fn row_to_ai_policy(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiPolicyRecord> {
Ok(AiPolicyRecord {
id: row.get(0)?,
@@ -348,6 +424,29 @@ fn row_to_ai_runtime_event(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiRuntim
})
}
fn row_to_ai_external_conversation_binding(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<AiExternalConversationBindingRecord> {
Ok(AiExternalConversationBindingRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
mnote_session_id: row.get(3)?,
acp_session_id: row.get(4)?,
agent_id: row.get(5)?,
profile: row.get(6)?,
provider: row.get(7)?,
remote_conversation_id: row.get(8)?,
remote_url: row.get(9)?,
status: row.get(10)?,
metadata_json: row.get(11)?,
created_at: row.get(12)?,
updated_at: row.get(13)?,
deleted_at: row.get(14)?,
revision: row.get(15)?,
})
}
fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String {
let title = serde_json::from_str::<serde_json::Value>(payload_json)
.ok()
@@ -391,6 +490,50 @@ fn prune_navigation_recent_for_kind(
Ok(())
}
fn list_ai_agent_profile_access_rows(
conn: &Connection,
user_id: &str,
) -> Result<Vec<AiAgentProfileAccessRecord>, ControlPlaneError> {
let mut stmt = conn.prepare(
"SELECT
p.id, p.agent_id, p.profile_kind, p.owner_user_id, p.base_profile_name,
p.isolated_profile_name, p.display_name, p.status, p.created_at, p.updated_at,
p.revision,
g.id, g.profile_id, g.user_id, g.role, g.can_run, g.can_manage_skills,
g.can_manage_config, g.created_at, g.updated_at, g.revision
FROM ai_agent_profiles p
JOIN ai_agent_profile_grants g ON g.profile_id = p.id
WHERE p.agent_id = 'hermes'
AND p.status = 'active'
AND g.can_run = 1
AND (
(p.profile_kind = 'personal' AND p.owner_user_id = ?1 AND g.user_id = ?1)
OR (p.profile_kind = 'shared' AND (g.user_id = '' OR g.user_id = ?1))
)
ORDER BY
CASE p.profile_kind WHEN 'personal' THEN 0 ELSE 1 END,
p.display_name ASC,
g.can_manage_skills DESC,
g.can_manage_config DESC",
)?;
let rows = stmt
.query_map(params![user_id], row_to_ai_agent_profile_access)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
let mut by_profile = BTreeMap::<String, AiAgentProfileAccessRecord>::new();
for row in rows {
by_profile
.entry(row.profile.id.clone())
.and_modify(|existing| {
if row.grant.can_manage_skills && !existing.grant.can_manage_skills {
*existing = row.clone();
}
})
.or_insert(row);
}
Ok(by_profile.into_values().collect())
}
impl ControlPlaneStore for SqliteControlPlaneStore {
fn upsert_user(&self, input: UpsertUserInput) -> Result<UserRecord, ControlPlaneError> {
if input.username.trim().is_empty() {
@@ -1461,6 +1604,168 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
Ok(rows)
}
fn ensure_ai_agent_profile_policy(
&self,
user_id: &str,
is_admin: bool,
) -> Result<Vec<AiAgentProfileAccessRecord>, ControlPlaneError> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"ai agent profile user_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let now = now_text();
conn.execute(
"INSERT INTO ai_agent_profiles (
id, agent_id, profile_kind, owner_user_id, base_profile_name,
isolated_profile_name, display_name, status, created_at, updated_at, revision
)
VALUES ('shared_lite', 'hermes', 'shared', '', 'lite', 'lite', 'Lite', 'active', ?1, ?2, 1)
ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name)
DO UPDATE SET status = 'active', updated_at = excluded.updated_at",
params![now, now],
)?;
conn.execute(
"INSERT INTO ai_agent_profile_grants (
id, profile_id, user_id, role, can_run, can_manage_skills,
can_manage_config, created_at, updated_at, revision
)
VALUES ('grant_shared_lite_all', 'shared_lite', '', 'user', 1, 0, 0, ?1, ?2, 1)
ON CONFLICT(profile_id, user_id, role)
DO UPDATE SET can_run = 1, can_manage_skills = 0, can_manage_config = 0,
updated_at = excluded.updated_at",
params![now, now],
)?;
// 旧的泛化 OpenClaw 网页问答入口已拆成明确的三类 chat agent。
conn.execute(
"UPDATE ai_agent_profiles
SET status = 'retired', updated_at = ?1
WHERE id = 'shared_openclaw_webqa'",
params![now],
)?;
let shared_chat_profiles = [
(
"shared_deepseek_chat",
"deepseek-chat",
"openclaw-deepseek-chat",
"DeepSeek Chat",
),
(
"shared_gemini_chat",
"gemini-chat",
"openclaw-gemini-chat",
"Gemini Chat",
),
(
"shared_doubao_chat",
"doubao-chat",
"openclaw-doubao-chat",
"豆包 Chat",
),
];
for (profile_id, base_profile, isolated_profile, display_name) in shared_chat_profiles {
conn.execute(
"INSERT INTO ai_agent_profiles (
id, agent_id, profile_kind, owner_user_id, base_profile_name,
isolated_profile_name, display_name, status, created_at, updated_at, revision
)
VALUES (?1, 'hermes', 'shared', '', ?2, ?3, ?4, 'active', ?5, ?6, 1)
ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name)
DO UPDATE SET status = 'active', display_name = excluded.display_name,
isolated_profile_name = excluded.isolated_profile_name, updated_at = excluded.updated_at",
params![profile_id, base_profile, isolated_profile, display_name, now, now],
)?;
let grant_id = format!("grant_{profile_id}_all");
conn.execute(
"INSERT INTO ai_agent_profile_grants (
id, profile_id, user_id, role, can_run, can_manage_skills,
can_manage_config, created_at, updated_at, revision
)
VALUES (?1, ?2, '', 'user', 1, 0, 0, ?3, ?4, 1)
ON CONFLICT(profile_id, user_id, role)
DO UPDATE SET can_run = 1, can_manage_skills = 0, can_manage_config = 0,
updated_at = excluded.updated_at",
params![grant_id, profile_id, now, now],
)?;
}
let user_part = sanitize_profile_component(user_id);
let personal_profile_id = format!("usr_{user_part}_default");
let personal_profile_name = format!("mnote-u-{user_part}-default");
conn.execute(
"INSERT INTO ai_agent_profiles (
id, agent_id, profile_kind, owner_user_id, base_profile_name,
isolated_profile_name, display_name, status, created_at, updated_at, revision
)
VALUES (?1, 'hermes', 'personal', ?2, 'default', ?3, '我的 Hermes', 'active', ?4, ?5, 1)
ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name)
DO UPDATE SET status = 'active', updated_at = excluded.updated_at",
params![personal_profile_id, user_id, personal_profile_name, now, now],
)?;
let personal_grant_id = format!("grant_{personal_profile_id}_owner");
conn.execute(
"INSERT INTO ai_agent_profile_grants (
id, profile_id, user_id, role, can_run, can_manage_skills,
can_manage_config, created_at, updated_at, revision
)
VALUES (?1, ?2, ?3, 'owner', 1, 1, 1, ?4, ?5, 1)
ON CONFLICT(profile_id, user_id, role)
DO UPDATE SET can_run = 1, can_manage_skills = 1, can_manage_config = 1,
updated_at = excluded.updated_at",
params![personal_grant_id, personal_profile_id, user_id, now, now],
)?;
if is_admin {
let admin_grant_id = format!("grant_shared_lite_admin_{user_part}");
conn.execute(
"INSERT INTO ai_agent_profile_grants (
id, profile_id, user_id, role, can_run, can_manage_skills,
can_manage_config, created_at, updated_at, revision
)
VALUES (?1, 'shared_lite', ?2, 'admin', 1, 1, 1, ?3, ?4, 1)
ON CONFLICT(profile_id, user_id, role)
DO UPDATE SET can_run = 1, can_manage_skills = 1, can_manage_config = 1,
updated_at = excluded.updated_at",
params![admin_grant_id, user_id, now, now],
)?;
for (profile_id, _, _, _) in shared_chat_profiles {
let admin_chat_grant_id = format!("grant_{profile_id}_admin_{user_part}");
conn.execute(
"INSERT INTO ai_agent_profile_grants (
id, profile_id, user_id, role, can_run, can_manage_skills,
can_manage_config, created_at, updated_at, revision
)
VALUES (?1, ?2, ?3, 'admin', 1, 0, 0, ?4, ?5, 1)
ON CONFLICT(profile_id, user_id, role)
DO UPDATE SET can_run = 1, can_manage_skills = 0, can_manage_config = 0,
updated_at = excluded.updated_at",
params![admin_chat_grant_id, profile_id, user_id, now, now],
)?;
}
}
list_ai_agent_profile_access_rows(&conn, user_id)
}
fn resolve_ai_agent_profile(
&self,
user_id: &str,
is_admin: bool,
profile_id: &str,
) -> Result<Option<AiAgentProfileAccessRecord>, ControlPlaneError> {
let profile_id = profile_id.trim();
if profile_id.is_empty() {
return Ok(None);
}
let access = self.ensure_ai_agent_profile_policy(user_id, is_admin)?;
Ok(access
.into_iter()
.find(|item| item.profile.id == profile_id && item.grant.can_run))
}
fn upsert_navigation_recent(
&self,
input: UpsertNavigationRecentInput,
@@ -2047,6 +2352,207 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
Ok(rows)
}
fn upsert_ai_external_conversation_binding(
&self,
input: UpsertAiExternalConversationBindingInput,
) -> Result<AiExternalConversationBindingRecord, ControlPlaneError> {
let user_id = input.user_id.trim();
let mnote_session_id = input.mnote_session_id.trim();
let provider = input.provider.trim();
let remote_conversation_id = input.remote_conversation_id.trim();
if user_id.is_empty()
|| mnote_session_id.is_empty()
|| provider.is_empty()
|| remote_conversation_id.is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"external conversation binding user_id/session_id/provider/remote_id 不能为空"
.to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.metadata_json)?;
validate_ai_external_conversation_status(&input.status)?;
let conn = self.conn.lock().unwrap();
let now = now_text();
let existing = conn
.query_row(
"SELECT id, user_id, workspace_id, mnote_session_id, acp_session_id, agent_id, profile, provider, remote_conversation_id, remote_url, status, metadata_json, created_at, updated_at, deleted_at, revision
FROM ai_external_conversation_bindings
WHERE user_id = ?1 AND mnote_session_id = ?2 AND provider = ?3
LIMIT 1",
params![user_id, mnote_session_id, provider],
row_to_ai_external_conversation_binding,
)
.optional()?;
if let Some(existing) = existing {
let revision = existing.revision + 1;
let deleted_at = if input.status == "active" {
None
} else {
existing.deleted_at.or_else(|| Some(now.clone()))
};
conn.execute(
"UPDATE ai_external_conversation_bindings
SET workspace_id = ?1, acp_session_id = ?2, agent_id = ?3, profile = ?4, remote_conversation_id = ?5, remote_url = ?6, status = ?7, metadata_json = ?8, updated_at = ?9, deleted_at = ?10, revision = ?11
WHERE id = ?12",
params![
input.workspace_id,
input.acp_session_id,
input.agent_id,
input.profile,
remote_conversation_id,
input.remote_url,
input.status,
input.metadata_json,
now,
deleted_at,
revision,
existing.id
],
)?;
return Ok(AiExternalConversationBindingRecord {
id: existing.id,
user_id: existing.user_id,
workspace_id: input.workspace_id,
mnote_session_id: existing.mnote_session_id,
acp_session_id: input.acp_session_id,
agent_id: input.agent_id,
profile: input.profile,
provider: existing.provider,
remote_conversation_id: remote_conversation_id.to_string(),
remote_url: input.remote_url,
status: input.status,
metadata_json: input.metadata_json,
created_at: existing.created_at,
updated_at: now,
deleted_at,
revision,
});
}
let deleted_at = if input.status == "active" {
None
} else {
Some(now.clone())
};
let record = AiExternalConversationBindingRecord {
id: input.id.unwrap_or_else(|| new_id("aecb")),
user_id: user_id.to_string(),
workspace_id: input.workspace_id,
mnote_session_id: mnote_session_id.to_string(),
acp_session_id: input.acp_session_id,
agent_id: input.agent_id,
profile: input.profile,
provider: provider.to_string(),
remote_conversation_id: remote_conversation_id.to_string(),
remote_url: input.remote_url,
status: input.status,
metadata_json: input.metadata_json,
created_at: now.clone(),
updated_at: now,
deleted_at,
revision: 1,
};
conn.execute(
"INSERT INTO ai_external_conversation_bindings (id, user_id, workspace_id, mnote_session_id, acp_session_id, agent_id, profile, provider, remote_conversation_id, remote_url, status, metadata_json, created_at, updated_at, deleted_at, revision)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 1)",
params![
record.id,
record.user_id,
record.workspace_id,
record.mnote_session_id,
record.acp_session_id,
record.agent_id,
record.profile,
record.provider,
record.remote_conversation_id,
record.remote_url,
record.status,
record.metadata_json,
record.created_at,
record.updated_at,
record.deleted_at
],
)?;
Ok(record)
}
fn find_ai_external_conversation_binding(
&self,
user_id: &str,
workspace_id: Option<&str>,
mnote_session_id: &str,
provider: &str,
) -> Result<Option<AiExternalConversationBindingRecord>, ControlPlaneError> {
let user_id = user_id.trim();
let mnote_session_id = mnote_session_id.trim();
let provider = provider.trim();
if user_id.is_empty() || mnote_session_id.is_empty() || provider.is_empty() {
return Ok(None);
}
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT id, user_id, workspace_id, mnote_session_id, acp_session_id, agent_id, profile, provider, remote_conversation_id, remote_url, status, metadata_json, created_at, updated_at, deleted_at, revision
FROM ai_external_conversation_bindings
WHERE user_id = ?1
AND (?2 IS NULL OR workspace_id = ?2)
AND mnote_session_id = ?3
AND provider = ?4
LIMIT 1",
params![user_id, workspace_id, mnote_session_id, provider],
row_to_ai_external_conversation_binding,
)
.optional()
.map_err(ControlPlaneError::from)
}
fn mark_ai_external_conversation_binding_status(
&self,
user_id: &str,
workspace_id: Option<&str>,
mnote_session_id: &str,
provider: &str,
status: &str,
metadata_json: Option<&str>,
) -> Result<usize, ControlPlaneError> {
let user_id = user_id.trim();
let mnote_session_id = mnote_session_id.trim();
let provider = provider.trim();
validate_ai_external_conversation_status(status)?;
let metadata_json = metadata_json.unwrap_or("{}");
serde_json::from_str::<serde_json::Value>(metadata_json)?;
if user_id.is_empty() || mnote_session_id.is_empty() || provider.is_empty() {
return Ok(0);
}
let conn = self.conn.lock().unwrap();
let now = now_text();
let deleted_at = if status == "active" {
None
} else {
Some(now.clone())
};
let changed = conn.execute(
"UPDATE ai_external_conversation_bindings
SET status = ?1, metadata_json = ?2, updated_at = ?3, deleted_at = COALESCE(?4, deleted_at), revision = revision + 1
WHERE user_id = ?5
AND (?6 IS NULL OR workspace_id = ?6)
AND mnote_session_id = ?7
AND provider = ?8",
params![
status,
metadata_json,
now,
deleted_at,
user_id,
workspace_id,
mnote_session_id,
provider
],
)?;
Ok(changed)
}
fn rename_ai_runtime_session(
&self,
user_id: &str,
@@ -2195,9 +2701,9 @@ mod tests {
use super::*;
use crate::model::{
password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput,
CreatePasswordIdentityInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput,
UpsertUserUiPreferenceInput,
CreatePasswordIdentityInput, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput,
UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
UpsertSyncStateInput, UpsertUserUiPreferenceInput,
};
fn store() -> SqliteControlPlaneStore {
@@ -2716,6 +3222,95 @@ mod tests {
assert!(bob_sidebar_preferences.is_empty());
}
#[test]
fn ai_agent_profile_policy_provisions_personal_and_shared_boundaries() {
let store = store();
create_user(&store, "alice");
create_user(&store, "bob");
store
.upsert_user(UpsertUserInput {
id: Some("admin".to_string()),
email: Some("admin@example.com".to_string()),
username: "admin".to_string(),
display_name: "admin".to_string(),
role: Some("admin".to_string()),
password_hash: None,
})
.expect("admin user");
let alice_profiles = store
.ensure_ai_agent_profile_policy("alice", false)
.expect("alice profiles");
assert_eq!(alice_profiles.len(), 5);
let alice_personal = alice_profiles
.iter()
.find(|item| item.profile.profile_kind == "personal")
.expect("alice personal profile");
assert_eq!(
alice_personal.profile.owner_user_id.as_deref(),
Some("alice")
);
assert!(alice_personal.grant.can_manage_skills);
let alice_shared = alice_profiles
.iter()
.find(|item| item.profile.id == "shared_lite")
.expect("alice shared profile");
assert!(alice_shared.grant.can_run);
assert!(!alice_shared.grant.can_manage_skills);
for (profile_id, isolated_profile, display_name) in [
(
"shared_deepseek_chat",
"openclaw-deepseek-chat",
"DeepSeek Chat",
),
("shared_gemini_chat", "openclaw-gemini-chat", "Gemini Chat"),
("shared_doubao_chat", "openclaw-doubao-chat", "豆包 Chat"),
] {
let alice_chat = alice_profiles
.iter()
.find(|item| item.profile.id == profile_id)
.expect("alice shared chat profile");
assert_eq!(alice_chat.profile.isolated_profile_name, isolated_profile);
assert_eq!(alice_chat.profile.display_name, display_name);
assert!(alice_chat.grant.can_run);
assert!(!alice_chat.grant.can_manage_skills);
assert!(!alice_chat.grant.can_manage_config);
}
let bob_profiles = store
.ensure_ai_agent_profile_policy("bob", false)
.expect("bob profiles");
let bob_personal = bob_profiles
.iter()
.find(|item| item.profile.profile_kind == "personal")
.expect("bob personal profile");
assert_ne!(bob_personal.profile.id, alice_personal.profile.id);
assert!(store
.resolve_ai_agent_profile("bob", false, &alice_personal.profile.id)
.expect("resolve cross user")
.is_none());
let admin_shared = store
.resolve_ai_agent_profile("admin", true, "shared_lite")
.expect("admin shared")
.expect("admin can see shared");
assert!(admin_shared.grant.can_manage_skills);
assert!(admin_shared.grant.can_manage_config);
for profile_id in [
"shared_deepseek_chat",
"shared_gemini_chat",
"shared_doubao_chat",
] {
let admin_chat = store
.resolve_ai_agent_profile("admin", true, profile_id)
.expect("admin chat")
.expect("admin can see shared chat profile");
assert!(admin_chat.grant.can_run);
assert!(!admin_chat.grant.can_manage_skills);
assert!(!admin_chat.grant.can_manage_config);
}
}
#[test]
fn navigation_recent_is_user_scoped_upserted_and_limited_by_kind() {
let store = store();
@@ -3025,6 +3620,79 @@ mod tests {
assert!(runs.is_empty());
}
#[test]
fn ai_external_conversation_binding_is_user_scoped_and_statused() {
let store = store();
create_user(&store, "doubao_user_a");
create_user(&store, "doubao_user_b");
let binding = store
.upsert_ai_external_conversation_binding(UpsertAiExternalConversationBindingInput {
id: None,
user_id: "doubao_user_a".to_string(),
workspace_id: Some("ws_1".to_string()),
mnote_session_id: "chatonly_session_1".to_string(),
acp_session_id: Some("acp_session_1".to_string()),
agent_id: "chat_only".to_string(),
profile: "openclaw-doubao-chat".to_string(),
provider: "doubao-web".to_string(),
remote_conversation_id: "38428454119180290".to_string(),
remote_url: Some("https://www.doubao.com/chat/38428454119180290".to_string()),
status: "active".to_string(),
metadata_json: "{\"source\":\"provider.conversation.bound\"}".to_string(),
})
.expect("upsert external conversation binding");
assert_eq!(binding.status, "active");
assert_eq!(binding.remote_conversation_id, "38428454119180290");
let found = store
.find_ai_external_conversation_binding(
"doubao_user_a",
Some("ws_1"),
"chatonly_session_1",
"doubao-web",
)
.expect("find binding")
.expect("binding exists");
assert_eq!(found.id, binding.id);
assert_eq!(found.acp_session_id.as_deref(), Some("acp_session_1"));
let other_user = store
.find_ai_external_conversation_binding(
"doubao_user_b",
Some("ws_1"),
"chatonly_session_1",
"doubao-web",
)
.expect("find other user binding");
assert!(other_user.is_none());
let changed = store
.mark_ai_external_conversation_binding_status(
"doubao_user_a",
Some("ws_1"),
"chatonly_session_1",
"doubao-web",
"local_deleted",
Some("{\"reason\":\"mnote_session_deleted\"}"),
)
.expect("mark local deleted");
assert_eq!(changed, 1);
let deleted = store
.find_ai_external_conversation_binding(
"doubao_user_a",
Some("ws_1"),
"chatonly_session_1",
"doubao-web",
)
.expect("find deleted binding")
.expect("binding remains auditable");
assert_eq!(deleted.status, "local_deleted");
assert!(deleted.deleted_at.is_some());
}
#[test]
fn session_lookup_resolves_active_user_by_token_hash() {
let store = store();