feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45

Merged
Kronecker merged 151 commits from feat/phase3-completion into main 2026-09-10 01:16:31 +08:00
14 changed files with 1036 additions and 26 deletions
Showing only changes of commit bafec01c41 - Show all commits
+17 -5
View File
@@ -8,7 +8,7 @@ Workspace schema 3 新增持久绑定、上传作业与远端 heads。首次向
Rust HTTP 客户端已实现握手、登录、空远端复核、1 MiB 分块上传、查询 offset 续传、complete 和 Revision 提交。默认 HTTPS,测试 HTTP 必须显式启用;不跟随重定向,令牌不进入 URL,响应有大小限制。真实本地 HTTP Fixture 已验证 20 次编辑形成 20 个正确远端基线、同一提交重复 100 次无重复 revision。此 Fixture 使用 SQLite/磁盘对象,不替代生产 PostgreSQL/MinIO 验收。
此增量尚未开放桌面 Sync capabilityStronghold 会话接入、冲突解决 UI、附件故障矩阵及数据分类仍在实施
桌面 Sync capability 已接入 Stronghold 会话、冲突解决 UI、附件续传与逻辑数据分类;本地真实 HTTP Fixture 只证明客户端协议行为,不替代 PostgreSQL/MinIO、备份恢复和发布包验收
Workspace schema 4 增加持久 inbox、分页 boundary 和冲突记录,并为文件移动/删除日志记录 local/remote 来源。下载先验证长度与摘要,再登记 inbox;文件操作通过 Workspace journal 应用后才推进 cursor。进程在文件提交与 cursor 提交之间重启时使用操作回执去重。远端新增保留 file_id,远端移动/删除不回流 outbox。本机历史提交回放仅确认操作,不回退新编辑。本地待上传修改或摘要不符产生冲突,保存本地与远端内容引用后推进接收游标;同一文件保留最新待处理冲突,旧冲突记录仍保留。空本地库可以绑定已有远端并仅拉取。
@@ -63,7 +63,7 @@ Host 在写入 journal、捕获外部修改和上传前验证记录;未知字
桌面 Task CRUD 经 Host records broker,操作重放返回原始记录且同 ID 不同字段拒绝;Core 不以全局 SQLite 作为任务来源。已明确归属于当前 Vault 的旧 Task 表在首次访问时逐条迁移,完成后设置所有权标记,来源表保留;未分配 Vault 的全局旧数据不猜测归属。文件身份采纳时,任务链接通过 Host 别名解析,并在上传队列物化前将规范化引用写成新的逻辑记录。
用户 Skill 的独立白名单与适配器见下文;可选对话等其他类别仍须逐类定义,不得用复制任意 JSON/SQLite 代替。
用户 Skill 与六类可选数据的独立白名单见下文;所有类别均使用逐字段逻辑记录,不得用复制任意 JSON/SQLite 代替。
## 主题与编辑器偏好记录 v1
@@ -108,10 +108,22 @@ Core 通过绑定 Vault 的 workspace.persona.get/write RPC 读写;Host 先校
逻辑记录的“另存副本并采用远端”可将原始记录内容导出为 `attachments/*.txt`;文本副本作为普通附件同步,拥有独立 file_id,不作为活动设置自动应用。副本目标必须属于同步白名单;若目标本身位于记录命名空间,还必须与原始记录 kind/id/schema 一致。这些检查在持久化冲突决定之前完成,因此无效路径不占用该冲突的解决选择。
## 可选记录范围(Workspace schema 11
## 对话、Agent、Provider 与安装清单记录 v1
人设与布局默认仅保存在本机。用户在同步设置中分别选择 persona/layout 后才参与传输;选择保存在本机 Vault 数据库,不随逻辑记录上传。已有活动绑定时不得直接改变范围,必须解除绑定、调整选择、重新预览合并;预览摘要包含范围,旧范围的预览不能用于绑定新范围
对话使用 `opennexus-records/v1/conversations/conversation_<32位小写十六进制>.json`。data 只包含 title、active_leaf、created_at_ms、updated_at_ms 和 messages;每条 message 只包含 message_id、parent_message_id、role、content、thinking、attachments、created_at_ms。消息 ID 唯一,父节点和 active_leaf 必须引用本记录中的消息;role 限 user/assistant/system,附件只保存可移植的相对引用。单条正文或思考最多 256 KiB,记录整体仍受 1 MiB 上限约束
已结束的 Agent 历史使用 `opennexus-records/v1/agent-history/agent_run_<32位小写十六进制>.json`。data 只包含 status、input、output、model、skill_id、error_code、error_message、token_usage、created_at_ms、updated_at_msstatus 仅允许 completed/failed/cancelled。queued、running、waiting_permission 不可编码,目标设备不会恢复运行、继续工具调用或继承许可。
一般 Provider 参数使用 `opennexus-records/v1/provider-settings/provider_<32位小写十六进制>.json`。data 只包含 version、provider_type、name、base_url、default_model、enabled、capabilitiesbase_url 必须是无用户信息、查询或 fragment 的 HTTP(S) URL。credential_id、API Key、Header、令牌、请求秘密和设备凭据状态不是合法字段,目标设备需单独配置凭据。
安装清单使用 `opennexus-records/v1/extension-installations/extension_<32位小写十六进制>.json`。data 恰好包含 package_kind、package_id、source、version、sha256package_kind 限 skill/plugin/theme。该记录只表达期望安装项,不包含 enabled、trusted、permissions、granted_permissions、device_grants、package_path 或本机托管目录。目标设备必须从 source 重新获取、验证 sha256、执行本机安装审查并重新授权,逻辑记录本身不能启用扩展。
以上四类与人设、布局均默认关闭。Rust 在 journal 前、远端应用前和上传提交前重复验证信封、路径、schema 与 data;未知字段、非法 ID、超限内容和 schema≠1 均拒绝。正文属于用户主动选择同步的内容,字段白名单不按关键字删除正文;秘密仍不得进入专用配置字段、附件外普通文件或任意数据库副本。
## 可选记录范围(Workspace schema 13
人设、布局、对话、已结束 Agent 历史、一般 Provider 参数和安装清单默认仅保存在本机。用户在同步设置中逐类选择后才参与传输;选择保存在本机 Vault 数据库,不随逻辑记录上传。已有活动绑定时不得直接改变范围,必须解除绑定、调整选择、重新预览合并;预览摘要包含范围,旧范围的预览不能用于绑定新范围。
本地扫描、外部删除发现、首次对账和 outbox 捕获均排除未选择的类别。旧版本残留的可选上传作业会被归档,提交前再次核验范围。关闭选项不删除本地文件或远端历史,也不产生远端 delete。接收未选择的类别时只保存 revision 元数据并推进已处理游标,既不获取对象正文,也不应用记录或制造冲突。已有未完成的排除类别冲突决定保留,但不继续应用;需重新对账处理。
重新开启经新绑定获取固定远端快照,因此能够获取先前已跳过游标的记录,匹配的现有文件沿用初始对账身份与 CAS 规则。普通 Markdown、允许附件、任务及默认设置类别保持原范围;手动导出的 attachments/*.txt 是普通附件,不是自动生效的人设。此版只定义已实现的人设和布局选择,不把尚无适配器的对话、Agent 历史或 Provider 参数视为已支持
重新开启经新绑定获取固定远端快照,因此能够获取先前已跳过游标的记录,匹配的现有文件沿用初始对账身份与 CAS 规则。普通 Markdown、允许附件、任务、用户 Skill、主题和编辑器偏好保持默认同步;手动导出的 attachments/*.txt 是普通附件,不是自动生效的人设。Workspace 从 schema 12 升级前创建完整可读 SQLite 备份,保留旧 persona/layout 选择,新类别全部初始化为关闭;schema 13 以上由旧客户端以 `SCHEMA_INCOMPATIBLE` 拒绝打开,不能降级写入
@@ -24,4 +24,4 @@ python scripts/phase3-production-acceptance.py `
报告目录包含 `summary.json``case-manifest.json``junit.xml``cases/<ID>.json` 和脱敏的 `logs/<ID>.log`。摘要记录 commit、各锁文件 SHA-256、配置摘要与已提供安装产物摘要。日志将仓库、数据根、报告根、用户主目录和配置声明的秘密值替换为占位符,并限制为 10 MiB。报告目录必须为空,避免单例复跑覆盖原始证据。
当前 runner 与失败闭合行为已实现,A-02/A-03 Sidecar、B-01/B-02 凭据、D-01 扩展包以及 S-01/S-02/S-03 Sync 客户端 driver 已登记;其余 22 个生产验收 ID 尚未登记,运行时会生成 `NOT_IMPLEMENTED` 证据并退出 1。这用于阻止误报,不是这些用例的验收通过。
当前 runner 与失败闭合行为已实现,A-02/A-03 Sidecar、B-01/B-02 凭据、D-01 扩展包以及 S-01/S-02/S-03/S-08 Sync 客户端 driver 已登记;其余 21 个生产验收 ID 尚未登记,运行时会生成 `NOT_IMPLEMENTED` 证据并退出 1。这用于阻止误报,不是这些用例的验收通过。
@@ -101,6 +101,60 @@ struct Preferences {
markdown: Markdown,
presets: Vec<Preset>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ConversationMessage {
message_id: String,
parent_message_id: Option<String>,
role: String,
content: String,
thinking: Option<String>,
attachments: Vec<String>,
created_at_ms: i64,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Conversation {
title: String,
active_leaf: Option<String>,
created_at_ms: i64,
updated_at_ms: i64,
messages: Vec<ConversationMessage>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AgentHistory {
status: String,
input: String,
output: Option<String>,
model: String,
skill_id: Option<String>,
error_code: Option<String>,
error_message: Option<String>,
token_usage: u64,
created_at_ms: i64,
updated_at_ms: i64,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ProviderSettings {
version: u64,
provider_type: String,
name: String,
base_url: Option<String>,
default_model: Option<String>,
enabled: bool,
capabilities: Vec<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ExtensionInstallation {
package_kind: String,
package_id: String,
source: String,
version: String,
sha256: String,
}
fn decode<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T> {
serde_json::from_value(value.clone()).map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))
}
@@ -138,6 +192,44 @@ fn unique_bounded(values: &[String], max_items: usize, max_chars: usize) -> bool
.enumerate()
.all(|(index, value)| !values[..index].contains(value))
}
fn bounded_identifier(value: &str, max_chars: usize) -> bool {
!value.is_empty()
&& value.chars().count() <= max_chars
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._:-".contains(&b))
}
fn portable_reference(value: &str, max_chars: usize) -> bool {
!value.is_empty()
&& value.chars().count() <= max_chars
&& !value.starts_with('/')
&& !value.contains(['\\', '\0'])
&& value
.split('/')
.all(|part| !part.is_empty() && part != "." && part != "..")
}
fn model_identifier(value: &str) -> bool {
!value.is_empty()
&& value.chars().count() <= 256
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._:/+-".contains(&byte))
}
fn timestamp(value: i64) -> bool {
(0..=253402300799999).contains(&value)
}
fn safe_base_url(value: &str) -> bool {
let Some(rest) = value
.strip_prefix("https://")
.or_else(|| value.strip_prefix("http://"))
else {
return false;
};
!rest.is_empty()
&& !rest.bytes().any(|byte| byte.is_ascii_control())
&& !rest.contains(['@', '?', '#', '\\'])
&& rest.split('/').next().is_some_and(|host| !host.is_empty())
}
pub fn validate(kind: &str, value: &Value) -> Result<()> {
let valid = match kind {
"persona" => {
@@ -252,6 +344,138 @@ pub fn validate(kind: &str, value: &Value) -> Result<()> {
&& markdown(&p.preferences)
})
}
"conversation" => {
let value: Conversation = decode(value)?;
let message_id = |id: &str| bounded_identifier(id, 128);
!value.title.trim().is_empty()
&& value.title.chars().count() <= 120
&& timestamp(value.created_at_ms)
&& timestamp(value.updated_at_ms)
&& value.updated_at_ms >= value.created_at_ms
&& value.messages.len() <= 2_000
&& value.active_leaf.as_deref().is_none_or(|id| {
message_id(id) && value.messages.iter().any(|v| v.message_id == id)
})
&& value.messages.iter().enumerate().all(|(index, message)| {
message_id(&message.message_id)
&& !value.messages[..index]
.iter()
.any(|previous| previous.message_id == message.message_id)
&& message.parent_message_id.as_deref().is_none_or(|id| {
message_id(id)
&& id != message.message_id
&& value
.messages
.iter()
.any(|candidate| candidate.message_id == id)
})
&& matches!(message.role.as_str(), "user" | "assistant" | "system")
&& message.content.chars().count() <= 262_144
&& message
.thinking
.as_ref()
.is_none_or(|text| text.chars().count() <= 262_144)
&& message.attachments.len() <= 64
&& message
.attachments
.iter()
.all(|path| portable_reference(path, 512))
&& message
.attachments
.iter()
.enumerate()
.all(|(index, path)| !message.attachments[..index].contains(path))
&& timestamp(message.created_at_ms)
})
}
"agent_history" => {
let value: AgentHistory = decode(value)?;
matches!(value.status.as_str(), "completed" | "failed" | "cancelled")
&& value.input.chars().count() <= 262_144
&& value
.output
.as_ref()
.is_none_or(|text| text.chars().count() <= 524_288)
&& model_identifier(&value.model)
&& value
.skill_id
.as_deref()
.is_none_or(|id| bounded_identifier(id, 128))
&& value
.error_code
.as_deref()
.is_none_or(|code| bounded_identifier(code, 128))
&& value
.error_message
.as_ref()
.is_none_or(|message| message.chars().count() <= 4_096)
&& value.token_usage <= 9_007_199_254_740_991
&& timestamp(value.created_at_ms)
&& timestamp(value.updated_at_ms)
&& value.updated_at_ms >= value.created_at_ms
}
"provider_settings" => {
let value: ProviderSettings = decode(value)?;
let _ = value.enabled;
let known_capabilities = [
"chat",
"vision",
"tool_calling",
"reasoning",
"streaming",
"structured_output",
"embedding",
"transcription",
"speaker_matching",
];
value.version <= 9_007_199_254_740_991
&& matches!(
value.provider_type.as_str(),
"mock"
| "openai_responses"
| "openai_chat"
| "openai_compatible"
| "anthropic_messages"
| "ollama"
)
&& !value.name.trim().is_empty()
&& value.name.chars().count() <= 128
&& value
.base_url
.as_deref()
.is_none_or(|url| url.len() <= 2_048 && safe_base_url(url))
&& value.default_model.as_deref().is_none_or(model_identifier)
&& unique_bounded(&value.capabilities, 16, 64)
&& value
.capabilities
.iter()
.all(|capability| known_capabilities.contains(&capability.as_str()))
}
"extension_installation" => {
let value: ExtensionInstallation = decode(value)?;
matches!(value.package_kind.as_str(), "skill" | "plugin" | "theme")
&& bounded_identifier(&value.package_id, 128)
&& !value.source.is_empty()
&& value.source.len() <= 512
&& value
.source
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._:/-".contains(&byte))
&& !value.source.contains("..")
&& !value.source.contains('@')
&& !value.source.contains(['?', '#', '\\'])
&& !value.version.is_empty()
&& value.version.len() <= 128
&& value
.version
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b".+-_".contains(&byte))
&& value.sha256.len() == 64
&& value
.sha256
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
_ => return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED")),
};
if valid {
@@ -347,4 +571,71 @@ mod tests {
"RECORD_DATA_INVALID"
);
}
#[test]
fn s08_optional_schemas_are_strict_and_installations_cannot_authorize() {
let fixtures = [
(
"conversation",
"conversation_00000000000000000000000000000001",
json!({"title":"Review","active_leaf":"message_1","created_at_ms":1,"updated_at_ms":2,"messages":[{"message_id":"message_1","parent_message_id":null,"role":"user","content":"Hello","thinking":null,"attachments":[],"created_at_ms":1}]}),
),
(
"agent_history",
"agent_run_00000000000000000000000000000001",
json!({"status":"completed","input":"Review","output":"Done","model":"gpt-5.6","skill_id":null,"error_code":null,"error_message":null,"token_usage":42,"created_at_ms":1,"updated_at_ms":2}),
),
(
"provider_settings",
"provider_00000000000000000000000000000001",
json!({"version":1,"provider_type":"openai_responses","name":"OpenAI","base_url":"https://api.openai.com/v1","default_model":"gpt-5.6","enabled":true,"capabilities":["chat","tool_calling"]}),
),
(
"extension_installation",
"extension_00000000000000000000000000000001",
json!({"package_kind":"plugin","package_id":"opennexus.review","source":"community/opennexus.review","version":"1.2.3","sha256":"a".repeat(64)}),
),
];
for (kind, id, data) in fixtures {
let path = crate::records::path_for(kind, id).unwrap();
let record = json!({"schema":1,"kind":kind,"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));
let mut unknown = record.clone();
unknown["data"]["api_key"] = json!("must-never-sync");
assert_eq!(
crate::records::validate(&path, &serde_json::to_vec(&unknown).unwrap())
.err()
.unwrap()
.code,
"RECORD_SCHEMA_INVALID"
);
}
let installation = json!({"package_kind":"plugin","package_id":"opennexus.review","source":"community/opennexus.review","version":"1.2.3","sha256":"a".repeat(64)});
for forbidden in [
"permissions",
"granted_permissions",
"enabled",
"trusted",
"device_grants",
"package_path",
] {
let mut invalid = installation.clone();
invalid[forbidden] = json!(true);
assert_eq!(
validate("extension_installation", &invalid)
.unwrap_err()
.code,
"RECORD_SCHEMA_INVALID"
);
}
let mut provider = json!({"version":1,"provider_type":"openai_responses","name":"OpenAI","base_url":"https://api.openai.com/v1","default_model":"gpt-5.6","enabled":true,"capabilities":["chat"]});
provider["credential_id"] = json!("device-secret-reference");
assert_eq!(
validate("provider_settings", &provider).unwrap_err().code,
"RECORD_SCHEMA_INVALID"
);
}
}
+78 -1
View File
@@ -45,6 +45,17 @@ pub fn user_skill_path(id: &str) -> Result<String> {
}
Ok(format!("opennexus-records/v1/user-skills/{id}.json"))
}
fn portable_id_path(id: &str, prefix: &str, directory: &str) -> Result<String> {
if !id.starts_with(prefix)
|| id.len() != prefix.len() + 32
|| !id[prefix.len()..]
.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/{directory}/{id}.json"))
}
pub fn path_for(kind: &str, id: &str) -> Result<String> {
match (kind, id) {
("task", id) => path(id),
@@ -55,6 +66,12 @@ pub fn path_for(kind: &str, id: &str) -> Result<String> {
("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),
("conversation", id) => portable_id_path(id, "conversation_", "conversations"),
("agent_history", id) => portable_id_path(id, "agent_run_", "agent-history"),
("provider_settings", id) => portable_id_path(id, "provider_", "provider-settings"),
("extension_installation", id) => {
portable_id_path(id, "extension_", "extension-installations")
}
_ => Err(HostError::new("RECORD_ID_INVALID")),
}
}
@@ -78,10 +95,32 @@ pub fn allowed(path_value: &str) -> bool {
{
return true;
}
path_value
if 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())
{
return true;
}
[
("conversation", "opennexus-records/v1/conversations/"),
("agent_history", "opennexus-records/v1/agent-history/"),
(
"provider_settings",
"opennexus-records/v1/provider-settings/",
),
(
"extension_installation",
"opennexus-records/v1/extension-installations/",
),
]
.iter()
.any(|(kind, prefix)| {
path_value
.strip_prefix(prefix)
.and_then(|value| value.strip_suffix(".json"))
.is_some_and(|id| path_for(kind, id).is_ok())
})
}
pub fn validate(path_value: &str, content: &[u8]) -> Result<Record> {
if content.len() > 1024 * 1024 {
@@ -184,6 +223,10 @@ impl Workspace {
let prefix = match kind {
"task" => "opennexus-records/v1/tasks/",
"user_skill" => "opennexus-records/v1/user-skills/",
"conversation" => "opennexus-records/v1/conversations/",
"agent_history" => "opennexus-records/v1/agent-history/",
"provider_settings" => "opennexus-records/v1/provider-settings/",
"extension_installation" => "opennexus-records/v1/extension-installations/",
_ => return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED")),
};
let paths = self
@@ -295,4 +338,38 @@ mod tests {
value
);
}
#[test]
fn s08_incompatible_remote_schema_preserves_local_file_and_upload_queue() {
let root = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(root.path()).unwrap();
let current = fixture();
let path = path(current["id"].as_str().unwrap()).unwrap();
let entry = ws
.write(&path, "", &serde_json::to_vec(&current).unwrap(), "local")
.unwrap();
assert_eq!(ws.pending_count().unwrap(), 1);
let mut future = current.clone();
future["schema"] = json!(2);
future["data"]["title"] = json!("future remote value");
assert_eq!(
ws.write(
&path,
&entry.hash,
&serde_json::to_vec(&future).unwrap(),
"remote",
)
.unwrap_err()
.code,
"RECORD_SCHEMA_UNSUPPORTED"
);
assert_eq!(ws.pending_count().unwrap(), 1);
assert_eq!(
ws.record_get(current["id"].as_str().unwrap())
.unwrap()
.unwrap()["record"],
current
);
}
}
+7
View File
@@ -206,6 +206,11 @@ mod tests {
"attachments/tool.exe",
"attachments/plugin.zip",
"settings.json",
"index.sqlite3",
"vectors.bin",
"cache/index.md",
"logs/agent.md",
"models/embedding.md",
] {
assert!(!allowed(path), "{path}");
}
@@ -214,6 +219,8 @@ mod tests {
"attachments/movie.mp4",
"attachments/image.png",
"attachments/fixture.bin",
"opennexus-records/v1/conversations/conversation_00000000000000000000000000000001.json",
"opennexus-records/v1/extension-installations/extension_00000000000000000000000000000001.json",
] {
assert!(allowed(path), "{path}");
}
@@ -419,6 +419,7 @@ mod tests {
ws.sync_set_optional_scope(crate::sync_scope::OptionalScope {
persona: true,
layout: true,
..Default::default()
})
.unwrap();
let binding = ws
+37 -5
View File
@@ -8,6 +8,10 @@ use serde::{Deserialize, Serialize};
pub struct OptionalScope {
pub persona: bool,
pub layout: bool,
pub conversations: bool,
pub agent_history: bool,
pub provider_settings: bool,
pub extension_installations: bool,
}
impl OptionalScope {
@@ -15,6 +19,14 @@ impl OptionalScope {
match path {
"opennexus-records/v1/persona/default.json" => self.persona,
"opennexus-records/v1/layout/sidebars.json" => self.layout,
path if path.starts_with("opennexus-records/v1/conversations/") => self.conversations,
path if path.starts_with("opennexus-records/v1/agent-history/") => self.agent_history,
path if path.starts_with("opennexus-records/v1/provider-settings/") => {
self.provider_settings
}
path if path.starts_with("opennexus-records/v1/extension-installations/") => {
self.extension_installations
}
_ => true,
}
}
@@ -29,12 +41,16 @@ impl Workspace {
Ok(self
.db
.query_row(
"SELECT persona,layout FROM sync_optional_scope WHERE id=1",
"SELECT persona,layout,conversations,agent_history,provider_settings,extension_installations FROM sync_optional_scope WHERE id=1",
[],
|row| {
Ok(OptionalScope {
persona: row.get(0)?,
layout: row.get(1)?,
conversations: row.get(2)?,
agent_history: row.get(3)?,
provider_settings: row.get(4)?,
extension_installations: row.get(5)?,
})
},
)
@@ -47,8 +63,8 @@ impl Workspace {
return Err(HostError::new("SYNC_SCOPE_REBIND_REQUIRED"));
}
self.db.execute(
"INSERT INTO sync_optional_scope VALUES (1,?1,?2) ON CONFLICT(id) DO UPDATE SET persona=excluded.persona,layout=excluded.layout",
rusqlite::params![scope.persona, scope.layout],
"INSERT INTO sync_optional_scope (id,persona,layout,conversations,agent_history,provider_settings,extension_installations) VALUES (1,?1,?2,?3,?4,?5,?6) ON CONFLICT(id) DO UPDATE SET persona=excluded.persona,layout=excluded.layout,conversations=excluded.conversations,agent_history=excluded.agent_history,provider_settings=excluded.provider_settings,extension_installations=excluded.extension_installations",
rusqlite::params![scope.persona, scope.layout, scope.conversations, scope.agent_history, scope.provider_settings, scope.extension_installations],
)?;
Ok(())
}
@@ -65,6 +81,7 @@ mod tests {
ws.sync_set_optional_scope(OptionalScope {
persona: true,
layout: false,
..OptionalScope::default()
})
.unwrap();
let data = serde_json::to_vec(&serde_json::json!({"schema":1,"kind":"persona","id":"default","data":{"version":0,"name":"local","system_prompt":"private","dialogue_pairs":[]}})).unwrap();
@@ -135,6 +152,7 @@ mod tests {
ws.sync_set_optional_scope(OptionalScope {
persona: true,
layout: false,
..OptionalScope::default()
})
.unwrap();
assert_eq!(
@@ -176,7 +194,7 @@ mod tests {
ws.db
.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))
.unwrap(),
12
13
);
}
#[test]
@@ -187,10 +205,24 @@ mod tests {
let off = ws.sync_optional_scope().unwrap();
assert!(!off.includes("opennexus-records/v1/persona/default.json"));
assert!(!off.includes("opennexus-records/v1/layout/sidebars.json"));
assert!(!off.includes(
"opennexus-records/v1/conversations/conversation_00000000000000000000000000000001.json"
));
assert!(!off.includes(
"opennexus-records/v1/agent-history/agent_run_00000000000000000000000000000001.json"
));
assert!(!off.includes(
"opennexus-records/v1/provider-settings/provider_00000000000000000000000000000001.json"
));
assert!(!off.includes("opennexus-records/v1/extension-installations/extension_00000000000000000000000000000001.json"));
assert!(off.includes("notes/example.md"));
let chosen = OptionalScope {
persona: true,
layout: false,
conversations: true,
agent_history: true,
provider_settings: true,
extension_installations: true,
};
ws.sync_set_optional_scope(chosen).unwrap();
assert_eq!(ws.pending_count().unwrap(), 0);
@@ -220,7 +252,7 @@ mod tests {
#[test]
fn scope_rejects_unknown_fields_instead_of_authorizing_future_categories() {
assert!(serde_json::from_str::<OptionalScope>(
r#"{"persona":true,"layout":false,"credentials":true}"#
r#"{"persona":true,"layout":false,"conversations":false,"agent_history":false,"provider_settings":false,"extension_installations":false,"credentials":true}"#
)
.is_err());
}
+74 -6
View File
@@ -136,10 +136,10 @@ impl Workspace {
let db = Connection::open(db_path)?;
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
if version > 12 {
if version > 13 {
return Err(HostError::new("SCHEMA_INCOMPATIBLE"));
}
if (1..12).contains(&version) {
if (1..13).contains(&version) {
// Independent, complete SQLite backup before the schema ownership change.
let backup = managed.join(format!("host-schema{version}-{}.sqlite3", Uuid::new_v4()));
db.execute("VACUUM INTO ?1", [backup.to_string_lossy().as_ref()])?;
@@ -166,7 +166,7 @@ impl Workspace {
CREATE TABLE IF NOT EXISTS sync_attempts (binding TEXT NOT NULL,operation_id TEXT NOT NULL,attempts INTEGER NOT NULL,outcome TEXT NOT NULL,error TEXT,PRIMARY KEY(binding,operation_id));
CREATE TABLE IF NOT EXISTS sync_retry (binding TEXT PRIMARY KEY,error TEXT,failures INTEGER NOT NULL,retry_at INTEGER,halted INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS sync_preferences (binding TEXT PRIMARY KEY,paused INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS sync_optional_scope (id INTEGER PRIMARY KEY CHECK(id=1),persona INTEGER NOT NULL CHECK(persona IN (0,1)),layout INTEGER NOT NULL CHECK(layout IN (0,1)));
CREATE TABLE IF NOT EXISTS sync_optional_scope (id INTEGER PRIMARY KEY CHECK(id=1),persona INTEGER NOT NULL CHECK(persona IN (0,1)),layout INTEGER NOT NULL CHECK(layout IN (0,1)),conversations INTEGER NOT NULL DEFAULT 0 CHECK(conversations IN (0,1)),agent_history INTEGER NOT NULL DEFAULT 0 CHECK(agent_history IN (0,1)),provider_settings INTEGER NOT NULL DEFAULT 0 CHECK(provider_settings IN (0,1)),extension_installations INTEGER NOT NULL DEFAULT 0 CHECK(extension_installations IN (0,1)));
CREATE TABLE IF NOT EXISTS sync_resolutions (binding TEXT NOT NULL,sequence INTEGER NOT NULL,choice TEXT NOT NULL,destination TEXT NOT NULL,expected TEXT NOT NULL,operation_id TEXT NOT NULL,rename_id TEXT NOT NULL,copy_id TEXT NOT NULL,state TEXT NOT NULL,retire_id TEXT NOT NULL,restore_id TEXT NOT NULL,PRIMARY KEY(binding,sequence));")?;
let has_origin: bool = db.query_row(
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('file_ops') WHERE name='origin')",
@@ -201,10 +201,30 @@ impl Workspace {
[],
)?;
}
for column in [
"conversations",
"agent_history",
"provider_settings",
"extension_installations",
] {
let exists: bool = db.query_row(
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('sync_optional_scope') WHERE name=?1)",
[column],
|row| row.get(0),
)?;
if !exists {
db.execute(
&format!(
"ALTER TABLE sync_optional_scope ADD COLUMN {column} INTEGER NOT NULL DEFAULT 0 CHECK({column} IN (0,1))"
),
[],
)?;
}
}
if version < 7 {
db.execute_batch("INSERT OR IGNORE INTO sync_observed SELECT f.id,COALESCE((SELECT o.path FROM outbox o WHERE o.file_id=f.id AND o.state IN ('pending','queued') ORDER BY rowid DESC LIMIT 1),(SELECT h.path FROM sync_heads h JOIN sync_bindings b ON h.binding=b.id WHERE h.file_id=f.id AND b.state='active'),f.path),COALESCE((SELECT o.hash FROM outbox o WHERE o.file_id=f.id AND o.state IN ('pending','queued') ORDER BY rowid DESC LIMIT 1),(SELECT h.hash FROM sync_heads h JOIN sync_bindings b ON h.binding=b.id WHERE h.file_id=f.id AND b.state='active'),f.hash),f.deleted FROM files f;")?;
}
db.execute_batch("UPDATE sync_attempts SET outcome=CASE WHEN EXISTS(SELECT 1 FROM sync_jobs j WHERE j.binding=sync_attempts.binding AND j.operation_id=sync_attempts.operation_id AND j.state='acked') THEN 'succeeded' ELSE 'interrupted' END WHERE outcome='running'; PRAGMA user_version=12; COMMIT;")?;
db.execute_batch("UPDATE sync_attempts SET outcome=CASE WHEN EXISTS(SELECT 1 FROM sync_jobs j WHERE j.binding=sync_attempts.binding AND j.operation_id=sync_attempts.operation_id AND j.state='acked') THEN 'succeeded' ELSE 'interrupted' END WHERE outcome='running'; PRAGMA user_version=13; COMMIT;")?;
let vault_id: String = db
.query_row("SELECT id FROM identity", [], |r| r.get(0))
.optional()?
@@ -1398,7 +1418,7 @@ mod tests {
}
#[test]
fn schema_eleven_upgrade_adds_durable_conflict_operation_ids() {
fn schema_eleven_upgrade_adds_durable_conflict_and_scope_fields() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path()).unwrap();
ws.db
@@ -1415,7 +1435,7 @@ mod tests {
ws.db
.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))
.unwrap(),
12
13
);
for column in ["retire_id", "restore_id"] {
assert!(ws
@@ -1447,6 +1467,54 @@ mod tests {
.unwrap());
}
#[test]
fn schema_twelve_upgrade_adds_optional_categories_without_consent() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path()).unwrap();
ws.db
.execute_batch(
"DROP TABLE sync_optional_scope;
CREATE TABLE sync_optional_scope (id INTEGER PRIMARY KEY CHECK(id=1),persona INTEGER NOT NULL CHECK(persona IN (0,1)),layout INTEGER NOT NULL CHECK(layout IN (0,1)));
INSERT INTO sync_optional_scope VALUES (1,1,0);
PRAGMA user_version=12;",
)
.unwrap();
drop(ws);
let ws = Workspace::open(dir.path()).unwrap();
let scope = ws.sync_optional_scope().unwrap();
assert!(scope.persona);
assert!(!scope.layout);
assert!(!scope.conversations);
assert!(!scope.agent_history);
assert!(!scope.provider_settings);
assert!(!scope.extension_installations);
assert_eq!(
ws.db
.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))
.unwrap(),
13
);
let backup = fs::read_dir(dir.path().join(".ainote"))
.unwrap()
.filter_map(|entry| entry.ok())
.find(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("host-schema12-")
})
.unwrap();
let old = Connection::open(backup.path()).unwrap();
assert!(!old
.query_row(
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('sync_optional_scope') WHERE name='conversations')",
[],
|row| row.get::<_, bool>(0),
)
.unwrap());
}
#[test]
fn unsafe_paths_external_change_and_remote_origin() {
let dir = tempfile::tempdir().unwrap();
@@ -0,0 +1,401 @@
#![cfg(feature = "desktop")]
use notesagent_host::{
records, sync_client::SyncClient, sync_scope::OptionalScope, workspace::Workspace,
};
use serde_json::{json, Value};
use std::{
io::{BufRead, BufReader},
path::Path,
process::{Child, Command, Stdio},
sync::{Arc, Mutex},
};
use zeroize::Zeroizing;
struct Server(Child);
impl Drop for Server {
fn drop(&mut self) {
self.0.stdin.take();
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn record(kind: &str, id: &str, data: Value) -> Value {
json!({"schema":1,"kind":kind,"id":id,"data":data})
}
fn records_fixture() -> Vec<(&'static str, &'static str, Value, bool)> {
let task = "task_00000000000000000000000000000001";
let skill = "user_skill_00000000000000000000000000000001";
let conversation = "conversation_00000000000000000000000000000001";
let run = "agent_run_00000000000000000000000000000001";
let provider = "provider_00000000000000000000000000000001";
let extension = "extension_00000000000000000000000000000001";
vec![
(
"task",
task,
record(
"task",
task,
json!({"title":"Ship","description":"Production checklist","status":"todo","note_id":null,"due_at_ms":null,"created_at_ms":1,"updated_at_ms":2}),
),
false,
),
(
"user_skill",
skill,
record(
"user_skill",
skill,
json!({"version":1,"name":"Review","description":"Review a note","prompt":"Check 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":2}),
),
false,
),
(
"theme_settings",
"appearance",
record(
"theme_settings",
"appearance",
json!({"themeId":"dark","fontEditorSize":18,"fontEditorFamily":"system-ui","lineHeight":1.7,"codeBlockTheme":"auto","headings":{"custom":false,"family":"inherit","levels":([32,28,24,21,18,16].map(|size|json!({"size":size,"weight":700})))}}),
),
false,
),
(
"preferences",
"editor",
record(
"preferences",
"editor",
json!({"restoreLastVault":true,"autoSaveInterval":1500,"language":"zh-CN","defaultEditorMode":"wysiwyg","editorLineWidth":80,"spellCheck":false,"markdown":{"heading":"atx","bullet":"-","incrementList":true,"fence":"`","math":true,"callouts":true,"diagrams":true,"autoLinks":true,"lineNumbers":true,"wrapCode":false,"indent":4,"defaultLanguage":""},"presets":[]}),
),
false,
),
(
"persona",
"default",
record(
"persona",
"default",
json!({"version":1,"name":"Writer","system_prompt":"Be concise","dialogue_pairs":[]}),
),
true,
),
(
"layout",
"sidebars",
record(
"layout",
"sidebars",
json!({"primaryExpanded":true,"workspaceWidth":400,"chatWidth":320}),
),
true,
),
(
"conversation",
conversation,
record(
"conversation",
conversation,
json!({"title":"Release review","active_leaf":"message_1","created_at_ms":1,"updated_at_ms":2,"messages":[{"message_id":"message_1","parent_message_id":null,"role":"user","content":"Review the release","thinking":null,"attachments":[],"created_at_ms":1}]}),
),
true,
),
(
"agent_history",
run,
record(
"agent_history",
run,
json!({"status":"completed","input":"Review release","output":"Ready","model":"gpt-5.6","skill_id":null,"error_code":null,"error_message":null,"token_usage":42,"created_at_ms":1,"updated_at_ms":2}),
),
true,
),
(
"provider_settings",
provider,
record(
"provider_settings",
provider,
json!({"version":1,"provider_type":"openai_responses","name":"OpenAI","base_url":"https://api.openai.com/v1","default_model":"gpt-5.6","enabled":true,"capabilities":["chat","tool_calling"]}),
),
true,
),
(
"extension_installation",
extension,
record(
"extension_installation",
extension,
json!({"package_kind":"plugin","package_id":"opennexus.review","source":"community/opennexus.review","version":"1.2.3","sha256":"a".repeat(64)}),
),
true,
),
]
}
fn seed(workspace: &mut Workspace) -> Vec<(String, Value, bool)> {
workspace
.write("notes/default.md", "", b"# Default markdown", "local")
.unwrap();
workspace
.write(
"attachments/manual.pdf",
"",
b"portable attachment",
"local",
)
.unwrap();
let mut written = Vec::new();
for (kind, id, value, optional) in records_fixture() {
let path = records::path_for(kind, id).unwrap();
workspace
.write(&path, "", &serde_json::to_vec(&value).unwrap(), "local")
.unwrap();
written.push((path, value, optional));
}
for (path, contents) in [
("logs/trace.md", "forbidden log"),
("cache/search.md", "forbidden cache"),
("models/local.md", "forbidden model"),
("index.sqlite3", "forbidden index"),
("vectors.bin", "forbidden vectors"),
("provider-settings.json", "fixture-api-key"),
] {
workspace
.write(path, "", contents.as_bytes(), "local")
.unwrap();
}
written
}
async fn push_all(
client: &SyncClient,
workspace: &Arc<Mutex<Workspace>>,
binding: &notesagent_host::sync_state::Binding,
) {
while client.push_one(workspace, binding).await.unwrap() {}
}
async fn pull_all(
client: &SyncClient,
workspace: &Arc<Mutex<Workspace>>,
binding: &notesagent_host::sync_state::Binding,
) {
while client.pull_page(workspace, binding).await.unwrap() > 0 {}
}
async fn create_remote(client: &SyncClient, name: &str) -> String {
client
.json(
reqwest::Method::POST,
"sync/v1/vaults",
Some(json!({"name":name})),
)
.await
.unwrap()["vault_id"]
.as_str()
.unwrap()
.to_owned()
}
#[tokio::test]
async fn s08_actual_service_enforces_classification_and_roundtrips_selected_records() {
let service_root = tempfile::tempdir().unwrap();
std::fs::write(service_root.path().join(".opennexus-test"), b"fixture").unwrap();
let service = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../server sync")
.canonicalize()
.unwrap();
let python = service.join(if cfg!(windows) {
".venv/Scripts/python.exe"
} else {
".venv/bin/python"
});
let mut server = Server(
Command::new(python)
.args(["-m", "tests.host_fixture"])
.arg(service_root.path())
.current_dir(service)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap(),
);
let mut line = String::new();
BufReader::new(server.0.stdout.take().unwrap())
.read_line(&mut line)
.unwrap();
let ready: Value = serde_json::from_str(&line).unwrap();
let endpoint = format!("http://127.0.0.1:{}", ready["port"]);
let public = SyncClient::new(&endpoint, Zeroizing::new(String::new()), true).unwrap();
public.handshake().await.unwrap();
let session = public
.login(
"rust-fixture",
Zeroizing::new("controlled-fixture-password".into()),
"S-08 classification",
)
.await
.unwrap();
let client = SyncClient::new(
&endpoint,
Zeroizing::new(session.access_token.clone()),
true,
)
.unwrap();
let default_remote = create_remote(&client, "S-08 default").await;
let source_root = tempfile::tempdir().unwrap();
let mut source = Workspace::open(source_root.path()).unwrap();
let written = seed(&mut source);
let source = Arc::new(Mutex::new(source));
let source_binding = source
.lock()
.unwrap()
.sync_bind_empty(&endpoint, &default_remote, "rust-fixture")
.unwrap();
push_all(&client, &source, &source_binding).await;
let default_snapshot = client.snapshot(&default_remote).await.unwrap();
let default_paths = default_snapshot
.items
.iter()
.map(|item| item.path.as_str())
.collect::<Vec<_>>();
assert_eq!(default_paths.len(), 6);
assert!(default_paths.contains(&"notes/default.md"));
assert!(default_paths.contains(&"attachments/manual.pdf"));
assert!(written
.iter()
.filter(|(_, _, optional)| !optional)
.all(|(path, _, _)| default_paths.contains(&path.as_str())));
assert!(written
.iter()
.filter(|(_, _, optional)| *optional)
.all(|(path, _, _)| !default_paths.contains(&path.as_str())));
assert!(default_paths.iter().all(|path| {
!path.starts_with("logs/")
&& !path.starts_with("cache/")
&& !path.starts_with("models/")
&& !path.contains("index")
&& !path.contains("vectors")
&& !path.contains("provider-settings.json")
}));
let target_root = tempfile::tempdir().unwrap();
let target = Arc::new(Mutex::new(Workspace::open(target_root.path()).unwrap()));
let target_binding = target
.lock()
.unwrap()
.sync_bind_download(&endpoint, &default_remote, "rust-fixture")
.unwrap();
pull_all(&client, &target, &target_binding).await;
assert_eq!(
target
.lock()
.unwrap()
.read("notes/default.md")
.unwrap()
.content,
"# Default markdown"
);
assert_eq!(
target
.lock()
.unwrap()
.read("attachments/manual.pdf")
.unwrap()
.content,
"portable attachment"
);
for (path, value, optional) in &written {
if *optional {
assert!(!target_root.path().join(path).exists());
} else {
assert_eq!(
serde_json::from_slice::<Value>(
&std::fs::read(target_root.path().join(path)).unwrap()
)
.unwrap(),
*value
);
}
}
let optional_remote = create_remote(&client, "S-08 optional").await;
let optional_source_root = tempfile::tempdir().unwrap();
let mut optional_source = Workspace::open(optional_source_root.path()).unwrap();
optional_source
.sync_set_optional_scope(OptionalScope {
persona: true,
layout: true,
conversations: true,
agent_history: true,
provider_settings: true,
extension_installations: true,
})
.unwrap();
let optional_written = seed(&mut optional_source);
let optional_source = Arc::new(Mutex::new(optional_source));
let optional_source_binding = optional_source
.lock()
.unwrap()
.sync_bind_empty(&endpoint, &optional_remote, "rust-fixture")
.unwrap();
push_all(&client, &optional_source, &optional_source_binding).await;
assert_eq!(
client.snapshot(&optional_remote).await.unwrap().items.len(),
12
);
let optional_target_root = tempfile::tempdir().unwrap();
let mut optional_target = Workspace::open(optional_target_root.path()).unwrap();
optional_target
.sync_set_optional_scope(OptionalScope {
persona: true,
layout: true,
conversations: true,
agent_history: true,
provider_settings: true,
extension_installations: true,
})
.unwrap();
let optional_target = Arc::new(Mutex::new(optional_target));
let optional_target_binding = optional_target
.lock()
.unwrap()
.sync_bind_download(&endpoint, &optional_remote, "rust-fixture")
.unwrap();
pull_all(&client, &optional_target, &optional_target_binding).await;
for (path, value, _) in &optional_written {
assert_eq!(
serde_json::from_slice::<Value>(
&std::fs::read(optional_target_root.path().join(path)).unwrap()
)
.unwrap(),
*value
);
}
let installation = &optional_written
.iter()
.find(|(_, value, _)| value["kind"] == "extension_installation")
.unwrap()
.1["data"];
let keys = installation
.as_object()
.unwrap()
.keys()
.cloned()
.collect::<Vec<_>>();
assert_eq!(keys.len(), 5);
assert!(keys.iter().all(|key| {
matches!(
key.as_str(),
"package_kind" | "package_id" | "source" | "version" | "sha256"
)
}));
}
+3
View File
@@ -81,6 +81,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
.sync_set_optional_scope(notesagent_host::sync_scope::OptionalScope {
persona: true,
layout: true,
..Default::default()
})
.unwrap();
let binding = {
@@ -211,6 +212,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
.sync_set_optional_scope(notesagent_host::sync_scope::OptionalScope {
persona: true,
layout: true,
..Default::default()
})
.unwrap();
let binding_b = workspace_b
@@ -810,6 +812,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
.sync_set_optional_scope(notesagent_host::sync_scope::OptionalScope {
persona: true,
layout: true,
..Default::default()
})
.unwrap();
}
@@ -83,13 +83,14 @@ it('shows persisted per-job interruption counts without starting work from the v
})
it('keeps optional records off until an explicit unbound scope change', async () => {
vi.mocked(hostInvoke).mockResolvedValue({ ...empty(), optional_scope: { persona: false, layout: false } })
it('keeps every optional record class off until an explicit unbound scope change', async () => {
const optionalScope = { persona: false, layout: false, conversations: false, agent_history: false, provider_settings: false, extension_installations: false }
vi.mocked(hostInvoke).mockResolvedValue({ ...empty(), optional_scope: optionalScope })
const wrapper = mount(SyncSettings); await flushPromises()
const options = wrapper.findAll('.sync-scope input[type=checkbox]')
expect(options).toHaveLength(2)
expect((options[0]!.element as HTMLInputElement).checked).toBe(false)
expect(options).toHaveLength(6)
expect(options.every(option => !(option.element as HTMLInputElement).checked)).toBe(true)
await options[0]!.setValue(true); await flushPromises()
expect(hostInvoke).toHaveBeenCalledWith('sync_set_scope', { vaultId: empty().vault_id, scope: { persona: true, layout: false } })
expect(hostInvoke).toHaveBeenCalledWith('sync_set_scope', { vaultId: empty().vault_id, scope: { ...optionalScope, persona: true } })
wrapper.unmount()
})
@@ -8,7 +8,8 @@ import { t } from '@/i18n'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
interface Binding { id: string; endpoint: string; account: string; remote_vault: string; cursor: number }
interface Conflict { sequence: number; local_path: string; local_hash: string; current_hash?: string; current_path?: string; remote: { path: string; operation: string } }
interface Status { optional_scope?: { persona: boolean; layout: boolean }; vault_id: string; binding: Binding | null; paused: boolean; pending: number; conflicts: Conflict[]; credential_state: string; running: boolean; error: string | null; retry_in: number | null; failures: number; halted: boolean; attempts?: Array<{ operation_id: string; path: string; attempts: number; outcome: string; error: string | null }> }
type OptionalScope = { persona: boolean; layout: boolean; conversations: boolean; agent_history: boolean; provider_settings: boolean; extension_installations: boolean }
interface Status { optional_scope?: OptionalScope; vault_id: string; binding: Binding | null; paused: boolean; pending: number; conflicts: Conflict[]; credential_state: string; running: boolean; error: string | null; retry_in: number | null; failures: number; halted: boolean; attempts?: Array<{ operation_id: string; path: string; attempts: number; outcome: string; error: string | null }> }
interface RemoteVault { id: string; name: string; sequence: number; used: number; quota: number }
const status = ref<Status | null>(null)
const endpoint = ref('https://'), account = ref(''), password = ref(''), device = ref('OpenNexus Desktop'), testHttp = ref(false)
@@ -42,11 +43,11 @@ async function act(action: () => Promise<void>) {
catch (error) { message.value = error instanceof Error ? error.message : 'SYNC_FAILED' }
finally { busy.value = false }
}
function setScope(kind: 'persona' | 'layout', event: Event) {
function setScope(kind: keyof OptionalScope, event: Event) {
const current = status.value
if (!current || current.binding) return
const input = event.target as HTMLInputElement
const scope = { persona: false, layout: false, ...current.optional_scope, [kind]: input.checked }
const scope: OptionalScope = { persona: false, layout: false, conversations: false, agent_history: false, provider_settings: false, extension_installations: false, ...current.optional_scope, [kind]: input.checked }
input.checked = current.optional_scope?.[kind] ?? false
preview.value = null
return act(async () => { await hostInvoke('sync_set_scope', { vaultId: current.vault_id, scope }) })
@@ -131,6 +132,10 @@ onUnmounted(() => { mounted = false; clearInterval(timer); password.value = '' }
<legend>{{ t('可选同步内容', 'Optional sync content') }}</legend>
<label><input type="checkbox" :checked="status.optional_scope?.persona ?? false" @change="setScope('persona', $event)" />{{ t('工作区人设', 'Workspace persona') }}</label>
<label><input type="checkbox" :checked="status.optional_scope?.layout ?? false" @change="setScope('layout', $event)" />{{ t('侧栏布局', 'Sidebar layout') }}</label>
<label><input type="checkbox" :checked="status.optional_scope?.conversations ?? false" @change="setScope('conversations', $event)" />{{ t('对话记录', 'Conversation history') }}</label>
<label><input type="checkbox" :checked="status.optional_scope?.agent_history ?? false" @change="setScope('agent_history', $event)" />{{ t('已结束的 Agent 历史', 'Completed agent history') }}</label>
<label><input type="checkbox" :checked="status.optional_scope?.provider_settings ?? false" @change="setScope('provider_settings', $event)" />{{ t('Provider 通用参数(不含凭据)', 'Provider parameters (credentials excluded)') }}</label>
<label><input type="checkbox" :checked="status.optional_scope?.extension_installations ?? false" @change="setScope('extension_installations', $event)" />{{ t('扩展安装清单(需重新下载和授权)', 'Extension list (download and authorize again)') }}</label>
<p>{{ t('默认仅保存在本机。修改已绑定范围时,请先解除绑定,再重新预览合并;关闭选项不会删除远端内容。', 'Kept locally by default. Unbind before changing scope, then preview a new merge. Disabling an option does not delete remote content.') }}</p>
</fieldset>
<div v-if="status?.binding" class="sync-bound">
+107
View File
@@ -0,0 +1,107 @@
"""S-08 sync classification and logical-record compatibility acceptance driver."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MANIFEST = ROOT / "frontend" / "src-tauri" / "Cargo.toml"
SERVICE_TEST = "s08_actual_service_enforces_classification_and_roundtrips_selected_records"
SCHEMA_TEST = "preference_records::tests::s08_optional_schemas_are_strict_and_installations_cannot_authorize"
QUEUE_TEST = "records::tests::s08_incompatible_remote_schema_preserves_local_file_and_upload_queue"
MIGRATION_TEST = "workspace::tests::schema_twelve_upgrade_adds_optional_categories_without_consent"
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def run_exact(command: list[str]) -> bool:
completed = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, check=False)
print(completed.stdout, end="")
print(completed.stderr, end="")
return completed.returncode == 0 and "1 passed; 0 failed" in completed.stdout
def result(case_id: str, status: str, reason: str, assertions: list[dict]) -> dict:
evidence = f"cargo exact tests {SERVICE_TEST}, {SCHEMA_TEST}, {QUEUE_TEST}, and {MIGRATION_TEST}"
for assertion in assertions:
assertion.update({"status": status, "evidence": evidence})
files = []
for relative in (
"frontend/src-tauri/src/records.rs",
"frontend/src-tauri/src/preference_records.rs",
"frontend/src-tauri/src/sync_discovery.rs",
"frontend/src-tauri/src/sync_scope.rs",
"frontend/src-tauri/src/workspace.rs",
"frontend/src-tauri/tests/sync_classification.rs",
"frontend/src/features/settings/SyncSettings.vue",
"server sync/tests/host_fixture.py",
):
files.append({"path": relative, "sha256": sha256(ROOT / relative)})
return {
"schema": 1,
"case_id": case_id,
"status": status,
"reason": reason,
"assertions": assertions,
"metrics": {"peak_rss_bytes": None, "max_process_count": None, "denied_access_count": None},
"files": files,
"revisions": [
{"scope": "actual two-client HTTP classification roundtrip", "default_records": 6, "all_selected_records": 12},
{"scope": "logical schema", "version": 1, "workspace_schema": 13},
],
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args()
case_id = os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID", "")
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
assertions = [
{"name": "classification fixtures cover default, optional, forbidden, and unknown data"},
{"name": "the complete default set roundtrips through the actual Sync service"},
{"name": "unselected optional records produce zero remote revisions"},
{"name": "secrets, indexes, vectors, logs, models, and caches produce zero remote revisions"},
{"name": "selected optional records roundtrip through the actual Sync service"},
{"name": "target extension installation records contain no permission or trust state"},
{"name": "an incompatible record schema rejects the write and preserves the local upload queue"},
{"name": "schema 12 upgrades with a readable backup and no inferred consent"},
]
cargo = shutil.which(os.environ.get("CARGO", "cargo"))
passed = case_id == "S-08" and cargo is not None
if passed:
commands = [
[cargo, "test", "--manifest-path", str(MANIFEST), "--locked", "--features", "desktop", "--test", "sync_classification", SERVICE_TEST, "--", "--exact", "--nocapture"],
[cargo, "test", "--manifest-path", str(MANIFEST), "--locked", "--lib", SCHEMA_TEST, "--", "--exact", "--nocapture"],
[cargo, "test", "--manifest-path", str(MANIFEST), "--locked", "--lib", QUEUE_TEST, "--", "--exact", "--nocapture"],
[cargo, "test", "--manifest-path", str(MANIFEST), "--locked", "--lib", MIGRATION_TEST, "--", "--exact", "--nocapture"],
]
for command in commands:
passed = run_exact(command) and passed
payload = result(
case_id,
"PASSED" if passed else "FAILED",
"" if passed else "An exact S-08 classification, roundtrip, compatibility, or migration oracle failed.",
assertions,
)
output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return 0 if passed else 1
if __name__ == "__main__":
raise SystemExit(main())
+5
View File
@@ -72,6 +72,11 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = {
"timeout_seconds": 900,
"required_metrics": (),
},
"S-08": {
"driver": "scripts/acceptance_cases/s08_sync_client.py",
"timeout_seconds": 900,
"required_metrics": (),
},
}
ENV_NAME = re.compile(r"[A-Z][A-Z0-9_]{2,127}")
RUN_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{2,63}")