diff --git a/Cargo.lock b/Cargo.lock index 9230cda0..d8635375 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,137 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.3", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.3", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.3", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -316,6 +447,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bollard" version = "0.18.1" @@ -525,6 +678,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.55" @@ -642,6 +804,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -1133,6 +1304,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1160,6 +1358,27 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.2.0" @@ -1292,6 +1511,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -1828,6 +2060,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -1911,6 +2144,8 @@ dependencies = [ "rust_decimal", "rust_decimal_macros", "secrecy", + "secret-service", + "security-framework", "serde", "serde_json", "sha2", @@ -1930,6 +2165,7 @@ dependencies = [ "wasmparser 0.220.1", "wasmtime", "wasmtime-wasi", + "zbus", ] [[package]] @@ -2165,6 +2401,15 @@ dependencies = [ "rustix 1.1.3", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -2183,6 +2428,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2192,12 +2450,76 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2279,6 +2601,22 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2386,12 +2724,37 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + [[package]] name = "polyval" version = "0.6.2" @@ -3172,6 +3535,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.5", + "serde", + "sha2", + "zbus", +] + [[package]] name = "security-framework" version = "3.5.1" @@ -3322,6 +3704,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3760,6 +4153,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -4065,6 +4459,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -5182,6 +5587,16 @@ dependencies = [ "rustix 1.1.3", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yansi" version = "1.0.1" @@ -5211,6 +5626,69 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tokio", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.37" @@ -5324,3 +5802,40 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] diff --git a/Cargo.toml b/Cargo.toml index 9f8f6e50..2dc13f28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,6 +98,15 @@ hyper-util = { version = "0.1", features = ["server", "tokio", "http1", "http2"] http-body-util = "0.1" bytes = "1" +# macOS keychain +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3" + +# Linux secret-service (GNOME Keyring, KWallet) +[target.'cfg(target_os = "linux")'.dependencies] +secret-service = { version = "4", features = ["rt-tokio-crypto-rust"] } +zbus = "4" + [dev-dependencies] tokio-test = "0.4" testcontainers-modules = { version = "0.11", features = ["postgres"] } diff --git a/src/cli/config.rs b/src/cli/config.rs new file mode 100644 index 00000000..080cccf1 --- /dev/null +++ b/src/cli/config.rs @@ -0,0 +1,195 @@ +//! Configuration management CLI commands. +//! +//! Commands for viewing and modifying settings. + +use clap::Subcommand; + +use crate::settings::Settings; + +#[derive(Subcommand, Debug, Clone)] +pub enum ConfigCommand { + /// List all settings and their current values + List { + /// Show only settings matching this prefix (e.g., "agent", "heartbeat") + #[arg(short, long)] + filter: Option, + }, + + /// Get a specific setting value + Get { + /// Setting path (e.g., "agent.max_parallel_jobs") + path: String, + }, + + /// Set a setting value + Set { + /// Setting path (e.g., "agent.max_parallel_jobs") + path: String, + + /// Value to set + value: String, + }, + + /// Reset a setting to its default value + Reset { + /// Setting path (e.g., "agent.max_parallel_jobs") + path: String, + }, + + /// Show the settings file path + Path, +} + +/// Run a config command. +pub fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> { + match cmd { + ConfigCommand::List { filter } => list_settings(filter), + ConfigCommand::Get { path } => get_setting(&path), + ConfigCommand::Set { path, value } => set_setting(&path, &value), + ConfigCommand::Reset { path } => reset_setting(&path), + ConfigCommand::Path => show_path(), + } +} + +/// List all settings. +fn list_settings(filter: Option) -> anyhow::Result<()> { + let settings = Settings::load(); + let all = settings.list(); + + // Find the longest key for alignment + let max_key_len = all.iter().map(|(k, _)| k.len()).max().unwrap_or(0); + + println!("Settings:"); + println!(); + + for (key, value) in all { + // Skip if filter is set and doesn't match + if let Some(ref f) = filter { + if !key.starts_with(f) { + continue; + } + } + + // Truncate long values for display + let display_value = if value.len() > 60 { + format!("{}...", &value[..57]) + } else { + value + }; + + println!(" {:width$} {}", key, display_value, width = max_key_len); + } + + Ok(()) +} + +/// Get a specific setting. +fn get_setting(path: &str) -> anyhow::Result<()> { + let settings = Settings::load(); + + match settings.get(path) { + Some(value) => { + println!("{}", value); + Ok(()) + } + None => { + anyhow::bail!("Setting not found: {}", path); + } + } +} + +/// Set a setting value. +fn set_setting(path: &str, value: &str) -> anyhow::Result<()> { + let mut settings = Settings::load(); + + // Try to set the value + settings + .set(path, value) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + // Save to disk + settings.save()?; + + println!("Set {} = {}", path, value); + Ok(()) +} + +/// Reset a setting to default. +fn reset_setting(path: &str) -> anyhow::Result<()> { + let mut settings = Settings::load(); + + // Get the default value for display + let default = Settings::default(); + let default_value = default + .get(path) + .ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?; + + // Reset it + settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?; + + // Save to disk + settings.save()?; + + println!("Reset {} to default: {}", path, default_value); + Ok(()) +} + +/// Show the settings file path. +fn show_path() -> anyhow::Result<()> { + let path = Settings::default_path(); + println!("{}", path.display()); + + if path.exists() { + let metadata = std::fs::metadata(&path)?; + println!(" Size: {} bytes", metadata.len()); + if let Ok(modified) = metadata.modified() { + use std::time::SystemTime; + let duration = SystemTime::now() + .duration_since(modified) + .unwrap_or_default(); + let secs = duration.as_secs(); + if secs < 60 { + println!(" Modified: {} seconds ago", secs); + } else if secs < 3600 { + println!(" Modified: {} minutes ago", secs / 60); + } else if secs < 86400 { + println!(" Modified: {} hours ago", secs / 3600); + } else { + println!(" Modified: {} days ago", secs / 86400); + } + } + } else { + println!(" (does not exist, using defaults)"); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_list_settings() { + // Just verify it doesn't panic + let settings = Settings::default(); + let list = settings.list(); + assert!(!list.is_empty()); + } + + #[test] + fn test_get_set_reset() { + let _dir = tempdir().unwrap(); + + let mut settings = Settings::default(); + + // Set a value + settings.set("agent.name", "testbot").unwrap(); + assert_eq!(settings.agent.name, "testbot"); + + // Reset to default + settings.reset("agent.name").unwrap(); + assert_eq!(settings.agent.name, "ironclaw"); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7fdcb3fe..631a4770 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,11 +3,13 @@ //! Provides subcommands for: //! - Running the agent (`run`) //! - Interactive setup wizard (`setup`) +//! - Managing configuration (`config list`, `config get`, `config set`) //! - Managing WASM tools (`tool install`, `tool list`, `tool remove`) -//! - Managing secrets (`secret set`, `secret list`, `secret remove`) +mod config; mod tool; +pub use config::{ConfigCommand, run_config_command}; pub use tool::{ToolCommand, run_tool_command}; use clap::{Parser, Subcommand}; @@ -63,12 +65,13 @@ pub enum Command { channels_only: bool, }, + /// Manage configuration settings + #[command(subcommand)] + Config(ConfigCommand), + /// Manage WASM tools #[command(subcommand)] Tool(ToolCommand), - // Future: Secret management - // #[command(subcommand)] - // Secret(SecretCommand), } impl Cli { diff --git a/src/config.rs b/src/config.rs index 5038fad8..0ead0078 100644 --- a/src/config.rs +++ b/src/config.rs @@ -131,16 +131,30 @@ pub struct DatabaseConfig { impl DatabaseConfig { fn from_env() -> Result { + let settings = crate::settings::Settings::load(); + + // Priority: env var > settings > error (required) + let url = optional_env("DATABASE_URL")? + .or(settings.database_url.clone()) + .ok_or_else(|| ConfigError::MissingRequired { + key: "database_url".to_string(), + hint: "Run 'ironclaw setup' or set DATABASE_URL environment variable".to_string(), + })?; + + // Priority: env var > settings > default + let pool_size = optional_env("DATABASE_POOL_SIZE")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "DATABASE_POOL_SIZE".to_string(), + message: format!("must be a positive integer: {e}"), + })? + .or(settings.database_pool_size) + .unwrap_or(10); + Ok(Self { - url: SecretString::from(required_env("DATABASE_URL")?), - pool_size: optional_env("DATABASE_POOL_SIZE")? - .map(|s| s.parse()) - .transpose() - .map_err(|e| ConfigError::InvalidValue { - key: "DATABASE_POOL_SIZE".to_string(), - message: format!("must be a positive integer: {e}"), - })? - .unwrap_or(10), + url: SecretString::from(url), + pool_size, }) } @@ -269,10 +283,17 @@ impl Default for EmbeddingsConfig { impl EmbeddingsConfig { fn from_env() -> Result { + let settings = crate::settings::Settings::load(); let openai_api_key = optional_env("OPENAI_API_KEY")?.map(SecretString::from); - let provider = optional_env("EMBEDDING_PROVIDER")?.unwrap_or_else(|| "openai".to_string()); - // Auto-enable if we have an API key + // Priority: env var > settings > default + let provider = optional_env("EMBEDDING_PROVIDER")? + .unwrap_or_else(|| settings.embeddings.provider.clone()); + + let model = + optional_env("EMBEDDING_MODEL")?.unwrap_or_else(|| settings.embeddings.model.clone()); + + // Priority: env var > settings > auto-detect from API key let enabled = optional_env("EMBEDDING_ENABLED")? .map(|s| s.parse()) .transpose() @@ -280,14 +301,16 @@ impl EmbeddingsConfig { key: "EMBEDDING_ENABLED".to_string(), message: format!("must be 'true' or 'false': {e}"), })? - .unwrap_or(openai_api_key.is_some()); + .unwrap_or_else(|| { + // Check settings, or auto-enable if API key present + settings.embeddings.enabled || openai_api_key.is_some() + }); Ok(Self { enabled, provider, openai_api_key, - model: optional_env("EMBEDDING_MODEL")? - .unwrap_or_else(|| "text-embedding-3-small".to_string()), + model, }) } @@ -394,19 +417,57 @@ pub struct AgentConfig { impl AgentConfig { fn from_env() -> Result { + let settings = crate::settings::Settings::load(); + Ok(Self { - name: optional_env("AGENT_NAME")?.unwrap_or_else(|| "ironclaw".to_string()), - max_parallel_jobs: parse_optional_env("AGENT_MAX_PARALLEL_JOBS", 5)?, - job_timeout: Duration::from_secs(parse_optional_env("AGENT_JOB_TIMEOUT_SECS", 3600)?), - stuck_threshold: Duration::from_secs(parse_optional_env( - "AGENT_STUCK_THRESHOLD_SECS", - 300, - )?), - repair_check_interval: Duration::from_secs(parse_optional_env( - "SELF_REPAIR_CHECK_INTERVAL_SECS", - 60, - )?), - max_repair_attempts: parse_optional_env("SELF_REPAIR_MAX_ATTEMPTS", 3)?, + // Priority: env var > settings > default + name: optional_env("AGENT_NAME")?.unwrap_or_else(|| settings.agent.name.clone()), + max_parallel_jobs: optional_env("AGENT_MAX_PARALLEL_JOBS")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "AGENT_MAX_PARALLEL_JOBS".to_string(), + message: format!("must be a positive integer: {e}"), + })? + .unwrap_or(settings.agent.max_parallel_jobs as usize), + job_timeout: Duration::from_secs( + optional_env("AGENT_JOB_TIMEOUT_SECS")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "AGENT_JOB_TIMEOUT_SECS".to_string(), + message: format!("must be a positive integer: {e}"), + })? + .unwrap_or(settings.agent.job_timeout_secs), + ), + stuck_threshold: Duration::from_secs( + optional_env("AGENT_STUCK_THRESHOLD_SECS")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "AGENT_STUCK_THRESHOLD_SECS".to_string(), + message: format!("must be a positive integer: {e}"), + })? + .unwrap_or(settings.agent.stuck_threshold_secs), + ), + repair_check_interval: Duration::from_secs( + optional_env("SELF_REPAIR_CHECK_INTERVAL_SECS")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "SELF_REPAIR_CHECK_INTERVAL_SECS".to_string(), + message: format!("must be a positive integer: {e}"), + })? + .unwrap_or(settings.agent.repair_check_interval_secs), + ), + max_repair_attempts: optional_env("SELF_REPAIR_MAX_ATTEMPTS")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "SELF_REPAIR_MAX_ATTEMPTS".to_string(), + message: format!("must be a positive integer: {e}"), + })? + .unwrap_or(settings.agent.max_repair_attempts), use_planning: optional_env("AGENT_USE_PLANNING")? .map(|s| s.parse()) .transpose() @@ -414,7 +475,7 @@ impl AgentConfig { key: "AGENT_USE_PLANNING".to_string(), message: format!("must be 'true' or 'false': {e}"), })? - .unwrap_or(true), // Default to planning enabled + .unwrap_or(settings.agent.use_planning), }) } } @@ -464,11 +525,13 @@ pub struct WasmConfig { /// Secrets management configuration. #[derive(Clone, Default)] pub struct SecretsConfig { - /// Master key for encrypting secrets (loaded from SECRETS_MASTER_KEY env var). - /// Must be at least 32 bytes for AES-256-GCM. + /// Master key for encrypting secrets. + /// Source determined by KeySource in settings. pub master_key: Option, /// Whether secrets management is enabled. pub enabled: bool, + /// Source of the master key. + pub source: crate::settings::KeySource, } impl std::fmt::Debug for SecretsConfig { @@ -476,13 +539,53 @@ impl std::fmt::Debug for SecretsConfig { f.debug_struct("SecretsConfig") .field("master_key", &self.master_key.is_some()) .field("enabled", &self.enabled) + .field("source", &self.source) .finish() } } impl SecretsConfig { fn from_env() -> Result { - let master_key = optional_env("SECRETS_MASTER_KEY")?.map(SecretString::from); + use crate::settings::KeySource; + + let settings = crate::settings::Settings::load(); + + // Priority: env var > keychain (based on settings) > disabled + let (master_key, source) = if let Some(env_key) = optional_env("SECRETS_MASTER_KEY")? { + // Env var takes priority (for CI/Docker) + (Some(SecretString::from(env_key)), KeySource::Env) + } else { + match settings.secrets_master_key_source { + KeySource::Keychain => { + // Try to load from OS keychain + match crate::secrets::keychain::get_master_key() { + Ok(key_bytes) => { + let key_hex: String = + key_bytes.iter().map(|b| format!("{:02x}", b)).collect(); + (Some(SecretString::from(key_hex)), KeySource::Keychain) + } + Err(_) => { + // Keychain configured but key not found + // This might happen if keychain was cleared + tracing::warn!( + "Secrets configured for keychain but key not found. \ + Run 'ironclaw setup' to reconfigure." + ); + (None, KeySource::None) + } + } + } + KeySource::Env => { + // Settings say env, but no env var found + tracing::warn!( + "Secrets configured for env var but SECRETS_MASTER_KEY not set." + ); + (None, KeySource::None) + } + KeySource::None => (None, KeySource::None), + } + }; + let enabled = master_key.is_some(); // Validate master key length if provided @@ -498,6 +601,7 @@ impl SecretsConfig { Ok(Self { master_key, enabled, + source, }) } @@ -676,7 +780,10 @@ impl Default for HeartbeatConfig { impl HeartbeatConfig { fn from_env() -> Result { + let settings = crate::settings::Settings::load(); + Ok(Self { + // Priority: env var > settings > default enabled: optional_env("HEARTBEAT_ENABLED")? .map(|s| s.parse()) .transpose() @@ -684,10 +791,19 @@ impl HeartbeatConfig { key: "HEARTBEAT_ENABLED".to_string(), message: format!("must be 'true' or 'false': {e}"), })? - .unwrap_or(false), - interval_secs: parse_optional_env("HEARTBEAT_INTERVAL_SECS", 1800)?, - notify_channel: optional_env("HEARTBEAT_NOTIFY_CHANNEL")?, - notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?, + .unwrap_or(settings.heartbeat.enabled), + interval_secs: optional_env("HEARTBEAT_INTERVAL_SECS")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "HEARTBEAT_INTERVAL_SECS".to_string(), + message: format!("must be a positive integer: {e}"), + })? + .unwrap_or(settings.heartbeat.interval_secs), + notify_channel: optional_env("HEARTBEAT_NOTIFY_CHANNEL")? + .or(settings.heartbeat.notify_channel.clone()), + notify_user: optional_env("HEARTBEAT_NOTIFY_USER")? + .or(settings.heartbeat.notify_user.clone()), }) } } @@ -787,10 +903,6 @@ impl SandboxModeConfig { // Helper functions -fn required_env(key: &str) -> Result { - std::env::var(key).map_err(|_| ConfigError::MissingEnvVar(key.to_string())) -} - fn optional_env(key: &str) -> Result, ConfigError> { match std::env::var(key) { Ok(val) if val.is_empty() => Ok(None), diff --git a/src/error.rs b/src/error.rs index a8fbced0..10da699f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -47,6 +47,9 @@ pub enum ConfigError { #[error("Missing required environment variable: {0}")] MissingEnvVar(String), + #[error("Missing required configuration: {key}. {hint}")] + MissingRequired { key: String, hint: String }, + #[error("Invalid configuration value for {key}: {message}")] InvalidValue { key: String, message: String }, diff --git a/src/main.rs b/src/main.rs index 89eeaed8..38b2da19 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,11 @@ async fn main() -> anyhow::Result<()> { return run_tool_command(tool_cmd.clone()).await; } + Some(Command::Config(config_cmd)) => { + // Config commands don't need logging setup + return ironclaw::cli::run_config_command(config_cmd.clone()) + .map_err(|e| anyhow::anyhow!("{}", e)); + } Some(Command::Setup { skip_auth, channels_only, @@ -70,14 +75,10 @@ async fn main() -> anyhow::Result<()> { // Load .env if present let _ = dotenvy::dotenv(); - // First-run detection: if setup hasn't been completed and user didn't skip it, - // automatically run the setup wizard + // Enhanced first-run detection if !cli.no_setup { - let settings = Settings::load(); - let session_path = ironclaw::llm::session::default_session_path(); - - if !settings.setup_completed && !session_path.exists() { - println!("First run detected. Starting setup wizard..."); + if let Some(reason) = check_setup_needed() { + println!("Setup needed: {}", reason); println!(); let mut wizard = SetupWizard::new(); wizard.run().await?; @@ -85,7 +86,19 @@ async fn main() -> anyhow::Result<()> { } // Load configuration (after potential setup) - let config = Config::from_env()?; + let config = match Config::from_env() { + Ok(c) => c, + Err(ironclaw::error::ConfigError::MissingRequired { key, hint }) => { + eprintln!("Configuration error: Missing required setting '{}'", key); + eprintln!(" {}", hint); + eprintln!(); + eprintln!( + "Run 'ironclaw setup' to configure, or set the required environment variables." + ); + std::process::exit(1); + } + Err(e) => return Err(e.into()), + }; // Initialize session manager and authenticate BEFORE TUI setup // This allows the auth menu to display cleanly without TUI interference @@ -571,6 +584,35 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +/// Check if setup is needed and return the reason. +/// +/// Returns `Some(reason)` if setup should be triggered, `None` otherwise. +fn check_setup_needed() -> Option<&'static str> { + let settings = Settings::load(); + + // Database not configured (and not in env) + if settings.database_url.is_none() && std::env::var("DATABASE_URL").is_err() { + return Some("Database not configured"); + } + + // Secrets not configured (and not in env) + if settings.secrets_master_key_source == ironclaw::settings::KeySource::None + && std::env::var("SECRETS_MASTER_KEY").is_err() + && !ironclaw::secrets::keychain::has_master_key() + { + // Only require secrets setup if user hasn't explicitly disabled it + // For now, we don't require it for first run + } + + // First run (setup never completed and no session) + let session_path = ironclaw::llm::session::default_session_path(); + if !settings.setup_completed && !session_path.exists() { + return Some("First run"); + } + + None +} + /// Inject credentials for a channel based on naming convention. /// /// Looks for secrets matching the pattern `{channel_name}_*` and injects them diff --git a/src/secrets/keychain.rs b/src/secrets/keychain.rs new file mode 100644 index 00000000..e81efcd0 --- /dev/null +++ b/src/secrets/keychain.rs @@ -0,0 +1,346 @@ +//! OS keychain integration for secrets master key storage. +//! +//! Provides platform-specific keychain support: +//! - macOS: security-framework (Keychain Services) +//! - Linux: secret-service (GNOME Keyring, KWallet) +//! +//! # Example +//! +//! ```ignore +//! use ironclaw::secrets::keychain::{store_master_key, get_master_key, delete_master_key}; +//! +//! // Generate and store a new master key +//! let key = generate_master_key(); +//! store_master_key(&key)?; +//! +//! // Later, retrieve it +//! let key = get_master_key()?; +//! ``` + +use crate::secrets::SecretError; + +/// Service name for keychain entries. +const SERVICE_NAME: &str = "ironclaw"; + +/// Account name for the master key. +const MASTER_KEY_ACCOUNT: &str = "master_key"; + +/// Generate a random 32-byte master key. +pub fn generate_master_key() -> Vec { + use rand::RngCore; + let mut key = vec![0u8; 32]; + rand::thread_rng().fill_bytes(&mut key); + key +} + +/// Generate a master key as a hex string. +pub fn generate_master_key_hex() -> String { + let bytes = generate_master_key(); + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +// ============================================================================ +// macOS implementation using security-framework +// ============================================================================ + +#[cfg(target_os = "macos")] +mod platform { + use security_framework::passwords::{ + delete_generic_password, get_generic_password, set_generic_password, + }; + + use super::*; + + /// Store the master key in the macOS Keychain. + pub fn store_master_key(key: &[u8]) -> Result<(), SecretError> { + // Convert to hex for storage (keychain prefers strings) + let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); + + set_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT, key_hex.as_bytes()) + .map_err(|e| SecretError::KeychainError(format!("Failed to store in keychain: {}", e))) + } + + /// Retrieve the master key from the macOS Keychain. + pub fn get_master_key() -> Result, SecretError> { + let password = get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|e| { + SecretError::KeychainError(format!("Failed to get from keychain: {}", e)) + })?; + + // Parse hex string back to bytes + let hex_str = String::from_utf8(password) + .map_err(|_| SecretError::KeychainError("Invalid UTF-8 in keychain".to_string()))?; + + hex_to_bytes(&hex_str) + } + + /// Delete the master key from the macOS Keychain. + pub fn delete_master_key() -> Result<(), SecretError> { + delete_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|e| { + SecretError::KeychainError(format!("Failed to delete from keychain: {}", e)) + }) + } + + /// Check if a master key exists in the keychain. + pub fn has_master_key() -> bool { + get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).is_ok() + } +} + +// ============================================================================ +// Linux implementation using secret-service +// ============================================================================ + +#[cfg(target_os = "linux")] +mod platform { + use secret_service::{EncryptionType, SecretService}; + + use super::*; + + /// Store the master key in the Linux secret service (GNOME Keyring, KWallet). + pub fn store_master_key(key: &[u8]) -> Result<(), SecretError> { + let rt = tokio::runtime::Handle::try_current() + .map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?; + + rt.block_on(async { + let ss = SecretService::connect(EncryptionType::Dh) + .await + .map_err(|e| { + SecretError::KeychainError(format!( + "Failed to connect to secret service: {}", + e + )) + })?; + + let collection = ss.get_default_collection().await.map_err(|e| { + SecretError::KeychainError(format!("Failed to get collection: {}", e)) + })?; + + // Unlock if needed + if collection.is_locked().await.unwrap_or(true) { + collection.unlock().await.map_err(|e| { + SecretError::KeychainError(format!("Failed to unlock collection: {}", e)) + })?; + } + + // Convert to hex for storage + let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); + + collection + .create_item( + &format!("{} master key", SERVICE_NAME), + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + key_hex.as_bytes(), + true, // Replace if exists + "text/plain", + ) + .await + .map_err(|e| { + SecretError::KeychainError(format!("Failed to create secret: {}", e)) + })?; + + Ok(()) + }) + } + + /// Retrieve the master key from the Linux secret service. + pub fn get_master_key() -> Result, SecretError> { + let rt = tokio::runtime::Handle::try_current() + .map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?; + + rt.block_on(async { + let ss = SecretService::connect(EncryptionType::Dh) + .await + .map_err(|e| { + SecretError::KeychainError(format!( + "Failed to connect to secret service: {}", + e + )) + })?; + + let items = ss + .search_items( + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + ) + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?; + + let item = items + .unlocked + .first() + .or(items.locked.first()) + .ok_or_else(|| SecretError::KeychainError("Master key not found".to_string()))?; + + // Unlock if needed + if item.is_locked().await.unwrap_or(true) { + item.unlock() + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to unlock: {}", e)))?; + } + + let secret = item + .get_secret() + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to get secret: {}", e)))?; + + let hex_str = String::from_utf8(secret) + .map_err(|_| SecretError::KeychainError("Invalid UTF-8 in secret".to_string()))?; + + hex_to_bytes(&hex_str) + }) + } + + /// Delete the master key from the Linux secret service. + pub fn delete_master_key() -> Result<(), SecretError> { + let rt = tokio::runtime::Handle::try_current() + .map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?; + + rt.block_on(async { + let ss = SecretService::connect(EncryptionType::Dh) + .await + .map_err(|e| { + SecretError::KeychainError(format!( + "Failed to connect to secret service: {}", + e + )) + })?; + + let items = ss + .search_items( + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + ) + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?; + + for item in items.unlocked.iter().chain(items.locked.iter()) { + item.delete() + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to delete: {}", e)))?; + } + + Ok(()) + }) + } + + /// Check if a master key exists in the secret service. + pub fn has_master_key() -> bool { + let rt = match tokio::runtime::Handle::try_current() { + Ok(rt) => rt, + Err(_) => return false, + }; + + rt.block_on(async { + let ss = match SecretService::connect(EncryptionType::Dh).await { + Ok(ss) => ss, + Err(_) => return false, + }; + + let items = match ss + .search_items( + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + ) + .await + { + Ok(items) => items, + Err(_) => return false, + }; + + !items.unlocked.is_empty() || !items.locked.is_empty() + }) + } +} + +// ============================================================================ +// Fallback for unsupported platforms +// ============================================================================ + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +mod platform { + use super::*; + + pub fn store_master_key(_key: &[u8]) -> Result<(), SecretError> { + Err(SecretError::KeychainError( + "Keychain not supported on this platform. Use SECRETS_MASTER_KEY env var.".to_string(), + )) + } + + pub fn get_master_key() -> Result, SecretError> { + Err(SecretError::KeychainError( + "Keychain not supported on this platform. Use SECRETS_MASTER_KEY env var.".to_string(), + )) + } + + pub fn delete_master_key() -> Result<(), SecretError> { + Err(SecretError::KeychainError( + "Keychain not supported on this platform".to_string(), + )) + } + + pub fn has_master_key() -> bool { + false + } +} + +// Re-export platform-specific functions +pub use platform::{delete_master_key, get_master_key, has_master_key, store_master_key}; + +/// Parse a hex string to bytes. +fn hex_to_bytes(hex: &str) -> Result, SecretError> { + if hex.len() % 2 != 0 { + return Err(SecretError::KeychainError( + "Invalid hex string length".to_string(), + )); + } + + (0..hex.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&hex[i..i + 2], 16) + .map_err(|_| SecretError::KeychainError("Invalid hex character".to_string())) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_master_key() { + let key = generate_master_key(); + assert_eq!(key.len(), 32); + + // Should be different each time + let key2 = generate_master_key(); + assert_ne!(key, key2); + } + + #[test] + fn test_generate_master_key_hex() { + let hex = generate_master_key_hex(); + assert_eq!(hex.len(), 64); // 32 bytes * 2 hex chars + assert!(hex.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_hex_to_bytes() { + let result = hex_to_bytes("deadbeef").unwrap(); + assert_eq!(result, vec![0xde, 0xad, 0xbe, 0xef]); + + let result = hex_to_bytes("00ff").unwrap(); + assert_eq!(result, vec![0x00, 0xff]); + } + + #[test] + fn test_hex_to_bytes_invalid() { + assert!(hex_to_bytes("abc").is_err()); // Odd length + assert!(hex_to_bytes("gg").is_err()); // Invalid chars + } +} diff --git a/src/secrets/mod.rs b/src/secrets/mod.rs index 06bdfc2d..d2f8696d 100644 --- a/src/secrets/mod.rs +++ b/src/secrets/mod.rs @@ -4,6 +4,7 @@ //! - AES-256-GCM encrypted secret storage //! - Per-secret key derivation (HKDF-SHA256) //! - PostgreSQL persistence +//! - OS keychain integration for master key //! - Access control for WASM tools //! //! # Security Model @@ -28,6 +29,12 @@ //! └─────────────────────────────────────────────────────────────────────────────┘ //! ``` //! +//! # Master Key Storage +//! +//! The master key for encrypting secrets can come from: +//! - **OS Keychain** (recommended for local installs): Auto-generated and stored securely +//! - **Environment variable** (for CI/Docker): Set `SECRETS_MASTER_KEY` +//! //! # Example //! //! ```ignore @@ -52,6 +59,7 @@ //! ``` mod crypto; +pub mod keychain; mod store; mod types; diff --git a/src/secrets/types.rs b/src/secrets/types.rs index 0050c9fd..eb259a69 100644 --- a/src/secrets/types.rs +++ b/src/secrets/types.rs @@ -156,6 +156,9 @@ pub enum SecretError { #[error("Secret access denied for tool")] AccessDenied, + + #[error("Keychain error: {0}")] + KeychainError(String), } /// Parameters for creating a new secret. diff --git a/src/settings.rs b/src/settings.rs index b5276d9b..bb594a19 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1,6 +1,7 @@ //! User settings persistence. //! -//! Stores user preferences like selected model in ~/.ironclaw/settings.json. +//! Stores user preferences in ~/.ironclaw/settings.json. +//! Settings are loaded with env var > settings.json > default priority. use std::path::PathBuf; @@ -9,21 +10,118 @@ use serde::{Deserialize, Serialize}; /// User settings persisted to disk. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Settings { - /// Currently selected model. - #[serde(default)] - pub selected_model: Option, - /// Whether setup wizard has been completed. #[serde(default)] pub setup_completed: bool, - /// Tunnel configuration for exposing the agent to the internet. + // === Step 1: Database === + /// Database connection URL (postgres://...). + #[serde(default)] + pub database_url: Option, + + /// Database pool size. + #[serde(default)] + pub database_pool_size: Option, + + // === Step 2: Security === + /// Source for the secrets master key. + #[serde(default)] + pub secrets_master_key_source: KeySource, + + // === Step 3: NEAR AI Auth === + // Session stored separately in session.json + + // === Step 4: Model Selection === + /// Currently selected model. + #[serde(default)] + pub selected_model: Option, + + // === Step 5: Embeddings === + /// Embeddings configuration. + #[serde(default)] + pub embeddings: EmbeddingsSettings, + + // === Step 6: Channels === + /// Tunnel configuration for public webhook endpoints. #[serde(default)] pub tunnel: TunnelSettings, /// Channel configuration. #[serde(default)] pub channels: ChannelSettings, + + // === Step 7: Heartbeat === + /// Heartbeat configuration. + #[serde(default)] + pub heartbeat: HeartbeatSettings, + + // === Advanced Settings (not asked during setup, editable via CLI) === + /// Agent behavior configuration. + #[serde(default)] + pub agent: AgentSettings, + + /// WASM sandbox configuration. + #[serde(default)] + pub wasm: WasmSettings, + + /// Docker sandbox configuration. + #[serde(default)] + pub sandbox: SandboxSettings, + + /// Safety configuration. + #[serde(default)] + pub safety: SafetySettings, + + /// Builder configuration. + #[serde(default)] + pub builder: BuilderSettings, +} + +/// Source for the secrets master key. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum KeySource { + /// Auto-generated key stored in OS keychain. + Keychain, + /// User provides via SECRETS_MASTER_KEY env var. + Env, + /// Not configured (secrets features disabled). + #[default] + None, +} + +/// Embeddings configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmbeddingsSettings { + /// Whether embeddings are enabled. + #[serde(default)] + pub enabled: bool, + + /// Provider to use: "openai" or "nearai". + #[serde(default = "default_embeddings_provider")] + pub provider: String, + + /// Model to use for embeddings. + #[serde(default = "default_embeddings_model")] + pub model: String, +} + +fn default_embeddings_provider() -> String { + "nearai".to_string() +} + +fn default_embeddings_model() -> String { + "text-embedding-3-small".to_string() +} + +impl Default for EmbeddingsSettings { + fn default() -> Self { + Self { + enabled: false, + provider: default_embeddings_provider(), + model: default_embeddings_model(), + } + } } /// Tunnel settings for public webhook endpoints. @@ -47,11 +145,330 @@ pub struct ChannelSettings { #[serde(default)] pub http_port: Option, + /// HTTP webhook host. + #[serde(default)] + pub http_host: Option, + /// Enabled WASM channels by name. /// Channels not in this list but present in the channels directory will still load. /// This is primarily used by the setup wizard to track which channels were configured. #[serde(default)] pub wasm_channels: Vec, + + /// Whether WASM channels are enabled. + #[serde(default = "default_true")] + pub wasm_channels_enabled: bool, + + /// Directory containing WASM channel modules. + #[serde(default)] + pub wasm_channels_dir: Option, +} + +/// Heartbeat configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HeartbeatSettings { + /// Whether heartbeat is enabled. + #[serde(default)] + pub enabled: bool, + + /// Interval between heartbeat checks in seconds. + #[serde(default = "default_heartbeat_interval")] + pub interval_secs: u64, + + /// Channel to notify on heartbeat findings. + #[serde(default)] + pub notify_channel: Option, + + /// User ID to notify on heartbeat findings. + #[serde(default)] + pub notify_user: Option, +} + +fn default_heartbeat_interval() -> u64 { + 1800 // 30 minutes +} + +impl Default for HeartbeatSettings { + fn default() -> Self { + Self { + enabled: false, + interval_secs: default_heartbeat_interval(), + notify_channel: None, + notify_user: None, + } + } +} + +/// Agent behavior configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentSettings { + /// Agent name. + #[serde(default = "default_agent_name")] + pub name: String, + + /// Maximum parallel jobs. + #[serde(default = "default_max_parallel_jobs")] + pub max_parallel_jobs: u32, + + /// Job timeout in seconds. + #[serde(default = "default_job_timeout")] + pub job_timeout_secs: u64, + + /// Stuck job threshold in seconds. + #[serde(default = "default_stuck_threshold")] + pub stuck_threshold_secs: u64, + + /// Whether to use planning before tool execution. + #[serde(default = "default_true")] + pub use_planning: bool, + + /// Self-repair check interval in seconds. + #[serde(default = "default_repair_interval")] + pub repair_check_interval_secs: u64, + + /// Maximum repair attempts. + #[serde(default = "default_max_repair_attempts")] + pub max_repair_attempts: u32, +} + +fn default_agent_name() -> String { + "ironclaw".to_string() +} + +fn default_max_parallel_jobs() -> u32 { + 5 +} + +fn default_job_timeout() -> u64 { + 3600 // 1 hour +} + +fn default_stuck_threshold() -> u64 { + 300 // 5 minutes +} + +fn default_repair_interval() -> u64 { + 60 // 1 minute +} + +fn default_max_repair_attempts() -> u32 { + 3 +} + +fn default_true() -> bool { + true +} + +impl Default for AgentSettings { + fn default() -> Self { + Self { + name: default_agent_name(), + max_parallel_jobs: default_max_parallel_jobs(), + job_timeout_secs: default_job_timeout(), + stuck_threshold_secs: default_stuck_threshold(), + use_planning: true, + repair_check_interval_secs: default_repair_interval(), + max_repair_attempts: default_max_repair_attempts(), + } + } +} + +/// WASM sandbox configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WasmSettings { + /// Whether WASM tool execution is enabled. + #[serde(default = "default_true")] + pub enabled: bool, + + /// Directory containing installed WASM tools. + #[serde(default)] + pub tools_dir: Option, + + /// Default memory limit in bytes. + #[serde(default = "default_wasm_memory_limit")] + pub default_memory_limit: u64, + + /// Default execution timeout in seconds. + #[serde(default = "default_wasm_timeout")] + pub default_timeout_secs: u64, + + /// Default fuel limit for CPU metering. + #[serde(default = "default_wasm_fuel_limit")] + pub default_fuel_limit: u64, + + /// Whether to cache compiled modules. + #[serde(default = "default_true")] + pub cache_compiled: bool, + + /// Directory for compiled module cache. + #[serde(default)] + pub cache_dir: Option, +} + +fn default_wasm_memory_limit() -> u64 { + 10 * 1024 * 1024 // 10 MB +} + +fn default_wasm_timeout() -> u64 { + 60 +} + +fn default_wasm_fuel_limit() -> u64 { + 10_000_000 +} + +impl Default for WasmSettings { + fn default() -> Self { + Self { + enabled: true, + tools_dir: None, + default_memory_limit: default_wasm_memory_limit(), + default_timeout_secs: default_wasm_timeout(), + default_fuel_limit: default_wasm_fuel_limit(), + cache_compiled: true, + cache_dir: None, + } + } +} + +/// Docker sandbox configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxSettings { + /// Whether the Docker sandbox is enabled. + #[serde(default = "default_true")] + pub enabled: bool, + + /// Sandbox policy: "readonly", "workspace_write", or "full_access". + #[serde(default = "default_sandbox_policy")] + pub policy: String, + + /// Command timeout in seconds. + #[serde(default = "default_sandbox_timeout")] + pub timeout_secs: u64, + + /// Memory limit in megabytes. + #[serde(default = "default_sandbox_memory")] + pub memory_limit_mb: u64, + + /// CPU shares (relative weight). + #[serde(default = "default_sandbox_cpu_shares")] + pub cpu_shares: u32, + + /// Docker image for the sandbox. + #[serde(default = "default_sandbox_image")] + pub image: String, + + /// Whether to auto-pull the image if not found. + #[serde(default = "default_true")] + pub auto_pull_image: bool, + + /// Additional domains to allow through the network proxy. + #[serde(default)] + pub extra_allowed_domains: Vec, +} + +fn default_sandbox_policy() -> String { + "readonly".to_string() +} + +fn default_sandbox_timeout() -> u64 { + 120 +} + +fn default_sandbox_memory() -> u64 { + 2048 +} + +fn default_sandbox_cpu_shares() -> u32 { + 1024 +} + +fn default_sandbox_image() -> String { + "ghcr.io/nearai/sandbox:latest".to_string() +} + +impl Default for SandboxSettings { + fn default() -> Self { + Self { + enabled: true, + policy: default_sandbox_policy(), + timeout_secs: default_sandbox_timeout(), + memory_limit_mb: default_sandbox_memory(), + cpu_shares: default_sandbox_cpu_shares(), + image: default_sandbox_image(), + auto_pull_image: true, + extra_allowed_domains: Vec::new(), + } + } +} + +/// Safety configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SafetySettings { + /// Maximum output length in bytes. + #[serde(default = "default_max_output_length")] + pub max_output_length: usize, + + /// Whether injection check is enabled. + #[serde(default = "default_true")] + pub injection_check_enabled: bool, +} + +fn default_max_output_length() -> usize { + 100_000 +} + +impl Default for SafetySettings { + fn default() -> Self { + Self { + max_output_length: default_max_output_length(), + injection_check_enabled: true, + } + } +} + +/// Builder configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuilderSettings { + /// Whether the software builder tool is enabled. + #[serde(default = "default_true")] + pub enabled: bool, + + /// Directory for build artifacts. + #[serde(default)] + pub build_dir: Option, + + /// Maximum iterations for the build loop. + #[serde(default = "default_builder_max_iterations")] + pub max_iterations: u32, + + /// Build timeout in seconds. + #[serde(default = "default_builder_timeout")] + pub timeout_secs: u64, + + /// Whether to automatically register built WASM tools. + #[serde(default = "default_true")] + pub auto_register: bool, +} + +fn default_builder_max_iterations() -> u32 { + 20 +} + +fn default_builder_timeout() -> u64 { + 600 +} + +impl Default for BuilderSettings { + fn default() -> Self { + Self { + enabled: true, + build_dir: None, + max_iterations: default_builder_max_iterations(), + timeout_secs: default_builder_timeout(), + auto_register: true, + } + } } impl Settings { @@ -106,6 +523,163 @@ impl Settings { self.selected_model = Some(model.to_string()); self.save() } + + /// Get a setting value by dotted path (e.g., "agent.max_parallel_jobs"). + pub fn get(&self, path: &str) -> Option { + let json = serde_json::to_value(self).ok()?; + let mut current = &json; + + for part in path.split('.') { + current = current.get(part)?; + } + + match current { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + serde_json::Value::Bool(b) => Some(b.to_string()), + serde_json::Value::Null => Some("null".to_string()), + serde_json::Value::Array(arr) => Some(serde_json::to_string(arr).unwrap_or_default()), + serde_json::Value::Object(obj) => Some(serde_json::to_string(obj).unwrap_or_default()), + } + } + + /// Set a setting value by dotted path. + /// + /// Returns error if path is invalid or value cannot be parsed. + pub fn set(&mut self, path: &str, value: &str) -> Result<(), String> { + let mut json = serde_json::to_value(&self) + .map_err(|e| format!("Failed to serialize settings: {}", e))?; + + let parts: Vec<&str> = path.split('.').collect(); + if parts.is_empty() { + return Err("Empty path".to_string()); + } + + // Navigate to parent and set the final key + let mut current = &mut json; + for part in &parts[..parts.len() - 1] { + current = current + .get_mut(*part) + .ok_or_else(|| format!("Path not found: {}", path))?; + } + + let final_key = parts.last().unwrap(); + let obj = current + .as_object_mut() + .ok_or_else(|| format!("Parent is not an object: {}", path))?; + + // Try to infer the type from the existing value + let new_value = if let Some(existing) = obj.get(*final_key) { + match existing { + serde_json::Value::Bool(_) => { + let b = value + .parse::() + .map_err(|_| format!("Expected boolean for {}, got '{}'", path, value))?; + serde_json::Value::Bool(b) + } + serde_json::Value::Number(n) => { + if n.is_u64() { + let n = value.parse::().map_err(|_| { + format!("Expected integer for {}, got '{}'", path, value) + })?; + serde_json::Value::Number(n.into()) + } else if n.is_i64() { + let n = value.parse::().map_err(|_| { + format!("Expected integer for {}, got '{}'", path, value) + })?; + serde_json::Value::Number(n.into()) + } else { + let n = value.parse::().map_err(|_| { + format!("Expected number for {}, got '{}'", path, value) + })?; + serde_json::Number::from_f64(n) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::String(value.to_string())) + } + } + serde_json::Value::Null => { + // Could be Option, try to parse as JSON or use string + serde_json::from_str(value) + .unwrap_or(serde_json::Value::String(value.to_string())) + } + serde_json::Value::Array(_) => serde_json::from_str(value) + .map_err(|e| format!("Invalid JSON array for {}: {}", path, e))?, + serde_json::Value::Object(_) => serde_json::from_str(value) + .map_err(|e| format!("Invalid JSON object for {}: {}", path, e))?, + serde_json::Value::String(_) => serde_json::Value::String(value.to_string()), + } + } else { + // Key doesn't exist, try to parse as JSON or use string + serde_json::from_str(value).unwrap_or(serde_json::Value::String(value.to_string())) + }; + + obj.insert((*final_key).to_string(), new_value); + + // Deserialize back to Settings + *self = + serde_json::from_value(json).map_err(|e| format!("Failed to apply setting: {}", e))?; + + Ok(()) + } + + /// Reset a setting to its default value. + pub fn reset(&mut self, path: &str) -> Result<(), String> { + let default = Self::default(); + let default_value = default + .get(path) + .ok_or_else(|| format!("Unknown setting: {}", path))?; + + self.set(path, &default_value) + } + + /// List all settings as (path, value) pairs. + pub fn list(&self) -> Vec<(String, String)> { + let json = match serde_json::to_value(self) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + + let mut results = Vec::new(); + collect_settings(&json, String::new(), &mut results); + results.sort_by(|a, b| a.0.cmp(&b.0)); + results + } +} + +/// Recursively collect settings paths and values. +fn collect_settings( + value: &serde_json::Value, + prefix: String, + results: &mut Vec<(String, String)>, +) { + match value { + serde_json::Value::Object(obj) => { + for (key, val) in obj { + let path = if prefix.is_empty() { + key.clone() + } else { + format!("{}.{}", prefix, key) + }; + collect_settings(val, path, results); + } + } + serde_json::Value::Array(arr) => { + let display = serde_json::to_string(arr).unwrap_or_default(); + results.push((prefix, display)); + } + serde_json::Value::String(s) => { + results.push((prefix, s.clone())); + } + serde_json::Value::Number(n) => { + results.push((prefix, n.to_string())); + } + serde_json::Value::Bool(b) => { + results.push((prefix, b.to_string())); + } + serde_json::Value::Null => { + results.push((prefix, "null".to_string())); + } + } } #[cfg(test)] @@ -146,4 +720,73 @@ mod tests { }; assert_eq!(settings.model_or("default-model"), "my-model".to_string()); } + + #[test] + fn test_get_setting() { + let settings = Settings::default(); + + assert_eq!(settings.get("agent.name"), Some("ironclaw".to_string())); + assert_eq!( + settings.get("agent.max_parallel_jobs"), + Some("5".to_string()) + ); + assert_eq!(settings.get("heartbeat.enabled"), Some("false".to_string())); + assert_eq!(settings.get("nonexistent"), None); + } + + #[test] + fn test_set_setting() { + let mut settings = Settings::default(); + + settings.set("agent.name", "mybot").unwrap(); + assert_eq!(settings.agent.name, "mybot"); + + settings.set("agent.max_parallel_jobs", "10").unwrap(); + assert_eq!(settings.agent.max_parallel_jobs, 10); + + settings.set("heartbeat.enabled", "true").unwrap(); + assert!(settings.heartbeat.enabled); + } + + #[test] + fn test_reset_setting() { + let mut settings = Settings::default(); + + settings.agent.name = "custom".to_string(); + settings.reset("agent.name").unwrap(); + assert_eq!(settings.agent.name, "ironclaw"); + } + + #[test] + fn test_list_settings() { + let settings = Settings::default(); + let list = settings.list(); + + // Check some expected entries + assert!(list.iter().any(|(k, _)| k == "agent.name")); + assert!(list.iter().any(|(k, _)| k == "heartbeat.enabled")); + assert!(list.iter().any(|(k, _)| k == "setup_completed")); + } + + #[test] + fn test_key_source_serialization() { + let settings = Settings { + secrets_master_key_source: KeySource::Keychain, + ..Default::default() + }; + + let json = serde_json::to_string(&settings).unwrap(); + assert!(json.contains("\"keychain\"")); + + let loaded: Settings = serde_json::from_str(&json).unwrap(); + assert_eq!(loaded.secrets_master_key_source, KeySource::Keychain); + } + + #[test] + fn test_embeddings_defaults() { + let settings = Settings::default(); + assert!(!settings.embeddings.enabled); + assert_eq!(settings.embeddings.provider, "nearai"); + assert_eq!(settings.embeddings.model, "text-embedding-3-small"); + } } diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 50711ffa..71bda14a 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -1,9 +1,13 @@ //! Interactive setup wizard for IronClaw. //! //! Provides a guided setup experience for: -//! - NEAR AI authentication -//! - Model selection -//! - Channel configuration (HTTP, Telegram, etc.) +//! 1. Database connection +//! 2. Security (secrets master key) +//! 3. NEAR AI authentication +//! 4. Model selection +//! 5. Embeddings +//! 6. Channel configuration (HTTP, Telegram, etc.) +//! 7. Heartbeat (background tasks) //! //! # Example //! @@ -21,5 +25,8 @@ mod wizard; pub use channels::{ SecretsContext, setup_http, setup_telegram, setup_tunnel, validate_telegram_token, }; -pub use prompts::{confirm, print_header, print_step, secret_input, select_many, select_one}; +pub use prompts::{ + confirm, input, optional_input, print_error, print_header, print_info, print_step, + print_success, secret_input, select_many, select_one, +}; pub use wizard::{SetupConfig, SetupWizard}; diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 6ad22295..5ba4b6ac 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -1,9 +1,13 @@ //! Main setup wizard orchestration. //! //! The wizard guides users through: -//! 1. NEAR AI authentication -//! 2. Model selection -//! 3. Channel configuration +//! 1. Database connection +//! 2. Security (secrets master key) +//! 3. NEAR AI authentication +//! 4. Model selection +//! 5. Embeddings +//! 6. Channel configuration +//! 7. Heartbeat (background tasks) use std::sync::Arc; @@ -14,12 +18,13 @@ use tokio_postgres::NoTls; use crate::channels::wasm::ChannelCapabilitiesFile; use crate::llm::{SessionConfig, SessionManager}; use crate::secrets::SecretsCrypto; -use crate::settings::Settings; +use crate::settings::{KeySource, Settings}; use crate::setup::channels::{ SecretsContext, setup_http, setup_telegram, setup_tunnel, setup_wasm_channel, }; use crate::setup::prompts::{ - input, print_header, print_info, print_step, print_success, select_many, select_one, + confirm, input, optional_input, print_error, print_header, print_info, print_step, + print_success, select_many, select_one, }; /// Setup wizard error. @@ -58,6 +63,10 @@ pub struct SetupWizard { config: SetupConfig, settings: Settings, session_manager: Option>, + /// Database pool (created during setup). + db_pool: Option, + /// Secrets crypto (created during setup). + secrets_crypto: Option>, } impl SetupWizard { @@ -67,6 +76,8 @@ impl SetupWizard { config: SetupConfig::default(), settings: Settings::load(), session_manager: None, + db_pool: None, + secrets_crypto: None, } } @@ -76,6 +87,8 @@ impl SetupWizard { config, settings: Settings::load(), session_manager: None, + db_pool: None, + secrets_crypto: None, } } @@ -89,26 +102,45 @@ impl SetupWizard { pub async fn run(&mut self) -> Result<(), SetupError> { print_header("IronClaw Setup Wizard"); - let total_steps = if self.config.channels_only { 1 } else { 3 }; - let mut current_step = 1; + if self.config.channels_only { + // Channels-only mode: just step 6 + print_step(1, 1, "Channel Configuration"); + self.step_channels().await?; + } else { + let total_steps = 7; - // Step 1: Authentication (unless skipped or channels-only) - if !self.config.channels_only && !self.config.skip_auth { - print_step(current_step, total_steps, "NEAR AI Authentication"); - self.step_authentication().await?; - current_step += 1; - } + // Step 1: Database + print_step(1, total_steps, "Database Connection"); + self.step_database().await?; - // Step 2: Model selection (unless channels-only) - if !self.config.channels_only { - print_step(current_step, total_steps, "Model Selection"); + // Step 2: Security + print_step(2, total_steps, "Security"); + self.step_security().await?; + + // Step 3: Authentication (unless skipped) + if !self.config.skip_auth { + print_step(3, total_steps, "NEAR AI Authentication"); + self.step_authentication().await?; + } else { + print_info("Skipping authentication (using existing session)"); + } + + // Step 4: Model selection + print_step(4, total_steps, "Model Selection"); self.step_model_selection().await?; - current_step += 1; - } - // Step 3: Channel configuration - print_step(current_step, total_steps, "Channel Configuration"); - self.step_channels().await?; + // Step 5: Embeddings + print_step(5, total_steps, "Embeddings (Semantic Search)"); + self.step_embeddings()?; + + // Step 6: Channel configuration + print_step(6, total_steps, "Channel Configuration"); + self.step_channels().await?; + + // Step 7: Heartbeat + print_step(7, total_steps, "Background Tasks"); + self.step_heartbeat()?; + } // Save settings and print summary self.save_and_summarize()?; @@ -116,7 +148,195 @@ impl SetupWizard { Ok(()) } - /// Step 1: NEAR AI authentication. + /// Step 1: Database connection. + async fn step_database(&mut self) -> Result<(), SetupError> { + // Check if we have an existing URL in env or settings + let existing_url = std::env::var("DATABASE_URL") + .ok() + .or_else(|| self.settings.database_url.clone()); + + if let Some(ref url) = existing_url { + // Mask the password for display + let display_url = mask_password_in_url(url); + print_info(&format!("Existing database URL: {}", display_url)); + + if confirm("Use this database?", true).map_err(SetupError::Io)? { + // Test the connection + if let Err(e) = self.test_database_connection(url).await { + print_error(&format!("Connection failed: {}", e)); + print_info("Let's configure a new database URL."); + } else { + print_success("Database connection successful"); + self.settings.database_url = Some(url.clone()); + return Ok(()); + } + } + } + + // Prompt for new URL + println!(); + print_info("Enter your PostgreSQL connection URL."); + print_info("Format: postgres://user:password@host:port/database"); + println!(); + + loop { + let url = input("Database URL").map_err(SetupError::Io)?; + + if url.is_empty() { + print_error("Database URL is required."); + continue; + } + + // Test the connection + print_info("Testing connection..."); + match self.test_database_connection(&url).await { + Ok(()) => { + print_success("Database connection successful"); + + // Ask if we should run migrations + if confirm("Run database migrations?", true).map_err(SetupError::Io)? { + self.run_migrations().await?; + } + + self.settings.database_url = Some(url); + return Ok(()); + } + Err(e) => { + print_error(&format!("Connection failed: {}", e)); + if !confirm("Try again?", true).map_err(SetupError::Io)? { + return Err(SetupError::Database( + "Database connection failed".to_string(), + )); + } + } + } + } + } + + /// Test database connection and store the pool. + async fn test_database_connection(&mut self, url: &str) -> Result<(), SetupError> { + let mut cfg = PoolConfig::new(); + cfg.url = Some(url.to_string()); + cfg.pool = Some(deadpool_postgres::PoolConfig { + max_size: 5, + ..Default::default() + }); + + let pool = cfg + .create_pool(Some(Runtime::Tokio1), NoTls) + .map_err(|e| SetupError::Database(format!("Failed to create pool: {}", e)))?; + + // Test the connection + let _ = pool + .get() + .await + .map_err(|e| SetupError::Database(format!("Failed to connect: {}", e)))?; + + self.db_pool = Some(pool); + Ok(()) + } + + /// Run database migrations. + async fn run_migrations(&self) -> Result<(), SetupError> { + if let Some(ref pool) = self.db_pool { + use refinery::embed_migrations; + embed_migrations!("migrations"); + + print_info("Running migrations..."); + + let mut client = pool + .get() + .await + .map_err(|e| SetupError::Database(format!("Pool error: {}", e)))?; + + migrations::runner() + .run_async(&mut **client) + .await + .map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?; + + print_success("Migrations applied"); + } + Ok(()) + } + + /// Step 2: Security (secrets master key). + async fn step_security(&mut self) -> Result<(), SetupError> { + // Check current configuration + let env_key_exists = std::env::var("SECRETS_MASTER_KEY").is_ok(); + let keychain_key_exists = crate::secrets::keychain::has_master_key(); + + if env_key_exists { + print_info("Secrets master key found in SECRETS_MASTER_KEY environment variable."); + self.settings.secrets_master_key_source = KeySource::Env; + print_success("Security configured (env var)"); + return Ok(()); + } + + if keychain_key_exists { + print_info("Existing master key found in OS keychain."); + if confirm("Use existing keychain key?", true).map_err(SetupError::Io)? { + self.settings.secrets_master_key_source = KeySource::Keychain; + print_success("Security configured (keychain)"); + return Ok(()); + } + } + + // Offer options + println!(); + print_info("The secrets master key encrypts sensitive data like API tokens."); + print_info("Choose where to store it:"); + println!(); + + let options = [ + "OS Keychain (recommended for local installs)", + "Environment variable (for CI/Docker)", + "Skip (disable secrets features)", + ]; + + let choice = select_one("Select storage method:", &options).map_err(SetupError::Io)?; + + match choice { + 0 => { + // Generate and store in keychain + print_info("Generating master key..."); + let key = crate::secrets::keychain::generate_master_key(); + + crate::secrets::keychain::store_master_key(&key).map_err(|e| { + SetupError::Config(format!("Failed to store in keychain: {}", e)) + })?; + + // Also create crypto instance + let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); + self.secrets_crypto = Some(Arc::new( + SecretsCrypto::new(SecretString::from(key_hex)) + .map_err(|e| SetupError::Config(e.to_string()))?, + )); + + self.settings.secrets_master_key_source = KeySource::Keychain; + print_success("Master key generated and stored in OS keychain"); + } + 1 => { + // Env var mode + print_info("Generate a key and add it to your environment:"); + let key_hex = crate::secrets::keychain::generate_master_key_hex(); + println!(); + println!(" export SECRETS_MASTER_KEY={}", key_hex); + println!(); + print_info("Add this to your shell profile or .env file."); + + self.settings.secrets_master_key_source = KeySource::Env; + print_success("Configured for environment variable"); + } + _ => { + self.settings.secrets_master_key_source = KeySource::None; + print_info("Secrets features disabled. Channel tokens must be set via env vars."); + } + } + + Ok(()) + } + + /// Step 3: NEAR AI authentication. async fn step_authentication(&mut self) -> Result<(), SetupError> { // Check if we already have a session if let Some(ref session) = self.session_manager { @@ -152,7 +372,7 @@ impl SetupWizard { Ok(()) } - /// Step 2: Model selection. + /// Step 4: Model selection. async fn step_model_selection(&mut self) -> Result<(), SetupError> { // Show current model if already configured if let Some(ref current) = self.settings.selected_model { @@ -160,7 +380,8 @@ impl SetupWizard { println!(); let options = ["Keep current model", "Change model"]; - let choice = select_one("What would you like to do?", &options)?; + let choice = + select_one("What would you like to do?", &options).map_err(SetupError::Io)?; if choice == 0 { print_success(&format!("Keeping {}", current)); @@ -201,11 +422,11 @@ impl SetupWizard { let mut all_options = options.clone(); all_options.push("Custom model ID"); - let choice = select_one("Select a model:", &all_options)?; + let choice = select_one("Select a model:", &all_options).map_err(SetupError::Io)?; let selected_model = if choice == all_options.len() - 1 { // Custom model - input("Enter model ID")? + input("Enter model ID").map_err(SetupError::Io)? } else if models.is_empty() { default_models[choice].0.to_string() } else { @@ -220,11 +441,9 @@ impl SetupWizard { /// Fetch available models from the API. async fn fetch_available_models(&self, session: &Arc) -> Vec { - // Create a temporary LLM provider to fetch models use crate::config::LlmConfig; use crate::llm::create_llm_provider; - // Read base URL from env, fallback to cloud-api.near.ai let base_url = std::env::var("NEARAI_BASE_URL") .unwrap_or_else(|_| "https://cloud-api.near.ai".to_string()); let auth_base_url = std::env::var("NEARAI_AUTH_URL") @@ -232,7 +451,7 @@ impl SetupWizard { let config = LlmConfig { nearai: crate::config::NearAiConfig { - model: "dummy".to_string(), // Not used for listing + model: "dummy".to_string(), base_url, auth_base_url, session_path: crate::llm::session::default_session_path(), @@ -259,65 +478,91 @@ impl SetupWizard { } } - /// Initialize secrets context for channel setup. - async fn init_secrets_context(&self) -> Result { - // Get DATABASE_URL - let database_url = std::env::var("DATABASE_URL").map_err(|_| { - SetupError::Config( - "DATABASE_URL not set. Please set it in .env or environment.".to_string(), - ) - })?; + /// Step 5: Embeddings configuration. + fn step_embeddings(&mut self) -> Result<(), SetupError> { + print_info("Embeddings enable semantic search in your workspace memory."); + println!(); - // Get or generate SECRETS_MASTER_KEY - let master_key = match std::env::var("SECRETS_MASTER_KEY") { - Ok(key) => { - if key.len() < 32 { - return Err(SetupError::Config( - "SECRETS_MASTER_KEY must be at least 32 characters".to_string(), - )); + if !confirm("Enable semantic search?", true).map_err(SetupError::Io)? { + self.settings.embeddings.enabled = false; + print_info("Embeddings disabled. Workspace will use keyword search only."); + return Ok(()); + } + + let options = [ + "NEAR AI (uses same auth, no extra cost)", + "OpenAI (requires API key)", + ]; + + let choice = select_one("Select embeddings provider:", &options).map_err(SetupError::Io)?; + + match choice { + 0 => { + self.settings.embeddings.enabled = true; + self.settings.embeddings.provider = "nearai".to_string(); + self.settings.embeddings.model = "text-embedding-3-small".to_string(); + print_success("Embeddings enabled via NEAR AI"); + } + 1 => { + // Check if API key is set + if std::env::var("OPENAI_API_KEY").is_err() { + print_info("OPENAI_API_KEY not set in environment."); + print_info("Add it to your .env file or environment to enable embeddings."); } - key + self.settings.embeddings.enabled = true; + self.settings.embeddings.provider = "openai".to_string(); + self.settings.embeddings.model = "text-embedding-3-small".to_string(); + print_success("Embeddings configured for OpenAI"); } - Err(_) => { - // Generate a new master key - print_info("SECRETS_MASTER_KEY not set. Generating a new one..."); - let key = generate_master_key(); - print_info(&format!( - "Generated master key. Add to your .env file:\nSECRETS_MASTER_KEY={}", - key - )); - key - } - }; + _ => unreachable!(), + } - // Create database pool - let mut cfg = PoolConfig::new(); - cfg.url = Some(database_url); - cfg.pool = Some(deadpool_postgres::PoolConfig { - max_size: 5, - ..Default::default() - }); - - let pool = cfg - .create_pool(Some(Runtime::Tokio1), NoTls) - .map_err(|e| SetupError::Database(format!("Failed to create pool: {}", e)))?; - - // Test connection - let _ = pool - .get() - .await - .map_err(|e| SetupError::Database(format!("Failed to connect to database: {}", e)))?; - - print_success("Connected to database"); - - // Create crypto - let crypto = SecretsCrypto::new(SecretString::from(master_key)) - .map_err(|e| SetupError::Config(format!("Invalid master key: {}", e)))?; - - Ok(SecretsContext::new(pool, Arc::new(crypto), "default")) + Ok(()) } - /// Step 3: Channel configuration. + /// Initialize secrets context for channel setup. + async fn init_secrets_context(&mut self) -> Result { + // Get database pool (should be set from step 1) + let pool = if let Some(ref p) = self.db_pool { + p.clone() + } else { + // Fall back to creating one from settings/env + let url = self + .settings + .database_url + .clone() + .or_else(|| std::env::var("DATABASE_URL").ok()) + .ok_or_else(|| SetupError::Config("Database URL not configured".to_string()))?; + + self.test_database_connection(&url).await?; + self.db_pool.clone().unwrap() + }; + + // Get crypto (should be set from step 2, or load from keychain/env) + let crypto = if let Some(ref c) = self.secrets_crypto { + Arc::clone(c) + } else { + // Try to load master key from keychain or env + let key = if let Ok(env_key) = std::env::var("SECRETS_MASTER_KEY") { + env_key + } else if let Ok(keychain_key) = crate::secrets::keychain::get_master_key() { + keychain_key.iter().map(|b| format!("{:02x}", b)).collect() + } else { + return Err(SetupError::Config( + "Secrets not configured. Run full setup or set SECRETS_MASTER_KEY.".to_string(), + )); + }; + + let crypto = SecretsCrypto::new(SecretString::from(key)) + .map_err(|e| SetupError::Config(e.to_string()))?; + self.secrets_crypto = Some(Arc::new(crypto)); + Arc::clone(self.secrets_crypto.as_ref().unwrap()) + }; + + Ok(SecretsContext::new(pool, crypto, "default")) + } + + /// Step 6: Channel configuration. async fn step_channels(&mut self) -> Result<(), SetupError> { // First, configure tunnel (shared across all channels that need webhooks) match setup_tunnel() { @@ -359,12 +604,20 @@ impl SetupWizard { let options_refs: Vec<(&str, bool)> = options.iter().map(|(s, b)| (s.as_str(), *b)).collect(); - let selected = select_many("Which channels do you want to enable?", &options_refs)?; + let selected = select_many("Which channels do you want to enable?", &options_refs) + .map_err(SetupError::Io)?; // Determine if we need secrets context let needs_secrets = selected.iter().any(|&i| i >= 1); let secrets = if needs_secrets { - Some(self.init_secrets_context().await?) + match self.init_secrets_context().await { + Ok(ctx) => Some(ctx), + Err(e) => { + print_info(&format!("Secrets not available: {}", e)); + print_info("Channel tokens must be set via environment variables."); + None + } + } } else { None }; @@ -376,6 +629,10 @@ impl SetupWizard { let result = setup_http(ctx).await.map_err(SetupError::Channel)?; self.settings.channels.http_enabled = result.enabled; self.settings.channels.http_port = Some(result.port); + } else { + self.settings.channels.http_enabled = true; + self.settings.channels.http_port = Some(8080); + print_info("HTTP webhook enabled on port 8080 (set HTTP_WEBHOOK_SECRET in env)"); } } else { self.settings.channels.http_enabled = false; @@ -418,6 +675,13 @@ impl SetupWizard { if result.enabled { enabled_wasm_channels.push(result.channel_name); } + } else { + // No secrets context, just enable the channel + print_info(&format!( + "{} enabled (configure tokens via environment)", + capitalize_first(channel_name) + )); + enabled_wasm_channels.push(channel_name.clone()); } } } @@ -426,6 +690,45 @@ impl SetupWizard { Ok(()) } + /// Step 7: Heartbeat configuration. + fn step_heartbeat(&mut self) -> Result<(), SetupError> { + print_info("Heartbeat runs periodic background tasks (e.g., checking your calendar,"); + print_info("monitoring for notifications, running scheduled workflows)."); + println!(); + + if !confirm("Enable heartbeat?", false).map_err(SetupError::Io)? { + self.settings.heartbeat.enabled = false; + print_info("Heartbeat disabled."); + return Ok(()); + } + + self.settings.heartbeat.enabled = true; + + // Interval + let interval_str = optional_input("Check interval in minutes", Some("default: 30")) + .map_err(SetupError::Io)?; + + if let Some(s) = interval_str { + if let Ok(mins) = s.parse::() { + self.settings.heartbeat.interval_secs = mins * 60; + } + } else { + self.settings.heartbeat.interval_secs = 1800; // 30 minutes + } + + // Notify channel + let notify_channel = optional_input("Notify channel on findings", Some("e.g., telegram")) + .map_err(SetupError::Io)?; + self.settings.heartbeat.notify_channel = notify_channel; + + print_success(&format!( + "Heartbeat enabled (every {} minutes)", + self.settings.heartbeat.interval_secs / 60 + )); + + Ok(()) + } + /// Save settings and print summary. fn save_and_summarize(&mut self) -> Result<(), SetupError> { self.settings.setup_completed = true; @@ -445,8 +748,33 @@ impl SetupWizard { println!("Configuration Summary:"); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + if self.settings.database_url.is_some() { + println!(" Database: configured"); + } + + match self.settings.secrets_master_key_source { + KeySource::Keychain => println!(" Security: OS keychain"), + KeySource::Env => println!(" Security: environment variable"), + KeySource::None => println!(" Security: disabled"), + } + if let Some(ref model) = self.settings.selected_model { - println!(" Model: {}", model); + // Truncate long model names + let display = if model.len() > 40 { + format!("{}...", &model[..37]) + } else { + model.clone() + }; + println!(" Model: {}", display); + } + + if self.settings.embeddings.enabled { + println!( + " Embeddings: {} ({})", + self.settings.embeddings.provider, self.settings.embeddings.model + ); + } else { + println!(" Embeddings: disabled"); } if let Some(ref tunnel_url) = self.settings.tunnel.public_url { @@ -474,30 +802,61 @@ impl SetupWizard { ); } + if self.settings.heartbeat.enabled { + println!( + " Heartbeat: every {} minutes", + self.settings.heartbeat.interval_secs / 60 + ); + } + println!(); println!("To start the agent, run:"); println!(" ironclaw"); println!(); + println!("To change settings later:"); + println!(" ironclaw config set "); + println!(" ironclaw setup"); + println!(); Ok(()) } } -/// Generate a random 32-byte master key as hex string. -fn generate_master_key() -> String { - use rand::RngCore; - let mut rng = rand::thread_rng(); - let mut bytes = [0u8; 32]; - rng.fill_bytes(&mut bytes); - bytes.iter().map(|b| format!("{:02x}", b)).collect() -} - impl Default for SetupWizard { fn default() -> Self { Self::new() } } +/// Mask password in a database URL for display. +fn mask_password_in_url(url: &str) -> String { + // URL format: scheme://user:password@host/database + // Find "://" to locate start of credentials + let Some(scheme_end) = url.find("://") else { + return url.to_string(); + }; + let credentials_start = scheme_end + 3; // After "://" + + // Find "@" to locate end of credentials + let Some(at_pos) = url[credentials_start..].find('@') else { + return url.to_string(); + }; + let at_abs = credentials_start + at_pos; + + // Find ":" in the credentials section (separates user from password) + let credentials = &url[credentials_start..at_abs]; + let Some(colon_pos) = credentials.find(':') else { + return url.to_string(); + }; + + // Build masked URL: scheme://user:****@host/database + let scheme = &url[..credentials_start]; // "postgres://" + let username = &credentials[..colon_pos]; // "user" + let after_at = &url[at_abs..]; // "@localhost/db" + + format!("{}{}:****{}", scheme, username, after_at) +} + /// Discover WASM channels in a directory. /// /// Returns a list of (channel_name, capabilities_file) pairs. @@ -595,8 +954,23 @@ mod tests { } #[test] - fn test_generate_master_key() { - let key = generate_master_key(); - assert_eq!(key.len(), 64); // 32 bytes = 64 hex chars + fn test_mask_password_in_url() { + assert_eq!( + mask_password_in_url("postgres://user:secret@localhost/db"), + "postgres://user:****@localhost/db" + ); + + // URL without password + assert_eq!( + mask_password_in_url("postgres://localhost/db"), + "postgres://localhost/db" + ); + } + + #[test] + fn test_capitalize_first() { + assert_eq!(capitalize_first("telegram"), "Telegram"); + assert_eq!(capitalize_first("CAPS"), "CAPS"); + assert_eq!(capitalize_first(""), ""); } }