diff --git a/docs/contracts/Sync-v1契约.md b/docs/contracts/Sync-v1契约.md index 5658729..76df0c8 100644 --- a/docs/contracts/Sync-v1契约.md +++ b/docs/contracts/Sync-v1契约.md @@ -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 capability:Stronghold 会话接入、冲突解决与 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_ms;status 仅允许 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、capabilities;base_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、sha256;package_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` 拒绝打开,不能降级写入。 diff --git a/docs/development/OpenNexus生产验收Runner.md b/docs/development/OpenNexus生产验收Runner.md index 2b4adf9..a2f3d59 100644 --- a/docs/development/OpenNexus生产验收Runner.md +++ b/docs/development/OpenNexus生产验收Runner.md @@ -24,4 +24,4 @@ python scripts/phase3-production-acceptance.py ` 报告目录包含 `summary.json`、`case-manifest.json`、`junit.xml`、`cases/.json` 和脱敏的 `logs/.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。这用于阻止误报,不是这些用例的验收通过。 diff --git a/frontend/src-tauri/src/preference_records.rs b/frontend/src-tauri/src/preference_records.rs index 7627a1b..7ea1611 100644 --- a/frontend/src-tauri/src/preference_records.rs +++ b/frontend/src-tauri/src/preference_records.rs @@ -101,6 +101,60 @@ struct Preferences { markdown: Markdown, presets: Vec, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ConversationMessage { + message_id: String, + parent_message_id: Option, + role: String, + content: String, + thinking: Option, + attachments: Vec, + created_at_ms: i64, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Conversation { + title: String, + active_leaf: Option, + created_at_ms: i64, + updated_at_ms: i64, + messages: Vec, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AgentHistory { + status: String, + input: String, + output: Option, + model: String, + skill_id: Option, + error_code: Option, + error_message: Option, + 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, + default_model: Option, + enabled: bool, + capabilities: Vec, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ExtensionInstallation { + package_kind: String, + package_id: String, + source: String, + version: String, + sha256: String, +} fn decode(value: &Value) -> Result { 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" + ); + } } diff --git a/frontend/src-tauri/src/records.rs b/frontend/src-tauri/src/records.rs index e1f4e64..5c1426a 100644 --- a/frontend/src-tauri/src/records.rs +++ b/frontend/src-tauri/src/records.rs @@ -45,6 +45,17 @@ pub fn user_skill_path(id: &str) -> Result { } Ok(format!("opennexus-records/v1/user-skills/{id}.json")) } +fn portable_id_path(id: &str, prefix: &str, directory: &str) -> Result { + 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 { match (kind, id) { ("task", id) => path(id), @@ -55,6 +66,12 @@ pub fn path_for(kind: &str, id: &str) -> Result { ("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 { 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(¤t).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 + ); + } } diff --git a/frontend/src-tauri/src/sync_discovery.rs b/frontend/src-tauri/src/sync_discovery.rs index eadfee2..67226da 100644 --- a/frontend/src-tauri/src/sync_discovery.rs +++ b/frontend/src-tauri/src/sync_discovery.rs @@ -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}"); } diff --git a/frontend/src-tauri/src/sync_resolution.rs b/frontend/src-tauri/src/sync_resolution.rs index e172856..c39d591 100644 --- a/frontend/src-tauri/src/sync_resolution.rs +++ b/frontend/src-tauri/src/sync_resolution.rs @@ -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 diff --git a/frontend/src-tauri/src/sync_scope.rs b/frontend/src-tauri/src/sync_scope.rs index fa12145..78214f9 100644 --- a/frontend/src-tauri/src/sync_scope.rs +++ b/frontend/src-tauri/src/sync_scope.rs @@ -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::( - 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()); } diff --git a/frontend/src-tauri/src/workspace.rs b/frontend/src-tauri/src/workspace.rs index dfbd0ca..a188800 100644 --- a/frontend/src-tauri/src/workspace.rs +++ b/frontend/src-tauri/src/workspace.rs @@ -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(); diff --git a/frontend/src-tauri/tests/sync_classification.rs b/frontend/src-tauri/tests/sync_classification.rs new file mode 100644 index 0000000..2aa70b3 --- /dev/null +++ b/frontend/src-tauri/tests/sync_classification.rs @@ -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>, + binding: ¬esagent_host::sync_state::Binding, +) { + while client.push_one(workspace, binding).await.unwrap() {} +} + +async fn pull_all( + client: &SyncClient, + workspace: &Arc>, + binding: ¬esagent_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::>(); + 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::( + &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::( + &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::>(); + assert_eq!(keys.len(), 5); + assert!(keys.iter().all(|key| { + matches!( + key.as_str(), + "package_kind" | "package_id" | "source" | "version" | "sha256" + ) + })); +} diff --git a/frontend/src-tauri/tests/sync_push.rs b/frontend/src-tauri/tests/sync_push.rs index 53daece..f909c52 100644 --- a/frontend/src-tauri/tests/sync_push.rs +++ b/frontend/src-tauri/tests/sync_push.rs @@ -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(); } diff --git a/frontend/src/features/settings/SyncSettings.spec.ts b/frontend/src/features/settings/SyncSettings.spec.ts index dc620a0..db9bd6b 100644 --- a/frontend/src/features/settings/SyncSettings.spec.ts +++ b/frontend/src/features/settings/SyncSettings.spec.ts @@ -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() }) diff --git a/frontend/src/features/settings/SyncSettings.vue b/frontend/src/features/settings/SyncSettings.vue index fb3464f..71c6d12 100644 --- a/frontend/src/features/settings/SyncSettings.vue +++ b/frontend/src/features/settings/SyncSettings.vue @@ -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(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) { 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 = '' } {{ t('可选同步内容', 'Optional sync content') }} + + + +

{{ t('默认仅保存在本机。修改已绑定范围时,请先解除绑定,再重新预览合并;关闭选项不会删除远端内容。', 'Kept locally by default. Unbind before changing scope, then preview a new merge. Disabling an option does not delete remote content.') }}

diff --git a/scripts/acceptance_cases/s08_sync_client.py b/scripts/acceptance_cases/s08_sync_client.py new file mode 100644 index 0000000..8eaf02b --- /dev/null +++ b/scripts/acceptance_cases/s08_sync_client.py @@ -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()) diff --git a/scripts/phase3_acceptance.py b/scripts/phase3_acceptance.py index add4e9f..8c98bc0 100644 --- a/scripts/phase3_acceptance.py +++ b/scripts/phase3_acceptance.py @@ -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}")