feat(sync): 添加 Vault 所有的用户 Skill 记录

This commit is contained in:
2026-09-09 08:39:14 +08:00
parent bdd1543a4d
commit b0783c9356
25 changed files with 1507 additions and 29 deletions
+117 -1
View File
@@ -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();