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
50 changes: 38 additions & 12 deletions docs/architecture/peer-device-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,32 @@ applying or uploading settings, a host fans out `account://settings-applied`
to attached controllers; the controller re-emits it locally so the frontend
config cache and model selectors refresh without reconnecting.

The account settings payload is the complete `ConfigExport.config` document,
not a whitelist assembled by the login UI. Its scope is:

| Persisted configuration | Account sync coverage |
|---|---|
| `app` | Language, startup/window preferences, logging, notifications, layout, FlowChat, AI experience/quick actions, voice input/call settings, keybindings, tool/Skill groups, hook enablement gates, worktree defaults |
| `ai` | Persisted models and credentials, default/task/subagent model selectors, Agent profile overrides, Skill availability, Review Teams, concurrency/timeouts, proxy, browser/tool preferences, non-secret WebSearch settings |
| `editor`, `terminal`, `workspace` | Preferences in the global document; workspace files and machine connection records are separate |
| `tool_permissions`, `memories` | User permission policy and memory preferences; project permission files and generated memory content are separate |
| `mcp_servers`, `acp_clients`, `plugin`, `project` | Declarations present in the global document; external executables, installed packages and separately stored project overlays are not copied |
| `appearance`, `font` | Appearance selection and UI font preferences; imported skin assets are stored separately |

The frontend refreshes the config cache and the appearance, font and language
runtimes after a settings-applied event. Keybindings register a path watcher
even when their initial value came from the bootstrap hint, and an empty or
removed override restores the registered default. Applying these preferences
does not save them again. An unavailable imported skin keeps the persisted
selection and exposes the existing degraded/unavailable state.

This is settings synchronization, not a user-home backup: custom Agent and Skill
source files, `hooks.json` declarations/scripts, plugin packages, skin/pet
assets, local credential-vault entries, SSH profiles and browser storage are
outside this payload. A synchronized declaration or asset path does not imply
that its dependency is installed or usable on another host. Runtime-only model
credentials are also excluded. Session backup upload has a separate lifecycle.

The sync engine subscribes to successful local mutations at `ConfigService`,
in addition to legacy host notifications. This covers model, Skill, Agent
profile, and individual preference mutations through Desktop and CLI. Failed
Expand All @@ -179,21 +205,21 @@ local-change signal. Pending local edits take priority over the periodic pull;
a fetched blob is applied only if the local document still matches its
pre-fetch snapshot. The comparison and import share the config write lock.

Older settings snapshots may omit fixed fields introduced by a newer build.
Imports preserve those local fields instead of replacing them with defaults.
Supplied arrays and dynamic maps remain authoritative, so deleted models,
profiles and list entries are not resurrected. Optional/default-elided fields
retain their existing reset semantics; an explicit raw backup restore also
honors omitted default memory and AI preferences. Legacy renamed fields still
pass through their migrations before values at the new names are preserved.
Imports validate the OpenBitFun product identity, export format and config
schema, then replace the document. Within the supported schema, omitted fields
with serde defaults acquire those defaults; they do not retain the receiving
host's prior value. Arrays and dynamic maps remain authoritative, so deleted
models, profiles and list entries are not resurrected. Pre-OpenBitFun formats
and retired fields require the explicit migration tool. Configuration write
timestamps and informational build versions are excluded from the sync content
hash so a reload or unchanged save does not cause a redundant upload.

Realtime voice credentials live in `app.voice_call` in the same persisted
configuration and export/backup format as model settings. Account settings
apply preserves the controller's existing voice fields when an older payload
omits them, and an empty voice API key from an unconfigured host does not erase
a configured local key. Non-empty synced keys still replace the local key.
Explicit file imports can restore or clear a supplied key; local voice saves
and resets can also clear it. A valid whole-config import creates a raw
apply is authoritative here too: a supplied empty voice key clears the local
key, and absent voice fields receive defaults. Explicit file imports can
restore or clear a supplied key; local voice saves and resets can also clear
it. A valid whole-config import creates a raw
`app_pre-import_*.json` backup before replacement, under the existing backup
retention policy. Config reload and model-reference reconciliation serialize
their reads and writes with local saves so stale snapshots cannot undo a
Expand Down
138 changes: 136 additions & 2 deletions src/crates/assembly/core/src/service/config/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1044,8 +1044,7 @@ mod tests {
for account_sync in [false, true] {
let (service, _dir) = test_service("import-explicit-deletions").await;
// A raw backup intentionally omits these default values. Restoring
// it must still reset them, even though legacy missing fixed fields
// now retain their local values.
// it must still reset them to the declared defaults.
let backup = service.create_backup().await.unwrap();
let raw_backup: serde_json::Value =
serde_json::from_slice(&tokio::fs::read(backup).await.unwrap()).unwrap();
Expand Down Expand Up @@ -1186,6 +1185,141 @@ mod tests {
assert!(changes.has_changed().unwrap());
}

#[tokio::test]
async fn account_settings_round_trip_covers_persisted_preference_groups() {
use serde_json::json;

let (source, _source_dir) = test_service("sync-coverage-source").await;
let (target, target_dir) = test_service("sync-coverage-target").await;
let mut changes = source.subscribe_local_changes();
let fixtures = vec![
("app.language", json!("en-US")),
("app.prevent_sleep", json!(true)),
("app.notifications.enabled", json!(false)),
("app.logging.include_sensitive_diagnostics", json!(true)),
("app.sidebar.width", json!(280)),
("app.right_panel.width", json!(420)),
("app.flow_chat.show_permission_mode_control", json!(false)),
("app.hooks.project_hooks_enabled", json!(true)),
(
"app.keybindings",
json!({"version": 1, "overrides": {"session.new": {"key": "n", "alt": true}}}),
),
(
"app.user_tool_groups",
json!({"version": 1, "groups": [{"id": "tools", "name": "Tools", "toolNames": ["Read"]}]}),
),
(
"app.user_skill_groups",
json!({"version": 1, "groups": [{"id": "skills", "name": "Skills", "skillKeys": ["user::fixture"]}]}),
),
(
"app.ai_experience.quick_actions",
json!([{"id": "fixture", "label": "Fixture", "prompt": "Check changes", "enabled": false}]),
),
(
"app.voice_call",
serde_json::to_value(realtime_voice_fixture()).unwrap(),
),
("editor.font_size", json!(18)),
("editor.format_on_save", json!(true)),
("terminal.font_size", json!(17)),
("terminal.terminal_panel_position", json!("bottom")),
("workspace.exclude_patterns", json!(["**/fixture-cache/**"])),
(
"ai.models",
serde_json::to_value(vec![runtime_model("fixture-model", "fixture-model-key")])
.unwrap(),
),
("ai.default_models.primary", json!("fixture-model")),
(
"ai.agent_profiles",
json!({"fixture-profile": {"profile_id": "fixture-profile", "added_tools": ["Read"], "disabled_user_skills": ["user::fixture"]}}),
),
(
"ai.skill_settings.globally_disabled_user_skills",
json!(["user::fixture"]),
),
("ai.subagent_max_concurrency", json!(3)),
("ai.stream_idle_timeout_secs", json!(123)),
("ai.web_search.provider", json!("tavily")),
("ai.allow_tool_json_repair", json!(false)),
("tool_permissions.interaction.auto_approve_ask", json!(true)),
("memories.use_memories", json!(true)),
(
"mcp_servers",
json!({"mcpServers": {"fixture": {"command": "fixture", "env": {"TOKEN": "fixture-mcp-key"}}}}),
),
(
"acp_clients",
json!({"acpClients": {"fixture": {"command": "fixture"}}}),
),
(
"plugin",
json!([{"spec": "fixture-package@1.0.0", "options": {"enabled": true}}]),
),
("appearance.selection", json!("fixture-appearance")),
(
"font",
json!({"uiSize": {"level": "custom", "customPx": 17}}),
),
];
for (path, value) in &fixtures {
source.set_config(path, value).await.unwrap();
assert!(
changes.has_changed().unwrap(),
"Missing upload signal: {path}"
);
changes.borrow_and_update();
}
// Use the serialized wire payload, not a typed in-memory shortcut.
let payload = serde_json::to_string(&source.export_config().await.unwrap()).unwrap();
let target_changes = target.subscribe_local_changes();
let result = target
.import_account_settings(serde_json::from_str(&payload).unwrap())
.await
.unwrap();
assert!(result.success, "{:?}", result.errors);
assert!(
!target_changes.has_changed().unwrap(),
"Cloud apply echoed an upload"
);
drop(target);
let restarted = restart_test_service(&target_dir, "sync-coverage-target").await;
for (path, expected) in fixtures {
let actual: serde_json::Value = restarted.get_config(Some(path)).await.unwrap();
// Agent profile defaults are materialized by serde; compare their
// complete source representation just like all other sections.
let source_value: serde_json::Value = source.get_config(Some(path)).await.unwrap();
assert_eq!(
actual, source_value,
"Settings lost in sync/restart: {path}"
);
if path != "ai.agent_profiles" && path != "ai.models" {
assert_eq!(
actual, expected,
"Setting was dropped before export: {path}"
);
}
}
let mut source_config =
serde_json::to_value(source.export_config().await.unwrap().config).unwrap();
let mut target_config =
serde_json::to_value(restarted.export_config().await.unwrap().config).unwrap();
source_config
.as_object_mut()
.unwrap()
.remove("last_modified");
target_config
.as_object_mut()
.unwrap()
.remove("last_modified");
assert_eq!(
source_config, target_config,
"Full settings document must round-trip"
);
}

#[tokio::test]
async fn stale_cloud_pull_cannot_overwrite_a_save_made_during_the_fetch() {
let name = "config-stale-cloud-pull";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,20 +191,24 @@ fn canonicalize_json(value: serde_json::Value) -> serde_json::Value {
.collect(),
)
}
serde_json::Value::Array(values) => serde_json::Value::Array(
values.into_iter().map(canonicalize_json).collect(),
),
serde_json::Value::Array(values) => {
serde_json::Value::Array(values.into_iter().map(canonicalize_json).collect())
}
value => value,
}
}

/// Hash the canonical config content of a settings payload, ignoring volatile
/// wrapper fields such as `export_timestamp`.
/// Hash settings content, excluding export and document write metadata. A
/// cloud import updates the local document timestamp/build; those changes
/// must not turn the next unchanged save into another upload.
fn settings_content_hash(payload: &str) -> Result<String> {
let export = config_export_value(payload)?;
let canonical = serde_json::to_string(&canonicalize_json(serde_json::to_value(
export.config,
)?))
let mut config = serde_json::to_value(export.config)?;
if let Some(root) = config.as_object_mut() {
root.remove("last_modified");
root.remove("version");
}
let canonical = serde_json::to_string(&canonicalize_json(config))
.map_err(|e| anyhow!("serialize settings for hashing: {e}"))?;
Ok(sync_state::content_hash(&canonical))
}
Expand Down Expand Up @@ -564,6 +568,22 @@ mod tests {
);
}

#[test]
fn content_hash_ignores_host_write_metadata_but_keeps_settings() {
let mut first = crate::service::config::GlobalConfig::default();
first.last_modified = chrono::DateTime::from_timestamp_millis(1_000).unwrap();
first.version = "older-build".to_string();
let mut second = first.clone();
second.last_modified = chrono::DateTime::from_timestamp_millis(2_000).unwrap();
second.version = "newer-build".to_string();
let hash = |config| {
settings_content_hash(&settings_payload(config, "fixture", "fixture")).unwrap()
};
assert_eq!(hash(first.clone()), hash(second.clone()));
second.app.notifications.enabled = !first.app.notifications.enabled;
assert_ne!(hash(first), hash(second));
}

#[test]
fn content_hash_changes_with_config_content() {
let a = crate::service::config::GlobalConfig::default();
Expand Down Expand Up @@ -594,4 +614,43 @@ mod tests {
invalid.as_object_mut().unwrap().remove("format_version");
assert!(config_export_value(&invalid.to_string()).is_err());
}

#[test]
fn older_supported_payload_defaults_missing_preferences_and_round_trips() {
let config = crate::service::config::GlobalConfig::default();
let mut payload: serde_json::Value = serde_json::from_str(&settings_payload(
config,
"2026-01-01T00:00:00Z",
"older-build",
))
.unwrap();
let app = payload["config"]["app"].as_object_mut().unwrap();
for field in [
"voice_call",
"user_tool_groups",
"user_skill_groups",
"prevent_sleep",
] {
app.remove(field);
}
payload["config"]["app"]["ai_experience"]["quick_actions"] = serde_json::json!([]);
payload["config"].as_object_mut().unwrap().remove("font");
let export = config_export_value(&payload.to_string()).unwrap();
assert!(export.config.app.voice_call.api_key.is_empty());
assert!(export.config.app.user_tool_groups.groups.is_empty());
assert!(export.config.app.user_skill_groups.groups.is_empty());
assert!(!export.config.app.prevent_sleep);
assert!(export.config.font.is_none());
assert!(export.config.app.ai_experience.quick_actions.is_empty());
let reexported = serde_json::to_string(&export).unwrap();
let reparsed = config_export_value(&reexported).unwrap();
assert_eq!(
serde_json::to_value(export.config).unwrap(),
serde_json::to_value(reparsed.config).unwrap()
);
assert_eq!(
settings_content_hash(&payload.to_string()).unwrap(),
settings_content_hash(&reexported).unwrap()
);
}
}
8 changes: 2 additions & 6 deletions src/web-ui/src/app/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,19 +157,15 @@ const AppLayout: React.FC<AppLayoutProps> = ({ className = '' }) => {
try {
const raw = await configManager.getOptionalConfig('app.keybindings');
const overrides = parseStoredKeybindings(raw);
if (Object.keys(overrides).length > 0) {
shortcutManager.loadUserOverrides(overrides);
}
shortcutManager.loadUserOverrides(overrides);
} catch {
// No overrides stored yet — that's fine
}
};

void load();

const unsubscribe = configManager.onConfigChange((path) => {
if (path === 'app.keybindings') void load();
});
const unsubscribe = configManager.watch('app.keybindings', () => { void load(); });

return () => unsubscribe();
}, []);
Expand Down
Loading
Loading