Add interactive setup wizard and persistent settings

- Add 7-step setup wizard: database, security, auth, model, embeddings, channels, heartbeat
- Store settings in ~/.ironclaw/settings.json with env var > settings > default priority
- Add OS keychain integration for secrets master key (macOS/Linux)
- Add `ironclaw config` CLI subcommand (list/get/set/reset/path)
- Expand Settings struct with all configuration fields
- Enhanced setup detection to auto-trigger wizard when needed

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-05 09:50:52 -08:00
co-authored by Claude Opus 4.5
parent 598dd43b1c
commit 0ab9643843
13 changed files with 2417 additions and 157 deletions
Generated
+515
View File
@@ -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",
]
+9
View File
@@ -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"] }
+195
View File
@@ -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<String>,
},
/// 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<String>) -> 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");
}
}
+7 -4
View File
@@ -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 {
+150 -38
View File
@@ -131,16 +131,30 @@ pub struct DatabaseConfig {
impl DatabaseConfig {
fn from_env() -> Result<Self, ConfigError> {
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<Self, ConfigError> {
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<Self, ConfigError> {
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<SecretString>,
/// 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<Self, ConfigError> {
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<Self, ConfigError> {
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<String, ConfigError> {
std::env::var(key).map_err(|_| ConfigError::MissingEnvVar(key.to_string()))
}
fn optional_env(key: &str) -> Result<Option<String>, ConfigError> {
match std::env::var(key) {
Ok(val) if val.is_empty() => Ok(None),
+3
View File
@@ -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 },
+50 -8
View File
@@ -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
+346
View File
@@ -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<u8> {
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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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
}
}
+8
View File
@@ -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;
+3
View File
@@ -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.
+649 -6
View File
@@ -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<String>,
/// 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<String>,
/// Database pool size.
#[serde(default)]
pub database_pool_size: Option<usize>,
// === 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<String>,
// === 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<u16>,
/// HTTP webhook host.
#[serde(default)]
pub http_host: Option<String>,
/// 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<String>,
/// 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<PathBuf>,
}
/// 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<String>,
/// User ID to notify on heartbeat findings.
#[serde(default)]
pub notify_user: Option<String>,
}
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<PathBuf>,
/// 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<PathBuf>,
}
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<String>,
}
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<PathBuf>,
/// 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<String> {
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::<bool>()
.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::<u64>().map_err(|_| {
format!("Expected integer for {}, got '{}'", path, value)
})?;
serde_json::Value::Number(n.into())
} else if n.is_i64() {
let n = value.parse::<i64>().map_err(|_| {
format!("Expected integer for {}, got '{}'", path, value)
})?;
serde_json::Value::Number(n.into())
} else {
let n = value.parse::<f64>().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<T>, 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");
}
}
+11 -4
View File
@@ -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};
+471 -97
View File
@@ -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<Arc<SessionManager>>,
/// Database pool (created during setup).
db_pool: Option<deadpool_postgres::Pool>,
/// Secrets crypto (created during setup).
secrets_crypto: Option<Arc<SecretsCrypto>>,
}
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<SessionManager>) -> Vec<String> {
// 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<SecretsContext, SetupError> {
// 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<SecretsContext, SetupError> {
// 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::<u64>() {
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 <setting> <value>");
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(""), "");
}
}