- add admin access-policy UI and local access control surfaces - add local markdown conflict resolution UI and smoke coverage - add ACP local agent changed-files audit scaffold and read-only write guard - document current P0-P2 checklist progress and verification evidence
261 lines
8.2 KiB
Rust
261 lines
8.2 KiB
Rust
use crate::kernel::WorkspaceSourceKind;
|
|
use serde::de;
|
|
use serde::{Deserialize, Deserializer, Serialize};
|
|
use serde_json::Value;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AiSession {
|
|
pub session_id: String,
|
|
pub workspace_id: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub document_id: Option<String>,
|
|
pub owner: AiRuntimeOwner,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum AiRuntimeOwner {
|
|
RustWebHermes,
|
|
CompatReactIsland,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AiToolCall {
|
|
pub call_id: String,
|
|
pub tool_name: String,
|
|
pub args: Value,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AiEvent {
|
|
pub event_id: String,
|
|
pub session_id: String,
|
|
pub kind: AiEventKind,
|
|
#[serde(default)]
|
|
pub payload: Value,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum AiEventKind {
|
|
SessionCreated,
|
|
ToolCallPlanned,
|
|
ToolCallApplied,
|
|
StructuredWriteCompleted,
|
|
Error,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AiStructuredWriteResult {
|
|
pub ok: bool,
|
|
pub write_kind: AiStructuredWriteKind,
|
|
pub command_name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub target_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub revision: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum AiStructuredWriteKind {
|
|
SummaryNode,
|
|
AiNoteNode,
|
|
ReferenceEdge,
|
|
PageBody,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum AiAccessPermissionLevel {
|
|
Admin,
|
|
ReadOnly,
|
|
ReadWrite,
|
|
SharedRead,
|
|
SharedWrite,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AiShareContext {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub share_id: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub mode: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AiAccessScope {
|
|
pub user_id: String,
|
|
pub workspace_id: String,
|
|
pub session_id: String,
|
|
pub source_kind: WorkspaceSourceKind,
|
|
pub permission_level: AiAccessPermissionLevel,
|
|
#[serde(default)]
|
|
pub allowed_roots: Vec<String>,
|
|
#[serde(default)]
|
|
pub allowed_file_paths: Vec<String>,
|
|
#[serde(default)]
|
|
pub allowed_resource_ids: Vec<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub share_context: Option<AiShareContext>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct AiAccessScopeRaw {
|
|
user_id: String,
|
|
workspace_id: String,
|
|
session_id: String,
|
|
source_kind: WorkspaceSourceKind,
|
|
permission_level: AiAccessPermissionLevel,
|
|
#[serde(default)]
|
|
allowed_roots: Vec<String>,
|
|
#[serde(default)]
|
|
allowed_file_paths: Vec<String>,
|
|
#[serde(default)]
|
|
allowed_resource_ids: Vec<String>,
|
|
#[serde(default)]
|
|
share_context: Option<AiShareContext>,
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for AiAccessScope {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let raw = AiAccessScopeRaw::deserialize(deserializer)?;
|
|
let scope = Self {
|
|
user_id: raw.user_id,
|
|
workspace_id: raw.workspace_id,
|
|
session_id: raw.session_id,
|
|
source_kind: raw.source_kind,
|
|
permission_level: raw.permission_level,
|
|
allowed_roots: raw.allowed_roots,
|
|
allowed_file_paths: raw.allowed_file_paths,
|
|
allowed_resource_ids: raw.allowed_resource_ids,
|
|
share_context: raw.share_context,
|
|
};
|
|
scope.validate().map_err(de::Error::custom)?;
|
|
Ok(scope)
|
|
}
|
|
}
|
|
|
|
impl AiAccessScope {
|
|
pub fn validate(&self) -> Result<(), String> {
|
|
reject_blank("userId", &self.user_id)?;
|
|
reject_blank("workspaceId", &self.workspace_id)?;
|
|
reject_blank("sessionId", &self.session_id)?;
|
|
reject_blank_vec("allowedRoots", &self.allowed_roots)?;
|
|
reject_blank_vec("allowedFilePaths", &self.allowed_file_paths)?;
|
|
reject_blank_vec("allowedResourceIds", &self.allowed_resource_ids)?;
|
|
if let Some(share_context) = &self.share_context {
|
|
if let Some(share_id) = &share_context.share_id {
|
|
reject_blank("shareContext.shareId", share_id)?;
|
|
}
|
|
if let Some(mode) = &share_context.mode {
|
|
reject_blank("shareContext.mode", mode)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn reject_blank(field: &str, value: &str) -> Result<(), String> {
|
|
if value.trim().is_empty() {
|
|
return Err(format!("{field} must not be blank"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn reject_blank_vec(field: &str, values: &[String]) -> Result<(), String> {
|
|
for value in values {
|
|
reject_blank(field, value)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::WorkspaceSourceKind;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn ai_access_scope_uses_camel_case_payload() {
|
|
let scope: AiAccessScope = serde_json::from_value(json!({
|
|
"userId": "user_1",
|
|
"workspaceId": "workspace_1",
|
|
"sessionId": "session_1",
|
|
"sourceKind": "local_folder",
|
|
"permissionLevel": "read_write",
|
|
"allowedRoots": ["file:///mnt/Data1T/Mnote_data/users/user_1"],
|
|
"allowedFilePaths": ["file:///mnt/Data1T/Mnote_data/users/user_1/我的空间/README.md"],
|
|
"allowedResourceIds": ["local-md:README.md"],
|
|
"shareContext": {
|
|
"shareId": "share_1",
|
|
"mode": "shared_write"
|
|
}
|
|
}))
|
|
.expect("scope");
|
|
|
|
assert_eq!(scope.user_id, "user_1");
|
|
assert_eq!(scope.workspace_id, "workspace_1");
|
|
assert_eq!(scope.session_id, "session_1");
|
|
assert_eq!(scope.source_kind, WorkspaceSourceKind::LocalFolder);
|
|
assert_eq!(scope.permission_level, AiAccessPermissionLevel::ReadWrite);
|
|
assert_eq!(scope.allowed_roots.len(), 1);
|
|
assert_eq!(scope.allowed_file_paths.len(), 1);
|
|
assert_eq!(scope.allowed_resource_ids, vec!["local-md:README.md"]);
|
|
assert_eq!(
|
|
scope
|
|
.share_context
|
|
.as_ref()
|
|
.and_then(|value| value.share_id.as_deref()),
|
|
Some("share_1")
|
|
);
|
|
|
|
let serialized = serde_json::to_value(&scope).expect("serialized");
|
|
assert_eq!(serialized["userId"], "user_1");
|
|
assert_eq!(serialized["permissionLevel"], "read_write");
|
|
assert_eq!(
|
|
serialized["allowedRoots"][0],
|
|
"file:///mnt/Data1T/Mnote_data/users/user_1"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ai_access_scope_rejects_blank_required_fields_and_paths() {
|
|
let error = serde_json::from_value::<AiAccessScope>(json!({
|
|
"userId": " ",
|
|
"workspaceId": "workspace_1",
|
|
"sessionId": "session_1",
|
|
"sourceKind": "local_folder",
|
|
"permissionLevel": "read_only",
|
|
"allowedRoots": ["file:///mnt/Data1T/Mnote_data/users/user_1"],
|
|
"allowedFilePaths": [],
|
|
"allowedResourceIds": []
|
|
}))
|
|
.expect_err("blank user id should be rejected");
|
|
assert!(error.to_string().contains("userId"));
|
|
|
|
let error = serde_json::from_value::<AiAccessScope>(json!({
|
|
"userId": "user_1",
|
|
"workspaceId": "workspace_1",
|
|
"sessionId": "session_1",
|
|
"sourceKind": "local_folder",
|
|
"permissionLevel": "read_only",
|
|
"allowedRoots": [" "],
|
|
"allowedFilePaths": [],
|
|
"allowedResourceIds": []
|
|
}))
|
|
.expect_err("blank allowed root should be rejected");
|
|
assert!(error.to_string().contains("allowedRoots"));
|
|
}
|
|
}
|