diff --git a/apps/desktop/src/components/settings/ConfigSyncScopeFields.tsx b/apps/desktop/src/components/settings/ConfigSyncScopeFields.tsx index 9c0f1961f..40ff5fef4 100644 --- a/apps/desktop/src/components/settings/ConfigSyncScopeFields.tsx +++ b/apps/desktop/src/components/settings/ConfigSyncScopeFields.tsx @@ -10,7 +10,6 @@ const CATEGORIES = [ ["skills", "categorySkills"], ["subagents", "categorySubagents"], ["instructions", "categoryInstructions"], - ["projects", "categoryProjects"], ["plugins", "categoryPlugins"], ["automation", "categoryAutomation"], ["memory", "categoryMemory"], diff --git a/apps/desktop/src/features/settings/config-sync-form.ts b/apps/desktop/src/features/settings/config-sync-form.ts index 7b1972414..86a6bb87f 100644 --- a/apps/desktop/src/features/settings/config-sync-form.ts +++ b/apps/desktop/src/features/settings/config-sync-form.ts @@ -15,12 +15,16 @@ export type ConfigSyncForm = ConfigSyncConnection & { }; export function selectionFromDraft(categories?: Partial): ConfigSyncCategorySelection { - return { + const selection: ConfigSyncCategorySelection = { application: true, providers: true, credentials: false, mcp: true, - skills: true, subagents: true, instructions: true, projects: true, + skills: true, subagents: true, instructions: true, plugins: true, automation: true, memory: false, - ...categories, }; + // Project selection from older drafts is no longer part of the portable contract. + for (const key of Object.keys(selection) as Array) { + if (typeof categories?.[key] === "boolean") selection[key] = categories[key]; + } + return selection; } export function initialConfigSyncForm(draft: ConfigSyncDraft = {}): ConfigSyncForm { @@ -44,7 +48,7 @@ export function draftFromState(next: ConfigSyncState): ConfigSyncDraft { endpoint: next.endpoint, region: next.region, bucket: next.bucket, prefix: next.prefix, accessKeyId: next.accessKeyId, pathStyle: next.pathStyle, mode: next.mode, automaticSync: next.automaticSync, deviceLabel: next.deviceLabel, - categories: { ...next.categories, credentials: next.includeSecrets, memory: next.includeMemory }, + categories: selectionFromDraft({ ...next.categories, credentials: next.includeSecrets, memory: next.includeMemory }), saved: true, }; } @@ -54,7 +58,7 @@ export function draftFromForm(form: ConfigSyncForm, categories: ConfigSyncCatego endpoint: form.endpoint, region: form.region, bucket: form.bucket, prefix: form.prefix, accessKeyId: form.accessKeyId, pathStyle: form.pathStyle, mode: form.mode, automaticSync: form.automaticSync, deviceLabel: form.deviceLabel, - categories, saved: false, + categories: selectionFromDraft(categories), saved: false, }; } @@ -68,7 +72,7 @@ export function configSyncTestInput(form: ConfigSyncForm, categories: ConfigSync secretAccessKey: form.secretAccessKey || undefined, // A complete replacement must not accidentally reuse an old temporary token. sessionToken: form.clearSessionToken ? "" : form.sessionToken || (form.secretAccessKey ? "" : undefined), - categories, includeSecrets: categories.credentials, includeMemory: categories.memory, + categories: selectionFromDraft(categories), includeSecrets: categories.credentials, includeMemory: categories.memory, }; } diff --git a/apps/desktop/src/features/settings/config-sync-preferences.ts b/apps/desktop/src/features/settings/config-sync-preferences.ts index fdd8c8dc0..8cfcf3f52 100644 --- a/apps/desktop/src/features/settings/config-sync-preferences.ts +++ b/apps/desktop/src/features/settings/config-sync-preferences.ts @@ -43,7 +43,6 @@ const CATEGORY_KEYS: ConfigSyncCategory[] = [ "skills", "subagents", "instructions", - "projects", "plugins", "automation", "memory", diff --git a/apps/desktop/test/config-sync-form.test.mjs b/apps/desktop/test/config-sync-form.test.mjs index fa71d18c8..d4c87e9d7 100644 --- a/apps/desktop/test/config-sync-form.test.mjs +++ b/apps/desktop/test/config-sync-form.test.mjs @@ -47,3 +47,30 @@ test("saved choices hydrate without turning off opt-ins or turning on automatic assert.equal(draft.categories.memory, true); assert.equal(draft.saved, true); }); + +test("legacy project choices never enter the supported selection or request", () => { + const legacy = { ...selection, projects: true, providers: false }; + const supported = selectionFromDraft(legacy); + assert.equal(Object.hasOwn(supported, "projects"), false); + assert.equal(supported.providers, false); + assert.equal(Object.keys(supported).length, 10); + const form = initialConfigSyncForm(); + for (const value of [ + draftFromState({ categories: legacy, includeSecrets: false, includeMemory: false }).categories, + draftFromForm(form, legacy).categories, + configSyncTestInput(form, legacy, "This device").categories, + configSyncConfigureInput(form, legacy, "This device").categories, + ]) { + assert.equal(Object.hasOwn(value, "projects"), false); + assert.equal(value.providers, false); + } +}); + +test("shared sync categories exclude projects while keeping scoped content", async () => { + const { CONFIG_SYNC_CATEGORIES } = await import("../../../packages/shared/src/types/config-sync.ts"); + assert.equal(CONFIG_SYNC_CATEGORIES.includes("projects"), false); + for (const category of ["mcp", "skills", "instructions", "automation", "memory"]) { + assert.equal(CONFIG_SYNC_CATEGORIES.includes(category), true); + } + assert.deepEqual(Object.keys(selection).sort(), [...CONFIG_SYNC_CATEGORIES].sort()); +}); diff --git a/apps/desktop/test/config-sync-preferences.test.mjs b/apps/desktop/test/config-sync-preferences.test.mjs index 834a9fe3c..4b6144b08 100644 --- a/apps/desktop/test/config-sync-preferences.test.mjs +++ b/apps/desktop/test/config-sync-preferences.test.mjs @@ -94,3 +94,17 @@ test("history expires and connection or mode changes invalidate it immediately", assert.equal(preferences.hasFreshConfigSyncHistory(1000), false, field); } }); + +test("legacy project selection is discarded on draft read and write", () => { + const store = fakeStorage(); + store.setItem(preferences.CONFIG_SYNC_DRAFT_STORAGE_KEY, JSON.stringify({ + ...connection, categories: { projects: true, providers: false, memory: true }, + })); + assert.deepEqual(preferences.readConfigSyncDraft(store), { + ...connection, categories: { providers: false, memory: true }, + }); + preferences.writeConfigSyncDraft({ ...connection, categories: { projects: false, mcp: true } }, store); + const saved = JSON.parse(store.getItem(preferences.CONFIG_SYNC_DRAFT_STORAGE_KEY)); + assert.deepEqual(saved.categories, { mcp: true }); + assert.equal(Object.hasOwn(saved.categories, "projects"), false); +}); diff --git a/apps/desktop/test/config-sync-user-path.test.mjs b/apps/desktop/test/config-sync-user-path.test.mjs index 66f5f9103..b58900645 100644 --- a/apps/desktop/test/config-sync-user-path.test.mjs +++ b/apps/desktop/test/config-sync-user-path.test.mjs @@ -153,6 +153,8 @@ window.configSyncProbe = async () => { check(input("region").value === "test-region", "collapsing advanced settings lost values"); await expand("advancedTitle"); await expand("categoriesTitle"); + check(document.querySelectorAll(".settings-config-sync-category").length === 10, "scope must contain supported categories only"); + check(!document.body.textContent.includes("Projects and folder mappings"), "removed project category still visible"); await toggle("categoryProviders"); check(![...document.querySelectorAll('input[type="checkbox"]')].find((node) => node.closest("label")?.textContent.trim() === label("categoryCredentials")).checked, "credentials opted in by default"); await toggle("categoryCredentials"); @@ -177,6 +179,7 @@ window.configSyncProbe = async () => { await until(() => !button("test").disabled, "probe did not settle"); const probe = last("configSyncTest"); check(probe.mode === "backup" && probe.pathStyle === false && probe.automaticSync === false, "probe lost choices"); + check(!Object.hasOwn(probe.categories, "projects"), "removed project category leaked into probe"); check(probe.region === "test-region" && probe.categories.providers === false && probe.includeSecrets === false, "hidden advanced values or scope lost"); check(probe.secretAccessKey === "test-secret" && probe.sessionToken === "test-token" && !('backupPassword' in probe), "probe credential contract"); await click("enable"); diff --git a/crates/host-core/src/config_sync/apply.rs b/crates/host-core/src/config_sync/apply.rs index 4ab673807..96897129d 100644 --- a/crates/host-core/src/config_sync/apply.rs +++ b/crates/host-core/src/config_sync/apply.rs @@ -488,50 +488,6 @@ fn apply_entity( )?; return Ok(()); } - if entity.domain == domains::DOMAIN_PROJECTS { - let Some(paths) = mapped_project_paths(config, &entity.payload)? else { - bail!("CONFIG_SYNC_MAPPING_REQUIRED: project folder is not mapped"); - }; - let logical_id = entity - .payload - .get("logicalId") - .and_then(Value::as_str) - .unwrap_or_default(); - let is_group = logical_id.starts_with("project-group:") - || entity - .payload - .get("roots") - .and_then(Value::as_array) - .is_some_and(|roots| roots.len() > 1) - || paths.len() > 1; - if entity.deleted { - if is_group { - if let Some(group) = st.db.project_group_for_path(&paths[0])? { - if !group.legacy { - st.db.delete_project_group_record(&group.id)?; - } - } - } - } else if is_group { - let name = entity - .payload - .get("name") - .and_then(Value::as_str) - .unwrap_or("Imported project") - .to_string(); - match st.db.project_group_for_path(&paths[0])? { - Some(group) if !group.legacy => { - st.db.update_project_group(&group.id, &name, &paths)?; - } - _ => { - st.db.create_project_group(&name, &paths)?; - } - } - } else { - st.db.ensure_project(&paths[0], false)?; - } - return Ok(()); - } if entity.domain == domains::DOMAIN_PLUGINS { let plugin_id = entity .payload @@ -602,9 +558,7 @@ fn retain_deferred_entity(bundle: &mut PendingBundle, entity: &PortableEntity, r fn apply_priority(domain: &str) -> u8 { match domain { - // Project roots and providers are prerequisites for scoped capability - // files and application-level model bindings. - domains::DOMAIN_PROJECTS => 10, + // Providers are prerequisites for application-level model bindings. domains::DOMAIN_PROVIDERS => 20, domains::DOMAIN_INSTRUCTIONS => 30, domains::DOMAIN_MCP diff --git a/crates/host-core/src/config_sync/baseline.rs b/crates/host-core/src/config_sync/baseline.rs index d01a92672..ddab3f86b 100644 --- a/crates/host-core/src/config_sync/baseline.rs +++ b/crates/host-core/src/config_sync/baseline.rs @@ -9,7 +9,13 @@ pub(super) struct Baseline { } pub(super) fn load(st: &AppState, key: &VaultKey) -> Result> { - load_encrypted(st, key, "local-base", &local_path(st, "base.bin")?) + let baseline: Option = + load_encrypted(st, key, "local-base", &local_path(st, "base.bin")?)?; + Ok(baseline.map(|mut baseline| { + domains::retain_supported_entities(&mut baseline.shared); + domains::retain_supported_entities(&mut baseline.observed); + baseline + })) } pub(super) fn store( diff --git a/crates/host-core/src/config_sync/domains.rs b/crates/host-core/src/config_sync/domains.rs index fd7fd0d32..0bf1d2015 100644 --- a/crates/host-core/src/config_sync/domains.rs +++ b/crates/host-core/src/config_sync/domains.rs @@ -16,7 +16,7 @@ pub(crate) use capture_impl::{ capture, global_instruction_path_for_sync, remove_instruction_file, write_instruction_file, }; #[cfg(test)] -pub(crate) use capture_impl::{capture_instructions, capture_memory, capture_projects}; +pub(crate) use capture_impl::{capture_instructions, capture_memory}; pub const DOMAIN_APPLICATION: &str = "application"; pub const DOMAIN_PROVIDERS: &str = "providers"; @@ -24,7 +24,6 @@ pub const DOMAIN_MCP: &str = "mcp"; pub const DOMAIN_SKILLS: &str = "skills"; pub const DOMAIN_SUBAGENTS: &str = "subagents"; pub const DOMAIN_INSTRUCTIONS: &str = "instructions"; -pub const DOMAIN_PROJECTS: &str = "projects"; pub const DOMAIN_PLUGINS: &str = "plugins"; pub const DOMAIN_AUTOMATION: &str = "automation"; pub const DOMAIN_MEMORY: &str = "memory"; @@ -59,14 +58,13 @@ const PROJECT_IDENTITY_NAMESPACE: &str = "configSyncIdentities"; const PROJECT_IDENTITY_KEY: &str = "projects"; const PROJECT_GROUP_IDENTITY_KEY: &str = "groups"; -pub const ALL_DOMAINS: [&str; 10] = [ +pub const ALL_DOMAINS: [&str; 9] = [ DOMAIN_APPLICATION, DOMAIN_PROVIDERS, DOMAIN_MCP, DOMAIN_SKILLS, DOMAIN_SUBAGENTS, DOMAIN_INSTRUCTIONS, - DOMAIN_PROJECTS, DOMAIN_PLUGINS, DOMAIN_AUTOMATION, DOMAIN_MEMORY, @@ -207,24 +205,6 @@ pub const DOMAIN_ADAPTERS: &[DomainAdapterSpec] = &[ activation_policy: "approval required before write", recovery_policy: "atomic file replacement journal", }, - DomainAdapterSpec { - domain: DOMAIN_PROJECTS, - schema_version: 1, - exportable_fields: &[ - "logical identity", - "name", - "group roots", - "ordering", - "pinned", - ], - secret_fields: &[], - local_overlays: &["absolute folder bindings"], - identity: "host-assigned logical project/group id", - references: &["root mappings"], - merge_granularity: "project or ordered group", - activation_policy: "mapping and approval required", - recovery_policy: "database import journal", - }, DomainAdapterSpec { domain: DOMAIN_PLUGINS, schema_version: 1, @@ -348,7 +328,7 @@ impl CategorySelection { } pub fn is_selected(&self, domain: &str) -> bool { - self.0.get(domain).copied().unwrap_or(true) + ALL_DOMAINS.contains(&domain) && self.0.get(domain).copied().unwrap_or(true) } pub fn include_secrets(&self, requested: bool) -> bool { @@ -585,6 +565,22 @@ pub fn snapshot_digest(snapshot: &LocalSnapshot) -> String { hex::encode(hasher.finalize()) } +/// Remove retired domains from decoded revisions and local recovery state. +/// Resource references are projected with the retained entities, after remote +/// readers have validated the complete authenticated manifest. +pub(crate) fn retain_supported_entities(manifest: &mut RevisionManifest) { + manifest + .entities + .retain(|entity| adapter_for(&entity.domain).is_some()); + manifest.resource_ids = manifest + .entities + .iter() + .flat_map(|entity| entity.resource_ids.iter().cloned()) + .collect::>() + .into_iter() + .collect(); +} + pub fn preview(snapshot: &LocalSnapshot, selection: &CategorySelection) -> PreviewCounts { let mut counts = PreviewCounts::default(); let mut by_category: BTreeMap = BTreeMap::new(); @@ -719,23 +715,28 @@ mod tests { .ensure_project(&project.to_string_lossy(), false) .unwrap(); - let first = capture_projects(&mut state, &ProjectIdentityOverrides::default()).unwrap(); - let second = capture_projects(&mut state, &ProjectIdentityOverrides::default()).unwrap(); - assert_eq!( - first[0].payload["logicalId"], - second[0].payload["logicalId"] - ); - assert!(first[0].payload["logicalId"] - .as_str() - .is_some_and(|value| value.starts_with("project:"))); let stored_path = state.db.list_projects().unwrap()[0].path.clone(); + let first = project_scope( + &mut state, + &stored_path, + &ProjectIdentityOverrides::default(), + ) + .unwrap(); + let second = project_scope( + &mut state, + &stored_path, + &ProjectIdentityOverrides::default(), + ) + .unwrap(); + assert_eq!(first.0, second.0); + assert!(first.0.starts_with("project:")); let mut overrides = ProjectIdentityOverrides::default(); overrides .project_ids - .insert(stored_path, "project:from-other-device".into()); - let mapped = capture_projects(&mut state, &overrides).unwrap(); - assert_eq!(mapped[0].payload["logicalId"], "project:from-other-device"); + .insert(stored_path.clone(), "project:from-other-device".into()); + let mapped = project_scope(&mut state, &stored_path, &overrides).unwrap(); + assert_eq!(mapped.0, "project:from-other-device"); } #[test] diff --git a/crates/host-core/src/config_sync/domains_capture.rs b/crates/host-core/src/config_sync/domains_capture.rs index 2bff4859e..b787c677c 100644 --- a/crates/host-core/src/config_sync/domains_capture.rs +++ b/crates/host-core/src/config_sync/domains_capture.rs @@ -299,69 +299,6 @@ fn capture_subagents( Ok((result, resources)) } -pub(crate) fn capture_projects( - st: &mut AppState, - identities: &ProjectIdentityOverrides, -) -> Result> { - let mut result = Vec::new(); - for project in st.db.list_projects()? { - let (project_id, group_id, _) = project_scope(st, &project.path, identities)?; - if group_id.is_some() { - // Non-legacy project groups are represented by their group entity; - // exporting each root as another project would duplicate the - // logical configuration and weaken primary-root invariants. - continue; - } - let payload = json!({ - "logicalId": project_id, - "name": project.name, - "pinned": project.pinned, - }); - let label = payload["name"].as_str().unwrap_or("Project").to_string(); - result.push(entity( - DOMAIN_PROJECTS, - format!("project:{project_id}"), - label, - payload, - true, - false, - true, - Vec::new(), - )); - } - for group in st.db.list_project_groups()? { - if group.legacy { - continue; - } - let (_, group_id, _) = project_scope(st, &group.primary_path, identities)?; - let logical_id = match group_id { - Some(logical_id) => logical_id, - None => project_group_logical_id(st, &group)?, - }; - let payload = json!({ - "logicalId": logical_id, - "name": group.name.clone(), - "pinned": group.pinned, - "roots": group - .roots - .iter() - .map(|root| json!({ "name": root.name, "position": root.position })) - .collect::>(), - }); - result.push(entity( - DOMAIN_PROJECTS, - format!("project-group:{}", group.id), - group.name, - payload, - true, - false, - true, - Vec::new(), - )); - } - Ok(result) -} - pub(crate) fn capture_memory( st: &mut AppState, identities: &ProjectIdentityOverrides, @@ -776,12 +713,6 @@ pub(crate) fn capture( .entities .extend(capture_instructions(st, identities)?); } - if selection.is_selected(DOMAIN_PROJECTS) { - snapshot - .manifest - .entities - .extend(capture_projects(st, identities)?); - } if selection.is_selected(DOMAIN_PLUGINS) { snapshot .manifest diff --git a/crates/host-core/src/config_sync/engine.rs b/crates/host-core/src/config_sync/engine.rs index b2c8a36c9..7efb1abae 100644 --- a/crates/host-core/src/config_sync/engine.rs +++ b/crates/host-core/src/config_sync/engine.rs @@ -266,7 +266,15 @@ fn default_categories() -> BTreeMap { fn selection(config: &StoredConfig) -> CategorySelection { let mut values = default_categories(); - values.extend(config.categories.clone()); + values.extend( + config + .categories + .iter() + .filter(|(domain, _)| { + domains::ALL_DOMAINS.contains(&domain.as_str()) || domain.as_str() == "credentials" + }) + .map(|(domain, selected)| (domain.clone(), *selected)), + ); values.insert("credentials".to_string(), config.include_secrets); values.insert(domains::DOMAIN_MEMORY.to_string(), config.include_memory); CategorySelection(values) @@ -351,11 +359,14 @@ fn load_config(st: &AppState) -> Result> { let Some(value) = st.db.kv_get(CONFIG_NS, CONFIG_KEY)? else { return Ok(None); }; - let config: StoredConfig = + let mut config: StoredConfig = serde_json::from_value(value).context("decode config sync settings")?; if config.format != FORMAT || config.version != 1 { bail!("CONFIG_SYNC_UNSUPPORTED: unsupported local sync settings"); } + config.categories.retain(|domain, _| { + domains::ALL_DOMAINS.contains(&domain.as_str()) || domain == "credentials" + }); Ok(Some(config)) } @@ -806,7 +817,21 @@ fn load_base(st: &AppState, key: &VaultKey) -> Result> } fn load_pending(st: &AppState, key: &VaultKey) -> Result> { - load_encrypted(st, key, "local-pending", &local_path(st, "pending.bin")?) + let pending: Option = + load_encrypted(st, key, "local-pending", &local_path(st, "pending.bin")?)?; + Ok(pending.map(|mut bundle| { + domains::retain_supported_entities(&mut bundle.manifest); + if let Some(local) = &mut bundle.local_before { + domains::retain_supported_entities(local); + } + bundle + .approvals + .retain(|item| domains::adapter_for(&item.entity.domain).is_some()); + bundle + .conflicts + .retain(|item| domains::adapter_for(&item.domain).is_some()); + bundle + })) } fn store_pending(st: &AppState, key: &VaultKey, pending: &PendingBundle) -> Result<()> { @@ -886,7 +911,7 @@ fn send_state_notification(tx: &mpsc::UnboundedSender, st: &mut AppState mod tests { use super::*; use crate::config_sync::domains::{ - DOMAIN_APPLICATION, DOMAIN_MCP, DOMAIN_PROJECTS, DOMAIN_PROVIDERS, DOMAIN_SKILLS, + DOMAIN_APPLICATION, DOMAIN_MCP, DOMAIN_PROVIDERS, DOMAIN_SKILLS, }; use crate::mcp_servers::McpServerInput; use crate::user_skills::UserSkillInput; @@ -1023,7 +1048,6 @@ mod tests { "credentials": true, "mcp": true, "skills": true, - "projects": true, }, "includeSecrets": true, "automaticSync": false, @@ -1148,6 +1172,11 @@ mod tests { std::fs::create_dir_all(&project_b)?; let project_a = project_a.canonicalize()?; let project_b = project_b.canonicalize()?; + device_b + .lock() + .await + .db + .ensure_project(&project_b.to_string_lossy(), false)?; let provider_id = { let mut state = device_a.lock().await; @@ -1233,7 +1262,7 @@ mod tests { ); assert!(captured.contains(DOMAIN_MCP), "captured: {captured:?}"); assert!(captured.contains(DOMAIN_SKILLS), "captured: {captured:?}"); - assert!(captured.contains(DOMAIN_PROJECTS), "captured: {captured:?}"); + assert!(!captured.contains("projects"), "captured: {captured:?}"); } let (tx, _rx) = mpsc::unbounded_channel(); @@ -1349,7 +1378,7 @@ mod tests { assert!(domains.contains(DOMAIN_PROVIDERS), "domains: {domains:?}"); assert!(domains.contains(DOMAIN_MCP), "domains: {domains:?}"); assert!(domains.contains(DOMAIN_SKILLS), "domains: {domains:?}"); - assert!(domains.contains(DOMAIN_PROJECTS), "domains: {domains:?}"); + assert!(!domains.contains("projects"), "domains: {domains:?}"); let mapping_key = received .get("pendingApprovals") .and_then(Value::as_array) diff --git a/crates/host-core/src/config_sync/engine_remote.rs b/crates/host-core/src/config_sync/engine_remote.rs index 9ce1dadf5..f7bdedfbb 100644 --- a/crates/host-core/src/config_sync/engine_remote.rs +++ b/crates/host-core/src/config_sync/engine_remote.rs @@ -178,7 +178,7 @@ pub(super) async fn read_remote_manifest( &config.vault_id, &ciphertext, )?; - let manifest: RevisionManifest = + let mut manifest: RevisionManifest = serde_json::from_slice(&plain).context("decode remote revision")?; if manifest.format != REVISION_FORMAT || manifest.version != 1 @@ -192,7 +192,9 @@ pub(super) async fn read_remote_manifest( validate_remote_id(parent, "revision")?; } for entity in &manifest.entities { - if domains::adapter_for(&entity.domain).is_none() { + // Retired project records remain readable in existing vault history, + // but are never merged or applied by this device. + if entity.domain != "projects" && domains::adapter_for(&entity.domain).is_none() { bail!( "CONFIG_SYNC_UNSUPPORTED: remote domain {} is not supported", entity.domain @@ -229,6 +231,7 @@ pub(super) async fn read_remote_manifest( for object_id in resource_ids { validate_remote_id(&object_id, "object")?; } + domains::retain_supported_entities(&mut manifest); Ok(manifest) } diff --git a/crates/host-core/src/config_sync/engine_s3_tests.rs b/crates/host-core/src/config_sync/engine_s3_tests.rs index fea93a079..70584e192 100644 --- a/crates/host-core/src/config_sync/engine_s3_tests.rs +++ b/crates/host-core/src/config_sync/engine_s3_tests.rs @@ -1,3 +1,4 @@ +use super::remote::{revision_path, serialize_json}; use super::tests::{ configure_test_device, configure_test_device_mode, pending_approvals, run_in_isolated_process, }; @@ -230,6 +231,9 @@ async fn resource_updates_and_packaged_skill_deletion_reach_the_other_device() - fs::create_dir_all(&project_b)?; let project_a = project_a.canonicalize()?.to_string_lossy().into_owned(); let project_b = project_b.canonicalize()?.to_string_lossy().into_owned(); + // Project registration is device-local; scoped capability capture requires + // the receiving device to have registered its own mapped project. + device_b.lock().await.db.ensure_project(&project_b, false)?; { let mut state = device_a.lock().await; state.db.ensure_project(&project_a, false)?; @@ -361,3 +365,214 @@ async fn chosen_conflict_is_published_without_reopening_old_candidates() -> Resu } Ok(()) } + +fn legacy_project(entity_id: &str, payload: Value, deleted: bool) -> PortableEntity { + PortableEntity { + domain: "projects".into(), + entity_id: entity_id.into(), + label: "Legacy project".into(), + deleted, + requires_approval: true, + secret_bearing: false, + mapping_required: true, + digest: format!("legacy-{entity_id}-{deleted}"), + payload, + resource_ids: Vec::new(), + } +} + +#[tokio::test] +async fn legacy_project_revision_sync_and_restore_preserve_local_projects() -> Result<()> { + if run_in_isolated_process( + "config_sync::engine::s3_tests::legacy_project_revision_sync_and_restore_preserve_local_projects", + )? { + return Ok(()); + } + let fixture = fixture(false).await; + let directory_a = tempfile::tempdir()?; + let directory_b = tempfile::tempdir()?; + let device_a = configure_test_device(directory_a.path(), &fixture.endpoint).await?; + let device_b = configure_test_device(directory_b.path(), &fixture.endpoint).await?; + let project_path = directory_b.path().join("existing-project"); + fs::create_dir_all(&project_path)?; + let project_path = project_path.canonicalize()?.to_string_lossy().into_owned(); + let (projects_before, groups_before) = { + let state = device_b.lock().await; + state.db.ensure_project(&project_path, false)?; + state + .db + .create_project_group("Local group", std::slice::from_ref(&project_path))?; + let mut config = load_config(&state)?.expect("configured device"); + config + .project_mappings + .insert("existing".into(), project_path.clone()); + config + .project_mappings + .insert("removed".into(), project_path.clone()); + config.project_group_mappings.insert( + "project-group:existing-group".into(), + vec![project_path.clone()], + ); + save_config(&state, &config)?; + ( + serde_json::to_value(state.db.list_projects()?)?, + serde_json::to_value(state.db.list_project_groups()?)?, + ) + }; + let legacy_revision = { + let mut state = device_a.lock().await; + state.db.set_setting("app", &json!({"theme":"light"}))?; + let config = load_config(&state)?.expect("configured device"); + let key = local_vault_key(&state, &config)?.expect("unlocked device"); + let transport = configured_transport(&state, &config)?; + let mut snapshot = domains::capture( + &mut state, + &key, + &selection(&config), + config.include_secrets, + &config.plugin_intents, + &identity_overrides(&config), + )?; + snapshot.manifest.entities.extend([ + legacy_project("project:existing", json!({"logicalId":"existing","name":"Remote name","pinned":true}), false), + legacy_project("project:removed", json!({"logicalId":"removed"}), true), + legacy_project("project:new", json!({"logicalId":"new","name":"New remote project"}), false), + legacy_project("project-group:existing-group", json!({"logicalId":"project-group:existing-group","name":"Remote group","roots":[{"name":"Remote root","position":0}]}), false), + ]); + // Write authenticated historical bytes directly: the current exporter + // must never produce the retired domain, even for compatibility tests. + let revision_id = snapshot.manifest.revision_id.clone(); + let encrypted = encrypt_object( + &key, + &format!("manifest/{revision_id}"), + &config.vault_id, + &serialize_json(&snapshot.manifest)?, + )?; + assert!( + transport + .put_if_none(&revision_path(&config, &revision_id)?, encrypted) + .await? + ); + assert!( + publish_head( + &transport, + &config, + &key, + &next_head(&revision_id, None), + None + ) + .await? + ); + revision_id + }; + let (tx, _rx) = mpsc::unbounded_channel(); + let received = sync_now(device_b.clone(), tx.clone()).await?; + assert!(pending_approvals(&received).is_empty()); + assert_eq!(theme(&*device_b.lock().await)?, "light"); + assert!(list_history(device_b.clone()).await?.as_array().is_some()); + { + let state = device_b.lock().await; + assert_eq!( + serde_json::to_value(state.db.list_projects()?)?, + projects_before + ); + assert_eq!( + serde_json::to_value(state.db.list_project_groups()?)?, + groups_before + ); + state.db.set_setting("app", &json!({"theme":"dark"}))?; + } + sync_now(device_b.clone(), tx.clone()).await?; + let restored = restore( + device_b.clone(), + json!({"revisionId":legacy_revision,"acknowledgePropagation":true}), + tx, + ) + .await?; + assert!(pending_approvals(&restored).is_empty()); + let state = device_b.lock().await; + assert_eq!(theme(&state)?, "light"); + assert_eq!( + serde_json::to_value(state.db.list_projects()?)?, + projects_before + ); + assert_eq!( + serde_json::to_value(state.db.list_project_groups()?)?, + groups_before + ); + Ok(()) +} + +#[tokio::test] +async fn legacy_project_config_and_pending_approval_do_not_block_sync() -> Result<()> { + if run_in_isolated_process( + "config_sync::engine::s3_tests::legacy_project_config_and_pending_approval_do_not_block_sync", + )? { + return Ok(()); + } + let fixture = fixture(false).await; + let directory = tempfile::tempdir()?; + let device = configure_test_device(directory.path(), &fixture.endpoint).await?; + { + let mut state = device.lock().await; + let config = load_config(&state)?.expect("configured device"); + let key = local_vault_key(&state, &config)?.expect("unlocked device"); + let mut raw_config = serde_json::to_value(&config)?; + raw_config["categories"]["projects"] = json!(true); + state.db.kv_set(CONFIG_NS, CONFIG_KEY, &raw_config)?; + assert!(!load_config(&state)? + .expect("configured device") + .categories + .contains_key("projects")); + let snapshot = domains::capture( + &mut state, + &key, + &selection(&config), + config.include_secrets, + &config.plugin_intents, + &identity_overrides(&config), + )?; + let entity = legacy_project("project:obsolete", json!({"logicalId":"obsolete"}), false); + let mut manifest = snapshot.manifest; + manifest.entities.push(entity.clone()); + store_pending( + &state, + &key, + &PendingBundle { + remote_revision_id: manifest.revision_id.clone(), + local_before: Some(manifest.clone()), + manifest, + resources: BTreeMap::new(), + approvals: vec![PendingApproval { + approval_id: "legacy-approval".into(), + entity, + reason: "mapping".into(), + approved: false, + }], + conflicts: Vec::new(), + journal_state: "staged".into(), + }, + )?; + let pending = load_pending(&state, &key)?.expect("pending journal"); + assert!(pending.approvals.is_empty()); + assert!(pending + .manifest + .entities + .iter() + .all(|entity| entity.domain != "projects")); + assert!(pending + .local_before + .as_ref() + .expect("local snapshot") + .entities + .iter() + .all(|entity| entity.domain != "projects")); + } + let (tx, _rx) = mpsc::unbounded_channel(); + assert!(pending_approvals(&get_state(device.clone()).await?).is_empty()); + let synced = sync_now(device.clone(), tx).await?; + assert!(pending_approvals(&synced).is_empty()); + assert!(synced["lastRevisionId"].as_str().is_some()); + assert!(device.lock().await.db.list_projects()?.is_empty()); + Ok(()) +} diff --git a/docs/adr/README.md b/docs/adr/README.md index 7dc525eee..a4b92cdc3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -20,6 +20,7 @@ Each ADR includes: | ID | Title | Status | |---|---|---| +| device-local-project-lists-in-config-sync | [Device-local project lists in configuration sync](device-local-project-lists-in-config-sync.md) | Accepted | | s3-only-configuration-backup-sync | [S3-only encrypted configuration backup and synchronization](s3-only-configuration-backup-sync.md) | Accepted; supersedes WebDAV decisions in 0300 / 0301 | | remote-provider-import | [Explicit provider import into a running remote Host](remote-provider-import.md) | Accepted for the experimental SSH MVP | | remote-project-home-workflow | [Remote projects in the shared Chat home workflow](remote-project-home-workflow.md) | Accepted | diff --git a/docs/adr/device-local-project-lists-in-config-sync.md b/docs/adr/device-local-project-lists-in-config-sync.md new file mode 100644 index 000000000..337fa79d1 --- /dev/null +++ b/docs/adr/device-local-project-lists-in-config-sync.md @@ -0,0 +1,54 @@ +# ADR: Device-local project lists in configuration sync + +- Status: Accepted +- Date: 2026-10-11 + +## Context + +Project and project-group names, pinning, root lists, and ordering describe a +device's workspace. Synchronizing these records can register unwanted local +workspaces. Project-scoped capabilities still need portable logical bindings +and explicit local folder mappings, without synchronizing the workspace list. +Existing encrypted vaults and local sync state can already contain `projects` +entities and a selected projects category. + +## Decision + +Remove `projects` from the public `ConfigSyncCategory` contract, category UI, +adapter registry, capture, and application. Normalize saved configuration and +draft categories to the supported category set. Filter retired project entities +from local baselines and pending imports. Validate and authenticate existing +remote revisions completely before ignoring their `projects` entities during +sync or restore. + +Keep local project records and remote revision history intact. Do not publish +project deletions to retire the category. Preserve logical identities and local +folder mappings for project-scoped MCP, skills, instructions, memory, and +automation. Receiving devices register their own workspaces when required. +Process ownership, encryption, and approval boundaries remain unchanged. + +## Alternatives + +- Disable projects by default but retain the category: rejected because it + still permits workspace-list synchronization. +- Remove all project-scoped configuration: rejected because it unnecessarily + removes capability synchronization and local mapping support. +- Delete old project entities from cloud history: rejected because immutable + authenticated revisions remain valid restore points for supported domains. + +## Consequences + +New clients no longer capture, preview, activate, or restore workspace-list +metadata. Consumers of `ConfigSyncCategory` must stop requesting `projects`. +Previously saved category selections and retired local imports are normalized +without requiring users to disconnect or remove application data. Other +domains in old revisions remain restorable after full validation. + +Older clients can still publish project entities into a shared vault; new +clients ignore them. This does not revoke old-client behavior or erase cloud +history. Project-scoped capability imports continue to require explicit folder +mapping and approval, and never synchronize local project names or ordering. + +The current behavioral contract is +[Portable configuration sync](../spec/03-runtime/22-config-sync.md); executable +compatibility and multi-device tests prove the implemented boundaries. diff --git a/docs/spec/03-runtime/22-config-sync.md b/docs/spec/03-runtime/22-config-sync.md index 03c70a39f..db78097f8 100644 --- a/docs/spec/03-runtime/22-config-sync.md +++ b/docs/spec/03-runtime/22-config-sync.md @@ -33,7 +33,7 @@ granularity, activation, and recovery policy for every supported domain. The current adapter set covers application preferences, user-owned providers, MCP definitions, user skills, global subagents, app-managed global/project -instructions and projects, plugin installation intent, scheduled automations, +instructions, plugin installation intent, scheduled automations, and optional project memory. Credentials are opt-in. Provider API keys and MCP environment variables/headers are included only when the credential category is selected; OAuth access/refresh tokens and cookies are never exported. @@ -51,6 +51,15 @@ per package, 2 MiB per resource, and 16 MiB per package, while the skill document itself stays capped at 128 KiB because it can reach a prompt. Scripts are stored as bytes and are never executed by import. +Project and project-group lists are device-local. Their names, pinning, root +lists, and ordering are not captured or applied by sync or backup, and there is +no projects category. Legacy `projects` entities in existing vault revisions +are ignored during sync and restore; this does not delete local projects or +remote revision history. Project-scoped MCP, skills, instructions, memory, and +automation bindings remain supported through local folder mappings. Receiving +devices register their own projects and groups when those features require a +local workspace. + Project and workspace bindings are represented by opaque logical identifiers. Host-core assigns a persistent logical identity to each registered standalone project; project-group roots derive identities from the group identity and diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 0f768c6da..12aaa7fcf 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -9557,7 +9557,13 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. never interpret 502 as a missing object. LAN HTTP follows network policy. 3. Configure two sync devices. Publish a provider and mapped packaged skill, receive them as pending where approval is required, approve, and verify - visible persisted configuration and resource bytes. Exercise disjoint edits, + visible persisted configuration and resource bytes. Verify there is no + projects category and publishing project/group metadata neither registers + nor changes projects/groups on the receiving device. Register its workspace + locally, then map and approve the project skill; its resources must arrive + without changing local group names, roots or ordering. Existing vault + `projects` entities are ignored on sync/restore, preserving local projects + and cloud history. Exercise disjoint edits, same-entity conflicts and explicit choice convergence, remote deletion versus offline edit, and repeated polling. Credential opt-out must not manufacture local edits or prevent deletion convergence. No real executable diff --git a/docs/zh-CN/adr/index.md b/docs/zh-CN/adr/index.md index dc1b190ac..b6f882bbb 100644 --- a/docs/zh-CN/adr/index.md +++ b/docs/zh-CN/adr/index.md @@ -11,6 +11,7 @@ ADR 记录那些不应被静默改变的架构选择。中文入口与英文索 | 决策 | 说明 | |---|---| +| [配置同步中的本机项目列表](/adr/device-local-project-lists-in-config-sync) | 移除 projects 类别,忽略旧项目实体,保留云端历史及项目级能力的本地映射 | | [ADR 0001:Electron 桌面壳](/adr/0001-use-electron) | 桌面窗口与平台能力的承载层 | | [ADR 0005:本地插件系统](/adr/0005-user-installable-plugin-system) | 用户安装插件的第一阶段边界 | | [ADR 0009:English-first 全球化](/adr/0009-english-first-globalization) | 源语言、术语和协作规则 | diff --git a/docs/zh-CN/spec/03-runtime/22-config-sync.md b/docs/zh-CN/spec/03-runtime/22-config-sync.md index d04d5bff7..20f91a6f2 100644 --- a/docs/zh-CN/spec/03-runtime/22-config-sync.md +++ b/docs/zh-CN/spec/03-runtime/22-config-sync.md @@ -24,11 +24,13 @@ application. Electron Main is a thin IPC bridge; Agent Runtime owns no sync. ## 2. 便携域 -当前适配器集合覆盖应用偏好、用户所有的 providers、MCP 定义、用户 skills、全局 subagents、由应用管理的全局/项目指令与项目、插件安装意图、定时自动化任务以及可选的项目记忆。凭据默认需要明确选择。只有选中凭据类别时才会包含 provider API key 和 MCP 环境变量/headers;OAuth access/refresh token 与 cookies 永远不会导出。 +当前适配器集合覆盖应用偏好、用户所有的 providers、MCP 定义、用户 skills、全局 subagents、由应用管理的全局/项目指令、插件安装意图、定时自动化任务以及可选的项目记忆。凭据默认需要明确选择。只有选中凭据类别时才会包含 provider API key 和 MCP 环境变量/headers;OAuth access/refresh token 与 cookies 永远不会导出。 指令适配器只读取固定的全局 `~/.pi/agent/AGENTS.md` 和每个已注册项目根目录的 `AGENTS.md`;不会扫描嵌套仓库或任意文件。导入的指令文件只有在明确选择其作用域、完成必要映射并获得审批后才会写入。 指令文件没有单独的字节上限;其内容只受 Host 在上传与远程校验时统一的便携实体负载上限约束。 目录形态的 skills 会将有界的同级资源作为已认证对象携带。Host 会校验 package path、symlink、冲突、文件数量和总大小后才写入已审批的资源:每个 package 最多 256 个资源、单个资源 2 MiB、整个 package 16 MiB;技能文档本身仍保持 128 KiB 上限,因为它可能进入 prompt。脚本按字节存储,导入时绝不执行。 +项目和项目组列表保留在本机。同步和备份均不采集或应用其名称、置顶、根目录列表和排序,也不提供 projects 类别。同步和恢复会忽略既有 vault 修订中的旧 `projects` 实体;不会因此删除本机项目或云端修订历史。项目级 MCP、skills、指令、记忆和自动化绑定仍通过本地文件夹映射受支持。需要本地 workspace 的功能由接收设备自行注册项目和项目组。 + 项目和 workspace 绑定使用不透明的逻辑标识符表示。Host-core 为每个已注册的独立项目分配持久逻辑身份;项目组根目录的身份由组身份和有序根位置派生。设备映射把接收的逻辑 ID 覆盖到本地文件夹,因此不同绝对路径不会产生重复实体。 传入的项目作用域可执行内容在本地文件夹映射和审批存在前保持暂存。加密的便携实体负载中不会写入本地路径。没有受支持适配器的插件数据会报告为不支持,而不会被声称为已同步。已审批的插件安装意图保存在 Host 所有的本地覆盖中;它不会拉取插件字节或权限授予。 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index b15e2e44c..d25630fca 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -8945,6 +8945,10 @@ Follow the [current S3 acceptance scenario](/spec/06-delivery/04-e2e-test-plan#e Local S3 fixtures exercise conditional writes, authentication, merge, conflict choices and repeated convergence, deletions, credential opt-out, approval and skill resources. +- 项目和项目组列表保留在本机,不提供 projects 同步类别。发布项目/项目组元数据 + 不会在接收设备创建或修改项目/项目组;接收设备自行注册 workspace 后,映射并 + 审批项目级技能,验证资源成功到达而本机项目组名称、根目录及排序保持不变。 + 同步/恢复忽略旧 vault 的 `projects` 实体,保留本机项目和云端历史。 - Backup only uploads local snapshots; histories are mode-specific. Sync restore publishes with explicit acknowledgment; backup restore stays local. - The real settings-page test covers secret-free drafts, saved credential reuse, diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 72404b43a..ac8cb4c16 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -903,7 +903,6 @@ sklm: { categorySkills: "Skills", categorySubagents: "Subagents", categoryInstructions: "Instructions and prompt templates", - categoryProjects: "Projects and folder mappings", categoryPlugins: "Plugin installation intent", categoryAutomation: "Automation definitions", categoryMemory: "Project memory", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index ea59b4d5c..6eb776181 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -1185,7 +1185,6 @@ sklm: { categorySkills: "Skills", categorySubagents: "Subagents", categoryInstructions: "Instructions and prompt templates", - categoryProjects: "Projects and folder mappings", categoryPlugins: "Plugin installation intent", categoryAutomation: "Automation definitions", categoryMemory: "Project memory", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 38fc4288f..7b6afb85d 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -903,7 +903,6 @@ sklm: { categorySkills: "Skills", categorySubagents: "Subagents", categoryInstructions: "Instructions and prompt templates", - categoryProjects: "Projects and folder mappings", categoryPlugins: "Plugin installation intent", categoryAutomation: "Automation definitions", categoryMemory: "Project memory", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index 6f1250acc..0fa077165 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -903,7 +903,6 @@ sklm: { categorySkills: "Skills", categorySubagents: "Subagents", categoryInstructions: "Instructions and prompt templates", - categoryProjects: "Projects and folder mappings", categoryPlugins: "Plugin installation intent", categoryAutomation: "Automation definitions", categoryMemory: "Project memory", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index b96f9920a..616019f23 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -911,7 +911,6 @@ sklm: { categorySkills: "Skills", categorySubagents: "Subagents", categoryInstructions: "Instructions and prompt templates", - categoryProjects: "Projects and folder mappings", categoryPlugins: "Plugin installation intent", categoryAutomation: "Automation definitions", categoryMemory: "Project memory", diff --git a/packages/i18n/src/locales/pt-BR/index.ts b/packages/i18n/src/locales/pt-BR/index.ts index 169f89f7b..a5af210e4 100644 --- a/packages/i18n/src/locales/pt-BR/index.ts +++ b/packages/i18n/src/locales/pt-BR/index.ts @@ -1151,7 +1151,6 @@ export const ptBR = { categorySkills: "Habilidades", categorySubagents: "Subagentes", categoryInstructions: "Instruções e modelos de prompt", - categoryProjects: "Projetos e mapeamentos de pastas", categoryPlugins: "Intenção de instalação de plugins", categoryAutomation: "Definições de automação", categoryMemory: "Memória do projeto", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index 965cda3a0..276906c09 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -909,7 +909,6 @@ sklm: { categorySkills: "Beceriler", categorySubagents: "Alt ajanlar", categoryInstructions: "Yönergeler ve istem şablonları", - categoryProjects: "Projeler ve klasör eşlemeleri", categoryPlugins: "Eklenti yükleme amacı", categoryAutomation: "Otomasyon tanımları", categoryMemory: "Proje belleği", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index d77fd773c..eeda6388c 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -900,7 +900,6 @@ sklm: { categorySkills: "技能", categorySubagents: "子智能体", categoryInstructions: "指令和提示词模板", - categoryProjects: "项目和文件夹映射", categoryPlugins: "插件安装意图", categoryAutomation: "自动化定义", categoryMemory: "项目记忆", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index 9c6113c74..1d446aca6 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -900,7 +900,6 @@ sklm: { categorySkills: "技能", categorySubagents: "子代理程式", categoryInstructions: "指令與提示詞範本", - categoryProjects: "專案與資料夾對應", categoryPlugins: "外掛程式安裝意圖", categoryAutomation: "自動化定義", categoryMemory: "專案記憶", diff --git a/packages/shared/src/changelog-de.ts b/packages/shared/src/changelog-de.ts index 15ebe8be0..165f208de 100644 --- a/packages/shared/src/changelog-de.ts +++ b/packages/shared/src/changelog-de.ts @@ -6,7 +6,7 @@ export const deEntries: ChangelogEntry[] = [ "date": "2026-10-09", "highlights": [ "Richten Sie den Jev-Klassifizierer von TypeSafe über „Dienst hinzufügen“ ein; der API-Schlüssel wird vor dem Speichern geprüft.", - "Cloud-Synchronisierung ist in den Einstellungen der installierten Version verfügbar und als experimentell gekennzeichnet.", + "Cloud-Synchronisierung ist in den Einstellungen der installierten Version verfügbar und als experimentell gekennzeichnet. Projektlisten bleiben auf dem jeweiligen Gerät.", "Fügen Sie über „Dienst hinzufügen“ Plugin-eigene API-Schlüssel-Dienste hinzu, mit lokalisierten Beschreibungen und Modellerkennung nach der Einrichtung.", "Autorisierte Plugin-Aktionen können den Composer-Entwurf ändern und lassen sich rückgängig machen; die Prompt-Verbesserung steckt jetzt in einem optionalen Plugin.", "Tool-Details bleiben eingeklappt, bis Sie sie öffnen, damit die fertige Antwort im Blick bleibt.", diff --git a/packages/shared/src/changelog-en.ts b/packages/shared/src/changelog-en.ts index c455d899d..fe87663f3 100644 --- a/packages/shared/src/changelog-en.ts +++ b/packages/shared/src/changelog-en.ts @@ -6,7 +6,7 @@ export const enEntries: ChangelogEntry[] = [ date: "2026-10-09", highlights: [ "Set up TypeSafe's Jev classifier from Add Service and verify its API key before saving.", - "Find Cloud sync in packaged Settings with its Experimental badge.", + "Find Cloud sync in packaged Settings with its Experimental badge. Project lists stay on each device.", "Add plugin-owned API-key services from Add Service, with localized descriptions and model discovery after setup.", "Authorized plugin actions can transform a Composer draft with undo; prompt enhancement now runs as an optional plugin.", "Tool details stay collapsed until opened, keeping the completed answer in view.", diff --git a/packages/shared/src/changelog-es.ts b/packages/shared/src/changelog-es.ts index ce2f77d31..d1ab47e0c 100644 --- a/packages/shared/src/changelog-es.ts +++ b/packages/shared/src/changelog-es.ts @@ -6,7 +6,7 @@ export const esEntries: ChangelogEntry[] = [ "date": "2026-10-09", "highlights": [ "Configura el clasificador Jev de TypeSafe desde Añadir servicio y verifica la clave API antes de guardarla.", - "La sincronización en la nube ya está disponible en los ajustes de la versión empaquetada y se marca como experimental.", + "La sincronización en la nube ya está disponible en los ajustes de la versión empaquetada y se marca como experimental. Las listas de proyectos permanecen en cada dispositivo.", "Añade desde Añadir servicio proveedores de plugins con clave API, descripciones localizadas y detección de modelos tras configurarlos.", "Las acciones autorizadas de plugins pueden transformar el borrador del Composer y deshacer el cambio; la mejora de prompts ahora es un plugin opcional.", "Los detalles de las herramientas permanecen contraídos hasta que los abras, para mantener visible la respuesta completa.", diff --git a/packages/shared/src/changelog-fr.ts b/packages/shared/src/changelog-fr.ts index 8da4a34d7..7e8fbb5bf 100644 --- a/packages/shared/src/changelog-fr.ts +++ b/packages/shared/src/changelog-fr.ts @@ -6,7 +6,7 @@ export const frEntries: ChangelogEntry[] = [ "date": "2026-10-09", "highlights": [ "Configurez le classificateur Jev de TypeSafe depuis Ajouter un service et vérifiez la clé API avant de l'enregistrer.", - "La synchronisation cloud est disponible dans les paramètres de la version empaquetée et porte la mention « expérimental ».", + "La synchronisation cloud est disponible dans les paramètres de la version empaquetée et porte la mention « expérimental ». Les listes de projets restent sur chaque appareil.", "Ajoutez depuis Ajouter un service des fournisseurs de plugins avec clé API, descriptions localisées et découverte des modèles après configuration.", "Les actions de plugins autorisées peuvent transformer le brouillon du Composer avec une annulation ; l'amélioration des prompts est désormais un plugin facultatif.", "Les détails des outils restent réduits jusqu'à leur ouverture, afin de garder la réponse terminée visible.", diff --git a/packages/shared/src/changelog-ko.ts b/packages/shared/src/changelog-ko.ts index 7a57cba82..dac8aafb4 100644 --- a/packages/shared/src/changelog-ko.ts +++ b/packages/shared/src/changelog-ko.ts @@ -6,7 +6,7 @@ export const koEntries: ChangelogEntry[] = [ "date": "2026-10-09", "highlights": [ "서비스 추가에서 TypeSafe Jev 분류기를 설정하고 저장 전에 API 키를 확인하세요.", - "패키지 빌드의 설정에서 클라우드 동기화를 사용할 수 있으며 실험적 기능으로 표시됩니다.", + "패키지 빌드의 설정에서 클라우드 동기화를 사용할 수 있으며 실험적 기능으로 표시됩니다. 프로젝트 목록은 각 기기에만 유지됩니다.", "서비스 추가에서 플러그인이 제공하는 API 키 서비스를 추가하고, 현지화된 설명을 확인한 뒤 키 설정 후 모델을 검색하세요.", "허용된 플러그인 작업으로 Composer 초안을 변환하고 실행 취소할 수 있습니다. 프롬프트 향상 기능은 이제 선택형 플러그인으로 제공됩니다.", "도구 세부 정보는 직접 펼칠 때까지 접힌 상태로 유지되어 완료된 답변을 계속 볼 수 있습니다.", diff --git a/packages/shared/src/changelog-pt-BR.ts b/packages/shared/src/changelog-pt-BR.ts index 4398a11ae..2a6dda817 100644 --- a/packages/shared/src/changelog-pt-BR.ts +++ b/packages/shared/src/changelog-pt-BR.ts @@ -6,7 +6,7 @@ export const ptBREntries: ChangelogEntry[] = [ "date": "2026-10-09", "highlights": [ "Configure o classificador Jev da TypeSafe em Adicionar serviço e valide a chave de API antes de salvá-la.", - "A sincronização na nuvem está disponível nas configurações da versão empacotada e é identificada como experimental.", + "A sincronização na nuvem está disponível nas configurações da versão empacotada e é identificada como experimental. As listas de projetos permanecem em cada dispositivo.", "Adicione em Adicionar serviço provedores de plugins com chave de API, descrições localizadas e descoberta de modelos após a configuração.", "Ações autorizadas de plugins podem transformar o rascunho do Composer com opção de desfazer; o aprimoramento de prompts agora é um plugin opcional.", "Os detalhes das ferramentas ficam recolhidos até serem abertos, mantendo a resposta concluída visível.", diff --git a/packages/shared/src/changelog-tr.ts b/packages/shared/src/changelog-tr.ts index a5ff6901b..396126762 100644 --- a/packages/shared/src/changelog-tr.ts +++ b/packages/shared/src/changelog-tr.ts @@ -6,7 +6,7 @@ export const trEntries: ChangelogEntry[] = [ "date": "2026-10-09", "highlights": [ "TypeSafe Jev sınıflandırıcısını Hizmet Ekle'den kurun ve kaydetmeden önce API anahtarını doğrulayın.", - "Bulut eşitlemesi paketlenmiş sürümün ayarlarında kullanılabilir ve deneysel olarak işaretlenir.", + "Bulut eşitlemesi paketlenmiş sürümün ayarlarında kullanılabilir ve deneysel olarak işaretlenir. Proje listeleri her cihazda yerel olarak kalır.", "Yerelleştirilmiş açıklamalar ve anahtar kurulumundan sonra model keşfiyle eklenti kaynaklı API anahtarlı hizmetleri ekleyin.", "İzin verilen eklenti işlemleri Composer taslağını dönüştürebilir ve geri alınabilir; istem iyileştirme artık isteğe bağlı bir eklentide sunulur.", "Yanıt görünür kalsın diye araç ayrıntıları siz açana kadar kapalı kalır.", diff --git a/packages/shared/src/changelog-zh-CN.ts b/packages/shared/src/changelog-zh-CN.ts index d2ad584bf..d773430ad 100644 --- a/packages/shared/src/changelog-zh-CN.ts +++ b/packages/shared/src/changelog-zh-CN.ts @@ -6,7 +6,7 @@ export const zhCNEntries: ChangelogEntry[] = [ date: "2026-10-09", highlights: [ "可从「添加服务」接入 TypeSafe 的 Jev 分类器;保存前会先验证 API 密钥。", - "打包版设置现已开放云同步,并标注为实验性功能。", + "打包版设置现已开放云同步,并标注为实验性功能。项目列表仅保留本机。", "可从「添加服务」添加插件提供的 API 密钥服务;提供本地化说明,并在配置密钥后发现模型。", "获准的插件操作可改写 Composer 草稿并支持撤销;提示词增强现由可选插件提供。", "工具详情默认收起,展开后再查看,让已完成的回答保持在视野中。", diff --git a/packages/shared/src/changelog-zh-TW.ts b/packages/shared/src/changelog-zh-TW.ts index 2cf370017..d74204971 100644 --- a/packages/shared/src/changelog-zh-TW.ts +++ b/packages/shared/src/changelog-zh-TW.ts @@ -6,7 +6,7 @@ export const zhTWEntries: ChangelogEntry[] = [ date: "2026-10-09", highlights: [ "可從「新增服務」設定 TypeSafe 的 Jev 分類器,並在儲存前驗證 API 金鑰。", - "打包版設定現已開放雲端同步,並標示為實驗性功能。", + "打包版設定現已開放雲端同步,並標示為實驗性功能。專案清單僅保留於本機。", "可從「新增服務」加入外掛提供的 API 金鑰服務,查看在地化說明,並在設定金鑰後探索模型。", "獲授權的外掛操作可改寫 Composer 草稿並支援復原;提示詞增強現在由選用外掛提供。", "工具詳細內容預設收合,展開後才會顯示,讓已完成的回答保持在視線內。", diff --git a/packages/shared/src/types/config-sync.ts b/packages/shared/src/types/config-sync.ts index b57652010..5d59b98ad 100644 --- a/packages/shared/src/types/config-sync.ts +++ b/packages/shared/src/types/config-sync.ts @@ -14,7 +14,6 @@ export const CONFIG_SYNC_CATEGORIES = [ "skills", "subagents", "instructions", - "projects", "plugins", "automation", "memory", diff --git a/scripts/e2e/config-sync-journey.mjs b/scripts/e2e/config-sync-journey.mjs index 176d5f516..a08880347 100644 --- a/scripts/e2e/config-sync-journey.mjs +++ b/scripts/e2e/config-sync-journey.mjs @@ -4,7 +4,7 @@ import { dirname, join } from "node:path"; export const PASSWORD = "portable-backup-password"; export const MARKERS = ["S3 shared provider", "fixture-provider-secret", "S3 fixture instructions", "resource-first", "resource-updated", "fixture-mcp-secret"]; -const categories = Object.fromEntries(["application", "providers", "credentials", "mcp", "skills", "subagents", "instructions", "projects", "plugins", "automation"].map((name) => [name, true])); +const categories = Object.fromEntries(["application", "providers", "credentials", "mcp", "skills", "subagents", "instructions", "plugins", "automation"].map((name) => [name, true])); export const rpc = (host, method, params = {}) => host.call(method, params, 120_000); const sync = (host) => rpc(host, "configSync.syncNow"); const settings = (host) => rpc(host, "settings.get"); @@ -128,11 +128,23 @@ export async function runSyncJourney({ a, b, connection, store, step }) { await sync(a); assert.equal(await provider(b, shared.id), null, "unchanged opted-out receiver must accept deletion"); - step("mapped-skill-approval-resource-update-and-deletion"); + step("project-metadata-stays-local"); const projectA = join(a.dataDir, "project-a"); const projectB = join(b.dataDir, "project-b"); await Promise.all([mkdir(projectA), mkdir(projectB)]); - await rpc(a, "projects.create", { path: projectA }); + const { group: sourceGroup } = await rpc(a, "project.group.create", { name: "Device A workspace", folders: [projectA] }); + const remoteProjectsBefore = await rpc(b, "projects.list"); + const remoteGroupsBefore = await rpc(b, "project.groups.list"); + await sync(a); + await approveAll(b, await sync(b)); + assert.deepEqual(await rpc(b, "projects.list"), remoteProjectsBefore, "sync must not register source projects on another device"); + assert.deepEqual(await rpc(b, "project.groups.list"), remoteGroupsBefore, "sync must not create source project groups"); + // Workspace registration is a local user action, independent of sync mappings. + await rpc(b, "project.group.create", { name: "Device B workspace", folders: [projectB] }); + const localGroups = await rpc(b, "project.groups.list"); + await rpc(a, "project.group.rename", { groupId: sourceGroup.id, name: "Renamed Device A workspace" }); + + step("mapped-skill-approval-resource-update-and-deletion"); const { skill } = await rpc(a, "skills.create", { id: "s3-resource-skill", name: "S3 resource skill", level: "project", projectPath: projectA, body: MARKERS[2], shape: "dir", @@ -149,6 +161,7 @@ export async function runSyncJourney({ a, b, connection, store, step }) { assert.ok(imported.path.startsWith(projectB), "source machine path must not become destination"); const destination = join(dirname(imported.path), "data.txt"); assert.equal(await readFile(destination, "utf8"), MARKERS[3]); + assert.deepEqual(await rpc(b, "project.groups.list"), localGroups, "mapped capability import must preserve device-local group metadata"); await writeFile(source, MARKERS[4]); await sync(a); await approveAll(b, await sync(b), projectB);