diff --git a/CLAUDE.md b/CLAUDE.md index 6ae73405..1c5d81eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,17 @@ -# NEAR Agent Development Guide +# IronClaw Development Guide ## Project Overview -LLM-powered autonomous agent for the NEAR AI marketplace. Features: -- **Multi-channel input**: Full TUI (Ratatui), HTTP webhook with secret auth (Slack/Telegram stubs) +**IronClaw** is a secure personal AI assistant that protects your data and expands its capabilities on the fly. + +### Core Philosophy +- **User-first security** - Your data stays yours, encrypted and local +- **Self-expanding** - Build new tools dynamically without vendor dependency +- **Defense in depth** - Multiple security layers against prompt injection and data exfiltration +- **Always available** - Multi-channel access with proactive background execution + +### Features +- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, Telegram, WhatsApp, Slack (WASM channels) - **Parallel job execution** with state machine and self-repair for stuck jobs - **Extensible tools**: Built-in tools, WASM sandbox, MCP client, dynamic builder - **Persistent memory**: Workspace with hybrid search (FTS + vector via RRF) @@ -26,7 +34,7 @@ cargo test cargo test test_name # Run with logging -RUST_LOG=near_agent=debug cargo run +RUST_LOG=ironclaw=debug cargo run ``` ## Project Structure @@ -201,7 +209,7 @@ Pending -> InProgress -> Completed -> Submitted -> Accepted Environment variables (see `.env.example`): ```bash -DATABASE_URL=postgres://user:pass@localhost/near_agent +DATABASE_URL=postgres://user:pass@localhost/ironclaw # NEAR AI (required) NEARAI_SESSION_TOKEN=sess_... @@ -209,7 +217,7 @@ NEARAI_MODEL=claude-3-5-sonnet-20241022 NEARAI_BASE_URL=https://private.near.ai # Agent settings -AGENT_NAME=near-agent +AGENT_NAME=ironclaw MAX_PARALLEL_JOBS=5 # Embeddings (for semantic memory search) @@ -328,13 +336,13 @@ Key test patterns: ```bash # Verbose logging -RUST_LOG=near_agent=trace cargo run +RUST_LOG=ironclaw=trace cargo run # Just the agent module -RUST_LOG=near_agent::agent=debug cargo run +RUST_LOG=ironclaw::agent=debug cargo run # With HTTP request logging -RUST_LOG=near_agent=debug,tower_http=debug cargo run +RUST_LOG=ironclaw=debug,tower_http=debug cargo run ``` ## Code Style diff --git a/Cargo.lock b/Cargo.lock index 3f545e36..d8635375 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,137 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.3", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.3", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.3", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -316,6 +447,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bollard" version = "0.18.1" @@ -525,6 +678,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.55" @@ -642,6 +804,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -1133,6 +1304,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1160,6 +1358,27 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.2.0" @@ -1292,6 +1511,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -1828,6 +2060,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -1876,6 +2109,65 @@ dependencies = [ "serde", ] +[[package]] +name = "ironclaw" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "aho-corasick", + "anyhow", + "async-trait", + "axum", + "blake3", + "bollard", + "bytes", + "chrono", + "clap", + "crossterm", + "deadpool-postgres", + "dirs 6.0.0", + "dotenvy", + "futures", + "hkdf", + "http-body-util", + "hyper", + "hyper-util", + "open", + "pgvector", + "postgres-types", + "pretty_assertions", + "rand 0.8.5", + "ratatui", + "refinery", + "regex", + "reqwest", + "rust_decimal", + "rust_decimal_macros", + "secrecy", + "secret-service", + "security-framework", + "serde", + "serde_json", + "sha2", + "tempfile", + "testcontainers-modules", + "thiserror 2.0.18", + "tokio", + "tokio-postgres", + "tokio-stream", + "tokio-test", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "urlencoding", + "uuid", + "wasmparser 0.220.1", + "wasmtime", + "wasmtime-wasi", + "zbus", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -2109,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" @@ -2128,59 +2429,16 @@ dependencies = [ ] [[package]] -name = "near-agent" -version = "0.1.0" +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "aes-gcm", - "aho-corasick", - "anyhow", - "async-trait", - "axum", - "blake3", - "bollard", - "bytes", - "chrono", - "clap", - "crossterm", - "deadpool-postgres", - "dirs 6.0.0", - "dotenvy", - "futures", - "hkdf", - "http-body-util", - "hyper", - "hyper-util", - "open", - "pgvector", - "postgres-types", - "pretty_assertions", - "rand 0.8.5", - "ratatui", - "refinery", - "regex", - "reqwest", - "rust_decimal", - "rust_decimal_macros", - "secrecy", - "serde", - "serde_json", - "sha2", - "tempfile", - "testcontainers-modules", - "thiserror 2.0.18", - "tokio", - "tokio-postgres", - "tokio-stream", - "tokio-test", - "tower", - "tower-http", - "tracing", - "tracing-subscriber", - "urlencoding", - "uuid", - "wasmparser 0.220.1", - "wasmtime", - "wasmtime-wasi", + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", ] [[package]] @@ -2192,12 +2450,76 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2279,6 +2601,22 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2386,12 +2724,37 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + [[package]] name = "polyval" version = "0.6.2" @@ -3172,6 +3535,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.5", + "serde", + "sha2", + "zbus", +] + [[package]] name = "security-framework" version = "3.5.1" @@ -3322,6 +3704,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3760,6 +4153,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -4065,6 +4459,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -5182,6 +5587,16 @@ dependencies = [ "rustix 1.1.3", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yansi" version = "1.0.1" @@ -5211,6 +5626,69 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tokio", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.37" @@ -5324,3 +5802,40 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] diff --git a/Cargo.toml b/Cargo.toml index 8af7ba00..2dc13f28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [package] -name = "near-agent" +name = "ironclaw" version = "0.1.0" edition = "2024" rust-version = "1.85" -description = "LLM-powered autonomous agent for the NEAR AI marketplace" +description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" license = "MIT OR Apache-2.0" [dependencies] @@ -98,6 +98,15 @@ hyper-util = { version = "0.1", features = ["server", "tokio", "http1", "http2"] http-body-util = "0.1" bytes = "1" +# macOS keychain +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3" + +# Linux secret-service (GNOME Keyring, KWallet) +[target.'cfg(target_os = "linux")'.dependencies] +secret-service = { version = "4", features = ["rt-tokio-crypto-rust"] } +zbus = "4" + [dev-dependencies] tokio-test = "0.4" testcontainers-modules = { version = "0.11", features = ["postgres"] } diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 3d44f86f..6f791052 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -208,7 +208,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Dynamic loading | ✅ | ✅ | WASM modules | | Manifest validation | ✅ | ✅ | WASM metadata | | HTTP path registration | ✅ | ❌ | Plugin routes | -| Workspace-relative install | ✅ | ✅ | ~/.near-agent/tools/ | +| Workspace-relative install | ✅ | ✅ | ~/.ironclaw/tools/ | | Channel plugins | ✅ | ✅ | WASM channels | | Auth plugins | ✅ | ❌ | | | Memory plugins | ✅ | ❌ | Custom backends | @@ -233,7 +233,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Config validation/schema | ✅ | ✅ | Type-safe Config struct | | Hot-reload | ✅ | ❌ | | | Legacy migration | ✅ | ➖ | | -| State directory | ✅ `~/.openclaw-state/` | ✅ `~/.near-agent/` | | +| State directory | ✅ `~/.openclaw-state/` | ✅ `~/.ironclaw/` | | | Credentials directory | ✅ | ✅ | Session files | ### Owner: _Unassigned_ diff --git a/README.md b/README.md index 675b051a..d815c833 100644 --- a/README.md +++ b/README.md @@ -5,58 +5,60 @@

IronClaw

- LLM-powered autonomous agent for the NEAR AI marketplace + Your secure personal AI assistant, always on your side

+ PhilosophyFeatures • - ParityInstallationConfiguration • - Architecture • - Security + Security • + Architecture

--- +## Philosophy + +IronClaw is built on a simple principle: **your AI assistant should work for you, not against you**. + +In a world where AI systems are increasingly opaque about data handling and aligned with corporate interests, IronClaw takes a different approach: + +- **Your data stays yours** - All information is stored locally, encrypted, and never leaves your control +- **Transparency by design** - Open source, auditable, no hidden telemetry or data harvesting +- **Self-expanding capabilities** - Build new tools on the fly without waiting for vendor updates +- **Defense in depth** - Multiple security layers protect against prompt injection and data exfiltration + +IronClaw is the AI assistant you can actually trust with your personal and professional life. + ## Features -- **Multi-channel input** - CLI, HTTP webhooks, Slack, Telegram -- **Parallel job execution** - Concurrent task processing with isolated contexts -- **Extensible tools** - Built-in tools + MCP protocol + WASM sandbox -- **Persistent memory** - Hybrid search (FTS + vector) with chunked documents -- **Prompt injection defense** - Pattern detection, content sanitization, policy enforcement -- **Self-repair** - Automatic detection and recovery of stuck jobs -- **Heartbeat system** - Proactive periodic execution for background tasks +### Security First -## OpenClaw Feature Parity +- **WASM Sandbox** - Untrusted tools run in isolated WebAssembly containers with capability-based permissions +- **Credential Protection** - Secrets are never exposed to tools; injected at the host boundary with leak detection +- **Prompt Injection Defense** - Pattern detection, content sanitization, and policy enforcement +- **Endpoint Allowlisting** - HTTP requests only to explicitly approved hosts and paths -IronClaw is a Rust reimplementation inspired by [OpenClaw](https://github.com/openclaw/openclaw). See [FEATURE_PARITY.md](FEATURE_PARITY.md) for the complete tracking matrix. +### Always Available -### Status Summary +- **Multi-channel** - Reach your assistant via CLI, Telegram, WhatsApp, Slack, or HTTP webhooks +- **Heartbeat System** - Proactive background execution for monitoring and maintenance tasks +- **Parallel Jobs** - Handle multiple requests concurrently with isolated contexts +- **Self-repair** - Automatic detection and recovery of stuck operations -| Category | Status | Notes | -|----------|--------|-------| -| **Core Agent** | ✅ Complete | Sessions, workers, routing, context compaction | -| **Channels** | 🚧 Partial | TUI, HTTP, REPL, WASM channels done; messaging platforms pending | -| **Tools** | ✅ Complete | Built-in, MCP, WASM sandbox, dynamic builder | -| **Memory** | ✅ Complete | Hybrid search, embeddings, workspace filesystem | -| **Security** | ✅ Complete | WASM sandbox, prompt injection, leak detection | -| **Automation** | 🚧 Partial | Heartbeat done; cron, hooks pending | -| **Gateway** | ❌ Pending | WebSocket control plane, service management | -| **Web UI** | ❌ Pending | Control dashboard, WebChat | -| **Mobile/Desktop** | 🚫 Out of scope | Focus on server-side initially | +### Self-Expanding -### Key Differences from OpenClaw +- **Dynamic Tool Building** - Describe what you need, and IronClaw builds it as a WASM tool +- **MCP Protocol** - Connect to Model Context Protocol servers for additional capabilities +- **Plugin Architecture** - Drop in new WASM tools and channels without restarting -- **Rust vs TypeScript** - Native performance, single binary -- **WASM sandbox vs Docker** - Lightweight, capability-based security -- **PostgreSQL vs SQLite** - Production-ready persistence -- **NEAR AI primary** - Session-based auth with model proxy +### Persistent Memory -### Contributing - -Pick an unassigned feature area in [FEATURE_PARITY.md](FEATURE_PARITY.md) and claim it. +- **Hybrid Search** - Full-text + vector search using Reciprocal Rank Fusion +- **Workspace Filesystem** - Flexible path-based storage for notes, logs, and context +- **Identity Files** - Maintain consistent personality and preferences across sessions ## Installation @@ -64,14 +66,14 @@ Pick an unassigned feature area in [FEATURE_PARITY.md](FEATURE_PARITY.md) and cl - Rust 1.85+ - PostgreSQL 15+ with pgvector extension -- NEAR AI session token +- NEAR AI session token (or other LLM provider) ### Build ```bash # Clone the repository -git clone https://github.com/nearai/near-agent.git -cd near-agent +git clone https://github.com/nearai/ironclaw.git +cd ironclaw # Build cargo build --release @@ -84,10 +86,10 @@ cargo test ```bash # Create database -createdb near_agent +createdb ironclaw # Enable pgvector -psql near_agent -c "CREATE EXTENSION IF NOT EXISTS vector;" +psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;" # Run migrations refinery migrate -c refinery.toml @@ -99,12 +101,13 @@ Copy `.env.example` to `.env` and configure: ```bash # Required -DATABASE_URL=postgres://user:pass@localhost/near_agent +DATABASE_URL=postgres://user:pass@localhost/ironclaw NEARAI_SESSION_TOKEN=sess_... # Optional: Enable channels -SLACK_BOT_TOKEN=xoxb-... TELEGRAM_BOT_TOKEN=... +WHATSAPP_ACCESS_TOKEN=... +SLACK_BOT_TOKEN=xoxb-... HTTP_PORT=8080 ``` @@ -118,15 +121,51 @@ HTTP_PORT=8080 | `AGENT_MAX_PARALLEL_JOBS` | Max concurrent jobs (default: 5) | No | | `SECRETS_MASTER_KEY` | 32+ byte key for secret encryption | For secrets | +## Security + +IronClaw implements defense in depth to protect your data and prevent misuse. + +### WASM Sandbox + +All untrusted tools run in isolated WebAssembly containers: + +- **Capability-based permissions** - Explicit opt-in for HTTP, secrets, tool invocation +- **Endpoint allowlisting** - HTTP requests only to approved hosts/paths +- **Credential injection** - Secrets injected at host boundary, never exposed to WASM code +- **Leak detection** - Scans requests and responses for secret exfiltration attempts +- **Rate limiting** - Per-tool request limits to prevent abuse +- **Resource limits** - Memory, CPU, and execution time constraints + +``` +WASM ──► Allowlist ──► Leak Scan ──► Credential ──► Execute ──► Leak Scan ──► WASM + Validator (request) Injector Request (response) +``` + +### Prompt Injection Defense + +External content passes through multiple security layers: + +- Pattern-based detection of injection attempts +- Content sanitization and escaping +- Policy rules with severity levels (Block/Warn/Review/Sanitize) +- Tool output wrapping for safe LLM context injection + +### Data Protection + +- All data stored locally in your PostgreSQL database +- Secrets encrypted with AES-256-GCM +- No telemetry, analytics, or data sharing +- Full audit log of all tool executions + ## Architecture ``` ┌─────────────────────────────────────────────────────────────────┐ │ Channels │ -│ ┌─────┐ ┌──────┐ ┌───────┐ ┌──────────┐ │ -│ │ CLI │ │ HTTP │ │ Slack │ │ Telegram │ │ -│ └──┬──┘ └──┬───┘ └───┬───┘ └────┬─────┘ │ -│ └────────┴──────────┴───────────┘ │ +│ ┌─────┐ ┌──────────┐ ┌──────────┐ ┌───────┐ │ +│ │ CLI │ │ Telegram │ │ WhatsApp │ │ Slack │ │ +│ └──┬──┘ └────┬─────┘ └────┬─────┘ └───┬───┘ │ +│ └──────────┴─────────────┴────────────┘ │ │ │ │ │ ┌────▼────┐ │ │ │ Router │ Intent classification │ @@ -165,31 +204,6 @@ HTTP_PORT=8080 | **Workspace** | Persistent memory with hybrid search | | **Safety Layer** | Prompt injection defense and content sanitization | -## Security - -### WASM Sandbox - -Untrusted tools run in a sandboxed WASM environment with: - -- **Capability-based permissions** - Explicit opt-in for HTTP, secrets, tool invocation -- **Endpoint allowlisting** - HTTP requests only to approved hosts/paths -- **Credential injection** - Secrets injected at host boundary, never exposed to WASM -- **Leak detection** - Scans requests and responses for secret exfiltration -- **Rate limiting** - Per-tool request limits (per-minute and per-hour) -- **Resource limits** - Memory, CPU, and execution time constraints - -``` -WASM ──► Allowlist ──► Leak Scan ──► Credential ──► Execute ──► Leak Scan ──► WASM - Validator (request) Injector Request (response) -``` - -### Prompt Injection Defense - -- Pattern-based detection of injection attempts -- Content sanitization and escaping -- Policy rules with severity levels (Block/Warn/Review/Sanitize) -- Tool output wrapping for LLM context - ## Usage ### CLI Mode @@ -199,7 +213,7 @@ WASM ──► Allowlist ──► Leak Scan ──► Credential ──► Exec cargo run # With debug logging -RUST_LOG=near_agent=debug cargo run +RUST_LOG=ironclaw=debug cargo run ``` ### HTTP Server @@ -211,7 +225,7 @@ HTTP_PORT=8080 cargo run # Send a request curl -X POST http://localhost:8080/webhook \ -H "Content-Type: application/json" \ - -d '{"message": "Hello, agent!"}' + -d '{"message": "Hello, IronClaw!"}' ``` ## Development @@ -230,6 +244,17 @@ cargo test cargo test test_name ``` +## OpenClaw Heritage + +IronClaw is a Rust reimplementation inspired by [OpenClaw](https://github.com/openclaw/openclaw). See [FEATURE_PARITY.md](FEATURE_PARITY.md) for the complete tracking matrix. + +Key differences: + +- **Rust vs TypeScript** - Native performance, memory safety, single binary +- **WASM sandbox vs Docker** - Lightweight, capability-based security +- **PostgreSQL vs SQLite** - Production-ready persistence +- **Security-first design** - Multiple defense layers, credential protection + ## License Licensed under either of: diff --git a/channels-src/slack/Cargo.toml b/channels-src/slack/Cargo.toml index c434519e..18d2fd39 100644 --- a/channels-src/slack/Cargo.toml +++ b/channels-src/slack/Cargo.toml @@ -2,7 +2,7 @@ name = "slack-channel" version = "0.1.0" edition = "2021" -description = "Slack Events API channel for NEAR Agent" +description = "Slack Events API channel for IronClaw" license = "MIT OR Apache-2.0" [lib] diff --git a/channels-src/slack/src/lib.rs b/channels-src/slack/src/lib.rs index 18748833..adb2c5aa 100644 --- a/channels-src/slack/src/lib.rs +++ b/channels-src/slack/src/lib.rs @@ -1,4 +1,4 @@ -//! Slack Events API channel for NEAR Agent. +//! Slack Events API channel for IronClaw. //! //! This WASM component implements the channel interface for handling Slack //! webhooks and sending messages back to Slack. diff --git a/channels-src/telegram/Cargo.toml b/channels-src/telegram/Cargo.toml index d33266e5..855aa8fa 100644 --- a/channels-src/telegram/Cargo.toml +++ b/channels-src/telegram/Cargo.toml @@ -2,7 +2,7 @@ name = "telegram-channel" version = "0.1.0" edition = "2021" -description = "Telegram Bot API channel for NEAR Agent" +description = "Telegram Bot API channel for IronClaw" license = "MIT OR Apache-2.0" [lib] diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs index 8b5c7517..146b1e11 100644 --- a/channels-src/telegram/src/lib.rs +++ b/channels-src/telegram/src/lib.rs @@ -1,7 +1,7 @@ // Telegram API types have fields reserved for future use (entities, reply threading, etc.) #![allow(dead_code)] -//! Telegram Bot API channel for NEAR Agent. +//! Telegram Bot API channel for IronClaw. //! //! This WASM component implements the channel interface for handling Telegram //! webhooks and sending messages back via the Bot API. diff --git a/channels/whatsapp/Cargo.toml b/channels/whatsapp/Cargo.toml index 6e91d1f3..8dd03499 100644 --- a/channels/whatsapp/Cargo.toml +++ b/channels/whatsapp/Cargo.toml @@ -2,7 +2,7 @@ name = "whatsapp-channel" version = "0.1.0" edition = "2021" -description = "WhatsApp channel for near-agent using the Cloud API" +description = "WhatsApp Cloud API channel for IronClaw" [lib] crate-type = ["cdylib"] diff --git a/channels/whatsapp/src/lib.rs b/channels/whatsapp/src/lib.rs index 6633ba4a..346c0f23 100644 --- a/channels/whatsapp/src/lib.rs +++ b/channels/whatsapp/src/lib.rs @@ -1,7 +1,7 @@ // WhatsApp API types have fields reserved for future use (contacts, statuses, etc.) #![allow(dead_code)] -//! WhatsApp Cloud API channel for NEAR Agent. +//! WhatsApp Cloud API channel for IronClaw. //! //! This WASM component implements the channel interface for handling WhatsApp //! webhooks and sending messages back via the Cloud API. diff --git a/docs/BUILDING_CHANNELS.md b/docs/BUILDING_CHANNELS.md index 7032bda3..a819bc01 100644 --- a/docs/BUILDING_CHANNELS.md +++ b/docs/BUILDING_CHANNELS.md @@ -1,6 +1,6 @@ # Building WASM Channels -This guide covers how to build WASM channel modules for the NEAR Agent. +This guide covers how to build WASM channel modules for IronClaw. ## Overview @@ -19,7 +19,7 @@ channels/ # Or channels-src/ After building, deploy to: ``` -~/.near-agent/channels/ +~/.ironclaw/channels/ ├── my-channel.wasm └── my-channel.capabilities.json ``` @@ -31,7 +31,7 @@ After building, deploy to: name = "my-channel" version = "0.1.0" edition = "2021" -description = "My messaging platform channel for NEAR Agent" +description = "My messaging platform channel for IronClaw" [lib] crate-type = ["cdylib"] @@ -251,9 +251,9 @@ Create `my-channel.capabilities.json`: cd channels/my-channel cargo component build --release -# Deploy to ~/.near-agent/channels/ -cp target/wasm32-wasip1/release/my_channel.wasm ~/.near-agent/channels/my-channel.wasm -cp my-channel.capabilities.json ~/.near-agent/channels/ +# Deploy to ~/.ironclaw/channels/ +cp target/wasm32-wasip1/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm +cp my-channel.capabilities.json ~/.ironclaw/channels/ ``` ## Host Functions Available diff --git a/examples/wasm-tools/slack/Cargo.toml b/examples/wasm-tools/slack/Cargo.toml index 92e34820..83425bc8 100644 --- a/examples/wasm-tools/slack/Cargo.toml +++ b/examples/wasm-tools/slack/Cargo.toml @@ -2,7 +2,7 @@ name = "slack-tool" version = "0.1.0" edition = "2021" -description = "Slack integration tool for NEAR Agent (WASM component)" +description = "Slack integration tool for IronClaw (WASM component)" license = "MIT OR Apache-2.0" publish = false diff --git a/examples/wasm-tools/slack/README.md b/examples/wasm-tools/slack/README.md index 2836643a..aee92f18 100644 --- a/examples/wasm-tools/slack/README.md +++ b/examples/wasm-tools/slack/README.md @@ -1,6 +1,6 @@ # Slack WASM Tool -A standalone WASM component that provides Slack integration for NEAR Agent. This serves as both a functional tool and a template for building custom WASM tools. +A standalone WASM component that provides Slack integration for IronClaw. This serves as both a functional tool and a template for building custom WASM tools. ## Features @@ -50,9 +50,9 @@ target/wasm32-wasip2/release/slack_tool.wasm Copy the WASM and capabilities files to the agent's tools directory: ```bash -mkdir -p ~/.near-agent/tools -cp target/wasm32-wasip2/release/slack_tool.wasm ~/.near-agent/tools/slack.wasm -cp slack.capabilities.json ~/.near-agent/tools/ +mkdir -p ~/.ironclaw/tools +cp target/wasm32-wasip2/release/slack_tool.wasm ~/.ironclaw/tools/slack.wasm +cp slack.capabilities.json ~/.ironclaw/tools/ ``` ### Option B: Database Storage (Production) @@ -60,7 +60,7 @@ cp slack.capabilities.json ~/.near-agent/tools/ Use the agent CLI or API to store the tool: ```bash -near-agent tool install \ +ironclaw tool install \ --name slack \ --wasm target/wasm32-wasip2/release/slack_tool.wasm \ --capabilities slack.capabilities.json @@ -71,7 +71,7 @@ near-agent tool install \ Store your Slack bot token as a secret: ```bash -near-agent secret set slack_bot_token "xoxb-your-token-here" +ironclaw secret set slack_bot_token "xoxb-your-token-here" ``` Or via SQL: @@ -88,7 +88,7 @@ VALUES ('your_user_id', 'slack_bot_token', ...); { "action": "send_message", "channel": "#general", - "text": "Hello from the NEAR Agent!" + "text": "Hello from IronClaw!" } ``` @@ -214,7 +214,7 @@ world sandboxed-tool { Ensure you've stored the secret: ```bash -near-agent secret set slack_bot_token "xoxb-..." +ironclaw secret set slack_bot_token "xoxb-..." ``` ### "Endpoint not in allowlist" diff --git a/examples/wasm-tools/slack/src/lib.rs b/examples/wasm-tools/slack/src/lib.rs index 432ba31f..04cc2b70 100644 --- a/examples/wasm-tools/slack/src/lib.rs +++ b/examples/wasm-tools/slack/src/lib.rs @@ -1,4 +1,4 @@ -//! Slack WASM Tool for NEAR Agent. +//! Slack WASM Tool for IronClaw. //! //! This is a standalone WASM component that provides Slack integration. //! It demonstrates how to build external tools that can be dynamically diff --git a/src/channels/cli/app.rs b/src/channels/cli/app.rs index a0e76113..8bae51bb 100644 --- a/src/channels/cli/app.rs +++ b/src/channels/cli/app.rs @@ -147,7 +147,7 @@ impl AppState { Self { mode: InputMode::Editing, messages: vec![ChatMessage::system( - "Welcome to NEAR Agent. Type a message or /help for commands.", + "Welcome to IronClaw. Type a message or /help for commands.", )], composer: ChatComposer::new(), approval: None, diff --git a/src/channels/repl.rs b/src/channels/repl.rs index c9c3f452..1046460e 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -64,7 +64,7 @@ impl Default for ReplChannel { fn print_help() { println!( r#" -NEAR Agent REPL - Interactive debugging mode +IronClaw REPL - Interactive debugging mode Commands: /help Show this help message @@ -116,7 +116,7 @@ impl Channel for ReplChannel { let stdin = io::stdin(); let mut stdout = io::stdout(); - println!("NEAR Agent REPL - Type /help for commands, /quit to exit"); + println!("IronClaw REPL - Type /help for commands, /quit to exit"); println!(); loop { diff --git a/src/channels/wasm/loader.rs b/src/channels/wasm/loader.rs index 5e94d38c..6ab145f2 100644 --- a/src/channels/wasm/loader.rs +++ b/src/channels/wasm/loader.rs @@ -1,6 +1,6 @@ //! WASM channel loader for loading channels from files or directories. //! -//! Loads WASM channel modules from the filesystem (default: ~/.near-agent/channels/). +//! Loads WASM channel modules from the filesystem (default: ~/.ironclaw/channels/). //! Each channel consists of: //! - `.wasm` - The compiled WASM component //! - `.capabilities.json` - Channel capabilities and configuration @@ -329,12 +329,12 @@ pub struct DiscoveredChannel { /// Get the default channels directory path. /// -/// Returns ~/.near-agent/channels/ +/// Returns ~/.ironclaw/channels/ #[allow(dead_code)] pub fn default_channels_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) - .join(".near-agent") + .join(".ironclaw") .join("channels") } diff --git a/src/channels/wasm/mod.rs b/src/channels/wasm/mod.rs index 24905fc0..7535d033 100644 --- a/src/channels/wasm/mod.rs +++ b/src/channels/wasm/mod.rs @@ -63,14 +63,14 @@ //! # Example Usage //! //! ```ignore -//! use near_agent::channels::wasm::{WasmChannelLoader, WasmChannelRuntime}; +//! use ironclaw::channels::wasm::{WasmChannelLoader, WasmChannelRuntime}; //! //! // Create runtime (can share engine with tool runtime) //! let runtime = WasmChannelRuntime::new(config)?; //! //! // Load channels from directory //! let loader = WasmChannelLoader::new(runtime); -//! let channels = loader.load_from_dir(Path::new("~/.near-agent/channels/")).await?; +//! let channels = loader.load_from_dir(Path::new("~/.ironclaw/channels/")).await?; //! //! // Add to channel manager //! for channel in channels { diff --git a/src/cli/config.rs b/src/cli/config.rs new file mode 100644 index 00000000..080cccf1 --- /dev/null +++ b/src/cli/config.rs @@ -0,0 +1,195 @@ +//! Configuration management CLI commands. +//! +//! Commands for viewing and modifying settings. + +use clap::Subcommand; + +use crate::settings::Settings; + +#[derive(Subcommand, Debug, Clone)] +pub enum ConfigCommand { + /// List all settings and their current values + List { + /// Show only settings matching this prefix (e.g., "agent", "heartbeat") + #[arg(short, long)] + filter: Option, + }, + + /// Get a specific setting value + Get { + /// Setting path (e.g., "agent.max_parallel_jobs") + path: String, + }, + + /// Set a setting value + Set { + /// Setting path (e.g., "agent.max_parallel_jobs") + path: String, + + /// Value to set + value: String, + }, + + /// Reset a setting to its default value + Reset { + /// Setting path (e.g., "agent.max_parallel_jobs") + path: String, + }, + + /// Show the settings file path + Path, +} + +/// Run a config command. +pub fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> { + match cmd { + ConfigCommand::List { filter } => list_settings(filter), + ConfigCommand::Get { path } => get_setting(&path), + ConfigCommand::Set { path, value } => set_setting(&path, &value), + ConfigCommand::Reset { path } => reset_setting(&path), + ConfigCommand::Path => show_path(), + } +} + +/// List all settings. +fn list_settings(filter: Option) -> anyhow::Result<()> { + let settings = Settings::load(); + let all = settings.list(); + + // Find the longest key for alignment + let max_key_len = all.iter().map(|(k, _)| k.len()).max().unwrap_or(0); + + println!("Settings:"); + println!(); + + for (key, value) in all { + // Skip if filter is set and doesn't match + if let Some(ref f) = filter { + if !key.starts_with(f) { + continue; + } + } + + // Truncate long values for display + let display_value = if value.len() > 60 { + format!("{}...", &value[..57]) + } else { + value + }; + + println!(" {:width$} {}", key, display_value, width = max_key_len); + } + + Ok(()) +} + +/// Get a specific setting. +fn get_setting(path: &str) -> anyhow::Result<()> { + let settings = Settings::load(); + + match settings.get(path) { + Some(value) => { + println!("{}", value); + Ok(()) + } + None => { + anyhow::bail!("Setting not found: {}", path); + } + } +} + +/// Set a setting value. +fn set_setting(path: &str, value: &str) -> anyhow::Result<()> { + let mut settings = Settings::load(); + + // Try to set the value + settings + .set(path, value) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + // Save to disk + settings.save()?; + + println!("Set {} = {}", path, value); + Ok(()) +} + +/// Reset a setting to default. +fn reset_setting(path: &str) -> anyhow::Result<()> { + let mut settings = Settings::load(); + + // Get the default value for display + let default = Settings::default(); + let default_value = default + .get(path) + .ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?; + + // Reset it + settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?; + + // Save to disk + settings.save()?; + + println!("Reset {} to default: {}", path, default_value); + Ok(()) +} + +/// Show the settings file path. +fn show_path() -> anyhow::Result<()> { + let path = Settings::default_path(); + println!("{}", path.display()); + + if path.exists() { + let metadata = std::fs::metadata(&path)?; + println!(" Size: {} bytes", metadata.len()); + if let Ok(modified) = metadata.modified() { + use std::time::SystemTime; + let duration = SystemTime::now() + .duration_since(modified) + .unwrap_or_default(); + let secs = duration.as_secs(); + if secs < 60 { + println!(" Modified: {} seconds ago", secs); + } else if secs < 3600 { + println!(" Modified: {} minutes ago", secs / 60); + } else if secs < 86400 { + println!(" Modified: {} hours ago", secs / 3600); + } else { + println!(" Modified: {} days ago", secs / 86400); + } + } + } else { + println!(" (does not exist, using defaults)"); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_list_settings() { + // Just verify it doesn't panic + let settings = Settings::default(); + let list = settings.list(); + assert!(!list.is_empty()); + } + + #[test] + fn test_get_set_reset() { + let _dir = tempdir().unwrap(); + + let mut settings = Settings::default(); + + // Set a value + settings.set("agent.name", "testbot").unwrap(); + assert_eq!(settings.agent.name, "testbot"); + + // Reset to default + settings.reset("agent.name").unwrap(); + assert_eq!(settings.agent.name, "ironclaw"); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index adc0a009..631a4770 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,18 +3,22 @@ //! 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}; #[derive(Parser, Debug)] -#[command(name = "near-agent")] -#[command(about = "LLM-powered autonomous agent for the NEAR AI marketplace")] +#[command(name = "ironclaw")] +#[command( + about = "Secure personal AI assistant that protects your data and expands its capabilities" +)] #[command(version)] pub struct Cli { #[command(subcommand)] @@ -61,12 +65,13 @@ pub enum Command { channels_only: bool, }, + /// Manage configuration settings + #[command(subcommand)] + Config(ConfigCommand), + /// Manage WASM tools #[command(subcommand)] Tool(ToolCommand), - // Future: Secret management - // #[command(subcommand)] - // Secret(SecretCommand), } impl Cli { diff --git a/src/cli/tool.rs b/src/cli/tool.rs index bee90001..1fa2df89 100644 --- a/src/cli/tool.rs +++ b/src/cli/tool.rs @@ -13,8 +13,8 @@ use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash}; /// Default tools directory. fn default_tools_dir() -> PathBuf { dirs::home_dir() - .map(|h| h.join(".near-agent").join("tools")) - .unwrap_or_else(|| PathBuf::from(".near-agent/tools")) + .map(|h| h.join(".ironclaw").join("tools")) + .unwrap_or_else(|| PathBuf::from(".ironclaw/tools")) } #[derive(Subcommand, Debug, Clone)] @@ -32,7 +32,7 @@ pub enum ToolCommand { #[arg(long)] capabilities: Option, - /// Target directory for installation (default: ~/.near-agent/tools/) + /// Target directory for installation (default: ~/.ironclaw/tools/) #[arg(short, long)] target: Option, @@ -51,7 +51,7 @@ pub enum ToolCommand { /// List installed tools List { - /// Directory to list tools from (default: ~/.near-agent/tools/) + /// Directory to list tools from (default: ~/.ironclaw/tools/) #[arg(short, long)] dir: Option, @@ -65,7 +65,7 @@ pub enum ToolCommand { /// Name of the tool to remove name: String, - /// Directory to remove tool from (default: ~/.near-agent/tools/) + /// Directory to remove tool from (default: ~/.ironclaw/tools/) #[arg(short, long)] dir: Option, }, @@ -75,7 +75,7 @@ pub enum ToolCommand { /// Name of the tool or path to .wasm file name_or_path: String, - /// Directory to look for tool (default: ~/.near-agent/tools/) + /// Directory to look for tool (default: ~/.ironclaw/tools/) #[arg(short, long)] dir: Option, }, @@ -420,7 +420,7 @@ async fn list_tools(dir: Option, verbose: bool) -> anyhow::Result<()> { if !tools_dir.exists() { println!("No tools directory found at {}", tools_dir.display()); - println!("Install a tool with: near-agent tool install "); + println!("Install a tool with: ironclaw tool install "); return Ok(()); } @@ -674,7 +674,7 @@ mod tests { #[test] fn test_default_tools_dir() { let dir = default_tools_dir(); - assert!(dir.to_string_lossy().contains(".near-agent")); + assert!(dir.to_string_lossy().contains(".ironclaw")); assert!(dir.to_string_lossy().contains("tools")); } } diff --git a/src/config.rs b/src/config.rs index ee5d6668..0ead0078 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -//! Configuration for the NEAR Agent. +//! Configuration for IronClaw. use std::path::PathBuf; use std::time::Duration; @@ -131,16 +131,30 @@ pub struct DatabaseConfig { impl DatabaseConfig { fn from_env() -> Result { + let settings = crate::settings::Settings::load(); + + // Priority: env var > settings > error (required) + let url = optional_env("DATABASE_URL")? + .or(settings.database_url.clone()) + .ok_or_else(|| ConfigError::MissingRequired { + key: "database_url".to_string(), + hint: "Run 'ironclaw setup' or set DATABASE_URL environment variable".to_string(), + })?; + + // Priority: env var > settings > default + let pool_size = optional_env("DATABASE_POOL_SIZE")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "DATABASE_POOL_SIZE".to_string(), + message: format!("must be a positive integer: {e}"), + })? + .or(settings.database_pool_size) + .unwrap_or(10); + Ok(Self { - url: SecretString::from(required_env("DATABASE_URL")?), - pool_size: optional_env("DATABASE_POOL_SIZE")? - .map(|s| s.parse()) - .transpose() - .map_err(|e| ConfigError::InvalidValue { - key: "DATABASE_POOL_SIZE".to_string(), - message: format!("must be a positive integer: {e}"), - })? - .unwrap_or(10), + url: SecretString::from(url), + pool_size, }) } @@ -192,7 +206,7 @@ pub struct NearAiConfig { pub base_url: String, /// Base URL for auth/refresh endpoints (default: https://private.near.ai) pub auth_base_url: String, - /// Path to session file (default: ~/.near-agent/session.json) + /// Path to session file (default: ~/.ironclaw/session.json) pub session_path: PathBuf, /// API mode: "responses" (chat-api) or "chat_completions" (cloud-api) pub api_mode: NearAiApiMode, @@ -269,10 +283,17 @@ impl Default for EmbeddingsConfig { impl EmbeddingsConfig { fn from_env() -> Result { + let settings = crate::settings::Settings::load(); let openai_api_key = optional_env("OPENAI_API_KEY")?.map(SecretString::from); - let provider = optional_env("EMBEDDING_PROVIDER")?.unwrap_or_else(|| "openai".to_string()); - // Auto-enable if we have an API key + // Priority: env var > settings > default + let provider = optional_env("EMBEDDING_PROVIDER")? + .unwrap_or_else(|| settings.embeddings.provider.clone()); + + let model = + optional_env("EMBEDDING_MODEL")?.unwrap_or_else(|| settings.embeddings.model.clone()); + + // Priority: env var > settings > auto-detect from API key let enabled = optional_env("EMBEDDING_ENABLED")? .map(|s| s.parse()) .transpose() @@ -280,14 +301,16 @@ impl EmbeddingsConfig { key: "EMBEDDING_ENABLED".to_string(), message: format!("must be 'true' or 'false': {e}"), })? - .unwrap_or(openai_api_key.is_some()); + .unwrap_or_else(|| { + // Check settings, or auto-enable if API key present + settings.embeddings.enabled || openai_api_key.is_some() + }); Ok(Self { enabled, provider, openai_api_key, - model: optional_env("EMBEDDING_MODEL")? - .unwrap_or_else(|| "text-embedding-3-small".to_string()), + model, }) } @@ -297,11 +320,11 @@ impl EmbeddingsConfig { } } -/// Get the default session file path (~/.near-agent/session.json). +/// Get the default session file path (~/.ironclaw/session.json). fn default_session_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) - .join(".near-agent") + .join(".ironclaw") .join("session.json") } @@ -310,7 +333,7 @@ fn default_session_path() -> PathBuf { pub struct ChannelsConfig { pub cli: CliConfig, pub http: Option, - /// Directory containing WASM channel modules (default: ~/.near-agent/channels/). + /// Directory containing WASM channel modules (default: ~/.ironclaw/channels/). pub wasm_channels_dir: std::path::PathBuf, /// Whether WASM channels are enabled. pub wasm_channels_enabled: bool, @@ -371,11 +394,11 @@ impl ChannelsConfig { } } -/// Get the default channels directory (~/.near-agent/channels/). +/// Get the default channels directory (~/.ironclaw/channels/). fn default_channels_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) - .join(".near-agent") + .join(".ironclaw") .join("channels") } @@ -394,19 +417,57 @@ pub struct AgentConfig { impl AgentConfig { fn from_env() -> Result { + let settings = crate::settings::Settings::load(); + Ok(Self { - name: optional_env("AGENT_NAME")?.unwrap_or_else(|| "near-agent".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), }) } } @@ -447,7 +508,7 @@ impl SafetyConfig { pub struct WasmConfig { /// Whether WASM tool execution is enabled. pub enabled: bool, - /// Directory containing installed WASM tools (default: ~/.near-agent/tools/). + /// Directory containing installed WASM tools (default: ~/.ironclaw/tools/). pub tools_dir: PathBuf, /// Default memory limit in bytes (default: 10 MB). pub default_memory_limit: u64, @@ -464,11 +525,13 @@ pub struct WasmConfig { /// Secrets management configuration. #[derive(Clone, Default)] pub struct SecretsConfig { - /// Master key for encrypting secrets (loaded from SECRETS_MASTER_KEY env var). - /// Must be at least 32 bytes for AES-256-GCM. + /// Master key for encrypting secrets. + /// Source determined by KeySource in settings. pub master_key: Option, /// Whether secrets management is enabled. pub enabled: bool, + /// Source of the master key. + pub source: crate::settings::KeySource, } impl std::fmt::Debug for SecretsConfig { @@ -476,13 +539,53 @@ impl std::fmt::Debug for SecretsConfig { f.debug_struct("SecretsConfig") .field("master_key", &self.master_key.is_some()) .field("enabled", &self.enabled) + .field("source", &self.source) .finish() } } impl SecretsConfig { fn from_env() -> Result { - let master_key = optional_env("SECRETS_MASTER_KEY")?.map(SecretString::from); + use crate::settings::KeySource; + + let settings = crate::settings::Settings::load(); + + // Priority: env var > keychain (based on settings) > disabled + let (master_key, source) = if let Some(env_key) = optional_env("SECRETS_MASTER_KEY")? { + // Env var takes priority (for CI/Docker) + (Some(SecretString::from(env_key)), KeySource::Env) + } else { + match settings.secrets_master_key_source { + KeySource::Keychain => { + // Try to load from OS keychain + match crate::secrets::keychain::get_master_key() { + Ok(key_bytes) => { + let key_hex: String = + key_bytes.iter().map(|b| format!("{:02x}", b)).collect(); + (Some(SecretString::from(key_hex)), KeySource::Keychain) + } + Err(_) => { + // Keychain configured but key not found + // This might happen if keychain was cleared + tracing::warn!( + "Secrets configured for keychain but key not found. \ + Run 'ironclaw setup' to reconfigure." + ); + (None, KeySource::None) + } + } + } + KeySource::Env => { + // Settings say env, but no env var found + tracing::warn!( + "Secrets configured for env var but SECRETS_MASTER_KEY not set." + ); + (None, KeySource::None) + } + KeySource::None => (None, KeySource::None), + } + }; + let enabled = master_key.is_some(); // Validate master key length if provided @@ -498,6 +601,7 @@ impl SecretsConfig { Ok(Self { master_key, enabled, + source, }) } @@ -521,11 +625,11 @@ impl Default for WasmConfig { } } -/// Get the default tools directory (~/.near-agent/tools/). +/// Get the default tools directory (~/.ironclaw/tools/). fn default_tools_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) - .join(".near-agent") + .join(".ironclaw") .join("tools") } @@ -676,7 +780,10 @@ impl Default for HeartbeatConfig { impl HeartbeatConfig { fn from_env() -> Result { + let settings = crate::settings::Settings::load(); + Ok(Self { + // Priority: env var > settings > default enabled: optional_env("HEARTBEAT_ENABLED")? .map(|s| s.parse()) .transpose() @@ -684,10 +791,19 @@ impl HeartbeatConfig { key: "HEARTBEAT_ENABLED".to_string(), message: format!("must be 'true' or 'false': {e}"), })? - .unwrap_or(false), - interval_secs: parse_optional_env("HEARTBEAT_INTERVAL_SECS", 1800)?, - notify_channel: optional_env("HEARTBEAT_NOTIFY_CHANNEL")?, - notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?, + .unwrap_or(settings.heartbeat.enabled), + interval_secs: optional_env("HEARTBEAT_INTERVAL_SECS")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "HEARTBEAT_INTERVAL_SECS".to_string(), + message: format!("must be a positive integer: {e}"), + })? + .unwrap_or(settings.heartbeat.interval_secs), + notify_channel: optional_env("HEARTBEAT_NOTIFY_CHANNEL")? + .or(settings.heartbeat.notify_channel.clone()), + notify_user: optional_env("HEARTBEAT_NOTIFY_USER")? + .or(settings.heartbeat.notify_user.clone()), }) } } @@ -787,10 +903,6 @@ impl SandboxModeConfig { // Helper functions -fn required_env(key: &str) -> Result { - std::env::var(key).map_err(|_| ConfigError::MissingEnvVar(key.to_string())) -} - fn optional_env(key: &str) -> Result, ConfigError> { match std::env::var(key) { Ok(val) if val.is_empty() => Ok(None), diff --git a/src/error.rs b/src/error.rs index 0f0d304a..10da699f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,4 +1,4 @@ -//! Error types for the NEAR Agent. +//! Error types for IronClaw. use std::time::Duration; @@ -47,6 +47,9 @@ pub enum ConfigError { #[error("Missing required environment variable: {0}")] MissingEnvVar(String), + #[error("Missing required configuration: {key}. {hint}")] + MissingRequired { key: String, hint: String }, + #[error("Invalid configuration value for {key}: {message}")] InvalidValue { key: String, message: String }, diff --git a/src/llm/session.rs b/src/llm/session.rs index 05006e30..5a136171 100644 --- a/src/llm/session.rs +++ b/src/llm/session.rs @@ -1,7 +1,7 @@ //! Session management for NEAR AI authentication. //! //! Handles session token persistence, expiration detection, and renewal via -//! OAuth flow. Tokens are stored in `~/.near-agent/session.json` and refreshed +//! OAuth flow. Tokens are stored in `~/.ironclaw/session.json` and refreshed //! automatically when expired. use std::path::PathBuf; @@ -29,7 +29,7 @@ pub struct SessionData { pub struct SessionConfig { /// Base URL for auth endpoints (e.g., https://private.near.ai). pub auth_base_url: String, - /// Path to session file (e.g., ~/.near-agent/session.json). + /// Path to session file (e.g., ~/.ironclaw/session.json). pub session_path: PathBuf, /// Port range for OAuth callback server. pub callback_port_range: (u16, u16), @@ -45,11 +45,11 @@ impl Default for SessionConfig { } } -/// Get the default session file path (~/.near-agent/session.json). +/// Get the default session file path (~/.ironclaw/session.json). pub fn default_session_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) - .join(".near-agent") + .join(".ironclaw") .join("session.json") } @@ -631,6 +631,6 @@ mod tests { fn test_default_session_path() { let path = default_session_path(); assert!(path.ends_with("session.json")); - assert!(path.to_string_lossy().contains(".near-agent")); + assert!(path.to_string_lossy().contains(".ironclaw")); } } diff --git a/src/main.rs b/src/main.rs index 78944fe2..38b2da19 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ -//! NEAR Agent - Main entry point. +//! IronClaw - Main entry point. use std::sync::Arc; use clap::Parser; use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; -use near_agent::{ +use ironclaw::{ agent::{Agent, AgentDeps}, channels::{ AppEvent, ChannelManager, HttpChannel, ReplChannel, TuiChannel, @@ -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 = near_agent::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 @@ -102,7 +115,7 @@ async fn main() -> anyhow::Result<()> { // Initialize tracing and channels based on mode let env_filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("near_agent=info,tower_http=debug")); + .unwrap_or_else(|_| EnvFilter::new("ironclaw=info,tower_http=debug")); // Determine which mode to use: REPL, single message, or TUI let use_repl = cli.repl || cli.message.is_some(); @@ -150,7 +163,7 @@ async fn main() -> anyhow::Result<()> { (None, None, None) }; - tracing::info!("Starting NEAR Agent..."); + tracing::info!("Starting IronClaw..."); tracing::info!("Loaded configuration for agent: {}", config.agent.name); tracing::info!("NEAR AI session authenticated"); @@ -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 @@ -578,7 +620,7 @@ async fn main() -> anyhow::Result<()> { /// /// Returns the number of credentials injected. async fn inject_channel_credentials( - channel: &Arc, + channel: &Arc, secrets: &dyn SecretsStore, channel_name: &str, ) -> anyhow::Result { diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs index ca6e9f9f..5a7aed0f 100644 --- a/src/sandbox/mod.rs +++ b/src/sandbox/mod.rs @@ -49,7 +49,7 @@ //! # Example //! //! ```rust,no_run -//! use near_agent::sandbox::{SandboxManager, SandboxManagerBuilder, SandboxPolicy}; +//! use ironclaw::sandbox::{SandboxManager, SandboxManagerBuilder, SandboxPolicy}; //! use std::collections::HashMap; //! use std::path::Path; //! diff --git a/src/secrets/keychain.rs b/src/secrets/keychain.rs new file mode 100644 index 00000000..e81efcd0 --- /dev/null +++ b/src/secrets/keychain.rs @@ -0,0 +1,346 @@ +//! OS keychain integration for secrets master key storage. +//! +//! Provides platform-specific keychain support: +//! - macOS: security-framework (Keychain Services) +//! - Linux: secret-service (GNOME Keyring, KWallet) +//! +//! # Example +//! +//! ```ignore +//! use ironclaw::secrets::keychain::{store_master_key, get_master_key, delete_master_key}; +//! +//! // Generate and store a new master key +//! let key = generate_master_key(); +//! store_master_key(&key)?; +//! +//! // Later, retrieve it +//! let key = get_master_key()?; +//! ``` + +use crate::secrets::SecretError; + +/// Service name for keychain entries. +const SERVICE_NAME: &str = "ironclaw"; + +/// Account name for the master key. +const MASTER_KEY_ACCOUNT: &str = "master_key"; + +/// Generate a random 32-byte master key. +pub fn generate_master_key() -> Vec { + use rand::RngCore; + let mut key = vec![0u8; 32]; + rand::thread_rng().fill_bytes(&mut key); + key +} + +/// Generate a master key as a hex string. +pub fn generate_master_key_hex() -> String { + let bytes = generate_master_key(); + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +// ============================================================================ +// macOS implementation using security-framework +// ============================================================================ + +#[cfg(target_os = "macos")] +mod platform { + use security_framework::passwords::{ + delete_generic_password, get_generic_password, set_generic_password, + }; + + use super::*; + + /// Store the master key in the macOS Keychain. + pub fn store_master_key(key: &[u8]) -> Result<(), SecretError> { + // Convert to hex for storage (keychain prefers strings) + let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); + + set_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT, key_hex.as_bytes()) + .map_err(|e| SecretError::KeychainError(format!("Failed to store in keychain: {}", e))) + } + + /// Retrieve the master key from the macOS Keychain. + pub fn get_master_key() -> Result, SecretError> { + let password = get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|e| { + SecretError::KeychainError(format!("Failed to get from keychain: {}", e)) + })?; + + // Parse hex string back to bytes + let hex_str = String::from_utf8(password) + .map_err(|_| SecretError::KeychainError("Invalid UTF-8 in keychain".to_string()))?; + + hex_to_bytes(&hex_str) + } + + /// Delete the master key from the macOS Keychain. + pub fn delete_master_key() -> Result<(), SecretError> { + delete_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|e| { + SecretError::KeychainError(format!("Failed to delete from keychain: {}", e)) + }) + } + + /// Check if a master key exists in the keychain. + pub fn has_master_key() -> bool { + get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).is_ok() + } +} + +// ============================================================================ +// Linux implementation using secret-service +// ============================================================================ + +#[cfg(target_os = "linux")] +mod platform { + use secret_service::{EncryptionType, SecretService}; + + use super::*; + + /// Store the master key in the Linux secret service (GNOME Keyring, KWallet). + pub fn store_master_key(key: &[u8]) -> Result<(), SecretError> { + let rt = tokio::runtime::Handle::try_current() + .map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?; + + rt.block_on(async { + let ss = SecretService::connect(EncryptionType::Dh) + .await + .map_err(|e| { + SecretError::KeychainError(format!( + "Failed to connect to secret service: {}", + e + )) + })?; + + let collection = ss.get_default_collection().await.map_err(|e| { + SecretError::KeychainError(format!("Failed to get collection: {}", e)) + })?; + + // Unlock if needed + if collection.is_locked().await.unwrap_or(true) { + collection.unlock().await.map_err(|e| { + SecretError::KeychainError(format!("Failed to unlock collection: {}", e)) + })?; + } + + // Convert to hex for storage + let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); + + collection + .create_item( + &format!("{} master key", SERVICE_NAME), + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + key_hex.as_bytes(), + true, // Replace if exists + "text/plain", + ) + .await + .map_err(|e| { + SecretError::KeychainError(format!("Failed to create secret: {}", e)) + })?; + + Ok(()) + }) + } + + /// Retrieve the master key from the Linux secret service. + pub fn get_master_key() -> Result, SecretError> { + let rt = tokio::runtime::Handle::try_current() + .map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?; + + rt.block_on(async { + let ss = SecretService::connect(EncryptionType::Dh) + .await + .map_err(|e| { + SecretError::KeychainError(format!( + "Failed to connect to secret service: {}", + e + )) + })?; + + let items = ss + .search_items( + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + ) + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?; + + let item = items + .unlocked + .first() + .or(items.locked.first()) + .ok_or_else(|| SecretError::KeychainError("Master key not found".to_string()))?; + + // Unlock if needed + if item.is_locked().await.unwrap_or(true) { + item.unlock() + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to unlock: {}", e)))?; + } + + let secret = item + .get_secret() + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to get secret: {}", e)))?; + + let hex_str = String::from_utf8(secret) + .map_err(|_| SecretError::KeychainError("Invalid UTF-8 in secret".to_string()))?; + + hex_to_bytes(&hex_str) + }) + } + + /// Delete the master key from the Linux secret service. + pub fn delete_master_key() -> Result<(), SecretError> { + let rt = tokio::runtime::Handle::try_current() + .map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?; + + rt.block_on(async { + let ss = SecretService::connect(EncryptionType::Dh) + .await + .map_err(|e| { + SecretError::KeychainError(format!( + "Failed to connect to secret service: {}", + e + )) + })?; + + let items = ss + .search_items( + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + ) + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?; + + for item in items.unlocked.iter().chain(items.locked.iter()) { + item.delete() + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to delete: {}", e)))?; + } + + Ok(()) + }) + } + + /// Check if a master key exists in the secret service. + pub fn has_master_key() -> bool { + let rt = match tokio::runtime::Handle::try_current() { + Ok(rt) => rt, + Err(_) => return false, + }; + + rt.block_on(async { + let ss = match SecretService::connect(EncryptionType::Dh).await { + Ok(ss) => ss, + Err(_) => return false, + }; + + let items = match ss + .search_items( + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + ) + .await + { + Ok(items) => items, + Err(_) => return false, + }; + + !items.unlocked.is_empty() || !items.locked.is_empty() + }) + } +} + +// ============================================================================ +// Fallback for unsupported platforms +// ============================================================================ + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +mod platform { + use super::*; + + pub fn store_master_key(_key: &[u8]) -> Result<(), SecretError> { + Err(SecretError::KeychainError( + "Keychain not supported on this platform. Use SECRETS_MASTER_KEY env var.".to_string(), + )) + } + + pub fn get_master_key() -> Result, SecretError> { + Err(SecretError::KeychainError( + "Keychain not supported on this platform. Use SECRETS_MASTER_KEY env var.".to_string(), + )) + } + + pub fn delete_master_key() -> Result<(), SecretError> { + Err(SecretError::KeychainError( + "Keychain not supported on this platform".to_string(), + )) + } + + pub fn has_master_key() -> bool { + false + } +} + +// Re-export platform-specific functions +pub use platform::{delete_master_key, get_master_key, has_master_key, store_master_key}; + +/// Parse a hex string to bytes. +fn hex_to_bytes(hex: &str) -> Result, SecretError> { + if hex.len() % 2 != 0 { + return Err(SecretError::KeychainError( + "Invalid hex string length".to_string(), + )); + } + + (0..hex.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&hex[i..i + 2], 16) + .map_err(|_| SecretError::KeychainError("Invalid hex character".to_string())) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_master_key() { + let key = generate_master_key(); + assert_eq!(key.len(), 32); + + // Should be different each time + let key2 = generate_master_key(); + assert_ne!(key, key2); + } + + #[test] + fn test_generate_master_key_hex() { + let hex = generate_master_key_hex(); + assert_eq!(hex.len(), 64); // 32 bytes * 2 hex chars + assert!(hex.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_hex_to_bytes() { + let result = hex_to_bytes("deadbeef").unwrap(); + assert_eq!(result, vec![0xde, 0xad, 0xbe, 0xef]); + + let result = hex_to_bytes("00ff").unwrap(); + assert_eq!(result, vec![0x00, 0xff]); + } + + #[test] + fn test_hex_to_bytes_invalid() { + assert!(hex_to_bytes("abc").is_err()); // Odd length + assert!(hex_to_bytes("gg").is_err()); // Invalid chars + } +} diff --git a/src/secrets/mod.rs b/src/secrets/mod.rs index 7335ec82..d2f8696d 100644 --- a/src/secrets/mod.rs +++ b/src/secrets/mod.rs @@ -4,6 +4,7 @@ //! - AES-256-GCM encrypted secret storage //! - Per-secret key derivation (HKDF-SHA256) //! - PostgreSQL persistence +//! - OS keychain integration for master key //! - Access control for WASM tools //! //! # Security Model @@ -28,10 +29,16 @@ //! └─────────────────────────────────────────────────────────────────────────────┘ //! ``` //! +//! # 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 -//! use near_agent::secrets::{SecretsStore, PostgresSecretsStore, SecretsCrypto, CreateSecretParams}; +//! use ironclaw::secrets::{SecretsStore, PostgresSecretsStore, SecretsCrypto, CreateSecretParams}; //! use secrecy::SecretString; //! //! // Initialize crypto with master key from environment @@ -52,6 +59,7 @@ //! ``` mod crypto; +pub mod keychain; mod store; mod types; diff --git a/src/secrets/types.rs b/src/secrets/types.rs index 0050c9fd..eb259a69 100644 --- a/src/secrets/types.rs +++ b/src/secrets/types.rs @@ -156,6 +156,9 @@ pub enum SecretError { #[error("Secret access denied for tool")] AccessDenied, + + #[error("Keychain error: {0}")] + KeychainError(String), } /// Parameters for creating a new secret. diff --git a/src/settings.rs b/src/settings.rs index de2aea02..bb594a19 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1,6 +1,7 @@ //! User settings persistence. //! -//! Stores user preferences like selected model in ~/.near-agent/settings.json. +//! Stores user preferences in ~/.ironclaw/settings.json. +//! Settings are loaded with env var > settings.json > default priority. use std::path::PathBuf; @@ -9,21 +10,118 @@ use serde::{Deserialize, Serialize}; /// User settings persisted to disk. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Settings { - /// Currently selected model. - #[serde(default)] - pub selected_model: Option, - /// Whether setup wizard has been completed. #[serde(default)] pub setup_completed: bool, - /// Tunnel configuration for exposing the agent to the internet. + // === Step 1: Database === + /// Database connection URL (postgres://...). + #[serde(default)] + pub database_url: Option, + + /// Database pool size. + #[serde(default)] + pub database_pool_size: Option, + + // === Step 2: Security === + /// Source for the secrets master key. + #[serde(default)] + pub secrets_master_key_source: KeySource, + + // === Step 3: NEAR AI Auth === + // Session stored separately in session.json + + // === Step 4: Model Selection === + /// Currently selected model. + #[serde(default)] + pub selected_model: Option, + + // === Step 5: Embeddings === + /// Embeddings configuration. + #[serde(default)] + pub embeddings: EmbeddingsSettings, + + // === Step 6: Channels === + /// Tunnel configuration for public webhook endpoints. #[serde(default)] pub tunnel: TunnelSettings, /// Channel configuration. #[serde(default)] pub channels: ChannelSettings, + + // === Step 7: Heartbeat === + /// Heartbeat configuration. + #[serde(default)] + pub heartbeat: HeartbeatSettings, + + // === Advanced Settings (not asked during setup, editable via CLI) === + /// Agent behavior configuration. + #[serde(default)] + pub agent: AgentSettings, + + /// WASM sandbox configuration. + #[serde(default)] + pub wasm: WasmSettings, + + /// Docker sandbox configuration. + #[serde(default)] + pub sandbox: SandboxSettings, + + /// Safety configuration. + #[serde(default)] + pub safety: SafetySettings, + + /// Builder configuration. + #[serde(default)] + pub builder: BuilderSettings, +} + +/// Source for the secrets master key. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum KeySource { + /// Auto-generated key stored in OS keychain. + Keychain, + /// User provides via SECRETS_MASTER_KEY env var. + Env, + /// Not configured (secrets features disabled). + #[default] + None, +} + +/// Embeddings configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmbeddingsSettings { + /// Whether embeddings are enabled. + #[serde(default)] + pub enabled: bool, + + /// Provider to use: "openai" or "nearai". + #[serde(default = "default_embeddings_provider")] + pub provider: String, + + /// Model to use for embeddings. + #[serde(default = "default_embeddings_model")] + pub model: String, +} + +fn default_embeddings_provider() -> String { + "nearai".to_string() +} + +fn default_embeddings_model() -> String { + "text-embedding-3-small".to_string() +} + +impl Default for EmbeddingsSettings { + fn default() -> Self { + Self { + enabled: false, + provider: default_embeddings_provider(), + model: default_embeddings_model(), + } + } } /// Tunnel settings for public webhook endpoints. @@ -47,19 +145,338 @@ pub struct ChannelSettings { #[serde(default)] pub http_port: Option, + /// HTTP webhook host. + #[serde(default)] + pub http_host: Option, + /// Enabled WASM channels by name. /// Channels not in this list but present in the channels directory will still load. /// This is primarily used by the setup wizard to track which channels were configured. #[serde(default)] pub wasm_channels: Vec, + + /// Whether WASM channels are enabled. + #[serde(default = "default_true")] + pub wasm_channels_enabled: bool, + + /// Directory containing WASM channel modules. + #[serde(default)] + pub wasm_channels_dir: Option, +} + +/// Heartbeat configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HeartbeatSettings { + /// Whether heartbeat is enabled. + #[serde(default)] + pub enabled: bool, + + /// Interval between heartbeat checks in seconds. + #[serde(default = "default_heartbeat_interval")] + pub interval_secs: u64, + + /// Channel to notify on heartbeat findings. + #[serde(default)] + pub notify_channel: Option, + + /// User ID to notify on heartbeat findings. + #[serde(default)] + pub notify_user: Option, +} + +fn default_heartbeat_interval() -> u64 { + 1800 // 30 minutes +} + +impl Default for HeartbeatSettings { + fn default() -> Self { + Self { + enabled: false, + interval_secs: default_heartbeat_interval(), + notify_channel: None, + notify_user: None, + } + } +} + +/// Agent behavior configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentSettings { + /// Agent name. + #[serde(default = "default_agent_name")] + pub name: String, + + /// Maximum parallel jobs. + #[serde(default = "default_max_parallel_jobs")] + pub max_parallel_jobs: u32, + + /// Job timeout in seconds. + #[serde(default = "default_job_timeout")] + pub job_timeout_secs: u64, + + /// Stuck job threshold in seconds. + #[serde(default = "default_stuck_threshold")] + pub stuck_threshold_secs: u64, + + /// Whether to use planning before tool execution. + #[serde(default = "default_true")] + pub use_planning: bool, + + /// Self-repair check interval in seconds. + #[serde(default = "default_repair_interval")] + pub repair_check_interval_secs: u64, + + /// Maximum repair attempts. + #[serde(default = "default_max_repair_attempts")] + pub max_repair_attempts: u32, +} + +fn default_agent_name() -> String { + "ironclaw".to_string() +} + +fn default_max_parallel_jobs() -> u32 { + 5 +} + +fn default_job_timeout() -> u64 { + 3600 // 1 hour +} + +fn default_stuck_threshold() -> u64 { + 300 // 5 minutes +} + +fn default_repair_interval() -> u64 { + 60 // 1 minute +} + +fn default_max_repair_attempts() -> u32 { + 3 +} + +fn default_true() -> bool { + true +} + +impl Default for AgentSettings { + fn default() -> Self { + Self { + name: default_agent_name(), + max_parallel_jobs: default_max_parallel_jobs(), + job_timeout_secs: default_job_timeout(), + stuck_threshold_secs: default_stuck_threshold(), + use_planning: true, + repair_check_interval_secs: default_repair_interval(), + max_repair_attempts: default_max_repair_attempts(), + } + } +} + +/// WASM sandbox configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WasmSettings { + /// Whether WASM tool execution is enabled. + #[serde(default = "default_true")] + pub enabled: bool, + + /// Directory containing installed WASM tools. + #[serde(default)] + pub tools_dir: Option, + + /// Default memory limit in bytes. + #[serde(default = "default_wasm_memory_limit")] + pub default_memory_limit: u64, + + /// Default execution timeout in seconds. + #[serde(default = "default_wasm_timeout")] + pub default_timeout_secs: u64, + + /// Default fuel limit for CPU metering. + #[serde(default = "default_wasm_fuel_limit")] + pub default_fuel_limit: u64, + + /// Whether to cache compiled modules. + #[serde(default = "default_true")] + pub cache_compiled: bool, + + /// Directory for compiled module cache. + #[serde(default)] + pub cache_dir: Option, +} + +fn default_wasm_memory_limit() -> u64 { + 10 * 1024 * 1024 // 10 MB +} + +fn default_wasm_timeout() -> u64 { + 60 +} + +fn default_wasm_fuel_limit() -> u64 { + 10_000_000 +} + +impl Default for WasmSettings { + fn default() -> Self { + Self { + enabled: true, + tools_dir: None, + default_memory_limit: default_wasm_memory_limit(), + default_timeout_secs: default_wasm_timeout(), + default_fuel_limit: default_wasm_fuel_limit(), + cache_compiled: true, + cache_dir: None, + } + } +} + +/// Docker sandbox configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxSettings { + /// Whether the Docker sandbox is enabled. + #[serde(default = "default_true")] + pub enabled: bool, + + /// Sandbox policy: "readonly", "workspace_write", or "full_access". + #[serde(default = "default_sandbox_policy")] + pub policy: String, + + /// Command timeout in seconds. + #[serde(default = "default_sandbox_timeout")] + pub timeout_secs: u64, + + /// Memory limit in megabytes. + #[serde(default = "default_sandbox_memory")] + pub memory_limit_mb: u64, + + /// CPU shares (relative weight). + #[serde(default = "default_sandbox_cpu_shares")] + pub cpu_shares: u32, + + /// Docker image for the sandbox. + #[serde(default = "default_sandbox_image")] + pub image: String, + + /// Whether to auto-pull the image if not found. + #[serde(default = "default_true")] + pub auto_pull_image: bool, + + /// Additional domains to allow through the network proxy. + #[serde(default)] + pub extra_allowed_domains: Vec, +} + +fn default_sandbox_policy() -> String { + "readonly".to_string() +} + +fn default_sandbox_timeout() -> u64 { + 120 +} + +fn default_sandbox_memory() -> u64 { + 2048 +} + +fn default_sandbox_cpu_shares() -> u32 { + 1024 +} + +fn default_sandbox_image() -> String { + "ghcr.io/nearai/sandbox:latest".to_string() +} + +impl Default for SandboxSettings { + fn default() -> Self { + Self { + enabled: true, + policy: default_sandbox_policy(), + timeout_secs: default_sandbox_timeout(), + memory_limit_mb: default_sandbox_memory(), + cpu_shares: default_sandbox_cpu_shares(), + image: default_sandbox_image(), + auto_pull_image: true, + extra_allowed_domains: Vec::new(), + } + } +} + +/// Safety configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SafetySettings { + /// Maximum output length in bytes. + #[serde(default = "default_max_output_length")] + pub max_output_length: usize, + + /// Whether injection check is enabled. + #[serde(default = "default_true")] + pub injection_check_enabled: bool, +} + +fn default_max_output_length() -> usize { + 100_000 +} + +impl Default for SafetySettings { + fn default() -> Self { + Self { + max_output_length: default_max_output_length(), + injection_check_enabled: true, + } + } +} + +/// Builder configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuilderSettings { + /// Whether the software builder tool is enabled. + #[serde(default = "default_true")] + pub enabled: bool, + + /// Directory for build artifacts. + #[serde(default)] + pub build_dir: Option, + + /// Maximum iterations for the build loop. + #[serde(default = "default_builder_max_iterations")] + pub max_iterations: u32, + + /// Build timeout in seconds. + #[serde(default = "default_builder_timeout")] + pub timeout_secs: u64, + + /// Whether to automatically register built WASM tools. + #[serde(default = "default_true")] + pub auto_register: bool, +} + +fn default_builder_max_iterations() -> u32 { + 20 +} + +fn default_builder_timeout() -> u64 { + 600 +} + +impl Default for BuilderSettings { + fn default() -> Self { + Self { + enabled: true, + build_dir: None, + max_iterations: default_builder_max_iterations(), + timeout_secs: default_builder_timeout(), + auto_register: true, + } + } } impl Settings { - /// Get the default settings file path (~/.near-agent/settings.json). + /// Get the default settings file path (~/.ironclaw/settings.json). pub fn default_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) - .join(".near-agent") + .join(".ironclaw") .join("settings.json") } @@ -106,6 +523,163 @@ impl Settings { self.selected_model = Some(model.to_string()); self.save() } + + /// Get a setting value by dotted path (e.g., "agent.max_parallel_jobs"). + pub fn get(&self, path: &str) -> Option { + let json = serde_json::to_value(self).ok()?; + let mut current = &json; + + for part in path.split('.') { + current = current.get(part)?; + } + + match current { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + serde_json::Value::Bool(b) => Some(b.to_string()), + serde_json::Value::Null => Some("null".to_string()), + serde_json::Value::Array(arr) => Some(serde_json::to_string(arr).unwrap_or_default()), + serde_json::Value::Object(obj) => Some(serde_json::to_string(obj).unwrap_or_default()), + } + } + + /// Set a setting value by dotted path. + /// + /// Returns error if path is invalid or value cannot be parsed. + pub fn set(&mut self, path: &str, value: &str) -> Result<(), String> { + let mut json = serde_json::to_value(&self) + .map_err(|e| format!("Failed to serialize settings: {}", e))?; + + let parts: Vec<&str> = path.split('.').collect(); + if parts.is_empty() { + return Err("Empty path".to_string()); + } + + // Navigate to parent and set the final key + let mut current = &mut json; + for part in &parts[..parts.len() - 1] { + current = current + .get_mut(*part) + .ok_or_else(|| format!("Path not found: {}", path))?; + } + + let final_key = parts.last().unwrap(); + let obj = current + .as_object_mut() + .ok_or_else(|| format!("Parent is not an object: {}", path))?; + + // Try to infer the type from the existing value + let new_value = if let Some(existing) = obj.get(*final_key) { + match existing { + serde_json::Value::Bool(_) => { + let b = value + .parse::() + .map_err(|_| format!("Expected boolean for {}, got '{}'", path, value))?; + serde_json::Value::Bool(b) + } + serde_json::Value::Number(n) => { + if n.is_u64() { + let n = value.parse::().map_err(|_| { + format!("Expected integer for {}, got '{}'", path, value) + })?; + serde_json::Value::Number(n.into()) + } else if n.is_i64() { + let n = value.parse::().map_err(|_| { + format!("Expected integer for {}, got '{}'", path, value) + })?; + serde_json::Value::Number(n.into()) + } else { + let n = value.parse::().map_err(|_| { + format!("Expected number for {}, got '{}'", path, value) + })?; + serde_json::Number::from_f64(n) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::String(value.to_string())) + } + } + serde_json::Value::Null => { + // Could be Option, try to parse as JSON or use string + serde_json::from_str(value) + .unwrap_or(serde_json::Value::String(value.to_string())) + } + serde_json::Value::Array(_) => serde_json::from_str(value) + .map_err(|e| format!("Invalid JSON array for {}: {}", path, e))?, + serde_json::Value::Object(_) => serde_json::from_str(value) + .map_err(|e| format!("Invalid JSON object for {}: {}", path, e))?, + serde_json::Value::String(_) => serde_json::Value::String(value.to_string()), + } + } else { + // Key doesn't exist, try to parse as JSON or use string + serde_json::from_str(value).unwrap_or(serde_json::Value::String(value.to_string())) + }; + + obj.insert((*final_key).to_string(), new_value); + + // Deserialize back to Settings + *self = + serde_json::from_value(json).map_err(|e| format!("Failed to apply setting: {}", e))?; + + Ok(()) + } + + /// Reset a setting to its default value. + pub fn reset(&mut self, path: &str) -> Result<(), String> { + let default = Self::default(); + let default_value = default + .get(path) + .ok_or_else(|| format!("Unknown setting: {}", path))?; + + self.set(path, &default_value) + } + + /// List all settings as (path, value) pairs. + pub fn list(&self) -> Vec<(String, String)> { + let json = match serde_json::to_value(self) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + + let mut results = Vec::new(); + collect_settings(&json, String::new(), &mut results); + results.sort_by(|a, b| a.0.cmp(&b.0)); + results + } +} + +/// Recursively collect settings paths and values. +fn collect_settings( + value: &serde_json::Value, + prefix: String, + results: &mut Vec<(String, String)>, +) { + match value { + serde_json::Value::Object(obj) => { + for (key, val) in obj { + let path = if prefix.is_empty() { + key.clone() + } else { + format!("{}.{}", prefix, key) + }; + collect_settings(val, path, results); + } + } + serde_json::Value::Array(arr) => { + let display = serde_json::to_string(arr).unwrap_or_default(); + results.push((prefix, display)); + } + serde_json::Value::String(s) => { + results.push((prefix, s.clone())); + } + serde_json::Value::Number(n) => { + results.push((prefix, n.to_string())); + } + serde_json::Value::Bool(b) => { + results.push((prefix, b.to_string())); + } + serde_json::Value::Null => { + results.push((prefix, "null".to_string())); + } + } } #[cfg(test)] @@ -146,4 +720,73 @@ mod tests { }; assert_eq!(settings.model_or("default-model"), "my-model".to_string()); } + + #[test] + fn test_get_setting() { + let settings = Settings::default(); + + assert_eq!(settings.get("agent.name"), Some("ironclaw".to_string())); + assert_eq!( + settings.get("agent.max_parallel_jobs"), + Some("5".to_string()) + ); + assert_eq!(settings.get("heartbeat.enabled"), Some("false".to_string())); + assert_eq!(settings.get("nonexistent"), None); + } + + #[test] + fn test_set_setting() { + let mut settings = Settings::default(); + + settings.set("agent.name", "mybot").unwrap(); + assert_eq!(settings.agent.name, "mybot"); + + settings.set("agent.max_parallel_jobs", "10").unwrap(); + assert_eq!(settings.agent.max_parallel_jobs, 10); + + settings.set("heartbeat.enabled", "true").unwrap(); + assert!(settings.heartbeat.enabled); + } + + #[test] + fn test_reset_setting() { + let mut settings = Settings::default(); + + settings.agent.name = "custom".to_string(); + settings.reset("agent.name").unwrap(); + assert_eq!(settings.agent.name, "ironclaw"); + } + + #[test] + fn test_list_settings() { + let settings = Settings::default(); + let list = settings.list(); + + // Check some expected entries + assert!(list.iter().any(|(k, _)| k == "agent.name")); + assert!(list.iter().any(|(k, _)| k == "heartbeat.enabled")); + assert!(list.iter().any(|(k, _)| k == "setup_completed")); + } + + #[test] + fn test_key_source_serialization() { + let settings = Settings { + secrets_master_key_source: KeySource::Keychain, + ..Default::default() + }; + + let json = serde_json::to_string(&settings).unwrap(); + assert!(json.contains("\"keychain\"")); + + let loaded: Settings = serde_json::from_str(&json).unwrap(); + assert_eq!(loaded.secrets_master_key_source, KeySource::Keychain); + } + + #[test] + fn test_embeddings_defaults() { + let settings = Settings::default(); + assert!(!settings.embeddings.enabled); + assert_eq!(settings.embeddings.provider, "nearai"); + assert_eq!(settings.embeddings.model, "text-embedding-3-small"); + } } diff --git a/src/setup/mod.rs b/src/setup/mod.rs index c082c56c..71bda14a 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -1,14 +1,18 @@ -//! Interactive setup wizard for NEAR Agent. +//! 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 //! //! ```ignore -//! use near_agent::setup::SetupWizard; +//! use ironclaw::setup::SetupWizard; //! //! let mut wizard = SetupWizard::new(); //! wizard.run().await?; @@ -21,5 +25,8 @@ mod wizard; pub use channels::{ SecretsContext, setup_http, setup_telegram, setup_tunnel, validate_telegram_token, }; -pub use prompts::{confirm, print_header, print_step, secret_input, select_many, select_one}; +pub use prompts::{ + confirm, input, optional_input, print_error, print_header, print_info, print_step, + print_success, secret_input, select_many, select_one, +}; pub use wizard::{SetupConfig, SetupWizard}; diff --git a/src/setup/prompts.rs b/src/setup/prompts.rs index 7a581cef..b07ae090 100644 --- a/src/setup/prompts.rs +++ b/src/setup/prompts.rs @@ -259,7 +259,7 @@ pub fn confirm(prompt: &str, default: bool) -> io::Result { /// # Example /// /// ```ignore -/// print_header("NEAR Agent Setup Wizard"); +/// print_header("IronClaw Setup Wizard"); /// ``` pub fn print_header(text: &str) { let width = text.len() + 4; diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index d63cf741..5ba4b6ac 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -1,9 +1,13 @@ //! Main setup wizard orchestration. //! //! The wizard guides users through: -//! 1. NEAR AI authentication -//! 2. Model selection -//! 3. Channel configuration +//! 1. Database connection +//! 2. Security (secrets master key) +//! 3. NEAR AI authentication +//! 4. Model selection +//! 5. Embeddings +//! 6. Channel configuration +//! 7. Heartbeat (background tasks) use std::sync::Arc; @@ -14,12 +18,13 @@ use tokio_postgres::NoTls; use crate::channels::wasm::ChannelCapabilitiesFile; use crate::llm::{SessionConfig, SessionManager}; use crate::secrets::SecretsCrypto; -use crate::settings::Settings; +use crate::settings::{KeySource, Settings}; use crate::setup::channels::{ SecretsContext, setup_http, setup_telegram, setup_tunnel, setup_wasm_channel, }; use crate::setup::prompts::{ - input, print_header, print_info, print_step, print_success, select_many, select_one, + confirm, input, optional_input, print_error, print_header, print_info, print_step, + print_success, select_many, select_one, }; /// Setup wizard error. @@ -53,11 +58,15 @@ pub struct SetupConfig { pub channels_only: bool, } -/// Interactive setup wizard for NEAR Agent. +/// Interactive setup wizard for IronClaw. pub struct SetupWizard { config: SetupConfig, settings: Settings, session_manager: Option>, + /// Database pool (created during setup). + db_pool: Option, + /// Secrets crypto (created during setup). + secrets_crypto: Option>, } impl SetupWizard { @@ -67,6 +76,8 @@ impl SetupWizard { config: SetupConfig::default(), settings: Settings::load(), session_manager: None, + db_pool: None, + secrets_crypto: None, } } @@ -76,6 +87,8 @@ impl SetupWizard { config, settings: Settings::load(), session_manager: None, + db_pool: None, + secrets_crypto: None, } } @@ -87,28 +100,47 @@ impl SetupWizard { /// Run the setup wizard. pub async fn run(&mut self) -> Result<(), SetupError> { - print_header("NEAR Agent Setup Wizard"); + print_header("IronClaw Setup Wizard"); - let total_steps = if self.config.channels_only { 1 } else { 3 }; - let mut current_step = 1; + if self.config.channels_only { + // Channels-only mode: just step 6 + print_step(1, 1, "Channel Configuration"); + self.step_channels().await?; + } else { + let total_steps = 7; - // Step 1: Authentication (unless skipped or channels-only) - if !self.config.channels_only && !self.config.skip_auth { - print_step(current_step, total_steps, "NEAR AI Authentication"); - self.step_authentication().await?; - current_step += 1; - } + // Step 1: Database + print_step(1, total_steps, "Database Connection"); + self.step_database().await?; - // Step 2: Model selection (unless channels-only) - if !self.config.channels_only { - print_step(current_step, total_steps, "Model Selection"); + // Step 2: Security + print_step(2, total_steps, "Security"); + self.step_security().await?; + + // Step 3: Authentication (unless skipped) + if !self.config.skip_auth { + print_step(3, total_steps, "NEAR AI Authentication"); + self.step_authentication().await?; + } else { + print_info("Skipping authentication (using existing session)"); + } + + // Step 4: Model selection + print_step(4, total_steps, "Model Selection"); self.step_model_selection().await?; - current_step += 1; - } - // Step 3: Channel configuration - print_step(current_step, total_steps, "Channel Configuration"); - self.step_channels().await?; + // Step 5: Embeddings + print_step(5, total_steps, "Embeddings (Semantic Search)"); + self.step_embeddings()?; + + // Step 6: Channel configuration + print_step(6, total_steps, "Channel Configuration"); + self.step_channels().await?; + + // Step 7: Heartbeat + print_step(7, total_steps, "Background Tasks"); + self.step_heartbeat()?; + } // Save settings and print summary self.save_and_summarize()?; @@ -116,7 +148,195 @@ impl SetupWizard { Ok(()) } - /// Step 1: NEAR AI authentication. + /// Step 1: Database connection. + async fn step_database(&mut self) -> Result<(), SetupError> { + // Check if we have an existing URL in env or settings + let existing_url = std::env::var("DATABASE_URL") + .ok() + .or_else(|| self.settings.database_url.clone()); + + if let Some(ref url) = existing_url { + // Mask the password for display + let display_url = mask_password_in_url(url); + print_info(&format!("Existing database URL: {}", display_url)); + + if confirm("Use this database?", true).map_err(SetupError::Io)? { + // Test the connection + if let Err(e) = self.test_database_connection(url).await { + print_error(&format!("Connection failed: {}", e)); + print_info("Let's configure a new database URL."); + } else { + print_success("Database connection successful"); + self.settings.database_url = Some(url.clone()); + return Ok(()); + } + } + } + + // Prompt for new URL + println!(); + print_info("Enter your PostgreSQL connection URL."); + print_info("Format: postgres://user:password@host:port/database"); + println!(); + + loop { + let url = input("Database URL").map_err(SetupError::Io)?; + + if url.is_empty() { + print_error("Database URL is required."); + continue; + } + + // Test the connection + print_info("Testing connection..."); + match self.test_database_connection(&url).await { + Ok(()) => { + print_success("Database connection successful"); + + // Ask if we should run migrations + if confirm("Run database migrations?", true).map_err(SetupError::Io)? { + self.run_migrations().await?; + } + + self.settings.database_url = Some(url); + return Ok(()); + } + Err(e) => { + print_error(&format!("Connection failed: {}", e)); + if !confirm("Try again?", true).map_err(SetupError::Io)? { + return Err(SetupError::Database( + "Database connection failed".to_string(), + )); + } + } + } + } + } + + /// Test database connection and store the pool. + async fn test_database_connection(&mut self, url: &str) -> Result<(), SetupError> { + let mut cfg = PoolConfig::new(); + cfg.url = Some(url.to_string()); + cfg.pool = Some(deadpool_postgres::PoolConfig { + max_size: 5, + ..Default::default() + }); + + let pool = cfg + .create_pool(Some(Runtime::Tokio1), NoTls) + .map_err(|e| SetupError::Database(format!("Failed to create pool: {}", e)))?; + + // Test the connection + let _ = pool + .get() + .await + .map_err(|e| SetupError::Database(format!("Failed to connect: {}", e)))?; + + self.db_pool = Some(pool); + Ok(()) + } + + /// Run database migrations. + async fn run_migrations(&self) -> Result<(), SetupError> { + if let Some(ref pool) = self.db_pool { + use refinery::embed_migrations; + embed_migrations!("migrations"); + + print_info("Running migrations..."); + + let mut client = pool + .get() + .await + .map_err(|e| SetupError::Database(format!("Pool error: {}", e)))?; + + migrations::runner() + .run_async(&mut **client) + .await + .map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?; + + print_success("Migrations applied"); + } + Ok(()) + } + + /// Step 2: Security (secrets master key). + async fn step_security(&mut self) -> Result<(), SetupError> { + // Check current configuration + let env_key_exists = std::env::var("SECRETS_MASTER_KEY").is_ok(); + let keychain_key_exists = crate::secrets::keychain::has_master_key(); + + if env_key_exists { + print_info("Secrets master key found in SECRETS_MASTER_KEY environment variable."); + self.settings.secrets_master_key_source = KeySource::Env; + print_success("Security configured (env var)"); + return Ok(()); + } + + if keychain_key_exists { + print_info("Existing master key found in OS keychain."); + if confirm("Use existing keychain key?", true).map_err(SetupError::Io)? { + self.settings.secrets_master_key_source = KeySource::Keychain; + print_success("Security configured (keychain)"); + return Ok(()); + } + } + + // Offer options + println!(); + print_info("The secrets master key encrypts sensitive data like API tokens."); + print_info("Choose where to store it:"); + println!(); + + let options = [ + "OS Keychain (recommended for local installs)", + "Environment variable (for CI/Docker)", + "Skip (disable secrets features)", + ]; + + let choice = select_one("Select storage method:", &options).map_err(SetupError::Io)?; + + match choice { + 0 => { + // Generate and store in keychain + print_info("Generating master key..."); + let key = crate::secrets::keychain::generate_master_key(); + + crate::secrets::keychain::store_master_key(&key).map_err(|e| { + SetupError::Config(format!("Failed to store in keychain: {}", e)) + })?; + + // Also create crypto instance + let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); + self.secrets_crypto = Some(Arc::new( + SecretsCrypto::new(SecretString::from(key_hex)) + .map_err(|e| SetupError::Config(e.to_string()))?, + )); + + self.settings.secrets_master_key_source = KeySource::Keychain; + print_success("Master key generated and stored in OS keychain"); + } + 1 => { + // Env var mode + print_info("Generate a key and add it to your environment:"); + let key_hex = crate::secrets::keychain::generate_master_key_hex(); + println!(); + println!(" export SECRETS_MASTER_KEY={}", key_hex); + println!(); + print_info("Add this to your shell profile or .env file."); + + self.settings.secrets_master_key_source = KeySource::Env; + print_success("Configured for environment variable"); + } + _ => { + self.settings.secrets_master_key_source = KeySource::None; + print_info("Secrets features disabled. Channel tokens must be set via env vars."); + } + } + + Ok(()) + } + + /// Step 3: NEAR AI authentication. async fn step_authentication(&mut self) -> Result<(), SetupError> { // Check if we already have a session if let Some(ref session) = self.session_manager { @@ -152,7 +372,7 @@ impl SetupWizard { Ok(()) } - /// Step 2: Model selection. + /// Step 4: Model selection. async fn step_model_selection(&mut self) -> Result<(), SetupError> { // Show current model if already configured if let Some(ref current) = self.settings.selected_model { @@ -160,7 +380,8 @@ impl SetupWizard { println!(); let options = ["Keep current model", "Change model"]; - let choice = select_one("What would you like to do?", &options)?; + let choice = + select_one("What would you like to do?", &options).map_err(SetupError::Io)?; if choice == 0 { print_success(&format!("Keeping {}", current)); @@ -201,11 +422,11 @@ impl SetupWizard { let mut all_options = options.clone(); all_options.push("Custom model ID"); - let choice = select_one("Select a model:", &all_options)?; + let choice = select_one("Select a model:", &all_options).map_err(SetupError::Io)?; let selected_model = if choice == all_options.len() - 1 { // Custom model - input("Enter model ID")? + input("Enter model ID").map_err(SetupError::Io)? } else if models.is_empty() { default_models[choice].0.to_string() } else { @@ -220,11 +441,9 @@ impl SetupWizard { /// Fetch available models from the API. async fn fetch_available_models(&self, session: &Arc) -> Vec { - // Create a temporary LLM provider to fetch models use crate::config::LlmConfig; use crate::llm::create_llm_provider; - // Read base URL from env, fallback to cloud-api.near.ai let base_url = std::env::var("NEARAI_BASE_URL") .unwrap_or_else(|_| "https://cloud-api.near.ai".to_string()); let auth_base_url = std::env::var("NEARAI_AUTH_URL") @@ -232,7 +451,7 @@ impl SetupWizard { let config = LlmConfig { nearai: crate::config::NearAiConfig { - model: "dummy".to_string(), // Not used for listing + model: "dummy".to_string(), base_url, auth_base_url, session_path: crate::llm::session::default_session_path(), @@ -259,65 +478,91 @@ impl SetupWizard { } } - /// Initialize secrets context for channel setup. - async fn init_secrets_context(&self) -> Result { - // Get DATABASE_URL - let database_url = std::env::var("DATABASE_URL").map_err(|_| { - SetupError::Config( - "DATABASE_URL not set. Please set it in .env or environment.".to_string(), - ) - })?; + /// Step 5: Embeddings configuration. + fn step_embeddings(&mut self) -> Result<(), SetupError> { + print_info("Embeddings enable semantic search in your workspace memory."); + println!(); - // Get or generate SECRETS_MASTER_KEY - let master_key = match std::env::var("SECRETS_MASTER_KEY") { - Ok(key) => { - if key.len() < 32 { - return Err(SetupError::Config( - "SECRETS_MASTER_KEY must be at least 32 characters".to_string(), - )); + if !confirm("Enable semantic search?", true).map_err(SetupError::Io)? { + self.settings.embeddings.enabled = false; + print_info("Embeddings disabled. Workspace will use keyword search only."); + return Ok(()); + } + + let options = [ + "NEAR AI (uses same auth, no extra cost)", + "OpenAI (requires API key)", + ]; + + let choice = select_one("Select embeddings provider:", &options).map_err(SetupError::Io)?; + + match choice { + 0 => { + self.settings.embeddings.enabled = true; + self.settings.embeddings.provider = "nearai".to_string(); + self.settings.embeddings.model = "text-embedding-3-small".to_string(); + print_success("Embeddings enabled via NEAR AI"); + } + 1 => { + // Check if API key is set + if std::env::var("OPENAI_API_KEY").is_err() { + print_info("OPENAI_API_KEY not set in environment."); + print_info("Add it to your .env file or environment to enable embeddings."); } - key + self.settings.embeddings.enabled = true; + self.settings.embeddings.provider = "openai".to_string(); + self.settings.embeddings.model = "text-embedding-3-small".to_string(); + print_success("Embeddings configured for OpenAI"); } - Err(_) => { - // Generate a new master key - print_info("SECRETS_MASTER_KEY not set. Generating a new one..."); - let key = generate_master_key(); - print_info(&format!( - "Generated master key. Add to your .env file:\nSECRETS_MASTER_KEY={}", - key - )); - key - } - }; + _ => unreachable!(), + } - // Create database pool - let mut cfg = PoolConfig::new(); - cfg.url = Some(database_url); - cfg.pool = Some(deadpool_postgres::PoolConfig { - max_size: 5, - ..Default::default() - }); - - let pool = cfg - .create_pool(Some(Runtime::Tokio1), NoTls) - .map_err(|e| SetupError::Database(format!("Failed to create pool: {}", e)))?; - - // Test connection - let _ = pool - .get() - .await - .map_err(|e| SetupError::Database(format!("Failed to connect to database: {}", e)))?; - - print_success("Connected to database"); - - // Create crypto - let crypto = SecretsCrypto::new(SecretString::from(master_key)) - .map_err(|e| SetupError::Config(format!("Invalid master key: {}", e)))?; - - Ok(SecretsContext::new(pool, Arc::new(crypto), "default")) + Ok(()) } - /// Step 3: Channel configuration. + /// Initialize secrets context for channel setup. + async fn init_secrets_context(&mut self) -> Result { + // Get database pool (should be set from step 1) + let pool = if let Some(ref p) = self.db_pool { + p.clone() + } else { + // Fall back to creating one from settings/env + let url = self + .settings + .database_url + .clone() + .or_else(|| std::env::var("DATABASE_URL").ok()) + .ok_or_else(|| SetupError::Config("Database URL not configured".to_string()))?; + + self.test_database_connection(&url).await?; + self.db_pool.clone().unwrap() + }; + + // Get crypto (should be set from step 2, or load from keychain/env) + let crypto = if let Some(ref c) = self.secrets_crypto { + Arc::clone(c) + } else { + // Try to load master key from keychain or env + let key = if let Ok(env_key) = std::env::var("SECRETS_MASTER_KEY") { + env_key + } else if let Ok(keychain_key) = crate::secrets::keychain::get_master_key() { + keychain_key.iter().map(|b| format!("{:02x}", b)).collect() + } else { + return Err(SetupError::Config( + "Secrets not configured. Run full setup or set SECRETS_MASTER_KEY.".to_string(), + )); + }; + + let crypto = SecretsCrypto::new(SecretString::from(key)) + .map_err(|e| SetupError::Config(e.to_string()))?; + self.secrets_crypto = Some(Arc::new(crypto)); + Arc::clone(self.secrets_crypto.as_ref().unwrap()) + }; + + Ok(SecretsContext::new(pool, crypto, "default")) + } + + /// Step 6: Channel configuration. async fn step_channels(&mut self) -> Result<(), SetupError> { // First, configure tunnel (shared across all channels that need webhooks) match setup_tunnel() { @@ -336,7 +581,7 @@ impl SetupWizard { // Discover available WASM channels let channels_dir = dirs::home_dir() .unwrap_or_default() - .join(".near-agent/channels"); + .join(".ironclaw/channels"); let discovered_channels = discover_wasm_channels(&channels_dir).await; @@ -359,12 +604,20 @@ impl SetupWizard { let options_refs: Vec<(&str, bool)> = options.iter().map(|(s, b)| (s.as_str(), *b)).collect(); - let selected = select_many("Which channels do you want to enable?", &options_refs)?; + let selected = select_many("Which channels do you want to enable?", &options_refs) + .map_err(SetupError::Io)?; // Determine if we need secrets context let needs_secrets = selected.iter().any(|&i| i >= 1); let secrets = if needs_secrets { - Some(self.init_secrets_context().await?) + match self.init_secrets_context().await { + Ok(ctx) => Some(ctx), + Err(e) => { + print_info(&format!("Secrets not available: {}", e)); + print_info("Channel tokens must be set via environment variables."); + None + } + } } else { None }; @@ -376,6 +629,10 @@ impl SetupWizard { let result = setup_http(ctx).await.map_err(SetupError::Channel)?; self.settings.channels.http_enabled = result.enabled; self.settings.channels.http_port = Some(result.port); + } else { + self.settings.channels.http_enabled = true; + self.settings.channels.http_port = Some(8080); + print_info("HTTP webhook enabled on port 8080 (set HTTP_WEBHOOK_SECRET in env)"); } } else { self.settings.channels.http_enabled = false; @@ -418,6 +675,13 @@ impl SetupWizard { if result.enabled { enabled_wasm_channels.push(result.channel_name); } + } else { + // No secrets context, just enable the channel + print_info(&format!( + "{} enabled (configure tokens via environment)", + capitalize_first(channel_name) + )); + enabled_wasm_channels.push(channel_name.clone()); } } } @@ -426,6 +690,45 @@ impl SetupWizard { Ok(()) } + /// Step 7: Heartbeat configuration. + fn step_heartbeat(&mut self) -> Result<(), SetupError> { + print_info("Heartbeat runs periodic background tasks (e.g., checking your calendar,"); + print_info("monitoring for notifications, running scheduled workflows)."); + println!(); + + if !confirm("Enable heartbeat?", false).map_err(SetupError::Io)? { + self.settings.heartbeat.enabled = false; + print_info("Heartbeat disabled."); + return Ok(()); + } + + self.settings.heartbeat.enabled = true; + + // Interval + let interval_str = optional_input("Check interval in minutes", Some("default: 30")) + .map_err(SetupError::Io)?; + + if let Some(s) = interval_str { + if let Ok(mins) = s.parse::() { + self.settings.heartbeat.interval_secs = mins * 60; + } + } else { + self.settings.heartbeat.interval_secs = 1800; // 30 minutes + } + + // Notify channel + let notify_channel = optional_input("Notify channel on findings", Some("e.g., telegram")) + .map_err(SetupError::Io)?; + self.settings.heartbeat.notify_channel = notify_channel; + + print_success(&format!( + "Heartbeat enabled (every {} minutes)", + self.settings.heartbeat.interval_secs / 60 + )); + + Ok(()) + } + /// Save settings and print summary. fn save_and_summarize(&mut self) -> Result<(), SetupError> { self.settings.setup_completed = true; @@ -438,15 +741,40 @@ impl SetupWizard { })?; println!(); - print_success("Configuration saved to ~/.near-agent/"); + print_success("Configuration saved to ~/.ironclaw/"); println!(); // Print summary 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!(" near-agent"); + println!(" ironclaw"); + println!(); + println!("To change settings later:"); + println!(" ironclaw config set "); + println!(" ironclaw setup"); println!(); Ok(()) } } -/// Generate a random 32-byte master key as hex string. -fn generate_master_key() -> String { - use rand::RngCore; - let mut rng = rand::thread_rng(); - let mut bytes = [0u8; 32]; - rng.fill_bytes(&mut bytes); - bytes.iter().map(|b| format!("{:02x}", b)).collect() -} - impl Default for SetupWizard { fn default() -> Self { Self::new() } } +/// Mask password in a database URL for display. +fn mask_password_in_url(url: &str) -> String { + // URL format: scheme://user:password@host/database + // Find "://" to locate start of credentials + let Some(scheme_end) = url.find("://") else { + return url.to_string(); + }; + let credentials_start = scheme_end + 3; // After "://" + + // Find "@" to locate end of credentials + let Some(at_pos) = url[credentials_start..].find('@') else { + return url.to_string(); + }; + let at_abs = credentials_start + at_pos; + + // Find ":" in the credentials section (separates user from password) + let credentials = &url[credentials_start..at_abs]; + let Some(colon_pos) = credentials.find(':') else { + return url.to_string(); + }; + + // Build masked URL: scheme://user:****@host/database + let scheme = &url[..credentials_start]; // "postgres://" + let username = &credentials[..colon_pos]; // "user" + let after_at = &url[at_abs..]; // "@localhost/db" + + format!("{}{}:****{}", scheme, username, after_at) +} + /// Discover WASM channels in a directory. /// /// Returns a list of (channel_name, capabilities_file) pairs. @@ -595,8 +954,23 @@ mod tests { } #[test] - fn test_generate_master_key() { - let key = generate_master_key(); - assert_eq!(key.len(), 64); // 32 bytes = 64 hex chars + fn test_mask_password_in_url() { + assert_eq!( + mask_password_in_url("postgres://user:secret@localhost/db"), + "postgres://user:****@localhost/db" + ); + + // URL without password + assert_eq!( + mask_password_in_url("postgres://localhost/db"), + "postgres://localhost/db" + ); + } + + #[test] + fn test_capitalize_first() { + assert_eq!(capitalize_first("telegram"), "Telegram"); + assert_eq!(capitalize_first("CAPS"), "CAPS"); + assert_eq!(capitalize_first(""), ""); } } diff --git a/src/tools/builder/core.rs b/src/tools/builder/core.rs index 839ff23f..e46c8aaa 100644 --- a/src/tools/builder/core.rs +++ b/src/tools/builder/core.rs @@ -218,7 +218,7 @@ pub struct BuilderConfig { impl Default for BuilderConfig { fn default() -> Self { Self { - build_dir: std::env::temp_dir().join("near-agent-builds"), + build_dir: std::env::temp_dir().join("ironclaw-builds"), max_iterations: 10, timeout: Duration::from_secs(600), // 10 minutes cleanup_on_failure: false, // Keep for debugging @@ -532,9 +532,12 @@ Create alongside the .wasm file to grant capabilities: .messages .push(ChatMessage::system(self.build_system_prompt(requirement))); - // Add initial user message + // Add initial user message - directive to force immediate tool use reason_ctx.messages.push(ChatMessage::user(format!( - "Build the {} in directory: {}\n\nRequirements:\n- {}\n\nStart by creating the project structure.", + "Build the {} in directory: {}\n\n\ + Requirements:\n- {}\n\n\ + IMPORTANT: Use the write_file tool NOW to create Cargo.toml. \ + Do not explain, plan, or output JSON—immediately call write_file.", requirement.name, project_dir.display(), requirement.description @@ -551,6 +554,7 @@ Create alongside the .wasm file to grant capabilities: let mut current_phase = BuildPhase::Scaffolding; let mut last_error: Option = None; let mut tools_executed = false; + let mut consecutive_text_responses = 0; loop { iteration += 1; @@ -593,27 +597,65 @@ Create alongside the .wasm file to grant capabilities: match result { RespondResult::Text(response) => { - // If no tools have been executed, prompt for tool use - if !tools_executed && iteration < 3 { + reason_ctx.messages.push(ChatMessage::assistant(&response)); + + // If tools haven't been executed yet, we're stuck in planning mode + if !tools_executed { + consecutive_text_responses += 1; + + // Fail fast after 2 consecutive text-only responses + if consecutive_text_responses >= 2 { + logs.push(BuildLog { + timestamp: Utc::now(), + phase: BuildPhase::Failed, + message: "Builder stuck in planning mode".into(), + details: Some(format!( + "LLM returned {} consecutive text responses without calling tools. \ + Try a more specific requirement.", + consecutive_text_responses + )), + }); + + return Ok(BuildResult { + build_id, + requirement: requirement.clone(), + artifact_path: project_dir.to_path_buf(), + logs, + success: false, + error: Some( + "LLM not executing tools - stuck in planning mode".into(), + ), + started_at, + completed_at: Utc::now(), + iterations: iteration, + validation_warnings: Vec::new(), + tests_passed: 0, + tests_failed: 0, + registered: false, + }); + } + tracing::debug!( - "Builder: no tools executed yet (iteration {}), prompting for action", - iteration + "Builder: no tools executed (text response #{}/2), forcing tool use", + consecutive_text_responses ); - reason_ctx.messages.push(ChatMessage::assistant(&response)); reason_ctx.messages.push(ChatMessage::user( - "Please use the available tools to implement this. Start by creating the necessary files.", + "STOP. Do NOT output text, JSON specs, or explanations. \ + Call the write_file tool RIGHT NOW to create Cargo.toml. \ + Just call the tool—no commentary.", )); continue; } - reason_ctx.messages.push(ChatMessage::assistant(&response)); + // Reset counter when tools have been executed (we're in completion phase) + consecutive_text_responses = 0; // Check for completion signals let response_lower = response.to_lowercase(); if response_lower.contains("build complete") || response_lower.contains("successfully built") || response_lower.contains("all tests pass") - || (tools_executed && response_lower.contains("complete")) + || response_lower.contains("complete") { logs.push(BuildLog { timestamp: Utc::now(), diff --git a/src/tools/wasm/loader.rs b/src/tools/wasm/loader.rs index 7fad73f9..0029b4c3 100644 --- a/src/tools/wasm/loader.rs +++ b/src/tools/wasm/loader.rs @@ -7,7 +7,7 @@ //! # Example: Loading from Directory //! //! ```text -//! ~/.near-agent/tools/ +//! ~/.ironclaw/tools/ //! ├── slack.wasm //! ├── slack.capabilities.json //! ├── github.wasm @@ -16,7 +16,7 @@ //! //! ```ignore //! let loader = WasmToolLoader::new(runtime, registry); -//! loader.load_from_dir(Path::new("~/.near-agent/tools/")).await?; +//! loader.load_from_dir(Path::new("~/.ironclaw/tools/")).await?; //! ``` //! //! # Security diff --git a/src/tools/wasm/mod.rs b/src/tools/wasm/mod.rs index c496acbe..378aa6ba 100644 --- a/src/tools/wasm/mod.rs +++ b/src/tools/wasm/mod.rs @@ -51,8 +51,8 @@ //! # Example //! //! ```ignore -//! use near_agent::tools::wasm::{WasmToolRuntime, WasmRuntimeConfig, WasmToolWrapper}; -//! use near_agent::tools::wasm::Capabilities; +//! use ironclaw::tools::wasm::{WasmToolRuntime, WasmRuntimeConfig, WasmToolWrapper}; +//! use ironclaw::tools::wasm::Capabilities; //! use std::sync::Arc; //! //! // Create runtime diff --git a/tests/wasm_channel_integration.rs b/tests/wasm_channel_integration.rs index 388ccc52..7ac0b909 100644 --- a/tests/wasm_channel_integration.rs +++ b/tests/wasm_channel_integration.rs @@ -9,8 +9,8 @@ use std::collections::HashMap; use std::sync::Arc; -use near_agent::channels::Channel; -use near_agent::channels::wasm::{ +use ironclaw::channels::Channel; +use ironclaw::channels::wasm::{ ChannelCapabilities, EmitRateLimitConfig, PreparedChannelModule, RegisteredEndpoint, WasmChannel, WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig, }; @@ -238,7 +238,7 @@ mod loader_tests { async fn test_discover_channels_empty_dir() { let dir = TempDir::new().expect("Failed to create temp dir"); - let channels = near_agent::channels::wasm::discover_channels(dir.path()) + let channels = ironclaw::channels::wasm::discover_channels(dir.path()) .await .expect("Discovery failed"); @@ -253,7 +253,7 @@ mod loader_tests { std::fs::File::create(dir.path().join("slack.wasm")).expect("Failed to create file"); std::fs::File::create(dir.path().join("telegram.wasm")).expect("Failed to create file"); - let channels = near_agent::channels::wasm::discover_channels(dir.path()) + let channels = ironclaw::channels::wasm::discover_channels(dir.path()) .await .expect("Discovery failed"); @@ -284,7 +284,7 @@ mod loader_tests { ) .expect("Failed to write capabilities"); - let channels = near_agent::channels::wasm::discover_channels(dir.path()) + let channels = ironclaw::channels::wasm::discover_channels(dir.path()) .await .expect("Discovery failed"); @@ -301,7 +301,7 @@ mod loader_tests { std::fs::File::create(dir.path().join("config.json")).expect("Failed to create file"); std::fs::File::create(dir.path().join("channel.wasm")).expect("Failed to create file"); - let channels = near_agent::channels::wasm::discover_channels(dir.path()) + let channels = ironclaw::channels::wasm::discover_channels(dir.path()) .await .expect("Discovery failed"); @@ -388,7 +388,7 @@ mod capabilities_tests { mod message_emission_tests { use super::*; - use near_agent::channels::wasm::{ChannelHostState, EmittedMessage}; + use ironclaw::channels::wasm::{ChannelHostState, EmittedMessage}; #[test] fn test_emit_message_basic() { diff --git a/tests/workspace_integration.rs b/tests/workspace_integration.rs index 63519abb..568d00b9 100644 --- a/tests/workspace_integration.rs +++ b/tests/workspace_integration.rs @@ -1,15 +1,15 @@ //! Integration tests for the workspace module. //! //! Requires a running PostgreSQL with pgvector extension. -//! Set DATABASE_URL=postgres://localhost/near_agent_test +//! Set DATABASE_URL=postgres://localhost/ironclaw_test use std::sync::Arc; -use near_agent::workspace::{MockEmbeddings, SearchConfig, Workspace, paths}; +use ironclaw::workspace::{MockEmbeddings, SearchConfig, Workspace, paths}; fn get_pool() -> deadpool_postgres::Pool { let database_url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgres://localhost/near_agent_test".to_string()); + .unwrap_or_else(|_| "postgres://localhost/ironclaw_test".to_string()); let config: tokio_postgres::Config = database_url.parse().expect("Invalid DATABASE_URL");