收口 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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user