mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
co-authored by
Claude Opus 4.5
parent
598dd43b1c
commit
0ab9643843
Generated
+515
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(""), "");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user