feat(sync): 添加 Vault 所有的用户 Skill 记录
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
//! Preference schemas contain portable values only; no paths, permissions, providers or secrets.
|
||||
//! Portable settings may declare required permissions, but never carry device grants, paths or secrets.
|
||||
use crate::workspace::{HostError, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
@@ -17,6 +17,27 @@ struct Persona {
|
||||
dialogue_pairs: Vec<DialoguePair>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct UserSkillRetrieval {
|
||||
top_k: u8,
|
||||
rerank: bool,
|
||||
citation: bool,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct UserSkill {
|
||||
version: u64,
|
||||
name: String,
|
||||
description: String,
|
||||
prompt: String,
|
||||
tools: Vec<String>,
|
||||
permissions: Vec<String>,
|
||||
retrieval: UserSkillRetrieval,
|
||||
required_capabilities: Vec<String>,
|
||||
created_at_ms: i64,
|
||||
updated_at_ms: i64,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct Layout {
|
||||
primary_expanded: bool,
|
||||
@@ -103,6 +124,20 @@ fn markdown(value: &Markdown) -> bool {
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"_+-".contains(&b))
|
||||
}
|
||||
fn unique_bounded(values: &[String], max_items: usize, max_chars: usize) -> bool {
|
||||
values.len() <= max_items
|
||||
&& values.iter().all(|value| {
|
||||
!value.is_empty()
|
||||
&& value.chars().count() <= max_chars
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
|
||||
})
|
||||
&& values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(index, value)| !values[..index].contains(value))
|
||||
}
|
||||
pub fn validate(kind: &str, value: &Value) -> Result<()> {
|
||||
let valid = match kind {
|
||||
"persona" => {
|
||||
@@ -121,6 +156,56 @@ pub fn validate(kind: &str, value: &Value) -> Result<()> {
|
||||
(200.0..=520.0).contains(&value.workspace_width)
|
||||
&& (200.0..=520.0).contains(&value.chat_width)
|
||||
}
|
||||
"user_skill" => {
|
||||
let value: UserSkill = decode(value)?;
|
||||
let _ = (value.retrieval.rerank, value.retrieval.citation);
|
||||
let timestamp = |time: i64| (0..=253402300799999).contains(&time);
|
||||
let known_permissions = [
|
||||
"notes.read",
|
||||
"notes.search",
|
||||
"notes.write",
|
||||
"notes.delete",
|
||||
"tasks.read",
|
||||
"tasks.write",
|
||||
"attachments.read",
|
||||
"network.request",
|
||||
"secrets.use",
|
||||
"ui.command",
|
||||
"ui.settings",
|
||||
"ui.sidebar",
|
||||
];
|
||||
let known_capabilities = [
|
||||
"chat",
|
||||
"vision",
|
||||
"tool_calling",
|
||||
"reasoning",
|
||||
"streaming",
|
||||
"structured_output",
|
||||
"embedding",
|
||||
"transcription",
|
||||
"speaker_matching",
|
||||
];
|
||||
value.version <= 9007199254740991
|
||||
&& !value.name.trim().is_empty()
|
||||
&& value.name.chars().count() <= 128
|
||||
&& value.description.chars().count() <= 2000
|
||||
&& value.prompt.chars().count() <= 64000
|
||||
&& unique_bounded(&value.tools, 64, 128)
|
||||
&& unique_bounded(&value.permissions, 32, 64)
|
||||
&& value
|
||||
.permissions
|
||||
.iter()
|
||||
.all(|v| known_permissions.contains(&v.as_str()))
|
||||
&& unique_bounded(&value.required_capabilities, 16, 64)
|
||||
&& value
|
||||
.required_capabilities
|
||||
.iter()
|
||||
.all(|v| known_capabilities.contains(&v.as_str()))
|
||||
&& (1..=100).contains(&value.retrieval.top_k)
|
||||
&& timestamp(value.created_at_ms)
|
||||
&& timestamp(value.updated_at_ms)
|
||||
&& value.updated_at_ms >= value.created_at_ms
|
||||
}
|
||||
"theme_settings" => {
|
||||
let value: Theme = decode(value)?;
|
||||
let _ = value.headings.custom;
|
||||
@@ -181,6 +266,37 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn user_skill_schema_is_portable_strict_and_default_synced() {
|
||||
let id = "user_skill_00000000000000000000000000000001";
|
||||
let path = crate::records::path_for("user_skill", id).unwrap();
|
||||
let data = json!({"version":1,"name":"Review","description":"Check a note","prompt":"Be precise.","tools":["notes.read"],"permissions":["notes.read"],"retrieval":{"top_k":10,"rerank":true,"citation":true},"required_capabilities":["chat","tool_calling"],"created_at_ms":1,"updated_at_ms":2});
|
||||
let record = json!({"schema":1,"kind":"user_skill","id":id,"data":data});
|
||||
crate::records::validate(&path, &serde_json::to_vec(&record).unwrap()).unwrap();
|
||||
assert!(crate::records::allowed(&path));
|
||||
assert!(crate::sync_scope::OptionalScope::default().includes(&path));
|
||||
for field in [
|
||||
"api_key",
|
||||
"package_path",
|
||||
"enabled",
|
||||
"device_grants",
|
||||
"environment",
|
||||
] {
|
||||
let mut bad = data.clone();
|
||||
bad[field] = json!("private");
|
||||
assert_eq!(
|
||||
validate("user_skill", &bad).unwrap_err().code,
|
||||
"RECORD_SCHEMA_INVALID"
|
||||
);
|
||||
}
|
||||
let mut bad = data.clone();
|
||||
bad["permissions"] = json!(["notes.read", "unknown.permission"]);
|
||||
assert_eq!(
|
||||
validate("user_skill", &bad).unwrap_err().code,
|
||||
"RECORD_DATA_INVALID"
|
||||
);
|
||||
assert!(crate::records::path_for("user_skill", "user_skill_ABCD").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn layout_schema_limits_widths_and_rejects_device_fields() {
|
||||
let good = json!({"primaryExpanded":true,"workspaceWidth":400.5,"chatWidth":320});
|
||||
let path = crate::records::path_for("layout", "sidebars").unwrap();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Versioned logical records: explicit fields only, never raw application databases/config.
|
||||
use crate::workspace::{HostError, Result, Workspace};
|
||||
use rusqlite::OptionalExtension;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -32,6 +33,18 @@ pub fn path(id: &str) -> Result<String> {
|
||||
}
|
||||
Ok(format!("opennexus-records/v1/tasks/{id}.json"))
|
||||
}
|
||||
|
||||
pub fn user_skill_path(id: &str) -> Result<String> {
|
||||
if !id.starts_with("user_skill_")
|
||||
|| id.len() != 43
|
||||
|| !id[11..]
|
||||
.bytes()
|
||||
.all(|v| v.is_ascii_digit() || (b'a'..=b'f').contains(&v))
|
||||
{
|
||||
return Err(HostError::new("RECORD_ID_INVALID"));
|
||||
}
|
||||
Ok(format!("opennexus-records/v1/user-skills/{id}.json"))
|
||||
}
|
||||
pub fn path_for(kind: &str, id: &str) -> Result<String> {
|
||||
match (kind, id) {
|
||||
("task", id) => path(id),
|
||||
@@ -41,6 +54,7 @@ pub fn path_for(kind: &str, id: &str) -> Result<String> {
|
||||
("persona", "default") => Ok("opennexus-records/v1/persona/default.json".into()),
|
||||
("layout", "sidebars") => Ok("opennexus-records/v1/layout/sidebars.json".into()),
|
||||
("preferences", "editor") => Ok("opennexus-records/v1/preferences/editor.json".into()),
|
||||
("user_skill", id) => user_skill_path(id),
|
||||
_ => Err(HostError::new("RECORD_ID_INVALID")),
|
||||
}
|
||||
}
|
||||
@@ -57,10 +71,17 @@ pub fn allowed(path_value: &str) -> bool {
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
path_value
|
||||
if path_value
|
||||
.strip_prefix("opennexus-records/v1/tasks/")
|
||||
.and_then(|v| v.strip_suffix(".json"))
|
||||
.is_some_and(|id| path(id).is_ok())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
path_value
|
||||
.strip_prefix("opennexus-records/v1/user-skills/")
|
||||
.and_then(|v| v.strip_suffix(".json"))
|
||||
.is_some_and(|id| user_skill_path(id).is_ok())
|
||||
}
|
||||
pub fn validate(path_value: &str, content: &[u8]) -> Result<Record> {
|
||||
if content.len() > 1024 * 1024 {
|
||||
@@ -154,23 +175,31 @@ impl Workspace {
|
||||
))
|
||||
}
|
||||
pub fn record_list(&mut self, offset: usize, limit: usize) -> Result<Value> {
|
||||
self.record_list_kind("task", offset, limit)
|
||||
}
|
||||
pub fn record_list_kind(&mut self, kind: &str, offset: usize, limit: usize) -> Result<Value> {
|
||||
if limit == 0 || limit > 1000 {
|
||||
return Err(HostError::new("RECORD_LIMIT_INVALID"));
|
||||
}
|
||||
let prefix = match kind {
|
||||
"task" => "opennexus-records/v1/tasks/",
|
||||
"user_skill" => "opennexus-records/v1/user-skills/",
|
||||
_ => return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED")),
|
||||
};
|
||||
let paths = self
|
||||
.sync_paths()?
|
||||
.into_iter()
|
||||
.filter(|path| path.starts_with("opennexus-records/v1/tasks/") && allowed(path))
|
||||
.filter(|path| path.starts_with(prefix) && allowed(path))
|
||||
.collect::<Vec<_>>();
|
||||
let total = paths.len();
|
||||
let mut items = Vec::new();
|
||||
let mut bytes = 0;
|
||||
for path in paths.into_iter().skip(offset).take(limit) {
|
||||
let id = path
|
||||
.strip_prefix("opennexus-records/v1/tasks/")
|
||||
.strip_prefix(prefix)
|
||||
.and_then(|v| v.strip_suffix(".json"))
|
||||
.ok_or_else(|| HostError::new("RECORD_ID_INVALID"))?;
|
||||
if let Some(value) = self.record_get(id)? {
|
||||
if let Some(value) = self.record_get_kind(kind, id)? {
|
||||
let size = serde_json::to_vec(&value)
|
||||
.map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))?
|
||||
.len();
|
||||
@@ -196,8 +225,27 @@ impl Workspace {
|
||||
}
|
||||
let bytes = self.payload(operation, &[])?;
|
||||
let record = validate(path, &bytes)?;
|
||||
let expected = receipt["result"]["expected"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.or(self
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT expected FROM journal WHERE operation_id=?1",
|
||||
[operation],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?)
|
||||
.or(self
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT hash FROM file_ops WHERE id=?1",
|
||||
[operation],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?);
|
||||
Ok(Some(
|
||||
json!({"record":record,"hash":crate::workspace::hash(&bytes),"deleted":receipt["result"]["deleted"],"state":receipt["state"]}),
|
||||
json!({"record":record,"hash":crate::workspace::hash(&bytes),"expected":expected,"deleted":receipt["result"]["deleted"],"state":receipt["state"]}),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,9 +379,13 @@ impl Workspace {
|
||||
.optional()?;
|
||||
value
|
||||
.map(|(state, result)| {
|
||||
let result: Option<Entry> = result
|
||||
let result: Option<serde_json::Value> = result
|
||||
.map(|value| {
|
||||
serde_json::from_str(&value).map_err(|_| HostError::new("DATABASE_ERROR"))
|
||||
let parsed: serde_json::Value = serde_json::from_str(&value)
|
||||
.map_err(|_| HostError::new("DATABASE_ERROR"))?;
|
||||
serde_json::from_value::<Entry>(parsed.clone())
|
||||
.map_err(|_| HostError::new("DATABASE_ERROR"))?;
|
||||
Ok::<serde_json::Value, HostError>(parsed)
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(serde_json::json!({"operation_id":operation_id,"state":state,"result":result}))
|
||||
@@ -690,7 +694,11 @@ impl Workspace {
|
||||
})
|
||||
},
|
||||
)?;
|
||||
let result = serde_json::to_string(&entry).map_err(|_| HostError::new("DATABASE_ERROR"))?;
|
||||
let mut result =
|
||||
serde_json::to_value(&entry).map_err(|_| HostError::new("DATABASE_ERROR"))?;
|
||||
result["expected"] = serde_json::json!(expected);
|
||||
let result =
|
||||
serde_json::to_string(&result).map_err(|_| HostError::new("DATABASE_ERROR"))?;
|
||||
tx.execute(
|
||||
"UPDATE operations SET state='committed',result=?2 WHERE operation_id=?1",
|
||||
params![operation_id, result],
|
||||
@@ -1002,11 +1010,15 @@ impl Workspace {
|
||||
} else {
|
||||
result.deleted = true;
|
||||
}
|
||||
let mut operation_result =
|
||||
serde_json::to_value(&result).map_err(|_| HostError::new("DATABASE_ERROR"))?;
|
||||
operation_result["expected"] = serde_json::json!(expected);
|
||||
tx.execute(
|
||||
"UPDATE operations SET state='committed',result=?2 WHERE operation_id=?1",
|
||||
params![
|
||||
id,
|
||||
serde_json::to_string(&result).map_err(|_| HostError::new("DATABASE_ERROR"))?
|
||||
serde_json::to_string(&operation_result)
|
||||
.map_err(|_| HostError::new("DATABASE_ERROR"))?
|
||||
],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
|
||||
@@ -103,6 +103,57 @@ pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result<Value, String> {
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.user_skills.list" => {
|
||||
let p: List = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
ws.record_list_kind("user_skill", p.offset, p.limit)
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.user_skills.get" => {
|
||||
let p: RecordRead = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
ws.record_get_kind("user_skill", &p.id)
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.user_skills.write" => {
|
||||
let p: RecordWrite = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
if p.record["kind"] != "user_skill" {
|
||||
return Err("RECORD_SCHEMA_UNSUPPORTED".into());
|
||||
}
|
||||
let path =
|
||||
crate::records::path_for("user_skill", p.record["id"].as_str().unwrap_or(""))
|
||||
.map_err(|e| e.code)?;
|
||||
let bytes = serde_json::to_vec(&p.record).map_err(|_| "RECORD_SCHEMA_INVALID")?;
|
||||
ws.write_operation(&path, &p.expected, &bytes, "local", &p.operation_id)
|
||||
.map_err(|e| e.code)?;
|
||||
ws.record_operation(&p.operation_id)
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.user_skills.delete" => {
|
||||
let p: RecordDelete = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
let path = crate::records::user_skill_path(&p.id).map_err(|e| e.code)?;
|
||||
ws.mutate_operation("delete", &path, "", &p.expected, &p.operation_id)
|
||||
.map_err(|e| e.code)?;
|
||||
ws.record_operation(&p.operation_id)
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.user_skills.operation" => {
|
||||
let p: Operation = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
let value = ws.record_operation(&p.operation_id).map_err(|e| e.code)?;
|
||||
if value
|
||||
.as_ref()
|
||||
.is_some_and(|receipt| receipt["record"]["kind"] != "user_skill")
|
||||
{
|
||||
return Err("RECORD_OPERATION_DENIED".into());
|
||||
}
|
||||
Ok(value.unwrap_or(Value::Null))
|
||||
}
|
||||
"workspace.records.get" => {
|
||||
let p: RecordRead = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
@@ -234,6 +285,54 @@ pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result<Value, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn user_skills_are_vault_bound_listed_and_deleted_as_logical_records() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
let id = "user_skill_00000000000000000000000000000001";
|
||||
let record = json!({"schema":1,"kind":"user_skill","id":id,"data":{"version":1,"name":"Review","description":"","prompt":"Review carefully","tools":["notes.read"],"permissions":["notes.read"],"retrieval":{"top_k":10,"rerank":true,"citation":true},"required_capabilities":["chat"],"created_at_ms":1,"updated_at_ms":1}});
|
||||
let operation_id = uuid::Uuid::new_v4().to_string();
|
||||
let write = json!({"rpc":"workspace.user_skills.write","params":{"vault_id":ws.vault_id,"record":record,"expected":"","operation_id":operation_id}});
|
||||
let receipt = dispatch(&mut ws, &write).unwrap();
|
||||
assert_eq!(receipt["expected"], "");
|
||||
assert_eq!(receipt["deleted"], false);
|
||||
assert_eq!(dispatch(&mut ws, &write).unwrap(), receipt);
|
||||
let list = json!({"rpc":"workspace.user_skills.list","params":{"vault_id":ws.vault_id,"offset":0,"limit":100}});
|
||||
let listed = dispatch(&mut ws, &list).unwrap();
|
||||
assert_eq!(listed["total"], 1);
|
||||
assert_eq!(listed["items"][0]["record"], record);
|
||||
drop(ws);
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
let delete = json!({"rpc":"workspace.user_skills.delete","params":{"vault_id":ws.vault_id,"id":id,"expected":receipt["hash"],"operation_id":uuid::Uuid::new_v4().to_string()}});
|
||||
let deleted = dispatch(&mut ws, &delete).unwrap();
|
||||
assert_eq!(deleted["deleted"], true);
|
||||
assert_eq!(dispatch(&mut ws, &delete).unwrap(), deleted);
|
||||
assert_eq!(dispatch(&mut ws, &list).unwrap()["total"], 0);
|
||||
assert_eq!(ws.pending_count().unwrap(), 2);
|
||||
let persona = json!({"schema":1,"kind":"persona","id":"default","data":{"version":1,"name":"private","system_prompt":"","dialogue_pairs":[]}});
|
||||
let persona_operation = uuid::Uuid::new_v4().to_string();
|
||||
ws.write_operation(
|
||||
"opennexus-records/v1/persona/default.json",
|
||||
"",
|
||||
&serde_json::to_vec(&persona).unwrap(),
|
||||
"local",
|
||||
&persona_operation,
|
||||
)
|
||||
.unwrap();
|
||||
let smuggle = json!({"rpc":"workspace.user_skills.operation","params":{"vault_id":ws.vault_id,"operation_id":persona_operation}});
|
||||
assert_eq!(
|
||||
dispatch(&mut ws, &smuggle).unwrap_err(),
|
||||
"RECORD_OPERATION_DENIED"
|
||||
);
|
||||
let mut denied = write;
|
||||
denied["params"]["vault_id"] = json!("other-vault");
|
||||
denied["params"]["operation_id"] = json!(uuid::Uuid::new_v4().to_string());
|
||||
assert_eq!(
|
||||
dispatch(&mut ws, &denied).unwrap_err(),
|
||||
"VAULT_PERMISSION_CHANGED"
|
||||
);
|
||||
assert_eq!(ws.pending_count().unwrap(), 3);
|
||||
}
|
||||
#[test]
|
||||
fn persona_is_vault_bound_durable_and_cas_protected() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user