Merge remote-tracking branch 'origin/main'

This commit is contained in:
Illia Polosukhin
2026-02-05 09:51:46 -08:00
39 changed files with 2728 additions and 391 deletions
+17 -9
View File
@@ -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
Generated
+567 -52
View File
@@ -182,6 +182,137 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix 1.1.3",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix 1.1.3",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "async-signal"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix 1.1.3",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -316,6 +447,28 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "bollard"
version = "0.18.1"
@@ -525,6 +678,15 @@ dependencies = [
"rustversion",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.2.55"
@@ -642,6 +804,15 @@ dependencies = [
"static_assertions",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "constant_time_eq"
version = "0.4.2"
@@ -1133,6 +1304,33 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1160,6 +1358,27 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.2.0"
@@ -1292,6 +1511,19 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.31"
@@ -1828,6 +2060,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
@@ -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",
]
+11 -2
View File
@@ -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"] }
+2 -2
View File
@@ -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_
+96 -71
View File
@@ -5,58 +5,60 @@
<h1 align="center">IronClaw</h1>
<p align="center">
<strong>LLM-powered autonomous agent for the NEAR AI marketplace</strong>
<strong>Your secure personal AI assistant, always on your side</strong>
</p>
<p align="center">
<a href="#philosophy">Philosophy</a> •
<a href="#features">Features</a> •
<a href="#openclaw-feature-parity">Parity</a> •
<a href="#installation">Installation</a> •
<a href="#configuration">Configuration</a> •
<a href="#architecture">Architecture</a> •
<a href="#security">Security</a>
<a href="#security">Security</a> •
<a href="#architecture">Architecture</a>
</p>
---
## 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:
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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.
+6 -6
View File
@@ -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
+1 -1
View File
@@ -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
+8 -8
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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 {
+3 -3
View File
@@ -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:
//! - `<name>.wasm` - The compiled WASM component
//! - `<name>.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")
}
+2 -2
View File
@@ -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 {
+195
View File
@@ -0,0 +1,195 @@
//! Configuration management CLI commands.
//!
//! Commands for viewing and modifying settings.
use clap::Subcommand;
use crate::settings::Settings;
#[derive(Subcommand, Debug, Clone)]
pub enum ConfigCommand {
/// List all settings and their current values
List {
/// Show only settings matching this prefix (e.g., "agent", "heartbeat")
#[arg(short, long)]
filter: Option<String>,
},
/// Get a specific setting value
Get {
/// Setting path (e.g., "agent.max_parallel_jobs")
path: String,
},
/// Set a setting value
Set {
/// Setting path (e.g., "agent.max_parallel_jobs")
path: String,
/// Value to set
value: String,
},
/// Reset a setting to its default value
Reset {
/// Setting path (e.g., "agent.max_parallel_jobs")
path: String,
},
/// Show the settings file path
Path,
}
/// Run a config command.
pub fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> {
match cmd {
ConfigCommand::List { filter } => list_settings(filter),
ConfigCommand::Get { path } => get_setting(&path),
ConfigCommand::Set { path, value } => set_setting(&path, &value),
ConfigCommand::Reset { path } => reset_setting(&path),
ConfigCommand::Path => show_path(),
}
}
/// List all settings.
fn list_settings(filter: Option<String>) -> anyhow::Result<()> {
let settings = Settings::load();
let all = settings.list();
// Find the longest key for alignment
let max_key_len = all.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
println!("Settings:");
println!();
for (key, value) in all {
// Skip if filter is set and doesn't match
if let Some(ref f) = filter {
if !key.starts_with(f) {
continue;
}
}
// Truncate long values for display
let display_value = if value.len() > 60 {
format!("{}...", &value[..57])
} else {
value
};
println!(" {:width$} {}", key, display_value, width = max_key_len);
}
Ok(())
}
/// Get a specific setting.
fn get_setting(path: &str) -> anyhow::Result<()> {
let settings = Settings::load();
match settings.get(path) {
Some(value) => {
println!("{}", value);
Ok(())
}
None => {
anyhow::bail!("Setting not found: {}", path);
}
}
}
/// Set a setting value.
fn set_setting(path: &str, value: &str) -> anyhow::Result<()> {
let mut settings = Settings::load();
// Try to set the value
settings
.set(path, value)
.map_err(|e| anyhow::anyhow!("{}", e))?;
// Save to disk
settings.save()?;
println!("Set {} = {}", path, value);
Ok(())
}
/// Reset a setting to default.
fn reset_setting(path: &str) -> anyhow::Result<()> {
let mut settings = Settings::load();
// Get the default value for display
let default = Settings::default();
let default_value = default
.get(path)
.ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?;
// Reset it
settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?;
// Save to disk
settings.save()?;
println!("Reset {} to default: {}", path, default_value);
Ok(())
}
/// Show the settings file path.
fn show_path() -> anyhow::Result<()> {
let path = Settings::default_path();
println!("{}", path.display());
if path.exists() {
let metadata = std::fs::metadata(&path)?;
println!(" Size: {} bytes", metadata.len());
if let Ok(modified) = metadata.modified() {
use std::time::SystemTime;
let duration = SystemTime::now()
.duration_since(modified)
.unwrap_or_default();
let secs = duration.as_secs();
if secs < 60 {
println!(" Modified: {} seconds ago", secs);
} else if secs < 3600 {
println!(" Modified: {} minutes ago", secs / 60);
} else if secs < 86400 {
println!(" Modified: {} hours ago", secs / 3600);
} else {
println!(" Modified: {} days ago", secs / 86400);
}
}
} else {
println!(" (does not exist, using defaults)");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_list_settings() {
// Just verify it doesn't panic
let settings = Settings::default();
let list = settings.list();
assert!(!list.is_empty());
}
#[test]
fn test_get_set_reset() {
let _dir = tempdir().unwrap();
let mut settings = Settings::default();
// Set a value
settings.set("agent.name", "testbot").unwrap();
assert_eq!(settings.agent.name, "testbot");
// Reset to default
settings.reset("agent.name").unwrap();
assert_eq!(settings.agent.name, "ironclaw");
}
}
+11 -6
View File
@@ -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 {
+8 -8
View File
@@ -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<PathBuf>,
/// Target directory for installation (default: ~/.near-agent/tools/)
/// Target directory for installation (default: ~/.ironclaw/tools/)
#[arg(short, long)]
target: Option<PathBuf>,
@@ -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<PathBuf>,
@@ -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<PathBuf>,
},
@@ -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<PathBuf>,
},
@@ -420,7 +420,7 @@ async fn list_tools(dir: Option<PathBuf>, 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 <path>");
println!("Install a tool with: ironclaw tool install <path>");
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"));
}
}
+160 -48
View File
@@ -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<Self, ConfigError> {
let settings = crate::settings::Settings::load();
// Priority: env var > settings > error (required)
let url = optional_env("DATABASE_URL")?
.or(settings.database_url.clone())
.ok_or_else(|| ConfigError::MissingRequired {
key: "database_url".to_string(),
hint: "Run 'ironclaw setup' or set DATABASE_URL environment variable".to_string(),
})?;
// Priority: env var > settings > default
let pool_size = optional_env("DATABASE_POOL_SIZE")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "DATABASE_POOL_SIZE".to_string(),
message: format!("must be a positive integer: {e}"),
})?
.or(settings.database_pool_size)
.unwrap_or(10);
Ok(Self {
url: SecretString::from(required_env("DATABASE_URL")?),
pool_size: optional_env("DATABASE_POOL_SIZE")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "DATABASE_POOL_SIZE".to_string(),
message: format!("must be a positive integer: {e}"),
})?
.unwrap_or(10),
url: SecretString::from(url),
pool_size,
})
}
@@ -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<Self, ConfigError> {
let settings = crate::settings::Settings::load();
let openai_api_key = optional_env("OPENAI_API_KEY")?.map(SecretString::from);
let provider = optional_env("EMBEDDING_PROVIDER")?.unwrap_or_else(|| "openai".to_string());
// Auto-enable if we have an API key
// Priority: env var > settings > default
let provider = optional_env("EMBEDDING_PROVIDER")?
.unwrap_or_else(|| settings.embeddings.provider.clone());
let model =
optional_env("EMBEDDING_MODEL")?.unwrap_or_else(|| settings.embeddings.model.clone());
// Priority: env var > settings > auto-detect from API key
let enabled = optional_env("EMBEDDING_ENABLED")?
.map(|s| s.parse())
.transpose()
@@ -280,14 +301,16 @@ impl EmbeddingsConfig {
key: "EMBEDDING_ENABLED".to_string(),
message: format!("must be 'true' or 'false': {e}"),
})?
.unwrap_or(openai_api_key.is_some());
.unwrap_or_else(|| {
// Check settings, or auto-enable if API key present
settings.embeddings.enabled || openai_api_key.is_some()
});
Ok(Self {
enabled,
provider,
openai_api_key,
model: optional_env("EMBEDDING_MODEL")?
.unwrap_or_else(|| "text-embedding-3-small".to_string()),
model,
})
}
@@ -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<HttpConfig>,
/// 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<Self, ConfigError> {
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<SecretString>,
/// Whether secrets management is enabled.
pub enabled: bool,
/// Source of the master key.
pub source: crate::settings::KeySource,
}
impl std::fmt::Debug for SecretsConfig {
@@ -476,13 +539,53 @@ impl std::fmt::Debug for SecretsConfig {
f.debug_struct("SecretsConfig")
.field("master_key", &self.master_key.is_some())
.field("enabled", &self.enabled)
.field("source", &self.source)
.finish()
}
}
impl SecretsConfig {
fn from_env() -> Result<Self, ConfigError> {
let master_key = optional_env("SECRETS_MASTER_KEY")?.map(SecretString::from);
use crate::settings::KeySource;
let settings = crate::settings::Settings::load();
// Priority: env var > keychain (based on settings) > disabled
let (master_key, source) = if let Some(env_key) = optional_env("SECRETS_MASTER_KEY")? {
// Env var takes priority (for CI/Docker)
(Some(SecretString::from(env_key)), KeySource::Env)
} else {
match settings.secrets_master_key_source {
KeySource::Keychain => {
// Try to load from OS keychain
match crate::secrets::keychain::get_master_key() {
Ok(key_bytes) => {
let key_hex: String =
key_bytes.iter().map(|b| format!("{:02x}", b)).collect();
(Some(SecretString::from(key_hex)), KeySource::Keychain)
}
Err(_) => {
// Keychain configured but key not found
// This might happen if keychain was cleared
tracing::warn!(
"Secrets configured for keychain but key not found. \
Run 'ironclaw setup' to reconfigure."
);
(None, KeySource::None)
}
}
}
KeySource::Env => {
// Settings say env, but no env var found
tracing::warn!(
"Secrets configured for env var but SECRETS_MASTER_KEY not set."
);
(None, KeySource::None)
}
KeySource::None => (None, KeySource::None),
}
};
let enabled = master_key.is_some();
// Validate master key length if provided
@@ -498,6 +601,7 @@ impl SecretsConfig {
Ok(Self {
master_key,
enabled,
source,
})
}
@@ -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<Self, ConfigError> {
let settings = crate::settings::Settings::load();
Ok(Self {
// Priority: env var > settings > default
enabled: optional_env("HEARTBEAT_ENABLED")?
.map(|s| s.parse())
.transpose()
@@ -684,10 +791,19 @@ impl HeartbeatConfig {
key: "HEARTBEAT_ENABLED".to_string(),
message: format!("must be 'true' or 'false': {e}"),
})?
.unwrap_or(false),
interval_secs: parse_optional_env("HEARTBEAT_INTERVAL_SECS", 1800)?,
notify_channel: optional_env("HEARTBEAT_NOTIFY_CHANNEL")?,
notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?,
.unwrap_or(settings.heartbeat.enabled),
interval_secs: optional_env("HEARTBEAT_INTERVAL_SECS")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "HEARTBEAT_INTERVAL_SECS".to_string(),
message: format!("must be a positive integer: {e}"),
})?
.unwrap_or(settings.heartbeat.interval_secs),
notify_channel: optional_env("HEARTBEAT_NOTIFY_CHANNEL")?
.or(settings.heartbeat.notify_channel.clone()),
notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?
.or(settings.heartbeat.notify_user.clone()),
})
}
}
@@ -787,10 +903,6 @@ impl SandboxModeConfig {
// Helper functions
fn required_env(key: &str) -> Result<String, ConfigError> {
std::env::var(key).map_err(|_| ConfigError::MissingEnvVar(key.to_string()))
}
fn optional_env(key: &str) -> Result<Option<String>, ConfigError> {
match std::env::var(key) {
Ok(val) if val.is_empty() => Ok(None),
+4 -1
View File
@@ -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 },
+5 -5
View File
@@ -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"));
}
}
+55 -13
View File
@@ -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<near_agent::channels::wasm::WasmChannel>,
channel: &Arc<ironclaw::channels::wasm::WasmChannel>,
secrets: &dyn SecretsStore,
channel_name: &str,
) -> anyhow::Result<usize> {
+1 -1
View File
@@ -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;
//!
+346
View File
@@ -0,0 +1,346 @@
//! OS keychain integration for secrets master key storage.
//!
//! Provides platform-specific keychain support:
//! - macOS: security-framework (Keychain Services)
//! - Linux: secret-service (GNOME Keyring, KWallet)
//!
//! # Example
//!
//! ```ignore
//! use ironclaw::secrets::keychain::{store_master_key, get_master_key, delete_master_key};
//!
//! // Generate and store a new master key
//! let key = generate_master_key();
//! store_master_key(&key)?;
//!
//! // Later, retrieve it
//! let key = get_master_key()?;
//! ```
use crate::secrets::SecretError;
/// Service name for keychain entries.
const SERVICE_NAME: &str = "ironclaw";
/// Account name for the master key.
const MASTER_KEY_ACCOUNT: &str = "master_key";
/// Generate a random 32-byte master key.
pub fn generate_master_key() -> Vec<u8> {
use rand::RngCore;
let mut key = vec![0u8; 32];
rand::thread_rng().fill_bytes(&mut key);
key
}
/// Generate a master key as a hex string.
pub fn generate_master_key_hex() -> String {
let bytes = generate_master_key();
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
// ============================================================================
// macOS implementation using security-framework
// ============================================================================
#[cfg(target_os = "macos")]
mod platform {
use security_framework::passwords::{
delete_generic_password, get_generic_password, set_generic_password,
};
use super::*;
/// Store the master key in the macOS Keychain.
pub fn store_master_key(key: &[u8]) -> Result<(), SecretError> {
// Convert to hex for storage (keychain prefers strings)
let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect();
set_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT, key_hex.as_bytes())
.map_err(|e| SecretError::KeychainError(format!("Failed to store in keychain: {}", e)))
}
/// Retrieve the master key from the macOS Keychain.
pub fn get_master_key() -> Result<Vec<u8>, SecretError> {
let password = get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|e| {
SecretError::KeychainError(format!("Failed to get from keychain: {}", e))
})?;
// Parse hex string back to bytes
let hex_str = String::from_utf8(password)
.map_err(|_| SecretError::KeychainError("Invalid UTF-8 in keychain".to_string()))?;
hex_to_bytes(&hex_str)
}
/// Delete the master key from the macOS Keychain.
pub fn delete_master_key() -> Result<(), SecretError> {
delete_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|e| {
SecretError::KeychainError(format!("Failed to delete from keychain: {}", e))
})
}
/// Check if a master key exists in the keychain.
pub fn has_master_key() -> bool {
get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).is_ok()
}
}
// ============================================================================
// Linux implementation using secret-service
// ============================================================================
#[cfg(target_os = "linux")]
mod platform {
use secret_service::{EncryptionType, SecretService};
use super::*;
/// Store the master key in the Linux secret service (GNOME Keyring, KWallet).
pub fn store_master_key(key: &[u8]) -> Result<(), SecretError> {
let rt = tokio::runtime::Handle::try_current()
.map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?;
rt.block_on(async {
let ss = SecretService::connect(EncryptionType::Dh)
.await
.map_err(|e| {
SecretError::KeychainError(format!(
"Failed to connect to secret service: {}",
e
))
})?;
let collection = ss.get_default_collection().await.map_err(|e| {
SecretError::KeychainError(format!("Failed to get collection: {}", e))
})?;
// Unlock if needed
if collection.is_locked().await.unwrap_or(true) {
collection.unlock().await.map_err(|e| {
SecretError::KeychainError(format!("Failed to unlock collection: {}", e))
})?;
}
// Convert to hex for storage
let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect();
collection
.create_item(
&format!("{} master key", SERVICE_NAME),
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
key_hex.as_bytes(),
true, // Replace if exists
"text/plain",
)
.await
.map_err(|e| {
SecretError::KeychainError(format!("Failed to create secret: {}", e))
})?;
Ok(())
})
}
/// Retrieve the master key from the Linux secret service.
pub fn get_master_key() -> Result<Vec<u8>, SecretError> {
let rt = tokio::runtime::Handle::try_current()
.map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?;
rt.block_on(async {
let ss = SecretService::connect(EncryptionType::Dh)
.await
.map_err(|e| {
SecretError::KeychainError(format!(
"Failed to connect to secret service: {}",
e
))
})?;
let items = ss
.search_items(
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
)
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?;
let item = items
.unlocked
.first()
.or(items.locked.first())
.ok_or_else(|| SecretError::KeychainError("Master key not found".to_string()))?;
// Unlock if needed
if item.is_locked().await.unwrap_or(true) {
item.unlock()
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to unlock: {}", e)))?;
}
let secret = item
.get_secret()
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to get secret: {}", e)))?;
let hex_str = String::from_utf8(secret)
.map_err(|_| SecretError::KeychainError("Invalid UTF-8 in secret".to_string()))?;
hex_to_bytes(&hex_str)
})
}
/// Delete the master key from the Linux secret service.
pub fn delete_master_key() -> Result<(), SecretError> {
let rt = tokio::runtime::Handle::try_current()
.map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?;
rt.block_on(async {
let ss = SecretService::connect(EncryptionType::Dh)
.await
.map_err(|e| {
SecretError::KeychainError(format!(
"Failed to connect to secret service: {}",
e
))
})?;
let items = ss
.search_items(
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
)
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?;
for item in items.unlocked.iter().chain(items.locked.iter()) {
item.delete()
.await
.map_err(|e| SecretError::KeychainError(format!("Failed to delete: {}", e)))?;
}
Ok(())
})
}
/// Check if a master key exists in the secret service.
pub fn has_master_key() -> bool {
let rt = match tokio::runtime::Handle::try_current() {
Ok(rt) => rt,
Err(_) => return false,
};
rt.block_on(async {
let ss = match SecretService::connect(EncryptionType::Dh).await {
Ok(ss) => ss,
Err(_) => return false,
};
let items = match ss
.search_items(
[("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)]
.into_iter()
.collect(),
)
.await
{
Ok(items) => items,
Err(_) => return false,
};
!items.unlocked.is_empty() || !items.locked.is_empty()
})
}
}
// ============================================================================
// Fallback for unsupported platforms
// ============================================================================
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
mod platform {
use super::*;
pub fn store_master_key(_key: &[u8]) -> Result<(), SecretError> {
Err(SecretError::KeychainError(
"Keychain not supported on this platform. Use SECRETS_MASTER_KEY env var.".to_string(),
))
}
pub fn get_master_key() -> Result<Vec<u8>, SecretError> {
Err(SecretError::KeychainError(
"Keychain not supported on this platform. Use SECRETS_MASTER_KEY env var.".to_string(),
))
}
pub fn delete_master_key() -> Result<(), SecretError> {
Err(SecretError::KeychainError(
"Keychain not supported on this platform".to_string(),
))
}
pub fn has_master_key() -> bool {
false
}
}
// Re-export platform-specific functions
pub use platform::{delete_master_key, get_master_key, has_master_key, store_master_key};
/// Parse a hex string to bytes.
fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, SecretError> {
if hex.len() % 2 != 0 {
return Err(SecretError::KeychainError(
"Invalid hex string length".to_string(),
));
}
(0..hex.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&hex[i..i + 2], 16)
.map_err(|_| SecretError::KeychainError("Invalid hex character".to_string()))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_master_key() {
let key = generate_master_key();
assert_eq!(key.len(), 32);
// Should be different each time
let key2 = generate_master_key();
assert_ne!(key, key2);
}
#[test]
fn test_generate_master_key_hex() {
let hex = generate_master_key_hex();
assert_eq!(hex.len(), 64); // 32 bytes * 2 hex chars
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_hex_to_bytes() {
let result = hex_to_bytes("deadbeef").unwrap();
assert_eq!(result, vec![0xde, 0xad, 0xbe, 0xef]);
let result = hex_to_bytes("00ff").unwrap();
assert_eq!(result, vec![0x00, 0xff]);
}
#[test]
fn test_hex_to_bytes_invalid() {
assert!(hex_to_bytes("abc").is_err()); // Odd length
assert!(hex_to_bytes("gg").is_err()); // Invalid chars
}
}
+9 -1
View File
@@ -4,6 +4,7 @@
//! - AES-256-GCM encrypted secret storage
//! - Per-secret key derivation (HKDF-SHA256)
//! - PostgreSQL persistence
//! - OS keychain integration for master key
//! - Access control for WASM tools
//!
//! # Security Model
@@ -28,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;
+3
View File
@@ -156,6 +156,9 @@ pub enum SecretError {
#[error("Secret access denied for tool")]
AccessDenied,
#[error("Keychain error: {0}")]
KeychainError(String),
}
/// Parameters for creating a new secret.
+651 -8
View File
@@ -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<String>,
/// Whether setup wizard has been completed.
#[serde(default)]
pub setup_completed: bool,
/// Tunnel configuration for exposing the agent to the internet.
// === Step 1: Database ===
/// Database connection URL (postgres://...).
#[serde(default)]
pub database_url: Option<String>,
/// Database pool size.
#[serde(default)]
pub database_pool_size: Option<usize>,
// === Step 2: Security ===
/// Source for the secrets master key.
#[serde(default)]
pub secrets_master_key_source: KeySource,
// === Step 3: NEAR AI Auth ===
// Session stored separately in session.json
// === Step 4: Model Selection ===
/// Currently selected model.
#[serde(default)]
pub selected_model: Option<String>,
// === Step 5: Embeddings ===
/// Embeddings configuration.
#[serde(default)]
pub embeddings: EmbeddingsSettings,
// === Step 6: Channels ===
/// Tunnel configuration for public webhook endpoints.
#[serde(default)]
pub tunnel: TunnelSettings,
/// Channel configuration.
#[serde(default)]
pub channels: ChannelSettings,
// === Step 7: Heartbeat ===
/// Heartbeat configuration.
#[serde(default)]
pub heartbeat: HeartbeatSettings,
// === Advanced Settings (not asked during setup, editable via CLI) ===
/// Agent behavior configuration.
#[serde(default)]
pub agent: AgentSettings,
/// WASM sandbox configuration.
#[serde(default)]
pub wasm: WasmSettings,
/// Docker sandbox configuration.
#[serde(default)]
pub sandbox: SandboxSettings,
/// Safety configuration.
#[serde(default)]
pub safety: SafetySettings,
/// Builder configuration.
#[serde(default)]
pub builder: BuilderSettings,
}
/// Source for the secrets master key.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum KeySource {
/// Auto-generated key stored in OS keychain.
Keychain,
/// User provides via SECRETS_MASTER_KEY env var.
Env,
/// Not configured (secrets features disabled).
#[default]
None,
}
/// Embeddings configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingsSettings {
/// Whether embeddings are enabled.
#[serde(default)]
pub enabled: bool,
/// Provider to use: "openai" or "nearai".
#[serde(default = "default_embeddings_provider")]
pub provider: String,
/// Model to use for embeddings.
#[serde(default = "default_embeddings_model")]
pub model: String,
}
fn default_embeddings_provider() -> String {
"nearai".to_string()
}
fn default_embeddings_model() -> String {
"text-embedding-3-small".to_string()
}
impl Default for EmbeddingsSettings {
fn default() -> Self {
Self {
enabled: false,
provider: default_embeddings_provider(),
model: default_embeddings_model(),
}
}
}
/// Tunnel settings for public webhook endpoints.
@@ -47,19 +145,338 @@ pub struct ChannelSettings {
#[serde(default)]
pub http_port: Option<u16>,
/// HTTP webhook host.
#[serde(default)]
pub http_host: Option<String>,
/// Enabled WASM channels by name.
/// Channels not in this list but present in the channels directory will still load.
/// This is primarily used by the setup wizard to track which channels were configured.
#[serde(default)]
pub wasm_channels: Vec<String>,
/// Whether WASM channels are enabled.
#[serde(default = "default_true")]
pub wasm_channels_enabled: bool,
/// Directory containing WASM channel modules.
#[serde(default)]
pub wasm_channels_dir: Option<PathBuf>,
}
/// Heartbeat configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeartbeatSettings {
/// Whether heartbeat is enabled.
#[serde(default)]
pub enabled: bool,
/// Interval between heartbeat checks in seconds.
#[serde(default = "default_heartbeat_interval")]
pub interval_secs: u64,
/// Channel to notify on heartbeat findings.
#[serde(default)]
pub notify_channel: Option<String>,
/// User ID to notify on heartbeat findings.
#[serde(default)]
pub notify_user: Option<String>,
}
fn default_heartbeat_interval() -> u64 {
1800 // 30 minutes
}
impl Default for HeartbeatSettings {
fn default() -> Self {
Self {
enabled: false,
interval_secs: default_heartbeat_interval(),
notify_channel: None,
notify_user: None,
}
}
}
/// Agent behavior configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSettings {
/// Agent name.
#[serde(default = "default_agent_name")]
pub name: String,
/// Maximum parallel jobs.
#[serde(default = "default_max_parallel_jobs")]
pub max_parallel_jobs: u32,
/// Job timeout in seconds.
#[serde(default = "default_job_timeout")]
pub job_timeout_secs: u64,
/// Stuck job threshold in seconds.
#[serde(default = "default_stuck_threshold")]
pub stuck_threshold_secs: u64,
/// Whether to use planning before tool execution.
#[serde(default = "default_true")]
pub use_planning: bool,
/// Self-repair check interval in seconds.
#[serde(default = "default_repair_interval")]
pub repair_check_interval_secs: u64,
/// Maximum repair attempts.
#[serde(default = "default_max_repair_attempts")]
pub max_repair_attempts: u32,
}
fn default_agent_name() -> String {
"ironclaw".to_string()
}
fn default_max_parallel_jobs() -> u32 {
5
}
fn default_job_timeout() -> u64 {
3600 // 1 hour
}
fn default_stuck_threshold() -> u64 {
300 // 5 minutes
}
fn default_repair_interval() -> u64 {
60 // 1 minute
}
fn default_max_repair_attempts() -> u32 {
3
}
fn default_true() -> bool {
true
}
impl Default for AgentSettings {
fn default() -> Self {
Self {
name: default_agent_name(),
max_parallel_jobs: default_max_parallel_jobs(),
job_timeout_secs: default_job_timeout(),
stuck_threshold_secs: default_stuck_threshold(),
use_planning: true,
repair_check_interval_secs: default_repair_interval(),
max_repair_attempts: default_max_repair_attempts(),
}
}
}
/// WASM sandbox configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WasmSettings {
/// Whether WASM tool execution is enabled.
#[serde(default = "default_true")]
pub enabled: bool,
/// Directory containing installed WASM tools.
#[serde(default)]
pub tools_dir: Option<PathBuf>,
/// Default memory limit in bytes.
#[serde(default = "default_wasm_memory_limit")]
pub default_memory_limit: u64,
/// Default execution timeout in seconds.
#[serde(default = "default_wasm_timeout")]
pub default_timeout_secs: u64,
/// Default fuel limit for CPU metering.
#[serde(default = "default_wasm_fuel_limit")]
pub default_fuel_limit: u64,
/// Whether to cache compiled modules.
#[serde(default = "default_true")]
pub cache_compiled: bool,
/// Directory for compiled module cache.
#[serde(default)]
pub cache_dir: Option<PathBuf>,
}
fn default_wasm_memory_limit() -> u64 {
10 * 1024 * 1024 // 10 MB
}
fn default_wasm_timeout() -> u64 {
60
}
fn default_wasm_fuel_limit() -> u64 {
10_000_000
}
impl Default for WasmSettings {
fn default() -> Self {
Self {
enabled: true,
tools_dir: None,
default_memory_limit: default_wasm_memory_limit(),
default_timeout_secs: default_wasm_timeout(),
default_fuel_limit: default_wasm_fuel_limit(),
cache_compiled: true,
cache_dir: None,
}
}
}
/// Docker sandbox configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxSettings {
/// Whether the Docker sandbox is enabled.
#[serde(default = "default_true")]
pub enabled: bool,
/// Sandbox policy: "readonly", "workspace_write", or "full_access".
#[serde(default = "default_sandbox_policy")]
pub policy: String,
/// Command timeout in seconds.
#[serde(default = "default_sandbox_timeout")]
pub timeout_secs: u64,
/// Memory limit in megabytes.
#[serde(default = "default_sandbox_memory")]
pub memory_limit_mb: u64,
/// CPU shares (relative weight).
#[serde(default = "default_sandbox_cpu_shares")]
pub cpu_shares: u32,
/// Docker image for the sandbox.
#[serde(default = "default_sandbox_image")]
pub image: String,
/// Whether to auto-pull the image if not found.
#[serde(default = "default_true")]
pub auto_pull_image: bool,
/// Additional domains to allow through the network proxy.
#[serde(default)]
pub extra_allowed_domains: Vec<String>,
}
fn default_sandbox_policy() -> String {
"readonly".to_string()
}
fn default_sandbox_timeout() -> u64 {
120
}
fn default_sandbox_memory() -> u64 {
2048
}
fn default_sandbox_cpu_shares() -> u32 {
1024
}
fn default_sandbox_image() -> String {
"ghcr.io/nearai/sandbox:latest".to_string()
}
impl Default for SandboxSettings {
fn default() -> Self {
Self {
enabled: true,
policy: default_sandbox_policy(),
timeout_secs: default_sandbox_timeout(),
memory_limit_mb: default_sandbox_memory(),
cpu_shares: default_sandbox_cpu_shares(),
image: default_sandbox_image(),
auto_pull_image: true,
extra_allowed_domains: Vec::new(),
}
}
}
/// Safety configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SafetySettings {
/// Maximum output length in bytes.
#[serde(default = "default_max_output_length")]
pub max_output_length: usize,
/// Whether injection check is enabled.
#[serde(default = "default_true")]
pub injection_check_enabled: bool,
}
fn default_max_output_length() -> usize {
100_000
}
impl Default for SafetySettings {
fn default() -> Self {
Self {
max_output_length: default_max_output_length(),
injection_check_enabled: true,
}
}
}
/// Builder configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuilderSettings {
/// Whether the software builder tool is enabled.
#[serde(default = "default_true")]
pub enabled: bool,
/// Directory for build artifacts.
#[serde(default)]
pub build_dir: Option<PathBuf>,
/// Maximum iterations for the build loop.
#[serde(default = "default_builder_max_iterations")]
pub max_iterations: u32,
/// Build timeout in seconds.
#[serde(default = "default_builder_timeout")]
pub timeout_secs: u64,
/// Whether to automatically register built WASM tools.
#[serde(default = "default_true")]
pub auto_register: bool,
}
fn default_builder_max_iterations() -> u32 {
20
}
fn default_builder_timeout() -> u64 {
600
}
impl Default for BuilderSettings {
fn default() -> Self {
Self {
enabled: true,
build_dir: None,
max_iterations: default_builder_max_iterations(),
timeout_secs: default_builder_timeout(),
auto_register: true,
}
}
}
impl Settings {
/// 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<String> {
let json = serde_json::to_value(self).ok()?;
let mut current = &json;
for part in path.split('.') {
current = current.get(part)?;
}
match current {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
serde_json::Value::Null => Some("null".to_string()),
serde_json::Value::Array(arr) => Some(serde_json::to_string(arr).unwrap_or_default()),
serde_json::Value::Object(obj) => Some(serde_json::to_string(obj).unwrap_or_default()),
}
}
/// Set a setting value by dotted path.
///
/// Returns error if path is invalid or value cannot be parsed.
pub fn set(&mut self, path: &str, value: &str) -> Result<(), String> {
let mut json = serde_json::to_value(&self)
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
let parts: Vec<&str> = path.split('.').collect();
if parts.is_empty() {
return Err("Empty path".to_string());
}
// Navigate to parent and set the final key
let mut current = &mut json;
for part in &parts[..parts.len() - 1] {
current = current
.get_mut(*part)
.ok_or_else(|| format!("Path not found: {}", path))?;
}
let final_key = parts.last().unwrap();
let obj = current
.as_object_mut()
.ok_or_else(|| format!("Parent is not an object: {}", path))?;
// Try to infer the type from the existing value
let new_value = if let Some(existing) = obj.get(*final_key) {
match existing {
serde_json::Value::Bool(_) => {
let b = value
.parse::<bool>()
.map_err(|_| format!("Expected boolean for {}, got '{}'", path, value))?;
serde_json::Value::Bool(b)
}
serde_json::Value::Number(n) => {
if n.is_u64() {
let n = value.parse::<u64>().map_err(|_| {
format!("Expected integer for {}, got '{}'", path, value)
})?;
serde_json::Value::Number(n.into())
} else if n.is_i64() {
let n = value.parse::<i64>().map_err(|_| {
format!("Expected integer for {}, got '{}'", path, value)
})?;
serde_json::Value::Number(n.into())
} else {
let n = value.parse::<f64>().map_err(|_| {
format!("Expected number for {}, got '{}'", path, value)
})?;
serde_json::Number::from_f64(n)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::String(value.to_string()))
}
}
serde_json::Value::Null => {
// Could be Option<T>, try to parse as JSON or use string
serde_json::from_str(value)
.unwrap_or(serde_json::Value::String(value.to_string()))
}
serde_json::Value::Array(_) => serde_json::from_str(value)
.map_err(|e| format!("Invalid JSON array for {}: {}", path, e))?,
serde_json::Value::Object(_) => serde_json::from_str(value)
.map_err(|e| format!("Invalid JSON object for {}: {}", path, e))?,
serde_json::Value::String(_) => serde_json::Value::String(value.to_string()),
}
} else {
// Key doesn't exist, try to parse as JSON or use string
serde_json::from_str(value).unwrap_or(serde_json::Value::String(value.to_string()))
};
obj.insert((*final_key).to_string(), new_value);
// Deserialize back to Settings
*self =
serde_json::from_value(json).map_err(|e| format!("Failed to apply setting: {}", e))?;
Ok(())
}
/// Reset a setting to its default value.
pub fn reset(&mut self, path: &str) -> Result<(), String> {
let default = Self::default();
let default_value = default
.get(path)
.ok_or_else(|| format!("Unknown setting: {}", path))?;
self.set(path, &default_value)
}
/// List all settings as (path, value) pairs.
pub fn list(&self) -> Vec<(String, String)> {
let json = match serde_json::to_value(self) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let mut results = Vec::new();
collect_settings(&json, String::new(), &mut results);
results.sort_by(|a, b| a.0.cmp(&b.0));
results
}
}
/// Recursively collect settings paths and values.
fn collect_settings(
value: &serde_json::Value,
prefix: String,
results: &mut Vec<(String, String)>,
) {
match value {
serde_json::Value::Object(obj) => {
for (key, val) in obj {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
collect_settings(val, path, results);
}
}
serde_json::Value::Array(arr) => {
let display = serde_json::to_string(arr).unwrap_or_default();
results.push((prefix, display));
}
serde_json::Value::String(s) => {
results.push((prefix, s.clone()));
}
serde_json::Value::Number(n) => {
results.push((prefix, n.to_string()));
}
serde_json::Value::Bool(b) => {
results.push((prefix, b.to_string()));
}
serde_json::Value::Null => {
results.push((prefix, "null".to_string()));
}
}
}
#[cfg(test)]
@@ -146,4 +720,73 @@ mod tests {
};
assert_eq!(settings.model_or("default-model"), "my-model".to_string());
}
#[test]
fn test_get_setting() {
let settings = Settings::default();
assert_eq!(settings.get("agent.name"), Some("ironclaw".to_string()));
assert_eq!(
settings.get("agent.max_parallel_jobs"),
Some("5".to_string())
);
assert_eq!(settings.get("heartbeat.enabled"), Some("false".to_string()));
assert_eq!(settings.get("nonexistent"), None);
}
#[test]
fn test_set_setting() {
let mut settings = Settings::default();
settings.set("agent.name", "mybot").unwrap();
assert_eq!(settings.agent.name, "mybot");
settings.set("agent.max_parallel_jobs", "10").unwrap();
assert_eq!(settings.agent.max_parallel_jobs, 10);
settings.set("heartbeat.enabled", "true").unwrap();
assert!(settings.heartbeat.enabled);
}
#[test]
fn test_reset_setting() {
let mut settings = Settings::default();
settings.agent.name = "custom".to_string();
settings.reset("agent.name").unwrap();
assert_eq!(settings.agent.name, "ironclaw");
}
#[test]
fn test_list_settings() {
let settings = Settings::default();
let list = settings.list();
// Check some expected entries
assert!(list.iter().any(|(k, _)| k == "agent.name"));
assert!(list.iter().any(|(k, _)| k == "heartbeat.enabled"));
assert!(list.iter().any(|(k, _)| k == "setup_completed"));
}
#[test]
fn test_key_source_serialization() {
let settings = Settings {
secrets_master_key_source: KeySource::Keychain,
..Default::default()
};
let json = serde_json::to_string(&settings).unwrap();
assert!(json.contains("\"keychain\""));
let loaded: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.secrets_master_key_source, KeySource::Keychain);
}
#[test]
fn test_embeddings_defaults() {
let settings = Settings::default();
assert!(!settings.embeddings.enabled);
assert_eq!(settings.embeddings.provider, "nearai");
assert_eq!(settings.embeddings.model, "text-embedding-3-small");
}
}
+13 -6
View File
@@ -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};
+1 -1
View File
@@ -259,7 +259,7 @@ pub fn confirm(prompt: &str, default: bool) -> io::Result<bool> {
/// # Example
///
/// ```ignore
/// print_header("NEAR Agent Setup Wizard");
/// print_header("IronClaw Setup Wizard");
/// ```
pub fn print_header(text: &str) {
let width = text.len() + 4;
+476 -102
View File
@@ -1,9 +1,13 @@
//! Main setup wizard orchestration.
//!
//! The wizard guides users through:
//! 1. NEAR AI authentication
//! 2. Model selection
//! 3. Channel configuration
//! 1. Database connection
//! 2. Security (secrets master key)
//! 3. NEAR AI authentication
//! 4. Model selection
//! 5. Embeddings
//! 6. Channel configuration
//! 7. Heartbeat (background tasks)
use std::sync::Arc;
@@ -14,12 +18,13 @@ use tokio_postgres::NoTls;
use crate::channels::wasm::ChannelCapabilitiesFile;
use crate::llm::{SessionConfig, SessionManager};
use crate::secrets::SecretsCrypto;
use crate::settings::Settings;
use crate::settings::{KeySource, Settings};
use crate::setup::channels::{
SecretsContext, setup_http, setup_telegram, setup_tunnel, setup_wasm_channel,
};
use crate::setup::prompts::{
input, print_header, print_info, print_step, print_success, select_many, select_one,
confirm, input, optional_input, print_error, print_header, print_info, print_step,
print_success, select_many, select_one,
};
/// Setup wizard error.
@@ -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<Arc<SessionManager>>,
/// Database pool (created during setup).
db_pool: Option<deadpool_postgres::Pool>,
/// Secrets crypto (created during setup).
secrets_crypto: Option<Arc<SecretsCrypto>>,
}
impl SetupWizard {
@@ -67,6 +76,8 @@ impl SetupWizard {
config: SetupConfig::default(),
settings: Settings::load(),
session_manager: None,
db_pool: None,
secrets_crypto: None,
}
}
@@ -76,6 +87,8 @@ impl SetupWizard {
config,
settings: Settings::load(),
session_manager: None,
db_pool: None,
secrets_crypto: None,
}
}
@@ -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<SessionManager>) -> Vec<String> {
// Create a temporary LLM provider to fetch models
use crate::config::LlmConfig;
use crate::llm::create_llm_provider;
// Read base URL from env, fallback to cloud-api.near.ai
let base_url = std::env::var("NEARAI_BASE_URL")
.unwrap_or_else(|_| "https://cloud-api.near.ai".to_string());
let auth_base_url = std::env::var("NEARAI_AUTH_URL")
@@ -232,7 +451,7 @@ impl SetupWizard {
let config = LlmConfig {
nearai: crate::config::NearAiConfig {
model: "dummy".to_string(), // Not used for listing
model: "dummy".to_string(),
base_url,
auth_base_url,
session_path: crate::llm::session::default_session_path(),
@@ -259,65 +478,91 @@ impl SetupWizard {
}
}
/// Initialize secrets context for channel setup.
async fn init_secrets_context(&self) -> Result<SecretsContext, SetupError> {
// Get DATABASE_URL
let database_url = std::env::var("DATABASE_URL").map_err(|_| {
SetupError::Config(
"DATABASE_URL not set. Please set it in .env or environment.".to_string(),
)
})?;
/// Step 5: Embeddings configuration.
fn step_embeddings(&mut self) -> Result<(), SetupError> {
print_info("Embeddings enable semantic search in your workspace memory.");
println!();
// Get or generate SECRETS_MASTER_KEY
let master_key = match std::env::var("SECRETS_MASTER_KEY") {
Ok(key) => {
if key.len() < 32 {
return Err(SetupError::Config(
"SECRETS_MASTER_KEY must be at least 32 characters".to_string(),
));
if !confirm("Enable semantic search?", true).map_err(SetupError::Io)? {
self.settings.embeddings.enabled = false;
print_info("Embeddings disabled. Workspace will use keyword search only.");
return Ok(());
}
let options = [
"NEAR AI (uses same auth, no extra cost)",
"OpenAI (requires API key)",
];
let choice = select_one("Select embeddings provider:", &options).map_err(SetupError::Io)?;
match choice {
0 => {
self.settings.embeddings.enabled = true;
self.settings.embeddings.provider = "nearai".to_string();
self.settings.embeddings.model = "text-embedding-3-small".to_string();
print_success("Embeddings enabled via NEAR AI");
}
1 => {
// Check if API key is set
if std::env::var("OPENAI_API_KEY").is_err() {
print_info("OPENAI_API_KEY not set in environment.");
print_info("Add it to your .env file or environment to enable embeddings.");
}
key
self.settings.embeddings.enabled = true;
self.settings.embeddings.provider = "openai".to_string();
self.settings.embeddings.model = "text-embedding-3-small".to_string();
print_success("Embeddings configured for OpenAI");
}
Err(_) => {
// Generate a new master key
print_info("SECRETS_MASTER_KEY not set. Generating a new one...");
let key = generate_master_key();
print_info(&format!(
"Generated master key. Add to your .env file:\nSECRETS_MASTER_KEY={}",
key
));
key
}
};
_ => unreachable!(),
}
// Create database pool
let mut cfg = PoolConfig::new();
cfg.url = Some(database_url);
cfg.pool = Some(deadpool_postgres::PoolConfig {
max_size: 5,
..Default::default()
});
let pool = cfg
.create_pool(Some(Runtime::Tokio1), NoTls)
.map_err(|e| SetupError::Database(format!("Failed to create pool: {}", e)))?;
// Test connection
let _ = pool
.get()
.await
.map_err(|e| SetupError::Database(format!("Failed to connect to database: {}", e)))?;
print_success("Connected to database");
// Create crypto
let crypto = SecretsCrypto::new(SecretString::from(master_key))
.map_err(|e| SetupError::Config(format!("Invalid master key: {}", e)))?;
Ok(SecretsContext::new(pool, Arc::new(crypto), "default"))
Ok(())
}
/// Step 3: Channel configuration.
/// Initialize secrets context for channel setup.
async fn init_secrets_context(&mut self) -> Result<SecretsContext, SetupError> {
// Get database pool (should be set from step 1)
let pool = if let Some(ref p) = self.db_pool {
p.clone()
} else {
// Fall back to creating one from settings/env
let url = self
.settings
.database_url
.clone()
.or_else(|| std::env::var("DATABASE_URL").ok())
.ok_or_else(|| SetupError::Config("Database URL not configured".to_string()))?;
self.test_database_connection(&url).await?;
self.db_pool.clone().unwrap()
};
// Get crypto (should be set from step 2, or load from keychain/env)
let crypto = if let Some(ref c) = self.secrets_crypto {
Arc::clone(c)
} else {
// Try to load master key from keychain or env
let key = if let Ok(env_key) = std::env::var("SECRETS_MASTER_KEY") {
env_key
} else if let Ok(keychain_key) = crate::secrets::keychain::get_master_key() {
keychain_key.iter().map(|b| format!("{:02x}", b)).collect()
} else {
return Err(SetupError::Config(
"Secrets not configured. Run full setup or set SECRETS_MASTER_KEY.".to_string(),
));
};
let crypto = SecretsCrypto::new(SecretString::from(key))
.map_err(|e| SetupError::Config(e.to_string()))?;
self.secrets_crypto = Some(Arc::new(crypto));
Arc::clone(self.secrets_crypto.as_ref().unwrap())
};
Ok(SecretsContext::new(pool, crypto, "default"))
}
/// Step 6: Channel configuration.
async fn step_channels(&mut self) -> Result<(), SetupError> {
// First, configure tunnel (shared across all channels that need webhooks)
match setup_tunnel() {
@@ -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::<u64>() {
self.settings.heartbeat.interval_secs = mins * 60;
}
} else {
self.settings.heartbeat.interval_secs = 1800; // 30 minutes
}
// Notify channel
let notify_channel = optional_input("Notify channel on findings", Some("e.g., telegram"))
.map_err(SetupError::Io)?;
self.settings.heartbeat.notify_channel = notify_channel;
print_success(&format!(
"Heartbeat enabled (every {} minutes)",
self.settings.heartbeat.interval_secs / 60
));
Ok(())
}
/// Save settings and print summary.
fn save_and_summarize(&mut self) -> Result<(), SetupError> {
self.settings.setup_completed = true;
@@ -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 <setting> <value>");
println!(" ironclaw setup");
println!();
Ok(())
}
}
/// Generate a random 32-byte master key as hex string.
fn generate_master_key() -> String {
use rand::RngCore;
let mut rng = rand::thread_rng();
let mut bytes = [0u8; 32];
rng.fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
impl Default for SetupWizard {
fn default() -> Self {
Self::new()
}
}
/// Mask password in a database URL for display.
fn mask_password_in_url(url: &str) -> String {
// URL format: scheme://user:password@host/database
// Find "://" to locate start of credentials
let Some(scheme_end) = url.find("://") else {
return url.to_string();
};
let credentials_start = scheme_end + 3; // After "://"
// Find "@" to locate end of credentials
let Some(at_pos) = url[credentials_start..].find('@') else {
return url.to_string();
};
let at_abs = credentials_start + at_pos;
// Find ":" in the credentials section (separates user from password)
let credentials = &url[credentials_start..at_abs];
let Some(colon_pos) = credentials.find(':') else {
return url.to_string();
};
// Build masked URL: scheme://user:****@host/database
let scheme = &url[..credentials_start]; // "postgres://"
let username = &credentials[..colon_pos]; // "user"
let after_at = &url[at_abs..]; // "@localhost/db"
format!("{}{}:****{}", scheme, username, after_at)
}
/// Discover WASM channels in a directory.
///
/// Returns a list of (channel_name, capabilities_file) pairs.
@@ -595,8 +954,23 @@ mod tests {
}
#[test]
fn test_generate_master_key() {
let key = generate_master_key();
assert_eq!(key.len(), 64); // 32 bytes = 64 hex chars
fn test_mask_password_in_url() {
assert_eq!(
mask_password_in_url("postgres://user:secret@localhost/db"),
"postgres://user:****@localhost/db"
);
// URL without password
assert_eq!(
mask_password_in_url("postgres://localhost/db"),
"postgres://localhost/db"
);
}
#[test]
fn test_capitalize_first() {
assert_eq!(capitalize_first("telegram"), "Telegram");
assert_eq!(capitalize_first("CAPS"), "CAPS");
assert_eq!(capitalize_first(""), "");
}
}
+53 -11
View File
@@ -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<String> = 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(),
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+7 -7
View File
@@ -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() {
+3 -3
View File
@@ -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");