Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ const CATEGORIES = [
["skills", "categorySkills"],
["subagents", "categorySubagents"],
["instructions", "categoryInstructions"],
["projects", "categoryProjects"],
["plugins", "categoryPlugins"],
["automation", "categoryAutomation"],
["memory", "categoryMemory"],
Expand Down
16 changes: 10 additions & 6 deletions apps/desktop/src/features/settings/config-sync-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,16 @@ export type ConfigSyncForm = ConfigSyncConnection & {
};

export function selectionFromDraft(categories?: Partial<ConfigSyncCategorySelection>): 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<keyof ConfigSyncCategorySelection>) {
if (typeof categories?.[key] === "boolean") selection[key] = categories[key];
}
return selection;
}

export function initialConfigSyncForm(draft: ConfigSyncDraft = {}): ConfigSyncForm {
Expand All @@ -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,
};
}
Expand All @@ -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,
};
}

Expand All @@ -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,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ const CATEGORY_KEYS: ConfigSyncCategory[] = [
"skills",
"subagents",
"instructions",
"projects",
"plugins",
"automation",
"memory",
Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/test/config-sync-form.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
});
14 changes: 14 additions & 0 deletions apps/desktop/test/config-sync-preferences.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
3 changes: 3 additions & 0 deletions apps/desktop/test/config-sync-user-path.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
Expand Down
48 changes: 1 addition & 47 deletions crates/host-core/src/config_sync/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion crates/host-core/src/config_sync/baseline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ pub(super) struct Baseline {
}

pub(super) fn load(st: &AppState, key: &VaultKey) -> Result<Option<Baseline>> {
load_encrypted(st, key, "local-base", &local_path(st, "base.bin")?)
let baseline: Option<Baseline> =
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(
Expand Down
71 changes: 36 additions & 35 deletions crates/host-core/src/config_sync/domains.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,14 @@ 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";
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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
}

pub fn preview(snapshot: &LocalSnapshot, selection: &CategorySelection) -> PreviewCounts {
let mut counts = PreviewCounts::default();
let mut by_category: BTreeMap<String, CategoryPreview> = BTreeMap::new();
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading