mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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_
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
//!
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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};
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user