From 80612a58a9c991781849227b58f84bbca7aafc05 Mon Sep 17 00:00:00 2001 From: Fenish Date: Sun, 11 Oct 2026 17:49:30 +0300 Subject: [PATCH] feat(sync): sync assistant settings such as custom providers and API keys --- crates/skillpouch/src/choose.rs | 5 +- crates/skillpouch/src/cli.rs | 31 + crates/skillpouch/src/commands/mod.rs | 2 + crates/skillpouch/src/commands/settings.rs | 114 ++ crates/skillpouch/src/daemon.rs | 3 + crates/skillpouch/src/sync.rs | 38 + crates/sp-harness/src/lib.rs | 302 +++- crates/sp-sync/src/engine.rs | 84 +- crates/sp-sync/src/item.rs | 3 +- crates/sp-sync/src/lib.rs | 1 + crates/sp-sync/src/settings.rs | 1697 ++++++++++++++++++++ scripts/e2e-settings.sh | 489 ++++++ scripts/e2e-settings/Dockerfile | 19 + scripts/e2e-settings/harnesses.sh | 370 +++++ scripts/e2e-settings/mock-llm.mjs | 42 + scripts/e2e-settings/probe.py | 136 ++ scripts/e2e-settings/web.mjs | 130 ++ 17 files changed, 3462 insertions(+), 4 deletions(-) create mode 100644 crates/skillpouch/src/commands/settings.rs create mode 100644 crates/sp-sync/src/settings.rs create mode 100755 scripts/e2e-settings.sh create mode 100644 scripts/e2e-settings/Dockerfile create mode 100644 scripts/e2e-settings/harnesses.sh create mode 100755 scripts/e2e-settings/mock-llm.mjs create mode 100755 scripts/e2e-settings/probe.py create mode 100755 scripts/e2e-settings/web.mjs diff --git a/crates/skillpouch/src/choose.rs b/crates/skillpouch/src/choose.rs index f66c51c..6e2e316 100644 --- a/crates/skillpouch/src/choose.rs +++ b/crates/skillpouch/src/choose.rs @@ -13,7 +13,7 @@ use sp_sync::release::Released; use sp_sync::status::Phase; /// The kinds, as the pouch names them and as people read them. -pub const KINDS: [(&str, &str); 7] = sp_harness::POUCH_KINDS; +pub const KINDS: [(&str, &str); 8] = sp_harness::POUCH_KINDS; /// An assistant or a kind, by id. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -40,6 +40,7 @@ pub fn parse(text: &str) -> Option { || kind == singular || label.to_lowercase().replace(' ', "-") == wanted || (singular == "mcp" && kind == "mcp-server") + || (wanted == "settings" && kind == "setting") }) .map(|(kind, _)| Choice::Kind(kind)) } @@ -134,6 +135,8 @@ mod tests { assert_eq!(parse("mcp"), Some(Choice::Kind("mcp_server"))); assert_eq!(parse("MCP servers"), Some(Choice::Kind("mcp_server"))); assert_eq!(parse("mcp_server"), Some(Choice::Kind("mcp_server"))); + assert_eq!(parse("settings"), Some(Choice::Kind("setting"))); + assert_eq!(label(Choice::Kind("setting")), "Settings"); assert_eq!(parse("nope"), None); assert_eq!(label(Choice::Kind("mcp_server")), "MCP servers"); assert_eq!(label(Choice::Assistant("gemini-cli")), "Gemini CLI"); diff --git a/crates/skillpouch/src/cli.rs b/crates/skillpouch/src/cli.rs index a86c69d..b3e05c5 100644 --- a/crates/skillpouch/src/cli.rs +++ b/crates/skillpouch/src/cli.rs @@ -51,6 +51,11 @@ pub enum Command { }, /// Sync this computer's pouch now. Sync, + /// List or control the preferences synced for each assistant. + Settings { + #[command(subcommand)] + action: SettingsAction, + }, /// Open the web app to pick versions of items changed in two places. Conflicts, /// Choose what this computer syncs: assistants and kinds, on or off. @@ -77,6 +82,32 @@ pub enum Command { }, } +#[derive(Debug, Subcommand)] +pub enum SettingsAction { + /// List entries and their status, optionally for one assistant. + List { + /// Only this assistant (`claude-code`, `opencode`, …). + #[arg(value_name = "ASSISTANT")] + harness: Option, + }, + /// Stop syncing an entry on this computer (the pouch keeps it). + Exclude { + /// The assistant (`claude-code`, `opencode`, …). + #[arg(value_name = "ASSISTANT")] + harness: String, + /// The entry, as `settings list` shows it (`settings.json:env.API_URL`). + entry: String, + }, + /// Restore an excluded entry from the pouch now. + Include { + /// The assistant (`claude-code`, `opencode`, …). + #[arg(value_name = "ASSISTANT")] + harness: String, + /// The entry, as `settings list` shows it (`settings.json:env.API_URL`). + entry: String, + }, +} + #[derive(Debug, Subcommand)] pub enum DaemonAction { /// Start it, and start it again whenever you log in. diff --git a/crates/skillpouch/src/commands/mod.rs b/crates/skillpouch/src/commands/mod.rs index b7728d8..617b8ac 100644 --- a/crates/skillpouch/src/commands/mod.rs +++ b/crates/skillpouch/src/commands/mod.rs @@ -2,6 +2,7 @@ mod home; mod login; mod logout; mod select; +mod settings; mod status; mod sync; mod tui; @@ -69,6 +70,7 @@ pub async fn run(cli: Cli) -> Result { Command::Logout => logout::run(&ctx).await, Command::Status { json } => status::run(&ctx, json).await, Command::Sync => sync::once(&ctx).await, + Command::Settings { action } => settings::run(&ctx, action), Command::Daemon { action } => match action { DaemonAction::Start => sync::start(&ctx), DaemonAction::Stop => sync::stop(&ctx), diff --git a/crates/skillpouch/src/commands/settings.rs b/crates/skillpouch/src/commands/settings.rs new file mode 100644 index 0000000..f7df319 --- /dev/null +++ b/crates/skillpouch/src/commands/settings.rs @@ -0,0 +1,114 @@ +use crate::cli::SettingsAction; +use crate::context::Context; +use anyhow::{Result, bail}; +use sp_core::home::lock_exclusive; +use sp_core::setup::SetupRecord; +use sp_sync::item::PouchDir; +use sp_sync::settings::{DeviceState, Setting}; +use std::collections::BTreeSet; +use std::process::ExitCode; + +pub fn run(ctx: &Context, action: SettingsAction) -> Result { + let _lock = lock_exclusive(&crate::choose::sync_lock(ctx))?; + let Some(record) = SetupRecord::load(&ctx.home)? else { + bail!("Connect this computer first with `skillpouch login`."); + }; + let targets = sp_harness::targets(&crate::sync::places(ctx)?); + let mut state = DeviceState::load(&ctx.home, &record.pouch_id)?; + let dir = PouchDir::new(crate::sync::pouch_dir(ctx, &record.pouch_id)); + let (items, _) = dir.read()?; + let exclude = matches!(&action, SettingsAction::Exclude { .. }); + match action { + SettingsAction::List { harness } => { + if let Some(id) = &harness + && !targets.iter().any(|target| target.id == id) + { + bail!("{id} is not installed."); + } + for target in &targets { + if harness.as_deref().is_some_and(|id| id != target.id) + || sp_harness::settings(target.id).is_empty() + { + continue; + } + println!("{}", target.name); + let item = items.get(&format!("setting/{}", target.id)); + let entries = item.map(Setting::from_item).transpose()?; + let baseline = state.harnesses.get(target.id); + let keys: BTreeSet<_> = entries + .iter() + .flat_map(|item| item.entries().into_keys()) + .chain(entries.iter().flat_map(|item| { + sp_sync::settings::local_entries(target.id, &target.root, item) + })) + .chain( + baseline + .into_iter() + .flat_map(|state| state.excluded.iter().cloned()), + ) + .collect(); + for key in keys { + let status = if baseline.is_some_and(|state| state.excluded.contains(&key)) { + "excluded on this computer" + } else if entries.as_ref().is_some_and(|setting| { + sp_sync::settings::translated_on(target.id, &target.root, setting, &key) + }) { + "translated" + } else if baseline.is_some_and(|state| state.baseline.contains_key(&key)) { + "synced" + } else { + "only in pouch" + }; + println!(" {key} {status}"); + } + } + } + SettingsAction::Exclude { harness, entry } | SettingsAction::Include { harness, entry } => { + if !targets + .iter() + .any(|target| target.id == harness && !sp_harness::settings(target.id).is_empty()) + { + bail!("{harness} is not an installed assistant with settings sync."); + } + let item = items + .get(&format!("setting/{harness}")) + .map(Setting::from_item) + .transpose()?; + if !item.as_ref().is_some_and(|item| { + item.entries().contains_key(&entry) + || targets + .iter() + .find(|target| target.id == harness) + .is_some_and(|target| { + sp_sync::settings::local_entries(&harness, &target.root, item) + .contains(&entry) + }) + }) { + bail!("{entry} is not in {harness}'s pouch settings."); + } + let root = targets + .iter() + .find(|target| target.id == harness) + .map(|target| target.root.clone()) + .ok_or_else(|| anyhow::anyhow!("assistant is not installed"))?; + let device = state.harnesses.entry(harness.clone()).or_default(); + if exclude { + sp_sync::settings::exclude(&root, &harness, &entry, device); + } else { + let item = items + .get(&format!("setting/{harness}")) + .ok_or_else(|| anyhow::anyhow!("settings item is missing"))?; + sp_sync::settings::include( + &root, + &harness, + &entry, + item, + &ctx.home.root().join("backups"), + device, + )?; + } + state.save(&ctx.home, &record.pouch_id)?; + } + } + Ok(ExitCode::SUCCESS) +} diff --git a/crates/skillpouch/src/daemon.rs b/crates/skillpouch/src/daemon.rs index d9d343e..dea9b25 100644 --- a/crates/skillpouch/src/daemon.rs +++ b/crates/skillpouch/src/daemon.rs @@ -585,6 +585,9 @@ pub fn local_signature(ctx: &Context) -> u64 { { add(path, false); } + for path in sp_sync::settings::files(&target) { + add(&path, false); + } let commands = target.commands.as_ref().map(|(dir, _)| dir); let agents = target.agents.as_ref().map(|(dir, _)| dir); for dir in [target.skills.as_ref(), agents, commands] diff --git a/crates/skillpouch/src/sync.rs b/crates/skillpouch/src/sync.rs index b4ac311..d28ef5c 100644 --- a/crates/skillpouch/src/sync.rs +++ b/crates/skillpouch/src/sync.rs @@ -417,6 +417,28 @@ async fn run_with(ctx: &Context, session: &mut Session, mut record: SetupRecord) .collect(); bring_in_hooks_and_plugins(&dir, &config_targets, &mut projection, &claude_plugins)?; + let mut settings_state = sp_sync::settings::DeviceState::load(&ctx.home, &record.pouch_id)?; + // Uninstalled assistants lose their per-device preferences history, but + // an explicit `select --off` remains in the user's config.toml. + let installed: std::collections::BTreeSet<_> = sp_harness::targets(&places) + .into_iter() + .map(|target| target.id.to_string()) + .collect(); + settings_state + .harnesses + .retain(|id, _| installed.contains(id)); + if choice.kind_on("setting") { + sp_sync::settings::capture(&dir, &targets, &mut settings_state)?; + settings_state.save(&ctx.home, &record.pouch_id)?; + } + + let previously_present: std::collections::BTreeSet<_> = dir + .read()? + .0 + .keys() + .filter(|key| key.starts_with("setting/")) + .cloned() + .collect(); let mut state = PouchState::load(&ctx.home, &record.pouch_id)?; if state.epoch != remote.pouch.epoch && !state.epoch.is_empty() { // The pouch's history was rewritten: compare everything afresh. @@ -466,6 +488,22 @@ async fn run_with(ctx: &Context, session: &mut Session, mut record: SetupRecord) } let (mut items, _) = dir.read()?; + if choice.kind_on("setting") { + for target in &targets { + let key = format!("setting/{}", target.id); + if !items.contains_key(&key) && previously_present.contains(&key) { + settings_state.deleted.insert(target.id.into()); + } + } + report.backed_up.extend(sp_sync::settings::project( + &dir, + &items, + &targets, + &mut settings_state, + &backups, + )?); + settings_state.save(&ctx.home, &record.pouch_id)?; + } items.retain(|_, item| choice.kind_on(&item.kind)); let (next, projected) = project::project(&ctx.home, &items, &targets, &backups, &projection)?; projection.links = next.links; diff --git a/crates/sp-harness/src/lib.rs b/crates/sp-harness/src/lib.rs index e499c7c..0c055ca 100644 --- a/crates/sp-harness/src/lib.rs +++ b/crates/sp-harness/src/lib.rs @@ -23,7 +23,7 @@ use std::path::{Path, PathBuf}; /// Every kind a pouch holds, as the pouch names them and as people read /// them; a computer can turn each off. -pub const POUCH_KINDS: [(&str, &str); 7] = [ +pub const POUCH_KINDS: [(&str, &str); 8] = [ ("instructions", "Instructions"), ("skill", "Skills"), ("agent", "Agents"), @@ -31,8 +31,308 @@ pub const POUCH_KINDS: [(&str, &str); 7] = [ ("mcp_server", "MCP servers"), ("hook", "Hooks"), ("plugin", "Plugins"), + ("setting", "Settings"), ]; +/// User preferences only; integrations (MCP, hooks, plugins and files) have +/// separate item kinds. Paths are relative to the assistant's root. +pub struct SettingsFile { + pub file: &'static str, + pub keys: &'static [&'static str], + pub maps: &'static [&'static str], +} + +pub fn settings(id: &str) -> &'static [SettingsFile] { + match id { + "opencode" => &[ + SettingsFile { + file: "opencode.json", + keys: &[ + "providers", + "model", + "default_agent", + "agents", + "permissions", + "share", + "username", + "compaction", + "tool_output", + "websearch", + "experimental", + ], + maps: &["providers", "agents"], + }, + // OpenCode 2 keeps its terminal preferences in `cli.json` (it + // migrates OpenCode 1's `tui.json` once, then ignores it). + SettingsFile { + file: "cli.json", + keys: &[ + "theme", + "keybinds", + "leader", + "scroll", + "diffs", + "prompt", + "mouse", + "cursor", + "animations", + "attention", + "session", + "terminal", + "tabs", + ], + maps: &[], + }, + ], + "claude-code" => &[SettingsFile { + file: "settings.json", + keys: &[ + "env", + "model", + "modelOverrides", + "fallbackModel", + "advisorModel", + "effortLevel", + "alwaysThinkingEnabled", + "fastMode", + "permissions", + "apiKeyHelper", + "statusLine", + "outputStyle", + "language", + "theme", + "tui", + "viewMode", + "editorMode", + "verbose", + "spinnerTipsEnabled", + "autoCompactEnabled", + "showThinkingSummaries", + "showTurnDuration", + "prefersReducedMotion", + "syntaxHighlightingDisabled", + "terminalProgressBarEnabled", + "includeCoAuthoredBy", + "attribution", + "autoUpdatesChannel", + "cleanupPeriodDays", + "respectGitignore", + ], + maps: &["env"], + }], + "codex" => &[SettingsFile { + file: "config.toml", + keys: &[ + "model", + "model_provider", + "model_providers", + "profiles", + "approval_policy", + "sandbox_mode", + "model_reasoning_effort", + "model_reasoning_summary", + "model_verbosity", + "openai_base_url", + "tui", + "features", + "web_search", + ], + maps: &["model_providers", "profiles"], + }], + "gemini-cli" => &[ + SettingsFile { + file: "settings.json", + keys: &[ + "general", + "ui", + "model", + "tools", + "privacy", + "security.auth.selectedType", + ], + maps: &[], + }, + SettingsFile { + file: ".env", + keys: &[], + maps: &[], + }, + ], + "qwen-code" => &[SettingsFile { + file: "settings.json", + keys: &[ + "modelProviders", + "providerProtocol", + "env", + "security.auth", + "model", + "general", + "ui", + "tools", + "proxy", + ], + maps: &["modelProviders", "env"], + }], + // Not `trustedFolders`, `hooks`, `disableAllHooks`, the proxy or the + // IDE: those belong to one computer or to the Hooks kind. + "copilot" => &[SettingsFile { + file: "settings.json", + keys: &[ + "model", + "contextTier", + "autoTier", + "theme", + "mouse", + "renderMarkdown", + "inlineImages", + "scrollbar", + "screenReader", + "showTipsOnStartup", + "stream", + "streamerMode", + "banner", + "bannerStyle", + "beep", + "beepOnSchedule", + "notifications", + "terminalNotifications", + "compactPaste", + "copyOnSelect", + "commandHistoryMaxSize", + "allowedUrls", + "deniedUrls", + "includeCoAuthoredBy", + "defaultMode", + "defaultPermissionMode", + "continueOnAutoMode", + "stayInAutopilot", + "respectGitignore", + "tabs", + "terminalProgress", + "updateTerminalTitle", + "footer", + "statusLine", + "autoUpdate", + "experimental", + "memory", + ], + maps: &[], + }], + "crush" => &[SettingsFile { + file: "crush.json", + keys: &["providers", "models", "options", "permissions", "tools"], + maps: &["providers"], + }], + "factory" => &[SettingsFile { + file: "settings.json", + keys: &[ + "model", + "reasoningEffort", + "customModels", + "sessionDefaultSettings", + ], + maps: &[], + }], + _ => &[], + } +} + +/// OpenCode 1's files: its config's own key names, and the terminal +/// preferences in `tui.json` (1.x ignores `theme` and `keybinds` in +/// `opencode.json`). +pub const OPENCODE_V1_SETTINGS: [SettingsFile; 2] = [ + SettingsFile { + file: "opencode.json", + keys: &[ + "provider", + "model", + "small_model", + "default_agent", + "agent", + "permission", + "tools", + "share", + "username", + "autoupdate", + "disabled_providers", + "enabled_providers", + "compaction", + "tool_output", + "experimental", + ], + maps: &["provider", "agent"], + }, + SettingsFile { + file: "tui.json", + keys: &[ + "theme", + "keybinds", + "leader_timeout", + "scroll_speed", + "scroll_acceleration", + "diff_style", + "mouse", + "cursor", + "prompt", + "attention", + ], + maps: &[], + }, +]; + +#[cfg(test)] +mod settings_tests { + use super::*; + + #[test] + fn settings_are_only_the_eight_global_assistants() { + for id in [ + "opencode", + "claude-code", + "codex", + "gemini-cli", + "qwen-code", + "copilot", + "crush", + "factory", + ] { + assert!(!settings(id).is_empty(), "{id}"); + } + for id in ["cursor", "windsurf", "antigravity"] { + assert!(settings(id).is_empty(), "{id}"); + } + assert!( + settings("gemini-cli") + .iter() + .any(|spec| spec.file == ".env") + ); + assert_eq!(settings("codex")[0].maps, ["model_providers", "profiles"]); + let files = |specs: &[SettingsFile]| specs.iter().map(|s| s.file).collect::>(); + assert_eq!(files(settings("opencode")), ["opencode.json", "cli.json"]); + assert_eq!(files(&OPENCODE_V1_SETTINGS), ["opencode.json", "tui.json"]); + for private in [ + "trustedFolders", + "hooks", + "disableAllHooks", + "proxyUrl", + "ide", + ] { + assert!(!settings("copilot")[0].keys.contains(&private), "{private}"); + } + assert!(settings("claude-code")[0].keys.contains(&"theme")); + assert!(!settings("crush")[0].keys.contains(&"env")); + assert_eq!( + settings("factory")[0].keys, + [ + "model", + "reasoningEffort", + "customModels", + "sessionDefaultSettings" + ] + ); + } +} + /// What an item is, in the order the setup screen lists them. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Kind { diff --git a/crates/sp-sync/src/engine.rs b/crates/sp-sync/src/engine.rs index 577a4d7..4c993ba 100644 --- a/crates/sp-sync/src/engine.rs +++ b/crates/sp-sync/src/engine.rs @@ -114,6 +114,13 @@ pub fn plan( let mine_changed = mine.fingerprint != agreed.fingerprint; let theirs_changed = theirs.revision_id != agreed.revision_id; match (mine_changed, theirs_changed) { + (true, true) if mine.item.kind == "setting" => { + // Download the verified head first. Projection + // merges entries against the device baseline; + // a local edit then wins without a conflict + // copy or losing unrelated remote entries. + plan.receive.push(key.clone()); + } (true, false) => plan.send.push(update()), (true, true) => { // On top of the agreed version: a conflict on the server. @@ -128,6 +135,7 @@ pub fn plan( (false, _) => plan.receive.push(key.clone()), } } + None if mine.item.kind == "setting" => plan.receive.push(key.clone()), None if options.prefer == Prefer::Computer => plan.send.push(update()), None => plan.copies.push(key.clone()), } @@ -137,6 +145,10 @@ pub fn plan( (Some(mine), None) => match theirs .filter(|base| agreed.is_none_or(|a| a.artifact_id == base.artifact_id)) { + // A signed delete of settings stops this harness's settings + // sync, even if local preferences changed at the same time. + // Only the encrypted pouch item is removed, never the config. + Some(_) if mine.item.kind == "setting" => plan.remove.push(key.clone()), // Deleted on the server (a signed delete), untouched here. Some(_) if agreed.is_some_and(|a| a.fingerprint == mine.fingerprint) => { plan.remove.push(key.clone()); @@ -163,7 +175,8 @@ pub fn plan( }, (None, Some(theirs)) => { let deleted_here = agreed.is_some_and(|a| a.revision_id == theirs.revision_id); - let first_meeting = agreed.is_none() && options.delete_unmatched; + let first_meeting = + agreed.is_none() && options.delete_unmatched && theirs.kind != "setting"; if deleted_here || first_meeting { plan.send.push(Change::Delete { base: theirs.clone(), @@ -708,6 +721,75 @@ mod tests { assert_eq!(plan.reupload, ["skill/missing"]); } + #[test] + fn first_settings_meeting_never_deletes_or_creates_a_conflict_copy() { + let mut mine = local_item("claude-code", "mine"); + mine.item.kind = "setting".into(); + let mut theirs = remote_item("claude-code", "r1", "theirs", false); + theirs.kind = "setting".into(); + theirs.body.kind = "setting".into(); + let (local, remote, state) = sides(&[mine], &[theirs], &[]); + let first = plan( + &local, + &remote, + &state, + Options { + prefer: Prefer::Computer, + delete_unmatched: true, + }, + ); + assert_eq!(first.receive, ["setting/claude-code"]); + assert!(first.copies.is_empty() && first.send.is_empty()); + + let mut theirs = remote_item("codex", "r1", "theirs", false); + theirs.kind = "setting".into(); + theirs.body.kind = "setting".into(); + let (local, remote, state) = sides(&[], &[theirs], &[]); + let plan = plan( + &local, + &remote, + &state, + Options { + prefer: Prefer::Computer, + delete_unmatched: true, + }, + ); + assert_eq!(plan.receive, ["setting/codex"]); + assert!(plan.send.is_empty()); + } + + #[test] + fn a_signed_settings_delete_does_not_resurrect_local_preferences() { + let mut mine = local_item("codex", "changed"); + mine.item.kind = "setting".into(); + let mut theirs = remote_item("codex", "r2", "old", true); + theirs.kind = "setting".into(); + theirs.body.kind = "setting".into(); + let mut agreed = agreed_on("codex", "r1", "old"); + agreed.0 = "setting/codex".into(); + agreed.1.kind = "setting".into(); + let (local, remote, state) = sides(&[mine], &[theirs], &[agreed]); + let plan = plan(&local, &remote, &state, Options::default()); + assert_eq!(plan.remove, ["setting/codex"]); + assert!(plan.send.is_empty()); + } + + #[test] + fn concurrent_settings_edits_download_before_per_entry_merge() { + let mut mine = local_item("codex", "locally edited"); + mine.item.kind = "setting".into(); + let mut theirs = remote_item("codex", "r2", "remotely edited", false); + theirs.kind = "setting".into(); + theirs.body.kind = "setting".into(); + let mut agreed = agreed_on("codex", "r1", "old"); + agreed.0 = "setting/codex".into(); + agreed.1.kind = "setting".into(); + let (local, remote, state) = sides(&[mine], &[theirs], &[agreed]); + let plan = plan(&local, &remote, &state, Options::default()); + assert_eq!(plan.receive, ["setting/codex"]); + assert!(plan.send.is_empty() && plan.conflicts.is_empty()); + } + #[test] fn conflict_copies_get_a_free_name() { assert_eq!(conflict_name("pdf", &|_| false), "pdf-conflict"); diff --git a/crates/sp-sync/src/item.rs b/crates/sp-sync/src/item.rs index 62577a5..eb0084f 100644 --- a/crates/sp-sync/src/item.rs +++ b/crates/sp-sync/src/item.rs @@ -8,7 +8,7 @@ use std::fs; use std::path::{Path, PathBuf}; /// Kind, its folder in the pouch and its main file, as the web stores them. -pub const KINDS: [(&str, &str, &str); 7] = [ +pub const KINDS: [(&str, &str, &str); 8] = [ ("instructions", "instructions", "INSTRUCTIONS.md"), ("skill", "skills", "SKILL.md"), ("agent", "agents", "AGENT.md"), @@ -16,6 +16,7 @@ pub const KINDS: [(&str, &str, &str); 7] = [ ("mcp_server", "mcp-servers", "server.json"), ("hook", "hooks", "hook.json"), ("plugin", "plugins", "plugin.json"), + ("setting", "settings", "setting.json"), ]; /// A pouch has one set of instructions, always under this name. diff --git a/crates/sp-sync/src/lib.rs b/crates/sp-sync/src/lib.rs index d04ef82..bf5de5c 100644 --- a/crates/sp-sync/src/lib.rs +++ b/crates/sp-sync/src/lib.rs @@ -20,6 +20,7 @@ pub mod plugins; pub mod project; pub mod release; pub mod remote; +pub mod settings; pub mod state; pub mod status; pub mod translate; diff --git a/crates/sp-sync/src/settings.rs b/crates/sp-sync/src/settings.rs new file mode 100644 index 0000000..7582ec5 --- /dev/null +++ b/crates/sp-sync/src/settings.rs @@ -0,0 +1,1697 @@ +//! Per-device agreement on harness preferences. These are data, never files +//! copied from the server: only allowlisted paths and keys can be applied. + +use crate::error::io; +use crate::item::{File, Files, LocalItem, PouchDir}; +use crate::project::{back_up, backup_path}; +use crate::{Result, SyncError, jsonc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sp_core::Home; +use sp_core::home::{read_optional, write_private}; +use sp_harness::{SettingsFile, Target}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +type Entries = BTreeMap; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct DeviceState { + pub harnesses: BTreeMap, + /// Signed deletes stop automatic recapture until a new item is received. + pub deleted: BTreeSet, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct HarnessState { + pub baseline: Entries, + pub excluded: BTreeSet, + /// This exclusion followed an observed local deletion, not a CLI choice. + pub absent: BTreeSet, + /// When the assistant's folder was made (nanoseconds since the epoch), + /// as of the last agreement: another time means it was installed again. + #[serde(skip_serializing_if = "Option::is_none")] + pub folder_created: Option, +} + +/// When `path` was made, where the system records it. Folders (unlike +/// files, which editors replace on every save) are only made again when the +/// assistant is installed again. +fn created(path: &Path) -> Option { + fs::metadata(path) + .and_then(|meta| meta.created()) + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|since| since.as_nanos()) +} + +impl DeviceState { + fn path(home: &Home, pouch: &str) -> PathBuf { + home.state_dir() + .join("settings") + .join(format!("{pouch}.json")) + } + + pub fn load(home: &Home, pouch: &str) -> Result { + let path = Self::path(home, pouch); + match read_optional(&path)? { + None => Ok(Self::default()), + Some(bytes) => serde_json::from_slice(&bytes) + .map_err(|e| SyncError::Invalid(format!("invalid settings state: {e}"))), + } + } + + pub fn save(&self, home: &Home, pouch: &str) -> Result<()> { + let path = Self::path(home, pouch); + if let Some(parent) = path.parent() { + home.ensure_private_dir(parent)?; + } + let bytes = + serde_json::to_vec_pretty(self).map_err(|e| SyncError::Invalid(e.to_string()))?; + write_private(&path, &bytes)?; + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Setting { + pub harness: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, + pub files: BTreeMap>, +} + +fn invalid(message: impl Into) -> SyncError { + SyncError::Invalid(message.into()) +} + +fn ordered(value: &Value) -> Value { + match value { + Value::Object(fields) => Value::Object( + fields + .iter() + .map(|(k, v)| (k.clone(), ordered(v))) + .collect(), + ), + Value::Array(values) => Value::Array(values.iter().map(ordered).collect()), + _ => value.clone(), + } +} + +impl Setting { + pub fn from_item(item: &LocalItem) -> Result { + if item.kind != "setting" + || sp_harness::settings(&item.name).is_empty() + || item.files.len() != 1 + { + return Err(invalid("invalid settings item")); + } + let bytes = &item + .files + .get("setting.json") + .ok_or_else(|| invalid("missing setting.json"))? + .bytes; + let setting: Self = serde_json::from_slice(bytes) + .map_err(|e| invalid(format!("invalid setting.json: {e}")))?; + if setting.harness != item.name + || (item.name == "opencode" && !matches!(setting.format, Some(1 | 2))) + || (item.name != "opencode" && setting.format.is_some()) + { + return Err(invalid("settings harness or format mismatch")); + } + let specs = specs(&item.name, setting.format); + for (file, values) in &setting.files { + let Some(spec) = specs.iter().find(|spec| spec.file == file) else { + return Err(invalid("settings contain an unknown file")); + }; + for (key, value) in values { + let valid = if key == "security" && item.name == "gemini-cli" { + value + .get("auth") + .and_then(Value::as_object) + .is_some_and(|auth| auth.len() == 1 && auth.contains_key("selectedType")) + && value.as_object().is_some_and(|map| map.len() == 1) + } else if key == "security" && item.name == "qwen-code" { + value + .as_object() + .is_some_and(|map| map.len() == 1 && map.contains_key("auth")) + } else if key == "options" && item.name == "crush" { + value + .as_object() + .is_some_and(|map| !map.contains_key("data_directory")) + } else if key == "sessionDefaultSettings" && item.name == "factory" { + value.as_object().is_some_and(|map| { + map.keys().all(|key| { + [ + "interactionMode", + "autonomyLevel", + "specModeModel", + "specModeReasoningEffort", + ] + .contains(&key.as_str()) + }) + }) + } else { + allowed(&item.name, spec, key) + }; + if !valid + || (file == ".env" + && !value.as_str().is_some_and(|s| !s.contains(['\n', '\r']))) + { + return Err(invalid("settings contain an unapproved key or value")); + } + } + } + Ok(setting) + } + + pub fn item(&self) -> Result { + let mut files = Files::new(); + let mut normalized = self.clone(); + for values in normalized.files.values_mut() { + for value in values.values_mut() { + *value = ordered(value); + } + } + let mut bytes = + serde_json::to_vec_pretty(&normalized).map_err(|e| invalid(e.to_string()))?; + bytes.push(b'\n'); + files.insert("setting.json".into(), File::new(bytes)); + Ok(LocalItem { + kind: "setting".into(), + name: self.harness.clone(), + files, + }) + } + + pub fn entries(&self) -> Entries { + self.files + .iter() + .flat_map(|(file, keys)| { + if file == ".env" { + return keys + .iter() + .map(|(key, value)| (format!("{file}:{key}"), value.clone())) + .collect::>() + .into_iter(); + } + let spec = specs(&self.harness, self.format) + .iter() + .find(|s| s.file == file); + let text = if file.ends_with(".toml") { + toml::to_string(keys).ok() + } else { + serde_json::to_string(keys).ok() + }; + let values = spec + .and_then(|spec| read_entries(&text?, &self.harness, spec)) + .unwrap_or_default(); + values + .into_iter() + .map(move |(key, value)| (format!("{file}:{key}"), value)) + .collect::>() + .into_iter() + }) + .collect() + } + + fn set(&mut self, entry: &str, value: Value) { + if let Some((file, key)) = entry.split_once(':') { + let keys = self.files.entry(file.into()).or_default(); + if file != ".env" && key.starts_with("security.auth.") { + let security = keys + .entry("security".into()) + .or_insert_with(|| Value::Object(Default::default())); + if let Some(root) = security.as_object_mut() { + let auth = root + .entry("auth") + .or_insert_with(|| Value::Object(Default::default())); + if let Some(fields) = auth.as_object_mut() { + fields.insert(key["security.auth.".len()..].into(), value); + } + } + } else if file != ".env" && key == "security.auth" { + keys.insert("security".into(), serde_json::json!({"auth": value})); + } else if let Some((parent, child)) = key.split_once('.') + && specs(&self.harness, self.format) + .iter() + .any(|spec| spec.file == file && spec.maps.contains(&parent)) + { + let map = keys + .entry(parent.into()) + .or_insert_with(|| Value::Object(Default::default())); + if let Some(fields) = map.as_object_mut() { + fields.insert(child.into(), value); + } + } else { + keys.insert(key.into(), value); + } + } + } +} + +fn allowed(id: &str, spec: &SettingsFile, key: &str) -> bool { + if spec.file == ".env" { + return !key.is_empty() + && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + && !key.as_bytes()[0].is_ascii_digit(); + } + let top = key.split('.').next().unwrap_or_default(); + let nested = key == top + || spec.maps.contains(&top) + && key.strip_prefix(top).is_some_and(|rest| { + rest.starts_with('.') && rest.len() > 1 && !rest[1..].contains(':') + }) + || matches!(key, "security.auth.selectedType" | "security.auth"); + let forbidden = id == "crush" && key.starts_with("options.data_directory"); + spec.keys.contains(&top) && nested && !forbidden +} + +/// The files and keys for this format: OpenCode 1 and 2 name them apart. +fn specs(id: &str, format: Option) -> &'static [SettingsFile] { + if id == "opencode" && format == Some(1) { + &sp_harness::OPENCODE_V1_SETTINGS + } else { + sp_harness::settings(id) + } +} + +pub fn detect_opencode(text: &str) -> u8 { + let Ok(value) = serde_json::from_str::(&sp_harness::strip_jsonc(text)) else { + return 2; + }; + if value + .get("$schema") + .and_then(Value::as_str) + .is_some_and(|s| s.contains("/v2/")) + || ["providers", "plugins", "permissions", "agents"] + .iter() + .any(|key| value.get(key).is_some()) + { + 2 + } else if ["provider", "plugin", "permission", "agent"] + .iter() + .any(|key| value.get(key).is_some()) + { + 1 + } else { + 2 + } +} + +fn local_format(root: &Path) -> u8 { + let text = ["opencode.json", "opencode.jsonc"] + .iter() + .find_map(|file| fs::read_to_string(root.join(file)).ok()); + if let Some(text) = &text { + let parsed = serde_json::from_str::(&sp_harness::strip_jsonc(text)).ok(); + let recognized = parsed.as_ref().is_some_and(|value| { + value + .get("$schema") + .and_then(Value::as_str) + .is_some_and(|s| s.contains("/v2/")) + || [ + "providers", + "plugins", + "permissions", + "agents", + "provider", + "plugin", + "permission", + "agent", + ] + .iter() + .any(|key| value.get(key).is_some()) + }); + if recognized { + return detect_opencode(text); + } + } + // Only OpenCode 2 writes `cli.json` (migrating `tui.json` into it). + if root.join("cli.json").exists() { + return 2; + } + let version = std::process::Command::new("opencode") + .arg("--version") + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()); + version.as_deref().map_or(2, version_format) +} + +fn version_format(version: &str) -> u8 { + if version + .split(|c: char| !c.is_ascii_digit()) + .find(|part| !part.is_empty()) + == Some("1") + { + 1 + } else { + 2 + } +} + +/// Every file settings sync reads for an assistant, in both OpenCode +/// formats, so the background sync notices edits to any of them. +pub fn files(target: &Target) -> Vec { + let mut names: BTreeSet<&str> = sp_harness::settings(target.id) + .iter() + .map(|spec| spec.file) + .collect(); + if target.id == "opencode" { + names.extend( + sp_harness::OPENCODE_V1_SETTINGS + .iter() + .map(|spec| spec.file), + ); + names.insert("opencode.jsonc"); + } + names + .into_iter() + .map(|name| target.root.join(name)) + .collect() +} + +pub fn translated_on(id: &str, root: &Path, setting: &Setting, entry: &str) -> bool { + id == "opencode" + && setting.format != Some(local_format(root)) + && translate(&setting.entries(), setting.format, Some(local_format(root))) + .contains_key(entry) +} + +pub fn local_entries(id: &str, root: &Path, setting: &Setting) -> BTreeSet { + if id == "opencode" && setting.format != Some(local_format(root)) { + translate(&setting.entries(), setting.format, Some(local_format(root))) + .into_keys() + .collect() + } else { + BTreeSet::new() + } +} + +fn file_path(root: &Path, id: &str, name: &str) -> PathBuf { + if id == "opencode" + && name == "opencode.json" + && root.join("opencode.jsonc").exists() + && !root.join(name).exists() + { + root.join("opencode.jsonc") + } else { + root.join(name) + } +} + +fn env_entries(text: &str) -> Entries { + let mut result = Entries::new(); + for line in text.lines() { + let line = line + .trim_start() + .strip_prefix("export ") + .unwrap_or(line.trim_start()); + if let Some((key, value)) = line.split_once('=') { + let key = key.trim(); + if key.is_empty() + || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + || key.as_bytes()[0].is_ascii_digit() + { + continue; + } + let value = value.trim(); + let value = if value.starts_with(['"', '\'']) { + value + .get(1..) + .and_then(|tail| tail.find(&value[..1]).map(|end| &tail[..end])) + .unwrap_or(value) + } else { + value + .split_once(" #") + .map_or(value, |(value, _)| value.trim_end()) + }; + result.insert(key.into(), Value::String(value.into())); + } + } + result +} + +fn read_entries(text: &str, id: &str, spec: &SettingsFile) -> Option { + if spec.file == ".env" { + return Some(env_entries(text)); + } + let parsed: Value = if spec.file.ends_with(".toml") { + serde_json::to_value(toml::from_str::(text).ok()?).ok()? + } else { + serde_json::from_str(&sp_harness::strip_jsonc(text)).ok()? + }; + parsed.as_object()?; + let mut entries = Entries::new(); + for key in spec.keys { + let path: Vec<&str> = key.split('.').collect(); + let value = path.iter().try_fold(&parsed, |v, p| v.get(*p)); + let Some(value) = value else { + continue; + }; + if spec.maps.contains(key) { + if let Some(map) = value.as_object() { + for (name, value) in map { + entries.insert(format!("{key}.{name}"), ordered(value)); + } + } + } else if id == "crush" && *key == "options" { + if let Some(map) = value.as_object() { + let mut map = map.clone(); + map.remove("data_directory"); + if !map.is_empty() { + entries.insert(key.to_string(), ordered(&Value::Object(map))); + } + } + } else if id == "factory" && *key == "sessionDefaultSettings" { + if let Some(map) = value.as_object() { + let filtered: serde_json::Map = map + .iter() + .filter(|(key, _)| { + [ + "interactionMode", + "autonomyLevel", + "specModeModel", + "specModeReasoningEffort", + ] + .contains(&key.as_str()) + }) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + if !filtered.is_empty() { + entries.insert(key.to_string(), ordered(&Value::Object(filtered))); + } + } + } else { + entries.insert(key.to_string(), ordered(value)); + } + } + Some(entries) +} + +fn write_env(text: &str, key: &str, value: &str) -> String { + let mut lines: Vec = text.lines().map(str::to_string).collect(); + let mut found = false; + for line in &mut lines { + let trimmed = line.trim_start(); + let raw = trimmed.strip_prefix("export ").unwrap_or(trimmed); + if let Some((name, old)) = raw.split_once('=') + && name.trim() == key + { + let prefix_len = line.len() - old.len(); + let old = old.trim(); + let quote = if old.starts_with('\'') { + "'" + } else if old.starts_with('"') { + "\"" + } else { + "" + }; + let suffix = if quote.is_empty() { + old.find(" #").map_or("", |n| &old[n..]) + } else { + old.get(1..) + .and_then(|rest| rest.find(quote).map(|n| &rest[n + quote.len()..])) + .unwrap_or("") + }; + line.replace_range(prefix_len.., &format!("{quote}{value}{quote}{suffix}")); + found = true; + break; + } + } + if !found { + lines.push(format!("{key}={value}")); + } + format!("{}\n", lines.join("\n")) +} + +fn toml_value(value: &Value) -> Option { + Some(match value { + Value::String(s) => s.as_str().into(), + Value::Bool(v) => (*v).into(), + Value::Number(n) if n.is_i64() => n.as_i64()?.into(), + Value::Number(n) => n.as_f64()?.into(), + Value::Array(values) => toml_edit::Value::Array( + values + .iter() + .map(toml_value) + .collect::>>()? + .into_iter() + .collect(), + ), + Value::Object(map) => { + let mut table = toml_edit::InlineTable::new(); + for (key, value) in map { + table.insert(key, toml_value(value)?); + } + toml_edit::Value::InlineTable(table) + } + Value::Null => return None, + }) +} + +fn set_toml( + doc: &mut toml_edit::DocumentMut, + path: &[&str], + value: &Value, +) -> std::result::Result<(), String> { + use toml_edit::{Item, Table}; + let mut table = doc.as_table_mut(); + for key in &path[..path.len() - 1] { + if !table.contains_key(key) { + table.insert(key, Item::Table(Table::new())); + } + table = table + .get_mut(key) + .and_then(Item::as_table_mut) + .ok_or_else(|| format!("{key} isn't a table"))?; + } + let last = path[path.len() - 1]; + if let Some(fields) = value.as_object() { + if !table.contains_key(last) { + table.insert(last, Item::Table(Table::new())); + } + let dest = table + .get_mut(last) + .and_then(Item::as_table_mut) + .ok_or_else(|| format!("{last} isn't a table"))?; + for (key, value) in fields { + if let Some(mut wanted) = toml_value(value) { + if let Some(previous) = dest.get_mut(key).and_then(Item::as_value_mut) { + *wanted.decor_mut() = previous.decor().clone(); + *previous = wanted; + } else { + dest.insert(key, Item::Value(wanted)); + } + } + } + } else { + let mut wanted = toml_value(value).ok_or("invalid TOML value")?; + if let Some(previous) = table.get(last).and_then(Item::as_value) { + *wanted.decor_mut() = previous.decor().clone(); + } + if let Some(previous) = table.get_mut(last).and_then(Item::as_value_mut) { + *previous = wanted; + } else { + table.insert(last, Item::Value(wanted)); + } + } + Ok(()) +} + +fn write_entry( + text: &str, + file: &str, + entry: &str, + value: &Value, +) -> std::result::Result { + if file == ".env" { + return value + .as_str() + .map(|v| write_env(text, entry, v)) + .ok_or("invalid .env value".into()); + } + if entry == "sessionDefaultSettings" + && file == "settings.json" + && let Some(settings) = value.as_object() + { + let mut text = text.to_string(); + for (name, value) in settings { + text = jsonc::set_value(&text, &[entry, name], Some(value))?; + } + return Ok(text); + } + let path: Vec<&str> = if let Some(field) = entry.strip_prefix("security.auth.") { + vec!["security", "auth", field] + } else if let Some((top, child)) = entry.split_once('.') { + vec![top, child] + } else { + vec![entry] + }; + if file.ends_with(".toml") { + let mut doc: toml_edit::DocumentMut = + text.parse().map_err(|e| format!("invalid TOML: {e}"))?; + set_toml(&mut doc, &path, value)?; + Ok(doc.to_string()) + } else { + jsonc::set_value(text, &path, Some(value)) + } +} + +/// Read a file only when it exists and parses. Missing and broken files cannot +/// establish that the user deleted any entry. +fn read_local(root: &Path, id: &str, format: Option) -> (Entries, BTreeSet) { + let mut entries = Entries::new(); + let mut readable = BTreeSet::new(); + for spec in specs(id, format) { + let path = file_path(root, id, spec.file); + if let Ok(text) = fs::read_to_string(path) + && let Some(values) = read_entries(&text, id, spec) + { + readable.insert(spec.file.into()); + entries.extend( + values + .into_iter() + .map(|(key, value)| (format!("{}:{key}", spec.file), value)), + ); + } + } + (entries, readable) +} + +fn file_of(entry: &str) -> &str { + entry.split_once(':').map_or("", |(file, _)| file) +} + +fn merge( + pouch: &mut Entries, + local: &Entries, + readable: &BTreeSet, + state: &mut HarnessState, +) -> Entries { + let mut writes = Entries::new(); + for (key, value) in local { + if state.excluded.contains(key) { + if !state.absent.contains(key) && state.baseline.get(key) == Some(value) { + continue; + } + state.excluded.remove(key); + state.absent.remove(key); + } + let remote = pouch.get(key).filter(|remote| *remote != value).cloned(); + match (state.baseline.get(key), remote) { + // Changed elsewhere, or first meeting (a new computer, a fresh + // install): the pouch wins over whatever default was here. + (agreed, Some(remote)) if agreed.is_none_or(|agreed| agreed == value) => { + writes.insert(key.clone(), remote.clone()); + state.baseline.insert(key.clone(), remote); + } + (Some(agreed), _) if agreed == value => {} + _ => { + // Edited here since the last agreement: the last writer wins. + pouch.insert(key.clone(), value.clone()); + state.baseline.insert(key.clone(), value.clone()); + } + } + } + let keys: BTreeSet = pouch.keys().chain(state.baseline.keys()).cloned().collect(); + for key in keys { + if local.contains_key(&key) || !readable.contains(file_of(&key)) { + continue; + } + if state.baseline.contains_key(&key) { + state.absent.insert(key.clone()); + state.excluded.insert(key); + } else if !state.excluded.contains(&key) + && let Some(value) = pouch.get(&key) + { + writes.insert(key.clone(), value.clone()); + state.baseline.insert(key, value.clone()); + } + } + writes +} + +fn apply(root: &Path, id: &str, entries: &Entries, backups: &Path) -> Result> { + let mut backed_up = Vec::new(); + let mut staged: BTreeMap = BTreeMap::new(); + for (entry, value) in entries { + let Some((file, key)) = entry.split_once(':') else { + continue; + }; + let path = file_path(root, id, file); + let text = if let Some(text) = staged.get(file) { + text.clone() + } else { + fs::read_to_string(&path).unwrap_or_default() + }; + let mut value = value.clone(); + if id == "crush" + && key == "options" + && let Ok(current) = serde_json::from_str::(&sp_harness::strip_jsonc(&text)) + && let Some(directory) = current.get("options").and_then(|v| v.get("data_directory")) + && let Some(options) = value.as_object_mut() + { + options.insert("data_directory".into(), directory.clone()); + } + let next = write_entry(&text, file, key, &value) + .map_err(|e| invalid(format!("{}: {e}", path.display())))?; + staged.insert(file.into(), next); + } + for (file, text) in staged { + let path = file_path(root, id, &file); + if fs::read_to_string(&path).ok().as_deref() == Some(&text) { + continue; + } + if fs::symlink_metadata(&path).is_ok() { + if backup_path(&path, backups).exists() { + let aside = backup_path(&path, backups).with_extension("setting-backup"); + if aside.exists() { + return Err(invalid(format!( + "backup already exists: {}", + aside.display() + ))); + } + fs::rename(&path, &aside).map_err(io(&path))?; + backed_up.push(aside); + } else { + backed_up.push(back_up(&path, backups)?); + } + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(io(parent))?; + } + write_private(&path, text.as_bytes())?; + } + Ok(backed_up) +} + +/// Restore one selected value now, so the next capture cannot mistake the +/// stale local value for a new edit and overwrite the pouch copy. +pub fn include( + root: &Path, + id: &str, + entry: &str, + item: &LocalItem, + backups: &Path, + state: &mut HarnessState, +) -> Result<()> { + let setting = Setting::from_item(item)?; + let format = (id == "opencode").then(|| local_format(root)); + let entries = if setting.format == format { + setting.entries() + } else { + translate(&setting.entries(), setting.format, format) + }; + let value = entries + .get(entry) + .ok_or_else(|| invalid("entry is not available for this format"))?; + apply( + root, + id, + &Entries::from([(entry.into(), value.clone())]), + backups, + )?; + state.excluded.remove(entry); + state.absent.remove(entry); + state.baseline.insert(entry.into(), value.clone()); + Ok(()) +} + +pub fn exclude(root: &Path, id: &str, entry: &str, state: &mut HarnessState) { + let format = (id == "opencode").then(|| local_format(root)); + let (local, _) = read_local(root, id, format); + if let Some(value) = local.get(entry) { + state.baseline.insert(entry.into(), value.clone()); + state.absent.remove(entry); + } else { + state.absent.insert(entry.into()); + } + state.excluded.insert(entry.into()); +} + +/// Before engine sync, capture local changes in the pouch. Only already +/// present items are edited; the engine sees the exact same normal item type. +pub fn capture(dir: &PouchDir, targets: &[Target], state: &mut DeviceState) -> Result<()> { + let (items, _) = dir.read()?; + for target in targets { + if sp_harness::settings(target.id).is_empty() || target.read_only.is_some() { + continue; + } + let format = (target.id == "opencode").then(|| local_format(&target.root)); + let (local, readable) = read_local(&target.root, target.id, format); + let Some(previous) = items.get(&format!("setting/{}", target.id)) else { + if state.deleted.contains(target.id) { + continue; + } + if !local.is_empty() { + let mut setting = Setting { + harness: target.id.into(), + format, + files: BTreeMap::new(), + }; + for (key, value) in &local { + setting.set(key, value.clone()); + } + let item = setting.item()?; + dir.write_item("setting", target.id, &item.files)?; + // No agreement until the server has resolved this item. An + // existing remote item can still arrive in this first sync. + } + continue; + }; + let mut setting = Setting::from_item(previous)?; + let pouch = if setting.format != format { + translate(&setting.entries(), setting.format, format) + } else { + setting.entries() + }; + let device = state.harnesses.entry(target.id.into()).or_default(); + // Installed again between two syncs: start over, like a new computer. + if let (Some(before), Some(now)) = (device.folder_created, created(&target.root)) + && before != now + { + *device = HarnessState::default(); + } + for file in readable { + let keys: Vec<_> = device + .baseline + .keys() + .filter(|key| file_of(key) == file) + .cloned() + .collect(); + if keys.len() >= 2 && keys.iter().all(|key| !local.contains_key(key)) { + for key in keys { + device.baseline.remove(&key); + device.excluded.remove(&key); + } + } + } + let mut edits = Entries::new(); + for (key, value) in &local { + if device.excluded.contains(key) { + if !device.absent.contains(key) && device.baseline.get(key) == Some(value) { + continue; + } + device.excluded.remove(key); + device.absent.remove(key); + } + match device.baseline.get(key) { + // An unobserved pouch entry must not be overwritten by first + // capture: projection brings it here instead. + None if pouch.contains_key(key) => {} + // Agreement happens after verified server sync. If the + // server advanced too, projection must still see L != B. + Some(agreed) if agreed == value => {} + _ => { + edits.insert(key.clone(), value.clone()); + } + } + } + if !edits.is_empty() { + put(&mut setting, &edits, format); + dir.write_item("setting", target.id, &setting.item()?.files)?; + } + } + Ok(()) +} + +/// Puts this computer's edits into the item, which keeps its own format: +/// rewriting it in another OpenCode version's names would lose what only +/// that version has. An edit with no counterpart there stays local. +fn put(setting: &mut Setting, edits: &Entries, format: Option) { + for (key, value) in translate(edits, format, setting.format) { + setting.set(&key, value); + } +} + +/// After verified engine sync, project preferences. No pouch item means no +/// changes to the user's files (including when a signed delete removed it). +pub fn project( + dir: &PouchDir, + items: &BTreeMap, + targets: &[Target], + state: &mut DeviceState, + backups: &Path, +) -> Result> { + let mut backed_up = Vec::new(); + let installed: BTreeSet<_> = targets.iter().map(|target| target.id).collect(); + state + .harnesses + .retain(|id, _| installed.contains(id.as_str())); + for target in targets { + if sp_harness::settings(target.id).is_empty() || target.read_only.is_some() { + continue; + } + let Some(item) = items.get(&format!("setting/{}", target.id)) else { + continue; + }; + let setting = Setting::from_item(item)?; + state.deleted.remove(target.id); + let format = (target.id == "opencode").then(|| local_format(&target.root)); + let (local, readable) = read_local(&target.root, target.id, format); + let state = state.harnesses.entry(target.id.into()).or_default(); + let mut pouch = setting.entries(); + if setting.format != format { + pouch = translate(&pouch, setting.format, format); + } + let before = pouch.clone(); + let mut writes = merge(&mut pouch, &local, &readable, state); + let edits: Entries = pouch + .iter() + .filter(|(key, value)| before.get(*key) != Some(*value)) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + if !edits.is_empty() { + let mut updated = setting.clone(); + put(&mut updated, &edits, format); + dir.write_item("setting", target.id, &updated.item()?.files)?; + } + // Missing files on an installed harness receive the pouch's values; + // they do not count as deletions or exclusions. + for (key, value) in &pouch { + if !readable.contains(file_of(key)) && !state.excluded.contains(key) { + writes.insert(key.clone(), value.clone()); + state.baseline.insert(key.clone(), value.clone()); + } + } + backed_up.extend(apply(&target.root, target.id, &writes, backups)?); + state.folder_created = created(&target.root); + } + Ok(backed_up) +} + +/// OpenCode 1 ↔ 2. Only what both versions mean the same way crosses over; +/// keybinds keep their own names per version and stay with it. +fn translate(entries: &Entries, source: Option, dest: Option) -> Entries { + let up = match (source, dest) { + (Some(1), Some(2)) => true, + (Some(2), Some(1)) => false, + _ => return entries.clone(), + }; + let mut out = Entries::new(); + let mut scroll = serde_json::Map::new(); + for (key, value) in entries { + let Some((file, name)) = key.split_once(':') else { + continue; + }; + let (top, rest) = name + .split_once('.') + .map_or((name, None), |(t, r)| (t, Some(r))); + match file { + "opencode.json" => { + let renamed = match (top, up) { + ("provider", true) => "providers", + ("providers", false) => "provider", + ("agent", true) => "agents", + ("agents", false) => "agent", + ("permission", true) => "permissions", + ("permissions", false) => "permission", + ( + "model" | "share" | "compaction" | "username" | "default_agent" + | "tool_output", + _, + ) => top, + _ => continue, + }; + let value = if renamed.starts_with("provider") && rest.is_some() { + provider(value, up) + } else { + value.clone() + }; + let name = rest.map_or(renamed.to_string(), |rest| format!("{renamed}.{rest}")); + out.insert(format!("opencode.json:{name}"), value); + } + "tui.json" if up => match name { + "theme" => { + out.insert( + "cli.json:theme".into(), + serde_json::json!({ "name": value }), + ); + } + "mouse" | "cursor" | "attention" => { + out.insert(format!("cli.json:{name}"), value.clone()); + } + "leader_timeout" => { + out.insert( + "cli.json:leader".into(), + serde_json::json!({ "timeout": value }), + ); + } + "scroll_speed" => { + scroll.insert("speed".into(), value.clone()); + } + "scroll_acceleration" => { + if let Some(enabled) = value.get("enabled") { + scroll.insert("acceleration".into(), enabled.clone()); + } + } + _ => {} + }, + "cli.json" if !up => { + let mut put = |name: &str, value: Option<&Value>| { + if let Some(value) = value { + out.insert(format!("tui.json:{name}"), value.clone()); + } + }; + match name { + "theme" => put("theme", value.get("name")), + "mouse" | "cursor" | "attention" => put(name, Some(value)), + "leader" => put("leader_timeout", value.get("timeout")), + "scroll" => { + put("scroll_speed", value.get("speed")); + let enabled = value + .get("acceleration") + .map(|on| serde_json::json!({ "enabled": on })); + put("scroll_acceleration", enabled.as_ref()); + } + _ => {} + } + } + _ => {} + } + } + if !scroll.is_empty() { + out.insert("cli.json:scroll".into(), Value::Object(scroll)); + } + out +} + +/// A custom provider: OpenCode 1's `npm`/`options` and models' `id`/`options` +/// are OpenCode 2's `package` (`aisdk:` for AI SDK packages)/`settings` +/// (headers apart) and `modelID`/`settings`. +fn provider(value: &Value, up: bool) -> Value { + let mut value = value.clone(); + let Some(map) = value.as_object_mut() else { + return value; + }; + let rename = |map: &mut serde_json::Map, from: &str, to: &str| { + if let Some(moved) = map.remove(from) { + map.insert(to.into(), moved); + } + }; + if up { + if let Some(Value::String(npm)) = map.remove("npm") { + let package = if npm.starts_with("aisdk:") || npm.starts_with("@opencode/") { + npm + } else { + format!("aisdk:{npm}") + }; + map.insert("package".into(), Value::String(package)); + } + if let Some(mut options) = map.remove("options") { + if let Some(fields) = options.as_object_mut() + && let Some(headers) = fields.remove("headers") + { + map.insert("headers".into(), headers); + } + map.insert("settings".into(), options); + } + if let Some(api) = map.remove("api") { + let settings = map + .entry("settings") + .or_insert_with(|| Value::Object(Default::default())); + if let Some(settings) = settings.as_object_mut() { + settings.entry("baseURL").or_insert(api); + } + } + } else { + if let Some(Value::String(package)) = map.remove("package") { + let npm = package + .strip_prefix("aisdk:") + .unwrap_or(&package) + .to_string(); + map.insert("npm".into(), Value::String(npm)); + } + if let Some(mut settings) = map.remove("settings") { + if let Some(options) = settings.as_object_mut() + && let Some(headers) = map.remove("headers") + { + options.insert("headers".into(), headers); + } + map.insert("options".into(), settings); + } + } + if let Some(models) = map.get_mut("models").and_then(Value::as_object_mut) { + for model in models.values_mut().filter_map(Value::as_object_mut) { + let (id, options) = if up { + (("id", "modelID"), ("options", "settings")) + } else { + (("modelID", "id"), ("settings", "options")) + }; + rename(model, id.0, id.1); + rename(model, options.0, options.1); + } + } + value +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn allowlists_omit_integrations_and_private_machine_data() { + for (id, forbidden) in [ + ("opencode", "mcp"), + ("claude-code", "hooks"), + ("codex", "mcp_servers"), + ("gemini-cli", "extensions"), + ("qwen-code", "channels"), + ("crush", "recent_models"), + ("factory", "enabledPlugins"), + ] { + assert!( + !sp_harness::settings(id) + .iter() + .any(|spec| spec.keys.contains(&forbidden)), + "{id}" + ); + } + assert!(sp_harness::settings("cursor").is_empty()); + assert!(sp_harness::settings("windsurf").is_empty()); + } + + #[test] + fn setting_item_is_deterministic_and_validated() -> Result<()> { + let mut setting = Setting { + harness: "claude-code".into(), + format: None, + files: BTreeMap::new(), + }; + setting.set("settings.json:env.API_KEY", json!("secret")); + setting.set("settings.json:model", json!("sonnet")); + let item = setting.item()?; + let text = String::from_utf8_lossy(&item.files["setting.json"].bytes); + assert!(text.starts_with("{\n \"harness\"")); + assert_eq!( + item.files["setting.json"].bytes, + setting.item()?.files["setting.json"].bytes + ); + assert_eq!( + Setting::from_item(&item)?.entries()["settings.json:env.API_KEY"], + json!("secret") + ); + setting.set("settings.json:hooks", json!({"evil":true})); + assert!(Setting::from_item(&setting.item()?).is_err()); + Ok(()) + } + + #[test] + fn editors_preserve_unrelated_content() { + let json = "{\n // keep this\n \"mcp\": {},\n \"model\": \"old\"\n}\n"; + let updated = + write_entry(json, "settings.json", "model", &json!("new")).unwrap_or_default(); + assert!(updated.contains("// keep this")); + assert!(updated.contains("\"mcp\": {}")); + assert!(updated.contains("\"model\": \"new\"")); + + let toml = "# keep this\nmodel = \"old\"\n[mcp_servers.foo]\ncommand = \"echo\"\n"; + let updated = write_entry(toml, "config.toml", "model", &json!("new")).unwrap_or_default(); + assert!(updated.contains("# keep this"), "{updated:?}"); + assert!(updated.contains("[mcp_servers.foo]")); + assert!(updated.contains("model = \"new\"")); + + let env = "# keep\nexport GEMINI_API_KEY='old'\n\nOTHER=keep\n"; + assert_eq!( + write_env(env, "GEMINI_API_KEY", "new"), + "# keep\nexport GEMINI_API_KEY='new'\n\nOTHER=keep\n" + ); + assert_eq!(env_entries(env)["GEMINI_API_KEY"], json!("old")); + } + + #[test] + fn merge_keeps_local_changes_and_excludes_deletions() { + let mut pouch = Entries::from([ + ("settings.json:model".into(), json!("remote")), + ("settings.json:env.KEY".into(), json!("secret")), + ]); + let mut device = HarnessState { + baseline: Entries::from([("settings.json:model".into(), json!("remote"))]), + ..Default::default() + }; + let readable = BTreeSet::from(["settings.json".into()]); + let local = Entries::from([("settings.json:model".into(), json!("local"))]); + let writes = merge(&mut pouch, &local, &readable, &mut device); + assert!(writes.contains_key("settings.json:env.KEY")); + assert_eq!(pouch["settings.json:model"], json!("local")); + device + .baseline + .insert("settings.json:env.KEY".into(), json!("secret")); + assert!(merge(&mut pouch, &local, &readable, &mut device).is_empty()); + assert!(device.excluded.contains("settings.json:env.KEY")); + let mut readded = local; + readded.insert("settings.json:env.KEY".into(), json!("again")); + merge(&mut pouch, &readded, &readable, &mut device); + assert!(!device.excluded.contains("settings.json:env.KEY")); + assert_eq!(pouch["settings.json:env.KEY"], json!("again")); + pouch.insert("settings.json:model".into(), json!("another computer")); + assert_eq!( + merge(&mut pouch, &readded, &readable, &mut device)["settings.json:model"], + json!("another computer") + ); + } + + #[test] + fn opencode_detects_versions_and_translates_providers() { + assert_eq!( + detect_opencode("{\"$schema\": \"https://opencode.ai/config/v2/config.json\"}"), + 2 + ); + assert_eq!(detect_opencode("{\"provider\": {}}"), 1); + assert_eq!(detect_opencode("{}"), 2); + assert_eq!(version_format("opencode 1.19.4"), 1); + assert_eq!(version_format("v2.0.1"), 2); + let original = Entries::from([( + "opencode.json:providers.mine".into(), + json!({"name":"Mine","package":"aisdk:@ai-sdk/openai-compatible","settings":{"baseURL":"https://example.com","apiKey":"secret"},"headers":{"x":"y"},"models":{"m1":{"name":"M1","modelID":"m-1","settings":{"reasoning":true}}}}), + )]); + let old = translate(&original, Some(2), Some(1)); + let mine = &old["opencode.json:provider.mine"]; + assert_eq!(mine["npm"], json!("@ai-sdk/openai-compatible")); + assert_eq!(mine["options"]["headers"]["x"], json!("y")); + assert_eq!(mine["models"]["m1"]["id"], json!("m-1")); + assert_eq!(mine["models"]["m1"]["options"]["reasoning"], json!(true)); + assert_eq!(translate(&old, Some(1), Some(2)), original); + } + + #[test] + fn opencode_terminal_preferences_move_between_tui_and_cli_json() { + let v1 = Entries::from([ + ("tui.json:theme".into(), json!("tokyonight")), + ("tui.json:leader_timeout".into(), json!(900)), + ("tui.json:scroll_speed".into(), json!(3)), + ( + "tui.json:scroll_acceleration".into(), + json!({"enabled": true}), + ), + ("tui.json:mouse".into(), json!(false)), + ("tui.json:keybinds".into(), json!({"session_new": "ctrl+n"})), + ("opencode.json:small_model".into(), json!("mine/m0")), + ("opencode.json:username".into(), json!("me")), + ]); + let v2 = translate(&v1, Some(1), Some(2)); + assert_eq!( + v2, + Entries::from([ + ("cli.json:theme".into(), json!({"name": "tokyonight"})), + ("cli.json:leader".into(), json!({"timeout": 900})), + ( + "cli.json:scroll".into(), + json!({"speed": 3, "acceleration": true}) + ), + ("cli.json:mouse".into(), json!(false)), + ("opencode.json:username".into(), json!("me")), + ]) + ); + let back = translate(&v2, Some(2), Some(1)); + for key in back.keys() { + assert_eq!(back[key], v1[key], "{key}"); + } + assert!(!back.contains_key("tui.json:keybinds")); + } + + #[test] + fn an_edit_on_the_other_opencode_version_keeps_the_items_format() -> Result<()> { + let mut setting = Setting { + harness: "opencode".into(), + format: Some(1), + files: BTreeMap::new(), + }; + setting.set("opencode.json:model", json!("mine/m1")); + setting.set("opencode.json:small_model", json!("mine/m0")); + setting.set("tui.json:theme", json!("tokyonight")); + put( + &mut setting, + &Entries::from([ + ("opencode.json:model".into(), json!("mine/m2")), + ("cli.json:theme".into(), json!({"name": "gruvbox"})), + ("cli.json:session".into(), json!({"sidebar": "auto"})), + ]), + Some(2), + ); + let entries = Setting::from_item(&setting.item()?)?.entries(); + assert_eq!(setting.format, Some(1)); + assert_eq!(entries["opencode.json:model"], json!("mine/m2")); + assert_eq!(entries["opencode.json:small_model"], json!("mine/m0")); + assert_eq!(entries["tui.json:theme"], json!("gruvbox")); + assert!(!setting.files.contains_key("cli.json")); + Ok(()) + } + + #[test] + fn first_meeting_takes_the_pouch_over_a_fresh_installs_defaults() { + let readable = BTreeSet::from(["settings.json".into()]); + let mut pouch = Entries::from([("settings.json:model".into(), json!("from pouch"))]); + let local = Entries::from([ + ("settings.json:model".into(), json!("fresh default")), + ("settings.json:theme".into(), json!("only here")), + ]); + let mut device = HarnessState::default(); + let writes = merge(&mut pouch, &local, &readable, &mut device); + assert_eq!(writes["settings.json:model"], json!("from pouch")); + assert_eq!(pouch["settings.json:model"], json!("from pouch")); + assert_eq!(pouch["settings.json:theme"], json!("only here")); + assert_eq!(device.baseline["settings.json:model"], json!("from pouch")); + } + + #[test] + fn the_background_sync_watches_every_settings_file() { + let target = |id: &'static str, root: &str| Target { + id, + name: id, + root: PathBuf::from(root), + instructions: None, + skills: None, + agents: None, + commands: None, + mcp: None, + hooks: None, + plugins: None, + read_only: None, + }; + let opencode = files(&target("opencode", "/oc")); + for name in ["opencode.json", "opencode.jsonc", "tui.json", "cli.json"] { + assert!( + opencode.contains(&PathBuf::from("/oc").join(name)), + "{name}" + ); + } + assert!(files(&target("gemini-cli", "/g")).contains(&PathBuf::from("/g/.env"))); + assert_eq!( + files(&target("copilot", "/c")), + [PathBuf::from("/c/settings.json")] + ); + assert!(files(&target("cursor", "/x")).is_empty()); + } + + #[test] + fn reinstall_clears_device_agreement_without_changing_pouch() -> Result<()> { + let temp = tempfile::tempdir().map_err(io(Path::new("temp")))?; + let root = temp.path().join(".claude"); + fs::create_dir(&root).map_err(io(&root))?; + let mut setting = Setting { + harness: "claude-code".into(), + format: None, + files: BTreeMap::new(), + }; + setting.set("settings.json:model", json!("remote")); + let dir = PouchDir::new(temp.path().join("pouch")); + dir.write_item("setting", "claude-code", &setting.item()?.files)?; + let mut state = DeviceState::default(); + state + .harnesses + .entry("claude-code".into()) + .or_default() + .excluded + .insert("settings.json:model".into()); + state.harnesses.retain(|_, _| root.exists()); + fs::remove_dir(&root).map_err(io(&root))?; + state.harnesses.retain(|_, _| root.exists()); + assert!(state.harnesses.is_empty()); + fs::create_dir(&root).map_err(io(&root))?; + let item = dir.read()?.0; + let target = Target { + id: "claude-code", + name: "Claude", + root: root.clone(), + instructions: None, + skills: None, + agents: None, + commands: None, + mcp: None, + hooks: None, + plugins: None, + read_only: None, + }; + project( + &dir, + &item, + &[target], + &mut state, + &temp.path().join("backups"), + )?; + assert!( + fs::read_to_string(root.join("settings.json")) + .map_err(io(&root))? + .contains("remote") + ); + Ok(()) + } + + #[test] + fn recreated_file_resets_baseline_but_partial_deletion_excludes() -> Result<()> { + let temp = tempfile::tempdir().map_err(io(Path::new("temp")))?; + let root = temp.path().join(".claude"); + fs::create_dir(&root).map_err(io(&root))?; + let file = root.join("settings.json"); + fs::write(&file, "{\"model\":\"new machine default\"}").map_err(io(&file))?; + let mut setting = Setting { + harness: "claude-code".into(), + format: None, + files: BTreeMap::new(), + }; + setting.set("settings.json:model", json!("remote")); + setting.set("settings.json:effortLevel", json!("high")); + let dir = PouchDir::new(temp.path().join("pouch")); + dir.write_item("setting", "claude-code", &setting.item()?.files)?; + let mut state = DeviceState::default(); + state.harnesses.insert( + "claude-code".into(), + HarnessState { + baseline: setting.entries(), + ..Default::default() + }, + ); + let target = Target { + id: "claude-code", + name: "Claude", + root: root.clone(), + instructions: None, + skills: None, + agents: None, + commands: None, + mcp: None, + hooks: None, + plugins: None, + read_only: None, + }; + capture(&dir, std::slice::from_ref(&target), &mut state)?; + project( + &dir, + &dir.read()?.0, + std::slice::from_ref(&target), + &mut state, + &temp.path().join("backups"), + )?; + assert!( + state.harnesses["claude-code"] + .excluded + .contains("settings.json:effortLevel") + ); + + fs::write(&file, "{}").map_err(io(&file))?; + capture(&dir, &[target], &mut state)?; + assert!(state.harnesses["claude-code"].baseline.is_empty()); + assert!(state.harnesses["claude-code"].excluded.is_empty()); + Ok(()) + } + + #[test] + fn a_folder_made_again_is_a_reinstall_even_with_a_default_value() -> Result<()> { + let temp = tempfile::tempdir().map_err(io(Path::new("temp")))?; + let root = temp.path().join(".gemini"); + fs::create_dir(&root).map_err(io(&root))?; + let Some(first) = created(&root) else { + return Ok(()); // This file system doesn't record creation times. + }; + let mut setting = Setting { + harness: "gemini-cli".into(), + format: None, + files: BTreeMap::new(), + }; + setting.set("settings.json:ui", json!({"theme": "Dracula"})); + setting.set("settings.json:model", json!({"name": "gem-mine"})); + setting.set("settings.json:general", json!({"vimMode": true})); + let dir = PouchDir::new(temp.path().join("pouch")); + dir.write_item("setting", "gemini-cli", &setting.item()?.files)?; + let mut state = DeviceState::default(); + state.harnesses.insert( + "gemini-cli".into(), + HarnessState { + baseline: setting.entries(), + folder_created: Some(first), + ..Default::default() + }, + ); + fs::remove_dir_all(&root).map_err(io(&root))?; + std::thread::sleep(std::time::Duration::from_millis(20)); + fs::create_dir(&root).map_err(io(&root))?; + let file = root.join("settings.json"); + fs::write(&file, "{\"ui\": {\"theme\": \"Default Light\"}}").map_err(io(&file))?; + let target = Target { + id: "gemini-cli", + name: "Gemini CLI", + root: root.clone(), + instructions: None, + skills: None, + agents: None, + commands: None, + mcp: None, + hooks: None, + plugins: None, + read_only: None, + }; + capture(&dir, std::slice::from_ref(&target), &mut state)?; + project( + &dir, + &dir.read()?.0, + &[target], + &mut state, + &temp.path().join("backups"), + )?; + let text = fs::read_to_string(&file).map_err(io(&file))?; + assert!(text.contains("Dracula") && text.contains("gem-mine") && text.contains("vimMode")); + assert!(state.harnesses["gemini-cli"].excluded.is_empty()); + assert_ne!(state.harnesses["gemini-cli"].folder_created, Some(first)); + Ok(()) + } + + #[test] + fn skipped_assistants_remain_in_the_config_after_reinstall() { + let choice = sp_core::SyncChoice { + skip_assistants: ["claude-code".into()].into(), + skip_kinds: BTreeSet::new(), + }; + assert!(!choice.assistant_on("claude-code")); + assert!(choice.kind_on("setting")); + } + + #[test] + fn fixture_for_installed_cli_config_parsers() -> Result<()> { + let temp = tempfile::tempdir().map_err(io(Path::new("temp")))?; + let home = std::env::var_os("SP_SETTINGS_PROBE_HOME") + .map_or_else(|| temp.path().to_path_buf(), PathBuf::from); + for (id, folder, entries) in [ + ( + "opencode", + ".config/opencode", + Entries::from([("opencode.json:model".into(), json!("openai/gpt-5"))]), + ), + ( + "codex", + ".codex", + Entries::from([("config.toml:model".into(), json!("gpt-5"))]), + ), + ( + "gemini-cli", + ".gemini", + Entries::from([("settings.json:ui".into(), json!({"theme": "Default"}))]), + ), + ] { + let root = home.join(folder); + fs::create_dir_all(&root).map_err(io(&root))?; + apply(&root, id, &entries, &home.join("backups"))?; + assert!(root.exists()); + } + Ok(()) + } + + #[test] + fn explicit_exclusion_does_not_immediately_uncancel_itself() -> Result<()> { + let temp = tempfile::tempdir().map_err(io(Path::new("temp")))?; + let file = temp.path().join("settings.json"); + fs::write(&file, "{\"model\":\"local\"}").map_err(io(&file))?; + let mut state = HarnessState::default(); + exclude( + temp.path(), + "claude-code", + "settings.json:model", + &mut state, + ); + let local = read_local(temp.path(), "claude-code", None).0; + let mut pouch = Entries::from([("settings.json:model".into(), json!("remote"))]); + assert!( + merge( + &mut pouch, + &local, + &BTreeSet::from(["settings.json".into()]), + &mut state + ) + .is_empty() + ); + assert_eq!(pouch["settings.json:model"], json!("remote")); + assert!(state.excluded.contains("settings.json:model")); + fs::write(&file, "{\"model\":\"readded\"}").map_err(io(&file))?; + let local = read_local(temp.path(), "claude-code", None).0; + merge( + &mut pouch, + &local, + &BTreeSet::from(["settings.json".into()]), + &mut state, + ); + assert!(!state.excluded.contains("settings.json:model")); + assert_eq!(pouch["settings.json:model"], json!("readded")); + Ok(()) + } + + #[test] + fn applying_after_another_config_editor_keeps_the_original_backup() -> Result<()> { + let temp = tempfile::tempdir().map_err(io(Path::new("temp")))?; + let root = temp.path().join("claude"); + fs::create_dir(&root).map_err(io(&root))?; + let file = root.join("settings.json"); + fs::write(&file, "{\"model\":\"original\"}").map_err(io(&file))?; + let backup = temp.path().join("backups"); + let original = back_up(&file, &backup)?; + fs::write(&file, "{\"model\":\"intermediate\"}").map_err(io(&file))?; + let saved = apply( + &root, + "claude-code", + &Entries::from([("settings.json:model".into(), json!("new"))]), + &backup, + )?; + assert_eq!( + fs::read_to_string(original).map_err(io(&file))?, + "{\"model\":\"original\"}" + ); + assert_eq!(saved.len(), 1); + assert!( + fs::read_to_string(&file) + .map_err(io(&file))? + .contains("new") + ); + Ok(()) + } + + #[test] + fn nested_allowlist_leaves_security_and_data_directory_alone() -> Result<()> { + let gemini = specs("gemini-cli", None); + let spec = gemini + .iter() + .find(|spec| spec.file == "settings.json") + .ok_or_else(|| invalid("missing Gemini settings spec"))?; + let entries = read_entries("{\"security\":{\"auth\":{\"selectedType\":\"api-key\",\"other\":true},\"folderTrust\":false}}", "gemini-cli", spec).ok_or_else(|| invalid("Gemini parse"))?; + assert_eq!(entries.len(), 1); + assert_eq!(entries["security.auth.selectedType"], json!("api-key")); + let mut setting = Setting { + harness: "gemini-cli".into(), + format: None, + files: BTreeMap::new(), + }; + setting.set("settings.json:security.auth.selectedType", json!("api-key")); + assert!(Setting::from_item(&setting.item()?).is_ok()); + let text = write_entry( + "{\"security\":{\"auth\":{\"other\":true},\"folderTrust\":false}}", + "settings.json", + "security.auth.selectedType", + &json!("api-key"), + ) + .map_err(invalid)?; + assert!(text.contains("folderTrust")); + assert!(text.contains("other")); + + let crush = specs("crush", None); + let entries = read_entries( + "{\"options\":{\"theme\":\"dark\",\"data_directory\":\"/personal\"}}", + "crush", + &crush[0], + ) + .ok_or_else(|| invalid("Crush parse"))?; + assert_eq!(entries["options"], json!({"theme":"dark"})); + let mut setting = Setting { + harness: "crush".into(), + format: None, + files: BTreeMap::new(), + }; + setting.set("crush.json:options", json!({"data_directory":"/bad"})); + assert!(Setting::from_item(&setting.item()?).is_err()); + Ok(()) + } +} diff --git a/scripts/e2e-settings.sh b/scripts/e2e-settings.sh new file mode 100755 index 0000000..64ed275 --- /dev/null +++ b/scripts/e2e-settings.sh @@ -0,0 +1,489 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# End-to-end test of assistant settings sync: three Docker "computers" with +# every supported assistant really installed, one account and pouch, the real +# API and web app, and each assistant's own CLI reading what arrived. +# +# scripts/e2e-settings.sh build, run, print the result table +# KEEP=1 scripts/e2e-settings.sh leave the computers and servers up +# +# A runs OpenCode 1, B OpenCode 2 (so OpenCode settings are translated both +# ways), C joins last with no assistant set up yet. Custom providers point at +# a mock provider (scripts/e2e-settings/mock-llm.mjs) that records the key +# and model each assistant sends: proof that the CLI accepted the settings. +# +# Needs Docker and the skillpouch repository next to this one (or +# SKILLPOUCH_REPO) with `pnpm install` done, Playwright's Chromium and the +# test Postgres (`skillpouch-pg-test`, see its AGENTS.md). Ports: +# E2E_API_PORT (18181), E2E_WEB_PORT (15181), E2E_MOCK_PORT (4999). Logs and +# temporary files go to target/e2e-tmp. +set -Eeuo pipefail +trap 'echo "failed at line $LINENO: $BASH_COMMAND" >&2' ERR + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cli="$(dirname "$here")" +repo="${SKILLPOUCH_REPO:-$(dirname "$cli")/skillpouch}" +api_port="${E2E_API_PORT:-18181}" +web_port="${E2E_WEB_PORT:-15181}" +mock_port="${E2E_MOCK_PORT:-4999}" +image="${E2E_IMAGE:-sp-e2e-settings:latest}" +origin="http://localhost:$web_port" +M="http://127.0.0.1:$mock_port" +# Everything on this side stays in the checkout (git-ignored), never in /tmp: +# the API's blobs, the browser and the build tools use TMPDIR too. +export TMPDIR="$cli/target/e2e-tmp" +mkdir -p "$TMPDIR" +work="$(mktemp -d -p "$cli/target/e2e-tmp" sp-e2e-settings.XXXXXX)" +pcs=(sp-e2e-a sp-e2e-b sp-e2e-c) +pids=() +source "$here/e2e-settings/harnesses.sh" + +log() { printf '\033[1;35m==>\033[0m %s\n' "$*"; } + +cleanup() { + set +e + if [ -n "${WEB_PID:-}" ]; then + { echo quit >&"${WEB[1]}"; } 2>/dev/null + sleep 2 + kill "$WEB_PID" 2>/dev/null + fi + if [ "${KEEP:-0}" = 1 ]; then + log "kept: ${pcs[*]}, API :$api_port, web :$web_port, logs in $work" + return + fi + for pid in "${pids[@]}"; do kill "$pid" 2>/dev/null; done + wait 2>/dev/null + docker rm -f "${pcs[@]}" >/dev/null 2>&1 + log "logs in $work" +} +trap cleanup EXIT + +# --- the CLI, the computers, the servers ------------------------------------ + +build_cli() { + if [ -n "${SKILLPOUCH_BIN:-}" ]; then + cp "$SKILLPOUCH_BIN" "$work/skillpouch" + return + fi + log "building the CLI for Debian bookworm" + local toolchain + toolchain="$(sed -n 's/^rust-version = "\(.*\)"/\1/p' "$cli/Cargo.toml")" + docker run --rm -v "$cli":/src:ro -v sp-e2e-cargo:/usr/local/cargo/registry \ + -v sp-e2e-target:/target -e CARGO_TARGET_DIR=/target -w /src \ + "rust:$toolchain-bookworm" cargo build --release --locked -p skillpouch >"$work/build.log" 2>&1 + docker run --rm -v sp-e2e-target:/target -v "$work":/out "rust:$toolchain-bookworm" \ + cp /target/release/skillpouch /out/skillpouch +} + +start_servers() { + for port in "$api_port" "$web_port" "$mock_port"; do + if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then + echo "port $port is taken: stop what listens there or pick another (see the top of this file)" >&2 + exit 1 + fi + done + log "starting the API (:$api_port) and the web app (:$web_port)" + (cd "$repo/apps/web" && E2E_API_PORT=$api_port E2E_WEB_PORT=$web_port \ + exec node --import tsx e2e/stack.ts >"$work/api.log" 2>&1) & + pids+=($!) + (cd "$repo/apps/web" && SKILLPOUCH_API_URL=http://127.0.0.1:$api_port WEB_PORT=$web_port \ + exec node_modules/.bin/vite --port "$web_port" --strictPort >"$work/web.log" 2>&1) & + pids+=($!) + node "$here/e2e-settings/mock-llm.mjs" "$mock_port" "$work/mock.log" & + pids+=($!) + for _ in $(seq 120); do + curl -sf "http://127.0.0.1:$api_port/readyz" >/dev/null && + curl -sf -o /dev/null "$origin/sign-in" && return + sleep 1 + done + echo "the servers didn't start; see $work/api.log and $work/web.log" >&2 + exit 1 +} + +start_computers() { + log "building $image and starting ${pcs[*]}" + docker build -q -t "$image" "$here/e2e-settings" >/dev/null + for pc in "${pcs[@]}"; do + docker rm -f "$pc" >/dev/null 2>&1 || true + docker run -d --name "$pc" --network host -e SKILLPOUCH_API_URL="$origin" \ + -e M="$M" "$image" sleep infinity >/dev/null + docker exec "$pc" mkdir -p /e2e + docker cp "$work/skillpouch" "$pc:/usr/local/bin/skillpouch" >/dev/null + for file in harnesses.sh probe.py; do + docker cp "$here/e2e-settings/$file" "$pc:/e2e/$file" >/dev/null + done + done + # B runs OpenCode 2. + docker exec sp-e2e-b ln -sf /opt/opencode2/node_modules/.bin/opencode /usr/local/bin/opencode +} + +# Runs bash on a computer, with the fixtures loaded. Never with this +# script's stdin: the headless CLIs would wait on it, and loops reading +# here-strings would lose their lines. `on_stdin` passes it on. +on() { + local pc="$1" + shift + docker exec -w /root "$pc" bash -c "source /e2e/harnesses.sh; $*" >"$work/sync-$1.log" 2>&1; then + echo "skillpouch sync failed on $1:" >&2 + tail -5 "$work/sync-$1.log" >&2 + return 1 + fi +} + +# The browser: commands to web.mjs, one line back for each. +start_browser() { + coproc WEB { + SKILLPOUCH_WEB_DIR="$repo/apps/web" SKILLPOUCH_WEB_ORIGIN="$origin" \ + exec node "$here/e2e-settings/web.mjs" 2>"$work/browser.log" + } + WEB_PID=$! +} +ask() { + echo "$*" >&"${WEB[1]}" + local line + read -r -t 300 line <&"${WEB[0]}" + echo "$line" +} + +# Logs a computer in and sets it up for the pouch, as the TUI would. +connect() { + local pc="$1" mode="$2" token + token="$(ask token)" + on "$pc" skillpouch login --name "$pc" "$token" >>"$work/login.log" 2>&1 + [ "$(ask connected "$pc")" = ok ] + on_stdin "$pc" python3 - "$pouch" "$mode" <<'EOF' +import json, sys, time +pouch, mode = sys.argv[1:] +device = json.load(open('/root/.skillpouch/state/credentials.json'))['device_id'] +first = {'mode': 'fill', 'assistant': None} if mode == 'fill' else {'mode': 'use_pouch'} +json.dump({'format': 1, 'device_id': device, 'pouch_id': pouch, 'first_sync': first, + 'chosen_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'done': False}, + open('/root/.skillpouch/state/setup.json', 'w')) +EOF + # Syncs happen when the test says so; the background sync has its own step. + on "$pc" skillpouch daemon stop >/dev/null 2>&1 || true +} + +# --- checks ----------------------------------------------------------------- + +declare -A RESULT +SCENARIOS=(arrive cli edit delete include folder reinstall late select translate) +variant() { [ "$2" = opencode ] && { [ "$1" = sp-e2e-b ] && echo opencode@2 || echo opencode@1; } || echo "$2"; } +fixture() { local name="$1[$2]"; echo "${!name:-}"; } + +pass() { [ -z "${RESULT[$1|$2]:-}" ] && RESULT[$1|$2]=ok || true; } +fail() { + RESULT[$1|$2]="FAIL" + echo " ✗ $1 $2: $3" | tee -a "$work/failures.log" +} + +# Every `file|path|json` line holds on the computer. +holds() { + local pc="$1" id="$2" lines="$3" file path json + while IFS='|' read -r file path json; do + [ -n "$file" ] || continue + on "$pc" python3 /e2e/probe.py eq "\$HOME/${ROOT[$id]}/$file" "'$path'" "'$json'" || + echo "$file:$path is $(on "$pc" python3 /e2e/probe.py get "\$HOME/${ROOT[$id]}/$file" "'$path'" || echo absent), wanted $json" + done <<<"$lines" + return 0 +} +absent() { + local pc="$1" id="$2" lines="$3" file path + while IFS='|' read -r file path; do + [ -n "$file" ] || continue + on "$pc" python3 /e2e/probe.py get "\$HOME/${ROOT[$id]}/$file" "'$path'" >/dev/null && + echo "$file:$path arrived" + done <<<"$lines" + return 0 +} +check() { + local scenario="$1" pc="$2" id="$3" lines="$4" problems + problems="$(holds "$pc" "$id" "$lines")" + [ -z "$problems" ] && pass "$id" "$scenario" || fail "$id" "$scenario" "on $pc: $(echo "$problems" | head -3 | paste -sd ';')" +} +# CHECK, with step 3's edits instead of what they changed once `edited` is set. +expected() { + local v="$1" file path json edits + edits="$(fixture EDIT "$v")" + while IFS='|' read -r file path json; do + [ -n "$file" ] || continue + if [ -n "${edited:-}" ] && grep -q "^$file|$path|" <<<"$edits"; then continue; fi + echo "$file|$path|$json" + done <<<"$(fixture CHECK "$v")" + [ -z "${edited:-}" ] || echo "$edits" +} +check_all() { + local scenario="$1" pc="$2" id="$3" v problems + v="$(variant "$pc" "$id")" + problems="$(holds "$pc" "$id" "$(expected "$v")")$(absent "$pc" "$id" "$(fixture ABSENT "$v")")" + [ -z "$problems" ] && pass "$id" "$scenario" || fail "$id" "$scenario" "on $pc: $(echo "$problems" | head -3 | paste -sd ';')" +} + +# The assistant's CLI sends the synced key (and the model) to the mock. +cli_check() { + local pc="$1" id="$2" key="$3" model="$4" scenario="${5:-cli}" + : >"$work/mock.log" + on "$pc" "run_$id" || { fail "$id" "$scenario" "on $pc: the CLI's own config check failed"; return; } + [ -n "$key" ] || { pass "$id" "$scenario"; return; } + if grep -F "\"key\":\"$key\"" "$work/mock.log" | grep -qF "\"model\":\"$model\""; then + pass "$id" "$scenario" + else + fail "$id" "$scenario" "on $pc: mock saw $(grep POST "$work/mock.log" | head -1 || echo nothing), wanted key $key model $model" + fi +} +declare -A RUN_KEY=([opencode]=sk-e2e-opencode [claude-code]=sk-e2e-claude [codex]=sk-e2e-codex + [gemini-cli]=sk-e2e-gemini [qwen-code]=sk-e2e-qwen [copilot]= + [crush]=sk-e2e-crush [factory]=sk-e2e-droid) +declare -A RUN_MODEL=([opencode]=m1 [claude-code]=mine-sonnet [codex]=m1 [gemini-cli]=gem-mine + [qwen-code]=m1 [copilot]=gpt-5.4 [crush]=m1 [factory]=m1) + +entry_status() { on "$1" skillpouch settings list "$2" | sed -n "s/^ $3 //p"; } +install_fresh() { on "$1" "mkdir -p \$HOME/${ROOT[$2]} && default_$2"; } +# B's OpenCode is version 2: a link, not the npm package. +uninstall() { + if [ "$1:$2" = sp-e2e-b:opencode ]; then + on "$1" "rm -f /usr/local/bin/opencode; rm -rf \$HOME/${ROOT[$2]}" + else + on "$1" "npm uninstall -g ${PKG[$2]%@*} >/dev/null 2>&1; rm -rf \$HOME/${ROOT[$2]}" + fi +} +reinstall() { + if [ "$1:$2" = sp-e2e-b:opencode ]; then + on "$1" ln -sf /opt/opencode2/node_modules/.bin/opencode /usr/local/bin/opencode + else + on "$1" "npm install -g --offline --no-fund --no-audit ${PKG[$2]}" >>"$work/npm.log" 2>&1 + fi + install_fresh "$1" "$2" +} + +# --- the scenario ----------------------------------------------------------- +# One block: bash reads all of it before running any, so editing this file +# while it runs can't derail it. +{ +build_cli +start_servers +start_computers +start_browser +read -r email pouch <<<"$(ask account)" +log "account $email, pouch $pouch" + +log "A: assistants with custom providers, then the first sync" +for id in "${IDS[@]}"; do on sp-e2e-a "mkdir -p \$HOME/${ROOT[$id]} && seed_$id"; done +connect sp-e2e-a fill +sync_on sp-e2e-a +log "B: fresh installs, then the first sync" +for id in "${IDS[@]}"; do install_fresh sp-e2e-b "$id"; done +connect sp-e2e-b use_pouch +sync_on sp-e2e-b +sync_on sp-e2e-a +sync_on sp-e2e-b + +log "1-2. settings arrive on B; B's own values survive; the CLIs accept them" +for id in "${IDS[@]}"; do + check_all arrive sp-e2e-b "$id" + problems="$(holds sp-e2e-b "$id" "$(fixture LOCAL "$id")")" + [ -z "$problems" ] || fail "$id" arrive "B's own value lost: $problems" + IFS='|' read -r file text <<<"$(fixture COMMENT "$id")" + if [ -n "$file" ] && ! on sp-e2e-b "grep -qF \"$text\" \$HOME/${ROOT[$id]}/$file"; then + fail "$id" arrive "the comment in $file is gone" + fi +done +leaks="$(on sp-e2e-a "grep -rlE 'sk-ant-oat01-e2e-credential|ya29.e2e-credential|sk-e2e-codex-auth-json|e2e-tool|opencode-e2e-plugin|echo stop' \$HOME/.skillpouch/pouches/*/settings" || true)" +[ -z "$leaks" ] || for id in "${IDS[@]}"; do fail "$id" arrive "credentials or integrations in the pouch: $leaks"; done +for id in "${IDS[@]}"; do cli_check sp-e2e-b "$id" "${RUN_KEY[$id]}" "${RUN_MODEL[$id]}"; done + +log "3. an edit on B reaches A" +for id in "${IDS[@]}"; do + while IFS='|' read -r file path json; do + on sp-e2e-b python3 /e2e/probe.py set "\$HOME/${ROOT[$id]}/$file" "'$path'" "'$json'" + done <<<"$(fixture EDIT "$(variant sp-e2e-b "$id")")" +done +sync_on sp-e2e-b +sync_on sp-e2e-a +for id in "${IDS[@]}"; do + check edit sp-e2e-a "$id" "$(fixture EDIT "$(variant sp-e2e-a "$id")")" + IFS='|' read -r file text <<<"$(fixture COMMENT "$id")" + text="${text/B/A}" + if [ -n "$file" ] && [ "$id" != qwen-code ] && ! on sp-e2e-a "grep -qF \"$text\" \$HOME/${ROOT[$id]}/$file"; then + fail "$id" edit "A's comment in $file is gone" + fi +done + +edited=1 + +log "4. a deletion on B: excluded there, kept in the pouch and on A" +for id in "${IDS[@]}"; do + IFS='|' read -r file path entry <<<"$(fixture GONE "$(variant sp-e2e-b "$id")")" + on sp-e2e-b python3 /e2e/probe.py del "\$HOME/${ROOT[$id]}/$file" "'$path'" +done +sync_on sp-e2e-b +sync_on sp-e2e-a +sync_on sp-e2e-b +for id in "${IDS[@]}"; do + IFS='|' read -r file path entry <<<"$(fixture GONE "$(variant sp-e2e-b "$id")")" + on sp-e2e-b python3 /e2e/probe.py get "\$HOME/${ROOT[$id]}/$file" "'$path'" >/dev/null && + fail "$id" delete "B got $file:$path back" + IFS='|' read -r afile apath _ <<<"$(fixture GONE "$(variant sp-e2e-a "$id")")" + on sp-e2e-a python3 /e2e/probe.py get "\$HOME/${ROOT[$id]}/$afile" "'$apath'" >/dev/null || + fail "$id" delete "A lost $afile:$apath" + status="$(entry_status sp-e2e-b "$id" "$entry")" + [ "$status" = "excluded on this computer" ] || fail "$id" delete "B lists $entry as '$status'" + pass "$id" delete +done + +log "4b. skillpouch settings include brings it back" +for id in "${IDS[@]}"; do + IFS='|' read -r file path entry <<<"$(fixture GONE "$(variant sp-e2e-b "$id")")" + on sp-e2e-b skillpouch settings include "$id" "$entry" >>"$work/include.log" 2>&1 || + fail "$id" include "skillpouch settings include failed: $(tail -1 "$work/include.log")" +done +sync_on sp-e2e-b +for id in "${IDS[@]}"; do + IFS='|' read -r file path entry <<<"$(fixture GONE "$(variant sp-e2e-b "$id")")" + on sp-e2e-b python3 /e2e/probe.py get "\$HOME/${ROOT[$id]}/$file" "'$path'" >/dev/null || + fail "$id" include "$file:$path is still missing" + status="$(entry_status sp-e2e-b "$id" "$entry")" + [ "$status" = synced ] || [ "$status" = translated ] || fail "$id" include "B lists $entry as '$status'" + pass "$id" include +done + +log "5. folder removed (and the entry excluded first), sync, reinstalled, sync" +for id in "${IDS[@]}"; do + IFS='|' read -r file path entry <<<"$(fixture GONE "$(variant sp-e2e-b "$id")")" + on sp-e2e-b skillpouch settings exclude "$id" "$entry" >>"$work/include.log" 2>&1 + uninstall sp-e2e-b "$id" +done +sync_on sp-e2e-b +for id in "${IDS[@]}"; do reinstall sp-e2e-b "$id"; done +sync_on sp-e2e-b +for id in "${IDS[@]}"; do + check_all folder sp-e2e-b "$id" +done + +log "6. uninstalled and installed again between two syncs" +for id in "${IDS[@]}"; do + uninstall sp-e2e-b "$id" + reinstall sp-e2e-b "$id" +done +sync_on sp-e2e-b +for id in "${IDS[@]}"; do + check_all reinstall sp-e2e-b "$id" +done + +log "7. C: set up with no assistant, assistants installed afterwards" +connect sp-e2e-c use_pouch +sync_on sp-e2e-c +for id in "${IDS[@]}"; do install_fresh sp-e2e-c "$id"; done +sync_on sp-e2e-c +for id in "${IDS[@]}"; do + v="$(variant sp-e2e-c "$id")" + problems="$(holds sp-e2e-c "$id" "$(fixture EDIT "$v")")$(absent sp-e2e-c "$id" "$(fixture ABSENT "$v")")" + IFS='|' read -r file path _ <<<"$(fixture GONE "$v")" + on sp-e2e-c python3 /e2e/probe.py get "\$HOME/${ROOT[$id]}/$file" "'$path'" >/dev/null || + problems+="$file:$path missing" + [ -z "$problems" ] && pass "$id" late || fail "$id" late "on C: $problems" +done + +log "8. select --off settings, and --off across a reinstall" +on sp-e2e-b skillpouch select --off settings >/dev/null +on sp-e2e-a python3 /e2e/probe.py set "\$HOME/.claude/settings.json" model "'\"off-test\"'" +on sp-e2e-a python3 /e2e/probe.py set "\$HOME/.codex/config.toml" model "'\"off-test\"'" +sync_on sp-e2e-a +sync_on sp-e2e-b +on sp-e2e-b python3 /e2e/probe.py eq "\$HOME/.claude/settings.json" model "'\"mine-opus\"'" && + on sp-e2e-b python3 /e2e/probe.py eq "\$HOME/.codex/config.toml" model "'\"m1\"'" || + { fail claude-code select "settings arrived with settings off"; fail codex select "settings arrived with settings off"; } +on sp-e2e-b skillpouch select --on settings >/dev/null +on sp-e2e-b skillpouch select --off claude-code >/dev/null +uninstall sp-e2e-b claude-code +reinstall sp-e2e-b claude-code +sync_on sp-e2e-b +on sp-e2e-b python3 /e2e/probe.py get "\$HOME/.claude/settings.json" model >/dev/null && + fail claude-code select "settings arrived for an assistant turned off" +on sp-e2e-b skillpouch select | grep -iE 'claude' >"$work/select.txt" || true +grep -qiE 'off|skip|✗|no' "$work/select.txt" || fail claude-code select "select no longer lists Claude Code as off: $(cat "$work/select.txt")" +on sp-e2e-b python3 /e2e/probe.py eq "\$HOME/.codex/config.toml" model "'\"off-test\"'" || + fail codex select "--on settings didn't bring A's edit" +on sp-e2e-b skillpouch select --on claude-code >/dev/null +sync_on sp-e2e-b +check select sp-e2e-b claude-code 'settings.json|model|"off-test"' +for id in "${IDS[@]}"; do pass "$id" select; done + +log "9. OpenCode 1 on A, OpenCode 2 on B: a provider edit on A" +on sp-e2e-a python3 /e2e/probe.py set "\$HOME/.config/opencode/opencode.json" provider.mine.options.apiKey "'\"sk-e2e-opencode-v1-edit\"'" +sync_on sp-e2e-a +sync_on sp-e2e-b +sync_on sp-e2e-c +check translate sp-e2e-b opencode 'opencode.json|providers.mine.settings.apiKey|"sk-e2e-opencode-v1-edit"' +cli_check sp-e2e-b opencode sk-e2e-opencode-v1-edit m2 translate +cli_check sp-e2e-a opencode sk-e2e-opencode-v1-edit m2 translate +cli_check sp-e2e-c opencode sk-e2e-opencode-v1-edit m2 translate + +log "10. the background sync on A notices settings edits by itself" +declare -A DAEMON_A=([opencode]='tui.json|theme|"gruvbox"' [claude-code]='settings.json|effortLevel|"high"' + [codex]='config.toml|model_verbosity|"low"' [gemini-cli]='.env|GEMINI_API_KEY|"sk-e2e-gemini-daemon"' + [qwen-code]='settings.json|general.vimMode|true' [copilot]='settings.json|theme|"high-contrast"' + [crush]='crush.json|options.tui.compact_mode|false' [factory]='settings.json|reasoningEffort|"medium"') +declare -A DAEMON_B=([opencode]='cli.json|theme.name|"gruvbox"') +on sp-e2e-a skillpouch daemon start >/dev/null 2>&1 +sleep 5 +# First the files only settings sync reads (nothing else would wake the +# background sync), then the rest. +for round in "opencode gemini-cli copilot" "claude-code codex qwen-code crush factory"; do + for id in $round; do + IFS='|' read -r file path json <<<"${DAEMON_A[$id]}" + on sp-e2e-a python3 /e2e/probe.py set "\$HOME/${ROOT[$id]}/$file" "'$path'" "'$json'" + done + for _ in $(seq 20); do + sleep 3 + sync_on sp-e2e-b + waiting=0 + for id in $round; do + [ -z "$(holds sp-e2e-b "$id" "${DAEMON_B[$id]:-${DAEMON_A[$id]}}")" ] || waiting=1 + done + [ "$waiting" = 0 ] && break + done +done +for id in "${IDS[@]}"; do check daemon sp-e2e-b "$id" "${DAEMON_B[$id]:-${DAEMON_A[$id]}}"; done +on sp-e2e-a skillpouch daemon stop >/dev/null 2>&1 || true +SCENARIOS+=(daemon) + +log "web: the pouch lists the Settings items, shares blank the secrets" +items="$(ask items)" +for id in "${IDS[@]}"; do + [[ ",$items," == *",$id,"* ]] || fail "$id" web "the web app lists: $items" + if [ -n "${SECRET[$id]}" ]; then + answer="$(ask share "$id" "${SECRET[$id]}")" + [[ "$answer" == ok* ]] || fail "$id" web "share link: $answer" + fi + pass "$id" web +done +SCENARIOS+=(web) + +# --- the table -------------------------------------------------------------- + +printf '\n%-12s' assistant +for s in "${SCENARIOS[@]}"; do printf ' %-9s' "$s"; done +echo +failed=0 +for id in "${IDS[@]}"; do + printf '%-12s' "$id" + for s in "${SCENARIOS[@]}"; do + r="${RESULT[$id|$s]:--}" + [ "$r" = FAIL ] && failed=1 + printf ' %-9s' "$r" + done + echo +done +[ "$failed" = 0 ] || { echo; cat "$work/failures.log"; exit 1; } +exit 0 +} diff --git a/scripts/e2e-settings/Dockerfile b/scripts/e2e-settings/Dockerfile new file mode 100644 index 0000000..e9a5a49 --- /dev/null +++ b/scripts/e2e-settings/Dockerfile @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# One "computer" for scripts/e2e-settings.sh: every assistant with settings +# sync, installed for real at the versions the test was written against. +# OpenCode 2 lives beside OpenCode 1 in /opt/opencode2; a computer that should +# run it links it over `opencode`. npm's cache stays, so an assistant can be +# uninstalled and installed again offline. +FROM node:22-bookworm + +RUN npm install -g --no-fund --no-audit \ + @anthropic-ai/claude-code@2.1.296 \ + @charmland/crush@0.98.1 \ + @github/copilot@1.0.95 \ + @google/gemini-cli@0.63.0 \ + @openai/codex@0.162.1 \ + @qwen-code/qwen-code@0.25.0 \ + opencode-ai@1.19.0 \ + droid@0.238.0 \ + && npm install --no-fund --no-audit --prefix /opt/opencode2 @opencode/cli@2.0.26 \ + && ln -s /opt/opencode2/node_modules/.bin/opencode /usr/local/bin/opencode2 diff --git a/scripts/e2e-settings/harnesses.sh b/scripts/e2e-settings/harnesses.sh new file mode 100644 index 0000000..cdea5c5 --- /dev/null +++ b/scripts/e2e-settings/harnesses.sh @@ -0,0 +1,370 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Per-assistant fixtures for scripts/e2e-settings.sh (sourced; $M is the mock +# provider's base URL). For each assistant id: +# +# ROOT[id] its folder, relative to $HOME +# PKG[id] npm package (uninstalled and installed again offline) +# seed_ computer A's config: a custom provider, base URL, key and +# model, a UI preference, and things that are never settings +# default_ a fresh install's config: a comment and a key that isn't +# synced, both of which must survive +# CHECK[id] entries that must arrive: `file|path|json` lines +# ABSENT[id] what must never arrive: `file|path` lines +# LOCAL[id] the fresh install's own values that must survive +# COMMENT[id] `file|text` comment that must survive, if the format has them +# EDIT[id] an edit made on B: `file|path|json` lines +# GONE[id] an entry deleted on B: `file|path|entry` +# SECRET[id] a value share links must blank +# run_ the real CLI using the synced settings: fails when its own +# config check does; its call to the mock provider fails by +# design and is judged from the mock's log +# opencode has v1 (A, C) and v2 (B) variants: CHECK[opencode@2] and so on. + +IDS=(opencode claude-code codex gemini-cli qwen-code copilot crush factory) +declare -A ROOT PKG CHECK ABSENT LOCAL COMMENT EDIT GONE SECRET + +ROOT[opencode]=.config/opencode +ROOT[claude-code]=.claude +ROOT[codex]=.codex +ROOT[gemini-cli]=.gemini +ROOT[qwen-code]=.qwen +ROOT[copilot]=.copilot +ROOT[crush]=.config/crush +ROOT[factory]=.factory + +PKG[opencode]=opencode-ai@1.19.0 +PKG[claude-code]=@anthropic-ai/claude-code@2.1.296 +PKG[codex]=@openai/codex@0.162.1 +PKG[gemini-cli]=@google/gemini-cli@0.63.0 +PKG[qwen-code]=@qwen-code/qwen-code@0.25.0 +PKG[copilot]=@github/copilot@1.0.95 +PKG[crush]=@charmland/crush@0.98.1 +PKG[factory]=droid@0.238.0 + +# --- OpenCode ---------------------------------------------------------------- +seed_opencode() { + cat >"$HOME/.config/opencode/opencode.json" <"$HOME/.config/opencode/tui.json" <"$HOME/.config/opencode/opencode.json" +} +CHECK[opencode@1]="opencode.json|provider.mine.options.baseURL|\"$M/v1\" +opencode.json|provider.mine.options.apiKey|\"sk-e2e-opencode\" +opencode.json|provider.mine.npm|\"@ai-sdk/openai-compatible\" +opencode.json|model|\"mine/m1\" +tui.json|theme|\"tokyonight\"" +CHECK[opencode@2]="opencode.json|providers.mine.settings.baseURL|\"$M/v1\" +opencode.json|providers.mine.settings.apiKey|\"sk-e2e-opencode\" +opencode.json|model|\"mine/m1\" +cli.json|theme.name|\"tokyonight\"" +ABSENT[opencode@1]="opencode.json|mcp +opencode.json|plugin +tui.json|plugin" +ABSENT[opencode@2]="opencode.json|mcp +opencode.json|plugin +opencode.json|plugins +cli.json|plugins +tui.json|plugin" +LOCAL[opencode]="opencode.json|snapshots|false" +COMMENT[opencode]="opencode.json|// B's OpenCode" +EDIT[opencode@2]="opencode.json|model|\"mine/m2\" +opencode.json|providers.mine.settings.baseURL|\"$M/v2\"" +EDIT[opencode@1]="opencode.json|model|\"mine/m2\" +opencode.json|provider.mine.options.baseURL|\"$M/v2\"" +GONE[opencode@2]="cli.json|theme|cli.json:theme" +GONE[opencode@1]="tui.json|theme|tui.json:theme" +SECRET[opencode]=sk-e2e-opencode +run_opencode() { + if opencode --version | grep -q 'v2\.'; then + timeout 120 opencode run --standalone hi >/dev/null 2>&1 || true + else + timeout 120 opencode run hi >/dev/null 2>&1 || true + fi +} + +# --- Claude Code ------------------------------------------------------------- +seed_claude-code() { + cat >"$HOME/.claude/settings.json" <"$HOME/.claude/.credentials.json" +} +default_claude-code() { + printf '{\n "autoMemoryDirectory": "/root/b-memory"\n}\n' >"$HOME/.claude/settings.json" +} +CHECK[claude-code]="settings.json|env.ANTHROPIC_BASE_URL|\"$M\" +settings.json|env.ANTHROPIC_AUTH_TOKEN|\"sk-e2e-claude\" +settings.json|model|\"mine-sonnet\" +settings.json|theme|\"dark-daltonized\"" +ABSENT[claude-code]="settings.json|hooks +settings.json|enabledPlugins +.credentials.json|claudeAiOauth" +LOCAL[claude-code]="settings.json|autoMemoryDirectory|\"/root/b-memory\"" +EDIT[claude-code]="settings.json|model|\"mine-opus\"" +GONE[claude-code]="settings.json|theme|settings.json:theme" +SECRET[claude-code]=sk-e2e-claude +run_claude-code() { timeout 90 claude -p hi >/dev/null 2>&1 || true; } + +# --- Codex ------------------------------------------------------------------- +seed_codex() { + cat >"$HOME/.codex/config.toml" <"$HOME/.codex/auth.json" +} +default_codex() { + printf '# B'"'"'s Codex\n[projects."/srv"]\ntrust_level = "trusted"\n' >"$HOME/.codex/config.toml" +} +CHECK[codex]="config.toml|model|\"m1\" +config.toml|model_provider|\"mine\" +config.toml|model_reasoning_effort|\"high\" +config.toml|model_providers.mine.base_url|\"$M/v1\" +config.toml|model_providers.mine.experimental_bearer_token|\"sk-e2e-codex\" +config.toml|tui.notifications|true" +ABSENT[codex]="config.toml|mcp_servers +auth.json|OPENAI_API_KEY" +LOCAL[codex]="config.toml|projects|{\"/srv\": {\"trust_level\": \"trusted\"}}" +COMMENT[codex]="config.toml|# B's Codex" +EDIT[codex]="config.toml|model_reasoning_effort|\"low\"" +GONE[codex]="config.toml|tui|config.toml:tui" +SECRET[codex]=sk-e2e-codex +run_codex() { + timeout 60 codex doctor 2>&1 | grep -E 'config.toml parse +ok' >/dev/null || return 1 + timeout 90 codex exec --skip-git-repo-check hi >/dev/null 2>&1 || true +} + +# --- Gemini CLI -------------------------------------------------------------- +seed_gemini-cli() { + cat >"$HOME/.gemini/settings.json" <"$HOME/.gemini/.env" + echo '{"access_token":"ya29.e2e-credential"}' >"$HOME/.gemini/oauth_creds.json" +} +default_gemini-cli() { + # A fresh install's own theme: the pouch's wins at the first sync. + printf '{\n // B'"'"'s Gemini\n "hasSeenIdeIntegrationNudge": true,\n "ui": { "theme": "Default Light" }\n}\n' >"$HOME/.gemini/settings.json" +} +CHECK[gemini-cli]="settings.json|security.auth.selectedType|\"gemini-api-key\" +settings.json|model.name|\"gem-mine\" +settings.json|ui.theme|\"Dracula\" +.env|GEMINI_API_KEY|\"sk-e2e-gemini\" +.env|GOOGLE_GEMINI_BASE_URL|\"$M\"" +ABSENT[gemini-cli]="settings.json|security.folderTrust +settings.json|mcpServers +oauth_creds.json|access_token" +LOCAL[gemini-cli]="settings.json|hasSeenIdeIntegrationNudge|true" +COMMENT[gemini-cli]="settings.json|// B's Gemini" +EDIT[gemini-cli]="settings.json|model.name|\"gem-mine-2\"" +GONE[gemini-cli]="settings.json|ui|settings.json:ui" +SECRET[gemini-cli]=sk-e2e-gemini +run_gemini-cli() { timeout 120 gemini -p hi --skip-trust >/dev/null 2>&1 || true; } + +# --- Qwen Code --------------------------------------------------------------- +seed_qwen-code() { + cat >"$HOME/.qwen/settings.json" <"$HOME/.qwen/settings.json" +} +CHECK[qwen-code]="settings.json|modelProviders.openai.0.baseUrl|\"$M/v1\" +settings.json|env.MINE_QWEN_KEY|\"sk-e2e-qwen\" +settings.json|security.auth.selectedType|\"openai\" +settings.json|model.name|\"m1\" +settings.json|ui.theme|\"GitHub\"" +ABSENT[qwen-code]="settings.json|security.folderTrust +settings.json|mcpServers" +LOCAL[qwen-code]="settings.json|ide.enabled|false" +COMMENT[qwen-code]="settings.json|// B's Qwen" +EDIT[qwen-code]="settings.json|model.name|\"m2\"" +GONE[qwen-code]="settings.json|ui|settings.json:ui" +SECRET[qwen-code]=sk-e2e-qwen +run_qwen-code() { timeout 120 qwen -p hi >/dev/null 2>&1 || true; } + +# --- Copilot CLI ------------------------------------------------------------- +# Copilot's own provider (BYOK) is only environment variables; its settings +# hold the model and the UI. Themes it doesn't know are dropped silently. +seed_copilot() { + cat >"$HOME/.copilot/settings.json" <"$HOME/.copilot/settings.json" +} +CHECK[copilot]="settings.json|model|\"gpt-5.4\" +settings.json|theme|\"dim\" +settings.json|banner|\"never\"" +ABSENT[copilot]="settings.json|disableAllHooks" +LOCAL[copilot]="settings.json|trustedFolders|[\"/srv\"]" +EDIT[copilot]="settings.json|model|\"gpt-5.5\"" +GONE[copilot]="settings.json|banner|settings.json:banner" +SECRET[copilot]= +# BYOK ignores the model setting (it needs COPILOT_MODEL), so the check is +# Copilot's own reading of settings.json. +run_copilot() { + timeout 60 copilot config --list >/tmp/copilot-config 2>&1 && + grep -qx 'model=gpt-5.4' /tmp/copilot-config && grep -qx 'theme=dim' /tmp/copilot-config +} + +# --- Crush ------------------------------------------------------------------- +# crush.json is plain JSON: no comment to keep. +seed_crush() { + cat >"$HOME/.config/crush/crush.json" <"$HOME/.config/crush/crush.json" +} +CHECK[crush]="crush.json|providers.mine.base_url|\"$M/v1\" +crush.json|providers.mine.api_key|\"sk-e2e-crush\" +crush.json|models.large.model|\"m1\" +crush.json|options.tui.compact_mode|true +crush.json|permissions.allowed_tools|[\"view\"]" +ABSENT[crush]="crush.json|mcp" +LOCAL[crush]="crush.json|lsp|{\"rust\": {\"command\": \"rust-analyzer\"}} +crush.json|options.data_directory|\"/root/b-crush-data\"" +EDIT[crush]="crush.json|models.large.model|\"m2\"" +GONE[crush]="crush.json|permissions|crush.json:permissions" +SECRET[crush]=sk-e2e-crush +run_crush() { + timeout 60 crush models 2>/dev/null | grep -q '^mine/m1$' || return 1 + timeout 90 crush run hi >/dev/null 2>&1 || true +} + +# --- Factory Droid ----------------------------------------------------------- +seed_factory() { + cat >"$HOME/.factory/settings.json" <"$HOME/.factory/settings.json" +} +CHECK[factory]="settings.json|model|\"custom:Mine-M1-0\" +settings.json|reasoningEffort|\"high\" +settings.json|customModels.0.baseUrl|\"$M/v1\" +settings.json|customModels.0.apiKey|\"sk-e2e-droid\" +settings.json|sessionDefaultSettings.autonomyLevel|\"medium\"" +ABSENT[factory]="settings.json|enabledPlugins" +LOCAL[factory]="settings.json|includeCoAuthoredByDroid|false" +COMMENT[factory]="settings.json|// B's Droid" +EDIT[factory]="settings.json|reasoningEffort|\"low\"" +GONE[factory]="settings.json|sessionDefaultSettings|settings.json:sessionDefaultSettings" +SECRET[factory]=sk-e2e-droid +run_factory() { + timeout 60 droid doctor 2>&1 | grep -q 'No configuration issues found' || return 1 + timeout 90 droid exec hi >/dev/null 2>&1 || true +} diff --git a/scripts/e2e-settings/mock-llm.mjs b/scripts/e2e-settings/mock-llm.mjs new file mode 100755 index 0000000..85ced22 --- /dev/null +++ b/scripts/e2e-settings/mock-llm.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: AGPL-3.0-only +// A stand-in model provider for scripts/e2e-settings.sh. It answers every +// request with an error, so the assistant stops at once, and writes one JSON +// line per request: what the assistant made of its synced settings (the base +// URL it called, the key it sent, the model it asked for). +// +// node mock-llm.mjs +import { appendFileSync } from 'node:fs'; +import { createServer } from 'node:http'; + +const [port, log] = process.argv.slice(2); + +createServer((request, response) => { + let body = ''; + request.on('data', (chunk) => (body += chunk)); + request.on('end', () => { + let model = null; + try { + model = JSON.parse(body).model ?? null; + } catch {} + // Gemini puts the model in the path: /v1beta/models/:generateContent + model ??= request.url.match(/\/models\/([^/:?]+)/)?.[1] ?? null; + const url = new URL(request.url, 'http://mock'); + const key = + request.headers['x-api-key'] ?? + request.headers['x-goog-api-key'] ?? + url.searchParams.get('key') ?? + request.headers.authorization?.replace(/^Bearer\s+/i, '') ?? + null; + appendFileSync( + log, + `${JSON.stringify({ method: request.method, path: url.pathname, key, model })}\n`, + ); + response.writeHead(400, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + error: { type: 'invalid_request_error', code: 400, message: 'mock provider' }, + }), + ); + }); +}).listen(Number(port), '127.0.0.1'); diff --git a/scripts/e2e-settings/probe.py b/scripts/e2e-settings/probe.py new file mode 100755 index 0000000..b647511 --- /dev/null +++ b/scripts/e2e-settings/probe.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# Reads and edits assistant config files inside a test computer for +# scripts/e2e-settings.sh. Paths are dotted (`model_providers.mine.base_url`); +# files are JSON with comments, TOML (`.toml`) or dotenv (`.env`). +# +# probe.py get print the value as JSON (exit 1: absent) +# probe.py eq exit 0 when the value equals +# probe.py set set a value (rewrites JSON/TOML files) +# probe.py del remove a value +import json +import re +import sys +import tomllib + + +def strip_jsonc(text): + out, i, string = [], 0, False + while i < len(text): + c = text[i] + if string: + out.append(c) + if c == '\\': + out.append(text[i + 1]) + i += 1 + elif c == '"': + string = False + elif c == '"': + string = True + out.append(c) + elif text.startswith('//', i): + while i < len(text) and text[i] != '\n': + i += 1 + continue + elif text.startswith('/*', i): + i = text.index('*/', i) + 2 + continue + else: + out.append(c) + i += 1 + return re.sub(r',(\s*[}\]])', r'\1', ''.join(out)) + + +def env_load(text): + values = {} + for line in text.splitlines(): + line = line.strip() + if line.startswith('export '): + line = line[7:] + if '=' in line and not line.startswith('#'): + key, value = line.split('=', 1) + value = value.strip() + if value[:1] in '"\'' and value: + value = value[1:value.index(value[0], 1)] + values[key.strip()] = value + return values + + +def load(path): + try: + text = open(path).read() + except FileNotFoundError: + return {} + if path.endswith('.toml'): + return tomllib.loads(text) + if path.endswith('.env'): + return env_load(text) + return json.loads(strip_jsonc(text)) if text.strip() else {} + + +def toml_dump(data, prefix=''): + def value(v): + if isinstance(v, bool): + return 'true' if v else 'false' + if isinstance(v, (int, float)): + return str(v) + if isinstance(v, str): + return json.dumps(v) + if isinstance(v, list): + return '[' + ', '.join(value(x) for x in v) + ']' + return '{ ' + ', '.join(f'{json.dumps(k)} = {value(x)}' for k, x in v.items()) + ' }' + + lines = [f'{json.dumps(k) if not re.match(r"^[A-Za-z0-9_-]+$", k) else k} = {value(v)}' + for k, v in data.items() if not isinstance(v, dict)] + for k, v in data.items(): + if isinstance(v, dict): + name = k if re.match(r'^[A-Za-z0-9_-]+$', k) else json.dumps(k) + lines.append(f'\n[{prefix}{name}]') + lines.append(toml_dump(v, f'{prefix}{name}.')) + return '\n'.join(line for line in lines if line != '') + + +def save(path, data): + if path.endswith('.toml'): + text = toml_dump(data) + '\n' + elif path.endswith('.env'): + text = ''.join(f'{k}={v}\n' for k, v in data.items()) + else: + text = json.dumps(data, indent=2) + '\n' + open(path, 'w').write(text) + + +def walk(data, path): + parts = path.split('.') + for part in parts[:-1]: + data = data[int(part)] if isinstance(data, list) else data[part] + return data, parts[-1] + + +command, file, path = sys.argv[1:4] +data = load(file) +try: + parent, last = walk(data, path) + present = last in parent if isinstance(parent, dict) else int(last) < len(parent) +except (KeyError, IndexError, TypeError): + parent, last, present = None, None, False + +if command == 'get': + if not present: + sys.exit(1) + print(json.dumps(parent[last], sort_keys=True)) +elif command == 'eq': + sys.exit(0 if present and parent[last] == json.loads(sys.argv[4]) else 1) +elif command == 'set': + node = data + parts = path.split('.') + for part in parts[:-1]: + node = node.setdefault(part, {}) + node[parts[-1]] = json.loads(sys.argv[4]) + save(file, data) +elif command == 'del': + if present: + del parent[last] + save(file, data) +else: + sys.exit(f'unknown command {command}') diff --git a/scripts/e2e-settings/web.mjs b/scripts/e2e-settings/web.mjs new file mode 100755 index 0000000..e6e9349 --- /dev/null +++ b/scripts/e2e-settings/web.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: AGPL-3.0-only +// The browser half of scripts/e2e-settings.sh: one signed-in Chromium page in +// the web app, driven one command per line on stdin, answering one line on +// stdout. It stays open while a computer logs in, because the browser seals +// the account keys to the new device. +// +// account a new account; answers ` ` +// token a one-time `skillpouch login` token +// connected waits until the computer is connected +// items the Settings items the pouch lists, comma separated +// share shares the Settings item , opens the link in a +// fresh browser; answers `ok ` when none +// of the secrets show, else `leak ` +// quit +// +// Needs the web app's dependencies: SKILLPOUCH_WEB_DIR (apps/web of the +// skillpouch repository) and its Playwright Chromium. +import { randomUUID } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline'; + +const webDir = process.env.SKILLPOUCH_WEB_DIR; +const origin = process.env.SKILLPOUCH_WEB_ORIGIN; +if (!webDir || !origin) throw new Error('set SKILLPOUCH_WEB_DIR and SKILLPOUCH_WEB_ORIGIN'); +const { chromium } = createRequire(join(webDir, 'package.json'))('@playwright/test'); + +const PASSWORD = 'correct horse battery staple 42'; +const browser = await chromium.launch( + process.env.PLAYWRIGHT_CHROMIUM_PATH + ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH } + : {}, +); +const page = await (await browser.newContext({ baseURL: origin })).newPage(); +page.setDefaultTimeout(30_000); +let pouchId = ''; + +async function account() { + const email = `settings-${randomUUID().slice(0, 8)}@skillpouch.test`; + await page.goto('/sign-in'); + const form = page.getByRole('form', { name: 'Development sign-in' }); + await form.getByLabel('Email').fill(email); + await form.getByRole('button', { name: 'Sign in' }).click(); + await page.getByLabel('Master password').fill(PASSWORD); + await page.getByLabel('Confirm password').fill(PASSWORD); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('checkbox', { name: 'I saved my Recovery Key' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Finish setup' }).click(); + await page.waitForURL(/\/app\/?$/, { timeout: 60_000 }); + await page.getByRole('link', { name: /Personal/ }).first().click(); + await page.waitForURL(/\/app\/p\/[0-9a-f-]+\/?$/); + pouchId = page.url().match(/\/app\/p\/([0-9a-f-]+)/)[1]; + return `${email} ${pouchId}`; +} + +async function token() { + await page.goto('/app/account/devices'); + await page.getByRole('button', { name: 'Connect CLI' }).first().click(); + const command = page.getByTestId('connect-command'); + await command.waitFor(); + return (await command.getAttribute('data-command')).match(/sp_lt_[A-Za-z0-9_-]{32}/)[0]; +} + +async function connected(name) { + await page.getByTestId('connected-device').filter({ hasText: name }).waitFor({ timeout: 120_000 }); + await page.keyboard.press('Escape'); + return 'ok'; +} + +async function openSettings() { + await page.goto(`/app/p/${pouchId}`); + await page.getByRole('navigation').getByRole('link', { name: /^Settings/ }).first().click(); + await page.getByRole('heading', { name: 'Settings' }).first().waitFor(); +} + +async function items() { + await openSettings(); + await page.getByRole('row').nth(1).waitFor(); + const names = await page + .getByRole('button', { name: /^Actions for / }) + .evaluateAll((buttons) => + buttons.map((b) => b.getAttribute('aria-label').replace(/^Actions for /, '')), + ); + return names.sort().join(','); +} + +async function share(name, secrets) { + await openSettings(); + await page.getByRole('button', { name: `Actions for ${name}` }).click(); + await page.getByRole('menuitem', { name: 'Share' }).click(); + const dialog = page.getByRole('dialog', { name: `Share ${name}` }); + await dialog.getByRole('button', { name: 'Create link' }).click(); + const url = await dialog.getByLabel('Link').inputValue(); + await dialog.getByRole('button', { name: 'Done' }).click(); + const stranger = await (await browser.newContext()).newPage(); + await stranger.goto(url); + await stranger.getByRole('heading', { name }).waitFor(); + await stranger.getByText('"harness"').first().waitFor(); + const text = await stranger.locator('body').innerText(); + await stranger.context().close(); + const leak = secrets.split(',').find((secret) => secret && text.includes(secret)); + return leak ? `leak ${leak}` : `ok ${text.length}`; +} + +for await (const line of createInterface({ input: process.stdin })) { + const [command, ...args] = line.trim().split(/\s+/); + try { + const answer = + command === 'account' + ? await account() + : command === 'token' + ? await token() + : command === 'connected' + ? await connected(args[0]) + : command === 'items' + ? await items() + : command === 'share' + ? await share(args[0], args[1] ?? '') + : command === 'quit' + ? null + : `error unknown command ${command}`; + if (answer === null) break; + process.stdout.write(`${answer}\n`); + } catch (error) { + process.stdout.write(`error ${String(error.message ?? error).split('\n')[0]}\n`); + } +} +await browser.close();